Integrate Forward+ lighting with temporal rendering
Native and manual checks / native (ubuntu-24.04) (push) Failing after 34s
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
Native and manual checks / native (ubuntu-24.04) (push) Failing after 34s
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
This commit is contained in:
@@ -199,7 +199,16 @@ GPU instance record содержит стабильные slot/generation; пл
|
||||
|
||||
### P3. Освещение, тени и temporal reconstruction
|
||||
|
||||
Расширить local lights, добавить clustered/Forward+ при измеренной необходимости, cascaded sun shadows и ограниченный local shadow atlas. Shadow views имеют собственную видимость и бюджеты.
|
||||
**Освещение и тени реализованы до измеренного выбора пути; приёмка этапа ещё
|
||||
открыта.** Есть 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 и повторные измерения входят в
|
||||
оставшуюся работу. [Протокол проверки](docs/validation/p3-lighting-2026-09-24/README.md)
|
||||
отделяет текущий checkpoint от финальной Linux/Windows приёмки.
|
||||
|
||||
Затем: previous transforms, motion vectors, jitter, history rejection и TAA; temporal upscaling — после устойчивого TAA. Проверять тонкую геометрию, движение, disocclusion, camera cut и смену разрешения, сравнивать с режимом без temporal. У cache/pass видны затраты и причины обновления.
|
||||
|
||||
|
||||
@@ -193,7 +193,15 @@ Json profileFrames(const std::vector<ProfileSample>& samples) {
|
||||
{"gpu_temporal_composite_ms",
|
||||
gpuMeasured ? Json(sample.lighting.gpu_temporal_composite_ms) : Json(nullptr)},
|
||||
{"gpu_ui_ms",
|
||||
gpuMeasured ? Json(sample.lighting.gpu_ui_ms) : Json(nullptr)}});
|
||||
gpuMeasured ? Json(sample.lighting.gpu_ui_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",
|
||||
|
||||
@@ -35,6 +35,14 @@ add_custom_command(OUTPUT "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/gp
|
||||
BYPRODUCTS "${FASET_SHADER_DIRECTORY}/gpuTemporalVertexMain.slang-reflection.json"
|
||||
DEPENDS "${PROJECT_SOURCE_DIR}/shaders/gpu_scene.slang" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py" VERBATIM)
|
||||
list(APPEND FASET_SHADER_OUTPUTS "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/gpuTemporalVertexMain.reflection.json")
|
||||
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)
|
||||
@@ -86,6 +94,8 @@ if(BUILD_TESTING)
|
||||
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)
|
||||
|
||||
@@ -466,3 +466,48 @@ 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. A 16×16
|
||||
tiled Forward+ path, image parity and before/after build+raster measurement are
|
||||
therefore pending. Temporal reconstruction is developed and accepted separately.
|
||||
|
||||
@@ -22,7 +22,27 @@ 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.
|
||||
See [Lighting](lighting.md) for the 128-light and 16-tile limits.
|
||||
|
||||
The Vulkan backend emits `VK_EXT_debug_utils` labels for `SunShadowAtlas`,
|
||||
`LocalShadowAtlas`, `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.
|
||||
|
||||
|
||||
@@ -1,37 +1,82 @@
|
||||
# Add lights to a 3D scene
|
||||
# Light a 3D scene
|
||||
|
||||
Add a **Light** component to a scene entity. The entity's transform places a point
|
||||
or spot light; its rotation aims a spot light along local negative Z. A directional
|
||||
light uses the entity's orientation. Light colors and intensity contribute to the
|
||||
mesh's linear PBR illumination before tone mapping. Sprites and UI retain their
|
||||
unlit tint.
|
||||
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.
|
||||
|
||||
The version-1 `faset.light` component has three `kind` values:
|
||||
|
||||
| Kind | Position and direction | Useful fields |
|
||||
| Light kind | Coverage | Shadow cost |
|
||||
| --- | --- | --- |
|
||||
| `directional` | Direction from the entity transform | `color`, `intensity`, `casts_shadow` |
|
||||
| `point` | Position from the entity transform; illuminates every direction | `color`, `intensity`, `range` |
|
||||
| `spot` | Position and local negative-Z direction | `color`, `intensity`, `range`, `inner_angle`, `outer_angle` |
|
||||
| `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 |
|
||||
|
||||
Angles are radians. A spot's inner angle must not exceed its outer angle. Intensity
|
||||
must be nonnegative and range positive. `enabled: false` keeps the component in the
|
||||
scene without contributing light. The `shadow_priority` integer is reserved for the
|
||||
bounded local-shadow scheduler; it does not change brightness.
|
||||
The Light component's fields are:
|
||||
|
||||
In the current rendering checkpoint, one enabled directional light can cast the
|
||||
existing single-map shadow. Point and spot lights illuminate meshes but do not yet
|
||||
cast shadows. The [P3 lighting plan](https://github.com/emil28092005/Faset_Engine/blob/main/docs/superpowers/plans/2026-09-24-p3-lighting.md)
|
||||
tracks cascades and the bounded local-shadow atlas. A scene with no Light component
|
||||
keeps the legacy white sun so older projects retain their appearance. Adding any
|
||||
Light component, even a disabled one, turns off that compatibility fallback. If
|
||||
several directionals are enabled, Faset chooses the one with the smallest stable
|
||||
entity ID and reports a diagnostic for the others.
|
||||
| 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 |
|
||||
|
||||
## Author a point light through MCP
|
||||
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.
|
||||
|
||||
Use `faset_schema` to inspect the current field IDs, then send a `faset_scene_edit`
|
||||
batch with the document ID, current revision, and target entity ID. For example:
|
||||
## 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.
|
||||
|
||||
## 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
|
||||
{
|
||||
@@ -46,22 +91,24 @@ batch with the document ID, current revision, and target entity ID. For example:
|
||||
"kind": "point",
|
||||
"color": [1, 0.15, 0.1, 1],
|
||||
"intensity": 8,
|
||||
"range": 6
|
||||
"range": 6,
|
||||
"shadow_priority": 2
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Move the entity with its Transform component. `component.add` fills any omitted
|
||||
light fields from the version-1 schema; use `component.set` for later edits. See
|
||||
[MCP and command line](mcp.md) for revision and retry handling.
|
||||
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++
|
||||
|
||||
When building a `faset::render::Snapshot` yourself, set
|
||||
`authored_lights_present` to suppress the compatibility sun in a local-only scene.
|
||||
Provide a stable ID for each light so future shadow scheduling remains independent
|
||||
of submission order.
|
||||
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;
|
||||
@@ -74,6 +121,7 @@ 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;
|
||||
@@ -88,5 +136,7 @@ spot.range = 9;
|
||||
snapshot.local_lights.push_back(spot);
|
||||
```
|
||||
|
||||
The renderer submits at most 128 local lights per frame in stable-ID order. Later
|
||||
P3 work adds explicit overflow diagnostics and measured light-list optimization.
|
||||
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.
|
||||
|
||||
@@ -144,13 +144,59 @@ quality, inspect a still thin edge, a slow pan and a newly uncovered surface, an
|
||||
compare the same frame against Off. See [Temporal rendering](temporal.md) for
|
||||
mode controls and native C++ configuration.
|
||||
|
||||
## 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 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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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, acceptance matrix, bounded evidence, and remaining Forward+/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,98 @@
|
||||
# P3 lighting and shadows — acceptance record
|
||||
|
||||
This record tracks P3 lighting separately from temporal reconstruction. The
|
||||
implementation checkpoint is source revision
|
||||
`b191ae0bed77a1544a49504b9a3f07e9a3c691f2` on `feat/p3-lighting`. The
|
||||
Manual/record edit itself is documentation-only. A later Forward+ change and
|
||||
its measurements require a new revision and validation entry before the lighting
|
||||
slice can be called complete. 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.
|
||||
|
||||
## Acceptance matrix
|
||||
|
||||
| Case | Automated evidence | Current status |
|
||||
| --- | --- | --- |
|
||||
| Empty, disabled, local-only, multiple sun; schema bounds | `scene_view`, `render_lighting_policy`, `render_offscreen` | Covered by Debug tests at the implementation checkpoint; re-run on final revision |
|
||||
| Four cascades, split bounds, subtexel stabilization, offscreen/source-LOD0 caster | `render_lighting_policy`, `render_lighting_sun` | Covered by CPU policy and Linux Vulkan image tests; final-revision runs pending |
|
||||
| Spot cone, six point faces and seam, dropped whole point shadow | `render_lighting_local`, `render_lighting_policy` | Covered by Linux Vulkan image and CPU tests; final-revision runs pending |
|
||||
| 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 supported-driver paths covered; final-revision runs pending |
|
||||
| Driver, profile, real 64×64 benchmark smoke | `render_lighting_benchmark_schema`, `render_lighting_benchmark_smoke`, `player_shutdown_diagnostics` | Focused integration tests passed on `b191ae0`; full raw log pending |
|
||||
| 1920×1080 0/4/16/32/64/128 Release sweep, three repeats, both shadow states | `tools/benchmark_p3_lighting.py --sweep` | Baseline measured on Linux physical GPU; raw CSV and post-Forward+ comparison pending publication |
|
||||
| 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`, and `render_lighting_benchmark_smoke` (labelled
|
||||
`gpu;p3`). Use `ctest --test-dir build/linux-debug -N -L p3` to confirm those
|
||||
five 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 three 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.
|
||||
|
||||
```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, CSV paths, before/after Forward+ gate, Linux SwiftShader results, final
|
||||
Debug/Release CTest logs, and Windows Actions links will be added after those
|
||||
checks run. Do not use this provisional record as a P3 completion claim.
|
||||
|
||||
## Limits carried forward
|
||||
|
||||
The current checkpoint scans all submitted lights in each mesh fragment; the
|
||||
measured Forward+ threshold was reached on the Linux reference GPU, so a bounded
|
||||
tiled path is in progress. 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,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.
|
||||
|
||||
[31m │ ⚠ Warning from the Material for MkDocs team[0m
|
||||
[31m │[0m
|
||||
[31m │[0m MkDocs 2.0, the underlying framework of Material for MkDocs,
|
||||
[31m │[0m will introduce backward-incompatible changes, including:
|
||||
[31m │[0m
|
||||
[31m │ × [0mAll plugins will stop working – the plugin system has been removed
|
||||
[31m │ × [0mAll theme overrides will break – the theming system has been rewritten
|
||||
[31m │ × [0mNo migration path exists – existing projects cannot be upgraded
|
||||
[31m │ × [0mClosed contribution model – community members can't report bugs
|
||||
[31m │ × [0mCurrently unlicensed – unsuitable for production use
|
||||
[31m │[0m
|
||||
[31m │[0m Our full analysis:
|
||||
[31m │[0m
|
||||
[31m │[0m [4mhttps://squidfunk.github.io/mkdocs-material/blog/2026/02/18/mkdocs-2.0/[0m
|
||||
[0m
|
||||
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
|
||||
@@ -19,8 +19,10 @@ namespace fs = std::filesystem;
|
||||
namespace {
|
||||
struct Options {
|
||||
unsigned lights{}, width{1920}, height{1080}, warmup{10}, frames{30}, run_index{};
|
||||
bool shadows{}, validation{};
|
||||
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;
|
||||
};
|
||||
@@ -48,6 +50,8 @@ Options parse(int argc, char** argv) {
|
||||
"--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);
|
||||
}
|
||||
@@ -72,11 +76,24 @@ Options parse(int argc, char** argv) {
|
||||
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};
|
||||
@@ -146,7 +163,7 @@ Snapshot benchmark_scene(const Options& options) {
|
||||
.6f + .4f * float(i % 3 == 1),
|
||||
.6f + .4f * float(i % 3 == 2), 1};
|
||||
light.intensity = 5.f;
|
||||
light.range = 8.f;
|
||||
light.range = options.light_layout == "localized" ? 1.75f : 8.f;
|
||||
light.casts_shadow = options.shadows;
|
||||
scene.local_lights.push_back(std::move(light));
|
||||
}
|
||||
@@ -160,6 +177,8 @@ void benchmark(const Options& options) {
|
||||
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)
|
||||
@@ -169,14 +188,18 @@ void benchmark(const Options& options) {
|
||||
std::ofstream csv(faset::native_io_path(options.csv));
|
||||
if (!csv)
|
||||
throw std::runtime_error("Cannot open benchmark CSV: " + faset::path_to_utf8(options.csv));
|
||||
csv << "light_count,shadows,visibility,effective_visibility,lighting_path,"
|
||||
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_ms,cpu_ms,readback_cpu_ms\n";
|
||||
"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);
|
||||
@@ -189,9 +212,13 @@ void benchmark(const Options& options) {
|
||||
throw std::runtime_error("Requested visibility path fell back during benchmark");
|
||||
if (stats.gpu_main_raster_ms <= 0 || stats.gpu_ms <= 0)
|
||||
throw std::runtime_error("GPU raster or frame timestamp was unavailable");
|
||||
csv << options.lights << ',' << (options.shadows ? "on" : "off") << ','
|
||||
csv << options.lights << ',' << options.light_layout << ','
|
||||
<< (options.shadows ? "on" : "off") << ','
|
||||
<< mode_name(options.visibility) << ',' << mode_name(stats.effective_visibility_mode)
|
||||
<< ',' << stats.effective_lighting_path << ',' << FASET_BENCHMARK_CONFIGURATION << ','
|
||||
<< ',' << 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 << ',';
|
||||
@@ -208,7 +235,13 @@ void benchmark(const Options& options) {
|
||||
<< stats.gpu_main_raster_ms << ',' << stats.gpu_post_raster_ms << ','
|
||||
<< stats.gpu_post_visible << ',' << (stats.visibility_counters_valid ? 1 : 0)
|
||||
<< ',' << stats.gpu_sun_shadow_ms << ',' << stats.gpu_local_shadow_ms << ','
|
||||
<< (stats.gpu_sun_shadow_ms + stats.gpu_local_shadow_ms) << ',' << stats.gpu_ms << ','
|
||||
<< (stats.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)
|
||||
|
||||
@@ -137,6 +137,7 @@ struct Snapshot {
|
||||
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);
|
||||
@@ -149,6 +150,9 @@ struct RendererConfig {
|
||||
VisibilityMode visibility_mode{VisibilityMode::Direct};
|
||||
TemporalMode temporal_mode{TemporalMode::Off};
|
||||
float render_scale{1.f};
|
||||
// Auto uses measured Forward on the reference workload. Tiled can be forced
|
||||
// for comparison and falls back to Forward if its device path is unavailable.
|
||||
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.
|
||||
@@ -217,6 +221,11 @@ struct FrameStats {
|
||||
std::array<float, 2> temporal_jitter{};
|
||||
double gpu_temporal_resolve_ms{}, gpu_temporal_composite_ms{}, gpu_ui_ms{};
|
||||
std::vector<std::string> graph_passes;
|
||||
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;
|
||||
};
|
||||
|
||||
+17
-1
@@ -50,6 +50,9 @@ struct ShadowViewGpu {
|
||||
[[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;
|
||||
@@ -170,7 +173,20 @@ float4 shadeScene(VertexOutput v) {
|
||||
linear += directBRDF(base.rgb, rough, metal, n, view, l) *
|
||||
lighting.sunColor.rgb * (lighting.sunDirectionIntensity.w * 3.0 * visibility);
|
||||
}
|
||||
for (uint i=0; i<lighting.counts.x; ++i) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
@@ -594,6 +595,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",
|
||||
|
||||
@@ -472,6 +472,14 @@ void DebugOverlay::append(render::Snapshot& output, render::Renderer& renderer,
|
||||
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",
|
||||
|
||||
+286
-12
@@ -98,6 +98,12 @@ struct ShadowViewGpu {
|
||||
std::array<float, 4> guarded_clamp{};
|
||||
std::array<float, 4> bias_flags{};
|
||||
};
|
||||
struct LightTilePush {
|
||||
Mat4 view_projection;
|
||||
std::array<float, 4> viewport;
|
||||
std::array<std::uint32_t, 4> dimensions;
|
||||
};
|
||||
static_assert(sizeof(LightTilePush) == 96);
|
||||
static_assert(sizeof(LightingHeaderGpu) == 80 &&
|
||||
offsetof(LightingHeaderGpu, sun_direction_intensity) == 16 &&
|
||||
offsetof(LightingHeaderGpu, sun_color) == 32 &&
|
||||
@@ -249,7 +255,7 @@ struct Renderer::Impl {
|
||||
float timestamp_period{};
|
||||
std::uint32_t timestamp_bits{};
|
||||
VkSemaphore acquired{}, present_ready{};
|
||||
std::array<std::string, 3> shader_layouts{};
|
||||
std::array<std::string, 4> shader_layouts{};
|
||||
VkSwapchainKHR swapchain{};
|
||||
VkFormat swap_format{};
|
||||
VkExtent2D swap_extent{};
|
||||
@@ -258,7 +264,9 @@ struct Renderer::Impl {
|
||||
Image color, depth, shadow, local_shadow;
|
||||
std::uint32_t sun_shadow_size{}, local_shadow_size{};
|
||||
Buffer vertices, readback;
|
||||
Buffer lighting_header, lighting_locals, lighting_views;
|
||||
Buffer lighting_header, lighting_locals, lighting_views, light_tile_words,
|
||||
light_tile_readback;
|
||||
bool light_tiles_capable{};
|
||||
SceneResources scene;
|
||||
TemporalResources temporal;
|
||||
InstanceTracker instance_tracker;
|
||||
@@ -279,6 +287,11 @@ struct Renderer::Impl {
|
||||
VkDescriptorSetLayout lighting_layout{};
|
||||
VkDescriptorPool lighting_pool{};
|
||||
VkDescriptorSet lighting_set{};
|
||||
VkDescriptorSetLayout light_tile_layout{};
|
||||
VkDescriptorPool light_tile_pool{};
|
||||
VkDescriptorSet light_tile_set{};
|
||||
VkPipelineLayout light_tile_pipeline_layout{};
|
||||
VkPipeline light_tile_pipeline{};
|
||||
VkSampler shadow_sampler{}, color_sampler{};
|
||||
VkPipelineLayout pipeline_layout{};
|
||||
VkPipeline pipeline{}, ui_pipeline{}, shadow_pipeline{}, sprite_pipeline{},
|
||||
@@ -420,6 +433,8 @@ struct Renderer::Impl {
|
||||
destroy(lighting_header);
|
||||
destroy(lighting_locals);
|
||||
destroy(lighting_views);
|
||||
destroy(light_tile_words);
|
||||
destroy(light_tile_readback);
|
||||
destroy(color);
|
||||
destroy(depth);
|
||||
destroy(shadow);
|
||||
@@ -427,6 +442,7 @@ struct Renderer::Impl {
|
||||
if (device) {
|
||||
destroy_temporal_interfaces();
|
||||
destroy_scene_interfaces();
|
||||
destroy_light_tile_interfaces();
|
||||
if (pipeline)
|
||||
vkDestroyPipeline(device, pipeline, nullptr);
|
||||
if (ui_pipeline)
|
||||
@@ -764,6 +780,13 @@ struct Renderer::Impl {
|
||||
max_storage_buffer_range = properties.limits.maxStorageBufferRange;
|
||||
max_image_dimension = properties.limits.maxImageDimension2D;
|
||||
independent_blend_supported = features.features.independentBlend;
|
||||
light_tiles_capable =
|
||||
(queues[i].queueFlags & VK_QUEUE_COMPUTE_BIT) != 0 &&
|
||||
properties.limits.maxComputeWorkGroupInvocations >= 64 &&
|
||||
properties.limits.maxComputeWorkGroupSize[0] >= 64 &&
|
||||
properties.limits.maxPerStageDescriptorStorageBuffers >= 4 &&
|
||||
properties.limits.maxDescriptorSetStorageBuffers >= 4 &&
|
||||
max_compute_groups_x > 0;
|
||||
scene.available = (queues[i].queueFlags & VK_QUEUE_COMPUTE_BIT) != 0 &&
|
||||
properties.limits.maxPerStageDescriptorStorageBuffers >= 8 &&
|
||||
properties.limits.maxDescriptorSetStorageBuffers >= 8 &&
|
||||
@@ -899,7 +922,19 @@ struct Renderer::Impl {
|
||||
}
|
||||
}
|
||||
make_targets();
|
||||
light_tile_words = make_buffer(16,
|
||||
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT |
|
||||
VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
|
||||
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
||||
make_descriptors();
|
||||
if (light_tiles_capable) {
|
||||
try {
|
||||
make_light_tile_descriptors();
|
||||
} catch (const std::exception&) {
|
||||
destroy_light_tile_interfaces();
|
||||
light_tiles_capable = false;
|
||||
}
|
||||
}
|
||||
make_pipelines();
|
||||
if (scene.available && (c.visibility_mode != VisibilityMode::Direct ||
|
||||
c.temporal_mode != TemporalMode::Off))
|
||||
@@ -1162,7 +1197,7 @@ struct Renderer::Impl {
|
||||
pi.pPoolSizes = sizes;
|
||||
check(vkCreateDescriptorPool(device, &pi, nullptr, &descriptor_pool),
|
||||
"Create descriptor pool");
|
||||
std::array<VkDescriptorSetLayoutBinding, 4> lighting_bindings{};
|
||||
std::array<VkDescriptorSetLayoutBinding, 5> lighting_bindings{};
|
||||
for (std::uint32_t i = 0; i < lighting_bindings.size(); ++i)
|
||||
lighting_bindings[i] = {i,
|
||||
i == 3 ? VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE
|
||||
@@ -1173,7 +1208,7 @@ struct Renderer::Impl {
|
||||
check(vkCreateDescriptorSetLayout(device, &li, nullptr, &lighting_layout),
|
||||
"Create lighting descriptor layout");
|
||||
VkDescriptorPoolSize lighting_sizes[] = {
|
||||
{VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 3}, {VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, 1}};
|
||||
{VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 4}, {VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, 1}};
|
||||
pi.flags = 0;
|
||||
pi.maxSets = 1;
|
||||
pi.poolSizeCount = 2;
|
||||
@@ -1197,6 +1232,59 @@ struct Renderer::Impl {
|
||||
si.magFilter = si.minFilter = VK_FILTER_LINEAR;
|
||||
check(vkCreateSampler(device, &si, nullptr, &color_sampler), "Create color sampler");
|
||||
}
|
||||
void destroy_light_tile_interfaces() {
|
||||
if (!device)
|
||||
return;
|
||||
if (light_tile_pipeline)
|
||||
vkDestroyPipeline(device, light_tile_pipeline, nullptr);
|
||||
if (light_tile_pipeline_layout)
|
||||
vkDestroyPipelineLayout(device, light_tile_pipeline_layout, nullptr);
|
||||
if (light_tile_pool)
|
||||
vkDestroyDescriptorPool(device, light_tile_pool, nullptr);
|
||||
if (light_tile_layout)
|
||||
vkDestroyDescriptorSetLayout(device, light_tile_layout, nullptr);
|
||||
light_tile_pipeline = {};
|
||||
light_tile_pipeline_layout = {};
|
||||
light_tile_pool = {};
|
||||
light_tile_layout = {};
|
||||
light_tile_set = {};
|
||||
}
|
||||
void make_light_tile_descriptors() {
|
||||
const std::array<VkDescriptorSetLayoutBinding, 2> bindings{{
|
||||
{0, VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 1, VK_SHADER_STAGE_COMPUTE_BIT, nullptr},
|
||||
{1, VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 1, VK_SHADER_STAGE_COMPUTE_BIT, nullptr}}};
|
||||
VkDescriptorSetLayoutCreateInfo layout{};
|
||||
layout.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
|
||||
layout.bindingCount = static_cast<std::uint32_t>(bindings.size());
|
||||
layout.pBindings = bindings.data();
|
||||
check(vkCreateDescriptorSetLayout(device, &layout, nullptr, &light_tile_layout),
|
||||
"Create light tile descriptor layout");
|
||||
VkDescriptorPoolSize size{VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 2};
|
||||
VkDescriptorPoolCreateInfo pool_info{};
|
||||
pool_info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
|
||||
pool_info.maxSets = 1;
|
||||
pool_info.poolSizeCount = 1;
|
||||
pool_info.pPoolSizes = &size;
|
||||
check(vkCreateDescriptorPool(device, &pool_info, nullptr, &light_tile_pool),
|
||||
"Create light tile descriptor pool");
|
||||
VkDescriptorSetAllocateInfo allocation{};
|
||||
allocation.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
|
||||
allocation.descriptorPool = light_tile_pool;
|
||||
allocation.descriptorSetCount = 1;
|
||||
allocation.pSetLayouts = &light_tile_layout;
|
||||
check(vkAllocateDescriptorSets(device, &allocation, &light_tile_set),
|
||||
"Allocate light tile descriptors");
|
||||
VkPushConstantRange push{VK_SHADER_STAGE_COMPUTE_BIT, 0,
|
||||
sizeof(LightTilePush)};
|
||||
VkPipelineLayoutCreateInfo pipeline{};
|
||||
pipeline.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
|
||||
pipeline.setLayoutCount = 1;
|
||||
pipeline.pSetLayouts = &light_tile_layout;
|
||||
pipeline.pushConstantRangeCount = 1;
|
||||
pipeline.pPushConstantRanges = &push;
|
||||
check(vkCreatePipelineLayout(device, &pipeline, nullptr, &light_tile_pipeline_layout),
|
||||
"Create light tile pipeline layout");
|
||||
}
|
||||
VkDescriptorSet upload_texture(std::shared_ptr<const Texture> source) {
|
||||
if (!source)
|
||||
source = white;
|
||||
@@ -1416,6 +1504,30 @@ struct Renderer::Impl {
|
||||
check(vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &pi, nullptr, output),
|
||||
"Create graphics pipeline");
|
||||
}
|
||||
if (light_tiles_capable) {
|
||||
VkShaderModule tile_shader{};
|
||||
try {
|
||||
tile_shader = shader(shaders[3]);
|
||||
VkComputePipelineCreateInfo tile{};
|
||||
tile.sType = VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO;
|
||||
tile.stage.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
|
||||
tile.stage.stage = VK_SHADER_STAGE_COMPUTE_BIT;
|
||||
tile.stage.module = tile_shader;
|
||||
tile.stage.pName = "main";
|
||||
tile.layout = light_tile_pipeline_layout;
|
||||
check(vkCreateComputePipelines(device, VK_NULL_HANDLE, 1, &tile,
|
||||
nullptr, &light_tile_pipeline),
|
||||
"Create light tile compute pipeline");
|
||||
} catch (const std::exception&) {
|
||||
// Optional acceleration: keep the validated forward renderer.
|
||||
if (light_tile_pipeline)
|
||||
vkDestroyPipeline(device, light_tile_pipeline, nullptr);
|
||||
light_tile_pipeline = {};
|
||||
light_tiles_capable = false;
|
||||
}
|
||||
if (tile_shader)
|
||||
vkDestroyShaderModule(device, tile_shader, nullptr);
|
||||
}
|
||||
} catch (...) {
|
||||
vkDestroyShaderModule(device, vertex, nullptr);
|
||||
vkDestroyShaderModule(device, fragment, nullptr);
|
||||
@@ -2044,14 +2156,15 @@ struct Renderer::Impl {
|
||||
upload_scene_buffer(buffer, values.data(), values.size() * sizeof(T), usage);
|
||||
}
|
||||
void update_lighting_descriptors() {
|
||||
const std::array<VkDescriptorBufferInfo, 3> buffers{{
|
||||
const std::array<VkDescriptorBufferInfo, 4> buffers{{
|
||||
{lighting_header.handle, 0, lighting_header.size},
|
||||
{lighting_locals.handle, 0, lighting_locals.size},
|
||||
{lighting_views.handle, 0, lighting_views.size}}};
|
||||
{lighting_views.handle, 0, lighting_views.size},
|
||||
{light_tile_words.handle, 0, light_tile_words.size}}};
|
||||
const VkDescriptorImageInfo atlas{VK_NULL_HANDLE,
|
||||
local_shadow.handle ? local_shadow.view : shadow.view,
|
||||
VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL};
|
||||
std::array<VkWriteDescriptorSet, 4> writes{};
|
||||
std::array<VkWriteDescriptorSet, 5> writes{};
|
||||
for (std::uint32_t i = 0; i < writes.size(); ++i) {
|
||||
writes[i].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
|
||||
writes[i].dstSet = lighting_set;
|
||||
@@ -2062,7 +2175,25 @@ struct Renderer::Impl {
|
||||
if (i == 3)
|
||||
writes[i].pImageInfo = &atlas;
|
||||
else
|
||||
writes[i].pBufferInfo = &buffers[i];
|
||||
writes[i].pBufferInfo = &buffers[i == 4 ? 3 : i];
|
||||
}
|
||||
vkUpdateDescriptorSets(device, static_cast<std::uint32_t>(writes.size()),
|
||||
writes.data(), 0, nullptr);
|
||||
}
|
||||
void update_light_tile_descriptors() {
|
||||
if (!light_tile_set)
|
||||
return;
|
||||
const std::array<VkDescriptorBufferInfo, 2> buffers{{
|
||||
{lighting_locals.handle, 0, lighting_locals.size},
|
||||
{light_tile_words.handle, 0, light_tile_words.size}}};
|
||||
std::array<VkWriteDescriptorSet, 2> writes{};
|
||||
for (std::uint32_t i = 0; i < writes.size(); ++i) {
|
||||
writes[i].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
|
||||
writes[i].dstSet = light_tile_set;
|
||||
writes[i].dstBinding = i;
|
||||
writes[i].descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
|
||||
writes[i].descriptorCount = 1;
|
||||
writes[i].pBufferInfo = &buffers[i];
|
||||
}
|
||||
vkUpdateDescriptorSets(device, static_cast<std::uint32_t>(writes.size()),
|
||||
writes.data(), 0, nullptr);
|
||||
@@ -2174,6 +2305,10 @@ struct Renderer::Impl {
|
||||
statistics.local_shadow_atlas_bytes = local_shadow_size
|
||||
? local_shadow.allocation_size : 0;
|
||||
statistics.gpu_local_shadow_ms = 0;
|
||||
statistics.gpu_light_tiles_ms = 0;
|
||||
statistics.light_tile_count = 0;
|
||||
statistics.light_tile_counts_valid = false;
|
||||
statistics.light_tile_candidate_count = statistics.light_tile_overflow_count = 0;
|
||||
statistics.effective_lighting_path = "forward";
|
||||
statistics.gpu_bins = statistics.gpu_visible_instances =
|
||||
statistics.gpu_frustum_rejected = statistics.gpu_occlusion_deferred =
|
||||
@@ -2838,7 +2973,64 @@ struct Renderer::Impl {
|
||||
upload_scene_buffer(lighting_header, &lighting, sizeof(lighting), 0);
|
||||
upload_scene_vector(lighting_locals, gpu_lights);
|
||||
upload_scene_vector(lighting_views, gpu_shadow_views);
|
||||
constexpr std::uint32_t light_tile_side = 16;
|
||||
constexpr std::uint32_t light_tile_stride_words = 66;
|
||||
constexpr std::uint32_t light_tile_capacity = 64;
|
||||
const std::uint32_t light_tiles_x = width / light_tile_side +
|
||||
(width % light_tile_side != 0);
|
||||
const std::uint32_t light_tiles_y = height / light_tile_side +
|
||||
(height % light_tile_side != 0);
|
||||
const std::uint64_t light_tile_count =
|
||||
std::uint64_t(light_tiles_x) * light_tiles_y;
|
||||
const std::uint64_t light_tile_bytes =
|
||||
(4u + light_tile_count * light_tile_stride_words) * sizeof(std::uint32_t);
|
||||
// The fixed 1080p reference scene has broad overlapping lights: 32 and
|
||||
// 64 nearly fill every tile, and 128 overflows every tile. Until a
|
||||
// validated runtime occupancy predictor exists, Auto keeps the measured
|
||||
// faster full scan. The explicit mode supports sparse-light projects.
|
||||
const bool requested_light_tiles =
|
||||
config.lighting_mode == LightingMode::Tiled;
|
||||
bool use_light_tiles = requested_light_tiles && lighting.counts[0] > 0 &&
|
||||
light_tiles_capable && light_tile_pipeline && light_tile_set &&
|
||||
std::all_of(scene_viewport.begin(), scene_viewport.end(),
|
||||
[](float value) { return std::isfinite(value); }) &&
|
||||
scene_viewport[2] > 0 && scene_viewport[3] > 0 &&
|
||||
light_tile_count <= std::numeric_limits<std::uint32_t>::max() &&
|
||||
light_tile_bytes <= max_storage_buffer_range &&
|
||||
(light_tile_count + 63) / 64 <= max_compute_groups_x;
|
||||
if (use_light_tiles && light_tile_words.size < light_tile_bytes) {
|
||||
try {
|
||||
auto replacement = make_buffer(light_tile_bytes,
|
||||
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT |
|
||||
VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
|
||||
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
||||
destroy(light_tile_words);
|
||||
light_tile_words = replacement;
|
||||
} catch (const std::exception&) {
|
||||
use_light_tiles = false;
|
||||
}
|
||||
}
|
||||
if (use_light_tiles) {
|
||||
statistics.effective_lighting_path = "tiled";
|
||||
statistics.light_tile_count = static_cast<std::uint32_t>(light_tile_count);
|
||||
}
|
||||
bool collect_light_tile_counts = use_light_tiles && config.visibility_diagnostics;
|
||||
if (collect_light_tile_counts && light_tile_readback.size < light_tile_bytes) {
|
||||
try {
|
||||
auto replacement = make_buffer(light_tile_bytes,
|
||||
VK_BUFFER_USAGE_TRANSFER_DST_BIT,
|
||||
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
|
||||
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
|
||||
VK_MEMORY_PROPERTY_HOST_CACHED_BIT);
|
||||
destroy(light_tile_readback);
|
||||
light_tile_readback = replacement;
|
||||
} catch (const std::exception&) {
|
||||
collect_light_tile_counts = false;
|
||||
}
|
||||
}
|
||||
update_lighting_descriptors();
|
||||
if (use_light_tiles)
|
||||
update_light_tile_descriptors();
|
||||
Vec3 light_eye{-direction[0] * 30, -direction[1] * 30, -direction[2] * 30};
|
||||
Vec3 light_up = std::abs(direction[1]) > .98f ? Vec3{0, 0, 1} : Vec3{0, 1, 0};
|
||||
Push push{sun_raster && !shadow_plan.sun_views.empty()
|
||||
@@ -3102,6 +3294,38 @@ struct Renderer::Impl {
|
||||
VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL,
|
||||
VK_IMAGE_ASPECT_DEPTH_BIT);
|
||||
});
|
||||
if (use_light_tiles)
|
||||
add_pass("LightTileBuild", {}, {"light_tiles"}, [&] {
|
||||
scene_barrier(VK_PIPELINE_STAGE_2_HOST_BIT,
|
||||
VK_ACCESS_2_HOST_WRITE_BIT,
|
||||
VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT,
|
||||
VK_ACCESS_2_SHADER_STORAGE_READ_BIT);
|
||||
vkCmdBindPipeline(command, VK_PIPELINE_BIND_POINT_COMPUTE,
|
||||
light_tile_pipeline);
|
||||
vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_COMPUTE,
|
||||
light_tile_pipeline_layout, 0, 1,
|
||||
&light_tile_set, 0, nullptr);
|
||||
const LightTilePush tile_push{
|
||||
snapshot.view_projection, scene_viewport,
|
||||
{light_tiles_x, light_tiles_y, lighting.counts[0], light_tile_capacity}};
|
||||
vkCmdPushConstants(command, light_tile_pipeline_layout,
|
||||
VK_SHADER_STAGE_COMPUTE_BIT, 0,
|
||||
sizeof(tile_push), &tile_push);
|
||||
vkCmdDispatch(command,
|
||||
static_cast<std::uint32_t>((light_tile_count + 63) / 64), 1, 1);
|
||||
scene_barrier(VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT,
|
||||
VK_ACCESS_2_SHADER_STORAGE_WRITE_BIT,
|
||||
VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT,
|
||||
VK_ACCESS_2_SHADER_STORAGE_READ_BIT);
|
||||
});
|
||||
else
|
||||
add_pass("LightTileFallback", {}, {"light_tiles"}, [&] {
|
||||
vkCmdFillBuffer(command, light_tile_words.handle, 0, 16, 0);
|
||||
scene_barrier(VK_PIPELINE_STAGE_2_TRANSFER_BIT,
|
||||
VK_ACCESS_2_TRANSFER_WRITE_BIT,
|
||||
VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT,
|
||||
VK_ACCESS_2_SHADER_STORAGE_READ_BIT);
|
||||
});
|
||||
if (gpu_active)
|
||||
add_pass("MainCull", {"shadow"},
|
||||
{"main_indirect", "main_visible", "deferred_ids"}, [&] {
|
||||
@@ -3165,8 +3389,8 @@ struct Renderer::Impl {
|
||||
const auto raster_height = temporal_active ? temporal.internal_height : height;
|
||||
add_pass(occlusion || temporal_active ? "MainRaster" : "ForwardAndUI",
|
||||
gpu_active ? std::vector<std::string>{"shadow", "local_shadow",
|
||||
"main_indirect", "main_visible"}
|
||||
: std::vector<std::string>{"shadow", "local_shadow"},
|
||||
"light_tiles", "main_indirect", "main_visible"}
|
||||
: std::vector<std::string>{"shadow", "local_shadow", "light_tiles"},
|
||||
temporal_active ? std::vector<std::string>{"scene_color", "depth", "scene_velocity"}
|
||||
: std::vector<std::string>{"color", "depth"}, [&] {
|
||||
transition(command, raster_color, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
|
||||
@@ -3498,7 +3722,9 @@ struct Renderer::Impl {
|
||||
vkCmdEndRendering(command);
|
||||
});
|
||||
}
|
||||
add_pass("Readback", {"color"}, {"capture"}, [&] {
|
||||
add_pass("Readback", collect_light_tile_counts
|
||||
? std::vector<std::string>{"color", "light_tiles"}
|
||||
: std::vector<std::string>{"color"}, {"capture"}, [&] {
|
||||
transition(command, color, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
|
||||
VK_IMAGE_ASPECT_COLOR_BIT);
|
||||
VkBufferImageCopy copy{};
|
||||
@@ -3535,6 +3761,19 @@ struct Renderer::Impl {
|
||||
VK_PIPELINE_STAGE_2_HOST_BIT,
|
||||
VK_ACCESS_2_HOST_READ_BIT);
|
||||
}
|
||||
if (collect_light_tile_counts) {
|
||||
scene_barrier(VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT,
|
||||
VK_ACCESS_2_SHADER_STORAGE_WRITE_BIT,
|
||||
VK_PIPELINE_STAGE_2_TRANSFER_BIT,
|
||||
VK_ACCESS_2_TRANSFER_READ_BIT);
|
||||
const VkBufferCopy tile_copy{0, 0, light_tile_bytes};
|
||||
vkCmdCopyBuffer(command, light_tile_words.handle,
|
||||
light_tile_readback.handle, 1, &tile_copy);
|
||||
scene_barrier(VK_PIPELINE_STAGE_2_TRANSFER_BIT,
|
||||
VK_ACCESS_2_TRANSFER_WRITE_BIT,
|
||||
VK_PIPELINE_STAGE_2_HOST_BIT,
|
||||
VK_ACCESS_2_HOST_READ_BIT);
|
||||
}
|
||||
});
|
||||
if (swap_index)
|
||||
add_pass("Presentation", {"color"}, {"swapchain"}, [&] {
|
||||
@@ -3578,6 +3817,7 @@ struct Renderer::Impl {
|
||||
if (label == "MainCull") statistics.gpu_main_cull_ms = elapsed;
|
||||
else if (label == "SunShadowAtlas") statistics.gpu_sun_shadow_ms = elapsed;
|
||||
else if (label == "LocalShadowAtlas") statistics.gpu_local_shadow_ms = elapsed;
|
||||
else if (label == "LightTileBuild") statistics.gpu_light_tiles_ms = elapsed;
|
||||
else if (label == "MainRaster" || label == "ForwardAndUI")
|
||||
statistics.gpu_main_raster_ms = elapsed;
|
||||
else if (label == "BuildCurrentHZB") statistics.gpu_hzb_ms = elapsed;
|
||||
@@ -3647,6 +3887,29 @@ struct Renderer::Impl {
|
||||
statistics.culled_meshes += statistics.gpu_frustum_rejected;
|
||||
statistics.visibility_counters_valid = true;
|
||||
}
|
||||
if (collect_light_tile_counts) {
|
||||
void* mapped_tiles{};
|
||||
check(vkMapMemory(device, light_tile_readback.memory, 0,
|
||||
light_tile_bytes, 0, &mapped_tiles),
|
||||
"Read light tile diagnostics");
|
||||
const auto* words = static_cast<const std::uint32_t*>(mapped_tiles);
|
||||
if (words[0] != light_tiles_x || words[1] != 1 ||
|
||||
words[2] != light_tiles_y || words[3] != light_tile_capacity) {
|
||||
vkUnmapMemory(device, light_tile_readback.memory);
|
||||
throw std::runtime_error("Light tile diagnostic header is inconsistent");
|
||||
}
|
||||
for (std::uint32_t tile = 0; tile < light_tile_count; ++tile) {
|
||||
const auto base = 4u + tile * light_tile_stride_words;
|
||||
if (words[base] > light_tile_capacity || words[base + 1] > 1) {
|
||||
vkUnmapMemory(device, light_tile_readback.memory);
|
||||
throw std::runtime_error("Light tile diagnostic record is invalid");
|
||||
}
|
||||
statistics.light_tile_candidate_count += words[base];
|
||||
statistics.light_tile_overflow_count += words[base + 1];
|
||||
}
|
||||
vkUnmapMemory(device, light_tile_readback.memory);
|
||||
statistics.light_tile_counts_valid = true;
|
||||
}
|
||||
instance_tracker.finish_frame();
|
||||
scene.previous_vp = raster_vp;
|
||||
scene.previous_projection = snapshot.projection;
|
||||
@@ -3671,7 +3934,9 @@ struct Renderer::Impl {
|
||||
local_shadow.allocation_size +
|
||||
lighting_header.allocation_size +
|
||||
lighting_locals.allocation_size +
|
||||
lighting_views.allocation_size;
|
||||
lighting_views.allocation_size +
|
||||
light_tile_words.allocation_size +
|
||||
light_tile_readback.allocation_size;
|
||||
statistics.texture_count = static_cast<std::uint32_t>(textures.size());
|
||||
for (const auto& [_, texture] : textures)
|
||||
statistics.gpu_allocated_bytes += texture.image.allocation_size;
|
||||
@@ -3716,6 +3981,8 @@ bool Renderer::reload_shaders(std::string& error) {
|
||||
auto previous_shadow = r.shadow_pipeline;
|
||||
auto previous_sprite = r.sprite_pipeline;
|
||||
auto previous_temporal_ui = r.temporal_ui_pipeline;
|
||||
auto previous_light_tile = r.light_tile_pipeline;
|
||||
auto previous_light_tiles_capable = r.light_tiles_capable;
|
||||
auto previous_layout_fingerprints = r.shader_layouts;
|
||||
auto previous_gpu_fingerprints = r.scene.shader_layouts;
|
||||
auto previous_gpu = r.scene.graphics_pipeline;
|
||||
@@ -3734,6 +4001,7 @@ bool Renderer::reload_shaders(std::string& error) {
|
||||
r.shadow_pipeline = {};
|
||||
r.sprite_pipeline = {};
|
||||
r.temporal_ui_pipeline = {};
|
||||
r.light_tile_pipeline = {};
|
||||
r.scene.graphics_pipeline = r.scene.cull_pipeline = r.scene.post_pipeline =
|
||||
r.scene.hzb_pipeline = {};
|
||||
r.temporal.resolve_pipeline = r.temporal.composite_pipeline =
|
||||
@@ -3755,6 +4023,8 @@ bool Renderer::reload_shaders(std::string& error) {
|
||||
vkDestroyPipeline(r.device, r.sprite_pipeline, nullptr);
|
||||
if (r.temporal_ui_pipeline)
|
||||
vkDestroyPipeline(r.device, r.temporal_ui_pipeline, nullptr);
|
||||
if (r.light_tile_pipeline)
|
||||
vkDestroyPipeline(r.device, r.light_tile_pipeline, nullptr);
|
||||
if (r.pipeline_layout)
|
||||
vkDestroyPipelineLayout(r.device, r.pipeline_layout, nullptr);
|
||||
for (auto pipeline : {r.scene.graphics_pipeline, r.scene.cull_pipeline,
|
||||
@@ -3772,6 +4042,8 @@ bool Renderer::reload_shaders(std::string& error) {
|
||||
r.shadow_pipeline = previous_shadow;
|
||||
r.sprite_pipeline = previous_sprite;
|
||||
r.temporal_ui_pipeline = previous_temporal_ui;
|
||||
r.light_tile_pipeline = previous_light_tile;
|
||||
r.light_tiles_capable = previous_light_tiles_capable;
|
||||
r.scene.graphics_pipeline = previous_gpu;
|
||||
r.scene.cull_pipeline = previous_cull;
|
||||
r.scene.post_pipeline = previous_post;
|
||||
@@ -3793,6 +4065,8 @@ bool Renderer::reload_shaders(std::string& error) {
|
||||
vkDestroyPipeline(r.device, previous_shadow, nullptr);
|
||||
vkDestroyPipeline(r.device, previous_sprite, nullptr);
|
||||
vkDestroyPipeline(r.device, previous_temporal_ui, nullptr);
|
||||
if (previous_light_tile)
|
||||
vkDestroyPipeline(r.device, previous_light_tile, nullptr);
|
||||
for (auto pipeline : {previous_gpu, previous_cull, previous_post, previous_hzb})
|
||||
if (pipeline)
|
||||
vkDestroyPipeline(r.device, pipeline, nullptr);
|
||||
|
||||
@@ -30,17 +30,56 @@ 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" || entry == "temporalFragmentMain";
|
||||
const bool temporal = entry == "temporalVertexMain" ||
|
||||
entry == "temporalFragmentMain";
|
||||
require(layout.at("stage") == (fragment ? "fragment" : "vertex"), "shader stage changed");
|
||||
const auto& descriptors = layout.at("descriptors");
|
||||
require(descriptors.is_array() && descriptors.size() == 8, "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];
|
||||
const auto set = i < 4 ? 0 : 1;
|
||||
const auto slot = i % 4;
|
||||
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");
|
||||
@@ -48,8 +87,8 @@ void validate_layout(const Json& layout, std::string_view entry) {
|
||||
: slot == 3 ? "sampled_image_2d" : "storage_buffer";
|
||||
require(binding.at("type") == expected_type,
|
||||
"descriptor type changed");
|
||||
if (set == 1 && slot < 3)
|
||||
require(binding.at("element_stride") == (slot == 2 ? 112 : 80),
|
||||
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");
|
||||
@@ -298,13 +337,15 @@ detail::ShaderCode load(const std::filesystem::path& directory, const char* entr
|
||||
validate_temporal_layout(layout, entry);
|
||||
else 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());
|
||||
const auto stage = temporal
|
||||
const auto stage = std::string_view(entry) == "lightTileMain" ? 5u : temporal
|
||||
? (std::string_view(entry) == "temporalResolveMain" ? 5u
|
||||
: std::string_view(entry) == "temporalCompositeVertexMain" ? 0u : 4u)
|
||||
: gpu ? ((std::string_view(entry) == "gpuVertexMain" ||
|
||||
@@ -316,10 +357,10 @@ detail::ShaderCode load(const std::filesystem::path& directory, const char* entr
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
// Order: temporal resolve compute, full-screen composite vertex and fragment.
|
||||
|
||||
@@ -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" /
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -39,7 +39,9 @@ with tempfile.TemporaryDirectory(prefix="faset-player-diagnostics-") as temporar
|
||||
"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_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 \
|
||||
|
||||
@@ -21,13 +21,14 @@ Frame capture(Renderer& renderer, const Snapshot& scene) {
|
||||
renderer.render(scene);
|
||||
return {renderer.pixels(), renderer.stats()};
|
||||
}
|
||||
Renderer make_renderer(VisibilityMode mode) {
|
||||
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);
|
||||
}
|
||||
@@ -286,17 +287,114 @@ void local() {
|
||||
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 or --local");
|
||||
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 or --local");
|
||||
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';
|
||||
|
||||
@@ -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", "temporalResolveMain",
|
||||
"temporalCompositeVertexMain", "temporalCompositeFragmentMain",
|
||||
@@ -91,7 +91,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",
|
||||
"temporalResolveMain", "temporalCompositeVertexMain",
|
||||
"temporalCompositeFragmentMain", "temporalVertexMain",
|
||||
"temporalFragmentMain", "gpuTemporalVertexMain"})
|
||||
@@ -139,6 +139,40 @@ int main() {
|
||||
"Temporal reload fixture has a completed color history");
|
||||
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}});
|
||||
@@ -152,7 +186,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", "temporalResolveMain",
|
||||
"temporalCompositeVertexMain", "temporalCompositeFragmentMain",
|
||||
|
||||
@@ -56,15 +56,35 @@ class ReflectionTests(unittest.TestCase):
|
||||
(d["set"], d["binding"]): (d["type"], d.get("element_stride"))
|
||||
for d in fragment["layout"]["descriptors"]
|
||||
}
|
||||
self.assertEqual([lighting[1, i] for i in range(4)],
|
||||
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", 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)], [288, 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
|
||||
|
||||
Reference in New Issue
Block a user