Bind shared lighting ABI and shade authored local lights

This commit is contained in:
Emil
2026-09-24 01:53:14 +03:00
parent 674e3e812b
commit cfcfdab949
14 changed files with 561 additions and 52 deletions
+8
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) \
@@ -44,6 +45,13 @@ int main() {
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"];
@@ -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");
+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)]]");
+59
View File
@@ -134,6 +134,65 @@ 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);
if (argc > 2)
renderer.capture(argv[2]);
renderer.resize(400, 300);
+12
View File
@@ -134,6 +134,18 @@ void lightingExtraction(faset::player::SceneView& view) {
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})}};
+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