Expose authored sun, point, and spot lights in render snapshots
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user