# P3 Lighting and Shadows Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Deliver PLAN P3's multi-light shading, four-cascade sun shadows, bounded point/spot shadow atlas, independent shadow-view visibility, explicit budgets and diagnostics, and a measured Forward+ decision. **Architecture:** An immutable `Snapshot` carries one sun, local lights, and an unjittered camera frustum from scene extraction. Both Direct and P2 GPU graphics paths use the existing material set 0 and shared `fragmentMain`, a new lighting set 1, and, only for P2 GPU vertices, scene set 2. CPU shadow planning produces atomic sun/spot/point views and caster lists; Vulkan renders those views into two bounded D32 atlases before forward shading. Temporal reconstruction has a separate plan. **Tech Stack:** C++20, Vulkan 1.3 dynamic rendering, Slang/SPIR-V and Faset reflection v1, CMake/Ninja, SDL3, Catch-style existing C++ test executables, Python measurement scripts, MkDocs Material. **Spec:** `docs/superpowers/specs/2026-09-24-p3-lighting-design.md` ## Global Constraints - Linux and Windows desktop 3D; exported Player remains independent of Slang, Editor, and MCP. - Preserve selectable Direct, GPU frustum, and GPU occlusion paths and their equivalent opaque lighting. - Preserve material descriptor set 0 bindings 0–3; add lighting set 1; move GPU graphics scene bindings to set 2; leave GPU compute sets unchanged. - Preserve 96-byte baseline and 112-byte GPU graphics push constants; no optional Vulkan feature may be assumed without a capability check. - Sun atlas: four 1024² tiles in 2048² D32; local atlas: sixteen 512² tiles in 2048² D32; fallback to half resolution or explicitly unshadowed lighting. - At most 128 submitted local lights, sixteen local shadow faces, and 4096 caster draws per frame. Overflow is deterministic and visible; a skipped view is entirely unshadowed. - Shadow caster selection is independent of camera/P2 culling and camera-selected mesh LOD. No incomplete point-light cubemap. - Sun shadows use unjittered camera data; lighting work must not mutate velocity/TAA history owned by the temporal plan. - Register each new CTest case before its red run; verify it appears in `ctest --test-dir build/linux-debug -N`, use `--no-tests=error` with exact `-R` names, and treat an absent build target as a failed setup rather than a passing test. ## Review Focus - A distant, offscreen caster overlapping a visible receiver's sun cascade must still cast a shadow; Task 3's CPU test and Task 4's GPU fixture pin this. - A point light with fewer than six free atlas tiles must be fully unshadowed, never show partial faces; Task 3's scheduler test and Task 5's image case pin this. - A view exceeding the caster draw budget must not render only some casters; Task 3's budget test and Task 5's overflow image pin this. - A material with no local lights, including UI/sprite pixels, must not read an uninitialized storage descriptor or change tint; Tasks 2 and 4 pin this. - Shader hot reload with a changed light-buffer stride/binding must reject the candidate and retain the displayed frame; Task 2 pins this. --- ## File map and integration order `include/faset/render/renderer.hpp` owns public light/camera snapshot fields and statistics. New `include/faset/render/lighting.hpp` and `src/render/lighting.cpp` own pure CPU split, shadow-view, tile-allocation, caster-culling, and budget policy, independently testable without Vulkan. `src/player/SceneView.cpp` and `src/authoring/schema.cpp` translate version-1 authoring fields into those typed records. `shaders/baseline.slang`, `shaders/gpu_scene.slang`, `src/render/shader_contract.cpp`, and `src/render/renderer.cpp` own the exact graphics ABI and Vulkan implementation. `src/editor/debug_overlay.cpp`, Player diagnostics, the Manual, and validation studies consume statistics after rendering. Tasks 1–3 define the shared interfaces. Integrate Task 2's descriptor ABI before concurrent temporal shader changes; Tasks 4–5 then implement Vulkan shadow rendering. The temporal plan may proceed independently in its own files, but `renderer.hpp`, `renderer.cpp`, and `baseline.slang` changes must be sequenced or reconciled with a focused Direct/P2/temporal regression pass. Make a checkpoint commit after every green task; do not mark P3 complete until the acceptance record exists. ### Task 1: Authoring schema and typed light extraction **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`, `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; 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); check(a.local_lights.size() == 2 && b.local_lights.size() == 2, "Point and spot lights survive scene extraction"); check(a.local_lights[0].stable_id == b.local_lights[0].stable_id, "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 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 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`. ### Task 2: Shared lighting shader ABI and unshadowed local PBR **Files:** Modify `shaders/baseline.slang`, `shaders/gpu_scene.slang`, `src/render/shader_contract.cpp`, `src/render/renderer.cpp`, `include/faset/render/renderer.hpp`, `tests/test_shader_reflection.py`, `tests/render_gpu_shader_contract_tests.cpp`, `tests/render_reload_tests.cpp`, `tests/render_tests.cpp`, `cmake/Renderer.cmake` if a new CPU-only ABI target is useful. **Interfaces:** Keep set 0 bindings 0–3 and `Push`/`ScenePush` sizes. Define set 1 bindings 0=`StructuredBuffer` (one record), 1=`StructuredBuffer` (minimum one allocated record even when count zero), 2=`StructuredBuffer` (minimum one record), 3=`Texture2D` local atlas. `LightingHeader` is five 16-byte lanes (counts/flags, sun direction+intensity, sun color, camera forward+shadow distance, four split depths); `LocalLightGpu` is five 16-byte lanes (position+range, direction+cosOuter, color+intensity, cone/type/shadow-view indices, reserved); `ShadowViewGpu` is seven 16-byte lanes (matrix, tile scale/offset, guarded clamp, bias/flags). GPU vertex buffers move to set 2 bindings 0–2. Put host mirrors in `src/render/renderer.cpp` with exact `static_assert` size/offsets and validate Slang element strides in reflection. These 80/80/112-byte records are renderer ABI, not scene-file schema. - [ ] **Step 1: Write failing ABI and image tests.** Extend the existing registered `render_shader_reflection`, `render_gpu_shader_contract`, `render_shader_reload`, and `render_offscreen` cases; do not rely on an unregistered new test. Assert exact set/binding/type/stride for baseline fragment and GPU vertex entries; corrupt a lighting stride or GPU set number in a copied reflection file and require validation rejection. Render a zero-local-light scene with unchanged sprite/UI tint; place red and blue point lights at different ranges and assert the correct receiver regions brighten without NaNs. Run in Direct and GPU frustum modes. ```python fragment = json.loads((shader_dir / "fragmentMain.reflection.json").read_text()) gpu_vertex = json.loads((shader_dir / "gpuVertexMain.reflection.json").read_text()) assert next(d for d in fragment["layout"]["descriptors"] if (d["set"], d["binding"]) == (1, 1))["element_stride"] == 80 assert next(d for d in gpu_vertex["layout"]["descriptors"] if (d["set"], d["binding"]) == (2, 0))["element_stride"] == 224 ``` - [ ] **Step 2: Run focused tests red.** Reconfigure and build the modified test targets, then run `ctest --test-dir build/linux-debug --output-on-failure --no-tests=error -R '^(render_shader_reflection|render_gpu_shader_contract|render_shader_reload|render_offscreen)$'`. The new ABI assertion or the point/spot fixture inside `render_offscreen` must reject missing set 1/2 behavior. Confirm all four expected cases in `ctest --test-dir build/linux-debug -N`. - [ ] **Step 3: Implement the checked ABI and shading.** Allocate/bind one frame lighting set in Direct and GPU graphics pipelines; update shader contracts and P2 graphics set indices only. Accumulate each direct-light BRDF in linear RGB before tone mapping; keep the zero-normal UI/sprite early return. Use finite-safe inverse-square-like distance attenuation with smooth range cutoff and a smooth spot cone. Keep sun/default image reference close to the old baseline. Reject incompatible runtime shader reload while preserving previous pipelines. ```slang [[vk::binding(0,1)]] StructuredBuffer lightingFrame; [[vk::binding(1,1)]] StructuredBuffer localLights; [[vk::binding(2,1)]] StructuredBuffer shadowViews; [[vk::binding(3,1)]] Texture2D localShadowAtlas; // GPU scene vertex descriptors change from binding(*,1) to binding(*,2). ``` - [ ] **Step 4: Rebuild shaders and run reflection, reload, offscreen, and Direct/P2 image tests green.** `cmake --build --preset linux-debug --target faset_shaders faset_render_tests faset_render_gpu_shader_contract_tests faset_render_reload_tests --parallel 4`; then the focused `ctest` regex above and `ctest --test-dir build/linux-debug -L p2 --output-on-failure`. Check zero Vulkan validation errors. - [ ] **Step 5: Commit** `Share typed multi-light shading across Direct and GPU paths`. ### Task 3: Pure CPU shadow planning, caster visibility, and capacity policy **Files:** Create `include/faset/render/lighting.hpp`, `src/render/lighting.cpp`, `tests/render_lighting_policy_tests.cpp`; modify `cmake/Renderer.cmake` and `src/render/renderer.cpp` only to call the policy after it is tested. **Interfaces:** `build_shadow_plan(const Snapshot&, std::span, ShadowBudget) -> ShadowPlan` returns ordered `ShadowView` records (sun cascade 0–3, spot one, point six), stable tile indices, caster indices, per-view update reason, requested/effective counts, and dropped-reason counters. `ShadowBudget` defaults to four sun views, sixteen local tiles, 4096 caster draws, 128 local lights, and 2048² atlas dimensions. `ShadowCasterBounds` contains source-mesh world AABB and draw index. No Vulkan handle enters this module. - [ ] **Step 1: Write and register failing policy tests.** Add `faset_render_lighting_policy_tests` and `add_test(NAME render_lighting_policy ...)` in `cmake/Renderer.cmake`. Assert practical split endpoints are increasing and end at `min(far,80)`; translating a camera by less than one cascade texel keeps the snapped projection origin fixed; a caster outside camera view but upstream of a receiver is included; a caster outside the shadow XY footprint is excluded. Fill 15 local tiles, then request one point light and assert no faces are scheduled while the light remains in the submitted lighting list with shadow validity false. Give a shadow view 4097 casters and assert it is skipped whole. Reverse input light order and assert allocations are unchanged. ```cpp auto plan = build_shadow_plan(snapshot, casters, ShadowBudget{}); require(plan.sun_views.size() == 4, "Explicit camera gets four cascades"); require(plan.local_faces_used <= 16 && plan.caster_draws <= 4096, "Shadow work stays within the configured budget"); require(plan.dropped_point_faces == 6 || plan.dropped_point_faces == 0, "Point shadow allocation is all-or-none"); ``` - [ ] **Step 2: Run policy test red.** Reconfigure, verify `render_lighting_policy` appears in `ctest --test-dir build/linux-debug -N`, build `faset_render_lighting_policy_tests`, then run `ctest --test-dir build/linux-debug --output-on-failure --no-tests=error -R '^render_lighting_policy$'` if compilation succeeds. Expected failure is the absent `lighting.hpp` interface or the first failing new assertion. - [ ] **Step 3: Implement the planner.** Use unjittered frustum corners and fixed λ=0.5 splits, enclosing-sphere square extents, two-texel guard, texel-snapped light XY, and conservative caster-derived light Z. Sort by explicit priority, projected influence, and stable ID. Choose entire views under tile/draw limits; never reuse an old tile if its owner/generation changes. Legacy Snapshot without `CameraFrustum` produces one reported sun view. If no atlas profile is usable, return an unshadowed plan with a reason. ```cpp ShadowPlan build_shadow_plan(const Snapshot& frame, std::span casters, const ShadowBudget& budget); ``` - [ ] **Step 4: Run focused policy and P2 visibility tests green.** `ctest --test-dir build/linux-debug --output-on-failure -R 'render_lighting_policy|visibility_policy|render_gpu_shadow'`. Preserve P2's offscreen caster fixture. - [ ] **Step 5: Commit** `Plan stable cascades and bounded shadow views independently of camera culling`. ### Task 4: Vulkan sun atlas and cascade sampling **Files:** Modify `src/render/renderer.cpp`, `shaders/baseline.slang`, `tests/render_tests.cpp`, `tests/render_gpu_acceptance_tests.cpp`; create `tests/render_lighting_gpu_tests.cpp` and register `render_lighting_sun` as `gpu;p3` in `cmake/Renderer.cmake` or a focused `cmake/LightingAcceptance.cmake`. **Interfaces:** `ShadowPlan::sun_views` supplies four 1024² tile viewports and matrices to one `ShadowAtlases` RenderGraph pass. `LightingHeader` and `ShadowViewGpu` provide split depths, tile transforms, and valid flags to `fragmentMain`; set 0 binding 0 points to the sun atlas. `FrameStats` reports requested/effective cascade count, sun tiles, caster draws, atlas bytes, and aggregated `gpu_sun_shadow_ms`. - [ ] **Step 1: Write and register failing sun image tests.** Register `render_lighting_sun` with `LABELS "gpu;p3"` in CMake before its red run. Make a receiver cross the first two split distances; require shadow continuity across the blend band. Move the camera by subtexel and whole-texel steps; require stable then updated shadow edges. Move/disable an upstream offscreen caster and require affected receiver pixels to change. Assert four effective cascades for explicit camera and one reported fallback for a legacy low-level Snapshot. Run Direct and GPU frustum; compare the final frames within the existing P2 image tolerance. ```cpp require(renderer.stats().effective_sun_cascades == 4, "Explicit 3D camera uses four sun cascades"); std::size_t darker = 0; for (std::size_t i = 0; i < with_caster.size(); i += 4) darker += without_caster[i] > with_caster[i] + 12; require(darker > 20, "Offscreen caster affects a visible cascade receiver"); ``` - [ ] **Step 2: Run new GPU test red.** Reconfigure, verify `render_lighting_sun` appears in `ctest --test-dir build/linux-debug -N`, and build its test executable. Run `ctest --test-dir build/linux-debug --output-on-failure --no-tests=error -R '^render_lighting_sun$'`; an old single fixed shadow projection must fail the new count/image assertions. A missing target is setup failure, not green. - [ ] **Step 3: Render and sample the sun atlas.** Check D32 sampled/depth-attachment format support and image limits; allocate 2048² or 1024² fallback. Transition to depth attachment once, loop tile rendering with per-tile `renderArea`, clear, viewport and scissor, push each matrix, draw only that view's caster list; transition once to depth read-only. Map projected XY into guarded tile UV, clamp every PCF tap, apply slope-aware bias, choose/blend cascades from unjittered depth. When recreating the atlas, rewrite set-0 binding 0 for every live material descriptor before retiring the old image view. Increase the fixed timestamp query capacity before adding pass labels so aggregate timing does not silently disappear. ```cpp graph.add("ShadowAtlases", {}, {"sun_shadow", "local_shadow"}, [&] { // For each scheduled view: clear only its renderArea, set tile viewport/scissor, // push its view_projection, draw exactly plan.caster_indices. }); ``` - [ ] **Step 4: Run sun, existing shadow, reload, P2, and Vulkan validation tests green.** `ctest --test-dir build/linux-debug --output-on-failure -R 'render_lighting_sun|render_offscreen|render_shader_reload|render_gpu_shadow'` and the full `-L p2` suite. Save difference frames before adjusting any threshold. - [ ] **Step 5: Commit** `Render stable cascaded sun shadows into a bounded atlas`. ### Task 5: Point/spot atlas, face scheduling, and observability **Files:** Modify `src/render/renderer.cpp`, `shaders/baseline.slang`, `src/editor/debug_overlay.cpp`, Player profile/diagnostics source, `include/faset/render/renderer.hpp`, `tests/render_lighting_gpu_tests.cpp`, `tests/editor_debug_overlay.cpp`, `tests/player_diagnostics_test.py`, and acceptance CMake registration. **Interfaces:** `ShadowPlan::local_views` supplies one spot or six point faces per assigned light. The shader resolves a point face from the dominant light-to-fragment axis and samples only a valid assigned tile. `FrameStats`/Player/editor expose submitted and omitted local lights, requested and effective faces, tile occupancy, dropped-reason counts, caster draws, atlas bytes, shadow GPU time, and actual lighting path. The common fallback is unshadowed local lighting. - [ ] **Step 1: Write and register failing image/diagnostic tests.** Register `render_lighting_local` with `LABELS "gpu;p3"`; extend existing Player diagnostics assertions. A spotlight lights inside its outer cone but not outside; a point light lights six cube-face directions and has no seam-caused bright leak at a face boundary. A caster darkens a nearby receiver; disabling its `casts_shadow` restores light. Fill all local tiles and verify the overflow light still contributes unshadowed. Exercise 15 occupied tiles plus one point; assert six faces are dropped and no stale tile is sampled. Verify editor/Player labels use actual effective counts and explicit reasons. ```cpp require(stats.local_shadow_faces <= 16 && stats.shadow_caster_draws <= 4096, "Rendered local shadow work obeys both budgets"); require(stats.dropped_shadow_faces == 6, "Insufficient atlas room disables a whole point shadow"); require(over_capacity_point_pixels[receiver_pixel] > no_point_pixels[receiver_pixel] + 12, "Unshadowed point light still illuminates its receiver"); ``` - [ ] **Step 2: Run the registered GPU and Player cases red.** Reconfigure and verify `render_lighting_local` appears in `ctest --test-dir build/linux-debug -N`; build the test executable and run `ctest --test-dir build/linux-debug --output-on-failure --no-tests=error -R '^(render_lighting_local|player_shutdown_diagnostics)$'`. For the optional overlay, which is absent from the default `linux-debug` preset, configure `cmake -S . -B build/p3-debug-overlay -G Ninja -DFASET_DEBUG_IMGUI=ON -DBUILD_TESTING=ON -DCMAKE_BUILD_TYPE=Debug -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++`, build `faset_debug_overlay_tests`, verify it appears in that build's `ctest -N`, then run `ctest --test-dir build/p3-debug-overlay --output-on-failure --no-tests=error -R '^editor_debug_overlay$'`. Red must come from the new assertions, not from an absent optional target. - [ ] **Step 3: Render local faces and sample them.** Allocate/clear local atlas tiles as in Task 4; assign six 90° views atomically for point lights and one cone view for spots. Bind the local atlas at set 1 binding 3. Apply per-tile PCF guard/clamp and correct face projection; skip sampling on `valid=false`. Report fallback when optional sampled D32 or atlas allocation is unavailable. Record conservative redraw reasons rather than claiming cached depth without invalidation proof. ```slang if (light.shadowFaceCount == 6 && light.shadowValid != 0) visibility = samplePointAtlas(light, worldPosition, normal); // An unassigned light always uses visibility = 1, but still illuminates. ``` - [ ] **Step 4: Run local image and diagnostics tests under Khronos validation.** `ctest --test-dir build/linux-debug --output-on-failure --no-tests=error -R '^(render_lighting_local|player_shutdown_diagnostics|render_shader_reload)$'`; `ctest --test-dir build/p3-debug-overlay --output-on-failure --no-tests=error -R '^editor_debug_overlay$'`; then `ctest --test-dir build/linux-debug -L p3 --no-tests=error --output-on-failure`. Run the point-face seam fixture on Linux physical GPU and pinned SwiftShader. - [ ] **Step 5: Commit** `Add bounded point and spot shadows with explicit fallback diagnostics`. ### Task 6: Release measurement and conditional Forward+ **Files:** Create `examples/renderer/p3_lighting_benchmark.cpp`, `tools/benchmark_p3_lighting.py`, `tests/test_p3_lighting_benchmark.py`, raw CSV under `docs/studies/data/`, and `docs/studies/22-p3-lighting-benchmark-2026-09-24.md`; modify `cmake/Renderer.cmake` and, only if the gate triggers, `shaders/gpu_scene.slang` or a focused new Slang file, `src/render/renderer.cpp`, `src/render/shader_contract.cpp`, `src/editor/build_service.cpp`, shader/package tests, and `tests/render_lighting_gpu_tests.cpp`. **Interfaces:** Benchmark `--lights 0|4|16|32|64|128 --shadows on|off --visibility direct|gpu-frustum|gpu-occlusion --csv PATH` at 1920×1080, fixed scene/camera, ten warm-up and thirty measured frames, three independent runs. The Python wrapper has `--list-runs` for a fast configuration-contract test and `--sweep` for the full offline measurement; the registered CTest schema smoke invokes one 64×64, one-frame benchmark, not the full sweep. CSV includes commit, device/driver, mode, light count, GPU forward/shadow/total milliseconds, CPU render/readback milliseconds, atlas use, draw counts, validation errors. If the spec threshold triggers, tiled Forward+ uses 16×16 screen tiles with an overflow flag; an overflowing tile evaluates all submitted lights. - [ ] **Step 1: Write and register benchmark/overflow contract checks.** Register `render_lighting_benchmark_schema` in CMake to run `tests/test_p3_lighting_benchmark.py` against the benchmark executable. Use `--list-runs` to verify the 0/4/16/32/64/128 sweep yields 18 mode×light configurations and 54 independent runs per shadow setting; use one 64×64 frame to verify every CSV row includes the effective lighting path. If tiled mode is needed, write an image case with more than the per-tile index capacity and compare against the full-light reference to ensure no missing illumination. ```cpp double absolute_error = 0; std::size_t large_error = 0; for (std::size_t i = 0; i < tiled_pixels.size(); i += 4) for (std::size_t channel = 0; channel < 3; ++channel) { const auto delta = std::abs(int(tiled_pixels[i + channel]) - int(full_scan_pixels[i + channel])); absolute_error += delta; large_error += delta > 16; } const double samples = 3.0 * (tiled_pixels.size() / 4); require(absolute_error / samples <= 2.0 && large_error / samples <= 0.005, "Forward+ overflow scans all local lights instead of dropping any"); ``` - [ ] **Step 2: Run the new checks red.** Reconfigure; verify `render_lighting_benchmark_schema` appears in `ctest --test-dir build/linux-debug -N`; build `faset_p3_lighting_benchmark` and run `ctest --test-dir build/linux-debug --output-on-failure --no-tests=error -R '^render_lighting_benchmark_schema$'`. Expected failure is a missing benchmark interface/output or effective-path CSV column, never an empty CTest selection. - [ ] **Step 3: Implement and run the fixed-scene benchmark.** Record Linux Release physical GPU and pinned SwiftShader functional runs separately. Do not use whole-render CPU time as a proxy for fragment cost because `Renderer::render` always performs synchronous framebuffer readback. - [ ] **Step 4: Apply the objective gate.** If at 32, 64, or 128 local lights the median main-raster overhead versus zero lights is at least 1.0 ms or at least 15% of the light-free GPU frame on the Linux reference GPU, implement and verify depth-free 16×16 tiled Forward+ from conservative projected light volumes and record before/after *build + raster* time. If the threshold is not reached, keep the simple path, record the measured reason, and keep the CSV harness. New compute shader entries require exact reflection validation, CMake outputs, Editor build-service copy lists, and exported Player bundle tests. ```text gate = max_over_32_64_128(Δmain_raster_p50 >= 1.0 ms OR Δmain_raster_p50 >= 0.15 × gpu_frame_zero_lights_p50) ``` - [ ] **Step 5: Re-run both paths on the same frames.** Compare image output, zero validation errors, and measured GPU construction+raster cost. Keep Direct and P2 modes correct regardless of chosen default. Retain all raw CSV and methodology in the study; never claim universal speedup from one device. - [ ] **Step 6: Commit** `Measure P3 light scaling and select a verified lighting path`. ### Task 7: End-to-end acceptance, Manual, and public evidence **Files:** Modify `docs/manual/editor/diagnostics.md`, `docs/manual/editor/profiling.md`, `mkdocs.yml`, `PLAN.md`, `docs/IMPLEMENTATION.md`, `docs/validation/README.md`; create `docs/manual/editor/lighting.md`, `docs/validation/p3-lighting-2026-09-24/README.md`, raw test logs/report files. Update Windows CI files only if the existing GPU-labeled suite does not pick up P3 cases. **Interfaces:** The Manual explains light kinds/properties, sun shadow distance, atlas face cost and overflow, shadow/camera behavior, editor and MCP authoring examples, and how to read actual diagnostics. The validation dossier names commit, exact Linux/Windows platform and driver, test counts, benchmark raw CSV paths, known limitations, and links to CI. P3 acceptance is an evidence claim, not a checkbox based only on compiling code. - [ ] **Step 1: Write acceptance cases before the final run.** Use fixed scenes for 0/1/many lights, moving sun/caster, camera pan/cut/resize, a thin receiver at a cascade split, offscreen caster, six point faces, 16-tile and 4096-draw capacity, an unsupported-atlas fallback, Direct/GPU frustum/occlusion equivalence, shader reload, and independent 2D/UI output. - [ ] **Step 2: Run Linux Debug and Release checks.** `cmake --build --preset linux-debug --parallel 4`; `ctest --preset linux-debug --output-on-failure`; `cmake --build --preset linux-release --parallel 4`; `ctest --test-dir build/linux-release --output-on-failure`. Run pinned Linux SwiftShader `ctest -L p3` and the same cases on a physical Vulkan GPU with validation enabled; record actual skips and layer availability. - [ ] **Step 3: Run Windows native and software-Vulkan CI.** Verify every `gpu;p3` test executes on pinned SwiftShader, shader reflection/package tests pass, and relocated 2D/3D exported Release Players run at least 120 frames. Publish the exact GitHub Actions run links, test logs, and exported-game report. Do not describe this as physical Windows-GPU validation unless that device was run. - [ ] **Step 4: Finish the English Manual and evidence.** Link `docs/manual/editor/lighting.md` in `mkdocs.yml`; include authoring JSON/Inspector and script examples, priorities, budgets, actual fallback and default path. Run `python -m mkdocs build --strict`, `git diff --check`, and the updated validation index link check. Update `PLAN.md` only for features whose stated acceptance evidence is present. - [ ] **Step 5: Review and commit** `Validate and document P3 lighting and shadows`; after independent code review, publish the completed checkpoint to GitHub and Gitea as previously authorized.