From f8cd73b95fc39a999a19d74e8c379d1df08a1be3 Mon Sep 17 00:00:00 2001 From: Emil <65846814+emil28092005@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:15:15 +0300 Subject: [PATCH] Introduce stable render instances and prepared LOD policy --- CMakeLists.txt | 1 + cmake/Visibility.cmake | 9 ++ include/faset/render/renderer.hpp | 8 ++ include/faset/render/visibility.hpp | 77 ++++++++++ src/player/SceneView.cpp | 51 +++++-- src/render/visibility.cpp | 174 +++++++++++++++++++++++ tests/render_visibility_policy_tests.cpp | 135 ++++++++++++++++++ tests/runtime_player_tests.cpp | 20 +++ 8 files changed, 460 insertions(+), 15 deletions(-) create mode 100644 cmake/Visibility.cmake create mode 100644 include/faset/render/visibility.hpp create mode 100644 src/render/visibility.cpp create mode 100644 tests/render_visibility_policy_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5707350..fd33004 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -59,6 +59,7 @@ if(FASET_ENABLE_LUA AND TARGET faset_runtime) endif() if(FASET_BUILD_RENDERER AND EXISTS "${PROJECT_SOURCE_DIR}/cmake/Renderer.cmake") include(cmake/Renderer.cmake) + include(cmake/Visibility.cmake) endif() if(TARGET faset_gameplay AND EXISTS "${PROJECT_SOURCE_DIR}/cmake/Player.cmake") include(cmake/Player.cmake) diff --git a/cmake/Visibility.cmake b/cmake/Visibility.cmake new file mode 100644 index 0000000..125f1c0 --- /dev/null +++ b/cmake/Visibility.cmake @@ -0,0 +1,9 @@ +if(TARGET faset_render) + target_sources(faset_render PRIVATE "${PROJECT_SOURCE_DIR}/src/render/visibility.cpp") +endif() +if(TARGET faset_render AND BUILD_TESTING) + add_executable(faset_render_visibility_policy_tests + "${PROJECT_SOURCE_DIR}/tests/render_visibility_policy_tests.cpp") + target_link_libraries(faset_render_visibility_policy_tests PRIVATE faset_render) + add_test(NAME visibility_policy COMMAND faset_render_visibility_policy_tests) +endif() diff --git a/include/faset/render/renderer.hpp b/include/faset/render/renderer.hpp index 56029ca..75e8544 100644 --- a/include/faset/render/renderer.hpp +++ b/include/faset/render/renderer.hpp @@ -39,6 +39,11 @@ struct DrawItem { float metallic{0.0f}; bool cast_shadow{true}; std::shared_ptr texture{}; + // Empty means an ad-hoc draw without temporal visibility state. Scene extraction + // derives this from persistent object and primitive identities, not draw order. + std::string instance_key{}; + // Optional prepared coarser meshes: element zero is LOD 1; mesh is LOD 0. + std::vector> lod_meshes{}; }; struct Sprite { Vec3 position{}; @@ -88,6 +93,9 @@ struct Snapshot { std::vector ui_quads; std::vector ui_text; std::vector ui_triangles; + // Distinguishes temporal histories when one Renderer displays different views. + std::string view_id{}; + bool camera_cut{}; }; // CPU-only validation used before publishing a game or creating Vulkan pipelines. void validate_shader_bundle(const std::filesystem::path& directory); diff --git a/include/faset/render/visibility.hpp b/include/faset/render/visibility.hpp new file mode 100644 index 0000000..ac01b8b --- /dev/null +++ b/include/faset/render/visibility.hpp @@ -0,0 +1,77 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace faset::render { +struct Bounds { + Vec3 min{}; + Vec3 max{}; +}; + +// Mesh bounds are computed from all vertices; world bounds transform all eight +// corners of the local AABB, including rotation, reflection and nonuniform scale. +// Empty or nonfinite geometry/matrices are rejected instead of being culled. +Bounds local_bounds(const Mesh& mesh); +Bounds transformed_bounds(const Mesh& mesh, const Mat4& model); + +struct InstanceUpdate { + std::uint32_t slot{}; + std::uint64_t generation{}; + Mat4 previous_model{identity}; + Bounds previous_bounds{}; + bool previous_valid{}; +}; + +// One update per logical instance per rendered frame. finish_frame() promotes the +// current state to *rendered* history and retires keys absent from that frame. +// Reordering the input never renumbers live instances. Mesh changes retain the +// slot but advance its generation; slot reuse also advances its generation. +class InstanceTracker { + public: + // Prefer this overload for engine-owned meshes. Retaining the owner prevents + // allocator address reuse from impersonating the previously rendered mesh. + InstanceUpdate update(std::string_view key, std::shared_ptr mesh_identity, + const Mat4& model, const Bounds& world_bounds, + std::string_view view_id); + // Non-owning overload for meshes whose lifetime is guaranteed by the caller. + InstanceUpdate update(std::string_view key, const Mesh* mesh_identity, const Mat4& model, + const Bounds& world_bounds, std::string_view view_id); + void finish_frame(); + void invalidate_view(std::string_view view_id); + + private: + struct Record { + std::uint32_t slot{}; + std::uint64_t generation{}; + const Mesh* mesh{}; + std::shared_ptr mesh_owner{}; + Mat4 current_model{identity}, previous_model{identity}; + Bounds current_bounds{}, previous_bounds{}; + std::string current_view, previous_view; + std::uint64_t seen_frame{}; + bool committed{}; + }; + std::unordered_map records_; + std::vector free_slots_; + std::vector slot_generations_; + std::unordered_set invalid_views_; + std::uint64_t frame_{1}; +}; + +// thresholds[i] is the projected-pixel boundary between levels i and i+1, +// ordered strictly high-to-low. Moving toward a coarser level requires size < +// threshold*(1-hysteresis); toward a finer level requires size > +// threshold*(1+hysteresis). SIZE_MAX means no prior level and uses raw thresholds. +// available is optional; when given it has thresholds.size()+1 entries, with 0 +// marking missing prepared geometry. Nearest available wins; ties prefer finer. +std::size_t select_lod(float projected_pixels, std::size_t previous_level, + std::span thresholds, float hysteresis, + std::span available = {}); +} // namespace faset::render diff --git a/src/player/SceneView.cpp b/src/player/SceneView.cpp index 29c4a81..570e25f 100644 --- a/src/player/SceneView.cpp +++ b/src/player/SceneView.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #if defined(FASET_HAS_STB) #define STB_IMAGE_IMPLEMENTATION @@ -16,6 +17,15 @@ namespace faset::player { namespace { using Json = nlohmann::json; +std::string render_key(std::initializer_list parts) { + std::string key; + for (const auto part : parts) { + key += std::to_string(part.size()); + key += ':'; + key += part; + } + return key; +} Json properties(const Json& entity, const std::string& name) { if (entity.contains("components")) { for (const auto& component : entity["components"]) @@ -144,10 +154,11 @@ struct SceneView::Impl { throw std::runtime_error("Texture subasset does not exist: " + ref); } void imported(render::Snapshot& out, const std::string& ref, const render::Mat4& model, - render::Color tint) { + render::Color tint, std::string_view entity_id) { const auto [id, selector] = reference(ref); auto& asset = bundle(id); - auto emit = [&](std::size_t meshIndex, const render::Mat4& local) { + auto emit = [&](std::size_t meshIndex, const render::Mat4& local, + std::string_view node_id) { if (meshIndex >= asset.meshes.size()) throw std::runtime_error("Invalid cooked mesh index"); for (std::size_t p = 0; p < asset.meshes[meshIndex].size(); ++p) { @@ -155,6 +166,10 @@ struct SceneView::Impl { draw.mesh = asset.meshes[meshIndex][p]; draw.model = render::multiply(model, local); draw.color = tint; + const auto primitive_id = std::to_string(p); + draw.instance_key = render_key( + {entity_id, "asset", ref, node_id, asset.data.meshes[meshIndex].id, + primitive_id}); const auto material = asset.data.meshes[meshIndex].primitives[p].material; if (material >= 0) { if (std::size_t(material) >= asset.data.materials.size()) @@ -181,7 +196,7 @@ struct SceneView::Impl { if (!selector.empty()) for (std::size_t i = 0; i < asset.data.meshes.size(); ++i) if (asset.data.meshes[i].id == selector) { - emit(i, render::identity); + emit(i, render::identity, "direct-mesh"); return; } std::unordered_map nodes; @@ -208,7 +223,7 @@ struct SceneView::Impl { }; if (asset.data.nodes.empty()) for (std::size_t i = 0; i < asset.meshes.size(); ++i) - emit(i, render::identity); + emit(i, render::identity, "unparented-mesh"); for (const auto& node : asset.data.nodes) if (node.mesh >= 0) { bool selected = selector.empty(); @@ -220,7 +235,7 @@ struct SceneView::Impl { current = parent == nodes.end() ? nullptr : parent->second; } if (selected) - emit(static_cast(node.mesh), world(world, node)); + emit(static_cast(node.mesh), world(world, node), node.id); } } }; @@ -238,6 +253,8 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting throw std::invalid_argument("Viewport aspect must be positive"); impl_->messages.clear(); render::Snapshot out; + const auto scene_id = scene.value("id", std::string{}); + std::string camera_id = camera.overrideSceneCamera ? "override" : "default"; const auto& entities = scene.at("entities"); if (!entities.is_array()) throw std::invalid_argument("Scene entities must be an array"); @@ -295,6 +312,7 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting camera.nearPlane = fields.value("near", 0.1f); camera.farPlane = fields.value("far", 1000.0f); 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}); @@ -331,20 +349,22 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting : asset.substr(8); if (primitive != "plane" && primitive != "cube") throw std::invalid_argument("Unsupported builtin mesh: " + primitive); - out.draws.push_back({primitive == "plane" ? plane() : render::cube_mesh(), - model, - tint, - 0.65f, - 0.0f, - true, - {}}); + render::DrawItem draw{primitive == "plane" ? plane() : render::cube_mesh(), + model, tint, 0.65f, 0.0f, true, {}}; + const auto entity_id = entity.at("id").get(); + draw.instance_key = render_key({entity_id, "builtin", primitive}); + out.draws.push_back(std::move(draw)); } else try { - impl_->imported(out, asset, model, tint); + impl_->imported(out, asset, model, tint, + entity.at("id").get()); } catch (const std::exception& e) { impl_->messages.push_back("error: " + std::string(e.what())); - out.draws.push_back( - {render::cube_mesh(), model, {1, 0, 1, 1}, 0.65f, 0.0f, true, {}}); + render::DrawItem draw{render::cube_mesh(), model, {1, 0, 1, 1}, + 0.65f, 0.0f, true, {}}; + const auto entity_id = entity.at("id").get(); + draw.instance_key = render_key({entity_id, "error", asset}); + out.draws.push_back(std::move(draw)); } } } @@ -393,6 +413,7 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting std::sort(impl_->messages.begin(), impl_->messages.end()); impl_->messages.erase(std::unique(impl_->messages.begin(), impl_->messages.end()), impl_->messages.end()); + out.view_id = render_key({scene_id, "camera", camera_id}); return out; } void SceneView::appendPhysicsDebug(render::Snapshot& snapshot, const Json& scene, diff --git a/src/render/visibility.cpp b/src/render/visibility.cpp new file mode 100644 index 0000000..7331fbc --- /dev/null +++ b/src/render/visibility.cpp @@ -0,0 +1,174 @@ +#include +#include +#include +#include +#include + +namespace faset::render { +namespace { +void validate(const Bounds& bounds) { + for (int axis = 0; axis < 3; ++axis) + if (!std::isfinite(bounds.min[axis]) || !std::isfinite(bounds.max[axis]) || + bounds.min[axis] > bounds.max[axis]) + throw std::invalid_argument("Invalid mesh bounds"); +} +} // namespace + +Bounds local_bounds(const Mesh& mesh) { + if (mesh.vertices.empty()) + throw std::invalid_argument("Empty mesh has no bounds"); + Bounds out{{std::numeric_limits::infinity(), std::numeric_limits::infinity(), + std::numeric_limits::infinity()}, + {-std::numeric_limits::infinity(), -std::numeric_limits::infinity(), + -std::numeric_limits::infinity()}}; + for (const auto& vertex : mesh.vertices) + for (int axis = 0; axis < 3; ++axis) { + const float value = vertex.position[axis]; + if (!std::isfinite(value)) + throw std::invalid_argument("Nonfinite mesh vertex"); + out.min[axis] = std::min(out.min[axis], value); + out.max[axis] = std::max(out.max[axis], value); + } + return out; +} + +Bounds transformed_bounds(const Mesh& mesh, const Mat4& model) { + const auto local = local_bounds(mesh); + for (float value : model) + if (!std::isfinite(value)) + throw std::invalid_argument("Nonfinite mesh transform"); + Bounds out{{std::numeric_limits::infinity(), std::numeric_limits::infinity(), + std::numeric_limits::infinity()}, + {-std::numeric_limits::infinity(), -std::numeric_limits::infinity(), + -std::numeric_limits::infinity()}}; + for (unsigned corner = 0; corner < 8; ++corner) { + const Vec3 point{corner & 1 ? local.max[0] : local.min[0], + corner & 2 ? local.max[1] : local.min[1], + corner & 4 ? local.max[2] : local.min[2]}; + for (int axis = 0; axis < 3; ++axis) { + const float transformed = model[12 + axis] + model[axis] * point[0] + + model[4 + axis] * point[1] + model[8 + axis] * point[2]; + if (!std::isfinite(transformed)) + throw std::invalid_argument("Nonfinite transformed mesh bound"); + out.min[axis] = std::min(out.min[axis], transformed); + out.max[axis] = std::max(out.max[axis], transformed); + } + } + // Float transforms can round an extremum inward by one ULP. Expand outward + // before using the result for a visibility rejection. + for (int axis = 0; axis < 3; ++axis) { + out.min[axis] = std::nextafter(out.min[axis], -std::numeric_limits::infinity()); + out.max[axis] = std::nextafter(out.max[axis], std::numeric_limits::infinity()); + } + return out; +} + +InstanceUpdate InstanceTracker::update(std::string_view key, + std::shared_ptr mesh_identity, + const Mat4& model, const Bounds& world_bounds, + std::string_view view_id) { + auto result = update(key, mesh_identity.get(), model, world_bounds, view_id); + records_.at(std::string(key)).mesh_owner = std::move(mesh_identity); + return result; +} + +InstanceUpdate InstanceTracker::update(std::string_view key, const Mesh* mesh_identity, + const Mat4& model, const Bounds& world_bounds, + std::string_view view_id) { + if (key.empty() || !mesh_identity) + throw std::invalid_argument("Tracked instance requires a key and mesh"); + validate(world_bounds); + for (float value : model) + if (!std::isfinite(value)) + throw std::invalid_argument("Nonfinite instance transform"); + + auto [it, inserted] = records_.try_emplace(std::string(key)); + auto& record = it->second; + if (inserted) { + if (free_slots_.empty()) { + if (slot_generations_.size() >= std::numeric_limits::max()) + throw std::overflow_error("Instance slot capacity exhausted"); + record.slot = static_cast(slot_generations_.size()); + slot_generations_.push_back(1); + } else { + record.slot = free_slots_.back(); + free_slots_.pop_back(); + ++slot_generations_[record.slot]; + } + record.generation = slot_generations_[record.slot]; + } else if (record.mesh != mesh_identity) { + record.generation = ++slot_generations_[record.slot]; + record.committed = false; + } + + const bool previous_valid = record.committed && record.previous_view == view_id && + !invalid_views_.contains(std::string(view_id)); + InstanceUpdate result{record.slot, record.generation, record.previous_model, + record.previous_bounds, previous_valid}; + record.mesh = mesh_identity; + record.mesh_owner.reset(); + record.current_model = model; + record.current_bounds = world_bounds; + record.current_view = view_id; + record.seen_frame = frame_; + return result; +} + +void InstanceTracker::finish_frame() { + for (auto it = records_.begin(); it != records_.end();) { + auto& record = it->second; + if (record.seen_frame != frame_) { + free_slots_.push_back(record.slot); + it = records_.erase(it); + } else { + record.previous_model = record.current_model; + record.previous_bounds = record.current_bounds; + record.previous_view = record.current_view; + record.committed = true; + ++it; + } + } + invalid_views_.clear(); + ++frame_; +} + +void InstanceTracker::invalidate_view(std::string_view view_id) { + invalid_views_.emplace(view_id); +} + +std::size_t select_lod(float projected_pixels, std::size_t previous_level, + std::span thresholds, float hysteresis, + std::span available) { + if (!std::isfinite(projected_pixels) || projected_pixels < 0 || + !std::isfinite(hysteresis) || hysteresis < 0 || hysteresis >= 1) + throw std::invalid_argument("Invalid LOD projection or hysteresis"); + for (std::size_t i = 0; i < thresholds.size(); ++i) + if (!std::isfinite(thresholds[i]) || thresholds[i] <= 0 || + (i && thresholds[i] >= thresholds[i - 1])) + throw std::invalid_argument("LOD thresholds must descend strictly"); + const auto count = thresholds.size() + 1; + if (!available.empty() && available.size() != count) + throw std::invalid_argument("LOD availability count mismatch"); + std::size_t level = previous_level; + if (level >= count) { + level = 0; + while (level < thresholds.size() && projected_pixels < thresholds[level]) + ++level; + } else { + while (level < thresholds.size() && + projected_pixels < thresholds[level] * (1 - hysteresis)) + ++level; + while (level && projected_pixels > thresholds[level - 1] * (1 + hysteresis)) + --level; + } + if (available.empty() || available[level]) + return level; + for (std::size_t distance = 1; distance < count; ++distance) { + if (level >= distance && available[level - distance]) + return level - distance; + if (level + distance < count && available[level + distance]) + return level + distance; + } + throw std::invalid_argument("No available mesh LOD"); +} +} // namespace faset::render diff --git a/tests/render_visibility_policy_tests.cpp b/tests/render_visibility_policy_tests.cpp new file mode 100644 index 0000000..cf7c674 --- /dev/null +++ b/tests/render_visibility_policy_tests.cpp @@ -0,0 +1,135 @@ +#include +#include +#include +#include +#include +#include +#include + +using namespace faset::render; +namespace { +void require(bool condition, const char* message) { + if (!condition) + throw std::runtime_error(message); +} +bool near(float actual, float expected) { + return std::abs(actual - expected) < 0.0001f; +} +template void rejects(F&& action, const char* message) { + bool rejected = false; + try { + action(); + } catch (const std::invalid_argument&) { + rejected = true; + } + require(rejected, message); +} +void bounds_cover_rotated_and_reflected_mesh() { + Mesh mesh; + mesh.vertices = {{{0, 0, 0}}, {{2, 0, 0}}, {{0, 1, 0}}, {{0, 0, 1}}, {{2, 1, 1}}}; + const auto model = transform({5, 7, 11}, {0, 0, 1.57079632679f}, {-2, 3, 4}); + const auto box = transformed_bounds(mesh, model); + require(near(box.min[0], 2) && near(box.max[0], 5) && near(box.min[1], 3) && + near(box.max[1], 7) && near(box.min[2], 11) && near(box.max[2], 15), + "Rotated and reflected mesh must have an enclosing world AABB"); + mesh.vertices[0].position[0] = std::numeric_limits::infinity(); + rejects([&] { transformed_bounds(mesh, model); }, + "Nonfinite geometry cannot produce a conservative culling bound"); +} +void ids_track_rendered_frames_and_generation() { + auto meshA = std::make_shared(); + auto meshB = std::make_shared(); + const Bounds box{{-1, -1, -1}, {1, 1, 1}}; + const auto moved = transform({3, 0, 0}); + InstanceTracker tracker; + const auto a0 = tracker.update("a", meshA.get(), identity, box, "game"); + const auto b0 = tracker.update("b", meshA.get(), identity, box, "game"); + require(a0.slot != b0.slot && !a0.previous_valid && !b0.previous_valid, + "New instances have unique slots and no temporal state"); + const auto duplicate = tracker.update("a", meshA.get(), moved, box, "game"); + require(!duplicate.previous_valid, + "An update before frame completion cannot become previous rendered state"); + tracker.finish_frame(); + + const auto b1 = tracker.update("b", meshA.get(), moved, box, "game"); + const auto a1 = tracker.update("a", meshA.get(), moved, box, "game"); + require(a1.slot == a0.slot && b1.slot == b0.slot && a1.previous_valid && + b1.previous_valid && a1.previous_model == moved && b1.previous_model == identity, + "Reordering does not change slots or the last rendered transforms"); + tracker.finish_frame(); + + const auto changed = tracker.update("b", meshB.get(), moved, box, "game"); + require(changed.slot == b0.slot && changed.generation != b0.generation && + !changed.previous_valid, + "Replacing mesh identity invalidates temporal state and bumps generation"); + tracker.finish_frame(); // a was absent and is now retired. + const auto c0 = tracker.update("c", meshA.get(), identity, box, "game"); + require(c0.slot == a0.slot && c0.generation != a0.generation && !c0.previous_valid, + "Reused slots cannot inherit a removed object's history"); + tracker.finish_frame(); + const auto c1 = tracker.update("c", meshA.get(), identity, box, "game"); + require(c1.previous_valid, "Same instance in a completed second frame may use history"); + tracker.finish_frame(); + tracker.invalidate_view("game"); + const auto cut = tracker.update("c", meshA.get(), identity, box, "game"); + require(!cut.previous_valid, "Camera cut invalidates view history"); + tracker.finish_frame(); + const auto switched = tracker.update("c", meshA.get(), identity, box, "editor"); + require(!switched.previous_valid, "Different viewport cannot reuse another view's history"); +} +void owned_mesh_identity_outlives_reimport_gap() { + InstanceTracker tracker; + auto old_mesh = std::make_shared(); + std::weak_ptr old_reference = old_mesh; + const Bounds box{{-1, -1, -1}, {1, 1, 1}}; + const auto first = tracker.update("imported", old_mesh, identity, box, "game"); + tracker.finish_frame(); + old_mesh.reset(); + require(!old_reference.expired(), + "Tracker must retain mesh identity until the instance is retired or replaced"); + auto replacement_mesh = std::make_shared(); + const auto replaced = tracker.update("imported", replacement_mesh, identity, box, "game"); + require(replaced.generation != first.generation && !replaced.previous_valid, + "Reimported mesh cannot reuse the previous mesh's occlusion history"); +} +void lod_thresholds_have_hysteresis_and_fallback() { + const std::array thresholds{100.f, 25.f}; + require(select_lod(95, 0, thresholds, .1f) == 0 && + select_lod(89, 0, thresholds, .1f) == 1, + "LOD downgrade waits for the lower hysteresis edge"); + require(select_lod(105, 1, thresholds, .1f) == 1 && + select_lod(111, 1, thresholds, .1f) == 0, + "LOD upgrade waits for the upper hysteresis edge"); + require(select_lod(24, 1, thresholds, .1f) == 1 && + select_lod(22, 1, thresholds, .1f) == 2 && + select_lod(27, 2, thresholds, .1f) == 2 && + select_lod(28, 2, thresholds, .1f) == 1, + "Second threshold has the same hysteresis contract"); + require(select_lod(50, std::numeric_limits::max(), thresholds, .1f) == 1, + "An instance without a previous level chooses its size without hysteresis"); + const std::array sparse{1, 0, 1}; + require(select_lod(50, 0, thresholds, 0, sparse) == 0, + "Missing requested LOD prefers the nearest available finer level"); + const std::array coarse_only{0, 0, 1}; + require(select_lod(500, 0, thresholds, 0, coarse_only) == 2, + "Missing fine LODs fall back to available coarse geometry"); + const std::array none{0, 0, 0}; + rejects([&] { select_lod(50, 0, thresholds, .1f, none); }, + "No available mesh is a content error"); + rejects([&] { select_lod(std::numeric_limits::quiet_NaN(), 0, thresholds, .1f); }, + "Nonfinite projected size cannot silently select a level"); +} +} // namespace +int main() { + try { + bounds_cover_rotated_and_reflected_mesh(); + ids_track_rendered_frames_and_generation(); + owned_mesh_identity_outlives_reimport_gap(); + lod_thresholds_have_hysteresis_and_fallback(); + std::cout << "Visibility bounds, instance identity and LOD policy passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/runtime_player_tests.cpp b/tests/runtime_player_tests.cpp index d1a5328..ef285a1 100644 --- a/tests/runtime_player_tests.cpp +++ b/tests/runtime_player_tests.cpp @@ -162,6 +162,22 @@ void run() { physicsDebug(view); auto snapshot = view.build(scene, 16.f / 9.f); check(snapshot.draws.size() == 1, "SceneView builtin mesh"); + check(!snapshot.draws[0].instance_key.empty(), + "Persistent scene entity gives its builtin mesh a stable render key"); + const auto builtin_key = snapshot.draws[0].instance_key; + auto second_mesh = scene; + second_mesh["entities"].push_back( + entity("other-mesh", nullptr, + Json::array({component("faset.mesh", {{"asset", "builtin:cube"}})}))); + const auto two_meshes = view.build(second_mesh, 1); + check(two_meshes.draws.size() == 2 && two_meshes.draws[0].instance_key == builtin_key && + two_meshes.draws[1].instance_key != builtin_key, + "A second instance of the same mesh has a distinct key without renumbering the first"); + std::swap(second_mesh["entities"][1], second_mesh["entities"][2]); + const auto reordered_meshes = view.build(second_mesh, 1); + check(reordered_meshes.draws[0].instance_key == two_meshes.draws[1].instance_key && + reordered_meshes.draws[1].instance_key == builtin_key, + "Render instance identity follows entity identity, not draw order"); check(snapshot.draws[0].model[12] == 3 && snapshot.draws[0].model[13] == 2 && snapshot.draws[0].model[14] == 3, "hierarchy local transforms composed"); @@ -269,6 +285,10 @@ void run() { check(view.diagnostics().empty(), "valid cooked texture/material produces no error"); check(snapshot.draws.size() == 1 && snapshot.draws[0].mesh->vertices.size() == 3, "cooked mesh reaches render snapshot"); + check(!snapshot.draws[0].instance_key.empty() && + snapshot.draws[0].instance_key != builtin_key && + view.build(importedScene, 1).draws[0].instance_key == snapshot.draws[0].instance_key, + "Imported primitive identity is stable across scene extraction"); check(snapshot.draws[0].model[12] == 5, "asset node transform composed with scene hierarchy"); check(snapshot.draws[0].texture && snapshot.draws[0].texture->srgb && snapshot.draws[0].texture->rgba == std::vector({240, 80, 20, 255}),