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