Plan bounded sun and local shadows from source casters
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
#pragma once
|
||||
#include <faset/render/visibility.hpp>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<float, 4> atlas_scale_offset{};
|
||||
std::array<float, 4> guarded_clamp{};
|
||||
float split_near{}, split_far{};
|
||||
float snapped_center_x{}, snapped_center_y{};
|
||||
std::vector<std::uint32_t> 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<ShadowView> sun_views; // Requested slots, including explicitly invalid ones.
|
||||
std::vector<ShadowView> local_views; // Only complete, valid spot/point allocations.
|
||||
std::vector<std::size_t> submitted_local_indices; // Priority/influence/stable-ID order.
|
||||
std::vector<LocalShadowAssignment> 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<const ShadowCasterBounds> casters,
|
||||
const ShadowBudget& budget = {});
|
||||
|
||||
} // namespace faset::render
|
||||
@@ -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};
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
#include <faset/render/lighting.hpp>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <numbers>
|
||||
#include <stdexcept>
|
||||
#include <unordered_set>
|
||||
|
||||
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<float, 4> 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<Vec3, 8> corners(const Bounds& bounds) {
|
||||
std::array<Vec3, 8> 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<Vec3, 8> frustum_slice(const CameraFrustum& camera, float near_distance,
|
||||
float far_distance) {
|
||||
std::array<Vec3, 8> 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<float>::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<unsigned, 7> 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<std::uint32_t> visible_casters(const Mat4& view_projection,
|
||||
std::span<const ShadowCasterBounds> casters) {
|
||||
std::vector<std::uint32_t> 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<float> / 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<const ShadowCasterBounds> 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<float>::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<const ShadowCasterBounds> 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<Vec3, 6> axes{{{1, 0, 0}, {-1, 0, 0}, {0, 1, 0},
|
||||
{0, -1, 0}, {0, 0, 1}, {0, 0, -1}}};
|
||||
static constexpr std::array<Vec3, 6> 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<float> / 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<const ShadowCasterBounds> 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<std::string> ids;
|
||||
std::vector<std::pair<std::size_t, float>> 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<std::size_t>(ranked.size(),
|
||||
std::min(budget.max_local_lights, 128u));
|
||||
plan.omitted_local_lights = static_cast<std::uint32_t>(ranked.size() - selected);
|
||||
for (std::size_t i = 0; i < selected; ++i)
|
||||
plan.submitted_local_indices.push_back(ranked[i].first);
|
||||
|
||||
std::optional<SunLight> 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<std::uint32_t>(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<std::uint32_t>(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<ShadowView> 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<std::uint32_t>(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
|
||||
+18
-21
@@ -10,6 +10,7 @@
|
||||
#include <cstring>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/render/render_graph.hpp>
|
||||
#include <faset/render/lighting.hpp>
|
||||
#include <faset/render/renderer.hpp>
|
||||
#include <faset/render/visibility.hpp>
|
||||
#include <fstream>
|
||||
@@ -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<SelectedDraw> selected_draws;
|
||||
selected_draws.reserve(snapshot.draws.size());
|
||||
std::vector<ShadowCasterBounds> 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<BuildingBin> building_bins;
|
||||
std::unordered_map<const Mesh*, std::pair<std::uint32_t, std::uint32_t>> mesh_ranges;
|
||||
std::unordered_map<std::string, std::size_t> 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<std::uint32_t>(source_index)});
|
||||
}
|
||||
std::vector<float> thresholds;
|
||||
std::vector<std::uint8_t> available;
|
||||
std::shared_ptr<const Mesh> 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<LocalLightGpu> 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<float> / 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<std::uint32_t>(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{};
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
#include <faset/render/lighting.hpp>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
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<ShadowCasterBounds> 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user