diff --git a/editor/Resources/splash_images/TUST0.jpg b/editor/Resources/splash_images/TUST0.jpg new file mode 100644 index 000000000..b50b8ee5e Binary files /dev/null and b/editor/Resources/splash_images/TUST0.jpg differ diff --git a/editor/Resources/splash_images/TUST1.jpg b/editor/Resources/splash_images/TUST1.jpg new file mode 100644 index 000000000..561adb4d5 Binary files /dev/null and b/editor/Resources/splash_images/TUST1.jpg differ diff --git a/editor/Resources/splash_images/TUST10.jpg b/editor/Resources/splash_images/TUST10.jpg new file mode 100644 index 000000000..319c9b5ad Binary files /dev/null and b/editor/Resources/splash_images/TUST10.jpg differ diff --git a/editor/Resources/splash_images/TUST11.jpg b/editor/Resources/splash_images/TUST11.jpg new file mode 100644 index 000000000..672d56b3e Binary files /dev/null and b/editor/Resources/splash_images/TUST11.jpg differ diff --git a/editor/Resources/splash_images/TUST12.jpg b/editor/Resources/splash_images/TUST12.jpg new file mode 100644 index 000000000..622a26d6b Binary files /dev/null and b/editor/Resources/splash_images/TUST12.jpg differ diff --git a/editor/Resources/splash_images/TUST13.jpg b/editor/Resources/splash_images/TUST13.jpg new file mode 100644 index 000000000..d7f9a4b1e Binary files /dev/null and b/editor/Resources/splash_images/TUST13.jpg differ diff --git a/editor/Resources/splash_images/TUST14.jpg b/editor/Resources/splash_images/TUST14.jpg new file mode 100644 index 000000000..2e90e802c Binary files /dev/null and b/editor/Resources/splash_images/TUST14.jpg differ diff --git a/editor/Resources/splash_images/TUST15.jpg b/editor/Resources/splash_images/TUST15.jpg new file mode 100644 index 000000000..2e52a7fcc Binary files /dev/null and b/editor/Resources/splash_images/TUST15.jpg differ diff --git a/editor/Resources/splash_images/TUST16.jpg b/editor/Resources/splash_images/TUST16.jpg new file mode 100644 index 000000000..fadab224c Binary files /dev/null and b/editor/Resources/splash_images/TUST16.jpg differ diff --git a/editor/Resources/splash_images/TUST17.jpg b/editor/Resources/splash_images/TUST17.jpg new file mode 100644 index 000000000..72e3d78b4 Binary files /dev/null and b/editor/Resources/splash_images/TUST17.jpg differ diff --git a/editor/Resources/splash_images/TUST18.jpg b/editor/Resources/splash_images/TUST18.jpg new file mode 100644 index 000000000..f57b50ff4 Binary files /dev/null and b/editor/Resources/splash_images/TUST18.jpg differ diff --git a/editor/Resources/splash_images/TUST19.jpg b/editor/Resources/splash_images/TUST19.jpg new file mode 100644 index 000000000..b557a1ce6 Binary files /dev/null and b/editor/Resources/splash_images/TUST19.jpg differ diff --git a/editor/Resources/splash_images/TUST2.jpg b/editor/Resources/splash_images/TUST2.jpg new file mode 100644 index 000000000..65237e3f6 Binary files /dev/null and b/editor/Resources/splash_images/TUST2.jpg differ diff --git a/editor/Resources/splash_images/TUST3.jpg b/editor/Resources/splash_images/TUST3.jpg new file mode 100644 index 000000000..4776544a1 Binary files /dev/null and b/editor/Resources/splash_images/TUST3.jpg differ diff --git a/editor/Resources/splash_images/TUST4.jpg b/editor/Resources/splash_images/TUST4.jpg new file mode 100644 index 000000000..ca1ba8d75 Binary files /dev/null and b/editor/Resources/splash_images/TUST4.jpg differ diff --git a/editor/Resources/splash_images/TUST5.jpg b/editor/Resources/splash_images/TUST5.jpg new file mode 100644 index 000000000..6b515a7e0 Binary files /dev/null and b/editor/Resources/splash_images/TUST5.jpg differ diff --git a/editor/Resources/splash_images/TUST6.jpg b/editor/Resources/splash_images/TUST6.jpg new file mode 100644 index 000000000..558e092ec Binary files /dev/null and b/editor/Resources/splash_images/TUST6.jpg differ diff --git a/editor/Resources/splash_images/TUST7.jpg b/editor/Resources/splash_images/TUST7.jpg new file mode 100644 index 000000000..c52e0ce13 Binary files /dev/null and b/editor/Resources/splash_images/TUST7.jpg differ diff --git a/editor/Resources/splash_images/TUST8.jpg b/editor/Resources/splash_images/TUST8.jpg new file mode 100644 index 000000000..43e3e7724 Binary files /dev/null and b/editor/Resources/splash_images/TUST8.jpg differ diff --git a/editor/Resources/splash_images/TUST9.jpg b/editor/Resources/splash_images/TUST9.jpg new file mode 100644 index 000000000..47f17c1fd Binary files /dev/null and b/editor/Resources/splash_images/TUST9.jpg differ diff --git a/editor/Resources/splash_images/ulysses.jpeg b/editor/Resources/splash_images/ulysses.jpg similarity index 100% rename from editor/Resources/splash_images/ulysses.jpeg rename to editor/Resources/splash_images/ulysses.jpg diff --git a/editor/Workspace/MainWindowViewModel.cs b/editor/Workspace/MainWindowViewModel.cs index c413201d1..efbc3e7a4 100644 --- a/editor/Workspace/MainWindowViewModel.cs +++ b/editor/Workspace/MainWindowViewModel.cs @@ -1035,7 +1035,6 @@ private async Task ReloadGame() { tasks.Add(LoaderTask.Run( "cmake build", "cmake", - // the game shares std types with the engine so both must use the same CRT // RelWithDebInfo keeps game PDB and ttracy #if DEBUG "--build .toast/cmake_cache --config Debug --parallel" @@ -1064,8 +1063,6 @@ private async Task ReloadGame() { await new SimpleLoaderWindow(vm).ShowDialog(owner); } - // The release build packs the files on disk, so unsaved edits (e.g. init_scene in the project settings) would silently be - // left out of player.exe; returns false when the user cancels private async Task SaveBeforeBuild(Avalonia.Controls.Window owner) { var dirtyWorkspaces = m_workspaces.Values.Where(ws => ws.IsModified).ToList(); if (dirtyWorkspaces.Count == 0 && CollectDirtyTools().Count == 0) return true; @@ -1245,13 +1242,25 @@ await Task.Run(() => { LoaderTask.Run( "cmake configure (Release)", "cmake", - $"lib/ -B .toast/cmake_release_cache {cmakeGenerator} -DTOAST_PATH={toastPath} -DCMAKE_BUILD_TYPE=Release" + #if DEBUG + $"lib/ -B .toast/cmake_release_cache {cmakeGenerator} -DTOAST_PATH={toastPath} -DCMAKE_BUILD_TYPE=Debug" + #elif RELWITHDEBINFO + $"lib/ -B .toast/cmake_release_cache {cmakeGenerator} -DTOAST_PATH={toastPath} -DCMAKE_BUILD_TYPE=RelWithDebInfo" + #else + $"lib/ -B .toast/cmake_release_cache {cmakeGenerator} -DTOAST_PATH={toastPath} -DCMAKE_BUILD_TYPE=Release" + #endif ), // Build Game DLL on Release LoaderTask.Run( "cmake build (Release)", "cmake", - "--build .toast/cmake_release_cache --config Release --parallel" + #if DEBUG + "--build .toast/cmake_release_cache --config Debug --parallel" + #elif RELWITHDEBINFO + "--build .toast/cmake_release_cache --config RelWithDebInfo --parallel" + #else + "--build .toast/cmake_release_cache --config Release --parallel" + #endif ), // Copy Engine libs LoaderTask.Do("copy libraries", CopyDlls), diff --git a/engine/assets/shaders/bloom.slang b/engine/assets/shaders/bloom.slang index ec2f9bb23..2684a791e 100644 --- a/engine/assets/shaders/bloom.slang +++ b/engine/assets/shaders/bloom.slang @@ -53,25 +53,28 @@ float4 fragmentDownsample(VSOutput input, uniform bool applyKaris) : SV_Target0 float2 texel = gParams.inverseSourceSize; float2 uv = input.uv; - float3 a = gSource.Sample(uv + float2(-2.0, 2.0) * texel).rgb; - float3 b = gSource.Sample(uv + float2( 0.0, 2.0) * texel).rgb; - float3 c = gSource.Sample(uv + float2( 2.0, 2.0) * texel).rgb; - float3 d = gSource.Sample(uv + float2(-2.0, 0.0) * texel).rgb; - float3 e = gSource.Sample(uv).rgb; - float3 f = gSource.Sample(uv + float2( 2.0, 0.0) * texel).rgb; - float3 g = gSource.Sample(uv + float2(-2.0,-2.0) * texel).rgb; - float3 h = gSource.Sample(uv + float2( 0.0,-2.0) * texel).rgb; - float3 i = gSource.Sample(uv + float2( 2.0,-2.0) * texel).rgb; - + // Interleaved so the texture queue never throttles and live registers stay low float3 j = gSource.Sample(uv + float2(-1.0, 1.0) * texel).rgb; float3 k = gSource.Sample(uv + float2( 1.0, 1.0) * texel).rgb; float3 l = gSource.Sample(uv + float2(-1.0,-1.0) * texel).rgb; float3 m = gSource.Sample(uv + float2( 1.0,-1.0) * texel).rgb; - float3 group0 = (j + k + l + m) * 0.25; + + float3 e = gSource.Sample(uv).rgb; + float3 b = gSource.Sample(uv + float2( 0.0, 2.0) * texel).rgb; + float3 d = gSource.Sample(uv + float2(-2.0, 0.0) * texel).rgb; + float3 a = gSource.Sample(uv + float2(-2.0, 2.0) * texel).rgb; float3 group1 = (a + b + d + e) * 0.25; + + float3 c = gSource.Sample(uv + float2( 2.0, 2.0) * texel).rgb; + float3 f = gSource.Sample(uv + float2( 2.0, 0.0) * texel).rgb; float3 group2 = (b + c + e + f) * 0.25; + + float3 g = gSource.Sample(uv + float2(-2.0,-2.0) * texel).rgb; + float3 h = gSource.Sample(uv + float2( 0.0,-2.0) * texel).rgb; float3 group3 = (d + e + g + h) * 0.25; + + float3 i = gSource.Sample(uv + float2( 2.0,-2.0) * texel).rgb; float3 group4 = (e + f + h + i) * 0.25; float3 result; @@ -113,20 +116,18 @@ float4 fragmentUpsample(VSOutput input) : SV_Target0 float2 offset = gParams.inverseSourceSize * gParams.filterRadius; float2 uv = input.uv; - float3 a = gSource.Sample(uv + float2(-1.0, 1.0) * offset).rgb; + float3 e = gSource.Sample(uv).rgb; float3 b = gSource.Sample(uv + float2( 0.0, 1.0) * offset).rgb; - float3 c = gSource.Sample(uv + float2( 1.0, 1.0) * offset).rgb; float3 d = gSource.Sample(uv + float2(-1.0, 0.0) * offset).rgb; - float3 e = gSource.Sample(uv).rgb; float3 f = gSource.Sample(uv + float2( 1.0, 0.0) * offset).rgb; - float3 g = gSource.Sample(uv + float2(-1.0, -1.0) * offset).rgb; float3 h = gSource.Sample(uv + float2( 0.0, -1.0) * offset).rgb; - float3 i = gSource.Sample(uv + float2( 1.0, -1.0) * offset).rgb; + float3 result = e * 4.0 + (b + d + f + h) * 2.0; - float3 result = e * 4.0; - result += (b + d + f + h) * 2.0; - result += (a + c + g + i); - result *= 1.0 / 16.0; + float3 a = gSource.Sample(uv + float2(-1.0, 1.0) * offset).rgb; + float3 c = gSource.Sample(uv + float2( 1.0, 1.0) * offset).rgb; + float3 g = gSource.Sample(uv + float2(-1.0, -1.0) * offset).rgb; + float3 i = gSource.Sample(uv + float2( 1.0, -1.0) * offset).rgb; + result = (result + a + c + g + i) * 0.0625; return float4(result, 1.0); } diff --git a/engine/assets/shaders/fog_media.slang b/engine/assets/shaders/fog_media.slang new file mode 100644 index 000000000..1ad8021cc --- /dev/null +++ b/engine/assets/shaders/fog_media.slang @@ -0,0 +1,71 @@ +// Fog media shared by the volumetrics pass and GI rays +import lighting; + +// Mirrors VulkanRenderer::FogVolumeGpu +struct FogVolume +{ + float4x4 worldToLocal; + /// xyz half extents w edge fade in meters + float4 extentsFade; + /// rgb albedo a extinction per meter + float4 albedoDensity; + /// rgb radiance per meter at density 1 a noise strength + float4 emissionNoise; + /// xyz wind in meters per second w noise scale in meters + float4 windScale; + /// x anisotropy y ambient scale + float4 phase; + /// xyz box centre in world space + float4 centre; +}; + +[[vk::binding(22, 0)]] +StructuredBuffer gFogVolumes; + +uint fogVolumeCount() +{ + return uint(gCamera.fogMedium.w); +} + +/// Height fog extinction along origin + direction * t integrated over [near, far] +float heightFogDepth(float3 origin, float3 direction, float near, float far) +{ + const float density = gCamera.fogMedium.x; + if (density <= 0.0 || far <= near) + { + return 0.0; + } + const float falloff = gCamera.fogMedium.z; + const float startHeight = origin.y + direction.y * near - gCamera.fogMedium.y; + const float atStart = density * exp(-clamp(falloff * startHeight, -20.0, 20.0)); + const float slope = falloff * direction.y; + const float span = far - near; + return abs(slope) < 1e-4 ? atStart * span : atStart * (1.0 - exp(-clamp(slope * span, -20.0, 20.0))) / slope; +} + +/// Volumes count as uniform boxes since noise averages out and the edge fade is thin +float fogOpticalDepth(float3 origin, float3 direction, float near, float far) +{ + float depth = heightFogDepth(origin, direction, near, far); + const uint count = fogVolumeCount(); + for (uint v = 0u; v < count; ++v) + { + const FogVolume volume = gFogVolumes[v]; + const float3 localOrigin = mul(volume.worldToLocal, float4(origin, 1.0)).xyz; + const float3 localDirection = mul(volume.worldToLocal, float4(direction, 0.0)).xyz; + const float3 inverse = 1.0 / select(abs(localDirection) > 1e-8, localDirection, float3(1e-8, 1e-8, 1e-8)); + const float3 t0 = (-volume.extentsFade.xyz - localOrigin) * inverse; + const float3 t1 = (volume.extentsFade.xyz - localOrigin) * inverse; + const float3 low = min(t0, t1); + const float3 high = max(t0, t1); + const float enter = max(max(low.x, low.y), max(low.z, near)); + const float exit = min(min(high.x, high.y), min(high.z, far)); + depth += max(exit - enter, 0.0) * volume.albedoDensity.a; + } + return depth; +} + +float fogTransmittance(float3 origin, float3 direction, float near, float far) +{ + return exp(-fogOpticalDepth(origin, direction, near, far)); +} diff --git a/engine/assets/shaders/fog_media.slang.meta b/engine/assets/shaders/fog_media.slang.meta new file mode 100644 index 000000000..24a92ae70 --- /dev/null +++ b/engine/assets/shaders/fog_media.slang.meta @@ -0,0 +1,4 @@ +uid = "F0gM3d1aSh4" +type = "shader" +created_at = "2026-10-26T00:00:00.0000000Z" +modified_at = "2026-10-26T00:00:00.0000000Z" diff --git a/engine/assets/shaders/ggx_sampling.slang b/engine/assets/shaders/ggx_sampling.slang new file mode 100644 index 000000000..82104b158 --- /dev/null +++ b/engine/assets/shaders/ggx_sampling.slang @@ -0,0 +1,38 @@ +// GGX visible normal sampling shared by SSR and traced specular + +static const float kGgxPi = 3.14159265; + +float ggxD(float NdotH, float alpha) +{ + const float a2 = alpha * alpha; + const float d = NdotH * NdotH * (a2 - 1.0) + 1.0; + return a2 / max(kGgxPi * d * d, 1e-8); +} + +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); +} + +/// 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 * kGgxPi * 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))); +} + +/// 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); +} diff --git a/engine/assets/shaders/ggx_sampling.slang.meta b/engine/assets/shaders/ggx_sampling.slang.meta new file mode 100644 index 000000000..e40af17f8 --- /dev/null +++ b/engine/assets/shaders/ggx_sampling.slang.meta @@ -0,0 +1,4 @@ +uid = "G9xSampl1ng" +type = "shader" +created_at = "2026-10-25T00:00:00.0000000Z" +modified_at = "2026-10-25T00:00:00.0000000Z" diff --git a/engine/assets/shaders/gi_common.slang b/engine/assets/shaders/gi_common.slang new file mode 100644 index 000000000..84b387469 --- /dev/null +++ b/engine/assets/shaders/gi_common.slang @@ -0,0 +1,440 @@ +// No resources so every GI pass can import it +import depth_convention; + +static const uint kGiMaxCascades = 6u; +static const uint kGiEmpty = 0xFFFFFFFFu; +static const uint kGiFlagSeen = 1u; + +/// Mirrors renderer::GiPass counters with counts per parity then failures then probes pixels touched +static const uint kGiCounterTableFull = 16u; +static const uint kGiCounterPoolFull = 17u; +static const uint kGiCounterSeen = 18u; +static const uint kGiCounterDepositFull = 24u; +static const uint kGiCounterRays = 25u; +static const uint kGiCounterSlots = 26u; +static const uint kGiCounterInvalidated = 27u; +/// Hit points stored for the secondary cache per pool parity then the ones past the buffer +static const uint kGiCounterPoints = 28u; +static const uint kGiCounterPointsDropped = 30u; +static const uint kGiCounterFresh = 31u; +/// Pixels that found a deposit and may exceed the spawn list +static const uint kGiCounterSpawn = 38u; +/// Hits queued by pixel rays +static const uint kGiCounterPixelHits = 39u; +/// Hits queued by point rays +static const uint kGiCounterPointHits = 40u; + +/// Frames an unseen probe keeps its history and tile +static const uint kGiKeepFrames = 16u; + +/// LODs coarser than its points +static const uint kGiSecondaryLods = 2u; + +static const float kGiMaxHistory = 4096.0; +static const float kGiMinHistory = 1.0 / 64.0; +/// Sixteenths of the history rays of every 64th bucket summed per cascade +static const uint kGiCounterDepth = 32u; + +/// Grid stride passes always dispatch this many and loop over the counters +static const uint kGiStrideThreads = 4096u * 64u; + +/// Equal area directions per probe are 2N by N with N this times 2 per cascade +static const uint kGiBaseDirections = 4u; +static const uint kGiC0Directions = 2u * kGiBaseDirections * kGiBaseDirections; + +/// 6 by 6 octahedral texels inside a one texel border +static const uint kGiTileSize = 8u; +static const uint kGiTileInterior = 6u; +/// Mirrors renderer::GiPass k_atlas_tiles_per_row and k_atlas_rows +static const uint kGiAtlasTilesPerRow = 256u; +static const uint kGiAtlasRows = 128u; +static const float2 kGiAtlasSize = float2(kGiAtlasTilesPerRow * kGiTileSize, kGiAtlasRows * kGiTileSize); + +/// x table offset y table mask z pool offset w pool capacity +typedef uint4 GiCascadeLayout; + +/// Mirrors renderer::GiPass::FrameData +struct GiFrame +{ + /// Jittered since depth was rasterized with it + float4x4 inverseViewProjection; + /// xyz camera w probe spacing at LOD 0 cascade 0 + float4 camera; + /// x Chebyshev distance where LOD 1 starts y LOD overlap share z lifetime in frames w spawn step in pixels + float4 lod; + /// x frame y cascades z pool parity written this frame w 0 drops every carried probe + uint4 frame; + /// x width y height zw spawn offset inside the step + uint4 viewport; + /// x pool entries per parity y table entries of every cascade z deposit probes of every cascade w direction slots + uint4 pool; + /// x interval of c0 in units of its probe spacing y sun reach in meters z deposit fixed point scale w cone slope + float4 rays; + /// xy R2 jitter z history kept per frame w history left after a change nearby + float4 jitter; + /// x hit points per parity y secondary cache on z asfloat share of off screen hits kept w points kept + uint4 secondary; + /// x max ray luminance y history floor in rays z keep of that floor + float4 limits; + GiCascadeLayout cascades[kGiMaxCascades]; + /// x deposit probe offset y deposit probe capacity w directions per probe + uint4 deposits[kGiMaxCascades]; + /// x spawn list capacity y point hit offset z hit capacity + uint4 queues; +}; + +struct GiPoint +{ + float3 position; + /// giOctEncode of the hit normal as two unorm16 + uint normal; +}; + +struct GiProbe +{ + /// xyz cell w LOD + int4 cell; + uint lastSeen; + /// Index in the previous pool or kGiEmpty for a new probe + uint previous; + /// kGiEmpty unless a pixel touched the probe this frame + uint deposit; + /// First direction slot this frame or kGiEmpty + uint slots; +}; + +/// Cell coordinates modulo 512 stay unique inside the few hundred cells one LOD spans around the camera +uint giKey(int3 cell, uint lod) +{ + return (uint(cell.x) & 511u) | ((uint(cell.y) & 511u) << 9) | ((uint(cell.z) & 511u) << 18) | ((lod & 31u) << 27); +} + +uint giHash(uint key) +{ + key ^= key >> 16; + key *= 0x7feb352du; + key ^= key >> 15; + key *= 0x846ca68bu; + key ^= key >> 16; + return key; +} + +/// Deposit transmittance in sixteenths so a ray through fog keeps a fraction +static const float kGiTransmittanceScale = 16.0; + +float giRandom(inout uint state) +{ + state = giHash(state + 0x9e3779b9u); + return float(state >> 8) / 16777216.0; +} + +float giSpacing(GiFrame frame, uint lod, uint cascade) +{ + return frame.camera.w * float(1u << min(lod + cascade, 30u)); +} + +int3 giCell(float3 world, float spacing) +{ + return int3(floor(world / spacing)); +} + +/// Chebyshev so LOD shells follow the grid +uint giLod(GiFrame frame, float3 world, out bool overlap) +{ + const float3 offset = abs(world - frame.camera.xyz); + const float distance = max(offset.x, max(offset.y, offset.z)); + const float start = frame.lod.x; + const uint lod = distance <= start ? 0u : min(uint(floor(log2(distance / start))), 29u); + overlap = distance >= frame.lod.y * start * float(1u << (lod + 1u)); + return lod; +} + +float3 giWorldAt(GiFrame frame, float2 uv, float depth) +{ + const float4 world = mul(frame.inverseViewProjection, float4(uv * 2.0 - 1.0, depth, 1.0)); + return world.xyz / world.w; +} + +bool giIsSky(float depth) +{ + return depth == kFarDepth; +} + +/// Share of a c0 cell along the normal since voxel faces lie on cell planes and jittered depth would flip the cell +static const float kGiSurfaceBias = 0.25; + +/// Toward the camera where nothing wrote a normal +float3 giSurfaceNormal(float4 encoded, float3 world, float3 camera) +{ + const float length2 = dot(encoded.xyz, encoded.xyz); + return length2 > 0.25 ? encoded.xyz * rsqrt(length2) : normalize(camera - world); +} + +float giIntervalEnd(GiFrame frame, uint lod, uint cascade) +{ + return frame.rays.x * giSpacing(frame, lod, 0u) * float(1u << min(2u * cascade, 30u)); +} + +/// Equal area map so every direction bucket covers the same solid angle +float2 giEncodeDirection(float3 direction) +{ + const float phi = atan2(direction.y, direction.x); + return float2(frac(phi / 6.28318531), saturate(direction.z * 0.5 + 0.5)); +} + +float3 giDecodeDirection(float2 uv) +{ + const float phi = uv.x * 6.28318531; + const float z = uv.y * 2.0 - 1.0; + const float r = sqrt(saturate(1.0 - z * z)); + return float3(r * cos(phi), r * sin(phi), z); +} + +uint giSide(uint cascade) +{ + return kGiBaseDirections << cascade; +} + +uint giMortonSpread(uint value) +{ + value &= 0x0000ffffu; + value = (value | (value << 8)) & 0x00ff00ffu; + value = (value | (value << 4)) & 0x0f0f0f0fu; + value = (value | (value << 2)) & 0x33333333u; + value = (value | (value << 1)) & 0x55555555u; + return value; +} + +/// Two Morton ordered N by N halves so the four children of a direction are contiguous one cascade up +/// @p uv from giEncodeDirection so a ray split over every cascade encodes once +uint giDirectionIndex(float2 uv, uint cascade) +{ + const uint side = giSide(cascade); + const uint x = min(uint(uv.x * float(2u * side)), 2u * side - 1u); + const uint y = min(uint(uv.y * float(side)), side - 1u); + return (x / side) * side * side + (giMortonSpread(x % side) | (giMortonSpread(y) << 1)); +} + +uint giMortonCompact(uint value) +{ + value &= 0x55555555u; + value = (value | (value >> 1)) & 0x33333333u; + value = (value | (value >> 2)) & 0x0f0f0f0fu; + value = (value | (value >> 4)) & 0x00ff00ffu; + value = (value | (value >> 8)) & 0x0000ffffu; + return value; +} + +static const float3 kGiC0DirectionsTable[32] = { + float3(0.61108887, 0.25312130, -0.75000000), + float3(0.25312130, 0.61108887, -0.75000000), + float3(0.89454251, 0.37053164, -0.25000000), + float3(0.37053164, 0.89454251, -0.25000000), + float3(-0.25312130, 0.61108887, -0.75000000), + float3(-0.61108887, 0.25312130, -0.75000000), + float3(-0.37053164, 0.89454251, -0.25000000), + float3(-0.89454251, 0.37053164, -0.25000000), + float3(0.89454251, 0.37053164, 0.25000000), + float3(0.37053164, 0.89454251, 0.25000000), + float3(0.61108887, 0.25312130, 0.75000000), + float3(0.25312130, 0.61108887, 0.75000000), + float3(-0.37053164, 0.89454251, 0.25000000), + float3(-0.89454251, 0.37053164, 0.25000000), + float3(-0.25312130, 0.61108887, 0.75000000), + float3(-0.61108887, 0.25312130, 0.75000000), + float3(-0.61108887, -0.25312130, -0.75000000), + float3(-0.25312130, -0.61108887, -0.75000000), + float3(-0.89454251, -0.37053164, -0.25000000), + float3(-0.37053164, -0.89454251, -0.25000000), + float3(0.25312130, -0.61108887, -0.75000000), + float3(0.61108887, -0.25312130, -0.75000000), + float3(0.37053164, -0.89454251, -0.25000000), + float3(0.89454251, -0.37053164, -0.25000000), + float3(-0.89454251, -0.37053164, 0.25000000), + float3(-0.37053164, -0.89454251, 0.25000000), + float3(-0.61108887, -0.25312130, 0.75000000), + float3(-0.25312130, -0.61108887, 0.75000000), + float3(0.37053164, -0.89454251, 0.25000000), + float3(0.89454251, -0.37053164, 0.25000000), + float3(0.25312130, -0.61108887, 0.75000000), + float3(0.61108887, -0.25312130, 0.75000000), +}; + +float3 giDirectionOf(uint index, uint cascade) +{ + if (cascade == 0u && index < 32u) + { + return kGiC0DirectionsTable[index]; + } + const uint side = giSide(cascade); + const uint half = index / (side * side); + const uint morton = index % (side * side); + const uint x = half * side + giMortonCompact(morton); + const uint y = giMortonCompact(morton >> 1); + return giDecodeDirection((float2(x, y) + 0.5) / float2(2u * side, side)); +} + +float2 giRayPoint(GiFrame frame, uint index) +{ + // Fixed point since a float index loses the fraction past a few hundred thousand rays + const uint2 r2 = uint2(0x80000000u, 0x80000000u) + index * uint2(3242174889u, 2447445414u); + return frac(float2(r2 >> 8) / 16777216.0 + frame.jitter.xy); +} + +int3 giSurfaceCell(GiFrame frame, float3 world, float3 normal, uint lod, uint cascade) +{ + const float3 point = world + normal * (kGiSurfaceBias * giSpacing(frame, lod, 0u)); + return giCell(point, giSpacing(frame, lod, cascade)); +} + +/// 16 bit transmittance since a small blend moves the mean in steps a byte would round away +struct GiHistory +{ + /// Red and green halves + uint redGreen; + /// Blue half then transmittance as unorm16 + uint blueTransmittance; + float rays; +}; + +/// 32 bit maths since f32tof16 needs shaderFloat16 and shaderInt16 which the device does not enable +/// Rounds to nearest and flushes under 2^-14 to zero and saturates past the largest half +uint giFloatToHalf(float value) +{ + const uint bits = asuint(value); + const uint sign = (bits >> 16) & 0x8000u; + int exponent = int((bits >> 23) & 0xffu) - 112; + uint mantissa = (bits & 0x7fffffu) + 0x1000u; + if ((mantissa & 0x800000u) != 0u) + { + mantissa = 0u; + exponent += 1; + } + if (exponent <= 0) + { + return sign; + } + if (exponent >= 31) + { + return sign | 0x7bffu; + } + return sign | (uint(exponent) << 10) | (mantissa >> 13); +} + +float giHalfToFloat(uint half) +{ + const uint sign = (half & 0x8000u) << 16; + const uint exponent = (half >> 10) & 0x1fu; + if (exponent == 0u) + { + return asfloat(sign); + } + return asfloat(sign | ((exponent + 112u) << 23) | ((half & 0x3ffu) << 13)); +} + +GiHistory giPackHistory(float3 radiance, float transmittance, float rays) +{ + GiHistory history; + if (rays < kGiMinHistory) + { + history.redGreen = 0u; + history.blueTransmittance = 0u; + history.rays = 0.0; + return history; + } + history.redGreen = giFloatToHalf(radiance.r) | (giFloatToHalf(radiance.g) << 16); + history.blueTransmittance = giFloatToHalf(radiance.b) | (uint(saturate(transmittance) * 65535.0 + 0.5) << 16); + history.rays = min(rays, kGiMaxHistory); + return history; +} + +/// Transparent with @p rays 0 where nothing ever landed so the cascade above fills it in +float4 giHistoryBucket(GiHistory history, out float rays) +{ + rays = history.rays; + if (rays < kGiMinHistory) + { + rays = 0.0; + return float4(0.0, 0.0, 0.0, 1.0); + } + const float3 radiance = float3( + giHalfToFloat(history.redGreen & 0xffffu), giHalfToFloat(history.redGreen >> 16), giHalfToFloat(history.blueTransmittance & 0xffffu) + ); + return float4(radiance, float(history.blueTransmittance >> 16) / 65535.0); +} + +int3 giNeighbourOffset(uint corner) +{ + return int3(int(corner & 1u), int((corner >> 1) & 1u), int((corner >> 2) & 1u)); +} + +float giNeighbourWeight(uint corner, float3 upper) +{ + const int3 offset = giNeighbourOffset(corner); + return (offset.x != 0 ? upper.x : 1.0 - upper.x) * (offset.y != 0 ? upper.y : 1.0 - upper.y) * + (offset.z != 0 ? upper.z : 1.0 - upper.z); +} + +/// Lowest of the 8 parent cascade probes around a probe centre and the weight of the upper one per axis +void giParentNeighbourhood(int3 cell, out int3 base, out float3 upper) +{ + const int3 odd = cell & 1; + base = (cell >> 1) - (int3(1, 1, 1) - odd); + upper = float3(odd.x != 0 ? 0.25 : 0.75, odd.y != 0 ? 0.25 : 0.75, odd.z != 0 ? 0.25 : 0.75); +} + +uint giParentCorner(int3 cell) +{ + const int3 even = int3(1, 1, 1) - (cell & 1); + return uint(even.x) | (uint(even.y) << 1) | (uint(even.z) << 2); +} + +float2 giOctEncode(float3 n) +{ + n /= abs(n.x) + abs(n.y) + abs(n.z); + float2 p = n.xy; + if (n.z < 0.0) + { + p = (1.0 - abs(n.yx)) * float2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0); + } + return p * 0.5 + 0.5; +} + +float3 giOctDecode(float2 uv) +{ + const float2 f = uv * 2.0 - 1.0; + float3 n = float3(f, 1.0 - abs(f.x) - abs(f.y)); + const float fold = saturate(-n.z); + n.x += n.x >= 0.0 ? -fold : fold; + n.y += n.y >= 0.0 ? -fold : fold; + return normalize(n); +} + +uint2 giTileOrigin(uint tile) +{ + return uint2(tile % kGiAtlasTilesPerRow, tile / kGiAtlasTilesPerRow) * kGiTileSize; +} + +/// Texels 1 to 7 are the interior so bilinear taps reach the border at most +float2 giTileUv(uint tile, float3 normal) +{ + const float2 texel = float2(giTileOrigin(tile)) + 1.0 + giOctEncode(normal) * float(kGiTileInterior); + return texel / kGiAtlasSize; +} + +uint giPackNormal(float3 normal) +{ + const uint2 packed = uint2(saturate(giOctEncode(normal)) * 65535.0 + 0.5); + return packed.x | (packed.y << 16); +} + +float3 giUnpackNormal(uint packed) +{ + return giOctDecode(float2(packed & 0xffffu, packed >> 16) / 65535.0); +} + +uint giSecondaryLod(GiFrame frame, float3 world) +{ + bool overlap = false; + return giLod(frame, world, overlap) + kGiSecondaryLods; +} diff --git a/engine/assets/shaders/gi_common.slang.meta b/engine/assets/shaders/gi_common.slang.meta new file mode 100644 index 000000000..3daf77ed9 --- /dev/null +++ b/engine/assets/shaders/gi_common.slang.meta @@ -0,0 +1,4 @@ +uid = "G1Comm0nRC0" +type = "shader" +created_at = "2026-10-22T00:00:00.0000000Z" +modified_at = "2026-10-22T00:00:00.0000000Z" diff --git a/engine/assets/shaders/gi_debug.slang b/engine/assets/shaders/gi_debug.slang new file mode 100644 index 000000000..fe10d113b --- /dev/null +++ b/engine/assets/shaders/gi_debug.slang @@ -0,0 +1,228 @@ +// Red where a pixel finds no probe +import fullscreen; +import gi_common; + +[[vk::binding(0, 0)]] +Sampler2D gSource; + +[[vk::binding(1, 0)]] +Sampler2D gDepth; + +[[vk::binding(2, 0)]] +StructuredBuffer gFrame; + +[[vk::binding(3, 0)]] +StructuredBuffer gTableKeys; + +[[vk::binding(4, 0)]] +StructuredBuffer gTableValues; + +[[vk::binding(5, 0)]] +StructuredBuffer gPool; + +[[vk::binding(6, 0)]] +Sampler2D gNormal; + +[[vk::binding(7, 0)]] +StructuredBuffer gDeposits; + +[[vk::binding(8, 0)]] +StructuredBuffer gDepositSlots; + +[[vk::binding(9, 0)]] +Sampler2D gIrradiance; + +[[vk::binding(10, 0)]] +StructuredBuffer gHistory; + +/// y smoothed share the irradiance changed per frame +[[vk::binding(11, 0)]] +StructuredBuffer gChange; + +struct GiDebugPush +{ + uint cascade; + /// Mirrors renderer::GiSettings debug_mode + uint mode; +}; + +[[vk::push_constant]] +GiDebugPush gPush; + +static const uint kGiDebugCells = 0u; +static const uint kGiDebugRadiance = 1u; +static const uint kGiDebugCoverage = 2u; +static const uint kGiDebugIrradiance = 3u; +static const uint kGiDebugDepth = 4u; +static const uint kGiDebugChange = 5u; +static const uint kGiDebugSamples = 256u; + +typedef FullscreenVSOutput VSOutput; + +[shader("vertex")] +VSOutput vertexMain(uint vertexId : SV_VertexID) +{ + return fullscreenTriangle(vertexId); +} + +uint giFind(GiFrame frame, uint cascade, int3 cell, uint lod) +{ + const GiCascadeLayout layout = frame.cascades[cascade]; + const uint key = giKey(cell, lod); + uint slot = giHash(key) & layout.y; + for (uint attempt = 0u; attempt < 32u; ++attempt) + { + const uint stored = gTableKeys[layout.x + slot]; + if (stored == key) + { + return gTableValues[layout.x + slot]; + } + if (stored == kGiEmpty) + { + return kGiEmpty; + } + slot = (slot + 1u) & layout.y; + } + return kGiEmpty; +} + +float3 giCellColour(int3 cell, uint lod) +{ + const uint hash = giHash(giKey(cell, lod) ^ 0x9e3779b9u); + return float3(float(hash & 255u), float((hash >> 8) & 255u), float((hash >> 16) & 255u)) / 255.0 * 0.8 + 0.2; +} + +/// Mean radiance of buckets with history or the share of facing buckets a ray reached this frame or their mean history +float3 giDepositColour(GiFrame frame, uint cascade, uint deposit, float3 normal) +{ + const uint4 layout = frame.deposits[cascade]; + const uint base = gDepositSlots[layout.x + deposit]; + const uint stride = max(layout.w / kGiDebugSamples, 1u); + float3 radiance = float3(0.0, 0.0, 0.0); + float filled = 0.0; + float facing = 0.0; + float covered = 0.0; + float depth = 0.0; + for (uint bucket = 0u; bucket < layout.w; bucket += stride) + { + const uint count = gDeposits[base + bucket].w & 0xffffu; + float rays = 0.0; + const float4 history = giHistoryBucket(gHistory[frame.frame.z * frame.pool.w + base + bucket], rays); + if (rays > 0.0) + { + radiance += history.rgb; + filled += 1.0; + } + if (dot(giDirectionOf(bucket, cascade), normal) > 0.25) + { + facing += 1.0; + covered += count > 0u ? 1.0 : 0.0; + depth += rays; + } + } + if (gPush.mode == kGiDebugDepth) + { + // Red no history yellow about 8 rays green 64 and up + const float share = saturate(log2(1.0 + (facing > 0.0 ? depth / facing : 0.0)) / 6.0); + return share < 0.5 ? lerp(float3(1.0, 0.1, 0.1), float3(1.0, 0.9, 0.1), share * 2.0) + : lerp(float3(1.0, 0.9, 0.1), float3(0.1, 1.0, 0.2), share * 2.0 - 1.0); + } + if (gPush.mode == kGiDebugCoverage) + { + const float share = facing > 0.0 ? covered / facing : 0.0; + return lerp(float3(1.0, 0.1, 0.1), float3(0.1, 1.0, 0.2), share); + } + const float3 mean = filled > 0.0 ? radiance / filled : float3(0.0, 0.0, 0.0); + return mean / (1.0 + mean); +} + +/// Same sparse trilinear c0 lookup lighting does +float3 giIrradianceAt(GiFrame frame, float3 world, float3 normal, uint lod, out bool found) +{ + const float spacing = giSpacing(frame, lod, 0u); + const float3 coord = (world + normal * (kGiSurfaceBias * spacing)) / spacing - 0.5; + const int3 base = int3(floor(coord)); + const float3 upper = coord - float3(base); + float3 irradiance = float3(0.0, 0.0, 0.0); + float total = 0.0; + for (uint corner = 0u; corner < 8u; ++corner) + { + const uint slot = giFind(frame, 0u, base + giNeighbourOffset(corner), lod); + if (slot == kGiEmpty || slot >= frame.cascades[0].w) + { + continue; + } + const GiProbe probe = gPool[frame.frame.z * frame.pool.x + frame.cascades[0].z + slot]; + if (probe.deposit == kGiEmpty) + { + continue; + } + const float weight = giNeighbourWeight(corner, upper); + irradiance += weight * gIrradiance.SampleLevel(giTileUv(probe.deposit, normal), 0.0).rgb; + total += weight; + } + found = total > 0.0; + return found ? irradiance / total : irradiance; +} + +[shader("fragment")] +float4 fragmentMain(VSOutput input) : SV_Target0 +{ + const int2 pixel = int2(input.position.xy); + const float4 scene = gSource.Load(int3(pixel, 0)); + const GiFrame frame = gFrame[0]; + const uint cascade = min(gPush.cascade, frame.frame.y - 1u); + const float depth = gDepth.Load(int3(pixel, 0)).r; + if (giIsSky(depth)) + { + return scene; + } + + const float2 uv = (float2(pixel) + 0.5) / float2(frame.viewport.xy); + const float3 world = giWorldAt(frame, uv, depth); + const float3 normal = giSurfaceNormal(gNormal.Load(int3(pixel, 0)), world, frame.camera.xyz); + bool overlap = false; + const uint lod = giLod(frame, world, overlap); + if (gPush.mode == kGiDebugChange) + { + // Black steady blue 1% yellow 5% red 15% and up of the irradiance changing every frame + const float change = gChange[uint(pixel.y) * frame.viewport.x + uint(pixel.x)].y; + const float3 heat = change < 0.01 ? lerp(float3(0.0, 0.0, 0.0), float3(0.1, 0.2, 1.0), change / 0.01) + : change < 0.05 ? lerp(float3(0.1, 0.2, 1.0), float3(1.0, 0.9, 0.1), (change - 0.01) / 0.04) + : lerp(float3(1.0, 0.9, 0.1), float3(1.0, 0.1, 0.1), saturate((change - 0.05) / 0.1)); + return float4(heat, scene.a); + } + if (gPush.mode == kGiDebugIrradiance) + { + bool found = false; + const float3 irradiance = giIrradianceAt(frame, world, normal, lod, found); + return float4(found ? irradiance / (1.0 + irradiance) : float3(1.0, 0.0, 0.0), scene.a); + } + const float spacing = giSpacing(frame, lod, cascade); + const int3 cell = giSurfaceCell(frame, world, normal, lod, cascade); + + const uint slot = giFind(frame, cascade, cell, lod); + float3 colour = float3(1.0, 0.0, 0.0); + if (slot != kGiEmpty && slot < frame.cascades[cascade].w) + { + const GiProbe probe = gPool[frame.frame.z * frame.pool.x + frame.cascades[cascade].z + slot]; + if (gPush.mode == kGiDebugCells) + { + colour = giCellColour(cell, lod) * (probe.lastSeen == frame.frame.x ? 1.0 : 0.35); + } + else if (probe.deposit != kGiEmpty) + { + colour = giDepositColour(frame, cascade, probe.deposit, normal); + } + else + { + // Carried or out of deposit room so no ray reached it this frame + colour = float3(0.25, 0.0, 0.3); + } + } + + const float3 inside = frac((world + normal * (kGiSurfaceBias * giSpacing(frame, lod, 0u))) / spacing); + const float edge = min(min(min(inside.x, 1.0 - inside.x), min(inside.y, 1.0 - inside.y)), min(inside.z, 1.0 - inside.z)); + colour *= edge < 0.06 ? 0.5 : 1.0; + return float4(lerp(scene.rgb, colour, gPush.mode == kGiDebugCells ? 0.6 : 0.9), scene.a); +} diff --git a/engine/assets/shaders/gi_debug.slang.meta b/engine/assets/shaders/gi_debug.slang.meta new file mode 100644 index 000000000..4f5bd4ad3 --- /dev/null +++ b/engine/assets/shaders/gi_debug.slang.meta @@ -0,0 +1,4 @@ +uid = "G1Debu9RC00" +type = "shader" +created_at = "2026-10-22T00:00:00.0000000Z" +modified_at = "2026-10-22T00:00:00.0000000Z" diff --git a/engine/assets/shaders/gi_merge.slang b/engine/assets/shaders/gi_merge.slang new file mode 100644 index 000000000..4296c3e1b --- /dev/null +++ b/engine/assets/shaders/gi_merge.slang @@ -0,0 +1,363 @@ +// Set 0 is the engine scene only for the sky above the top cascade +import lighting; +import gi_common; +import gi_sky; + +[[vk::binding(0, 1)]] +StructuredBuffer gFrame; + +[[vk::binding(1, 1)]] +StructuredBuffer gPool; + +[[vk::binding(2, 1)]] +StructuredBuffer gDepositPool; + +[[vk::binding(3, 1)]] +StructuredBuffer gDepositSlots; + +[[vk::binding(4, 1)]] +StructuredBuffer gNeighbours; + +[[vk::binding(5, 1)]] +StructuredBuffer gDeposits; + +/// Merged radiance of every cascade above c0 at first slot / 4 as half rgb +[[vk::binding(6, 1)]] +RWStructuredBuffer gMerged; + +[[vk::binding(7, 1)]] +[[vk::image_format("rgba16f")]] +RWTexture2D gIrradiance; + +[[vk::binding(8, 1)]] +RWStructuredBuffer gCounters; + +/// Two halves by pool parity +[[vk::binding(9, 1)]] +RWStructuredBuffer gHistory; + +[[vk::binding(10, 1)]] +StructuredBuffer gHistorySource; + +/// Mirrors gi_probes.slang gDirty +[[vk::binding(11, 1)]] +StructuredBuffer gDirty; + +struct GiMergePush +{ + uint cascade; +}; + +[[vk::push_constant]] +GiMergePush gPush; + +static const uint kGiGroup = 64u; + +uint giDepositCount(GiFrame frame, uint cascade) +{ + return min(gCounters[kGiCounterSeen + cascade], frame.deposits[cascade].y); +} + +struct GiParents +{ + uint merged[8]; + float weight[8]; + float total; +}; + +GiParents giParents(GiFrame frame, uint cascade, uint deposit, out int4 probeCell) +{ + const GiProbe probe = gPool[frame.frame.z * frame.pool.x + frame.cascades[cascade].z + gDepositPool[deposit]]; + probeCell = probe.cell; + int3 base = int3(0, 0, 0); + float3 upper = float3(0.0, 0.0, 0.0); + giParentNeighbourhood(probe.cell.xyz, base, upper); + const uint4 low = gNeighbours[deposit * 2u]; + const uint4 high = gNeighbours[deposit * 2u + 1u]; + + GiParents parents; + parents.total = 0.0; + // Branch free so the eight slot loads overlap + [unroll] + for (uint corner = 0u; corner < 8u; ++corner) + { + const uint neighbour = corner < 4u ? low[corner] : high[corner - 4u]; + const uint slot = gDepositSlots[neighbour == kGiEmpty ? 0u : neighbour]; + const uint first = neighbour == kGiEmpty ? kGiEmpty : slot; + parents.merged[corner] = first == kGiEmpty ? 0u : first / 4u; + parents.weight[corner] = first == kGiEmpty ? 0.0 : giNeighbourWeight(corner, upper); + parents.total += parents.weight[corner]; + } + return parents; +} + +/// Black when no parent got rays since a guess of sky would leak through walls +float3 giUpper(GiParents parents, uint direction) +{ + if (parents.total <= 0.0) + { + return float3(0.0, 0.0, 0.0); + } + // Loads first so the eight overlap and a missing parent weighs 0 + uint2 merged[8]; + [unroll] + for (uint corner = 0u; corner < 8u; ++corner) + { + merged[corner] = gMerged[parents.merged[corner] + direction]; + } + float3 radiance = float3(0.0, 0.0, 0.0); + [unroll] + for (uint corner = 0u; corner < 8u; ++corner) + { + if (parents.weight[corner] > 0.0) + { + radiance += parents.weight[corner] * + float3(giHalfToFloat(merged[corner].x & 0xffffu), giHalfToFloat(merged[corner].x >> 16), giHalfToFloat(merged[corner].y)); + } + } + return radiance / parents.total; +} + +struct GiBucket +{ + uint4 value; + GiHistory old; +}; + +/// Bounded since a capture replay may not restore last frame pool +bool giCarried(GiFrame frame, uint cascade, uint2 source) +{ + return source.x != kGiEmpty && source.x <= frame.pool.w - frame.deposits[cascade].w; +} + +/// Reads the old bucket unconditionally so it loads alongside the deposit +GiBucket giLoadBucket(GiFrame frame, uint first, uint direction, uint2 source, bool carried) +{ + GiBucket bucket; + bucket.value = gDeposits[first + direction]; + bucket.old = gHistory[(1u - frame.frame.z) * frame.pool.w + (carried ? source.x : 0u) + direction]; + return bucket; +} + +bool giBucketTouchesChange(GiFrame frame, uint cascade, int4 cell, uint direction) +{ + const uint count = uint(gDirty[0].x); + if (count == 0u) + { + return false; + } + const uint lod = uint(cell.w); + const float spacing = giSpacing(frame, lod, cascade); + const float3 centre = (float3(cell.xyz) + 0.5) * spacing; + const float start = (cascade == 0u ? 0.0 : giIntervalEnd(frame, lod, cascade - 1u)) - spacing; + const float end = giIntervalEnd(frame, lod, cascade) + spacing; + const float3 axis = giDirectionOf(direction, cascade); + // Half again the bucket half angle since rays jitter across the whole bucket + const float cosine = max(1.0 - 2.0 / float(frame.deposits[cascade].w), 0.05); + const float spread = 1.5 * sqrt(1.0 - cosine * cosine) / cosine; + for (uint i = 0u; i < count; ++i) + { + const float3 low = gDirty[1u + 2u * i].xyz; + const float3 high = gDirty[2u + 2u * i].xyz; + const float radius = 0.5 * length(high - low); + const float3 offset = 0.5 * (low + high) - centre; + const float along = dot(offset, axis); + if (along + radius < start || along - radius > end) + { + continue; + } + if (length(offset - axis * along) <= radius + spacing + max(along, 0.0) * spread) + { + return true; + } + } + return false; +} + +/// keepMain flags whole probes and only buckets looking at the change drop history +float giBucketLimit(GiFrame frame, uint cascade, int4 cell, uint direction, uint2 source) +{ + const float limit = asfloat(source.y); + if (limit >= kGiMaxHistory) + { + return limit; + } + return giBucketTouchesChange(frame, cascade, cell, direction) ? limit : kGiMaxHistory; +} + +/// Unreached buckets decay only to a slower fading floor of limits.y rays +GiHistory giAccumulate(GiFrame frame, GiBucket bucket, uint2 source, bool carried, float limit) +{ + float weight = float(bucket.value.w & 0xffffu); + float3 radiance = float3(bucket.value.xyz) / frame.rays.z; + float transmittance = float(bucket.value.w >> 16) / kGiTransmittanceScale; + float rays = 0.0; + const float4 old = giHistoryBucket(bucket.old, rays); + const float held = min(rays * frame.limits.z, max(frame.limits.y - weight, 0.0)); + const float kept = carried ? min(max(rays * frame.jitter.z, held), limit) : 0.0; + radiance += old.rgb * kept; + transmittance += old.a * kept; + weight += kept; + const float inverse = weight > 0.0 ? 1.0 / weight : 0.0; + return giPackHistory(radiance * inverse, transmittance * inverse, weight); +} + +/// Every 64th @p item feeds the depth counter +void giStoreBucket(GiFrame frame, uint cascade, uint first, uint direction, uint item, GiHistory history) +{ + gHistory[frame.frame.z * frame.pool.w + first + direction] = history; + if ((item & 63u) == 0u) + { + InterlockedAdd(gCounters[kGiCounterDepth + cascade], uint(history.rays * 16.0 + 0.5)); + } +} + +/// L = R + T * L above for one bucket +float3 giMergeBucket(GiFrame frame, uint cascade, uint direction, bool top, GiParents parents, GiHistory history) +{ + float rays = 0.0; + const float4 bucket = giHistoryBucket(history, rays); + const float3 upper = top ? giSky(giDirectionOf(direction, cascade)) : giUpper(parents, direction); + return bucket.rgb + bucket.a * upper; +} + +/// One cascade above c0 per dispatch from the top down accumulating its buckets on the way +[shader("compute")] +[numthreads(kGiGroup, 1, 1)] +void mergeMain(uint3 id : SV_DispatchThreadID) +{ + const GiFrame frame = gFrame[0]; + const uint cascade = gPush.cascade; + const uint4 deposits = frame.deposits[cascade]; + const uint outputs = deposits.w / 4u; + const uint total = giDepositCount(frame, cascade) * outputs; + const bool top = cascade + 1u >= frame.frame.y; + for (uint item = id.x; item < total; item += kGiStrideThreads) + { + const uint deposit = deposits.x + item / outputs; + const uint output = item % outputs; + const uint first = gDepositSlots[deposit]; + if (first == kGiEmpty) + { + continue; + } + const uint2 source = gHistorySource[deposit]; + const bool carried = giCarried(frame, cascade, source); + int4 probeCell = int4(0, 0, 0, 0); + const GiParents parents = giParents(frame, cascade, deposit, probeCell); + // Stores last since the compiler cannot move a load past a store that may alias it + GiBucket buckets[4]; + [unroll] + for (uint child = 0u; child < 4u; ++child) + { + buckets[child] = giLoadBucket(frame, first, output * 4u + child, source, carried); + } + GiHistory histories[4]; + float3 radiance = float3(0.0, 0.0, 0.0); + [unroll] + for (uint child = 0u; child < 4u; ++child) + { + histories[child] = + giAccumulate(frame, buckets[child], source, carried, giBucketLimit(frame, cascade, probeCell, output * 4u + child, source)); + radiance += giMergeBucket(frame, cascade, output * 4u + child, top, parents, histories[child]); + } + [unroll] + for (uint child = 0u; child < 4u; ++child) + { + const uint direction = output * 4u + child; + giStoreBucket(frame, cascade, first, direction, (item / outputs) * deposits.w + direction, histories[child]); + } + radiance *= 0.25; + gMerged[first / 4u + output] = + uint2(giFloatToHalf(radiance.r) | (giFloatToHalf(radiance.g) << 16), giFloatToHalf(radiance.b)); + } +} + +groupshared float3 gsRadiance[2][kGiC0Directions]; +groupshared bool gsWrite[2]; +groupshared GiParents gsParents[2]; +groupshared int4 gsCell[2]; + +/// Two c0 deposit probes per 8 by 8 group so every thread merges one bucket and then writes one texel of each tile +[shader("compute")] +[numthreads(kGiTileSize, kGiTileSize, 1)] +void irradianceMain(uint3 group : SV_GroupID, uint3 thread : SV_GroupThreadID, uint index : SV_GroupIndex) +{ + const GiFrame frame = gFrame[0]; + const uint count = giDepositCount(frame, 0u); + const uint pair = group.y * kGiAtlasTilesPerRow + group.x * 2u; + // Uniform across the group so the barrier below stays legal + if (pair >= count) + { + return; + } + + const uint side = index / kGiC0Directions; + const uint direction = index % kGiC0Directions; + const uint tile = pair + side; + const uint deposit = frame.deposits[0].x + tile; + const uint first = tile < count ? gDepositSlots[deposit] : kGiEmpty; + if (direction == 0u) + { + const bool willWrite = first != kGiEmpty; + gsWrite[side] = willWrite; + if (willWrite) + { + int4 cell = int4(0, 0, 0, 0); + gsParents[side] = giParents(frame, 0u, deposit, cell); + gsCell[side] = cell; + } + } + GroupMemoryBarrierWithGroupSync(); + + if (gsWrite[side]) + { + const bool top = frame.frame.y == 1u; + const uint2 source = gHistorySource[deposit]; + const bool carried = giCarried(frame, 0u, source); + const GiBucket bucket = giLoadBucket(frame, first, direction, source, carried); + const GiParents parents = gsParents[side]; + const GiHistory history = giAccumulate(frame, bucket, source, carried, giBucketLimit(frame, 0u, gsCell[side], direction, source)); + gsRadiance[side][direction] = giMergeBucket(frame, 0u, direction, top, parents, history); + giStoreBucket(frame, 0u, first, direction, tile * kGiC0Directions + direction, history); + } + GroupMemoryBarrierWithGroupSync(); + + // Border texels hold the interior texel mirrored across the octahedral seam so bilinear taps wrap correctly + const uint last = kGiTileSize - 1u; + const bool edgeX = thread.x == 0u || thread.x == last; + const bool edgeY = thread.y == 0u || thread.y == last; + uint2 source = thread.xy; + if (edgeX && edgeY) + { + source = uint2(thread.x == 0u ? kGiTileInterior : 1u, thread.y == 0u ? kGiTileInterior : 1u); + } + else if (edgeY) + { + source = uint2(last - thread.x, thread.y == 0u ? 1u : kGiTileInterior); + } + else if (edgeX) + { + source = uint2(thread.x == 0u ? 1u : kGiTileInterior, last - thread.y); + } + const float3 normal = giOctDecode((float2(source) - 0.5) / float(kGiTileInterior)); + + // Normalized by the discrete cosine sum so uniform radiance comes out exact with only 32 buckets + float3 irradiance[2] = { float3(0.0, 0.0, 0.0), float3(0.0, 0.0, 0.0) }; + float weight = 0.0; + for (uint d = 0u; d < kGiC0Directions; ++d) + { + const float cosine = max(dot(normal, kGiC0DirectionsTable[d]), 0.0); + irradiance[0] += gsRadiance[0][d] * cosine; + irradiance[1] += gsRadiance[1][d] * cosine; + weight += cosine; + } + [unroll] + for (uint s = 0u; s < 2u; ++s) + { + if (gsWrite[s]) + { + gIrradiance[giTileOrigin(pair + s) + thread.xy] = float4(weight > 0.0 ? irradiance[s] / weight : irradiance[s], 1.0); + } + } +} diff --git a/engine/assets/shaders/gi_merge.slang.meta b/engine/assets/shaders/gi_merge.slang.meta new file mode 100644 index 000000000..ed9f1a3fe --- /dev/null +++ b/engine/assets/shaders/gi_merge.slang.meta @@ -0,0 +1,4 @@ +uid = "G1Merg3RC00" +type = "shader" +created_at = "2026-10-22T00:00:00.0000000Z" +modified_at = "2026-10-22T00:00:00.0000000Z" diff --git a/engine/assets/shaders/gi_probes.slang b/engine/assets/shaders/gi_probes.slang new file mode 100644 index 000000000..41840420f --- /dev/null +++ b/engine/assets/shaders/gi_probes.slang @@ -0,0 +1,601 @@ +import gi_common; + +[[vk::binding(0, 0)]] +Sampler2D gDepth; + +[[vk::binding(1, 0)]] +StructuredBuffer gFrame; + +[[vk::binding(9, 0)]] +Sampler2D gNormal; + +/// One open addressing table per cascade rebuilt every frame +[[vk::binding(2, 0)]] +RWStructuredBuffer gTableKeys; +[[vk::binding(3, 0)]] +RWStructuredBuffer gTableFlags; +/// Index in the previous pool of a carried probe +[[vk::binding(4, 0)]] +RWStructuredBuffer gTablePrevious; +/// Index in the pool written this frame +[[vk::binding(5, 0)]] +RWStructuredBuffer gTableValues; +[[vk::binding(6, 0)]] +RWStructuredBuffer gTableCells; + +/// Two pools by parity +[[vk::binding(7, 0)]] +RWStructuredBuffer gPool; + +/// Mirrors renderer::GiPass counters +[[vk::binding(8, 0)]] +RWStructuredBuffer gCounters; + +/// Pool slot of each deposit probe +[[vk::binding(10, 0)]] +RWStructuredBuffer gDepositPool; +/// Deposit probe of the parent or kGiEmpty +[[vk::binding(11, 0)]] +RWStructuredBuffer gParent; +/// x rays y first R2 index z cursor its children take blocks from w cursor its pixels take indices from +[[vk::binding(12, 0)]] +RWStructuredBuffer gRayCounts; +/// Per direction xyz fixed point radiance w transmittance << 16 plus count +[[vk::binding(13, 0)]] +RWStructuredBuffer gDeposits; +/// First direction slot of each deposit probe or kGiEmpty when the slots ran out +[[vk::binding(14, 0)]] +RWStructuredBuffer gDepositSlots; +/// The 8 parent cascade deposit probes around each deposit probe as two uint4 in giNeighbourOffset order +[[vk::binding(15, 0)]] +RWStructuredBuffer gNeighbours; +/// x first history slot of the probe last frame or kGiEmpty y asfloat rays of history it may keep +[[vk::binding(16, 0)]] +RWStructuredBuffer gHistorySource; +/// Mirrors renderer::GiPass dirty boxes with the count in [0].x then world min and max per changed region +[[vk::binding(17, 0)]] +StructuredBuffer gDirty; +/// Two halves by pool parity +[[vk::binding(18, 0)]] +StructuredBuffer gPoints; +/// Pixels with a deposit packed x | y << 16 for the trace +[[vk::binding(19, 0)]] +RWStructuredBuffer gSpawn; + +struct GiPush +{ + uint cascade; + /// slotsMain hands out to probes seen this frame when 0 and to the carried ones after when 1 + uint carried; +}; + +[[vk::push_constant]] +GiPush gPush; + +static const uint kGiGroup = 64u; +/// Carried probes may sit this far past their LOD shell and stay well inside the 512 cell key window +static const float kGiShellSlack = 1.5; +static const uint kGiMaxProbes = 32u; + +void giInsert(GiFrame frame, uint cascade, int3 cell, uint lod, uint flags, uint previous) +{ + const GiCascadeLayout layout = frame.cascades[cascade]; + const uint key = giKey(cell, lod); + uint slot = giHash(key) & layout.y; + for (uint attempt = 0u; attempt < kGiMaxProbes; ++attempt) + { + const uint index = layout.x + slot; + uint original = 0u; + InterlockedCompareExchange(gTableKeys[index], kGiEmpty, key, original); + if (original == kGiEmpty || original == key) + { + if (original == kGiEmpty) + { + gTableCells[index] = int4(cell, int(lod)); + if (previous != kGiEmpty) + { + gTablePrevious[index] = previous; + } + } + if (flags != 0u) + { + InterlockedOr(gTableFlags[index], flags); + } + return; + } + slot = (slot + 1u) & layout.y; + } + InterlockedAdd(gCounters[kGiCounterTableFull], 1u); +} + +[shader("compute")] +[numthreads(kGiGroup, 1, 1)] +void clearMain(uint3 id : SV_DispatchThreadID) +{ + const GiFrame frame = gFrame[0]; + const uint index = id.x; + if (index == 0u) + { + for (uint c = 0u; c < kGiMaxCascades; ++c) + { + gCounters[frame.frame.z * 8u + c] = 0u; + gCounters[kGiCounterSeen + c] = 0u; + } + gCounters[kGiCounterTableFull] = 0u; + gCounters[kGiCounterPoolFull] = 0u; + gCounters[kGiCounterDepositFull] = 0u; + gCounters[kGiCounterRays] = 0u; + gCounters[kGiCounterSlots] = 0u; + gCounters[kGiCounterInvalidated] = 0u; + gCounters[kGiCounterPoints + frame.frame.z] = 0u; + gCounters[kGiCounterPointsDropped] = 0u; + gCounters[kGiCounterFresh] = 0u; + gCounters[kGiCounterSpawn] = 0u; + gCounters[kGiCounterPixelHits] = 0u; + gCounters[kGiCounterPointHits] = 0u; + for (uint d = 0u; d < kGiMaxCascades; ++d) + { + gCounters[kGiCounterDepth + d] = 0u; + } + } + if (index >= frame.pool.y) + { + return; + } + gTableKeys[index] = kGiEmpty; + gTableFlags[index] = 0u; + gTablePrevious[index] = kGiEmpty; + gTableValues[index] = kGiEmpty; +} + +[shader("compute")] +[numthreads(kGiGroup, 1, 1)] +void carryMain(uint3 id : SV_DispatchThreadID) +{ + const GiFrame frame = gFrame[0]; + const uint cascade = gPush.cascade; + const GiCascadeLayout layout = frame.cascades[cascade]; + const uint parity = 1u - frame.frame.z; + const uint count = min(gCounters[parity * 8u + cascade], layout.w); + if (frame.frame.w == 0u || id.x >= count) + { + return; + } + const GiProbe probe = gPool[parity * frame.pool.x + layout.z + id.x]; + if (frame.frame.x - probe.lastSeen > uint(frame.lod.z)) + { + return; + } + // Outside its LOD shell around the camera now so the modulo key cannot alias a probe a whole window away + const uint lod = uint(probe.cell.w); + const float3 centre = (float3(probe.cell.xyz) + 0.5) * giSpacing(frame, lod, cascade); + const float3 offset = abs(centre - frame.camera.xyz); + if (max(offset.x, max(offset.y, offset.z)) > kGiShellSlack * frame.lod.x * float(1u << (lod + 1u))) + { + return; + } + giInsert(frame, cascade, probe.cell.xyz, uint(probe.cell.w), 0u, id.x); +} + +[shader("compute")] +[numthreads(8, 8, 1)] +void pixelMain(uint3 id : SV_DispatchThreadID) +{ + const GiFrame frame = gFrame[0]; + const uint step = uint(frame.lod.w); + const uint2 pixel = id.xy * step + frame.viewport.zw; + if (any(pixel >= frame.viewport.xy)) + { + return; + } + const float depth = gDepth.Load(int3(pixel, 0)).r; + if (giIsSky(depth)) + { + return; + } + const float2 uv = (float2(pixel) + 0.5) / float2(frame.viewport.xy); + const float3 world = giWorldAt(frame, uv, depth); + const float3 normal = giSurfaceNormal(gNormal.Load(int3(pixel, 0)), world, frame.camera.xyz); + + bool overlap = false; + const uint lod = giLod(frame, world, overlap); + giInsert(frame, 0u, giSurfaceCell(frame, world, normal, lod, 0u), lod, kGiFlagSeen, kGiEmpty); + if (overlap) + { + giInsert(frame, 0u, giSurfaceCell(frame, world, normal, lod + 1u, 0u), lod + 1u, kGiFlagSeen, kGiEmpty); + } +} + +uint giPointCount(GiFrame frame) +{ + return min(gCounters[kGiCounterPoints + 1u - frame.frame.z], frame.secondary.w); +} + +[shader("compute")] +[numthreads(kGiGroup, 1, 1)] +void pointMain(uint3 id : SV_DispatchThreadID) +{ + const GiFrame frame = gFrame[0]; + if (id.x >= giPointCount(frame)) + { + return; + } + const GiPoint point = gPoints[(1u - frame.frame.z) * frame.secondary.x + id.x]; + const float3 normal = giUnpackNormal(point.normal); + const uint lod = giSecondaryLod(frame, point.position); + giInsert(frame, 0u, giSurfaceCell(frame, point.position, normal, lod, 0u), lod, kGiFlagSeen, kGiEmpty); +} + +[shader("compute")] +[numthreads(kGiGroup, 1, 1)] +void parentMain(uint3 id : SV_DispatchThreadID) +{ + const GiFrame frame = gFrame[0]; + const uint cascade = gPush.cascade; + const GiCascadeLayout layout = frame.cascades[cascade]; + if (id.x > layout.y) + { + return; + } + const uint index = layout.x + id.x; + if (gTableKeys[index] == kGiEmpty || (gTableFlags[index] & kGiFlagSeen) == 0u) + { + return; + } + const int4 cell = gTableCells[index]; + // Arithmetic shift so negative cells round down like floor + giInsert(frame, cascade + 1u, cell.xyz >> 1, uint(cell.w), kGiFlagSeen, kGiEmpty); +} + +[shader("compute")] +[numthreads(kGiGroup, 1, 1)] +void compactMain(uint3 id : SV_DispatchThreadID) +{ + const GiFrame frame = gFrame[0]; + const uint cascade = gPush.cascade; + const GiCascadeLayout layout = frame.cascades[cascade]; + if (id.x > layout.y) + { + return; + } + const uint index = layout.x + id.x; + if (gTableKeys[index] == kGiEmpty) + { + return; + } + + uint slot = 0u; + InterlockedAdd(gCounters[frame.frame.z * 8u + cascade], 1u, slot); + if (slot >= layout.w) + { + InterlockedAdd(gCounters[kGiCounterPoolFull], 1u); + return; + } + + const bool seen = (gTableFlags[index] & kGiFlagSeen) != 0u; + const uint previous = gTablePrevious[index]; + const uint oldBase = (1u - frame.frame.z) * frame.pool.x + layout.z; + const uint lastSeen = seen || previous == kGiEmpty ? frame.frame.x : gPool[oldBase + previous].lastSeen; + + // Probes a pixel touched get deposit storage first + uint deposit = kGiEmpty; + if (seen) + { + InterlockedAdd(gCounters[kGiCounterSeen + cascade], 1u, deposit); + if (deposit >= frame.deposits[cascade].y) + { + InterlockedAdd(gCounters[kGiCounterDepositFull], 1u); + deposit = kGiEmpty; + } + else + { + gDepositPool[frame.deposits[cascade].x + deposit] = slot; + } + } + + GiProbe probe; + probe.cell = gTableCells[index]; + probe.lastSeen = lastSeen; + probe.previous = previous; + probe.deposit = deposit; + probe.slots = kGiEmpty; + gPool[frame.frame.z * frame.pool.x + layout.z + slot] = probe; + gTableValues[index] = slot; +} + +uint giFindSlot(GiFrame frame, uint cascade, int3 cell, uint lod) +{ + const GiCascadeLayout layout = frame.cascades[cascade]; + const uint key = giKey(cell, lod); + uint slot = giHash(key) & layout.y; + for (uint attempt = 0u; attempt < kGiMaxProbes; ++attempt) + { + const uint stored = gTableKeys[layout.x + slot]; + if (stored == key) + { + const uint value = gTableValues[layout.x + slot]; + return value < layout.w ? value : kGiEmpty; + } + if (stored == kGiEmpty) + { + return kGiEmpty; + } + slot = (slot + 1u) & layout.y; + } + return kGiEmpty; +} + +uint giFindDeposit(GiFrame frame, uint cascade, int3 cell, uint lod) +{ + const uint slot = giFindSlot(frame, cascade, cell, lod); + if (slot == kGiEmpty) + { + return kGiEmpty; + } + const uint deposit = gPool[frame.frame.z * frame.pool.x + frame.cascades[cascade].z + slot].deposit; + return deposit == kGiEmpty ? kGiEmpty : frame.deposits[cascade].x + deposit; +} + +uint giDepositCount(GiFrame frame, uint cascade) +{ + return min(gCounters[kGiCounterSeen + cascade], frame.deposits[cascade].y); +} + +/// Padded a cell since the rays start anywhere on the surfaces inside it +bool giTouchesChange(GiFrame frame, uint cascade, int4 cell) +{ + const uint count = uint(gDirty[0].x); + if (count == 0u) + { + return false; + } + const uint lod = uint(cell.w); + const float spacing = giSpacing(frame, lod, cascade); + const float3 centre = (float3(cell.xyz) + 0.5) * spacing; + const float start = (cascade == 0u ? 0.0 : giIntervalEnd(frame, lod, cascade - 1u)) - spacing; + const float end = giIntervalEnd(frame, lod, cascade) + spacing; + for (uint i = 0u; i < count; ++i) + { + const float3 low = gDirty[1u + 2u * i].xyz; + const float3 high = gDirty[2u + 2u * i].xyz; + const float nearest = length(max(max(low - centre, centre - high), 0.0)); + const float farthest = length(max(abs(centre - low), abs(centre - high))); + if (nearest <= end && farthest >= start) + { + return true; + } + } + return false; +} + +/// After compact so running out never costs a visible probe +[shader("compute")] +[numthreads(kGiGroup, 1, 1)] +void keepMain(uint3 id : SV_DispatchThreadID) +{ + const GiFrame frame = gFrame[0]; + const uint cascade = gPush.cascade; + const GiCascadeLayout layout = frame.cascades[cascade]; + if (id.x >= min(gCounters[frame.frame.z * 8u + cascade], layout.w)) + { + return; + } + const uint index = frame.frame.z * frame.pool.x + layout.z + id.x; + const GiProbe probe = gPool[index]; + if (probe.deposit != kGiEmpty || frame.frame.x - probe.lastSeen > kGiKeepFrames) + { + return; + } + uint deposit = 0u; + InterlockedAdd(gCounters[kGiCounterSeen + cascade], 1u, deposit); + if (deposit >= frame.deposits[cascade].y) + { + InterlockedAdd(gCounters[kGiCounterDepositFull], 1u); + return; + } + gDepositPool[frame.deposits[cascade].x + deposit] = id.x; + gPool[index].deposit = deposit; +} + +/// Top cascade first on seen probes then again on carried ones so carried probes run out first +[shader("compute")] +[numthreads(kGiGroup, 1, 1)] +void slotsMain(uint3 id : SV_DispatchThreadID) +{ + const GiFrame frame = gFrame[0]; + const uint cascade = gPush.cascade; + if (id.x >= giDepositCount(frame, cascade)) + { + return; + } + const uint4 deposits = frame.deposits[cascade]; + const uint deposit = deposits.x + id.x; + const uint index = frame.frame.z * frame.pool.x + frame.cascades[cascade].z + gDepositPool[deposit]; + if ((gPool[index].lastSeen != frame.frame.x) != (gPush.carried != 0u)) + { + return; + } + gRayCounts[deposit] = uint4(0u, 0u, 0u, 0u); + gParent[deposit] = kGiEmpty; + gNeighbours[deposit * 2u] = uint4(kGiEmpty, kGiEmpty, kGiEmpty, kGiEmpty); + gNeighbours[deposit * 2u + 1u] = uint4(kGiEmpty, kGiEmpty, kGiEmpty, kGiEmpty); + + uint first = 0u; + InterlockedAdd(gCounters[kGiCounterSlots], deposits.w, first); + if (first + deposits.w > frame.pool.w) + { + // Every lookup now misses it so it gets no rays and its children merge without it + InterlockedAdd(gCounters[kGiCounterDepositFull], 1u); + gDepositSlots[deposit] = kGiEmpty; + gPool[index].deposit = kGiEmpty; + return; + } + gDepositSlots[deposit] = first; + gPool[index].slots = first; + const GiProbe probe = gPool[index]; + uint history = kGiEmpty; + if (probe.previous != kGiEmpty) + { + history = gPool[(1u - frame.frame.z) * frame.pool.x + frame.cascades[cascade].z + probe.previous].slots; + } + if (history == kGiEmpty) + { + InterlockedAdd(gCounters[kGiCounterFresh], 1u); + } + float kept = kGiMaxHistory; + if (history != kGiEmpty && giTouchesChange(frame, cascade, probe.cell)) + { + kept = frame.jitter.w; + InterlockedAdd(gCounters[kGiCounterInvalidated], 1u); + } + gHistorySource[deposit] = uint2(history, asuint(kept)); +} + +[shader("compute")] +[numthreads(kGiGroup, 1, 1)] +void depositClearMain(uint3 id : SV_DispatchThreadID) +{ + const GiFrame frame = gFrame[0]; + const uint used = min(gCounters[kGiCounterSlots], frame.pool.w); + for (uint slot = id.x; slot < used; slot += kGiStrideThreads) + { + gDeposits[slot] = uint4(0u, 0u, 0u, 0u); + } +} + +[shader("compute")] +[numthreads(kGiGroup, 1, 1)] +void linkMain(uint3 id : SV_DispatchThreadID) +{ + const GiFrame frame = gFrame[0]; + const uint cascade = gPush.cascade; + if (id.x >= giDepositCount(frame, cascade)) + { + return; + } + const uint deposit = frame.deposits[cascade].x + id.x; + if (gDepositSlots[deposit] == kGiEmpty) + { + return; + } + const GiProbe probe = gPool[frame.frame.z * frame.pool.x + frame.cascades[cascade].z + gDepositPool[deposit]]; + const uint lod = uint(probe.cell.w); + int3 base = int3(0, 0, 0); + float3 upper = float3(0.0, 0.0, 0.0); + giParentNeighbourhood(probe.cell.xyz, base, upper); + uint neighbours[8]; + for (uint corner = 0u; corner < 8u; ++corner) + { + neighbours[corner] = giFindDeposit(frame, cascade + 1u, base + giNeighbourOffset(corner), lod); + } + gNeighbours[deposit * 2u] = uint4(neighbours[0], neighbours[1], neighbours[2], neighbours[3]); + gNeighbours[deposit * 2u + 1u] = uint4(neighbours[4], neighbours[5], neighbours[6], neighbours[7]); + gParent[deposit] = neighbours[giParentCorner(probe.cell.xyz)]; +} + +uint giPixelDeposit(GiFrame frame, uint2 pixel) +{ + const float depth = gDepth.Load(int3(pixel, 0)).r; + if (giIsSky(depth)) + { + return kGiEmpty; + } + const float2 uv = (float2(pixel) + 0.5) / float2(frame.viewport.xy); + const float3 world = giWorldAt(frame, uv, depth); + const float3 normal = giSurfaceNormal(gNormal.Load(int3(pixel, 0)), world, frame.camera.xyz); + bool overlap = false; + const uint lod = giLod(frame, world, overlap); + return giFindDeposit(frame, 0u, giSurfaceCell(frame, world, normal, lod, 0u), lod); +} + +[shader("compute")] +[numthreads(8, 8, 1)] +void countMain(uint3 id : SV_DispatchThreadID) +{ + const GiFrame frame = gFrame[0]; + const uint2 pixel = id.xy * uint(frame.lod.w) + frame.viewport.zw; + if (any(pixel >= frame.viewport.xy)) + { + return; + } + const uint deposit = giPixelDeposit(frame, pixel); + if (deposit == kGiEmpty) + { + return; + } + uint slot = 0u; + InterlockedAdd(gCounters[kGiCounterSpawn], 1u, slot); + // Pixels past the list trace nothing so they must not claim rays + if (slot < frame.queues.x) + { + gSpawn[slot] = pixel.x | (pixel.y << 16u); + InterlockedAdd(gRayCounts[deposit].x, 1u); + } +} + +[shader("compute")] +[numthreads(kGiGroup, 1, 1)] +void pointCountMain(uint3 id : SV_DispatchThreadID) +{ + const GiFrame frame = gFrame[0]; + if (id.x >= giPointCount(frame)) + { + return; + } + const GiPoint point = gPoints[(1u - frame.frame.z) * frame.secondary.x + id.x]; + const float3 normal = giUnpackNormal(point.normal); + const uint lod = giSecondaryLod(frame, point.position); + const uint deposit = giFindDeposit(frame, 0u, giSurfaceCell(frame, point.position, normal, lod, 0u), lod); + if (deposit != kGiEmpty) + { + InterlockedAdd(gRayCounts[deposit].x, 1u); + } +} + +/// Cascade by cascade upward so a parent holds the rays of all its children +[shader("compute")] +[numthreads(kGiGroup, 1, 1)] +void propagateMain(uint3 id : SV_DispatchThreadID) +{ + const GiFrame frame = gFrame[0]; + const uint cascade = gPush.cascade; + if (id.x >= giDepositCount(frame, cascade)) + { + return; + } + const uint deposit = frame.deposits[cascade].x + id.x; + const uint parent = gParent[deposit]; + const uint rays = gRayCounts[deposit].x; + if (parent != kGiEmpty && rays > 0u) + { + InterlockedAdd(gRayCounts[parent].x, rays); + } +} + +/// Top down so the rays of every probe are one contiguous R2 run +[shader("compute")] +[numthreads(kGiGroup, 1, 1)] +void offsetMain(uint3 id : SV_DispatchThreadID) +{ + const GiFrame frame = gFrame[0]; + const uint cascade = gPush.cascade; + if (id.x >= giDepositCount(frame, cascade)) + { + return; + } + const uint deposit = frame.deposits[cascade].x + id.x; + const uint rays = gRayCounts[deposit].x; + const uint parent = cascade + 1u < frame.frame.y ? gParent[deposit] : kGiEmpty; + uint offset = 0u; + if (parent == kGiEmpty) + { + InterlockedAdd(gCounters[kGiCounterRays], rays, offset); + } + else + { + uint local = 0u; + InterlockedAdd(gRayCounts[parent].z, rays, local); + offset = gRayCounts[parent].y + local; + } + gRayCounts[deposit].y = offset; +} diff --git a/engine/assets/shaders/gi_probes.slang.meta b/engine/assets/shaders/gi_probes.slang.meta new file mode 100644 index 000000000..c0dbd64f6 --- /dev/null +++ b/engine/assets/shaders/gi_probes.slang.meta @@ -0,0 +1,4 @@ +uid = "G1Pr0besRC0" +type = "shader" +created_at = "2026-10-22T00:00:00.0000000Z" +modified_at = "2026-10-22T00:00:00.0000000Z" diff --git a/engine/assets/shaders/gi_rays.slang b/engine/assets/shaders/gi_rays.slang new file mode 100644 index 000000000..45beee054 --- /dev/null +++ b/engine/assets/shaders/gi_rays.slang @@ -0,0 +1,605 @@ +// Set 0 is the engine scene and set 1 the voxel storage with the scene trace +import voxel_dda; +import lighting; +import scene_trace; +import gi_common; +import gi_sky; +import trace_shading; +import fog_media; + +[[vk::binding(0, 2)]] +Sampler2D gDepth; + +[[vk::binding(1, 2)]] +Sampler2D gNormal; + +[[vk::binding(2, 2)]] +StructuredBuffer gFrame; + +[[vk::binding(3, 2)]] +StructuredBuffer gTableKeys; + +[[vk::binding(4, 2)]] +StructuredBuffer gTableValues; + +[[vk::binding(5, 2)]] +StructuredBuffer gPool; + +/// x rays y first R2 index z child cursor w cursor the pixels of the probe take indices from +[[vk::binding(6, 2)]] +RWStructuredBuffer gRayCounts; + +/// Per direction xyz fixed point radiance w transmittance << 16 plus count +[[vk::binding(7, 2)]] +RWStructuredBuffer gDeposits; + +[[vk::binding(8, 2)]] +StructuredBuffer gParent; + +[[vk::binding(9, 2)]] +StructuredBuffer gDepositSlots; + +/// Lit HDR scene of this frame before any post pass +[[vk::binding(10, 2)]] +Sampler2D gSceneColour; + +[[vk::binding(11, 2)]] +RWStructuredBuffer gCounters; + +/// Read from the half the last trace wrote and appended to the half of this frame +[[vk::binding(12, 2)]] +RWStructuredBuffer gPoints; + +/// c0 tiles of the last merge until this frame merges after the trace +[[vk::binding(13, 2)]] +Sampler2D gIrradiance; + +/// Per pixel x irradiance luminance last frame y smoothed change per frame z camera distance +[[vk::binding(14, 2)]] +RWStructuredBuffer gChange; + +/// Pixels countMain found with a deposit packed x | y << 16 +[[vk::binding(15, 2)]] +StructuredBuffer gSpawn; + +/// Off screen hits of pixel rays then of point rays from giPackHit for shadeMain +[[vk::binding(16, 2)]] +RWStructuredBuffer gHits; + +/// Off the surface in meters so the first voxel the ray leaves is not hit at t 0 +static const float kGiRayOffset = 0.05; +static const float kGiMaxRadiance = 16384.0; +/// Meters off the plane of the hit face the visible surface may sit and still count as the same face +static const float kGiScreenPlane = 0.025; + +/// One corner by trilinear weight skipping probes over half a cell behind the surface so light does not leak under +float3 giSecondaryIrradiance(GiFrame frame, float3 position, float3 normal, inout uint random) +{ + const uint lod = giSecondaryLod(frame, position); + const GiCascadeLayout layout = frame.cascades[0]; + const float spacing = giSpacing(frame, lod, 0u); + const float3 coord = (position + normal * (kGiSurfaceBias * spacing)) / spacing - 0.5; + const int3 base = int3(floor(coord)); + const float3 upper = coord - float3(base); + const float pick = giRandom(random); + float cumulative = 0.0; + uint first = 7u; + for (uint corner = 0u; corner < 7u; ++corner) + { + cumulative += giNeighbourWeight(corner, upper); + if (pick < cumulative) + { + first = corner; + break; + } + } + for (uint step = 0u; step < 8u; ++step) + { + const uint corner = (first + step) & 7u; + const int3 cell = base + giNeighbourOffset(corner); + if (giNeighbourWeight(corner, upper) <= 0.0 || dot((float3(cell) + 0.5) * spacing - position, normal) < -0.5 * spacing) + { + continue; + } + const uint key = giKey(cell, lod); + uint slot = giHash(key) & layout.y; + uint value = kGiEmpty; + for (uint attempt = 0u; attempt < 32u; ++attempt) + { + const uint stored = gTableKeys[layout.x + slot]; + if (stored == key || stored == kGiEmpty) + { + value = stored == key ? gTableValues[layout.x + slot] : kGiEmpty; + break; + } + slot = (slot + 1u) & layout.y; + } + if (value >= layout.w) + { + continue; + } + const uint previous = gPool[frame.frame.z * frame.pool.x + layout.z + value].previous; + if (previous == kGiEmpty) + { + continue; + } + const uint tile = gPool[(1u - frame.frame.z) * frame.pool.x + layout.z + previous].deposit; + if (tile == kGiEmpty) + { + continue; + } + return gIrradiance.SampleLevel(giTileUv(tile, normal), 0.0).rgb; + } + return float3(0.0, 0.0, 0.0); +} + +/// Rare rays onto small bright emitters flicker their bucket without a ceiling +float3 giClampRay(GiFrame frame, float3 radiance) +{ + const float luminance = dot(radiance, float3(0.2126, 0.7152, 0.0722)); + return luminance > frame.limits.x ? radiance * (frame.limits.x / luminance) : radiance; +} + +uint giFindDeposit(GiFrame frame, uint cascade, int3 cell, uint lod) +{ + const GiCascadeLayout layout = frame.cascades[cascade]; + const uint key = giKey(cell, lod); + uint slot = giHash(key) & layout.y; + for (uint attempt = 0u; attempt < 32u; ++attempt) + { + const uint stored = gTableKeys[layout.x + slot]; + if (stored == key) + { + const uint value = gTableValues[layout.x + slot]; + if (value >= layout.w) + { + return kGiEmpty; + } + const uint deposit = gPool[frame.frame.z * frame.pool.x + layout.z + value].deposit; + return deposit == kGiEmpty ? kGiEmpty : frame.deposits[cascade].x + deposit; + } + if (stored == kGiEmpty) + { + return kGiEmpty; + } + slot = (slot + 1u) & layout.y; + } + return kGiEmpty; +} + +/// False unless the pixel the hit projects to shows the same face +bool giScreenRadiance(GiFrame frame, float3 position, float3 normal, out float3 radiance) +{ + radiance = float3(0.0, 0.0, 0.0); + const float3 toCamera = gCamera.position - position; + if (dot(normal, toCamera) <= 0.0) + { + return false; + } + const float4 clip = mul(gCamera.jitteredViewProjection, float4(position, 1.0)); + if (clip.w <= 0.0) + { + return false; + } + const float2 ndc = clip.xy / clip.w; + if (any(abs(ndc) >= 1.0)) + { + return false; + } + const int2 pixel = int2((ndc * 0.5 + 0.5) * float2(frame.viewport.xy)); + const float depth = gDepth.Load(int3(pixel, 0)).r; + if (giIsSky(depth)) + { + return false; + } + // Same plane within a quarter voxel and close along it so an occluder in front or a parallel face behind fails + const float distance = length(toCamera); + const float3 visible = giWorldAt(frame, (float2(pixel) + 0.5) / float2(frame.viewport.xy), depth); + const float3 offset = visible - position; + if (abs(dot(offset, normal)) > kGiScreenPlane + 0.002 * distance || length(offset) > 0.2 + 0.02 * distance) + { + return false; + } + radiance = gSceneColour.Load(int3(pixel, 0)).rgb; + return true; +} + +/// Radiance of a ray hit that is not on screen +float3 giOffscreenRadiance(GiFrame frame, SceneHit hit, float3 position, float3 normal, uint2 pixel, inout uint random) +{ + const PaletteSample palette = samplePalette(gTraceVolumes[hit.volume].recordIndex, hit.material); + // Shadow rays take the cone floor of the GI rays since their light is averaged over a whole bucket + float3 radiance = palette.albedo * palette.emissive + traceLocalLight(position, normal, palette.albedo, frame.rays.w, random); + if (frame.secondary.y != 0u) + { + radiance += palette.albedo * giSecondaryIrradiance(frame, position, normal, random); + } + return radiance + traceSunLight(position, normal, palette.albedo, frame.rays.w, frame.rays.y, float2(pixel)); +} + +/// x source y R2 index z asuint(t) w volume | axisCode << 22 | material << 24 with axisCode 3 for no axis +uint4 giPackHit(uint source, uint rayPoint, SceneHit hit) +{ + const uint axisCode = hit.axis < 0 ? 3u : uint(hit.axis); + const uint packed = (hit.volume & kTraceInstanceVolume) | (axisCode << 22) | ((hit.material & 0xFFu) << 24); + return uint4(source, rayPoint, asuint(hit.t), packed); +} + +/// Drops the voxel since shading reads only the volume axis and material +SceneHit giUnpackHit(uint4 packed) +{ + const uint axisCode = (packed.w >> 22) & 3u; + SceneHit hit; + hit.hit = true; + hit.t = asfloat(packed.z); + hit.volume = packed.w & kTraceInstanceVolume; + hit.voxel = int3(0, 0, 0); + hit.axis = axisCode == 3u ? -1 : int(axisCode); + hit.material = packed.w >> 24; + return hit; +} + +void giDeposit(GiFrame frame, uint cascade, uint deposit, float2 uv, float3 radiance, float transmittance) +{ + const uint first = gDepositSlots[deposit]; + if (first == kGiEmpty) + { + return; + } + const uint slot = first + giDirectionIndex(uv, cascade); + if (any(radiance > 0.0)) + { + const uint3 fixedPoint = uint3(clamp(radiance, 0.0, kGiMaxRadiance) * frame.rays.z + 0.5); + InterlockedAdd(gDeposits[slot].x, fixedPoint.x); + InterlockedAdd(gDeposits[slot].y, fixedPoint.y); + InterlockedAdd(gDeposits[slot].z, fixedPoint.z); + } + InterlockedAdd(gDeposits[slot].w, (uint(saturate(transmittance) * kGiTransmittanceScale + 0.5) << 16) | 1u); +} + +/// Fog dims each interval and the top cascade takes the sky +void giSplitRay(GiFrame frame, uint deposit, uint lod, float3 origin, float3 direction, bool hit, float distance, float3 radiance) +{ + const uint top = frame.frame.y - 1u; + const float2 uv = giEncodeDirection(direction); + float start = 0.0; + const bool hasFog = gCamera.fogMedium.x > 0.0 || fogVolumeCount() > 0u; + for (uint cascade = 0u; cascade <= top && deposit != kGiEmpty; ++cascade) + { + const float end = giIntervalEnd(frame, lod, cascade); + if (hit && distance <= end) + { + const float through = hasFog ? fogTransmittance(origin, direction, start, distance) : 1.0; + giDeposit(frame, cascade, deposit, uv, radiance * through, 0.0); + return; + } + const float through = hasFog ? fogTransmittance(origin, direction, start, end) : 1.0; + if (cascade == top) + { + giDeposit(frame, cascade, deposit, uv, giSky(direction) * through, 0.0); + return; + } + giDeposit(frame, cascade, deposit, uv, float3(0.0, 0.0, 0.0), through); + deposit = gParent[deposit]; + start = end; + } +} + +/// One probe ray of a pixel or a secondary point before its direction is picked +struct GiRay +{ + float3 origin; + float3 direction; + float3 surfaceNormal; + /// End of the traceScene interval + float reach; + uint deposit; + uint lod; + /// kGiEmpty when the pixel has no overlap or no deposit in the next lod + uint overlapDeposit; + uint rayPoint; + uint2 pixel; + uint random; +}; + +/// False for sky or a pixel without a deposit with direction and rayPoint left unset +bool giPixelRayAt(GiFrame frame, uint2 pixel, out GiRay ray) +{ + ray.origin = float3(0.0, 0.0, 0.0); + ray.direction = float3(0.0, 0.0, 0.0); + ray.surfaceNormal = float3(0.0, 0.0, 0.0); + ray.reach = 0.0; + ray.deposit = kGiEmpty; + ray.lod = 0u; + ray.overlapDeposit = kGiEmpty; + ray.rayPoint = 0u; + ray.pixel = pixel; + ray.random = 0u; + const float depth = gDepth.Load(int3(pixel, 0)).r; + if (giIsSky(depth)) + { + return false; + } + const float2 uv = (float2(pixel) + 0.5) / float2(frame.viewport.xy); + const float3 world = giWorldAt(frame, uv, depth); + const float3 normal = giSurfaceNormal(gNormal.Load(int3(pixel, 0)), world, frame.camera.xyz); + bool overlap = false; + const uint lod = giLod(frame, world, overlap); + const uint deposit = giFindDeposit(frame, 0u, giSurfaceCell(frame, world, normal, lod, 0u), lod); + if (deposit == kGiEmpty) + { + return false; + } + ray.surfaceNormal = normal; + ray.lod = lod; + ray.deposit = deposit; + ray.overlapDeposit = overlap ? giFindDeposit(frame, 0u, giSurfaceCell(frame, world, normal, lod + 1u, 0u), lod + 1u) : kGiEmpty; + ray.reach = giIntervalEnd(frame, overlap ? lod + 1u : lod, frame.frame.y - 1u); + ray.origin = world + normal * kGiRayOffset; + ray.random = giHash(pixel.x ^ giHash(pixel.y ^ giHash(frame.frame.x))); + return true; +} + +/// False for a point without a deposit with direction and rayPoint left unset +bool giPointRayAt(GiFrame frame, uint index, out GiRay ray) +{ + ray.origin = float3(0.0, 0.0, 0.0); + ray.direction = float3(0.0, 0.0, 0.0); + ray.surfaceNormal = float3(0.0, 0.0, 0.0); + ray.reach = 0.0; + ray.deposit = kGiEmpty; + ray.lod = 0u; + ray.overlapDeposit = kGiEmpty; + ray.rayPoint = 0u; + ray.pixel = uint2(index & 63u, index >> 6); + ray.random = 0u; + const GiPoint point = gPoints[(1u - frame.frame.z) * frame.secondary.x + index]; + const float3 normal = giUnpackNormal(point.normal); + const uint lod = giSecondaryLod(frame, point.position); + const uint deposit = giFindDeposit(frame, 0u, giSurfaceCell(frame, point.position, normal, lod, 0u), lod); + if (deposit == kGiEmpty) + { + return false; + } + ray.surfaceNormal = normal; + ray.lod = lod; + ray.deposit = deposit; + ray.reach = giIntervalEnd(frame, lod, frame.frame.y - 1u); + ray.origin = point.position + normal * kGiRayOffset; + ray.random = giHash(index ^ giHash(frame.frame.x ^ 0x5bd1e995u)); + return true; +} + +/// Directions flipped into the surface +void giAimRay(GiFrame frame, uint rayPoint, inout GiRay ray) +{ + ray.rayPoint = rayPoint; + const float3 direction = giDecodeDirection(giRayPoint(frame, rayPoint)); + ray.direction = dot(direction, ray.surfaceNormal) < 0.0 ? -direction : direction; +} + +[shader("compute")] +[numthreads(64, 1, 1)] +void traceMain(uint3 id : SV_DispatchThreadID) +{ + const GiFrame frame = gFrame[0]; + if (id.x >= min(gCounters[kGiCounterSpawn], frame.queues.x)) + { + return; + } + const uint packed = gSpawn[id.x]; + const uint2 pixel = uint2(packed & 0xFFFFu, packed >> 16u); + GiRay ray; + if (!giPixelRayAt(frame, pixel, ray)) + { + return; + } + + uint rayIndex = 0u; + InterlockedAdd(gRayCounts[ray.deposit].w, 1u, rayIndex); + giAimRay(frame, gRayCounts[ray.deposit].y + rayIndex, ray); + + const uint top = frame.frame.y - 1u; + const SceneHit hit = traceScene(ray.origin, ray.direction, 0.0, ray.reach, frame.rays.w); + float3 radiance = float3(0.0, 0.0, 0.0); + if (hit.hit) + { + const float3 position = ray.origin + ray.direction * hit.t; + const float3 normal = sceneHitNormal(hit, ray.direction); + if (!giScreenRadiance(frame, position, normal, radiance)) + { + // A uniform share so every region seeds instead of dispatch order filling the buffer + if (frame.secondary.y != 0u && giRandom(ray.random) < asfloat(frame.secondary.z)) + { + uint index = 0u; + InterlockedAdd(gCounters[kGiCounterPoints + frame.frame.z], 1u, index); + if (index < frame.secondary.w) + { + GiPoint point; + point.position = position; + point.normal = giPackNormal(normal); + gPoints[frame.frame.z * frame.secondary.x + index] = point; + } + else + { + InterlockedAdd(gCounters[kGiCounterPointsDropped], 1u); + } + } + // shadeMain deposits it + uint slot = 0u; + InterlockedAdd(gCounters[kGiCounterPixelHits], 1u, slot); + if (slot < frame.queues.y) + { + gHits[slot] = giPackHit(pixel.x | (pixel.y << 16u), ray.rayPoint, hit); + } + return; + } + radiance = giClampRay(frame, radiance); + } + + const bool primaryHit = hit.hit && hit.t <= giIntervalEnd(frame, ray.lod, top); + giSplitRay(frame, ray.deposit, ray.lod, ray.origin, ray.direction, primaryHit, hit.t, radiance); + if (ray.overlapDeposit != kGiEmpty) + { + giSplitRay(frame, ray.overlapDeposit, ray.lod + 1u, ray.origin, ray.direction, hit.hit, hit.t, radiance); + } +} + +[shader("compute")] +[numthreads(64, 1, 1)] +void pointTraceMain(uint3 id : SV_DispatchThreadID) +{ + const GiFrame frame = gFrame[0]; + if (id.x >= min(gCounters[kGiCounterPoints + 1u - frame.frame.z], frame.secondary.w)) + { + return; + } + GiRay ray; + if (!giPointRayAt(frame, id.x, ray)) + { + return; + } + + uint rayIndex = 0u; + InterlockedAdd(gRayCounts[ray.deposit].w, 1u, rayIndex); + giAimRay(frame, gRayCounts[ray.deposit].y + rayIndex, ray); + + const SceneHit hit = traceScene(ray.origin, ray.direction, 0.0, ray.reach, frame.rays.w); + float3 radiance = float3(0.0, 0.0, 0.0); + if (hit.hit) + { + const float3 position = ray.origin + ray.direction * hit.t; + if (!giScreenRadiance(frame, position, sceneHitNormal(hit, ray.direction), radiance)) + { + // shadeMain deposits it + uint slot = 0u; + InterlockedAdd(gCounters[kGiCounterPointHits], 1u, slot); + if (slot < frame.queues.z - frame.queues.y) + { + gHits[frame.queues.y + slot] = giPackHit(id.x, ray.rayPoint, hit); + } + return; + } + radiance = giClampRay(frame, radiance); + } + giSplitRay(frame, ray.deposit, ray.lod, ray.origin, ray.direction, hit.hit, hit.t, radiance); +} + +/// Off screen hits of both trace kernels in full warps +[shader("compute")] +[numthreads(64, 1, 1)] +void shadeMain(uint3 id : SV_DispatchThreadID) +{ + const GiFrame frame = gFrame[0]; + const uint pixelHits = min(gCounters[kGiCounterPixelHits], frame.queues.y); + const bool pixelRay = id.x < pixelHits; + const uint pointIndex = id.x - pixelHits; + if (!pixelRay && pointIndex >= min(gCounters[kGiCounterPointHits], frame.queues.z - frame.queues.y)) + { + return; + } + const uint4 packed = gHits[pixelRay ? id.x : frame.queues.y + pointIndex]; + GiRay ray; + const bool found = + pixelRay ? giPixelRayAt(frame, uint2(packed.x & 0xFFFFu, packed.x >> 16u), ray) : giPointRayAt(frame, packed.x, ray); + if (!found) + { + return; + } + giAimRay(frame, packed.y, ray); + const SceneHit hit = giUnpackHit(packed); + const float3 position = ray.origin + ray.direction * hit.t; + const float3 normal = sceneHitNormal(hit, ray.direction); + // Salted so the light pick is independent of the seeding draw of traceMain + uint random = giHash(ray.random ^ 0x68bc21ebu); + const float3 radiance = giClampRay(frame, giOffscreenRadiance(frame, hit, position, normal, ray.pixel, random)); + + const uint top = frame.frame.y - 1u; + const bool primaryHit = !pixelRay || hit.t <= giIntervalEnd(frame, ray.lod, top); + giSplitRay(frame, ray.deposit, ray.lod, ray.origin, ray.direction, primaryHit, hit.t, radiance); + if (ray.overlapDeposit != kGiEmpty) + { + giSplitRay(frame, ray.overlapDeposit, ray.lod + 1u, ray.origin, ray.direction, true, hit.t, radiance); + } +} + +float3 giTileIrradiance(GiFrame frame, float3 world, float3 normal, uint lod, out float total) +{ + const GiCascadeLayout layout = frame.cascades[0]; + const float spacing = giSpacing(frame, lod, 0u); + const float3 coord = (world + normal * (kGiSurfaceBias * spacing)) / spacing - 0.5; + const int3 base = int3(floor(coord)); + const float3 upper = coord - float3(base); + float3 irradiance = float3(0.0, 0.0, 0.0); + total = 0.0; + for (uint corner = 0u; corner < 8u; ++corner) + { + const float weight = giNeighbourWeight(corner, upper); + if (weight <= 0.0) + { + continue; + } + const uint key = giKey(base + giNeighbourOffset(corner), lod); + uint slot = giHash(key) & layout.y; + uint value = kGiEmpty; + for (uint attempt = 0u; attempt < 32u; ++attempt) + { + const uint stored = gTableKeys[layout.x + slot]; + if (stored == key || stored == kGiEmpty) + { + value = stored == key ? gTableValues[layout.x + slot] : kGiEmpty; + break; + } + slot = (slot + 1u) & layout.y; + } + if (value >= layout.w) + { + continue; + } + const uint tile = gPool[frame.frame.z * frame.pool.x + layout.z + value].deposit; + if (tile == kGiEmpty) + { + continue; + } + irradiance += weight * gIrradiance.SampleLevel(giTileUv(tile, normal), 0.0).rgb; + total += weight; + } + return total > 0.0 ? irradiance / total : irradiance; +} + +/// For the change debug view +[shader("compute")] +[numthreads(8, 8, 1)] +void changeMain(uint3 id : SV_DispatchThreadID) +{ + const GiFrame frame = gFrame[0]; + if (any(id.xy >= frame.viewport.xy)) + { + return; + } + const uint index = id.y * frame.viewport.x + id.x; + const float depth = gDepth.Load(int3(id.xy, 0)).r; + if (giIsSky(depth)) + { + gChange[index] = float4(0.0, 0.0, 0.0, 0.0); + return; + } + const float2 uv = (float2(id.xy) + 0.5) / float2(frame.viewport.xy); + const float3 world = giWorldAt(frame, uv, depth); + const float3 normal = giSurfaceNormal(gNormal.Load(int3(id.xy, 0)), world, frame.camera.xyz); + bool overlap = false; + const uint lod = giLod(frame, world, overlap); + float total = 0.0; + const float3 irradiance = giTileIrradiance(frame, world, normal, lod, total); + const float luminance = dot(irradiance, float3(0.2126, 0.7152, 0.0722)); + const float4 last = gChange[index]; + // A silhouette pixel lands on a different surface each jittered frame so it only counts while its surface holds + const float distance = length(world - frame.camera.xyz); + if (abs(distance - last.z) > 0.01 * distance) + { + gChange[index] = float4(luminance, last.y, distance, 0.0); + return; + } + const float change = total > 0.0 ? abs(luminance - last.x) / max(max(luminance, last.x), 1e-4) : 0.0; + gChange[index] = float4(luminance, lerp(last.y, change, 0.1), distance, 0.0); +} diff --git a/engine/assets/shaders/gi_rays.slang.meta b/engine/assets/shaders/gi_rays.slang.meta new file mode 100644 index 000000000..780571d7a --- /dev/null +++ b/engine/assets/shaders/gi_rays.slang.meta @@ -0,0 +1,4 @@ +uid = "G1Rays0RC00" +type = "shader" +created_at = "2026-10-22T00:00:00.0000000Z" +modified_at = "2026-10-22T00:00:00.0000000Z" diff --git a/engine/assets/shaders/gi_sample.slang b/engine/assets/shaders/gi_sample.slang new file mode 100644 index 000000000..a9517c31a --- /dev/null +++ b/engine/assets/shaders/gi_sample.slang @@ -0,0 +1,162 @@ +// Set 0 written by SceneDescriptorSets from GiPass +import gi_common; + +/// Copied by GiPass after its merge so this is the frame the tables belong to and frame.x 0 means no GI yet +[[vk::binding(17, 0)]] +StructuredBuffer gGiFrame; + +[[vk::binding(18, 0)]] +StructuredBuffer gGiTableKeys; + +[[vk::binding(19, 0)]] +StructuredBuffer gGiTableValues; + +[[vk::binding(20, 0)]] +StructuredBuffer gGiPool; + +/// c0 octahedral tiles holding E / pi +[[vk::binding(21, 0)]] +Sampler2D gGiIrradiance; + +static const uint kRcMaxProbes = 32u; + +/// c0 tile per corner around @p base or kGiEmpty with each stage batched over all eight +void rcFindTiles(GiFrame frame, int3 base, uint lod, out uint tiles[8]) +{ + const GiCascadeLayout layout = frame.cascades[0]; + uint keys[8]; + uint slots[8]; + uint stored[8]; + [unroll] + for (uint corner = 0u; corner < 8u; ++corner) + { + keys[corner] = giKey(base + giNeighbourOffset(corner), lod); + slots[corner] = giHash(keys[corner]) & layout.y; + stored[corner] = gGiTableKeys[layout.x + slots[corner]]; + } + // Probing past the first slot is rare with the tables at most half full + [unroll] + for (uint corner = 0u; corner < 8u; ++corner) + { + for (uint attempt = 1u; attempt < kRcMaxProbes && stored[corner] != keys[corner] && stored[corner] != kGiEmpty; ++attempt) + { + slots[corner] = (slots[corner] + 1u) & layout.y; + stored[corner] = gGiTableKeys[layout.x + slots[corner]]; + } + } + uint values[8]; + [unroll] + for (uint corner = 0u; corner < 8u; ++corner) + { + values[corner] = gGiTableValues[layout.x + slots[corner]]; + } + [unroll] + for (uint corner = 0u; corner < 8u; ++corner) + { + const bool found = stored[corner] == keys[corner] && values[corner] < layout.w; + const uint tile = gGiPool[frame.frame.z * frame.pool.x + layout.z + (found ? values[corner] : 0u)].deposit; + tiles[corner] = found ? tile : kGiEmpty; + } +} + +/// Corner tiles around the biased point and its position between them +void rcCornersAtLod(GiFrame frame, float3 worldPosition, float3 normal, uint lod, out float3 upper, out uint tiles[8]) +{ + const float spacing = giSpacing(frame, lod, 0u); + const float3 coord = (worldPosition + normal * (kGiSurfaceBias * spacing)) / spacing - 0.5; + const int3 base = int3(floor(coord)); + upper = coord - float3(base); + rcFindTiles(frame, base, lod, tiles); +} + +/// Weight rcIrradianceAtLod would sum without its irradiance fetches +float rcCoverageAtLod(GiFrame frame, float3 worldPosition, float3 normal, uint lod) +{ + float3 upper; + uint tiles[8]; + rcCornersAtLod(frame, worldPosition, normal, lod, upper, tiles); + float total = 0.0; + [unroll] + for (uint corner = 0u; corner < 8u; ++corner) + { + total += tiles[corner] != kGiEmpty ? giNeighbourWeight(corner, upper) : 0.0; + } + return total; +} + +float3 rcIrradianceAtLod(GiFrame frame, float3 worldPosition, float3 normal, uint lod, out float total) +{ + float3 upper; + uint tiles[8]; + rcCornersAtLod(frame, worldPosition, normal, lod, upper, tiles); + + // Every tile is read at the same normal so the octahedral texel is shared + const float2 texel = 1.0 + giOctEncode(normal) * float(kGiTileInterior); + float3 irradiance = float3(0.0, 0.0, 0.0); + total = 0.0; + [unroll] + for (uint corner = 0u; corner < 8u; ++corner) + { + // A missing probe samples tile 0 at no weight so the eight fetches issue together + const bool found = tiles[corner] != kGiEmpty; + const float weight = found ? giNeighbourWeight(corner, upper) : 0.0; + const float2 uv = (float2(giTileOrigin(found ? tiles[corner] : 0u)) + texel) / kGiAtlasSize; + irradiance += weight * gGiIrradiance.SampleLevel(uv, 0.0).rgb; + total += weight; + } + return total > 0.0 ? irradiance / total : irradiance; +} + +/// Over pi like gIrradianceMap and false where no probe covers the point +bool sampleRadianceCascades(float3 worldPosition, float3 normal, out float3 irradiance) +{ + irradiance = float3(0.0, 0.0, 0.0); + const GiFrame frame = gGiFrame[0]; + if (frame.frame.x == 0u) + { + return false; + } + bool overlap = false; + const uint lod = giLod(frame, worldPosition, overlap); + float nearTotal = 0.0; + const float3 near = rcIrradianceAtLod(frame, worldPosition, normal, lod, nearTotal); + + float blend = 0.0; + float farTotal = 0.0; + float3 far = float3(0.0, 0.0, 0.0); + if (overlap) + { + const float3 offset = abs(worldPosition - frame.camera.xyz); + const float edge = frame.lod.x * float(1u << min(lod + 1u, 30u)); + blend = saturate((max(offset.x, max(offset.y, offset.z)) - frame.lod.y * edge) / ((1.0 - frame.lod.y) * edge)); + far = rcIrradianceAtLod(frame, worldPosition, normal, lod + 1u, farTotal); + } + if (nearTotal <= 0.0 && farTotal <= 0.0) + { + return false; + } + irradiance = nearTotal <= 0.0 ? far : (farTotal <= 0.0 ? near : lerp(near, far, blend)); + return true; +} + +/// Whether sampleRadianceCascades finds a probe at the point +bool radianceCascadesCover(float3 worldPosition, float3 normal) +{ + const GiFrame frame = gGiFrame[0]; + if (frame.frame.x == 0u) + { + return false; + } + bool overlap = false; + const uint lod = giLod(frame, worldPosition, overlap); + return rcCoverageAtLod(frame, worldPosition, normal, lod) > 0.0 || + (overlap && rcCoverageAtLod(frame, worldPosition, normal, lod + 1u) > 0.0); +} + +/// End of the c0 interval at a point so short contact rays can add what the probe spacing blurs +float radianceCascadesContactReach(float3 worldPosition) +{ + const GiFrame frame = gGiFrame[0]; + bool overlap = false; + return frame.rays.x * giSpacing(frame, giLod(frame, worldPosition, overlap), 0u); +} diff --git a/engine/assets/shaders/gi_sample.slang.meta b/engine/assets/shaders/gi_sample.slang.meta new file mode 100644 index 000000000..5d585d213 --- /dev/null +++ b/engine/assets/shaders/gi_sample.slang.meta @@ -0,0 +1,4 @@ +uid = "G1Sampl3RC0" +type = "shader" +created_at = "2026-10-22T00:00:00.0000000Z" +modified_at = "2026-10-22T00:00:00.0000000Z" diff --git a/engine/assets/shaders/gi_sky.slang b/engine/assets/shaders/gi_sky.slang new file mode 100644 index 000000000..854b26a8b --- /dev/null +++ b/engine/assets/shaders/gi_sky.slang @@ -0,0 +1,9 @@ +// What a GI ray sees past the last cascade so the trace and the merge agree on it +import lighting; + +/// Matches the lighting ambient term so turning GI on keeps the sky as bright +float3 giSky(float3 direction) +{ + const float3 ambient = gCamera.ambientColorIntensity.rgb * gCamera.ambientColorIntensity.a; + return gCamera.environmentParams.x >= 0.5 ? ambient + gPrefilteredEnv.SampleLevel(direction, 0.0).rgb : ambient; +} diff --git a/engine/assets/shaders/gi_sky.slang.meta b/engine/assets/shaders/gi_sky.slang.meta new file mode 100644 index 000000000..72a25e6d6 --- /dev/null +++ b/engine/assets/shaders/gi_sky.slang.meta @@ -0,0 +1,4 @@ +uid = "G1Sky0RC000" +type = "shader" +created_at = "2026-10-22T00:00:00.0000000Z" +modified_at = "2026-10-22T00:00:00.0000000Z" diff --git a/engine/assets/shaders/lighting.slang b/engine/assets/shaders/lighting.slang index a3dd60f36..cdae8f503 100644 --- a/engine/assets/shaders/lighting.slang +++ b/engine/assets/shaders/lighting.slang @@ -1,4 +1,5 @@ // Set 0 belongs to the engine and importing shaders own set 1 +import gi_sample; struct DirectionalLightGpu { @@ -83,6 +84,11 @@ struct CameraUBO /// xy blue noise texel offset walking R2 per frame z frame index float4 noiseParams; + + /// x height fog extinction per meter at the base height y base height z height falloff w fog volumes + float4 fogMedium; + /// x height fog distance y fog volume distance z marched sun reach in meters + float4 fogReach; }; [[vk::binding(0,0)]] @@ -459,7 +465,9 @@ float2 concentricDisc(float2 u) { const float radius = sqrt(u.x); const float angle = 6.28318531 * u.y; - return radius * float2(cos(angle), sin(angle)); + float s, c; + sincos(angle, s, c); + return radius * float2(c, s); } /// A sphere samples its silhouette disc facing the receiver @@ -630,9 +638,11 @@ static const float2 kPoissonDisk[12] = { static const uint kShadowProbeTaps = 4u; +/// Walked per frame so TAA averages the rotation float interleavedGradientNoise(float2 pixel) { - return frac(52.9829189 * frac(dot(pixel, float2(0.06711056, 0.00583715)))); + const float2 shifted = pixel + 5.588238 * float(uint(gCamera.noiseParams.z) & 63u); + return frac(52.9829189 * frac(dot(shifted, float2(0.06711056, 0.00583715)))); } static const float kPunctualBiasMeters = 0.01; @@ -1073,6 +1083,57 @@ float3 clusterHeatmapColor(uint lightCount) return lerp(c2, c3, (t - 0.667) / 0.333); } +/// One compare tap since scattering blurs any filtered edge +float volumeSunShadow(float3 worldPosition) +{ + if (gCamera.shadowParams.x < 0.5) + { + return 1.0; + } + const uint cascadeCount = uint(gCamera.shadowParams.y); + for (uint cascade = 0u; cascade < cascadeCount; ++cascade) + { + if (cascadeEdge(cascade, worldPosition) <= 0.0) + { + continue; + } + const float3 center = cascadeUvDepth(cascade, worldPosition); + return gShadowMap.SampleCmpLevelZero(float3(center.xy, float(cascade)), center.z - gCamera.cascadeDepthBias[cascade]); + } + return 1.0; +} + +/// One compare tap in the spot or cube face map and 1 for a light without one +float volumeLocalShadow(GpuLight light, float3 worldPosition) +{ + const float layer = light.directionPad.w; + if (layer < 0.0) + { + return 1.0; + } + const float3 direction = worldPosition - light.worldPosRange.xyz; + if (dot(direction, direction) < 1e-8) + { + return 1.0; + } + const float3 toLight = -normalize(direction); + if (light.viewPosType.w > 0.5) + { + if (mul(light.shadowViewProjection, float4(worldPosition, 1.0)).w <= 0.0) + { + return 1.0; + } + const float3 center = spotUvDepth(light, worldPosition); + const float compareDepth = spotUvDepth(light, worldPosition + toLight * kPunctualBiasMeters).z; + return gPunctualShadowMaps.SampleCmpLevelZero(float3(center.xy, layer), compareDepth); + } + const uint face = pointFace(direction); + const PointFaceFrame frame = pointFaceFrame(light, face); + const float3 center = pointFaceUvDepth(frame, direction); + const float compareDepth = pointFaceDepth(frame, direction + toLight * kPunctualBiasMeters); + return gPunctualShadowMaps.SampleCmpLevelZero(float3(center.xy, layer + float(face)), compareDepth); +} + struct Surface { float3 worldPosition; @@ -1110,6 +1171,12 @@ struct ShadingResult float3 indirectDiffuse; + /// Environment and probe share of specular which traced specular replaces on voxels + float3 ambientSpecular; + + /// Meters below which the radiance cascades blur occlusion and 0 where they did not light the point + float contactReach; + bool isDebugView; }; @@ -1171,8 +1238,19 @@ bool isVoxelDebugMode(uint mode) return mode >= kRenderModeVoxelSteps && mode <= kRenderModeVoxelPaletteId; } +/// Modes evaluateDebugView draws for every surface +bool isSurfaceDebugMode(uint mode) +{ + return (mode >= 1u && mode <= 10u) || mode == 16u || isVoxelDebugMode(mode); +} + float4 evaluateDebugView(Surface surface, ClusterCell cluster) { + if (!isSurfaceDebugMode(gCamera.renderModePad.x)) + { + return float4(0.0, 0.0, 0.0, 0.0); + } + float3 albedo = surface.albedo; float3 N = surface.normal; float3 V = surface.viewDirection; @@ -1200,10 +1278,17 @@ float4 evaluateDebugView(Surface surface, ClusterCell cluster) case 4: return float4(metallic, roughness, 0.0, 1.0); case 5: - return float4( - gCamera.ambientColorIntensity.rgb * gCamera.ambientColorIntensity.a + - (gCamera.environmentParams.x >= 0.5 ? gIrradianceMap.SampleLevel(N, 0.0).rgb : float3(0.0, 0.0, 0.0)), - 1.0); + { + float3 cascadeIrradiance; + if (sampleRadianceCascades(surface.worldPosition, N, cascadeIrradiance)) + { + return float4(cascadeIrradiance, 1.0); + } + return float4( + gCamera.ambientColorIntensity.rgb * gCamera.ambientColorIntensity.a + + (gCamera.environmentParams.x >= 0.5 ? gIrradianceMap.SampleLevel(N, 0.0).rgb : float3(0.0, 0.0, 0.0)), + 1.0); + } case 6: { float3 debugReflection = reflect(-V, N); @@ -1313,6 +1398,9 @@ interface IShadowFilter { /// @p distance is negative for a directional light float3 filter(float3 L, float distance); + /// False leaves shadow mapped lights to another pass + bool shadesMapLights(); + float traceMeshShadow(float3 position, float3 normal, float3 L, float distance); } struct NoShadowFilter : IShadowFilter @@ -1321,10 +1409,89 @@ struct NoShadowFilter : IShadowFilter { return float3(1.0, 1.0, 1.0); } + + bool shadesMapLights() + { + return true; + } + + float traceMeshShadow(float3 position, float3 normal, float3 L, float distance) + { + return traceShadowRay(position, normal, L, distance); + } } -ShadingResult evaluateLighting(Surface surface, ClusterCell cluster, Filter shadowFilter) +int bestReflectionProbe(float3 worldPosition, out float weight) { + int best = -1; + weight = 0.0; + for (uint pi = 0; pi < gCamera.reflectionProbeCountPad.x; ++pi) + { + const float probeWeight = reflectionProbeWeight(worldPosition, gCamera.reflectionProbes[pi]); + if (probeWeight > weight) + { + weight = probeWeight; + best = int(pi); + } + } + return best; +} + +/// Uniform ambient plus the prefiltered environment blended toward the best probe +float3 ambientSpecularRadiance(float3 worldPosition, float3 reflection, float roughness, int bestProbe, float bestProbeWeight) +{ + const float3 uniformRadiance = gCamera.ambientColorIntensity.rgb * gCamera.ambientColorIntensity.a; + if (gCamera.environmentParams.x < 0.5) + { + return uniformRadiance; + } + + const float prefilteredLod = roughness * max(gCamera.environmentParams.y - 1.0, 0.0); + float3 globalSpecular = gPrefilteredEnv.SampleLevel(reflection, prefilteredLod).rgb; + if (bestProbe >= 0) + { + ReflectionProbeGpu probe = gCamera.reflectionProbes[bestProbe]; + float3 corrected = parallaxCorrectedReflection( + reflection, + worldPosition, + probe.positionRadius.xyz, + probe.boxExtentsIntensity.xyz, + probe.positionRadius.w); + + uint probeCube = uint(probe.params.x); + bool probeBaked = (gCamera.reflectionProbeCountPad.z & (1u << probeCube)) != 0u; + float3 probeSpecular; + if (probeBaked) + { + float probeLod = roughness * max(float(gCamera.reflectionProbeCountPad.y) - 1.0, 0.0); + probeSpecular = gProbeMaps[NonUniformResourceIndex(probeCube)].SampleLevel(corrected, probeLod).rgb; + } + else + { + probeSpecular = gPrefilteredEnv.SampleLevel(corrected, prefilteredLod).rgb; + } + probeSpecular *= probe.boxExtentsIntensity.w; + globalSpecular = lerp(globalSpecular, probeSpecular, bestProbeWeight); + } + return uniformRadiance + globalSpecular; +} + +float3 ambientSpecularRadiance(float3 worldPosition, float3 reflection, float roughness) +{ + float weight; + const int best = bestReflectionProbe(worldPosition, weight); + return ambientSpecularRadiance(worldPosition, reflection, roughness, best, weight); +} + +/// Shadow mapped suns and cluster lights +void evaluateDirectLighting(Surface surface, ClusterCell cluster, Filter shadowFilter, inout float3 color, + inout float3 specular, inout float debugMinShadow) +{ + if (!shadowFilter.shadesMapLights()) + { + return; + } + float3 N = surface.normal; float3 V = surface.viewDirection; float3 geometricNormal = surface.geometricNormal; @@ -1332,26 +1499,13 @@ ShadingResult evaluateLighting(Surface surface, ClusterC float metallic = surface.metallic; float roughness = surface.roughness; float reflectivity = saturate(surface.reflectivity); - float occlusion = surface.occlusion; uint clusterIndex = cluster.index; uint clusterCount = cluster.count; uint lightCountInCluster = cluster.lightCount; float dist = cluster.viewDistance; - 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); - uint shadowCasterIndex = uint(gCamera.shadowParams.x) - 1; - - float debugMinShadow = 1.0; bool shadowDebugView = gCamera.renderModePad.x == 19; for (uint di = 0; di < gCamera.directionalLightCountPad.x; ++di) @@ -1372,7 +1526,7 @@ ShadingResult evaluateLighting(Surface surface, ClusterC float shadow = 1.0; if (tracedShadowsEnabled()) { - shadow = traceShadowRay(surface.worldPosition, geometricNormal, L, 1.0e5); + shadow = shadowFilter.traceMeshShadow(surface.worldPosition, geometricNormal, L, 1.0e5); } else if (gCamera.shadowParams.x >= 0.5 && di == shadowCasterIndex) { @@ -1436,7 +1590,7 @@ ShadingResult evaluateLighting(Surface surface, ClusterC float shadow; if (tracedShadowsEnabled()) { - shadow = traceShadowRay(surface.worldPosition, geometricNormal, L, lightDist); + shadow = shadowFilter.traceMeshShadow(surface.worldPosition, geometricNormal, L, lightDist); } else { @@ -1460,6 +1614,30 @@ ShadingResult evaluateLighting(Surface surface, ClusterC specular += lightSpecular * weight; } } +} + +ShadingResult evaluateLighting(Surface surface, ClusterCell cluster, Filter shadowFilter) +{ + float3 N = surface.normal; + float3 V = surface.viewDirection; + float3 albedo = surface.albedo; + float metallic = surface.metallic; + float roughness = surface.roughness; + float reflectivity = saturate(surface.reflectivity); + float occlusion = surface.occlusion; + + ShadingResult result; + result.indirectDiffuse = float3(0.0, 0.0, 0.0); + result.specular = float3(0.0, 0.0, 0.0); + result.ambientSpecular = float3(0.0, 0.0, 0.0); + result.contactReach = 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); + float debugMinShadow = 1.0; + evaluateDirectLighting(surface, cluster, shadowFilter, color, specular, debugMinShadow); if (gCamera.renderModePad.x == 19) { @@ -1472,27 +1650,17 @@ ShadingResult evaluateLighting(Surface surface, ClusterC float NdotV = saturate(dot(N, V)); bool hasEnvironment = gCamera.environmentParams.x >= 0.5; - float3 reflection = reflect(-V, N); - - float prefilteredLod = roughness * max(gCamera.environmentParams.y - 1.0, 0.0); - - int bestProbe = -1; - float bestProbeWeight = 0.0; - for (uint pi = 0; pi < gCamera.reflectionProbeCountPad.x; ++pi) - { - float weight = reflectionProbeWeight(surface.worldPosition, gCamera.reflectionProbes[pi]); - if (weight > bestProbeWeight) - { - bestProbeWeight = weight; - bestProbe = int(pi); - } - } + float bestProbeWeight; + const int bestProbe = bestReflectionProbe(surface.worldPosition, bestProbeWeight); float3 irradiance = uniformRadiance; - float3 prefiltered = uniformRadiance; + const float3 prefiltered = ambientSpecularRadiance(surface.worldPosition, reflect(-V, N), roughness, bestProbe, bestProbeWeight); + + float3 cascadeIrradiance; + const bool hasCascades = sampleRadianceCascades(surface.worldPosition, N, cascadeIrradiance); - float3 volumeIrradiance; - bool hasVolumeIrradiance = sampleIrradianceVolumes(surface.worldPosition, N, volumeIrradiance); + float3 volumeIrradiance = float3(0.0, 0.0, 0.0); + bool hasVolumeIrradiance = !hasCascades && sampleIrradianceVolumes(surface.worldPosition, N, volumeIrradiance); if (hasEnvironment) { @@ -1507,42 +1675,19 @@ ShadingResult evaluateLighting(Surface surface, ClusterC } irradiance += hasVolumeIrradiance ? volumeIrradiance : globalIrradiance; - - float3 globalSpecular = gPrefilteredEnv.SampleLevel(reflection, prefilteredLod).rgb; - - if (bestProbe >= 0) - { - ReflectionProbeGpu probe = gCamera.reflectionProbes[bestProbe]; - float3 corrected = parallaxCorrectedReflection( - reflection, - surface.worldPosition, - probe.positionRadius.xyz, - probe.boxExtentsIntensity.xyz, - probe.positionRadius.w); - - uint probeCube = uint(probe.params.x); - bool probeBaked = (gCamera.reflectionProbeCountPad.z & (1u << probeCube)) != 0u; - float3 probeSpecular; - if (probeBaked) - { - float probeLod = roughness * max(float(gCamera.reflectionProbeCountPad.y) - 1.0, 0.0); - probeSpecular = gProbeMaps[NonUniformResourceIndex(probeCube)].SampleLevel(corrected, probeLod).rgb; - } - else - { - probeSpecular = gPrefilteredEnv.SampleLevel(corrected, prefilteredLod).rgb; - } - probeSpecular *= probe.boxExtentsIntensity.w; - globalSpecular = lerp(globalSpecular, probeSpecular, bestProbeWeight); - } - - prefiltered += globalSpecular; } else if (hasVolumeIrradiance) { irradiance = volumeIrradiance; } + // The cascades already hold the sky and what it bounces off so they replace ambient and baked volumes + if (hasCascades) + { + irradiance = cascadeIrradiance; + result.contactReach = radianceCascadesContactReach(surface.worldPosition); + } + float3 F0 = lerp(float3(0.04, 0.04, 0.04), albedo, metallic); float3 specularFresnel = fresnelSchlickRoughness(NdotV, F0, roughness); @@ -1557,6 +1702,7 @@ ShadingResult evaluateLighting(Surface surface, ClusterC outIndirectDiffuse = ambientDiffuse * occlusion; + result.ambientSpecular = ambientSpecular * occlusion; result.color = color; result.specular = specular; result.indirectDiffuse = outIndirectDiffuse; @@ -1574,6 +1720,8 @@ ShadingResult shadeSurfaceLighting(Surface surface, Filt result.color = debug.rgb; result.specular = float3(0.0, 0.0, 0.0); result.indirectDiffuse = float3(0.0, 0.0, 0.0); + result.ambientSpecular = float3(0.0, 0.0, 0.0); + result.contactReach = 0.0; result.isDebugView = true; return result; } diff --git a/engine/assets/shaders/mesh.slang b/engine/assets/shaders/mesh.slang index 71ab37f28..6106b0abd 100644 --- a/engine/assets/shaders/mesh.slang +++ b/engine/assets/shaders/mesh.slang @@ -219,11 +219,15 @@ float4 shadeSurface(VSOutput input, inout float4 outNormalRoughness, inout float float3 albedo = albedoSample.rgb * gMaterial.tint.rgb * input.color; float metallic = clamp(gMaterial.metallic * channelOf(gMetallicMap.Sample(input.uv), gMaterial.metallicChannel), 0.0, 1.0); float roughness = clamp(gMaterial.roughness * channelOf(gRoughnessMap.Sample(input.uv), gMaterial.roughnessChannel), 0.045, 1.0); - float occlusion = lerp( - 1.0, - channelOf(gOcclusionMap.Sample(input.uv), gMaterial.occlusionChannel), - clamp(gMaterial.occlusionStrength, 0.0, 1.0) - ); + float occlusion = 1.0; + if (gMaterial.occlusionStrength > 0.0) + { + occlusion = lerp( + 1.0, + channelOf(gOcclusionMap.Sample(input.uv), gMaterial.occlusionChannel), + clamp(gMaterial.occlusionStrength, 0.0, 1.0) + ); + } float3 N = normalize(input.worldNormal); float3 T = input.worldTangent - N * dot(N, input.worldTangent); @@ -277,7 +281,10 @@ float4 shadeSurface(VSOutput input, inout float4 outNormalRoughness, inout float float3 color = lit.color; - color += gMaterial.emissiveColor * gMaterial.emissiveIntensity * gEmissiveMap.Sample(input.uv).rgb; + if (gMaterial.emissiveIntensity > 0.0 && any(gMaterial.emissiveColor > 0.0)) + { + color += gMaterial.emissiveColor * gMaterial.emissiveIntensity * gEmissiveMap.Sample(input.uv).rgb; + } return float4(color, alpha); } diff --git a/engine/assets/shaders/scene_trace.slang b/engine/assets/shaders/scene_trace.slang new file mode 100644 index 000000000..36aae1d11 --- /dev/null +++ b/engine/assets/shaders/scene_trace.slang @@ -0,0 +1,513 @@ +// Voxel rays across every traced volume through ray queries when the TLAS holds the volumes and a camera centred +// cell grid otherwise and both march the same volumes with voxel_dda +import voxel_dda; +import lighting; + +/// Mirrors renderer::VoxelTraceScene in the order of the TLAS custom indices +[[vk::binding(10, 1)]] +StructuredBuffer gTraceVolumes; +/// xyz centre w radius +[[vk::binding(11, 1)]] +StructuredBuffer gTraceBounds; +/// Mirrors renderer::VoxelTraceScene::writeGrid +[[vk::binding(12, 1)]] +StructuredBuffer gTraceGrid; +/// x opaque y glass first brick of each volume in gTraceBricks z record +[[vk::binding(14, 1)]] +StructuredBuffer gTraceBrickBases; +/// Brick of each brick BLAS primitive as x | y << 10 | z << 20 +[[vk::binding(15, 1)]] +StructuredBuffer gTraceBricks; + +/// Mirrors VoxelTraceScene::k_mask_volume and k_mask_glass +static const uint kTraceMaskVolume = 2u; +static const uint kTraceMaskGlass = 4u; +/// Mirrors renderer::k_brick_coord_bits +static const uint kTraceBrickBits = 10u; +/// Mirrors VoxelBlasCache::k_instance_bricks and k_instance_glass with the volume below them +static const uint kTraceInstanceBricks = 1u << 23; +static const uint kTraceInstanceGlass = 1u << 22; +static const uint kTraceInstanceVolume = kTraceInstanceGlass - 1u; + +/// Header words then offset and count per cell +static const uint kTraceVolumeCount = 5u; +static const uint kTraceFarCount = 6u; +static const uint kTraceGlassCount = 7u; +static const uint kTraceFarOffset = 8u; +static const uint kTraceGlassOffset = 9u; +static const uint kTraceEntriesOffset = 10u; +static const uint kTraceHeaderWords = 16u; + +/// Ray query candidates may repeat so tinted traversal skips this many recent volumes +static const uint kTraceSeen = 8u; +/// Above every volume index so an unused ring slot never matches +static const uint kTraceEmptyVolume = 0xFFFFFFFFu; + +struct SceneHit +{ + bool hit; + float t; + uint volume; + int3 voxel; + int axis; + uint material; +}; + +bool traceRayQueryEnabled() +{ +#if TOAST_RAY_QUERY + return gCamera.tracedShadowParams.y >= 0.5; +#else + return false; +#endif +} + +/// Cone footprint grows from t 0 so a march restarted mid ray keeps the floor it would have had +RayHit traceMarchBox(uint v, float3 origin, float3 direction, float tMin, float tMax, float coneSlope, bool tinted, + inout float3 transmittance, int3 boxMin, int3 boxMax) +{ + const VoxelInstance volume = gTraceVolumes[v]; + const float3 voxelOrigin = mul(volume.worldToVoxel, float4(origin, 1.0)).xyz; + const float3 voxelDirection = mul(volume.worldToVoxel, float4(direction, 0.0)).xyz; + const uint record = volume.recordIndex; + + ConeFloor cone; + cone.tMin = 0.0; + cone.slope = coneSlope * length(voxelDirection); + if (tinted) + { + TintGlass tint; + tint.transmittance = transmittance; + tint.runEnd = -kFloatMax; + const RayHit hit = marchCore(record, voxelOrigin, voxelDirection, tMin, tMax, boxMin, boxMax, true, cone, tint); + transmittance = tint.transmittance; + return hit; + } + SkipGlass skip; + return marchCore(record, voxelOrigin, voxelDirection, tMin, tMax, boxMin, boxMax, true, cone, skip); +} + +RayHit traceMarchVolume(uint v, float3 origin, float3 direction, float tMin, float tMax, float coneSlope, bool tinted, + inout float3 transmittance) +{ + const uint record = gTraceVolumes[v].recordIndex; + const int3 dims = + int3(gVolumeRecords[record].brickDimsX, gVolumeRecords[record].brickDimsY, gVolumeRecords[record].brickDimsZ) * int(kBrickDim); + return traceMarchBox(v, origin, direction, tMin, tMax, coneSlope, tinted, transmittance, int3(0, 0, 0), dims); +} + +/// Exact over one brick with the candidate object space ray which is voxel space for a brick BLAS +RayHit traceMarchBrick(uint custom, uint primitive, float3 voxelOrigin, float3 voxelDirection, float tMin, float tMax, bool tinted, + inout float3 transmittance) +{ + const uint4 bases = gTraceBrickBases[custom & kTraceInstanceVolume]; + const uint packed = gTraceBricks[((custom & kTraceInstanceGlass) != 0u ? bases.y : bases.x) + primitive]; + const uint mask = (1u << kTraceBrickBits) - 1u; + const int3 brick = int3(int(packed & mask), int((packed >> kTraceBrickBits) & mask), int(packed >> (2u * kTraceBrickBits))); + const uint record = bases.z; + const uint entry = gridEntry(record, uint3(brick)); + const int3 boxMin = brick * int(kBrickDim); + ExactFloor exact; + if (tinted) + { + TintGlass tint; + tint.transmittance = transmittance; + tint.runEnd = -kFloatMax; + const RayHit hit = marchCore(record, voxelOrigin, voxelDirection, tMin, tMax, boxMin, boxMin + int(kBrickDim), true, exact, tint, entry); + transmittance = tint.transmittance; + return hit; + } + SkipGlass skip; + return marchCore(record, voxelOrigin, voxelDirection, tMin, tMax, boxMin, boxMin + int(kBrickDim), true, exact, skip, entry); +} + +bool traceBoundsRange(uint v, float3 origin, float3 direction, float tMin, float tMax) +{ + const float4 bounds = gTraceBounds[v]; + const float lengthSquared = max(dot(direction, direction), 1e-12); + const float along = clamp(dot(bounds.xyz - origin, direction) / lengthSquared, tMin, tMax); + const float3 nearest = origin + direction * along - bounds.xyz; + return dot(nearest, nearest) <= bounds.w * bounds.w; +} + +/// Ring of the last kTraceSeen volumes in two vectors since an indexed array lands in local memory +struct TraceSeen +{ + uint4 low; + uint4 high; + uint count; +}; + +TraceSeen traceSeenEmpty() +{ + TraceSeen seen; + seen.low = uint4(kTraceEmptyVolume, kTraceEmptyVolume, kTraceEmptyVolume, kTraceEmptyVolume); + seen.high = seen.low; + seen.count = 0u; + return seen; +} + +bool traceSeen(inout TraceSeen seen, uint v) +{ + if (any(seen.low == v) || any(seen.high == v)) + { + return true; + } + const uint slot = seen.count % kTraceSeen; + seen.low = select(uint4(0u, 1u, 2u, 3u) == slot, uint4(v, v, v, v), seen.low); + seen.high = select(uint4(4u, 5u, 6u, 7u) == slot, uint4(v, v, v, v), seen.high); + ++seen.count; + return false; +} + +/// Grid header unpacked once per ray where a volume is listed in every cell its bounds touch +struct TraceGridWalk +{ + float3 cellOrigin; + float cellSize; + int dim; + uint cellsBase; + uint entriesBase; +} + +TraceGridWalk traceGridWalk() +{ + TraceGridWalk walk; + walk.cellOrigin = float3(asfloat(gTraceGrid[0]), asfloat(gTraceGrid[1]), asfloat(gTraceGrid[2])); + walk.cellSize = asfloat(gTraceGrid[3]); + walk.dim = int(gTraceGrid[4]); + walk.cellsBase = kTraceHeaderWords; + walk.entriesBase = gTraceGrid[kTraceEntriesOffset]; + return walk; +} + +bool traceGridRange(TraceGridWalk walk, float3 origin, float3 direction, inout float tMin, inout float tMax) +{ + const float3 boxMin = walk.cellOrigin; + const float3 boxMax = walk.cellOrigin + float(walk.dim) * walk.cellSize; + const float3 inverse = 1.0 / select(abs(direction) > 1e-12, direction, float3(1e-12, 1e-12, 1e-12)); + const float3 t0 = (boxMin - origin) * inverse; + const float3 t1 = (boxMax - origin) * inverse; + const float3 near = min(t0, t1); + const float3 far = max(t0, t1); + tMin = max(tMin, max(near.x, max(near.y, near.z))); + tMax = min(tMax, min(far.x, min(far.y, far.z))); + return tMin < tMax; +} + +/// Order independent visibility so it suits glass tint and any opaque hit ends it +float3 traceVisibilitySoftware(float3 origin, float3 segment, float coneSlope, bool tinted) +{ + float3 transmittance = float3(1.0, 1.0, 1.0); + + const uint farCount = gTraceGrid[kTraceFarCount]; + const uint farBase = gTraceGrid[kTraceFarOffset]; + for (uint f = 0u; f < farCount; ++f) + { + const uint v = gTraceGrid[farBase + f]; + if (!traceBoundsRange(v, origin, segment, 0.0, 1.0)) + { + continue; + } + if (traceMarchVolume(v, origin, segment, 0.0, 1.0, coneSlope, tinted, transmittance).hit) + { + return float3(0.0, 0.0, 0.0); + } + } + + const TraceGridWalk walk = traceGridWalk(); + float tEnter = 0.0; + float tExit = 1.0; + if (walk.dim <= 0 || !traceGridRange(walk, origin, segment, tEnter, tExit)) + { + return transmittance; + } + + const float3 start = (origin + segment * tEnter - walk.cellOrigin) / walk.cellSize; + int3 cell = clamp(int3(floor(start)), int3(0, 0, 0), int3(walk.dim - 1, walk.dim - 1, walk.dim - 1)); + const int3 stepSign = int3(sign(segment)); + const float3 cellT = walk.cellSize / max(abs(segment), float3(1e-12, 1e-12, 1e-12)); + float3 nextT; + [unroll] + for (int a = 0; a < 3; ++a) + { + const float boundary = walk.cellOrigin[a] + (float(cell[a]) + (stepSign[a] > 0 ? 1.0 : 0.0)) * walk.cellSize; + nextT[a] = stepSign[a] == 0 ? kFloatMax : (boundary - origin[a]) / segment[a]; + } + + float cellEnter = tEnter; + for (int steps = 0; steps < 3 * walk.dim && cellEnter < tExit; ++steps) + { + const float cellExit = min(min(nextT.x, min(nextT.y, nextT.z)), tExit); + const uint index = uint(cell.x + cell.y * walk.dim + cell.z * walk.dim * walk.dim); + const uint first = gTraceGrid[walk.cellsBase + 2u * index]; + const uint count = gTraceGrid[walk.cellsBase + 2u * index + 1u]; + for (uint e = 0u; e < count; ++e) + { + const uint v = gTraceGrid[walk.entriesBase + first + e]; + if (!traceBoundsRange(v, origin, segment, cellEnter, cellExit)) + { + continue; + } + // Only the part inside this cell so a volume listed in several cells is never counted twice + if (traceMarchVolume(v, origin, segment, cellEnter, cellExit, coneSlope, tinted, transmittance).hit) + { + return float3(0.0, 0.0, 0.0); + } + } + + const int axis = nextT.x <= nextT.y ? (nextT.x <= nextT.z ? 0 : 2) : (nextT.y <= nextT.z ? 1 : 2); + cell[axis] += stepSign[axis]; + if (cell[axis] < 0 || cell[axis] >= walk.dim) + { + break; + } + nextT[axis] += cellT[axis]; + cellEnter = cellExit; + } + return transmittance; +} + +/// Transmittance along origin to origin + segment and 0 once a voxel blocks it +float3 traceVisibility(float3 origin, float3 segment, float coneSlope, bool tinted) +{ +#if TOAST_RAY_QUERY + if (traceRayQueryEnabled()) + { + float3 transmittance = float3(1.0, 1.0, 1.0); + TraceSeen seen = traceSeenEmpty(); + + RayDesc ray; + ray.Origin = origin; + ray.Direction = segment; + ray.TMin = 0.0; + ray.TMax = 1.0; + RayQuery query; + query.TraceRayInline(gScene, RAY_FLAG_NONE, tinted ? kTraceMaskVolume | kTraceMaskGlass : kTraceMaskVolume, ray); + while (query.Proceed()) + { + if (query.CandidateType() != CANDIDATE_PROCEDURAL_PRIMITIVE) + { + continue; + } + const uint custom = query.CandidateInstanceID(); + const uint v = custom & kTraceInstanceVolume; + bool blocked = false; + if ((custom & kTraceInstanceBricks) != 0u) + { + // Glass voxels tint only through the glass BLAS so a mixed brick tints once + const bool glassRun = (custom & kTraceInstanceGlass) != 0u; + blocked = traceMarchBrick( + custom, query.CandidatePrimitiveIndex(), query.CandidateObjectRayOrigin(), query.CandidateObjectRayDirection(), + 0.0, 1.0, glassRun, transmittance + ).hit; + } + else if (!tinted || !traceSeen(seen, v)) + { + blocked = traceMarchVolume(v, origin, segment, 0.0, 1.0, coneSlope, tinted, transmittance).hit; + } + if (blocked || (tinted && max(transmittance.x, max(transmittance.y, transmittance.z)) <= kTintedOpaque)) + { + query.Abort(); + return float3(0.0, 0.0, 0.0); + } + } + return transmittance; + } +#endif + return traceVisibilitySoftware(origin, segment, coneSlope, tinted); +} + +/// Glass tint only and an opaque voxel ends its own volume but not the ray +float3 traceGlassFilter(float3 origin, float3 segment, float coneSlope) +{ + float3 transmittance = float3(1.0, 1.0, 1.0); +#if TOAST_RAY_QUERY + if (traceRayQueryEnabled()) + { + TraceSeen seen = traceSeenEmpty(); + + RayDesc ray; + ray.Origin = origin; + ray.Direction = segment; + ray.TMin = 0.0; + ray.TMax = 1.0; + RayQuery query; + query.TraceRayInline(gScene, RAY_FLAG_NONE, kTraceMaskGlass, ray); + while (query.Proceed()) + { + if (query.CandidateType() != CANDIDATE_PROCEDURAL_PRIMITIVE) + { + continue; + } + const uint custom = query.CandidateInstanceID(); + const uint v = custom & kTraceInstanceVolume; + if ((custom & kTraceInstanceBricks) != 0u) + { + traceMarchBrick( + custom, query.CandidatePrimitiveIndex(), query.CandidateObjectRayOrigin(), query.CandidateObjectRayDirection(), 0.0, 1.0, + true, transmittance + ); + } + else if (!traceSeen(seen, v)) + { + traceMarchVolume(v, origin, segment, 0.0, 1.0, coneSlope, true, transmittance); + } + if (max(transmittance.x, max(transmittance.y, transmittance.z)) <= kTintedOpaque) + { + query.Abort(); + return float3(0.0, 0.0, 0.0); + } + } + return transmittance; + } +#endif + const uint glassCount = gTraceGrid[kTraceGlassCount]; + const uint glassBase = gTraceGrid[kTraceGlassOffset]; + for (uint g = 0u; g < glassCount; ++g) + { + const uint v = gTraceGrid[glassBase + g]; + if (!traceBoundsRange(v, origin, segment, 0.0, 1.0)) + { + continue; + } + traceMarchVolume(v, origin, segment, 0.0, 1.0, coneSlope, true, transmittance); + if (max(transmittance.x, max(transmittance.y, transmittance.z)) <= kTintedOpaque) + { + return float3(0.0, 0.0, 0.0); + } + } + return transmittance; +} + +void traceKeepCloser(uint v, RayHit hit, inout SceneHit best) +{ + if (hit.hit && hit.t < best.t) + { + best.hit = true; + best.t = hit.t; + best.volume = v; + best.voxel = hit.voxel; + best.axis = hit.axis; + best.material = hit.material; + } +} + +/// Closest opaque voxel along origin + direction * t for t in [tMin tMax] while glass passes untinted +SceneHit traceScene(float3 origin, float3 direction, float tMin, float tMax, float coneSlope) +{ + SceneHit best; + best.hit = false; + best.t = tMax; + best.volume = 0u; + best.voxel = int3(0, 0, 0); + best.axis = -1; + best.material = 0u; + float3 unused = float3(1.0, 1.0, 1.0); + +#if TOAST_RAY_QUERY + if (traceRayQueryEnabled()) + { + RayDesc ray; + ray.Origin = origin; + ray.Direction = direction; + ray.TMin = tMin; + ray.TMax = tMax; + RayQuery query; + query.TraceRayInline(gScene, RAY_FLAG_NONE, kTraceMaskVolume, ray); + while (query.Proceed()) + { + if (query.CandidateType() != CANDIDATE_PROCEDURAL_PRIMITIVE) + { + continue; + } + const uint custom = query.CandidateInstanceID(); + const uint v = custom & kTraceInstanceVolume; + const RayHit hit = (custom & kTraceInstanceBricks) != 0u + ? traceMarchBrick( + custom, query.CandidatePrimitiveIndex(), query.CandidateObjectRayOrigin(), + query.CandidateObjectRayDirection(), tMin, best.t, false, unused + ) + : traceMarchVolume(v, origin, direction, tMin, best.t, coneSlope, false, unused); + if (hit.hit && hit.t < best.t) + { + traceKeepCloser(v, hit, best); + query.CommitProceduralPrimitiveHit(hit.t); + } + } + return best; + } +#endif + + const uint farCount = gTraceGrid[kTraceFarCount]; + const uint farBase = gTraceGrid[kTraceFarOffset]; + for (uint f = 0u; f < farCount; ++f) + { + const uint v = gTraceGrid[farBase + f]; + if (traceBoundsRange(v, origin, direction, tMin, best.t)) + { + traceKeepCloser(v, traceMarchVolume(v, origin, direction, tMin, best.t, coneSlope, false, unused), best); + } + } + + const TraceGridWalk walk = traceGridWalk(); + float tEnter = tMin; + float tExit = best.t; + if (walk.dim <= 0 || !traceGridRange(walk, origin, direction, tEnter, tExit)) + { + return best; + } + + const float3 start = (origin + direction * tEnter - walk.cellOrigin) / walk.cellSize; + int3 cell = clamp(int3(floor(start)), int3(0, 0, 0), int3(walk.dim - 1, walk.dim - 1, walk.dim - 1)); + const int3 stepSign = int3(sign(direction)); + const float3 cellT = walk.cellSize / max(abs(direction), float3(1e-12, 1e-12, 1e-12)); + float3 nextT; + [unroll] + for (int a = 0; a < 3; ++a) + { + const float boundary = walk.cellOrigin[a] + (float(cell[a]) + (stepSign[a] > 0 ? 1.0 : 0.0)) * walk.cellSize; + nextT[a] = stepSign[a] == 0 ? kFloatMax : (boundary - origin[a]) / direction[a]; + } + + float cellEnter = tEnter; + for (int steps = 0; steps < 3 * walk.dim && cellEnter < min(tExit, best.t); ++steps) + { + const float cellExit = min(min(nextT.x, min(nextT.y, nextT.z)), tExit); + const uint index = uint(cell.x + cell.y * walk.dim + cell.z * walk.dim * walk.dim); + const uint first = gTraceGrid[walk.cellsBase + 2u * index]; + const uint count = gTraceGrid[walk.cellsBase + 2u * index + 1u]; + for (uint e = 0u; e < count; ++e) + { + const uint v = gTraceGrid[walk.entriesBase + first + e]; + const float limit = min(cellExit, best.t); + if (traceBoundsRange(v, origin, direction, cellEnter, limit)) + { + traceKeepCloser(v, traceMarchVolume(v, origin, direction, cellEnter, limit, coneSlope, false, unused), best); + } + } + + const int axis = nextT.x <= nextT.y ? (nextT.x <= nextT.z ? 0 : 2) : (nextT.y <= nextT.z ? 1 : 2); + cell[axis] += stepSign[axis]; + if (cell[axis] < 0 || cell[axis] >= walk.dim) + { + break; + } + nextT[axis] += cellT[axis]; + cellEnter = cellExit; + } + return best; +} + +float3 sceneHitNormal(SceneHit hit, float3 direction) +{ + if (!hit.hit || hit.axis < 0) + { + return -normalize(direction); + } + const float4x4 worldToVoxel = gTraceVolumes[hit.volume].worldToVoxel; + const float3 voxelDirection = mul(worldToVoxel, float4(direction, 0.0)).xyz; + float3 voxelNormal = float3(0.0, 0.0, 0.0); + voxelNormal[hit.axis] = voxelDirection[hit.axis] > 0.0 ? -1.0 : 1.0; + return normalize(mul(transpose((float3x3)worldToVoxel), voxelNormal)); +} diff --git a/engine/assets/shaders/scene_trace.slang.meta b/engine/assets/shaders/scene_trace.slang.meta new file mode 100644 index 000000000..690ad692c --- /dev/null +++ b/engine/assets/shaders/scene_trace.slang.meta @@ -0,0 +1,4 @@ +uid = "Sc3neTrace0" +type = "shader" +created_at = "2026-10-21T00:00:00.0000000Z" +modified_at = "2026-10-21T00:00:00.0000000Z" diff --git a/engine/assets/shaders/ssao.slang b/engine/assets/shaders/ssao.slang index a54c837cf..8292b2c0f 100644 --- a/engine/assets/shaders/ssao.slang +++ b/engine/assets/shaders/ssao.slang @@ -5,7 +5,7 @@ struct SsaoParams { float4x4 viewProjection; float4x4 inverseViewProjection; - // xy target size zw reciprocal + // xy target size z frame index for the noise float4 screenSize; // x sample radius y strength z depth cutoff w sample count float4 tuning; @@ -49,9 +49,11 @@ float3 worldPositionFromDepth(float2 uv, float depth) return world.xyz / world.w; } -float hash(float2 p) +/// Walked per frame so TAA averages it +float rotationNoise(float2 pixel) { - return frac(sin(dot(p, float2(12.9898, 78.233))) * 43758.5453); + const float2 shifted = pixel + 5.588238 * gParams.screenSize.z; + return frac(52.9829189 * frac(dot(shifted, float2(0.06711056, 0.00583715)))); } float3 hemisphereSample(uint index, uint count, float rotation) @@ -60,7 +62,9 @@ float3 hemisphereSample(uint index, uint count, float rotation) float angle = float(index) * golden + rotation; float radius = sqrt((float(index) + 0.5) / float(count)); - float2 disk = float2(cos(angle), sin(angle)) * radius; + float s, c; + sincos(angle, s, c); + float2 disk = float2(c, s) * radius; float height = sqrt(max(1.0 - dot(disk, disk), 0.0)); return float3(disk, height); } @@ -68,15 +72,14 @@ float3 hemisphereSample(uint index, uint count, float rotation) [shader("fragment")] float4 aoMain(VSOutput input) : SV_Target0 { - float4 normalRoughness = gNormalRoughness.Sample(input.uv); - - if (dot(normalRoughness.xyz, normalRoughness.xyz) < 1e-4) + float depth = gDepth.Sample(input.uv).r; + if (isFarDepth(depth)) { return float4(1.0, kNoSurfaceDistance, 0.0, 1.0); } - float depth = gDepth.Sample(input.uv).r; - if (isFarDepth(depth)) + float4 normalRoughness = gNormalRoughness.Sample(input.uv); + if (dot(normalRoughness.xyz, normalRoughness.xyz) < 1e-4) { return float4(1.0, kNoSurfaceDistance, 0.0, 1.0); } @@ -94,7 +97,7 @@ float4 aoMain(VSOutput input) : SV_Target0 float3 T = normalize(cross(helper, N)); float3 B = cross(N, T); - float rotation = hash(input.uv * gParams.screenSize.xy) * 6.2831853; + float rotation = rotationNoise(input.position.xy) * 6.2831853; float occlusion = 0.0; float validSamples = 0.0; @@ -152,6 +155,7 @@ float4 aoMain(VSOutput input) : SV_Target0 return float4(ao, surfaceDistance, 0.0, 1.0); } +/// 4 by 4 texels kept on the surface by camera distance float upsampleOcclusion(float2 uv, float pixelDistance, float tolerance) { uint width; @@ -161,23 +165,22 @@ float upsampleOcclusion(float2 uv, float pixelDistance, float tolerance) float2 texel = uv * lowSize - 0.5; float2 base = floor(texel); - float2 fraction = texel - base; float weighted = 0.0; float weightSum = 0.0; float nearestAo = 1.0; float nearestError = 1e30; - for (int y = 0; y < 2; ++y) + for (int y = -1; y <= 2; ++y) { - for (int x = 0; x < 2; ++x) + for (int x = -1; x <= 2; ++x) { float2 offset = float2(float(x), float(y)); float4 texelValue = gOcclusion.SampleLevel((base + offset + 0.5) / lowSize, 0.0); - float2 bilinear = lerp(1.0 - fraction, fraction, offset); + float2 fromCentre = base + offset - texel; float error = abs(texelValue.g - pixelDistance); - float weight = bilinear.x * bilinear.y * exp(-error / tolerance); + float weight = exp(-dot(fromCentre, fromCentre) * 0.5 - error / tolerance); weighted += texelValue.r * weight; weightSum += weight; @@ -196,28 +199,27 @@ float upsampleOcclusion(float2 uv, float pixelDistance, float tolerance) [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; uint renderMode = uint(gParams.misc.x); bool aoOnly = (renderMode == 15); - // fwidth before any early return - float depth = gDepth.Sample(input.uv).r; - float3 worldPosition = worldPositionFromDepth(input.uv, depth); - float pixelDistance = length(worldPosition - gParams.misc.yzw); - float tolerance = clamp(2.0 * fwidth(pixelDistance), pixelDistance * 0.01, pixelDistance * 0.25); - - if (dot(normalRoughness.xyz, normalRoughness.xyz) < 1e-4) + if (isFarDepth(depth)) { - return float4(aoOnly ? float3(1.0, 1.0, 1.0) : sceneColor, 1.0); + float3 sceneColor = aoOnly ? float3(1.0, 1.0, 1.0) : gSceneColor.Sample(input.uv).rgb; + return float4(sceneColor, 1.0); } - if (isFarDepth(depth)) + float4 normalRoughness = gNormalRoughness.Sample(input.uv); + if (dot(normalRoughness.xyz, normalRoughness.xyz) < 1e-4) { - return float4(aoOnly ? float3(1.0, 1.0, 1.0) : sceneColor, 1.0); + float3 sceneColor = aoOnly ? float3(1.0, 1.0, 1.0) : gSceneColor.Sample(input.uv).rgb; + return float4(sceneColor, 1.0); } + float3 worldPosition = worldPositionFromDepth(input.uv, depth); + float pixelDistance = length(worldPosition - gParams.misc.yzw); + float tolerance = clamp(2.0 * fwidth(pixelDistance), pixelDistance * 0.01, pixelDistance * 0.25); + float ao = upsampleOcclusion(input.uv, pixelDistance, tolerance); if (aoOnly) @@ -225,6 +227,7 @@ float4 compositeMain(VSOutput input) : SV_Target0 return float4(ao, ao, ao, 1.0); } + float3 sceneColor = gSceneColor.Sample(input.uv).rgb; // Clamped since blended surfaces scaled the opaque term by 1 - alpha float3 indirect = gIndirectDiffuse.Sample(input.uv).rgb; return float4(max(sceneColor - indirect + indirect * ao, float3(0.0, 0.0, 0.0)), 1.0); diff --git a/engine/assets/shaders/ssr.slang b/engine/assets/shaders/ssr.slang index fa60dc8ca..c73cd4a39 100644 --- a/engine/assets/shaders/ssr.slang +++ b/engine/assets/shaders/ssr.slang @@ -2,6 +2,7 @@ import fullscreen; import depth_convention; import scene_normal; +import ggx_sampling; struct SsrParams { @@ -11,7 +12,7 @@ struct SsrParams float4 cameraPosition; // x intensity y max roughness z thickness w step count float4 tuning; - // x reach in meters y render mode z 1 with a valid history + // x reach in meters y render mode z 1 with a valid history w 1 when gPreviousColor holds last frame resolve float4 marching; }; @@ -44,6 +45,10 @@ Sampler2D gMotion; [[vk::binding(7, 0)]] Sampler2D gTemporal; +/// Last frame TAA resolve since this frame colour is still noisy +[[vk::binding(8, 0)]] +Sampler2D gPreviousColor; + static const float kPi = 3.14159265; /// Below this a surface is a mirror and gets one ray with no reuse @@ -110,41 +115,6 @@ float2 noise2(float2 pixel) return frac(float2(noiseAt(pixel), noiseAt(pixel.yx + float2(47.0, 17.0))) + frame * float2(0.7548776662, 0.5698402910)); } -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); -} - -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); -} - -/// 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))); -} - -/// 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); -} - struct SurfaceSample { bool valid; @@ -181,6 +151,12 @@ SurfaceSample surfaceAt(float2 uv) return surface; } +/// Tangent plane distance since linear depth jumps every pixel on a grazing floor +float planeWeight(SurfaceSample surface, float3 position) +{ + return exp(-abs(dot(surface.normal, position - surface.position)) / (0.005 * surface.linearDepth + 0.01)); +} + bool marchScreen(float3 origin, float3 direction, float2 jitter, out float2 hitUv, out float travelled) { hitUv = float2(0.0, 0.0); @@ -211,13 +187,14 @@ bool marchScreen(float3 origin, float3 direction, float2 jitter, out float2 hitU // 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)); + const float invSteps = 1.0 / float(steps); 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 x = (float(i) + jitter.x) * invSteps; const float t = x * x; const float2 uv = lerp(uv0, uv1, t); if (!insideScreen(uv)) @@ -225,39 +202,48 @@ bool marchScreen(float3 origin, float3 direction, float2 jitter, out float2 hitU return false; } - const float rayDepth = 1.0 / lerp(k0, k1, t); const float depth = gDepth.SampleLevel(uv, 0.0).r; if (!isFarDepth(depth)) { - 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) + const float sceneInvW = max(dot(gParams.inverseViewProjection[3], float4(uv * 2.0 - 1.0, depth, 1.0)), 1e-8); + const float rayInvW = lerp(k0, k1, t); + // Behind the surface when the ray 1/w is below the scene 1/w + if (rayInvW < sceneInvW) { - float lo = previousT; - float hi = t; - for (uint j = 0u; j < 5u; ++j) + const float sceneDepth = 1.0 / sceneInvW; + const float rayDepth = 1.0 / max(rayInvW, 1e-8); + 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) { - 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 + float lo = previousT; + float hi = t; + [unroll] + for (uint j = 0u; j < 5u; ++j) { - 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; + const float midSceneInvW = max(dot(gParams.inverseViewProjection[3], float4(midUv * 2.0 - 1.0, midDepth, 1.0)), 1e-8); + const float midRayInvW = lerp(k0, k1, mid); + if (!isFarDepth(midDepth) && midRayInvW < midSceneInvW) + { + hi = mid; + } + else + { + lo = mid; + } } + hitUv = lerp(uv0, uv1, hi); + travelled = hi; + return true; } - hitUv = lerp(uv0, uv1, hi); - travelled = hi; - return true; } } previousT = t; - previousRayDepth = rayDepth; + previousRayDepth = 1.0 / max(lerp(k0, k1, t), 1e-8); } return false; } @@ -290,6 +276,11 @@ float4 traceMain(VSOutput input) : SV_Target0 L = reflect(-V, H); pdf = visibleNormalPdf(max(NdotV, 1e-4), saturate(dot(N, H)), alpha); } + // Folded above the surface so a grazing floor keeps its reflection + if (dot(L, N) <= 0.0) + { + L = normalize(L - 2.0 * dot(L, N) * N); + } if (dot(L, N) <= 0.0 || dot(L, V) > 0.999) { return float4(0.0, 0.0, 0.0, 0.0); @@ -311,6 +302,20 @@ float4 traceMain(VSOutput input) : SV_Target0 return float4(hitUv, pdf, confidence); } +float3 hitColor(float2 uv) +{ + if (gParams.marching.w > 0.5) + { + const float4 motion = gMotion.SampleLevel(uv, 0.0); + const float2 previousUv = uv - (motion.w > 0.5 ? motion.xy : float2(0.0, 0.0)); + if (motion.z <= 0.5 && insideScreen(previousUv)) + { + return gPreviousColor.SampleLevel(previousUv, 0.0).rgb; + } + } + return gSceneColor.SampleLevel(uv, 0.0).rgb; +} + [shader("fragment")] float4 resolveMain(VSOutput input) : SV_Target0 { @@ -355,8 +360,7 @@ float4 resolveMain(VSOutput input) : SV_Target0 { continue; } - geometry = exp(-abs(neighbour.linearDepth - surface.linearDepth) / (0.02 * surface.linearDepth + 1e-3)) * - saturate(dot(neighbour.normal, N) * 4.0 - 3.0); + geometry = planeWeight(surface, neighbour.position) * saturate(dot(neighbour.normal, N) * 4.0 - 3.0); } if (geometry <= 1e-3) { @@ -385,7 +389,7 @@ float4 resolveMain(VSOutput input) : SV_Target0 } brdf *= geometry; - const float3 color = gSceneColor.SampleLevel(hit.xy, 0.0).rgb; + const float3 color = hitColor(hit.xy); colorSum += brdf * hit.w * color / (1.0 + luminance(color)); brdfSum += brdf * hit.w; confidenceSum += geometry * hit.w; @@ -501,9 +505,8 @@ float4 compositeMain(VSOutput input) : SV_Target0 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; + const float plane = isFarDepth(depth) ? 0.0 : planeWeight(surface, worldPositionFromDepth(uv, depth)); + const float weight = bilinear.x * bilinear.y * plane + 1e-5 * bilinear.x * bilinear.y; reflection += gTemporal.SampleLevel(uv, 0.0) * weight; weightSum += weight; } diff --git a/engine/assets/shaders/taa.slang b/engine/assets/shaders/taa.slang index ae220b6ef..1ceb33f32 100644 --- a/engine/assets/shaders/taa.slang +++ b/engine/assets/shaders/taa.slang @@ -81,7 +81,7 @@ float3 sampleHistory(float2 uv) sum += gHistory.SampleLevel(float2(uv3.x, uv12.y), 0.0).rgb * (w3.x * w12.y); sum += gHistory.SampleLevel(float2(uv12.x, uv3.y), 0.0).rgb * (w12.x * w3.y); - float weight = w12.x * w0.y + w0.x * w12.y + w12.x * w12.y + w3.x * w12.y + w12.x * w3.y; + float weight = w12.x * (w0.y + w12.y + w3.y) + (w0.x + w3.x) * w12.y; return max(sum / weight, float3(0.0, 0.0, 0.0)); } @@ -120,8 +120,10 @@ float4 fragmentMain(VSOutput input) : SV_Target0 float closestDepth = kFarDepth; float2 closestUv = uv; + [unroll] for (int y = -1; y <= 1; ++y) { + [unroll] for (int x = -1; x <= 1; ++x) { float2 sampleUv = uv + float2(x, y) * texel; @@ -133,18 +135,22 @@ float4 fragmentMain(VSOutput input) : SV_Target0 center = value; } - // Keeps a moving edge from trailing the background - float depth = gDepth.SampleLevel(sampleUv, 0.0).r; - if (isCloserDepth(depth, closestDepth)) + // Cross pattern for the closest depth saves four fetches + if (abs(x) + abs(y) <= 1) { - closestDepth = depth; - closestUv = sampleUv; + float depth = gDepth.SampleLevel(sampleUv, 0.0).r; + if (isCloserDepth(depth, closestDepth)) + { + closestDepth = depth; + closestUv = sampleUv; + } } } } float4 motion = gMotion.SampleLevel(closestUv, 0.0); + [branch] if (gParams.tuning.w > 0.5) { return float4(motionColor(motion), 1.0); @@ -162,6 +168,7 @@ float4 fragmentMain(VSOutput input) : SV_Target0 } bool offscreen = any(previousUv < float2(0.0, 0.0)) || any(previousUv > float2(1.0, 1.0)); + [branch] if (gParams.tuning.y > 0.5 || offscreen) { return float4(expand(fromYCoCg(center)), 1.0); diff --git a/engine/assets/shaders/tonemap.slang b/engine/assets/shaders/tonemap.slang index 9a79fcbcf..2f0fc56cf 100644 --- a/engine/assets/shaders/tonemap.slang +++ b/engine/assets/shaders/tonemap.slang @@ -77,10 +77,11 @@ float4 fragmentMain(VSOutput input) : SV_Target0 const float kMiddleGrey = 0.18; color = max((color - kMiddleGrey) * gParams.contrast + kMiddleGrey, float3(0.0, 0.0, 0.0)); - color = max(lerp(float3(luminance(color), luminance(color), luminance(color)), color, gParams.saturation), - float3(0.0, 0.0, 0.0)); + float lum = luminance(color); + color = max(lerp(float3(lum, lum, lum), color, gParams.saturation), float3(0.0, 0.0, 0.0)); - color = pow(max(color, float3(0.0, 0.0, 0.0)), float3(1.0 / gParams.gamma, 1.0 / gParams.gamma, 1.0 / gParams.gamma)); + float invGamma = 1.0 / max(gParams.gamma, 1e-4); + color = pow(max(color, float3(0.0, 0.0, 0.0)), float3(invGamma, invGamma, invGamma)); if (gParams.vignette > 0.0) { diff --git a/engine/assets/shaders/trace_shading.slang b/engine/assets/shaders/trace_shading.slang new file mode 100644 index 000000000..ad80ca819 --- /dev/null +++ b/engine/assets/shaders/trace_shading.slang @@ -0,0 +1,102 @@ +// Direct light at a traced hit shared by GI rays and traced specular +import voxel_dda; +import lighting; +import scene_trace; +import gi_common; + +/// Meters off the hit face shadow rays start +static const float kTraceHitOffset = 0.05; +static const uint kTraceMaxLocalLights = 64u; + +float traceLightWeight(GpuLight light, float3 position, float3 normal) +{ + float3 L; + float distance; + const float3 radiance = lightSampleRadiance(light, position, light.worldPosRange.xyz, L, distance); + return dot(radiance, float3(0.2126, 0.7152, 0.0722)) * max(dot(normal, L), 0.0); +} + +/// Weighted and uniform reservoirs mixed half and half so a weak visible light beside a blocked strong one never fireflies +float3 traceLocalLight(float3 position, float3 normal, float3 albedo, float coneSlope, inout uint random) +{ + const uint count = min(gClusterParams.lightCounts.x, kTraceMaxLocalLights); + float total = 0.0; + float lit = 0.0; + uint weighted = kGiEmpty; + float weightedWeight = 0.0; + uint even = kGiEmpty; + float evenWeight = 0.0; + for (uint li = 0u; li < count; ++li) + { + const float weight = traceLightWeight(gLights[li], position, normal); + if (weight <= 0.0) + { + continue; + } + total += weight; + lit += 1.0; + random = giHash(random + 0x9e3779b9u); + if (float(random & 0xffffu) / 65536.0 * total < weight) + { + weighted = li; + weightedWeight = weight; + } + if (float(random >> 16) / 65536.0 * lit < 1.0) + { + even = li; + evenWeight = weight; + } + } + if (lit <= 0.0) + { + return float3(0.0, 0.0, 0.0); + } + + const bool byWeight = giRandom(random) < 0.5; + const uint chosen = byWeight ? weighted : even; + const float odds = 0.5 * (byWeight ? weightedWeight : evenWeight) / total + 0.5 / lit; + const GpuLight light = gLights[chosen]; + const float3 u = float3(giRandom(random), giRandom(random), giRandom(random)); + const float3 samplePoint = sampleLightShape(light, position, u); + float3 L; + float distance; + const float3 radiance = lightSampleRadiance(light, position, samplePoint, L, distance); + const float cosine = dot(normal, L); + if (cosine <= 0.0 || all(radiance <= 0.0)) + { + return float3(0.0, 0.0, 0.0); + } + const float3 origin = position + normal * kTraceHitOffset; + const float visibility = traceVisibility(origin, samplePoint - origin, coneSlope, false).x; + return albedo / kPi * radiance * cosine * visibility / odds; +} + +/// The caster sun reads its cascade at @p pixel and a marched sun also marches voxels up to @p sunReach meters +float3 traceSunLight(float3 position, float3 normal, float3 albedo, float coneSlope, float sunReach, float2 pixel) +{ + float3 radiance = float3(0.0, 0.0, 0.0); + const uint casterIndex = uint(gCamera.shadowParams.x) - 1u; + for (uint di = 0u; di < gCamera.directionalLightCountPad.x; ++di) + { + const DirectionalLightGpu light = gCamera.directionalLights[di]; + const float3 L = normalize(-light.direction.xyz); + const float cosine = dot(normal, L); + if (cosine <= 0.0) + { + continue; + } + const bool mapped = gCamera.shadowParams.x >= 0.5 && di == casterIndex; + float shadow = 1.0; + if (mapped) + { + const float viewDepth = abs(mul(gCamera.view, float4(position, 1.0)).z); + shadow = sampleDirectionalShadow(position, normal, L, viewDepth, pixel); + } + if ((!mapped || light.direction.w > 0.0) && shadow > 0.0) + { + shadow = min(shadow, traceVisibility(position + normal * kTraceHitOffset, L * sunReach, coneSlope, false).x); + } + radiance += albedo / kPi * light.colorIntensity.rgb * light.colorIntensity.a * cosine * shadow; + } + return radiance; +} diff --git a/engine/assets/shaders/trace_shading.slang.meta b/engine/assets/shaders/trace_shading.slang.meta new file mode 100644 index 000000000..29e57fc04 --- /dev/null +++ b/engine/assets/shaders/trace_shading.slang.meta @@ -0,0 +1,4 @@ +uid = "Tr4c3Shad3d" +type = "shader" +created_at = "2026-10-25T00:00:00.0000000Z" +modified_at = "2026-10-25T00:00:00.0000000Z" diff --git a/engine/assets/shaders/volumetrics.slang b/engine/assets/shaders/volumetrics.slang new file mode 100644 index 000000000..2f46f0305 --- /dev/null +++ b/engine/assets/shaders/volumetrics.slang @@ -0,0 +1,554 @@ +// Set 0 is the engine scene and set 1 the voxel storage with the scene trace +import fullscreen; +import depth_convention; +import voxel_dda; +import lighting; +import gi_common; +import gi_sample; +import scene_trace; +import trace_shading; +import fog_media; + +// Mirrors VolumetricPass::Params +struct VolumetricParams +{ + /// Jittered since depth was rasterized with it + float4x4 inverseViewProjection; + /// x extinction per meter at the base height y base height z height falloff per meter w anisotropy + float4 medium; + /// rgb scattering albedo a ambient scale + float4 albedo; + /// x march distance y steps z 1 with a valid history w fog volumes + float4 marching; + /// x full resolution pixels per texel y local lights per step z 1 when local lights cast shadows w fog volume distance + float4 quality; +}; + +[[vk::push_constant]] +ConstantBuffer gParams; + +[[vk::binding(0, 2)]] +Sampler2D gSceneColour; + +[[vk::binding(1, 2)]] +Sampler2D gDepth; + +/// xy uv motion z reactive w 1 when written +[[vk::binding(2, 2)]] +Sampler2D gMotion; + +/// rgb in-scattered radiance a transmittance +[[vk::binding(3, 2)]] +Sampler2D gScatter; + +[[vk::binding(4, 2)]] +Sampler2D gHistory; + +[[vk::binding(5, 2)]] +Sampler2D gTemporal; + +/// Mean radiance at each fog volume centre then the camera with w the probe key +[[vk::binding(6, 2)]] +RWStructuredBuffer gFogAmbient; + +[[vk::binding(7, 2)]] +StructuredBuffer gPreviousFogAmbient; + +/// Cone floor of fog shadow rays since shafts are soft +static const float kFogShadowCone = 0.02; + +/// Rays and meters each ambient probe traces +static const uint kAmbientRays = 64u; +static const float kAmbientReach = 40.0; +/// New probe share per frame so one ray at a lamp cannot flash a volume +static const float kAmbientBlend = 0.1; +/// Mirrors VulkanRenderer::k_max_fog_volumes plus the camera +static const uint kAmbientProbes = 33u; +static const float kTemporalBlend = 0.1; + +/// Meters per scatter step until the step setting caps the count +static const float kFogStepMeters = 0.5; +static const uint kFogMinSteps = 4u; +/// Marched light shadows trace on one step in this many, rotated over 2 by 2 texels and frames +static const uint kMarchedInterleave = 4u; + +typedef FullscreenVSOutput VSOutput; + +[shader("vertex")] +VSOutput vertexMain(uint vertexId : SV_VertexID) +{ + return fullscreenTriangle(vertexId); +} + +/// Height fog stops at its own distance and fog volumes at the renderer setting +float marchLimit() +{ + const float height = gParams.medium.x > 0.0 ? gParams.marching.x : 0.0; + const float volumes = gParams.marching.w > 0.0 ? gParams.quality.w : 0.0; + return max(height, volumes); +} + +float2 fullSize() +{ + uint width; + uint height; + gDepth.GetDimensions(width, height); + return float2(float(width), float(height)); +} + +/// Where the view ray of a full resolution pixel ends and how far that is +float3 rayEnd(int2 pixel, out float distance) +{ + const float2 uv = (float2(pixel) + 0.5) / fullSize(); + const float depth = gDepth.Load(int3(pixel, 0)).r; + const float4 farClip = mul(gParams.inverseViewProjection, float4(uv * 2.0 - 1.0, isFarDepth(depth) ? kNearDepth : depth, 1.0)); + const float3 point = farClip.xyz / farClip.w; + const float3 toPoint = point - gCamera.position; + const float span = max(sqrt(dot(toPoint, toPoint)), 1e-4); + distance = isFarDepth(depth) ? marchLimit() : min(span, marchLimit()); + return gCamera.position + toPoint / span * distance; +} + +float henyeyGreenstein(float cosine, float g) +{ + const float g2 = g * g; + return (1.0 - g2) / (4.0 * kPi * pow(max(1.0 + g2 - 2.0 * g * cosine, 1e-4), 1.5)); +} + +struct Medium +{ + float extinction; + float3 scattering; + float3 emission; + /// Weighted by the scattering of each medium + float anisotropy; + /// Scattering times the ambient probe of each medium + float3 ambientScatter; +}; + +float latticeHash(float3 cell) +{ + const uint3 u = uint3(int3(cell) + 32768); + uint h = u.x * 73856093u ^ u.y * 19349663u ^ u.z * 83492791u; + h ^= h >> 13; + h *= 0x5bd1e995u; + h ^= h >> 15; + return float(h & 0xffffu) / 65535.0; +} + +float valueNoise(float3 position) +{ + const float3 cell = floor(position); + const float3 f = position - cell; + const float3 s = f * f * (3.0 - 2.0 * f); + const float x00 = lerp(latticeHash(cell), latticeHash(cell + float3(1, 0, 0)), s.x); + const float x10 = lerp(latticeHash(cell + float3(0, 1, 0)), latticeHash(cell + float3(1, 1, 0)), s.x); + const float x01 = lerp(latticeHash(cell + float3(0, 0, 1)), latticeHash(cell + float3(1, 0, 1)), s.x); + const float x11 = lerp(latticeHash(cell + float3(0, 1, 1)), latticeHash(cell + float3(1, 1, 1)), s.x); + return lerp(lerp(x00, x10, s.y), lerp(x01, x11, s.y), s.z); +} + +/// Three octaves in 0 to 1 +float fogNoise(float3 position) +{ + return 0.5714 * valueNoise(position) + 0.2857 * valueNoise(position * 2.03) + 0.1429 * valueNoise(position * 4.07); +} + +Medium mediumAt(float3 position, float along) +{ + Medium medium; + medium.extinction = along <= gParams.marching.x + ? gParams.medium.x * exp(-clamp(gParams.medium.z * (position.y - gParams.medium.y), -20.0, 20.0)) + : 0.0; + medium.scattering = medium.extinction * gParams.albedo.rgb; + medium.emission = float3(0.0, 0.0, 0.0); + float weight = medium.extinction; + float anisotropy = medium.extinction * gParams.medium.w; + const uint count = uint(gParams.marching.w); + medium.ambientScatter = medium.scattering * gParams.albedo.a * gFogAmbient[count].rgb; + for (uint v = 0u; v < count; ++v) + { + const FogVolume volume = gFogVolumes[v]; + const float3 local = mul(volume.worldToLocal, float4(position, 1.0)).xyz; + const float3 inside = volume.extentsFade.xyz - abs(local); + if (any(inside <= 0.0)) + { + continue; + } + const float edge = saturate(min(inside.x, min(inside.y, inside.z)) / max(volume.extentsFade.w, 1e-3)); + float density = volume.albedoDensity.a * edge * edge; + if (volume.emissionNoise.a > 0.0) + { + const float noise = fogNoise((position - volume.windScale.xyz * gCamera.time) / volume.windScale.w); + density *= saturate(lerp(1.0, 2.0 * noise, volume.emissionNoise.a)); + } + medium.extinction += density; + medium.scattering += density * volume.albedoDensity.rgb; + medium.emission += density * volume.emissionNoise.rgb; + weight += density; + anisotropy += density * volume.phase.x; + medium.ambientScatter += density * volume.albedoDensity.rgb * volume.phase.y * gFogAmbient[v].rgb; + } + medium.anisotropy = weight > 0.0 ? anisotropy / weight : 0.0; + return medium; +} + +/// Span across the fog volumes so a level without height fog marches only inside them +bool volumeSpan(float3 origin, float3 direction, float maxDistance, out float near, out float far) +{ + near = maxDistance; + far = 0.0; + const uint count = uint(gParams.marching.w); + for (uint v = 0u; v < count; ++v) + { + const FogVolume volume = gFogVolumes[v]; + const float3 localOrigin = mul(volume.worldToLocal, float4(origin, 1.0)).xyz; + const float3 localDirection = mul(volume.worldToLocal, float4(direction, 0.0)).xyz; + const float3 inverse = 1.0 / select(abs(localDirection) > 1e-8, localDirection, float3(1e-8, 1e-8, 1e-8)); + const float3 t0 = (-volume.extentsFade.xyz - localOrigin) * inverse; + const float3 t1 = (volume.extentsFade.xyz - localOrigin) * inverse; + const float3 low = min(t0, t1); + const float3 high = max(t0, t1); + const float enter = max(max(low.x, low.y), max(low.z, 0.0)); + const float exit = min(min(high.x, high.y), min(high.z, maxDistance)); + if (enter < exit) + { + near = min(near, enter); + far = max(far, exit); + } + } + return near < far; +} + +float3 skyRadiance(float3 direction) +{ + const float3 ambient = gCamera.ambientColorIntensity.rgb * gCamera.ambientColorIntensity.a; + return gCamera.environmentParams.x >= 0.5 ? ambient + gPrefilteredEnv.SampleLevel(direction, 0.0).rgb : ambient; +} + +/// Shaded like a GI off screen hit +float3 probeHitRadiance(SceneHit hit, float3 origin, float3 direction, inout uint random) +{ + const float3 position = origin + direction * hit.t; + const float3 normal = sceneHitNormal(hit, direction); + const PaletteSample palette = samplePalette(gTraceVolumes[hit.volume].recordIndex, hit.material); + float3 irradiance; + if (!sampleRadianceCascades(position, normal, irradiance)) + { + irradiance = gCamera.ambientColorIntensity.rgb * gCamera.ambientColorIntensity.a; + if (gCamera.environmentParams.x >= 0.5) + { + irradiance += gIrradianceMap.SampleLevel(normal, 0.0).rgb; + } + } + return palette.albedo * (palette.emissive + irradiance) + traceLocalLight(position, normal, palette.albedo, kFogShadowCone, random) + + traceSunLight(position, normal, palette.albedo, kFogShadowCone, gCamera.fogReach.z, float2(0.0, 0.0)); +} + +groupshared float3 gsAmbient[kAmbientRays]; + +/// One group per fog volume plus the camera so fog is lit by its room +[shader("compute")] +[numthreads(kAmbientRays, 1, 1)] +void ambientMain(uint3 group : SV_GroupID, uint index : SV_GroupIndex) +{ + const uint probe = group.x; + const uint count = uint(gParams.marching.w); + const float3 origin = probe < count ? gFogVolumes[probe].centre.xyz : gCamera.position; + + // Fibonacci sphere turned per frame + const uint frame = uint(gCamera.noiseParams.z); + const float turn = float(giHash(probe ^ giHash(frame)) & 0xffffu) / 65536.0 * 6.28318531; + const float height = 1.0 - (2.0 * float(index) + 1.0) / float(kAmbientRays); + const float ring = sqrt(saturate(1.0 - height * height)); + const float angle = float(index) * 2.39996323 + turn; + const float3 direction = float3(ring * cos(angle), height, ring * sin(angle)); + + uint random = giHash(index ^ giHash(probe ^ giHash(frame ^ 0x68bc21ebu))); + const SceneHit hit = traceScene(origin, direction, 0.0, kAmbientReach, kFogShadowCone); + // Tonemapped before the sum so one bright ray cannot carry the probe + float3 radiance = hit.hit ? probeHitRadiance(hit, origin, direction, random) : skyRadiance(direction); + radiance = all(isfinite(radiance)) ? max(radiance, float3(0.0, 0.0, 0.0)) : float3(0.0, 0.0, 0.0); + gsAmbient[index] = radiance / (1.0 + dot(radiance, float3(0.2126, 0.7152, 0.0722))); + GroupMemoryBarrierWithGroupSync(); + for (uint stride = kAmbientRays / 2u; stride > 0u; stride >>= 1u) + { + if (index < stride) + { + gsAmbient[index] += gsAmbient[index + stride]; + } + GroupMemoryBarrierWithGroupSync(); + } + if (index == 0u) + { + const float3 mapped = gsAmbient[0] / float(kAmbientRays); + const float3 current = mapped / max(1.0 - dot(mapped, float3(0.2126, 0.7152, 0.0722)), 1e-3); + // Keyed by centre cell since the volume order shuffles + const uint key = probe < count ? giHash(uint(int(floor(origin.x * 2.0))) ^ giHash(uint(int(floor(origin.y * 2.0))) ^ + giHash(uint(int(floor(origin.z * 2.0)))))) + : 0xffffffffu; + float3 blended = current; + if (gParams.marching.z > 0.5) + { + for (uint previous = 0u; previous < kAmbientProbes; ++previous) + { + const float4 stored = gPreviousFogAmbient[previous]; + if (asuint(stored.w) == key) + { + blended = lerp(stored.rgb, current, kAmbientBlend); + break; + } + } + } + gFogAmbient[probe] = float4(blended, asfloat(key)); + } +} + +/// One marched light per step traces a ray since none has a shadow map +struct MarchedPick +{ + float total; + float weight; + float3 light; + float3 segment; +}; + +void offerMarched(inout MarchedPick pick, float3 light, float3 segment, inout uint random) +{ + const float weight = dot(light, float3(0.2126, 0.7152, 0.0722)); + if (weight <= 0.0) + { + return; + } + pick.total += weight; + if (giRandom(random) * pick.total < weight) + { + pick.weight = weight; + pick.light = light; + pick.segment = segment; + } +} + +/// Sun and froxel lights toward the camera with marched ones only when @p traceMarched +float3 directInScatter(float3 position, float3 toCamera, float2 pixel, float g, bool traceMarched, inout uint random) +{ + const bool shadowed = gParams.quality.z > 0.5; + MarchedPick pick; + pick.total = 0.0; + pick.weight = 0.0; + pick.light = float3(0.0, 0.0, 0.0); + pick.segment = float3(0.0, 0.0, 0.0); + + float3 light = float3(0.0, 0.0, 0.0); + const uint casterIndex = uint(gCamera.shadowParams.x) - 1u; + for (uint di = 0u; di < gCamera.directionalLightCountPad.x; ++di) + { + const DirectionalLightGpu sun = gCamera.directionalLights[di]; + const float3 L = normalize(-sun.direction.xyz); + const float3 sunLight = sun.colorIntensity.rgb * sun.colorIntensity.a * henyeyGreenstein(dot(-L, toCamera), g); + if (gCamera.shadowParams.x >= 0.5 && di == casterIndex) + { + light += sunLight * volumeSunShadow(position); + } + else if (shadowed && sun.direction.w > 0.0) + { + if (traceMarched) + { + offerMarched(pick, sunLight, L * gCamera.fogReach.z, random); + } + } + else + { + light += sunLight; + } + } + + const float viewZ = mul(gCamera.view, float4(position, 1.0)).z; + const ClusterCell cluster = clusterCellAt(viewZ, pixel); + if (cluster.index < cluster.count) + { + const uint stride = gClusterParams.clusterDims.w; + const uint indexBase = cluster.index * stride; + const uint count = min(min(cluster.lightCount, stride), uint(gParams.quality.y)); + for (uint li = 0u; li < count; ++li) + { + const GpuLight local = gLights[gLightIndexList[indexBase + li]]; + const float3 samplePoint = analyticLightPoint(local, position); + float3 L; + float distance; + const float3 radiance = lightSampleRadiance(local, position, samplePoint, L, distance); + if (all(radiance <= 0.0)) + { + continue; + } + const float3 localLight = radiance * henyeyGreenstein(dot(-L, toCamera), g); + if (!shadowed) + { + light += localLight; + } + else if (lightShadowMode(local) == kShadowModeMap) + { + light += localLight * volumeLocalShadow(local, position); + } + else if (traceMarched) + { + offerMarched(pick, localLight, samplePoint - position, random); + } + } + } + + // Scaled up since only one step in kMarchedInterleave traces + if (pick.weight > 0.0) + { + const float visibility = traceVisibility(position, pick.segment, kFogShadowCone, false).x; + light += pick.light * visibility * (pick.total / pick.weight) * float(kMarchedInterleave); + } + return light; +} + +/// Energy conserving integration of in-scattering over extinction +[shader("fragment")] +float4 scatterMain(VSOutput input) : SV_Target0 +{ + const int2 pixel = int2(input.position.xy) * int(gParams.quality.x); + float distance; + const float3 end = rayEnd(pixel, distance); + const float3 direction = (end - gCamera.position) / max(distance, 1e-4); + const float3 toCamera = -direction; + const float jitter = blueNoise(input.position.xy, 180u).x; + + float start = 0.0; + float span = distance; + if (gParams.medium.x <= 0.0) + { + float far; + if (!volumeSpan(gCamera.position, direction, distance, start, far)) + { + return float4(0.0, 0.0, 0.0, 1.0); + } + span = far - start; + } + + // Short indoor rays take fewer steps + const uint maxSteps = max(uint(gParams.marching.y), 1u); + const uint steps = clamp(uint(ceil(span / kFogStepMeters)), min(kFogMinSteps, maxSteps), maxSteps); + const uint2 texel = uint2(input.position.xy); + const uint phase = (texel.x & 1u) + ((texel.y & 1u) << 1u) + uint(gCamera.noiseParams.z); + + uint random = giHash(uint(pixel.x) ^ giHash(uint(pixel.y) ^ giHash(uint(gCamera.noiseParams.z)))); + float3 scattered = float3(0.0, 0.0, 0.0); + float transmittance = 1.0; + for (uint i = 0u; i < steps; ++i) + { + // Squared so steps crowd near the camera where a shaft spans the most pixels + const float near = start + span * (float(i) / float(steps)) * (float(i) / float(steps)); + const float far = start + span * (float(i + 1u) / float(steps)) * (float(i + 1u) / float(steps)); + const float along = lerp(near, far, jitter); + const float3 position = gCamera.position + direction * along; + const Medium medium = mediumAt(position, along); + if (medium.extinction <= 1e-6) + { + continue; + } + const float3 inScatter = + medium.scattering * + directInScatter(position, toCamera, float2(pixel), medium.anisotropy, (i + phase) % kMarchedInterleave == 0u, random) + + medium.ambientScatter + + medium.emission; + const float stepExtinction = exp(-medium.extinction * (far - near)); + scattered += transmittance * inScatter * (1.0 - stepExtinction) / medium.extinction; + transmittance *= stepExtinction; + if (transmittance < 1e-3) + { + break; + } + } + return float4(scattered, transmittance); +} + +[shader("fragment")] +float4 temporalMain(VSOutput input) : SV_Target0 +{ + const int2 texel = int2(input.position.xy); + const float4 current = gScatter.Load(int3(texel, 0)); + if (gParams.marching.z < 0.5) + { + return current; + } + + uint width; + uint height; + gScatter.GetDimensions(width, height); + const int2 last = int2(int(width), int(height)) - 1; + 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 = gScatter.Load(int3(clamp(texel + int2(x, y), int2(0, 0), last), 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))); + + // The end point through the previous camera since fog belongs to no surface + float distance; + const float3 end = rayEnd(texel * int(gParams.quality.x), distance); + const float4 previousClip = mul(gCamera.previousViewProjection, float4(end, 1.0)); + const float2 previousUv = previousClip.xy / max(previousClip.w, 1e-6) * 0.5 + 0.5; + if (previousClip.w <= 0.0 || any(previousUv < float2(0.0, 0.0)) || any(previousUv > float2(1.0, 1.0)) || + gMotion.Load(int3(texel * int(gParams.quality.x), 0)).z > 0.5) + { + return current; + } + const float4 history = clamp(gHistory.SampleLevel(previousUv, 0.0), mean - 1.5 * sigma, mean + 1.5 * sigma); + return lerp(history, current, kTemporalBlend); +} + +/// Bilinear among texels whose rays ended about as far away +[shader("fragment")] +float4 compositeMain(VSOutput input) : SV_Target0 +{ + const int2 pixel = int2(input.position.xy); + const float3 scene = gSceneColour.Load(int3(pixel, 0)).rgb; + float distance; + rayEnd(pixel, distance); + + uint width; + uint height; + gTemporal.GetDimensions(width, height); + const int2 last = int2(int(width), int(height)) - 1; + const float2 low = (float2(pixel) + 0.5) / gParams.quality.x - 0.5; + const int2 base = int2(floor(low)); + const float2 fraction = low - float2(base); + + float4 fog = float4(0.0, 0.0, 0.0, 0.0); + float weightSum = 0.0; + float4 nearest = float4(0.0, 0.0, 0.0, 1.0); + float nearestError = 1e30; + for (int y = 0; y < 2; ++y) + { + for (int x = 0; x < 2; ++x) + { + const int2 texel = clamp(base + int2(x, y), int2(0, 0), last); + const float4 value = gTemporal.Load(int3(texel, 0)); + float texelDistance; + rayEnd(texel * int(gParams.quality.x), texelDistance); + const float error = abs(texelDistance - distance); + const float2 bilinear = lerp(1.0 - fraction, fraction, float2(float(x), float(y))); + const float weight = bilinear.x * bilinear.y * exp(-error / (0.05 * distance + 0.05)); + fog += value * weight; + weightSum += weight; + if (error < nearestError) + { + nearestError = error; + nearest = value; + } + } + } + fog = weightSum > 1e-4 ? fog / weightSum : nearest; + return float4(scene * fog.a + fog.rgb, 1.0); +} diff --git a/engine/assets/shaders/volumetrics.slang.meta b/engine/assets/shaders/volumetrics.slang.meta new file mode 100644 index 000000000..f4bcfdd6f --- /dev/null +++ b/engine/assets/shaders/volumetrics.slang.meta @@ -0,0 +1,4 @@ +uid = "V0lum3tr1c5" +type = "shader" +created_at = "2026-10-26T00:00:00.0000000Z" +modified_at = "2026-10-26T00:00:00.0000000Z" diff --git a/engine/assets/shaders/voxel_dda.slang b/engine/assets/shaders/voxel_dda.slang index 5b1ae92cb..758f4df8e 100644 --- a/engine/assets/shaders/voxel_dda.slang +++ b/engine/assets/shaders/voxel_dda.slang @@ -192,6 +192,8 @@ void cellEntryBox(uint word, uint3 cell, int3 dims, out int3 boxMin, out int3 bo static const uint kTagEmpty = 0; static const uint kTagUniform = 1; +/// No grid entry handed to marchCore so it reads one per step +static const uint kUnknownBrickEntry = 0xFFFFFFFFu; static const uint kTagShared = 2; static const uint kTagOwned = 3; @@ -535,9 +537,10 @@ struct ConeFloor : IHitFloor /// 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 +/// @p knownEntry is the grid entry of a single brick box so its march skips the coarse and grid reads RayHit marchCore(uint volume, float3 origin, float3 direction, float tMin, float tMax, int3 boxMin, int3 boxMax, bool throughGlass, Floor hitFloor, - inout Glass glass) + inout Glass glass, uint knownEntry = kUnknownBrickEntry) { const VolumeRecord record = gVolumeRecords[volume]; const bool skipGlass = throughGlass && record.glassOffset != kNoGlass; @@ -564,6 +567,10 @@ RayHit marchCore(uint volume, float3 orig const int3 extent = boxMax - boxMin; const uint stepLimit = min(kMaxMarchSteps, uint(extent.x + extent.y + extent.z) + 8u); + // A ray steps several voxels through one 4x4x4 sub-brick so its two words are read once + uint cachedSub = kUnknownBrickEntry; + uint subLow = 0u; + uint subHigh = 0u; for (uint step = 0; step < stepLimit; ++step) { result.steps = step + 1; @@ -579,7 +586,7 @@ RayHit marchCore(uint volume, float3 orig } const int3 coarse = voxel >> 5; - if (!coarseSolid(record, coarse)) + if (knownEntry == kUnknownBrickEntry && !coarseSolid(record, coarse)) { ++result.coarseSteps; marchLeave(origin, march, coarse << 5, kCoarseVoxels); @@ -596,7 +603,7 @@ RayHit marchCore(uint volume, float3 orig } const int3 brick = voxel >> 3; - const uint entry = gridEntry(record, uint3(brick)); + const uint entry = knownEntry != kUnknownBrickEntry ? knownEntry : gridEntry(record, uint3(brick)); const uint tag = entry & 3u; if (tag == kTagEmpty) @@ -649,8 +656,12 @@ RayHit marchCore(uint volume, float3 orig 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 (subBase != cachedSub) + { + subLow = gBrickOccupancy[subBase]; + subHigh = gBrickOccupancy[subBase + 1u]; + cachedSub = subBase; + } if ((subLow | subHigh) == 0u) { ++result.subSteps; diff --git a/engine/assets/shaders/voxel_denoise.slang b/engine/assets/shaders/voxel_denoise.slang index 0a603afc5..50f224b75 100644 --- a/engine/assets/shaders/voxel_denoise.slang +++ b/engine/assets/shaders/voxel_denoise.slang @@ -115,6 +115,74 @@ float3 noisyAt(int2 pixel, bool specular) return specular ? gNoisySpecular.Load(int3(pixel, 0)).rgb : gNoisyDiffuse.Load(int3(pixel, 0)).rgb; } +/// Bilinear at the expected depth so jitter across an edge keeps history and else the closest match one pixel around +bool reprojectHistory(float2 previousUv, float expectedDepth, out float4 diffuseHistory, out float4 specularHistory) +{ + diffuseHistory = float4(0.0, 0.0, 0.0, 0.0); + specularHistory = float4(0.0, 0.0, 0.0, 0.0); + const int2 size = int2(gPush.viewport.xy); + const float tolerance = kDisocclusionDepth * expectedDepth; + + const float2 texel = previousUv * gPush.viewport.xy - 0.5; + const int2 base = int2(floor(texel)); + const float2 f = texel - float2(base); + float4 diffuseSum = float4(0.0, 0.0, 0.0, 0.0); + float4 specularSum = float4(0.0, 0.0, 0.0, 0.0); + float weightSum = 0.0; + [unroll] + for (int i = 0; i < 4; ++i) + { + const int2 offset = int2(i & 1, i >> 1); + const int2 tap = base + offset; + if (any(tap < int2(0, 0)) || any(tap >= size)) + { + continue; + } + const float4 diffuse = gDiffuseHistory.Load(int3(tap, 0)); + if (abs(diffuse.a - expectedDepth) > tolerance) + { + continue; + } + const float weight = (offset.x == 1 ? f.x : 1.0 - f.x) * (offset.y == 1 ? f.y : 1.0 - f.y); + diffuseSum += diffuse * weight; + specularSum += gSpecularHistory.Load(int3(tap, 0)) * weight; + weightSum += weight; + } + if (weightSum > 1e-3) + { + diffuseHistory = diffuseSum / weightSum; + specularHistory = specularSum / weightSum; + return true; + } + + const int2 nearest = int2(floor(previousUv * gPush.viewport.xy)); + float bestError = tolerance; + bool found = false; + [unroll] + for (int y = -1; y <= 1; ++y) + { + [unroll] + for (int x = -1; x <= 1; ++x) + { + const int2 tap = nearest + int2(x, y); + if (any(tap < int2(0, 0)) || any(tap >= size)) + { + continue; + } + const float4 diffuse = gDiffuseHistory.Load(int3(tap, 0)); + const float error = abs(diffuse.a - expectedDepth); + if (error <= bestError) + { + bestError = error; + diffuseHistory = diffuse; + specularHistory = gSpecularHistory.Load(int3(tap, 0)); + found = true; + } + } + } + return found; +} + [shader("fragment")] float4 temporalMain(VSOutput input) : SV_Target0 { @@ -151,31 +219,46 @@ float4 temporalMain(VSOutput input) : SV_Target0 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); + const float4 reset = float4(current, specular ? 1.0 : linearDepth); // 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; + float2 previousUv; + float expectedDepth; + if (motion.w > 0.5) + { + previousUv = uv - motion.xy; + expectedDepth = linearDepth; + } + else + { + const float3 world = worldPositionAt(uv, depth); + const float4 previousClip = mul(gPush.previousViewProjection, float4(world, 1.0)); + if (previousClip.w <= 0.0) + { + return reset; + } + previousUv = previousClip.xy / previousClip.w * 0.5 + 0.5; + expectedDepth = previousClip.w; + } 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) + if (gPush.params.y < 0.5 || !inside || 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) + float4 diffuseHistory; + float4 specularHistory; + if (!reprojectHistory(previousUv, expectedDepth, diffuseHistory, specularHistory)) { 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; @@ -217,7 +300,9 @@ float4 compositeMain(VSOutput input) : SV_Target0 float3 diffuse = gDiffuseCurrent.Load(int3(pixel, 0)).rgb; float weightSum = 1.0; - const float2 turn = float2(cos(rotation), sin(rotation)) * radius; + float s, c; + sincos(rotation, s, c); + const float2 turn = float2(c, s) * radius; [unroll] for (uint i = 0u; i < kSpatialTaps; ++i) { diff --git a/engine/assets/shaders/voxel_lighting.slang b/engine/assets/shaders/voxel_lighting.slang index 552a32f26..11abeb2c4 100644 --- a/engine/assets/shaders/voxel_lighting.slang +++ b/engine/assets/shaders/voxel_lighting.slang @@ -1,7 +1,9 @@ import fullscreen; import voxel_dda; import voxel_gbuffer; +import gi_sample; import lighting; +import scene_trace; import scene_normal; // Mirrors VoxelPass::LightingPushConstants @@ -13,10 +15,11 @@ 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 + // x instances with bounds y light candidates z nonzero when fragmentMarched shades 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 + // bit 3 ray query ambient bit 4 traced specular bits 8 to 15 its max roughness in 255ths 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; }; @@ -34,11 +37,6 @@ StructuredBuffer gInstanceBounds; [[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; @@ -77,6 +75,13 @@ Sampler2D gCandidateReservoirs; [[vk::binding(7, 2)]] Sampler2D gSceneNormal; +/// Marched terms fragmentMarchedComposite adds when the denoiser is off +[[vk::binding(8, 2)]] +Sampler2D gNoisyDiffuse; + +[[vk::binding(9, 2)]] +Sampler2D gNoisySpecular; + typedef FullscreenVSOutput VSOutput; struct FSOutput @@ -85,11 +90,8 @@ 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 + /// Indirect over albedo that fragmentAmbient occludes when ambient rays are on 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 @@ -148,12 +150,15 @@ float3 roundedNormal(float2 pixel, float3 center, float3 face) const int2 size = int2(gPush.viewport.xy); const uint taps = clamp(uint(radiusPixels), kMinRoundingTaps, kMaxRoundingTaps); + const float invRadiusSq = 1.0 / (radius * radius); float3 sum = face; [loop] for (uint i = 0u; i < taps; ++i) { const float angle = float(i) * kGoldenAngle + rotation; - const float2 offset = sqrt((float(i) + 0.5) / float(taps)) * radiusPixels * float2(cos(angle), sin(angle)); + float s, c; + sincos(angle, s, c); + const float2 offset = sqrt((float(i) + 0.5) / float(taps)) * radiusPixels * float2(c, s); const int2 tap = int2(floor(pixel + offset)); float3 tapNormal; if (any(tap < int2(0, 0)) || any(tap >= size) || !isLitVoxelAt(tap, tapNormal)) @@ -163,7 +168,7 @@ 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)); + const float falloff = saturate(1.0 - dot(toTap, toTap) * invRadiusSq); sum += tapNormal * (falloff * falloff); } return normalize(sum); @@ -178,45 +183,15 @@ 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; + return traceVisibility(origin, target - origin, coneSlope, (gPush.reuse.x & kSceneGlass) != 0u); } -/// Mesh shadowing of a marched light from its shadow map or traced ray +/// Mesh shadow of a marched light from its map since traced shadows are off with voxels 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; @@ -240,54 +215,75 @@ struct VoxelGlassFilter : IShadowFilter } 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; + return traceGlassFilter(origin, segment, slope); + } + + bool shadesMapLights() + { + return true; + } + + float traceMeshShadow(float3 position, float3 normal, float3 L, float distance) + { + return 1.0; + } +} + +/// fragmentMain shades shadow mapped lights without glass and traces nothing +struct VoxelRayFreeFilter : IShadowFilter +{ + bool mapLights; + + float3 filter(float3 L, float distance) + { + return float3(1.0, 1.0, 1.0); + } + + bool shadesMapLights() + { + return mapLights; + } + + float traceMeshShadow(float3 position, float3 normal, float3 L, float distance) + { + return 1.0; } } -/// Mirrors renderer::VoxelPass ambient rays per pixel -static const uint kAmbientRays = 2u; +/// One per pixel since the blue noise turns every frame and the denoiser accumulates the rest +static const uint kAmbientRays = 1u; /// Teardown style AO from the share of the reach cosine rays travel -float ambientOcclusion(Surface surface, float3 origin, float reach) +float ambientOcclusion(float3 normal, float2 pixel, float3 origin, float reach) { float3 tangent; float3 bitangent; - perpendicularBasis(surface.normal, tangent, bitangent); + perpendicularBasis(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; + const float2 disc = concentricDisc(blueNoise(pixel, 120u + r).xy); + segments[r] = (tangent * disc.x + bitangent * disc.y + normal * sqrt(saturate(1.0 - dot(disc, disc)))) * reach; travelled[r] = 1.0; } +#if TOAST_RAY_QUERY + // Exact first hits through the TLAS instead of eight samples per volume + if ((gPush.reuse.x & kAmbientRayQuery) != 0u && traceRayQueryEnabled()) + { + float reached = 0.0; + [unroll] + for (uint r = 0u; r < kAmbientRays; ++r) + { + const SceneHit hit = traceScene(origin, segments[r], 0.0, 1.0, 0.0); + reached += hit.hit ? hit.t : 1.0; + } + return reached / float(kAmbientRays); + } +#endif + const float cellSize = asfloat(gAmbientGrid[0]); if (!(cellSize > 0.0)) { @@ -299,6 +295,7 @@ float ambientOcclusion(Surface surface, float3 origin, float reach) const uint cellCount = gAmbientGrid[kAmbientGridHeader + 2u * bucket + 1u]; const uint globalOffset = gAmbientGrid[1]; const uint total = cellCount + gAmbientGrid[2]; + const float invReach2 = 1.0 / (reach * reach); for (uint j = 0u; j < total; ++j) { const uint i = gAmbientGrid[indexBase + (j < cellCount ? cellOffset + j : globalOffset + j - cellCount)]; @@ -314,7 +311,7 @@ float ambientOcclusion(Surface surface, float3 origin, float reach) [unroll] for (uint r = 0u; r < kAmbientRays; ++r) { - const float along = clamp(dot(offset, segments[r]) / (reach * reach), 0.0, travelled[r]); + const float along = clamp(dot(offset, segments[r]) * invReach2, 0.0, travelled[r]); const float3 closest = segments[r] * along - offset; rays |= dot(closest, closest) <= bounds.w * bounds.w ? 1u << r : 0u; } @@ -333,9 +330,9 @@ float ambientOcclusion(Surface surface, float3 origin, float reach) { continue; } - const float3 voxelSegment = mul(worldToVoxel, float4(segments[r], 0.0)).xyz; + const float3 voxelSegment = mul((float3x3)worldToVoxel, segments[r]); // 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); + const RayHit hit = marchSuperSparse(record, voxelOrigin, voxelSegment, 0.0, travelled[r], length(voxelSegment) * 0.025); if (hit.hit) { travelled[r] = min(travelled[r], hit.t); @@ -369,6 +366,22 @@ float3 sampleCone(float3 axis, float radius, float2 u) return normalize(axis * cosTheta + (tangent * cos(phi) + bitangent * sin(phi)) * sinTheta); } +/// Representative point since a glossy highlight from a random point sparkles +float3 analyticSpecular(GpuLight light, Surface surface) +{ + float3 L; + float distance; + const float3 radiance = lightSampleRadiance(light, surface.worldPosition, analyticLightPoint(light, surface.worldPosition), L, distance); + if (all(radiance <= 0.0) || dot(surface.normal, L) <= 0.0) + { + return float3(0.0, 0.0, 0.0); + } + float3 specular; + shadeAnalyticLight(light, surface.worldPosition, surface.normal, surface.viewDirection, L, radiance, surface.albedo, surface.metallic, + surface.roughness, surface.reflectivity, specular); + return specular; +} + /// Marched suns as area lights with the cascade kept for mesh shadows LightTerms shadeMarchedSuns(Surface surface, float3 shadowOrigin) { @@ -394,11 +407,7 @@ LightTerms shadeMarchedSuns(Surface surface, float3 shadowOrigin) } float meshShadow = 1.0; - if (tracedShadowsEnabled()) - { - meshShadow = traceShadowRay(surface.worldPosition, surface.geometricNormal, L, 1.0e5); - } - else if (gCamera.shadowParams.x >= 0.5 && di == casterIndex) + if (gCamera.shadowParams.x >= 0.5 && di == casterIndex) { meshShadow = sampleDirectionalShadow(surface.worldPosition, surface.geometricNormal, center, -surface.viewSpaceZ, surface.pixel); } @@ -415,8 +424,10 @@ LightTerms shadeMarchedSuns(Surface surface, float3 shadowOrigin) 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); + const LightTerms centred = shadeLightTerms(surface.normal, surface.viewDirection, center, radiance, surface.albedo, surface.metallic, + surface.roughness, surface.reflectivity); terms.diffuse += sample.diffuse * visibility; - terms.specular += sample.specular * visibility; + terms.specular += centred.specular * visibility; } return terms; } @@ -455,6 +466,11 @@ static const float kReuseRadius = 8.0; static const uint kReuseHistory = 1u; static const uint kReuseSpatial = 2u; static const uint kSceneGlass = 4u; +static const uint kAmbientRayQuery = 8u; +static const uint kTracedSpecular = 16u; + +/// Scene normal roughness of a pixel whose environment specular voxel_specular.slang replaces and SSR skips +static const float kTracedMark = 2.0; /// gLights index of a sampled light by stable id in one froxel list uint findSampledLight(uint lightId, uint indexBase, uint lightCount) @@ -520,7 +536,7 @@ LightTerms shadeSampledLight(Surface surface, SampledLight sample, float3 scale) 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; + terms.specular = analyticSpecular(gLights[sample.lightIndex], surface) * scale; return terms; } @@ -569,7 +585,7 @@ LightTerms shadeMarchedLights(Surface surface, float3 shadowOrigin, float4 motio 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; + terms.specular += analyticSpecular(light, surface) * visibility; } // Uniform light and point candidates weighted by luminance times count then ReSTIR temporal reuse @@ -747,8 +763,10 @@ SpatialOutput fragmentSpatial(VSOutput input) if (t > 0u) { const float tapAngle = angle + float(t) * 2.39996323; + float s, c; + sincos(tapAngle, s, c); const float distance = kSpatialRadius * sqrt((float(t) - 0.5) / float(kSpatialReservoirs)); - tap = int2(floor(input.position.xy + distance * float2(cos(tapAngle), sin(tapAngle)))); + tap = int2(floor(input.position.xy + distance * float2(c, s))); float3 tapFace; if (any(tap < int2(0, 0)) || any(tap >= size) || !isLitVoxelAt(tap, tapFace)) { @@ -756,8 +774,12 @@ SpatialOutput fragmentSpatial(VSOutput input) } // A neighbour on another surface chose for a point this pixel is not const float3 tapNormal = gSceneNormal.Load(int3(tap, 0)).xyz; + if (dot(tapNormal, tapNormal) < 1e-4 || dot(normalize(tapNormal), normal) < 0.9) + { + continue; + } 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) + if (plane > 0.02 * length(toCamera) + 0.05) { continue; } @@ -827,8 +849,6 @@ FSOutput fragmentMain(VSOutput input) 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); @@ -841,8 +861,11 @@ FSOutput fragmentMain(VSOutput input) const float4 material = gMaterial.Load(texel); const float roughness = clamp(material.b, 0.045, 1.0); + const bool traced = (gPush.reuse.x & kTracedSpecular) != 0u && material.g > 1e-3 && + roughness <= float((gPush.reuse.x >> 8) & 0xffu) / 255.0; // SSR only reads roughness so zero reflectivity must look fully rough - output.normalRoughness = encodeSceneNormal(normal, lerp(1.0, roughness, material.g), specularF0(albedo.rgb, material.r)); + output.normalRoughness = + encodeSceneNormal(normal, traced ? kTracedMark : 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; @@ -862,11 +885,10 @@ FSOutput fragmentMain(VSOutput input) surface.viewSpaceZ = mul(gCamera.view, float4(position, 1.0)).z; surface.pixel = input.position.xy; - 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); + // Glass scenes leave shadow mapped lights to fragmentMarched which tints them + VoxelRayFreeFilter rayFree; + rayFree.mapLights = (gPush.reuse.x & kSceneGlass) == 0u || gCamera.renderModePad.x == 19; + const ShadingResult lit = shadeSurfaceLighting(surface, rayFree); output.indirectDiffuse = float4(lit.indirectDiffuse, 1.0); if (lit.isDebugView) { @@ -875,32 +897,136 @@ FSOutput fragmentMain(VSOutput input) } output.color = float4(lit.color + albedo.rgb * decodeEmissive(material.a), 1.0); + if (traced) + { + output.color.rgb -= lit.ambientSpecular; + } + + // Traced AO replaces SSAO so ambient diffuse moves into the filtered term + if (gPush.lighting.z != 0u && gPush.reuse.w != 0u) + { + output.color.rgb -= lit.indirectDiffuse; + output.noisyDiffuse = float4(select(albedo.rgb > 1e-3, lit.indirectDiffuse / max(albedo.rgb, 1e-3), float3(0.0, 0.0, 0.0)), 0.0); + output.indirectDiffuse = float4(0.0, 0.0, 0.0, 1.0); + } + return output; +} + +/// Mirrors the voxel marched scope where alpha 0 makes the premultiplied blend add +struct MarchedOutput +{ + /// Glass tinted shadow mapped lights added over fragmentMain + float4 color : SV_Target0; + float4 noisyDiffuse : SV_Target1; + float4 noisySpecular : SV_Target2; + /// packReservoir for next frame + uint4 reservoir : SV_Target3; +}; + +/// Every voxel lighting ray so fragmentMain stays light on registers +[shader("fragment")] +MarchedOutput fragmentMarched(VSOutput input) +{ + MarchedOutput output; + output.color = 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); + + const int2 pixel = int2(input.position.xy); + const int3 texel = int3(pixel, 0); + const float4 albedo = gAlbedo.Load(texel); + if (!isLitVoxel(albedo.a)) + { + discard; + } + + 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; + + // Debug views come from fragmentMain alone + if (gCamera.renderModePad.x == 19 || isSurfaceDebugMode(gCamera.renderModePad.x)) + { + return output; + } + + const float3 shadowOrigin = position + faceNormal * (kShadowOriginOffset * kVoxelSize); + if ((gPush.reuse.x & kSceneGlass) != 0u) + { + VoxelGlassFilter glassFilter; + glassFilter.origin = shadowOrigin; + glassFilter.sunReach = float(gPush.reuse.x >> 16); + glassFilter.sunSlope = 0.005; + float3 direct = float3(0.0, 0.0, 0.0); + float3 specular = float3(0.0, 0.0, 0.0); + float debugMinShadow = 1.0; + evaluateDirectLighting(surface, clusterCellAt(surface.viewSpaceZ, surface.pixel), glassFilter, direct, specular, debugMinShadow); + output.color = float4(direct, 0.0); + } + if (gPush.lighting.z != 0u) { - const float3 shadowOrigin = position + faceNormal * (kShadowOriginOffset * kVoxelSize); Reservoir reservoir; - LightTerms marched = shadeMarchedLights(surface, shadowOrigin, gMotion.Load(texel), reservoir); + const LightTerms marched = shadeMarchedLights(surface, shadowOrigin, gMotion.Load(texel), reservoir); output.reservoir = packReservoir(reservoir); + output.noisyDiffuse = float4(marched.diffuse, 0.0); + output.noisySpecular = float4(marched.specular, 0.0); + } + return output; +} - // 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); - } +/// Ambient rays alone so fragmentMarched keeps fewer live registers +[shader("fragment")] +float4 fragmentAmbient(VSOutput input) : SV_Target0 +{ + const int2 pixel = int2(input.position.xy); + const int3 texel = int3(pixel, 0); + if (!isLitVoxel(gAlbedo.Load(texel).a) || gCamera.renderModePad.x == 19 || isSurfaceDebugMode(gCamera.renderModePad.x)) + { + discard; + } - 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); - } + 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; + + // Under the cascades the rays only add the contact detail their probe spacing blurs + const float reach = float(gPush.reuse.w) * 0.01; + const float contactReach = radianceCascadesCover(position, normal) ? radianceCascadesContactReach(position) : 0.0; + const float3 shadowOrigin = position + faceNormal * (kShadowOriginOffset * kVoxelSize); + const float occlusion = ambientOcclusion(normal, input.position.xy, shadowOrigin, contactReach > 0.0 ? min(reach, contactReach) : reach); + // Premultiplied so the indirect fragmentMain left in noisy diffuse scales by occlusion + return float4(0.0, 0.0, 0.0, 1.0 - occlusion); +} + +/// Marched terms straight into colour when the denoiser is off +[shader("fragment")] +float4 fragmentMarchedComposite(VSOutput input) : SV_Target0 +{ + const int3 texel = int3(int2(input.position.xy), 0); + const float4 albedo = gAlbedo.Load(texel); + if (!isLitVoxel(albedo.a)) + { + discard; } - return output; + return float4(albedo.rgb * gNoisyDiffuse.Load(texel).rgb + gNoisySpecular.Load(texel).rgb, 0.0); } diff --git a/engine/assets/shaders/voxel_specular.slang b/engine/assets/shaders/voxel_specular.slang new file mode 100644 index 000000000..4361f3a3d --- /dev/null +++ b/engine/assets/shaders/voxel_specular.slang @@ -0,0 +1,529 @@ +// Set 0 is the engine scene and set 1 the voxel storage with the scene trace +import fullscreen; +import depth_convention; +import voxel_dda; +import voxel_gbuffer; +import lighting; +import gi_common; +import gi_sample; +import scene_trace; +import trace_shading; +import ggx_sampling; + +// Mirrors TracedSpecularPass::Params +struct SpecularParams +{ + /// Jittered since depth was rasterized with it + float4x4 inverseViewProjection; + /// x reach in meters y max roughness z 1 with a valid history w sun reach in meters + float4 tracing; + /// x 1 when gPreviousColour holds last frame resolve + float4 resolve; +}; + +[[vk::push_constant]] +ConstantBuffer gParams; + +[[vk::binding(0, 2)]] +Sampler2D gSceneColour; + +[[vk::binding(1, 2)]] +Sampler2D gDepth; + +[[vk::binding(2, 2)]] +Sampler2D gAlbedo; + +[[vk::binding(3, 2)]] +Sampler2D gMaterial; + +[[vk::binding(4, 2)]] +Sampler2D gFaceNormal; + +/// Rounded normal with w kTracedMark where voxel lighting left the specular to this pass +[[vk::binding(5, 2)]] +Sampler2D gSceneNormal; + +/// rgb radiance a meters to the hit or kMissDistance and kNoTrace for none +[[vk::binding(6, 2)]] +Sampler2D gTrace; + +[[vk::binding(7, 2)]] +Sampler2D gHistory; + +/// xy uv motion z reactive w 1 when written +[[vk::binding(8, 2)]] +Sampler2D gMotion; + +[[vk::binding(9, 2)]] +Sampler2D gTemporal; + +/// Last frame TAA resolve since this frame colour is still noisy +[[vk::binding(10, 2)]] +Sampler2D gPreviousColour; + +/// rgb radiance a packHistory and kNoTrace for none +[[vk::binding(11, 2)]] +Sampler2D gResolved; + +/// Mirrors voxel_lighting.slang +static const float kTracedMark = 2.0; +static const float kVoxelSize = 0.1; +/// Ray origin offset off its face in voxels +static const float kOriginOffset = 0.6; + +static const float kNoTrace = -1.0; +/// Below this a surface is a mirror and traces the reflection itself +static const float kMirrorRoughness = 0.05; +/// Trims the noisy GGX tail +static const float kLobeTrim = 0.75; +/// Meters off the plane of the hit face the visible surface may sit and still count as the same face +static const float kScreenPlane = 0.025; +/// History roughness further off than this belongs to another material +static const float kRoughnessChange = 0.05; +/// Frames of history a texel counts up to so 1 / count stops at the floor blend +static const float kMaxHistory = 15.0; +/// Trace distance of a ray that left the scene since its radiance came from a direction not a point +static const float kMissDistance = 60000.0; + +/// Neighbour rays a texel reuses on a rotated golden angle spiral besides its own +static const uint kResolveTaps = 12u; +/// Spiral radius in half resolution texels from mirrors to the roughness cut +static const float kResolveRadiusMirror = 2.0; +static const float kResolveRadiusRough = 5.0; +/// Lobe over pdf a single ray may reach so one unlikely neighbour cannot take the texel +static const float kMaxRayWeight = 8.0; + +/// Frames of history in the integer part and roughness in the fraction +float packHistory(float frames, float roughness) +{ + return min(frames, kMaxHistory) + 0.9 * roughness; +} + +float historyFrames(float packed) +{ + return floor(packed); +} + +float historyRoughness(float packed) +{ + return frac(packed) / 0.9; +} + +typedef FullscreenVSOutput VSOutput; + +[shader("vertex")] +VSOutput vertexMain(uint vertexId : SV_VertexID) +{ + return fullscreenTriangle(vertexId); +} + +float3 worldAt(int2 pixel, float depth) +{ + uint width; + uint height; + gDepth.GetDimensions(width, height); + const float2 uv = (float2(pixel) + 0.5) / float2(float(width), float(height)); + const float4 world = mul(gParams.inverseViewProjection, float4(uv * 2.0 - 1.0, depth, 1.0)); + return world.xyz / world.w; +} + +struct TracedPixel +{ + bool traced; + float3 position; + float3 normal; + float3 face; + float3 albedo; + float metallic; + float reflectivity; + float roughness; +}; + +/// Not traced once glass or a mesh drawn after voxel lighting rewrote the scene normal +TracedPixel tracedPixelAt(int2 pixel) +{ + TracedPixel surface; + surface.traced = false; + surface.position = float3(0.0, 0.0, 0.0); + surface.normal = float3(0.0, 0.0, 1.0); + surface.face = float3(0.0, 0.0, 1.0); + surface.albedo = float3(0.0, 0.0, 0.0); + surface.metallic = 0.0; + surface.reflectivity = 0.0; + surface.roughness = 1.0; + + const int3 texel = int3(pixel, 0); + const float4 encoded = gSceneNormal.Load(texel); + const float depth = gDepth.Load(texel).r; + if (encoded.w < 0.5 * (1.0 + kTracedMark) || isFarDepth(depth)) + { + return surface; + } + const float4 albedo = gAlbedo.Load(texel); + const float4 material = gMaterial.Load(texel); + surface.traced = true; + surface.position = worldAt(pixel, depth); + surface.face = decodeFaceNormal(gFaceNormal.Load(texel)); + surface.normal = dot(encoded.xyz, encoded.xyz) > 1e-4 ? normalize(encoded.xyz) : surface.face; + surface.albedo = albedo.rgb; + surface.metallic = material.r; + surface.reflectivity = saturate(material.g); + surface.roughness = clamp(material.b, 0.045, 1.0); + return surface; +} + +/// False unless the pixel the hit projects to shows the same face +bool screenRadiance(float3 position, float3 normal, out float3 radiance) +{ + radiance = float3(0.0, 0.0, 0.0); + const float3 toCamera = gCamera.position - position; + if (dot(normal, toCamera) <= 0.0) + { + return false; + } + const float4 clip = mul(gCamera.jitteredViewProjection, float4(position, 1.0)); + if (clip.w <= 0.0) + { + return false; + } + const float2 ndc = clip.xy / clip.w; + if (any(abs(ndc) >= 1.0)) + { + return false; + } + uint width; + uint height; + gDepth.GetDimensions(width, height); + const int2 pixel = int2((ndc * 0.5 + 0.5) * float2(float(width), float(height))); + const float depth = gDepth.Load(int3(pixel, 0)).r; + if (isFarDepth(depth)) + { + return false; + } + const float distance = length(toCamera); + const float3 offset = worldAt(pixel, depth) - position; + if (abs(dot(offset, normal)) > kScreenPlane + 0.002 * distance || length(offset) > 0.2 + 0.02 * distance) + { + return false; + } + radiance = gSceneColour.Load(int3(pixel, 0)).rgb; + const float4 motion = gMotion.Load(int3(pixel, 0)); + const float2 previousUv = (float2(pixel) + 0.5) / float2(float(width), float(height)) - (motion.w > 0.5 ? motion.xy : float2(0.0, 0.0)); + if (gParams.resolve.x > 0.5 && motion.z <= 0.5 && all(previousUv >= float2(0.0, 0.0)) && all(previousUv < float2(1.0, 1.0))) + { + radiance = gPreviousColour.Load(int3(int2(previousUv * float2(float(width), float(height))), 0)).rgb; + } + return true; +} + +float3 hitRadiance(SceneHit hit, float3 origin, float3 direction, float coneSlope, float2 pixel, inout uint random) +{ + const float3 position = origin + direction * hit.t; + const float3 normal = sceneHitNormal(hit, direction); + float3 radiance; + if (screenRadiance(position, normal, radiance)) + { + return radiance; + } + + const PaletteSample palette = samplePalette(gTraceVolumes[hit.volume].recordIndex, hit.material); + float3 irradiance; + if (!sampleRadianceCascades(position, normal, irradiance)) + { + irradiance = gCamera.ambientColorIntensity.rgb * gCamera.ambientColorIntensity.a; + if (gCamera.environmentParams.x >= 0.5) + { + irradiance += gIrradianceMap.SampleLevel(normal, 0.0).rgb; + } + } + return palette.albedo * (palette.emissive + irradiance) + traceLocalLight(position, normal, palette.albedo, coneSlope, random) + + traceSunLight(position, normal, palette.albedo, coneSlope, gParams.tracing.w, pixel); +} + +/// Face plane distance since camera distance jumps every pixel on a grazing floor +float planeWeight(TracedPixel surface, float3 position, float distance) +{ + return exp(-abs(dot(surface.face, position - surface.position)) / (0.005 * distance + 0.01)); +} + +/// Visible normal GGX sample and its pdf +float3 rayDirection(TracedPixel surface, float2 u, out float pdf) +{ + const float3 N = surface.normal; + const float3 V = normalize(gCamera.position - surface.position); + const float3 mirror = reflect(-V, N); + const float alpha = max(surface.roughness * surface.roughness, 1e-3); + float3 L = mirror; + if (surface.roughness > kMirrorRoughness) + { + float3 tangent; + float3 bitangent; + perpendicularBasis(N, tangent, bitangent); + const float3 localView = float3(dot(V, tangent), dot(V, bitangent), max(dot(N, V), 1e-4)); + const float3 h = sampleVisibleNormal(localView, alpha, float2(u.x * kLobeTrim, u.y)); + L = reflect(-V, normalize(tangent * h.x + bitangent * h.y + N * h.z)); + } + // Folded above the face so a grazing lobe stays glossy + if (dot(L, surface.face) <= 0.0) + { + L = normalize(L - 2.0 * dot(L, surface.face) * surface.face); + } + if (dot(L, surface.face) <= 0.0) + { + L = mirror; + } + pdf = visibleNormalPdf(max(dot(N, V), 1e-4), saturate(dot(N, normalize(V + L))), alpha); + return L; +} + +/// A miss returns the environment term lighting would have added +[shader("fragment")] +float4 traceMain(VSOutput input) : SV_Target0 +{ + const int2 pixel = int2(input.position.xy) * 2; + const TracedPixel surface = tracedPixelAt(pixel); + if (!surface.traced) + { + return float4(0.0, 0.0, 0.0, kNoTrace); + } + + // Half resolution coordinates since every other texel is not blue noise + const float4 noise = blueNoise(input.position.xy, 160u); + float pdf; + const float3 L = rayDirection(surface, noise.xy, pdf); + const float3 mirror = reflect(-normalize(gCamera.position - surface.position), surface.normal); + if (dot(L, surface.face) <= 0.0) + { + return float4(ambientSpecularRadiance(surface.position, mirror, surface.roughness), kMissDistance); + } + + // Rough lobes blur what they hit so their marches take a cone floor + const float coneSlope = 0.25 * surface.roughness * surface.roughness; + const float3 origin = surface.position + surface.face * (kOriginOffset * kVoxelSize); + const SceneHit hit = traceScene(origin, L, 0.0, gParams.tracing.x, coneSlope); + if (!hit.hit) + { + return float4(ambientSpecularRadiance(surface.position, mirror, surface.roughness), kMissDistance); + } + uint random = giHash(uint(pixel.x) | (uint(pixel.y) << 16)) ^ asuint(noise.z); + return float4(hitRadiance(hit, origin, L, coneSlope, float2(pixel), random), hit.t); +} + +float specularLuma(float3 colour) +{ + return dot(colour, float3(0.2126, 0.7152, 0.0722)); +} + +/// Tonemapped so one bright hit does not firefly through the variance box +float3 compress(float3 colour) +{ + return colour / (1.0 + specularLuma(colour)); +} + +float3 expand(float3 colour) +{ + return colour / max(1.0 - specularLuma(colour), 1e-3); +} + +/// Neighbour rays weighted by this lobe over their pdf +[shader("fragment")] +float4 resolveMain(VSOutput input) : SV_Target0 +{ + const int2 texel = int2(input.position.xy); + const float4 own = gTrace.Load(int3(texel, 0)); + if (own.a < 0.0) + { + return own; + } + const TracedPixel surface = tracedPixelAt(texel * 2); + const float packed = packHistory(0.0, surface.roughness); + if (!surface.traced) + { + return float4(own.rgb, packed); + } + + uint width; + uint height; + gTrace.GetDimensions(width, height); + const int2 last = int2(int(width), int(height)) - 1; + const float3 N = surface.normal; + const float3 V = normalize(gCamera.position - surface.position); + const float NdotV = max(dot(N, V), 1e-4); + const float alpha = max(surface.roughness * surface.roughness, 1e-3); + const float distance = length(gCamera.position - surface.position); + const float radius = lerp(kResolveRadiusMirror, kResolveRadiusRough, saturate(surface.roughness / max(gParams.tracing.y, 1e-3))); + const float angle = blueNoise(input.position.xy, 170u).x * 6.28318531; + + float3 sum = float3(0.0, 0.0, 0.0); + float weightSum = 0.0; + for (uint i = 0u; i <= kResolveTaps; ++i) + { + const float tapAngle = angle + float(i) * 2.39996323; + const float2 offset = i == 0u ? float2(0.0, 0.0) : radius * sqrt((float(i) - 0.5) / float(kResolveTaps)) * float2(cos(tapAngle), sin(tapAngle)); + const int2 tap = clamp(texel + int2(round(offset)), int2(0, 0), last); + if (i > 0u && all(tap == texel)) + { + continue; + } + const float4 hit = gTrace.Load(int3(tap, 0)); + if (hit.a < 0.0) + { + continue; + } + const TracedPixel neighbour = i == 0u ? surface : tracedPixelAt(tap * 2); + if (!neighbour.traced) + { + continue; + } + // A neighbour on another surface traced a ray this texel could not have + const float geometry = planeWeight(surface, neighbour.position, distance) * saturate(dot(neighbour.normal, N) * 4.0 - 3.0); + if (geometry <= 1e-3) + { + continue; + } + + float pdf; + const float3 sampled = rayDirection(neighbour, blueNoise(float2(tap), 160u).xy, pdf); + const float3 hitPoint = neighbour.position + neighbour.face * (kOriginOffset * kVoxelSize) + sampled * hit.a; + const float3 L = hit.a >= kMissDistance ? sampled : normalize(hitPoint - surface.position); + const float NdotL = dot(N, L); + if (NdotL <= 0.0 || pdf <= 0.0) + { + continue; + } + const float NdotH = saturate(dot(N, normalize(V + L))); + const float ratio = ggxD(NdotH, alpha) * smithG1(NdotV, alpha) * smithG1(NdotL, alpha) / (4.0 * NdotV) / pdf; + const float weight = geometry * min(ratio, kMaxRayWeight); + sum += compress(hit.rgb) * weight; + weightSum += weight; + } + if (weightSum <= 1e-6) + { + return float4(own.rgb, packed); + } + return float4(expand(sum / weightSum), packed); +} + +/// Mirrors keep a faster floor blend since surface motion smears them +[shader("fragment")] +float4 temporalMain(VSOutput input) : SV_Target0 +{ + const int2 texel = int2(input.position.xy); + const float4 current = gResolved.Load(int3(texel, 0)); + if (current.a < 0.0 || gParams.tracing.z < 0.5) + { + return current; + } + const float roughness = historyRoughness(current.a); + + uint width; + uint height; + gResolved.GetDimensions(width, height); + const int2 last = int2(int(width), int(height)) - 1; + float3 mean = float3(0.0, 0.0, 0.0); + float3 meanSquared = float3(0.0, 0.0, 0.0); + float count = 0.0; + for (int y = -1; y <= 1; ++y) + { + for (int x = -1; x <= 1; ++x) + { + const float4 value = gResolved.Load(int3(clamp(texel + int2(x, y), int2(0, 0), last), 0)); + if (value.a < 0.0) + { + continue; + } + const float3 mapped = compress(value.rgb); + mean += mapped; + meanSquared += mapped * mapped; + count += 1.0; + } + } + mean /= count; + const float3 sigma = sqrt(max(meanSquared / count - mean * mean, float3(0.0, 0.0, 0.0))); + + const float4 motion = gMotion.Load(int3(texel * 2, 0)); + const float2 uv = (float2(texel) + 0.5) / float2(float(width), float(height)); + const float2 previousUv = uv - (motion.w > 0.5 ? motion.xy : float2(0.0, 0.0)); + if (motion.z > 0.5 || any(previousUv < float2(0.0, 0.0)) || any(previousUv > float2(1.0, 1.0))) + { + return current; + } + const float4 history = gHistory.SampleLevel(previousUv, 0.0); + if (history.a < 0.0 || abs(historyRoughness(history.a) - roughness) > kRoughnessChange) + { + return current; + } + + // A young history trusts the box less since it is still mostly noise + const float frames = historyFrames(history.a) + 1.0; + const float3 clipped = clamp(compress(history.rgb), mean - 2.0 * sigma, mean + 2.0 * sigma); + const float floorBlend = lerp(0.2, 0.04, saturate(roughness / max(gParams.tracing.y, 1e-3))); + const float blend = max(1.0 / (frames + 1.0), floorBlend); + return float4(expand(lerp(clipped, compress(current.rgb), blend)), packHistory(frames, roughness)); +} + +/// Half resolution texels a rough pixel gathers across on top of the bilinear footprint +static const float kFilterRadius = 2.0; + +/// Gaussian widened with roughness and kept on the surface by plane distance and normal +[shader("fragment")] +float4 compositeMain(VSOutput input) : SV_Target0 +{ + const int2 pixel = int2(input.position.xy); + const float3 scene = gSceneColour.Load(int3(pixel, 0)).rgb; + const TracedPixel surface = tracedPixelAt(pixel); + if (!surface.traced) + { + return float4(scene, 1.0); + } + + uint width; + uint height; + gTemporal.GetDimensions(width, height); + const int2 last = int2(int(width), int(height)) - 1; + const float2 low = (float2(pixel) + 0.5) * 0.5 - 0.5; + const int2 base = int2(floor(low)); + const float distance = length(gCamera.position - surface.position); + const float spread = saturate(surface.roughness / max(gParams.tracing.y, 1e-3)); + const float sigma = 0.6 + kFilterRadius * spread; + // Mirrors stay on the bilinear footprint and rough pixels reach two texels further + const int reach = spread > 0.25 ? 2 : 0; + + float3 reflection = float3(0.0, 0.0, 0.0); + float weightSum = 0.0; + for (int y = -reach; y <= 1 + reach; ++y) + { + for (int x = -reach; x <= 1 + reach; ++x) + { + const int2 texel = base + int2(x, y); + if (any(texel < int2(0, 0)) || any(texel > last)) + { + continue; + } + const float4 value = gTemporal.Load(int3(texel, 0)); + if (value.a < 0.0) + { + continue; + } + const int2 source = texel * 2; + const float3 sourceNormal = gSceneNormal.Load(int3(source, 0)).xyz; + const float facing = dot(sourceNormal, sourceNormal) > 1e-4 ? saturate(dot(normalize(sourceNormal), surface.normal)) : 0.0; + const float3 sourcePosition = worldAt(source, gDepth.Load(int3(source, 0)).r); + const float2 offset = float2(texel) - low; + const float weight = + exp(-dot(offset, offset) / (2.0 * sigma * sigma)) * planeWeight(surface, sourcePosition, distance) * pow(facing, 16.0); + reflection += compress(value.rgb) * weight; + weightSum += weight; + } + } + + const float3 V = normalize(gCamera.position - surface.position); + reflection = weightSum > 1e-4 ? expand(reflection / weightSum) + : ambientSpecularRadiance(surface.position, reflect(-V, surface.normal), surface.roughness); + + const float NdotV = saturate(dot(surface.normal, V)); + const float3 F0 = lerp(float3(0.04, 0.04, 0.04), surface.albedo, surface.metallic); + const float3 fresnel = fresnelSchlickRoughness(NdotV, F0, surface.roughness); + const float2 split = environmentBRDF(surface.roughness, NdotV); + return float4(scene + reflection * (fresnel * split.x + split.y) * surface.reflectivity, 1.0); +} diff --git a/engine/assets/shaders/voxel_specular.slang.meta b/engine/assets/shaders/voxel_specular.slang.meta new file mode 100644 index 000000000..ec264961e --- /dev/null +++ b/engine/assets/shaders/voxel_specular.slang.meta @@ -0,0 +1,4 @@ +uid = "V0xe1Sp3cTr" +type = "shader" +created_at = "2026-10-25T00:00:00.0000000Z" +modified_at = "2026-10-25T00:00:00.0000000Z" diff --git a/engine/src/toast/engine.cpp b/engine/src/toast/engine.cpp index 6ab7dc87b..f0452baed 100644 --- a/engine/src/toast/engine.cpp +++ b/engine/src/toast/engine.cpp @@ -26,6 +26,7 @@ #include "renderer/passes/debug_pass.hpp" #include "renderer/passes/environment_pass.hpp" #include "renderer/passes/fxaa_pass.hpp" +#include "renderer/passes/gi_pass.hpp" #include "renderer/passes/grid_pass.hpp" #include "renderer/passes/reflection_probe_pass.hpp" #include "renderer/passes/shadow_pass.hpp" @@ -34,6 +35,8 @@ #include "renderer/passes/taa_pass.hpp" #include "renderer/passes/tonemap_pass.hpp" #include "renderer/passes/traced_shadow_pass.hpp" +#include "renderer/passes/traced_specular_pass.hpp" +#include "renderer/passes/volumetric_pass.hpp" #include "renderer/passes/voxel_pass.hpp" #include "renderer/render_events.hpp" #include "renderer/renderer_settings.hpp" @@ -426,6 +429,11 @@ void Engine::tick() { m->tick_thread.store(std::hash {}(std::this_thread::get_id())); physics::Simulator::bindToThisThread(); + + // Before polling so the waits do not age the input + if (m->renderer) { + m->renderer->waitForFrameSlot(); + } m->time.tick(); // Poll window events @@ -441,6 +449,9 @@ void Engine::tick() { m->input_system->tick(); m->haptics_system->tick(); + // Delivers the InputEvents of this tick + event::pollEvents(); + { std::scoped_lock lock(m->owners_mutex); for (const auto& [_, node_owner] : m->owners) { @@ -656,6 +667,10 @@ void Engine::createSDLWindow(const char* w_name) { // World-stage passes render into the HDR scene target const auto scene_format = m->renderer->getSceneColorFormat(); + // Before the passes whose scene sets bind its cascades + auto gi_pass = std::make_unique(*m->vulkan_core, scene_format, extent); + m->renderer->setGiPass(gi_pass.get()); + // Recorded in its own scopes so registration order here does not matter auto voxel_pass = std::make_unique(*m->vulkan_core, scene_format, depth_format, extent); m->renderer->setVoxelPass(voxel_pass.get()); @@ -665,14 +680,24 @@ void Engine::createSDLWindow(const char* w_name) { // World-space UI panels are scene content and get exposed with it, the screen-space UI does not m->renderer->addRenderPass(std::make_unique(*m->vulkan_core, scene_format, depth_format, extent)); + // First in the chain so the probes read depth before any post pass + m->renderer->addPostProcessPass(std::move(gi_pass)); + if (m->vulkan_core->isRayTracingSupported()) { m->renderer->addPostProcessPass(std::make_unique(*m->vulkan_core, scene_format, extent)); } // SSAO first m->renderer->addPostProcessPass(std::make_unique(*m->vulkan_core, scene_format, extent)); + // Before SSR so the scene colour it reads already holds the traced voxel reflections + auto traced_specular = std::make_unique(*m->vulkan_core, scene_format, extent); + m->renderer->setTracedSpecularPass(traced_specular.get()); + m->renderer->addPostProcessPass(std::move(traced_specular)); m->renderer->addPostProcessPass(std::make_unique(*m->vulkan_core, scene_format, extent)); - m->renderer->addPostProcessPass(std::make_unique(*m->vulkan_core, scene_format, extent)); + m->renderer->addPostProcessPass(std::make_unique(*m->vulkan_core, scene_format, extent)); + auto taa = std::make_unique(*m->vulkan_core, scene_format, extent); + m->renderer->setTaaPass(taa.get()); + m->renderer->addPostProcessPass(std::move(taa)); m->renderer->addPostProcessPass(std::make_unique(*m->vulkan_core, scene_format, extent)); m->renderer->addPostProcessPass(std::make_unique(*m->vulkan_core, color_format, extent)); m->renderer->addPostProcessPass(std::make_unique(*m->vulkan_core, color_format, extent)); @@ -746,6 +771,10 @@ void Engine::createAvaloniaWindow() { // World-stage passes render into the HDR scene target const auto scene_format = m->renderer->getSceneColorFormat(); + // Before the passes whose scene sets bind its cascades + auto gi_pass = std::make_unique(*m->vulkan_core, scene_format, extent); + m->renderer->setGiPass(gi_pass.get()); + // Recorded in its own scopes so registration order here does not matter auto voxel_pass = std::make_unique(*m->vulkan_core, scene_format, depth_format, extent); m->renderer->setVoxelPass(voxel_pass.get()); @@ -755,14 +784,24 @@ void Engine::createAvaloniaWindow() { // World-space UI panels are scene content m->renderer->addRenderPass(std::make_unique(*m->vulkan_core, scene_format, depth_format, extent)); + // First in the chain so the probes read depth before any post pass + m->renderer->addPostProcessPass(std::move(gi_pass)); + if (m->vulkan_core->isRayTracingSupported()) { m->renderer->addPostProcessPass(std::make_unique(*m->vulkan_core, scene_format, extent)); } // SSAO first m->renderer->addPostProcessPass(std::make_unique(*m->vulkan_core, scene_format, extent)); + // Before SSR so the scene colour it reads already holds the traced voxel reflections + auto traced_specular = std::make_unique(*m->vulkan_core, scene_format, extent); + m->renderer->setTracedSpecularPass(traced_specular.get()); + m->renderer->addPostProcessPass(std::move(traced_specular)); m->renderer->addPostProcessPass(std::make_unique(*m->vulkan_core, scene_format, extent)); - m->renderer->addPostProcessPass(std::make_unique(*m->vulkan_core, scene_format, extent)); + m->renderer->addPostProcessPass(std::make_unique(*m->vulkan_core, scene_format, extent)); + auto taa = std::make_unique(*m->vulkan_core, scene_format, extent); + m->renderer->setTaaPass(taa.get()); + m->renderer->addPostProcessPass(std::move(taa)); m->renderer->addPostProcessPass(std::make_unique(*m->vulkan_core, scene_format, extent)); m->renderer->addPostProcessPass(std::make_unique(*m->vulkan_core, color_format, extent)); m->renderer->addPostProcessPass(std::make_unique(*m->vulkan_core, color_format, extent)); diff --git a/engine/src/toast/renderer/gi_settings.hpp b/engine/src/toast/renderer/gi_settings.hpp new file mode 100644 index 000000000..7d585b759 --- /dev/null +++ b/engine/src/toast/renderer/gi_settings.hpp @@ -0,0 +1,48 @@ +/// @file gi_settings.hpp +/// @author dario +/// @date 22/10/2026 + +#pragma once + +#include +#include + +namespace renderer { + +/// renderer.gi settings written by the settings thread and read by GiPass on the render thread +struct GiSettings { + static constexpr uint32_t k_quality_custom = 4; + + std::atomic_bool enabled {true}; + /// Low medium high epic and custom where only custom reads the cost knobs below + std::atomic quality {2}; + /// GI frames run once every this many rendered frames + std::atomic update_interval {1}; + /// Off screen hits kept for the secondary cache each frame + std::atomic secondary_points {65536}; + /// Off screen hits seed and read a coarser secondary cache for every bounce past the first + std::atomic_bool multibounce {true}; + /// Meters between c0 probes at LOD 0 + std::atomic probe_spacing {0.1f}; + /// Chebyshev meters from the camera where LOD 1 starts + std::atomic lod_distance {4.0f}; + std::atomic cascades {5}; + /// Pixels per side of the block one probe insertion and later one ray comes from + std::atomic ray_step {2}; + std::atomic probe_lifetime {60}; + /// 0 keeps no history + std::atomic history_seconds {0.3f}; + /// Blend history_blend per frame instead of history_seconds + std::atomic_bool history_per_frame {false}; + /// Share of a new frame when history_per_frame is on and 1 keeps no history + std::atomic history_blend {0.03f}; + /// Meters a body has to move in a frame before GI treats it as a change and not physics jitter + std::atomic motion_threshold {0.01f}; + std::atomic max_ray_radiance {16.0f}; + /// 0 off and otherwise 1 + the cascade drawn + std::atomic debug_view {0}; + /// Mirrors gi_debug.slang kGiDebug with 0 cells 1 interval radiance 2 coverage 3 irradiance 4 history depth 5 change + std::atomic debug_mode {0}; +}; + +} diff --git a/engine/src/toast/renderer/gpu_timer.cpp b/engine/src/toast/renderer/gpu_timer.cpp index d4a890879..6084c0ac0 100644 --- a/engine/src/toast/renderer/gpu_timer.cpp +++ b/engine/src/toast/renderer/gpu_timer.cpp @@ -33,6 +33,17 @@ GpuTimer::ScopeName::ScopeName(std::string_view text) noexcept } GpuTimer::GpuTimer(const VulkanCore& core, uint32_t frame_slots) { + // Labels work without timestamp queries + if (core.debugUtilsEnabled()) { + // Extension entry points are not exported by the loader library so they come from the device dispatcher + m_begin_label = core.getDevice().getDispatcher()->vkCmdBeginDebugUtilsLabelEXT; + m_end_label = core.getDevice().getDispatcher()->vkCmdEndDebugUtilsLabelEXT; + if (m_begin_label == nullptr || m_end_label == nullptr) { + m_begin_label = nullptr; + m_end_label = nullptr; + } + } + if (frame_slots == 0) { return; } @@ -85,7 +96,28 @@ void GpuTimer::endGraphics(uint32_t slot, vk::CommandBuffer cmd) { m_slots[slot].graphics_written = true; } +void GpuTimer::beginLabel(vk::CommandBuffer cmd, std::string_view name) const { + if (m_begin_label == nullptr) { + return; + } + // Copied since the label name must be null terminated + std::array text {}; + std::copy_n(name.data(), std::min(name.size(), text.size() - 1), text.data()); + VkDebugUtilsLabelEXT label {}; + label.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_LABEL_EXT; + label.pLabelName = text.data(); + m_begin_label(cmd, &label); +} + +void GpuTimer::endLabel(vk::CommandBuffer cmd) const { + if (m_end_label != nullptr) { + m_end_label(cmd); + } +} + auto GpuTimer::beginScope(uint32_t slot, vk::CommandBuffer cmd, std::string_view name) -> uint32_t { + // Before every early return since endScope always closes it + beginLabel(cmd, name); if (!m_supported || slot >= m_slots.size()) { return k_no_scope; } @@ -102,6 +134,7 @@ auto GpuTimer::beginScope(uint32_t slot, vk::CommandBuffer cmd, std::string_view } void GpuTimer::endScope(uint32_t slot, vk::CommandBuffer cmd, uint32_t scope) { + endLabel(cmd); if (scope == k_no_scope || slot >= m_slots.size()) { return; } diff --git a/engine/src/toast/renderer/gpu_timer.hpp b/engine/src/toast/renderer/gpu_timer.hpp index c7854ab93..1b57f8d63 100644 --- a/engine/src/toast/renderer/gpu_timer.hpp +++ b/engine/src/toast/renderer/gpu_timer.hpp @@ -103,6 +103,13 @@ class GpuTimer { [[nodiscard]] auto ticksToMs(uint64_t begin, uint64_t end, uint64_t mask) const noexcept -> double; + /// Scopes also open debug labels so frame captures split per pass + void beginLabel(vk::CommandBuffer cmd, std::string_view name) const; + void endLabel(vk::CommandBuffer cmd) const; + + PFN_vkCmdBeginDebugUtilsLabelEXT m_begin_label = nullptr; + PFN_vkCmdEndDebugUtilsLabelEXT m_end_label = nullptr; + vk::raii::QueryPool m_pool = nullptr; std::vector m_slots; double m_ns_per_tick = 1.0; diff --git a/engine/src/toast/renderer/passes/debug_pass.cpp b/engine/src/toast/renderer/passes/debug_pass.cpp index e8cfd62af..6df4c69d2 100644 --- a/engine/src/toast/renderer/passes/debug_pass.cpp +++ b/engine/src/toast/renderer/passes/debug_pass.cpp @@ -1072,6 +1072,15 @@ void DebugPass::record(vk::CommandBuffer cmd, uint32_t frame_index, uint32_t ima cmd.draw(6, 1, 0, 0); } + + // The billboard layout is not compatible for set 0 so the gizmos below need the frame set back + cmd.bindDescriptorSets( + vk::PipelineBindPoint::eGraphics, + *m_shader_layout.getPipelineLayout(), + 0, + std::array {*m_frame_descriptor_sets[frame_index]}, + {} + ); } if (!frame->debug_gizmo_instances.empty() && m_gizmo_pipeline.isReady() && m_gizmo_vertex_count > 0) { diff --git a/engine/src/toast/renderer/passes/gi_pass.cpp b/engine/src/toast/renderer/passes/gi_pass.cpp new file mode 100644 index 000000000..80e457bf6 --- /dev/null +++ b/engine/src/toast/renderer/passes/gi_pass.cpp @@ -0,0 +1,1342 @@ +/// @file gi_pass.cpp +/// @author dario +/// @date 22/10/2026 + +#include "gi_pass.hpp" + +#include "../descriptor_writer.hpp" +#include "../gpu_sync.hpp" +#include "../gpu_timer.hpp" +#include "../shader_cache.hpp" +#include "../shader_compiler.hpp" +#include "../voxel_blas_cache.hpp" +#include "../voxel_gpu_storage.hpp" +#include "../voxel_trace_scene.hpp" +#include "../vulkan_core.hpp" +#include "../vulkan_debug.hpp" +#include "../vulkan_renderer.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace renderer { + +namespace { + +constexpr uint32_t k_group = 64; +constexpr uint32_t k_pixel_group = 8; +/// Mirrors gi_common GiProbe +constexpr vk::DeviceSize k_probe_bytes = 32; +/// LOD start shortened by this share so neighbouring LODs overlap and blend +constexpr float k_lod_overlap = 0.9f; +/// LOD distance over probe spacing keeping a carried shell inside the 512 cell key window +constexpr float k_max_cells_to_lod = 80.0f; + +constexpr uint32_t k_depth_binding = 0; +constexpr uint32_t k_frame_binding = 1; +constexpr uint32_t k_keys_binding = 2; +constexpr uint32_t k_flags_binding = 3; +constexpr uint32_t k_previous_binding = 4; +constexpr uint32_t k_values_binding = 5; +constexpr uint32_t k_cells_binding = 6; +constexpr uint32_t k_pool_binding = 7; +constexpr uint32_t k_counters_binding = 8; +constexpr uint32_t k_normal_binding = 9; +constexpr uint32_t k_deposit_pool_binding = 10; +constexpr uint32_t k_parent_binding = 11; +constexpr uint32_t k_ray_counts_binding = 12; +constexpr uint32_t k_deposits_binding = 13; +constexpr uint32_t k_deposit_slots_binding = 14; +constexpr uint32_t k_neighbours_binding = 15; +constexpr uint32_t k_history_source_binding = 16; +constexpr uint32_t k_dirty_binding = 17; +constexpr uint32_t k_points_binding = 18; +constexpr uint32_t k_spawn_binding = 19; + +/// gi_debug +constexpr uint32_t k_debug_source_binding = 0; +constexpr uint32_t k_debug_depth_binding = 1; +constexpr uint32_t k_debug_frame_binding = 2; +constexpr uint32_t k_debug_keys_binding = 3; +constexpr uint32_t k_debug_values_binding = 4; +constexpr uint32_t k_debug_pool_binding = 5; +constexpr uint32_t k_debug_normal_binding = 6; +constexpr uint32_t k_debug_deposits_binding = 7; +constexpr uint32_t k_debug_deposit_slots_binding = 8; +constexpr uint32_t k_debug_irradiance_binding = 9; +constexpr uint32_t k_debug_history_binding = 10; +constexpr uint32_t k_debug_change_binding = 11; + +/// gi_rays +constexpr uint32_t k_ray_scene_set = 0; +constexpr uint32_t k_ray_storage_set = 1; +constexpr uint32_t k_ray_gi_set = 2; +constexpr uint32_t k_ray_instances_binding = 6; +constexpr uint32_t k_ray_trace_volumes_binding = 10; +constexpr uint32_t k_ray_trace_bounds_binding = 11; +constexpr uint32_t k_ray_trace_grid_binding = 12; +constexpr uint32_t k_ray_trace_brick_bases_binding = 14; +constexpr uint32_t k_ray_trace_bricks_binding = 15; +constexpr uint32_t k_ray_depth_binding = 0; +constexpr uint32_t k_ray_normal_binding = 1; +constexpr uint32_t k_ray_frame_binding = 2; +constexpr uint32_t k_ray_keys_binding = 3; +constexpr uint32_t k_ray_values_binding = 4; +constexpr uint32_t k_ray_pool_binding = 5; +constexpr uint32_t k_ray_ray_counts_binding = 6; +constexpr uint32_t k_ray_deposits_binding = 7; +constexpr uint32_t k_ray_parent_binding = 8; +constexpr uint32_t k_ray_deposit_slots_binding = 9; +constexpr uint32_t k_ray_scene_colour_binding = 10; +constexpr uint32_t k_ray_counters_binding = 11; +constexpr uint32_t k_ray_points_binding = 12; +constexpr uint32_t k_ray_irradiance_binding = 13; +constexpr uint32_t k_ray_change_binding = 14; +constexpr uint32_t k_ray_spawn_binding = 15; +constexpr uint32_t k_ray_hits_binding = 16; +/// Mirrors gi_common GiPoint +constexpr vk::DeviceSize k_point_bytes = 16; +/// Mirrors gi_common GiHistory +constexpr vk::DeviceSize k_history_bytes = 12; + +/// gi_merge +constexpr uint32_t k_merge_scene_set = 0; +constexpr uint32_t k_merge_set = 1; +constexpr uint32_t k_merge_frame_binding = 0; +constexpr uint32_t k_merge_pool_binding = 1; +constexpr uint32_t k_merge_deposit_pool_binding = 2; +constexpr uint32_t k_merge_deposit_slots_binding = 3; +constexpr uint32_t k_merge_neighbours_binding = 4; +constexpr uint32_t k_merge_deposits_binding = 5; +constexpr uint32_t k_merge_merged_binding = 6; +constexpr uint32_t k_merge_irradiance_binding = 7; +constexpr uint32_t k_merge_counters_binding = 8; +constexpr uint32_t k_merge_history_binding = 9; +constexpr uint32_t k_merge_history_source_binding = 10; +constexpr uint32_t k_merge_dirty_binding = 11; + +/// Rays of history a probe keeps after a change crosses its interval +constexpr float k_reset_history = 0.25f; + +/// Mirrors gi_common kGiBaseDirections +constexpr uint32_t k_base_directions = 4; +/// Mirrors gi_common kGiStrideThreads +constexpr uint32_t k_stride_groups = 4096; +constexpr vk::Format k_atlas_format = vk::Format::eR16G16B16A16Sfloat; +/// First interval length t0 in c0 probe spacings +constexpr float k_interval_scale = 1.6f; +/// 1/256 steps so a bucket holds about a thousand rays at gi_rays kGiMaxRadiance +constexpr float k_deposit_fixed_scale = 256.0f; + +auto groups(uint32_t count, uint32_t size) -> uint32_t { + return (count + size - 1) / size; +} + +auto deviceBuffer(const VulkanCore& core, vk::DeviceSize size, vk::BufferUsageFlags usage, std::string_view name) + -> vma::raii::Buffer { + vk::BufferCreateInfo buffer_ci {}; + buffer_ci.size = size; + buffer_ci.usage = usage; + vma::AllocationCreateInfo alloc_ci {}; + alloc_ci.usage = vma::MemoryUsage::eAutoPreferDevice; + vma::raii::Buffer buffer = core.getAllocator().createBuffer(buffer_ci, alloc_ci); + setDebugName(core, *buffer, name); + return buffer; +} + +auto hostBuffer(const VulkanCore& core, vk::DeviceSize size, vk::BufferUsageFlags usage, bool read, std::string_view name) + -> vma::raii::Buffer { + vk::BufferCreateInfo buffer_ci {}; + buffer_ci.size = size; + buffer_ci.usage = usage; + vma::AllocationCreateInfo alloc_ci {}; + alloc_ci.usage = read ? vma::MemoryUsage::eAutoPreferHost : vma::MemoryUsage::eAuto; + alloc_ci.flags = vma::AllocationCreateFlagBits::eMapped | (read ? vma::AllocationCreateFlagBits::eHostAccessRandom + : vma::AllocationCreateFlagBits::eHostAccessSequentialWrite); + vma::raii::Buffer buffer = core.getAllocator().createBuffer(buffer_ci, alloc_ci); + setDebugName(core, *buffer, name); + return buffer; +} + +void computeBarrier(vk::CommandBuffer cmd) { + sync::BarrierBatch barriers; + barriers.memory(sync::Usage::compute_storage_write, sync::Usage::compute_storage_write); + barriers.flush(cmd); +} + +/// Mirrors gi_common giRayPoint fixed point R2 +auto r2Jitter(uint32_t frame) -> glm::vec2 { + const uint32_t x = 0x80000000u + (frame * 3242174889u); + const uint32_t y = 0x80000000u + (frame * 2447445414u); + return glm::vec2(static_cast(x >> 8), static_cast(y >> 8)) / 16777216.0f; +} + +struct QualityLevel { + uint32_t ray_budget = 0; + uint32_t cascades = 0; + float spacing = 0.0f; + float lod_distance = 0.0f; + bool multibounce = false; + uint32_t points = 0; + uint32_t interval = 1; +}; + +/// Scalability +constexpr std::array k_quality_levels { + { + {.ray_budget = 150'000, + .cascades = 4, + .spacing = 0.2f, + .lod_distance = 4.0f, + .multibounce = false, + .points = 8192, + .interval = 2}, + {.ray_budget = 300'000, + .cascades = 4, + .spacing = 0.15f, + .lod_distance = 4.0f, + .multibounce = true, + .points = 32768, + .interval = 1}, + {.ray_budget = 600'000, + .cascades = 5, + .spacing = 0.1f, + .lod_distance = 4.0f, + .multibounce = true, + .points = 65536, + .interval = 1}, + {.ray_budget = 2'200'000, + .cascades = 5, + .spacing = 0.1f, + .lod_distance = 4.0f, + .multibounce = true, + .points = 65536, + .interval = 1}, + } +}; + +auto stepForBudget(vk::Extent2D extent, uint32_t budget) -> uint32_t { + for (uint32_t step = 1; step < 4; ++step) { + const uint64_t rays = static_cast(groups(extent.width, step)) * groups(extent.height, step); + if (rays <= budget) { + return step; + } + } + return 4; +} + +/// Most pixels any quality preset spawns at this extent +auto spawnCapacity(vk::Extent2D extent) -> uint32_t { + uint32_t capacity = 1; + for (const QualityLevel& preset : k_quality_levels) { + const uint32_t step = stepForBudget(extent, preset.ray_budget); + capacity = std::max(capacity, groups(extent.width, step) * groups(extent.height, step)); + } + return capacity; +} + +} + +auto GiPass::resolveQuality() const -> Quality { + const GiSettings& settings = VulkanRenderer::instance->giSettings(); + constexpr auto relaxed = std::memory_order_relaxed; + const uint32_t level = std::min(settings.quality.load(relaxed), GiSettings::k_quality_custom); + if (level == GiSettings::k_quality_custom) { + uint32_t step = std::clamp(settings.ray_step.load(relaxed), 1u, 4u); + // A step whose pixels overflow the spawn list would starve the screen tail + while (m_spawn_capacity > 0 && step < 4 && groups(m_extent.width, step) * groups(m_extent.height, step) > m_spawn_capacity) { + ++step; + } + return Quality { + .level = level, + .step = step, + .cascades = std::clamp(settings.cascades.load(relaxed), 1u, k_max_cascades), + .spacing = settings.probe_spacing.load(relaxed), + .lod_distance = settings.lod_distance.load(relaxed), + .multibounce = settings.multibounce.load(relaxed), + .points = std::clamp(settings.secondary_points.load(relaxed), 1u, k_max_points), + .interval = std::clamp(settings.update_interval.load(relaxed), 1u, 4u), + }; + } + const QualityLevel& preset = k_quality_levels[level]; + return Quality { + .level = level, + .step = stepForBudget(m_extent, preset.ray_budget), + .cascades = std::min(preset.cascades, k_max_cascades), + .spacing = preset.spacing, + .lod_distance = preset.lod_distance, + .multibounce = preset.multibounce, + .points = std::min(preset.points, k_max_points), + .interval = preset.interval, + }; +} + +GiPass::GiPass(const VulkanCore& core, vk::Format scene_format, vk::Extent2D extent) + : m_core(&core), + m_scene_format(scene_format), + m_extent(extent) { + ZoneScoped; + static_assert(sizeof(FrameData) == 416, "FrameData is mirrored by gi_common GiFrame"); + + createLayout(); + createBuffers(core); + if (!createPipelines(core)) { + setEnabled(false); + return; + } + m_debug_target.create(core, extent, scene_format, "GiPass Debug"); + TOAST_INFO( + "Render", + "GiPass ready ({} table entries, {} probes per pool, {} deposit probes, {} MB of deposits history and merged radiance)", + m_table_total, + m_pool_total, + m_deposit_total, + ((sizeof(glm::uvec4) + (2 * k_history_bytes) + (sizeof(glm::uvec2) / 4)) * k_deposit_slots) >> 20 + ); +} + +void GiPass::createLayout() { + uint32_t table = 0; + uint32_t pool = 0; + uint32_t deposit = 0; + for (uint32_t i = 0; i < k_max_cascades; ++i) { + const uint32_t capacity = std::max(k_base_capacity >> (2 * i), k_min_capacity); + // Half full at most so linear probing stays short + const uint32_t size = std::bit_ceil(capacity * 2); + const uint32_t side = k_base_directions << i; + const uint32_t directions = 2 * side * side; + // c0 deposit probes double as irradiance tiles + const uint32_t deposit_capacity = i == 0 ? std::min(capacity, k_atlas_tiles) : capacity; + m_cascades[i] = Cascade { + .table_offset = table, + .table_size = size, + .pool_offset = pool, + .capacity = capacity, + .deposit_offset = deposit, + .deposit_capacity = deposit_capacity, + .directions = directions, + }; + table += size; + pool += capacity; + deposit += deposit_capacity; + } + m_table_total = table; + m_pool_total = pool; + m_deposit_total = deposit; +} + +void GiPass::createBuffers(const VulkanCore& core) { + ZoneScoped; + constexpr auto storage = vk::BufferUsageFlagBits::eStorageBuffer; + m_table_keys.emplace(deviceBuffer(core, sizeof(uint32_t) * m_table_total, storage, "GiPass TableKeys")); + m_table_flags.emplace(deviceBuffer(core, sizeof(uint32_t) * m_table_total, storage, "GiPass TableFlags")); + m_table_previous.emplace(deviceBuffer(core, sizeof(uint32_t) * m_table_total, storage, "GiPass TablePrevious")); + m_table_values.emplace(deviceBuffer(core, sizeof(uint32_t) * m_table_total, storage, "GiPass TableValues")); + m_table_cells.emplace(deviceBuffer(core, sizeof(glm::ivec4) * m_table_total, storage, "GiPass TableCells")); + m_pool.emplace(deviceBuffer(core, k_probe_bytes * 2 * m_pool_total, storage, "GiPass Pool")); + m_counters.emplace(deviceBuffer( + core, + sizeof(uint32_t) * k_counter_words, + storage | vk::BufferUsageFlagBits::eTransferSrc | vk::BufferUsageFlagBits::eTransferDst, + "GiPass Counters" + )); + m_deposit_pool.emplace(deviceBuffer(core, sizeof(uint32_t) * m_deposit_total, storage, "GiPass DepositPool")); + m_parent.emplace(deviceBuffer(core, sizeof(uint32_t) * m_deposit_total, storage, "GiPass Parent")); + m_ray_counts.emplace(deviceBuffer(core, sizeof(glm::uvec4) * m_deposit_total, storage, "GiPass RayCounts")); + m_deposits.emplace(deviceBuffer(core, sizeof(glm::uvec4) * k_deposit_slots, storage, "GiPass Deposits")); + m_deposit_slots.emplace(deviceBuffer(core, sizeof(uint32_t) * m_deposit_total, storage, "GiPass DepositSlots")); + m_neighbours.emplace(deviceBuffer(core, sizeof(glm::uvec4) * 2 * m_deposit_total, storage, "GiPass Neighbours")); + // Every probe direction count is a multiple of four so slot / 4 indexes the averaged directions + m_merged.emplace(deviceBuffer(core, sizeof(glm::uvec2) * (k_deposit_slots / 4), storage, "GiPass Merged")); + + constexpr uint32_t atlas_width = k_atlas_tiles_per_row * 8; + constexpr uint32_t atlas_height = k_atlas_rows * 8; + vk::ImageCreateInfo image_ci {}; + image_ci.imageType = vk::ImageType::e2D; + image_ci.format = k_atlas_format; + image_ci.extent = vk::Extent3D {atlas_width, atlas_height, 1}; + image_ci.mipLevels = 1; + image_ci.arrayLayers = 1; + image_ci.samples = vk::SampleCountFlagBits::e1; + image_ci.tiling = vk::ImageTiling::eOptimal; + image_ci.usage = vk::ImageUsageFlagBits::eStorage | vk::ImageUsageFlagBits::eSampled | vk::ImageUsageFlagBits::eTransferDst; + image_ci.sharingMode = vk::SharingMode::eExclusive; + image_ci.initialLayout = vk::ImageLayout::eUndefined; + vma::AllocationCreateInfo image_alloc {}; + image_alloc.usage = vma::MemoryUsage::eAutoPreferDevice; + m_atlas.emplace(core.getAllocator().createImage(image_ci, image_alloc)); + setDebugName(core, **m_atlas, "GiPass IrradianceAtlas"); + const vk::ImageSubresourceRange range {vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1}; + m_atlas_state.reset(**m_atlas, range); + + vk::ImageViewCreateInfo view_ci {}; + view_ci.image = **m_atlas; + view_ci.viewType = vk::ImageViewType::e2D; + view_ci.format = k_atlas_format; + view_ci.subresourceRange = range; + m_atlas_view.emplace(core.getDevice(), view_ci); + setDebugName(core, **m_atlas_view, "GiPass IrradianceAtlas View"); + + m_published.emplace( + deviceBuffer(core, sizeof(FrameData), storage | vk::BufferUsageFlagBits::eTransferDst, "GiPass PublishedFrame") + ); + m_history.emplace(deviceBuffer(core, k_history_bytes * 2 * k_deposit_slots, storage, "GiPass History")); + m_history_source.emplace(deviceBuffer(core, sizeof(glm::uvec2) * m_deposit_total, storage, "GiPass HistorySource")); + m_points.emplace(deviceBuffer(core, k_point_bytes * 2 * k_max_points, storage, "GiPass Points")); + + // Lighting binds the atlas and the published frame before the first GI frame so both start readable and empty + const auto& device = core.getDevice(); + const vk::raii::CommandPool one_shot_pool( + device, vk::CommandPoolCreateInfo(vk::CommandPoolCreateFlagBits::eTransient, core.getGraphicsQueueFamilyIndex()) + ); + auto cmd_buffers = + device.allocateCommandBuffers(vk::CommandBufferAllocateInfo(*one_shot_pool, vk::CommandBufferLevel::ePrimary, 1)); + const vk::raii::CommandBuffer cmd = std::move(cmd_buffers[0]); + cmd.begin(vk::CommandBufferBeginInfo(vk::CommandBufferUsageFlagBits::eOneTimeSubmit)); + cmd.fillBuffer(**m_counters, 0, vk::WholeSize, 0u); + cmd.fillBuffer(**m_published, 0, vk::WholeSize, 0u); + recordUndefinedToTransferDst(cmd, **m_atlas, range); + cmd.clearColorImage(**m_atlas, vk::ImageLayout::eTransferDstOptimal, vk::ClearColorValue(0.0f, 0.0f, 0.0f, 0.0f), range); + recordTransferDstToShaderRead(cmd, **m_atlas, range); + sync::BarrierBatch barriers; + barriers.memory(sync::Usage::transfer_dst, sync::Usage::general); + barriers.flush(*cmd); + cmd.end(); + submitAndWait(device, core.getGraphicsQueue(), core.graphicsSubmitMutex(), *cmd); + m_atlas_state.usage = sync::Usage::shader_sampled; + + m_frame_data.clear(); + m_readback.clear(); + m_dirty.clear(); + for (uint32_t i = 0; i < VulkanRenderer::k_frames_in_flight; ++i) { + m_dirty.push_back( + hostBuffer(core, sizeof(glm::vec4) * (1 + (2 * k_max_dirty)), storage, false, std::format("GiPass Dirty[{}]", i)) + ); + const auto& dirty_allocation = m_dirty.back().getAllocation(); + std::memset(dirty_allocation.getInfo().pMappedData, 0, sizeof(glm::vec4)); + dirty_allocation.flush(0, sizeof(glm::vec4)); + m_frame_data.push_back(hostBuffer( + core, sizeof(FrameData), storage | vk::BufferUsageFlagBits::eTransferSrc, false, std::format("GiPass Frame[{}]", i) + )); + m_readback.push_back(hostBuffer( + core, + sizeof(uint32_t) * k_counter_words, + vk::BufferUsageFlagBits::eTransferDst, + true, + std::format("GiPass Readback[{}]", i) + )); + } + m_dirty_stats.assign(VulkanRenderer::k_frames_in_flight, Stats {}); + m_readback_valid.assign(VulkanRenderer::k_frames_in_flight, false); + m_readback_parity.assign(VulkanRenderer::k_frames_in_flight, 0u); + m_readback_point_share.assign(VulkanRenderer::k_frames_in_flight, m_point_share); + m_readback_point_budget.assign(VulkanRenderer::k_frames_in_flight, k_max_points); + m_readback_spawn_capacity.assign(VulkanRenderer::k_frames_in_flight, 0u); + + m_point_sampler = vk::raii::Sampler(core.getDevice(), nearestClampSamplerInfo()); + setDebugName(core, *m_point_sampler, "GiPass PointSampler"); + m_linear_sampler = vk::raii::Sampler(core.getDevice(), linearClampSamplerInfo()); + setDebugName(core, *m_linear_sampler, "GiPass LinearSampler"); +} + +auto GiPass::createPipelines(const VulkanCore& core) -> bool { + ZoneScoped; + const auto probe_uid = assets::resolveURI("core://shaders/gi_probes.slang"); + const auto probe_shader = probe_uid.has_value() ? ShaderCache::get().acquire(*probe_uid) : nullptr; + const auto debug_uid = assets::resolveURI("core://shaders/gi_debug.slang"); + const auto debug_shader = debug_uid.has_value() ? ShaderCache::get().acquire(*debug_uid) : nullptr; + const auto ray_uid = assets::resolveURI("core://shaders/gi_rays.slang"); + const auto ray_shader = ray_uid.has_value() ? ShaderCache::get().acquire(*ray_uid) : nullptr; + const auto merge_uid = assets::resolveURI("core://shaders/gi_merge.slang"); + const auto merge_shader = merge_uid.has_value() ? ShaderCache::get().acquire(*merge_uid) : nullptr; + if (!probe_shader || !debug_shader || !ray_shader || !merge_shader) { + TOAST_ERROR("Render", "GiPass shaders unavailable, radiance cascades are disabled"); + return false; + } + + m_probe_layout.rebuild(core, probe_shader->reflection, "GiPass Probes"); + const auto compute = [&](VulkanPipeline& pipeline, const char* entry) { + VulkanPipeline::Config config; + config.pipeline_type = VulkanPipeline::PipelineType::compute; + config.debug_name = std::format("GiPass {}", entry); + config.shader_spirv = probe_shader->spirv; + config.pipeline_layout = *m_probe_layout.getPipelineLayout(); + config.compute_entry = entry; + pipeline.rebuild(core, config); + }; + compute(m_clear_pipeline, "clearMain"); + compute(m_carry_pipeline, "carryMain"); + compute(m_pixel_pipeline, "pixelMain"); + compute(m_point_pipeline, "pointMain"); + compute(m_parent_pipeline, "parentMain"); + compute(m_compact_pipeline, "compactMain"); + compute(m_keep_pipeline, "keepMain"); + compute(m_slots_pipeline, "slotsMain"); + compute(m_deposit_clear_pipeline, "depositClearMain"); + compute(m_link_pipeline, "linkMain"); + compute(m_count_pipeline, "countMain"); + compute(m_point_count_pipeline, "pointCountMain"); + compute(m_propagate_pipeline, "propagateMain"); + compute(m_offset_pipeline, "offsetMain"); + + m_ray_layout.rebuild(core, ray_shader->reflection, "GiPass Rays"); + VulkanPipeline::Config ray_config; + ray_config.pipeline_type = VulkanPipeline::PipelineType::compute; + ray_config.debug_name = "GiPass traceMain"; + ray_config.shader_spirv = ray_shader->spirv; + ray_config.pipeline_layout = *m_ray_layout.getPipelineLayout(); + ray_config.compute_entry = "traceMain"; + m_ray_pipeline.rebuild(core, ray_config); + ray_config.debug_name = "GiPass pointTraceMain"; + ray_config.compute_entry = "pointTraceMain"; + m_point_trace_pipeline.rebuild(core, ray_config); + ray_config.debug_name = "GiPass shadeMain"; + ray_config.compute_entry = "shadeMain"; + m_shade_pipeline.rebuild(core, ray_config); + ray_config.debug_name = "GiPass changeMain"; + ray_config.compute_entry = "changeMain"; + m_change_pipeline.rebuild(core, ray_config); + + m_merge_layout.rebuild(core, merge_shader->reflection, "GiPass Merge"); + VulkanPipeline::Config merge_config; + merge_config.pipeline_type = VulkanPipeline::PipelineType::compute; + merge_config.shader_spirv = merge_shader->spirv; + merge_config.pipeline_layout = *m_merge_layout.getPipelineLayout(); + merge_config.debug_name = "GiPass mergeMain"; + merge_config.compute_entry = "mergeMain"; + m_merge_pipeline.rebuild(core, merge_config); + merge_config.debug_name = "GiPass irradianceMain"; + merge_config.compute_entry = "irradianceMain"; + m_irradiance_pipeline.rebuild(core, merge_config); + + m_debug_layout.rebuild(core, debug_shader->reflection, "GiPass Debug"); + VulkanPipeline::Config config; + config.pipeline_type = VulkanPipeline::PipelineType::graphics; + config.debug_name = "GiPass Debug"; + config.color_format = m_scene_format; + config.write_color_alpha = true; + config.extent = m_extent; + config.shader_spirv = debug_shader->spirv; + config.pipeline_layout = *m_debug_layout.getPipelineLayout(); + config.vertex_bindings = {}; + config.vertex_attributes = {}; + config.cull_mode = vk::CullModeFlagBits::eNone; + config.depth_test = false; + config.depth_write = false; + m_debug_pipeline.rebuild(core, config); + + const auto& probe_layouts = m_probe_layout.getDescriptorSetLayouts(); + const auto& debug_layouts = m_debug_layout.getDescriptorSetLayouts(); + const auto& ray_layouts = m_ray_layout.getDescriptorSetLayouts(); + const auto& merge_layouts = m_merge_layout.getDescriptorSetLayouts(); + if (probe_layouts.empty() || debug_layouts.empty() || ray_layouts.size() <= k_ray_gi_set || + merge_layouts.size() <= k_merge_set) { + TOAST_ERROR("Render", "GiPass shader layouts have no descriptor sets, radiance cascades are disabled"); + return false; + } + + const vk::DescriptorPool pool = VulkanRenderer::instance->getDescriptorPoolHandle(); + const vk::DescriptorSetLayout probe_set_layout = *probe_layouts[0]; + const vk::DescriptorSetLayout debug_set_layout = *debug_layouts[0]; + const vk::DescriptorSetLayout ray_storage_layout = *ray_layouts[k_ray_storage_set]; + const vk::DescriptorSetLayout ray_set_layout = *ray_layouts[k_ray_gi_set]; + const vk::DescriptorSetLayout merge_set_layout = *merge_layouts[k_merge_set]; + m_ray_scene_sets.create(core, ray_shader->reflection, *ray_layouts[k_ray_scene_set], "GiPass Rays"); + m_merge_scene_sets.create(core, merge_shader->reflection, *merge_layouts[k_merge_scene_set], "GiPass Merge"); + m_ray_instances_bound = std::ranges::any_of(ray_shader->reflection.bindings, [](const ShaderBinding& binding) { + return binding.set == k_ray_storage_set && binding.binding == k_ray_instances_binding; + }); + const VoxelTraceScene* trace = VulkanRenderer::instance->voxelTraceScene(); + m_probe_sets.clear(); + m_debug_sets.clear(); + m_ray_storage_sets.clear(); + m_ray_sets.clear(); + m_merge_sets.clear(); + for (uint32_t i = 0; i < VulkanRenderer::k_frames_in_flight; ++i) { + auto probe = core.getDevice().allocateDescriptorSets(vk::DescriptorSetAllocateInfo(pool, 1, &probe_set_layout)); + m_probe_sets.push_back(std::move(probe[0])); + setDebugName(core, *m_probe_sets.back(), std::format("GiPass ProbeSet[{}]", i)); + auto debug = core.getDevice().allocateDescriptorSets(vk::DescriptorSetAllocateInfo(pool, 1, &debug_set_layout)); + m_debug_sets.push_back(std::move(debug[0])); + setDebugName(core, *m_debug_sets.back(), std::format("GiPass DebugSet[{}]", i)); + + DescriptorWriter writer; + const vk::DescriptorSet probe_set = *m_probe_sets.back(); + constexpr auto storage = vk::DescriptorType::eStorageBuffer; + writer.buffer(probe_set, k_frame_binding, storage, *m_frame_data[i]); + writer.buffer(probe_set, k_keys_binding, storage, **m_table_keys); + writer.buffer(probe_set, k_flags_binding, storage, **m_table_flags); + writer.buffer(probe_set, k_previous_binding, storage, **m_table_previous); + writer.buffer(probe_set, k_values_binding, storage, **m_table_values); + writer.buffer(probe_set, k_cells_binding, storage, **m_table_cells); + writer.buffer(probe_set, k_pool_binding, storage, **m_pool); + writer.buffer(probe_set, k_counters_binding, storage, **m_counters); + writer.buffer(probe_set, k_deposit_pool_binding, storage, **m_deposit_pool); + writer.buffer(probe_set, k_parent_binding, storage, **m_parent); + writer.buffer(probe_set, k_ray_counts_binding, storage, **m_ray_counts); + writer.buffer(probe_set, k_deposits_binding, storage, **m_deposits); + writer.buffer(probe_set, k_deposit_slots_binding, storage, **m_deposit_slots); + writer.buffer(probe_set, k_neighbours_binding, storage, **m_neighbours); + writer.buffer(probe_set, k_history_source_binding, storage, **m_history_source); + writer.buffer(probe_set, k_dirty_binding, storage, *m_dirty[i]); + writer.buffer(probe_set, k_points_binding, storage, **m_points); + + const vk::DescriptorSet debug_set = *m_debug_sets.back(); + writer.buffer(debug_set, k_debug_frame_binding, storage, *m_frame_data[i]); + writer.buffer(debug_set, k_debug_keys_binding, storage, **m_table_keys); + writer.buffer(debug_set, k_debug_values_binding, storage, **m_table_values); + writer.buffer(debug_set, k_debug_pool_binding, storage, **m_pool); + writer.buffer(debug_set, k_debug_deposits_binding, storage, **m_deposits); + writer.buffer(debug_set, k_debug_deposit_slots_binding, storage, **m_deposit_slots); + writer.buffer(debug_set, k_debug_history_binding, storage, **m_history); + writer.image( + debug_set, + k_debug_irradiance_binding, + vk::DescriptorType::eCombinedImageSampler, + *m_linear_sampler, + **m_atlas_view, + vk::ImageLayout::eShaderReadOnlyOptimal + ); + + auto ray_storage = core.getDevice().allocateDescriptorSets(vk::DescriptorSetAllocateInfo(pool, 1, &ray_storage_layout)); + m_ray_storage_sets.push_back(std::move(ray_storage[0])); + setDebugName(core, *m_ray_storage_sets.back(), std::format("GiPass RayStorageSet[{}]", i)); + const vk::DescriptorSet ray_storage_set = *m_ray_storage_sets.back(); + if (trace != nullptr) { + writer.buffer(ray_storage_set, k_ray_trace_volumes_binding, storage, trace->volumeBuffer(i)); + writer.buffer(ray_storage_set, k_ray_trace_bounds_binding, storage, trace->boundsBuffer(i)); + writer.buffer(ray_storage_set, k_ray_trace_grid_binding, storage, trace->gridBuffer(i)); + if (m_ray_instances_bound) { + writer.buffer(ray_storage_set, k_ray_instances_binding, storage, trace->volumeBuffer(i)); + } + if (ShaderCompiler::isRayQueryAvailable()) { + const VoxelBlasCache* bricks = VulkanRenderer::instance->voxelBlasCache(); + writer.buffer(ray_storage_set, k_ray_trace_brick_bases_binding, storage, trace->brickBaseBuffer(i)); + writer.buffer( + ray_storage_set, + k_ray_trace_bricks_binding, + storage, + bricks != nullptr ? bricks->poolBuffer() : trace->brickBaseBuffer(i) + ); + } + } + + auto ray = core.getDevice().allocateDescriptorSets(vk::DescriptorSetAllocateInfo(pool, 1, &ray_set_layout)); + m_ray_sets.push_back(std::move(ray[0])); + setDebugName(core, *m_ray_sets.back(), std::format("GiPass RaySet[{}]", i)); + const vk::DescriptorSet ray_set = *m_ray_sets.back(); + writer.buffer(ray_set, k_ray_frame_binding, storage, *m_frame_data[i]); + writer.buffer(ray_set, k_ray_keys_binding, storage, **m_table_keys); + writer.buffer(ray_set, k_ray_values_binding, storage, **m_table_values); + writer.buffer(ray_set, k_ray_pool_binding, storage, **m_pool); + writer.buffer(ray_set, k_ray_ray_counts_binding, storage, **m_ray_counts); + writer.buffer(ray_set, k_ray_deposits_binding, storage, **m_deposits); + writer.buffer(ray_set, k_ray_parent_binding, storage, **m_parent); + writer.buffer(ray_set, k_ray_deposit_slots_binding, storage, **m_deposit_slots); + writer.buffer(ray_set, k_ray_counters_binding, storage, **m_counters); + writer.buffer(ray_set, k_ray_points_binding, storage, **m_points); + writer.image( + ray_set, + k_ray_irradiance_binding, + vk::DescriptorType::eCombinedImageSampler, + *m_linear_sampler, + **m_atlas_view, + vk::ImageLayout::eShaderReadOnlyOptimal + ); + + auto merge = core.getDevice().allocateDescriptorSets(vk::DescriptorSetAllocateInfo(pool, 1, &merge_set_layout)); + m_merge_sets.push_back(std::move(merge[0])); + setDebugName(core, *m_merge_sets.back(), std::format("GiPass MergeSet[{}]", i)); + const vk::DescriptorSet merge_set = *m_merge_sets.back(); + writer.buffer(merge_set, k_merge_frame_binding, storage, *m_frame_data[i]); + writer.buffer(merge_set, k_merge_pool_binding, storage, **m_pool); + writer.buffer(merge_set, k_merge_deposit_pool_binding, storage, **m_deposit_pool); + writer.buffer(merge_set, k_merge_deposit_slots_binding, storage, **m_deposit_slots); + writer.buffer(merge_set, k_merge_neighbours_binding, storage, **m_neighbours); + writer.buffer(merge_set, k_merge_deposits_binding, storage, **m_deposits); + writer.buffer(merge_set, k_merge_merged_binding, storage, **m_merged); + writer.image( + merge_set, + k_merge_irradiance_binding, + vk::DescriptorType::eStorageImage, + nullptr, + **m_atlas_view, + vk::ImageLayout::eGeneral + ); + writer.buffer(merge_set, k_merge_counters_binding, storage, **m_counters); + writer.buffer(merge_set, k_merge_history_binding, storage, **m_history); + writer.buffer(merge_set, k_merge_history_source_binding, storage, **m_history_source); + writer.buffer(merge_set, k_merge_dirty_binding, storage, *m_dirty[i]); + writer.flush(core.getDevice()); + } + m_probe_bound.assign(VulkanRenderer::k_frames_in_flight, {}); + m_debug_bound.assign(VulkanRenderer::k_frames_in_flight, {}); + m_ray_bound.assign(VulkanRenderer::k_frames_in_flight, {}); + createChange(core); + m_ray_storage_bound.assign(VulkanRenderer::k_frames_in_flight, nullptr); + if (trace == nullptr) { + TOAST_WARN("Render", "GiPass found no voxel trace scene so probes get no rays"); + } + + return m_clear_pipeline.isReady() && m_carry_pipeline.isReady() && m_pixel_pipeline.isReady() && m_parent_pipeline.isReady() && + m_compact_pipeline.isReady() && m_keep_pipeline.isReady() && m_slots_pipeline.isReady() && + m_deposit_clear_pipeline.isReady() && m_link_pipeline.isReady() && m_count_pipeline.isReady() && + m_propagate_pipeline.isReady() && m_offset_pipeline.isReady() && m_point_pipeline.isReady() && + m_point_count_pipeline.isReady() && m_merge_pipeline.isReady() && m_irradiance_pipeline.isReady(); +} + +void GiPass::onResize(vk::Extent2D extent) { + m_extent = extent; + if (m_core != nullptr) { + m_debug_target.create(*m_core, extent, m_scene_format, "GiPass Debug"); + std::ranges::fill(m_probe_bound, std::array {}); + std::ranges::fill(m_debug_bound, std::array {}); + std::ranges::fill(m_ray_bound, std::array {}); + createChange(*m_core); + } +} + +void GiPass::createChange(const VulkanCore& core) { + if (m_ray_sets.empty() || m_debug_sets.empty()) { + return; + } + const vk::DeviceSize pixels = static_cast(std::max(m_extent.width, 1u)) * std::max(m_extent.height, 1u); + m_change.reset(); + m_change.emplace(deviceBuffer(core, sizeof(glm::vec4) * pixels, vk::BufferUsageFlagBits::eStorageBuffer, "GiPass Change")); + m_spawn_capacity = spawnCapacity(m_extent); + m_spawn.reset(); + m_spawn.emplace( + deviceBuffer(core, sizeof(uint32_t) * m_spawn_capacity, vk::BufferUsageFlagBits::eStorageBuffer, "GiPass Spawn") + ); + m_hits.reset(); + m_hits.emplace(deviceBuffer( + core, sizeof(glm::uvec4) * (m_spawn_capacity + k_max_points), vk::BufferUsageFlagBits::eStorageBuffer, "GiPass Hits" + )); + DescriptorWriter writer; + for (uint32_t i = 0; i < VulkanRenderer::k_frames_in_flight; ++i) { + writer.buffer(*m_probe_sets[i], k_spawn_binding, vk::DescriptorType::eStorageBuffer, **m_spawn); + writer.buffer(*m_ray_sets[i], k_ray_spawn_binding, vk::DescriptorType::eStorageBuffer, **m_spawn); + writer.buffer(*m_ray_sets[i], k_ray_hits_binding, vk::DescriptorType::eStorageBuffer, **m_hits); + writer.buffer(*m_ray_sets[i], k_ray_change_binding, vk::DescriptorType::eStorageBuffer, **m_change); + writer.buffer(*m_debug_sets[i], k_debug_change_binding, vk::DescriptorType::eStorageBuffer, **m_change); + } + writer.flush(core.getDevice()); +} + +void GiPass::recordChange(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView scene_view) { + ZoneScoped; + if (!m_change_pipeline.isReady() || !m_change.has_value() || !bindRaySets(frame_index, scene_view)) { + return; + } + const vk::PipelineLayout layout = *m_ray_layout.getPipelineLayout(); + cmd.bindPipeline(vk::PipelineBindPoint::eCompute, m_change_pipeline.getPipeline()); + cmd.bindDescriptorSets( + vk::PipelineBindPoint::eCompute, + layout, + 0, + std::array {m_ray_scene_sets.get(frame_index), *m_ray_storage_sets[frame_index], *m_ray_sets[frame_index]}, + {} + ); + cmd.dispatch(groups(m_extent.width, k_pixel_group), groups(m_extent.height, k_pixel_group), 1); +} + +void GiPass::gatherDirty() { + ZoneScoped; + const auto* frame = VulkanRenderer::instance->renderingFrame(); + const auto add = [&](glm::vec3 low, glm::vec3 high) { + if (m_dirty_scratch.size() < 2 * k_max_dirty) { + m_dirty_scratch.push_back(low); + m_dirty_scratch.push_back(high); + return; + } + auto last = m_dirty_scratch.end() - 2; + last[0] = glm::min(last[0], low); + last[1] = glm::max(last[1], high); + }; + + Stats& counts = m_dirty_pending; + const float threshold = std::max(VulkanRenderer::instance->giSettings().motion_threshold.load(std::memory_order_relaxed), 0.0f); + + for (const auto& proxy : frame->voxel_instances) { + if (proxy.has_previous && proxy.model == proxy.previous_model) { + continue; + } + const glm::vec3 previous = glm::vec3(proxy.previous_model * proxy.inverse_model * glm::vec4(proxy.bounds_center, 1.0f)); + if (proxy.has_previous) { + // Centre shift plus what the change of the linear part can move a point of the bounds + const glm::mat3 change = glm::mat3(proxy.model) - glm::mat3(proxy.previous_model); + const float spread = + std::sqrt(glm::dot(change[0], change[0]) + glm::dot(change[1], change[1]) + glm::dot(change[2], change[2])); + const float scale = std::max(glm::length(glm::vec3(proxy.model[0])), 1e-6f); + const float motion = glm::distance(proxy.bounds_center, previous) + (spread * proxy.bounds_radius / scale); + counts.largest_motion = std::max(counts.largest_motion, motion); + if (motion < threshold) { + continue; + } + ++counts.moved; + } else { + ++counts.appeared; + } + const glm::vec3 radius(proxy.bounds_radius); + add(glm::min(proxy.bounds_center, previous) - radius, glm::max(proxy.bounds_center, previous) + radius); + } + + bool known = true; + if (frame->voxel_storage) { + const uint64_t generation = frame->voxel_storage->generation(); + if (generation != m_region_generation && m_region_generation != 0) { + m_model_scratch.clear(); + for (const auto& proxy : frame->voxel_instances) { + m_model_scratch.emplace(proxy.node_rid, proxy.model * glm::scale(glm::mat4(1.0f), glm::vec3(voxel::k_voxel_size))); + } + known = frame->voxel_storage->changeHistory().forEachSince(m_region_generation, [&](const VoxelRegion& region) { + const auto found = m_model_scratch.find(region.node_rid); + if (found == m_model_scratch.end()) { + return; + } + ++counts.edits; + glm::vec3 low(std::numeric_limits::max()); + glm::vec3 high(std::numeric_limits::lowest()); + for (uint32_t corner = 0; corner < 8; ++corner) { + const glm::vec3 local( + (corner & 1) != 0 ? region.max.x : region.min.x, + (corner & 2) != 0 ? region.max.y : region.min.y, + (corner & 4) != 0 ? region.max.z : region.min.z + ); + const glm::vec3 world = glm::vec3(found->second * glm::vec4(local, 1.0f)); + low = glm::min(low, world); + high = glm::max(high, world); + } + add(low, high); + }); + } + m_region_generation = generation; + } + m_dirty_known = m_dirty_known && known; +} + +auto GiPass::writeDirty(uint32_t frame_index) -> bool { + m_dirty_stats[frame_index] = std::exchange(m_dirty_pending, Stats {}); + const bool known = std::exchange(m_dirty_known, true); + const auto count = static_cast(m_dirty_scratch.size() / 2); + const auto& allocation = m_dirty[frame_index].getAllocation(); + auto* mapped = static_cast(allocation.getInfo().pMappedData); + mapped[0] = glm::vec4(static_cast(count), 0.0f, 0.0f, 0.0f); + for (uint32_t i = 0; i < count * 2; ++i) { + mapped[1 + i] = glm::vec4(m_dirty_scratch[i], 0.0f); + } + allocation.flush(0, sizeof(glm::vec4) * (1 + (count * 2))); + m_dirty_scratch.clear(); + return known; +} + +auto GiPass::sceneBuffer(std::string_view name) const -> vk::Buffer { + const auto handle = [](const std::optional& buffer) { + return buffer.has_value() ? **buffer : vk::Buffer {}; + }; + if (name == "gGiFrame") { + return handle(m_published); + } + if (name == "gGiTableKeys") { + return handle(m_table_keys); + } + if (name == "gGiTableValues") { + return handle(m_table_values); + } + if (name == "gGiPool") { + return handle(m_pool); + } + return {}; +} + +auto GiPass::stats() const -> Stats { + std::scoped_lock lock(m_stats_mutex); + return m_stats; +} + +void GiPass::readStats(uint32_t frame_index) { + if (!m_readback_valid[frame_index]) { + return; + } + m_readback_valid[frame_index] = false; + const auto& allocation = m_readback[frame_index].getAllocation(); + allocation.invalidate(0, sizeof(uint32_t) * k_counter_words); + std::array words {}; + std::memcpy(words.data(), allocation.getInfo().pMappedData, sizeof(uint32_t) * k_counter_words); + + const uint32_t parity = m_readback_parity[frame_index]; + Stats stats; + stats.cascades = m_quality.cascades; + for (uint32_t i = 0; i < k_max_cascades; ++i) { + stats.probes[i] = std::min(words[(parity * 8) + i], m_cascades[i].capacity); + stats.seen[i] = words[k_counter_seen + i]; + stats.capacity[i] = m_cascades[i].capacity; + stats.deposits[i] = std::min(words[k_counter_seen + i], m_cascades[i].deposit_capacity); + stats.deposit_capacity[i] = m_cascades[i].deposit_capacity; + const uint64_t sampled = ((static_cast(stats.deposits[i]) * m_cascades[i].directions) + 63) / 64; + stats.depth[i] = sampled > 0 ? static_cast(words[k_counter_depth + i]) / (16.0f * static_cast(sampled)) : 0.0f; + } + stats.table_full = words[k_counter_table_full]; + stats.pool_full = words[k_counter_pool_full]; + stats.deposit_full = words[k_counter_deposit_full]; + stats.rays = words[k_counter_rays]; + stats.slots = std::min(words[k_counter_slots], k_deposit_slots); + stats.moved = m_dirty_stats[frame_index].moved; + stats.appeared = m_dirty_stats[frame_index].appeared; + stats.edits = m_dirty_stats[frame_index].edits; + stats.largest_motion = m_dirty_stats[frame_index].largest_motion; + stats.invalidated = words[k_counter_invalidated]; + stats.fresh = words[k_counter_fresh]; + stats.blend = m_blend; + const uint32_t budget = m_readback_point_budget[frame_index]; + stats.points = std::min(words[k_counter_points + parity], budget); + stats.points_dropped = words[k_counter_points_dropped]; + if (words[k_counter_spawn] > m_readback_spawn_capacity[frame_index] && !m_spawn_overflow_logged) { + m_spawn_overflow_logged = true; + TOAST_WARN( + "Render", + "GI spawn pixels ({}) exceed the spawn list ({}) so the extra pixels trace no rays this frame", + words[k_counter_spawn], + m_readback_spawn_capacity[frame_index] + ); + } + + // Hits offered at a known share give the whole demand so the share fills the budget to 90% without a race + const float offered = static_cast(words[k_counter_points + parity]); + const float demand = offered / std::max(m_readback_point_share[frame_index], 1e-3f); + m_point_share = demand > 0.0f ? std::clamp(0.9f * static_cast(budget) / demand, 1.0f / 64.0f, 1.0f) : 1.0f; + stats.point_share = m_point_share; + stats.quality = m_quality.level; + stats.ray_step = m_quality.step; + stats.update_interval = m_quality.interval; + stats.point_budget = m_quality.multibounce ? m_quality.points : 0; + + std::scoped_lock lock(m_stats_mutex); + m_stats = stats; +} + +void GiPass::bindProbeSet(uint32_t frame_index) { + const vk::ImageView depth_view = VulkanRenderer::instance->getDepthView(); + const vk::ImageView normal_view = VulkanRenderer::instance->getSceneNormalView(); + const std::array wanted {depth_view, normal_view}; + if (m_probe_bound[frame_index] == wanted) { + return; + } + DescriptorWriter writer; + writer.image( + *m_probe_sets[frame_index], + k_depth_binding, + vk::DescriptorType::eCombinedImageSampler, + *m_point_sampler, + depth_view, + vk::ImageLayout::eDepthReadOnlyOptimal + ); + writer.image( + *m_probe_sets[frame_index], + k_normal_binding, + vk::DescriptorType::eCombinedImageSampler, + *m_point_sampler, + normal_view, + vk::ImageLayout::eShaderReadOnlyOptimal + ); + writer.flush(m_core->getDevice()); + m_probe_bound[frame_index] = wanted; +} + +auto GiPass::bindRaySets(uint32_t frame_index, vk::ImageView scene_view) -> bool { + const auto* frame = VulkanRenderer::instance->renderingFrame(); + if (!m_ray_pipeline.isReady() || !scene_view || VulkanRenderer::instance->voxelTraceScene() == nullptr || + !frame->voxel_storage || !frame->voxel_storage->isReady()) { + return false; + } + // Safe since the fence of this slot was waited on + if (m_ray_storage_bound[frame_index] != frame->voxel_storage) { + writeVoxelStorageDescriptors(m_core->getDevice(), *m_ray_storage_sets[frame_index], *frame->voxel_storage); + m_ray_storage_bound[frame_index] = frame->voxel_storage; + } + + const vk::ImageView depth_view = VulkanRenderer::instance->getDepthView(); + const vk::ImageView normal_view = VulkanRenderer::instance->getSceneNormalView(); + const std::array wanted {depth_view, normal_view, scene_view}; + if (m_ray_bound[frame_index] != wanted) { + DescriptorWriter writer; + writer.image( + *m_ray_sets[frame_index], + k_ray_scene_colour_binding, + vk::DescriptorType::eCombinedImageSampler, + *m_point_sampler, + scene_view, + vk::ImageLayout::eShaderReadOnlyOptimal + ); + writer.image( + *m_ray_sets[frame_index], + k_ray_depth_binding, + vk::DescriptorType::eCombinedImageSampler, + *m_point_sampler, + depth_view, + vk::ImageLayout::eDepthReadOnlyOptimal + ); + writer.image( + *m_ray_sets[frame_index], + k_ray_normal_binding, + vk::DescriptorType::eCombinedImageSampler, + *m_point_sampler, + normal_view, + vk::ImageLayout::eShaderReadOnlyOptimal + ); + writer.flush(m_core->getDevice()); + m_ray_bound[frame_index] = wanted; + } + m_ray_scene_sets.updateTlas(frame_index); + return true; +} + +auto GiPass::recordTrace( + vk::CommandBuffer cmd, uint32_t frame_index, uint32_t spawn_x, uint32_t spawn_y, vk::ImageView scene_view +) -> bool { + ZoneScoped; + // Hits without a shade pass would queue and never deposit + if (!m_shade_pipeline.isReady() || !bindRaySets(frame_index, scene_view)) { + return false; + } + const vk::PipelineLayout layout = *m_ray_layout.getPipelineLayout(); + cmd.bindPipeline(vk::PipelineBindPoint::eCompute, m_ray_pipeline.getPipeline()); + cmd.bindDescriptorSets( + vk::PipelineBindPoint::eCompute, + layout, + 0, + std::array {m_ray_scene_sets.get(frame_index), *m_ray_storage_sets[frame_index], *m_ray_sets[frame_index]}, + {} + ); + cmd.dispatch(groups(std::min(spawn_x * spawn_y, m_spawn_capacity), k_group), 1, 1); + if (m_point_trace_pipeline.isReady()) { + cmd.bindPipeline(vk::PipelineBindPoint::eCompute, m_point_trace_pipeline.getPipeline()); + cmd.dispatch(groups(m_quality.points, k_group), 1, 1); + } + return true; +} + +void GiPass::recordShade(vk::CommandBuffer cmd, uint32_t frame_index, uint32_t rays) { + ZoneScoped; + if (!m_shade_pipeline.isReady()) { + return; + } + const vk::PipelineLayout layout = *m_ray_layout.getPipelineLayout(); + cmd.bindPipeline(vk::PipelineBindPoint::eCompute, m_shade_pipeline.getPipeline()); + cmd.bindDescriptorSets( + vk::PipelineBindPoint::eCompute, + layout, + 0, + std::array {m_ray_scene_sets.get(frame_index), *m_ray_storage_sets[frame_index], *m_ray_sets[frame_index]}, + {} + ); + cmd.dispatch(groups(rays, k_group), 1, 1); +} + +void GiPass::recordMerge(vk::CommandBuffer cmd, uint32_t frame_index, uint32_t cascades) { + ZoneScoped; + const vk::PipelineLayout layout = *m_merge_layout.getPipelineLayout(); + m_merge_scene_sets.updateTlas(frame_index); + cmd.bindDescriptorSets( + vk::PipelineBindPoint::eCompute, layout, 0, std::array {m_merge_scene_sets.get(frame_index), *m_merge_sets[frame_index]}, {} + ); + + cmd.bindPipeline(vk::PipelineBindPoint::eCompute, m_merge_pipeline.getPipeline()); + for (uint32_t i = cascades; i-- > 1;) { + cmd.pushConstants(layout, vk::ShaderStageFlagBits::eAll, 0, sizeof(uint32_t), &i); + cmd.dispatch(k_stride_groups, 1, 1); + computeBarrier(cmd); + } + + sync::BarrierBatch barriers; + barriers.image(m_atlas_state, sync::Usage::compute_storage_write); + barriers.flush(cmd); + cmd.bindPipeline(vk::PipelineBindPoint::eCompute, m_irradiance_pipeline.getPipeline()); + const uint32_t c0 = 0; + cmd.pushConstants(layout, vk::ShaderStageFlagBits::eAll, 0, sizeof(uint32_t), &c0); + // Mirrors gi_merge irradianceMain with two tiles per group + cmd.dispatch(k_atlas_tiles_per_row / 2, groups(m_cascades[0].deposit_capacity, k_atlas_tiles_per_row), 1); + barriers.image(m_atlas_state, sync::Usage::shader_sampled); + barriers.flush(cmd); +} + +auto GiPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView source_view) -> vk::ImageView { + ZoneScoped; + const auto* renderer = VulkanRenderer::instance; + const auto* frame = renderer->renderingFrame(); + const GiSettings& settings = renderer->giSettings(); + const bool enabled = settings.enabled.load(std::memory_order_relaxed); + // Lighting falls back to the flat ambient once the frame it reads says no GI + if (!enabled && m_published_valid) { + sync::BarrierBatch barriers; + barriers.memory(sync::Usage::fragment_storage_read, sync::Usage::transfer_dst); + barriers.memory(sync::Usage::compute_storage_read, sync::Usage::transfer_dst); + barriers.flush(cmd); + cmd.fillBuffer(**m_published, 0, vk::WholeSize, 0u); + barriers.memory(sync::Usage::transfer_dst, sync::Usage::fragment_storage_read); + barriers.memory(sync::Usage::transfer_dst, sync::Usage::compute_storage_read); + barriers.flush(cmd); + m_published_valid = false; + } + if (frame == nullptr || frame_index >= m_probe_sets.size() || !enabled || !renderer->getDepthView() || + !renderer->getSceneNormalView()) { + return source_view; + } + + // The fence of this slot was waited on so its last readback is complete + readStats(frame_index); + gatherDirty(); + + m_quality = resolveQuality(); + const Quality& quality = m_quality; + if (quality.level == GiSettings::k_quality_custom && + quality.step > std::clamp(settings.ray_step.load(std::memory_order_relaxed), 1u, 4u) && !m_spawn_step_logged) { + m_spawn_step_logged = true; + TOAST_INFO("Render", "GI custom ray step raised to {} to fit the spawn list", quality.step); + } + const uint32_t cascades = quality.cascades; + const uint32_t step = quality.step; + const float spacing = std::max(quality.spacing, 0.01f); + const KeySettings keys { + .spacing = spacing, + .lod_distance = std::clamp(quality.lod_distance, 0.25f, spacing * k_max_cells_to_lod), + }; + const bool carry = m_key_settings.has_value() && *m_key_settings == keys; + const uint32_t debug_view = settings.debug_view.load(std::memory_order_relaxed); + const uint32_t debug_mode = std::min(settings.debug_mode.load(std::memory_order_relaxed), 5u); + + // Lighting keeps reading the last published tables on skipped frames + if (carry && m_published_valid && debug_view == 0 && ++m_skipped < quality.interval) { + return source_view; + } + m_skipped = 0; + m_key_settings = keys; + + ++m_frame; + const uint32_t parity = m_frame & 1u; + const bool history_known = writeDirty(frame_index); + const auto now = std::chrono::steady_clock::now(); + const float frame_seconds = m_last_record == std::chrono::steady_clock::time_point {} + ? 1.0f / 60.0f + : std::clamp(std::chrono::duration(now - m_last_record).count(), 0.001f, 0.1f); + m_last_record = now; + const float history_seconds = settings.history_seconds.load(std::memory_order_relaxed); + float history = 1.0f; + if (settings.history_per_frame.load(std::memory_order_relaxed)) { + history = std::clamp(settings.history_blend.load(std::memory_order_relaxed), 0.005f, 1.0f); + } else if (history_seconds > 0.0f) { + history = std::clamp(1.0f - std::exp(-frame_seconds / history_seconds), 0.001f, 1.0f); + } + m_blend = history; + // Narrowest bucket of the top cascade so the march never coarsens past what any interval resolves + const float cone_slope = std::sqrt(4.0f * glm::pi() / static_cast(m_cascades[cascades - 1].directions)); + FrameData data { + .inverse_view_projection = glm::inverse(frame->frame_data.jittered_view_projection), + .camera = glm::vec4(frame->frame_data.camera_position, keys.spacing), + .lod = glm::vec4( + keys.lod_distance, + k_lod_overlap, + static_cast(settings.probe_lifetime.load(std::memory_order_relaxed)), + static_cast(step) + ), + .frame = glm::uvec4(m_frame, cascades, parity, carry ? 1u : 0u), + // The spawn pixel walks every position of its block so each pixel inserts once every step squared frames + .viewport = glm::uvec4(m_extent.width, m_extent.height, m_frame % step, (m_frame / step) % step), + .pool = glm::uvec4(m_pool_total, m_table_total, m_deposit_total, k_deposit_slots), + .rays = glm::vec4(k_interval_scale, static_cast(renderer->voxelSunReach()), k_deposit_fixed_scale, cone_slope), + .jitter = glm::vec4(r2Jitter(m_frame), history_known ? 1.0f - history : 0.0f, k_reset_history), + .secondary = glm::uvec4(k_max_points, quality.multibounce ? 1u : 0u, std::bit_cast(m_point_share), quality.points), + // Rare directions hold 2 / blend rays fading eight times slower than the blend so they average as many as busy ones + .limits = glm::vec4( + std::max(settings.max_ray_radiance.load(std::memory_order_relaxed), 0.01f), + history >= 1.0f ? 0.0f : 2.0f / history, + history_known ? 1.0f - (history / 8.0f) : 0.0f, + 0.0f + ), + }; + for (uint32_t i = 0; i < k_max_cascades; ++i) { + const Cascade& c = m_cascades[i]; + data.cascades[i] = glm::uvec4(c.table_offset, c.table_size - 1, c.pool_offset, c.capacity); + data.deposits[i] = glm::uvec4(c.deposit_offset, c.deposit_capacity, 0u, c.directions); + } + data.queues = glm::uvec4(m_spawn_capacity, m_spawn_capacity, m_spawn_capacity + k_max_points, 0u); + const auto& frame_allocation = m_frame_data[frame_index].getAllocation(); + std::memcpy(frame_allocation.getInfo().pMappedData, &data, sizeof(FrameData)); + frame_allocation.flush(0, sizeof(FrameData)); + + bindProbeSet(frame_index); + + // Lighting read the tables and pool of the last merge earlier in this frame + sync::BarrierBatch war; + war.memory(sync::Usage::fragment_storage_read, sync::Usage::compute_storage_write); + war.memory(sync::Usage::compute_storage_read, sync::Usage::compute_storage_write); + war.flush(cmd); + + const vk::PipelineLayout layout = *m_probe_layout.getPipelineLayout(); + cmd.bindDescriptorSets( + vk::PipelineBindPoint::eCompute, layout, 0, std::array {*m_probe_sets[frame_index]}, {} + ); + const auto dispatch = [&](const VulkanPipeline& pipeline, uint32_t cascade, uint32_t x, uint32_t y = 1, uint32_t carried = 0) { + cmd.bindPipeline(vk::PipelineBindPoint::eCompute, pipeline.getPipeline()); + const std::array push {cascade, carried}; + cmd.pushConstants(layout, vk::ShaderStageFlagBits::eAll, 0, sizeof(push), push.data()); + cmd.dispatch(x, y, 1); + }; + + GpuTimer* timer = VulkanRenderer::instance->gpuTimer(); + const uint32_t spawn_x = (m_extent.width + step - 1) / step; + const uint32_t spawn_y = (m_extent.height + step - 1) / step; + { + const GpuScope scope(timer, frame_index, cmd, "GI probes"); + dispatch(m_clear_pipeline, 0, groups(m_table_total, k_group)); + computeBarrier(cmd); + for (uint32_t i = 0; i < cascades; ++i) { + dispatch(m_carry_pipeline, i, groups(m_cascades[i].capacity, k_group)); + } + computeBarrier(cmd); + dispatch(m_pixel_pipeline, 0, groups(spawn_x, k_pixel_group), groups(spawn_y, k_pixel_group)); + dispatch(m_point_pipeline, 0, groups(quality.points, k_group)); + computeBarrier(cmd); + for (uint32_t i = 0; i + 1 < cascades; ++i) { + dispatch(m_parent_pipeline, i, groups(m_cascades[i].table_size, k_group)); + computeBarrier(cmd); + } + for (uint32_t i = 0; i < cascades; ++i) { + dispatch(m_compact_pipeline, i, groups(m_cascades[i].table_size, k_group)); + } + computeBarrier(cmd); + for (uint32_t i = 0; i < cascades; ++i) { + dispatch(m_keep_pipeline, i, groups(m_cascades[i].capacity, k_group)); + } + computeBarrier(cmd); + } + { + const GpuScope scope(timer, frame_index, cmd, "GI deposits"); + for (uint32_t carried = 0; carried < 2; ++carried) { + for (uint32_t i = cascades; i-- > 0;) { + dispatch(m_slots_pipeline, i, groups(m_cascades[i].deposit_capacity, k_group), 1, carried); + computeBarrier(cmd); + } + } + dispatch(m_deposit_clear_pipeline, 0, k_stride_groups); + for (uint32_t i = 0; i + 1 < cascades; ++i) { + dispatch(m_link_pipeline, i, groups(m_cascades[i].deposit_capacity, k_group)); + } + dispatch(m_count_pipeline, 0, groups(spawn_x, k_pixel_group), groups(spawn_y, k_pixel_group)); + dispatch(m_point_count_pipeline, 0, groups(quality.points, k_group)); + computeBarrier(cmd); + for (uint32_t i = 0; i + 1 < cascades; ++i) { + dispatch(m_propagate_pipeline, i, groups(m_cascades[i].deposit_capacity, k_group)); + computeBarrier(cmd); + } + for (uint32_t i = cascades; i-- > 0;) { + dispatch(m_offset_pipeline, i, groups(m_cascades[i].deposit_capacity, k_group)); + computeBarrier(cmd); + } + } + bool traced = false; + { + const GpuScope scope(timer, frame_index, cmd, "GI trace"); + traced = recordTrace(cmd, frame_index, spawn_x, spawn_y, source_view); + // Runs without a trace too so a scene with no voxels still sees the sky + computeBarrier(cmd); + } + if (traced) { + const GpuScope scope(timer, frame_index, cmd, "GI shade"); + recordShade(cmd, frame_index, std::min(spawn_x * spawn_y, m_spawn_capacity) + m_quality.points); + computeBarrier(cmd); + } + { + const GpuScope scope(timer, frame_index, cmd, "GI merge"); + recordMerge(cmd, frame_index, cascades); + } + if (debug_view > 0 && debug_mode == 5) { + recordChange(cmd, frame_index, source_view); + } + + sync::BarrierBatch barriers; + // The copy of the frame before wrote the published frame too + barriers.memory(sync::Usage::transfer_dst, sync::Usage::transfer_dst); + barriers.memory(sync::Usage::compute_storage_write, sync::Usage::transfer_src); + barriers.memory(sync::Usage::compute_storage_write, sync::Usage::fragment_storage_read); + barriers.memory(sync::Usage::compute_storage_write, sync::Usage::compute_storage_read); + barriers.flush(cmd); + cmd.copyBuffer(**m_counters, *m_readback[frame_index], vk::BufferCopy(0, 0, sizeof(uint32_t) * k_counter_words)); + cmd.copyBuffer(*m_frame_data[frame_index], **m_published, vk::BufferCopy(0, 0, sizeof(FrameData))); + m_published_valid = true; + barriers.memory(sync::Usage::transfer_dst, sync::Usage::host_read); + barriers.memory(sync::Usage::transfer_dst, sync::Usage::fragment_storage_read); + barriers.memory(sync::Usage::transfer_dst, sync::Usage::compute_storage_read); + barriers.flush(cmd); + m_readback_valid[frame_index] = true; + m_readback_parity[frame_index] = parity; + m_readback_point_share[frame_index] = m_point_share; + m_readback_point_budget[frame_index] = quality.points; + m_readback_spawn_capacity[frame_index] = m_spawn_capacity; + + if (debug_view == 0 || !source_view || !m_debug_pipeline.isReady() || !m_debug_target.isReady()) { + return source_view; + } + return recordDebug(cmd, frame_index, source_view, debug_view - 1, debug_mode); +} + +auto GiPass::recordDebug(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView source_view, uint32_t cascade, uint32_t mode) + -> vk::ImageView { + ZoneScoped; + const vk::ImageView depth_view = VulkanRenderer::instance->getDepthView(); + const vk::ImageView normal_view = VulkanRenderer::instance->getSceneNormalView(); + const std::array wanted {source_view, depth_view, normal_view}; + if (m_debug_bound[frame_index] != wanted) { + DescriptorWriter writer; + writer.image( + *m_debug_sets[frame_index], + k_debug_source_binding, + vk::DescriptorType::eCombinedImageSampler, + *m_point_sampler, + source_view, + vk::ImageLayout::eShaderReadOnlyOptimal + ); + writer.image( + *m_debug_sets[frame_index], + k_debug_depth_binding, + vk::DescriptorType::eCombinedImageSampler, + *m_point_sampler, + depth_view, + vk::ImageLayout::eDepthReadOnlyOptimal + ); + writer.image( + *m_debug_sets[frame_index], + k_debug_normal_binding, + vk::DescriptorType::eCombinedImageSampler, + *m_point_sampler, + normal_view, + vk::ImageLayout::eShaderReadOnlyOptimal + ); + writer.flush(m_core->getDevice()); + m_debug_bound[frame_index] = wanted; + } + + const vk::PipelineLayout layout = *m_debug_layout.getPipelineLayout(); + m_debug_target.beginScope(cmd); + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, m_debug_pipeline.getPipeline()); + cmd.bindDescriptorSets( + vk::PipelineBindPoint::eGraphics, layout, 0, std::array {*m_debug_sets[frame_index]}, {} + ); + const std::array push {cascade, mode}; + cmd.pushConstants(layout, vk::ShaderStageFlagBits::eAll, 0, sizeof(push), push.data()); + cmd.draw(3, 1, 0, 0); + m_debug_target.endScope(cmd); + return m_debug_target.view(); +} + +} diff --git a/engine/src/toast/renderer/passes/gi_pass.hpp b/engine/src/toast/renderer/passes/gi_pass.hpp new file mode 100644 index 000000000..a20eea548 --- /dev/null +++ b/engine/src/toast/renderer/passes/gi_pass.hpp @@ -0,0 +1,321 @@ +/// @file gi_pass.hpp +/// @author dario +/// @date 22/10/2026 + +#pragma once + +#include "../gpu_sync.hpp" +#include "../post_process_pass_base.hpp" +#include "../post_process_target.hpp" +#include "../scene_descriptor_set.hpp" +#include "../shader_layout.hpp" +#include "../vulkan_common.hpp" +#include "../vulkan_pipeline.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace renderer { +class VulkanCore; +class VoxelGpuStorage; + +class GiPass : public IPostProcessPass { +public: + static constexpr uint32_t k_max_cascades = 6; + /// c0 probes and every cascade above holds a quarter down to k_min_capacity + static constexpr uint32_t k_base_capacity = 65536; + static constexpr uint32_t k_min_capacity = 1024; + /// Shared by every cascade and handed out top cascade first + static constexpr uint32_t k_deposit_slots = 1u << 23; + /// Mirrors gi_common kGiAtlasTilesPerRow and kGiAtlasRows and caps the c0 deposit probes at one tile each + static constexpr uint32_t k_atlas_tiles_per_row = 256; + static constexpr uint32_t k_atlas_rows = 128; + static constexpr uint32_t k_atlas_tiles = k_atlas_tiles_per_row * k_atlas_rows; + static constexpr uint32_t k_max_points = 65536; + + struct Stats { + uint32_t cascades = 0; + std::array probes {}; + /// Probes seen within gi_common kGiKeepFrames and the rest are carried without storage + std::array seen {}; + std::array capacity {}; + std::array deposits {}; + std::array deposit_capacity {}; + /// Mean rays of history behind one direction of a deposit probe + std::array depth {}; + uint32_t table_full = 0; + uint32_t pool_full = 0; + uint32_t deposit_full = 0; + uint32_t rays = 0; + uint32_t slots = 0; + uint32_t moved = 0; + uint32_t appeared = 0; + uint32_t edits = 0; + /// Meters including bodies under the motion threshold so physics jitter shows up + float largest_motion = 0.0f; + uint32_t invalidated = 0; + uint32_t fresh = 0; + float blend = 0.0f; + /// Hit points the last trace stored for the secondary cache and the ones past the buffer + uint32_t points = 0; + uint32_t points_dropped = 0; + float point_share = 0.0f; + uint32_t quality = 0; + uint32_t ray_step = 0; + uint32_t update_interval = 0; + uint32_t point_budget = 0; + }; + + GiPass(const VulkanCore& core, vk::Format scene_format, vk::Extent2D extent); + + [[nodiscard]] + auto name() const -> std::string_view override { + return "Radiance cascades"; + } + + auto record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView source_view) -> vk::ImageView override; + + void onResize(vk::Extent2D extent) override; + + /// A few frames old since it is read back once the fence of its frame passed + [[nodiscard]] + auto stats() const -> Stats; + + /// Set 0 buffers lighting samples by gi_sample binding name + [[nodiscard]] + auto sceneBuffer(std::string_view name) const -> vk::Buffer; + + [[nodiscard]] + auto irradianceView() const -> vk::ImageView { + return m_atlas_view.has_value() ? **m_atlas_view : vk::ImageView {}; + } + + [[nodiscard]] + auto irradianceSampler() const -> vk::Sampler { + return *m_linear_sampler; + } + +private: + /// Mirrors gi_common GiFrame + struct FrameData { + glm::mat4 inverse_view_projection {1.0f}; + glm::vec4 camera {0.0f}; + glm::vec4 lod {0.0f}; + glm::uvec4 frame {0u}; + glm::uvec4 viewport {0u}; + glm::uvec4 pool {0u}; + glm::vec4 rays {0.0f}; + glm::vec4 jitter {0.0f}; + glm::uvec4 secondary {0u}; + glm::vec4 limits {0.0f}; + std::array cascades {}; + std::array deposits {}; + glm::uvec4 queues {0u}; + }; + + /// Mirrors gi_common kGiCounter layout with counts per parity then table full pool full and seen per cascade + static constexpr uint32_t k_counter_words = 44; + static constexpr uint32_t k_counter_table_full = 16; + static constexpr uint32_t k_counter_pool_full = 17; + static constexpr uint32_t k_counter_seen = 18; + static constexpr uint32_t k_counter_deposit_full = 24; + static constexpr uint32_t k_counter_rays = 25; + static constexpr uint32_t k_counter_slots = 26; + static constexpr uint32_t k_counter_invalidated = 27; + static constexpr uint32_t k_counter_points = 28; + static constexpr uint32_t k_counter_points_dropped = 30; + static constexpr uint32_t k_counter_fresh = 31; + /// Sixteenths of the history rays of every 64th bucket per cascade + static constexpr uint32_t k_counter_depth = 32; + /// Pixels with a deposit that may exceed the spawn list + static constexpr uint32_t k_counter_spawn = 38; + /// Hits queued by pixel rays + static constexpr uint32_t k_counter_pixel_hits = 39; + /// Hits queued by point rays + static constexpr uint32_t k_counter_point_hits = 40; + /// Mirrors gi_probes gDirty and past it every extra region grows the last box + static constexpr uint32_t k_max_dirty = 64; + + struct Cascade { + uint32_t table_offset = 0; + uint32_t table_size = 0; + uint32_t pool_offset = 0; + uint32_t capacity = 0; + uint32_t deposit_offset = 0; + uint32_t deposit_capacity = 0; + uint32_t directions = 0; + }; + + /// The cost knobs of renderer.gi.quality + struct Quality { + uint32_t level = 0; + uint32_t step = 2; + uint32_t cascades = 5; + float spacing = 0.1f; + float lod_distance = 4.0f; + bool multibounce = true; + uint32_t points = k_max_points; + uint32_t interval = 1; + }; + + /// What decides a probe key so changing it drops every carried probe + struct KeySettings { + float spacing = 0.0f; + float lod_distance = 0.0f; + [[nodiscard]] + auto operator==(const KeySettings&) const -> bool = default; + }; + + void createLayout(); + void createBuffers(const VulkanCore& core); + auto createPipelines(const VulkanCore& core) -> bool; + void readStats(uint32_t frame_index); + void bindProbeSet(uint32_t frame_index); + auto bindRaySets(uint32_t frame_index, vk::ImageView scene_view) -> bool; + /// False when the ray sets could not bind so nothing was traced + auto recordTrace(vk::CommandBuffer cmd, uint32_t frame_index, uint32_t spawn_x, uint32_t spawn_y, vk::ImageView scene_view) + -> bool; + void recordShade(vk::CommandBuffer cmd, uint32_t frame_index, uint32_t rays); + void recordMerge(vk::CommandBuffer cmd, uint32_t frame_index, uint32_t cascades); + void recordChange(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView scene_view); + /// Sized to the viewport so it is rebuilt on resize while the device is idle + void createChange(const VulkanCore& core); + auto resolveQuality() const -> Quality; + /// Every rendered frame so the boxes of skipped GI frames still reach the next one + void gatherDirty(); + /// @return false when the edits are no longer known so every probe must drop its history + auto writeDirty(uint32_t frame_index) -> bool; + auto recordDebug(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView source_view, uint32_t cascade, uint32_t mode) + -> vk::ImageView; + + const VulkanCore* m_core = nullptr; + vk::Format m_scene_format = vk::Format::eUndefined; + vk::Extent2D m_extent {}; + + std::array m_cascades {}; + uint32_t m_table_total = 0; + uint32_t m_pool_total = 0; + uint32_t m_deposit_total = 0; + + ShaderLayout m_probe_layout; + VulkanPipeline m_clear_pipeline; + VulkanPipeline m_carry_pipeline; + VulkanPipeline m_pixel_pipeline; + VulkanPipeline m_point_pipeline; + VulkanPipeline m_parent_pipeline; + VulkanPipeline m_compact_pipeline; + VulkanPipeline m_keep_pipeline; + VulkanPipeline m_slots_pipeline; + VulkanPipeline m_deposit_clear_pipeline; + VulkanPipeline m_link_pipeline; + VulkanPipeline m_count_pipeline; + VulkanPipeline m_point_count_pipeline; + VulkanPipeline m_propagate_pipeline; + VulkanPipeline m_offset_pipeline; + + /// gi_rays with the scene in set 0 the voxel storage and trace scene in set 1 and the probes in set 2 + ShaderLayout m_ray_layout; + VulkanPipeline m_ray_pipeline; + VulkanPipeline m_point_trace_pipeline; + VulkanPipeline m_shade_pipeline; + VulkanPipeline m_change_pipeline; + SceneDescriptorSets m_ray_scene_sets; + std::vector m_ray_storage_sets; + std::vector> m_ray_storage_bound; + std::vector m_ray_sets; + std::vector> m_ray_bound; + /// Reflection keeps gVoxelInstances only when the trace code reads it + bool m_ray_instances_bound = false; + + /// gi_merge with the scene in set 0 for the sky and the cascades in set 1 + ShaderLayout m_merge_layout; + VulkanPipeline m_merge_pipeline; + VulkanPipeline m_irradiance_pipeline; + SceneDescriptorSets m_merge_scene_sets; + std::vector m_merge_sets; + + ShaderLayout m_debug_layout; + VulkanPipeline m_debug_pipeline; + PostProcessTarget m_debug_target; + + vk::raii::Sampler m_point_sampler = nullptr; + vk::raii::Sampler m_linear_sampler = nullptr; + + std::optional m_table_keys; + std::optional m_table_flags; + std::optional m_table_previous; + std::optional m_table_values; + std::optional m_table_cells; + std::optional m_pool; + std::optional m_counters; + std::optional m_deposit_pool; + std::optional m_parent; + std::optional m_ray_counts; + std::optional m_deposits; + std::optional m_deposit_slots; + std::optional m_neighbours; + std::optional m_merged; + /// FrameData of the last merge so lighting next frame reads the frame its tables belong to + std::optional m_published; + /// Two halves by pool parity of gi_common GiHistory + std::optional m_history; + std::optional m_history_source; + /// Mirrors gi_common GiPoint in two halves by pool parity + std::optional m_points; + /// Per pixel float4 of gi_rays changeMain + std::optional m_change; + /// Pixels with a deposit packed x | y << 16 sized to the most any preset spawns + std::optional m_spawn; + uint32_t m_spawn_capacity = 0; + /// Off screen hits as uint4 with the pixel rays in the spawn capacity then the point rays in k_max_points + std::optional m_hits; + bool m_spawn_overflow_logged = false; + bool m_spawn_step_logged = false; + std::vector m_dirty; + + std::vector m_dirty_stats; + std::vector m_dirty_scratch; + Stats m_dirty_pending; + bool m_dirty_known = true; + std::unordered_map m_model_scratch; + uint64_t m_region_generation = 0; + uint32_t m_skipped = 0; + Quality m_quality; + /// Kept in shader read layout between frames + std::optional m_atlas; + std::optional m_atlas_view; + sync::ImageState m_atlas_state; + std::vector m_frame_data; + std::vector m_readback; + std::vector m_readback_valid; + /// Pool parity the counts in each readback belong to + std::vector m_readback_parity; + /// Share of off screen hits each readback frame kept so the next share can size the demand + std::vector m_readback_point_share; + std::vector m_readback_point_budget; + /// Spawn list capacity each readback frame was recorded with + std::vector m_readback_spawn_capacity; + float m_point_share = 0.5f; + + std::vector m_probe_sets; + std::vector> m_probe_bound; + std::vector m_debug_sets; + std::vector> m_debug_bound; + + bool m_published_valid = false; + uint32_t m_frame = 0; + std::chrono::steady_clock::time_point m_last_record; + float m_blend = 0.05f; + std::optional m_key_settings; + + mutable std::mutex m_stats_mutex; + Stats m_stats; +}; + +} diff --git a/engine/src/toast/renderer/passes/perf_window.cpp b/engine/src/toast/renderer/passes/perf_window.cpp index 701a37aee..70fcf4b4e 100644 --- a/engine/src/toast/renderer/passes/perf_window.cpp +++ b/engine/src/toast/renderer/passes/perf_window.cpp @@ -7,6 +7,7 @@ #include "../async_compute.hpp" #include "../shadow_constants.hpp" #include "../vulkan_core.hpp" +#include "gi_pass.hpp" #include #include @@ -862,6 +863,89 @@ void PerfWindow::drawSceneTab() { row("Lights", std::format("{} of {} submitted", frame->lights.size(), frame->light_stats.submitted)); ImGui::EndTable(); } + + const GiPass* gi = VulkanRenderer::instance->giPass(); + if (gi == nullptr || !gi->isEnabled()) { + return; + } + ImGui::SeparatorText("Radiance cascades"); + const GiPass::Stats stats = gi->stats(); + constexpr std::array k_quality_names {"Low", "Medium", "High", "Epic", "Custom"}; + const char* quality_name = k_quality_names[std::min(stats.quality, k_quality_names.size() - 1)]; + if (stats.point_budget > 0) { + ImGui::Text( + "Quality %s, ray step %u, update every %u frames, %u multibounce points", + quality_name, + stats.ray_step, + stats.update_interval, + stats.point_budget + ); + } else { + ImGui::Text( + "Quality %s, ray step %u, update every %u frames, multibounce off", quality_name, stats.ray_step, stats.update_interval + ); + } + if (ImGui::BeginTable("##perf_gi", 6, ImGuiTableFlags_SizingFixedFit)) { + ImGui::TableSetupColumn("Cascade"); + ImGui::TableSetupColumn("Probes"); + ImGui::TableSetupColumn("Active"); + ImGui::TableSetupColumn("Pool"); + ImGui::TableSetupColumn("Deposits"); + ImGui::TableSetupColumn("Depth"); + ImGui::TableHeadersRow(); + for (uint32_t i = 0; i < stats.cascades && i < GiPass::k_max_cascades; ++i) { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::Text("c%u", i); + ImGui::TableNextColumn(); + ImGui::Text("%u", stats.probes[i]); + ImGui::TableNextColumn(); + ImGui::Text("%u", stats.seen[i]); + ImGui::TableNextColumn(); + ImGui::Text( + "%.0f%%", + stats.capacity[i] > 0 ? 100.0f * static_cast(stats.probes[i]) / static_cast(stats.capacity[i]) : 0.0f + ); + ImGui::TableNextColumn(); + ImGui::Text("%u of %u", stats.deposits[i], stats.deposit_capacity[i]); + ImGui::TableNextColumn(); + ImGui::Text("%.1f rays", stats.depth[i]); + } + ImGui::EndTable(); + } + ImGui::Text( + "%u rays, %u of %u direction slots (%.0f%%)", + stats.rays, + stats.slots, + GiPass::k_deposit_slots, + 100.0f * static_cast(stats.slots) / static_cast(GiPass::k_deposit_slots) + ); + ImGui::Text( + "Changes: %u moved, %u new, %u edits reset %u probes. Largest motion %.2f mm", + stats.moved, + stats.appeared, + stats.edits, + stats.invalidated, + stats.largest_motion * 1000.0f + ); + ImGui::Text("History: %.1f%% new light a frame, %u probes started without any this frame", stats.blend * 100.0f, stats.fresh); + ImGui::Text( + "Multibounce: %u off screen hits seeded the secondary cache, %.0f%% offered (%u dropped)", + stats.points, + stats.point_share * 100.0f, + stats.points_dropped + ); + if (stats.deposit_full > 0) { + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.3f, 1.0f), "%u seen probes got no deposit room and no rays", stats.deposit_full); + } + if (stats.table_full > 0 || stats.pool_full > 0) { + ImGui::TextColored( + ImVec4(1.0f, 0.5f, 0.3f, 1.0f), + "Dropped %u insertions on full tables and %u on full pools", + stats.table_full, + stats.pool_full + ); + } } void PerfWindow::drawShadowsTab() { diff --git a/engine/src/toast/renderer/passes/ssao_pass.cpp b/engine/src/toast/renderer/passes/ssao_pass.cpp index cffa855e2..cc5ba3708 100644 --- a/engine/src/toast/renderer/passes/ssao_pass.cpp +++ b/engine/src/toast/renderer/passes/ssao_pass.cpp @@ -150,7 +150,8 @@ auto SsaoPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView const auto* frame = VulkanRenderer::instance->renderingFrame(); const auto settings = frame != nullptr ? frame->post_process.ssao : VulkanRenderer::PostProcessSettings::Ssao {}; - const glm::mat4 view_projection = frame != nullptr ? frame->frame_data.view_projection : glm::mat4(1.0f); + // Jittered since depth was rasterized with it + const glm::mat4 view_projection = frame != nullptr ? frame->frame_data.jittered_view_projection : glm::mat4(1.0f); const glm::vec3 camera_position = frame != nullptr ? frame->frame_data.camera_position : glm::vec3(0.0f); Params params {}; @@ -164,8 +165,8 @@ auto SsaoPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView params.screen_size = glm::vec4( static_cast(size.width), static_cast(size.height), - 1.0f / static_cast(size.width), - 1.0f / static_cast(size.height) + static_cast(frame != nullptr ? frame->sequence % 4096 : 0), + 0.0f ); target.beginScope(cmd); diff --git a/engine/src/toast/renderer/passes/ssao_pass.hpp b/engine/src/toast/renderer/passes/ssao_pass.hpp index a68b06fdc..46be8e03a 100644 --- a/engine/src/toast/renderer/passes/ssao_pass.hpp +++ b/engine/src/toast/renderer/passes/ssao_pass.hpp @@ -37,6 +37,7 @@ class SsaoPass : public IPostProcessPass { struct Params { glm::mat4 view_projection {1.0f}; glm::mat4 inverse_view_projection {1.0f}; + /// xy target size z frame index for the noise glm::vec4 screen_size {0.0f}; /// x sample radius y strength z depth cutoff w sample count glm::vec4 tuning {0.0f}; diff --git a/engine/src/toast/renderer/passes/ssr_pass.cpp b/engine/src/toast/renderer/passes/ssr_pass.cpp index e0ad92a5d..41750a5a6 100644 --- a/engine/src/toast/renderer/passes/ssr_pass.cpp +++ b/engine/src/toast/renderer/passes/ssr_pass.cpp @@ -100,7 +100,7 @@ void SsrPass::createResources(const VulkanCore& core) { const vk::DescriptorPool pool = VulkanRenderer::instance->getDescriptorPoolHandle(); m_descriptor_sets.clear(); - m_bound_views.assign(VulkanRenderer::k_frames_in_flight * 2, vk::ImageView {}); + m_bound_views.assign(VulkanRenderer::k_frames_in_flight * 2, {}); for (uint32_t i = 0; i < VulkanRenderer::k_frames_in_flight * 2; ++i) { const vk::DescriptorSetAllocateInfo alloc_info(pool, 1, &set_layout); auto allocated = device.allocateDescriptorSets(alloc_info); @@ -116,7 +116,7 @@ void SsrPass::createTargets(const VulkanCore& core, vk::Extent2D extent) { m_resolve_target.create(core, half, k_reflection_format, "SsrPass Resolve"); m_history[0].create(core, half, k_reflection_format, "SsrPass History0"); m_history[1].create(core, half, k_reflection_format, "SsrPass History1"); - std::ranges::fill(m_bound_views, vk::ImageView {}); + std::ranges::fill(m_bound_views, std::array {}); m_history_valid = false; } @@ -143,12 +143,21 @@ auto SsrPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView return source_view; } + const auto* frame = VulkanRenderer::instance->renderingFrame(); + const uint64_t sequence = frame != nullptr ? frame->sequence : 0; + // A repeated snapshot carries no new sample so the last output stands + if (m_history_valid && sequence == m_last_sequence) { + return m_target.view(); + } + const PostProcessTarget& history_write = m_history[m_parity]; const PostProcessTarget& history_read = m_history[1 - m_parity]; const uint32_t set_index = (frame_index * 2) + m_parity; const vk::DescriptorSet set = *m_descriptor_sets[set_index]; - if (m_bound_views[set_index] != source_view) { + const vk::ImageView previous_view = VulkanRenderer::instance->previousResolvedColour(); + const std::array wanted {source_view, previous_view ? previous_view : source_view}; + if (m_bound_views[set_index] != wanted) { // Point sampled except the colours since interpolated depth or normals invent surfaces const std::array image_infos { vk::DescriptorImageInfo(*m_sampler, source_view, vk::ImageLayout::eShaderReadOnlyOptimal), @@ -161,6 +170,7 @@ auto SsrPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView vk::DescriptorImageInfo(*m_point_sampler, motion_view, vk::ImageLayout::eShaderReadOnlyOptimal), // Point sampled since the upsample picks its own texels vk::DescriptorImageInfo(*m_point_sampler, history_write.view(), vk::ImageLayout::eShaderReadOnlyOptimal), + vk::DescriptorImageInfo(*m_sampler, wanted[1], vk::ImageLayout::eShaderReadOnlyOptimal), }; std::array writes {}; @@ -169,18 +179,17 @@ auto SsrPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView vk::WriteDescriptorSet(set, binding, 0, 1, vk::DescriptorType::eCombinedImageSampler, &image_infos[binding]); } m_core->getDevice().updateDescriptorSets(writes, {}); - m_bound_views[set_index] = source_view; + m_bound_views[set_index] = wanted; } - const auto* frame = VulkanRenderer::instance->renderingFrame(); - const uint64_t sequence = frame != nullptr ? frame->sequence : 0; const bool history_valid = m_history_valid && sequence == m_last_sequence + 1; if (!history_valid) { history_read.makeSampleable(cmd); } const auto settings = frame != nullptr ? frame->post_process.ssr : VulkanRenderer::PostProcessSettings::Ssr {}; - const glm::mat4 view_projection = frame != nullptr ? frame->frame_data.view_projection : glm::mat4(1.0f); + // Jittered since depth was rasterized with it + const glm::mat4 view_projection = frame != nullptr ? frame->frame_data.jittered_view_projection : glm::mat4(1.0f); Params params {}; params.view_projection = view_projection; @@ -193,7 +202,7 @@ auto SsrPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView settings.stride * static_cast(settings.max_steps), static_cast(frame != nullptr ? frame->render_mode : 0u), history_valid ? 1.0f : 0.0f, - 0.0f + previous_view ? 1.0f : 0.0f ); const auto draw = [&](const PostProcessTarget& target, const VulkanPipeline& pipeline) { diff --git a/engine/src/toast/renderer/passes/ssr_pass.hpp b/engine/src/toast/renderer/passes/ssr_pass.hpp index 1498b4d8e..571d98833 100644 --- a/engine/src/toast/renderer/passes/ssr_pass.hpp +++ b/engine/src/toast/renderer/passes/ssr_pass.hpp @@ -43,7 +43,7 @@ class SsrPass : public IPostProcessPass { glm::vec4 camera_position {0.0f}; /// x intensity y max roughness z thickness w step count glm::vec4 tuning {0.0f}; - /// x reach in meters y render mode z 1 with a valid history + /// x reach in meters y render mode z 1 with a valid history w 1 when binding 8 holds last frame resolve glm::vec4 marching {0.0f}; }; @@ -53,7 +53,7 @@ class SsrPass : public IPostProcessPass { static constexpr vk::Format k_reflection_format = vk::Format::eR16G16B16A16Sfloat; /// Bindings of ssr.slang in order - static constexpr uint32_t k_binding_count = 8; + static constexpr uint32_t k_binding_count = 9; void createResources(const VulkanCore& core); void createTargets(const VulkanCore& core, vk::Extent2D extent); @@ -72,7 +72,8 @@ class SsrPass : public IPostProcessPass { /// Per frame in flight then per history parity std::vector m_descriptor_sets; - std::vector m_bound_views; + /// Source then previous resolve last written into each set + std::vector> m_bound_views; PostProcessTarget m_hit_target; PostProcessTarget m_resolve_target; diff --git a/engine/src/toast/renderer/passes/taa_pass.hpp b/engine/src/toast/renderer/passes/taa_pass.hpp index 12a456182..bd6b9c446 100644 --- a/engine/src/toast/renderer/passes/taa_pass.hpp +++ b/engine/src/toast/renderer/passes/taa_pass.hpp @@ -35,6 +35,12 @@ class TaaPass : public IPostProcessPass { void onResize(vk::Extent2D extent) override; + /// Last frame resolve or null without one + [[nodiscard]] + auto resolvedHistory() const noexcept -> vk::ImageView { + return m_history_valid ? m_targets[m_write ^ 1u].view() : vk::ImageView {}; + } + private: /// Mirrors taa.slang TaaParams struct Params { diff --git a/engine/src/toast/renderer/passes/traced_shadow_pass.cpp b/engine/src/toast/renderer/passes/traced_shadow_pass.cpp index 813bff9dc..73851ae82 100644 --- a/engine/src/toast/renderer/passes/traced_shadow_pass.cpp +++ b/engine/src/toast/renderer/passes/traced_shadow_pass.cpp @@ -154,7 +154,8 @@ auto TracedShadowPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::I } Params params {}; - params.inverse_view_projection = glm::inverse(frame->frame_data.view_projection); + // Jittered since depth was rasterized with it + params.inverse_view_projection = glm::inverse(frame->frame_data.jittered_view_projection); params.light_direction = light_direction; const float mode_flag = render_mode == k_mode_applied ? 1.0f : 0.0f; params.tuning = glm::vec4(mode_flag, k_normal_bias, k_max_trace_distance, 0.0f); diff --git a/engine/src/toast/renderer/passes/traced_specular_pass.cpp b/engine/src/toast/renderer/passes/traced_specular_pass.cpp new file mode 100644 index 000000000..bda272d37 --- /dev/null +++ b/engine/src/toast/renderer/passes/traced_specular_pass.cpp @@ -0,0 +1,273 @@ +/** + * @file traced_specular_pass.cpp + * @author dario + * @date 25/10/2026 + */ + +#include "traced_specular_pass.hpp" + +#include "../descriptor_writer.hpp" +#include "../shader_cache.hpp" +#include "../shader_compiler.hpp" +#include "../voxel_blas_cache.hpp" +#include "../voxel_gpu_storage.hpp" +#include "../voxel_trace_scene.hpp" +#include "../vulkan_core.hpp" +#include "../vulkan_debug.hpp" +#include "../vulkan_renderer.hpp" + +#include +#include +#include +#include +#include + +namespace renderer { + +namespace { + +constexpr uint32_t k_specular_scene_set = 0; +constexpr uint32_t k_specular_storage_set = 1; +constexpr uint32_t k_specular_image_set = 2; + +/// voxel_dda.slang gVoxelInstances and scene_trace.slang bindings in the storage set +constexpr uint32_t k_specular_instances_binding = 6; +constexpr uint32_t k_specular_trace_volumes_binding = 10; +constexpr uint32_t k_specular_trace_bounds_binding = 11; +constexpr uint32_t k_specular_trace_grid_binding = 12; +constexpr uint32_t k_specular_trace_brick_bases_binding = 14; +constexpr uint32_t k_specular_trace_bricks_binding = 15; + +/// voxel_specular.slang gDepth +constexpr uint32_t k_specular_depth_binding = 1; + +} + +TracedSpecularPass::TracedSpecularPass(const VulkanCore& core, vk::Format scene_format, vk::Extent2D extent) + : m_core(&core), + m_scene_format(scene_format) { + ZoneScoped; + const auto uid = assets::resolveURI("core://shaders/voxel_specular.slang"); + const auto shader = uid.has_value() ? ShaderCache::get().acquire(*uid) : nullptr; + if (!shader) { + TOAST_ERROR( + "Render", + "TracedSpecularPass shader core://shaders/voxel_specular.slang unavailable; voxels keep the environment specular" + ); + setEnabled(false); + return; + } + + m_layout.rebuild(core, shader->reflection, "TracedSpecularPass"); + const auto& layouts = m_layout.getDescriptorSetLayouts(); + if (layouts.size() <= k_specular_image_set) { + TOAST_ERROR("Render", "TracedSpecularPass shader layout has {} descriptor sets instead of 3", layouts.size()); + setEnabled(false); + return; + } + + const auto build_pipeline = + [&](VulkanPipeline& pipeline, vk::Format format, vk::Extent2D target_extent, const char* entry, const char* debug_name) { + VulkanPipeline::Config config; + config.pipeline_type = VulkanPipeline::PipelineType::graphics; + config.debug_name = debug_name; + config.color_format = format; + // Roughness and the no trace mark live in alpha + config.write_color_alpha = true; + config.extent = target_extent; + config.shader_spirv = shader->spirv; + config.fragment_entry = entry; + config.pipeline_layout = *m_layout.getPipelineLayout(); + config.vertex_bindings = {}; + config.vertex_attributes = {}; + config.cull_mode = vk::CullModeFlagBits::eNone; + config.depth_test = false; + config.depth_write = false; + pipeline.rebuild(core, config); + }; + const vk::Extent2D half = PostProcessTarget::halfExtent(extent); + build_pipeline(m_trace_pipeline, k_trace_format, half, "traceMain", "TracedSpecular Trace"); + build_pipeline(m_resolve_pipeline, k_trace_format, half, "resolveMain", "TracedSpecular Resolve"); + build_pipeline(m_temporal_pipeline, k_trace_format, half, "temporalMain", "TracedSpecular Temporal"); + build_pipeline(m_composite_pipeline, scene_format, extent, "compositeMain", "TracedSpecular Composite"); + + m_scene_sets.create(core, shader->reflection, *layouts[k_specular_scene_set], "TracedSpecular"); + m_instances_bound = std::ranges::any_of(shader->reflection.bindings, [](const ShaderBinding& binding) { + return binding.set == k_specular_storage_set && binding.binding == k_specular_instances_binding; + }); + createSets(core); + createTargets(core, extent); + + m_ready = m_trace_pipeline.isReady() && m_resolve_pipeline.isReady() && m_temporal_pipeline.isReady() && + m_composite_pipeline.isReady() && !m_scene_sets.empty() && !m_storage_sets.empty(); + if (m_ready) { + TOAST_INFO("Render", "TracedSpecularPass ready (GGX rays traced at half resolution)"); + } +} + +void TracedSpecularPass::createSets(const VulkanCore& core) { + ZoneScoped; + const auto& device = core.getDevice(); + const auto& layouts = m_layout.getDescriptorSetLayouts(); + const vk::DescriptorPool pool = VulkanRenderer::instance->getDescriptorPoolHandle(); + const VoxelTraceScene* trace = VulkanRenderer::instance->voxelTraceScene(); + if (trace == nullptr) { + TOAST_ERROR("Render", "TracedSpecularPass needs the voxel trace scene; voxels keep the environment specular"); + return; + } + + // Nearest since interpolated depth or normals invent surfaces + m_point_sampler = vk::raii::Sampler(device, nearestClampSamplerInfo()); + setDebugName(core, *m_point_sampler, "TracedSpecular PointSampler"); + + const vk::DescriptorSetLayout storage_layout = *layouts[k_specular_storage_set]; + const vk::DescriptorSetLayout image_layout = *layouts[k_specular_image_set]; + constexpr auto storage = vk::DescriptorType::eStorageBuffer; + m_storage_sets.clear(); + m_image_sets.clear(); + DescriptorWriter writer; + for (uint32_t i = 0; i < VulkanRenderer::k_frames_in_flight; ++i) { + auto storage_set = device.allocateDescriptorSets(vk::DescriptorSetAllocateInfo(pool, 1, &storage_layout)); + m_storage_sets.push_back(std::move(storage_set[0])); + setDebugName(core, *m_storage_sets.back(), std::format("TracedSpecular StorageSet[{}]", i)); + const vk::DescriptorSet set = *m_storage_sets.back(); + writer.buffer(set, k_specular_trace_volumes_binding, storage, trace->volumeBuffer(i)); + writer.buffer(set, k_specular_trace_bounds_binding, storage, trace->boundsBuffer(i)); + writer.buffer(set, k_specular_trace_grid_binding, storage, trace->gridBuffer(i)); + if (m_instances_bound) { + writer.buffer(set, k_specular_instances_binding, storage, trace->volumeBuffer(i)); + } + if (ShaderCompiler::isRayQueryAvailable()) { + const VoxelBlasCache* bricks = VulkanRenderer::instance->voxelBlasCache(); + writer.buffer(set, k_specular_trace_brick_bases_binding, storage, trace->brickBaseBuffer(i)); + writer.buffer( + set, k_specular_trace_bricks_binding, storage, bricks != nullptr ? bricks->poolBuffer() : trace->brickBaseBuffer(i) + ); + } + + for (uint32_t parity = 0; parity < 2; ++parity) { + auto image_set = device.allocateDescriptorSets(vk::DescriptorSetAllocateInfo(pool, 1, &image_layout)); + m_image_sets.push_back(std::move(image_set[0])); + setDebugName(core, *m_image_sets.back(), std::format("TracedSpecular ImageSet[{}][{}]", i, parity)); + } + } + writer.flush(device); + m_storage_bound.assign(m_storage_sets.size(), nullptr); + m_image_bound.assign(m_image_sets.size(), {}); +} + +void TracedSpecularPass::createTargets(const VulkanCore& core, vk::Extent2D extent) { + const vk::Extent2D half = PostProcessTarget::halfExtent(extent); + m_target.create(core, extent, m_scene_format, "TracedSpecular"); + m_trace.create(core, half, k_trace_format, "TracedSpecular Trace"); + m_resolved.create(core, half, k_trace_format, "TracedSpecular Resolved"); + m_history[0].create(core, half, k_trace_format, "TracedSpecular History0"); + m_history[1].create(core, half, k_trace_format, "TracedSpecular History1"); + std::ranges::fill(m_image_bound, std::array {}); + m_history_valid = false; +} + +void TracedSpecularPass::onResize(vk::Extent2D extent) { + if (m_core != nullptr && m_ready) { + createTargets(*m_core, extent); + } +} + +auto TracedSpecularPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView source_view) -> vk::ImageView { + ZoneScoped; + auto* renderer = VulkanRenderer::instance; + const auto* frame = renderer->renderingFrame(); + if (!m_ready || !renderer->tracedSpecularFrame() || !source_view || frame == nullptr || !frame->voxel_storage || + !frame->voxel_storage->isReady() || frame_index >= m_storage_sets.size()) { + return source_view; + } + + // A repeated snapshot carries no new sample so the last output stands + if (m_history_valid && frame->sequence == m_last_sequence) { + return m_target.view(); + } + + // Safe since the fence of this slot was waited on + if (m_storage_bound[frame_index] != frame->voxel_storage) { + writeVoxelStorageDescriptors(m_core->getDevice(), *m_storage_sets[frame_index], *frame->voxel_storage); + m_storage_bound[frame_index] = frame->voxel_storage; + } + + const PostProcessTarget& history_write = m_history[m_parity]; + const PostProcessTarget& history_read = m_history[1 - m_parity]; + const uint32_t set_index = (frame_index * 2) + m_parity; + const vk::DescriptorSet image_set = *m_image_sets[set_index]; + const vk::ImageView previous_view = renderer->previousResolvedColour(); + const std::array views { + source_view, + renderer->getDepthView(), + renderer->getVoxelAlbedoView(), + renderer->getVoxelMaterialView(), + renderer->getVoxelFaceNormalView(), + renderer->getSceneNormalView(), + m_trace.view(), + history_read.view(), + renderer->getSceneMotionView(), + history_write.view(), + previous_view ? previous_view : source_view, + m_resolved.view(), + }; + if (std::ranges::any_of(views, [](vk::ImageView view) { return !view; })) { + return source_view; + } + if (m_image_bound[set_index] != views) { + DescriptorWriter writer; + for (uint32_t binding = 0; binding < k_image_bindings; ++binding) { + writer.image( + image_set, + binding, + vk::DescriptorType::eCombinedImageSampler, + *m_point_sampler, + views[binding], + binding == k_specular_depth_binding ? vk::ImageLayout::eDepthReadOnlyOptimal : vk::ImageLayout::eShaderReadOnlyOptimal + ); + } + writer.flush(m_core->getDevice()); + m_image_bound[set_index] = views; + } + m_scene_sets.updateTlas(frame_index); + + const uint64_t sequence = frame->sequence; + const bool history_valid = m_history_valid && sequence == m_last_sequence + 1; + if (!history_valid) { + history_read.makeSampleable(cmd); + } + + Params params {}; + params.inverse_view_projection = glm::inverse(frame->frame_data.jittered_view_projection); + params.tracing = glm::vec4( + renderer->voxelSpecularReach(), + renderer->voxelSpecularRoughness(), + history_valid ? 1.0f : 0.0f, + static_cast(renderer->voxelSunReach()) + ); + params.resolve = glm::vec4(previous_view ? 1.0f : 0.0f, 0.0f, 0.0f, 0.0f); + + const vk::PipelineLayout layout = *m_layout.getPipelineLayout(); + const std::array sets {m_scene_sets.get(frame_index), *m_storage_sets[frame_index], image_set}; + const auto draw = [&](const PostProcessTarget& target, const VulkanPipeline& pipeline) { + target.beginScope(cmd); + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, pipeline.getPipeline()); + cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, layout, k_specular_scene_set, sets, {}); + cmd.pushConstants(layout, vk::ShaderStageFlagBits::eAll, 0, sizeof(Params), ¶ms); + cmd.draw(3, 1, 0, 0); + target.endScope(cmd); + }; + + draw(m_trace, m_trace_pipeline); + draw(m_resolved, m_resolve_pipeline); + draw(history_write, m_temporal_pipeline); + draw(m_target, m_composite_pipeline); + + m_parity = 1 - m_parity; + m_history_valid = true; + m_last_sequence = sequence; + return m_target.view(); +} + +} diff --git a/engine/src/toast/renderer/passes/traced_specular_pass.hpp b/engine/src/toast/renderer/passes/traced_specular_pass.hpp new file mode 100644 index 000000000..d6d357639 --- /dev/null +++ b/engine/src/toast/renderer/passes/traced_specular_pass.hpp @@ -0,0 +1,95 @@ +/** + * @file traced_specular_pass.hpp + * @author dario + * @date 25/10/2026 + */ + +#pragma once + +#include "../post_process_pass_base.hpp" +#include "../post_process_target.hpp" +#include "../scene_descriptor_set.hpp" +#include "../shader_layout.hpp" +#include "../vulkan_common.hpp" +#include "../vulkan_pipeline.hpp" + +#include +#include +#include +#include + +namespace renderer { +class VulkanCore; +class VoxelGpuStorage; + +/// GGX rays for reflective voxels at half resolution in place of their environment specular +class TracedSpecularPass : public IPostProcessPass { +public: + TracedSpecularPass(const VulkanCore& core, vk::Format scene_format, vk::Extent2D extent); + + [[nodiscard]] + auto name() const -> std::string_view override { + return "Traced specular"; + } + + auto record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView source_view) -> vk::ImageView override; + + void onResize(vk::Extent2D extent) override; + + [[nodiscard]] + auto isReady() const noexcept -> bool { + return m_ready; + } + +private: + /// Mirrors voxel_specular.slang SpecularParams + struct Params { + glm::mat4 inverse_view_projection {1.0f}; + /// x reach in meters y max roughness z 1 with a valid history w sun reach in meters + glm::vec4 tracing {0.0f}; + /// x 1 when binding 10 holds last frame resolve + glm::vec4 resolve {0.0f}; + }; + + /// rgb radiance a hit distance then history and -1 where nothing was traced + static constexpr vk::Format k_trace_format = vk::Format::eR16G16B16A16Sfloat; + + static constexpr uint32_t k_image_bindings = 12; + + void createSets(const VulkanCore& core); + void createTargets(const VulkanCore& core, vk::Extent2D extent); + + const VulkanCore* m_core = nullptr; + vk::Format m_scene_format = vk::Format::eUndefined; + bool m_ready = false; + + ShaderLayout m_layout; + VulkanPipeline m_trace_pipeline; + VulkanPipeline m_resolve_pipeline; + VulkanPipeline m_temporal_pipeline; + VulkanPipeline m_composite_pipeline; + SceneDescriptorSets m_scene_sets; + bool m_instances_bound = false; + + vk::raii::Sampler m_point_sampler = nullptr; + + /// Per frame in flight + std::vector m_storage_sets; + std::vector> m_storage_bound; + /// Per frame in flight then per history parity + std::vector m_image_sets; + std::vector> m_image_bound; + + PostProcessTarget m_trace; + PostProcessTarget m_resolved; + /// The temporal pass reads one and writes the other + std::array m_history; + PostProcessTarget m_target; + + uint32_t m_parity = 0; + bool m_history_valid = false; + /// A gap in frames or a recreated target restarts the history + uint64_t m_last_sequence = 0; +}; + +} diff --git a/engine/src/toast/renderer/passes/volumetric_pass.cpp b/engine/src/toast/renderer/passes/volumetric_pass.cpp new file mode 100644 index 000000000..fa6da72cc --- /dev/null +++ b/engine/src/toast/renderer/passes/volumetric_pass.cpp @@ -0,0 +1,346 @@ +/** + * @file volumetric_pass.cpp + * @author dario + * @date 26/10/2026 + */ + +#include "volumetric_pass.hpp" + +#include "../descriptor_writer.hpp" +#include "../gpu_sync.hpp" +#include "../gpu_timer.hpp" +#include "../shader_cache.hpp" +#include "../shader_compiler.hpp" +#include "../voxel_blas_cache.hpp" +#include "../voxel_gpu_storage.hpp" +#include "../voxel_trace_scene.hpp" +#include "../vulkan_core.hpp" +#include "../vulkan_debug.hpp" +#include "../vulkan_renderer.hpp" + +#include +#include +#include +#include +#include + +namespace renderer { + +namespace { + +constexpr uint32_t k_volumetric_scene_set = 0; +constexpr uint32_t k_volumetric_storage_set = 1; +constexpr uint32_t k_volumetric_image_set = 2; + +/// voxel_dda.slang gVoxelInstances and scene_trace.slang bindings in the storage set +constexpr uint32_t k_volumetric_instances_binding = 6; +constexpr uint32_t k_volumetric_trace_volumes_binding = 10; +constexpr uint32_t k_volumetric_trace_bounds_binding = 11; +constexpr uint32_t k_volumetric_trace_grid_binding = 12; +constexpr uint32_t k_volumetric_trace_brick_bases_binding = 14; +constexpr uint32_t k_volumetric_trace_bricks_binding = 15; + +/// volumetrics.slang gDepth gHistory and gFogAmbient +constexpr uint32_t k_volumetric_depth_binding = 1; +constexpr uint32_t k_volumetric_history_binding = 4; +constexpr uint32_t k_volumetric_ambient_binding = 6; +constexpr uint32_t k_volumetric_previous_ambient_binding = 7; + +/// Low Medium High Epic +constexpr std::array k_volumetric_levels { + { + {.steps = 12, .lights = 2, .light_shadows = false, .divisor = 4}, + {.steps = 16, .lights = 4, .light_shadows = true, .divisor = 2}, + {.steps = 24, .lights = 8, .light_shadows = true, .divisor = 2}, + {.steps = 40, .lights = 16, .light_shadows = true, .divisor = 2}, + } +}; + +auto divided(vk::Extent2D extent, uint32_t divisor) -> vk::Extent2D { + return {std::max((extent.width + divisor - 1) / divisor, 1u), std::max((extent.height + divisor - 1) / divisor, 1u)}; +} + +} + +VolumetricPass::VolumetricPass(const VulkanCore& core, vk::Format scene_format, vk::Extent2D extent) + : m_core(&core), + m_scene_format(scene_format) { + ZoneScoped; + if (core.getPhysicalDevice().getProperties().limits.maxPushConstantsSize < sizeof(Params)) { + TOAST_ERROR("Render", "VolumetricPass needs {} bytes of push constants; volumetrics are disabled", sizeof(Params)); + setEnabled(false); + return; + } + const auto uid = assets::resolveURI("core://shaders/volumetrics.slang"); + const auto shader = uid.has_value() ? ShaderCache::get().acquire(*uid) : nullptr; + if (!shader) { + TOAST_ERROR("Render", "VolumetricPass shader core://shaders/volumetrics.slang unavailable; volumetrics are disabled"); + setEnabled(false); + return; + } + + m_layout.rebuild(core, shader->reflection, "VolumetricPass"); + const auto& layouts = m_layout.getDescriptorSetLayouts(); + if (layouts.size() <= k_volumetric_image_set) { + TOAST_ERROR("Render", "VolumetricPass shader layout has {} descriptor sets instead of 3", layouts.size()); + setEnabled(false); + return; + } + const VoxelTraceScene* trace = VulkanRenderer::instance->voxelTraceScene(); + if (trace == nullptr) { + TOAST_ERROR("Render", "VolumetricPass needs the voxel trace scene; volumetrics are disabled"); + setEnabled(false); + return; + } + + const auto build_pipeline = + [&](VulkanPipeline& pipeline, vk::Format format, vk::Extent2D target_extent, const char* entry, const char* debug_name) { + VulkanPipeline::Config config; + config.pipeline_type = VulkanPipeline::PipelineType::graphics; + config.debug_name = debug_name; + config.color_format = format; + // Transmittance lives in alpha + config.write_color_alpha = true; + config.extent = target_extent; + config.shader_spirv = shader->spirv; + config.fragment_entry = entry; + config.pipeline_layout = *m_layout.getPipelineLayout(); + config.vertex_bindings = {}; + config.vertex_attributes = {}; + config.cull_mode = vk::CullModeFlagBits::eNone; + config.depth_test = false; + config.depth_write = false; + pipeline.rebuild(core, config); + }; + VulkanPipeline::Config ambient_config; + ambient_config.pipeline_type = VulkanPipeline::PipelineType::compute; + ambient_config.debug_name = "Volumetrics Ambient"; + ambient_config.shader_spirv = shader->spirv; + ambient_config.pipeline_layout = *m_layout.getPipelineLayout(); + ambient_config.compute_entry = "ambientMain"; + m_ambient_pipeline.rebuild(core, ambient_config); + + const vk::Extent2D half = PostProcessTarget::halfExtent(extent); + build_pipeline(m_scatter_pipeline, k_scatter_format, half, "scatterMain", "Volumetrics Scatter"); + build_pipeline(m_temporal_pipeline, k_scatter_format, half, "temporalMain", "Volumetrics Temporal"); + build_pipeline(m_composite_pipeline, scene_format, extent, "compositeMain", "Volumetrics Composite"); + + m_scene_sets.create(core, shader->reflection, *layouts[k_volumetric_scene_set], "Volumetrics"); + const bool instances_bound = std::ranges::any_of(shader->reflection.bindings, [](const ShaderBinding& binding) { + return binding.set == k_volumetric_storage_set && binding.binding == k_volumetric_instances_binding; + }); + + const auto& device = core.getDevice(); + // Nearest since interpolated depth invents surfaces + m_point_sampler = vk::raii::Sampler(device, nearestClampSamplerInfo()); + setDebugName(core, *m_point_sampler, "Volumetrics PointSampler"); + m_linear_sampler = vk::raii::Sampler(device, linearClampSamplerInfo()); + setDebugName(core, *m_linear_sampler, "Volumetrics LinearSampler"); + + const vk::DescriptorSetLayout storage_layout = *layouts[k_volumetric_storage_set]; + const vk::DescriptorSetLayout image_layout = *layouts[k_volumetric_image_set]; + const vk::DescriptorPool pool = VulkanRenderer::instance->getDescriptorPoolHandle(); + constexpr auto storage = vk::DescriptorType::eStorageBuffer; + for (uint32_t i = 0; i < VulkanRenderer::k_frames_in_flight; ++i) { + vk::BufferCreateInfo ambient_ci {}; + ambient_ci.size = sizeof(glm::vec4) * (VulkanRenderer::k_max_fog_volumes + 1); + ambient_ci.usage = vk::BufferUsageFlagBits::eStorageBuffer; + vma::AllocationCreateInfo ambient_alloc {}; + ambient_alloc.usage = vma::MemoryUsage::eAutoPreferDevice; + m_ambient.push_back(core.getAllocator().createBuffer(ambient_ci, ambient_alloc)); + setDebugName(core, *m_ambient.back(), std::format("Volumetrics Ambient[{}]", i)); + } + + DescriptorWriter writer; + for (uint32_t i = 0; i < VulkanRenderer::k_frames_in_flight; ++i) { + auto storage_set = device.allocateDescriptorSets(vk::DescriptorSetAllocateInfo(pool, 1, &storage_layout)); + m_storage_sets.push_back(std::move(storage_set[0])); + setDebugName(core, *m_storage_sets.back(), std::format("Volumetrics StorageSet[{}]", i)); + const vk::DescriptorSet set = *m_storage_sets.back(); + writer.buffer(set, k_volumetric_trace_volumes_binding, storage, trace->volumeBuffer(i)); + writer.buffer(set, k_volumetric_trace_bounds_binding, storage, trace->boundsBuffer(i)); + writer.buffer(set, k_volumetric_trace_grid_binding, storage, trace->gridBuffer(i)); + if (instances_bound) { + writer.buffer(set, k_volumetric_instances_binding, storage, trace->volumeBuffer(i)); + } + if (ShaderCompiler::isRayQueryAvailable()) { + const VoxelBlasCache* bricks = VulkanRenderer::instance->voxelBlasCache(); + writer.buffer(set, k_volumetric_trace_brick_bases_binding, storage, trace->brickBaseBuffer(i)); + writer.buffer( + set, k_volumetric_trace_bricks_binding, storage, bricks != nullptr ? bricks->poolBuffer() : trace->brickBaseBuffer(i) + ); + } + + const uint32_t previous = (i + VulkanRenderer::k_frames_in_flight - 1) % VulkanRenderer::k_frames_in_flight; + for (uint32_t parity = 0; parity < 2; ++parity) { + auto image_set = device.allocateDescriptorSets(vk::DescriptorSetAllocateInfo(pool, 1, &image_layout)); + m_image_sets.push_back(std::move(image_set[0])); + setDebugName(core, *m_image_sets.back(), std::format("Volumetrics ImageSet[{}][{}]", i, parity)); + writer.buffer(*m_image_sets.back(), k_volumetric_ambient_binding, storage, *m_ambient[i]); + writer.buffer(*m_image_sets.back(), k_volumetric_previous_ambient_binding, storage, *m_ambient[previous]); + } + } + writer.flush(device); + m_storage_bound.assign(m_storage_sets.size(), nullptr); + m_image_bound.assign(m_image_sets.size(), {}); + + createTargets(core, extent, resolveQuality().divisor); + m_ready = m_ambient_pipeline.isReady() && m_scatter_pipeline.isReady() && m_temporal_pipeline.isReady() && + m_composite_pipeline.isReady() && !m_scene_sets.empty(); + if (m_ready) { + TOAST_INFO( + "Render", + "VolumetricPass ready (height fog and up to {} fog volumes marched at half resolution)", + VulkanRenderer::k_max_fog_volumes + ); + } +} + +auto VolumetricPass::resolveQuality() -> Quality { + const auto* renderer = VulkanRenderer::instance; + const uint32_t level = renderer->volumetricQuality(); + if (level < k_volumetric_levels.size()) { + return k_volumetric_levels[level]; + } + return Quality { + .steps = renderer->volumetricSteps(), + .lights = renderer->volumetricLights(), + .light_shadows = renderer->volumetricLightShadows(), + .divisor = renderer->volumetricQuarterResolution() ? 4u : 2u, + }; +} + +void VolumetricPass::createTargets(const VulkanCore& core, vk::Extent2D extent, uint32_t divisor) { + m_extent = extent; + m_divisor = divisor; + const vk::Extent2D reduced = divided(extent, divisor); + m_target.create(core, extent, m_scene_format, "Volumetrics"); + m_scatter.create(core, reduced, k_scatter_format, "Volumetrics Scatter"); + m_history[0].create(core, reduced, k_scatter_format, "Volumetrics History0"); + m_history[1].create(core, reduced, k_scatter_format, "Volumetrics History1"); + std::ranges::fill(m_image_bound, std::array {}); + m_history_valid = false; +} + +void VolumetricPass::onResize(vk::Extent2D extent) { + if (m_core != nullptr && m_ready) { + createTargets(*m_core, extent, m_divisor); + } +} + +auto VolumetricPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView source_view) -> vk::ImageView { + ZoneScoped; + auto* renderer = VulkanRenderer::instance; + const auto* frame = renderer->renderingFrame(); + if (!m_ready || !source_view || frame == nullptr || !renderer->volumetrics() || frame->render_mode != 0 || + (frame->post_process.fog.density <= 0.0f && frame->fog_volumes.empty()) || frame_index >= m_storage_sets.size() || + !frame->voxel_storage || !frame->voxel_storage->isReady()) { + m_history_valid = false; + return source_view; + } + // A repeated snapshot carries no new sample so the last output stands + if (m_history_valid && frame->sequence == m_last_sequence) { + return m_target.view(); + } + + // Frames in flight still sample the old targets + const Quality quality = resolveQuality(); + if (quality.divisor != m_divisor) { + m_core->getDevice().waitIdle(); + createTargets(*m_core, m_extent, quality.divisor); + } + + // Safe since the fence of this slot was waited on + if (m_storage_bound[frame_index] != frame->voxel_storage) { + writeVoxelStorageDescriptors(m_core->getDevice(), *m_storage_sets[frame_index], *frame->voxel_storage); + m_storage_bound[frame_index] = frame->voxel_storage; + } + + const PostProcessTarget& history_write = m_history[m_parity]; + const PostProcessTarget& history_read = m_history[1 - m_parity]; + const uint32_t set_index = (frame_index * 2) + m_parity; + const vk::DescriptorSet image_set = *m_image_sets[set_index]; + const std::array views { + source_view, + renderer->getDepthView(), + renderer->getSceneMotionView(), + m_scatter.view(), + history_read.view(), + history_write.view(), + }; + if (std::ranges::any_of(views, [](vk::ImageView view) { return !view; })) { + return source_view; + } + if (m_image_bound[set_index] != views) { + DescriptorWriter writer; + for (uint32_t binding = 0; binding < k_image_bindings; ++binding) { + writer.image( + image_set, + binding, + vk::DescriptorType::eCombinedImageSampler, + binding == k_volumetric_history_binding ? *m_linear_sampler : *m_point_sampler, + views[binding], + binding == k_volumetric_depth_binding ? vk::ImageLayout::eDepthReadOnlyOptimal : vk::ImageLayout::eShaderReadOnlyOptimal + ); + } + writer.flush(m_core->getDevice()); + m_image_bound[set_index] = views; + } + m_scene_sets.updateTlas(frame_index); + + const bool history_valid = m_history_valid && frame->sequence == m_last_sequence + 1 && !frame->taa_reset; + if (!history_valid) { + history_read.makeSampleable(cmd); + } + + const auto volume_count = static_cast(std::min(frame->fog_volumes.size(), VulkanRenderer::k_max_fog_volumes)); + const auto& fog = frame->post_process.fog; + Params params {}; + params.inverse_view_projection = glm::inverse(frame->frame_data.jittered_view_projection); + params.medium = glm::vec4(fog.density, fog.height, fog.height_falloff, fog.anisotropy); + params.albedo = glm::vec4(fog.albedo_r, fog.albedo_g, fog.albedo_b, fog.ambient); + params.marching = + glm::vec4(fog.distance, static_cast(quality.steps), history_valid ? 1.0f : 0.0f, static_cast(volume_count)); + params.quality = glm::vec4( + static_cast(m_divisor), + static_cast(quality.lights), + quality.light_shadows ? 1.0f : 0.0f, + renderer->volumetricDistance() + ); + + const vk::PipelineLayout layout = *m_layout.getPipelineLayout(); + const std::array sets {m_scene_sets.get(frame_index), *m_storage_sets[frame_index], image_set}; + const auto draw = [&](const PostProcessTarget& target, const VulkanPipeline& pipeline) { + target.beginScope(cmd); + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, pipeline.getPipeline()); + cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, layout, k_volumetric_scene_set, sets, {}); + cmd.pushConstants(layout, vk::ShaderStageFlagBits::eAll, 0, sizeof(Params), ¶ms); + cmd.draw(3, 1, 0, 0); + target.endScope(cmd); + }; + + { + const GpuScope scope(renderer->gpuTimer(), frame_index, cmd, "Fog ambient"); + // Last frame wrote the probes this one blends with + sync::BarrierBatch history; + history.memory(sync::Usage::compute_storage_write, sync::Usage::compute_storage_read); + history.flush(cmd); + cmd.bindPipeline(vk::PipelineBindPoint::eCompute, m_ambient_pipeline.getPipeline()); + cmd.bindDescriptorSets(vk::PipelineBindPoint::eCompute, layout, k_volumetric_scene_set, sets, {}); + cmd.pushConstants(layout, vk::ShaderStageFlagBits::eAll, 0, sizeof(Params), ¶ms); + cmd.dispatch(volume_count + 1, 1, 1); + sync::BarrierBatch barriers; + barriers.memory(sync::Usage::compute_storage_write, sync::Usage::fragment_storage_read); + barriers.flush(cmd); + } + draw(m_scatter, m_scatter_pipeline); + draw(history_write, m_temporal_pipeline); + draw(m_target, m_composite_pipeline); + + m_parity = 1 - m_parity; + m_history_valid = true; + m_last_sequence = frame->sequence; + return m_target.view(); +} + +} diff --git a/engine/src/toast/renderer/passes/volumetric_pass.hpp b/engine/src/toast/renderer/passes/volumetric_pass.hpp new file mode 100644 index 000000000..540c93659 --- /dev/null +++ b/engine/src/toast/renderer/passes/volumetric_pass.hpp @@ -0,0 +1,107 @@ +/** + * @file volumetric_pass.hpp + * @author dario + * @date 26/10/2026 + */ + +#pragma once + +#include "../post_process_pass_base.hpp" +#include "../post_process_target.hpp" +#include "../scene_descriptor_set.hpp" +#include "../shader_layout.hpp" +#include "../vulkan_common.hpp" +#include "../vulkan_pipeline.hpp" + +#include +#include +#include +#include + +namespace renderer { +class VulkanCore; +class VoxelGpuStorage; + +/// Height fog and fog volumes marched at half or quarter resolution +class VolumetricPass : public IPostProcessPass { +public: + struct Quality { + uint32_t steps = 24; + uint32_t lights = 8; + bool light_shadows = true; + /// Full resolution pixels per scatter texel + uint32_t divisor = 2; + }; + + VolumetricPass(const VulkanCore& core, vk::Format scene_format, vk::Extent2D extent); + + [[nodiscard]] + auto name() const -> std::string_view override { + return "Volumetrics"; + } + + auto record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView source_view) -> vk::ImageView override; + + void onResize(vk::Extent2D extent) override; + +private: + /// Mirrors volumetrics.slang VolumetricParams + struct Params { + glm::mat4 inverse_view_projection {1.0f}; + /// x extinction per meter y base height z height falloff w anisotropy + glm::vec4 medium {0.0f}; + /// rgb scattering albedo a ambient scale + glm::vec4 albedo {0.0f}; + /// x march distance y steps z 1 with a valid history w fog volumes + glm::vec4 marching {0.0f}; + /// x full resolution pixels per texel y local lights per step z 1 when local lights cast shadows w fog volume distance + glm::vec4 quality {0.0f}; + }; + + [[nodiscard]] + static auto resolveQuality() -> Quality; + + /// rgb in-scattered radiance a transmittance + static constexpr vk::Format k_scatter_format = vk::Format::eR16G16B16A16Sfloat; + + static constexpr uint32_t k_image_bindings = 6; + + void createTargets(const VulkanCore& core, vk::Extent2D extent, uint32_t divisor); + + const VulkanCore* m_core = nullptr; + vk::Format m_scene_format = vk::Format::eUndefined; + bool m_ready = false; + + ShaderLayout m_layout; + VulkanPipeline m_ambient_pipeline; + VulkanPipeline m_scatter_pipeline; + VulkanPipeline m_temporal_pipeline; + VulkanPipeline m_composite_pipeline; + SceneDescriptorSets m_scene_sets; + + vk::raii::Sampler m_point_sampler = nullptr; + vk::raii::Sampler m_linear_sampler = nullptr; + + /// Per frame in flight + std::vector m_storage_sets; + /// volumetrics.slang gFogAmbient per frame in flight + std::vector m_ambient; + std::vector> m_storage_bound; + + /// Per frame in flight then per history parity + std::vector m_image_sets; + std::vector> m_image_bound; + + PostProcessTarget m_scatter; + /// The temporal pass reads one and writes the other + std::array m_history; + PostProcessTarget m_target; + + vk::Extent2D m_extent {}; + uint32_t m_divisor = 2; + uint32_t m_parity = 0; + bool m_history_valid = false; + uint64_t m_last_sequence = 0; +}; + +} diff --git a/engine/src/toast/renderer/passes/voxel_pass.cpp b/engine/src/toast/renderer/passes/voxel_pass.cpp index e52720d64..9282af345 100644 --- a/engine/src/toast/renderer/passes/voxel_pass.cpp +++ b/engine/src/toast/renderer/passes/voxel_pass.cpp @@ -4,6 +4,7 @@ #include "../descriptor_writer.hpp" #include "../gpu_sync.hpp" #include "../shader_cache.hpp" +#include "../shader_compiler.hpp" #include "../voxel_gpu_storage.hpp" #include "../vulkan_core.hpp" #include "../vulkan_debug.hpp" @@ -13,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -65,8 +67,12 @@ constexpr uint32_t k_instance_bounds_binding = 7; /// voxel_lighting.slang gAmbientGrid constexpr uint32_t k_ambient_grid_binding = 8; -/// Count then instance indices of the shadow casters holding glass -constexpr uint32_t k_glass_casters_binding = 9; +/// scene_trace.slang volumes bounds and cell grid +constexpr uint32_t k_trace_volumes_binding = 10; +constexpr uint32_t k_trace_bounds_binding = 11; +constexpr uint32_t k_trace_grid_binding = 12; +constexpr uint32_t k_trace_brick_bases_binding = 14; +constexpr uint32_t k_trace_bricks_binding = 15; /// Mirrored by voxel_lighting.slang constexpr uint32_t k_ambient_grid_buckets = 4096; constexpr uint32_t k_ambient_grid_header = 4; @@ -91,6 +97,9 @@ constexpr uint32_t k_reuse_history = 1; constexpr uint32_t k_reuse_spatial = 2; /// Some shadow ray instance holds glass constexpr uint32_t k_scene_glass = 4; +constexpr uint32_t k_ambient_ray_query = 8; +/// Bits 8 to 15 carry the max roughness in 255ths +constexpr uint32_t k_traced_specular = 16; /// ReSTIR reuse of last frame's reservoir at the reprojected pixel and two around it constexpr uint32_t k_reused_reservoirs = 3; @@ -412,9 +421,7 @@ void VoxelPass::createLighting(const VulkanCore& core, vk::Format scene_format, config.pipeline_type = VulkanPipeline::PipelineType::graphics; config.debug_name = "VoxelLighting"; config.color_format = scene_format; - config.extra_color_formats = { - k_scene_indirect_format, k_scene_normal_format, k_voxel_noisy_format, k_voxel_noisy_format, k_voxel_reservoir_format - }; + config.extra_color_formats = {k_scene_indirect_format, k_scene_normal_format, k_voxel_noisy_format}; config.write_extra_color = true; config.extent = extent; config.shader_spirv = shader->spirv; @@ -434,12 +441,37 @@ void VoxelPass::createLighting(const VulkanCore& core, vk::Format scene_format, config.extra_blend_mask = 1u; m_spatial_pipeline.rebuild(core, config); + // Premultiplied so colour adds and noisy diffuse keeps the indirect scaled by occlusion + config.debug_name = "VoxelMarchedLighting"; + config.fragment_entry = "fragmentMarched"; + config.color_format = scene_format; + config.extra_color_formats = {k_voxel_noisy_format, k_voxel_noisy_format, k_voxel_reservoir_format}; + config.blend_preset = VulkanPipeline::BlendPreset::premultiplied; + config.extra_blend_mask = 1u; + m_marched_pipeline.rebuild(core, config); + + // Alpha one minus occlusion scales the indirect the lighting scope left in noisy diffuse + config.debug_name = "VoxelAmbientRays"; + config.fragment_entry = "fragmentAmbient"; + config.color_format = k_voxel_noisy_format; + config.extra_color_formats = {}; + config.extra_blend_mask = 0u; + m_ambient_pipeline.rebuild(core, config); + + config.debug_name = "VoxelMarchedComposite"; + config.fragment_entry = "fragmentMarchedComposite"; + config.color_format = scene_format; + config.blend_preset = VulkanPipeline::BlendPreset::additive; + config.extra_blend_mask = 0u; + config.write_extra_color = false; + config.write_color_alpha = false; + m_marched_composite_pipeline.rebuild(core, config); + m_lighting_scene_sets.create(core, shader->reflection, *layouts[k_scene_set], "VoxelLighting"); const vk::DescriptorSetLayout storage_layout = *layouts[k_storage_set]; m_lighting_storage_sets.clear(); m_instance_bounds.clear(); - m_glass_casters.clear(); for (uint32_t i = 0; i < VulkanRenderer::k_frames_in_flight; ++i) { vk::BufferCreateInfo bounds_ci {}; bounds_ci.size = sizeof(glm::vec4) * k_max_instances; @@ -462,16 +494,6 @@ void VoxelPass::createLighting(const VulkanCore& core, vk::Format scene_format, m_ambient_grids.back().getAllocation().flush(0, vk::WholeSize); } - vk::BufferCreateInfo casters_ci {}; - casters_ci.size = sizeof(uint32_t) * (1 + k_max_instances); - casters_ci.usage = vk::BufferUsageFlagBits::eStorageBuffer; - m_glass_casters.push_back(core.getAllocator().createBuffer(casters_ci, bounds_alloc)); - setDebugName(core, *m_glass_casters.back(), std::format("VoxelLighting GlassCasters[{}]", i)); - if (auto* words = static_cast(m_glass_casters.back().getAllocation().getInfo().pMappedData)) { - words[0] = 0; - m_glass_casters.back().getAllocation().flush(0, sizeof(uint32_t)); - } - auto allocated = core.getDevice().allocateDescriptorSets( vk::DescriptorSetAllocateInfo(VulkanRenderer::instance->getDescriptorPoolHandle(), 1, &storage_layout) ); @@ -488,9 +510,32 @@ void VoxelPass::createLighting(const VulkanCore& core, vk::Format scene_format, writer.buffer( *m_lighting_storage_sets.back(), k_ambient_grid_binding, vk::DescriptorType::eStorageBuffer, *m_ambient_grids.back() ); - writer.buffer( - *m_lighting_storage_sets.back(), k_glass_casters_binding, vk::DescriptorType::eStorageBuffer, *m_glass_casters.back() - ); + if (const VoxelTraceScene* trace = VulkanRenderer::instance->voxelTraceScene(); trace != nullptr) { + writer.buffer( + *m_lighting_storage_sets.back(), k_trace_volumes_binding, vk::DescriptorType::eStorageBuffer, trace->volumeBuffer(i) + ); + writer.buffer( + *m_lighting_storage_sets.back(), k_trace_bounds_binding, vk::DescriptorType::eStorageBuffer, trace->boundsBuffer(i) + ); + writer.buffer( + *m_lighting_storage_sets.back(), k_trace_grid_binding, vk::DescriptorType::eStorageBuffer, trace->gridBuffer(i) + ); + if (ShaderCompiler::isRayQueryAvailable()) { + const VoxelBlasCache* bricks = VulkanRenderer::instance->voxelBlasCache(); + writer.buffer( + *m_lighting_storage_sets.back(), + k_trace_brick_bases_binding, + vk::DescriptorType::eStorageBuffer, + trace->brickBaseBuffer(i) + ); + writer.buffer( + *m_lighting_storage_sets.back(), + k_trace_bricks_binding, + vk::DescriptorType::eStorageBuffer, + bricks != nullptr ? bricks->poolBuffer() : trace->brickBaseBuffer(i) + ); + } + } writer.flush(core.getDevice()); } @@ -685,29 +730,6 @@ void VoxelPass::bindStorage(uint32_t frame_index, const std::shared_ptr casters -) { - ZoneScoped; - if (frame_index >= m_glass_casters.size()) { - return; - } - auto* words = static_cast(m_glass_casters[frame_index].getAllocation().getInfo().pMappedData); - if (words == nullptr) { - return; - } - // Same cut as the shadow instance count the shader loops over - const size_t limit = std::min(casters.size(), VulkanRenderer::instance->voxelShadowInstances()); - uint32_t count = 0; - for (size_t i = 0; i < limit && count < k_max_instances; ++i) { - if (!storage.glassBricks(casters[i].record_index).empty()) { - words[1 + count++] = static_cast(i); - } - } - words[0] = count; - m_glass_casters[frame_index].getAllocation().flush(0, sizeof(uint32_t) * (1 + count)); -} - void VoxelPass::writeAmbientGrid(uint32_t frame_index, std::span bounds) { ZoneScoped; if (frame_index >= m_ambient_grids.size()) { @@ -759,11 +781,12 @@ void VoxelPass::writeAmbientGrid(uint32_t frame_index, std::span bool { } bool cells_fit = true; + // Built here and copied once since the mapped buffers are write combined and slow or stale to read back + m_instance_scratch.clear(); + m_bounds_scratch.clear(); auto* bounds = frame_index < m_instance_bounds.size() ? static_cast(m_instance_bounds[frame_index].getAllocation().getInfo().pMappedData) : nullptr; @@ -837,16 +863,15 @@ auto VoxelPass::prepare(uint32_t frame_index) -> bool { cells_fit = cells_fit && glm::all(glm::lessThanEqual(coarse, glm::uvec3(k_max_coarse_dim))); const auto index = static_cast(m_draws.size()); - instances[index] = + VoxelInstanceGpu instance = makeVoxelInstance(proxy.model, proxy.inverse_model, proxy.previous_model, proxy.has_previous, proxy.record_index); - instances[index].pad0 = proxy.highlight_record; - if (bounds != nullptr) { - bounds[index] = glm::vec4(proxy.bounds_center, proxy.bounds_radius); - } - instances[index].cell_base = m_total_cells; + instance.pad0 = proxy.highlight_record; + instance.cell_base = m_total_cells; if (proxy.mirrored) { - instances[index].flags |= k_voxel_instance_mirrored; + instance.flags |= k_voxel_instance_mirrored; } + m_instance_scratch.push_back(instance); + m_bounds_scratch.emplace_back(proxy.bounds_center, proxy.bounds_radius); m_total_cells += coarse.x * coarse.y * coarse.z; // voxel.slang flips the winding of mirrored instances so the cull mode only follows the camera @@ -895,24 +920,28 @@ auto VoxelPass::prepare(uint32_t frame_index) -> bool { if (proxy.visible || bounds == nullptr || m_shadow_instance_count >= k_max_instances) { continue; } - instances[m_shadow_instance_count] = - makeVoxelInstance(proxy.model, proxy.inverse_model, proxy.previous_model, proxy.has_previous, proxy.record_index); - bounds[m_shadow_instance_count] = glm::vec4(proxy.bounds_center, proxy.bounds_radius); + m_instance_scratch.push_back( + makeVoxelInstance(proxy.model, proxy.inverse_model, proxy.previous_model, proxy.has_previous, proxy.record_index) + ); + m_bounds_scratch.emplace_back(proxy.bounds_center, proxy.bounds_radius); ++m_shadow_instance_count; } + std::memcpy(instances, m_instance_scratch.data(), sizeof(VoxelInstanceGpu) * m_shadow_instance_count); allocation.flush(0, sizeof(VoxelInstanceGpu) * m_shadow_instance_count); - writeGlassCasters(frame_index, *frame->voxel_storage, std::span(instances, m_shadow_instance_count)); if (bounds != nullptr) { + std::memcpy(bounds, m_bounds_scratch.data(), sizeof(glm::vec4) * m_shadow_instance_count); m_instance_bounds[frame_index].getAllocation().flush(0, sizeof(glm::vec4) * m_shadow_instance_count); if (m_ambient_rays) { writeAmbientGrid( frame_index, - std::span(bounds, std::min(m_shadow_instance_count, VulkanRenderer::instance->voxelShadowInstances())) + std::span( + m_bounds_scratch.data(), std::min(m_shadow_instance_count, VulkanRenderer::instance->voxelShadowInstances()) + ) ); } } - prepareGlass(frame_index, *frame->voxel_storage, std::span(instances, m_draws.size())); + prepareGlass(frame_index, *frame->voxel_storage, std::span(m_instance_scratch.data(), m_draws.size())); // Before any scope of this frame binds the glass set const std::array backdrop { @@ -1349,7 +1378,7 @@ void VoxelPass::recordLighting(vk::CommandBuffer cmd, uint32_t frame_index, vk:: return; } - const std::array views { + const std::array views { instance->getVoxelAlbedoView(), instance->getVoxelFaceNormalView(), instance->getVoxelMaterialView(), @@ -1358,6 +1387,8 @@ void VoxelPass::recordLighting(vk::CommandBuffer cmd, uint32_t frame_index, vk:: instance->getSceneMotionView(), instance->getVoxelCandidateReservoirView(), instance->getSceneNormalView(), + instance->getVoxelNoisyDiffuseView(), + instance->getVoxelNoisySpecularView(), }; if (std::ranges::any_of(views, [](vk::ImageView view) { return !view; })) { return; @@ -1385,6 +1416,10 @@ void VoxelPass::recordLighting(vk::CommandBuffer cmd, uint32_t frame_index, vk:: const auto height = static_cast(viewport.height); const float rotation = frame->taa_active ? std::fmod(static_cast(frame->sequence % 4096) * k_golden_ratio_conjugate, 1.0f) : 0.0f; + const uint32_t traced_specular = + instance->tracedSpecularFrame() + ? k_traced_specular | (static_cast(std::lround(instance->voxelSpecularRoughness() * 255.0f)) << 8) + : 0u; LightingPushConstants push; if (m_denoise_active) { push = LightingPushConstants { @@ -1401,7 +1436,8 @@ void VoxelPass::recordLighting(vk::CommandBuffer cmd, uint32_t frame_index, vk:: ), .reuse = glm::uvec4( (instance->voxelReservoirHistory() ? k_reuse_history : 0u) | (m_spatial_active ? k_reuse_spatial : 0u) | - (m_scene_glass ? k_scene_glass : 0u) | (std::min(instance->voxelGlassShadowReach(), 0xFFFFu) << 16), + (m_scene_glass ? k_scene_glass : 0u) | (instance->voxelAmbientRayQuery() ? k_ambient_ray_query : 0u) | + traced_specular | (std::min(instance->voxelGlassShadowReach(), 0xFFFFu) << 16), k_reservoir_m_cap * instance->voxelLightCandidates(), k_reused_reservoirs, m_ambient_rays ? static_cast(std::lround(instance->voxelAmbientReach() * 100.0f)) : 0u @@ -1422,7 +1458,8 @@ void VoxelPass::recordLighting(vk::CommandBuffer cmd, uint32_t frame_index, vk:: ), .reuse = glm::uvec4( (instance->voxelReservoirHistory() ? k_reuse_history : 0u) | (m_spatial_active ? k_reuse_spatial : 0u) | - (m_scene_glass ? k_scene_glass : 0u) | (std::min(instance->voxelGlassShadowReach(), 0xFFFFu) << 16), + (m_scene_glass ? k_scene_glass : 0u) | (instance->voxelAmbientRayQuery() ? k_ambient_ray_query : 0u) | + traced_specular | (std::min(instance->voxelGlassShadowReach(), 0xFFFFu) << 16), k_reservoir_m_cap * instance->voxelLightCandidates(), k_reused_reservoirs, m_ambient_rays ? static_cast(std::lround(instance->voxelAmbientReach() * 100.0f)) : 0u @@ -1714,8 +1751,8 @@ void VoxelPass::recordDenoiseTemporal(vk::CommandBuffer cmd, uint32_t frame_inde return; } - // A skipped frame or a recreated target leaves a history from somewhere else - m_denoise_frame_valid = m_denoise_valid && frame->sequence == m_denoise_sequence + 1; + // A skipped frame or a recreated target leaves a history from somewhere else and a repeated snapshot keeps it + m_denoise_frame_valid = m_denoise_valid && frame->sequence - m_denoise_sequence <= 1; const uint32_t read = 1 - m_denoise_parity; if (!m_denoise_frame_valid) { m_denoise_history[0][read].makeSampleable(cmd); @@ -1767,8 +1804,54 @@ void VoxelPass::recordSpatial(vk::CommandBuffer cmd, uint32_t frame_index, vk::E cmd.draw(3, 1, 0, 0); } +void VoxelPass::bindMarched(vk::CommandBuffer cmd, uint32_t frame_index, const VulkanPipeline& pipeline) { + const vk::PipelineLayout layout = *m_lighting_layout.getPipelineLayout(); + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, pipeline.getPipeline()); + cmd.bindDescriptorSets( + vk::PipelineBindPoint::eGraphics, + layout, + k_scene_set, + std::array { + m_lighting_scene_sets.get(frame_index), *m_lighting_storage_sets[frame_index], *m_gbuffer_sets[frame_index] + }, + {} + ); + cmd.pushConstants(layout, vk::ShaderStageFlagBits::eAll, 0, sizeof(LightingPushConstants), &m_lighting_push); +} + +void VoxelPass::recordMarched(vk::CommandBuffer cmd, uint32_t frame_index) { + ZoneScoped; + // The scope still clears the reservoirs and noisy specular when nothing marches + if (!(m_marched_lights || m_scene_glass) || !m_marched_pipeline.isReady() || frame_index >= m_gbuffer_sets.size() || + frame_index >= m_lighting_storage_sets.size() || !m_lighting_scene_sets.get(frame_index)) { + return; + } + bindMarched(cmd, frame_index, m_marched_pipeline); + cmd.draw(3, 1, 0, 0); +} + +void VoxelPass::recordAmbient(vk::CommandBuffer cmd, uint32_t frame_index) { + ZoneScoped; + if (!m_ambient_rays || !m_ambient_pipeline.isReady() || frame_index >= m_gbuffer_sets.size() || + frame_index >= m_lighting_storage_sets.size() || !m_lighting_scene_sets.get(frame_index)) { + return; + } + bindMarched(cmd, frame_index, m_ambient_pipeline); + cmd.draw(3, 1, 0, 0); +} + +void VoxelPass::recordMarchedComposite(vk::CommandBuffer cmd, uint32_t frame_index) { + ZoneScoped; + if (!marchedCompositeActive() || !m_marched_composite_pipeline.isReady() || frame_index >= m_gbuffer_sets.size() || + frame_index >= m_lighting_storage_sets.size() || !m_lighting_scene_sets.get(frame_index)) { + return; + } + bindMarched(cmd, frame_index, m_marched_composite_pipeline); + cmd.draw(3, 1, 0, 0); +} + void VoxelPass::onTargetsRecreated() { - std::ranges::fill(m_bound_gbuffer, std::array {}); + std::ranges::fill(m_bound_gbuffer, std::array {}); if (!m_denoise_sets.empty()) { createDenoiseTargets(VulkanRenderer::instance->getOutputTarget().getExtent()); } diff --git a/engine/src/toast/renderer/passes/voxel_pass.hpp b/engine/src/toast/renderer/passes/voxel_pass.hpp index cf9dd271c..a45867c4d 100644 --- a/engine/src/toast/renderer/passes/voxel_pass.hpp +++ b/engine/src/toast/renderer/passes/voxel_pass.hpp @@ -69,9 +69,21 @@ class VoxelPass : public IRenderPass { /// Inside a depth only scope before the G-buffer scope that shades the same quads void recordQuadDepth(vk::CommandBuffer cmd, uint32_t frame_index, bool late); - /// Writes scene colour indirect and rounded normal while reading the G-buffer and depth + /// Writes scene colour indirect rounded normal and noisy diffuse while reading the G-buffer and depth void recordLighting(vk::CommandBuffer cmd, uint32_t frame_index, vk::Extent2D viewport); + /// After prepare() whether recordAmbient() has rays to trace + [[nodiscard]] + auto ambientRaysActive() const noexcept -> bool { + return m_ambient_rays; + } + + /// Inside a scope over noisy diffuse alone with the scene normal sampleable and before recordMarched() + void recordAmbient(vk::CommandBuffer cmd, uint32_t frame_index); + + /// Inside a scope over scene colour noisy terms and reservoirs with the scene normal sampleable + void recordMarched(vk::CommandBuffer cmd, uint32_t frame_index); + struct GlassDraw { float distance_squared = 0.0f; uint32_t instance = 0; @@ -117,6 +129,15 @@ class VoxelPass : public IRenderPass { /// Inside a scope over the scene colour alone void recordDenoiseComposite(vk::CommandBuffer cmd, uint32_t frame_index); + /// After prepare() whether marched terms reach colour without the denoiser + [[nodiscard]] + auto marchedCompositeActive() const noexcept -> bool { + return !m_denoise_active && (m_marched_lights || m_ambient_rays); + } + + /// Inside a scope over the scene colour alone with the noisy terms sampleable + void recordMarchedComposite(vk::CommandBuffer cmd, uint32_t frame_index); + /// After the G-buffer and depth targets are recreated since a new view can reuse a destroyed handle value void onTargetsRecreated(); @@ -235,6 +256,8 @@ class VoxelPass : public IRenderPass { /// After the pyramid is created since its view changes with the depth target void writeHizDescriptors(); void recordCellDraws(vk::CommandBuffer cmd, uint32_t frame_index); + /// The lighting sets and last recordLighting() push constants for a pipeline on the lighting layout + void bindMarched(vk::CommandBuffer cmd, uint32_t frame_index, const VulkanPipeline& pipeline); enum class Cull : uint8_t { back, @@ -331,16 +354,20 @@ class VoxelPass : public IRenderPass { std::vector m_instance_bounds; /// Instances an ambient ray can reach per hashed world cell std::vector m_ambient_grids; - /// Shadow casters with glass bricks so the glass shadow filter skips every other volume - std::vector m_glass_casters; std::vector> m_grid_pairs; std::vector m_grid_global; + /// Prepare scratch copied into the mapped instance and bounds buffers + std::vector m_instance_scratch; + std::vector m_bounds_scratch; /// Drawn instances then the off screen ones only shadow rays test uint32_t m_shadow_instance_count = 0; /// Albedo face normal material and depth last written into each set - std::vector> m_bound_gbuffer; + std::vector> m_bound_gbuffer; VulkanPipeline m_spatial_pipeline; + VulkanPipeline m_marched_pipeline; + VulkanPipeline m_ambient_pipeline; + VulkanPipeline m_marched_composite_pipeline; bool m_spatial_active = false; bool m_scene_glass = false; bool m_backdrop_captured = false; @@ -374,7 +401,6 @@ class VoxelPass : public IRenderPass { /// Change regions of a newly published storage that voxel.slang marks reactive void writeFreshRegions(uint32_t frame_index, const VoxelGpuStorage& storage); void writeAmbientGrid(uint32_t frame_index, std::span bounds); - void writeGlassCasters(uint32_t frame_index, const VoxelGpuStorage& storage, std::span casters); void createDenoise(const VulkanCore& core, vk::Format scene_format, vk::Extent2D extent); void createDenoiseTargets(vk::Extent2D extent); void bindDenoise(vk::CommandBuffer cmd, uint32_t frame_index, const VulkanPipeline& pipeline, float term); diff --git a/engine/src/toast/renderer/post_process_settings.cpp b/engine/src/toast/renderer/post_process_settings.cpp index 6fabdf848..48eff3641 100644 --- a/engine/src/toast/renderer/post_process_settings.cpp +++ b/engine/src/toast/renderer/post_process_settings.cpp @@ -58,6 +58,16 @@ void blendPostProcess(PostProcessSettings& target, const PostProcessSettings& so target.ssao.strength = mix(target.ssao.strength, source.ssao.strength, t); target.ssao.range_cutoff = mix(target.ssao.range_cutoff, source.ssao.range_cutoff, t); target.ssao.sample_count = mixCount(target.ssao.sample_count, source.ssao.sample_count, t); + + target.fog.density = mix(target.fog.density, source.fog.density, t); + target.fog.height = mix(target.fog.height, source.fog.height, t); + target.fog.height_falloff = mix(target.fog.height_falloff, source.fog.height_falloff, t); + target.fog.albedo_r = mix(target.fog.albedo_r, source.fog.albedo_r, t); + target.fog.albedo_g = mix(target.fog.albedo_g, source.fog.albedo_g, t); + target.fog.albedo_b = mix(target.fog.albedo_b, source.fog.albedo_b, t); + target.fog.anisotropy = mix(target.fog.anisotropy, source.fog.anisotropy, t); + target.fog.ambient = mix(target.fog.ambient, source.fog.ambient, t); + target.fog.distance = mix(target.fog.distance, source.fog.distance, t); } } diff --git a/engine/src/toast/renderer/post_process_settings.hpp b/engine/src/toast/renderer/post_process_settings.hpp index 4d69f4e12..d16c1bda5 100644 --- a/engine/src/toast/renderer/post_process_settings.hpp +++ b/engine/src/toast/renderer/post_process_settings.hpp @@ -65,6 +65,22 @@ struct PostProcessSettings { uint32_t sample_count = 16; } ssao; + + struct Fog { + /// Extinction per meter at the base height and 0 turns volumetrics off + float density = 0.0f; + float height = 0.0f; + /// Per meter above the base height and 0 is uniform + float height_falloff = 0.0f; + float albedo_r = 1.0f; + float albedo_g = 1.0f; + float albedo_b = 1.0f; + /// Henyey Greenstein g + float anisotropy = 0.3f; + float ambient = 1.0f; + /// Meters a view ray marches before the sky + float distance = 64.0f; + } fog; }; TOAST_API void blendPostProcess(PostProcessSettings& target, const PostProcessSettings& source, float weight); diff --git a/engine/src/toast/renderer/renderer_settings.cpp b/engine/src/toast/renderer/renderer_settings.cpp index 91b286417..8cf03ad39 100644 --- a/engine/src/toast/renderer/renderer_settings.cpp +++ b/engine/src/toast/renderer/renderer_settings.cpp @@ -6,6 +6,7 @@ #include "vulkan_renderer.hpp" #include +#include #include #include @@ -321,6 +322,16 @@ void registerVoxels(VulkanRenderer& renderer) { ) .onChange([&renderer](bool enabled) { renderer.setVoxelAmbientRays(enabled); }); + toast::settings::declareBool( + "renderer.voxels.ambient_ray_query", + true, + {.label = "Ray query ambient rays", + .category = "Voxels", + .description = "Ambient rays take the exact first hit through hardware ray queries when the GPU has them. Off " + "samples each volume eight times and can step over thin walls"} + ) + .onChange([&renderer](bool enabled) { renderer.setVoxelAmbientRayQuery(enabled); }); + toast::settings::declareFloat( "renderer.voxels.ambient_reach", 2.4, @@ -333,6 +344,40 @@ void registerVoxels(VulkanRenderer& renderer) { ) .onChange([&renderer](double v) { renderer.setVoxelAmbientReach(static_cast(v)); }); + toast::settings::declareBool( + "renderer.voxels.traced_specular", + true, + {.label = "Traced specular", + .category = "Voxels", + .description = "One GGX ray per reflective voxel pixel at half resolution in place of the environment and " + "SSR. Off keeps both"} + ) + .onChange([&renderer](bool enabled) { renderer.setVoxelTracedSpecular(enabled); }); + + toast::settings::declareFloat( + "renderer.voxels.specular_roughness", + 0.6, + {.label = "Traced specular roughness", + .category = "Voxels", + .description = "Rougher voxels keep the environment specular", + .min = 0.0, + .max = 1.0, + .step = 0.05} + ) + .onChange([&renderer](double v) { renderer.setVoxelSpecularRoughness(static_cast(v)); }); + + toast::settings::declareFloat( + "renderer.voxels.specular_reach", + 32.0, + {.label = "Traced specular reach", + .category = "Voxels", + .description = "Meters a reflection ray travels before it reads the environment. Teardown uses 32", + .min = 1.0, + .max = 200.0, + .step = 1.0} + ) + .onChange([&renderer](double v) { renderer.setVoxelSpecularReach(static_cast(v)); }); + toast::settings::declareBool( "renderer.voxels.glass_refraction_enabled", true, @@ -430,6 +475,52 @@ void registerVoxels(VulkanRenderer& renderer) { ) .onChange([&renderer](int64_t v) { renderer.setVoxelSunReach(static_cast(std::max(v, 1))); }); + toast::settings::declareBool( + "renderer.voxels.trace_ray_query", + true, + {.label = "Ray query voxel rays", + .category = "Voxels", + .description = "Marched voxel rays find the volumes they cross through hardware ray queries when the GPU has them. " + "Off walks the software cell grid on every GPU"} + ) + .onChange([&renderer](bool enabled) { renderer.setVoxelTraceRayQuery(enabled); }); + + toast::settings::declareBool( + "renderer.voxels.trace_bricks", + true, + {.label = "Brick BLAS", + .category = "Voxels", + .description = "Ray queries find the occupied bricks a ray crosses instead of whole volumes. Off marches every " + "volume a ray crosses from end to end"} + ) + .onChange([&renderer](bool enabled) { renderer.setVoxelTraceBricks(enabled); }); + + toast::settings::declareInt( + "renderer.voxels.blas_budget", + 262144, + {.label = "BLAS bricks per frame", + .category = "Voxels", + .description = "Bricks of brick BLAS builds a frame may record. A volume waiting for its build traces as a whole", + .min = 4096.0, + .max = 4194304.0} + ) + .onChange([&renderer](int64_t v) { + renderer.setVoxelBlasBudget(static_cast(std::clamp(v, 4096, 4194304))); + }); + + toast::settings::declareFloat( + "renderer.voxels.trace_cell_size", + 4.0, + {.label = "Trace grid cell", + .category = "Voxels", + .description = "Meters per cell of the camera centred grid marched voxel rays walk without ray queries. The grid " + "is 32 cells across", + .min = 0.5, + .max = 32.0, + .step = 0.5} + ) + .onChange([&renderer](double v) { renderer.setVoxelTraceCellSize(static_cast(v)); }); + toast::settings::declareInt( "renderer.voxels.shadow_instances", 256, @@ -541,6 +632,88 @@ void registerEnvironment(VulkanRenderer& renderer) { }); } +void registerVolumetrics(VulkanRenderer& renderer) { + toast::settings::declareBool( + "renderer.volumetrics.enabled", + true, + {.label = "Volumetrics", + .category = "Volumetrics", + .description = "Marched height fog where a post process volume sets a fog density. Off skips the pass"} + ) + .onChange([&renderer](bool enabled) { renderer.setVolumetrics(enabled); }); + + toast::settings::declareInt( + "renderer.volumetrics.quality", + 2, + { + .label = "Volumetric quality", + .category = "Volumetrics", + .description = "Steps, local lights, local light shadows and resolution. Custom uses the settings below", + .options = {"Low", "Medium", "High", "Epic", "Custom"} + } + ) + .onChange([&renderer](int64_t v) { + renderer.setVolumetricQuality( + static_cast(std::clamp(v, 0, VulkanRenderer::k_volumetric_quality_custom)) + ); + }); + + toast::settings::declareInt( + "renderer.volumetrics.steps", + 24, + {.label = "Volumetric steps", + .category = "Volumetrics", + .description = "Samples along each view ray. Custom quality only", + .min = 4.0, + .max = 96.0} + ) + .onChange([&renderer](int64_t v) { renderer.setVolumetricSteps(static_cast(std::max(v, 1))); }); + + toast::settings::declareInt( + "renderer.volumetrics.lights", + 8, + {.label = "Volumetric lights", + .category = "Volumetrics", + .description = "Local lights of a froxel each step scatters. Custom quality only", + .min = 0.0, + .max = 32.0} + ) + .onChange([&renderer](int64_t v) { renderer.setVolumetricLights(static_cast(std::max(v, 0))); }); + + toast::settings::declareBool( + "renderer.volumetrics.light_shadows", + true, + {.label = "Volumetric light shadows", + .category = "Volumetrics", + .description = "Local lights cast shadows into the fog. Custom quality only"} + ) + .onChange([&renderer](bool enabled) { renderer.setVolumetricLightShadows(enabled); }); + + toast::settings::declareInt( + "renderer.volumetrics.resolution", + 0, + { + .label = "Volumetric resolution", + .category = "Volumetrics", + .description = "Custom quality only", + .options = {"Half", "Quarter"} + } + ) + .onChange([&renderer](int64_t v) { renderer.setVolumetricQuarterResolution(v != 0); }); + + toast::settings::declareFloat( + "renderer.volumetrics.distance", + 100.0, + {.label = "Volumetric distance", + .category = "Volumetrics", + .description = "Meters fog volumes stay visible. Height fog keeps its own Fog Distance", + .min = 10.0, + .max = 1000.0, + .step = 5.0} + ) + .onChange([&renderer](double v) { renderer.setVolumetricDistance(static_cast(v)); }); +} + void registerDebug(VulkanRenderer& renderer) { toast::settings::declareBool( k_debug_overlay_in_game, @@ -559,11 +732,238 @@ void registerRendererStartupSettings() { registerShadows(); } +void registerGi(VulkanRenderer& renderer) { + GiSettings& gi = renderer.giSettings(); + toast::settings::declareBool( + "renderer.gi.enabled", + true, + {.label = "Radiance cascades", .category = "Global illumination", .description = "World space radiance cascade probes"} + ) + .onChange([&gi](bool enabled) { gi.enabled.store(enabled, std::memory_order_relaxed); }); + + toast::settings::declareInt( + "renderer.gi.quality", + 2, + { + .label = "Quality", + .category = "Global illumination", + .description = "Rays per frame, cascades, probe spacing, multibounce and update rate. Custom uses the knobs below", + .options = {"Low", "Medium", "High", "Epic", "Custom"} + } + ) + .onChange([&gi](int64_t v) { + gi.quality.store( + static_cast(std::clamp(v, 0, GiSettings::k_quality_custom)), std::memory_order_relaxed + ); + }); + + toast::settings::declareInt( + "renderer.gi.update_interval", + 1, + {.label = "Update interval", + .category = "Global illumination", + .description = "Rendered frames per GI update. Custom quality only", + .min = 1.0, + .max = 4.0} + ) + .onChange([&gi](int64_t v) { + gi.update_interval.store(static_cast(std::clamp(v, 1, 4)), std::memory_order_relaxed); + }); + + toast::settings::declareInt( + "renderer.gi.secondary_points", + 65536, + {.label = "Multibounce points", + .category = "Global illumination", + .description = "Off screen hits kept for the secondary cache per frame. Custom quality only", + .min = 1024.0, + .max = 65536.0} + ) + .onChange([&gi](int64_t v) { + gi.secondary_points.store(static_cast(std::clamp(v, 1024, 65536)), std::memory_order_relaxed); + }); + + toast::settings::declareBool( + "renderer.gi.multibounce", + true, + {.label = "Multibounce", + .category = "Global illumination", + .description = "GI rays that hit something the camera cannot see seed a coarser secondary probe cache and read it " + "back, so light keeps bouncing off screen. Costs about half again the rays. Custom quality only"} + ) + .onChange([&gi](bool enabled) { gi.multibounce.store(enabled, std::memory_order_relaxed); }); + + toast::settings::declareFloat( + "renderer.gi.probe_spacing", + 0.1, + {.label = "Probe spacing", + .category = "Global illumination", + .description = + "Meters between cascade 0 probes near the camera. Every cascade and every LOD doubles it. Custom quality only", + .min = 0.025, + .max = 1.0, + .step = 0.025} + ) + .onChange([&gi](double v) { gi.probe_spacing.store(static_cast(v), std::memory_order_relaxed); }); + + toast::settings::declareFloat( + "renderer.gi.lod_distance", + 4.0, + {.label = "LOD distance", + .category = "Global illumination", + .description = "Meters from the camera where probes start doubling their spacing. Custom quality only", + .min = 0.5, + .max = 64.0, + .step = 0.5} + ) + .onChange([&gi](double v) { gi.lod_distance.store(static_cast(v), std::memory_order_relaxed); }); + + toast::settings::declareInt( + "renderer.gi.cascades", + 5, + {.label = "Cascades", + .category = "Global illumination", + .description = "Each cascade reaches four times farther than the one below. Custom quality only", + .min = 1.0, + .max = 6.0} + ) + .onChange([&gi](int64_t v) { + gi.cascades.store(static_cast(std::clamp(v, 1, 6)), std::memory_order_relaxed); + }); + + toast::settings::declareInt( + "renderer.gi.ray_resolution", + 1, + { + .label = "Ray resolution", + .category = "Global illumination", + .description = "Pixels that place probes and trace rays each frame. Custom quality only", + .options = {"Full", "Half", "Quarter"} + } + ) + .onChange([&gi](int64_t v) { + constexpr std::array k_steps {1, 2, 4}; + gi.ray_step.store(k_steps[static_cast(std::clamp(v, 0, 2))], std::memory_order_relaxed); + }); + + toast::settings::declareInt( + "renderer.gi.probe_lifetime", + 60, + {.label = "Probe lifetime", + .category = "Global illumination", + .description = "Frames a probe stays after no pixel needs it", + .min = 1.0, + .max = 600.0} + ) + .onChange([&gi](int64_t v) { + gi.probe_lifetime.store(static_cast(std::clamp(v, 1, 600)), std::memory_order_relaxed); + }); + + toast::settings::declareFloat( + "renderer.gi.history_seconds", + 0.3, + {.label = "History length", + .category = "Global illumination", + .description = "Seconds of light every probe direction averages, the same at any frame rate. Longer is smoother " + "in dark places lit only through gaps but follows moving lights more slowly, 0 keeps no history. " + "Voxel changes and moving bodies reset the probes around them either way", + .min = 0.0, + .max = 5.0, + .step = 0.05} + ) + .onChange([&gi](double v) { gi.history_seconds.store(static_cast(v), std::memory_order_relaxed); }); + + toast::settings::declareBool( + "renderer.gi.history_per_frame", + false, + {.label = "Per frame history", + .category = "Global illumination", + .description = "Blend a fixed share of every frame into the history instead of a length in seconds. Less ghosting " + "at high frame rates, more flicker where light only reaches through thin gaps"} + ) + .onChange([&gi](bool enabled) { gi.history_per_frame.store(enabled, std::memory_order_relaxed); }); + + toast::settings::declareFloat( + "renderer.gi.history", + 0.03, + {.label = "History blend", + .category = "Global illumination", + .description = "Share of each new frame in the light of every probe direction while Per frame history is on. " + "Lower is smoother but follows moving lights more slowly, 1 keeps no history", + .min = 0.005, + .max = 1.0, + .step = 0.005} + ) + .onChange([&gi](double v) { gi.history_blend.store(static_cast(v), std::memory_order_relaxed); }); + + toast::settings::declareFloat( + "renderer.gi.motion_threshold", + 0.01, + {.label = "Motion threshold", + .category = "Global illumination", + .description = "Meters a voxel body has to move in one frame before the probes around it drop their history. " + "Raise it when resting physics bodies jitter and the perf window keeps reporting moved bodies", + .min = 0.0, + .max = 0.2, + .step = 0.001} + ) + .onChange([&gi](double v) { gi.motion_threshold.store(static_cast(v), std::memory_order_relaxed); }); + + toast::settings::declareFloat( + "renderer.gi.max_ray_radiance", + 16.0, + {.label = "Ray brightness clamp", + .category = "Global illumination", + .description = "Brightest a single GI ray hit may count as, keeping its colour. Rays rarely catch a small very bright " + "emitter like a lamp shade, so each catch jumps the light around it. Lower is steadier, higher keeps " + "more bounce from bright emitters", + .min = 0.5, + .max = 1000.0, + .step = 0.5} + ) + .onChange([&gi](double v) { gi.max_ray_radiance.store(static_cast(v), std::memory_order_relaxed); }); + + toast::settings::declareInt( + "renderer.gi.debug_view", + 0, + { + .label = "Probe view", + .category = "Global illumination", + .description = "Draws the probe cells of one cascade over the scene. Red has no probe and dim was carried from an " + "earlier frame", + .options = {"Off", "Cascade 0", "Cascade 1", "Cascade 2", "Cascade 3", "Cascade 4", "Cascade 5"} + } + ) + .onChange([&gi](int64_t v) { + gi.debug_view.store(static_cast(std::clamp(v, 0, 6)), std::memory_order_relaxed); + }); + + toast::settings::declareInt( + "renderer.gi.debug_mode", + 0, + { + .label = "Probe view shows", + .category = "Global illumination", + .description = "Probe cells, the accumulated radiance of the cascade interval, the share of directions facing the " + "surface that got a ray this frame, the merged irradiance lighting will read, which ignores the " + "cascade picked, or how many rays of history the directions facing the surface hold, red none, " + "yellow about 8, green 64 and up, or how much the irradiance at each pixel changes per frame, black " + "steady, blue 1%, yellow 5%, red 15% and up. Purple probes have no storage", + .options = {"Probe cells", "Interval radiance", "Coverage", "Irradiance", "History depth", "Irradiance change"} + } + ) + .onChange([&gi](int64_t v) { + gi.debug_mode.store(static_cast(std::clamp(v, 0, 5)), std::memory_order_relaxed); + }); +} + void registerRendererSettings(VulkanRenderer& renderer) { registerDisplay(renderer); registerQuality(renderer); registerVoxels(renderer); + registerGi(renderer); registerEnvironment(renderer); + registerVolumetrics(renderer); registerDebug(renderer); } diff --git a/engine/src/toast/renderer/scene_descriptor_set.cpp b/engine/src/toast/renderer/scene_descriptor_set.cpp index 8de5abc1b..068b99f1c 100644 --- a/engine/src/toast/renderer/scene_descriptor_set.cpp +++ b/engine/src/toast/renderer/scene_descriptor_set.cpp @@ -3,6 +3,7 @@ #include "descriptor_writer.hpp" #include "passes/cluster_lighting_pass.hpp" #include "passes/environment_pass.hpp" +#include "passes/gi_pass.hpp" #include "passes/reflection_probe_pass.hpp" #include "passes/shadow_pass.hpp" #include "ray_tracing_scene.hpp" @@ -77,6 +78,21 @@ void SceneDescriptorSets::create( continue; } + // Left unwritten for GiPass own sets since it is created after them and they never sample GI + if (binding.name == "gGiIrradiance") { + if (const auto* gi = VulkanRenderer::instance->giPass(); gi != nullptr) { + writer.image( + *m_sets[i], + binding.binding, + vk::DescriptorType::eCombinedImageSampler, + gi->irradianceSampler(), + gi->irradianceView(), + vk::ImageLayout::eShaderReadOnlyOptimal + ); + } + continue; + } + const bool is_probe_irradiance = binding.name == "gProbeIrradiance"; if (binding.name == "gProbeMaps" || is_probe_irradiance) { const auto* probes = VulkanRenderer::instance->getReflectionProbePass(); @@ -171,9 +187,17 @@ void SceneDescriptorSets::create( buffer = VulkanRenderer::instance->getInstanceBuffer(i); size = sizeof(VulkanRenderer::InstanceData) * VulkanRenderer::k_max_instances; descriptor_type = vk::DescriptorType::eStorageBuffer; + } else if (binding.name == "gFogVolumes") { + buffer = VulkanRenderer::instance->getFogVolumeBuffer(i); + descriptor_type = vk::DescriptorType::eStorageBuffer; } else if (binding.name == "gBlueNoise") { buffer = VulkanRenderer::instance->getBlueNoiseBuffer(); descriptor_type = vk::DescriptorType::eStorageBuffer; + } else if (binding.name.starts_with("gGi")) { + if (const auto* gi = VulkanRenderer::instance->giPass(); gi != nullptr) { + buffer = gi->sceneBuffer(binding.name); + } + descriptor_type = vk::DescriptorType::eStorageBuffer; } else if (binding.name == "gIrradianceSH") { if (auto* probes = VulkanRenderer::instance->getReflectionProbePassMutable(); probes != nullptr) { buffer = probes->getShBuffer(); diff --git a/engine/src/toast/renderer/voxel_blas_cache.cpp b/engine/src/toast/renderer/voxel_blas_cache.cpp new file mode 100644 index 000000000..65af2f1c2 --- /dev/null +++ b/engine/src/toast/renderer/voxel_blas_cache.cpp @@ -0,0 +1,401 @@ +/// @file voxel_blas_cache.cpp +/// @author dario +/// @date 25/10/2026 + +#include "voxel_blas_cache.hpp" + +#include "gpu_sync.hpp" +#include "vulkan_core.hpp" +#include "vulkan_debug.hpp" + +#include +#include +#include +#include +#include +#include + +namespace renderer { + +namespace { + +auto aabbGeometry(vk::DeviceAddress aabbs) -> vk::AccelerationStructureGeometryKHR { + vk::AccelerationStructureGeometryAabbsDataKHR data {}; + data.data.deviceAddress = aabbs; + data.stride = sizeof(vk::AabbPositionsKHR); + + vk::AccelerationStructureGeometryKHR geometry {}; + geometry.geometryType = vk::GeometryTypeKHR::eAabbs; + geometry.geometry.aabbs = data; + geometry.flags = vk::GeometryFlagBitsKHR::eOpaque | vk::GeometryFlagBitsKHR::eNoDuplicateAnyHitInvocation; + return geometry; +} + +auto alignUp(vk::DeviceSize value, vk::DeviceSize alignment) -> vk::DeviceSize { + return (value + alignment - 1) / alignment * alignment; +} + +auto brickBox(uint32_t brick) -> vk::AabbPositionsKHR { + constexpr uint32_t mask = (1u << k_brick_coord_bits) - 1u; + const auto dim = static_cast(voxel::k_brick_dim); + const float x = static_cast(brick & mask) * dim; + const float y = static_cast((brick >> k_brick_coord_bits) & mask) * dim; + const float z = static_cast(brick >> (2 * k_brick_coord_bits)) * dim; + return {x, y, z, x + dim, y + dim, z + dim}; +} + +} + +VoxelBlasCache::VoxelBlasCache(const VulkanCore& core, uint32_t frames_in_flight) : m_core(&core), m_frames(frames_in_flight) { + vk::BufferCreateInfo pool_ci {}; + pool_ci.size = sizeof(uint32_t) * k_pool_bricks; + pool_ci.usage = vk::BufferUsageFlagBits::eStorageBuffer | vk::BufferUsageFlagBits::eTransferDst; + vma::AllocationCreateInfo pool_alloc {}; + pool_alloc.usage = vma::MemoryUsage::eAutoPreferDevice; + m_pool = core.getAllocator().createBuffer(pool_ci, pool_alloc); + setDebugName(core, *m_pool, "VoxelBlas BrickPool"); + m_free.emplace(0u, k_pool_bricks); +} + +auto VoxelBlasCache::allocate(uint32_t count) -> std::optional { + for (auto it = m_free.begin(); it != m_free.end(); ++it) { + if (it->second < count) { + continue; + } + const uint32_t base = it->first; + const uint32_t rest = it->second - count; + m_free.erase(it); + if (rest > 0) { + m_free.emplace(base + count, rest); + } + m_pool_used += count; + return base; + } + return std::nullopt; +} + +void VoxelBlasCache::release(uint32_t base, uint32_t count) { + if (count == 0) { + return; + } + m_pool_used -= count; + auto next = m_free.lower_bound(base); + if (next != m_free.end() && base + count == next->first) { + count += next->second; + next = m_free.erase(next); + } + if (next != m_free.begin()) { + auto previous = std::prev(next); + if (previous->first + previous->second == base) { + previous->second += count; + return; + } + } + m_free.emplace(base, count); +} + +void VoxelBlasCache::retire(FrameResources& frame, Entry& entry) { + for (std::optional* blas : {&entry.opaque, &entry.glass}) { + if (blas->has_value()) { + frame.retired.push_back(std::move(**blas)); + blas->reset(); + } + } +} + +auto VoxelBlasCache::createBlas(uint32_t count, vk::DeviceSize& scratch_size) -> std::optional { + const auto& device = m_core->getDevice(); + const vk::AccelerationStructureGeometryKHR geometry = aabbGeometry(0); + vk::AccelerationStructureBuildGeometryInfoKHR build {}; + build.type = vk::AccelerationStructureTypeKHR::eBottomLevel; + build.flags = vk::BuildAccelerationStructureFlagBitsKHR::ePreferFastTrace; + build.mode = vk::BuildAccelerationStructureModeKHR::eBuild; + build.geometryCount = 1; + build.pGeometries = &geometry; + const auto sizes = device.getAccelerationStructureBuildSizesKHR(vk::AccelerationStructureBuildTypeKHR::eDevice, build, count); + + Blas blas; + vk::BufferCreateInfo storage_ci {}; + storage_ci.size = sizes.accelerationStructureSize; + storage_ci.usage = vk::BufferUsageFlagBits::eAccelerationStructureStorageKHR | vk::BufferUsageFlagBits::eShaderDeviceAddress; + vma::AllocationCreateInfo alloc_ci {}; + alloc_ci.usage = vma::MemoryUsage::eAutoPreferDevice; + blas.storage = m_core->getAllocator().createBuffer(storage_ci, alloc_ci); + setDebugName(*m_core, *blas.storage, "VoxelBlas Storage"); + + vk::AccelerationStructureCreateInfoKHR as_ci {}; + as_ci.buffer = *blas.storage; + as_ci.size = sizes.accelerationStructureSize; + as_ci.type = vk::AccelerationStructureTypeKHR::eBottomLevel; + blas.handle = vk::raii::AccelerationStructureKHR(device, as_ci); + + vk::AccelerationStructureDeviceAddressInfoKHR address_info {}; + address_info.accelerationStructure = *blas.handle; + blas.address = device.getAccelerationStructureAddressKHR(address_info); + blas.count = count; + blas.bytes = sizes.accelerationStructureSize; + scratch_size = sizes.buildScratchSize; + return blas; +} + +void VoxelBlasCache::update( + vk::CommandBuffer cmd, uint32_t frame_index, const VoxelGpuStorage& storage, std::span records, + uint32_t budget +) { + ZoneScoped; + if (frame_index >= m_frames.size()) { + return; + } + FrameResources& frame = m_frames[frame_index]; + for (const Blas& blas : frame.retired) { + release(blas.base, blas.count); + } + frame.retired.clear(); + ++m_frame; + Stats stats; + + m_jobs.clear(); + for (const uint32_t record : records) { + const auto& list = storage.brickList(record); + const uint64_t rid = storage.nodeRid(record); + if (!list || rid == 0 || list->occupied.size() <= k_unit_box_bricks) { + continue; + } + Entry& entry = m_entries[rid]; + if (entry.last_used == m_frame) { + continue; + } + entry.last_used = m_frame; + + size_t dead = 0; + if (entry.built) { + const VoxelBrickList& built = *entry.built; + const size_t total = built.occupied.size() + built.glass.size(); + dead = total - std::min(total, list->occupied.size() + list->glass.size()); + if (entry.checked != list->hash) { + entry.checked = list->hash; + entry.usable = + list->hash == built.hash || (built.dims == list->dims && std::ranges::includes(built.occupied, list->occupied) && + std::ranges::includes(built.glass, list->glass)); + entry.stale = entry.usable && list->hash != built.hash && dead * 10 >= total; + } + } + if (!entry.built || !entry.usable) { + m_jobs.push_back({.node_rid = rid, .list = list, .required = true}); + } else if (entry.stale) { + m_jobs.push_back({.node_rid = rid, .list = list, .dead = dead}); + ++stats.stale; + } + } + std::ranges::stable_sort(m_jobs, [](const Job& a, const Job& b) { + return a.required != b.required ? a.required : a.dead > b.dead; + }); + + size_t chosen = 0; + uint64_t planned = 0; + for (; chosen < m_jobs.size(); ++chosen) { + const size_t bricks = m_jobs[chosen].list->occupied.size() + m_jobs[chosen].list->glass.size(); + if (chosen > 0 && planned + bricks > budget) { + break; + } + planned += bricks; + } + for (size_t i = chosen; i < m_jobs.size(); ++i) { + stats.waiting += m_jobs[i].required ? 1u : 0u; + } + + if (chosen > 0) { + const vk::DeviceSize coords_offset = alignUp(sizeof(vk::AabbPositionsKHR) * planned, 16); + const vk::DeviceSize staging_size = coords_offset + (sizeof(uint32_t) * planned); + if (!frame.staging.has_value() || frame.staging_size < staging_size) { + frame.staging_size = std::max(staging_size, frame.staging_size + (frame.staging_size / 2)); + vk::BufferCreateInfo staging_ci {}; + staging_ci.size = frame.staging_size; + staging_ci.usage = vk::BufferUsageFlagBits::eAccelerationStructureBuildInputReadOnlyKHR | + vk::BufferUsageFlagBits::eShaderDeviceAddress | vk::BufferUsageFlagBits::eTransferSrc; + vma::AllocationCreateInfo staging_alloc {}; + staging_alloc.usage = vma::MemoryUsage::eAuto; + staging_alloc.flags = vma::AllocationCreateFlagBits::eMapped | vma::AllocationCreateFlagBits::eHostAccessSequentialWrite; + frame.staging.reset(); + frame.staging.emplace(m_core->getAllocator().createBuffer(staging_ci, staging_alloc)); + setDebugName(*m_core, **frame.staging, std::format("VoxelBlas Staging[{}]", frame_index)); + } + auto* mapped = static_cast(frame.staging->getAllocation().getInfo().pMappedData); + auto* boxes = reinterpret_cast(mapped); + auto* coords = reinterpret_cast(mapped + coords_offset); + const vk::DeviceAddress staging_address = m_core->getBufferAddress(**frame.staging); + + struct Built { + uint64_t node_rid = 0; + std::shared_ptr list; + std::optional opaque; + std::optional glass; + }; + + std::vector built; + std::vector geometries; + std::vector infos; + std::vector ranges; + std::vector scratch_offsets; + std::vector copies; + built.reserve(chosen); + geometries.reserve(2 * chosen); + infos.reserve(2 * chosen); + ranges.reserve(2 * chosen); + scratch_offsets.reserve(2 * chosen); + const vk::DeviceSize scratch_alignment = m_core->getScratchAllocationSize(0); + vk::DeviceSize scratch_total = 0; + uint32_t written = 0; + + const auto add = [&](std::span bricks) -> std::optional { + const auto count = static_cast(bricks.size()); + const std::optional base = allocate(count); + if (!base.has_value()) { + return std::nullopt; + } + vk::DeviceSize scratch_size = 0; + std::optional blas = createBlas(count, scratch_size); + if (!blas.has_value()) { + release(*base, count); + return std::nullopt; + } + blas->base = *base; + for (uint32_t i = 0; i < count; ++i) { + boxes[written + i] = brickBox(bricks[i]); + } + std::memcpy(coords + written, bricks.data(), sizeof(uint32_t) * count); + copies.emplace_back(coords_offset + (sizeof(uint32_t) * written), sizeof(uint32_t) * *base, sizeof(uint32_t) * count); + + geometries.push_back(aabbGeometry(staging_address + (sizeof(vk::AabbPositionsKHR) * written))); + vk::AccelerationStructureBuildGeometryInfoKHR info {}; + info.type = vk::AccelerationStructureTypeKHR::eBottomLevel; + info.flags = vk::BuildAccelerationStructureFlagBitsKHR::ePreferFastTrace; + info.mode = vk::BuildAccelerationStructureModeKHR::eBuild; + info.geometryCount = 1; + info.pGeometries = &geometries.back(); + info.dstAccelerationStructure = *blas->handle; + infos.push_back(info); + vk::AccelerationStructureBuildRangeInfoKHR range {}; + range.primitiveCount = count; + ranges.push_back(range); + scratch_offsets.push_back(scratch_total); + scratch_total = alignUp(scratch_total + scratch_size, scratch_alignment); + written += count; + return blas; + }; + + for (size_t j = 0; j < chosen; ++j) { + const Job& job = m_jobs[j]; + Built entry {.node_rid = job.node_rid, .list = job.list}; + entry.opaque = add(job.list->occupied); + if (entry.opaque.has_value() && !job.list->glass.empty()) { + entry.glass = add(job.list->glass); + } + if (!entry.opaque.has_value() || (!job.list->glass.empty() && !entry.glass.has_value())) { + // Built but never referenced so it is freed with this slot + for (std::optional* blas : {&entry.opaque, &entry.glass}) { + if (blas->has_value()) { + frame.retired.push_back(std::move(**blas)); + } + } + ++stats.pool_full; + continue; + } + stats.built_bricks += entry.opaque->count + (entry.glass.has_value() ? entry.glass->count : 0u); + built.push_back(std::move(entry)); + } + + if (!infos.empty()) { + if (!frame.scratch.has_value() || frame.scratch_size < m_core->getScratchAllocationSize(scratch_total)) { + frame.scratch_size = + m_core->getScratchAllocationSize(std::max(scratch_total, frame.scratch_size + (frame.scratch_size / 2))); + vk::BufferCreateInfo scratch_ci {}; + scratch_ci.size = frame.scratch_size; + scratch_ci.usage = vk::BufferUsageFlagBits::eStorageBuffer | vk::BufferUsageFlagBits::eShaderDeviceAddress; + vma::AllocationCreateInfo scratch_alloc {}; + scratch_alloc.usage = vma::MemoryUsage::eAutoPreferDevice; + frame.scratch.reset(); + frame.scratch.emplace(m_core->getAllocator().createBuffer(scratch_ci, scratch_alloc)); + setDebugName(*m_core, **frame.scratch, std::format("VoxelBlas Scratch[{}]", frame_index)); + } + const vk::DeviceAddress scratch_base = m_core->getAlignedScratchAddress(**frame.scratch); + for (size_t i = 0; i < infos.size(); ++i) { + infos[i].scratchData.deviceAddress = scratch_base + scratch_offsets[i]; + } + frame.staging->getAllocation().flush(0, staging_size); + + cmd.copyBuffer(**frame.staging, *m_pool, copies); + std::vector range_ptrs; + range_ptrs.reserve(ranges.size()); + for (const auto& range : ranges) { + range_ptrs.push_back(reinterpret_cast(&range)); + } + m_core->getDevice().getDispatcher()->vkCmdBuildAccelerationStructuresKHR( + static_cast(cmd), + static_cast(infos.size()), + reinterpret_cast(infos.data()), + range_ptrs.data() + ); + + // The TLAS build that follows references these and every voxel ray reads the brick pool + sync::BarrierBatch barriers; + barriers.memory(sync::Usage::acceleration_build, sync::Usage::acceleration_build); + barriers.memory(sync::Usage::transfer_dst, sync::Usage::compute_storage_read); + barriers.memory(sync::Usage::transfer_dst, sync::Usage::fragment_storage_read); + barriers.flush(cmd); + } + + for (Built& result : built) { + Entry& entry = m_entries[result.node_rid]; + retire(frame, entry); + entry.opaque = std::move(result.opaque); + entry.glass = std::move(result.glass); + entry.lookup = Lookup { + .opaque = entry.opaque->address, + .glass = entry.glass.has_value() ? entry.glass->address : 0, + .opaque_base = entry.opaque->base, + .glass_base = entry.glass.has_value() ? entry.glass->base : 0, + }; + entry.checked = result.list->hash; + entry.built = std::move(result.list); + entry.usable = true; + entry.stale = false; + ++stats.builds; + } + } + + for (auto it = m_entries.begin(); it != m_entries.end();) { + if (it->second.last_used + k_evict_frames < m_frame) { + retire(frame, it->second); + it = m_entries.erase(it); + continue; + } + for (const std::optional* blas : {&it->second.opaque, &it->second.glass}) { + if (blas->has_value()) { + ++stats.blases; + stats.bricks += (*blas)->count; + stats.bytes += (*blas)->bytes; + } + } + ++it; + } + stats.pool_used = m_pool_used; + + std::scoped_lock lock(m_stats_mutex); + m_stats = stats; +} + +auto VoxelBlasCache::lookup(uint64_t node_rid) const -> const Lookup* { + const auto found = m_entries.find(node_rid); + if (found == m_entries.end() || !found->second.usable || !found->second.opaque.has_value()) { + return nullptr; + } + return &found->second.lookup; +} + +auto VoxelBlasCache::stats() const -> Stats { + std::scoped_lock lock(m_stats_mutex); + return m_stats; +} + +} diff --git a/engine/src/toast/renderer/voxel_blas_cache.hpp b/engine/src/toast/renderer/voxel_blas_cache.hpp new file mode 100644 index 000000000..66b25ed6a --- /dev/null +++ b/engine/src/toast/renderer/voxel_blas_cache.hpp @@ -0,0 +1,137 @@ +/// @file voxel_blas_cache.hpp +/// @author dario +/// @date 25/10/2026 + +#pragma once + +#include "voxel_gpu_storage.hpp" +#include "vulkan_common.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace renderer { +class VulkanCore; + +/// One AABB per occupied brick per record where carving keeps a built BLAS correct and only a newly occupied brick rebuilds +/// @note Render thread only apart from stats() +class VoxelBlasCache { +public: + /// Records this small keep the unit box since marching them whole is already short + static constexpr uint32_t k_unit_box_bricks = 8; + /// Brick coordinates gTraceBricks holds for every BLAS together + static constexpr uint32_t k_pool_bricks = 1u << 22; + /// Frames a record goes unused before its BLAS is freed + static constexpr uint64_t k_evict_frames = 240; + /// Mirrors scene_trace.slang kTraceInstanceBricks and kTraceInstanceGlass + static constexpr uint32_t k_instance_bricks = 1u << 23; + static constexpr uint32_t k_instance_glass = 1u << 22; + + struct Lookup { + vk::DeviceAddress opaque = 0; + vk::DeviceAddress glass = 0; + uint32_t opaque_base = 0; + uint32_t glass_base = 0; + }; + + struct Stats { + uint32_t blases = 0; + uint64_t bricks = 0; + uint64_t bytes = 0; + uint32_t builds = 0; + uint32_t built_bricks = 0; + /// Usable BLASes that lost enough bricks to want a rebuild + uint32_t stale = 0; + /// Records on the unit box until their build fits the budget + uint32_t waiting = 0; + uint32_t pool_used = 0; + uint32_t pool_full = 0; + }; + + VoxelBlasCache(const VulkanCore& core, uint32_t frames_in_flight); + + /// Before the TLAS build once the fence of @p frame_index was waited on + /// @param records the volume records the trace scene instances this frame + /// @param budget bricks built per frame though the first build always goes ahead + void update( + vk::CommandBuffer cmd, uint32_t frame_index, const VoxelGpuStorage& storage, std::span records, + uint32_t budget + ); + + /// Null keeps the volume on the unit box + [[nodiscard]] + auto lookup(uint64_t node_rid) const -> const Lookup*; + + [[nodiscard]] + auto poolBuffer() const -> vk::Buffer { + return *m_pool; + } + + [[nodiscard]] + auto stats() const -> Stats; + +private: + struct Blas { + vma::raii::Buffer storage = nullptr; + vk::raii::AccelerationStructureKHR handle = nullptr; + vk::DeviceAddress address = 0; + uint32_t base = 0; + uint32_t count = 0; + vk::DeviceSize bytes = 0; + }; + + struct Entry { + std::shared_ptr built; + std::optional opaque; + std::optional glass; + Lookup lookup; + /// Hash of the last list checked against the built one + uint64_t checked = 0; + bool usable = false; + bool stale = false; + uint64_t last_used = 0; + }; + + struct FrameResources { + std::optional staging; + vk::DeviceSize staging_size = 0; + std::optional scratch; + vk::DeviceSize scratch_size = 0; + /// Freed once this slot comes round again since older frames may still trace them + std::vector retired; + }; + + struct Job { + uint64_t node_rid = 0; + std::shared_ptr list; + bool required = false; + size_t dead = 0; + }; + + void retire(FrameResources& frame, Entry& entry); + auto allocate(uint32_t count) -> std::optional; + void release(uint32_t base, uint32_t count); + auto createBlas(uint32_t count, vk::DeviceSize& scratch_size) -> std::optional; + + const VulkanCore* m_core = nullptr; + std::vector m_frames; + std::unordered_map m_entries; + std::vector m_jobs; + + vma::raii::Buffer m_pool = nullptr; + /// Free runs of the pool as offset to length + std::map m_free; + uint32_t m_pool_used = 0; + uint64_t m_frame = 0; + + mutable std::mutex m_stats_mutex; + Stats m_stats; +}; + +} diff --git a/engine/src/toast/renderer/voxel_debug.cpp b/engine/src/toast/renderer/voxel_debug.cpp index 138cb872e..0e0178233 100644 --- a/engine/src/toast/renderer/voxel_debug.cpp +++ b/engine/src/toast/renderer/voxel_debug.cpp @@ -2,7 +2,9 @@ #include "depth_convention.hpp" #include "passes/voxel_pass.hpp" +#include "voxel_blas_cache.hpp" #include "voxel_gpu_storage.hpp" +#include "vulkan_renderer.hpp" #include #include @@ -553,6 +555,32 @@ void Monitor::drawPanel(const VulkanRenderer::RenderFrame& frame) const { ImGui::TextDisabled("%zu full in the last 5 s, %u rendered frames ago", m_uploads.size(), m_frames_since_upload); } ImGui::TextDisabled("%u pool slots, %u palettes, %u records", info.packed_slots, info.packed_palettes, storage.recordCount()); + size_t occupied = 0; + size_t glass = 0; + uint32_t small = 0; + for (uint32_t record = 0; record < storage.recordCount(); ++record) { + if (const auto& list = storage.brickList(record)) { + occupied += list->occupied.size(); + glass += list->glass.size(); + small += list->occupied.size() <= VoxelBlasCache::k_unit_box_bricks ? 1u : 0u; + } + } + ImGui::TextDisabled("%zu occupied bricks, %zu glass, %u records on the unit box", occupied, glass, small); + if (const VoxelBlasCache* blas = VulkanRenderer::instance->voxelBlasCache()) { + const VoxelBlasCache::Stats s = blas->stats(); + ImGui::TextDisabled( + "Brick BLAS %u, %llu bricks, %.1f MB, pool %u of %u", + s.blases, + static_cast(s.bricks), + static_cast(s.bytes) / (1024.0 * 1024.0), + s.pool_used, + VoxelBlasCache::k_pool_bricks + ); + ImGui::TextDisabled("Built %u with %u bricks this frame, %u stale, %u waiting", s.builds, s.built_bricks, s.stale, s.waiting); + if (s.pool_full > 0) { + ImGui::TextColored(k_warn_color, "%u brick BLAS builds found the brick pool full", s.pool_full); + } + } ImGui::TextDisabled("CPU mirror %s", info.mirror ? "kept for the cursor probe" : "off, kept only while a voxel view is active"); constexpr std::array, VoxelGpuStorage::k_section_count> k_sections { diff --git a/engine/src/toast/renderer/voxel_gpu_storage.hpp b/engine/src/toast/renderer/voxel_gpu_storage.hpp index 14989c6e1..757bb3e5a 100644 --- a/engine/src/toast/renderer/voxel_gpu_storage.hpp +++ b/engine/src/toast/renderer/voxel_gpu_storage.hpp @@ -51,6 +51,18 @@ struct VoxelStorageDebugInfo { class VoxelGpuSections; +/// Mirrors scene_trace.slang kTraceBrickBits +inline constexpr uint32_t k_brick_coord_bits = 10; + +/// Bricks of one record packed as x | y << 10 | z << 20 for its brick BLAS +struct VoxelBrickList { + std::vector occupied; + std::vector glass; + /// A BLAS built for other dims may hold boxes outside the volume + glm::uvec3 dims {0}; + uint64_t hash = 0; +}; + /// Immutable scene version and versions from small carves share buffers patched in place class VoxelGpuStorage : public IVulkanResource { public: @@ -137,6 +149,15 @@ class VoxelGpuStorage : public IVulkanResource { return record < m_glass_bricks.size() ? std::span(m_glass_bricks[record]) : std::span {}; } + /// @note Main thread only and before the upload is queued + void setBrickLists(std::vector> lists) { m_brick_lists = std::move(lists); } + + [[nodiscard]] + auto brickList(uint32_t record) const -> const std::shared_ptr& { + static const std::shared_ptr k_none; + return record < m_brick_lists.size() ? m_brick_lists[record] : k_none; + } + private: friend class VoxelSceneUpload; @@ -155,6 +176,7 @@ class VoxelGpuStorage : public IVulkanResource { VoxelChangeHistory m_history; std::vector> m_glass_bricks; + std::vector> m_brick_lists; }; struct VoxelCollisionWorld { diff --git a/engine/src/toast/renderer/voxel_trace_scene.cpp b/engine/src/toast/renderer/voxel_trace_scene.cpp new file mode 100644 index 000000000..aa343e268 --- /dev/null +++ b/engine/src/toast/renderer/voxel_trace_scene.cpp @@ -0,0 +1,349 @@ +/// @file voxel_trace_scene.cpp +/// @author dario +/// @date 21/10/2026 + +#include "voxel_trace_scene.hpp" + +#include "gpu_sync.hpp" +#include "ray_tracing_scene.hpp" +#include "voxel_blas_cache.hpp" +#include "vulkan_core.hpp" +#include "vulkan_debug.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace renderer { + +namespace { + +auto mappedBuffer(const VulkanCore& core, vk::DeviceSize size, vk::BufferUsageFlags usage, std::string_view name) + -> vma::raii::Buffer { + vk::BufferCreateInfo buffer_ci {}; + buffer_ci.size = size; + buffer_ci.usage = usage; + buffer_ci.sharingMode = vk::SharingMode::eExclusive; + + // Every marched ray reads these so device local host visible when there is any + vma::AllocationCreateInfo alloc_ci {}; + alloc_ci.usage = vma::MemoryUsage::eAuto; + alloc_ci.flags = vma::AllocationCreateFlagBits::eMapped | vma::AllocationCreateFlagBits::eHostAccessSequentialWrite; + vma::raii::Buffer buffer = core.getAllocator().createBuffer(buffer_ci, alloc_ci); + setDebugName(core, *buffer, name); + return buffer; +} + +template +auto mapped(const vma::raii::Buffer& buffer) -> T* { + return static_cast(buffer.getAllocation().getInfo().pMappedData); +} + +/// The geometry data is a union that defaults to triangles so the aabbs member needs its own sType +auto boxGeometry(vk::DeviceAddress aabb) -> vk::AccelerationStructureGeometryKHR { + vk::AccelerationStructureGeometryAabbsDataKHR aabbs {}; + aabbs.data.deviceAddress = aabb; + aabbs.stride = sizeof(vk::AabbPositionsKHR); + + vk::AccelerationStructureGeometryKHR geometry {}; + geometry.geometryType = vk::GeometryTypeKHR::eAabbs; + geometry.geometry.aabbs = aabbs; + geometry.flags = vk::GeometryFlagBitsKHR::eOpaque | vk::GeometryFlagBitsKHR::eNoDuplicateAnyHitInvocation; + return geometry; +} + +} + +VoxelTraceScene::VoxelTraceScene(const VulkanCore& core, uint32_t frames_in_flight) : m_core(&core) { + ZoneScoped; + m_frames.resize(frames_in_flight); + for (uint32_t i = 0; i < frames_in_flight; ++i) { + FrameResources& frame = m_frames[i]; + frame.volumes = mappedBuffer( + core, + sizeof(VoxelInstanceGpu) * k_max_volumes, + vk::BufferUsageFlagBits::eStorageBuffer, + std::format("VoxelTrace Volumes[{}]", i) + ); + frame.bounds = mappedBuffer( + core, sizeof(glm::vec4) * k_max_volumes, vk::BufferUsageFlagBits::eStorageBuffer, std::format("VoxelTrace Bounds[{}]", i) + ); + frame.grid = mappedBuffer( + core, sizeof(uint32_t) * k_grid_words, vk::BufferUsageFlagBits::eStorageBuffer, std::format("VoxelTrace Grid[{}]", i) + ); + frame.brick_bases = mappedBuffer( + core, + sizeof(glm::uvec4) * k_max_volumes, + vk::BufferUsageFlagBits::eStorageBuffer, + std::format("VoxelTrace BrickBases[{}]", i) + ); + if (auto* words = mapped(frame.grid)) { + std::fill_n(words, k_header_words, 0u); + frame.grid.getAllocation().flush(0, sizeof(uint32_t) * k_header_words); + } + } + m_cell_counts.resize(static_cast(k_grid_dim) * k_grid_dim * k_grid_dim); + m_cell_cursor.resize(m_cell_counts.size()); + + if (core.isRayTracingSupported()) { + createUnitBox(); + } +} + +void VoxelTraceScene::update(uint32_t frame_index, std::span volumes, glm::vec3 camera_position, float cell_size) { + ZoneScoped; + if (frame_index >= m_frames.size()) { + return; + } + FrameResources& frame = m_frames[frame_index]; + + m_volumes.assign(volumes.begin(), volumes.begin() + std::min(volumes.size(), k_max_volumes)); + + auto* instances = mapped(frame.volumes); + auto* bounds = mapped(frame.bounds); + if (instances == nullptr || bounds == nullptr) { + m_volumes.clear(); + } + for (size_t i = 0; i < m_volumes.size(); ++i) { + instances[i] = m_volumes[i].instance; + bounds[i] = m_volumes[i].bounds; + } + if (!m_volumes.empty()) { + frame.volumes.getAllocation().flush(0, sizeof(VoxelInstanceGpu) * m_volumes.size()); + frame.bounds.getAllocation().flush(0, sizeof(glm::vec4) * m_volumes.size()); + } + + writeGrid(frame, camera_position, cell_size); +} + +void VoxelTraceScene::writeGrid(FrameResources& frame, glm::vec3 camera_position, float cell_size) { + ZoneScoped; + auto* words = mapped(frame.grid); + if (words == nullptr) { + return; + } + + const float cell = std::max(cell_size, 0.25f); + const auto dim = static_cast(k_grid_dim); + const glm::vec3 origin = (glm::floor(camera_position / cell) - glm::vec3(static_cast(dim / 2.0))) * cell; + + struct Span { + glm::ivec3 lo; + glm::ivec3 hi; + }; + + std::vector spans(m_volumes.size()); + std::vector far; + std::vector glass; + std::ranges::fill(m_cell_counts, 0u); + + const auto cell_index = [dim](int32_t x, int32_t y, int32_t z) { + return static_cast(x) + (static_cast(y) * dim) + (static_cast(z) * dim * dim); + }; + + // Volumes leaving the window or covering too many cells are tested by every ray instead + uint64_t entries = 0; + for (size_t i = 0; i < m_volumes.size(); ++i) { + const glm::vec4 b = m_volumes[i].bounds; + const glm::ivec3 lo(glm::floor((glm::vec3(b) - b.w - origin) / cell)); + const glm::ivec3 hi(glm::floor((glm::vec3(b) + b.w - origin) / cell)); + const glm::ivec3 span = hi - lo + 1; + const uint64_t cells = static_cast(span.x) * span.y * span.z; + const bool inside = glm::all(glm::greaterThanEqual(lo, glm::ivec3(0))) && glm::all(glm::lessThan(hi, glm::ivec3(dim))); + if (m_volumes[i].glass) { + glass.push_back(static_cast(i)); + } + if (!inside || cells > k_max_cells_per_volume || entries + cells > k_max_entries) { + far.push_back(static_cast(i)); + spans[i] = {.lo = glm::ivec3(0), .hi = glm::ivec3(-1)}; + continue; + } + spans[i] = {.lo = lo, .hi = hi}; + entries += cells; + for (int32_t z = lo.z; z <= hi.z; ++z) { + for (int32_t y = lo.y; y <= hi.y; ++y) { + for (int32_t x = lo.x; x <= hi.x; ++x) { + ++m_cell_counts[cell_index(x, y, z)]; + } + } + } + } + + const uint32_t cells_offset = k_header_words; + const auto entries_offset = static_cast(cells_offset + (2 * m_cell_counts.size())); + uint32_t running = 0; + for (size_t c = 0; c < m_cell_counts.size(); ++c) { + words[cells_offset + (2 * c)] = running; + words[cells_offset + (2 * c) + 1] = m_cell_counts[c]; + m_cell_cursor[c] = running; + running += m_cell_counts[c]; + } + for (size_t i = 0; i < spans.size(); ++i) { + const Span& s = spans[i]; + for (int32_t z = s.lo.z; z <= s.hi.z; ++z) { + for (int32_t y = s.lo.y; y <= s.hi.y; ++y) { + for (int32_t x = s.lo.x; x <= s.hi.x; ++x) { + words[entries_offset + m_cell_cursor[cell_index(x, y, z)]++] = static_cast(i); + } + } + } + } + + const uint32_t far_offset = entries_offset + running; + const auto glass_offset = static_cast(far_offset + far.size()); + std::ranges::copy(far, words + far_offset); + std::ranges::copy(glass, words + glass_offset); + + words[0] = std::bit_cast(origin.x); + words[1] = std::bit_cast(origin.y); + words[2] = std::bit_cast(origin.z); + words[3] = std::bit_cast(cell); + words[4] = k_grid_dim; + words[5] = static_cast(m_volumes.size()); + words[6] = static_cast(far.size()); + words[7] = static_cast(glass.size()); + words[8] = far_offset; + words[9] = glass_offset; + words[10] = entries_offset; + std::fill(words + 11, words + k_header_words, 0u); + + frame.grid.getAllocation().flush(0, sizeof(uint32_t) * (glass_offset + glass.size())); +} + +void VoxelTraceScene::createUnitBox() { + ZoneScoped; + const auto& device = m_core->getDevice(); + + m_box_aabb.emplace(mappedBuffer( + *m_core, + sizeof(vk::AabbPositionsKHR), + vk::BufferUsageFlagBits::eAccelerationStructureBuildInputReadOnlyKHR | vk::BufferUsageFlagBits::eShaderDeviceAddress, + "VoxelTrace UnitBox" + )); + if (auto* box = mapped(*m_box_aabb)) { + *box = vk::AabbPositionsKHR(0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f); + m_box_aabb->getAllocation().flush(0, sizeof(vk::AabbPositionsKHR)); + } + + const vk::AccelerationStructureGeometryKHR geometry = boxGeometry(m_core->getBufferAddress(**m_box_aabb)); + + vk::AccelerationStructureBuildGeometryInfoKHR build {}; + build.type = vk::AccelerationStructureTypeKHR::eBottomLevel; + build.flags = vk::BuildAccelerationStructureFlagBitsKHR::ePreferFastTrace; + build.mode = vk::BuildAccelerationStructureModeKHR::eBuild; + build.geometryCount = 1; + build.pGeometries = &geometry; + const auto sizes = device.getAccelerationStructureBuildSizesKHR(vk::AccelerationStructureBuildTypeKHR::eDevice, build, 1u); + + vk::BufferCreateInfo storage_ci {}; + storage_ci.size = sizes.accelerationStructureSize; + storage_ci.usage = vk::BufferUsageFlagBits::eAccelerationStructureStorageKHR | vk::BufferUsageFlagBits::eShaderDeviceAddress; + vma::AllocationCreateInfo device_alloc {}; + device_alloc.usage = vma::MemoryUsage::eAutoPreferDevice; + m_box_storage.emplace(m_core->getAllocator().createBuffer(storage_ci, device_alloc)); + + vk::AccelerationStructureCreateInfoKHR as_ci {}; + as_ci.buffer = **m_box_storage; + as_ci.size = sizes.accelerationStructureSize; + as_ci.type = vk::AccelerationStructureTypeKHR::eBottomLevel; + m_box = vk::raii::AccelerationStructureKHR(device, as_ci); + setDebugName(*m_core, **m_box_storage, "VoxelTrace UnitBoxBLAS"); + + vk::BufferCreateInfo scratch_ci {}; + scratch_ci.size = m_core->getScratchAllocationSize(sizes.buildScratchSize); + scratch_ci.usage = vk::BufferUsageFlagBits::eStorageBuffer | vk::BufferUsageFlagBits::eShaderDeviceAddress; + m_box_scratch.emplace(m_core->getAllocator().createBuffer(scratch_ci, device_alloc)); + + vk::AccelerationStructureDeviceAddressInfoKHR address_info {}; + address_info.accelerationStructure = *m_box; + m_box_address = device.getAccelerationStructureAddressKHR(address_info); +} + +auto VoxelTraceScene::addRayQueryInstances( + vk::CommandBuffer cmd, uint32_t frame_index, RayTracingScene& scene, const VoxelBlasCache* bricks, + const VoxelGpuStorage* storage +) -> bool { + ZoneScoped; + if (m_box_address == 0) { + return false; + } + + if (!m_box_built) { + const vk::AccelerationStructureGeometryKHR geometry = boxGeometry(m_core->getBufferAddress(**m_box_aabb)); + + vk::AccelerationStructureBuildGeometryInfoKHR build {}; + build.type = vk::AccelerationStructureTypeKHR::eBottomLevel; + build.flags = vk::BuildAccelerationStructureFlagBitsKHR::ePreferFastTrace; + build.mode = vk::BuildAccelerationStructureModeKHR::eBuild; + build.geometryCount = 1; + build.pGeometries = &geometry; + build.dstAccelerationStructure = *m_box; + build.scratchData.deviceAddress = m_core->getAlignedScratchAddress(**m_box_scratch); + + vk::AccelerationStructureBuildRangeInfoKHR range {}; + range.primitiveCount = 1; + const auto* range_ptr = reinterpret_cast(&range); + m_core->getDevice().getDispatcher()->vkCmdBuildAccelerationStructuresKHR( + static_cast(cmd), + 1, + reinterpret_cast(&build), + &range_ptr + ); + + // The TLAS build that follows references it + sync::BarrierBatch barriers; + barriers.memory(sync::Usage::acceleration_build, sync::Usage::acceleration_build); + barriers.flush(cmd); + m_box_built = true; + } + + auto* bases = frame_index < m_frames.size() ? mapped(m_frames[frame_index].brick_bases) : nullptr; + for (size_t i = 0; i < m_volumes.size(); ++i) { + const Volume& volume = m_volumes[i]; + const VoxelBlasCache::Lookup* found = bricks != nullptr && storage != nullptr && bases != nullptr + ? bricks->lookup(storage->nodeRid(volume.instance.record_index)) + : nullptr; + if (found != nullptr) { + const auto index = static_cast(i) | VoxelBlasCache::k_instance_bricks; + scene.addInstance( + RayTracingScene::Instance { + .transform = volume.instance.voxel_to_world, + .blas_address = found->opaque, + .custom_index = index, + .mask = k_mask_volume + } + ); + if (found->glass != 0) { + scene.addInstance( + RayTracingScene::Instance { + .transform = volume.instance.voxel_to_world, + .blas_address = found->glass, + .custom_index = index | VoxelBlasCache::k_instance_glass, + .mask = k_mask_glass, + } + ); + } + bases[i] = glm::uvec4(found->opaque_base, found->glass_base, volume.instance.record_index, 0u); + continue; + } + scene.addInstance( + RayTracingScene::Instance { + .transform = volume.instance.voxel_to_world * glm::scale(glm::mat4(1.0f), glm::vec3(volume.voxel_dims)), + .blas_address = m_box_address, + .custom_index = static_cast(i), + .mask = static_cast(k_mask_volume | (volume.glass ? k_mask_glass : 0)), + } + ); + } + if (bases != nullptr && !m_volumes.empty()) { + m_frames[frame_index].brick_bases.getAllocation().flush(0, sizeof(glm::uvec4) * m_volumes.size()); + } + return true; +} + +} diff --git a/engine/src/toast/renderer/voxel_trace_scene.hpp b/engine/src/toast/renderer/voxel_trace_scene.hpp new file mode 100644 index 000000000..580cbc333 --- /dev/null +++ b/engine/src/toast/renderer/voxel_trace_scene.hpp @@ -0,0 +1,109 @@ +/// @file voxel_trace_scene.hpp +/// @author dario +/// @date 21/10/2026 + +#pragma once + +#include "voxel_gpu_storage.hpp" +#include "vulkan_common.hpp" + +#include +#include +#include +#include +#include + +namespace renderer { +class VulkanCore; +class RayTracingScene; +class VoxelBlasCache; + +/// Voxel volumes every marched ray walks in one order shared by scene_trace.slang and the ray query instances +/// Without ray queries rays walk a camera centred cell grid over the same list +class VoxelTraceScene { +public: + struct Volume { + VoxelInstanceGpu instance; + glm::vec4 bounds {0.0f}; + bool glass = false; + /// Voxel space box is [0 dims) + glm::uvec3 voxel_dims {0}; + }; + + static constexpr uint32_t k_max_volumes = 4096; + static constexpr uint32_t k_grid_dim = 32; + static constexpr uint32_t k_max_cells_per_volume = 64; + static constexpr uint32_t k_max_entries = 131072; + /// Mirrors scene_trace.slang kTraceHeaderWords + static constexpr uint32_t k_header_words = 16; + static constexpr uint32_t k_grid_words = + k_header_words + (2 * k_grid_dim * k_grid_dim * k_grid_dim) + k_max_entries + (2 * k_max_volumes); + + /// Mirrors scene_trace.slang kTraceMask while mesh instances use 0x01 + static constexpr uint8_t k_mask_volume = 0x02; + static constexpr uint8_t k_mask_glass = 0x04; + + VoxelTraceScene(const VulkanCore& core, uint32_t frames_in_flight); + + /// Render thread before the acceleration structure builds and every pass that marches voxels + void update(uint32_t frame_index, std::span volumes, glm::vec3 camera_position, float cell_size); + + /// Builds the shared unit box once and adds the brick BLASes of @p bricks or the unit box per volume of the last update + /// @return false while the unit box is not usable yet + auto addRayQueryInstances( + vk::CommandBuffer cmd, uint32_t frame_index, RayTracingScene& scene, const VoxelBlasCache* bricks, + const VoxelGpuStorage* storage + ) -> bool; + + [[nodiscard]] + auto volumeBuffer(uint32_t frame_index) const -> vk::Buffer { + return *m_frames[frame_index].volumes; + } + + [[nodiscard]] + auto boundsBuffer(uint32_t frame_index) const -> vk::Buffer { + return *m_frames[frame_index].bounds; + } + + [[nodiscard]] + auto gridBuffer(uint32_t frame_index) const -> vk::Buffer { + return *m_frames[frame_index].grid; + } + + /// Mirrors scene_trace.slang gTraceBrickBases + [[nodiscard]] + auto brickBaseBuffer(uint32_t frame_index) const -> vk::Buffer { + return *m_frames[frame_index].brick_bases; + } + + [[nodiscard]] + auto volumeCount() const noexcept -> uint32_t { + return static_cast(m_volumes.size()); + } + +private: + struct FrameResources { + vma::raii::Buffer volumes = nullptr; + vma::raii::Buffer bounds = nullptr; + vma::raii::Buffer grid = nullptr; + vma::raii::Buffer brick_bases = nullptr; + }; + + void writeGrid(FrameResources& frame, glm::vec3 camera_position, float cell_size); + void createUnitBox(); + + const VulkanCore* m_core = nullptr; + std::vector m_frames; + std::vector m_volumes; + std::vector m_cell_counts; + std::vector m_cell_cursor; + + std::optional m_box_aabb; + std::optional m_box_storage; + std::optional m_box_scratch; + vk::raii::AccelerationStructureKHR m_box = nullptr; + vk::DeviceAddress m_box_address = 0; + bool m_box_built = false; +}; + +} diff --git a/engine/src/toast/renderer/vulkan_core.cpp b/engine/src/toast/renderer/vulkan_core.cpp index d81215f35..47ce96f00 100644 --- a/engine/src/toast/renderer/vulkan_core.cpp +++ b/engine/src/toast/renderer/vulkan_core.cpp @@ -655,6 +655,11 @@ void VulkanCore::createLogicalDeviceAndAllocator(std::span re enabled_features.dualSrcBlend = m_dual_source_blend_supported ? VK_TRUE : VK_FALSE; // HiZPyramid picks its level image by a loop index enabled_features.shaderStorageImageArrayDynamicIndexing = VK_TRUE; + // Replays can start a pass from state no frame wrote so an index past a buffer reads zero instead of losing the device + if (m_nsight_mode != NsightMode::none && device_features.robustBufferAccess == VK_TRUE) { + enabled_features.robustBufferAccess = VK_TRUE; + TOAST_INFO("Render", "Nsight is attached so robust buffer access is on"); + } vk::PhysicalDeviceFeatures2 supported_features {}; vk::PhysicalDeviceVulkan12Features supported_vulkan12 {}; diff --git a/engine/src/toast/renderer/vulkan_renderer.cpp b/engine/src/toast/renderer/vulkan_renderer.cpp index 8cc0aed01..a47dcd8b9 100644 --- a/engine/src/toast/renderer/vulkan_renderer.cpp +++ b/engine/src/toast/renderer/vulkan_renderer.cpp @@ -18,6 +18,8 @@ #include "passes/selection_outline.hpp" #include "passes/shadow_pass.hpp" #include "passes/skinning_pass.hpp" +#include "passes/taa_pass.hpp" +#include "passes/traced_specular_pass.hpp" #include "passes/voxel_pass.hpp" #include "ray_tracing_scene.hpp" #include "renderer_settings.hpp" @@ -55,10 +57,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -69,6 +73,7 @@ #include #include #include +#include #if defined(_WIN32) #include @@ -492,6 +497,77 @@ auto packedGlassBricks(const voxel::gpu::PackedScene& scene) -> std::vector uint32_t { + const uint32_t x = index % record.brick_dims_x; + const uint32_t y = (index / record.brick_dims_x) % record.brick_dims_y; + const uint32_t z = index / (record.brick_dims_x * record.brick_dims_y); + return x | (y << k_brick_coord_bits) | (z << (2 * k_brick_coord_bits)); +} + +auto hashBricks(const VoxelBrickList& list) -> uint64_t { + uint64_t hash = 14695981039346656037ull; + const auto mix = [&hash](uint32_t word) { hash = (hash ^ word) * 1099511628211ull; }; + for (const uint32_t brick : list.occupied) { + mix(brick); + } + mix(0xFFFFFFFFu); + for (const uint32_t brick : list.glass) { + mix(brick); + } + mix(list.dims.x); + mix(list.dims.y); + mix(list.dims.z); + return hash; +} + +/// Records no delta touched share the list of @p previous +[[nodiscard]] +auto packedBrickLists( + const voxel::gpu::PackedScene& scene, const std::vector>& glass, const VoxelGpuStorage& storage, + const VoxelGpuStorage* previous, std::span deltas +) -> std::vector> { + std::unordered_set dirty; + for (const VolumeDelta& delta : deltas) { + if (delta.unknown || !delta.bricks.empty()) { + dirty.insert(delta.node_rid); + } + } + + std::vector> out(scene.records.size()); + for (uint32_t r = 0; r < scene.records.size(); ++r) { + const voxel::gpu::VolumeRecord& record = scene.records[r]; + auto list = std::make_shared(); + list->dims = glm::uvec3(record.brick_dims_x, record.brick_dims_y, record.brick_dims_z); + for (const uint32_t index : glass[r]) { + list->glass.push_back(brickCoord(record, index)); + } + + const uint64_t rid = storage.nodeRid(r); + const auto kept = previous != nullptr && rid != 0 && !dirty.contains(rid) ? previous->recordIndexOf(rid) : std::nullopt; + const VoxelBrickList* old = kept.has_value() ? previous->brickList(*kept).get() : nullptr; + if (old != nullptr && old->dims != list->dims) { + old = nullptr; + } + if (old != nullptr && old->glass == list->glass) { + out[r] = previous->brickList(*kept); + continue; + } + if (old != nullptr) { + list->occupied = old->occupied; + } else { + const uint32_t count = record.brick_dims_x * record.brick_dims_y * record.brick_dims_z; + for (uint32_t i = 0; i < count; ++i) { + if (voxel::BrickEntry {scene.grids[record.grid_offset + i]}.tag() != voxel::BrickTag::empty) { + list->occupied.push_back(brickCoord(record, i)); + } + } + } + list->hash = hashBricks(*list); + out[r] = std::move(list); + } + return out; +} + } auto VulkanRenderer::getPrepassDrawnCount() const noexcept -> uint32_t { @@ -606,6 +682,45 @@ auto VulkanRenderer::blendPostProcessVolumes(const glm::vec3& camera_position) - return result; } +void VulkanRenderer::gatherFogVolumes(RenderFrame& frame, const glm::vec3& camera_position) { + ZoneScoped; + frame.fog_volumes.clear(); + auto& volumes = m_tick_fog_volumes; + { + std::scoped_lock lock(m_reflection_probe_mutex); + volumes.assign(m_fog_volume_nodes.begin(), m_fog_volume_nodes.end()); + } + std::erase_if(volumes, [this](const toast::FogVolume* volume) { + return volume == nullptr || !volume->isEnabled() || volume->density() <= 0.0f || + (m_render_owner_filter != nullptr && volume->owner() != m_render_owner_filter); + }); + + for (const auto* volume : volumes) { + volume->syncTransform(); + } + const auto centre = [](const toast::FogVolume* volume) { return glm::vec3(volume->getWorldTransform()[3]); }; + std::ranges::sort(volumes, {}, [&](const toast::FogVolume* volume) { return glm::distance(centre(volume), camera_position); }); + if (volumes.size() > k_max_fog_volumes) { + volumes.resize(k_max_fog_volumes); + } + + frame.fog_volumes.reserve(volumes.size()); + for (const auto* volume : volumes) { + debug::drawOrientedBox(volume->getWorldTransform(), volume->extents(), glm::vec4(0.6f, 0.75f, 0.9f, 1.0f)); + frame.fog_volumes.push_back( + FogVolumeGpu { + .world_to_local = glm::inverse(volume->getWorldTransform()), + .extents_fade = glm::vec4(glm::abs(volume->extents()), std::max(volume->edgeFade(), 0.0f)), + .albedo_density = glm::vec4(volume->albedo(), volume->density()), + .emission_noise = glm::vec4(volume->emission(), std::clamp(volume->noiseStrength(), 0.0f, 1.0f)), + .wind_scale = glm::vec4(volume->wind(), std::max(volume->noiseScale(), 0.05f)), + .phase = glm::vec4(std::clamp(volume->anisotropy(), -0.9f, 0.9f), std::max(volume->ambient(), 0.0f), 0.0f, 0.0f), + .centre = glm::vec4(glm::vec3(volume->getWorldTransform()[3]), 1.0f), + } + ); + } +} + void VulkanRenderer::cancelIrradianceBake() { if (m_irradiance_bake_cursor < 0) { return; @@ -770,10 +885,12 @@ VulkanRenderer::VulkanRenderer(const VulkanCore& core, std::unique_ptr(*m_core); + m_voxel_trace_scene = std::make_unique(*m_core, k_frames_in_flight); if (m_core->isRayTracingSupported()) { m_ray_tracing_scene = std::make_unique(*m_core, k_frames_in_flight); m_skinned_blas_pool = std::make_unique(*m_core); + m_voxel_blas_cache = std::make_unique(*m_core, k_frames_in_flight); } m_capture_listener.subscribe([this](const auto&) { @@ -1198,6 +1315,20 @@ void VulkanRenderer::createFrameResources() { setDebugName(*m_core, **frame.gpu_buffer, std::format("VulkanRenderer JointMatrices[{}]", i)); } + m_fog_volume_res.resize(k_frames_in_flight); + for (uint32_t i = 0; i < m_fog_volume_res.size(); ++i) { + vk::BufferCreateInfo buffer_ci {}; + buffer_ci.size = sizeof(FogVolumeGpu) * k_max_fog_volumes; + buffer_ci.usage = vk::BufferUsageFlagBits::eStorageBuffer; + + vma::AllocationCreateInfo alloc_ci {}; + alloc_ci.usage = vma::MemoryUsage::eAutoPreferHost; + alloc_ci.flags = vma::AllocationCreateFlagBits::eMapped | vma::AllocationCreateFlagBits::eHostAccessSequentialWrite; + + m_fog_volume_res[i].gpu_buffer.emplace(m_core->getAllocator().createBuffer(buffer_ci, alloc_ci)); + setDebugName(*m_core, **m_fog_volume_res[i].gpu_buffer, std::format("VulkanRenderer FogVolumes[{}]", i)); + } + const auto create_instance_buffers = [this](std::vector& target, std::string_view debug_name) { target.resize(k_frames_in_flight); for (uint32_t i = 0; i < target.size(); ++i) { @@ -1394,6 +1525,40 @@ void VulkanRenderer::publishCompletedFrames() { } } +void VulkanRenderer::updateVoxelTraceScene() { + ZoneScoped; + const auto* render_frame = renderingFrame(); + if (m_voxel_trace_scene == nullptr || render_frame == nullptr) { + return; + } + + m_trace_volumes.clear(); + const VoxelGpuStorage* storage = render_frame->voxel_storage.get(); + if (storage != nullptr && storage->isReady()) { + const auto cap = std::min(voxelShadowInstances(), VoxelTraceScene::k_max_volumes); + // Drawn volumes first so the cap drops off screen ones + for (const bool visible : {true, false}) { + for (const VoxelVolumeProxy& proxy : render_frame->voxel_instances) { + if (proxy.visible != visible || m_trace_volumes.size() >= cap) { + continue; + } + VoxelTraceScene::Volume volume { + .instance = + makeVoxelInstance(proxy.model, proxy.inverse_model, proxy.previous_model, proxy.has_previous, proxy.record_index), + .bounds = glm::vec4(proxy.bounds_center, proxy.bounds_radius), + .glass = !storage->glassBricks(proxy.record_index).empty(), + .voxel_dims = proxy.brick_dims * voxel::k_brick_dim, + }; + if (proxy.mirrored) { + volume.instance.flags |= k_voxel_instance_mirrored; + } + m_trace_volumes.push_back(volume); + } + } + } + m_voxel_trace_scene->update(m_current_frame, m_trace_volumes, render_frame->frame_data.camera_position, voxelTraceCellSize()); +} + void VulkanRenderer::recordAccelerationStructureBuilds(FrameContext& frame) { ZoneScoped; if (!m_core->isRayTracingSupported()) { @@ -1478,10 +1643,27 @@ void VulkanRenderer::recordAccelerationStructureBuilds(FrameContext& frame) { } m_ray_tracing_scene->addInstance( - {.transform = posed ? glm::mat4(1.0f) : proxy.model, .blas_address = blas_address, .deforming = posed} + {.transform = posed ? glm::mat4(1.0f) : proxy.model, .blas_address = blas_address, .mask = 0x01, .deforming = posed} ); } - m_ray_tracing_scene->build(*frame.command_buffer, m_current_frame, tracing); + const bool queries = render_frame->frame_data.traced_shadow_params.y >= 0.5f && m_voxel_trace_scene != nullptr; + const VoxelGpuStorage* storage = render_frame->voxel_storage.get(); + const bool bricks = + queries && m_voxel_blas_cache != nullptr && voxelTraceBricks() && storage != nullptr && storage->isReady(); + if (bricks) { + const GpuScope scope(m_gpu_timer.get(), m_current_frame, *frame.command_buffer, "Voxel BLAS"); + m_trace_records.clear(); + for (const VoxelTraceScene::Volume& volume : m_trace_volumes) { + m_trace_records.push_back(volume.instance.record_index); + } + m_voxel_blas_cache->update(*frame.command_buffer, m_current_frame, *storage, m_trace_records, voxelBlasBudget()); + } + const bool voxel_queries = + queries && + m_voxel_trace_scene->addRayQueryInstances( + *frame.command_buffer, m_current_frame, *m_ray_tracing_scene, bricks ? m_voxel_blas_cache.get() : nullptr, storage + ); + m_ray_tracing_scene->build(*frame.command_buffer, m_current_frame, tracing || voxel_queries); } } @@ -1592,6 +1774,7 @@ auto VulkanRenderer::recordVoxelScopes( FrameContext& frame, const vk::Viewport& viewport, const vk::Rect2D& scissor, bool depth_loaded ) -> bool { ZoneScoped; + m_traced_specular_frame = false; const bool targets_ready = m_depth_resources.view.has_value() && m_voxel_albedo.view.has_value() && m_voxel_material.view.has_value() && m_voxel_face_normal.view.has_value() && m_scene_color.view.has_value() && m_scene_normal.view.has_value() && @@ -1602,6 +1785,12 @@ auto VulkanRenderer::recordVoxelScopes( m_depth_resources.state.usage != sync::Usage::depth_attachment || !m_voxel_pass->prepare(m_current_frame)) { return false; } + // Decided once since voxel lighting and the post pass must agree on which pixels lost their environment specular + const auto* specular_frame = renderingFrame(); + m_traced_specular_frame = m_traced_specular_pass != nullptr && m_traced_specular_pass->isEnabled() && + m_traced_specular_pass->isReady() && voxelTracedSpecular() && specular_frame != nullptr && + specular_frame->render_mode == 0 && specular_frame->probe_capture_index < 0 && + specular_frame->irradiance_capture_index < 0; const vk::CommandBuffer cmd = *frame.command_buffer; { @@ -1754,7 +1943,7 @@ auto VulkanRenderer::recordVoxelScopes( const auto* lit_frame = renderingFrame(); const uint64_t sequence = lit_frame != nullptr ? lit_frame->sequence : 0; const bool capture = lit_frame != nullptr && (lit_frame->probe_capture_index >= 0 || lit_frame->irradiance_capture_index >= 0); - m_voxel_reservoir_history = !capture && m_voxel_reservoir_written && sequence == m_voxel_reservoir_sequence + 1; + m_voxel_reservoir_history = !capture && m_voxel_reservoir_written && sequence - m_voxel_reservoir_sequence <= 1; // Clears what no voxel covers since the world scope now loads instead const std::array lighting_attachments { @@ -1762,8 +1951,6 @@ auto VulkanRenderer::recordVoxelScopes( attachment(m_scene_indirect, vk::AttachmentLoadOp::eClear, clear_zero), attachment(m_scene_normal, vk::AttachmentLoadOp::eClear, clear_zero), attachment(m_voxel_noisy_diffuse, vk::AttachmentLoadOp::eClear, clear_zero), - attachment(m_voxel_noisy_specular, vk::AttachmentLoadOp::eClear, clear_zero), - attachment(lighting_reservoirs, vk::AttachmentLoadOp::eClear, clear_zero), }; vk::RenderingInfo lighting_info {}; @@ -1782,9 +1969,58 @@ auto VulkanRenderer::recordVoxelScopes( } cmd.endRendering(); + barriers.image(m_scene_normal.state, sync::Usage::fragment_sampled); + barriers.image(m_scene_color.state, sync::Usage::color_attachment); + barriers.image(m_voxel_noisy_diffuse.state, sync::Usage::color_attachment); + barriers.flush(cmd); + + // Before the marched scope so occlusion scales only the indirect + if (m_voxel_pass->ambientRaysActive()) { + const std::array ambient_attachments {attachment(m_voxel_noisy_diffuse, vk::AttachmentLoadOp::eLoad, clear_zero)}; + vk::RenderingInfo ambient_info {}; + ambient_info.renderArea = scissor; + ambient_info.layerCount = 1; + ambient_info.colorAttachmentCount = static_cast(ambient_attachments.size()); + ambient_info.pColorAttachments = ambient_attachments.data(); + + cmd.beginRendering(ambient_info); + cmd.setViewport(0, std::array {viewport}); + cmd.setScissor(0, std::array {scissor}); + { + TracyVkZone(m_tracy_vk_ctx, cmd, "VoxelAmbientRays"); + const GpuScope scope(m_gpu_timer.get(), m_current_frame, cmd, "Voxel ambient rays"); + m_voxel_pass->recordAmbient(cmd, m_current_frame); + } + cmd.endRendering(); + barriers.image(m_voxel_noisy_diffuse.state, sync::Usage::color_attachment); + barriers.flush(cmd); + } + + // Always recorded so the reservoirs and noisy specular clear even when nothing marches + const std::array marched_attachments { + attachment(m_scene_color, vk::AttachmentLoadOp::eLoad, clear_scene), + attachment(m_voxel_noisy_diffuse, vk::AttachmentLoadOp::eLoad, clear_zero), + attachment(m_voxel_noisy_specular, vk::AttachmentLoadOp::eClear, clear_zero), + attachment(lighting_reservoirs, vk::AttachmentLoadOp::eClear, clear_zero), + }; + vk::RenderingInfo marched_info {}; + marched_info.renderArea = scissor; + marched_info.layerCount = 1; + marched_info.colorAttachmentCount = static_cast(marched_attachments.size()); + marched_info.pColorAttachments = marched_attachments.data(); + + cmd.beginRendering(marched_info); + cmd.setViewport(0, std::array {viewport}); + cmd.setScissor(0, std::array {scissor}); + { + TracyVkZone(m_tracy_vk_ctx, cmd, "VoxelMarchedLighting"); + const GpuScope scope(m_gpu_timer.get(), m_current_frame, cmd, "Voxel marched lighting"); + m_voxel_pass->recordMarched(cmd, m_current_frame); + } + cmd.endRendering(); + if (spatial) { barriers.image(m_voxel_candidate_reservoirs.state, sync::Usage::fragment_sampled); - barriers.image(m_scene_normal.state, sync::Usage::fragment_sampled); barriers.image(m_voxel_reservoirs[m_voxel_reservoir_parity].state, sync::Usage::color_attachment); barriers.image(m_voxel_noisy_diffuse.state, sync::Usage::color_attachment); barriers.image(m_voxel_noisy_specular.state, sync::Usage::color_attachment); @@ -1810,8 +2046,6 @@ auto VulkanRenderer::recordVoxelScopes( m_voxel_pass->recordSpatial(cmd, m_current_frame, scissor.extent); } cmd.endRendering(); - barriers.image(m_scene_normal.state, sync::Usage::color_attachment); - barriers.flush(cmd); } m_voxel_reservoir_parity = 1 - m_voxel_reservoir_parity; m_voxel_reservoir_written = !capture; @@ -1846,6 +2080,27 @@ auto VulkanRenderer::recordVoxelScopes( m_voxel_pass->recordDenoiseComposite(cmd, m_current_frame); } cmd.endRendering(); + } else if (m_voxel_pass->marchedCompositeActive()) { + barriers.image(m_voxel_noisy_diffuse.state, sync::Usage::fragment_sampled); + barriers.image(m_voxel_noisy_specular.state, sync::Usage::fragment_sampled); + barriers.image(m_scene_color.state, sync::Usage::color_attachment); + barriers.flush(cmd); + const std::array composite_attachments {attachment(m_scene_color, vk::AttachmentLoadOp::eLoad, clear_scene)}; + vk::RenderingInfo composite_info {}; + composite_info.renderArea = scissor; + composite_info.layerCount = 1; + composite_info.colorAttachmentCount = static_cast(composite_attachments.size()); + composite_info.pColorAttachments = composite_attachments.data(); + + cmd.beginRendering(composite_info); + cmd.setViewport(0, std::array {viewport}); + cmd.setScissor(0, std::array {scissor}); + { + TracyVkZone(m_tracy_vk_ctx, cmd, "VoxelMarchedComposite"); + const GpuScope scope(m_gpu_timer.get(), m_current_frame, cmd, "Voxel marched composite"); + m_voxel_pass->recordMarchedComposite(cmd, m_current_frame); + } + cmd.endRendering(); } // The world scope loads all of these so writes above must land first @@ -1896,6 +2151,7 @@ auto VulkanRenderer::recordFrame(FrameContext& frame, uint32_t image_index) noex } frame.blas_scratch.clear(); + updateVoxelTraceScene(); { const GpuScope scope(m_gpu_timer.get(), m_current_frame, *frame.command_buffer, "Acceleration structures"); recordAccelerationStructureBuilds(frame); @@ -2153,6 +2409,11 @@ auto VulkanRenderer::recordFrame(FrameContext& frame, uint32_t image_index) noex barriers.image(m_scene_indirect.state, sync::Usage::shader_sampled); barriers.image(m_scene_motion.state, sync::Usage::shader_sampled); barriers.image(m_depth_resources.state, sync::Usage::depth_sampled); + if (m_traced_specular_frame) { + barriers.image(m_voxel_albedo.state, sync::Usage::fragment_sampled); + barriers.image(m_voxel_material.state, sync::Usage::fragment_sampled); + barriers.image(m_voxel_face_normal.state, sync::Usage::fragment_sampled); + } barriers.flush(*frame.command_buffer); const auto* capture_check = renderingFrame(); @@ -2406,6 +2667,17 @@ void VulkanRenderer::updateFrameResources(uint32_t frame_index, RenderFrame& fra upload_instances(m_instance_res, frame_index, frame_data.instance_data); upload_instances(m_shadow_instance_res, frame_index, frame_data.shadow_instance_data); + + if (frame_index < m_fog_volume_res.size() && m_fog_volume_res[frame_index].gpu_buffer.has_value() && + !frame_data.fog_volumes.empty()) { + const auto count = std::min(frame_data.fog_volumes.size(), k_max_fog_volumes); + const auto bytes = static_cast(count) * sizeof(FogVolumeGpu); + const auto& fog_allocation = m_fog_volume_res[frame_index].gpu_buffer->getAllocation(); + if (auto* fog_mapped = fog_allocation.getInfo().pMappedData) { + std::memcpy(fog_mapped, frame_data.fog_volumes.data(), bytes); + fog_allocation.flush(0, bytes); + } + } } auto VulkanRenderer::drawFrame(RenderFrame& frame_data) -> bool { @@ -2447,8 +2719,14 @@ auto VulkanRenderer::drawFrame(RenderFrame& frame_data) -> bool { } end_phase(m_perf_record_ns); - const auto acquired = - m_output_target->acquireNextImage(std::numeric_limits::max(), *frame.image_available, VK_NULL_HANDLE); + auto acquired = m_output_target->acquireNextImage(std::numeric_limits::max(), *frame.image_available, VK_NULL_HANDLE); + // A dropped snapshot breaks every temporal history so a busy compositor is waited on and only a stalled one drops it + for (uint32_t attempt = 0; acquired.result == vk::Result::eNotReady && attempt < k_output_acquire_attempts && + m_running.load(std::memory_order_acquire); + ++attempt) { + waitForOutputImage(); + acquired = m_output_target->acquireNextImage(std::numeric_limits::max(), *frame.image_available, VK_NULL_HANDLE); + } end_phase(m_perf_acquire_ns); if (acquired.result == vk::Result::eErrorOutOfDateKHR) { @@ -2457,8 +2735,7 @@ auto VulkanRenderer::drawFrame(RenderFrame& frame_data) -> bool { return false; } if (acquired.result == vk::Result::eNotReady) { - waitForOutputImage(); - end_phase(m_perf_acquire_ns); + TOAST_WARN("Render", "Output image wait timed out so the snapshot was dropped"); return false; } if (acquired.result != vk::Result::eSuccess && acquired.result != vk::Result::eSuboptimalKHR) { @@ -2731,7 +3008,8 @@ void VulkanRenderer::mainRenderThread() try { } } - if (limit_hz > 0.0) { + // Clamped frames are capped in waitForFrameSlot + if (limit_hz > 0.0 && !clamp_to_simulation) { const auto pacing_start = clock::now(); std::this_thread::sleep_until(next_frame_deadline); m_perf_pacing_ns.fetch_add( @@ -2887,6 +3165,44 @@ void VulkanRenderer::setGameView(bool game_view) { } } +void VulkanRenderer::acquireFrameSlot() noexcept { + using namespace std::chrono_literals; + const auto slot_wait_start = std::chrono::steady_clock::now(); + m_frame_slot = m_free_frames.try_acquire_for(50ms) ? FrameSlot::held : FrameSlot::missed; + m_perf_slot_wait_ns.fetch_add( + static_cast( + std::chrono::duration_cast(std::chrono::steady_clock::now() - slot_wait_start).count() + ), + std::memory_order_relaxed + ); +} + +void VulkanRenderer::waitForFrameSlot() noexcept { + ZoneScoped; + using clock = std::chrono::steady_clock; + + const double limit_hz = m_bake_active.load(std::memory_order_relaxed) ? 0.0 : effectiveFrameRateLimit(); + if (limit_hz > 0.0) { + std::this_thread::sleep_until(m_tick_deadline); + const auto interval = std::chrono::duration_cast(std::chrono::duration(1.0 / limit_hz)); + const auto now = clock::now(); + m_tick_deadline = (m_tick_deadline + interval > now) ? m_tick_deadline + interval : now + interval; + } + + // A paused tick keeps its slot + if (m_frame_slot != FrameSlot::held && !m_rendering_paused.load(std::memory_order_relaxed)) { + acquireFrameSlot(); + } +} + +auto VulkanRenderer::previousResolvedColour() const noexcept -> vk::ImageView { + const auto* frame = renderingFrame(); + if (m_taa_pass == nullptr || !m_taa_pass->isEnabled() || frame == nullptr || !frame->taa_active || frame->taa_reset) { + return {}; + } + return m_taa_pass->resolvedHistory(); +} + void VulkanRenderer::submitFrame() noexcept { { std::lock_guard lock(m_queue_mutex); @@ -3149,16 +3465,10 @@ void VulkanRenderer::tick(float time) noexcept { return; } - using namespace std::chrono_literals; - const auto slot_wait_start = std::chrono::steady_clock::now(); - const bool got_slot = m_free_frames.try_acquire_for(50ms); - - m_perf_slot_wait_ns.fetch_add( - static_cast( - std::chrono::duration_cast(std::chrono::steady_clock::now() - slot_wait_start).count() - ), - std::memory_order_relaxed - ); + if (m_frame_slot == FrameSlot::none) { + acquireFrameSlot(); + } + const bool got_slot = std::exchange(m_frame_slot, FrameSlot::none) == FrameSlot::held; m_perf_ticks.fetch_add(1, std::memory_order_relaxed); if (!got_slot) { @@ -3242,6 +3552,14 @@ void VulkanRenderer::tick(float time) noexcept { m_camera->syncTransform(); frame.post_process = blendPostProcessVolumes(m_camera->world_position); + gatherFogVolumes(frame, m_camera->world_position); + // GI reads the media too so they go out whether or not the volumetrics pass draws them + const auto& fog = frame.post_process.fog; + const bool fog_on = volumetrics(); + frame.frame_data.fog_medium = glm::vec4( + fog_on ? fog.density : 0.0f, fog.height, fog.height_falloff, fog_on ? static_cast(frame.fog_volumes.size()) : 0.0f + ); + frame.frame_data.fog_reach = glm::vec4(fog.distance, volumetricDistance(), static_cast(voxelSunReach()), 0.0f); auto& mesh_nodes_snapshot = m_tick_mesh_nodes; { @@ -4002,6 +4320,9 @@ void VulkanRenderer::tick(float time) noexcept { } m_traced_shadows_voxel_fallback_logged = tracedShadowsEnabled() && voxels_block_tracing; frame.frame_data.traced_shadow_params.x = trace_shadows ? 1.0f : 0.0f; + // Voxel volumes join the TLAS so marched voxel rays use ray queries + frame.frame_data.traced_shadow_params.y = + m_core->isRayTracingSupported() && voxelTraceRayQuery() && !frame.voxel_instances.empty() ? 1.0f : 0.0f; if (!trace_shadows) { fitShadowViews(frame, aspect, shadow_caster_index, shadow_caster_direction, spot_candidates, point_candidates); @@ -4519,7 +4840,9 @@ void VulkanRenderer::buildVoxelProxies(RenderFrame& frame) { auto storage = std::make_shared( std::move(patched->record_rids), std::move(patched->record_dims), *m_voxel_storage, *patch_sequence ); - storage->setGlassBricks(packedGlassBricks(m_voxel_patcher.scene())); + auto glass = packedGlassBricks(m_voxel_patcher.scene()); + storage->setBrickLists(packedBrickLists(m_voxel_patcher.scene(), glass, *storage, m_voxel_storage.get(), deltas)); + storage->setGlassBricks(std::move(glass)); storage->setDebugInfo(std::move(debug)); storage->setChangeHistory(m_voxel_change_tracker.packed(storage->generation())); m_voxel_storage = std::move(storage); @@ -4555,7 +4878,9 @@ void VulkanRenderer::buildVoxelProxies(RenderFrame& frame) { m_voxel_patcher.adopt(packed->scene, debug.packed_slots, node_rids); auto storage = std::make_shared(std::move(node_rids), std::move(brick_dims)); - storage->setGlassBricks(packedGlassBricks(packed->scene)); + auto glass = packedGlassBricks(packed->scene); + storage->setBrickLists(packedBrickLists(packed->scene, glass, *storage, nullptr, {})); + storage->setGlassBricks(std::move(glass)); storage->setDebugInfo(std::move(debug)); storage->setChangeHistory(m_voxel_change_tracker.packed(storage->generation())); queueResourceUpload(std::make_unique(storage, std::move(packed))); @@ -4751,6 +5076,26 @@ void VulkanRenderer::unregisterPostProcessVolumeProxy(toast::PostProcessVolume* std::erase(m_post_process_volume_nodes, node); } +void VulkanRenderer::registerFogVolumeProxy(toast::FogVolume* node) { + if (node == nullptr) { + return; + } + + std::scoped_lock lock(m_reflection_probe_mutex); + if (!std::ranges::contains(m_fog_volume_nodes, node)) { + m_fog_volume_nodes.push_back(node); + } +} + +void VulkanRenderer::unregisterFogVolumeProxy(toast::FogVolume* node) { + if (node == nullptr) { + return; + } + + std::scoped_lock lock(m_reflection_probe_mutex); + std::erase(m_fog_volume_nodes, node); +} + void VulkanRenderer::registerLightNodeProxy(toast::Light* node) { if (node == nullptr) { return; diff --git a/engine/src/toast/renderer/vulkan_renderer.hpp b/engine/src/toast/renderer/vulkan_renderer.hpp index 813dc83b0..67558bb65 100644 --- a/engine/src/toast/renderer/vulkan_renderer.hpp +++ b/engine/src/toast/renderer/vulkan_renderer.hpp @@ -7,6 +7,7 @@ #include "compute_pass_base.hpp" #include "debug_draw.hpp" #include "depth_convention.hpp" +#include "gi_settings.hpp" #include "gpu_sync.hpp" #include "gpu_timer.hpp" #include "output_target_base.hpp" @@ -16,8 +17,10 @@ #include "shader_layout.hpp" #include "shadow_constants.hpp" #include "shadow_slots.hpp" +#include "voxel_blas_cache.hpp" #include "voxel_gpu_storage.hpp" #include "voxel_scene_patcher.hpp" +#include "voxel_trace_scene.hpp" #include "vulkan_core.hpp" #include "vulkan_mesh.hpp" #include "vulkan_pipeline.hpp" @@ -66,6 +69,7 @@ class Light; class ReflectionProbe; class IrradianceVolume; class PostProcessVolume; +class FogVolume; class INodeOwner; class Node; class Node3D; @@ -83,6 +87,9 @@ class SelectionOutline; class SkinningPass; class VoxelPass; class DebrisPass; +class GiPass; +class TracedSpecularPass; +class TaaPass; class RayTracingScene; class SkinnedBlasPool; @@ -93,7 +100,29 @@ class VulkanRenderer { static constexpr uint32_t k_frames_in_flight = 3; - static constexpr uint8_t k_render_frames = 3; + /// One drawing and one building + static constexpr uint8_t k_render_frames = 2; + /// waitForOutputImage rounds of about 8 ms before a snapshot is dropped + static constexpr uint32_t k_output_acquire_attempts = 12; + + static constexpr uint32_t k_max_fog_volumes = 32; + + /// Mirrors volumetrics.slang FogVolume + struct FogVolumeGpu { + glm::mat4 world_to_local {1.0f}; + /// xyz half extents w edge fade in meters + glm::vec4 extents_fade {0.0f}; + /// rgb albedo a extinction per meter + glm::vec4 albedo_density {0.0f}; + /// rgb radiance per meter at density 1 a noise strength + glm::vec4 emission_noise {0.0f}; + /// xyz wind in meters per second w noise scale in meters + glm::vec4 wind_scale {0.0f}; + /// x anisotropy y ambient scale + glm::vec4 phase {0.0f}; + /// xyz box centre in world space + glm::vec4 centre {0.0f}; + }; struct FrameContext { vk::raii::CommandBuffer command_buffer = nullptr; @@ -216,6 +245,11 @@ class VulkanRenderer { /// xy blue noise texel offset walking R2 per frame z frame index glm::vec4 noise_params {0.0f}; + + /// x height fog extinction per meter at the base height y base height z height falloff w fog volumes + glm::vec4 fog_medium {0.0f}; + /// x height fog distance y fog volume distance z marched sun reach in meters + glm::vec4 fog_reach {0.0f}; }; static_assert( @@ -452,6 +486,9 @@ class VulkanRenderer { PostProcessSettings post_process; + /// Nearest first and at most k_max_fog_volumes + std::vector fog_volumes; + int32_t probe_capture_index = -1; uint32_t probe_capture_face = 0; @@ -499,6 +536,9 @@ class VulkanRenderer { /// For the next frame when called before frame construction begins void queueDebugLine(glm::vec3 a, glm::vec3 b, glm::vec4 color); + /// Tick thread before input is polled + void waitForFrameSlot() noexcept; + void tick(float time) noexcept; void registerMeshNodeProxy(toast::MeshNode* node); @@ -529,6 +569,10 @@ class VulkanRenderer { void unregisterPostProcessVolumeProxy(toast::PostProcessVolume* node); + void registerFogVolumeProxy(toast::FogVolume* node); + + void unregisterFogVolumeProxy(toast::FogVolume* node); + void unregisterIrradianceVolumeProxy(toast::IrradianceVolume* node); private: @@ -757,6 +801,13 @@ class VulkanRenderer { return m_voxel_ambient_rays.load(std::memory_order_relaxed); } + void setVoxelAmbientRayQuery(bool enabled) noexcept { m_voxel_ambient_ray_query.store(enabled, std::memory_order_relaxed); } + + [[nodiscard]] + auto voxelAmbientRayQuery() const noexcept -> bool { + return m_voxel_ambient_ray_query.load(std::memory_order_relaxed); + } + void setVoxelAmbientReach(float meters) noexcept { m_voxel_ambient_reach.store(std::max(meters, 0.0f), std::memory_order_relaxed); } @@ -766,6 +817,88 @@ class VulkanRenderer { return m_voxel_ambient_reach.load(std::memory_order_relaxed); } + void setVolumetrics(bool enabled) noexcept { m_volumetrics.store(enabled, std::memory_order_relaxed); } + + [[nodiscard]] + auto volumetrics() const noexcept -> bool { + return m_volumetrics.load(std::memory_order_relaxed); + } + + void setVolumetricSteps(uint32_t steps) noexcept { m_volumetric_steps.store(std::max(steps, 1u), std::memory_order_relaxed); } + + static constexpr uint32_t k_volumetric_quality_custom = 4; + + void setVolumetricQuality(uint32_t level) noexcept { + m_volumetric_quality.store(std::min(level, k_volumetric_quality_custom), std::memory_order_relaxed); + } + + [[nodiscard]] + auto volumetricQuality() const noexcept -> uint32_t { + return m_volumetric_quality.load(std::memory_order_relaxed); + } + + void setVolumetricLights(uint32_t count) noexcept { m_volumetric_lights.store(count, std::memory_order_relaxed); } + + [[nodiscard]] + auto volumetricLights() const noexcept -> uint32_t { + return m_volumetric_lights.load(std::memory_order_relaxed); + } + + void setVolumetricLightShadows(bool enabled) noexcept { m_volumetric_light_shadows.store(enabled, std::memory_order_relaxed); } + + [[nodiscard]] + auto volumetricLightShadows() const noexcept -> bool { + return m_volumetric_light_shadows.load(std::memory_order_relaxed); + } + + void setVolumetricQuarterResolution(bool quarter) noexcept { m_volumetric_quarter.store(quarter, std::memory_order_relaxed); } + + /// Meters fog volumes are marched to while height fog keeps its own Fog Distance + void setVolumetricDistance(float meters) noexcept { + m_volumetric_distance.store(std::max(meters, 1.0f), std::memory_order_relaxed); + } + + [[nodiscard]] + auto volumetricDistance() const noexcept -> float { + return m_volumetric_distance.load(std::memory_order_relaxed); + } + + [[nodiscard]] + auto volumetricQuarterResolution() const noexcept -> bool { + return m_volumetric_quarter.load(std::memory_order_relaxed); + } + + [[nodiscard]] + auto volumetricSteps() const noexcept -> uint32_t { + return m_volumetric_steps.load(std::memory_order_relaxed); + } + + void setVoxelTracedSpecular(bool enabled) noexcept { m_voxel_traced_specular.store(enabled, std::memory_order_relaxed); } + + [[nodiscard]] + auto voxelTracedSpecular() const noexcept -> bool { + return m_voxel_traced_specular.load(std::memory_order_relaxed); + } + + /// Rougher voxels keep the environment specular + void setVoxelSpecularRoughness(float roughness) noexcept { + m_voxel_specular_roughness.store(std::clamp(roughness, 0.0f, 1.0f), std::memory_order_relaxed); + } + + [[nodiscard]] + auto voxelSpecularRoughness() const noexcept -> float { + return m_voxel_specular_roughness.load(std::memory_order_relaxed); + } + + void setVoxelSpecularReach(float meters) noexcept { + m_voxel_specular_reach.store(std::max(meters, 0.1f), std::memory_order_relaxed); + } + + [[nodiscard]] + auto voxelSpecularReach() const noexcept -> float { + return m_voxel_specular_reach.load(std::memory_order_relaxed); + } + /// 1 is Snell and 0 turns refraction off void setVoxelGlassRefraction(float strength) noexcept { m_voxel_glass_refraction.store(std::max(strength, 0.0f), std::memory_order_relaxed); @@ -845,6 +978,38 @@ class VulkanRenderer { return m_voxel_sun_reach.load(std::memory_order_relaxed); } + void setVoxelTraceRayQuery(bool enabled) noexcept { m_voxel_trace_ray_query.store(enabled, std::memory_order_relaxed); } + + [[nodiscard]] + auto voxelTraceRayQuery() const noexcept -> bool { + return m_voxel_trace_ray_query.load(std::memory_order_relaxed); + } + + /// Off keeps every volume on the unit box BLAS so the two can be compared + void setVoxelTraceBricks(bool enabled) noexcept { m_voxel_trace_bricks.store(enabled, std::memory_order_relaxed); } + + [[nodiscard]] + auto voxelTraceBricks() const noexcept -> bool { + return m_voxel_trace_bricks.load(std::memory_order_relaxed); + } + + void setVoxelBlasBudget(uint32_t bricks) noexcept { m_voxel_blas_budget.store(bricks, std::memory_order_relaxed); } + + [[nodiscard]] + auto voxelBlasBudget() const noexcept -> uint32_t { + return m_voxel_blas_budget.load(std::memory_order_relaxed); + } + + /// Meters per cell of the grid marched rays walk without ray queries + void setVoxelTraceCellSize(float meters) noexcept { + m_voxel_trace_cell_size.store(std::max(meters, 0.25f), std::memory_order_relaxed); + } + + [[nodiscard]] + auto voxelTraceCellSize() const noexcept -> float { + return m_voxel_trace_cell_size.load(std::memory_order_relaxed); + } + /// Nearest drawn voxel instances a marched shadow ray tests void setVoxelShadowInstances(uint32_t count) noexcept { m_voxel_shadow_instances.store(count, std::memory_order_relaxed); } @@ -951,6 +1116,13 @@ class VulkanRenderer { return m_blue_noise_buffer.has_value() ? **m_blue_noise_buffer : vk::Buffer {}; } + /// fog_media.slang gFogVolumes + auto getFogVolumeBuffer(uint32_t frame_index) const -> vk::Buffer { + return frame_index < m_fog_volume_res.size() && m_fog_volume_res[frame_index].gpu_buffer.has_value() + ? **m_fog_volume_res[frame_index].gpu_buffer + : vk::Buffer {}; + } + auto getJointMatrixBuffer(uint32_t frame_index) const -> vk::Buffer { return frame_index < m_joint_matrix_res.size() && m_joint_matrix_res[frame_index].gpu_buffer.has_value() ? **m_joint_matrix_res[frame_index].gpu_buffer @@ -988,6 +1160,38 @@ class VulkanRenderer { void setDebrisPass(DebrisPass* pass) noexcept { m_debris_pass = pass; } + void setGiPass(GiPass* pass) noexcept { m_gi_pass = pass; } + + /// Still registered with addPostProcessPass so it runs in the post chain + void setTracedSpecularPass(TracedSpecularPass* pass) noexcept { m_traced_specular_pass = pass; } + + void setTaaPass(TaaPass* pass) noexcept { m_taa_pass = pass; } + + /// Render thread before TaaPass records and null on a camera cut or without TAA + [[nodiscard]] + auto previousResolvedColour() const noexcept -> vk::ImageView; + + /// Render thread whether voxel lighting left the environment specular of traced pixels to the traced specular pass + [[nodiscard]] + auto tracedSpecularFrame() const noexcept -> bool { + return m_traced_specular_frame; + } + + [[nodiscard]] + auto giPass() const noexcept -> GiPass* { + return m_gi_pass; + } + + [[nodiscard]] + auto giSettings() noexcept -> GiSettings& { + return m_gi_settings; + } + + [[nodiscard]] + auto giSettings() const noexcept -> const GiSettings& { + return m_gi_settings; + } + [[nodiscard]] auto voxelCollisionWorld() const -> std::shared_ptr; @@ -1070,6 +1274,17 @@ class VulkanRenderer { return m_ray_tracing_scene.get(); } + [[nodiscard]] + auto voxelTraceScene() const noexcept -> VoxelTraceScene* { + return m_voxel_trace_scene.get(); + } + + /// Null without ray tracing + [[nodiscard]] + auto voxelBlasCache() const noexcept -> const VoxelBlasCache* { + return m_voxel_blas_cache.get(); + } + [[nodiscard]] auto getPrepassDrawnCount() const noexcept -> uint32_t; @@ -1198,6 +1413,12 @@ class VulkanRenderer { return m_gpu_timer.get(); } + /// @note Render thread only + [[nodiscard]] + auto gpuTimer() noexcept -> GpuTimer* { + return m_gpu_timer.get(); + } + void setTracedShadowsEnabled(bool enabled) noexcept { m_traced_shadows_enabled.store(enabled, std::memory_order_relaxed); } [[nodiscard]] @@ -1305,6 +1526,17 @@ class VulkanRenderer { std::counting_semaphore m_free_frames {k_render_frames}; + void acquireFrameSlot() noexcept; + + /// Tick thread only + enum class FrameSlot : uint8_t { + none, + held, + missed + }; + FrameSlot m_frame_slot = FrameSlot::none; + std::chrono::steady_clock::time_point m_tick_deadline; + struct DepthResources { std::optional image; std::optional view; @@ -1330,6 +1562,7 @@ class VulkanRenderer { void publishCompletedFrames(); + void updateVoxelTraceScene(); void recordAccelerationStructureBuilds(FrameContext& frame); void createPresentResources(); @@ -1466,6 +1699,11 @@ class VulkanRenderer { VoxelPass* m_voxel_pass = nullptr; DebrisPass* m_debris_pass = nullptr; + GiPass* m_gi_pass = nullptr; + GiSettings m_gi_settings; + TracedSpecularPass* m_traced_specular_pass = nullptr; + TaaPass* m_taa_pass = nullptr; + bool m_traced_specular_frame = false; std::atomic_bool m_probe_bake_requested {false}; std::atomic m_probe_stale_count {0}; @@ -1506,9 +1744,24 @@ class VulkanRenderer { std::atomic m_voxel_normal_rounding {0.3f}; std::atomic m_voxel_light_candidates {8}; std::atomic m_voxel_shadow_instances {256}; + std::atomic_bool m_voxel_trace_ray_query {true}; + std::atomic_bool m_voxel_trace_bricks {true}; + std::atomic m_voxel_blas_budget {262144}; + std::atomic m_voxel_trace_cell_size {4.0f}; std::atomic m_voxel_sun_reach {200}; std::atomic m_voxel_ambient_rays {true}; + std::atomic m_voxel_ambient_ray_query {true}; std::atomic m_voxel_ambient_reach {2.4f}; + std::atomic_bool m_voxel_traced_specular {true}; + std::atomic_bool m_volumetrics {true}; + std::atomic m_volumetric_steps {24}; + std::atomic m_volumetric_quality {2}; + std::atomic m_volumetric_lights {8}; + std::atomic_bool m_volumetric_light_shadows {true}; + std::atomic_bool m_volumetric_quarter {false}; + std::atomic m_volumetric_distance {100.0f}; + std::atomic m_voxel_specular_roughness {0.6f}; + std::atomic m_voxel_specular_reach {32.0f}; std::atomic m_voxel_glass_refraction {1.0f}; std::atomic_bool m_voxel_glass_refraction_enabled {true}; std::atomic m_voxel_glass_refraction_distance {60.0f}; @@ -1556,6 +1809,8 @@ class VulkanRenderer { [[nodiscard]] auto blendPostProcessVolumes(const glm::vec3& camera_position) -> PostProcessSettings; + void gatherFogVolumes(RenderFrame& frame, const glm::vec3& camera_position); + PostProcessSettings m_post_process_settings; std::atomic_bool m_traced_shadows_enabled {false}; @@ -1685,6 +1940,10 @@ class VulkanRenderer { /// Guarded by m_reflection_probe_mutex std::vector m_post_process_volume_nodes; + /// Guarded by m_reflection_probe_mutex + std::vector m_fog_volume_nodes; + std::vector m_tick_fog_volumes; + std::vector m_tick_mesh_nodes; std::vector m_tick_light_nodes; std::vector m_tick_camera_nodes; @@ -1749,6 +2008,12 @@ class VulkanRenderer { std::unique_ptr m_ray_tracing_scene; + std::unique_ptr m_voxel_trace_scene; + std::unique_ptr m_voxel_blas_cache; + /// Render thread scratch + std::vector m_trace_volumes; + std::vector m_trace_records; + std::unique_ptr m_skinned_blas_pool; std::atomic m_last_drawn_sequence {0}; @@ -1792,6 +2057,7 @@ class VulkanRenderer { std::vector m_frame_ubos; std::vector m_frame_ubo_res; std::vector m_joint_matrix_res; + std::vector m_fog_volume_res; std::optional m_blue_noise_buffer; std::vector m_instance_res; @@ -1918,6 +2184,14 @@ inline void unregisterPostProcessVolumeProxy(toast::PostProcessVolume* node) { VulkanRenderer::instance->unregisterPostProcessVolumeProxy(node); } +inline void registerFogVolumeProxy(toast::FogVolume* node) { + VulkanRenderer::instance->registerFogVolumeProxy(node); +} + +inline void unregisterFogVolumeProxy(toast::FogVolume* node) { + VulkanRenderer::instance->unregisterFogVolumeProxy(node); +} + inline void queueResourceUpload(std::unique_ptr upload) { VulkanRenderer::instance->queueResourceUpload(std::move(upload)); } diff --git a/engine/src/toast/world/animation_player.hpp b/engine/src/toast/world/animation_player.hpp index aa80a9444..34cd10e7d 100644 --- a/engine/src/toast/world/animation_player.hpp +++ b/engine/src/toast/world/animation_player.hpp @@ -26,7 +26,7 @@ namespace toast { * * @note Also ticked outside Play, by Workspace::tickAnimationPreviews() */ -class [[ToastNode, Icon("Film")]] TOAST_API AnimationPlayer : public Node3D { +class [[ToastNode, Icon("Animation")]] TOAST_API AnimationPlayer : public Node3D { public: AnimationPlayer() = default; diff --git a/engine/src/toast/world/fog_volume.cpp b/engine/src/toast/world/fog_volume.cpp new file mode 100644 index 000000000..23febb9a2 --- /dev/null +++ b/engine/src/toast/world/fog_volume.cpp @@ -0,0 +1,31 @@ +/** + * @file fog_volume.cpp + * @author dario + * @date 26/10/2026 + */ + +#include "fog_volume.hpp" + +#include + +namespace toast { + +void FogVolume::init() { + renderer::registerFogVolumeProxy(this); + m_registered_proxy = true; +} + +void FogVolume::end() { + if (!m_registered_proxy) { + return; + } + + renderer::unregisterFogVolumeProxy(this); + m_registered_proxy = false; +} + +void FogVolume::destroy() { + end(); +} + +} diff --git a/engine/src/toast/world/fog_volume.hpp b/engine/src/toast/world/fog_volume.hpp new file mode 100644 index 000000000..cce8d32a8 --- /dev/null +++ b/engine/src/toast/world/fog_volume.hpp @@ -0,0 +1,112 @@ +/** + * @file fog_volume.hpp + * @author dario + * @date 26/10/2026 + */ + +#pragma once +#include "node_3d.hpp" + +#include +#include + +namespace toast { + +class [[ToastNode, Icon("FogVolume")]] TOAST_API FogVolume : public Node3D { +public: + FogVolume() = default; + ~FogVolume() override = default; + + [[nodiscard]] + auto isEnabled() const noexcept -> bool { + return m_enabled; + } + + [[nodiscard]] + auto extents() const noexcept -> const glm::vec3& { + return m_extents; + } + + [[nodiscard]] + auto edgeFade() const noexcept -> float { + return m_edge_fade; + } + + [[nodiscard]] + auto density() const noexcept -> float { + return m_density; + } + + [[nodiscard]] + auto albedo() const noexcept -> const glm::vec3& { + return m_albedo; + } + + /// Radiance per meter at density 1 + [[nodiscard]] + auto emission() const noexcept -> glm::vec3 { + return m_emission * m_emission_intensity; + } + + [[nodiscard]] + auto anisotropy() const noexcept -> float { + return m_anisotropy; + } + + [[nodiscard]] + auto ambient() const noexcept -> float { + return m_ambient; + } + + [[nodiscard]] + auto noiseStrength() const noexcept -> float { + return m_noise_strength; + } + + [[nodiscard]] + auto noiseScale() const noexcept -> float { + return m_noise_scale; + } + + [[nodiscard]] + auto wind() const noexcept -> const glm::vec3& { + return m_wind; + } + + void density(float value) noexcept { m_density = value; } + +private: + void init(); + void end(); + void destroy(); + + [[Reflect, Name("Enabled")]] + bool m_enabled = true; + + [[Reflect, Name("Extents"), Unit("m"), Group("Shape")]] alignas(16) glm::vec3 m_extents = glm::vec3(2.0f); + [[Reflect, Name("Edge Fade"), Unit("m"), Range(0.0, 10.0), Group("Shape")]] + float m_edge_fade = 0.5f; + + [[Reflect, Name("Density"), Unit("1/m"), Range(0.0, 10.0), Group("Medium")]] + float m_density = 0.2f; + [[Reflect, Name("Albedo"), Color, Group("Medium")]] + glm::vec3 m_albedo = glm::vec3(0.8f); + [[Reflect, Name("Emission"), Color, Group("Medium")]] + glm::vec3 m_emission = glm::vec3(1.0f, 0.45f, 0.15f); + [[Reflect, Name("Emission Intensity"), Range(0.0, 100.0), Group("Medium")]] + float m_emission_intensity = 0.0f; + [[Reflect, Name("Anisotropy"), Range(-0.9, 0.9), Group("Medium")]] + float m_anisotropy = 0.2f; + [[Reflect, Name("Ambient"), Range(0.0, 4.0), Group("Medium")]] + float m_ambient = 1.0f; + + [[Reflect, Name("Noise Strength"), Range(0.0, 1.0), Group("Noise")]] + float m_noise_strength = 0.0f; + [[Reflect, Name("Noise Scale"), Unit("m"), Range(0.05, 50.0), Group("Noise")]] + float m_noise_scale = 2.0f; + [[Reflect, Name("Wind"), Unit("m/s"), Group("Noise")]] alignas(16) glm::vec3 m_wind = glm::vec3(0.0f); + + bool m_registered_proxy = false; +}; + +} diff --git a/engine/src/toast/world/irradiance_volume.hpp b/engine/src/toast/world/irradiance_volume.hpp index 15808aea2..e172fc06e 100644 --- a/engine/src/toast/world/irradiance_volume.hpp +++ b/engine/src/toast/world/irradiance_volume.hpp @@ -32,7 +32,7 @@ namespace toast { * needs ray tracing to be practical. The storage and the shading lookup are deliberately identical * either way, so swapping the bake for a live update later touches only the bake */ -class [[ToastNode]] TOAST_API IrradianceVolume : public Node3D { +class [[ToastNode, Icon("LightmapProbe")]] TOAST_API IrradianceVolume : public Node3D { public: IrradianceVolume() = default; ~IrradianceVolume() override = default; diff --git a/engine/src/toast/world/post_process_volume.cpp b/engine/src/toast/world/post_process_volume.cpp index 9f0285b57..bb2ef132f 100644 --- a/engine/src/toast/world/post_process_volume.cpp +++ b/engine/src/toast/world/post_process_volume.cpp @@ -59,6 +59,16 @@ void PostProcessVolume::syncSettings() const { m_settings.ssao.strength = m_ssao_strength; m_settings.ssao.range_cutoff = m_ssao_range_cutoff; m_settings.ssao.sample_count = static_cast(std::max(m_ssao_sample_count, 1)); + + m_settings.fog.density = std::max(m_fog_density, 0.0f); + m_settings.fog.height = m_fog_height; + m_settings.fog.height_falloff = std::max(m_fog_height_falloff, 0.0f); + m_settings.fog.albedo_r = m_fog_albedo.r; + m_settings.fog.albedo_g = m_fog_albedo.g; + m_settings.fog.albedo_b = m_fog_albedo.b; + m_settings.fog.anisotropy = std::clamp(m_fog_anisotropy, -0.9f, 0.9f); + m_settings.fog.ambient = std::max(m_fog_ambient, 0.0f); + m_settings.fog.distance = std::max(m_fog_distance, 1.0f); } auto PostProcessVolume::settings() const -> const renderer::PostProcessSettings& { diff --git a/engine/src/toast/world/post_process_volume.hpp b/engine/src/toast/world/post_process_volume.hpp index da2ad21d2..362c8369b 100644 --- a/engine/src/toast/world/post_process_volume.hpp +++ b/engine/src/toast/world/post_process_volume.hpp @@ -29,7 +29,7 @@ namespace toast { * @note The blend is a crossfade, not a switch. A box fades in over blendDistance() as the camera approaches, * because grading that snaps at a boundary reads as a bug even when the two grades are both correct */ -class [[ToastNode, Icon("Aperture")]] TOAST_API PostProcessVolume : public Volume { +class [[ToastNode, Icon("WorldEnvironment")]] TOAST_API PostProcessVolume : public Volume { public: /// Volume defaults blendDistance to 0, which makes a grade snap at the boundary. A post volume is a /// crossfade, so it starts with a metre of blend @@ -155,6 +155,24 @@ class [[ToastNode, Icon("Aperture")]] TOAST_API PostProcessVolume : public Volum [[Reflect, Name("AO Samples"), Range(1, 64), Group("Ambient Occlusion")]] int32_t m_ssao_sample_count = 16; + /// 0 turns volumetrics off + [[Reflect, Name("Fog Density"), Unit("1/m"), Range(0.0, 1.0), Group("Fog")]] + float m_fog_density = 0.0f; + [[Reflect, Name("Fog Height"), Unit("m"), Range(-1000.0, 1000.0), Group("Fog")]] + float m_fog_height = 0.0f; + /// 0 is uniform fog + [[Reflect, Name("Fog Height Falloff"), Unit("1/m"), Range(0.0, 2.0), Group("Fog")]] + float m_fog_height_falloff = 0.0f; + [[Reflect, Name("Fog Albedo"), Color, Group("Fog")]] + glm::vec3 m_fog_albedo = glm::vec3(1.0f); + /// Above 0 scatters forward so shafts brighten toward the light + [[Reflect, Name("Fog Anisotropy"), Range(-0.9, 0.9), Group("Fog")]] + float m_fog_anisotropy = 0.3f; + [[Reflect, Name("Fog Ambient"), Range(0.0, 4.0), Group("Fog")]] + float m_fog_ambient = 1.0f; + [[Reflect, Name("Fog Distance"), Unit("m"), Range(1.0, 1000.0), Group("Fog")]] + float m_fog_distance = 64.0f; + /// @brief Rebuilt from the reflected fields each time the renderer asks /// /// The fields are separate rather than a nested PostProcessSettings because reflection describes flat diff --git a/engine/src/toast/world/reflection_probe.hpp b/engine/src/toast/world/reflection_probe.hpp index 4a6f64ece..a869ef0f4 100644 --- a/engine/src/toast/world/reflection_probe.hpp +++ b/engine/src/toast/world/reflection_probe.hpp @@ -30,7 +30,7 @@ namespace toast { * @note A probe reprojects its own captured cubemap once baked, and the global environment until then - so an * unbaked probe still corrects the *shape* of reflections, just not their content */ -class [[ToastNode, Icon("Eclipse")]] TOAST_API ReflectionProbe : public Node3D { +class [[ToastNode, Icon("ReflectionProbe")]] TOAST_API ReflectionProbe : public Node3D { public: ReflectionProbe() = default; ~ReflectionProbe() override = default;