30 KiB
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=errorwith exact-Rnames, 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<SunLight> Snapshot::sun, std::vector<LocalLight> Snapshot::local_lights, bool Snapshot::authored_lights_present (default false), and std::optional<CameraFrustum> 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 == falseand 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; invalidrange <= 0,inner_angle > outer_angle, nonfinite color/transform, and unknown kind identify the entity/field. An essential assertion is: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-debugandcmake --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.valuefor 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. Setcamera_frustumfrom the same unjittered view/projection used to formview_projection. Update direct C++ API examples to construct one point and one spot light.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<LightingHeader> (one record), 1=StructuredBuffer<LocalLightGpu> (minimum one allocated record even when count zero), 2=StructuredBuffer<ShadowViewGpu> (minimum one record), 3=Texture2D<float> 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, andrender_offscreencases; 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.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 insiderender_offscreenmust reject missing set 1/2 behavior. Confirm all four expected cases inctest --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.
[[vk::binding(0,1)]] StructuredBuffer<LightingHeader> lightingFrame; [[vk::binding(1,1)]] StructuredBuffer<LocalLightGpu> localLights; [[vk::binding(2,1)]] StructuredBuffer<ShadowViewGpu> shadowViews; [[vk::binding(3,1)]] Texture2D<float> 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 focusedctestregex above andctest --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<const ShadowCasterBounds>, 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_testsandadd_test(NAME render_lighting_policy ...)incmake/Renderer.cmake. Assert practical split endpoints are increasing and end atmin(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.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_policyappears inctest --test-dir build/linux-debug -N, buildfaset_render_lighting_policy_tests, then runctest --test-dir build/linux-debug --output-on-failure --no-tests=error -R '^render_lighting_policy$'if compilation succeeds. Expected failure is the absentlighting.hppinterface 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
CameraFrustumproduces one reported sun view. If no atlas profile is usable, return an unshadowed plan with a reason.ShadowPlan build_shadow_plan(const Snapshot& frame, std::span<const ShadowCasterBounds> 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_sunwithLABELS "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.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_sunappears inctest --test-dir build/linux-debug -N, and build its test executable. Runctest --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.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 p2suite. 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_localwithLABELS "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 itscasts_shadowrestores 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.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_localappears inctest --test-dir build/linux-debug -N; build the test executable and runctest --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 defaultlinux-debugpreset, configurecmake -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++, buildfaset_debug_overlay_tests, verify it appears in that build'sctest -N, then runctest --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.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$'; thenctest --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_schemain CMake to runtests/test_p3_lighting_benchmark.pyagainst the benchmark executable. Use--list-runsto 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.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_schemaappears inctest --test-dir build/linux-debug -N; buildfaset_p3_lighting_benchmarkand runctest --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::renderalways 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.
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 SwiftShaderctest -L p3and 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;p3test 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.mdinmkdocs.yml; include authoring JSON/Inspector and script examples, priorities, budgets, actual fallback and default path. Runpython -m mkdocs build --strict,git diff --check, and the updated validation index link check. UpdatePLAN.mdonly 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.