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 5ead206..b1f2136 100644 --- a/cmake/Renderer.cmake +++ b/cmake/Renderer.cmake @@ -40,13 +40,23 @@ 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_gpu_tests "${PROJECT_SOURCE_DIR}/tests/render_lighting_gpu_tests.cpp") + target_link_libraries(faset_render_lighting_gpu_tests PRIVATE faset_render) + add_test(NAME render_lighting_sun COMMAND faset_render_lighting_gpu_tests --sun) + set_tests_properties(render_lighting_sun PROPERTIES LABELS "gpu;p3") + add_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) + 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) @@ -78,5 +88,17 @@ if(BUILD_TESTING) target_link_libraries(faset_render_window_tests PRIVATE faset_render SDL3::SDL3) add_test(NAME render_window_lifecycle COMMAND faset_render_window_tests "${CMAKE_BINARY_DIR}/window-test") set_tests_properties(render_window_lifecycle PROPERTIES LABELS "gpu;window" TIMEOUT 40 SKIP_RETURN_CODE 77) + add_executable(faset_p3_lighting_benchmark + "${PROJECT_SOURCE_DIR}/examples/renderer/p3_lighting_benchmark.cpp") + target_link_libraries(faset_p3_lighting_benchmark PRIVATE faset_render faset_core) + target_compile_definitions(faset_p3_lighting_benchmark PRIVATE + FASET_BENCHMARK_CONFIGURATION="$") + add_test(NAME render_lighting_benchmark_schema COMMAND + "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tests/test_p3_lighting_benchmark.py") + set_tests_properties(render_lighting_benchmark_schema PROPERTIES LABELS "p3" TIMEOUT 90) + add_test(NAME render_lighting_benchmark_smoke COMMAND + "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tests/test_p3_lighting_benchmark.py" + --real-executable "$") + set_tests_properties(render_lighting_benchmark_smoke PROPERTIES LABELS "gpu;p3" TIMEOUT 90) endif() install(FILES ${FASET_SHADER_OUTPUTS} DESTINATION shaders) diff --git a/docs/manual/editor/lighting.md b/docs/manual/editor/lighting.md new file mode 100644 index 0000000..dee215a --- /dev/null +++ b/docs/manual/editor/lighting.md @@ -0,0 +1,92 @@ +# Add lights to a 3D scene + +Add a **Light** component to a scene entity. The entity's transform places a point +or spot light; its rotation aims a spot light along local negative Z. A directional +light uses the entity's orientation. Light colors and intensity contribute to the +mesh's linear PBR illumination before tone mapping. Sprites and UI retain their +unlit tint. + +The version-1 `faset.light` component has three `kind` values: + +| Kind | Position and direction | Useful fields | +| --- | --- | --- | +| `directional` | Direction from the entity transform | `color`, `intensity`, `casts_shadow` | +| `point` | Position from the entity transform; illuminates every direction | `color`, `intensity`, `range` | +| `spot` | Position and local negative-Z direction | `color`, `intensity`, `range`, `inner_angle`, `outer_angle` | + +Angles are radians. A spot's inner angle must not exceed its outer angle. Intensity +must be nonnegative and range positive. `enabled: false` keeps the component in the +scene without contributing light. The `shadow_priority` integer is reserved for the +bounded local-shadow scheduler; it does not change brightness. + +In the current rendering checkpoint, one enabled directional light can cast the +existing single-map shadow. Point and spot lights illuminate meshes but do not yet +cast shadows. The [P3 lighting plan](https://github.com/emil28092005/Faset_Engine/blob/main/docs/superpowers/plans/2026-09-24-p3-lighting.md) +tracks cascades and the bounded local-shadow atlas. A scene with no Light component +keeps the legacy white sun so older projects retain their appearance. Adding any +Light component, even a disabled one, turns off that compatibility fallback. If +several directionals are enabled, Faset chooses the one with the smallest stable +entity ID and reports a diagnostic for the others. + +## Author a point light through MCP + +Use `faset_schema` to inspect the current field IDs, then send a `faset_scene_edit` +batch with the document ID, current revision, and target entity ID. For example: + +```json +{ + "document": "REPLACE_WITH_DOCUMENT_ID", + "revision": 4, + "idempotency_key": "add-red-point-light", + "operations": [{ + "op": "component.add", + "entity": "REPLACE_WITH_ENTITY_ID", + "type": "faset.light", + "fields": { + "kind": "point", + "color": [1, 0.15, 0.1, 1], + "intensity": 8, + "range": 6 + } + }] +} +``` + +Move the entity with its Transform component. `component.add` fills any omitted +light fields from the version-1 schema; use `component.set` for later edits. See +[MCP and command line](mcp.md) for revision and retry handling. + +## Supply lights directly from C++ + +When building a `faset::render::Snapshot` yourself, set +`authored_lights_present` to suppress the compatibility sun in a local-only scene. +Provide a stable ID for each light so future shadow scheduling remains independent +of submission order. + +```cpp +faset::render::Snapshot snapshot; +snapshot.authored_lights_present = true; + +faset::render::LocalLight point; +point.kind = faset::render::LocalLight::Kind::Point; +point.stable_id = "level/torch"; +point.position = {-2, 1.5f, 0}; +point.color = {1, 0.3f, 0.1f, 1}; +point.intensity = 8; +point.range = 6; +snapshot.local_lights.push_back(point); + +faset::render::LocalLight spot; +spot.kind = faset::render::LocalLight::Kind::Spot; +spot.stable_id = "level/lamp"; +spot.position = {2, 3, 0}; +spot.direction = {0, -1, 0}; +spot.inner_angle = 0.25f; +spot.outer_angle = 0.55f; +spot.intensity = 5; +spot.range = 9; +snapshot.local_lights.push_back(spot); +``` + +The renderer submits at most 128 local lights per frame in stable-ID order. Later +P3 work adds explicit overflow diagnostics and measured light-list optimization. diff --git a/docs/superpowers/plans/2026-09-24-p3-lighting.md b/docs/superpowers/plans/2026-09-24-p3-lighting.md index b7078c5..7253087 100644 --- a/docs/superpowers/plans/2026-09-24-p3-lighting.md +++ b/docs/superpowers/plans/2026-09-24-p3-lighting.md @@ -42,9 +42,9 @@ Tasks 1–3 define the shared interfaces. Integrate Task 2's descriptor ABI befo **Files:** Modify `include/faset/render/renderer.hpp`, `src/authoring/schema.cpp`, `src/player/SceneView.cpp`, `tests/authoring_tests.cpp`, `tests/runtime_player_tests.cpp`, and relevant Manual authoring examples. -**Interfaces:** Introduce `SunLight { stable_id, direction, color, intensity, casts_shadow }`, `LocalLight { Kind::Point|Spot, stable_id, position, direction, color, intensity, range, inner_angle, outer_angle, casts_shadow, shadow_priority }`, and `CameraFrustum { view, projection, near_plane, far_plane, perspective }`. Add `std::optional Snapshot::sun`, `std::vector Snapshot::local_lights`, and `std::optional Snapshot::camera_frustum` after existing aggregate fields; retain `Snapshot::light_direction`. `SceneView::build` fills camera data for 3D scenes and picks the first enabled directional by stable ID. +**Interfaces:** Introduce `SunLight { stable_id, direction, color, intensity, casts_shadow }`, `LocalLight { Kind::Point|Spot, stable_id, position, direction, color, intensity, range, inner_angle, outer_angle, casts_shadow, shadow_priority }`, and `CameraFrustum { view, projection, near_plane, far_plane, perspective }`. Add `std::optional Snapshot::sun`, `std::vector Snapshot::local_lights`, `bool Snapshot::authored_lights_present` (default false), and `std::optional Snapshot::camera_frustum` after existing aggregate fields; retain `Snapshot::light_direction`. `SceneView::build` fills camera data for 3D scenes, sets authored-light presence even for disabled/future-version light components, and picks the first enabled directional by stable ID. A legacy sun is synthesized only when both sun and authored-light presence are absent. -- [ ] **Step 1: Write failing schema/extraction tests.** Construct a version-1 scene with directional, point, and spot entities in one order and reversed order. Assert all local IDs/properties agree; authored sun color/intensity are preserved; no-light scene retains the default legacy sun; extra directionals emit a diagnostic; invalid `range <= 0`, `inner_angle > outer_angle`, nonfinite color/transform, and unknown kind identify the entity/field. An essential assertion is: +- [ ] **Step 1: Write failing schema/extraction tests.** Construct a version-1 scene with directional, point, and spot entities in one order and reversed order. Assert all local IDs/properties agree; authored sun color/intensity are preserved; a no-light scene has `authored_lights_present == false` and retains the default legacy sun, while a local-only or explicitly disabled-sun scene has the flag true and no sun. Extra directionals emit a diagnostic; invalid `range <= 0`, `inner_angle > outer_angle`, nonfinite color/transform, and unknown kind identify the entity/field. An essential assertion is: ```cpp auto a = view.build(scene_with_three_lights(), 16.f / 9.f); auto b = view.build(reordered_scene_with_three_lights(), 16.f / 9.f); @@ -54,14 +54,14 @@ Tasks 1–3 define the shared interfaces. Integrate Task 2's descriptor ABI befo "Light ordering follows stable IDs, not entity array order"); ``` - [ ] **Step 2: Run the focused tests red.** Run `cmake --preset linux-debug` and `cmake --build --preset linux-debug --target faset_authoring_tests faset_player_tests --parallel 4`; missing typed fields should fail compilation. If compilation succeeds, `ctest --test-dir build/linux-debug --output-on-failure --no-tests=error -R '^(authoring|player_scene_contracts)$'` must fail on a new behavioral assertion. Record the expected failure rather than assuming the build itself must be red. -- [ ] **Step 3: Add schema defaults and extraction.** Keep builtin version 1; use `fields.value` for additive fields, normalize directions after the world transform, validate finite/color/range/cone values, sort by stable ID, and preserve the legacy fallback. Set `camera_frustum` from the same unjittered view/projection used to form `view_projection`. Update direct C++ API examples to construct one point and one spot light. +- [ ] **Step 3: Add schema defaults and extraction.** Keep builtin version 1; use `fields.value` for additive fields, normalize directions after the world transform, validate finite/color/range/cone values, sort by stable ID, and preserve the legacy fallback only when no authored light component exists. Set `camera_frustum` from the same unjittered view/projection used to form `view_projection`. Update direct C++ API examples to construct one point and one spot light. ```cpp struct CameraFrustum { Mat4 view{identity}, projection{identity}; float near_plane{0.1f}, far_plane{1000.f}; bool perspective{true}; }; - // Snapshot::light_direction remains available to callers without Snapshot::sun. + // Snapshot::light_direction remains a fallback only if authored_lights_present is false. ``` - [ ] **Step 4: Run focused tests green.** `ctest --test-dir build/linux-debug --output-on-failure -R '^(authoring|player_scene_contracts)$'` passes, including old version-1 scenes and exported-scene decoding. - [ ] **Step 5: Commit** `Expose authored sun, point, and spot lights in render snapshots`. diff --git a/docs/superpowers/specs/2026-09-24-p3-lighting-design.md b/docs/superpowers/specs/2026-09-24-p3-lighting-design.md index 39442de..0e7ff34 100644 --- a/docs/superpowers/specs/2026-09-24-p3-lighting-design.md +++ b/docs/superpowers/specs/2026-09-24-p3-lighting-design.md @@ -12,7 +12,7 @@ P3 supplies one sun plus point and spot lights, camera-fitted cascaded sun shado Keep `faset.light` at builtin schema version 1 and add optional fields with defaults: `kind` (`directional`, `point`, `spot`, default `directional`), `enabled` (true), `color` (white), `intensity` (1, nonnegative), `range` (10, positive, for local lights), `inner_angle` (0.35 radians), `outer_angle` (0.70 radians, strictly below π/2), `casts_shadow` (true), and `shadow_priority` (integer 0). Existing fields and component IDs remain valid. Cross-field validation requires `0 <= inner_angle <= outer_angle`; local range, color channels, intensity, transforms, and cone directions must be finite. Invalid authored values return a diagnostic with entity and field rather than nonfinite GPU data. Missing new fields use the schema defaults. -`Snapshot` retains `light_direction` for existing direct-render clients. Add an optional `SunLight`, a vector of `LocalLight`, and an optional explicit `CameraFrustum` containing unjittered view/projection matrices, near/far distances, and projection kind. `SceneView` populates these from the authoring scene. Each light carries the stable entity/component identity, transformed position or normalized direction, color, intensity, range, cone angles, and shadow options. The first enabled directional light by stable ID is the sun; extra directionals produce a visible diagnostic until a later multi-sun design exists. If there is no authored directional light, the renderer synthesizes the legacy sun from `Snapshot::light_direction`. Local lights are ordered by stable ID to prevent reordering from changing atlas allocation or results. Point attenuation tends smoothly to zero at `range`; a spot multiplies it by a smooth inner-to-outer cone factor. The shader handles zero distance and invalid normals without NaN output. +`Snapshot` retains `light_direction` for existing direct-render clients. Add an optional `SunLight`, a vector of `LocalLight`, an `authored_lights_present` flag (default false), and an optional explicit `CameraFrustum` containing unjittered view/projection matrices, near/far distances, and projection kind. `SceneView` populates these from the authoring scene and sets the flag if any authored light component exists, including a disabled or future-version component. Each light carries the stable entity/component identity, transformed position or normalized direction, color, intensity, range, cone angles, and shadow options. The first enabled directional light by stable ID is the sun; extra directionals produce a visible diagnostic until a later multi-sun design exists. The renderer synthesizes the legacy sun from `Snapshot::light_direction` only when `sun` is absent **and** `authored_lights_present` is false. Thus an old scene without light components keeps its previous appearance, while a local-only scene or explicitly disabled sun does not receive an unintended directional light. Low-level callers may set the flag to request a dark scene without authoring metadata. Local lights are ordered by stable ID to prevent reordering from changing atlas allocation or results. Point attenuation tends smoothly to zero at `range`; a spot multiplies it by a smooth inner-to-outer cone factor. The shader handles zero distance and invalid normals without NaN output. Explicit camera frustum data is required for four cascades. If a low-level caller supplies only the legacy `view_projection`, the renderer uses one bounded legacy-compatible sun shadow view and reports `effective_sun_cascades = 1`; it never silently claims CSM. 2D sprite-only snapshots do not incur shadow work. The future temporal stage may jitter the main raster projection, but the shadow planner consumes the unjittered `CameraFrustum` exclusively. diff --git a/examples/renderer/p3_lighting_benchmark.cpp b/examples/renderer/p3_lighting_benchmark.cpp new file mode 100644 index 0000000..aae5c72 --- /dev/null +++ b/examples/renderer/p3_lighting_benchmark.cpp @@ -0,0 +1,239 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace faset::render; +namespace fs = std::filesystem; + +namespace { +struct Options { + unsigned lights{}, width{1920}, height{1080}, warmup{10}, frames{30}, run_index{}; + bool shadows{}, validation{}; + VisibilityMode visibility{VisibilityMode::Direct}; + std::string commit{"unknown"}, driver{"unknown"}; + fs::path csv, capture; +}; + +unsigned number(std::string_view text, std::string_view name) { + std::size_t end{}; + const auto value = std::stoul(std::string(text), &end); + if (end != text.size() || value > 100000) + throw std::invalid_argument("Invalid value for " + std::string(name)); + return static_cast(value); +} + +Options parse(int argc, char** argv) { + Options options; + for (int i = 1; i < argc; ++i) { + const std::string name = argv[i]; + if (name == "--list-runs") { + std::cout << "{\"lights\":[0,4,16,32,64,128]," + "\"visibility\":[\"direct\",\"gpu-frustum\",\"gpu-occlusion\"]," + "\"shadows\":[\"off\",\"on\"]}\n"; + std::exit(0); + } + if (name == "--help") { + std::cout << "Usage: faset_p3_lighting_benchmark --lights 0|4|16|32|64|128 " + "--shadows on|off --visibility direct|gpu-frustum|gpu-occlusion " + "--csv PATH [--width N --height N --warmup N --frames N " + "--run-index N --commit SHA --driver NAME --validation on|off " + "--capture PATH]\n"; + std::exit(0); + } + if (i + 1 >= argc) + throw std::invalid_argument("Missing value for " + name); + const std::string value = argv[++i]; + if (name == "--lights") options.lights = number(value, name); + else if (name == "--width") options.width = number(value, name); + else if (name == "--height") options.height = number(value, name); + else if (name == "--warmup") options.warmup = number(value, name); + else if (name == "--frames") options.frames = number(value, name); + else if (name == "--run-index") options.run_index = number(value, name); + else if (name == "--commit") options.commit = value; + else if (name == "--driver") options.driver = value; + else if (name == "--csv") options.csv = faset::path_from_utf8(value); + else if (name == "--capture") options.capture = faset::path_from_utf8(value); + else if (name == "--shadows") { + if (value != "on" && value != "off") + throw std::invalid_argument("--shadows must be on or off"); + options.shadows = value == "on"; + } else if (name == "--validation") { + if (value != "on" && value != "off") + throw std::invalid_argument("--validation must be on or off"); + options.validation = value == "on"; + } else if (name == "--visibility") { + if (value == "direct") options.visibility = VisibilityMode::Direct; + else if (value == "gpu-frustum") options.visibility = VisibilityMode::GpuFrustum; + else if (value == "gpu-occlusion") options.visibility = VisibilityMode::GpuOcclusion; + else throw std::invalid_argument("Unknown visibility mode: " + value); + } else throw std::invalid_argument("Unknown option: " + name); + } + constexpr std::array allowed_lights{0u, 4u, 16u, 32u, 64u, 128u}; + if (options.csv.empty() || options.width == 0 || options.height == 0 || + options.frames == 0 || options.warmup > 1000 || + std::find(allowed_lights.begin(), allowed_lights.end(), options.lights) == + allowed_lights.end()) + throw std::invalid_argument("Invalid benchmark configuration"); + return options; +} + +const char* mode_name(VisibilityMode mode) { + switch (mode) { + case VisibilityMode::Direct: return "direct"; + case VisibilityMode::GpuFrustum: return "gpu-frustum"; + case VisibilityMode::GpuOcclusion: return "gpu-occlusion"; + } + return "unknown"; +} + +void csv_text(std::ostream& out, std::string_view value) { + out << '"'; + for (const char c : value) { + if (c == '"') out << '"'; + out << c; + } + out << '"'; +} + +Snapshot benchmark_scene(const Options& options) { + Snapshot scene; + scene.view_id = "p3-lighting-benchmark-fixed-scene"; + scene.eye = {0, 0, 9}; + scene.projection = perspective(.9f, float(options.width) / float(options.height), .1f, 100); + const auto view = look_at(scene.eye, {0, 0, 0}); + scene.view_projection = multiply(scene.projection, view); + scene.camera_frustum = CameraFrustum{view, scene.projection, .1f, 100, true}; + scene.authored_lights_present = true; + scene.clear_color = {.035f, .04f, .05f, 1}; + DrawItem receiver; + receiver.mesh = cube_mesh(); + receiver.model = transform({0, 0, -.15f}, {}, {10, 7.5f, .2f}); + receiver.color = {.65f, .67f, .7f, 1}; + receiver.roughness = .65f; + receiver.cast_shadow = options.shadows; + receiver.instance_key = "large-receiver"; + scene.draws.push_back(receiver); + for (int i = 0; i < 9; ++i) { + DrawItem object; + object.mesh = cube_mesh(); + object.model = transform({(float(i % 3) - 1.f) * 2.5f, + (float(i / 3) - 1.f) * 1.8f, .45f}, + {}, {.42f, .42f, .6f}); + object.color = {.6f + .1f * float(i % 3), .5f, .4f + .1f * float(i / 3), 1}; + object.cast_shadow = options.shadows; + object.instance_key = "caster-" + std::to_string(i); + scene.draws.push_back(std::move(object)); + } + for (unsigned i = 0; i < options.lights; ++i) { + LocalLight light; + light.kind = LocalLight::Kind::Point; + light.stable_id = "benchmark-light-" + std::to_string(i); + light.position = {(float(i % 8) - 3.5f) * 1.35f, + (float((i / 8) % 8) - 3.5f) * .95f, + 2.f + .35f * float(i % 3)}; + light.color = {.6f + .4f * float(i % 3 == 0), + .6f + .4f * float(i % 3 == 1), + .6f + .4f * float(i % 3 == 2), 1}; + light.intensity = 5.f; + light.range = 8.f; + light.casts_shadow = options.shadows; + scene.local_lights.push_back(std::move(light)); + } + return scene; +} + +void benchmark(const Options& options) { + RendererConfig config; + config.width = options.width; + config.height = options.height; + config.headless = true; + config.validation = options.validation; + config.visibility_mode = options.visibility; + auto renderer = Renderer(config); + const auto scene = benchmark_scene(options); + for (unsigned i = 0; i < options.warmup; ++i) + renderer.render(scene); + if (!options.csv.parent_path().empty()) + fs::create_directories(faset::native_io_path(options.csv.parent_path())); + std::ofstream csv(faset::native_io_path(options.csv)); + if (!csv) + throw std::runtime_error("Cannot open benchmark CSV: " + faset::path_to_utf8(options.csv)); + csv << "light_count,shadows,visibility,effective_visibility,lighting_path," + "build_configuration,run_index,frame," + "device,driver,commit,width,height,validation_enabled,validation_errors," + "submitted_local_lights,omitted_local_lights," + "requested_local_shadow_faces,rendered_local_shadow_faces,dropped_shadow_faces," + "shadow_atlas_full_drops,shadow_tiles,draw_calls,gpu_bytes," + "gpu_main_raster_ms,gpu_post_raster_ms,gpu_post_visible,visibility_counters_valid," + "gpu_sun_shadow_ms,gpu_local_shadow_ms,gpu_shadow_ms,gpu_ms,cpu_ms,readback_cpu_ms\n"; + csv << std::fixed << std::setprecision(6); + for (unsigned frame = 0; frame < options.frames; ++frame) { + renderer.render(scene); + const auto stats = renderer.stats(); + if (stats.validation_errors != 0) + throw std::runtime_error("Vulkan validation error during benchmark"); + if (stats.submitted_local_lights != options.lights || stats.omitted_local_lights != 0) + throw std::runtime_error("Renderer did not submit every requested local light"); + if (stats.effective_visibility_mode != options.visibility) + throw std::runtime_error("Requested visibility path fell back during benchmark"); + if (stats.gpu_main_raster_ms <= 0 || stats.gpu_ms <= 0) + throw std::runtime_error("GPU raster or frame timestamp was unavailable"); + csv << options.lights << ',' << (options.shadows ? "on" : "off") << ',' + << mode_name(options.visibility) << ',' << mode_name(stats.effective_visibility_mode) + << ',' << stats.effective_lighting_path << ',' << FASET_BENCHMARK_CONFIGURATION << ',' + << options.run_index << ',' << frame << ','; + csv_text(csv, stats.device); + csv << ','; + csv_text(csv, options.driver); + csv << ','; + csv_text(csv, options.commit); + csv << ',' << options.width << ',' << options.height << ',' + << (stats.validation_enabled ? 1 : 0) << ',' << stats.validation_errors << ',' + << stats.submitted_local_lights << ',' << stats.omitted_local_lights << ',' + << stats.requested_local_shadow_faces << ',' << stats.local_shadow_faces << ',' + << stats.dropped_shadow_faces << ',' << stats.shadow_atlas_full_drops << ',' + << stats.local_shadow_tiles << ',' << stats.draw_calls << ',' + << stats.gpu_allocated_bytes << ',' + << stats.gpu_main_raster_ms << ',' << stats.gpu_post_raster_ms << ',' + << stats.gpu_post_visible << ',' << (stats.visibility_counters_valid ? 1 : 0) + << ',' << stats.gpu_sun_shadow_ms << ',' << stats.gpu_local_shadow_ms << ',' + << (stats.gpu_sun_shadow_ms + stats.gpu_local_shadow_ms) << ',' << stats.gpu_ms << ',' + << stats.cpu_ms << ',' << stats.readback_cpu_ms << '\n'; + } + if (!csv) + throw std::runtime_error("Cannot finish benchmark CSV: " + faset::path_to_utf8(options.csv)); + if (!options.capture.empty()) + renderer.capture(faset::native_io_path(options.capture)); +} +} // namespace + +int benchmark_main(int argc, char** argv) { + try { + benchmark(parse(argc, argv)); + return 0; + } catch (const std::exception& error) { + std::cerr << "P3 lighting benchmark: " << error.what() << '\n'; + return 1; + } +} + +#ifdef _WIN32 +int wmain(int argc, wchar_t** argv) { + return faset::run_utf8_main(argc, argv, benchmark_main); +} +#else +int main(int argc, char** argv) { + return benchmark_main(argc, argv); +} +#endif 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 8a71592..324ed18 100644 --- a/include/faset/render/renderer.hpp +++ b/include/faset/render/renderer.hpp @@ -80,6 +80,34 @@ struct Text { Color color{0.85f, 0.87f, 0.90f, 1}; float size{14}; }; +struct SunLight { + std::string stable_id; + Vec3 direction{-0.5f, -1, -0.3f}; + Color color{1, 1, 1, 1}; + float intensity{1}; + bool casts_shadow{true}; +}; +struct LocalLight { + enum class Kind { Point, Spot }; + Kind kind{Kind::Point}; + std::string stable_id; + Vec3 position{}; + Vec3 direction{0, 0, -1}; + Color color{1, 1, 1, 1}; + float intensity{1}; + float range{10}; + float inner_angle{0.35f}; + float outer_angle{0.7f}; + bool casts_shadow{true}; + int shadow_priority{}; +}; +struct CameraFrustum { + Mat4 view{identity}; + Mat4 projection{identity}; + float near_plane{0.1f}; + float far_plane{1000}; + bool perspective{true}; +}; struct Snapshot { // Optional scene viewport in drawable pixels (x, y, width, height); zero size uses the full // target. @@ -100,6 +128,12 @@ struct Snapshot { // Distinguishes temporal histories when one Renderer displays different views. std::string view_id{}; bool camera_cut{}; + // Empty legacy scenes may use the renderer's compatibility sun. Any authored light + // component, including an explicitly disabled or opaque future one, suppresses it. + bool authored_lights_present{}; + std::optional sun{}; + std::vector local_lights; + std::optional camera_frustum{}; }; enum class VisibilityMode { Direct, GpuFrustum, GpuOcclusion }; // CPU-only validation used before publishing a game or creating Vulkan pipelines. @@ -149,6 +183,15 @@ 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{}; + 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}; @@ -160,6 +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{}, gpu_local_shadow_ms{}; + std::string effective_lighting_path{"forward"}; std::string device; }; struct HzbDebugImage { diff --git a/mkdocs.yml b/mkdocs.yml index 3622a6d..6d35b79 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -44,6 +44,7 @@ nav: - Editor workspace: editor/workspace.md - Scene templates: editor/templates.md - Assets and Blender: editor/assets.md + - Lighting: editor/lighting.md - GPU visibility and mesh LOD: editor/visibility-lod.md - Build, Play, and export: editor/export.md - Profiling and measurements: editor/profiling.md diff --git a/shaders/baseline.slang b/shaders/baseline.slang index 92cf8da..bf04df3 100644 --- a/shaders/baseline.slang +++ b/shaders/baseline.slang @@ -24,6 +24,32 @@ struct FrameParameters { [[vk::binding(1,0)]] SamplerState shadowSampler; [[vk::binding(2,0)]] Texture2D colorMap; [[vk::binding(3,0)]] SamplerState colorSampler; +// Shared Direct/P2 graphics ABI. The legacy material set remains set 0; +// GPU-only instance/visibility records occupy set 2. +struct LightingHeader { + uint4 counts; // local count, sun enabled, sun shadow enabled, sun view count + float4 sunDirectionIntensity; // xyz world-space ray direction, w intensity + float4 sunColor; + float4 cameraForwardShadowDistance; + float4 cascadeSplits; +}; +struct LocalLightGpu { + float4 positionRange; + float4 directionCosOuter; + float4 colorIntensity; + float4 coneTypeShadowView; // cos(inner), 0=point/1=spot, shadow view, flags + float4 reserved; +}; +struct ShadowViewGpu { + column_major float4x4 viewProjection; + float4 tileScaleOffset; + float4 guardedClamp; + float4 biasFlags; +}; +[[vk::binding(0,1)]] StructuredBuffer lightingFrame; +[[vk::binding(1,1)]] StructuredBuffer localLights; +[[vk::binding(2,1)]] StructuredBuffer shadowViews; +[[vk::binding(3,1)]] Texture2D localShadowAtlas; [shader("vertex")] VertexOutput vertexMain(VertexInput v) { VertexOutput o; @@ -32,6 +58,70 @@ VertexOutput vertexMain(VertexInput v) { } [shader("vertex")] float4 shadowMain(VertexInput v) : SV_Position { return mul(frame.lightViewProjection, float4(v.world,1)); } +float sampleSunCascade(uint index, float3 world, float nl) { + ShadowViewGpu record = shadowViews[index]; + if (record.biasFlags.w < 0.5) return 1.0; + float4 clip = mul(record.viewProjection, float4(world,1)); + if (clip.w <= 0.0) return 1.0; + float3 projected = clip.xyz / clip.w; + float2 localUV = projected.xy * 0.5 + 0.5; + if (any(localUV < 0.0) || any(localUV > 1.0) || + projected.z < 0.0 || projected.z > 1.0) return 1.0; + float2 atlasUV = localUV * record.tileScaleOffset.xy + record.tileScaleOffset.zw; + float bias = max(record.biasFlags.x, record.biasFlags.y * (1.0 - nl)); + float visible = 0.0; + for (int y=-1; y<=1; ++y) for (int x=-1; x<=1; ++x) { + float2 tap = clamp(atlasUV + float2(x,y) * record.biasFlags.z, + record.guardedClamp.xy, record.guardedClamp.zw); + float depth = shadowMap.SampleLevel(shadowSampler, tap, 0); + visible += projected.z - bias <= depth ? 1.0 / 9.0 : 0.0; + } + return visible; +} +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); + if (nl <= 0.0) return float3(0); + float3 halfVector = l + view; + float halfLengthSquared = dot(halfVector, halfVector); + float3 h = halfLengthSquared > 1e-8 ? halfVector * rsqrt(halfLengthSquared) : n; + float nv=max(dot(n,view),0.001), nh=max(dot(n,h),0.0), vh=max(dot(view,h),0.0); + float a=rough*rough, a2=a*a, denom=nh*nh*(a2-1.0)+1.0; + float d=a2/(pi*denom*denom+0.0001); + float k=(rough+1.0)*(rough+1.0)/8.0; + float g=(nl/(nl*(1.0-k)+k))*(nv/(nv*(1.0-k)+k)); + float3 f0=lerp(float3(0.04),base,metal), fresnel=f0+(1.0-f0)*pow(1.0-vh,5.0); + float3 spec=d*g*fresnel/max(4.0*nv*nl,0.001); + return ((1.0-fresnel)*(1.0-metal)*base/pi+spec)*nl; +} [shader("fragment")] float4 fragmentMain(VertexOutput v) : SV_Target { float4 sampled = colorMap.Sample(colorSampler, v.uv); @@ -41,28 +131,74 @@ float4 fragmentMain(VertexOutput v) : SV_Target { return v.color * sampled; } float4 base = v.color * sampled; - const float pi = 3.14159265; - float3 n=normalize(v.normal), l=normalize(-frame.lightDirection.xyz), view=normalize(frame.eye.xyz-v.world), h=normalize(l+view); - float nl=max(dot(n,l),0.0), nv=max(dot(n,view),0.001), nh=max(dot(n,h),0.0), vh=max(dot(view,h),0.0); + LightingHeader lighting = lightingFrame[0]; + float3 n=normalize(v.normal); + float3 viewDelta=frame.eye.xyz-v.world; + float viewLengthSquared=dot(viewDelta,viewDelta); + float3 view=viewLengthSquared > 1e-8 ? viewDelta*rsqrt(viewLengthSquared) : n; float rough=clamp(v.material.x,0.08,1.0), metal=saturate(v.material.y); - float a=rough*rough, a2=a*a, denom=nh*nh*(a2-1.0)+1.0; - float d=a2/(pi*denom*denom+0.0001); - float k=(rough+1.0)*(rough+1.0)/8.0; - float g=(nl/(nl*(1.0-k)+k))*(nv/(nv*(1.0-k)+k)); - float3 f0=lerp(float3(0.04),base.rgb,metal), fresnel=f0+(1.0-f0)*pow(1.0-vh,5.0); - float3 spec=d*g*fresnel/max(4.0*nv*nl,0.001); - float4 lightClip=mul(frame.lightViewProjection,float4(v.world,1)); - float3 projected=lightClip.xyz/lightClip.w; - float2 uv=projected.xy*.5+.5; - float visibility=1.0; - if(all(uv>=0.0)&&all(uv<=1.0)&&projected.z>=0.0&&projected.z<=1.0) { - visibility=0.0; - for(int y=-1;y<=1;++y) for(int x=-1;x<=1;++x) { - float depth=shadowMap.SampleLevel(shadowSampler,uv+float2(x,y)/1024.0,0); - visibility += projected.z-max(0.0008,0.003*(1.0-nl)) <= depth ? 1.0/9.0 : 0.0; + float3 linear=base.rgb*.12; + if (lighting.counts.y != 0 && lighting.sunDirectionIntensity.w > 0) { + float3 l=normalize(-lighting.sunDirectionIntensity.xyz); + float nl=max(dot(n,l),0.0); + float visibility=1.0; + if (lighting.counts.z != 0 && lighting.counts.w != 0 && nl > 0) { + if (lighting.counts.w == 1) { + visibility = sampleSunCascade(0, v.world, nl); + } else { + float cameraDepth = dot(v.world - frame.eye.xyz, + lighting.cameraForwardShadowDistance.xyz); + if (cameraDepth >= 0.0 && + cameraDepth <= lighting.cameraForwardShadowDistance.w) { + uint cascade = 0; + while (cascade + 1 < lighting.counts.w && + cameraDepth > lighting.cascadeSplits[cascade]) ++cascade; + visibility = sampleSunCascade(cascade, v.world, nl); + if (cascade + 1 < lighting.counts.w) { + float previousSplit = cascade == 0 ? 0.0 : + lighting.cascadeSplits[cascade-1]; + float blendWidth = max(0.2, + 0.1 * (lighting.cascadeSplits[cascade] - previousSplit)); + float blend = saturate((cameraDepth - + (lighting.cascadeSplits[cascade] - blendWidth)) / blendWidth); + if (blend > 0.0) + visibility = lerp(visibility, + sampleSunCascade(cascade+1, v.world, nl), blend); + } + } + } } + linear += directBRDF(base.rgb, rough, metal, n, view, l) * + lighting.sunColor.rgb * (lighting.sunDirectionIntensity.w * 3.0 * visibility); + } + for (uint i=0; i= range || light.colorIntensity.w <= 0) continue; + float3 l=delta/distance; + float relative=distance/range; + float cutoff=1.0-relative*relative*relative*relative; + float attenuation=cutoff*cutoff/(1.0+distanceSquared); + if (light.coneTypeShadowView.y > 0.5) { + float cosAngle=dot(-l,normalize(light.directionCosOuter.xyz)); + float denominator=max(light.coneTypeShadowView.x-light.directionCosOuter.w,1e-4); + 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 * visibility); } - float3 linear=base.rgb*.12 + ((1.0-fresnel)*(1.0-metal)*base.rgb/pi+spec)*nl*3.0*visibility; linear=linear/(1.0+linear); return float4(pow(max(linear,0),float3(1.0/2.2)),base.a); } diff --git a/shaders/gpu_scene.slang b/shaders/gpu_scene.slang index 30d5182..8e8283d 100644 --- a/shaders/gpu_scene.slang +++ b/shaders/gpu_scene.slang @@ -57,9 +57,9 @@ struct GpuFrameParameters { uint4 drawInfo; // x=visible ID range base; firstInstance is always zero }; [[vk::push_constant]] ConstantBuffer gpuFrame; -[[vk::binding(0,1)]] StructuredBuffer gfxInstances; -[[vk::binding(1,1)]] StructuredBuffer gfxVisibleIds; -[[vk::binding(2,1)]] StructuredBuffer gfxViews; +[[vk::binding(0,2)]] StructuredBuffer gfxInstances; +[[vk::binding(1,2)]] StructuredBuffer gfxVisibleIds; +[[vk::binding(2,2)]] StructuredBuffer gfxViews; // All indirect commands use firstInstance=0. The raw Vulkan index avoids the // BaseInstance read that Slang adds for SV_InstanceID (DrawParameters feature). diff --git a/src/authoring/schema.cpp b/src/authoring/schema.cpp index aa42d60..9b255f7 100644 --- a/src/authoring/schema.cpp +++ b/src/authoring/schema.cpp @@ -156,6 +156,15 @@ void SchemaRegistry::validate_component(const Json& component) const { for (const auto& [id, value] : component["fields"].items()) if (metadata["fields"].contains(id)) validate_field(value, metadata["fields"][id]); + if (type == "faset.light") { + auto effective = default_fields(type); + effective.update(component.at("fields")); + if (effective.at("kind") == "spot") + require(effective.at("inner_angle").get() <= + effective.at("outer_angle").get(), + "validation.light_cone", + "Spotlight inner_angle must not exceed outer_angle"); + } } void SchemaRegistry::add_migration(const std::string& type, int from_version, Json rules) { require(contains(type) && from_version > 0 && from_version < schema(type).value("version", 1) && @@ -252,9 +261,25 @@ SchemaRegistry builtin_schemas() { {{"fov", Json{{"type", "number"}, {"default", 60.0}, {"min", 1.0}, {"max", 179.0}}}, {"near", Json{{"type", "number"}, {"default", 0.1}, {"min", 0.001}}}, {"far", Json{{"type", "number"}, {"default", 1000.0}, {"min", 0.01}}}}); - add("faset.light", "Directional Light", - {{"color", field("color", {1, 1, 1, 1})}, - {"intensity", Json{{"type", "number"}, {"default", 1.0}, {"min", 0.0}}}}); + add("faset.light", "Light", + {{"kind", Json{{"type", "string"}, + {"default", "directional"}, + {"enum", {"directional", "point", "spot"}}}}, + {"enabled", field("boolean", true)}, + {"color", field("color", {1, 1, 1, 1})}, + {"intensity", Json{{"type", "number"}, {"default", 1.0}, {"min", 0.0}}}, + {"range", Json{{"type", "number"}, {"default", 10.0}, {"min", 0.001}}}, + {"inner_angle", Json{{"type", "number"}, + {"default", 0.35}, {"min", 0.0}, {"max", 1.55}, + {"unit", "radians"}}}, + {"outer_angle", Json{{"type", "number"}, + {"default", 0.7}, {"min", 0.001}, {"max", 1.55}, + {"unit", "radians"}}}, + {"casts_shadow", field("boolean", true)}, + {"shadow_priority", Json{{"type", "integer"}, + {"default", 0}, + {"min", std::numeric_limits::min()}, + {"max", std::numeric_limits::max()}}}}); for (int dimension : {2, 3}) { Json vector = dimension == 2 ? Json{0, 0} : Json{0, 0, 0}; Json extents = dimension == 2 ? Json{0.5, 0.5} : Json{0.5, 0.5, 0.5}; 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/player/SceneView.cpp b/src/player/SceneView.cpp index 103f975..87d2063 100644 --- a/src/player/SceneView.cpp +++ b/src/player/SceneView.cpp @@ -61,6 +61,16 @@ render::Vec3 direction(const render::Mat4& m, render::Vec3 p) { return {m[0] * p[0] + m[4] * p[1] + m[8] * p[2], m[1] * p[0] + m[5] * p[1] + m[9] * p[2], m[2] * p[0] + m[6] * p[1] + m[10] * p[2]}; } +render::Vec3 normalized(render::Vec3 value, const std::string& entityId, + std::string_view field) { + const auto length = std::hypot(value[0], value[1], value[2]); + if (!std::isfinite(length) || length < 1e-6f) + throw std::invalid_argument("Light on entity " + entityId + " has invalid " + + std::string(field)); + for (auto& axis : value) + axis /= length; + return value; +} std::pair reference(const std::string& ref) { const auto hash = ref.find('#'); return {ref.substr(0, hash), hash == std::string::npos ? std::string{} : ref.substr(hash + 1)}; @@ -263,8 +273,12 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting const auto id = entity.at("id").get(); if (!byId.emplace(id, &entity).second) throw std::invalid_argument("Duplicate scene ID"); + if (!entity.contains("components") && entity.contains("light")) + out.authored_lights_present = true; for (const auto& component : entity.value("components", Json::array())) { const auto type = component.at("type").get(); + if (type == "faset.light") + out.authored_lights_present = true; if (component.value("version", 1) != 1 && (type == "faset.transform" || type == "faset.sprite" || type == "faset.mesh" || type == "faset.camera" || type == "faset.light")) @@ -301,8 +315,15 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting render::Vec3 cameraUp{0, 1, 0}; bool foundCamera = false; std::vector> sprites; + std::vector directionalLights; for (const auto& entity : entities) { - const auto model = world(world, entity); + const auto id = entity.at("id").get(); + render::Mat4 model; + try { + model = world(world, entity); + } catch (const std::exception& error) { + throw std::invalid_argument("Entity " + id + " transform: " + error.what()); + } if (auto fields = properties(entity, "camera"); !fields.is_null() && !camera.overrideSceneCamera && !foundCamera) { camera.eye = point(model, {0, 0, 0}); @@ -314,8 +335,97 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting foundCamera = true; camera_id = entity.at("id").get(); } - if (auto fields = properties(entity, "light"); !fields.is_null()) - out.light_direction = direction(model, {-0.5f, -1, -0.3f}); + if (auto fields = properties(entity, "light"); !fields.is_null()) { + auto invalid = [&](std::string_view field) -> void { + throw std::invalid_argument("Light on entity " + id + " has invalid " + + std::string(field)); + }; + for (const auto entry : model) + if (!std::isfinite(entry)) + invalid("transform"); + auto number = [&](const char* field, float fallback) { + if (!fields.contains(field)) + return fallback; + const auto& value = fields.at(field); + if (!value.is_number()) + invalid(field); + const auto decimal = value.get(); + if (!std::isfinite(decimal) || + std::abs(decimal) > std::numeric_limits::max()) + invalid(field); + return static_cast(decimal); + }; + auto boolean = [&](const char* field, bool fallback) { + if (!fields.contains(field)) + return fallback; + if (!fields.at(field).is_boolean()) + invalid(field); + return fields.at(field).get(); + }; + const auto enabled = boolean("enabled", true); + if (enabled) { + if (fields.contains("kind") && !fields.at("kind").is_string()) + invalid("kind"); + const auto kind = fields.value("kind", std::string("directional")); + if (kind != "directional" && kind != "point" && kind != "spot") + invalid("kind"); + render::Color color; + try { + color = vec<4>(fields, "color", {1, 1, 1, 1}); + } catch (const std::exception&) { + invalid("color"); + } + for (const auto channel : color) + if (channel < 0) + invalid("color"); + const auto intensity = number("intensity", 1); + if (intensity < 0) + invalid("intensity"); + const auto castsShadow = boolean("casts_shadow", true); + const auto stableId = id; + if (kind == "directional") { + directionalLights.push_back({stableId, + normalized(direction(model, {-0.5f, -1, -0.3f}), id, + "direction"), + color, intensity, castsShadow}); + } else { + render::LocalLight local; + local.kind = kind == "point" ? render::LocalLight::Kind::Point + : render::LocalLight::Kind::Spot; + local.stable_id = stableId; + local.position = point(model, {0, 0, 0}); + for (const auto coordinate : local.position) + if (!std::isfinite(coordinate)) + invalid("position"); + if (local.kind == render::LocalLight::Kind::Spot) + local.direction = normalized(direction(model, {0, 0, -1}), id, + "direction"); + local.color = color; + local.intensity = intensity; + local.range = number("range", 10); + if (local.range <= 0) + invalid("range"); + local.inner_angle = number("inner_angle", 0.35f); + local.outer_angle = number("outer_angle", 0.7f); + if (local.kind == render::LocalLight::Kind::Spot && + (local.inner_angle < 0 || local.inner_angle > local.outer_angle || + local.outer_angle <= 0 || + local.outer_angle >= std::numbers::pi_v / 2)) + invalid("inner_angle/outer_angle"); + local.casts_shadow = castsShadow; + if (fields.contains("shadow_priority")) { + if (!fields.at("shadow_priority").is_number_integer()) + invalid("shadow_priority"); + const auto priority = fields.at("shadow_priority").get(); + if (priority < std::numeric_limits::min() || + priority > std::numeric_limits::max()) + invalid("shadow_priority"); + local.shadow_priority = fields.at("shadow_priority").get(); + } + out.local_lights.push_back(std::move(local)); + } + } + } if (auto fields = properties(entity, "sprite"); !fields.is_null()) { render::Sprite sprite; sprite.layer = fields.value("layer", 0); @@ -372,6 +482,18 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting [](const auto& a, const auto& b) { return a.first < b.first; }); for (auto& pair : sprites) out.sprites.push_back(std::move(pair.second)); + std::sort(directionalLights.begin(), directionalLights.end(), + [](const auto& a, const auto& b) { return a.stable_id < b.stable_id; }); + std::sort(out.local_lights.begin(), out.local_lights.end(), + [](const auto& a, const auto& b) { return a.stable_id < b.stable_id; }); + if (!directionalLights.empty()) { + out.sun = directionalLights.front(); + out.light_direction = out.sun->direction; + if (directionalLights.size() > 1) + impl_->messages.push_back("warning: multiple enabled directional lights; using " + + out.sun->stable_id + " and ignoring " + + std::to_string(directionalLights.size() - 1) + " others"); + } if (dimension == 2) { const float height = camera.orthographicHeight; if (!std::isfinite(height) || height <= 0) @@ -408,8 +530,10 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting out.projection = render::perspective( camera.verticalFovDegrees * std::numbers::pi_v / 180, aspect, camera.nearPlane, camera.farPlane); - out.view_projection = render::multiply( - out.projection, render::look_at(camera.eye, camera.target, cameraUp)); + const auto view = render::look_at(camera.eye, camera.target, cameraUp); + out.view_projection = render::multiply(out.projection, view); + out.camera_frustum = render::CameraFrustum{view, out.projection, camera.nearPlane, + camera.farPlane, true}; } std::sort(impl_->messages.begin(), impl_->messages.end()); impl_->messages.erase(std::unique(impl_->messages.begin(), impl_->messages.end()), diff --git a/src/render/lighting.cpp b/src/render/lighting.cpp new file mode 100644 index 0000000..acaa41d --- /dev/null +++ b/src/render/lighting.cpp @@ -0,0 +1,394 @@ +#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)); + // 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 + .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)); + 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 0a1f564..0745172 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -6,14 +6,17 @@ #include #include #include +#include #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -72,6 +75,40 @@ struct ScenePush { std::array draw_info; }; static_assert(sizeof(ScenePush) == 112); +struct LightingHeaderGpu { + std::array counts{}; + std::array sun_direction_intensity{}; + std::array sun_color{}; + std::array camera_forward_shadow_distance{}; + std::array cascade_splits{}; +}; +struct LocalLightGpu { + std::array position_range{}; + std::array direction_cos_outer{}; + std::array color_intensity{}; + std::array cone_type_shadow_view{}; + std::array reserved{}; +}; +struct ShadowViewGpu { + Mat4 view_projection{identity}; + std::array tile_scale_offset{}; + std::array guarded_clamp{}; + std::array bias_flags{}; +}; +static_assert(sizeof(LightingHeaderGpu) == 80 && + offsetof(LightingHeaderGpu, sun_direction_intensity) == 16 && + offsetof(LightingHeaderGpu, sun_color) == 32 && + offsetof(LightingHeaderGpu, camera_forward_shadow_distance) == 48 && + offsetof(LightingHeaderGpu, cascade_splits) == 64); +static_assert(sizeof(LocalLightGpu) == 80 && + offsetof(LocalLightGpu, direction_cos_outer) == 16 && + offsetof(LocalLightGpu, color_intensity) == 32 && + offsetof(LocalLightGpu, cone_type_shadow_view) == 48 && + offsetof(LocalLightGpu, reserved) == 64); +static_assert(sizeof(ShadowViewGpu) == 112 && + offsetof(ShadowViewGpu, tile_scale_offset) == 64 && + offsetof(ShadowViewGpu, guarded_clamp) == 80 && + offsetof(ShadowViewGpu, bias_flags) == 96); std::array point(const Mat4& m, std::array p) { std::array o{}; for (int r = 0; r < 4; ++r) @@ -164,7 +201,7 @@ struct SceneResources { std::array previous_viewport{}; std::string previous_view_id; }; -constexpr std::uint32_t shadow_size = 1024; +constexpr std::uint32_t timestamp_capacity = 24; } // namespace struct Renderer::Impl { RendererConfig config; @@ -194,8 +231,10 @@ struct Renderer::Impl { VkExtent2D swap_extent{}; std::vector swap_images; std::vector swap_layouts; - Image color, depth, shadow; + 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; InstanceTracker instance_tracker; std::unordered_map previous_lods; @@ -212,6 +251,9 @@ struct Renderer::Impl { std::unordered_map opacity_cache; VkDescriptorSetLayout descriptor_layout{}; VkDescriptorPool descriptor_pool{}; + VkDescriptorSetLayout lighting_layout{}; + VkDescriptorPool lighting_pool{}; + VkDescriptorSet lighting_set{}; VkSampler shadow_sampler{}, color_sampler{}; VkPipelineLayout pipeline_layout{}; VkPipeline pipeline{}, ui_pipeline{}, shadow_pipeline{}, sprite_pipeline{}; @@ -316,9 +358,13 @@ struct Renderer::Impl { destroy(scene.hzb[1]); destroy(vertices); destroy(readback); + destroy(lighting_header); + destroy(lighting_locals); + destroy(lighting_views); destroy(color); destroy(depth); destroy(shadow); + destroy(local_shadow); if (device) { destroy_scene_interfaces(); if (pipeline) @@ -333,8 +379,12 @@ struct Renderer::Impl { vkDestroyPipelineLayout(device, pipeline_layout, nullptr); if (descriptor_pool) vkDestroyDescriptorPool(device, descriptor_pool, nullptr); + if (lighting_pool) + vkDestroyDescriptorPool(device, lighting_pool, nullptr); if (descriptor_layout) vkDestroyDescriptorSetLayout(device, descriptor_layout, nullptr); + if (lighting_layout) + vkDestroyDescriptorSetLayout(device, lighting_layout, nullptr); if (shadow_sampler) vkDestroySampler(device, shadow_sampler, nullptr); if (color_sampler) @@ -721,14 +771,39 @@ struct Renderer::Impl { VkQueryPoolCreateInfo query{}; query.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO; query.queryType = VK_QUERY_TYPE_TIMESTAMP; - query.queryCount = 12; + query.queryCount = timestamp_capacity; check(vkCreateQueryPool(device, &query, nullptr, ×tamp_pool), "Create GPU timestamp queries"); } - shadow = - make_image(shadow_size, shadow_size, VK_FORMAT_D32_SFLOAT, - VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, - VK_IMAGE_ASPECT_DEPTH_BIT); + const auto shadow_usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | + VK_IMAGE_USAGE_SAMPLED_BIT; + for (const auto size : {2048u, 1024u}) { + if (size > max_image_dimension) + continue; + try { + shadow = make_image(size, size, VK_FORMAT_D32_SFLOAT, shadow_usage, + VK_IMAGE_ASPECT_DEPTH_BIT); + sun_shadow_size = size; + break; + } catch (const std::exception&) { + // Optional atlas allocation may fail; try the bounded half-size profile. + } + } + if (!shadow.handle) + shadow = make_image(1, 1, VK_FORMAT_D32_SFLOAT, shadow_usage, + VK_IMAGE_ASPECT_DEPTH_BIT); + 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(); @@ -889,6 +964,31 @@ struct Renderer::Impl { pi.pPoolSizes = sizes; check(vkCreateDescriptorPool(device, &pi, nullptr, &descriptor_pool), "Create descriptor pool"); + std::array lighting_bindings{}; + for (std::uint32_t i = 0; i < lighting_bindings.size(); ++i) + lighting_bindings[i] = {i, + i == 3 ? VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE + : VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, + 1, VK_SHADER_STAGE_FRAGMENT_BIT, nullptr}; + li.bindingCount = static_cast(lighting_bindings.size()); + li.pBindings = lighting_bindings.data(); + check(vkCreateDescriptorSetLayout(device, &li, nullptr, &lighting_layout), + "Create lighting descriptor layout"); + VkDescriptorPoolSize lighting_sizes[] = { + {VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 3}, {VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, 1}}; + pi.flags = 0; + pi.maxSets = 1; + pi.poolSizeCount = 2; + pi.pPoolSizes = lighting_sizes; + check(vkCreateDescriptorPool(device, &pi, nullptr, &lighting_pool), + "Create lighting descriptor pool"); + VkDescriptorSetAllocateInfo lighting_allocation{}; + lighting_allocation.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + lighting_allocation.descriptorPool = lighting_pool; + lighting_allocation.descriptorSetCount = 1; + lighting_allocation.pSetLayouts = &lighting_layout; + check(vkAllocateDescriptorSets(device, &lighting_allocation, &lighting_set), + "Allocate lighting descriptors"); VkSamplerCreateInfo si{}; si.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; si.magFilter = si.minFilter = VK_FILTER_NEAREST; @@ -1009,8 +1109,9 @@ struct Renderer::Impl { sizeof(Push)}; VkPipelineLayoutCreateInfo li{}; li.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; - li.setLayoutCount = 1; - li.pSetLayouts = &descriptor_layout; + const std::array set_layouts{descriptor_layout, lighting_layout}; + li.setLayoutCount = static_cast(set_layouts.size()); + li.pSetLayouts = set_layouts.data(); li.pushConstantRangeCount = 1; li.pPushConstantRanges = &push; check(vkCreatePipelineLayout(device, &li, nullptr, &pipeline_layout), @@ -1220,14 +1321,14 @@ struct Renderer::Impl { layout.pBindings = hzb.data(); check(vkCreateDescriptorSetLayout(device, &layout, nullptr, &scene.hzb_layout), "Create HZB descriptor layout"); - const std::array scene_layouts{descriptor_layout, - scene.graphics_layout}; + const std::array scene_layouts{ + descriptor_layout, lighting_layout, scene.graphics_layout}; VkPushConstantRange graphics_push{VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(ScenePush)}; VkPipelineLayoutCreateInfo pipeline_info{}; pipeline_info.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; - pipeline_info.setLayoutCount = 2; + pipeline_info.setLayoutCount = static_cast(scene_layouts.size()); pipeline_info.pSetLayouts = scene_layouts.data(); pipeline_info.pushConstantRangeCount = 1; pipeline_info.pPushConstantRanges = &graphics_push; @@ -1533,6 +1634,30 @@ struct Renderer::Impl { VkBufferUsageFlags usage = 0) { upload_scene_buffer(buffer, values.data(), values.size() * sizeof(T), usage); } + void update_lighting_descriptors() { + const std::array buffers{{ + {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, + 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) { + writes[i].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[i].dstSet = lighting_set; + writes[i].dstBinding = i; + writes[i].descriptorCount = 1; + writes[i].descriptorType = i == 3 ? VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE + : VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + if (i == 3) + writes[i].pImageInfo = &atlas; + else + writes[i].pBufferInfo = &buffers[i]; + } + vkUpdateDescriptorSets(device, static_cast(writes.size()), + writes.data(), 0, nullptr); + } void update_scene_descriptors(bool occlusion) { auto write_buffers = [&](VkDescriptorSet set, std::span buffers, std::uint32_t first_binding) { @@ -1625,6 +1750,22 @@ 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.requested_sun_cascades = statistics.effective_sun_cascades = + statistics.sun_shadow_caster_draws = 0; + statistics.sun_shadow_atlas_bytes = sun_shadow_size ? shadow.allocation_size : 0; + statistics.gpu_sun_shadow_ms = 0; + statistics.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; @@ -1715,6 +1856,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{}; @@ -1724,10 +1867,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; @@ -1858,12 +2008,63 @@ struct Renderer::Impl { gpu_frame.textures.push_back(bin.texture); } gpu_frame.candidate_count = static_cast(gpu_frame.candidates.size()); + ShadowBudget shadow_budget; + shadow_budget.sun_atlas_size = sun_shadow_size; + shadow_budget.sun_atlas_available = sun_shadow_size != 0; + 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(), + [](const ShadowView& view) { + return view.valid && !view.caster_indices.empty(); + }); + statistics.requested_sun_cascades = shadow_plan.requested_sun_cascades; + statistics.effective_sun_cascades = sun_raster + ? shadow_plan.effective_sun_cascades : 0; + if (sun_raster) + for (const auto& view : shadow_plan.sun_views) + if (view.valid) + statistics.sun_shadow_caster_draws += + static_cast(view.caster_indices.size()); + 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, shadow_batches, - sprite_batches, ui_batches; + std::vector scene_batches, transparent_batches, sprite_batches, ui_batches; for (const auto& selected : selected_draws) { const auto& item = *selected.source; - if (selected.gpu && !item.cast_shadow) + if (selected.gpu) continue; auto first = data.size(); const auto& mesh = *selected.mesh; @@ -1886,16 +2087,12 @@ struct Renderer::Impl { continue; Batch batch{static_cast(first), count, item.texture ? item.texture.get() : white.get()}; - if (item.cast_shadow) - shadow_batches.push_back(batch); - if (!selected.gpu) { - if (outside(data, first)) - ++statistics.culled_meshes; - else if (gpu_active && !selected.opaque) - transparent_batches.push_back(batch); - else - scene_batches.push_back(batch); - } + if (outside(data, first)) + ++statistics.culled_meshes; + else if (gpu_active && !selected.opaque) + transparent_batches.push_back(batch); + else + scene_batches.push_back(batch); } struct OrderedSprite { const Sprite* sprite; @@ -1972,6 +2169,45 @@ struct Renderer::Impl { triangles.texture ? triangles.texture.get() : white.get(), triangles.clip_rect}); } + std::vector shadow_batch_by_source(snapshot.draws.size()); + if (sun_raster || local_raster) { + std::vector required(snapshot.draws.size()); + for (const auto& view : shadow_plan.sun_views) + 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) { + if (!required[source]) + continue; + const auto& item = snapshot.draws[source]; + if (!item.mesh) + continue; + const auto first = data.size(); + const auto& mesh = *item.mesh; // Source LOD 0, independent of camera/P2 LOD. + auto emit = [&](std::uint32_t index) { + if (index >= mesh.vertices.size()) + throw std::out_of_range("Shadow mesh index outside vertex range"); + data.push_back(gpu_vertex(mesh.vertices[index], item, + snapshot.view_projection)); + }; + if (mesh.indices.empty()) + for (std::uint32_t i = 0; i < mesh.vertices.size(); ++i) + emit(i); + else + for (const auto index : mesh.indices) + emit(index); + const auto count = data.size() - first; + if (count % 3 || first > UINT32_MAX || count > UINT32_MAX) + throw std::invalid_argument("Shadow mesh must fit complete triangles"); + shadow_batch_by_source[source] = + {static_cast(first), static_cast(count), + white.get()}; + } + } statistics.vertices = static_cast(data.size() + gpu_frame.vertices.size()); auto byte_count = std::max(sizeof(GpuVertex), data.size() * sizeof(GpuVertex)); if (vertices.size < byte_count) { @@ -2017,19 +2253,114 @@ struct Renderer::Impl { VK_BUFFER_USAGE_TRANSFER_DST_BIT); update_scene_descriptors(occlusion); } - Vec3 direction = snapshot.light_direction; + std::optional sun = snapshot.sun; + if (!sun && !snapshot.authored_lights_present && snapshot.local_lights.empty()) + sun = SunLight{"legacy-sun", snapshot.light_direction, {1, 1, 1, 1}, 1, true}; + Vec3 direction = sun ? sun->direction : snapshot.light_direction; float length = std::sqrt(direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2]); - if (length < 1e-5f) { + if (!std::isfinite(length) || length < 1e-5f) { + if (sun && sun->stable_id != "legacy-sun") + throw std::invalid_argument("Authored sun direction must be finite and nonzero"); direction = {-.5f, -1, -.3f}; length = std::sqrt(1.34f); } for (auto& v : direction) v /= length; + LightingHeaderGpu lighting{}; + lighting.counts[1] = sun ? 1u : 0u; + lighting.counts[2] = sun_raster ? 1u : 0u; + lighting.sun_direction_intensity = {direction[0], direction[1], direction[2], + sun ? sun->intensity : 0}; + lighting.sun_color = sun ? sun->color : Color{0, 0, 0, 1}; + if (sun && (!std::isfinite(sun->intensity) || sun->intensity < 0 || + std::any_of(sun->color.begin(), sun->color.end(), + [](float v) { return !std::isfinite(v) || v < 0; }))) + throw std::invalid_argument("Authored sun radiance must be finite and nonnegative"); + lighting.camera_forward_shadow_distance = {0, 0, -1, 80}; + if (snapshot.camera_frustum) { + const auto& view = snapshot.camera_frustum->view; + lighting.camera_forward_shadow_distance = {-view[2], -view[6], -view[10], 80}; + } + lighting.counts[3] = static_cast(shadow_plan.sun_views.size()); + std::vector gpu_shadow_views; + gpu_shadow_views.reserve(std::max(1, shadow_plan.sun_views.size())); + for (std::size_t i = 0; i < shadow_plan.sun_views.size(); ++i) { + const auto& view = shadow_plan.sun_views[i]; + ShadowViewGpu gpu{}; + gpu.view_projection = view.view_projection; + gpu.tile_scale_offset = view.atlas_scale_offset; + gpu.guarded_clamp = view.guarded_clamp; + gpu.bias_flags = {.0008f, .003f, + sun_shadow_size ? 1.f / float(sun_shadow_size) : 0.f, + sun_raster && view.valid ? 1.f : 0.f}; + gpu_shadow_views.push_back(gpu); + if (i < lighting.cascade_splits.size()) + lighting.cascade_splits[i] = view.split_far; + } + 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()); + 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]); + if (!std::isfinite(spot_length) || spot_length < 1e-6f) { + if (local.kind == LocalLight::Kind::Spot) + throw std::invalid_argument("Spotlight direction must be finite and nonzero"); + spot_direction = {0, 0, -1}; + spot_length = 1; + } + for (auto& axis : spot_direction) + axis /= spot_length; + LocalLightGpu gpu{}; + gpu.position_range = {local.position[0], local.position[1], local.position[2], + local.range}; + const bool spot = local.kind == LocalLight::Kind::Spot; + gpu.direction_cos_outer = {spot_direction[0], spot_direction[1], spot_direction[2], + spot ? std::cos(local.outer_angle) : 0.f}; + gpu.color_intensity = {local.color[0], local.color[1], local.color[2], + 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()); + statistics.submitted_local_lights = lighting.counts[0]; + if (gpu_lights.empty()) + gpu_lights.push_back({}); // Descriptors always point at a full initialized record. + if (gpu_shadow_views.empty()) + gpu_shadow_views.push_back({}); // Always bind an initialized record. + upload_scene_buffer(lighting_header, &lighting, sizeof(lighting), 0); + upload_scene_vector(lighting_locals, gpu_lights); + upload_scene_vector(lighting_views, gpu_shadow_views); + update_lighting_descriptors(); Vec3 light_eye{-direction[0] * 30, -direction[1] * 30, -direction[2] * 30}; Vec3 light_up = std::abs(direction[1]) > .98f ? Vec3{0, 0, 1} : Vec3{0, 1, 0}; - Push push{multiply(orthographic(-20, 20, -20, 20, .1f, 80), - look_at(light_eye, {0, 0, 0}, light_up)), + Push push{sun_raster && !shadow_plan.sun_views.empty() + ? shadow_plan.sun_views.front().view_projection + : multiply(orthographic(-20, 20, -20, 20, .1f, 80), + look_at(light_eye, {0, 0, 0}, light_up)), {direction[0], direction[1], direction[2], 0}, {snapshot.eye[0], snapshot.eye[1], snapshot.eye[2], 1}}; std::optional swap_index; @@ -2053,7 +2384,7 @@ struct Renderer::Impl { std::uint32_t timestamp_cursor = 0; std::vector timestamp_labels; if (timestamp_pool) { - vkCmdResetQueryPool(command, timestamp_pool, 0, 12); + vkCmdResetQueryPool(command, timestamp_pool, 0, timestamp_capacity); vkCmdWriteTimestamp2(command, VK_PIPELINE_STAGE_2_TOP_OF_PIPE_BIT, timestamp_pool, timestamp_cursor++); } @@ -2078,6 +2409,12 @@ struct Renderer::Impl { vkCmdSetViewport(command, 0, 1, &viewport); vkCmdSetScissor(command, 0, 1, &scissor); }; + auto bind_material = [&](VkDescriptorSet material) { + const std::array sets{material, lighting_set}; + vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout, + 0, static_cast(sets.size()), sets.data(), + 0, nullptr); + }; auto draw_transparent = [&] { if (transparent_batches.empty()) return; @@ -2088,8 +2425,7 @@ struct Renderer::Impl { 0, sizeof(push), &push); for (auto batch : transparent_batches) { auto descriptor = textures.at(batch.texture).descriptor; - vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, - pipeline_layout, 0, 1, &descriptor, 0, nullptr); + bind_material(descriptor); vkCmdDraw(command, batch.count, 1, batch.first, 0); ++statistics.draw_calls; } @@ -2102,8 +2438,7 @@ struct Renderer::Impl { sizeof(push), &push); for (auto batch : sprite_batches) { auto descriptor = textures.at(batch.texture).descriptor; - vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout, - 0, 1, &descriptor, 0, nullptr); + bind_material(descriptor); vkCmdDraw(command, batch.count, 1, batch.first, 0); ++statistics.draw_calls; } @@ -2125,8 +2460,7 @@ struct Renderer::Impl { continue; vkCmdSetScissor(command, 0, 1, &scissor); auto descriptor = textures.at(batch.texture).descriptor; - vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout, - 0, 1, &descriptor, 0, nullptr); + bind_material(descriptor); vkCmdDraw(command, batch.count, 1, batch.first, 0); ++statistics.draw_calls; } @@ -2181,7 +2515,7 @@ struct Renderer::Impl { } } end{*this}; callback(); - if (timestamp_pool && timestamp_cursor < 12) { + if (timestamp_pool && timestamp_cursor < timestamp_capacity) { vkCmdWriteTimestamp2(command, VK_PIPELINE_STAGE_2_BOTTOM_OF_PIPE_BIT, timestamp_pool, timestamp_cursor++); @@ -2189,35 +2523,79 @@ struct Renderer::Impl { } }); }; - add_pass("ShadowMap", {}, {"shadow"}, [&] { - transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL, - VK_IMAGE_ASPECT_DEPTH_BIT); - VkRenderingAttachmentInfo attachment{}; - attachment.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO; - attachment.imageView = shadow.view; - attachment.imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL; - attachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; - attachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE; - attachment.clearValue.depthStencil = {1, 0}; - VkRenderingInfo rendering{}; - rendering.sType = VK_STRUCTURE_TYPE_RENDERING_INFO; - rendering.renderArea = {{0, 0}, {shadow_size, shadow_size}}; - rendering.layerCount = 1; - rendering.pDepthAttachment = &attachment; - vkCmdBeginRendering(command, &rendering); - set_viewport(shadow_size, shadow_size); - vkCmdBindPipeline(command, VK_PIPELINE_BIND_POINT_GRAPHICS, shadow_pipeline); - vkCmdPushConstants(command, pipeline_layout, - VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, - sizeof(push), &push); - for (auto batch : shadow_batches) { - vkCmdDraw(command, batch.count, 1, batch.first, 0); - ++statistics.draw_calls; - } - vkCmdEndRendering(command); - transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL, - VK_IMAGE_ASPECT_DEPTH_BIT); - }); + 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 = 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}, {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 : views) { + if (!view.valid || view.caster_indices.empty()) + continue; + const auto guard = (view.tile_size - view.usable_size) / 2; + const auto x = view.tile_origin_x + guard; + const auto y = view.tile_origin_y + guard; + const VkViewport viewport{float(x), float(y), float(view.usable_size), + float(view.usable_size), 0, 1}; + const VkRect2D scissor{{static_cast(x), + static_cast(y)}, + {view.usable_size, view.usable_size}}; + vkCmdSetViewport(command, 0, 1, &viewport); + vkCmdSetScissor(command, 0, 1, &scissor); + auto view_push = push; + view_push.light_view_projection = view.view_projection; + vkCmdPushConstants(command, pipeline_layout, + VK_SHADER_STAGE_VERTEX_BIT | + VK_SHADER_STAGE_FRAGMENT_BIT, + 0, sizeof(view_push), &view_push); + for (const auto source : view.caster_indices) { + const auto& batch = shadow_batch_by_source.at(source); + if (!batch.count) + continue; + vkCmdDraw(command, batch.count, 1, batch.first, 0); + ++statistics.draw_calls; + } + } + vkCmdEndRendering(command); + transition(command, 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"}, [&] { + // A bound descriptor still needs a matching image layout, even when + // every graphics shader branch treats its shadow as unshadowed. + transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL, + VK_IMAGE_ASPECT_DEPTH_BIT); + }); + if (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"}, [&] { @@ -2277,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); @@ -2313,8 +2692,7 @@ struct Renderer::Impl { vkCmdPushConstants(command, pipeline_layout, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(push), &push); - vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout, 0, 1, - &white_descriptor, 0, nullptr); + bind_material(white_descriptor); if (gpu_active && !gpu_frame.bins.empty()) { VkDeviceSize scene_offset{}; vkCmdBindVertexBuffers(command, 0, 1, &scene.vertices.handle, &scene_offset); @@ -2322,7 +2700,7 @@ struct Renderer::Impl { scene.graphics_pipeline); for (std::uint32_t bin = 0; bin < gpu_frame.bins.size(); ++bin) { auto descriptor = textures.at(gpu_frame.textures[bin]).descriptor; - const std::array sets{descriptor, + const std::array sets{descriptor, lighting_set, scene.graphics_main}; vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, scene.graphics_pipeline_layout, 0, sets.size(), @@ -2346,8 +2724,7 @@ struct Renderer::Impl { } for (auto batch : scene_batches) { auto descriptor = textures.at(batch.texture).descriptor; - vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout, - 0, 1, &descriptor, 0, nullptr); + bind_material(descriptor); vkCmdDraw(command, batch.count, 1, batch.first, 0); ++statistics.draw_calls; } @@ -2446,7 +2823,7 @@ struct Renderer::Impl { scene.graphics_pipeline); for (std::uint32_t bin = 0; bin < gpu_frame.bins.size(); ++bin) { auto descriptor = textures.at(gpu_frame.textures[bin]).descriptor; - const std::array sets{descriptor, + const std::array sets{descriptor, lighting_set, scene.graphics_post}; vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, scene.graphics_pipeline_layout, 0, @@ -2526,7 +2903,7 @@ struct Renderer::Impl { graph.execute(); submit(swap_index.has_value()); if (timestamp_pool) { - std::array stamps{}; + std::array stamps{}; check(vkGetQueryPoolResults(device, timestamp_pool, 0, timestamp_cursor, timestamp_cursor * sizeof(std::uint64_t), stamps.data(), sizeof(std::uint64_t), @@ -2546,6 +2923,8 @@ struct Renderer::Impl { const auto elapsed = milliseconds(stamps[i], stamps[i + 1]); const auto& label = timestamp_labels[i]; if (label == "MainCull") statistics.gpu_main_cull_ms = elapsed; + else if (label == "SunShadowAtlas") statistics.gpu_sun_shadow_ms = elapsed; + else if (label == "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; @@ -2618,7 +2997,11 @@ struct Renderer::Impl { ++statistics.frame; statistics.gpu_allocated_bytes = vertices.allocation_size + readback.allocation_size + color.allocation_size + depth.allocation_size + - shadow.allocation_size; + shadow.allocation_size + + local_shadow.allocation_size + + lighting_header.allocation_size + + lighting_locals.allocation_size + + lighting_views.allocation_size; statistics.texture_count = static_cast(textures.size()); for (const auto& [_, texture] : textures) statistics.gpu_allocated_bytes += texture.image.allocation_size; diff --git a/src/render/shader_contract.cpp b/src/render/shader_contract.cpp index 3ccb943..7c2e6cc 100644 --- a/src/render/shader_contract.cpp +++ b/src/render/shader_contract.cpp @@ -34,13 +34,21 @@ void validate_layout(const Json& layout, std::string_view entry) { const bool fragment = entry == "fragmentMain"; require(layout.at("stage") == (fragment ? "fragment" : "vertex"), "shader stage changed"); const auto& descriptors = layout.at("descriptors"); - require(descriptors.is_array() && descriptors.size() == 4, "descriptor count changed"); + require(descriptors.is_array() && descriptors.size() == 8, "descriptor count changed"); for (std::size_t i = 0; i < descriptors.size(); ++i) { const auto& binding = descriptors[i]; - require(binding.at("set") == 0 && binding.at("binding") == i && binding.at("count") == 1, + const auto set = i < 4 ? 0 : 1; + const auto slot = i % 4; + require(binding.at("set") == set && binding.at("binding") == slot && + binding.at("count") == 1, "descriptor set, binding or array count changed"); - require(binding.at("type") == (i % 2 ? "sampler" : "sampled_image_2d"), + const auto* expected_type = set == 0 ? (slot % 2 ? "sampler" : "sampled_image_2d") + : slot == 3 ? "sampled_image_2d" : "storage_buffer"; + require(binding.at("type") == expected_type, "descriptor type changed"); + if (set == 1 && slot < 3) + require(binding.at("element_stride") == (slot == 2 ? 112 : 80), + "lighting storage record stride changed"); require(fragment || !binding.at("used").get(), "vertex texture bindings are unsupported"); } @@ -98,7 +106,7 @@ void validate_gpu_layout(const Json& layout, std::string_view entry) { const std::array graphics_strides{224, 4, 208}; for (std::size_t i = 0; i < expected_count; ++i) { const auto& binding = descriptors[i]; - require(binding.at("set") == (graphics ? 1 : 0) && binding.at("binding") == i && + require(binding.at("set") == (graphics ? 2 : 0) && binding.at("binding") == i && binding.at("count") == 1, "GPU descriptor set, binding or count changed"); const int stride = graphics ? graphics_strides[i] : hzb ? 0 : compute_strides[i]; diff --git a/tests/authoring_tests.cpp b/tests/authoring_tests.cpp index 05b5e6d..024585a 100644 --- a/tests/authoring_tests.cpp +++ b/tests/authoring_tests.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #define CHECK(x) \ @@ -25,6 +26,32 @@ int main() { const auto root = std::filesystem::temp_directory_path() / ("faset-authoring-" + new_id()); try { auto schemas = builtin_schemas(); + const auto light = schemas.schema("faset.light"); + CHECK(light["version"] == 1); + const auto defaults = schemas.default_fields("faset.light"); + CHECK(defaults["kind"] == "directional"); + CHECK(defaults["enabled"] == true); + CHECK(defaults["range"] == 10.0); + CHECK(defaults["inner_angle"] < defaults["outer_angle"]); + CHECK(defaults["casts_shadow"] == true); + CHECK(defaults["shadow_priority"] == 0); + auto light_component = Json{{"type", "faset.light"}, {"version", 1}, {"fields", defaults}}; + schemas.validate_component(light_component); + light_component["fields"]["kind"] = "area"; + fails([&] { schemas.validate_component(light_component); }, "validation.enum"); + light_component["fields"]["kind"] = "point"; + light_component["fields"]["range"] = 0; + fails([&] { schemas.validate_component(light_component); }, "validation.minimum"); + light_component["fields"]["range"] = 10; + light_component["fields"]["intensity"] = -1; + fails([&] { schemas.validate_component(light_component); }, "validation.minimum"); + light_component["fields"] = {{"kind", "spot"}, {"inner_angle", 0.9}}; + fails([&] { schemas.validate_component(light_component); }, "validation.light_cone"); + light_component["fields"] = {{"kind", "spot"}, + {"inner_angle", 0.2}, + {"outer_angle", 0.5}, + {"shadow_priority", std::int64_t{2147483648}}}; + fails([&] { schemas.validate_component(light_component); }, "validation.maximum"); AuthoringService service(root, schemas); auto created = service.create("Courtyard", 3); const std::string id = created["id"]; 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_gpu_shader_contract_tests.cpp b/tests/render_gpu_shader_contract_tests.cpp index 423dc93..82aabfc 100644 --- a/tests/render_gpu_shader_contract_tests.cpp +++ b/tests/render_gpu_shader_contract_tests.cpp @@ -59,6 +59,15 @@ int main() { faset::atomic_write_json(reflection_file, metadata); must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); }, "A consistently rehashed but incompatible GPU record stride must be rejected"); + faset::atomic_write_json(reflection_file, + faset::read_json(original / "gpuPostCullMain.reflection.json")); + reflection_file = temporary / "gpuVertexMain.reflection.json"; + metadata = faset::read_json(original / "gpuVertexMain.reflection.json"); + metadata["layout"]["descriptors"][0]["set"] = 1; + metadata["layout_fingerprint"] = faset::sha256(metadata["layout"].dump()); + faset::atomic_write_json(reflection_file, metadata); + must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); }, + "GPU graphics scene buffers must stay in descriptor set two"); fs::remove(temporary / "gpuHzbMain.spv"); must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); }, "Missing P2 entry must be rejected"); diff --git a/tests/render_lighting_gpu_tests.cpp b/tests/render_lighting_gpu_tests.cpp new file mode 100644 index 0000000..99728d3 --- /dev/null +++ b/tests/render_lighting_gpu_tests.cpp @@ -0,0 +1,305 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace faset::render; +namespace { +void require(bool condition, const std::string& message) { + if (!condition) + throw std::runtime_error(message); +} +struct Frame { + std::vector pixels; + FrameStats stats; +}; +Frame capture(Renderer& renderer, const Snapshot& scene) { + renderer.render(scene); + return {renderer.pixels(), renderer.stats()}; +} +Renderer make_renderer(VisibilityMode mode) { + RendererConfig config; + config.width = 320; + config.height = 240; + config.headless = true; + config.validation = true; + config.visibility_mode = mode; + config.visibility_diagnostics = true; + return Renderer(config); +} +void compare_frames(const Frame& direct, const Frame& gpu) { + require(direct.pixels.size() == gpu.pixels.size(), "Lighting image dimensions match"); + std::uint64_t error{}; + std::size_t bad{}; + for (std::size_t i = 0; i < direct.pixels.size(); i += 4) { + int worst{}; + for (int channel = 0; channel < 3; ++channel) { + const int difference = std::abs(int(direct.pixels[i + channel]) - + int(gpu.pixels[i + channel])); + error += difference; + worst = std::max(worst, difference); + } + bad += worst > 16; + } + const auto count = direct.pixels.size() / 4; + require(bad <= std::max(24, count / 200) && + double(error) / double(count * 3) <= 2.0, + "Direct and GPU sun lighting images agree (bad=" + std::to_string(bad) + + ", mean=" + std::to_string(double(error) / double(count * 3)) + ")"); +} +Snapshot scene(bool caster) { + Snapshot result; + result.view_id = "p3-offscreen-sun"; + result.eye = {0, 5, 8}; + const auto view = look_at(result.eye, {0, -1, 0}); + const auto projection = orthographic(-2.5f, 2.5f, -2, 2, .1f, 50); + result.projection = projection; + result.view_projection = multiply(projection, view); + result.camera_frustum = CameraFrustum{view, projection, .1f, 50.f, false}; + DrawItem receiver; + receiver.mesh = cube_mesh(); + receiver.model = transform({0, -1, 0}, {}, {8, .1f, 8}); + receiver.color = {.8f, .8f, .8f, 1}; + receiver.instance_key = "receiver"; + result.draws.push_back(receiver); + if (caster) { + DrawItem shadow_caster; + shadow_caster.mesh = cube_mesh(); + shadow_caster.model = transform({3, 1, 0}, {}, {.8f, .8f, .8f}); + shadow_caster.color = {.2f, .2f, .8f, 1}; + shadow_caster.instance_key = "offscreen-caster"; + result.draws.push_back(shadow_caster); + } + return result; +} +void sun() { + auto direct = make_renderer(VisibilityMode::Direct); + auto gpu = make_renderer(VisibilityMode::GpuFrustum); + auto occlusion = make_renderer(VisibilityMode::GpuOcclusion); + auto with_caster = scene(true); + const auto direct_frame = capture(direct, with_caster); + const auto gpu_frame = capture(gpu, with_caster); + const auto occlusion_frame = capture(occlusion, with_caster); + require(direct_frame.stats.effective_sun_cascades == 4 && + gpu_frame.stats.effective_sun_cascades == 4 && + occlusion_frame.stats.effective_sun_cascades == 4, + "Explicit 3D camera renders four sun cascades on every graphics path"); + require(direct_frame.stats.requested_sun_cascades == 4 && + direct_frame.stats.sun_shadow_caster_draws > 0 && + direct_frame.stats.sun_shadow_caster_draws <= 4096 && + direct_frame.stats.sun_shadow_atlas_bytes > 0 && + direct_frame.stats.gpu_sun_shadow_ms > 0, + "Sun cascade stats describe bounded actual raster work and GPU time"); + require(gpu_frame.stats.gpu_frustum_rejected > 0, + "Offscreen caster fixture is outside GPU camera frustum"); + require(direct_frame.stats.validation_errors == 0 && + gpu_frame.stats.validation_errors == 0 && + occlusion_frame.stats.validation_errors == 0, + "Sun atlas rendering reports no Vulkan validation errors"); + compare_frames(direct_frame, gpu_frame); + compare_frames(direct_frame, occlusion_frame); + auto without = scene(false); + const auto no_caster = capture(direct, without); + std::size_t darkened{}; + for (std::size_t i = 0; i < direct_frame.pixels.size(); i += 4) + darkened += int(no_caster.pixels[i]) > int(direct_frame.pixels[i]) + 12; + require(darkened > 20, + "Offscreen source-LOD0 caster darkens visible receiver (count=" + + std::to_string(darkened) + ")"); + auto coarser = with_caster; + auto degenerate_lod = std::make_shared(*cube_mesh()); + for (auto& vertex : degenerate_lod->vertices) + vertex.position = {0, 0, 0}; + coarser.draws.back().lod_meshes.push_back(degenerate_lod); + const auto source_lod_shadow = capture(gpu, coarser); + std::size_t lod_darkened{}; + for (std::size_t i = 0; i < source_lod_shadow.pixels.size(); i += 4) + lod_darkened += int(no_caster.pixels[i]) > + int(source_lod_shadow.pixels[i]) + 12; + require(source_lod_shadow.stats.lod_counts[1] > 0 && lod_darkened > 20, + "Shadow raster uses source LOD0 even when camera chooses a coarse LOD"); + auto no_shadow = with_caster; + no_shadow.authored_lights_present = true; + no_shadow.sun = SunLight{"sun", no_shadow.light_direction, {1, 1, 1, 1}, 1, false}; + const auto disabled = capture(direct, no_shadow); + require(disabled.stats.effective_sun_cascades == 0, + "Disabled sun shadow does no shadow raster work"); + require(disabled.stats.sun_shadow_caster_draws == 0 && + disabled.stats.gpu_sun_shadow_ms == 0, + "Disabled sun does not draw a hidden legacy shadow pass"); + auto legacy = with_caster; + legacy.camera_frustum.reset(); + const auto fallback = capture(direct, legacy); + require(fallback.stats.effective_sun_cascades == 1, + "Low-level snapshot without explicit camera retains one reported shadow view"); + Snapshot sprite_only; + sprite_only.sprites.push_back({{0, 0, 0}, {1, 1}}); + const auto two_d = capture(direct, sprite_only); + require(two_d.stats.effective_sun_cascades == 0, + "Sprite-only scene skips the sun atlas raster"); + require(two_d.stats.sun_shadow_caster_draws == 0 && + two_d.stats.gpu_sun_shadow_ms == 0, + "Sprite-only rendering spends no sun shadow GPU work"); +} +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) + 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; + } +} 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_reload_tests.cpp b/tests/render_reload_tests.cpp index da9cade..59e0ae0 100644 --- a/tests/render_reload_tests.cpp +++ b/tests/render_reload_tests.cpp @@ -59,6 +59,27 @@ int main() { const auto original_reflection = read_text(bundle / "fragmentMain.reflection.json"); const auto original_fingerprint = Json::parse(original_reflection).at("layout_fingerprint"); render::validate_shader_bundle(bundle); + auto bad_lighting_stride = Json::parse(original_reflection); + auto& lighting_descriptors = bad_lighting_stride["layout"]["descriptors"]; + bool found_local_buffer = false; + for (auto& descriptor : lighting_descriptors) + if (descriptor["set"] == 1 && descriptor["binding"] == 1) { + descriptor["element_stride"] = 96; + found_local_buffer = true; + } + require(found_local_buffer, "Lighting stride fixture exists"); + bad_lighting_stride["layout_fingerprint"] = + sha256(bad_lighting_stride["layout"].dump()); + atomic_write_json(bundle / "fragmentMain.reflection.json", bad_lighting_stride); + bool rejected_lighting_stride = false; + try { + render::validate_shader_bundle(bundle); + } catch (const std::exception&) { + rejected_lighting_stride = true; + } + require(rejected_lighting_stride, + "Rehashed incompatible local-light element stride must be rejected"); + atomic_write(bundle / "fragmentMain.reflection.json", original_reflection); render::RendererConfig configuration; configuration.width = configuration.height = 64; configuration.headless = true; @@ -161,6 +182,18 @@ int main() { atomic_write(bundle / "fragmentMain.spv", "damaged bytecode"); retained(); restore(); + auto incompatible = original_source; + auto at = incompatible.find(" float4 reserved;"); + require(at != std::string::npos, "Local-light stride fixture exists"); + incompatible.replace(at, std::string(" float4 reserved;").size(), + " float4 reserved;\n float4 incompatibleExtraLane;"); + atomic_write(source, incompatible); + require(compile(source, bundle) == 0, "Compile incompatible light-buffer stride"); + require(read_json(bundle / "fragmentMain.reflection.json").at("layout_fingerprint") != + original_fingerprint, + "Lighting stride edit changes normalized layout fingerprint"); + retained(); + restore(); auto malformed = original_spirv; for (int i = 0; i < 4; ++i) malformed[20 + i] = 0; // zero-word SPIR-V instruction @@ -170,8 +203,8 @@ int main() { atomic_write_json(bundle / "fragmentMain.reflection.json", metadata); retained(); restore(); - auto incompatible = original_source; - auto at = incompatible.find("[[vk::binding(2,0)]]"); + incompatible = original_source; + at = incompatible.find("[[vk::binding(2,0)]]"); require(at != std::string::npos, "Shader descriptor fixture exists"); incompatible.replace(at, std::string("[[vk::binding(2,0)]]").size(), "[[vk::binding(7,0)]]"); diff --git a/tests/render_tests.cpp b/tests/render_tests.cpp index 3aaacc0..2ac6735 100644 --- a/tests/render_tests.cpp +++ b/tests/render_tests.cpp @@ -134,6 +134,102 @@ int main(int argc, char** argv) { renderer.render(scene); pixels = renderer.pixels(); require(pixels[index + 2] > 220, "Texture revision upload"); + Snapshot two_lights; + two_lights.eye = {0, 0, 6}; + two_lights.projection = perspective(.85f, 320.f / 240.f, .1f, 30.f); + two_lights.view_projection = + multiply(two_lights.projection, look_at(two_lights.eye, {0, 0, 0})); + two_lights.authored_lights_present = true; + two_lights.draws.push_back( + {cube_mesh(), transform({-1.4f, 0, 0}), {.5f, .5f, .5f, 1}, .6f, 0, false}); + two_lights.draws.back().instance_key = "left-light-receiver"; + two_lights.draws.push_back( + {cube_mesh(), transform({1.4f, 0, 0}), {.5f, .5f, .5f, 1}, .6f, 0, false}); + two_lights.draws.back().instance_key = "right-light-receiver"; + two_lights.ui_quads.push_back({8, 8, 40, 20, {.8f, .1f, .15f, 1}}); + for (auto mode : {VisibilityMode::Direct, VisibilityMode::GpuFrustum}) { + renderer.set_visibility_mode(mode); + renderer.render(two_lights); + const auto dark = renderer.pixels(); + require(renderer.stats().validation_errors == 0, + "Zero-local-light descriptors are initialized"); + auto legacy_lights = two_lights; + legacy_lights.authored_lights_present = false; + renderer.render(legacy_lights); + const auto legacy = renderer.pixels(); + const auto left = (120 * 320 + 99) * 4; + require(legacy[left] > dark[left] + 15, + "Authored-light presence suppresses the legacy sun even without a local light"); + two_lights.local_lights = { + {LocalLight::Kind::Point, "red", {-1.4f, 0, 1.4f}, {0, 0, -1}, + {1, 0, 0, 1}, 8, 2.2f, .35f, .7f, false, 0}, + {LocalLight::Kind::Point, "blue", {1.4f, 0, 1.4f}, {0, 0, -1}, + {0, 0, 1, 1}, 8, 2.5f, .35f, .7f, false, 0}}; + renderer.render(two_lights); + const auto lit = renderer.pixels(); + const auto right = (120 * 320 + 221) * 4; + const auto ui = (10 * 320 + 10) * 4; + require(lit[left] > dark[left] + 20 && lit[right + 2] > dark[right + 2] + 20, + "Separated red and blue point lights illuminate their receivers"); + require(std::abs(int(lit[left + 2]) - int(dark[left + 2])) < 6 && + std::abs(int(lit[right]) - int(dark[right])) < 6, + "Local light range keeps the opposite colored light off each receiver"); + for (int channel = 0; channel < 4; ++channel) + require(lit[ui + channel] == dark[ui + channel], + "Lighting changes leave UI tint unchanged"); + require(renderer.stats().validation_errors == 0, + "Direct and GPU local lighting report no Vulkan errors"); + if (mode == VisibilityMode::GpuFrustum) + require(renderer.stats().effective_visibility_mode == VisibilityMode::GpuFrustum, + "Local light image test actually exercises the GPU visibility path"); + two_lights.local_lights[1].kind = LocalLight::Kind::Spot; + renderer.render(two_lights); + const auto aimed = renderer.pixels(); + two_lights.local_lights[1].direction = {1, 0, 0}; + renderer.render(two_lights); + const auto turned = renderer.pixels(); + require(aimed[right + 2] > turned[right + 2] + 20, + "Spotlight cone direction changes receiver illumination"); + 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); diff --git a/tests/runtime_player_tests.cpp b/tests/runtime_player_tests.cpp index ef285a1..e36e7bc 100644 --- a/tests/runtime_player_tests.cpp +++ b/tests/runtime_player_tests.cpp @@ -1,10 +1,12 @@ #include +#include #include #include #include #include #include #include +#include #include #include @@ -23,12 +25,137 @@ template void rejects(F&& function, const char* message) { } check(caught, message); } +template +void rejectsContaining(F&& function, std::string_view entityId, std::string_view field) { + try { + function(); + } catch (const std::exception& error) { + const std::string_view what(error.what()); + check(what.find(entityId) != std::string_view::npos && + what.find(field) != std::string_view::npos, + "Invalid light reports entity and field"); + return; + } + throw std::runtime_error("Invalid light was accepted"); +} Json component(std::string type, Json fields) { return {{"id", type}, {"type", type}, {"version", 1}, {"fields", fields}}; } Json entity(std::string id, Json parent, Json components) { return {{"id", id}, {"name", id}, {"parent", parent}, {"components", components}}; } +void lightingExtraction(faset::player::SceneView& view) { + auto scene = Json{{"format", "faset.scene"}, + {"version", 1}, + {"id", "lighting"}, + {"name", "Lighting"}, + {"dimension", 3}, + {"instances", Json::array()}, + {"entities", Json::array()}}; + const auto empty = view.build(scene, 16.f / 9.f); + check(!empty.authored_lights_present && !empty.sun && empty.local_lights.empty(), + "Scene with no lights leaves legacy sun fallback available"); + check(empty.camera_frustum && empty.camera_frustum->perspective && + empty.camera_frustum->near_plane > 0 && + empty.camera_frustum->far_plane > empty.camera_frustum->near_plane && + faset::render::multiply(empty.camera_frustum->projection, + empty.camera_frustum->view) == empty.view_projection, + "3D extraction retains an unjittered camera frustum"); + scene["entities"] = Json::array({ + entity("sun", nullptr, + Json::array({component("faset.transform", {{"rotation", {0, .4, 0}}}), + component("faset.light", {{"kind", "directional"}, + {"color", {1, .8, .6, 1}}, + {"intensity", 2.5}, + {"casts_shadow", false}})})), + entity("point", nullptr, + Json::array({component("faset.transform", {{"position", {2, 3, 4}}}), + component("faset.light", {{"kind", "point"}, + {"color", {1, 0, 0, 1}}, + {"intensity", 4}, + {"range", 6}, + {"shadow_priority", 3}})})), + entity("spot", nullptr, + Json::array({component("faset.transform", {{"position", {-2, 1, 0}}}), + component("faset.light", {{"kind", "spot"}, + {"color", {0, 0, 1, 1}}, + {"intensity", 3}, + {"range", 8}, + {"inner_angle", .2}, + {"outer_angle", .6}})}))}); + const auto a = view.build(scene, 16.f / 9.f); + check(a.authored_lights_present && a.sun && a.local_lights.size() == 2, + "Directional, point, and spot lights survive extraction"); + check(a.sun->color[1] == .8f && a.sun->intensity == 2.5f && !a.sun->casts_shadow, + "Authored sun properties survive extraction"); + check(a.local_lights[0].kind == faset::render::LocalLight::Kind::Point && + a.local_lights[0].position == faset::render::Vec3{2, 3, 4} && + a.local_lights[0].range == 6 && a.local_lights[0].shadow_priority == 3, + "Point fields and transform survive extraction"); + check(a.local_lights[1].kind == faset::render::LocalLight::Kind::Spot && + a.local_lights[1].inner_angle == .2f && a.local_lights[1].outer_angle == .6f, + "Spot cone survives extraction"); + std::reverse(scene["entities"].begin(), scene["entities"].end()); + const auto b = view.build(scene, 16.f / 9.f); + check(a.sun->stable_id == b.sun->stable_id && + a.local_lights[0].stable_id == b.local_lights[0].stable_id && + a.local_lights[1].stable_id == b.local_lights[1].stable_id, + "Light identity and ordering ignore entity array order"); + scene["entities"].erase(scene["entities"].begin() + 2); + const auto localOnly = view.build(scene, 1); + check(localOnly.authored_lights_present && !localOnly.sun && + localOnly.local_lights.size() == 2, + "Local-only lighting does not synthesize a sun"); + scene["entities"] = Json::array({entity( + "disabled-sun", nullptr, + Json::array({component("faset.light", {{"kind", "directional"}, {"enabled", false}})}))}); + const auto disabled = view.build(scene, 1); + check(disabled.authored_lights_present && !disabled.sun && disabled.local_lights.empty(), + "Explicit disabled sun suppresses legacy fallback"); + scene["entities"][0]["components"][0]["version"] = 2; + const auto future = view.build(scene, 1); + check(future.authored_lights_present && !future.sun, + "Opaque future-version light still suppresses legacy fallback"); + scene["entities"] = Json::array({ + entity("sun-z", nullptr, Json::array({component("faset.light", Json::object())})), + entity("sun-a", nullptr, Json::array({component("faset.light", Json::object())}))}); + const auto twoSuns = view.build(scene, 1); + check(twoSuns.sun && twoSuns.sun->stable_id.find("sun-a") != std::string::npos, + "Multiple suns select lowest stable identity"); + check(!view.diagnostics().empty() && + view.diagnostics().front().find("directional") != std::string::npos, + "Additional directionals produce an actionable diagnostic"); + scene["entities"] = Json::array({entity( + "bad-light", nullptr, + Json::array({component("faset.light", {{"kind", "point"}, {"range", 0}})}))}); + rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "range"); + scene["entities"][0]["components"][0]["fields"] = + {{"kind", "spot"}, {"range", 10}, {"inner_angle", .8}, {"outer_angle", .2}}; + rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "inner_angle"); + scene["entities"][0]["components"][0]["fields"] = {{"kind", "area"}}; + rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "kind"); + scene["entities"][0]["components"][0]["fields"] = + {{"kind", "point"}, {"shadow_priority", std::int64_t{2147483648}}}; + rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "shadow_priority"); + scene["entities"][0]["components"][0]["fields"] = {{"kind", "point"}}; + scene["entities"][0]["components"].insert( + scene["entities"][0]["components"].begin(), + component("faset.transform", {{"scale", {1, 1, 0}}})); + const auto flatPoint = view.build(scene, 1); + check(flatPoint.local_lights.size() == 1 && + flatPoint.local_lights[0].kind == faset::render::LocalLight::Kind::Point, + "Point light accepts a zero Z scale because it needs only a position"); + scene["entities"][0]["components"].erase(scene["entities"][0]["components"].begin()); + scene["entities"][0]["components"][0]["fields"] = + {{"kind", "point"}, + {"color", Json::array({1, std::numeric_limits::quiet_NaN(), 1, 1})}}; + rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "color"); + scene["entities"][0]["components"].insert( + scene["entities"][0]["components"].begin(), + component("faset.transform", {{"position", {0, 0, 0}}, + {"scale", Json::array({1, std::numeric_limits::quiet_NaN(), 1})}})); + rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "transform"); +} void physicsDebug(faset::player::SceneView& view) { for (int dimension : {2, 3}) { const std::string bodyName = dimension == 2 ? "rigid_body_2d" : "rigid_body_3d"; @@ -159,6 +286,7 @@ void run() { faset::atomic_write(folder / "version.fscene", version); rejects([&] { faset::player::readScene(folder / "version.fscene"); }, "reject cooked version"); faset::player::SceneView view(folder); + lightingExtraction(view); physicsDebug(view); auto snapshot = view.build(scene, 16.f / 9.f); check(snapshot.draws.size() == 1, "SceneView builtin mesh"); diff --git a/tests/test_p3_lighting_benchmark.py b/tests/test_p3_lighting_benchmark.py new file mode 100644 index 0000000..bdfa443 --- /dev/null +++ b/tests/test_p3_lighting_benchmark.py @@ -0,0 +1,229 @@ +"""Contract and arithmetic tests for the offline P3 lighting sweep wrapper.""" + +import csv +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "tools/benchmark_p3_lighting.py" +sys.path.insert(0, str(ROOT / "tools")) + + +def sample(shadows, visibility, lights, raster, gpu, repeat=1, frame=0): + return { + "shadows": shadows, "visibility": visibility, "light_count": str(lights), + "run_index": str(repeat), "frame": str(frame), + "gpu_main_raster_ms": str(raster), "gpu_ms": str(gpu), + "gpu_shadow_ms": "0", "cpu_ms": "1", "readback_cpu_ms": ".5", + "validation_errors": "0", "device": "Fake GPU", "driver": "Fake Driver", + "commit": "abc123", "effective_visibility": visibility, + "lighting_path": "forward", "submitted_local_lights": str(lights), + "omitted_local_lights": "0", "shadow_tiles": "0", "draw_calls": "1", + "gpu_bytes": "4096", "validation_enabled": "0", "width": "1920", "height": "1080", + } + + +FAKE_BENCHMARK = r'''import argparse +import csv +import json +from pathlib import Path + +p = argparse.ArgumentParser() +for flag in ("lights", "run-index", "width", "height", "warmup", "frames"): + p.add_argument("--" + flag, type=int, required=True) +for flag in ("shadows", "visibility", "csv", "commit", "validation"): + p.add_argument("--" + flag, required=True) +p.add_argument("--driver") +a = p.parse_args() +path = Path(a.csv) +path.with_suffix(".args.json").write_text(json.dumps(vars(a)), encoding="utf-8") +fieldnames = ["light_count", "shadows", "visibility", "frame", "device", "driver", + "commit", "gpu_main_raster_ms", "gpu_ms", "gpu_shadow_ms", "cpu_ms", + "readback_cpu_ms", "validation_errors", "run_index", "effective_visibility", + "lighting_path", "submitted_local_lights", "omitted_local_lights", + "shadow_tiles", "draw_calls", "gpu_bytes", "validation_enabled", "width", "height"] +with path.open("w", newline="", encoding="utf-8") as stream: + writer = csv.DictWriter(stream, fieldnames=fieldnames) + writer.writeheader() + for frame in range(a.frames): + writer.writerow(dict(light_count=a.lights, shadows=a.shadows, + visibility=a.visibility, frame=frame, device="Fake GPU", + driver="Fake Driver", commit=a.commit, + gpu_main_raster_ms=.4 + .02 * a.lights, gpu_ms=4 + .02 * a.lights, + gpu_shadow_ms=0, cpu_ms=1, readback_cpu_ms=.5, + validation_errors=0, run_index=a.run_index, + effective_visibility=a.visibility, lighting_path="forward", + submitted_local_lights=a.lights, omitted_local_lights=0, + shadow_tiles=0, draw_calls=1, gpu_bytes=4096, + validation_enabled=0, width=a.width, height=a.height)) +''' + + +class LightingBenchmarkTests(unittest.TestCase): + def test_list_runs_has_three_independent_repeats_for_each_shadow_setting(self): + process = subprocess.run([sys.executable, SCRIPT, "--list-runs"], + text=True, capture_output=True, check=True) + runs = json.loads(process.stdout)["runs"] + self.assertEqual(len(runs), 108) + for shadows in ("off", "on"): + group = [run for run in runs if run["shadows"] == shadows] + self.assertEqual(len(group), 54) + self.assertEqual({(run["light_count"], run["visibility"]) + for run in group}, + {(light, mode) for light in (0, 4, 16, 32, 64, 128) + for mode in ("direct", "gpu-frustum", "gpu-occlusion")}) + self.assertEqual({(run["light_count"], run["visibility"], run["repeat"]) + for run in group}, + {(light, mode, repeat) + for light in (0, 4, 16, 32, 64, 128) + for mode in ("direct", "gpu-frustum", "gpu-occlusion") + for repeat in (1, 2, 3)}) + + def test_gate_uses_per_run_medians_and_same_mode_shadow_baseline(self): + from benchmark_p3_lighting import summarize_rows + + rows = [] + for repeat in (1, 2, 3): + for frame in (0, 1, 2): + rows.append(sample("off", "direct", 0, .5, 10, repeat, frame)) + rows.append(sample("off", "direct", 32, + 100 if repeat == 3 else 1.5, 11, repeat, frame)) + rows.append(sample("on", "gpu-frustum", 0, .5, 4, repeat, frame)) + rows.append(sample("on", "gpu-frustum", 64, 1.1, 4.6, repeat, frame)) + rows.append(sample("off", "gpu-occlusion", 0, .5, 4, repeat, frame)) + rows.append(sample("off", "gpu-occlusion", 128, 1.09, 4.59, repeat, frame)) + summary = summarize_rows(rows) + hits = {(item["shadows"], item["visibility"], item["light_count"]): item + for item in summary["forward_plus_gate"]["candidates"]} + self.assertEqual(set(hits), {("off", "direct", 32), + ("on", "gpu-frustum", 64)}) + self.assertAlmostEqual(hits[("off", "direct", 32)]["overhead_ms"], 1.0) + self.assertAlmostEqual(hits[("on", "gpu-frustum", 64)]["overhead_ms"], .6) + self.assertEqual(hits[("off", "direct", 32)]["zero_light_gpu_ms"], 10) + self.assertEqual(hits[("on", "gpu-frustum", 64)]["zero_light_gpu_ms"], 4) + + def test_sweep_runs_fake_executable_and_preserves_all_raw_frames(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + fake = root / "fake_benchmark.py" + fake.write_text(FAKE_BENCHMARK, encoding="utf-8") + output = root / "café 世界" + process = subprocess.run( + [sys.executable, SCRIPT, "--sweep", "--executable", fake, + "--output", output, "--shadows", "off", "--commit", "abc123", + "--driver", "Fake Driver"], + text=True, capture_output=True) + self.assertEqual(process.returncode, 0, process.stderr) + raw = list((output / "raw").glob("*.csv")) + self.assertEqual(len(raw), 54) + with (output / "merged.csv").open(newline="", encoding="utf-8") as stream: + merged = list(csv.DictReader(stream)) + self.assertEqual(len(merged), 54 * 30) + self.assertEqual({row["source_csv"] for row in merged}, + {path.name for path in raw}) + report = json.loads((output / "summary.json").read_text(encoding="utf-8")) + self.assertEqual(report["runs_completed"], 54) + self.assertEqual(report["rows"], 54 * 30) + self.assertTrue(report["forward_plus_gate"]["triggered"]) + one = json.loads(next((output / "raw").glob("*.args.json")).read_text()) + self.assertEqual((one["width"], one["height"], one["warmup"], one["frames"]), + (1920, 1080, 10, 30)) + self.assertEqual(one["validation"], "off") + + def test_sweep_rejects_missing_gpu_raster_column(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + fake = root / "bad_benchmark.py" + fake.write_text(FAKE_BENCHMARK.replace( + '"gpu_main_raster_ms", "gpu_ms"', '"gpu_ms"').replace( + 'gpu_main_raster_ms=.4 + .02 * a.lights, ', ''), encoding="utf-8") + output = root / "invalid" + process = subprocess.run( + [sys.executable, SCRIPT, "--sweep", "--executable", fake, + "--output", output, "--shadows", "off", "--commit", "abc123", + "--driver", "Fake Driver"], + text=True, capture_output=True) + self.assertNotEqual(process.returncode, 0) + self.assertIn("gpu_main_raster_ms", process.stderr) + self.assertFalse((output / "summary.json").exists()) + + def test_sweep_rejects_visibility_fallback_as_a_mode_measurement(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + fake = root / "fallback.py" + fake.write_text(FAKE_BENCHMARK.replace( + 'effective_visibility=a.visibility', 'effective_visibility="direct"'), + encoding="utf-8") + output = root / "fallback-output" + process = subprocess.run( + [sys.executable, SCRIPT, "--sweep", "--executable", fake, + "--output", output, "--shadows", "off", "--commit", "abc123", + "--driver", "Fake Driver"], + text=True, capture_output=True) + self.assertNotEqual(process.returncode, 0) + self.assertIn("effective_visibility", process.stderr) + self.assertFalse((output / "summary.json").exists()) + + def test_sweep_rejects_a_scene_that_does_not_submit_requested_lights(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + fake = root / "wrong-count.py" + fake.write_text(FAKE_BENCHMARK.replace( + 'submitted_local_lights=a.lights', 'submitted_local_lights=0'), + encoding="utf-8") + output = root / "wrong-count-output" + process = subprocess.run( + [sys.executable, SCRIPT, "--sweep", "--executable", fake, + "--output", output, "--shadows", "off", "--commit", "abc123", + "--driver", "Fake Driver"], + text=True, capture_output=True) + self.assertNotEqual(process.returncode, 0) + self.assertIn("submitted_local_lights", process.stderr) + self.assertFalse((output / "summary.json").exists()) + + def test_sweep_requires_known_driver_identity(self): + from benchmark_p3_lighting import sweep + with tempfile.TemporaryDirectory() as temporary: + fake = Path(temporary) / "fake.py" + fake.write_text(FAKE_BENCHMARK, encoding="utf-8") + with self.assertRaisesRegex(ValueError, "--driver"): + sweep(fake, Path(temporary) / "out", "off", "abc123") + + +def real_executable_smoke(executable: Path) -> None: + from benchmark_p3_lighting import REQUIRED_COLUMNS + + choices = subprocess.run([executable, "--list-runs"], capture_output=True, + text=True, check=True) + declared = json.loads(choices.stdout) + if declared["lights"] != [0, 4, 16, 32, 64, 128] or len(declared["visibility"]) != 3: + raise AssertionError("C++ executable and Python sweep matrix disagree") + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "café 世界" / "smoke.csv" + subprocess.run([executable, "--lights", "4", "--shadows", "off", + "--visibility", "direct", "--width", "64", "--height", "64", + "--warmup", "0", "--frames", "1", "--validation", "on", + "--commit", "smoke", "--driver", "smoke-driver", + "--csv", output], capture_output=True, text=True, check=True) + with output.open(newline="", encoding="utf-8") as stream: + reader = csv.DictReader(stream) + columns, rows = reader.fieldnames or [], list(reader) + if set(REQUIRED_COLUMNS) - set(columns) or len(rows) != 1: + raise AssertionError("Real benchmark CSV lacks a complete single-frame row") + row = rows[0] + if (row["effective_visibility"] != "direct" or row["lighting_path"] != "forward" or + row["submitted_local_lights"] != "4" or row["validation_errors"] != "0" or + float(row["gpu_main_raster_ms"]) <= 0): + raise AssertionError("Real benchmark did not report the measured lighting path") + + +if __name__ == "__main__": + if len(sys.argv) == 3 and sys.argv[1] == "--real-executable": + real_executable_smoke(Path(sys.argv[2]).resolve()) + else: + unittest.main() diff --git a/tests/test_shader_reflection.py b/tests/test_shader_reflection.py index 3a81afb..38f8bb3 100644 --- a/tests/test_shader_reflection.py +++ b/tests/test_shader_reflection.py @@ -36,6 +36,35 @@ def parameter(name: str, index: int, shape: str, access: str, stride: int | None class ReflectionTests(unittest.TestCase): + def test_graphics_lighting_abi(self): + compiler = os.environ["FASET_TEST_SLANGC"] + with tempfile.TemporaryDirectory(prefix="faset-lighting-abi-") as directory: + for source, entry, defines in ( + ("baseline.slang", "fragmentMain", []), + ("gpu_scene.slang", "gpuVertexMain", ["--define", "FASET_GPU_GRAPHICS=1"]), + ): + process = subprocess.run( + [sys.executable, str(SCRIPT), "--compiler", compiler, "--source", + str(SCRIPT.parents[1] / "shaders" / source), "--entry", entry, + *defines, "--output", directory], + capture_output=True, text=True, + ) + self.assertEqual(process.returncode, 0, process.stderr) + fragment = json.loads((Path(directory) / "fragmentMain.reflection.json").read_text()) + gpu_vertex = json.loads((Path(directory) / "gpuVertexMain.reflection.json").read_text()) + lighting = { + (d["set"], d["binding"]): (d["type"], d.get("element_stride")) + for d in fragment["layout"]["descriptors"] + } + self.assertEqual([lighting[1, i] for i in range(4)], + [("storage_buffer", 80), ("storage_buffer", 80), + ("storage_buffer", 112), ("sampled_image_2d", None)]) + graphics = { + (d["set"], d["binding"]): d["element_stride"] + for d in gpu_vertex["layout"]["descriptors"] + } + self.assertEqual([graphics[2, i] for i in range(3)], [224, 4, 208]) + def test_gpu_vertex_paths_do_not_require_shader_draw_parameters(self): # SV_InstanceID makes Slang subtract BaseInstance and emit DrawParameters. # Our indirect commands always use firstInstance=0, so the Vulkan instance diff --git a/tools/benchmark_p3_lighting.py b/tools/benchmark_p3_lighting.py new file mode 100644 index 0000000..66bdad8 --- /dev/null +++ b/tools/benchmark_p3_lighting.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Run the fixed P3 lighting sweep and retain raw per-frame GPU measurements. + +The Forward+ threshold in the summary is a measurement result, not an automatic +renderer switch. Apply it to the Linux physical reference GPU; keep other devices +as separate functional/performance observations. +""" +from __future__ import annotations + +import argparse +import csv +import json +import math +from pathlib import Path +import statistics +import subprocess +import sys + + +LIGHT_COUNTS = (0, 4, 16, 32, 64, 128) +VISIBILITY_MODES = ("direct", "gpu-frustum", "gpu-occlusion") +REPEATS = (1, 2, 3) +WARMUP_FRAMES = 10 +MEASURED_FRAMES = 30 +WIDTH, HEIGHT = 1920, 1080 +REQUIRED_COLUMNS = ( + "light_count", "shadows", "visibility", "frame", "device", "driver", + "commit", "gpu_main_raster_ms", "gpu_ms", "gpu_shadow_ms", "cpu_ms", + "readback_cpu_ms", "validation_errors", "run_index", "effective_visibility", + "lighting_path", "submitted_local_lights", "omitted_local_lights", + "shadow_tiles", "draw_calls", "gpu_bytes", "validation_enabled", "width", "height", +) +TIMING_COLUMNS = ("gpu_main_raster_ms", "gpu_ms", "gpu_shadow_ms", "cpu_ms", + "readback_cpu_ms") + + +def build_runs(shadows: str = "both") -> list[dict]: + if shadows not in ("off", "on", "both"): + raise ValueError(f"Unsupported shadow setting: {shadows}") + settings = ("off", "on") if shadows == "both" else (shadows,) + return [{"shadows": shadow, "visibility": mode, "light_count": lights, + "repeat": repeat} + for shadow in settings for mode in VISIBILITY_MODES + for lights in LIGHT_COUNTS for repeat in REPEATS] + + +def median(values: list[float]) -> float: + if not values: + raise ValueError("Cannot summarize empty measurements") + return float(statistics.median(values)) + + +def p95(values: list[float]) -> float: + if not values: + raise ValueError("Cannot summarize empty measurements") + ordered = sorted(values) + return ordered[math.ceil(.95 * len(ordered)) - 1] + + +def _measurement(row: dict, name: str) -> float: + try: + value = float(row[name]) + except (KeyError, TypeError, ValueError) as error: + raise ValueError(f"Invalid {name} in benchmark CSV") from error + if not math.isfinite(value) or value < 0: + raise ValueError(f"Invalid {name} in benchmark CSV: {value}") + return value + + +def summarize_rows(rows: list[dict]) -> dict: + """Use the median of each independent run's median, then compare like baselines.""" + grouped: dict[tuple[str, str, int], dict[int, list[dict]]] = {} + for row in rows: + try: + key = (row["shadows"], row["visibility"], int(row["light_count"])) + repeat = int(row["run_index"]) + except (KeyError, TypeError, ValueError) as error: + raise ValueError("Benchmark row lacks shadow/mode/light/repeat identity") from error + if key[0] not in ("off", "on") or key[1] not in VISIBILITY_MODES or repeat < 1: + raise ValueError(f"Invalid benchmark configuration: {key}, repeat {repeat}") + for name in TIMING_COLUMNS: + _measurement(row, name) + grouped.setdefault(key, {}).setdefault(repeat, []).append(row) + + configurations = [] + lookup = {} + for (shadows, visibility, lights), repeats in sorted(grouped.items()): + run_summaries = [] + for repeat, samples in sorted(repeats.items()): + run_summaries.append({ + "run_index": repeat, "frames": len(samples), + "median_ms": {name: median([_measurement(row, name) for row in samples]) + for name in TIMING_COLUMNS}, + }) + entry = { + "shadows": shadows, "visibility": visibility, "light_count": lights, + "runs": run_summaries, + "median_ms": {name: median([run["median_ms"][name] for run in run_summaries]) + for name in TIMING_COLUMNS}, + "p95_ms": {name: p95([_measurement(row, name) for samples in repeats.values() + for row in samples]) for name in TIMING_COLUMNS}, + } + configurations.append(entry) + lookup[(shadows, visibility, lights)] = entry + + candidates = [] + evaluated = [] + for entry in configurations: + if entry["light_count"] not in (32, 64, 128): + continue + baseline = lookup.get((entry["shadows"], entry["visibility"], 0)) + if baseline is None: + raise ValueError("Forward+ gate requires a zero-light baseline for each mode/shadow setting") + zero_gpu = baseline["median_ms"]["gpu_ms"] + if zero_gpu <= 0: + raise ValueError("Forward+ gate requires positive zero-light GPU frame timing") + overhead = (entry["median_ms"]["gpu_main_raster_ms"] - + baseline["median_ms"]["gpu_main_raster_ms"]) + result = {"shadows": entry["shadows"], "visibility": entry["visibility"], + "light_count": entry["light_count"], "overhead_ms": overhead, + "zero_light_gpu_ms": zero_gpu, + "overhead_percent_of_zero_gpu": 100 * overhead / zero_gpu, + "absolute_threshold_reached": overhead >= 1.0, + "relative_threshold_reached": overhead >= .15 * zero_gpu} + evaluated.append(result) + if result["absolute_threshold_reached"] or result["relative_threshold_reached"]: + candidates.append(result) + return {"configurations": configurations, + "forward_plus_gate": {"triggered": bool(candidates), "candidates": candidates, + "evaluated": evaluated, + "basis": "median of three independent run medians; same-mode/shadow zero-light GPU baseline"}} + + +def _read_run_csv(path: Path, run: dict, commit: str) -> tuple[list[str], list[dict]]: + with path.open(newline="", encoding="utf-8") as stream: + reader = csv.DictReader(stream) + columns = reader.fieldnames or [] + missing = sorted(set(REQUIRED_COLUMNS) - set(columns)) + if missing: + raise ValueError(f"{path}: missing CSV column(s): {', '.join(missing)}") + rows = list(reader) + if len(rows) != MEASURED_FRAMES: + raise ValueError(f"{path}: expected {MEASURED_FRAMES} measured frames, got {len(rows)}") + frames = set() + for row in rows: + expected = {"light_count": str(run["light_count"]), "shadows": run["shadows"], + "visibility": run["visibility"], "run_index": str(run["repeat"]), + "commit": commit, "width": str(WIDTH), "height": str(HEIGHT)} + for name, value in expected.items(): + if row[name] != value: + raise ValueError(f"{path}: {name} mismatch: expected {value}, got {row[name]}") + if row["effective_visibility"] != run["visibility"]: + raise ValueError(f"{path}: effective_visibility fell back from {run['visibility']}") + if row["submitted_local_lights"] != str(run["light_count"]) or row["omitted_local_lights"] != "0": + raise ValueError(f"{path}: submitted_local_lights or omitted_local_lights disagrees with the workload") + try: + frame = int(row["frame"]) + errors = int(row["validation_errors"]) + except ValueError as error: + raise ValueError(f"{path}: invalid frame or validation error count") from error + if frame in frames or errors != 0: + raise ValueError(f"{path}: duplicate frame or Vulkan validation error") + frames.add(frame) + if not row["device"] or not row["driver"] or not row["lighting_path"]: + raise ValueError(f"{path}: device, driver and effective lighting path are required") + for name in TIMING_COLUMNS: + _measurement(row, name) + return columns, rows + + +def _git_revision() -> str: + root = Path(__file__).resolve().parents[1] + return subprocess.check_output(["git", "-C", str(root), "rev-parse", "HEAD"], + text=True).strip() + + +def sweep(executable: Path, output: Path, shadows: str, commit: str, + validation: str = "off", driver: str | None = None) -> dict: + if not executable.is_file(): + raise ValueError(f"Benchmark executable does not exist: {executable}") + if driver is None or not driver.strip() or driver.strip().lower() == "unknown": + raise ValueError("A measured sweep requires an explicit --driver identity") + if output.exists() and any(output.iterdir()): + raise ValueError(f"Output directory must be new or empty: {output}") + raw = output / "raw" + raw.mkdir(parents=True) + all_rows = [] + columns = None + runs = build_runs(shadows) + command_prefix = [sys.executable, str(executable)] if executable.suffix.lower() == ".py" else [str(executable)] + for run in runs: + filename = (f"shadows-{run['shadows']}_{run['visibility']}_" + f"lights-{run['light_count']:03d}_run-{run['repeat']}.csv") + target = raw / filename + command = command_prefix + [ + "--lights", str(run["light_count"]), "--shadows", run["shadows"], + "--visibility", run["visibility"], "--csv", str(target), + "--run-index", str(run["repeat"]), "--commit", commit, + "--validation", validation, "--width", str(WIDTH), "--height", str(HEIGHT), + "--warmup", str(WARMUP_FRAMES), "--frames", str(MEASURED_FRAMES), + ] + if driver is not None: + command += ["--driver", driver] + result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", + errors="replace", timeout=180) + if result.returncode != 0: + raise RuntimeError(f"Benchmark failed for {filename}: {result.stderr[-2000:]}") + run_columns, samples = _read_run_csv(target, run, commit) + if columns is None: + columns = run_columns + elif columns != run_columns: + raise ValueError(f"{target}: CSV schema differs from other runs") + all_rows.extend({**row, "source_csv": filename} for row in samples) + + merged = output / "merged.csv" + with merged.open("w", newline="", encoding="utf-8") as stream: + writer = csv.DictWriter(stream, fieldnames=[*(columns or []), "source_csv"]) + writer.writeheader() + writer.writerows(all_rows) + summary = {"format": "faset.p3-lighting-benchmark", "version": 1, + "commit": commit, "warmup_frames_per_run": WARMUP_FRAMES, + "measured_frames_per_run": MEASURED_FRAMES, "width": WIDTH, "height": HEIGHT, + "validation": validation, "driver": driver, + "runs_completed": len(runs), "rows": len(all_rows), + **summarize_rows(all_rows)} + (output / "summary.json").write_text(json.dumps(summary, indent=2) + "\n", + encoding="utf-8") + return summary + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--list-runs", action="store_true", help="Print the deterministic sweep matrix as JSON") + mode.add_argument("--sweep", action="store_true", help="Run every configuration and retain raw CSV") + parser.add_argument("--shadows", choices=("off", "on", "both"), default="both") + parser.add_argument("--executable", type=Path, help="Built C++ benchmark executable") + parser.add_argument("--output", type=Path, help="New or empty evidence directory") + parser.add_argument("--commit", help="Source revision; defaults to this checkout's HEAD") + parser.add_argument("--driver", help="Required driver identity for a measured sweep") + parser.add_argument("--validation", choices=("on", "off"), default="off") + args = parser.parse_args() + if args.list_runs: + print(json.dumps({"format": "faset.p3-lighting-run-matrix", "version": 1, + "runs": build_runs(args.shadows)}, indent=2)) + return 0 + if args.executable is None or args.output is None: + parser.error("--sweep requires --executable and --output") + try: + summary = sweep(args.executable.resolve(), args.output.resolve(), args.shadows, + args.commit or _git_revision(), args.validation, args.driver) + except (OSError, ValueError, RuntimeError) as error: + print(f"P3 lighting benchmark failed: {error}", file=sys.stderr) + return 1 + print(json.dumps({"summary": str(args.output.resolve() / "summary.json"), + "runs_completed": summary["runs_completed"], + "forward_plus_threshold_reached": summary["forward_plus_gate"]["triggered"]})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())