Files
Faset_Engine/docs/superpowers/specs/2026-09-24-p3-lighting-design.md
T

14 KiB
Raw Blame History

P3 lighting and shadows design

This design implements the lighting and shadow portion of PLAN P3 for desktop 3D games. Temporal reconstruction has a separate design and implementation plan. The P2 Direct, GPU frustum, and GPU occlusion paths must shade the same scene from the same immutable snapshot. Ordered sprites, editor UI, and 2D presentation remain unlit unless a later 2D-lighting project explicitly changes them.

Existing behavior and target

Snapshot currently carries one light_direction; SceneView::build overwrites it for each faset.light, without using the authored color or intensity. fragmentMain evaluates one hard-coded directional BRDF and samples one 1024² D32 shadow map. Its shadow matrix covers a fixed world-origin orthographic box. The shadow pass renders all cast_shadow meshes through CPU-transformed geometry even when P2 GPU visibility is selected; it is independent of the camera visibility decision. The compiled gpuShadowMain is not used by the current pass. These facts make multiple authored lights, large camera movement, and explicit shadow budgets impossible without changing the scene and shader contracts.

P3 supplies one sun plus point and spot lights, camera-fitted cascaded sun shadows, a bounded local shadow atlas, and independent per-shadow-view caster selection. Authored values reach exported Player games and the editor preview through the existing scene schema. A scene with no light component retains the previous default sun appearance. Lighting is accumulated in linear space before the current tone mapping; the legacy directional factor is preserved for the default so existing unlit UI and basic rendering tests remain meaningful. Artist-facing intensity is unitless in this stage; photometric units and IES profiles are outside P3.

Scene contract and validation

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, 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.

Graphics shader ABI and ownership

Preserve material descriptor set 0 and its four bindings: sampled sun depth image at binding 0 (formerly the single shadow map), shadow sampler at 1, color image at 2, color sampler at 3. Add frame lighting descriptor set 1 with a StructuredBuffer of a versioned, 16-byte-lane lighting header and local-light records plus one sampled 2D local depth atlas. Both the Direct pipeline and P2 GPU graphics pipeline bind this frame set; their fragment stage remains fragmentMain in shaders/baseline.slang. Move the P2 GPU vertex scene bindings (instance, visible-ID, view buffers) from set 1 to set 2 in shaders/gpu_scene.slang, the Vulkan graphics layout, and the exact reflection validator. P2 cull and HZB compute bindings stay in their current set 0. Baseline graphics push constants remain 96 bytes and P2 graphics push constants remain 112 bytes, under the Vulkan 1.3 guaranteed 128-byte minimum. The shadow vertex entry can continue to read a per-view matrix through the existing push block, so no new shader entry is required for atlas rasterization.

Host and Slang structs have named offsets, strides, static assertions, and reflection checks. Keep the SPIR-V + normalized Faset reflection package as the runtime boundary; slangc remains a build dependency, not a Player dependency. Existing shader-reload behavior must preserve working pipelines when an incompatible or invalid bundle is offered. Update the package validator and export checks whenever an entry or layout changes. Lighting buffers are renderer-owned per frame and never expose Vulkan types to authoring, gameplay, or MCP.

Sun cascades and atlas

Use four practical-split cascades between the camera near plane and min(camera far, 80 world units) with λ = 0.5. Bound each receiver frustum slice by a stable square extent derived from its enclosing sphere in sun-light space; snap the XY projection center to its effective texel grid so small camera movement does not shimmer. The sun atlas is one 2048² D32 image with four 1024² tiles, including a two-texel guard inside each tile. Its C++/shader metadata contains each light VP, split end, atlas scale/offset, effective UV clamp, and bias. Select the cascade by unjittered view depth and blend over the final 5% of a split to avoid a hard line. Three-by-three PCF samples clamp to that tile's guarded interior. Atlas rendering clears each tile independently and samples it only after the whole atlas transitions from depth attachment to depth read-only. The split/fitting, atlas, blend, PCF guard, and depth-bias considerations follow Microsoft's CSM technical article and shadow depth-map guidance; Faset's actual implementation remains Vulkan-native.

The receiver frustum determines a cascade's XY footprint, but casters upstream of the camera slice still matter. Build shadow-view visibility from world-space caster bounds, never the main camera's P2 visible IDs. Extend the light-space depth interval conservatively over all cast-shadow bounds overlapping the cascade's XY footprint, then test candidates against that resulting view. Do not truncate this interval by the main camera far plane. Shadow geometry uses source LOD 0 initially, independent of the main camera's selected mesh LOD; a measured shadow-LOD policy can follow later.

Local shadows and budgets

