Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b191ae0bed | ||
|
|
a428a33cb0 | ||
|
|
ac50ac8855 | ||
|
|
6c29e177c6 | ||
|
|
2773f0b75c | ||
|
|
a5fb2167d6 | ||
|
|
252f7e2e91 |
+33
-2
@@ -48,6 +48,7 @@ struct ProfileSample {
|
||||
bool physicsDebug{};
|
||||
bool gpuVisibilityActive{};
|
||||
faset::render::VisibilityMode effectiveVisibilityMode{faset::render::VisibilityMode::Direct};
|
||||
faset::render::FrameStats lighting;
|
||||
};
|
||||
Json distribution(std::vector<double> values) {
|
||||
if (values.empty())
|
||||
@@ -96,7 +97,35 @@ Json profileFrames(const std::vector<ProfileSample>& samples) {
|
||||
{"physics_debug", sample.physicsDebug},
|
||||
{"gpu_visibility_active", sample.gpuVisibilityActive},
|
||||
{"effective_visibility_mode",
|
||||
visibility_mode_name(sample.effectiveVisibilityMode)}});
|
||||
visibility_mode_name(sample.effectiveVisibilityMode)},
|
||||
{"effective_lighting_path", sample.lighting.effective_lighting_path},
|
||||
{"submitted_local_lights", sample.lighting.submitted_local_lights},
|
||||
{"omitted_local_lights", sample.lighting.omitted_local_lights},
|
||||
{"requested_sun_cascades", sample.lighting.requested_sun_cascades},
|
||||
{"effective_sun_cascades", sample.lighting.effective_sun_cascades},
|
||||
{"requested_local_shadow_faces",
|
||||
sample.lighting.requested_local_shadow_faces},
|
||||
{"local_shadow_faces", sample.lighting.local_shadow_faces},
|
||||
{"local_shadow_tiles", sample.lighting.local_shadow_tiles},
|
||||
{"dropped_shadow_faces", sample.lighting.dropped_shadow_faces},
|
||||
{"dropped_point_shadow_faces",
|
||||
sample.lighting.dropped_point_shadow_faces},
|
||||
{"shadow_atlas_full_drops", sample.lighting.shadow_atlas_full_drops},
|
||||
{"shadow_caster_budget_drops",
|
||||
sample.lighting.shadow_caster_budget_drops},
|
||||
{"shadow_unavailable_drops",
|
||||
sample.lighting.shadow_unavailable_drops},
|
||||
{"shadow_caster_draws", sample.lighting.shadow_caster_draws},
|
||||
{"sun_shadow_atlas_bytes",
|
||||
sample.lighting.sun_shadow_atlas_bytes},
|
||||
{"local_shadow_atlas_bytes",
|
||||
sample.lighting.local_shadow_atlas_bytes},
|
||||
{"gpu_main_raster_ms",
|
||||
gpuMeasured ? Json(sample.lighting.gpu_main_raster_ms) : Json(nullptr)},
|
||||
{"gpu_sun_shadow_ms",
|
||||
gpuMeasured ? Json(sample.lighting.gpu_sun_shadow_ms) : Json(nullptr)},
|
||||
{"gpu_local_shadow_ms",
|
||||
gpuMeasured ? Json(sample.lighting.gpu_local_shadow_ms) : Json(nullptr)}});
|
||||
}
|
||||
return {{"samples", std::move(frames)},
|
||||
{"summary_ms",
|
||||
@@ -587,7 +616,8 @@ int player_main(int argc, char** argv) {
|
||||
milliseconds(renderStarted, frameFinished), measured.cpu_ms, measured.gpu_ms,
|
||||
measured.readback_cpu_ms, runtimeStats, measured.draw_calls, measured.vertices,
|
||||
measured.gpu_allocated_bytes, measured.texture_count, debugPhysics,
|
||||
measured.gpu_visibility_active, measured.effective_visibility_mode});
|
||||
measured.gpu_visibility_active, measured.effective_visibility_mode,
|
||||
measured});
|
||||
}
|
||||
++frames;
|
||||
}
|
||||
@@ -619,6 +649,7 @@ int player_main(int argc, char** argv) {
|
||||
{"visibility_mode", visibilityName},
|
||||
{"effective_visibility_mode",
|
||||
visibility_mode_name(stats.effective_visibility_mode)},
|
||||
{"effective_lighting_path", stats.effective_lighting_path},
|
||||
{"simulation_mode", "synthetic_fixed_timestep"},
|
||||
{"fixed_delta_seconds", config.fixedDelta},
|
||||
{"percentile_method", "nearest_rank_all_completed_frames_no_warmup_exclusion"},
|
||||
|
||||
@@ -47,6 +47,12 @@ target_link_libraries(faset_render PRIVATE Vulkan::Vulkan SDL3::SDL3 faset_core)
|
||||
target_compile_definitions(faset_render PRIVATE FASET_SHADER_DIRECTORY="${FASET_SHADER_DIRECTORY}")
|
||||
add_dependencies(faset_render faset_shaders)
|
||||
if(BUILD_TESTING)
|
||||
add_executable(faset_render_lighting_gpu_tests "${PROJECT_SOURCE_DIR}/tests/render_lighting_gpu_tests.cpp")
|
||||
target_link_libraries(faset_render_lighting_gpu_tests PRIVATE faset_render)
|
||||
add_test(NAME render_lighting_sun COMMAND faset_render_lighting_gpu_tests --sun)
|
||||
set_tests_properties(render_lighting_sun PROPERTIES LABELS "gpu;p3")
|
||||
add_test(NAME render_lighting_local COMMAND faset_render_lighting_gpu_tests --local)
|
||||
set_tests_properties(render_lighting_local PROPERTIES LABELS "gpu;p3")
|
||||
add_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)
|
||||
@@ -82,5 +88,17 @@ if(BUILD_TESTING)
|
||||
target_link_libraries(faset_render_window_tests PRIVATE faset_render SDL3::SDL3)
|
||||
add_test(NAME render_window_lifecycle COMMAND faset_render_window_tests "${CMAKE_BINARY_DIR}/window-test")
|
||||
set_tests_properties(render_window_lifecycle PROPERTIES LABELS "gpu;window" TIMEOUT 40 SKIP_RETURN_CODE 77)
|
||||
add_executable(faset_p3_lighting_benchmark
|
||||
"${PROJECT_SOURCE_DIR}/examples/renderer/p3_lighting_benchmark.cpp")
|
||||
target_link_libraries(faset_p3_lighting_benchmark PRIVATE faset_render faset_core)
|
||||
target_compile_definitions(faset_p3_lighting_benchmark PRIVATE
|
||||
FASET_BENCHMARK_CONFIGURATION="$<CONFIG>")
|
||||
add_test(NAME render_lighting_benchmark_schema COMMAND
|
||||
"${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tests/test_p3_lighting_benchmark.py")
|
||||
set_tests_properties(render_lighting_benchmark_schema PROPERTIES LABELS "p3" TIMEOUT 90)
|
||||
add_test(NAME render_lighting_benchmark_smoke COMMAND
|
||||
"${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tests/test_p3_lighting_benchmark.py"
|
||||
--real-executable "$<TARGET_FILE:faset_p3_lighting_benchmark>")
|
||||
set_tests_properties(render_lighting_benchmark_smoke PROPERTIES LABELS "gpu;p3" TIMEOUT 90)
|
||||
endif()
|
||||
install(FILES ${FASET_SHADER_OUTPUTS} DESTINATION shaders)
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/render/renderer.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
using namespace faset::render;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
struct Options {
|
||||
unsigned lights{}, width{1920}, height{1080}, warmup{10}, frames{30}, run_index{};
|
||||
bool shadows{}, validation{};
|
||||
VisibilityMode visibility{VisibilityMode::Direct};
|
||||
std::string commit{"unknown"}, driver{"unknown"};
|
||||
fs::path csv, capture;
|
||||
};
|
||||
|
||||
unsigned number(std::string_view text, std::string_view name) {
|
||||
std::size_t end{};
|
||||
const auto value = std::stoul(std::string(text), &end);
|
||||
if (end != text.size() || value > 100000)
|
||||
throw std::invalid_argument("Invalid value for " + std::string(name));
|
||||
return static_cast<unsigned>(value);
|
||||
}
|
||||
|
||||
Options parse(int argc, char** argv) {
|
||||
Options options;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::string name = argv[i];
|
||||
if (name == "--list-runs") {
|
||||
std::cout << "{\"lights\":[0,4,16,32,64,128],"
|
||||
"\"visibility\":[\"direct\",\"gpu-frustum\",\"gpu-occlusion\"],"
|
||||
"\"shadows\":[\"off\",\"on\"]}\n";
|
||||
std::exit(0);
|
||||
}
|
||||
if (name == "--help") {
|
||||
std::cout << "Usage: faset_p3_lighting_benchmark --lights 0|4|16|32|64|128 "
|
||||
"--shadows on|off --visibility direct|gpu-frustum|gpu-occlusion "
|
||||
"--csv PATH [--width N --height N --warmup N --frames N "
|
||||
"--run-index N --commit SHA --driver NAME --validation on|off "
|
||||
"--capture PATH]\n";
|
||||
std::exit(0);
|
||||
}
|
||||
if (i + 1 >= argc)
|
||||
throw std::invalid_argument("Missing value for " + name);
|
||||
const std::string value = argv[++i];
|
||||
if (name == "--lights") options.lights = number(value, name);
|
||||
else if (name == "--width") options.width = number(value, name);
|
||||
else if (name == "--height") options.height = number(value, name);
|
||||
else if (name == "--warmup") options.warmup = number(value, name);
|
||||
else if (name == "--frames") options.frames = number(value, name);
|
||||
else if (name == "--run-index") options.run_index = number(value, name);
|
||||
else if (name == "--commit") options.commit = value;
|
||||
else if (name == "--driver") options.driver = value;
|
||||
else if (name == "--csv") options.csv = faset::path_from_utf8(value);
|
||||
else if (name == "--capture") options.capture = faset::path_from_utf8(value);
|
||||
else if (name == "--shadows") {
|
||||
if (value != "on" && value != "off")
|
||||
throw std::invalid_argument("--shadows must be on or off");
|
||||
options.shadows = value == "on";
|
||||
} else if (name == "--validation") {
|
||||
if (value != "on" && value != "off")
|
||||
throw std::invalid_argument("--validation must be on or off");
|
||||
options.validation = value == "on";
|
||||
} else if (name == "--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 throw std::invalid_argument("Unknown option: " + name);
|
||||
}
|
||||
constexpr std::array allowed_lights{0u, 4u, 16u, 32u, 64u, 128u};
|
||||
if (options.csv.empty() || options.width == 0 || options.height == 0 ||
|
||||
options.frames == 0 || options.warmup > 1000 ||
|
||||
std::find(allowed_lights.begin(), allowed_lights.end(), options.lights) ==
|
||||
allowed_lights.end())
|
||||
throw std::invalid_argument("Invalid benchmark configuration");
|
||||
return options;
|
||||
}
|
||||
|
||||
const char* mode_name(VisibilityMode mode) {
|
||||
switch (mode) {
|
||||
case VisibilityMode::Direct: return "direct";
|
||||
case VisibilityMode::GpuFrustum: return "gpu-frustum";
|
||||
case VisibilityMode::GpuOcclusion: return "gpu-occlusion";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
void csv_text(std::ostream& out, std::string_view value) {
|
||||
out << '"';
|
||||
for (const char c : value) {
|
||||
if (c == '"') out << '"';
|
||||
out << c;
|
||||
}
|
||||
out << '"';
|
||||
}
|
||||
|
||||
Snapshot benchmark_scene(const Options& options) {
|
||||
Snapshot scene;
|
||||
scene.view_id = "p3-lighting-benchmark-fixed-scene";
|
||||
scene.eye = {0, 0, 9};
|
||||
scene.projection = perspective(.9f, float(options.width) / float(options.height), .1f, 100);
|
||||
const auto view = look_at(scene.eye, {0, 0, 0});
|
||||
scene.view_projection = multiply(scene.projection, view);
|
||||
scene.camera_frustum = CameraFrustum{view, scene.projection, .1f, 100, true};
|
||||
scene.authored_lights_present = true;
|
||||
scene.clear_color = {.035f, .04f, .05f, 1};
|
||||
DrawItem receiver;
|
||||
receiver.mesh = cube_mesh();
|
||||
receiver.model = transform({0, 0, -.15f}, {}, {10, 7.5f, .2f});
|
||||
receiver.color = {.65f, .67f, .7f, 1};
|
||||
receiver.roughness = .65f;
|
||||
receiver.cast_shadow = options.shadows;
|
||||
receiver.instance_key = "large-receiver";
|
||||
scene.draws.push_back(receiver);
|
||||
for (int i = 0; i < 9; ++i) {
|
||||
DrawItem object;
|
||||
object.mesh = cube_mesh();
|
||||
object.model = transform({(float(i % 3) - 1.f) * 2.5f,
|
||||
(float(i / 3) - 1.f) * 1.8f, .45f},
|
||||
{}, {.42f, .42f, .6f});
|
||||
object.color = {.6f + .1f * float(i % 3), .5f, .4f + .1f * float(i / 3), 1};
|
||||
object.cast_shadow = options.shadows;
|
||||
object.instance_key = "caster-" + std::to_string(i);
|
||||
scene.draws.push_back(std::move(object));
|
||||
}
|
||||
for (unsigned i = 0; i < options.lights; ++i) {
|
||||
LocalLight light;
|
||||
light.kind = LocalLight::Kind::Point;
|
||||
light.stable_id = "benchmark-light-" + std::to_string(i);
|
||||
light.position = {(float(i % 8) - 3.5f) * 1.35f,
|
||||
(float((i / 8) % 8) - 3.5f) * .95f,
|
||||
2.f + .35f * float(i % 3)};
|
||||
light.color = {.6f + .4f * float(i % 3 == 0),
|
||||
.6f + .4f * float(i % 3 == 1),
|
||||
.6f + .4f * float(i % 3 == 2), 1};
|
||||
light.intensity = 5.f;
|
||||
light.range = 8.f;
|
||||
light.casts_shadow = options.shadows;
|
||||
scene.local_lights.push_back(std::move(light));
|
||||
}
|
||||
return scene;
|
||||
}
|
||||
|
||||
void benchmark(const Options& options) {
|
||||
RendererConfig config;
|
||||
config.width = options.width;
|
||||
config.height = options.height;
|
||||
config.headless = true;
|
||||
config.validation = options.validation;
|
||||
config.visibility_mode = options.visibility;
|
||||
auto renderer = Renderer(config);
|
||||
const auto scene = benchmark_scene(options);
|
||||
for (unsigned i = 0; i < options.warmup; ++i)
|
||||
renderer.render(scene);
|
||||
if (!options.csv.parent_path().empty())
|
||||
fs::create_directories(faset::native_io_path(options.csv.parent_path()));
|
||||
std::ofstream csv(faset::native_io_path(options.csv));
|
||||
if (!csv)
|
||||
throw std::runtime_error("Cannot open benchmark CSV: " + faset::path_to_utf8(options.csv));
|
||||
csv << "light_count,shadows,visibility,effective_visibility,lighting_path,"
|
||||
"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";
|
||||
csv << std::fixed << std::setprecision(6);
|
||||
for (unsigned frame = 0; frame < options.frames; ++frame) {
|
||||
renderer.render(scene);
|
||||
const auto stats = renderer.stats();
|
||||
if (stats.validation_errors != 0)
|
||||
throw std::runtime_error("Vulkan validation error during benchmark");
|
||||
if (stats.submitted_local_lights != options.lights || stats.omitted_local_lights != 0)
|
||||
throw std::runtime_error("Renderer did not submit every requested local light");
|
||||
if (stats.effective_visibility_mode != options.visibility)
|
||||
throw std::runtime_error("Requested visibility path fell back during benchmark");
|
||||
if (stats.gpu_main_raster_ms <= 0 || stats.gpu_ms <= 0)
|
||||
throw std::runtime_error("GPU raster or frame timestamp was unavailable");
|
||||
csv << options.lights << ',' << (options.shadows ? "on" : "off") << ','
|
||||
<< mode_name(options.visibility) << ',' << mode_name(stats.effective_visibility_mode)
|
||||
<< ',' << stats.effective_lighting_path << ',' << FASET_BENCHMARK_CONFIGURATION << ','
|
||||
<< options.run_index << ',' << frame << ',';
|
||||
csv_text(csv, stats.device);
|
||||
csv << ',';
|
||||
csv_text(csv, options.driver);
|
||||
csv << ',';
|
||||
csv_text(csv, options.commit);
|
||||
csv << ',' << options.width << ',' << options.height << ','
|
||||
<< (stats.validation_enabled ? 1 : 0) << ',' << stats.validation_errors << ','
|
||||
<< stats.submitted_local_lights << ',' << stats.omitted_local_lights << ','
|
||||
<< stats.requested_local_shadow_faces << ',' << stats.local_shadow_faces << ','
|
||||
<< stats.dropped_shadow_faces << ',' << stats.shadow_atlas_full_drops << ','
|
||||
<< stats.local_shadow_tiles << ',' << stats.draw_calls << ','
|
||||
<< stats.gpu_allocated_bytes << ','
|
||||
<< stats.gpu_main_raster_ms << ',' << stats.gpu_post_raster_ms << ','
|
||||
<< stats.gpu_post_visible << ',' << (stats.visibility_counters_valid ? 1 : 0)
|
||||
<< ',' << stats.gpu_sun_shadow_ms << ',' << stats.gpu_local_shadow_ms << ','
|
||||
<< (stats.gpu_sun_shadow_ms + stats.gpu_local_shadow_ms) << ',' << stats.gpu_ms << ','
|
||||
<< stats.cpu_ms << ',' << stats.readback_cpu_ms << '\n';
|
||||
}
|
||||
if (!csv)
|
||||
throw std::runtime_error("Cannot finish benchmark CSV: " + faset::path_to_utf8(options.csv));
|
||||
if (!options.capture.empty())
|
||||
renderer.capture(faset::native_io_path(options.capture));
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int benchmark_main(int argc, char** argv) {
|
||||
try {
|
||||
benchmark(parse(argc, argv));
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "P3 lighting benchmark: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
int wmain(int argc, wchar_t** argv) {
|
||||
return faset::run_utf8_main(argc, argv, benchmark_main);
|
||||
}
|
||||
#else
|
||||
int main(int argc, char** argv) {
|
||||
return benchmark_main(argc, argv);
|
||||
}
|
||||
#endif
|
||||
@@ -184,6 +184,14 @@ struct FrameStats {
|
||||
std::uint32_t texture_count{};
|
||||
std::uint32_t vertices{}, draw_calls{}, culled_meshes{}, validation_errors{};
|
||||
std::uint32_t submitted_local_lights{}, omitted_local_lights{};
|
||||
std::uint32_t requested_sun_cascades{}, effective_sun_cascades{};
|
||||
std::uint32_t sun_shadow_caster_draws{};
|
||||
std::uint64_t sun_shadow_atlas_bytes{};
|
||||
std::uint32_t requested_local_shadow_faces{}, local_shadow_faces{}, local_shadow_tiles{};
|
||||
std::uint32_t dropped_shadow_faces{}, dropped_point_shadow_faces{};
|
||||
std::uint32_t shadow_atlas_full_drops{}, shadow_caster_budget_drops{};
|
||||
std::uint32_t shadow_unavailable_drops{}, shadow_caster_draws{};
|
||||
std::uint64_t local_shadow_atlas_bytes{};
|
||||
bool gpu_visibility_active{}, hzb_valid{};
|
||||
// Requested and actual paths for the last frame; actual may be less capable.
|
||||
VisibilityMode requested_visibility_mode{VisibilityMode::Direct};
|
||||
@@ -195,6 +203,8 @@ struct FrameStats {
|
||||
double cpu_ms{}, gpu_ms{}, readback_cpu_ms{};
|
||||
double gpu_main_cull_ms{}, gpu_main_raster_ms{}, gpu_hzb_ms{};
|
||||
double gpu_post_cull_ms{}, gpu_post_raster_ms{};
|
||||
double gpu_sun_shadow_ms{}, gpu_local_shadow_ms{};
|
||||
std::string effective_lighting_path{"forward"};
|
||||
std::string device;
|
||||
};
|
||||
struct HzbDebugImage {
|
||||
|
||||
+81
-11
@@ -27,7 +27,7 @@ struct FrameParameters {
|
||||
// Shared Direct/P2 graphics ABI. The legacy material set remains set 0;
|
||||
// GPU-only instance/visibility records occupy set 2.
|
||||
struct LightingHeader {
|
||||
uint4 counts; // local count, sun enabled, sun shadow enabled, view count
|
||||
uint4 counts; // local count, sun enabled, sun shadow enabled, sun view count
|
||||
float4 sunDirectionIntensity; // xyz world-space ray direction, w intensity
|
||||
float4 sunColor;
|
||||
float4 cameraForwardShadowDistance;
|
||||
@@ -58,6 +58,54 @@ VertexOutput vertexMain(VertexInput v) {
|
||||
}
|
||||
[shader("vertex")]
|
||||
float4 shadowMain(VertexInput v) : SV_Position { return mul(frame.lightViewProjection, float4(v.world,1)); }
|
||||
float sampleSunCascade(uint index, float3 world, float nl) {
|
||||
ShadowViewGpu record = shadowViews[index];
|
||||
if (record.biasFlags.w < 0.5) return 1.0;
|
||||
float4 clip = mul(record.viewProjection, float4(world,1));
|
||||
if (clip.w <= 0.0) return 1.0;
|
||||
float3 projected = clip.xyz / clip.w;
|
||||
float2 localUV = projected.xy * 0.5 + 0.5;
|
||||
if (any(localUV < 0.0) || any(localUV > 1.0) ||
|
||||
projected.z < 0.0 || projected.z > 1.0) return 1.0;
|
||||
float2 atlasUV = localUV * record.tileScaleOffset.xy + record.tileScaleOffset.zw;
|
||||
float bias = max(record.biasFlags.x, record.biasFlags.y * (1.0 - nl));
|
||||
float visible = 0.0;
|
||||
for (int y=-1; y<=1; ++y) for (int x=-1; x<=1; ++x) {
|
||||
float2 tap = clamp(atlasUV + float2(x,y) * record.biasFlags.z,
|
||||
record.guardedClamp.xy, record.guardedClamp.zw);
|
||||
float depth = shadowMap.SampleLevel(shadowSampler, tap, 0);
|
||||
visible += projected.z - bias <= depth ? 1.0 / 9.0 : 0.0;
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
float sampleLocalFace(uint index, float3 world, float nl) {
|
||||
ShadowViewGpu record = shadowViews[index];
|
||||
if (record.biasFlags.w < 0.5) return 1.0;
|
||||
float4 clip = mul(record.viewProjection, float4(world,1));
|
||||
if (clip.w <= 0.0) return 1.0;
|
||||
float3 projected = clip.xyz / clip.w;
|
||||
float2 localUV = projected.xy * 0.5 + 0.5;
|
||||
if (any(localUV < 0.0) || any(localUV > 1.0) ||
|
||||
projected.z < 0.0 || projected.z > 1.0) return 1.0;
|
||||
float2 atlasUV = localUV * record.tileScaleOffset.xy + record.tileScaleOffset.zw;
|
||||
float bias = max(record.biasFlags.x, record.biasFlags.y * (1.0 - nl));
|
||||
float visible = 0.0;
|
||||
for (int y=-1; y<=1; ++y) for (int x=-1; x<=1; ++x) {
|
||||
float2 tap = clamp(atlasUV + float2(x,y) * record.biasFlags.z,
|
||||
record.guardedClamp.xy, record.guardedClamp.zw);
|
||||
float depth = localShadowAtlas.SampleLevel(shadowSampler, tap, 0);
|
||||
visible += projected.z - bias <= depth ? 1.0 / 9.0 : 0.0;
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
uint pointShadowFace(float3 lightToFragment) {
|
||||
float3 magnitude = abs(lightToFragment);
|
||||
if (magnitude.x >= magnitude.y && magnitude.x >= magnitude.z)
|
||||
return lightToFragment.x >= 0.0 ? 0 : 1;
|
||||
if (magnitude.y >= magnitude.z)
|
||||
return lightToFragment.y >= 0.0 ? 2 : 3;
|
||||
return lightToFragment.z >= 0.0 ? 4 : 5;
|
||||
}
|
||||
float3 directBRDF(float3 base, float rough, float metal, float3 n, float3 view, float3 l) {
|
||||
const float pi = 3.14159265;
|
||||
float nl = max(dot(n,l),0.0);
|
||||
@@ -94,15 +142,29 @@ float4 fragmentMain(VertexOutput v) : SV_Target {
|
||||
float3 l=normalize(-lighting.sunDirectionIntensity.xyz);
|
||||
float nl=max(dot(n,l),0.0);
|
||||
float visibility=1.0;
|
||||
if (lighting.counts.z != 0 && nl > 0) {
|
||||
float4 lightClip=mul(frame.lightViewProjection,float4(v.world,1));
|
||||
float3 projected=lightClip.xyz/lightClip.w;
|
||||
float2 uv=projected.xy*.5+.5;
|
||||
if(all(uv>=0.0)&&all(uv<=1.0)&&projected.z>=0.0&&projected.z<=1.0) {
|
||||
visibility=0.0;
|
||||
for(int y=-1;y<=1;++y) for(int x=-1;x<=1;++x) {
|
||||
float depth=shadowMap.SampleLevel(shadowSampler,uv+float2(x,y)/1024.0,0);
|
||||
visibility += projected.z-max(0.0008,0.003*(1.0-nl)) <= depth ? 1.0/9.0 : 0.0;
|
||||
if (lighting.counts.z != 0 && lighting.counts.w != 0 && nl > 0) {
|
||||
if (lighting.counts.w == 1) {
|
||||
visibility = sampleSunCascade(0, v.world, nl);
|
||||
} else {
|
||||
float cameraDepth = dot(v.world - frame.eye.xyz,
|
||||
lighting.cameraForwardShadowDistance.xyz);
|
||||
if (cameraDepth >= 0.0 &&
|
||||
cameraDepth <= lighting.cameraForwardShadowDistance.w) {
|
||||
uint cascade = 0;
|
||||
while (cascade + 1 < lighting.counts.w &&
|
||||
cameraDepth > lighting.cascadeSplits[cascade]) ++cascade;
|
||||
visibility = sampleSunCascade(cascade, v.world, nl);
|
||||
if (cascade + 1 < lighting.counts.w) {
|
||||
float previousSplit = cascade == 0 ? 0.0 :
|
||||
lighting.cascadeSplits[cascade-1];
|
||||
float blendWidth = max(0.2,
|
||||
0.1 * (lighting.cascadeSplits[cascade] - previousSplit));
|
||||
float blend = saturate((cameraDepth -
|
||||
(lighting.cascadeSplits[cascade] - blendWidth)) / blendWidth);
|
||||
if (blend > 0.0)
|
||||
visibility = lerp(visibility,
|
||||
sampleSunCascade(cascade+1, v.world, nl), blend);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,8 +188,16 @@ float4 fragmentMain(VertexOutput v) : SV_Target {
|
||||
float cone=saturate((cosAngle-light.directionCosOuter.w)/denominator);
|
||||
attenuation *= cone*cone*(3.0-2.0*cone);
|
||||
}
|
||||
float visibility = 1.0;
|
||||
float nl = max(dot(n,l), 0.0);
|
||||
if (light.coneTypeShadowView.w > 0.5 && nl > 0.0 && attenuation > 0.0) {
|
||||
uint face = light.coneTypeShadowView.w > 1.5 ?
|
||||
pointShadowFace(v.world - light.positionRange.xyz) : 0;
|
||||
uint viewIndex = uint(light.coneTypeShadowView.z + 0.5) + face;
|
||||
visibility = sampleLocalFace(viewIndex, v.world, nl);
|
||||
}
|
||||
linear += directBRDF(base.rgb, rough, metal, n, view, l) *
|
||||
light.colorIntensity.rgb * (light.colorIntensity.w * attenuation);
|
||||
light.colorIntensity.rgb * (light.colorIntensity.w * attenuation * visibility);
|
||||
}
|
||||
linear=linear/(1.0+linear);
|
||||
return float4(pow(max(linear,0),float3(1.0/2.2)),base.a);
|
||||
|
||||
@@ -381,6 +381,27 @@ void DebugOverlay::append(render::Snapshot& output, render::Renderer& renderer,
|
||||
ImGui::Text("Prepared LOD: %u / %u / %u / %u+", stats.lod_counts[0],
|
||||
stats.lod_counts[1], stats.lod_counts[2], stats.lod_counts[3]);
|
||||
ImGui::Separator();
|
||||
ImGui::TextUnformatted("Lighting and shadows");
|
||||
ImGui::Text("Lighting path: %s", stats.effective_lighting_path.c_str());
|
||||
ImGui::Text("Local lights: %u submitted, %u omitted",
|
||||
stats.submitted_local_lights, stats.omitted_local_lights);
|
||||
ImGui::Text("Sun cascades: %u / %u effective",
|
||||
stats.requested_sun_cascades, stats.effective_sun_cascades);
|
||||
ImGui::Text("Local faces: %u requested, %u rasterized (%u tiles)",
|
||||
stats.requested_local_shadow_faces, stats.local_shadow_faces,
|
||||
stats.local_shadow_tiles);
|
||||
ImGui::Text("Dropped faces: %u (point %u, atlas %u, draw budget %u, unavailable %u)",
|
||||
stats.dropped_shadow_faces, stats.dropped_point_shadow_faces,
|
||||
stats.shadow_atlas_full_drops, stats.shadow_caster_budget_drops,
|
||||
stats.shadow_unavailable_drops);
|
||||
ImGui::Text("Shadow caster draws: %u / 4096", stats.shadow_caster_draws);
|
||||
ImGui::Text("Atlas memory: sun %.1f MiB, local %.1f MiB",
|
||||
double(stats.sun_shadow_atlas_bytes) / 1048576.0,
|
||||
double(stats.local_shadow_atlas_bytes) / 1048576.0);
|
||||
if (stats.gpu_ms > 0)
|
||||
ImGui::Text("Shadow GPU: sun %.2f ms, local %.2f ms",
|
||||
stats.gpu_sun_shadow_ms, stats.gpu_local_shadow_ms);
|
||||
ImGui::Separator();
|
||||
ImGui::Text("Vulkan allocations: %.2f MiB",
|
||||
double(stats.gpu_allocated_bytes) / 1048576.0);
|
||||
ImGui::Text("Validation: %s Errors: %u",
|
||||
|
||||
@@ -240,8 +240,10 @@ ShadowView local_view(const LocalLight& light, std::uint32_t face,
|
||||
const auto up = light.kind == LocalLight::Kind::Point ? ups.at(face)
|
||||
: std::abs(direction[1]) > .98f ? Vec3{0, 0, 1} : Vec3{0, 1, 0};
|
||||
const auto near_plane = std::max(.0001f, std::min(.05f, light.range * .1f));
|
||||
// Slight face overlap keeps the dominant-axis choice inside both adjacent
|
||||
// projections at a cubemap seam; the guarded tile still prevents PCF bleed.
|
||||
const auto fov = light.kind == LocalLight::Kind::Point
|
||||
? std::numbers::pi_v<float> / 2 : light.outer_angle * 2;
|
||||
? std::numbers::pi_v<float> / 2 + .04f : light.outer_angle * 2;
|
||||
result.view_projection = multiply(
|
||||
perspective(fov, 1, near_plane, light.range),
|
||||
look_at(light.position, add(light.position, direction), up));
|
||||
|
||||
+275
-61
@@ -201,7 +201,7 @@ struct SceneResources {
|
||||
std::array<float, 4> previous_viewport{};
|
||||
std::string previous_view_id;
|
||||
};
|
||||
constexpr std::uint32_t shadow_size = 1024;
|
||||
constexpr std::uint32_t timestamp_capacity = 24;
|
||||
} // namespace
|
||||
struct Renderer::Impl {
|
||||
RendererConfig config;
|
||||
@@ -231,7 +231,8 @@ struct Renderer::Impl {
|
||||
VkExtent2D swap_extent{};
|
||||
std::vector<VkImage> swap_images;
|
||||
std::vector<VkImageLayout> swap_layouts;
|
||||
Image color, depth, shadow;
|
||||
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;
|
||||
SceneResources scene;
|
||||
@@ -363,6 +364,7 @@ struct Renderer::Impl {
|
||||
destroy(color);
|
||||
destroy(depth);
|
||||
destroy(shadow);
|
||||
destroy(local_shadow);
|
||||
if (device) {
|
||||
destroy_scene_interfaces();
|
||||
if (pipeline)
|
||||
@@ -769,14 +771,39 @@ struct Renderer::Impl {
|
||||
VkQueryPoolCreateInfo query{};
|
||||
query.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO;
|
||||
query.queryType = VK_QUERY_TYPE_TIMESTAMP;
|
||||
query.queryCount = 12;
|
||||
query.queryCount = timestamp_capacity;
|
||||
check(vkCreateQueryPool(device, &query, nullptr, ×tamp_pool),
|
||||
"Create GPU timestamp queries");
|
||||
}
|
||||
shadow =
|
||||
make_image(shadow_size, shadow_size, VK_FORMAT_D32_SFLOAT,
|
||||
VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT,
|
||||
VK_IMAGE_ASPECT_DEPTH_BIT);
|
||||
const auto shadow_usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT |
|
||||
VK_IMAGE_USAGE_SAMPLED_BIT;
|
||||
for (const auto size : {2048u, 1024u}) {
|
||||
if (size > max_image_dimension)
|
||||
continue;
|
||||
try {
|
||||
shadow = make_image(size, size, VK_FORMAT_D32_SFLOAT, shadow_usage,
|
||||
VK_IMAGE_ASPECT_DEPTH_BIT);
|
||||
sun_shadow_size = size;
|
||||
break;
|
||||
} catch (const std::exception&) {
|
||||
// Optional atlas allocation may fail; try the bounded half-size profile.
|
||||
}
|
||||
}
|
||||
if (!shadow.handle)
|
||||
shadow = make_image(1, 1, VK_FORMAT_D32_SFLOAT, shadow_usage,
|
||||
VK_IMAGE_ASPECT_DEPTH_BIT);
|
||||
for (const auto size : {2048u, 1024u}) {
|
||||
if (size > max_image_dimension)
|
||||
continue;
|
||||
try {
|
||||
local_shadow = make_image(size, size, VK_FORMAT_D32_SFLOAT,
|
||||
shadow_usage, VK_IMAGE_ASPECT_DEPTH_BIT);
|
||||
local_shadow_size = size;
|
||||
break;
|
||||
} catch (const std::exception&) {
|
||||
// Local shadows are optional; all affected lights remain unshadowed.
|
||||
}
|
||||
}
|
||||
make_targets();
|
||||
make_descriptors();
|
||||
make_pipelines();
|
||||
@@ -1612,7 +1639,8 @@ struct Renderer::Impl {
|
||||
{lighting_header.handle, 0, lighting_header.size},
|
||||
{lighting_locals.handle, 0, lighting_locals.size},
|
||||
{lighting_views.handle, 0, lighting_views.size}}};
|
||||
const VkDescriptorImageInfo atlas{VK_NULL_HANDLE, shadow.view,
|
||||
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{};
|
||||
for (std::uint32_t i = 0; i < writes.size(); ++i) {
|
||||
@@ -1723,6 +1751,21 @@ struct Renderer::Impl {
|
||||
auto start = std::chrono::steady_clock::now();
|
||||
statistics.draw_calls = statistics.culled_meshes = statistics.gpu_label_count = 0;
|
||||
statistics.submitted_local_lights = statistics.omitted_local_lights = 0;
|
||||
statistics.requested_sun_cascades = statistics.effective_sun_cascades =
|
||||
statistics.sun_shadow_caster_draws = 0;
|
||||
statistics.sun_shadow_atlas_bytes = sun_shadow_size ? shadow.allocation_size : 0;
|
||||
statistics.gpu_sun_shadow_ms = 0;
|
||||
statistics.requested_local_shadow_faces = statistics.local_shadow_faces =
|
||||
statistics.local_shadow_tiles = statistics.dropped_shadow_faces =
|
||||
statistics.dropped_point_shadow_faces =
|
||||
statistics.shadow_atlas_full_drops =
|
||||
statistics.shadow_caster_budget_drops =
|
||||
statistics.shadow_unavailable_drops =
|
||||
statistics.shadow_caster_draws = 0;
|
||||
statistics.local_shadow_atlas_bytes = local_shadow_size
|
||||
? local_shadow.allocation_size : 0;
|
||||
statistics.gpu_local_shadow_ms = 0;
|
||||
statistics.effective_lighting_path = "forward";
|
||||
statistics.gpu_bins = statistics.gpu_visible_instances =
|
||||
statistics.gpu_frustum_rejected = statistics.gpu_occlusion_deferred =
|
||||
statistics.gpu_post_visible = 0;
|
||||
@@ -1965,12 +2008,63 @@ struct Renderer::Impl {
|
||||
gpu_frame.textures.push_back(bin.texture);
|
||||
}
|
||||
gpu_frame.candidate_count = static_cast<std::uint32_t>(gpu_frame.candidates.size());
|
||||
ShadowBudget shadow_budget;
|
||||
shadow_budget.sun_atlas_size = sun_shadow_size;
|
||||
shadow_budget.sun_atlas_available = sun_shadow_size != 0;
|
||||
shadow_budget.local_atlas_size = local_shadow_size;
|
||||
shadow_budget.local_atlas_available = local_shadow_size != 0;
|
||||
const auto shadow_plan = build_shadow_plan(snapshot, shadow_casters, shadow_budget);
|
||||
const bool sun_raster = sun_shadow_size &&
|
||||
std::any_of(shadow_plan.sun_views.begin(), shadow_plan.sun_views.end(),
|
||||
[](const ShadowView& view) {
|
||||
return view.valid && !view.caster_indices.empty();
|
||||
});
|
||||
statistics.requested_sun_cascades = shadow_plan.requested_sun_cascades;
|
||||
statistics.effective_sun_cascades = sun_raster
|
||||
? shadow_plan.effective_sun_cascades : 0;
|
||||
if (sun_raster)
|
||||
for (const auto& view : shadow_plan.sun_views)
|
||||
if (view.valid)
|
||||
statistics.sun_shadow_caster_draws +=
|
||||
static_cast<std::uint32_t>(view.caster_indices.size());
|
||||
const bool local_raster = local_shadow_size &&
|
||||
std::any_of(shadow_plan.local_views.begin(), shadow_plan.local_views.end(),
|
||||
[](const ShadowView& view) {
|
||||
return view.valid && !view.caster_indices.empty();
|
||||
});
|
||||
statistics.requested_local_shadow_faces = shadow_plan.local_faces_requested;
|
||||
statistics.local_shadow_tiles = shadow_plan.local_faces_used;
|
||||
statistics.local_shadow_faces = local_raster ? shadow_plan.local_faces_used : 0;
|
||||
statistics.dropped_shadow_faces = shadow_plan.dropped_local_faces;
|
||||
statistics.dropped_point_shadow_faces = shadow_plan.dropped_point_faces;
|
||||
for (const auto& assignment : shadow_plan.local_assignments) {
|
||||
if (assignment.valid || assignment.reason == ShadowDropReason::None)
|
||||
continue;
|
||||
const auto& light = snapshot.local_lights[assignment.source_index];
|
||||
const auto faces = light.kind == LocalLight::Kind::Point ? 6u : 1u;
|
||||
if (assignment.reason == ShadowDropReason::TileBudget)
|
||||
statistics.shadow_atlas_full_drops += faces;
|
||||
else if (assignment.reason == ShadowDropReason::CasterBudget)
|
||||
statistics.shadow_caster_budget_drops += faces;
|
||||
else if (assignment.reason == ShadowDropReason::Unavailable)
|
||||
statistics.shadow_unavailable_drops += faces;
|
||||
}
|
||||
for (const auto& view : shadow_plan.sun_views) {
|
||||
if (view.reason == ShadowDropReason::CasterBudget)
|
||||
++statistics.shadow_caster_budget_drops;
|
||||
else if (view.reason == ShadowDropReason::Unavailable)
|
||||
++statistics.shadow_unavailable_drops;
|
||||
}
|
||||
statistics.shadow_caster_draws = statistics.sun_shadow_caster_draws;
|
||||
if (local_raster)
|
||||
for (const auto& view : shadow_plan.local_views)
|
||||
statistics.shadow_caster_draws +=
|
||||
static_cast<std::uint32_t>(view.caster_indices.size());
|
||||
std::vector<GpuVertex> data;
|
||||
std::vector<Batch> scene_batches, transparent_batches, shadow_batches,
|
||||
sprite_batches, ui_batches;
|
||||
std::vector<Batch> scene_batches, transparent_batches, sprite_batches, ui_batches;
|
||||
for (const auto& selected : selected_draws) {
|
||||
const auto& item = *selected.source;
|
||||
if (selected.gpu && !item.cast_shadow)
|
||||
if (selected.gpu)
|
||||
continue;
|
||||
auto first = data.size();
|
||||
const auto& mesh = *selected.mesh;
|
||||
@@ -1993,16 +2087,12 @@ struct Renderer::Impl {
|
||||
continue;
|
||||
Batch batch{static_cast<std::uint32_t>(first), count,
|
||||
item.texture ? item.texture.get() : white.get()};
|
||||
if (item.cast_shadow)
|
||||
shadow_batches.push_back(batch);
|
||||
if (!selected.gpu) {
|
||||
if (outside(data, first))
|
||||
++statistics.culled_meshes;
|
||||
else if (gpu_active && !selected.opaque)
|
||||
transparent_batches.push_back(batch);
|
||||
else
|
||||
scene_batches.push_back(batch);
|
||||
}
|
||||
if (outside(data, first))
|
||||
++statistics.culled_meshes;
|
||||
else if (gpu_active && !selected.opaque)
|
||||
transparent_batches.push_back(batch);
|
||||
else
|
||||
scene_batches.push_back(batch);
|
||||
}
|
||||
struct OrderedSprite {
|
||||
const Sprite* sprite;
|
||||
@@ -2079,6 +2169,45 @@ struct Renderer::Impl {
|
||||
triangles.texture ? triangles.texture.get() : white.get(),
|
||||
triangles.clip_rect});
|
||||
}
|
||||
std::vector<Batch> shadow_batch_by_source(snapshot.draws.size());
|
||||
if (sun_raster || local_raster) {
|
||||
std::vector<std::uint8_t> required(snapshot.draws.size());
|
||||
for (const auto& view : shadow_plan.sun_views)
|
||||
if (sun_raster && view.valid)
|
||||
for (const auto source : view.caster_indices)
|
||||
required.at(source) = 1;
|
||||
for (const auto& view : shadow_plan.local_views)
|
||||
if (local_raster && view.valid)
|
||||
for (const auto source : view.caster_indices)
|
||||
required.at(source) = 1;
|
||||
for (std::size_t source = 0; source < required.size(); ++source) {
|
||||
if (!required[source])
|
||||
continue;
|
||||
const auto& item = snapshot.draws[source];
|
||||
if (!item.mesh)
|
||||
continue;
|
||||
const auto first = data.size();
|
||||
const auto& mesh = *item.mesh; // Source LOD 0, independent of camera/P2 LOD.
|
||||
auto emit = [&](std::uint32_t index) {
|
||||
if (index >= mesh.vertices.size())
|
||||
throw std::out_of_range("Shadow mesh index outside vertex range");
|
||||
data.push_back(gpu_vertex(mesh.vertices[index], item,
|
||||
snapshot.view_projection));
|
||||
};
|
||||
if (mesh.indices.empty())
|
||||
for (std::uint32_t i = 0; i < mesh.vertices.size(); ++i)
|
||||
emit(i);
|
||||
else
|
||||
for (const auto index : mesh.indices)
|
||||
emit(index);
|
||||
const auto count = data.size() - first;
|
||||
if (count % 3 || first > UINT32_MAX || count > UINT32_MAX)
|
||||
throw std::invalid_argument("Shadow mesh must fit complete triangles");
|
||||
shadow_batch_by_source[source] =
|
||||
{static_cast<std::uint32_t>(first), static_cast<std::uint32_t>(count),
|
||||
white.get()};
|
||||
}
|
||||
}
|
||||
statistics.vertices = static_cast<std::uint32_t>(data.size() + gpu_frame.vertices.size());
|
||||
auto byte_count = std::max<std::size_t>(sizeof(GpuVertex), data.size() * sizeof(GpuVertex));
|
||||
if (vertices.size < byte_count) {
|
||||
@@ -2140,7 +2269,7 @@ struct Renderer::Impl {
|
||||
v /= length;
|
||||
LightingHeaderGpu lighting{};
|
||||
lighting.counts[1] = sun ? 1u : 0u;
|
||||
lighting.counts[2] = sun && sun->casts_shadow ? 1u : 0u;
|
||||
lighting.counts[2] = sun_raster ? 1u : 0u;
|
||||
lighting.sun_direction_intensity = {direction[0], direction[1], direction[2],
|
||||
sun ? sun->intensity : 0};
|
||||
lighting.sun_color = sun ? sun->color : Color{0, 0, 0, 1};
|
||||
@@ -2153,7 +2282,35 @@ struct Renderer::Impl {
|
||||
const auto& view = snapshot.camera_frustum->view;
|
||||
lighting.camera_forward_shadow_distance = {-view[2], -view[6], -view[10], 80};
|
||||
}
|
||||
const auto shadow_plan = build_shadow_plan(snapshot, shadow_casters);
|
||||
lighting.counts[3] = static_cast<std::uint32_t>(shadow_plan.sun_views.size());
|
||||
std::vector<ShadowViewGpu> gpu_shadow_views;
|
||||
gpu_shadow_views.reserve(std::max<std::size_t>(1, shadow_plan.sun_views.size()));
|
||||
for (std::size_t i = 0; i < shadow_plan.sun_views.size(); ++i) {
|
||||
const auto& view = shadow_plan.sun_views[i];
|
||||
ShadowViewGpu gpu{};
|
||||
gpu.view_projection = view.view_projection;
|
||||
gpu.tile_scale_offset = view.atlas_scale_offset;
|
||||
gpu.guarded_clamp = view.guarded_clamp;
|
||||
gpu.bias_flags = {.0008f, .003f,
|
||||
sun_shadow_size ? 1.f / float(sun_shadow_size) : 0.f,
|
||||
sun_raster && view.valid ? 1.f : 0.f};
|
||||
gpu_shadow_views.push_back(gpu);
|
||||
if (i < lighting.cascade_splits.size())
|
||||
lighting.cascade_splits[i] = view.split_far;
|
||||
}
|
||||
for (const auto& view : shadow_plan.local_views) {
|
||||
ShadowViewGpu gpu{};
|
||||
gpu.view_projection = view.view_projection;
|
||||
gpu.tile_scale_offset = view.atlas_scale_offset;
|
||||
gpu.guarded_clamp = view.guarded_clamp;
|
||||
gpu.bias_flags = {.0008f, .003f,
|
||||
local_shadow_size ? 1.f / float(local_shadow_size) : 0.f,
|
||||
local_raster && view.valid ? 1.f : 0.f};
|
||||
gpu_shadow_views.push_back(gpu);
|
||||
}
|
||||
std::vector<const LocalShadowAssignment*> assignments(snapshot.local_lights.size());
|
||||
for (const auto& assignment : shadow_plan.local_assignments)
|
||||
assignments.at(assignment.source_index) = &assignment;
|
||||
statistics.omitted_local_lights = shadow_plan.omitted_local_lights;
|
||||
std::vector<LocalLightGpu> gpu_lights;
|
||||
gpu_lights.reserve(shadow_plan.submitted_local_indices.size());
|
||||
@@ -2180,21 +2337,30 @@ struct Renderer::Impl {
|
||||
local.intensity};
|
||||
gpu.cone_type_shadow_view = {spot ? std::cos(local.inner_angle) : 1.f,
|
||||
spot ? 1.f : 0.f, -1, 0};
|
||||
const auto* assignment = assignments.at(source);
|
||||
if (local_raster && assignment && assignment->valid) {
|
||||
gpu.cone_type_shadow_view[2] = float(
|
||||
shadow_plan.sun_views.size() + assignment->first_view);
|
||||
gpu.cone_type_shadow_view[3] = float(assignment->face_count);
|
||||
}
|
||||
gpu_lights.push_back(gpu);
|
||||
}
|
||||
lighting.counts[0] = static_cast<std::uint32_t>(gpu_lights.size());
|
||||
statistics.submitted_local_lights = lighting.counts[0];
|
||||
if (gpu_lights.empty())
|
||||
gpu_lights.push_back({}); // Descriptors always point at a full initialized record.
|
||||
const ShadowViewGpu empty_shadow_view{};
|
||||
if (gpu_shadow_views.empty())
|
||||
gpu_shadow_views.push_back({}); // Always bind an initialized record.
|
||||
upload_scene_buffer(lighting_header, &lighting, sizeof(lighting), 0);
|
||||
upload_scene_vector(lighting_locals, gpu_lights);
|
||||
upload_scene_buffer(lighting_views, &empty_shadow_view, sizeof(empty_shadow_view), 0);
|
||||
upload_scene_vector(lighting_views, gpu_shadow_views);
|
||||
update_lighting_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{multiply(orthographic(-20, 20, -20, 20, .1f, 80),
|
||||
look_at(light_eye, {0, 0, 0}, light_up)),
|
||||
Push push{sun_raster && !shadow_plan.sun_views.empty()
|
||||
? shadow_plan.sun_views.front().view_projection
|
||||
: multiply(orthographic(-20, 20, -20, 20, .1f, 80),
|
||||
look_at(light_eye, {0, 0, 0}, light_up)),
|
||||
{direction[0], direction[1], direction[2], 0},
|
||||
{snapshot.eye[0], snapshot.eye[1], snapshot.eye[2], 1}};
|
||||
std::optional<std::uint32_t> swap_index;
|
||||
@@ -2218,7 +2384,7 @@ struct Renderer::Impl {
|
||||
std::uint32_t timestamp_cursor = 0;
|
||||
std::vector<std::string> timestamp_labels;
|
||||
if (timestamp_pool) {
|
||||
vkCmdResetQueryPool(command, timestamp_pool, 0, 12);
|
||||
vkCmdResetQueryPool(command, timestamp_pool, 0, timestamp_capacity);
|
||||
vkCmdWriteTimestamp2(command, VK_PIPELINE_STAGE_2_TOP_OF_PIPE_BIT,
|
||||
timestamp_pool, timestamp_cursor++);
|
||||
}
|
||||
@@ -2349,7 +2515,7 @@ struct Renderer::Impl {
|
||||
}
|
||||
} end{*this};
|
||||
callback();
|
||||
if (timestamp_pool && timestamp_cursor < 12) {
|
||||
if (timestamp_pool && timestamp_cursor < timestamp_capacity) {
|
||||
vkCmdWriteTimestamp2(command,
|
||||
VK_PIPELINE_STAGE_2_BOTTOM_OF_PIPE_BIT,
|
||||
timestamp_pool, timestamp_cursor++);
|
||||
@@ -2357,35 +2523,79 @@ struct Renderer::Impl {
|
||||
}
|
||||
});
|
||||
};
|
||||
add_pass("ShadowMap", {}, {"shadow"}, [&] {
|
||||
transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL,
|
||||
VK_IMAGE_ASPECT_DEPTH_BIT);
|
||||
VkRenderingAttachmentInfo attachment{};
|
||||
attachment.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO;
|
||||
attachment.imageView = shadow.view;
|
||||
attachment.imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL;
|
||||
attachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
|
||||
attachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
|
||||
attachment.clearValue.depthStencil = {1, 0};
|
||||
VkRenderingInfo rendering{};
|
||||
rendering.sType = VK_STRUCTURE_TYPE_RENDERING_INFO;
|
||||
rendering.renderArea = {{0, 0}, {shadow_size, shadow_size}};
|
||||
rendering.layerCount = 1;
|
||||
rendering.pDepthAttachment = &attachment;
|
||||
vkCmdBeginRendering(command, &rendering);
|
||||
set_viewport(shadow_size, shadow_size);
|
||||
vkCmdBindPipeline(command, VK_PIPELINE_BIND_POINT_GRAPHICS, shadow_pipeline);
|
||||
vkCmdPushConstants(command, pipeline_layout,
|
||||
VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0,
|
||||
sizeof(push), &push);
|
||||
for (auto batch : shadow_batches) {
|
||||
vkCmdDraw(command, batch.count, 1, batch.first, 0);
|
||||
++statistics.draw_calls;
|
||||
}
|
||||
vkCmdEndRendering(command);
|
||||
transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL,
|
||||
VK_IMAGE_ASPECT_DEPTH_BIT);
|
||||
});
|
||||
auto raster_shadow_atlas = [&](Image& atlas, std::uint32_t atlas_size,
|
||||
const std::vector<ShadowView>& views) {
|
||||
transition(command, atlas, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL,
|
||||
VK_IMAGE_ASPECT_DEPTH_BIT);
|
||||
VkRenderingAttachmentInfo attachment{};
|
||||
attachment.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO;
|
||||
attachment.imageView = atlas.view;
|
||||
attachment.imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL;
|
||||
attachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
|
||||
attachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
|
||||
attachment.clearValue.depthStencil = {1, 0};
|
||||
VkRenderingInfo rendering{};
|
||||
rendering.sType = VK_STRUCTURE_TYPE_RENDERING_INFO;
|
||||
rendering.renderArea = {{0, 0}, {atlas_size, atlas_size}};
|
||||
rendering.layerCount = 1;
|
||||
rendering.pDepthAttachment = &attachment;
|
||||
vkCmdBeginRendering(command, &rendering);
|
||||
vkCmdBindPipeline(command, VK_PIPELINE_BIND_POINT_GRAPHICS,
|
||||
shadow_pipeline);
|
||||
for (const auto& view : views) {
|
||||
if (!view.valid || view.caster_indices.empty())
|
||||
continue;
|
||||
const auto guard = (view.tile_size - view.usable_size) / 2;
|
||||
const auto x = view.tile_origin_x + guard;
|
||||
const auto y = view.tile_origin_y + guard;
|
||||
const VkViewport viewport{float(x), float(y), float(view.usable_size),
|
||||
float(view.usable_size), 0, 1};
|
||||
const VkRect2D scissor{{static_cast<std::int32_t>(x),
|
||||
static_cast<std::int32_t>(y)},
|
||||
{view.usable_size, view.usable_size}};
|
||||
vkCmdSetViewport(command, 0, 1, &viewport);
|
||||
vkCmdSetScissor(command, 0, 1, &scissor);
|
||||
auto view_push = push;
|
||||
view_push.light_view_projection = view.view_projection;
|
||||
vkCmdPushConstants(command, pipeline_layout,
|
||||
VK_SHADER_STAGE_VERTEX_BIT |
|
||||
VK_SHADER_STAGE_FRAGMENT_BIT,
|
||||
0, sizeof(view_push), &view_push);
|
||||
for (const auto source : view.caster_indices) {
|
||||
const auto& batch = shadow_batch_by_source.at(source);
|
||||
if (!batch.count)
|
||||
continue;
|
||||
vkCmdDraw(command, batch.count, 1, batch.first, 0);
|
||||
++statistics.draw_calls;
|
||||
}
|
||||
}
|
||||
vkCmdEndRendering(command);
|
||||
transition(command, atlas, VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL,
|
||||
VK_IMAGE_ASPECT_DEPTH_BIT);
|
||||
};
|
||||
if (sun_raster)
|
||||
add_pass("SunShadowAtlas", {}, {"shadow"}, [&] {
|
||||
raster_shadow_atlas(shadow, sun_shadow_size, shadow_plan.sun_views);
|
||||
});
|
||||
else
|
||||
add_pass("ShadowFallback", {}, {"shadow"}, [&] {
|
||||
// A bound descriptor still needs a matching image layout, even when
|
||||
// every graphics shader branch treats its shadow as unshadowed.
|
||||
transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL,
|
||||
VK_IMAGE_ASPECT_DEPTH_BIT);
|
||||
});
|
||||
if (local_raster)
|
||||
add_pass("LocalShadowAtlas", {}, {"local_shadow"}, [&] {
|
||||
raster_shadow_atlas(local_shadow, local_shadow_size,
|
||||
shadow_plan.local_views);
|
||||
});
|
||||
else
|
||||
add_pass("LocalShadowFallback", {}, {"local_shadow"}, [&] {
|
||||
if (local_shadow.handle)
|
||||
transition(command, local_shadow,
|
||||
VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL,
|
||||
VK_IMAGE_ASPECT_DEPTH_BIT);
|
||||
});
|
||||
if (gpu_active)
|
||||
add_pass("MainCull", {"shadow"},
|
||||
{"main_indirect", "main_visible", "deferred_ids"}, [&] {
|
||||
@@ -2445,8 +2655,9 @@ struct Renderer::Impl {
|
||||
}
|
||||
});
|
||||
add_pass(occlusion ? "MainRaster" : "ForwardAndUI",
|
||||
gpu_active ? std::vector<std::string>{"shadow", "main_indirect", "main_visible"}
|
||||
: std::vector<std::string>{"shadow"},
|
||||
gpu_active ? std::vector<std::string>{"shadow", "local_shadow",
|
||||
"main_indirect", "main_visible"}
|
||||
: std::vector<std::string>{"shadow", "local_shadow"},
|
||||
{"color", "depth"}, [&] {
|
||||
transition(command, color, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
|
||||
VK_IMAGE_ASPECT_COLOR_BIT);
|
||||
@@ -2692,7 +2903,7 @@ struct Renderer::Impl {
|
||||
graph.execute();
|
||||
submit(swap_index.has_value());
|
||||
if (timestamp_pool) {
|
||||
std::array<std::uint64_t, 12> stamps{};
|
||||
std::array<std::uint64_t, timestamp_capacity> stamps{};
|
||||
check(vkGetQueryPoolResults(device, timestamp_pool, 0, timestamp_cursor,
|
||||
timestamp_cursor * sizeof(std::uint64_t), stamps.data(),
|
||||
sizeof(std::uint64_t),
|
||||
@@ -2712,6 +2923,8 @@ struct Renderer::Impl {
|
||||
const auto elapsed = milliseconds(stamps[i], stamps[i + 1]);
|
||||
const auto& label = timestamp_labels[i];
|
||||
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 == "MainRaster" || label == "ForwardAndUI")
|
||||
statistics.gpu_main_raster_ms = elapsed;
|
||||
else if (label == "BuildCurrentHZB") statistics.gpu_hzb_ms = elapsed;
|
||||
@@ -2785,6 +2998,7 @@ struct Renderer::Impl {
|
||||
statistics.gpu_allocated_bytes = vertices.allocation_size + readback.allocation_size +
|
||||
color.allocation_size + depth.allocation_size +
|
||||
shadow.allocation_size +
|
||||
local_shadow.allocation_size +
|
||||
lighting_header.allocation_size +
|
||||
lighting_locals.allocation_size +
|
||||
lighting_views.allocation_size;
|
||||
|
||||
@@ -29,6 +29,21 @@ with tempfile.TemporaryDirectory(prefix="faset-player-diagnostics-") as temporar
|
||||
report = json.loads(profile.read_text(encoding="utf-8"))
|
||||
assert report["completed_frames"] == 1 and len(report["samples"]) == 1, report
|
||||
assert report["samples"][0]["tick"] == 1, report["samples"]
|
||||
lighting = report["samples"][0]
|
||||
assert lighting["effective_lighting_path"] == "forward", lighting
|
||||
for field in ["submitted_local_lights", "omitted_local_lights",
|
||||
"requested_sun_cascades", "effective_sun_cascades",
|
||||
"requested_local_shadow_faces", "local_shadow_faces",
|
||||
"local_shadow_tiles", "dropped_shadow_faces",
|
||||
"dropped_point_shadow_faces", "shadow_atlas_full_drops",
|
||||
"shadow_caster_budget_drops", "shadow_unavailable_drops",
|
||||
"shadow_caster_draws", "sun_shadow_atlas_bytes",
|
||||
"local_shadow_atlas_bytes", "gpu_main_raster_ms",
|
||||
"gpu_sun_shadow_ms", "gpu_local_shadow_ms"]:
|
||||
assert field in lighting, (field, lighting)
|
||||
assert lighting["submitted_local_lights"] == 0 and \
|
||||
lighting["effective_sun_cascades"] == 0 and \
|
||||
lighting["local_shadow_faces"] == 0, lighting
|
||||
|
||||
# The same linked v2 schema must validate without registering or invoking behavior.
|
||||
validated = subprocess.run([sys.argv[1], "--scene", str(scene), "--validate"],
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
#include <faset/render/renderer.hpp>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace faset::render;
|
||||
namespace {
|
||||
void require(bool condition, const std::string& message) {
|
||||
if (!condition)
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
struct Frame {
|
||||
std::vector<std::uint8_t> pixels;
|
||||
FrameStats stats;
|
||||
};
|
||||
Frame capture(Renderer& renderer, const Snapshot& scene) {
|
||||
renderer.render(scene);
|
||||
return {renderer.pixels(), renderer.stats()};
|
||||
}
|
||||
Renderer make_renderer(VisibilityMode mode) {
|
||||
RendererConfig config;
|
||||
config.width = 320;
|
||||
config.height = 240;
|
||||
config.headless = true;
|
||||
config.validation = true;
|
||||
config.visibility_mode = mode;
|
||||
config.visibility_diagnostics = true;
|
||||
return Renderer(config);
|
||||
}
|
||||
void compare_frames(const Frame& direct, const Frame& gpu) {
|
||||
require(direct.pixels.size() == gpu.pixels.size(), "Lighting image dimensions match");
|
||||
std::uint64_t error{};
|
||||
std::size_t bad{};
|
||||
for (std::size_t i = 0; i < direct.pixels.size(); i += 4) {
|
||||
int worst{};
|
||||
for (int channel = 0; channel < 3; ++channel) {
|
||||
const int difference = std::abs(int(direct.pixels[i + channel]) -
|
||||
int(gpu.pixels[i + channel]));
|
||||
error += difference;
|
||||
worst = std::max(worst, difference);
|
||||
}
|
||||
bad += worst > 16;
|
||||
}
|
||||
const auto count = direct.pixels.size() / 4;
|
||||
require(bad <= std::max<std::size_t>(24, count / 200) &&
|
||||
double(error) / double(count * 3) <= 2.0,
|
||||
"Direct and GPU sun lighting images agree (bad=" + std::to_string(bad) +
|
||||
", mean=" + std::to_string(double(error) / double(count * 3)) + ")");
|
||||
}
|
||||
Snapshot scene(bool caster) {
|
||||
Snapshot result;
|
||||
result.view_id = "p3-offscreen-sun";
|
||||
result.eye = {0, 5, 8};
|
||||
const auto view = look_at(result.eye, {0, -1, 0});
|
||||
const auto projection = orthographic(-2.5f, 2.5f, -2, 2, .1f, 50);
|
||||
result.projection = projection;
|
||||
result.view_projection = multiply(projection, view);
|
||||
result.camera_frustum = CameraFrustum{view, projection, .1f, 50.f, false};
|
||||
DrawItem receiver;
|
||||
receiver.mesh = cube_mesh();
|
||||
receiver.model = transform({0, -1, 0}, {}, {8, .1f, 8});
|
||||
receiver.color = {.8f, .8f, .8f, 1};
|
||||
receiver.instance_key = "receiver";
|
||||
result.draws.push_back(receiver);
|
||||
if (caster) {
|
||||
DrawItem shadow_caster;
|
||||
shadow_caster.mesh = cube_mesh();
|
||||
shadow_caster.model = transform({3, 1, 0}, {}, {.8f, .8f, .8f});
|
||||
shadow_caster.color = {.2f, .2f, .8f, 1};
|
||||
shadow_caster.instance_key = "offscreen-caster";
|
||||
result.draws.push_back(shadow_caster);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
void sun() {
|
||||
auto direct = make_renderer(VisibilityMode::Direct);
|
||||
auto gpu = make_renderer(VisibilityMode::GpuFrustum);
|
||||
auto occlusion = make_renderer(VisibilityMode::GpuOcclusion);
|
||||
auto with_caster = scene(true);
|
||||
const auto direct_frame = capture(direct, with_caster);
|
||||
const auto gpu_frame = capture(gpu, with_caster);
|
||||
const auto occlusion_frame = capture(occlusion, with_caster);
|
||||
require(direct_frame.stats.effective_sun_cascades == 4 &&
|
||||
gpu_frame.stats.effective_sun_cascades == 4 &&
|
||||
occlusion_frame.stats.effective_sun_cascades == 4,
|
||||
"Explicit 3D camera renders four sun cascades on every graphics path");
|
||||
require(direct_frame.stats.requested_sun_cascades == 4 &&
|
||||
direct_frame.stats.sun_shadow_caster_draws > 0 &&
|
||||
direct_frame.stats.sun_shadow_caster_draws <= 4096 &&
|
||||
direct_frame.stats.sun_shadow_atlas_bytes > 0 &&
|
||||
direct_frame.stats.gpu_sun_shadow_ms > 0,
|
||||
"Sun cascade stats describe bounded actual raster work and GPU time");
|
||||
require(gpu_frame.stats.gpu_frustum_rejected > 0,
|
||||
"Offscreen caster fixture is outside GPU camera frustum");
|
||||
require(direct_frame.stats.validation_errors == 0 &&
|
||||
gpu_frame.stats.validation_errors == 0 &&
|
||||
occlusion_frame.stats.validation_errors == 0,
|
||||
"Sun atlas rendering reports no Vulkan validation errors");
|
||||
compare_frames(direct_frame, gpu_frame);
|
||||
compare_frames(direct_frame, occlusion_frame);
|
||||
auto without = scene(false);
|
||||
const auto no_caster = capture(direct, without);
|
||||
std::size_t darkened{};
|
||||
for (std::size_t i = 0; i < direct_frame.pixels.size(); i += 4)
|
||||
darkened += int(no_caster.pixels[i]) > int(direct_frame.pixels[i]) + 12;
|
||||
require(darkened > 20,
|
||||
"Offscreen source-LOD0 caster darkens visible receiver (count=" +
|
||||
std::to_string(darkened) + ")");
|
||||
auto coarser = with_caster;
|
||||
auto degenerate_lod = std::make_shared<Mesh>(*cube_mesh());
|
||||
for (auto& vertex : degenerate_lod->vertices)
|
||||
vertex.position = {0, 0, 0};
|
||||
coarser.draws.back().lod_meshes.push_back(degenerate_lod);
|
||||
const auto source_lod_shadow = capture(gpu, coarser);
|
||||
std::size_t lod_darkened{};
|
||||
for (std::size_t i = 0; i < source_lod_shadow.pixels.size(); i += 4)
|
||||
lod_darkened += int(no_caster.pixels[i]) >
|
||||
int(source_lod_shadow.pixels[i]) + 12;
|
||||
require(source_lod_shadow.stats.lod_counts[1] > 0 && lod_darkened > 20,
|
||||
"Shadow raster uses source LOD0 even when camera chooses a coarse LOD");
|
||||
auto no_shadow = with_caster;
|
||||
no_shadow.authored_lights_present = true;
|
||||
no_shadow.sun = SunLight{"sun", no_shadow.light_direction, {1, 1, 1, 1}, 1, false};
|
||||
const auto disabled = capture(direct, no_shadow);
|
||||
require(disabled.stats.effective_sun_cascades == 0,
|
||||
"Disabled sun shadow does no shadow raster work");
|
||||
require(disabled.stats.sun_shadow_caster_draws == 0 &&
|
||||
disabled.stats.gpu_sun_shadow_ms == 0,
|
||||
"Disabled sun does not draw a hidden legacy shadow pass");
|
||||
auto legacy = with_caster;
|
||||
legacy.camera_frustum.reset();
|
||||
const auto fallback = capture(direct, legacy);
|
||||
require(fallback.stats.effective_sun_cascades == 1,
|
||||
"Low-level snapshot without explicit camera retains one reported shadow view");
|
||||
Snapshot sprite_only;
|
||||
sprite_only.sprites.push_back({{0, 0, 0}, {1, 1}});
|
||||
const auto two_d = capture(direct, sprite_only);
|
||||
require(two_d.stats.effective_sun_cascades == 0,
|
||||
"Sprite-only scene skips the sun atlas raster");
|
||||
require(two_d.stats.sun_shadow_caster_draws == 0 &&
|
||||
two_d.stats.gpu_sun_shadow_ms == 0,
|
||||
"Sprite-only rendering spends no sun shadow GPU work");
|
||||
}
|
||||
Snapshot local_scene(LocalLight::Kind kind, bool caster_shadow) {
|
||||
Snapshot result;
|
||||
result.view_id = "p3-local-shadow";
|
||||
result.eye = {0, 5, 8};
|
||||
const auto view = look_at(result.eye, {0, -1, 0});
|
||||
const auto projection = orthographic(-3, 3, -2.25f, 2.25f, .1f, 50);
|
||||
result.projection = projection;
|
||||
result.view_projection = multiply(projection, view);
|
||||
result.camera_frustum = CameraFrustum{view, projection, .1f, 50, false};
|
||||
result.authored_lights_present = true;
|
||||
DrawItem floor;
|
||||
floor.mesh = cube_mesh();
|
||||
floor.model = transform({0, -1, 0}, {}, {8, .1f, 8});
|
||||
floor.color = {.8f, .8f, .8f, 1};
|
||||
floor.instance_key = "floor";
|
||||
result.draws.push_back(floor);
|
||||
DrawItem caster;
|
||||
caster.mesh = cube_mesh();
|
||||
caster.model = transform({0, .7f, 0}, {}, {.8f, .8f, .8f});
|
||||
caster.color = {.4f, .4f, .4f, 1};
|
||||
caster.cast_shadow = caster_shadow;
|
||||
caster.instance_key = "caster";
|
||||
result.draws.push_back(caster);
|
||||
LocalLight light;
|
||||
light.kind = kind;
|
||||
light.stable_id = "local";
|
||||
light.position = {0, 3, 0};
|
||||
light.direction = {0, -1, 0};
|
||||
light.color = {1, .85f, .65f, 1};
|
||||
light.intensity = 80;
|
||||
light.range = 8;
|
||||
light.inner_angle = .3f;
|
||||
light.outer_angle = .7f;
|
||||
result.local_lights.push_back(light);
|
||||
return result;
|
||||
}
|
||||
std::size_t darker_pixels(const Frame& shadowed, const Frame& unshadowed) {
|
||||
std::size_t count{};
|
||||
for (std::size_t i = 0; i < shadowed.pixels.size(); i += 4)
|
||||
count += int(unshadowed.pixels[i]) > int(shadowed.pixels[i]) + 12;
|
||||
return count;
|
||||
}
|
||||
Snapshot point_face_scene(Vec3 axis, bool caster_shadow) {
|
||||
Snapshot result;
|
||||
result.view_id = "point-six-faces";
|
||||
const Vec3 lateral = std::abs(axis[1]) > .9f ? Vec3{0, 0, 1} : Vec3{0, 1, 0};
|
||||
result.eye = {-axis[0] * .4f + lateral[0] * 2,
|
||||
-axis[1] * .4f + lateral[1] * 2,
|
||||
-axis[2] * .4f + lateral[2] * 2};
|
||||
const Vec3 target{axis[0] * 3, axis[1] * 3, axis[2] * 3};
|
||||
const auto view = look_at(result.eye, target);
|
||||
const auto projection = orthographic(-2, 2, -2, 2, .1f, 20);
|
||||
result.projection = projection;
|
||||
result.view_projection = multiply(projection, view);
|
||||
result.camera_frustum = CameraFrustum{view, projection, .1f, 20, false};
|
||||
result.authored_lights_present = true;
|
||||
DrawItem receiver;
|
||||
receiver.mesh = cube_mesh();
|
||||
receiver.model = transform(target, {}, {1.5f, 1.5f, 1.5f});
|
||||
receiver.color = {.8f, .8f, .8f, 1};
|
||||
receiver.instance_key = "point-receiver";
|
||||
result.draws.push_back(receiver);
|
||||
DrawItem caster;
|
||||
caster.mesh = cube_mesh();
|
||||
caster.model = transform({axis[0] * 1.5f, axis[1] * 1.5f, axis[2] * 1.5f},
|
||||
{}, {.5f, .5f, .5f});
|
||||
caster.cast_shadow = caster_shadow;
|
||||
caster.instance_key = "point-caster";
|
||||
result.draws.push_back(caster);
|
||||
LocalLight light;
|
||||
light.stable_id = "point-face";
|
||||
light.position = {0, 0, 0};
|
||||
light.range = 8;
|
||||
light.intensity = 90;
|
||||
result.local_lights.push_back(light);
|
||||
return result;
|
||||
}
|
||||
void local() {
|
||||
auto direct = make_renderer(VisibilityMode::Direct);
|
||||
auto gpu = make_renderer(VisibilityMode::GpuFrustum);
|
||||
auto occlusion = make_renderer(VisibilityMode::GpuOcclusion);
|
||||
for (auto kind : {LocalLight::Kind::Point, LocalLight::Kind::Spot}) {
|
||||
const auto scene_with_shadow = local_scene(kind, true);
|
||||
const auto shadowed = capture(direct, scene_with_shadow);
|
||||
const auto gpu_shadowed = capture(gpu, scene_with_shadow);
|
||||
const auto occlusion_shadowed = capture(occlusion, scene_with_shadow);
|
||||
const auto unshadowed = capture(direct, local_scene(kind, false));
|
||||
const auto faces = kind == LocalLight::Kind::Point ? 6u : 1u;
|
||||
require(shadowed.stats.local_shadow_faces == faces &&
|
||||
shadowed.stats.requested_local_shadow_faces == faces &&
|
||||
shadowed.stats.shadow_caster_draws <= 4096 &&
|
||||
shadowed.stats.local_shadow_atlas_bytes > 0 &&
|
||||
shadowed.stats.gpu_local_shadow_ms > 0,
|
||||
"Point/spot views render within atlas and caster budgets");
|
||||
require(darker_pixels(shadowed, unshadowed) > 20,
|
||||
"Caster darkens point/spot-lit receiver (count=" +
|
||||
std::to_string(darker_pixels(shadowed, unshadowed)) + ")");
|
||||
require(shadowed.stats.validation_errors == 0 &&
|
||||
gpu_shadowed.stats.validation_errors == 0 &&
|
||||
occlusion_shadowed.stats.validation_errors == 0,
|
||||
"Local shadow rendering passes Vulkan validation");
|
||||
compare_frames(shadowed, gpu_shadowed);
|
||||
compare_frames(shadowed, occlusion_shadowed);
|
||||
}
|
||||
for (const Vec3 axis : {Vec3{1, 0, 0}, Vec3{-1, 0, 0}, Vec3{0, 1, 0},
|
||||
Vec3{0, -1, 0}, Vec3{0, 0, 1}, Vec3{0, 0, -1},
|
||||
Vec3{.7071068f, .7071068f, 0}}) {
|
||||
const auto shadowed = capture(direct, point_face_scene(axis, true));
|
||||
const auto unshadowed = capture(direct, point_face_scene(axis, false));
|
||||
require(shadowed.stats.local_shadow_faces == 6 &&
|
||||
darker_pixels(shadowed, unshadowed) > 5,
|
||||
"A point light shadows each face direction and the adjacent-face seam");
|
||||
}
|
||||
auto crowded = local_scene(LocalLight::Kind::Point, true);
|
||||
const auto point = crowded.local_lights.front();
|
||||
crowded.local_lights.clear();
|
||||
for (int i = 0; i < 15; ++i) {
|
||||
LocalLight filler;
|
||||
filler.kind = LocalLight::Kind::Spot;
|
||||
filler.stable_id = "filler-" + std::to_string(i);
|
||||
filler.position = {100, 100, 100};
|
||||
filler.direction = {0, -1, 0};
|
||||
filler.range = 8;
|
||||
filler.intensity = 1;
|
||||
filler.shadow_priority = 10;
|
||||
crowded.local_lights.push_back(filler);
|
||||
}
|
||||
const auto without_point = capture(direct, crowded);
|
||||
crowded.local_lights.push_back(point);
|
||||
const auto overflow = capture(direct, crowded);
|
||||
require(overflow.stats.requested_local_shadow_faces == 21 &&
|
||||
overflow.stats.dropped_point_shadow_faces == 6 &&
|
||||
overflow.stats.shadow_atlas_full_drops == 6 &&
|
||||
overflow.stats.local_shadow_tiles <= 16,
|
||||
"Fifteen occupied tiles drop the complete six-face point shadow");
|
||||
std::size_t brightened{};
|
||||
for (std::size_t i = 0; i < overflow.pixels.size(); i += 4)
|
||||
brightened += int(overflow.pixels[i]) > int(without_point.pixels[i]) + 12;
|
||||
require(brightened > 20,
|
||||
"Point light with dropped atlas faces still illuminates unshadowed");
|
||||
}
|
||||
} // namespace
|
||||
int main(int argc, char** argv) {
|
||||
try {
|
||||
if (argc != 2)
|
||||
throw std::invalid_argument("Expected --sun or --local");
|
||||
if (std::string(argv[1]) == "--sun")
|
||||
sun();
|
||||
else if (std::string(argv[1]) == "--local")
|
||||
local();
|
||||
else
|
||||
throw std::invalid_argument("Expected --sun or --local");
|
||||
std::cout << "Shadow atlas and Direct/GPU lighting parity passed\n";
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Contract and arithmetic tests for the offline P3 lighting sweep wrapper."""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "tools/benchmark_p3_lighting.py"
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
|
||||
def sample(shadows, visibility, lights, raster, gpu, repeat=1, frame=0):
|
||||
return {
|
||||
"shadows": shadows, "visibility": visibility, "light_count": str(lights),
|
||||
"run_index": str(repeat), "frame": str(frame),
|
||||
"gpu_main_raster_ms": str(raster), "gpu_ms": str(gpu),
|
||||
"gpu_shadow_ms": "0", "cpu_ms": "1", "readback_cpu_ms": ".5",
|
||||
"validation_errors": "0", "device": "Fake GPU", "driver": "Fake Driver",
|
||||
"commit": "abc123", "effective_visibility": visibility,
|
||||
"lighting_path": "forward", "submitted_local_lights": str(lights),
|
||||
"omitted_local_lights": "0", "shadow_tiles": "0", "draw_calls": "1",
|
||||
"gpu_bytes": "4096", "validation_enabled": "0", "width": "1920", "height": "1080",
|
||||
}
|
||||
|
||||
|
||||
FAKE_BENCHMARK = r'''import argparse
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
p = argparse.ArgumentParser()
|
||||
for flag in ("lights", "run-index", "width", "height", "warmup", "frames"):
|
||||
p.add_argument("--" + flag, type=int, required=True)
|
||||
for flag in ("shadows", "visibility", "csv", "commit", "validation"):
|
||||
p.add_argument("--" + flag, required=True)
|
||||
p.add_argument("--driver")
|
||||
a = p.parse_args()
|
||||
path = Path(a.csv)
|
||||
path.with_suffix(".args.json").write_text(json.dumps(vars(a)), encoding="utf-8")
|
||||
fieldnames = ["light_count", "shadows", "visibility", "frame", "device", "driver",
|
||||
"commit", "gpu_main_raster_ms", "gpu_ms", "gpu_shadow_ms", "cpu_ms",
|
||||
"readback_cpu_ms", "validation_errors", "run_index", "effective_visibility",
|
||||
"lighting_path", "submitted_local_lights", "omitted_local_lights",
|
||||
"shadow_tiles", "draw_calls", "gpu_bytes", "validation_enabled", "width", "height"]
|
||||
with path.open("w", newline="", encoding="utf-8") as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for frame in range(a.frames):
|
||||
writer.writerow(dict(light_count=a.lights, shadows=a.shadows,
|
||||
visibility=a.visibility, frame=frame, device="Fake GPU",
|
||||
driver="Fake Driver", commit=a.commit,
|
||||
gpu_main_raster_ms=.4 + .02 * a.lights, gpu_ms=4 + .02 * a.lights,
|
||||
gpu_shadow_ms=0, cpu_ms=1, readback_cpu_ms=.5,
|
||||
validation_errors=0, run_index=a.run_index,
|
||||
effective_visibility=a.visibility, lighting_path="forward",
|
||||
submitted_local_lights=a.lights, omitted_local_lights=0,
|
||||
shadow_tiles=0, draw_calls=1, gpu_bytes=4096,
|
||||
validation_enabled=0, width=a.width, height=a.height))
|
||||
'''
|
||||
|
||||
|
||||
class LightingBenchmarkTests(unittest.TestCase):
|
||||
def test_list_runs_has_three_independent_repeats_for_each_shadow_setting(self):
|
||||
process = subprocess.run([sys.executable, SCRIPT, "--list-runs"],
|
||||
text=True, capture_output=True, check=True)
|
||||
runs = json.loads(process.stdout)["runs"]
|
||||
self.assertEqual(len(runs), 108)
|
||||
for shadows in ("off", "on"):
|
||||
group = [run for run in runs if run["shadows"] == shadows]
|
||||
self.assertEqual(len(group), 54)
|
||||
self.assertEqual({(run["light_count"], run["visibility"])
|
||||
for run in group},
|
||||
{(light, mode) for light in (0, 4, 16, 32, 64, 128)
|
||||
for mode in ("direct", "gpu-frustum", "gpu-occlusion")})
|
||||
self.assertEqual({(run["light_count"], run["visibility"], run["repeat"])
|
||||
for run in group},
|
||||
{(light, mode, repeat)
|
||||
for light in (0, 4, 16, 32, 64, 128)
|
||||
for mode in ("direct", "gpu-frustum", "gpu-occlusion")
|
||||
for repeat in (1, 2, 3)})
|
||||
|
||||
def test_gate_uses_per_run_medians_and_same_mode_shadow_baseline(self):
|
||||
from benchmark_p3_lighting import summarize_rows
|
||||
|
||||
rows = []
|
||||
for repeat in (1, 2, 3):
|
||||
for frame in (0, 1, 2):
|
||||
rows.append(sample("off", "direct", 0, .5, 10, repeat, frame))
|
||||
rows.append(sample("off", "direct", 32,
|
||||
100 if repeat == 3 else 1.5, 11, repeat, frame))
|
||||
rows.append(sample("on", "gpu-frustum", 0, .5, 4, repeat, frame))
|
||||
rows.append(sample("on", "gpu-frustum", 64, 1.1, 4.6, repeat, frame))
|
||||
rows.append(sample("off", "gpu-occlusion", 0, .5, 4, repeat, frame))
|
||||
rows.append(sample("off", "gpu-occlusion", 128, 1.09, 4.59, repeat, frame))
|
||||
summary = summarize_rows(rows)
|
||||
hits = {(item["shadows"], item["visibility"], item["light_count"]): item
|
||||
for item in summary["forward_plus_gate"]["candidates"]}
|
||||
self.assertEqual(set(hits), {("off", "direct", 32),
|
||||
("on", "gpu-frustum", 64)})
|
||||
self.assertAlmostEqual(hits[("off", "direct", 32)]["overhead_ms"], 1.0)
|
||||
self.assertAlmostEqual(hits[("on", "gpu-frustum", 64)]["overhead_ms"], .6)
|
||||
self.assertEqual(hits[("off", "direct", 32)]["zero_light_gpu_ms"], 10)
|
||||
self.assertEqual(hits[("on", "gpu-frustum", 64)]["zero_light_gpu_ms"], 4)
|
||||
|
||||
def test_sweep_runs_fake_executable_and_preserves_all_raw_frames(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
fake = root / "fake_benchmark.py"
|
||||
fake.write_text(FAKE_BENCHMARK, encoding="utf-8")
|
||||
output = root / "café 世界"
|
||||
process = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--sweep", "--executable", fake,
|
||||
"--output", output, "--shadows", "off", "--commit", "abc123",
|
||||
"--driver", "Fake Driver"],
|
||||
text=True, capture_output=True)
|
||||
self.assertEqual(process.returncode, 0, process.stderr)
|
||||
raw = list((output / "raw").glob("*.csv"))
|
||||
self.assertEqual(len(raw), 54)
|
||||
with (output / "merged.csv").open(newline="", encoding="utf-8") as stream:
|
||||
merged = list(csv.DictReader(stream))
|
||||
self.assertEqual(len(merged), 54 * 30)
|
||||
self.assertEqual({row["source_csv"] for row in merged},
|
||||
{path.name for path in raw})
|
||||
report = json.loads((output / "summary.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["runs_completed"], 54)
|
||||
self.assertEqual(report["rows"], 54 * 30)
|
||||
self.assertTrue(report["forward_plus_gate"]["triggered"])
|
||||
one = json.loads(next((output / "raw").glob("*.args.json")).read_text())
|
||||
self.assertEqual((one["width"], one["height"], one["warmup"], one["frames"]),
|
||||
(1920, 1080, 10, 30))
|
||||
self.assertEqual(one["validation"], "off")
|
||||
|
||||
def test_sweep_rejects_missing_gpu_raster_column(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
fake = root / "bad_benchmark.py"
|
||||
fake.write_text(FAKE_BENCHMARK.replace(
|
||||
'"gpu_main_raster_ms", "gpu_ms"', '"gpu_ms"').replace(
|
||||
'gpu_main_raster_ms=.4 + .02 * a.lights, ', ''), encoding="utf-8")
|
||||
output = root / "invalid"
|
||||
process = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--sweep", "--executable", fake,
|
||||
"--output", output, "--shadows", "off", "--commit", "abc123",
|
||||
"--driver", "Fake Driver"],
|
||||
text=True, capture_output=True)
|
||||
self.assertNotEqual(process.returncode, 0)
|
||||
self.assertIn("gpu_main_raster_ms", process.stderr)
|
||||
self.assertFalse((output / "summary.json").exists())
|
||||
|
||||
def test_sweep_rejects_visibility_fallback_as_a_mode_measurement(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
fake = root / "fallback.py"
|
||||
fake.write_text(FAKE_BENCHMARK.replace(
|
||||
'effective_visibility=a.visibility', 'effective_visibility="direct"'),
|
||||
encoding="utf-8")
|
||||
output = root / "fallback-output"
|
||||
process = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--sweep", "--executable", fake,
|
||||
"--output", output, "--shadows", "off", "--commit", "abc123",
|
||||
"--driver", "Fake Driver"],
|
||||
text=True, capture_output=True)
|
||||
self.assertNotEqual(process.returncode, 0)
|
||||
self.assertIn("effective_visibility", process.stderr)
|
||||
self.assertFalse((output / "summary.json").exists())
|
||||
|
||||
def test_sweep_rejects_a_scene_that_does_not_submit_requested_lights(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
fake = root / "wrong-count.py"
|
||||
fake.write_text(FAKE_BENCHMARK.replace(
|
||||
'submitted_local_lights=a.lights', 'submitted_local_lights=0'),
|
||||
encoding="utf-8")
|
||||
output = root / "wrong-count-output"
|
||||
process = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--sweep", "--executable", fake,
|
||||
"--output", output, "--shadows", "off", "--commit", "abc123",
|
||||
"--driver", "Fake Driver"],
|
||||
text=True, capture_output=True)
|
||||
self.assertNotEqual(process.returncode, 0)
|
||||
self.assertIn("submitted_local_lights", process.stderr)
|
||||
self.assertFalse((output / "summary.json").exists())
|
||||
|
||||
def test_sweep_requires_known_driver_identity(self):
|
||||
from benchmark_p3_lighting import sweep
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
fake = Path(temporary) / "fake.py"
|
||||
fake.write_text(FAKE_BENCHMARK, encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "--driver"):
|
||||
sweep(fake, Path(temporary) / "out", "off", "abc123")
|
||||
|
||||
|
||||
def real_executable_smoke(executable: Path) -> None:
|
||||
from benchmark_p3_lighting import REQUIRED_COLUMNS
|
||||
|
||||
choices = subprocess.run([executable, "--list-runs"], capture_output=True,
|
||||
text=True, check=True)
|
||||
declared = json.loads(choices.stdout)
|
||||
if declared["lights"] != [0, 4, 16, 32, 64, 128] or len(declared["visibility"]) != 3:
|
||||
raise AssertionError("C++ executable and Python sweep matrix disagree")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "café 世界" / "smoke.csv"
|
||||
subprocess.run([executable, "--lights", "4", "--shadows", "off",
|
||||
"--visibility", "direct", "--width", "64", "--height", "64",
|
||||
"--warmup", "0", "--frames", "1", "--validation", "on",
|
||||
"--commit", "smoke", "--driver", "smoke-driver",
|
||||
"--csv", output], capture_output=True, text=True, check=True)
|
||||
with output.open(newline="", encoding="utf-8") as stream:
|
||||
reader = csv.DictReader(stream)
|
||||
columns, rows = reader.fieldnames or [], list(reader)
|
||||
if set(REQUIRED_COLUMNS) - set(columns) or len(rows) != 1:
|
||||
raise AssertionError("Real benchmark CSV lacks a complete single-frame row")
|
||||
row = rows[0]
|
||||
if (row["effective_visibility"] != "direct" or row["lighting_path"] != "forward" or
|
||||
row["submitted_local_lights"] != "4" or row["validation_errors"] != "0" or
|
||||
float(row["gpu_main_raster_ms"]) <= 0):
|
||||
raise AssertionError("Real benchmark did not report the measured lighting path")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 3 and sys.argv[1] == "--real-executable":
|
||||
real_executable_smoke(Path(sys.argv[2]).resolve())
|
||||
else:
|
||||
unittest.main()
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the fixed P3 lighting sweep and retain raw per-frame GPU measurements.
|
||||
|
||||
The Forward+ threshold in the summary is a measurement result, not an automatic
|
||||
renderer switch. Apply it to the Linux physical reference GPU; keep other devices
|
||||
as separate functional/performance observations.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
LIGHT_COUNTS = (0, 4, 16, 32, 64, 128)
|
||||
VISIBILITY_MODES = ("direct", "gpu-frustum", "gpu-occlusion")
|
||||
REPEATS = (1, 2, 3)
|
||||
WARMUP_FRAMES = 10
|
||||
MEASURED_FRAMES = 30
|
||||
WIDTH, HEIGHT = 1920, 1080
|
||||
REQUIRED_COLUMNS = (
|
||||
"light_count", "shadows", "visibility", "frame", "device", "driver",
|
||||
"commit", "gpu_main_raster_ms", "gpu_ms", "gpu_shadow_ms", "cpu_ms",
|
||||
"readback_cpu_ms", "validation_errors", "run_index", "effective_visibility",
|
||||
"lighting_path", "submitted_local_lights", "omitted_local_lights",
|
||||
"shadow_tiles", "draw_calls", "gpu_bytes", "validation_enabled", "width", "height",
|
||||
)
|
||||
TIMING_COLUMNS = ("gpu_main_raster_ms", "gpu_ms", "gpu_shadow_ms", "cpu_ms",
|
||||
"readback_cpu_ms")
|
||||
|
||||
|
||||
def build_runs(shadows: str = "both") -> list[dict]:
|
||||
if shadows not in ("off", "on", "both"):
|
||||
raise ValueError(f"Unsupported shadow setting: {shadows}")
|
||||
settings = ("off", "on") if shadows == "both" else (shadows,)
|
||||
return [{"shadows": shadow, "visibility": mode, "light_count": lights,
|
||||
"repeat": repeat}
|
||||
for shadow in settings for mode in VISIBILITY_MODES
|
||||
for lights in LIGHT_COUNTS for repeat in REPEATS]
|
||||
|
||||
|
||||
def median(values: list[float]) -> float:
|
||||
if not values:
|
||||
raise ValueError("Cannot summarize empty measurements")
|
||||
return float(statistics.median(values))
|
||||
|
||||
|
||||
def p95(values: list[float]) -> float:
|
||||
if not values:
|
||||
raise ValueError("Cannot summarize empty measurements")
|
||||
ordered = sorted(values)
|
||||
return ordered[math.ceil(.95 * len(ordered)) - 1]
|
||||
|
||||
|
||||
def _measurement(row: dict, name: str) -> float:
|
||||
try:
|
||||
value = float(row[name])
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
raise ValueError(f"Invalid {name} in benchmark CSV") from error
|
||||
if not math.isfinite(value) or value < 0:
|
||||
raise ValueError(f"Invalid {name} in benchmark CSV: {value}")
|
||||
return value
|
||||
|
||||
|
||||
def summarize_rows(rows: list[dict]) -> dict:
|
||||
"""Use the median of each independent run's median, then compare like baselines."""
|
||||
grouped: dict[tuple[str, str, int], dict[int, list[dict]]] = {}
|
||||
for row in rows:
|
||||
try:
|
||||
key = (row["shadows"], row["visibility"], int(row["light_count"]))
|
||||
repeat = int(row["run_index"])
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
raise ValueError("Benchmark row lacks shadow/mode/light/repeat identity") from error
|
||||
if key[0] not in ("off", "on") or key[1] not in VISIBILITY_MODES or repeat < 1:
|
||||
raise ValueError(f"Invalid benchmark configuration: {key}, repeat {repeat}")
|
||||
for name in TIMING_COLUMNS:
|
||||
_measurement(row, name)
|
||||
grouped.setdefault(key, {}).setdefault(repeat, []).append(row)
|
||||
|
||||
configurations = []
|
||||
lookup = {}
|
||||
for (shadows, visibility, lights), repeats in sorted(grouped.items()):
|
||||
run_summaries = []
|
||||
for repeat, samples in sorted(repeats.items()):
|
||||
run_summaries.append({
|
||||
"run_index": repeat, "frames": len(samples),
|
||||
"median_ms": {name: median([_measurement(row, name) for row in samples])
|
||||
for name in TIMING_COLUMNS},
|
||||
})
|
||||
entry = {
|
||||
"shadows": shadows, "visibility": visibility, "light_count": lights,
|
||||
"runs": run_summaries,
|
||||
"median_ms": {name: median([run["median_ms"][name] for run in run_summaries])
|
||||
for name in TIMING_COLUMNS},
|
||||
"p95_ms": {name: p95([_measurement(row, name) for samples in repeats.values()
|
||||
for row in samples]) for name in TIMING_COLUMNS},
|
||||
}
|
||||
configurations.append(entry)
|
||||
lookup[(shadows, visibility, lights)] = entry
|
||||
|
||||
candidates = []
|
||||
evaluated = []
|
||||
for entry in configurations:
|
||||
if entry["light_count"] not in (32, 64, 128):
|
||||
continue
|
||||
baseline = lookup.get((entry["shadows"], entry["visibility"], 0))
|
||||
if baseline is None:
|
||||
raise ValueError("Forward+ gate requires a zero-light baseline for each mode/shadow setting")
|
||||
zero_gpu = baseline["median_ms"]["gpu_ms"]
|
||||
if zero_gpu <= 0:
|
||||
raise ValueError("Forward+ gate requires positive zero-light GPU frame timing")
|
||||
overhead = (entry["median_ms"]["gpu_main_raster_ms"] -
|
||||
baseline["median_ms"]["gpu_main_raster_ms"])
|
||||
result = {"shadows": entry["shadows"], "visibility": entry["visibility"],
|
||||
"light_count": entry["light_count"], "overhead_ms": overhead,
|
||||
"zero_light_gpu_ms": zero_gpu,
|
||||
"overhead_percent_of_zero_gpu": 100 * overhead / zero_gpu,
|
||||
"absolute_threshold_reached": overhead >= 1.0,
|
||||
"relative_threshold_reached": overhead >= .15 * zero_gpu}
|
||||
evaluated.append(result)
|
||||
if result["absolute_threshold_reached"] or result["relative_threshold_reached"]:
|
||||
candidates.append(result)
|
||||
return {"configurations": configurations,
|
||||
"forward_plus_gate": {"triggered": bool(candidates), "candidates": candidates,
|
||||
"evaluated": evaluated,
|
||||
"basis": "median of three independent run medians; same-mode/shadow zero-light GPU baseline"}}
|
||||
|
||||
|
||||
def _read_run_csv(path: Path, run: dict, commit: str) -> tuple[list[str], list[dict]]:
|
||||
with path.open(newline="", encoding="utf-8") as stream:
|
||||
reader = csv.DictReader(stream)
|
||||
columns = reader.fieldnames or []
|
||||
missing = sorted(set(REQUIRED_COLUMNS) - set(columns))
|
||||
if missing:
|
||||
raise ValueError(f"{path}: missing CSV column(s): {', '.join(missing)}")
|
||||
rows = list(reader)
|
||||
if len(rows) != MEASURED_FRAMES:
|
||||
raise ValueError(f"{path}: expected {MEASURED_FRAMES} measured frames, got {len(rows)}")
|
||||
frames = set()
|
||||
for row in rows:
|
||||
expected = {"light_count": str(run["light_count"]), "shadows": run["shadows"],
|
||||
"visibility": run["visibility"], "run_index": str(run["repeat"]),
|
||||
"commit": commit, "width": str(WIDTH), "height": str(HEIGHT)}
|
||||
for name, value in expected.items():
|
||||
if row[name] != value:
|
||||
raise ValueError(f"{path}: {name} mismatch: expected {value}, got {row[name]}")
|
||||
if row["effective_visibility"] != run["visibility"]:
|
||||
raise ValueError(f"{path}: effective_visibility fell back from {run['visibility']}")
|
||||
if row["submitted_local_lights"] != str(run["light_count"]) or row["omitted_local_lights"] != "0":
|
||||
raise ValueError(f"{path}: submitted_local_lights or omitted_local_lights disagrees with the workload")
|
||||
try:
|
||||
frame = int(row["frame"])
|
||||
errors = int(row["validation_errors"])
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{path}: invalid frame or validation error count") from error
|
||||
if frame in frames or errors != 0:
|
||||
raise ValueError(f"{path}: duplicate frame or Vulkan validation error")
|
||||
frames.add(frame)
|
||||
if not row["device"] or not row["driver"] or not row["lighting_path"]:
|
||||
raise ValueError(f"{path}: device, driver and effective lighting path are required")
|
||||
for name in TIMING_COLUMNS:
|
||||
_measurement(row, name)
|
||||
return columns, rows
|
||||
|
||||
|
||||
def _git_revision() -> str:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
return subprocess.check_output(["git", "-C", str(root), "rev-parse", "HEAD"],
|
||||
text=True).strip()
|
||||
|
||||
|
||||
def sweep(executable: Path, output: Path, shadows: str, commit: str,
|
||||
validation: str = "off", driver: str | None = None) -> dict:
|
||||
if not executable.is_file():
|
||||
raise ValueError(f"Benchmark executable does not exist: {executable}")
|
||||
if driver is None or not driver.strip() or driver.strip().lower() == "unknown":
|
||||
raise ValueError("A measured sweep requires an explicit --driver identity")
|
||||
if output.exists() and any(output.iterdir()):
|
||||
raise ValueError(f"Output directory must be new or empty: {output}")
|
||||
raw = output / "raw"
|
||||
raw.mkdir(parents=True)
|
||||
all_rows = []
|
||||
columns = None
|
||||
runs = build_runs(shadows)
|
||||
command_prefix = [sys.executable, str(executable)] if executable.suffix.lower() == ".py" else [str(executable)]
|
||||
for run in runs:
|
||||
filename = (f"shadows-{run['shadows']}_{run['visibility']}_"
|
||||
f"lights-{run['light_count']:03d}_run-{run['repeat']}.csv")
|
||||
target = raw / filename
|
||||
command = command_prefix + [
|
||||
"--lights", str(run["light_count"]), "--shadows", run["shadows"],
|
||||
"--visibility", run["visibility"], "--csv", str(target),
|
||||
"--run-index", str(run["repeat"]), "--commit", commit,
|
||||
"--validation", validation, "--width", str(WIDTH), "--height", str(HEIGHT),
|
||||
"--warmup", str(WARMUP_FRAMES), "--frames", str(MEASURED_FRAMES),
|
||||
]
|
||||
if driver is not None:
|
||||
command += ["--driver", driver]
|
||||
result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8",
|
||||
errors="replace", timeout=180)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Benchmark failed for {filename}: {result.stderr[-2000:]}")
|
||||
run_columns, samples = _read_run_csv(target, run, commit)
|
||||
if columns is None:
|
||||
columns = run_columns
|
||||
elif columns != run_columns:
|
||||
raise ValueError(f"{target}: CSV schema differs from other runs")
|
||||
all_rows.extend({**row, "source_csv": filename} for row in samples)
|
||||
|
||||
merged = output / "merged.csv"
|
||||
with merged.open("w", newline="", encoding="utf-8") as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=[*(columns or []), "source_csv"])
|
||||
writer.writeheader()
|
||||
writer.writerows(all_rows)
|
||||
summary = {"format": "faset.p3-lighting-benchmark", "version": 1,
|
||||
"commit": commit, "warmup_frames_per_run": WARMUP_FRAMES,
|
||||
"measured_frames_per_run": MEASURED_FRAMES, "width": WIDTH, "height": HEIGHT,
|
||||
"validation": validation, "driver": driver,
|
||||
"runs_completed": len(runs), "rows": len(all_rows),
|
||||
**summarize_rows(all_rows)}
|
||||
(output / "summary.json").write_text(json.dumps(summary, indent=2) + "\n",
|
||||
encoding="utf-8")
|
||||
return summary
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
mode = parser.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--list-runs", action="store_true", help="Print the deterministic sweep matrix as JSON")
|
||||
mode.add_argument("--sweep", action="store_true", help="Run every configuration and retain raw CSV")
|
||||
parser.add_argument("--shadows", choices=("off", "on", "both"), default="both")
|
||||
parser.add_argument("--executable", type=Path, help="Built C++ benchmark executable")
|
||||
parser.add_argument("--output", type=Path, help="New or empty evidence directory")
|
||||
parser.add_argument("--commit", help="Source revision; defaults to this checkout's HEAD")
|
||||
parser.add_argument("--driver", help="Required driver identity for a measured sweep")
|
||||
parser.add_argument("--validation", choices=("on", "off"), default="off")
|
||||
args = parser.parse_args()
|
||||
if args.list_runs:
|
||||
print(json.dumps({"format": "faset.p3-lighting-run-matrix", "version": 1,
|
||||
"runs": build_runs(args.shadows)}, indent=2))
|
||||
return 0
|
||||
if args.executable is None or args.output is None:
|
||||
parser.error("--sweep requires --executable and --output")
|
||||
try:
|
||||
summary = sweep(args.executable.resolve(), args.output.resolve(), args.shadows,
|
||||
args.commit or _git_revision(), args.validation, args.driver)
|
||||
except (OSError, ValueError, RuntimeError) as error:
|
||||
print(f"P3 lighting benchmark failed: {error}", file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps({"summary": str(args.output.resolve() / "summary.json"),
|
||||
"runs_completed": summary["runs_completed"],
|
||||
"forward_plus_threshold_reached": summary["forward_plus_gate"]["triggered"]}))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user