diff --git a/editor/StartWindow/StartWindowViewModel.cs b/editor/StartWindow/StartWindowViewModel.cs index f6177984c..75f11e658 100644 --- a/editor/StartWindow/StartWindowViewModel.cs +++ b/editor/StartWindow/StartWindowViewModel.cs @@ -154,12 +154,13 @@ private void LaunchProject(string projectPath, Window parentWindow) { "--build .toast/cmake_cache --config Release --parallel")); #endif - var vm = new LoaderViewModel(tasks) { - OnComplete = async () => { - await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Render); - _ = new ToastEngine(projectPath); - } - }; + tasks.Add(LoaderTask.Do("Start engine", async log => { + log("Compiling changed shaders"); + await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Render); + _ = new ToastEngine(projectPath); + })); + + var vm = new LoaderViewModel(tasks); var desktop = (IClassicDesktopStyleApplicationLifetime)Application.Current!.ApplicationLifetime!; desktop.MainWindow = new SplashLoaderWindow(vm); diff --git a/editor/Workspace/MainWindowViewModel.cs b/editor/Workspace/MainWindowViewModel.cs index efbc3e7a4..b88a7e347 100644 --- a/editor/Workspace/MainWindowViewModel.cs +++ b/editor/Workspace/MainWindowViewModel.cs @@ -1103,6 +1103,8 @@ private async Task CompileGameRelease() { Directory.CreateDirectory(outputDir); + Log.InvalidateBacktraceCache(); + async Task CopyDlls(Action log) { var libExt = OperatingSystem.IsWindows() ? ".dll" : ".so"; var exeExt = OperatingSystem.IsWindows() ? ".exe" : ""; @@ -1157,7 +1159,11 @@ async Task CopyBakedLighting(Action log) { async Task BakeAndPack(Action log, string dbName, string dbSourceDir, string manifestJsonPath) { var stageDir = Path.Combine(stageRoot, dbName); - if (Directory.Exists(stageDir)) Directory.Delete(stageDir, true); + if (Directory.Exists(stageDir)) { + foreach (var f in Directory.EnumerateFiles(stageDir, "*", SearchOption.AllDirectories)) + File.SetAttributes(f, FileAttributes.Normal); + Directory.Delete(stageDir, true); + } Directory.CreateDirectory(stageDir); // Copy all source files into staging @@ -1168,7 +1174,7 @@ await Task.Run(() => { var dest = Path.Combine(stageDir, rel); Directory.CreateDirectory(Path.GetDirectoryName(dest)!); File.Copy(srcFile, dest, true); - File.SetAttributes(dest, File.GetAttributes(dest) & ~FileAttributes.ReadOnly); + File.SetAttributes(dest, File.GetAttributes(dest) & ~FileAttributes.ReadOnly); } }); diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 65b3bad7d..2fbaa668f 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -106,16 +106,20 @@ target_link_libraries(toast_engine unofficial::VulkanMemoryAllocator-Hpp::VulkanMemoryAllocator-Hpp slang::gfx slang::slang protobuf::libprotobuf tinysplinecxx::tinysplinecxx LZ4::lz4 - imgui::imgui + $<$,$>:imgui::imgui> RmlUi::RmlUi cpptrace::cpptrace $<$:${EXTERNAL_LIBS_DEBUG}> $<$>:${EXTERNAL_LIBS_RELEASE}> ${LUA_LIBRARIES} PUBLIC - Tracy::TracyClient glm::glm nlohmann_json::nlohmann_json GPUOpen::VulkanMemoryAllocator + $<$,$>:Tracy::TracyClient> glm::glm nlohmann_json::nlohmann_json GPUOpen::VulkanMemoryAllocator ) +# Release links without Tracy +get_target_property(_tracy_headers Tracy::TracyClient INTERFACE_INCLUDE_DIRECTORIES) +target_include_directories(toast_engine PUBLIC ${_tracy_headers}) + foreach (config IN ITEMS Debug Release RelWithDebInfo MinSizeRel) string(TOUPPER "${config}" config_upper) set_target_properties(toast_engine PROPERTIES @@ -161,6 +165,7 @@ target_compile_definitions(toast_engine PUBLIC $<$,$>:TOAST_SYNC_VALIDATION> $<$,$>:NDEBUG> $<$,$>:TRACY_ENABLE> + $<$,$>:TOAST_DEV_TOOLS> $<$,$>:TOAST_BREAK_ON_ERROR> $<$:TOAST_LUA_OWNERSHIP_CHECKS> GLM_FORCE_INTRINSICS diff --git a/engine/assets/shaders/exposure.slang b/engine/assets/shaders/exposure.slang index bde1ee30a..3ed00ac52 100644 --- a/engine/assets/shaders/exposure.slang +++ b/engine/assets/shaders/exposure.slang @@ -37,6 +37,7 @@ static const float kLowPercentile = 0.1; static const float kHighPercentile = 0.9; groupshared uint sBins[kBins]; +groupshared float2 sSums[kBins]; float luminance(float3 color) { @@ -70,34 +71,41 @@ void histogramMain(uint3 id : SV_DispatchThreadID, uint index : SV_GroupIndex) [numthreads(256, 1, 1)] void adaptMain(uint index : SV_GroupIndex) { - sBins[index] = gHistogram[index]; + const uint count = gHistogram[index]; + sBins[index] = count; GroupMemoryBarrierWithGroupSync(); - if (index != 0u) + for (uint offset = 1u; offset < kBins; offset <<= 1u) { - return; + const uint below = index >= offset ? sBins[index - offset] : 0u; + GroupMemoryBarrierWithGroupSync(); + sBins[index] += below; + GroupMemoryBarrierWithGroupSync(); } - float total = 0.0; - for (uint i = 0u; i < kBins; ++i) + const float total = float(sBins[kBins - 1u]); + const float seen = float(sBins[index] - count); + const float low = total * kLowPercentile; + const float high = total * kHighPercentile; + const float inside = max(min(seen + float(count), high) - max(seen, low), 0.0); + sSums[index] = float2(inside * (gParams.minLog + (float(index) + 0.5) / float(kBins) * gParams.logRange), inside); + GroupMemoryBarrierWithGroupSync(); + + for (uint stride = kBins / 2u; stride > 0u; stride >>= 1u) { - total += float(sBins[i]); + if (index < stride) + { + sSums[index] += sSums[index + stride]; + } + GroupMemoryBarrierWithGroupSync(); } - float low = total * kLowPercentile; - float high = total * kHighPercentile; - float seen = 0.0; - float weighted = 0.0; - float counted = 0.0; - for (uint i = 0u; i < kBins; ++i) + if (index != 0u) { - float count = float(sBins[i]); - float inside = max(min(seen + count, high) - max(seen, low), 0.0); - weighted += inside * (gParams.minLog + (float(i) + 0.5) / float(kBins) * gParams.logRange); - counted += inside; - seen += count; + return; } - + const float weighted = sSums[0].x; + const float counted = sSums[0].y; float target = counted > 0.0 ? weighted / counted : gParams.minLog + 0.5 * gParams.logRange; float previous = gExposure[0]; float speed = target > previous ? gParams.speedUp : gParams.speedDown; diff --git a/engine/assets/shaders/fullscreen.slang b/engine/assets/shaders/fullscreen.slang index dd41c89fe..d26e0bc09 100644 --- a/engine/assets/shaders/fullscreen.slang +++ b/engine/assets/shaders/fullscreen.slang @@ -15,3 +15,21 @@ public FullscreenVSOutput fullscreenTriangle(uint vertexId) output.position = float4(output.uv * 2.0 - 1.0, 0.0, 1.0); return output; } + +public float4 uiToDisplay(float4 premultiplied, float hdrPaperWhiteScale) +{ + if (hdrPaperWhiteScale <= 0.0) + { + return premultiplied; + } + + const float alpha = premultiplied.a; + const float3 encoded = alpha > 1e-5 ? premultiplied.rgb / alpha : float3(0.0, 0.0, 0.0); + const float3 low = encoded / 12.92; + const float3 high = pow((encoded + 0.055) / 1.055, float3(2.4, 2.4, 2.4)); + const float3 linearColor = float3( + encoded.x <= 0.04045 ? low.x : high.x, + encoded.y <= 0.04045 ? low.y : high.y, + encoded.z <= 0.04045 ? low.z : high.z); + return float4(linearColor * hdrPaperWhiteScale * alpha, alpha); +} diff --git a/engine/assets/shaders/ggx_sampling.slang b/engine/assets/shaders/ggx_sampling.slang index 82104b158..c9a28bef2 100644 --- a/engine/assets/shaders/ggx_sampling.slang +++ b/engine/assets/shaders/ggx_sampling.slang @@ -31,6 +31,33 @@ float3 sampleVisibleNormal(float3 view, float alpha, float2 u) return normalize(float3(alpha * h.x, alpha * h.y, max(h.z, 0.0))); } +/// Any 4x4 texel window holds every stratum once +static const uint kLobeStrata = 16u; +static const uint kLobeBayer[16] = {0u, 8u, 2u, 10u, 12u, 4u, 14u, 6u, 3u, 11u, 1u, 9u, 15u, 7u, 13u, 5u}; + +uint lobeStratumIndex(int2 cell) +{ + return kLobeBayer[(uint(cell.y) & 3u) * 4u + (uint(cell.x) & 3u)]; +} + +/// Hammersley point of a stratum +float2 lobeStratumSample(uint index) +{ + const uint reversed = ((index & 1u) << 3) | ((index & 2u) << 1) | ((index & 4u) >> 1) | ((index & 8u) >> 3); + return (float2(float(index), float(reversed)) + 0.5) / float(kLobeStrata); +} + +float2 lobeStratum(int2 texel) +{ + return lobeStratumSample(lobeStratumIndex(texel)); +} + +/// Every stratum of the 4x4 tile weighs the same over the 5x5 tent +float lobeStratumWeight(int2 offset) +{ + return (abs(offset.x) == 2 ? 0.5 : 1.0) * (abs(offset.y) == 2 ? 0.5 : 1.0); +} + /// Of a direction sampled through sampleVisibleNormal float visibleNormalPdf(float NdotV, float NdotH, float alpha) { diff --git a/engine/assets/shaders/gi_rays.slang b/engine/assets/shaders/gi_rays.slang index 45beee054..bf58cb6ab 100644 --- a/engine/assets/shaders/gi_rays.slang +++ b/engine/assets/shaders/gi_rays.slang @@ -512,14 +512,17 @@ void shadeMain(uint3 id : SV_DispatchThreadID) 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); + const float3 radiance = giOffscreenRadiance(frame, hit, position, normal, ray.pixel, random); + + // Reread so the frame is not live across the traces + const GiFrame after = gFrame[0]; + const float3 clamped = giClampRay(after, radiance); + giSplitRay(after, ray.deposit, ray.lod, ray.origin, ray.direction, primaryHit, hit.t, clamped); if (ray.overlapDeposit != kGiEmpty) { - giSplitRay(frame, ray.overlapDeposit, ray.lod + 1u, ray.origin, ray.direction, true, hit.t, radiance); + giSplitRay(after, ray.overlapDeposit, ray.lod + 1u, ray.origin, ray.direction, true, hit.t, clamped); } } diff --git a/engine/assets/shaders/gt7_tonemap.slang b/engine/assets/shaders/gt7_tonemap.slang new file mode 100644 index 000000000..293889f98 --- /dev/null +++ b/engine/assets/shaders/gt7_tonemap.slang @@ -0,0 +1,194 @@ +// Port of the GT7 tone mapping operator sample by Polyphony Digital Inc. +// https://blog.selfshadow.com/publications/s2025-shading-course/pdi/supplemental/gt7_tone_mapping.cpp +// +// MIT License +// +// Copyright (c) 2025 Polyphony Digital Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// Framebuffer value 1.0 is this many cd/m^2 +static const float kReferenceLuminance = 100.0; + +/// SDR reference white the curve parameters were fitted for +public static const float kGt7SdrPaperWhite = 250.0 / kReferenceLuminance; + +static const float kCurveAlpha = 0.25; +static const float kCurveGrayPoint = 0.538; +static const float kCurveLinearSection = 0.444; +static const float kBlendRatio = 0.6; +static const float kFadeStart = 0.98; +static const float kFadeEnd = 1.16; + +float smoothStepRange(float x, float edge0, float edge1) +{ + if (x < edge0) + { + return 0.0; + } + if (x > edge1) + { + return 1.0; + } + const float t = (x - edge0) / (edge1 - edge0); + return t * t * (3.0 - 2.0 * t); +} + +struct GtCurve +{ + float peak; + float midPoint; + float linearSection; + float toeStrength; + float kA; + float kB; + float kC; +}; + +GtCurve makeGtCurve(float peak, float toeStrength) +{ + GtCurve curve; + curve.peak = peak; + curve.midPoint = kCurveGrayPoint; + curve.linearSection = kCurveLinearSection; + curve.toeStrength = toeStrength; + + const float k = (kCurveLinearSection - 1.0) / (kCurveAlpha - 1.0); + curve.kA = peak * kCurveLinearSection + peak * k; + curve.kB = -peak * k * exp(kCurveLinearSection / k); + curve.kC = -1.0 / (k * peak); + return curve; +} + +/// Toe below the grey point and a convergent shoulder toward the peak +float evaluateGtCurve(GtCurve curve, float x) +{ + if (x < 0.0) + { + return 0.0; + } + + const float weightLinear = smoothStepRange(x, 0.0, curve.midPoint); + const float weightToe = 1.0 - weightLinear; + + if (x < curve.linearSection * curve.peak) + { + const float toeMapped = curve.midPoint * pow(x / curve.midPoint, curve.toeStrength); + return weightToe * toeMapped + weightLinear * x; + } + return curve.kA + curve.kB * exp(x * curve.kC); +} + +static const float kPqM1 = 0.1593017578125; +static const float kPqM2 = 78.84375; +static const float kPqC1 = 0.8359375; +static const float kPqC2 = 18.8515625; +static const float kPqC3 = 18.6875; +static const float kPqPeak = 10000.0; + +/// Normalised PQ signal to framebuffer value +float eotfSt2084(float n) +{ + n = saturate(n); + const float np = pow(n, 1.0 / kPqM2); + const float l = max(np - kPqC1, 0.0) / (kPqC2 - kPqC3 * np); + return pow(l, 1.0 / kPqM1) * kPqPeak / kReferenceLuminance; +} + +/// Framebuffer value to normalised PQ signal +float inverseEotfSt2084(float v) +{ + const float y = max(v, 0.0) * kReferenceLuminance / kPqPeak; + const float ym = pow(y, kPqM1); + return exp2(kPqM2 * (log2(kPqC1 + kPqC2 * ym) - log2(1.0 + kPqC3 * ym))); +} + +/// Linear Rec.2020 to ICtCp +float3 rgbToICtCp(float3 rgb) +{ + const float l = (rgb.r * 1688.0 + rgb.g * 2146.0 + rgb.b * 262.0) / 4096.0; + const float m = (rgb.r * 683.0 + rgb.g * 2951.0 + rgb.b * 462.0) / 4096.0; + const float s = (rgb.r * 99.0 + rgb.g * 309.0 + rgb.b * 3688.0) / 4096.0; + + const float lPq = inverseEotfSt2084(l); + const float mPq = inverseEotfSt2084(m); + const float sPq = inverseEotfSt2084(s); + + return float3( + (2048.0 * lPq + 2048.0 * mPq) / 4096.0, + (6610.0 * lPq - 13613.0 * mPq + 7003.0 * sPq) / 4096.0, + (17933.0 * lPq - 17390.0 * mPq - 543.0 * sPq) / 4096.0); +} + +float3 iCtCpToRgb(float3 ictcp) +{ + const float l = ictcp.x + 0.00860904 * ictcp.y + 0.11103 * ictcp.z; + const float m = ictcp.x - 0.00860904 * ictcp.y - 0.11103 * ictcp.z; + const float s = ictcp.x + 0.560031 * ictcp.y - 0.320627 * ictcp.z; + + const float lLin = eotfSt2084(l); + const float mLin = eotfSt2084(m); + const float sLin = eotfSt2084(s); + + return max( + float3( + 3.43661 * lLin - 2.50645 * mLin + 0.0698454 * sLin, + -0.79133 * lLin + 1.9836 * mLin - 0.192271 * sLin, + -0.0259499 * lLin - 0.0989137 * mLin + 1.12486 * sLin), + float3(0.0, 0.0, 0.0)); +} + +float3 rec709ToRec2020(float3 c) +{ + return float3( + dot(float3(0.6274039, 0.3292830, 0.0433131), c), + dot(float3(0.0690973, 0.9195404, 0.0113623), c), + dot(float3(0.0163914, 0.0880133, 0.8955953), c)); +} + +float3 rec2020ToRec709(float3 c) +{ + return float3( + dot(float3(1.6604910, -0.5876411, -0.0728499), c), + dot(float3(-0.1245505, 1.1328999, -0.0083494), c), + dot(float3(-0.0181508, -0.1005789, 1.1187297), c)); +} + +/// Scene linear Rec.709 where 1.0 is paper white to the same space relative to paper white, at most peak over paper white +/// Both whites are framebuffer values with 1.0 at 100 nits and the peak never goes under the SDR paper white +public float3 gt7ToneMap(float3 scene, float paperWhite, float peak, float toeStrength) +{ + peak = max(peak, kGt7SdrPaperWhite); + const GtCurve curve = makeGtCurve(peak, toeStrength); + const float peakUcs = inverseEotfSt2084(peak); + + const float3 rgb = rec709ToRec2020(clamp(scene, 0.0, 65000.0) * paperWhite); + const float3 ucs = rgbToICtCp(rgb); + + // Per channel curve gives the skewed colour + const float3 skewedRgb = float3(evaluateGtCurve(curve, rgb.r), evaluateGtCurve(curve, rgb.g), evaluateGtCurve(curve, rgb.b)); + const float3 skewedUcs = rgbToICtCp(skewedRgb); + + // Chroma fades out as the pixel nears the peak + const float chromaScale = 1.0 - smoothStepRange(ucs.x / peakUcs, kFadeStart, kFadeEnd); + const float3 scaledRgb = iCtCpToRgb(float3(skewedUcs.x, ucs.y * chromaScale, ucs.z * chromaScale)); + + const float3 blended = lerp(skewedRgb, scaledRgb, kBlendRatio); + return max(rec2020ToRec709(min(blended, float3(peak, peak, peak))), float3(0.0, 0.0, 0.0)) / paperWhite; +} diff --git a/engine/assets/shaders/gt7_tonemap.slang.meta b/engine/assets/shaders/gt7_tonemap.slang.meta new file mode 100644 index 000000000..c17695cd1 --- /dev/null +++ b/engine/assets/shaders/gt7_tonemap.slang.meta @@ -0,0 +1,4 @@ +uid = "gT7T0nemap1" +type = "slang_module" +created_at = "2026-10-29T11:30:00.0000000Z" +modified_at = "2026-10-29T11:30:00.0000000Z" diff --git a/engine/assets/shaders/lens_flare.slang b/engine/assets/shaders/lens_flare.slang new file mode 100644 index 000000000..71987bca5 --- /dev/null +++ b/engine/assets/shaders/lens_flare.slang @@ -0,0 +1,278 @@ +// Lens flare after Froyok's UE4 custom flare, ghosts and halo from a chroma shifted threshold, glare as luminance scaled streak quads +import fullscreen; + +struct LensFlareParams +{ + // xy 1 over the source size zw 1 over the target size + float4 texel; + // x threshold level y threshold range z ghost intensity w chroma shift + float4 flare; + // x halo intensity y halo width z halo mask w halo compression + float4 halo; + // x glare intensity y glare divider z aspect + float4 glare; + // rgb tint w overall intensity + float4 tint; +}; + +[[vk::push_constant]] +ConstantBuffer gParams; + +[[vk::binding(0, 0)]] +Sampler2D gSource; + +[[vk::binding(1, 0)]] +Sampler2D gScene; + +[[vk::binding(2, 0)]] +Sampler2D gGlare; + +typedef FullscreenVSOutput VSOutput; + +[shader("vertex")] +VSOutput vertexMain(uint vertexId : SV_VertexID) +{ + return fullscreenTriangle(vertexId); +} + +static const float2 kCenter = float2(0.5, 0.5); +static const uint kGhostCount = 8u; +static const float kGhostScales[8] = {-1.5, 2.5, -5.0, 10.0, 0.7, -0.4, -0.2, -0.1}; +static const float4 kGhostColors[8] = { + float4(1.0, 0.85, 0.7, 0.8), float4(0.6, 0.8, 1.0, 0.6), float4(1.0, 0.6, 0.7, 0.5), float4(0.7, 1.0, 0.8, 0.4), + float4(1.0, 0.9, 0.5, 0.6), float4(0.6, 0.7, 1.0, 0.5), float4(1.0, 0.7, 0.5, 0.4), float4(0.8, 0.6, 1.0, 0.5)}; + +/// Disc that is 1 at the centre and 0 at radius 1 of the screen position in -1 to 1 +float discMask(float2 screen) +{ + return saturate(1.0 - dot(screen, screen)); +} + +/// Smooth 13 tap box that keeps a moving highlight from flickering the flare, then a soft threshold +[shader("fragment")] +float4 fragmentThreshold(VSOutput input) : SV_Target0 +{ + const float2 texel = gParams.texel.xy; + const float2 uv = input.uv; + + float3 corners = float3(0.0, 0.0, 0.0); + corners += gSource.Sample(uv + float2(-1.0, 1.0) * texel).rgb; + corners += gSource.Sample(uv + float2(1.0, 1.0) * texel).rgb; + corners += gSource.Sample(uv + float2(-1.0, -1.0) * texel).rgb; + corners += gSource.Sample(uv + float2(1.0, -1.0) * texel).rgb; + + float3 grid = float3(0.0, 0.0, 0.0); + for (int y = -1; y <= 1; ++y) + { + for (int x = -1; x <= 1; ++x) + { + grid += gSource.Sample(uv + float2(float(x), float(y)) * 2.0 * texel).rgb; + } + } + + float3 color = corners * 0.25 * 0.5 + grid / 9.0 * 0.5; + color = clamp(color, 0.0, 65000.0); + + const float luma = dot(color, float3(1.0, 1.0, 1.0)); + const float scale = saturate((luma - gParams.flare.x) / max(gParams.flare.y, 1e-4)); + return float4(color * scale, 1.0); +} + +/// Dual Kawase down onto the next smaller target +[shader("fragment")] +float4 fragmentBlurDown(VSOutput input) : SV_Target0 +{ + const float2 texel = gParams.texel.xy; + const float2 uv = input.uv; + float3 sum = gSource.Sample(uv).rgb * 4.0; + sum += gSource.Sample(uv + float2(-1.0, -1.0) * texel).rgb; + sum += gSource.Sample(uv + float2(1.0, -1.0) * texel).rgb; + sum += gSource.Sample(uv + float2(-1.0, 1.0) * texel).rgb; + sum += gSource.Sample(uv + float2(1.0, 1.0) * texel).rgb; + return float4(sum / 8.0, 1.0); +} + +[shader("fragment")] +float4 fragmentBlurUp(VSOutput input) : SV_Target0 +{ + const float2 texel = gParams.texel.xy; + const float2 uv = input.uv; + float3 sum = float3(0.0, 0.0, 0.0); + sum += gSource.Sample(uv + float2(-2.0, 0.0) * texel).rgb; + sum += gSource.Sample(uv + float2(2.0, 0.0) * texel).rgb; + sum += gSource.Sample(uv + float2(0.0, -2.0) * texel).rgb; + sum += gSource.Sample(uv + float2(0.0, 2.0) * texel).rgb; + sum += gSource.Sample(uv + float2(-1.0, 1.0) * texel).rgb * 2.0; + sum += gSource.Sample(uv + float2(1.0, 1.0) * texel).rgb * 2.0; + sum += gSource.Sample(uv + float2(-1.0, -1.0) * texel).rgb * 2.0; + sum += gSource.Sample(uv + float2(1.0, -1.0) * texel).rgb * 2.0; + return float4(sum / 12.0, 1.0); +} + +[shader("fragment")] +float4 fragmentChroma(VSOutput input) : SV_Target0 +{ + const float shift = gParams.flare.w; + const float2 uv = input.uv; + const float2 uvR = (uv - kCenter) * (1.0 + shift) + kCenter; + const float2 uvB = (uv - kCenter) * (1.0 - shift) + kCenter; + return float4(gSource.Sample(uvR).r, gSource.Sample(uv).g, gSource.Sample(uvB).b, 1.0); +} + +[shader("fragment")] +float4 fragmentGhosts(VSOutput input) : SV_Target0 +{ + const float2 uv = input.uv; + const float2 screen = uv * 2.0 - 1.0; + + float3 color = float3(0.0, 0.0, 0.0); + for (uint i = 0u; i < kGhostCount; ++i) + { + const float4 ghost = kGhostColors[i]; + const float2 ghostUv = (uv - kCenter) * kGhostScales[i]; + const float distanceMask = 1.0 - length(ghostUv); + const float mask = smoothstep(0.5, 0.9, distanceMask); + const float edge = smoothstep(0.75, 1.0, distanceMask) * 0.95 + 0.05; + color += gSource.Sample(ghostUv + kCenter).rgb * ghost.rgb * ghost.a * mask * edge; + } + return float4(color * discMask(screen * 0.9) * gParams.flare.z, 1.0); +} + +float2 fisheyeUv(float2 uv, float compression) +{ + const float2 centered = uv * 2.0 - 1.0; + const float scale = compression * atan(1.0 / compression); + const float radius = length(centered); + if (radius < 1e-5) + { + return uv; + } + const float bent = compression * tan(radius / compression); + return (centered / radius * bent / scale) * 0.5 + 0.5; +} + +[shader("fragment")] +float4 fragmentHalo(VSOutput input) : SV_Target0 +{ + const float shift = gParams.flare.w; + const float2 uv = input.uv; + const float2 screen = uv * 2.0 - 1.0; + + const float2 fish = fisheyeUv(uv, max(gParams.halo.w, 0.05)); + const float2 toCenter = kCenter - uv; + const float2 haloVector = normalize(toCenter + float2(1e-6, 0.0)) * gParams.halo.y; + + const float haloMask = smoothstep(gParams.halo.z, 1.0, saturate(distance(uv, kCenter) * 2.0)); + const float border = (discMask(screen) * discMask(screen * 0.8)) * 0.95 + 0.05; + + const float2 uvR = (fish - kCenter) * (1.0 + shift) + kCenter + haloVector; + const float2 uvG = fish + haloVector; + const float2 uvB = (fish - kCenter) * (1.0 - shift) + kCenter + haloVector; + const float3 color = float3(gSource.Sample(uvR).r, gSource.Sample(uvG).g, gSource.Sample(uvB).b); + return float4(color * border * haloMask * gParams.halo.x, 1.0); +} + +struct GlareVSOutput +{ + float4 position : SV_Position; + + [[vk::location(0)]] + float2 local : TEXCOORD0; + + [[vk::location(1)]] + float3 color : COLOR0; +}; + +static const float kPi = 3.14159265; + +/// Three streak quads per bright tile, 18 vertices an instance and the dim ones collapse offscreen +[shader("vertex")] +GlareVSOutput glareVertex(uint vertexId : SV_VertexID, uint instanceId : SV_InstanceID) +{ + GlareVSOutput output; + output.local = float2(0.0, 0.0); + output.color = float3(0.0, 0.0, 0.0); + output.position = float4(2.0, 2.0, 0.0, 1.0); + + // One tile covers 2x2 texels of the threshold target + uint width; + uint height; + gSource.GetDimensions(width, height); + const uint tilesX = (width + 1u) / 2u; + const uint2 tile = uint2(instanceId % tilesX, instanceId / tilesX); + const float2 size = float2(float(width), float(height)); + const float2 uv = (float2(tile) * 2.0 + 1.0) / size; + const float2 pixel = 1.0 / size; + + const float2 taps[5] = {float2(-1.0, 1.0), float2(1.0, 1.0), float2(0.0, 0.0), float2(-1.0, -1.0), float2(1.0, -1.0)}; + const float weights[5] = {0.175, 0.175, 0.3, 0.175, 0.175}; + float3 color = float3(0.0, 0.0, 0.0); + for (int i = 0; i < 5; ++i) + { + color += gSource.SampleLevel(uv + taps[i] * pixel * 1.5, 0.0).rgb * weights[i]; + } + + const float luma = dot(color, float3(1.0, 1.0, 1.0)); + if (luma <= 0.1 || gParams.glare.x <= 0.0) + { + return output; + } + + const uint quad = vertexId / 6u; + const uint corner = vertexId % 6u; + const uint cornerIndex[6] = {0u, 1u, 2u, 2u, 1u, 3u}; + const uint index = cornerIndex[corner]; + const float2 local = float2((index & 1u) == 0u ? -1.0 : 1.0, (index & 2u) == 0u ? -1.0 : 1.0); + + const float lumaScale = saturate(luma / max(gParams.glare.y, 0.01)); + float mask = 1.0 - saturate(distance(uv - 0.5, float2(0.0, 0.0)) * 2.0); + mask = mask * 0.6 + 0.4; + + const float streak = lumaScale * mask; + const float thickness = 4.0 / min(size.x, size.y) * 2.0; + const float angle = (uv.x * 2.0 - 1.0) * (kPi / 6.0) + (quad == 0u ? kPi * 0.5 : (quad == 1u ? kPi / 6.0 : kPi * 5.0 / 6.0)); + + const float2 scaled = float2(local.x * streak, local.y * thickness); + const float2 rotated = float2(scaled.x * cos(angle) - scaled.y * sin(angle), scaled.x * sin(angle) + scaled.y * cos(angle)); + // Aspect correct so the streak keeps its angle on a wide screen + const float2 offset = float2(rotated.x / gParams.glare.z, rotated.y); + + output.position = float4(uv * 2.0 - 1.0 + offset, 0.0, 1.0); + output.local = local; + output.color = color * gParams.tint.rgb * gParams.glare.x; + return output; +} + +[shader("fragment")] +float4 fragmentGlare(GlareVSOutput input) : SV_Target0 +{ + // Bright along the length, fading out to the tips and across the width + const float along = pow(saturate(1.0 - abs(input.local.x)), 2.0); + const float across = exp(-3.0 * input.local.y * input.local.y); + return float4(input.color * along * across, 1.0); +} + +/// Colour ramp from warm in the middle to cool at the edge +float3 flareGradient(float2 uv) +{ + const float r = saturate(distance(uv, kCenter) * 2.0); + return lerp(float3(1.0, 0.9, 0.75), float3(0.55, 0.75, 1.0), smoothstep(0.2, 1.0, r)); +} + +[shader("fragment")] +float4 fragmentMix(VSOutput input) : SV_Target0 +{ + const float2 uv = input.uv; + const float3 scene = gScene.Sample(uv).rgb; + const float3 flares = gSource.Sample(uv).rgb; + + // The streaks are drawn as points so a small blur hides the tile grid + const float2 pixel = gParams.texel.zw; + float3 glare = gGlare.Sample(uv + float2(-1.0, -1.0) * pixel).rgb * 0.25; + glare += gGlare.Sample(uv + float2(1.0, -1.0) * pixel).rgb * 0.25; + glare += gGlare.Sample(uv + float2(-1.0, 1.0) * pixel).rgb * 0.25; + glare += gGlare.Sample(uv + float2(1.0, 1.0) * pixel).rgb * 0.25; + + const float3 total = (flares + glare) * flareGradient(uv) * gParams.tint.rgb * gParams.tint.w; + return float4(scene + total, 1.0); +} diff --git a/engine/assets/shaders/lens_flare.slang.meta b/engine/assets/shaders/lens_flare.slang.meta new file mode 100644 index 000000000..d9a2e17c6 --- /dev/null +++ b/engine/assets/shaders/lens_flare.slang.meta @@ -0,0 +1,4 @@ +uid = "l3nsF1are0x" +type = "shader" +created_at = "2026-10-29T17:50:00.0000000Z" +modified_at = "2026-10-29T17:50:00.0000000Z" diff --git a/engine/assets/shaders/lighting.slang b/engine/assets/shaders/lighting.slang index cdae8f503..ce28a364c 100644 --- a/engine/assets/shaders/lighting.slang +++ b/engine/assets/shaders/lighting.slang @@ -432,11 +432,26 @@ float3 shadeLight(float3 N, float3 V, float3 L, float3 radiance, float3 albedo, return shadeLight(N, V, L, radiance, albedo, metallic, roughness, reflectivity, specular); } -float attenuateRange(float dist, float range) +float rangeWindow(float dist, float range) { float rangeFalloff = clamp(1.0 - pow(dist / max(range, 0.0001), 4.0), 0.0, 1.0); - rangeFalloff = rangeFalloff * rangeFalloff; - return rangeFalloff / max(dist * dist, 0.0001); + return rangeFalloff * rangeFalloff; +} + +float attenuateRange(float dist, float range) +{ + return rangeWindow(dist, range) / max(dist * dist, 0.0001); +} + +float spotFactor(GpuLight light, float3 position) +{ + if (light.viewPosType.w <= 0.5) + { + return 1.0; + } + const float3 fromCenter = position - light.worldPosRange.xyz; + const float cosAngle = dot(normalize(fromCenter), normalize(light.directionPad.xyz)); + return smoothstep(light.coneAngles.x, light.coneAngles.y, cosAngle); } // Mirror toast::LightShape and toast::LightShadowMode @@ -517,17 +532,11 @@ float3 lightSampleRadiance(GpuLight light, float3 position, float3 samplePoint, distance = length(toLight); L = toLight / max(distance, 1e-4); - float atten = attenuateRange(distance, light.worldPosRange.w); - if (light.viewPosType.w > 0.5) - { - const float3 fromCenter = position - light.worldPosRange.xyz; - const float cosAngle = dot(normalize(fromCenter), normalize(light.directionPad.xyz)); - atten *= smoothstep(light.coneAngles.x, light.coneAngles.y, cosAngle); - } + float atten = attenuateRange(distance, light.worldPosRange.w) * spotFactor(light, position); const uint shape = uint(light.shape.x + 0.5); - if ((shape == kLightShapeDisc || shape == kLightShapeRect) && dot(light.directionPad.xyz, -L) <= 0.0) + if (shape == kLightShapeDisc || shape == kLightShapeRect) { - atten = 0.0; + atten *= saturate(dot(light.directionPad.xyz, -L)); } return light.colorIntensity.rgb * light.colorIntensity.a * atten; } @@ -545,8 +554,173 @@ float3 analyticLightPoint(GpuLight light, float3 position) return center - axis + 2.0 * axis * t; } -/// Karis 2013 representative points -/// Disc and rect use the sphere of equal area +float emitterExtent(float4 shapeParams) +{ + const uint shape = uint(shapeParams.x + 0.5); + if (shape == kLightShapeRect) + { + return length(shapeParams.yz); + } + return shape == kLightShapeCapsule ? shapeParams.y + shapeParams.z : shapeParams.y; +} + +static const float kOctagonRadiusScale = 1.0540925; +static const uint kMaxEmitterVertices = 8u; + +/// Vector irradiance of a uniform Lambert polygon seen from the origin and clipped to the horizon of N +/// Vertices are relative to the receiver, its dot with N is the cosine weighted solid angle +float3 clippedPolygonVectorIrradiance(float3 vertices[kMaxEmitterVertices], uint count, float3 N) +{ + float3 clipped[kMaxEmitterVertices + 2]; + float heights[kMaxEmitterVertices]; + uint kept = 0u; + for (uint i = 0u; i < count; ++i) + { + heights[i] = dot(vertices[i], N); + } + for (uint i = 0u; i < count; ++i) + { + const uint j = (i + 1u == count) ? 0u : i + 1u; + const bool inside = heights[i] >= 0.0; + if (inside) + { + clipped[kept++] = vertices[i]; + } + if (inside != (heights[j] >= 0.0)) + { + clipped[kept++] = lerp(vertices[i], vertices[j], heights[i] / (heights[i] - heights[j])); + } + } + float3 sum = float3(0.0, 0.0, 0.0); + if (kept < 3u) + { + return sum; + } + for (uint i = 0u; i < kept; ++i) + { + const float3 a = clipped[i]; + const float3 b = clipped[(i + 1u == kept) ? 0u : i + 1u]; + const float3 na = a / max(length(a), 1e-6); + const float3 nb = b / max(length(b), 1e-6); + const float3 edge = cross(na, nb); + const float edgeLength = length(edge); + if (edgeLength > 1e-7) + { + sum += atan2(edgeLength, dot(na, nb)) * (edge / edgeLength); + } + } + return 0.5 * sum; +} + +/// Cosine weighted solid angle of a sphere clipped to the horizon, Lagarde and de Rousiers 2014 +float sphereCosineSolidAngle(float cosTheta, float sinSigmaSqr) +{ + if (cosTheta * cosTheta > sinSigmaSqr) + { + return kPi * sinSigmaSqr * saturate(cosTheta); + } + const float sinTheta = sqrt(max(1.0 - cosTheta * cosTheta, 1e-8)); + const float x = sqrt(1.0 / sinSigmaSqr - 1.0); + const float y = clamp(-x * (cosTheta / sinTheta), -1.0, 1.0); + const float sinThetaSqrtY = sinTheta * sqrt(1.0 - y * y); + return max((cosTheta * acos(y) - x * sinThetaSqrtY) * sinSigmaSqr + atan2(sinThetaSqrtY, x), 0.0); +} + +struct AreaEmitter +{ + float3 direction; + float irradiance; + float area; +}; + +/// Radiance is intensity over area so the far field matches a point light of that intensity +AreaEmitter evaluateAreaEmitter(GpuLight light, float3 position, float3 N) +{ + AreaEmitter emitter; + emitter.direction = N; + emitter.irradiance = 0.0; + emitter.area = 1.0; + + const uint shape = uint(light.shape.x + 0.5); + const float3 axis = light.directionPad.xyz; + const bool oriented = dot(axis, axis) > 0.5; + const float3 center = light.worldPosRange.xyz; + const float3 toCenter = center - position; + const float distance = max(length(toCenter), 1e-4); + const float3 centerDirection = toCenter / distance; + + float3 vertices[kMaxEmitterVertices]; + uint count = 0u; + if (shape == kLightShapeRect && oriented) + { + const float3 tangent = light.shapeTangent.xyz; + const float3 bitangent = cross(axis, tangent); + const float3 w = tangent * light.shape.y; + const float3 h = bitangent * light.shape.z; + vertices[0] = toCenter - w - h; + vertices[1] = toCenter + w - h; + vertices[2] = toCenter + w + h; + vertices[3] = toCenter - w + h; + count = 4u; + emitter.area = 4.0 * light.shape.y * light.shape.z; + } + else if (shape == kLightShapeDisc && oriented) + { + float3 tangent; + float3 bitangent; + perpendicularBasis(axis, tangent, bitangent); + const float radius = light.shape.y * kOctagonRadiusScale; + for (uint i = 0u; i < 8u; ++i) + { + float s, c; + sincos(0.78539816 * float(i), s, c); + vertices[i] = toCenter + (tangent * c + bitangent * s) * radius; + } + count = 8u; + emitter.area = kPi * light.shape.y * light.shape.y; + } + else if (shape == kLightShapeCapsule && oriented) + { + // Tube seen as a rectangle along the axis facing the receiver + const float3 sideways = -toCenter - axis * dot(-toCenter, axis); + float3 facing = sideways / max(length(sideways), 1e-6); + if (dot(sideways, sideways) < 1e-10) + { + float3 unused; + perpendicularBasis(axis, facing, unused); + } + const float3 across = cross(axis, facing) * light.shape.y; + const float3 along = axis * (light.shape.z + 0.25 * kPi * light.shape.y); + vertices[0] = toCenter - along - across; + vertices[1] = toCenter + along - across; + vertices[2] = toCenter + along + across; + vertices[3] = toCenter - along + across; + count = 4u; + emitter.area = 4.0 * light.shape.y * light.shape.z + kPi * light.shape.y * light.shape.y; + } + + if (count > 0u) + { + float3 vector = clippedPolygonVectorIrradiance(vertices, count, N); + // Either winding is accepted and the emitter lies on the side of its centre + vector *= dot(vector, centerDirection) < 0.0 ? -1.0 : 1.0; + emitter.irradiance = max(dot(N, vector), 0.0); + emitter.direction = vector / max(length(vector), 1e-6); + return emitter; + } + + const float radius = shape == kLightShapeRect ? sqrt(4.0 * light.shape.y * light.shape.z / kPi) : light.shape.y; + const float sinSigma = min(radius / distance, 0.999); + const float cosTheta = dot(N, centerDirection); + emitter.area = kPi * radius * radius; + emitter.irradiance = sphereCosineSolidAngle(cosTheta, sinSigma * sinSigma); + // A direction partly under the horizon is lifted so the lit part still shades + const float lifted = max(cosTheta, 0.5 * sinSigma); + emitter.direction = normalize(centerDirection + N * (lifted - cosTheta)); + return emitter; +} + +/// Whole surface diffuse, Karis 2013 representative point specular with disc and rect as the sphere of equal area float3 shadeAnalyticLight(GpuLight light, float3 position, float3 N, float3 V, float3 L, float3 radiance, float3 albedo, float metallic, float roughness, float reflectivity, out float3 specularPart) { @@ -565,6 +739,16 @@ float3 shadeAnalyticLight(GpuLight light, float3 position, float3 N, float3 V, f return float3(0.0, 0.0, 0.0); } + const AreaEmitter emitter = evaluateAreaEmitter(light, position, N); + const float nearest = length(analyticLightPoint(light, position) - position); + const float3 emitted = light.colorIntensity.rgb * light.colorIntensity.a * rangeWindow(nearest, light.worldPosRange.w) * + spotFactor(light, position); + const float3 diffuseRadiance = + emitted * emitter.irradiance / (emitter.area * max(dot(N, emitter.direction), 1e-3)); + + // Point falloff is singular at the surface + radiance *= nearest * nearest / (nearest * nearest + emitter.area / kPi); + const float alpha = max(roughness * roughness, 1e-4); const float3 R = reflect(-V, N); float3 core = toCenter; @@ -596,7 +780,8 @@ float3 shadeAnalyticLight(GpuLight light, float3 position, float3 N, float3 V, f const float3 direction = normalize(lerp(normalize(core), normalize(closest), hit)); const float normalization = lerp(1.0, (alpha / widened) * (alpha / widened) * lineNormalization, hit); - const LightTerms diffuse = shadeLightTerms(N, V, L, radiance, albedo, metallic, roughness, reflectivity); + const LightTerms diffuse = + shadeLightTerms(N, V, emitter.direction, diffuseRadiance, albedo, metallic, roughness, reflectivity); const LightTerms specular = shadeLightTerms(N, V, direction, radiance, albedo, metallic, roughness, reflectivity); specularPart = specular.specular * normalization; return diffuse.diffuse * albedo + specularPart; @@ -638,6 +823,9 @@ static const float2 kPoissonDisk[12] = { static const uint kShadowProbeTaps = 4u; +static const float kWidePenumbraTexels = 6.0; +static const uint kWidePenumbraTaps = 16u; + /// Walked per frame so TAA averages the rotation float interleavedGradientNoise(float2 pixel) { @@ -693,6 +881,9 @@ float pcfShadowRadius( float2 rotation = float2(cos(angle), sin(angle)); float radius = texelSize * radiusTexels; + // A wide penumbra self shadows without a larger slope allowance + maxGradientAdjust *= max(radiusTexels / kShadowFilterRadiusTexels, 1.0); + // LevelZero since implicit derivatives are undefined under per pixel branches // Centre and cross agreeing skips the rest float sum = 0.0; @@ -719,7 +910,22 @@ float pcfShadowRadius( const float tapDepth = compareDepth + clamp(dot(gradient, offset), -maxGradientAdjust, maxGradientAdjust); sum += shadowMaps.SampleCmpLevelZero(float3(uv + offset, layer), tapDepth); } - return sum / 12.0; + if (radiusTexels <= kWidePenumbraTexels) + { + return sum / 12.0; + } + + [unroll] + for (uint i = 0u; i < kWidePenumbraTaps; ++i) + { + const float spiral = sqrt((float(i) + 0.5) / float(kWidePenumbraTaps)); + const float spiralAngle = float(i) * 2.39996323 + angle; + const float2 tap = float2(cos(spiralAngle), sin(spiralAngle)) * spiral; + const float2 offset = tap * radius; + const float tapDepth = compareDepth + clamp(dot(gradient, offset), -maxGradientAdjust, maxGradientAdjust); + sum += shadowMaps.SampleCmpLevelZero(float3(uv + offset, layer), tapDepth); + } + return sum / float(12u + kWidePenumbraTaps); } float pcfShadow( @@ -731,8 +937,8 @@ float pcfShadow( } /// Caps the blocker search cost -static const float kMaxBlockerSearchTexels = 16.0; -static const float kMaxPenumbraTexels = 24.0; +static const float kMaxBlockerSearchTexels = 40.0; +static const float kMaxPenumbraTexels = 56.0; float emitterRadius(GpuLight light) { @@ -1233,15 +1439,33 @@ static const uint kRenderModeVoxelVolumes = 23; static const uint kRenderModeVoxelMaterials = 24; static const uint kRenderModeVoxelPaletteId = 25; +// Folds away when TOAST_DEV_TOOLS is 0 bool isVoxelDebugMode(uint mode) { +#if TOAST_DEV_TOOLS return mode >= kRenderModeVoxelSteps && mode <= kRenderModeVoxelPaletteId; +#else + return false; +#endif +} + +bool isShadowDebugMode(uint mode) +{ +#if TOAST_DEV_TOOLS + return mode == 19u; +#else + return false; +#endif } /// Modes evaluateDebugView draws for every surface bool isSurfaceDebugMode(uint mode) { +#if TOAST_DEV_TOOLS return (mode >= 1u && mode <= 10u) || mode == 16u || isVoxelDebugMode(mode); +#else + return false; +#endif } float4 evaluateDebugView(Surface surface, ClusterCell cluster) @@ -1506,7 +1730,7 @@ void evaluateDirectLighting(Surface surface, ClusterCell float dist = cluster.viewDistance; uint shadowCasterIndex = uint(gCamera.shadowParams.x) - 1; - bool shadowDebugView = gCamera.renderModePad.x == 19; + bool shadowDebugView = isShadowDebugMode(gCamera.renderModePad.x); for (uint di = 0; di < gCamera.directionalLightCountPad.x; ++di) { @@ -1562,7 +1786,8 @@ void evaluateDirectLighting(Surface surface, ClusterCell } const float3 toCenter = positionRange.xyz - surface.worldPosition; const float reach = positionRange.w + shapeParams.z; - if (!shadowDebugView && (dot(toCenter, toCenter) >= reach * reach || dot(N, toCenter) + shapeParams.z <= 0.0)) + const float extent = emitterExtent(shapeParams); + if (!shadowDebugView && (dot(toCenter, toCenter) >= reach * reach || dot(N, toCenter) + extent <= 0.0)) { continue; } @@ -1582,7 +1807,9 @@ void evaluateDirectLighting(Surface surface, ClusterCell } // Zero contribution so skip before any shadow or ray work - if ((atten <= 0.0 || dot(N, L) <= 0.0) && !shadowDebugView) + // An emitter with extent still lights a receiver whose centre direction is under the horizon + const bool hasExtent = extent > 0.0; + if ((atten <= 0.0 || (!hasExtent && dot(N, L) <= 0.0)) && !shadowDebugView) { continue; } @@ -1590,7 +1817,18 @@ void evaluateDirectLighting(Surface surface, ClusterCell float shadow; if (tracedShadowsEnabled()) { - shadow = shadowFilter.traceMeshShadow(surface.worldPosition, geometricNormal, L, lightDist); + float3 rayDirection = L; + float rayDistance = lightDist; + if (hasExtent) + { + const float2 pixel = surface.pixel; + const float3 u = float3(interleavedGradientNoise(pixel), interleavedGradientNoise(pixel + 7.31), + interleavedGradientNoise(pixel + 13.77)); + const float3 toSample = sampleLightShape(light, surface.worldPosition, u) - surface.worldPosition; + rayDistance = max(length(toSample), 1e-4); + rayDirection = toSample / rayDistance; + } + shadow = shadowFilter.traceMeshShadow(surface.worldPosition, geometricNormal, rayDirection, rayDistance); } else { @@ -1639,7 +1877,7 @@ ShadingResult evaluateLighting(Surface surface, ClusterC float debugMinShadow = 1.0; evaluateDirectLighting(surface, cluster, shadowFilter, color, specular, debugMinShadow); - if (gCamera.renderModePad.x == 19) + if (isShadowDebugMode(gCamera.renderModePad.x)) { result.color = float3(debugMinShadow, debugMinShadow, debugMinShadow); result.isDebugView = true; diff --git a/engine/assets/shaders/motion_blur.slang b/engine/assets/shaders/motion_blur.slang new file mode 100644 index 000000000..c2cb1204c --- /dev/null +++ b/engine/assets/shaders/motion_blur.slang @@ -0,0 +1,104 @@ +import fullscreen; +import depth_convention; + +struct MotionBlurParams +{ + // Current unjittered clip to previous clip when motion is not written + float4x4 reprojection; + // xy 1 over size zw size + float4 texel; + // x shutter share of the frame y longest blur as a share of the height z samples w 1 ignores written motion + float4 tuning; + // x camera near y camera far + float4 camera; +}; + +[[vk::push_constant]] +ConstantBuffer gParams; + +[[vk::binding(0, 0)]] +Sampler2D gColor; + +[[vk::binding(1, 0)]] +Sampler2D gDepth; + +[[vk::binding(2, 0)]] +Sampler2D gMotion; + +typedef FullscreenVSOutput VSOutput; + +[shader("vertex")] +VSOutput vertexMain(uint vertexId : SV_VertexID) +{ + return fullscreenTriangle(vertexId); +} + +float noiseAt(float2 pixel) +{ + return frac(52.9829189 * frac(dot(pixel, float2(0.06711056, 0.00583715)))); +} + +float linearDepth(float depth) +{ +#if TOAST_REVERSED_Z + const float standardDepth = 1.0 - depth; +#else + const float standardDepth = depth; +#endif + const float near = gParams.camera.x; + const float far = gParams.camera.y; + return near * far / max(far - standardDepth * (far - near), 1e-6); +} + +/// Movement of the pixel over one frame in uv +float2 velocityAt(float2 uv, float depth) +{ + const float4 motion = gMotion.SampleLevel(uv, 0.0); + if (motion.w > 0.5 && gParams.tuning.w < 0.5) + { + return motion.xy; + } + const float4 previousClip = mul(gParams.reprojection, float4(uv * 2.0 - 1.0, depth, 1.0)); + return uv - (previousClip.xy / previousClip.w * 0.5 + 0.5); +} + +[shader("fragment")] +float4 fragmentMain(VSOutput input) : SV_Target0 +{ + const float2 uv = input.uv; + const float4 center = gColor.SampleLevel(uv, 0.0); + + const float depth = gDepth.SampleLevel(uv, 0.0).r; + const float2 size = gParams.texel.zw; + float2 velocity = velocityAt(uv, depth) * gParams.tuning.x; + + // Longest blur in pixels since uv is stretched along the width + const float maxPixels = gParams.tuning.y * size.y; + const float2 pixels = velocity * size; + const float blurPixels = sqrt(dot(pixels, pixels)); + if (blurPixels < 1.0) + { + return center; + } + velocity *= min(1.0, maxPixels / blurPixels); + + const uint count = uint(gParams.tuning.z); + const float centerDepth = linearDepth(depth); + const float jitter = noiseAt(input.position.xy); + + float3 sum = center.rgb; + float weightSum = 1.0; + for (uint i = 0u; i < count; ++i) + { + const float t = (float(i) + jitter) / float(count) - 0.5; + const float2 sampleUv = uv + velocity * t; + const float sampleDepth = linearDepth(gDepth.SampleLevel(sampleUv, 0.0).r); + + // A sample in front of the pixel must not smear over what is behind it + const float inFront = (centerDepth - sampleDepth) / max(centerDepth, 0.1); + const float weight = 1.0 - smoothstep(0.02, 0.1, inFront); + sum += gColor.SampleLevel(sampleUv, 0.0).rgb * weight; + weightSum += weight; + } + return float4(sum / weightSum, center.a); +} diff --git a/engine/assets/shaders/motion_blur.slang.meta b/engine/assets/shaders/motion_blur.slang.meta new file mode 100644 index 000000000..7805f95b9 --- /dev/null +++ b/engine/assets/shaders/motion_blur.slang.meta @@ -0,0 +1,4 @@ +uid = "mOtI0nBlur7" +type = "shader" +created_at = "2026-10-29T03:00:00.0000000Z" +modified_at = "2026-10-29T03:00:00.0000000Z" diff --git a/engine/assets/shaders/present.slang b/engine/assets/shaders/present.slang index 28fe3b230..7516e2b6a 100644 --- a/engine/assets/shaders/present.slang +++ b/engine/assets/shaders/present.slang @@ -16,3 +16,9 @@ float4 fragmentMain(VSOutput input) : SV_Target0 { return float4(gSource.Sample(input.uv).rgb, 1.0); } + +[shader("fragment")] +float depthMain(VSOutput input) : SV_Depth +{ + return gSource.SampleLevel(input.uv, 0.0).r; +} diff --git a/engine/assets/shaders/scene_trace.slang b/engine/assets/shaders/scene_trace.slang index 36aae1d11..691b04950 100644 --- a/engine/assets/shaders/scene_trace.slang +++ b/engine/assets/shaders/scene_trace.slang @@ -53,10 +53,15 @@ struct SceneHit uint material; }; +// Per compile since both walks together cost 40 registers +#ifndef TOAST_TRACE_RAY_QUERY +#define TOAST_TRACE_RAY_QUERY TOAST_RAY_QUERY +#endif + bool traceRayQueryEnabled() { -#if TOAST_RAY_QUERY - return gCamera.tracedShadowParams.y >= 0.5; +#if TOAST_RAY_QUERY && TOAST_TRACE_RAY_QUERY + return true; #else return false; #endif diff --git a/engine/assets/shaders/ssr.slang b/engine/assets/shaders/ssr.slang index c73cd4a39..2eaa741d9 100644 --- a/engine/assets/shaders/ssr.slang +++ b/engine/assets/shaders/ssr.slang @@ -8,7 +8,6 @@ struct SsrParams { float4x4 viewProjection; float4x4 inverseViewProjection; - /// xyz camera position w frame index for the noise float4 cameraPosition; // x intensity y max roughness z thickness w step count float4 tuning; @@ -49,8 +48,6 @@ Sampler2D gTemporal; [[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 static const float kMirrorRoughness = 0.05; @@ -59,10 +56,8 @@ static const float kLobeTrim = 0.75; static const float kTemporalBlend = 0.1; -static const uint kResolveTaps = 5u; -static const float2 kResolvePattern[5] = { - float2(0.0, 0.0), float2(1.0, 0.3), float2(-0.3, 1.0), float2(-1.0, -0.3), float2(0.3, -1.0), -}; +/// Covers the 4x4 stratum tile +static const int kResolveReach = 2; typedef FullscreenVSOutput VSOutput; @@ -108,13 +103,6 @@ float noiseAt(float2 pixel) return frac(52.9829189 * frac(dot(pixel, float2(0.06711056, 0.00583715)))); } -/// Walks R2 per frame -float2 noise2(float2 pixel) -{ - const float frame = gParams.cameraPosition.w; - return frac(float2(noiseAt(pixel), noiseAt(pixel.yx + float2(47.0, 17.0))) + frame * float2(0.7548776662, 0.5698402910)); -} - struct SurfaceSample { bool valid; @@ -260,7 +248,7 @@ float4 traceMain(VSOutput input) : SV_Target0 const float3 N = surface.normal; const float3 V = normalize(gParams.cameraPosition.xyz - surface.position); const float NdotV = saturate(dot(N, V)); - const float2 u = noise2(input.position.xy); + const float2 u = lobeStratum(int2(input.position.xy)); float3 L = reflect(-V, N); float pdf = 1.0; @@ -290,7 +278,7 @@ float4 traceMain(VSOutput input) : SV_Target0 float2 hitUv; float travelled; - if (!marchScreen(origin, L, u, hitUv, travelled)) + if (!marchScreen(origin, L, float2(noiseAt(input.position.xy), 0.0), hitUv, travelled)) { return float4(0.0, 0.0, pdf, 0.0); } @@ -336,63 +324,68 @@ float4 resolveMain(VSOutput input) : SV_Target0 const float alpha = max(surface.roughness * surface.roughness, 1e-3); const bool mirror = surface.roughness <= kMirrorRoughness; - const uint taps = mirror ? 1u : kResolveTaps; - const float radius = lerp(1.0, 4.0, saturate(surface.roughness / max(gParams.tuning.y, 1e-4))); - const float angle = noise2(input.position.xy + 31.0).x * 2.0 * kPi; - const float2x2 rotation = float2x2(cos(angle), -sin(angle), sin(angle), cos(angle)); + const int reach = mirror ? 0 : kResolveReach; // Tonemapped so one bright hit does not speckle float3 colorSum = float3(0.0, 0.0, 0.0); float brdfSum = 0.0; float confidenceSum = 0.0; float geometrySum = 0.0; - for (uint i = 0u; i < taps; ++i) + for (int y = -reach; y <= reach; ++y) { - const float2 uv = input.uv + mul(rotation, kResolvePattern[i]) * radius * texel; - const float4 hit = gHits.SampleLevel(uv, 0.0); - - // A neighbour on another surface traced a ray this pixel could not have - float geometry = 1.0; - if (i > 0u) + for (int x = -reach; x <= reach; ++x) { - const SurfaceSample neighbour = surfaceAt(uv); - if (!neighbour.valid) + const int2 offset = int2(x, y); + const float2 uv = input.uv + float2(offset) * texel; + if (!insideScreen(uv)) { continue; } - geometry = planeWeight(surface, neighbour.position) * saturate(dot(neighbour.normal, N) * 4.0 - 3.0); - } - if (geometry <= 1e-3) - { - continue; - } - geometrySum += geometry; - if (hit.w <= 0.0) - { - continue; - } + const float4 hit = gHits.SampleLevel(uv, 0.0); - const float hitDepth = gDepth.SampleLevel(hit.xy, 0.0).r; - const float3 L = normalize(worldPositionFromDepth(hit.xy, hitDepth) - surface.position); - const float NdotL = dot(N, L); - if (NdotL <= 0.0) - { - continue; - } + // A neighbour on another surface traced a ray this pixel could not have + float geometry = mirror ? 1.0 : lobeStratumWeight(offset); + if (x != 0 || y != 0) + { + const SurfaceSample neighbour = surfaceAt(uv); + if (!neighbour.valid) + { + continue; + } + geometry *= planeWeight(surface, neighbour.position) * saturate(dot(neighbour.normal, N) * 4.0 - 3.0); + } + if (geometry <= 1e-3) + { + continue; + } + geometrySum += geometry; + if (hit.w <= 0.0) + { + continue; + } - float brdf = 1.0; - if (!mirror) - { - const float3 H = normalize(V + L); - brdf = ggxD(saturate(dot(N, H)), alpha) * smithG1(NdotV, alpha) * smithG1(NdotL, alpha) / max(4.0 * NdotV, 1e-4) / - max(hit.z, 1e-4); - } - brdf *= geometry; + const float hitDepth = gDepth.SampleLevel(hit.xy, 0.0).r; + const float3 L = normalize(worldPositionFromDepth(hit.xy, hitDepth) - surface.position); + const float NdotL = dot(N, L); + if (NdotL <= 0.0) + { + continue; + } - const float3 color = hitColor(hit.xy); - colorSum += brdf * hit.w * color / (1.0 + luminance(color)); - brdfSum += brdf * hit.w; - confidenceSum += geometry * hit.w; + float brdf = 1.0; + if (!mirror) + { + const float3 H = normalize(V + L); + brdf = ggxD(saturate(dot(N, H)), alpha) * smithG1(NdotV, alpha) * smithG1(NdotL, alpha) / max(4.0 * NdotV, 1e-4) / + max(hit.z, 1e-4); + } + brdf *= geometry; + + const float3 color = hitColor(hit.xy); + colorSum += brdf * hit.w * color / (1.0 + luminance(color)); + brdfSum += brdf * hit.w; + confidenceSum += geometry * hit.w; + } } if (brdfSum <= 1e-6 || geometrySum <= 1e-6) diff --git a/engine/assets/shaders/taa.slang b/engine/assets/shaders/taa.slang index 1ceb33f32..7cdce811d 100644 --- a/engine/assets/shaders/taa.slang +++ b/engine/assets/shaders/taa.slang @@ -9,6 +9,10 @@ struct TaaParams float4 texel; // x history weight y 1 resets history z 1 ignores written motion w 1 draws the motion debug view float4 tuning; + // xy render size zw jitter in uv + float4 source; + // x 1 when the source is smaller than the output + float4 upscale; }; [[vk::push_constant]] @@ -108,12 +112,102 @@ float3 motionColor(float4 motion) return rgb * saturate(length(pixels) / 8.0); } +/// Jitter aware Gaussian of the render texels around this output pixel +float4 resolveUpscaled(float2 uv) +{ + const float2 renderSize = gParams.source.xy; + const float2 renderTexel = 1.0 / renderSize; + // Render texel i shows the scene at (i + 0.5) / size minus the jitter + const float2 renderPosition = (uv + gParams.source.zw) * renderSize - 0.5; + const int2 base = int2(round(renderPosition)); + const int2 last = int2(renderSize) - 1; + + float3 filtered = float3(0.0, 0.0, 0.0); + float weightSum = 0.0; + float nearest = 0.0; + float3 moment1 = float3(0.0, 0.0, 0.0); + float3 moment2 = float3(0.0, 0.0, 0.0); + float closestDepth = kFarDepth; + float2 closestUv = uv; + for (int y = -1; y <= 1; ++y) + { + for (int x = -1; x <= 1; ++x) + { + const int2 texel = clamp(base + int2(x, y), int2(0, 0), last); + const float2 sampleUv = (float2(texel) + 0.5) * renderTexel; + const float3 value = toYCoCg(compress(gCurrent.SampleLevel(sampleUv, 0.0).rgb)); + moment1 += value; + moment2 += value * value; + + const float2 offset = float2(texel) - renderPosition; + const float weight = exp(-2.29 * dot(offset, offset)); + filtered += value * weight; + weightSum += weight; + nearest = max(nearest, weight); + + if (abs(x) + abs(y) <= 1) + { + const float depth = gDepth.SampleLevel(sampleUv, 0.0).r; + if (isCloserDepth(depth, closestDepth)) + { + closestDepth = depth; + closestUv = sampleUv; + } + } + } + } + filtered /= max(weightSum, 1e-5); + + const float4 motion = gMotion.SampleLevel(closestUv, 0.0); + [branch] + if (gParams.tuning.w > 0.5) + { + return float4(motionColor(motion), 1.0); + } + + float2 previousUv; + if (motion.w > 0.5 && gParams.tuning.z < 0.5) + { + previousUv = uv - motion.xy; + } + else + { + const float4 previousClip = mul(gParams.reprojection, float4(closestUv * 2.0 - 1.0, closestDepth, 1.0)); + previousUv = uv + previousClip.xy / previousClip.w * 0.5 + 0.5 - closestUv; + } + + const 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(filtered)), 1.0); + } + + const float3 mean = moment1 / 9.0; + const float3 sigma = sqrt(max(moment2 / 9.0 - mean * mean, float3(0.0, 0.0, 0.0))); + const float3 history = clipToBox(toYCoCg(compress(sampleHistory(previousUv))), mean - sigma, mean + sigma); + + const float currentWeight = (1.0 - gParams.tuning.x * (1.0 - saturate(motion.z))) * nearest; + float3 resolved = lerp(history, filtered, currentWeight); + if (any(isnan(resolved))) + { + resolved = filtered; + } + return float4(expand(fromYCoCg(resolved)), 1.0); +} + [shader("fragment")] float4 fragmentMain(VSOutput input) : SV_Target0 { float2 uv = input.uv; float2 texel = gParams.texel.xy; + [branch] + if (gParams.upscale.x > 0.5) + { + return resolveUpscaled(uv); + } + float3 center = float3(0.0, 0.0, 0.0); float3 moment1 = float3(0.0, 0.0, 0.0); float3 moment2 = float3(0.0, 0.0, 0.0); diff --git a/engine/assets/shaders/tonemap.slang b/engine/assets/shaders/tonemap.slang index 2f0fc56cf..e51e6b695 100644 --- a/engine/assets/shaders/tonemap.slang +++ b/engine/assets/shaders/tonemap.slang @@ -1,10 +1,11 @@ import fullscreen; +import gt7_tonemap; struct TonemapParams { float exposure; - // 0 Reinhard 1 ACES + // 0 Reinhard 1 ACES 2 GT7 uint mode; float gamma; @@ -20,6 +21,19 @@ struct TonemapParams // 1 multiplies exposure by the metered gExposure uint autoExposure; + + // 1 writes scRGB where 1.0 is 80 nits + uint hdr; + + // Both in scRGB + float paperWhiteScale; + float peakScale; + + // GT7 toe exponent, the contrast dip under the grey point + float gtToe; + + // 0.01 moves red and blue 1% of the distance to the centre + float chromaticAberration; }; [[vk::binding(0,0)]] @@ -66,13 +80,46 @@ float ditherOffset(float2 pixel) return (noise - 0.5) / 255.0; } +/// Reinhard on luminance with its asymptote at the white point +float3 tonemapHdr(float3 color, float whitePoint) +{ + const float l = luminance(color); + const float mapped = l / (1.0 + l / whitePoint); + return color * (mapped / max(l, 1e-6)); +} + [shader("fragment")] float4 fragmentMain(VSOutput input) : SV_Target0 { float exposure = gParams.autoExposure != 0u ? gParams.exposure * gExposure[1] : gParams.exposure; - float3 color = gSceneColor.Sample(input.uv).rgb * exposure; + float3 color; + if (gParams.chromaticAberration > 0.0) + { + const float2 offset = (input.uv - 0.5) * gParams.chromaticAberration; + color = float3(gSceneColor.Sample(input.uv - offset).r, gSceneColor.Sample(input.uv).g, gSceneColor.Sample(input.uv + offset).b) * exposure; + } + else + { + color = gSceneColor.Sample(input.uv).rgb * exposure; + } - color = gParams.mode == 1 ? tonemapACES(color) : tonemapReinhard(color); + const bool hdr = gParams.hdr != 0u; + if (gParams.mode == 2) + { + // scRGB 1.0 is 80 nits and GT7 counts in units of 100 + const float paperWhite = hdr ? gParams.paperWhiteScale * 0.8 : kGt7SdrPaperWhite; + const float peak = hdr ? gParams.peakScale * 0.8 : kGt7SdrPaperWhite; + color = gt7ToneMap(color, paperWhite, peak, gParams.gtToe); + } + else if (hdr) + { + const float whitePoint = max(gParams.peakScale / max(gParams.paperWhiteScale, 1e-3), 1.0); + color = tonemapHdr(color, whitePoint); + } + else + { + color = gParams.mode == 1 ? tonemapACES(color) : tonemapReinhard(color); + } const float kMiddleGrey = 0.18; color = max((color - kMiddleGrey) * gParams.contrast + kMiddleGrey, float3(0.0, 0.0, 0.0)); @@ -80,6 +127,24 @@ float4 fragmentMain(VSOutput input) : SV_Target0 float lum = luminance(color); color = max(lerp(float3(lum, lum, lum), color, gParams.saturation), float3(0.0, 0.0, 0.0)); + if (hdr) + { + if (gParams.vignette > 0.0) + { + float2 centered = input.uv - 0.5; + float falloff = 1.0 - saturate(dot(centered, centered) * gParams.vignette * 2.0); + color *= falloff * falloff; + } + + if (gParams.grain > 0.0) + { + float noise = frac(sin(dot(input.uv + frac(gParams.time), float2(12.9898, 78.233))) * 43758.5453); + color += (noise - 0.5) * gParams.grain; + } + + return float4(min(max(color, float3(0.0, 0.0, 0.0)) * gParams.paperWhiteScale, float3(gParams.peakScale, gParams.peakScale, gParams.peakScale)), 1.0); + } + float invGamma = 1.0 / max(gParams.gamma, 1e-4); color = pow(max(color, float3(0.0, 0.0, 0.0)), float3(invGamma, invGamma, invGamma)); diff --git a/engine/assets/shaders/ui.slang b/engine/assets/shaders/ui.slang index 6e77d283d..0af51d750 100644 --- a/engine/assets/shaders/ui.slang +++ b/engine/assets/shaders/ui.slang @@ -1,6 +1,16 @@ +import fullscreen; + [[vk::binding(0,0)]] Sampler2D gUITexture; +struct UiPush { + // x paper white scale, 0 on SDR + float4 hdr; +}; + +[[vk::push_constant]] +UiPush gPush; + struct VSOutput { float4 position : SV_Position; @@ -18,5 +28,5 @@ VSOutput vertexMain(uint vertex_id : SV_VertexID) { [shader("fragment")] float4 fragmentMain(VSOutput input) : SV_Target0 { - return gUITexture.Sample(input.uv); + return uiToDisplay(gUITexture.Sample(input.uv), gPush.hdr.x); } diff --git a/engine/assets/shaders/ui_world.slang b/engine/assets/shaders/ui_world.slang index 81061b7ba..85e27f458 100644 --- a/engine/assets/shaders/ui_world.slang +++ b/engine/assets/shaders/ui_world.slang @@ -1,3 +1,5 @@ +import fullscreen; + struct CameraUBO { float4x4 view; float4x4 projection; @@ -15,6 +17,9 @@ Sampler2D gPanelTexture; struct WorldPush { float4x4 model; + + // x paper white scale for the overlay layer, 0 elsewhere + float4 hdr; }; [[vk::push_constant]] @@ -48,5 +53,5 @@ VSOutput vertexMain(uint vertex_id : SV_VertexID) { float4 fragmentMain(VSOutput input) : SV_Target0 { const float4 color = gPanelTexture.Sample(input.uv); clip(color.a - 0.001); - return color; + return uiToDisplay(color, gWorldPush.hdr.x); } diff --git a/engine/assets/shaders/volumetrics.slang b/engine/assets/shaders/volumetrics.slang index 2f46f0305..203789476 100644 --- a/engine/assets/shaders/volumetrics.slang +++ b/engine/assets/shaders/volumetrics.slang @@ -400,8 +400,8 @@ float3 directInScatter(float3 position, float3 toCamera, float2 pixel, float g, // 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); + const float3 picked = pick.light * (pick.total / pick.weight) * float(kMarchedInterleave); + light += picked * traceVisibility(position, pick.segment, kFogShadowCone, false).x; } return light; } @@ -450,14 +450,14 @@ float4 scatterMain(VSOutput input) : SV_Target0 { continue; } - const float3 inScatter = - medium.scattering * - directInScatter(position, toCamera, float2(pixel), medium.anisotropy, (i + phase) % kMarchedInterleave == 0u, random) + - medium.ambientScatter + - medium.emission; + // Before the shadow trace so less is live across it const float stepExtinction = exp(-medium.extinction * (far - near)); - scattered += transmittance * inScatter * (1.0 - stepExtinction) / medium.extinction; + const float stepWeight = transmittance * (1.0 - stepExtinction) / medium.extinction; + scattered += (medium.ambientScatter + medium.emission) * stepWeight; + const float3 directWeight = medium.scattering * stepWeight; transmittance *= stepExtinction; + scattered += directWeight * + directInScatter(position, toCamera, float2(pixel), medium.anisotropy, (i + phase) % kMarchedInterleave == 0u, random); if (transmittance < 1e-3) { break; diff --git a/engine/assets/shaders/voxel_lighting.slang b/engine/assets/shaders/voxel_lighting.slang index 11abeb2c4..8d7b77ef9 100644 --- a/engine/assets/shaders/voxel_lighting.slang +++ b/engine/assets/shaders/voxel_lighting.slang @@ -11,9 +11,7 @@ struct LightingPushConstants { // Must be jittered since depth was rasterized with it float4x4 inverseViewProjection; - // xyz camera position w rounding radius in world units - float4 cameraRadius; - // xy viewport size z pixels per world unit at 1 m w spiral rotation in turns + // xy viewport size float4 viewport; // x instances with bounds y light candidates z nonzero when fragmentMarched shades w sun reach in meters uint4 lighting; @@ -49,7 +47,6 @@ uint ambientGridBucket(int3 cell) [[vk::binding(0, 2)]] Sampler2D gAlbedo; -// Unrounded so shadow bias still sees the flat face [[vk::binding(1, 2)]] Sampler2D gFaceNormal; @@ -71,7 +68,7 @@ Sampler2D gMotion; [[vk::binding(6, 2)]] Sampler2D gCandidateReservoirs; -/// Rounded normal of fragmentMain +/// Scene normal of fragmentMain [[vk::binding(7, 2)]] Sampler2D gSceneNormal; @@ -88,20 +85,12 @@ struct FSOutput { float4 color : SV_Target0; float4 indirectDiffuse : SV_Target1; - /// xyz rounded normal w SSR roughness and zero where SSAO and SSR pass through + /// xyz normal w SSR roughness and zero where SSAO and SSR pass through float4 normalRoughness : SV_Target2; /// Indirect over albedo that fragmentAmbient occludes when ambient rays are on float4 noisyDiffuse : SV_Target3; }; -/// One tap per pixel of radius within this range -static const uint kMinRoundingTaps = 4; -static const uint kMaxRoundingTaps = 8; -static const float kGoldenAngle = 2.39996323; - -// kMaxRoundingTaps spread wider than this stop reading as one smooth bevel -static const float kMaxRoundingPixels = 32.0; - [shader("vertex")] VSOutput vertexMain(uint vertexId : SV_VertexID) { @@ -123,57 +112,6 @@ bool isLitVoxelAt(int2 pixel, out float3 faceNormal) return encoded.a > 0.5; } -// Interleaved gradient noise -float noiseAt(float2 pixel) -{ - return frac(52.9829189 * frac(dot(pixel, float2(0.06711056, 0.00583715)))); -} - -/// Averages lit neighbour face normals by world distance so voxel edges read as bevels -float3 roundedNormal(float2 pixel, float3 center, float3 face) -{ - const float radius = gPush.cameraRadius.w; - if (radius <= 0.0) - { - return face; - } - - // A fixed world radius shrinks under a pixel with distance so rounding stops there - const float distanceToCamera = max(distance(center, gPush.cameraRadius.xyz), 1e-4); - const float radiusPixels = min(radius * gPush.viewport.z / distanceToCamera, kMaxRoundingPixels); - if (radiusPixels < 1.0) - { - return face; - } - - const float rotation = (noiseAt(pixel) + gPush.viewport.w) * 6.28318531; - 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; - 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)) - { - continue; - } - - // An occluded or grazing neighbour drops out - const float3 toTap = positionAt(tap) - center; - const float falloff = saturate(1.0 - dot(toTap, toTap) * invRadiusSq); - sum += tapNormal * (falloff * falloff); - } - return normalize(sum); -} - /// Mirrors voxel::k_voxel_size static const float kVoxelSize = 0.1; @@ -181,9 +119,9 @@ static const float kVoxelSize = 0.1; 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) +float3 marchedVisibility(float3 origin, float3 target, float coneSlope, bool glassScene = true) { - return traceVisibility(origin, target - origin, coneSlope, (gPush.reuse.x & kSceneGlass) != 0u); + return traceVisibility(origin, target - origin, coneSlope, glassScene && (gPush.reuse.x & kSceneGlass) != 0u); } /// Mesh shadow of a marched light from its map since traced shadows are off with voxels @@ -576,16 +514,17 @@ LightTerms shadeMarchedLights(Surface surface, float3 shadowOrigin, float4 motio continue; } const float meshShadow = meshShadowFactor(light, surface); - const float3 visibility = meshShadow > 0.0 ? min(meshShadow, marchedVisibility(shadowOrigin, samplePoint, shadowConeSlope(light, distance))) - : float3(0.0, 0.0, 0.0); - if (all(visibility <= 0.0)) + if (meshShadow <= 0.0) { continue; } - const LightTerms sample = shadeLightTerms(surface.normal, surface.viewDirection, L, radiance, surface.albedo, surface.metallic, - surface.roughness, surface.reflectivity); - terms.diffuse += sample.diffuse * visibility; - terms.specular += analyticSpecular(light, surface) * visibility; + // Before the trace so the surface is not live across it + const float3 diffuse = shadeLightTerms(surface.normal, surface.viewDirection, L, radiance, surface.albedo, surface.metallic, + surface.roughness, surface.reflectivity).diffuse; + const float3 specular = analyticSpecular(light, surface); + const float3 visibility = min(meshShadow, marchedVisibility(shadowOrigin, samplePoint, shadowConeSlope(light, distance))); + terms.diffuse += diffuse * visibility; + terms.specular += specular * visibility; } // Uniform light and point candidates weighted by luminance times count then ReSTIR temporal reuse @@ -676,11 +615,11 @@ LightTerms shadeMarchedLights(Surface surface, float3 shadowOrigin, float4 motio { const float unbiasedWeight = weightSum / (float(sampleCount) * chosen.target); const float meshShadow = meshShadowFactor(gLights[chosen.lightIndex], surface); + const LightTerms unshadowed = shadeSampledLight(surface, chosen, float3(unbiasedWeight, unbiasedWeight, unbiasedWeight)); const float3 visibility = meshShadow > 0.0 ? min(meshShadow, marchedVisibility(shadowOrigin, chosen.samplePoint, chosen.slope)) : float3(0.0, 0.0, 0.0); - const LightTerms sampled = shadeSampledLight(surface, chosen, unbiasedWeight * visibility); - terms.diffuse += sampled.diffuse; - terms.specular += sampled.specular; + terms.diffuse += unshadowed.diffuse * visibility; + terms.specular += unshadowed.specular * visibility; // Occluded samples are not reused reservoir.lightId = chosen.lightId; @@ -705,8 +644,8 @@ struct SpatialOutput }; /// Merges neighbour reservoirs of this frame then traces the chosen light -[shader("fragment")] -SpatialOutput fragmentSpatial(VSOutput input) +/// A constant glassScene compiles the tinted trace out +SpatialOutput spatialReuse(VSOutput input, bool glassScene) { SpatialOutput output; output.noisyDiffuse = float4(0.0, 0.0, 0.0, 0.0); @@ -819,21 +758,35 @@ SpatialOutput fragmentSpatial(VSOutput input) const float unbiasedWeight = weightSum / (float(sampleCount) * chosen.target); const float3 shadowOrigin = position + faceNormal * (kShadowOriginOffset * kVoxelSize); const float meshShadow = meshShadowFactor(gLights[chosen.lightIndex], surface); - const float3 visibility = meshShadow > 0.0 ? min(meshShadow, marchedVisibility(shadowOrigin, chosen.samplePoint, chosen.slope)) - : float3(0.0, 0.0, 0.0); - const LightTerms sampled = shadeSampledLight(surface, chosen, unbiasedWeight * visibility); - output.noisyDiffuse = float4(sampled.diffuse, 0.0); - output.noisySpecular = float4(sampled.specular, 0.0); - + // Before the trace so the surface is not live across it + const LightTerms unshadowed = shadeSampledLight(surface, chosen, float3(unbiasedWeight, unbiasedWeight, unbiasedWeight)); Reservoir reservoir; reservoir.lightId = chosen.lightId; - reservoir.W = any(visibility > 0.0) ? unbiasedWeight : 0.0; reservoir.u = chosen.u; reservoir.M = sampleCount; + + const float3 visibility = meshShadow > 0.0 + ? min(meshShadow, marchedVisibility(shadowOrigin, chosen.samplePoint, chosen.slope, glassScene)) + : float3(0.0, 0.0, 0.0); + output.noisyDiffuse = float4(unshadowed.diffuse * visibility, 0.0); + output.noisySpecular = float4(unshadowed.specular * visibility, 0.0); + reservoir.W = any(visibility > 0.0) ? unbiasedWeight : 0.0; output.reservoir = packReservoir(reservoir); return output; } +[shader("fragment")] +SpatialOutput fragmentSpatial(VSOutput input) +{ + return spatialReuse(input, true); +} + +[shader("fragment")] +SpatialOutput fragmentSpatialOpaque(VSOutput input) +{ + return spatialReuse(input, false); +} + [shader("fragment")] FSOutput fragmentMain(VSOutput input) { @@ -856,8 +809,7 @@ FSOutput fragmentMain(VSOutput input) } const float3 position = positionAt(pixel); - const float3 faceNormal = decodeFaceNormal(gFaceNormal.Load(texel)); - const float3 normal = roundedNormal(input.position.xy, position, faceNormal); + const float3 normal = decodeFaceNormal(gFaceNormal.Load(texel)); const float4 material = gMaterial.Load(texel); const float roughness = clamp(material.b, 0.045, 1.0); @@ -875,7 +827,7 @@ FSOutput fragmentMain(VSOutput input) surface.marchedShadows = true; surface.worldPosition = position; surface.normal = normal; - surface.geometricNormal = faceNormal; + surface.geometricNormal = normal; surface.viewDirection = view; surface.albedo = albedo.rgb; surface.metallic = material.r; @@ -887,7 +839,7 @@ FSOutput fragmentMain(VSOutput input) // Glass scenes leave shadow mapped lights to fragmentMarched which tints them VoxelRayFreeFilter rayFree; - rayFree.mapLights = (gPush.reuse.x & kSceneGlass) == 0u || gCamera.renderModePad.x == 19; + rayFree.mapLights = (gPush.reuse.x & kSceneGlass) == 0u || isShadowDebugMode(gCamera.renderModePad.x); const ShadingResult lit = shadeSurfaceLighting(surface, rayFree); output.indirectDiffuse = float4(lit.indirectDiffuse, 1.0); if (lit.isDebugView) @@ -963,7 +915,7 @@ MarchedOutput fragmentMarched(VSOutput input) surface.pixel = input.position.xy; // Debug views come from fragmentMain alone - if (gCamera.renderModePad.x == 19 || isSurfaceDebugMode(gCamera.renderModePad.x)) + if (isShadowDebugMode(gCamera.renderModePad.x) || isSurfaceDebugMode(gCamera.renderModePad.x)) { return output; } @@ -999,7 +951,7 @@ 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)) + if (!isLitVoxel(gAlbedo.Load(texel).a) || isShadowDebugMode(gCamera.renderModePad.x) || isSurfaceDebugMode(gCamera.renderModePad.x)) { discard; } diff --git a/engine/assets/shaders/voxel_specular.slang b/engine/assets/shaders/voxel_specular.slang index 4361f3a3d..7d3ddac26 100644 --- a/engine/assets/shaders/voxel_specular.slang +++ b/engine/assets/shaders/voxel_specular.slang @@ -39,7 +39,7 @@ Sampler2D gMaterial; [[vk::binding(4, 2)]] Sampler2D gFaceNormal; -/// Rounded normal with w kTracedMark where voxel lighting left the specular to this pass +/// Normal with w kTracedMark where voxel lighting left the specular to this pass [[vk::binding(5, 2)]] Sampler2D gSceneNormal; @@ -65,6 +65,10 @@ Sampler2D gPreviousColour; [[vk::binding(11, 2)]] Sampler2D gResolved; +/// packSurface per half resolution texel +[[vk::binding(12, 2)]] +Sampler2D gSurface; + /// Mirrors voxel_lighting.slang static const float kTracedMark = 2.0; static const float kVoxelSize = 0.1; @@ -85,11 +89,8 @@ 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; +/// Covers the 4x4 stratum tile +static const int kResolveReach = 2; /// Lobe over pdf a single ray may reach so one unlikely neighbour cannot take the texel static const float kMaxRayWeight = 8.0; @@ -211,7 +212,8 @@ bool screenRadiance(float3 position, float3 normal, out float3 radiance) 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; + // By uv since TAA output is output sized + radiance = gPreviousColour.SampleLevel(previousUv, 0.0).rgb; } return true; } @@ -276,6 +278,115 @@ float3 rayDirection(TracedPixel surface, float2 u, out float pdf) return L; } +struct LobeFrame +{ + float4x4 worldToVoxel; + /// Voxel axes spanning the face plane + int2 axes; +}; + +/// Probes back along the view ray since the gbuffer has no volume id +LobeFrame lobeFrameOf(TracedPixel surface) +{ + LobeFrame frame; + const float scale = 1.0 / kVoxelSize; + frame.worldToVoxel = float4x4(scale, 0.0, 0.0, 0.0, 0.0, scale, 0.0, 0.0, 0.0, 0.0, scale, 0.0, 0.0, 0.0, 0.0, 1.0); + const float3 toCamera = gCamera.position - surface.position; + const float distance = length(toCamera); + const float3 V = toCamera / max(distance, 1e-4); + const float reach = 0.05 + 0.01 * distance; + const SceneHit hit = traceScene(surface.position + V * reach, -V, 0.0, 2.0 * reach, 0.0); + if (hit.hit) + { + frame.worldToVoxel = gTraceVolumes[hit.volume].worldToVoxel; + } + int axis = hit.hit ? hit.axis : -1; + if (axis < 0) + { + const float3 normal = abs(mul((float3x3)frame.worldToVoxel, surface.face)); + axis = normal.x >= normal.y && normal.x >= normal.z ? 0 : (normal.y >= normal.z ? 1 : 2); + } + frame.axes = axis == 0 ? int2(1, 2) : (axis == 1 ? int2(0, 2) : int2(0, 1)); + return frame; +} + +/// On the unjittered ray so TAA jitter never moves the pattern +float2 faceCoordinate(TracedPixel surface, LobeFrame frame, float2 ndc, float2 jitter, float depth) +{ + const float4 onRay = mul(gParams.inverseViewProjection, float4(ndc + jitter, depth, 1.0)); + const float3 direction = onRay.xyz / onRay.w - gCamera.position; + const float facing = dot(direction, surface.face); + const float t = abs(facing) > 1e-6 ? dot(surface.position - gCamera.position, surface.face) / facing : 1.0; + const float3 voxel = mul(frame.worldToVoxel, float4(gCamera.position + direction * t, 1.0)).xyz; + return float2(voxel[frame.axes.x], voxel[frame.axes.y]); +} + +uint surfaceStratum(TracedPixel surface, LobeFrame frame, int2 pixel) +{ + if (surface.roughness <= kMirrorRoughness) + { + return 0u; + } + uint width; + uint height; + gDepth.GetDimensions(width, height); + const float2 size = float2(float(width), float(height)); + const float4 jittered = mul(gCamera.jitteredViewProjection, float4(surface.position, 1.0)); + const float4 unjittered = mul(gCamera.viewProjection, float4(surface.position, 1.0)); + const float2 jitter = jittered.xy / jittered.w - unjittered.xy / unjittered.w; + const float depth = jittered.z / jittered.w; + const float2 ndc = (float2(pixel) + 0.5) / size * 2.0 - 1.0; + const float2 texel = 4.0 / size; + + const float2 centre = faceCoordinate(surface, frame, ndc, jitter, depth); + const float2 alongX = faceCoordinate(surface, frame, ndc + float2(texel.x, 0.0), jitter, depth) - centre; + const float2 alongY = faceCoordinate(surface, frame, ndc + float2(0.0, texel.y), jitter, depth) - centre; + // Power of two so zooming rescales the pattern only at octaves + const float2 cell = exp2(round(log2(max(max(abs(alongX), abs(alongY)), float2(1e-4, 1e-4))))); + return lobeStratumIndex(int2(floor(centre / cell))); +} + +/// xyz position bits w normal octahedron 11 and 11 bits then roughness 6 and stratum 4 +uint4 packSurface(TracedPixel surface, uint stratum) +{ + const uint2 normal = uint2(round(saturate(giOctEncode(surface.normal)) * 2047.0)); + const uint roughness = uint(round(saturate(surface.roughness) * 63.0)); + return uint4(asuint(surface.position), normal.x | (normal.y << 11) | (roughness << 22) | (stratum << 28)); +} + +/// Neighbours passing the geometry weight share the face of the reader +TracedPixel unpackSurface(uint4 packed, float3 face) +{ + TracedPixel surface; + surface.traced = true; + surface.position = asfloat(packed.xyz); + surface.normal = giOctDecode(float2(packed.w & 2047u, (packed.w >> 11) & 2047u) / 2047.0); + surface.face = face; + surface.albedo = float3(0.0, 0.0, 0.0); + surface.metallic = 0.0; + surface.reflectivity = 0.0; + surface.roughness = max(float((packed.w >> 22) & 63u) / 63.0, 0.045); + return surface; +} + +uint packedStratum(uint4 packed) +{ + return packed.w >> 28; +} + +[shader("fragment")] +uint4 surfaceMain(VSOutput input) : SV_Target0 +{ + const int2 pixel = int2(input.position.xy) * 2; + const TracedPixel surface = tracedPixelAt(pixel); + if (!surface.traced) + { + return uint4(0u, 0u, 0u, 0u); + } + const uint stratum = surface.roughness > kMirrorRoughness ? surfaceStratum(surface, lobeFrameOf(surface), pixel) : 0u; + return packSurface(surface, stratum); +} + /// A miss returns the environment term lighting would have added [shader("fragment")] float4 traceMain(VSOutput input) : SV_Target0 @@ -287,10 +398,11 @@ float4 traceMain(VSOutput input) : SV_Target0 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); + const int2 texel = int2(input.position.xy); + // Quantized like the resolve regenerates it + const uint4 packed = gSurface.Load(int3(texel, 0)); float pdf; - const float3 L = rayDirection(surface, noise.xy, pdf); + const float3 L = rayDirection(unpackSurface(packed, surface.face), lobeStratumSample(packedStratum(packed)), pdf); const float3 mirror = reflect(-normalize(gCamera.position - surface.position), surface.normal); if (dot(L, surface.face) <= 0.0) { @@ -305,7 +417,7 @@ float4 traceMain(VSOutput input) : SV_Target0 { return float4(ambientSpecularRadiance(surface.position, mirror, surface.roughness), kMissDistance); } - uint random = giHash(uint(pixel.x) | (uint(pixel.y) << 16)) ^ asuint(noise.z); + uint random = giHash(uint(texel.x) | (uint(texel.y) << 16)); return float4(hitRadiance(hit, origin, L, coneSlope, float2(pixel), random), hit.t); } @@ -351,51 +463,49 @@ float4 resolveMain(VSOutput input) : SV_Target0 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) + for (int y = -kResolveReach; y <= kResolveReach; ++y) { - 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) + for (int x = -kResolveReach; x <= kResolveReach; ++x) { - 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; - } + const int2 offset = int2(x, y); + const int2 tap = texel + offset; + if (any(tap < int2(0, 0)) || any(tap > last)) + { + continue; + } + const float4 hit = gTrace.Load(int3(tap, 0)); + if (hit.a < 0.0) + { + continue; + } + const uint4 packed = gSurface.Load(int3(tap, 0)); + const TracedPixel neighbour = unpackSurface(packed, surface.face); + // A neighbour on another surface traced a ray this texel could not have + const float geometry = lobeStratumWeight(offset) * 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; + float pdf; + const float3 sampled = rayDirection(neighbour, lobeStratumSample(packedStratum(packed)), 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; } - 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) { diff --git a/engine/src/toast/backtrace.cpp b/engine/src/toast/backtrace.cpp index 2feccce38..fd34c92b2 100644 --- a/engine/src/toast/backtrace.cpp +++ b/engine/src/toast/backtrace.cpp @@ -3,12 +3,14 @@ #include "log.hpp" #include +#include #include #include #include #include #include #include +#include #ifdef _WIN32 #include @@ -29,6 +31,45 @@ std::unordered_map g_cache; std::uint64_t g_cache_generation = 0; #ifdef _WIN32 std::unordered_set g_symbol_loaded_modules; + +// we need to unload pdbs since the backtrace of the logs loads them for logging +// this is only a problem on windows btw +void unloadDbghelpSymbols() noexcept { + using EnumCallback = BOOL(CALLBACK*)(PCSTR, DWORD64, PVOID); + using EnumModules = BOOL(WINAPI*)(HANDLE, EnumCallback, PVOID); + using UnloadModule = BOOL(WINAPI*)(HANDLE, DWORD64); + + const HMODULE dbghelp = GetModuleHandleA("dbghelp.dll"); + if (dbghelp == nullptr) { + return; + } + const auto enumModules = reinterpret_cast(GetProcAddress(dbghelp, "SymEnumerateModules64")); + const auto unloadModule = reinterpret_cast(GetProcAddress(dbghelp, "SymUnloadModule64")); + if (enumModules == nullptr || unloadModule == nullptr) { + return; + } + + const auto unloadSession = [&](HANDLE process) { + std::vector bases; + enumModules( + process, + [](PCSTR, DWORD64 base, PVOID user) -> BOOL { + static_cast*>(user)->push_back(base); + return TRUE; + }, + &bases + ); + for (const DWORD64 base : bases) { + unloadModule(process, base); + } + }; + + unloadSession(GetCurrentProcess()); + constexpr std::uintptr_t k_max_handle = 0x10000; + for (std::uintptr_t value = 4; value < k_max_handle; value += 4) { + unloadSession(reinterpret_cast(value)); + } +} #endif struct Module { @@ -202,6 +243,7 @@ void invalidateBacktraceCache() noexcept { ++g_cache_generation; #ifdef _WIN32 g_symbol_loaded_modules.clear(); + unloadDbghelpSymbols(); #endif } diff --git a/engine/src/toast/engine.cpp b/engine/src/toast/engine.cpp index 62abc81a5..cda8e8b99 100644 --- a/engine/src/toast/engine.cpp +++ b/engine/src/toast/engine.cpp @@ -23,11 +23,15 @@ #include "renderer/passes/bloom_pass.hpp" #include "renderer/passes/cluster_lighting_pass.hpp" #include "renderer/passes/debris_pass.hpp" +#if defined(TOAST_DEV_TOOLS) #include "renderer/passes/debug_pass.hpp" +#endif #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/lens_flare_pass.hpp" +#include "renderer/passes/motion_blur_pass.hpp" #include "renderer/passes/reflection_probe_pass.hpp" #include "renderer/passes/shadow_pass.hpp" #include "renderer/passes/ssao_pass.hpp" @@ -58,6 +62,7 @@ #include "ui/ui_system.hpp" #include "window/base_window.hpp" #include "window/sdl_window.hpp" +#include "window/startup_splash.hpp" #include "window/window_events.hpp" #include "world/camera.hpp" #include "world/play_workspace.hpp" @@ -616,7 +621,8 @@ auto Engine::shootVoxel( void Engine::createSDLWindow(const char* w_name) { ZoneScoped; - m->window = std::make_unique(w_name, 1080, 720, SDL_WINDOW_VULKAN); + // Hidden until the shaders compile + m->window = std::make_unique(w_name, 1080, 720, SDL_WINDOW_VULKAN | SDL_WINDOW_HIDDEN); auto* sdl_window = static_cast(m->window->nativeHandle()); if (m->ui_system) { @@ -627,17 +633,43 @@ void Engine::createSDLWindow(const char* w_name) { m->vulkan_core = std::make_unique(instance_extensions, device_extensions); // Compile every stale shader up front - renderer::ShaderCache::get().compileAllAtStartup(); + renderer::ShaderCompiler::setFullOptimization(true); + renderer::ShaderCompiler::setMaxParallelCompiles(std::clamp(std::thread::hardware_concurrency(), 2, 8)); + { + StartupSplash splash; + renderer::ShaderCache::get().compileAllAtStartup([&splash](const renderer::ShaderCache::StartupProgress& progress) { + splash.update(progress.finished, progress.total, progress.compiling); + }); + if (splash.closeRequested()) { + event::send(); + } + } + SDL_ShowWindow(sdl_window); + SDL_RaiseWindow(sdl_window); + + // Reads true only once the window is shown + const SDLWindow::DisplayHdr display_hdr = SDLWindow::queryDisplayHdr(sdl_window); + const auto hdr_setting = settings::Settings::get().value(renderer::k_hdr_output); + const bool prefer_hdr = display_hdr.enabled && (!hdr_setting.has_value() || std::get(*hdr_setting)); + TOAST_INFO( + "Render", + "Display HDR {} (SDR white {:.2f}, headroom {:.2f}), HDR output {}", + display_hdr.enabled ? "on" : "off", + display_hdr.sdr_white, + display_hdr.headroom, + prefer_hdr ? "requested" : "off" + ); // create output texture auto output_target = std::make_unique( - *m->vulkan_core, sdl_window, renderer::SDLOutputTarget::queryExtent(sdl_window) + *m->vulkan_core, sdl_window, renderer::SDLOutputTarget::queryExtent(sdl_window), prefer_hdr ); auto extent = output_target->getExtent(); auto color_format = output_target->getColorFormat(); auto depth_format = renderer::VulkanRenderer::selectDepthFormat(*m->vulkan_core); m->renderer = std::make_unique(*m->vulkan_core, std::move(output_target)); + m->renderer->setDisplayHdr(display_hdr.sdr_white, display_hdr.headroom); // Constructed before any material pass so materials can bind the culled light buffers straight away auto cluster_lighting_pass = std::make_unique(*m->vulkan_core); @@ -698,6 +730,8 @@ void Engine::createSDLWindow(const char* w_name) { 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, scene_format, extent)); 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)); @@ -714,11 +748,13 @@ void Engine::createSDLWindow(const char* w_name) { }); } - // Players get the overlay above the game UI and hidden until F3 +#if defined(TOAST_DEV_TOOLS) + // Overlay above the game UI and hidden until F3 auto debug_pass = std::make_unique(*m->vulkan_core, color_format, depth_format, extent, &cluster_lighting_pass_ref); debug_pass->setEditorPanelsEnabled(false); m->renderer->addRenderPass(std::move(debug_pass)); +#endif // FIXME: CAPPED AT 240 for now m->renderer->setFrameRateLimit(240.0); @@ -734,6 +770,8 @@ void Engine::createAvaloniaWindow() { m->vulkan_core = std::make_unique(std::span {}, std::span {}); + renderer::ShaderCache::get().compileAllAtStartup(); + auto output_target = std::make_unique(*m->vulkan_core, vk::Extent2D(1080, 720)); auto color_format = output_target->getColorFormat(); auto extent = output_target->getExtent(); @@ -802,18 +840,22 @@ void Engine::createAvaloniaWindow() { 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, scene_format, extent)); 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)); // Editor overlays, drawn after the tonemap in display space (see DebugPass::stage() / GridPass::stage()) m->renderer->addRenderPass(std::make_unique(*m->vulkan_core, color_format, depth_format, extent)); +#if defined(TOAST_DEV_TOOLS) m->renderer->addRenderPass( std::make_unique(*m->vulkan_core, color_format, depth_format, extent, &cluster_lighting_pass_ref) ); // DebugPass is enabled on editor m->renderer->setDebugDrawEnabled(true); +#endif m->renderer->addRenderPass( std::make_unique(*m->vulkan_core, color_format, depth_format, extent, ui::WorldUIPass::Layer::overlay) ); diff --git a/engine/src/toast/physics/damage_command.hpp b/engine/src/toast/physics/damage_command.hpp index eaf0934b8..47bca8e55 100644 --- a/engine/src/toast/physics/damage_command.hpp +++ b/engine/src/toast/physics/damage_command.hpp @@ -36,6 +36,7 @@ struct ExplosionDamage { glm::vec3 point {}; float force = 0.0f; float falloff = tunables().explosion_falloff; + float max_radius = tunables().explosion_max_radius; ///< Nothing past it breaks or gets pushed Hardness max_hardness = k_hardest; }; diff --git a/engine/src/toast/physics/physics_settings.cpp b/engine/src/toast/physics/physics_settings.cpp index a9936d723..c8a797614 100644 --- a/engine/src/toast/physics/physics_settings.cpp +++ b/engine/src/toast/physics/physics_settings.cpp @@ -364,7 +364,7 @@ void registerPhysicsSettings() { 8.0, {.label = "Explosion max radius", .category = "Fracture", - .description = "Meters an explosion can break or push things at", + .description = "Meters an explosion can break or push things at, unless it sets its own radius", .min = 0.0, .max = 64.0, .step = 0.5}, diff --git a/engine/src/toast/physics/simulator.cpp b/engine/src/toast/physics/simulator.cpp index 78edd58e7..54e5282cf 100644 --- a/engine/src/toast/physics/simulator.cpp +++ b/engine/src/toast/physics/simulator.cpp @@ -4702,11 +4702,12 @@ auto Simulator::explode(const ExplosionDamage& explosion) -> uint32_t { if (not mainThreadMutationAllowed()) { return 0; } - if (not std::isfinite(explosion.force) || explosion.force <= 0.0f || not std::isfinite(glm::length(explosion.point))) { + if (not std::isfinite(explosion.force) || explosion.force <= 0.0f || not std::isfinite(glm::length(explosion.point)) || + not std::isfinite(explosion.max_radius) || explosion.max_radius <= 0.0f) { return 0; } - const float max_radius = tunables().explosion_max_radius; + const float max_radius = explosion.max_radius; const AABB bounds {.min = explosion.point - glm::vec3(max_radius), .max = explosion.point + glm::vec3(max_radius)}; uint32_t moved = 0; @@ -4958,7 +4959,7 @@ auto Simulator::explodeShape(ShapeID shape_id, const ExplosionDamage& explosion) return 0; } reach = explosionReach( - explosion.force, explosion.falloff, weakestToughness(materials, explosion.max_hardness), tunables().explosion_max_radius + explosion.force, explosion.falloff, weakestToughness(materials, explosion.max_hardness), explosion.max_radius ); if (not(reach > 0.0f)) { return 0; diff --git a/engine/src/toast/profiler_phases.hpp b/engine/src/toast/profiler_phases.hpp new file mode 100644 index 000000000..1d7582964 --- /dev/null +++ b/engine/src/toast/profiler_phases.hpp @@ -0,0 +1,57 @@ +/** + * @file profiler_phases.hpp + * @author dario + * @date 29 Oct 2026 + */ + +#pragma once + +#include + +#ifdef TRACY_ENABLE +#include + +namespace toast { + +/// Closes the last phase zone and opens the next +class PhaseZones { +public: + PhaseZones() = default; + PhaseZones(const PhaseZones&) = delete; + auto operator=(const PhaseZones&) -> PhaseZones& = delete; + + void next(const tracy::SourceLocationData* location) { + m_zone.reset(); + m_zone.emplace(location); + } + + void end() { m_zone.reset(); } + +private: + std::optional m_zone; +}; + +} + +#define TOAST_PHASE(phases, label) \ + do { \ + static constexpr tracy::SourceLocationData toast_phase_location { \ + label, TracyFunction, TracyFile, static_cast(TracyLine), 0 \ + }; \ + (phases).next(&toast_phase_location); \ + } while (false) + +#else + +namespace toast { + +class PhaseZones { +public: + void end() { } +}; + +} + +#define TOAST_PHASE(phases, label) static_cast(phases) + +#endif diff --git a/engine/src/toast/renderer/gpu_timer.hpp b/engine/src/toast/renderer/gpu_timer.hpp index 1b57f8d63..afd54e73f 100644 --- a/engine/src/toast/renderer/gpu_timer.hpp +++ b/engine/src/toast/renderer/gpu_timer.hpp @@ -6,7 +6,9 @@ #include #include #include +#include #include +#include #include namespace renderer { @@ -129,7 +131,20 @@ class GpuScope { : m_timer(timer), m_slot(slot), m_cmd(cmd), - m_scope(timer != nullptr ? timer->beginScope(slot, cmd, name) : GpuTimer::k_no_scope) { } + m_scope(timer != nullptr ? timer->beginScope(slot, cmd, name) : GpuTimer::k_no_scope) { +#ifdef TRACY_ENABLE + m_zone.emplace( + static_cast(__LINE__), + __FILE__, + sizeof(__FILE__) - 1, + __FUNCTION__, + sizeof(__FUNCTION__) - 1, + name.data(), + name.size(), + 0u + ); +#endif + } ~GpuScope() { if (m_timer != nullptr) { @@ -147,6 +162,9 @@ class GpuScope { uint32_t m_slot; vk::CommandBuffer m_cmd; uint32_t m_scope; +#ifdef TRACY_ENABLE + std::optional m_zone; +#endif }; } diff --git a/engine/src/toast/renderer/output_target_base.hpp b/engine/src/toast/renderer/output_target_base.hpp index e9bf76f72..79b69a000 100644 --- a/engine/src/toast/renderer/output_target_base.hpp +++ b/engine/src/toast/renderer/output_target_base.hpp @@ -28,6 +28,12 @@ class IOutputTarget { [[nodiscard]] virtual auto getColorFormat() const -> vk::Format = 0; + /// scRGB float where 1.0 is 80 nits + [[nodiscard]] + virtual auto isHdr() const -> bool { + return false; + } + [[nodiscard]] virtual auto getImageCount() const -> uint32_t = 0; diff --git a/engine/src/toast/renderer/passes/bloom_pass.hpp b/engine/src/toast/renderer/passes/bloom_pass.hpp index dc43488f5..5dc39513a 100644 --- a/engine/src/toast/renderer/passes/bloom_pass.hpp +++ b/engine/src/toast/renderer/passes/bloom_pass.hpp @@ -22,6 +22,11 @@ class BloomPass : public IPostProcessPass { public: BloomPass(const VulkanCore& core, vk::Format hdr_format, vk::Extent2D extent); + [[nodiscard]] + auto atOutputResolution() const -> bool override { + return true; + } + [[nodiscard]] auto name() const -> std::string_view override { return "Bloom"; diff --git a/engine/src/toast/renderer/passes/debug_pass.cpp b/engine/src/toast/renderer/passes/debug_pass.cpp index 6df4c69d2..f13fd82d8 100644 --- a/engine/src/toast/renderer/passes/debug_pass.cpp +++ b/engine/src/toast/renderer/passes/debug_pass.cpp @@ -2,6 +2,8 @@ /// @author dario /// @date 10/06/2026 +#if defined(TOAST_DEV_TOOLS) + #include "debug_pass.hpp" #include "../clustered_lighting_constants.hpp" @@ -399,7 +401,7 @@ void DebugPass::update(uint32_t frame_index, float dt) { ImGuiIO& io = ImGui::GetIO(); const auto& input = frame->imgui_input; - io.DisplaySize = ImVec2(frame->viewport_extent.x, frame->viewport_extent.y); + io.DisplaySize = ImVec2(frame->output_extent.x, frame->output_extent.y); io.DeltaTime = std::max(dt, 0.0001f); if (input.mouse_pos.x >= 0.0f && input.mouse_pos.y >= 0.0f) { @@ -890,8 +892,8 @@ void DebugPass::update(uint32_t frame_index, float dt) { using namespace clustered_lighting; ImDrawList* draw_list = ImGui::GetForegroundDrawList(); - const float cell_w = frame->viewport_extent.x / static_cast(k_cluster_dim_x); - const float cell_h = frame->viewport_extent.y / static_cast(k_cluster_dim_y); + const float cell_w = frame->output_extent.x / static_cast(k_cluster_dim_x); + const float cell_h = frame->output_extent.y / static_cast(k_cluster_dim_y); for (uint32_t ty = 0; ty < k_cluster_dim_y; ++ty) { for (uint32_t tx = 0; tx < k_cluster_dim_x; ++tx) { @@ -1575,3 +1577,5 @@ void DebugPass::ensureLineCapacity(const renderer::VulkanCore& core, DynamicVert } } + +#endif diff --git a/engine/src/toast/renderer/passes/fxaa_pass.cpp b/engine/src/toast/renderer/passes/fxaa_pass.cpp index 9f4a2e215..55bcc1079 100644 --- a/engine/src/toast/renderer/passes/fxaa_pass.cpp +++ b/engine/src/toast/renderer/passes/fxaa_pass.cpp @@ -99,6 +99,11 @@ auto FxaaPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView return source_view; } + // FXAA reads gamma encoded luma + if (VulkanRenderer::instance->hdrOutput()) { + return source_view; + } + if (m_bound_views[frame_index] != source_view) { vk::DescriptorImageInfo image_info {}; image_info.imageLayout = vk::ImageLayout::eShaderReadOnlyOptimal; diff --git a/engine/src/toast/renderer/passes/fxaa_pass.hpp b/engine/src/toast/renderer/passes/fxaa_pass.hpp index ed5c309ba..bad9bbece 100644 --- a/engine/src/toast/renderer/passes/fxaa_pass.hpp +++ b/engine/src/toast/renderer/passes/fxaa_pass.hpp @@ -23,6 +23,11 @@ class FxaaPass : public IPostProcessPass { public: FxaaPass(const VulkanCore& core, vk::Format ldr_format, vk::Extent2D extent); + [[nodiscard]] + auto atOutputResolution() const -> bool override { + return true; + } + [[nodiscard]] auto name() const -> std::string_view override { return "FXAA"; diff --git a/engine/src/toast/renderer/passes/gi_pass.cpp b/engine/src/toast/renderer/passes/gi_pass.cpp index 80e457bf6..b1c5cdff0 100644 --- a/engine/src/toast/renderer/passes/gi_pass.cpp +++ b/engine/src/toast/renderer/passes/gi_pass.cpp @@ -1117,8 +1117,11 @@ auto GiPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView s // Lighting keeps reading the last published tables on skipped frames if (carry && m_published_valid && debug_view == 0 && ++m_skipped < quality.interval) { + TracyPlot("GI: updated", static_cast(0)); return source_view; } + TracyPlot("GI: updated", static_cast(1)); + TracyPlot("GI: update interval", static_cast(quality.interval)); m_skipped = 0; m_key_settings = keys; diff --git a/engine/src/toast/renderer/passes/lens_flare_pass.cpp b/engine/src/toast/renderer/passes/lens_flare_pass.cpp new file mode 100644 index 000000000..55a383af7 --- /dev/null +++ b/engine/src/toast/renderer/passes/lens_flare_pass.cpp @@ -0,0 +1,288 @@ +/** + * @file lens_flare_pass.cpp + * @author dario + * @date 29/10/2026 + */ + +#include "lens_flare_pass.hpp" + +#include "../shader_cache.hpp" +#include "../vulkan_core.hpp" +#include "../vulkan_debug.hpp" +#include "../vulkan_renderer.hpp" + +#include +#include +#include +#include +#include +#include + +namespace renderer { + +namespace { + +constexpr uint32_t k_min_size = 8; +constexpr uint32_t k_glare_vertices = 18; + +auto scaledExtent(vk::Extent2D extent, uint32_t divisor) -> vk::Extent2D { + return {std::max(extent.width / divisor, k_min_size), std::max(extent.height / divisor, k_min_size)}; +} + +} + +LensFlarePass::LensFlarePass(const VulkanCore& core, vk::Format hdr_format, vk::Extent2D extent) + : m_core(&core), + m_format(hdr_format) { + ZoneScoped; + static_assert(sizeof(Params) <= 128, "Vulkan guarantees 128 bytes of push constants"); + + const auto uid = assets::resolveURI("core://shaders/lens_flare.slang"); + const auto shader = uid.has_value() ? ShaderCache::get().acquire(*uid) : nullptr; + if (!shader) { + TOAST_ERROR("Render", "LensFlarePass shader core://shaders/lens_flare.slang unavailable; lens flares are disabled"); + setEnabled(false); + return; + } + + m_shader_layout.rebuild(core, shader->reflection, "LensFlarePass"); + m_sampler = vk::raii::Sampler(core.getDevice(), linearClampSamplerInfo()); + setDebugName(core, *m_sampler, "LensFlarePass Sampler"); + + createPipelines(core); + createTargets(core, extent); +} + +void LensFlarePass::createPipelines(const VulkanCore& core) { + ZoneScoped; + const auto uid = assets::resolveURI("core://shaders/lens_flare.slang"); + const auto shader = uid.has_value() ? ShaderCache::get().acquire(*uid) : nullptr; + if (!shader) { + return; + } + + VulkanPipeline::Config config; + config.pipeline_type = VulkanPipeline::PipelineType::graphics; + config.color_format = m_format; + config.extent = vk::Extent2D {1, 1}; + config.shader_spirv = shader->spirv; + config.pipeline_layout = *m_shader_layout.getPipelineLayout(); + config.vertex_bindings = {}; + config.vertex_attributes = {}; + config.cull_mode = vk::CullModeFlagBits::eNone; + config.depth_test = false; + config.depth_write = false; + + const auto build = [&](VulkanPipeline& pipeline, const char* label, const char* fragment, VulkanPipeline::BlendPreset blend) { + config.debug_name = std::format("LensFlarePass {}", label); + config.fragment_entry = fragment; + config.blend_preset = blend; + pipeline.rebuild(core, config); + }; + + using Blend = VulkanPipeline::BlendPreset; + build(m_threshold_pipeline, "Threshold", "fragmentThreshold", Blend::none); + build(m_blur_down_pipeline, "BlurDown", "fragmentBlurDown", Blend::none); + build(m_blur_up_pipeline, "BlurUp", "fragmentBlurUp", Blend::none); + build(m_chroma_pipeline, "Chroma", "fragmentChroma", Blend::none); + build(m_ghosts_pipeline, "Ghosts", "fragmentGhosts", Blend::none); + build(m_halo_pipeline, "Halo", "fragmentHalo", Blend::additive); + build(m_mix_pipeline, "Mix", "fragmentMix", Blend::none); + + config.vertex_entry = "glareVertex"; + build(m_glare_pipeline, "Glare", "fragmentGlare", Blend::additive); +} + +void LensFlarePass::createTargets(const VulkanCore& core, vk::Extent2D extent) { + ZoneScoped; + const auto& device = core.getDevice(); + + const auto make_target = [&](Target& target, vk::Extent2D size, std::string_view debug_name) { + target = {}; + target.extent = size; + + const auto image_ci = colorTargetImageInfo(size, m_format); + vma::AllocationCreateInfo allocation_ci {}; + allocation_ci.usage = vma::MemoryUsage::eAutoPreferDevice; + + target.image.emplace(core.getAllocator().createImage(image_ci, allocation_ci)); + setDebugName(core, **target.image, std::string(debug_name)); + target.state.reset(**target.image, colorSubresourceRange()); + + vk::ImageViewCreateInfo view_ci {}; + view_ci.image = **target.image; + view_ci.viewType = vk::ImageViewType::e2D; + view_ci.format = m_format; + view_ci.subresourceRange = vk::ImageSubresourceRange(vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1); + target.view.emplace(device, view_ci); + setDebugName(core, **target.view, std::format("{} View", debug_name)); + }; + + const vk::Extent2D quarter = scaledExtent(extent, 4); + make_target(m_quarter_a, quarter, "LensFlarePass QuarterA"); + make_target(m_quarter_b, quarter, "LensFlarePass QuarterB"); + make_target(m_flares, quarter, "LensFlarePass Flares"); + make_target(m_glare_target, quarter, "LensFlarePass Glare"); + make_target(m_eighth, scaledExtent(extent, 8), "LensFlarePass Eighth"); + make_target(m_composite, extent, "LensFlarePass Composite"); + + const auto& layouts = m_shader_layout.getDescriptorSetLayouts(); + if (layouts.empty()) { + return; + } + + const vk::DescriptorSetLayout set_layout = *layouts[0]; + const vk::DescriptorPool pool = VulkanRenderer::instance->getDescriptorPoolHandle(); + + m_frame_sets.clear(); + m_frame_sets.resize(VulkanRenderer::k_frames_in_flight); + for (size_t frame = 0; frame < m_frame_sets.size(); ++frame) { + for (uint32_t step = 0; step < step_count; ++step) { + const vk::DescriptorSetAllocateInfo alloc_info(pool, 1, &set_layout); + auto allocated = device.allocateDescriptorSets(alloc_info); + m_frame_sets[frame].sets.push_back(std::move(allocated[0])); + setDebugName(core, *m_frame_sets[frame].sets.back(), std::format("LensFlarePass Set[{}][{}]", frame, step)); + } + } + + TOAST_INFO("Render", "LensFlarePass ready: {}x{} flare targets", quarter.width, quarter.height); +} + +void LensFlarePass::writeDescriptor(vk::DescriptorSet set, vk::ImageView source, vk::ImageView scene, vk::ImageView glare) { + const std::array image_infos { + vk::DescriptorImageInfo(*m_sampler, source, vk::ImageLayout::eShaderReadOnlyOptimal), + vk::DescriptorImageInfo(*m_sampler, scene ? scene : source, vk::ImageLayout::eShaderReadOnlyOptimal), + vk::DescriptorImageInfo(*m_sampler, glare ? glare : source, vk::ImageLayout::eShaderReadOnlyOptimal), + }; + std::array writes {}; + for (uint32_t binding = 0; binding < writes.size(); ++binding) { + writes[binding] = + vk::WriteDescriptorSet(set, binding, 0, 1, vk::DescriptorType::eCombinedImageSampler, &image_infos[binding]); + } + m_core->getDevice().updateDescriptorSets(writes, {}); +} + +void LensFlarePass::draw( + vk::CommandBuffer cmd, const VulkanPipeline& pipeline, vk::DescriptorSet set, Target& target, const Params& params, + vk::AttachmentLoadOp load, uint32_t vertex_count, uint32_t instance_count +) { + sync::transition(cmd, target.state, sync::Usage::color_attachment); + + vk::RenderingAttachmentInfo attachment {}; + attachment.imageView = **target.view; + attachment.imageLayout = vk::ImageLayout::eColorAttachmentOptimal; + attachment.loadOp = load; + attachment.storeOp = vk::AttachmentStoreOp::eStore; + attachment.clearValue = vk::ClearValue(vk::ClearColorValue(std::array {0.0f, 0.0f, 0.0f, 1.0f})); + + vk::RenderingInfo rendering_info {}; + rendering_info.renderArea = vk::Rect2D({0, 0}, target.extent); + rendering_info.layerCount = 1; + rendering_info.colorAttachmentCount = 1; + rendering_info.pColorAttachments = &attachment; + + cmd.beginRendering(rendering_info); + cmd.setViewport( + 0, + std::array { + vk::Viewport(0.0f, 0.0f, static_cast(target.extent.width), static_cast(target.extent.height), 0.0f, 1.0f) + } + ); + cmd.setScissor(0, std::array {vk::Rect2D({0, 0}, target.extent)}); + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, pipeline.getPipeline()); + cmd.bindDescriptorSets( + vk::PipelineBindPoint::eGraphics, *m_shader_layout.getPipelineLayout(), 0, std::array {set}, {} + ); + cmd.pushConstants(*m_shader_layout.getPipelineLayout(), vk::ShaderStageFlagBits::eAll, 0, sizeof(Params), ¶ms); + cmd.draw(vertex_count, instance_count, 0, 0); + cmd.endRendering(); + + sync::transition(cmd, target.state, sync::Usage::shader_sampled); +} + +auto LensFlarePass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView source_view) -> vk::ImageView { + ZoneScoped; + if (!m_composite.view.has_value() || !m_mix_pipeline.isReady() || !m_glare_pipeline.isReady() || !source_view || + frame_index >= m_frame_sets.size() || m_frame_sets[frame_index].sets.size() != step_count) { + return source_view; + } + + const auto* frame = VulkanRenderer::instance->renderingFrame(); + if (frame == nullptr || frame->render_mode != 0) { + return source_view; + } + const auto settings = frame->post_process.lens_flare; + if (settings.intensity <= 0.0f) { + return source_view; + } + + // Safe since the fence of this slot was waited on + FrameSets& sets = m_frame_sets[frame_index]; + const vk::ImageView quarter_a = **m_quarter_a.view; + const vk::ImageView quarter_b = **m_quarter_b.view; + if (sets.bound_scene != source_view) { + const auto set = [&sets](Step step) { return *sets.sets[step]; }; + writeDescriptor(set(step_threshold), source_view, source_view, source_view); + writeDescriptor(set(step_glare), quarter_a, quarter_a, quarter_a); + writeDescriptor(set(step_blur_down), quarter_a, quarter_a, quarter_a); + writeDescriptor(set(step_blur_up), **m_eighth.view, **m_eighth.view, **m_eighth.view); + writeDescriptor(set(step_chroma), quarter_b, quarter_b, quarter_b); + writeDescriptor(set(step_ghosts), quarter_a, quarter_a, quarter_a); + writeDescriptor(set(step_halo), quarter_b, quarter_b, quarter_b); + writeDescriptor(set(step_mix), **m_flares.view, source_view, **m_glare_target.view); + sets.bound_scene = source_view; + } + + const auto inverse = [](vk::Extent2D size) { + return glm::vec2(1.0f / static_cast(size.width), 1.0f / static_cast(size.height)); + }; + const vk::Extent2D output = m_composite.extent; + + Params params {}; + params.flare = glm::vec4(settings.threshold, settings.threshold_range, settings.ghost_intensity, settings.chroma_shift); + params.halo = glm::vec4(settings.halo_intensity, settings.halo_width, settings.halo_mask, settings.halo_compression); + params.glare = glm::vec4( + settings.glare_intensity, settings.glare_divider, static_cast(output.width) / static_cast(output.height), 0.0f + ); + params.tint = glm::vec4(1.0f, 1.0f, 1.0f, settings.intensity); + + const auto texel = [&](vk::Extent2D source, vk::Extent2D target, float source_scale) { + return glm::vec4(inverse(source) * source_scale, inverse(target)); + }; + const uint32_t glare_instances = ((m_quarter_a.extent.width + 1) / 2) * ((m_quarter_a.extent.height + 1) / 2); + + using Load = vk::AttachmentLoadOp; + params.texel = texel(output, m_quarter_a.extent, 2.0f); + draw(cmd, m_threshold_pipeline, *sets.sets[step_threshold], m_quarter_a, params, Load::eDontCare, 3, 1); + + params.texel = texel(m_quarter_a.extent, m_glare_target.extent, 1.0f); + draw(cmd, m_glare_pipeline, *sets.sets[step_glare], m_glare_target, params, Load::eClear, k_glare_vertices, glare_instances); + + params.texel = texel(m_quarter_a.extent, m_eighth.extent, 1.0f); + draw(cmd, m_blur_down_pipeline, *sets.sets[step_blur_down], m_eighth, params, Load::eDontCare, 3, 1); + + params.texel = texel(m_eighth.extent, m_quarter_b.extent, 1.0f); + draw(cmd, m_blur_up_pipeline, *sets.sets[step_blur_up], m_quarter_b, params, Load::eDontCare, 3, 1); + + draw(cmd, m_chroma_pipeline, *sets.sets[step_chroma], m_quarter_a, params, Load::eDontCare, 3, 1); + draw(cmd, m_ghosts_pipeline, *sets.sets[step_ghosts], m_flares, params, Load::eDontCare, 3, 1); + draw(cmd, m_halo_pipeline, *sets.sets[step_halo], m_flares, params, Load::eLoad, 3, 1); + + params.texel = texel(m_flares.extent, output, 1.0f); + params.texel.z = 1.0f / static_cast(m_glare_target.extent.width); + params.texel.w = 1.0f / static_cast(m_glare_target.extent.height); + draw(cmd, m_mix_pipeline, *sets.sets[step_mix], m_composite, params, Load::eDontCare, 3, 1); + + return **m_composite.view; +} + +void LensFlarePass::onResize(vk::Extent2D extent) { + if (m_core != nullptr && m_mix_pipeline.isReady()) { + createTargets(*m_core, extent); + for (FrameSets& sets : m_frame_sets) { + sets.bound_scene = nullptr; + } + } +} + +} diff --git a/engine/src/toast/renderer/passes/lens_flare_pass.hpp b/engine/src/toast/renderer/passes/lens_flare_pass.hpp new file mode 100644 index 000000000..26c9bffb4 --- /dev/null +++ b/engine/src/toast/renderer/passes/lens_flare_pass.hpp @@ -0,0 +1,110 @@ +/** + * @file lens_flare_pass.hpp + * @author dario + * @date 29/10/2026 + */ + +#pragma once + +#include "../post_process_pass_base.hpp" +#include "../shader_layout.hpp" +#include "../vulkan_common.hpp" +#include "../vulkan_pipeline.hpp" + +#include +#include +#include +#include + +namespace renderer { +class VulkanCore; + +class LensFlarePass : public IPostProcessPass { +public: + LensFlarePass(const VulkanCore& core, vk::Format hdr_format, vk::Extent2D extent); + + [[nodiscard]] + auto atOutputResolution() const -> bool override { + return true; + } + + [[nodiscard]] + auto name() const -> std::string_view override { + return "Lens flare"; + } + + auto record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView source_view) -> vk::ImageView override; + + void onResize(vk::Extent2D extent) override; + +private: + /// Mirrors lens_flare.slang LensFlareParams + struct Params { + glm::vec4 texel {0.0f}; + glm::vec4 flare {0.0f}; + glm::vec4 halo {0.0f}; + glm::vec4 glare {0.0f}; + glm::vec4 tint {1.0f}; + }; + + struct Target { + std::optional image; + std::optional view; + vk::Extent2D extent; + sync::ImageState state; + }; + + /// Targets in the order they are drawn into + enum Step : uint8_t { + step_threshold, + step_glare, + step_blur_down, + step_blur_up, + step_chroma, + step_ghosts, + step_halo, + step_mix, + step_count, + }; + + struct FrameSets { + std::vector sets; + vk::ImageView bound_scene = nullptr; + }; + + void createTargets(const VulkanCore& core, vk::Extent2D extent); + void createPipelines(const VulkanCore& core); + + void writeDescriptor(vk::DescriptorSet set, vk::ImageView source, vk::ImageView scene, vk::ImageView glare); + + void draw( + vk::CommandBuffer cmd, const VulkanPipeline& pipeline, vk::DescriptorSet set, Target& target, const Params& params, + vk::AttachmentLoadOp load, uint32_t vertex_count, uint32_t instance_count + ); + + const VulkanCore* m_core = nullptr; + vk::Format m_format = vk::Format::eUndefined; + + ShaderLayout m_shader_layout; + VulkanPipeline m_threshold_pipeline; + VulkanPipeline m_glare_pipeline; + VulkanPipeline m_blur_down_pipeline; + VulkanPipeline m_blur_up_pipeline; + VulkanPipeline m_chroma_pipeline; + VulkanPipeline m_ghosts_pipeline; + VulkanPipeline m_halo_pipeline; + VulkanPipeline m_mix_pipeline; + + vk::raii::Sampler m_sampler = nullptr; + + Target m_quarter_a; + Target m_quarter_b; + Target m_flares; + Target m_glare_target; + Target m_eighth; + Target m_composite; + + std::vector m_frame_sets; +}; + +} diff --git a/engine/src/toast/renderer/passes/motion_blur_pass.cpp b/engine/src/toast/renderer/passes/motion_blur_pass.cpp new file mode 100644 index 000000000..d7cebc7b6 --- /dev/null +++ b/engine/src/toast/renderer/passes/motion_blur_pass.cpp @@ -0,0 +1,171 @@ +/** + * @file motion_blur_pass.cpp + * @author dario + * @date 29/10/2026 + */ + +#include "motion_blur_pass.hpp" + +#include "../shader_cache.hpp" +#include "../vulkan_core.hpp" +#include "../vulkan_debug.hpp" +#include "../vulkan_renderer.hpp" + +#include +#include +#include +#include +#include +#include + +namespace renderer { + +MotionBlurPass::MotionBlurPass(const VulkanCore& core, vk::Format scene_format, vk::Extent2D extent) + : m_core(&core), + m_scene_format(scene_format) { + ZoneScoped; + static_assert(sizeof(Params) <= 128, "Vulkan guarantees 128 bytes of push constants"); + + const auto uid = assets::resolveURI("core://shaders/motion_blur.slang"); + const auto shader = uid.has_value() ? ShaderCache::get().acquire(*uid) : nullptr; + if (!shader) { + TOAST_ERROR("Render", "MotionBlurPass shader core://shaders/motion_blur.slang unavailable; motion blur is disabled"); + setEnabled(false); + return; + } + + m_shader_layout.rebuild(core, shader->reflection, "MotionBlurPass"); + + VulkanPipeline::Config config; + config.pipeline_type = VulkanPipeline::PipelineType::graphics; + config.debug_name = "MotionBlurPass"; + config.color_format = scene_format; + config.extent = extent; + config.shader_spirv = shader->spirv; + config.pipeline_layout = *m_shader_layout.getPipelineLayout(); + config.vertex_bindings = {}; + config.vertex_attributes = {}; + config.cull_mode = vk::CullModeFlagBits::eNone; + config.depth_test = false; + config.depth_write = false; + m_pipeline.rebuild(core, config); + + createResources(core); + m_target.create(core, extent, scene_format, "MotionBlurPass"); + TOAST_INFO("Render", "MotionBlurPass ready ({})", vk::to_string(scene_format)); +} + +void MotionBlurPass::createResources(const VulkanCore& core) { + ZoneScoped; + const auto& device = core.getDevice(); + const auto& layouts = m_shader_layout.getDescriptorSetLayouts(); + if (layouts.empty()) { + TOAST_ERROR("Render", "MotionBlurPass shader layout has no descriptor sets"); + return; + } + + m_linear_sampler = vk::raii::Sampler(device, linearClampSamplerInfo()); + setDebugName(core, *m_linear_sampler, "MotionBlurPass Sampler"); + + m_point_sampler = vk::raii::Sampler(device, nearestClampSamplerInfo()); + setDebugName(core, *m_point_sampler, "MotionBlurPass PointSampler"); + + const vk::DescriptorSetLayout set_layout = *layouts[0]; + const vk::DescriptorPool pool = VulkanRenderer::instance->getDescriptorPoolHandle(); + + m_descriptor_sets.clear(); + m_bound_views.assign(VulkanRenderer::k_frames_in_flight, {}); + for (uint32_t i = 0; i < VulkanRenderer::k_frames_in_flight; ++i) { + const vk::DescriptorSetAllocateInfo alloc_info(pool, 1, &set_layout); + auto allocated = device.allocateDescriptorSets(alloc_info); + m_descriptor_sets.push_back(std::move(allocated[0])); + setDebugName(core, *m_descriptor_sets[i], std::format("MotionBlurPass DescriptorSet[{}]", i)); + } +} + +void MotionBlurPass::onResize(vk::Extent2D extent) { + if (m_core != nullptr) { + m_target.create(*m_core, extent, m_scene_format, "MotionBlurPass"); + std::ranges::fill(m_bound_views, vk::ImageView {}); + } +} + +auto MotionBlurPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView source_view) -> vk::ImageView { + ZoneScoped; + const auto* frame = VulkanRenderer::instance->renderingFrame(); + if (frame == nullptr || frame->render_mode != 0 || frame->taa_reset) { + m_have_sequence = false; + return source_view; + } + + const vk::ImageView depth_view = VulkanRenderer::instance->getDepthView(); + const vk::ImageView motion_view = VulkanRenderer::instance->getSceneMotionView(); + if (!m_pipeline.isReady() || frame_index >= m_descriptor_sets.size() || !source_view || !depth_view || !motion_view || + !m_target.isReady()) { + return source_view; + } + + // A repeated snapshot keeps the pose it was first drawn with + bool frames_skipped = false; + if (!m_have_sequence) { + m_previous_view_projection = frame->frame_data.view_projection; + m_view_projection = frame->frame_data.view_projection; + m_sequence = frame->sequence; + m_have_sequence = true; + } else if (frame->sequence != m_sequence) { + frames_skipped = frame->sequence != m_sequence + 1; + m_previous_view_projection = m_view_projection; + m_view_projection = frame->frame_data.view_projection; + m_sequence = frame->sequence; + } + + if (m_bound_views[frame_index] != source_view) { + const std::array image_infos { + vk::DescriptorImageInfo(*m_linear_sampler, source_view, vk::ImageLayout::eShaderReadOnlyOptimal), + // Depth stays eDepthReadOnlyOptimal for the overlay stage + vk::DescriptorImageInfo(*m_point_sampler, depth_view, vk::ImageLayout::eDepthReadOnlyOptimal), + vk::DescriptorImageInfo(*m_point_sampler, motion_view, vk::ImageLayout::eShaderReadOnlyOptimal) + }; + + std::array writes {}; + for (uint32_t binding = 0; binding < writes.size(); ++binding) { + writes[binding] = vk::WriteDescriptorSet( + *m_descriptor_sets[frame_index], binding, 0, 1, vk::DescriptorType::eCombinedImageSampler, &image_infos[binding] + ); + } + m_core->getDevice().updateDescriptorSets(writes, {}); + m_bound_views[frame_index] = source_view; + } + + const auto width = static_cast(m_target.extent().width); + const auto height = static_cast(m_target.extent().height); + const auto* vulkan = VulkanRenderer::instance; + + Params params {}; + params.reprojection = m_previous_view_projection * glm::inverse(m_view_projection); + params.texel = glm::vec4(1.0f / width, 1.0f / height, width, height); + params.tuning = glm::vec4( + vulkan->motionBlurStrength(), + vulkan->motionBlurMaxLength(), + static_cast(vulkan->motionBlurSamples()), + frames_skipped ? 1.0f : 0.0f + ); + params.camera = glm::vec4(frame->camera_near, frame->camera_far, 0.0f, 0.0f); + + m_target.beginScope(cmd); + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, m_pipeline.getPipeline()); + cmd.bindDescriptorSets( + vk::PipelineBindPoint::eGraphics, + *m_shader_layout.getPipelineLayout(), + 0, + std::array {*m_descriptor_sets[frame_index]}, + {} + ); + cmd.pushConstants(*m_shader_layout.getPipelineLayout(), vk::ShaderStageFlagBits::eAll, 0, sizeof(Params), ¶ms); + cmd.draw(3, 1, 0, 0); + m_target.endScope(cmd); + + return m_target.view(); +} + +} diff --git a/engine/src/toast/renderer/passes/motion_blur_pass.hpp b/engine/src/toast/renderer/passes/motion_blur_pass.hpp new file mode 100644 index 000000000..9dbbcf8e9 --- /dev/null +++ b/engine/src/toast/renderer/passes/motion_blur_pass.hpp @@ -0,0 +1,71 @@ +/** + * @file motion_blur_pass.hpp + * @author dario + * @date 29/10/2026 + */ + +#pragma once + +#include "../post_process_pass_base.hpp" +#include "../post_process_target.hpp" +#include "../shader_layout.hpp" +#include "../vulkan_common.hpp" +#include "../vulkan_pipeline.hpp" + +#include +#include +#include + +namespace renderer { +class VulkanCore; + +class MotionBlurPass : public IPostProcessPass { +public: + MotionBlurPass(const VulkanCore& core, vk::Format scene_format, vk::Extent2D extent); + + [[nodiscard]] + auto atOutputResolution() const -> bool override { + return true; + } + + [[nodiscard]] + auto name() const -> std::string_view override { + return "Motion blur"; + } + + auto record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView source_view) -> vk::ImageView override; + + void onResize(vk::Extent2D extent) override; + +private: + /// Mirrors motion_blur.slang MotionBlurParams + struct Params { + glm::mat4 reprojection {1.0f}; + glm::vec4 texel {0.0f}; + glm::vec4 tuning {0.0f}; + glm::vec4 camera {0.0f}; + }; + + void createResources(const VulkanCore& core); + + const VulkanCore* m_core = nullptr; + vk::Format m_scene_format = vk::Format::eUndefined; + + ShaderLayout m_shader_layout; + VulkanPipeline m_pipeline; + + vk::raii::Sampler m_linear_sampler = nullptr; + vk::raii::Sampler m_point_sampler = nullptr; + + std::vector m_descriptor_sets; + std::vector m_bound_views; + + PostProcessTarget m_target; + + uint64_t m_sequence = 0; + bool m_have_sequence = false; + glm::mat4 m_view_projection {1.0f}; + glm::mat4 m_previous_view_projection {1.0f}; +}; + +} diff --git a/engine/src/toast/renderer/passes/perf_window.cpp b/engine/src/toast/renderer/passes/perf_window.cpp index 70fcf4b4e..84c813a33 100644 --- a/engine/src/toast/renderer/passes/perf_window.cpp +++ b/engine/src/toast/renderer/passes/perf_window.cpp @@ -2,9 +2,13 @@ /// @author dario /// @date 09/10/2026 +#if defined(TOAST_DEV_TOOLS) + #include "perf_window.hpp" #include "../async_compute.hpp" +#include "../renderer_settings.hpp" +#include "../shader_compiler.hpp" #include "../shadow_constants.hpp" #include "../vulkan_core.hpp" #include "gi_pass.hpp" @@ -14,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -45,7 +50,105 @@ enum class PerfLevel : uint8_t { bad }; -/// Good up to @p warn_from and bad past @p bad_from +/// Queued ahead of an edit so Custom starts from the tier +void requestCustomPreset() { + auto& registry = toast::settings::Settings::get(); + if (const toast::settings::Entry* preset = registry.find("renderer.quality.preset"); preset != nullptr) { + VulkanRenderer::instance->requestSettingValue(preset->key, static_cast(preset->meta.options.size()) - 1); + } +} + +/// An edit of a preset_driven row switches to Custom +void drawSettingRow(toast::settings::Entry* entry, bool preset_driven = false) { + using toast::settings::Type; + using toast::settings::Value; + + auto& registry = toast::settings::Settings::get(); + ImGui::PushID(entry->key.c_str()); + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + + std::string_view label = entry->meta.label; + if (label.empty()) { + label = std::string_view(entry->key).substr(entry->key.rfind('.') + 1); + } + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(label.data(), label.data() + label.size()); + if (ImGui::IsItemHovered() && !entry->meta.description.empty()) { + ImGui::SetTooltip("%s%s", entry->meta.description.c_str(), entry->meta.requires_restart ? "\nApplies on restart" : ""); + } + if (entry->meta.requires_restart) { + ImGui::SameLine(); + ImGui::TextColored(k_perf_warn, "(restart)"); + } + + ImGui::TableNextColumn(); + ImGui::SetNextItemWidth(-FLT_MIN); + Value value = registry.resolved(entry); + bool leaves_tier = false; + if (preset_driven) { + if (auto forced = presetDrivenValue(entry->key); forced.has_value()) { + value = std::move(*forced); + leaves_tier = true; + } + } + const bool bounded = entry->meta.min < entry->meta.max; + std::optional changed; + switch (entry->type) { + case Type::boolean: { + bool current = std::get(value); + if (ImGui::Checkbox("##value", ¤t)) { + changed = current; + } + break; + } + case Type::integer: { + int64_t current = std::get(value); + const auto& options = entry->meta.options; + if (!options.empty()) { + const auto index = static_cast(std::clamp(current, 0, static_cast(options.size()) - 1)); + if (ImGui::BeginCombo("##value", options[index].c_str())) { + for (size_t i = 0; i < options.size(); ++i) { + if (ImGui::Selectable(options[i].c_str(), i == index)) { + changed = static_cast(i); + } + } + ImGui::EndCombo(); + } + break; + } + const auto low = static_cast(entry->meta.min); + const auto high = static_cast(entry->meta.max); + const bool edited = bounded ? ImGui::SliderScalar("##value", ImGuiDataType_S64, ¤t, &low, &high) + : ImGui::DragScalar("##value", ImGuiDataType_S64, ¤t); + if (edited) { + changed = current; + } + break; + } + case Type::floating: { + double current = std::get(value); + const double low = entry->meta.min; + const double high = entry->meta.max; + const auto speed = static_cast(entry->meta.step > 0.0 ? entry->meta.step : 0.01); + const bool edited = bounded ? ImGui::SliderScalar("##value", ImGuiDataType_Double, ¤t, &low, &high, "%.3g") + : ImGui::DragScalar("##value", ImGuiDataType_Double, ¤t, speed, nullptr, nullptr, "%.3g"); + if (edited) { + changed = current; + } + break; + } + case Type::string: ImGui::TextDisabled("%s", std::get(value).c_str()); break; + } + if (changed.has_value()) { + if (leaves_tier) { + requestCustomPreset(); + } + VulkanRenderer::instance->requestSettingValue(entry->key, *changed); + } + ImGui::PopID(); +} + auto perfLevel(float value, float warn_from, float bad_from) -> PerfLevel { if (value > bad_from) { return PerfLevel::bad; @@ -950,8 +1053,6 @@ void PerfWindow::drawSceneTab() { void PerfWindow::drawShadowsTab() { using toast::settings::Entry; - using toast::settings::Type; - using toast::settings::Value; if (const auto* frame = VulkanRenderer::instance->renderingFrame(); frame != nullptr) { const auto& lights = frame->light_stats; @@ -979,80 +1080,141 @@ void PerfWindow::drawShadowsTab() { if (entry->meta.hidden || !entry->key.starts_with("renderer.shadows.")) { continue; } - ImGui::PushID(entry->key.c_str()); - ImGui::TableNextRow(); - ImGui::TableNextColumn(); + drawSettingRow(entry); + } + ImGui::EndTable(); + } + ImGui::TextDisabled("Saved like the project settings window. Hover a name for what it costs"); +} - std::string_view label = entry->meta.label; - if (label.empty()) { - label = std::string_view(entry->key).substr(entry->key.rfind('.') + 1); - } - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(label.data(), label.data() + label.size()); - if (ImGui::IsItemHovered() && !entry->meta.description.empty()) { - ImGui::SetTooltip("%s%s", entry->meta.description.c_str(), entry->meta.requires_restart ? "\nApplies on restart" : ""); +void PerfWindow::drawQualityTab() { + auto& registry = toast::settings::Settings::get(); + const auto rows = [®istry](const char* id, std::initializer_list keys, bool preset_driven = false) { + if (!ImGui::BeginTable(id, 2, ImGuiTableFlags_SizingStretchProp)) { + return; + } + for (const std::string_view key : keys) { + if (toast::settings::Entry* entry = registry.find(key); entry != nullptr) { + drawSettingRow(entry, preset_driven); } + } + ImGui::EndTable(); + }; - ImGui::TableNextColumn(); - ImGui::SetNextItemWidth(-FLT_MIN); - const Value value = registry.resolved(entry); - const bool bounded = entry->meta.min < entry->meta.max; - std::optional changed; - switch (entry->type) { - case Type::boolean: { - bool current = std::get(value); - if (ImGui::Checkbox("##value", ¤t)) { - changed = current; - } - break; - } - case Type::integer: { - int64_t current = std::get(value); - const auto& options = entry->meta.options; - if (!options.empty()) { - const auto index = static_cast(std::clamp(current, 0, static_cast(options.size()) - 1)); - if (ImGui::BeginCombo("##value", options[index].c_str())) { - for (size_t i = 0; i < options.size(); ++i) { - if (ImGui::Selectable(options[i].c_str(), i == index)) { - changed = static_cast(i); - } - } - ImGui::EndCombo(); - } - break; - } - const auto low = static_cast(entry->meta.min); - const auto high = static_cast(entry->meta.max); - const bool edited = bounded ? ImGui::SliderScalar("##value", ImGuiDataType_S64, ¤t, &low, &high) - : ImGui::DragScalar("##value", ImGuiDataType_S64, ¤t); - if (edited) { - changed = current; - } - break; - } - case Type::floating: { - double current = std::get(value); - const double low = entry->meta.min; - const double high = entry->meta.max; - const auto speed = static_cast(entry->meta.step > 0.0 ? entry->meta.step : 0.01); - const bool edited = bounded - ? ImGui::SliderScalar("##value", ImGuiDataType_Double, ¤t, &low, &high, "%.3g") - : ImGui::DragScalar("##value", ImGuiDataType_Double, ¤t, speed, nullptr, nullptr, "%.3g"); - if (edited) { - changed = current; - } - break; - } - case Type::string: ImGui::TextDisabled("%s", std::get(value).c_str()); break; - } - if (changed.has_value()) { - registry.setValue(entry->key, *changed); + rows("##perf_quality_preset", {"renderer.quality.preset", "renderer.quality.ray_tracing"}); + + const auto& core = VulkanRenderer::instance->getCore(); + const bool tracing = core.isRayTracingSupported(); + ImGui::TextColored(tracing ? k_perf_good : k_perf_warn, "Ray tracing %s", tracing ? "in use" : "not in use"); + ImGui::SameLine(); + ImGui::TextDisabled("%s", std::string(core.rayTracingReason()).c_str()); + + const bool hdr_active = VulkanRenderer::instance->hdrOutput(); + ImGui::TextColored(hdr_active ? k_perf_good : k_perf_warn, "HDR output %s", hdr_active ? "on" : "off"); + rows("##perf_display_hdr", {"renderer.display.hdr", "renderer.display.hdr_paper_white", "renderer.display.hdr_peak"}); + if (const toast::settings::Entry* hdr_setting = registry.find("renderer.display.hdr"); hdr_setting != nullptr) { + const bool wanted = std::get(registry.resolved(hdr_setting)); + if (wanted && !hdr_active) { + ImGui::PushStyleColor(ImGuiCol_Text, k_perf_warn); + ImGui::TextWrapped("SDR is in use. Turn HDR on for this display"); + ImGui::PopStyleColor(); + } else if (!wanted && hdr_active) { + ImGui::PushStyleColor(ImGuiCol_Text, k_perf_warn); + ImGui::TextWrapped("Restart needed to go back to SDR"); + ImGui::PopStyleColor(); + } + } + + const toast::settings::Entry* preset = registry.find("renderer.quality.preset"); + const size_t tier_count = preset != nullptr ? preset->meta.options.size() - 1 : 0; + const int64_t preset_value = preset != nullptr ? std::get(registry.resolved(preset)) : 0; + const bool custom = preset == nullptr || std::cmp_greater_equal(preset_value, tier_count); + const bool rt_tier = !custom && std::cmp_greater_equal(preset_value, tier_count / 2); + + // Applies at device creation + const auto warn = [](const char* text) { + ImGui::PushStyleColor(ImGuiCol_Text, k_perf_warn); + ImGui::TextWrapped("%s", text); + ImGui::PopStyleColor(); + }; + if (ShaderCompiler::rayTracingMode() != core.rayTracingStartupMode()) { + warn("Restart needed"); + } + if (rt_tier && !tracing) { + warn("RT preset runs its non RT tier because ray tracing is not enabled"); + } else if (!custom && !rt_tier && tracing) { + warn("Non RT preset with ray tracing still on"); + } + + // On release since each value recreates every target + if (toast::settings::Entry* scale = registry.find("renderer.quality.render_scale"); + scale != nullptr && ImGui::BeginTable("##perf_quality_scale", 2, ImGuiTableFlags_SizingStretchProp)) { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(scale->meta.label.c_str()); + ImGui::TableNextColumn(); + ImGui::SetNextItemWidth(-FLT_MIN); + const auto forced_scale = presetDrivenValue(scale->key); + if (!m_render_scale_editing) { + m_render_scale_edit = static_cast(std::get(forced_scale.value_or(registry.resolved(scale)))); + } + ImGui::SliderFloat( + "##value", &m_render_scale_edit, static_cast(scale->meta.min), static_cast(scale->meta.max), "%.2f" + ); + m_render_scale_editing = ImGui::IsItemActive(); + if (ImGui::IsItemDeactivatedAfterEdit()) { + if (forced_scale.has_value()) { + requestCustomPreset(); } - ImGui::PopID(); + VulkanRenderer::instance->requestSettingValue(scale->key, static_cast(m_render_scale_edit)); } ImGui::EndTable(); + + const auto* renderer = VulkanRenderer::instance; + const vk::Extent2D render = renderer->renderExtent(); + const vk::Extent2D output = renderer->getOutputTarget().getExtent(); + ImGui::TextDisabled("%ux%u of %ux%u", render.width, render.height, output.width, output.height); } - ImGui::TextDisabled("Saved like the project settings window. Hover a name for what it costs"); + + ImGui::SeparatorText(custom ? "Preset Custom" : "Set by the preset, editing one switches to Custom"); + rows( + "##perf_quality_driven", + {"renderer.quality.ssr", + "renderer.quality.motion_blur", + "renderer.quality.ssr_reach", + "renderer.quality.ssr_steps", + "renderer.voxels.shadow_maps_only", + "renderer.quality.ssao", + "renderer.voxels.traced_specular", + "renderer.voxels.specular_roughness", + "renderer.voxels.specular_reach", + "renderer.voxels.light_candidates", + "renderer.voxels.ambient_rays", + "renderer.voxels.glass_shadows", + "renderer.volumetrics.quality", + "renderer.gi.enabled", + "renderer.gi.quality", + "renderer.shadows.distance", + "renderer.shadows.spot_budget", + "renderer.shadows.point_budget"}, + true + ); + + ImGui::SeparatorText("Always"); + rows( + "##perf_quality_always", + {"renderer.quality.bloom", + "renderer.quality.taa", + "renderer.quality.taa_history", + "renderer.quality.fxaa", + "renderer.quality.lens_flare", + "renderer.quality.motion_blur_strength", + "renderer.quality.motion_blur_max_length", + "renderer.quality.motion_blur_samples", + "renderer.quality.traced_shadows", + "renderer.volumetrics.enabled"} + ); } void PerfWindow::draw() { @@ -1145,6 +1307,10 @@ void PerfWindow::draw() { drawShadowsTab(); ImGui::EndTabItem(); } + if (ImGui::BeginTabItem("Quality")) { + drawQualityTab(); + ImGui::EndTabItem(); + } ImGui::EndTabBar(); } @@ -1152,3 +1318,5 @@ void PerfWindow::draw() { } } + +#endif diff --git a/engine/src/toast/renderer/passes/perf_window.hpp b/engine/src/toast/renderer/passes/perf_window.hpp index 634c912f5..643403fca 100644 --- a/engine/src/toast/renderer/passes/perf_window.hpp +++ b/engine/src/toast/renderer/passes/perf_window.hpp @@ -116,6 +116,7 @@ class PerfWindow { void drawSceneTab(); /// Slot usage and shadow settings beside the timings static void drawShadowsTab(); + void drawQualityTab(); [[nodiscard]] auto historyOffset() const -> int; @@ -153,6 +154,8 @@ class PerfWindow { float m_window_x = 0.0f; float m_window_y = 0.0f; bool m_hide_idle_passes = true; + float m_render_scale_edit = 1.0f; + bool m_render_scale_editing = false; bool m_has_gpu = false; }; diff --git a/engine/src/toast/renderer/passes/ssr_pass.cpp b/engine/src/toast/renderer/passes/ssr_pass.cpp index 41750a5a6..8aa312689 100644 --- a/engine/src/toast/renderer/passes/ssr_pass.cpp +++ b/engine/src/toast/renderer/passes/ssr_pass.cpp @@ -186,7 +186,12 @@ auto SsrPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView if (!history_valid) { history_read.makeSampleable(cmd); } - const auto settings = frame != nullptr ? frame->post_process.ssr : VulkanRenderer::PostProcessSettings::Ssr {}; + auto settings = frame != nullptr ? frame->post_process.ssr : VulkanRenderer::PostProcessSettings::Ssr {}; + // Non RT presets override the volume reach + if (const uint32_t steps = VulkanRenderer::instance->ssrOverrideSteps(); steps > 0) { + settings.max_steps = steps; + settings.stride = std::max(VulkanRenderer::instance->ssrOverrideReach(), 1.0f) / static_cast(steps); + } // Jittered since depth was rasterized with it const glm::mat4 view_projection = frame != nullptr ? frame->frame_data.jittered_view_projection : glm::mat4(1.0f); @@ -194,8 +199,7 @@ auto SsrPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView Params params {}; params.view_projection = view_projection; params.inverse_view_projection = glm::inverse(view_projection); - params.camera_position = - glm::vec4(frame != nullptr ? frame->frame_data.camera_position : glm::vec3(0.0f), static_cast(sequence % 4096)); + params.camera_position = glm::vec4(frame != nullptr ? frame->frame_data.camera_position : glm::vec3(0.0f), 0.0f); params.tuning = glm::vec4(settings.intensity, settings.max_roughness, settings.thickness, static_cast(settings.max_steps)); params.marching = glm::vec4( diff --git a/engine/src/toast/renderer/passes/ssr_pass.hpp b/engine/src/toast/renderer/passes/ssr_pass.hpp index 571d98833..b9b00c6b6 100644 --- a/engine/src/toast/renderer/passes/ssr_pass.hpp +++ b/engine/src/toast/renderer/passes/ssr_pass.hpp @@ -39,7 +39,6 @@ class SsrPass : public IPostProcessPass { struct Params { glm::mat4 view_projection {1.0f}; glm::mat4 inverse_view_projection {1.0f}; - /// w frame index for the noise glm::vec4 camera_position {0.0f}; /// x intensity y max roughness z thickness w step count glm::vec4 tuning {0.0f}; diff --git a/engine/src/toast/renderer/passes/taa_pass.cpp b/engine/src/toast/renderer/passes/taa_pass.cpp index a2baf45c9..087cf706c 100644 --- a/engine/src/toast/renderer/passes/taa_pass.cpp +++ b/engine/src/toast/renderer/passes/taa_pass.cpp @@ -102,7 +102,9 @@ auto TaaPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView const auto* frame = VulkanRenderer::instance->renderingFrame(); const bool show_motion = frame != nullptr && frame->render_mode == k_render_mode_motion; const bool active = frame != nullptr && frame->taa_active; - if (!active && !show_motion) { + const glm::vec2 output_size(static_cast(m_targets[0].extent().width), static_cast(m_targets[0].extent().height)); + const bool upscale = frame != nullptr && frame->viewport_extent != output_size && frame->viewport_extent.x > 0.0f; + if (!active && !show_motion && !upscale) { m_history_valid = false; return source_view; } @@ -122,7 +124,8 @@ auto TaaPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView const PostProcessTarget& target = m_targets[m_write]; const PostProcessTarget& history = m_targets[m_write ^ 1u]; - const bool reset = !m_history_valid || frame->taa_reset; + // Upsample only without TAA + const bool reset = !m_history_valid || frame->taa_reset || !active; if (reset) { // Only for a legal layout since a reset frame never reads the history history.beginScope(cmd); @@ -164,6 +167,8 @@ auto TaaPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView params.tuning = glm::vec4( VulkanRenderer::instance->taaHistoryWeight(), reset ? 1.0f : 0.0f, frames_skipped ? 1.0f : 0.0f, show_motion ? 1.0f : 0.0f ); + params.source = glm::vec4(frame->viewport_extent, frame->jitter_ndc * 0.5f); + params.upscale = glm::vec4(upscale ? 1.0f : 0.0f, 0.0f, 0.0f, 0.0f); target.beginScope(cmd); cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, m_pipeline.getPipeline()); @@ -182,7 +187,7 @@ auto TaaPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageView m_write ^= 1u; // The motion view is not a resolve and must never become history - m_history_valid = !show_motion; + m_history_valid = !show_motion && active; m_last_sequence = frame->sequence; m_last_view_projection = current_view_projection; return output; diff --git a/engine/src/toast/renderer/passes/taa_pass.hpp b/engine/src/toast/renderer/passes/taa_pass.hpp index bd6b9c446..c3bf063eb 100644 --- a/engine/src/toast/renderer/passes/taa_pass.hpp +++ b/engine/src/toast/renderer/passes/taa_pass.hpp @@ -26,6 +26,11 @@ class TaaPass : public IPostProcessPass { TaaPass(const VulkanCore& core, vk::Format scene_format, vk::Extent2D extent); + [[nodiscard]] + auto atOutputResolution() const -> bool override { + return true; + } + [[nodiscard]] auto name() const -> std::string_view override { return "TAA"; @@ -47,6 +52,10 @@ class TaaPass : public IPostProcessPass { glm::mat4 reprojection {1.0f}; glm::vec4 texel {0.0f}; glm::vec4 tuning {0.0f}; + /// xy render size zw jitter in uv + glm::vec4 source {0.0f}; + /// x 1 when the source is smaller than the output + glm::vec4 upscale {0.0f}; }; void createResources(const VulkanCore& core); diff --git a/engine/src/toast/renderer/passes/tonemap_pass.cpp b/engine/src/toast/renderer/passes/tonemap_pass.cpp index 6d7812e58..3d986c733 100644 --- a/engine/src/toast/renderer/passes/tonemap_pass.cpp +++ b/engine/src/toast/renderer/passes/tonemap_pass.cpp @@ -316,6 +316,11 @@ auto TonemapPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk::ImageV .grain = settings.grain, .time = frame != nullptr ? frame->frame_data.time : 0.0f, .auto_exposure = metering ? 1u : 0u, + .hdr = VulkanRenderer::instance->hdrOutput() ? 1u : 0u, + .paper_white_scale = VulkanRenderer::instance->hdrPaperWhiteScale(), + .peak_scale = VulkanRenderer::instance->hdrPeakScale(), + .gt_toe = settings.gt_contrast_dip, + .chromatic_aberration = settings.chromatic_aberration, }; if (m_params_buffers[frame_index].gpu_buffer.has_value()) { diff --git a/engine/src/toast/renderer/passes/tonemap_pass.hpp b/engine/src/toast/renderer/passes/tonemap_pass.hpp index e5e76582b..f5d584612 100644 --- a/engine/src/toast/renderer/passes/tonemap_pass.hpp +++ b/engine/src/toast/renderer/passes/tonemap_pass.hpp @@ -26,12 +26,18 @@ class VulkanCore; enum class TonemapMode : uint8_t { reinhard = 0, aces = 1, + gt7 = 2, }; class TonemapPass : public IPostProcessPass { public: TonemapPass(const VulkanCore& core, vk::Format ldr_format, vk::Extent2D extent); + [[nodiscard]] + auto atOutputResolution() const -> bool override { + return true; + } + [[nodiscard]] auto name() const -> std::string_view override { return "Tonemap"; @@ -53,7 +59,13 @@ class TonemapPass : public IPostProcessPass { float grain = 0.0f; float time = 0.0f; uint32_t auto_exposure = 0; - std::array pad0 {}; + /// 1 writes scRGB nits + uint32_t hdr = 0; + float paper_white_scale = 1.0f; + float peak_scale = 1.0f; + float gt_toe = 1.28f; + float chromatic_aberration = 0.0f; + std::array pad1 {}; }; /// Mirrors exposure.slang ExposureParams diff --git a/engine/src/toast/renderer/passes/traced_specular_pass.cpp b/engine/src/toast/renderer/passes/traced_specular_pass.cpp index bda272d37..2a95b9a7b 100644 --- a/engine/src/toast/renderer/passes/traced_specular_pass.cpp +++ b/engine/src/toast/renderer/passes/traced_specular_pass.cpp @@ -86,6 +86,7 @@ TracedSpecularPass::TracedSpecularPass(const VulkanCore& core, vk::Format scene_ pipeline.rebuild(core, config); }; const vk::Extent2D half = PostProcessTarget::halfExtent(extent); + build_pipeline(m_surface_pipeline, k_surface_format, half, "surfaceMain", "TracedSpecular Surface"); 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"); @@ -98,8 +99,8 @@ TracedSpecularPass::TracedSpecularPass(const VulkanCore& core, vk::Format scene_ 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(); + m_ready = m_surface_pipeline.isReady() && 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)"); } @@ -159,6 +160,7 @@ void TracedSpecularPass::createSets(const VulkanCore& core) { 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_surface.create(core, half, k_surface_format, "TracedSpecular Surface"); 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"); @@ -211,6 +213,7 @@ auto TracedSpecularPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk: history_write.view(), previous_view ? previous_view : source_view, m_resolved.view(), + m_surface.view(), }; if (std::ranges::any_of(views, [](vk::ImageView view) { return !view; })) { return source_view; @@ -259,6 +262,7 @@ auto TracedSpecularPass::record(vk::CommandBuffer cmd, uint32_t frame_index, vk: target.endScope(cmd); }; + draw(m_surface, m_surface_pipeline); draw(m_trace, m_trace_pipeline); draw(m_resolved, m_resolve_pipeline); draw(history_write, m_temporal_pipeline); diff --git a/engine/src/toast/renderer/passes/traced_specular_pass.hpp b/engine/src/toast/renderer/passes/traced_specular_pass.hpp index d6d357639..f05b9e322 100644 --- a/engine/src/toast/renderer/passes/traced_specular_pass.hpp +++ b/engine/src/toast/renderer/passes/traced_specular_pass.hpp @@ -53,8 +53,10 @@ class TracedSpecularPass : public IPostProcessPass { /// rgb radiance a hit distance then history and -1 where nothing was traced static constexpr vk::Format k_trace_format = vk::Format::eR16G16B16A16Sfloat; + /// voxel_specular.slang packSurface + static constexpr vk::Format k_surface_format = vk::Format::eR32G32B32A32Uint; - static constexpr uint32_t k_image_bindings = 12; + static constexpr uint32_t k_image_bindings = 13; void createSets(const VulkanCore& core); void createTargets(const VulkanCore& core, vk::Extent2D extent); @@ -64,6 +66,7 @@ class TracedSpecularPass : public IPostProcessPass { bool m_ready = false; ShaderLayout m_layout; + VulkanPipeline m_surface_pipeline; VulkanPipeline m_trace_pipeline; VulkanPipeline m_resolve_pipeline; VulkanPipeline m_temporal_pipeline; @@ -80,6 +83,7 @@ class TracedSpecularPass : public IPostProcessPass { std::vector m_image_sets; std::vector> m_image_bound; + PostProcessTarget m_surface; PostProcessTarget m_trace; PostProcessTarget m_resolved; /// The temporal pass reads one and writes the other diff --git a/engine/src/toast/renderer/passes/voxel_pass.cpp b/engine/src/toast/renderer/passes/voxel_pass.cpp index 9282af345..28dfcd657 100644 --- a/engine/src/toast/renderer/passes/voxel_pass.cpp +++ b/engine/src/toast/renderer/passes/voxel_pass.cpp @@ -112,7 +112,7 @@ constexpr uint32_t k_glass_bricks_binding = 7; constexpr uint32_t k_backdrop_binding = 8; constexpr uint32_t k_backdrop_depth_binding = 9; -/// Turns the rounding spiral each frame for TAA to average +/// Turns the denoiser pattern each frame for TAA to average constexpr float k_golden_ratio_conjugate = 0.61803398875f; } @@ -440,6 +440,9 @@ void VoxelPass::createLighting(const VulkanCore& core, vk::Format scene_format, config.blend_preset = VulkanPipeline::BlendPreset::additive; config.extra_blend_mask = 1u; m_spatial_pipeline.rebuild(core, config); + config.debug_name = "VoxelSpatialReuseOpaque"; + config.fragment_entry = "fragmentSpatialOpaque"; + m_spatial_opaque_pipeline.rebuild(core, config); // Premultiplied so colour adds and noisy diffuse keeps the indirect scaled by occlusion config.debug_name = "VoxelMarchedLighting"; @@ -1008,7 +1011,7 @@ auto VoxelPass::prepare(uint32_t frame_index) -> bool { // A voxel covers at least this many pixels closer than near_voxels voxels whatever the volume scale const float raster_pixels = VulkanRenderer::instance->voxelRasterPixels(); - const float height = static_cast(VulkanRenderer::instance->getOutputTarget().getExtent().height); + const float height = static_cast(VulkanRenderer::instance->renderExtent().height); m_near_voxels = raster_pixels > 0.0f && m_mesh_pipeline.isReady() && m_quad_pipeline.isReady() && m_quad_depth_pipeline.isReady() ? std::abs(frame->frame_data.projection[1][1]) * height / (2.0f * raster_pixels) @@ -1414,8 +1417,6 @@ void VoxelPass::recordLighting(vk::CommandBuffer cmd, uint32_t frame_index, vk:: m_lighting_scene_sets.updateTlas(frame_index); 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) @@ -1424,10 +1425,7 @@ void VoxelPass::recordLighting(vk::CommandBuffer cmd, uint32_t frame_index, vk:: if (m_denoise_active) { push = LightingPushConstants { .inverse_view_projection = glm::inverse(frame->frame_data.jittered_view_projection), - .camera_radius = glm::vec4(frame->frame_data.camera_position, instance->voxelNormalRounding() * voxel::k_voxel_size), - .viewport = glm::vec4( - static_cast(viewport.width), height, 0.5f * height * std::abs(frame->frame_data.projection[1][1]), rotation - ), + .viewport = glm::vec4(static_cast(viewport.width), height, 0.0f, 0.0f), .lighting = glm::uvec4( std::min(m_shadow_instance_count, instance->voxelShadowInstances()), instance->voxelLightCandidates(), @@ -1446,10 +1444,7 @@ void VoxelPass::recordLighting(vk::CommandBuffer cmd, uint32_t frame_index, vk:: } else { push = LightingPushConstants { .inverse_view_projection = glm::inverse(frame->frame_data.jittered_view_projection), - .camera_radius = glm::vec4(frame->frame_data.camera_position, instance->voxelNormalRounding() * voxel::k_voxel_size), - .viewport = glm::vec4( - static_cast(viewport.width), height, 0.5f * height * std::abs(frame->frame_data.projection[1][1]), rotation - ), + .viewport = glm::vec4(static_cast(viewport.width), height, 0.0f, 0.0f), .lighting = glm::uvec4( std::min(m_shadow_instance_count, instance->voxelShadowInstances()), instance->voxelLightCandidates(), @@ -1790,7 +1785,8 @@ void VoxelPass::recordSpatial(vk::CommandBuffer cmd, uint32_t frame_index, vk::E return; } const vk::PipelineLayout layout = *m_lighting_layout.getPipelineLayout(); - cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, m_spatial_pipeline.getPipeline()); + const bool opaque = !m_scene_glass && m_spatial_opaque_pipeline.isReady(); + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, (opaque ? m_spatial_opaque_pipeline : m_spatial_pipeline).getPipeline()); cmd.bindDescriptorSets( vk::PipelineBindPoint::eGraphics, layout, @@ -1853,12 +1849,12 @@ void VoxelPass::recordMarchedComposite(vk::CommandBuffer cmd, uint32_t frame_ind void VoxelPass::onTargetsRecreated() { std::ranges::fill(m_bound_gbuffer, std::array {}); if (!m_denoise_sets.empty()) { - createDenoiseTargets(VulkanRenderer::instance->getOutputTarget().getExtent()); + createDenoiseTargets(VulkanRenderer::instance->renderExtent()); } if (!m_cell_sets.empty()) { const auto* renderer = VulkanRenderer::instance; - m_hiz.create(*m_core, renderer->getDepthView(), renderer->getOutputTarget().getExtent()); + m_hiz.create(*m_core, renderer->getDepthView(), renderer->renderExtent()); writeHizDescriptors(); } } diff --git a/engine/src/toast/renderer/passes/voxel_pass.hpp b/engine/src/toast/renderer/passes/voxel_pass.hpp index a45867c4d..b63bb51ae 100644 --- a/engine/src/toast/renderer/passes/voxel_pass.hpp +++ b/engine/src/toast/renderer/passes/voxel_pass.hpp @@ -233,7 +233,6 @@ class VoxelPass : public IRenderPass { /// Mirrors voxel_lighting.slang LightingPushConstants struct LightingPushConstants { glm::mat4 inverse_view_projection {1.0f}; - glm::vec4 camera_radius {0.0f}; glm::vec4 viewport {0.0f}; /// x instances with bounds y light sampling candidates z marched light routing w sun reach in meters glm::uvec4 lighting {0u}; @@ -365,6 +364,7 @@ class VoxelPass : public IRenderPass { /// Albedo face normal material and depth last written into each set std::vector> m_bound_gbuffer; VulkanPipeline m_spatial_pipeline; + VulkanPipeline m_spatial_opaque_pipeline; VulkanPipeline m_marched_pipeline; VulkanPipeline m_ambient_pipeline; VulkanPipeline m_marched_composite_pipeline; diff --git a/engine/src/toast/renderer/post_process_pass_base.hpp b/engine/src/toast/renderer/post_process_pass_base.hpp index ce25617e7..ce3b006e3 100644 --- a/engine/src/toast/renderer/post_process_pass_base.hpp +++ b/engine/src/toast/renderer/post_process_pass_base.hpp @@ -20,6 +20,11 @@ class IPostProcessPass { virtual void onResize(vk::Extent2D extent) { } + [[nodiscard]] + virtual auto atOutputResolution() const -> bool { + return false; + } + [[nodiscard]] virtual auto name() const -> std::string_view { return "PostProcess"; diff --git a/engine/src/toast/renderer/post_process_settings.cpp b/engine/src/toast/renderer/post_process_settings.cpp index 48eff3641..e00801fc8 100644 --- a/engine/src/toast/renderer/post_process_settings.cpp +++ b/engine/src/toast/renderer/post_process_settings.cpp @@ -31,8 +31,22 @@ void blendPostProcess(PostProcessSettings& target, const PostProcessSettings& so target.bloom.filter_radius = mix(target.bloom.filter_radius, source.bloom.filter_radius, t); target.bloom.strength = mix(target.bloom.strength, source.bloom.strength, t); + target.lens_flare.intensity = mix(target.lens_flare.intensity, source.lens_flare.intensity, t); + target.lens_flare.threshold = mix(target.lens_flare.threshold, source.lens_flare.threshold, t); + target.lens_flare.threshold_range = mix(target.lens_flare.threshold_range, source.lens_flare.threshold_range, t); + target.lens_flare.ghost_intensity = mix(target.lens_flare.ghost_intensity, source.lens_flare.ghost_intensity, t); + target.lens_flare.chroma_shift = mix(target.lens_flare.chroma_shift, source.lens_flare.chroma_shift, t); + target.lens_flare.halo_intensity = mix(target.lens_flare.halo_intensity, source.lens_flare.halo_intensity, t); + target.lens_flare.halo_width = mix(target.lens_flare.halo_width, source.lens_flare.halo_width, t); + target.lens_flare.halo_mask = mix(target.lens_flare.halo_mask, source.lens_flare.halo_mask, t); + target.lens_flare.halo_compression = mix(target.lens_flare.halo_compression, source.lens_flare.halo_compression, t); + target.lens_flare.glare_intensity = mix(target.lens_flare.glare_intensity, source.lens_flare.glare_intensity, t); + target.lens_flare.glare_divider = mix(target.lens_flare.glare_divider, source.lens_flare.glare_divider, t); + target.tonemap.exposure = mix(target.tonemap.exposure, source.tonemap.exposure, t); target.tonemap.mode = pick(target.tonemap.mode, source.tonemap.mode, t); + target.tonemap.gt_contrast_dip = mix(target.tonemap.gt_contrast_dip, source.tonemap.gt_contrast_dip, t); + target.tonemap.chromatic_aberration = mix(target.tonemap.chromatic_aberration, source.tonemap.chromatic_aberration, t); target.tonemap.gamma = mix(target.tonemap.gamma, source.tonemap.gamma, t); target.tonemap.contrast = mix(target.tonemap.contrast, source.tonemap.contrast, t); target.tonemap.saturation = mix(target.tonemap.saturation, source.tonemap.saturation, t); diff --git a/engine/src/toast/renderer/post_process_settings.hpp b/engine/src/toast/renderer/post_process_settings.hpp index d16c1bda5..acc711a49 100644 --- a/engine/src/toast/renderer/post_process_settings.hpp +++ b/engine/src/toast/renderer/post_process_settings.hpp @@ -19,10 +19,26 @@ struct PostProcessSettings { float strength = 0.05f; } bloom; + struct LensFlare { + float intensity = 0.1f; + float threshold = 1.0f; + float threshold_range = 1.0f; + float ghost_intensity = 1.0f; + float chroma_shift = 0.015f; + float halo_intensity = 1.0f; + float halo_width = 0.6f; + float halo_mask = 0.5f; + float halo_compression = 0.65f; + float glare_intensity = 0.2f; + float glare_divider = 60.0f; + } lens_flare; + struct Tonemap { float exposure = 1.0f; - /// 0 Reinhard 1 ACES + /// 0 Reinhard 1 ACES 2 GT7 uint32_t mode = 0; + float gt_contrast_dip = 1.28f; + float chromatic_aberration = 0.0f; float gamma = 2.2f; float contrast = 1.0f; float saturation = 1.0f; diff --git a/engine/src/toast/renderer/renderer_settings.cpp b/engine/src/toast/renderer/renderer_settings.cpp index 8cf03ad39..8856306bf 100644 --- a/engine/src/toast/renderer/renderer_settings.cpp +++ b/engine/src/toast/renderer/renderer_settings.cpp @@ -1,12 +1,16 @@ #include "renderer_settings.hpp" #include "passes/environment_pass.hpp" +#include "shader_compiler.hpp" #include "shadow_constants.hpp" #include "shadow_slots.hpp" #include "vulkan_renderer.hpp" #include #include +#include +#include +#include #include #include @@ -15,6 +19,271 @@ namespace { using toast::settings::Meta; +constexpr uint32_t k_tiers_per_branch = 4; +constexpr uint32_t k_preset_custom = 2 * k_tiers_per_branch; + +struct PresetKnobs { + bool ssr = true; + bool ssao = true; + bool traced_specular = true; + float specular_roughness = 0.6f; + float specular_reach = 32.0f; + uint32_t light_candidates = 8; + bool ambient_rays = true; + bool glass_shadows = true; + uint32_t volumetric_quality = 2; + uint32_t gi_quality = 2; + bool gi_enabled = true; + float render_scale = 1.0f; + float shadow_distance = shadows::k_shadow_distance; + uint32_t spot_budget = shadows::k_max_spot_shadows; + uint32_t point_budget = shadows::k_max_point_shadows; + bool shadow_maps_only = false; + /// Zero steps keeps the post process volume values + float ssr_reach = 0.0f; + uint32_t ssr_steps = 0; + bool motion_blur = false; +}; + +/// Low Medium High Epic without ray tracing, then Low RT Medium RT High RT Epic RT +constexpr std::array k_preset_tiers { + { + // Without ray tracing + {.ssr = false, + .ssao = false, + .traced_specular = false, + .specular_roughness = 0.3f, + .specular_reach = 16.0f, + .light_candidates = 2, + .ambient_rays = false, + .glass_shadows = false, + .volumetric_quality = 0, + .gi_quality = 0, + .gi_enabled = false, + .render_scale = 0.65f, + .shadow_distance = 60.0f, + .spot_budget = 4, + .point_budget = 2, + .shadow_maps_only = true}, + {.ssr = true, + .ssao = true, + .traced_specular = false, + .specular_roughness = 0.4f, + .specular_reach = 24.0f, + .light_candidates = 4, + .ambient_rays = false, + .glass_shadows = false, + .volumetric_quality = 0, + .gi_quality = 0, + .gi_enabled = false, + .render_scale = 0.75f, + .shadow_distance = 100.0f, + .spot_budget = 6, + .point_budget = 3, + .shadow_maps_only = true, + .ssr_reach = 12.0f, + .ssr_steps = 48}, + {.ssr = true, + .ssao = true, + .traced_specular = false, + .specular_roughness = 0.5f, + .specular_reach = 32.0f, + .light_candidates = 6, + .ambient_rays = false, + .glass_shadows = true, + .volumetric_quality = 1, + .gi_quality = 0, + .gi_enabled = true, + .render_scale = 0.85f, + .shadow_distance = 150.0f, + .spot_budget = 10, + .point_budget = 5, + .shadow_maps_only = true, + .ssr_reach = 20.0f, + .ssr_steps = 64, + .motion_blur = true}, + {.ssr = true, + .ssao = true, + .traced_specular = false, + .specular_roughness = 0.5f, + .specular_reach = 32.0f, + .light_candidates = 8, + .ambient_rays = false, + .glass_shadows = true, + .volumetric_quality = 1, + .gi_quality = 1, + .gi_enabled = true, + .render_scale = 1.0f, + .shadow_distance = 250.0f, + .spot_budget = 16, + .point_budget = 8, + .shadow_maps_only = true, + .ssr_reach = 32.0f, + .ssr_steps = 96, + .motion_blur = true}, + // With ray tracing + {.ssr = false, + .ssao = false, + .traced_specular = false, + .specular_roughness = 0.3f, + .specular_reach = 16.0f, + .light_candidates = 2, + .ambient_rays = false, + .glass_shadows = false, + .volumetric_quality = 0, + .gi_quality = 0, + .shadow_distance = 60.0f, + .spot_budget = 4, + .point_budget = 2}, + {.ssr = true, + .ssao = true, + .traced_specular = false, + .specular_roughness = 0.4f, + .specular_reach = 24.0f, + .light_candidates = 4, + .ambient_rays = true, + .glass_shadows = true, + .volumetric_quality = 1, + .gi_quality = 1, + .shadow_distance = 100.0f, + .spot_budget = 8, + .point_budget = 4}, + {.ssr = true, + .ssao = true, + .traced_specular = true, + .specular_roughness = 0.5f, + .specular_reach = 32.0f, + .light_candidates = 8, + .ambient_rays = true, + .glass_shadows = true, + .volumetric_quality = 2, + .gi_quality = 2, + .shadow_distance = 150.0f, + .spot_budget = 16, + .point_budget = 8, + .motion_blur = true}, + {.ssr = true, + .ssao = true, + .traced_specular = true, + .specular_roughness = 0.6f, + .specular_reach = 48.0f, + .light_candidates = 16, + .ambient_rays = true, + .glass_shadows = true, + .volumetric_quality = 3, + .gi_quality = 3, + .shadow_distance = 250.0f, + .spot_budget = shadows::k_max_spot_shadows, + .point_budget = shadows::k_max_point_shadows, + .motion_blur = true}, + } +}; + +/// Main thread writes and the debug window reads under the mutex +struct PresetState { + std::mutex mutex; + VulkanRenderer* renderer = nullptr; + uint32_t preset = k_preset_custom; + /// Off until every driven setting has reported its value + bool ready = false; + PresetKnobs knobs; +}; + +PresetState g_preset; + +/// RT tiers fall back to the non RT tier of the same level when not tracing +auto activeKnobs() -> const PresetKnobs& { + if (g_preset.preset >= k_preset_custom) { + return g_preset.knobs; + } + uint32_t tier = g_preset.preset; + if (tier >= k_tiers_per_branch && !g_preset.renderer->getCore().isRayTracingSupported()) { + tier -= k_tiers_per_branch; + } + return k_preset_tiers[tier]; +} + +void applyPreset() { + if (!g_preset.ready) { + return; + } + VulkanRenderer& renderer = *g_preset.renderer; + const PresetKnobs& knobs = activeKnobs(); + renderer.setRenderScale(knobs.render_scale); + renderer.giSettings().enabled.store(knobs.gi_enabled, std::memory_order_relaxed); + renderer.setPostProcessPassEnabled("SSR", knobs.ssr); + renderer.setSsrOverride(knobs.ssr_reach, knobs.ssr_steps); + renderer.setPostProcessPassEnabled("Motion blur", knobs.motion_blur); + renderer.setVoxelShadowMapsOnly(knobs.shadow_maps_only); + renderer.setPostProcessPassEnabled("SSAO", knobs.ssao); + renderer.setVoxelTracedSpecular(knobs.traced_specular); + renderer.setVoxelSpecularRoughness(knobs.specular_roughness); + renderer.setVoxelSpecularReach(knobs.specular_reach); + renderer.setVoxelLightCandidates(knobs.light_candidates); + renderer.setVoxelAmbientRays(knobs.ambient_rays); + renderer.setVoxelGlassShadows(knobs.glass_shadows); + renderer.setVolumetricQuality(knobs.volumetric_quality); + renderer.giSettings().quality.store(knobs.gi_quality, std::memory_order_relaxed); + shadows::setShadowDistance(knobs.shadow_distance); + shadows::setSpotShadowBudget(knobs.spot_budget); + shadows::setPointShadowBudget(knobs.point_budget); +} + +using toast::settings::Value; + +struct DrivenSetting { + std::string_view key; + Value (*get)(const PresetKnobs&); +}; + +const std::array k_driven { + { + {"renderer.voxels.shadow_maps_only", [](const PresetKnobs& k) { return Value {k.shadow_maps_only}; }}, + {"renderer.quality.ssr_reach", [](const PresetKnobs& k) { return Value {static_cast(k.ssr_reach)}; }}, + {"renderer.quality.ssr_steps", [](const PresetKnobs& k) { return Value {static_cast(k.ssr_steps)}; }}, + {"renderer.quality.motion_blur", [](const PresetKnobs& k) { return Value {k.motion_blur}; }}, + {"renderer.quality.ssr", [](const PresetKnobs& k) { return Value {k.ssr}; }}, + {"renderer.quality.ssao", [](const PresetKnobs& k) { return Value {k.ssao}; }}, + {"renderer.quality.render_scale", [](const PresetKnobs& k) { return Value {static_cast(k.render_scale)}; }}, + {"renderer.voxels.traced_specular", [](const PresetKnobs& k) { return Value {k.traced_specular}; }}, + {"renderer.voxels.specular_roughness", + [](const PresetKnobs& k) { return Value {static_cast(k.specular_roughness)}; }}, + {"renderer.voxels.specular_reach", [](const PresetKnobs& k) { return Value {static_cast(k.specular_reach)}; }}, + {"renderer.voxels.light_candidates", [](const PresetKnobs& k) { return Value {static_cast(k.light_candidates)}; }}, + {"renderer.voxels.ambient_rays", [](const PresetKnobs& k) { return Value {k.ambient_rays}; }}, + {"renderer.voxels.glass_shadows", [](const PresetKnobs& k) { return Value {k.glass_shadows}; }}, + {"renderer.volumetrics.quality", [](const PresetKnobs& k) { return Value {static_cast(k.volumetric_quality)}; }}, + {"renderer.gi.enabled", [](const PresetKnobs& k) { return Value {k.gi_enabled}; }}, + {"renderer.gi.quality", [](const PresetKnobs& k) { return Value {static_cast(k.gi_quality)}; }}, + {"renderer.shadows.distance", [](const PresetKnobs& k) { return Value {static_cast(k.shadow_distance)}; }}, + {"renderer.shadows.spot_budget", [](const PresetKnobs& k) { return Value {static_cast(k.spot_budget)}; }}, + {"renderer.shadows.point_budget", [](const PresetKnobs& k) { return Value {static_cast(k.point_budget)}; }}, + } +}; + +/// Custom starts from the tier it leaves +void seedSettingsFrom(const PresetKnobs& tier) { + auto& settings = toast::settings::Settings::get(); + for (const DrivenSetting& driven : k_driven) { + settings.setValue(driven.key, driven.get(tier), true); + } +} + +template +auto presetKnob(T PresetKnobs::* knob, Convert convert) { + return [knob, convert](V value) { + { + const std::lock_guard lock(g_preset.mutex); + g_preset.knobs.*knob = convert(value); + } + applyPreset(); + }; +} + +constexpr auto k_same_bool = [](bool v) { return v; }; +constexpr auto k_to_float = [](double v) { return static_cast(v); }; +constexpr auto k_to_count = [](int64_t v) { return static_cast(std::max(v, 0)); }; + void registerDisplay(VulkanRenderer& renderer) { toast::settings::declareBool( "renderer.display.clamp_to_simulation", @@ -39,9 +308,100 @@ void registerDisplay(VulkanRenderer& renderer) { .step = 1.0} ) .onChange([&renderer](double v) { renderer.setFrameRateLimit(v); }); + + static float hdr_paper_white = 0.0f; + static float hdr_peak = 0.0f; + toast::settings::declareFloat( + "renderer.display.hdr_paper_white", + 0.0, + {.label = "HDR paper white", + .category = "Display", + .description = "Nits that SDR white and the UI sit at when HDR output is on. 0 follows the Windows SDR content " + "brightness slider", + .min = 0.0, + .max = 500.0, + .step = 5.0} + ) + .onChange([&renderer](double v) { + hdr_paper_white = static_cast(v); + renderer.setHdrNits(hdr_paper_white, hdr_peak); + }); + + toast::settings::declareFloat( + "renderer.display.hdr_peak", + 0.0, + {.label = "HDR peak brightness", + .category = "Display", + .description = "Nits the tonemap rolls highlights off toward when HDR output is on. 0 uses what the display reports, " + "1000 when it does not say", + .min = 0.0, + .max = 4000.0, + .step = 50.0} + ) + .onChange([&renderer](double v) { + hdr_peak = static_cast(v); + renderer.setHdrNits(hdr_paper_white, hdr_peak); + }); +} + +/// Dev builds keep Custom and players start on a tier for their GPU +auto defaultPreset(VulkanRenderer& renderer) -> int64_t { +#if defined(TOAST_DEV_TOOLS) + static_cast(renderer); + return k_preset_custom; +#else + const VulkanCore& core = renderer.getCore(); + if (core.isRayTracingSupported()) { + return k_tiers_per_branch + 2; + } + return core.getPhysicalDevice().getProperties().deviceType == vk::PhysicalDeviceType::eDiscreteGpu ? 1 : 0; +#endif } void registerQuality(VulkanRenderer& renderer) { + toast::settings::declareInt( + "renderer.quality.preset", + defaultPreset(renderer), + { + .label = "Preset", + .category = "Quality", + .description = "Reflections, AO, shadows, voxel lighting, volumetrics, GI and render scale together. The RT tiers need " + "hardware ray tracing and run the matching non RT tier without it. Custom uses each setting", + .options = {"Low", "Medium", "High", "Epic", "Low RT", "Medium RT", "High RT", "Epic RT", "Custom"} + } + ) + .onChange([](int64_t v) { + const auto next = static_cast(std::clamp(v, 0, k_preset_custom)); + std::optional seed; + { + const std::lock_guard lock(g_preset.mutex); + if (g_preset.ready && g_preset.preset < k_preset_custom && next == k_preset_custom) { + seed = activeKnobs(); + g_preset.knobs = *seed; + } + g_preset.preset = next; + } + if (seed.has_value()) { + seedSettingsFrom(*seed); + } + if (next >= k_tiers_per_branch && next < k_preset_custom && !g_preset.renderer->getCore().isRayTracingSupported()) { + TOAST_INFO("Render", "Ray tracing is not in use on this device so the RT preset runs its non RT tier"); + } + applyPreset(); + }); + + toast::settings::declareFloat( + "renderer.quality.render_scale", + 1.0, + {.label = "Render scale", + .category = "Quality", + .description = "Share of the output resolution the world renders at before TAA upsamples it. Preset Custom only", + .min = 0.5, + .max = 1.0, + .step = 0.05} + ) + .onChange(presetKnob(&PresetKnobs::render_scale, k_to_float)); + const auto pass_toggle = [&renderer](std::string_view key, std::string_view pass, bool default_value, std::string description) { toast::settings::declareBool( key, default_value, {.label = std::string {pass}, .category = "Quality", .description = std::move(description)} @@ -50,8 +410,93 @@ void registerQuality(VulkanRenderer& renderer) { }; pass_toggle("renderer.quality.bloom", "Bloom", true, "Glow around highlights bright enough to overwhelm a lens."); - pass_toggle("renderer.quality.ssr", "SSR", true, "Screen-space reflections. The most expensive post pass."); - pass_toggle("renderer.quality.ssao", "SSAO", true, "Contact darkening where geometry meets geometry."); + pass_toggle( + "renderer.quality.lens_flare", + "Lens flare", + true, + "Ghosts, halo and glare streaks from the brightest pixels. Tuned on the post process volume." + ); + toast::settings::declareBool( + "renderer.quality.ssr", + true, + {.label = "SSR", .category = "Quality", .description = "Screen-space reflections. Preset Custom only"} + ) + .onChange(presetKnob(&PresetKnobs::ssr, k_same_bool)); + toast::settings::declareBool( + "renderer.quality.ssao", + true, + {.label = "SSAO", + .category = "Quality", + .description = "Contact darkening where geometry meets geometry. Preset Custom only"} + ) + .onChange(presetKnob(&PresetKnobs::ssao, k_same_bool)); + + toast::settings::declareBool( + "renderer.quality.motion_blur", + false, + {.label = "Motion blur", + .category = "Quality", + .description = "Blurs the image along the motion vectors of the frame. Preset Custom only"} + ) + .onChange(presetKnob(&PresetKnobs::motion_blur, k_same_bool)); + + toast::settings::declareFloat( + "renderer.quality.motion_blur_strength", + 0.5, + {.label = "Motion blur shutter", + .category = "Quality", + .description = "Share of the frame the shutter stays open. 0.5 is a 180 degree shutter", + .min = 0.0, + .max = 1.0, + .step = 0.05} + ) + .onChange([&renderer](double v) { renderer.setMotionBlurStrength(static_cast(v)); }); + + toast::settings::declareFloat( + "renderer.quality.motion_blur_max_length", + 0.04, + {.label = "Motion blur max length", + .category = "Quality", + .description = "Longest blur as a share of the screen height", + .min = 0.005, + .max = 0.2, + .step = 0.005} + ) + .onChange([&renderer](double v) { renderer.setMotionBlurMaxLength(static_cast(v)); }); + + toast::settings::declareInt( + "renderer.quality.motion_blur_samples", + 12, + {.label = "Motion blur samples", + .category = "Quality", + .description = "Taps along each blur. More is smoother and costs more", + .min = 4.0, + .max = 32.0} + ) + .onChange([&renderer](int64_t v) { renderer.setMotionBlurSamples(static_cast(std::max(v, 2))); }); + + toast::settings::declareFloat( + "renderer.quality.ssr_reach", + 0.0, + {.label = "SSR reach", + .category = "Quality", + .description = "Meters an SSR ray travels, with the steps below in place of the post process volume. Preset Custom only", + .min = 0.0, + .max = 100.0, + .step = 1.0} + ) + .onChange(presetKnob(&PresetKnobs::ssr_reach, k_to_float)); + + toast::settings::declareInt( + "renderer.quality.ssr_steps", + 0, + {.label = "SSR steps", + .category = "Quality", + .description = "Samples along each SSR ray. 0 keeps the post process volume values. Preset Custom only", + .min = 0.0, + .max = 256.0} + ) + .onChange(presetKnob(&PresetKnobs::ssr_steps, k_to_count)); pass_toggle("renderer.quality.fxaa", "FXAA", true, "Edge anti-aliasing on the tonemapped image. Skipped while TAA is on."); toast::settings::declareBool( @@ -131,7 +576,7 @@ void registerShadows() { .max = 1000.0, .step = 5.0} ) - .onChange([](double v) { shadows::setShadowDistance(static_cast(v)); }); + .onChange(presetKnob(&PresetKnobs::shadow_distance, k_to_float)); toast::settings::declareFloat( "renderer.shadows.slot_retention", @@ -198,7 +643,7 @@ void registerShadows() { .max = static_cast(shadows::k_max_spot_shadows), .step = 1.0} ) - .onChange([](int64_t lights) { shadows::setSpotShadowBudget(static_cast(std::max(lights, 0))); }); + .onChange(presetKnob(&PresetKnobs::spot_budget, k_to_count)); toast::settings::declareInt( "renderer.shadows.point_budget", @@ -211,7 +656,7 @@ void registerShadows() { .max = static_cast(shadows::k_max_point_shadows), .step = 1.0} ) - .onChange([](int64_t lights) { shadows::setPointShadowBudget(static_cast(std::max(lights, 0))); }); + .onChange(presetKnob(&PresetKnobs::point_budget, k_to_count)); toast::settings::declareFloat( "renderer.shadows.full_resolution_ranges", @@ -310,7 +755,20 @@ void registerVoxels(VulkanRenderer& renderer) { .min = 1.0, .max = 32.0} ) - .onChange([&renderer](int64_t v) { renderer.setVoxelLightCandidates(static_cast(std::max(v, 1))); }); + .onChange(presetKnob(&PresetKnobs::light_candidates, [](int64_t v) { + return static_cast(std::max(v, 1)); + })); + + toast::settings::declareBool( + "renderer.voxels.shadow_maps_only", + false, + {.label = "Shadow maps only", + .category = "Voxels", + .description = "Every light shadows voxels with its shadow map whatever its Voxel Shadows mode says. Marched and " + "Sampled shadows trace scene rays, through ray queries when the GPU traces and else by walking the " + "software cell grid. Preset Custom only"} + ) + .onChange(presetKnob(&PresetKnobs::shadow_maps_only, k_same_bool)); toast::settings::declareBool( "renderer.voxels.ambient_rays", @@ -320,7 +778,7 @@ void registerVoxels(VulkanRenderer& renderer) { .description = "Two super-sparse rays per voxel pixel occlude ambient light by how far they get, in place of " "SSAO. Off keeps SSAO on voxels"} ) - .onChange([&renderer](bool enabled) { renderer.setVoxelAmbientRays(enabled); }); + .onChange(presetKnob(&PresetKnobs::ambient_rays, k_same_bool)); toast::settings::declareBool( "renderer.voxels.ambient_ray_query", @@ -352,7 +810,7 @@ void registerVoxels(VulkanRenderer& renderer) { .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); }); + .onChange(presetKnob(&PresetKnobs::traced_specular, k_same_bool)); toast::settings::declareFloat( "renderer.voxels.specular_roughness", @@ -364,7 +822,7 @@ void registerVoxels(VulkanRenderer& renderer) { .max = 1.0, .step = 0.05} ) - .onChange([&renderer](double v) { renderer.setVoxelSpecularRoughness(static_cast(v)); }); + .onChange(presetKnob(&PresetKnobs::specular_roughness, k_to_float)); toast::settings::declareFloat( "renderer.voxels.specular_reach", @@ -376,7 +834,7 @@ void registerVoxels(VulkanRenderer& renderer) { .max = 200.0, .step = 1.0} ) - .onChange([&renderer](double v) { renderer.setVoxelSpecularReach(static_cast(v)); }); + .onChange(presetKnob(&PresetKnobs::specular_reach, k_to_float)); toast::settings::declareBool( "renderer.voxels.glass_refraction_enabled", @@ -449,7 +907,7 @@ void registerVoxels(VulkanRenderer& renderer) { .description = "Glass tints the light that reaches voxels behind it in every light mode. Off makes glass cast " "no shadow and lets marched shadow rays take coarse steps through it"} ) - .onChange([&renderer](bool enabled) { renderer.setVoxelGlassShadows(enabled); }); + .onChange(presetKnob(&PresetKnobs::glass_shadows, k_same_bool)); toast::settings::declareInt( "renderer.voxels.glass_shadow_reach", @@ -475,16 +933,6 @@ 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, @@ -533,18 +981,6 @@ void registerVoxels(VulkanRenderer& renderer) { ) .onChange([&renderer](int64_t v) { renderer.setVoxelShadowInstances(static_cast(std::max(v, 1))); }); - toast::settings::declareFloat( - "renderer.voxels.edge_rounding", - 0.3, - {.label = "Edge rounding", - .category = "Voxels", - .description = "Width in voxels that normals blend across edges near the camera. 0 keeps flat faces.", - .min = 0.0, - .max = 1.0, - .step = 0.05} - ) - .onChange([&renderer](double v) { renderer.setVoxelNormalRounding(static_cast(v)); }); - toast::settings::declareBool( "renderer.voxels.cell_boxes", true, @@ -652,11 +1088,9 @@ void registerVolumetrics(VulkanRenderer& renderer) { .options = {"Low", "Medium", "High", "Epic", "Custom"} } ) - .onChange([&renderer](int64_t v) { - renderer.setVolumetricQuality( - static_cast(std::clamp(v, 0, VulkanRenderer::k_volumetric_quality_custom)) - ); - }); + .onChange(presetKnob(&PresetKnobs::volumetric_quality, [](int64_t v) { + return static_cast(std::clamp(v, 0, VulkanRenderer::k_volumetric_quality_custom)); + })); toast::settings::declareInt( "renderer.volumetrics.steps", @@ -714,6 +1148,7 @@ void registerVolumetrics(VulkanRenderer& renderer) { .onChange([&renderer](double v) { renderer.setVolumetricDistance(static_cast(v)); }); } +#if defined(TOAST_DEV_TOOLS) void registerDebug(VulkanRenderer& renderer) { toast::settings::declareBool( k_debug_overlay_in_game, @@ -725,11 +1160,51 @@ void registerDebug(VulkanRenderer& renderer) { ) .onChange([&renderer](bool visible) { renderer.setDebugOverlayInGame(visible); }); } +#endif } void registerRendererStartupSettings() { registerShadows(); + + toast::settings::declareBool( + k_hdr_output, + true, + {.label = "HDR output", + .category = "Display", + .description = "Presents HDR when the display has it turned on in the operating system and SDR otherwise. Off keeps " + "SDR on every display", + .requires_restart = true} + ); + + toast::settings::declareInt( + "renderer.quality.ray_tracing", + 0, + { + .label = "Hardware ray tracing", + .category = "Quality", + .description = "Auto traces only on GPUs with RT cores since drivers emulate ray query slowly elsewhere. Off keeps the " + "raster and software paths on every GPU, which is what the non RT presets are tuned for. On forces ray " + "query wherever the driver offers it", + .options = {"Auto", "On", "Off"}, + .requires_restart = true + } + ) + .onChange([](int64_t v) { + constexpr std::array k_modes {RayTracingMode::automatic, RayTracingMode::on, RayTracingMode::off}; + ShaderCompiler::setRayTracingMode(k_modes[static_cast(std::clamp(v, 0, 2))]); + }); + + 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", + .requires_restart = true} + ) + .onChange([](bool enabled) { ShaderCompiler::setTraceRayQuery(enabled); }); } void registerGi(VulkanRenderer& renderer) { @@ -737,9 +1212,11 @@ void registerGi(VulkanRenderer& renderer) { toast::settings::declareBool( "renderer.gi.enabled", true, - {.label = "Radiance cascades", .category = "Global illumination", .description = "World space radiance cascade probes"} + {.label = "Radiance cascades", + .category = "Global illumination", + .description = "World space radiance cascade probes. Preset Custom only"} ) - .onChange([&gi](bool enabled) { gi.enabled.store(enabled, std::memory_order_relaxed); }); + .onChange(presetKnob(&PresetKnobs::gi_enabled, k_same_bool)); toast::settings::declareInt( "renderer.gi.quality", @@ -751,11 +1228,9 @@ void registerGi(VulkanRenderer& renderer) { .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 - ); - }); + .onChange(presetKnob(&PresetKnobs::gi_quality, [](int64_t v) { + return static_cast(std::clamp(v, 0, GiSettings::k_quality_custom)); + })); toast::settings::declareInt( "renderer.gi.update_interval", @@ -957,14 +1432,32 @@ void registerGi(VulkanRenderer& renderer) { }); } +auto presetDrivenValue(std::string_view key) -> std::optional { + const std::lock_guard lock(g_preset.mutex); + if (g_preset.preset >= k_preset_custom) { + return std::nullopt; + } + for (const DrivenSetting& driven : k_driven) { + if (driven.key == key) { + return driven.get(activeKnobs()); + } + } + return std::nullopt; +} + void registerRendererSettings(VulkanRenderer& renderer) { + g_preset.renderer = &renderer; registerDisplay(renderer); registerQuality(renderer); registerVoxels(renderer); registerGi(renderer); registerEnvironment(renderer); registerVolumetrics(renderer); +#if defined(TOAST_DEV_TOOLS) registerDebug(renderer); +#endif + g_preset.ready = true; + applyPreset(); } } diff --git a/engine/src/toast/renderer/renderer_settings.hpp b/engine/src/toast/renderer/renderer_settings.hpp index 61fcce52e..35b384915 100644 --- a/engine/src/toast/renderer/renderer_settings.hpp +++ b/engine/src/toast/renderer/renderer_settings.hpp @@ -5,13 +5,17 @@ */ #pragma once +#include #include #include +#include namespace renderer { class VulkanRenderer; +inline constexpr std::string_view k_hdr_output = "renderer.display.hdr"; + /// F3 in game view flips it inline constexpr std::string_view k_debug_overlay_in_game = "renderer.debug.overlay_in_game"; @@ -21,4 +25,8 @@ TOAST_API void registerRendererStartupSettings(); /// @note Call after the post process passes exist TOAST_API void registerRendererSettings(VulkanRenderer& renderer); +/// Nothing under Custom or for a setting no tier drives +[[nodiscard]] +auto presetDrivenValue(std::string_view key) -> std::optional; + } diff --git a/engine/src/toast/renderer/sdl_output_target.cpp b/engine/src/toast/renderer/sdl_output_target.cpp index a884ab2df..a4d754b47 100644 --- a/engine/src/toast/renderer/sdl_output_target.cpp +++ b/engine/src/toast/renderer/sdl_output_target.cpp @@ -56,11 +56,15 @@ auto SDLOutputTarget::createSurface(const VulkanCore& core, SDL_Window* window) return {core.getInstance(), raw_surface}; } -SDLOutputTarget::SDLOutputTarget(const VulkanCore& core, SDL_Window* window, vk::Extent2D preferred_extent) +SDLOutputTarget::SDLOutputTarget(const VulkanCore& core, SDL_Window* window, vk::Extent2D preferred_extent, bool prefer_hdr) : m_core(&core), m_window(window), m_surface(createSurface(core, window)) { - m_swapchain.emplace(core, m_surface, preferred_extent); + m_swapchain.emplace(core, m_surface, preferred_extent, prefer_hdr); +} + +auto SDLOutputTarget::isHdr() const -> bool { + return m_swapchain->isHdr(); } auto SDLOutputTarget::getExtent() const -> vk::Extent2D { diff --git a/engine/src/toast/renderer/sdl_output_target.hpp b/engine/src/toast/renderer/sdl_output_target.hpp index 7514bf1ff..d037a48cd 100644 --- a/engine/src/toast/renderer/sdl_output_target.hpp +++ b/engine/src/toast/renderer/sdl_output_target.hpp @@ -21,7 +21,7 @@ class SDLOutputTarget final : public IOutputTarget { static auto getRequiredDeviceExtensions() -> std::vector; static auto queryExtent(SDL_Window* window) -> vk::Extent2D; - SDLOutputTarget(const VulkanCore& core, SDL_Window* window, vk::Extent2D preferred_extent); + SDLOutputTarget(const VulkanCore& core, SDL_Window* window, vk::Extent2D preferred_extent, bool prefer_hdr = false); ~SDLOutputTarget() override = default; SDLOutputTarget(const SDLOutputTarget&) = delete; @@ -34,6 +34,8 @@ class SDLOutputTarget final : public IOutputTarget { [[nodiscard]] auto getColorFormat() const -> vk::Format override; [[nodiscard]] + auto isHdr() const -> bool override; + [[nodiscard]] auto getImageCount() const -> uint32_t override; [[nodiscard]] auto getColorImage(uint32_t index) const -> const vk::Image& override; diff --git a/engine/src/toast/renderer/shader_cache.cpp b/engine/src/toast/renderer/shader_cache.cpp index ed2b11f53..04557818b 100644 --- a/engine/src/toast/renderer/shader_cache.cpp +++ b/engine/src/toast/renderer/shader_cache.cpp @@ -2,9 +2,13 @@ #include "shader_compiler.hpp" +#include +#include +#include #include #include #include +#include #include #include #include @@ -32,6 +36,15 @@ auto hashToHex(uint64_t hash) -> std::string { return std::format("{:016x}", hash); } +#if !defined(TOAST_DEV_TOOLS) +/// Loaded only by the debug and grid passes +auto isDevOnlyShader(toast::UID uid) -> bool { + constexpr std::array k_names {"debug_billboard.slang", "debug_shape.slang", "grid.slang"}; + const std::string uri = assets::AssetManager::getURI(uid); + return std::ranges::any_of(k_names, [&uri](std::string_view name) { return uri.ends_with(name); }); +} +#endif + /// Source plus optional device features since TOAST_RAY_QUERY changes the SPIR-V auto sourceHash(const std::string& source) -> uint64_t { return ShaderCache::fnv1a(source.data(), source.size()) ^ (ShaderCompiler::featureHash() * 0x100000001b3ull); @@ -174,6 +187,29 @@ auto ShaderCache::compile(toast::UID uid, const std::string& source) -> EntryPtr ZoneScoped; const std::string source_uri = assets::AssetManager::getURI(uid); + TOAST_INFO("Render", "Compiling shader {}", source_uri); + m_compiled_count.fetch_add(1, std::memory_order_relaxed); + + struct ActiveCompile { + ShaderCache& cache; + std::string name; + + ActiveCompile(ShaderCache& cache, std::string_view uri) : cache(cache), name(uri.substr(uri.find_last_of('/') + 1)) { + const std::lock_guard lock(cache.m_active_mutex); + cache.m_active_compiles.push_back(name); + } + + ~ActiveCompile() { + const std::lock_guard lock(cache.m_active_mutex); + std::erase(cache.m_active_compiles, name); + } + + ActiveCompile(const ActiveCompile&) = delete; + auto operator=(const ActiveCompile&) -> ActiveCompile& = delete; + }; + + ActiveCompile active(*this, source_uri); + auto compiled = ShaderCompiler::compile(uid, source, source_uri); if (compiled.spirv.empty()) { TOAST_ERROR("Render", "Compilation failed for shader {} ({})", uid.get(), source_uri); @@ -256,28 +292,80 @@ auto ShaderCache::loadOrCompile(toast::UID uid) -> EntryPtr { return compile(uid, *source); } -void ShaderCache::compileAllAtStartup() { +void ShaderCache::compileAllAtStartup(const std::function& on_progress) { ZoneScoped; const auto shader_uids = assets::listByType("shader"); + TOAST_INFO("Render", "Checking {} shaders, compiling any that changed", shader_uids.size()); + const auto started = std::chrono::steady_clock::now(); + const size_t compiled_before = m_compiled_count.load(std::memory_order_relaxed); - std::vector> jobs; - jobs.reserve(shader_uids.size()); + // Import only modules have no entry point and the biggest sources go first + std::vector> entries; + size_t module_count = 0; for (const auto uid : shader_uids) { - jobs.push_back(toast::ThreadPool::push([this, uid] { - if (acquire(uid) != nullptr) { - return true; + const auto source = sourceOf(uid); + if (source.has_value() && !source->contains("[shader(")) { + ++module_count; + continue; + } +#if !defined(TOAST_DEV_TOOLS) + if (isDevOnlyShader(uid)) { + continue; + } +#endif + entries.emplace_back(uid, source ? source->size() : 0); + } + m_module_count.store(module_count, std::memory_order_relaxed); + std::ranges::stable_sort(entries, std::ranges::greater {}, &std::pair::second); + + std::atomic finished = 0; + std::vector> jobs; + jobs.reserve(entries.size()); + for (const auto& [uid, size] : entries) { + jobs.push_back(toast::ThreadPool::push([this, uid, &finished] { + const bool ok = acquire(uid) != nullptr; + if (!ok) { + TOAST_ERROR("Render", "Failed to load or compile shader {}", uid.get()); } - TOAST_ERROR("Render", "Failed to load or compile shader {}", uid.get()); - return false; + finished.fetch_add(1, std::memory_order_relaxed); + return ok; })); } + const auto report = [&] { + if (!on_progress) { + return; + } + StartupProgress progress; + progress.total = entries.size(); + progress.finished = finished.load(std::memory_order_relaxed); + progress.compiled = m_compiled_count.load(std::memory_order_relaxed) - compiled_before; + { + const std::lock_guard lock(m_active_mutex); + progress.compiling = m_active_compiles; + } + on_progress(progress); + }; + size_t loaded = 0; for (auto& job : jobs) { + while (job.wait_for(std::chrono::milliseconds(30)) != std::future_status::ready) { + report(); + } loaded += job.get() ? 1 : 0; } - - TOAST_INFO("Render", "Shader cache ready: {}/{} shaders loaded", loaded, shader_uids.size()); + report(); + + const std::chrono::duration elapsed = std::chrono::steady_clock::now() - started; + TOAST_INFO( + "Render", + "Shader cache ready: {}/{} shaders loaded, {} import only modules, {} compiled in {:.1f} s", + loaded, + entries.size(), + module_count, + m_compiled_count.load(std::memory_order_relaxed) - compiled_before, + elapsed.count() + ); } auto ShaderCache::acquire(toast::UID uid) -> EntryPtr { diff --git a/engine/src/toast/renderer/shader_cache.hpp b/engine/src/toast/renderer/shader_cache.hpp index d7b69bffe..9668cb3e4 100644 --- a/engine/src/toast/renderer/shader_cache.hpp +++ b/engine/src/toast/renderer/shader_cache.hpp @@ -8,7 +8,9 @@ #include "shader_reflection.hpp" +#include #include +#include #include #include #include @@ -47,7 +49,16 @@ class TOAST_API ShaderCache { static auto get() -> ShaderCache&; - void compileAllAtStartup(); + struct StartupProgress { + size_t total = 0; + size_t finished = 0; + /// Empty while everything loads from the disk cache + std::vector compiling; + size_t compiled = 0; + }; + + /// Blocks and reports from the caller every 30 ms + void compileAllAtStartup(const std::function& on_progress = {}); /// Thread safe and concurrent calls for one shader share a compile auto acquire(toast::UID uid) -> std::shared_ptr; @@ -79,6 +90,10 @@ class TOAST_API ShaderCache { std::mutex m_mutex; std::unordered_map m_entries; std::unordered_map> m_in_flight; + std::atomic m_compiled_count {0}; + std::atomic m_module_count {0}; + std::mutex m_active_mutex; + std::vector m_active_compiles; std::unordered_map> m_sources; std::unordered_map> m_reverse_deps; nlohmann::json m_hash_index; diff --git a/engine/src/toast/renderer/shader_compiler.cpp b/engine/src/toast/renderer/shader_compiler.cpp index 8c53df62a..144e008cc 100644 --- a/engine/src/toast/renderer/shader_compiler.cpp +++ b/engine/src/toast/renderer/shader_compiler.cpp @@ -79,6 +79,14 @@ class SlangGlobalSessionPool { m_available.notify_one(); } + void setLimit(size_t limit) { + { + const std::lock_guard lock(m_mutex); + m_limit = std::max(limit, 1); + } + m_available.notify_all(); + } + private: SlangGlobalSessionPool() : m_limit(std::clamp(std::thread::hardware_concurrency() / 2, 1, 4)) { } @@ -89,8 +97,19 @@ class SlangGlobalSessionPool { size_t m_limit; }; +std::atomic ray_tracing_mode = RayTracingMode::automatic; std::atomic_bool ray_query_available = false; +std::atomic_bool full_optimization = false; +std::atomic_bool trace_ray_query_requested = true; + +} +void ShaderCompiler::setRayTracingMode(RayTracingMode mode) { + ray_tracing_mode.store(mode, std::memory_order_relaxed); +} + +auto ShaderCompiler::rayTracingMode() -> RayTracingMode { + return ray_tracing_mode.load(std::memory_order_relaxed); } void ShaderCompiler::setRayQueryAvailable(bool available) { @@ -101,16 +120,40 @@ auto ShaderCompiler::isRayQueryAvailable() -> bool { return ray_query_available.load(std::memory_order_relaxed); } +void ShaderCompiler::setTraceRayQuery(bool enabled) { + trace_ray_query_requested.store(enabled, std::memory_order_relaxed); +} + +auto ShaderCompiler::traceRayQuery() -> bool { + static const bool fixed = isRayQueryAvailable() && trace_ray_query_requested.load(std::memory_order_relaxed); + return fixed; +} + +void ShaderCompiler::setFullOptimization(bool enabled) { + full_optimization.store(enabled, std::memory_order_relaxed); +} + +void ShaderCompiler::setMaxParallelCompiles(size_t count) { + SlangGlobalSessionPool::get().setLimit(count); +} + auto ShaderCompiler::featureHash() -> uint64_t { // Debug compiles unoptimized with debug info so a release entry in the shared cache must read as stale and the reverse #if defined(NDEBUG) - constexpr uint64_t k_build_profile = 0x0ull; + const uint64_t k_build_profile = full_optimization.load(std::memory_order_relaxed) ? 0x0ull : 0x0f7d1e5ca1ab1e00ull; #else constexpr uint64_t k_build_profile = 0xd3b09c0de5a11e01ull; #endif // TOAST_REVERSED_Z changes the SPIR-V so the other convention must read as stale constexpr uint64_t k_depth_convention = depth::k_reversed_z ? 0x5eed2e7e45edull : 0x0ull; - return (ShaderCompiler::isRayQueryAvailable() ? 0x9e3779b97f4a7c15ull : 0x0ull) ^ k_build_profile ^ k_depth_convention; + const uint64_t k_trace_walk = traceRayQuery() ? 0x0ull : 0x7a11c0de5eedull; +#if defined(TOAST_DEV_TOOLS) + constexpr uint64_t k_dev_tools = 0xde7700150000ull; +#else + constexpr uint64_t k_dev_tools = 0x0ull; +#endif + return (ShaderCompiler::isRayQueryAvailable() ? 0x9e3779b97f4a7c15ull : 0x0ull) ^ k_build_profile ^ k_depth_convention ^ + k_trace_walk ^ k_dev_tools; } static auto createSession(slang::IGlobalSession* global_session) -> Slang::ComPtr { @@ -139,12 +182,14 @@ static auto createSession(slang::IGlobalSession* global_session) -> Slang::ComPt compiler_options.push_back(dbg_entry); #else - TOAST_INFO("Render", "Configuring Slang for RELEASE: Maximum optimizations enabled."); + const bool full = full_optimization.load(std::memory_order_relaxed); + TOAST_TRACE("Render", "Configuring Slang for RELEASE with {} optimization", full ? "maximal" : "default"); slang::CompilerOptionEntry opt_entry {}; opt_entry.name = slang::CompilerOptionName::Optimization; opt_entry.value.kind = slang::CompilerOptionValueKind::Int; - opt_entry.value.intValue0 = SlangOptimizationLevel::SLANG_OPTIMIZATION_LEVEL_MAXIMAL; + opt_entry.value.intValue0 = + full ? SlangOptimizationLevel::SLANG_OPTIMIZATION_LEVEL_MAXIMAL : SlangOptimizationLevel::SLANG_OPTIMIZATION_LEVEL_DEFAULT; compiler_options.push_back(opt_entry); #endif @@ -157,6 +202,25 @@ static auto createSession(slang::IGlobalSession* global_session) -> Slang::ComPt ray_query_entry.value.stringValue1 = ShaderCompiler::isRayQueryAvailable() ? "1" : "0"; compiler_options.push_back(ray_query_entry); + slang::CompilerOptionEntry trace_entry {}; + trace_entry.name = slang::CompilerOptionName::MacroDefine; + trace_entry.value.kind = slang::CompilerOptionValueKind::String; + trace_entry.value.stringValue0 = "TOAST_TRACE_RAY_QUERY"; + trace_entry.value.stringValue1 = ShaderCompiler::traceRayQuery() ? "1" : "0"; + compiler_options.push_back(trace_entry); + + // Always defined to 0 or 1 + slang::CompilerOptionEntry dev_tools_entry {}; + dev_tools_entry.name = slang::CompilerOptionName::MacroDefine; + dev_tools_entry.value.kind = slang::CompilerOptionValueKind::String; + dev_tools_entry.value.stringValue0 = "TOAST_DEV_TOOLS"; +#if defined(TOAST_DEV_TOOLS) + dev_tools_entry.value.stringValue1 = "1"; +#else + dev_tools_entry.value.stringValue1 = "0"; +#endif + compiler_options.push_back(dev_tools_entry); + // Always defined to 0 or 1 slang::CompilerOptionEntry reversed_z_entry {}; reversed_z_entry.name = slang::CompilerOptionName::MacroDefine; @@ -201,9 +265,17 @@ void logDiagnostics(const Slang::ComPtr& diagnostics, std::string_ const void* diag_ptr = diagnostics->getBufferPointer(); const size_t diag_size = diagnostics->getBufferSize(); if (diag_ptr && diag_size > 0) { - const char* diag_c = reinterpret_cast(diag_ptr); + const char* diag_c = static_cast(diag_ptr); const std::string diag_str(diag_c, diag_c + diag_size); - TOAST_MESSAGE(logging::Severity::error, "Render", std::format("Slang diagnostics for '{}'", source_name), diag_str); + + // Default to warning + logging::Severity severity = logging::Severity::warning; + + if (diag_str.contains(": error ")) { + severity = logging::Severity::error; + } + + TOAST_MESSAGE(severity, "Render", std::format("Slang diagnostics for '{}'", source_name), diag_str); } } @@ -260,7 +332,7 @@ auto compileModule(std::string_view module_name, std::string_view source_path, s return {}; } - const auto* bytes = reinterpret_cast(spirv_blob->getBufferPointer()); + const auto* bytes = static_cast(spirv_blob->getBufferPointer()); CompiledShaderCode result; result.spirv.assign(bytes, bytes + spirv_blob->getBufferSize()); diff --git a/engine/src/toast/renderer/shader_compiler.hpp b/engine/src/toast/renderer/shader_compiler.hpp index 9ce2b50b9..fc4037ca5 100644 --- a/engine/src/toast/renderer/shader_compiler.hpp +++ b/engine/src/toast/renderer/shader_compiler.hpp @@ -21,8 +21,21 @@ struct CompiledShaderCode { std::vector dependencies; }; +/// Mirrors the options of renderer.quality.ray_tracing +enum class RayTracingMode : uint8_t { + automatic, + on, + off, +}; + class ShaderCompiler { public: + /// @note Call before the Vulkan device exists + static void setRayTracingMode(RayTracingMode mode); + + [[nodiscard]] + static auto rayTracingMode() -> RayTracingMode; + /// Thread safe static auto compile(toast::UID uid, std::string_view source, std::string_view source_uri) -> CompiledShaderCode; @@ -32,6 +45,20 @@ class ShaderCompiler { [[nodiscard]] static auto isRayQueryAvailable() -> bool; + /// @note Call before any compilation + static void setTraceRayQuery(bool enabled); + + /// Fixed at the first call until a restart + [[nodiscard]] + static auto traceRayQuery() -> bool; + + /// Player only + /// @note Call before any compilation + static void setFullOptimization(bool enabled); + + /// Each compile holds a Slang session of up to 1 GB + static void setMaxParallelCompiles(size_t count); + [[nodiscard]] static auto featureHash() -> uint64_t; }; diff --git a/engine/src/toast/renderer/voxel_debug.cpp b/engine/src/toast/renderer/voxel_debug.cpp index 0e0178233..705000d48 100644 --- a/engine/src/toast/renderer/voxel_debug.cpp +++ b/engine/src/toast/renderer/voxel_debug.cpp @@ -1,3 +1,5 @@ +#if defined(TOAST_DEV_TOOLS) + #include "voxel_debug.hpp" #include "depth_convention.hpp" @@ -684,3 +686,5 @@ void Monitor::drawPanel(const VulkanRenderer::RenderFrame& frame) const { } } + +#endif diff --git a/engine/src/toast/renderer/vulkan_core.cpp b/engine/src/toast/renderer/vulkan_core.cpp index bb3af8e90..a12867d91 100644 --- a/engine/src/toast/renderer/vulkan_core.cpp +++ b/engine/src/toast/renderer/vulkan_core.cpp @@ -7,6 +7,8 @@ #include "shader_compiler.hpp" #include +#include +#include #include #include #include @@ -28,7 +30,7 @@ #include #include -#if defined(_WIN32) +#if defined(_WIN32) && defined(TOAST_DEV_TOOLS) #include #include #endif @@ -56,7 +58,7 @@ auto vulkanCorePipelineCacheMatches(std::span data, const vk::Phy /// Injected before main so it is known before the instance exists auto vulkanCoreAttachedGraphicsDebugger() -> std::optional { -#if defined(_WIN32) +#if defined(_WIN32) && defined(TOAST_DEV_TOOLS) if (GetModuleHandleA("renderdoc.dll") != nullptr) { return "RenderDoc"; } @@ -65,10 +67,10 @@ auto vulkanCoreAttachedGraphicsDebugger() -> std::optional { return "Nsight Graphics"; } #elif defined(__linux__) - if (void* mod = dlopen("librenderdoc.so", RTLD_NOLOAD | RTLD_LAZY); mod != nullptr) { - dlclose(mod); - return "RenderDoc"; - } +#elif defined(__linux__) && defined(TOAST_DEV_TOOLS) + dlclose(mod); + return "RenderDoc"; +} #endif return std::nullopt; } @@ -307,6 +309,51 @@ auto joinRequiredExtensions(std::span extensions) -> std::str auto deviceNameString(const vk::PhysicalDeviceProperties& props) -> std::string { return {props.deviceName.data()}; } + +struct RayTracingVerdict { + bool hardware = false; + std::string_view reason; +}; + +/// Drivers emulate ray query in compute on GPUs without RT cores +auto judgeHardwareRayTracing(const vk::PhysicalDeviceProperties& props, bool mesh_shader_extension) -> RayTracingVerdict { + constexpr uint32_t k_vendor_nvidia = 0x10DE; + constexpr uint32_t k_vendor_amd = 0x1002; + constexpr uint32_t k_vendor_intel = 0x8086; + + if (const char* forced = std::getenv("TOAST_RAY_QUERY"); forced != nullptr && forced[0] != '\0') { + return {forced[0] != '0', "forced by TOAST_RAY_QUERY"}; + } + switch (ShaderCompiler::rayTracingMode()) { + case RayTracingMode::off: return {false, "turned off in the ray tracing setting"}; + case RayTracingMode::on: return {true, "forced on in the ray tracing setting"}; + case RayTracingMode::automatic: break; + } + if (props.deviceType == vk::PhysicalDeviceType::eCpu) { + return {false, "software device"}; + } + + if (props.vendorID == k_vendor_nvidia) { + std::string name = deviceNameString(props); + std::ranges::transform(name, name.begin(), [](unsigned char c) { return static_cast(std::toupper(c)); }); + // Mesh shaders start at Turing + if (!mesh_shader_extension) { + return {false, "NVIDIA GPU older than Turing has no RT cores, the driver emulates ray query"}; + } + // GTX 16 and MX are Turing without RT cores + if (name.contains("GTX") || name.contains(" MX")) { + return {false, "NVIDIA GTX and MX parts have no RT cores, the driver emulates ray query"}; + } + return {true, "NVIDIA RT cores"}; + } + if (props.vendorID == k_vendor_amd) { + return {true, "AMD drivers only expose ray query on RDNA2 and newer"}; + } + if (props.vendorID == k_vendor_intel) { + return {true, "Intel drivers only expose ray query on Arc class hardware"}; + } + return {true, "unknown vendor, trusting the driver"}; +} } // namespace auto DeviceScore::toString() const noexcept -> std::string { @@ -332,7 +379,7 @@ VulkanCore::VulkanCore( m_validation_enabled = false; #endif -#if defined(_WIN32) +#if defined(_WIN32) && defined(TOAST_DEV_TOOLS) initializeNsightActivity(); #endif @@ -349,9 +396,11 @@ VulkanCore::VulkanCore( vk::ApplicationInfo app_info("SUPER DUPER TOASTY GAME", 1, "TOAST ENGINE", 1, VK_API_VERSION_1_4); std::vector layers; +#ifdef DEBUG if (m_validation_enabled) { layers.push_back("VK_LAYER_KHRONOS_validation"); } +#endif std::vector extensions(required_instance_extensions.begin(), required_instance_extensions.end()); m_debug_utils_enabled = checkInstanceExtensionSupport(VK_EXT_DEBUG_UTILS_EXTENSION_NAME); @@ -360,6 +409,10 @@ VulkanCore::VulkanCore( } TOAST_INFO("Render", "Debug object names: {}", m_debug_utils_enabled ? "enabled" : "disabled"); + if (checkInstanceExtensionSupport(VK_EXT_SWAPCHAIN_COLOR_SPACE_EXTENSION_NAME)) { + extensions.push_back(VK_EXT_SWAPCHAIN_COLOR_SPACE_EXTENSION_NAME); + } + vk::InstanceCreateInfo instance_ci({}, &app_info, layers, extensions); #ifdef TOAST_SYNC_VALIDATION @@ -391,27 +444,27 @@ VulkanCore::VulkanCore( createLogicalDeviceAndAllocator(required_device_extensions); m_pipeline_cache = vk::raii::PipelineCache(m_device, vk::PipelineCacheCreateInfo {}); -#if defined(_WIN32) +#if defined(TOAST_DEV_TOOLS) && defined(_WIN32) if (HMODULE mod = GetModuleHandleA("renderdoc.dll")) { pRENDERDOC_GetAPI renderdoc_get_api = reinterpret_cast(GetProcAddress(mod, "RENDERDOC_GetAPI")); renderdoc_get_api(eRENDERDOC_API_Version_1_6_0, reinterpret_cast(&rdoc_api)); TOAST_INFO("Render", "RenderDoc API detected"); } #elif defined(__linux__) - if (auto* mod = dlopen("librenderdoc.so", RTLD_NOLOAD | RTLD_LAZY)) { - auto* renderdoc_get_api = reinterpret_cast(dlsym(mod, "RENDERDOC_GetAPI")); - if (renderdoc_get_api) { - renderdoc_get_api(eRENDERDOC_API_Version_1_6_0, reinterpret_cast(&rdoc_api)); - TOAST_INFO("Render", "RenderDoc API detected"); - } - dlclose(mod); +#elif defined(TOAST_DEV_TOOLS) && defined(__linux__) + auto* renderdoc_get_api = reinterpret_cast(dlsym(mod, "RENDERDOC_GetAPI")); + if (renderdoc_get_api) { + renderdoc_get_api(eRENDERDOC_API_Version_1_6_0, reinterpret_cast(&rdoc_api)); + TOAST_INFO("Render", "RenderDoc API detected"); } + dlclose(mod); +} #endif // Not activating Nsight GPU trace here since it blocks until the host attaches } -#if defined(_WIN32) +#if defined(_WIN32) && defined(TOAST_DEV_TOOLS) namespace { auto nsightResultName(NGFX_Result result) -> std::string_view { @@ -655,6 +708,8 @@ void VulkanCore::createLogicalDeviceAndAllocator(std::span re enabled_features.depthClamp = m_depth_clamp_supported ? VK_TRUE : VK_FALSE; m_dual_source_blend_supported = device_features.dualSrcBlend == VK_TRUE; enabled_features.dualSrcBlend = m_dual_source_blend_supported ? VK_TRUE : VK_FALSE; + // volumetrics.slang declares the fog ambient buffer writable and its fragment stages read it + enabled_features.fragmentStoresAndAtomics = device_features.fragmentStoresAndAtomics; // 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 @@ -773,7 +828,21 @@ void VulkanCore::createLogicalDeviceAndAllocator(std::span re features_present = as_probe.accelerationStructure == VK_TRUE && rq_probe.rayQuery == VK_TRUE; } - m_ray_tracing_supported = extensions_present && features_present; + const bool driver_exposes_ray_query = extensions_present && features_present; + RayTracingVerdict verdict {}; + if (driver_exposes_ray_query) { + verdict = judgeHardwareRayTracing(m_physical_device.getProperties(), has_extension(VK_EXT_MESH_SHADER_EXTENSION_NAME)); + if (!verdict.hardware) { + TOAST_WARN( + "Render", + "Ray query is advertised but not used: {}. The ray tracing setting or TOAST_RAY_QUERY=1 forces it", + verdict.reason + ); + } + } + m_ray_tracing_startup_mode = ShaderCompiler::rayTracingMode(); + m_ray_tracing_supported = driver_exposes_ray_query && verdict.hardware; + m_ray_tracing_reason = driver_exposes_ray_query ? verdict.reason : "the driver does not expose ray query"; // Before the shader cache compiles anything ShaderCompiler::setRayQueryAvailable(m_ray_tracing_supported); @@ -790,14 +859,18 @@ void VulkanCore::createLogicalDeviceAndAllocator(std::span re m_as_scratch_alignment = as_props.minAccelerationStructureScratchOffsetAlignment; TOAST_INFO( - "Render", "Ray query supported; acceleration structures available (scratch alignment {} bytes)", m_as_scratch_alignment + "Render", + "Ray query supported ({}); acceleration structures available (scratch alignment {} bytes)", + verdict.reason, + m_as_scratch_alignment ); } else { TOAST_INFO( "Render", - "Ray query unavailable (extensions={}, features={}); every RT-backed feature keeps its raster path", + "Ray query unavailable (extensions={}, features={}, hardware={}); every RT-backed feature keeps its raster path", extensions_present, - features_present + features_present, + verdict.hardware ); } } @@ -1205,6 +1278,9 @@ auto VulkanCore::checkInstanceExtensionSupport(std::string_view extension) -> bo } auto VulkanCore::checkValidationLayerSupport() -> bool { +#ifndef DEBUG + return false; +#else { uint32_t layer_count; vkEnumerateInstanceLayerProperties(&layer_count, nullptr); @@ -1216,6 +1292,7 @@ auto VulkanCore::checkValidationLayerSupport() -> bool { return std::string(layer.layerName) == "VK_LAYER_KHRONOS_validation"; }) != available_layers.end(); } +#endif } void VulkanCore::loadPipelineCache() const { diff --git a/engine/src/toast/renderer/vulkan_core.hpp b/engine/src/toast/renderer/vulkan_core.hpp index 727026157..9eba8a5e4 100644 --- a/engine/src/toast/renderer/vulkan_core.hpp +++ b/engine/src/toast/renderer/vulkan_core.hpp @@ -19,6 +19,8 @@ namespace renderer { +enum class RayTracingMode : uint8_t; + enum class NsightMode : uint8_t { none, graphics_capture, @@ -116,6 +118,16 @@ class VulkanCore { return m_ray_tracing_supported; } + [[nodiscard]] + auto rayTracingReason() const noexcept -> std::string_view { + return m_ray_tracing_reason; + } + + [[nodiscard]] + auto rayTracingStartupMode() const noexcept -> RayTracingMode { + return m_ray_tracing_startup_mode; + } + /// VK_EXT_mesh_shader with task shaders and the output limits the voxel meshlets need [[nodiscard]] auto isMeshShaderSupported() const noexcept -> bool { @@ -177,7 +189,7 @@ class VulkanCore { return m_nsight_mode; } -#if defined(_WIN32) +#if defined(_WIN32) && defined(TOAST_DEV_TOOLS) /// @warning Blocks until the Nsight Graphics host attaches so F12 handler only void activateNsightGpuTraceIfNeeded() const; #endif @@ -294,6 +306,8 @@ class VulkanCore { bool m_texture_sharing_supported = false; bool m_ray_tracing_supported = false; + std::string_view m_ray_tracing_reason = "not probed"; + RayTracingMode m_ray_tracing_startup_mode {}; bool m_mesh_shader_supported = false; uint32_t m_mesh_multiview_views = 0; bool m_depth_clamp_supported = false; diff --git a/engine/src/toast/renderer/vulkan_renderer.cpp b/engine/src/toast/renderer/vulkan_renderer.cpp index a47dcd8b9..d9241a17e 100644 --- a/engine/src/toast/renderer/vulkan_renderer.cpp +++ b/engine/src/toast/renderer/vulkan_renderer.cpp @@ -23,6 +23,7 @@ #include "passes/voxel_pass.hpp" #include "ray_tracing_scene.hpp" #include "renderer_settings.hpp" +#include "shader_compiler.hpp" #include "shadow_slots.hpp" #include "skinned_blas_pool.hpp" #include "voxel_debug.hpp" @@ -51,6 +52,7 @@ #include #include #include +#include #include #include #include @@ -65,6 +67,7 @@ #include #include #include +#include #include #include #include @@ -75,7 +78,7 @@ #include #include -#if defined(_WIN32) +#if defined(_WIN32) && defined(TOAST_DEV_TOOLS) #include #include #endif @@ -795,6 +798,27 @@ auto VulkanRenderer::applyResize(vk::Extent2D extent) -> void { m_frame_cv.notify_one(); } +void VulkanRenderer::setRenderScale(float scale) { + scale = std::clamp(scale, 0.5f, 1.0f); + if (m_render_scale.exchange(scale, std::memory_order_relaxed) != scale) { + applyResize(m_output_target->getExtent()); + } +} + +void VulkanRenderer::requestSettingValue(std::string key, toast::settings::Value value) { + const std::lock_guard lock(m_setting_request_mutex); + m_setting_requests.emplace_back(std::move(key), std::move(value)); +} + +auto VulkanRenderer::renderExtent() const -> vk::Extent2D { + const vk::Extent2D output = m_output_target->getExtent(); + const float scale = m_applied_render_scale.load(std::memory_order_relaxed); + return { + std::max(1u, static_cast(std::lround(static_cast(output.width) * scale))), + std::max(1u, static_cast(std::lround(static_cast(output.height) * scale))), + }; +} + void VulkanRenderer::stop() { ZoneScoped; const bool was_running = m_running.exchange(false, std::memory_order_acq_rel); @@ -898,6 +922,7 @@ VulkanRenderer::VulkanRenderer(const VulkanCore& core, std::unique_ptr([this](const auto& e) { m_imgui_mouse_pos = {e.x, e.y}; return false; @@ -931,6 +956,12 @@ VulkanRenderer::VulkanRenderer(const VulkanCore& core, std::unique_ptr([this](const auto& e) { + setDisplayHdr(e.sdr_white, e.headroom); + return false; + }); m_render_mode_listener.subscribe([this](const auto& e) { m_render_mode = e.mode; @@ -1054,7 +1085,7 @@ auto VulkanRenderer::createDepthResources() -> void { TOAST_CRITICAL("Render", "Toast Engine Error: VulkanRenderer requires a valid depth format!"); } - const auto extent = m_output_target->getExtent(); + const auto extent = renderExtent(); if (extent.width == 0 || extent.height == 0) { TOAST_CRITICAL("Render", "Toast Engine Error: VulkanRenderer requires a non-zero output extent for depth resources!"); } @@ -1106,6 +1137,22 @@ auto VulkanRenderer::createDepthResources() -> void { setDebugName(*m_core, **m_depth_resources.view, "VulkanRenderer DepthImageView"); m_depth_resources.state.reset(**m_depth_resources.image, depthAttachmentRange(m_depth_format)); + + m_output_depth.view.reset(); + m_output_depth.image.reset(); + const vk::Extent2D output_extent = m_output_target->getExtent(); + if (output_extent != extent) { + vk::ImageCreateInfo output_ci = image_ci; + output_ci.extent = vk::Extent3D {output_extent.width, output_extent.height, 1}; + output_ci.usage = vk::ImageUsageFlagBits::eDepthStencilAttachment | vk::ImageUsageFlagBits::eSampled; + m_output_depth.image.emplace(m_core->getAllocator().createImage(output_ci, allocation_ci)); + setDebugName(*m_core, **m_output_depth.image, "VulkanRenderer OutputDepthImage"); + vk::ImageViewCreateInfo output_view_ci = view_ci; + output_view_ci.image = **m_output_depth.image; + m_output_depth.view.emplace(m_core->getDevice(), output_view_ci); + setDebugName(*m_core, **m_output_depth.view, "VulkanRenderer OutputDepthView"); + m_output_depth.state.reset(**m_output_depth.image, depthAttachmentRange(m_depth_format)); + } TOAST_TRACE( "Render", "Depth resources created at {}x{} with format {}", extent.width, extent.height, vk::to_string(m_depth_format) ); @@ -1113,7 +1160,7 @@ auto VulkanRenderer::createDepthResources() -> void { void VulkanRenderer::createSceneColorResources() { ZoneScoped; - const auto extent = m_output_target->getExtent(); + const auto extent = renderExtent(); if (extent.width == 0 || extent.height == 0) { TOAST_CRITICAL("Render", "Toast Engine Error: VulkanRenderer requires a non-zero output extent for the scene target!"); } @@ -1251,11 +1298,23 @@ void VulkanRenderer::createPresentResources() { config.depth_write = false; m_present_pipeline.rebuild(*m_core, config); + VulkanPipeline::Config depth_config = config; + depth_config.debug_name = "DepthUpscale"; + depth_config.fragment_entry = "depthMain"; + depth_config.depth_only = true; + depth_config.depth_fragment = true; + depth_config.depth_test = true; + depth_config.depth_write = true; + depth_config.depth_compare = vk::CompareOp::eAlways; + m_depth_upscale_pipeline.rebuild(*m_core, depth_config); + const auto& device = m_core->getDevice(); const auto sampler_ci = linearClampSamplerInfo(); m_present_sampler = vk::raii::Sampler(device, sampler_ci); setDebugName(*m_core, *m_present_sampler, "VulkanRenderer PresentSampler"); + m_depth_upscale_sampler = vk::raii::Sampler(device, nearestClampSamplerInfo()); + setDebugName(*m_core, *m_depth_upscale_sampler, "VulkanRenderer DepthUpscaleSampler"); const auto& layouts = m_present_layout.getDescriptorSetLayouts(); if (layouts.empty()) { @@ -1264,13 +1323,62 @@ void VulkanRenderer::createPresentResources() { const vk::DescriptorSetLayout set_layout = *layouts[0]; m_present_sets.clear(); + m_depth_upscale_sets.clear(); m_present_bound_views.assign(k_frames_in_flight, vk::ImageView {}); for (uint32_t i = 0; i < k_frames_in_flight; ++i) { const vk::DescriptorSetAllocateInfo alloc_info(*m_descriptor_pool, 1, &set_layout); auto allocated = device.allocateDescriptorSets(alloc_info); m_present_sets.push_back(std::move(allocated[0])); setDebugName(*m_core, *m_present_sets[i], std::format("VulkanRenderer PresentSet[{}]", i)); + auto depth_set = device.allocateDescriptorSets(alloc_info); + m_depth_upscale_sets.push_back(std::move(depth_set[0])); + setDebugName(*m_core, *m_depth_upscale_sets[i], std::format("VulkanRenderer DepthUpscaleSet[{}]", i)); } + m_depth_upscale_bound.assign(k_frames_in_flight, vk::ImageView {}); +} + +void VulkanRenderer::recordDepthUpscale(vk::CommandBuffer cmd, const vk::Rect2D& area) { + ZoneScoped; + const vk::ImageView scene_depth = **m_depth_resources.view; + if (m_depth_upscale_bound[m_current_frame] != scene_depth) { + const vk::DescriptorImageInfo image_info(*m_depth_upscale_sampler, scene_depth, vk::ImageLayout::eDepthReadOnlyOptimal); + const vk::WriteDescriptorSet write( + *m_depth_upscale_sets[m_current_frame], 0, 0, 1, vk::DescriptorType::eCombinedImageSampler, &image_info + ); + m_core->getDevice().updateDescriptorSets(write, {}); + m_depth_upscale_bound[m_current_frame] = scene_depth; + } + sync::transition(cmd, m_output_depth.state, sync::Usage::depth_attachment); + + vk::RenderingAttachmentInfo depth_info {}; + depth_info.imageView = **m_output_depth.view; + depth_info.imageLayout = vk::ImageLayout::eDepthAttachmentOptimal; + depth_info.loadOp = vk::AttachmentLoadOp::eDontCare; + depth_info.storeOp = vk::AttachmentStoreOp::eStore; + + vk::RenderingInfo info {}; + info.renderArea = area; + info.layerCount = 1; + info.pDepthAttachment = &depth_info; + cmd.beginRendering(info); + cmd.setViewport( + 0, + std::array { + vk::Viewport(0.0f, 0.0f, static_cast(area.extent.width), static_cast(area.extent.height), 0.0f, 1.0f) + } + ); + cmd.setScissor(0, std::array {area}); + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, m_depth_upscale_pipeline.getPipeline()); + cmd.bindDescriptorSets( + vk::PipelineBindPoint::eGraphics, + *m_present_layout.getPipelineLayout(), + 0, + std::array {*m_depth_upscale_sets[m_current_frame]}, + {} + ); + cmd.draw(3, 1, 0, 0); + cmd.endRendering(); + sync::transition(cmd, m_output_depth.state, sync::Usage::depth_sampled); } void VulkanRenderer::createFrameResources() { @@ -1503,6 +1611,125 @@ void VulkanRenderer::createDefaultCubemap() { #pragma endregion +#if defined(TOAST_DEV_TOOLS) +namespace { + +/// Logs the scopes that grew when a frame runs far past the usual GPU time +void reportGpuSpike(const GpuTimer& timer) { + using clock = std::chrono::steady_clock; + constexpr size_t k_history = 120; + constexpr double k_spike_ratio = 2.0; + constexpr double k_min_spike_ms = 6.0; + constexpr double k_average_blend = 0.05; + constexpr auto k_report_every = std::chrono::seconds(1); + + // Render thread only + static uint64_t last_serial = 0; + static std::array history {}; + static size_t filled = 0; + static size_t cursor = 0; + static std::unordered_map averages; + static clock::time_point last_report {}; + static uint32_t spikes_since_report = 0; + + const GpuTimer::Timings& timings = timer.last(); + if (!timings.valid || timer.frameSerial() == last_serial) { + return; + } + last_serial = timer.frameSerial(); + + std::array sorted = history; + const size_t count = filled; + double median = 0.0; + if (count >= 30) { + std::nth_element( + sorted.begin(), + sorted.begin() + static_cast(count / 2), + sorted.begin() + static_cast(count) + ); + median = sorted[count / 2]; + } + const bool spike = median > 0.0 && timings.graphics_ms > median * k_spike_ratio && timings.graphics_ms > k_min_spike_ms; + + struct Growth { + std::string name; + double now = 0.0; + double usual = 0.0; + }; + + std::vector growth; + for (const GpuTimer::ScopeTiming& scope : timings.scopes) { + double& average = averages.try_emplace(std::string(scope.name.view()), scope.gpu_ms).first->second; + if (spike) { + growth.push_back({std::string(scope.name.view()), scope.gpu_ms, average}); + } + // Spikes stay out of the average + if (!spike) { + average += (scope.gpu_ms - average) * k_average_blend; + } + } + + if (!spike) { + history[cursor] = static_cast(timings.graphics_ms); + cursor = (cursor + 1) % k_history; + filled = std::min(filled + 1, k_history); + return; + } + + ++spikes_since_report; + const auto now = clock::now(); + if (now - last_report < k_report_every) { + return; + } + last_report = now; + + std::ranges::sort(growth, [](const Growth& a, const Growth& b) { return (a.now - a.usual) > (b.now - b.usual); }); + std::string worst; + for (size_t i = 0; i < std::min(growth.size(), 4); ++i) { + worst += std::format("{}{} {:.1f} ms (usually {:.1f})", i == 0 ? "" : ", ", growth[i].name, growth[i].now, growth[i].usual); + } + TOAST_WARN( + "Render", + "GPU spike {:.1f} ms against a usual {:.1f}, {} spike(s) in the last second. Grew most: {}", + timings.graphics_ms, + median, + spikes_since_report, + worst + ); + spikes_since_report = 0; +} + +} +#endif + +#ifdef TRACY_ENABLE +namespace { + +/// One plot per timed scope, read a frame late behind the fence +void plotGpuTimings(const GpuTimer& timer) { + static uint64_t last_serial = 0; + const GpuTimer::Timings& timings = timer.last(); + if (!timings.valid || timer.frameSerial() == last_serial) { + return; + } + last_serial = timer.frameSerial(); + + // Node based so plot names keep their address + static std::unordered_map plot_names; + TracyPlot("GPU: graphics ms", timings.graphics_ms); + for (const GpuTimer::ScopeTiming& scope : timings.scopes) { + std::string key(scope.name.view()); + auto it = plot_names.find(key); + if (it == plot_names.end()) { + it = plot_names.emplace(key, "GPU: " + key).first; + } + TracyPlot(it->second.c_str(), scope.gpu_ms); + } +} + +} +#endif + void VulkanRenderer::publishCompletedFrames() { ZoneScoped; while (!m_pending_publish.empty()) { @@ -2171,7 +2398,8 @@ auto VulkanRenderer::recordFrame(FrameContext& frame, uint32_t image_index) noex const auto* capture_source = renderingFrame(); const uint32_t capture_size = capture_source != nullptr ? capture_source->capture_extent : 0u; - const vk::Extent2D scene_extent = capture_size > 0 ? vk::Extent2D {capture_size, capture_size} : extent; + const vk::Extent2D render_extent = renderExtent(); + const vk::Extent2D scene_extent = capture_size > 0 ? vk::Extent2D {capture_size, capture_size} : render_extent; const vk::Viewport scene_viewport( 0.0f, 0.0f, static_cast(scene_extent.width), static_cast(scene_extent.height), 0.0f, 1.0f ); @@ -2304,8 +2532,7 @@ auto VulkanRenderer::recordFrame(FrameContext& frame, uint32_t image_index) noex to_copy.image(m_glass_backdrop_depth.state, sync::Usage::transfer_dst); to_copy.flush(cmd); - const vk::Extent2D target_extent = m_output_target->getExtent(); - const vk::Extent3D copy_extent {target_extent.width, target_extent.height, 1}; + const vk::Extent3D copy_extent {render_extent.width, render_extent.height, 1}; const vk::ImageSubresourceLayers color_layers(vk::ImageAspectFlagBits::eColor, 0, 0, 1); const vk::ImageSubresourceLayers depth_layers(vk::ImageAspectFlagBits::eDepth, 0, 0, 1); cmd.copyImage( @@ -2433,13 +2660,21 @@ auto VulkanRenderer::recordFrame(FrameContext& frame, uint32_t image_index) noex } } - if (!capture_frame && m_selection_outline != nullptr && m_depth_resources.view.has_value()) { + const bool upscaled_depth = m_output_depth.view.has_value() && m_depth_upscale_pipeline.isReady() && + m_depth_resources.view.has_value() && m_current_frame < m_depth_upscale_sets.size(); + if (upscaled_depth && !capture_frame) { + const GpuScope scope(m_gpu_timer.get(), m_current_frame, *frame.command_buffer, "Depth upscale"); + recordDepthUpscale(*frame.command_buffer, scissor); + } + DepthResources& overlay_depth = upscaled_depth ? m_output_depth : m_depth_resources; + + if (!capture_frame && m_selection_outline != nullptr && overlay_depth.view.has_value()) { const GpuScope scope(m_gpu_timer.get(), m_current_frame, *frame.command_buffer, "Selection mask"); - m_selection_outline->recordMask(*frame.command_buffer, m_current_frame, **m_depth_resources.view); + m_selection_outline->recordMask(*frame.command_buffer, m_current_frame, **overlay_depth.view); } // The overlay scope depth tests against the scene and its DontCare store counts as a write - barriers.image(m_depth_resources.state, sync::Usage::depth_attachment); + barriers.image(overlay_depth.state, sync::Usage::depth_attachment); const vk::Image image = m_output_target->getColorImage(image_index); sync::Usage& output_usage = m_output_image_usage.at(image_index); @@ -2456,8 +2691,8 @@ auto VulkanRenderer::recordFrame(FrameContext& frame, uint32_t image_index) noex output_attachment_info.clearValue = clear_color; vk::RenderingAttachmentInfo overlay_depth_info {}; - if (m_depth_resources.view.has_value()) { - overlay_depth_info.imageView = **m_depth_resources.view; + if (overlay_depth.view.has_value()) { + overlay_depth_info.imageView = **overlay_depth.view; overlay_depth_info.imageLayout = vk::ImageLayout::eDepthAttachmentOptimal; overlay_depth_info.loadOp = vk::AttachmentLoadOp::eLoad; overlay_depth_info.storeOp = vk::AttachmentStoreOp::eDontCare; @@ -2468,7 +2703,7 @@ auto VulkanRenderer::recordFrame(FrameContext& frame, uint32_t image_index) noex output_rendering_info.layerCount = 1; output_rendering_info.colorAttachmentCount = 1; output_rendering_info.pColorAttachments = &output_attachment_info; - if (m_depth_resources.view.has_value()) { + if (overlay_depth.view.has_value()) { output_rendering_info.pDepthAttachment = &overlay_depth_info; } frame.command_buffer.beginRendering(output_rendering_info); @@ -2534,6 +2769,22 @@ auto VulkanRenderer::recordFrame(FrameContext& frame, uint32_t image_index) noex TracyVkCollect(m_tracy_vk_ctx, *frame.command_buffer); +#ifdef TRACY_ENABLE + if (m_shadow_pass != nullptr) { + const ShadowPass::Stats& shadow = m_shadow_pass->getStats(); + TracyPlot("Shadow: draws", static_cast(shadow.draws)); + TracyPlot("Shadow: scopes", static_cast(shadow.scopes)); + TracyPlot("Shadow: groups composed", static_cast(shadow.composed)); + TracyPlot("Shadow: groups cached", static_cast(shadow.cached)); + TracyPlot("Shadow: static for view move", static_cast(shadow.static_views)); + TracyPlot("Shadow: static for casters", static_cast(shadow.static_casters)); + TracyPlot("Shadow: static for voxel edits", static_cast(shadow.static_edits)); + TracyPlot("Shadow: moving volumes", static_cast(shadow.moving_volumes)); + TracyPlot("Shadow: voxel draws marched", static_cast(shadow.marched_volumes)); + TracyPlot("Shadow: voxel draws meshed", static_cast(shadow.meshed_volumes)); + } +#endif + if (m_gpu_timer != nullptr) { m_gpu_timer->endGraphics(m_current_frame, *frame.command_buffer); } @@ -2702,23 +2953,34 @@ auto VulkanRenderer::drawFrame(RenderFrame& frame_data) -> bool { phase_start = now; }; + toast::PhaseZones phases; + TOAST_PHASE(phases, "Draw: uploads"); processPendingUploads(); flushResourceUploads(); end_phase(m_perf_upload_ns); auto& frame = m_frames[m_current_frame]; + TOAST_PHASE(phases, "Draw: wait frame fence"); std::ignore = m_core->getDevice().waitForFences(*frame.in_flight, true, std::numeric_limits::max()); end_phase(m_perf_gpu_wait_ns); + TOAST_PHASE(phases, "Draw: publish frames and timings"); frame.retired.clear(); publishCompletedFrames(); m_core->updateTracyMemory(); if (m_gpu_timer != nullptr && !frame.was_probe_capture) { m_gpu_timer->collect(m_current_frame); +#if defined(TOAST_DEV_TOOLS) + reportGpuSpike(*m_gpu_timer); +#endif +#ifdef TRACY_ENABLE + plotGpuTimings(*m_gpu_timer); +#endif } end_phase(m_perf_record_ns); + TOAST_PHASE(phases, "Draw: acquire image"); 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 && @@ -2743,7 +3005,9 @@ auto VulkanRenderer::drawFrame(RenderFrame& frame_data) -> bool { } const uint32_t image_index = acquired.value; - if (m_images_in_flight.at(image_index)) { + TOAST_PHASE(phases, "Draw: wait image fence"); + // The acquire semaphore and the queue order image reuse and a CPU wait here pinned composed HDR to one frame + if (!m_output_target->usesAcquirePresentSemaphores() && m_images_in_flight.at(image_index)) { std::ignore = m_core->getDevice().waitForFences(m_images_in_flight.at(image_index), true, std::numeric_limits::max()); } @@ -2752,9 +3016,11 @@ auto VulkanRenderer::drawFrame(RenderFrame& frame_data) -> bool { m_core->getDevice().resetFences(*frame.in_flight); m_images_in_flight[image_index] = *frame.in_flight; + TOAST_PHASE(phases, "Draw: update frame resources"); updateFrameResources(m_current_frame, frame_data); // FIXME dt m_rendering_frame = &frame_data; + TOAST_PHASE(phases, "Draw: update passes"); ensureMaterialPasses(frame_data); // TODO move outside the render loop @@ -2784,9 +3050,11 @@ auto VulkanRenderer::drawFrame(RenderFrame& frame_data) -> bool { frame.was_probe_capture = false; } + TOAST_PHASE(phases, "Draw: record frame"); recordFrame(frame, image_index); end_phase(m_perf_record_ns); + TOAST_PHASE(phases, "Draw: submit setup"); const bool present_sync = m_output_target->usesAcquirePresentSemaphores(); const vk::CommandBuffer command_buffer = *frame.command_buffer; @@ -2834,6 +3102,7 @@ auto VulkanRenderer::drawFrame(RenderFrame& frame_data) -> bool { submit.next = &frame_boundary; } + TOAST_PHASE(phases, "Draw: submit"); { std::scoped_lock submit_lock(m_core->graphicsSubmitMutex()); sync::submit(m_core->getGraphicsQueue(), submit); @@ -2842,7 +3111,7 @@ auto VulkanRenderer::drawFrame(RenderFrame& frame_data) -> bool { m_core->asyncCompute().flushDeferred(); end_phase(m_perf_submit_ns); -#if defined(_WIN32) +#if defined(_WIN32) && defined(TOAST_DEV_TOOLS) if (m_core->getNsightMode() == NsightMode::graphics_capture) { NGFX_ResourceDescription_Vulkan output_resource {NGFX_ResourceDescription_Vulkan_VER}; output_resource.type = NGFX_ResourceType_Vulkan_VkImage; @@ -2856,6 +3125,7 @@ auto VulkanRenderer::drawFrame(RenderFrame& frame_data) -> bool { } #endif + TOAST_PHASE(phases, "Draw: present"); const auto present_result = m_output_target->present(image_index, present_sync ? signal_semaphore : vk::Semaphore {}); end_phase(m_perf_present_ns); frame.last_image_index = image_index; @@ -2901,7 +3171,9 @@ void VulkanRenderer::waitForOutputImage() { } void VulkanRenderer::mainRenderThread() try { +#ifdef TRACY_ENABLE tracy::SetThreadName("Renderer Thread"); +#endif #ifdef TRACY_ENABLE { @@ -3032,7 +3304,7 @@ void VulkanRenderer::mainRenderThread() try { if (do_capture && rdoc_api != nullptr) { rdoc_api->StartFrameCapture(nullptr, nullptr); } -#if defined(_WIN32) +#if defined(_WIN32) && defined(TOAST_DEV_TOOLS) else if (do_capture && nsight_mode == NsightMode::graphics_capture) { NGFX_GraphicsCapture_StartCapture_Vulkan_Params params {NGFX_GraphicsCapture_StartCapture_Vulkan_Params_VER}; const NGFX_Result start_result = NGFX_GraphicsCapture_StartCapture_Vulkan(¶ms); @@ -3092,7 +3364,7 @@ void VulkanRenderer::mainRenderThread() try { rdoc_api->EndFrameCapture(nullptr, nullptr); TOAST_INFO("VulkanRenderer", "RenderDoc frame capture triggered"); } -#if defined(_WIN32) +#if defined(_WIN32) && defined(TOAST_DEV_TOOLS) else if (do_capture && nsight_mode == NsightMode::graphics_capture) { NGFX_GraphicsCapture_StopCapture_Vulkan_Params params {NGFX_GraphicsCapture_StopCapture_Vulkan_Params_VER}; const NGFX_Result stop_result = NGFX_GraphicsCapture_StopCapture_Vulkan(¶ms); @@ -3235,6 +3507,7 @@ void VulkanRenderer::fitShadowViews( const uint32_t resolution = shadows::cascadeResolution(); const float padding = shadows::cascadePadding(); float split_near = m_camera->near_plane; + uint32_t cascades_refit = 0; for (uint32_t cascade = 0; cascade < shadows::k_cascade_count; ++cascade) { const BoundingSphere slice = frustumSliceSphere(*m_camera, aspect, split_near, splits[cascade]); @@ -3244,6 +3517,7 @@ void VulkanRenderer::fitShadowViews( glm::distance(anchor.center, slice.center) + slice.radius <= anchor.radius && slice.radius * (1.0f + (2.0f * padding)) >= anchor.radius; if (!holds) { + ++cascades_refit; anchor = CascadeAnchor { .center = slice.center, .radius = slice.radius * (1.0f + padding), @@ -3271,6 +3545,7 @@ void VulkanRenderer::fitShadowViews( split_near = splits[cascade]; } + TracyPlot("Shadow: cascades refit", static_cast(cascades_refit)); frame.frame_data.shadow_params.x = static_cast(shadow_caster_index + 1); frame.frame_data.shadow_params.y = static_cast(shadows::k_cascade_count); @@ -3451,6 +3726,9 @@ void VulkanRenderer::finalizeLights(RenderFrame& frame) { frame.light_stats.spot_shadows = occupied(m_spot_slots); frame.light_stats.point_shadows = occupied(m_point_slots); frame.light_stats.shadow_displaced = m_shadow_displaced_total; + TracyPlot("Shadow: spot slots used", static_cast(frame.light_stats.spot_shadows)); + TracyPlot("Shadow: point slots used", static_cast(frame.light_stats.point_shadows)); + TracyPlot("Shadow: lights denied", static_cast(frame.light_stats.spot_denied + frame.light_stats.point_denied)); const auto depth = light_culling::clusterDepthRange(frame.camera_near, frame.camera_far, light_reach); frame.cluster_near = depth.near_depth; @@ -3459,12 +3737,25 @@ void VulkanRenderer::finalizeLights(RenderFrame& frame) { void VulkanRenderer::tick(float time) noexcept { ZoneScopedN("VulkanRenderer::tick()"); + toast::PhaseZones phases; + TOAST_PHASE(phases, "Tick: settings"); + + // Queued by render thread UI that holds the pass lock + std::vector> setting_requests; + { + const std::lock_guard lock(m_setting_request_mutex); + setting_requests.swap(m_setting_requests); + } + for (auto& [key, value] : setting_requests) { + toast::settings::Settings::get().setValue(key, value, true); + } if (m_rendering_paused.load(std::memory_order_relaxed)) { getCurrentFrameBuild().debug_line_vertices.clear(); return; } + TOAST_PHASE(phases, "Tick: wait frame slot"); if (m_frame_slot == FrameSlot::none) { acquireFrameSlot(); } @@ -3487,6 +3778,7 @@ void VulkanRenderer::tick(float time) noexcept { ); }; + TOAST_PHASE(phases, "Tick: reset frame"); auto& frame = getCurrentFrameBuild(); frame.mesh_instances.clear(); @@ -3517,6 +3809,7 @@ void VulkanRenderer::tick(float time) noexcept { frame.shadows.views.clear(); frame.shadows.matrices.clear(); +#if defined(TOAST_DEV_TOOLS) frame.imgui_input.mouse_pos = m_imgui_mouse_pos; frame.imgui_input.mouse_down = m_imgui_mouse_down; frame.imgui_input.mouse_wheel_x = m_imgui_wheel_x_accum; @@ -3527,6 +3820,7 @@ void VulkanRenderer::tick(float time) noexcept { m_imgui_wheel_y_accum = 0.0f; m_imgui_key_events_accum.clear(); m_imgui_char_events_accum.clear(); +#endif frame.sequence = ++m_frame_sequence_counter; frame.render_mode = m_render_mode; @@ -3550,6 +3844,7 @@ void VulkanRenderer::tick(float time) noexcept { return; } + TOAST_PHASE(phases, "Tick: camera and volumes"); m_camera->syncTransform(); frame.post_process = blendPostProcessVolumes(m_camera->world_position); gatherFogVolumes(frame, m_camera->world_position); @@ -3561,6 +3856,7 @@ void VulkanRenderer::tick(float time) noexcept { ); frame.frame_data.fog_reach = glm::vec4(fog.distance, volumetricDistance(), static_cast(voxelSunReach()), 0.0f); + TOAST_PHASE(phases, "Tick: snapshot nodes"); auto& mesh_nodes_snapshot = m_tick_mesh_nodes; { std::scoped_lock lock(m_mesh_proxy_mutex); @@ -3578,6 +3874,7 @@ void VulkanRenderer::tick(float time) noexcept { frame.lights.clear(); frame.light_stats = {}; + TOAST_PHASE(phases, "Tick: mesh proxies"); for (auto* node : mesh_nodes_snapshot) { if (node == nullptr || !node->enabled() || !node->participatesIn(toast::NodeOwnerParticipation::render)) { continue; @@ -3668,9 +3965,12 @@ void VulkanRenderer::tick(float time) noexcept { ); } + TOAST_PHASE(phases, "Tick: voxel proxies"); buildVoxelProxies(frame); + TOAST_PHASE(phases, "Tick: camera frame data"); const auto extent = m_output_target->getExtent(); + const auto render_extent = renderExtent(); beginDebugLineCollection(frame); { std::scoped_lock lock(m_mesh_proxy_mutex); @@ -3703,6 +4003,7 @@ void VulkanRenderer::tick(float time) noexcept { (overlays.surface_unit_grid ? 1u : 0u) | (overlays.surface_voxel_grid ? 2u : 0u) | (overlays.voxel_edges ? 4u : 0u); } + TOAST_PHASE(phases, "Tick: reflection probes"); // PROBES SHIT { std::scoped_lock lock(m_reflection_probe_mutex); @@ -3769,7 +4070,8 @@ void VulkanRenderer::tick(float time) noexcept { frame.frame_data.camera_position = probe_position; frame.probe_capture_index = probe_index; frame.probe_capture_face = face; - frame.capture_extent = std::min({m_reflection_probe_nodes[probe_index]->resolution(), extent.width, extent.height}); + frame.capture_extent = + std::min({m_reflection_probe_nodes[probe_index]->resolution(), render_extent.width, render_extent.height}); frame.render_mode = 0; frame.frame_data.render_mode_pad.x = 0; @@ -3939,7 +4241,7 @@ void VulkanRenderer::tick(float time) noexcept { frame.irradiance_capture_index = probe_index; frame.irradiance_capture_face = face; - frame.capture_extent = std::min({k_irradiance_capture_size, extent.width, extent.height}); + frame.capture_extent = std::min({k_irradiance_capture_size, render_extent.width, render_extent.height}); frame.render_mode = 0; frame.frame_data.render_mode_pad.x = 0; @@ -3953,7 +4255,9 @@ void VulkanRenderer::tick(float time) noexcept { m_bake_active.store(m_probe_bake_cursor >= 0 || m_irradiance_bake_cursor >= 0, std::memory_order_relaxed); } - frame.viewport_extent = glm::vec2(static_cast(extent.width), static_cast(extent.height)); + TOAST_PHASE(phases, "Tick: viewport and debug draws"); + frame.viewport_extent = glm::vec2(static_cast(render_extent.width), static_cast(render_extent.height)); + frame.output_extent = glm::vec2(static_cast(extent.width), static_cast(extent.height)); frame.camera_near = m_camera->near_plane; frame.camera_far = m_camera->far_plane; @@ -3983,6 +4287,7 @@ void VulkanRenderer::tick(float time) noexcept { } } + TOAST_PHASE(phases, "Tick: lights"); // Lights shit glm::vec3 ambient_sum(0.0f); @@ -4054,12 +4359,18 @@ void VulkanRenderer::tick(float time) noexcept { frame.lights.push_back(gpu); }; + // Marched and sampled shadows trace scene rays + const bool shadow_maps_only = voxelShadowMapsOnly(); + const auto shadow_mode = [shadow_maps_only](const toast::Light& light) { + return shadow_maps_only ? toast::LightShadowMode::shadow_map : light.shadowMode(); + }; + // y plus z bounds the emitter so culling widens by it - const auto area_shape = [](const toast::Light& light, bool casts_shadows) -> glm::vec4 { + const auto area_shape = [&shadow_mode](const toast::Light& light, bool casts_shadows) -> glm::vec4 { const float radius = std::max(light.sourceRadius(), 0.0f); const float half_length = 0.5f * std::max(light.sourceLength(), 0.0f); const float half_width = 0.5f * std::max(light.sourceWidth(), 0.0f); - const float mode = casts_shadows ? static_cast(light.shadowMode()) : 0.0f; + const float mode = casts_shadows ? static_cast(shadow_mode(light)) : 0.0f; switch (light.shape()) { case toast::LightShape::sphere: case toast::LightShape::disc: return {static_cast(light.shape()), radius, 0.0f, mode}; @@ -4093,7 +4404,11 @@ void VulkanRenderer::tick(float time) noexcept { const bool casts_shadows = light->castsShadows() && (!positional || light->shadowDistance() <= 0.0f || camera_distance <= shadow_limit); - const bool shadow_map = casts_shadows && light->shadowMode() == toast::LightShadowMode::shadow_map; + const bool shadow_map = casts_shadows && shadow_mode(*light) == toast::LightShadowMode::shadow_map; + + if (isOutlined(light->rid())) { + toast::light_gizmo::draw(*light); + } constexpr float k_light_icon_size = 0.5f; debug::drawBillboard( @@ -4106,13 +4421,10 @@ void VulkanRenderer::tick(float time) noexcept { break; } case toast::LightType::directional: { - // FIXME - debug::drawArrow(light->world_position, light->world_position + light->forward() * 2.0f, glm::vec4(light->color(), 1.0f)); - if (directional_count >= k_max_directional_lights) { break; } - const bool marched_sun = casts_shadows && light->shadowMode() != toast::LightShadowMode::shadow_map; + const bool marched_sun = casts_shadows && shadow_mode(*light) != toast::LightShadowMode::shadow_map; const float angular_radius = 0.5f * glm::radians(static_cast(light)->angularDiameter()); frame.frame_data.directional_lights[directional_count] = DirectionalLightData { .direction = glm::vec4(light->forward(), marched_sun ? std::max(angular_radius, 1e-6f) : 0.0f), @@ -4132,9 +4444,6 @@ void VulkanRenderer::tick(float time) noexcept { const glm::vec3 forward = glm::normalize(point->forward()); const glm::vec4 shape = area_shape(*point, casts_shadows); - // FIXME - // debug::drawSphere(world_pos, point->attenuation(), glm::vec4(point->color(), 1.0f)); - submit_punctual( PunctualSubmit { .gpu = @@ -4173,9 +4482,6 @@ void VulkanRenderer::tick(float time) noexcept { light_culling::Sphere spot_bounds = light_culling::spotBounds(world_pos, forward, spot->attenuation(), outer); spot_bounds.radius += shape.y + shape.z; - // FIXME - debug::drawCone(world_pos, forward, spot->attenuation(), spot->outerRadius(), glm::vec4(spot->color(), 1.0f)); - submit_punctual( PunctualSubmit { .gpu = @@ -4206,6 +4512,7 @@ void VulkanRenderer::tick(float time) noexcept { } } + TOAST_PHASE(phases, "Tick: light uniforms"); frame.frame_data.ambient_color_intensity = glm::vec4(ambient_sum, 1.0f); frame.frame_data.directional_light_count_pad.x = directional_count; @@ -4271,6 +4578,7 @@ void VulkanRenderer::tick(float time) noexcept { } } + TOAST_PHASE(phases, "Tick: environment"); if (m_environment_pass != nullptr && m_environment_pass->isReady()) { frame.frame_data.environment_params.x = 1.0f; frame.frame_data.environment_params.y = static_cast(m_environment_pass->getPrefilteredMipCount()); @@ -4322,7 +4630,7 @@ void VulkanRenderer::tick(float time) noexcept { 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; + m_core->isRayTracingSupported() && ShaderCompiler::traceRayQuery() && !frame.voxel_instances.empty() ? 1.0f : 0.0f; if (!trace_shadows) { fitShadowViews(frame, aspect, shadow_caster_index, shadow_caster_direction, spot_candidates, point_candidates); @@ -4330,8 +4638,10 @@ void VulkanRenderer::tick(float time) noexcept { releaseShadowSlots(); } + TOAST_PHASE(phases, "Tick: finalize lights"); finalizeLights(frame); + TOAST_PHASE(phases, "Tick: gizmos and post process"); // TODO compile out of non editor builds if (m_gizmo_state.visible) { const float scale = toast::gizmo_layout::k_screen_size * glm::distance(m_camera->world_position, m_gizmo_state.origin); @@ -4499,6 +4809,7 @@ void VulkanRenderer::tick(float time) noexcept { } } + TOAST_PHASE(phases, "Tick: antialiasing"); // ANTIALIASING { const bool is_capture = frame.probe_capture_index >= 0 || frame.irradiance_capture_index >= 0; @@ -4525,7 +4836,8 @@ void VulkanRenderer::tick(float time) noexcept { } frame.taa_reset = taa && (!m_taa_history.was_active || !same_camera); - if (taa && extent.width > 0 && extent.height > 0) { + frame.jitter_ndc = glm::vec2(0.0f); + if (taa && render_extent.width > 0 && render_extent.height > 0) { // R2 low discrepancy sequence from the plastic number constexpr double k_plastic = 1.32471795724474602596; const auto n = static_cast(++m_taa_history.jitter_index); @@ -4534,7 +4846,8 @@ void VulkanRenderer::tick(float time) noexcept { static_cast(std::fmod(0.5 + (n / (k_plastic * k_plastic)), 1.0)) - 0.5f ); const glm::vec2 ndc_offset = - 2.0f * pixel_offset / glm::vec2(static_cast(extent.width), static_cast(extent.height)); + 2.0f * pixel_offset / glm::vec2(static_cast(render_extent.width), static_cast(render_extent.height)); + frame.jitter_ndc = ndc_offset; frame.frame_data.jittered_view_projection = glm::translate(glm::mat4(1.0f), glm::vec3(ndc_offset, 0.0f)) * frame.frame_data.view_projection; } @@ -4548,12 +4861,18 @@ void VulkanRenderer::tick(float time) noexcept { } } + TOAST_PHASE(phases, "Tick: ui frame"); if (m_ui_frame_builder) { m_ui_frame_builder(frame); } endDebugLineCollection(); count_build(); + TracyPlot("Tick: lights", static_cast(frame.lights.size())); + TracyPlot("Tick: mesh instances", static_cast(frame.mesh_instances.size())); + TracyPlot("Tick: voxel instances", static_cast(frame.voxel_instances.size())); + TracyPlot("Tick: shadow views", static_cast(frame.shadows.views.size())); + TOAST_PHASE(phases, "Tick: submit frame"); submitFrame(); } @@ -5149,6 +5468,7 @@ auto VulkanRenderer::applyResizeInternal(vk::Extent2D extent) -> void { m_core->getDevice().waitIdle(); publishCompletedFrames(); m_pending_publish.clear(); + m_applied_render_scale.store(m_render_scale.load(std::memory_order_relaxed), std::memory_order_relaxed); for (auto& frame : m_frames) { frame.has_submitted = false; } @@ -5168,7 +5488,7 @@ auto VulkanRenderer::applyResizeInternal(vk::Extent2D extent) -> void { { std::lock_guard lock(m_pass_mutex); for (auto& pass : m_post_process_passes) { - pass->onResize(actual_extent); + pass->onResize(pass->atOutputResolution() ? actual_extent : renderExtent()); } } if (m_voxel_pass != nullptr) { diff --git a/engine/src/toast/renderer/vulkan_renderer.hpp b/engine/src/toast/renderer/vulkan_renderer.hpp index 67558bb65..5039ca52e 100644 --- a/engine/src/toast/renderer/vulkan_renderer.hpp +++ b/engine/src/toast/renderer/vulkan_renderer.hpp @@ -49,6 +49,7 @@ #include #include #include +#include #include #include #include @@ -404,6 +405,7 @@ class VulkanRenderer { float size_handle_scale = 1.0f; }; +#if defined(TOAST_DEV_TOOLS) struct ImGuiKeyEvent { int32_t key = 0; bool down = false; @@ -417,6 +419,7 @@ class VulkanRenderer { std::vector key_events; std::vector char_events; }; +#endif struct LightStats { uint32_t submitted = 0; @@ -463,7 +466,11 @@ class VulkanRenderer { std::vector material_ranges; + /// Render resolution glm::vec2 viewport_extent {0.0f}; + glm::vec2 output_extent {0.0f}; + /// NDC offset jittered_view_projection adds + glm::vec2 jitter_ndc {0.0f}; float camera_near = 0.01f; float camera_far = 5000.0f; @@ -472,7 +479,9 @@ class VulkanRenderer { LightStats light_stats; +#if defined(TOAST_DEV_TOOLS) ImGuiInputSnapshot imgui_input; +#endif uint32_t render_mode = 0; @@ -943,6 +952,92 @@ class VulkanRenderer { return m_voxel_glass_lit_surfaces.load(std::memory_order_relaxed); } + [[nodiscard]] + auto hdrOutput() const -> bool { + return m_output_target != nullptr && m_output_target->isHdr(); + } + + /// scRGB where 1.0 is 80 nits + void setDisplayHdr(float sdr_white, float headroom) noexcept { + m_display_sdr_white.store(std::max(sdr_white, 1.0f), std::memory_order_relaxed); + m_display_hdr_headroom.store(std::max(headroom, 1.0f), std::memory_order_relaxed); + } + + /// Zero follows the display + void setHdrNits(float paper_white, float peak) noexcept { + m_hdr_paper_white_nits.store(std::max(paper_white, 0.0f), std::memory_order_relaxed); + m_hdr_peak_nits.store(std::max(peak, 0.0f), std::memory_order_relaxed); + } + + /// scRGB multiplier of SDR 1.0 + [[nodiscard]] + auto hdrPaperWhiteScale() const -> float { + const float nits = m_hdr_paper_white_nits.load(std::memory_order_relaxed); + return nits > 0.0f ? nits / 80.0f : m_display_sdr_white.load(std::memory_order_relaxed); + } + + [[nodiscard]] + auto hdrPeakScale() const -> float { + const float nits = m_hdr_peak_nits.load(std::memory_order_relaxed); + const float headroom = m_display_hdr_headroom.load(std::memory_order_relaxed); + constexpr float k_unknown_peak = 1000.0f / 80.0f; + const float display = headroom > 1.05f ? m_display_sdr_white.load(std::memory_order_relaxed) * headroom : k_unknown_peak; + return std::max(nits > 0.0f ? nits / 80.0f : display, hdrPaperWhiteScale() * 1.25f); + } + + /// Overrides the Voxel Shadows mode of every light + void setVoxelShadowMapsOnly(bool enabled) noexcept { m_voxel_shadow_maps_only.store(enabled, std::memory_order_relaxed); } + + [[nodiscard]] + auto voxelShadowMapsOnly() const noexcept -> bool { + return m_voxel_shadow_maps_only.load(std::memory_order_relaxed); + } + + /// Share of the frame the shutter stays open + void setMotionBlurStrength(float strength) noexcept { + m_motion_blur_strength.store(std::clamp(strength, 0.0f, 2.0f), std::memory_order_relaxed); + } + + [[nodiscard]] + auto motionBlurStrength() const noexcept -> float { + return m_motion_blur_strength.load(std::memory_order_relaxed); + } + + /// Longest blur as a share of the screen height + void setMotionBlurMaxLength(float share) noexcept { + m_motion_blur_max_length.store(std::clamp(share, 0.001f, 0.5f), std::memory_order_relaxed); + } + + [[nodiscard]] + auto motionBlurMaxLength() const noexcept -> float { + return m_motion_blur_max_length.load(std::memory_order_relaxed); + } + + void setMotionBlurSamples(uint32_t samples) noexcept { + m_motion_blur_samples.store(std::clamp(samples, 2u, 32u), std::memory_order_relaxed); + } + + [[nodiscard]] + auto motionBlurSamples() const noexcept -> uint32_t { + return m_motion_blur_samples.load(std::memory_order_relaxed); + } + + /// Zero steps keep the post process volume values + void setSsrOverride(float reach, uint32_t steps) noexcept { + m_ssr_reach.store(std::max(reach, 0.0f), std::memory_order_relaxed); + m_ssr_steps.store(steps, std::memory_order_relaxed); + } + + [[nodiscard]] + auto ssrOverrideReach() const noexcept -> float { + return m_ssr_reach.load(std::memory_order_relaxed); + } + + [[nodiscard]] + auto ssrOverrideSteps() const noexcept -> uint32_t { + return m_ssr_steps.load(std::memory_order_relaxed); + } + void setVoxelGlassShadows(bool enabled) noexcept { m_voxel_glass_shadows.store(enabled, std::memory_order_relaxed); } [[nodiscard]] @@ -978,13 +1073,6 @@ 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); } @@ -1018,21 +1106,17 @@ class VulkanRenderer { return m_voxel_shadow_instances.load(std::memory_order_relaxed); } - void setVoxelNormalRounding(float voxels) noexcept { - m_voxel_normal_rounding.store(std::max(voxels, 0.0f), std::memory_order_relaxed); - } - - [[nodiscard]] - auto voxelNormalRounding() const noexcept -> float { - return m_voxel_normal_rounding.load(std::memory_order_relaxed); - } - /// Left in eDepthReadOnlyOptimal [[nodiscard]] auto getDepthView() const noexcept -> vk::ImageView { return m_depth_resources.view.has_value() ? **m_depth_resources.view : vk::ImageView {}; } + [[nodiscard]] + auto getOverlayDepthView() const noexcept -> vk::ImageView { + return m_output_depth.view.has_value() ? **m_output_depth.view : getDepthView(); + } + struct PassInfo { std::string name; bool enabled = true; @@ -1428,6 +1512,20 @@ class VulkanRenderer { void setTaaEnabled(bool enabled) noexcept { m_taa_enabled.store(enabled, std::memory_order_relaxed); } + /// Recreates every target + void setRenderScale(float scale); + + /// Thread safe and applied at the next tick + void requestSettingValue(std::string key, toast::settings::Value value); + + [[nodiscard]] + auto renderScale() const noexcept -> float { + return m_render_scale.load(std::memory_order_relaxed); + } + + [[nodiscard]] + auto renderExtent() const -> vk::Extent2D; + [[nodiscard]] auto taaEnabled() const noexcept -> bool { return m_taa_enabled.load(std::memory_order_relaxed); @@ -1566,6 +1664,7 @@ class VulkanRenderer { void recordAccelerationStructureBuilds(FrameContext& frame); void createPresentResources(); + void recordDepthUpscale(vk::CommandBuffer cmd, const vk::Rect2D& area); void createDescriptorPool(); void createDefaultTexture(); @@ -1712,6 +1811,8 @@ class VulkanRenderer { bool m_probe_auto_baked = false; vk::Format m_depth_format = vk::Format::eUndefined; DepthResources m_depth_resources; + /// Only below render scale 1 + DepthResources m_output_depth; DepthResources m_glass_backdrop_depth; static constexpr vk::Format m_scene_color_format = vk::Format::eR16G16B16A16Sfloat; @@ -1741,10 +1842,8 @@ class VulkanRenderer { bool m_voxel_reservoir_history = false; uint64_t m_voxel_reservoir_sequence = 0; - std::atomic m_voxel_normal_rounding {0.3f}; std::atomic m_voxel_light_candidates {8}; std::atomic m_voxel_shadow_instances {256}; - std::atomic_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}; @@ -1768,6 +1867,17 @@ class VulkanRenderer { std::atomic m_voxel_glass_refraction_voxels {32}; std::atomic m_voxel_glass_lit_surfaces {4}; std::atomic_bool m_voxel_glass_shadows {true}; + std::atomic m_display_sdr_white {2.5375f}; + std::atomic m_display_hdr_headroom {1.0f}; + std::atomic m_hdr_paper_white_nits {0.0f}; + std::atomic m_hdr_peak_nits {0.0f}; + event::Listener m_hdr_listener; + std::atomic_bool m_voxel_shadow_maps_only {false}; + std::atomic m_motion_blur_strength {0.5f}; + std::atomic m_motion_blur_max_length {0.04f}; + std::atomic m_motion_blur_samples {12}; + std::atomic m_ssr_reach {0.0f}; + std::atomic m_ssr_steps {0}; std::atomic m_voxel_glass_shadow_reach {50}; std::atomic_bool m_voxel_cell_boxes {true}; std::atomic_bool m_voxel_occlusion {true}; @@ -1781,6 +1891,10 @@ class VulkanRenderer { vk::raii::Sampler m_present_sampler = nullptr; std::vector m_present_sets; std::vector m_present_bound_views; + VulkanPipeline m_depth_upscale_pipeline; + vk::raii::Sampler m_depth_upscale_sampler = nullptr; + std::vector m_depth_upscale_sets; + std::vector m_depth_upscale_bound; TracyVkCtx m_tracy_vk_ctx = nullptr; std::unique_ptr m_gpu_timer; @@ -1818,6 +1932,11 @@ class VulkanRenderer { bool m_traced_shadows_voxel_fallback_logged = false; std::atomic_bool m_taa_enabled {true}; + std::atomic m_render_scale {1.0f}; + std::mutex m_setting_request_mutex; + std::vector> m_setting_requests; + /// Of the current targets + std::atomic m_applied_render_scale {1.0f}; std::atomic m_taa_history_weight {0.9f}; /// Game thread only @@ -1868,6 +1987,7 @@ class VulkanRenderer { event::Listener m_capture_listener; std::atomic_bool m_capture_frame_requested {false}; +#if defined(TOAST_DEV_TOOLS) event::Listener m_imgui_input_listener; glm::vec2 m_imgui_mouse_pos {-1.0f, -1.0f}; std::array m_imgui_mouse_down {}; @@ -1875,6 +1995,7 @@ class VulkanRenderer { float m_imgui_wheel_y_accum = 0.0f; std::vector m_imgui_key_events_accum; std::vector m_imgui_char_events_accum; +#endif event::Listener m_render_mode_listener; uint32_t m_render_mode = 0; diff --git a/engine/src/toast/renderer/vulkan_swapchain.cpp b/engine/src/toast/renderer/vulkan_swapchain.cpp index 72cfe56da..4c052cd1a 100644 --- a/engine/src/toast/renderer/vulkan_swapchain.cpp +++ b/engine/src/toast/renderer/vulkan_swapchain.cpp @@ -65,9 +65,12 @@ auto uniqueQueueFamilies(uint32_t graphics_family, uint32_t present_family) -> s } } // namespace -VulkanSwapchain::VulkanSwapchain(const VulkanCore& core, vk::raii::SurfaceKHR& surface, vk::Extent2D preferred_extent) +VulkanSwapchain::VulkanSwapchain( + const VulkanCore& core, vk::raii::SurfaceKHR& surface, vk::Extent2D preferred_extent, bool prefer_hdr +) : m_core(&core), - m_surface(&surface) { + m_surface(&surface), + m_prefer_hdr(prefer_hdr) { create(preferred_extent); } @@ -124,7 +127,9 @@ auto VulkanSwapchain::create(vk::Extent2D preferred_extent) -> void { capabilities.maxImageExtent.height ); - uint32_t image_count = capabilities.minImageCount + 1; + // Three frames in flight plus the image the compositor holds + constexpr uint32_t k_preferred_images = 4; + uint32_t image_count = std::max(capabilities.minImageCount + 1, k_preferred_images); if (capabilities.maxImageCount > 0 && image_count > capabilities.maxImageCount) { image_count = capabilities.maxImageCount; } @@ -189,6 +194,7 @@ auto VulkanSwapchain::create(vk::Extent2D preferred_extent) -> void { m_present_queue_family_index = present_queue_family_index; m_present_queue = m_core->getDevice().getQueue(m_present_queue_family_index, 0); m_image_format = image_format; + m_hdr = chosen_format.colorSpace == vk::ColorSpaceKHR::eExtendedSrgbLinearEXT; m_extent = chosen_extent; m_image_views = std::move(new_image_views); } @@ -209,6 +215,16 @@ auto VulkanSwapchain::selectSurfaceFormat(const std::vector bool { + return m_hdr; + } + [[nodiscard]] auto getImageCount() const -> uint32_t { return static_cast(m_images.size()); @@ -67,6 +73,8 @@ class VulkanSwapchain { const VulkanCore* m_core = nullptr; vk::raii::SurfaceKHR* m_surface = nullptr; + bool m_prefer_hdr = false; + bool m_hdr = false; vk::raii::SwapchainKHR m_swapchain = nullptr; std::vector m_images; diff --git a/engine/src/toast/settings/settings.cpp b/engine/src/toast/settings/settings.cpp index a1041ea31..834d024cc 100644 --- a/engine/src/toast/settings/settings.cpp +++ b/engine/src/toast/settings/settings.cpp @@ -251,7 +251,7 @@ auto Settings::resolved(const Entry* entry) -> Value { return entry->resolved; } -auto Settings::setValue(std::string_view key, const Value& value) -> bool { +auto Settings::setValue(std::string_view key, const Value& value, bool override_higher_layers) -> bool { std::vector> callbacks; Value new_value; { @@ -273,6 +273,11 @@ auto Settings::setValue(std::string_view key, const Value& value) -> bool { const Value previous = entry.resolved; entry.overrides[layerIndex(m_active_layer)] = *converted; + if (override_higher_layers) { + for (size_t i = layerIndex(m_active_layer) + 1; i < entry.overrides.size(); ++i) { + entry.overrides[i].reset(); + } + } resolveEntry(entry); if (sameValue(previous, entry.resolved)) { return true; diff --git a/engine/src/toast/settings/settings.hpp b/engine/src/toast/settings/settings.hpp index 2f2a14667..10a0d6850 100644 --- a/engine/src/toast/settings/settings.hpp +++ b/engine/src/toast/settings/settings.hpp @@ -122,7 +122,9 @@ class TOAST_API Settings { /// /// A value of the wrong alternative is converted when the conversion is lossless-ish (int to double, bool /// to int) and rejected otherwise, so a caller cannot silently turn a float setting into a string - auto setValue(std::string_view key, const Value& value) -> bool; + /// + /// @p override_higher_layers clears the layers above the active one in memory and leaves their files alone + auto setValue(std::string_view key, const Value& value, bool override_higher_layers = false) -> bool; /// @brief Drops @p key's override in the active layer, exposing whatever is underneath auto reset(std::string_view key) -> bool; diff --git a/engine/src/toast/ui/render/ui_pass.cpp b/engine/src/toast/ui/render/ui_pass.cpp index 23aa3d74a..199daeb5e 100644 --- a/engine/src/toast/ui/render/ui_pass.cpp +++ b/engine/src/toast/ui/render/ui_pass.cpp @@ -125,6 +125,9 @@ void UIPass::record(vk::CommandBuffer cmd, uint32_t frame_index, uint32_t image_ auto& sets = m_frame_sets[frame_index % m_frame_sets.size()]; cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, *m_composite_pipeline.getPipeline()); + const auto* vulkan = renderer::VulkanRenderer::instance; + const std::array hdr {vulkan->hdrOutput() ? vulkan->hdrPaperWhiteScale() : 0.0f, 0.0f, 0.0f, 0.0f}; + cmd.pushConstants>(*m_shader_layout.getPipelineLayout(), vk::ShaderStageFlagBits::eAll, 0, hdr); for (uint32_t i = 0; i < m_draw_count; i++) { cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, *m_shader_layout.getPipelineLayout(), 0, *sets[i], nullptr); cmd.draw(3, 1, 0, 0); diff --git a/engine/src/toast/ui/render/world_ui_pass.cpp b/engine/src/toast/ui/render/world_ui_pass.cpp index ff6b9ab4d..774aaf14e 100644 --- a/engine/src/toast/ui/render/world_ui_pass.cpp +++ b/engine/src/toast/ui/render/world_ui_pass.cpp @@ -152,8 +152,11 @@ void WorldUIPass::record(vk::CommandBuffer cmd, uint32_t frame_index, uint32_t i cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, layout, 1, *sets[draw_index], nullptr); - const PushConstants push {panel.model}; - cmd.pushConstants(layout, vk::ShaderStageFlagBits::eVertex, 0, push); + // Only the overlay layer draws after the tonemap + const auto* vulkan = renderer::VulkanRenderer::instance; + const float paper_white = overlay && vulkan->hdrOutput() ? vulkan->hdrPaperWhiteScale() : 0.0f; + const PushConstants push {panel.model, glm::vec4(paper_white, 0.0f, 0.0f, 0.0f)}; + cmd.pushConstants(layout, vk::ShaderStageFlagBits::eAll, 0, push); cmd.draw(6, 1, 0, 0); draw_index++; } diff --git a/engine/src/toast/ui/render/world_ui_pass.hpp b/engine/src/toast/ui/render/world_ui_pass.hpp index e3fe225e9..4f8b35513 100644 --- a/engine/src/toast/ui/render/world_ui_pass.hpp +++ b/engine/src/toast/ui/render/world_ui_pass.hpp @@ -49,8 +49,10 @@ class WorldUIPass : public IRenderPass { void record(vk::CommandBuffer cmd, uint32_t frame_index, uint32_t image_index) override; private: + /// Mirrors ui_world.slang WorldPush struct PushConstants { glm::mat4 model; + glm::vec4 hdr; }; const renderer::VulkanCore* m_core = nullptr; diff --git a/engine/src/toast/window/sdl_window.cpp b/engine/src/toast/window/sdl_window.cpp index d7ffcfe66..5a3645ad7 100644 --- a/engine/src/toast/window/sdl_window.cpp +++ b/engine/src/toast/window/sdl_window.cpp @@ -56,6 +56,15 @@ void forwardUtf8(std::string_view text) { namespace toast { +auto SDLWindow::queryDisplayHdr(SDL_Window* window) -> DisplayHdr { + const SDL_PropertiesID props = SDL_GetWindowProperties(window); + return { + .enabled = SDL_GetBooleanProperty(props, SDL_PROP_WINDOW_HDR_ENABLED_BOOLEAN, false), + .sdr_white = SDL_GetFloatProperty(props, SDL_PROP_WINDOW_SDR_WHITE_LEVEL_FLOAT, 1.0f), + .headroom = SDL_GetFloatProperty(props, SDL_PROP_WINDOW_HDR_HEADROOM_FLOAT, 1.0f), + }; +} + SDLWindow::SDLWindow(const char* title, unsigned width, unsigned height, uint64_t flags) { ZoneScoped; const bool sdl_initialized = SDL_Init(SDL_INIT_VIDEO); @@ -207,6 +216,12 @@ void SDLWindow::pollEvents() { break; } + case SDL_EVENT_WINDOW_HDR_STATE_CHANGED: { + const DisplayHdr hdr = queryDisplayHdr(m.sdl_window.get()); + event::send(hdr.enabled, hdr.sdr_white, hdr.headroom); + break; + } + case SDL_EVENT_WINDOW_FOCUS_GAINED: { event::send(true); break; diff --git a/engine/src/toast/window/sdl_window.hpp b/engine/src/toast/window/sdl_window.hpp index 628d4af85..7c5547af0 100644 --- a/engine/src/toast/window/sdl_window.hpp +++ b/engine/src/toast/window/sdl_window.hpp @@ -20,6 +20,15 @@ namespace toast { class SDLWindow : public IBaseWindow { public: + struct DisplayHdr { + bool enabled = false; + float sdr_white = 1.0f; + float headroom = 1.0f; + }; + + /// Main thread only + static auto queryDisplayHdr(SDL_Window* window) -> DisplayHdr; + SDLWindow(const char* title, unsigned width = 1080, unsigned height = 720, uint64_t flags = 0); ~SDLWindow() override = default; diff --git a/engine/src/toast/window/startup_splash.cpp b/engine/src/toast/window/startup_splash.cpp new file mode 100644 index 000000000..ccdd5e424 --- /dev/null +++ b/engine/src/toast/window/startup_splash.cpp @@ -0,0 +1,100 @@ +#include "startup_splash.hpp" + +#include +#include +#include + +namespace toast { + +namespace { + +constexpr int k_width = 640; +constexpr int k_height = 220; +/// SDL_RenderDebugText glyphs are 8 pixels +constexpr float k_scale = 2.0f; +constexpr size_t k_listed_shaders = 3; + +} + +StartupSplash::~StartupSplash() { + if (m_renderer != nullptr) { + SDL_DestroyRenderer(m_renderer); + } + if (m_window != nullptr) { + SDL_DestroyWindow(m_window); + } + if (m_video_ref) { + SDL_QuitSubSystem(SDL_INIT_VIDEO); + } +} + +void StartupSplash::update(size_t finished, size_t total, std::span compiling) { + if (m_window == nullptr) { + if (compiling.empty()) { + return; + } + if (!SDL_InitSubSystem(SDL_INIT_VIDEO)) { + TOAST_WARN("SDLWindow", "Startup splash needs SDL video: {}", SDL_GetError()); + return; + } + m_video_ref = true; + m_window = SDL_CreateWindow("Toast Engine", k_width, k_height, 0); + if (m_window == nullptr) { + TOAST_WARN("SDLWindow", "Startup splash window failed: {}", SDL_GetError()); + return; + } + m_renderer = SDL_CreateRenderer(m_window, "software"); + if (m_renderer == nullptr) { + TOAST_WARN("SDLWindow", "Startup splash renderer failed: {}", SDL_GetError()); + } + } + + for (SDL_Event event; SDL_PollEvent(&event);) { + if (event.type == SDL_EVENT_QUIT || event.type == SDL_EVENT_WINDOW_CLOSE_REQUESTED) { + m_close_requested = true; + } + } + + draw(finished, total, compiling); +} + +void StartupSplash::draw(size_t finished, size_t total, std::span compiling) const { + if (m_renderer == nullptr) { + return; + } + + SDL_SetRenderScale(m_renderer, k_scale, k_scale); + SDL_SetRenderDrawColor(m_renderer, 22, 22, 26, 255); + SDL_RenderClear(m_renderer); + + constexpr float margin = 16.0f; + constexpr float logical_width = static_cast(k_width) / k_scale; + + SDL_SetRenderDrawColor(m_renderer, 160, 160, 170, 255); + SDL_RenderDebugText(m_renderer, margin, 17.0f, "Compiling shaders..."); + + const float fraction = total == 0 ? 1.0f : std::clamp(static_cast(finished) / static_cast(total), 0.0f, 1.0f); + const SDL_FRect track {margin, 48.0f, logical_width - (2.0f * margin), 10.0f}; + SDL_SetRenderDrawColor(m_renderer, 48, 48, 56, 255); + SDL_RenderFillRect(m_renderer, &track); + const SDL_FRect fill {track.x, track.y, track.w * fraction, track.h}; + SDL_SetRenderDrawColor(m_renderer, 255, 176, 64, 255); + SDL_RenderFillRect(m_renderer, &fill); + + SDL_SetRenderDrawColor(m_renderer, 240, 240, 240, 255); + SDL_RenderDebugText(m_renderer, margin, 64.0f, std::format("{} / {}", finished, total).c_str()); + + SDL_SetRenderDrawColor(m_renderer, 160, 160, 170, 255); + float y = 80.0f; + for (size_t i = 0; i < std::min(compiling.size(), k_listed_shaders); ++i) { + SDL_RenderDebugText(m_renderer, margin, y, compiling[i].c_str()); + y += 10.0f; + } + if (compiling.size() > k_listed_shaders) { + SDL_RenderDebugText(m_renderer, margin, y, std::format("+{} more", compiling.size() - k_listed_shaders).c_str()); + } + + SDL_RenderPresent(m_renderer); +} + +} diff --git a/engine/src/toast/window/startup_splash.hpp b/engine/src/toast/window/startup_splash.hpp new file mode 100644 index 000000000..23bdc343e --- /dev/null +++ b/engine/src/toast/window/startup_splash.hpp @@ -0,0 +1,42 @@ +/** + * @file startup_splash.hpp + * @author dario + * @date 28 Oct 2026 + */ + +#pragma once + +#include +#include +#include +#include + +namespace toast { + +/// Software drawn window shown during the first shader compile +class StartupSplash { +public: + StartupSplash() = default; + ~StartupSplash(); + + StartupSplash(const StartupSplash&) = delete; + auto operator=(const StartupSplash&) -> StartupSplash& = delete; + + /// Opens on the first call with something compiling + void update(size_t finished, size_t total, std::span compiling); + + [[nodiscard]] + auto closeRequested() const -> bool { + return m_close_requested; + } + +private: + void draw(size_t finished, size_t total, std::span compiling) const; + + SDL_Window* m_window = nullptr; + SDL_Renderer* m_renderer = nullptr; + bool m_close_requested = false; + bool m_video_ref = false; +}; + +} diff --git a/engine/src/toast/window/window_events.hpp b/engine/src/toast/window/window_events.hpp index a64a91ac1..9e2e3bc16 100644 --- a/engine/src/toast/window/window_events.hpp +++ b/engine/src/toast/window/window_events.hpp @@ -107,6 +107,16 @@ struct WindowFocus : Event { explicit WindowFocus(bool focused) : focused(focused) { } }; +/// @brief Display HDR state or SDR white level changed +/// @note scRGB where 1.0 is 80 nits +struct WindowHdr : Event { + bool enabled; + float sdr_white; + float headroom; + + WindowHdr(bool enabled, float sdr_white, float headroom) : enabled(enabled), sdr_white(sdr_white), headroom(headroom) { } +}; + /// @brief The debug overlay is up in game view and needs a free cursor /// @note Queued before the state the editor polls flips so input it sends after reacting lands behind it struct DebugOverlayCursor : Event { diff --git a/engine/src/toast/world/light.hpp b/engine/src/toast/world/light.hpp index d480dd0e4..471376e15 100644 --- a/engine/src/toast/world/light.hpp +++ b/engine/src/toast/world/light.hpp @@ -9,6 +9,7 @@ #pragma once #include "node_3d.hpp" +#include #include namespace toast { @@ -111,6 +112,12 @@ class [[ToastNode, Hidden, Icon("PointLight")]] TOAST_API Light : public Node3D return m_shadow_mode; } + void setSourceRadius(float radius) { m_source_radius = std::max(radius, 0.0f); } + + void setSourceLength(float length) { m_source_length = std::max(length, 0.0f); } + + void setSourceWidth(float width) { m_source_width = std::max(width, 0.0f); } + protected: /// @brief Tags the concrete light kind; called once from each subclass's own constructor body void setLightType(LightType type) { m_light_type = type; } diff --git a/engine/src/toast/world/light_gizmo.cpp b/engine/src/toast/world/light_gizmo.cpp new file mode 100644 index 000000000..2453d7149 --- /dev/null +++ b/engine/src/toast/world/light_gizmo.cpp @@ -0,0 +1,217 @@ +#include "light_gizmo.hpp" + +#include "point_light.hpp" +#include "spotlight.hpp" + +#include +#include +#include +#include +#include +#include + +namespace toast::light_gizmo { + +namespace { + +constexpr float k_min_extent = 0.01f; +constexpr float k_min_angle = 0.5f; +constexpr float k_max_angle = 89.0f; +constexpr int k_circle_segments = 40; + +struct Basis { + glm::vec3 origin {0.0f}; + glm::vec3 right {1.0f, 0.0f, 0.0f}; + glm::vec3 forward {0.0f, 1.0f, 0.0f}; + glm::vec3 up {0.0f, 0.0f, 1.0f}; +}; + +auto basisOf(const Light& light) -> Basis { + light.syncTransform(); + const glm::quat rotation = light.world_rotation; + return { + .origin = light.world_position, + .right = rotation * Node3D::world_right, + .forward = rotation * Node3D::world_forward, + .up = rotation * Node3D::world_up, + }; +} + +auto rangeOf(const Light& light) -> float { + if (light.lightType() == LightType::spot) { + return std::max(static_cast(light).attenuation(), k_min_extent); + } + if (light.lightType() == LightType::point) { + return std::max(static_cast(light).attenuation(), k_min_extent); + } + return 0.0f; +} + +void circle(const glm::vec3& center, const glm::vec3& u, const glm::vec3& v, float radius, const glm::vec4& color) { + glm::vec3 previous = center + u * radius; + for (int i = 1; i <= k_circle_segments; ++i) { + const float angle = glm::two_pi() * static_cast(i) / static_cast(k_circle_segments); + const glm::vec3 next = center + (u * std::cos(angle) + v * std::sin(angle)) * radius; + debug::drawLine(previous, next, color); + previous = next; + } +} + +} + +auto handles(const Light& light) -> std::pair, uint32_t> { + std::array out {}; + uint32_t count = 0; + const auto add = [&](GizmoHandle handle, glm::vec3 local) { out[count++] = Handle {.handle = handle, .local = local}; }; + + const LightType type = light.lightType(); + if (type != LightType::spot && type != LightType::point) { + return {out, 0}; + } + + const float range = rangeOf(light); + add(GizmoHandle::size_pos_y, {0.0f, range, 0.0f}); + + if (type == LightType::spot) { + const auto& spot = static_cast(light); + add(GizmoHandle::size_pos_x, {std::tan(glm::radians(spot.outerRadius())) * range, range, 0.0f}); + add(GizmoHandle::size_neg_x, {-std::tan(glm::radians(spot.innerRadius())) * range, range, 0.0f}); + return {out, count}; + } + + switch (light.shape()) { + case LightShape::point: break; + case LightShape::sphere: + case LightShape::disc: add(GizmoHandle::size_pos_z, {0.0f, 0.0f, light.sourceRadius()}); break; + case LightShape::capsule: + add(GizmoHandle::size_pos_z, {0.0f, 0.0f, light.sourceRadius()}); + add(GizmoHandle::size_neg_y, {0.0f, -0.5f * light.sourceLength(), 0.0f}); + break; + case LightShape::rect: + add(GizmoHandle::size_pos_x, {0.5f * light.sourceWidth(), 0.0f, 0.0f}); + add(GizmoHandle::size_pos_z, {0.0f, 0.0f, 0.5f * light.sourceLength()}); + break; + } + return {out, count}; +} + +auto handleDistance(const Light& light, GizmoHandle handle) -> float { + const auto [entries, count] = handles(light); + for (uint32_t i = 0; i < count; ++i) { + if (entries[i].handle == handle) { + return std::abs(entries[i].local[gizmo_layout::sizeHandleAxis(handle)]); + } + } + return 0.0f; +} + +void applyHandleDistance(Light& light, GizmoHandle handle, float distance) { + distance = std::max(distance, k_min_extent); + const LightType type = light.lightType(); + + if (handle == GizmoHandle::size_pos_y) { + if (type == LightType::spot) { + static_cast(light).setAttenuation(distance); + } else if (type == LightType::point) { + static_cast(light).setAttenuation(distance); + } + return; + } + + if (type == LightType::spot) { + auto& spot = static_cast(light); + const float angle = std::clamp(glm::degrees(std::atan(distance / rangeOf(light))), k_min_angle, k_max_angle); + if (handle == GizmoHandle::size_pos_x) { + spot.setOuterRadius(angle); + spot.setInnerRadius(std::min(spot.innerRadius(), angle)); + } else if (handle == GizmoHandle::size_neg_x) { + spot.setInnerRadius(std::min(angle, spot.outerRadius())); + } + return; + } + + const bool rect = light.shape() == LightShape::rect; + switch (handle) { + case GizmoHandle::size_pos_z: + if (rect) { + light.setSourceLength(2.0f * distance); + } else { + light.setSourceRadius(distance); + } + break; + case GizmoHandle::size_neg_y: light.setSourceLength(2.0f * distance); break; + case GizmoHandle::size_pos_x: light.setSourceWidth(2.0f * distance); break; + default: break; + } +} + +auto handleField(const Light& light, GizmoHandle handle) -> const char* { + const LightType type = light.lightType(); + if (handle == GizmoHandle::size_pos_y) { + return "m_attenuation"; + } + if (type == LightType::spot) { + if (handle == GizmoHandle::size_pos_x) { + return "m_outer_radius"; + } + return handle == GizmoHandle::size_neg_x ? "m_inner_radius" : nullptr; + } + switch (handle) { + case GizmoHandle::size_pos_z: return light.shape() == LightShape::rect ? "m_source_length" : "m_source_radius"; + case GizmoHandle::size_neg_y: return "m_source_length"; + case GizmoHandle::size_pos_x: return "m_source_width"; + default: return nullptr; + } +} + +void draw(const Light& light) { + const LightType type = light.lightType(); + const Basis basis = basisOf(light); + const glm::vec4 color(light.color(), 1.0f); + const glm::vec4 faint(light.color() * 0.5f, 1.0f); + + switch (type) { + case LightType::directional: debug::drawArrow(basis.origin, basis.origin + basis.forward * 2.0f, color); return; + case LightType::spot: { + const auto& spot = static_cast(light); + const float range = rangeOf(light); + debug::drawCone(basis.origin, basis.forward, range, spot.outerRadius(), color); + debug::drawCone(basis.origin, basis.forward, range, std::min(spot.innerRadius(), spot.outerRadius()), faint); + break; + } + case LightType::point: debug::drawSphere(basis.origin, rangeOf(light), faint); break; + default: return; + } + + const float radius = light.sourceRadius(); + switch (light.shape()) { + case LightShape::point: break; + case LightShape::sphere: debug::drawSphere(basis.origin, radius, color); break; + case LightShape::capsule: { + // The capsule helper runs along local z so z takes the light forward axis + glm::mat4 transform(1.0f); + transform[0] = glm::vec4(basis.right, 0.0f); + transform[1] = glm::vec4(glm::cross(basis.forward, basis.right), 0.0f); + transform[2] = glm::vec4(basis.forward, 0.0f); + transform[3] = glm::vec4(basis.origin, 1.0f); + debug::drawCapsule(transform, radius, light.sourceLength() + (2.0f * radius), color, false); + break; + } + case LightShape::disc: + circle(basis.origin, basis.right, basis.up, radius, color); + debug::drawArrow(basis.origin, basis.origin + basis.forward * std::max(radius, 0.3f), color, 0.08f); + break; + case LightShape::rect: { + const glm::vec3 w = basis.right * (0.5f * light.sourceWidth()); + const glm::vec3 h = basis.up * (0.5f * light.sourceLength()); + debug::drawLine(basis.origin - w - h, basis.origin + w - h, color); + debug::drawLine(basis.origin + w - h, basis.origin + w + h, color); + debug::drawLine(basis.origin + w + h, basis.origin - w + h, color); + debug::drawLine(basis.origin - w + h, basis.origin - w - h, color); + debug::drawArrow(basis.origin, basis.origin + basis.forward * 0.4f, color, 0.08f); + break; + } + } +} + +} diff --git a/engine/src/toast/world/light_gizmo.hpp b/engine/src/toast/world/light_gizmo.hpp new file mode 100644 index 000000000..3738a9032 --- /dev/null +++ b/engine/src/toast/world/light_gizmo.hpp @@ -0,0 +1,42 @@ +/** + * @file light_gizmo.hpp + * @author dario + * @date 29/10/2026 + * @brief Selected light shapes and the viewport handles that edit them, laid out like the collider size handles + */ + +#pragma once + +#include "gizmo_layout.hpp" +#include "light.hpp" + +#include +#include +#include + +namespace toast::light_gizmo { + +constexpr size_t k_max_handles = 6; + +struct Handle { + GizmoHandle handle = GizmoHandle::none; + /// Light local space where x is right, y forward and z up + glm::vec3 local {0.0f}; +}; + +[[nodiscard]] +auto handles(const Light& light) -> std::pair, uint32_t>; + +/// Where the handle sits along its own axis, always positive +[[nodiscard]] +auto handleDistance(const Light& light, GizmoHandle handle) -> float; + +void applyHandleDistance(Light& light, GizmoHandle handle, float distance); + +/// Reflected name of the field a handle edits or null +[[nodiscard]] +auto handleField(const Light& light, GizmoHandle handle) -> const char*; + +void draw(const Light& light); + +} diff --git a/engine/src/toast/world/point_light.hpp b/engine/src/toast/world/point_light.hpp index 36b9ffe8b..dbc23aad0 100644 --- a/engine/src/toast/world/point_light.hpp +++ b/engine/src/toast/world/point_light.hpp @@ -23,6 +23,8 @@ class [[ToastNode, Icon("PointLight")]] TOAST_API PointLight : public Light { return m_attenuation; } + void setAttenuation(float range) { m_attenuation = std::max(range, 0.0f); } + private: [[Reflect, Unit("m"), Range(0, 1000)]] float m_attenuation = 10.0f; diff --git a/engine/src/toast/world/post_process_volume.cpp b/engine/src/toast/world/post_process_volume.cpp index bb2ef132f..8ca7860ed 100644 --- a/engine/src/toast/world/post_process_volume.cpp +++ b/engine/src/toast/world/post_process_volume.cpp @@ -32,8 +32,16 @@ void PostProcessVolume::syncSettings() const { m_settings.bloom.filter_radius = m_bloom_filter_radius; m_settings.bloom.strength = m_bloom_strength; + m_settings.lens_flare.intensity = m_lens_flare_intensity; + m_settings.lens_flare.threshold = m_lens_flare_threshold; + m_settings.lens_flare.ghost_intensity = m_ghost_intensity; + m_settings.lens_flare.halo_intensity = m_halo_intensity; + m_settings.lens_flare.glare_intensity = m_glare_intensity; + m_settings.tonemap.exposure = m_exposure; m_settings.tonemap.mode = static_cast(std::max(m_tonemap_mode, 0)); + m_settings.tonemap.gt_contrast_dip = m_gt_contrast_dip; + m_settings.tonemap.chromatic_aberration = m_chromatic_aberration; m_settings.tonemap.gamma = m_gamma; m_settings.tonemap.contrast = m_contrast; m_settings.tonemap.saturation = m_saturation; diff --git a/engine/src/toast/world/post_process_volume.hpp b/engine/src/toast/world/post_process_volume.hpp index 362c8369b..aeb7240c3 100644 --- a/engine/src/toast/world/post_process_volume.hpp +++ b/engine/src/toast/world/post_process_volume.hpp @@ -98,11 +98,27 @@ class [[ToastNode, Icon("WorldEnvironment")]] TOAST_API PostProcessVolume : publ [[Reflect, Name("Bloom Strength"), Range(0.0, 1.0), Group("Bloom")]] float m_bloom_strength = 0.05f; + [[Reflect, Name("Lens Flare Intensity"), Range(0.0, 2.0), Group("Lens Flare")]] + float m_lens_flare_intensity = 0.1f; + [[Reflect, Name("Lens Flare Threshold"), Range(0.0, 20.0), Group("Lens Flare")]] + float m_lens_flare_threshold = 1.0f; + [[Reflect, Name("Ghost Intensity"), Range(0.0, 4.0), Group("Lens Flare")]] + float m_ghost_intensity = 1.0f; + [[Reflect, Name("Halo Intensity"), Range(0.0, 4.0), Group("Lens Flare")]] + float m_halo_intensity = 1.0f; + [[Reflect, Name("Glare Intensity"), Range(0.0, 2.0), Group("Lens Flare")]] + float m_glare_intensity = 0.2f; + [[Reflect, Name("Exposure"), Range(0.0, 8.0), Group("Tonemap")]] float m_exposure = 1.0f; /// Switches at the halfway point of a blend rather than interpolating - there is no curve in between - [[Reflect, Name("Tonemap Curve"), Enum("Reinhard", "ACES"), Group("Tonemap")]] + [[Reflect, Name("Tonemap Curve"), Enum("Reinhard", "ACES", "GT7"), Group("Tonemap")]] int32_t m_tonemap_mode = 0; + /// GT7 toe exponent under the grey point, 1 is linear and higher darkens the shadows + [[Reflect, Name("GT7 Contrast Dip"), Range(1.0, 2.0), Group("Tonemap")]] + float m_gt_contrast_dip = 1.28f; + [[Reflect, Name("Chromatic Aberration"), Range(0.0, 0.05), Group("Tonemap")]] + float m_chromatic_aberration = 0.0f; [[Reflect, Name("Gamma"), Range(1.0, 3.0), Group("Tonemap")]] float m_gamma = 2.2f; [[Reflect, Name("Contrast"), Range(0.0, 2.0), Group("Tonemap")]] diff --git a/engine/src/toast/world/spotlight.hpp b/engine/src/toast/world/spotlight.hpp index a4c030c78..dd7a66efa 100644 --- a/engine/src/toast/world/spotlight.hpp +++ b/engine/src/toast/world/spotlight.hpp @@ -33,6 +33,12 @@ class [[ToastNode, Icon("SpotLight")]] TOAST_API Spotlight : public Light { return m_attenuation; } + void setAttenuation(float range) { m_attenuation = std::max(range, 0.0f); } + + void setInnerRadius(float degrees) { m_inner_radius = degrees; } + + void setOuterRadius(float degrees) { m_outer_radius = degrees; } + private: [[Reflect, Unit("°"), Range(0.0, 90.0)]] float m_inner_radius = 15.0f; diff --git a/engine/src/toast/world/workspace.cpp b/engine/src/toast/world/workspace.cpp index 1d22172a4..9f81c8e0f 100644 --- a/engine/src/toast/world/workspace.cpp +++ b/engine/src/toast/world/workspace.cpp @@ -2,6 +2,7 @@ #include "animation_player.hpp" #include "camera.hpp" +#include "light_gizmo.hpp" #include "node.hpp" #include "node_3d.hpp" #include "play_workspace.hpp" @@ -321,6 +322,10 @@ static auto gizmoField(GizmoTool tool, GizmoHandle handle, Node& node) -> const if (box.as().exists()) { return node.info()->getField("size"); } + if (const auto light = box.as(); light.exists()) { + const char* name = light_gizmo::handleField(*light, handle); + return name != nullptr ? node.info()->getField(name) : nullptr; + } if (box.as().exists()) { return node.info()->getField("size"); } @@ -1106,6 +1111,14 @@ auto Workspace::collectSizeHandles() const -> std::pair(); light.exists()) { + const auto [entries, entry_count] = light_gizmo::handles(*light); + for (uint32_t i = 0; i < entry_count; ++i) { + out[count++] = SizeHandlePoint {.world_position = origin + rotation * entries[i].local, .handle = entries[i].handle}; + } + return {out, count}; + } + if (auto volume = m_focused_node.as(); volume.exists()) { const glm::vec3 half = glm::vec3(volume->sizeVoxels()) * voxel::k_voxel_size * 0.5f; const glm::mat4& world = node3d->getWorldTransform(); @@ -1166,6 +1179,11 @@ void Workspace::gizmoApplySizeDrag(float delta) { growth = std::round(growth / m_translate_snap.value) * m_translate_snap.value; } + if (auto light = m_focused_node.as(); light.exists()) { + light_gizmo::applyHandleDistance(*light, m_gizmo_drag, m_gizmo_drag_start_size[axis] + growth); + return; + } + // Volumes grow a whole voxel at a time and the negative faces move the corner if (auto volume = m_focused_node.as(); volume.exists()) { const int32_t start = static_cast(m_gizmo_drag_start_size[axis]); @@ -1287,7 +1305,9 @@ void Workspace::gizmoBeginDrag(GizmoHandle handle) { m_gizmo_drag_start_size = glm::vec3(0.0f); if (gizmo_layout::isSizeHandle(handle)) { - if (auto volume = m_focused_node.as(); volume.exists()) { + if (auto light = m_focused_node.as(); light.exists()) { + m_gizmo_drag_start_size[gizmo_layout::sizeHandleAxis(handle)] = light_gizmo::handleDistance(*light, handle); + } else if (auto volume = m_focused_node.as(); volume.exists()) { m_gizmo_drag_start_size = glm::vec3(volume->sizeVoxels()); } else if (auto box = m_focused_node.as(); box.exists()) { m_gizmo_drag_start_size = box->size; @@ -1386,7 +1406,9 @@ void Workspace::gizmoUpdateDrag() { const glm::vec3 current = origin + m_gizmo_drag_axis * t2; gizmoApplySizeDrag(glm::dot(current - m_gizmo_drag_anchor, m_gizmo_drag_axis)); - m_focused_node->onReflectedFieldChanged("size"); + const auto light = m_focused_node.as(); + const char* light_field = light.exists() ? light_gizmo::handleField(*light, m_gizmo_drag) : nullptr; + m_focused_node->onReflectedFieldChanged(light_field != nullptr ? light_field : "size"); return; }