From 674e3e812b1744026f110007696ed0166bcfadc3 Mon Sep 17 00:00:00 2001 From: Emil <65846814+emil28092005@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:35:20 +0300 Subject: [PATCH] Expose authored sun, point, and spot lights in render snapshots --- .../plans/2026-09-24-p3-lighting.md | 8 +- .../specs/2026-09-24-p3-lighting-design.md | 2 +- include/faset/render/renderer.hpp | 34 +++++ src/authoring/schema.cpp | 19 ++- src/player/SceneView.cpp | 126 +++++++++++++++++- tests/authoring_tests.cpp | 19 +++ tests/runtime_player_tests.cpp | 116 ++++++++++++++++ 7 files changed, 311 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/plans/2026-09-24-p3-lighting.md b/docs/superpowers/plans/2026-09-24-p3-lighting.md index b7078c5..7253087 100644 --- a/docs/superpowers/plans/2026-09-24-p3-lighting.md +++ b/docs/superpowers/plans/2026-09-24-p3-lighting.md @@ -42,9 +42,9 @@ Tasks 1–3 define the shared interfaces. Integrate Task 2's descriptor ABI befo **Files:** Modify `include/faset/render/renderer.hpp`, `src/authoring/schema.cpp`, `src/player/SceneView.cpp`, `tests/authoring_tests.cpp`, `tests/runtime_player_tests.cpp`, and relevant Manual authoring examples. -**Interfaces:** Introduce `SunLight { stable_id, direction, color, intensity, casts_shadow }`, `LocalLight { Kind::Point|Spot, stable_id, position, direction, color, intensity, range, inner_angle, outer_angle, casts_shadow, shadow_priority }`, and `CameraFrustum { view, projection, near_plane, far_plane, perspective }`. Add `std::optional Snapshot::sun`, `std::vector Snapshot::local_lights`, and `std::optional Snapshot::camera_frustum` after existing aggregate fields; retain `Snapshot::light_direction`. `SceneView::build` fills camera data for 3D scenes and picks the first enabled directional by stable ID. +**Interfaces:** Introduce `SunLight { stable_id, direction, color, intensity, casts_shadow }`, `LocalLight { Kind::Point|Spot, stable_id, position, direction, color, intensity, range, inner_angle, outer_angle, casts_shadow, shadow_priority }`, and `CameraFrustum { view, projection, near_plane, far_plane, perspective }`. Add `std::optional Snapshot::sun`, `std::vector Snapshot::local_lights`, `bool Snapshot::authored_lights_present` (default false), and `std::optional Snapshot::camera_frustum` after existing aggregate fields; retain `Snapshot::light_direction`. `SceneView::build` fills camera data for 3D scenes, sets authored-light presence even for disabled/future-version light components, and picks the first enabled directional by stable ID. A legacy sun is synthesized only when both sun and authored-light presence are absent. -- [ ] **Step 1: Write failing schema/extraction tests.** Construct a version-1 scene with directional, point, and spot entities in one order and reversed order. Assert all local IDs/properties agree; authored sun color/intensity are preserved; no-light scene retains the default legacy sun; extra directionals emit a diagnostic; invalid `range <= 0`, `inner_angle > outer_angle`, nonfinite color/transform, and unknown kind identify the entity/field. An essential assertion is: +- [ ] **Step 1: Write failing schema/extraction tests.** Construct a version-1 scene with directional, point, and spot entities in one order and reversed order. Assert all local IDs/properties agree; authored sun color/intensity are preserved; a no-light scene has `authored_lights_present == false` and retains the default legacy sun, while a local-only or explicitly disabled-sun scene has the flag true and no sun. Extra directionals emit a diagnostic; invalid `range <= 0`, `inner_angle > outer_angle`, nonfinite color/transform, and unknown kind identify the entity/field. An essential assertion is: ```cpp auto a = view.build(scene_with_three_lights(), 16.f / 9.f); auto b = view.build(reordered_scene_with_three_lights(), 16.f / 9.f); @@ -54,14 +54,14 @@ Tasks 1–3 define the shared interfaces. Integrate Task 2's descriptor ABI befo "Light ordering follows stable IDs, not entity array order"); ``` - [ ] **Step 2: Run the focused tests red.** Run `cmake --preset linux-debug` and `cmake --build --preset linux-debug --target faset_authoring_tests faset_player_tests --parallel 4`; missing typed fields should fail compilation. If compilation succeeds, `ctest --test-dir build/linux-debug --output-on-failure --no-tests=error -R '^(authoring|player_scene_contracts)$'` must fail on a new behavioral assertion. Record the expected failure rather than assuming the build itself must be red. -- [ ] **Step 3: Add schema defaults and extraction.** Keep builtin version 1; use `fields.value` for additive fields, normalize directions after the world transform, validate finite/color/range/cone values, sort by stable ID, and preserve the legacy fallback. Set `camera_frustum` from the same unjittered view/projection used to form `view_projection`. Update direct C++ API examples to construct one point and one spot light. +- [ ] **Step 3: Add schema defaults and extraction.** Keep builtin version 1; use `fields.value` for additive fields, normalize directions after the world transform, validate finite/color/range/cone values, sort by stable ID, and preserve the legacy fallback only when no authored light component exists. Set `camera_frustum` from the same unjittered view/projection used to form `view_projection`. Update direct C++ API examples to construct one point and one spot light. ```cpp struct CameraFrustum { Mat4 view{identity}, projection{identity}; float near_plane{0.1f}, far_plane{1000.f}; bool perspective{true}; }; - // Snapshot::light_direction remains available to callers without Snapshot::sun. + // Snapshot::light_direction remains a fallback only if authored_lights_present is false. ``` - [ ] **Step 4: Run focused tests green.** `ctest --test-dir build/linux-debug --output-on-failure -R '^(authoring|player_scene_contracts)$'` passes, including old version-1 scenes and exported-scene decoding. - [ ] **Step 5: Commit** `Expose authored sun, point, and spot lights in render snapshots`. diff --git a/docs/superpowers/specs/2026-09-24-p3-lighting-design.md b/docs/superpowers/specs/2026-09-24-p3-lighting-design.md index 39442de..0e7ff34 100644 --- a/docs/superpowers/specs/2026-09-24-p3-lighting-design.md +++ b/docs/superpowers/specs/2026-09-24-p3-lighting-design.md @@ -12,7 +12,7 @@ P3 supplies one sun plus point and spot lights, camera-fitted cascaded sun shado Keep `faset.light` at builtin schema version 1 and add optional fields with defaults: `kind` (`directional`, `point`, `spot`, default `directional`), `enabled` (true), `color` (white), `intensity` (1, nonnegative), `range` (10, positive, for local lights), `inner_angle` (0.35 radians), `outer_angle` (0.70 radians, strictly below π/2), `casts_shadow` (true), and `shadow_priority` (integer 0). Existing fields and component IDs remain valid. Cross-field validation requires `0 <= inner_angle <= outer_angle`; local range, color channels, intensity, transforms, and cone directions must be finite. Invalid authored values return a diagnostic with entity and field rather than nonfinite GPU data. Missing new fields use the schema defaults. -`Snapshot` retains `light_direction` for existing direct-render clients. Add an optional `SunLight`, a vector of `LocalLight`, and an optional explicit `CameraFrustum` containing unjittered view/projection matrices, near/far distances, and projection kind. `SceneView` populates these from the authoring scene. Each light carries the stable entity/component identity, transformed position or normalized direction, color, intensity, range, cone angles, and shadow options. The first enabled directional light by stable ID is the sun; extra directionals produce a visible diagnostic until a later multi-sun design exists. If there is no authored directional light, the renderer synthesizes the legacy sun from `Snapshot::light_direction`. Local lights are ordered by stable ID to prevent reordering from changing atlas allocation or results. Point attenuation tends smoothly to zero at `range`; a spot multiplies it by a smooth inner-to-outer cone factor. The shader handles zero distance and invalid normals without NaN output. +`Snapshot` retains `light_direction` for existing direct-render clients. Add an optional `SunLight`, a vector of `LocalLight`, an `authored_lights_present` flag (default false), and an optional explicit `CameraFrustum` containing unjittered view/projection matrices, near/far distances, and projection kind. `SceneView` populates these from the authoring scene and sets the flag if any authored light component exists, including a disabled or future-version component. Each light carries the stable entity/component identity, transformed position or normalized direction, color, intensity, range, cone angles, and shadow options. The first enabled directional light by stable ID is the sun; extra directionals produce a visible diagnostic until a later multi-sun design exists. The renderer synthesizes the legacy sun from `Snapshot::light_direction` only when `sun` is absent **and** `authored_lights_present` is false. Thus an old scene without light components keeps its previous appearance, while a local-only scene or explicitly disabled sun does not receive an unintended directional light. Low-level callers may set the flag to request a dark scene without authoring metadata. Local lights are ordered by stable ID to prevent reordering from changing atlas allocation or results. Point attenuation tends smoothly to zero at `range`; a spot multiplies it by a smooth inner-to-outer cone factor. The shader handles zero distance and invalid normals without NaN output. Explicit camera frustum data is required for four cascades. If a low-level caller supplies only the legacy `view_projection`, the renderer uses one bounded legacy-compatible sun shadow view and reports `effective_sun_cascades = 1`; it never silently claims CSM. 2D sprite-only snapshots do not incur shadow work. The future temporal stage may jitter the main raster projection, but the shadow planner consumes the unjittered `CameraFrustum` exclusively. diff --git a/include/faset/render/renderer.hpp b/include/faset/render/renderer.hpp index 8a71592..84a8dcd 100644 --- a/include/faset/render/renderer.hpp +++ b/include/faset/render/renderer.hpp @@ -80,6 +80,34 @@ struct Text { Color color{0.85f, 0.87f, 0.90f, 1}; float size{14}; }; +struct SunLight { + std::string stable_id; + Vec3 direction{-0.5f, -1, -0.3f}; + Color color{1, 1, 1, 1}; + float intensity{1}; + bool casts_shadow{true}; +}; +struct LocalLight { + enum class Kind { Point, Spot }; + Kind kind{Kind::Point}; + std::string stable_id; + Vec3 position{}; + Vec3 direction{0, 0, -1}; + Color color{1, 1, 1, 1}; + float intensity{1}; + float range{10}; + float inner_angle{0.35f}; + float outer_angle{0.7f}; + bool casts_shadow{true}; + int shadow_priority{}; +}; +struct CameraFrustum { + Mat4 view{identity}; + Mat4 projection{identity}; + float near_plane{0.1f}; + float far_plane{1000}; + bool perspective{true}; +}; struct Snapshot { // Optional scene viewport in drawable pixels (x, y, width, height); zero size uses the full // target. @@ -100,6 +128,12 @@ struct Snapshot { // Distinguishes temporal histories when one Renderer displays different views. std::string view_id{}; bool camera_cut{}; + // Empty legacy scenes may use the renderer's compatibility sun. Any authored light + // component, including an explicitly disabled or opaque future one, suppresses it. + bool authored_lights_present{}; + std::optional sun{}; + std::vector local_lights; + std::optional camera_frustum{}; }; enum class VisibilityMode { Direct, GpuFrustum, GpuOcclusion }; // CPU-only validation used before publishing a game or creating Vulkan pipelines. diff --git a/src/authoring/schema.cpp b/src/authoring/schema.cpp index aa42d60..63366e6 100644 --- a/src/authoring/schema.cpp +++ b/src/authoring/schema.cpp @@ -252,9 +252,22 @@ SchemaRegistry builtin_schemas() { {{"fov", Json{{"type", "number"}, {"default", 60.0}, {"min", 1.0}, {"max", 179.0}}}, {"near", Json{{"type", "number"}, {"default", 0.1}, {"min", 0.001}}}, {"far", Json{{"type", "number"}, {"default", 1000.0}, {"min", 0.01}}}}); - add("faset.light", "Directional Light", - {{"color", field("color", {1, 1, 1, 1})}, - {"intensity", Json{{"type", "number"}, {"default", 1.0}, {"min", 0.0}}}}); + add("faset.light", "Light", + {{"kind", Json{{"type", "string"}, + {"default", "directional"}, + {"enum", {"directional", "point", "spot"}}}}, + {"enabled", field("boolean", true)}, + {"color", field("color", {1, 1, 1, 1})}, + {"intensity", Json{{"type", "number"}, {"default", 1.0}, {"min", 0.0}}}, + {"range", Json{{"type", "number"}, {"default", 10.0}, {"min", 0.001}}}, + {"inner_angle", Json{{"type", "number"}, + {"default", 0.35}, {"min", 0.0}, {"max", 1.55}, + {"unit", "radians"}}}, + {"outer_angle", Json{{"type", "number"}, + {"default", 0.7}, {"min", 0.001}, {"max", 1.55}, + {"unit", "radians"}}}, + {"casts_shadow", field("boolean", true)}, + {"shadow_priority", field("integer", 0)}}); for (int dimension : {2, 3}) { Json vector = dimension == 2 ? Json{0, 0} : Json{0, 0, 0}; Json extents = dimension == 2 ? Json{0.5, 0.5} : Json{0.5, 0.5, 0.5}; diff --git a/src/player/SceneView.cpp b/src/player/SceneView.cpp index 103f975..12a206d 100644 --- a/src/player/SceneView.cpp +++ b/src/player/SceneView.cpp @@ -61,6 +61,16 @@ render::Vec3 direction(const render::Mat4& m, render::Vec3 p) { return {m[0] * p[0] + m[4] * p[1] + m[8] * p[2], m[1] * p[0] + m[5] * p[1] + m[9] * p[2], m[2] * p[0] + m[6] * p[1] + m[10] * p[2]}; } +render::Vec3 normalized(render::Vec3 value, const std::string& entityId, + std::string_view field) { + const auto length = std::hypot(value[0], value[1], value[2]); + if (!std::isfinite(length) || length < 1e-6f) + throw std::invalid_argument("Light on entity " + entityId + " has invalid " + + std::string(field)); + for (auto& axis : value) + axis /= length; + return value; +} std::pair reference(const std::string& ref) { const auto hash = ref.find('#'); return {ref.substr(0, hash), hash == std::string::npos ? std::string{} : ref.substr(hash + 1)}; @@ -263,8 +273,12 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting const auto id = entity.at("id").get(); if (!byId.emplace(id, &entity).second) throw std::invalid_argument("Duplicate scene ID"); + if (!entity.contains("components") && entity.contains("light")) + out.authored_lights_present = true; for (const auto& component : entity.value("components", Json::array())) { const auto type = component.at("type").get(); + if (type == "faset.light") + out.authored_lights_present = true; if (component.value("version", 1) != 1 && (type == "faset.transform" || type == "faset.sprite" || type == "faset.mesh" || type == "faset.camera" || type == "faset.light")) @@ -301,8 +315,15 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting render::Vec3 cameraUp{0, 1, 0}; bool foundCamera = false; std::vector> sprites; + std::vector directionalLights; for (const auto& entity : entities) { - const auto model = world(world, entity); + const auto id = entity.at("id").get(); + render::Mat4 model; + try { + model = world(world, entity); + } catch (const std::exception& error) { + throw std::invalid_argument("Entity " + id + " transform: " + error.what()); + } if (auto fields = properties(entity, "camera"); !fields.is_null() && !camera.overrideSceneCamera && !foundCamera) { camera.eye = point(model, {0, 0, 0}); @@ -314,8 +335,89 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting foundCamera = true; camera_id = entity.at("id").get(); } - if (auto fields = properties(entity, "light"); !fields.is_null()) - out.light_direction = direction(model, {-0.5f, -1, -0.3f}); + if (auto fields = properties(entity, "light"); !fields.is_null()) { + auto invalid = [&](std::string_view field) -> void { + throw std::invalid_argument("Light on entity " + id + " has invalid " + + std::string(field)); + }; + for (const auto entry : model) + if (!std::isfinite(entry)) + invalid("transform"); + auto number = [&](const char* field, float fallback) { + if (!fields.contains(field)) + return fallback; + const auto& value = fields.at(field); + if (!value.is_number()) + invalid(field); + const auto decimal = value.get(); + if (!std::isfinite(decimal) || + std::abs(decimal) > std::numeric_limits::max()) + invalid(field); + return static_cast(decimal); + }; + auto boolean = [&](const char* field, bool fallback) { + if (!fields.contains(field)) + return fallback; + if (!fields.at(field).is_boolean()) + invalid(field); + return fields.at(field).get(); + }; + const auto enabled = boolean("enabled", true); + if (enabled) { + if (fields.contains("kind") && !fields.at("kind").is_string()) + invalid("kind"); + const auto kind = fields.value("kind", std::string("directional")); + if (kind != "directional" && kind != "point" && kind != "spot") + invalid("kind"); + render::Color color; + try { + color = vec<4>(fields, "color", {1, 1, 1, 1}); + } catch (const std::exception&) { + invalid("color"); + } + for (const auto channel : color) + if (channel < 0) + invalid("color"); + const auto intensity = number("intensity", 1); + if (intensity < 0) + invalid("intensity"); + const auto castsShadow = boolean("casts_shadow", true); + const auto stableId = id; + if (kind == "directional") { + directionalLights.push_back({stableId, + normalized(direction(model, {-0.5f, -1, -0.3f}), id, + "direction"), + color, intensity, castsShadow}); + } else { + render::LocalLight local; + local.kind = kind == "point" ? render::LocalLight::Kind::Point + : render::LocalLight::Kind::Spot; + local.stable_id = stableId; + local.position = point(model, {0, 0, 0}); + for (const auto coordinate : local.position) + if (!std::isfinite(coordinate)) + invalid("position"); + local.direction = normalized(direction(model, {0, 0, -1}), id, "direction"); + local.color = color; + local.intensity = intensity; + local.range = number("range", 10); + if (local.range <= 0) + invalid("range"); + local.inner_angle = number("inner_angle", 0.35f); + local.outer_angle = number("outer_angle", 0.7f); + if (local.inner_angle < 0 || local.inner_angle > local.outer_angle || + local.outer_angle <= 0 || local.outer_angle >= std::numbers::pi_v / 2) + invalid("inner_angle/outer_angle"); + local.casts_shadow = castsShadow; + if (fields.contains("shadow_priority")) { + if (!fields.at("shadow_priority").is_number_integer()) + invalid("shadow_priority"); + local.shadow_priority = fields.at("shadow_priority").get(); + } + out.local_lights.push_back(std::move(local)); + } + } + } if (auto fields = properties(entity, "sprite"); !fields.is_null()) { render::Sprite sprite; sprite.layer = fields.value("layer", 0); @@ -372,6 +474,18 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting [](const auto& a, const auto& b) { return a.first < b.first; }); for (auto& pair : sprites) out.sprites.push_back(std::move(pair.second)); + std::sort(directionalLights.begin(), directionalLights.end(), + [](const auto& a, const auto& b) { return a.stable_id < b.stable_id; }); + std::sort(out.local_lights.begin(), out.local_lights.end(), + [](const auto& a, const auto& b) { return a.stable_id < b.stable_id; }); + if (!directionalLights.empty()) { + out.sun = directionalLights.front(); + out.light_direction = out.sun->direction; + if (directionalLights.size() > 1) + impl_->messages.push_back("warning: multiple enabled directional lights; using " + + out.sun->stable_id + " and ignoring " + + std::to_string(directionalLights.size() - 1) + " others"); + } if (dimension == 2) { const float height = camera.orthographicHeight; if (!std::isfinite(height) || height <= 0) @@ -408,8 +522,10 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting out.projection = render::perspective( camera.verticalFovDegrees * std::numbers::pi_v / 180, aspect, camera.nearPlane, camera.farPlane); - out.view_projection = render::multiply( - out.projection, render::look_at(camera.eye, camera.target, cameraUp)); + const auto view = render::look_at(camera.eye, camera.target, cameraUp); + out.view_projection = render::multiply(out.projection, view); + out.camera_frustum = render::CameraFrustum{view, out.projection, camera.nearPlane, + camera.farPlane, true}; } std::sort(impl_->messages.begin(), impl_->messages.end()); impl_->messages.erase(std::unique(impl_->messages.begin(), impl_->messages.end()), diff --git a/tests/authoring_tests.cpp b/tests/authoring_tests.cpp index 05b5e6d..a300f35 100644 --- a/tests/authoring_tests.cpp +++ b/tests/authoring_tests.cpp @@ -25,6 +25,25 @@ 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"); AuthoringService service(root, schemas); auto created = service.create("Courtyard", 3); const std::string id = created["id"]; diff --git a/tests/runtime_player_tests.cpp b/tests/runtime_player_tests.cpp index ef285a1..2d94349 100644 --- a/tests/runtime_player_tests.cpp +++ b/tests/runtime_player_tests.cpp @@ -1,10 +1,12 @@ #include +#include #include #include #include #include #include #include +#include #include #include @@ -23,12 +25,125 @@ template void rejects(F&& function, const char* message) { } check(caught, message); } +template +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"}, + {"color", Json::array({1, std::numeric_limits::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::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 +274,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");