Introduce stable render instances and prepared LOD policy
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -39,6 +39,11 @@ struct DrawItem {
|
||||
float metallic{0.0f};
|
||||
bool cast_shadow{true};
|
||||
std::shared_ptr<const Texture> 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<std::shared_ptr<const Mesh>> lod_meshes{};
|
||||
};
|
||||
struct Sprite {
|
||||
Vec3 position{};
|
||||
@@ -88,6 +93,9 @@ struct Snapshot {
|
||||
std::vector<Quad> ui_quads;
|
||||
std::vector<Text> ui_text;
|
||||
std::vector<UiTriangles> 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);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#pragma once
|
||||
#include <faset/render/renderer.hpp>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
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<const Mesh> 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<const Mesh> 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<std::string, Record> records_;
|
||||
std::vector<std::uint32_t> free_slots_;
|
||||
std::vector<std::uint64_t> slot_generations_;
|
||||
std::unordered_set<std::string> 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<const float> thresholds, float hysteresis,
|
||||
std::span<const std::uint8_t> available = {});
|
||||
} // namespace faset::render
|
||||
+36
-15
@@ -6,6 +6,7 @@
|
||||
#include <numbers>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#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<std::string_view> 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<std::string, const assets::Node*> 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<std::size_t>(node.mesh), world(world, node));
|
||||
emit(static_cast<std::size_t>(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<std::string>();
|
||||
}
|
||||
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<std::string>();
|
||||
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<std::string>());
|
||||
} 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<std::string>();
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#include <faset/render/visibility.hpp>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
|
||||
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<float>::infinity(), std::numeric_limits<float>::infinity(),
|
||||
std::numeric_limits<float>::infinity()},
|
||||
{-std::numeric_limits<float>::infinity(), -std::numeric_limits<float>::infinity(),
|
||||
-std::numeric_limits<float>::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<float>::infinity(), std::numeric_limits<float>::infinity(),
|
||||
std::numeric_limits<float>::infinity()},
|
||||
{-std::numeric_limits<float>::infinity(), -std::numeric_limits<float>::infinity(),
|
||||
-std::numeric_limits<float>::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<float>::infinity());
|
||||
out.max[axis] = std::nextafter(out.max[axis], std::numeric_limits<float>::infinity());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
InstanceUpdate InstanceTracker::update(std::string_view key,
|
||||
std::shared_ptr<const Mesh> 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<std::uint32_t>::max())
|
||||
throw std::overflow_error("Instance slot capacity exhausted");
|
||||
record.slot = static_cast<std::uint32_t>(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<const float> thresholds, float hysteresis,
|
||||
std::span<const std::uint8_t> 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
|
||||
@@ -0,0 +1,135 @@
|
||||
#include <faset/render/renderer.hpp>
|
||||
#include <faset/render/visibility.hpp>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
|
||||
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 <class F> 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<float>::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<Mesh>();
|
||||
auto meshB = std::make_shared<Mesh>();
|
||||
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<Mesh>();
|
||||
std::weak_ptr<Mesh> 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<Mesh>();
|
||||
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<std::size_t>::max(), thresholds, .1f) == 1,
|
||||
"An instance without a previous level chooses its size without hysteresis");
|
||||
const std::array<std::uint8_t, 3> 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<std::uint8_t, 3> 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<std::uint8_t, 3> 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<float>::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;
|
||||
}
|
||||
}
|
||||
@@ -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<std::uint8_t>({240, 80, 20, 255}),
|
||||
|
||||
Reference in New Issue
Block a user