From 36a44e14ca321adbf289bb62e9af2acac1c21032 Mon Sep 17 00:00:00 2001 From: Emil <65846814+emil28092005@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:26:53 +0300 Subject: [PATCH] Plan bounded sun and local shadows from source casters --- cmake/Renderer.cmake | 6 +- include/faset/render/lighting.hpp | 76 +++++ include/faset/render/renderer.hpp | 1 + src/render/lighting.cpp | 392 +++++++++++++++++++++++++ src/render/renderer.cpp | 39 ++- tests/render_lighting_policy_tests.cpp | 191 ++++++++++++ tests/render_tests.cpp | 37 +++ 7 files changed, 720 insertions(+), 22 deletions(-) create mode 100644 include/faset/render/lighting.hpp create mode 100644 src/render/lighting.cpp create mode 100644 tests/render_lighting_policy_tests.cpp diff --git a/cmake/Renderer.cmake b/cmake/Renderer.cmake index 5ead206..c7faa65 100644 --- a/cmake/Renderer.cmake +++ b/cmake/Renderer.cmake @@ -40,13 +40,17 @@ add_custom_command(OUTPUT "${FASET_SHADER_DIRECTORY}/compatibility.spv" -o "${FASET_SHADER_DIRECTORY}/compatibility.spv" DEPENDS "${PROJECT_SOURCE_DIR}/shaders/compatibility.hlsl" VERBATIM) add_custom_target(faset_shaders DEPENDS ${FASET_SHADER_OUTPUTS} "${FASET_SHADER_DIRECTORY}/compatibility.spv") -add_library(faset_render "${PROJECT_SOURCE_DIR}/src/render/renderer.cpp" "${PROJECT_SOURCE_DIR}/src/render/math.cpp" "${PROJECT_SOURCE_DIR}/src/render/render_graph.cpp" "${PROJECT_SOURCE_DIR}/src/render/shader_contract.cpp") +add_library(faset_render "${PROJECT_SOURCE_DIR}/src/render/renderer.cpp" "${PROJECT_SOURCE_DIR}/src/render/math.cpp" "${PROJECT_SOURCE_DIR}/src/render/render_graph.cpp" "${PROJECT_SOURCE_DIR}/src/render/shader_contract.cpp" "${PROJECT_SOURCE_DIR}/src/render/lighting.cpp") target_include_directories(faset_render PUBLIC "${PROJECT_SOURCE_DIR}/include") target_compile_features(faset_render PUBLIC cxx_std_20) 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_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) + set_tests_properties(render_lighting_policy PROPERTIES LABELS "p3") add_executable(faset_render_tests "${PROJECT_SOURCE_DIR}/tests/render_tests.cpp") target_link_libraries(faset_render_tests PRIVATE faset_render SDL3::SDL3) add_test(NAME render_graph COMMAND faset_render_tests --unit) diff --git a/include/faset/render/lighting.hpp b/include/faset/render/lighting.hpp new file mode 100644 index 0000000..79de596 --- /dev/null +++ b/include/faset/render/lighting.hpp @@ -0,0 +1,76 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +namespace faset::render { + +enum class ShadowDropReason { None, Unavailable, TileBudget, CasterBudget }; +enum class ShadowRedrawReason { EveryFrame }; + +struct ShadowBudget { + std::uint32_t max_sun_views{4}; + std::uint32_t max_local_faces{16}; + std::uint32_t max_caster_draws{4096}; + std::uint32_t max_local_lights{128}; + std::uint32_t sun_atlas_size{2048}; + std::uint32_t local_atlas_size{2048}; + float max_shadow_distance{80}; + bool sun_atlas_available{true}; + bool local_atlas_available{true}; +}; + +struct ShadowCasterBounds { + Bounds world; + std::uint32_t draw_index{}; // Index of the original source LOD-0 DrawItem. +}; + +struct ShadowView { + enum class Kind { Sun, Spot, Point }; + Kind kind{Kind::Sun}; + std::string light_id; + std::uint32_t face_index{}; + std::uint32_t tile_index{}; + std::uint32_t tile_origin_x{}, tile_origin_y{}, tile_size{}, usable_size{}; + Mat4 view_projection{identity}; + std::array atlas_scale_offset{}; + std::array guarded_clamp{}; + float split_near{}, split_far{}; + float snapped_center_x{}, snapped_center_y{}; + std::vector caster_indices; + bool valid{}; + ShadowDropReason reason{ShadowDropReason::None}; + ShadowRedrawReason redraw_reason{ShadowRedrawReason::EveryFrame}; +}; + +struct LocalShadowAssignment { + std::size_t source_index{}; + std::uint32_t first_view{}; + std::uint32_t face_count{}; + bool valid{}; + ShadowDropReason reason{ShadowDropReason::None}; +}; + +struct ShadowPlan { + std::vector sun_views; // Requested slots, including explicitly invalid ones. + std::vector local_views; // Only complete, valid spot/point allocations. + std::vector submitted_local_indices; // Priority/influence/stable-ID order. + std::vector local_assignments; + std::uint32_t requested_sun_cascades{}, effective_sun_cascades{}; + std::uint32_t local_faces_requested{}, local_faces_used{}; + std::uint32_t dropped_sun_views{}, dropped_local_faces{}, dropped_point_faces{}; + std::uint32_t omitted_local_lights{}, caster_draws{}; + std::uint32_t sun_atlas_size{}, local_atlas_size{}; +}; + +// Stateless: every scheduled tile is cleared/redrawn; no prior-frame depth or +// ownership is reused. Shadow casters are source LOD-0 world bounds, independent +// of the camera/P2 visibility decision. This function performs no Vulkan work. +ShadowPlan build_shadow_plan(const Snapshot& frame, + std::span casters, + const ShadowBudget& budget = {}); + +} // namespace faset::render diff --git a/include/faset/render/renderer.hpp b/include/faset/render/renderer.hpp index 84a8dcd..48427ed 100644 --- a/include/faset/render/renderer.hpp +++ b/include/faset/render/renderer.hpp @@ -183,6 +183,7 @@ struct FrameStats { std::uint64_t gpu_allocated_bytes{}; std::uint32_t texture_count{}; std::uint32_t vertices{}, draw_calls{}, culled_meshes{}, validation_errors{}; + std::uint32_t submitted_local_lights{}, omitted_local_lights{}; 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}; diff --git a/src/render/lighting.cpp b/src/render/lighting.cpp new file mode 100644 index 0000000..5ca4c97 --- /dev/null +++ b/src/render/lighting.cpp @@ -0,0 +1,392 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +namespace faset::render { +namespace { +Vec3 add(Vec3 a, Vec3 b) { return {a[0] + b[0], a[1] + b[1], a[2] + b[2]}; } +Vec3 subtract(Vec3 a, Vec3 b) { return {a[0] - b[0], a[1] - b[1], a[2] - b[2]}; } +Vec3 scale(Vec3 a, float factor) { return {a[0] * factor, a[1] * factor, a[2] * factor}; } +float dot(Vec3 a, Vec3 b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } +float length(Vec3 a) { return std::sqrt(dot(a, a)); } +Vec3 unit(Vec3 a) { + const float magnitude = length(a); + if (!std::isfinite(magnitude) || magnitude < 1e-6f) + throw std::invalid_argument("Shadow light direction must be finite and nonzero"); + return scale(a, 1.f / magnitude); +} +Vec3 project(const Mat4& matrix, Vec3 value) { + return {matrix[0] * value[0] + matrix[4] * value[1] + matrix[8] * value[2] + matrix[12], + matrix[1] * value[0] + matrix[5] * value[1] + matrix[9] * value[2] + matrix[13], + matrix[2] * value[0] + matrix[6] * value[1] + matrix[10] * value[2] + matrix[14]}; +} +std::array clip(const Mat4& matrix, Vec3 value) { + return {matrix[0] * value[0] + matrix[4] * value[1] + matrix[8] * value[2] + matrix[12], + matrix[1] * value[0] + matrix[5] * value[1] + matrix[9] * value[2] + matrix[13], + matrix[2] * value[0] + matrix[6] * value[1] + matrix[10] * value[2] + matrix[14], + matrix[3] * value[0] + matrix[7] * value[1] + matrix[11] * value[2] + matrix[15]}; +} +std::array corners(const Bounds& bounds) { + std::array result{}; + for (unsigned i = 0; i < 8; ++i) + result[i] = {i & 1 ? bounds.max[0] : bounds.min[0], + i & 2 ? bounds.max[1] : bounds.min[1], + i & 4 ? bounds.max[2] : bounds.min[2]}; + return result; +} +Vec3 camera_to_world(const Mat4& view, Vec3 camera) { + // CameraFrustum::view is an unscaled, orthonormal look_at matrix. + return {view[0] * (camera[0] - view[12]) + view[1] * (camera[1] - view[13]) + + view[2] * (camera[2] - view[14]), + view[4] * (camera[0] - view[12]) + view[5] * (camera[1] - view[13]) + + view[6] * (camera[2] - view[14]), + view[8] * (camera[0] - view[12]) + view[9] * (camera[1] - view[13]) + + view[10] * (camera[2] - view[14])}; +} +std::array frustum_slice(const CameraFrustum& camera, float near_distance, + float far_distance) { + std::array result{}; + for (unsigned i = 0; i < 8; ++i) { + const float distance = i & 4 ? far_distance : near_distance; + const float x = i & 1 ? 1.f : -1.f; + const float y = i & 2 ? 1.f : -1.f; + Vec3 local{}; + if (camera.perspective) + local = {x * distance / camera.projection[0], + y * distance / camera.projection[5], -distance}; + else + local = {(x - camera.projection[12]) / camera.projection[0], + (y - camera.projection[13]) / camera.projection[5], -distance}; + result[i] = camera_to_world(camera.view, local); + } + return result; +} +bool overlaps_xy(const Bounds& bounds, const Mat4& light_view, float left, float right, + float bottom, float top) { + float min_x = std::numeric_limits::infinity(); + float max_x = -min_x, min_y = min_x, max_y = -min_x; + for (const auto point : corners(bounds)) { + const auto light = project(light_view, point); + min_x = std::min(min_x, light[0]); + max_x = std::max(max_x, light[0]); + min_y = std::min(min_y, light[1]); + max_y = std::max(max_y, light[1]); + } + return max_x >= left && min_x <= right && max_y >= bottom && min_y <= top; +} +bool intersects_frustum(const Bounds& bounds, const Mat4& view_projection) { + std::array rejected{}; + for (const auto point : corners(bounds)) { + const auto p = clip(view_projection, point); + if (!std::all_of(p.begin(), p.end(), [](float value) { return std::isfinite(value); })) + return true; // Invalid projection fails open so no caster is lost silently. + rejected[0] += p[0] < -p[3]; + rejected[1] += p[0] > p[3]; + rejected[2] += p[1] < -p[3]; + rejected[3] += p[1] > p[3]; + rejected[4] += p[2] < 0; + rejected[5] += p[2] > p[3]; + rejected[6] += p[3] <= 0; + } + return std::none_of(rejected.begin(), rejected.end(), [](unsigned count) { return count == 8; }); +} +void tile(ShadowView& view, std::uint32_t atlas_size, std::uint32_t tiles_across, + std::uint32_t index) { + constexpr std::uint32_t guard = 2; + const auto size = atlas_size / tiles_across; + view.tile_index = index; + view.tile_origin_x = (index % tiles_across) * size; + view.tile_origin_y = (index / tiles_across) * size; + view.tile_size = size; + view.usable_size = size - 2 * guard; + const auto reciprocal = 1.f / float(atlas_size); + view.atlas_scale_offset = {float(view.usable_size) * reciprocal, + float(view.usable_size) * reciprocal, + float(view.tile_origin_x + guard) * reciprocal, + float(view.tile_origin_y + guard) * reciprocal}; + view.guarded_clamp = {(float(view.tile_origin_x + guard) + 1.5f) * reciprocal, + (float(view.tile_origin_y + guard) + 1.5f) * reciprocal, + (float(view.tile_origin_x + size - guard) - 1.5f) * reciprocal, + (float(view.tile_origin_y + size - guard) - 1.5f) * reciprocal}; +} +std::vector visible_casters(const Mat4& view_projection, + std::span casters) { + std::vector result; + for (const auto& caster : casters) + if (intersects_frustum(caster.world, view_projection)) + result.push_back(caster.draw_index); + std::sort(result.begin(), result.end()); + result.erase(std::unique(result.begin(), result.end()), result.end()); + return result; +} +bool supported_atlas(std::uint32_t size, bool available) { + return available && (size == 2048 || size == 1024); +} +void validate_local(const LocalLight& light) { + const auto invalid = [&](const char* field) { + throw std::invalid_argument("Local light " + light.stable_id + " has invalid " + field); + }; + if (light.stable_id.empty()) + invalid("stable_id"); + if (!std::all_of(light.position.begin(), light.position.end(), + [](float v) { return std::isfinite(v); })) + invalid("position"); + if (!std::all_of(light.color.begin(), light.color.end(), + [](float v) { return std::isfinite(v) && v >= 0; })) + invalid("color"); + if (!std::isfinite(light.intensity) || light.intensity < 0) + invalid("intensity"); + if (!std::isfinite(light.range) || light.range <= 0) + invalid("range"); + if (light.kind == LocalLight::Kind::Spot) { + if (!std::isfinite(light.inner_angle) || !std::isfinite(light.outer_angle) || + light.inner_angle < 0 || light.inner_angle > light.outer_angle || + light.outer_angle >= std::numbers::pi_v / 2 || light.outer_angle <= 0) + invalid("inner_angle/outer_angle"); + if (!std::all_of(light.direction.begin(), light.direction.end(), + [](float v) { return std::isfinite(v); }) || + length(light.direction) < 1e-6f) + invalid("direction"); + } +} +float projected_influence(const LocalLight& light, const Snapshot& frame) { + const auto distance = length(subtract(light.position, frame.eye)); + const auto projection_scale = + std::max(std::abs(frame.projection[0]), std::abs(frame.projection[5])); + return light.range * projection_scale / std::max(distance, .1f); +} +ShadowView sun_view(const Snapshot& frame, const SunLight& sun, + std::span casters, float split_near, + float split_far, std::uint32_t index, std::uint32_t atlas_size) { + ShadowView result; + result.kind = ShadowView::Kind::Sun; + result.light_id = sun.stable_id; + result.face_index = index; + result.split_near = split_near; + result.split_far = split_far; + tile(result, atlas_size, 2, index); + const auto direction = unit(sun.direction); + const auto up = std::abs(direction[1]) > .98f ? Vec3{0, 0, 1} : Vec3{0, 1, 0}; + const auto light_origin_view = look_at({0, 0, 0}, direction, up); + if (!frame.camera_frustum) { + const auto light_eye = scale(direction, -30); + result.view_projection = multiply(orthographic(-20, 20, -20, 20, .1f, 80), + look_at(light_eye, {0, 0, 0}, up)); + result.caster_indices = visible_casters(result.view_projection, casters); + return result; + } + const auto receivers = frustum_slice(*frame.camera_frustum, split_near, split_far); + Vec3 center{}; + for (const auto corner : receivers) + center = add(center, scale(corner, 1.f / 8)); + float radius{}; + for (const auto corner : receivers) + radius = std::max(radius, length(subtract(corner, center))); + radius = std::max(.25f, std::ceil(radius * 16.f) / 16.f); + const auto center_light = project(light_origin_view, center); + const auto texel = (2 * radius) / float(result.usable_size); + result.snapped_center_x = std::round(center_light[0] / texel) * texel; + result.snapped_center_y = std::round(center_light[1] / texel) * texel; + const float left = result.snapped_center_x - radius; + const float right = result.snapped_center_x + radius; + const float bottom = result.snapped_center_y - radius; + const float top = result.snapped_center_y + radius; + float nearest_ray = std::numeric_limits::infinity(); + float furthest_ray = -nearest_ray; + for (const auto corner : receivers) { + const auto ray = dot(corner, direction); + nearest_ray = std::min(nearest_ray, ray); + furthest_ray = std::max(furthest_ray, ray); + } + for (const auto& caster : casters) { + if (!overlaps_xy(caster.world, light_origin_view, left, right, bottom, top)) + continue; + result.caster_indices.push_back(caster.draw_index); + for (const auto corner : corners(caster.world)) { + const auto ray = dot(corner, direction); + nearest_ray = std::min(nearest_ray, ray); + furthest_ray = std::max(furthest_ray, ray); + } + } + std::sort(result.caster_indices.begin(), result.caster_indices.end()); + result.caster_indices.erase( + std::unique(result.caster_indices.begin(), result.caster_indices.end()), + result.caster_indices.end()); + const auto light_eye = scale(direction, nearest_ray - 1.f); + const auto view = look_at(light_eye, add(light_eye, direction), up); + const auto depth = std::max(2.f, furthest_ray - nearest_ray + 2.f); + result.view_projection = multiply(orthographic(left, right, bottom, top, .1f, depth), + view); + return result; +} +ShadowView local_view(const LocalLight& light, std::uint32_t face, + std::span casters) { + ShadowView result; + result.kind = light.kind == LocalLight::Kind::Point ? ShadowView::Kind::Point + : ShadowView::Kind::Spot; + result.light_id = light.stable_id; + result.face_index = face; + static constexpr std::array axes{{{1, 0, 0}, {-1, 0, 0}, {0, 1, 0}, + {0, -1, 0}, {0, 0, 1}, {0, 0, -1}}}; + static constexpr std::array ups{{{0, -1, 0}, {0, -1, 0}, {0, 0, 1}, + {0, 0, -1}, {0, -1, 0}, {0, -1, 0}}}; + const auto direction = light.kind == LocalLight::Kind::Point ? axes.at(face) + : unit(light.direction); + 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)); + const auto fov = light.kind == LocalLight::Kind::Point + ? std::numbers::pi_v / 2 : light.outer_angle * 2; + result.view_projection = multiply( + perspective(fov, 1, near_plane, light.range), + look_at(light.position, add(light.position, direction), up)); + result.caster_indices = visible_casters(result.view_projection, casters); + return result; +} +} // namespace + +ShadowPlan build_shadow_plan(const Snapshot& frame, + std::span casters, + const ShadowBudget& budget) { + for (const auto& caster : casters) + for (int axis = 0; axis < 3; ++axis) + if (!std::isfinite(caster.world.min[axis]) || + !std::isfinite(caster.world.max[axis]) || + caster.world.min[axis] > caster.world.max[axis]) + throw std::invalid_argument("Shadow caster world bounds must be finite and ordered"); + ShadowPlan plan; + plan.sun_atlas_size = supported_atlas(budget.sun_atlas_size, budget.sun_atlas_available) + ? budget.sun_atlas_size : 0; + plan.local_atlas_size = supported_atlas(budget.local_atlas_size, budget.local_atlas_available) + ? budget.local_atlas_size : 0; + std::unordered_set ids; + std::vector> ranked; + ranked.reserve(frame.local_lights.size()); + for (std::size_t i = 0; i < frame.local_lights.size(); ++i) { + const auto& light = frame.local_lights[i]; + validate_local(light); // Validate overflow records too, before truncation. + if (!ids.insert(light.stable_id).second) + throw std::invalid_argument("Duplicate local light stable_id: " + light.stable_id); + ranked.emplace_back(i, projected_influence(light, frame)); + } + std::sort(ranked.begin(), ranked.end(), [&](const auto& a, const auto& b) { + const auto& left = frame.local_lights[a.first]; + const auto& right = frame.local_lights[b.first]; + if (left.shadow_priority != right.shadow_priority) + return left.shadow_priority > right.shadow_priority; + if (a.second != b.second) + return a.second > b.second; + return left.stable_id < right.stable_id; + }); + const auto selected = std::min(ranked.size(), + std::min(budget.max_local_lights, 128u)); + plan.omitted_local_lights = static_cast(ranked.size() - selected); + for (std::size_t i = 0; i < selected; ++i) + plan.submitted_local_indices.push_back(ranked[i].first); + + std::optional sun = frame.sun; + if (!sun && !frame.authored_lights_present && frame.local_lights.empty()) + sun = SunLight{"legacy-sun", frame.light_direction, {1, 1, 1, 1}, 1, true}; + if (sun && sun->casts_shadow) { + plan.requested_sun_cascades = frame.camera_frustum + ? std::min(4u, budget.max_sun_views) : 1u; + if (frame.camera_frustum && + (frame.camera_frustum->near_plane <= 0 || + frame.camera_frustum->far_plane <= frame.camera_frustum->near_plane || + frame.camera_frustum->projection[0] == 0 || + frame.camera_frustum->projection[5] == 0)) + throw std::invalid_argument("Shadow camera frustum is invalid"); + const float near_plane = frame.camera_frustum ? frame.camera_frustum->near_plane : .1f; + const float far_plane = frame.camera_frustum + ? std::min(frame.camera_frustum->far_plane, budget.max_shadow_distance) : 80.f; + if (far_plane <= near_plane) + throw std::invalid_argument("Shadow distance does not reach the camera near plane"); + float previous = near_plane; + for (std::uint32_t i = 0; i < plan.requested_sun_cascades; ++i) { + const auto ratio = float(i + 1) / float(plan.requested_sun_cascades); + const auto logarithmic = near_plane * std::pow(far_plane / near_plane, ratio); + const auto uniform = near_plane + (far_plane - near_plane) * ratio; + const auto split = i + 1 == plan.requested_sun_cascades + ? far_plane : .5f * (logarithmic + uniform); + ShadowView view; + if (plan.sun_atlas_size) + view = sun_view(frame, *sun, casters, previous, split, i, plan.sun_atlas_size); + else { + view.kind = ShadowView::Kind::Sun; + view.light_id = sun->stable_id; + view.face_index = i; + view.split_near = previous; + view.split_far = split; + view.reason = ShadowDropReason::Unavailable; + } + if (plan.sun_atlas_size && + view.caster_indices.size() <= + budget.max_caster_draws - std::min(plan.caster_draws, budget.max_caster_draws)) { + view.valid = true; + plan.caster_draws += static_cast(view.caster_indices.size()); + ++plan.effective_sun_cascades; + } else { + if (plan.sun_atlas_size) + view.reason = ShadowDropReason::CasterBudget; + view.caster_indices.clear(); + ++plan.dropped_sun_views; + } + plan.sun_views.push_back(std::move(view)); + previous = split; + } + } + for (const auto source : plan.submitted_local_indices) { + const auto& light = frame.local_lights[source]; + LocalShadowAssignment assignment; + assignment.source_index = source; + assignment.first_view = static_cast(plan.local_views.size()); + const auto faces = light.kind == LocalLight::Kind::Point ? 6u : 1u; + if (!light.casts_shadow || light.intensity == 0) { + plan.local_assignments.push_back(assignment); + continue; + } + plan.local_faces_requested += faces; + if (!plan.local_atlas_size) + assignment.reason = ShadowDropReason::Unavailable; + else if (const auto capacity = std::min(budget.max_local_faces, 16u); + faces > capacity - std::min(plan.local_faces_used, capacity)) + assignment.reason = ShadowDropReason::TileBudget; + else { + std::vector group; + std::uint32_t group_draws{}; + for (std::uint32_t face = 0; face < faces; ++face) { + auto view = local_view(light, face, casters); + tile(view, plan.local_atlas_size, 4, + plan.local_faces_used + face); + group_draws += static_cast(view.caster_indices.size()); + group.push_back(std::move(view)); + } + if (group_draws > budget.max_caster_draws - + std::min(plan.caster_draws, budget.max_caster_draws)) + assignment.reason = ShadowDropReason::CasterBudget; + else { + assignment.valid = true; + assignment.face_count = faces; + plan.local_faces_used += faces; + plan.caster_draws += group_draws; + for (auto& view : group) { + view.valid = true; + plan.local_views.push_back(std::move(view)); + } + } + } + if (!assignment.valid) { + plan.dropped_local_faces += faces; + if (light.kind == LocalLight::Kind::Point) + plan.dropped_point_faces += 6; + } + plan.local_assignments.push_back(assignment); + } + return plan; +} +} // namespace faset::render diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index d99c7b8..25ebcfb 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -1721,6 +1722,7 @@ struct Renderer::Impl { void render(const Snapshot& snapshot) { 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.gpu_bins = statistics.gpu_visible_instances = statistics.gpu_frustum_rejected = statistics.gpu_occlusion_deferred = statistics.gpu_post_visible = 0; @@ -1811,6 +1813,8 @@ struct Renderer::Impl { }; std::vector selected_draws; selected_draws.reserve(snapshot.draws.size()); + std::vector shadow_casters; + shadow_casters.reserve(snapshot.draws.size()); struct BuildingBin { const Mesh* mesh{}; const Texture* texture{}; @@ -1820,10 +1824,17 @@ struct Renderer::Impl { std::vector building_bins; std::unordered_map> mesh_ranges; std::unordered_map current_lods; - for (const auto& item : snapshot.draws) { + for (std::size_t source_index = 0; source_index < snapshot.draws.size(); ++source_index) { + const auto& item = snapshot.draws[source_index]; if (!item.mesh || item.mesh->vertices.empty()) continue; const auto source_bounds = world_bounds(item.mesh, item.model); + if (item.cast_shadow) { + if (source_index > UINT32_MAX) + throw std::overflow_error("Shadow source draw index exceeds 32-bit capacity"); + shadow_casters.push_back( + {source_bounds, static_cast(source_index)}); + } std::vector thresholds; std::vector available; std::shared_ptr selected_mesh = item.mesh; @@ -2142,27 +2153,12 @@ struct Renderer::Impl { const auto& view = snapshot.camera_frustum->view; lighting.camera_forward_shadow_distance = {-view[2], -view[6], -view[10], 80}; } - auto sorted_lights = snapshot.local_lights; - std::stable_sort(sorted_lights.begin(), sorted_lights.end(), - [](const auto& a, const auto& b) { return a.stable_id < b.stable_id; }); - constexpr std::size_t max_local_lights = 128; + const auto shadow_plan = build_shadow_plan(snapshot, shadow_casters); + statistics.omitted_local_lights = shadow_plan.omitted_local_lights; std::vector gpu_lights; - gpu_lights.reserve(std::min(sorted_lights.size(), max_local_lights)); - for (const auto& local : sorted_lights) { - if (gpu_lights.size() == max_local_lights) - break; - const auto finite_color = std::all_of(local.color.begin(), local.color.end(), - [](float v) { return std::isfinite(v) && v >= 0; }); - const auto finite_position = std::all_of(local.position.begin(), local.position.end(), - [](float v) { return std::isfinite(v); }); - if (!finite_color || !finite_position || !std::isfinite(local.intensity) || - local.intensity < 0 || !std::isfinite(local.range) || local.range <= 0) - throw std::invalid_argument("Local light radiance, position and range must be finite"); - if (local.kind == LocalLight::Kind::Spot && - (!std::isfinite(local.inner_angle) || !std::isfinite(local.outer_angle) || - local.inner_angle < 0 || local.inner_angle > local.outer_angle || - local.outer_angle >= std::numbers::pi_v / 2)) - throw std::invalid_argument("Spotlight cone angles are invalid"); + gpu_lights.reserve(shadow_plan.submitted_local_indices.size()); + for (const auto source : shadow_plan.submitted_local_indices) { + const auto& local = snapshot.local_lights[source]; auto spot_direction = local.direction; float spot_length = std::hypot(spot_direction[0], spot_direction[1], spot_direction[2]); @@ -2187,6 +2183,7 @@ struct Renderer::Impl { gpu_lights.push_back(gpu); } lighting.counts[0] = static_cast(gpu_lights.size()); + 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{}; diff --git a/tests/render_lighting_policy_tests.cpp b/tests/render_lighting_policy_tests.cpp new file mode 100644 index 0000000..1820c70 --- /dev/null +++ b/tests/render_lighting_policy_tests.cpp @@ -0,0 +1,191 @@ +#include +#include +#include +#include +#include +#include + +using namespace faset::render; +namespace { +void require(bool condition, const char* message) { + if (!condition) + throw std::runtime_error(message); +} +Snapshot fixture() { + Snapshot frame; + const Vec3 eye{0, 2, 8}; + const auto view = look_at(eye, {0, 0, 0}); + const auto projection = perspective(.9f, 16.f / 9.f, .1f, 120.f); + frame.eye = eye; + frame.view_projection = multiply(projection, view); + frame.projection = projection; + frame.camera_frustum = CameraFrustum{view, projection, .1f, 120.f, true}; + frame.authored_lights_present = true; + frame.sun = SunLight{"sun", {-.7f, -.5f, -.3f}, {1, 1, 1, 1}, 1, true}; + return frame; +} +LocalLight point_light(std::string id, int priority = 0) { + LocalLight light; + light.kind = LocalLight::Kind::Point; + light.stable_id = std::move(id); + light.position = {0, 2, 0}; + light.range = 12; + light.shadow_priority = priority; + return light; +} +LocalLight spot_light(std::string id, int priority = 0) { + auto light = point_light(std::move(id), priority); + light.kind = LocalLight::Kind::Spot; + light.direction = {0, -1, 0}; + return light; +} +bool contains_caster(const ShadowView& view, std::uint32_t draw_index) { + return std::find(view.caster_indices.begin(), view.caster_indices.end(), draw_index) != + view.caster_indices.end(); +} +void run() { + auto frame = fixture(); + const auto plan = build_shadow_plan(frame, {}, {}); + require(plan.sun_views.size() == 4 && plan.effective_sun_cascades == 4, + "Explicit camera receives four usable sun cascades"); + require(plan.sun_views[0].split_near == .1f && + std::abs(plan.sun_views.back().split_far - 80.f) < 1e-4f, + "Practical splits start at camera near and stop at shadow distance"); + for (std::size_t i = 1; i < plan.sun_views.size(); ++i) + require(plan.sun_views[i].split_near == plan.sun_views[i - 1].split_far && + plan.sun_views[i].split_far > plan.sun_views[i].split_near, + "Cascade split endpoints are strictly increasing and contiguous"); + require(plan.sun_views[0].tile_index == 0 && plan.sun_views[3].tile_index == 3 && + plan.sun_views[0].usable_size == 1020, + "Four guarded 1024-square tiles fit a 2048-square sun atlas"); + auto shifted = frame; + shifted.eye[0] += .00001f; + const auto shifted_view = look_at(shifted.eye, {.00001f, 0, 0}); + shifted.camera_frustum->view = shifted_view; + shifted.view_projection = multiply(shifted.projection, shifted_view); + const auto stable = build_shadow_plan(shifted, {}, {}); + require(stable.sun_views[0].snapped_center_x == plan.sun_views[0].snapped_center_x && + stable.sun_views[0].snapped_center_y == plan.sun_views[0].snapped_center_y, + "Subtexel camera translation retains the snapped sun projection origin"); + const auto sun_direction = frame.sun->direction; + const auto inv_length = 1.f / std::hypot(sun_direction[0], sun_direction[1], sun_direction[2]); + const Vec3 upstream{-sun_direction[0] * inv_length * 18, + -sun_direction[1] * inv_length * 18, + -sun_direction[2] * inv_length * 18}; + const ShadowCasterBounds offscreen{{{upstream[0] - .5f, upstream[1] - .5f, + upstream[2] - .5f}, + {upstream[0] + .5f, upstream[1] + .5f, + upstream[2] + .5f}}, 7}; + const ShadowCasterBounds outside{{{999, 0, 0}, {1001, 2, 2}}, 8}; + const std::array casters{offscreen, outside}; + const auto with_casters = build_shadow_plan(frame, casters, {}); + require(std::any_of(with_casters.sun_views.begin(), with_casters.sun_views.end(), + [](const auto& view) { return contains_caster(view, 7); }), + "Offscreen upstream caster remains in a receiver's sun shadow view"); + require(std::none_of(with_casters.sun_views.begin(), with_casters.sun_views.end(), + [](const auto& view) { return contains_caster(view, 8); }), + "Caster outside every sun XY footprint is excluded"); + std::vector many(4097, {{{-.1f, -.1f, -.1f}, {.1f, .1f, .1f}}, 0}); + for (std::uint32_t i = 0; i < many.size(); ++i) + many[i].draw_index = i; + const auto overdraw = build_shadow_plan(frame, many, {}); + require(overdraw.caster_draws <= 4096 && + std::any_of(overdraw.sun_views.begin(), overdraw.sun_views.end(), + [](const auto& view) { + return !view.valid && view.reason == ShadowDropReason::CasterBudget && + view.caster_indices.empty(); + }), + "A view with 4097 casters is skipped whole rather than partially rendered"); + frame.sun.reset(); + for (int i = 0; i < 15; ++i) + frame.local_lights.push_back(spot_light("spot-" + std::to_string(i), 10)); + frame.local_lights.push_back(point_light("last-point")); + const auto capacity = build_shadow_plan(frame, {}, {}); + require(capacity.local_faces_used == 15 && capacity.dropped_point_faces == 6 && + capacity.local_faces_used <= 16 && capacity.caster_draws <= 4096, + "Insufficient room for six point faces drops the complete point shadow"); + require(capacity.submitted_local_indices.size() == 16 && + std::none_of(capacity.local_views.begin(), capacity.local_views.end(), + [](const auto& view) { return view.light_id == "last-point"; }), + "Atlas overflow leaves the point light in the lighting list, unshadowed"); + frame.local_lights = {point_light("omnidirectional")}; + const ShadowCasterBounds positive_x{{{3, -.2f, -.2f}, {3.4f, .2f, .2f}}, 19}; + const auto point_faces = build_shadow_plan(frame, std::array{positive_x}, {}); + require(point_faces.local_views.size() == 6 && + contains_caster(point_faces.local_views[0], 19) && + !contains_caster(point_faces.local_views[1], 19), + "Point-light caster behind the opposite face is culled from that face"); + frame.local_lights.clear(); + for (int i = 0; i < 15; ++i) + frame.local_lights.push_back(spot_light("spot-" + std::to_string(i), 10)); + frame.local_lights.push_back(point_light("last-point")); + auto reversed = frame; + std::reverse(reversed.local_lights.begin(), reversed.local_lights.end()); + const auto reordered = build_shadow_plan(reversed, {}, {}); + require(reordered.local_views.size() == capacity.local_views.size(), + "Reversing input lights preserves scheduled view count"); + for (std::size_t i = 0; i < capacity.local_views.size(); ++i) + require(reordered.local_views[i].light_id == capacity.local_views[i].light_id && + reordered.local_views[i].tile_index == capacity.local_views[i].tile_index, + "Stable IDs preserve atlas assignments across input reordering"); + frame.local_lights.clear(); + for (int i = 0; i < 128; ++i) { + auto light = spot_light("ordinary-" + std::to_string(i)); + light.casts_shadow = false; + frame.local_lights.push_back(light); + } + auto important = spot_light("late-high-priority", 5); + important.casts_shadow = false; + frame.local_lights.push_back(important); + const auto ranked = build_shadow_plan(frame, {}, {}); + require(ranked.submitted_local_indices.size() == 128 && + ranked.omitted_local_lights == 1 && + ranked.submitted_local_indices.front() == 128, + "Submission selects all 128 by priority and reports one omitted light"); + frame.local_lights.back().range = -1; + bool invalid_overflow_rejected = false; + try { + (void)build_shadow_plan(frame, {}, {}); + } catch (const std::invalid_argument&) { + invalid_overflow_rejected = true; + } + require(invalid_overflow_rejected, + "All authored lights are validated even when beyond the submission cap"); + frame.local_lights.clear(); + frame.sun.reset(); + const auto no_sun = build_shadow_plan(frame, {}, {}); + require(no_sun.requested_sun_cascades == 0 && no_sun.sun_views.empty(), + "Authored lights suppress legacy sun even when none is enabled"); + for (int i = 0; i < 20; ++i) + frame.local_lights.push_back(spot_light("over-cap-" + std::to_string(i))); + ShadowBudget relaxed; + relaxed.max_local_faces = 64; + relaxed.max_local_lights = 256; + const auto clamped = build_shadow_plan(frame, {}, relaxed); + require(clamped.local_faces_used == 16 && clamped.dropped_local_faces == 4, + "Fixed 4x4 local atlas never allocates outside its sixteen tiles"); + frame = fixture(); + frame.camera_frustum.reset(); + const auto legacy = build_shadow_plan(frame, {}, {}); + require(legacy.sun_views.size() == 1 && legacy.effective_sun_cascades == 1, + "A low-level snapshot without camera frustum uses one reported sun view"); + frame = fixture(); + ShadowBudget unsupported; + unsupported.sun_atlas_available = false; + const auto no_atlas = build_shadow_plan(frame, {}, unsupported); + require(no_atlas.effective_sun_cascades == 0 && + no_atlas.dropped_sun_views == 4 && + no_atlas.sun_views[0].reason == ShadowDropReason::Unavailable, + "Unsupported depth atlas yields an explicit unshadowed sun fallback"); +} +} // namespace +int main() { + try { + run(); + std::cout << "Shadow planning, stable allocation, caster visibility, and budgets passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/render_tests.cpp b/tests/render_tests.cpp index bb7be9e..2ac6735 100644 --- a/tests/render_tests.cpp +++ b/tests/render_tests.cpp @@ -193,6 +193,43 @@ int main(int argc, char** argv) { two_lights.local_lights.clear(); } renderer.set_visibility_mode(VisibilityMode::Direct); + renderer.render(two_lights); + const auto unlit_overflow = renderer.pixels(); + for (int i = 0; i < 128; ++i) { + LocalLight local; + local.stable_id = "low-priority-" + std::to_string(i); + local.position = {20, 20, 20}; + local.range = 1; + local.intensity = 0; + local.casts_shadow = false; + two_lights.local_lights.push_back(local); + } + LocalLight high; + high.stable_id = "last-high-priority"; + high.position = {-1.4f, 0, 1.4f}; + high.color = {1, 0, 0, 1}; + high.range = 2.2f; + high.intensity = 8; + high.shadow_priority = 10; + high.casts_shadow = false; + two_lights.local_lights.push_back(high); + two_lights.local_lights.back().range = -1; + bool overflow_validation_failed = false; + try { + renderer.render(two_lights); + } catch (const std::invalid_argument&) { + overflow_validation_failed = true; + } + require(overflow_validation_failed, + "Renderer validates light records beyond the 128-light cap"); + two_lights.local_lights.back().range = 2.2f; + renderer.render(two_lights); + const auto ranked_pixels = renderer.pixels(); + const auto ranked_left = (120 * 320 + 99) * 4; + require(renderer.stats().submitted_local_lights == 128 && + renderer.stats().omitted_local_lights == 1 && + ranked_pixels[ranked_left] > unlit_overflow[ranked_left] + 20, + "High-priority last light is submitted and omitted count is observable"); if (argc > 2) renderer.capture(argv[2]); renderer.resize(400, 300);