The local atlas is a separate 2048² D32 image with sixteen 512² guarded tiles. A spot light uses one perspective shadow view; a point light uses six 90° face views and receives all six tiles or none. A shadow tile stores its owner stable ID/face and generation. Rank candidate shadowed lights by authored shadow_priority, then projected influence, then stable ID; keep allocations stable while their owners remain eligible. Lower-priority lights that do not fit still illuminate the scene without shadowing, with an explicit reason/counter. No partial point-light shadow cubemap is allowed.

At most 128 local lights are submitted to the shader per view; select them deterministically by priority/influence if more exist and report the count of omitted lights. At most 16 local shadow faces and 4096 caster draws per frame are scheduled. Shadow views are atomic for draw budgeting: if all casters for a view do not fit, skip that view and treat its contribution as unshadowed. Sun cascades are considered nearest first; local views follow by priority. The policy exposes requested and effective counts. If the nearest cascade itself exceeds the draw budget, it becomes unshadowed rather than emitting an incomplete shadow. No skipped tile is sampled. Atlas allocation is capped at 32 MiB of D32 image payload for both atlases, excluding Vulkan alignment/driver overhead; actual allocation bytes remain visible in FrameStats.

Check D32 sampled/depth-attachment support, 2048² image extent, relevant sampled-image/storage-buffer limits, and allocation results before enabling the corresponding shadow feature. If the 2048² profile is unavailable, try a 1024² profile with four 512² sun tiles and sixteen 256² local tiles. If neither profile is available, keep direct lighting and explicitly report shadows unavailable. A failed optional atlas must not silently disable local illumination or crash a scene that previously rendered. Maintain explicit Vulkan transitions and barriers; the current RenderGraph validates ordering but does not synthesize barriers. One ShadowAtlases graph pass may loop over tile rendering instances because graph pass names must be unique.

Every scheduled view records why it was redrawn (first use, light/camera/caster change, atlas reassignment, or conservative every-frame update), its caster count, tile, and GPU time. Caching is optional in this stage: reporting an every-frame update is preferable to reusing stale depth. If caching is introduced, an uncertain caster revision forces a redraw. Camera cuts, resolution changes, light deletion, and slot reuse invalidate relevant assignments and histories.

Forward+ measurement gate and diagnostics

Start with a correct bounded loop over local lights in the fragment shader. Benchmark fixed 1080p scenes with 0, 4, 16, 32, 64, and 128 visible local lights in Release, with shadows separately disabled and enabled. Retain raw per-frame CSV, driver/GPU/commit/configuration, warm-up and three repeated runs, median and p95 of main raster, shadow passes, full GPU command span, and capture/readback CPU time. The current full framebuffer readback is synchronous, so its CPU cost is not attributed to light shading. If 32 or more lights add at least 1.0 ms median to main raster or at least 15% of the light-free GPU frame on the Linux reference GPU, add tiled Forward+; otherwise record why the simple path remains default and keep the measurement harness for later hardware. A conservative, depth-free 16×16 screen-tile list built from projected light volumes is sufficient for the first measured optimization; it does not require a depth prepass before the existing forward pass. No light may disappear on list overflow: the fragment path checks an overflow flag and scans the full submitted-light list for that tile. Measure the sum of list construction and raster, not the raster pass alone, before enabling tiled mode by default.

FrameStats, Player diagnostics, and the editor debug overlay expose requested/effective sun cascades, visible and omitted local lights, scheduled/dropped shadow faces and reasons, atlas occupancy and allocated bytes, caster draws, shadow GPU time, and the active lighting path. Counters remain diagnostic-only; they do not require GPU readback for scheduling. Shadow atlas thumbnails may be added to developer diagnostics but are not a prerequisite for functional lighting.

Acceptance and integration boundaries

CPU tests cover schema defaults/invalid fields, stable extraction from reordered entities, shadow-view math, cascade split/texel snap, offscreen casters, deterministic atlas allocation and face/draw capacity, point-light all-or-none behavior, and fallback capability policy. GPU image tests cover point distance, spot cone, color/intensity, sun cascade boundaries, caster movement, camera translation/cut/resize, point seams and atlas tile isolation, overflow as unshadowed light, and Direct vs P2 frustum/occlusion equivalence. Shader reflection, hot reload, exported Player shader contents, and Vulkan validation are exercised on Linux physical GPU and pinned Linux SwiftShader; Windows native build, GPU-labeled CI tests, and relocated exported Player examples run on pinned Windows SwiftShader. A physical Windows GPU is reported only if actually tested. The manual documents light properties, budgets, expected fallbacks, and script/editor examples. Acceptance records commit, hardware/driver, raw benchmark data, tests, known limitations, and links from PLAN.md without claiming universal speedups.

Temporal reconstruction owns previous transforms, velocity targets, jitter, history rejection, TAA, and upscaling. Lighting owns only stable unjittered shadow views and direct-light data. The shared files renderer.hpp, renderer.cpp, and baseline.slang need sequenced integration or an agreed ABI commit before concurrent feature work; temporal code must not repurpose the lighting descriptors or shadow camera metadata.