diff --git a/docs/renderer.md b/docs/renderer.md index 3c2f02535..30c80605a 100644 --- a/docs/renderer.md +++ b/docs/renderer.md @@ -82,8 +82,22 @@ flushed from the render thread to the transfer queue - **SDL/swapchain path**: uses acquire/present semaphores and transitions to `ePresentSrcKHR` in `recordFinalize` - **Shared texture path (editor)**: no acquire/present semaphore dependency, transitions - color attachment to transfer source, copies to host-visible staging, and publishes the - latest completed frame for `toast_viewport_get_frame` + color attachment to transfer source and publishes only completed frames. Windows shares + BGRA textures with a keyed mutex; Linux blits into RGBA textures for Avalonia's OpenGL + importer and releases them in transfer-source layout, using ready/done semaphores. + On X11 the editor uses Avalonia's default GLX compositor. Linux shared images + use non-dedicated Vulkan allocations because Avalonia's OpenGL importer does + not mark imported memory objects as dedicated; mismatching those flags can + produce transparent-black frames. + CPU readback remains BGRA and runs only on request while sharing is active. + The editor reserves the exact generation, slot, and frame ID before importing a frame + into its drawing surface. In-flight, reserved, and displayed slots cannot be overwritten; + acquisition returns `eNotReady` when no slot is free. Dropped frames' ready signals are + consumed before a slot signals readiness again. Presentation starts from animation-frame + callbacks and can pick up a newer frame after an update completes via a low-priority + dispatcher job; this avoids waiting an extra animation tick without starving input. + Set `TOAST_VIEWPORT_READBACK=1` when starting the editor to bypass GPU sharing + and compare the CPU readback path while diagnosing display artifacts. ### Resizing diff --git a/editor/Assets/VoxelPaletteFile.cs b/editor/Assets/VoxelPaletteFile.cs index 5a0336768..37573d2b9 100644 --- a/editor/Assets/VoxelPaletteFile.cs +++ b/editor/Assets/VoxelPaletteFile.cs @@ -25,6 +25,12 @@ public sealed class VoxelPaletteEntry { public float Alpha { get; set; } = 1f; + /// Mirrors voxel::k_default_ior + public const float DefaultIor = 1.5f; + public const float MaxIor = 3.55f; + + public float Ior { get; set; } = DefaultIor; + public int Material { get; set; } /// Authored without a material which differs from material 0 @@ -40,6 +46,7 @@ public void Clear() { R = G = B = 0; Roughness = Metallic = Reflectivity = Emissive = 0f; Alpha = 1f; + Ior = DefaultIor; TransformsTo = 0; Transparent = false; } @@ -132,6 +139,7 @@ public static VoxelPaletteFile FromString(string toml) { target.Reflectivity = GetFloat(entry, "reflectivity", 0f); target.Emissive = GetFloat(entry, "emissive", 0f); target.Alpha = GetFloat(entry, "alpha", 1f); + target.Ior = GetFloat(entry, "ior", VoxelPaletteEntry.DefaultIor); target.HasMaterial = entry.ContainsKey("material"); target.Material = GetInt(entry, "material", 0); @@ -185,6 +193,8 @@ public string Serialize() { ["emissive"] = (double)Clamp01(entry.Emissive), ["alpha"] = (double)Clamp01(entry.Alpha) }; + if (Math.Abs(entry.Ior - VoxelPaletteEntry.DefaultIor) > 0.005f) + table["ior"] = Math.Round((double)Math.Clamp(entry.Ior, 1f, VoxelPaletteEntry.MaxIor), 2); if (entry.HasMaterial) table["material"] = (long)Math.Clamp(entry.Material, 0, SlotCount - 1); table["transforms_to"] = (long)Math.Clamp(entry.TransformsTo, 0, 255); table["transparent"] = entry.Transparent; diff --git a/editor/Editors/PaletteView.axaml b/editor/Editors/PaletteView.axaml index be2603e69..06be4842a 100644 --- a/editor/Editors/PaletteView.axaml +++ b/editor/Editors/PaletteView.axaml @@ -254,7 +254,7 @@ @@ -332,6 +332,16 @@ Value="{Binding Alpha, Mode=TwoWay}" Minimum="0" Maximum="1" /> + + + IsEngineReady && toast_debug_overlay_owns_cursor() != 0; + public string ProjectPath { get; } public string CorePath { get; } @@ -294,8 +297,8 @@ public bool ExportSharedViewportSlot(ulong generation, uint slot, out ToastViewp return IsEngineReady && toast_viewport_export_shared_slot(generation, slot, out handles) == 1; } - public bool HoldSharedViewportSlot(ulong generation, uint slot) => - IsEngineReady && toast_viewport_hold_shared_slot(generation, slot) == 1; + public bool HoldSharedViewportSlot(ulong generation, uint slot, ulong frameId) => + IsEngineReady && toast_viewport_hold_shared_slot(generation, slot, frameId) == 1; public void ReleaseSharedViewportSlot(ulong generation, uint slot, bool consumed) { if (IsEngineReady) toast_viewport_release_shared_slot(generation, slot, consumed ? 1 : 0); @@ -553,7 +556,7 @@ private static partial int toast_viewport_export_shared_slot( ulong generation, uint slot, out ToastViewportSharedHandles outHandles); [LibraryImport(EngineLib)] - private static partial int toast_viewport_hold_shared_slot(ulong generation, uint slot); + private static partial int toast_viewport_hold_shared_slot(ulong generation, uint slot, ulong frameId); [LibraryImport(EngineLib)] private static partial void toast_viewport_release_shared_slot(ulong generation, uint slot, int consumed); @@ -600,6 +603,9 @@ public static void ReloadManifest() { [LibraryImport(EngineLib)] private static partial void toast_set_show_debug_windows(int visible); + [LibraryImport(EngineLib)] + private static partial int toast_debug_overlay_owns_cursor(); + public static void ReloadProjectSettings() { if (IsEngineReady) OnTickThread(toast_reload_project_settings); } diff --git a/editor/Program.cs b/editor/Program.cs index 4589c2fee..c5a79b2fd 100644 --- a/editor/Program.cs +++ b/editor/Program.cs @@ -28,17 +28,26 @@ public static AppBuilder BuildAvaloniaApp() { #endif .WithInterFont(); + // The DXGI swap chain ticks on the vblank of the fastest monitor where WinUI commits can lag the refresh + var win32 = new Win32PlatformOptions { + CompositionMode = [ + Win32CompositionMode.LowLatencyDxgiSwapChain, + Win32CompositionMode.WinUIComposition, + Win32CompositionMode.DirectComposition, + Win32CompositionMode.RedirectionSurface + ] + }; + // Force software when renderdoc if (GraphicsDebuggerDetector.IsAttached) { - builder = builder - .With(new Win32PlatformOptions { - RenderingMode = [ Win32RenderingMode.Software ] - }) - .With(new X11PlatformOptions { - RenderingMode = [ X11RenderingMode.Software ] - }); + win32.RenderingMode = [ Win32RenderingMode.Software ]; + builder = builder.With(new X11PlatformOptions { + RenderingMode = [ X11RenderingMode.Software ] + }); } + builder = builder.With(win32); + return builder.LogToTrace(); } } diff --git a/editor/VoxelEditor/VoxelPalettePicker.cs b/editor/VoxelEditor/VoxelPalettePicker.cs index aa6a9d6eb..58ceaad0e 100644 --- a/editor/VoxelEditor/VoxelPalettePicker.cs +++ b/editor/VoxelEditor/VoxelPalettePicker.cs @@ -88,6 +88,11 @@ public float Alpha { set => Set(() => m_entry.Alpha = value); } + public float Ior { + get => m_entry.Ior; + set => Set(() => m_entry.Ior = Math.Clamp(value, 1f, VoxelPaletteEntry.MaxIor)); + } + private static byte ToByte(float value) { return (byte)Math.Clamp((int)Math.Round(value * 255f), 0, 255); } @@ -326,6 +331,10 @@ private Control BuildPopup() { transparent.Bind(BoolBox.ValueProperty, new Binding(nameof(PaletteEntryVM.Transparent)) { Mode = BindingMode.TwoWay }); properties.Children.Add(Row("Transparent", transparent)); properties.Children.Add(Row("Alpha", Number(nameof(PaletteEntryVM.Alpha)))); + var ior = Number(nameof(PaletteEntryVM.Ior), false); + ior.Minimum = 1; + ior.Maximum = VoxelPaletteEntry.MaxIor; + properties.Children.Add(Row("IOR", ior)); } var content = new StackPanel { diff --git a/editor/Workspace/SharedViewportPresenter.cs b/editor/Workspace/SharedViewportPresenter.cs index 21ec52396..d7d578ecf 100644 --- a/editor/Workspace/SharedViewportPresenter.cs +++ b/editor/Workspace/SharedViewportPresenter.cs @@ -5,6 +5,7 @@ using Avalonia; using Avalonia.Platform; using Avalonia.Rendering.Composition; +using Avalonia.Threading; using editor.Engine; namespace editor.Workspace; @@ -12,6 +13,8 @@ namespace editor.Workspace; internal sealed class SharedViewportPresenter : IDisposable { private readonly Visual m_host; private readonly ToastEngine m_engine; + private readonly Action m_onPresented; + private readonly Func m_canPresent; private Compositor? m_compositor; private ICompositionGpuInterop? m_interop; @@ -33,9 +36,11 @@ private sealed class Slot { public ICompositionImportedGpuSemaphore? Done; } - public SharedViewportPresenter(Visual host, ToastEngine engine) { + public SharedViewportPresenter(Visual host, ToastEngine engine, Action onPresented, Func canPresent) { m_host = host; m_engine = engine; + m_onPresented = onPresented; + m_canPresent = canPresent; } public bool Failed { get; private set; } @@ -107,6 +112,11 @@ public bool Tick() { } Layout(); + return TryPresentLatest(); + } + + private bool TryPresentLatest() { + if (!Active || m_surface is null || m_updating) return false; var result = m_engine.TryGetSharedViewportFrame(out var frame); if (result < 0) { @@ -118,7 +128,7 @@ public bool Tick() { if (frame.generation != m_generation && !Import(frame)) return false; var slot = m_slots[frame.slot]; - if (slot.Image is null || !m_engine.HoldSharedViewportSlot(m_generation, frame.slot)) return false; + if (slot.Image is null || !m_engine.HoldSharedViewportSlot(m_generation, frame.slot, frame.frame_id)) return false; m_lastFrameId = frame.frame_id; m_updating = true; @@ -141,6 +151,11 @@ private async Task PresentAsync(ulong generation, uint index, Slot slot) { } m_engine.ReleaseSharedViewportSlot(generation, index, true); m_updating = false; + // The compositor has finished the snapshot. Pick up a newer frame without + // waiting another animation tick, but let input run ahead of viewport work. + Dispatcher.UIThread.Post(() => { + if (Active && m_canPresent() && TryPresentLatest()) m_onPresented(); + }, DispatcherPriority.Background); } private bool Import(ToastViewportSharedFrame frame) { @@ -152,7 +167,8 @@ private bool Import(ToastViewportSharedFrame frame) { var properties = new PlatformGraphicsExternalImageProperties { Width = (int)frame.width, Height = (int)frame.height, - Format = PlatformGraphicsExternalImageFormat.B8G8R8A8UNorm, + Format = UsesKeyedMutex ? PlatformGraphicsExternalImageFormat.B8G8R8A8UNorm + : PlatformGraphicsExternalImageFormat.R8G8B8A8UNorm, MemorySize = frame.memory_size, TopLeftOrigin = true, }; @@ -180,8 +196,9 @@ private void Layout() { var scale = Math.Min(bounds.Width / m_width, bounds.Height / m_height); if (!double.IsFinite(scale) || scale <= 0) return; var size = new Vector2((float)(m_width * scale), (float)(m_height * scale)); - m_visual.Size = size; - m_visual.Offset = new Vector3((float)((bounds.Width - size.X) / 2), (float)((bounds.Height - size.Y) / 2), 0); + var offset = new Vector3((float)((bounds.Width - size.X) / 2), (float)((bounds.Height - size.Y) / 2), 0); + if (m_visual.Size != size) m_visual.Size = size; + if (m_visual.Offset != offset) m_visual.Offset = offset; } private void DisposeSlots() { diff --git a/editor/Workspace/ViewportControl.axaml.cs b/editor/Workspace/ViewportControl.axaml.cs index 57b2eb0c0..cd45a341d 100644 --- a/editor/Workspace/ViewportControl.axaml.cs +++ b/editor/Workspace/ViewportControl.axaml.cs @@ -30,6 +30,8 @@ public partial class ViewportControl : UserControl { private const double PanScale = 40.0; private const float PinchZoomScale = 100f; private const float WheelZoomScale = 20f; + private static readonly bool s_forceViewportReadback = + Environment.GetEnvironmentVariable("TOAST_VIEWPORT_READBACK") == "1"; public static readonly StyledProperty PlayModeProperty = AvaloniaProperty.Register(nameof(PlayMode)); @@ -54,6 +56,7 @@ public partial class ViewportControl : UserControl { private bool m_captured; private bool m_gameInput; + private bool m_overlayCursor; private readonly HashSet m_heldButtons = []; private readonly HashSet m_heldKeys = []; private bool m_mouseLockRequested; @@ -132,7 +135,8 @@ private bool OwnsEngineFrame { public static bool AnyEditorFlying => s_attached.Any(v => v.m_editorFlyActive); private bool GameOwnsInput => PlayMode && !CanControlEditorCamera; private bool ShouldForward => GameOwnsInput ? m_gameInput : IsFocused; - private bool ShouldForwardPointer => !GameOwnsInput || m_gameInput; + // The overlay needs the pointer and the input system drops what ImGui takes + private bool ShouldForwardPointer => !GameOwnsInput || m_gameInput || m_overlayCursor; private bool MouseLocked => m_captured && !m_editorFlyActive; protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { @@ -143,6 +147,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang ReleaseHeldInput(); m_gameInput = false; m_mouseLockRequested = false; + m_overlayCursor = false; UpdateCapture(); } else if (m_mouseLockRequested) { // the game locks the mouse as it starts @@ -179,12 +184,20 @@ private void OnMouseLock(WindowMouseLock e) { } private void UpdateCapture() { - var capture = m_editorFlyActive || (GameOwnsInput && m_gameInput && m_mouseLockRequested); + var capture = m_editorFlyActive || (GameOwnsInput && m_gameInput && m_mouseLockRequested && !m_overlayCursor); if (capture == m_captured) return; if (capture) BeginCapture(); else ReleaseCapture(); } + // The debug overlay frees the cursor under a game lock + private void SyncOverlayCursor() { + var owned = PlayMode && ToastEngine.DebugOverlayOwnsCursor; + if (owned == m_overlayCursor) return; + m_overlayCursor = owned; + UpdateCapture(); + } + private void BeginCapture() { Focus(); // hide the cursor over the whole window @@ -338,6 +351,8 @@ private void OnTopLevelPointerMoved(object? sender, PointerEventArgs e) { } private void OnTick(object? sender, EventArgs e) { + SyncOverlayCursor(); + // re-grab the pointer even while the mouse sits still if (m_captured && m_pointer is { } pointer && pointer.Captured != Surface) { pointer.Capture(Surface); @@ -373,11 +388,15 @@ private void OnTick(object? sender, EventArgs e) { SendResizeIfChanged(); - if (m_shared is null) { - m_shared = new SharedViewportPresenter(SurfaceHost, m_engine); + if (m_shared is null && !GraphicsDebuggerDetector.IsAttached && !s_forceViewportReadback) { + m_shared = new SharedViewportPresenter( + SurfaceHost, m_engine, + () => (DataContext as WorkspaceViewModel)?.NotePresentedFrame(), + () => m_frameLoopActive && IsEffectivelyVisible && OwnsEngineFrame + ); m_shared.Start(); } - if (!m_shared.Failed) { + if (m_shared is { Failed: false }) { if (!m_shared.Active) return; if (Surface.IsVisible) { Surface.IsVisible = false; @@ -389,9 +408,11 @@ private void OnTick(object? sender, EventArgs e) { } Surface.IsVisible = true; - var peek =m_engine.TryGetViewportFrame(IntPtr.Zero, 0, out var dims); + var peek = m_engine.TryGetViewportFrame(IntPtr.Zero, 0, out var dims); if (peek == 0 || dims.width == 0 || dims.height == 0) return; + if (m_bitmap is not null && dims.frame_id == m_lastFrameId) + return; if (m_bitmap is null || m_bitmap.PixelSize.Width != (int)dims.width @@ -718,7 +739,7 @@ protected override void OnPointerWheelChanged(PointerWheelEventArgs e) { return; } - if (!ShouldForward || m_engine is null) return; + if ((!ShouldForward && !m_overlayCursor) || m_engine is null) return; Events.Send(new WindowMouseScroll { X = (float)e.Delta.X, @@ -793,9 +814,12 @@ private void OnSwallowedAlt(object? sender, KeyEventArgs e) { SendKey(e, e.RoutedEvent == KeyDownEvent ? ActionPressed : ActionReleased); } + // F3 still reaches the engine after the backtick freed the mouse + private bool IsOverlayKey(Key key) => PlayMode && key == Key.F3; + private bool SendKey(KeyEventArgs e, int action) { var wasHeld = action == ActionReleased && m_heldKeys.Remove(e.Key); - if ((!wasHeld && !ShouldForward) || m_engine is null) return false; + if ((!wasHeld && !ShouldForward && !IsOverlayKey(e.Key)) || m_engine is null) return false; if (action == ActionPressed) m_heldKeys.Add(e.Key); var (key, _) = MapKey(e.Key); diff --git a/engine/assets/shaders/cluster_lighting.slang b/engine/assets/shaders/cluster_lighting.slang index 22e940980..c649e6868 100644 --- a/engine/assets/shaders/cluster_lighting.slang +++ b/engine/assets/shaders/cluster_lighting.slang @@ -18,6 +18,9 @@ struct GpuLight float4 coneAngles; // x cos outer y cos inner z shadow near w shadow far float4 shadowAtlas; // x fraction of the shadow layer rendered float4x4 shadowViewProjection; + /// y plus z bounds the emitter extent past the attenuation range + float4 shape; + float4 shapeTangent; }; struct ClusterAABB @@ -169,7 +172,7 @@ void lightCullMain(uint3 dispatchThreadID : SV_DispatchThreadID) for (uint lightIndex = 0; lightIndex < gParams.lightCounts.x; ++lightIndex) { float3 lightViewPos = gLights[lightIndex].viewPosType.xyz; - float radius = gLights[lightIndex].worldPosRange.w; + float radius = gLights[lightIndex].worldPosRange.w + gLights[lightIndex].shape.y + gLights[lightIndex].shape.z; if (sqDistPointAABB(lightViewPos, aabbMin, aabbMax) > (radius * radius)) { diff --git a/engine/assets/shaders/debris.slang b/engine/assets/shaders/debris.slang index bee6b3f70..018059570 100644 --- a/engine/assets/shaders/debris.slang +++ b/engine/assets/shaders/debris.slang @@ -3,7 +3,7 @@ struct Params { float3 gravity; float dt; - float lifetime; + float fadeIn; float restitution; float friction; float voxelSize; @@ -12,15 +12,15 @@ struct Params { uint volumeCount; uint substeps; uint clear; + uint retireCount; + float fadeOut; uint pad0; - uint pad1; - uint pad2; }; [[vk::push_constant]] Params gParams; -// most of this structs mirrors my stuff on the cpp +// Mirrors physics::DebrisSimulation Gpu structs struct DebrisParticle { float3 position; @@ -28,15 +28,19 @@ struct DebrisParticle { float4 rotation; float3 velocity; uint albedo; + float scale; + uint glass; + uint pad0; + uint pad1; }; struct DebrisMotion { float3 angular; float age; uint restSteps; + float lifetime; + float baseScale; uint pad0; - uint pad1; - uint pad2; }; struct DebrisSpawn { @@ -47,6 +51,10 @@ struct DebrisSpawn { uint albedo; float3 angular; uint packed; + float lifetime; + float scale; + uint glass; + uint pad0; }; struct DebrisVolume { @@ -72,6 +80,8 @@ RWStructuredBuffer gDrawArgs; StructuredBuffer gSpawns; [[vk::binding(6, 0)]] StructuredBuffer gVolumes; +[[vk::binding(7, 0)]] +StructuredBuffer gRetires; struct VolumeRecord { uint brickDimsX; @@ -94,19 +104,32 @@ StructuredBuffer gVolumeGrids; StructuredBuffer gVolumeRecords; static const uint kGroupSize = 64; +static const uint kMaxVolumes = 512; // mirrors DebrisSimulation::k_max_volumes +static const uint kMaxCandidates = 16; // volumes one piece collides with per step static const uint kBrickDim = 8; static const uint kTagEmpty = 0; static const uint kTagUniform = 1; static const uint kMaskBits = 0xFFu; static const uint kFlagAsleep = 1u << 24; +static const uint kFlagGlass = 1u << 25; +static const float kSpawnScale = 0.4; // grows from this share of its size -static const float kSleepSpeed = 0.2; // below this speed a pice is sleeping +static const float kSleepSpeed = 0.2; // below this speed a piece may sleep static const uint kSleepSteps = 15; static const float kBounceSpeed = 0.5; // slower impact sticks static const float kContactSlop = 0.001; // next step does not start touching +// Graphics queue patches sections in place unordered with this queue so new records can still be uninitialized headroom +// No robust buffer access so every index is checked against the buffer uint debrisMaterialAt(uint volume, int3 voxel) { + uint count = 0u; + uint stride = 0u; + gVolumeRecords.GetDimensions(count, stride); + if (volume >= count) { + return 0u; + } + VolumeRecord record = gVolumeRecords[volume]; if (any(voxel < 0) || voxel.x >= int(record.brickDimsX * kBrickDim) || voxel.y >= int(record.brickDimsY * kBrickDim) || voxel.z >= int(record.brickDimsZ * kBrickDim)) { @@ -114,7 +137,12 @@ uint debrisMaterialAt(uint volume, int3 voxel) { } uint3 brick = uint3(voxel) >> 3; - uint entry = gVolumeGrids[record.gridOffset + brick.x + brick.y * record.brickDimsX + brick.z * (record.brickDimsX * record.brickDimsY)]; + uint gridIndex = record.gridOffset + brick.x + brick.y * record.brickDimsX + brick.z * (record.brickDimsX * record.brickDimsY); + gVolumeGrids.GetDimensions(count, stride); + if (gridIndex >= count) { + return 0u; + } + uint entry = gVolumeGrids[gridIndex]; uint tag = entry & 3u; if (tag == kTagEmpty) { return 0u; @@ -126,7 +154,12 @@ uint debrisMaterialAt(uint volume, int3 voxel) { uint3 local = uint3(voxel) & 7u; uint index = (local.z << 6) | (local.y << 3) | local.x; - uint word = gBrickMaterials[((entry >> 2) << 7) + (index >> 2)]; + uint materialIndex = ((entry >> 2) << 7) + (index >> 2); + gBrickMaterials.GetDimensions(count, stride); + if (materialIndex >= count) { + return 0u; + } + uint word = gBrickMaterials[materialIndex]; return (word >> ((index & 3u) << 3)) & 0xFFu; } @@ -143,8 +176,15 @@ float4 quatMul(float4 a, float4 b) { return float4(a.w * b.xyz + b.w * a.xyz + cross(a.xyz, b.xyz), a.w * b.w - dot(a.xyz, b.xyz)); } -float3 bitOffset(uint bit) { - return (float3(float(bit & 1u), float((bit >> 1) & 1u), float(bit >> 2)) - 0.5) * gParams.voxelSize; +float3 bitOffset(uint bit, float scale) { + return (float3(float(bit & 1u), float((bit >> 1) & 1u), float(bit >> 2)) - 0.5) * (gParams.voxelSize * scale); +} + +// Grows in after spawning and shrinks away before dying +float displayScale(DebrisMotion motion) { + float grow = lerp(kSpawnScale, 1.0, saturate(motion.age / max(gParams.fadeIn, 1e-4))); + float shrink = smoothstep(0.0, max(gParams.fadeOut, 1e-4), motion.lifetime - motion.age); + return motion.baseScale * grow * shrink; } struct Contact { @@ -153,20 +193,47 @@ struct Contact { float depth; }; -Contact contactAt(float3 world) { - // deepest overlap of a point +// Bounds of every volume staged once per group since gVolumes lives in host visible memory +groupshared float4 sBounds[kMaxVolumes]; + +struct Candidates { + uint count; + uint index[kMaxCandidates]; +}; + +// Volumes whose bounds meet the sphere the piece sweeps this step so each point skips the rest +Candidates gatherCandidates(float3 centre, float radius) { + Candidates candidates; + candidates.count = 0u; + uint volumeCount = min(gParams.volumeCount, kMaxVolumes); + for (uint v = 0u; v < volumeCount && candidates.count < kMaxCandidates; ++v) { + float4 bounds = sBounds[v]; + float3 toCentre = centre - bounds.xyz; + float reach = bounds.w + radius; + if (dot(toCentre, toCentre) <= reach * reach) { + candidates.index[candidates.count] = v; + ++candidates.count; + } + } + return candidates; +} + +Contact contactAt(float3 world, Candidates candidates) { + // Deepest overlap of a point Contact contact; contact.hit = false; contact.normal = float3(0.0, 0.0, 1.0); contact.depth = 0.0; - for (uint v = 0; v < gParams.volumeCount; ++v) { - DebrisVolume volume = gVolumes[v]; - float3 toCentre = world - volume.bounds.xyz; - if (dot(toCentre, toCentre) > volume.bounds.w * volume.bounds.w) { + for (uint c = 0u; c < candidates.count; ++c) { + uint v = candidates.index[c]; + float4 bounds = sBounds[v]; + float3 toCentre = world - bounds.xyz; + if (dot(toCentre, toCentre) > bounds.w * bounds.w) { continue; } + DebrisVolume volume = gVolumes[v]; float3 local = mul(volume.worldToVoxel, float4(world, 1.0)).xyz; int3 voxel = int3(floor(local)); if (debrisMaterialAt(volume.recordIndex, voxel) == 0u) { @@ -193,7 +260,7 @@ Contact contactAt(float3 world) { float3 voxelNormal = float3(0.0, 0.0, 0.0); voxelNormal[axis] = side; - // same normal transform as voxel.slang shadeHit + // Same normal transform as voxel.slang shadeHit float3 normal = normalize(mul(transpose((float3x3)volume.worldToVoxel), voxelNormal)); float depth = depthVoxels * gParams.voxelSize; @@ -206,18 +273,20 @@ Contact contactAt(float3 world) { return contact; } -void appendVoxels(uint index, uint mask) { +// Opaque fills the list from the bottom with args 0 to 3 and glass from the top with args 4 to 7 +void appendVoxels(uint index, uint mask, bool glass) { uint count = countbits(mask); if (count == 0u) { return; } uint base = 0u; - InterlockedAdd(gDrawArgs[1], count, base); + InterlockedAdd(gDrawArgs[glass ? 5u : 1u], count, base); uint listCapacity = gParams.capacity * 8u; uint written = 0u; for (uint bit = 0u; bit < 8u; ++bit) { if (((mask >> bit) & 1u) != 0u && base + written < listCapacity) { - gDrawList[base + written] = (index << 3) | bit; + uint slot = glass ? listCapacity - 1u - (base + written) : base + written; + gDrawList[slot] = (index << 3) | bit; ++written; } } @@ -230,17 +299,43 @@ void resetMain(uint3 id : SV_DispatchThreadID) { gDrawArgs[1] = 0u; gDrawArgs[2] = 0u; gDrawArgs[3] = 0u; + gDrawArgs[4] = 36u; + gDrawArgs[5] = 0u; + gDrawArgs[6] = 0u; + gDrawArgs[7] = 0u; } +// Over the particle cap the oldest get only the fade left [shader("compute")] [numthreads(kGroupSize, 1, 1)] -void integrateMain(uint3 id : SV_DispatchThreadID) { +void retireMain(uint3 id : SV_DispatchThreadID) { + if (id.x >= gParams.retireCount) { + return; + } + uint slot = gRetires[id.x]; + if (slot >= gParams.capacity) { + return; + } + DebrisMotion motion = gMotion[slot]; + motion.lifetime = min(motion.lifetime, motion.age + gParams.fadeOut); + gMotion[slot] = motion; +} + +[shader("compute")] +[numthreads(kGroupSize, 1, 1)] +void integrateMain(uint3 id : SV_DispatchThreadID, uint3 groupThread : SV_GroupThreadID) { + // Before any early return so every thread reaches the barrier + uint volumeCount = min(gParams.volumeCount, kMaxVolumes); + for (uint v = groupThread.x; v < volumeCount; v += kGroupSize) { + sBounds[v] = gVolumes[v].bounds; + } + GroupMemoryBarrierWithGroupSync(); + uint i = id.x; if (i >= gParams.capacity) { return; } - // write every particle DebrisParticle particle = gPrevious[i]; uint mask = particle.packed & kMaskBits; if (mask == 0u || gParams.clear != 0u) { @@ -252,27 +347,34 @@ void integrateMain(uint3 id : SV_DispatchThreadID) { DebrisMotion motion = gMotion[i]; const float stepTime = gParams.dt * float(gParams.substeps); motion.age += stepTime; - if (motion.age > gParams.lifetime) { + if (motion.age > motion.lifetime) { particle.packed = 0u; gCurrent[i] = particle; gMotion[i] = motion; return; } + particle.scale = displayScale(motion); + const bool glass = (particle.packed & kFlagGlass) != 0u; + + // Farthest point plus the floor probe below it plus how far gravity and velocity carry it this step + float travel = (length(particle.velocity) + length(gParams.gravity) * stepTime) * stepTime; + float reach = 0.87 * gParams.voxelSize * motion.baseScale + 1.5 * gParams.voxelSize + travel; + Candidates candidates = gatherCandidates(particle.position, reach); if ((particle.packed & kFlagAsleep) != 0u) { - // wake up when no floor + // Wakes once nothing supports it bool supported = false; for (uint bit = 0u; bit < 8u && !supported; ++bit) { if (((mask >> bit) & 1u) != 0u) { - float3 below = particle.position + rotateByQuat(particle.rotation, bitOffset(bit)) - float3(0.0, 0.0, gParams.voxelSize); - supported = contactAt(below).hit; + float3 below = particle.position + rotateByQuat(particle.rotation, bitOffset(bit, motion.baseScale)) - float3(0.0, 0.0, gParams.voxelSize); + supported = contactAt(below, candidates).hit; } } if (supported) { gCurrent[i] = particle; gMotion[i] = motion; - appendVoxels(i, mask); + appendVoxels(i, mask, glass); return; } particle.packed &= ~kFlagAsleep; @@ -290,7 +392,7 @@ void integrateMain(uint3 id : SV_DispatchThreadID) { particle.rotation = normalize(particle.rotation + 0.5 * dt * quatMul(float4(motion.angular, 0.0), particle.rotation)); } - // oone response per substep + // One response per substep Contact deepest; deepest.hit = false; deepest.normal = float3(0.0, 0.0, 1.0); @@ -299,7 +401,7 @@ void integrateMain(uint3 id : SV_DispatchThreadID) { if (((mask >> bit) & 1u) == 0u) { continue; } - Contact contact = contactAt(particle.position + rotateByQuat(particle.rotation, bitOffset(bit))); + Contact contact = contactAt(particle.position + rotateByQuat(particle.rotation, bitOffset(bit, motion.baseScale)), candidates); if (contact.hit && (!deepest.hit || contact.depth > deepest.depth)) { deepest = contact; } @@ -333,7 +435,7 @@ void integrateMain(uint3 id : SV_DispatchThreadID) { gCurrent[i] = particle; gMotion[i] = motion; - appendVoxels(i, mask); + appendVoxels(i, mask, glass); } [shader("compute")] @@ -355,16 +457,21 @@ void spawnMain(uint3 id : SV_DispatchThreadID) { particle.rotation = spawn.rotation; particle.velocity = spawn.velocity; particle.albedo = spawn.albedo; - gCurrent[spawn.slot] = particle; + particle.glass = spawn.glass; + particle.pad0 = 0u; + particle.pad1 = 0u; DebrisMotion motion; motion.angular = spawn.angular; motion.age = 0.0; motion.restSteps = 0u; + motion.lifetime = spawn.lifetime; + motion.baseScale = spawn.scale; motion.pad0 = 0u; - motion.pad1 = 0u; - motion.pad2 = 0u; gMotion[spawn.slot] = motion; - appendVoxels(spawn.slot, spawn.packed & kMaskBits); + particle.scale = displayScale(motion); + gCurrent[spawn.slot] = particle; + + appendVoxels(spawn.slot, spawn.packed & kMaskBits, (spawn.packed & kFlagGlass) != 0u); } diff --git a/engine/assets/shaders/debris_draw.slang b/engine/assets/shaders/debris_draw.slang index 856af2761..86bcfb460 100644 --- a/engine/assets/shaders/debris_draw.slang +++ b/engine/assets/shaders/debris_draw.slang @@ -1,7 +1,7 @@ -// Draws debris into the voxel G-buffer so they are like every other voxel -// I think this works maybe dario should review it +// Opaque debris into the voxel G-buffer and glass debris blended forward like voxel glass import voxel_gbuffer; import lighting; +import glass_common; struct PushConstants { float extrapolation; @@ -13,12 +13,17 @@ struct PushConstants { [[vk::push_constant]] PushConstants gPush; +// Mirrors physics::DebrisSimulation::GpuParticle struct DebrisParticle { float3 position; uint packed; float4 rotation; float3 velocity; uint albedo; + float scale; + uint glass; + uint pad0; + uint pad1; }; [[vk::binding(0, 1)]] @@ -62,6 +67,22 @@ struct VSOutput { [[vk::location(4)]] nointerpolation uint packed : TEXCOORD4; + + [[vk::location(5)]] + nointerpolation uint glass : TEXCOORD5; + + /// Piece space in unscaled voxels where this voxel spans cellMin to cellMin + 1 + [[vk::location(6)]] + float3 boxLocal : TEXCOORD6; + + [[vk::location(7)]] + nointerpolation float3 cameraLocal : TEXCOORD7; + + [[vk::location(8)]] + nointerpolation float3 cellMin : TEXCOORD8; + + [[vk::location(9)]] + nointerpolation float scale : TEXCOORD9; }; float3 debrisRotate(float4 q, float3 v) { @@ -73,8 +94,7 @@ float3 debrisSrgbToLinear(float3 value) { return lerp(pow((value + 0.055) / 1.055, 2.4), value / 12.92, step(value, float3(0.04045, 0.04045, 0.04045))); } -[shader("vertex")] -VSOutput vertexMain(uint vertexId : SV_VertexID, uint instanceId : SV_InstanceID) { +VSOutput emptyVertex() { VSOutput output; output.position = float4(0.0, 0.0, 0.0, 1.0); output.worldPosition = float3(0.0, 0.0, 0.0); @@ -82,35 +102,71 @@ VSOutput vertexMain(uint vertexId : SV_VertexID, uint instanceId : SV_InstanceID output.normal = float3(0.0, 0.0, 1.0); output.albedo = 0u; output.packed = 0u; + output.glass = 0u; + output.boxLocal = float3(0.0, 0.0, 0.0); + output.cameraLocal = float3(0.0, 0.0, 0.0); + output.cellMin = float3(0.0, 0.0, 0.0); + output.scale = 0.0; + return output; +} - if (instanceId >= gPush.listCapacity) { - return output; - } - - uint entry = gDrawList[instanceId]; +VSOutput debrisVertex(uint entry, uint vertexId) { DebrisParticle particle = gParticles[entry >> 3]; uint bit = entry & 7u; - if (((particle.packed >> bit) & 1u) == 0u) { - return output; + if (((particle.packed >> bit) & 1u) == 0u || particle.scale <= 0.0) { + return emptyVertex(); } // The block spans one voxel either side of its center float3 corner = kDebrisBoxCorners[kDebrisBoxIndices[vertexId]]; float3 cell = float3(float(bit & 1u), float((bit >> 1) & 1u), float(bit >> 2)); - float3 local = (cell - 1.0 + corner) * gPush.voxelSize; + float3 boxLocal = cell - 1.0 + corner; + float worldScale = gPush.voxelSize * particle.scale; float3 center = particle.position + particle.velocity * gPush.extrapolation; - float3 world = center + debrisRotate(particle.rotation, local); + float3 world = center + debrisRotate(particle.rotation, boxLocal * worldScale); + float4 inverse = float4(-particle.rotation.xyz, particle.rotation.w); + VSOutput output; output.position = mul(gCamera.jitteredViewProjection, float4(world, 1.0)); output.worldPosition = world; output.previousWorldPosition = world - particle.velocity * gPush.frameDelta; output.normal = debrisRotate(particle.rotation, kDebrisFaceNormals[vertexId / 6u]); output.albedo = particle.albedo; output.packed = particle.packed; + output.glass = particle.glass; + output.boxLocal = boxLocal; + output.cameraLocal = debrisRotate(inverse, gCamera.position - center) / max(worldScale, 1e-6); + output.cellMin = cell - 1.0; + output.scale = particle.scale; return output; } +[shader("vertex")] +VSOutput vertexMain(uint vertexId : SV_VertexID, uint instanceId : SV_InstanceID) { + if (instanceId >= gPush.listCapacity) { + return emptyVertex(); + } + return debrisVertex(gDrawList[instanceId], vertexId); +} + +/// Glass entries fill the draw list from the top +[shader("vertex")] +VSOutput vertexGlass(uint vertexId : SV_VertexID, uint instanceId : SV_InstanceID) { + if (instanceId >= gPush.listCapacity) { + return emptyVertex(); + } + return debrisVertex(gDrawList[gPush.listCapacity - 1u - instanceId], vertexId); +} + +float3 debrisAlbedo(uint albedo) { + return debrisSrgbToLinear(float3(albedo & 0xFFu, (albedo >> 8) & 0xFFu, (albedo >> 16) & 0xFFu) / 255.0); +} + +float debrisRoughness(uint albedo) { + return clamp(float((albedo >> 24) & 0xFFu) / 255.0, 0.045, 1.0); +} + struct FSOutput { float4 albedo : SV_Target0; float4 faceNormal : SV_Target1; @@ -120,8 +176,6 @@ struct FSOutput { [shader("fragment")] FSOutput fragmentMain(VSOutput input) { - float3 albedo = debrisSrgbToLinear(float3(input.albedo & 0xFFu, (input.albedo >> 8) & 0xFFu, (input.albedo >> 16) & 0xFFu) / 255.0); - float roughness = clamp(float((input.albedo >> 24) & 0xFFu) / 255.0, 0.045, 1.0); float metallic = float((input.packed >> 8) & 0xFFu) / 255.0; float reflectivity = float((input.packed >> 16) & 0xFFu) / 255.0; @@ -129,9 +183,94 @@ FSOutput fragmentMain(VSOutput input) { float4 previousClip = mul(gCamera.previousViewProjection, float4(input.previousWorldPosition, 1.0)); FSOutput output; - output.albedo = float4(albedo, kGbufferLit); + output.albedo = float4(debrisAlbedo(input.albedo), kGbufferLit); output.faceNormal = encodeFaceNormal(normalize(input.normal)); - output.material = float4(metallic, reflectivity, roughness, encodeEmissive(0.0)); + output.material = float4(metallic, reflectivity, debrisRoughness(input.albedo), encodeEmissive(0.0)); output.motion = float4((clip.xy / clip.w - previousClip.xy / previousClip.w) * 0.5, 0.0, 1.0); return output; } + +struct GlassShade { + float3 color; + float3 transmittance; +}; + +/// Same optics as voxel_glass shadeInterface with the path through this one voxel +GlassShade shadeDebrisGlass(VSOutput input) { + const float3 albedo = debrisAlbedo(input.albedo); + const float roughness = debrisRoughness(input.albedo); + const float alpha = float(input.glass & 0xFFu) / 255.0; + const float ior = glassIor((input.glass >> 8) & 0xFFu); + const float3 normal = normalize(input.normal); + const float3 view = normalize(gCamera.position - input.worldPosition); + + Surface surface; + surface.marchedShadows = false; + surface.worldPosition = input.worldPosition; + surface.normal = normal; + surface.geometricNormal = normal; + surface.viewDirection = view; + surface.albedo = albedo; + // Dielectric with the specular lobe always on so the IOR alone sets reflectance + surface.metallic = 0.0; + surface.reflectivity = 1.0; + surface.roughness = roughness; + surface.occlusion = 1.0; + surface.viewSpaceZ = mul(gCamera.view, float4(input.worldPosition, 1.0)).z; + surface.pixel = input.position.xy; + + GlassShade shade; + const ShadingResult lit = shadeSurfaceLighting(surface); + if (lit.isDebugView) { + shade.color = lit.color; + shade.transmittance = float3(0.0, 0.0, 0.0); + return shade; + } + + const float f0 = iorF0(ior); + const float facing = pow(1.0 - saturate(dot(normal, view)), 5.0); + const float fresnel = f0 + (1.0 - f0) * facing; + const float scatter = glassScatter(alpha, roughness); + + const float3 diffuse = max(lit.color - lit.specular, float3(0.0, 0.0, 0.0)); + // Lighting assumes F0 0.04 so rescale by the Fresnel ratio + const float3 specular = lit.specular * fresnel / (0.04 + 0.96 * facing); + shade.color = diffuse * scatter + specular; + + // Front faces only so the exit face is the far slab of this voxel + const float3 direction = normalize(input.boxLocal - input.cameraLocal); + const float3 safe = select(abs(direction) > 1e-6, direction, float3(1e-6, 1e-6, 1e-6)); + const float3 far = max((input.cellMin - input.boxLocal) / safe, (input.cellMin + 1.0 - input.boxLocal) / safe); + const float chord = max(min(far.x, min(far.y, far.z)), 0.0); + + shade.transmittance = (1.0 - fresnel) * (1.0 - scatter) * (1.0 - f0) * + glassPathFilter(albedo, alpha, chord * input.scale); + return shade; +} + +// Location 0 index 1 is the second blend source so the background is tinted not just faded +struct GlassOutput { + [[vk::location(0)]] + [[vk::index(0)]] + float4 color : SV_Target0; + + [[vk::location(0)]] + [[vk::index(1)]] + float4 transmittance : SV_Target1; +}; + +[shader("fragment")] +GlassOutput fragmentGlass(VSOutput input) { + const GlassShade shade = shadeDebrisGlass(input); + GlassOutput output; + output.color = float4(shade.color, 1.0); + output.transmittance = float4(shade.transmittance, 1.0); + return output; +} + +/// Without dualSrcBlend the tint is lost and the background only fades +[shader("fragment")] +float4 fragmentGlassPremultiplied(VSOutput input) : SV_Target0 { + const GlassShade shade = shadeDebrisGlass(input); + return float4(shade.color, 1.0 - dot(shade.transmittance, float3(1.0, 1.0, 1.0)) / 3.0); +} diff --git a/engine/assets/shaders/glass_common.slang b/engine/assets/shaders/glass_common.slang new file mode 100644 index 000000000..98129b530 --- /dev/null +++ b/engine/assets/shaders/glass_common.slang @@ -0,0 +1,32 @@ +// Glass optics shared by voxel glass and glass debris, no resources so any pass can import it + +/// Voxels of alpha 1 glass that let exactly its albedo through +static const float kGlassTintVoxels = 4.0; + +/// Mirrors voxel::k_default_ior +static const float kDefaultIor = 1.5; + +/// Beer Lambert through @p voxels of glass +float3 glassPathFilter(float3 albedo, float alpha, float voxels) +{ + return pow(max(albedo, float3(0.01, 0.01, 0.01)), alpha * max(voxels, 0.0) / kGlassTintVoxels); +} + +/// Diffuse share of an entered surface +float glassScatter(float alpha, float roughness) +{ + return alpha * roughness; +} + +/// Head on reflectance of a dielectric against air +float iorF0(float ior) +{ + const float r = (ior - 1.0) / (ior + 1.0); + return r * r; +} + +/// Palette ior byte where 0 is the default +float glassIor(uint iorByte) +{ + return iorByte == 0u ? kDefaultIor : 1.0 + float(iorByte) / 100.0; +} diff --git a/engine/assets/shaders/glass_common.slang.meta b/engine/assets/shaders/glass_common.slang.meta new file mode 100644 index 000000000..6e12ccc45 --- /dev/null +++ b/engine/assets/shaders/glass_common.slang.meta @@ -0,0 +1,4 @@ +uid = "G1assC0mmon" +type = "shader" +created_at = "2026-10-21T00:00:00.0000000Z" +modified_at = "2026-10-21T00:00:00.0000000Z" diff --git a/engine/assets/shaders/lighting.slang b/engine/assets/shaders/lighting.slang index 968a332b9..a3dd60f36 100644 --- a/engine/assets/shaders/lighting.slang +++ b/engine/assets/shaders/lighting.slang @@ -78,8 +78,11 @@ struct CameraUBO // Must match VulkanRenderer::k_max_irradiance_volumes IrradianceVolumeGpu irradianceVolumes[4]; - // x 1 when gScene holds a TLAS and traced shadows are on. Keep last + // x 1 when gScene holds a TLAS and traced shadows are on float4 tracedShadowParams; + + /// xy blue noise texel offset walking R2 per frame z frame index + float4 noiseParams; }; [[vk::binding(0,0)]] @@ -103,11 +106,18 @@ struct GpuLight float4 directionPad; // w shadow layer or -1 float4 coneAngles; // x cos outer y cos inner z shadow near w shadow far - // x fraction of the shadow layer rendered + // x fraction of the shadow layer rendered y point light face scale inside the guard band z shadow strength float4 shadowAtlas; // Spotlight only float4x4 shadowViewProjection; + + /// x kLightShape y radius or rect half width z capsule half length or rect half height w kShadowMode + /// The shape faces or runs along directionPad.xyz + float4 shape; + + /// xyz rect width axis + float4 shapeTangent; }; [[vk::binding(1,0)]] @@ -125,6 +135,10 @@ Sampler2DArrayShadow gShadowMap; [[vk::binding(7,0)]] Sampler2DArrayShadow gPunctualShadowMaps; +/// gPunctualShadowMaps without comparison for the PCSS blocker search +[[vk::binding(16,0)]] +Sampler2DArray gPunctualShadowDepths; + [[vk::binding(8,0)]] SamplerCube gIrradianceMap; @@ -142,6 +156,22 @@ SamplerCube gProbeIrradiance[8]; [[vk::binding(12,0)]] StructuredBuffer gIrradianceSH; +/// renderer::blue_noise texels with four independent channels a byte each +[[vk::binding(15,0)]] +StructuredBuffer gBlueNoise; + +/// Mirrors renderer::blue_noise::k_size +static const uint kBlueNoiseSize = 64u; + +/// Four values in (0 1) decorrelated per sample index +float4 blueNoise(float2 pixel, uint sampleIndex) +{ + const uint2 offset = uint2(gCamera.noiseParams.xy) + sampleIndex * uint2(23u, 41u); + const uint2 texel = (uint2(pixel) + offset) & (kBlueNoiseSize - 1u); + const uint packed = gBlueNoise[texel.y * kBlueNoiseSize + texel.x]; + return (float4(float(packed & 0xFFu), float((packed >> 8) & 0xFFu), float((packed >> 16) & 0xFFu), float(packed >> 24)) + 0.5) / 256.0; +} + // Ray query only Gated on tracedShadowParams.x #if TOAST_RAY_QUERY [[vk::binding(14,0)]] @@ -344,12 +374,24 @@ float2 environmentBRDF(float roughness, float NdotV) return float2(-1.04, 1.04) * a004 + r.zw; } -float3 shadeLight(float3 N, float3 V, float3 L, float3 radiance, float3 albedo, float metallic, float roughness, float reflectivity) +/// Diffuse is over albedo so the denoiser filters texture free light +struct LightTerms { + float3 diffuse; + float3 specular; +}; + +LightTerms shadeLightTerms(float3 N, float3 V, float3 L, float3 radiance, float3 albedo, float metallic, float roughness, + float reflectivity) +{ + LightTerms terms; + terms.diffuse = float3(0.0, 0.0, 0.0); + terms.specular = float3(0.0, 0.0, 0.0); + float NdotL = max(dot(N, L), 0.0); if (NdotL <= 0.0) { - return float3(0.0, 0.0, 0.0); + return terms; } float3 H = normalize(V + L); @@ -365,7 +407,23 @@ float3 shadeLight(float3 N, float3 V, float3 L, float3 radiance, float3 albedo, float3 kD = lerp(float3(1.0, 1.0, 1.0), (float3(1.0, 1.0, 1.0) - F) * (1.0 - metallic), reflectivity); - return (kD * albedo / kPi + specular * reflectivity) * radiance * NdotL; + terms.diffuse = kD / kPi * radiance * NdotL; + terms.specular = specular * reflectivity * radiance * NdotL; + return terms; +} + +float3 shadeLight(float3 N, float3 V, float3 L, float3 radiance, float3 albedo, float metallic, float roughness, float reflectivity, + out float3 specular) +{ + const LightTerms terms = shadeLightTerms(N, V, L, radiance, albedo, metallic, roughness, reflectivity); + specular = terms.specular; + return terms.diffuse * albedo + terms.specular; +} + +float3 shadeLight(float3 N, float3 V, float3 L, float3 radiance, float3 albedo, float metallic, float roughness, float reflectivity) +{ + float3 specular; + return shadeLight(N, V, L, radiance, albedo, metallic, roughness, reflectivity, specular); } float attenuateRange(float dist, float range) @@ -375,6 +433,174 @@ float attenuateRange(float dist, float range) return rangeFalloff / max(dist * dist, 0.0001); } +// Mirror toast::LightShape and toast::LightShadowMode +static const uint kLightShapePoint = 0u; +static const uint kLightShapeSphere = 1u; +static const uint kLightShapeCapsule = 2u; +static const uint kLightShapeDisc = 3u; +static const uint kLightShapeRect = 4u; +static const uint kShadowModeMap = 0u; +static const uint kShadowModeMarched = 1u; +static const uint kShadowModeSampled = 2u; + +uint lightShadowMode(GpuLight light) +{ + return uint(light.shape.w + 0.5); +} + +void perpendicularBasis(float3 axis, out float3 tangent, out float3 bitangent) +{ + const float3 reference = abs(axis.y) < 0.99 ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0); + tangent = normalize(cross(axis, reference)); + bitangent = cross(axis, tangent); +} + +float2 concentricDisc(float2 u) +{ + const float radius = sqrt(u.x); + const float angle = 6.28318531 * u.y; + return radius * float2(cos(angle), sin(angle)); +} + +/// A sphere samples its silhouette disc facing the receiver +float3 sampleLightShape(GpuLight light, float3 receiver, float3 u) +{ + const float3 center = light.worldPosRange.xyz; + const uint shape = uint(light.shape.x + 0.5); + const float3 axis = light.directionPad.xyz; + float3 tangent; + float3 bitangent; + + if (shape == kLightShapeSphere || shape == kLightShapeCapsule) + { + float3 core = center; + if (shape == kLightShapeCapsule && dot(axis, axis) > 0.5) + { + core += axis * ((2.0 * u.z - 1.0) * light.shape.z); + } + const float3 toReceiver = receiver - core; + if (dot(toReceiver, toReceiver) < 1e-8) + { + return core; + } + perpendicularBasis(normalize(toReceiver), tangent, bitangent); + const float2 disc = concentricDisc(u.xy) * light.shape.y; + return core + tangent * disc.x + bitangent * disc.y; + } + if (shape == kLightShapeDisc && dot(axis, axis) > 0.5) + { + perpendicularBasis(axis, tangent, bitangent); + const float2 disc = concentricDisc(u.xy) * light.shape.y; + return center + tangent * disc.x + bitangent * disc.y; + } + if (shape == kLightShapeRect && dot(axis, axis) > 0.5) + { + tangent = light.shapeTangent.xyz; + bitangent = cross(axis, tangent); + return center + tangent * ((2.0 * u.x - 1.0) * light.shape.y) + bitangent * ((2.0 * u.y - 1.0) * light.shape.z); + } + return center; +} + +/// Spot cone from the light centre and flat emitters one sided +float3 lightSampleRadiance(GpuLight light, float3 position, float3 samplePoint, out float3 L, out float distance) +{ + const float3 toLight = samplePoint - position; + distance = length(toLight); + L = toLight / max(distance, 1e-4); + + float atten = attenuateRange(distance, light.worldPosRange.w); + if (light.viewPosType.w > 0.5) + { + const float3 fromCenter = position - light.worldPosRange.xyz; + const float cosAngle = dot(normalize(fromCenter), normalize(light.directionPad.xyz)); + atten *= smoothstep(light.coneAngles.x, light.coneAngles.y, cosAngle); + } + const uint shape = uint(light.shape.x + 0.5); + if ((shape == kLightShapeDisc || shape == kLightShapeRect) && dot(light.directionPad.xyz, -L) <= 0.0) + { + atten = 0.0; + } + return light.colorIntensity.rgb * light.colorIntensity.a * atten; +} + +/// Nearest capsule core point or the centre +float3 analyticLightPoint(GpuLight light, float3 position) +{ + const float3 center = light.worldPosRange.xyz; + if (uint(light.shape.x + 0.5) != kLightShapeCapsule || dot(light.directionPad.xyz, light.directionPad.xyz) < 0.5) + { + return center; + } + const float3 axis = light.directionPad.xyz * light.shape.z; + const float t = saturate(dot(position - (center - axis), axis) / max(2.0 * dot(axis, axis), 1e-8)); + return center - axis + 2.0 * axis * t; +} + +/// Karis 2013 representative points +/// Disc and rect use the sphere of equal area +float3 shadeAnalyticLight(GpuLight light, float3 position, float3 N, float3 V, float3 L, float3 radiance, float3 albedo, float metallic, + float roughness, float reflectivity, out float3 specularPart) +{ + specularPart = float3(0.0, 0.0, 0.0); + const uint shape = uint(light.shape.x + 0.5); + if (shape == kLightShapePoint || light.shape.y + light.shape.z <= 0.0) + { + return shadeLight(N, V, L, radiance, albedo, metallic, roughness, reflectivity, specularPart); + } + + const float3 axis = light.directionPad.xyz; + const bool oriented = dot(axis, axis) > 0.5; + const float3 toCenter = light.worldPosRange.xyz - position; + if ((shape == kLightShapeDisc || shape == kLightShapeRect) && oriented && dot(axis, toCenter) >= 0.0) + { + return float3(0.0, 0.0, 0.0); + } + + const float alpha = max(roughness * roughness, 1e-4); + const float3 R = reflect(-V, N); + float3 core = toCenter; + float radius = light.shape.y; + float lineNormalization = 1.0; + if (shape == kLightShapeCapsule && oriented) + { + const float3 start = toCenter - axis * light.shape.z; + const float3 segment = axis * (2.0 * light.shape.z); + const float RdotSegment = dot(R, segment); + const float t = saturate((dot(R, start) * RdotSegment - dot(start, segment)) / + max(dot(segment, segment) - RdotSegment * RdotSegment, 1e-8)); + core = start + segment * t; + lineNormalization = alpha / saturate(alpha + light.shape.z / (1.5 * max(length(toCenter), 1e-3))); + } + else if (shape == kLightShapeRect) + { + radius = sqrt(4.0 * light.shape.y * light.shape.z / kPi); + } + + const float3 centerToRay = dot(core, R) * R - core; + const float3 closest = core + centerToRay * saturate(radius / max(length(centerToRay), 1e-6)); + const float coreDistance = max(length(core), 1e-3); + const float widened = saturate(alpha + radius / (2.0 * coreDistance)); + + // A reflection ray missing the emitter by over a lobe width fades back to the centre + const float miss = max(length(centerToRay) - radius, 0.0) / coreDistance; + const float hit = 1.0 - saturate(miss / max(alpha, 1e-3)); + const float3 direction = normalize(lerp(normalize(core), normalize(closest), hit)); + const float normalization = lerp(1.0, (alpha / widened) * (alpha / widened) * lineNormalization, hit); + + const LightTerms diffuse = shadeLightTerms(N, V, L, radiance, albedo, metallic, roughness, reflectivity); + const LightTerms specular = shadeLightTerms(N, V, direction, radiance, albedo, metallic, roughness, reflectivity); + specularPart = specular.specular * normalization; + return diffuse.diffuse * albedo + specularPart; +} + +float3 shadeAnalyticLight(GpuLight light, float3 position, float3 N, float3 V, float3 L, float3 radiance, float3 albedo, float metallic, + float roughness, float reflectivity) +{ + float3 specularPart; + return shadeAnalyticLight(light, position, N, V, L, radiance, albedo, metallic, roughness, reflectivity, specularPart); +} + static const float kShadowFilterRadiusTexels = 2.5; static const float kShadowNormalOffsetTexels = 0.5; @@ -395,38 +621,58 @@ float shadowNormalOffset(float texelWorldSize, float NdotL) return offset; } +/// The first kShadowProbeTaps form a cross static const float2 kPoissonDisk[12] = { - float2(-0.326, -0.406), float2(-0.840, -0.074), float2(-0.696, 0.457), float2(-0.203, 0.621), - float2( 0.962, -0.195), float2( 0.473, -0.480), float2( 0.519, 0.767), float2( 0.185, -0.893), + float2(-0.840, -0.074), float2( 0.962, -0.195), float2(-0.203, 0.621), float2( 0.185, -0.893), + float2(-0.326, -0.406), float2(-0.696, 0.457), float2( 0.473, -0.480), float2( 0.519, 0.767), float2( 0.507, 0.064), float2( 0.896, 0.412), float2(-0.322, -0.933), float2(-0.792, -0.598), }; +static const uint kShadowProbeTaps = 4u; + float interleavedGradientNoise(float2 pixel) { return frac(52.9829189 * frac(dot(pixel, float2(0.06711056, 0.00583715)))); } -// Screen space derivatives break across cascade seams hence the clamp at the call site -float2 receiverPlaneDepthGradient(float2 uv, float depth) +static const float kPunctualBiasMeters = 0.01; +static const float kPunctualBiasTexels = 0.25; + +/// Tangent of the steepest receiver slope so about 76 degrees +static const float kMaxReceiverSlope = 4.0; + +/// Short enough that perspective depth is linear over it +static const float kDepthProbeMeters = 0.01; + +float receiverAdjustLimit(float depthPerMeter, float texelWorldSize) +{ + return depthPerMeter * texelWorldSize * kShadowFilterRadiusTexels * kMaxReceiverSlope; +} + +void tangentBasis(float3 normal, out float3 tangent, out float3 bitangent) { - float3 dx = float3(ddx(uv), ddx(depth)); - float3 dy = float3(ddy(uv), ddy(depth)); + const float3 helper = abs(normal.y) < 0.99 ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0); + tangent = normalize(cross(helper, normal)); + bitangent = cross(normal, tangent); +} - float determinant = (dx.x * dy.y) - (dx.y * dy.x); +/// dDepth by dUV from three receiver plane points as uv then depth +/// Not screen derivatives which cross deferred and voxel edges +float2 planeDepthGradient(float3 center, float3 alongTangent, float3 alongBitangent) +{ + const float3 a = alongTangent - center; + const float3 b = alongBitangent - center; + const float determinant = (a.x * b.y) - (a.y * b.x); if (abs(determinant) < 1e-12) { return float2(0.0, 0.0); } - - float inverse = 1.0 / determinant; - return float2( - ((dy.y * dx.z) - (dx.y * dy.z)) * inverse, - ((dx.x * dy.z) - (dy.x * dx.z)) * inverse); + return float2((a.z * b.y) - (a.y * b.z), (a.x * b.z) - (a.z * b.x)) / determinant; } -float pcfShadow( +float pcfShadowRadius( Sampler2DArrayShadow shadowMaps, float2 uv, float layer, float compareDepth, float texelSize, float2 pixel, - float2 gradient, float maxGradientAdjust) + float2 gradient, float maxGradientAdjust, float radiusTexels) { if (any(uv < float2(0.0, 0.0)) || any(uv > float2(1.0, 1.0)) || compareDepth > 1.0 || compareDepth < 0.0) { @@ -435,51 +681,129 @@ float pcfShadow( float angle = interleavedGradientNoise(pixel) * 6.28318530718; float2 rotation = float2(cos(angle), sin(angle)); - float radius = texelSize * kShadowFilterRadiusTexels; + float radius = texelSize * radiusTexels; + // LevelZero since implicit derivatives are undefined under per pixel branches + // Centre and cross agreeing skips the rest float sum = 0.0; - for (int i = 0; i < 12; ++i) + float probe = shadowMaps.SampleCmpLevelZero(float3(uv, layer), compareDepth); + [unroll] + for (uint i = 0u; i < kShadowProbeTaps; ++i) { - float2 tap = kPoissonDisk[i]; - float2 offset = float2( - tap.x * rotation.x - tap.y * rotation.y, - tap.x * rotation.y + tap.y * rotation.x) * radius; - - float tapDepth = compareDepth + clamp(dot(gradient, offset), -maxGradientAdjust, maxGradientAdjust); - - // LevelZero since implicit derivatives are undefined under per pixel branches + const float2 tap = kPoissonDisk[i]; + const float2 offset = float2(tap.x * rotation.x - tap.y * rotation.y, tap.x * rotation.y + tap.y * rotation.x) * radius; + const float tapDepth = compareDepth + clamp(dot(gradient, offset), -maxGradientAdjust, maxGradientAdjust); sum += shadowMaps.SampleCmpLevelZero(float3(uv + offset, layer), tapDepth); } + probe += sum; + if (probe < 1e-3 || probe > float(kShadowProbeTaps + 1u) - 1e-3) + { + return probe / float(kShadowProbeTaps + 1u); + } + [unroll] + for (uint i = kShadowProbeTaps; i < 12u; ++i) + { + const float2 tap = kPoissonDisk[i]; + const float2 offset = float2(tap.x * rotation.x - tap.y * rotation.y, tap.x * rotation.y + tap.y * rotation.x) * radius; + const float tapDepth = compareDepth + clamp(dot(gradient, offset), -maxGradientAdjust, maxGradientAdjust); + sum += shadowMaps.SampleCmpLevelZero(float3(uv + offset, layer), tapDepth); + } return sum / 12.0; } -float sampleCascade(uint cascade, float3 worldPosition, float3 N, float3 L, float2 pixel) +float pcfShadow( + Sampler2DArrayShadow shadowMaps, float2 uv, float layer, float compareDepth, float texelSize, float2 pixel, + float2 gradient, float maxGradientAdjust) { - float NdotL = saturate(dot(N, L)); - float3 offsetPosition = worldPosition + N * shadowNormalOffset(gCamera.cascadeTexelWorldSize[cascade], NdotL); + return pcfShadowRadius(shadowMaps, uv, layer, compareDepth, texelSize, pixel, gradient, maxGradientAdjust, + kShadowFilterRadiusTexels); +} - float4 lightClip = mul(gCamera.cascadeViewProjection[cascade], float4(offsetPosition, 1.0)); - if (lightClip.w <= 0.0) +/// Caps the blocker search cost +static const float kMaxBlockerSearchTexels = 16.0; +static const float kMaxPenumbraTexels = 24.0; + +float emitterRadius(GpuLight light) +{ + const uint shape = uint(light.shape.x + 0.5); + if (shape == kLightShapeCapsule) { - return 1.0; + return light.shape.y + 0.5 * light.shape.z; } + if (shape == kLightShapeRect) + { + return sqrt(4.0 * light.shape.y * light.shape.z / kPi); + } + return shape == kLightShapePoint ? 0.0 : light.shape.y; +} - float3 projected = lightClip.xyz / lightClip.w; - float2 uv = projected.xy * 0.5 + 0.5; - - float bias = gCamera.cascadeDepthBias[cascade]; - float2 gradient = receiverPlaneDepthGradient(uv, projected.z); - - return pcfShadow( - gShadowMap, - uv, - float(cascade), - projected.z - bias, - gCamera.shadowParams.z, - pixel, - gradient, - bias * 8.0); +/// perspectiveRH_ZO depth back to distance along the view axis +float punctualLinearDepth(float depth, GpuLight light) +{ + const float nearPlane = light.coneAngles.z; + const float farPlane = light.coneAngles.w; + return farPlane * nearPlane / max(farPlane - depth * (farPlane - nearPlane), 1e-6); +} + +/// PCSS filter radius in texels or 0 with no blocker +/// Shadow maps keep standard depth +float penumbraTexels(GpuLight light, float2 uv, float layer, float compareDepth, float receiverDistance, float texelWorldSize, + float2 pixel) +{ + const float radius = emitterRadius(light); + const float texelSize = gCamera.shadowParams.w; + const float searchTexels = clamp(radius / max(texelWorldSize, 1e-6), kShadowFilterRadiusTexels, kMaxBlockerSearchTexels); + + const float angle = interleavedGradientNoise(pixel + 17.0) * 6.28318530718; + const float2 rotation = float2(cos(angle), sin(angle)); + float blockerSum = 0.0; + float blockers = 0.0; + for (int i = 0; i < 12; ++i) + { + const float2 tap = kPoissonDisk[i]; + const float2 offset = float2(tap.x * rotation.x - tap.y * rotation.y, tap.x * rotation.y + tap.y * rotation.x) * searchTexels * texelSize; + const float depth = gPunctualShadowDepths.SampleLevel(float3(uv + offset, layer), 0.0).r; + if (depth < compareDepth) + { + blockerSum += punctualLinearDepth(depth, light); + blockers += 1.0; + } + } + if (blockers <= 0.0) + { + return 0.0; + } + const float blocker = blockerSum / blockers; + const float penumbraWorld = radius * max(receiverDistance - blocker, 0.0) / max(blocker, 1e-3); + return clamp(penumbraWorld / max(texelWorldSize, 1e-6), kShadowFilterRadiusTexels, kMaxPenumbraTexels); +} + +/// Shadow map uv then depth of a point in an orthographic cascade +float3 cascadeUvDepth(uint cascade, float3 position) +{ + const float3 projected = mul(gCamera.cascadeViewProjection[cascade], float4(position, 1.0)).xyz; + return float3(projected.xy * 0.5 + 0.5, projected.z); +} + +float sampleCascade(uint cascade, float3 worldPosition, float3 N, float3 L, float2 pixel) +{ + float NdotL = saturate(dot(N, L)); + const float texelWorldSize = gCamera.cascadeTexelWorldSize[cascade]; + float3 offsetPosition = worldPosition + N * shadowNormalOffset(texelWorldSize, NdotL); + + const float3 center = cascadeUvDepth(cascade, offsetPosition); + float3 tangent; + float3 bitangent; + tangentBasis(N, tangent, bitangent); + const float2 gradient = planeDepthGradient(center, cascadeUvDepth(cascade, offsetPosition + tangent * texelWorldSize), + cascadeUvDepth(cascade, offsetPosition + bitangent * texelWorldSize)); + + const float texelUv = gCamera.shadowParams.z; + const float depthPerMeter = abs(center.z - cascadeUvDepth(cascade, offsetPosition + L * kDepthProbeMeters).z) / kDepthProbeMeters; + const float adjustLimit = receiverAdjustLimit(depthPerMeter, texelWorldSize); + const float compareDepth = center.z - gCamera.cascadeDepthBias[cascade]; + return pcfShadow(gShadowMap, center.xy, float(cascade), compareDepth, texelUv, pixel, gradient, adjustLimit); } // Guards every gScene access. Folds to false without ray query @@ -511,6 +835,26 @@ float traceShadowRay(float3 worldPosition, float3 N, float3 L, float maxDistance #endif } +/// Share of a cascade map at its border that blends into the next cascade +static const float kCascadeBlendBand = 0.1; +/// Share of the shadow distance over which the last cascade fades out +static const float kShadowDistanceFade = 0.1; + +/// Uv distance inside the cascade border or 0 outside its map or depth range +/// Past the far plane a receiver would compare as lit +float cascadeEdge(uint cascade, float3 worldPosition) +{ + const float3 uvDepth = cascadeUvDepth(cascade, worldPosition); + if (uvDepth.z <= 0.0 || uvDepth.z >= 1.0) + { + return 0.0; + } + // The filter and normal offset reach a few texels past the point + const float margin = gCamera.shadowParams.z * (kShadowFilterRadiusTexels + 2.0); + return min(min(uvDepth.x, uvDepth.y), min(1.0 - uvDepth.x, 1.0 - uvDepth.y)) - margin; +} + +/// Finest cascade whose map and depth range hold the point float sampleDirectionalShadow(float3 worldPosition, float3 N, float3 L, float viewDepth, float2 pixel) { if (gCamera.shadowParams.x < 0.5) @@ -518,38 +862,42 @@ float sampleDirectionalShadow(float3 worldPosition, float3 N, float3 L, float vi return 1.0; } - uint cascadeCount = uint(gCamera.shadowParams.y); - uint cascade = cascadeCount; - for (uint i = 0; i < cascadeCount; ++i) - { - if (viewDepth <= gCamera.cascadeSplits[i]) - { - cascade = i; - break; - } - } - if (cascade >= cascadeCount) + const uint cascadeCount = uint(gCamera.shadowParams.y); + const float shadowDistance = gCamera.cascadeSplits[cascadeCount - 1u]; + const float distanceFade = saturate((shadowDistance - viewDepth) / (shadowDistance * kShadowDistanceFade)); + if (distanceFade <= 0.0) { return 1.0; } - float shadow = sampleCascade(cascade, worldPosition, N, L, pixel); - - float cascadeFar = gCamera.cascadeSplits[cascade]; - float cascadeNear = cascade > 0 ? gCamera.cascadeSplits[cascade - 1] : 0.0; - float span = max(cascadeFar - cascadeNear, 1e-4); - float t = (viewDepth - cascadeNear) / span; - - const float kBlendBand = 0.15; - if (t > (1.0 - kBlendBand) && (cascade + 1) < cascadeCount) + for (uint cascade = 0u; cascade < cascadeCount; ++cascade) { - float nextShadow = sampleCascade(cascade + 1, worldPosition, N, L, pixel); - shadow = lerp(shadow, nextShadow, (t - (1.0 - kBlendBand)) / kBlendBand); + const float edge = cascadeEdge(cascade, worldPosition); + if (edge <= 0.0) + { + continue; + } + + float shadow = sampleCascade(cascade, worldPosition, N, L, pixel); + if (edge < kCascadeBlendBand && cascade + 1u < cascadeCount && cascadeEdge(cascade + 1u, worldPosition) > 0.0) + { + const float coarser = sampleCascade(cascade + 1u, worldPosition, N, L, pixel); + shadow = lerp(coarser, shadow, edge / kCascadeBlendBand); + } + return lerp(1.0, shadow, distanceFade); } + return 1.0; +} - return shadow; +/// Shadow layer uv then depth of a point in a spot light frustum +float3 spotUvDepth(GpuLight light, float3 position) +{ + const float4 clip = mul(light.shadowViewProjection, float4(position, 1.0)); + const float3 projected = clip.xyz / max(clip.w, 1e-6); + return float3((projected.xy * 0.5 + 0.5) * light.shadowAtlas.x, projected.z); } +/// Bias is a world distance toward the light since perspective depth is not linear float sampleSpotShadow(GpuLight light, float3 worldPosition, float3 N, float3 L, float2 pixel) { float layer = light.directionPad.w; @@ -558,28 +906,106 @@ float sampleSpotShadow(GpuLight light, float3 worldPosition, float3 N, float3 L, return 1.0; } + // A perspective row scales by the reciprocal tangent of the half field of view + const float tanHalfFov = 1.0 / length(light.shadowViewProjection[0].xyz); + const float renderedTexels = max(light.shadowAtlas.x, 1e-4) / gCamera.shadowParams.w; + const float texelWorldSize = 2.0 * distance(worldPosition, light.worldPosRange.xyz) * tanHalfFov / renderedTexels; + float NdotL = saturate(dot(N, L)); - float texelWorldSize = - distance(worldPosition, light.worldPosRange.xyz) * (gCamera.shadowParams.w / max(light.shadowAtlas.x, 1e-4)) * 2.0; float3 offsetPosition = worldPosition + N * shadowNormalOffset(texelWorldSize, NdotL); - - float4 lightClip = mul(light.shadowViewProjection, float4(offsetPosition, 1.0)); - if (lightClip.w <= 0.0) + if (mul(light.shadowViewProjection, float4(offsetPosition, 1.0)).w <= 0.0) { return 1.0; } - float3 projected = lightClip.xyz / lightClip.w; - float2 uv = (projected.xy * 0.5 + 0.5) * light.shadowAtlas.x; + const float3 center = spotUvDepth(light, offsetPosition); + const float biasWorld = kPunctualBiasMeters + kPunctualBiasTexels * texelWorldSize; + const float compareDepth = spotUvDepth(light, offsetPosition + L * biasWorld).z; - float bias = max(0.0005, 0.0025 * (1.0 - projected.z)); - float2 gradient = receiverPlaneDepthGradient(uv, projected.z); + float3 tangent; + float3 bitangent; + tangentBasis(N, tangent, bitangent); + const float2 gradient = planeDepthGradient(center, spotUvDepth(light, offsetPosition + tangent * texelWorldSize), + spotUvDepth(light, offsetPosition + bitangent * texelWorldSize)); - return pcfShadow( - gPunctualShadowMaps, uv, layer, projected.z - bias, gCamera.shadowParams.w, pixel, gradient, bias * 8.0); + const float depthPerMeter = abs(center.z - spotUvDepth(light, offsetPosition + L * kDepthProbeMeters).z) / kDepthProbeMeters; + const float adjustLimit = receiverAdjustLimit(depthPerMeter, texelWorldSize); + float radiusTexels = kShadowFilterRadiusTexels; + if (emitterRadius(light) > 0.0) + { + const float receiver = mul(light.shadowViewProjection, float4(offsetPosition, 1.0)).w; + radiusTexels = penumbraTexels(light, center.xy, layer, compareDepth, receiver, texelWorldSize, pixel); + if (radiusTexels <= 0.0) + { + return 1.0; + } + } + const float shadow = pcfShadowRadius(gPunctualShadowMaps, center.xy, layer, compareDepth, gCamera.shadowParams.w, pixel, gradient, + adjustLimit, radiusTexels); + return lerp(1.0, shadow, light.shadowAtlas.z); +} + +/// Cube face of a direction from the light in cubeFaceBasis() order +X -X +Y -Y +Z -Z +uint pointFace(float3 direction) +{ + const float3 absDirection = abs(direction); + if (absDirection.x >= absDirection.y && absDirection.x >= absDirection.z) + { + return direction.x > 0.0 ? 0u : 1u; + } + if (absDirection.y >= absDirection.z) + { + return direction.y > 0.0 ? 2u : 3u; + } + return direction.z > 0.0 ? 4u : 5u; +} + +struct PointFaceFrame +{ + float3 u; + float3 v; + float3 major; + /// shadowAtlas.y and shadowAtlas.x + float ndcScale; + float atlas; + /// perspectiveRH_ZO depth is depthNear - depthFar / majorAxis + float depthNear; + float depthFar; +}; + +/// Must agree with cubeFaceBasis() or shadows tear at face seams +PointFaceFrame pointFaceFrame(GpuLight light, uint face) +{ + PointFaceFrame frame; + const uint axis = face >> 1; + frame.major = float3(0.0, 0.0, 0.0); + frame.major[axis] = 1.0; + const bool zFace = axis == 2u; + frame.v = zFace ? float3(0.0, 1.0, 0.0) : float3(0.0, 0.0, 1.0); + const float uSign = (face == 0u || face == 3u || face == 4u) ? -1.0 : 1.0; + frame.u = axis == 0u ? float3(0.0, uSign, 0.0) : float3(uSign, 0.0, 0.0); + frame.ndcScale = light.shadowAtlas.y; + frame.atlas = light.shadowAtlas.x; + const float nearPlane = light.coneAngles.z; + const float farPlane = light.coneAngles.w; + const float range = max(farPlane - nearPlane, 0.0001); + frame.depthNear = farPlane / range; + frame.depthFar = farPlane * nearPlane / range; + return frame; +} + +float3 pointFaceUvDepth(PointFaceFrame frame, float3 direction) +{ + const float majorAxis = max(abs(dot(direction, frame.major)), 1e-4); + const float2 ndc = float2(dot(direction, frame.u), dot(direction, frame.v)) * (frame.ndcScale / majorAxis); + return float3((ndc * 0.5 + 0.5) * frame.atlas, frame.depthNear - frame.depthFar / majorAxis); +} + +float pointFaceDepth(PointFaceFrame frame, float3 direction) +{ + return frame.depthNear - frame.depthFar / max(abs(dot(direction, frame.major)), 1e-4); } -// Six layers in cubeFaceBasis() order +X -X +Y -Y +Z -Z float samplePointShadow(GpuLight light, float3 worldPosition, float3 N, float3 L, float2 pixel) { float baseLayer = light.directionPad.w; @@ -588,55 +1014,48 @@ float samplePointShadow(GpuLight light, float3 worldPosition, float3 N, float3 L return 1.0; } - float nearPlane = light.coneAngles.z; - float farPlane = light.coneAngles.w; + // Guard texels widen each face so its half field of view has a tangent of 1 over shadowAtlas.y + const float renderedTexels = max(light.shadowAtlas.x, 1e-4) / gCamera.shadowParams.w; + const float texelWorldSize = + 2.0 * distance(worldPosition, light.worldPosRange.xyz) / (max(light.shadowAtlas.y, 1e-4) * renderedTexels); float NdotL = saturate(dot(N, L)); - float texelWorldSize = - distance(worldPosition, light.worldPosRange.xyz) * (gCamera.shadowParams.w / max(light.shadowAtlas.x, 1e-4)) * 2.0; float3 offsetPosition = worldPosition + N * shadowNormalOffset(texelWorldSize, NdotL); - float3 direction = offsetPosition - light.worldPosRange.xyz; - float3 absDirection = abs(direction); - float majorAxis = max(absDirection.x, max(absDirection.y, absDirection.z)); - if (majorAxis < 0.0001) + const float3 direction = offsetPosition - light.worldPosRange.xyz; + if (dot(direction, direction) < 1e-8) { return 1.0; } - // Must agree with cubeFaceBasis() or shadows tear at face seams - uint face; - float2 ndc; - if (absDirection.x >= absDirection.y && absDirection.x >= absDirection.z) - { - face = direction.x > 0.0 ? 0 : 1; - ndc = float2(direction.x > 0.0 ? -direction.y : direction.y, direction.z) / majorAxis; - } - else if (absDirection.y >= absDirection.z) - { - face = direction.y > 0.0 ? 2 : 3; - ndc = float2(direction.y > 0.0 ? direction.x : -direction.x, direction.z) / majorAxis; - } - else + const uint face = pointFace(direction); + const PointFaceFrame frame = pointFaceFrame(light, face); + const float3 center = pointFaceUvDepth(frame, direction); + const float biasWorld = kPunctualBiasMeters + kPunctualBiasTexels * texelWorldSize; + const float compareDepth = pointFaceDepth(frame, direction + L * biasWorld); + + float3 tangent; + float3 bitangent; + tangentBasis(N, tangent, bitangent); + const float2 gradient = planeDepthGradient(center, pointFaceUvDepth(frame, direction + tangent * texelWorldSize), + pointFaceUvDepth(frame, direction + bitangent * texelWorldSize)); + + // d depth / d majorAxis is depthFar / majorAxis squared + const float majorAxis = max(abs(dot(direction, frame.major)), 1e-4); + const float depthPerMeter = frame.depthFar * abs(dot(L, frame.major)) / (majorAxis * majorAxis); + const float adjustLimit = receiverAdjustLimit(depthPerMeter, texelWorldSize); + float radiusTexels = kShadowFilterRadiusTexels; + if (emitterRadius(light) > 0.0) { - face = direction.z > 0.0 ? 4 : 5; - ndc = float2(direction.z > 0.0 ? -direction.x : direction.x, direction.y) / majorAxis; + radiusTexels = penumbraTexels(light, center.xy, baseLayer + float(face), compareDepth, abs(direction[face >> 1]), texelWorldSize, pixel); + if (radiusTexels <= 0.0) + { + return 1.0; + } } - - ndc *= light.shadowAtlas.y; - - float2 uv = (ndc * 0.5 + 0.5) * light.shadowAtlas.x; - - // perspectiveRH_ZO depth at majorAxis is z = f/(f-n) - f*n/((f-n)*majorAxis) - float range = max(farPlane - nearPlane, 0.0001); - float faceDepth = (farPlane / range) - ((farPlane * nearPlane) / (range * majorAxis)); - - float bias = max(0.0005, 0.0025 * (1.0 - faceDepth)); - float2 gradient = receiverPlaneDepthGradient(uv, faceDepth); - - return pcfShadow( - gPunctualShadowMaps, uv, baseLayer + float(face), faceDepth - bias, gCamera.shadowParams.w, pixel, gradient, - bias * 8.0); + const float shadow = pcfShadowRadius(gPunctualShadowMaps, center.xy, baseLayer + float(face), compareDepth, gCamera.shadowParams.w, + pixel, gradient, adjustLimit, radiusTexels); + return lerp(1.0, shadow, light.shadowAtlas.z); } float3 clusterHeatmapColor(uint lightCount) @@ -676,12 +1095,19 @@ struct Surface float viewSpaceZ; float2 pixel; + + /// Marched and sampled lights are left to the caller + /// Every Surface must set it since Slang leaves a plain declaration uninitialized + bool marchedShadows; }; struct ShadingResult { float3 color; + /// Specular share of color + float3 specular; + float3 indirectDiffuse; bool isDebugView; @@ -700,6 +1126,7 @@ struct ClusterCell ClusterCell clusterCellAt(float viewSpaceZ, float2 pixel) { Surface surface; + surface.marchedShadows = false; surface.viewSpaceZ = viewSpaceZ; surface.pixel = pixel; @@ -881,7 +1308,22 @@ float4 evaluateDebugView(Surface surface, ClusterCell cluster) return float4(0.0, 0.0, 0.0, 0.0); } -ShadingResult evaluateLighting(Surface surface, ClusterCell cluster) +/// Colour a shadow map cannot hold such as voxel glass +interface IShadowFilter +{ + /// @p distance is negative for a directional light + float3 filter(float3 L, float distance); +} + +struct NoShadowFilter : IShadowFilter +{ + float3 filter(float3 L, float distance) + { + return float3(1.0, 1.0, 1.0); + } +} + +ShadingResult evaluateLighting(Surface surface, ClusterCell cluster, Filter shadowFilter) { float3 N = surface.normal; float3 V = surface.viewDirection; @@ -899,9 +1341,11 @@ ShadingResult evaluateLighting(Surface surface, ClusterCell cluster) ShadingResult result; result.indirectDiffuse = float3(0.0, 0.0, 0.0); + result.specular = float3(0.0, 0.0, 0.0); result.isDebugView = false; float3 outIndirectDiffuse = float3(0.0, 0.0, 0.0); + float3 specular = float3(0.0, 0.0, 0.0); float3 color = float3(0.0, 0.0, 0.0); @@ -913,6 +1357,10 @@ ShadingResult evaluateLighting(Surface surface, ClusterCell cluster) for (uint di = 0; di < gCamera.directionalLightCountPad.x; ++di) { DirectionalLightGpu light = gCamera.directionalLights[di]; + if (surface.marchedShadows && light.direction.w > 0.0) + { + continue; + } float3 L = normalize(-light.direction.xyz); if (dot(N, L) <= 0.0 && !shadowDebugView) { @@ -936,7 +1384,11 @@ ShadingResult evaluateLighting(Surface surface, ClusterCell cluster) debugMinShadow = min(debugMinShadow, shadow); } - color += shadeLight(N, V, L, radiance, albedo, metallic, roughness, reflectivity) * shadow; + const float3 tint = shadow > 0.0 ? shadowFilter.filter(L, -1.0) : float3(0.0, 0.0, 0.0); + const float3 weight = shadow * tint; + float3 lightSpecular; + color += shadeLight(N, V, L, radiance, albedo, metallic, roughness, reflectivity, lightSpecular) * weight; + specular += lightSpecular * weight; } if (clusterIndex < clusterCount) @@ -945,9 +1397,25 @@ ShadingResult evaluateLighting(Surface surface, ClusterCell cluster) for (uint li = 0; li < lightCountInCluster; ++li) { - GpuLight light = gLights[gLightIndexList[indexBase + li]]; + const uint lightIndex = gLightIndexList[indexBase + li]; + // Rejected from two float4 before the full load + // A capsule core lies within shape.z of the centre and every other shape lights from the centre + const float4 positionRange = gLights[lightIndex].worldPosRange; + const float4 shapeParams = gLights[lightIndex].shape; + if (surface.marchedShadows && uint(shapeParams.w + 0.5) != kShadowModeMap) + { + continue; + } + const float3 toCenter = positionRange.xyz - surface.worldPosition; + const float reach = positionRange.w + shapeParams.z; + if (!shadowDebugView && (dot(toCenter, toCenter) >= reach * reach || dot(N, toCenter) + shapeParams.z <= 0.0)) + { + continue; + } - float3 toLight = light.worldPosRange.xyz - surface.worldPosition; + GpuLight light = gLights[lightIndex]; + + float3 toLight = analyticLightPoint(light, surface.worldPosition) - surface.worldPosition; float lightDist = length(toLight); float3 L = toLight / max(lightDist, 0.0001); @@ -984,7 +1452,12 @@ ShadingResult evaluateLighting(Surface surface, ClusterCell cluster) float3 radiance = light.colorIntensity.rgb * light.colorIntensity.a * atten; - color += shadeLight(N, V, L, radiance, albedo, metallic, roughness, reflectivity) * shadow; + const float3 tint = shadow > 0.0 ? shadowFilter.filter(L, lightDist) : float3(0.0, 0.0, 0.0); + const float3 weight = shadow * tint; + float3 lightSpecular; + color += shadeAnalyticLight(light, surface.worldPosition, N, V, L, radiance, albedo, metallic, roughness, reflectivity, + lightSpecular) * weight; + specular += lightSpecular * weight; } } @@ -1080,15 +1553,17 @@ ShadingResult evaluateLighting(Surface surface, ClusterCell cluster) float3 ambientSpecular = prefiltered * (specularFresnel * envBrdf.x + envBrdf.y) * reflectivity; color += (ambientDiffuse + ambientSpecular) * occlusion; + specular += ambientSpecular * occlusion; outIndirectDiffuse = ambientDiffuse * occlusion; result.color = color; + result.specular = specular; result.indirectDiffuse = outIndirectDiffuse; return result; } -ShadingResult shadeSurfaceLighting(Surface surface) +ShadingResult shadeSurfaceLighting(Surface surface, Filter shadowFilter) { ClusterCell cluster = clusterCellAt(surface.viewSpaceZ, surface.pixel); @@ -1097,10 +1572,17 @@ ShadingResult shadeSurfaceLighting(Surface surface) { ShadingResult result; result.color = debug.rgb; + result.specular = float3(0.0, 0.0, 0.0); result.indirectDiffuse = float3(0.0, 0.0, 0.0); result.isDebugView = true; return result; } - return evaluateLighting(surface, cluster); + return evaluateLighting(surface, cluster, shadowFilter); +} + +ShadingResult shadeSurfaceLighting(Surface surface) +{ + NoShadowFilter none; + return shadeSurfaceLighting(surface, none); } diff --git a/engine/assets/shaders/mesh.slang b/engine/assets/shaders/mesh.slang index b4b1c82b6..71ab37f28 100644 --- a/engine/assets/shaders/mesh.slang +++ b/engine/assets/shaders/mesh.slang @@ -1,6 +1,7 @@ import toast; import lighting; +import scene_normal; struct MaterialUBO { @@ -248,11 +249,12 @@ float4 shadeSurface(VSOutput input, inout float4 outNormalRoughness, inout float float3 tangentNormal = gNormal.Sample(input.uv).rgb * 2.0 - 1.0; N = normalize(T * tangentNormal.x + B * tangentNormal.y + N * tangentNormal.z); - outNormalRoughness = float4(N, roughness); + outNormalRoughness = encodeSceneNormal(N, roughness, specularF0(albedo, metallic)); float3 V = normalize(gCamera.position - input.worldPosition); Surface surface; + surface.marchedShadows = false; surface.worldPosition = input.worldPosition; surface.normal = N; surface.geometricNormal = geometricNormal; diff --git a/engine/assets/shaders/scene_normal.slang b/engine/assets/shaders/scene_normal.slang new file mode 100644 index 000000000..6da9f3b51 --- /dev/null +++ b/engine/assets/shaders/scene_normal.slang @@ -0,0 +1,17 @@ +// World stage normal target xyz is the normal scaled by 1 + specular F0 and w the roughness SSR reads +// Readers that normalize the normal are unaffected + +public float specularF0(float3 albedo, float metallic) +{ + return lerp(0.04, dot(albedo, float3(0.2126, 0.7152, 0.0722)), metallic); +} + +public float4 encodeSceneNormal(float3 normal, float roughness, float f0) +{ + return float4(normalize(normal) * (1.0 + saturate(f0)), roughness); +} + +public float sceneNormalF0(float4 encoded) +{ + return saturate(length(encoded.xyz) - 1.0); +} diff --git a/engine/assets/shaders/scene_normal.slang.meta b/engine/assets/shaders/scene_normal.slang.meta new file mode 100644 index 000000000..0a3da3e53 --- /dev/null +++ b/engine/assets/shaders/scene_normal.slang.meta @@ -0,0 +1,4 @@ +uid = "Sc3neN0rm4l" +type = "slang_module" +created_at = "2026-10-18T12:00:00.0000000Z" +modified_at = "2026-10-18T12:00:00.0000000Z" diff --git a/engine/assets/shaders/selection_mask.slang b/engine/assets/shaders/selection_mask.slang new file mode 100644 index 000000000..2a3f6a26a --- /dev/null +++ b/engine/assets/shaders/selection_mask.slang @@ -0,0 +1,151 @@ +// Stencil bit 1 covers every pixel of the outlined nodes and bit 2 the pixels where they are in front +import voxel_dda; +import depth_convention; + +struct CameraUBO +{ + float4x4 view; + float4x4 projection; + float4x4 viewProjection; + + float3 position; + float time; + + float4x4 jitteredViewProjection; +}; + +[[vk::binding(0, 0)]] +ConstantBuffer gCamera; + +// Mirrors mesh.slang InstanceData and VulkanRenderer::InstanceData +struct InstanceData +{ + float4x4 model; + float4x4 previousModel; + uint jointOffset; + uint flags; + uint _pad0; + uint _pad1; +}; + +[[vk::binding(13, 0)]] +StructuredBuffer gInstances; + +[[vk::binding(1, 0)]] +Sampler2D gSceneDepth; + +/// Mirrors renderer::SelectionOutline::MaskPush +struct PushConstants +{ + uint instance; + uint testOcclusion; +}; + +[[vk::push_constant]] +PushConstants gPush; + +/// Relative since voxel cells raster as quads in the scene and march here +static const float kDepthSlack = 0.002; + +/// The mask shares the scene jitter so both depths sample the same point +bool unoccluded(int2 pixel, float depth) +{ + const float scene = gSceneDepth.Load(int3(pixel, 0)); + +#if TOAST_REVERSED_Z + return scene <= depth * (1.0 + kDepthSlack); +#else + return scene >= depth - kDepthSlack * (1.0 - depth); +#endif +} + +struct MeshVSInput +{ + [[vk::location(0)]] + float3 position : POSITION; +}; + +struct MeshVSOutput +{ + float4 position : SV_Position; +}; + +[shader("vertex")] +MeshVSOutput meshVertex(MeshVSInput input, uint instanceId : SV_InstanceID) +{ + const float4x4 model = gInstances[gPush.instance + instanceId].model; + + MeshVSOutput output; + output.position = mul(gCamera.jitteredViewProjection, mul(model, float4(input.position, 1.0))); + return output; +} + +[shader("fragment")] +void meshFragment(MeshVSOutput input) +{ + if (gPush.testOcclusion != 0u && !unoccluded(int2(input.position.xy), input.position.z)) + { + discard; + } +} + +struct VoxelVSOutput +{ + float4 position : SV_Position; + + [[vk::location(0)]] + float3 worldPosition : TEXCOORD0; +}; + +// Back faces only so each pixel marches once even with the camera inside the volume +[shader("vertex")] +VoxelVSOutput voxelVertex(uint vertexId : SV_VertexID) +{ + const VoxelInstance instance = gVoxelInstances[gPush.instance]; + const VolumeRecord record = gVolumeRecords[instance.recordIndex]; + const float3 voxels = float3(record.brickDimsX, record.brickDimsY, record.brickDimsZ) * float(kBrickDim); + + // A mirrored transform flips winding so swap two corners of each triangle to keep one cull mode + uint corner = vertexId % 3u; + if ((instance.flags & kInstanceMirrored) != 0u && corner != 0u) + { + corner = 3u - corner; + } + const float3 local = kBoxCorners[kBoxIndices[vertexId - (vertexId % 3u) + corner]] * voxels; + const float4 world = mul(instance.voxelToWorld, float4(local, 1.0)); + + VoxelVSOutput output; + output.worldPosition = world.xyz; + output.position = mul(gCamera.jitteredViewProjection, world); + return output; +} + +[shader("fragment")] +void voxelFragment(VoxelVSOutput input) +{ + const VoxelInstance instance = gVoxelInstances[gPush.instance]; + const float3 camera = gCamera.position; + + // t = 1 is the back face + const float3 worldDirection = input.worldPosition - camera; + const float3 origin = mul(instance.worldToVoxel, float4(camera, 1.0)).xyz; + const float3 direction = mul(instance.worldToVoxel, float4(worldDirection, 0.0)).xyz; + + const float nearOrigin = clipNearDistance(mul(gCamera.viewProjection, float4(camera, 1.0))); + const float nearStep = clipNearDistance(mul(gCamera.viewProjection, float4(worldDirection, 0.0))); + const float tMin = nearStep > 0.0 ? max(0.0, -nearOrigin / nearStep) : 0.0; + + const RayHit hit = marchRay(instance.recordIndex, origin, direction, tMin, 1.0, false); + if (!hit.hit) + { + discard; + } + if (gPush.testOcclusion != 0u) + { + const float4 clip = mul(gCamera.jitteredViewProjection, float4(camera + worldDirection * hit.t, 1.0)); + if (!unoccluded(int2(input.position.xy), clip.z / clip.w)) + { + discard; + } + } +} diff --git a/engine/assets/shaders/selection_mask.slang.meta b/engine/assets/shaders/selection_mask.slang.meta new file mode 100644 index 000000000..8375e6d72 --- /dev/null +++ b/engine/assets/shaders/selection_mask.slang.meta @@ -0,0 +1,4 @@ +uid = "s3lMaskSt3n" +type = "shader" +created_at = "2026-10-16T12:00:00.0000000Z" +modified_at = "2026-10-16T12:00:00.0000000Z" diff --git a/engine/assets/shaders/selection_outline.slang b/engine/assets/shaders/selection_outline.slang new file mode 100644 index 000000000..09e098a61 --- /dev/null +++ b/engine/assets/shaders/selection_outline.slang @@ -0,0 +1,111 @@ +// The mask carries the TAA jitter so the outline is averaged over frames before it reaches the output +import fullscreen; + +/// Stencil aspect of the selection_mask.slang target +[[vk::binding(0, 0)]] +Sampler2D gMask; + +/// r solid coverage g hidden coverage +[[vk::binding(1, 0)]] +Sampler2D gHistory; + +[[vk::binding(2, 0)]] +Sampler2D gOutline; + +/// Mirrors renderer::SelectionOutline::OutlinePush +struct PushConstants +{ + float4 solidColor; + float4 hiddenColor; + /// Pixels from xy up to zw the resolve may cover + int4 rect; + /// Weight of this frame where 1 drops the history + float blend; + float pad0; + float pad1; + float pad2; +}; + +[[vk::push_constant]] +PushConstants gPush; + +static const uint kSilhouette = 1u; +static const uint kVisible = 2u; + +static const int kWidth = 2; +static const int kDashPixels = 4; + +typedef FullscreenVSOutput VSOutput; + +[shader("vertex")] +VSOutput vertexMain(uint vertexId : SV_VertexID) +{ + return fullscreenTriangle(vertexId); +} + +/// Solid where the outline borders a visible part and dashed where only hidden parts are around +float2 outlineCoverage(int2 pixel) +{ + const uint center = gMask.Load(int3(pixel, 0)).r; + if ((center & kVisible) != 0u) + { + return float2(0.0, 0.0); + } + + uint2 size; + gMask.GetDimensions(size.x, size.y); + const int2 last = int2(size) - 1; + + uint around = 0u; + [unroll] + for (int y = -kWidth; y <= kWidth; ++y) + { + [unroll] + for (int x = -kWidth; x <= kWidth; ++x) + { + if (x * x + y * y <= kWidth * kWidth) + { + around |= gMask.Load(int3(clamp(pixel + int2(x, y), int2(0, 0), last), 0)).r; + } + } + } + + if ((around & kVisible) != 0u) + { + return float2(1.0, 0.0); + } + + // A hidden pixel of the selection only draws where a visible part meets it + const bool hidden = (center & kSilhouette) != 0u; + if (hidden || (around & kSilhouette) == 0u || ((pixel.x / kDashPixels + pixel.y / kDashPixels) & 1) != 0) + { + return float2(0.0, 0.0); + } + return float2(0.0, 1.0); +} + +[shader("fragment")] +float2 resolveMain(VSOutput input) : SV_Target0 +{ + const int2 pixel = int2(input.position.xy); + if (any(pixel < gPush.rect.xy) || any(pixel >= gPush.rect.zw)) + { + return float2(0.0, 0.0); + } + const float2 current = outlineCoverage(pixel); + return lerp(gHistory.Load(int3(pixel, 0)).rg, current, gPush.blend); +} + +[shader("fragment")] +float4 compositeMain(VSOutput input) : SV_Target0 +{ + const float2 coverage = gOutline.Load(int3(int2(input.position.xy), 0)).rg; + if (coverage.x + coverage.y < 1.0 / 255.0) + { + discard; + } + + // Premultiplied + return float4( + gPush.solidColor.rgb * coverage.x + gPush.hiddenColor.rgb * coverage.y, saturate(coverage.x + coverage.y)); +} diff --git a/engine/assets/shaders/selection_outline.slang.meta b/engine/assets/shaders/selection_outline.slang.meta new file mode 100644 index 000000000..c2a36b6a2 --- /dev/null +++ b/engine/assets/shaders/selection_outline.slang.meta @@ -0,0 +1,4 @@ +uid = "s3lOutl1neC" +type = "shader" +created_at = "2026-10-16T12:00:00.0000000Z" +modified_at = "2026-10-16T12:00:00.0000000Z" diff --git a/engine/assets/shaders/shadow_depth.slang b/engine/assets/shaders/shadow_depth.slang index d226588d2..1279e86c6 100644 --- a/engine/assets/shaders/shadow_depth.slang +++ b/engine/assets/shaders/shadow_depth.slang @@ -1,5 +1,5 @@ // Must match renderer::shadows::k_max_shadow_views -static const uint kMaxShadowViews = 20; +static const uint kMaxShadowViews = 228; struct ShadowUBO { diff --git a/engine/assets/shaders/shadow_views.slang b/engine/assets/shaders/shadow_views.slang new file mode 100644 index 000000000..9a10c72b3 --- /dev/null +++ b/engine/assets/shaders/shadow_views.slang @@ -0,0 +1,13 @@ + +// Must match renderer::shadows::k_max_shadow_views +static const uint kMaxShadowViews = 228; + +/// Mirrors renderer::ShadowPass::ShadowUBO and shadow_depth.slang declares only the first array of the same buffer +struct ShadowUBO +{ + float4x4 viewProjection[kMaxShadowViews]; + float4x4 inverseViewProjection[kMaxShadowViews]; +}; + +[[vk::binding(0, 0)]] +ConstantBuffer gShadow; diff --git a/engine/assets/shaders/shadow_views.slang.meta b/engine/assets/shaders/shadow_views.slang.meta new file mode 100644 index 000000000..1045459f5 --- /dev/null +++ b/engine/assets/shaders/shadow_views.slang.meta @@ -0,0 +1,4 @@ +uid = "ShdwV13wsUb" +type = "slang_module" +created_at = "2026-10-16T12:00:00.0000000Z" +modified_at = "2026-10-16T12:00:00.0000000Z" diff --git a/engine/assets/shaders/ssr.slang b/engine/assets/shaders/ssr.slang index 138e7439c..fa60dc8ca 100644 --- a/engine/assets/shaders/ssr.slang +++ b/engine/assets/shaders/ssr.slang @@ -1,14 +1,17 @@ +// Stochastic SSR traced resolved and accumulated at half resolution then composited import fullscreen; import depth_convention; +import scene_normal; struct SsrParams { float4x4 viewProjection; float4x4 inverseViewProjection; + /// xyz camera position w frame index for the noise float4 cameraPosition; // x intensity y max roughness z thickness w step count float4 tuning; - // x stride y render mode + // x reach in meters y render mode z 1 with a valid history float4 marching; }; @@ -24,11 +27,37 @@ Sampler2D gDepth; [[vk::binding(2, 0)]] Sampler2D gNormalRoughness; -// r hit distance g 1 on hit a camera distance +/// xy hit uv z pdf of the ray and w confidence with 0 a miss [[vk::binding(3, 0)]] -Sampler2D gReflection; +Sampler2D gHits; -static const float kNoSurfaceDistance = 60000.0; +/// rgb reflection a confidence +[[vk::binding(4, 0)]] +Sampler2D gResolved; + +[[vk::binding(5, 0)]] +Sampler2D gHistory; + +[[vk::binding(6, 0)]] +Sampler2D gMotion; + +[[vk::binding(7, 0)]] +Sampler2D gTemporal; + +static const float kPi = 3.14159265; + +/// Below this a surface is a mirror and gets one ray with no reuse +static const float kMirrorRoughness = 0.05; + +/// Trims the noisy GGX tail +static const float kLobeTrim = 0.75; + +static const float kTemporalBlend = 0.1; + +static const uint kResolveTaps = 5u; +static const float2 kResolvePattern[5] = { + float2(0.0, 0.0), float2(1.0, 0.3), float2(-0.3, 1.0), float2(-1.0, -0.3), float2(0.3, -1.0), +}; typedef FullscreenVSOutput VSOutput; @@ -45,19 +74,14 @@ float3 worldPositionFromDepth(float2 uv, float depth) return world.xyz / world.w; } -bool projectToScreen(float3 worldPosition, out float2 uv, out float depth) +/// Clip w from the 1 / w row of the inverse view projection +float linearDepth(float2 uv, float depth) { - float4 clip = mul(gParams.viewProjection, float4(worldPosition, 1.0)); - if (clip.w <= 0.0) - { - uv = float2(0.0, 0.0); - depth = 0.0; - return false; - } + return 1.0 / max(dot(gParams.inverseViewProjection[3], float4(uv * 2.0 - 1.0, depth, 1.0)), 1e-8); +} - float3 ndc = clip.xyz / clip.w; - uv = ndc.xy * 0.5 + 0.5; - depth = ndc.z; +bool insideScreen(float2 uv) +{ return all(uv >= float2(0.0, 0.0)) && all(uv <= float2(1.0, 1.0)); } @@ -68,210 +92,378 @@ float screenEdgeFade(float2 uv) return fade * fade; } -float behindSurface(float3 position, float2 uv, float sceneDepth) +float luminance(float3 color) { - float3 scenePosition = worldPositionFromDepth(uv, sceneDepth); - return dot(position - scenePosition, normalize(scenePosition - gParams.cameraPosition.xyz)); + return dot(color, float3(0.2126, 0.7152, 0.0722)); } -[shader("fragment")] -float4 traceMain(VSOutput input) : SV_Target0 +// Interleaved gradient noise +float noiseAt(float2 pixel) { - float4 normalRoughness = gNormalRoughness.Sample(input.uv); + return frac(52.9829189 * frac(dot(pixel, float2(0.06711056, 0.00583715)))); +} - if (dot(normalRoughness.xyz, normalRoughness.xyz) < 1e-4) - { - return float4(0.0, 0.0, 0.0, kNoSurfaceDistance); - } +/// Walks R2 per frame +float2 noise2(float2 pixel) +{ + const float frame = gParams.cameraPosition.w; + return frac(float2(noiseAt(pixel), noiseAt(pixel.yx + float2(47.0, 17.0))) + frame * float2(0.7548776662, 0.5698402910)); +} - float depth = gDepth.Sample(input.uv).r; - if (isFarDepth(depth)) - { - return float4(0.0, 0.0, 0.0, kNoSurfaceDistance); - } +float ggxD(float NdotH, float alpha) +{ + const float a2 = alpha * alpha; + const float d = NdotH * NdotH * (a2 - 1.0) + 1.0; + return a2 / max(kPi * d * d, 1e-8); +} - float3 worldPosition = worldPositionFromDepth(input.uv, depth); - float4 nothing = float4(0.0, 0.0, 0.0, length(gParams.cameraPosition.xyz - worldPosition)); +float smithG1(float NdotX, float alpha) +{ + const float a2 = alpha * alpha; + return 2.0 * NdotX / max(NdotX + sqrt(a2 + (1.0 - a2) * NdotX * NdotX), 1e-8); +} - float3 N = normalize(normalRoughness.xyz); +/// Heitz 2018 visible normal sampling in a frame with z up +float3 sampleVisibleNormal(float3 view, float alpha, float2 u) +{ + const float3 stretched = normalize(float3(alpha * view.x, alpha * view.y, view.z)); + const float lengthSquared = stretched.x * stretched.x + stretched.y * stretched.y; + const float3 t1 = lengthSquared > 0.0 ? float3(-stretched.y, stretched.x, 0.0) * rsqrt(lengthSquared) : float3(1.0, 0.0, 0.0); + const float3 t2 = cross(stretched, t1); + const float r = sqrt(u.x); + const float phi = 2.0 * kPi * u.y; + const float p1 = r * cos(phi); + const float s = 0.5 * (1.0 + stretched.z); + const float p2 = (1.0 - s) * sqrt(max(1.0 - p1 * p1, 0.0)) + s * r * sin(phi); + const float3 h = p1 * t1 + p2 * t2 + sqrt(max(1.0 - p1 * p1 - p2 * p2, 0.0)) * stretched; + return normalize(float3(alpha * h.x, alpha * h.y, max(h.z, 0.0))); +} - if (normalRoughness.w > gParams.tuning.y) - { - return nothing; - } +/// Of a direction sampled through sampleVisibleNormal +float visibleNormalPdf(float NdotV, float NdotH, float alpha) +{ + return smithG1(NdotV, alpha) * ggxD(NdotH, alpha) / max(4.0 * NdotV, 1e-8); +} - float3 V = normalize(gParams.cameraPosition.xyz - worldPosition); - float3 R = reflect(-V, N); +struct SurfaceSample +{ + bool valid; + float3 position; + float3 normal; + float roughness; + float f0; + float depth; + float linearDepth; +}; - if (dot(R, V) > 0.999) +SurfaceSample surfaceAt(float2 uv) +{ + SurfaceSample surface; + surface.valid = false; + surface.position = float3(0.0, 0.0, 0.0); + surface.normal = float3(0.0, 0.0, 1.0); + surface.roughness = 1.0; + surface.f0 = 0.0; + surface.depth = gDepth.SampleLevel(uv, 0.0).r; + surface.linearDepth = 0.0; + + const float4 encoded = gNormalRoughness.SampleLevel(uv, 0.0); + if (dot(encoded.xyz, encoded.xyz) < 1e-4 || isFarDepth(surface.depth)) { - return nothing; + return surface; } + surface.valid = true; + surface.position = worldPositionFromDepth(uv, surface.depth); + surface.normal = normalize(encoded.xyz); + surface.roughness = encoded.w; + surface.f0 = sceneNormalF0(encoded); + surface.linearDepth = linearDepth(uv, surface.depth); + return surface; +} + +bool marchScreen(float3 origin, float3 direction, float2 jitter, out float2 hitUv, out float travelled) +{ + hitUv = float2(0.0, 0.0); + travelled = 0.0; - uint stepCount = uint(gParams.tuning.w); - float stride = gParams.marching.x; - float thickness = gParams.tuning.z; + uint width; + uint height; + gDepth.GetDimensions(width, height); + const float2 size = float2(float(width), float(height)); - // compositeMain starts from the same offset - float3 rayStart = worldPosition + N * stride * 0.5; - float3 rayPosition = rayStart; - float3 previousPosition = rayStart; + float reach = gParams.marching.x; + const float4 start = mul(gParams.viewProjection, float4(origin, 1.0)); + float4 end = mul(gParams.viewProjection, float4(origin + direction * reach, 1.0)); - for (uint i = 0; i < stepCount; ++i) + // Stops short of the near plane so 1 / w stays finite + const float minW = 1e-3; + if (end.w < minW) { - rayPosition += R * stride; + reach *= (start.w - minW) / max(start.w - end.w, 1e-6); + end = mul(gParams.viewProjection, float4(origin + direction * reach, 1.0)); + } - float2 sampleUv; - float sampleDepth; - if (!projectToScreen(rayPosition, sampleUv, sampleDepth)) - { - break; - } + const float k0 = 1.0 / start.w; + const float k1 = 1.0 / end.w; + const float2 uv0 = start.xy * k0 * 0.5 + 0.5; + const float2 uv1 = end.xy * k1 * 0.5 + 0.5; + + // At most one sample per pixel the ray crosses + const float pixels = max(abs(uv1.x - uv0.x) * size.x, abs(uv1.y - uv0.y) * size.y); + const uint steps = clamp(uint(pixels), 1u, uint(gParams.tuning.w)); - float sceneDepth = gDepth.SampleLevel(sampleUv, 0.0).r; - if (isFarDepth(sceneDepth)) + float previousT = 0.0; + float previousRayDepth = start.w; + for (uint i = 0u; i < steps; ++i) + { + // Squared to crowd samples near the surface + const float x = (float(i) + jitter.x) / float(steps); + const float t = x * x; + const float2 uv = lerp(uv0, uv1, t); + if (!insideScreen(uv)) { - previousPosition = rayPosition; - continue; + return false; } - float delta = behindSurface(rayPosition, sampleUv, sceneDepth); - if (delta > 0.0) + const float rayDepth = 1.0 / lerp(k0, k1, t); + const float depth = gDepth.SampleLevel(uv, 0.0).r; + if (!isFarDepth(depth)) { - if (delta > thickness) + const float sceneDepth = linearDepth(uv, depth); + const float behind = rayDepth - sceneDepth; + // Never thinner than one step + const float thickness = max(gParams.tuning.z * (1.0 + 0.025 * sceneDepth), 1.5 * abs(rayDepth - previousRayDepth)); + if (behind > 0.0 && behind < thickness) { - break; - } - - float3 lo = previousPosition; - float3 hi = rayPosition; - for (uint j = 0; j < 4; ++j) - { - float3 mid = (lo + hi) * 0.5; - float2 midUv; - float midDepth; - if (!projectToScreen(mid, midUv, midDepth)) + float lo = previousT; + float hi = t; + for (uint j = 0u; j < 5u; ++j) { - break; - } - - if (behindSurface(mid, midUv, gDepth.SampleLevel(midUv, 0.0).r) > 0.0) - { - hi = mid; - } - else - { - lo = mid; + const float mid = 0.5 * (lo + hi); + const float2 midUv = lerp(uv0, uv1, mid); + const float midDepth = gDepth.SampleLevel(midUv, 0.0).r; + if (!isFarDepth(midDepth) && 1.0 / lerp(k0, k1, mid) > linearDepth(midUv, midDepth)) + { + hi = mid; + } + else + { + lo = mid; + } } + hitUv = lerp(uv0, uv1, hi); + travelled = hi; + return true; } - - return float4(dot(hi - rayStart, R), 1.0, 0.0, nothing.a); } - - previousPosition = rayPosition; + previousT = t; + previousRayDepth = rayDepth; } - - return nothing; + return false; } -bool resolveHit(float2 uv, float3 worldPosition, float3 N, float3 R, float pixelDistance, float tolerance, - out float2 hitUv, out float coverage) +[shader("fragment")] +float4 traceMain(VSOutput input) : SV_Target0 { - hitUv = float2(0.0, 0.0); - coverage = 0.0; + const SurfaceSample surface = surfaceAt(input.uv); + if (!surface.valid || surface.roughness > gParams.tuning.y) + { + return float4(0.0, 0.0, 0.0, 0.0); + } - uint width; - uint height; - gReflection.GetDimensions(width, height); - float2 lowSize = float2(float(width), float(height)); + const float3 N = surface.normal; + const float3 V = normalize(gParams.cameraPosition.xyz - surface.position); + const float NdotV = saturate(dot(N, V)); + const float2 u = noise2(input.position.xy); - float2 texel = uv * lowSize - 0.5; - float2 base = floor(texel); - float2 fraction = texel - base; + float3 L = reflect(-V, N); + float pdf = 1.0; + if (surface.roughness > kMirrorRoughness) + { + const float3 up = abs(N.z) < 0.999 ? float3(0.0, 0.0, 1.0) : float3(1.0, 0.0, 0.0); + const float3 tangent = normalize(cross(up, N)); + const float3 bitangent = cross(N, tangent); + const float3 localView = float3(dot(V, tangent), dot(V, bitangent), NdotV); + const float alpha = surface.roughness * surface.roughness; + const float3 h = sampleVisibleNormal(localView, alpha, float2(u.x * kLobeTrim, u.y)); + const float3 H = normalize(tangent * h.x + bitangent * h.y + N * h.z); + L = reflect(-V, H); + pdf = visibleNormalPdf(max(NdotV, 1e-4), saturate(dot(N, H)), alpha); + } + if (dot(L, N) <= 0.0 || dot(L, V) > 0.999) + { + return float4(0.0, 0.0, 0.0, 0.0); + } - float3 rayStart = worldPosition + N * gParams.marching.x * 0.5; + const float3 origin = surface.position + N * max(0.002 * surface.linearDepth, 0.01); - float bilateralSum = 0.0; - float hitSum = 0.0; - float bestError = 1e30; - bool found = false; + float2 hitUv; + float travelled; + if (!marchScreen(origin, L, u, hitUv, travelled)) + { + return float4(0.0, 0.0, pdf, 0.0); + } - for (int y = 0; y < 2; ++y) + // A back face of what it hit means the ray went behind it + const float4 hitEncoded = gNormalRoughness.SampleLevel(hitUv, 0.0); + const float facing = dot(hitEncoded.xyz, hitEncoded.xyz) > 1e-4 ? -dot(normalize(hitEncoded.xyz), L) : 1.0; + const float confidence = screenEdgeFade(hitUv) * (1.0 - smoothstep(0.7, 1.0, travelled)) * step(0.0, facing); + return float4(hitUv, pdf, confidence); +} + +[shader("fragment")] +float4 resolveMain(VSOutput input) : SV_Target0 +{ + const SurfaceSample surface = surfaceAt(input.uv); + if (!surface.valid || surface.roughness > gParams.tuning.y) { - for (int x = 0; x < 2; ++x) - { - float2 offset = float2(float(x), float(y)); - float4 texelValue = gReflection.SampleLevel((base + offset + 0.5) / lowSize, 0.0); + return float4(0.0, 0.0, 0.0, 0.0); + } - float2 bilinear = lerp(1.0 - fraction, fraction, offset); - float bilateral = bilinear.x * bilinear.y * exp(-abs(texelValue.a - pixelDistance) / tolerance); - bilateralSum += bilateral; - hitSum += bilateral * texelValue.g; + uint width; + uint height; + gHits.GetDimensions(width, height); + const float2 texel = 1.0 / float2(float(width), float(height)); + + const float3 N = surface.normal; + const float3 V = normalize(gParams.cameraPosition.xyz - surface.position); + const float NdotV = max(saturate(dot(N, V)), 1e-4); + const float alpha = max(surface.roughness * surface.roughness, 1e-3); + + const bool mirror = surface.roughness <= kMirrorRoughness; + const uint taps = mirror ? 1u : kResolveTaps; + const float radius = lerp(1.0, 4.0, saturate(surface.roughness / max(gParams.tuning.y, 1e-4))); + const float angle = noise2(input.position.xy + 31.0).x * 2.0 * kPi; + const float2x2 rotation = float2x2(cos(angle), -sin(angle), sin(angle), cos(angle)); + + // Tonemapped so one bright hit does not speckle + float3 colorSum = float3(0.0, 0.0, 0.0); + float brdfSum = 0.0; + float confidenceSum = 0.0; + float geometrySum = 0.0; + for (uint i = 0u; i < taps; ++i) + { + const float2 uv = input.uv + mul(rotation, kResolvePattern[i]) * radius * texel; + const float4 hit = gHits.SampleLevel(uv, 0.0); - if (texelValue.g <= 0.0) + // A neighbour on another surface traced a ray this pixel could not have + float geometry = 1.0; + if (i > 0u) + { + const SurfaceSample neighbour = surfaceAt(uv); + if (!neighbour.valid) { continue; } + geometry = exp(-abs(neighbour.linearDepth - surface.linearDepth) / (0.02 * surface.linearDepth + 1e-3)) * + saturate(dot(neighbour.normal, N) * 4.0 - 3.0); + } + if (geometry <= 1e-3) + { + continue; + } + geometrySum += geometry; + if (hit.w <= 0.0) + { + continue; + } - float3 candidate = rayStart + R * texelValue.r; - float2 candidateUv; - float candidateDepth; - if (!projectToScreen(candidate, candidateUv, candidateDepth)) - { - continue; - } + const float hitDepth = gDepth.SampleLevel(hit.xy, 0.0).r; + const float3 L = normalize(worldPositionFromDepth(hit.xy, hitDepth) - surface.position); + const float NdotL = dot(N, L); + if (NdotL <= 0.0) + { + continue; + } - float sceneDepth = gDepth.SampleLevel(candidateUv, 0.0).r; - if (isFarDepth(sceneDepth)) - { - continue; - } + float brdf = 1.0; + if (!mirror) + { + const float3 H = normalize(V + L); + brdf = ggxD(saturate(dot(N, H)), alpha) * smithG1(NdotV, alpha) * smithG1(NdotL, alpha) / max(4.0 * NdotV, 1e-4) / + max(hit.z, 1e-4); + } + brdf *= geometry; - float error = abs(behindSurface(candidate, candidateUv, sceneDepth)); - if (error < bestError) - { - bestError = error; - hitUv = candidateUv; - found = true; - } + const float3 color = gSceneColor.SampleLevel(hit.xy, 0.0).rgb; + colorSum += brdf * hit.w * color / (1.0 + luminance(color)); + brdfSum += brdf * hit.w; + confidenceSum += geometry * hit.w; + } + + if (brdfSum <= 1e-6 || geometrySum <= 1e-6) + { + return float4(0.0, 0.0, 0.0, 0.0); + } + const float3 mapped = colorSum / brdfSum; + return float4(mapped / max(1.0 - luminance(mapped), 1e-3), saturate(confidenceSum / geometrySum)); +} + +[shader("fragment")] +float4 temporalMain(VSOutput input) : SV_Target0 +{ + uint width; + uint height; + gResolved.GetDimensions(width, height); + const float2 texel = 1.0 / float2(float(width), float(height)); + + const float4 current = gResolved.SampleLevel(input.uv, 0.0); + + float4 mean = float4(0.0, 0.0, 0.0, 0.0); + float4 meanSquared = float4(0.0, 0.0, 0.0, 0.0); + for (int y = -1; y <= 1; ++y) + { + for (int x = -1; x <= 1; ++x) + { + const float4 value = gResolved.SampleLevel(input.uv + float2(float(x), float(y)) * texel, 0.0); + mean += value; + meanSquared += value * value; } } + mean /= 9.0; + const float4 sigma = sqrt(max(meanSquared / 9.0 - mean * mean, float4(0.0, 0.0, 0.0, 0.0))); - if (!found || bestError > gParams.tuning.z) + const float4 motion = gMotion.SampleLevel(input.uv, 0.0); + const float2 previousUv = input.uv - (motion.w > 0.5 ? motion.xy : float2(0.0, 0.0)); + // Reactive pixels drop their history + if (gParams.marching.z < 0.5 || !insideScreen(previousUv) || motion.z > 0.5) { - return false; + return current; } - coverage = bilateralSum > 1e-4 ? hitSum / bilateralSum : 1.0; - return coverage > 0.0; + const float4 history = clamp(gHistory.SampleLevel(previousUv, 0.0), mean - 1.25 * sigma, mean + 1.25 * sigma); + return lerp(history, current, kTemporalBlend); +} + +/// Karis analytic fit of the split sum environment BRDF as scale and bias on F0 +float2 environmentBrdfApprox(float roughness, float NdotV) +{ + const float4 c0 = float4(-1.0, -0.0275, -0.572, 0.022); + const float4 c1 = float4(1.0, 0.0425, 1.04, -0.04); + const float4 r = roughness * c0 + c1; + const float a004 = min(r.x * r.x, exp2(-9.28 * NdotV)) * r.x + r.y; + return float2(-1.04, 1.04) * a004 + r.zw; } [shader("fragment")] float4 compositeMain(VSOutput input) : SV_Target0 { - float3 sceneColor = gSceneColor.Sample(input.uv).rgb; - float4 normalRoughness = gNormalRoughness.Sample(input.uv); - float depth = gDepth.Sample(input.uv).r; - - // fwidth before any early return - float3 worldPosition = worldPositionFromDepth(input.uv, depth); - float pixelDistance = length(gParams.cameraPosition.xyz - worldPosition); - float tolerance = clamp(2.0 * fwidth(pixelDistance), pixelDistance * 0.01, pixelDistance * 0.25); - - uint renderMode = uint(gParams.marching.y); + const float3 sceneColor = gSceneColor.SampleLevel(input.uv, 0.0).rgb; + const float4 encoded = gNormalRoughness.SampleLevel(input.uv, 0.0); + const uint renderMode = uint(gParams.marching.y); if (renderMode == 12) { - if (dot(normalRoughness.xyz, normalRoughness.xyz) < 1e-4) + if (dot(encoded.xyz, encoded.xyz) < 1e-4) { return float4(0.0, 0.0, 0.0, 1.0); } - return float4(normalize(normalRoughness.xyz) * 0.5 + 0.5, 1.0); + return float4(normalize(encoded.xyz) * 0.5 + 0.5, 1.0); } if (renderMode == 13) { - return float4(normalRoughness.www, 1.0); + return float4(encoded.www, 1.0); } // Voxel debug views 20 to 24 so a floor never reflects a legend colour @@ -280,58 +472,54 @@ float4 compositeMain(VSOutput input) : SV_Target0 return float4(sceneColor, 1.0); } - bool ssrOnly = (renderMode == 14); - float3 passthrough = ssrOnly ? float3(0.0, 0.0, 0.0) : sceneColor; - - if (dot(normalRoughness.xyz, normalRoughness.xyz) < 1e-4 || isFarDepth(depth)) - { - return float4(passthrough, 1.0); - } - - float3 N = normalize(normalRoughness.xyz); - float roughness = normalRoughness.w; - float maxRoughness = gParams.tuning.y; - if (roughness > maxRoughness) - { - return float4(passthrough, 1.0); - } - - float3 V = normalize(gParams.cameraPosition.xyz - worldPosition); - float3 R = reflect(-V, N); - if (dot(R, V) > 0.999) - { - return float4(passthrough, 1.0); - } + const bool ssrOnly = (renderMode == 14); + const float3 passthrough = ssrOnly ? float3(0.0, 0.0, 0.0) : sceneColor; - float2 hitUv; - float coverage; - if (!resolveHit(input.uv, worldPosition, N, R, pixelDistance, tolerance, hitUv, coverage)) + const SurfaceSample surface = surfaceAt(input.uv); + const float maxRoughness = gParams.tuning.y; + if (!surface.valid || surface.roughness > maxRoughness) { return float4(passthrough, 1.0); } - float3 hitNormal = gNormalRoughness.SampleLevel(hitUv, 0.0).xyz; - float facing = dot(hitNormal, hitNormal) > 1e-4 ? saturate(-dot(normalize(hitNormal), R)) : 0.0; - - float confidence = coverage * screenEdgeFade(hitUv) * step(0.01, facing); - if (confidence <= 0.0) + // Depth aware bilinear + uint width; + uint height; + gTemporal.GetDimensions(width, height); + const float2 lowSize = float2(float(width), float(height)); + const float2 position = input.uv * lowSize - 0.5; + const float2 base = floor(position); + const float2 fraction = position - base; + + float4 reflection = float4(0.0, 0.0, 0.0, 0.0); + float weightSum = 0.0; + for (int y = 0; y < 2; ++y) { - return float4(passthrough, 1.0); + for (int x = 0; x < 2; ++x) + { + const float2 offset = float2(float(x), float(y)); + const float2 uv = (base + offset + 0.5) / lowSize; + const float2 bilinear = lerp(1.0 - fraction, fraction, offset); + const float depth = gDepth.SampleLevel(uv, 0.0).r; + const float texelDepth = isFarDepth(depth) ? 1e6 : linearDepth(uv, depth); + const float weight = bilinear.x * bilinear.y * + exp(-abs(texelDepth - surface.linearDepth) / (0.02 * surface.linearDepth + 1e-3)) + 1e-5 * bilinear.x * bilinear.y; + reflection += gTemporal.SampleLevel(uv, 0.0) * weight; + weightSum += weight; + } } - - float3 hitColor = gSceneColor.SampleLevel(hitUv, 0.0).rgb; + reflection /= max(weightSum, 1e-6); if (ssrOnly) { - return float4(hitColor * confidence, 1.0); + return float4(reflection.rgb * reflection.a, 1.0); } - float NdotV = saturate(dot(N, V)); - float fresnel = 0.04 + 0.96 * pow(1.0 - NdotV, 5.0); - - float roughnessWeight = saturate(1.0 - roughness / max(maxRoughness, 1e-4)); - - float weight = confidence * fresnel * roughnessWeight * gParams.tuning.x; + const float3 V = normalize(gParams.cameraPosition.xyz - surface.position); + const float2 split = environmentBrdfApprox(surface.roughness, saturate(dot(surface.normal, V))); + const float fresnel = surface.f0 * split.x + split.y; + const float roughnessFade = 1.0 - smoothstep(0.6 * maxRoughness, maxRoughness, surface.roughness); - return float4(sceneColor + hitColor * weight, 1.0); + const float weight = reflection.a * fresnel * roughnessFade * gParams.tuning.x; + return float4(sceneColor + reflection.rgb * weight, 1.0); } diff --git a/engine/assets/shaders/voxel.slang b/engine/assets/shaders/voxel.slang index 7c561a5e4..05e8ed857 100644 --- a/engine/assets/shaders/voxel.slang +++ b/engine/assets/shaders/voxel.slang @@ -20,6 +20,27 @@ static const uint kDrawCells = 1u; [[vk::binding(7, 1)]] StructuredBuffer gVoxelCells; + +/// x of entry 0 is the count then per region min xyz with w the record and max xyz +/// Voxel space boxes the storage published this frame changed +[[vk::binding(13, 1)]] +StructuredBuffer gFreshRegions; + +/// Near a box carved this frame +bool freshlyChanged(uint recordIndex, int3 voxel) +{ + const uint count = uint(gFreshRegions[0].x); + for (uint i = 0u; i < count; ++i) + { + const float4 low = gFreshRegions[1u + i * 2u]; + const float4 high = gFreshRegions[2u + i * 2u]; + if (uint(low.w) == recordIndex && all(float3(voxel) >= low.xyz - 1.0) && all(float3(voxel) <= high.xyz + 1.0)) + { + return true; + } + } + return false; +} [[vk::binding(8, 1)]] StructuredBuffer gVoxelCellArgs; @@ -321,6 +342,10 @@ bool shadeHit(float4 pixel, VoxelInstance instance, float3 origin, float3 direct const float4 previousClip = mul(gCamera.previousViewProjection, previousWorld); output.motion = float4((clip.xy / clip.w - previousClip.xy / previousClip.w) * 0.5, 0.0, 1.0); } + if (freshlyChanged(instance.recordIndex, hit.voxel)) + { + output.motion.z = 1.0; + } if (hit.axis >= 0) { @@ -454,31 +479,15 @@ QuadVSOutput quadCorner(uint2 cell, uint packedQuad, uint corner) const VoxelInstance instance = gVoxelInstances[instanceIndex]; const uint direction = packedQuad & 7u; - const uint slice = (packedQuad >> 3) & 7u; - const uint u0 = (packedQuad >> 6) & 7u; - const uint v0 = (packedQuad >> 9) & 7u; - const uint width = ((packedQuad >> 12) & 7u) + 1u; - const uint height = ((packedQuad >> 15) & 7u) + 1u; - const uint brickInCell = (packedQuad >> 18) & 63u; - const uint axis = direction >> 1; - const bool positive = (direction & 1u) == 0u; - const uint3 brick = unpackCell(cell.y) * kCoarseBricks + uint3(brickInCell & 3u, (brickInCell >> 2) & 3u, brickInCell >> 4); - - const float cornerU = (corner == 1u || corner == 2u) ? float(width) : 0.0; - const float cornerV = corner >= 2u ? float(height) : 0.0; - - float3 local = float3(brick * kBrickDim); - local[axis] += float(slice) + (positive ? 1.0 : 0.0); - local[quadAxisU(axis)] += float(u0) + cornerU; - local[quadAxisV(axis)] += float(v0) + cornerV; + const float3 local = quadCornerLocal(unpackCell(cell.y), packedQuad, corner); const float4 world = mul(instance.voxelToWorld, float4(local, 1.0)); QuadVSOutput output; output.position = mul(gCamera.jitteredViewProjection, world); output.local = local; - output.local[quadAxisU(axis)] += cornerU > 0.0 ? -kQuadInset : kQuadInset; - output.local[quadAxisV(axis)] += cornerV > 0.0 ? -kQuadInset : kQuadInset; + output.local[quadAxisU(axis)] += (corner == 1u || corner == 2u) ? -kQuadInset : kQuadInset; + output.local[quadAxisV(axis)] += corner >= 2u ? -kQuadInset : kQuadInset; output.instanceIndex = instanceIndex; output.direction = direction; return output; @@ -529,6 +538,7 @@ FSOutput shadeQuad(QuadVSOutput input) hit.coarseSteps = 0u; hit.brickSteps = 0u; hit.subSteps = 0u; + hit.level = 0u; FSOutput output; float depth = input.position.z; diff --git a/engine/assets/shaders/voxel_cells.slang b/engine/assets/shaders/voxel_cells.slang index c597bdacd..5844de6c9 100644 --- a/engine/assets/shaders/voxel_cells.slang +++ b/engine/assets/shaders/voxel_cells.slang @@ -438,7 +438,7 @@ uint meshBrick(uint volume, int3 brick, float3 camera, uint occupancy[16], uint step[axis] = positive ? 1 : -1; const uint neighbourEntry = brickEntryAt(volume, brick + step); uint neighbour[16]; - loadBrickSlices(neighbourEntry, neighbour); + loadSolidSlices(volume, brick + step, neighbourEntry, neighbour); for (uint slice = 0u; slice < 8u; ++slice) { @@ -512,7 +512,7 @@ void meshMain(uint3 group : SV_GroupID, uint3 local : SV_GroupThreadID) } uint occupancy[16]; - loadBrickSlices(brickEntry, occupancy); + loadSolidSlices(instance.recordIndex, brick, brickEntry, occupancy); const float3 camera = mul(instance.worldToVoxel, float4(gCull.cameraNear.xyz, 1.0)).xyz; const uint count = meshBrick(instance.recordIndex, brick, camera, occupancy, entryIndex, brickInCell, kNoBucket); diff --git a/engine/assets/shaders/voxel_dda.slang b/engine/assets/shaders/voxel_dda.slang index c26cea3a5..5b1ae92cb 100644 --- a/engine/assets/shaders/voxel_dda.slang +++ b/engine/assets/shaders/voxel_dda.slang @@ -1,5 +1,7 @@ // Twin of toast/voxel/ray_march.cpp with layout from toast/voxel/gpu_layout.hpp so change them together +__exported import glass_common; + /// Mirrors voxel::gpu::VolumeRecord struct VolumeRecord { @@ -13,8 +15,14 @@ struct VolumeRecord uint coarseOffset; uint paletteOffset; float maxEmissive; + /// Coarse buffer word of one bit per brick holding glass or kNoGlass + uint glassOffset; }; +static const uint kNoGlass = 0xFFFFFFFFu; +/// voxel::k_entry_transparent in palette word 2 +static const uint kEntryTransparent = 1u; + /// Mirrors renderer::VoxelInstanceGpu struct VoxelInstance { @@ -123,6 +131,27 @@ uint quadAxisV(uint axis) return axis == 2u ? 1u : 2u; } +/// Voxel space corner 0 to 3 of a packQuad quad going round its plane +float3 quadCornerLocal(uint3 cell, uint packedQuad, uint corner) +{ + const uint direction = packedQuad & 7u; + const uint slice = (packedQuad >> 3) & 7u; + const uint u0 = (packedQuad >> 6) & 7u; + const uint v0 = (packedQuad >> 9) & 7u; + const uint width = ((packedQuad >> 12) & 7u) + 1u; + const uint height = ((packedQuad >> 15) & 7u) + 1u; + const uint brickInCell = (packedQuad >> 18) & 63u; + + const uint axis = direction >> 1; + const uint3 brick = cell * kCoarseBricks + uint3(brickInCell & 3u, (brickInCell >> 2) & 3u, brickInCell >> 4); + + float3 local = float3(brick * kBrickDim); + local[axis] += float(slice) + ((direction & 1u) == 0u ? 1.0 : 0.0); + local[quadAxisU(axis)] += float(u0) + ((corner == 1u || corner == 2u) ? float(width) : 0.0); + local[quadAxisV(axis)] += float(v0) + (corner >= 2u ? float(height) : 0.0); + return local; +} + /// Ten bits per axis uint packCell(uint3 cell) { @@ -183,24 +212,32 @@ struct RayHit uint coarseSteps; uint brickSteps; uint subSteps; + + /// Pyramid level the hit was accepted at where 0 is the voxel + uint level; }; +uint gridEntry(VolumeRecord record, uint3 brick) +{ + return gVolumeGrids[record.gridOffset + brick.x + brick.y * record.brickDimsX + brick.z * (record.brickDimsX * record.brickDimsY)]; +} + uint gridEntry(uint volume, uint3 brick) { - const uint dimsX = gVolumeRecords[volume].brickDimsX; - const uint dimsY = gVolumeRecords[volume].brickDimsY; - return gVolumeGrids[gVolumeRecords[volume].gridOffset + brick.x + brick.y * dimsX + brick.z * (dimsX * dimsY)]; + return gridEntry(gVolumeRecords[volume], brick); +} + +bool coarseSolid(VolumeRecord record, int3 cell) +{ + if (any(uint3(cell) >= uint3(record.coarseDimsX, record.coarseDimsY, record.coarseDimsZ))) return false; + + const uint index = uint(cell.x) + uint(cell.y) * record.coarseDimsX + uint(cell.z) * (record.coarseDimsX * record.coarseDimsY); + return ((gVolumeCoarse[record.coarseOffset + (index >> 5)] >> (index & 31u)) & 1u) != 0; } bool coarseSolid(uint volume, int3 cell) { - const uint dimsX = gVolumeRecords[volume].coarseDimsX; - const uint dimsY = gVolumeRecords[volume].coarseDimsY; - const uint dimsZ = gVolumeRecords[volume].coarseDimsZ; - if (any(uint3(cell) >= uint3(dimsX, dimsY, dimsZ))) return false; - - const uint index = uint(cell.x) + uint(cell.y) * dimsX + uint(cell.z) * (dimsX * dimsY); - return ((gVolumeCoarse[gVolumeRecords[volume].coarseOffset + (index >> 5)] >> (index & 31u)) & 1u) != 0; + return coarseSolid(gVolumeRecords[volume], cell); } uint pooledMaterial(uint slot, uint3 local) @@ -210,20 +247,9 @@ uint pooledMaterial(uint slot, uint3 local) return (word >> ((index & 3u) << 3)) & 0xFFu; } -/// Get the palette ID for a voxel -uint voxelMaterialAt(uint volume, int3 voxel) +/// Palette index of a voxel inside the brick behind @p entry +uint brickMaterialAt(uint entry, int3 voxel) { - const uint dimsX = gVolumeRecords[volume].brickDimsX; - const uint dimsY = gVolumeRecords[volume].brickDimsY; - const uint dimsZ = gVolumeRecords[volume].brickDimsZ; - - if (any(voxel < 0) || voxel.x >= int(dimsX * kBrickDim) || voxel.y >= int(dimsY * kBrickDim) || voxel.z >= int(dimsZ * kBrickDim)) - { - return 0u; - } - - // Shifts/masks instead of modulo arithmetic - const uint entry = gridEntry(volume, uint3(voxel) >> 3); const uint tag = entry & 3u; if (tag == kTagEmpty) { @@ -236,6 +262,121 @@ uint voxelMaterialAt(uint volume, int3 voxel) return pooledMaterial(entry >> 2, uint3(voxel) & 7u); } +uint voxelMaterialAt(VolumeRecord record, int3 voxel) +{ + if (any(voxel < 0) || voxel.x >= int(record.brickDimsX * kBrickDim) || voxel.y >= int(record.brickDimsY * kBrickDim) || + voxel.z >= int(record.brickDimsZ * kBrickDim)) + { + return 0u; + } + return brickMaterialAt(gridEntry(record, uint3(voxel) >> 3), voxel); +} + +uint voxelMaterialAt(uint volume, int3 voxel) +{ + return voxelMaterialAt(gVolumeRecords[volume], voxel); +} + +bool volumeHasGlass(uint volume) +{ + return gVolumeRecords[volume].glassOffset != kNoGlass; +} + +/// Mirrors voxel::isGlass +bool glassEntry(VolumeRecord record, uint index) +{ + const uint word2 = gPalettes[(record.paletteOffset + index) * 4u + 2u]; + return index != 0u && (word2 & kEntryTransparent) != 0u && ((word2 >> 16) & 0xFFu) < 255u; +} + +bool glassEntry(uint volume, uint index) +{ + return glassEntry(gVolumeRecords[volume], index); +} + +float3 srgbToLinear(float3 value) +{ + return lerp(pow((value + 0.055) / 1.055, 2.4), value / 12.92, step(value, float3(0.04045, 0.04045, 0.04045))); +} + +/// Below this a filtered ray counts as blocked +static const float kTintedOpaque = 0.01; + +bool glassBrick(VolumeRecord record, int3 brick) +{ + const uint3 dims = uint3(record.brickDimsX, record.brickDimsY, record.brickDimsZ); + if (record.glassOffset == kNoGlass || any(brick < 0) || any(uint3(brick) >= dims)) + { + return false; + } + const uint index = uint(brick.x) + uint(brick.y) * dims.x + uint(brick.z) * (dims.x * dims.y); + return ((gVolumeCoarse[record.glassOffset + (index >> 5)] >> (index & 31u)) & 1u) != 0u; +} + +bool glassBrick(uint volume, int3 brick) +{ + return glassBrick(gVolumeRecords[volume], brick); +} + +interface IGlassPass +{ + /// False once no light passes + [mutating] + bool pass(VolumeRecord record, uint material, float tEnter, float tExit, float voxelsPerT); + + /// Coarse floors would take glass as opaque + bool filters(); +} + +struct SkipGlass : IGlassPass +{ + [mutating] + bool pass(VolumeRecord record, uint material, float tEnter, float tExit, float voxelsPerT) + { + return true; + } + + bool filters() + { + return false; + } +} + +/// Mirrors compositeGlass in voxel_glass.slang at head on Fresnel +struct TintGlass : IGlassPass +{ + float3 transmittance; + /// End of the last entry so a contiguous one joins its run + float runEnd; + + [mutating] + bool pass(VolumeRecord record, uint material, float tEnter, float tExit, float voxelsPerT) + { + const uint base = (record.paletteOffset + material) * 4u; + const uint word0 = gPalettes[base]; + const uint word2 = gPalettes[base + 2u]; + const float alpha = float((word2 >> 16) & 0xFFu) / 255.0; + const float roughness = float(word0 >> 24) / 255.0; + const float3 albedo = srgbToLinear(float3(word0 & 0xFFu, (word0 >> 8) & 0xFFu, (word0 >> 16) & 0xFFu) / 255.0); + transmittance *= glassPathFilter(albedo, alpha, (tExit - tEnter) * voxelsPerT); + + // voxel::iorOf + const uint iorByte = word2 >> 24; + const float f0 = iorF0(iorByte == 0u ? kDefaultIor : 1.0 + float(iorByte) / 100.0); + if (abs(tEnter - runEnd) * voxelsPerT > 1e-3) + { + transmittance *= (1.0 - f0) * (1.0 - glassScatter(alpha, roughness)); + } + runEnd = tExit; + return max(transmittance.x, max(transmittance.y, transmittance.z)) > kTintedOpaque; + } + + bool filters() + { + return true; + } +} + float marchTieSlack(float t) { return kMarchTieEpsilon * max(1.0, abs(t)); @@ -248,148 +389,262 @@ void leaveCell(float3 origin, float3 inverse, float3 stepSign, int3 cellMin, int plane.x = float(cellMin.x) + (stepSign.x > 0.0 ? float(size) : 0.0); plane.y = float(cellMin.y) + (stepSign.y > 0.0 ? float(size) : 0.0); plane.z = float(cellMin.z) + (stepSign.z > 0.0 ? float(size) : 0.0); - + float3 planeTimes; planeTimes.x = stepSign.x != 0.0 ? (plane.x - origin.x) * inverse.x : kFloatMax; planeTimes.y = stepSign.y != 0.0 ? (plane.y - origin.y) * inverse.y : kFloatMax; planeTimes.z = stepSign.z != 0.0 ? (plane.z - origin.z) * inverse.z : kFloatMax; - + float next = min(min(planeTimes.x, planeTimes.y), planeTimes.z); int nextAxis = planeTimes.x == next ? 0 : (planeTimes.y == next ? 1 : 2); - + float slackNext = next + marchTieSlack(next); crossed = (uint(planeTimes.x <= slackNext)) | (uint(planeTimes.y <= slackNext) << 1) | (uint(planeTimes.z <= slackNext) << 2); - - t = max(next, t); + + // A plane at or behind t stalls the ray until kMaxMarchSteps + t = next > t ? next : t + marchTieSlack(t); axis = nextAxis; } -RayHit marchRay(uint volume, float3 origin, float3 direction, float tMin, float tMax) +RayHit marchRay(uint volume, float3 origin, float3 direction, float tMin, float tMax, bool throughGlass = true) { const int3 dims = int3(gVolumeRecords[volume].brickDimsX, gVolumeRecords[volume].brickDimsY, gVolumeRecords[volume].brickDimsZ) * int(kBrickDim); - return marchRayBox(volume, origin, direction, tMin, tMax, int3(0, 0, 0), dims); + return marchRayBox(volume, origin, direction, tMin, tMax, int3(0, 0, 0), dims, throughGlass); } -/// The box must lie inside the volume -/// Cells are chosen by integer index not float position since nudging positions skips voxels under grazing rays -RayHit marchRayBox(uint volume, float3 origin, float3 direction, float tMin, float tMax, int3 boxMin, int3 boxMax) +struct MarchState { - RayHit result; - result.hit = false; - result.t = 0.0; - result.voxel = int3(0, 0, 0); - result.axis = -1; - result.material = 0; - result.steps = 0; - result.coarseSteps = 0; - result.brickSteps = 0; - result.subSteps = 0; + float t; + float tExit; + float3 inverse; + float3 stepSign; + /// Axis of the last plane crossed and -1 before any + int axis; + /// Axes crossed at t within the tie slack + uint crossed; +}; - float tExit = kFloatMax; - float3 entryTimes = float3(-kFloatMax, -kFloatMax, -kFloatMax); - float3 inverse = float3(0.0, 0.0, 0.0); - float3 stepSign = float3(0.0, 0.0, 0.0); +/// False when the ray misses the box or meets it only past tMax +bool beginMarch(float3 origin, float3 direction, float tMin, float tMax, int3 boxMin, int3 boxMax, out MarchState march) +{ + march.t = 0.0; + march.tExit = kFloatMax; + march.inverse = float3(0.0, 0.0, 0.0); + march.stepSign = float3(0.0, 0.0, 0.0); + march.axis = -1; + march.crossed = 0u; + float3 entryTimes = float3(-kFloatMax, -kFloatMax, -kFloatMax); for (int a = 0; a < 3; ++a) { if (direction[a] == 0.0) { if (origin[a] < float(boxMin[a]) || origin[a] > float(boxMax[a])) { - return result; + return false; } continue; } - inverse[a] = 1.0 / direction[a]; - stepSign[a] = direction[a] > 0.0 ? 1.0 : -1.0; + march.inverse[a] = 1.0 / direction[a]; + march.stepSign[a] = direction[a] > 0.0 ? 1.0 : -1.0; - const float t0 = (float(boxMin[a]) - origin[a]) * inverse[a]; - const float t1 = (float(boxMax[a]) - origin[a]) * inverse[a]; + const float t0 = (float(boxMin[a]) - origin[a]) * march.inverse[a]; + const float t1 = (float(boxMax[a]) - origin[a]) * march.inverse[a]; entryTimes[a] = min(t0, t1); - tExit = min(tExit, max(t0, t1)); + march.tExit = min(march.tExit, max(t0, t1)); } - float t = -kFloatMax; - int axis = -1; + march.t = -kFloatMax; for (int a = 0; a < 3; ++a) { - if (entryTimes[a] > t) + if (entryTimes[a] > march.t) { - t = entryTimes[a]; - axis = a; + march.t = entryTimes[a]; + march.axis = a; } } - uint crossed = 0; - if (t < tMin) + if (march.t < tMin) { - t = tMin; - axis = -1; + march.t = tMin; + march.axis = -1; } else { for (int a = 0; a < 3; ++a) { - if (entryTimes[a] >= t - marchTieSlack(t)) + if (entryTimes[a] >= march.t - marchTieSlack(march.t)) { - crossed |= 1u << uint(a); + march.crossed |= 1u << uint(a); } } } - tExit = min(tExit, tMax); - if (t > tExit) + march.tExit = min(march.tExit, tMax); + return march.t <= march.tExit; +} + +/// Crossed axes come from the plane not the rounded position +int3 marchVoxel(float3 origin, float3 direction, MarchState march) +{ + const float3 position = origin + direction * march.t; + + int3 voxel = int3(floor(position)); + if ((march.crossed & 1u) != 0) voxel.x = int(round(position.x)) - (march.stepSign.x < 0.0 ? 1 : 0); + if ((march.crossed & 2u) != 0) voxel.y = int(round(position.y)) - (march.stepSign.y < 0.0 ? 1 : 0); + if ((march.crossed & 4u) != 0) voxel.z = int(round(position.z)) - (march.stepSign.z < 0.0 ? 1 : 0); + return voxel; +} + +void marchLeave(float3 origin, inout MarchState march, int3 cellMin, int size) +{ + leaveCell(origin, march.inverse, march.stepSign, cellMin, size, march.t, march.axis, march.crossed); +} + +/// Coarsest pyramid level a hit may be accepted at for a given t with 0 the voxel and 4 the coarse cell +interface IHitFloor +{ + uint level(float t); +} + +struct ExactFloor : IHitFloor +{ + uint level(float t) + { + return 0u; + } +} + +/// Accepts a cell once it fits the cone footprint +struct ConeFloor : IHitFloor +{ + float tMin; + /// Cone slope times voxels per unit t + float slope; + + uint level(float t) + { + const float footprint = max(t - tMin, 0.0) * slope; + return footprint >= float(kCoarseVoxels) ? 4u : (footprint >= 8.0 ? 3u : (footprint >= 4.0 ? 2u : (footprint >= 2.0 ? 1u : 0u))); + } +} + +/// The box must lie inside the volume +/// Integer cell index since nudged positions skip voxels under grazing rays +/// Passes through glass unlike toast/voxel/ray_march.cpp +RayHit marchCore(uint volume, float3 origin, float3 direction, float tMin, float tMax, + int3 boxMin, int3 boxMax, bool throughGlass, Floor hitFloor, + inout Glass glass) +{ + const VolumeRecord record = gVolumeRecords[volume]; + const bool skipGlass = throughGlass && record.glassOffset != kNoGlass; + const bool filterGlass = skipGlass && glass.filters(); + const float voxelsPerT = length(direction); + + RayHit result; + result.hit = false; + result.t = 0.0; + result.voxel = int3(0, 0, 0); + result.axis = -1; + result.material = 0; + result.steps = 0; + result.coarseSteps = 0; + result.brickSteps = 0; + result.subSteps = 0; + result.level = 0; + + MarchState march; + if (!beginMarch(origin, direction, tMin, tMax, boxMin, boxMax, march)) { return result; } - for (uint step = 0; step < kMaxMarchSteps; ++step) + const int3 extent = boxMax - boxMin; + const uint stepLimit = min(kMaxMarchSteps, uint(extent.x + extent.y + extent.z) + 8u); + for (uint step = 0; step < stepLimit; ++step) { result.steps = step + 1; - if (t > tExit) return result; - - const float3 position = origin + direction * t; - - // Unrolled scalar assignment completely bypasses vector register allocation - int3 voxel = int3(floor(position)); - if ((crossed & 1u) != 0) voxel.x = int(round(position.x)) - (stepSign.x < 0.0 ? 1 : 0); - if ((crossed & 2u) != 0) voxel.y = int(round(position.y)) - (stepSign.y < 0.0 ? 1 : 0); - if ((crossed & 4u) != 0) voxel.z = int(round(position.z)) - (stepSign.z < 0.0 ? 1 : 0); + if (march.t > march.tExit) return result; + const int3 voxel = marchVoxel(origin, direction, march); if (any(voxel < boxMin) || any(voxel >= boxMax)) return result; - const int3 coarse = voxel >> 5; - if (!coarseSolid(volume, coarse)) + uint floorLevel = hitFloor.level(march.t); + if (filterGlass) + { + floorLevel = min(floorLevel, 3u); + } + + const int3 coarse = voxel >> 5; + if (!coarseSolid(record, coarse)) { ++result.coarseSteps; - leaveCell(origin, inverse, stepSign, coarse << 5, kCoarseVoxels, t, axis, crossed); + marchLeave(origin, march, coarse << 5, kCoarseVoxels); continue; } + if (floorLevel >= 4u) + { + result.hit = true; + result.t = march.t; + result.voxel = voxel; + result.axis = march.axis; + result.level = 4u; + return result; + } const int3 brick = voxel >> 3; - const uint entry = gridEntry(volume, uint3(brick)); + const uint entry = gridEntry(record, uint3(brick)); const uint tag = entry & 3u; - + if (tag == kTagEmpty) { ++result.brickSteps; - leaveCell(origin, inverse, stepSign, brick << 3, int(kBrickDim), t, axis, crossed); + marchLeave(origin, march, brick << 3, int(kBrickDim)); continue; } - + + // A uniform brick is full so its hit is exact at any floor if (tag == kTagUniform) { + if (skipGlass && glassEntry(record, entry >> 2)) + { + ++result.brickSteps; + const float entered = march.t; + marchLeave(origin, march, brick << 3, int(kBrickDim)); + if (glass.pass(record, entry >> 2, entered, min(march.t, march.tExit), voxelsPerT)) + { + continue; + } + result.hit = true; + result.t = entered; + result.voxel = voxel; + result.material = entry >> 2; + return result; + } result.hit = true; - result.t = t; + result.t = march.t; result.voxel = voxel; - result.axis = axis; + result.axis = march.axis; result.material = entry >> 2; return result; } - + if (filterGlass && floorLevel > 0u && glassBrick(record, brick)) + { + floorLevel = 0u; + } + if (floorLevel >= 3u) + { + result.hit = true; + result.t = march.t; + result.voxel = voxel; + result.axis = march.axis; + result.level = 3u; + return result; + } + // Pool words are 4x4x4 sub-bricks as gpu::packOccupancyWords lays them out const uint slot = entry >> 2; const uint3 local = uint3(voxel & 7u); @@ -399,32 +654,223 @@ RayHit marchRayBox(uint volume, float3 origin, float3 direction, float tMin, flo if ((subLow | subHigh) == 0u) { ++result.subSteps; - leaveCell(origin, inverse, stepSign, voxel & int3(~3, ~3, ~3), 4, t, axis, crossed); + marchLeave(origin, march, voxel & int3(~3, ~3, ~3), 4); continue; } + if (floorLevel >= 2u) + { + result.hit = true; + result.t = march.t; + result.voxel = voxel; + result.axis = march.axis; + result.level = 2u; + return result; + } // A 2x2x2 block never straddles the low and high word const uint subWord = (local.z & 2u) != 0u ? subHigh : subLow; if (((subWord >> ((local.x & 2u) | ((local.y & 2u) << 2))) & 0x00330033u) == 0u) { ++result.subSteps; - leaveCell(origin, inverse, stepSign, voxel & int3(~1, ~1, ~1), 2, t, axis, crossed); + marchLeave(origin, march, voxel & int3(~1, ~1, ~1), 2); continue; } + if (floorLevel >= 1u) + { + result.hit = true; + result.t = march.t; + result.voxel = voxel; + result.axis = march.axis; + result.level = 1u; + return result; + } if (((subWord >> ((local.x & 3u) | ((local.y & 3u) << 2) | ((local.z & 1u) << 4))) & 1u) != 0u) + { + const uint material = pooledMaterial(slot, local); + const float entered = march.t; + const int enteredAxis = march.axis; + const bool opaque = !skipGlass || !glassEntry(record, material); + if (!opaque) + { + marchLeave(origin, march, voxel, 1); + } + if (opaque || !glass.pass(record, material, entered, min(march.t, march.tExit), voxelsPerT)) + { + result.hit = true; + result.t = entered; + result.voxel = voxel; + result.axis = enteredAxis; + result.material = material; + return result; + } + continue; + } + + marchLeave(origin, march, voxel, 1); + } + + result.steps = kMaxMarchSteps; + return result; +} + +RayHit marchRayBox(uint volume, float3 origin, float3 direction, float tMin, float tMax, int3 boxMin, int3 boxMax, bool throughGlass = true) +{ + ExactFloor exact; + SkipGlass skip; + return marchCore(volume, origin, direction, tMin, tMax, boxMin, boxMax, throughGlass, exact, skip); +} + +/// Sparse tier for light visibility and specular rays +/// Hits at the coarsest occupied cell within the cone so never later than the dense hit +/// With coneSlope 0 it is the dense tier +RayHit marchSparse(uint volume, float3 origin, float3 direction, float tMin, float tMax, float coneSlope, bool throughGlass = true) +{ + const int3 dims = int3(gVolumeRecords[volume].brickDimsX, gVolumeRecords[volume].brickDimsY, gVolumeRecords[volume].brickDimsZ) * int(kBrickDim); + ConeFloor cone; + cone.tMin = tMin; + cone.slope = coneSlope * length(direction); + SkipGlass skip; + return marchCore(volume, origin, direction, tMin, tMax, int3(0, 0, 0), dims, throughGlass, cone, skip); +} + +/// marchSparse filtering by glass colour +RayHit marchSparseTinted(uint volume, float3 origin, float3 direction, float tMin, float tMax, float coneSlope, + inout float3 transmittance) +{ + const int3 dims = int3(gVolumeRecords[volume].brickDimsX, gVolumeRecords[volume].brickDimsY, gVolumeRecords[volume].brickDimsZ) * int(kBrickDim); + ConeFloor cone; + cone.tMin = tMin; + cone.slope = coneSlope * length(direction); + TintGlass tint; + tint.transmittance = transmittance; + tint.runEnd = -kFloatMax; + const RayHit hit = marchCore(volume, origin, direction, tMin, tMax, int3(0, 0, 0), dims, true, cone, tint); + transmittance = tint.transmittance; + return hit; +} + +/// Levels of the occupancy pyramid the super-sparse tier samples +static const uint kLevelCoarse = 4u; + +/// Glass counts only at the voxel and uniform bricks +bool occupiedAtLevel(VolumeRecord record, int3 voxel, uint level, bool skipGlass) +{ + if (!coarseSolid(record, voxel >> 5)) + { + return false; + } + if (level == kLevelCoarse) + { + return true; + } + + const uint entry = gridEntry(record, uint3(voxel >> 3)); + const uint tag = entry & 3u; + if (tag == kTagEmpty) + { + return false; + } + if (tag == kTagUniform) + { + return !skipGlass || !glassEntry(record, entry >> 2); + } + if (level == 3u) + { + return true; + } + + const uint slot = entry >> 2; + const uint3 local = uint3(voxel & 7u); + const uint subBase = (slot << 4) + (((local.x >> 2) | ((local.y >> 2) << 1) | ((local.z >> 2) << 2)) << 1); + const uint subLow = gBrickOccupancy[subBase]; + const uint subHigh = gBrickOccupancy[subBase + 1u]; + if (level == 2u) + { + return (subLow | subHigh) != 0u; + } + + const uint subWord = (local.z & 2u) != 0u ? subHigh : subLow; + if (level == 1u) + { + return ((subWord >> ((local.x & 2u) | ((local.y & 2u) << 2))) & 0x00330033u) != 0u; + } + + if (((subWord >> ((local.x & 3u) | ((local.y & 3u) << 2) | ((local.z & 1u) << 4))) & 1u) == 0u) + { + return false; + } + return !skipGlass || !glassEntry(record, pooledMaterial(slot, local)); +} + +/// Super-sparse tier for ambient and long occlusion rays +/// Five fixed samples firstStep voxels apart then three doubling ones each tested at the level its step spans +RayHit marchSuperSparse(uint volume, float3 origin, float3 direction, float tMin, float tMax, float firstStep, bool throughGlass = true) +{ + const VolumeRecord record = gVolumeRecords[volume]; + const bool skipGlass = throughGlass && record.glassOffset != kNoGlass; + const int3 dims = int3(record.brickDimsX, record.brickDimsY, record.brickDimsZ) * int(kBrickDim); + + RayHit result; + result.hit = false; + result.t = 0.0; + result.voxel = int3(0, 0, 0); + result.axis = -1; + result.material = 0; + result.steps = 0; + result.coarseSteps = 0; + result.brickSteps = 0; + result.subSteps = 0; + result.level = 0; + + const float3 safeDirection = select(abs(direction) > 1e-12, direction, float3(1e-12, 1e-12, 1e-12)); + const float3 inverse = 1.0 / safeDirection; + const float3 near = (float3(0.0, 0.0, 0.0) - origin) * inverse; + const float3 far = (float3(dims) - origin) * inverse; + const float3 enter = min(near, far); + const float3 leave = max(near, far); + const float tEnter = max(max(enter.x, enter.y), max(enter.z, tMin)); + const float tExit = min(min(leave.x, leave.y), min(leave.z, tMax)); + if (tEnter > tExit) + { + return result; + } + + const float tPerVoxel = 1.0 / max(length(direction), 1e-8); + float previous = 0.0; + [unroll] + for (uint sample = 0u; sample < 8u; ++sample) + { + const float distance = sample < 5u ? firstStep * float(sample + 1u) : firstStep * 5.0 * exp2(float(sample) - 4.0); + const float t = tMin + distance * tPerVoxel; + if (t > tExit) + { + return result; + } + result.steps = sample + 1u; + + const float span = distance - previous; + previous = distance; + if (t < tEnter) + { + continue; + } + const int3 voxel = int3(floor(origin + direction * t)); + if (any(voxel < int3(0, 0, 0)) || any(voxel >= dims)) + { + continue; + } + + const uint level = span <= 1.0 ? 0u : (span <= 2.0 ? 1u : (span <= 4.0 ? 2u : (span <= 8.0 ? 3u : kLevelCoarse))); + if (occupiedAtLevel(record, voxel, level, skipGlass)) { result.hit = true; result.t = t; result.voxel = voxel; - result.axis = axis; - result.material = pooledMaterial(slot, local); + result.level = level; return result; } - - leaveCell(origin, inverse, stepSign, voxel, 1, t, axis, crossed); } - return result; } @@ -440,21 +886,18 @@ struct PaletteSample float alpha; + float ior; + /// Physical material id where 0 also covers entries authored without one uint material; }; -float3 srgbToLinear(float3 value) -{ - return lerp(pow((value + 0.055) / 1.055, 2.4), value / 12.92, step(value, float3(0.04045, 0.04045, 0.04045))); -} - /// Word 0 bytes albedo rgb roughness and word 1 bytes metallic reflectivity emissive material -PaletteSample samplePalette(uint volume, uint index) +PaletteSample samplePalette(VolumeRecord record, uint index) { - const uint paletteOffset = gVolumeRecords[volume].paletteOffset; - const float maxEmissive = gVolumeRecords[volume].maxEmissive; - + const uint paletteOffset = record.paletteOffset; + const float maxEmissive = record.maxEmissive; + const uint base = (paletteOffset + index) * 4; const uint word0 = gPalettes[base]; const uint word1 = gPalettes[base + 1]; @@ -468,9 +911,17 @@ PaletteSample samplePalette(uint volume, uint index) sample.emissive = float((word1 >> 16) & 0xFFu) / 255.0 * maxEmissive; sample.material = word1 >> 24; sample.alpha = float((word2 >> 16) & 0xFFu) / 255.0; + // voxel::iorOf + const uint iorByte = word2 >> 24; + sample.ior = iorByte == 0u ? kDefaultIor : 1.0 + float(iorByte) / 100.0; return sample; } +PaletteSample samplePalette(uint volume, uint index) +{ + return samplePalette(gVolumeRecords[volume], index); +} + /// Meshing helpers shared by the compute mesher in voxel_cells.slang and the mesh shader in voxel_mesh.slang /// Grid entry with the empty tag outside the volume @@ -530,6 +981,51 @@ void loadBrickSlices(uint entry, out uint slices[16]) } } +/// loadBrickSlices minus glass voxels so opaque faces against glass are exposed and glass meshes nothing +void loadSolidSlices(uint volume, int3 brick, uint entry, out uint slices[16]) +{ + loadBrickSlices(entry, slices); + if (!glassBrick(volume, brick)) + { + return; + } + + const uint tag = entry & 3u; + if (tag == kTagUniform) + { + if (glassEntry(volume, entry >> 2)) + { + [ForceUnroll] + for (uint word = 0u; word < 16u; ++word) + { + slices[word] = 0u; + } + } + return; + } + + // Material word w holds x 4 * (w & 1) onward of row (w >> 1) & 7 in slice w >> 4 + const uint base = (entry >> 2) << 7; + for (uint word = 0u; word < kMaterialWordsPerBrick; ++word) + { + const uint bytes = gBrickMaterials[base + word]; + if (bytes == 0u) + { + continue; + } + uint glass = 0u; + for (uint b = 0u; b < 4u; ++b) + { + if (glassEntry(volume, (bytes >> (b * 8u)) & 0xFFu)) + { + glass |= 1u << b; + } + } + const uint y = (word >> 1) & 7u; + slices[(word >> 4) * 2u + (y >> 2)] &= ~(glass << (((word & 1u) << 2) + ((y & 3u) << 3))); + } +} + /// Eight x bits of row y in slice z uint occupancyRow(uint occupancy[16], uint z, uint y) { diff --git a/engine/assets/shaders/voxel_denoise.slang b/engine/assets/shaders/voxel_denoise.slang new file mode 100644 index 000000000..0a603afc5 --- /dev/null +++ b/engine/assets/shaders/voxel_denoise.slang @@ -0,0 +1,246 @@ +// Temporal per term then spatial diffuse and composite of the voxel_lighting.slang noisy terms +// Specular is temporal only since a spatial filter erases highlights +import fullscreen; +import depth_convention; +import voxel_gbuffer; + +/// Mirrors renderer::VoxelPass::DenoisePush +struct DenoisePush +{ + /// Jittered since depth was rasterized with it + float4x4 inverseViewProjection; + float4x4 previousViewProjection; + /// x term 0 diffuse 1 specular y 1 with a valid history z spiral rotation in turns w spiral radius in pixels + float4 params; + float4 viewport; +}; + +[[vk::push_constant]] +ConstantBuffer gPush; + +[[vk::binding(0, 0)]] +Sampler2D gNoisyDiffuse; + +[[vk::binding(1, 0)]] +Sampler2D gNoisySpecular; + +[[vk::binding(2, 0)]] +Sampler2D gMotion; + +[[vk::binding(3, 0)]] +Sampler2D gDepth; + +[[vk::binding(4, 0)]] +Sampler2D gAlbedo; + +[[vk::binding(5, 0)]] +Sampler2D gFaceNormal; + +/// rgb term a linear depth +[[vk::binding(6, 0)]] +Sampler2D gDiffuseHistory; + +/// a frames accumulated for both terms +[[vk::binding(7, 0)]] +Sampler2D gSpecularHistory; + +[[vk::binding(8, 0)]] +Sampler2D gDiffuseCurrent; + +[[vk::binding(9, 0)]] +Sampler2D gSpecularCurrent; + +/// Blend floors so a settled pixel still follows changes +static const float kDiffuseBlend = 0.1; +static const float kSpecularBlend = 0.2; + +static const float kMaxHistoryLength = 32.0; + +/// Frames over which the spatial radius shrinks from widest to its setting +static const float kSettleFrames = 8.0; +static const float kFreshRadiusScale = 2.5; + +/// Clip box half width in standard deviations +static const float kDiffuseClip = 2.0; +static const float kSpecularClip = 1.25; + +/// Depth error share past which a history is another surface +static const float kDisocclusionDepth = 0.05; + +static const uint kSpatialTaps = 12u; + +static const float2 kSpiral[kSpatialTaps] = { + float2(0.2041241, 0.0000000), + float2(-0.2606993, 0.2388219), + float2(0.0399042, -0.4546878), + float2(0.3285945, 0.4285934), + float2(-0.6030114, -0.1066642), + float2(0.5712250, -0.3633666), + float2(-0.1910636, 0.7107470), + float2(-0.3643790, -0.7015896), + float2(0.7905567, 0.2887100), + float2(-0.8224425, 0.3394923), + float2(0.3964716, -0.8472368), + float2(0.2929824, 0.9340742) +}; + +typedef FullscreenVSOutput VSOutput; + +[shader("vertex")] +VSOutput vertexMain(uint vertexId : SV_VertexID) +{ + return fullscreenTriangle(vertexId); +} + +float3 worldPositionAt(float2 uv, float depth) +{ + const float4 world = mul(gPush.inverseViewProjection, float4(uv * 2.0 - 1.0, depth, 1.0)); + return world.xyz / world.w; +} + +/// Clip w from the 1 / w row of the inverse view projection +float linearDepthAt(float2 uv, float depth) +{ + return 1.0 / max(dot(gPush.inverseViewProjection[3], float4(uv * 2.0 - 1.0, depth, 1.0)), 1e-8); +} + +/// Only lit voxels write a face normal +bool litVoxelAt(int2 pixel) +{ + return gFaceNormal.Load(int3(pixel, 0)).a > 0.5; +} + +float3 noisyAt(int2 pixel, bool specular) +{ + return specular ? gNoisySpecular.Load(int3(pixel, 0)).rgb : gNoisyDiffuse.Load(int3(pixel, 0)).rgb; +} + +[shader("fragment")] +float4 temporalMain(VSOutput input) : SV_Target0 +{ + const int2 pixel = int2(input.position.xy); + if (!litVoxelAt(pixel)) + { + return float4(0.0, 0.0, 0.0, 0.0); + } + + const bool specular = gPush.params.x > 0.5; + const float3 current = noisyAt(pixel, specular); + + float3 mean = float3(0.0, 0.0, 0.0); + float3 meanSquared = float3(0.0, 0.0, 0.0); + float count = 0.0; + const int2 size = int2(gPush.viewport.xy); + [unroll] + for (int y = -1; y <= 1; ++y) + { + [unroll] + for (int x = -1; x <= 1; ++x) + { + const int2 wanted = pixel + int2(x, y); + const int2 tap = clamp(wanted, int2(0, 0), size - 1); + const float weight = all(tap == wanted) && litVoxelAt(tap) ? 1.0 : 0.0; + const float3 value = noisyAt(tap, specular); + mean += value * weight; + meanSquared += value * value * weight; + count += weight; + } + } + mean /= count; + const float3 sigma = sqrt(max(meanSquared / count - mean * mean, float3(0.0, 0.0, 0.0))); + + const float depth = gDepth.Load(int3(pixel, 0)).r; + const float2 uv = (float2(pixel) + 0.5) / gPush.viewport.xy; + const float3 world = worldPositionAt(uv, depth); + const float linearDepth = linearDepthAt(uv, depth); + + // Camera reprojection when voxel motion is missing + const float4 motion = gMotion.Load(int3(pixel, 0)); + const float4 previousClip = mul(gPush.previousViewProjection, float4(world, 1.0)); + const float2 previousUv = motion.w > 0.5 ? uv - motion.xy : previousClip.xy / previousClip.w * 0.5 + 0.5; + + const bool inside = all(previousUv >= float2(0.0, 0.0)) && all(previousUv <= float2(1.0, 1.0)); + const float4 reset = float4(current, specular ? 1.0 : linearDepth); + + // Reactive pixels drop their history + if (gPush.params.y < 0.5 || !inside || previousClip.w <= 0.0 || motion.z > 0.5) + { + return reset; + } + + // Depth lives in the diffuse history + const float4 diffuseHistory = gDiffuseHistory.SampleLevel(previousUv, 0.0); + if (abs(diffuseHistory.a - previousClip.w) > kDisocclusionDepth * previousClip.w) + { + return reset; + } + + const float4 specularHistory = gSpecularHistory.SampleLevel(previousUv, 0.0); + const float historyLength = min(max(specularHistory.a, 1.0) + 1.0, kMaxHistoryLength); + const float4 history = specular ? specularHistory : diffuseHistory; + const float clip = specular ? kSpecularClip : kDiffuseClip; + const float3 clipped = clamp(history.rgb, mean - clip * sigma, mean + clip * sigma); + const float blend = max(1.0 / historyLength, specular ? kSpecularBlend : kDiffuseBlend); + return float4(lerp(clipped, current, blend), specular ? historyLength : linearDepth); +} + +// Interleaved gradient noise +float noiseAt(float2 pixel) +{ + return frac(52.9829189 * frac(dot(pixel, float2(0.06711056, 0.00583715)))); +} + +/// Additive into the scene colour +[shader("fragment")] +float4 compositeMain(VSOutput input) : SV_Target0 +{ + const int2 pixel = int2(input.position.xy); + const float4 albedo = gAlbedo.Load(int3(pixel, 0)); + if (!isLitVoxel(albedo.a)) + { + discard; + } + + const float depth = gDepth.Load(int3(pixel, 0)).r; + const float2 uv = (float2(pixel) + 0.5) / gPush.viewport.xy; + const float3 center = worldPositionAt(uv, depth); + const float3 normal = decodeFaceNormal(gFaceNormal.Load(int3(pixel, 0))); + const float centerDepth = linearDepthAt(uv, depth); + + // Tangent plane distance keeps grazing faces + const float planeTolerance = 0.01 * centerDepth + 1e-3; + const float rotation = (noiseAt(input.position.xy) + gPush.params.z) * 6.28318531; + // Wider while the history is short + const float settled = saturate((gSpecularCurrent.Load(int3(pixel, 0)).a - 1.0) / kSettleFrames); + const float radius = gPush.params.w * lerp(kFreshRadiusScale, 1.0, settled); + const int2 size = int2(gPush.viewport.xy); + + float3 diffuse = gDiffuseCurrent.Load(int3(pixel, 0)).rgb; + float weightSum = 1.0; + const float2 turn = float2(cos(rotation), sin(rotation)) * radius; + [unroll] + for (uint i = 0u; i < kSpatialTaps; ++i) + { + const float2 offset = float2(kSpiral[i].x * turn.x - kSpiral[i].y * turn.y, kSpiral[i].x * turn.y + kSpiral[i].y * turn.x); + const int2 wanted = int2(floor(input.position.xy + offset)); + const int2 tap = clamp(wanted, int2(0, 0), size - 1); + const float tapDepth = gDepth.Load(int3(tap, 0)).r; + const float4 tapFace = gFaceNormal.Load(int3(tap, 0)); + const float3 tapDiffuse = gDiffuseCurrent.Load(int3(tap, 0)).rgb; + + const float3 tapPosition = worldPositionAt((float2(tap) + 0.5) / gPush.viewport.xy, tapDepth); + const float plane = abs(dot(normal, tapPosition - center)); + const float facing = saturate(dot(normal, decodeFaceNormal(tapFace))); + const float facing2 = facing * facing; + const float facing8 = facing2 * facing2 * facing2 * facing2; + const float facing32 = facing8 * facing8 * facing8 * facing8; + const bool valid = all(tap == wanted) && tapFace.a > 0.5; + const float weight = valid ? exp(-plane / planeTolerance) * facing32 : 0.0; + diffuse += tapDiffuse * weight; + weightSum += weight; + } + diffuse /= weightSum; + + const float3 specular = gSpecularCurrent.Load(int3(pixel, 0)).rgb; + return float4(albedo.rgb * diffuse + specular, 0.0); +} diff --git a/engine/assets/shaders/voxel_denoise.slang.meta b/engine/assets/shaders/voxel_denoise.slang.meta new file mode 100644 index 000000000..19720310a --- /dev/null +++ b/engine/assets/shaders/voxel_denoise.slang.meta @@ -0,0 +1,4 @@ +uid = "V0xe1D3n01s" +type = "shader" +created_at = "2026-10-18T12:00:00.0000000Z" +modified_at = "2026-10-18T12:00:00.0000000Z" diff --git a/engine/assets/shaders/voxel_glass.slang b/engine/assets/shaders/voxel_glass.slang new file mode 100644 index 000000000..5d1dd5c39 --- /dev/null +++ b/engine/assets/shaders/voxel_glass.slang @@ -0,0 +1,637 @@ +// Forward glass one box per glass brick far to near +import voxel_dda; +import lighting; +import scene_normal; +import depth_convention; + +/// Instance index then brick grid index sorted far to near +[[vk::binding(7, 1)]] +StructuredBuffer gGlassBricks; + +/// Mirrors renderer::VoxelPass::GlassPush +struct GlassPush +{ + /// Run start in gGlassBricks since SV_InstanceID leaves out firstInstance + uint first; + /// 0 when the backdrop was not captured this frame + float refraction; + /// Meters past which glass does not refract + float refractionDistance; + /// Voxels the glass run walk follows + uint refractionVoxels; + /// Lit glass surfaces per brick and pixel past which layers only absorb + uint litSurfaces; +}; + +[[vk::push_constant]] +GlassPush gGlassPush; + +/// Scene colour and depth after the sky and before anything blended +[[vk::binding(8, 1)]] +Sampler2D gBackdrop; + +[[vk::binding(9, 1)]] +Sampler2D gBackdropDepth; + +/// Ratio blending reads a near black backdrop as a difference instead +static const float kRatioFloor = 0.02; +static const float kMaxRatio = 16.0; + + +/// Below this nothing behind shows +static const float kOpaqueTransmittance = 0.004; + + +struct VSOutput +{ + float4 position : SV_Position; + + [[vk::location(0)]] + float3 worldPosition : TEXCOORD0; + + [[vk::location(1)]] + nointerpolation uint instanceIndex : TEXCOORD1; + + [[vk::location(2)]] + nointerpolation int3 brick : TEXCOORD2; + + // Flat per draw or linear in position so fragments skip the instance fetch + [[vk::location(3)]] + nointerpolation uint volume : TEXCOORD3; + + [[vk::location(4)]] + nointerpolation uint brickEntry : TEXCOORD4; + + [[vk::location(5)]] + nointerpolation float3 voxelCamera : TEXCOORD5; + + [[vk::location(6)]] + float3 voxelPosition : TEXCOORD6; + + [[vk::location(7)]] + nointerpolation float nearOrigin : TEXCOORD7; + + [[vk::location(8)]] + float nearStep : TEXCOORD8; +}; + +/// Back faces so a camera inside the brick still covers it +[shader("vertex")] +VSOutput vertexMain(uint vertexId : SV_VertexID, uint instanceId : SV_InstanceID) +{ + const uint2 entry = gGlassBricks[gGlassPush.first + instanceId]; + const VoxelInstance instance = gVoxelInstances[entry.x]; + const VolumeRecord record = gVolumeRecords[instance.recordIndex]; + const uint3 brick = uint3(entry.y % record.brickDimsX, (entry.y / record.brickDimsX) % record.brickDimsY, + entry.y / (record.brickDimsX * record.brickDimsY)); + + uint corner = vertexId % 3u; + if ((instance.flags & kInstanceMirrored) != 0u && corner != 0u) + { + corner = 3u - corner; + } + const float3 local = float3(brick * kBrickDim) + kBoxCorners[kBoxIndices[vertexId - (vertexId % 3u) + corner]] * float(kBrickDim); + const float4 world = mul(instance.voxelToWorld, float4(local, 1.0)); + + VSOutput output; + output.worldPosition = world.xyz; + output.position = mul(gCamera.jitteredViewProjection, world); + output.instanceIndex = entry.x; + output.brick = int3(brick); + output.volume = instance.recordIndex; + output.brickEntry = gridEntry(record, brick); + output.voxelCamera = mul(instance.worldToVoxel, float4(gCamera.position, 1.0)).xyz; + output.voxelPosition = local; + output.nearOrigin = clipNearDistance(mul(gCamera.viewProjection, float4(gCamera.position, 1.0))); + output.nearStep = clipNearDistance(mul(gCamera.viewProjection, float4(world.xyz - gCamera.position, 0.0))); + return output; +} + +struct GlassComposite +{ + /// Premultiplied by the transmittance in front + float3 color; + /// Transmittance of the colour behind + float3 transmittance; + /// Of the nearest glass in the brick + float depth; + bool debugView; + + /// Nearest entered surface for SSR and TAA + bool entered; + float3 normal; + float roughness; + float f0; + float3 voxelPosition; + + /// Backdrop uv the refracted ray lands on + bool refracted; + float2 refractedUv; +}; + +float3 shadeInterface(float3 hitWorld, float3 normal, float3 view, float2 pixel, PaletteSample palette, out float fresnel, + out bool debugView) +{ + Surface surface; + surface.marchedShadows = false; + surface.worldPosition = hitWorld; + surface.normal = normal; + surface.geometricNormal = normal; + surface.viewDirection = view; + surface.albedo = palette.albedo; + // Dielectric with the specular lobe always on so the IOR alone sets reflectance + surface.metallic = 0.0; + surface.reflectivity = 1.0; + surface.roughness = clamp(palette.roughness, 0.045, 1.0); + surface.occlusion = 1.0; + surface.viewSpaceZ = mul(gCamera.view, float4(hitWorld, 1.0)).z; + surface.pixel = pixel; + + const float f0 = iorF0(palette.ior); + const float facing = pow(1.0 - saturate(dot(normal, view)), 5.0); + fresnel = f0 + (1.0 - f0) * facing; + + // One pass split into lobes since each pass resamples every shadow map + const ShadingResult lit = shadeSurfaceLighting(surface); + debugView = lit.isDebugView; + if (debugView) + { + return lit.color; + } + const float3 diffuse = max(lit.color - lit.specular, float3(0.0, 0.0, 0.0)); + // Lighting assumes F0 0.04 so rescale by the Fresnel ratio + const float3 specular = lit.specular * fresnel / (0.04 + 0.96 * facing); + return diffuse * glassScatter(palette.alpha, palette.roughness) + palette.albedo * palette.emissive * palette.alpha + specular; +} + + +/// First glass run of the camera ray across the whole volume +struct GlassRun +{ + float entryT; + float exitT; + int3 entryVoxel; + float3 entryNormal; + float3 exitNormal; + float ior; +}; + +float3 voxelFaceNormal(float4x4 worldToVoxel, int axis, float sign) +{ + float3 voxelNormal = float3(0.0, 0.0, 0.0); + voxelNormal[axis] = sign; + return normalize(mul(transpose((float3x3)worldToVoxel), voxelNormal)); +} + +/// False when an opaque voxel comes first or the camera starts inside glass +bool firstGlassRun(uint volume, VolumeRecord record, float4x4 worldToVoxel, float3 origin, float3 direction, float tMin, + out GlassRun run) +{ + run.entryT = 0.0; + run.exitT = 0.0; + run.entryVoxel = int3(0, 0, 0); + run.entryNormal = float3(0.0, 0.0, 0.0); + run.exitNormal = float3(0.0, 0.0, 0.0); + run.ior = kDefaultIor; + + const int3 dims = int3(record.brickDimsX, record.brickDimsY, record.brickDimsZ) * int(kBrickDim); + const RayHit hit = marchRayBox(volume, origin, direction, tMin, kFloatMax, int3(0, 0, 0), dims, false); + if (!hit.hit || hit.axis < 0 || !glassEntry(record, hit.material)) + { + return false; + } + run.entryT = hit.t; + run.entryVoxel = hit.voxel; + run.entryNormal = voxelFaceNormal(worldToVoxel, hit.axis, direction[hit.axis] > 0.0 ? -1.0 : 1.0); + run.ior = samplePalette(record, hit.material).ior; + + int3 voxel = hit.voxel; + int3 stepSign; + float3 next; + float3 delta; + for (int a = 0; a < 3; ++a) + { + stepSign[a] = direction[a] > 0.0 ? 1 : (direction[a] < 0.0 ? -1 : 0); + delta[a] = stepSign[a] == 0 ? kFloatMax : abs(1.0 / direction[a]); + next[a] = stepSign[a] == 0 ? kFloatMax : (float(voxel[a] + (stepSign[a] > 0 ? 1 : 0)) - origin[a]) / direction[a]; + } + + // Parallel faces when the run is longer than the walk + run.exitT = hit.t; + run.exitNormal = -run.entryNormal; + int3 cachedBrick = int3(-1, -1, -1); + uint cachedEntry = 0u; + uint cachedMaterial = hit.material; + bool cachedGlass = true; + for (uint step = 0u; step < gGlassPush.refractionVoxels; ++step) + { + const int axis = next.x < next.y ? (next.x < next.z ? 0 : 2) : (next.y < next.z ? 1 : 2); + run.exitT = next[axis]; + voxel[axis] += stepSign[axis]; + next[axis] += delta[axis]; + bool glass = false; + if (all(voxel >= int3(0, 0, 0)) && all(voxel < dims)) + { + const int3 brick = voxel >> 3; + if (any(brick != cachedBrick)) + { + cachedBrick = brick; + cachedEntry = gridEntry(record, uint3(brick)); + } + const uint material = brickMaterialAt(cachedEntry, voxel); + if (material != cachedMaterial) + { + cachedMaterial = material; + cachedGlass = material != 0u && glassEntry(record, material); + } + glass = cachedGlass; + } + if (!glass) + { + run.exitNormal = voxelFaceNormal(worldToVoxel, axis, float(stepSign[axis])); + break; + } + } + return true; +} + +/// Camera distance of a backdrop texel +float backdropDistance(float2 uv, float depth) +{ + const float4 viewPosition = mul(gClusterParams.inverseProjection, float4(uv * 2.0 - 1.0, depth, 1.0)); + return length(viewPosition.xyz / viewPosition.w); +} + +/// Snell at entry and exit faces with a straight path inside +/// The bent ray goes as far past the glass as the straight one +float2 refractedBackdropUv(float3 camera, float3 worldDirection, float entryT, float exitT, float3 entryNormal, float3 exitNormal, + float ior, float2 pixel, out bool valid) +{ + valid = false; + float2 size; + gBackdrop.GetDimensions(size.x, size.y); + const float2 straightUv = pixel / size; + + const float rayLength = length(worldDirection); + const float3 direction = worldDirection / rayLength; + const float3 entryPoint = camera + worldDirection * entryT; + + const float3 inside = refract(direction, entryNormal, 1.0 / ior); + if (dot(inside, inside) < 1e-6) + { + return straightUv; + } + const float across = (exitT - entryT) * rayLength * max(dot(direction, -entryNormal), 1e-3); + const float3 exitPoint = entryPoint + inside * (across / max(dot(inside, -entryNormal), 1e-3)); + float3 bent = refract(inside, -exitNormal, ior); + // Total internal reflection keeps the inside direction + if (dot(bent, bent) < 1e-6) + { + bent = inside; + } + + const float straightDepth = gBackdropDepth.Load(int3(int2(pixel), 0)).r; + const float exitDistance = distance(camera, exitPoint); + const float remaining = isFarDepth(straightDepth) ? 1000.0 : max(backdropDistance(straightUv, straightDepth) - exitDistance, 0.0); + const float4 clip = mul(gCamera.jitteredViewProjection, float4(exitPoint + bent * remaining, 1.0)); + if (clip.w <= 1e-4) + { + return straightUv; + } + const float2 uv = lerp(straightUv, clip.xy / clip.w * 0.5 + 0.5, gGlassPush.refraction); + if (any(uv < float2(0.0, 0.0)) || any(uv > float2(1.0, 1.0))) + { + return straightUv; + } + + // Something in front of the glass never shows through it + const float bentDepth = gBackdropDepth.SampleLevel(uv, 0.0).r; + if (!isFarDepth(bentDepth) && backdropDistance(uv, bentDepth) < distance(camera, entryPoint)) + { + return straightUv; + } + valid = true; + return uv; +} + +/// Every glass layer front to back or with @p surfaceOnly the nearest entered surface unlit +/// Surfaces reflect by Fresnel and scatter by glassScatter and the path filters by glassPathFilter +/// Never shortened per voxel or the grid shows +bool compositeGlass(VSOutput input, bool surfaceOnly, out GlassComposite result) +{ + result.color = float3(0.0, 0.0, 0.0); + result.transmittance = float3(1.0, 1.0, 1.0); + result.depth = input.position.z; + result.debugView = false; + result.entered = false; + result.normal = float3(0.0, 0.0, 0.0); + result.roughness = 1.0; + result.f0 = 0.0; + result.voxelPosition = float3(0.0, 0.0, 0.0); + result.refracted = false; + result.refractedUv = float2(0.0, 0.0); + if (isVoxelDebugMode(gCamera.renderModePad.x)) + { + return false; + } + + const uint volume = input.volume; + const VolumeRecord record = gVolumeRecords[volume]; + // Only surface normals and refraction read it + const float4x4 worldToVoxel = gVoxelInstances[input.instanceIndex].worldToVoxel; + const float3 camera = gCamera.position; + + // t = 1 is the back face + const float3 worldDirection = input.worldPosition - camera; + const float3 origin = input.voxelCamera; + const float3 direction = input.voxelPosition - input.voxelCamera; + const float3 view = -normalize(worldDirection); + + const float tMin = input.nearStep > 0.0 ? max(0.0, -input.nearOrigin / input.nearStep) : 0.0; + + const int3 boxMin = input.brick * int(kBrickDim); + const int3 boxMax = boxMin + int(kBrickDim); + + float t = tMin; + float tExit = kFloatMax; + int axis = -1; + float3 inverse = float3(kFloatMax, kFloatMax, kFloatMax); + int3 stepSign = int3(0, 0, 0); + for (int a = 0; a < 3; ++a) + { + if (direction[a] == 0.0) + { + if (origin[a] < float(boxMin[a]) || origin[a] > float(boxMax[a])) + { + return false; + } + continue; + } + inverse[a] = 1.0 / direction[a]; + stepSign[a] = direction[a] > 0.0 ? 1 : -1; + const float t0 = (float(boxMin[a]) - origin[a]) * inverse[a]; + const float t1 = (float(boxMax[a]) - origin[a]) * inverse[a]; + if (min(t0, t1) > t) + { + t = min(t0, t1); + axis = a; + } + tExit = min(tExit, max(t0, t1)); + } + if (t > tExit) + { + return false; + } + + int3 voxel = int3(floor(origin + direction * t)); + if (axis >= 0) + { + voxel[axis] = stepSign[axis] > 0 ? boxMin[axis] : boxMax[axis] - 1; + } + voxel = clamp(voxel, boxMin, boxMax - 1); + + // Glass continuing from the voxel the ray came through was entered in another brick or holds the camera + int3 previous = voxel; + if (axis >= 0) + { + previous[axis] -= stepSign[axis]; + } + bool inside = glassEntry(record, voxelMaterialAt(record, previous)); + + float3 next; + for (int a = 0; a < 3; ++a) + { + next[a] = stepSign[a] == 0 ? kFloatMax : (float(voxel[a] + (stepSign[a] > 0 ? 1 : 0)) - origin[a]) * inverse[a]; + } + const float3 delta = abs(inverse); + const float voxelsPerT = length(direction); + + bool touched = false; + float firstT = t; + uint litInterfaces = 0u; + + // A run of glass is nearly always one palette entry + uint cachedMaterial = 0xFFFFFFFFu; + bool cachedGlass = false; + PaletteSample palette = {}; + + for (uint step = 0u; step < 3u * kBrickDim; ++step) + { + // The march stays in its brick so one grid entry covers it + const uint material = brickMaterialAt(input.brickEntry, voxel); + const int nextAxis = next.x < next.y ? (next.x < next.z ? 0 : 2) : (next.y < next.z ? 1 : 2); + const float tNext = min(next[nextAxis], tExit); + + if (material != 0u) + { + // The opaque surface behind is already in the scene colour the transmittance filters + if (material != cachedMaterial) + { + cachedMaterial = material; + cachedGlass = glassEntry(record, material); + if (cachedGlass) + { + palette = samplePalette(record, material); + } + } + if (!cachedGlass) + { + break; + } + + const float thickness = max(tNext - t, 0.0) * voxelsPerT; + if (!touched) + { + touched = true; + firstT = t; + } + + if (!inside && axis >= 0) + { + float3 voxelNormal = float3(0.0, 0.0, 0.0); + voxelNormal[axis] = direction[axis] > 0.0 ? -1.0 : 1.0; + const float3 normal = normalize(mul(transpose((float3x3)worldToVoxel), voxelNormal)); + + if (!result.entered) + { + result.entered = true; + result.normal = normal; + result.roughness = clamp(palette.roughness, 0.045, 1.0); + result.f0 = iorF0(palette.ior); + result.voxelPosition = origin + direction * t; + if (surfaceOnly) + { + firstT = t; + break; + } + } + + float fresnel = 0.0; + if (litInterfaces < gGlassPush.litSurfaces) + { + bool debugView = false; + const float3 light = shadeInterface(camera + worldDirection * t, normal, view, input.position.xy, palette, fresnel, + debugView); + if (debugView) + { + result.color = light; + result.transmittance = float3(0.0, 0.0, 0.0); + result.debugView = true; + firstT = t; + break; + } + result.color += result.transmittance * light; + ++litInterfaces; + } + + // What the surface reflects or scatters does not pass through + result.transmittance *= (1.0 - fresnel) * (1.0 - glassScatter(palette.alpha, palette.roughness)); + } + else if (surfaceOnly) + { + // Glass continuing from a nearer brick whose own surface pass writes the surface + break; + } + + result.transmittance *= glassPathFilter(palette.albedo, palette.alpha, thickness); + + if (max(result.transmittance.x, max(result.transmittance.y, result.transmittance.z)) < kOpaqueTransmittance) + { + result.transmittance = float3(0.0, 0.0, 0.0); + break; + } + } + inside = material != 0u; + + if (next[nextAxis] > tExit) + { + break; + } + axis = nextAxis; + t = next[nextAxis]; + voxel[nextAxis] += stepSign[nextAxis]; + next[nextAxis] += delta[nextAxis]; + // A plane tied with tExit steps out where brickEntry would wrap + if (voxel[nextAxis] < boxMin[nextAxis] || voxel[nextAxis] >= boxMax[nextAxis]) + { + break; + } + } + + if (!touched || (surfaceOnly && !result.entered)) + { + return false; + } + const float4 clip = mul(gCamera.viewProjection, float4(camera + worldDirection * firstT, 1.0)); + result.depth = clip.z / clip.w; + + // Only the brick holding the entry voxel refracts + GlassRun run; + if (!surfaceOnly && !result.debugView && result.entered && gGlassPush.refraction > 0.0 && + firstT * length(worldDirection) <= gGlassPush.refractionDistance && + firstGlassRun(volume, record, worldToVoxel, origin, direction, tMin, run) && all((run.entryVoxel >> 3) == input.brick)) + { + result.refractedUv = refractedBackdropUv(camera, worldDirection, run.entryT, run.exitT, run.entryNormal, run.exitNormal, run.ior, + input.position.xy, result.refracted); + } + return true; +} + +// Location 0 index 1 is the second blend source so the background is tinted not just faded +struct DualSourceOutput +{ + [[vk::location(0)]] + [[vk::index(0)]] + float4 color : SV_Target0; + + [[vk::location(0)]] + [[vk::index(1)]] + float4 transmittance : SV_Target1; +}; + +[shader("fragment")] +DualSourceOutput fragmentMain(VSOutput input, out float depth : SV_Depth) +{ + GlassComposite glass; + if (!compositeGlass(input, false, glass)) + { + discard; + } + depth = glass.depth; + + // The blend sees only the straight backdrop so the bent one scales or offsets it + float3 offset = float3(0.0, 0.0, 0.0); + float3 scale = glass.transmittance; + if (glass.refracted) + { + const float3 straight = gBackdrop.Load(int3(int2(input.position.xy), 0)).rgb; + const float3 bent = gBackdrop.SampleLevel(glass.refractedUv, 0.0).rgb; + [unroll] + for (int c = 0; c < 3; ++c) + { + if (straight[c] > kRatioFloor) + { + scale[c] *= min(bent[c] / straight[c], kMaxRatio); + } + else + { + offset[c] = scale[c] * (bent[c] - straight[c]); + } + } + } + + DualSourceOutput output; + output.color = float4(glass.color + offset, 1.0); + output.transmittance = float4(scale, 1.0); + return output; +} + +/// Without dualSrcBlend the tint is lost and the background only fades +[shader("fragment")] +float4 fragmentPremultiplied(VSOutput input, out float depth : SV_Depth) : SV_Target0 +{ + GlassComposite glass; + if (!compositeGlass(input, false, glass)) + { + discard; + } + depth = glass.depth; + return float4(glass.color, 1.0 - dot(glass.transmittance, float3(1.0, 1.0, 1.0)) / 3.0); +} + +// Mirrors the world stage attachment order with the scene colour masked off +struct SurfaceOutput +{ + float4 color : SV_Target0; + float4 normalRoughness : SV_Target1; + /// Zero so SSAO leaves glass and what shows through it alone + float4 indirectDiffuse : SV_Target2; + /// xy uv motion z reactive w 1 when written + float4 motion : SV_Target3; +}; + +/// After every blended draw +[shader("fragment")] +SurfaceOutput fragmentSurface(VSOutput input, out float depth : SV_Depth) +{ + GlassComposite glass; + if (!compositeGlass(input, true, glass)) + { + discard; + } + depth = glass.depth; + + const VoxelInstance instance = gVoxelInstances[input.instanceIndex]; + SurfaceOutput output; + output.color = float4(0.0, 0.0, 0.0, 0.0); + output.normalRoughness = encodeSceneNormal(glass.normal, glass.roughness, glass.f0); + output.indirectDiffuse = float4(0.0, 0.0, 0.0, 1.0); + output.motion = float4(0.0, 0.0, 0.0, 0.0); + if ((instance.flags & kInstanceNoMotion) == 0u) + { + const float4 clip = mul(gCamera.viewProjection, mul(instance.voxelToWorld, float4(glass.voxelPosition, 1.0))); + const float4 previousClip = mul(gCamera.previousViewProjection, mul(instance.previousVoxelToWorld, float4(glass.voxelPosition, 1.0))); + output.motion = float4((clip.xy / clip.w - previousClip.xy / previousClip.w) * 0.5, 0.0, 1.0); + } + return output; +} diff --git a/engine/assets/shaders/voxel_glass.slang.meta b/engine/assets/shaders/voxel_glass.slang.meta new file mode 100644 index 000000000..c39836772 --- /dev/null +++ b/engine/assets/shaders/voxel_glass.slang.meta @@ -0,0 +1,4 @@ +uid = "V0xe1G1ass1" +type = "shader" +created_at = "2026-10-18T12:00:00.0000000Z" +modified_at = "2026-10-18T12:00:00.0000000Z" diff --git a/engine/assets/shaders/voxel_lighting.slang b/engine/assets/shaders/voxel_lighting.slang index 6b89c9aa7..552a32f26 100644 --- a/engine/assets/shaders/voxel_lighting.slang +++ b/engine/assets/shaders/voxel_lighting.slang @@ -1,6 +1,8 @@ import fullscreen; +import voxel_dda; import voxel_gbuffer; import lighting; +import scene_normal; // Mirrors VoxelPass::LightingPushConstants struct LightingPushConstants @@ -11,24 +13,70 @@ struct LightingPushConstants float4 cameraRadius; // xy viewport size z pixels per world unit at 1 m w spiral rotation in turns float4 viewport; + // x instances with bounds y light sampling candidates z 1 terms to voxel_denoise.slang 2 straight into colour w sun reach in meters + uint4 lighting; + // x bit 0 last frame's reservoirs are valid bit 1 fragmentSpatial picks and shades sampled lights bit 2 glass shadows + // and the high 16 bits the glass shadow reach of a sun in meters + // y M cap z reservoirs reused w ambient ray reach in centimetres and 0 for none + uint4 reuse; }; [[vk::push_constant]] ConstantBuffer gPush; -[[vk::binding(0, 1)]] +// Set 1 is the voxel storage of voxel_dda.slang so shadow rays can march +/// Bounding spheres in gVoxelInstances order with off screen casters last +[[vk::binding(7, 1)]] +StructuredBuffer gInstanceBounds; + +/// Mirrors renderer::VoxelPass::writeAmbientGrid +/// Cell size bits, global offset and count, pad, offset and count per bucket, instance indices +[[vk::binding(8, 1)]] +StructuredBuffer gAmbientGrid; + +/// Mirrors renderer::VoxelPass::writeGlassCasters +/// Count then indices into gVoxelInstances of the shadow casters holding glass +[[vk::binding(9, 1)]] +StructuredBuffer gGlassCasters; + +static const uint kAmbientGridBuckets = 4096u; +static const uint kAmbientGridHeader = 4u; + +uint ambientGridBucket(int3 cell) +{ + const uint3 u = uint3(cell); + return ((u.x * 73856093u) ^ (u.y * 19349663u) ^ (u.z * 83492791u)) & (kAmbientGridBuckets - 1u); +} + +[[vk::binding(0, 2)]] Sampler2D gAlbedo; // Unrounded so shadow bias still sees the flat face -[[vk::binding(1, 1)]] +[[vk::binding(1, 2)]] Sampler2D gFaceNormal; -[[vk::binding(2, 1)]] +[[vk::binding(2, 2)]] Sampler2D gMaterial; -[[vk::binding(3, 1)]] +[[vk::binding(3, 2)]] Sampler2D gDepth; +/// packReservoir of last frame +[[vk::binding(4, 2)]] +Sampler2D gPreviousReservoirs; + +/// xy uv motion z reactive w 1 when written +[[vk::binding(5, 2)]] +Sampler2D gMotion; + +/// This frame's reservoirs before spatial reuse +[[vk::binding(6, 2)]] +Sampler2D gCandidateReservoirs; + +/// Rounded normal of fragmentMain +[[vk::binding(7, 2)]] +Sampler2D gSceneNormal; + typedef FullscreenVSOutput VSOutput; struct FSOutput @@ -37,11 +85,16 @@ struct FSOutput float4 indirectDiffuse : SV_Target1; /// xyz rounded normal w SSR roughness and zero where SSAO and SSR pass through float4 normalRoughness : SV_Target2; + /// Marched and sampled light over albedo that voxel_denoise.slang filters + float4 noisyDiffuse : SV_Target3; + float4 noisySpecular : SV_Target4; + /// packReservoir for next frame + uint4 reservoir : SV_Target5; }; /// One tap per pixel of radius within this range static const uint kMinRoundingTaps = 4; -static const uint kMaxRoundingTaps = 16; +static const uint kMaxRoundingTaps = 8; static const float kGoldenAngle = 2.39996323; // kMaxRoundingTaps spread wider than this stop reading as one smooth bevel @@ -61,9 +114,11 @@ float3 positionAt(int2 pixel) return world.xyz / world.w; } -bool isLitVoxelAt(int2 pixel) +bool isLitVoxelAt(int2 pixel, out float3 faceNormal) { - return isLitVoxel(gAlbedo.Load(int3(pixel, 0)).a); + const float4 encoded = gFaceNormal.Load(int3(pixel, 0)); + faceNormal = decodeFaceNormal(encoded); + return encoded.a > 0.5; } // Interleaved gradient noise @@ -100,7 +155,8 @@ float3 roundedNormal(float2 pixel, float3 center, float3 face) const float angle = float(i) * kGoldenAngle + rotation; const float2 offset = sqrt((float(i) + 0.5) / float(taps)) * radiusPixels * float2(cos(angle), sin(angle)); const int2 tap = int2(floor(pixel + offset)); - if (any(tap < int2(0, 0)) || any(tap >= size) || !isLitVoxelAt(tap)) + float3 tapNormal; + if (any(tap < int2(0, 0)) || any(tap >= size) || !isLitVoxelAt(tap, tapNormal)) { continue; } @@ -108,11 +164,654 @@ float3 roundedNormal(float2 pixel, float3 center, float3 face) // An occluded or grazing neighbour drops out const float3 toTap = positionAt(tap) - center; const float falloff = saturate(1.0 - dot(toTap, toTap) / (radius * radius)); - sum += decodeFaceNormal(gFaceNormal.Load(int3(tap, 0))) * (falloff * falloff); + sum += tapNormal * (falloff * falloff); } return normalize(sum); } +/// Mirrors voxel::k_voxel_size +static const float kVoxelSize = 0.1; + +/// Shadow origin offset off its face in voxels +static const float kShadowOriginOffset = 0.6; + +/// Transmittance along the segment and 0 once a voxel blocks it +float3 marchedVisibility(float3 origin, float3 target, float coneSlope) +{ + const float3 segment = target - origin; + const float lengthSquared = max(dot(segment, segment), 1e-8); + float3 transmittance = float3(1.0, 1.0, 1.0); + for (uint i = 0u; i < gPush.lighting.x; ++i) + { + const float4 bounds = gInstanceBounds[i]; + const float along = saturate(dot(bounds.xyz - origin, segment) / lengthSquared); + const float3 nearest = origin + segment * along - bounds.xyz; + if (dot(nearest, nearest) > bounds.w * bounds.w) + { + continue; + } + + const float4x4 worldToVoxel = gVoxelInstances[i].worldToVoxel; + const float3 voxelOrigin = mul(worldToVoxel, float4(origin, 1.0)).xyz; + const float3 voxelSegment = mul(worldToVoxel, float4(segment, 0.0)).xyz; + const uint record = gVoxelInstances[i].recordIndex; + // Without glass shadows coarse steps pass glass + const bool blocked = (gPush.reuse.x & kSceneGlass) != 0u + ? marchSparseTinted(record, voxelOrigin, voxelSegment, 0.0, 1.0, coneSlope, transmittance).hit + : marchSparse(record, voxelOrigin, voxelSegment, 0.0, 1.0, coneSlope).hit; + if (blocked) + { + return float3(0.0, 0.0, 0.0); + } + } + return transmittance; +} + +/// Mesh shadowing of a marched light from its shadow map or traced ray +float meshShadowFactor(GpuLight light, Surface surface) +{ + const float3 toLight = light.worldPosRange.xyz - surface.worldPosition; + const float distance = length(toLight); + const float3 L = toLight / max(distance, 1e-4); + if (tracedShadowsEnabled()) + { + return traceShadowRay(surface.worldPosition, surface.geometricNormal, L, distance); + } + if (light.directionPad.w < 0.0) + { + return 1.0; + } + return light.viewPosType.w > 0.5 ? sampleSpotShadow(light, surface.worldPosition, surface.geometricNormal, L, surface.pixel) + : samplePointShadow(light, surface.worldPosition, surface.geometricNormal, L, surface.pixel); +} + +/// Shadow maps hold no glass so voxel pixels march toward the light through glass volumes +struct VoxelGlassFilter : IShadowFilter +{ + float3 origin; + float sunReach; + float sunSlope; + + float3 filter(float3 L, float distance) + { + if ((gPush.reuse.x & kSceneGlass) == 0u) + { + return float3(1.0, 1.0, 1.0); + } + const float3 segment = L * (distance < 0.0 ? sunReach : distance); + const float slope = distance < 0.0 ? sunSlope : 0.0; + const float lengthSquared = max(dot(segment, segment), 1e-8); + float3 transmittance = float3(1.0, 1.0, 1.0); + const uint casters = gGlassCasters[0]; + for (uint c = 0u; c < casters; ++c) + { + const uint i = gGlassCasters[1u + c]; + const float4 bounds = gInstanceBounds[i]; + const float along = saturate(dot(bounds.xyz - origin, segment) / lengthSquared); + const float3 nearest = origin + segment * along - bounds.xyz; + if (dot(nearest, nearest) > bounds.w * bounds.w) + { + continue; + } + const uint record = gVoxelInstances[i].recordIndex; + + const float4x4 worldToVoxel = gVoxelInstances[i].worldToVoxel; + const float3 voxelOrigin = mul(worldToVoxel, float4(origin, 1.0)).xyz; + const float3 voxelSegment = mul(worldToVoxel, float4(segment, 0.0)).xyz; + // An opaque hit is the shadow map's to judge so only glass before it counts + marchSparseTinted(record, voxelOrigin, voxelSegment, 0.0, 1.0, slope, transmittance); + if (max(transmittance.x, max(transmittance.y, transmittance.z)) <= kTintedOpaque) + { + return float3(0.0, 0.0, 0.0); + } + } + return transmittance; + } +} + +/// Mirrors renderer::VoxelPass ambient rays per pixel +static const uint kAmbientRays = 2u; + +/// Teardown style AO from the share of the reach cosine rays travel +float ambientOcclusion(Surface surface, float3 origin, float reach) +{ + float3 tangent; + float3 bitangent; + perpendicularBasis(surface.normal, tangent, bitangent); + float3 segments[kAmbientRays]; + float travelled[kAmbientRays]; + [unroll] + for (uint r = 0u; r < kAmbientRays; ++r) + { + const float2 disc = concentricDisc(blueNoise(surface.pixel, 120u + r).xy); + segments[r] = (tangent * disc.x + bitangent * disc.y + surface.normal * sqrt(saturate(1.0 - dot(disc, disc)))) * reach; + travelled[r] = 1.0; + } + + const float cellSize = asfloat(gAmbientGrid[0]); + if (!(cellSize > 0.0)) + { + return 1.0; + } + const uint bucket = ambientGridBucket(int3(floor(origin / cellSize))); + const uint indexBase = kAmbientGridHeader + 2u * kAmbientGridBuckets; + const uint cellOffset = gAmbientGrid[kAmbientGridHeader + 2u * bucket]; + const uint cellCount = gAmbientGrid[kAmbientGridHeader + 2u * bucket + 1u]; + const uint globalOffset = gAmbientGrid[1]; + const uint total = cellCount + gAmbientGrid[2]; + for (uint j = 0u; j < total; ++j) + { + const uint i = gAmbientGrid[indexBase + (j < cellCount ? cellOffset + j : globalOffset + j - cellCount)]; + const float4 bounds = gInstanceBounds[i]; + const float3 offset = bounds.xyz - origin; + const float touch = bounds.w + reach; + if (dot(offset, offset) > touch * touch) + { + continue; + } + + uint rays = 0u; + [unroll] + for (uint r = 0u; r < kAmbientRays; ++r) + { + const float along = clamp(dot(offset, segments[r]) / (reach * reach), 0.0, travelled[r]); + const float3 closest = segments[r] * along - offset; + rays |= dot(closest, closest) <= bounds.w * bounds.w ? 1u << r : 0u; + } + if (rays == 0u) + { + continue; + } + + const float4x4 worldToVoxel = gVoxelInstances[i].worldToVoxel; + const uint record = gVoxelInstances[i].recordIndex; + const float3 voxelOrigin = mul(worldToVoxel, float4(origin, 1.0)).xyz; + [unroll] + for (uint r = 0u; r < kAmbientRays; ++r) + { + if ((rays & (1u << r)) == 0u) + { + continue; + } + const float3 voxelSegment = mul(worldToVoxel, float4(segments[r], 0.0)).xyz; + // Five fixed samples then three doubling ones end exactly at the segment end when the first step is a fortieth + const RayHit hit = marchSuperSparse(record, voxelOrigin, voxelSegment, 0.0, travelled[r], length(voxelSegment) / 40.0); + if (hit.hit) + { + travelled[r] = min(travelled[r], hit.t); + } + } + } + + float sum = 0.0; + [unroll] + for (uint r = 0u; r < kAmbientRays; ++r) + { + sum += travelled[r]; + } + return sum / float(kAmbientRays); +} + +/// Cone slope from the receiver to the emitter +float shadowConeSlope(GpuLight light, float distance) +{ + return min(0.5 * (light.shape.y + light.shape.z) / max(distance, 1e-3), 0.25); +} + +float3 sampleCone(float3 axis, float radius, float2 u) +{ + const float cosTheta = lerp(1.0, cos(radius), u.x); + const float sinTheta = sqrt(saturate(1.0 - cosTheta * cosTheta)); + const float phi = 6.28318531 * u.y; + float3 tangent; + float3 bitangent; + perpendicularBasis(axis, tangent, bitangent); + return normalize(axis * cosTheta + (tangent * cos(phi) + bitangent * sin(phi)) * sinTheta); +} + +/// Marched suns as area lights with the cascade kept for mesh shadows +LightTerms shadeMarchedSuns(Surface surface, float3 shadowOrigin) +{ + LightTerms terms; + terms.diffuse = float3(0.0, 0.0, 0.0); + terms.specular = float3(0.0, 0.0, 0.0); + + const uint casterIndex = uint(gCamera.shadowParams.x) - 1u; + const float reach = float(gPush.lighting.w); + for (uint di = 0u; di < gCamera.directionalLightCountPad.x; ++di) + { + const DirectionalLightGpu light = gCamera.directionalLights[di]; + const float radius = light.direction.w; + if (radius <= 0.0) + { + continue; + } + const float3 center = normalize(-light.direction.xyz); + const float3 L = sampleCone(center, radius, blueNoise(surface.pixel, 200u + di).xy); + if (dot(surface.normal, L) <= 0.0) + { + continue; + } + + float meshShadow = 1.0; + if (tracedShadowsEnabled()) + { + meshShadow = traceShadowRay(surface.worldPosition, surface.geometricNormal, L, 1.0e5); + } + else if (gCamera.shadowParams.x >= 0.5 && di == casterIndex) + { + meshShadow = sampleDirectionalShadow(surface.worldPosition, surface.geometricNormal, center, -surface.viewSpaceZ, surface.pixel); + } + if (meshShadow <= 0.0) + { + continue; + } + + const float3 visibility = min(meshShadow, marchedVisibility(shadowOrigin, shadowOrigin + L * reach, radius)); + if (all(visibility <= 0.0)) + { + continue; + } + const float3 radiance = light.colorIntensity.rgb * light.colorIntensity.a; + const LightTerms sample = shadeLightTerms(surface.normal, surface.viewDirection, L, radiance, surface.albedo, surface.metallic, + surface.roughness, surface.reflectivity); + terms.diffuse += sample.diffuse * visibility; + terms.specular += sample.specular * visibility; + } + return terms; +} + +struct Reservoir +{ + /// asuint of GpuLight::shapeTangent.w stable across light list rebuilds + uint lightId; + /// Unbiased contribution weight and 0 for an empty reservoir + float W; + /// sampleLightShape numbers + float3 u; + /// Candidates behind it + uint M; +}; + +uint4 packReservoir(Reservoir reservoir) +{ + const uint3 u = uint3(saturate(reservoir.u) * 65535.0 + 0.5); + return uint4(reservoir.lightId, asuint(reservoir.W), u.x | (u.y << 16), u.z | (min(reservoir.M, 65535u) << 16)); +} + +Reservoir unpackReservoir(uint4 packed) +{ + Reservoir reservoir; + reservoir.lightId = packed.x; + reservoir.W = asfloat(packed.y); + reservoir.u = float3(float(packed.z & 0xFFFFu), float(packed.z >> 16), float(packed.w & 0xFFFFu)) / 65535.0; + reservoir.M = packed.w >> 16; + return reservoir; +} + +/// Pixels around the reprojected one whose last reservoir joins +static const float kReuseRadius = 8.0; + +static const uint kReuseHistory = 1u; +static const uint kReuseSpatial = 2u; +static const uint kSceneGlass = 4u; + +/// gLights index of a sampled light by stable id in one froxel list +uint findSampledLight(uint lightId, uint indexBase, uint lightCount) +{ + for (uint li = 0u; li < lightCount; ++li) + { + const uint index = gLightIndexList[indexBase + li]; + if (asuint(gLights[index].shapeTangent.w) == lightId) + { + return lightShadowMode(gLights[index]) == kShadowModeSampled ? index : 0xFFFFFFFFu; + } + } + return 0xFFFFFFFFu; +} + +struct SampledLight +{ + float3 L; + float3 radiance; + /// Unshadowed irradiance luminance + float target; + float3 samplePoint; + float slope; + float3 u; + uint lightId; + /// gLights index this frame + uint lightIndex; +}; + +SampledLight evaluateSampledLight(Surface surface, GpuLight light, float3 u) +{ + SampledLight sample; + sample.target = 0.0; + sample.L = float3(0.0, 0.0, 1.0); + sample.radiance = float3(0.0, 0.0, 0.0); + sample.u = u; + sample.lightId = asuint(light.shapeTangent.w); + sample.lightIndex = 0u; + sample.samplePoint = sampleLightShape(light, surface.worldPosition, u); + sample.slope = 0.0; + + float distance; + sample.radiance = lightSampleRadiance(light, surface.worldPosition, sample.samplePoint, sample.L, distance); + const float NdotL = dot(surface.normal, sample.L); + if (all(sample.radiance <= 0.0) || NdotL <= 0.0) + { + return sample; + } + sample.target = dot(sample.radiance, float3(0.2126, 0.7152, 0.0722)) * NdotL; + sample.slope = shadowConeSlope(light, distance); + return sample; +} + +LightTerms shadeSampledLight(Surface surface, SampledLight sample, float3 scale) +{ + if (all(scale <= 0.0)) + { + LightTerms none; + none.diffuse = float3(0.0, 0.0, 0.0); + none.specular = float3(0.0, 0.0, 0.0); + return none; + } + LightTerms terms = shadeLightTerms(surface.normal, surface.viewDirection, sample.L, sample.radiance, surface.albedo, surface.metallic, + surface.roughness, surface.reflectivity); + terms.diffuse *= scale; + terms.specular *= scale; + return terms; +} + +/// Marched lights ray each and sampled lights share one RIS pick +LightTerms shadeMarchedLights(Surface surface, float3 shadowOrigin, float4 motion, out Reservoir reservoir) +{ + reservoir.lightId = 0u; + reservoir.W = 0.0; + reservoir.u = float3(0.0, 0.0, 0.0); + reservoir.M = 0u; + + LightTerms terms = shadeMarchedSuns(surface, shadowOrigin); + + const ClusterCell cluster = clusterCellAt(surface.viewSpaceZ, surface.pixel); + if (cluster.index >= cluster.count) + { + return terms; + } + const uint stride = gClusterParams.clusterDims.w; + const uint indexBase = cluster.index * stride; + const uint lightCount = min(cluster.lightCount, stride); + + for (uint li = 0u; li < lightCount; ++li) + { + const GpuLight light = gLights[gLightIndexList[indexBase + li]]; + if (lightShadowMode(light) != kShadowModeMarched) + { + continue; + } + const float4 noise = blueNoise(surface.pixel, 1u + li); + const float3 samplePoint = sampleLightShape(light, surface.worldPosition, noise.xyz); + float3 L; + float distance; + const float3 radiance = lightSampleRadiance(light, surface.worldPosition, samplePoint, L, distance); + if (all(radiance <= 0.0) || dot(surface.normal, L) <= 0.0) + { + continue; + } + const float meshShadow = meshShadowFactor(light, surface); + const float3 visibility = meshShadow > 0.0 ? min(meshShadow, marchedVisibility(shadowOrigin, samplePoint, shadowConeSlope(light, distance))) + : float3(0.0, 0.0, 0.0); + if (all(visibility <= 0.0)) + { + continue; + } + const LightTerms sample = shadeLightTerms(surface.normal, surface.viewDirection, L, radiance, surface.albedo, surface.metallic, + surface.roughness, surface.reflectivity); + terms.diffuse += sample.diffuse * visibility; + terms.specular += sample.specular * visibility; + } + + // Uniform light and point candidates weighted by luminance times count then ReSTIR temporal reuse + const uint candidates = gPush.lighting.y; + if (lightCount == 0u || candidates == 0u) + { + return terms; + } + float weightSum = 0.0; + uint sampleCount = candidates; + SampledLight chosen; + chosen.target = 0.0; + for (uint k = 0u; k < candidates; ++k) + { + const float4 noise = blueNoise(surface.pixel, 64u + k); + const uint pick = min(uint(noise.x * float(lightCount)), lightCount - 1u); + const GpuLight light = gLights[gLightIndexList[indexBase + pick]]; + if (lightShadowMode(light) != kShadowModeSampled) + { + continue; + } + SampledLight candidate = evaluateSampledLight(surface, light, float3(noise.y, noise.z, frac(noise.x * 7.0 + noise.w))); + candidate.lightIndex = gLightIndexList[indexBase + pick]; + const float weight = candidate.target * float(lightCount); + if (weight <= 0.0) + { + continue; + } + weightSum += weight; + if (noise.w * weightSum < weight) + { + chosen = candidate; + } + } + + if ((gPush.reuse.x & kReuseHistory) != 0u && motion.z <= 0.5) + { + const float2 size = gPush.viewport.xy; + const float2 previousPixel = (surface.pixel / size - (motion.w > 0.5 ? motion.xy : float2(0.0, 0.0))) * size; + const float angle = blueNoise(surface.pixel, 95u).x * 6.28318531; + for (uint t = 0u; t < gPush.reuse.z; ++t) + { + const float2 offset = t == 0u ? float2(0.0, 0.0) : kReuseRadius * float2(cos(angle + float(t) * 2.39996323), sin(angle + float(t) * 2.39996323)); + const int2 tap = int2(floor(previousPixel + offset)); + if (any(tap < int2(0, 0)) || any(tap >= int2(size))) + { + continue; + } + const Reservoir previous = unpackReservoir(gPreviousReservoirs.Load(int3(tap, 0))); + if (previous.W <= 0.0) + { + continue; + } + const uint previousM = min(previous.M, gPush.reuse.y); + sampleCount += previousM; + + // Light ids persist across frames while indices do not + const uint found = findSampledLight(previous.lightId, indexBase, lightCount); + if (found == 0xFFFFFFFFu) + { + continue; + } + SampledLight candidate = evaluateSampledLight(surface, gLights[found], previous.u); + candidate.lightIndex = found; + const float weight = candidate.target * previous.W * float(previousM); + if (weight <= 0.0) + { + continue; + } + weightSum += weight; + if (blueNoise(surface.pixel, 96u + t).y * weightSum < weight) + { + chosen = candidate; + } + } + } + + // The spatial pass traces the merged choice + if (chosen.target > 0.0 && (gPush.reuse.x & kReuseSpatial) != 0u) + { + reservoir.lightId = chosen.lightId; + reservoir.W = weightSum / (float(sampleCount) * chosen.target); + reservoir.u = chosen.u; + reservoir.M = sampleCount; + return terms; + } + if (chosen.target > 0.0) + { + const float unbiasedWeight = weightSum / (float(sampleCount) * chosen.target); + const float meshShadow = meshShadowFactor(gLights[chosen.lightIndex], surface); + const float3 visibility = meshShadow > 0.0 ? min(meshShadow, marchedVisibility(shadowOrigin, chosen.samplePoint, chosen.slope)) + : float3(0.0, 0.0, 0.0); + const LightTerms sampled = shadeSampledLight(surface, chosen, unbiasedWeight * visibility); + terms.diffuse += sampled.diffuse; + terms.specular += sampled.specular; + + // Occluded samples are not reused + reservoir.lightId = chosen.lightId; + reservoir.W = any(visibility > 0.0) ? unbiasedWeight : 0.0; + reservoir.u = chosen.u; + reservoir.M = sampleCount; + } + return terms; +} + + +/// Neighbour reservoirs of this frame a pixel resamples and how far apart in pixels +static const uint kSpatialReservoirs = 3u; +static const float kSpatialRadius = 16.0; + +// Mirrors the attachments of the voxel spatial scope +struct SpatialOutput +{ + float4 noisyDiffuse : SV_Target0; + float4 noisySpecular : SV_Target1; + uint4 reservoir : SV_Target2; +}; + +/// Merges neighbour reservoirs of this frame then traces the chosen light +[shader("fragment")] +SpatialOutput fragmentSpatial(VSOutput input) +{ + SpatialOutput output; + output.noisyDiffuse = float4(0.0, 0.0, 0.0, 0.0); + output.noisySpecular = float4(0.0, 0.0, 0.0, 0.0); + output.reservoir = uint4(0u, 0u, 0u, 0u); + + const int2 pixel = int2(input.position.xy); + const int3 texel = int3(pixel, 0); + const float4 albedo = gAlbedo.Load(texel); + if (!isLitVoxel(albedo.a)) + { + return output; + } + + const float3 position = positionAt(pixel); + const float3 faceNormal = decodeFaceNormal(gFaceNormal.Load(texel)); + const float3 sceneNormal = gSceneNormal.Load(texel).xyz; + const float3 normal = dot(sceneNormal, sceneNormal) > 1e-4 ? normalize(sceneNormal) : faceNormal; + const float4 material = gMaterial.Load(texel); + const float3 toCamera = gCamera.position - position; + + Surface surface; + surface.marchedShadows = true; + surface.worldPosition = position; + surface.normal = normal; + surface.geometricNormal = faceNormal; + surface.viewDirection = dot(toCamera, toCamera) > 1e-10 ? normalize(toCamera) : normal; + surface.albedo = albedo.rgb; + surface.metallic = material.r; + surface.reflectivity = material.g; + surface.roughness = clamp(material.b, 0.045, 1.0); + surface.occlusion = 1.0; + surface.viewSpaceZ = mul(gCamera.view, float4(position, 1.0)).z; + surface.pixel = input.position.xy; + + const ClusterCell cluster = clusterCellAt(surface.viewSpaceZ, surface.pixel); + if (cluster.index >= cluster.count) + { + return output; + } + const uint stride = gClusterParams.clusterDims.w; + const uint indexBase = cluster.index * stride; + const uint lightCount = min(cluster.lightCount, stride); + + float weightSum = 0.0; + uint sampleCount = 0u; + SampledLight chosen; + chosen.target = 0.0; + const float angle = blueNoise(surface.pixel, 140u).x * 6.28318531; + const int2 size = int2(gPush.viewport.xy); + for (uint t = 0u; t <= kSpatialReservoirs; ++t) + { + int2 tap = pixel; + if (t > 0u) + { + const float tapAngle = angle + float(t) * 2.39996323; + const float distance = kSpatialRadius * sqrt((float(t) - 0.5) / float(kSpatialReservoirs)); + tap = int2(floor(input.position.xy + distance * float2(cos(tapAngle), sin(tapAngle)))); + float3 tapFace; + if (any(tap < int2(0, 0)) || any(tap >= size) || !isLitVoxelAt(tap, tapFace)) + { + continue; + } + // A neighbour on another surface chose for a point this pixel is not + const float3 tapNormal = gSceneNormal.Load(int3(tap, 0)).xyz; + const float plane = abs(dot(faceNormal, positionAt(tap) - position)); + if (dot(tapNormal, tapNormal) < 1e-4 || dot(normalize(tapNormal), normal) < 0.9 || plane > 0.02 * length(toCamera) + 0.05) + { + continue; + } + } + + const Reservoir candidateReservoir = unpackReservoir(gCandidateReservoirs.Load(int3(tap, 0))); + if (candidateReservoir.W <= 0.0) + { + sampleCount += t == 0u ? candidateReservoir.M : 0u; + continue; + } + sampleCount += candidateReservoir.M; + + const uint found = findSampledLight(candidateReservoir.lightId, indexBase, lightCount); + if (found == 0xFFFFFFFFu) + { + continue; + } + SampledLight candidate = evaluateSampledLight(surface, gLights[found], candidateReservoir.u); + candidate.lightIndex = found; + const float weight = candidate.target * candidateReservoir.W * float(candidateReservoir.M); + if (weight <= 0.0) + { + continue; + } + weightSum += weight; + if (blueNoise(surface.pixel, 150u + t).z * weightSum < weight) + { + chosen = candidate; + } + } + + if (chosen.target <= 0.0 || sampleCount == 0u) + { + return output; + } + const float unbiasedWeight = weightSum / (float(sampleCount) * chosen.target); + const float3 shadowOrigin = position + faceNormal * (kShadowOriginOffset * kVoxelSize); + const float meshShadow = meshShadowFactor(gLights[chosen.lightIndex], surface); + const float3 visibility = meshShadow > 0.0 ? min(meshShadow, marchedVisibility(shadowOrigin, chosen.samplePoint, chosen.slope)) + : float3(0.0, 0.0, 0.0); + const LightTerms sampled = shadeSampledLight(surface, chosen, unbiasedWeight * visibility); + output.noisyDiffuse = float4(sampled.diffuse, 0.0); + output.noisySpecular = float4(sampled.specular, 0.0); + + Reservoir reservoir; + reservoir.lightId = chosen.lightId; + reservoir.W = any(visibility > 0.0) ? unbiasedWeight : 0.0; + reservoir.u = chosen.u; + reservoir.M = sampleCount; + output.reservoir = packReservoir(reservoir); + return output; +} + [shader("fragment")] FSOutput fragmentMain(VSOutput input) { @@ -127,6 +826,9 @@ FSOutput fragmentMain(VSOutput input) FSOutput output; output.indirectDiffuse = float4(0.0, 0.0, 0.0, 1.0); output.normalRoughness = float4(0.0, 0.0, 0.0, 0.0); + output.noisyDiffuse = float4(0.0, 0.0, 0.0, 0.0); + output.noisySpecular = float4(0.0, 0.0, 0.0, 0.0); + output.reservoir = uint4(0u, 0u, 0u, 0u); if (!isLitVoxel(albedo.a)) { output.color = float4(albedo.rgb, 1.0); @@ -140,13 +842,14 @@ FSOutput fragmentMain(VSOutput input) const float roughness = clamp(material.b, 0.045, 1.0); // SSR only reads roughness so zero reflectivity must look fully rough - output.normalRoughness = float4(normal, lerp(1.0, roughness, material.g)); + output.normalRoughness = encodeSceneNormal(normal, lerp(1.0, roughness, material.g), specularF0(albedo.rgb, material.r)); // The near plane clip can put the hit on the camera const float3 toCamera = gCamera.position - position; const float3 view = dot(toCamera, toCamera) > 1e-10 ? normalize(toCamera) : normal; Surface surface; + surface.marchedShadows = true; surface.worldPosition = position; surface.normal = normal; surface.geometricNormal = faceNormal; @@ -159,8 +862,45 @@ FSOutput fragmentMain(VSOutput input) surface.viewSpaceZ = mul(gCamera.view, float4(position, 1.0)).z; surface.pixel = input.position.xy; - const ShadingResult lit = shadeSurfaceLighting(surface); + VoxelGlassFilter glassFilter; + glassFilter.origin = position + faceNormal * (kShadowOriginOffset * kVoxelSize); + glassFilter.sunReach = float(gPush.reuse.x >> 16); + glassFilter.sunSlope = 0.005; + const ShadingResult lit = shadeSurfaceLighting(surface, glassFilter); output.indirectDiffuse = float4(lit.indirectDiffuse, 1.0); - output.color = float4(lit.isDebugView ? lit.color : lit.color + albedo.rgb * decodeEmissive(material.a), 1.0); + if (lit.isDebugView) + { + output.color = float4(lit.color, 1.0); + return output; + } + + output.color = float4(lit.color + albedo.rgb * decodeEmissive(material.a), 1.0); + if (gPush.lighting.z != 0u) + { + const float3 shadowOrigin = position + faceNormal * (kShadowOriginOffset * kVoxelSize); + Reservoir reservoir; + LightTerms marched = shadeMarchedLights(surface, shadowOrigin, gMotion.Load(texel), reservoir); + output.reservoir = packReservoir(reservoir); + + // Traced AO replaces SSAO so ambient diffuse moves into the filtered term + if (gPush.reuse.w != 0u) + { + const float occlusion = ambientOcclusion(surface, shadowOrigin, float(gPush.reuse.w) * 0.01); + const float3 demodulated = select(albedo.rgb > 1e-3, lit.indirectDiffuse / max(albedo.rgb, 1e-3), float3(0.0, 0.0, 0.0)); + output.color.rgb -= lit.indirectDiffuse; + marched.diffuse += demodulated * occlusion; + output.indirectDiffuse = float4(0.0, 0.0, 0.0, 1.0); + } + + if (gPush.lighting.z == 2u) + { + output.color.rgb += albedo.rgb * marched.diffuse + marched.specular; + } + else + { + output.noisyDiffuse = float4(marched.diffuse, 0.0); + output.noisySpecular = float4(marched.specular, 0.0); + } + } return output; } diff --git a/engine/assets/shaders/voxel_mesh.slang b/engine/assets/shaders/voxel_mesh.slang index 6e9efaf02..8024e1766 100644 --- a/engine/assets/shaders/voxel_mesh.slang +++ b/engine/assets/shaders/voxel_mesh.slang @@ -3,28 +3,9 @@ import voxel; import voxel_dda; import voxel_mesh_common; +import voxel_mesh_window; import lighting; -groupshared uint sFirstEntry; -groupshared uint sQuads[kMeshletQuads]; - -/// next is the global index of the next quad of the slice -struct WindowSink : IQuadSink -{ - uint next; - uint windowStart; - - [mutating] - void quad(uint packedQuad) - { - if (next - windowStart < kMeshletQuads) - { - sQuads[next - windowStart] = packedQuad; - } - ++next; - } -} - [shader("mesh")] [outputtopology("triangle")] [numthreads(kMeshletQuads, 1, 1)] @@ -55,7 +36,7 @@ void quadMeshMain(uint3 group : SV_GroupID, uint thread : SV_GroupIndex, in payl } GroupMemoryBarrierWithGroupSync(); - const uint2 cell = gVoxelCells[payload.entryIndex]; + const uint2 cell = gVoxelCells[payload.cell]; const VoxelInstance instance = gVoxelInstances[cellEntryInstance(cell.x)]; // Every slice holds a quad so a window spans at most kMeshletQuads slices @@ -63,31 +44,15 @@ void quadMeshMain(uint3 group : SV_GroupID, uint thread : SV_GroupIndex, in payl const uint entry = index < payload.entryCount ? payload.entries[index] : 0u; if (index < payload.entryCount && entryFirstQuad(entry) < windowStart + kMeshletQuads) { - const uint brickInCell = entryBrick(entry); - const uint direction = entrySlice(entry) >> 3; - const uint slice = entrySlice(entry) & 7u; - const int3 brick = int3(brickOfCell(cell, brickInCell)); - - const uint brickEntry = brickEntryAt(instance.recordIndex, brick); - const uint neighbourEntry = brickEntryAt(instance.recordIndex, brick + directionStep(direction)); - uint occupancy[16]; - uint neighbour[16]; - loadBrickSlices(brickEntry, occupancy); - loadBrickSlices(neighbourEntry, neighbour); - - uint faces[16]; - exposedFaces(occupancy, neighbour, direction, faces); - uint rows[8]; - sliceRows(faces, direction >> 1, slice, rows); WindowSink sink = { entryFirstQuad(entry), windowStart }; - greedySlice(rows, direction, slice, brickInCell, sink); + meshEntry(entry, instance.recordIndex, unpackCell(cell.y), sink); } GroupMemoryBarrierWithGroupSync(); SetMeshOutputCounts(quads * 4u, quads * 2u); if (thread < quads) { - const uint packedQuad = sQuads[thread]; + const uint packedQuad = windowQuad(thread); const uint first = thread * 4u; for (uint corner = 0u; corner < 4u; ++corner) { diff --git a/engine/assets/shaders/voxel_mesh_common.slang b/engine/assets/shaders/voxel_mesh_common.slang index b30556751..6d8e42533 100644 --- a/engine/assets/shaders/voxel_mesh_common.slang +++ b/engine/assets/shaders/voxel_mesh_common.slang @@ -1,21 +1,18 @@ -// Shared by voxel_mesh_task.slang and voxel_mesh.slang which compile apart since Slang gives task and mesh entry points of -// one module separate payload variables that NVIDIA drivers mishandle (shader-slang/slang#10123) -import voxel; +// Shared by the camera and shadow task and mesh stages which compile apart since Slang gives task and mesh entry points of one +// module separate payload variables that NVIDIA drivers mishandle (shader-slang/slang#10123) import voxel_dda; static const uint kTaskBricks = 64u / kTaskGroupsPerCell; -/// Camera facing sides of a brick are at most 3 so its slices are at most 24 +/// Eye facing sides of a brick are at most 3 so its slices are at most 24 static const uint kBrickEntries = 3u * 8u; static const uint kMaxEntries = kTaskBricks * kBrickEntries; static const uint kMeshletQuads = 64u; -/// gPush.bucket picks the list since the near and late near lists share these entry points -static const uint kMeshEarly = 0u; - /// Mesh group m takes quads m * kMeshletQuads onward of the task group in entry order struct QuadTaskPayload { - uint entryIndex; + /// The camera cell list entry or the packCell coordinates of a shadow cell + uint cell; uint entryCount; uint quadCount; /// brickInCell 6 bits then direction * 8 + slice 6 bits then the first quad of the slice 16 bits @@ -37,9 +34,9 @@ uint entryFirstQuad(uint entry) return entry >> 12; } -uint3 brickOfCell(uint2 cell, uint brickInCell) +uint3 brickOfCell(uint3 cell, uint brickInCell) { - return unpackCell(cell.y) * kCoarseBricks + uint3(brickInCell & 3u, (brickInCell >> 2) & 3u, brickInCell >> 4); + return cell * kCoarseBricks + uint3(brickInCell & 3u, (brickInCell >> 2) & 3u, brickInCell >> 4); } int3 directionStep(uint direction) @@ -105,11 +102,12 @@ void sliceRows(uint faces[16], uint axis, uint slice, out uint rows[8]) } } -/// Mirrors the slice tests of meshBrick in voxel_cells.slang -bool sliceFacesCamera(float3 camera, int3 brickMin, uint axis, bool positive, uint slice) +/// Mirrors the slice tests of meshBrick in voxel_cells.slang with w 1 for a point eye and 0 for a direction toward it +bool sliceFacesEye(float4 eye, int3 brickMin, uint axis, bool positive, uint slice) { const float plane = float(brickMin[axis] + int(slice)) + (positive ? 1.0 : 0.0); - return positive ? camera[axis] > plane : camera[axis] < plane; + const float side = eye[axis] - plane * eye.w; + return positive ? side > 0.0 : side < 0.0; } interface IQuadSink @@ -142,3 +140,77 @@ uint greedySlice(uint rows[8], uint direction, uint slice, uin } return count; } + +struct CountSink : IQuadSink +{ + [mutating] + void quad(uint packedQuad) { } +} + +interface ISliceSink +{ + /// directionSlice is direction * 8 + slice + [mutating] + void slice(uint directionSlice, uint quads); +} + +/// Greedy counts the quads of every eye facing slice of a brick that is not empty +void countBrickSlices(uint recordIndex, int3 brick, uint brickEntry, uint brickInCell, float4 eye, inout Sink sink) +{ + const int3 brickMin = brick * int(kBrickDim); + uint occupancy[16]; + loadSolidSlices(recordIndex, brick, brickEntry, occupancy); + + CountSink counter; + for (uint direction = 0u; direction < 6u; ++direction) + { + const uint axis = direction >> 1; + const bool positive = (direction & 1u) == 0u; + if (!sliceFacesEye(eye, brickMin, axis, positive, positive ? 0u : 7u)) + { + continue; + } + + const int3 neighbourBrick = brick + directionStep(direction); + uint neighbour[16]; + loadSolidSlices(recordIndex, neighbourBrick, brickEntryAt(recordIndex, neighbourBrick), neighbour); + + uint faces[16]; + exposedFaces(occupancy, neighbour, direction, faces); + for (uint slice = 0u; slice < 8u; ++slice) + { + if (!sliceFacesEye(eye, brickMin, axis, positive, slice)) + { + continue; + } + uint rows[8]; + sliceRows(faces, axis, slice, rows); + const uint count = greedySlice(rows, direction, slice, brickInCell, counter); + if (count != 0u) + { + sink.slice(direction * 8u + slice, count); + } + } + } +} + +/// Quads of one payload entry again in the countBrickSlices order +void meshEntry(uint entry, uint recordIndex, uint3 cell, inout Sink sink) +{ + const uint brickInCell = entryBrick(entry); + const uint direction = entrySlice(entry) >> 3; + const uint slice = entrySlice(entry) & 7u; + const int3 brick = int3(brickOfCell(cell, brickInCell)); + + uint occupancy[16]; + uint neighbour[16]; + const int3 neighbourBrick = brick + directionStep(direction); + loadSolidSlices(recordIndex, brick, brickEntryAt(recordIndex, brick), occupancy); + loadSolidSlices(recordIndex, neighbourBrick, brickEntryAt(recordIndex, neighbourBrick), neighbour); + + uint faces[16]; + exposedFaces(occupancy, neighbour, direction, faces); + uint rows[8]; + sliceRows(faces, direction >> 1, slice, rows); + greedySlice(rows, direction, slice, brickInCell, sink); +} diff --git a/engine/assets/shaders/voxel_mesh_task.slang b/engine/assets/shaders/voxel_mesh_task.slang index b808ca7ef..5b489eb57 100644 --- a/engine/assets/shaders/voxel_mesh_task.slang +++ b/engine/assets/shaders/voxel_mesh_task.slang @@ -3,18 +3,20 @@ import voxel; import voxel_dda; import voxel_mesh_common; +import voxel_task_payload; import lighting; -groupshared QuadTaskPayload sPayload; -/// Each thread lists its slices here before the scan gives them their place in the payload -groupshared uint sStaged[kMaxEntries]; -groupshared uint sEntryBase[kTaskBricks]; -groupshared uint sQuadBase[kTaskBricks]; +/// gPush.bucket picks the list since the near and late near lists share these entry points +static const uint kMeshEarly = 0u; -struct CountSink : IQuadSink +struct CameraBrickView : IBrickView { - [mutating] - void quad(uint packedQuad) { } + float4x4 voxelToClip; + + bool sees(float3 low, float3 high) + { + return !outsideFrustum(voxelToClip, low, high); + } } [shader("amplification")] @@ -29,88 +31,10 @@ void quadTaskMain(uint3 group : SV_GroupID, uint thread : SV_GroupIndex) const uint2 cell = gVoxelCells[entryIndex]; const VoxelInstance instance = gVoxelInstances[cellEntryInstance(cell.x)]; - const int3 brick = int3(brickOfCell(cell, brickInCell)); - const uint brickEntry = brickEntryAt(instance.recordIndex, brick); - - const int3 brickMin = brick * int(kBrickDim); - const float3 brickLow = float3(brickMin); - const bool visible = (brickEntry & 3u) != kTagEmpty && - !outsideFrustum(mul(gCamera.viewProjection, instance.voxelToWorld), brickLow, brickLow + float(kBrickDim)); - - const uint staged = thread * kBrickEntries; - uint entries = 0u; - uint quads = 0u; - if (visible) - { - uint occupancy[16]; - loadBrickSlices(brickEntry, occupancy); - const float3 camera = mul(instance.worldToVoxel, float4(gCamera.position, 1.0)).xyz; - - CountSink sink; - for (uint direction = 0u; direction < 6u; ++direction) - { - const uint axis = direction >> 1; - const bool positive = (direction & 1u) == 0u; - if (!sliceFacesCamera(camera, brickMin, axis, positive, positive ? 0u : 7u)) - { - continue; - } - - const uint neighbourEntry = brickEntryAt(instance.recordIndex, brick + directionStep(direction)); - uint neighbour[16]; - loadBrickSlices(neighbourEntry, neighbour); - - uint faces[16]; - exposedFaces(occupancy, neighbour, direction, faces); - for (uint slice = 0u; slice < 8u; ++slice) - { - if (!sliceFacesCamera(camera, brickMin, axis, positive, slice)) - { - continue; - } - uint rows[8]; - sliceRows(faces, axis, slice, rows); - const uint count = greedySlice(rows, direction, slice, brickInCell, sink); - if (count != 0u) - { - sStaged[staged + entries] = (direction * 8u + slice) | (count << 6); - ++entries; - quads += count; - } - } - } - } - sEntryBase[thread] = entries; - sQuadBase[thread] = quads; - GroupMemoryBarrierWithGroupSync(); - - if (thread == 0u) - { - uint entryTotal = 0u; - uint quadTotal = 0u; - for (uint i = 0u; i < kTaskBricks; ++i) - { - const uint threadEntries = sEntryBase[i]; - const uint threadQuads = sQuadBase[i]; - sEntryBase[i] = entryTotal; - sQuadBase[i] = quadTotal; - entryTotal += threadEntries; - quadTotal += threadQuads; - } - sPayload.entryIndex = entryIndex; - sPayload.entryCount = entryTotal; - sPayload.quadCount = quadTotal; - } - GroupMemoryBarrierWithGroupSync(); - - uint firstQuad = sQuadBase[thread]; - for (uint i = 0u; i < entries; ++i) - { - const uint slice = sStaged[staged + i]; - sPayload.entries[sEntryBase[thread] + i] = brickInCell | ((slice & 63u) << 6) | (firstQuad << 12); - firstQuad += slice >> 6; - } - GroupMemoryBarrierWithGroupSync(); + const int3 brick = int3(brickOfCell(unpackCell(cell.y), brickInCell)); + const float4 eye = mul(instance.worldToVoxel, float4(gCamera.position, 1.0)); + const CameraBrickView view = { mul(gCamera.viewProjection, instance.voxelToWorld) }; + buildTaskPayload(thread, brickInCell, entryIndex, instance.recordIndex, brick, eye, view); DispatchMesh((sPayload.quadCount + kMeshletQuads - 1u) / kMeshletQuads, 1u, 1u, sPayload); } diff --git a/engine/assets/shaders/voxel_mesh_window.slang b/engine/assets/shaders/voxel_mesh_window.slang new file mode 100644 index 000000000..0b3e83d16 --- /dev/null +++ b/engine/assets/shaders/voxel_mesh_window.slang @@ -0,0 +1,27 @@ +// Quad window shared by the camera and shadow mesh stages and never imported by a task stage +import voxel_mesh_common; + +groupshared uint sFirstEntry; +groupshared uint sQuads[kMeshletQuads]; + +uint windowQuad(uint index) +{ + return sQuads[index]; +} + +/// next is the global index of the next quad of the slice +struct WindowSink : IQuadSink +{ + uint next; + uint windowStart; + + [mutating] + void quad(uint packedQuad) + { + if (next - windowStart < kMeshletQuads) + { + sQuads[next - windowStart] = packedQuad; + } + ++next; + } +} diff --git a/engine/assets/shaders/voxel_mesh_window.slang.meta b/engine/assets/shaders/voxel_mesh_window.slang.meta new file mode 100644 index 000000000..2f4c56525 --- /dev/null +++ b/engine/assets/shaders/voxel_mesh_window.slang.meta @@ -0,0 +1,4 @@ +uid = "V0xM3shWndw" +type = "slang_module" +created_at = "2026-10-16T12:00:00.0000000Z" +modified_at = "2026-10-16T12:00:00.0000000Z" diff --git a/engine/assets/shaders/voxel_shadow.slang b/engine/assets/shaders/voxel_shadow.slang index d6ba8e5f0..9ec83c8f1 100644 --- a/engine/assets/shaders/voxel_shadow.slang +++ b/engine/assets/shaders/voxel_shadow.slang @@ -1,23 +1,11 @@ import voxel_dda; +import shadow_views; -// Must match renderer::shadows::k_max_shadow_views -static const uint kMaxShadowViews = 20; +// Mirrors ShadowPass k_caster_bias_slope since SV_Depth bypasses the pipeline depth bias +static const float kSlopeBias = 1.25; -// Mirror ShadowPass depth_bias_slope since SV_Depth bypasses the pipeline depth bias -static const float kSlopeBias = 3.0; - -// Derivatives across a silhouette see the miss lanes so the slope term is capped -static const float kMaxSlopeBias = 0.005; - -/// shadow_depth.slang declares only the first array of the same buffer -struct ShadowUBO -{ - float4x4 viewProjection[kMaxShadowViews]; - float4x4 inverseViewProjection[kMaxShadowViews]; -}; - -[[vk::binding(0, 0)]] -ConstantBuffer gShadow; +/// World cap of the slope term so a grazing face never pushes its shadow further than this +static const float kMaxSlopeBiasMeters = 0.1; /// Mirrors renderer::ShadowPass::VoxelShadowPush struct PushConstants @@ -27,7 +15,8 @@ struct PushConstants /// Depth units of the rasterizer constant bias for this shadow format float constantBias; - uint pad0; + /// Nonzero for cascades whose rays march from behind the near plane + uint directional; }; [[vk::push_constant]] @@ -60,35 +49,78 @@ VSOutput vertexMain(uint vertexId : SV_VertexID, uint viewId : SV_ViewID) return output; } +/// The view ray of an NDC point from the near plane where t = 1 reaches the far plane +void viewRay(uint view, float2 ndc, out float3 nearWorld, out float3 worldDirection) +{ + const float4x4 inverseViewProjection = gShadow.inverseViewProjection[view]; + const float4 nearPoint = mul(inverseViewProjection, float4(ndc, 0.0, 1.0)); + const float4 farPoint = mul(inverseViewProjection, float4(ndc, 1.0, 1.0)); + nearWorld = nearPoint.xyz / nearPoint.w; + worldDirection = farPoint.xyz / farPoint.w - nearWorld; +} + +float shadowDepth(uint view, float3 world) +{ + const float4 clip = mul(gShadow.viewProjection[view], float4(world, 1.0)); + return clip.z / clip.w; +} + +/// Depth of a neighbour ray where it crosses the plane of the hit face +float facePlaneDepth(uint view, float4x4 worldToVoxel, float2 ndc, uint axis, float plane) +{ + float3 nearWorld; + float3 worldDirection; + viewRay(view, ndc, nearWorld, worldDirection); + const float3 origin = mul(worldToVoxel, float4(nearWorld, 1.0)).xyz; + const float3 direction = mul(worldToVoxel, float4(worldDirection, 0.0)).xyz; + const float t = abs(direction[axis]) > 1e-8 ? (plane - origin[axis]) / direction[axis] : 0.0; + return shadowDepth(view, nearWorld + worldDirection * t); +} + /// False discards bool casterDepth(VSOutput input, out float biasedDepth) { - const VoxelInstance instance = gVoxelInstances[gPush.instanceIndex]; - const float4x4 inverseViewProjection = gShadow.inverseViewProjection[input.view]; + const float4x4 worldToVoxel = gVoxelInstances[gPush.instanceIndex].worldToVoxel; + const uint record = gVoxelInstances[gPush.instanceIndex].recordIndex; - // From the near plane so a box crossing it marches from where it is visible + // Rays leave from the near plane const float2 ndc = input.clip.xy / input.clip.w; - const float4 nearPoint = mul(inverseViewProjection, float4(ndc, 0.0, 1.0)); - const float4 farPoint = mul(inverseViewProjection, float4(ndc, 1.0, 1.0)); - const float3 nearWorld = nearPoint.xyz / nearPoint.w; - const float3 worldDirection = farPoint.xyz / farPoint.w - nearWorld; + // NDC is smooth across the box unlike the hit depth + const float2 ndcX = ndc + ddx(ndc); + const float2 ndcY = ndc + ddy(ndc); + + float3 nearWorld; + float3 worldDirection; + viewRay(input.view, ndc, nearWorld, worldDirection); + + const float3 origin = mul(worldToVoxel, float4(nearWorld, 1.0)).xyz; + const float3 direction = mul(worldToVoxel, float4(worldDirection, 0.0)).xyz; + // A cascade clamps depth so a caster in front of its near plane still writes depth 0 + const float tMin = gPush.directional != 0u ? -kFloatMax : 0.0; + const RayHit hit = marchRay(record, origin, direction, tMin, 1.0); + + biasedDepth = input.position.z; + if (!hit.hit) + { + return false; + } - // t = 1 is the far plane - const float3 origin = mul(instance.worldToVoxel, float4(nearWorld, 1.0)).xyz; - const float3 direction = mul(instance.worldToVoxel, float4(worldDirection, 0.0)).xyz; - const RayHit hit = marchRay(instance.recordIndex, origin, direction, 0.0, 1.0); + const float3 hitWorld = nearWorld + worldDirection * hit.t; + const float depth = shadowDepth(input.view, hitWorld); - float depth = input.position.z; - if (hit.hit) + // The receiver plane slope of the hit face as the rasterizer bias of a quad would see it + float slopeBias = 0.0; + if (hit.axis >= 0) { - const float4 clip = mul(gShadow.viewProjection[input.view], float4(nearWorld + worldDirection * hit.t, 1.0)); - depth = clip.z / clip.w; + const uint axis = uint(hit.axis); + const float plane = float(hit.voxel[axis]) + (direction[axis] > 0.0 ? 0.0 : 1.0); + const float slope = max(abs(facePlaneDepth(input.view, worldToVoxel, ndcX, axis, plane) - depth), + abs(facePlaneDepth(input.view, worldToVoxel, ndcY, axis, plane) - depth)); + const float cap = shadowDepth(input.view, hitWorld + normalize(worldDirection) * kMaxSlopeBiasMeters) - depth; + slopeBias = min(kSlopeBias * slope, cap); } - - // Before the caller discards since derivatives need uniform control flow - const float slope = max(abs(ddx(depth)), abs(ddy(depth))); - biasedDepth = saturate(depth + min(kSlopeBias * slope, kMaxSlopeBias) + gPush.constantBias); - return hit.hit; + biasedDepth = saturate(depth + slopeBias + gPush.constantBias); + return true; } // Front faces of a box wholly past the near plane so early depth rejects it behind earlier casters diff --git a/engine/assets/shaders/voxel_shadow_mesh.slang b/engine/assets/shaders/voxel_shadow_mesh.slang new file mode 100644 index 000000000..c2a0ad174 --- /dev/null +++ b/engine/assets/shaders/voxel_shadow_mesh.slang @@ -0,0 +1,69 @@ +// Mesh stage of the voxel shadow mesh backend which writes depth only so the pipeline has no fragment stage +import voxel_dda; +import voxel_mesh_common; +import voxel_mesh_window; +import voxel_shadow_mesh_common; +import shadow_views; + +struct ShadowVertex +{ + float4 position : SV_Position; +}; + +[shader("mesh")] +[outputtopology("triangle")] +[numthreads(kMeshletQuads, 1, 1)] +void shadowMeshMain(uint3 group : SV_GroupID, uint thread : SV_GroupIndex, uint viewId : SV_ViewID, in payload QuadTaskPayload payload, + OutputVertices vertices, OutputIndices triangles) +{ + const uint windowStart = group.x * kMeshletQuads; + const uint quads = min(payload.quadCount - windowStart, kMeshletQuads); + + // Last slice starting at or before the window so a slice split across two windows meshes in both + if (thread == 0u) + { + uint low = 0u; + uint high = payload.entryCount; + while (high - low > 1u) + { + const uint middle = (low + high) / 2u; + if (entryFirstQuad(payload.entries[middle]) <= windowStart) + { + low = middle; + } + else + { + high = middle; + } + } + sFirstEntry = low; + } + GroupMemoryBarrierWithGroupSync(); + + const uint3 cell = unpackCell(payload.cell); + const VoxelInstance instance = gVoxelInstances[gPush.instanceIndex]; + + const uint index = sFirstEntry + thread; + const uint entry = index < payload.entryCount ? payload.entries[index] : 0u; + if (index < payload.entryCount && entryFirstQuad(entry) < windowStart + kMeshletQuads) + { + WindowSink sink = { entryFirstQuad(entry), windowStart }; + meshEntry(entry, instance.recordIndex, cell, sink); + } + GroupMemoryBarrierWithGroupSync(); + + SetMeshOutputCounts(quads * 4u, quads * 2u); + if (thread < quads) + { + const uint packedQuad = windowQuad(thread); + const float4x4 voxelToClip = mul(gShadow.viewProjection[gPush.viewIndex + viewId], instance.voxelToWorld); + const uint first = thread * 4u; + for (uint corner = 0u; corner < 4u; ++corner) + { + vertices[first + corner].position = mul(voxelToClip, float4(quadCornerLocal(cell, packedQuad, corner), 1.0)); + } + // Drawn without culling so the winding never matters + triangles[thread * 2u] = uint3(first, first + 1u, first + 2u); + triangles[thread * 2u + 1u] = uint3(first, first + 2u, first + 3u); + } +} diff --git a/engine/assets/shaders/voxel_shadow_mesh.slang.meta b/engine/assets/shaders/voxel_shadow_mesh.slang.meta new file mode 100644 index 000000000..e5eb20e0e --- /dev/null +++ b/engine/assets/shaders/voxel_shadow_mesh.slang.meta @@ -0,0 +1,4 @@ +uid = "V0xShdwMsh0" +type = "shader" +created_at = "2026-10-16T12:00:00.0000000Z" +modified_at = "2026-10-16T12:00:00.0000000Z" diff --git a/engine/assets/shaders/voxel_shadow_mesh_common.slang b/engine/assets/shaders/voxel_shadow_mesh_common.slang new file mode 100644 index 000000000..30a1cf33c --- /dev/null +++ b/engine/assets/shaders/voxel_shadow_mesh_common.slang @@ -0,0 +1,16 @@ +// Shared by the voxel shadow task and mesh stages +import shadow_views; + +/// Mirrors renderer::ShadowPass::VoxelMeshShadowPush +struct ShadowMeshPush +{ + uint instanceIndex; + /// The first view of the layer group + uint viewIndex; + uint viewCount; + /// Nonzero for a sun whose views are orthographic + uint directional; +}; + +[[vk::push_constant]] +ShadowMeshPush gPush; diff --git a/engine/assets/shaders/voxel_shadow_mesh_common.slang.meta b/engine/assets/shaders/voxel_shadow_mesh_common.slang.meta new file mode 100644 index 000000000..28e4f42c3 --- /dev/null +++ b/engine/assets/shaders/voxel_shadow_mesh_common.slang.meta @@ -0,0 +1,4 @@ +uid = "V0xShMshCmn" +type = "slang_module" +created_at = "2026-10-16T12:00:00.0000000Z" +modified_at = "2026-10-16T12:00:00.0000000Z" diff --git a/engine/assets/shaders/voxel_shadow_task.slang b/engine/assets/shaders/voxel_shadow_task.slang new file mode 100644 index 000000000..2ff84bfb9 --- /dev/null +++ b/engine/assets/shaders/voxel_shadow_task.slang @@ -0,0 +1,47 @@ +// Task stage of the voxel shadow mesh backend with one dispatch per volume and kTaskGroupsPerCell groups per coarse cell in x +import voxel_dda; +import voxel_mesh_common; +import voxel_task_payload; +import voxel_shadow_mesh_common; +import shadow_views; + +/// Any view of the layer group since one task group feeds all of them +struct LightBrickView : IBrickView +{ + float4x4 voxelToWorld; + + bool sees(float3 low, float3 high) + { + for (uint view = 0u; view < gPush.viewCount; ++view) + { + if (!outsideFrustum(mul(gShadow.viewProjection[gPush.viewIndex + view], voxelToWorld), low, high)) + { + return true; + } + } + return false; + } +} + +/// The light in voxel space as a point with w 1 or the direction toward it with w 0 +float4 lightEye(VoxelInstance instance) +{ + // The clip direction of growing depth is the eye of a perspective view and points away from an orthographic light + const float4 away = mul(gShadow.inverseViewProjection[gPush.viewIndex], float4(0.0, 0.0, 1.0, 0.0)); + const float4 light = gPush.directional != 0u ? float4(-away.xyz, 0.0) : float4(away.xyz / away.w, 1.0); + return mul(instance.worldToVoxel, light); +} + +[shader("amplification")] +[numthreads(kTaskBricks, 1, 1)] +void shadowTaskMain(uint3 group : SV_GroupID, uint thread : SV_GroupIndex) +{ + const uint3 cell = uint3(group.x / kTaskGroupsPerCell, group.y, group.z); + const uint brickInCell = (group.x % kTaskGroupsPerCell) * kTaskBricks + thread; + const VoxelInstance instance = gVoxelInstances[gPush.instanceIndex]; + const int3 brick = int3(brickOfCell(cell, brickInCell)); + const LightBrickView view = { instance.voxelToWorld }; + + buildTaskPayload(thread, brickInCell, packCell(cell), instance.recordIndex, brick, lightEye(instance), view); + DispatchMesh((sPayload.quadCount + kMeshletQuads - 1u) / kMeshletQuads, 1u, 1u, sPayload); +} diff --git a/engine/assets/shaders/voxel_shadow_task.slang.meta b/engine/assets/shaders/voxel_shadow_task.slang.meta new file mode 100644 index 000000000..97707694b --- /dev/null +++ b/engine/assets/shaders/voxel_shadow_task.slang.meta @@ -0,0 +1,4 @@ +uid = "V0xShdwTsk0" +type = "shader" +created_at = "2026-10-16T12:00:00.0000000Z" +modified_at = "2026-10-16T12:00:00.0000000Z" diff --git a/engine/assets/shaders/voxel_task_payload.slang b/engine/assets/shaders/voxel_task_payload.slang new file mode 100644 index 000000000..f32ccc0a3 --- /dev/null +++ b/engine/assets/shaders/voxel_task_payload.slang @@ -0,0 +1,74 @@ +// Payload building shared by the camera and shadow task stages and never imported by a mesh stage +import voxel_dda; +import voxel_mesh_common; + +groupshared QuadTaskPayload sPayload; +/// Each thread lists its slices here before the scan gives them their place in the payload +groupshared uint sStaged[kMaxEntries]; +groupshared uint sEntryBase[kTaskBricks]; +groupshared uint sQuadBase[kTaskBricks]; + +/// Brick culling of the task stage +interface IBrickView +{ + bool sees(float3 low, float3 high); +} + +struct StagingSink : ISliceSink +{ + uint staged; + uint entries; + uint quads; + + [mutating] + void slice(uint directionSlice, uint count) + { + sStaged[staged + entries] = directionSlice | (count << 6); + ++entries; + quads += count; + } +} + +/// One thread per brick fills sPayload so every mesh group gets kMeshletQuads quads and the entry point dispatches it +void buildTaskPayload(uint thread, uint brickInCell, uint cell, uint recordIndex, int3 brick, float4 eye, View view) +{ + const uint brickEntry = brickEntryAt(recordIndex, brick); + const float3 brickLow = float3(brick * int(kBrickDim)); + + StagingSink sink = { thread * kBrickEntries, 0u, 0u }; + if ((brickEntry & 3u) != kTagEmpty && view.sees(brickLow, brickLow + float(kBrickDim))) + { + countBrickSlices(recordIndex, brick, brickEntry, brickInCell, eye, sink); + } + sEntryBase[thread] = sink.entries; + sQuadBase[thread] = sink.quads; + GroupMemoryBarrierWithGroupSync(); + + if (thread == 0u) + { + uint entryTotal = 0u; + uint quadTotal = 0u; + for (uint i = 0u; i < kTaskBricks; ++i) + { + const uint threadEntries = sEntryBase[i]; + const uint threadQuads = sQuadBase[i]; + sEntryBase[i] = entryTotal; + sQuadBase[i] = quadTotal; + entryTotal += threadEntries; + quadTotal += threadQuads; + } + sPayload.cell = cell; + sPayload.entryCount = entryTotal; + sPayload.quadCount = quadTotal; + } + GroupMemoryBarrierWithGroupSync(); + + uint firstQuad = sQuadBase[thread]; + for (uint i = 0u; i < sink.entries; ++i) + { + const uint slice = sStaged[sink.staged + i]; + sPayload.entries[sEntryBase[thread] + i] = brickInCell | ((slice & 63u) << 6) | (firstQuad << 12); + firstQuad += slice >> 6; + } + GroupMemoryBarrierWithGroupSync(); +} diff --git a/engine/assets/shaders/voxel_task_payload.slang.meta b/engine/assets/shaders/voxel_task_payload.slang.meta new file mode 100644 index 000000000..28cc3b964 --- /dev/null +++ b/engine/assets/shaders/voxel_task_payload.slang.meta @@ -0,0 +1,4 @@ +uid = "V0xT4skPayl" +type = "slang_module" +created_at = "2026-10-16T12:00:00.0000000Z" +modified_at = "2026-10-16T12:00:00.0000000Z" diff --git a/engine/ffi/engine.h b/engine/ffi/engine.h index 2514eb6f9..62d10a967 100644 --- a/engine/ffi/engine.h +++ b/engine/ffi/engine.h @@ -147,7 +147,8 @@ TOAST_C_API int toast_viewport_export_shared_slot(uint64_t generation, uint32_t slot, toast_viewport_shared_handles_t* out) NOEXCEPT; /// @brief Keeps the renderer from writing @p slot while the compositor reads it -TOAST_C_API int toast_viewport_hold_shared_slot(uint64_t generation, uint32_t slot) NOEXCEPT; +/// @return 0 if the generation or frame_id is stale, or the slot is already in use +TOAST_C_API int toast_viewport_hold_shared_slot(uint64_t generation, uint32_t slot, uint64_t frame_id) NOEXCEPT; /// @param consumed 1 when the compositor waited for ready and signalled done TOAST_C_API void toast_viewport_release_shared_slot(uint64_t generation, uint32_t slot, int consumed) NOEXCEPT; @@ -159,6 +160,9 @@ TOAST_C_API uint32_t toast_get_max_tick_rate(void) NOEXCEPT; TOAST_C_API void toast_set_show_debug_windows(int visible) NOEXCEPT; +/// @return 1 while the debug overlay is up in game view and the cursor must stay free +TOAST_C_API int toast_debug_overlay_owns_cursor(void) NOEXCEPT; + /// @brief Tells the renderer whether the host application is focused and whether it is minimized /// @param focused 0 holds rendering at 30 fps (or the configured cap when lower), 1 restores the configured cap /// @param minimized 1 stops rendering entirely until called again with 0 diff --git a/engine/src/toast/assets/prefab.cpp b/engine/src/toast/assets/prefab.cpp index 5db56d8e8..c72c5034c 100644 --- a/engine/src/toast/assets/prefab.cpp +++ b/engine/src/toast/assets/prefab.cpp @@ -495,6 +495,27 @@ auto Prefab::parseNodeChunk(std::span lines) -> std::optional node.lua_vars.push_back(std::move(*lua_var)); } i++; + } else if (current.starts_with("~override ")) { + // ~override + std::string_view rest = std::string_view(current).substr(std::string_view("~override ").size()); + const size_t uid_end = rest.find(' '); + bool is_array = false; + const auto target = + uid_end != std::string_view::npos ? valueFromString(FieldType::uid_t, is_array, rest.substr(0, uid_end)) : std::nullopt; + auto field = target.has_value() ? parseField(std::string(rest.substr(uid_end + 1))) : std::nullopt; + if (field) { + const UID target_uid = std::any_cast(*target); + auto existing = + std::ranges::find_if(node.interior, [&](const InteriorOverride& o) { return o.target.data() == target_uid.data(); }); + if (existing == node.interior.end()) { + node.interior.push_back({.target = target_uid}); + existing = std::prev(node.interior.end()); + } + existing->fields.push_back(std::move(*field)); + } else { + TOAST_WARN("ResourceManager", "Malformed interior override in node {}: {}", node.name, current); + } + i++; } else if ( const size_t name_end = current.find(' '); name_end != std::string::npos && std::string_view(current).substr(name_end + 1).starts_with("@signal ") @@ -776,7 +797,6 @@ auto Prefab::parseValue(FieldType type, std::string_view value, bool& is_array) auto Prefab::valueFromString(FieldType type, bool is_array, std::string_view value) -> std::optional { auto parse_single = [](FieldType type, std::string_view token) -> std::optional { switch (type) { - // holy boilerplate lil bro case FieldType::string_t: return std::any {unescapeString(token)}; case FieldType::bool_t: if (auto b = getValue(token)) { @@ -950,6 +970,14 @@ void Prefab::writeNode(const BasicNode& node, std::stringstream& ss) const { writeSignal(signal, ss); } + for (const auto& entry : node.interior) { + for (const auto& field : entry.fields) { + std::stringstream line; + writeField(field, line); + ss << std::format("~override {} ", entry.target) << line.str(); + } + } + for (const auto& group : node.groups) { writeGroup(group, ss); } @@ -1204,6 +1232,15 @@ auto Prefab::toBinary() const -> std::vector { } } + writeValue(buffer, static_cast(node.interior.size())); + for (const auto& entry : node.interior) { + writeValue(buffer, entry.target.data()); + writeValue(buffer, static_cast(entry.fields.size())); + for (const auto& field : entry.fields) { + write_field(field); + } + } + writeValue(buffer, static_cast(node.groups.size())); for (const auto& group : node.groups) { writeString(buffer, group.name); @@ -1347,6 +1384,19 @@ Prefab::Prefab(std::span bytes) { } } + if (header.version >= 5) { + uint32_t interior_count = reader.readValue(); + for (uint32_t j = 0; j < interior_count; ++j) { + InteriorOverride entry; + entry.target.value = reader.readValue(); + uint32_t override_field_count = reader.readValue(); + for (uint32_t k = 0; k < override_field_count; ++k) { + entry.fields.push_back(read_field()); + } + node.interior.push_back(std::move(entry)); + } + } + uint32_t group_count = reader.readValue(); for (uint32_t j = 0; j < group_count; ++j) { Group group; @@ -1658,6 +1708,11 @@ void Prefab::serializeNode(const toast::Node& node, bool is_root) { } } + // Interior nodes are not written as chunks so edits made on them travel as overrides + if (is_reference) { + collectInteriorOverrides(node, out); + } + nodes.push_back(std::move(out)); // we do not go inside prefabs, no need to serialize its children @@ -1788,6 +1843,118 @@ auto Prefab::flattenedRootFields(const Handle& source) const -> std::opt return flat; } +auto Prefab::interiorBase(const toast::Node& node) const -> std::optional { + // The nearest instance root above holds the file this node was written in + const toast::Node* owner = node.m_parent.exists() ? &*node.m_parent : nullptr; + while (owner != nullptr && !owner->isInstanceRoot()) { + owner = owner->m_parent.exists() ? &*owner->m_parent : nullptr; + } + if (owner == nullptr || !owner->m_source_prefab.hasValue()) { + return std::nullopt; + } + + const Prefab& file = owner->m_source_prefab.get(); + const uint64_t uid = node.uid().data(); + const auto chunk = std::ranges::find_if(file.nodes, [uid](const BasicNode& candidate) { + const auto field = candidate.find("m_uid"); + if (!field) { + return false; + } + try { + return field->as().data() == uid; + } catch (const std::bad_any_cast&) { return false; } + }); + if (chunk == file.nodes.end()) { + return std::nullopt; + } + + BasicNode base = *chunk; + // A nested instance chunk only holds its diffs so the rest comes from its own prefab root + if (node.isInstanceRoot()) { + if (auto flat = flattenedRootFields(node.m_source_prefab)) { + for (const Field& field : flat->fields) { + if (!base.find(field.name)) { + base.fields.push_back(field); + } + } + for (const Group& group : flat->groups) { + for (const Field& field : group.fields) { + if (!base.find(field.name)) { + base.fields.push_back(field); + } + } + for (const Subgroup& subgroup : group.subgroups) { + for (const Field& field : subgroup.fields) { + if (!base.find(field.name)) { + base.fields.push_back(field); + } + } + } + } + } + } + + // Overrides the owning file already puts on this node where top level fields win over groups in find + for (const BasicNode& candidate : file.nodes) { + for (const InteriorOverride& entry : candidate.interior) { + if (entry.target.data() != uid) { + continue; + } + for (const Field& field : entry.fields) { + std::erase_if(base.fields, [&](const Field& existing) { return existing.name == field.name; }); + base.fields.push_back(field); + } + } + } + return base; +} + +void Prefab::collectInteriorOverrides(const toast::Node& instance, BasicNode& out) const { + ZoneScoped; + auto visit = [&](this auto&& self, const toast::Node& node) -> void { + for (const auto& child : node.m_children) { + // Raw data of an unresolved reference is not ours to diff + if (child->m_unresolved_chunk) { + continue; + } + const NodeInfo* info = child->info(); + const std::optional base = info != nullptr ? interiorBase(*child) : std::nullopt; + if (base) { + InteriorOverride entry {.target = child->uid()}; + info->forEachBaseType([&](const NodeInfo& level) { + for (const FieldInfo& field : level.all_fields) { + if (field.name == "m_uid" || field.name == "m_parent" || field.name == "m_source_prefab" || !field.get || + field.hasAttribute("ReadOnly") || field.hasAttribute("NoSerialize")) { + continue; + } + std::any value = field.get(const_cast(&*child)); + if (auto* box = std::any_cast>(&value)) { + if (!box->exists()) { + continue; + } + value = (*box)->uid(); + } + const auto base_field = base->find(field.name); + if (base_field && fieldEquals(field.value_type, field.is_array, value, base_field->value)) { + continue; + } + entry.fields.push_back( + Field { + .name = std::string(field.name), .type = field.value_type, .is_array = field.is_array, .value = std::move(value) + } + ); + } + }); + if (!entry.fields.empty()) { + out.interior.push_back(std::move(entry)); + } + } + self(*child); + } + }; + visit(instance); +} + auto Prefab::validate() const -> bool { ZoneScoped; std::unordered_set seen_uids; diff --git a/engine/src/toast/assets/prefab.hpp b/engine/src/toast/assets/prefab.hpp index d3e9b365e..83fe9d91a 100644 --- a/engine/src/toast/assets/prefab.hpp +++ b/engine/src/toast/assets/prefab.hpp @@ -42,7 +42,7 @@ constexpr std::string_view vec3_str = "vec3"; constexpr std::string_view vec4_str = "vec4"; constexpr std::string_view quaternion_str = "quat"; -constexpr uint16_t format_version = 4; ///< current binary layout version +constexpr uint16_t format_version = 5; ///< current binary layout version struct TOAST_API NodeFileBinaryHeader { const std::array magic = {'T', 'N', 'O', 'D', 'E', '\0'}; @@ -222,6 +222,12 @@ class TOAST_API Prefab final : public Asset, public ISaveable { std::vector connections; }; + /// Field values on one node inside a nested instance layered over the inner prefab when it loads + struct InteriorOverride { + toast::UID target; + std::vector fields; + }; + /** * @brief One node entry in a prefab file * @@ -235,8 +241,9 @@ class TOAST_API Prefab final : public Asset, public ISaveable { std::vector fields; std::vector groups; - std::vector lua_vars; ///< empty for old files or nodes with no edited Lua vars - std::vector signals; ///< empty for old files or nodes with no signal connections + std::vector lua_vars; ///< empty for old files or nodes with no edited Lua vars + std::vector signals; ///< empty for old files or nodes with no signal connections + std::vector interior; ///< only on nested instance chunks and empty before format 5 [[nodiscard]] auto find(std::string_view name) const -> std::optional { @@ -286,6 +293,9 @@ class TOAST_API Prefab final : public Asset, public ISaveable { auto fieldEquals(toast::FieldType type, bool is_array, const std::any& a, const std::any& b) const -> bool; auto flattenedRootFields(const Handle& source) const -> std::optional; + /// What a node inside a nested instance looks like straight out of its own prefab + auto interiorBase(const toast::Node& node) const -> std::optional; + void collectInteriorOverrides(const toast::Node& instance, BasicNode& out) const; toast::UID m_self_uid; ///< if this prefab embeds itself, this UID breaks the recursion during instantiation Purpose m_purpose = Purpose::asset_definition; diff --git a/engine/src/toast/engine.cpp b/engine/src/toast/engine.cpp index 25b4ea516..e51501530 100644 --- a/engine/src/toast/engine.cpp +++ b/engine/src/toast/engine.cpp @@ -94,7 +94,8 @@ void addDebrisPass( const renderer::VulkanCore& core, renderer::VulkanRenderer& renderer, vk::Format depth_format, vk::Extent2D extent ) { auto debris = std::make_shared(physics::tunables().debris_max_particles); - auto debris_pass = std::make_unique(core, depth_format, extent, std::move(debris)); + auto debris_pass = + std::make_unique(core, renderer.getSceneColorFormat(), depth_format, extent, std::move(debris)); renderer.setDebrisPass(debris_pass.get()); renderer.addRenderPass(std::move(debris_pass)); } @@ -537,6 +538,28 @@ void Engine::tick() { m->renderer->setGizmoState(gizmo_state); } } + + if (m->renderer) { + std::vector outlined; + if (it != m->owners.end()) { + // A live game shows no editor selection + if (const Workspace* ws = it->second->asWorkspace(); ws != nullptr && !ws->isPlaying()) { + ws->collectOutlinedRids(outlined); + } + } + m->renderer->setOutlinedNodes(std::move(outlined)); + } + + if (m->renderer) { + // The standalone player is always the game + bool game_view = m->shared_target == nullptr; + if (it != m->owners.end()) { + if (const Workspace* ws = it->second->asWorkspace(); ws != nullptr) { + game_view = ws->isPlaying() && ws->usesGameCamera(); + } + } + m->renderer->setGameView(game_view); + } } if (m->renderer) { @@ -666,7 +689,7 @@ void Engine::createSDLWindow(const char* w_name) { }); } - // Players get the performance overlay on top of the game's UI, in every build for now + // Players get the overlay above the game UI and hidden until F3 auto debug_pass = std::make_unique(*m->vulkan_core, color_format, depth_format, extent, &cluster_lighting_pass_ref); debug_pass->setEditorPanelsEnabled(false); @@ -1102,9 +1125,9 @@ auto toast_viewport_export_shared_slot(uint64_t generation, uint32_t slot, toast return 1; } -auto toast_viewport_hold_shared_slot(uint64_t generation, uint32_t slot) noexcept -> int { +auto toast_viewport_hold_shared_slot(uint64_t generation, uint32_t slot, uint64_t frame_id) noexcept -> int { auto* target = toast::Engine::get()->viewportTarget(); - return target != nullptr && target->holdSharedSlot(generation, slot) ? 1 : 0; + return target != nullptr && target->holdSharedSlot(generation, slot, frame_id) ? 1 : 0; } void toast_viewport_release_shared_slot(uint64_t generation, uint32_t slot, int consumed) noexcept { @@ -1230,6 +1253,11 @@ void toast_set_show_debug_windows(int visible) noexcept { } } +auto toast_debug_overlay_owns_cursor() noexcept -> int { + const auto* vk_renderer = renderer::VulkanRenderer::instance; + return vk_renderer != nullptr && vk_renderer->debugOverlayOwnsCursor() ? 1 : 0; +} + void toast_set_window_state(int focused, int minimized) noexcept { auto* vk_renderer = renderer::VulkanRenderer::instance; if (vk_renderer == nullptr) { diff --git a/engine/src/toast/input/input_system.cpp b/engine/src/toast/input/input_system.cpp index 47f2cbb80..92d0efebb 100644 --- a/engine/src/toast/input/input_system.cpp +++ b/engine/src/toast/input/input_system.cpp @@ -107,11 +107,12 @@ void InputSystem::subscribeEvents() { return false; }); + // Releases always land so a press made before ImGui took over never sticks m_listener.subscribe([this](const event::WindowKey& e) { m_mods = toModifierKey(static_cast(e.mods)); if (e.action == event::window_input_released) { m_keys_down.erase(e.key); - } else { + } else if (!m_overlay_keyboard) { m_keys_down.insert(e.key); noteLastInput(Device::keyboard, "keyboard"); } @@ -122,7 +123,7 @@ void InputSystem::subscribeEvents() { m_mods = toModifierKey(static_cast(e.mods)); if (e.action == event::window_input_released) { m_mouse_down.erase(e.button); - } else { + } else if (!m_overlay_mouse) { m_mouse_down.insert(e.button); noteLastInput(Device::mouse, "mouse"); } @@ -130,15 +131,49 @@ void InputSystem::subscribeEvents() { }); m_listener.subscribe([this](const event::WindowMousePosition& e) { - m_mouse_position = glm::vec2 {e.x, e.y}; + const glm::vec2 raw {e.x, e.y}; + // The host sends the free overlay cursor instead of lock deltas so the look holds still + if (m_overlay_cursor && m_game_mouse_locked) { + m_resync_cursor = true; + return false; + } + if (m_resync_cursor) { + m_cursor_offset = m_mouse_position - raw; + m_resync_cursor = false; + } + m_mouse_position = raw + m_cursor_offset; return false; }); m_listener.subscribe([this](const event::WindowMouseScroll& e) { + if (m_overlay_mouse) { + return false; + } m_scroll += glm::vec2 {e.x, e.y}; noteLastInput(Device::mouse, "mouse"); return false; }); + + m_listener.subscribe([this](const event::WindowMouseLock& e) { + m_game_mouse_locked = e.locked; + // A free cursor is absolute again + if (!e.locked) { + m_cursor_offset = glm::vec2 {0.0f}; + m_resync_cursor = false; + } + return false; + }); + + m_listener.subscribe([this](const event::DebugOverlayCursor& e) { + m_overlay_cursor = e.owned; + return false; + }); + + m_listener.subscribe([this](const event::DebugOverlayCapture& e) { + m_overlay_mouse = e.mouse; + m_overlay_keyboard = e.keyboard; + return false; + }); } void InputSystem::reloadActions() { diff --git a/engine/src/toast/input/input_system.hpp b/engine/src/toast/input/input_system.hpp index 530a4d8a7..64e73e219 100644 --- a/engine/src/toast/input/input_system.hpp +++ b/engine/src/toast/input/input_system.hpp @@ -75,6 +75,16 @@ class TOAST_API InputSystem { glm::vec2 m_mouse_delta {0.0f}; glm::vec2 m_scroll {0.0f}; + /// Every event still arrives and only gameplay stops seeing what ImGui takes + bool m_overlay_cursor = false; + bool m_overlay_mouse = false; + bool m_overlay_keyboard = false; + bool m_game_mouse_locked = false; + + /// Added to raw positions so a locked cursor resumes where the game left it + glm::vec2 m_cursor_offset {0.0f}; + bool m_resync_cursor = false; + glm::vec2 m_viewport_position {0.0f}; glm::vec2 m_viewport_size {0.0f}; diff --git a/engine/src/toast/log.hpp b/engine/src/toast/log.hpp index 759fa20e4..68649885c 100644 --- a/engine/src/toast/log.hpp +++ b/engine/src/toast/log.hpp @@ -29,6 +29,11 @@ void TOAST_API log(uint8_t severity, std::string_view file_name, unsigned line_number, std::string_view sink, std::string_view message); } +namespace logging { +/// Sends every queued log now for paths about to abort the process +TOAST_API void flush() noexcept; +} + #define TOAST_FILE_NAME ::logging::_detail::getOnlyName(__FILE__) #ifdef DEBUG diff --git a/engine/src/toast/logger.cpp b/engine/src/toast/logger.cpp index c79bd6518..3299b9e67 100644 --- a/engine/src/toast/logger.cpp +++ b/engine/src/toast/logger.cpp @@ -70,6 +70,10 @@ void _detail::log( Logger::log(file_name, line_number, severity, sink, message); } +void flush() noexcept { + Logger::flush(); +} + auto Logger::create() noexcept -> std::unique_ptr { ZoneScoped; @@ -425,6 +429,27 @@ auto Logger::collectQueue() -> std::vector { return buffer; } +void Logger::flush() noexcept { + auto* logger = instance; + if (logger == nullptr) { + return; + } + + // Holding the drain slot keeps a pool drain off the socket. Bounded since the pool may be stuck on the caller + constexpr auto k_timeout = std::chrono::milliseconds(500); + const auto deadline = std::chrono::steady_clock::now() + k_timeout; + while (logger->m.drain_pending.exchange(true, std::memory_order_acq_rel)) { + if (std::chrono::steady_clock::now() > deadline) { + return; + } + std::this_thread::yield(); + } + try { + logger->flushSync(); + } catch (...) { (void)0; } + logger->m.drain_pending.store(false, std::memory_order_release); +} + void Logger::flushSync() { ZoneScoped; diff --git a/engine/src/toast/logger.hpp b/engine/src/toast/logger.hpp index 482803244..426f6d7cb 100644 --- a/engine/src/toast/logger.hpp +++ b/engine/src/toast/logger.hpp @@ -85,6 +85,9 @@ class Logger { */ static void log(std::string_view file, unsigned line, char severity, std::string_view sink, std::string_view message); + /// Blocks until the queue reached the socket or the background drain stayed busy too long + static void flush() noexcept; + private: Logger() = default; diff --git a/engine/src/toast/physics/debris.hpp b/engine/src/toast/physics/debris.hpp index d0e96e41b..a4732f27c 100644 --- a/engine/src/toast/physics/debris.hpp +++ b/engine/src/toast/physics/debris.hpp @@ -45,6 +45,11 @@ struct DebrisSpawn { uint32_t albedo_roughness = 0; uint8_t metallic = 0; uint8_t reflectivity = 0; + bool glass = false; + uint8_t alpha = 255; + /// Palette ior byte and 0 for the default + uint8_t ior = 0; + float scale = 1.0f; }; struct DebrisBlast { @@ -56,6 +61,7 @@ struct DebrisBlast { inline constexpr float k_max_debris_speed = 40.0f; inline constexpr float k_debris_cone_spread = 0.5f; inline constexpr float k_debris_max_spin = 8.0f; +inline constexpr float k_debris_min_scale = 0.55f; [[nodiscard]] constexpr auto debrisBit(const glm::ivec3& local) -> uint32_t { diff --git a/engine/src/toast/physics/debris_simulation.cpp b/engine/src/toast/physics/debris_simulation.cpp index 2eaf11b2b..ec8ade415 100644 --- a/engine/src/toast/physics/debris_simulation.cpp +++ b/engine/src/toast/physics/debris_simulation.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -24,6 +25,8 @@ constexpr uint32_t k_debris_max_substeps = 4; constexpr uint32_t k_debris_reset_entry = 0; constexpr uint32_t k_debris_integrate_entry = 1; constexpr uint32_t k_debris_spawn_entry = 2; +constexpr uint32_t k_debris_retire_entry = 3; +constexpr float k_debris_min_lifetime = 0.5f; constexpr double k_debris_min_extrapolation = -0.1; constexpr double k_debris_max_extrapolation = 0.25; @@ -34,22 +37,28 @@ auto debrisGroups(uint32_t count) -> uint32_t { } -DebrisSimulation::DebrisSimulation(uint32_t capacity) +DebrisSimulation::DebrisSimulation(uint32_t max_particles) : AsyncComputeTask({ .shader_uri = "core://shaders/debris.slang", - .entries = {"resetMain", "integrateMain", "spawnMain"}, + .entries = {"resetMain", "integrateMain", "spawnMain", "retireMain"}, .debug_name = "Debris" }), - m_capacity(std::max(capacity, 1u)) { + m_capacity(std::max(max_particles, 1u) + (std::max(max_particles, 1u) / k_cap_headroom_divisor)), + m_max_particles(std::max(max_particles, 1u)) { ZoneScoped; + m_slot_death.assign(m_capacity, -std::numeric_limits::infinity()); + m_slot_retiring.assign(m_capacity, 0); + m_slot_sequence.assign(m_capacity, 0); // motion is always on this shader m_particles = addRingBuffer(sizeof(GpuParticle) * m_capacity); m_motion = addPersistentBuffer(sizeof(GpuMotion) * m_capacity); m_draw_list = addRingBuffer(sizeof(uint32_t) * m_capacity * k_voxels_per_particle); - m_draw_args = addRingBuffer(sizeof(uint32_t) * 4); + // Opaque then glass indirect draw + m_draw_args = addRingBuffer(sizeof(uint32_t) * 8); m_spawns = addHostBuffer(sizeof(GpuSpawn) * k_max_spawns_per_step); m_volumes = addHostBuffer(sizeof(GpuVolume) * k_max_volumes); + m_retires = addHostBuffer(sizeof(uint32_t) * k_max_spawns_per_step); setAcquireUsage(m_draw_args, renderer::sync::Usage::indirect_read); bind(0, m_particles, Copy::previous); @@ -59,6 +68,7 @@ DebrisSimulation::DebrisSimulation(uint32_t capacity) bind(4, m_draw_args); bind(5, m_spawns); bind(6, m_volumes); + bind(7, m_retires); const auto section = [this](renderer::VoxelGpuStorage::Section which) { return [this, which]() -> vk::Buffer { @@ -92,26 +102,30 @@ auto DebrisSimulation::get() -> DebrisSimulation* { } void DebrisSimulation::spawn(std::span spawns) { + const Tunables& settings = tunables(); + const float jitter = std::clamp(settings.debris_lifetime_jitter, 0.0f, 0.9f); for (const DebrisSpawn& spawn : spawns) { if (spawn.mask == 0) { continue; } + const float lifetime = settings.debris_lifetime * (1.0f + (jitter * ((randomUnit() * 2.0f) - 1.0f))); m_pending.push_back( GpuSpawn { .position = spawn.position, - .slot = m_cursor, .rotation = {spawn.rotation.x, spawn.rotation.y, spawn.rotation.z, spawn.rotation.w}, .velocity = spawn.velocity, .albedo_roughness = spawn.albedo_roughness, .angular_velocity = spawn.angular_velocity, .packed = static_cast(spawn.mask) | (static_cast(spawn.metallic) << 8u) | - (static_cast(spawn.reflectivity) << 16u), + (static_cast(spawn.reflectivity) << 16u) | (spawn.glass ? k_flag_glass : 0u), + .lifetime = std::max(lifetime, k_debris_min_lifetime), + .scale = std::clamp(spawn.scale, 0.05f, 1.0f), + .glass = static_cast(spawn.alpha) | (static_cast(spawn.ior) << 8u), } ); - m_cursor = (m_cursor + 1) % m_capacity; } - // Anything older would be overwritten before drawn - while (m_pending.size() > m_capacity) { + // Anything older would be retired before drawn + while (m_pending.size() > m_max_particles) { m_pending.pop_front(); } } @@ -129,10 +143,10 @@ void DebrisSimulation::step(uint32_t steps, double fixed_delta, std::shared_ptr< const uint32_t substeps = std::min(steps, k_debris_max_substeps); const Tunables& settings = tunables(); - const Params params { + Params params { .gravity = settings.gravity, .dt = static_cast(total / static_cast(substeps)), - .lifetime = settings.debris_lifetime, + .fade_in = k_fade_in_seconds, .restitution = settings.debris_restitution, .friction = settings.debris_friction, .voxel_size = voxel::k_voxel_size, @@ -141,13 +155,18 @@ void DebrisSimulation::step(uint32_t steps, double fixed_delta, std::shared_ptr< .volume_count = volume_count, .substeps = substeps, .clear = m_clear_pending ? 1u : 0u, + .fade_out = k_fade_out_seconds, }; m_world = world; const uint64_t step_id = AsyncComputeTask::step( [&](StepContext& ctx) { - const std::span spawns = ctx.hostData(m_spawns); - std::copy_n(m_pending.begin(), std::min(spawn_count, spawns.size()), spawns.begin()); + // Only runs once the step holds a slot so the bookkeeping matches what the GPU sees + const std::span host_spawns = ctx.hostData(m_spawns); + const std::span spawns = host_spawns.first(std::min(spawn_count, host_spawns.size())); + std::copy_n(m_pending.begin(), spawns.size(), spawns.begin()); + params.spawn_count = static_cast(spawns.size()); + params.retire_count = planSlots(m_sim_time + total, spawns, ctx.hostData(m_retires)); const std::span volumes = ctx.hostData(m_volumes); for (uint32_t i = 0; i < volume_count && i < volumes.size(); ++i) { @@ -160,9 +179,12 @@ void DebrisSimulation::step(uint32_t steps, double fixed_delta, std::shared_ptr< } ctx.dispatch(1, params, k_debris_reset_entry); + if (params.retire_count > 0) { + ctx.dispatch(debrisGroups(params.retire_count), params, k_debris_retire_entry); + } ctx.dispatch(debrisGroups(m_capacity), params, k_debris_integrate_entry); - if (spawn_count > 0) { - ctx.dispatch(debrisGroups(spawn_count), params, k_debris_spawn_entry); + if (params.spawn_count > 0) { + ctx.dispatch(debrisGroups(params.spawn_count), params, k_debris_spawn_entry); } }, "Debris", @@ -192,6 +214,9 @@ void DebrisSimulation::setDisplayAlpha(float alpha, double fixed_delta) { void DebrisSimulation::clear() { m_pending.clear(); m_clear_pending = true; + std::ranges::fill(m_slot_death, -std::numeric_limits::infinity()); + std::ranges::fill(m_slot_retiring, 0); + m_spawn_order.clear(); std::scoped_lock lock(m_time_mutex); m_visible = false; } @@ -215,6 +240,74 @@ auto DebrisSimulation::frameDelta() const -> float { return static_cast(m_frame_delta); } +auto DebrisSimulation::planSlots(double now, std::span spawns, std::span retires) -> uint32_t { + ZoneScoped; + const auto stale = [this, now](const std::pair& entry) { + return m_slot_sequence[entry.first] != entry.second || m_slot_death[entry.first] <= now || m_slot_retiring[entry.first] != 0; + }; + const auto drop_stale = [&] { + while (!m_spawn_order.empty() && stale(m_spawn_order.front())) { + m_spawn_order.pop_front(); + } + }; + drop_stale(); + + uint32_t live = 0; + for (uint32_t slot = 0; slot < m_capacity; ++slot) { + live += m_slot_death[slot] > now && m_slot_retiring[slot] == 0 ? 1u : 0u; + } + + // The GPU cuts their life to the fade from the current age so they die by the same time as here + const uint32_t cap = std::min(m_max_particles, std::max(tunables().debris_max_particles, 1u)); + const auto incoming = static_cast(spawns.size()); + uint32_t retire_count = 0; + while (live + incoming > cap && retire_count < retires.size() && !m_spawn_order.empty()) { + const uint32_t slot = m_spawn_order.front().first; + m_spawn_order.pop_front(); + m_slot_death[slot] = std::min(m_slot_death[slot], m_sim_time + k_fade_out_seconds); + m_slot_retiring[slot] = 1; + retires[retire_count++] = slot; + --live; + drop_stale(); + } + + bool ring_full = false; + for (GpuSpawn& spawn : spawns) { + std::optional slot = ring_full ? std::nullopt : findFreeSlot(now); + if (!slot.has_value()) { + // Every slot alive even with the headroom so a piece pops instead of the spawn being dropped + ring_full = true; + slot = m_cursor; + m_cursor = (m_cursor + 1) % m_capacity; + } + spawn.slot = *slot; + m_slot_death[*slot] = now + static_cast(spawn.lifetime); + m_slot_retiring[*slot] = 0; + m_slot_sequence[*slot] = ++m_next_sequence; + m_spawn_order.emplace_back(*slot, m_next_sequence); + } + return retire_count; +} + +auto DebrisSimulation::findFreeSlot(double now) -> std::optional { + for (uint32_t i = 0; i < m_capacity; ++i) { + const uint32_t slot = m_cursor; + m_cursor = (m_cursor + 1) % m_capacity; + if (m_slot_death[slot] <= now) { + return slot; + } + } + return std::nullopt; +} + +auto DebrisSimulation::randomUnit() -> float { + // xorshift64 since lifetimes only need to look uneven + m_rng ^= m_rng << 13u; + m_rng ^= m_rng >> 7u; + m_rng ^= m_rng << 17u; + return static_cast(m_rng >> 40u) / static_cast(1u << 24u); +} + void stepDebris(size_t steps) { ZoneScopedN("physics::StepDebris"); Simulator* simulator = Simulator::current(); diff --git a/engine/src/toast/physics/debris_simulation.hpp b/engine/src/toast/physics/debris_simulation.hpp index 639c9680b..36d93b392 100644 --- a/engine/src/toast/physics/debris_simulation.hpp +++ b/engine/src/toast/physics/debris_simulation.hpp @@ -15,8 +15,11 @@ #include #include #include +#include #include #include +#include +#include namespace renderer { struct VoxelCollisionWorld; @@ -32,13 +35,20 @@ class TOAST_API DebrisSimulation : public renderer::AsyncComputeTask { glm::vec4 rotation {0.0f, 0.0f, 0.0f, 1.0f}; glm::vec3 velocity {0.0f}; uint32_t albedo_roughness = 0; + /// Base scale times the spawn and death fades + float scale = 0.0f; + /// Alpha and ior byte << 8 and only read with k_flag_glass in packed + uint32_t glass = 0; + std::array pad {}; }; struct GpuMotion { glm::vec3 angular_velocity {0.0f}; float age = 0.0f; uint32_t rest_steps = 0; - std::array pad {}; + float lifetime = 0.0f; + float base_scale = 1.0f; + uint32_t pad = 0; }; struct GpuSpawn { @@ -49,6 +59,10 @@ class TOAST_API DebrisSimulation : public renderer::AsyncComputeTask { uint32_t albedo_roughness = 0; glm::vec3 angular_velocity {0.0f}; uint32_t packed = 0; + float lifetime = 0.0f; + float scale = 1.0f; + uint32_t glass = 0; + uint32_t pad = 0; }; struct GpuVolume { @@ -59,16 +73,22 @@ class TOAST_API DebrisSimulation : public renderer::AsyncComputeTask { }; // just in case somebody touches this - static_assert(sizeof(GpuParticle) == 48, "GpuParticle is mirrored by debris.slang"); + static_assert(sizeof(GpuParticle) == 64, "GpuParticle is mirrored by debris.slang and debris_draw.slang"); static_assert(sizeof(GpuMotion) == 32, "GpuMotion is mirrored by debris.slang"); - static_assert(sizeof(GpuSpawn) == 64, "GpuSpawn is mirrored by debris.slang"); + static_assert(sizeof(GpuSpawn) == 80, "GpuSpawn is mirrored by debris.slang"); static_assert(sizeof(GpuVolume) == 96, "GpuVolume is mirrored by debris.slang"); static constexpr uint32_t k_max_spawns_per_step = 4096; static constexpr uint32_t k_max_volumes = 512; static constexpr uint32_t k_voxels_per_particle = 8; - - explicit DebrisSimulation(uint32_t capacity); + /// Mirrors debris.slang where bits 0 to 7 are the voxel mask + static constexpr uint32_t k_flag_glass = 1u << 25; + static constexpr float k_fade_in_seconds = 0.12f; + static constexpr float k_fade_out_seconds = 0.6f; + /// Slots past the cap so pieces shrinking away still have room + static constexpr uint32_t k_cap_headroom_divisor = 4; + + explicit DebrisSimulation(uint32_t max_particles); ~DebrisSimulation() override; DebrisSimulation(const DebrisSimulation&) = delete; @@ -116,7 +136,7 @@ class TOAST_API DebrisSimulation : public renderer::AsyncComputeTask { struct Params { glm::vec3 gravity {0.0f}; float dt = 0.0f; - float lifetime = 0.0f; + float fade_in = 0.0f; float restitution = 0.0f; float friction = 0.0f; float voxel_size = 0.0f; @@ -125,22 +145,41 @@ class TOAST_API DebrisSimulation : public renderer::AsyncComputeTask { uint32_t volume_count = 0; uint32_t substeps = 1; uint32_t clear = 0; - std::array pad {}; + uint32_t retire_count = 0; + float fade_out = 0.0f; + uint32_t pad = 0; }; static_assert(sizeof(Params) == 64, "Params is mirrored by debris.slang"); static constexpr uint64_t k_step_history = 16; + /// Picks spawn slots and retires the oldest past the cap + /// Owner thread inside a step that reached the GPU + auto planSlots(double now, std::span spawns, std::span retires) -> uint32_t; + auto findFreeSlot(double now) -> std::optional; + auto randomUnit() -> float; + uint32_t m_capacity = 0; + uint32_t m_max_particles = 0; uint32_t m_cursor = 0; + /// Sim time each slot dies at and whether it was already told to shrink away + std::vector m_slot_death; + std::vector m_slot_retiring; + std::vector m_slot_sequence; + /// Slot and spawn sequence oldest first and stale once the slot dies or is reused + std::deque> m_spawn_order; + uint64_t m_next_sequence = 0; + uint64_t m_rng = 0x9e3779b97f4a7c15ull; + BufferId m_particles = 0; BufferId m_motion = 0; BufferId m_draw_list = 0; BufferId m_draw_args = 0; BufferId m_spawns = 0; BufferId m_volumes = 0; + BufferId m_retires = 0; std::deque m_pending; bool m_clear_pending = false; diff --git a/engine/src/toast/physics/physics_settings.cpp b/engine/src/toast/physics/physics_settings.cpp index 9b119b85a..c95e55502 100644 --- a/engine/src/toast/physics/physics_settings.cpp +++ b/engine/src/toast/physics/physics_settings.cpp @@ -424,13 +424,28 @@ void registerPhysicsSettings() { {.label = "Speed scale", .category = "Debris", .min = 0.0f, .max = 8.0f, .step = 0.1f}, &Tunables::debris_speed_scale); flt("physics.debris.lifetime", - 60.0, + 12.0, {.label = "Lifetime", .category = "Debris", .min = 0.5, .max = 600.0, .step = 0.5}, &Tunables::debris_lifetime); + flt("physics.debris.lifetime_jitter", + 0.3, + {.label = "Lifetime jitter", + .category = "Debris", + .description = "Each piece lives lifetime times one plus or minus this so a burst does not vanish at once", + .min = 0.0, + .max = 0.9, + .step = 0.05}, + &Tunables::debris_lifetime_jitter); uint_setting( "physics.debris.max_particles", 16384, - {.label = "Max particles", .category = "Debris", .min = 256.0, .max = 262144.0, .step = 1024.0, .requires_restart = true}, + {.label = "Max particles", + .category = "Debris", + .description = "Past this the oldest pieces shrink away to make room for new ones", + .min = 256.0, + .max = 262144.0, + .step = 1024.0, + .requires_restart = true}, &Tunables::debris_max_particles ); flt("physics.debris.restitution", diff --git a/engine/src/toast/physics/physics_settings.hpp b/engine/src/toast/physics/physics_settings.hpp index 151cae763..b0dd95506 100644 --- a/engine/src/toast/physics/physics_settings.hpp +++ b/engine/src/toast/physics/physics_settings.hpp @@ -48,7 +48,8 @@ struct Tunables { float fracture_max_shatter_distance = 8.0f; uint32_t debris_max_spawns_per_step = 512; float debris_speed_scale = 1.0f; - float debris_lifetime = 60.0f; + float debris_lifetime = 12.0f; + float debris_lifetime_jitter = 0.3f; uint32_t debris_max_particles = 16384; float debris_restitution = 0.3f; float debris_friction = 0.6f; diff --git a/engine/src/toast/physics/simulator.cpp b/engine/src/toast/physics/simulator.cpp index 130aca271..9ef0eec03 100644 --- a/engine/src/toast/physics/simulator.cpp +++ b/engine/src/toast/physics/simulator.cpp @@ -889,6 +889,10 @@ void Simulator::emitDebris( (static_cast(look.albedo_b) << 16u) | (static_cast(look.roughness) << 24u), .metallic = look.metallic, .reflectivity = look.reflectivity, + .glass = voxel::isGlass(look), + .alpha = look.alpha, + .ior = look.ior, + .scale = rng.range(k_debris_min_scale, 1.0f), } ); } diff --git a/engine/src/toast/renderer/async_compute.cpp b/engine/src/toast/renderer/async_compute.cpp index c6feebc51..8dbc3c4d6 100644 --- a/engine/src/toast/renderer/async_compute.cpp +++ b/engine/src/toast/renderer/async_compute.cpp @@ -37,8 +37,21 @@ AsyncCompute::~AsyncCompute() { } } +auto AsyncCompute::counterValue() const -> Ticket { + try { + const Ticket completed = m_timeline.getCounterValue(); + m_last_completed.store(completed, std::memory_order_relaxed); + return completed; + } catch (const vk::SystemError& error) { + // A lost device would otherwise throw through the noexcept engine tick and abort the process + const Ticket last = m_last_completed.load(std::memory_order_relaxed); + TOAST_ERROR("Render", "Async compute timeline unreadable, treating ticket {} as the newest: {}", last, error.what()); + return last; + } +} + void AsyncCompute::reclaimLocked() { - const Ticket completed = m_timeline.getCounterValue(); + const Ticket completed = counterValue(); for (auto it = m_in_flight.begin(); it != m_in_flight.end();) { if (it->ticket <= completed) { m_free.push_back(std::move(*it)); @@ -71,7 +84,8 @@ auto AsyncCompute::acquireSlot() -> Slot { return slot; } -auto AsyncCompute::submit(const std::function& record, std::string_view debug_name) -> Ticket { +auto AsyncCompute::submit(const std::function& record, std::string_view debug_name, Timing timing) + -> Ticket { ZoneScoped; Slot slot = acquireSlot(); const vk::CommandBuffer cmd = *slot.cmd; @@ -86,21 +100,36 @@ auto AsyncCompute::submit(const std::function& record, throw; } + std::scoped_lock lock(m_mutex); + // Same lock as the submit so values reach the queue in order with older deferred jobs first + flushDeferredLocked(); + const Ticket ticket = m_last_submitted + 1; + slot.ticket = ticket; + m_last_submitted = ticket; + if (timing == Timing::after_frame) { + m_deferred.push_back(std::move(slot)); + } else { + submitLocked(std::move(slot)); + } + + if (!debug_name.empty()) { + TOAST_TRACE("Render", "Async compute job '{}' submitted as ticket {}", debug_name, ticket); + } + return ticket; +} + +void AsyncCompute::submitLocked(Slot&& slot) { vk::CommandBufferSubmitInfo command_info {}; - command_info.commandBuffer = cmd; + command_info.commandBuffer = *slot.cmd; vk::SemaphoreSubmitInfo signal {}; signal.semaphore = *m_timeline; + signal.value = slot.ticket; signal.stageMask = vk::PipelineStageFlagBits2::eAllCommands; vk::SubmitInfo2 info {}; info.setCommandBufferInfos(command_info); info.setSignalSemaphoreInfos(signal); - - std::scoped_lock lock(m_mutex); - // Same lock as the submit so values reach the queue in increasing order - const Ticket ticket = m_last_submitted + 1; - signal.value = ticket; { std::unique_lock queue_lock; if (m_queue_mutex != nullptr) { @@ -108,32 +137,45 @@ auto AsyncCompute::submit(const std::function& record, } m_queue.submit2(info, nullptr); } - m_last_submitted = ticket; + m_in_flight.push_back(std::move(slot)); +} - if (!debug_name.empty()) { - TOAST_TRACE("Render", "Async compute job '{}' submitted as ticket {}", debug_name, ticket); +void AsyncCompute::flushDeferredLocked() { + // Taken first so a throwing submit never leaves moved from slots behind + std::vector pending = std::exchange(m_deferred, {}); + for (Slot& slot : pending) { + submitLocked(std::move(slot)); } +} - slot.ticket = ticket; - m_in_flight.push_back(std::move(slot)); - return ticket; +void AsyncCompute::flushDeferred() { + ZoneScoped; + std::scoped_lock lock(m_mutex); + flushDeferredLocked(); } auto AsyncCompute::isComplete(Ticket ticket) const -> bool { - return ticket == k_no_ticket || m_timeline.getCounterValue() >= ticket; + return ticket == k_no_ticket || counterValue() >= ticket; } -auto AsyncCompute::wait(Ticket ticket, uint64_t timeout_ns) const -> bool { +auto AsyncCompute::wait(Ticket ticket, uint64_t timeout_ns) -> bool { if (isComplete(ticket)) { return true; } const vk::Semaphore semaphore = *m_timeline; const vk::SemaphoreWaitInfo wait_info({}, semaphore, ticket); - return m_device->waitSemaphores(wait_info, timeout_ns) == vk::Result::eSuccess; + try { + flushDeferred(); + return m_device->waitSemaphores(wait_info, timeout_ns) == vk::Result::eSuccess; + } catch (const vk::SystemError& error) { + // A throw from here reaches the noexcept tick and aborts instead of reporting + TOAST_ERROR("Render", "Async compute wait for ticket {} failed: {}", ticket, error.what()); + return false; + } } auto AsyncCompute::completedTicket() const -> Ticket { - return m_timeline.getCounterValue(); + return counterValue(); } auto AsyncCompute::lastSubmittedTicket() const -> Ticket { diff --git a/engine/src/toast/renderer/async_compute.hpp b/engine/src/toast/renderer/async_compute.hpp index 5a39a5ad0..97b13feb3 100644 --- a/engine/src/toast/renderer/async_compute.hpp +++ b/engine/src/toast/renderer/async_compute.hpp @@ -5,6 +5,7 @@ #pragma once #include +#include #include #include #include @@ -23,6 +24,13 @@ class TOAST_API AsyncCompute { static constexpr Ticket k_no_ticket = 0; + enum class Timing : uint8_t { + now, + /// Held until the render thread submits its next frame so it runs next to that frame graphics + /// Also goes out with the next submit of any timing so a paused renderer never strands it + after_frame, + }; + /// @param queue_mutex non null when another subsystem submits to the same VkQueue AsyncCompute(const vk::raii::Device& device, uint32_t queue_family_index, vk::Queue queue, std::mutex* queue_mutex); @@ -35,13 +43,18 @@ class TOAST_API AsyncCompute { /// Thread safe and records outside the lock so threads record in parallel /// An exception from @p record propagates and no ticket is handed out - auto submit(const std::function& record, std::string_view debug_name = {}) -> Ticket; + auto submit(const std::function& record, std::string_view debug_name = {}, Timing timing = Timing::now) + -> Ticket; + + /// Render thread right after its frame submit though any thread may call it + void flushDeferred(); [[nodiscard]] auto isComplete(Ticket ticket) const -> bool; - /// @return false on timeout - auto wait(Ticket ticket, uint64_t timeout_ns = std::numeric_limits::max()) const -> bool; + /// Flushes deferred jobs first since they never complete otherwise + /// @return false on timeout or a lost device + auto wait(Ticket ticket, uint64_t timeout_ns = std::numeric_limits::max()) -> bool; [[nodiscard]] auto completedTicket() const -> Ticket; @@ -78,8 +91,17 @@ class TOAST_API AsyncCompute { /// Caller holds m_mutex void reclaimLocked(); + /// Caller holds m_mutex + void submitLocked(Slot&& slot); + /// Caller holds m_mutex + void flushDeferredLocked(); auto acquireSlot() -> Slot; + /// Timeline value or the last one read once the device is gone + /// Never throws since the tick that reaches this is noexcept + [[nodiscard]] + auto counterValue() const -> Ticket; + const vk::raii::Device* m_device = nullptr; uint32_t m_queue_family_index = 0; vk::Queue m_queue = nullptr; @@ -87,10 +109,14 @@ class TOAST_API AsyncCompute { vk::raii::Semaphore m_timeline = nullptr; + mutable std::atomic m_last_completed {k_no_ticket}; + mutable std::mutex m_mutex; Ticket m_last_submitted = k_no_ticket; std::vector m_in_flight; std::vector m_free; + /// Ticket order and not on the queue yet + std::vector m_deferred; }; } diff --git a/engine/src/toast/renderer/async_compute_task.cpp b/engine/src/toast/renderer/async_compute_task.cpp index 2efa2b0ba..8ac3ac2d8 100644 --- a/engine/src/toast/renderer/async_compute_task.cpp +++ b/engine/src/toast/renderer/async_compute_task.cpp @@ -11,7 +11,6 @@ #include #include #include -#include #include #include #include @@ -26,6 +25,9 @@ constexpr vk::BufferUsageFlags k_task_buffer_usage = constexpr vk::DeviceSize k_placeholder_bytes = 16; +/// A queue that falls behind drops many steps in a row so only the first of every batch logs +constexpr uint64_t k_dropped_step_warn_interval = 120; + } void AsyncComputeTask::StepContext::dispatchRaw( @@ -102,11 +104,11 @@ AsyncComputeTask::AsyncComputeTask(Config config) : m_config(std::move(config)), AsyncComputeTask::~AsyncComputeTask() { // Buffers and sets must outlive every job that uses them - const AsyncCompute& compute = m_core->asyncCompute(); + AsyncCompute& compute = m_core->asyncCompute(); auto unused = compute.wait(compute.lastSubmittedTicket()); } -/// This is superturbobad do not overuse it +/// One copy per slot so keep ring buffers few and small auto AsyncComputeTask::addRingBuffer(vk::DeviceSize size, bool cpu_readback) -> BufferId { const auto shared = m_core->asyncCompute().sharedFamilies(m_core->getGraphicsQueueFamilyIndex()); vk::BufferCreateInfo buffer_ci({}, size, k_task_buffer_usage); @@ -155,8 +157,9 @@ auto AsyncComputeTask::addPersistentBuffer(vk::DeviceSize size) -> BufferId { auto AsyncComputeTask::addHostBuffer(vk::DeviceSize size) -> BufferId { const vk::BufferCreateInfo buffer_ci({}, size, vk::BufferUsageFlagBits::eStorageBuffer); + // eAuto lands in device local host visible memory when there is any so shaders do not read over PCIe vma::AllocationCreateInfo host_alloc {}; - host_alloc.usage = vma::MemoryUsage::eAutoPreferHost; + host_alloc.usage = vma::MemoryUsage::eAuto; host_alloc.flags = vma::AllocationCreateFlagBits::eMapped | vma::AllocationCreateFlagBits::eHostAccessSequentialWrite; const auto id = static_cast(m_buffers.size()); @@ -312,49 +315,69 @@ auto AsyncComputeTask::step( } createDescriptors(); - const uint32_t slot = claimFreeSlot(); + const std::optional claimed = claimFreeSlot(); + if (!claimed.has_value()) { + // Dropping a step keeps the tick running and the caller keeps what it meant to feed this one + ++m_dropped_steps; + if (m_dropped_steps % k_dropped_step_warn_interval == 1) { + TOAST_WARN( + "Render", "{} dropped {} steps so far because the compute queue is behind", m_config.debug_name, m_dropped_steps + ); + } + return 0; + } + const uint32_t slot = *claimed; m_slots[slot].keep_alive = std::move(keep_alive); writeDescriptors(slot); - const AsyncCompute::Ticket ticket = m_core->asyncCompute().submit( - [&](vk::CommandBuffer cmd) { - // Submits on one queue may overlap so the previous step and any upload must land first - sync::BarrierBatch barriers; - barriers.memory(sync::Usage::compute_storage_write, sync::Usage::compute_storage_write); - barriers.memory(sync::Usage::transfer_dst, sync::Usage::compute_storage_write); - barriers.flush(cmd); - - StepContext context(*this, cmd, slot); - record(context); - - for (const Buffer& buffer : m_buffers) { - if (buffer.host) { - buffer.copies[slot].getAllocation().flush(0, buffer.size); + AsyncCompute::Ticket ticket = AsyncCompute::k_no_ticket; + try { + ticket = m_core->asyncCompute().submit( + [&](vk::CommandBuffer cmd) { + // Submits on one queue may overlap so the previous step and any upload must land first + sync::BarrierBatch barriers; + barriers.memory(sync::Usage::compute_storage_write, sync::Usage::compute_storage_write); + barriers.memory(sync::Usage::transfer_dst, sync::Usage::compute_storage_write); + barriers.flush(cmd); + + StepContext context(*this, cmd, slot); + record(context); + + for (const Buffer& buffer : m_buffers) { + if (buffer.host) { + buffer.copies[slot].getAllocation().flush(0, buffer.size); + } } - } - // CPU copies of this step for read() - bool any_readback = false; - for (const Buffer& buffer : m_buffers) { - if (buffer.cpu_readback) { - barriers.buffer(*buffer.copies[slot], sync::Usage::compute_storage_write, sync::Usage::transfer_src); - any_readback = true; + // CPU copies of this step for read() + bool any_readback = false; + for (const Buffer& buffer : m_buffers) { + if (buffer.cpu_readback) { + barriers.buffer(*buffer.copies[slot], sync::Usage::compute_storage_write, sync::Usage::transfer_src); + any_readback = true; + } } - } - if (!any_readback) { - return; - } - barriers.flush(cmd); - for (const Buffer& buffer : m_buffers) { - if (buffer.cpu_readback) { - cmd.copyBuffer(*buffer.copies[slot], *buffer.readbacks[slot], vk::BufferCopy(0, 0, buffer.size)); - barriers.buffer(*buffer.readbacks[slot], sync::Usage::transfer_dst, sync::Usage::host_read); + if (!any_readback) { + return; } - } - barriers.flush(cmd); - }, - debug_name.empty() ? std::string_view(m_config.debug_name) : debug_name - ); + barriers.flush(cmd); + for (const Buffer& buffer : m_buffers) { + if (buffer.cpu_readback) { + cmd.copyBuffer(*buffer.copies[slot], *buffer.readbacks[slot], vk::BufferCopy(0, 0, buffer.size)); + barriers.buffer(*buffer.readbacks[slot], sync::Usage::transfer_dst, sync::Usage::host_read); + } + } + barriers.flush(cmd); + }, + debug_name.empty() ? std::string_view(m_config.debug_name) : debug_name, + // Submitted mid tick it finishes before the frame graphics even reach the GPU so nothing overlaps + AsyncCompute::Timing::after_frame + ); + } catch (const vk::SystemError& error) { + // The slot keeps its old ticket so a later step reclaims it once the queue catches up + TOAST_ERROR("Render", "{} step submit failed, dropping the step: {}", m_config.debug_name, error.what()); + return 0; + } publish(slot, ticket); return m_slots[slot].step; @@ -431,29 +454,21 @@ auto AsyncComputeTask::finishedStep() const -> uint64_t { return newest < 0 ? 0 : m_slots[static_cast(newest)].step; } -auto AsyncComputeTask::claimFreeSlot() -> uint32_t { +auto AsyncComputeTask::claimFreeSlot() -> std::optional { const AsyncCompute& compute = m_core->asyncCompute(); - while (true) { - { - std::scoped_lock lock(m_mutex); - // Never the next step input, the result the renderer shows next or a slot a frame still binds - const int32_t newest = newestFinishedLocked(); - for (uint32_t i = 0; i < k_slot_count; ++i) { - Slot& slot = m_slots[i]; - const auto index = static_cast(i); - if (index != m_last_written && index != newest && slot.pins == 0 && compute.isComplete(slot.ticket)) { - slot.step = 0; - return i; - } - } - } - // Every slot busy means the GPU is behind or frames still bind them, thats bad - if (compute.completedTicket() < compute.lastSubmittedTicket()) { - auto unused = compute.wait(compute.completedTicket() + 1); - } else { - std::this_thread::yield(); + std::scoped_lock lock(m_mutex); + // Never the next step input or the result the renderer shows next or a slot a frame still binds + const int32_t newest = newestFinishedLocked(); + for (uint32_t i = 0; i < k_slot_count; ++i) { + Slot& slot = m_slots[i]; + const auto index = static_cast(i); + if (index != m_last_written && index != newest && slot.pins == 0 && compute.isComplete(slot.ticket)) { + slot.step = 0; + return i; } } + // Every slot busy means the GPU is behind and waiting would park the engine tick on the GPU + return std::nullopt; } void AsyncComputeTask::publish(uint32_t slot, AsyncCompute::Ticket ticket) { diff --git a/engine/src/toast/renderer/async_compute_task.hpp b/engine/src/toast/renderer/async_compute_task.hpp index 2c350095e..2b3303e12 100644 --- a/engine/src/toast/renderer/async_compute_task.hpp +++ b/engine/src/toast/renderer/async_compute_task.hpp @@ -48,7 +48,7 @@ class TOAST_API AsyncComputeTask { std::string debug_name = "AsyncComputeTask"; }; - /// Handed to the step callback. Barriers between dispatches of one step are inserted for you + /// Handed to the step callback with barriers between its dispatches already inserted class TOAST_API StepContext { public: template @@ -206,7 +206,8 @@ class TOAST_API AsyncComputeTask { void createDescriptors(); void writeDescriptors(uint32_t slot); - auto claimFreeSlot() -> uint32_t; + /// Empty while every slot is busy so the owner thread drops the step instead of blocking on the GPU + auto claimFreeSlot() -> std::optional; void publish(uint32_t slot, AsyncCompute::Ticket ticket); /// Caller holds m_mutex auto newestFinishedLocked() const -> int32_t; @@ -228,6 +229,8 @@ class TOAST_API AsyncComputeTask { std::array m_slots; uint64_t m_step_counter = 0; int32_t m_last_written = -1; + /// Owner thread + uint64_t m_dropped_steps = 0; /// Render thread only std::array m_frame_slot {}; diff --git a/engine/src/toast/renderer/blue_noise.cpp b/engine/src/toast/renderer/blue_noise.cpp new file mode 100644 index 000000000..143da10df --- /dev/null +++ b/engine/src/toast/renderer/blue_noise.cpp @@ -0,0 +1,163 @@ +#include "blue_noise.hpp" + +#include +#include +#include +#include +#include + +namespace renderer::blue_noise { + +namespace { + +constexpr uint32_t k_texels = k_size * k_size; + +/// Ulichney's sigma +constexpr float k_sigma = 1.5f; + +/// Kernel window past about five sigma +constexpr int k_reach = 8; + +/// Seed points before the swap phase +constexpr uint32_t k_initial_points = k_texels / 10; + +/// Toroidal Gaussian per offset +auto energyKernel() -> std::vector { + std::vector kernel(k_texels); + for (uint32_t y = 0; y < k_size; ++y) { + for (uint32_t x = 0; x < k_size; ++x) { + const auto dx = static_cast(std::min(x, k_size - x)); + const auto dy = static_cast(std::min(y, k_size - y)); + kernel[(y * k_size) + x] = std::exp(-((dx * dx) + (dy * dy)) / (2.0f * k_sigma * k_sigma)); + } + } + return kernel; +} + +class Field { +public: + explicit Field(const std::vector& kernel) : m_kernel(kernel), m_energy(k_texels, 0.0f), m_set(k_texels, false) { } + + void toggle(uint32_t index) { + const float sign = m_set[index] ? -1.0f : 1.0f; + m_set[index] = !m_set[index]; + const int px = static_cast(index % k_size); + const int py = static_cast(index / k_size); + for (int dy = -k_reach; dy <= k_reach; ++dy) { + const uint32_t y = static_cast(py + dy + static_cast(k_size)) % k_size; + const uint32_t ky = static_cast(dy + static_cast(k_size)) % k_size; + for (int dx = -k_reach; dx <= k_reach; ++dx) { + const uint32_t x = static_cast(px + dx + static_cast(k_size)) % k_size; + const uint32_t kx = static_cast(dx + static_cast(k_size)) % k_size; + m_energy[(y * k_size) + x] += sign * m_kernel[(ky * k_size) + kx]; + } + } + } + + [[nodiscard]] + auto isSet(uint32_t index) const -> bool { + return m_set[index]; + } + + /// The set point with the most energy around it + [[nodiscard]] + auto tightestCluster() const -> uint32_t { + uint32_t best = 0; + float best_energy = -1.0f; + for (uint32_t i = 0; i < k_texels; ++i) { + if (m_set[i] && m_energy[i] > best_energy) { + best_energy = m_energy[i]; + best = i; + } + } + return best; + } + + /// The empty texel with the least energy around it + [[nodiscard]] + auto largestVoid() const -> uint32_t { + uint32_t best = 0; + float best_energy = 1e30f; + for (uint32_t i = 0; i < k_texels; ++i) { + if (!m_set[i] && m_energy[i] < best_energy) { + best_energy = m_energy[i]; + best = i; + } + } + return best; + } + +private: + const std::vector& m_kernel; + std::vector m_energy; + std::vector m_set; +}; + +} + +auto generate(uint32_t seed) -> std::vector { + ZoneScoped; + static const std::vector kernel = energyKernel(); + + std::mt19937 rng(seed); + Field initial(kernel); + for (uint32_t placed = 0; placed < k_initial_points;) { + const uint32_t index = rng() % k_texels; + if (!initial.isSet(index)) { + initial.toggle(index); + ++placed; + } + } + + // Move the tightest cluster into the largest void until that stops changing anything + for (uint32_t pass = 0; pass < k_texels; ++pass) { + const uint32_t cluster = initial.tightestCluster(); + initial.toggle(cluster); + const uint32_t hole = initial.largestVoid(); + if (hole == cluster) { + initial.toggle(cluster); + break; + } + initial.toggle(hole); + } + + std::vector rank(k_texels, 0); + + // Ranks below the seed count by peeling clusters off a copy + Field peel = initial; + for (uint32_t r = k_initial_points; r-- > 0;) { + const uint32_t cluster = peel.tightestCluster(); + peel.toggle(cluster); + rank[cluster] = r; + } + + // Ranks above it by filling voids + Field fill = initial; + for (uint32_t r = k_initial_points; r < k_texels; ++r) { + const uint32_t hole = fill.largestVoid(); + fill.toggle(hole); + rank[hole] = r; + } + + std::vector out(k_texels); + for (uint32_t i = 0; i < k_texels; ++i) { + out[i] = static_cast((rank[i] * 256u) / k_texels); + } + return out; +} + +auto generatePacked() -> std::vector { + ZoneScoped; + std::array, 4> channels; + for (uint32_t c = 0; c < channels.size(); ++c) { + channels[c] = generate(0x9E3779B9u * (c + 1u)); + } + std::vector out(k_texels); + for (uint32_t i = 0; i < k_texels; ++i) { + out[i] = uint32_t {channels[0][i]} | (uint32_t {channels[1][i]} << 8u) | (uint32_t {channels[2][i]} << 16u) | + (uint32_t {channels[3][i]} << 24u); + } + return out; +} + +} diff --git a/engine/src/toast/renderer/blue_noise.hpp b/engine/src/toast/renderer/blue_noise.hpp new file mode 100644 index 000000000..eb8732893 --- /dev/null +++ b/engine/src/toast/renderer/blue_noise.hpp @@ -0,0 +1,26 @@ +/** + * @file blue_noise.hpp + * @author dario + * @date 18/10/2026 + */ + +#pragma once + +#include +#include +#include + +namespace renderer::blue_noise { + +/// Mirrors lighting.slang kBlueNoiseSize +inline constexpr uint32_t k_size = 64; + +/// Void and cluster ranks of one tileable k_size square scaled to 0-255 in row major order +[[nodiscard]] +TOAST_API auto generate(uint32_t seed) -> std::vector; + +/// Four independent channels packed r | g << 8 | b << 16 | a << 24 per texel as lighting.slang gBlueNoise reads them +[[nodiscard]] +TOAST_API auto generatePacked() -> std::vector; + +} diff --git a/engine/src/toast/renderer/gpu_sync.cpp b/engine/src/toast/renderer/gpu_sync.cpp index 8a47c6b7f..1e60b4b8c 100644 --- a/engine/src/toast/renderer/gpu_sync.cpp +++ b/engine/src/toast/renderer/gpu_sync.cpp @@ -78,6 +78,12 @@ auto info(Usage usage) -> UsageInfo { Access::eDepthStencilAttachmentRead | Access::eDepthStencilAttachmentWrite, Layout::eDepthAttachmentOptimal, }; + case Usage::stencil_attachment: + return { + Stage::eEarlyFragmentTests | Stage::eLateFragmentTests, + Access::eDepthStencilAttachmentRead | Access::eDepthStencilAttachmentWrite, + Layout::eDepthStencilAttachmentOptimal, + }; case Usage::depth_sampled: return {Stage::eFragmentShader | Stage::eComputeShader, Access::eShaderSampledRead, Layout::eDepthReadOnlyOptimal}; diff --git a/engine/src/toast/renderer/gpu_sync.hpp b/engine/src/toast/renderer/gpu_sync.hpp index 0a0c39af9..b0e319d11 100644 --- a/engine/src/toast/renderer/gpu_sync.hpp +++ b/engine/src/toast/renderer/gpu_sync.hpp @@ -20,6 +20,8 @@ enum class Usage : uint8_t { color_attachment, /// eDepthAttachmentOptimal including load and store ops depth_attachment, + /// eDepthStencilAttachmentOptimal for images with a stencil aspect + stencil_attachment, /// eDepthReadOnlyOptimal sampled by fragment and compute shaders depth_sampled, diff --git a/engine/src/toast/renderer/light_culling.cpp b/engine/src/toast/renderer/light_culling.cpp index aab22d805..1dff5f577 100644 --- a/engine/src/toast/renderer/light_culling.cpp +++ b/engine/src/toast/renderer/light_culling.cpp @@ -33,6 +33,14 @@ auto importance(const glm::vec3& color, float intensity, float range, float came return std::isnan(value) ? 0.0f : value; } +auto shadowPriority(const glm::vec3& color, float intensity, float camera_distance, float cone_fraction) -> float { + const glm::vec3 k_luminance {0.2126f, 0.7152f, 0.0722f}; + + const float brightness = glm::dot(color, k_luminance) * std::max(intensity, 0.0f); + const float value = brightness * cone_fraction / ((camera_distance * camera_distance) + 1.0f); + return std::isnan(value) ? 0.0f : value; +} + auto clusterDepthRange(float camera_near, float camera_far, float light_reach) -> DepthRange { using namespace clustered_lighting; diff --git a/engine/src/toast/renderer/light_culling.hpp b/engine/src/toast/renderer/light_culling.hpp index c3b683754..85721260f 100644 --- a/engine/src/toast/renderer/light_culling.hpp +++ b/engine/src/toast/renderer/light_culling.hpp @@ -26,6 +26,10 @@ auto spotBounds(const glm::vec3& position, const glm::vec3& direction, float ran [[nodiscard]] auto importance(const glm::vec3& color, float intensity, float range, float camera_distance, float cone_fraction) -> float; +/// Ranks lights for shadow slots by nearness +[[nodiscard]] +auto shadowPriority(const glm::vec3& color, float intensity, float camera_distance, float cone_fraction) -> float; + /// Ends at the farthest visible light so slices are not spent on empty depth [[nodiscard]] auto clusterDepthRange(float camera_near, float camera_far, float light_reach) -> DepthRange; diff --git a/engine/src/toast/renderer/output_target_base.hpp b/engine/src/toast/renderer/output_target_base.hpp index b76d78cb5..e9bf76f72 100644 --- a/engine/src/toast/renderer/output_target_base.hpp +++ b/engine/src/toast/renderer/output_target_base.hpp @@ -6,6 +6,7 @@ #include "vulkan_common.hpp" +#include #include namespace renderer { @@ -48,7 +49,17 @@ class IOutputTarget { virtual void recordFinalize(vk::CommandBuffer command_buffer, uint32_t image_index) = 0; - virtual void onImageRenderComplete(uint32_t image_index) { (void)image_index; } + // Always release the acquisition, including probe captures that must not be published. + virtual void onImageRenderComplete(uint32_t image_index, bool publish = true) { + (void)image_index; + (void)publish; + } + + /// After acquireNextImage returned eNotReady and false when none freed within @p timeout + virtual auto waitForFreeImage(std::chrono::nanoseconds timeout) -> bool { + (void)timeout; + return true; + } [[nodiscard]] virtual auto submitSync(uint32_t image_index) -> OutputSubmitSync { diff --git a/engine/src/toast/renderer/passes/debris_pass.cpp b/engine/src/toast/renderer/passes/debris_pass.cpp index 150c4abce..aa45b6bee 100644 --- a/engine/src/toast/renderer/passes/debris_pass.cpp +++ b/engine/src/toast/renderer/passes/debris_pass.cpp @@ -12,6 +12,7 @@ #include #include #include +#include namespace renderer { @@ -25,7 +26,8 @@ constexpr uint32_t k_debris_list_binding = 1; } DebrisPass::DebrisPass( - const VulkanCore& core, vk::Format depth_format, vk::Extent2D extent, std::shared_ptr simulation + const VulkanCore& core, vk::Format scene_format, vk::Format depth_format, vk::Extent2D extent, + std::shared_ptr simulation ) : m_core(&core), m_simulation(std::move(simulation)) { @@ -67,6 +69,19 @@ DebrisPass::DebrisPass( config.depth_write = true; m_pipeline.rebuild(core, config); + // Same targets and blending as VoxelPass glass + const bool dual_source = core.isDualSourceBlendSupported(); + config.debug_name = "DebrisPass Glass"; + config.color_format = scene_format; + config.extra_color_formats = worldStageExtraColorFormats(); + config.write_extra_color = false; + config.write_color_alpha = false; + config.vertex_entry = "vertexGlass"; + config.fragment_entry = dual_source ? "fragmentGlass" : "fragmentGlassPremultiplied"; + config.depth_write = false; + config.blend_preset = dual_source ? VulkanPipeline::BlendPreset::dual_source : VulkanPipeline::BlendPreset::premultiplied; + m_glass_pipeline.rebuild(core, config); + m_scene_sets.create(core, shader->reflection, *layouts[k_debris_scene_set], "DebrisPass"); const vk::DescriptorPool pool = VulkanRenderer::instance->getDescriptorPoolHandle(); @@ -145,4 +160,25 @@ void DebrisPass::record(vk::CommandBuffer cmd, uint32_t frame_index, uint32_t im cmd.drawIndirect(m_args, 0, 1, sizeof(vk::DrawIndirectCommand)); } +void DebrisPass::recordGlass(vk::CommandBuffer cmd, uint32_t frame_index) { + ZoneScoped; + // Cleared here since prepare only runs on frames with a voxel scope + const vk::Buffer args = std::exchange(m_args, nullptr); + if (!args || !m_glass_pipeline.isReady() || frame_index >= m_sets.size()) { + return; + } + + const vk::PipelineLayout layout = *m_layout.getPipelineLayout(); + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, m_glass_pipeline.getPipeline()); + cmd.bindDescriptorSets( + vk::PipelineBindPoint::eGraphics, + layout, + k_debris_scene_set, + std::array {m_scene_sets.get(frame_index), *m_sets[frame_index]}, + {} + ); + cmd.pushConstants(layout, vk::ShaderStageFlagBits::eAll, 0, sizeof(PushConstants), &m_push); + cmd.drawIndirect(args, sizeof(vk::DrawIndirectCommand), 1, sizeof(vk::DrawIndirectCommand)); +} + } diff --git a/engine/src/toast/renderer/passes/debris_pass.hpp b/engine/src/toast/renderer/passes/debris_pass.hpp index 2cf03ee40..7580e2a47 100644 --- a/engine/src/toast/renderer/passes/debris_pass.hpp +++ b/engine/src/toast/renderer/passes/debris_pass.hpp @@ -26,7 +26,8 @@ class VulkanCore; class DebrisPass : public IRenderPass { public: DebrisPass( - const VulkanCore& core, vk::Format depth_format, vk::Extent2D extent, std::shared_ptr simulation + const VulkanCore& core, vk::Format scene_format, vk::Format depth_format, vk::Extent2D extent, + std::shared_ptr simulation ); [[nodiscard]] @@ -39,6 +40,9 @@ class DebrisPass : public IRenderPass { void record(vk::CommandBuffer cmd, uint32_t frame_index, uint32_t image_index) override; + /// World scope with the blended draws, after record() in the same frame + void recordGlass(vk::CommandBuffer cmd, uint32_t frame_index); + [[nodiscard]] auto name() const -> std::string_view override { return "Debris"; @@ -57,6 +61,7 @@ class DebrisPass : public IRenderPass { ShaderLayout m_layout; VulkanPipeline m_pipeline; + VulkanPipeline m_glass_pipeline; SceneDescriptorSets m_scene_sets; std::vector m_sets; std::vector> m_bound; diff --git a/engine/src/toast/renderer/passes/debug_pass.cpp b/engine/src/toast/renderer/passes/debug_pass.cpp index 1fcbfb51b..e8cfd62af 100644 --- a/engine/src/toast/renderer/passes/debug_pass.cpp +++ b/engine/src/toast/renderer/passes/debug_pass.cpp @@ -36,6 +36,7 @@ #include #include #include +#include #include namespace renderer { @@ -423,7 +424,18 @@ void DebugPass::update(uint32_t frame_index, float dt) { ImGui_ImplVulkan_NewFrame(); ImGui::NewFrame(); - const bool show_windows = VulkanRenderer::instance->debugWindowsVisible(); + // Hidden only skips submitting so every window comes back with the state it had + const bool hidden = VulkanRenderer::instance->debugOverlayHidden(); + const bool show_windows = !hidden && VulkanRenderer::instance->debugWindowsVisible(); + + const bool capture_mouse = show_windows && io.WantCaptureMouse; + const bool capture_keyboard = show_windows && io.WantCaptureKeyboard; + if (capture_mouse != m_capture_mouse || capture_keyboard != m_capture_keyboard) { + m_capture_mouse = capture_mouse; + m_capture_keyboard = capture_keyboard; + event::send(capture_mouse, capture_keyboard); + } + if (show_windows) { if (!m_perf) { m_perf = std::make_unique(); @@ -477,15 +489,19 @@ void DebugPass::update(uint32_t frame_index, float dt) { shadow_stats.static_edits, shadow_stats.spared ); + ImGui::TextDisabled( + "Shadow voxel draws: %u meshed, %u marched", shadow_stats.meshed_volumes, shadow_stats.marched_volumes + ); if (const auto* frame = VulkanRenderer::instance->renderingFrame(); frame != nullptr) { const auto& stats = frame->light_stats; ImGui::TextDisabled( - "Shadow slots: %u of %u spot, %u of %u point, %llu displaced", + "Shadow slots: %u of %u spot, %u of %u point, %u without shadow, %llu displaced", stats.spot_shadows, - renderer::shadows::k_max_spot_shadows, + renderer::shadows::spotShadowBudget(), stats.point_shadows, - renderer::shadows::k_max_point_shadows, + renderer::shadows::pointShadowBudget(), + stats.spot_denied + stats.point_denied, static_cast(stats.shadow_displaced) ); } @@ -866,7 +882,7 @@ void DebugPass::update(uint32_t frame_index, float dt) { m_voxels->drawOverlay(*frame); } - if (frame->render_mode == 1 && m_cluster_lighting_pass != nullptr) { + if (!hidden && frame->render_mode == 1 && m_cluster_lighting_pass != nullptr) { m_cluster_lighting_pass->requestGridReadback(); const auto counts = m_cluster_lighting_pass->getClusterLightGridCounts(frame_index); diff --git a/engine/src/toast/renderer/passes/debug_pass.hpp b/engine/src/toast/renderer/passes/debug_pass.hpp index 9f4a3e3d3..c6bdac916 100644 --- a/engine/src/toast/renderer/passes/debug_pass.hpp +++ b/engine/src/toast/renderer/passes/debug_pass.hpp @@ -133,6 +133,10 @@ class DebugPass : public IRenderPass { bool m_imgui_ready = false; bool m_editor_panels = true; + /// Last io.WantCapture* sent to the input system + bool m_capture_mouse = false; + bool m_capture_keyboard = false; + std::unique_ptr m_perf; std::unique_ptr m_voxels; diff --git a/engine/src/toast/renderer/passes/grid_pass.cpp b/engine/src/toast/renderer/passes/grid_pass.cpp index f8121a2e1..810aa2abc 100644 --- a/engine/src/toast/renderer/passes/grid_pass.cpp +++ b/engine/src/toast/renderer/passes/grid_pass.cpp @@ -45,6 +45,9 @@ GridPass::GridPass(const renderer::VulkanCore& core, vk::Format color_format, vk config.cull_mode = vk::CullModeFlagBits::eNone; config.depth_test = true; config.depth_write = false; + // Toward the camera so a coplanar floor rasterized with the TAA jitter never wins half the pixels + config.depth_bias_constant = depth::k_reversed_z ? 8.0f : -8.0f; + config.depth_bias_slope = depth::k_reversed_z ? 1.5f : -1.5f; config.blend_enable = true; m_pipeline.rebuild(core, config); diff --git a/engine/src/toast/renderer/passes/perf_window.cpp b/engine/src/toast/renderer/passes/perf_window.cpp index 5c3d76ef4..701a37aee 100644 --- a/engine/src/toast/renderer/passes/perf_window.cpp +++ b/engine/src/toast/renderer/passes/perf_window.cpp @@ -5,13 +5,19 @@ #include "perf_window.hpp" #include "../async_compute.hpp" +#include "../shadow_constants.hpp" #include "../vulkan_core.hpp" #include +#include #include #include #include +#include +#include +#include #include +#include #include namespace renderer { @@ -468,6 +474,30 @@ void PerfWindow::drawSummary(bool has_gpu) { perfVerdictText("Render", perfTextColor(perfLevel(render.bad, render.warn)), render.label, render.detail); const Verdict game = gameVerdict(); perfVerdictText("Game ", perfTextColor(perfLevel(game.bad, game.warn)), game.label, game.detail); + const Verdict shadow = shadowVerdict(); + perfVerdictText("Shadow", perfTextColor(perfLevel(shadow.bad, shadow.warn)), shadow.label, shadow.detail); +} + +auto PerfWindow::shadowVerdict() -> Verdict { + const auto* frame = VulkanRenderer::instance->renderingFrame(); + if (frame == nullptr) { + return {.label = "n/a"}; + } + const auto& lights = frame->light_stats; + const uint32_t spot_budget = shadows::spotShadowBudget(); + const uint32_t point_budget = shadows::pointShadowBudget(); + const std::string slots = + std::format("{}/{} spot {}/{} point", lights.spot_shadows, spot_budget, lights.point_shadows, point_budget); + + const uint32_t denied = lights.spot_denied + lights.point_denied; + if (denied > 0) { + return { + .label = "slots full", + .warn = true, + .detail = std::format("{} {} lights cast no shadow", slots, denied), + }; + } + return {.label = "ok", .detail = slots}; } void PerfWindow::drawFrameTab() { @@ -834,6 +864,113 @@ void PerfWindow::drawSceneTab() { } } +void PerfWindow::drawShadowsTab() { + using toast::settings::Entry; + using toast::settings::Type; + using toast::settings::Value; + + if (const auto* frame = VulkanRenderer::instance->renderingFrame(); frame != nullptr) { + const auto& lights = frame->light_stats; + const auto slots = [](const char* label, uint32_t used, uint32_t budget, uint32_t denied) { + const float fraction = budget > 0 ? std::clamp(static_cast(used) / static_cast(budget), 0.0f, 1.0f) : 1.0f; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, denied > 0 ? k_perf_warn : k_perf_good); + const std::string text = std::format("{} {} of {}", label, used, budget); + ImGui::ProgressBar(fraction, ImVec2(ImGui::GetFontSize() * 14.0f, 0.0f), text.c_str()); + ImGui::PopStyleColor(); + if (denied > 0) { + ImGui::SameLine(); + ImGui::TextColored(k_perf_warn, "full, %u visible lights cast no shadow", denied); + } + }; + ImGui::SeparatorText("Slots"); + slots("Spot", lights.spot_shadows, shadows::spotShadowBudget(), lights.spot_denied); + slots("Point", lights.point_shadows, shadows::pointShadowBudget(), lights.point_denied); + ImGui::TextDisabled("Nearest lights win. Raise a budget or lower Shadow Distance on lights that need none"); + } + + auto& registry = toast::settings::Settings::get(); + ImGui::SeparatorText("Settings"); + if (ImGui::BeginTable("##perf_shadow_settings", 2, ImGuiTableFlags_SizingStretchProp)) { + for (Entry* entry : registry.entries()) { + if (entry->meta.hidden || !entry->key.starts_with("renderer.shadows.")) { + continue; + } + ImGui::PushID(entry->key.c_str()); + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + + std::string_view label = entry->meta.label; + if (label.empty()) { + label = std::string_view(entry->key).substr(entry->key.rfind('.') + 1); + } + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(label.data(), label.data() + label.size()); + if (ImGui::IsItemHovered() && !entry->meta.description.empty()) { + ImGui::SetTooltip("%s%s", entry->meta.description.c_str(), entry->meta.requires_restart ? "\nApplies on restart" : ""); + } + + ImGui::TableNextColumn(); + ImGui::SetNextItemWidth(-FLT_MIN); + const Value value = registry.resolved(entry); + const bool bounded = entry->meta.min < entry->meta.max; + std::optional changed; + switch (entry->type) { + case Type::boolean: { + bool current = std::get(value); + if (ImGui::Checkbox("##value", ¤t)) { + changed = current; + } + break; + } + case Type::integer: { + int64_t current = std::get(value); + const auto& options = entry->meta.options; + if (!options.empty()) { + const auto index = static_cast(std::clamp(current, 0, static_cast(options.size()) - 1)); + if (ImGui::BeginCombo("##value", options[index].c_str())) { + for (size_t i = 0; i < options.size(); ++i) { + if (ImGui::Selectable(options[i].c_str(), i == index)) { + changed = static_cast(i); + } + } + ImGui::EndCombo(); + } + break; + } + const auto low = static_cast(entry->meta.min); + const auto high = static_cast(entry->meta.max); + const bool edited = bounded ? ImGui::SliderScalar("##value", ImGuiDataType_S64, ¤t, &low, &high) + : ImGui::DragScalar("##value", ImGuiDataType_S64, ¤t); + if (edited) { + changed = current; + } + break; + } + case Type::floating: { + double current = std::get(value); + const double low = entry->meta.min; + const double high = entry->meta.max; + const auto speed = static_cast(entry->meta.step > 0.0 ? entry->meta.step : 0.01); + const bool edited = bounded + ? ImGui::SliderScalar("##value", ImGuiDataType_Double, ¤t, &low, &high, "%.3g") + : ImGui::DragScalar("##value", ImGuiDataType_Double, ¤t, speed, nullptr, nullptr, "%.3g"); + if (edited) { + changed = current; + } + break; + } + case Type::string: ImGui::TextDisabled("%s", std::get(value).c_str()); break; + } + if (changed.has_value()) { + registry.setValue(entry->key, *changed); + } + ImGui::PopID(); + } + ImGui::EndTable(); + } + ImGui::TextDisabled("Saved like the project settings window. Hover a name for what it costs"); +} + void PerfWindow::draw() { ZoneScoped; auto* renderer = VulkanRenderer::instance; @@ -920,6 +1057,10 @@ void PerfWindow::draw() { drawSceneTab(); ImGui::EndTabItem(); } + if (ImGui::BeginTabItem("Shadows")) { + drawShadowsTab(); + ImGui::EndTabItem(); + } ImGui::EndTabBar(); } diff --git a/engine/src/toast/renderer/passes/perf_window.hpp b/engine/src/toast/renderer/passes/perf_window.hpp index 68b61c5d9..634c912f5 100644 --- a/engine/src/toast/renderer/passes/perf_window.hpp +++ b/engine/src/toast/renderer/passes/perf_window.hpp @@ -103,6 +103,9 @@ class PerfWindow { auto renderVerdict(bool has_gpu) const -> Verdict; [[nodiscard]] auto gameVerdict() const -> Verdict; + /// Warns once a spot or point light within its shadow distance found every shadow slot taken + [[nodiscard]] + static auto shadowVerdict() -> Verdict; void drawSummary(bool has_gpu); void drawFrameGraph(float width, float height); @@ -111,6 +114,8 @@ class PerfWindow { void drawGpuTab(); void drawMemoryTab(); void drawSceneTab(); + /// Slot usage and shadow settings beside the timings + static void drawShadowsTab(); [[nodiscard]] auto historyOffset() const -> int; diff --git a/engine/src/toast/renderer/passes/selection_outline.cpp b/engine/src/toast/renderer/passes/selection_outline.cpp new file mode 100644 index 000000000..7c6283a7f --- /dev/null +++ b/engine/src/toast/renderer/passes/selection_outline.cpp @@ -0,0 +1,587 @@ +/** + * @file selection_outline.cpp + * @author dario + * @date 16/10/2026 + */ + +#include "selection_outline.hpp" + +#include "../descriptor_writer.hpp" +#include "../shader_cache.hpp" +#include "../voxel_gpu_storage.hpp" +#include "../vulkan_core.hpp" +#include "../vulkan_debug.hpp" +#include "../vulkan_mesh.hpp" +#include "../vulkan_renderer.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace renderer { + +namespace { + +constexpr uint32_t k_silhouette_bit = 1u; +constexpr uint32_t k_visible_bit = 2u; + +/// Matches kWidth in selection_outline.slang plus a pixel +constexpr int32_t k_outline_reach = 3; + +const glm::vec4 k_solid_color {1.0f, 0.55f, 0.1f, 1.0f}; +const glm::vec4 k_hidden_color {0.55f, 0.55f, 0.55f, 1.0f}; + +/// Weight of the newest frame in the outline history +constexpr float k_history_blend = 0.1f; + +constexpr vk::Format k_coverage_format = vk::Format::eR8G8Unorm; + +auto selectMaskFormat(const VulkanCore& core) -> vk::Format { + constexpr std::array candidates {vk::Format::eS8Uint, vk::Format::eD24UnormS8Uint, vk::Format::eD32SfloatS8Uint}; + constexpr vk::FormatFeatureFlags needed = + vk::FormatFeatureFlagBits::eDepthStencilAttachment | vk::FormatFeatureFlagBits::eSampledImage; + for (const auto candidate : candidates) { + const auto props = core.getPhysicalDevice().getFormatProperties(candidate); + if ((props.optimalTilingFeatures & needed) == needed) { + return candidate; + } + } + return vk::Format::eUndefined; +} + +auto maskAspects(vk::Format format) -> vk::ImageAspectFlags { + return format == vk::Format::eS8Uint ? vk::ImageAspectFlags {vk::ImageAspectFlagBits::eStencil} + : vk::ImageAspectFlagBits::eDepth | vk::ImageAspectFlagBits::eStencil; +} + +/// Pixels a world sphere covers or the whole extent once it reaches behind the camera +auto sphereRect(const glm::mat4& view_projection, glm::vec3 center, float radius, vk::Extent2D extent) -> vk::Rect2D { + const vk::Rect2D full { + {0, 0}, + extent + }; + if (radius <= 0.0f) { + return full; + } + + glm::vec2 low(std::numeric_limits::max()); + glm::vec2 high(std::numeric_limits::lowest()); + for (uint32_t corner = 0; corner < 8; ++corner) { + const glm::vec3 sign( + (corner & 1u) != 0u ? 1.0f : -1.0f, (corner & 2u) != 0u ? 1.0f : -1.0f, (corner & 4u) != 0u ? 1.0f : -1.0f + ); + const glm::vec4 clip = view_projection * glm::vec4(center + sign * radius, 1.0f); + if (clip.w <= 1e-4f) { + return full; + } + const glm::vec2 ndc = glm::vec2(clip) / clip.w; + low = glm::min(low, ndc); + high = glm::max(high, ndc); + } + + const glm::vec2 size(static_cast(extent.width), static_cast(extent.height)); + const glm::ivec2 begin = glm::ivec2(glm::floor((low * 0.5f + 0.5f) * size)) - k_outline_reach; + const glm::ivec2 end = glm::ivec2(glm::ceil((high * 0.5f + 0.5f) * size)) + k_outline_reach; + const glm::ivec2 clamped_begin = glm::clamp(begin, glm::ivec2(0), glm::ivec2(size)); + const glm::ivec2 clamped_end = glm::clamp(end, glm::ivec2(0), glm::ivec2(size)); + return { + { clamped_begin.x, clamped_begin.y}, + {static_cast(clamped_end.x - clamped_begin.x), static_cast(clamped_end.y - clamped_begin.y)} + }; +} + +auto unite(const vk::Rect2D& a, const vk::Rect2D& b) -> vk::Rect2D { + if (a.extent.width == 0 || a.extent.height == 0) { + return b; + } + if (b.extent.width == 0 || b.extent.height == 0) { + return a; + } + const int32_t x0 = std::min(a.offset.x, b.offset.x); + const int32_t y0 = std::min(a.offset.y, b.offset.y); + const int32_t x1 = + std::max(a.offset.x + static_cast(a.extent.width), b.offset.x + static_cast(b.extent.width)); + const int32_t y1 = + std::max(a.offset.y + static_cast(a.extent.height), b.offset.y + static_cast(b.extent.height)); + return { + { x0, y0}, + {static_cast(x1 - x0), static_cast(y1 - y0)} + }; +} + +auto outlinedMesh(const VulkanRenderer::MeshInstanceProxy& proxy) -> bool { + return proxy.outlined && proxy.visible && proxy.mesh != nullptr && proxy.mesh->isReady(); +} + +} + +SelectionOutline::SelectionOutline(const VulkanCore& core, vk::Format output_format, vk::Format depth_format, vk::Extent2D extent) + : m_core(&core) { + ZoneScoped; + m_mask_format = selectMaskFormat(core); + if (m_mask_format == vk::Format::eUndefined) { + TOAST_ERROR("Render", "No sampleable stencil format so selected nodes are not outlined"); + return; + } + + createOutlinePipelines(output_format, depth_format, extent); + createMaskPipelines(extent); + createTargets(extent); +} + +void SelectionOutline::onResize(vk::Extent2D extent) { + if (m_mask_format != vk::Format::eUndefined) { + createTargets(extent); + } +} + +void SelectionOutline::createTargets(vk::Extent2D extent) { + ZoneScoped; + m_mask_sampled.reset(); + m_mask_attachment.reset(); + m_mask.reset(); + m_extent = extent; + m_drawn = false; + m_history_valid = false; + std::ranges::fill(m_bound_outline, std::array {}); + if (extent.width == 0 || extent.height == 0) { + return; + } + + m_coverage[0].create(*m_core, extent, k_coverage_format, "SelectionOutline Coverage[0]"); + m_coverage[1].create(*m_core, extent, k_coverage_format, "SelectionOutline Coverage[1]"); + + vk::ImageCreateInfo image_ci {}; + image_ci.imageType = vk::ImageType::e2D; + image_ci.format = m_mask_format; + image_ci.extent = vk::Extent3D {extent.width, extent.height, 1}; + image_ci.mipLevels = 1; + image_ci.arrayLayers = 1; + image_ci.samples = vk::SampleCountFlagBits::e1; + image_ci.tiling = vk::ImageTiling::eOptimal; + image_ci.usage = vk::ImageUsageFlagBits::eDepthStencilAttachment | vk::ImageUsageFlagBits::eSampled; + image_ci.sharingMode = vk::SharingMode::eExclusive; + image_ci.initialLayout = vk::ImageLayout::eUndefined; + + vma::AllocationCreateInfo allocation_ci {}; + allocation_ci.usage = vma::MemoryUsage::eAutoPreferDevice; + + m_mask.emplace(m_core->getAllocator().createImage(image_ci, allocation_ci)); + setDebugName(*m_core, **m_mask, "SelectionOutline Mask"); + + const vk::ImageSubresourceRange range {maskAspects(m_mask_format), 0, 1, 0, 1}; + vk::ImageViewCreateInfo view_ci {}; + view_ci.image = **m_mask; + view_ci.viewType = vk::ImageViewType::e2D; + view_ci.format = m_mask_format; + view_ci.subresourceRange = range; + m_mask_attachment.emplace(m_core->getDevice(), view_ci); + setDebugName(*m_core, **m_mask_attachment, "SelectionOutline MaskAttachment"); + + view_ci.subresourceRange.aspectMask = vk::ImageAspectFlagBits::eStencil; + m_mask_sampled.emplace(m_core->getDevice(), view_ci); + setDebugName(*m_core, **m_mask_sampled, "SelectionOutline MaskStencil"); + + m_mask_state.reset(**m_mask, range); +} + +void SelectionOutline::createMaskPipelines(vk::Extent2D extent) { + ZoneScoped; + const auto uid = assets::resolveURI("core://shaders/selection_mask.slang"); + const auto shader = uid.has_value() ? ShaderCache::get().acquire(*uid) : nullptr; + if (!shader) { + TOAST_ERROR("Render", "core://shaders/selection_mask.slang unavailable so selected nodes are not outlined"); + return; + } + + m_mask_layout.rebuild(*m_core, shader->reflection, "SelectionMask"); + const auto& layouts = m_mask_layout.getDescriptorSetLayouts(); + if (layouts.size() < 2) { + TOAST_ERROR("Render", "SelectionMask needs the frame set and the voxel storage set"); + return; + } + + VulkanPipeline::Config config; + config.pipeline_type = VulkanPipeline::PipelineType::graphics; + config.depth_only = true; + config.depth_fragment = true; + config.stencil_format = m_mask_format; + config.stencil_replace = true; + config.extent = extent; + config.shader_spirv = shader->spirv; + config.pipeline_layout = *m_mask_layout.getPipelineLayout(); + + VulkanPipeline::Config mesh = config; + mesh.debug_name = "SelectionMask Mesh"; + mesh.vertex_entry = "meshVertex"; + mesh.fragment_entry = "meshFragment"; + mesh.vertex_bindings = {vertexBindingDescription()}; + const auto vertex_attributes = vertexAttributeDescriptions(); + mesh.vertex_attributes.assign(vertex_attributes.begin(), vertex_attributes.end()); + // Mirrored and single sided meshes still cover their silhouette + mesh.cull_mode = vk::CullModeFlagBits::eNone; + m_mesh_pipeline.rebuild(*m_core, mesh); + + VulkanPipeline::Config voxel = config; + voxel.debug_name = "SelectionMask Voxels"; + voxel.vertex_entry = "voxelVertex"; + voxel.fragment_entry = "voxelFragment"; + voxel.cull_mode = vk::CullModeFlagBits::eFront; + m_voxel_pipeline.rebuild(*m_core, voxel); + + const auto& device = m_core->getDevice(); + const vk::DescriptorPool pool = VulkanRenderer::instance->getDescriptorPoolHandle(); + const vk::DescriptorSetLayout frame_layout = *layouts[0]; + const vk::DescriptorSetLayout voxel_layout = *layouts[1]; + + m_mask_sets.clear(); + m_voxel_sets.clear(); + m_volume_buffers.clear(); + m_bound_depth.assign(VulkanRenderer::k_frames_in_flight, vk::ImageView {}); + m_bound_storage.assign(VulkanRenderer::k_frames_in_flight, nullptr); + + DescriptorWriter writer; + for (uint32_t i = 0; i < VulkanRenderer::k_frames_in_flight; ++i) { + vk::BufferCreateInfo buffer_ci {}; + buffer_ci.size = sizeof(VoxelInstanceGpu) * k_max_volumes; + buffer_ci.usage = vk::BufferUsageFlagBits::eStorageBuffer; + + vma::AllocationCreateInfo alloc_ci {}; + alloc_ci.usage = vma::MemoryUsage::eAutoPreferHost; + alloc_ci.flags = vma::AllocationCreateFlagBits::eMapped | vma::AllocationCreateFlagBits::eHostAccessSequentialWrite; + + m_volume_buffers.push_back(m_core->getAllocator().createBuffer(buffer_ci, alloc_ci)); + setDebugName(*m_core, *m_volume_buffers.back(), std::format("SelectionMask Volumes[{}]", i)); + + auto frame_set = device.allocateDescriptorSets(vk::DescriptorSetAllocateInfo(pool, 1, &frame_layout)); + m_mask_sets.push_back(std::move(frame_set[0])); + setDebugName(*m_core, *m_mask_sets.back(), std::format("SelectionMask FrameSet[{}]", i)); + + auto voxel_set = device.allocateDescriptorSets(vk::DescriptorSetAllocateInfo(pool, 1, &voxel_layout)); + m_voxel_sets.push_back(std::move(voxel_set[0])); + setDebugName(*m_core, *m_voxel_sets.back(), std::format("SelectionMask VoxelSet[{}]", i)); + + if (const auto* frame_res = VulkanRenderer::instance->getFrameUBORes(i); + frame_res != nullptr && frame_res->gpu_buffer.has_value()) { + writer.buffer( + *m_mask_sets[i], 0, vk::DescriptorType::eUniformBuffer, **frame_res->gpu_buffer, sizeof(VulkanRenderer::FrameUBO) + ); + } + writer.buffer( + *m_mask_sets[i], + 13, + vk::DescriptorType::eStorageBuffer, + VulkanRenderer::instance->getInstanceBuffer(i), + sizeof(VulkanRenderer::InstanceData) * VulkanRenderer::k_max_instances + ); + writer.buffer( + *m_voxel_sets[i], 6, vk::DescriptorType::eStorageBuffer, *m_volume_buffers[i], sizeof(VoxelInstanceGpu) * k_max_volumes + ); + } + writer.flush(device); + + if (!m_mesh_pipeline.isReady() || !m_voxel_pipeline.isReady()) { + TOAST_ERROR("Render", "SelectionMask pipelines failed to build so selected nodes are not outlined"); + } +} + +void SelectionOutline::createOutlinePipelines(vk::Format output_format, vk::Format depth_format, vk::Extent2D extent) { + ZoneScoped; + const auto uid = assets::resolveURI("core://shaders/selection_outline.slang"); + const auto shader = uid.has_value() ? ShaderCache::get().acquire(*uid) : nullptr; + if (!shader) { + TOAST_ERROR("Render", "core://shaders/selection_outline.slang unavailable so selected nodes are not outlined"); + return; + } + + m_outline_layout.rebuild(*m_core, shader->reflection, "SelectionOutline"); + const auto& layouts = m_outline_layout.getDescriptorSetLayouts(); + if (layouts.empty()) { + return; + } + + VulkanPipeline::Config config; + config.pipeline_type = VulkanPipeline::PipelineType::graphics; + config.extent = extent; + config.shader_spirv = shader->spirv; + config.pipeline_layout = *m_outline_layout.getPipelineLayout(); + config.cull_mode = vk::CullModeFlagBits::eNone; + config.depth_test = false; + config.depth_write = false; + + VulkanPipeline::Config resolve = config; + resolve.debug_name = "SelectionOutline Resolve"; + resolve.color_format = k_coverage_format; + resolve.write_color_alpha = true; + resolve.fragment_entry = "resolveMain"; + m_resolve_pipeline.rebuild(*m_core, resolve); + + VulkanPipeline::Config composite = config; + composite.debug_name = "SelectionOutline Composite"; + composite.color_format = output_format; + // The output scope keeps the scene depth bound + composite.depth_format = depth_format; + composite.fragment_entry = "compositeMain"; + composite.blend_preset = VulkanPipeline::BlendPreset::premultiplied; + m_composite_pipeline.rebuild(*m_core, composite); + + const auto& device = m_core->getDevice(); + // Integer stencil texels never filter + m_sampler.emplace(device, nearestClampSamplerInfo()); + setDebugName(*m_core, **m_sampler, "SelectionOutline Sampler"); + + const vk::DescriptorPool pool = VulkanRenderer::instance->getDescriptorPoolHandle(); + const vk::DescriptorSetLayout set_layout = *layouts[0]; + m_outline_sets.clear(); + m_bound_outline.assign(VulkanRenderer::k_frames_in_flight, {}); + for (uint32_t i = 0; i < VulkanRenderer::k_frames_in_flight; ++i) { + auto allocated = device.allocateDescriptorSets(vk::DescriptorSetAllocateInfo(pool, 1, &set_layout)); + m_outline_sets.push_back(std::move(allocated[0])); + setDebugName(*m_core, *m_outline_sets.back(), std::format("SelectionOutline Set[{}]", i)); + } +} + +auto SelectionOutline::prepareVolumes(uint32_t frame_index) -> uint32_t { + const auto* frame = VulkanRenderer::instance->renderingFrame(); + const auto& storage = frame->voxel_storage; + if (!storage || !storage->isReady() || frame_index >= m_volume_buffers.size()) { + return 0; + } + + auto* instances = static_cast(m_volume_buffers[frame_index].getAllocation().getInfo().pMappedData); + if (instances == nullptr) { + return 0; + } + + uint32_t count = 0; + for (const auto& proxy : frame->voxel_instances) { + if (!proxy.outlined || !proxy.visible) { + continue; + } + if (count == k_max_volumes) { + break; + } + instances[count] = makeVoxelInstance(proxy.model, proxy.inverse_model, proxy.model, true, proxy.record_index); + if (proxy.mirrored) { + instances[count].flags |= k_voxel_instance_mirrored; + } + ++count; + } + if (count == 0) { + return 0; + } + m_volume_buffers[frame_index].getAllocation().flush(0, sizeof(VoxelInstanceGpu) * count); + + if (m_bound_storage[frame_index] != storage) { + // Safe since the fence of this slot was waited on + writeVoxelStorageDescriptors(m_core->getDevice(), *m_voxel_sets[frame_index], *storage); + m_bound_storage[frame_index] = storage; + } + return count; +} + +void SelectionOutline::recordMask(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView scene_depth) { + ZoneScoped; + m_drawn = false; + const auto* frame = VulkanRenderer::instance->renderingFrame(); + if (frame == nullptr || !m_mask_attachment.has_value() || !m_mesh_pipeline.isReady() || !m_voxel_pipeline.isReady() || + !m_resolve_pipeline.isReady() || !m_composite_pipeline.isReady() || !scene_depth || frame_index >= m_mask_sets.size() || + frame_index >= m_outline_sets.size()) { + m_history_valid = false; + return; + } + + const glm::mat4& view_projection = frame->frame_data.view_projection; + vk::Rect2D rect {}; + bool any_mesh = false; + for (const auto& proxy : frame->mesh_instances) { + if (outlinedMesh(proxy)) { + any_mesh = true; + rect = unite(rect, sphereRect(view_projection, proxy.bounds_center, proxy.bounds_radius, m_extent)); + } + } + const uint32_t volumes = prepareVolumes(frame_index); + if (volumes > 0) { + for (const auto& proxy : frame->voxel_instances) { + if (proxy.outlined && proxy.visible) { + rect = unite(rect, sphereRect(view_projection, proxy.bounds_center, proxy.bounds_radius, m_extent)); + } + } + } + if ((!any_mesh && volumes == 0) || rect.extent.width == 0 || rect.extent.height == 0) { + m_history_valid = false; + return; + } + + // The history only lines up while nothing it shows has moved + std::vector models; + for (const auto& proxy : frame->mesh_instances) { + if (outlinedMesh(proxy)) { + models.push_back(proxy.model); + } + } + for (const auto& proxy : frame->voxel_instances) { + if (proxy.outlined && proxy.visible) { + models.push_back(proxy.model); + } + } + const bool reset = !m_history_valid || !frame->taa_active || frame->frame_data.view_projection != m_history_view_projection || + models != m_history_models; + m_history_view_projection = frame->frame_data.view_projection; + m_history_models = std::move(models); + + if (m_bound_depth[frame_index] != scene_depth) { + DescriptorWriter writer; + writer.image( + *m_mask_sets[frame_index], + 1, + vk::DescriptorType::eCombinedImageSampler, + *m_sampler, + scene_depth, + vk::ImageLayout::eDepthReadOnlyOptimal + ); + writer.flush(m_core->getDevice()); + m_bound_depth[frame_index] = scene_depth; + } + + sync::transition(cmd, m_mask_state, sync::Usage::stencil_attachment); + + vk::RenderingAttachmentInfo stencil_info {}; + stencil_info.imageView = **m_mask_attachment; + stencil_info.imageLayout = vk::ImageLayout::eDepthStencilAttachmentOptimal; + stencil_info.loadOp = vk::AttachmentLoadOp::eClear; + stencil_info.storeOp = vk::AttachmentStoreOp::eStore; + stencil_info.clearValue = vk::ClearValue(vk::ClearDepthStencilValue {0.0f, 0}); + + const vk::Rect2D full { + {0, 0}, + m_extent + }; + vk::RenderingInfo rendering_info {}; + rendering_info.renderArea = full; + rendering_info.layerCount = 1; + rendering_info.pStencilAttachment = &stencil_info; + + cmd.beginRendering(rendering_info); + cmd.setViewport( + 0, + std::array {vk::Viewport(0.0f, 0.0f, static_cast(m_extent.width), static_cast(m_extent.height), 0.0f, 1.0f)} + ); + cmd.setScissor(0, std::array {full}); + + const vk::PipelineLayout layout = *m_mask_layout.getPipelineLayout(); + cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, layout, 0, std::array {*m_mask_sets[frame_index]}, {}); + if (volumes > 0) { + cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, layout, 1, std::array {*m_voxel_sets[frame_index]}, {}); + } + + const vk::Buffer posed_vertices = VulkanRenderer::instance->getPosedVertexBuffer(frame_index); + for (const uint32_t bit : {k_silhouette_bit, k_visible_bit}) { + cmd.setStencilReference(vk::StencilFaceFlagBits::eFrontAndBack, bit); + cmd.setStencilWriteMask(vk::StencilFaceFlagBits::eFrontAndBack, bit); + MaskPush push {.test_occlusion = bit == k_visible_bit ? 1u : 0u}; + + if (any_mesh) { + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, m_mesh_pipeline.getPipeline()); + for (const auto& proxy : frame->mesh_instances) { + if (!outlinedMesh(proxy)) { + continue; + } + const bool posed = proxy.posed_vertex_offset != VulkanRenderer::MeshInstanceProxy::k_no_posed_vertices && posed_vertices; + if (posed) { + proxy.mesh->bindPosed(cmd, posed_vertices, proxy.posed_vertex_offset); + } else { + proxy.mesh->bind(cmd); + } + push.instance = proxy.instance_index; + cmd.pushConstants(layout, vk::ShaderStageFlagBits::eAll, 0, sizeof(MaskPush), &push); + proxy.mesh->draw(cmd, 1); + } + } + + if (volumes > 0) { + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, m_voxel_pipeline.getPipeline()); + for (uint32_t i = 0; i < volumes; ++i) { + push.instance = i; + cmd.pushConstants(layout, vk::ShaderStageFlagBits::eAll, 0, sizeof(MaskPush), &push); + cmd.draw(36, 1, 0, 0); + } + } + } + + cmd.endRendering(); + sync::transition(cmd, m_mask_state, sync::Usage::fragment_sampled); + + m_outline_rect = rect; + recordResolve(cmd, frame_index, reset); + m_history_valid = true; + m_drawn = true; +} + +void SelectionOutline::recordResolve(vk::CommandBuffer cmd, uint32_t frame_index, bool reset) { + m_write ^= 1u; + const PostProcessTarget& target = m_coverage[m_write]; + const PostProcessTarget& history = m_coverage[m_write ^ 1u]; + if (reset) { + // Only for a legal layout since a reset never weighs the history + history.beginScope(cmd); + history.endScope(cmd); + } + + const std::array wanted {**m_mask_sampled, history.view(), target.view()}; + if (m_bound_outline[frame_index] != wanted) { + DescriptorWriter writer; + const vk::DescriptorSet set = *m_outline_sets[frame_index]; + const auto type = vk::DescriptorType::eCombinedImageSampler; + writer.image(set, 0, type, *m_sampler, wanted[0], vk::ImageLayout::eShaderReadOnlyOptimal); + writer.image(set, 1, type, *m_sampler, wanted[1], vk::ImageLayout::eShaderReadOnlyOptimal); + writer.image(set, 2, type, *m_sampler, wanted[2], vk::ImageLayout::eShaderReadOnlyOptimal); + writer.flush(m_core->getDevice()); + m_bound_outline[frame_index] = wanted; + } + + const OutlinePush push { + .solid_color = k_solid_color, + .hidden_color = k_hidden_color, + .rect = + {m_outline_rect.offset.x, + m_outline_rect.offset.y, + m_outline_rect.offset.x + static_cast(m_outline_rect.extent.width), + m_outline_rect.offset.y + static_cast(m_outline_rect.extent.height)}, + .blend = reset ? 1.0f : k_history_blend, + }; + + const vk::PipelineLayout layout = *m_outline_layout.getPipelineLayout(); + target.beginScope(cmd); + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, m_resolve_pipeline.getPipeline()); + cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, layout, 0, std::array {*m_outline_sets[frame_index]}, {}); + cmd.pushConstants(layout, vk::ShaderStageFlagBits::eAll, 0, sizeof(OutlinePush), &push); + cmd.draw(3, 1, 0, 0); + target.endScope(cmd); +} + +void SelectionOutline::recordOutline(vk::CommandBuffer cmd, uint32_t frame_index) { + if (!m_drawn || frame_index >= m_outline_sets.size()) { + return; + } + + const vk::PipelineLayout layout = *m_outline_layout.getPipelineLayout(); + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, m_composite_pipeline.getPipeline()); + cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, layout, 0, std::array {*m_outline_sets[frame_index]}, {}); + const OutlinePush push {.solid_color = k_solid_color, .hidden_color = k_hidden_color}; + cmd.pushConstants(layout, vk::ShaderStageFlagBits::eAll, 0, sizeof(OutlinePush), &push); + cmd.setScissor(0, std::array {m_outline_rect}); + cmd.draw(3, 1, 0, 0); + cmd.setScissor( + 0, + std::array { + vk::Rect2D {{0, 0}, m_extent} + } + ); +} + +} diff --git a/engine/src/toast/renderer/passes/selection_outline.hpp b/engine/src/toast/renderer/passes/selection_outline.hpp new file mode 100644 index 000000000..005e6c7b6 --- /dev/null +++ b/engine/src/toast/renderer/passes/selection_outline.hpp @@ -0,0 +1,107 @@ +/** + * @file selection_outline.hpp + * @author dario + * @date 16/10/2026 + */ + +#pragma once + +#include "../gpu_sync.hpp" +#include "../post_process_target.hpp" +#include "../shader_layout.hpp" +#include "../vulkan_common.hpp" +#include "../vulkan_pipeline.hpp" + +#include +#include +#include +#include +#include +#include + +namespace renderer { +class VulkanCore; +class VoxelGpuStorage; + +/// Outlines the nodes selected in the editor hierarchy through a stencil mask +class SelectionOutline { +public: + SelectionOutline(const VulkanCore& core, vk::Format output_format, vk::Format depth_format, vk::Extent2D extent); + + /// After a device wait idle + void onResize(vk::Extent2D extent); + + /// Outside any rendering scope with the scene depth in sync::Usage::depth_sampled + void recordMask(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView scene_depth); + + /// Inside the output scope after recordMask + void recordOutline(vk::CommandBuffer cmd, uint32_t frame_index); + +private: + /// Mirrors selection_mask.slang PushConstants + struct MaskPush { + uint32_t instance = 0; + uint32_t test_occlusion = 0; + }; + + /// Mirrors selection_outline.slang PushConstants + struct OutlinePush { + glm::vec4 solid_color {0.0f}; + glm::vec4 hidden_color {0.0f}; + glm::ivec4 rect {0}; + float blend = 1.0f; + std::array pad {}; + }; + + static constexpr uint32_t k_max_volumes = 64; + + void createTargets(vk::Extent2D extent); + void createMaskPipelines(vk::Extent2D extent); + void createOutlinePipelines(vk::Format output_format, vk::Format depth_format, vk::Extent2D extent); + + /// Bound voxel instances or 0 when no outlined volume can draw + auto prepareVolumes(uint32_t frame_index) -> uint32_t; + + /// Averages the outline over the jitter sequence while the camera and selection hold still + void recordResolve(vk::CommandBuffer cmd, uint32_t frame_index, bool reset); + + const VulkanCore* m_core = nullptr; + + vk::Format m_mask_format = vk::Format::eUndefined; + vk::Extent2D m_extent {}; + std::optional m_mask; + /// Every aspect for the attachment and the stencil alone for sampling + std::optional m_mask_attachment; + std::optional m_mask_sampled; + sync::ImageState m_mask_state; + std::optional m_sampler; + + ShaderLayout m_mask_layout; + VulkanPipeline m_mesh_pipeline; + VulkanPipeline m_voxel_pipeline; + std::vector m_mask_sets; + std::vector m_voxel_sets; + std::vector m_volume_buffers; + std::vector m_bound_depth; + std::vector> m_bound_storage; + + ShaderLayout m_outline_layout; + VulkanPipeline m_resolve_pipeline; + VulkanPipeline m_composite_pipeline; + std::vector m_outline_sets; + /// Mask history and outline views each set was written with + std::vector> m_bound_outline; + + /// Ping pong where m_write is the one this frame resolves into + std::array m_coverage; + uint32_t m_write = 0; + bool m_history_valid = false; + glm::mat4 m_history_view_projection {1.0f}; + std::vector m_history_models; + + /// The output scope composites this frame + bool m_drawn = false; + vk::Rect2D m_outline_rect {}; +}; + +} diff --git a/engine/src/toast/renderer/passes/shadow_pass.cpp b/engine/src/toast/renderer/passes/shadow_pass.cpp index 583bc533e..53ed36f0b 100644 --- a/engine/src/toast/renderer/passes/shadow_pass.cpp +++ b/engine/src/toast/renderer/passes/shadow_pass.cpp @@ -17,12 +17,14 @@ #include #include +#include #include #include #include #include #include #include +#include #include #include @@ -31,10 +33,28 @@ namespace renderer { namespace { /// A moving volume would invalidate every resting voxel caster each frame so it draws with the meshes instead -auto isMovingVolume(const VulkanRenderer::VoxelVolumeProxy& proxy) -> bool { - return !proxy.has_previous || proxy.previous_model != proxy.model; +/// Frames a caster stays moving after its last step +constexpr uint64_t k_caster_settle_frames = 30; + +auto cornerDrift(const glm::mat4& from, const glm::mat4& to, const glm::vec3& extent) -> float { + const glm::mat4 delta = to - from; + float drift = 0.0f; + for (uint32_t corner = 0; corner < 8; ++corner) { + const glm::vec4 local( + (corner & 1u) != 0 ? extent.x : 0.0f, (corner & 2u) != 0 ? extent.y : 0.0f, (corner & 4u) != 0 ? extent.z : 0.0f, 1.0f + ); + drift = std::max(drift, glm::length(glm::vec3(delta * local))); + } + return drift; } +/// Mirrors kTaskGroupsPerCell in voxel_dda.slang +constexpr uint32_t k_task_groups_per_cell = 2; + +/// Shared by every caster pipeline and mirrored by kSlopeBias in voxel_shadow.slang +constexpr float k_caster_bias_constant = 1.5f; +constexpr float k_caster_bias_slope = 1.25f; + auto shadowLayerRange(uint32_t base_layer, uint32_t layer_count) -> vk::ImageSubresourceRange { return {vk::ImageAspectFlagBits::eDepth, 0, 1, base_layer, layer_count}; } @@ -129,8 +149,9 @@ ShadowPass::ShadowPass(const VulkanCore& core) : m_core(&core) { config.depth_write = true; // Shadow maps keep standard depth whatever the scene convention config.depth_compare = vk::CompareOp::eLess; - config.depth_bias_constant = 1.5f; - config.depth_bias_slope = 3.0f; + config.depth_bias_constant = k_caster_bias_constant; + config.depth_bias_slope = k_caster_bias_slope; + config.depth_clamp = core.isDepthClampSupported(); config.cull_mode = vk::CullModeFlagBits::eNone; // Single view is 0x1 not 0 since 0 disables multiview and leaves SV_ViewID undefined @@ -163,7 +184,7 @@ ShadowPass::ShadowPass(const VulkanCore& core) : m_core(&core) { shadows::k_cascade_count, shadows::cascadeResolution(), shadows::cascadeResolution(), - shadows::k_punctual_layer_count, + shadows::punctualLayerCount(), shadows::punctualResolution(), shadows::punctualResolution(), vk::to_string(m_format) @@ -274,8 +295,12 @@ void ShadowPass::createResources(const VulkanCore& core) { // One group per cascade so a refit of one cascade leaves the cached others alone const std::vector cascade_groups(shadows::k_cascade_count, 1u); - std::vector punctual_groups(shadows::k_max_spot_shadows, 1u); - punctual_groups.insert(punctual_groups.end(), shadows::k_max_point_shadows, shadows::k_cube_faces); + std::vector punctual_groups(shadows::spotShadowSlots(), 1u); + punctual_groups.insert(punctual_groups.end(), shadows::pointShadowSlots(), shadows::k_cube_faces); + if (punctual_groups.empty()) { + // A layer nothing renders into so the map and its descriptors still exist with every slot off + punctual_groups.push_back(1u); + } constexpr auto k_static_usage = vk::ImageUsageFlagBits::eTransferSrc; createShadowMap( @@ -380,6 +405,9 @@ void ShadowPass::createVoxelResources(const VulkanCore& core) { config.depth_write = true; // Shadow maps keep standard depth whatever the scene convention config.depth_compare = vk::CompareOp::eLess; + config.depth_clamp = core.isDepthClampSupported(); + // Shadow projections are not Y flipped + config.front_face = vk::FrontFace::eClockwise; for (size_t mask_index = 0; mask_index < m_voxel_pipeline_sets.size(); ++mask_index) { VoxelPipelineSet& set = m_voxel_pipeline_sets[mask_index]; @@ -464,7 +492,85 @@ void ShadowPass::createVoxelResources(const VulkanCore& core) { }); if (!m_voxels_ready) { TOAST_ERROR("Render", "ShadowPass voxel pipelines failed to build, voxels will not cast shadows"); + return; } + createVoxelMeshResources(core); +} + +void ShadowPass::createVoxelMeshResources(const VulkanCore& core) { + ZoneScoped; + static_assert(sizeof(VoxelMeshShadowPush) == 16, "VoxelMeshShadowPush is mirrored by voxel_shadow_mesh_common.slang"); + if (core.meshMultiviewViews() == 0) { + TOAST_INFO("Render", "ShadowPass voxels march since multiview mesh shaders are unavailable"); + return; + } + + const auto mesh_uid = assets::resolveURI("core://shaders/voxel_shadow_mesh.slang"); + const auto task_uid = assets::resolveURI("core://shaders/voxel_shadow_task.slang"); + const auto mesh_shader = mesh_uid.has_value() ? ShaderCache::get().acquire(*mesh_uid) : nullptr; + const auto task_shader = task_uid.has_value() ? ShaderCache::get().acquire(*task_uid) : nullptr; + if (!mesh_shader || !task_shader) { + TOAST_WARN( + "Render", + "ShadowPass shaders core://shaders/voxel_shadow_mesh.slang and voxel_shadow_task.slang unavailable, voxels march" + ); + return; + } + + // Same sets as voxel_shadow.slang so the voxel shadow and storage sets bind to either layout + m_voxel_mesh_layout.rebuild(core, mesh_shader->reflection, "ShadowPass VoxelMesh"); + + VulkanPipeline::Config config; + config.pipeline_type = VulkanPipeline::PipelineType::graphics; + config.depth_only = true; + config.depth_format = m_format; + config.extent = vk::Extent2D {shadows::cascadeResolution(), shadows::cascadeResolution()}; + config.shader_spirv = mesh_shader->spirv; + config.task_spirv = task_shader->spirv; + config.task_entry = "shadowTaskMain"; + config.mesh_entry = "shadowMeshMain"; + config.pipeline_layout = *m_voxel_mesh_layout.getPipelineLayout(); + config.depth_test = true; + config.depth_write = true; + config.depth_compare = vk::CompareOp::eLess; + config.depth_bias_constant = k_caster_bias_constant; + config.depth_bias_slope = k_caster_bias_slope; + config.depth_clamp = core.isDepthClampSupported(); + config.cull_mode = vk::CullModeFlagBits::eNone; + + for (size_t mask_index = 0; mask_index < m_voxel_mesh_pipelines.size(); ++mask_index) { + VulkanPipeline::Config variant = config; + variant.view_mask = m_voxel_pipeline_sets[mask_index].view_mask; + // Groups wider than the device allows march + if (static_cast(std::popcount(variant.view_mask)) > core.meshMultiviewViews()) { + continue; + } + variant.debug_name = std::format("ShadowPass VoxelMesh [viewMask {:#x}]", variant.view_mask); + m_voxel_mesh_pipelines[mask_index].rebuild(core, variant); + } + + const auto properties = + core.getPhysicalDevice().getProperties2(); + const auto& mesh_properties = properties.get(); + std::ranges::copy(mesh_properties.maxTaskWorkGroupCount, m_max_task_groups.begin()); + m_max_task_group_total = mesh_properties.maxTaskWorkGroupTotalCount; + + // Extension entry points are not exported by the loader library so they come from the device dispatcher + m_draw_mesh_tasks = core.getDevice().getDispatcher()->vkCmdDrawMeshTasksEXT; + TOAST_INFO( + "Render", + "ShadowPass voxels near enough to the light rasterize task and mesh shader quads in groups of up to {} views", + core.meshMultiviewViews() + ); +} + +auto ShadowPass::voxelMeshPipelineFor(uint32_t view_mask) const -> const VulkanPipeline* { + for (size_t mask_index = 0; mask_index < m_voxel_mesh_pipelines.size(); ++mask_index) { + if (m_voxel_pipeline_sets[mask_index].view_mask == view_mask && m_voxel_mesh_pipelines[mask_index].isReady()) { + return &m_voxel_mesh_pipelines[mask_index]; + } + } + return nullptr; } auto ShadowPass::prepareVoxels(uint32_t frame_index) -> uint32_t { @@ -505,12 +611,34 @@ auto ShadowPass::prepareVoxels(uint32_t frame_index) -> uint32_t { ); } } + ++m_caster_frame; + const float tolerance = shadows::casterTolerance(); + m_caster_models.resize(count); + m_caster_moving.resize(count); for (uint32_t i = 0; i < count; ++i) { const auto& proxy = frame->voxel_instances[i]; + auto [it, inserted] = m_settled_casters.try_emplace(proxy.node_rid); + SettledCaster& settled = it->second; + const glm::vec3 extent = glm::vec3(proxy.brick_dims) * voxel::k_brick_size; + if (inserted || cornerDrift(settled.model, proxy.model, extent) > tolerance) { + settled.model = proxy.model; + settled.inverse_model = proxy.inverse_model; + settled.moved_frame = m_caster_frame; + } + settled.seen_frame = m_caster_frame; + m_caster_models[i] = settled.model; + m_caster_moving[i] = m_caster_frame - settled.moved_frame < k_caster_settle_frames ? 1u : 0u; + // Shadows never sample motion so whether a real previous transform exists does not matter here - instances[i] = makeVoxelInstance(proxy.model, proxy.inverse_model, proxy.model, true, proxy.record_index); + instances[i] = makeVoxelInstance(settled.model, settled.inverse_model, settled.model, true, proxy.record_index); } allocation.flush(0, sizeof(VoxelInstanceGpu) * count); + + if (m_caster_frame % k_caster_settle_frames == 0) { + std::erase_if(m_settled_casters, [&](const auto& entry) { + return m_caster_frame - entry.second.seen_frame > k_caster_settle_frames; + }); + } return count; } @@ -579,6 +707,7 @@ void ShadowPass::recordMap(vk::CommandBuffer cmd, ShadowMap& map, ShadowMap& sta return result; }; + const float receiver_depth_radii = shadows::cascadeReceiverDepthRadii(); const auto reaches = [&](const LayerGroup& group, const glm::vec3& center, float radius) { for (uint32_t i = 0; i < group.layer_count; ++i) { const auto* member = layer_views[group.base_layer + i]; @@ -587,7 +716,20 @@ void ShadowPass::recordMap(vk::CommandBuffer cmd, ShadowMap& map, ShadowMap& sta } const glm::vec3 offset = center - glm::vec3(member->cull_sphere); const float reach = member->cull_sphere.w + radius; - if (glm::dot(offset, offset) <= (reach * reach)) { + if (!member->directional) { + if (glm::dot(offset, offset) <= (reach * reach)) { + return true; + } + continue; + } + + // Any distance toward the sun since clamped depth keeps casters in front + const auto& m = member->view_projection; + const glm::vec3 away = glm::normalize(glm::vec3(m[0][2], m[1][2], m[2][2])); + const float along = glm::dot(offset, away); + const glm::vec3 lateral = offset - (away * along); + const float depth_reach = (member->cull_sphere.w * receiver_depth_radii) + radius; + if (along <= depth_reach && glm::dot(lateral, lateral) <= (reach * reach)) { return true; } } @@ -604,7 +746,9 @@ void ShadowPass::recordMap(vk::CommandBuffer cmd, ShadowMap& map, ShadowMap& sta return reaches(group, voxel_proxies[index].bounds_center, voxel_proxies[index].bounds_radius); }; - const auto voxel_moving = [&](size_t index) { return isMovingVolume(voxel_proxies[index]); }; + // Without the cache every voxel caster draws with the moving ones into each slot + const bool static_cache = shadows::staticCasterCache(); + const auto voxel_moving = [&](size_t index) { return !static_cache || m_caster_moving[index] != 0; }; // Summed since voxel proxies are sorted by camera distance and a static light must not re-render as the camera moves const auto voxel_signature = [&](const LayerGroup& group, bool moving, std::vector* casters) -> uint64_t { @@ -615,10 +759,10 @@ void ShadowPass::recordMap(vk::CommandBuffer cmd, ShadowMap& map, ShadowMap& sta } ShadowSignature caster; caster.add(voxel_proxies[index].node_rid); - caster.add(voxel_proxies[index].model); + caster.add(m_caster_models[index]); sum += caster.value; if (casters != nullptr) { - casters->push_back({.node_rid = voxel_proxies[index].node_rid, .model = voxel_proxies[index].model}); + casters->push_back({.node_rid = voxel_proxies[index].node_rid, .model = m_caster_models[index]}); } } return sum; @@ -659,9 +803,9 @@ void ShadowPass::recordMap(vk::CommandBuffer cmd, ShadowMap& map, ShadowMap& sta } ShadowSignature caster; caster.add(voxel_proxies[index].node_rid); - caster.add(voxel_proxies[index].model); + caster.add(m_caster_models[index]); voxel_casters += caster.value; - m_voxel_eligible.push_back({.node_rid = voxel_proxies[index].node_rid, .model = voxel_proxies[index].model}); + m_voxel_eligible.push_back({.node_rid = voxel_proxies[index].node_rid, .model = m_caster_models[index]}); } if (!m_voxel_eligible.empty()) { signature.add(voxel_casters); @@ -832,6 +976,14 @@ void ShadowPass::recordMap(vk::CommandBuffer cmd, ShadowMap& map, ShadowMap& sta } }; + // Only a volume past the device task dispatch limits marches when mesh shaders are available + const auto meshes_volume = [&](const VulkanRenderer::VoxelVolumeProxy& proxy) { + const glm::uvec3 coarse = voxel::gpu::coarseDims(proxy.brick_dims); + const uint64_t groups_x = uint64_t {coarse.x} * k_task_groups_per_cell; + return groups_x <= m_max_task_groups[0] && coarse.y <= m_max_task_groups[1] && coarse.z <= m_max_task_groups[2] && + groups_x * coarse.y * coarse.z <= m_max_task_group_total; + }; + const auto draw_voxels = [&](const LayerGroup& group, const VulkanRenderer::ShadowView& view, uint32_t view_index, bool moving) { const VoxelPipelineSet* voxel_pipelines = voxelPipelineSetFor(group.view_mask); @@ -839,12 +991,42 @@ void ShadowPass::recordMap(vk::CommandBuffer cmd, ShadowMap& map, ShadowMap& sta return; } const vk::PipelineLayout voxel_layout = *m_voxel_layout.getPipelineLayout(); + const vk::PipelineLayout mesh_layout = *m_voxel_mesh_layout.getPipelineLayout(); + const VulkanPipeline* mesh_pipeline = + m_draw_mesh_tasks != nullptr && shadows::voxelMeshShadows() ? voxelMeshPipelineFor(group.view_mask) : nullptr; + + const auto sets = + std::array {*m_voxel_shadow_sets[frame_index], *m_voxel_storage_sets[frame_index]}; + // Meshed first since their quads keep early depth for the marched volumes behind them m_voxel_draw_order.clear(); + bool meshes_bound = false; for (uint32_t index = 0; index < m_voxel_instance_count; ++index) { - if (voxel_moving(index) == moving && voxel_eligible(group, index)) { + if (voxel_moving(index) != moving || !voxel_eligible(group, index)) { + continue; + } + const auto& proxy = voxel_proxies[index]; + if (mesh_pipeline == nullptr || !meshes_volume(proxy)) { m_voxel_draw_order.push_back(index); + continue; } + + if (!meshes_bound) { + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, mesh_pipeline->getPipeline()); + cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, mesh_layout, 0, sets, {}); + meshes_bound = true; + } + const VoxelMeshShadowPush push { + .instance_index = index, + .view_index = view_index, + .view_count = group.layer_count, + .directional = directional ? 1u : 0u, + }; + cmd.pushConstants(mesh_layout, vk::ShaderStageFlagBits::eAll, 0, sizeof(VoxelMeshShadowPush), &push); + const glm::uvec3 coarse = voxel::gpu::coarseDims(proxy.brick_dims); + m_draw_mesh_tasks(static_cast(cmd), coarse.x * k_task_groups_per_cell, coarse.y, coarse.z); + ++m_stats.draws; + ++m_stats.meshed_volumes; } if (m_voxel_draw_order.empty()) { return; @@ -855,15 +1037,18 @@ void ShadowPass::recordMap(vk::CommandBuffer cmd, ShadowMap& map, ShadowMap& sta return (view.view_projection * glm::vec4(voxel_proxies[index].bounds_center, 1.0f)).z; }); - // A corner in front of any near plane clips the front faces so only exact back faces are safe - const auto crosses_near_plane = [&](const VulkanRenderer::VoxelVolumeProxy& proxy) { - const glm::vec3 extent = glm::vec3(proxy.brick_dims) * voxel::k_brick_size; + // A perspective near plane clips front faces so only exact back faces are safe and cascades clamp + const auto crosses_near_plane = [&](uint32_t index) { + if (directional && m_core->isDepthClampSupported()) { + return false; + } + const glm::vec3 extent = glm::vec3(voxel_proxies[index].brick_dims) * voxel::k_brick_size; for (uint32_t i = 0; i < group.layer_count; ++i) { const auto* member = layer_views[group.base_layer + i]; if (member == nullptr) { continue; } - const glm::mat4 to_clip = member->view_projection * proxy.model; + const glm::mat4 to_clip = member->view_projection * m_caster_models[index]; for (uint32_t corner = 0; corner < 8; ++corner) { const glm::vec3 local( (corner & 1u) != 0 ? extent.x : 0.0f, (corner & 2u) != 0 ? extent.y : 0.0f, (corner & 4u) != 0 ? extent.z : 0.0f @@ -876,17 +1061,11 @@ void ShadowPass::recordMap(vk::CommandBuffer cmd, ShadowMap& map, ShadowMap& sta return false; }; - cmd.bindDescriptorSets( - vk::PipelineBindPoint::eGraphics, - voxel_layout, - 0, - std::array {*m_voxel_shadow_sets[frame_index], *m_voxel_storage_sets[frame_index]}, - {} - ); + cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, voxel_layout, 0, sets, {}); const VulkanPipeline* bound = nullptr; for (const uint32_t index : m_voxel_draw_order) { const auto& proxy = voxel_proxies[index]; - const bool inside = crosses_near_plane(proxy); + const bool inside = crosses_near_plane(index); const bool cull_front = inside != proxy.mirrored; const VulkanPipeline& pipeline = voxel_pipelines->pipelines[cull_front ? 1 : 0][inside ? 1 : 0]; if (bound != &pipeline) { @@ -898,10 +1077,12 @@ void ShadowPass::recordMap(vk::CommandBuffer cmd, ShadowMap& map, ShadowMap& sta .instance_index = index, .view_index = view_index, .constant_bias = m_voxel_constant_bias, + .directional = directional ? 1u : 0u, }; cmd.pushConstants(voxel_layout, vk::ShaderStageFlagBits::eAll, 0, sizeof(VoxelShadowPush), &push); cmd.draw(36, 1, 0, 0); ++m_stats.draws; + ++m_stats.marched_volumes; } }; @@ -1014,7 +1195,7 @@ void ShadowPass::recordPre(vk::CommandBuffer cmd, uint32_t frame_index, uint32_t m_voxel_instance_count = prepareVoxels(frame_index); for (uint32_t index = 0; index < m_voxel_instance_count; ++index) { - m_stats.moving_volumes += isMovingVolume(frame->voxel_instances[index]) ? 1u : 0u; + m_stats.moving_volumes += m_caster_moving[index]; } recordMap(cmd, target.cascades, m_static.cascades, frame_index, true); diff --git a/engine/src/toast/renderer/passes/shadow_pass.hpp b/engine/src/toast/renderer/passes/shadow_pass.hpp index 0bf7b8232..3725dc738 100644 --- a/engine/src/toast/renderer/passes/shadow_pass.hpp +++ b/engine/src/toast/renderer/passes/shadow_pass.hpp @@ -19,6 +19,7 @@ #include #include #include +#include #include namespace renderer { @@ -70,6 +71,10 @@ class ShadowPass : public IRenderPass { uint32_t moving_volumes = 0; /// Cached static groups that a voxel edit elsewhere would have re-rendered uint32_t spared = 0; + /// Voxel volume draws rasterized from task and mesh shader quads + uint32_t meshed_volumes = 0; + /// Voxel volume draws marched per texel + uint32_t marched_volumes = 0; }; [[nodiscard]] @@ -95,7 +100,8 @@ class ShadowPass : public IRenderPass { uint32_t instance_index = 0; uint32_t view_index = 0; float constant_bias = 0.0f; - uint32_t pad0 = 0; + /// Nonzero for cascades whose rays march from behind the near plane + uint32_t directional = 0; }; struct VoxelPipelineSet { @@ -105,6 +111,14 @@ class ShadowPass : public IRenderPass { std::array, 2> pipelines; }; + /// Mirrors voxel_shadow_mesh_common.slang ShadowMeshPush + struct VoxelMeshShadowPush { + uint32_t instance_index = 0; + uint32_t view_index = 0; + uint32_t view_count = 1; + uint32_t directional = 0; + }; + static constexpr uint32_t k_max_voxel_casters = 4096; /// Throttles the over limit warning std::chrono::steady_clock::time_point m_last_caster_limit_warning; @@ -177,6 +191,12 @@ class ShadowPass : public IRenderPass { [[nodiscard]] auto voxelPipelineSetFor(uint32_t view_mask) const -> const VoxelPipelineSet*; + /// Task and mesh shader voxel casters which share the voxel shadow descriptor sets + void createVoxelMeshResources(const VulkanCore& core); + + [[nodiscard]] + auto voxelMeshPipelineFor(uint32_t view_mask) const -> const VulkanPipeline*; + void recordMap(vk::CommandBuffer cmd, ShadowMap& map, ShadowMap& static_map, uint32_t frame_index, bool directional); [[nodiscard]] @@ -211,8 +231,27 @@ class ShadowPass : public IRenderPass { uint32_t m_voxel_instance_count = 0; + struct SettledCaster { + glm::mat4 model {1.0f}; + glm::mat4 inverse_model {1.0f}; + uint64_t moved_frame = 0; + uint64_t seen_frame = 0; + }; + + std::unordered_map m_settled_casters; + uint64_t m_caster_frame = 0; + std::vector m_caster_models; + std::vector m_caster_moving; + std::vector m_voxel_draw_order; + ShaderLayout m_voxel_mesh_layout; + /// Matches m_voxel_pipeline_sets by index + std::array m_voxel_mesh_pipelines; + PFN_vkCmdDrawMeshTasksEXT m_draw_mesh_tasks = nullptr; + std::array m_max_task_groups {}; + uint32_t m_max_task_group_total = 0; + std::vector m_static_copies; }; diff --git a/engine/src/toast/renderer/passes/ssr_pass.cpp b/engine/src/toast/renderer/passes/ssr_pass.cpp index 9be4ec230..e0ad92a5d 100644 --- a/engine/src/toast/renderer/passes/ssr_pass.cpp +++ b/engine/src/toast/renderer/passes/ssr_pass.cpp @@ -54,6 +54,8 @@ SsrPass::SsrPass(const VulkanCore& core, vk::Format scene_format, vk::Extent2D e config.pipeline_type = VulkanPipeline::PipelineType::graphics; config.debug_name = debug_name; config.color_format = format; + // Confidence lives in alpha + config.write_color_alpha = true; config.extent = target_extent; config.shader_spirv = shader->spirv; config.fragment_entry = entry; @@ -65,12 +67,15 @@ SsrPass::SsrPass(const VulkanCore& core, vk::Format scene_format, vk::Extent2D e config.depth_write = false; pipeline.rebuild(core, config); }; - build_pipeline(m_trace_pipeline, k_reflection_format, PostProcessTarget::halfExtent(extent), "traceMain", "SsrPass Trace"); + const vk::Extent2D half = PostProcessTarget::halfExtent(extent); + build_pipeline(m_trace_pipeline, k_hit_format, half, "traceMain", "SsrPass Trace"); + build_pipeline(m_resolve_pipeline, k_reflection_format, half, "resolveMain", "SsrPass Resolve"); + build_pipeline(m_temporal_pipeline, k_reflection_format, half, "temporalMain", "SsrPass Temporal"); build_pipeline(m_composite_pipeline, scene_format, extent, "compositeMain", "SsrPass Composite"); createResources(core); createTargets(core, extent); - TOAST_INFO("Render", "SsrPass ready ({}, marched at half resolution)", vk::to_string(scene_format)); + TOAST_INFO("Render", "SsrPass ready ({}, GGX rays traced at half resolution)", vk::to_string(scene_format)); } void SsrPass::createResources(const VulkanCore& core) { @@ -95,8 +100,8 @@ void SsrPass::createResources(const VulkanCore& core) { const vk::DescriptorPool pool = VulkanRenderer::instance->getDescriptorPoolHandle(); m_descriptor_sets.clear(); - m_bound_views.assign(VulkanRenderer::k_frames_in_flight, vk::ImageView {}); - for (uint32_t i = 0; i < VulkanRenderer::k_frames_in_flight; ++i) { + m_bound_views.assign(VulkanRenderer::k_frames_in_flight * 2, vk::ImageView {}); + for (uint32_t i = 0; i < VulkanRenderer::k_frames_in_flight * 2; ++i) { const vk::DescriptorSetAllocateInfo alloc_info(pool, 1, &set_layout); auto allocated = device.allocateDescriptorSets(alloc_info); m_descriptor_sets.push_back(std::move(allocated[0])); @@ -105,9 +110,14 @@ void SsrPass::createResources(const VulkanCore& core) { } void SsrPass::createTargets(const VulkanCore& core, vk::Extent2D extent) { + const vk::Extent2D half = PostProcessTarget::halfExtent(extent); m_target.create(core, extent, m_scene_format, "SsrPass"); - m_reflection_target.create(core, PostProcessTarget::halfExtent(extent), k_reflection_format, "SsrPass Reflection"); + m_hit_target.create(core, half, k_hit_format, "SsrPass Hits"); + m_resolve_target.create(core, half, k_reflection_format, "SsrPass Resolve"); + m_history[0].create(core, half, k_reflection_format, "SsrPass History0"); + m_history[1].create(core, half, k_reflection_format, "SsrPass History1"); std::ranges::fill(m_bound_views, vk::ImageView {}); + m_history_valid = false; } void SsrPass::onResize(vk::Extent2D extent) { @@ -118,38 +128,56 @@ void SsrPass::onResize(vk::Extent2D extent) { auto SsrPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView source_view) -> vk::ImageView { ZoneScoped; - if (!m_trace_pipeline.isReady() || !m_composite_pipeline.isReady() || frame_index >= m_descriptor_sets.size() || !source_view || - !m_target.isReady() || !m_reflection_target.isReady()) { + const bool pipelines_ready = m_trace_pipeline.isReady() && m_resolve_pipeline.isReady() && m_temporal_pipeline.isReady() && + m_composite_pipeline.isReady(); + const bool targets_ready = m_target.isReady() && m_hit_target.isReady() && m_resolve_target.isReady() && + m_history[0].isReady() && m_history[1].isReady(); + if (!pipelines_ready || !targets_ready || (frame_index * 2) + 1 >= m_descriptor_sets.size() || !source_view) { return source_view; } const vk::ImageView depth_view = VulkanRenderer::instance->getDepthView(); const vk::ImageView normal_view = VulkanRenderer::instance->getSceneNormalView(); - if (!depth_view || !normal_view) { + const vk::ImageView motion_view = VulkanRenderer::instance->getSceneMotionView(); + if (!depth_view || !normal_view || !motion_view) { return source_view; } - if (m_bound_views[frame_index] != source_view) { - const std::array image_infos { + const PostProcessTarget& history_write = m_history[m_parity]; + const PostProcessTarget& history_read = m_history[1 - m_parity]; + const uint32_t set_index = (frame_index * 2) + m_parity; + const vk::DescriptorSet set = *m_descriptor_sets[set_index]; + + if (m_bound_views[set_index] != source_view) { + // Point sampled except the colours since interpolated depth or normals invent surfaces + const std::array image_infos { vk::DescriptorImageInfo(*m_sampler, source_view, vk::ImageLayout::eShaderReadOnlyOptimal), // Depth stays eDepthReadOnlyOptimal for the overlay stage vk::DescriptorImageInfo(*m_point_sampler, depth_view, vk::ImageLayout::eDepthReadOnlyOptimal), vk::DescriptorImageInfo(*m_point_sampler, normal_view, vk::ImageLayout::eShaderReadOnlyOptimal), + vk::DescriptorImageInfo(*m_point_sampler, m_hit_target.view(), vk::ImageLayout::eShaderReadOnlyOptimal), + vk::DescriptorImageInfo(*m_point_sampler, m_resolve_target.view(), vk::ImageLayout::eShaderReadOnlyOptimal), + vk::DescriptorImageInfo(*m_sampler, history_read.view(), vk::ImageLayout::eShaderReadOnlyOptimal), + vk::DescriptorImageInfo(*m_point_sampler, motion_view, vk::ImageLayout::eShaderReadOnlyOptimal), // Point sampled since the upsample picks its own texels - vk::DescriptorImageInfo(*m_point_sampler, m_reflection_target.view(), vk::ImageLayout::eShaderReadOnlyOptimal) + vk::DescriptorImageInfo(*m_point_sampler, history_write.view(), vk::ImageLayout::eShaderReadOnlyOptimal), }; - std::array writes {}; + std::array writes {}; for (uint32_t binding = 0; binding < writes.size(); ++binding) { - writes[binding] = vk::WriteDescriptorSet( - *m_descriptor_sets[frame_index], binding, 0, 1, vk::DescriptorType::eCombinedImageSampler, &image_infos[binding] - ); + writes[binding] = + vk::WriteDescriptorSet(set, binding, 0, 1, vk::DescriptorType::eCombinedImageSampler, &image_infos[binding]); } m_core->getDevice().updateDescriptorSets(writes, {}); - m_bound_views[frame_index] = source_view; + m_bound_views[set_index] = source_view; } const auto* frame = VulkanRenderer::instance->renderingFrame(); + const uint64_t sequence = frame != nullptr ? frame->sequence : 0; + const bool history_valid = m_history_valid && sequence == m_last_sequence + 1; + if (!history_valid) { + history_read.makeSampleable(cmd); + } const auto settings = frame != nullptr ? frame->post_process.ssr : VulkanRenderer::PostProcessSettings::Ssr {}; const glm::mat4 view_projection = frame != nullptr ? frame->frame_data.view_projection : glm::mat4(1.0f); @@ -157,29 +185,36 @@ auto SsrPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView Params params {}; params.view_projection = view_projection; params.inverse_view_projection = glm::inverse(view_projection); - params.camera_position = glm::vec4(frame != nullptr ? frame->frame_data.camera_position : glm::vec3(0.0f), 1.0f); + params.camera_position = + glm::vec4(frame != nullptr ? frame->frame_data.camera_position : glm::vec3(0.0f), static_cast(sequence % 4096)); params.tuning = glm::vec4(settings.intensity, settings.max_roughness, settings.thickness, static_cast(settings.max_steps)); - params.marching = glm::vec4(settings.stride, static_cast(frame != nullptr ? frame->render_mode : 0u), 0.0f, 0.0f); + params.marching = glm::vec4( + settings.stride * static_cast(settings.max_steps), + static_cast(frame != nullptr ? frame->render_mode : 0u), + history_valid ? 1.0f : 0.0f, + 0.0f + ); const auto draw = [&](const PostProcessTarget& target, const VulkanPipeline& pipeline) { target.beginScope(cmd); cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, pipeline.getPipeline()); cmd.bindDescriptorSets( - vk::PipelineBindPoint::eGraphics, - *m_shader_layout.getPipelineLayout(), - 0, - std::array {*m_descriptor_sets[frame_index]}, - {} + vk::PipelineBindPoint::eGraphics, *m_shader_layout.getPipelineLayout(), 0, std::array {set}, {} ); cmd.pushConstants(*m_shader_layout.getPipelineLayout(), vk::ShaderStageFlagBits::eAll, 0, sizeof(Params), ¶ms); cmd.draw(3, 1, 0, 0); target.endScope(cmd); }; - draw(m_reflection_target, m_trace_pipeline); + draw(m_hit_target, m_trace_pipeline); + draw(m_resolve_target, m_resolve_pipeline); + draw(history_write, m_temporal_pipeline); draw(m_target, m_composite_pipeline); + m_parity = 1 - m_parity; + m_history_valid = true; + m_last_sequence = sequence; return m_target.view(); } diff --git a/engine/src/toast/renderer/passes/ssr_pass.hpp b/engine/src/toast/renderer/passes/ssr_pass.hpp index 6e2194bbf..1498b4d8e 100644 --- a/engine/src/toast/renderer/passes/ssr_pass.hpp +++ b/engine/src/toast/renderer/passes/ssr_pass.hpp @@ -12,6 +12,7 @@ #include "../vulkan_common.hpp" #include "../vulkan_pipeline.hpp" +#include #include #include #include @@ -19,6 +20,7 @@ namespace renderer { class VulkanCore; +/// Half resolution stochastic SSR class SsrPass : public IPostProcessPass { public: SsrPass(const VulkanCore& core, vk::Format scene_format, vk::Extent2D extent); @@ -37,16 +39,22 @@ class SsrPass : public IPostProcessPass { struct Params { glm::mat4 view_projection {1.0f}; glm::mat4 inverse_view_projection {1.0f}; + /// w frame index for the noise glm::vec4 camera_position {0.0f}; /// x intensity y max roughness z thickness w step count glm::vec4 tuning {0.0f}; - /// x stride y render mode + /// x reach in meters y render mode z 1 with a valid history glm::vec4 marching {0.0f}; }; - /// rgb reflection a camera distance + /// xy hit uv z pdf w confidence in 32 bit since 16 bit cannot address wide screens + static constexpr vk::Format k_hit_format = vk::Format::eR32G32B32A32Sfloat; + /// rgb reflection a confidence static constexpr vk::Format k_reflection_format = vk::Format::eR16G16B16A16Sfloat; + /// Bindings of ssr.slang in order + static constexpr uint32_t k_binding_count = 8; + void createResources(const VulkanCore& core); void createTargets(const VulkanCore& core, vk::Extent2D extent); @@ -55,16 +63,28 @@ class SsrPass : public IPostProcessPass { ShaderLayout m_shader_layout; VulkanPipeline m_trace_pipeline; + VulkanPipeline m_resolve_pipeline; + VulkanPipeline m_temporal_pipeline; VulkanPipeline m_composite_pipeline; vk::raii::Sampler m_sampler = nullptr; vk::raii::Sampler m_point_sampler = nullptr; + /// Per frame in flight then per history parity std::vector m_descriptor_sets; std::vector m_bound_views; - PostProcessTarget m_reflection_target; + PostProcessTarget m_hit_target; + PostProcessTarget m_resolve_target; + /// The temporal pass reads one and writes the other + std::array m_history; PostProcessTarget m_target; + + /// Which history the temporal pass writes this frame + uint32_t m_parity = 0; + bool m_history_valid = false; + /// A gap in frames or a recreated target restarts the history + uint64_t m_last_sequence = 0; }; } diff --git a/engine/src/toast/renderer/passes/voxel_pass.cpp b/engine/src/toast/renderer/passes/voxel_pass.cpp index 12728f56f..121906ea9 100644 --- a/engine/src/toast/renderer/passes/voxel_pass.cpp +++ b/engine/src/toast/renderer/passes/voxel_pass.cpp @@ -11,8 +11,10 @@ #include #include +#include #include #include +#include #include #include #include @@ -38,10 +40,8 @@ constexpr uint32_t k_vertices_per_quad = 6; /// Readback words after the quad vertex count constexpr uint32_t k_readback_words = 3; -/// Below this share of listed cells hidden the late passes cost more than they save -constexpr float k_occlusion_min_hidden = 0.05f; +/// Measured frames below the renderer minimum hidden share before occlusion idles constexpr uint32_t k_occlusion_low_frames = 16; -constexpr uint32_t k_occlusion_idle_frames = 120; constexpr uint32_t k_cell_phase_count = 0; constexpr uint32_t k_cell_phase_allocate = 1; @@ -50,10 +50,59 @@ constexpr uint32_t k_cell_phase_late = 3; constexpr uint32_t k_cell_group_size = 64; /// Albedo face normal material and depth at bindings 0 to 3 of voxel_lighting.slang -constexpr uint32_t k_gbuffer_set = 1; +constexpr uint32_t k_gbuffer_set = 2; + +/// voxel_lighting.slang lighting.z +constexpr uint32_t k_marched_none = 0; +constexpr uint32_t k_marched_into_terms = 1; +constexpr uint32_t k_marched_into_colour = 2; + +/// voxel_denoise.slang spiral radius in pixels +constexpr float k_denoise_radius = 6.0f; + +/// voxel_lighting.slang gInstanceBounds +constexpr uint32_t k_instance_bounds_binding = 7; + +/// voxel_lighting.slang gAmbientGrid +constexpr uint32_t k_ambient_grid_binding = 8; +/// Count then instance indices of the shadow casters holding glass +constexpr uint32_t k_glass_casters_binding = 9; +/// Mirrored by voxel_lighting.slang +constexpr uint32_t k_ambient_grid_buckets = 4096; +constexpr uint32_t k_ambient_grid_header = 4; +constexpr uint32_t k_ambient_grid_capacity = 65536; +/// More cells than this goes to the global list +constexpr uint64_t k_ambient_grid_max_cells = 125; + +auto ambientGridHash(const glm::ivec3& cell) -> uint32_t { + const glm::uvec3 u(cell); + return ((u.x * 73856093u) ^ (u.y * 19349663u) ^ (u.z * 83492791u)) & (k_ambient_grid_buckets - 1u); +} constexpr uint32_t k_depth_binding = 3; +/// voxel.slang gFreshRegions +constexpr uint32_t k_fresh_regions_binding = 13; +/// Regions a frame marks reactive and the rest wait for no later frame +constexpr uint32_t k_max_fresh_regions = 255; + +/// voxel_lighting.slang reuse.x bits +constexpr uint32_t k_reuse_history = 1; +constexpr uint32_t k_reuse_spatial = 2; +/// Some shadow ray instance holds glass +constexpr uint32_t k_scene_glass = 4; + +/// ReSTIR reuse of last frame's reservoir at the reprojected pixel and two around it +constexpr uint32_t k_reused_reservoirs = 3; +/// Temporal M cap as a multiple of the fresh candidates +constexpr uint32_t k_reservoir_m_cap = 20; + +/// voxel_glass.slang gGlassBricks +constexpr uint32_t k_glass_bricks_binding = 7; +/// voxel_glass.slang gBackdrop and gBackdropDepth +constexpr uint32_t k_backdrop_binding = 8; +constexpr uint32_t k_backdrop_depth_binding = 9; + /// Turns the rounding spiral each frame for TAA to average constexpr float k_golden_ratio_conjugate = 0.61803398875f; @@ -153,6 +202,8 @@ VoxelPass::VoxelPass(const VulkanCore& core, vk::Format scene_format, vk::Format writeHizDescriptors(); } createLighting(core, scene_format, extent); + createGlass(core, scene_format, depth_format, extent); + createDenoise(core, scene_format, extent); TOAST_INFO( "Render", "VoxelPass ready: DDA into a G-buffer, up to {} volumes a frame, cell boxes {}", @@ -361,7 +412,9 @@ void VoxelPass::createLighting(const VulkanCore& core, vk::Format scene_format, config.pipeline_type = VulkanPipeline::PipelineType::graphics; config.debug_name = "VoxelLighting"; config.color_format = scene_format; - config.extra_color_formats = {k_scene_indirect_format, k_scene_normal_format}; + config.extra_color_formats = { + k_scene_indirect_format, k_scene_normal_format, k_voxel_noisy_format, k_voxel_noisy_format, k_voxel_reservoir_format + }; config.write_extra_color = true; config.extent = extent; config.shader_spirv = shader->spirv; @@ -373,8 +426,74 @@ void VoxelPass::createLighting(const VulkanCore& core, vk::Format scene_format, config.depth_write = false; m_lighting_pipeline.rebuild(core, config); + config.debug_name = "VoxelSpatialReuse"; + config.fragment_entry = "fragmentSpatial"; + config.color_format = k_voxel_noisy_format; + config.extra_color_formats = {k_voxel_noisy_format, k_voxel_reservoir_format}; + config.blend_preset = VulkanPipeline::BlendPreset::additive; + config.extra_blend_mask = 1u; + m_spatial_pipeline.rebuild(core, config); + m_lighting_scene_sets.create(core, shader->reflection, *layouts[k_scene_set], "VoxelLighting"); + const vk::DescriptorSetLayout storage_layout = *layouts[k_storage_set]; + m_lighting_storage_sets.clear(); + m_instance_bounds.clear(); + m_glass_casters.clear(); + for (uint32_t i = 0; i < VulkanRenderer::k_frames_in_flight; ++i) { + vk::BufferCreateInfo bounds_ci {}; + bounds_ci.size = sizeof(glm::vec4) * k_max_instances; + bounds_ci.usage = vk::BufferUsageFlagBits::eStorageBuffer; + + // Every lit pixel reads these so device local host visible beats host memory over PCIe + vma::AllocationCreateInfo bounds_alloc {}; + bounds_alloc.usage = vma::MemoryUsage::eAuto; + bounds_alloc.flags = vma::AllocationCreateFlagBits::eMapped | vma::AllocationCreateFlagBits::eHostAccessSequentialWrite; + m_instance_bounds.push_back(core.getAllocator().createBuffer(bounds_ci, bounds_alloc)); + setDebugName(core, *m_instance_bounds.back(), std::format("VoxelLighting InstanceBounds[{}]", i)); + + vk::BufferCreateInfo grid_ci {}; + grid_ci.size = sizeof(uint32_t) * (k_ambient_grid_header + (2 * k_ambient_grid_buckets) + k_ambient_grid_capacity); + grid_ci.usage = vk::BufferUsageFlagBits::eStorageBuffer; + m_ambient_grids.push_back(core.getAllocator().createBuffer(grid_ci, bounds_alloc)); + setDebugName(core, *m_ambient_grids.back(), std::format("VoxelLighting AmbientGrid[{}]", i)); + if (auto* words = static_cast(m_ambient_grids.back().getAllocation().getInfo().pMappedData)) { + std::fill_n(words, k_ambient_grid_header + (2 * k_ambient_grid_buckets), 0u); + m_ambient_grids.back().getAllocation().flush(0, vk::WholeSize); + } + + vk::BufferCreateInfo casters_ci {}; + casters_ci.size = sizeof(uint32_t) * (1 + k_max_instances); + casters_ci.usage = vk::BufferUsageFlagBits::eStorageBuffer; + m_glass_casters.push_back(core.getAllocator().createBuffer(casters_ci, bounds_alloc)); + setDebugName(core, *m_glass_casters.back(), std::format("VoxelLighting GlassCasters[{}]", i)); + if (auto* words = static_cast(m_glass_casters.back().getAllocation().getInfo().pMappedData)) { + words[0] = 0; + m_glass_casters.back().getAllocation().flush(0, sizeof(uint32_t)); + } + + auto allocated = core.getDevice().allocateDescriptorSets( + vk::DescriptorSetAllocateInfo(VulkanRenderer::instance->getDescriptorPoolHandle(), 1, &storage_layout) + ); + m_lighting_storage_sets.push_back(std::move(allocated[0])); + setDebugName(core, *m_lighting_storage_sets.back(), std::format("VoxelLighting StorageSet[{}]", i)); + + DescriptorWriter writer; + writer.buffer( + *m_lighting_storage_sets.back(), k_instance_binding, vk::DescriptorType::eStorageBuffer, *m_instance_buffers[i] + ); + writer.buffer( + *m_lighting_storage_sets.back(), k_instance_bounds_binding, vk::DescriptorType::eStorageBuffer, *m_instance_bounds.back() + ); + writer.buffer( + *m_lighting_storage_sets.back(), k_ambient_grid_binding, vk::DescriptorType::eStorageBuffer, *m_ambient_grids.back() + ); + writer.buffer( + *m_lighting_storage_sets.back(), k_glass_casters_binding, vk::DescriptorType::eStorageBuffer, *m_glass_casters.back() + ); + writer.flush(core.getDevice()); + } + // Nearest since every fetch is one texel of its own pixel m_point_sampler = vk::raii::Sampler(core.getDevice(), nearestClampSamplerInfo()); setDebugName(core, *m_point_sampler, "VoxelLighting PointSampler"); @@ -390,6 +509,91 @@ void VoxelPass::createLighting(const VulkanCore& core, vk::Format scene_format, } } +void VoxelPass::createGlass(const VulkanCore& core, vk::Format scene_format, vk::Format depth_format, vk::Extent2D extent) { + ZoneScoped; + const auto uid = assets::resolveURI("core://shaders/voxel_glass.slang"); + const auto shader = uid.has_value() ? ShaderCache::get().acquire(*uid) : nullptr; + if (!shader) { + TOAST_WARN("Render", "VoxelPass shader core://shaders/voxel_glass.slang unavailable; voxel glass will not draw"); + return; + } + + m_glass_layout.rebuild(core, shader->reflection, "VoxelGlass"); + const auto& layouts = m_glass_layout.getDescriptorSetLayouts(); + if (layouts.size() <= k_storage_set) { + TOAST_WARN("Render", "VoxelGlass shader layout declares {} descriptor sets; voxel glass will not draw", layouts.size()); + return; + } + + const bool dual_source = core.isDualSourceBlendSupported(); + m_glass_dual_source = dual_source; + m_backdrop_sampler = vk::raii::Sampler(core.getDevice(), linearClampSamplerInfo()); + setDebugName(core, *m_backdrop_sampler, "VoxelGlass BackdropSampler"); + m_backdrop_depth_sampler = vk::raii::Sampler(core.getDevice(), nearestClampSamplerInfo()); + setDebugName(core, *m_backdrop_depth_sampler, "VoxelGlass BackdropDepthSampler"); + + VulkanPipeline::Config config; + config.pipeline_type = VulkanPipeline::PipelineType::graphics; + config.debug_name = "VoxelGlass"; + config.color_format = scene_format; + config.depth_format = depth_format; + config.extent = extent; + config.extra_color_formats = worldStageExtraColorFormats(); + config.write_extra_color = false; + config.shader_spirv = shader->spirv; + config.pipeline_layout = *m_glass_layout.getPipelineLayout(); + config.vertex_bindings = {}; + config.vertex_attributes = {}; + config.fragment_entry = dual_source ? "fragmentMain" : "fragmentPremultiplied"; + // Back faces so a camera inside a brick still draws it + config.cull_mode = vk::CullModeFlagBits::eFront; + config.depth_test = true; + config.depth_write = false; + config.depth_compare = depth::k_closer; + config.blend_preset = dual_source ? VulkanPipeline::BlendPreset::dual_source : VulkanPipeline::BlendPreset::premultiplied; + m_glass_pipeline.rebuild(core, config); + + config.debug_name = "VoxelGlass Surface"; + config.fragment_entry = "fragmentSurface"; + config.blend_preset = VulkanPipeline::BlendPreset::none; + config.write_color = false; + config.write_extra_color = true; + config.depth_write = true; + m_glass_surface_pipeline.rebuild(core, config); + + if (!dual_source) { + TOAST_WARN("Render", "dualSrcBlend unsupported so voxel glass fades the background without tinting it"); + } + + m_glass_scene_sets.create(core, shader->reflection, *layouts[k_scene_set], "VoxelGlass"); + + const vk::DescriptorPool pool = VulkanRenderer::instance->getDescriptorPoolHandle(); + const vk::DescriptorSetLayout glass_layout = *layouts[k_storage_set]; + m_glass_sets.clear(); + m_glass_buffers.clear(); + m_bound_backdrop.assign(VulkanRenderer::k_frames_in_flight, {}); + for (uint32_t i = 0; i < VulkanRenderer::k_frames_in_flight; ++i) { + vk::BufferCreateInfo buffer_ci {}; + buffer_ci.size = sizeof(glm::uvec2) * k_max_glass_bricks; + buffer_ci.usage = vk::BufferUsageFlagBits::eStorageBuffer; + + vma::AllocationCreateInfo alloc_ci {}; + alloc_ci.usage = vma::MemoryUsage::eAutoPreferHost; + alloc_ci.flags = vma::AllocationCreateFlagBits::eMapped | vma::AllocationCreateFlagBits::eHostAccessSequentialWrite; + m_glass_buffers.push_back(core.getAllocator().createBuffer(buffer_ci, alloc_ci)); + setDebugName(core, *m_glass_buffers.back(), std::format("VoxelGlass Bricks[{}]", i)); + + auto allocated = core.getDevice().allocateDescriptorSets(vk::DescriptorSetAllocateInfo(pool, 1, &glass_layout)); + m_glass_sets.push_back(std::move(allocated[0])); + setDebugName(core, *m_glass_sets.back(), std::format("VoxelGlass StorageSet[{}]", i)); + + DescriptorWriter writer; + writer.buffer(*m_glass_sets.back(), k_instance_binding, vk::DescriptorType::eStorageBuffer, *m_instance_buffers[i]); + writer.buffer(*m_glass_sets.back(), k_glass_bricks_binding, vk::DescriptorType::eStorageBuffer, *m_glass_buffers.back()); + writer.flush(core.getDevice()); + } +} + void VoxelPass::createInstanceBuffers(const VulkanCore& core) { ZoneScoped; m_instance_buffers.clear(); @@ -400,8 +604,9 @@ void VoxelPass::createInstanceBuffers(const VulkanCore& core) { buffer_ci.size = sizeof(VoxelInstanceGpu) * k_max_instances; buffer_ci.usage = vk::BufferUsageFlagBits::eStorageBuffer; + // Read by every voxel draw and shadow ray so device local host visible when the device has it vma::AllocationCreateInfo alloc_ci {}; - alloc_ci.usage = vma::MemoryUsage::eAutoPreferHost; + alloc_ci.usage = vma::MemoryUsage::eAuto; alloc_ci.flags = vma::AllocationCreateFlagBits::eMapped | vma::AllocationCreateFlagBits::eHostAccessSequentialWrite; m_instance_buffers.push_back(core.getAllocator().createBuffer(buffer_ci, alloc_ci)); @@ -441,6 +646,26 @@ void VoxelPass::createDescriptors(const VulkanCore& core, const ShaderReflection *m_storage_sets[i], k_instance_binding, 0, 1, vk::DescriptorType::eStorageBuffer, nullptr, &instance_info ); device.updateDescriptorSets(write, {}); + + vk::BufferCreateInfo regions_ci {}; + regions_ci.size = sizeof(glm::vec4) * (1 + (2 * k_max_fresh_regions)); + regions_ci.usage = vk::BufferUsageFlagBits::eStorageBuffer; + + vma::AllocationCreateInfo regions_alloc {}; + regions_alloc.usage = vma::MemoryUsage::eAutoPreferHost; + regions_alloc.flags = vma::AllocationCreateFlagBits::eMapped | vma::AllocationCreateFlagBits::eHostAccessSequentialWrite; + m_fresh_regions.push_back(core.getAllocator().createBuffer(regions_ci, regions_alloc)); + setDebugName(core, *m_fresh_regions.back(), std::format("VoxelPass FreshRegions[{}]", i)); + if (auto* mapped = static_cast(m_fresh_regions.back().getAllocation().getInfo().pMappedData); mapped != nullptr) { + mapped[0] = glm::vec4(0.0f); + m_fresh_regions.back().getAllocation().flush(0, sizeof(glm::vec4)); + } + + DescriptorWriter regions_writer; + regions_writer.buffer( + *m_storage_sets[i], k_fresh_regions_binding, vk::DescriptorType::eStorageBuffer, *m_fresh_regions.back() + ); + regions_writer.flush(device); } } @@ -451,9 +676,107 @@ void VoxelPass::bindStorage(uint32_t frame_index, const std::shared_ptrgetDevice(), *m_cell_sets[frame_index], *storage); } + if (frame_index < m_glass_sets.size()) { + writeVoxelStorageDescriptors(m_core->getDevice(), *m_glass_sets[frame_index], *storage); + } + if (frame_index < m_lighting_storage_sets.size()) { + writeVoxelStorageDescriptors(m_core->getDevice(), *m_lighting_storage_sets[frame_index], *storage); + } m_bound_storage[frame_index] = storage; } +void VoxelPass::writeGlassCasters( + uint32_t frame_index, const VoxelGpuStorage& storage, std::span casters +) { + ZoneScoped; + if (frame_index >= m_glass_casters.size()) { + return; + } + auto* words = static_cast(m_glass_casters[frame_index].getAllocation().getInfo().pMappedData); + if (words == nullptr) { + return; + } + // Same cut as the shadow instance count the shader loops over + const size_t limit = std::min(casters.size(), VulkanRenderer::instance->voxelShadowInstances()); + uint32_t count = 0; + for (size_t i = 0; i < limit && count < k_max_instances; ++i) { + if (!storage.glassBricks(casters[i].record_index).empty()) { + words[1 + count++] = static_cast(i); + } + } + words[0] = count; + m_glass_casters[frame_index].getAllocation().flush(0, sizeof(uint32_t) * (1 + count)); +} + +void VoxelPass::writeAmbientGrid(uint32_t frame_index, std::span bounds) { + ZoneScoped; + if (frame_index >= m_ambient_grids.size()) { + return; + } + const auto& grid_allocation = m_ambient_grids[frame_index].getAllocation(); + auto* words = static_cast(grid_allocation.getInfo().pMappedData); + if (words == nullptr) { + return; + } + + // Centimetres like the push constant + const float reach = static_cast(std::lround(VulkanRenderer::instance->voxelAmbientReach() * 100.0f)) * 0.01f; + const float cell_size = std::max(reach, 0.5f); + + m_grid_pairs.clear(); + m_grid_global.clear(); + for (uint32_t index = 0; index < bounds.size(); ++index) { + const glm::vec3 center(bounds[index]); + const float radius = bounds[index].w + reach; + const glm::ivec3 low(glm::floor((center - radius) / cell_size)); + const glm::ivec3 high(glm::floor((center + radius) / cell_size)); + const glm::u64vec3 span(glm::max(high - low + 1, glm::ivec3(1))); + if (span.x * span.y * span.z > k_ambient_grid_max_cells) { + m_grid_global.push_back(index); + continue; + } + for (int32_t z = low.z; z <= high.z; ++z) { + for (int32_t y = low.y; y <= high.y; ++y) { + for (int32_t x = low.x; x <= high.x; ++x) { + m_grid_pairs.emplace_back(ambientGridHash({x, y, z}), index); + } + } + } + } + std::ranges::sort(m_grid_pairs); + const auto duplicates = std::ranges::unique(m_grid_pairs); + m_grid_pairs.erase(duplicates.begin(), duplicates.end()); + + if (m_grid_pairs.size() + m_grid_global.size() > k_ambient_grid_capacity) { + m_grid_pairs.clear(); + m_grid_global.resize(bounds.size()); + std::iota(m_grid_global.begin(), m_grid_global.end(), 0u); + } + + uint32_t* buckets = words + k_ambient_grid_header; + uint32_t* indices = buckets + (2 * k_ambient_grid_buckets); + std::fill_n(buckets, 2 * k_ambient_grid_buckets, 0u); + uint32_t cursor = 0; + for (size_t i = 0; i < m_grid_pairs.size();) { + const uint32_t bucket = m_grid_pairs[i].first; + buckets[2 * bucket] = cursor; + for (; i < m_grid_pairs.size() && m_grid_pairs[i].first == bucket; ++i) { + indices[cursor++] = m_grid_pairs[i].second; + } + buckets[(2 * bucket) + 1] = cursor - buckets[2 * bucket]; + } + const uint32_t global_offset = cursor; + for (const uint32_t index : m_grid_global) { + indices[cursor++] = index; + } + + words[0] = std::bit_cast(cell_size); + words[1] = global_offset; + words[2] = static_cast(m_grid_global.size()); + words[3] = 0; + grid_allocation.flush(0, sizeof(uint32_t) * (k_ambient_grid_header + (2 * k_ambient_grid_buckets) + cursor)); +} + auto VoxelPass::prepare(uint32_t frame_index) -> bool { ZoneScoped; m_draws.clear(); @@ -463,6 +786,13 @@ auto VoxelPass::prepare(uint32_t frame_index) -> bool { m_total_cells = 0; m_near_voxels = 0.0f; m_mesh_shading = false; + m_glass_count = 0; + m_shadow_instance_count = 0; + m_marched_lights = false; + m_ambient_rays = false; + m_denoise_active = false; + m_spatial_active = false; + m_scene_glass = false; if (frame_index >= m_storage_sets.size() || frame_index >= m_instance_buffers.size() || !m_scene_sets.get(frame_index) || !m_lighting_pipeline.isReady()) { @@ -485,6 +815,9 @@ auto VoxelPass::prepare(uint32_t frame_index) -> bool { } bool cells_fit = true; + auto* bounds = frame_index < m_instance_bounds.size() + ? static_cast(m_instance_bounds[frame_index].getAllocation().getInfo().pMappedData) + : nullptr; for (const auto& proxy : frame->voxel_instances) { if (!proxy.visible) { @@ -507,6 +840,9 @@ auto VoxelPass::prepare(uint32_t frame_index) -> bool { instances[index] = makeVoxelInstance(proxy.model, proxy.inverse_model, proxy.previous_model, proxy.has_previous, proxy.record_index); instances[index].pad0 = proxy.highlight_record; + if (bounds != nullptr) { + bounds[index] = glm::vec4(proxy.bounds_center, proxy.bounds_radius); + } instances[index].cell_base = m_total_cells; if (proxy.mirrored) { instances[index].flags |= k_voxel_instance_mirrored; @@ -525,10 +861,91 @@ auto VoxelPass::prepare(uint32_t frame_index) -> bool { if (m_draws.empty()) { return false; } - allocation.flush(0, sizeof(VoxelInstanceGpu) * m_draws.size()); + + writeFreshRegions(frame_index, *frame->voxel_storage); + + m_marched_lights = + std::ranges::any_of(frame->lights, [](const VulkanRenderer::GpuLight& light) { return light.shape.w > 0.5f; }); + const uint32_t suns = std::min(frame->frame_data.directional_light_count_pad.x, VulkanRenderer::k_max_directional_lights); + for (uint32_t i = 0; i < suns; ++i) { + m_marched_lights = m_marched_lights || frame->frame_data.directional_lights[i].direction.w > 0.0f; + } + m_ambient_rays = VulkanRenderer::instance->voxelAmbientRays() && VulkanRenderer::instance->voxelAmbientReach() > 0.0f; + + // Debug views and probe captures skip the denoiser + const bool capture = frame->probe_capture_index >= 0 || frame->irradiance_capture_index >= 0; + m_denoise_active = (m_marched_lights || m_ambient_rays) && frame->render_mode == 0 && !capture && + m_denoise_temporal_pipeline.isReady() && m_denoise_composite_pipeline.isReady() && + frame_index * 2 + 1 < m_denoise_sets.size() && m_denoise_history[1][1].isReady(); + if (!m_denoise_active) { + m_denoise_valid = false; + } + // Without the denoiser sampled lights shade in the lighting scope + m_spatial_active = + m_denoise_active && m_spatial_pipeline.isReady() && + std::ranges::any_of(frame->lights, [](const VulkanRenderer::GpuLight& light) { return light.shape.w > 1.5f; }); + + // Off screen volumes follow the drawn ones for shadow rays only + m_shadow_instance_count = static_cast(m_draws.size()); + m_scene_glass = VulkanRenderer::instance->voxelGlassShadows() && + std::ranges::any_of(frame->voxel_instances, [&](const VulkanRenderer::VoxelVolumeProxy& proxy) { + return !frame->voxel_storage->glassBricks(proxy.record_index).empty(); + }); + for (const auto& proxy : frame->voxel_instances) { + if (proxy.visible || bounds == nullptr || m_shadow_instance_count >= k_max_instances) { + continue; + } + instances[m_shadow_instance_count] = + makeVoxelInstance(proxy.model, proxy.inverse_model, proxy.previous_model, proxy.has_previous, proxy.record_index); + bounds[m_shadow_instance_count] = glm::vec4(proxy.bounds_center, proxy.bounds_radius); + ++m_shadow_instance_count; + } + + allocation.flush(0, sizeof(VoxelInstanceGpu) * m_shadow_instance_count); + writeGlassCasters(frame_index, *frame->voxel_storage, std::span(instances, m_shadow_instance_count)); + if (bounds != nullptr) { + m_instance_bounds[frame_index].getAllocation().flush(0, sizeof(glm::vec4) * m_shadow_instance_count); + if (m_ambient_rays) { + writeAmbientGrid( + frame_index, + std::span(bounds, std::min(m_shadow_instance_count, VulkanRenderer::instance->voxelShadowInstances())) + ); + } + } + prepareGlass(frame_index, *frame->voxel_storage, std::span(instances, m_draws.size())); + + // Before any scope of this frame binds the glass set + const std::array backdrop { + VulkanRenderer::instance->getGlassBackdropView(), VulkanRenderer::instance->getGlassBackdropDepthView() + }; + if (frame_index < m_glass_sets.size() && frame_index < m_bound_backdrop.size() && backdrop[0] && backdrop[1] && + m_bound_backdrop[frame_index] != backdrop) { + DescriptorWriter writer; + writer.image( + *m_glass_sets[frame_index], + k_backdrop_binding, + vk::DescriptorType::eCombinedImageSampler, + *m_backdrop_sampler, + backdrop[0], + vk::ImageLayout::eShaderReadOnlyOptimal + ); + writer.image( + *m_glass_sets[frame_index], + k_backdrop_depth_binding, + vk::DescriptorType::eCombinedImageSampler, + *m_backdrop_depth_sampler, + backdrop[1], + vk::ImageLayout::eDepthReadOnlyOptimal + ); + writer.flush(m_core->getDevice()); + m_bound_backdrop[frame_index] = backdrop; + } // Before any scope of this frame binds the set since updating a bound set invalidates the command buffer m_scene_sets.updateTlas(frame_index); + if (m_glass_count > 0) { + m_glass_scene_sets.updateTlas(frame_index); + } // The cell set binds the pyramid view so it must exist even with occlusion off if (VulkanRenderer::instance->voxelCellBoxes() && m_cell_pipeline.isReady() && m_hiz.view()) { @@ -730,11 +1147,12 @@ void VoxelPass::updateOcclusionMode(uint32_t retest_cells, uint32_t listed_cells if (m_occlusion_mode != OcclusionMode::engaged) { return; } + const auto* renderer = VulkanRenderer::instance; const float hidden = static_cast(retest_cells) / static_cast(std::max(listed_cells, 1u)); - m_occlusion_low_frames = hidden < k_occlusion_min_hidden ? m_occlusion_low_frames + 1 : 0; + m_occlusion_low_frames = hidden < renderer->voxelOcclusionMinHidden() ? m_occlusion_low_frames + 1 : 0; if (m_occlusion_low_frames >= k_occlusion_low_frames) { m_occlusion_mode = OcclusionMode::idle; - m_occlusion_idle_frames = k_occlusion_idle_frames; + m_occlusion_idle_frames = renderer->voxelOcclusionIdleFrames(); } } @@ -920,7 +1338,8 @@ void VoxelPass::record(vk::CommandBuffer cmd, uint32_t frame_index, uint32_t ima void VoxelPass::recordLighting(vk::CommandBuffer cmd, uint32_t frame_index, vk::Extent2D viewport) { ZoneScoped; - if (!m_lighting_pipeline.isReady() || frame_index >= m_gbuffer_sets.size() || !m_lighting_scene_sets.get(frame_index)) { + if (!m_lighting_pipeline.isReady() || frame_index >= m_gbuffer_sets.size() || frame_index >= m_lighting_storage_sets.size() || + !m_lighting_scene_sets.get(frame_index)) { return; } @@ -930,11 +1349,15 @@ void VoxelPass::recordLighting(vk::CommandBuffer cmd, uint32_t frame_index, vk:: return; } - const std::array views { + const std::array views { instance->getVoxelAlbedoView(), instance->getVoxelFaceNormalView(), instance->getVoxelMaterialView(), instance->getDepthView(), + instance->getVoxelPreviousReservoirView(), + instance->getSceneMotionView(), + instance->getVoxelCandidateReservoirView(), + instance->getSceneNormalView(), }; if (std::ranges::any_of(views, [](vk::ImageView view) { return !view; })) { return; @@ -962,28 +1385,393 @@ void VoxelPass::recordLighting(vk::CommandBuffer cmd, uint32_t frame_index, vk:: const auto height = static_cast(viewport.height); const float rotation = frame->taa_active ? std::fmod(static_cast(frame->sequence % 4096) * k_golden_ratio_conjugate, 1.0f) : 0.0f; - const LightingPushConstants push { - .inverse_view_projection = glm::inverse(frame->frame_data.jittered_view_projection), - .camera_radius = glm::vec4(frame->frame_data.camera_position, instance->voxelNormalRounding() * voxel::k_voxel_size), - .viewport = glm::vec4( - static_cast(viewport.width), height, 0.5f * height * std::abs(frame->frame_data.projection[1][1]), rotation - ), - }; + LightingPushConstants push; + if (m_denoise_active) { + push = LightingPushConstants { + .inverse_view_projection = glm::inverse(frame->frame_data.jittered_view_projection), + .camera_radius = glm::vec4(frame->frame_data.camera_position, instance->voxelNormalRounding() * voxel::k_voxel_size), + .viewport = glm::vec4( + static_cast(viewport.width), height, 0.5f * height * std::abs(frame->frame_data.projection[1][1]), rotation + ), + .lighting = glm::uvec4( + std::min(m_shadow_instance_count, instance->voxelShadowInstances()), + instance->voxelLightCandidates(), + !m_marched_lights && !m_ambient_rays ? k_marched_none : k_marched_into_terms, + instance->voxelSunReach() + ), + .reuse = glm::uvec4( + (instance->voxelReservoirHistory() ? k_reuse_history : 0u) | (m_spatial_active ? k_reuse_spatial : 0u) | + (m_scene_glass ? k_scene_glass : 0u) | (std::min(instance->voxelGlassShadowReach(), 0xFFFFu) << 16), + k_reservoir_m_cap * instance->voxelLightCandidates(), + k_reused_reservoirs, + m_ambient_rays ? static_cast(std::lround(instance->voxelAmbientReach() * 100.0f)) : 0u + ), + }; + } else { + push = LightingPushConstants { + .inverse_view_projection = glm::inverse(frame->frame_data.jittered_view_projection), + .camera_radius = glm::vec4(frame->frame_data.camera_position, instance->voxelNormalRounding() * voxel::k_voxel_size), + .viewport = glm::vec4( + static_cast(viewport.width), height, 0.5f * height * std::abs(frame->frame_data.projection[1][1]), rotation + ), + .lighting = glm::uvec4( + std::min(m_shadow_instance_count, instance->voxelShadowInstances()), + instance->voxelLightCandidates(), + !m_marched_lights && !m_ambient_rays ? k_marched_none : k_marched_into_colour, + instance->voxelSunReach() + ), + .reuse = glm::uvec4( + (instance->voxelReservoirHistory() ? k_reuse_history : 0u) | (m_spatial_active ? k_reuse_spatial : 0u) | + (m_scene_glass ? k_scene_glass : 0u) | (std::min(instance->voxelGlassShadowReach(), 0xFFFFu) << 16), + k_reservoir_m_cap * instance->voxelLightCandidates(), + k_reused_reservoirs, + m_ambient_rays ? static_cast(std::lround(instance->voxelAmbientReach() * 100.0f)) : 0u + ), + }; + } const vk::PipelineLayout layout = *m_lighting_layout.getPipelineLayout(); cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, m_lighting_pipeline.getPipeline()); cmd.bindDescriptorSets( vk::PipelineBindPoint::eGraphics, layout, k_scene_set, - std::array {m_lighting_scene_sets.get(frame_index), *m_gbuffer_sets[frame_index]}, + std::array { + m_lighting_scene_sets.get(frame_index), *m_lighting_storage_sets[frame_index], *m_gbuffer_sets[frame_index] + }, {} ); cmd.pushConstants(layout, vk::ShaderStageFlagBits::eAll, 0, sizeof(LightingPushConstants), &push); + m_lighting_push = push; + cmd.draw(3, 1, 0, 0); +} + +void VoxelPass::prepareGlass(uint32_t frame_index, const VoxelGpuStorage& storage, std::span instances) { + ZoneScoped; + m_glass_draws.clear(); + const auto* frame = VulkanRenderer::instance->renderingFrame(); + if (!m_glass_pipeline.isReady() || frame_index >= m_glass_buffers.size() || frame == nullptr) { + return; + } + + const glm::vec3 camera = frame->frame_data.camera_position; + const float half_brick = 0.5f * static_cast(voxel::k_brick_dim); + for (uint32_t i = 0; i < instances.size(); ++i) { + const VoxelInstanceGpu& instance = instances[i]; + const std::span bricks = storage.glassBricks(instance.record_index); + if (bricks.empty()) { + continue; + } + const glm::uvec3 dims = storage.recordBrickDims(instance.record_index); + for (const uint32_t brick : bricks) { + const glm::uvec3 coord(brick % dims.x, (brick / dims.x) % dims.y, brick / (dims.x * dims.y)); + const glm::vec3 local = (glm::vec3(coord) * static_cast(voxel::k_brick_dim)) + half_brick; + const glm::vec3 delta = glm::vec3(instance.voxel_to_world * glm::vec4(local, 1.0f)) - camera; + m_glass_draws.push_back({.distance_squared = glm::dot(delta, delta), .instance = i, .brick = brick}); + } + } + if (m_glass_draws.empty()) { + return; + } + + // Far to near and the nearest kept when over the limit + std::ranges::sort(m_glass_draws, [](const GlassDraw& a, const GlassDraw& b) { + return a.distance_squared > b.distance_squared; + }); + size_t first = 0; + if (m_glass_draws.size() > k_max_glass_bricks) { + first = m_glass_draws.size() - k_max_glass_bricks; + if (const auto now = std::chrono::steady_clock::now(); now - m_last_glass_warning > std::chrono::seconds(5)) { + m_last_glass_warning = now; + TOAST_WARN("Render", "VoxelPass: {} glass bricks, only the nearest {} are drawn", m_glass_draws.size(), k_max_glass_bricks); + } + } + + const auto& allocation = m_glass_buffers[frame_index].getAllocation(); + auto* mapped = static_cast(allocation.getInfo().pMappedData); + if (mapped == nullptr) { + return; + } + m_glass_draws.erase(m_glass_draws.begin(), m_glass_draws.begin() + static_cast(first)); + m_glass_count = static_cast(m_glass_draws.size()); + for (uint32_t i = 0; i < m_glass_count; ++i) { + mapped[i] = glm::uvec2(m_glass_draws[i].instance, m_glass_draws[i].brick); + } + allocation.flush(0, sizeof(glm::uvec2) * m_glass_count); +} + +void VoxelPass::recordGlass(vk::CommandBuffer cmd, uint32_t frame_index, uint32_t first, uint32_t count) { + ZoneScoped; + recordGlassBricks(cmd, frame_index, m_glass_pipeline, first, count); +} + +void VoxelPass::recordGlassSurfaces(vk::CommandBuffer cmd, uint32_t frame_index) { + ZoneScoped; + recordGlassBricks(cmd, frame_index, m_glass_surface_pipeline, 0, m_glass_count); +} + +void VoxelPass::recordGlassBricks( + vk::CommandBuffer cmd, uint32_t frame_index, const VulkanPipeline& pipeline, uint32_t first, uint32_t count +) { + if (count == 0 || first + count > m_glass_count || !pipeline.isReady() || frame_index >= m_glass_sets.size() || + !m_glass_scene_sets.get(frame_index)) { + return; + } + + const vk::PipelineLayout layout = *m_glass_layout.getPipelineLayout(); + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, pipeline.getPipeline()); + cmd.bindDescriptorSets( + vk::PipelineBindPoint::eGraphics, + layout, + k_scene_set, + std::array {m_glass_scene_sets.get(frame_index), *m_glass_sets[frame_index]}, + {} + ); + // SV_InstanceID leaves out firstInstance so the run start is pushed + const auto* renderer = VulkanRenderer::instance; + const GlassPush push { + .first = first, + .refraction = m_backdrop_captured && &pipeline == &m_glass_pipeline ? renderer->voxelGlassRefraction() : 0.0f, + .refraction_distance = renderer->voxelGlassRefractionDistance(), + .refraction_voxels = renderer->voxelGlassRefractionVoxels(), + .lit_surfaces = renderer->voxelGlassLitSurfaces(), + }; + cmd.pushConstants(layout, vk::ShaderStageFlagBits::eAll, 0, sizeof(GlassPush), &push); + cmd.draw(36, count, 0, 0); +} + +auto VoxelPass::refractsGlass() const -> bool { + const auto* renderer = VulkanRenderer::instance; + if (m_glass_count == 0 || !m_glass_dual_source || renderer->voxelGlassRefraction() <= 0.0f || + !std::ranges::all_of(m_bound_backdrop, [](const auto& views) { return views[0] && views[1]; })) { + return false; + } + // Draws are far to near and a brick centre sits up to a diagonal past its voxels + constexpr float k_brick_reach = 2.0f; + const float reach = renderer->voxelGlassRefractionDistance() + k_brick_reach; + return m_glass_draws.back().distance_squared <= reach * reach; +} + +void VoxelPass::writeFreshRegions(uint32_t frame_index, const VoxelGpuStorage& storage) { + if (frame_index >= m_fresh_regions.size()) { + return; + } + auto* mapped = static_cast(m_fresh_regions[frame_index].getAllocation().getInfo().pMappedData); + if (mapped == nullptr) { + return; + } + + // Only the first frame of a new storage marks its changes + uint32_t count = 0; + const uint64_t generation = storage.generation(); + if (generation != m_region_generation) { + if (m_region_generation != 0) { + (void)storage.changeHistory().forEachSince(m_region_generation, [&](const VoxelRegion& region) { + const std::optional record = storage.recordIndexOf(region.node_rid); + if (!record.has_value() || count >= k_max_fresh_regions) { + return; + } + mapped[1 + (count * 2)] = glm::vec4(region.min, static_cast(*record)); + mapped[2 + (count * 2)] = glm::vec4(region.max, 0.0f); + ++count; + }); + } + m_region_generation = generation; + } + mapped[0] = glm::vec4(static_cast(count), 0.0f, 0.0f, 0.0f); + m_fresh_regions[frame_index].getAllocation().flush(0, sizeof(glm::vec4) * (1 + (2 * count))); +} + +void VoxelPass::createDenoise(const VulkanCore& core, vk::Format scene_format, vk::Extent2D extent) { + ZoneScoped; + const auto uid = assets::resolveURI("core://shaders/voxel_denoise.slang"); + const auto shader = uid.has_value() ? ShaderCache::get().acquire(*uid) : nullptr; + if (!shader) { + TOAST_WARN( + "Render", "VoxelPass shader core://shaders/voxel_denoise.slang unavailable; marched light is only smoothed by TAA" + ); + return; + } + if (core.getPhysicalDevice().getProperties().limits.maxPushConstantsSize < sizeof(DenoisePush)) { + TOAST_WARN( + "Render", "VoxelDenoise needs {} bytes of push constants; marched light is only smoothed by TAA", sizeof(DenoisePush) + ); + return; + } + + m_denoise_layout.rebuild(core, shader->reflection, "VoxelDenoise"); + const auto& layouts = m_denoise_layout.getDescriptorSetLayouts(); + if (layouts.empty()) { + TOAST_WARN("Render", "VoxelDenoise shader layout has no descriptor sets; marched light is only smoothed by TAA"); + return; + } + + VulkanPipeline::Config config; + config.pipeline_type = VulkanPipeline::PipelineType::graphics; + config.debug_name = "VoxelDenoise Temporal"; + config.color_format = k_denoise_history_format; + config.write_color_alpha = true; + config.extent = extent; + config.shader_spirv = shader->spirv; + config.fragment_entry = "temporalMain"; + config.pipeline_layout = *m_denoise_layout.getPipelineLayout(); + config.vertex_bindings = {}; + config.vertex_attributes = {}; + config.cull_mode = vk::CullModeFlagBits::eNone; + config.depth_test = false; + config.depth_write = false; + m_denoise_temporal_pipeline.rebuild(core, config); + + config.debug_name = "VoxelDenoise Composite"; + config.color_format = scene_format; + config.write_color_alpha = false; + config.fragment_entry = "compositeMain"; + config.blend_preset = VulkanPipeline::BlendPreset::additive; + m_denoise_composite_pipeline.rebuild(core, config); + + m_linear_sampler = vk::raii::Sampler(core.getDevice(), linearClampSamplerInfo()); + setDebugName(core, *m_linear_sampler, "VoxelDenoise LinearSampler"); + + const vk::DescriptorPool pool = VulkanRenderer::instance->getDescriptorPoolHandle(); + const vk::DescriptorSetLayout set_layout = *layouts[0]; + m_denoise_sets.clear(); + for (uint32_t i = 0; i < VulkanRenderer::k_frames_in_flight * 2; ++i) { + auto allocated = core.getDevice().allocateDescriptorSets(vk::DescriptorSetAllocateInfo(pool, 1, &set_layout)); + m_denoise_sets.push_back(std::move(allocated[0])); + setDebugName(core, *m_denoise_sets.back(), std::format("VoxelDenoise Set[{}]", i)); + } + createDenoiseTargets(extent); +} + +void VoxelPass::createDenoiseTargets(vk::Extent2D extent) { + for (uint32_t term = 0; term < m_denoise_history.size(); ++term) { + for (uint32_t parity = 0; parity < 2; ++parity) { + m_denoise_history[term][parity].create( + *m_core, extent, k_denoise_history_format, std::format("VoxelDenoise {}{}", term == 0 ? "Diffuse" : "Specular", parity) + ); + } + } + m_denoise_bound.assign(m_denoise_sets.size(), {}); + m_denoise_valid = false; +} + +void VoxelPass::bindDenoise(vk::CommandBuffer cmd, uint32_t frame_index, const VulkanPipeline& pipeline, float term) { + const auto* renderer = VulkanRenderer::instance; + const auto* frame = renderer->renderingFrame(); + const uint32_t set_index = (frame_index * 2) + m_denoise_parity; + const vk::DescriptorSet set = *m_denoise_sets[set_index]; + const uint32_t read = 1 - m_denoise_parity; + + const std::array views { + renderer->getVoxelNoisyDiffuseView(), + renderer->getVoxelNoisySpecularView(), + renderer->getSceneMotionView(), + renderer->getDepthView(), + renderer->getVoxelAlbedoView(), + renderer->getVoxelFaceNormalView(), + m_denoise_history[0][read].view(), + m_denoise_history[1][read].view(), + m_denoise_history[0][m_denoise_parity].view(), + m_denoise_history[1][m_denoise_parity].view(), + }; + + // Safe since the fence of this slot was waited on + if (m_denoise_bound[set_index] != views) { + DescriptorWriter writer; + for (uint32_t binding = 0; binding < views.size(); ++binding) { + const bool history = binding == 6 || binding == 7; + writer.image( + set, + binding, + vk::DescriptorType::eCombinedImageSampler, + history ? *m_linear_sampler : *m_point_sampler, + views[binding], + binding == 3 ? vk::ImageLayout::eDepthReadOnlyOptimal : vk::ImageLayout::eShaderReadOnlyOptimal + ); + } + writer.flush(m_core->getDevice()); + m_denoise_bound[set_index] = views; + } + + const vk::Extent2D extent = m_denoise_history[0][0].extent(); + const float rotation = std::fmod(static_cast(frame->sequence % 4096) * k_golden_ratio_conjugate, 1.0f); + const DenoisePush push { + .inverse_view_projection = glm::inverse(frame->frame_data.jittered_view_projection), + .previous_view_projection = frame->frame_data.previous_view_projection, + .params = glm::vec4(term, m_denoise_frame_valid ? 1.0f : 0.0f, rotation, k_denoise_radius), + .viewport = glm::vec4(static_cast(extent.width), static_cast(extent.height), 0.0f, 0.0f), + }; + + const vk::PipelineLayout layout = *m_denoise_layout.getPipelineLayout(); + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, pipeline.getPipeline()); + cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, layout, 0, std::array {set}, {}); + cmd.pushConstants(layout, vk::ShaderStageFlagBits::eAll, 0, sizeof(DenoisePush), &push); +} + +void VoxelPass::recordDenoiseTemporal(vk::CommandBuffer cmd, uint32_t frame_index) { + ZoneScoped; + const auto* frame = VulkanRenderer::instance->renderingFrame(); + if (!m_denoise_active || frame == nullptr) { + return; + } + + // A skipped frame or a recreated target leaves a history from somewhere else + m_denoise_frame_valid = m_denoise_valid && frame->sequence == m_denoise_sequence + 1; + const uint32_t read = 1 - m_denoise_parity; + if (!m_denoise_frame_valid) { + m_denoise_history[0][read].makeSampleable(cmd); + m_denoise_history[1][read].makeSampleable(cmd); + } + + for (uint32_t term = 0; term < 2; ++term) { + const PostProcessTarget& target = m_denoise_history[term][m_denoise_parity]; + target.beginScope(cmd); + bindDenoise(cmd, frame_index, m_denoise_temporal_pipeline, static_cast(term)); + cmd.draw(3, 1, 0, 0); + target.endScope(cmd); + } +} + +void VoxelPass::recordDenoiseComposite(vk::CommandBuffer cmd, uint32_t frame_index) { + ZoneScoped; + const auto* frame = VulkanRenderer::instance->renderingFrame(); + if (!m_denoise_active || frame == nullptr) { + return; + } + bindDenoise(cmd, frame_index, m_denoise_composite_pipeline, 0.0f); + cmd.draw(3, 1, 0, 0); + + m_denoise_parity = 1 - m_denoise_parity; + m_denoise_valid = true; + m_denoise_sequence = frame->sequence; +} + +void VoxelPass::recordSpatial(vk::CommandBuffer cmd, uint32_t frame_index, vk::Extent2D viewport) { + ZoneScoped; + (void)viewport; + if (!m_spatial_active || frame_index >= m_gbuffer_sets.size() || frame_index >= m_lighting_storage_sets.size() || + !m_lighting_scene_sets.get(frame_index)) { + return; + } + const vk::PipelineLayout layout = *m_lighting_layout.getPipelineLayout(); + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, m_spatial_pipeline.getPipeline()); + cmd.bindDescriptorSets( + vk::PipelineBindPoint::eGraphics, + layout, + k_scene_set, + std::array { + m_lighting_scene_sets.get(frame_index), *m_lighting_storage_sets[frame_index], *m_gbuffer_sets[frame_index] + }, + {} + ); + cmd.pushConstants(layout, vk::ShaderStageFlagBits::eAll, 0, sizeof(LightingPushConstants), &m_lighting_push); cmd.draw(3, 1, 0, 0); } void VoxelPass::onTargetsRecreated() { - std::ranges::fill(m_bound_gbuffer, std::array {}); + std::ranges::fill(m_bound_gbuffer, std::array {}); + if (!m_denoise_sets.empty()) { + createDenoiseTargets(VulkanRenderer::instance->getOutputTarget().getExtent()); + } if (!m_cell_sets.empty()) { const auto* renderer = VulkanRenderer::instance; diff --git a/engine/src/toast/renderer/passes/voxel_pass.hpp b/engine/src/toast/renderer/passes/voxel_pass.hpp index ee0d6784b..cf9dd271c 100644 --- a/engine/src/toast/renderer/passes/voxel_pass.hpp +++ b/engine/src/toast/renderer/passes/voxel_pass.hpp @@ -7,6 +7,7 @@ #pragma once #include "../hiz_pyramid.hpp" +#include "../post_process_target.hpp" #include "../render_pass_base.hpp" #include "../scene_descriptor_set.hpp" #include "../shader_layout.hpp" @@ -17,12 +18,15 @@ #include #include #include +#include #include +#include #include namespace renderer { class VulkanCore; class VoxelGpuStorage; +struct VoxelInstanceGpu; /// Deferred so lighting runs once per visible voxel pixel not once per march class VoxelPass : public IRenderPass { @@ -68,6 +72,51 @@ class VoxelPass : public IRenderPass { /// Writes scene colour indirect and rounded normal while reading the G-buffer and depth void recordLighting(vk::CommandBuffer cmd, uint32_t frame_index, vk::Extent2D viewport); + struct GlassDraw { + float distance_squared = 0.0f; + uint32_t instance = 0; + uint32_t brick = 0; + }; + + /// Far to near in the order recordGlass() indexes them and empty when no glass draws this frame + [[nodiscard]] + auto glassDraws() const -> std::span { + return m_glass_count > 0 ? std::span(m_glass_draws) : std::span {}; + } + + /// Inside the world scope after the sky + void recordGlass(vk::CommandBuffer cmd, uint32_t frame_index, uint32_t first, uint32_t count); + + /// After every blended draw + void recordGlassSurfaces(vk::CommandBuffer cmd, uint32_t frame_index); + + /// Whether the backdrop must be copied before glass draws + [[nodiscard]] + auto refractsGlass() const -> bool; + + void setBackdropCaptured(bool captured) noexcept { m_backdrop_captured = captured; } + + /// After prepare() whether the lighting scope writes denoised terms + [[nodiscard]] + auto denoiseActive() const -> bool { + return m_denoise_active; + } + + /// After prepare() whether recordSpatial() follows the lighting scope + [[nodiscard]] + auto spatialActive() const -> bool { + return m_spatial_active; + } + + /// Inside a scope over the noisy terms and this frame's reservoirs with the candidates and scene normal sampleable + void recordSpatial(vk::CommandBuffer cmd, uint32_t frame_index, vk::Extent2D viewport); + + /// Outside any scope with the noisy terms motion albedo face normal and depth sampleable + void recordDenoiseTemporal(vk::CommandBuffer cmd, uint32_t frame_index); + + /// Inside a scope over the scene colour alone + void recordDenoiseComposite(vk::CommandBuffer cmd, uint32_t frame_index); + /// After the G-buffer and depth targets are recreated since a new view can reuse a destroyed handle value void onTargetsRecreated(); @@ -78,6 +127,8 @@ class VoxelPass : public IRenderPass { static constexpr uint32_t k_max_instances = 4096; + static constexpr uint32_t k_max_glass_bricks = 16384; + private: /// Throttles the over limit warning std::chrono::steady_clock::time_point m_last_limit_warning; @@ -163,6 +214,10 @@ class VoxelPass : public IRenderPass { glm::mat4 inverse_view_projection {1.0f}; glm::vec4 camera_radius {0.0f}; glm::vec4 viewport {0.0f}; + /// x instances with bounds y light sampling candidates z marched light routing w sun reach in meters + glm::uvec4 lighting {0u}; + /// x 1 with last frame's reservoirs y M cap z reservoirs reused + glm::uvec4 reuse {0u}; }; void createCells(const VulkanCore& core); @@ -202,6 +257,12 @@ class VoxelPass : public IRenderPass { void createInstanceBuffers(const VulkanCore& core); void createDescriptors(const VulkanCore& core, const ShaderReflection& reflection); void createLighting(const VulkanCore& core, vk::Format scene_format, vk::Extent2D extent); + void createGlass(const VulkanCore& core, vk::Format scene_format, vk::Format depth_format, vk::Extent2D extent); + void recordGlassBricks( + vk::CommandBuffer cmd, uint32_t frame_index, const VulkanPipeline& pipeline, uint32_t first, uint32_t count + ); + /// Sorts the glass bricks of the instances written this frame far to near into the glass buffer + void prepareGlass(uint32_t frame_index, const VoxelGpuStorage& storage, std::span instances); void bindStorage(uint32_t frame_index, const std::shared_ptr& storage); @@ -264,8 +325,101 @@ class VoxelPass : public IRenderPass { vk::raii::Sampler m_point_sampler = nullptr; std::vector m_gbuffer_sets; + /// voxel_dda.slang storage plus instance bounds so shadow rays march + std::vector m_lighting_storage_sets; + /// World bounding sphere per instance in the instance buffer order + std::vector m_instance_bounds; + /// Instances an ambient ray can reach per hashed world cell + std::vector m_ambient_grids; + /// Shadow casters with glass bricks so the glass shadow filter skips every other volume + std::vector m_glass_casters; + std::vector> m_grid_pairs; + std::vector m_grid_global; + /// Drawn instances then the off screen ones only shadow rays test + uint32_t m_shadow_instance_count = 0; + /// Albedo face normal material and depth last written into each set - std::vector> m_bound_gbuffer; + std::vector> m_bound_gbuffer; + VulkanPipeline m_spatial_pipeline; + bool m_spatial_active = false; + bool m_scene_glass = false; + bool m_backdrop_captured = false; + bool m_glass_dual_source = false; + vk::raii::Sampler m_backdrop_sampler = nullptr; + vk::raii::Sampler m_backdrop_depth_sampler = nullptr; + /// Backdrop colour and depth each glass set was written with + std::vector> m_bound_backdrop; + /// The push the lighting draw used so the spatial draw matches it + LightingPushConstants m_lighting_push; + + /// voxel.slang gFreshRegions per frame in flight + std::vector m_fresh_regions; + /// Storage generation whose change regions were last marked reactive + uint64_t m_region_generation = 0; + + /// Mirrors voxel_denoise.slang DenoisePush + struct DenoisePush { + glm::mat4 inverse_view_projection {1.0f}; + glm::mat4 previous_view_projection {1.0f}; + /// x term y 1 with a valid history z spiral rotation w spiral radius in pixels + glm::vec4 params {0.0f}; + glm::vec4 viewport {0.0f}; + }; + + /// rgb filtered term a linear depth it was written at + static constexpr vk::Format k_denoise_history_format = vk::Format::eR16G16B16A16Sfloat; + /// Bindings of voxel_denoise.slang in order + static constexpr size_t k_denoise_bindings = 10; + + /// Change regions of a newly published storage that voxel.slang marks reactive + void writeFreshRegions(uint32_t frame_index, const VoxelGpuStorage& storage); + void writeAmbientGrid(uint32_t frame_index, std::span bounds); + void writeGlassCasters(uint32_t frame_index, const VoxelGpuStorage& storage, std::span casters); + void createDenoise(const VulkanCore& core, vk::Format scene_format, vk::Extent2D extent); + void createDenoiseTargets(vk::Extent2D extent); + void bindDenoise(vk::CommandBuffer cmd, uint32_t frame_index, const VulkanPipeline& pipeline, float term); + + ShaderLayout m_denoise_layout; + VulkanPipeline m_denoise_temporal_pipeline; + VulkanPipeline m_denoise_composite_pipeline; + vk::raii::Sampler m_linear_sampler = nullptr; + /// Per frame in flight then per history parity + std::vector m_denoise_sets; + std::vector> m_denoise_bound; + /// [term][parity] where the temporal pass writes the parity and reads the other + std::array, 2> m_denoise_history; + uint32_t m_denoise_parity = 0; + /// Histories hold the previous frame + bool m_denoise_valid = false; + bool m_denoise_frame_valid = false; + uint64_t m_denoise_sequence = 0; + /// Some light is marched or sampled this frame + bool m_marched_lights = false; + bool m_ambient_rays = false; + bool m_denoise_active = false; + + /// Mirrors voxel_glass.slang GlassPush + /// Mirrors voxel_glass.slang GlassPush + struct GlassPush { + uint32_t first = 0; + /// 0 leaves the backdrop unread + float refraction = 0.0f; + float refraction_distance = 0.0f; + uint32_t refraction_voxels = 0; + uint32_t lit_surfaces = 0; + }; + + ShaderLayout m_glass_layout; + VulkanPipeline m_glass_pipeline; + VulkanPipeline m_glass_surface_pipeline; + SceneDescriptorSets m_glass_scene_sets; + std::vector m_glass_sets; + /// Instance index and brick grid index per drawn glass brick + std::vector m_glass_buffers; + std::vector m_glass_draws; + uint32_t m_glass_count = 0; + /// Throttles the over limit warning + std::chrono::steady_clock::time_point m_last_glass_warning; }; } diff --git a/engine/src/toast/renderer/post_process_target.cpp b/engine/src/toast/renderer/post_process_target.cpp index b48b66d67..aebce1237 100644 --- a/engine/src/toast/renderer/post_process_target.cpp +++ b/engine/src/toast/renderer/post_process_target.cpp @@ -78,4 +78,10 @@ void PostProcessTarget::endScope(vk::CommandBuffer cmd) const { sync::transition(cmd, m_state, sync::Usage::shader_sampled); } +void PostProcessTarget::makeSampleable(vk::CommandBuffer cmd) const { + if (isReady()) { + sync::transition(cmd, m_state, sync::Usage::shader_sampled); + } +} + } diff --git a/engine/src/toast/renderer/post_process_target.hpp b/engine/src/toast/renderer/post_process_target.hpp index 21e723304..1de3cacac 100644 --- a/engine/src/toast/renderer/post_process_target.hpp +++ b/engine/src/toast/renderer/post_process_target.hpp @@ -32,6 +32,9 @@ class PostProcessTarget { void endScope(vk::CommandBuffer cmd) const; + /// Makes a never drawn target sampleable for descriptors + void makeSampleable(vk::CommandBuffer cmd) const; + [[nodiscard]] auto view() const -> vk::ImageView { return m_view.has_value() ? **m_view : vk::ImageView {}; diff --git a/engine/src/toast/renderer/renderer_settings.cpp b/engine/src/toast/renderer/renderer_settings.cpp index 011dec0be..91b286417 100644 --- a/engine/src/toast/renderer/renderer_settings.cpp +++ b/engine/src/toast/renderer/renderer_settings.cpp @@ -157,9 +157,291 @@ void registerShadows() { .step = 1.0} ) .onChange([](int64_t frames) { shadow_slots::setGraceFrames(static_cast(std::max(frames, 0))); }); + + toast::settings::declareInt( + "renderer.shadows.spot_slots", + shadows::k_default_spot_shadows, + {.label = "Spot shadow slots", + .category = "Shadows", + .description = "Spot lights that can hold a shadow. Each slot is one punctual layer in every copy of the map, " + "about 3 MB at 512", + .min = 0.0, + .max = static_cast(shadows::k_max_spot_shadows), + .step = 1.0, + .requires_restart = true} + ) + .onChange([](int64_t slots) { shadows::setSpotShadowSlots(static_cast(std::max(slots, 0))); }); + + toast::settings::declareInt( + "renderer.shadows.point_slots", + shadows::k_default_point_shadows, + {.label = "Point shadow slots", + .category = "Shadows", + .description = "Point lights that can hold a shadow. Each slot is six punctual layers in every copy of the map, " + "about 18 MB at 512", + .min = 0.0, + .max = static_cast(shadows::k_max_point_shadows), + .step = 1.0, + .requires_restart = true} + ) + .onChange([](int64_t slots) { shadows::setPointShadowSlots(static_cast(std::max(slots, 0))); }); + + toast::settings::declareInt( + "renderer.shadows.spot_budget", + shadows::k_max_spot_shadows, + {.label = "Spot lights casting", + .category = "Shadows", + .description = "Of the spot shadow slots how many may be used at once, nearest lights first. Lowers live " + "without a restart to compare costs", + .min = 0.0, + .max = static_cast(shadows::k_max_spot_shadows), + .step = 1.0} + ) + .onChange([](int64_t lights) { shadows::setSpotShadowBudget(static_cast(std::max(lights, 0))); }); + + toast::settings::declareInt( + "renderer.shadows.point_budget", + shadows::k_max_point_shadows, + {.label = "Point lights casting", + .category = "Shadows", + .description = "Of the point shadow slots how many may be used at once, nearest lights first. Each costs six " + "shadow faces whenever its casters change", + .min = 0.0, + .max = static_cast(shadows::k_max_point_shadows), + .step = 1.0} + ) + .onChange([](int64_t lights) { shadows::setPointShadowBudget(static_cast(std::max(lights, 0))); }); + + toast::settings::declareFloat( + "renderer.shadows.full_resolution_ranges", + shadows::k_punctual_full_resolution_ranges, + {.label = "Full resolution reach", + .category = "Shadows", + .description = "Camera distance in light ranges within which a spot or point shadow keeps its full " + "resolution. Past it each doubling of the distance halves the shadow map", + .min = 0.5, + .max = 16.0, + .step = 0.5} + ) + .onChange([](double ranges) { shadows::setPunctualFullResolutionRanges(static_cast(ranges)); }); + + toast::settings::declareFloat( + "renderer.shadows.cascade_split_lambda", + shadows::k_cascade_split_lambda, + {.label = "Cascade split", + .category = "Shadows", + .description = "0 spaces the cascades evenly and 1 logarithmically. Higher keeps more resolution near the " + "camera", + .min = 0.0, + .max = 1.0, + .step = 0.05} + ) + .onChange([](double lambda) { shadows::setCascadeSplitLambda(static_cast(lambda)); }); + + toast::settings::declareFloat( + "renderer.shadows.cascade_padding", + shadows::k_cascade_padding, + {.label = "Cascade padding", + .category = "Shadows", + .description = "Extra share of its slice each cascade covers so it keeps its cached shadow map while the " + "camera moves inside it. Higher re-renders less and blurs a little more", + .min = 0.0, + .max = 0.5, + .step = 0.01} + ) + .onChange([](double padding) { shadows::setCascadePadding(static_cast(padding)); }); + + toast::settings::declareFloat( + "renderer.shadows.cascade_receiver_depth", + shadows::k_cascade_receiver_depth_radii, + {.label = "Cascade receiver depth", + .category = "Shadows", + .description = "How far along the light each cascade reaches in cascade radii. Lower draws fewer casters " + "and loses shadows on surfaces far down the light", + .min = 1.0, + .max = 16.0, + .step = 0.5} + ) + .onChange([](double radii) { shadows::setCascadeReceiverDepthRadii(static_cast(radii)); }); + + toast::settings::declareBool( + "renderer.shadows.static_cache", + true, + {.label = "Static caster cache", + .category = "Shadows", + .description = "Resting voxel volumes render into a cached map that each shadow copies under the moving " + "casters. Off draws every voxel caster whenever anything in that shadow changes"} + ) + .onChange([](bool enabled) { shadows::setStaticCasterCache(enabled); }); + + toast::settings::declareFloat( + "renderer.shadows.caster_tolerance", + shadows::k_caster_tolerance, + {.label = "Caster tolerance", + .category = "Shadows", + .description = "Meters a voxel volume may drift before its shadow follows. Higher keeps jittering bodies " + "in the static cache, lower makes slow shadows step less", + .min = 0.0, + .max = 0.1, + .step = 0.005} + ) + .onChange([](double meters) { shadows::setCasterTolerance(static_cast(meters)); }); + + toast::settings::declareBool( + "renderer.shadows.voxel_mesh_shaders", + true, + {.label = "Mesh shader voxel casters", + .category = "Shadows", + .description = "Voxel casters rasterize task and mesh shader quads on GPUs that have them. Off marches " + "every voxel caster per shadow texel"} + ) + .onChange([](bool enabled) { shadows::setVoxelMeshShadows(enabled); }); } void registerVoxels(VulkanRenderer& renderer) { + toast::settings::declareInt( + "renderer.voxels.light_candidates", + 8, + {.label = "Light candidates", + .category = "Voxels", + .description = "Lights in Sampled voxel shadow mode share one marched ray per pixel chosen among this many " + "candidates from the froxel. More is less noise and more shading work", + .min = 1.0, + .max = 32.0} + ) + .onChange([&renderer](int64_t v) { renderer.setVoxelLightCandidates(static_cast(std::max(v, 1))); }); + + toast::settings::declareBool( + "renderer.voxels.ambient_rays", + true, + {.label = "Ambient rays", + .category = "Voxels", + .description = "Two super-sparse rays per voxel pixel occlude ambient light by how far they get, in place of " + "SSAO. Off keeps SSAO on voxels"} + ) + .onChange([&renderer](bool enabled) { renderer.setVoxelAmbientRays(enabled); }); + + toast::settings::declareFloat( + "renderer.voxels.ambient_reach", + 2.4, + {.label = "Ambient reach", + .category = "Voxels", + .description = "Meters an ambient ray travels before it counts as open sky. Teardown clamps at 24 voxels", + .min = 0.2, + .max = 20.0, + .step = 0.1} + ) + .onChange([&renderer](double v) { renderer.setVoxelAmbientReach(static_cast(v)); }); + + toast::settings::declareBool( + "renderer.voxels.glass_refraction_enabled", + true, + {.label = "Glass refraction", + .category = "Voxels", + .description = "Glass bends what is behind it by its IOR. Costs a copy of the scene colour and depth plus a " + "march through the volume for every glass pixel. Off draws glass straight through"} + ) + .onChange([&renderer](bool enabled) { renderer.setVoxelGlassRefractionEnabled(enabled); }); + + toast::settings::declareFloat( + "renderer.voxels.glass_refraction", + 1.0, + {.label = "Glass refraction strength", + .category = "Voxels", + .description = "How far glass bends what is behind it. 1 follows the IOR and higher exaggerates it", + .min = 0.0, + .max = 4.0, + .step = 0.05} + ) + .onChange([&renderer](double v) { renderer.setVoxelGlassRefraction(static_cast(v)); }); + + toast::settings::declareFloat( + "renderer.voxels.glass_refraction_distance", + 60.0, + {.label = "Glass refraction distance", + .category = "Voxels", + .description = "Meters from the camera past which glass draws straight through. Far glass bends too little to " + "see and the scene copy is skipped when every glass brick is past it", + .min = 1.0, + .max = 500.0, + .step = 1.0} + ) + .onChange([&renderer](double v) { renderer.setVoxelGlassRefractionDistance(static_cast(v)); }); + + toast::settings::declareInt( + "renderer.voxels.glass_refraction_depth", + 32, + {.label = "Glass refraction depth", + .category = "Voxels", + .description = "Voxels refraction follows through thick glass to find its far side. Past it the far side " + "counts as parallel to the near one", + .min = 1.0, + .max = 96.0} + ) + .onChange([&renderer](int64_t v) { + renderer.setVoxelGlassRefractionVoxels(static_cast(std::clamp(v, 1, 96))); + }); + + toast::settings::declareInt( + "renderer.voxels.glass_lit_surfaces", + 4, + {.label = "Glass lit surfaces", + .category = "Voxels", + .description = "Glass surfaces per brick and pixel that get full lighting and reflections. Layers past it " + "only tint what is behind them", + .min = 1.0, + .max = 4.0} + ) + .onChange([&renderer](int64_t v) { + renderer.setVoxelGlassLitSurfaces(static_cast(std::clamp(v, 1, 4))); + }); + + toast::settings::declareBool( + "renderer.voxels.glass_shadows", + true, + {.label = "Glass shadows", + .category = "Voxels", + .description = "Glass tints the light that reaches voxels behind it in every light mode. Off makes glass cast " + "no shadow and lets marched shadow rays take coarse steps through it"} + ) + .onChange([&renderer](bool enabled) { renderer.setVoxelGlassShadows(enabled); }); + + toast::settings::declareInt( + "renderer.voxels.glass_shadow_reach", + 50, + {.label = "Glass shadow reach", + .category = "Voxels", + .description = "Meters a sun looks for glass between a voxel and the sky when its shadow comes from the " + "shadow map", + .min = 1.0, + .max = 2000.0} + ) + .onChange([&renderer](int64_t v) { renderer.setVoxelGlassShadowReach(static_cast(std::max(v, 1))); }); + + toast::settings::declareInt( + "renderer.voxels.sun_reach", + 200, + {.label = "Sun ray reach", + .category = "Voxels", + .description = "Meters a marched sun shadow ray travels through voxels. Longer finds distant occluders and " + "costs more on open ground", + .min = 10.0, + .max = 2000.0} + ) + .onChange([&renderer](int64_t v) { renderer.setVoxelSunReach(static_cast(std::max(v, 1))); }); + + toast::settings::declareInt( + "renderer.voxels.shadow_instances", + 256, + {.label = "Shadow ray instances", + .category = "Voxels", + .description = "Nearest drawn voxel volumes a marched shadow ray tests against. Farther volumes cast no " + "marched shadow", + .min = 1.0, + .max = 4096.0} + ) + .onChange([&renderer](int64_t v) { renderer.setVoxelShadowInstances(static_cast(std::max(v, 1))); }); + toast::settings::declareFloat( "renderer.voxels.edge_rounding", 0.3, @@ -212,6 +494,33 @@ void registerVoxels(VulkanRenderer& renderer) { .step = 0.5} ) .onChange([&renderer](double pixels) { renderer.setVoxelRasterPixels(static_cast(pixels)); }); + + toast::settings::declareFloat( + "renderer.voxels.occlusion_min_hidden", + 0.05, + {.label = "Occlusion idle below", + .category = "Voxels", + .description = "Share of cells occlusion culling must hide to keep running. Below it for a few frames it " + "pauses since it costs more than it saves. 0 never pauses it", + .min = 0.0, + .max = 0.5, + .step = 0.01} + ) + .onChange([&renderer](double share) { renderer.setVoxelOcclusionMinHidden(static_cast(share)); }); + + toast::settings::declareInt( + "renderer.voxels.occlusion_idle_frames", + 120, + {.label = "Occlusion idle frames", + .category = "Voxels", + .description = "Frames occlusion culling stays paused before it measures again", + .min = 0.0, + .max = 600.0, + .step = 1.0} + ) + .onChange([&renderer](int64_t frames) { + renderer.setVoxelOcclusionIdleFrames(static_cast(std::max(frames, 0))); + }); } void registerEnvironment(VulkanRenderer& renderer) { @@ -232,6 +541,18 @@ void registerEnvironment(VulkanRenderer& renderer) { }); } +void registerDebug(VulkanRenderer& renderer) { + toast::settings::declareBool( + k_debug_overlay_in_game, + false, + {.label = "Debug overlay in game", + .category = "Debug", + .description = "Draws the ImGui debug windows while a game camera is live and frees the cursor to use them. " + "Off hides them in game and brings them back as they were in the editor. F3 in game flips it."} + ) + .onChange([&renderer](bool visible) { renderer.setDebugOverlayInGame(visible); }); +} + } void registerRendererStartupSettings() { @@ -243,6 +564,7 @@ void registerRendererSettings(VulkanRenderer& renderer) { registerQuality(renderer); registerVoxels(renderer); registerEnvironment(renderer); + registerDebug(renderer); } } diff --git a/engine/src/toast/renderer/renderer_settings.hpp b/engine/src/toast/renderer/renderer_settings.hpp index 8f9022ebb..61fcce52e 100644 --- a/engine/src/toast/renderer/renderer_settings.hpp +++ b/engine/src/toast/renderer/renderer_settings.hpp @@ -5,12 +5,16 @@ */ #pragma once +#include #include namespace renderer { class VulkanRenderer; +/// F3 in game view flips it +inline constexpr std::string_view k_debug_overlay_in_game = "renderer.debug.overlay_in_game"; + /// @note Call after Settings::load() and before the passes exist TOAST_API void registerRendererStartupSettings(); diff --git a/engine/src/toast/renderer/scene_descriptor_set.cpp b/engine/src/toast/renderer/scene_descriptor_set.cpp index 3c365b6ab..8de5abc1b 100644 --- a/engine/src/toast/renderer/scene_descriptor_set.cpp +++ b/engine/src/toast/renderer/scene_descriptor_set.cpp @@ -101,6 +101,23 @@ void SceneDescriptorSets::create( continue; } + if (binding.name == "gPunctualShadowDepths") { + if (!*m_shadow_depth_sampler) { + m_shadow_depth_sampler = vk::raii::Sampler(device, nearestClampSamplerInfo()); + } + const auto* shadow_pass = VulkanRenderer::instance->getShadowPass(); + const vk::ImageView view = shadow_pass != nullptr ? shadow_pass->getPunctualMapView(i) : vk::ImageView {}; + writer.image( + *m_sets[i], + binding.binding, + vk::DescriptorType::eCombinedImageSampler, + *m_shadow_depth_sampler, + view ? view : VulkanRenderer::instance->getDefaultShadowMapView(), + vk::ImageLayout::eShaderReadOnlyOptimal + ); + continue; + } + const bool is_cascade_map = binding.name == "gShadowMap"; if (is_cascade_map || binding.name == "gPunctualShadowMaps") { const auto* shadow_pass = VulkanRenderer::instance->getShadowPass(); @@ -154,6 +171,9 @@ void SceneDescriptorSets::create( buffer = VulkanRenderer::instance->getInstanceBuffer(i); size = sizeof(VulkanRenderer::InstanceData) * VulkanRenderer::k_max_instances; descriptor_type = vk::DescriptorType::eStorageBuffer; + } else if (binding.name == "gBlueNoise") { + buffer = VulkanRenderer::instance->getBlueNoiseBuffer(); + descriptor_type = vk::DescriptorType::eStorageBuffer; } else if (binding.name == "gIrradianceSH") { if (auto* probes = VulkanRenderer::instance->getReflectionProbePassMutable(); probes != nullptr) { buffer = probes->getShBuffer(); diff --git a/engine/src/toast/renderer/scene_descriptor_set.hpp b/engine/src/toast/renderer/scene_descriptor_set.hpp index 231db1c89..f397f5c6f 100644 --- a/engine/src/toast/renderer/scene_descriptor_set.hpp +++ b/engine/src/toast/renderer/scene_descriptor_set.hpp @@ -42,6 +42,9 @@ class SceneDescriptorSets { std::vector m_sets; + /// Reads gPunctualShadowDepths without the comparison the shadow sampler does + vk::raii::Sampler m_shadow_depth_sampler = nullptr; + std::optional m_scene_binding; /// Handle plus generation since a recreated structure can reuse a destroyed handle value diff --git a/engine/src/toast/renderer/shadow_constants.cpp b/engine/src/toast/renderer/shadow_constants.cpp index 1f1f09550..29f508832 100644 --- a/engine/src/toast/renderer/shadow_constants.cpp +++ b/engine/src/toast/renderer/shadow_constants.cpp @@ -1,5 +1,6 @@ #include "shadow_constants.hpp" +#include #include namespace renderer::shadows { @@ -8,6 +9,17 @@ namespace { std::atomic g_cascade_resolution {k_cascade_resolution}; std::atomic g_punctual_resolution {k_punctual_resolution}; std::atomic g_shadow_distance {k_shadow_distance}; +std::atomic g_spot_slots {k_default_spot_shadows}; +std::atomic g_point_slots {k_default_point_shadows}; +std::atomic g_spot_budget {k_max_spot_shadows}; +std::atomic g_point_budget {k_max_point_shadows}; +std::atomic g_full_resolution_ranges {k_punctual_full_resolution_ranges}; +std::atomic g_split_lambda {k_cascade_split_lambda}; +std::atomic g_cascade_padding {k_cascade_padding}; +std::atomic g_receiver_depth_radii {k_cascade_receiver_depth_radii}; +std::atomic_bool g_static_caster_cache {true}; +std::atomic_bool g_voxel_mesh_shadows {true}; +std::atomic g_caster_tolerance {k_caster_tolerance}; } @@ -38,4 +50,93 @@ void setShadowDistance(float distance) { g_shadow_distance.store(distance <= 0.0f ? k_shadow_distance : distance, std::memory_order_relaxed); } +auto spotShadowSlots() -> uint32_t { + return g_spot_slots.load(std::memory_order_relaxed); +} + +auto pointShadowSlots() -> uint32_t { + return g_point_slots.load(std::memory_order_relaxed); +} + +void setSpotShadowSlots(uint32_t slots) { + g_spot_slots.store(std::min(slots, k_max_spot_shadows), std::memory_order_relaxed); +} + +void setPointShadowSlots(uint32_t slots) { + g_point_slots.store(std::min(slots, k_max_point_shadows), std::memory_order_relaxed); +} + +auto spotShadowBudget() -> uint32_t { + return std::min(g_spot_budget.load(std::memory_order_relaxed), spotShadowSlots()); +} + +auto pointShadowBudget() -> uint32_t { + return std::min(g_point_budget.load(std::memory_order_relaxed), pointShadowSlots()); +} + +auto punctualFullResolutionRanges() -> float { + return g_full_resolution_ranges.load(std::memory_order_relaxed); +} + +auto cascadeSplitLambda() -> float { + return g_split_lambda.load(std::memory_order_relaxed); +} + +auto cascadePadding() -> float { + return g_cascade_padding.load(std::memory_order_relaxed); +} + +auto cascadeReceiverDepthRadii() -> float { + return g_receiver_depth_radii.load(std::memory_order_relaxed); +} + +auto staticCasterCache() -> bool { + return g_static_caster_cache.load(std::memory_order_relaxed); +} + +auto voxelMeshShadows() -> bool { + return g_voxel_mesh_shadows.load(std::memory_order_relaxed); +} + +void setSpotShadowBudget(uint32_t lights) { + g_spot_budget.store(std::min(lights, k_max_spot_shadows), std::memory_order_relaxed); +} + +void setPointShadowBudget(uint32_t lights) { + g_point_budget.store(std::min(lights, k_max_point_shadows), std::memory_order_relaxed); +} + +void setPunctualFullResolutionRanges(float ranges) { + g_full_resolution_ranges.store(std::max(ranges, 0.0f), std::memory_order_relaxed); +} + +void setCascadeSplitLambda(float lambda) { + g_split_lambda.store(std::clamp(lambda, 0.0f, 1.0f), std::memory_order_relaxed); +} + +void setCascadePadding(float padding) { + g_cascade_padding.store(std::max(padding, 0.0f), std::memory_order_relaxed); +} + +void setCascadeReceiverDepthRadii(float radii) { + // One radius is the far side of the cascade sphere and anything less would clip its own slice + g_receiver_depth_radii.store(std::max(radii, 1.0f), std::memory_order_relaxed); +} + +auto casterTolerance() -> float { + return g_caster_tolerance.load(std::memory_order_relaxed); +} + +void setCasterTolerance(float meters) { + g_caster_tolerance.store(std::max(meters, 0.0f), std::memory_order_relaxed); +} + +void setStaticCasterCache(bool enabled) { + g_static_caster_cache.store(enabled, std::memory_order_relaxed); +} + +void setVoxelMeshShadows(bool enabled) { + g_voxel_mesh_shadows.store(enabled, std::memory_order_relaxed); +} + } diff --git a/engine/src/toast/renderer/shadow_constants.hpp b/engine/src/toast/renderer/shadow_constants.hpp index 422b2d498..c7d7d8cdb 100644 --- a/engine/src/toast/renderer/shadow_constants.hpp +++ b/engine/src/toast/renderer/shadow_constants.hpp @@ -20,9 +20,15 @@ inline constexpr float k_shadow_distance = 150.0f; inline constexpr float k_cascade_split_lambda = 0.75f; -inline constexpr uint32_t k_max_spot_shadows = 4; +/// Cascade depth past its center in radii +inline constexpr float k_cascade_receiver_depth_radii = 4.0f; -inline constexpr uint32_t k_max_point_shadows = 2; +/// Ceilings of the slot settings which size the shader matrix arrays +inline constexpr uint32_t k_max_spot_shadows = 32; +inline constexpr uint32_t k_max_point_shadows = 32; + +inline constexpr uint32_t k_default_spot_shadows = 8; +inline constexpr uint32_t k_default_point_shadows = 6; inline constexpr uint32_t k_cube_faces = 6; @@ -30,8 +36,11 @@ inline constexpr uint32_t k_punctual_resolution = 512; inline constexpr uint32_t k_min_punctual_resolution = 64; -inline constexpr float k_punctual_full_resolution_distance = 10.0f; -inline constexpr float k_punctual_min_resolution_distance = 60.0f; +/// Camera distance in light ranges within which a light keeps its full tile and past which each doubling halves it +inline constexpr float k_punctual_full_resolution_ranges = 4.0f; + +/// Share of the slice radius a cascade covers beyond its slice so small camera moves reuse its cached map +inline constexpr float k_cascade_padding = 0.15f; /// @note Set on the main thread before the passes are built TOAST_API auto cascadeResolution() -> uint32_t; @@ -42,17 +51,46 @@ TOAST_API void setCascadeResolution(uint32_t resolution); TOAST_API void setPunctualResolution(uint32_t resolution); TOAST_API void setShadowDistance(float distance); +/// Allocated slots with one punctual layer per spot and six per point +/// @note Set on the main thread before the passes are built +TOAST_API auto spotShadowSlots() -> uint32_t; +TOAST_API auto pointShadowSlots() -> uint32_t; +TOAST_API void setSpotShadowSlots(uint32_t slots); +TOAST_API void setPointShadowSlots(uint32_t slots); + +/// Runtime tuning read every frame and set from the renderer settings +/// Shadow slots in use where the rest of the allocated slots stay empty +TOAST_API auto spotShadowBudget() -> uint32_t; +TOAST_API auto pointShadowBudget() -> uint32_t; +TOAST_API auto punctualFullResolutionRanges() -> float; +TOAST_API auto cascadeSplitLambda() -> float; +TOAST_API auto cascadePadding() -> float; +TOAST_API auto cascadeReceiverDepthRadii() -> float; +/// Resting voxel casters render once into a static map that each frame copies under the moving casters +TOAST_API auto staticCasterCache() -> bool; +/// Voxel casters rasterize task and mesh shader quads where the device has them instead of marching per texel +TOAST_API auto voxelMeshShadows() -> bool; +TOAST_API auto casterTolerance() -> float; + +TOAST_API void setSpotShadowBudget(uint32_t lights); +TOAST_API void setPointShadowBudget(uint32_t lights); +TOAST_API void setPunctualFullResolutionRanges(float ranges); +TOAST_API void setCascadeSplitLambda(float lambda); +TOAST_API void setCascadePadding(float padding); +TOAST_API void setCascadeReceiverDepthRadii(float radii); +TOAST_API void setStaticCasterCache(bool enabled); +TOAST_API void setCasterTolerance(float meters); +TOAST_API void setVoxelMeshShadows(bool enabled); + +/// Follows how large the lit sphere looks from the camera so a wide light far away keeps its detail [[nodiscard]] -inline auto punctualShadowResolution(float camera_distance, float resolution_scale) -> uint32_t { +inline auto punctualShadowResolution(float camera_distance, float light_range, float resolution_scale) -> uint32_t { const uint32_t full_resolution = punctualResolution(); - const float span = k_punctual_min_resolution_distance - k_punctual_full_resolution_distance; - float t = span > 0.0f ? (camera_distance - k_punctual_full_resolution_distance) / span : 1.0f; - t = std::clamp(t, 0.0f, 1.0f); + const float reach = punctualFullResolutionRanges() * std::max(light_range, 0.0f); + const float coverage = camera_distance > reach ? reach / camera_distance : 1.0f; - const auto max_resolution = static_cast(full_resolution); - const auto min_resolution = static_cast(k_min_punctual_resolution); const float scale = std::clamp(resolution_scale, 0.0f, 1.0f); - const float target = (max_resolution + ((min_resolution - max_resolution) * t)) * scale; + const float target = static_cast(full_resolution) * coverage * scale; uint32_t resolution = k_min_punctual_resolution; while ((resolution * 2) <= full_resolution && static_cast(resolution * 2) <= target) { @@ -63,12 +101,23 @@ inline auto punctualShadowResolution(float camera_distance, float resolution_sca /// Spots first then six faces per point light inline constexpr uint32_t k_spot_layer_base = 0; -inline constexpr uint32_t k_point_layer_base = k_max_spot_shadows; -inline constexpr uint32_t k_punctual_layer_count = k_max_spot_shadows + (k_max_point_shadows * k_cube_faces); -/// Also sizes the shadow_depth.slang matrix array -inline constexpr uint32_t k_max_shadow_views = k_cascade_count + k_punctual_layer_count; +[[nodiscard]] +inline auto pointLayerBase() -> uint32_t { + return spotShadowSlots(); +} + +/// At least one so the punctual map exists with every slot turned off +[[nodiscard]] +inline auto punctualLayerCount() -> uint32_t { + return std::max(spotShadowSlots() + (pointShadowSlots() * k_cube_faces), 1u); +} + +/// Mirrored by kMaxShadowViews in shadow_depth.slang and shadow_views.slang +inline constexpr uint32_t k_max_shadow_views = k_cascade_count + k_max_spot_shadows + (k_max_point_shadows * k_cube_faces); +/// Within kPunctualBiasMeters of lighting.slang so a stale pose never shadows itself +inline constexpr float k_caster_tolerance = 0.01f; inline constexpr float k_punctual_near = 0.05f; /// Must exceed the mesh.slang filter radius diff --git a/engine/src/toast/renderer/shadow_slots.cpp b/engine/src/toast/renderer/shadow_slots.cpp index 6f5a1c9aa..2497c3cff 100644 --- a/engine/src/toast/renderer/shadow_slots.cpp +++ b/engine/src/toast/renderer/shadow_slots.cpp @@ -15,7 +15,8 @@ namespace renderer::shadow_slots { namespace { -constexpr size_t k_max_slots = 8; +constexpr size_t k_max_slots = 32; +static_assert(k_max_slots >= shadows::k_max_spot_shadows && k_max_slots >= shadows::k_max_point_shadows); std::atomic g_retention {k_default_retention}; std::atomic g_grace_frames {k_default_grace_frames}; @@ -126,12 +127,14 @@ void setGraceFrames(uint32_t frames) { g_grace_frames.store(frames, std::memory_order_relaxed); } -auto stableResolution(uint32_t current, float camera_distance, float resolution_scale) -> uint32_t { +auto stableResolution(uint32_t current, float camera_distance, float light_range, float resolution_scale) -> uint32_t { if (current == 0) { - return shadows::punctualShadowResolution(camera_distance, resolution_scale); + return shadows::punctualShadowResolution(camera_distance, light_range, resolution_scale); } - const uint32_t nearer = shadows::punctualShadowResolution(camera_distance * (1.0f - k_resolution_band), resolution_scale); - const uint32_t farther = shadows::punctualShadowResolution(camera_distance * (1.0f + k_resolution_band), resolution_scale); + const uint32_t nearer = + shadows::punctualShadowResolution(camera_distance * (1.0f - k_resolution_band), light_range, resolution_scale); + const uint32_t farther = + shadows::punctualShadowResolution(camera_distance * (1.0f + k_resolution_band), light_range, resolution_scale); return std::clamp(current, farther, nearer); } diff --git a/engine/src/toast/renderer/shadow_slots.hpp b/engine/src/toast/renderer/shadow_slots.hpp index 2c862c613..1b4933041 100644 --- a/engine/src/toast/renderer/shadow_slots.hpp +++ b/engine/src/toast/renderer/shadow_slots.hpp @@ -50,6 +50,6 @@ void setGraceFrames(uint32_t frames); /// Keeps the current tier until the camera distance leaves its band [[nodiscard]] -auto stableResolution(uint32_t current, float camera_distance, float resolution_scale) -> uint32_t; +auto stableResolution(uint32_t current, float camera_distance, float light_range, float resolution_scale) -> uint32_t; } diff --git a/engine/src/toast/renderer/shared_texture_output_target.cpp b/engine/src/toast/renderer/shared_texture_output_target.cpp index e25c9468f..036d732bc 100644 --- a/engine/src/toast/renderer/shared_texture_output_target.cpp +++ b/engine/src/toast/renderer/shared_texture_output_target.cpp @@ -37,6 +37,22 @@ constexpr vk::ExternalMemoryHandleTypeFlagBits k_memory_handle_type = vk::Extern constexpr vk::ExternalMemoryHandleTypeFlagBits k_memory_handle_type = vk::ExternalMemoryHandleTypeFlagBits::eOpaqueFd; #endif +#if defined(_WIN32) +constexpr vk::ImageUsageFlags k_shared_usage = vk::ImageUsageFlagBits::eTransferDst; +#else +constexpr vk::ImageUsageFlags k_shared_usage = vk::ImageUsageFlagBits::eTransferDst | vk::ImageUsageFlagBits::eTransferSrc | + vk::ImageUsageFlagBits::eColorAttachment | vk::ImageUsageFlagBits::eSampled; +#endif + +#if defined(_WIN32) +constexpr vk::ImageLayout k_shared_release_layout = vk::ImageLayout::eGeneral; +constexpr vk::Format k_shared_format = vk::Format::eB8G8R8A8Unorm; +#else +// Avalonia's GL importer uses GL_RGBA8 and waits with GL_LAYOUT_TRANSFER_SRC_EXT. +constexpr vk::ImageLayout k_shared_release_layout = vk::ImageLayout::eTransferSrcOptimal; +constexpr vk::Format k_shared_format = vk::Format::eR8G8B8A8Unorm; +#endif + [[nodiscard]] auto findMemoryType(const vk::raii::PhysicalDevice& physical, uint32_t type_bits) -> std::optional { const auto properties = physical.getMemoryProperties(); @@ -58,7 +74,7 @@ auto findMemoryType(const vk::raii::PhysicalDevice& physical, uint32_t type_bits auto canShareImages(const vk::raii::PhysicalDevice& physical, vk::Format format) -> bool { vk::PhysicalDeviceExternalImageFormatInfo external_info(k_memory_handle_type); vk::PhysicalDeviceImageFormatInfo2 info( - format, vk::ImageType::e2D, vk::ImageTiling::eOptimal, vk::ImageUsageFlagBits::eTransferDst, {}, &external_info + format, vk::ImageType::e2D, vk::ImageTiling::eOptimal, k_shared_usage, {}, &external_info ); try { const auto chain = physical.getImageFormatProperties2(info); @@ -66,7 +82,9 @@ auto canShareImages(const vk::raii::PhysicalDevice& physical, vk::Format format) #if defined(_WIN32) return static_cast(features & vk::ExternalMemoryFeatureFlagBits::eImportable); #else - return static_cast(features & vk::ExternalMemoryFeatureFlagBits::eExportable); + // Avalonia's GL importer does not set GL_DEDICATED_MEMORY_OBJECT_EXT. + return static_cast(features & vk::ExternalMemoryFeatureFlagBits::eExportable) && + !static_cast(features & vk::ExternalMemoryFeatureFlagBits::eDedicatedOnly); #endif } catch (const vk::SystemError&) { return false; } } @@ -96,6 +114,7 @@ struct SharedTextureOutputTarget::Interop { std::vector slots; vk::DeviceSize memory_size = 0; uint64_t generation = 0; + int32_t displayed = -1; uint64_t adapter_luid = 0; std::array adapter_uuid {}; #if defined(_WIN32) @@ -116,6 +135,7 @@ struct SharedTextureOutputTarget::Interop { void createSlots(const VulkanCore& core, vk::Extent2D extent, vk::Format format, size_t count) { const auto& device = core.getDevice(); slots.clear(); + displayed = -1; slots.resize(count); ++generation; @@ -132,17 +152,17 @@ struct SharedTextureOutputTarget::Interop { image_ci.arrayLayers = 1; image_ci.samples = vk::SampleCountFlagBits::e1; image_ci.tiling = vk::ImageTiling::eOptimal; - image_ci.usage = vk::ImageUsageFlagBits::eTransferDst; + image_ci.usage = k_shared_usage; image_ci.sharingMode = vk::SharingMode::eExclusive; image_ci.initialLayout = vk::ImageLayout::eUndefined; slot.image = vk::raii::Image(device, image_ci); setDebugName(core, *slot.image, std::format("SharedTextureOutput Shared[{}]", index)); const vk::MemoryRequirements requirements = slot.image.getMemoryRequirements(); - vk::MemoryDedicatedAllocateInfo dedicated(*slot.image); vk::MemoryAllocateInfo allocate_info(requirements.size, 0); #if defined(_WIN32) + vk::MemoryDedicatedAllocateInfo dedicated(*slot.image); D3D11_TEXTURE2D_DESC desc {}; desc.Width = extent.width; desc.Height = extent.height; @@ -183,7 +203,10 @@ struct SharedTextureOutputTarget::Interop { allocate_info.pNext = &import_info; #else const uint32_t type_bits = requirements.memoryTypeBits; - vk::ExportMemoryAllocateInfo export_info(k_memory_handle_type, &dedicated); + // Match Avalonia's non-dedicated GL memory-object import. A Vulkan + // dedicated allocation needs GL_DEDICATED_MEMORY_OBJECT_EXT on import, + // which its OpenGL backend currently does not set. + vk::ExportMemoryAllocateInfo export_info(k_memory_handle_type); allocate_info.pNext = &export_info; #endif const auto type_index = findMemoryType(core.getPhysicalDevice(), type_bits); @@ -241,6 +264,7 @@ void SharedTextureOutputTarget::allocateResources(vk::Extent2D extent) { shared.mapped = nullptr; shared.read_back = false; shared.shared = false; + shared.in_flight = false; shared.view.reset(); shared.staging.reset(); shared.image.reset(); @@ -290,7 +314,7 @@ void SharedTextureOutputTarget::allocateResources(vk::Extent2D extent) { } if (m_interop) { try { - m_interop->createSlots(*m_core, m_extent, m_color_format, m_images.size()); + m_interop->createSlots(*m_core, m_extent, k_shared_format, m_images.size()); } catch (const std::exception& error) { TOAST_WARN("Render", "Could not recreate the shared viewport textures ({}); reading frames back instead", error.what()); m_interop.reset(); @@ -330,7 +354,7 @@ void SharedTextureOutputTarget::createInterop() { throw std::runtime_error("D3D11CreateDevice failed"); } #endif - interop->createSlots(*m_core, m_extent, m_color_format, m_images.size()); + interop->createSlots(*m_core, m_extent, k_shared_format, m_images.size()); } catch (const std::exception& error) { TOAST_WARN("Render", "Could not share the viewport textures ({}); reading frames back instead", error.what()); m_sharing.store(SharingState::failed, std::memory_order_release); @@ -361,23 +385,36 @@ auto SharedTextureOutputTarget::acquireNextImage(uint64_t, vk::Semaphore, vk::Fe const std::scoped_lock lock(m_frame_mutex); const uint32_t count = getImageCount(); - const int32_t published = m_latest_ready.load(std::memory_order_acquire); - const auto busy = [&](uint32_t index) { - return std::cmp_equal(index, published) || std::cmp_equal(index, m_reading) || - (m_interop && index < m_interop->slots.size() && m_interop->slots[index].held); - }; - - uint32_t image_index = m_next_acquire_index; for (uint32_t step = 0; step < count; ++step) { const uint32_t candidate = (m_next_acquire_index + step) % count; - if (!busy(candidate)) { - image_index = candidate; - break; + if (!imageBusy(candidate)) { + m_images[candidate].in_flight = true; + m_images[candidate].shared = false; + m_next_acquire_index = (candidate + 1) % count; + return {vk::Result::eSuccess, uint32_t(candidate)}; } } - m_next_acquire_index = (image_index + 1) % count; + // Backpressure must never overwrite a frame held by the compositor. + return {vk::Result::eNotReady, 0}; +} - return {vk::Result::eSuccess, image_index}; +auto SharedTextureOutputTarget::imageBusy(uint32_t index) const -> bool { + return m_images[index].in_flight || std::cmp_equal(index, m_latest_ready.load(std::memory_order_acquire)) || + std::cmp_equal(index, m_reading) || + (m_interop && index < m_interop->slots.size() && + (m_interop->slots[index].held || std::cmp_equal(index, m_interop->displayed))); +} + +auto SharedTextureOutputTarget::waitForFreeImage(std::chrono::nanoseconds timeout) -> bool { + std::unique_lock lock(m_frame_mutex); + return m_slot_freed.wait_for(lock, timeout, [this] { + for (uint32_t index = 0; index < m_images.size(); ++index) { + if (!imageBusy(index)) { + return true; + } + } + return false; + }); } auto SharedTextureOutputTarget::present(uint32_t, vk::Semaphore) -> vk::Result { @@ -414,20 +451,25 @@ void SharedTextureOutputTarget::recordFinalize(vk::CommandBuffer command_buffer, vk::PipelineStageFlagBits::eTopOfPipe, vk::PipelineStageFlagBits::eTransfer, {}, {}, {}, acquire ); - const vk::ImageCopy copy( - vk::ImageSubresourceLayers(vk::ImageAspectFlagBits::eColor, 0, 0, 1), - vk::Offset3D {0, 0, 0}, - vk::ImageSubresourceLayers(vk::ImageAspectFlagBits::eColor, 0, 0, 1), - vk::Offset3D {0, 0, 0}, - vk::Extent3D {m_extent.width, m_extent.height, 1} - ); - command_buffer.copyImage(image, vk::ImageLayout::eTransferSrcOptimal, target, vk::ImageLayout::eTransferDstOptimal, copy); + const vk::ImageSubresourceLayers layers(vk::ImageAspectFlagBits::eColor, 0, 0, 1); + if (m_color_format == k_shared_format) { + const vk::ImageCopy copy(layers, {}, layers, {}, {m_extent.width, m_extent.height, 1}); + command_buffer.copyImage(image, vk::ImageLayout::eTransferSrcOptimal, target, vk::ImageLayout::eTransferDstOptimal, copy); + } else { + // Convert BGRA rendering/readback into RGBA for Linux GL interop. + const vk::Offset3D end(static_cast(m_extent.width), static_cast(m_extent.height), 1); + const std::array offsets {vk::Offset3D {}, end}; + const vk::ImageBlit blit(layers, offsets, layers, offsets); + command_buffer.blitImage( + image, vk::ImageLayout::eTransferSrcOptimal, target, vk::ImageLayout::eTransferDstOptimal, blit, vk::Filter::eNearest + ); + } const vk::ImageMemoryBarrier release( vk::AccessFlagBits::eTransferWrite, {}, vk::ImageLayout::eTransferDstOptimal, - vk::ImageLayout::eGeneral, + k_shared_release_layout, graphics_family, VK_QUEUE_FAMILY_EXTERNAL, target, @@ -471,16 +513,19 @@ auto SharedTextureOutputTarget::submitSync(uint32_t image_index) -> OutputSubmit sync.wait = *slot.done; sync.wait_stage = vk::PipelineStageFlagBits::eTransfer; slot.done_pending = false; + } else if (slot.ready_signaled) { + // A dropped frame still has an unconsumed binary signal. Drain it before + // signalling readiness for the new contents of this slot. + sync.wait = *slot.ready; + sync.wait_stage = vk::PipelineStageFlagBits::eTransfer; } - if (!slot.ready_signaled) { - sync.signal = *slot.ready; - slot.ready_signaled = true; - } + sync.signal = *slot.ready; + slot.ready_signaled = true; #endif return sync; } -void SharedTextureOutputTarget::onImageRenderComplete(uint32_t image_index) { +void SharedTextureOutputTarget::onImageRenderComplete(uint32_t image_index, bool publish) { ZoneScoped; if (image_index >= m_images.size()) { return; @@ -489,6 +534,10 @@ void SharedTextureOutputTarget::onImageRenderComplete(uint32_t image_index) { const std::scoped_lock lock(m_frame_mutex); auto& shared = m_images[image_index]; + shared.in_flight = false; + if (!publish) { + return; + } if (shared.read_back && shared.staging.has_value()) { shared.staging->getAllocation().invalidate(0, imageByteSize()); m_latest_readback.store(static_cast(image_index), std::memory_order_release); @@ -530,6 +579,7 @@ auto SharedTextureOutputTarget::copyLatestFrame(void* dst, uint32_t dst_capacity m_reading = -1; } m_reading_done.notify_all(); + m_slot_freed.notify_all(); return 1; } @@ -546,9 +596,20 @@ void SharedTextureOutputTarget::disableSharedTextures() { } auto SharedTextureOutputTarget::enableSharedTextures(uint64_t adapter_luid, const uint8_t* adapter_uuid) -> bool { - if (!m_core->isTextureSharingSupported() || !canShareImages(m_core->getPhysicalDevice(), m_color_format)) { + if (m_core->getNsightMode() != NsightMode::none || m_core->getRenderDocAPI() != nullptr) { + TOAST_INFO("Render", "A graphics debugger is attached; the viewport reads frames back instead of sharing textures"); return false; } + if (!m_core->isTextureSharingSupported() || !canShareImages(m_core->getPhysicalDevice(), k_shared_format)) { + return false; + } + if (m_color_format != k_shared_format) { + const auto source = m_core->getPhysicalDevice().getFormatProperties(m_color_format).optimalTilingFeatures; + const auto destination = m_core->getPhysicalDevice().getFormatProperties(k_shared_format).optimalTilingFeatures; + if (!(source & vk::FormatFeatureFlagBits::eBlitSrc) || !(destination & vk::FormatFeatureFlagBits::eBlitDst)) { + return false; + } + } const auto ids = m_core->getPhysicalDevice().getProperties2(); const auto& id = ids.get(); #if defined(_WIN32) @@ -629,11 +690,16 @@ auto SharedTextureOutputTarget::exportSharedSlot(uint64_t generation, uint32_t s return true; } -auto SharedTextureOutputTarget::holdSharedSlot(uint64_t generation, uint32_t slot) -> bool { +auto SharedTextureOutputTarget::holdSharedSlot(uint64_t generation, uint32_t slot, uint64_t frame_id) -> bool { std::scoped_lock lock(m_frame_mutex); if (!m_interop || m_interop->generation != generation || slot >= m_interop->slots.size()) { return false; } + if (m_images[slot].in_flight || m_interop->slots[slot].held || std::cmp_equal(slot, m_interop->displayed) || + !std::cmp_equal(slot, m_latest_ready.load(std::memory_order_acquire)) || + frame_id != m_frame_counter.load(std::memory_order_acquire)) { + return false; + } m_interop->slots[slot].held = true; return true; } @@ -643,8 +709,13 @@ void SharedTextureOutputTarget::releaseSharedSlot(uint64_t generation, uint32_t if (!m_interop || m_interop->generation != generation || slot >= m_interop->slots.size()) { return; } + // Under the lock so the render thread cannot check and then sleep through it + m_slot_freed.notify_all(); auto& shared = m_interop->slots[slot]; shared.held = false; + if (consumed) { + m_interop->displayed = static_cast(slot); + } #if !defined(_WIN32) if (consumed) { shared.ready_signaled = false; diff --git a/engine/src/toast/renderer/shared_texture_output_target.hpp b/engine/src/toast/renderer/shared_texture_output_target.hpp index 8a1d30c91..79882ee3f 100644 --- a/engine/src/toast/renderer/shared_texture_output_target.hpp +++ b/engine/src/toast/renderer/shared_texture_output_target.hpp @@ -43,7 +43,7 @@ struct ViewportSharedHandles { class SharedTextureOutputTarget final : public IOutputTarget { public: - SharedTextureOutputTarget(const VulkanCore& core, vk::Extent2D preferred_extent, uint32_t image_count = 3); + SharedTextureOutputTarget(const VulkanCore& core, vk::Extent2D preferred_extent, uint32_t image_count = 4); ~SharedTextureOutputTarget() override; SharedTextureOutputTarget(const SharedTextureOutputTarget&) = delete; @@ -82,7 +82,9 @@ class SharedTextureOutputTarget final : public IOutputTarget { } void recordFinalize(vk::CommandBuffer command_buffer, uint32_t image_index) override; - void onImageRenderComplete(uint32_t image_index) override; + void onImageRenderComplete(uint32_t image_index, bool publish = true) override; + [[nodiscard]] + auto waitForFreeImage(std::chrono::nanoseconds timeout) -> bool override; [[nodiscard]] auto submitSync(uint32_t image_index) -> OutputSubmitSync override; @@ -112,7 +114,7 @@ class SharedTextureOutputTarget final : public IOutputTarget { auto exportSharedSlot(uint64_t generation, uint32_t slot, ViewportSharedHandles* out) -> bool; [[nodiscard]] - auto holdSharedSlot(uint64_t generation, uint32_t slot) -> bool; + auto holdSharedSlot(uint64_t generation, uint32_t slot, uint64_t frame_id) -> bool; void releaseSharedSlot(uint64_t generation, uint32_t slot, bool consumed); @@ -124,10 +126,15 @@ class SharedTextureOutputTarget final : public IOutputTarget { void* mapped = nullptr; bool read_back = false; bool shared = false; + bool in_flight = false; }; struct Interop; + /// m_frame_mutex held + [[nodiscard]] + auto imageBusy(uint32_t index) const -> bool; + enum class SharingState : uint8_t { off, requested, @@ -155,6 +162,8 @@ class SharedTextureOutputTarget final : public IOutputTarget { std::atomic m_latest_readback {-1}; int32_t m_reading = -1; std::condition_variable m_reading_done; + /// Woken when the editor gives a slot back + std::condition_variable m_slot_freed; std::atomic m_frame_counter {0}; std::unique_ptr m_interop; std::atomic m_sharing {SharingState::off}; diff --git a/engine/src/toast/renderer/voxel_gpu_storage.hpp b/engine/src/toast/renderer/voxel_gpu_storage.hpp index fc4c2290b..14989c6e1 100644 --- a/engine/src/toast/renderer/voxel_gpu_storage.hpp +++ b/engine/src/toast/renderer/voxel_gpu_storage.hpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -127,6 +128,15 @@ class VoxelGpuStorage : public IVulkanResource { return m_history; } + /// Per record grid indices of its glass bricks + /// @note Main thread only and before the upload is queued + void setGlassBricks(std::vector> bricks) { m_glass_bricks = std::move(bricks); } + + [[nodiscard]] + auto glassBricks(uint32_t record) const -> std::span { + return record < m_glass_bricks.size() ? std::span(m_glass_bricks[record]) : std::span {}; + } + private: friend class VoxelSceneUpload; @@ -143,6 +153,8 @@ class VoxelGpuStorage : public IVulkanResource { VoxelStorageDebugInfo m_debug; VoxelChangeHistory m_history; + + std::vector> m_glass_bricks; }; struct VoxelCollisionWorld { diff --git a/engine/src/toast/renderer/voxel_scene_patcher.cpp b/engine/src/toast/renderer/voxel_scene_patcher.cpp index 9761e3a4c..a20ff5d49 100644 --- a/engine/src/toast/renderer/voxel_scene_patcher.cpp +++ b/engine/src/toast/renderer/voxel_scene_patcher.cpp @@ -156,6 +156,10 @@ auto VoxelScenePatcher::tryPatch( m_scene.grids.resize(m_scene.grids.size() + (dims.x * dims.y * dims.z), 0u); m_scene.coarse.resize(m_scene.coarse.size() + (((coarse.x * coarse.y * coarse.z) + 31u) / 32u), 0u); + if (paletteHasGlass(*volumes[i].palette)) { + record.glass_offset = static_cast(m_scene.coarse.size()); + m_scene.coarse.resize(m_scene.coarse.size() + glassWords(dims), 0u); + } appended.push_back(static_cast(m_scene.records.size())); m_scene.records.push_back(record); m_node_rids.push_back(node_rids[i]); diff --git a/engine/src/toast/renderer/vulkan_common.hpp b/engine/src/toast/renderer/vulkan_common.hpp index 5d5a8f078..15ef830b8 100644 --- a/engine/src/toast/renderer/vulkan_common.hpp +++ b/engine/src/toast/renderer/vulkan_common.hpp @@ -41,6 +41,12 @@ inline constexpr vk::Format k_voxel_material_format = vk::Format::eR8G8B8A8Unorm /// xyz voxel face normal * 0.5 + 0.5 inline constexpr vk::Format k_voxel_face_normal_format = vk::Format::eA2B10G10R10UnormPack32; +/// Marched and sampled light terms before voxel_denoise.slang and never negative +inline constexpr vk::Format k_voxel_noisy_format = vk::Format::eB10G11R11UfloatPack32; + +/// voxel_lighting.slang packReservoir +inline constexpr vk::Format k_voxel_reservoir_format = vk::Format::eR32G32B32A32Uint; + /// @warning RenderStage::world only since the overlay scope has one attachment [[nodiscard]] inline auto worldStageExtraColorFormats() -> std::vector { diff --git a/engine/src/toast/renderer/vulkan_core.cpp b/engine/src/toast/renderer/vulkan_core.cpp index 2da9f527b..d81215f35 100644 --- a/engine/src/toast/renderer/vulkan_core.cpp +++ b/engine/src/toast/renderer/vulkan_core.cpp @@ -649,6 +649,10 @@ void VulkanCore::createLogicalDeviceAndAllocator(std::span re enabled_features.samplerAnisotropy = m_sampler_anisotropy_supported ? VK_TRUE : VK_FALSE; enabled_features.independentBlend = VK_TRUE; + m_depth_clamp_supported = device_features.depthClamp == VK_TRUE; + enabled_features.depthClamp = m_depth_clamp_supported ? VK_TRUE : VK_FALSE; + m_dual_source_blend_supported = device_features.dualSrcBlend == VK_TRUE; + enabled_features.dualSrcBlend = m_dual_source_blend_supported ? VK_TRUE : VK_FALSE; // HiZPyramid picks its level image by a loop index enabled_features.shaderStorageImageArrayDynamicIndexing = VK_TRUE; @@ -679,6 +683,11 @@ void VulkanCore::createLogicalDeviceAndAllocator(std::span re "Vulkan 1.2 descriptorBindingVariableDescriptorCount" ); require_feature(supported_vulkan12.bufferDeviceAddress == VK_TRUE, "Vulkan 1.2 bufferDeviceAddress"); + // lighting.slang picks reflection probe cubemaps per pixel with NonUniformResourceIndex + require_feature( + supported_vulkan12.shaderSampledImageArrayNonUniformIndexing == VK_TRUE, + "Vulkan 1.2 shaderSampledImageArrayNonUniformIndexing" + ); // eDepthAttachmentOptimal and eDepthReadOnlyOptimal are only legal with this enabled require_feature(supported_vulkan12.separateDepthStencilLayouts == VK_TRUE, "Vulkan 1.2 separateDepthStencilLayouts"); // AsyncCompute tickets are timeline values @@ -720,11 +729,13 @@ void VulkanCore::createLogicalDeviceAndAllocator(std::span re #else const std::array sharing_extensions {"VK_KHR_external_memory_fd", "VK_KHR_external_semaphore_fd"}; #endif - m_texture_sharing_supported = std::ranges::all_of(sharing_extensions, [&available](std::string_view name) { - return std::ranges::any_of(available, [name](const vk::ExtensionProperties& ext) { - return std::string_view(ext.extensionName.data()) == name; - }); - }); + const bool debugger_attached = m_nsight_mode != NsightMode::none || rdoc_api != nullptr; + m_texture_sharing_supported = + !debugger_attached && std::ranges::all_of(sharing_extensions, [&available](std::string_view name) { + return std::ranges::any_of(available, [name](const vk::ExtensionProperties& ext) { + return std::string_view(ext.extensionName.data()) == name; + }); + }); if (m_texture_sharing_supported) { device_extensions.insert(device_extensions.end(), sharing_extensions.begin(), sharing_extensions.end()); } else { @@ -806,6 +817,7 @@ void VulkanCore::createLogicalDeviceAndAllocator(std::span re usable = mesh_probe.taskShader == VK_TRUE && mesh_probe.meshShader == VK_TRUE && mesh_props.maxMeshOutputVertices >= 256 && mesh_props.maxMeshOutputPrimitives >= 128 && mesh_props.maxMeshWorkGroupInvocations >= 64 && mesh_props.maxTaskWorkGroupInvocations >= 64 && mesh_props.maxTaskPayloadSize >= 3084; + m_mesh_multiview_views = usable && mesh_probe.multiviewMeshShader == VK_TRUE ? mesh_props.maxMeshMultiviewViewCount : 0u; } m_mesh_shader_supported = usable; @@ -822,6 +834,7 @@ void VulkanCore::createLogicalDeviceAndAllocator(std::span re vk::PhysicalDeviceMeshShaderFeaturesEXT mesh_shader_features {}; mesh_shader_features.taskShader = VK_TRUE; mesh_shader_features.meshShader = VK_TRUE; + mesh_shader_features.multiviewMeshShader = m_mesh_multiview_views > 0 ? VK_TRUE : VK_FALSE; vk::PhysicalDeviceAccelerationStructureFeaturesKHR acceleration_structure_features {}; acceleration_structure_features.accelerationStructure = VK_TRUE; @@ -843,6 +856,7 @@ void VulkanCore::createLogicalDeviceAndAllocator(std::span re vulkan12_features.descriptorBindingPartiallyBound = VK_TRUE; vulkan12_features.descriptorBindingVariableDescriptorCount = VK_TRUE; vulkan12_features.bufferDeviceAddress = VK_TRUE; + vulkan12_features.shaderSampledImageArrayNonUniformIndexing = VK_TRUE; vulkan12_features.separateDepthStencilLayouts = VK_TRUE; vulkan12_features.timelineSemaphore = VK_TRUE; vulkan11_features.shaderDrawParameters = VK_TRUE; diff --git a/engine/src/toast/renderer/vulkan_core.hpp b/engine/src/toast/renderer/vulkan_core.hpp index 23e0b444a..727026157 100644 --- a/engine/src/toast/renderer/vulkan_core.hpp +++ b/engine/src/toast/renderer/vulkan_core.hpp @@ -122,6 +122,22 @@ class VulkanCore { return m_mesh_shader_supported; } + [[nodiscard]] + auto isDepthClampSupported() const noexcept -> bool { + return m_depth_clamp_supported; + } + + [[nodiscard]] + auto isDualSourceBlendSupported() const noexcept -> bool { + return m_dual_source_blend_supported; + } + + /// Views a multiview scope may have under a mesh pipeline or 0 without multiview mesh shaders + [[nodiscard]] + auto meshMultiviewViews() const noexcept -> uint32_t { + return m_mesh_multiview_views; + } + [[nodiscard]] auto getBufferAddress(vk::Buffer buffer) const -> vk::DeviceAddress { vk::BufferDeviceAddressInfo info {}; @@ -279,6 +295,9 @@ class VulkanCore { bool m_ray_tracing_supported = false; bool m_mesh_shader_supported = false; + uint32_t m_mesh_multiview_views = 0; + bool m_depth_clamp_supported = false; + bool m_dual_source_blend_supported = false; uint32_t m_as_scratch_alignment = 0; mutable bool m_nsight_gputrace_activated = false; diff --git a/engine/src/toast/renderer/vulkan_pipeline.cpp b/engine/src/toast/renderer/vulkan_pipeline.cpp index 14851313d..55707d917 100644 --- a/engine/src/toast/renderer/vulkan_pipeline.cpp +++ b/engine/src/toast/renderer/vulkan_pipeline.cpp @@ -95,6 +95,9 @@ auto createGraphicsPipelineImpl( if (config.depth_format.has_value()) { rendering_ci.depthAttachmentFormat = *config.depth_format; } + if (config.stencil_format.has_value()) { + rendering_ci.stencilAttachmentFormat = *config.stencil_format; + } rendering_ci.viewMask = config.view_mask; const uint32_t vertex_binding_count = @@ -110,7 +113,11 @@ auto createGraphicsPipelineImpl( const vk::PipelineViewportStateCreateInfo viewport_state_ci({}, 1, nullptr, 1, nullptr); - const std::array dynamic_states {vk::DynamicState::eViewport, vk::DynamicState::eScissor}; + std::vector dynamic_states {vk::DynamicState::eViewport, vk::DynamicState::eScissor}; + if (config.stencil_replace) { + dynamic_states.push_back(vk::DynamicState::eStencilWriteMask); + dynamic_states.push_back(vk::DynamicState::eStencilReference); + } const vk::PipelineDynamicStateCreateInfo dynamic_state_ci( {}, static_cast(dynamic_states.size()), dynamic_states.data() ); @@ -118,7 +125,7 @@ auto createGraphicsPipelineImpl( const bool depth_bias_enable = config.depth_bias_constant != 0.0f || config.depth_bias_slope != 0.0f; const vk::PipelineRasterizationStateCreateInfo rasterization_state_ci( {}, - false, + config.depth_clamp, false, vk::PolygonMode::eFill, config.cull_mode, @@ -157,6 +164,10 @@ auto createGraphicsPipelineImpl( src_factor = vk::BlendFactor::eDstColor; dst_factor = vk::BlendFactor::eZero; break; + case BlendPreset::dual_source: + src_factor = vk::BlendFactor::eOne; + dst_factor = vk::BlendFactor::eSrc1Color; + break; case BlendPreset::none: break; } @@ -164,24 +175,37 @@ auto createGraphicsPipelineImpl( vk::ColorComponentFlagBits::eR | vk::ColorComponentFlagBits::eG | vk::ColorComponentFlagBits::eB; std::vector color_blend_attachments; - color_blend_attachments.emplace_back( - blend_preset != BlendPreset::none, - src_factor, - dst_factor, - vk::BlendOp::eAdd, - vk::BlendFactor::eOne, - vk::BlendFactor::eZero, - vk::BlendOp::eAdd, - config.write_color_alpha ? (rgb_write | vk::ColorComponentFlagBits::eA) : rgb_write - ); - - for ([[maybe_unused]] - const vk::Format extra : config.extra_color_formats) { + if (config.write_color_alpha) { color_blend_attachments.emplace_back( - false, + blend_preset != BlendPreset::none, + src_factor, + dst_factor, + vk::BlendOp::eAdd, vk::BlendFactor::eOne, vk::BlendFactor::eZero, vk::BlendOp::eAdd, + !config.write_color ? vk::ColorComponentFlags {} : rgb_write | vk::ColorComponentFlagBits::eA + ); + } else { + color_blend_attachments.emplace_back( + blend_preset != BlendPreset::none, + src_factor, + dst_factor, + vk::BlendOp::eAdd, + vk::BlendFactor::eOne, + vk::BlendFactor::eZero, + vk::BlendOp::eAdd, + !config.write_color ? vk::ColorComponentFlags {} : rgb_write + ); + } + + for (size_t extra = 0; extra < config.extra_color_formats.size(); ++extra) { + const bool blended = blend_preset != BlendPreset::none && ((config.extra_blend_mask >> extra) & 1u) != 0; + color_blend_attachments.emplace_back( + blended, + blended ? src_factor : vk::BlendFactor::eOne, + blended ? dst_factor : vk::BlendFactor::eZero, + vk::BlendOp::eAdd, vk::BlendFactor::eOne, vk::BlendFactor::eZero, vk::BlendOp::eAdd, @@ -198,19 +222,23 @@ auto createGraphicsPipelineImpl( std::array {0.0f, 0.0f, 0.0f, 0.0f} ); + const bool has_depth_stencil = config.depth_format.has_value() || config.stencil_format.has_value(); + const vk::StencilOpState stencil_op( + vk::StencilOp::eKeep, vk::StencilOp::eReplace, vk::StencilOp::eKeep, vk::CompareOp::eAlways, 0xFFu, 0u, 0u + ); vk::PipelineDepthStencilStateCreateInfo depth_stencil_state_ci {}; - if (config.depth_format.has_value()) { + if (has_depth_stencil) { depth_stencil_state_ci = vk::PipelineDepthStencilStateCreateInfo( {}, - config.depth_test, - config.depth_write, - config.depth_compare, // depthCompareOp - false, // depthBoundsTestEnable - false, // stencilTestEnable - vk::StencilOpState(), // front - vk::StencilOpState(), // back - 0.0f, // minDepthBounds - 1.0f // maxDepthBounds + config.depth_format.has_value() && config.depth_test, + config.depth_format.has_value() && config.depth_write, + config.depth_compare, // depthCompareOp + false, // depthBoundsTestEnable + config.stencil_replace, // stencilTestEnable + config.stencil_replace ? stencil_op : vk::StencilOpState(), // front + config.stencil_replace ? stencil_op : vk::StencilOpState(), // back + 0.0f, // minDepthBounds + 1.0f // maxDepthBounds ); } @@ -226,7 +254,7 @@ auto createGraphicsPipelineImpl( pipeline_ci.pMultisampleState = &multisample_state_ci; pipeline_ci.pColorBlendState = &color_blend_state_ci; pipeline_ci.pDynamicState = &dynamic_state_ci; - if (config.depth_format.has_value()) { + if (has_depth_stencil) { pipeline_ci.pDepthStencilState = &depth_stencil_state_ci; } pipeline_ci.layout = pipeline_layout; @@ -273,7 +301,7 @@ auto VulkanPipeline::rebuild(const VulkanCore& core, const Config& config) -> vo if (config.color_format == vk::Format::eUndefined && !config.depth_only) { TOAST_CRITICAL("Render", "Graphics pipeline requires a valid color format!"); } - if (config.depth_only && !config.depth_format.has_value()) { + if (config.depth_only && !config.depth_format.has_value() && !config.stencil_format.has_value()) { TOAST_CRITICAL("Render", "Depth-only graphics pipeline requires a depth format!"); } if (config.extent.width == 0 || config.extent.height == 0) { diff --git a/engine/src/toast/renderer/vulkan_pipeline.hpp b/engine/src/toast/renderer/vulkan_pipeline.hpp index 8bf944e50..fa29b0124 100644 --- a/engine/src/toast/renderer/vulkan_pipeline.hpp +++ b/engine/src/toast/renderer/vulkan_pipeline.hpp @@ -29,6 +29,7 @@ class VulkanPipeline { premultiplied, // one 1-srcAlpha additive, // one one multiply, // dstColor zero + dual_source, // one src1Color and needs dualSrcBlend }; struct Config { @@ -37,15 +38,23 @@ class VulkanPipeline { vk::Format color_format = vk::Format::eUndefined; std::optional depth_format; + /// Without depth_format the depth aspect is left out of the rendering + std::optional stencil_format; vk::Extent2D extent; std::vector extra_color_formats; bool write_extra_color = false; + /// Bit i blends extra attachment i with blend_preset and never for an integer format + uint32_t extra_blend_mask = 0; + /// color_format keeps its destination alpha unless set bool write_color_alpha = false; + /// False masks color_format off entirely while extra attachments still follow write_extra_color + bool write_color = true; + bool depth_only = false; /// With depth_only keeps the fragment stage for shaders that write SV_Depth @@ -82,6 +91,12 @@ class VulkanPipeline { /// Scene convention. Shadow maps set eLess and far plane draws need depth::k_closer_or_equal vk::CompareOp depth_compare = depth::k_closer; + /// Every surviving fragment writes the dynamic stencil reference through the dynamic write mask + bool stencil_replace = false; + + /// Clamps depth instead of clipping so casters in front of a shadow view still occlude + bool depth_clamp = false; + float depth_bias_constant = 0.0f; float depth_bias_slope = 0.0f; bool blend_enable = false; diff --git a/engine/src/toast/renderer/vulkan_renderer.cpp b/engine/src/toast/renderer/vulkan_renderer.cpp index 380c8bb66..40a875dcd 100644 --- a/engine/src/toast/renderer/vulkan_renderer.cpp +++ b/engine/src/toast/renderer/vulkan_renderer.cpp @@ -4,6 +4,7 @@ #include "vulkan_renderer.hpp" +#include "blue_noise.hpp" #include "clustered_lighting_constants.hpp" #include "cube_face_basis.hpp" #include "editor_overlays.hpp" @@ -14,17 +15,21 @@ #include "passes/environment_pass.hpp" #include "passes/material_pass.hpp" #include "passes/reflection_probe_pass.hpp" +#include "passes/selection_outline.hpp" #include "passes/shadow_pass.hpp" #include "passes/skinning_pass.hpp" #include "passes/voxel_pass.hpp" #include "ray_tracing_scene.hpp" +#include "renderer_settings.hpp" #include "shadow_slots.hpp" #include "skinned_blas_pool.hpp" #include "voxel_debug.hpp" #include "vulkan_debug.hpp" +#include #include #include +#include #include #include #include @@ -44,6 +49,7 @@ #include #include #include +#include #include #include #include @@ -52,6 +58,7 @@ #include #include #include +#include #include #include #include @@ -100,9 +107,6 @@ struct DirectionalCascadeFit { constexpr float k_caster_back_off = 100.0f; -/// Share of the slice radius a cascade covers beyond its slice so small camera moves reuse its cached map -constexpr float k_cascade_padding = 0.15f; - struct BoundingSphere { glm::vec3 center {0.0f}; float radius = 0.0f; @@ -149,7 +153,7 @@ auto fitDirectionalCascade(glm::vec3 center, float radius, glm::vec3 light_direc const glm::mat4 light_view = glm::lookAt(center - direction * (radius + k_caster_back_off), center, up); - const float depth_extent = (2.0f * radius) + k_caster_back_off; + const float depth_extent = (radius * (1.0f + shadows::cascadeReceiverDepthRadii())) + k_caster_back_off; // orthoRH_ZO since GLM_FORCE_DEPTH_ZERO_TO_ONE is not defined glm::mat4 light_projection = glm::orthoRH_ZO(-radius, radius, -radius, radius, 0.0f, depth_extent); @@ -164,21 +168,31 @@ auto fitDirectionalCascade(glm::vec3 center, float radius, glm::vec3 light_direc return DirectionalCascadeFit { .view_projection = light_projection * light_view, - .cull_sphere = glm::vec4(center, radius + k_caster_back_off), + .cull_sphere = glm::vec4(center, radius), .depth_bias = k_world_depth_bias / depth_extent, .texel_world_size = 2.0f * radius / static_cast(resolution), }; } +/// Fades out over the slack past the shadow distance before the slot is released +auto punctualShadowStrength(float camera_distance, float shadow_distance) -> float { + if (shadow_distance <= 0.0f) { + return 1.0f; + } + const float fade_end = shadow_distance * shadow_slots::k_distance_slack; + return std::clamp((fade_end - camera_distance) / (fade_end - shadow_distance), 0.0f, 1.0f); +} + auto computeCascadeSplits(float near_plane, float far_plane) -> std::array { std::array splits {}; const float range = far_plane - near_plane; + const float lambda = shadows::cascadeSplitLambda(); for (uint32_t i = 0; i < shadows::k_cascade_count; ++i) { const float fraction = static_cast(i + 1) / static_cast(shadows::k_cascade_count); const float logarithmic = near_plane * std::pow(far_plane / near_plane, fraction); const float uniform = near_plane + (range * fraction); - splits[i] = glm::mix(uniform, logarithmic, shadows::k_cascade_split_lambda); + splits[i] = glm::mix(uniform, logarithmic, lambda); } return splits; @@ -450,6 +464,34 @@ auto voxelFragmentRenderId(physics::ShapeID shape) -> uint64_t { return 0xF7A6'1D00'0000'0000ull ^ ((static_cast(shape.slot) << 32) | shape.generation); } +/// Float in [1 2) whose mantissa hashes the light so ReSTIR finds it after rebuilds +[[nodiscard]] +auto lightIdBits(uint64_t key) -> float { + uint64_t hash = key * 0x9E3779B97F4A7C15ull; + hash ^= hash >> 29u; + return std::bit_cast(static_cast(hash & 0x007FFFFFull) | 0x3F800000u); +} + +/// Rect width axis perpendicular to the facing even when up is parallel to it +[[nodiscard]] +auto areaTangent(const glm::vec3& forward, const glm::vec3& up) -> glm::vec3 { + const glm::vec3 side = glm::cross(forward, up); + if (glm::dot(side, side) > 1e-8f) { + return glm::normalize(side); + } + const glm::vec3 fallback = std::abs(forward.y) < 0.99f ? glm::vec3(0.0f, 1.0f, 0.0f) : glm::vec3(1.0f, 0.0f, 0.0f); + return glm::normalize(glm::cross(forward, fallback)); +} + +[[nodiscard]] +auto packedGlassBricks(const voxel::gpu::PackedScene& scene) -> std::vector> { + std::vector> out(scene.records.size()); + for (uint32_t record = 0; record < scene.records.size(); ++record) { + out[record] = voxel::gpu::glassBricks(scene, record); + } + return out; +} + } auto VulkanRenderer::getPrepassDrawnCount() const noexcept -> uint32_t { @@ -500,8 +542,8 @@ void VulkanRenderer::assignShadowSlots( } void VulkanRenderer::releaseShadowSlots() { - m_spot_slots = {}; - m_point_slots = {}; + std::ranges::fill(m_spot_slots, shadow_slots::Slot {}); + std::ranges::fill(m_point_slots, shadow_slots::Slot {}); } auto VulkanRenderer::ownerTransform(const toast::Node& node) const -> const glm::mat4* { @@ -723,6 +765,10 @@ VulkanRenderer::VulkanRenderer(const VulkanCore& core, std::unique_ptr(*m_core, m_depth_format, m_output_target->getExtent()); + m_selection_outline = std::make_unique( + *m_core, m_output_target->getColorFormat(), m_depth_format, m_output_target->getExtent() + ); + m_skinning_pass = std::make_unique(*m_core); if (m_core->isRayTracingSupported()) { @@ -759,6 +805,9 @@ VulkanRenderer::VulkanRenderer(const VulkanCore& core, std::unique_ptr([this](const auto& e) { m_imgui_key_events_accum.push_back({e.key, e.action != 0}); + if (e.key == SDLK_F3 && e.action == event::window_input_pressed && m_game_view.load(std::memory_order_relaxed)) { + toast::settings::Settings::get().setValue(k_debug_overlay_in_game, toast::settings::Value {!debugOverlayInGame()}); + } return false; }); m_imgui_input_listener.subscribe([this](const auto& e) { @@ -904,13 +953,29 @@ auto VulkanRenderer::createDepthResources() -> void { image_ci.arrayLayers = 1; image_ci.samples = vk::SampleCountFlagBits::e1; image_ci.tiling = vk::ImageTiling::eOptimal; - image_ci.usage = vk::ImageUsageFlagBits::eDepthStencilAttachment | vk::ImageUsageFlagBits::eSampled; + image_ci.usage = + vk::ImageUsageFlagBits::eDepthStencilAttachment | vk::ImageUsageFlagBits::eSampled | vk::ImageUsageFlagBits::eTransferSrc; image_ci.sharingMode = vk::SharingMode::eExclusive; image_ci.initialLayout = vk::ImageLayout::eUndefined; vma::AllocationCreateInfo allocation_ci {}; allocation_ci.usage = vma::MemoryUsage::eAutoPreferDevice; + m_glass_backdrop_depth.view.reset(); + m_glass_backdrop_depth.image.reset(); + vk::ImageCreateInfo backdrop_ci = image_ci; + backdrop_ci.usage = vk::ImageUsageFlagBits::eSampled | vk::ImageUsageFlagBits::eTransferDst; + m_glass_backdrop_depth.image.emplace(m_core->getAllocator().createImage(backdrop_ci, allocation_ci)); + setDebugName(*m_core, **m_glass_backdrop_depth.image, "VulkanRenderer GlassBackdropDepthImage"); + vk::ImageViewCreateInfo backdrop_view_ci {}; + backdrop_view_ci.image = **m_glass_backdrop_depth.image; + backdrop_view_ci.viewType = vk::ImageViewType::e2D; + backdrop_view_ci.format = m_depth_format; + backdrop_view_ci.subresourceRange = vk::ImageSubresourceRange(vk::ImageAspectFlagBits::eDepth, 0, 1, 0, 1); + m_glass_backdrop_depth.view.emplace(m_core->getDevice(), backdrop_view_ci); + setDebugName(*m_core, **m_glass_backdrop_depth.view, "VulkanRenderer GlassBackdropDepthView"); + m_glass_backdrop_depth.state.reset(**m_glass_backdrop_depth.image, depthAttachmentRange(m_depth_format)); + auto depth_image = m_core->getAllocator().createImage(image_ci, allocation_ci); m_depth_resources.image.emplace(std::move(depth_image)); setDebugName(*m_core, **m_depth_resources.image, "VulkanRenderer DepthImage"); @@ -936,11 +1001,13 @@ void VulkanRenderer::createSceneColorResources() { TOAST_CRITICAL("Render", "Toast Engine Error: VulkanRenderer requires a non-zero output extent for the scene target!"); } - const auto create = [&](SceneColorResources& target, vk::Format format, std::string_view label) { + const auto create = [&]( + SceneColorResources& target, vk::Format format, std::string_view label, vk::ImageUsageFlags usage = {} + ) { target.view.reset(); target.image.reset(); - const auto image_ci = colorTargetImageInfo(extent, format); + const auto image_ci = colorTargetImageInfo(extent, format, usage); vma::AllocationCreateInfo allocation_ci {}; allocation_ci.usage = vma::MemoryUsage::eAutoPreferDevice; @@ -960,13 +1027,20 @@ void VulkanRenderer::createSceneColorResources() { TOAST_TRACE("Render", "{} target created at {}x{} with format {}", label, extent.width, extent.height, vk::to_string(format)); }; - create(m_scene_color, m_scene_color_format, "SceneColor"); + create(m_scene_color, m_scene_color_format, "SceneColor", vk::ImageUsageFlagBits::eTransferSrc); + create(m_glass_backdrop, m_scene_color_format, "GlassBackdrop", vk::ImageUsageFlagBits::eTransferDst); create(m_scene_normal, m_scene_normal_format, "SceneNormal"); create(m_scene_indirect, m_scene_indirect_format, "SceneIndirect"); create(m_scene_motion, m_scene_motion_format, "SceneMotion"); create(m_voxel_albedo, k_voxel_albedo_format, "VoxelAlbedo"); create(m_voxel_material, k_voxel_material_format, "VoxelMaterial"); create(m_voxel_face_normal, k_voxel_face_normal_format, "VoxelFaceNormal"); + create(m_voxel_noisy_diffuse, k_voxel_noisy_format, "VoxelNoisyDiffuse"); + create(m_voxel_noisy_specular, k_voxel_noisy_format, "VoxelNoisySpecular"); + create(m_voxel_reservoirs[0], k_voxel_reservoir_format, "VoxelReservoirs0"); + create(m_voxel_reservoirs[1], k_voxel_reservoir_format, "VoxelReservoirs1"); + create(m_voxel_candidate_reservoirs, k_voxel_reservoir_format, "VoxelCandidateReservoirs"); + m_voxel_reservoir_written = false; } void VulkanRenderer::createDescriptorPool() { @@ -1142,6 +1216,23 @@ void VulkanRenderer::createFrameResources() { create_instance_buffers(m_instance_res, "Instances"); create_instance_buffers(m_shadow_instance_res, "ShadowInstances"); + + { + const std::vector noise = blue_noise::generatePacked(); + vk::BufferCreateInfo buffer_ci {}; + buffer_ci.size = noise.size() * sizeof(uint32_t); + buffer_ci.usage = vk::BufferUsageFlagBits::eStorageBuffer; + + vma::AllocationCreateInfo alloc_ci {}; + alloc_ci.usage = vma::MemoryUsage::eAutoPreferHost; + alloc_ci.flags = vma::AllocationCreateFlagBits::eMapped | vma::AllocationCreateFlagBits::eHostAccessSequentialWrite; + + m_blue_noise_buffer.emplace(m_core->getAllocator().createBuffer(buffer_ci, alloc_ci)); + setDebugName(*m_core, **m_blue_noise_buffer, "VulkanRenderer BlueNoise"); + const auto& allocation = m_blue_noise_buffer->getAllocation(); + std::memcpy(allocation.getInfo().pMappedData, noise.data(), buffer_ci.size); + allocation.flush(0, buffer_ci.size); + } } void VulkanRenderer::createFailsafeTextures() { @@ -1296,9 +1387,7 @@ void VulkanRenderer::publishCompletedFrames() { break; } - if (!pending.was_probe_capture) { - m_output_target->onImageRenderComplete(pending.last_image_index); - } + m_output_target->onImageRenderComplete(pending.last_image_index, !pending.was_probe_capture); pending.has_submitted = false; m_pending_publish.pop_front(); @@ -1396,7 +1485,9 @@ void VulkanRenderer::recordAccelerationStructureBuilds(FrameContext& frame) { } } -void VulkanRenderer::recordMeshScene(vk::CommandBuffer cmd, uint32_t image_index) { +void VulkanRenderer::recordMeshScene( + vk::CommandBuffer cmd, uint32_t image_index, bool voxel_glass, const std::function& capture_backdrop +) { // Opaque first since m_material_passes is unordered { const GpuScope scope(m_gpu_timer.get(), m_current_frame, cmd, "Opaque materials"); @@ -1409,6 +1500,24 @@ void VulkanRenderer::recordMeshScene(vk::CommandBuffer cmd, uint32_t image_index } } + // Before blended draws since the sky overwrites far plane pixels + if (m_environment_pass != nullptr && m_environment_pass->isEnabled()) { + TracyVkZone(m_tracy_vk_ctx, cmd, "WorldPass"); + const GpuScope scope(m_gpu_timer.get(), m_current_frame, cmd, m_environment_pass->name()); + m_environment_pass->record(cmd, m_current_frame, image_index); + } + + // Before anything blended so the backdrop holds only opaque and sky + const bool refract = voxel_glass && m_voxel_pass != nullptr && !m_voxel_pass->glassDraws().empty() && + m_voxel_pass->refractsGlass() && capture_backdrop; + if (refract) { + const GpuScope scope(m_gpu_timer.get(), m_current_frame, cmd, "Glass backdrop"); + capture_backdrop(); + } + if (m_voxel_pass != nullptr) { + m_voxel_pass->setBackdropCaptured(refract); + } + { const auto* render_frame = renderingFrame(); if (render_frame != nullptr) { @@ -1441,11 +1550,40 @@ void VulkanRenderer::recordMeshScene(vk::CommandBuffer cmd, uint32_t image_index return a.pass < b.pass; }); + const std::span glass = + voxel_glass && m_voxel_pass != nullptr ? m_voxel_pass->glassDraws() : std::span {}; + size_t next_glass = 0; + // Glass bricks at least as far as the next mesh go first as one run + const auto record_glass_until = [&](float distance_squared) { + size_t end = next_glass; + while (end < glass.size() && glass[end].distance_squared >= distance_squared) { + ++end; + } + if (end > next_glass) { + m_voxel_pass->recordGlass( + cmd, m_current_frame, static_cast(next_glass), static_cast(end - next_glass) + ); + next_glass = end; + } + }; + TracyVkZone(m_tracy_vk_ctx, cmd, "BlendedDraws"); - const GpuScope scope(m_gpu_timer.get(), m_current_frame, cmd, "Blended materials"); + const GpuScope scope(m_gpu_timer.get(), m_current_frame, cmd, "Blended materials and glass"); for (const auto& draw : draws) { + record_glass_until(draw.distance_squared); draw.pass->recordInstance(cmd, m_current_frame, *draw.proxy); } + record_glass_until(-1.0f); + + // One run after voxel glass since the pieces are small and mostly in front of it + if (m_debris_pass != nullptr && m_debris_pass->isEnabled()) { + m_debris_pass->recordGlass(cmd, m_current_frame); + } + + // Last so the glass depth it writes never rejects a blended draw behind the glass + if (!glass.empty()) { + m_voxel_pass->recordGlassSurfaces(cmd, m_current_frame); + } } } } @@ -1454,10 +1592,12 @@ auto VulkanRenderer::recordVoxelScopes( FrameContext& frame, const vk::Viewport& viewport, const vk::Rect2D& scissor, bool depth_loaded ) -> bool { ZoneScoped; - const bool targets_ready = m_depth_resources.view.has_value() && m_voxel_albedo.view.has_value() && - m_voxel_material.view.has_value() && m_voxel_face_normal.view.has_value() && - m_scene_color.view.has_value() && m_scene_normal.view.has_value() && - m_scene_indirect.view.has_value() && m_scene_motion.view.has_value(); + const bool targets_ready = + m_depth_resources.view.has_value() && m_voxel_albedo.view.has_value() && m_voxel_material.view.has_value() && + m_voxel_face_normal.view.has_value() && m_scene_color.view.has_value() && m_scene_normal.view.has_value() && + m_scene_indirect.view.has_value() && m_scene_motion.view.has_value() && m_voxel_noisy_diffuse.view.has_value() && + m_voxel_noisy_specular.view.has_value() && m_voxel_reservoirs[0].view.has_value() && + m_voxel_reservoirs[1].view.has_value() && m_voxel_candidate_reservoirs.view.has_value(); if (m_voxel_pass == nullptr || !m_voxel_pass->isEnabled() || !targets_ready || m_depth_resources.state.usage != sync::Usage::depth_attachment || !m_voxel_pass->prepare(m_current_frame)) { return false; @@ -1600,13 +1740,30 @@ auto VulkanRenderer::recordVoxelScopes( barriers.image(m_scene_color.state, sync::Usage::color_attachment); barriers.image(m_scene_indirect.state, sync::Usage::color_attachment); barriers.image(m_scene_normal.state, sync::Usage::color_attachment); + barriers.image(m_voxel_noisy_diffuse.state, sync::Usage::color_attachment); + barriers.image(m_voxel_noisy_specular.state, sync::Usage::color_attachment); + const bool spatial = m_voxel_pass->spatialActive(); + SceneColorResources& lighting_reservoirs = + spatial ? m_voxel_candidate_reservoirs : m_voxel_reservoirs[m_voxel_reservoir_parity]; + barriers.image(lighting_reservoirs.state, sync::Usage::color_attachment); + barriers.image(m_voxel_reservoirs[1 - m_voxel_reservoir_parity].state, sync::Usage::fragment_sampled); + barriers.image(m_scene_motion.state, sync::Usage::fragment_sampled); barriers.flush(cmd); + // A frame without voxel lighting in between or a probe capture leaves reservoirs another view wrote + const auto* lit_frame = renderingFrame(); + const uint64_t sequence = lit_frame != nullptr ? lit_frame->sequence : 0; + const bool capture = lit_frame != nullptr && (lit_frame->probe_capture_index >= 0 || lit_frame->irradiance_capture_index >= 0); + m_voxel_reservoir_history = !capture && m_voxel_reservoir_written && sequence == m_voxel_reservoir_sequence + 1; + // Clears what no voxel covers since the world scope now loads instead const std::array lighting_attachments { attachment(m_scene_color, vk::AttachmentLoadOp::eClear, clear_scene), attachment(m_scene_indirect, vk::AttachmentLoadOp::eClear, clear_zero), attachment(m_scene_normal, vk::AttachmentLoadOp::eClear, clear_zero), + attachment(m_voxel_noisy_diffuse, vk::AttachmentLoadOp::eClear, clear_zero), + attachment(m_voxel_noisy_specular, vk::AttachmentLoadOp::eClear, clear_zero), + attachment(lighting_reservoirs, vk::AttachmentLoadOp::eClear, clear_zero), }; vk::RenderingInfo lighting_info {}; @@ -1625,6 +1782,72 @@ auto VulkanRenderer::recordVoxelScopes( } cmd.endRendering(); + if (spatial) { + barriers.image(m_voxel_candidate_reservoirs.state, sync::Usage::fragment_sampled); + barriers.image(m_scene_normal.state, sync::Usage::fragment_sampled); + barriers.image(m_voxel_reservoirs[m_voxel_reservoir_parity].state, sync::Usage::color_attachment); + barriers.image(m_voxel_noisy_diffuse.state, sync::Usage::color_attachment); + barriers.image(m_voxel_noisy_specular.state, sync::Usage::color_attachment); + barriers.flush(cmd); + + const std::array spatial_attachments { + attachment(m_voxel_noisy_diffuse, vk::AttachmentLoadOp::eLoad, clear_zero), + attachment(m_voxel_noisy_specular, vk::AttachmentLoadOp::eLoad, clear_zero), + attachment(m_voxel_reservoirs[m_voxel_reservoir_parity], vk::AttachmentLoadOp::eClear, clear_zero), + }; + vk::RenderingInfo spatial_info {}; + spatial_info.renderArea = scissor; + spatial_info.layerCount = 1; + spatial_info.colorAttachmentCount = static_cast(spatial_attachments.size()); + spatial_info.pColorAttachments = spatial_attachments.data(); + + cmd.beginRendering(spatial_info); + cmd.setViewport(0, std::array {viewport}); + cmd.setScissor(0, std::array {scissor}); + { + TracyVkZone(m_tracy_vk_ctx, cmd, "VoxelSpatialReuse"); + const GpuScope scope(m_gpu_timer.get(), m_current_frame, cmd, "Voxel spatial reuse"); + m_voxel_pass->recordSpatial(cmd, m_current_frame, scissor.extent); + } + cmd.endRendering(); + barriers.image(m_scene_normal.state, sync::Usage::color_attachment); + barriers.flush(cmd); + } + m_voxel_reservoir_parity = 1 - m_voxel_reservoir_parity; + m_voxel_reservoir_written = !capture; + m_voxel_reservoir_sequence = sequence; + + if (m_voxel_pass->denoiseActive()) { + barriers.image(m_voxel_noisy_diffuse.state, sync::Usage::fragment_sampled); + barriers.image(m_voxel_noisy_specular.state, sync::Usage::fragment_sampled); + barriers.image(m_scene_motion.state, sync::Usage::fragment_sampled); + barriers.flush(cmd); + { + TracyVkZone(m_tracy_vk_ctx, cmd, "VoxelDenoiseTemporal"); + const GpuScope scope(m_gpu_timer.get(), m_current_frame, cmd, "Voxel denoise temporal"); + m_voxel_pass->recordDenoiseTemporal(cmd, m_current_frame); + } + + barriers.image(m_scene_color.state, sync::Usage::color_attachment); + barriers.flush(cmd); + const std::array composite_attachments {attachment(m_scene_color, vk::AttachmentLoadOp::eLoad, clear_scene)}; + vk::RenderingInfo composite_info {}; + composite_info.renderArea = scissor; + composite_info.layerCount = 1; + composite_info.colorAttachmentCount = static_cast(composite_attachments.size()); + composite_info.pColorAttachments = composite_attachments.data(); + + cmd.beginRendering(composite_info); + cmd.setViewport(0, std::array {viewport}); + cmd.setScissor(0, std::array {scissor}); + { + TracyVkZone(m_tracy_vk_ctx, cmd, "VoxelDenoiseComposite"); + const GpuScope scope(m_gpu_timer.get(), m_current_frame, cmd, "Voxel denoise composite"); + m_voxel_pass->recordDenoiseComposite(cmd, m_current_frame); + } + cmd.endRendering(); + } + // The world scope loads all of these so writes above must land first barriers.image(m_scene_normal.state, sync::Usage::color_attachment); barriers.image(m_scene_color.state, sync::Usage::color_attachment); @@ -1710,6 +1933,11 @@ auto VulkanRenderer::recordFrame(FrameContext& frame, uint32_t image_index) noex barriers.image(m_scene_indirect.state, sync::Usage::color_attachment); barriers.image(m_scene_motion.state, sync::Usage::color_attachment); barriers.image(m_depth_resources.state, sync::Usage::depth_attachment); + // Glass binds the backdrop every frame whether or not it was captured + if (m_glass_backdrop.image && m_glass_backdrop_depth.image) { + barriers.image(m_glass_backdrop.state, sync::Usage::fragment_sampled); + barriers.image(m_glass_backdrop_depth.state, sync::Usage::depth_sampled); + } barriers.flush(*frame.command_buffer); const vk::ClearValue clear_color(vk::ClearColorValue(std::array {0.0f, 0.0f, 0.0f, 1.0f})); @@ -1806,6 +2034,62 @@ auto VulkanRenderer::recordFrame(FrameContext& frame, uint32_t image_index) noex frame.command_buffer.setViewport(0, std::array {scene_viewport}); frame.command_buffer.setScissor(0, std::array {scene_scissor}); + const auto capture_glass_backdrop = [&] { + if (!m_glass_backdrop.image || !m_glass_backdrop_depth.image || scene_image == VK_NULL_HANDLE || !m_depth_resources.image) { + return; + } + const vk::CommandBuffer cmd = *frame.command_buffer; + cmd.endRendering(); + + sync::BarrierBatch to_copy; + to_copy.image(m_scene_color.state, sync::Usage::transfer_src); + to_copy.image(m_depth_resources.state, sync::Usage::transfer_src); + to_copy.image(m_glass_backdrop.state, sync::Usage::transfer_dst); + to_copy.image(m_glass_backdrop_depth.state, sync::Usage::transfer_dst); + to_copy.flush(cmd); + + const vk::Extent2D target_extent = m_output_target->getExtent(); + const vk::Extent3D copy_extent {target_extent.width, target_extent.height, 1}; + const vk::ImageSubresourceLayers color_layers(vk::ImageAspectFlagBits::eColor, 0, 0, 1); + const vk::ImageSubresourceLayers depth_layers(vk::ImageAspectFlagBits::eDepth, 0, 0, 1); + cmd.copyImage( + scene_image, + vk::ImageLayout::eTransferSrcOptimal, + **m_glass_backdrop.image, + vk::ImageLayout::eTransferDstOptimal, + vk::ImageCopy(color_layers, {}, color_layers, {}, copy_extent) + ); + cmd.copyImage( + **m_depth_resources.image, + vk::ImageLayout::eTransferSrcOptimal, + **m_glass_backdrop_depth.image, + vk::ImageLayout::eTransferDstOptimal, + vk::ImageCopy(depth_layers, {}, depth_layers, {}, copy_extent) + ); + + sync::BarrierBatch resume; + resume.image(m_scene_color.state, sync::Usage::color_attachment); + resume.image(m_depth_resources.state, sync::Usage::depth_attachment); + resume.image(m_glass_backdrop.state, sync::Usage::fragment_sampled); + resume.image(m_glass_backdrop_depth.state, sync::Usage::depth_sampled); + resume.flush(cmd); + + std::array resume_colors = scene_color_attachments; + for (auto& attachment : resume_colors) { + attachment.loadOp = vk::AttachmentLoadOp::eLoad; + } + vk::RenderingAttachmentInfo resume_depth = depth_attachment_info; + resume_depth.loadOp = vk::AttachmentLoadOp::eLoad; + vk::RenderingInfo resume_info = scene_rendering_info; + resume_info.pColorAttachments = resume_colors.data(); + if (m_depth_resources.view.has_value()) { + resume_info.pDepthAttachment = &resume_depth; + } + cmd.beginRendering(resume_info); + cmd.setViewport(0, std::array {scene_viewport}); + cmd.setScissor(0, std::array {scene_scissor}); + }; + { TracyVkZone(m_tracy_vk_ctx, *frame.command_buffer, "Scene"); const GpuScope scene_scope(m_gpu_timer.get(), m_current_frame, *frame.command_buffer, "Scene"); @@ -1813,7 +2097,8 @@ auto VulkanRenderer::recordFrame(FrameContext& frame, uint32_t image_index) noex const auto record_stage = [&](RenderStage stage) { for (auto& pass : m_render_passes) { - if (!pass->isEnabled() || pass->stage() != stage) { + // recordMeshScene draws the sky + if (!pass->isEnabled() || pass->stage() != stage || pass.get() == m_environment_pass) { continue; } TracyVkZone(m_tracy_vk_ctx, *frame.command_buffer, "WorldPass"); @@ -1822,7 +2107,7 @@ auto VulkanRenderer::recordFrame(FrameContext& frame, uint32_t image_index) noex } }; - recordMeshScene(*frame.command_buffer, image_index); + recordMeshScene(*frame.command_buffer, image_index, voxels_drawn, capture_glass_backdrop); record_stage(RenderStage::world); } @@ -1887,6 +2172,11 @@ auto VulkanRenderer::recordFrame(FrameContext& frame, uint32_t image_index) noex } } + if (!capture_frame && m_selection_outline != nullptr && m_depth_resources.view.has_value()) { + const GpuScope scope(m_gpu_timer.get(), m_current_frame, *frame.command_buffer, "Selection mask"); + m_selection_outline->recordMask(*frame.command_buffer, m_current_frame, **m_depth_resources.view); + } + // The overlay scope depth tests against the scene and its DontCare store counts as a write barriers.image(m_depth_resources.state, sync::Usage::depth_attachment); @@ -1954,6 +2244,11 @@ auto VulkanRenderer::recordFrame(FrameContext& frame, uint32_t image_index) noex } } + if (!capture_frame && m_selection_outline != nullptr) { + const GpuScope scope(m_gpu_timer.get(), m_current_frame, *frame.command_buffer, "Selection outline"); + m_selection_outline->recordOutline(*frame.command_buffer, m_current_frame); + } + if (!capture_frame) { std::lock_guard lock(m_pass_mutex); const GpuScope overlay_scope(m_gpu_timer.get(), m_current_frame, *frame.command_buffer, "Overlays"); @@ -2113,15 +2408,15 @@ void VulkanRenderer::updateFrameResources(uint32_t frame_index, RenderFrame& fra upload_instances(m_shadow_instance_res, frame_index, frame_data.shadow_instance_data); } -auto VulkanRenderer::drawFrame(RenderFrame& frame_data) -> void { +auto VulkanRenderer::drawFrame(RenderFrame& frame_data) -> bool { ZoneScoped; if (m_frames.empty()) { - return; + return false; } if (!m_output_target->isPresentable()) { std::this_thread::sleep_for(std::chrono::milliseconds(50)); - return; + return false; } using clock = std::chrono::steady_clock; @@ -2159,7 +2454,12 @@ auto VulkanRenderer::drawFrame(RenderFrame& frame_data) -> void { if (acquired.result == vk::Result::eErrorOutOfDateKHR) { TOAST_WARN("Render", "Swapchain out of date on acquire; recreating"); applyResize(m_output_target->getExtent()); - return; + return false; + } + if (acquired.result == vk::Result::eNotReady) { + waitForOutputImage(); + end_phase(m_perf_acquire_ns); + return false; } if (acquired.result != vk::Result::eSuccess && acquired.result != vk::Result::eSuboptimalKHR) { TOAST_CRITICAL("Render", "Toast Engine Error: Failed to acquire the next output image!"); @@ -2261,6 +2561,8 @@ auto VulkanRenderer::drawFrame(RenderFrame& frame_data) -> void { std::scoped_lock submit_lock(m_core->graphicsSubmitMutex()); sync::submit(m_core->getGraphicsQueue(), submit); } + // Steps the tick queued run on the compute queue while this frame renders + m_core->asyncCompute().flushDeferred(); end_phase(m_perf_submit_ns); #if defined(_WIN32) @@ -2289,16 +2591,39 @@ auto VulkanRenderer::drawFrame(RenderFrame& frame_data) -> void { TOAST_WARN("Render", "Swapchain out of date or suboptimal on present; recreating"); m_rendering_frame = nullptr; applyResize(m_output_target->getExtent()); - return; + return true; } if (present_result != vk::Result::eSuccess) { TOAST_CRITICAL("Render", "Toast Engine Error: Failed to present the current output image!"); } m_rendering_frame = nullptr; + return true; +} + +void VulkanRenderer::waitForOutputImage() { + ZoneScoped; + using clock = std::chrono::steady_clock; + constexpr auto k_budget = std::chrono::milliseconds(8); + constexpr auto k_step = std::chrono::milliseconds(1); + const auto deadline = clock::now() + k_budget; + while (clock::now() < deadline) { + // An image the GPU still renders only frees once this thread publishes it + publishCompletedFrames(); + if (!m_pending_publish.empty()) { + const auto& oldest = m_frames[m_pending_publish.front()]; + std::ignore = m_core->getDevice().waitForFences( + *oldest.in_flight, true, static_cast(std::chrono::nanoseconds(k_step).count()) + ); + publishCompletedFrames(); + } + if (m_output_target->waitForFreeImage(k_step)) { + return; + } + } } -void VulkanRenderer::mainRenderThread() { +void VulkanRenderer::mainRenderThread() try { tracy::SetThreadName("Renderer Thread"); #ifdef TRACY_ENABLE @@ -2422,30 +2747,6 @@ void VulkanRenderer::mainRenderThread() { Time::get().renderTick(); - { - static constexpr size_t k_window = 60; - const auto now = clock::now(); - if (last_frame_time.time_since_epoch().count() != 0) { - const auto interval_ms = std::chrono::duration(now - last_frame_time).count(); - frame_time_window[frame_time_cursor % k_window] = interval_ms; - ++frame_time_cursor; - - const size_t filled = std::min(frame_time_cursor, k_window); - float sum = 0.0f; - float lowest = std::numeric_limits::max(); - float highest = 0.0f; - for (size_t i = 0; i < filled; ++i) { - sum += frame_time_window[i]; - lowest = std::min(lowest, frame_time_window[i]); - highest = std::max(highest, frame_time_window[i]); - } - m_frame_time_avg_ms.store(sum / static_cast(filled), std::memory_order_relaxed); - m_frame_time_min_ms.store(lowest, std::memory_order_relaxed); - m_frame_time_max_ms.store(highest, std::memory_order_relaxed); - } - last_frame_time = now; - } - const bool do_capture = m_capture_frame_requested.exchange(false, std::memory_order_acq_rel); const auto* rdoc_api = m_core->getRenderDocAPI(); const auto nsight_mode = m_core->getNsightMode(); @@ -2473,14 +2774,40 @@ void VulkanRenderer::mainRenderThread() { #endif const auto draw_start = clock::now(); - drawFrame(frame_to_draw); - m_perf_draw_work_ns.fetch_add( - static_cast(std::chrono::duration_cast(clock::now() - draw_start).count()), - std::memory_order_relaxed - ); - m_perf_draws.fetch_add(1, std::memory_order_relaxed); - if (!consumed_queued_frame) { - m_perf_repeated_draws.fetch_add(1, std::memory_order_relaxed); + // A pass with no free output image rendered nothing + if (drawFrame(frame_to_draw)) { + m_perf_draw_work_ns.fetch_add( + static_cast(std::chrono::duration_cast(clock::now() - draw_start).count()), + std::memory_order_relaxed + ); + m_perf_draws.fetch_add(1, std::memory_order_relaxed); + if (!consumed_queued_frame) { + m_perf_repeated_draws.fetch_add(1, std::memory_order_relaxed); + } + + { + static constexpr size_t k_window = 60; + const auto now = clock::now(); + if (last_frame_time.time_since_epoch().count() != 0) { + const auto interval_ms = std::chrono::duration(now - last_frame_time).count(); + frame_time_window[frame_time_cursor % k_window] = interval_ms; + ++frame_time_cursor; + + const size_t filled = std::min(frame_time_cursor, k_window); + float sum = 0.0f; + float lowest = std::numeric_limits::max(); + float highest = 0.0f; + for (size_t i = 0; i < filled; ++i) { + sum += frame_time_window[i]; + lowest = std::min(lowest, frame_time_window[i]); + highest = std::max(highest, frame_time_window[i]); + } + m_frame_time_avg_ms.store(sum / static_cast(filled), std::memory_order_relaxed); + m_frame_time_min_ms.store(lowest, std::memory_order_relaxed); + m_frame_time_max_ms.store(highest, std::memory_order_relaxed); + } + last_frame_time = now; + } } if (do_capture && rdoc_api != nullptr) { @@ -2526,6 +2853,11 @@ void VulkanRenderer::mainRenderThread() { FrameMarkNamed("RenderFrame"); } +} catch (const vk::DeviceLostError& error) { + // Nothing recovers a lost device and the abort would otherwise drop the queued logs + TOAST_CRITICAL("Render", "GPU device lost on the render thread, shutting down: {}", error.what()); + logging::flush(); + std::abort(); } void VulkanRenderer::start() noexcept { @@ -2545,6 +2877,16 @@ void VulkanRenderer::setRenderingPaused(bool paused) noexcept { m_frame_cv.notify_all(); } +void VulkanRenderer::setGameView(bool game_view) { + m_game_view.store(game_view, std::memory_order_relaxed); + const bool owns = game_view && debugWindowsVisible() && debugOverlayInGame(); + if (owns != m_debug_overlay_owns_cursor.load(std::memory_order_relaxed)) { + // Queued first so whatever the editor sends after polling the flag lands behind it + event::send(owns); + m_debug_overlay_owns_cursor.store(owns, std::memory_order_release); + } +} + void VulkanRenderer::submitFrame() noexcept { { std::lock_guard lock(m_queue_mutex); @@ -2575,6 +2917,7 @@ void VulkanRenderer::fitShadowViews( const glm::vec3 light_direction = glm::normalize(shadow_caster_direction); const uint32_t resolution = shadows::cascadeResolution(); + const float padding = shadows::cascadePadding(); float split_near = m_camera->near_plane; for (uint32_t cascade = 0; cascade < shadows::k_cascade_count; ++cascade) { const BoundingSphere slice = frustumSliceSphere(*m_camera, aspect, split_near, splits[cascade]); @@ -2583,11 +2926,11 @@ void VulkanRenderer::fitShadowViews( CascadeAnchor& anchor = m_cascade_anchors[cascade]; const bool holds = anchor.resolution == resolution && glm::dot(anchor.light_direction, light_direction) > 0.99999f && glm::distance(anchor.center, slice.center) + slice.radius <= anchor.radius && - slice.radius * (1.0f + (2.0f * k_cascade_padding)) >= anchor.radius; + slice.radius * (1.0f + (2.0f * padding)) >= anchor.radius; if (!holds) { anchor = CascadeAnchor { .center = slice.center, - .radius = slice.radius * (1.0f + k_cascade_padding), + .radius = slice.radius * (1.0f + padding), .light_direction = light_direction, .resolution = resolution, }; @@ -2617,7 +2960,25 @@ void VulkanRenderer::fitShadowViews( frame.frame_data.shadow_params.y = static_cast(shadows::k_cascade_count); } - assignShadowSlots(spot_candidates, m_spot_slots); + // Slots past a lowered budget are released so their lights stop casting at once + const auto budget_slots = [](std::span slots, uint32_t budget) { + const size_t used = std::min(budget, slots.size()); + std::ranges::fill(slots.subspan(used), shadow_slots::Slot {}); + return slots.first(used); + }; + // Only lights reaching the view + const auto denied = [this](const std::vector& candidates) { + uint32_t count = 0; + for (size_t i = 0; i < candidates.size(); ++i) { + count += m_tick_slot_of[i] < 0 && candidates[i].visible ? 1u : 0u; + } + return count; + }; + + m_spot_slots.resize(shadows::spotShadowSlots()); + m_point_slots.resize(shadows::pointShadowSlots()); + assignShadowSlots(spot_candidates, budget_slots(m_spot_slots, shadows::spotShadowBudget())); + frame.light_stats.spot_denied = denied(spot_candidates); for (size_t i = 0; i < spot_candidates.size(); ++i) { const int32_t slot = m_tick_slot_of[i]; if (slot < 0) { @@ -2641,8 +3002,9 @@ void VulkanRenderer::fitShadowViews( const glm::mat4 view_projection = projection * view; auto& owner = m_spot_slots[static_cast(slot)]; - owner.resolution = - shadow_slots::stableResolution(owner.resolution, std::sqrt(candidate.distance_squared), candidate.resolution_scale); + owner.resolution = shadow_slots::stableResolution( + owner.resolution, std::sqrt(candidate.distance_squared), range, candidate.resolution_scale + ); const uint32_t resolution = owner.resolution; const uint32_t layer = shadows::k_spot_layer_base + static_cast(slot); @@ -2650,6 +3012,7 @@ void VulkanRenderer::fitShadowViews( light.cone_angles.z = shadows::k_punctual_near; light.cone_angles.w = range; light.shadow_atlas.x = static_cast(resolution) / static_cast(shadows::punctualResolution()); + light.shadow_atlas.z = punctualShadowStrength(std::sqrt(candidate.distance_squared), candidate.shadow_distance); light.shadow_view_projection = view_projection; frame.shadows.views.push_back( @@ -2664,7 +3027,8 @@ void VulkanRenderer::fitShadowViews( frame.shadows.matrices.push_back(view_projection); } - assignShadowSlots(point_candidates, m_point_slots); + assignShadowSlots(point_candidates, budget_slots(m_point_slots, shadows::pointShadowBudget())); + frame.light_stats.point_denied = denied(point_candidates); for (size_t i = 0; i < point_candidates.size(); ++i) { const int32_t slot = m_tick_slot_of[i]; if (slot < 0) { @@ -2677,15 +3041,17 @@ void VulkanRenderer::fitShadowViews( const float range = std::max(light.world_pos_range.w, 0.01f); auto& owner = m_point_slots[static_cast(slot)]; - owner.resolution = - shadow_slots::stableResolution(owner.resolution, std::sqrt(candidate.distance_squared), candidate.resolution_scale); + owner.resolution = shadow_slots::stableResolution( + owner.resolution, std::sqrt(candidate.distance_squared), range, candidate.resolution_scale + ); const uint32_t resolution = owner.resolution; - const uint32_t base_layer = shadows::k_point_layer_base + (static_cast(slot) * shadows::k_cube_faces); + const uint32_t base_layer = shadows::pointLayerBase() + (static_cast(slot) * shadows::k_cube_faces); light.direction_pad.w = static_cast(base_layer); light.cone_angles.z = shadows::k_punctual_near; light.cone_angles.w = range; light.shadow_atlas.x = static_cast(resolution) / static_cast(shadows::punctualResolution()); + light.shadow_atlas.z = punctualShadowStrength(std::sqrt(candidate.distance_squared), candidate.shadow_distance); const float guard_ndc_scale = static_cast(resolution) / (static_cast(resolution) + (2.0f * shadows::k_cube_face_guard_texels)); @@ -2979,6 +3345,7 @@ void VulkanRenderer::tick(float time) noexcept { .bounds_center = world_center, .bounds_radius = world_radius, .node_rid = node->rid(), + .outlined = isOutlined(node->rid()), } ); } @@ -3323,14 +3690,17 @@ void VulkanRenderer::tick(float time) noexcept { float cone_fraction = 1.0f; float camera_distance = 0.0f; float resolution_scale = 1.0f; + float shadow_distance = 0.0f; bool casts_shadows = false; + /// Marched and sampled lights shadow voxels by marching so they never take a shadow map slot + bool shadow_map = false; }; const auto submit_punctual = [&](const PunctualSubmit& submit, std::vector& candidates) { ++frame.light_stats.submitted; - const bool visible = sphereInFrustum(frustum, submit.bounds.center, submit.bounds.radius); - if (!visible && !submit.casts_shadows) { + // Its whole range is off screen so it neither lights nor shadows a visible pixel + if (!sphereInFrustum(frustum, submit.bounds.center, submit.bounds.radius)) { ++frame.light_stats.offscreen; return; } @@ -3340,15 +3710,18 @@ void VulkanRenderer::tick(float time) noexcept { glm::vec3(gpu.color_intensity), gpu.color_intensity.w, gpu.world_pos_range.w, submit.camera_distance, submit.cone_fraction ); - if (submit.casts_shadows) { + if (submit.shadow_map) { candidates.push_back( PunctualShadowCandidate { .light_index = frame.lights.size(), .distance_squared = submit.camera_distance * submit.camera_distance, .resolution_scale = submit.resolution_scale, - .visible = visible, + .shadow_distance = submit.shadow_distance, + .visible = true, .key = submit.key, - .importance = importance, + .importance = light_culling::shadowPriority( + glm::vec3(gpu.color_intensity), gpu.color_intensity.w, submit.camera_distance, submit.cone_fraction + ), } ); } @@ -3357,12 +3730,28 @@ void VulkanRenderer::tick(float time) noexcept { LightMeta { .importance = importance, .depth_reach = -(frame_view * glm::vec4(submit.bounds.center, 1.0f)).z + submit.bounds.radius, - .visible = visible, + .visible = true, } ); frame.lights.push_back(gpu); }; + // y plus z bounds the emitter so culling widens by it + const auto area_shape = [](const toast::Light& light, bool casts_shadows) -> glm::vec4 { + const float radius = std::max(light.sourceRadius(), 0.0f); + const float half_length = 0.5f * std::max(light.sourceLength(), 0.0f); + const float half_width = 0.5f * std::max(light.sourceWidth(), 0.0f); + const float mode = casts_shadows ? static_cast(light.shadowMode()) : 0.0f; + switch (light.shape()) { + case toast::LightShape::sphere: + case toast::LightShape::disc: return {static_cast(light.shape()), radius, 0.0f, mode}; + case toast::LightShape::capsule: return {static_cast(light.shape()), radius, half_length, mode}; + case toast::LightShape::rect: return {static_cast(light.shape()), half_width, half_length, mode}; + case toast::LightShape::point: break; + } + return {0.0f, 0.0f, 0.0f, mode}; + }; + for (auto* light : light_nodes_snapshot) { if (light == nullptr || !light->enabled()) { continue; @@ -3386,6 +3775,7 @@ void VulkanRenderer::tick(float time) noexcept { const bool casts_shadows = light->castsShadows() && (!positional || light->shadowDistance() <= 0.0f || camera_distance <= shadow_limit); + const bool shadow_map = casts_shadows && light->shadowMode() == toast::LightShadowMode::shadow_map; constexpr float k_light_icon_size = 0.5f; debug::drawBillboard( @@ -3404,11 +3794,13 @@ void VulkanRenderer::tick(float time) noexcept { if (directional_count >= k_max_directional_lights) { break; } + const bool marched_sun = casts_shadows && light->shadowMode() != toast::LightShadowMode::shadow_map; + const float angular_radius = 0.5f * glm::radians(static_cast(light)->angularDiameter()); frame.frame_data.directional_lights[directional_count] = DirectionalLightData { - .direction = glm::vec4(light->forward(), 0.0f), + .direction = glm::vec4(light->forward(), marched_sun ? std::max(angular_radius, 1e-6f) : 0.0f), .color_intensity = glm::vec4(light->color(), light->intensity()), }; - if (shadow_caster_index < 0 && casts_shadows) { + if (shadow_caster_index < 0 && shadow_map) { shadow_caster_index = static_cast(directional_count); shadow_caster_direction = light->forward(); } @@ -3419,6 +3811,8 @@ void VulkanRenderer::tick(float time) noexcept { auto* point = static_cast(light); const glm::vec3 world_pos = point->world_position; const glm::vec3 view_pos = glm::vec3(frame_view * glm::vec4(world_pos, 1.0f)); + const glm::vec3 forward = glm::normalize(point->forward()); + const glm::vec4 shape = area_shape(*point, casts_shadows); // FIXME // debug::drawSphere(world_pos, point->attenuation(), glm::vec4(point->color(), 1.0f)); @@ -3430,14 +3824,18 @@ void VulkanRenderer::tick(float time) noexcept { .world_pos_range = glm::vec4(world_pos, point->attenuation()), .view_pos_type = glm::vec4(view_pos, 0.0f), .color_intensity = glm::vec4(point->color(), point->intensity()), - .direction_pad = glm::vec4(0.0f, 0.0f, 0.0f, -1.0f), + .direction_pad = glm::vec4(forward, -1.0f), .cone_angles = glm::vec4(0.0f), + .shape = shape, + .shape_tangent = glm::vec4(areaTangent(forward, point->up()), lightIdBits(light_key)), }, - .bounds = light_culling::Sphere {world_pos, point->attenuation()}, + .bounds = light_culling::Sphere {world_pos, point->attenuation() + shape.y + shape.z}, .key = light_key, .camera_distance = camera_distance, .resolution_scale = point->shadowResolutionScale(), + .shadow_distance = light->shadowDistance(), .casts_shadows = casts_shadows, + .shadow_map = shadow_map, }, point_candidates ); @@ -3449,10 +3847,13 @@ void VulkanRenderer::tick(float time) noexcept { const glm::vec3 forward = glm::normalize(spot->forward()); const glm::vec3 view_pos = glm::vec3(frame_view * glm::vec4(world_pos, 1.0f)); const glm::vec3 view_axis = glm::normalize(glm::mat3(frame_view) * forward); + const glm::vec4 shape = area_shape(*spot, casts_shadows); // Inner past outer would invert the smoothstep edges const float outer = glm::radians(spot->outerRadius()); const float inner = std::min(glm::radians(spot->innerRadius()), outer); + light_culling::Sphere spot_bounds = light_culling::spotBounds(world_pos, forward, spot->attenuation(), outer); + spot_bounds.radius += shape.y + shape.z; // FIXME debug::drawCone(world_pos, forward, spot->attenuation(), spot->outerRadius(), glm::vec4(spot->color(), 1.0f)); @@ -3467,13 +3868,17 @@ void VulkanRenderer::tick(float time) noexcept { .color_intensity = glm::vec4(spot->color(), spot->intensity()), .direction_pad = glm::vec4(forward, -1.0f), .cone_angles = glm::vec4(std::cos(outer), std::cos(inner), 0.0f, 0.0f), + .shape = shape, + .shape_tangent = glm::vec4(areaTangent(forward, spot->up()), lightIdBits(light_key)), }, - .bounds = light_culling::spotBounds(world_pos, forward, spot->attenuation(), outer), + .bounds = spot_bounds, .key = light_key, .cone_fraction = 0.5f * (1.0f - std::cos(outer)), .camera_distance = camera_distance, .resolution_scale = spot->shadowResolutionScale(), + .shadow_distance = light->shadowDistance(), .casts_shadows = casts_shadows, + .shadow_map = shadow_map, }, spot_candidates ); @@ -3783,6 +4188,20 @@ void VulkanRenderer::tick(float time) noexcept { same_camera ? m_taa_history.previous_view_projection : frame.frame_data.view_projection; frame.frame_data.jittered_view_projection = frame.frame_data.view_projection; frame.taa_active = taa; + + // R2 offsets so accumulated frames converge + { + constexpr double k_r2_x = 0.7548776662466927; + constexpr double k_r2_y = 0.5698402909980532; + const auto n = static_cast(frame.sequence % 65536); + const auto size = static_cast(blue_noise::k_size); + frame.frame_data.noise_params = glm::vec4( + static_cast(std::floor(std::fmod(n * k_r2_x, 1.0) * size)), + static_cast(std::floor(std::fmod(n * k_r2_y, 1.0) * size)), + static_cast(frame.sequence % 65536), + 0.0f + ); + } frame.taa_reset = taa && (!m_taa_history.was_active || !same_camera); if (taa && extent.width > 0 && extent.height > 0) { @@ -4054,6 +4473,7 @@ void VulkanRenderer::buildVoxelProxies(RenderFrame& frame) { auto storage = std::make_shared( std::move(patched->record_rids), std::move(patched->record_dims), *m_voxel_storage, *patch_sequence ); + storage->setGlassBricks(packedGlassBricks(m_voxel_patcher.scene())); storage->setDebugInfo(std::move(debug)); storage->setChangeHistory(m_voxel_change_tracker.packed(storage->generation())); m_voxel_storage = std::move(storage); @@ -4089,6 +4509,7 @@ void VulkanRenderer::buildVoxelProxies(RenderFrame& frame) { m_voxel_patcher.adopt(packed->scene, debug.packed_slots, node_rids); auto storage = std::make_shared(std::move(node_rids), std::move(brick_dims)); + storage->setGlassBricks(packedGlassBricks(packed->scene)); storage->setDebugInfo(std::move(debug)); storage->setChangeHistory(m_voxel_change_tracker.packed(storage->generation())); queueResourceUpload(std::make_unique(storage, std::move(packed))); @@ -4149,6 +4570,7 @@ void VulkanRenderer::buildVoxelProxies(RenderFrame& frame) { .mirrored = glm::determinant(model) < 0.0f, .node_rid = node_rid, .highlight_record = highlight_record_of(entry.highlight_id), + .outlined = isOutlined(node_rid), } ); drawn_models.insert_or_assign(node_rid, model); @@ -4348,6 +4770,9 @@ auto VulkanRenderer::applyResizeInternal(vk::Extent2D extent) -> void { createPerImageSync(); createDepthResources(); createSceneColorResources(); + if (m_selection_outline != nullptr) { + m_selection_outline->onResize(actual_extent); + } { std::lock_guard lock(m_pass_mutex); diff --git a/engine/src/toast/renderer/vulkan_renderer.hpp b/engine/src/toast/renderer/vulkan_renderer.hpp index fa515cc9a..a971f1e0e 100644 --- a/engine/src/toast/renderer/vulkan_renderer.hpp +++ b/engine/src/toast/renderer/vulkan_renderer.hpp @@ -79,6 +79,7 @@ class ShadowPass; class EnvironmentPass; class ReflectionProbePass; class DepthPrepass; +class SelectionOutline; class SkinningPass; class VoxelPass; class DebrisPass; @@ -128,6 +129,7 @@ class VulkanRenderer { static_assert(sizeof(InstanceData) == 144, "InstanceData stride must match mesh.slang's std430 layout"); struct DirectionalLightData { + /// w angular radius in radians when voxel shadows are marched and 0 for the shadow map glm::vec4 direction; glm::vec4 color_intensity; }; @@ -211,6 +213,9 @@ class VulkanRenderer { // x 1 traces the TLAS instead of sampling shadow maps glm::vec4 traced_shadow_params {0.0f}; + + /// xy blue noise texel offset walking R2 per frame z frame index + glm::vec4 noise_params {0.0f}; }; static_assert( @@ -220,6 +225,7 @@ class VulkanRenderer { static_assert( offsetof(FrameUBO, irradiance_volumes) == 1264, "FrameUBO::irradiance_volumes must match lighting.slang's std140 layout" ); + static_assert(offsetof(FrameUBO, noise_params) == 1536, "FrameUBO::noise_params must match lighting.slang's std140 layout"); static_assert( offsetof(FrameUBO, traced_shadow_params) == 1520, "FrameUBO::traced_shadow_params must match lighting.slang's std140 layout" ); @@ -233,13 +239,20 @@ class VulkanRenderer { glm::vec4 direction_pad; // w shadow layer or -1 glm::vec4 cone_angles; // x cos outer y cos inner z shadow near w shadow far - // x fraction of the layer rendered y point light face scale inside the guard band + // x fraction of the layer rendered y point light face scale inside the guard band z shadow strength glm::vec4 shadow_atlas {1.0f, 1.0f, 0.0f, 0.0f}; glm::mat4 shadow_view_projection {1.0f}; + + /// x toast::LightShape y radius or rect half width z capsule half length or rect half height w toast::LightShadowMode + /// The shape faces or runs along direction_pad.xyz + glm::vec4 shape {0.0f}; + + /// xyz rect width axis + glm::vec4 shape_tangent {0.0f}; }; - static_assert(sizeof(GpuLight) == 176, "GpuLight must match the stride in lighting.slang and cluster_lighting.slang"); + static_assert(sizeof(GpuLight) == 208, "GpuLight must match the stride in lighting.slang and cluster_lighting.slang"); struct ShadowView { glm::mat4 view_projection {1.0f}; @@ -281,6 +294,9 @@ class VulkanRenderer { uint32_t posed_vertex_offset = k_no_posed_vertices; uint64_t node_rid = 0; + + /// Selected in the editor hierarchy + bool outlined = false; }; struct VoxelVolumeProxy { @@ -309,6 +325,9 @@ class VulkanRenderer { uint64_t node_rid = 0; uint32_t highlight_record = 0; + + /// Selected in the editor hierarchy + bool outlined = false; }; struct UIWorldPanelProxy { @@ -371,6 +390,9 @@ class VulkanRenderer { uint32_t truncated = 0; uint32_t spot_shadows = 0; uint32_t point_shadows = 0; + /// Lights within their shadow distance left without a shadow because every slot was taken + uint32_t spot_denied = 0; + uint32_t point_denied = 0; uint64_t shadow_displaced = 0; }; @@ -538,6 +560,27 @@ class VulkanRenderer { return m_debug_windows_visible.load(std::memory_order_relaxed); } + void setDebugOverlayInGame(bool visible) noexcept { m_debug_overlay_in_game.store(visible, std::memory_order_relaxed); } + + [[nodiscard]] + auto debugOverlayInGame() const noexcept -> bool { + return m_debug_overlay_in_game.load(std::memory_order_relaxed); + } + + /// Main thread only, once per tick + void setGameView(bool game_view); + + /// ImGui draws nothing while a game camera is live unless shown in game + [[nodiscard]] + auto debugOverlayHidden() const noexcept -> bool { + return m_game_view.load(std::memory_order_relaxed) && !debugOverlayInGame(); + } + + [[nodiscard]] + auto debugOverlayOwnsCursor() const noexcept -> bool { + return m_debug_overlay_owns_cursor.load(std::memory_order_acquire); + } + void setApplicationFocused(bool focused) noexcept { m_application_focused.store(focused, std::memory_order_relaxed); } [[nodiscard]] @@ -618,6 +661,36 @@ class VulkanRenderer { return m_voxel_face_normal.view.has_value() ? **m_voxel_face_normal.view : vk::ImageView {}; } + /// Marched and sampled diffuse over albedo before denoising + [[nodiscard]] + auto getVoxelNoisyDiffuseView() const noexcept -> vk::ImageView { + return m_voxel_noisy_diffuse.view.has_value() ? **m_voxel_noisy_diffuse.view : vk::ImageView {}; + } + + [[nodiscard]] + auto getVoxelNoisySpecularView() const noexcept -> vk::ImageView { + return m_voxel_noisy_specular.view.has_value() ? **m_voxel_noisy_specular.view : vk::ImageView {}; + } + + /// Last frame's reservoirs that this frame's voxel lighting reads + [[nodiscard]] + auto getVoxelPreviousReservoirView() const noexcept -> vk::ImageView { + const auto& target = m_voxel_reservoirs[1 - m_voxel_reservoir_parity]; + return target.view.has_value() ? **target.view : vk::ImageView {}; + } + + /// This frame's reservoirs before the spatial pass merges them + [[nodiscard]] + auto getVoxelCandidateReservoirView() const noexcept -> vk::ImageView { + return m_voxel_candidate_reservoirs.view.has_value() ? **m_voxel_candidate_reservoirs.view : vk::ImageView {}; + } + + /// The previous reservoirs were written by the frame right before this one + [[nodiscard]] + auto voxelReservoirHistory() const noexcept -> bool { + return m_voxel_reservoir_history; + } + /// Edge rounding radius in voxels where 0 keeps the flat face normals void setVoxelCellBoxes(bool enabled) noexcept { m_voxel_cell_boxes.store(enabled, std::memory_order_relaxed); } @@ -625,6 +698,24 @@ class VulkanRenderer { void setVoxelMeshShaders(bool enabled) noexcept { m_voxel_mesh_shaders.store(enabled, std::memory_order_relaxed); } + /// Share of listed cells occlusion must hide to stay on where 0 never idles it + void setVoxelOcclusionMinHidden(float share) noexcept { m_voxel_occlusion_min_hidden.store(share, std::memory_order_relaxed); } + + /// Frames occlusion stays off once it hid too little before it measures again + void setVoxelOcclusionIdleFrames(uint32_t frames) noexcept { + m_voxel_occlusion_idle_frames.store(frames, std::memory_order_relaxed); + } + + [[nodiscard]] + auto voxelOcclusionMinHidden() const noexcept -> float { + return m_voxel_occlusion_min_hidden.load(std::memory_order_relaxed); + } + + [[nodiscard]] + auto voxelOcclusionIdleFrames() const noexcept -> uint32_t { + return m_voxel_occlusion_idle_frames.load(std::memory_order_relaxed); + } + [[nodiscard]] auto voxelMeshShaders() const noexcept -> bool { return m_voxel_mesh_shaders.load(std::memory_order_relaxed); @@ -650,6 +741,118 @@ class VulkanRenderer { return m_voxel_cell_boxes.load(std::memory_order_relaxed); } + /// Candidates the voxel lighting resamples among the froxel's sampled lights + void setVoxelLightCandidates(uint32_t count) noexcept { m_voxel_light_candidates.store(count, std::memory_order_relaxed); } + + [[nodiscard]] + auto voxelLightCandidates() const noexcept -> uint32_t { + return m_voxel_light_candidates.load(std::memory_order_relaxed); + } + + /// Traced ambient occlusion on voxel pixels in place of SSAO + void setVoxelAmbientRays(bool enabled) noexcept { m_voxel_ambient_rays.store(enabled, std::memory_order_relaxed); } + + [[nodiscard]] + auto voxelAmbientRays() const noexcept -> bool { + return m_voxel_ambient_rays.load(std::memory_order_relaxed); + } + + void setVoxelAmbientReach(float meters) noexcept { + m_voxel_ambient_reach.store(std::max(meters, 0.0f), std::memory_order_relaxed); + } + + [[nodiscard]] + auto voxelAmbientReach() const noexcept -> float { + return m_voxel_ambient_reach.load(std::memory_order_relaxed); + } + + /// 1 is Snell and 0 turns refraction off + void setVoxelGlassRefraction(float strength) noexcept { + m_voxel_glass_refraction.store(std::max(strength, 0.0f), std::memory_order_relaxed); + } + + [[nodiscard]] + auto voxelGlassRefraction() const noexcept -> float { + return m_voxel_glass_refraction_enabled.load(std::memory_order_relaxed) + ? m_voxel_glass_refraction.load(std::memory_order_relaxed) + : 0.0f; + } + + /// Off skips the backdrop copy and the volume march of every glass pixel + void setVoxelGlassRefractionEnabled(bool enabled) noexcept { + m_voxel_glass_refraction_enabled.store(enabled, std::memory_order_relaxed); + } + + void setVoxelGlassRefractionDistance(float meters) noexcept { + m_voxel_glass_refraction_distance.store(std::max(meters, 0.0f), std::memory_order_relaxed); + } + + [[nodiscard]] + auto voxelGlassRefractionDistance() const noexcept -> float { + return m_voxel_glass_refraction_distance.load(std::memory_order_relaxed); + } + + void setVoxelGlassRefractionVoxels(uint32_t voxels) noexcept { + m_voxel_glass_refraction_voxels.store(std::max(voxels, 1u), std::memory_order_relaxed); + } + + [[nodiscard]] + auto voxelGlassRefractionVoxels() const noexcept -> uint32_t { + return m_voxel_glass_refraction_voxels.load(std::memory_order_relaxed); + } + + void setVoxelGlassLitSurfaces(uint32_t surfaces) noexcept { + m_voxel_glass_lit_surfaces.store(surfaces, std::memory_order_relaxed); + } + + [[nodiscard]] + auto voxelGlassLitSurfaces() const noexcept -> uint32_t { + return m_voxel_glass_lit_surfaces.load(std::memory_order_relaxed); + } + + void setVoxelGlassShadows(bool enabled) noexcept { m_voxel_glass_shadows.store(enabled, std::memory_order_relaxed); } + + [[nodiscard]] + auto voxelGlassShadows() const noexcept -> bool { + return m_voxel_glass_shadows.load(std::memory_order_relaxed); + } + + void setVoxelGlassShadowReach(uint32_t meters) noexcept { + m_voxel_glass_shadow_reach.store(std::max(meters, 1u), std::memory_order_relaxed); + } + + [[nodiscard]] + auto voxelGlassShadowReach() const noexcept -> uint32_t { + return m_voxel_glass_shadow_reach.load(std::memory_order_relaxed); + } + + /// Scene colour and depth copied after the sky for glass to refract + [[nodiscard]] + auto getGlassBackdropView() const noexcept -> vk::ImageView { + return m_glass_backdrop.view ? **m_glass_backdrop.view : vk::ImageView {}; + } + + [[nodiscard]] + auto getGlassBackdropDepthView() const noexcept -> vk::ImageView { + return m_glass_backdrop_depth.view ? **m_glass_backdrop_depth.view : vk::ImageView {}; + } + + /// Meters a marched sun ray travels before it counts as lit + void setVoxelSunReach(uint32_t meters) noexcept { m_voxel_sun_reach.store(meters, std::memory_order_relaxed); } + + [[nodiscard]] + auto voxelSunReach() const noexcept -> uint32_t { + return m_voxel_sun_reach.load(std::memory_order_relaxed); + } + + /// Nearest drawn voxel instances a marched shadow ray tests + void setVoxelShadowInstances(uint32_t count) noexcept { m_voxel_shadow_instances.store(count, std::memory_order_relaxed); } + + [[nodiscard]] + auto voxelShadowInstances() const noexcept -> uint32_t { + return m_voxel_shadow_instances.load(std::memory_order_relaxed); + } + void setVoxelNormalRounding(float voxels) noexcept { m_voxel_normal_rounding.store(std::max(voxels, 0.0f), std::memory_order_relaxed); } @@ -742,6 +945,12 @@ class VulkanRenderer { } [[nodiscard]] + /// renderer::blue_noise::generatePacked texels bound to lighting.slang gBlueNoise + [[nodiscard]] + auto getBlueNoiseBuffer() const -> vk::Buffer { + return m_blue_noise_buffer.has_value() ? **m_blue_noise_buffer : vk::Buffer {}; + } + auto getJointMatrixBuffer(uint32_t frame_index) const -> vk::Buffer { return frame_index < m_joint_matrix_res.size() && m_joint_matrix_res[frame_index].gpu_buffer.has_value() ? **m_joint_matrix_res[frame_index].gpu_buffer @@ -902,6 +1111,12 @@ class VulkanRenderer { void setGizmoState(const GizmoState& state) noexcept { m_gizmo_state = state; } + /// Node rids whose meshes and voxels get the selection outline + void setOutlinedNodes(std::vector rids) noexcept { + std::ranges::sort(rids); + m_outlined_rids = std::move(rids); + } + void setDebugDrawEnabled(bool enabled) noexcept { m_debug_draw_enabled.store(enabled, std::memory_order_relaxed); } [[nodiscard]] @@ -1063,7 +1278,9 @@ class VulkanRenderer { static VulkanRenderer* instance; private: - void drawFrame(RenderFrame& frame_data); + /// False when nothing was rendered + auto drawFrame(RenderFrame& frame_data) -> bool; + void waitForOutputImage(); void mainRenderThread(); @@ -1125,12 +1342,18 @@ class VulkanRenderer { void recordFrame(FrameContext& frame, uint32_t image_index) noexcept; /// @note Expects an open rendering scope with m_pass_mutex held - void recordMeshScene(vk::CommandBuffer cmd, uint32_t image_index); + /// Opaque meshes then the sky then blended meshes and voxel glass far to near + /// @p capture_backdrop ends the world scope copies the scene for refracting glass and resumes it + void recordMeshScene( + vk::CommandBuffer cmd, uint32_t image_index, bool voxel_glass, const std::function& capture_backdrop + ); struct PunctualShadowCandidate { size_t light_index = 0; float distance_squared = 0.0f; float resolution_scale = 1.0f; + /// The light shadow distance where 0 is no limit + float shadow_distance = 0.0f; bool visible = true; uint64_t key = 0; float importance = 0.0f; @@ -1246,9 +1469,11 @@ class VulkanRenderer { bool m_probe_auto_baked = false; vk::Format m_depth_format = vk::Format::eUndefined; DepthResources m_depth_resources; + DepthResources m_glass_backdrop_depth; static constexpr vk::Format m_scene_color_format = vk::Format::eR16G16B16A16Sfloat; SceneColorResources m_scene_color; + SceneColorResources m_glass_backdrop; static constexpr vk::Format m_scene_normal_format = renderer::k_scene_normal_format; SceneColorResources m_scene_normal; @@ -1262,11 +1487,35 @@ class VulkanRenderer { SceneColorResources m_voxel_albedo; SceneColorResources m_voxel_material; SceneColorResources m_voxel_face_normal; + SceneColorResources m_voxel_noisy_diffuse; + SceneColorResources m_voxel_noisy_specular; + /// The voxel lighting writes the parity one and reads the other + std::array m_voxel_reservoirs; + /// The lighting scope's reservoirs when the spatial pass then writes m_voxel_reservoirs + SceneColorResources m_voxel_candidate_reservoirs; + uint32_t m_voxel_reservoir_parity = 0; + bool m_voxel_reservoir_written = false; + bool m_voxel_reservoir_history = false; + uint64_t m_voxel_reservoir_sequence = 0; std::atomic m_voxel_normal_rounding {0.3f}; + std::atomic m_voxel_light_candidates {8}; + std::atomic m_voxel_shadow_instances {256}; + std::atomic m_voxel_sun_reach {200}; + std::atomic m_voxel_ambient_rays {true}; + std::atomic m_voxel_ambient_reach {2.4f}; + std::atomic m_voxel_glass_refraction {1.0f}; + std::atomic_bool m_voxel_glass_refraction_enabled {true}; + std::atomic m_voxel_glass_refraction_distance {60.0f}; + std::atomic m_voxel_glass_refraction_voxels {32}; + std::atomic m_voxel_glass_lit_surfaces {4}; + std::atomic_bool m_voxel_glass_shadows {true}; + std::atomic m_voxel_glass_shadow_reach {50}; std::atomic_bool m_voxel_cell_boxes {true}; std::atomic_bool m_voxel_occlusion {true}; std::atomic_bool m_voxel_mesh_shaders {true}; + std::atomic m_voxel_occlusion_min_hidden {0.05f}; + std::atomic m_voxel_occlusion_idle_frames {120}; std::atomic m_voxel_raster_pixels {4.0f}; ShaderLayout m_present_layout; @@ -1291,6 +1540,14 @@ class VulkanRenderer { GizmoState m_gizmo_state; + /// Sorted + std::vector m_outlined_rids; + + [[nodiscard]] + auto isOutlined(uint64_t node_rid) const -> bool { + return std::ranges::binary_search(m_outlined_rids, node_rid); + } + [[nodiscard]] auto blendPostProcessVolumes(const glm::vec3& camera_position) -> PostProcessSettings; @@ -1418,8 +1675,9 @@ class VulkanRenderer { std::vector m_tick_point_candidates; /// Survive across ticks so a shadowed light keeps its layer and its cached map - std::array m_spot_slots {}; - std::array m_point_slots {}; + /// Sized to shadows::spotShadowSlots() and pointShadowSlots() + std::vector m_spot_slots; + std::vector m_point_slots; uint64_t m_shadow_displaced_total = 0; /// Padded sphere a cascade keeps until the camera slice leaves it @@ -1466,6 +1724,8 @@ class VulkanRenderer { std::unique_ptr m_depth_prepass; + std::unique_ptr m_selection_outline; + std::unique_ptr m_skinning_pass; std::unique_ptr m_ray_tracing_scene; @@ -1513,6 +1773,8 @@ class VulkanRenderer { std::vector m_frame_ubos; std::vector m_frame_ubo_res; std::vector m_joint_matrix_res; + + std::optional m_blue_noise_buffer; std::vector m_instance_res; std::vector m_shadow_instance_res; bool m_joint_matrix_pool_exhausted_warned = false; @@ -1528,6 +1790,9 @@ class VulkanRenderer { std::atomic m_frame_rate_limit_hz {0.0}; std::atomic_bool m_application_focused {true}; std::atomic_bool m_debug_windows_visible {true}; + std::atomic_bool m_debug_overlay_in_game {false}; + std::atomic_bool m_game_view {false}; + std::atomic_bool m_debug_overlay_owns_cursor {false}; std::atomic_bool m_rendering_paused {false}; std::atomic_bool m_clamp_to_simulation {true}; }; diff --git a/engine/src/toast/ui/ui_input.cpp b/engine/src/toast/ui/ui_input.cpp index 8441c2d38..9d5ff212b 100644 --- a/engine/src/toast/ui/ui_input.cpp +++ b/engine/src/toast/ui/ui_input.cpp @@ -105,6 +105,11 @@ UIInputRouter::UIInputRouter() { } if (e.action == event::window_input_pressed) { + // A press ImGui wants skips the panels under it and still reaches the input system + if (m_overlay_mouse) { + m_ui_buttons.erase(e.button); + return false; + } const bool consumed = dispatchToPanels([&](Rml::Context* context) { return context->ProcessMouseButtonDown(button, m_mods); }); if (consumed) { @@ -124,6 +129,9 @@ UIInputRouter::UIInputRouter() { m_listener.subscribe( [this](event::WindowMouseScroll& e) { + if (m_overlay_mouse) { + return false; + } return dispatchToPanels([&](Rml::Context* context) { return context->ProcessMouseWheel(Rml::Vector2f(-e.x, -e.y), m_mods); }); @@ -131,6 +139,14 @@ UIInputRouter::UIInputRouter() { k_ui_input_priority ); + m_listener.subscribe( + [this](event::DebugOverlayCapture& e) { + m_overlay_mouse = e.mouse; + return false; + }, + k_ui_input_priority + ); + m_listener.subscribe( [this](event::WindowDisplayScale& e) { if (e.scale <= 0.0f) { diff --git a/engine/src/toast/ui/ui_input.hpp b/engine/src/toast/ui/ui_input.hpp index 08abd1458..ca974da81 100644 --- a/engine/src/toast/ui/ui_input.hpp +++ b/engine/src/toast/ui/ui_input.hpp @@ -30,6 +30,7 @@ class UIInputRouter { float m_dp_ratio = 1.0f; int m_mods = 0; + bool m_overlay_mouse = false; }; } diff --git a/engine/src/toast/voxel/assets/vox_import.cpp b/engine/src/toast/voxel/assets/vox_import.cpp index 3a61c1d62..22c63a1ce 100644 --- a/engine/src/toast/voxel/assets/vox_import.cpp +++ b/engine/src/toast/voxel/assets/vox_import.cpp @@ -278,6 +278,9 @@ struct VoxMaterial { float metallic = 0.0f; float reflectivity = 0.0f; float emission = 0.0f; + float opacity = 1.0f; + /// 0 leaves the palette default + float ior = 0.0f; bool transparent = false; }; @@ -407,6 +410,7 @@ auto importVox(std::span data) -> VoxScene { entry.albedo_b = b; if (a < 255) { entry.flags |= voxel::k_entry_transparent; + entry.alpha = a; } } has_rgba = true; @@ -431,8 +435,19 @@ auto importVox(std::span data) -> VoxScene { } // _flux multiplies the emission by 2^flux material.emission = voxDictFloat(attributes, "_emit", 0.0f) * std::pow(2.0f, voxDictFloat(attributes, "_flux", 0.0f)); - material.transparent = type == "_glass" || voxDictFloat(attributes, "_alpha", 1.0f) < 1.0f || - (type == "_blend" && voxDictFloat(attributes, "_trans", 0.0f) > 0.0f); + // _trans is the 0.99 transparency slider and _alpha what older files wrote + if (attributes.contains("_trans")) { + material.opacity = 1.0f - voxDictFloat(attributes, "_trans", 0.0f); + } else { + material.opacity = voxDictFloat(attributes, "_alpha", 1.0f); + } + material.transparent = type == "_glass" || material.opacity < 1.0f; + // _ri is the index itself and _ior what older files wrote as the index minus one + if (attributes.contains("_ri")) { + material.ior = voxDictFloat(attributes, "_ri", 0.0f); + } else if (attributes.contains("_ior")) { + material.ior = 1.0f + voxDictFloat(attributes, "_ior", 0.0f); + } materials.insert_or_assign(static_cast(index), material); } } else if (is("nTRN")) { @@ -568,6 +583,10 @@ auto importVox(std::span data) -> VoxScene { entry.emissive = voxByteFromUnit(material.emission / scene.palette.max_emissive); if (material.transparent) { entry.flags |= voxel::k_entry_transparent; + entry.alpha = std::min(entry.alpha, voxByteFromUnit(material.opacity)); + } + if (material.ior > 1.0f) { + entry.ior = voxel::iorByte(material.ior); } } diff --git a/engine/src/toast/voxel/assets/voxel_palette.cpp b/engine/src/toast/voxel/assets/voxel_palette.cpp index d39639e68..0f14cd752 100644 --- a/engine/src/toast/voxel/assets/voxel_palette.cpp +++ b/engine/src/toast/voxel/assets/voxel_palette.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -237,6 +238,14 @@ auto VoxelPalette::parseToml(const toml::table& table) -> Parsed { out.alpha = unit_or("alpha", 255); + if (const toml::node* ior = entry->get("ior")) { + const std::optional value = ior->is_number() ? ior->value() : std::nullopt; + if (!value || !(*value >= 1.0 && *value <= voxel::k_max_ior)) { + throw fail(std::format("ior must be a number from 1 to {:.2f}", voxel::k_max_ior)); + } + out.ior = voxel::iorByte(static_cast(*value)); + } + if (const std::optional material = whole("material", 0, 255)) { out.material = static_cast(*material); } else { @@ -340,6 +349,9 @@ auto VoxelPalette::serialize(SaveMode /*mode*/) const -> std::vector { entry.insert("reflectivity", as_unit(e.reflectivity)); entry.insert("emissive", as_unit(e.emissive)); entry.insert("alpha", as_unit(e.alpha)); + if (e.ior != 0) { + entry.insert("ior", static_cast(voxel::iorOf(e))); + } if (!is_defaulted) { entry.insert("material", static_cast(e.material)); } diff --git a/engine/src/toast/voxel/gpu_layout.cpp b/engine/src/toast/voxel/gpu_layout.cpp index 1e42ae5bb..dee9495a6 100644 --- a/engine/src/toast/voxel/gpu_layout.cpp +++ b/engine/src/toast/voxel/gpu_layout.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -79,6 +80,64 @@ void sortUnique(std::vector& values) { values.erase(std::ranges::unique(values).begin(), values.end()); } +using GlassTable = std::array; + +[[nodiscard]] +auto glassTable(const Palette& palette) -> GlassTable { + GlassTable out {}; + for (uint32_t i = 1; i < k_palette_size; ++i) { + out[i] = isGlass(palette.entries[i]); + } + return out; +} + +[[nodiscard]] +auto brickHoldsGlass(const BrickPool& pool, BrickEntry entry, const GlassTable& glass) -> bool { + if (entry.tag() == BrickTag::uniform) { + return glass[entry.payload() & 0xFFu]; + } + if (!entry.isPooled()) { + return false; + } + const std::span bytes = pool.material(entry.payload()); + return std::ranges::any_of(bytes, [&glass](uint8_t index) { return glass[index]; }); +} + +void setGlassBit(std::vector& coarse, uint32_t glass_offset, uint32_t index, bool glass) { + const uint32_t bit = 1u << (index % 32u); + uint32_t& word = coarse[glass_offset + (index / 32u)]; + word = glass ? (word | bit) : (word & ~bit); +} + +} + +auto paletteHasGlass(const Palette& palette) -> bool { + return std::ranges::any_of(palette.entries, [](const PaletteEntry& entry) { return isGlass(entry); }); +} + +auto brickHasGlass(const BrickPool& pool, BrickEntry entry, const Palette& palette) -> bool { + return brickHoldsGlass(pool, entry, glassTable(palette)); +} + +auto glassBricks(const PackedScene& scene, uint32_t record) -> std::vector { + std::vector out; + const VolumeRecord& volume = scene.records[record]; + if (volume.glass_offset == k_no_glass) { + return out; + } + const uint32_t bricks = volume.brick_dims_x * volume.brick_dims_y * volume.brick_dims_z; + const uint32_t words = glassWords(glm::uvec3(volume.brick_dims_x, volume.brick_dims_y, volume.brick_dims_z)); + for (uint32_t w = 0; w < words; ++w) { + uint32_t bits = scene.coarse[volume.glass_offset + w]; + while (bits != 0) { + const uint32_t index = (w * 32u) + static_cast(std::countr_zero(bits)); + if (index < bricks) { + out.push_back(index); + } + bits &= bits - 1u; + } + } + return out; } void packOccupancyWords(const BrickOccupancy& occupancy, uint32_t* out) { @@ -143,6 +202,10 @@ auto packLayout(std::span volumes) -> SceneLayout { out.grid_words += dims.x * dims.y * dims.z; out.coarse_words += ((coarse.x * coarse.y * coarse.z) + 31u) / 32u; + if (paletteHasGlass(*entry.palette)) { + record.glass_offset = out.coarse_words; + out.coarse_words += glassWords(dims); + } const auto found = std::find(packed_palettes.begin(), packed_palettes.end(), entry.palette); if (found != packed_palettes.end()) { @@ -177,15 +240,21 @@ auto packScene(std::span volumes) -> PackedScene { const VolumeRecord& record = out.records[i]; const glm::uvec3 dims = volume.brickDims(); const glm::uvec3 coarse = coarseDims(dims); + const bool has_glass = record.glass_offset != k_no_glass && volume.pool() != nullptr; + const GlassTable glass = has_glass ? glassTable(*volumes[i].palette) : GlassTable {}; for (uint32_t z = 0; z < dims.z; ++z) { for (uint32_t y = 0; y < dims.y; ++y) { for (uint32_t x = 0; x < dims.x; ++x) { const BrickEntry brick_entry = volume.entryAt(glm::ivec3(x, y, z)); - out.grids[record.grid_offset + gridIndex(dims, glm::uvec3(x, y, z))] = brick_entry.value; + const uint32_t index = gridIndex(dims, glm::uvec3(x, y, z)); + out.grids[record.grid_offset + index] = brick_entry.value; if (brick_entry.tag() == BrickTag::empty) { continue; } + if (has_glass && brickHoldsGlass(*volume.pool(), brick_entry, glass)) { + setGlassBit(out.coarse, record.glass_offset, index, true); + } // Volume frees emptied bricks so any non empty tag means something solid const uint32_t cell = gridIndex(coarse, glm::uvec3(x, y, z) / k_coarse_bricks); @@ -227,6 +296,8 @@ auto computePatch( const glm::uvec3 dims(record.brick_dims_x, record.brick_dims_y, record.brick_dims_z); const glm::uvec3 coarse(record.coarse_dims_x, record.coarse_dims_y, record.coarse_dims_z); TagDelta& tags = patch.tags[index]; + const bool has_glass = record.glass_offset != k_no_glass; + const GlassTable glass = has_glass ? glassTable(*volumes[index].palette) : GlassTable {}; const uint32_t bricks = dims.x * dims.y * dims.z; for (uint32_t i = 0; i < bricks; ++i) { @@ -241,12 +312,20 @@ auto computePatch( const uint32_t cell = gridIndex(coarse, brickOfIndex(dims, i) / k_coarse_bricks); retained.coarse[record.coarse_offset + (cell / 32u)] |= 1u << (cell % 32u); } + if (has_glass) { + setGlassBit(retained.coarse, record.glass_offset, i, brickHoldsGlass(pool, entry, glass)); + } } const uint32_t coarse_count = ((coarse.x * coarse.y * coarse.z) + 31u) / 32u; for (uint32_t i = 0; i < coarse_count; ++i) { coarse_words.push_back(record.coarse_offset + i); } + if (has_glass) { + for (uint32_t i = 0; i < glassWords(dims); ++i) { + coarse_words.push_back(record.glass_offset + i); + } + } constexpr uint32_t k_record_words = sizeof(VolumeRecord) / sizeof(uint32_t); for (uint32_t i = 0; i < k_record_words; ++i) { @@ -260,12 +339,23 @@ auto computePatch( const glm::uvec3 dims(record.brick_dims_x, record.brick_dims_y, record.brick_dims_z); const glm::uvec3 coarse(record.coarse_dims_x, record.coarse_dims_y, record.coarse_dims_z); TagDelta& tags = patch.tags[entry.volume]; + const bool has_glass = record.glass_offset != k_no_glass; + const GlassTable glass = has_glass ? glassTable(*volumes[entry.volume].palette) : GlassTable {}; cells.clear(); for (const uint32_t index : entry.bricks) { const glm::uvec3 brick = brickOfIndex(dims, index); const BrickEntry now = volume.entryAt(glm::ivec3(brick)); + if (has_glass) { + const uint32_t word_index = record.glass_offset + (index / 32u); + const uint32_t before = retained.coarse[word_index]; + setGlassBit(retained.coarse, record.glass_offset, index, brickHoldsGlass(pool, now, glass)); + if (retained.coarse[word_index] != before) { + coarse_words.push_back(word_index); + } + } + uint32_t& word = retained.grids[record.grid_offset + index]; if (word != now.value) { bump(tags, BrickEntry {word}.tag(), -1); diff --git a/engine/src/toast/voxel/gpu_layout.hpp b/engine/src/toast/voxel/gpu_layout.hpp index 7b172eece..683b190d5 100644 --- a/engine/src/toast/voxel/gpu_layout.hpp +++ b/engine/src/toast/voxel/gpu_layout.hpp @@ -51,13 +51,20 @@ struct VolumeRecord { uint32_t palette_offset = 0; float max_emissive = 1.0f; + + /// Coarse section word of one bit per brick in grid order set where a glass voxel lives + uint32_t glass_offset = UINT32_MAX; }; -static_assert(sizeof(VolumeRecord) == 40, "VolumeRecord is mirrored by voxel_dda.slang member for member"); +/// VolumeRecord::glass_offset of a volume whose palette has no glass +inline constexpr uint32_t k_no_glass = UINT32_MAX; + +static_assert(sizeof(VolumeRecord) == 44, "VolumeRecord is mirrored by voxel_dda.slang member for member"); static_assert(offsetof(VolumeRecord, grid_offset) == 12); static_assert(offsetof(VolumeRecord, coarse_offset) == 28); static_assert(offsetof(VolumeRecord, palette_offset) == 32); static_assert(offsetof(VolumeRecord, max_emissive) == 36); +static_assert(offsetof(VolumeRecord, glass_offset) == 40); struct PackedPool { /// 512 palette bytes per slot little endian @@ -151,6 +158,22 @@ constexpr auto coarseDims(glm::uvec3 brick_dims) noexcept -> glm::uvec3 { return (brick_dims + (k_coarse_bricks - 1u)) / k_coarse_bricks; } +[[nodiscard]] +constexpr auto glassWords(glm::uvec3 brick_dims) noexcept -> uint32_t { + return ((brick_dims.x * brick_dims.y * brick_dims.z) + 31u) / 32u; +} + +[[nodiscard]] +TOAST_API auto paletteHasGlass(const Palette& palette) -> bool; + +/// Whether a voxel of the brick behind @p entry is glass under @p palette +[[nodiscard]] +TOAST_API auto brickHasGlass(const BrickPool& pool, BrickEntry entry, const Palette& palette) -> bool; + +/// Grid indices of the glass bricks of one packed record +[[nodiscard]] +TOAST_API auto glassBricks(const PackedScene& scene, uint32_t record) -> std::vector; + /// @brief Packs up to the highest slot ever handed out [[nodiscard]] TOAST_API auto packPool(const BrickPool& pool) -> PackedPool; diff --git a/engine/src/toast/voxel/palette.hpp b/engine/src/toast/voxel/palette.hpp index 646fa0b41..260f988da 100644 --- a/engine/src/toast/voxel/palette.hpp +++ b/engine/src/toast/voxel/palette.hpp @@ -7,7 +7,9 @@ #pragma once #include "voxel_constants.hpp" +#include #include +#include #include #include #include @@ -47,7 +49,10 @@ struct PaletteEntry { uint8_t alpha = 255; - std::array reserved {}; + /// 0 for k_default_ior and otherwise 1 + ior / 100 + uint8_t ior = 0; + + std::array reserved {}; [[nodiscard]] constexpr auto operator==(const PaletteEntry&) const noexcept -> bool = default; @@ -55,6 +60,26 @@ struct PaletteEntry { static_assert(sizeof(PaletteEntry) == 16, "a palette entry must be sixteen bytes - the shader indexes it that way"); +/// Mirrors voxel_dda.slang glassEntry +[[nodiscard]] +constexpr auto isGlass(const PaletteEntry& entry) noexcept -> bool { + return (entry.flags & k_entry_transparent) != 0 && entry.alpha < 255; +} + +/// Window glass and the IOR of an entry without one +inline constexpr float k_default_ior = 1.5f; +inline constexpr float k_max_ior = 1.0f + (255.0f / 100.0f); + +[[nodiscard]] +constexpr auto iorOf(const PaletteEntry& entry) noexcept -> float { + return entry.ior == 0 ? k_default_ior : 1.0f + (static_cast(entry.ior) / 100.0f); +} + +[[nodiscard]] +inline auto iorByte(float ior) noexcept -> uint8_t { + return static_cast(std::clamp(std::lround((ior - 1.0f) * 100.0f), 1L, 255L)); +} + /// @brief Entry 0 is the empty voxel and must stay all zero struct Palette { std::array entries {}; diff --git a/engine/src/toast/window/sdl_window.cpp b/engine/src/toast/window/sdl_window.cpp index 0d7cfd821..7d223c35d 100644 --- a/engine/src/toast/window/sdl_window.cpp +++ b/engine/src/toast/window/sdl_window.cpp @@ -79,7 +79,13 @@ SDLWindow::SDLWindow(const char* title, unsigned width, unsigned height, uint64_ return false; }); m.event_listener.subscribe([this](const event::WindowMouseLock& e) { - applyMouseLock(e.locked); + m.lock_requested = e.locked; + applyMouseLock(m.lock_requested && !m.overlay_cursor); + return false; + }); + m.event_listener.subscribe([this](const event::DebugOverlayCursor& e) { + m.overlay_cursor = e.owned; + applyMouseLock(m.lock_requested && !m.overlay_cursor); return false; }); diff --git a/engine/src/toast/window/sdl_window.hpp b/engine/src/toast/window/sdl_window.hpp index 2a1fb9cec..628d4af85 100644 --- a/engine/src/toast/window/sdl_window.hpp +++ b/engine/src/toast/window/sdl_window.hpp @@ -42,6 +42,8 @@ class SDLWindow : public IBaseWindow { event::Listener event_listener; bool should_close = false; bool cursor_locked = false; + bool lock_requested = false; + bool overlay_cursor = false; glm::vec2 virtual_mouse_position {0.0f}; } m; }; diff --git a/engine/src/toast/window/window_events.hpp b/engine/src/toast/window/window_events.hpp index 6d38cbd39..a64a91ac1 100644 --- a/engine/src/toast/window/window_events.hpp +++ b/engine/src/toast/window/window_events.hpp @@ -107,4 +107,20 @@ struct WindowFocus : Event { explicit WindowFocus(bool focused) : focused(focused) { } }; +/// @brief The debug overlay is up in game view and needs a free cursor +/// @note Queued before the state the editor polls flips so input it sends after reacting lands behind it +struct DebugOverlayCursor : Event { + bool owned; + + explicit DebugOverlayCursor(bool owned) : owned(owned) { } +}; + +/// @brief ImGui io.WantCaptureMouse and io.WantCaptureKeyboard, sent from the render thread when either changes +struct DebugOverlayCapture : Event { + bool mouse; + bool keyboard; + + DebugOverlayCapture(bool mouse, bool keyboard) : mouse(mouse), keyboard(keyboard) { } +}; + } diff --git a/engine/src/toast/world/directional_light.hpp b/engine/src/toast/world/directional_light.hpp index 758251ec2..84917aeb5 100644 --- a/engine/src/toast/world/directional_light.hpp +++ b/engine/src/toast/world/directional_light.hpp @@ -18,6 +18,14 @@ class [[ToastNode, Icon("DirectionalLight")]] TOAST_API DirectionalLight : publi ~DirectionalLight() override = default; + [[nodiscard]] + auto angularDiameter() const -> float { + return m_angular_diameter; + } + private: + /// Marched voxel shadow penumbra width where the sun is about 0.53 + [[Reflect, Name("Angular Diameter"), Unit("deg"), Range(0.0, 10.0), Group("Area")]] + float m_angular_diameter = 0.53f; }; } diff --git a/engine/src/toast/world/light.hpp b/engine/src/toast/world/light.hpp index f66c33601..d480dd0e4 100644 --- a/engine/src/toast/world/light.hpp +++ b/engine/src/toast/world/light.hpp @@ -22,6 +22,27 @@ enum class LightType : uint8_t { ambient, }; +/// Mirrors lighting.slang kLightShape +enum class LightShape : uint8_t { + point, + sphere, + /// Along the node forward axis + capsule, + /// Facing the node forward axis + disc, + /// Facing forward with its width along the node right axis + rect, +}; + +/// Mirrors lighting.slang kShadowMode and only affects voxel pixels +enum class LightShadowMode : uint8_t { + shadow_map, + /// One marched ray to a point on the shape per pixel + marched, + /// Shares one marched ray with every sampled light of the froxel + sampled, +}; + class [[ToastNode, Hidden, Icon("PointLight")]] TOAST_API Light : public Node3D { public: Light() = default; @@ -65,6 +86,31 @@ class [[ToastNode, Hidden, Icon("PointLight")]] TOAST_API Light : public Node3D return m_cull_distance; } + [[nodiscard]] + auto shape() const -> LightShape { + return m_shape; + } + + [[nodiscard]] + auto sourceRadius() const -> float { + return m_source_radius; + } + + [[nodiscard]] + auto sourceLength() const -> float { + return m_source_length; + } + + [[nodiscard]] + auto sourceWidth() const -> float { + return m_source_width; + } + + [[nodiscard]] + auto shadowMode() const -> LightShadowMode { + return m_shadow_mode; + } + protected: /// @brief Tags the concrete light kind; called once from each subclass's own constructor body void setLightType(LightType type) { m_light_type = type; } @@ -91,7 +137,7 @@ class [[ToastNode, Hidden, Icon("PointLight")]] TOAST_API Light : public Node3D float m_shadow_resolution_scale = 1.0f; /// Camera-to-light distance past which this light stops casting shadows, though it still lights the - /// scene. Shadow slots are scarce (renderer::shadows::k_max_spot_shadows / k_max_point_shadows) and + /// scene. Shadow slots are scarce (renderer.shadows.spot_slots / point_slots) and /// this is what hands one back to a nearer light. 0 means no limit [[Reflect, Name("Shadow Distance"), Unit("m"), Group("Optimization")]] float m_shadow_distance = 60.0f; @@ -102,6 +148,24 @@ class [[ToastNode, Hidden, Icon("PointLight")]] TOAST_API Light : public Node3D [[Reflect, Name("Cull Distance"), Unit("m"), Group("Optimization")]] float m_cull_distance = 0.0f; + [[Reflect, Name("Shape"), Enum("Point", "Sphere", "Capsule", "Disc", "Rect"), Group("Area")]] + LightShape m_shape = LightShape::point; + + /// Sphere capsule and disc radius + [[Reflect, Name("Source Radius"), Unit("m"), Range(0.0, 10.0), Group("Area")]] + float m_source_radius = 0.1f; + + /// Capsule length or rect height + [[Reflect, Name("Source Length"), Unit("m"), Range(0.0, 20.0), Group("Area")]] + float m_source_length = 0.5f; + + [[Reflect, Name("Source Width"), Unit("m"), Range(0.0, 20.0), Group("Area")]] + float m_source_width = 0.5f; + + /// Marched and sampled soften voxel shadows by the shape but meshes stop shadowing voxels for this light + [[Reflect, Name("Voxel Shadows"), Enum("Shadow Map", "Marched", "Sampled"), Group("Area")]] + LightShadowMode m_shadow_mode = LightShadowMode::shadow_map; + // Deliberately not [[Reflect]] - applyFields() only ever touches reflected fields during prefab // deserialization, so this constructor-set value can never be clobbered LightType m_light_type = LightType::none; diff --git a/engine/src/toast/world/node_owner.cpp b/engine/src/toast/world/node_owner.cpp index 2d61414d8..5f382ef58 100644 --- a/engine/src/toast/world/node_owner.cpp +++ b/engine/src/toast/world/node_owner.cpp @@ -514,7 +514,38 @@ void INodeOwner::applyFields(Node& node, const assets::Prefab::BasicNode& data) std::string proper_name = snakeToNormalCase(data.name); node.name(proper_name); + applyFieldValues(node, data); +} +void INodeOwner::applyInteriorOverrides(Node& instance_root, const assets::Prefab::BasicNode& chunk) { + ZoneScoped; + for (const auto& entry : chunk.interior) { + Node* target = nullptr; + auto find = [&](this auto&& self, Node& node) -> void { + for (auto& child : node.m_children) { + if (target != nullptr) { + return; + } + if (child->uid().data() == entry.target.data()) { + target = &*child; + return; + } + self(*child); + } + }; + find(instance_root); + // The inner prefab dropped that node since this file was saved + if (target == nullptr) { + continue; + } + assets::Prefab::BasicNode values; + values.fields = entry.fields; + applyFieldValues(*target, values); + } +} + +void INodeOwner::applyFieldValues(Node& node, const assets::Prefab::BasicNode& data) { + ZoneScoped; const NodeInfo* info = node.info(); if (not info) { return; @@ -895,6 +926,7 @@ auto INodeOwner::instantiate(const assets::Handle& file, Instant } applyFields(*sub_root, *chunk); + applyInteriorOverrides(*sub_root, *chunk); sub_root->m_source_prefab = sub; // Everything below an instance root is interior to that instance diff --git a/engine/src/toast/world/node_owner.hpp b/engine/src/toast/world/node_owner.hpp index 889f29894..ca74fb04f 100644 --- a/engine/src/toast/world/node_owner.hpp +++ b/engine/src/toast/world/node_owner.hpp @@ -163,6 +163,10 @@ class TOAST_API INodeOwner { /// Copies reflected field values from the serialized BasicNode onto the live node; skips fields absent from NodeInfo void applyFields(Node& node, const assets::Prefab::BasicNode& data); + /// applyFields without the rename + void applyFieldValues(Node& node, const assets::Prefab::BasicNode& data); + /// Interior overrides of a nested instance chunk onto the nodes of the instance just built from it + void applyInteriorOverrides(Node& instance_root, const assets::Prefab::BasicNode& chunk); /** * @brief Restores edited Lua script variables saved in the prefab diff --git a/engine/src/toast/world/workspace.cpp b/engine/src/toast/world/workspace.cpp index 9f30e4f84..19e42ed72 100644 --- a/engine/src/toast/world/workspace.cpp +++ b/engine/src/toast/world/workspace.cpp @@ -2738,6 +2738,25 @@ void Workspace::tick() { event::send(m_focused_node->uid().get(), lua_schema_version, std::move(cards)); } +void Workspace::collectOutlinedRids(std::vector& rids) const { + rids.clear(); + if (!m_focused_node.exists()) { + return; + } + std::vector> pending {m_focused_node}; + while (!pending.empty()) { + const Box node = std::move(pending.back()); + pending.pop_back(); + if (!node.exists()) { + continue; + } + rids.push_back(node->rid()); + for (Box& child : node->childrenSnapshot()) { + pending.push_back(std::move(child)); + } + } +} + auto Workspace::gizmoRenderState() const -> GizmoRenderState { GizmoRenderState state; const bool has_node = m_focused_node.as().exists(); diff --git a/engine/src/toast/world/workspace.hpp b/engine/src/toast/world/workspace.hpp index 2737a91ea..703507f87 100644 --- a/engine/src/toast/world/workspace.hpp +++ b/engine/src/toast/world/workspace.hpp @@ -326,5 +326,8 @@ class TOAST_API Workspace : public INodeOwner { /// @note See docs/renderer.md RenderFrame pattern [[nodiscard]] auto gizmoRenderState() const -> GizmoRenderState; + + /// Rids of the focused node and its descendants for the selection outline + void collectOutlinedRids(std::vector& rids) const; }; } diff --git a/engine/src/toast/world/workspace_voxel.cpp b/engine/src/toast/world/workspace_voxel.cpp index cdf1263d6..29614f4f4 100644 --- a/engine/src/toast/world/workspace_voxel.cpp +++ b/engine/src/toast/world/workspace_voxel.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -1158,6 +1159,57 @@ void Workspace::subscribeVoxelEditing() { }); } +namespace { + +/// Nearest two sided triangle hit before max_t with the ray in mesh space and skinned meshes in bind pose +[[nodiscard]] +auto meshRayHit(const assets::Mesh& mesh, glm::vec3 origin, glm::vec3 direction, float max_t) -> std::optional { + const std::vector& vertices = mesh.vertices(); + const std::vector& indices = mesh.indices(); + std::optional nearest; + for (size_t i = 0; i + 2 < indices.size(); i += 3) { + const glm::vec3 a = vertices[indices[i]].position; + const glm::vec3 ab = glm::vec3(vertices[indices[i + 1]].position) - a; + const glm::vec3 ac = glm::vec3(vertices[indices[i + 2]].position) - a; + const glm::vec3 p = glm::cross(direction, ac); + const float det = glm::dot(ab, p); + if (det == 0.0f) { + continue; + } + const float inverse = 1.0f / det; + const glm::vec3 s = origin - a; + const glm::vec3 q = glm::cross(s, ab); + const float u = glm::dot(s, p) * inverse; + const float v = glm::dot(direction, q) * inverse; + const float t = glm::dot(ac, q) * inverse; + // Phrased so NaN from a sliver triangle fails + if (u >= 0.0f && v >= 0.0f && u + v <= 1.0f && t >= 0.0f && t < max_t) { + max_t = t; + nearest = t; + } + } + return nearest; +} + +/// Entry t of a unit ray into a world sphere or 0 from inside and none behind +[[nodiscard]] +auto raySphereEntry(const Ray& ray, glm::vec3 direction, glm::vec4 sphere) -> std::optional { + const glm::vec3 to_centre = glm::vec3(sphere) - ray.origin; + const float along = glm::dot(to_centre, direction); + const float miss = glm::dot(to_centre, to_centre) - (along * along); + const float radius_squared = sphere.w * sphere.w; + if (miss > radius_squared) { + return std::nullopt; + } + const float half_chord = std::sqrt(radius_squared - miss); + if (along + half_chord < 0.0f) { + return std::nullopt; + } + return std::max(0.0f, along - half_chord); +} + +} + auto Workspace::pickNodeUnderMouse() -> Box { const std::optional ray = voxelMouseRay(m_gizmo_mouse_pos); if (!ray.has_value() || !m_root_node.exists()) { @@ -1165,15 +1217,16 @@ auto Workspace::pickNodeUnderMouse() -> Box { } const glm::vec3 direction = glm::normalize(ray->direction); - Box best; - float best_t = std::numeric_limits::max(); - const auto consider = [&](const Box& node, float t) { - if (t >= 0.0f && t < best_t) { - best_t = t; - best = node; - } + struct Candidate { + Box node; + VoxelNode* voxels = nullptr; + MeshNode* mesh = nullptr; + glm::mat4 to_local {1.0f}; + float entry = 0.0f; }; + std::vector candidates; + const auto visit = [&](const auto& self, Box node) -> void { if (!node.exists() || !node->enabled()) { return; @@ -1181,27 +1234,21 @@ auto Workspace::pickNodeUnderMouse() -> Box { if (auto* voxels = reflect_cast(&*node)) { if (const voxel::Volume* volume = voxels->volume()) { voxels->syncTransform(); - const glm::mat4 to_volume = glm::inverse(voxels->getWorldTransform() * voxels->volumeLocalTransform()); - const glm::vec3 origin = glm::vec3(to_volume * glm::vec4(ray->origin, 1.0f)) / voxel::k_voxel_size; - const glm::vec3 step = glm::vec3(to_volume * glm::vec4(direction, 0.0f)) / voxel::k_voxel_size; - // Along step one unit is a world metre, so t compares with the meshes - if (const auto hit = voxel::raycast(*volume, origin, step, 1e6f)) { - consider(node, hit->t); + const glm::mat4 model = voxels->getWorldTransform() * voxels->volumeLocalTransform(); + if (const auto entry = raySphereEntry(*ray, direction, voxel::worldBoundingSphere(model, volume->brickDims()))) { + candidates.push_back({.node = node, .voxels = voxels, .to_local = glm::inverse(model), .entry = *entry}); } } } else if (auto* mesh = reflect_cast(&*node); mesh != nullptr && mesh->getMesh().hasValue()) { mesh->syncTransform(); const glm::mat4& world = mesh->getWorldTransform(); const glm::vec4 local = mesh->getMesh()->boundingSphere(); - const glm::vec3 centre = glm::vec3(world * glm::vec4(glm::vec3(local), 1.0f)); const float radius = local.w * std::max({glm::length(glm::vec3(world[0])), glm::length(glm::vec3(world[1])), glm::length(glm::vec3(world[2]))}); - const glm::vec3 to_centre = centre - ray->origin; - const float along = glm::dot(to_centre, direction); - const float miss = glm::dot(to_centre, to_centre) - (along * along); - if (miss <= radius * radius) { - consider(node, std::max(0.0f, along - std::sqrt((radius * radius) - miss))); + const glm::vec4 sphere(glm::vec3(world * glm::vec4(glm::vec3(local), 1.0f)), radius); + if (const auto entry = raySphereEntry(*ray, direction, sphere)) { + candidates.push_back({.node = node, .mesh = mesh, .to_local = glm::inverse(world), .entry = *entry}); } } for (const Box& child : node->children()) { @@ -1210,6 +1257,58 @@ auto Workspace::pickNodeUnderMouse() -> Box { }; visit(visit, m_root_node); + // Near bounds first so the exact tests stop once a hit beats the next bounds + std::ranges::sort(candidates, {}, &Candidate::entry); + uint32_t tested = 0; + const auto nearest = [&](bool voxels) -> std::pair, float> { + Box best; + float best_t = std::numeric_limits::max(); + for (const Candidate& candidate : candidates) { + if ((candidate.voxels != nullptr) != voxels) { + continue; + } + if (candidate.entry >= best_t) { + break; + } + ++tested; + // Along step one unit is a world metre + const glm::vec3 origin = glm::vec3(candidate.to_local * glm::vec4(ray->origin, 1.0f)); + const glm::vec3 step = glm::vec3(candidate.to_local * glm::vec4(direction, 0.0f)); + std::optional t; + if (candidate.voxels != nullptr) { + if (const voxel::Volume* volume = candidate.voxels->volume()) { + if (const auto hit = voxel::raycast(*volume, origin / voxel::k_voxel_size, step / voxel::k_voxel_size, best_t)) { + t = hit->t; + } + } + } else { + t = meshRayHit(candidate.mesh->getMesh().get(), origin, step, best_t); + } + if (t.has_value() && *t < best_t) { + best_t = *t; + best = candidate.node; + } + } + return {best, best_t}; + }; + + // Voxels outrank meshes so a mesh in front of or under a volume never takes its click + std::pair, float> hit = nearest(true); + const bool voxel_hit = hit.first.exists(); + if (!voxel_hit) { + hit = nearest(false); + } + Box best = std::move(hit.first); + TOAST_TRACE( + "World", + "Pick tested {} of {} candidates and hit {} {} at {:.2f} m", + tested, + candidates.size(), + voxel_hit ? "volume" : (best.exists() ? "mesh" : "nothing"), + best, + best.exists() ? hit.second : 0.0f + ); + // The hierarchy lists a prefab instance as one node while (best.exists() && best->m_prefab_interior) { best = best->parent(); diff --git a/flake.lock b/flake.lock index 631dde9d1..2899d8110 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1790046670, - "narHash": "sha256-MYiI+CzL0tuWgRPjGsKCDHqYs2T3OzMlMQWOYWG0qso=", + "lastModified": 1792094642, + "narHash": "sha256-zp8ToDMnqZcu4o4IH9pdwXFXGp7yLm/M0dEnNWnyd7I=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "6774f7bc253789b113a4f39285dc0fa100abeacc", + "rev": "fe13242281a67641c0102cdb1087f0ebe586c7df", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index abe8517f9..9fb4127a3 100644 --- a/flake.nix +++ b/flake.nix @@ -80,19 +80,21 @@ stdenv.cc.cc.lib ] ++ libraries; + # The editor needs LD_LIBRARY_PATH for native dependencies, but build tools + # (including CMake cached by vcpkg) must use their own Nix library closure. cmake-gen = pkgs.writeShellScriptBin "cmake-gen" '' - exec cmake -B build/Debug -G Ninja -DCMAKE_TOOLCHAIN_FILE=${pkgs.vcpkg}/share/vcpkg/scripts/buildsystems/vcpkg.cmake "$@" + exec env -u LD_LIBRARY_PATH ${pkgs.cmake}/bin/cmake -B build/Debug -G Ninja -DCMAKE_TOOLCHAIN_FILE=${pkgs.vcpkg}/share/vcpkg/scripts/buildsystems/vcpkg.cmake "$@" ''; cmake-gen-rel = pkgs.writeShellScriptBin "cmake-gen" '' - exec cmake --preset linux-relwithdebinfo -DCMAKE_TOOLCHAIN_FILE=${pkgs.vcpkg}/share/vcpkg/scripts/buildsystems/vcpkg.cmake "$@" + exec env -u LD_LIBRARY_PATH ${pkgs.cmake}/bin/cmake --preset linux-relwithdebinfo -DCMAKE_TOOLCHAIN_FILE=${pkgs.vcpkg}/share/vcpkg/scripts/buildsystems/vcpkg.cmake "$@" ''; cmake-build = pkgs.writeShellScriptBin "cmake-build" '' - exec cmake --build ./build/Debug --parallel "$(nproc)" "$@" + exec env -u LD_LIBRARY_PATH ${pkgs.cmake}/bin/cmake --build ./build/Debug --parallel "$(nproc)" "$@" ''; cmake-build-rel = pkgs.writeShellScriptBin "cmake-build" '' - exec cmake --build ./build/RelWithDebInfo --parallel "$(nproc)" "$@" + exec env -u LD_LIBRARY_PATH ${pkgs.cmake}/bin/cmake --build ./build/RelWithDebInfo --parallel "$(nproc)" "$@" ''; # editor = pkgs.writeShellScriptBin "editor" '' diff --git a/tests/renderer/01-shadow-resolution.cpp b/tests/renderer/01-shadow-resolution.cpp index f05ef7e38..476daf862 100644 --- a/tests/renderer/01-shadow-resolution.cpp +++ b/tests/renderer/01-shadow-resolution.cpp @@ -13,25 +13,27 @@ auto isPowerOfTwo(uint32_t value) -> bool { } -// A punctual light's shadow is rendered into a sub-rect of its layer whose size falls off with camera -// distance. Anything outside [k_min_punctual_resolution, k_punctual_resolution] would either scissor outside -// the attachment or waste the whole tile, and a non-power-of-two would put the light's texel grid out of -// step with the atlas grid +// Punctual tiles stay within the min and max resolution and a power of two TOAST_TEST_NAMED("Renderer", "renderer/01-shadow-resolution", test_renderer_01_shadow_resolution) { using namespace renderer::shadows; + constexpr float k_range = 10.0f; + const float reach = k_punctual_full_resolution_ranges * k_range; - // Inside the full-resolution radius the light gets the whole tile - assert(punctualShadowResolution(0.0f, 1.0f) == k_punctual_resolution); - assert(punctualShadowResolution(k_punctual_full_resolution_distance, 1.0f) == k_punctual_resolution); + // Within the full resolution reach the light gets the whole tile + assert(punctualShadowResolution(0.0f, k_range, 1.0f) == k_punctual_resolution); + assert(punctualShadowResolution(reach, k_range, 1.0f) == k_punctual_resolution); - // At and beyond the far end it sits at the floor, including absurd distances - assert(punctualShadowResolution(k_punctual_min_resolution_distance, 1.0f) == k_min_punctual_resolution); - assert(punctualShadowResolution(100000.0f, 1.0f) == k_min_punctual_resolution); + // Each doubling of the distance past the reach halves the tile down to the floor + assert(punctualShadowResolution(reach * 2.0f, k_range, 1.0f) == k_punctual_resolution / 2); + assert(punctualShadowResolution(100000.0f, k_range, 1.0f) == k_min_punctual_resolution); + + // A wider light keeps its detail further away + assert(punctualShadowResolution(reach * 2.0f, k_range * 2.0f, 1.0f) == k_punctual_resolution); // Monotonically non-increasing with distance, and always a legal power-of-two tile - uint32_t previous = punctualShadowResolution(0.0f, 1.0f); - for (float distance = 0.0f; distance < 200.0f; distance += 0.5f) { - const uint32_t resolution = punctualShadowResolution(distance, 1.0f); + uint32_t previous = punctualShadowResolution(0.0f, k_range, 1.0f); + for (float distance = 0.0f; distance < 500.0f; distance += 0.5f) { + const uint32_t resolution = punctualShadowResolution(distance, k_range, 1.0f); assert(resolution <= previous); assert(resolution >= k_min_punctual_resolution); assert(resolution <= k_punctual_resolution); @@ -40,14 +42,15 @@ TOAST_TEST_NAMED("Renderer", "renderer/01-shadow-resolution", test_renderer_01_s } // The artist scale only ever reduces, and never below the floor however small it goes - assert(punctualShadowResolution(0.0f, 0.5f) < k_punctual_resolution); - assert(punctualShadowResolution(0.0f, 0.5f) >= k_min_punctual_resolution); - assert(punctualShadowResolution(0.0f, 0.0f) == k_min_punctual_resolution); - assert(punctualShadowResolution(0.0f, 1.0f) >= punctualShadowResolution(0.0f, 0.75f)); + assert(punctualShadowResolution(0.0f, k_range, 0.5f) < k_punctual_resolution); + assert(punctualShadowResolution(0.0f, k_range, 0.5f) >= k_min_punctual_resolution); + assert(punctualShadowResolution(0.0f, k_range, 0.0f) == k_min_punctual_resolution); + assert(punctualShadowResolution(0.0f, k_range, 1.0f) >= punctualShadowResolution(0.0f, k_range, 0.75f)); // Out-of-range inputs are clamped rather than extrapolated - a negative distance is what a light sitting // exactly on the camera can produce through floating-point noise - assert(punctualShadowResolution(-5.0f, 1.0f) == k_punctual_resolution); - assert(punctualShadowResolution(0.0f, 5.0f) == k_punctual_resolution); - assert(punctualShadowResolution(0.0f, -1.0f) == k_min_punctual_resolution); + assert(punctualShadowResolution(-5.0f, k_range, 1.0f) == k_punctual_resolution); + assert(punctualShadowResolution(0.0f, k_range, 5.0f) == k_punctual_resolution); + assert(punctualShadowResolution(0.0f, k_range, -1.0f) == k_min_punctual_resolution); + assert(punctualShadowResolution(10.0f, -1.0f, 1.0f) == k_min_punctual_resolution); }