diff --git a/cmake/Renderer.cmake b/cmake/Renderer.cmake index c7faa65..d3e6b56 100644 --- a/cmake/Renderer.cmake +++ b/cmake/Renderer.cmake @@ -47,6 +47,10 @@ target_link_libraries(faset_render PRIVATE Vulkan::Vulkan SDL3::SDL3 faset_core) target_compile_definitions(faset_render PRIVATE FASET_SHADER_DIRECTORY="${FASET_SHADER_DIRECTORY}") add_dependencies(faset_render faset_shaders) if(BUILD_TESTING) + add_executable(faset_render_lighting_gpu_tests "${PROJECT_SOURCE_DIR}/tests/render_lighting_gpu_tests.cpp") + target_link_libraries(faset_render_lighting_gpu_tests PRIVATE faset_render) + add_test(NAME render_lighting_sun COMMAND faset_render_lighting_gpu_tests --sun) + set_tests_properties(render_lighting_sun PROPERTIES LABELS "gpu;p3") add_executable(faset_render_lighting_policy_tests "${PROJECT_SOURCE_DIR}/tests/render_lighting_policy_tests.cpp") target_link_libraries(faset_render_lighting_policy_tests PRIVATE faset_render) add_test(NAME render_lighting_policy COMMAND faset_render_lighting_policy_tests) diff --git a/include/faset/render/renderer.hpp b/include/faset/render/renderer.hpp index 48427ed..b5517eb 100644 --- a/include/faset/render/renderer.hpp +++ b/include/faset/render/renderer.hpp @@ -184,6 +184,9 @@ struct FrameStats { std::uint32_t texture_count{}; std::uint32_t vertices{}, draw_calls{}, culled_meshes{}, validation_errors{}; std::uint32_t submitted_local_lights{}, omitted_local_lights{}; + std::uint32_t requested_sun_cascades{}, effective_sun_cascades{}; + std::uint32_t sun_shadow_caster_draws{}; + std::uint64_t sun_shadow_atlas_bytes{}; bool gpu_visibility_active{}, hzb_valid{}; // Requested and actual paths for the last frame; actual may be less capable. VisibilityMode requested_visibility_mode{VisibilityMode::Direct}; @@ -195,6 +198,7 @@ struct FrameStats { double cpu_ms{}, gpu_ms{}, readback_cpu_ms{}; double gpu_main_cull_ms{}, gpu_main_raster_ms{}, gpu_hzb_ms{}; double gpu_post_cull_ms{}, gpu_post_raster_ms{}; + double gpu_sun_shadow_ms{}; std::string device; }; struct HzbDebugImage { diff --git a/shaders/baseline.slang b/shaders/baseline.slang index 9f2bb22..e74571f 100644 --- a/shaders/baseline.slang +++ b/shaders/baseline.slang @@ -27,7 +27,7 @@ struct FrameParameters { // Shared Direct/P2 graphics ABI. The legacy material set remains set 0; // GPU-only instance/visibility records occupy set 2. struct LightingHeader { - uint4 counts; // local count, sun enabled, sun shadow enabled, view count + uint4 counts; // local count, sun enabled, sun shadow enabled, sun view count float4 sunDirectionIntensity; // xyz world-space ray direction, w intensity float4 sunColor; float4 cameraForwardShadowDistance; @@ -58,6 +58,26 @@ VertexOutput vertexMain(VertexInput v) { } [shader("vertex")] float4 shadowMain(VertexInput v) : SV_Position { return mul(frame.lightViewProjection, float4(v.world,1)); } +float sampleSunCascade(uint index, float3 world, float nl) { + ShadowViewGpu record = shadowViews[index]; + if (record.biasFlags.w < 0.5) return 1.0; + float4 clip = mul(record.viewProjection, float4(world,1)); + if (clip.w <= 0.0) return 1.0; + float3 projected = clip.xyz / clip.w; + float2 localUV = projected.xy * 0.5 + 0.5; + if (any(localUV < 0.0) || any(localUV > 1.0) || + projected.z < 0.0 || projected.z > 1.0) return 1.0; + float2 atlasUV = localUV * record.tileScaleOffset.xy + record.tileScaleOffset.zw; + float bias = max(record.biasFlags.x, record.biasFlags.y * (1.0 - nl)); + float visible = 0.0; + for (int y=-1; y<=1; ++y) for (int x=-1; x<=1; ++x) { + float2 tap = clamp(atlasUV + float2(x,y) * record.biasFlags.z, + record.guardedClamp.xy, record.guardedClamp.zw); + float depth = shadowMap.SampleLevel(shadowSampler, tap, 0); + visible += projected.z - bias <= depth ? 1.0 / 9.0 : 0.0; + } + return visible; +} float3 directBRDF(float3 base, float rough, float metal, float3 n, float3 view, float3 l) { const float pi = 3.14159265; float nl = max(dot(n,l),0.0); @@ -94,15 +114,29 @@ float4 fragmentMain(VertexOutput v) : SV_Target { float3 l=normalize(-lighting.sunDirectionIntensity.xyz); float nl=max(dot(n,l),0.0); float visibility=1.0; - if (lighting.counts.z != 0 && nl > 0) { - float4 lightClip=mul(frame.lightViewProjection,float4(v.world,1)); - float3 projected=lightClip.xyz/lightClip.w; - float2 uv=projected.xy*.5+.5; - if(all(uv>=0.0)&&all(uv<=1.0)&&projected.z>=0.0&&projected.z<=1.0) { - visibility=0.0; - for(int y=-1;y<=1;++y) for(int x=-1;x<=1;++x) { - float depth=shadowMap.SampleLevel(shadowSampler,uv+float2(x,y)/1024.0,0); - visibility += projected.z-max(0.0008,0.003*(1.0-nl)) <= depth ? 1.0/9.0 : 0.0; + if (lighting.counts.z != 0 && lighting.counts.w != 0 && nl > 0) { + if (lighting.counts.w == 1) { + visibility = sampleSunCascade(0, v.world, nl); + } else { + float cameraDepth = dot(v.world - frame.eye.xyz, + lighting.cameraForwardShadowDistance.xyz); + if (cameraDepth >= 0.0 && + cameraDepth <= lighting.cameraForwardShadowDistance.w) { + uint cascade = 0; + while (cascade + 1 < lighting.counts.w && + cameraDepth > lighting.cascadeSplits[cascade]) ++cascade; + visibility = sampleSunCascade(cascade, v.world, nl); + if (cascade + 1 < lighting.counts.w) { + float previousSplit = cascade == 0 ? 0.0 : + lighting.cascadeSplits[cascade-1]; + float blendWidth = max(0.2, + 0.1 * (lighting.cascadeSplits[cascade] - previousSplit)); + float blend = saturate((cameraDepth - + (lighting.cascadeSplits[cascade] - blendWidth)) / blendWidth); + if (blend > 0.0) + visibility = lerp(visibility, + sampleSunCascade(cascade+1, v.world, nl), blend); + } } } } diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 25ebcfb..c471f0b 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -201,7 +201,7 @@ struct SceneResources { std::array previous_viewport{}; std::string previous_view_id; }; -constexpr std::uint32_t shadow_size = 1024; +constexpr std::uint32_t timestamp_capacity = 24; } // namespace struct Renderer::Impl { RendererConfig config; @@ -232,6 +232,7 @@ struct Renderer::Impl { std::vector swap_images; std::vector swap_layouts; Image color, depth, shadow; + std::uint32_t sun_shadow_size{}; Buffer vertices, readback; Buffer lighting_header, lighting_locals, lighting_views; SceneResources scene; @@ -769,14 +770,27 @@ struct Renderer::Impl { VkQueryPoolCreateInfo query{}; query.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO; query.queryType = VK_QUERY_TYPE_TIMESTAMP; - query.queryCount = 12; + query.queryCount = timestamp_capacity; check(vkCreateQueryPool(device, &query, nullptr, ×tamp_pool), "Create GPU timestamp queries"); } - shadow = - make_image(shadow_size, shadow_size, VK_FORMAT_D32_SFLOAT, - VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, - VK_IMAGE_ASPECT_DEPTH_BIT); + const auto shadow_usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | + VK_IMAGE_USAGE_SAMPLED_BIT; + for (const auto size : {2048u, 1024u}) { + if (size > max_image_dimension) + continue; + try { + shadow = make_image(size, size, VK_FORMAT_D32_SFLOAT, shadow_usage, + VK_IMAGE_ASPECT_DEPTH_BIT); + sun_shadow_size = size; + break; + } catch (const std::exception&) { + // Optional atlas allocation may fail; try the bounded half-size profile. + } + } + if (!shadow.handle) + shadow = make_image(1, 1, VK_FORMAT_D32_SFLOAT, shadow_usage, + VK_IMAGE_ASPECT_DEPTH_BIT); make_targets(); make_descriptors(); make_pipelines(); @@ -1723,6 +1737,10 @@ struct Renderer::Impl { auto start = std::chrono::steady_clock::now(); statistics.draw_calls = statistics.culled_meshes = statistics.gpu_label_count = 0; statistics.submitted_local_lights = statistics.omitted_local_lights = 0; + statistics.requested_sun_cascades = statistics.effective_sun_cascades = + statistics.sun_shadow_caster_draws = 0; + statistics.sun_shadow_atlas_bytes = sun_shadow_size ? shadow.allocation_size : 0; + statistics.gpu_sun_shadow_ms = 0; statistics.gpu_bins = statistics.gpu_visible_instances = statistics.gpu_frustum_rejected = statistics.gpu_occlusion_deferred = statistics.gpu_post_visible = 0; @@ -1965,12 +1983,28 @@ struct Renderer::Impl { gpu_frame.textures.push_back(bin.texture); } gpu_frame.candidate_count = static_cast(gpu_frame.candidates.size()); + ShadowBudget shadow_budget; + shadow_budget.sun_atlas_size = sun_shadow_size; + shadow_budget.sun_atlas_available = sun_shadow_size != 0; + const auto shadow_plan = build_shadow_plan(snapshot, shadow_casters, shadow_budget); + const bool sun_raster = sun_shadow_size && + std::any_of(shadow_plan.sun_views.begin(), shadow_plan.sun_views.end(), + [](const ShadowView& view) { + return view.valid && !view.caster_indices.empty(); + }); + statistics.requested_sun_cascades = shadow_plan.requested_sun_cascades; + statistics.effective_sun_cascades = sun_raster + ? shadow_plan.effective_sun_cascades : 0; + if (sun_raster) + for (const auto& view : shadow_plan.sun_views) + if (view.valid) + statistics.sun_shadow_caster_draws += + static_cast(view.caster_indices.size()); std::vector data; - std::vector scene_batches, transparent_batches, shadow_batches, - sprite_batches, ui_batches; + std::vector scene_batches, transparent_batches, sprite_batches, ui_batches; for (const auto& selected : selected_draws) { const auto& item = *selected.source; - if (selected.gpu && !item.cast_shadow) + if (selected.gpu) continue; auto first = data.size(); const auto& mesh = *selected.mesh; @@ -1993,16 +2027,12 @@ struct Renderer::Impl { continue; Batch batch{static_cast(first), count, item.texture ? item.texture.get() : white.get()}; - if (item.cast_shadow) - shadow_batches.push_back(batch); - if (!selected.gpu) { - if (outside(data, first)) - ++statistics.culled_meshes; - else if (gpu_active && !selected.opaque) - transparent_batches.push_back(batch); - else - scene_batches.push_back(batch); - } + if (outside(data, first)) + ++statistics.culled_meshes; + else if (gpu_active && !selected.opaque) + transparent_batches.push_back(batch); + else + scene_batches.push_back(batch); } struct OrderedSprite { const Sprite* sprite; @@ -2079,6 +2109,41 @@ struct Renderer::Impl { triangles.texture ? triangles.texture.get() : white.get(), triangles.clip_rect}); } + std::vector shadow_batch_by_source(snapshot.draws.size()); + if (sun_raster) { + std::vector required(snapshot.draws.size()); + for (const auto& view : shadow_plan.sun_views) + if (view.valid) + for (const auto source : view.caster_indices) + required.at(source) = 1; + for (std::size_t source = 0; source < required.size(); ++source) { + if (!required[source]) + continue; + const auto& item = snapshot.draws[source]; + if (!item.mesh) + continue; + const auto first = data.size(); + const auto& mesh = *item.mesh; // Source LOD 0, independent of camera/P2 LOD. + auto emit = [&](std::uint32_t index) { + if (index >= mesh.vertices.size()) + throw std::out_of_range("Shadow mesh index outside vertex range"); + data.push_back(gpu_vertex(mesh.vertices[index], item, + snapshot.view_projection)); + }; + if (mesh.indices.empty()) + for (std::uint32_t i = 0; i < mesh.vertices.size(); ++i) + emit(i); + else + for (const auto index : mesh.indices) + emit(index); + const auto count = data.size() - first; + if (count % 3 || first > UINT32_MAX || count > UINT32_MAX) + throw std::invalid_argument("Shadow mesh must fit complete triangles"); + shadow_batch_by_source[source] = + {static_cast(first), static_cast(count), + white.get()}; + } + } statistics.vertices = static_cast(data.size() + gpu_frame.vertices.size()); auto byte_count = std::max(sizeof(GpuVertex), data.size() * sizeof(GpuVertex)); if (vertices.size < byte_count) { @@ -2140,7 +2205,7 @@ struct Renderer::Impl { v /= length; LightingHeaderGpu lighting{}; lighting.counts[1] = sun ? 1u : 0u; - lighting.counts[2] = sun && sun->casts_shadow ? 1u : 0u; + lighting.counts[2] = sun_raster ? 1u : 0u; lighting.sun_direction_intensity = {direction[0], direction[1], direction[2], sun ? sun->intensity : 0}; lighting.sun_color = sun ? sun->color : Color{0, 0, 0, 1}; @@ -2153,7 +2218,22 @@ struct Renderer::Impl { const auto& view = snapshot.camera_frustum->view; lighting.camera_forward_shadow_distance = {-view[2], -view[6], -view[10], 80}; } - const auto shadow_plan = build_shadow_plan(snapshot, shadow_casters); + lighting.counts[3] = static_cast(shadow_plan.sun_views.size()); + std::vector gpu_shadow_views; + gpu_shadow_views.reserve(std::max(1, shadow_plan.sun_views.size())); + for (std::size_t i = 0; i < shadow_plan.sun_views.size(); ++i) { + const auto& view = shadow_plan.sun_views[i]; + ShadowViewGpu gpu{}; + gpu.view_projection = view.view_projection; + gpu.tile_scale_offset = view.atlas_scale_offset; + gpu.guarded_clamp = view.guarded_clamp; + gpu.bias_flags = {.0008f, .003f, + sun_shadow_size ? 1.f / float(sun_shadow_size) : 0.f, + sun_raster && view.valid ? 1.f : 0.f}; + gpu_shadow_views.push_back(gpu); + if (i < lighting.cascade_splits.size()) + lighting.cascade_splits[i] = view.split_far; + } statistics.omitted_local_lights = shadow_plan.omitted_local_lights; std::vector gpu_lights; gpu_lights.reserve(shadow_plan.submitted_local_indices.size()); @@ -2186,15 +2266,18 @@ struct Renderer::Impl { statistics.submitted_local_lights = lighting.counts[0]; if (gpu_lights.empty()) gpu_lights.push_back({}); // Descriptors always point at a full initialized record. - const ShadowViewGpu empty_shadow_view{}; + if (gpu_shadow_views.empty()) + gpu_shadow_views.push_back({}); // Always bind an initialized record. upload_scene_buffer(lighting_header, &lighting, sizeof(lighting), 0); upload_scene_vector(lighting_locals, gpu_lights); - upload_scene_buffer(lighting_views, &empty_shadow_view, sizeof(empty_shadow_view), 0); + upload_scene_vector(lighting_views, gpu_shadow_views); update_lighting_descriptors(); Vec3 light_eye{-direction[0] * 30, -direction[1] * 30, -direction[2] * 30}; Vec3 light_up = std::abs(direction[1]) > .98f ? Vec3{0, 0, 1} : Vec3{0, 1, 0}; - Push push{multiply(orthographic(-20, 20, -20, 20, .1f, 80), - look_at(light_eye, {0, 0, 0}, light_up)), + Push push{sun_raster && !shadow_plan.sun_views.empty() + ? shadow_plan.sun_views.front().view_projection + : multiply(orthographic(-20, 20, -20, 20, .1f, 80), + look_at(light_eye, {0, 0, 0}, light_up)), {direction[0], direction[1], direction[2], 0}, {snapshot.eye[0], snapshot.eye[1], snapshot.eye[2], 1}}; std::optional swap_index; @@ -2218,7 +2301,7 @@ struct Renderer::Impl { std::uint32_t timestamp_cursor = 0; std::vector timestamp_labels; if (timestamp_pool) { - vkCmdResetQueryPool(command, timestamp_pool, 0, 12); + vkCmdResetQueryPool(command, timestamp_pool, 0, timestamp_capacity); vkCmdWriteTimestamp2(command, VK_PIPELINE_STAGE_2_TOP_OF_PIPE_BIT, timestamp_pool, timestamp_cursor++); } @@ -2349,7 +2432,7 @@ struct Renderer::Impl { } } end{*this}; callback(); - if (timestamp_pool && timestamp_cursor < 12) { + if (timestamp_pool && timestamp_cursor < timestamp_capacity) { vkCmdWriteTimestamp2(command, VK_PIPELINE_STAGE_2_BOTTOM_OF_PIPE_BIT, timestamp_pool, timestamp_cursor++); @@ -2357,35 +2440,63 @@ struct Renderer::Impl { } }); }; - add_pass("ShadowMap", {}, {"shadow"}, [&] { - transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL, - VK_IMAGE_ASPECT_DEPTH_BIT); - VkRenderingAttachmentInfo attachment{}; - attachment.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO; - attachment.imageView = shadow.view; - attachment.imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL; - attachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; - attachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE; - attachment.clearValue.depthStencil = {1, 0}; - VkRenderingInfo rendering{}; - rendering.sType = VK_STRUCTURE_TYPE_RENDERING_INFO; - rendering.renderArea = {{0, 0}, {shadow_size, shadow_size}}; - rendering.layerCount = 1; - rendering.pDepthAttachment = &attachment; - vkCmdBeginRendering(command, &rendering); - set_viewport(shadow_size, shadow_size); - vkCmdBindPipeline(command, VK_PIPELINE_BIND_POINT_GRAPHICS, shadow_pipeline); - vkCmdPushConstants(command, pipeline_layout, - VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, - sizeof(push), &push); - for (auto batch : shadow_batches) { - vkCmdDraw(command, batch.count, 1, batch.first, 0); - ++statistics.draw_calls; - } - vkCmdEndRendering(command); - transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL, - VK_IMAGE_ASPECT_DEPTH_BIT); - }); + if (sun_raster) + add_pass("SunShadowAtlas", {}, {"shadow"}, [&] { + transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL, + VK_IMAGE_ASPECT_DEPTH_BIT); + VkRenderingAttachmentInfo attachment{}; + attachment.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO; + attachment.imageView = shadow.view; + attachment.imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL; + attachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + attachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachment.clearValue.depthStencil = {1, 0}; + VkRenderingInfo rendering{}; + rendering.sType = VK_STRUCTURE_TYPE_RENDERING_INFO; + rendering.renderArea = {{0, 0}, {sun_shadow_size, sun_shadow_size}}; + rendering.layerCount = 1; + rendering.pDepthAttachment = &attachment; + vkCmdBeginRendering(command, &rendering); + vkCmdBindPipeline(command, VK_PIPELINE_BIND_POINT_GRAPHICS, + shadow_pipeline); + for (const auto& view : shadow_plan.sun_views) { + if (!view.valid || view.caster_indices.empty()) + continue; + const auto guard = (view.tile_size - view.usable_size) / 2; + const auto x = view.tile_origin_x + guard; + const auto y = view.tile_origin_y + guard; + const VkViewport viewport{float(x), float(y), float(view.usable_size), + float(view.usable_size), 0, 1}; + const VkRect2D scissor{{static_cast(x), + static_cast(y)}, + {view.usable_size, view.usable_size}}; + vkCmdSetViewport(command, 0, 1, &viewport); + vkCmdSetScissor(command, 0, 1, &scissor); + auto view_push = push; + view_push.light_view_projection = view.view_projection; + vkCmdPushConstants(command, pipeline_layout, + VK_SHADER_STAGE_VERTEX_BIT | + VK_SHADER_STAGE_FRAGMENT_BIT, + 0, sizeof(view_push), &view_push); + for (const auto source : view.caster_indices) { + const auto& batch = shadow_batch_by_source.at(source); + if (!batch.count) + continue; + vkCmdDraw(command, batch.count, 1, batch.first, 0); + ++statistics.draw_calls; + } + } + vkCmdEndRendering(command); + transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL, + VK_IMAGE_ASPECT_DEPTH_BIT); + }); + else + add_pass("ShadowFallback", {}, {"shadow"}, [&] { + // A bound descriptor still needs a matching image layout, even when + // every graphics shader branch treats its shadow as unshadowed. + transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL, + VK_IMAGE_ASPECT_DEPTH_BIT); + }); if (gpu_active) add_pass("MainCull", {"shadow"}, {"main_indirect", "main_visible", "deferred_ids"}, [&] { @@ -2692,7 +2803,7 @@ struct Renderer::Impl { graph.execute(); submit(swap_index.has_value()); if (timestamp_pool) { - std::array stamps{}; + std::array stamps{}; check(vkGetQueryPoolResults(device, timestamp_pool, 0, timestamp_cursor, timestamp_cursor * sizeof(std::uint64_t), stamps.data(), sizeof(std::uint64_t), @@ -2712,6 +2823,7 @@ struct Renderer::Impl { const auto elapsed = milliseconds(stamps[i], stamps[i + 1]); const auto& label = timestamp_labels[i]; if (label == "MainCull") statistics.gpu_main_cull_ms = elapsed; + else if (label == "SunShadowAtlas") statistics.gpu_sun_shadow_ms = elapsed; else if (label == "MainRaster" || label == "ForwardAndUI") statistics.gpu_main_raster_ms = elapsed; else if (label == "BuildCurrentHZB") statistics.gpu_hzb_ms = elapsed; diff --git a/tests/render_lighting_gpu_tests.cpp b/tests/render_lighting_gpu_tests.cpp new file mode 100644 index 0000000..da869b4 --- /dev/null +++ b/tests/render_lighting_gpu_tests.cpp @@ -0,0 +1,159 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace faset::render; +namespace { +void require(bool condition, const std::string& message) { + if (!condition) + throw std::runtime_error(message); +} +struct Frame { + std::vector pixels; + FrameStats stats; +}; +Frame capture(Renderer& renderer, const Snapshot& scene) { + renderer.render(scene); + return {renderer.pixels(), renderer.stats()}; +} +Renderer make_renderer(VisibilityMode mode) { + RendererConfig config; + config.width = 320; + config.height = 240; + config.headless = true; + config.validation = true; + config.visibility_mode = mode; + config.visibility_diagnostics = true; + return Renderer(config); +} +void compare_frames(const Frame& direct, const Frame& gpu) { + require(direct.pixels.size() == gpu.pixels.size(), "Lighting image dimensions match"); + std::uint64_t error{}; + std::size_t bad{}; + for (std::size_t i = 0; i < direct.pixels.size(); i += 4) { + int worst{}; + for (int channel = 0; channel < 3; ++channel) { + const int difference = std::abs(int(direct.pixels[i + channel]) - + int(gpu.pixels[i + channel])); + error += difference; + worst = std::max(worst, difference); + } + bad += worst > 16; + } + const auto count = direct.pixels.size() / 4; + require(bad <= std::max(24, count / 200) && + double(error) / double(count * 3) <= 2.0, + "Direct and GPU sun lighting images agree (bad=" + std::to_string(bad) + + ", mean=" + std::to_string(double(error) / double(count * 3)) + ")"); +} +Snapshot scene(bool caster) { + Snapshot result; + result.view_id = "p3-offscreen-sun"; + result.eye = {0, 5, 8}; + const auto view = look_at(result.eye, {0, -1, 0}); + const auto projection = orthographic(-2.5f, 2.5f, -2, 2, .1f, 50); + result.projection = projection; + result.view_projection = multiply(projection, view); + result.camera_frustum = CameraFrustum{view, projection, .1f, 50.f, false}; + DrawItem receiver; + receiver.mesh = cube_mesh(); + receiver.model = transform({0, -1, 0}, {}, {8, .1f, 8}); + receiver.color = {.8f, .8f, .8f, 1}; + receiver.instance_key = "receiver"; + result.draws.push_back(receiver); + if (caster) { + DrawItem shadow_caster; + shadow_caster.mesh = cube_mesh(); + shadow_caster.model = transform({3, 1, 0}, {}, {.8f, .8f, .8f}); + shadow_caster.color = {.2f, .2f, .8f, 1}; + shadow_caster.instance_key = "offscreen-caster"; + result.draws.push_back(shadow_caster); + } + return result; +} +void sun() { + auto direct = make_renderer(VisibilityMode::Direct); + auto gpu = make_renderer(VisibilityMode::GpuFrustum); + auto occlusion = make_renderer(VisibilityMode::GpuOcclusion); + auto with_caster = scene(true); + const auto direct_frame = capture(direct, with_caster); + const auto gpu_frame = capture(gpu, with_caster); + const auto occlusion_frame = capture(occlusion, with_caster); + require(direct_frame.stats.effective_sun_cascades == 4 && + gpu_frame.stats.effective_sun_cascades == 4 && + occlusion_frame.stats.effective_sun_cascades == 4, + "Explicit 3D camera renders four sun cascades on every graphics path"); + require(direct_frame.stats.requested_sun_cascades == 4 && + direct_frame.stats.sun_shadow_caster_draws > 0 && + direct_frame.stats.sun_shadow_caster_draws <= 4096 && + direct_frame.stats.sun_shadow_atlas_bytes > 0 && + direct_frame.stats.gpu_sun_shadow_ms > 0, + "Sun cascade stats describe bounded actual raster work and GPU time"); + require(gpu_frame.stats.gpu_frustum_rejected > 0, + "Offscreen caster fixture is outside GPU camera frustum"); + require(direct_frame.stats.validation_errors == 0 && + gpu_frame.stats.validation_errors == 0 && + occlusion_frame.stats.validation_errors == 0, + "Sun atlas rendering reports no Vulkan validation errors"); + compare_frames(direct_frame, gpu_frame); + compare_frames(direct_frame, occlusion_frame); + auto without = scene(false); + const auto no_caster = capture(direct, without); + std::size_t darkened{}; + for (std::size_t i = 0; i < direct_frame.pixels.size(); i += 4) + darkened += int(no_caster.pixels[i]) > int(direct_frame.pixels[i]) + 12; + require(darkened > 20, + "Offscreen source-LOD0 caster darkens visible receiver (count=" + + std::to_string(darkened) + ")"); + auto coarser = with_caster; + auto degenerate_lod = std::make_shared(*cube_mesh()); + for (auto& vertex : degenerate_lod->vertices) + vertex.position = {0, 0, 0}; + coarser.draws.back().lod_meshes.push_back(degenerate_lod); + const auto source_lod_shadow = capture(gpu, coarser); + std::size_t lod_darkened{}; + for (std::size_t i = 0; i < source_lod_shadow.pixels.size(); i += 4) + lod_darkened += int(no_caster.pixels[i]) > + int(source_lod_shadow.pixels[i]) + 12; + require(source_lod_shadow.stats.lod_counts[1] > 0 && lod_darkened > 20, + "Shadow raster uses source LOD0 even when camera chooses a coarse LOD"); + auto no_shadow = with_caster; + no_shadow.authored_lights_present = true; + no_shadow.sun = SunLight{"sun", no_shadow.light_direction, {1, 1, 1, 1}, 1, false}; + const auto disabled = capture(direct, no_shadow); + require(disabled.stats.effective_sun_cascades == 0, + "Disabled sun shadow does no shadow raster work"); + require(disabled.stats.sun_shadow_caster_draws == 0 && + disabled.stats.gpu_sun_shadow_ms == 0, + "Disabled sun does not draw a hidden legacy shadow pass"); + auto legacy = with_caster; + legacy.camera_frustum.reset(); + const auto fallback = capture(direct, legacy); + require(fallback.stats.effective_sun_cascades == 1, + "Low-level snapshot without explicit camera retains one reported shadow view"); + Snapshot sprite_only; + sprite_only.sprites.push_back({{0, 0, 0}, {1, 1}}); + const auto two_d = capture(direct, sprite_only); + require(two_d.stats.effective_sun_cascades == 0, + "Sprite-only scene skips the sun atlas raster"); + require(two_d.stats.sun_shadow_caster_draws == 0 && + two_d.stats.gpu_sun_shadow_ms == 0, + "Sprite-only rendering spends no sun shadow GPU work"); +} +} // namespace +int main(int argc, char** argv) { + try { + if (argc != 2 || std::string(argv[1]) != "--sun") + throw std::invalid_argument("Expected --sun"); + sun(); + std::cout << "Sun cascade atlas and Direct/GPU lighting parity passed\n"; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +}