Add measured optional tiled Forward+ lighting
Native and manual checks / native (ubuntu-24.04) (push) Failing after 36s
Native and manual checks / manual (push) Successful in 25s
Windows editor and software Vulkan / windows-graphics (push) Canceled after 0s
Native and manual checks / native (windows-2025) (push) Canceled after 0s
Native and manual checks / native (ubuntu-24.04) (push) Failing after 36s
Native and manual checks / manual (push) Successful in 25s
Windows editor and software Vulkan / windows-graphics (push) Canceled after 0s
Native and manual checks / native (windows-2025) (push) Canceled after 0s
This commit is contained in:
@@ -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"})
|
||||
for (const auto* extension : {".spv", ".reflection.json"}) {
|
||||
@@ -88,7 +88,7 @@ int main() {
|
||||
render::Renderer renderer(configuration);
|
||||
const auto baseline_only = temporary / "baseline-only";
|
||||
fs::create_directories(baseline_only);
|
||||
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain"})
|
||||
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain", "lightTileMain"})
|
||||
for (const auto* extension : {".spv", ".reflection.json"}) {
|
||||
const auto name = std::string(entry) + extension;
|
||||
fs::copy_file(bundle / name, baseline_only / name);
|
||||
@@ -126,6 +126,40 @@ int main() {
|
||||
opaque_scene.draws.push_back(opaque_cube);
|
||||
gpu_renderer.render(opaque_scene);
|
||||
const auto gpu_expected = gpu_renderer.pixels();
|
||||
auto tiled_configuration = configuration;
|
||||
tiled_configuration.lighting_mode = render::LightingMode::Tiled;
|
||||
render::Renderer tiled_renderer(tiled_configuration);
|
||||
auto lit_scene = opaque_scene;
|
||||
render::LocalLight point;
|
||||
point.stable_id = "reload-point";
|
||||
point.position = {1, 1, 3};
|
||||
point.intensity = 5;
|
||||
point.range = 8;
|
||||
point.casts_shadow = false;
|
||||
lit_scene.local_lights.push_back(point);
|
||||
tiled_renderer.render(lit_scene);
|
||||
require(tiled_renderer.stats().effective_lighting_path == "tiled" &&
|
||||
tiled_renderer.stats().validation_errors == 0,
|
||||
"Tiled lighting is active before shader reload");
|
||||
const auto tiled_expected = tiled_renderer.pixels();
|
||||
const auto original_tile_spirv = read_text(bundle / "lightTileMain.spv");
|
||||
atomic_write(bundle / "lightTileMain.spv", "damaged tile bytecode");
|
||||
std::string tile_error;
|
||||
require(!tiled_renderer.reload_shaders(tile_error) && !tile_error.empty(),
|
||||
"Rejected light tile shader preserves the working pipeline");
|
||||
tiled_renderer.render(lit_scene);
|
||||
require(tiled_renderer.stats().effective_lighting_path == "tiled" &&
|
||||
tiled_renderer.pixels() == tiled_expected &&
|
||||
tiled_renderer.stats().validation_errors == 0,
|
||||
"Rejected light tile shader retains tiled lighting and pixels");
|
||||
atomic_write(bundle / "lightTileMain.spv", original_tile_spirv);
|
||||
require(tiled_renderer.reload_shaders(tile_error),
|
||||
"Compatible light tile shader reloads successfully");
|
||||
tiled_renderer.render(lit_scene);
|
||||
require(tiled_renderer.stats().effective_lighting_path == "tiled" &&
|
||||
tiled_renderer.pixels() == tiled_expected &&
|
||||
tiled_renderer.stats().validation_errors == 0,
|
||||
"Compatible light tile reload preserves tiled pixels");
|
||||
render::Snapshot scene;
|
||||
scene.ui_quads.push_back({0, 0, 32, 64, {1, .8f, .4f, 1}});
|
||||
scene.sprites.push_back({{.5f, 0, .5f}, {1, 2}, {.2f, 1, .4f, 1}});
|
||||
@@ -139,7 +173,7 @@ int main() {
|
||||
require(deep_bundle.native().size() > 300,
|
||||
"Shader file fixture must exceed the legacy Windows path limit");
|
||||
fs::create_directories(native_io_path(deep_bundle));
|
||||
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain",
|
||||
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain", "lightTileMain",
|
||||
"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
|
||||
"gpuHzbMain", "gpuPostCullMain"})
|
||||
for (const auto* extension : {".spv", ".reflection.json"}) {
|
||||
|
||||
@@ -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)], [224, 4, 208])
|
||||
|
||||
def test_light_tile_compute_reflection(self):
|
||||
compiler = os.environ["FASET_TEST_SLANGC"]
|
||||
with tempfile.TemporaryDirectory(prefix="faset-light-tiles-abi-") as directory:
|
||||
process = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--compiler", compiler, "--source",
|
||||
str(SCRIPT.parents[1] / "shaders" / "light_tiles.slang"), "--entry",
|
||||
"lightTileMain", "--output", directory],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
self.assertEqual(process.returncode, 0, process.stderr)
|
||||
layout = json.loads((Path(directory) / "lightTileMain.reflection.json").read_text())["layout"]
|
||||
self.assertEqual(layout["stage"], "compute")
|
||||
self.assertEqual(
|
||||
[(item["set"], item["binding"], item["type"], item.get("element_stride"))
|
||||
for item in layout["descriptors"]],
|
||||
[(0, 0, "storage_buffer", 80), (0, 1, "storage_buffer", 4)],
|
||||
)
|
||||
self.assertEqual(layout["push_constants"][0]["size"], 96)
|
||||
|
||||
def test_gpu_vertex_paths_do_not_require_shader_draw_parameters(self):
|
||||
# SV_InstanceID makes Slang subtract BaseInstance and emit DrawParameters.
|
||||
# Our indirect commands always use firstInstance=0, so the Vulkan instance
|
||||
|
||||
Reference in New Issue
Block a user