Introduce stable render instances and prepared LOD policy

This commit is contained in:
Emil
2026-09-23 22:15:15 +03:00
parent 66f59f4afa
commit f8cd73b95f
8 changed files with 460 additions and 15 deletions
+36 -15
View File
@@ -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,
+174
View File
@@ -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