Expose authored sun, point, and spot lights in render snapshots

This commit is contained in:
Emil
2026-09-24 01:35:20 +03:00
parent bf89ba6f3d
commit 674e3e812b
7 changed files with 311 additions and 13 deletions
@@ -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<SunLight> Snapshot::sun`, `std::vector<LocalLight> Snapshot::local_lights`, and `std::optional<CameraFrustum> 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<SunLight> Snapshot::sun`, `std::vector<LocalLight> Snapshot::local_lights`, `bool Snapshot::authored_lights_present` (default false), and `std::optional<CameraFrustum> 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`.
@@ -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.
+34
View File
@@ -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<SunLight> sun{};
std::vector<LocalLight> local_lights;
std::optional<CameraFrustum> camera_frustum{};
};
enum class VisibilityMode { Direct, GpuFrustum, GpuOcclusion };
// CPU-only validation used before publishing a game or creating Vulkan pipelines.
+16 -3
View File
@@ -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};
+121 -5
View File
@@ -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<std::string, std::string> 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<std::string>();
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<std::string>();
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<std::pair<int, render::Sprite>> sprites;
std::vector<render::SunLight> directionalLights;
for (const auto& entity : entities) {
const auto model = world(world, entity);
const auto id = entity.at("id").get<std::string>();
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<std::string>();
}
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<double>();
if (!std::isfinite(decimal) ||
std::abs(decimal) > std::numeric_limits<float>::max())
invalid(field);
return static_cast<float>(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<bool>();
};
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<float> / 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<int>();
}
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<float> / 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()),
+19
View File
@@ -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"];
+116
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,125 @@ 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"},
{"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 +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");