Author SHA1 Message Date
Emil f32e4b4058 Document Forward+ measurements and Linux lighting evidence
Native and manual checks / native (ubuntu-24.04) (push) Failing after 35s
Native and manual checks / manual (push) Successful in 27s
Native and manual checks / native (windows-2025) (push) Canceled after 0s
Windows editor and software Vulkan / windows-graphics (push) Canceled after 0s
2026-09-24 03:46:40 +03:00
Emil a0a4e29d48 Add measured optional tiled Forward+ lighting
Native and manual checks / native (ubuntu-24.04) (push) Failing after 36s
Native and manual checks / manual (push) Successful in 25s
Windows editor and software Vulkan / windows-graphics (push) Canceled after 0s
Native and manual checks / native (windows-2025) (push) Canceled after 0s
2026-09-24 03:37:04 +03:00
Emil 2dfff62f8c Document P3 lighting and provisional acceptance 2026-09-24 03:02:44 +03:00
Emil b191ae0bed Report measured P3 shadow allocation and GPU time
Native and manual checks / native (ubuntu-24.04) (push) Failing after 32s
Native and manual checks / manual (push) Successful in 28s
Windows editor and software Vulkan / windows-graphics (push) Canceled after 0s
Native and manual checks / native (windows-2025) (push) Canceled after 0s
2026-09-24 02:54:37 +03:00
Emil a428a33cb0 Record build profile and temporal raster cost in lighting benchmark 2026-09-24 02:53:01 +03:00
Emil ac50ac8855 Check real lighting benchmark output and driver identity 2026-09-24 02:53:01 +03:00
Emil 6c29e177c6 Measure P3 lighting sweep and Forward+ threshold 2026-09-24 02:53:01 +03:00
Emil 2773f0b75c Add repeatable P3 lighting renderer benchmark 2026-09-24 02:53:01 +03:00
Emil a5fb2167d6 Add bounded point and spot shadows with fallback diagnostics 2026-09-24 02:51:11 +03:00
Emil 252f7e2e91 Render stable cascaded sun shadows into a bounded atlas 2026-09-24 02:37:42 +03:00
Emil 36a44e14ca Plan bounded sun and local shadows from source casters 2026-09-24 02:26:53 +03:00
Emil cfcfdab949 Bind shared lighting ABI and shade authored local lights 2026-09-24 01:53:14 +03:00
Emil 674e3e812b Expose authored sun, point, and spot lights in render snapshots 2026-09-24 01:35:20 +03:00
50 changed files with 5505 additions and 141 deletions
+13 -1
View File
@@ -199,7 +199,19 @@ GPU instance record содержит стабильные slot/generation; пл
### P3. Освещение, тени и temporal reconstruction
Расширить local lights, добавить clustered/Forward+ при измеренной необходимости, cascaded sun shadows и ограниченный local shadow atlas. Shadow views имеют собственную видимость и бюджеты.
**Освещение, тени и измеренный выбор пути реализованы; приёмка всего P3 ещё
открыта.** Есть authored directional/point/spot lights, общий shader ABI для
Direct и P2, четыре каскада солнца, отдельный 16-face atlas для point/spot,
видимость каскадеров из shadow views и общий бюджет 4096 caster draws.
Ранжирование 128 local lights, атомарный отказ от шести point faces и
unshadowed fallback доступны с диагностикой. На Linux reference GPU Release
1920×1080 измеренный рост стоимости main raster превысил порог для проверки
Forward+. Depth-free tiled путь 16×16 прошёл image parity, но полный build +
raster на плотной контрольной сцене оказался медленнее; `Auto` оставлен на
forward, явный tiled доступен для локализованных источников и выиграл в
отдельном сценарии. [Исследование](docs/studies/23-p3-forward-plus-2026-09-24.md)
и [протокол проверки](docs/validation/p3-lighting-2026-09-24/README.md)
отделяют этот Linux checkpoint от финальной Windows/temporal приёмки.
Затем: previous transforms, motion vectors, jitter, history rejection и TAA; temporal upscaling — после устойчивого TAA. Проверять тонкую геометрию, движение, disocclusion, camera cut и смену разрешения, сравнивать с режимом без temporal. У cache/pass видны затраты и причины обновления.
+41 -2
View File
@@ -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<double> values) {
if (values.empty())
@@ -96,7 +97,43 @@ Json profileFrames(const std::vector<ProfileSample>& 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)},
{"gpu_light_tiles_ms",
gpuMeasured ? Json(sample.lighting.gpu_light_tiles_ms) : Json(nullptr)},
{"light_tile_count", sample.lighting.light_tile_count},
{"light_tile_counts_valid", sample.lighting.light_tile_counts_valid},
{"light_tile_candidate_count", sample.lighting.light_tile_counts_valid
? Json(sample.lighting.light_tile_candidate_count) : Json(nullptr)},
{"light_tile_overflow_count", sample.lighting.light_tile_counts_valid
? Json(sample.lighting.light_tile_overflow_count) : Json(nullptr)}});
}
return {{"samples", std::move(frames)},
{"summary_ms",
@@ -587,7 +624,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 +657,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"},
+33 -1
View File
@@ -17,6 +17,14 @@ foreach(FASET_ENTRY vertexMain fragmentMain shadowMain)
DEPENDS "${PROJECT_SOURCE_DIR}/shaders/baseline.slang" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py" VERBATIM)
list(APPEND FASET_SHADER_OUTPUTS "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.reflection.json")
endforeach()
set(FASET_SHADER_OUTPUT "${FASET_SHADER_DIRECTORY}/lightTileMain.spv")
add_custom_command(OUTPUT "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/lightTileMain.reflection.json"
COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py"
--compiler "${SLANGC_EXECUTABLE}" --source "${PROJECT_SOURCE_DIR}/shaders/light_tiles.slang"
--entry lightTileMain --output "${FASET_SHADER_DIRECTORY}"
BYPRODUCTS "${FASET_SHADER_DIRECTORY}/lightTileMain.slang-reflection.json"
DEPENDS "${PROJECT_SOURCE_DIR}/shaders/light_tiles.slang" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py" VERBATIM)
list(APPEND FASET_SHADER_OUTPUTS "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/lightTileMain.reflection.json")
foreach(FASET_ENTRY gpuVertexMain gpuShadowMain gpuCullMain gpuHzbMain gpuPostCullMain)
if(FASET_ENTRY STREQUAL "gpuVertexMain" OR FASET_ENTRY STREQUAL "gpuShadowMain")
set(FASET_GPU_DEFINE FASET_GPU_GRAPHICS=1)
@@ -40,13 +48,25 @@ 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_test(NAME render_lighting_tiled COMMAND faset_render_lighting_gpu_tests --tiled)
set_tests_properties(render_lighting_tiled 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 +98,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="$<CONFIG>")
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 "$<TARGET_FILE:faset_p3_lighting_benchmark>")
set_tests_properties(render_lighting_benchmark_smoke PROPERTIES LABELS "gpu;p3" TIMEOUT 90)
endif()
install(FILES ${FASET_SHADER_OUTPUTS} DESTINATION shaders)
+63
View File
@@ -466,3 +466,66 @@ rendered 120 frames in Direct mode. The corresponding
[native/manual CI run](https://github.com/emil28092005/Faset_Engine/actions/runs/35922643004)
passed on Linux and Windows. Unsupported-HZB integration and physical Windows
GPU coverage remain untested.
## P3 lighting checkpoint — authored lights and bounded shadow views
At source revision `b191ae0`, the versioned `faset.light` schema and SceneView
extract directional, point, and spot lights. Any authored Light, even disabled,
suppresses the compatibility sun; scenes without a Light keep their previous
appearance. The renderer validates all local records, then selects at most 128
by priority, projected influence, and stable ID. A single typed lighting
descriptor ABI serves Direct and P2 GPU graphics: materials remain set 0,
lighting is set 1, GPU scene graphics data moves to set 2, and existing push
constant sizes remain unchanged. Both paths shade the same sun/local PBR lights
before tone mapping.
A pure CPU shadow planner builds up to four texel-snapped sun cascades from an
explicit camera frustum, ending at at most 80 world units; a low-level Snapshot
without the frustum keeps one shadow view. Shadow caster bounds come from the
source LOD-0 draw and are tested against the light view, independently of
camera/P2 culling. The Vulkan backend renders the sun to its own D32 atlas and
point/spot shadows to a separate 4×4 D32 atlas. A point light claims six faces
atomically, a spot one. Both atlases try 2048² and then 1024² if required by
capabilities or allocation. The combined frame budget is 4096 caster draws;
scheduled tiles are cleared and redrawn each frame. Overflow, disabled shadow,
or unavailable atlas leaves a submitted light illuminating without shadow.
There is no hidden sun raster when the sun is absent, its shadow is disabled, or
the scene only has sprites. Atlas ownership, dropout, submitted light counts,
actual raster work and GPU timings are exposed in `FrameStats`, Player profiles
and the optional Editor diagnostics overlay.
The implementation's Linux Debug checkpoint at `a5fb216` built all targets and
ran 60 CTests with no failures; the existing native window lifecycle test
skipped under the compositor. The optional ImGui overlay passed its dedicated
test in an enabled build. After benchmark integration at `b191ae0`, six focused
tests passed, including the real Vulkan benchmark smoke. These are bounded
checks, not a final P3 acceptance run. The [lighting validation record](validation/p3-lighting-2026-09-24/README.md)
lists cases, exact revision, and remaining Windows/Release evidence.
The fixed-scene Release reference-GPU sweep uses 1920×1080, 0/4/16/32/64/128
lights, Direct/GPU frustum/GPU occlusion, shadows on/off, three independent
repeats, ten warm-up and thirty measured frames per configuration. It reached
the agreed Forward+ gate: main-raster overhead at 32 lights was about 0.50 ms
relative to the matching zero-light case, roughly 30% of that GPU frame;
64 and 128 lights added about 1.02 and 2.03 ms. Its raw CSV/report are being
published separately with the exact benchmark revision and driver.
Revision `a0a4e29` adds an explicit depth-free 16×16 tiled Forward+ path. One
compute invocation tests every submitted point/spot range sphere against a
tile's four screen-space planes and writes at most 64 stable-order indices.
An overflowing tile scans the entire submitted list in the fragment shader;
there is no dropped light. The tile shader has exact reflection validation,
package/build integration, GPU timing, optional occupancy readback, and
reload rollback. Direct, GPU frustum and GPU occlusion image tests cover a
cropped viewport, resize, near-plane light/shadow, and overflow. A 128-light
localized 1920×1080 forward/tiled capture matched byte for byte.
The [paired Release study](studies/23-p3-forward-plus-2026-09-24.md) measured
tile build **plus** raster on the same RTX 2080 Ti source revision and shader
bundle. At 32/64/128 broad overlapping lights it was 0.064/0.122/0.222 ms
slower; all 8160 tiles overflowed at 128. A separate localized-range scene
was 0.103/0.214/0.440 ms faster at those counts, with no overflows. There is
no robust scene/device runtime predictor yet, so `Auto` remains forward and
`Tiled` is explicit. Linux Debug passed 62 CTests with one window skip;
pinned Linux SwiftShader passed all six P3 cases. Windows CI on this new
revision and P3 temporal reconstruction are separate acceptance work.
+27 -1
View File
@@ -16,7 +16,33 @@ The panel reports the previous completed frame: renderer wall time, GPU timestam
In **GPU occlusion** mode, enable **Show HZB** to inspect the current grayscale depth pyramid. The **Mip** slider selects a pyramid level; the preview starts at mip 3 to keep its readback small. A larger mip number shows coarser depth. The preview reads the HZB only while the panel and toggle are open, and only once per completed frame or mip change. Switching it off or closing the panel releases the preview; its GPU texture retires when the next frame begins. Opening diagnostics also enables readback of GPU visibility counters, which is disabled again when the panel closes. Disable the HZB preview for performance comparisons: its diagnostic copy and texture upload add GPU and CPU work. **Freeze counters** does not freeze the HZB image.
The Vulkan backend emits `VK_EXT_debug_utils` labels for `ShadowMap`, `ForwardAndUI`, `Readback`, and, when presenting, `Presentation`. A graphics capture tool that supports this extension can identify those command-buffer regions. Labels remain available without the Khronos validation layer when the extension is exposed; unsupported systems continue rendering and report labels unavailable. A submitted-label count confirms calls were emitted, not that an external capture tool was tested.
The **Lighting and shadows** section reports the actual local lights submitted
and omitted, requested/effective sun cascades, requested/rasterized local faces,
allocated local tiles, and shadow caster draws against the 4096-draw limit.
Dropped-face counters distinguish a full atlas, caster budget, and unavailable
atlas; `point` counts faces dropped as a complete six-face group. A light whose
shadow faces are dropped still illuminates without a shadow. Atlas memory is the
live explicit Vulkan allocation size for the separate sun and local atlases.
When GPU timestamps are available, the panel shows sun and local shadow pass
durations. A zero duration after a disabled sun or sprite-only frame confirms
that no sun shadow raster ran. The lighting path names the algorithm actually
used, so compare it with a benchmark's requested mode before interpreting costs.
For an explicitly tiled frame, the overlay also reports the 16×16 grid size,
its compute GPU duration, the number of stored light candidates, and how many
tiles overflowed their 64-index list and scanned all lights. Candidate and
overflow counts require the diagnostics readback; **unavailable** is distinct
from a measured zero. The diagnostic copy itself adds work, so close the panel
before measuring performance.
See [Lighting](lighting.md) for the 128-light and 16-tile limits.
The Vulkan backend emits `VK_EXT_debug_utils` labels for `SunShadowAtlas`,
`LocalShadowAtlas`, `LightTileBuild` when tiled, `ForwardAndUI`, `Readback`, and, when presenting,
`Presentation`. A fallback frame can have no shadow-raster label. A graphics
capture tool that supports this extension can identify the command-buffer
regions. Labels remain available without the Khronos validation layer when
the extension is exposed; unsupported systems continue rendering and report
labels unavailable. A submitted-label count confirms calls were emitted, not
that an external capture tool was tested.
This module is disabled by default and is linked only to the graphical Editor and its dedicated test when enabled. Player and exported games do not link ImGui. No overlay control changes authoring documents, gameplay state or export settings.
+161
View File
@@ -0,0 +1,161 @@
# Light a 3D scene
Select an entity in the **Scene** tree, choose **+ Add Component** in the
**Inspector**, and add **Light**. Its Transform places a point or spot light. A
spot light points along the entity's local negative Z axis; a directional light
uses the entity's orientation. Lights affect 3D meshes in linear PBR shading
before tone mapping. Sprites and Editor UI remain unlit.
| Light kind | Coverage | Shadow cost |
| --- | --- | --- |
| `directional` | A sun-like direction, independent of position | Up to four cascade tiles |
| `point` | All directions within `range` | Six local-atlas tiles, assigned together |
| `spot` | A cone within `range` | One local-atlas tile |
The Light component's fields are:
| Field | Default | Meaning |
| --- | --- | --- |
| `kind` | `directional` | `directional`, `point`, or `spot` |
| `enabled` | `true` | A disabled light contributes no illumination |
| `color` | `[1, 1, 1, 1]` | RGB illumination color; alpha is part of the schema color value |
| `intensity` | `1` | Nonnegative brightness |
| `range` | `10` | Positive reach of point and spot lights |
| `inner_angle` | `0.35` | Full-strength spot cone half-angle, in radians |
| `outer_angle` | `0.7` | Outer spot cone half-angle, in radians; must be at least `inner_angle` |
| `casts_shadow` | `true` | Allow this light to use its shadow atlas |
| `shadow_priority` | `0` | Higher local-light selection and shadow priority; does not change brightness |
The Inspector validates the spot angles together. Their allowed outer limit is
below π/2 radians. A point light does not depend on the entity's rotation.
After editing the light or Transform, save the scene as usual. [Editor
workspace](workspace.md) explains Inspector editing, Undo, and save conflicts.
## Sun shadows and compatibility
With a 3D scene camera, a shadow-casting directional light uses four cascades
covering the camera near plane through at most **80 world units**, or the camera
far plane if it is closer. Faset blends samples near cascade splits and snaps
each shadow projection to texels to reduce shimmer during small camera moves.
Objects outside the camera view can still cast into a visible receiver: shadow
visibility uses each light's view and the source mesh's LOD 0, separately from
the main camera's Direct or GPU visibility result. A low-level renderer Snapshot
without an explicit camera frustum uses one compatibility sun view.
Only one enabled directional light is used. If there are several, Faset chooses
the one with the smallest stable entity ID and reports the ignored lights. A
scene with **no Light component** retains the older white sun. Adding any Light
component, including a disabled one, suppresses that compatibility sun. Thus a
local-only scene does not receive an unexpected directional light.
## Local shadow capacity and fallbacks
The renderer accepts at most **128** local lights per frame. It sorts candidates
by `shadow_priority` (highest first), then projected influence, then stable ID.
The `omitted_local_lights` counter reports lights beyond this limit; an omitted
light contributes no illumination. All authored light records are validated,
including candidates past the limit.
The separate local shadow atlas has **16 tiles**. A spot consumes one; a point
consumes six or none. Sun cascades and local shadows share a maximum of **4096
caster draws** per frame. A light whose shadow group does not fit the remaining
tiles or draw budget still illuminates, **without a shadow**. Disabling
`casts_shadow` also keeps illumination while skipping that light's shadow work.
The renderer reports requested faces, rendered faces, tiles, and drops by cause
in [Diagnostics](diagnostics.md) and the [Player profile](profiling.md).
Faset uses separate sampled D32 sun and local atlases, normally 2048×2048 pixels
each. If a device cannot use that size, the renderer tries 1024×1024; if a
sampled depth atlas cannot be created, the affected lights fall back to unshadowed
illumination and report unavailable shadow views. Scheduled atlas tiles are
cleared and redrawn each frame; there is no persistent shadow cache yet.
Sprite-only scenes, a missing sun, and a sun with `casts_shadow: false` skip sun
shadow raster work.
## Local-light rendering path
The normal `Auto` setting uses the measured forward light scan. It is the
current default for Editor and Player. A C++ renderer integration can explicitly
set `RendererConfig::lighting_mode = LightingMode::Tiled` to build depth-free
16×16 screen-tile lists on a capable Vulkan device. Each tile stores at most
64 light indices in stable order. If more lights touch a tile, its fragment
shader scans the complete submitted list, so an overflow never removes
illumination. The path falls back to forward when no local lights are present
or the compute/buffer requirements are unavailable. Sprites and UI stay unlit.
This explicit path can help when light ranges occupy small parts of the screen;
it costs extra work when nearly every light covers nearly every tile. The
fixed dense benchmark was slower after including tile construction, so there
is no automatic scene-dependent switch yet. The Player profile reports
`effective_lighting_path`, tile GPU time and grid size; optional Editor
diagnostics also report stored candidates and overflowing tiles. See
[Profiling](profiling.md) and the [measured Forward+ study](https://github.com/emil28092005/Faset_Engine/blob/main/docs/studies/23-p3-forward-plus-2026-09-24.md).
## Add a point light through MCP
MCP edits the **Editor document**, not entities in a running game. Use
`faset_schema` to inspect the current field IDs, then send a `faset_scene_edit`
batch with the document ID, its current revision, and a target entity ID:
```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,
"shadow_priority": 2
}
}]
}
```
Move the entity with its Transform component. `component.add` fills omitted
fields from the schema; `component.set` changes an existing field. Save the
document with `faset_document_save`. [MCP and command line](mcp.md) covers
revisions, retries, and transactions.
## Supply lights directly from C++
Code that constructs a renderer `faset::render::Snapshot` can supply lights
directly. Set `authored_lights_present` even when the only authored Light is
disabled, so the renderer does not synthesize the compatibility sun. Use stable,
unique IDs for deterministic capacity decisions:
```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;
point.shadow_priority = 2;
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);
```
This is the **renderer Snapshot API**, not a gameplay `Update()` method. The
current gameplay scripting API does not expose live Light-component creation or
modification; author lights in the Inspector or through Editor MCP. See
[Gameplay scripting](../scripting/index.md) for the APIs available to game code.
+73 -6
View File
@@ -118,13 +118,80 @@ and reads back the full image, so `cpu_ms` is wall time including waits, not CPU
utilization. An open scene can run slower with HZB; visibility correctness and
full-frame speed are separate findings.
## Measure P3 lighting and shadows
A Player `--profile` sample includes `effective_lighting_path`, local lights
submitted/omitted, requested/effective sun cascades, requested/rasterized local
shadow faces, tile use, shadow drop reasons, caster draws, and explicit atlas
allocation bytes. `gpu_main_raster_ms`, `gpu_sun_shadow_ms`, and
`gpu_local_shadow_ms` are GPU timestamps or `null` when timestamps are
unavailable. A light can illuminate while its shadow faces are dropped. A
submitted-light count of zero is a different workload from 128 lights whose
shadows are disabled. See [Lighting](lighting.md) for the capacity policy and
[Diagnostics](diagnostics.md) for the Editor counters.
The same sample includes `effective_lighting_path` (`forward` or `tiled`),
`gpu_light_tiles_ms`, and `light_tile_count`. Stored candidate and overflow
counts are present only when visibility diagnostics readback was enabled;
`light_tile_counts_valid: false` means their `null` values are unavailable,
not zero. The normal `Auto` setting currently resolves to `forward` after the
fixed dense 1080p benchmark showed that tile construction cost outweighed its
raster savings. A C++ renderer integration can explicitly request `Tiled` for
a localized-light scene, then check the actual path before comparing timings.
The fixed-scene benchmark compares 0, 4, 16, 32, 64, and 128 local lights under
Direct, GPU frustum, and GPU occlusion visibility, with shadows on and off. Its
wrapper runs three independent 1920×1080 repetitions per configuration, each
with ten warm-up and thirty recorded frames. First inspect the planned matrix:
```sh
python3 tools/benchmark_p3_lighting.py --list-runs
```
From the repository, after a Linux Release renderer build, run one shadow setting
into a new output directory. Supply the actual device driver identity:
```sh
python3 tools/benchmark_p3_lighting.py --sweep \
--executable build/linux-release/faset_p3_lighting_benchmark \
--output .cache/p3-lighting-off \
--shadows off --driver 'REPLACE_WITH_ACTUAL_DRIVER' --validation off
```
The wrapper writes one raw CSV per run, `merged.csv`, and `summary.json`. Keep
all three with the exact source revision and device. It checks that every run
used its requested visibility mode and submitted every requested light. GPU
timestamps for the main raster isolate fragment-heavy lighting better than
renderer wall time, which includes GPU waits and synchronous readback. Shadow
time is split into sun and local GPU durations. The Forward+ decision compares
the median of three run medians against the matching zero-light configuration;
the threshold is **1.0 ms extra main raster time or 15% of the zero-light GPU
frame** at 32, 64, or 128 lights on the Linux physical reference GPU. The
[P3 lighting validation record](https://github.com/emil28092005/Faset_Engine/blob/main/docs/validation/p3-lighting-2026-09-24/README.md)
states the measured decision and scope. A software Vulkan run checks
functionality, not physical GPU performance.
For a direct comparison of the two algorithms on the same scene, invoke the
Release executable twice with `--lighting forward` and `--lighting tiled`,
using the same `--lights`, `--shadows`, `--visibility`, and output size. The
default `--light-layout dense` preserves the fixed benchmark scene;
`--light-layout localized` reduces point-light ranges to 1.75 units as a
separately labelled workload. Compare `gpu_build_plus_raster_ms`, which includes
`gpu_light_tiles_ms`, rather than raster time alone. One optional diagnostic
frame with `--tile-diagnostics on` reports candidate and overflow counts but
adds a GPU readback, so do not mix it into the timed runs. The
[Forward+ measurement](https://github.com/emil28092005/Faset_Engine/blob/main/docs/studies/23-p3-forward-plus-2026-09-24.md) retains
raw frames, shader hashes, and the decision.
## Current performance scope
The accepted MVP path uses direct draws and CPU culling; P2 adds optional GPU
visibility for opaque static meshes, with prepared LODs supplied by the project.
Both paths currently use one graphics queue and synchronous full-image
capture/readback. Use measurements to find the next bottleneck before introducing
parallel jobs or expanding GPU-driven rendering. Neither an offscreen capture
benchmark nor a tiny demo is a promise of a production frame budget. Observed
measurements and follow-up targets belong in the implementation acceptance report
with their source revision and method.
P3 adds local lights and bounded sun/local shadow atlases. The benchmark's
`lighting_path` and a Player profile's `effective_lighting_path` identify the
algorithm actually used. Both paths currently use one graphics queue and
synchronous full-image capture/readback. Use measurements to find the next
bottleneck before introducing parallel jobs or expanding GPU-driven rendering.
Neither an offscreen capture benchmark nor a tiny demo is a promise of a
production frame budget. Observed measurements and follow-up targets belong in
the implementation acceptance report with their source revision and method.
@@ -0,0 +1,105 @@
# P3 Forward+ experiment: correctness and cost on localized lights
The fixed 1920×1080 P3 benchmark crossed the agreed threshold for trying
Forward+. A depth-free 16×16 tiled implementation now exists, but the measured
**build + raster** cost is higher than a full light scan on that benchmark's
dense lights. `RendererConfig::lighting_mode = Auto` therefore keeps the forward
path. `Tiled` is an explicit option for scenes whose projected light volumes
are localized. There is no unmeasured automatic occupancy heuristic.
This is a follow-up to the fixed-scene baseline sweep, which is being merged
as a separate study. It compares both paths in the same source revision
`a0a4e29d480ed3344f19bd3565d48668ca913fed`. The baseline's dense
placement remains the default. An explicit `--light-layout localized` changes
only point-light range from 8 to 1.75 world units; camera, nine casters,
receiver, positions, colors, light count, and output size are unchanged. The
localized fixture is a separate workload, not a replacement for the fixed
baseline gate.
## Renderer behavior and safety
The compute pass builds up to 64 stable-order light indices per screen tile.
It tests each world-space range sphere against four clip-space tile planes.
It does not use depth or reject near-plane intersections. A tile with more than
64 candidates sets an overflow bit; the fragment shader then scans **all**
submitted lights for that tile. Zero lights, missing capability, excessive
buffer size, failed optional allocation, and `Auto` use the forward path. The
shader contract checks the new compute entry's descriptors and 96-byte push
constants; Direct and P2 GPU graphics still use materials at set 0, lighting
at set 1, and GPU scene data at set 2. The tile list is set 1 binding 4 in the
shared fragment shader. Sprite/UI shading returns before tile reads.
The Linux Vulkan image test compares forward and tiled output in Direct, GPU
frustum, and GPU occlusion modes, including a cropped scene viewport, near-plane
crossing point light and shadow, resize, an offscreen light, and 80 coincident
lights that exceed tile capacity. Every overflowing tile falls back to the full
list. Shader reload preserves a working tiled pipeline after invalid bytecode
and recreates it after a valid reload. A separate 1920×1080 capture with 128
localized lights was byte-identical across both paths; its SHA-256 is in the
[provenance record](data/p3-forward-plus-provenance-2026-09-24.json).
## Measurement
The device was NVIDIA GeForce RTX 2080 Ti with NVIDIA driver 595.84.0.0,
Linux Clang Release, Direct visibility, shadows off, 1920×1080. Each mode had
three independent process runs with ten warm-up and thirty measured frames.
Forward/tiled run order alternated. The table uses the median of the three
per-run medians in milliseconds. The tile build column is an actual GPU
timestamp; `build + raster` also includes post raster if present. The dense
and localized CSVs contain every one of the 1080 measured frames, with a
`source_csv` identifier. The executable and all loaded `.spv`/reflection
SHA-256 values are in the provenance record.
| Light layout | Lights | Forward raster | Tile build | Tiled raster | Tiled build + raster | Tiled change |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| Dense fixed scene | 32 | 0.5500 | 0.0617 | 0.5527 | 0.6144 | +0.0644 ms (11.7% slower) |
| Dense fixed scene | 64 | 1.0701 | 0.1177 | 1.0740 | 1.1921 | +0.1220 ms (11.4% slower) |
| Dense fixed scene | 128 | 2.1172 | 0.2219 | 2.1164 | 2.3388 | +0.2216 ms (10.5% slower) |
| Localized range 1.75 | 32 | 0.2336 | 0.0555 | 0.0758 | 0.1312 | −0.1025 ms (43.9% faster) |
| Localized range 1.75 | 64 | 0.4254 | 0.1060 | 0.1057 | 0.2109 | −0.2144 ms (50.4% faster) |
| Localized range 1.75 | 128 | 0.8094 | 0.2048 | 0.1643 | 0.3691 | −0.4404 ms (54.4% faster) |
At 32 dense lights, the first forward process had a 0.7405 ms run median;
the other two were 0.5488 and 0.5500 ms. A single paired run would have
incorrectly suggested a tiled win. The median of three process medians and a
separate earlier repeat both support the slower dense result. This is why
`Auto` remains forward despite the localized-scene gain. The total GPU frame
also includes visibility, shadow fallback, copies, and synchronous readback;
the table isolates the passes that the optimization changes. For example, at
32 localized lights the full GPU frame was 1.6494 ms forward and 1.6472 ms
tiled, essentially unchanged despite lower build + raster cost. At 128 it
was 2.2788 versus 1.7948 ms.
One diagnostic frame per layout/count copied the tile buffer after the timed
draw. That copy was **not enabled** in the 1080 performance frames. The grid
has 8160 tiles and a 64-index capacity per tile.
| Layout | Lights | Stored candidates across tiles | Overflowed tiles |
| --- | ---: | ---: | ---: |
| Dense | 32 | 259,896 | 0 |
| Dense | 64 | 519,792 | 0 |
| Dense | 128 | 522,240 | 8,160 |
| Localized | 32 | 38,237 | 0 |
| Localized | 64 | 76,103 | 0 |
| Localized | 128 | 152,202 | 0 |
The dense 128 candidate count is capped at 64 × 8160 stored slots; all tiles
overflow and correctly evaluate all 128 lights in the fragment shader. This
explains why paying for tile construction cannot help that frame. The localized
128 scene averages about 19 stored candidates per tile and avoids fallback.
Raw data: [all paired frames](data/p3-forward-plus-ab-2026-09-24.csv),
[diagnostic frames](data/p3-forward-plus-diagnostics-2026-09-24.csv), and
[binary/shader provenance](data/p3-forward-plus-provenance-2026-09-24.json).
## Verification and scope
At the implementation revision, Linux Debug built all targets and passed
62/63 CTests, with the compositor-dependent window lifecycle case skipped and
no failures. The pinned Linux SwiftShader ICD passed all six P3 cases, including
the tiled parity/overflow test. The [lighting validation record](../validation/p3-lighting-2026-09-24/README.md)
retains those logs. These are functional checks on Linux and software Vulkan,
not physical Windows GPU performance. The A/B numbers apply to one GPU, driver,
camera, receiver and two synthetic light layouts. They do not establish an
engine-wide speedup. A measured runtime occupancy predictor and representative
game scenes are prerequisites before changing `Auto` from forward.
+2 -1
View File
@@ -1,6 +1,6 @@
# Исследования для Faset Engine
Обновлено 23.09.2026. Исходники и официальная документация исследовались прежде всего 17.09.2026; затем результаты согласованы с принятой архитектурой. P2 implementation/acceptance добавлены позже и отделены от исходного статического исследования.
Обновлено 24.09.2026. Исходники и официальная документация исследовались прежде всего 17.09.2026; затем результаты согласованы с принятой архитектурой. P2/P3 implementation/acceptance добавлены позже и отделены от исходного статического исследования.
**Актуальные решения — в [ARCHITECTURE.md](../ARCHITECTURE.md), порядок реализации — в [PLAN.md](../../PLAN.md).** Реализация MVP и проверки идут отдельно: [журнал реализации](../IMPLEMENTATION.md), [результаты проверок](../validation/README.md), [пользовательский Manual](../manual/index.md). Исследования дают обоснования и проверочные сценарии; их статический анализ не является измерением Faset.
@@ -41,6 +41,7 @@ Linux/Windows, десктопные 2D/3D, C++ сначала и Lua следу
- [19 — P2 GPU visibility: протокол приёмки](19-p2-gpu-visibility-acceptance.md): GPU-сценарии, допуски сравнения и методика измерений; отделяет проверку реализации от предложений исследования 15.
- [20 — P2 GPU visibility: первый benchmark](20-p2-gpu-visibility-benchmark-2026-09-23.md): три запуска, raw CSV, p50/p95 и границы интерпретации на Linux reference GPU.
- [21 — P2 GPU visibility: оптимизация MainCull](21-p2-gpu-visibility-optimization-2026-09-23.md): отдельные измерения переноса выходных буферов в память GPU и замены CAS-цикла на atomic add.
- [23 — P3 Forward+: корректность и стоимость](23-p3-forward-plus-2026-09-24.md): сравнение полной цены построения плиток и рисования на плотной и локализованной сценах, raw CSV и точный shader/binary provenance.
## Происхождение и воспроизводимость
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
light_count,light_layout,shadows,visibility,effective_visibility,lighting_path,requested_lighting,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_light_tiles_ms,gpu_build_plus_raster_ms,light_tile_count,light_tile_counts_valid,light_tile_candidate_count,light_tile_overflow_count,gpu_ms,cpu_ms,readback_cpu_ms
32,dense,off,direct,direct,tiled,tiled,Release,0,0,NVIDIA GeForce RTX 2080 Ti,NVIDIA 595.84.0.0,a0a4e29d480ed3344f19bd3565d48668ca913fed,1920,1080,0,0,32,0,0,0,0,0,0,10,63881248,0.551200,0.000000,0,0,0.000000,0.000000,0.000000,0.061472,0.612672,8160,1,259896,0,3.324384,4.920211,1.106698
64,dense,off,direct,direct,tiled,tiled,Release,0,0,NVIDIA GeForce RTX 2080 Ti,NVIDIA 595.84.0.0,a0a4e29d480ed3344f19bd3565d48668ca913fed,1920,1080,0,0,64,0,0,0,0,0,0,10,63883808,1.077376,0.000000,0,0,0.000000,0.000000,0.000000,0.117440,1.194816,8160,1,519792,0,3.713568,5.236988,1.014093
128,dense,off,direct,direct,tiled,tiled,Release,0,0,NVIDIA GeForce RTX 2080 Ti,NVIDIA 595.84.0.0,a0a4e29d480ed3344f19bd3565d48668ca913fed,1920,1080,0,0,128,0,0,0,0,0,0,10,63888928,2.109440,0.000000,0,0,0.000000,0.000000,0.000000,0.221184,2.330624,8160,1,522240,8160,4.435136,6.211166,0.970080
32,localized,off,direct,direct,tiled,tiled,Release,0,0,NVIDIA GeForce RTX 2080 Ti,NVIDIA 595.84.0.0,a0a4e29d480ed3344f19bd3565d48668ca913fed,1920,1080,0,0,32,0,0,0,0,0,0,10,63881248,0.075328,0.000000,0,0,0.000000,0.000000,0.000000,0.055296,0.130624,8160,1,38237,0,2.355808,3.586245,0.930515
64,localized,off,direct,direct,tiled,tiled,Release,0,0,NVIDIA GeForce RTX 2080 Ti,NVIDIA 595.84.0.0,a0a4e29d480ed3344f19bd3565d48668ca913fed,1920,1080,0,0,64,0,0,0,0,0,0,10,63883808,0.105248,0.000000,0,0,0.000000,0.000000,0.000000,0.105312,0.210560,8160,1,76103,0,2.445600,3.884127,1.069417
128,localized,off,direct,direct,tiled,tiled,Release,0,0,NVIDIA GeForce RTX 2080 Ti,NVIDIA 595.84.0.0,a0a4e29d480ed3344f19bd3565d48668ca913fed,1920,1080,0,0,128,0,0,0,0,0,0,10,63888928,0.215040,0.000000,0,0,0.000000,0.000000,0.000000,0.229376,0.444416,8160,1,152202,0,2.585504,4.059668,0.951374
1 light_count light_layout shadows visibility effective_visibility lighting_path requested_lighting 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_light_tiles_ms gpu_build_plus_raster_ms light_tile_count light_tile_counts_valid light_tile_candidate_count light_tile_overflow_count gpu_ms cpu_ms readback_cpu_ms
2 32 dense off direct direct tiled tiled Release 0 0 NVIDIA GeForce RTX 2080 Ti NVIDIA 595.84.0.0 a0a4e29d480ed3344f19bd3565d48668ca913fed 1920 1080 0 0 32 0 0 0 0 0 0 10 63881248 0.551200 0.000000 0 0 0.000000 0.000000 0.000000 0.061472 0.612672 8160 1 259896 0 3.324384 4.920211 1.106698
3 64 dense off direct direct tiled tiled Release 0 0 NVIDIA GeForce RTX 2080 Ti NVIDIA 595.84.0.0 a0a4e29d480ed3344f19bd3565d48668ca913fed 1920 1080 0 0 64 0 0 0 0 0 0 10 63883808 1.077376 0.000000 0 0 0.000000 0.000000 0.000000 0.117440 1.194816 8160 1 519792 0 3.713568 5.236988 1.014093
4 128 dense off direct direct tiled tiled Release 0 0 NVIDIA GeForce RTX 2080 Ti NVIDIA 595.84.0.0 a0a4e29d480ed3344f19bd3565d48668ca913fed 1920 1080 0 0 128 0 0 0 0 0 0 10 63888928 2.109440 0.000000 0 0 0.000000 0.000000 0.000000 0.221184 2.330624 8160 1 522240 8160 4.435136 6.211166 0.970080
5 32 localized off direct direct tiled tiled Release 0 0 NVIDIA GeForce RTX 2080 Ti NVIDIA 595.84.0.0 a0a4e29d480ed3344f19bd3565d48668ca913fed 1920 1080 0 0 32 0 0 0 0 0 0 10 63881248 0.075328 0.000000 0 0 0.000000 0.000000 0.000000 0.055296 0.130624 8160 1 38237 0 2.355808 3.586245 0.930515
6 64 localized off direct direct tiled tiled Release 0 0 NVIDIA GeForce RTX 2080 Ti NVIDIA 595.84.0.0 a0a4e29d480ed3344f19bd3565d48668ca913fed 1920 1080 0 0 64 0 0 0 0 0 0 10 63883808 0.105248 0.000000 0 0 0.000000 0.000000 0.000000 0.105312 0.210560 8160 1 76103 0 2.445600 3.884127 1.069417
7 128 localized off direct direct tiled tiled Release 0 0 NVIDIA GeForce RTX 2080 Ti NVIDIA 595.84.0.0 a0a4e29d480ed3344f19bd3565d48668ca913fed 1920 1080 0 0 128 0 0 0 0 0 0 10 63888928 0.215040 0.000000 0 0 0.000000 0.000000 0.000000 0.229376 0.444416 8160 1 152202 0 2.585504 4.059668 0.951374
@@ -0,0 +1,33 @@
{
"commit": "a0a4e29d480ed3344f19bd3565d48668ca913fed",
"device": "NVIDIA GeForce RTX 2080 Ti",
"driver": "NVIDIA 595.84.0.0",
"build": "Linux Clang Release",
"executable_sha256": "db151a07eeaee04871920f8617f5bcf4c8ac7cb11abf4e36e5595cc526440a36",
"shader_bundle_sha256": "60d2abf2f43557f72f39de7a50f4bc92816eb7859c613b2797c0410e72dbaeed",
"shader_bundle_hash_method": "SHA256 of UTF-8 canonical JSON mapping sorted shader filenames to per-file SHA256",
"shader_files": {
"vertexMain.spv": "1ad2631c35d654f48166321ae43d4165043e1b919b14f1dc61caf97b1ada0898",
"vertexMain.reflection.json": "b3bdff863d53a0b385e06a36701a83a6ecafe9a93e97281a732cce8ff862c0db",
"fragmentMain.spv": "eb65edf62a4ead573f740903dbbd1a61565fe97d234fbe8a09934ce6415bec92",
"fragmentMain.reflection.json": "6b62a42ea448d9ae253d78b6b326dfe0d761532ce695b49263689ec649ede36c",
"shadowMain.spv": "7643b4d688492b5ee923b9606f0b0e70343ca05fa67673838206119a6f86d8d8",
"shadowMain.reflection.json": "d39e2f83c797589296eac9dce139b9ad836a895d6685bc40f79dd2609d687fee",
"lightTileMain.spv": "92850edcc5ee5b586a90ce84ee69a98075a6fff3c13db4a57528d9431afe8b68",
"lightTileMain.reflection.json": "bf6a9256aaa6d6edacfa1e1c265d7c8ce2c1e4ea4036e0bea078c22fd4b536ca",
"gpuVertexMain.spv": "fc9770f304976875f8dd1f6acbe380c77cb06c13276f5596554b3f0b396b31bd",
"gpuVertexMain.reflection.json": "09166d80fba7ab4167e97f85125a7c58ab92ea447c1a6a2ab5084e657401dae9",
"gpuShadowMain.spv": "2da7e7f96bc7de4e9c8ac365fe0a63f608c7bb26ab3e7bfebfcf88b32e86c71d",
"gpuShadowMain.reflection.json": "c9dd844aefb3840f29caad5dfc53986cacc2220f886eda5ad345ebdbb9ff5b1c",
"gpuCullMain.spv": "7691360ec55f41d40d43652f506b051cef8b3a3ddcdcc7db61a7d4d97f9e87d8",
"gpuCullMain.reflection.json": "996fe5c7e31c6b8f316d8f290b3be0ae7063eb46c500a661e8715e9b822a2598",
"gpuHzbMain.spv": "08682362ee2120ed19d260e613285071747c3e436bb09bb6238e46f9cedc2308",
"gpuHzbMain.reflection.json": "589818f670881c64f3aba726ee3bc82fe4b9d835a347f99134e0e157dc4a112e",
"gpuPostCullMain.spv": "62cb72e356346e1511c6d640a3cecb4a6e5283795338299b357edae5f7f52be4",
"gpuPostCullMain.reflection.json": "eed140bbe09c7d14935b688c770261ac0e81677c0974aa811c88ed67ea779b95"
},
"matrix": "Direct, shadows off, 1920x1080, dense or localized, 32/64/128 lights, 3 repeats, 10 warmup + 30 measured frames; alternating A/B order",
"raw_rows": 1080,
"localized_128_capture_sha256": "2aa5d408867f7193b7fd83984f2333b188623bda4d083512ede6b8318f11b45c",
"localized_128_capture_exact_equal": true
}
@@ -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<SunLight> Snapshot::sun`, `std::vector<LocalLight> Snapshot::local_lights`, and `std::optional<CameraFrustum> 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<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; 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`.
@@ -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.
+1
View File
@@ -5,6 +5,7 @@ These files preserve bounded checks and their inputs. Each record states its sou
- [MVP acceptance dossier](mvp-acceptance.md): criterion-by-criterion closure, tested revisions and remaining compatibility coverage.
- [P2 GPU visibility Linux evidence](p2-gpu-visibility-2026-09-23/README.md): Debug/Release GPU acceptance, lavapipe functional checks, relocated Player exports, and explicit platform/performance limits.
- [P2 pinned SwiftShader compatibility](p2-swiftshader-2026-09-23/README.md): the Windows CI regression, shader capability fix, independent review closure, final native CI and relocated Player evidence.
- [P3 lighting and shadows](p3-lighting-2026-09-24/README.md): implementation, Forward+ A/B and image parity, acceptance matrix, bounded Linux evidence, and remaining Windows/platform checks; temporal reconstruction is tracked separately.
- [Windows software Vulkan](windows-software-vulkan-2026-09-18/README.md): fresh native build, 35 tests, launcher/window/MCP workflows and both relocated Release games on SwiftShader.
- [Checkpoint 5 Linux acceptance](checkpoint5-linux-2026-09-18/README.md): clean offline source build, first Editor launch, exact-candidate standalone games and live Blender checks.
- [Final Linux source checks](final-linux-2026-09-18/README.md): `4cb8255` integrated test results and both Release games after the asset-relocation correction, including package manifests and standalone captures.
@@ -0,0 +1,124 @@
# P3 lighting and shadows — acceptance record
This record tracks P3 lighting separately from temporal reconstruction. The
implementation and measured Forward+ checkpoint is source revision
`a0a4e29d480ed3344f19bd3565d48668ca913fed` on `feat/p3-lighting`.
The earlier shadow/benchmark integration checkpoint was `b191ae0`. The
lighting slice has Linux functional and reference-GPU evidence; Windows CI for
the new tiled revision is pending. Temporal reconstruction has its own acceptance.
## Implemented at the checkpoint
- Authored directional, point, and spot lights are extracted from the same
versioned Light schema used by the Inspector and MCP. No authored Light
component retains the legacy sun; any authored Light, including disabled or
local-only, suppresses that fallback.
- Direct, GPU frustum, and GPU occlusion graphics paths use the same typed
lighting data. Material descriptors remain set 0, lighting set 1, and GPU
graphics scene data set 2. Sun and local light contributions accumulate before
tone mapping. Sprites and UI remain unlit.
- Four texel-snapped sun cascades cover up to 80 world units for an explicit
camera frustum. A low-level Snapshot without one keeps a single sun view.
Shadow caster selection uses each light view and source LOD 0, independent of
camera visibility and prepared camera LOD. Missing/disabled sun and sprite-only
scenes skip sun shadow raster.
- The separate D32 local atlas admits 16 faces, one per spot or six atomically
per point. Both shadow systems share a 4096-caster-draw frame budget. Up to 128
local lights are submitted by priority, projected influence, then stable ID.
Overflow or unsupported-atlas lights remain unshadowed when submitted; omitted
lights beyond 128 do not illuminate. Both atlases try 2048², then 1024².
- The editor overlay and Player profile expose actual submitted/omitted lights,
requested/effective views, drop reasons, atlas bytes, caster draws, GPU shadow
durations, and the effective lighting path. Shadow tiles are redrawn every
frame; no persistent depth cache is claimed.
- An explicit 16×16 depth-free tiled Forward+ path uses at most 64 light indices
per tile and evaluates the complete submitted list on overflow. The compute
entry has checked reflection and is included in game builds. `Auto` uses
forward: three-run Release measurements found tile build + raster slower on
the dense fixed scene. The [paired study](../../studies/23-p3-forward-plus-2026-09-24.md)
retains a separate localized-light win and exact binary/shader provenance.
## Acceptance matrix
| Case | Automated evidence | Current status |
| --- | --- | --- |
| Empty, disabled, local-only, multiple sun; schema bounds | `scene_view`, `render_lighting_policy`, `render_offscreen` | Linux Debug green at `a0a4e29`; integrated revision pending |
| Four cascades, split bounds, subtexel stabilization, offscreen/source-LOD0 caster | `render_lighting_policy`, `render_lighting_sun` | Linux GPU and pinned SwiftShader P3 green at `a0a4e29` |
| Spot cone, six point faces and seam, dropped whole point shadow | `render_lighting_local`, `render_lighting_policy` | Linux GPU and pinned SwiftShader P3 green at `a0a4e29` |
| 128-light/16-face/4096-draw limits, unsupported-atlas fallback | `render_lighting_policy`, `render_offscreen`, `render_lighting_local` | CPU and supported-atlas GPU paths covered; actual unsupported Vulkan device not tested |
| Direct/GPU frustum/GPU occlusion image parity, P2 reload and 2D/UI independence | `render_lighting_sun`, `render_lighting_local`, `render_shader_reload`, `render_offscreen` | Linux Debug green at `a0a4e29`; integrated revision pending |
| Forward+/forward parity, near plane, resize, overflow, and shader reload | `render_lighting_tiled`, `render_shader_reload`, `render_shader_reflection`, `build_schema_publication` | Linux Debug and pinned SwiftShader P3 green at `a0a4e29`; 128-light localized Release captures match exactly |
| Driver, profile, real 64×64 benchmark smoke | `render_lighting_benchmark_schema`, `render_lighting_benchmark_smoke`, `player_shutdown_diagnostics` | Full Linux Debug green at `a0a4e29` |
| 1920×1080 0/4/16/32/64/128 Release sweep, three repeats, both shadow states | `tools/benchmark_p3_lighting.py --sweep` | Forward baseline measured; its separate raw study is being integrated |
| 1920×1080 paired paths, 32/64/128 dense and localized lights | `faset_p3_lighting_benchmark --lighting forward|tiled` | Raw 1080 frames and six diagnostic samples retained in study 23; dense slower, localized faster by build+raster |
| Windows native build, pinned SwiftShader GPU tests, relocated Release 2D/3D Players | `windows-graphics.yml`, `ci.yml` | New P3 revision has not yet completed Windows CI |
The supported-atlas GPU tests create a renderer with validation requested and
assert zero reported Vulkan errors; a test result is a validation-layer pass only
when the layer was actually active. `render_window_lifecycle` can skip if the
Linux compositor declines programmatic restore. The Windows workflow uses pinned
SwiftShader, not a physical Windows GPU, and may lack the Khronos layer. Linux
reference-GPU results cannot establish physical Windows performance.
## Reproduction and retained evidence
The P3 CTest registrations are `render_lighting_policy` and
`render_lighting_benchmark_schema` (CPU), plus `render_lighting_sun`,
`render_lighting_local`, `render_lighting_tiled`, and
`render_lighting_benchmark_smoke` (labelled `gpu;p3`). Use
`ctest --test-dir build/linux-debug -N -L p3` to confirm those six cases exist
before running them; an empty test selection is not a pass.
The Windows full graphics job runs all registered tests, while the native
Windows CPU job uses `-LE gpu` and therefore excludes the four Vulkan cases.
On the Linux host at `b191ae0`, the [CTest inventory](linux-debug-p3-inventory.txt)
listed all five cases. The [CPU-only P3 run](linux-debug-cpu-ctest.txt) passed
`render_lighting_policy` and `render_lighting_benchmark_schema` 2/2 with zero
failures. The [strict MkDocs build](strict-mkdocs.txt) passed for these Manual
changes. This run deliberately excluded Vulkan tests while the 1920×1080
physical-GPU baseline was being measured, so it is not a final GPU acceptance
result. The local host was Linux x86_64, kernel 7.0.0-31-generic; the source
checkout had documentation changes only during these checks.
At `a0a4e29`, the [full Linux Debug run](linux-debug-tiled-ctest.txt) had
63 registered cases: 62 passed, no failures, and the compositor-dependent
window lifecycle case skipped. The [pinned Linux SwiftShader P3 run](linux-swiftshader-tiled-p3-ctest.txt)
passed all six P3 cases without a skip. The Vulkan image cases requested
validation and asserted zero reported errors. The RTX 2080 Ti A/B used NVIDIA
driver 595.84.0.0; study 23 records the executable and shader bundle hashes,
all raw per-frame timings, tile overflow counts, and exact image equality for
the localized 128-light capture. Its first dense 32-light forward run was an
outlier, so the decision uses the median of three process medians rather than
the apparent win in one paired run.
At the earlier `b191ae0` checkpoint, [GitHub native/manual CI](https://github.com/emil28092005/Faset_Engine/actions/runs/35935899512)
and [Windows graphics/SwiftShader CI](https://github.com/emil28092005/Faset_Engine/actions/runs/35935899505)
passed. These jobs did **not** include the new tile shader; Windows CI for
`a0a4e29` is still required.
```sh
cmake --build --preset linux-debug --parallel 2
ctest --test-dir build/linux-debug -L p3 --no-tests=error --output-on-failure
ctest --test-dir build/linux-debug --output-on-failure
cmake --build --preset linux-release --parallel 2
ctest --test-dir build/linux-release --output-on-failure
```
The benchmark wrapper retains one raw CSV per run, a merged CSV, and a summary.
It rejects visibility fallback, missing GPU timestamps, missing lights, duplicate
frames, and validation errors. An offscreen capture's `cpu_ms` includes GPU wait
and readback; it is not thread CPU time. The exact Release benchmark revision,
driver and paired path data are retained in study 23. Release full CTest,
final integrated Windows Actions and relocated Player checks still need to be
added before this is a complete P3 lighting acceptance record.
## Limits carried forward
The default path scans all submitted lights in each mesh fragment; the
measured Forward+ threshold prompted a bounded tiled implementation. `Auto`
still uses forward because this dense fixed workload was slower after tile
construction. Transparent/game UI and sprites keep their existing
ordering and unlit behavior. The atlas caps are fixed budgets, not adaptive
quality settings, and shadow depth is redrawn each frame. The renderer still
performs synchronous framebuffer readback. No broad scene/driver matrix or
physical Windows GPU performance claim follows from these fixtures.
@@ -0,0 +1,12 @@
Test project /home/emil/Desktop/.worktrees/Faset_Engine-p3-lighting/build/linux-debug
Start 9: render_lighting_policy
1/2 Test #9: render_lighting_policy ............. Passed 0.04 sec
Start 17: render_lighting_benchmark_schema
2/2 Test #17: render_lighting_benchmark_schema ... Passed 4.95 sec
100% tests passed, 0 tests failed out of 2
Label Time Summary:
p3 = 4.99 sec*proc (2 tests)
Total Test time (real) = 5.00 sec
@@ -0,0 +1,8 @@
Test project /home/emil/Desktop/.worktrees/Faset_Engine-p3-lighting/build/linux-debug
Test #7: render_lighting_sun
Test #8: render_lighting_local
Test #9: render_lighting_policy
Test #17: render_lighting_benchmark_schema
Test #18: render_lighting_benchmark_smoke
Total Tests: 5
@@ -0,0 +1,140 @@
Test project /home/emil/Desktop/.worktrees/Faset_Engine-p3-lighting/build/linux-debug
Start 1: authoring
1/63 Test #1: authoring .......................... Passed 0.06 sec
Start 2: runtime_contracts
2/63 Test #2: runtime_contracts .................. Passed 0.12 sec
Start 3: assets_pipeline
3/63 Test #3: assets_pipeline .................... Passed 0.14 sec
Start 4: assets_blender_bundle
4/63 Test #4: assets_blender_bundle .............. Passed 0.08 sec
Start 5: lua_contracts
5/63 Test #5: lua_contracts ...................... Passed 0.11 sec
Start 6: lua_safety_contracts
6/63 Test #6: lua_safety_contracts ............... Passed 0.21 sec
Start 7: render_lighting_sun
7/63 Test #7: render_lighting_sun ................ Passed 0.92 sec
Start 8: render_lighting_local
8/63 Test #8: render_lighting_local .............. Passed 0.87 sec
Start 9: render_lighting_tiled
9/63 Test #9: render_lighting_tiled .............. Passed 2.75 sec
Start 10: render_lighting_policy
10/63 Test #10: render_lighting_policy ............. Passed 0.04 sec
Start 11: render_graph
11/63 Test #11: render_graph ....................... Passed 0.00 sec
Start 12: render_offscreen
12/63 Test #12: render_offscreen ................... Passed 0.43 sec
Start 13: render_sprite_alpha
13/63 Test #13: render_sprite_alpha ................ Passed 0.36 sec
Start 14: render_shader_reload
14/63 Test #14: render_shader_reload ............... Passed 4.49 sec
Start 15: render_gpu_shader_contract
15/63 Test #15: render_gpu_shader_contract ......... Passed 0.06 sec
Start 16: render_shader_reflection
16/63 Test #16: render_shader_reflection ........... Passed 3.63 sec
Start 17: render_window_lifecycle
17/63 Test #17: render_window_lifecycle ............***Skipped 4.02 sec
Start 18: render_lighting_benchmark_schema
18/63 Test #18: render_lighting_benchmark_schema ... Passed 4.02 sec
Start 19: render_lighting_benchmark_smoke
19/63 Test #19: render_lighting_benchmark_smoke .... Passed 0.44 sec
Start 20: visibility_policy
20/63 Test #20: visibility_policy .................. Passed 0.01 sec
Start 21: gpu_visibility
21/63 Test #21: gpu_visibility ..................... Passed 0.90 sec
Start 22: render_gpu_empty
22/63 Test #22: render_gpu_empty ................... Passed 0.59 sec
Start 23: render_gpu_capacity
23/63 Test #23: render_gpu_capacity ................ Passed 0.65 sec
Start 24: render_gpu_dense
24/63 Test #24: render_gpu_dense ................... Passed 0.64 sec
Start 25: render_gpu_door
25/63 Test #25: render_gpu_door .................... Passed 0.60 sec
Start 26: render_gpu_shadow
26/63 Test #26: render_gpu_shadow .................. Passed 0.60 sec
Start 27: render_gpu_cut
27/63 Test #27: render_gpu_cut ..................... Passed 0.60 sec
Start 28: render_gpu_resize
28/63 Test #28: render_gpu_resize .................. Passed 0.63 sec
Start 29: render_gpu_lod
29/63 Test #29: render_gpu_lod ..................... Passed 0.66 sec
Start 30: render_gpu_teleport
30/63 Test #30: render_gpu_teleport ................ Passed 0.61 sec
Start 31: render_gpu_near
31/63 Test #31: render_gpu_near .................... Passed 0.61 sec
Start 32: render_gpu_lifecycle
32/63 Test #32: render_gpu_lifecycle ............... Passed 1.32 sec
Start 33: render_gpu_views
33/63 Test #33: render_gpu_views ................... Passed 0.59 sec
Start 34: render_gpu_projection
34/63 Test #34: render_gpu_projection .............. Passed 0.57 sec
Start 35: render_gpu_open_sequence
35/63 Test #35: render_gpu_open_sequence ........... Passed 1.80 sec
Start 36: render_gpu_transparent
36/63 Test #36: render_gpu_transparent ............. Passed 0.62 sec
Start 37: player_scene_contracts
37/63 Test #37: player_scene_contracts ............. Passed 0.03 sec
Start 38: player_shutdown_diagnostics
38/63 Test #38: player_shutdown_diagnostics ........ Passed 1.53 sec
Start 39: lua_cli_contracts
39/63 Test #39: lua_cli_contracts .................. Passed 0.13 sec
Start 40: lua_player_reload
40/63 Test #40: lua_player_reload .................. Passed 2.45 sec
Start 41: editor_mcp
41/63 Test #41: editor_mcp ......................... Passed 0.01 sec
Start 42: process_and_cook
42/63 Test #42: process_and_cook ................... Passed 0.32 sec
Start 43: build_schema_publication
43/63 Test #43: build_schema_publication ........... Passed 9.27 sec
Start 44: editor_plugins
44/63 Test #44: editor_plugins ..................... Passed 0.03 sec
Start 45: editor_session_settings
45/63 Test #45: editor_session_settings ............ Passed 0.02 sec
Start 46: ui_widgets
46/63 Test #46: ui_widgets ......................... Passed 0.12 sec
Start 47: ui_render
47/63 Test #47: ui_render .......................... Passed 0.48 sec
Start 48: editor_ui_import_conflicts
48/63 Test #48: editor_ui_import_conflicts ......... Passed 0.82 sec
Start 49: editor_ui_project_settings
49/63 Test #49: editor_ui_project_settings ......... Passed 0.97 sec
Start 50: editor_ui_reload
50/63 Test #50: editor_ui_reload ................... Passed 4.04 sec
Start 51: editor_ui_launcher
51/63 Test #51: editor_ui_launcher ................. Passed 0.84 sec
Start 52: editor_ui_templates
52/63 Test #52: editor_ui_templates ................ Passed 2.48 sec
Start 53: editor_ui_gizmos
53/63 Test #53: editor_ui_gizmos ................... Passed 0.92 sec
Start 54: editor_ui_authoring
54/63 Test #54: editor_ui_authoring ................ Passed 1.71 sec
Start 55: editor_mcp_stdio
55/63 Test #55: editor_mcp_stdio ................... Passed 0.27 sec
Start 56: editor_gui_mcp
56/63 Test #56: editor_gui_mcp ..................... Passed 4.65 sec
Start 57: core
57/63 Test #57: core ............................... Passed 0.16 sec
Start 58: tutorial_moving
58/63 Test #58: tutorial_moving .................... Passed 0.02 sec
Start 59: tutorial_following
59/63 Test #59: tutorial_following ................. Passed 0.01 sec
Start 60: tutorial_spawning
60/63 Test #60: tutorial_spawning .................. Passed 0.02 sec
Start 61: tutorial_physics
61/63 Test #61: tutorial_physics ................... Passed 0.06 sec
Start 62: playable_2d
62/63 Test #62: playable_2d ........................ Passed 0.40 sec
Start 63: playable_3d
63/63 Test #63: playable_3d ........................ Passed 0.93 sec
100% tests passed, 0 tests failed out of 63
Label Time Summary:
gpu = 47.20 sec*proc (35 tests)
p2 = 12.02 sec*proc (16 tests)
p3 = 9.05 sec*proc (6 tests)
window = 8.67 sec*proc (2 tests)
Total Test time (real) = 67.51 sec
The following tests did not run:
17 - render_window_lifecycle (Skipped)
@@ -0,0 +1,21 @@
Test project /home/emil/Desktop/.worktrees/Faset_Engine-p3-lighting/build/linux-debug
Start 7: render_lighting_sun
1/6 Test #7: render_lighting_sun ................ Passed 5.14 sec
Start 8: render_lighting_local
2/6 Test #8: render_lighting_local .............. Passed 4.76 sec
Start 9: render_lighting_tiled
3/6 Test #9: render_lighting_tiled .............. Passed 14.30 sec
Start 10: render_lighting_policy
4/6 Test #10: render_lighting_policy ............. Passed 0.03 sec
Start 18: render_lighting_benchmark_schema
5/6 Test #18: render_lighting_benchmark_schema ... Passed 4.04 sec
Start 19: render_lighting_benchmark_smoke
6/6 Test #19: render_lighting_benchmark_smoke .... Passed 1.23 sec
100% tests passed, 0 tests failed out of 6
Label Time Summary:
gpu = 25.42 sec*proc (4 tests)
p3 = 29.50 sec*proc (6 tests)
Total Test time (real) = 29.50 sec
@@ -0,0 +1,20 @@
warning: An executable named `mkdocs` is not provided by package `mkdocs-material` but is available via the dependency `mkdocs`. Consider using `uvx --from mkdocs mkdocs` instead.
 │ ⚠ Warning from the Material for MkDocs team
 │
 │ MkDocs 2.0, the underlying framework of Material for MkDocs,
 │ will introduce backward-incompatible changes, including:
 │
 │ × All plugins will stop working – the plugin system has been removed
 │ × All theme overrides will break – the theming system has been rewritten
 │ × No migration path exists – existing projects cannot be upgraded
 │ × Closed contribution model – community members can't report bugs
 │ × Currently unlicensed – unsuitable for production use
 │
 │ Our full analysis:
 │
 │ https://squidfunk.github.io/mkdocs-material/blog/2026/02/18/mkdocs-2.0/

INFO - Cleaning site directory
INFO - Building documentation to directory: /home/emil/Desktop/.worktrees/Faset_Engine-p3-lighting/build/manual
INFO - Documentation built in 0.76 seconds
@@ -0,0 +1,20 @@
warning: An executable named `mkdocs` is not provided by package `mkdocs-material` but is available via the dependency `mkdocs`. Consider using `uvx --from mkdocs mkdocs` instead.
 │ ⚠ Warning from the Material for MkDocs team
 │
 │ MkDocs 2.0, the underlying framework of Material for MkDocs,
 │ will introduce backward-incompatible changes, including:
 │
 │ × All plugins will stop working – the plugin system has been removed
 │ × All theme overrides will break – the theming system has been rewritten
 │ × No migration path exists – existing projects cannot be upgraded
 │ × Closed contribution model – community members can't report bugs
 │ × Currently unlicensed – unsuitable for production use
 │
 │ Our full analysis:
 │
 │ https://squidfunk.github.io/mkdocs-material/blog/2026/02/18/mkdocs-2.0/

INFO - Cleaning site directory
INFO - Building documentation to directory: /home/emil/Desktop/.worktrees/Faset_Engine-p3-lighting/build/manual
INFO - Documentation built in 0.84 seconds
+272
View File
@@ -0,0 +1,272 @@
#include <faset/core/io.hpp>
#include <faset/render/renderer.hpp>
#include <algorithm>
#include <array>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <stdexcept>
#include <string>
#include <string_view>
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{}, tile_diagnostics{};
VisibilityMode visibility{VisibilityMode::Direct};
LightingMode lighting{LightingMode::Auto};
std::string light_layout{"dense"};
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<unsigned>(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 "
"--lighting auto|forward|tiled --light-layout dense|localized "
"--tile-diagnostics 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 == "--tile-diagnostics") {
if (value != "on" && value != "off")
throw std::invalid_argument("--tile-diagnostics must be on or off");
options.tile_diagnostics = 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 if (name == "--lighting") {
if (value == "auto") options.lighting = LightingMode::Auto;
else if (value == "forward") options.lighting = LightingMode::Forward;
else if (value == "tiled") options.lighting = LightingMode::Tiled;
else throw std::invalid_argument("Unknown lighting mode: " + value);
} else if (name == "--light-layout") {
if (value != "dense" && value != "localized")
throw std::invalid_argument("--light-layout must be dense or localized");
options.light_layout = 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 = options.light_layout == "localized" ? 1.75f : 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;
config.lighting_mode = options.lighting;
config.visibility_diagnostics = options.tile_diagnostics;
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,light_layout,shadows,visibility,effective_visibility,lighting_path,"
"requested_lighting,"
"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_light_tiles_ms,"
"gpu_build_plus_raster_ms,light_tile_count,light_tile_counts_valid,"
"light_tile_candidate_count,light_tile_overflow_count,"
"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.light_layout << ','
<< (options.shadows ? "on" : "off") << ','
<< mode_name(options.visibility) << ',' << mode_name(stats.effective_visibility_mode)
<< ',' << stats.effective_lighting_path << ','
<< (options.lighting == LightingMode::Forward ? "forward" :
options.lighting == LightingMode::Tiled ? "tiled" : "auto") << ','
<< 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_light_tiles_ms << ','
<< (stats.gpu_main_raster_ms + stats.gpu_post_raster_ms +
stats.gpu_light_tiles_ms) << ','
<< stats.light_tile_count << ',' << (stats.light_tile_counts_valid ? 1 : 0)
<< ',' << stats.light_tile_candidate_count << ','
<< stats.light_tile_overflow_count << ',' << 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
+76
View File
@@ -0,0 +1,76 @@
#pragma once
#include <faset/render/visibility.hpp>
#include <cstddef>
#include <cstdint>
#include <span>
#include <string>
#include <vector>
namespace faset::render {
enum class ShadowDropReason { None, Unavailable, TileBudget, CasterBudget };
enum class ShadowRedrawReason { EveryFrame };
struct ShadowBudget {
std::uint32_t max_sun_views{4};
std::uint32_t max_local_faces{16};
std::uint32_t max_caster_draws{4096};
std::uint32_t max_local_lights{128};
std::uint32_t sun_atlas_size{2048};
std::uint32_t local_atlas_size{2048};
float max_shadow_distance{80};
bool sun_atlas_available{true};
bool local_atlas_available{true};
};
struct ShadowCasterBounds {
Bounds world;
std::uint32_t draw_index{}; // Index of the original source LOD-0 DrawItem.
};
struct ShadowView {
enum class Kind { Sun, Spot, Point };
Kind kind{Kind::Sun};
std::string light_id;
std::uint32_t face_index{};
std::uint32_t tile_index{};
std::uint32_t tile_origin_x{}, tile_origin_y{}, tile_size{}, usable_size{};
Mat4 view_projection{identity};
std::array<float, 4> atlas_scale_offset{};
std::array<float, 4> guarded_clamp{};
float split_near{}, split_far{};
float snapped_center_x{}, snapped_center_y{};
std::vector<std::uint32_t> caster_indices;
bool valid{};
ShadowDropReason reason{ShadowDropReason::None};
ShadowRedrawReason redraw_reason{ShadowRedrawReason::EveryFrame};
};
struct LocalShadowAssignment {
std::size_t source_index{};
std::uint32_t first_view{};
std::uint32_t face_count{};
bool valid{};
ShadowDropReason reason{ShadowDropReason::None};
};
struct ShadowPlan {
std::vector<ShadowView> sun_views; // Requested slots, including explicitly invalid ones.
std::vector<ShadowView> local_views; // Only complete, valid spot/point allocations.
std::vector<std::size_t> submitted_local_indices; // Priority/influence/stable-ID order.
std::vector<LocalShadowAssignment> local_assignments;
std::uint32_t requested_sun_cascades{}, effective_sun_cascades{};
std::uint32_t local_faces_requested{}, local_faces_used{};
std::uint32_t dropped_sun_views{}, dropped_local_faces{}, dropped_point_faces{};
std::uint32_t omitted_local_lights{}, caster_draws{};
std::uint32_t sun_atlas_size{}, local_atlas_size{};
};
// Stateless: every scheduled tile is cleared/redrawn; no prior-frame depth or
// ownership is reused. Shadow casters are source LOD-0 world bounds, independent
// of the camera/P2 visibility decision. This function performs no Vulkan work.
ShadowPlan build_shadow_plan(const Snapshot& frame,
std::span<const ShadowCasterBounds> casters,
const ShadowBudget& budget = {});
} // namespace faset::render
+54
View File
@@ -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,8 +128,15 @@ 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<SunLight> sun{};
std::vector<LocalLight> local_lights;
std::optional<CameraFrustum> camera_frustum{};
};
enum class VisibilityMode { Direct, GpuFrustum, GpuOcclusion };
enum class LightingMode { Auto, Forward, Tiled };
// CPU-only validation used before publishing a game or creating Vulkan pipelines.
void validate_shader_bundle(const std::filesystem::path& directory);
void validate_gpu_shader_bundle(const std::filesystem::path& directory);
@@ -112,6 +147,9 @@ struct RendererConfig {
bool headless{false};
bool validation{true};
VisibilityMode visibility_mode{VisibilityMode::Direct};
// Auto prefers the 16x16 tiled light list at 32+ submitted local lights.
// Forward remains the reference and the fallback on unsupported devices.
LightingMode lighting_mode{LightingMode::Auto};
// GPU counter readback is diagnostic-only; normal visibility uses no CPU feedback.
bool visibility_diagnostics{false};
// Optional isolated shader bundle, useful for editor preview and shader reload tests.
@@ -149,6 +187,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 +207,13 @@ 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{};
double gpu_light_tiles_ms{};
std::uint32_t light_tile_count{};
// Optional tile-list readback, valid only when visibility diagnostics are on.
bool light_tile_counts_valid{};
std::uint32_t light_tile_candidate_count{}, light_tile_overflow_count{};
std::string effective_lighting_path{"forward"};
std::string device;
};
struct HzbDebugImage {
+1
View File
@@ -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
+171 -19
View File
@@ -24,6 +24,35 @@ struct FrameParameters {
[[vk::binding(1,0)]] SamplerState shadowSampler;
[[vk::binding(2,0)]] Texture2D<float4> 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<LightingHeader> lightingFrame;
[[vk::binding(1,1)]] StructuredBuffer<LocalLightGpu> localLights;
[[vk::binding(2,1)]] StructuredBuffer<ShadowViewGpu> shadowViews;
[[vk::binding(3,1)]] Texture2D<float> localShadowAtlas;
// 4-word header, then 66 words per 16x16 tile: count, overflow, 64 indices.
// An overflowing tile evaluates the full submitted list instead of losing light.
[[vk::binding(4,1)]] StructuredBuffer<uint> lightTileWords;
[shader("vertex")]
VertexOutput vertexMain(VertexInput v) {
VertexOutput o;
@@ -32,6 +61,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 +134,87 @@ 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);
}
uint candidateCount = lighting.counts.x;
uint tileBase = 0;
bool tileList = false;
if (lightTileWords[1] != 0 && lightTileWords[0] != 0 && lightTileWords[2] != 0) {
uint tileX = min(uint(v.position.x) / 16u, lightTileWords[0] - 1u);
uint tileY = min(uint(v.position.y) / 16u, lightTileWords[2] - 1u);
tileBase = 4u + (tileY * lightTileWords[0] + tileX) * 66u;
if (lightTileWords[tileBase + 1u] == 0) {
candidateCount = min(lightTileWords[tileBase], lighting.counts.x);
tileList = true;
}
}
float3 linear=base.rgb*.12 + ((1.0-fresnel)*(1.0-metal)*base.rgb/pi+spec)*nl*3.0*visibility;
for (uint candidate=0; candidate<candidateCount; ++candidate) {
uint i = tileList ? lightTileWords[tileBase + 2u + candidate] : candidate;
LocalLightGpu light=localLights[i];
float3 delta=light.positionRange.xyz-v.world;
float distanceSquared=max(dot(delta,delta),1e-6);
float distance=sqrt(distanceSquared);
float range=max(light.positionRange.w,1e-4);
if (distance >= 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);
}
linear=linear/(1.0+linear);
return float4(pow(max(linear,0),float3(1.0/2.2)),base.a);
}
+3 -3
View File
@@ -57,9 +57,9 @@ struct GpuFrameParameters {
uint4 drawInfo; // x=visible ID range base; firstInstance is always zero
};
[[vk::push_constant]] ConstantBuffer<GpuFrameParameters> gpuFrame;
[[vk::binding(0,1)]] StructuredBuffer<InstanceRecord> gfxInstances;
[[vk::binding(1,1)]] StructuredBuffer<uint> gfxVisibleIds;
[[vk::binding(2,1)]] StructuredBuffer<ViewRecord> gfxViews;
[[vk::binding(0,2)]] StructuredBuffer<InstanceRecord> gfxInstances;
[[vk::binding(1,2)]] StructuredBuffer<uint> gfxVisibleIds;
[[vk::binding(2,2)]] StructuredBuffer<ViewRecord> gfxViews;
// All indirect commands use firstInstance=0. The raw Vulkan index avoids the
// BaseInstance read that Slang adds for SV_InstanceID (DrawParameters feature).
+75
View File
@@ -0,0 +1,75 @@
// Conservative depth-free 16x16 Forward+ construction. One invocation owns
// one tile, so indices remain in the same sorted order as the forward reference.
struct LocalLightGpu {
float4 positionRange;
float4 directionCosOuter;
float4 colorIntensity;
float4 coneTypeShadowView;
float4 reserved;
};
struct TileBuildParameters {
column_major float4x4 viewProjection;
float4 viewport; // x, y, width, height in framebuffer pixels
uint4 dimensions; // tilesX, tilesY, submitted local lights, capacity (<= 64)
};
[[vk::push_constant]] ConstantBuffer<TileBuildParameters> build;
[[vk::binding(0,0)]] StructuredBuffer<LocalLightGpu> localLights;
[[vk::binding(1,0)]] RWStructuredBuffer<uint> tileWords;
bool sphereTouchesPlane(float3 center, float radius, float4 plane) {
// The final epsilon admits boundary/rounding cases rather than dropping a
// light. We intentionally do not use scene depth or reject near-plane cuts.
return dot(plane, float4(center, 1.0)) + radius * length(plane.xyz) >= -1e-4;
}
[shader("compute")]
[numthreads(64, 1, 1)]
void lightTileMain(uint3 dispatchId : SV_DispatchThreadID) {
uint tileId = dispatchId.x;
uint tilesX = build.dimensions.x;
uint tilesY = build.dimensions.y;
if (tileId >= tilesX * tilesY) return;
if (tileId == 0) {
tileWords[0] = tilesX;
tileWords[1] = 1;
tileWords[2] = tilesY;
tileWords[3] = min(build.dimensions.w, 64u);
}
uint tileX = tileId % tilesX;
uint tileY = tileId / tilesX;
float x0 = float(tileX * 16u);
float y0 = float(tileY * 16u);
float x1 = x0 + 16.0;
float y1 = y0 + 16.0;
float left = 2.0 * (x0 - build.viewport.x) / build.viewport.z - 1.0;
float right = 2.0 * (x1 - build.viewport.x) / build.viewport.z - 1.0;
float top = 2.0 * (y0 - build.viewport.y) / build.viewport.w - 1.0;
float bottom = 2.0 * (y1 - build.viewport.y) / build.viewport.w - 1.0;
float4 xRow = mul(float4(1, 0, 0, 0), build.viewProjection);
float4 yRow = mul(float4(0, 1, 0, 0), build.viewProjection);
float4 wRow = mul(float4(0, 0, 0, 1), build.viewProjection);
float4 leftPlane = xRow - left * wRow;
float4 rightPlane = right * wRow - xRow;
float4 topPlane = yRow - top * wRow;
float4 bottomPlane = bottom * wRow - yRow;
uint base = 4u + tileId * 66u;
uint count = 0;
bool overflow = false;
for (uint i = 0; i < build.dimensions.z; ++i) {
LocalLightGpu light = localLights[i];
if (light.colorIntensity.w <= 0.0) continue;
float3 center = light.positionRange.xyz;
float radius = light.positionRange.w;
if (!sphereTouchesPlane(center, radius, leftPlane) ||
!sphereTouchesPlane(center, radius, rightPlane) ||
!sphereTouchesPlane(center, radius, topPlane) ||
!sphereTouchesPlane(center, radius, bottomPlane)) continue;
if (count < min(build.dimensions.w, 64u))
tileWords[base + 2u + count] = i;
else
overflow = true;
++count;
}
tileWords[base] = min(count, min(build.dimensions.w, 64u));
tileWords[base + 1u] = overflow ? 1u : 0u;
}
+28 -3
View File
@@ -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<double>() <=
effective.at("outer_angle").get<double>(),
"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<int>::min()},
{"max", std::numeric_limits<int>::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};
+2
View File
@@ -333,6 +333,7 @@ struct BuildService::Impl {
copy_required_file(player, staging / ("faset_player" + executable_suffix()));
copy_required_file(exporter, staging / ("faset_schema_exporter" + executable_suffix()));
for (const auto* file : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv",
"lightTileMain.spv", "lightTileMain.reflection.json",
"vertexMain.reflection.json", "fragmentMain.reflection.json",
"shadowMain.reflection.json", "gpuVertexMain.spv",
"gpuShadowMain.spv", "gpuCullMain.spv", "gpuHzbMain.spv",
@@ -585,6 +586,7 @@ struct BuildService::Impl {
copy_required_file(build_directory / ("faset_player" + executable_suffix()),
staging / ("faset_player" + executable_suffix()));
for (const auto* shader : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv",
"lightTileMain.spv", "lightTileMain.reflection.json",
"vertexMain.reflection.json", "fragmentMain.reflection.json",
"shadowMain.reflection.json", "gpuVertexMain.spv",
"gpuShadowMain.spv", "gpuCullMain.spv", "gpuHzbMain.spv",
+29
View File
@@ -381,6 +381,35 @@ 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("Light tiles: %u; GPU build %.2f ms",
stats.light_tile_count, stats.gpu_light_tiles_ms);
if (stats.light_tile_counts_valid)
ImGui::Text("Tile entries: %u; overflow tiles: %u",
stats.light_tile_candidate_count,
stats.light_tile_overflow_count);
else if (stats.light_tile_count)
ImGui::TextDisabled("Tile entry counts unavailable until diagnostics readback");
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",
+129 -5
View File
@@ -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<std::string, std::string> 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<std::string>();
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<std::string>();
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<std::pair<int, render::Sprite>> sprites;
std::vector<render::SunLight> directionalLights;
for (const auto& entity : entities) {
const auto model = world(world, entity);
const auto id = entity.at("id").get<std::string>();
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<std::string>();
}
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<double>();
if (!std::isfinite(decimal) ||
std::abs(decimal) > std::numeric_limits<float>::max())
invalid(field);
return static_cast<float>(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<bool>();
};
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<float> / 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<double>();
if (priority < std::numeric_limits<int>::min() ||
priority > std::numeric_limits<int>::max())
invalid("shadow_priority");
local.shadow_priority = fields.at("shadow_priority").get<int>();
}
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<float> / 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()),
+394
View File
@@ -0,0 +1,394 @@
#include <faset/render/lighting.hpp>
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <numbers>
#include <stdexcept>
#include <unordered_set>
namespace faset::render {
namespace {
Vec3 add(Vec3 a, Vec3 b) { return {a[0] + b[0], a[1] + b[1], a[2] + b[2]}; }
Vec3 subtract(Vec3 a, Vec3 b) { return {a[0] - b[0], a[1] - b[1], a[2] - b[2]}; }
Vec3 scale(Vec3 a, float factor) { return {a[0] * factor, a[1] * factor, a[2] * factor}; }
float dot(Vec3 a, Vec3 b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
float length(Vec3 a) { return std::sqrt(dot(a, a)); }
Vec3 unit(Vec3 a) {
const float magnitude = length(a);
if (!std::isfinite(magnitude) || magnitude < 1e-6f)
throw std::invalid_argument("Shadow light direction must be finite and nonzero");
return scale(a, 1.f / magnitude);
}
Vec3 project(const Mat4& matrix, Vec3 value) {
return {matrix[0] * value[0] + matrix[4] * value[1] + matrix[8] * value[2] + matrix[12],
matrix[1] * value[0] + matrix[5] * value[1] + matrix[9] * value[2] + matrix[13],
matrix[2] * value[0] + matrix[6] * value[1] + matrix[10] * value[2] + matrix[14]};
}
std::array<float, 4> clip(const Mat4& matrix, Vec3 value) {
return {matrix[0] * value[0] + matrix[4] * value[1] + matrix[8] * value[2] + matrix[12],
matrix[1] * value[0] + matrix[5] * value[1] + matrix[9] * value[2] + matrix[13],
matrix[2] * value[0] + matrix[6] * value[1] + matrix[10] * value[2] + matrix[14],
matrix[3] * value[0] + matrix[7] * value[1] + matrix[11] * value[2] + matrix[15]};
}
std::array<Vec3, 8> corners(const Bounds& bounds) {
std::array<Vec3, 8> result{};
for (unsigned i = 0; i < 8; ++i)
result[i] = {i & 1 ? bounds.max[0] : bounds.min[0],
i & 2 ? bounds.max[1] : bounds.min[1],
i & 4 ? bounds.max[2] : bounds.min[2]};
return result;
}
Vec3 camera_to_world(const Mat4& view, Vec3 camera) {
// CameraFrustum::view is an unscaled, orthonormal look_at matrix.
return {view[0] * (camera[0] - view[12]) + view[1] * (camera[1] - view[13]) +
view[2] * (camera[2] - view[14]),
view[4] * (camera[0] - view[12]) + view[5] * (camera[1] - view[13]) +
view[6] * (camera[2] - view[14]),
view[8] * (camera[0] - view[12]) + view[9] * (camera[1] - view[13]) +
view[10] * (camera[2] - view[14])};
}
std::array<Vec3, 8> frustum_slice(const CameraFrustum& camera, float near_distance,
float far_distance) {
std::array<Vec3, 8> result{};
for (unsigned i = 0; i < 8; ++i) {
const float distance = i & 4 ? far_distance : near_distance;
const float x = i & 1 ? 1.f : -1.f;
const float y = i & 2 ? 1.f : -1.f;
Vec3 local{};
if (camera.perspective)
local = {x * distance / camera.projection[0],
y * distance / camera.projection[5], -distance};
else
local = {(x - camera.projection[12]) / camera.projection[0],
(y - camera.projection[13]) / camera.projection[5], -distance};
result[i] = camera_to_world(camera.view, local);
}
return result;
}
bool overlaps_xy(const Bounds& bounds, const Mat4& light_view, float left, float right,
float bottom, float top) {
float min_x = std::numeric_limits<float>::infinity();
float max_x = -min_x, min_y = min_x, max_y = -min_x;
for (const auto point : corners(bounds)) {
const auto light = project(light_view, point);
min_x = std::min(min_x, light[0]);
max_x = std::max(max_x, light[0]);
min_y = std::min(min_y, light[1]);
max_y = std::max(max_y, light[1]);
}
return max_x >= left && min_x <= right && max_y >= bottom && min_y <= top;
}
bool intersects_frustum(const Bounds& bounds, const Mat4& view_projection) {
std::array<unsigned, 7> rejected{};
for (const auto point : corners(bounds)) {
const auto p = clip(view_projection, point);
if (!std::all_of(p.begin(), p.end(), [](float value) { return std::isfinite(value); }))
return true; // Invalid projection fails open so no caster is lost silently.
rejected[0] += p[0] < -p[3];
rejected[1] += p[0] > p[3];
rejected[2] += p[1] < -p[3];
rejected[3] += p[1] > p[3];
rejected[4] += p[2] < 0;
rejected[5] += p[2] > p[3];
rejected[6] += p[3] <= 0;
}
return std::none_of(rejected.begin(), rejected.end(), [](unsigned count) { return count == 8; });
}
void tile(ShadowView& view, std::uint32_t atlas_size, std::uint32_t tiles_across,
std::uint32_t index) {
constexpr std::uint32_t guard = 2;
const auto size = atlas_size / tiles_across;
view.tile_index = index;
view.tile_origin_x = (index % tiles_across) * size;
view.tile_origin_y = (index / tiles_across) * size;
view.tile_size = size;
view.usable_size = size - 2 * guard;
const auto reciprocal = 1.f / float(atlas_size);
view.atlas_scale_offset = {float(view.usable_size) * reciprocal,
float(view.usable_size) * reciprocal,
float(view.tile_origin_x + guard) * reciprocal,
float(view.tile_origin_y + guard) * reciprocal};
view.guarded_clamp = {(float(view.tile_origin_x + guard) + 1.5f) * reciprocal,
(float(view.tile_origin_y + guard) + 1.5f) * reciprocal,
(float(view.tile_origin_x + size - guard) - 1.5f) * reciprocal,
(float(view.tile_origin_y + size - guard) - 1.5f) * reciprocal};
}
std::vector<std::uint32_t> visible_casters(const Mat4& view_projection,
std::span<const ShadowCasterBounds> casters) {
std::vector<std::uint32_t> result;
for (const auto& caster : casters)
if (intersects_frustum(caster.world, view_projection))
result.push_back(caster.draw_index);
std::sort(result.begin(), result.end());
result.erase(std::unique(result.begin(), result.end()), result.end());
return result;
}
bool supported_atlas(std::uint32_t size, bool available) {
return available && (size == 2048 || size == 1024);
}
void validate_local(const LocalLight& light) {
const auto invalid = [&](const char* field) {
throw std::invalid_argument("Local light " + light.stable_id + " has invalid " + field);
};
if (light.stable_id.empty())
invalid("stable_id");
if (!std::all_of(light.position.begin(), light.position.end(),
[](float v) { return std::isfinite(v); }))
invalid("position");
if (!std::all_of(light.color.begin(), light.color.end(),
[](float v) { return std::isfinite(v) && v >= 0; }))
invalid("color");
if (!std::isfinite(light.intensity) || light.intensity < 0)
invalid("intensity");
if (!std::isfinite(light.range) || light.range <= 0)
invalid("range");
if (light.kind == LocalLight::Kind::Spot) {
if (!std::isfinite(light.inner_angle) || !std::isfinite(light.outer_angle) ||
light.inner_angle < 0 || light.inner_angle > light.outer_angle ||
light.outer_angle >= std::numbers::pi_v<float> / 2 || light.outer_angle <= 0)
invalid("inner_angle/outer_angle");
if (!std::all_of(light.direction.begin(), light.direction.end(),
[](float v) { return std::isfinite(v); }) ||
length(light.direction) < 1e-6f)
invalid("direction");
}
}
float projected_influence(const LocalLight& light, const Snapshot& frame) {
const auto distance = length(subtract(light.position, frame.eye));
const auto projection_scale =
std::max(std::abs(frame.projection[0]), std::abs(frame.projection[5]));
return light.range * projection_scale / std::max(distance, .1f);
}
ShadowView sun_view(const Snapshot& frame, const SunLight& sun,
std::span<const ShadowCasterBounds> casters, float split_near,
float split_far, std::uint32_t index, std::uint32_t atlas_size) {
ShadowView result;
result.kind = ShadowView::Kind::Sun;
result.light_id = sun.stable_id;
result.face_index = index;
result.split_near = split_near;
result.split_far = split_far;
tile(result, atlas_size, 2, index);
const auto direction = unit(sun.direction);
const auto up = std::abs(direction[1]) > .98f ? Vec3{0, 0, 1} : Vec3{0, 1, 0};
const auto light_origin_view = look_at({0, 0, 0}, direction, up);
if (!frame.camera_frustum) {
const auto light_eye = scale(direction, -30);
result.view_projection = multiply(orthographic(-20, 20, -20, 20, .1f, 80),
look_at(light_eye, {0, 0, 0}, up));
result.caster_indices = visible_casters(result.view_projection, casters);
return result;
}
const auto receivers = frustum_slice(*frame.camera_frustum, split_near, split_far);
Vec3 center{};
for (const auto corner : receivers)
center = add(center, scale(corner, 1.f / 8));
float radius{};
for (const auto corner : receivers)
radius = std::max(radius, length(subtract(corner, center)));
radius = std::max(.25f, std::ceil(radius * 16.f) / 16.f);
const auto center_light = project(light_origin_view, center);
const auto texel = (2 * radius) / float(result.usable_size);
result.snapped_center_x = std::round(center_light[0] / texel) * texel;
result.snapped_center_y = std::round(center_light[1] / texel) * texel;
const float left = result.snapped_center_x - radius;
const float right = result.snapped_center_x + radius;
const float bottom = result.snapped_center_y - radius;
const float top = result.snapped_center_y + radius;
float nearest_ray = std::numeric_limits<float>::infinity();
float furthest_ray = -nearest_ray;
for (const auto corner : receivers) {
const auto ray = dot(corner, direction);
nearest_ray = std::min(nearest_ray, ray);
furthest_ray = std::max(furthest_ray, ray);
}
for (const auto& caster : casters) {
if (!overlaps_xy(caster.world, light_origin_view, left, right, bottom, top))
continue;
result.caster_indices.push_back(caster.draw_index);
for (const auto corner : corners(caster.world)) {
const auto ray = dot(corner, direction);
nearest_ray = std::min(nearest_ray, ray);
furthest_ray = std::max(furthest_ray, ray);
}
}
std::sort(result.caster_indices.begin(), result.caster_indices.end());
result.caster_indices.erase(
std::unique(result.caster_indices.begin(), result.caster_indices.end()),
result.caster_indices.end());
const auto light_eye = scale(direction, nearest_ray - 1.f);
const auto view = look_at(light_eye, add(light_eye, direction), up);
const auto depth = std::max(2.f, furthest_ray - nearest_ray + 2.f);
result.view_projection = multiply(orthographic(left, right, bottom, top, .1f, depth),
view);
return result;
}
ShadowView local_view(const LocalLight& light, std::uint32_t face,
std::span<const ShadowCasterBounds> casters) {
ShadowView result;
result.kind = light.kind == LocalLight::Kind::Point ? ShadowView::Kind::Point
: ShadowView::Kind::Spot;
result.light_id = light.stable_id;
result.face_index = face;
static constexpr std::array<Vec3, 6> axes{{{1, 0, 0}, {-1, 0, 0}, {0, 1, 0},
{0, -1, 0}, {0, 0, 1}, {0, 0, -1}}};
static constexpr std::array<Vec3, 6> ups{{{0, -1, 0}, {0, -1, 0}, {0, 0, 1},
{0, 0, -1}, {0, -1, 0}, {0, -1, 0}}};
const auto direction = light.kind == LocalLight::Kind::Point ? axes.at(face)
: unit(light.direction);
const auto up = light.kind == LocalLight::Kind::Point ? ups.at(face)
: std::abs(direction[1]) > .98f ? Vec3{0, 0, 1} : Vec3{0, 1, 0};
const auto near_plane = std::max(.0001f, std::min(.05f, light.range * .1f));
// 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<float> / 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<const ShadowCasterBounds> casters,
const ShadowBudget& budget) {
for (const auto& caster : casters)
for (int axis = 0; axis < 3; ++axis)
if (!std::isfinite(caster.world.min[axis]) ||
!std::isfinite(caster.world.max[axis]) ||
caster.world.min[axis] > caster.world.max[axis])
throw std::invalid_argument("Shadow caster world bounds must be finite and ordered");
ShadowPlan plan;
plan.sun_atlas_size = supported_atlas(budget.sun_atlas_size, budget.sun_atlas_available)
? budget.sun_atlas_size : 0;
plan.local_atlas_size = supported_atlas(budget.local_atlas_size, budget.local_atlas_available)
? budget.local_atlas_size : 0;
std::unordered_set<std::string> ids;
std::vector<std::pair<std::size_t, float>> ranked;
ranked.reserve(frame.local_lights.size());
for (std::size_t i = 0; i < frame.local_lights.size(); ++i) {
const auto& light = frame.local_lights[i];
validate_local(light); // Validate overflow records too, before truncation.
if (!ids.insert(light.stable_id).second)
throw std::invalid_argument("Duplicate local light stable_id: " + light.stable_id);
ranked.emplace_back(i, projected_influence(light, frame));
}
std::sort(ranked.begin(), ranked.end(), [&](const auto& a, const auto& b) {
const auto& left = frame.local_lights[a.first];
const auto& right = frame.local_lights[b.first];
if (left.shadow_priority != right.shadow_priority)
return left.shadow_priority > right.shadow_priority;
if (a.second != b.second)
return a.second > b.second;
return left.stable_id < right.stable_id;
});
const auto selected = std::min<std::size_t>(ranked.size(),
std::min(budget.max_local_lights, 128u));
plan.omitted_local_lights = static_cast<std::uint32_t>(ranked.size() - selected);
for (std::size_t i = 0; i < selected; ++i)
plan.submitted_local_indices.push_back(ranked[i].first);
std::optional<SunLight> sun = frame.sun;
if (!sun && !frame.authored_lights_present && frame.local_lights.empty())
sun = SunLight{"legacy-sun", frame.light_direction, {1, 1, 1, 1}, 1, true};
if (sun && sun->casts_shadow) {
plan.requested_sun_cascades = frame.camera_frustum
? std::min(4u, budget.max_sun_views) : 1u;
if (frame.camera_frustum &&
(frame.camera_frustum->near_plane <= 0 ||
frame.camera_frustum->far_plane <= frame.camera_frustum->near_plane ||
frame.camera_frustum->projection[0] == 0 ||
frame.camera_frustum->projection[5] == 0))
throw std::invalid_argument("Shadow camera frustum is invalid");
const float near_plane = frame.camera_frustum ? frame.camera_frustum->near_plane : .1f;
const float far_plane = frame.camera_frustum
? std::min(frame.camera_frustum->far_plane, budget.max_shadow_distance) : 80.f;
if (far_plane <= near_plane)
throw std::invalid_argument("Shadow distance does not reach the camera near plane");
float previous = near_plane;
for (std::uint32_t i = 0; i < plan.requested_sun_cascades; ++i) {
const auto ratio = float(i + 1) / float(plan.requested_sun_cascades);
const auto logarithmic = near_plane * std::pow(far_plane / near_plane, ratio);
const auto uniform = near_plane + (far_plane - near_plane) * ratio;
const auto split = i + 1 == plan.requested_sun_cascades
? far_plane : .5f * (logarithmic + uniform);
ShadowView view;
if (plan.sun_atlas_size)
view = sun_view(frame, *sun, casters, previous, split, i, plan.sun_atlas_size);
else {
view.kind = ShadowView::Kind::Sun;
view.light_id = sun->stable_id;
view.face_index = i;
view.split_near = previous;
view.split_far = split;
view.reason = ShadowDropReason::Unavailable;
}
if (plan.sun_atlas_size &&
view.caster_indices.size() <=
budget.max_caster_draws - std::min(plan.caster_draws, budget.max_caster_draws)) {
view.valid = true;
plan.caster_draws += static_cast<std::uint32_t>(view.caster_indices.size());
++plan.effective_sun_cascades;
} else {
if (plan.sun_atlas_size)
view.reason = ShadowDropReason::CasterBudget;
view.caster_indices.clear();
++plan.dropped_sun_views;
}
plan.sun_views.push_back(std::move(view));
previous = split;
}
}
for (const auto source : plan.submitted_local_indices) {
const auto& light = frame.local_lights[source];
LocalShadowAssignment assignment;
assignment.source_index = source;
assignment.first_view = static_cast<std::uint32_t>(plan.local_views.size());
const auto faces = light.kind == LocalLight::Kind::Point ? 6u : 1u;
if (!light.casts_shadow || light.intensity == 0) {
plan.local_assignments.push_back(assignment);
continue;
}
plan.local_faces_requested += faces;
if (!plan.local_atlas_size)
assignment.reason = ShadowDropReason::Unavailable;
else if (const auto capacity = std::min(budget.max_local_faces, 16u);
faces > capacity - std::min(plan.local_faces_used, capacity))
assignment.reason = ShadowDropReason::TileBudget;
else {
std::vector<ShadowView> group;
std::uint32_t group_draws{};
for (std::uint32_t face = 0; face < faces; ++face) {
auto view = local_view(light, face, casters);
tile(view, plan.local_atlas_size, 4,
plan.local_faces_used + face);
group_draws += static_cast<std::uint32_t>(view.caster_indices.size());
group.push_back(std::move(view));
}
if (group_draws > budget.max_caster_draws -
std::min(plan.caster_draws, budget.max_caster_draws))
assignment.reason = ShadowDropReason::CasterBudget;
else {
assignment.valid = true;
assignment.face_count = faces;
plan.local_faces_used += faces;
plan.caster_draws += group_draws;
for (auto& view : group) {
view.valid = true;
plan.local_views.push_back(std::move(view));
}
}
}
if (!assignment.valid) {
plan.dropped_local_faces += faces;
if (light.kind == LocalLight::Kind::Point)
plan.dropped_point_faces += 6;
}
plan.local_assignments.push_back(assignment);
}
return plan;
}
} // namespace faset::render
+737 -79
View File
File diff suppressed because it is too large Load Diff
+57 -7
View File
@@ -30,17 +30,64 @@ void locations(const Json& fields, std::initializer_list<const char*> types, con
++index;
}
}
void validate_tile_layout(const Json& layout) {
require(layout.at("stage") == "compute", "light tile shader stage changed");
const auto& descriptors = layout.at("descriptors");
require(descriptors.is_array() && descriptors.size() == 2,
"light tile descriptor count changed");
for (std::size_t i = 0; i < 2; ++i)
require(descriptors[i].at("set") == 0 && descriptors[i].at("binding") == i &&
descriptors[i].at("count") == 1 &&
descriptors[i].at("type") == "storage_buffer" &&
descriptors[i].at("element_stride") == (i == 0 ? 80 : 4),
"light tile descriptor ABI changed");
const auto& constants = layout.at("push_constants");
require(constants.is_array() && constants.size() == 1 &&
constants[0].at("offset") == 0 && constants[0].at("size") == 96,
"light tile push size changed");
const auto& members = constants[0].at("members");
require(members.is_array() && members.size() == 3,
"light tile push members changed");
const int offsets[] = {0, 64, 80};
const char* types[] = {"float32x4x4", "float32x4", "uint32x4"};
for (std::size_t i = 0; i < 3; ++i)
require(members[i].at("offset") == offsets[i] &&
members[i].at("size") == (i == 0 ? 64 : 16) &&
members[i].at("type") == types[i],
"light tile push field changed");
const auto& blocks = layout.at("spirv_push_constants");
require(blocks.is_array() && blocks.size() == 1 &&
blocks[0].at("members").size() == 3,
"light tile SPIR-V push block changed");
const auto& actual = blocks[0].at("members");
for (std::size_t i = 0; i < 3; ++i)
require(actual[i].at("member") == i && actual[i].at("offset") == offsets[i],
"light tile SPIR-V push offset changed");
require(actual[0].at("matrix_layout") == "row-major" &&
actual[0].at("matrix_stride") == 16,
"light tile SPIR-V matrix storage convention changed");
locations(layout.at("inputs"), {}, "light tile inputs");
locations(layout.at("outputs"), {}, "light tile outputs");
}
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() == 9, "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 = set == 0 ? i : 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 == 4 ? 4 : slot == 2 ? 112 : 80),
"lighting storage record stride changed");
require(fragment || !binding.at("used").get<bool>(),
"vertex texture bindings are unsupported");
}
@@ -98,7 +145,7 @@ void validate_gpu_layout(const Json& layout, std::string_view entry) {
const std::array<int, 3> 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];
@@ -190,22 +237,25 @@ detail::ShaderCode load(const std::filesystem::path& directory, const char* entr
require(metadata.at("layout_fingerprint") == fingerprint, "layout fingerprint mismatch");
if (gpu)
validate_gpu_layout(layout, entry);
else if (std::string_view(entry) == "lightTileMain")
validate_tile_layout(layout);
else
validate_layout(layout, entry);
detail::ShaderCode result;
result.layout_fingerprint = fingerprint;
result.words.resize(bytes.size() / 4);
std::memcpy(result.words.data(), bytes.data(), bytes.size());
validate_spirv(result.words, gpu ? ((std::string_view(entry) == "gpuVertexMain" ||
validate_spirv(result.words, std::string_view(entry) == "lightTileMain" ? 5u :
gpu ? ((std::string_view(entry) == "gpuVertexMain" ||
std::string_view(entry) == "gpuShadowMain") ? 0u : 5u)
: (std::string_view(entry) == "fragmentMain" ? 4u : 0u));
return result;
}
} // namespace
std::array<detail::ShaderCode, 3>
std::array<detail::ShaderCode, 4>
detail::load_shader_bundle(const std::filesystem::path& directory) {
return {load(directory, "vertexMain"), load(directory, "fragmentMain"),
load(directory, "shadowMain")};
load(directory, "shadowMain"), load(directory, "lightTileMain")};
}
std::array<detail::ShaderCode, 5>
detail::load_gpu_shader_bundle(const std::filesystem::path& directory) {
+2 -1
View File
@@ -10,7 +10,8 @@ struct ShaderCode {
std::vector<std::uint32_t> words;
std::string layout_fingerprint;
};
std::array<ShaderCode, 3> load_shader_bundle(const std::filesystem::path& directory);
// Direct graphics plus the independent 16x16 light-tile compute entry.
std::array<ShaderCode, 4> load_shader_bundle(const std::filesystem::path& directory);
// Order: opaque vertex, optional instanced shadow vertex, main cull, HZB, post cull.
std::array<ShaderCode, 5> load_gpu_shader_bundle(const std::filesystem::path& directory);
} // namespace faset::render::detail
+27
View File
@@ -2,6 +2,7 @@
#include <faset/authoring/templates.hpp>
#include <faset/authoring/transforms.hpp>
#include <faset/core/io.hpp>
#include <cstdint>
#include <iostream>
#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"];
+1 -1
View File
@@ -148,7 +148,7 @@ int test_main(int argc, char** argv) {
const auto first = builds.wait(builds.start_build());
check(first.state == "succeeded", "Valid custom schema v2 publishes: " + first.error);
const auto directory = path_from_utf8(first.result.at("directory").get<std::string>());
for (const auto* entry : {"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
for (const auto* entry : {"lightTileMain", "gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"})
for (const auto* extension : {".spv", ".reflection.json"})
check(fs::is_regular_file(directory / "shaders" /
+1 -1
View File
@@ -60,7 +60,7 @@ int tool_main(int argc, char** argv) {
for (const auto* target : {"faset_player", "faset_schema_exporter"})
fs::copy_file(self, build / (std::string(target) + suffix),
fs::copy_options::overwrite_existing);
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain",
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain", "lightTileMain",
"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"})
for (const auto* extension : {".spv", ".reflection.json"})
+17
View File
@@ -29,6 +29,23 @@ 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",
"gpu_light_tiles_ms", "light_tile_count", "light_tile_counts_valid",
"light_tile_candidate_count", "light_tile_overflow_count"]:
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"],
@@ -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");
+403
View File
@@ -0,0 +1,403 @@
#include <faset/render/renderer.hpp>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
using namespace faset::render;
namespace {
void require(bool condition, const std::string& message) {
if (!condition)
throw std::runtime_error(message);
}
struct Frame {
std::vector<std::uint8_t> pixels;
FrameStats stats;
};
Frame capture(Renderer& renderer, const Snapshot& scene) {
renderer.render(scene);
return {renderer.pixels(), renderer.stats()};
}
Renderer make_renderer(VisibilityMode mode, LightingMode lighting = LightingMode::Auto) {
RendererConfig config;
config.width = 320;
config.height = 240;
config.headless = true;
config.validation = true;
config.visibility_mode = mode;
config.lighting_mode = lighting;
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<std::size_t>(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<Mesh>(*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");
}
void tiled() {
for (auto visibility : {VisibilityMode::Direct, VisibilityMode::GpuFrustum,
VisibilityMode::GpuOcclusion}) {
auto forward = make_renderer(visibility, LightingMode::Forward);
auto tiles = make_renderer(visibility, LightingMode::Tiled);
auto fixture = local_scene(LocalLight::Kind::Point, false);
auto no_lights = fixture;
no_lights.local_lights.clear();
const auto empty_tiled = capture(tiles, no_lights);
require(empty_tiled.stats.effective_lighting_path == "forward" &&
empty_tiled.stats.light_tile_count == 0,
"Forced tiles correctly fall back when no local lights are submitted");
fixture.scene_rect = {32, 24, 256, 192};
fixture.local_lights.front().casts_shadow = false;
auto spot = fixture.local_lights.front();
spot.kind = LocalLight::Kind::Spot;
spot.stable_id = "second-spot";
spot.position = {1.5f, 2, 0};
spot.direction = {0, -1, 0};
spot.intensity = 7;
spot.range = 4;
fixture.local_lights.push_back(spot);
auto outside = spot;
outside.stable_id = "offscreen-light";
outside.position = {100, 100, 100};
outside.range = 2;
fixture.local_lights.push_back(outside);
const auto expected = capture(forward, fixture);
const auto actual = capture(tiles, fixture);
require(expected.stats.effective_lighting_path == "forward" &&
actual.stats.effective_lighting_path == "tiled" &&
actual.stats.gpu_light_tiles_ms > 0 &&
actual.stats.light_tile_count > 0,
"Forced 16x16 tile construction reports its actual GPU work");
require(actual.stats.validation_errors == 0,
"Forward+ tile build and fragment reads pass Vulkan validation");
require(actual.stats.light_tile_overflow_count == 0 &&
actual.stats.light_tile_candidate_count <
actual.stats.light_tile_count * 3,
"Depth-free tile lists exclude an offscreen light without overflow");
compare_frames(expected, actual);
auto near_plane = local_scene(LocalLight::Kind::Point, true);
near_plane.local_lights.front().position = {0, 5, 7.95f};
near_plane.local_lights.front().range = 15;
const auto near_forward = capture(forward, near_plane);
const auto near_tiled = capture(tiles, near_plane);
require(near_tiled.stats.effective_lighting_path == "tiled" &&
near_tiled.stats.light_tile_counts_valid,
"Near-plane crossing light and its shadow use actual tile lists");
compare_frames(near_forward, near_tiled);
forward.resize(336, 256);
tiles.resize(336, 256);
fixture.scene_rect = {40, 32, 248, 176};
const auto resized_forward = capture(forward, fixture);
const auto resized_tiled = capture(tiles, fixture);
require(resized_tiled.stats.light_tile_count == 21 * 16,
"Forward+ rebuilds its grid after a drawable resize");
compare_frames(resized_forward, resized_tiled);
// Eighty coincident lights cover the same central tiles. A 64-index tile
// must evaluate the entire submitted list instead of losing late lights.
fixture.local_lights.clear();
for (int i = 0; i < 80; ++i) {
auto light = point_face_scene({0, 0, 1}, false).local_lights.front();
light.stable_id = "overflow-" + std::to_string(i);
light.position = {0, 3, 0};
light.intensity = .45f;
light.range = 8;
light.casts_shadow = false;
fixture.local_lights.push_back(light);
}
const auto all_forward = capture(forward, fixture);
const auto all_tiled = capture(tiles, fixture);
auto automatic = make_renderer(visibility, LightingMode::Auto);
const auto dense_auto = capture(automatic, fixture);
require(dense_auto.stats.effective_lighting_path == "forward" &&
dense_auto.stats.light_tile_count == 0,
"Auto avoids tile construction for unmeasured dense overlap");
require(all_tiled.stats.submitted_local_lights == 80 &&
all_tiled.stats.effective_lighting_path == "tiled" &&
all_tiled.stats.light_tile_overflow_count > 0,
"Overflow fixture submits all eighty lights through Forward+");
compare_frames(all_forward, all_tiled);
fixture.local_lights.resize(64);
const auto first_sixty_four = capture(forward, fixture);
std::size_t extra_light_pixels{};
for (std::size_t i = 0; i < all_forward.pixels.size(); i += 4)
extra_light_pixels += int(all_forward.pixels[i]) >
int(first_sixty_four.pixels[i]) + 2;
require(extra_light_pixels > 20,
"Overflow fixture visibly depends on lights past index 63");
}
}
} // namespace
int main(int argc, char** argv) {
try {
if (argc != 2)
throw std::invalid_argument("Expected --sun, --local, or --tiled");
if (std::string(argv[1]) == "--sun")
sun();
else if (std::string(argv[1]) == "--local")
local();
else if (std::string(argv[1]) == "--tiled")
tiled();
else
throw std::invalid_argument("Expected --sun, --local, or --tiled");
std::cout << "Shadow atlas and Direct/GPU lighting parity passed\n";
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
+191
View File
@@ -0,0 +1,191 @@
#include <faset/render/lighting.hpp>
#include <algorithm>
#include <array>
#include <cmath>
#include <iostream>
#include <stdexcept>
using namespace faset::render;
namespace {
void require(bool condition, const char* message) {
if (!condition)
throw std::runtime_error(message);
}
Snapshot fixture() {
Snapshot frame;
const Vec3 eye{0, 2, 8};
const auto view = look_at(eye, {0, 0, 0});
const auto projection = perspective(.9f, 16.f / 9.f, .1f, 120.f);
frame.eye = eye;
frame.view_projection = multiply(projection, view);
frame.projection = projection;
frame.camera_frustum = CameraFrustum{view, projection, .1f, 120.f, true};
frame.authored_lights_present = true;
frame.sun = SunLight{"sun", {-.7f, -.5f, -.3f}, {1, 1, 1, 1}, 1, true};
return frame;
}
LocalLight point_light(std::string id, int priority = 0) {
LocalLight light;
light.kind = LocalLight::Kind::Point;
light.stable_id = std::move(id);
light.position = {0, 2, 0};
light.range = 12;
light.shadow_priority = priority;
return light;
}
LocalLight spot_light(std::string id, int priority = 0) {
auto light = point_light(std::move(id), priority);
light.kind = LocalLight::Kind::Spot;
light.direction = {0, -1, 0};
return light;
}
bool contains_caster(const ShadowView& view, std::uint32_t draw_index) {
return std::find(view.caster_indices.begin(), view.caster_indices.end(), draw_index) !=
view.caster_indices.end();
}
void run() {
auto frame = fixture();
const auto plan = build_shadow_plan(frame, {}, {});
require(plan.sun_views.size() == 4 && plan.effective_sun_cascades == 4,
"Explicit camera receives four usable sun cascades");
require(plan.sun_views[0].split_near == .1f &&
std::abs(plan.sun_views.back().split_far - 80.f) < 1e-4f,
"Practical splits start at camera near and stop at shadow distance");
for (std::size_t i = 1; i < plan.sun_views.size(); ++i)
require(plan.sun_views[i].split_near == plan.sun_views[i - 1].split_far &&
plan.sun_views[i].split_far > plan.sun_views[i].split_near,
"Cascade split endpoints are strictly increasing and contiguous");
require(plan.sun_views[0].tile_index == 0 && plan.sun_views[3].tile_index == 3 &&
plan.sun_views[0].usable_size == 1020,
"Four guarded 1024-square tiles fit a 2048-square sun atlas");
auto shifted = frame;
shifted.eye[0] += .00001f;
const auto shifted_view = look_at(shifted.eye, {.00001f, 0, 0});
shifted.camera_frustum->view = shifted_view;
shifted.view_projection = multiply(shifted.projection, shifted_view);
const auto stable = build_shadow_plan(shifted, {}, {});
require(stable.sun_views[0].snapped_center_x == plan.sun_views[0].snapped_center_x &&
stable.sun_views[0].snapped_center_y == plan.sun_views[0].snapped_center_y,
"Subtexel camera translation retains the snapped sun projection origin");
const auto sun_direction = frame.sun->direction;
const auto inv_length = 1.f / std::hypot(sun_direction[0], sun_direction[1], sun_direction[2]);
const Vec3 upstream{-sun_direction[0] * inv_length * 18,
-sun_direction[1] * inv_length * 18,
-sun_direction[2] * inv_length * 18};
const ShadowCasterBounds offscreen{{{upstream[0] - .5f, upstream[1] - .5f,
upstream[2] - .5f},
{upstream[0] + .5f, upstream[1] + .5f,
upstream[2] + .5f}}, 7};
const ShadowCasterBounds outside{{{999, 0, 0}, {1001, 2, 2}}, 8};
const std::array casters{offscreen, outside};
const auto with_casters = build_shadow_plan(frame, casters, {});
require(std::any_of(with_casters.sun_views.begin(), with_casters.sun_views.end(),
[](const auto& view) { return contains_caster(view, 7); }),
"Offscreen upstream caster remains in a receiver's sun shadow view");
require(std::none_of(with_casters.sun_views.begin(), with_casters.sun_views.end(),
[](const auto& view) { return contains_caster(view, 8); }),
"Caster outside every sun XY footprint is excluded");
std::vector<ShadowCasterBounds> many(4097, {{{-.1f, -.1f, -.1f}, {.1f, .1f, .1f}}, 0});
for (std::uint32_t i = 0; i < many.size(); ++i)
many[i].draw_index = i;
const auto overdraw = build_shadow_plan(frame, many, {});
require(overdraw.caster_draws <= 4096 &&
std::any_of(overdraw.sun_views.begin(), overdraw.sun_views.end(),
[](const auto& view) {
return !view.valid && view.reason == ShadowDropReason::CasterBudget &&
view.caster_indices.empty();
}),
"A view with 4097 casters is skipped whole rather than partially rendered");
frame.sun.reset();
for (int i = 0; i < 15; ++i)
frame.local_lights.push_back(spot_light("spot-" + std::to_string(i), 10));
frame.local_lights.push_back(point_light("last-point"));
const auto capacity = build_shadow_plan(frame, {}, {});
require(capacity.local_faces_used == 15 && capacity.dropped_point_faces == 6 &&
capacity.local_faces_used <= 16 && capacity.caster_draws <= 4096,
"Insufficient room for six point faces drops the complete point shadow");
require(capacity.submitted_local_indices.size() == 16 &&
std::none_of(capacity.local_views.begin(), capacity.local_views.end(),
[](const auto& view) { return view.light_id == "last-point"; }),
"Atlas overflow leaves the point light in the lighting list, unshadowed");
frame.local_lights = {point_light("omnidirectional")};
const ShadowCasterBounds positive_x{{{3, -.2f, -.2f}, {3.4f, .2f, .2f}}, 19};
const auto point_faces = build_shadow_plan(frame, std::array{positive_x}, {});
require(point_faces.local_views.size() == 6 &&
contains_caster(point_faces.local_views[0], 19) &&
!contains_caster(point_faces.local_views[1], 19),
"Point-light caster behind the opposite face is culled from that face");
frame.local_lights.clear();
for (int i = 0; i < 15; ++i)
frame.local_lights.push_back(spot_light("spot-" + std::to_string(i), 10));
frame.local_lights.push_back(point_light("last-point"));
auto reversed = frame;
std::reverse(reversed.local_lights.begin(), reversed.local_lights.end());
const auto reordered = build_shadow_plan(reversed, {}, {});
require(reordered.local_views.size() == capacity.local_views.size(),
"Reversing input lights preserves scheduled view count");
for (std::size_t i = 0; i < capacity.local_views.size(); ++i)
require(reordered.local_views[i].light_id == capacity.local_views[i].light_id &&
reordered.local_views[i].tile_index == capacity.local_views[i].tile_index,
"Stable IDs preserve atlas assignments across input reordering");
frame.local_lights.clear();
for (int i = 0; i < 128; ++i) {
auto light = spot_light("ordinary-" + std::to_string(i));
light.casts_shadow = false;
frame.local_lights.push_back(light);
}
auto important = spot_light("late-high-priority", 5);
important.casts_shadow = false;
frame.local_lights.push_back(important);
const auto ranked = build_shadow_plan(frame, {}, {});
require(ranked.submitted_local_indices.size() == 128 &&
ranked.omitted_local_lights == 1 &&
ranked.submitted_local_indices.front() == 128,
"Submission selects all 128 by priority and reports one omitted light");
frame.local_lights.back().range = -1;
bool invalid_overflow_rejected = false;
try {
(void)build_shadow_plan(frame, {}, {});
} catch (const std::invalid_argument&) {
invalid_overflow_rejected = true;
}
require(invalid_overflow_rejected,
"All authored lights are validated even when beyond the submission cap");
frame.local_lights.clear();
frame.sun.reset();
const auto no_sun = build_shadow_plan(frame, {}, {});
require(no_sun.requested_sun_cascades == 0 && no_sun.sun_views.empty(),
"Authored lights suppress legacy sun even when none is enabled");
for (int i = 0; i < 20; ++i)
frame.local_lights.push_back(spot_light("over-cap-" + std::to_string(i)));
ShadowBudget relaxed;
relaxed.max_local_faces = 64;
relaxed.max_local_lights = 256;
const auto clamped = build_shadow_plan(frame, {}, relaxed);
require(clamped.local_faces_used == 16 && clamped.dropped_local_faces == 4,
"Fixed 4x4 local atlas never allocates outside its sixteen tiles");
frame = fixture();
frame.camera_frustum.reset();
const auto legacy = build_shadow_plan(frame, {}, {});
require(legacy.sun_views.size() == 1 && legacy.effective_sun_cascades == 1,
"A low-level snapshot without camera frustum uses one reported sun view");
frame = fixture();
ShadowBudget unsupported;
unsupported.sun_atlas_available = false;
const auto no_atlas = build_shadow_plan(frame, {}, unsupported);
require(no_atlas.effective_sun_cascades == 0 &&
no_atlas.dropped_sun_views == 4 &&
no_atlas.sun_views[0].reason == ShadowDropReason::Unavailable,
"Unsupported depth atlas yields an explicit unshadowed sun fallback");
}
} // namespace
int main() {
try {
run();
std::cout << "Shadow planning, stable allocation, caster visibility, and budgets passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
+72 -5
View File
@@ -46,7 +46,7 @@ int main() {
try {
const auto bundle = temporary / "shaders";
fs::create_directories(bundle);
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain",
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain", "lightTileMain",
"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"})
for (const auto* extension : {".spv", ".reflection.json"}) {
@@ -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;
@@ -67,7 +88,7 @@ int main() {
render::Renderer renderer(configuration);
const auto baseline_only = temporary / "baseline-only";
fs::create_directories(baseline_only);
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain"})
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain", "lightTileMain"})
for (const auto* extension : {".spv", ".reflection.json"}) {
const auto name = std::string(entry) + extension;
fs::copy_file(bundle / name, baseline_only / name);
@@ -105,6 +126,40 @@ int main() {
opaque_scene.draws.push_back(opaque_cube);
gpu_renderer.render(opaque_scene);
const auto gpu_expected = gpu_renderer.pixels();
auto tiled_configuration = configuration;
tiled_configuration.lighting_mode = render::LightingMode::Tiled;
render::Renderer tiled_renderer(tiled_configuration);
auto lit_scene = opaque_scene;
render::LocalLight point;
point.stable_id = "reload-point";
point.position = {1, 1, 3};
point.intensity = 5;
point.range = 8;
point.casts_shadow = false;
lit_scene.local_lights.push_back(point);
tiled_renderer.render(lit_scene);
require(tiled_renderer.stats().effective_lighting_path == "tiled" &&
tiled_renderer.stats().validation_errors == 0,
"Tiled lighting is active before shader reload");
const auto tiled_expected = tiled_renderer.pixels();
const auto original_tile_spirv = read_text(bundle / "lightTileMain.spv");
atomic_write(bundle / "lightTileMain.spv", "damaged tile bytecode");
std::string tile_error;
require(!tiled_renderer.reload_shaders(tile_error) && !tile_error.empty(),
"Rejected light tile shader preserves the working pipeline");
tiled_renderer.render(lit_scene);
require(tiled_renderer.stats().effective_lighting_path == "tiled" &&
tiled_renderer.pixels() == tiled_expected &&
tiled_renderer.stats().validation_errors == 0,
"Rejected light tile shader retains tiled lighting and pixels");
atomic_write(bundle / "lightTileMain.spv", original_tile_spirv);
require(tiled_renderer.reload_shaders(tile_error),
"Compatible light tile shader reloads successfully");
tiled_renderer.render(lit_scene);
require(tiled_renderer.stats().effective_lighting_path == "tiled" &&
tiled_renderer.pixels() == tiled_expected &&
tiled_renderer.stats().validation_errors == 0,
"Compatible light tile reload preserves tiled pixels");
render::Snapshot scene;
scene.ui_quads.push_back({0, 0, 32, 64, {1, .8f, .4f, 1}});
scene.sprites.push_back({{.5f, 0, .5f}, {1, 2}, {.2f, 1, .4f, 1}});
@@ -118,7 +173,7 @@ int main() {
require(deep_bundle.native().size() > 300,
"Shader file fixture must exceed the legacy Windows path limit");
fs::create_directories(native_io_path(deep_bundle));
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain",
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain", "lightTileMain",
"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"})
for (const auto* extension : {".spv", ".reflection.json"}) {
@@ -161,6 +216,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 +237,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)]]");
+96
View File
@@ -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);
+128
View File
@@ -1,10 +1,12 @@
#include <bit>
#include <algorithm>
#include <cmath>
#include <faset/assets/asset_pipeline.hpp>
#include <faset/core/io.hpp>
#include <faset/player/SceneView.hpp>
#include <faset/runtime/Runtime.hpp>
#include <iostream>
#include <limits>
#include <numbers>
#include <stdexcept>
@@ -23,12 +25,137 @@ template <class F> void rejects(F&& function, const char* message) {
}
check(caught, message);
}
template <class F>
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<double>::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<double>::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");
+229
View File
@@ -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()
+49
View File
@@ -36,6 +36,55 @@ 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(5)],
[("storage_buffer", 80), ("storage_buffer", 80),
("storage_buffer", 112), ("sampled_image_2d", None),
("storage_buffer", 4)])
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_light_tile_compute_reflection(self):
compiler = os.environ["FASET_TEST_SLANGC"]
with tempfile.TemporaryDirectory(prefix="faset-light-tiles-abi-") as directory:
process = subprocess.run(
[sys.executable, str(SCRIPT), "--compiler", compiler, "--source",
str(SCRIPT.parents[1] / "shaders" / "light_tiles.slang"), "--entry",
"lightTileMain", "--output", directory],
capture_output=True, text=True,
)
self.assertEqual(process.returncode, 0, process.stderr)
layout = json.loads((Path(directory) / "lightTileMain.reflection.json").read_text())["layout"]
self.assertEqual(layout["stage"], "compute")
self.assertEqual(
[(item["set"], item["binding"], item["type"], item.get("element_stride"))
for item in layout["descriptors"]],
[(0, 0, "storage_buffer", 80), (0, 1, "storage_buffer", 4)],
)
self.assertEqual(layout["push_constants"][0]["size"], 96)
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
+262
View File
@@ -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())