From a5fb2167d6b9d0692bbca6d5f149a95eb8be329e Mon Sep 17 00:00:00 2001 From: Emil <65846814+emil28092005@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:51:11 +0300 Subject: [PATCH] Add bounded point and spot shadows with fallback diagnostics --- apps/player_main.cpp | 35 ++++++- cmake/Renderer.cmake | 2 + include/faset/render/renderer.hpp | 8 +- shaders/baseline.slang | 38 ++++++- src/editor/debug_overlay.cpp | 21 ++++ src/render/lighting.cpp | 4 +- src/render/renderer.cpp | 130 ++++++++++++++++++++--- tests/player_diagnostics_test.py | 15 +++ tests/render_lighting_gpu_tests.cpp | 154 +++++++++++++++++++++++++++- 9 files changed, 384 insertions(+), 23 deletions(-) diff --git a/apps/player_main.cpp b/apps/player_main.cpp index 1bb792d..3ac7f5d 100644 --- a/apps/player_main.cpp +++ b/apps/player_main.cpp @@ -48,6 +48,7 @@ struct ProfileSample { bool physicsDebug{}; bool gpuVisibilityActive{}; faset::render::VisibilityMode effectiveVisibilityMode{faset::render::VisibilityMode::Direct}; + faset::render::FrameStats lighting; }; Json distribution(std::vector values) { if (values.empty()) @@ -96,7 +97,35 @@ Json profileFrames(const std::vector& samples) { {"physics_debug", sample.physicsDebug}, {"gpu_visibility_active", sample.gpuVisibilityActive}, {"effective_visibility_mode", - visibility_mode_name(sample.effectiveVisibilityMode)}}); + visibility_mode_name(sample.effectiveVisibilityMode)}, + {"effective_lighting_path", sample.lighting.effective_lighting_path}, + {"submitted_local_lights", sample.lighting.submitted_local_lights}, + {"omitted_local_lights", sample.lighting.omitted_local_lights}, + {"requested_sun_cascades", sample.lighting.requested_sun_cascades}, + {"effective_sun_cascades", sample.lighting.effective_sun_cascades}, + {"requested_local_shadow_faces", + sample.lighting.requested_local_shadow_faces}, + {"local_shadow_faces", sample.lighting.local_shadow_faces}, + {"local_shadow_tiles", sample.lighting.local_shadow_tiles}, + {"dropped_shadow_faces", sample.lighting.dropped_shadow_faces}, + {"dropped_point_shadow_faces", + sample.lighting.dropped_point_shadow_faces}, + {"shadow_atlas_full_drops", sample.lighting.shadow_atlas_full_drops}, + {"shadow_caster_budget_drops", + sample.lighting.shadow_caster_budget_drops}, + {"shadow_unavailable_drops", + sample.lighting.shadow_unavailable_drops}, + {"shadow_caster_draws", sample.lighting.shadow_caster_draws}, + {"sun_shadow_atlas_bytes", + sample.lighting.sun_shadow_atlas_bytes}, + {"local_shadow_atlas_bytes", + sample.lighting.local_shadow_atlas_bytes}, + {"gpu_main_raster_ms", + gpuMeasured ? Json(sample.lighting.gpu_main_raster_ms) : Json(nullptr)}, + {"gpu_sun_shadow_ms", + gpuMeasured ? Json(sample.lighting.gpu_sun_shadow_ms) : Json(nullptr)}, + {"gpu_local_shadow_ms", + gpuMeasured ? Json(sample.lighting.gpu_local_shadow_ms) : Json(nullptr)}}); } return {{"samples", std::move(frames)}, {"summary_ms", @@ -587,7 +616,8 @@ int player_main(int argc, char** argv) { milliseconds(renderStarted, frameFinished), measured.cpu_ms, measured.gpu_ms, measured.readback_cpu_ms, runtimeStats, measured.draw_calls, measured.vertices, measured.gpu_allocated_bytes, measured.texture_count, debugPhysics, - measured.gpu_visibility_active, measured.effective_visibility_mode}); + measured.gpu_visibility_active, measured.effective_visibility_mode, + measured}); } ++frames; } @@ -619,6 +649,7 @@ int player_main(int argc, char** argv) { {"visibility_mode", visibilityName}, {"effective_visibility_mode", visibility_mode_name(stats.effective_visibility_mode)}, + {"effective_lighting_path", stats.effective_lighting_path}, {"simulation_mode", "synthetic_fixed_timestep"}, {"fixed_delta_seconds", config.fixedDelta}, {"percentile_method", "nearest_rank_all_completed_frames_no_warmup_exclusion"}, diff --git a/cmake/Renderer.cmake b/cmake/Renderer.cmake index d3e6b56..8904547 100644 --- a/cmake/Renderer.cmake +++ b/cmake/Renderer.cmake @@ -51,6 +51,8 @@ if(BUILD_TESTING) 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_test(NAME render_lighting_local COMMAND faset_render_lighting_gpu_tests --local) + set_tests_properties(render_lighting_local 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 b5517eb..324ed18 100644 --- a/include/faset/render/renderer.hpp +++ b/include/faset/render/renderer.hpp @@ -187,6 +187,11 @@ struct FrameStats { std::uint32_t requested_sun_cascades{}, effective_sun_cascades{}; std::uint32_t sun_shadow_caster_draws{}; std::uint64_t sun_shadow_atlas_bytes{}; + std::uint32_t requested_local_shadow_faces{}, local_shadow_faces{}, local_shadow_tiles{}; + std::uint32_t dropped_shadow_faces{}, dropped_point_shadow_faces{}; + std::uint32_t shadow_atlas_full_drops{}, shadow_caster_budget_drops{}; + std::uint32_t shadow_unavailable_drops{}, shadow_caster_draws{}; + std::uint64_t local_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}; @@ -198,7 +203,8 @@ 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{}; + double gpu_sun_shadow_ms{}, gpu_local_shadow_ms{}; + std::string effective_lighting_path{"forward"}; std::string device; }; struct HzbDebugImage { diff --git a/shaders/baseline.slang b/shaders/baseline.slang index e74571f..bf04df3 100644 --- a/shaders/baseline.slang +++ b/shaders/baseline.slang @@ -78,6 +78,34 @@ float sampleSunCascade(uint index, float3 world, float nl) { } return visible; } +float sampleLocalFace(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 = localShadowAtlas.SampleLevel(shadowSampler, tap, 0); + visible += projected.z - bias <= depth ? 1.0 / 9.0 : 0.0; + } + return visible; +} +uint pointShadowFace(float3 lightToFragment) { + float3 magnitude = abs(lightToFragment); + if (magnitude.x >= magnitude.y && magnitude.x >= magnitude.z) + return lightToFragment.x >= 0.0 ? 0 : 1; + if (magnitude.y >= magnitude.z) + return lightToFragment.y >= 0.0 ? 2 : 3; + return lightToFragment.z >= 0.0 ? 4 : 5; +} 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); @@ -160,8 +188,16 @@ float4 fragmentMain(VertexOutput v) : SV_Target { float cone=saturate((cosAngle-light.directionCosOuter.w)/denominator); attenuation *= cone*cone*(3.0-2.0*cone); } + float visibility = 1.0; + float nl = max(dot(n,l), 0.0); + if (light.coneTypeShadowView.w > 0.5 && nl > 0.0 && attenuation > 0.0) { + uint face = light.coneTypeShadowView.w > 1.5 ? + pointShadowFace(v.world - light.positionRange.xyz) : 0; + uint viewIndex = uint(light.coneTypeShadowView.z + 0.5) + face; + visibility = sampleLocalFace(viewIndex, v.world, nl); + } linear += directBRDF(base.rgb, rough, metal, n, view, l) * - light.colorIntensity.rgb * (light.colorIntensity.w * attenuation); + light.colorIntensity.rgb * (light.colorIntensity.w * attenuation * visibility); } linear=linear/(1.0+linear); return float4(pow(max(linear,0),float3(1.0/2.2)),base.a); diff --git a/src/editor/debug_overlay.cpp b/src/editor/debug_overlay.cpp index ec2c3a0..c9d18b7 100644 --- a/src/editor/debug_overlay.cpp +++ b/src/editor/debug_overlay.cpp @@ -381,6 +381,27 @@ void DebugOverlay::append(render::Snapshot& output, render::Renderer& renderer, ImGui::Text("Prepared LOD: %u / %u / %u / %u+", stats.lod_counts[0], stats.lod_counts[1], stats.lod_counts[2], stats.lod_counts[3]); ImGui::Separator(); + ImGui::TextUnformatted("Lighting and shadows"); + ImGui::Text("Lighting path: %s", stats.effective_lighting_path.c_str()); + ImGui::Text("Local lights: %u submitted, %u omitted", + stats.submitted_local_lights, stats.omitted_local_lights); + ImGui::Text("Sun cascades: %u / %u effective", + stats.requested_sun_cascades, stats.effective_sun_cascades); + ImGui::Text("Local faces: %u requested, %u rasterized (%u tiles)", + stats.requested_local_shadow_faces, stats.local_shadow_faces, + stats.local_shadow_tiles); + ImGui::Text("Dropped faces: %u (point %u, atlas %u, draw budget %u, unavailable %u)", + stats.dropped_shadow_faces, stats.dropped_point_shadow_faces, + stats.shadow_atlas_full_drops, stats.shadow_caster_budget_drops, + stats.shadow_unavailable_drops); + ImGui::Text("Shadow caster draws: %u / 4096", stats.shadow_caster_draws); + ImGui::Text("Atlas memory: sun %.1f MiB, local %.1f MiB", + double(stats.sun_shadow_atlas_bytes) / 1048576.0, + double(stats.local_shadow_atlas_bytes) / 1048576.0); + if (stats.gpu_ms > 0) + ImGui::Text("Shadow GPU: sun %.2f ms, local %.2f ms", + stats.gpu_sun_shadow_ms, stats.gpu_local_shadow_ms); + ImGui::Separator(); ImGui::Text("Vulkan allocations: %.2f MiB", double(stats.gpu_allocated_bytes) / 1048576.0); ImGui::Text("Validation: %s Errors: %u", diff --git a/src/render/lighting.cpp b/src/render/lighting.cpp index 5ca4c97..acaa41d 100644 --- a/src/render/lighting.cpp +++ b/src/render/lighting.cpp @@ -240,8 +240,10 @@ ShadowView local_view(const LocalLight& light, std::uint32_t face, const auto up = light.kind == LocalLight::Kind::Point ? ups.at(face) : std::abs(direction[1]) > .98f ? Vec3{0, 0, 1} : Vec3{0, 1, 0}; const auto near_plane = std::max(.0001f, std::min(.05f, light.range * .1f)); + // Slight face overlap keeps the dominant-axis choice inside both adjacent + // projections at a cubemap seam; the guarded tile still prevents PCF bleed. const auto fov = light.kind == LocalLight::Kind::Point - ? std::numbers::pi_v / 2 : light.outer_angle * 2; + ? std::numbers::pi_v / 2 + .04f : light.outer_angle * 2; result.view_projection = multiply( perspective(fov, 1, near_plane, light.range), look_at(light.position, add(light.position, direction), up)); diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index c471f0b..0745172 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -231,8 +231,8 @@ struct Renderer::Impl { VkExtent2D swap_extent{}; std::vector swap_images; std::vector swap_layouts; - Image color, depth, shadow; - std::uint32_t sun_shadow_size{}; + Image color, depth, shadow, local_shadow; + std::uint32_t sun_shadow_size{}, local_shadow_size{}; Buffer vertices, readback; Buffer lighting_header, lighting_locals, lighting_views; SceneResources scene; @@ -364,6 +364,7 @@ struct Renderer::Impl { destroy(color); destroy(depth); destroy(shadow); + destroy(local_shadow); if (device) { destroy_scene_interfaces(); if (pipeline) @@ -791,6 +792,18 @@ struct Renderer::Impl { if (!shadow.handle) shadow = make_image(1, 1, VK_FORMAT_D32_SFLOAT, shadow_usage, VK_IMAGE_ASPECT_DEPTH_BIT); + for (const auto size : {2048u, 1024u}) { + if (size > max_image_dimension) + continue; + try { + local_shadow = make_image(size, size, VK_FORMAT_D32_SFLOAT, + shadow_usage, VK_IMAGE_ASPECT_DEPTH_BIT); + local_shadow_size = size; + break; + } catch (const std::exception&) { + // Local shadows are optional; all affected lights remain unshadowed. + } + } make_targets(); make_descriptors(); make_pipelines(); @@ -1626,7 +1639,8 @@ struct Renderer::Impl { {lighting_header.handle, 0, lighting_header.size}, {lighting_locals.handle, 0, lighting_locals.size}, {lighting_views.handle, 0, lighting_views.size}}}; - const VkDescriptorImageInfo atlas{VK_NULL_HANDLE, shadow.view, + const VkDescriptorImageInfo atlas{VK_NULL_HANDLE, + local_shadow.handle ? local_shadow.view : shadow.view, VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL}; std::array writes{}; for (std::uint32_t i = 0; i < writes.size(); ++i) { @@ -1741,6 +1755,17 @@ struct Renderer::Impl { 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.requested_local_shadow_faces = statistics.local_shadow_faces = + statistics.local_shadow_tiles = statistics.dropped_shadow_faces = + statistics.dropped_point_shadow_faces = + statistics.shadow_atlas_full_drops = + statistics.shadow_caster_budget_drops = + statistics.shadow_unavailable_drops = + statistics.shadow_caster_draws = 0; + statistics.local_shadow_atlas_bytes = local_shadow_size + ? local_shadow.allocation_size : 0; + statistics.gpu_local_shadow_ms = 0; + statistics.effective_lighting_path = "forward"; statistics.gpu_bins = statistics.gpu_visible_instances = statistics.gpu_frustum_rejected = statistics.gpu_occlusion_deferred = statistics.gpu_post_visible = 0; @@ -1986,6 +2011,8 @@ struct Renderer::Impl { ShadowBudget shadow_budget; shadow_budget.sun_atlas_size = sun_shadow_size; shadow_budget.sun_atlas_available = sun_shadow_size != 0; + shadow_budget.local_atlas_size = local_shadow_size; + shadow_budget.local_atlas_available = local_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(), @@ -2000,6 +2027,39 @@ struct Renderer::Impl { if (view.valid) statistics.sun_shadow_caster_draws += static_cast(view.caster_indices.size()); + const bool local_raster = local_shadow_size && + std::any_of(shadow_plan.local_views.begin(), shadow_plan.local_views.end(), + [](const ShadowView& view) { + return view.valid && !view.caster_indices.empty(); + }); + statistics.requested_local_shadow_faces = shadow_plan.local_faces_requested; + statistics.local_shadow_tiles = shadow_plan.local_faces_used; + statistics.local_shadow_faces = local_raster ? shadow_plan.local_faces_used : 0; + statistics.dropped_shadow_faces = shadow_plan.dropped_local_faces; + statistics.dropped_point_shadow_faces = shadow_plan.dropped_point_faces; + for (const auto& assignment : shadow_plan.local_assignments) { + if (assignment.valid || assignment.reason == ShadowDropReason::None) + continue; + const auto& light = snapshot.local_lights[assignment.source_index]; + const auto faces = light.kind == LocalLight::Kind::Point ? 6u : 1u; + if (assignment.reason == ShadowDropReason::TileBudget) + statistics.shadow_atlas_full_drops += faces; + else if (assignment.reason == ShadowDropReason::CasterBudget) + statistics.shadow_caster_budget_drops += faces; + else if (assignment.reason == ShadowDropReason::Unavailable) + statistics.shadow_unavailable_drops += faces; + } + for (const auto& view : shadow_plan.sun_views) { + if (view.reason == ShadowDropReason::CasterBudget) + ++statistics.shadow_caster_budget_drops; + else if (view.reason == ShadowDropReason::Unavailable) + ++statistics.shadow_unavailable_drops; + } + statistics.shadow_caster_draws = statistics.sun_shadow_caster_draws; + if (local_raster) + for (const auto& view : shadow_plan.local_views) + statistics.shadow_caster_draws += + static_cast(view.caster_indices.size()); std::vector data; std::vector scene_batches, transparent_batches, sprite_batches, ui_batches; for (const auto& selected : selected_draws) { @@ -2110,10 +2170,14 @@ struct Renderer::Impl { triangles.clip_rect}); } std::vector shadow_batch_by_source(snapshot.draws.size()); - if (sun_raster) { + if (sun_raster || local_raster) { std::vector required(snapshot.draws.size()); for (const auto& view : shadow_plan.sun_views) - if (view.valid) + if (sun_raster && view.valid) + for (const auto source : view.caster_indices) + required.at(source) = 1; + for (const auto& view : shadow_plan.local_views) + if (local_raster && view.valid) for (const auto source : view.caster_indices) required.at(source) = 1; for (std::size_t source = 0; source < required.size(); ++source) { @@ -2234,6 +2298,19 @@ struct Renderer::Impl { if (i < lighting.cascade_splits.size()) lighting.cascade_splits[i] = view.split_far; } + for (const auto& view : shadow_plan.local_views) { + 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, + local_shadow_size ? 1.f / float(local_shadow_size) : 0.f, + local_raster && view.valid ? 1.f : 0.f}; + gpu_shadow_views.push_back(gpu); + } + std::vector assignments(snapshot.local_lights.size()); + for (const auto& assignment : shadow_plan.local_assignments) + assignments.at(assignment.source_index) = &assignment; statistics.omitted_local_lights = shadow_plan.omitted_local_lights; std::vector gpu_lights; gpu_lights.reserve(shadow_plan.submitted_local_indices.size()); @@ -2260,6 +2337,12 @@ struct Renderer::Impl { local.intensity}; gpu.cone_type_shadow_view = {spot ? std::cos(local.inner_angle) : 1.f, spot ? 1.f : 0.f, -1, 0}; + const auto* assignment = assignments.at(source); + if (local_raster && assignment && assignment->valid) { + gpu.cone_type_shadow_view[2] = float( + shadow_plan.sun_views.size() + assignment->first_view); + gpu.cone_type_shadow_view[3] = float(assignment->face_count); + } gpu_lights.push_back(gpu); } lighting.counts[0] = static_cast(gpu_lights.size()); @@ -2440,26 +2523,26 @@ struct Renderer::Impl { } }); }; - if (sun_raster) - add_pass("SunShadowAtlas", {}, {"shadow"}, [&] { - transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL, + auto raster_shadow_atlas = [&](Image& atlas, std::uint32_t atlas_size, + const std::vector& views) { + transition(command, atlas, 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.imageView = atlas.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.renderArea = {{0, 0}, {atlas_size, atlas_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) { + for (const auto& view : views) { if (!view.valid || view.caster_indices.empty()) continue; const auto guard = (view.tile_size - view.usable_size) / 2; @@ -2487,8 +2570,12 @@ struct Renderer::Impl { } } vkCmdEndRendering(command); - transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL, + transition(command, atlas, VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL, VK_IMAGE_ASPECT_DEPTH_BIT); + }; + if (sun_raster) + add_pass("SunShadowAtlas", {}, {"shadow"}, [&] { + raster_shadow_atlas(shadow, sun_shadow_size, shadow_plan.sun_views); }); else add_pass("ShadowFallback", {}, {"shadow"}, [&] { @@ -2497,6 +2584,18 @@ struct Renderer::Impl { transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL, VK_IMAGE_ASPECT_DEPTH_BIT); }); + if (local_raster) + add_pass("LocalShadowAtlas", {}, {"local_shadow"}, [&] { + raster_shadow_atlas(local_shadow, local_shadow_size, + shadow_plan.local_views); + }); + else + add_pass("LocalShadowFallback", {}, {"local_shadow"}, [&] { + if (local_shadow.handle) + transition(command, local_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"}, [&] { @@ -2556,8 +2655,9 @@ struct Renderer::Impl { } }); add_pass(occlusion ? "MainRaster" : "ForwardAndUI", - gpu_active ? std::vector{"shadow", "main_indirect", "main_visible"} - : std::vector{"shadow"}, + gpu_active ? std::vector{"shadow", "local_shadow", + "main_indirect", "main_visible"} + : std::vector{"shadow", "local_shadow"}, {"color", "depth"}, [&] { transition(command, color, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_ASPECT_COLOR_BIT); @@ -2824,6 +2924,7 @@ struct Renderer::Impl { 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 == "LocalShadowAtlas") statistics.gpu_local_shadow_ms = elapsed; else if (label == "MainRaster" || label == "ForwardAndUI") statistics.gpu_main_raster_ms = elapsed; else if (label == "BuildCurrentHZB") statistics.gpu_hzb_ms = elapsed; @@ -2897,6 +2998,7 @@ struct Renderer::Impl { statistics.gpu_allocated_bytes = vertices.allocation_size + readback.allocation_size + color.allocation_size + depth.allocation_size + shadow.allocation_size + + local_shadow.allocation_size + lighting_header.allocation_size + lighting_locals.allocation_size + lighting_views.allocation_size; diff --git a/tests/player_diagnostics_test.py b/tests/player_diagnostics_test.py index c3a4227..a8f7a5b 100644 --- a/tests/player_diagnostics_test.py +++ b/tests/player_diagnostics_test.py @@ -29,6 +29,21 @@ with tempfile.TemporaryDirectory(prefix="faset-player-diagnostics-") as temporar report = json.loads(profile.read_text(encoding="utf-8")) assert report["completed_frames"] == 1 and len(report["samples"]) == 1, report assert report["samples"][0]["tick"] == 1, report["samples"] + lighting = report["samples"][0] + assert lighting["effective_lighting_path"] == "forward", lighting + for field in ["submitted_local_lights", "omitted_local_lights", + "requested_sun_cascades", "effective_sun_cascades", + "requested_local_shadow_faces", "local_shadow_faces", + "local_shadow_tiles", "dropped_shadow_faces", + "dropped_point_shadow_faces", "shadow_atlas_full_drops", + "shadow_caster_budget_drops", "shadow_unavailable_drops", + "shadow_caster_draws", "sun_shadow_atlas_bytes", + "local_shadow_atlas_bytes", "gpu_main_raster_ms", + "gpu_sun_shadow_ms", "gpu_local_shadow_ms"]: + assert field in lighting, (field, lighting) + assert lighting["submitted_local_lights"] == 0 and \ + lighting["effective_sun_cascades"] == 0 and \ + lighting["local_shadow_faces"] == 0, lighting # The same linked v2 schema must validate without registering or invoking behavior. validated = subprocess.run([sys.argv[1], "--scene", str(scene), "--validate"], diff --git a/tests/render_lighting_gpu_tests.cpp b/tests/render_lighting_gpu_tests.cpp index da869b4..99728d3 100644 --- a/tests/render_lighting_gpu_tests.cpp +++ b/tests/render_lighting_gpu_tests.cpp @@ -145,13 +145,159 @@ void sun() { two_d.stats.gpu_sun_shadow_ms == 0, "Sprite-only rendering spends no sun shadow GPU work"); } +Snapshot local_scene(LocalLight::Kind kind, bool caster_shadow) { + Snapshot result; + result.view_id = "p3-local-shadow"; + result.eye = {0, 5, 8}; + const auto view = look_at(result.eye, {0, -1, 0}); + const auto projection = orthographic(-3, 3, -2.25f, 2.25f, .1f, 50); + result.projection = projection; + result.view_projection = multiply(projection, view); + result.camera_frustum = CameraFrustum{view, projection, .1f, 50, false}; + result.authored_lights_present = true; + DrawItem floor; + floor.mesh = cube_mesh(); + floor.model = transform({0, -1, 0}, {}, {8, .1f, 8}); + floor.color = {.8f, .8f, .8f, 1}; + floor.instance_key = "floor"; + result.draws.push_back(floor); + DrawItem caster; + caster.mesh = cube_mesh(); + caster.model = transform({0, .7f, 0}, {}, {.8f, .8f, .8f}); + caster.color = {.4f, .4f, .4f, 1}; + caster.cast_shadow = caster_shadow; + caster.instance_key = "caster"; + result.draws.push_back(caster); + LocalLight light; + light.kind = kind; + light.stable_id = "local"; + light.position = {0, 3, 0}; + light.direction = {0, -1, 0}; + light.color = {1, .85f, .65f, 1}; + light.intensity = 80; + light.range = 8; + light.inner_angle = .3f; + light.outer_angle = .7f; + result.local_lights.push_back(light); + return result; +} +std::size_t darker_pixels(const Frame& shadowed, const Frame& unshadowed) { + std::size_t count{}; + for (std::size_t i = 0; i < shadowed.pixels.size(); i += 4) + count += int(unshadowed.pixels[i]) > int(shadowed.pixels[i]) + 12; + return count; +} +Snapshot point_face_scene(Vec3 axis, bool caster_shadow) { + Snapshot result; + result.view_id = "point-six-faces"; + const Vec3 lateral = std::abs(axis[1]) > .9f ? Vec3{0, 0, 1} : Vec3{0, 1, 0}; + result.eye = {-axis[0] * .4f + lateral[0] * 2, + -axis[1] * .4f + lateral[1] * 2, + -axis[2] * .4f + lateral[2] * 2}; + const Vec3 target{axis[0] * 3, axis[1] * 3, axis[2] * 3}; + const auto view = look_at(result.eye, target); + const auto projection = orthographic(-2, 2, -2, 2, .1f, 20); + result.projection = projection; + result.view_projection = multiply(projection, view); + result.camera_frustum = CameraFrustum{view, projection, .1f, 20, false}; + result.authored_lights_present = true; + DrawItem receiver; + receiver.mesh = cube_mesh(); + receiver.model = transform(target, {}, {1.5f, 1.5f, 1.5f}); + receiver.color = {.8f, .8f, .8f, 1}; + receiver.instance_key = "point-receiver"; + result.draws.push_back(receiver); + DrawItem caster; + caster.mesh = cube_mesh(); + caster.model = transform({axis[0] * 1.5f, axis[1] * 1.5f, axis[2] * 1.5f}, + {}, {.5f, .5f, .5f}); + caster.cast_shadow = caster_shadow; + caster.instance_key = "point-caster"; + result.draws.push_back(caster); + LocalLight light; + light.stable_id = "point-face"; + light.position = {0, 0, 0}; + light.range = 8; + light.intensity = 90; + result.local_lights.push_back(light); + return result; +} +void local() { + auto direct = make_renderer(VisibilityMode::Direct); + auto gpu = make_renderer(VisibilityMode::GpuFrustum); + auto occlusion = make_renderer(VisibilityMode::GpuOcclusion); + for (auto kind : {LocalLight::Kind::Point, LocalLight::Kind::Spot}) { + const auto scene_with_shadow = local_scene(kind, true); + const auto shadowed = capture(direct, scene_with_shadow); + const auto gpu_shadowed = capture(gpu, scene_with_shadow); + const auto occlusion_shadowed = capture(occlusion, scene_with_shadow); + const auto unshadowed = capture(direct, local_scene(kind, false)); + const auto faces = kind == LocalLight::Kind::Point ? 6u : 1u; + require(shadowed.stats.local_shadow_faces == faces && + shadowed.stats.requested_local_shadow_faces == faces && + shadowed.stats.shadow_caster_draws <= 4096 && + shadowed.stats.local_shadow_atlas_bytes > 0 && + shadowed.stats.gpu_local_shadow_ms > 0, + "Point/spot views render within atlas and caster budgets"); + require(darker_pixels(shadowed, unshadowed) > 20, + "Caster darkens point/spot-lit receiver (count=" + + std::to_string(darker_pixels(shadowed, unshadowed)) + ")"); + require(shadowed.stats.validation_errors == 0 && + gpu_shadowed.stats.validation_errors == 0 && + occlusion_shadowed.stats.validation_errors == 0, + "Local shadow rendering passes Vulkan validation"); + compare_frames(shadowed, gpu_shadowed); + compare_frames(shadowed, occlusion_shadowed); + } + for (const Vec3 axis : {Vec3{1, 0, 0}, Vec3{-1, 0, 0}, Vec3{0, 1, 0}, + Vec3{0, -1, 0}, Vec3{0, 0, 1}, Vec3{0, 0, -1}, + Vec3{.7071068f, .7071068f, 0}}) { + const auto shadowed = capture(direct, point_face_scene(axis, true)); + const auto unshadowed = capture(direct, point_face_scene(axis, false)); + require(shadowed.stats.local_shadow_faces == 6 && + darker_pixels(shadowed, unshadowed) > 5, + "A point light shadows each face direction and the adjacent-face seam"); + } + auto crowded = local_scene(LocalLight::Kind::Point, true); + const auto point = crowded.local_lights.front(); + crowded.local_lights.clear(); + for (int i = 0; i < 15; ++i) { + LocalLight filler; + filler.kind = LocalLight::Kind::Spot; + filler.stable_id = "filler-" + std::to_string(i); + filler.position = {100, 100, 100}; + filler.direction = {0, -1, 0}; + filler.range = 8; + filler.intensity = 1; + filler.shadow_priority = 10; + crowded.local_lights.push_back(filler); + } + const auto without_point = capture(direct, crowded); + crowded.local_lights.push_back(point); + const auto overflow = capture(direct, crowded); + require(overflow.stats.requested_local_shadow_faces == 21 && + overflow.stats.dropped_point_shadow_faces == 6 && + overflow.stats.shadow_atlas_full_drops == 6 && + overflow.stats.local_shadow_tiles <= 16, + "Fifteen occupied tiles drop the complete six-face point shadow"); + std::size_t brightened{}; + for (std::size_t i = 0; i < overflow.pixels.size(); i += 4) + brightened += int(overflow.pixels[i]) > int(without_point.pixels[i]) + 12; + require(brightened > 20, + "Point light with dropped atlas faces still illuminates unshadowed"); +} } // 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"; + if (argc != 2) + throw std::invalid_argument("Expected --sun or --local"); + if (std::string(argv[1]) == "--sun") + sun(); + else if (std::string(argv[1]) == "--local") + local(); + else + throw std::invalid_argument("Expected --sun or --local"); + std::cout << "Shadow atlas and Direct/GPU lighting parity passed\n"; } catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1;