Merge commit 'b191ae0' into feat/p1-p3-integration
Native and manual checks / native (ubuntu-24.04) (push) Failing after 31s
Windows editor and software Vulkan / windows-graphics (push) Canceled after 0s
Native and manual checks / manual (push) Successful in 28s
Native and manual checks / native (windows-2025) (push) Canceled after 0s

This commit is contained in:
Emil
2026-09-24 03:01:51 +03:00
28 changed files with 3042 additions and 121 deletions
+27
View File
@@ -2,6 +2,7 @@
#include <faset/authoring/templates.hpp>
#include <faset/authoring/transforms.hpp>
#include <faset/core/io.hpp>
#include <cstdint>
#include <iostream>
#define CHECK(x) \
@@ -25,6 +26,32 @@ int main() {
const auto root = std::filesystem::temp_directory_path() / ("faset-authoring-" + new_id());
try {
auto schemas = builtin_schemas();
const auto light = schemas.schema("faset.light");
CHECK(light["version"] == 1);
const auto defaults = schemas.default_fields("faset.light");
CHECK(defaults["kind"] == "directional");
CHECK(defaults["enabled"] == true);
CHECK(defaults["range"] == 10.0);
CHECK(defaults["inner_angle"] < defaults["outer_angle"]);
CHECK(defaults["casts_shadow"] == true);
CHECK(defaults["shadow_priority"] == 0);
auto light_component = Json{{"type", "faset.light"}, {"version", 1}, {"fields", defaults}};
schemas.validate_component(light_component);
light_component["fields"]["kind"] = "area";
fails([&] { schemas.validate_component(light_component); }, "validation.enum");
light_component["fields"]["kind"] = "point";
light_component["fields"]["range"] = 0;
fails([&] { schemas.validate_component(light_component); }, "validation.minimum");
light_component["fields"]["range"] = 10;
light_component["fields"]["intensity"] = -1;
fails([&] { schemas.validate_component(light_component); }, "validation.minimum");
light_component["fields"] = {{"kind", "spot"}, {"inner_angle", 0.9}};
fails([&] { schemas.validate_component(light_component); }, "validation.light_cone");
light_component["fields"] = {{"kind", "spot"},
{"inner_angle", 0.2},
{"outer_angle", 0.5},
{"shadow_priority", std::int64_t{2147483648}}};
fails([&] { schemas.validate_component(light_component); }, "validation.maximum");
AuthoringService service(root, schemas);
auto created = service.create("Courtyard", 3);
const std::string id = created["id"];
+15
View File
@@ -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"],
@@ -59,6 +59,15 @@ int main() {
faset::atomic_write_json(reflection_file, metadata);
must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); },
"A consistently rehashed but incompatible GPU record stride must be rejected");
faset::atomic_write_json(reflection_file,
faset::read_json(original / "gpuPostCullMain.reflection.json"));
reflection_file = temporary / "gpuVertexMain.reflection.json";
metadata = faset::read_json(original / "gpuVertexMain.reflection.json");
metadata["layout"]["descriptors"][0]["set"] = 1;
metadata["layout_fingerprint"] = faset::sha256(metadata["layout"].dump());
faset::atomic_write_json(reflection_file, metadata);
must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); },
"GPU graphics scene buffers must stay in descriptor set two");
fs::remove(temporary / "gpuHzbMain.spv");
must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); },
"Missing P2 entry must be rejected");
+305
View File
@@ -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;
}
}
+191
View File
@@ -0,0 +1,191 @@
#include <faset/render/lighting.hpp>
#include <algorithm>
#include <array>
#include <cmath>
#include <iostream>
#include <stdexcept>
using namespace faset::render;
namespace {
void require(bool condition, const char* message) {
if (!condition)
throw std::runtime_error(message);
}
Snapshot fixture() {
Snapshot frame;
const Vec3 eye{0, 2, 8};
const auto view = look_at(eye, {0, 0, 0});
const auto projection = perspective(.9f, 16.f / 9.f, .1f, 120.f);
frame.eye = eye;
frame.view_projection = multiply(projection, view);
frame.projection = projection;
frame.camera_frustum = CameraFrustum{view, projection, .1f, 120.f, true};
frame.authored_lights_present = true;
frame.sun = SunLight{"sun", {-.7f, -.5f, -.3f}, {1, 1, 1, 1}, 1, true};
return frame;
}
LocalLight point_light(std::string id, int priority = 0) {
LocalLight light;
light.kind = LocalLight::Kind::Point;
light.stable_id = std::move(id);
light.position = {0, 2, 0};
light.range = 12;
light.shadow_priority = priority;
return light;
}
LocalLight spot_light(std::string id, int priority = 0) {
auto light = point_light(std::move(id), priority);
light.kind = LocalLight::Kind::Spot;
light.direction = {0, -1, 0};
return light;
}
bool contains_caster(const ShadowView& view, std::uint32_t draw_index) {
return std::find(view.caster_indices.begin(), view.caster_indices.end(), draw_index) !=
view.caster_indices.end();
}
void run() {
auto frame = fixture();
const auto plan = build_shadow_plan(frame, {}, {});
require(plan.sun_views.size() == 4 && plan.effective_sun_cascades == 4,
"Explicit camera receives four usable sun cascades");
require(plan.sun_views[0].split_near == .1f &&
std::abs(plan.sun_views.back().split_far - 80.f) < 1e-4f,
"Practical splits start at camera near and stop at shadow distance");
for (std::size_t i = 1; i < plan.sun_views.size(); ++i)
require(plan.sun_views[i].split_near == plan.sun_views[i - 1].split_far &&
plan.sun_views[i].split_far > plan.sun_views[i].split_near,
"Cascade split endpoints are strictly increasing and contiguous");
require(plan.sun_views[0].tile_index == 0 && plan.sun_views[3].tile_index == 3 &&
plan.sun_views[0].usable_size == 1020,
"Four guarded 1024-square tiles fit a 2048-square sun atlas");
auto shifted = frame;
shifted.eye[0] += .00001f;
const auto shifted_view = look_at(shifted.eye, {.00001f, 0, 0});
shifted.camera_frustum->view = shifted_view;
shifted.view_projection = multiply(shifted.projection, shifted_view);
const auto stable = build_shadow_plan(shifted, {}, {});
require(stable.sun_views[0].snapped_center_x == plan.sun_views[0].snapped_center_x &&
stable.sun_views[0].snapped_center_y == plan.sun_views[0].snapped_center_y,
"Subtexel camera translation retains the snapped sun projection origin");
const auto sun_direction = frame.sun->direction;
const auto inv_length = 1.f / std::hypot(sun_direction[0], sun_direction[1], sun_direction[2]);
const Vec3 upstream{-sun_direction[0] * inv_length * 18,
-sun_direction[1] * inv_length * 18,
-sun_direction[2] * inv_length * 18};
const ShadowCasterBounds offscreen{{{upstream[0] - .5f, upstream[1] - .5f,
upstream[2] - .5f},
{upstream[0] + .5f, upstream[1] + .5f,
upstream[2] + .5f}}, 7};
const ShadowCasterBounds outside{{{999, 0, 0}, {1001, 2, 2}}, 8};
const std::array casters{offscreen, outside};
const auto with_casters = build_shadow_plan(frame, casters, {});
require(std::any_of(with_casters.sun_views.begin(), with_casters.sun_views.end(),
[](const auto& view) { return contains_caster(view, 7); }),
"Offscreen upstream caster remains in a receiver's sun shadow view");
require(std::none_of(with_casters.sun_views.begin(), with_casters.sun_views.end(),
[](const auto& view) { return contains_caster(view, 8); }),
"Caster outside every sun XY footprint is excluded");
std::vector<ShadowCasterBounds> many(4097, {{{-.1f, -.1f, -.1f}, {.1f, .1f, .1f}}, 0});
for (std::uint32_t i = 0; i < many.size(); ++i)
many[i].draw_index = i;
const auto overdraw = build_shadow_plan(frame, many, {});
require(overdraw.caster_draws <= 4096 &&
std::any_of(overdraw.sun_views.begin(), overdraw.sun_views.end(),
[](const auto& view) {
return !view.valid && view.reason == ShadowDropReason::CasterBudget &&
view.caster_indices.empty();
}),
"A view with 4097 casters is skipped whole rather than partially rendered");
frame.sun.reset();
for (int i = 0; i < 15; ++i)
frame.local_lights.push_back(spot_light("spot-" + std::to_string(i), 10));
frame.local_lights.push_back(point_light("last-point"));
const auto capacity = build_shadow_plan(frame, {}, {});
require(capacity.local_faces_used == 15 && capacity.dropped_point_faces == 6 &&
capacity.local_faces_used <= 16 && capacity.caster_draws <= 4096,
"Insufficient room for six point faces drops the complete point shadow");
require(capacity.submitted_local_indices.size() == 16 &&
std::none_of(capacity.local_views.begin(), capacity.local_views.end(),
[](const auto& view) { return view.light_id == "last-point"; }),
"Atlas overflow leaves the point light in the lighting list, unshadowed");
frame.local_lights = {point_light("omnidirectional")};
const ShadowCasterBounds positive_x{{{3, -.2f, -.2f}, {3.4f, .2f, .2f}}, 19};
const auto point_faces = build_shadow_plan(frame, std::array{positive_x}, {});
require(point_faces.local_views.size() == 6 &&
contains_caster(point_faces.local_views[0], 19) &&
!contains_caster(point_faces.local_views[1], 19),
"Point-light caster behind the opposite face is culled from that face");
frame.local_lights.clear();
for (int i = 0; i < 15; ++i)
frame.local_lights.push_back(spot_light("spot-" + std::to_string(i), 10));
frame.local_lights.push_back(point_light("last-point"));
auto reversed = frame;
std::reverse(reversed.local_lights.begin(), reversed.local_lights.end());
const auto reordered = build_shadow_plan(reversed, {}, {});
require(reordered.local_views.size() == capacity.local_views.size(),
"Reversing input lights preserves scheduled view count");
for (std::size_t i = 0; i < capacity.local_views.size(); ++i)
require(reordered.local_views[i].light_id == capacity.local_views[i].light_id &&
reordered.local_views[i].tile_index == capacity.local_views[i].tile_index,
"Stable IDs preserve atlas assignments across input reordering");
frame.local_lights.clear();
for (int i = 0; i < 128; ++i) {
auto light = spot_light("ordinary-" + std::to_string(i));
light.casts_shadow = false;
frame.local_lights.push_back(light);
}
auto important = spot_light("late-high-priority", 5);
important.casts_shadow = false;
frame.local_lights.push_back(important);
const auto ranked = build_shadow_plan(frame, {}, {});
require(ranked.submitted_local_indices.size() == 128 &&
ranked.omitted_local_lights == 1 &&
ranked.submitted_local_indices.front() == 128,
"Submission selects all 128 by priority and reports one omitted light");
frame.local_lights.back().range = -1;
bool invalid_overflow_rejected = false;
try {
(void)build_shadow_plan(frame, {}, {});
} catch (const std::invalid_argument&) {
invalid_overflow_rejected = true;
}
require(invalid_overflow_rejected,
"All authored lights are validated even when beyond the submission cap");
frame.local_lights.clear();
frame.sun.reset();
const auto no_sun = build_shadow_plan(frame, {}, {});
require(no_sun.requested_sun_cascades == 0 && no_sun.sun_views.empty(),
"Authored lights suppress legacy sun even when none is enabled");
for (int i = 0; i < 20; ++i)
frame.local_lights.push_back(spot_light("over-cap-" + std::to_string(i)));
ShadowBudget relaxed;
relaxed.max_local_faces = 64;
relaxed.max_local_lights = 256;
const auto clamped = build_shadow_plan(frame, {}, relaxed);
require(clamped.local_faces_used == 16 && clamped.dropped_local_faces == 4,
"Fixed 4x4 local atlas never allocates outside its sixteen tiles");
frame = fixture();
frame.camera_frustum.reset();
const auto legacy = build_shadow_plan(frame, {}, {});
require(legacy.sun_views.size() == 1 && legacy.effective_sun_cascades == 1,
"A low-level snapshot without camera frustum uses one reported sun view");
frame = fixture();
ShadowBudget unsupported;
unsupported.sun_atlas_available = false;
const auto no_atlas = build_shadow_plan(frame, {}, unsupported);
require(no_atlas.effective_sun_cascades == 0 &&
no_atlas.dropped_sun_views == 4 &&
no_atlas.sun_views[0].reason == ShadowDropReason::Unavailable,
"Unsupported depth atlas yields an explicit unshadowed sun fallback");
}
} // namespace
int main() {
try {
run();
std::cout << "Shadow planning, stable allocation, caster visibility, and budgets passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
+35 -2
View File
@@ -59,6 +59,27 @@ int main() {
const auto original_reflection = read_text(bundle / "fragmentMain.reflection.json");
const auto original_fingerprint = Json::parse(original_reflection).at("layout_fingerprint");
render::validate_shader_bundle(bundle);
auto bad_lighting_stride = Json::parse(original_reflection);
auto& lighting_descriptors = bad_lighting_stride["layout"]["descriptors"];
bool found_local_buffer = false;
for (auto& descriptor : lighting_descriptors)
if (descriptor["set"] == 1 && descriptor["binding"] == 1) {
descriptor["element_stride"] = 96;
found_local_buffer = true;
}
require(found_local_buffer, "Lighting stride fixture exists");
bad_lighting_stride["layout_fingerprint"] =
sha256(bad_lighting_stride["layout"].dump());
atomic_write_json(bundle / "fragmentMain.reflection.json", bad_lighting_stride);
bool rejected_lighting_stride = false;
try {
render::validate_shader_bundle(bundle);
} catch (const std::exception&) {
rejected_lighting_stride = true;
}
require(rejected_lighting_stride,
"Rehashed incompatible local-light element stride must be rejected");
atomic_write(bundle / "fragmentMain.reflection.json", original_reflection);
render::RendererConfig configuration;
configuration.width = configuration.height = 64;
configuration.headless = true;
@@ -161,6 +182,18 @@ int main() {
atomic_write(bundle / "fragmentMain.spv", "damaged bytecode");
retained();
restore();
auto incompatible = original_source;
auto at = incompatible.find(" float4 reserved;");
require(at != std::string::npos, "Local-light stride fixture exists");
incompatible.replace(at, std::string(" float4 reserved;").size(),
" float4 reserved;\n float4 incompatibleExtraLane;");
atomic_write(source, incompatible);
require(compile(source, bundle) == 0, "Compile incompatible light-buffer stride");
require(read_json(bundle / "fragmentMain.reflection.json").at("layout_fingerprint") !=
original_fingerprint,
"Lighting stride edit changes normalized layout fingerprint");
retained();
restore();
auto malformed = original_spirv;
for (int i = 0; i < 4; ++i)
malformed[20 + i] = 0; // zero-word SPIR-V instruction
@@ -170,8 +203,8 @@ int main() {
atomic_write_json(bundle / "fragmentMain.reflection.json", metadata);
retained();
restore();
auto incompatible = original_source;
auto at = incompatible.find("[[vk::binding(2,0)]]");
incompatible = original_source;
at = incompatible.find("[[vk::binding(2,0)]]");
require(at != std::string::npos, "Shader descriptor fixture exists");
incompatible.replace(at, std::string("[[vk::binding(2,0)]]").size(),
"[[vk::binding(7,0)]]");
+96
View File
@@ -134,6 +134,102 @@ int main(int argc, char** argv) {
renderer.render(scene);
pixels = renderer.pixels();
require(pixels[index + 2] > 220, "Texture revision upload");
Snapshot two_lights;
two_lights.eye = {0, 0, 6};
two_lights.projection = perspective(.85f, 320.f / 240.f, .1f, 30.f);
two_lights.view_projection =
multiply(two_lights.projection, look_at(two_lights.eye, {0, 0, 0}));
two_lights.authored_lights_present = true;
two_lights.draws.push_back(
{cube_mesh(), transform({-1.4f, 0, 0}), {.5f, .5f, .5f, 1}, .6f, 0, false});
two_lights.draws.back().instance_key = "left-light-receiver";
two_lights.draws.push_back(
{cube_mesh(), transform({1.4f, 0, 0}), {.5f, .5f, .5f, 1}, .6f, 0, false});
two_lights.draws.back().instance_key = "right-light-receiver";
two_lights.ui_quads.push_back({8, 8, 40, 20, {.8f, .1f, .15f, 1}});
for (auto mode : {VisibilityMode::Direct, VisibilityMode::GpuFrustum}) {
renderer.set_visibility_mode(mode);
renderer.render(two_lights);
const auto dark = renderer.pixels();
require(renderer.stats().validation_errors == 0,
"Zero-local-light descriptors are initialized");
auto legacy_lights = two_lights;
legacy_lights.authored_lights_present = false;
renderer.render(legacy_lights);
const auto legacy = renderer.pixels();
const auto left = (120 * 320 + 99) * 4;
require(legacy[left] > dark[left] + 15,
"Authored-light presence suppresses the legacy sun even without a local light");
two_lights.local_lights = {
{LocalLight::Kind::Point, "red", {-1.4f, 0, 1.4f}, {0, 0, -1},
{1, 0, 0, 1}, 8, 2.2f, .35f, .7f, false, 0},
{LocalLight::Kind::Point, "blue", {1.4f, 0, 1.4f}, {0, 0, -1},
{0, 0, 1, 1}, 8, 2.5f, .35f, .7f, false, 0}};
renderer.render(two_lights);
const auto lit = renderer.pixels();
const auto right = (120 * 320 + 221) * 4;
const auto ui = (10 * 320 + 10) * 4;
require(lit[left] > dark[left] + 20 && lit[right + 2] > dark[right + 2] + 20,
"Separated red and blue point lights illuminate their receivers");
require(std::abs(int(lit[left + 2]) - int(dark[left + 2])) < 6 &&
std::abs(int(lit[right]) - int(dark[right])) < 6,
"Local light range keeps the opposite colored light off each receiver");
for (int channel = 0; channel < 4; ++channel)
require(lit[ui + channel] == dark[ui + channel],
"Lighting changes leave UI tint unchanged");
require(renderer.stats().validation_errors == 0,
"Direct and GPU local lighting report no Vulkan errors");
if (mode == VisibilityMode::GpuFrustum)
require(renderer.stats().effective_visibility_mode == VisibilityMode::GpuFrustum,
"Local light image test actually exercises the GPU visibility path");
two_lights.local_lights[1].kind = LocalLight::Kind::Spot;
renderer.render(two_lights);
const auto aimed = renderer.pixels();
two_lights.local_lights[1].direction = {1, 0, 0};
renderer.render(two_lights);
const auto turned = renderer.pixels();
require(aimed[right + 2] > turned[right + 2] + 20,
"Spotlight cone direction changes receiver illumination");
two_lights.local_lights.clear();
}
renderer.set_visibility_mode(VisibilityMode::Direct);
renderer.render(two_lights);
const auto unlit_overflow = renderer.pixels();
for (int i = 0; i < 128; ++i) {
LocalLight local;
local.stable_id = "low-priority-" + std::to_string(i);
local.position = {20, 20, 20};
local.range = 1;
local.intensity = 0;
local.casts_shadow = false;
two_lights.local_lights.push_back(local);
}
LocalLight high;
high.stable_id = "last-high-priority";
high.position = {-1.4f, 0, 1.4f};
high.color = {1, 0, 0, 1};
high.range = 2.2f;
high.intensity = 8;
high.shadow_priority = 10;
high.casts_shadow = false;
two_lights.local_lights.push_back(high);
two_lights.local_lights.back().range = -1;
bool overflow_validation_failed = false;
try {
renderer.render(two_lights);
} catch (const std::invalid_argument&) {
overflow_validation_failed = true;
}
require(overflow_validation_failed,
"Renderer validates light records beyond the 128-light cap");
two_lights.local_lights.back().range = 2.2f;
renderer.render(two_lights);
const auto ranked_pixels = renderer.pixels();
const auto ranked_left = (120 * 320 + 99) * 4;
require(renderer.stats().submitted_local_lights == 128 &&
renderer.stats().omitted_local_lights == 1 &&
ranked_pixels[ranked_left] > unlit_overflow[ranked_left] + 20,
"High-priority last light is submitted and omitted count is observable");
if (argc > 2)
renderer.capture(argv[2]);
renderer.resize(400, 300);
+128
View File
@@ -1,10 +1,12 @@
#include <bit>
#include <algorithm>
#include <cmath>
#include <faset/assets/asset_pipeline.hpp>
#include <faset/core/io.hpp>
#include <faset/player/SceneView.hpp>
#include <faset/runtime/Runtime.hpp>
#include <iostream>
#include <limits>
#include <numbers>
#include <stdexcept>
@@ -23,12 +25,137 @@ template <class F> void rejects(F&& function, const char* message) {
}
check(caught, message);
}
template <class F>
void rejectsContaining(F&& function, std::string_view entityId, std::string_view field) {
try {
function();
} catch (const std::exception& error) {
const std::string_view what(error.what());
check(what.find(entityId) != std::string_view::npos &&
what.find(field) != std::string_view::npos,
"Invalid light reports entity and field");
return;
}
throw std::runtime_error("Invalid light was accepted");
}
Json component(std::string type, Json fields) {
return {{"id", type}, {"type", type}, {"version", 1}, {"fields", fields}};
}
Json entity(std::string id, Json parent, Json components) {
return {{"id", id}, {"name", id}, {"parent", parent}, {"components", components}};
}
void lightingExtraction(faset::player::SceneView& view) {
auto scene = Json{{"format", "faset.scene"},
{"version", 1},
{"id", "lighting"},
{"name", "Lighting"},
{"dimension", 3},
{"instances", Json::array()},
{"entities", Json::array()}};
const auto empty = view.build(scene, 16.f / 9.f);
check(!empty.authored_lights_present && !empty.sun && empty.local_lights.empty(),
"Scene with no lights leaves legacy sun fallback available");
check(empty.camera_frustum && empty.camera_frustum->perspective &&
empty.camera_frustum->near_plane > 0 &&
empty.camera_frustum->far_plane > empty.camera_frustum->near_plane &&
faset::render::multiply(empty.camera_frustum->projection,
empty.camera_frustum->view) == empty.view_projection,
"3D extraction retains an unjittered camera frustum");
scene["entities"] = Json::array({
entity("sun", nullptr,
Json::array({component("faset.transform", {{"rotation", {0, .4, 0}}}),
component("faset.light", {{"kind", "directional"},
{"color", {1, .8, .6, 1}},
{"intensity", 2.5},
{"casts_shadow", false}})})),
entity("point", nullptr,
Json::array({component("faset.transform", {{"position", {2, 3, 4}}}),
component("faset.light", {{"kind", "point"},
{"color", {1, 0, 0, 1}},
{"intensity", 4},
{"range", 6},
{"shadow_priority", 3}})})),
entity("spot", nullptr,
Json::array({component("faset.transform", {{"position", {-2, 1, 0}}}),
component("faset.light", {{"kind", "spot"},
{"color", {0, 0, 1, 1}},
{"intensity", 3},
{"range", 8},
{"inner_angle", .2},
{"outer_angle", .6}})}))});
const auto a = view.build(scene, 16.f / 9.f);
check(a.authored_lights_present && a.sun && a.local_lights.size() == 2,
"Directional, point, and spot lights survive extraction");
check(a.sun->color[1] == .8f && a.sun->intensity == 2.5f && !a.sun->casts_shadow,
"Authored sun properties survive extraction");
check(a.local_lights[0].kind == faset::render::LocalLight::Kind::Point &&
a.local_lights[0].position == faset::render::Vec3{2, 3, 4} &&
a.local_lights[0].range == 6 && a.local_lights[0].shadow_priority == 3,
"Point fields and transform survive extraction");
check(a.local_lights[1].kind == faset::render::LocalLight::Kind::Spot &&
a.local_lights[1].inner_angle == .2f && a.local_lights[1].outer_angle == .6f,
"Spot cone survives extraction");
std::reverse(scene["entities"].begin(), scene["entities"].end());
const auto b = view.build(scene, 16.f / 9.f);
check(a.sun->stable_id == b.sun->stable_id &&
a.local_lights[0].stable_id == b.local_lights[0].stable_id &&
a.local_lights[1].stable_id == b.local_lights[1].stable_id,
"Light identity and ordering ignore entity array order");
scene["entities"].erase(scene["entities"].begin() + 2);
const auto localOnly = view.build(scene, 1);
check(localOnly.authored_lights_present && !localOnly.sun &&
localOnly.local_lights.size() == 2,
"Local-only lighting does not synthesize a sun");
scene["entities"] = Json::array({entity(
"disabled-sun", nullptr,
Json::array({component("faset.light", {{"kind", "directional"}, {"enabled", false}})}))});
const auto disabled = view.build(scene, 1);
check(disabled.authored_lights_present && !disabled.sun && disabled.local_lights.empty(),
"Explicit disabled sun suppresses legacy fallback");
scene["entities"][0]["components"][0]["version"] = 2;
const auto future = view.build(scene, 1);
check(future.authored_lights_present && !future.sun,
"Opaque future-version light still suppresses legacy fallback");
scene["entities"] = Json::array({
entity("sun-z", nullptr, Json::array({component("faset.light", Json::object())})),
entity("sun-a", nullptr, Json::array({component("faset.light", Json::object())}))});
const auto twoSuns = view.build(scene, 1);
check(twoSuns.sun && twoSuns.sun->stable_id.find("sun-a") != std::string::npos,
"Multiple suns select lowest stable identity");
check(!view.diagnostics().empty() &&
view.diagnostics().front().find("directional") != std::string::npos,
"Additional directionals produce an actionable diagnostic");
scene["entities"] = Json::array({entity(
"bad-light", nullptr,
Json::array({component("faset.light", {{"kind", "point"}, {"range", 0}})}))});
rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "range");
scene["entities"][0]["components"][0]["fields"] =
{{"kind", "spot"}, {"range", 10}, {"inner_angle", .8}, {"outer_angle", .2}};
rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "inner_angle");
scene["entities"][0]["components"][0]["fields"] = {{"kind", "area"}};
rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "kind");
scene["entities"][0]["components"][0]["fields"] =
{{"kind", "point"}, {"shadow_priority", std::int64_t{2147483648}}};
rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "shadow_priority");
scene["entities"][0]["components"][0]["fields"] = {{"kind", "point"}};
scene["entities"][0]["components"].insert(
scene["entities"][0]["components"].begin(),
component("faset.transform", {{"scale", {1, 1, 0}}}));
const auto flatPoint = view.build(scene, 1);
check(flatPoint.local_lights.size() == 1 &&
flatPoint.local_lights[0].kind == faset::render::LocalLight::Kind::Point,
"Point light accepts a zero Z scale because it needs only a position");
scene["entities"][0]["components"].erase(scene["entities"][0]["components"].begin());
scene["entities"][0]["components"][0]["fields"] =
{{"kind", "point"},
{"color", Json::array({1, std::numeric_limits<double>::quiet_NaN(), 1, 1})}};
rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "color");
scene["entities"][0]["components"].insert(
scene["entities"][0]["components"].begin(),
component("faset.transform", {{"position", {0, 0, 0}},
{"scale", Json::array({1, std::numeric_limits<double>::quiet_NaN(), 1})}}));
rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "transform");
}
void physicsDebug(faset::player::SceneView& view) {
for (int dimension : {2, 3}) {
const std::string bodyName = dimension == 2 ? "rigid_body_2d" : "rigid_body_3d";
@@ -159,6 +286,7 @@ void run() {
faset::atomic_write(folder / "version.fscene", version);
rejects([&] { faset::player::readScene(folder / "version.fscene"); }, "reject cooked version");
faset::player::SceneView view(folder);
lightingExtraction(view);
physicsDebug(view);
auto snapshot = view.build(scene, 16.f / 9.f);
check(snapshot.draws.size() == 1, "SceneView builtin mesh");
+229
View File
@@ -0,0 +1,229 @@
"""Contract and arithmetic tests for the offline P3 lighting sweep wrapper."""
import csv
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "tools/benchmark_p3_lighting.py"
sys.path.insert(0, str(ROOT / "tools"))
def sample(shadows, visibility, lights, raster, gpu, repeat=1, frame=0):
return {
"shadows": shadows, "visibility": visibility, "light_count": str(lights),
"run_index": str(repeat), "frame": str(frame),
"gpu_main_raster_ms": str(raster), "gpu_ms": str(gpu),
"gpu_shadow_ms": "0", "cpu_ms": "1", "readback_cpu_ms": ".5",
"validation_errors": "0", "device": "Fake GPU", "driver": "Fake Driver",
"commit": "abc123", "effective_visibility": visibility,
"lighting_path": "forward", "submitted_local_lights": str(lights),
"omitted_local_lights": "0", "shadow_tiles": "0", "draw_calls": "1",
"gpu_bytes": "4096", "validation_enabled": "0", "width": "1920", "height": "1080",
}
FAKE_BENCHMARK = r'''import argparse
import csv
import json
from pathlib import Path
p = argparse.ArgumentParser()
for flag in ("lights", "run-index", "width", "height", "warmup", "frames"):
p.add_argument("--" + flag, type=int, required=True)
for flag in ("shadows", "visibility", "csv", "commit", "validation"):
p.add_argument("--" + flag, required=True)
p.add_argument("--driver")
a = p.parse_args()
path = Path(a.csv)
path.with_suffix(".args.json").write_text(json.dumps(vars(a)), encoding="utf-8")
fieldnames = ["light_count", "shadows", "visibility", "frame", "device", "driver",
"commit", "gpu_main_raster_ms", "gpu_ms", "gpu_shadow_ms", "cpu_ms",
"readback_cpu_ms", "validation_errors", "run_index", "effective_visibility",
"lighting_path", "submitted_local_lights", "omitted_local_lights",
"shadow_tiles", "draw_calls", "gpu_bytes", "validation_enabled", "width", "height"]
with path.open("w", newline="", encoding="utf-8") as stream:
writer = csv.DictWriter(stream, fieldnames=fieldnames)
writer.writeheader()
for frame in range(a.frames):
writer.writerow(dict(light_count=a.lights, shadows=a.shadows,
visibility=a.visibility, frame=frame, device="Fake GPU",
driver="Fake Driver", commit=a.commit,
gpu_main_raster_ms=.4 + .02 * a.lights, gpu_ms=4 + .02 * a.lights,
gpu_shadow_ms=0, cpu_ms=1, readback_cpu_ms=.5,
validation_errors=0, run_index=a.run_index,
effective_visibility=a.visibility, lighting_path="forward",
submitted_local_lights=a.lights, omitted_local_lights=0,
shadow_tiles=0, draw_calls=1, gpu_bytes=4096,
validation_enabled=0, width=a.width, height=a.height))
'''
class LightingBenchmarkTests(unittest.TestCase):
def test_list_runs_has_three_independent_repeats_for_each_shadow_setting(self):
process = subprocess.run([sys.executable, SCRIPT, "--list-runs"],
text=True, capture_output=True, check=True)
runs = json.loads(process.stdout)["runs"]
self.assertEqual(len(runs), 108)
for shadows in ("off", "on"):
group = [run for run in runs if run["shadows"] == shadows]
self.assertEqual(len(group), 54)
self.assertEqual({(run["light_count"], run["visibility"])
for run in group},
{(light, mode) for light in (0, 4, 16, 32, 64, 128)
for mode in ("direct", "gpu-frustum", "gpu-occlusion")})
self.assertEqual({(run["light_count"], run["visibility"], run["repeat"])
for run in group},
{(light, mode, repeat)
for light in (0, 4, 16, 32, 64, 128)
for mode in ("direct", "gpu-frustum", "gpu-occlusion")
for repeat in (1, 2, 3)})
def test_gate_uses_per_run_medians_and_same_mode_shadow_baseline(self):
from benchmark_p3_lighting import summarize_rows
rows = []
for repeat in (1, 2, 3):
for frame in (0, 1, 2):
rows.append(sample("off", "direct", 0, .5, 10, repeat, frame))
rows.append(sample("off", "direct", 32,
100 if repeat == 3 else 1.5, 11, repeat, frame))
rows.append(sample("on", "gpu-frustum", 0, .5, 4, repeat, frame))
rows.append(sample("on", "gpu-frustum", 64, 1.1, 4.6, repeat, frame))
rows.append(sample("off", "gpu-occlusion", 0, .5, 4, repeat, frame))
rows.append(sample("off", "gpu-occlusion", 128, 1.09, 4.59, repeat, frame))
summary = summarize_rows(rows)
hits = {(item["shadows"], item["visibility"], item["light_count"]): item
for item in summary["forward_plus_gate"]["candidates"]}
self.assertEqual(set(hits), {("off", "direct", 32),
("on", "gpu-frustum", 64)})
self.assertAlmostEqual(hits[("off", "direct", 32)]["overhead_ms"], 1.0)
self.assertAlmostEqual(hits[("on", "gpu-frustum", 64)]["overhead_ms"], .6)
self.assertEqual(hits[("off", "direct", 32)]["zero_light_gpu_ms"], 10)
self.assertEqual(hits[("on", "gpu-frustum", 64)]["zero_light_gpu_ms"], 4)
def test_sweep_runs_fake_executable_and_preserves_all_raw_frames(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
fake = root / "fake_benchmark.py"
fake.write_text(FAKE_BENCHMARK, encoding="utf-8")
output = root / "café 世界"
process = subprocess.run(
[sys.executable, SCRIPT, "--sweep", "--executable", fake,
"--output", output, "--shadows", "off", "--commit", "abc123",
"--driver", "Fake Driver"],
text=True, capture_output=True)
self.assertEqual(process.returncode, 0, process.stderr)
raw = list((output / "raw").glob("*.csv"))
self.assertEqual(len(raw), 54)
with (output / "merged.csv").open(newline="", encoding="utf-8") as stream:
merged = list(csv.DictReader(stream))
self.assertEqual(len(merged), 54 * 30)
self.assertEqual({row["source_csv"] for row in merged},
{path.name for path in raw})
report = json.loads((output / "summary.json").read_text(encoding="utf-8"))
self.assertEqual(report["runs_completed"], 54)
self.assertEqual(report["rows"], 54 * 30)
self.assertTrue(report["forward_plus_gate"]["triggered"])
one = json.loads(next((output / "raw").glob("*.args.json")).read_text())
self.assertEqual((one["width"], one["height"], one["warmup"], one["frames"]),
(1920, 1080, 10, 30))
self.assertEqual(one["validation"], "off")
def test_sweep_rejects_missing_gpu_raster_column(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
fake = root / "bad_benchmark.py"
fake.write_text(FAKE_BENCHMARK.replace(
'"gpu_main_raster_ms", "gpu_ms"', '"gpu_ms"').replace(
'gpu_main_raster_ms=.4 + .02 * a.lights, ', ''), encoding="utf-8")
output = root / "invalid"
process = subprocess.run(
[sys.executable, SCRIPT, "--sweep", "--executable", fake,
"--output", output, "--shadows", "off", "--commit", "abc123",
"--driver", "Fake Driver"],
text=True, capture_output=True)
self.assertNotEqual(process.returncode, 0)
self.assertIn("gpu_main_raster_ms", process.stderr)
self.assertFalse((output / "summary.json").exists())
def test_sweep_rejects_visibility_fallback_as_a_mode_measurement(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
fake = root / "fallback.py"
fake.write_text(FAKE_BENCHMARK.replace(
'effective_visibility=a.visibility', 'effective_visibility="direct"'),
encoding="utf-8")
output = root / "fallback-output"
process = subprocess.run(
[sys.executable, SCRIPT, "--sweep", "--executable", fake,
"--output", output, "--shadows", "off", "--commit", "abc123",
"--driver", "Fake Driver"],
text=True, capture_output=True)
self.assertNotEqual(process.returncode, 0)
self.assertIn("effective_visibility", process.stderr)
self.assertFalse((output / "summary.json").exists())
def test_sweep_rejects_a_scene_that_does_not_submit_requested_lights(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
fake = root / "wrong-count.py"
fake.write_text(FAKE_BENCHMARK.replace(
'submitted_local_lights=a.lights', 'submitted_local_lights=0'),
encoding="utf-8")
output = root / "wrong-count-output"
process = subprocess.run(
[sys.executable, SCRIPT, "--sweep", "--executable", fake,
"--output", output, "--shadows", "off", "--commit", "abc123",
"--driver", "Fake Driver"],
text=True, capture_output=True)
self.assertNotEqual(process.returncode, 0)
self.assertIn("submitted_local_lights", process.stderr)
self.assertFalse((output / "summary.json").exists())
def test_sweep_requires_known_driver_identity(self):
from benchmark_p3_lighting import sweep
with tempfile.TemporaryDirectory() as temporary:
fake = Path(temporary) / "fake.py"
fake.write_text(FAKE_BENCHMARK, encoding="utf-8")
with self.assertRaisesRegex(ValueError, "--driver"):
sweep(fake, Path(temporary) / "out", "off", "abc123")
def real_executable_smoke(executable: Path) -> None:
from benchmark_p3_lighting import REQUIRED_COLUMNS
choices = subprocess.run([executable, "--list-runs"], capture_output=True,
text=True, check=True)
declared = json.loads(choices.stdout)
if declared["lights"] != [0, 4, 16, 32, 64, 128] or len(declared["visibility"]) != 3:
raise AssertionError("C++ executable and Python sweep matrix disagree")
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "café 世界" / "smoke.csv"
subprocess.run([executable, "--lights", "4", "--shadows", "off",
"--visibility", "direct", "--width", "64", "--height", "64",
"--warmup", "0", "--frames", "1", "--validation", "on",
"--commit", "smoke", "--driver", "smoke-driver",
"--csv", output], capture_output=True, text=True, check=True)
with output.open(newline="", encoding="utf-8") as stream:
reader = csv.DictReader(stream)
columns, rows = reader.fieldnames or [], list(reader)
if set(REQUIRED_COLUMNS) - set(columns) or len(rows) != 1:
raise AssertionError("Real benchmark CSV lacks a complete single-frame row")
row = rows[0]
if (row["effective_visibility"] != "direct" or row["lighting_path"] != "forward" or
row["submitted_local_lights"] != "4" or row["validation_errors"] != "0" or
float(row["gpu_main_raster_ms"]) <= 0):
raise AssertionError("Real benchmark did not report the measured lighting path")
if __name__ == "__main__":
if len(sys.argv) == 3 and sys.argv[1] == "--real-executable":
real_executable_smoke(Path(sys.argv[2]).resolve())
else:
unittest.main()
+29
View File
@@ -36,6 +36,35 @@ def parameter(name: str, index: int, shape: str, access: str, stride: int | None
class ReflectionTests(unittest.TestCase):
def test_graphics_lighting_abi(self):
compiler = os.environ["FASET_TEST_SLANGC"]
with tempfile.TemporaryDirectory(prefix="faset-lighting-abi-") as directory:
for source, entry, defines in (
("baseline.slang", "fragmentMain", []),
("gpu_scene.slang", "gpuVertexMain", ["--define", "FASET_GPU_GRAPHICS=1"]),
):
process = subprocess.run(
[sys.executable, str(SCRIPT), "--compiler", compiler, "--source",
str(SCRIPT.parents[1] / "shaders" / source), "--entry", entry,
*defines, "--output", directory],
capture_output=True, text=True,
)
self.assertEqual(process.returncode, 0, process.stderr)
fragment = json.loads((Path(directory) / "fragmentMain.reflection.json").read_text())
gpu_vertex = json.loads((Path(directory) / "gpuVertexMain.reflection.json").read_text())
lighting = {
(d["set"], d["binding"]): (d["type"], d.get("element_stride"))
for d in fragment["layout"]["descriptors"]
}
self.assertEqual([lighting[1, i] for i in range(4)],
[("storage_buffer", 80), ("storage_buffer", 80),
("storage_buffer", 112), ("sampled_image_2d", None)])
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_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