From a0a4e29d480ed3344f19bd3565d48668ca913fed Mon Sep 17 00:00:00 2001 From: Emil <65846814+emil28092005@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:37:04 +0300 Subject: [PATCH] Add measured optional tiled Forward+ lighting --- apps/player_main.cpp | 10 +- cmake/Renderer.cmake | 10 + examples/renderer/p3_lighting_benchmark.cpp | 47 ++- include/faset/render/renderer.hpp | 9 + shaders/baseline.slang | 18 +- shaders/light_tiles.slang | 75 +++++ src/editor/build_service.cpp | 2 + src/editor/debug_overlay.cpp | 8 + src/render/renderer.cpp | 299 +++++++++++++++++++- src/render/shader_contract.cpp | 56 +++- src/render/shader_contract.hpp | 3 +- tests/build_schema_tests.cpp | 2 +- tests/build_schema_tool.cpp | 2 +- tests/player_diagnostics_test.py | 4 +- tests/render_lighting_gpu_tests.cpp | 104 ++++++- tests/render_reload_tests.cpp | 40 ++- tests/test_shader_reflection.py | 24 +- 17 files changed, 673 insertions(+), 40 deletions(-) create mode 100644 shaders/light_tiles.slang diff --git a/apps/player_main.cpp b/apps/player_main.cpp index 3ac7f5d..1191406 100644 --- a/apps/player_main.cpp +++ b/apps/player_main.cpp @@ -125,7 +125,15 @@ Json profileFrames(const std::vector& samples) { {"gpu_sun_shadow_ms", gpuMeasured ? Json(sample.lighting.gpu_sun_shadow_ms) : Json(nullptr)}, {"gpu_local_shadow_ms", - gpuMeasured ? Json(sample.lighting.gpu_local_shadow_ms) : Json(nullptr)}}); + gpuMeasured ? Json(sample.lighting.gpu_local_shadow_ms) : Json(nullptr)}, + {"gpu_light_tiles_ms", + gpuMeasured ? Json(sample.lighting.gpu_light_tiles_ms) : Json(nullptr)}, + {"light_tile_count", sample.lighting.light_tile_count}, + {"light_tile_counts_valid", sample.lighting.light_tile_counts_valid}, + {"light_tile_candidate_count", sample.lighting.light_tile_counts_valid + ? Json(sample.lighting.light_tile_candidate_count) : Json(nullptr)}, + {"light_tile_overflow_count", sample.lighting.light_tile_counts_valid + ? Json(sample.lighting.light_tile_overflow_count) : Json(nullptr)}}); } return {{"samples", std::move(frames)}, {"summary_ms", diff --git a/cmake/Renderer.cmake b/cmake/Renderer.cmake index b1f2136..681c6cf 100644 --- a/cmake/Renderer.cmake +++ b/cmake/Renderer.cmake @@ -17,6 +17,14 @@ foreach(FASET_ENTRY vertexMain fragmentMain shadowMain) DEPENDS "${PROJECT_SOURCE_DIR}/shaders/baseline.slang" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py" VERBATIM) list(APPEND FASET_SHADER_OUTPUTS "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.reflection.json") endforeach() +set(FASET_SHADER_OUTPUT "${FASET_SHADER_DIRECTORY}/lightTileMain.spv") +add_custom_command(OUTPUT "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/lightTileMain.reflection.json" + COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py" + --compiler "${SLANGC_EXECUTABLE}" --source "${PROJECT_SOURCE_DIR}/shaders/light_tiles.slang" + --entry lightTileMain --output "${FASET_SHADER_DIRECTORY}" + BYPRODUCTS "${FASET_SHADER_DIRECTORY}/lightTileMain.slang-reflection.json" + DEPENDS "${PROJECT_SOURCE_DIR}/shaders/light_tiles.slang" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py" VERBATIM) +list(APPEND FASET_SHADER_OUTPUTS "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/lightTileMain.reflection.json") foreach(FASET_ENTRY gpuVertexMain gpuShadowMain gpuCullMain gpuHzbMain gpuPostCullMain) if(FASET_ENTRY STREQUAL "gpuVertexMain" OR FASET_ENTRY STREQUAL "gpuShadowMain") set(FASET_GPU_DEFINE FASET_GPU_GRAPHICS=1) @@ -53,6 +61,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) diff --git a/examples/renderer/p3_lighting_benchmark.cpp b/examples/renderer/p3_lighting_benchmark.cpp index aae5c72..256dfe9 100644 --- a/examples/renderer/p3_lighting_benchmark.cpp +++ b/examples/renderer/p3_lighting_benchmark.cpp @@ -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) diff --git a/include/faset/render/renderer.hpp b/include/faset/render/renderer.hpp index 324ed18..58cfa9a 100644 --- a/include/faset/render/renderer.hpp +++ b/include/faset/render/renderer.hpp @@ -136,6 +136,7 @@ struct Snapshot { std::optional 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); @@ -146,6 +147,9 @@ struct RendererConfig { bool headless{false}; bool validation{true}; VisibilityMode visibility_mode{VisibilityMode::Direct}; + // Auto prefers the 16x16 tiled light list at 32+ submitted local lights. + // Forward remains the reference and the fallback on unsupported devices. + LightingMode lighting_mode{LightingMode::Auto}; // GPU counter readback is diagnostic-only; normal visibility uses no CPU feedback. bool visibility_diagnostics{false}; // Optional isolated shader bundle, useful for editor preview and shader reload tests. @@ -204,6 +208,11 @@ struct FrameStats { double gpu_main_cull_ms{}, gpu_main_raster_ms{}, gpu_hzb_ms{}; double gpu_post_cull_ms{}, gpu_post_raster_ms{}; double gpu_sun_shadow_ms{}, gpu_local_shadow_ms{}; + double gpu_light_tiles_ms{}; + std::uint32_t light_tile_count{}; + // Optional tile-list readback, valid only when visibility diagnostics are on. + bool light_tile_counts_valid{}; + std::uint32_t light_tile_candidate_count{}, light_tile_overflow_count{}; std::string effective_lighting_path{"forward"}; std::string device; }; diff --git a/shaders/baseline.slang b/shaders/baseline.slang index bf04df3..a1ecabb 100644 --- a/shaders/baseline.slang +++ b/shaders/baseline.slang @@ -50,6 +50,9 @@ struct ShadowViewGpu { [[vk::binding(1,1)]] StructuredBuffer localLights; [[vk::binding(2,1)]] StructuredBuffer shadowViews; [[vk::binding(3,1)]] Texture2D 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 lightTileWords; [shader("vertex")] VertexOutput vertexMain(VertexInput v) { VertexOutput o; @@ -171,7 +174,20 @@ float4 fragmentMain(VertexOutput v) : SV_Target { linear += directBRDF(base.rgb, rough, metal, n, view, l) * lighting.sunColor.rgb * (lighting.sunDirectionIntensity.w * 3.0 * visibility); } - for (uint i=0; i build; +[[vk::binding(0,0)]] StructuredBuffer localLights; +[[vk::binding(1,0)]] RWStructuredBuffer 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; +} diff --git a/src/editor/build_service.cpp b/src/editor/build_service.cpp index 5ad1c1c..3fee274 100644 --- a/src/editor/build_service.cpp +++ b/src/editor/build_service.cpp @@ -333,6 +333,7 @@ struct BuildService::Impl { copy_required_file(player, staging / ("faset_player" + executable_suffix())); copy_required_file(exporter, staging / ("faset_schema_exporter" + executable_suffix())); for (const auto* file : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv", + "lightTileMain.spv", "lightTileMain.reflection.json", "vertexMain.reflection.json", "fragmentMain.reflection.json", "shadowMain.reflection.json", "gpuVertexMain.spv", "gpuShadowMain.spv", "gpuCullMain.spv", "gpuHzbMain.spv", @@ -585,6 +586,7 @@ struct BuildService::Impl { copy_required_file(build_directory / ("faset_player" + executable_suffix()), staging / ("faset_player" + executable_suffix())); for (const auto* shader : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv", + "lightTileMain.spv", "lightTileMain.reflection.json", "vertexMain.reflection.json", "fragmentMain.reflection.json", "shadowMain.reflection.json", "gpuVertexMain.spv", "gpuShadowMain.spv", "gpuCullMain.spv", "gpuHzbMain.spv", diff --git a/src/editor/debug_overlay.cpp b/src/editor/debug_overlay.cpp index c9d18b7..c2cb930 100644 --- a/src/editor/debug_overlay.cpp +++ b/src/editor/debug_overlay.cpp @@ -383,6 +383,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", diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 0745172..5af84e4 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -95,6 +95,12 @@ struct ShadowViewGpu { std::array guarded_clamp{}; std::array bias_flags{}; }; +struct LightTilePush { + Mat4 view_projection; + std::array viewport; + std::array dimensions; +}; +static_assert(sizeof(LightTilePush) == 96); static_assert(sizeof(LightingHeaderGpu) == 80 && offsetof(LightingHeaderGpu, sun_direction_intensity) == 16 && offsetof(LightingHeaderGpu, sun_color) == 32 && @@ -225,7 +231,7 @@ struct Renderer::Impl { float timestamp_period{}; std::uint32_t timestamp_bits{}; VkSemaphore acquired{}, present_ready{}; - std::array shader_layouts{}; + std::array shader_layouts{}; VkSwapchainKHR swapchain{}; VkFormat swap_format{}; VkExtent2D swap_extent{}; @@ -234,7 +240,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; InstanceTracker instance_tracker; std::unordered_map previous_lods; @@ -254,6 +262,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{}; @@ -361,12 +374,15 @@ 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); destroy(local_shadow); if (device) { destroy_scene_interfaces(); + destroy_light_tile_interfaces(); if (pipeline) vkDestroyPipeline(device, pipeline, nullptr); if (ui_pipeline) @@ -697,6 +713,13 @@ struct Renderer::Impl { max_compute_groups_x = properties.limits.maxComputeWorkGroupCount[0]; max_storage_buffer_range = properties.limits.maxStorageBufferRange; max_image_dimension = properties.limits.maxImageDimension2D; + 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 && @@ -805,7 +828,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) make_scene_descriptors_and_pipelines(); @@ -964,7 +999,7 @@ struct Renderer::Impl { pi.pPoolSizes = sizes; check(vkCreateDescriptorPool(device, &pi, nullptr, &descriptor_pool), "Create descriptor pool"); - std::array lighting_bindings{}; + std::array lighting_bindings{}; for (std::uint32_t i = 0; i < lighting_bindings.size(); ++i) lighting_bindings[i] = {i, i == 3 ? VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE @@ -975,7 +1010,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; @@ -999,6 +1034,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 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(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 source) { if (!source) source = white; @@ -1215,6 +1303,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); @@ -1635,14 +1747,15 @@ struct Renderer::Impl { upload_scene_buffer(buffer, values.data(), values.size() * sizeof(T), usage); } void update_lighting_descriptors() { - const std::array buffers{{ + const std::array 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 writes{}; + std::array 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; @@ -1653,7 +1766,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(writes.size()), + writes.data(), 0, nullptr); + } + void update_light_tile_descriptors() { + if (!light_tile_set) + return; + const std::array buffers{{ + {lighting_locals.handle, 0, lighting_locals.size}, + {light_tile_words.handle, 0, light_tile_words.size}}}; + std::array 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(writes.size()), writes.data(), 0, nullptr); @@ -1765,6 +1896,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 = @@ -2354,7 +2489,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::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(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() @@ -2596,6 +2788,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((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"}, [&] { @@ -2656,8 +2880,9 @@ struct Renderer::Impl { }); add_pass(occlusion ? "MainRaster" : "ForwardAndUI", gpu_active ? std::vector{"shadow", "local_shadow", - "main_indirect", "main_visible"} - : std::vector{"shadow", "local_shadow"}, + "light_tiles", "main_indirect", "main_visible"} + : std::vector{"shadow", "local_shadow", + "light_tiles"}, {"color", "depth"}, [&] { transition(command, color, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_ASPECT_COLOR_BIT); @@ -2845,7 +3070,9 @@ struct Renderer::Impl { vkCmdEndRendering(command); }); } - add_pass("Readback", {"color"}, {"capture"}, [&] { + add_pass("Readback", collect_light_tile_counts + ? std::vector{"color", "light_tiles"} + : std::vector{"color"}, {"capture"}, [&] { transition(command, color, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_ASPECT_COLOR_BIT); VkBufferImageCopy copy{}; @@ -2882,6 +3109,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"}, [&] { @@ -2925,6 +3165,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; @@ -2988,6 +3229,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(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 = snapshot.view_projection; scene.previous_projection = snapshot.projection; @@ -3001,7 +3265,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(textures.size()); for (const auto& [_, texture] : textures) statistics.gpu_allocated_bytes += texture.image.allocation_size; @@ -3039,6 +3305,8 @@ bool Renderer::reload_shaders(std::string& error) { auto previous_ui = r.ui_pipeline; auto previous_shadow = r.shadow_pipeline; auto previous_sprite = r.sprite_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; @@ -3050,6 +3318,7 @@ bool Renderer::reload_shaders(std::string& error) { r.ui_pipeline = {}; r.shadow_pipeline = {}; r.sprite_pipeline = {}; + r.light_tile_pipeline = {}; r.scene.graphics_pipeline = r.scene.cull_pipeline = r.scene.post_pipeline = r.scene.hzb_pipeline = {}; try { @@ -3065,6 +3334,8 @@ bool Renderer::reload_shaders(std::string& error) { vkDestroyPipeline(r.device, r.shadow_pipeline, nullptr); if (r.sprite_pipeline) vkDestroyPipeline(r.device, r.sprite_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, @@ -3076,6 +3347,8 @@ bool Renderer::reload_shaders(std::string& error) { r.ui_pipeline = previous_ui; r.shadow_pipeline = previous_shadow; r.sprite_pipeline = previous_sprite; + 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; @@ -3089,6 +3362,8 @@ bool Renderer::reload_shaders(std::string& error) { vkDestroyPipeline(r.device, previous_ui, nullptr); vkDestroyPipeline(r.device, previous_shadow, nullptr); vkDestroyPipeline(r.device, previous_sprite, 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); diff --git a/src/render/shader_contract.cpp b/src/render/shader_contract.cpp index 7c2e6cc..52fadb5 100644 --- a/src/render/shader_contract.cpp +++ b/src/render/shader_contract.cpp @@ -30,15 +30,54 @@ void locations(const Json& fields, std::initializer_list types, con ++index; } } +void validate_tile_layout(const Json& layout) { + require(layout.at("stage") == "compute", "light tile shader stage changed"); + const auto& descriptors = layout.at("descriptors"); + require(descriptors.is_array() && descriptors.size() == 2, + "light tile descriptor count changed"); + for (std::size_t i = 0; i < 2; ++i) + require(descriptors[i].at("set") == 0 && descriptors[i].at("binding") == i && + descriptors[i].at("count") == 1 && + descriptors[i].at("type") == "storage_buffer" && + descriptors[i].at("element_stride") == (i == 0 ? 80 : 4), + "light tile descriptor ABI changed"); + const auto& constants = layout.at("push_constants"); + require(constants.is_array() && constants.size() == 1 && + constants[0].at("offset") == 0 && constants[0].at("size") == 96, + "light tile push size changed"); + const auto& members = constants[0].at("members"); + require(members.is_array() && members.size() == 3, + "light tile push members changed"); + const int offsets[] = {0, 64, 80}; + const char* types[] = {"float32x4x4", "float32x4", "uint32x4"}; + for (std::size_t i = 0; i < 3; ++i) + require(members[i].at("offset") == offsets[i] && + members[i].at("size") == (i == 0 ? 64 : 16) && + members[i].at("type") == types[i], + "light tile push field changed"); + const auto& blocks = layout.at("spirv_push_constants"); + require(blocks.is_array() && blocks.size() == 1 && + blocks[0].at("members").size() == 3, + "light tile SPIR-V push block changed"); + const auto& actual = blocks[0].at("members"); + for (std::size_t i = 0; i < 3; ++i) + require(actual[i].at("member") == i && actual[i].at("offset") == offsets[i], + "light tile SPIR-V push offset changed"); + require(actual[0].at("matrix_layout") == "row-major" && + actual[0].at("matrix_stride") == 16, + "light tile SPIR-V matrix storage convention changed"); + locations(layout.at("inputs"), {}, "light tile inputs"); + locations(layout.at("outputs"), {}, "light tile outputs"); +} void validate_layout(const Json& layout, std::string_view entry) { const bool fragment = entry == "fragmentMain"; require(layout.at("stage") == (fragment ? "fragment" : "vertex"), "shader stage changed"); const auto& descriptors = layout.at("descriptors"); - require(descriptors.is_array() && descriptors.size() == 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"); @@ -46,8 +85,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(), "vertex texture bindings are unsupported"); @@ -198,22 +237,25 @@ detail::ShaderCode load(const std::filesystem::path& directory, const char* entr require(metadata.at("layout_fingerprint") == fingerprint, "layout fingerprint mismatch"); if (gpu) validate_gpu_layout(layout, entry); + else if (std::string_view(entry) == "lightTileMain") + validate_tile_layout(layout); else validate_layout(layout, entry); detail::ShaderCode result; result.layout_fingerprint = fingerprint; result.words.resize(bytes.size() / 4); std::memcpy(result.words.data(), bytes.data(), bytes.size()); - validate_spirv(result.words, gpu ? ((std::string_view(entry) == "gpuVertexMain" || + validate_spirv(result.words, std::string_view(entry) == "lightTileMain" ? 5u : + gpu ? ((std::string_view(entry) == "gpuVertexMain" || std::string_view(entry) == "gpuShadowMain") ? 0u : 5u) : (std::string_view(entry) == "fragmentMain" ? 4u : 0u)); return result; } } // namespace -std::array +std::array 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::load_gpu_shader_bundle(const std::filesystem::path& directory) { diff --git a/src/render/shader_contract.hpp b/src/render/shader_contract.hpp index 86b136d..da3a406 100644 --- a/src/render/shader_contract.hpp +++ b/src/render/shader_contract.hpp @@ -10,7 +10,8 @@ struct ShaderCode { std::vector words; std::string layout_fingerprint; }; -std::array load_shader_bundle(const std::filesystem::path& directory); +// Direct graphics plus the independent 16x16 light-tile compute entry. +std::array load_shader_bundle(const std::filesystem::path& directory); // Order: opaque vertex, optional instanced shadow vertex, main cull, HZB, post cull. std::array load_gpu_shader_bundle(const std::filesystem::path& directory); } // namespace faset::render::detail diff --git a/tests/build_schema_tests.cpp b/tests/build_schema_tests.cpp index ecb3acf..b155a97 100644 --- a/tests/build_schema_tests.cpp +++ b/tests/build_schema_tests.cpp @@ -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()); - 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" / diff --git a/tests/build_schema_tool.cpp b/tests/build_schema_tool.cpp index b6d80ab..35aee0f 100644 --- a/tests/build_schema_tool.cpp +++ b/tests/build_schema_tool.cpp @@ -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"}) diff --git a/tests/player_diagnostics_test.py b/tests/player_diagnostics_test.py index a8f7a5b..b3eb204 100644 --- a/tests/player_diagnostics_test.py +++ b/tests/player_diagnostics_test.py @@ -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 \ diff --git a/tests/render_lighting_gpu_tests.cpp b/tests/render_lighting_gpu_tests.cpp index 99728d3..ecc1de0 100644 --- a/tests/render_lighting_gpu_tests.cpp +++ b/tests/render_lighting_gpu_tests.cpp @@ -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'; diff --git a/tests/render_reload_tests.cpp b/tests/render_reload_tests.cpp index 59e0ae0..e392be1 100644 --- a/tests/render_reload_tests.cpp +++ b/tests/render_reload_tests.cpp @@ -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"}) { diff --git a/tests/test_shader_reflection.py b/tests/test_shader_reflection.py index 38f8bb3..7622c82 100644 --- a/tests/test_shader_reflection.py +++ b/tests/test_shader_reflection.py @@ -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