diff --git a/CMakeLists.txt b/CMakeLists.txt index eb35f2813..b7fb48979 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -841,6 +841,9 @@ if(UNIX) "$ORIGIN/lib" "${CUDAToolkit_LIBRARY_DIR}" ) + if(TARGET OpenMeshCore) + list(APPEND _lichtfeld_runtime_rpath_common "$") + endif() set(_lichtfeld_runtime_rpath_release "${_lichtfeld_runtime_rpath_common}") set(_lichtfeld_runtime_rpath_debug "${_lichtfeld_runtime_rpath_common}") if(DEFINED VCPKG_TARGET_TRIPLET) diff --git a/external/zep/src/indexer.cpp b/external/zep/src/indexer.cpp index 39c35cfa8..0b5dd177e 100644 --- a/external/zep/src/indexer.cpp +++ b/external/zep/src/indexer.cpp @@ -9,39 +9,6 @@ namespace Zep { - enum TypeName { - t_class, - t_void, - t_byte, - t_char, - t_int, - t_long, - t_float, - t_double, - t_uint32_t, - t_uint8_t, - t_uint64_t, - t_int32_t, - t_int64_t, - t_int8_t - }; - - std::map MapToType = { - {"class", t_class}, - {"void", t_void}, - {"byte", t_byte}, - {"char", t_char}, - {"int", t_int}, - {"long", t_long}, - {"float", t_float}, - {"double", t_double}, - {"uint32_t", t_uint32_t}, - {"uint8_t", t_uint8_t}, - {"uint64_t", t_uint64_t}, - {"int32_t", t_int32_t}, - {"int64_t", t_int64_t}, - {"int8_t", t_int8_t}}; - Indexer::Indexer(ZepEditor& editor) : ZepComponent(editor) { } diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index b2a27193c..0e07098b9 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -102,7 +102,7 @@ target_link_libraries(lfs_core lfs_tensor_kernels taywee::args OpenImageIO::OpenImageIO - $,OpenMeshCore,OpenMeshCoreStatic> + OpenMeshCore ) # OpenMP for tensor multi-threaded operations diff --git a/src/core/argument_parser.cpp b/src/core/argument_parser.cpp index bce3b6789..320a7f7b7 100644 --- a/src/core/argument_parser.cpp +++ b/src/core/argument_parser.cpp @@ -193,7 +193,7 @@ namespace { ::args::Group mode_group(parser, "MODE SELECTION:"); ::args::HelpFlag help(mode_group, "help", "Display help menu", {'h', "help"}); ::args::Flag version(mode_group, "version", "Display version information", {'V', "version"}); - ::args::ValueFlag view_ply(mode_group, "path", "View file(s). Supports splat (.ply, .sog, .spz, .usd, .usda, .usdc, .usdz) and mesh (.obj, .fbx, .gltf, .glb, .stl) formats. If directory, loads all.", {'v', "view"}); + ::args::ValueFlag view_ply(mode_group, "path", "View file(s). Supports splat (.ply, .sog, .spz, .rad, .usd, .usda, .usdc, .usdz) and mesh (.obj, .fbx, .gltf, .glb, .stl) formats. If directory, loads all.", {'v', "view"}); ::args::ValueFlag resume_checkpoint(mode_group, "checkpoint", "Resume training from checkpoint file", {"resume"}); ::args::CompletionFlag completion(parser, {"complete"}); @@ -425,8 +425,8 @@ namespace { return std::unexpected(std::format("Path does not exist: {}", lfs::core::path_to_utf8(view_path))); } - constexpr std::array SUPPORTED_EXTENSIONS = { - ".ply", ".sog", ".spz", ".resume", + constexpr std::array SUPPORTED_EXTENSIONS = { + ".ply", ".sog", ".spz", ".rad", ".resume", ".obj", ".fbx", ".gltf", ".glb", ".stl", ".dae", ".3ds", ".blend"}; const auto is_supported = [&](const std::filesystem::path& p) { auto ext = p.extension().string(); diff --git a/src/core/include/core/export.hpp b/src/core/include/core/export.hpp index f699a6bc2..9e74fb844 100644 --- a/src/core/include/core/export.hpp +++ b/src/core/include/core/export.hpp @@ -4,6 +4,7 @@ #pragma once #ifdef _WIN32 +#define LFS_LOCAL_SYMBOL #ifdef LFS_LOGGER_EXPORTS #define LFS_LOGGER_API __declspec(dllexport) #else @@ -26,6 +27,7 @@ #define LFS_MCP_API __declspec(dllimport) #endif #else +#define LFS_LOCAL_SYMBOL __attribute__((visibility("hidden"))) #define LFS_LOGGER_API __attribute__((visibility("default"))) #define LFS_CORE_API __attribute__((visibility("default"))) #define LFS_IO_API __attribute__((visibility("default"))) diff --git a/src/core/include/core/splat_data.hpp b/src/core/include/core/splat_data.hpp index 23bac2090..0c78d709f 100644 --- a/src/core/include/core/splat_data.hpp +++ b/src/core/include/core/splat_data.hpp @@ -13,7 +13,7 @@ #include #include #include -#include +#include #include #include #include @@ -28,6 +28,18 @@ namespace lfs::core { struct TrainingParameters; } + struct SplatLodTree { + std::vector child_count; + std::vector child_start; + std::vector lod_level; + std::vector centers; + std::vector sizes; + bool lod_opacity_encoded = false; + + size_t total_nodes() const { return child_count.size(); } + bool has_tree() const { return !child_count.empty(); } + }; + using SplatTensorAllocator = std::function lod_tree; + private: int _active_sh_degree = 0; int _max_sh_degree = 0; diff --git a/src/core/include/core/splat_simplify_history.hpp b/src/core/include/core/splat_simplify_history.hpp index 23d74b16a..874e1381c 100644 --- a/src/core/include/core/splat_simplify_history.hpp +++ b/src/core/include/core/splat_simplify_history.hpp @@ -26,9 +26,8 @@ namespace lfs::core { int target_count = 0; int post_prune_count = 0; - double requested_ratio = 0.1; - int requested_knn_k = 16; - double requested_merge_cap = 0.5; + double requested_ratio = 0.5; + float requested_lod_base = 2.0f; float requested_opacity_prune_threshold = 0.1f; std::vector final_roots; diff --git a/src/core/include/core/splat_simplify_types.hpp b/src/core/include/core/splat_simplify_types.hpp index 777708064..3aba2d373 100644 --- a/src/core/include/core/splat_simplify_types.hpp +++ b/src/core/include/core/splat_simplify_types.hpp @@ -10,9 +10,8 @@ namespace lfs::core { struct SplatSimplifyOptions { - double ratio = 0.1; - int knn_k = 16; - double merge_cap = 0.5; + double ratio = 0.5; + float lod_base = 2.0f; float opacity_prune_threshold = 0.1f; }; diff --git a/src/core/splat_data.cpp b/src/core/splat_data.cpp index 9f88c5f91..9e1c3c2a2 100644 --- a/src/core/splat_data.cpp +++ b/src/core/splat_data.cpp @@ -551,6 +551,7 @@ namespace lfs::core { _deleted(std::move(other._deleted)), _deleted_count(other._deleted_count.load(std::memory_order_relaxed)), _tensor_allocator(std::move(other._tensor_allocator)), + lod_tree(std::move(other.lod_tree)), _frozen_ranges(std::move(other._frozen_ranges)) { // Reset the moved-from object other._active_sh_degree = 0; @@ -576,6 +577,9 @@ namespace lfs::core { _opacity = std::move(other._opacity); _densification_info = std::move(other._densification_info); _deleted = std::move(other._deleted); + + // Move LOD tree + lod_tree = std::move(other.lod_tree); _deleted_count.store(other._deleted_count.load(std::memory_order_relaxed), std::memory_order_relaxed); _tensor_allocator = std::move(other._tensor_allocator); diff --git a/src/core/splat_simplify.cpp b/src/core/splat_simplify.cpp index eddb03eb1..b06e30b16 100644 --- a/src/core/splat_simplify.cpp +++ b/src/core/splat_simplify.cpp @@ -8,10 +8,8 @@ #include "core/cuda/sh_layout.cuh" #include "core/logger.hpp" #include "core/splat_data.hpp" -#include "nanoflann.hpp" #include -#include #include #include @@ -22,6 +20,7 @@ #include #include #include +#include #include #include @@ -36,23 +35,7 @@ namespace lfs::core { constexpr float kMinProb = 1e-6f; constexpr float kMinEval = 1e-18f; constexpr int kJacobiIterations = 32; - - struct PointCloudAdaptor { - const float* points = nullptr; - size_t num_points = 0; - - [[nodiscard]] inline size_t kdtree_get_point_count() const { return num_points; } - [[nodiscard]] inline double kdtree_get_pt(const size_t idx, const size_t dim) const { - return static_cast(points[idx * 3 + dim]); - } - template - bool kdtree_get_bbox(BBOX&) const { return false; } - }; - - using KDTree = nanoflann::KDTreeSingleIndexAdaptor< - nanoflann::L2_Simple_Adaptor, - PointCloudAdaptor, - 3>; + constexpr float kEllipsoidAreaP = 1.6075f; struct SplatSimplifyWorkset { Tensor means; @@ -78,25 +61,11 @@ namespace lfs::core { std::vector appearance; }; - struct CacheEntry { - std::array R{}; - float mass = 0.0f; - }; - struct Eigen3x3 { std::array values{}; std::array vectors{}; }; - struct SimplifyScratch { - std::vector cache; - std::vector costs; - std::vector order; - std::vector used_rows; - std::vector keep_idx; - std::vector> pairs; - }; - struct SimplifyHistoryState { SplatSimplifyMergeTree tree; std::vector current_node_ids; @@ -240,8 +209,7 @@ namespace lfs::core { history.tree.source_scene_scale = input.scene_scale; history.tree.target_count = target_count; history.tree.requested_ratio = options.ratio; - history.tree.requested_knn_k = options.knn_k; - history.tree.requested_merge_cap = options.merge_cap; + history.tree.requested_lod_base = options.lod_base; history.tree.requested_opacity_prune_threshold = options.opacity_prune_threshold; history.current_node_ids.resize(static_cast(input.size())); @@ -343,8 +311,6 @@ namespace lfs::core { const float vy, const float vz, std::array& out) { - // Match NumPy's `np.matmul(R * v[None, :], R.T)` by first scaling - // columns, then multiplying by the transposed rotation. const std::array variance = {vx, vy, vz}; std::array scaled{}; for (int row = 0; row < 3; ++row) { @@ -431,7 +397,7 @@ namespace lfs::core { if (src.app_dim > 0) { std::copy_n(src.appearance.begin() + static_cast(src_row * src.app_dim), src.app_dim, - dst.appearance.begin() + static_cast(dst_row * src.app_dim)); + dst.appearance.begin() + static_cast(dst_row * dst.app_dim)); } } @@ -477,40 +443,11 @@ namespace lfs::core { return out; } - void build_cache(const NativeRows& rows, std::vector& cache) { - cache.resize(static_cast(rows.count)); - tbb::parallel_for(tbb::blocked_range(0, rows.count), [&](const tbb::blocked_range& range) { - for (int i = range.begin(); i != range.end(); ++i) { - auto& entry = cache[static_cast(i)]; - const size_t i3 = static_cast(i) * 3; - const size_t i4 = static_cast(i) * 4; - - const float sx = std::max(rows.scales[i3 + 0], kMinScale); - const float sy = std::max(rows.scales[i3 + 1], kMinScale); - const float sz = std::max(rows.scales[i3 + 2], kMinScale); - - const float qw = rows.rotation[i4 + 0]; - const float qx = rows.rotation[i4 + 1]; - const float qy = rows.rotation[i4 + 2]; - const float qz = rows.rotation[i4 + 3]; - quat_to_rotmat(qw, qx, qy, qz, entry.R); - - const float alpha = rows.opacity[static_cast(i)]; - const float scale_prod = strict_prod3(sx, sy, sz); - entry.mass = strict_add(strict_mul(strict_mul(kTwoPiPow1p5, alpha), scale_prod), 1e-12f); - } - }); - } - - [[nodiscard]] float compute_edge_cost_euclidean(const NativeRows& rows, - const int i, - const int j) { - const size_t i3 = static_cast(i) * 3; - const size_t j3 = static_cast(j) * 3; - const float dx = strict_sub(rows.means[i3 + 0], rows.means[j3 + 0]); - const float dy = strict_sub(rows.means[i3 + 1], rows.means[j3 + 1]); - const float dz = strict_sub(rows.means[i3 + 2], rows.means[j3 + 2]); - return std::sqrt(strict_add(strict_add(strict_mul(dx, dx), strict_mul(dy, dy)), strict_mul(dz, dz))); + [[nodiscard]] float ellipsoid_area(const float sx, const float sy, const float sz) { + const float t1 = std::pow(sx * sy, kEllipsoidAreaP); + const float t2 = std::pow(sx * sz, kEllipsoidAreaP); + const float t3 = std::pow(sy * sz, kEllipsoidAreaP); + return 4.0f * static_cast(M_PI) * std::pow((t1 + t2 + t3) / 3.0f, 1.0f / kEllipsoidAreaP); } [[nodiscard]] Eigen3x3 sort_eigendecomposition(const Eigen3x3& out) { @@ -540,15 +477,9 @@ namespace lfs::core { [[nodiscard]] Eigen3x3 eigen_symmetric_3x3_jacobi(const std::array& Ain) { std::array A = Ain; std::array V = { - 1.0f, - 0.0f, - 0.0f, - 0.0f, - 1.0f, - 0.0f, - 0.0f, - 0.0f, - 1.0f, + 1.0f, 0.0f, 0.0f, + 0.0f, 1.0f, 0.0f, + 0.0f, 0.0f, 1.0f, }; for (int iter = 0; iter < kJacobiIterations; ++iter) { @@ -676,248 +607,280 @@ namespace lfs::core { rotmat_to_quat(eig.vectors, rotation_raw); } - [[nodiscard]] std::vector> build_knn_union_edges(const NativeRows& rows, const int knn_k) { - if (rows.count <= 1 || knn_k <= 0) - return {}; - - const int k_eff = std::min(std::max(1, knn_k), std::max(1, rows.count - 1)); - PointCloudAdaptor cloud{rows.means.data(), static_cast(rows.count)}; - KDTree index(3, cloud, nanoflann::KDTreeSingleIndexAdaptorParams(10)); - index.buildIndex(); - - tbb::enumerable_thread_specific> local_edge_keys; - - tbb::parallel_for(tbb::blocked_range(0, rows.count), [&](const tbb::blocked_range& range) { - std::vector ret_indices; - std::vector out_dists_sqr; - auto& edge_keys = local_edge_keys.local(); - if (edge_keys.empty()) - edge_keys.reserve(static_cast(range.size()) * static_cast(k_eff)); - - for (int i = range.begin(); i != range.end(); ++i) { - const size_t query_count = static_cast(std::min(rows.count, k_eff + 1)); - ret_indices.assign(query_count, 0); - out_dists_sqr.assign(query_count, 0.0); - nanoflann::KNNResultSet result_set(query_count); - result_set.init(ret_indices.data(), out_dists_sqr.data()); - const double query[3] = { - static_cast(rows.means[static_cast(i) * 3 + 0]), - static_cast(rows.means[static_cast(i) * 3 + 1]), - static_cast(rows.means[static_cast(i) * 3 + 2]), - }; - index.findNeighbors(result_set, query, nanoflann::SearchParameters(0.0f, true)); - - const size_t take = std::min(static_cast(k_eff), result_set.size() > 0 ? result_set.size() - 1 : size_t{0}); - for (size_t j = 0; j < take; ++j) { - const int neighbor = static_cast(ret_indices[j + 1]); - if (neighbor < 0 || neighbor == i) - continue; - const int u = std::min(i, neighbor); - const int v = std::max(i, neighbor); - edge_keys.push_back( - (static_cast(static_cast(u)) << 32) | - static_cast(v)); - } + void compute_bounds(const NativeRows& rows, float out_min[3], float out_max[3]) { + if (rows.count == 0) { + for (int i = 0; i < 3; ++i) { + out_min[i] = 0.0f; + out_max[i] = 0.0f; + } + return; + } + for (int i = 0; i < 3; ++i) { + out_min[i] = rows.means[static_cast(i)]; + out_max[i] = rows.means[static_cast(i)]; + } + for (int r = 1; r < rows.count; ++r) { + const size_t r3 = static_cast(r) * 3; + for (int i = 0; i < 3; ++i) { + out_min[i] = std::min(out_min[i], rows.means[r3 + i]); + out_max[i] = std::max(out_max[i], rows.means[r3 + i]); } - }); - - size_t total_edge_keys = 0; - for (const auto& local : local_edge_keys) - total_edge_keys += local.size(); - - std::vector edge_keys; - edge_keys.reserve(total_edge_keys); - for (const auto& local : local_edge_keys) - edge_keys.insert(edge_keys.end(), local.begin(), local.end()); - - std::sort(edge_keys.begin(), edge_keys.end()); - edge_keys.erase(std::unique(edge_keys.begin(), edge_keys.end()), edge_keys.end()); - - std::vector> edges; - edges.reserve(edge_keys.size()); - for (const std::uint64_t key : edge_keys) { - const int u = static_cast(key >> 32); - const int v = static_cast(key & 0xffffffffU); - edges.emplace_back(u, v); } - return edges; } - void compute_edge_costs(const NativeRows& rows, - const std::vector>& edges, - std::vector& costs) { - costs.assign(edges.size(), std::numeric_limits::infinity()); - tbb::parallel_for(tbb::blocked_range(0, edges.size()), [&](const tbb::blocked_range& range) { - for (size_t i = range.begin(); i != range.end(); ++i) { - const auto [u, v] = edges[i]; - costs[i] = compute_edge_cost_euclidean(rows, u, v); + [[nodiscard]] float compute_voxel_size(const NativeRows& rows, int target_count) { + float min[3], max[3]; + compute_bounds(rows, min, max); + float volume = 1.0f; + int active_dims = 0; + for (int axis = 0; axis < 3; ++axis) { + const float extent = max[axis] - min[axis]; + if (extent > 1e-6f) { + volume *= extent; + ++active_dims; } - }); + } + if (active_dims == 0) + return 1.0f; + return std::pow(volume / std::max(1, target_count), 1.0f / static_cast(active_dims)) * 1.2f; } - void greedy_pairs_from_edges(const std::vector>& edges, - const std::vector& costs, - const int count, - const int max_pairs, - std::vector& order, - std::vector& used, - std::vector>& pairs) { - order.clear(); - order.reserve(edges.size()); - for (size_t i = 0; i < costs.size(); ++i) { - if (std::isfinite(costs[i])) - order.push_back(i); + [[nodiscard]] int pass_target_count_for(const int current_count, + const int final_target_count, + const float lod_base) { + const float base = std::max(lod_base, 1.01f); + const int lod_target = static_cast(std::ceil(static_cast(current_count) / base)); + return std::clamp(std::max(final_target_count, lod_target), 1, std::max(1, current_count - 1)); + } + + struct VoxelKey { + int64_t x, y, z; + bool operator==(const VoxelKey& other) const { + return x == other.x && y == other.y && z == other.z; } - std::stable_sort(order.begin(), order.end(), [&](const size_t lhs, const size_t rhs) { - return costs[lhs] < costs[rhs]; - }); + }; - used.assign(static_cast(count), uint8_t{0}); - pairs.clear(); - pairs.reserve(static_cast(std::max(0, max_pairs))); - for (const size_t edge_idx : order) { - const auto [u, v] = edges[edge_idx]; - if (used[static_cast(u)] || used[static_cast(v)]) - continue; - used[static_cast(u)] = 1; - used[static_cast(v)] = 1; - pairs.emplace_back(u, v); - if (max_pairs > 0 && static_cast(pairs.size()) >= max_pairs) - break; + struct VoxelKeyHash { + std::size_t operator()(const VoxelKey& k) const noexcept { + // Simple hash combining + std::size_t h = static_cast(k.x); + h = h * 31 + static_cast(k.y); + h = h * 31 + static_cast(k.z); + return h; } - } + }; - [[nodiscard]] NativeRows merge_pairs(const NativeRows& input, - const std::vector& cache, - const std::vector>& pairs, - std::vector& used, - std::vector& keep_idx) { - if (pairs.empty()) - return input; + [[nodiscard]] std::vector> group_into_voxels( + const NativeRows& rows, + float voxel_size, + const float bounds_min[3]) { + std::vector> groups; + if (voxel_size <= 0.0f || rows.count == 0) + return groups; + + std::unordered_map, VoxelKeyHash> cells; + cells.reserve(static_cast(rows.count)); + + const float inv_size = 1.0f / voxel_size; + for (int i = 0; i < rows.count; ++i) { + const size_t i3 = static_cast(i) * 3; + VoxelKey key; + key.x = static_cast(std::floor((rows.means[i3 + 0] - bounds_min[0]) * inv_size)); + key.y = static_cast(std::floor((rows.means[i3 + 1] - bounds_min[1]) * inv_size)); + key.z = static_cast(std::floor((rows.means[i3 + 2] - bounds_min[2]) * inv_size)); + cells[key].push_back(i); + } - used.assign(static_cast(input.count), uint8_t{0}); - for (const auto [u, v] : pairs) { - used[static_cast(u)] = 1; - used[static_cast(v)] = 1; + groups.reserve(cells.size()); + for (auto& [key, indices] : cells) { + std::sort(indices.begin(), indices.end()); + groups.push_back(std::move(indices)); } + std::sort(groups.begin(), groups.end(), [](const auto& lhs, const auto& rhs) { + return lhs.front() < rhs.front(); + }); + return groups; + } + [[nodiscard]] NativeRows merge_voxel_groups( + const NativeRows& input, + const std::vector>& groups, + std::vector& keep_idx, + SimplifyHistoryState* history, + int pass_index) { keep_idx.clear(); keep_idx.reserve(static_cast(input.count)); - for (int i = 0; i < input.count; ++i) { - if (!used[static_cast(i)]) - keep_idx.push_back(i); + + // Count output rows + int out_count = 0; + for (const auto& group : groups) { + if (group.size() == 1) { + keep_idx.push_back(group[0]); + ++out_count; + } else if (group.size() > 1) { + ++out_count; + } } NativeRows out; - out.count = static_cast(keep_idx.size() + pairs.size()); + out.count = out_count; out.app_dim = input.app_dim; out.means.resize(static_cast(out.count) * 3); out.scales.resize(static_cast(out.count) * 3); out.rotation.resize(static_cast(out.count) * 4); out.opacity.resize(static_cast(out.count)); out.appearance.resize(static_cast(out.count) * static_cast(out.app_dim)); + // Rebuild current_node_ids from the output of this pass + std::vector next_node_ids; + if (history) + next_node_ids.reserve(static_cast(out_count)); + + int out_row = 0; + for (const auto& group : groups) { + if (group.empty()) + continue; - tbb::parallel_for(tbb::blocked_range(0, static_cast(keep_idx.size())), [&](const tbb::blocked_range& range) { - for (int dst_row = range.begin(); dst_row != range.end(); ++dst_row) - copy_row(input, keep_idx[static_cast(dst_row)], out, dst_row); - }); - - tbb::parallel_for(tbb::blocked_range(0, static_cast(pairs.size())), [&](const tbb::blocked_range& range) { - for (int pair_idx = range.begin(); pair_idx != range.end(); ++pair_idx) { - const auto [i, j] = pairs[static_cast(pair_idx)]; - const auto& cache_i = cache[static_cast(i)]; - const auto& cache_j = cache[static_cast(j)]; - const size_t i3 = static_cast(i) * 3; - const size_t j3 = static_cast(j) * 3; - - const float sxi = std::max(input.scales[i3 + 0], kMinScale); - const float syi = std::max(input.scales[i3 + 1], kMinScale); - const float szi = std::max(input.scales[i3 + 2], kMinScale); - const float sxj = std::max(input.scales[j3 + 0], kMinScale); - const float syj = std::max(input.scales[j3 + 1], kMinScale); - const float szj = std::max(input.scales[j3 + 2], kMinScale); - - const float alpha_i = input.opacity[static_cast(i)]; - const float alpha_j = input.opacity[static_cast(j)]; - const float wi = cache_i.mass; - const float wj = cache_j.mass; - const float W = std::max(wi + wj, 1e-12f); - - const int out_row = static_cast(keep_idx.size()) + pair_idx; - const size_t o3 = static_cast(out_row) * 3; - const size_t o4 = static_cast(out_row) * 4; - - out.means[o3 + 0] = (wi * input.means[i3 + 0] + wj * input.means[j3 + 0]) / W; - out.means[o3 + 1] = (wi * input.means[i3 + 1] + wj * input.means[j3 + 1]) / W; - out.means[o3 + 2] = (wi * input.means[i3 + 2] + wj * input.means[j3 + 2]) / W; - - std::array sig_i{}; - std::array sig_j{}; - sigma_from_rot_var(cache_i.R, sxi * sxi, syi * syi, szi * szi, sig_i); - sigma_from_rot_var(cache_j.R, sxj * sxj, syj * syj, szj * szj, sig_j); - - const float dix = input.means[i3 + 0] - out.means[o3 + 0]; - const float diy = input.means[i3 + 1] - out.means[o3 + 1]; - const float diz = input.means[i3 + 2] - out.means[o3 + 2]; - const float djx = input.means[j3 + 0] - out.means[o3 + 0]; - const float djy = input.means[j3 + 1] - out.means[o3 + 1]; - const float djz = input.means[j3 + 2] - out.means[o3 + 2]; - - sig_i[0] += dix * dix; - sig_i[1] += dix * diy; - sig_i[2] += dix * diz; - sig_i[3] += diy * dix; - sig_i[4] += diy * diy; - sig_i[5] += diy * diz; - sig_i[6] += diz * dix; - sig_i[7] += diz * diy; - sig_i[8] += diz * diz; - sig_j[0] += djx * djx; - sig_j[1] += djx * djy; - sig_j[2] += djx * djz; - sig_j[3] += djy * djx; - sig_j[4] += djy * djy; - sig_j[5] += djy * djz; - sig_j[6] += djz * djx; - sig_j[7] += djz * djy; - sig_j[8] += djz * djz; - - std::array sigma{}; - for (int a = 0; a < 9; ++a) { - sigma[static_cast(a)] = - (wi * sig_i[static_cast(a)] + wj * sig_j[static_cast(a)]) / W; + if (group.size() == 1) { + copy_row(input, group[0], out, out_row); + if (history) { + next_node_ids.push_back(history->current_node_ids[static_cast(group[0])]); } - sigma[1] = sigma[3] = 0.5f * (sigma[1] + sigma[3]); - sigma[2] = sigma[6] = 0.5f * (sigma[2] + sigma[6]); - sigma[5] = sigma[7] = 0.5f * (sigma[5] + sigma[7]); - sigma[0] += kEpsCov; - sigma[4] += kEpsCov; - sigma[8] += kEpsCov; - - std::array scaling_raw{}; - std::array rotation{}; - decompose_sigma_to_raw_scale_quat(sigma, scaling_raw, rotation); - - out.scales[o3 + 0] = activated_scale(scaling_raw[0]); - out.scales[o3 + 1] = activated_scale(scaling_raw[1]); - out.scales[o3 + 2] = activated_scale(scaling_raw[2]); - out.rotation[o4 + 0] = rotation[0]; - out.rotation[o4 + 1] = rotation[1]; - out.rotation[o4 + 2] = rotation[2]; - out.rotation[o4 + 3] = rotation[3]; - out.opacity[static_cast(out_row)] = alpha_i + alpha_j - alpha_i * alpha_j; - - const size_t ai = static_cast(i) * static_cast(input.app_dim); - const size_t aj = static_cast(j) * static_cast(input.app_dim); - const size_t ao = static_cast(out_row) * static_cast(input.app_dim); - for (int k = 0; k < input.app_dim; ++k) { - out.appearance[ao + static_cast(k)] = - (wi * input.appearance[ai + static_cast(k)] + - wj * input.appearance[aj + static_cast(k)]) / - W; + ++out_row; + continue; + } + + // Compute weights and total weight (volume-based, not area-based) + std::vector weights; + weights.reserve(group.size()); + float total_weight = 0.0f; + for (int idx : group) { + const size_t idx3 = static_cast(idx) * 3; + const float sx = std::max(input.scales[idx3 + 0], kMinScale); + const float sy = std::max(input.scales[idx3 + 1], kMinScale); + const float sz = std::max(input.scales[idx3 + 2], kMinScale); + const float alpha = input.opacity[static_cast(idx)]; + const float volume = sx * sy * sz; + const float w = volume * alpha; + weights.push_back(w); + total_weight += w; + } + if (total_weight < 1e-30f) + total_weight = 1e-30f; + for (float& w : weights) + w /= total_weight; + + // Compute weighted center + const size_t o3 = static_cast(out_row) * 3; + float cx = 0.0f, cy = 0.0f, cz = 0.0f; + for (size_t g = 0; g < group.size(); ++g) { + const int idx = group[g]; + const size_t idx3 = static_cast(idx) * 3; + cx += weights[g] * input.means[idx3 + 0]; + cy += weights[g] * input.means[idx3 + 1]; + cz += weights[g] * input.means[idx3 + 2]; + } + out.means[o3 + 0] = cx; + out.means[o3 + 1] = cy; + out.means[o3 + 2] = cz; + + // Compute blended covariance + std::array sigma{}; + for (size_t g = 0; g < group.size(); ++g) { + const int idx = group[g]; + const size_t idx3 = static_cast(idx) * 3; + const size_t idx4 = static_cast(idx) * 4; + + const float sx = std::max(input.scales[idx3 + 0], kMinScale); + const float sy = std::max(input.scales[idx3 + 1], kMinScale); + const float sz = std::max(input.scales[idx3 + 2], kMinScale); + + float qw = input.rotation[idx4 + 0]; + float qx = input.rotation[idx4 + 1]; + float qy = input.rotation[idx4 + 2]; + float qz = input.rotation[idx4 + 3]; + std::array R{}; + quat_to_rotmat(qw, qx, qy, qz, R); + + std::array sig{}; + sigma_from_rot_var(R, sx * sx, sy * sy, sz * sz, sig); + + // Add delta outer product + const float dx = input.means[idx3 + 0] - cx; + const float dy = input.means[idx3 + 1] - cy; + const float dz = input.means[idx3 + 2] - cz; + sig[0] += dx * dx; + sig[1] += dx * dy; + sig[2] += dx * dz; + sig[3] += dy * dx; + sig[4] += dy * dy; + sig[5] += dy * dz; + sig[6] += dz * dx; + sig[7] += dz * dy; + sig[8] += dz * dz; + + // Accumulate weighted + for (int a = 0; a < 9; ++a) + sigma[static_cast(a)] += weights[g] * sig[static_cast(a)]; + } + + sigma[1] = sigma[3] = 0.5f * (sigma[1] + sigma[3]); + sigma[2] = sigma[6] = 0.5f * (sigma[2] + sigma[6]); + sigma[5] = sigma[7] = 0.5f * (sigma[5] + sigma[7]); + sigma[0] += kEpsCov; + sigma[4] += kEpsCov; + sigma[8] += kEpsCov; + + std::array scaling_raw{}; + std::array rotation{}; + decompose_sigma_to_raw_scale_quat(sigma, scaling_raw, rotation); + + out.scales[o3 + 0] = activated_scale(scaling_raw[0]); + out.scales[o3 + 1] = activated_scale(scaling_raw[1]); + out.scales[o3 + 2] = activated_scale(scaling_raw[2]); + const size_t o4 = static_cast(out_row) * 4; + out.rotation[o4 + 0] = rotation[0]; + out.rotation[o4 + 1] = rotation[1]; + out.rotation[o4 + 2] = rotation[2]; + out.rotation[o4 + 3] = rotation[3]; + + // Opacity: union of coverage for independent Gaussians + float merged_opacity = 1.0f; + for (int idx : group) { + merged_opacity *= (1.0f - input.opacity[static_cast(idx)]); + } + merged_opacity = 1.0f - merged_opacity; + out.opacity[static_cast(out_row)] = std::clamp(merged_opacity, 0.0f, 1.0f); + + // Appearance weighted average + const size_t ao = static_cast(out_row) * static_cast(input.app_dim); + for (int k = 0; k < input.app_dim; ++k) + out.appearance[ao + static_cast(k)] = 0.0f; + for (size_t g = 0; g < group.size(); ++g) { + const int idx = group[g]; + const size_t ai = static_cast(idx) * static_cast(input.app_dim); + for (int k = 0; k < input.app_dim; ++k) + out.appearance[ao + static_cast(k)] += weights[g] * input.appearance[ai + static_cast(k)]; + } + + // History tracking: decompose N-way merge into sequential binary merges + if (history) { + int current_node = history->current_node_ids[static_cast(group[0])]; + for (size_t g = 1; g < group.size(); ++g) { + const int next_node = history->current_node_ids[static_cast(group[g])]; + history->tree.merge_left.push_back(current_node); + history->tree.merge_right.push_back(next_node); + history->tree.merge_pass.push_back(pass_index); + const int merged_node = static_cast(history->tree.leaf_count() + history->tree.merge_count() - 1); + current_node = merged_node; } + next_node_ids.push_back(current_node); } - }); + + ++out_row; + } + + if (history) + history->current_node_ids = std::move(next_node_ids); return out; } @@ -930,11 +893,6 @@ namespace lfs::core { std::max(1, input_count)); } - [[nodiscard]] int pass_merge_cap_for(const int input_count, const double merge_cap) { - const double clamped_merge_cap = std::clamp(merge_cap, 0.01, 0.5); - return std::max(1, static_cast(clamped_merge_cap * static_cast(input_count))); - } - [[nodiscard]] float progress_for_count(const int input_count, const int target_count, const int current_count) { if (input_count <= target_count) return 0.95f; @@ -955,8 +913,7 @@ namespace lfs::core { const int input_count = current.count; const int target_count = target_count_for(input_count, options.ratio); - const int pass_merge_cap = pass_merge_cap_for(input_count, options.merge_cap); - SimplifyScratch scratch; + std::vector keep_idx; if (history) *history = make_history_state(input, options, target_count); @@ -965,7 +922,7 @@ namespace lfs::core { current = prune_by_opacity( current, options.opacity_prune_threshold, - history ? &scratch.keep_idx : nullptr); + history ? &keep_idx : nullptr); if (current.count == 0) return std::unexpected("Splat simplify: input has no visible gaussians"); @@ -976,7 +933,7 @@ namespace lfs::core { pruned_ids.reserve(history->current_node_ids.size()); std::vector kept_mask(history->current_node_ids.size(), uint8_t{0}); - for (const int idx : scratch.keep_idx) { + for (const int idx : keep_idx) { if (idx >= 0 && static_cast(idx) < kept_mask.size()) kept_mask[static_cast(idx)] = 1; } @@ -1005,56 +962,48 @@ namespace lfs::core { const float pass_progress = progress_for_count(input_count, target_count, current.count); const std::string pass_prefix = "Pass " + std::to_string(pass + 1) + ": "; - if (!report_progress(progress, pass_progress, pass_prefix + "building kNN graph")) - return std::unexpected("Cancelled"); - const auto edges = build_knn_union_edges(current, options.knn_k); - if (edges.empty()) - return std::unexpected( - "Splat simplify stalled at " + std::to_string(current.count) + - " gaussians (target " + std::to_string(target_count) + ")"); - - if (!report_progress(progress, pass_progress + 0.01f, pass_prefix + "computing edge costs")) + if (!report_progress(progress, pass_progress, pass_prefix + "building voxel grid")) return std::unexpected("Cancelled"); - compute_edge_costs(current, edges, scratch.costs); - if (!report_progress(progress, pass_progress + 0.02f, pass_prefix + "selecting pairs")) - return std::unexpected("Cancelled"); - const int merges_needed = current.count - target_count; - const int max_pairs_this_pass = merges_needed > 0 ? std::min(merges_needed, pass_merge_cap) : 0; - greedy_pairs_from_edges( - edges, - scratch.costs, + float bounds_min[3], bounds_max[3]; + compute_bounds(current, bounds_min, bounds_max); + const int pass_target_count = pass_target_count_for( current.count, - max_pairs_this_pass, - scratch.order, - scratch.used_rows, - scratch.pairs); - if (scratch.pairs.empty()) { + target_count, + options.lod_base); + float voxel_size = compute_voxel_size(current, pass_target_count); + + // If we're not reducing enough, increase voxel size + bool reduced = false; + for (int attempt = 0; attempt < 10 && !reduced; ++attempt) { + auto groups = group_into_voxels(current, voxel_size, bounds_min); + + int merge_count = 0; + for (const auto& g : groups) + if (g.size() > 1) + ++merge_count; + + if (merge_count == 0) { + // No merges possible with this voxel size, increase it + voxel_size *= 1.5f; + continue; + } + + if (!report_progress(progress, + pass_progress + 0.02f, + pass_prefix + "merging " + std::to_string(merge_count) + " voxels")) + return std::unexpected("Cancelled"); + + current = merge_voxel_groups(current, groups, keep_idx, history, pass); + reduced = true; + } + + if (!reduced) { return std::unexpected( "Splat simplify stalled at " + std::to_string(current.count) + " gaussians (target " + std::to_string(target_count) + ")"); } - if (!report_progress(progress, - pass_progress + 0.03f, - pass_prefix + "merging " + std::to_string(scratch.pairs.size()) + " pairs")) - return std::unexpected("Cancelled"); - build_cache(current, scratch.cache); - current = merge_pairs(current, scratch.cache, scratch.pairs, scratch.used_rows, scratch.keep_idx); - if (history) { - std::vector next_ids; - next_ids.reserve(static_cast(current.count)); - for (const int keep_row : scratch.keep_idx) { - next_ids.push_back(history->current_node_ids[static_cast(keep_row)]); - } - for (const auto [left_row, right_row] : scratch.pairs) { - history->tree.merge_left.push_back(history->current_node_ids[static_cast(left_row)]); - history->tree.merge_right.push_back(history->current_node_ids[static_cast(right_row)]); - history->tree.merge_pass.push_back(pass); - next_ids.push_back(input_count + history->tree.merge_count() - 1); - } - history->current_node_ids = std::move(next_ids); - } ++pass; } @@ -1063,7 +1012,7 @@ namespace lfs::core { (void)report_progress(progress, 1.0f, "Complete"); return workset_from_rows(current, input); } catch (const std::exception& e) { - return std::unexpected(e.what()); + return std::unexpected(std::string("Splat simplify failed: ") + e.what()); } } diff --git a/src/io/CMakeLists.txt b/src/io/CMakeLists.txt index c350fff86..64eedea9a 100644 --- a/src/io/CMakeLists.txt +++ b/src/io/CMakeLists.txt @@ -168,6 +168,8 @@ add_library(lfs_io STATIC loaders/spz_loader.cpp loaders/usd_loader.hpp loaders/usd_loader.cpp + loaders/rad_loader.hpp + loaders/rad_loader.cpp loaders/checkpoint_loader.hpp loaders/checkpoint_loader.cpp loaders/mesh_loader.hpp @@ -254,7 +256,7 @@ target_link_libraries(lfs_io lfs_core_cuda # Core CUDA utilities (lanczos_resize for nvcodec) assimp::assimp # Multi-format mesh import usdVol # OpenUSD Gaussian ParticleField import - $,OpenMeshCore,OpenMeshCoreStatic> + OpenMeshCore spz_lib # Niantic SPZ compressed format ) diff --git a/src/io/formats/rad.cpp b/src/io/formats/rad.cpp index 0908a7877..5aa29fa7e 100644 --- a/src/io/formats/rad.cpp +++ b/src/io/formats/rad.cpp @@ -888,7 +888,6 @@ namespace lfs::io { float temp_x = oct_x; oct_x = (1.0f - std::abs(oct_y)) * (oct_x >= 0.0f ? 1.0f : -1.0f); oct_y = (1.0f - std::abs(temp_x)) * (oct_y >= 0.0f ? 1.0f : -1.0f); - oct_z = -oct_z; } // Project from octahedron to sphere (normalize) @@ -2016,38 +2015,42 @@ namespace lfs::io { constexpr size_t kUpperLodFanout = 64; - // Build all LOD levels - std::vector levels; - std::vector> child_counts; - std::vector> child_starts; - - for (size_t i = 0; i < ratios.size(); ++i) { - float ratio = ratios[i]; + auto build_level_for_ratio = [&](const float ratio) -> std::optional { PackedSplatData level; - if (ratio >= 1.0f) { - // Full detail - use source directly level = pack_splat_data(source); } else { - // Simplified level lfs::core::SplatSimplifyOptions options; options.ratio = ratio; auto result = lfs::core::simplify_splats(source, options, {}); if (!result || !result.value()) { - LOG_WARN("RAD export: failed to build LOD at ratio {}, falling back to non-LOD", ratio); return std::nullopt; } level = pack_splat_data(*result.value()); } - if (level.count == 0) { - LOG_WARN("RAD export: LOD at ratio {} produced empty level, falling back to non-LOD", ratio); return std::nullopt; } + return level; + }; - levels.push_back(std::move(level)); + // Build all requested LOD levels first. + std::vector levels; + std::vector> child_counts; + std::vector> child_starts; + std::vector built_ratios; + + for (size_t i = 0; i < ratios.size(); ++i) { + const float ratio = ratios[i]; + auto level = build_level_for_ratio(ratio); + if (!level.has_value()) { + LOG_WARN("RAD export: failed to build LOD at ratio {}, falling back to non-LOD", ratio); + return std::nullopt; + } + levels.push_back(std::move(*level)); child_counts.emplace_back(); child_starts.emplace_back(); + built_ratios.push_back(ratio); } // Verify all levels have same SH degree @@ -2059,6 +2062,37 @@ namespace lfs::io { } } + // Keep extending upward with real simplified levels instead of fabricating + // arbitrary 64-wide parents from consecutive rows. The chunk-local + // orientation artifacts came from those synthetic aggregate nodes. + const float min_ratio = 1.0f / static_cast(std::max(source.size(), 1ul)); + while (levels.front().count > 1) { + const size_t current_count = levels.front().count; + const size_t target_parent_count = std::max( + 1, (current_count + kUpperLodFanout - 1) / kUpperLodFanout); + const float candidate_ratio = std::clamp( + static_cast(target_parent_count) / static_cast(source.size()), + min_ratio, + built_ratios.front()); + + if (candidate_ratio >= built_ratios.front()) { + break; + } + + auto parent_level = build_level_for_ratio(candidate_ratio); + if (!parent_level.has_value()) { + break; + } + if (parent_level->count >= current_count) { + break; + } + + levels.insert(levels.begin(), std::move(*parent_level)); + child_counts.insert(child_counts.begin(), {}); + child_starts.insert(child_starts.begin(), {}); + built_ratios.insert(built_ratios.begin(), candidate_ratio); + } + sort_level_spatially(levels.front()); // Build parent-child relationships and reorder levels @@ -2100,6 +2134,176 @@ namespace lfs::io { reorder_level(fine, fine_order); } + // Math helpers for merged node rotation (covariance -> eigen-decomposition -> quaternion) + auto quat_to_rotmat = [](const float qw, const float qx, const float qy, const float qz, std::array& out) { + const float xx = qx * qx; + const float yy = qy * qy; + const float zz = qz * qz; + const float wx = qw * qx; + const float wy = qw * qy; + const float wz = qw * qz; + const float xy = qx * qy; + const float xz = qx * qz; + const float yz = qy * qz; + out[0] = 1.0f - 2.0f * (yy + zz); + out[1] = 2.0f * (xy - wz); + out[2] = 2.0f * (xz + wy); + out[3] = 2.0f * (xy + wz); + out[4] = 1.0f - 2.0f * (xx + zz); + out[5] = 2.0f * (yz - wx); + out[6] = 2.0f * (xz - wy); + out[7] = 2.0f * (yz + wx); + out[8] = 1.0f - 2.0f * (xx + yy); + }; + + auto sigma_from_rot_var = [](const std::array& R, const float vx, const float vy, const float vz, std::array& out) { + const std::array variance = {vx, vy, vz}; + std::array scaled{}; + for (int row = 0; row < 3; ++row) { + for (int col = 0; col < 3; ++col) { + scaled[row * 3 + col] = R[row * 3 + col] * variance[col]; + } + } + for (int row = 0; row < 3; ++row) { + for (int col = 0; col < 3; ++col) { + float sum = 0.0f; + for (int k = 0; k < 3; ++k) { + sum += scaled[row * 3 + k] * R[col * 3 + k]; + } + out[row * 3 + col] = sum; + } + } + }; + + auto det3 = [](const std::array& A) -> float { + return A[0] * (A[4] * A[8] - A[5] * A[7]) - + A[1] * (A[3] * A[8] - A[5] * A[6]) + + A[2] * (A[3] * A[7] - A[4] * A[6]); + }; + + auto sort_eigendecomposition = [&](const std::array& values, const std::array& vectors) -> std::pair, std::array> { + std::array order = {0, 1, 2}; + std::sort(order.begin(), order.end(), [&](const int lhs, const int rhs) { + if (values[lhs] != values[rhs]) + return values[lhs] > values[rhs]; + return lhs < rhs; + }); + std::array sorted_values{}; + std::array sorted_vectors{}; + for (int col = 0; col < 3; ++col) { + const int src_col = order[col]; + sorted_values[col] = values[src_col]; + for (int row = 0; row < 3; ++row) + sorted_vectors[row * 3 + col] = vectors[row * 3 + src_col]; + } + // Normalize eigenvectors (columns) to unit length + for (int col = 0; col < 3; ++col) { + float len = 0.0f; + for (int row = 0; row < 3; ++row) + len += sorted_vectors[row * 3 + col] * sorted_vectors[row * 3 + col]; + len = std::sqrt(len); + if (len > 1.0e-6f) { + for (int row = 0; row < 3; ++row) + sorted_vectors[row * 3 + col] /= len; + } + } + if (det3(sorted_vectors) < 0.0f) { + sorted_vectors[2] *= -1.0f; + sorted_vectors[5] *= -1.0f; + sorted_vectors[8] *= -1.0f; + } + return {sorted_values, sorted_vectors}; + }; + + auto eigen_symmetric_3x3_jacobi = [&](const std::array& Ain) -> std::pair, std::array> { + std::array A = Ain; + std::array V = { + 1.0f, 0.0f, 0.0f, + 0.0f, 1.0f, 0.0f, + 0.0f, 0.0f, 1.0f, + }; + for (int iter = 0; iter < 32; ++iter) { + int p = 0, q = 1; + float max_abs = std::abs(A[1]); + if (std::abs(A[2]) > max_abs) { p = 0; q = 2; max_abs = std::abs(A[2]); } + if (std::abs(A[5]) > max_abs) { p = 1; q = 2; max_abs = std::abs(A[5]); } + if (max_abs < 1e-12f) break; + const int pp = 3 * p + p; + const int qq = 3 * q + q; + const int pq = 3 * p + q; + const float app = A[pp]; + const float aqq = A[qq]; + const float apq = A[pq]; + const float tau = (aqq - app) / (2.0f * apq); + const float t = std::copysign(1.0f, tau) / (std::abs(tau) + std::sqrt(1.0f + tau * tau)); + const float c = 1.0f / std::sqrt(1.0f + t * t); + const float s = t * c; + for (int k = 0; k < 3; ++k) { + if (k == p || k == q) continue; + const int kp = 3 * k + p; + const int kq = 3 * k + q; + const float akp = A[kp]; + const float akq = A[kq]; + A[kp] = c * akp - s * akq; + A[3 * p + k] = A[kp]; + A[kq] = s * akp + c * akq; + A[3 * q + k] = A[kq]; + } + A[pp] = c * c * app - 2.0f * s * c * apq + s * s * aqq; + A[qq] = s * s * app + 2.0f * s * c * apq + c * c * aqq; + A[pq] = 0.0f; + A[3 * q + p] = 0.0f; + for (int k = 0; k < 3; ++k) { + const int kp = 3 * k + p; + const int kq = 3 * k + q; + const float vkp = V[kp]; + const float vkq = V[kq]; + V[kp] = c * vkp - s * vkq; + V[kq] = s * vkp + c * vkq; + } + } + std::array values = {A[0], A[4], A[8]}; + return sort_eigendecomposition(values, V); + }; + + auto rotmat_to_quat = [](const std::array& R, std::array& out) { + const float m00 = R[0]; + const float m11 = R[4]; + const float m22 = R[8]; + const float tr = m00 + m11 + m22; + float qw = 0.0f, qx = 0.0f, qy = 0.0f, qz = 0.0f; + if (tr > 0.0f) { + const float S = std::sqrt(tr + 1.0f) * 2.0f; + qw = 0.25f * S; + qx = (R[7] - R[5]) / S; + qy = (R[2] - R[6]) / S; + qz = (R[3] - R[1]) / S; + } else if (m00 > m11 && m00 > m22) { + const float S = std::sqrt(1.0f + m00 - m11 - m22) * 2.0f; + qw = (R[7] - R[5]) / S; + qx = 0.25f * S; + qy = (R[1] + R[3]) / S; + qz = (R[2] + R[6]) / S; + } else if (m11 > m22) { + const float S = std::sqrt(1.0f + m11 - m00 - m22) * 2.0f; + qw = (R[2] - R[6]) / S; + qx = (R[1] + R[3]) / S; + qy = 0.25f * S; + qz = (R[5] + R[7]) / S; + } else { + const float S = std::sqrt(1.0f + m22 - m00 - m11) * 2.0f; + qw = (R[3] - R[1]) / S; + qx = (R[2] + R[6]) / S; + qy = (R[5] + R[7]) / S; + qz = 0.25f * S; + } + const float inv_n = 1.0f / std::max(std::sqrt(qw * qw + qx * qx + qy * qy + qz * qz), 1e-12f); + out[0] = qw * inv_n; + out[1] = qx * inv_n; + out[2] = qy * inv_n; + out[3] = qz * inv_n; + }; + // Aggregate node helper struct AggregateNode { std::array center{0.0f, 0.0f, 0.0f}; @@ -2116,16 +2320,19 @@ namespace lfs::io { return node; } - std::array min_corner{ + // Compute per-child weights (area * opacity) and weighted mean center. + std::vector weights(count); + float total_weight = 0.0f; + std::array weighted_center_sum{0.0, 0.0, 0.0}; + std::array sh0_sum{0.0, 0.0, 0.0}; + std::array min_center{ std::numeric_limits::infinity(), std::numeric_limits::infinity(), std::numeric_limits::infinity()}; - std::array max_corner{ + std::array max_center{ -std::numeric_limits::infinity(), -std::numeric_limits::infinity(), -std::numeric_limits::infinity()}; - std::array sh0_sum{0.0, 0.0, 0.0}; - double opacity_sum = 0.0; const size_t shn_row_width = static_cast(std::max(level.sh_coeffs, 0)) * 3; if (shn_row_width > 0) { @@ -2134,35 +2341,144 @@ namespace lfs::io { for (size_t i = 0; i < count; ++i) { const size_t idx = start + i; + float area = 1.0f; + for (size_t d = 0; d < 3; ++d) { + area *= std::max(std::abs(level.scales[idx * 3 + d]), 1.0e-6f); + } + weights[i] = area * level.opacity[idx]; + total_weight += weights[i]; for (size_t d = 0; d < 3; ++d) { - const float c = level.means[idx * 3 + d]; - const float s = std::max(std::abs(level.scales[idx * 3 + d]), 1.0e-6f); - min_corner[d] = std::min(min_corner[d], c - s); - max_corner[d] = std::max(max_corner[d], c + s); - sh0_sum[d] += static_cast(level.sh0[idx * 3 + d]); + const float coord = level.means[idx * 3 + d]; + min_center[d] = std::min(min_center[d], coord); + max_center[d] = std::max(max_center[d], coord); + weighted_center_sum[d] += static_cast(weights[i] * level.means[idx * 3 + d]); + sh0_sum[d] += static_cast(weights[i] * level.sh0[idx * 3 + d]); } - opacity_sum += static_cast(level.opacity[idx]); if (shn_row_width > 0 && !level.shN.empty()) { const size_t base = idx * shn_row_width; for (size_t k = 0; k < shn_row_width; ++k) { - node.shN[k] += level.shN[base + k]; + node.shN[k] += weights[i] * level.shN[base + k]; + } + } + } + + if (total_weight > 1.0e-30f) { + const float inv_total_weight = 1.0f / total_weight; + for (float& w : weights) w /= total_weight; + for (size_t d = 0; d < 3; ++d) { + node.center[d] = static_cast(weighted_center_sum[d] / static_cast(total_weight)); + sh0_sum[d] *= static_cast(inv_total_weight); + } + for (float& v : node.shN) { + v *= inv_total_weight; + } + } else { + // Fallback to unweighted mean if all weights are zero + for (size_t d = 0; d < 3; ++d) { + float sum = 0.0f; + for (size_t i = 0; i < count; ++i) { + sum += level.means[(start + i) * 3 + d]; + } + node.center[d] = sum / static_cast(count); + sh0_sum[d] /= static_cast(count); + } + if (!node.shN.empty()) { + const float inv_count = 1.0f / static_cast(count); + for (float& v : node.shN) { + v *= inv_count; } } } for (size_t d = 0; d < 3; ++d) { - node.center[d] = 0.5f * (min_corner[d] + max_corner[d]); - node.scale[d] = std::max(0.5f * (max_corner[d] - min_corner[d]), 1.0e-6f); - node.sh0[d] = static_cast(sh0_sum[d] / static_cast(count)); + node.sh0[d] = static_cast(sh0_sum[d]); } - node.opacity = std::clamp(static_cast(opacity_sum / static_cast(count)), 0.0f, 1.0f); - if (!node.shN.empty()) { - const float inv_count = 1.0f / static_cast(count); - for (float& v : node.shN) { - v *= inv_count; + // Compute merged rotation and scale from weighted covariance matrix of children. + { + // Spark regularizes with an isotropic term derived from the merge + // cell size, not from the child splat thickness. Using child scale + // here leaves planar groups nearly singular and produces chunk-local + // orientation shards when viewed from grazing angles. + float max_extent = 0.0f; + float max_child_diameter = 0.0f; + for (size_t i = 0; i < count; ++i) { + const size_t idx = start + i; + for (size_t d = 0; d < 3; ++d) { + max_extent = std::max(max_extent, max_center[d] - min_center[d]); + } + for (size_t d = 0; d < 3; ++d) { + max_child_diameter = std::max( + max_child_diameter, + 2.0f * std::abs(level.scales[idx * 3 + d])); + } + } + const float filter_size = std::max(max_extent, max_child_diameter); + const float filter2 = (0.5f * std::max(filter_size, 1.0e-6f)) * (0.5f * std::max(filter_size, 1.0e-6f)); + + std::array total_cov{}; + for (size_t i = 0; i < count; ++i) { + const size_t idx = start + i; + const float w = weights[i]; + const float qw = level.rotation[idx * 4 + 0]; + const float qx = level.rotation[idx * 4 + 1]; + const float qy = level.rotation[idx * 4 + 2]; + const float qz = level.rotation[idx * 4 + 3]; + std::array R{}; + quat_to_rotmat(qw, qx, qy, qz, R); + std::array s2{}; + for (size_t d = 0; d < 3; ++d) { + const float sd = std::max(std::abs(level.scales[idx * 3 + d]), 1.0e-6f); + s2[d] = sd * sd; + } + std::array cov{}; + sigma_from_rot_var(R, s2[0], s2[1], s2[2], cov); + // Add delta * delta^T where delta = child_center - weighted_mean_center + float dx = level.means[idx * 3 + 0] - node.center[0]; + float dy = level.means[idx * 3 + 1] - node.center[1]; + float dz = level.means[idx * 3 + 2] - node.center[2]; + cov[0] += dx * dx + filter2; + cov[1] += dx * dy; + cov[2] += dx * dz; + cov[3] += dx * dy; + cov[4] += dy * dy + filter2; + cov[5] += dy * dz; + cov[6] += dx * dz; + cov[7] += dy * dz; + cov[8] += dz * dz + filter2; + for (size_t k = 0; k < 9; ++k) { + total_cov[k] += w * cov[k]; + } + } + + auto [eigenvalues, eigenvectors] = eigen_symmetric_3x3_jacobi(total_cov); + std::array evals = { + std::max(eigenvalues[0], 1e-18f), + std::max(eigenvalues[1], 1e-18f), + std::max(eigenvalues[2], 1e-18f), + }; + for (size_t d = 0; d < 3; ++d) { + node.scale[d] = std::sqrt(evals[d]); + } + rotmat_to_quat(eigenvectors, node.rotation); + + // Energy-preserving opacity: total child weight divided by merged + // ellipsoid area. This can legitimately exceed 1.0 for dense clusters. + constexpr float kEllipsoidAreaP = 1.6075f; + auto ellipsoid_area = [&](const std::array& s) -> float { + const float t1 = std::pow(s[0] * s[1], kEllipsoidAreaP); + const float t2 = std::pow(s[0] * s[2], kEllipsoidAreaP); + const float t3 = std::pow(s[1] * s[2], kEllipsoidAreaP); + return 4.0f * static_cast(M_PI) * std::pow((t1 + t2 + t3) / 3.0f, 1.0f / kEllipsoidAreaP); + }; + const float merged_area = ellipsoid_area(node.scale); + if (merged_area > 1.0e-30f) { + node.opacity = total_weight / merged_area; + } else { + node.opacity = total_weight; } + node.opacity = std::clamp(node.opacity, 0.000001f, 1000.0f); } return node; @@ -2183,53 +2499,27 @@ namespace lfs::io { } }; - auto make_upper_level = [&](const PackedSplatData& child_level) { - PackedSplatData parent_level; - parent_level.count = (child_level.count + kUpperLodFanout - 1) / kUpperLodFanout; - parent_level.sh_degree = sh_degree; - parent_level.sh_coeffs = child_level.sh_coeffs; - parent_level.lod_tree = true; - parent_level.means.reserve(parent_level.count * 3); - parent_level.opacity.reserve(parent_level.count); - parent_level.sh0.reserve(parent_level.count * 3); - parent_level.scales.reserve(parent_level.count * 3); - parent_level.rotation.reserve(parent_level.count * 4); - if (parent_level.sh_coeffs > 0) { - parent_level.shN.reserve(parent_level.count * static_cast(parent_level.sh_coeffs) * 3); - } - - for (size_t g = 0; g < parent_level.count; ++g) { - const size_t group_start = g * kUpperLodFanout; - const size_t group_count = std::min(kUpperLodFanout, child_level.count - group_start); - append_node_to_level(parent_level, aggregate_range(child_level, group_start, group_count)); - } - return parent_level; - }; - - // Build a bounded-fanout hierarchy above the coarsest simplified level. - // The previous implementation connected the root to arbitrary 65k-wide - // row groups, which creates scene-sized blobs and weak paging boundaries. - std::vector upper_levels; - const PackedSplatData* child_level = &levels.front(); - while (true) { - upper_levels.push_back(make_upper_level(*child_level)); - if (upper_levels.back().count == 1) { - break; + if (levels.front().count != 1) { + const size_t root_child_count = levels.front().count; + if (root_child_count > static_cast(std::numeric_limits::max())) { + LOG_WARN("RAD export: coarsest level still exceeds u16 root fanout, falling back to non-LOD"); + return std::nullopt; } - child_level = &upper_levels.back(); - } - - // Build final packed data with tree structure. Upper levels are stored - // root-first, followed by original LOD levels from coarsest to finest. + PackedSplatData root_level; + root_level.count = 1; + root_level.sh_degree = sh_degree; + root_level.sh_coeffs = levels.front().sh_coeffs; + root_level.lod_tree = true; + append_node_to_level(root_level, aggregate_range(levels.front(), 0, levels.front().count)); + levels.insert(levels.begin(), std::move(root_level)); + child_counts.insert(child_counts.begin(), {static_cast(root_child_count)}); + child_starts.insert(child_starts.begin(), {1u}); + } + + // Build final packed data with tree structure stored root-first, + // followed by progressively finer LOD levels. const size_t finest_idx = levels.size() - 1; size_t current_base = 0; - std::vector upper_bases(upper_levels.size()); - for (size_t out = 0; out < upper_levels.size(); ++out) { - const size_t upper_idx = upper_levels.size() - 1 - out; - upper_bases[upper_idx] = current_base; - current_base += upper_levels[upper_idx].count; - } - std::vector level_bases(levels.size()); for (size_t i = 0; i < levels.size(); ++i) { level_bases[i] = current_base; @@ -2265,20 +2555,7 @@ namespace lfs::io { packed.shN.reserve(packed.count * static_cast(packed.sh_coeffs) * 3); } - // Add upper hierarchy root-first. - for (size_t out = 0; out < upper_levels.size(); ++out) { - const size_t upper_idx = upper_levels.size() - 1 - out; - append_rows(packed.means, upper_levels[upper_idx].means); - packed.opacity.insert(packed.opacity.end(), upper_levels[upper_idx].opacity.begin(), upper_levels[upper_idx].opacity.end()); - append_rows(packed.sh0, upper_levels[upper_idx].sh0); - append_rows(packed.scales, upper_levels[upper_idx].scales); - append_rows(packed.rotation, upper_levels[upper_idx].rotation); - if (packed.sh_coeffs > 0) { - append_rows(packed.shN, upper_levels[upper_idx].shN); - } - } - - // Add all LOD levels (coarsest to finest) + // Add all LOD levels (coarsest/root to finest) for (size_t i = 0; i < levels.size(); ++i) { append_rows(packed.means, levels[i].means); packed.opacity.insert(packed.opacity.end(), levels[i].opacity.begin(), levels[i].opacity.end()); @@ -2290,32 +2567,16 @@ namespace lfs::io { } } - // Set up child links - for (size_t upper_idx = 0; upper_idx < upper_levels.size(); ++upper_idx) { - const bool points_to_coarsest = upper_idx == 0; - const size_t child_base = points_to_coarsest ? level_bases[0] : upper_bases[upper_idx - 1]; - const size_t child_total = points_to_coarsest ? levels[0].count : upper_levels[upper_idx - 1].count; - - for (size_t j = 0; j < upper_levels[upper_idx].count; ++j) { - const size_t group_start = j * kUpperLodFanout; - const size_t group_count = std::min(kUpperLodFanout, child_total - group_start); - const size_t idx = upper_bases[upper_idx] + j; - packed.child_count[idx] = static_cast(group_count); - packed.child_start[idx] = static_cast(child_base + group_start); - } - } - // Level-to-level links - const uint32_t index_shift = static_cast(level_bases[0]); - for (size_t i = 0; i < levels.size() - 1; ++i) { - size_t coarse_level_idx = i; - - for (size_t j = 0; j < levels[coarse_level_idx].count; ++j) { - const size_t idx = level_bases[coarse_level_idx] + j; - packed.child_count[idx] = child_counts[coarse_level_idx][j]; - if (child_counts[coarse_level_idx][j] > 0) { - packed.child_start[idx] = child_starts[coarse_level_idx][j] + index_shift; + if (child_counts[i].empty()) { + continue; + } + for (size_t j = 0; j < levels[i].count; ++j) { + const size_t idx = level_bases[i] + j; + packed.child_count[idx] = child_counts[i][j]; + if (child_counts[i][j] > 0) { + packed.child_start[idx] = child_starts[i][j]; } } } @@ -2715,12 +2976,15 @@ namespace lfs::io { std::vector all_means; std::vector all_opacity; std::vector all_sh0; - std::vector all_scales; + std::vector all_scales_linear; std::vector all_rotation; std::vector all_shN; + std::vector all_child_count; + std::vector all_child_start; const int max_sh = meta.max_sh.value_or(0); const int sh_coeffs = max_sh > 0 ? SH_COEFFS_FOR_DEGREE[max_sh] : 0; + const bool has_lod_tree = meta.lod_tree.value_or(false); for (size_t chunk_idx = 0; chunk_idx < meta.chunks.size(); ++chunk_idx) { if (offset + 8 > data.size()) { @@ -2775,6 +3039,12 @@ namespace lfs::io { std::vector chunk_scales(chunk_count * 3); std::vector chunk_rotation(chunk_count * 4); std::vector chunk_shN(chunk_count * sh_coeffs * 3, 0.0f); + std::vector chunk_child_count; + std::vector chunk_child_start; + if (has_lod_tree) { + chunk_child_count.resize(chunk_count); + chunk_child_start.resize(chunk_count); + } // Temporary buffers for component data std::vector comp_data(chunk_count); @@ -2901,15 +3171,27 @@ namespace lfs::io { int coeff = std::stoi(prop.property.substr(first_underscore + 1, second_underscore - first_underscore - 1)); int ch = prop.property.back() - '0'; PropertyDecoder::decode_sh(prop_data.data(), comp_data.data(), 1, chunk_count, - prop.encoding, - prop.min_val.value_or(0.0f), - prop.max_val.value_or(1.0f), - prop.base.value_or(0.0f), - prop.scale.value_or(1.0f)); + prop.encoding, + prop.min_val.value_or(0.0f), + prop.max_val.value_or(1.0f), + prop.base.value_or(0.0f), + prop.scale.value_or(1.0f)); for (size_t i = 0; i < chunk_count; ++i) { chunk_shN[i * sh_coeffs * 3 + coeff * 3 + ch] = comp_data[i]; } } + } else if (prop.property == PROP_CHILD_COUNT) { + if (prop_data.size() >= chunk_count * 2) { + for (size_t i = 0; i < chunk_count; ++i) { + chunk_child_count[i] = decode_u16(&prop_data[i * 2]); + } + } + } else if (prop.property == PROP_CHILD_START) { + if (prop_data.size() >= chunk_count * 4) { + for (size_t i = 0; i < chunk_count; ++i) { + chunk_child_start[i] = decode_u32(&prop_data[i * 4]); + } + } } } @@ -2917,9 +3199,13 @@ namespace lfs::io { all_means.insert(all_means.end(), chunk_means.begin(), chunk_means.end()); all_opacity.insert(all_opacity.end(), chunk_opacity.begin(), chunk_opacity.end()); all_sh0.insert(all_sh0.end(), chunk_sh0.begin(), chunk_sh0.end()); - all_scales.insert(all_scales.end(), chunk_scales.begin(), chunk_scales.end()); + all_scales_linear.insert(all_scales_linear.end(), chunk_scales.begin(), chunk_scales.end()); all_rotation.insert(all_rotation.end(), chunk_rotation.begin(), chunk_rotation.end()); all_shN.insert(all_shN.end(), chunk_shN.begin(), chunk_shN.end()); + if (has_lod_tree) { + all_child_count.insert(all_child_count.end(), chunk_child_count.begin(), chunk_child_count.end()); + all_child_start.insert(all_child_start.end(), chunk_child_start.begin(), chunk_child_start.end()); + } // Move to next chunk offset = chunk_end; @@ -2928,10 +3214,47 @@ namespace lfs::io { // Create tensors const size_t N = meta.count; + // RAD stores display RGB in SH0 slot (0.5 + SH_C0 * sh0_raw). + // Convert back to optimizer-domain sh0_raw expected by SplatData. + for (float& v : all_sh0) { + v = (v - 0.5f) / SH_C0; + } + + bool lod_opacity_encoded = false; + if (meta.splat_encoding.has_value()) { + const auto& enc = meta.splat_encoding.value(); + if (enc.is_object()) { + auto it = enc.find("lodOpacity"); + if (it != enc.end() && it->is_boolean()) { + lod_opacity_encoded = it->get(); + } + } + } + if (!lod_opacity_encoded) { + // RAD stores activated opacity alpha in [0, 1]. Convert back to + // optimizer-domain logits expected by SplatData. + for (float& v : all_opacity) { + const float a = std::clamp(v, 1.0e-6f, 1.0f - 1.0e-6f); + v = std::log(a / (1.0f - a)); + } + } else { + // Spark LOD opacity encoding stores display-space alpha directly and + // can legitimately exceed 1.0 for dense merged nodes. + for (float& v : all_opacity) { + v = std::max(v, 0.0f); + } + } + Tensor means_tensor = Tensor::from_vector(all_means, {N, 3}, Device::CPU); Tensor opacity_tensor = Tensor::from_vector(all_opacity, {N, 1}, Device::CPU); Tensor sh0_tensor = Tensor::from_vector(all_sh0, {N, 1, 3}, Device::CPU); - Tensor scales_tensor = Tensor::from_vector(all_scales, {N, 3}, Device::CPU); + // RAD stores activated (linear) scale values. SplatData expects + // optimizer-domain scaling_raw (log-space), so convert here. + std::vector all_scales_raw = all_scales_linear; + for (float& v : all_scales_raw) { + v = std::log(std::max(v, 1.0e-8f)); + } + Tensor scales_tensor = Tensor::from_vector(all_scales_raw, {N, 3}, Device::CPU); Tensor rotation_tensor = Tensor::from_vector(all_rotation, {N, 4}, Device::CPU); Tensor shN_tensor; @@ -2951,6 +3274,38 @@ namespace lfs::io { 1.0f // scene_scale ); + // Attach LOD tree if present + if (!all_child_count.empty()) { + if (all_child_count.size() != N || all_child_start.size() != N) { + return std::unexpected("RAD LOD tree size mismatch"); + } + auto tree = std::make_unique(); + tree->child_count = std::move(all_child_count); + tree->child_start = std::move(all_child_start); + tree->centers.reserve(N); + tree->sizes.reserve(N); + for (size_t i = 0; i < N; ++i) { + const float cx = all_means[i * 3 + 0]; + const float cy = all_means[i * 3 + 1]; + const float cz = all_means[i * 3 + 2]; + tree->centers.emplace_back(cx, cy, cz); + + const float sx = all_scales_linear[i * 3 + 0]; + const float sy = all_scales_linear[i * 3 + 1]; + const float sz = all_scales_linear[i * 3 + 2]; + float size = 2.0f * std::max({sx, sy, sz}); + if (lod_opacity_encoded) { + const float lod_alpha = std::max(all_opacity[i], 0.0f); + if (lod_alpha > 1.0f) { + size *= std::sqrt(lod_alpha); + } + } + tree->sizes.push_back(size); + } + tree->lod_opacity_encoded = lod_opacity_encoded; + splat_data.lod_tree = std::move(tree); + } + return std::expected(std::move(splat_data)); } }; diff --git a/src/io/loader_service.cpp b/src/io/loader_service.cpp index e671e2cce..7953ff39e 100644 --- a/src/io/loader_service.cpp +++ b/src/io/loader_service.cpp @@ -12,6 +12,7 @@ #include "io/loaders/colmap_loader.hpp" #include "io/loaders/mesh_loader.hpp" #include "io/loaders/ply_loader.hpp" +#include "io/loaders/rad_loader.hpp" #include "io/loaders/sogs_loader.hpp" #include "io/loaders/spz_loader.hpp" #include "io/loaders/usd_loader.hpp" @@ -27,6 +28,7 @@ namespace lfs::io { registry_->registerLoader(std::make_unique()); registry_->registerLoader(std::make_unique()); registry_->registerLoader(std::make_unique()); + registry_->registerLoader(std::make_unique()); registry_->registerLoader(std::make_unique()); registry_->registerLoader(std::make_unique()); registry_->registerLoader(std::make_unique()); @@ -99,7 +101,9 @@ namespace lfs::io { if (deleted.is_valid()) { migrated.deleted() = std::move(deleted); } + auto lod_tree = std::move(model.lod_tree); model = std::move(migrated); + model.lod_tree = std::move(lod_tree); model.set_tensor_allocator(allocator); lfs::core::Tensor::trim_memory_pool(); } catch (const std::exception& e) { @@ -143,7 +147,7 @@ namespace lfs::io { message = std::format( "Cannot open '{}' - unsupported file format.\n\n" "Supported formats:\n" - " - Gaussian Splat files: .ply, .sog, .spz, .usd, .usda, .usdc, .usdz\n" + " - Gaussian Splat files: .ply, .sog, .spz, .rad, .usd, .usda, .usdc, .usdz\n" " - Mesh files: .obj, .fbx, .gltf, .glb, .stl, .dae\n" " - Training checkpoints: .resume\n" " - NeRF transforms: .json", diff --git a/src/io/loaders/rad_loader.cpp b/src/io/loaders/rad_loader.cpp new file mode 100644 index 000000000..56742c4f2 --- /dev/null +++ b/src/io/loaders/rad_loader.cpp @@ -0,0 +1,140 @@ +/* SPDX-FileCopyrightText: 2025 LichtFeld Studio Authors + * + * SPDX-License-Identifier: GPL-3.0-or-later */ + +#include "rad_loader.hpp" +#include "core/logger.hpp" +#include "core/path_utils.hpp" +#include "core/splat_data.hpp" +#include "formats/rad.hpp" +#include "io/error.hpp" +#include +#include +#include +#include + +namespace lfs::io { + + using lfs::core::Device; + using lfs::core::SplatData; + using lfs::core::Tensor; + + Result RadLoader::load( + const std::filesystem::path& path, + const LoadOptions& options) { + + LOG_TIMER("RAD Loading"); + auto start_time = std::chrono::high_resolution_clock::now(); + + if (options.progress) { + options.progress(0.0f, "Loading RAD file..."); + } + + if (!std::filesystem::exists(path)) { + return make_error(ErrorCode::PATH_NOT_FOUND, + "RAD file does not exist", path); + } + + // Validation only mode + if (options.validate_only) { + LOG_DEBUG("Validation only mode for RAD: {}", lfs::core::path_to_utf8(path)); + + std::ifstream file; + if (!lfs::core::open_file_for_read(path, std::ios::binary, file)) { + return make_error(ErrorCode::READ_FAILURE, + "Cannot open RAD file", path); + } + + uint8_t header[4]; + file.read(reinterpret_cast(header), 4); + + // RAD magic: "RAD0" in little-endian = 0x30444152 + if (header[0] != 0x52 || header[1] != 0x41 || header[2] != 0x44 || header[3] != 0x30) { + return make_error(ErrorCode::INVALID_HEADER, + "Invalid RAD format (expected 'RAD0' magic)", path); + } + + if (options.progress) { + options.progress(100.0f, "RAD validation complete"); + } + + LoadResult result; + result.data = std::shared_ptr{}; + result.scene_center = Tensor::zeros({3}, Device::CPU); + result.loader_used = name(); + result.load_time = std::chrono::duration_cast( + std::chrono::high_resolution_clock::now() - start_time); + result.warnings = {}; + + return result; + } + + if (options.progress) { + options.progress(50.0f, "Decoding RAD data..."); + } + + LOG_INFO("Loading RAD file: {}", lfs::core::path_to_utf8(path)); + auto splat_result = load_rad(path); + if (!splat_result) { + return make_error(ErrorCode::CORRUPTED_DATA, + std::format("Failed to load RAD: {}", splat_result.error()), path); + } + + // Move tensors to CUDA for Vulkan renderer compatibility + SplatData& data = *splat_result; + data.means_raw() = data.means_raw().to(Device::CUDA); + data.sh0_raw() = data.sh0_raw().to(Device::CUDA); + if (data.shN_raw().is_valid() && data.shN_raw().numel() > 0) { + data.shN_raw() = data.shN_raw().to(Device::CUDA); + } + data.scaling_raw() = data.scaling_raw().to(Device::CUDA); + data.rotation_raw() = data.rotation_raw().to(Device::CUDA); + data.opacity_raw() = data.opacity_raw().to(Device::CUDA); + + if (options.progress) { + options.progress(100.0f, "RAD loading complete"); + } + + auto end_time = std::chrono::high_resolution_clock::now(); + auto load_time = std::chrono::duration_cast( + end_time - start_time); + + LoadResult result{ + .data = std::make_shared(std::move(data)), + .scene_center = Tensor::zeros({3}, Device::CPU), + .loader_used = name(), + .load_time = load_time, + .warnings = {}}; + + LOG_INFO("RAD loaded successfully in {}ms", load_time.count()); + + return result; + } + + bool RadLoader::canLoad(const std::filesystem::path& path) const { + if (!std::filesystem::exists(path)) { + return false; + } + + if (!std::filesystem::is_regular_file(path)) { + return false; + } + + auto ext = path.extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + return ext == ".rad"; + } + + std::string RadLoader::name() const { + return "RAD"; + } + + std::vector RadLoader::supportedExtensions() const { + return {".rad", ".RAD"}; + } + + int RadLoader::priority() const { + return 20; // High priority since it's our native format with LOD + } + +} // namespace lfs::io diff --git a/src/io/loaders/rad_loader.hpp b/src/io/loaders/rad_loader.hpp new file mode 100644 index 000000000..d41b944dc --- /dev/null +++ b/src/io/loaders/rad_loader.hpp @@ -0,0 +1,29 @@ +/* SPDX-FileCopyrightText: 2025 LichtFeld Studio Authors + * + * SPDX-License-Identifier: GPL-3.0-or-later */ + +#pragma once + +#include "io/loader_interface.hpp" + +namespace lfs::io { + + /** + * @brief Loader for RAD (Random Access Dynamic) hierarchical Gaussian splat files + */ + class RadLoader : public IDataLoader { + public: + RadLoader() = default; + ~RadLoader() override = default; + + [[nodiscard]] Result load( + const std::filesystem::path& path, + const LoadOptions& options = {}) override; + + bool canLoad(const std::filesystem::path& path) const override; + std::string name() const override; + std::vector supportedExtensions() const override; + int priority() const override; + }; + +} // namespace lfs::io diff --git a/src/python/CMakeLists.txt b/src/python/CMakeLists.txt index 4e71c473e..dd784835c 100644 --- a/src/python/CMakeLists.txt +++ b/src/python/CMakeLists.txt @@ -263,8 +263,8 @@ endif() target_link_libraries(lfs_py PRIVATE ${LFS_PY_INTERNAL_LINK_LIBS} - $,OpenMeshCore,OpenMeshCoreStatic> - $,OpenMeshTools,OpenMeshToolsStatic> + OpenMeshCore + OpenMeshTools OpenImageIO::OpenImageIO CUDA::cudart CUDA::curand diff --git a/src/python/lfs/module.cpp b/src/python/lfs/module.cpp index e337c2db0..7061aec21 100644 --- a/src/python/lfs/module.cpp +++ b/src/python/lfs/module.cpp @@ -1999,7 +1999,7 @@ Mesh-to-Splat: lf.get_mesh2splat_error() - Get error message Splat Simplify: - lf.simplify_splats("name", ratio=..., knn_k=..., merge_cap=..., opacity_prune_threshold=...) + lf.simplify_splats("name", ratio=..., lod_base=..., opacity_prune_threshold=...) - Simplify a splat node into a new output node lf.simplify_splat_data_with_history(splat_data, ...) - Simplify a SplatData value and return output + merge tree @@ -2071,8 +2071,7 @@ Camera Control: "build_splat_lod_hierarchy", [](nb::object source, double ratio, - int knn_k, - double merge_cap, + float lod_base, float opacity_prune_threshold, std::optional max_levels, int min_points, @@ -2082,8 +2081,7 @@ Camera Control: return helper( std::move(source), ratio, - knn_k, - merge_cap, + lod_base, opacity_prune_threshold, py_max_levels, min_points, @@ -2091,8 +2089,7 @@ Camera Control: }, nb::arg("source") = nb::none(), nb::arg("ratio") = 0.5, - nb::arg("knn_k") = 16, - nb::arg("merge_cap") = 0.5, + nb::arg("lod_base") = 2.0f, nb::arg("opacity_prune_threshold") = 0.1f, nb::arg("max_levels") = nb::none(), nb::arg("min_points") = 1, diff --git a/src/python/lfs/py_rendering.cpp b/src/python/lfs/py_rendering.cpp index 00fc80216..77c96219d 100644 --- a/src/python/lfs/py_rendering.cpp +++ b/src/python/lfs/py_rendering.cpp @@ -744,6 +744,21 @@ namespace lfs::python { "Compute metrics when jumping to a source camera", {{"Off", "OFF", 0}, {"PSNR", "PSNR", 1}, {"PSNR + SSIM", "PSNR_SSIM", 2}}, 0); + add_bool(&Proxy::lod_enabled, "lod_enabled", "Enable LOD", + "Enable hierarchical level-of-detail rendering", false); + add_bool(&Proxy::lod_debug_colors, "lod_debug_colors", "Debug Colors", + "Color splats by their LOD level for debugging", false); + add_float(&Proxy::lod_max_splats, "lod_max_splats", "Max Splats", + "Maximum number of splats to render per frame", 1500000.0, 100000.0, 5000000.0); + add_float(&Proxy::lod_render_scale, "lod_render_scale", "Render Scale", + "Resolution multiplier for LOD calculations", 1.0, 0.1, 2.0); + add_float(&Proxy::lod_cone_foveation, "lod_cone_foveation", "Cone Foveation", + "Peripheral LOD penalty factor (1.0 = no penalty)", 1.0, 0.1, 2.0); + add_float(&Proxy::lod_cone_inner_degrees, "lod_cone_inner_degrees", "Cone Inner", + "Inner cone angle in degrees (no penalty inside this angle)", 0.0, 0.0, 180.0); + add_float(&Proxy::lod_cone_outer_degrees, "lod_cone_outer_degrees", "Cone Outer", + "Outer cone angle in degrees (full penalty beyond this angle)", 0.0, 0.0, 180.0); + add_bool(&Proxy::apply_appearance_correction, "apply_appearance_correction", "Appearance Correction", "Enable PPISP appearance correction", false); add_int_enum(&Proxy::ppisp_mode, "ppisp_mode", "Mode", "PPISP correction mode", diff --git a/src/python/lfs/py_splat_simplify.cpp b/src/python/lfs/py_splat_simplify.cpp index eda1d8666..925ab3d37 100644 --- a/src/python/lfs/py_splat_simplify.cpp +++ b/src/python/lfs/py_splat_simplify.cpp @@ -70,8 +70,7 @@ namespace lfs::python { int target_count() const { return owner_->target_count; } int post_prune_count() const { return owner_->post_prune_count; } double requested_ratio() const { return owner_->requested_ratio; } - int requested_knn_k() const { return owner_->requested_knn_k; } - double requested_merge_cap() const { return owner_->requested_merge_cap; } + float requested_lod_base() const { return owner_->requested_lod_base; } float requested_opacity_prune_threshold() const { return owner_->requested_opacity_prune_threshold; } const std::vector& final_roots() const { return owner_->final_roots; } @@ -132,10 +131,8 @@ namespace lfs::python { "Count remaining after opacity pruning") .def_prop_ro("requested_ratio", &PySplatSimplifyMergeTree::requested_ratio, "Requested simplify ratio") - .def_prop_ro("requested_knn_k", &PySplatSimplifyMergeTree::requested_knn_k, - "Requested kNN neighborhood size") - .def_prop_ro("requested_merge_cap", &PySplatSimplifyMergeTree::requested_merge_cap, - "Requested per-pass merge cap") + .def_prop_ro("requested_lod_base", &PySplatSimplifyMergeTree::requested_lod_base, + "Requested LOD base factor") .def_prop_ro("requested_opacity_prune_threshold", &PySplatSimplifyMergeTree::requested_opacity_prune_threshold, "Requested opacity prune threshold") @@ -164,8 +161,7 @@ namespace lfs::python { "simplify_splats", [](const std::string& source_name, double ratio, - int knn_k, - double merge_cap, + float lod_base, float opacity_prune_threshold) { auto* scene = get_application_scene(); if (!scene) @@ -180,15 +176,13 @@ namespace lfs::python { core::SplatSimplifyOptions opts; opts.ratio = ratio; - opts.knn_k = knn_k; - opts.merge_cap = merge_cap; + opts.lod_base = lod_base; opts.opacity_prune_threshold = opacity_prune_threshold; invoke_splat_simplify_start(source_name, opts); }, nb::arg("source_name"), nb::arg("ratio") = 0.1, - nb::arg("knn_k") = 16, - nb::arg("merge_cap") = 0.5, + nb::arg("lod_base") = 2.0f, nb::arg("opacity_prune_threshold") = 0.1f, "Simplify a splat node asynchronously and create a new output node."); @@ -196,14 +190,12 @@ namespace lfs::python { "simplify_splat_data_with_history", [](const PySplatData& source, double ratio, - int knn_k, - double merge_cap, + float lod_base, float opacity_prune_threshold, nb::object progress) { core::SplatSimplifyOptions opts; opts.ratio = ratio; - opts.knn_k = knn_k; - opts.merge_cap = merge_cap; + opts.lod_base = lod_base; opts.opacity_prune_threshold = opacity_prune_threshold; PyProgressCallback py_progress{std::move(progress)}; @@ -229,8 +221,7 @@ namespace lfs::python { }, nb::arg("source"), nb::arg("ratio") = 0.1, - nb::arg("knn_k") = 16, - nb::arg("merge_cap") = 0.5, + nb::arg("lod_base") = 2.0f, nb::arg("opacity_prune_threshold") = 0.1f, nb::arg("progress") = nb::none(), "Synchronously simplify SplatData and return both the simplified output and its merge tree."); diff --git a/src/python/lfs_plugins/rendering_panel.py b/src/python/lfs_plugins/rendering_panel.py index ebde32f7f..833025b3e 100644 --- a/src/python/lfs_plugins/rendering_panel.py +++ b/src/python/lfs_plugins/rendering_panel.py @@ -50,10 +50,8 @@ def _set_theme_vignette_style(*, intensity=None, radius=None, softness=None): SENSOR_HALF_HEIGHT_MM = 12.0 DEFAULT_SIMPLIFY_TARGET_RATIO = 0.5 -DEFAULT_SIMPLIFY_KNN_K = 16 -DEFAULT_SIMPLIFY_MERGE_CAP = 0.5 +DEFAULT_SIMPLIFY_LOD_BASE = 2.0 DEFAULT_SIMPLIFY_OPACITY_PRUNE_THRESHOLD = 0.1 -MAX_SIMPLIFY_KNN_K = 64 BOOL_PROPS = [ "show_coord_axes", "show_pivot", "show_grid", "show_camera_frustums", @@ -61,6 +59,7 @@ def _set_theme_vignette_style(*, intensity=None, radius=None, softness=None): "equirectangular", "mip_filter", "mesh_wireframe", "mesh_backface_culling", "mesh_shadow_enabled", "apply_appearance_correction", "ppisp_vignette_enabled", + "lod_enabled", "lod_debug_mode", ] SLIDER_PROPS = [ @@ -70,6 +69,7 @@ def _set_theme_vignette_style(*, intensity=None, radius=None, softness=None): "ppisp_exposure", "ppisp_vignette_strength", "ppisp_gamma_multiplier", "ppisp_gamma_red", "ppisp_gamma_green", "ppisp_gamma_blue", "ppisp_crf_toe", "ppisp_crf_shoulder", + "lod_max_splats", "lod_render_scale", "lod_cone_foveation", "lod_cone_inner_degrees", "lod_cone_outer_degrees", ] SCRUB_FIELD_DEFS = { @@ -96,9 +96,13 @@ def _set_theme_vignette_style(*, intensity=None, radius=None, softness=None): "theme_vignette_radius": ScrubFieldSpec(0.0, 1.0, 0.01, "%.2f"), "theme_vignette_softness": ScrubFieldSpec(0.0, 1.0, 0.01, "%.2f"), "simplify_target": ScrubFieldSpec(1.0, 1.0, 1.0, "%d", data_type=int), - "simplify_knn_k": ScrubFieldSpec(1.0, float(MAX_SIMPLIFY_KNN_K), 1.0, "%d", data_type=int), - "simplify_merge_cap": ScrubFieldSpec(0.01, 0.5, 0.01, "%.2f"), + "simplify_lod_base": ScrubFieldSpec(0.1, 10.0, 0.1, "%.1f"), "simplify_opacity_prune_threshold": ScrubFieldSpec(0.0, 1.0, 0.01, "%.2f"), + "lod_max_splats": ScrubFieldSpec(100000.0, 5000000.0, 100000.0, "%.0f", data_type=int), + "lod_render_scale": ScrubFieldSpec(0.1, 2.0, 0.1, "%.1f"), + "lod_cone_foveation": ScrubFieldSpec(0.1, 2.0, 0.1, "%.1f"), + "lod_cone_inner_degrees": ScrubFieldSpec(0.0, 180.0, 1.0, "%.0f"), + "lod_cone_outer_degrees": ScrubFieldSpec(0.0, 180.0, 1.0, "%.0f"), } SELECT_PROPS = [ @@ -130,6 +134,7 @@ def _set_theme_vignette_style(*, intensity=None, radius=None, softness=None): SECTION_NAMES = ( "viewport", "camera", + "lod", "simplify", "selection", "mesh", @@ -181,6 +186,13 @@ def _set_theme_vignette_style(*, intensity=None, radius=None, softness=None): "ppisp_gamma_blue": "main_panel.ppisp_gamma_blue", "ppisp_crf_toe": "main_panel.ppisp_crf_toe", "ppisp_crf_shoulder": "main_panel.ppisp_crf_shoulder", + "lod_enabled": "rendering_panel.lod_enabled", + "lod_debug_mode": "rendering_panel.lod_debug_mode", + "lod_max_splats": "rendering_panel.lod_max_splats", + "lod_render_scale": "rendering_panel.lod_render_scale", + "lod_cone_foveation": "rendering_panel.lod_cone_foveation", + "lod_cone_inner_degrees": "rendering_panel.lod_cone_inner_degrees", + "lod_cone_outer_degrees": "rendering_panel.lod_cone_outer_degrees", } @@ -235,7 +247,7 @@ class RenderingPanel(Panel): def __init__(self): self._handle = None self._color_edit_prop = None - self._collapsed = {"selection", "mesh", "post_process", "ppisp_crf"} + self._collapsed = {"lod", "selection", "mesh", "post_process", "ppisp_crf"} self._popup_el = None self._doc = None self._picker_click_handled = False @@ -243,9 +255,8 @@ def __init__(self): self._last_panel_label = "" self._simplify_target_count = 0 self._simplify_target_touched = False - self._simplify_knn_k = DEFAULT_SIMPLIFY_KNN_K - self._simplify_knn_k_touched = False - self._simplify_merge_cap = DEFAULT_SIMPLIFY_MERGE_CAP + self._simplify_lod_base = DEFAULT_SIMPLIFY_LOD_BASE + self._simplify_lod_base_touched = False self._simplify_opacity_prune_threshold = DEFAULT_SIMPLIFY_OPACITY_PRUNE_THRESHOLD self._simplify_source_name = "" self._simplify_original_count = 0 @@ -256,6 +267,8 @@ def __init__(self): self._last_environment_state = None self._last_projection_state = None self._last_custom_environment_map_path = "" + self._last_lod_total_splats = 0 + self._last_lod_selected_splats = 0 self._escape_revert = w.EscapeRevertController() self._scrub_fields = ScrubFieldController( SCRUB_FIELD_DEFS, @@ -331,6 +344,10 @@ def on_bind_model(self, ctx): model.bind(prop_id, lambda p=prop_id: getattr(s(), p, False), lambda v: self._set_equirectangular(v)) + elif prop_id == "lod_debug_mode": + model.bind(prop_id, + lambda: getattr(s(), "lod_debug_colors", False), + lambda v: setattr(s(), "lod_debug_colors", v) if s() else None) else: model.bind(prop_id, lambda p=prop_id: getattr(s(), p, False), @@ -394,12 +411,9 @@ def on_bind_model(self, ctx): model.bind("simplify_target", lambda: str(self._compute_simplify_target_count()), lambda v: self._set_simplify_target_count(v)) - model.bind("simplify_knn_k", - lambda: str(self._compute_simplify_knn_k()), - lambda v: self._set_simplify_knn_k(v)) - model.bind("simplify_merge_cap", - lambda: f"{self._compute_simplify_merge_cap():.2f}", - lambda v: self._set_simplify_merge_cap(v)) + model.bind("simplify_lod_base", + lambda: f"{self._compute_simplify_lod_base():.1f}", + lambda v: self._set_simplify_lod_base(v)) model.bind("simplify_opacity_prune_threshold", lambda: f"{self._compute_simplify_opacity_prune_threshold():.2f}", lambda v: self._set_simplify_opacity_prune_threshold(v)) @@ -413,6 +427,8 @@ def on_bind_model(self, ctx): lambda: "Viewport") model.bind_func("label_hdr_camera", lambda: "Camera & Projection") + model.bind_func("label_hdr_lod", + lambda: _tr_fallback("rendering_panel.section_lod", "LOD")) model.bind_func("label_hdr_simplify", lambda: _tr_fallback("rendering_panel.section_simplify", "Splat Simplify")) model.bind_func("label_hdr_selection", @@ -431,10 +447,8 @@ def on_bind_model(self, ctx): lambda: _entry_label(_tr_fallback("rendering_panel.simplify_target", "Target"))) model.bind_func("label_simplify_target_stat", lambda: _tr_fallback("rendering_panel.simplify_target", "Target")) - model.bind_func("label_simplify_knn_k", - lambda: _entry_label(_tr_fallback("rendering_panel.simplify_knn_k", "kNN K"))) - model.bind_func("label_simplify_merge_cap", - lambda: _entry_label(_tr_fallback("rendering_panel.simplify_merge_cap", "Merge Cap"))) + model.bind_func("label_simplify_lod_base", + lambda: _entry_label(_tr_fallback("rendering_panel.simplify_lod_base", "LOD Base"))) model.bind_func("label_simplify_opacity_prune", lambda: _entry_label(_tr_fallback("rendering_panel.simplify_opacity_prune", "Opacity Prune"))) model.bind_func("label_simplify_original", @@ -479,6 +493,24 @@ def on_bind_model(self, ctx): model.bind_func("simplify_show_error", lambda: bool(self._simplify_error_text)) model.bind_func("simplify_error_text", lambda: self._simplify_error_text) + model.bind_func("lod_total_splats", self._lod_total_splats) + model.bind_func("lod_selected_splats", self._lod_selected_splats) + + model.bind_func("tooltip_lod_enabled", + lambda: lf.ui.tr("tooltip.lod_enabled") or "") + model.bind_func("tooltip_lod_max_splats", + lambda: lf.ui.tr("tooltip.lod_max_splats") or "") + model.bind_func("tooltip_lod_render_scale", + lambda: lf.ui.tr("tooltip.lod_render_scale") or "") + model.bind_func("tooltip_lod_cone_foveation", + lambda: lf.ui.tr("tooltip.lod_cone_foveation") or "") + model.bind_func("tooltip_lod_cone_inner_degrees", + lambda: lf.ui.tr("tooltip.lod_cone_inner_degrees") or "") + model.bind_func("tooltip_lod_cone_outer_degrees", + lambda: lf.ui.tr("tooltip.lod_cone_outer_degrees") or "") + model.bind_func("tooltip_lod_debug_mode", + lambda: lf.ui.tr("tooltip.lod_debug_mode") or "") + model.bind("theme_vignette_enabled", lambda: bool((vignette := _theme_vignette()) and vignette.enabled), lambda v: lf.ui.set_theme_vignette_enabled(bool(v))) @@ -537,6 +569,7 @@ def on_update(self, doc): dirty = True dirty |= self._refresh_simplify_source(force=False) dirty |= self._sync_simplify_task_state(force=False) + dirty |= self._sync_lod_stats() dirty |= self._scrub_fields.sync_all() return dirty @@ -751,10 +784,8 @@ def on_unmount(self, doc): def _get_scrub_value(self, prop): if prop == "simplify_target": return float(self._compute_simplify_target_count()) - if prop == "simplify_knn_k": - return float(self._compute_simplify_knn_k()) - if prop == "simplify_merge_cap": - return self._compute_simplify_merge_cap() + if prop == "simplify_lod_base": + return self._compute_simplify_lod_base() if prop == "simplify_opacity_prune_threshold": return self._compute_simplify_opacity_prune_threshold() if prop == "theme_vignette_intensity": @@ -776,11 +807,8 @@ def _set_scrub_value(self, prop, value): if prop == "simplify_target": self._set_simplify_target_count(value) return - if prop == "simplify_knn_k": - self._set_simplify_knn_k(value) - return - if prop == "simplify_merge_cap": - self._set_simplify_merge_cap(value) + if prop == "simplify_lod_base": + self._set_simplify_lod_base(value) return if prop == "simplify_opacity_prune_threshold": self._set_simplify_opacity_prune_threshold(value) @@ -938,6 +966,14 @@ def _dirty_model(self, *fields): for field in fields: self._handle.dirty(field) + def _lod_total_splats(self): + _node, _name, count = self._active_splat_node() + return count + + def _lod_selected_splats(self): + _node, _name, count = self._active_splat_node() + return count + def _active_splat_node(self): scene = getattr(lf, "get_scene", lambda: None)() if scene is None: @@ -981,14 +1017,14 @@ def _refresh_simplify_source(self, force: bool) -> bool: self._simplify_target_count = self._clamp_simplify_target_count(self._simplify_target_count, source_count) else: self._simplify_target_count = self._default_simplify_target_count(source_count) - if self._simplify_knn_k_touched and self._simplify_knn_k > 0: - self._simplify_knn_k = self._clamp_simplify_knn_k(self._simplify_knn_k, source_count) + if self._simplify_lod_base_touched and self._simplify_lod_base > 0: + self._simplify_lod_base = self._clamp_simplify_lod_base(self._simplify_lod_base) else: - self._simplify_knn_k = self._default_simplify_knn_k(source_count) + self._simplify_lod_base = DEFAULT_SIMPLIFY_LOD_BASE elif not self._simplify_target_touched: self._simplify_target_count = 0 - if source_count <= 0 and not self._simplify_knn_k_touched: - self._simplify_knn_k = DEFAULT_SIMPLIFY_KNN_K + if source_count <= 0 and not self._simplify_lod_base_touched: + self._simplify_lod_base = DEFAULT_SIMPLIFY_LOD_BASE self._sync_simplify_scrub_spec() self._dirty_model( "simplify_has_source", @@ -996,8 +1032,7 @@ def _refresh_simplify_source(self, force: bool) -> bool: "simplify_original_count", "simplify_target", "simplify_target_count", - "simplify_knn_k", - "simplify_merge_cap", + "simplify_lod_base", "simplify_opacity_prune_threshold", "simplify_output_name", "simplify_can_apply", @@ -1013,10 +1048,9 @@ def _sync_simplify_scrub_spec(self): "%d", data_type=int, ) - knn_max = float(self._compute_simplify_knn_k_max()) - knn_spec = ScrubFieldSpec(1.0, knn_max, 1.0, "%d", data_type=int) + lod_spec = ScrubFieldSpec(0.1, 10.0, 0.1, "%.1f") self._scrub_fields.set_spec("simplify_target", target_spec) - self._scrub_fields.set_spec("simplify_knn_k", knn_spec) + self._scrub_fields.set_spec("simplify_lod_base", lod_spec) def _default_simplify_target_count(self, original_count=None) -> int: source_count = self._simplify_original_count if original_count is None else int(original_count) @@ -1050,54 +1084,24 @@ def _compute_simplify_ratio(self) -> float: return 0.0 return float(self._compute_simplify_target_count()) / float(self._simplify_original_count) - def _compute_simplify_knn_k_max(self, original_count=None) -> int: - source_count = self._simplify_original_count if original_count is None else int(original_count) - if source_count <= 1: - return 1 - return max(1, min(MAX_SIMPLIFY_KNN_K, source_count - 1)) - - def _default_simplify_knn_k(self, original_count=None) -> int: - clamped = self._clamp_simplify_knn_k(DEFAULT_SIMPLIFY_KNN_K, original_count) - return 1 if clamped is None else clamped - - def _clamp_simplify_knn_k(self, value, original_count=None): - try: - parsed = int(round(float(str(value).strip().replace(",", "").replace("_", "")))) - except (TypeError, ValueError): - return None - return max(1, min(parsed, self._compute_simplify_knn_k_max(original_count))) - - def _compute_simplify_knn_k(self, original_count=None) -> int: - clamped = self._clamp_simplify_knn_k(self._simplify_knn_k, original_count) - if clamped is not None: - return clamped - return self._default_simplify_knn_k(original_count) - - def _set_simplify_knn_k(self, value): - next_value = self._clamp_simplify_knn_k(value) - if next_value is None or next_value == self._simplify_knn_k: - return - self._simplify_knn_k = next_value - self._simplify_knn_k_touched = True - self._dirty_model("simplify_knn_k") - - def _clamp_simplify_merge_cap(self, value): + def _clamp_simplify_lod_base(self, value): try: parsed = float(str(value).strip().replace(",", "").replace("_", "")) except (TypeError, ValueError): return None - return max(0.01, min(parsed, 0.5)) + return max(0.1, min(parsed, 10.0)) - def _compute_simplify_merge_cap(self) -> float: - clamped = self._clamp_simplify_merge_cap(self._simplify_merge_cap) - return DEFAULT_SIMPLIFY_MERGE_CAP if clamped is None else clamped + def _compute_simplify_lod_base(self) -> float: + clamped = self._clamp_simplify_lod_base(self._simplify_lod_base) + return DEFAULT_SIMPLIFY_LOD_BASE if clamped is None else clamped - def _set_simplify_merge_cap(self, value): - next_value = self._clamp_simplify_merge_cap(value) - if next_value is None or math.isclose(next_value, self._simplify_merge_cap, abs_tol=1.0e-9): + def _set_simplify_lod_base(self, value): + next_value = self._clamp_simplify_lod_base(value) + if next_value is None or math.isclose(next_value, self._simplify_lod_base, abs_tol=1.0e-9): return - self._simplify_merge_cap = next_value - self._dirty_model("simplify_merge_cap") + self._simplify_lod_base = next_value + self._simplify_lod_base_touched = True + self._dirty_model("simplify_lod_base") def _clamp_simplify_opacity_prune_threshold(self, value): try: @@ -1157,6 +1161,17 @@ def _simplify_task_state(self) -> dict[str, object]: "error": getattr(lf, "get_splat_simplify_error", lambda: "")() or "", } + def _sync_lod_stats(self) -> bool: + total = self._lod_total_splats() + selected = self._lod_selected_splats() + changed = total != self._last_lod_total_splats or selected != self._last_lod_selected_splats + if not changed: + return False + self._last_lod_total_splats = total + self._last_lod_selected_splats = selected + self._dirty_model("lod_total_splats", "lod_selected_splats") + return True + def _sync_simplify_task_state(self, force: bool) -> bool: state = self._simplify_task_state() active = bool(state.get("active", False)) @@ -1197,8 +1212,7 @@ def _start_simplify(self): lf.simplify_splats( self._simplify_source_name, ratio=self._compute_simplify_ratio(), - knn_k=self._compute_simplify_knn_k(), - merge_cap=self._compute_simplify_merge_cap(), + lod_base=self._compute_simplify_lod_base(), opacity_prune_threshold=self._compute_simplify_opacity_prune_threshold(), ) self._sync_simplify_task_state(force=True) diff --git a/src/python/lfs_splat_lod_hierarchy.py b/src/python/lfs_splat_lod_hierarchy.py index 002ee4ea8..59f89ef09 100644 --- a/src/python/lfs_splat_lod_hierarchy.py +++ b/src/python/lfs_splat_lod_hierarchy.py @@ -167,15 +167,13 @@ class SplatLodHierarchy: source_node_name: str | None source_node_id: int | None ratio: float - knn_k: int - merge_cap: float + lod_base: float opacity_prune_threshold: float max_levels: int | None min_points: int levels: list[SplatLodLevel] = field(default_factory=list) merge_node_ids: list[int] = field(default_factory=list) - merge_left: list[int] = field(default_factory=list) - merge_right: list[int] = field(default_factory=list) + merge_children: list[list[int]] = field(default_factory=list) created_lod: list[int] = field(default_factory=list) created_pass: list[int] = field(default_factory=list) @@ -196,14 +194,14 @@ def final_row_node_ids(self) -> list[int]: def is_leaf(self, node_id: int) -> bool: return int(node_id) < self.leaf_count - def children(self, node_id: int) -> tuple[int, int] | None: + def children(self, node_id: int) -> list[int] | None: node_id = int(node_id) if node_id < self.leaf_count: return None merge_offset = node_id - self.leaf_count - if merge_offset < 0 or merge_offset >= len(self.merge_left): + if merge_offset < 0 or merge_offset >= len(self.merge_children): raise KeyError(f"Unknown node id: {node_id}") - return int(self.merge_left[merge_offset]), int(self.merge_right[merge_offset]) + return list(self.merge_children[merge_offset]) def to_dict(self) -> dict[str, Any]: return { @@ -215,16 +213,14 @@ def to_dict(self) -> dict[str, Any]: "leaf_count": int(self.leaf_count), "node_count": int(self.node_count), "ratio": float(self.ratio), - "knn_k": int(self.knn_k), - "merge_cap": float(self.merge_cap), + "lod_base": float(self.lod_base), "opacity_prune_threshold": float(self.opacity_prune_threshold), "max_levels": None if self.max_levels is None else int(self.max_levels), "min_points": int(self.min_points), "levels": [level.to_dict() for level in self.levels], "merge_nodes": { "node_ids": list(self.merge_node_ids), - "left_child": list(self.merge_left), - "right_child": list(self.merge_right), + "children": [list(c) for c in self.merge_children], "created_lod": list(self.created_lod), "created_pass": list(self.created_pass), }, @@ -332,8 +328,7 @@ def save( def build_splat_lod_hierarchy( source: Any = None, ratio: float = 0.5, - knn_k: int = 16, - merge_cap: float = 0.5, + lod_base: float = 2.0, opacity_prune_threshold: float = 0.1, max_levels: int | None = None, min_points: int = 1, @@ -345,10 +340,8 @@ def build_splat_lod_hierarchy( if ratio <= 0.0 or ratio > 1.0: raise ValueError("ratio must be in the range (0, 1]") - if knn_k < 1: - raise ValueError("knn_k must be at least 1") - if merge_cap <= 0.0 or merge_cap > 0.5: - raise ValueError("merge_cap must be in the range (0, 0.5]") + if lod_base <= 1.0: + raise ValueError("lod_base must be > 1") if min_points < 1: raise ValueError("min_points must be at least 1") if max_levels is not None and max_levels < 1: @@ -366,8 +359,7 @@ def build_splat_lod_hierarchy( source_node_name=source_node_name, source_node_id=source_node_id, ratio=float(ratio), - knn_k=int(knn_k), - merge_cap=float(merge_cap), + lod_base=float(lod_base), opacity_prune_threshold=float(opacity_prune_threshold), max_levels=None if max_levels is None else int(max_levels), min_points=int(min_points), @@ -400,8 +392,7 @@ def build_splat_lod_hierarchy( result = lf.simplify_splat_data_with_history( previous_level.splat_data, ratio=float(target_count) / float(current_count), - knn_k=knn_k, - merge_cap=merge_cap, + lod_base=lod_base, opacity_prune_threshold=opacity_prune_threshold, progress=simplify_progress, ) @@ -441,8 +432,9 @@ def build_splat_lod_hierarchy( raise RuntimeError(f"Invalid merge right child id {right_local} in LOD {target_lod_level}") hierarchy.merge_node_ids.append(int(node_id)) - hierarchy.merge_left.append(int(local_to_global[left_local])) - hierarchy.merge_right.append(int(local_to_global[right_local])) + hierarchy.merge_children.append( + [int(local_to_global[left_local]), int(local_to_global[right_local])] + ) hierarchy.created_lod.append(int(target_lod_level)) hierarchy.created_pass.append(int(merge_pass[merge_index])) diff --git a/src/python/stubs/lichtfeld/__init__.pyi b/src/python/stubs/lichtfeld/__init__.pyi index fdeec22c0..131b33746 100644 --- a/src/python/stubs/lichtfeld/__init__.pyi +++ b/src/python/stubs/lichtfeld/__init__.pyi @@ -1098,12 +1098,8 @@ class SplatSimplifyMergeTree: """Requested simplify ratio""" @property - def requested_knn_k(self) -> int: - """Requested kNN neighborhood size""" - - @property - def requested_merge_cap(self) -> float: - """Requested per-pass merge cap""" + def requested_lod_base(self) -> float: + """Requested LOD base factor""" @property def requested_opacity_prune_threshold(self) -> float: @@ -1144,10 +1140,10 @@ class SplatSimplifyResult: def merge_tree(self) -> SplatSimplifyMergeTree: """Merge tree describing how the output was formed""" -def simplify_splats(source_name: str, ratio: float = 0.1, knn_k: int = 16, merge_cap: float = 0.5, opacity_prune_threshold: float = 0.10000000149011612) -> None: +def simplify_splats(source_name: str, ratio: float = 0.1, lod_base: float = 2.0, opacity_prune_threshold: float = 0.10000000149011612) -> None: """Simplify a splat node asynchronously and create a new output node.""" -def simplify_splat_data_with_history(source: scene.SplatData, ratio: float = 0.1, knn_k: int = 16, merge_cap: float = 0.5, opacity_prune_threshold: float = 0.10000000149011612, progress: object | None = None) -> SplatSimplifyResult: +def simplify_splat_data_with_history(source: scene.SplatData, ratio: float = 0.1, lod_base: float = 2.0, opacity_prune_threshold: float = 0.10000000149011612, progress: object | None = None) -> SplatSimplifyResult: """ Synchronously simplify SplatData and return both the simplified output and its merge tree. """ @@ -2226,7 +2222,7 @@ class DatasetInfo: def __repr__(self) -> str: ... -def build_splat_lod_hierarchy(source: object | None = None, ratio: float = 0.5, knn_k: int = 16, merge_cap: float = 0.5, opacity_prune_threshold: float = 0.10000000149011612, max_levels: int | None = None, min_points: int = 1, progress: object | None = None) -> object: +def build_splat_lod_hierarchy(source: object | None = None, ratio: float = 0.5, lod_base: float = 2.0, opacity_prune_threshold: float = 0.10000000149011612, max_levels: int | None = None, min_points: int = 1, progress: object | None = None) -> object: """ Build a script-side multi-level LOD hierarchy from SplatData or a scene node. """ diff --git a/src/python/stubs/lichtfeld/scene.pyi b/src/python/stubs/lichtfeld/scene.pyi index 5051d719e..e2e4d53ab 100644 --- a/src/python/stubs/lichtfeld/scene.pyi +++ b/src/python/stubs/lichtfeld/scene.pyi @@ -117,6 +117,8 @@ class NodeType(enum.Enum): GROUP = 2 + PLY_SEQUENCE = 13 + CROPBOX = 3 ELLIPSOID = 4 diff --git a/src/python/stubs/lichtfeld/ui/__init__.pyi b/src/python/stubs/lichtfeld/ui/__init__.pyi index 96c6ee60d..41e868873 100644 --- a/src/python/stubs/lichtfeld/ui/__init__.pyi +++ b/src/python/stubs/lichtfeld/ui/__init__.pyi @@ -2318,6 +2318,13 @@ class SequencerUIState: @show_film_strip.setter def show_film_strip(self, arg: bool, /) -> None: ... + @property + def sequence_fps(self) -> float: + """Playback FPS for loaded PLY sequences""" + + @sequence_fps.setter + def sequence_fps(self, arg: float, /) -> None: ... + @property def selected_keyframe(self) -> int: ... diff --git a/src/rendering/include/rendering/rendering.hpp b/src/rendering/include/rendering/rendering.hpp index b89dbc358..d1a0453ad 100644 --- a/src/rendering/include/rendering/rendering.hpp +++ b/src/rendering/include/rendering/rendering.hpp @@ -172,6 +172,11 @@ namespace lfs::rendering { bool depth_view = false; float depth_view_min = DEFAULT_NEAR_PLANE; float depth_view_max = DEFAULT_FAR_PLANE; + + // LOD index indirection (optional) + const uint32_t* lod_indices = nullptr; + size_t lod_count = 0; + bool lod_debug_mode = false; }; struct PointCloudSceneState { diff --git a/src/rendering/rasterizer/vulkan/shader/src/slang/utils.slang b/src/rendering/rasterizer/vulkan/shader/src/slang/utils.slang index d4fa4d5cd..1d65ff978 100644 --- a/src/rendering/rasterizer/vulkan/shader/src/slang/utils.slang +++ b/src/rendering/rasterizer/vulkan/shader/src/slang/utils.slang @@ -1043,6 +1043,8 @@ struct Uniforms { uint camera_model; uint sort_capacity; uint shN_layout_slots; + uint lod_enabled; + uint lod_count; uint mip_filter; uint pad2; float fx; diff --git a/src/rendering/rasterizer/vulkan/shader/src/slang/vertex_shader.slang b/src/rendering/rasterizer/vulkan/shader/src/slang/vertex_shader.slang index 6d58db2c1..d75038e76 100644 --- a/src/rendering/rasterizer/vulkan/shader/src/slang/vertex_shader.slang +++ b/src/rendering/rasterizer/vulkan/shader/src/slang/vertex_shader.slang @@ -45,6 +45,7 @@ Gaussian_3D load_gaussian_split( StructuredBuffer rotations, StructuredBuffer scaling_raw, StructuredBuffer opacity_raw, + uint lod_flags, uint active_sh, uint shN_layout_slots ) { @@ -59,7 +60,15 @@ Gaussian_3D load_gaussian_split( float3 g_raw_scaling = read_t3_float3(g_idx, scaling_raw); g_raw_scaling = min(g_raw_scaling, float3(MAX_RAW_SCALE)); float3 g_scales = exp(g_raw_scaling); - float g_opacities = sigmoid_activation(opacity_raw[g_idx]); + const bool lod_opacity_encoded = (lod_flags & 4u) != 0u; + float g_opacities = lod_opacity_encoded + ? max(opacity_raw[g_idx], 0.0f) + : sigmoid_activation(opacity_raw[g_idx]); + if (lod_opacity_encoded && g_opacities > 1.0f) { + const float inflate = sqrt(g_opacities); + g_scales *= inflate; + g_opacities /= (inflate * inflate); + } return { g_xyz_ws, g_sh_coeffs, g_rotations, g_scales, g_opacities }; } @@ -75,6 +84,13 @@ Gaussian_3D load_zero_gaussian() { return { g_xyz_ws, g_sh_coeffs, g_rotations, g_scales, g_opacities }; } +[ForceInline] +float3 chunk_debug_color(uint chunk_id) { + float hue = fract(float(chunk_id) * 0.61803398875); + float3 rgb = clamp(abs(fmod(hue * 6.0 + float3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0); + return lerp(float3(1.0), rgb, 0.85); +} + [Differentiable] Splat_2D_Vertex project_gaussian_to_camera(Gaussian_3D g, Camera cam, uint active_sh) { float3 xyz_vs = project_point(g.xyz_ws, cam); @@ -347,6 +363,9 @@ layout(binding=17) StructuredBuffer model_transforms; // Two-stage sort: per-primitive depth key written only at the success path. // CPU pre-fills with 0xFFFFFFFFu so invisible primitives sort to the tail. layout(binding=18) RWStructuredBuffer out_primitive_depth_keys; +// LOD index indirection buffer +layout(binding=19) StructuredBuffer lod_indices; + #elif EXPORT_MODE == EXPORT_MODE_VULKAN_BACKWARD @@ -394,10 +413,18 @@ void main( const float4x4 world_view_transform = (uniforms.world_view_transform); uint g_idx = dispatchThreadID.x; - + uint render_idx = g_idx; + if (g_idx >= uniforms.num_splats) return; + // LOD indirection + const bool lod_active = (uniforms.lod_enabled & 1u) != 0u; + if (lod_active) { + g_idx = lod_indices[render_idx]; + } + const uint out_idx = render_idx; + #if EXPORT_MODE == EXPORT_MODE_VULKAN_BACKWARD // no overlap bool is_zero_grad = (out_tiles_touched[g_idx] == 0); @@ -430,6 +457,7 @@ void main( Gaussian_3D gauss = load_gaussian_split( g_idx, xyz_ws, sh0, shN, rotations, scaling_raw, opacity_raw, + uniforms.lod_enabled, active_sh, uniforms.shN_layout_slots ); @@ -543,14 +571,14 @@ void main( } if (!active) { - out_overlay_flags[g_idx] = overlay_flags; - out_radii[g_idx] = 0; - out_tiles_touched[g_idx] = 0; + out_overlay_flags[out_idx] = overlay_flags; + out_radii[out_idx] = 0; + out_tiles_touched[out_idx] = 0; #if USE_EMULATED_INT64 - out_rect_tile_space[2*g_idx+0] = 0; - out_rect_tile_space[2*g_idx+1] = 0; + out_rect_tile_space[2*out_idx+0] = 0; + out_rect_tile_space[2*out_idx+1] = 0; #else - out_rect_tile_space[g_idx] = int64_t(0); + out_rect_tile_space[out_idx] = int64_t(0); #endif return; } @@ -609,14 +637,14 @@ void main( splat.det <= 0.0 ) { #if EXPORT_MODE == EXPORT_MODE_VULKAN_FORWARD - out_overlay_flags[g_idx] = overlay_flags; - out_radii[g_idx] = 0; - out_tiles_touched[g_idx] = 0; + out_overlay_flags[out_idx] = overlay_flags; + out_radii[out_idx] = 0; + out_tiles_touched[out_idx] = 0; #if USE_EMULATED_INT64 - out_rect_tile_space[2*g_idx+0] = 0; - out_rect_tile_space[2*g_idx+1] = 0; + out_rect_tile_space[2*out_idx+0] = 0; + out_rect_tile_space[2*out_idx+1] = 0; #else - out_rect_tile_space[g_idx] = int64_t(0); + out_rect_tile_space[out_idx] = int64_t(0); #endif #endif return; @@ -645,14 +673,14 @@ void main( int32_t n_tiles = int32_t(rect_tile_space.max_x - rect_tile_space.min_x) * int32_t(rect_tile_space.max_y - rect_tile_space.min_y); if (n_tiles == 0) { #if EXPORT_MODE == EXPORT_MODE_VULKAN_FORWARD - out_overlay_flags[g_idx] = overlay_flags; - out_radii[g_idx] = 0; - out_tiles_touched[g_idx] = 0; + out_overlay_flags[out_idx] = overlay_flags; + out_radii[out_idx] = 0; + out_tiles_touched[out_idx] = 0; #if USE_EMULATED_INT64 - out_rect_tile_space[2*g_idx+0] = 0; - out_rect_tile_space[2*g_idx+1] = 0; + out_rect_tile_space[2*out_idx+0] = 0; + out_rect_tile_space[2*out_idx+1] = 0; #else - out_rect_tile_space[g_idx] = int64_t(0); + out_rect_tile_space[out_idx] = int64_t(0); #endif #endif return; @@ -668,17 +696,17 @@ void main( #endif // out_radii[g_idx] = (uint32_t)radius; // inria - out_radii[g_idx] = (uint32_t)ceil(max(radii.x, radii.y)); // gsplat, only used in densification - out_tiles_touched[g_idx] = n_tiles; + out_radii[out_idx] = (uint32_t)ceil(max(radii.x, radii.y)); // gsplat, only used in densification + out_tiles_touched[out_idx] = n_tiles; #if USE_EMULATED_INT64 - out_rect_tile_space[2*g_idx+0] = (int32_t)rect_tile_space.min_x | ((int32_t)rect_tile_space.min_y << 16); - out_rect_tile_space[2*g_idx+1] = (int32_t)rect_tile_space.max_x | ((int32_t)rect_tile_space.max_y << 16); + out_rect_tile_space[2*out_idx+0] = (int32_t)rect_tile_space.min_x | ((int32_t)rect_tile_space.min_y << 16); + out_rect_tile_space[2*out_idx+1] = (int32_t)rect_tile_space.max_x | ((int32_t)rect_tile_space.max_y << 16); #else - out_rect_tile_space[g_idx] = reinterpret(rect_tile_space); + out_rect_tile_space[out_idx] = reinterpret(rect_tile_space); #endif if (n_tiles == 0) { - out_overlay_flags[g_idx] = overlay_flags; + out_overlay_flags[out_idx] = overlay_flags; return; } @@ -691,17 +719,22 @@ void main( float3 cam_origin = no_diff camera_origin_ws(cam); float3 sort_delta = mean_ws - cam_origin; float radial_sort_metric = dot(sort_delta, sort_delta); - out_primitive_depth_keys[g_idx] = reinterpret(radial_sort_metric); + out_primitive_depth_keys[out_idx] = reinterpret(radial_sort_metric); #endif // EXPORT_MODE != EXPORT_MODE_VULKAN_BACKWARD #if EXPORT_MODE == EXPORT_MODE_VULKAN_FORWARD - out_xy_vs[g_idx] = splat.xyz_vs.xy; - out_depths[g_idx] = splat.xyz_vs.z; - out_inv_cov_vs[g_idx] = splat.inv_cov_vs_opac; - write_t3_float3(splat.rgb, g_idx, out_rgb); - out_overlay_flags[g_idx] = overlay_flags; + if ((uniforms.lod_enabled & 2u) != 0u) { + const float3 debug_rgb = chunk_debug_color(uint(g_idx) >> 12u); + splat.rgb = lerp(splat.rgb, debug_rgb, 0.80f); + } + + out_xy_vs[out_idx] = splat.xyz_vs.xy; + out_depths[out_idx] = splat.xyz_vs.z; + out_inv_cov_vs[out_idx] = splat.inv_cov_vs_opac; + write_t3_float3(splat.rgb, out_idx, out_rgb); + out_overlay_flags[out_idx] = overlay_flags; #elif EXPORT_MODE == EXPORT_MODE_VULKAN_BACKWARD diff --git a/src/rendering/rasterizer/vulkan/src/buffer.h b/src/rendering/rasterizer/vulkan/src/buffer.h index 4fd65a9a0..dac60a8ec 100644 --- a/src/rendering/rasterizer/vulkan/src/buffer.h +++ b/src/rendering/rasterizer/vulkan/src/buffer.h @@ -153,6 +153,10 @@ struct VulkanGSPipelineBuffers { // can size buffers without a synchronous cumsum readback. size_t num_indices_high_water = 0; + // LOD index indirection buffer + Buffer lod_indices; // [M] selected splat indices + bool has_lod_indices = false; + [[nodiscard]] size_t getTotalOwnedAllocSize() const; [[nodiscard]] std::map getOwnedVramBreakdown() const; diff --git a/src/rendering/rasterizer/vulkan/src/gs_pipeline.cpp b/src/rendering/rasterizer/vulkan/src/gs_pipeline.cpp index d2493da8b..4af43fcd1 100644 --- a/src/rendering/rasterizer/vulkan/src/gs_pipeline.cpp +++ b/src/rendering/rasterizer/vulkan/src/gs_pipeline.cpp @@ -253,6 +253,7 @@ void VulkanGSPipeline::cleanupBuffers(VulkanGSPipelineBuffers& buffers) { _(_cumsum_blockSums2) _(_sorting_histogram) _(_sorting_histogram_cumsum) + _(lod_indices) #undef _ } diff --git a/src/rendering/rasterizer/vulkan/src/gs_renderer.cpp b/src/rendering/rasterizer/vulkan/src/gs_renderer.cpp index 9fc94aa7f..793c7f4ce 100644 --- a/src/rendering/rasterizer/vulkan/src/gs_renderer.cpp +++ b/src/rendering/rasterizer/vulkan/src/gs_renderer.cpp @@ -241,11 +241,12 @@ void VulkanGSRenderer::executeProjectionForward( const _VulkanBuffer& overlay_params, const _VulkanBuffer& model_transforms, size_t alloc_reserve, - bool use_gut_projection) { + bool use_gut_projection, + const _VulkanBuffer& lod_indices) { PerfTimer::Timer timer(this); DEVICE_GUARD; - size_t num_splats = buffers.num_splats; + const size_t num_splats = static_cast(uniforms.num_splats); bufferMemoryBarrier({ {buffers.xyz_ws.deviceBuffer, TRANSFER_COMPUTE_SHADER_WRITE}, @@ -277,33 +278,48 @@ void VulkanGSRenderer::executeProjectionForward( bufferMemoryBarrier({{primitive_depth_keys, TRANSFER_COMPUTE_SHADER_WRITE}}, COMPUTE_SHADER_READ_WRITE); + // Ensure transfer writes to optional LOD buffers are visible to projection. + if (lod_indices.buffer != VK_NULL_HANDLE) { + bufferMemoryBarrier( + { + {lod_indices, TRANSFER_COMPUTE_SHADER_WRITE}, + }, + COMPUTE_SHADER_READ); + } + + const _VulkanBuffer lod_indices_binding = + (lod_indices.buffer != VK_NULL_HANDLE) ? lod_indices : primitive_depth_keys; + + std::vector<_VulkanBuffer> projection_buffers = { + // inputs + buffers.xyz_ws.deviceBuffer, + buffers.sh0.deviceBuffer, + buffers.shN.deviceBuffer, + buffers.rotations.deviceBuffer, + buffers.scaling_raw.deviceBuffer, + buffers.opacity_raw.deviceBuffer, + // outputs + resizeDeviceBuffer(buffers.tiles_touched, alloc_size), + resizeDeviceBuffer(buffers.rect_tile_space, alloc_size), + resizeDeviceBuffer(buffers.radii, alloc_size), + resizeDeviceBuffer(buffers.xy_vs, 2 * alloc_size), + resizeDeviceBuffer(buffers.depths, alloc_size), + resizeDeviceBuffer(buffers.inv_cov_vs_opacity, 4 * alloc_size), + resizeDeviceBuffer(buffers.rgb, 3 * alloc_size), + resizeDeviceBuffer(buffers.overlay_flags, alloc_size), + transform_indices, + node_mask, + overlay_params, + model_transforms, + primitive_depth_keys, + lod_indices_binding, + }; + executeCompute( {{num_splats, SUBGROUP_SIZE}}, &uniforms, sizeof(uniforms), use_gut_projection ? pipeline_projection_forward_3dgut : pipeline_projection_forward, - { - // inputs - buffers.xyz_ws.deviceBuffer, - buffers.sh0.deviceBuffer, - buffers.shN.deviceBuffer, - buffers.rotations.deviceBuffer, - buffers.scaling_raw.deviceBuffer, - buffers.opacity_raw.deviceBuffer, - // outputs - resizeDeviceBuffer(buffers.tiles_touched, alloc_size), - resizeDeviceBuffer(buffers.rect_tile_space, alloc_size), - resizeDeviceBuffer(buffers.radii, alloc_size), - resizeDeviceBuffer(buffers.xy_vs, 2 * alloc_size), - resizeDeviceBuffer(buffers.depths, alloc_size), - resizeDeviceBuffer(buffers.inv_cov_vs_opacity, 4 * alloc_size), - resizeDeviceBuffer(buffers.rgb, 3 * alloc_size), - resizeDeviceBuffer(buffers.overlay_flags, alloc_size), - transform_indices, - node_mask, - overlay_params, - model_transforms, - primitive_depth_keys, - }); + projection_buffers); } void VulkanGSRenderer::executeGenerateKeys( @@ -312,7 +328,7 @@ void VulkanGSRenderer::executeGenerateKeys( PerfTimer::Timer timer(this); DEVICE_GUARD; - const size_t num_elements = buffers.num_splats; + const size_t num_elements = static_cast(uniforms.num_splats); // executeCalculateIndexBufferOffset has synchronously read the cumsum tail, // so num_indices is the exact tile-instance count for this frame. const size_t capacity = buffers.num_indices; @@ -709,7 +725,7 @@ void VulkanGSRenderer::executeCalculateIndexBufferOffset( VulkanGSPipelineBuffers& buffers) { PerfTimer::Timer timer(this); - const size_t num_elements = buffers.num_splats; + const size_t num_elements = static_cast(uniforms.num_splats); if (num_elements == 0) { buffers.num_indices = 0; return; @@ -757,7 +773,7 @@ void VulkanGSRenderer::executePrepareTileSort( uint32_t sort_partition_size; uint32_t pad0; } prepare_uniforms{ - static_cast(buffers.num_splats), + uniforms.num_splats, static_cast( std::min(buffers.num_indices, static_cast(std::numeric_limits::max()))), @@ -1073,7 +1089,7 @@ void VulkanGSRenderer::executeSortPrimitivesByDepth( VulkanGSPipelineBuffers& buffers) { PerfTimer::Timer timer(this); - const size_t num_splats = buffers.num_splats; + const size_t num_splats = static_cast(uniforms.num_splats); if (num_splats == 0) return; @@ -1226,7 +1242,7 @@ void VulkanGSRenderer::executeApplyDepthOrdering( PerfTimer::Timer timer(this); DEVICE_GUARD; - const size_t num_splats = buffers.num_splats; + const size_t num_splats = static_cast(uniforms.num_splats); if (num_splats == 0) return; diff --git a/src/rendering/rasterizer/vulkan/src/gs_renderer.h b/src/rendering/rasterizer/vulkan/src/gs_renderer.h index 5ca41729c..e02714fd1 100644 --- a/src/rendering/rasterizer/vulkan/src/gs_renderer.h +++ b/src/rendering/rasterizer/vulkan/src/gs_renderer.h @@ -18,16 +18,19 @@ PACK_STRUCT(struct VulkanGSRendererUniforms { uint32_t camera_model; uint32_t sort_capacity; uint32_t shN_layout_slots; + uint32_t lod_enabled; + uint32_t lod_count; uint32_t mip_filter; uint32_t pad2; float fx; float fy; float cx; float cy; + uint32_t pad3[2]; // align dist_coeffs to 16 bytes (match shader) float dist_coeffs[4]; float world_view_transform[16]; }); -static_assert(sizeof(VulkanGSRendererUniforms) == 144); +static_assert(sizeof(VulkanGSRendererUniforms) == 160); PACK_STRUCT(struct VulkanGSSelectionMaskUniforms { uint32_t num_splats; @@ -97,7 +100,8 @@ class VulkanGSRenderer : public VulkanGSPipeline { const _VulkanBuffer& overlay_params, const _VulkanBuffer& model_transforms, size_t alloc_reserve = 0, - bool use_gut_projection = false); + bool use_gut_projection = false, + const _VulkanBuffer& lod_indices = _VulkanBuffer()); void executeGenerateKeys(const VulkanGSRendererUniforms& uniforms, VulkanGSPipelineBuffers& buffers); void executeComputeTileRanges(const VulkanGSRendererUniforms& uniforms, VulkanGSPipelineBuffers& buffers); void executeRasterizeForward(const VulkanGSRendererUniforms& uniforms, @@ -167,8 +171,8 @@ class VulkanGSRenderer : public VulkanGSPipeline { void executePrepareTileSort(const VulkanGSRendererUniforms& uniforms, VulkanGSPipelineBuffers& buffers); - _ComputePipeline pipeline_projection_forward = _ComputePipeline(19); - _ComputePipeline pipeline_projection_forward_3dgut = _ComputePipeline(19); + _ComputePipeline pipeline_projection_forward = _ComputePipeline(20); + _ComputePipeline pipeline_projection_forward_3dgut = _ComputePipeline(20); _ComputePipeline pipeline_selection_mask = _ComputePipeline(9); _ComputePipeline pipeline_selection_polygon_rasterize = _ComputePipeline(2); _ComputePipeline pipeline_generate_keys = _ComputePipeline(7); diff --git a/src/visualizer/CMakeLists.txt b/src/visualizer/CMakeLists.txt index c686d6fb8..f53ce23e6 100644 --- a/src/visualizer/CMakeLists.txt +++ b/src/visualizer/CMakeLists.txt @@ -41,6 +41,7 @@ add_library(lfs_visualizer ${LFS_VIS_LIB_TYPE} rendering/rendering_manager_frame.cpp rendering/rendering_manager_vulkan.cpp rendering/rendering_manager_viewport.cpp + rendering/spark_lod_controller.cpp rendering/split_view_composition.cpp rendering/split_view_service.cpp rendering/viewport_artifact_service.cpp diff --git a/src/visualizer/gui/resources/locales/de.json b/src/visualizer/gui/resources/locales/de.json index 2b6df3ca8..61e1f6a56 100644 --- a/src/visualizer/gui/resources/locales/de.json +++ b/src/visualizer/gui/resources/locales/de.json @@ -46,6 +46,13 @@ "preferences": "Einstellungen", "histogram": "Histogram" }, + "tooltip_lod_enabled": "Hierarchisches Level-of-Detail-Rendering für RAD-Dateien aktivieren", + "tooltip_lod_max_splats": "Maximale Anzahl an Splats pro Frame", + "tooltip_lod_render_scale": "Auflösungsmultiplikator für Level of Detail-Berechnungen", + "tooltip_lod_cone_foveation": "Peripherer Level of Detail-Straffaktor (1,0 = keine Strafe)", + "tooltip_lod_cone_inner_degrees": "Innerer Kegelwinkel in Grad (keine Strafe innerhalb dieses Winkels)", + "tooltip_lod_cone_outer_degrees": "Äußerer Kegelwinkel in Grad (volle Strafe über diesen Winkel hinaus)", + "tooltip_lod_debug_mode": "Splats nach Level of Detail-Stufe zum Debuggen einfärben", "getting_started": { "title": "Erste Schritte", "description": "Erfahren Sie, wie Sie Datensätze vorbereiten und mit LichtFeld Studio beginnen:", @@ -580,10 +587,10 @@ "failed": "Fehlgeschlagen", "select_at_least_one": "Mindestens ein Modell auswählen", "tooltip": { - "rad_lod_input": "LOD-Prozentsatz (1-100). Standard: 100", - "rad_lod_add": "Diese LOD-Stufe hinzufügen", - "rad_lod_remove": "Diese LOD-Stufe entfernen", - "rad_lod_select": "LOD-Stufe zum Entfernen auswählen" + "rad_lod_input": "Level of Detail-Prozentsatz (1-100). Standard: 100", + "rad_lod_add": "Diese Level of Detail-Stufe hinzufügen", + "rad_lod_remove": "Diese Level of Detail-Stufe entfernen", + "rad_lod_select": "Level of Detail-Stufe zum Entfernen auswählen" } }, "common": { @@ -864,6 +871,7 @@ "theme_vignette_softness": "Weichheit des Vignettenrands anpassen (0-1)", "simplify_source": "Ausgewählter Splat-Knoten, der als Eingabe für die Vereinfachung dient", "simplify_target": "Zielanzahl an Gaussians, die im vereinfachten Ergebnis erhalten bleiben", + "simplify_lod_base": "LOD-Basis zur Auswahl der Vereinfachungsstufen pro Durchgang", "simplify_knn_k": "Nachbarschaftsgröße zur Bewertung von Merge-Kandidaten", "simplify_merge_cap": "Maximaler Anteil an Gaussians, die in einem Durchgang zusammengeführt werden können", "simplify_opacity_prune": "Gaussians unterhalb dieses Opazitätsschwellenwerts vor dem Mergen entfernen", @@ -871,6 +879,13 @@ "simplify_apply": "Einen neuen vereinfachten Splat-Knoten aus der ausgewählten Quelle erzeugen", "simplify_cancel": "Aktiven Splat-Vereinfachungsauftrag abbrechen", "render_scale": "Viewer-Auflösung (niedriger = weniger VRAM)", + "lod_enabled": "Hierarchisches Level-of-Detail-Rendering für RAD-Dateien aktivieren", + "lod_max_splats": "Maximale Anzahl an Splats pro Frame", + "lod_render_scale": "Auflösungsmultiplikator für Level of Detail-Berechnungen", + "lod_cone_foveation": "Peripherer Level of Detail-Straffaktor (1,0 = keine Strafe)", + "lod_cone_inner_degrees": "Innerer Kegelwinkel in Grad (keine Strafe innerhalb dieses Winkels)", + "lod_cone_outer_degrees": "Äußerer Kegelwinkel in Grad (volle Strafe über diesen Winkel hinaus)", + "lod_debug_mode": "Splats nach Level of Detail-Stufe zum Debuggen einfärben", "point_cloud_forced": "Vor Trainingsstart erzwungen", "desaturate_unselected": "Nicht ausgewählte PLYs abdunkeln", "desaturate_cropping": "Außerhalb des Zuschnitts abdunkeln statt ausblenden", @@ -1048,9 +1063,9 @@ "no_models": "Keine Modelle in der Szene", "sh_degree": "SH GRAD", "export_merged": "Zusammengeführt exportieren...", - "rad_customize_lod": "LOD anpassen", - "rad_lod_levels": "LOD-Stufen", - "no_lod_settings": "Keine LOD-Einstellungen (Verwendung von Standardwerten)", + "rad_customize_lod": "Level of Detail anpassen", + "rad_lod_levels": "Level of Detail-Stufen", + "no_lod_settings": "Keine Level of Detail-Einstellungen (Verwendung von Standardwerten)", "rad_flip_y": "Y-Achse spiegeln", "colmap_writes_sparse": "Schreibt cameras.bin, images.bin und points3D.bin", "colmap_no_sparse": "Kein COLMAP sparse-Ordner gefunden", @@ -1512,10 +1527,19 @@ }, "rendering_panel": { "fov_format": "Sichtfeld: {hfov:.1f}° H / {vfov:.1f}° V", + "section_lod": "Level of Detail", + "lod_enabled": "Level of Detail aktivieren", + "lod_max_splats": "Max. Splats", + "lod_render_scale": "Render-Skalierung", + "lod_cone_foveation": "Kegel-Foveation", + "lod_cone_inner_degrees": "Kegel-Innenwinkel", + "lod_cone_outer_degrees": "Kegel-Außenwinkel", + "lod_debug_mode": "Debug-Farben", "section_simplify": "Splat-Vereinfachung", "simplify_source": "Quelle", "simplify_select_source": "Wählen Sie einen Splat-Knoten aus", "simplify_target": "Ziel", + "simplify_lod_base": "LOD-Basis", "simplify_knn_k": "kNN K", "simplify_merge_cap": "Merge-Limit", "simplify_opacity_prune": "Opazitäts-Pruning", diff --git a/src/visualizer/gui/resources/locales/en.json b/src/visualizer/gui/resources/locales/en.json index 563d1d172..d742aca9f 100644 --- a/src/visualizer/gui/resources/locales/en.json +++ b/src/visualizer/gui/resources/locales/en.json @@ -46,6 +46,13 @@ "preferences": "Preferences", "histogram": "Histogram" }, + "tooltip_lod_enabled": "Enable hierarchical level-of-detail rendering for RAD files", + "tooltip_lod_max_splats": "Maximum number of splats to render per frame", + "tooltip_lod_render_scale": "Resolution multiplier for Level of Detail calculations", + "tooltip_lod_cone_foveation": "Peripheral Level of Detail penalty factor (1.0 = no penalty)", + "tooltip_lod_cone_inner_degrees": "Inner cone angle in degrees (no penalty inside this angle)", + "tooltip_lod_cone_outer_degrees": "Outer cone angle in degrees (full penalty beyond this angle)", + "tooltip_lod_debug_mode": "Color splats by their Level of Detail level for debugging", "getting_started": { "title": "Getting Started", "description": "Learn how to prepare datasets and get started with LichtFeld Studio:", @@ -582,10 +589,10 @@ "failed": "Failed", "select_at_least_one": "Select at least one model", "tooltip": { - "rad_lod_input": "LOD percentage (1-100). Default: 100", - "rad_lod_add": "Add this LOD level", - "rad_lod_remove": "Remove this LOD level", - "rad_lod_select": "Select LOD level to remove" + "rad_lod_input": "Level of Detail percentage (1-100). Default: 100", + "rad_lod_add": "Add this Level of Detail level", + "rad_lod_remove": "Remove this Level of Detail level", + "rad_lod_select": "Select Level of Detail level to remove" } }, "common": { @@ -872,6 +879,13 @@ "ppisp_crf_toe": "Shadow compression (toe of CRF curve)", "ppisp_crf_shoulder": "Highlight roll-off (shoulder of CRF curve)", "render_scale": "Viewer resolution (lower = less VRAM)", + "lod_enabled": "Enable hierarchical level-of-detail rendering for RAD files", + "lod_max_splats": "Maximum number of splats to render per frame", + "lod_render_scale": "Resolution multiplier for Level of Detail calculations", + "lod_cone_foveation": "Peripheral Level of Detail penalty factor (1.0 = no penalty)", + "lod_cone_inner_degrees": "Inner cone angle in degrees (no penalty inside this angle)", + "lod_cone_outer_degrees": "Outer cone angle in degrees (full penalty beyond this angle)", + "lod_debug_mode": "Color splats by their Level of Detail level for debugging", "point_cloud_forced": "Forced before training starts", "desaturate_unselected": "Dim unselected PLYs", "desaturate_cropping": "Dim outside crop area instead of hiding", @@ -929,6 +943,7 @@ "theme_vignette_softness": "Adjust vignette edge softness (0-1)", "simplify_source": "Selected splat node used as the simplification input", "simplify_target": "Target number of Gaussians to keep in the simplified result", + "simplify_lod_base": "LOD base used to choose per-pass simplification levels", "simplify_knn_k": "Neighborhood size used to evaluate merge candidates", "simplify_merge_cap": "Maximum fraction of Gaussians that can merge in a single pass", "simplify_opacity_prune": "Prune Gaussians below this opacity threshold before merging", @@ -1064,9 +1079,9 @@ "no_models": "No models in scene", "sh_degree": "SH DEGREE", "export_merged": "Export Merged...", - "rad_customize_lod": "Customize LOD", - "rad_lod_levels": "LOD Levels", - "no_lod_settings": "No LOD settings (using defaults)", + "rad_customize_lod": "Customize Level of Detail", + "rad_lod_levels": "Level of Detail Levels", + "no_lod_settings": "No Level of Detail settings (using defaults)", "rad_flip_y": "Flip Y axis", "colmap_writes_sparse": "Writes cameras.bin, images.bin, and points3D.bin", "colmap_no_sparse": "No COLMAP sparse folder found", @@ -1464,10 +1479,19 @@ }, "rendering_panel": { "fov_format": "FOV: {hfov:.1f}° H / {vfov:.1f}° V", + "section_lod": "Level of Detail", + "lod_enabled": "Enable Level of Detail", + "lod_max_splats": "Max Splats", + "lod_render_scale": "Render Scale", + "lod_cone_foveation": "Cone Foveation", + "lod_cone_inner_degrees": "Cone Inner", + "lod_cone_outer_degrees": "Cone Outer", + "lod_debug_mode": "Debug Colors", "section_simplify": "Splat Simplify", "simplify_source": "Source", "simplify_select_source": "Select a splat node", "simplify_target": "Target", + "simplify_lod_base": "LOD Base", "simplify_knn_k": "kNN K", "simplify_merge_cap": "Merge Cap", "simplify_opacity_prune": "Opacity Prune", diff --git a/src/visualizer/gui/resources/locales/es.json b/src/visualizer/gui/resources/locales/es.json index b70d9815e..0d2b6b4f4 100644 --- a/src/visualizer/gui/resources/locales/es.json +++ b/src/visualizer/gui/resources/locales/es.json @@ -252,6 +252,37 @@ "build_info.value": "Valor", "separator": " | " }, + "tooltip": { + "lod_enabled": "Habilitar renderizado jerárquico de nivel de detalle para archivos RAD", + "lod_max_splats": "Número máximo de splats a renderizar por fotograma", + "lod_render_scale": "Multiplicador de resolución para los cálculos de Level of Detail", + "lod_cone_foveation": "Factor de penalización periférica Level of Detail (1.0 = sin penalización)", + "lod_cone_inner_degrees": "Ángulo interior del cono en grados (sin penalización dentro de este ángulo)", + "lod_cone_outer_degrees": "Ángulo exterior del cono en grados (penalización completa más allá de este ángulo)", + "lod_debug_mode": "Colorear splats según su nivel de Level of Detail para depuración", + "simplify_lod_base": "Base LOD usada para elegir los niveles de simplificación por pasada" + }, + "rendering_panel": { + "fov_format": "FOV: {hfov:.1f}° H / {vfov:.1f}° V", + "section_lod": "Level of Detail", + "lod_enabled": "Habilitar Level of Detail", + "lod_max_splats": "Splats máximos", + "lod_render_scale": "Escala de render", + "lod_cone_foveation": "Foveación en cono", + "lod_cone_inner_degrees": "Ángulo interior del cono", + "lod_cone_outer_degrees": "Ángulo exterior del cono", + "lod_debug_mode": "Colores de depuración", + "section_simplify": "Simplificación de Splat", + "simplify_source": "Fuente", + "simplify_select_source": "Seleccione un nodo splat", + "simplify_target": "Objetivo", + "simplify_lod_base": "Base LOD", + "simplify_knn_k": "kNN K", + "simplify_merge_cap": "Límite de fusión", + "simplify_opacity_prune": "Poda de opacidad", + "simplify_original": "Original", + "simplify_output": "Salida" + }, "asset_manager": { "title": "Gestor de Activos", "action.add_tag": "Añadir etiqueta", diff --git a/src/visualizer/gui/resources/locales/fr.json b/src/visualizer/gui/resources/locales/fr.json index 060844e7f..1c76c7c85 100644 --- a/src/visualizer/gui/resources/locales/fr.json +++ b/src/visualizer/gui/resources/locales/fr.json @@ -46,6 +46,13 @@ "preferences": "Préférences", "histogram": "Histogram" }, + "tooltip_lod_enabled": "Activer le rendu hiérarchique de niveau de détail pour les fichiers RAD", + "tooltip_lod_max_splats": "Nombre maximal de splats à rendre par image", + "tooltip_lod_render_scale": "Multiplicateur de résolution pour les calculs Level of Detail", + "tooltip_lod_cone_foveation": "Facteur de pénalité Level of Detail périphérique (1,0 = aucune pénalité)", + "tooltip_lod_cone_inner_degrees": "Angle intérieur du cône en degrés (aucune pénalité à l'intérieur de cet angle)", + "tooltip_lod_cone_outer_degrees": "Angle extérieur du cône en degrés (pénalité complète au-delà de cet angle)", + "tooltip_lod_debug_mode": "Colorer les splats selon leur niveau Level of Detail pour le débogage", "getting_started": { "title": "Démarrage", "description": "Apprenez à préparer les datasets et à démarrer avec LichtFeld Studio :", @@ -580,10 +587,10 @@ "failed": "Échec", "select_at_least_one": "Sélectionnez au moins un modèle", "tooltip": { - "rad_lod_input": "Pourcentage LOD (1-100). Défaut: 100", - "rad_lod_add": "Ajouter ce niveau LOD", - "rad_lod_remove": "Supprimer ce niveau LOD", - "rad_lod_select": "Sélectionner le niveau LOD à supprimer" + "rad_lod_input": "Pourcentage Level of Detail (1-100). Défaut: 100", + "rad_lod_add": "Ajouter ce niveau Level of Detail", + "rad_lod_remove": "Supprimer ce niveau Level of Detail", + "rad_lod_select": "Sélectionner le niveau Level of Detail à supprimer" } }, "common": { @@ -864,6 +871,7 @@ "theme_vignette_softness": "Ajuster la douceur du bord du vignettage (0-1)", "simplify_source": "Nœud splat sélectionné utilisé comme entrée de la simplification", "simplify_target": "Nombre cible de gaussiennes à conserver dans le résultat simplifié", + "simplify_lod_base": "Base LOD utilisée pour choisir les niveaux de simplification par passe", "simplify_knn_k": "Taille du voisinage utilisée pour évaluer les candidats à la fusion", "simplify_merge_cap": "Fraction maximale de gaussiennes pouvant fusionner en une seule passe", "simplify_opacity_prune": "Supprime les gaussiennes en dessous de ce seuil d'opacité avant la fusion", @@ -871,6 +879,13 @@ "simplify_apply": "Créer un nouveau nœud splat simplifié à partir de la source sélectionnée", "simplify_cancel": "Annuler la tâche active de simplification des splats", "render_scale": "Résolution viewer (inférieur = moins VRAM)", + "lod_enabled": "Activer le rendu hiérarchique de niveau de détail pour les fichiers RAD", + "lod_max_splats": "Nombre maximal de splats à rendre par image", + "lod_render_scale": "Multiplicateur de résolution pour les calculs Level of Detail", + "lod_cone_foveation": "Facteur de pénalité Level of Detail périphérique (1,0 = aucune pénalité)", + "lod_cone_inner_degrees": "Angle intérieur du cône en degrés (aucune pénalité à l'intérieur de cet angle)", + "lod_cone_outer_degrees": "Angle extérieur du cône en degrés (pénalité complète au-delà de cet angle)", + "lod_debug_mode": "Colorer les splats selon leur niveau Level of Detail pour le débogage", "point_cloud_forced": "Forcé avant l'entraînement", "desaturate_unselected": "Assombrir les PLY non sélectionnés", "desaturate_cropping": "Assombrir l'extérieur du recadrage au lieu de masquer", @@ -1048,9 +1063,9 @@ "no_models": "Aucun modèle dans la scène", "sh_degree": "SH DEGREE", "export_merged": "Exporter Fusionné...", - "rad_customize_lod": "Personnaliser LOD", - "rad_lod_levels": "Niveaux LOD", - "no_lod_settings": "Pas de paramètres LOD (utilisation des valeurs par défaut)", + "rad_customize_lod": "Personnaliser Level of Detail", + "rad_lod_levels": "Niveaux Level of Detail", + "no_lod_settings": "Pas de paramètres Level of Detail (utilisation des valeurs par défaut)", "rad_flip_y": "Inverser l'axe Y", "colmap_writes_sparse": "Écrit cameras.bin, images.bin et points3D.bin", "colmap_no_sparse": "Aucun dossier sparse COLMAP trouvé", @@ -1512,10 +1527,19 @@ }, "rendering_panel": { "fov_format": "FOV : {hfov:.1f}° H / {vfov:.1f}° V", + "section_lod": "Level of Detail", + "lod_enabled": "Activer le Level of Detail", + "lod_max_splats": "Splats max", + "lod_render_scale": "Échelle de rendu", + "lod_cone_foveation": "Fovéation conique", + "lod_cone_inner_degrees": "Angle intérieur du cône", + "lod_cone_outer_degrees": "Angle extérieur du cône", + "lod_debug_mode": "Couleurs debug", "section_simplify": "Simplification des splats", "simplify_source": "Source", "simplify_select_source": "Sélectionnez un nœud splat", "simplify_target": "Cible", + "simplify_lod_base": "Base LOD", "simplify_knn_k": "kNN K", "simplify_merge_cap": "Limite de fusion", "simplify_opacity_prune": "Élagage d'opacité", diff --git a/src/visualizer/gui/resources/locales/it.json b/src/visualizer/gui/resources/locales/it.json index aee8efd2b..97a43a82c 100644 --- a/src/visualizer/gui/resources/locales/it.json +++ b/src/visualizer/gui/resources/locales/it.json @@ -46,6 +46,13 @@ "preferences": "Preferenze", "histogram": "Histogram" }, + "tooltip_lod_enabled": "Abilita il rendering gerarchico level-of-detail per i file RAD", + "tooltip_lod_max_splats": "Numero massimo di splat da renderizzare per frame", + "tooltip_lod_render_scale": "Moltiplicatore di risoluzione per i calcoli Level of Detail", + "tooltip_lod_cone_foveation": "Fattore di penalità Level of Detail periferica (1,0 = nessuna penalità)", + "tooltip_lod_cone_inner_degrees": "Angolo interno del cono in gradi (nessuna penalità all'interno di questo angolo)", + "tooltip_lod_cone_outer_degrees": "Angolo esterno del cono in gradi (penalità completa oltre questo angolo)", + "tooltip_lod_debug_mode": "Colora gli splat in base al livello Level of Detail per il debug", "getting_started": { "title": "Inizia", "description": "Impara a preparare i dataset e iniziare con LichtFeld Studio:", @@ -580,10 +587,10 @@ "failed": "Fallito", "select_at_least_one": "Seleziona almeno un modello", "tooltip": { - "rad_lod_input": "Percentuale LOD (1-100). Predefinito: 100", - "rad_lod_add": "Aggiungi questo livello LOD", - "rad_lod_remove": "Rimuovi questo livello LOD", - "rad_lod_select": "Seleziona il livello LOD da rimuovere" + "rad_lod_input": "Percentuale Level of Detail (1-100). Predefinito: 100", + "rad_lod_add": "Aggiungi questo livello Level of Detail", + "rad_lod_remove": "Rimuovi questo livello Level of Detail", + "rad_lod_select": "Seleziona il livello Level of Detail da rimuovere" } }, "common": { @@ -864,6 +871,7 @@ "theme_vignette_softness": "Regola la morbidezza del bordo della vignettatura (0-1)", "simplify_source": "Nodo splat selezionato usato come input per la semplificazione", "simplify_target": "Numero target di Gaussiane da mantenere nel risultato semplificato", + "simplify_lod_base": "Base LOD usata per scegliere i livelli di semplificazione per passata", "simplify_knn_k": "Dimensione del vicinato usata per valutare i candidati alla fusione", "simplify_merge_cap": "Frazione massima di Gaussiane che possono fondersi in una singola passata", "simplify_opacity_prune": "Rimuovi le Gaussiane sotto questa soglia di opacità prima della fusione", @@ -871,6 +879,13 @@ "simplify_apply": "Crea un nuovo nodo splat semplificato dalla sorgente selezionata", "simplify_cancel": "Annulla il processo attivo di semplificazione splat", "render_scale": "Risoluzione viewer (inferiore = meno VRAM)", + "lod_enabled": "Abilita il rendering gerarchico level-of-detail per i file RAD", + "lod_max_splats": "Numero massimo di splat da renderizzare per frame", + "lod_render_scale": "Moltiplicatore di risoluzione per i calcoli Level of Detail", + "lod_cone_foveation": "Fattore di penalità Level of Detail periferica (1,0 = nessuna penalità)", + "lod_cone_inner_degrees": "Angolo interno del cono in gradi (nessuna penalità all'interno di questo angolo)", + "lod_cone_outer_degrees": "Angolo esterno del cono in gradi (penalità completa oltre questo angolo)", + "lod_debug_mode": "Colora gli splat in base al livello Level of Detail per il debug", "point_cloud_forced": "Forzato prima dell'addestramento", "desaturate_unselected": "Scurisci PLY non selezionati", "desaturate_cropping": "Scurisci esterno ritaglio invece di nascondere", @@ -1048,9 +1063,9 @@ "no_models": "Nessun modello nella scena", "sh_degree": "SH DEGREE", "export_merged": "Esporta Unito...", - "rad_customize_lod": "Personalizza LOD", - "rad_lod_levels": "Livelli LOD", - "no_lod_settings": "Nessuna impostazione LOD (uso valori predefiniti)", + "rad_customize_lod": "Personalizza Level of Detail", + "rad_lod_levels": "Livelli Level of Detail", + "no_lod_settings": "Nessuna impostazione Level of Detail (uso valori predefiniti)", "rad_flip_y": "Inverti asse Y", "colmap_writes_sparse": "Scrive cameras.bin, images.bin e points3D.bin", "colmap_no_sparse": "Nessuna cartella sparse COLMAP trovata", @@ -1512,10 +1527,19 @@ }, "rendering_panel": { "fov_format": "FOV: {hfov:.1f}° H / {vfov:.1f}° V", + "section_lod": "Level of Detail", + "lod_enabled": "Abilita Level of Detail", + "lod_max_splats": "Splat massimi", + "lod_render_scale": "Scala di rendering", + "lod_cone_foveation": "Foveazione a cono", + "lod_cone_inner_degrees": "Angolo interno cono", + "lod_cone_outer_degrees": "Angolo esterno cono", + "lod_debug_mode": "Colori debug", "section_simplify": "Semplificazione Splat", "simplify_source": "Sorgente", "simplify_select_source": "Seleziona un nodo splat", "simplify_target": "Obiettivo", + "simplify_lod_base": "Base LOD", "simplify_knn_k": "kNN K", "simplify_merge_cap": "Limite di fusione", "simplify_opacity_prune": "Potatura opacità", diff --git a/src/visualizer/gui/resources/locales/ja.json b/src/visualizer/gui/resources/locales/ja.json index 404cea822..d74592b4e 100644 --- a/src/visualizer/gui/resources/locales/ja.json +++ b/src/visualizer/gui/resources/locales/ja.json @@ -46,6 +46,13 @@ "preferences": "環境設定", "histogram": "Histogram" }, + "tooltip_lod_enabled": "RADファイル向けの階層型Level of Detailレンダリングを有効化", + "tooltip_lod_max_splats": "1フレームあたりに描画するスプラットの最大数", + "tooltip_lod_render_scale": "Level of Detail計算の解像度倍率", + "tooltip_lod_cone_foveation": "周辺Level of Detailペナルティ係数 (1.0 = ペナルティなし)", + "tooltip_lod_cone_inner_degrees": "内側コーン角度(度)(この角度内はペナルティなし)", + "tooltip_lod_cone_outer_degrees": "外側コーン角度(度)(この角度を超えると完全ペナルティ)", + "tooltip_lod_debug_mode": "デバッグ用にLevel of Detailレベルごとにスプラットを色分け", "getting_started": { "title": "はじめに", "description": "データセットの準備方法とLichtFeld Studioの使い方を学びましょう:", @@ -580,10 +587,10 @@ "failed": "失敗", "select_at_least_one": "少なくとも1つのモデルを選択してください", "tooltip": { - "rad_lod_input": "LODパーセンテージ (1-100)。デフォルト: 100", - "rad_lod_add": "このLODレベルを追加", - "rad_lod_remove": "このLODレベルを削除", - "rad_lod_select": "削除するLODレベルを選択" + "rad_lod_input": "Level of Detailパーセンテージ (1-100)。デフォルト: 100", + "rad_lod_add": "このLevel of Detailレベルを追加", + "rad_lod_remove": "このLevel of Detailレベルを削除", + "rad_lod_select": "削除するLevel of Detailレベルを選択" } }, "common": { @@ -864,6 +871,7 @@ "theme_vignette_softness": "ビネット境界の柔らかさを調整 (0-1)", "simplify_source": "簡略化の入力として使う選択中のスプラットノード", "simplify_target": "簡略化結果に残すガウシアン数の目標値", + "simplify_lod_base": "パスごとの簡略化レベルを選ぶためのLOD基準", "simplify_knn_k": "統合候補を評価するために使う近傍サイズ", "simplify_merge_cap": "1回のパスで統合できるガウシアンの最大割合", "simplify_opacity_prune": "統合前にこの不透明度しきい値未満のガウシアンを削除", @@ -871,6 +879,13 @@ "simplify_apply": "選択した入力から新しい簡略化スプラットノードを作成", "simplify_cancel": "実行中のスプラット簡略化ジョブをキャンセル", "render_scale": "ビューア解像度(低い=VRAM削減)", + "lod_enabled": "RADファイル向けの階層型Level of Detailレンダリングを有効化", + "lod_max_splats": "1フレームあたりに描画するスプラットの最大数", + "lod_render_scale": "Level of Detail計算の解像度倍率", + "lod_cone_foveation": "周辺Level of Detailペナルティ係数 (1.0 = ペナルティなし)", + "lod_cone_inner_degrees": "内側コーン角度(度)(この角度内はペナルティなし)", + "lod_cone_outer_degrees": "外側コーン角度(度)(この角度を超えると完全ペナルティ)", + "lod_debug_mode": "デバッグ用にLevel of Detailレベルごとにスプラットを色分け", "point_cloud_forced": "トレーニング開始前に強制", "desaturate_unselected": "未選択PLYを暗くする", "desaturate_cropping": "クロップ外を非表示ではなく暗くする", @@ -1048,9 +1063,9 @@ "no_models": "シーンにモデルがありません", "sh_degree": "SH Degree", "export_merged": "結合してエクスポート...", - "rad_customize_lod": "LODをカスタマイズ", - "rad_lod_levels": "LODレベル", - "no_lod_settings": "LOD設定なし(デフォルトを使用)", + "rad_customize_lod": "Level of Detailをカスタマイズ", + "rad_lod_levels": "Level of Detailレベル", + "no_lod_settings": "Level of Detail設定なし(デフォルトを使用)", "rad_flip_y": "Y軸を反転", "colmap_writes_sparse": "cameras.bin、images.bin、points3D.binを書き込みます", "colmap_no_sparse": "COLMAP sparseフォルダーが見つかりません", @@ -1512,10 +1527,19 @@ }, "rendering_panel": { "fov_format": "FOV: {hfov:.1f}° H / {vfov:.1f}° V", + "section_lod": "Level of Detail", + "lod_enabled": "Level of Detailを有効化", + "lod_max_splats": "最大スプラット数", + "lod_render_scale": "レンダースケール", + "lod_cone_foveation": "コーン中心窩", + "lod_cone_inner_degrees": "コーン内角", + "lod_cone_outer_degrees": "コーン外角", + "lod_debug_mode": "デバッグ色", "section_simplify": "スプラット簡略化", "simplify_source": "ソース", "simplify_select_source": "スプラットノードを選択", "simplify_target": "目標", + "simplify_lod_base": "LOD基準", "simplify_knn_k": "kNN K", "simplify_merge_cap": "統合上限", "simplify_opacity_prune": "不透明度プルーニング", diff --git a/src/visualizer/gui/resources/locales/ko.json b/src/visualizer/gui/resources/locales/ko.json index 67b0d3fd2..df8b83b84 100644 --- a/src/visualizer/gui/resources/locales/ko.json +++ b/src/visualizer/gui/resources/locales/ko.json @@ -46,6 +46,13 @@ "preferences": "환경설정", "histogram": "Histogram" }, + "tooltip_lod_enabled": "RAD 파일용 계층형 Level of Detail 렌더링 사용", + "tooltip_lod_max_splats": "프레임당 렌더링할 최대 스플랫 수", + "tooltip_lod_render_scale": "Level of Detail 계산용 해상도 배율", + "tooltip_lod_cone_foveation": "주변 Level of Detail 패널티 계수 (1.0 = 패널티 없음)", + "tooltip_lod_cone_inner_degrees": "내부 원추 각도(도)(이 각도 내에서는 패널티 없음)", + "tooltip_lod_cone_outer_degrees": "외부 원추 각도(도)(이 각도를 넘어서면 완전 패널티)", + "tooltip_lod_debug_mode": "디버깅을 위해 Level of Detail 레벨별로 스플랫 색상 표시", "getting_started": { "title": "시작하기", "description": "데이터셋 준비 방법과 LichtFeld Studio 사용법을 배워보세요:", @@ -580,10 +587,10 @@ "failed": "실패", "select_at_least_one": "최소 하나의 모델을 선택하세요", "tooltip": { - "rad_lod_input": "LOD 백분율 (1-100). 기본값: 100", - "rad_lod_add": "이 LOD 레벨 추가", - "rad_lod_remove": "이 LOD 레벨 제거", - "rad_lod_select": "제거할 LOD 레벨 선택" + "rad_lod_input": "Level of Detail 백분율 (1-100). 기본값: 100", + "rad_lod_add": "이 Level of Detail 레벨 추가", + "rad_lod_remove": "이 Level of Detail 레벨 제거", + "rad_lod_select": "제거할 Level of Detail 레벨 선택" } }, "common": { @@ -864,6 +871,7 @@ "theme_vignette_softness": "비네팅 가장자리의 부드러움 조정 (0-1)", "simplify_source": "단순화 입력으로 사용할 선택된 스플랫 노드", "simplify_target": "단순화 결과에 유지할 가우시안 목표 개수", + "simplify_lod_base": "패스별 단순화 레벨을 선택하는 데 사용하는 LOD 기준값", "simplify_knn_k": "병합 후보를 평가하는 데 사용하는 이웃 크기", "simplify_merge_cap": "한 번의 패스에서 병합할 수 있는 가우시안의 최대 비율", "simplify_opacity_prune": "병합 전에 이 불투명도 임계값 아래의 가우시안을 제거합니다", @@ -871,6 +879,13 @@ "simplify_apply": "선택한 소스에서 새 단순화 스플랫 노드를 만듭니다", "simplify_cancel": "활성 스플랫 단순화 작업을 취소합니다", "render_scale": "뷰어 해상도 (낮을수록=VRAM 감소)", + "lod_enabled": "RAD 파일용 계층형 Level of Detail 렌더링 사용", + "lod_max_splats": "프레임당 렌더링할 최대 스플랫 수", + "lod_render_scale": "Level of Detail 계산용 해상도 배율", + "lod_cone_foveation": "주변 Level of Detail 패널티 계수 (1.0 = 패널티 없음)", + "lod_cone_inner_degrees": "내부 원추 각도(도)(이 각도 내에서는 패널티 없음)", + "lod_cone_outer_degrees": "외부 원추 각도(도)(이 각도를 넘어서면 완전 패널티)", + "lod_debug_mode": "디버깅을 위해 Level of Detail 레벨별로 스플랫 색상 표시", "point_cloud_forced": "학습 시작 전 강제", "desaturate_unselected": "선택되지 않은 PLY 어둡게", "desaturate_cropping": "자르기 외부 숨기기 대신 어둡게", @@ -1048,9 +1063,9 @@ "no_models": "씬에 모델 없음", "sh_degree": "SH Degree", "export_merged": "병합하여보내기...", - "rad_customize_lod": "LOD 사용자 정의", - "rad_lod_levels": "LOD 레벨", - "no_lod_settings": "LOD 설정 없음 (기본값 사용)", + "rad_customize_lod": "Level of Detail 사용자 정의", + "rad_lod_levels": "Level of Detail 레벨", + "no_lod_settings": "Level of Detail 설정 없음 (기본값 사용)", "rad_flip_y": "Y축 뒤집기", "colmap_writes_sparse": "cameras.bin, images.bin, points3D.bin을 씁니다", "colmap_no_sparse": "COLMAP sparse 폴더를 찾을 수 없습니다", @@ -1512,10 +1527,19 @@ }, "rendering_panel": { "fov_format": "FOV: {hfov:.1f}° H / {vfov:.1f}° V", + "section_lod": "Level of Detail", + "lod_enabled": "Level of Detail 사용", + "lod_max_splats": "최대 스플랫", + "lod_render_scale": "렌더 스케일", + "lod_cone_foveation": "원추 중심부", + "lod_cone_inner_degrees": "원추 내각", + "lod_cone_outer_degrees": "원추 외각", + "lod_debug_mode": "디버그 색상", "section_simplify": "스플랫 단순화", "simplify_source": "소스", "simplify_select_source": "스플랫 노드를 선택하세요", "simplify_target": "대상", + "simplify_lod_base": "LOD 기준", "simplify_knn_k": "kNN K", "simplify_merge_cap": "병합 한도", "simplify_opacity_prune": "불투명도 가지치기", diff --git a/src/visualizer/gui/resources/locales/nl.json b/src/visualizer/gui/resources/locales/nl.json index 2dcfdc806..d52ea23e8 100644 --- a/src/visualizer/gui/resources/locales/nl.json +++ b/src/visualizer/gui/resources/locales/nl.json @@ -46,6 +46,13 @@ "preferences": "Voorkeuren", "histogram": "Histogram" }, + "tooltip_lod_enabled": "Hiërarchische level-of-detail-rendering voor RAD-bestanden inschakelen", + "tooltip_lod_max_splats": "Maximum aantal splats om per frame te renderen", + "tooltip_lod_render_scale": "Resolutiemultiplier voor Level of Detail-berekeningen", + "tooltip_lod_cone_foveation": "Perifere Level of Detail-penaltyfactor (1,0 = geen penalty)", + "tooltip_lod_cone_inner_degrees": "Innerste hoek van de kegel in graden (geen penalty binnen deze hoek)", + "tooltip_lod_cone_outer_degrees": "Buitenste hoek van de kegel in graden (volledige penalty voorbij deze hoek)", + "tooltip_lod_debug_mode": "Kleur splats op Level of Detail-niveau voor debugging", "getting_started": { "title": "Aan de Slag", "description": "Leer hoe je datasets voorbereidt en aan de slag gaat met LichtFeld Studio:", @@ -580,10 +587,10 @@ "failed": "Mislukt", "select_at_least_one": "Selecteer minimaal één model", "tooltip": { - "rad_lod_input": "LOD-percentage (1-100). Standaard: 100", - "rad_lod_add": "Dit LOD-niveau toevoegen", - "rad_lod_remove": "Dit LOD-niveau verwijderen", - "rad_lod_select": "Selecteer LOD-niveau om te verwijderen" + "rad_lod_input": "Level of Detail-percentage (1-100). Standaard: 100", + "rad_lod_add": "Dit Level of Detail-niveau toevoegen", + "rad_lod_remove": "Dit Level of Detail-niveau verwijderen", + "rad_lod_select": "Selecteer Level of Detail-niveau om te verwijderen" } }, "common": { @@ -864,6 +871,7 @@ "theme_vignette_softness": "Zachtheid van de vignetrand aanpassen (0-1)", "simplify_source": "Geselecteerd splat-knooppunt dat als invoer voor de vereenvoudiging wordt gebruikt", "simplify_target": "Doelaantal Gaussians om in het vereenvoudigde resultaat te behouden", + "simplify_lod_base": "LOD-basis die wordt gebruikt om vereenvoudigingsniveaus per pass te kiezen", "simplify_knn_k": "Grootte van de buurt die wordt gebruikt om samenvoegkandidaten te beoordelen", "simplify_merge_cap": "Maximale fractie Gaussians die in één doorgang kunnen samenvoegen", "simplify_opacity_prune": "Verwijder Gaussians onder deze opaciteitsdrempel vóór het samenvoegen", @@ -871,6 +879,13 @@ "simplify_apply": "Maak een nieuw vereenvoudigd splat-knooppunt van de geselecteerde bron", "simplify_cancel": "Annuleer de actieve splat-vereenvoudigingstaak", "render_scale": "Viewer resolutie (lager = minder VRAM)", + "lod_enabled": "Hiërarchische level-of-detail-rendering voor RAD-bestanden inschakelen", + "lod_max_splats": "Maximum aantal splats om per frame te renderen", + "lod_render_scale": "Resolutiemultiplier voor Level of Detail-berekeningen", + "lod_cone_foveation": "Perifere Level of Detail-penaltyfactor (1,0 = geen penalty)", + "lod_cone_inner_degrees": "Innerste hoek van de kegel in graden (geen penalty binnen deze hoek)", + "lod_cone_outer_degrees": "Buitenste hoek van de kegel in graden (volledige penalty voorbij deze hoek)", + "lod_debug_mode": "Kleur splats op Level of Detail-niveau voor debugging", "point_cloud_forced": "Gedwongen voor training start", "desaturate_unselected": "Dim niet-geselecteerde PLY's", "desaturate_cropping": "Dim buiten bijsnijdgebied in plaats van verbergen", @@ -1048,9 +1063,9 @@ "no_models": "Geen modellen in scène", "sh_degree": "SH DEGREE", "export_merged": "Exporteer Samengevoegd...", - "rad_customize_lod": "LOD aanpassen", - "rad_lod_levels": "LOD-niveaus", - "no_lod_settings": "Geen LOD-instellingen (standaardwaarden gebruiken)", + "rad_customize_lod": "Level of Detail aanpassen", + "rad_lod_levels": "Level of Detail-niveaus", + "no_lod_settings": "Geen Level of Detail-instellingen (standaardwaarden gebruiken)", "rad_flip_y": "Y-as omkeren", "colmap_writes_sparse": "Schrijft cameras.bin, images.bin en points3D.bin", "colmap_no_sparse": "Geen COLMAP sparse-map gevonden", @@ -1512,10 +1527,19 @@ }, "rendering_panel": { "fov_format": "FOV: {hfov:.1f}° H / {vfov:.1f}° V", + "section_lod": "Level of Detail", + "lod_enabled": "Level of Detail inschakelen", + "lod_max_splats": "Max. splats", + "lod_render_scale": "Render-schaal", + "lod_cone_foveation": "Kegel-foveatie", + "lod_cone_inner_degrees": "Kegel binnenhoek", + "lod_cone_outer_degrees": "Kegel buitenhoek", + "lod_debug_mode": "Debugkleuren", "section_simplify": "Splat-vereenvoudiging", "simplify_source": "Bron", "simplify_select_source": "Selecteer een splat-knooppunt", "simplify_target": "Doel", + "simplify_lod_base": "LOD-basis", "simplify_knn_k": "kNN K", "simplify_merge_cap": "Samenvoeglimiet", "simplify_opacity_prune": "Opaciteitssnoei", diff --git a/src/visualizer/gui/resources/locales/pl.json b/src/visualizer/gui/resources/locales/pl.json index a3ba61e39..f009f6684 100644 --- a/src/visualizer/gui/resources/locales/pl.json +++ b/src/visualizer/gui/resources/locales/pl.json @@ -46,6 +46,13 @@ "preferences": "Preferencje", "histogram": "Histogram" }, + "tooltip_lod_enabled": "Włącz hierarchiczne renderowanie poziomów szczegółowości dla plików RAD", + "tooltip_lod_max_splats": "Maksymalna liczba splatów renderowanych na klatkę", + "tooltip_lod_render_scale": "Mnożnik rozdzielczości dla obliczeń Level of Detail", + "tooltip_lod_cone_foveation": "Współczynnik kary peryferyjnego Level of Detail (1,0 = brak kary)", + "tooltip_lod_cone_inner_degrees": "Wewnętrzny kąt stożka w stopniach (brak kary wewnątrz tego kąta)", + "tooltip_lod_cone_outer_degrees": "Zewnętrzny kąt stożka w stopniach (pełna kara poza tym kątem)", + "tooltip_lod_debug_mode": "Koloruj splaty według poziomu Level of Detail do debugowania", "getting_started": { "title": "Wprowadzenie", "description": "Dowiedz się, jak przygotować zbiory danych i rozpocząć pracę z LichtFeld Studio:", @@ -580,10 +587,10 @@ "failed": "Niepowodzenie", "select_at_least_one": "Wybierz co najmniej jeden model", "tooltip": { - "rad_lod_input": "Procent LOD (1-100). Domyślnie: 100", - "rad_lod_add": "Dodaj ten poziom LOD", - "rad_lod_remove": "Usuń ten poziom LOD", - "rad_lod_select": "Wybierz poziom LOD do usunięcia" + "rad_lod_input": "Procent Level of Detail (1-100). Domyślnie: 100", + "rad_lod_add": "Dodaj ten poziom Level of Detail", + "rad_lod_remove": "Usuń ten poziom Level of Detail", + "rad_lod_select": "Wybierz poziom Level of Detail do usunięcia" } }, "common": { @@ -864,6 +871,7 @@ "theme_vignette_softness": "Dostosuj miękkość krawędzi winietowania (0-1)", "simplify_source": "Wybrany węzeł splat używany jako wejście uproszczenia", "simplify_target": "Docelowa liczba Gaussów, które mają pozostać w uproszczonym wyniku", + "simplify_lod_base": "Baza LOD używana do wyboru poziomów uproszczenia na przebieg", "simplify_knn_k": "Rozmiar sąsiedztwa używany do oceny kandydatów do scalania", "simplify_merge_cap": "Maksymalny udział Gaussów, które mogą zostać scalone w jednym przebiegu", "simplify_opacity_prune": "Usuń Gaussy poniżej tego progu nieprzezroczystości przed scalaniem", @@ -871,6 +879,13 @@ "simplify_apply": "Utwórz nowy uproszczony węzeł splat z wybranego źródła", "simplify_cancel": "Anuluj aktywne zadanie upraszczania splatów", "render_scale": "Rozdzielczość widoku (niższa = mniej VRAM)", + "lod_enabled": "Włącz hierarchiczne renderowanie poziomów szczegółowości dla plików RAD", + "lod_max_splats": "Maksymalna liczba splatów renderowanych na klatkę", + "lod_render_scale": "Mnożnik rozdzielczości dla obliczeń Level of Detail", + "lod_cone_foveation": "Współczynnik kary peryferyjnego Level of Detail (1,0 = brak kary)", + "lod_cone_inner_degrees": "Wewnętrzny kąt stożka w stopniach (brak kary wewnątrz tego kąta)", + "lod_cone_outer_degrees": "Zewnętrzny kąt stożka w stopniach (pełna kara poza tym kątem)", + "lod_debug_mode": "Koloruj splaty według poziomu Level of Detail do debugowania", "point_cloud_forced": "Wymuszone przed rozpoczęciem treningu", "desaturate_unselected": "Przyciemnij niezaznaczone PLY", "desaturate_cropping": "Przyciemnij obszar poza przycinaniem zamiast ukrywać", @@ -1048,9 +1063,9 @@ "no_models": "Brak modeli w scenie", "sh_degree": "SH DEGREE", "export_merged": "Eksportuj Połączony...", - "rad_customize_lod": "Dostosuj LOD", - "rad_lod_levels": "Poziomy LOD", - "no_lod_settings": "Brak ustawień LOD (używanie wartości domyślnych)", + "rad_customize_lod": "Dostosuj Level of Detail", + "rad_lod_levels": "Poziomy Level of Detail", + "no_lod_settings": "Brak ustawień Level of Detail (używanie wartości domyślnych)", "rad_flip_y": "Odwróć oś Y", "colmap_writes_sparse": "Zapisuje cameras.bin, images.bin i points3D.bin", "colmap_no_sparse": "Nie znaleziono folderu sparse COLMAP", @@ -1512,10 +1527,19 @@ }, "rendering_panel": { "fov_format": "FOV: {hfov:.1f}° H / {vfov:.1f}° V", + "section_lod": "Level of Detail", + "lod_enabled": "Włącz Level of Detail", + "lod_max_splats": "Maks. splatów", + "lod_render_scale": "Skala renderowania", + "lod_cone_foveation": "Foveacja stożkowa", + "lod_cone_inner_degrees": "Kąt wewnętrzny stożka", + "lod_cone_outer_degrees": "Kąt zewnętrzny stożka", + "lod_debug_mode": "Kolory debugowania", "section_simplify": "Uproszczenie splatów", "simplify_source": "Źródło", "simplify_select_source": "Wybierz węzeł splat", "simplify_target": "Cel", + "simplify_lod_base": "Baza LOD", "simplify_knn_k": "kNN K", "simplify_merge_cap": "Limit scalania", "simplify_opacity_prune": "Przycinanie nieprzezroczystości", diff --git a/src/visualizer/gui/resources/locales/zh.json b/src/visualizer/gui/resources/locales/zh.json index d7d6da8e5..610ada945 100644 --- a/src/visualizer/gui/resources/locales/zh.json +++ b/src/visualizer/gui/resources/locales/zh.json @@ -46,6 +46,13 @@ "preferences": "偏好设置", "histogram": "Histogram" }, + "tooltip_lod_enabled": "启用 RAD 文件的分层 Level of Detail 渲染", + "tooltip_lod_max_splats": "每帧渲染的最大 Splat 数量", + "tooltip_lod_render_scale": "Level of Detail 计算的分辨率倍率", + "tooltip_lod_cone_foveation": "周边Level of Detail惩罚因子 (1.0 = 无惩罚)", + "tooltip_lod_cone_inner_degrees": "内锥角度(度)(此角度内无惩罚)", + "tooltip_lod_cone_outer_degrees": "外锥角度(度)(超出此角度完全惩罚)", + "tooltip_lod_debug_mode": "按 Level of Detail 级别为 Splat 着色以便调试", "getting_started": { "title": "入门指南", "description": "学习如何准备数据集并开始使用LichtFeld Studio:", @@ -410,10 +417,10 @@ "failed": "失败", "select_at_least_one": "至少选择一个模型", "tooltip": { - "rad_lod_input": "LOD百分比 (1-100)。默认: 100", - "rad_lod_add": "添加此LOD级别", - "rad_lod_remove": "移除此LOD级别", - "rad_lod_select": "选择要移除的LOD级别" + "rad_lod_input": "Level of Detail百分比 (1-100)。默认: 100", + "rad_lod_add": "添加此Level of Detail级别", + "rad_lod_remove": "移除此Level of Detail级别", + "rad_lod_select": "选择要移除的Level of Detail级别" } }, "common": { @@ -694,6 +701,7 @@ "theme_vignette_softness": "调整暗角边缘的柔和度 (0-1)", "simplify_source": "用作简化输入的已选 splat 节点", "simplify_target": "简化结果中要保留的目标高斯数量", + "simplify_lod_base": "用于选择每次简化级别的 LOD 基数", "simplify_knn_k": "用于评估合并候选的邻域大小", "simplify_merge_cap": "单次迭代中允许合并的高斯最大比例", "simplify_opacity_prune": "在合并前剪除低于该不透明度阈值的高斯", @@ -701,6 +709,13 @@ "simplify_apply": "根据所选来源创建一个新的简化 splat 节点", "simplify_cancel": "取消当前正在运行的 splat 简化任务", "render_scale": "查看器分辨率(越低=越少VRAM)", + "lod_enabled": "启用 RAD 文件的分层 Level of Detail 渲染", + "lod_max_splats": "每帧渲染的最大 Splat 数量", + "lod_render_scale": "Level of Detail 计算的分辨率倍率", + "lod_cone_foveation": "周边Level of Detail惩罚因子 (1.0 = 无惩罚)", + "lod_cone_inner_degrees": "内锥角度(度)(此角度内无惩罚)", + "lod_cone_outer_degrees": "外锥角度(度)(超出此角度完全惩罚)", + "lod_debug_mode": "按 Level of Detail 级别为 Splat 着色以便调试", "point_cloud_forced": "训练开始前强制启用", "desaturate_unselected": "使未选择的PLY变暗", "desaturate_cropping": "变暗裁剪区域外部而非隐藏", @@ -878,9 +893,9 @@ "no_models": "场景中无模型", "sh_degree": "SH Degree", "export_merged": "导出合并的...", - "rad_customize_lod": "自定义LOD", - "rad_lod_levels": "LOD级别", - "no_lod_settings": "无LOD设置(使用默认值)", + "rad_customize_lod": "自定义Level of Detail", + "rad_lod_levels": "Level of Detail级别", + "no_lod_settings": "无Level of Detail设置(使用默认值)", "rad_flip_y": "翻转Y轴", "colmap_writes_sparse": "写入 cameras.bin、images.bin 和 points3D.bin", "colmap_no_sparse": "未找到 COLMAP sparse 文件夹", @@ -1171,6 +1186,27 @@ "drop_to_import": "放下以导入", "drop_to_import_subtitle": "PLY、SOG、SPZ、USD、COLMAP数据集或项目" }, + "rendering_panel": { + "fov_format": "FOV: {hfov:.1f}° H / {vfov:.1f}° V", + "section_lod": "Level of Detail", + "lod_enabled": "启用Level of Detail", + "lod_max_splats": "最大Splat数", + "lod_render_scale": "渲染尺度", + "lod_cone_foveation": "锥形注视", + "lod_cone_inner_degrees": "锥形内角", + "lod_cone_outer_degrees": "锥形外角", + "lod_debug_mode": "调试颜色", + "section_simplify": "Splat简化", + "simplify_source": "源", + "simplify_select_source": "选择一个splat节点", + "simplify_target": "目标", + "simplify_lod_base": "LOD 基数", + "simplify_knn_k": "kNN K", + "simplify_merge_cap": "合并上限", + "simplify_opacity_prune": "不透明度修剪", + "simplify_original": "原始", + "simplify_output": "输出" + }, "asset_manager": { "title": "资源管理器", "panel_title": "资源管理器", diff --git a/src/visualizer/gui/rmlui/resources/rendering.rml b/src/visualizer/gui/rmlui/resources/rendering.rml index 36e0c71c2..217f3a9fc 100644 --- a/src/visualizer/gui/rmlui/resources/rendering.rml +++ b/src/visualizer/gui/rmlui/resources/rendering.rml @@ -198,6 +198,63 @@ + +
+ + {{label_hdr_lod}} +
+ +
@@ -219,18 +276,11 @@ {{simplify_target_count}}
-
- {{label_simplify_knn_k}} -
- - {{simplify_knn_k}} -
-
-
- {{label_simplify_merge_cap}} +
+ {{label_simplify_lod_base}}
- - {{simplify_merge_cap}} + + {{simplify_lod_base}}
diff --git a/src/visualizer/ipc/render_settings_convert.hpp b/src/visualizer/ipc/render_settings_convert.hpp index 77bdb1c86..86f6fa725 100644 --- a/src/visualizer/ipc/render_settings_convert.hpp +++ b/src/visualizer/ipc/render_settings_convert.hpp @@ -82,6 +82,14 @@ namespace lfs::vis { p.depth_filter_max = detail::to_array(s.depth_filter_max); p.depth_filter_rotation = detail::to_array(s.depth_filter_transform.getRotation()); p.depth_filter_translation = detail::to_array(s.depth_filter_transform.getTranslation()); + p.lod_enabled = s.lod_enabled; + p.lod_debug_colors = s.lod_debug_colors; + p.lod_max_splats = static_cast(s.lod_max_splats); + p.lod_pixel_scale_limit = s.lod_pixel_scale_limit; + p.lod_render_scale = s.lod_render_scale; + p.lod_cone_foveation = s.lod_cone_foveation; + p.lod_cone_inner_degrees = s.lod_cone_inner_degrees; + p.lod_cone_outer_degrees = s.lod_cone_outer_degrees; return p; } @@ -159,6 +167,14 @@ namespace lfs::vis { s.depth_filter_transform = lfs::geometry::EuclideanTransform(detail::to_quat(p.depth_filter_rotation), detail::to_vec3(p.depth_filter_translation)); + s.lod_enabled = p.lod_enabled; + s.lod_debug_colors = p.lod_debug_colors; + s.lod_max_splats = static_cast(p.lod_max_splats); + s.lod_pixel_scale_limit = p.lod_pixel_scale_limit; + s.lod_render_scale = p.lod_render_scale; + s.lod_cone_foveation = p.lod_cone_foveation; + s.lod_cone_inner_degrees = p.lod_cone_inner_degrees; + s.lod_cone_outer_degrees = p.lod_cone_outer_degrees; } } // namespace lfs::vis diff --git a/src/visualizer/ipc/view_context.hpp b/src/visualizer/ipc/view_context.hpp index 80cd7d1c3..185b4d2c8 100644 --- a/src/visualizer/ipc/view_context.hpp +++ b/src/visualizer/ipc/view_context.hpp @@ -134,6 +134,15 @@ namespace lfs::vis { std::array depth_filter_max{50.0f, 10000.0f, 100.0f}; std::array depth_filter_rotation{1.0f, 0.0f, 0.0f, 0.0f}; std::array depth_filter_translation{0.0f, 0.0f, 0.0f}; + + bool lod_enabled = false; + bool lod_debug_colors = false; + float lod_max_splats = 1500000.0f; + float lod_pixel_scale_limit = 0.0001f; + float lod_render_scale = 1.0f; + float lod_cone_foveation = 1.0f; + float lod_cone_inner_degrees = 0.0f; + float lod_cone_outer_degrees = 0.0f; }; using GetRenderSettingsCallback = std::function()>; diff --git a/src/visualizer/operator/operator.hpp b/src/visualizer/operator/operator.hpp index c506f61ab..0ae1d9719 100644 --- a/src/visualizer/operator/operator.hpp +++ b/src/visualizer/operator/operator.hpp @@ -4,6 +4,7 @@ #pragma once +#include "core/export.hpp" #include "operator_context.hpp" #include "operator_flags.hpp" #include "operator_id.hpp" diff --git a/src/visualizer/operator/ops/align_ops.hpp b/src/visualizer/operator/ops/align_ops.hpp index 8af508fe7..cb48aad56 100644 --- a/src/visualizer/operator/ops/align_ops.hpp +++ b/src/visualizer/operator/ops/align_ops.hpp @@ -12,7 +12,7 @@ namespace lfs::vis::op { class AlignPickPointOperator : public Operator { public: - static const OperatorDescriptor DESCRIPTOR; + static LFS_LOCAL_SYMBOL const OperatorDescriptor DESCRIPTOR; [[nodiscard]] const OperatorDescriptor& descriptor() const override { return DESCRIPTOR; } [[nodiscard]] bool poll(const OperatorContext& ctx, const OperatorProperties* props = nullptr) const override; diff --git a/src/visualizer/operator/ops/brush_ops.hpp b/src/visualizer/operator/ops/brush_ops.hpp index 3731705e8..addf05b2f 100644 --- a/src/visualizer/operator/ops/brush_ops.hpp +++ b/src/visualizer/operator/ops/brush_ops.hpp @@ -24,7 +24,7 @@ namespace lfs::vis::op { class BrushStrokeOperator : public Operator { public: - static const OperatorDescriptor DESCRIPTOR; + static LFS_LOCAL_SYMBOL const OperatorDescriptor DESCRIPTOR; [[nodiscard]] const OperatorDescriptor& descriptor() const override { return DESCRIPTOR; } [[nodiscard]] bool poll(const OperatorContext& ctx, const OperatorProperties* props = nullptr) const override; diff --git a/src/visualizer/operator/ops/edit_ops.hpp b/src/visualizer/operator/ops/edit_ops.hpp index becd08b20..c4e8658e8 100644 --- a/src/visualizer/operator/ops/edit_ops.hpp +++ b/src/visualizer/operator/ops/edit_ops.hpp @@ -11,7 +11,7 @@ namespace lfs::vis::op { class LFS_VIS_API UndoOperator : public Operator { public: - static const OperatorDescriptor DESCRIPTOR; + static LFS_LOCAL_SYMBOL const OperatorDescriptor DESCRIPTOR; [[nodiscard]] const OperatorDescriptor& descriptor() const override { return DESCRIPTOR; } [[nodiscard]] bool poll(const OperatorContext& ctx, const OperatorProperties* props = nullptr) const override; @@ -20,7 +20,7 @@ namespace lfs::vis::op { class LFS_VIS_API RedoOperator : public Operator { public: - static const OperatorDescriptor DESCRIPTOR; + static LFS_LOCAL_SYMBOL const OperatorDescriptor DESCRIPTOR; [[nodiscard]] const OperatorDescriptor& descriptor() const override { return DESCRIPTOR; } [[nodiscard]] bool poll(const OperatorContext& ctx, const OperatorProperties* props = nullptr) const override; @@ -29,7 +29,7 @@ namespace lfs::vis::op { class LFS_VIS_API DeleteOperator : public Operator { public: - static const OperatorDescriptor DESCRIPTOR; + static LFS_LOCAL_SYMBOL const OperatorDescriptor DESCRIPTOR; [[nodiscard]] const OperatorDescriptor& descriptor() const override { return DESCRIPTOR; } [[nodiscard]] bool poll(const OperatorContext& ctx, const OperatorProperties* props = nullptr) const override; diff --git a/src/visualizer/operator/ops/scene_ops.hpp b/src/visualizer/operator/ops/scene_ops.hpp index c0ecbc6d5..82e135f9c 100644 --- a/src/visualizer/operator/ops/scene_ops.hpp +++ b/src/visualizer/operator/ops/scene_ops.hpp @@ -10,7 +10,7 @@ namespace lfs::vis::op { class SelectionClearOperator : public Operator { public: - static const OperatorDescriptor DESCRIPTOR; + static LFS_LOCAL_SYMBOL const OperatorDescriptor DESCRIPTOR; [[nodiscard]] const OperatorDescriptor& descriptor() const override { return DESCRIPTOR; } [[nodiscard]] bool poll(const OperatorContext& ctx, const OperatorProperties* props = nullptr) const override; @@ -19,7 +19,7 @@ namespace lfs::vis::op { class SceneSelectNodeOperator : public Operator { public: - static const OperatorDescriptor DESCRIPTOR; + static LFS_LOCAL_SYMBOL const OperatorDescriptor DESCRIPTOR; [[nodiscard]] const OperatorDescriptor& descriptor() const override { return DESCRIPTOR; } [[nodiscard]] bool poll(const OperatorContext& ctx, const OperatorProperties* props = nullptr) const override; @@ -28,7 +28,7 @@ namespace lfs::vis::op { class CropBoxAddOperator : public Operator { public: - static const OperatorDescriptor DESCRIPTOR; + static LFS_LOCAL_SYMBOL const OperatorDescriptor DESCRIPTOR; [[nodiscard]] const OperatorDescriptor& descriptor() const override { return DESCRIPTOR; } [[nodiscard]] bool poll(const OperatorContext& ctx, const OperatorProperties* props = nullptr) const override; @@ -37,7 +37,7 @@ namespace lfs::vis::op { class CropBoxSetOperator : public Operator { public: - static const OperatorDescriptor DESCRIPTOR; + static LFS_LOCAL_SYMBOL const OperatorDescriptor DESCRIPTOR; [[nodiscard]] const OperatorDescriptor& descriptor() const override { return DESCRIPTOR; } [[nodiscard]] bool poll(const OperatorContext& ctx, const OperatorProperties* props = nullptr) const override; @@ -46,7 +46,7 @@ namespace lfs::vis::op { class CropBoxFitOperator : public Operator { public: - static const OperatorDescriptor DESCRIPTOR; + static LFS_LOCAL_SYMBOL const OperatorDescriptor DESCRIPTOR; [[nodiscard]] const OperatorDescriptor& descriptor() const override { return DESCRIPTOR; } [[nodiscard]] bool poll(const OperatorContext& ctx, const OperatorProperties* props = nullptr) const override; @@ -55,7 +55,7 @@ namespace lfs::vis::op { class CropBoxResetOperator : public Operator { public: - static const OperatorDescriptor DESCRIPTOR; + static LFS_LOCAL_SYMBOL const OperatorDescriptor DESCRIPTOR; [[nodiscard]] const OperatorDescriptor& descriptor() const override { return DESCRIPTOR; } [[nodiscard]] bool poll(const OperatorContext& ctx, const OperatorProperties* props = nullptr) const override; @@ -64,7 +64,7 @@ namespace lfs::vis::op { class EllipsoidAddOperator : public Operator { public: - static const OperatorDescriptor DESCRIPTOR; + static LFS_LOCAL_SYMBOL const OperatorDescriptor DESCRIPTOR; [[nodiscard]] const OperatorDescriptor& descriptor() const override { return DESCRIPTOR; } [[nodiscard]] bool poll(const OperatorContext& ctx, const OperatorProperties* props = nullptr) const override; @@ -73,7 +73,7 @@ namespace lfs::vis::op { class EllipsoidSetOperator : public Operator { public: - static const OperatorDescriptor DESCRIPTOR; + static LFS_LOCAL_SYMBOL const OperatorDescriptor DESCRIPTOR; [[nodiscard]] const OperatorDescriptor& descriptor() const override { return DESCRIPTOR; } [[nodiscard]] bool poll(const OperatorContext& ctx, const OperatorProperties* props = nullptr) const override; @@ -82,7 +82,7 @@ namespace lfs::vis::op { class EllipsoidFitOperator : public Operator { public: - static const OperatorDescriptor DESCRIPTOR; + static LFS_LOCAL_SYMBOL const OperatorDescriptor DESCRIPTOR; [[nodiscard]] const OperatorDescriptor& descriptor() const override { return DESCRIPTOR; } [[nodiscard]] bool poll(const OperatorContext& ctx, const OperatorProperties* props = nullptr) const override; @@ -91,7 +91,7 @@ namespace lfs::vis::op { class EllipsoidResetOperator : public Operator { public: - static const OperatorDescriptor DESCRIPTOR; + static LFS_LOCAL_SYMBOL const OperatorDescriptor DESCRIPTOR; [[nodiscard]] const OperatorDescriptor& descriptor() const override { return DESCRIPTOR; } [[nodiscard]] bool poll(const OperatorContext& ctx, const OperatorProperties* props = nullptr) const override; diff --git a/src/visualizer/operator/ops/selection_ops.hpp b/src/visualizer/operator/ops/selection_ops.hpp index 77be1acb3..9721bf619 100644 --- a/src/visualizer/operator/ops/selection_ops.hpp +++ b/src/visualizer/operator/ops/selection_ops.hpp @@ -12,7 +12,7 @@ namespace lfs::vis::op { class LFS_VIS_API SelectionStrokeOperator : public Operator { public: - static const OperatorDescriptor DESCRIPTOR; + static LFS_LOCAL_SYMBOL const OperatorDescriptor DESCRIPTOR; [[nodiscard]] const OperatorDescriptor& descriptor() const override { return DESCRIPTOR; } [[nodiscard]] bool poll(const OperatorContext& ctx, const OperatorProperties* props = nullptr) const override; diff --git a/src/visualizer/operator/ops/transform_ops.hpp b/src/visualizer/operator/ops/transform_ops.hpp index 531cbe548..0a8ca663b 100644 --- a/src/visualizer/operator/ops/transform_ops.hpp +++ b/src/visualizer/operator/ops/transform_ops.hpp @@ -11,7 +11,7 @@ namespace lfs::vis::op { class LFS_VIS_API TransformSetOperator : public Operator { public: - static const OperatorDescriptor DESCRIPTOR; + static LFS_LOCAL_SYMBOL const OperatorDescriptor DESCRIPTOR; [[nodiscard]] const OperatorDescriptor& descriptor() const override { return DESCRIPTOR; } [[nodiscard]] bool poll(const OperatorContext& ctx, const OperatorProperties* props = nullptr) const override; @@ -20,7 +20,7 @@ namespace lfs::vis::op { class LFS_VIS_API TransformTranslateOperator : public Operator { public: - static const OperatorDescriptor DESCRIPTOR; + static LFS_LOCAL_SYMBOL const OperatorDescriptor DESCRIPTOR; [[nodiscard]] const OperatorDescriptor& descriptor() const override { return DESCRIPTOR; } [[nodiscard]] bool poll(const OperatorContext& ctx, const OperatorProperties* props = nullptr) const override; @@ -29,7 +29,7 @@ namespace lfs::vis::op { class LFS_VIS_API TransformRotateOperator : public Operator { public: - static const OperatorDescriptor DESCRIPTOR; + static LFS_LOCAL_SYMBOL const OperatorDescriptor DESCRIPTOR; [[nodiscard]] const OperatorDescriptor& descriptor() const override { return DESCRIPTOR; } [[nodiscard]] bool poll(const OperatorContext& ctx, const OperatorProperties* props = nullptr) const override; @@ -38,7 +38,7 @@ namespace lfs::vis::op { class LFS_VIS_API TransformScaleOperator : public Operator { public: - static const OperatorDescriptor DESCRIPTOR; + static LFS_LOCAL_SYMBOL const OperatorDescriptor DESCRIPTOR; [[nodiscard]] const OperatorDescriptor& descriptor() const override { return DESCRIPTOR; } [[nodiscard]] bool poll(const OperatorContext& ctx, const OperatorProperties* props = nullptr) const override; @@ -47,7 +47,7 @@ namespace lfs::vis::op { class LFS_VIS_API TransformApplyBatchOperator : public Operator { public: - static const OperatorDescriptor DESCRIPTOR; + static LFS_LOCAL_SYMBOL const OperatorDescriptor DESCRIPTOR; [[nodiscard]] const OperatorDescriptor& descriptor() const override { return DESCRIPTOR; } [[nodiscard]] bool poll(const OperatorContext& ctx, const OperatorProperties* props = nullptr) const override; diff --git a/src/visualizer/rendering/rendering_manager.cpp b/src/visualizer/rendering/rendering_manager.cpp index 7a5662a72..cf672e16a 100644 --- a/src/visualizer/rendering/rendering_manager.cpp +++ b/src/visualizer/rendering/rendering_manager.cpp @@ -131,6 +131,23 @@ namespace lfs::vis { } } + void RenderingManager::setLodAvailable(bool available) { + lod_available_ = available; + } + + void RenderingManager::setLodEnabled(bool enabled) { + { + std::lock_guard lock(settings_mutex_); + settings_.lod_enabled = enabled; + } + markDirty(DirtyFlag::SPLATS); + } + + bool RenderingManager::isLodEnabled() const { + std::lock_guard lock(settings_mutex_); + return settings_.lod_enabled; + } + void RenderingManager::releaseSceneModelResources() { clearVulkanMeshFrame(); diff --git a/src/visualizer/rendering/rendering_manager.hpp b/src/visualizer/rendering/rendering_manager.hpp index 26d99831d..442e60d67 100644 --- a/src/visualizer/rendering/rendering_manager.hpp +++ b/src/visualizer/rendering/rendering_manager.hpp @@ -19,6 +19,7 @@ #include "rendering/rendering.hpp" #include "rendering/screen_overlay_renderer.hpp" #include "rendering_types.hpp" +#include "spark_lod_controller.hpp" #include "split_view_service.hpp" #include "viewport_artifact_service.hpp" #include "viewport_frame_lifecycle_service.hpp" @@ -459,6 +460,11 @@ namespace lfs::vis { } bool consumeResizeCompleted() { return frame_lifecycle_service_.consumeResizeCompleted(); } + // LOD management + void setLodAvailable(bool available); + void setLodEnabled(bool enabled); + [[nodiscard]] bool isLodEnabled() const; + private: std::shared_ptr renderPreviewImageWithState( SceneManager* scene_manager, @@ -543,6 +549,10 @@ namespace lfs::vis { std::uint64_t vulkan_viewport_image_generation_ = 0; std::unique_ptr vksplat_viewport_renderer_; std::unique_ptr point_cloud_vulkan_renderer_; + std::unique_ptr lod_controller_; + const lfs::core::SplatData* lod_controller_model_ = nullptr; + bool lod_was_active_last_frame_ = false; + bool lod_need_sync_fallback_ = false; // Cached SH0→RGB derivation for the point-cloud Vulkan path. Refreshed // only when the source sh0_raw() pointer/size changes so the Vulkan // renderer's per-tensor upload cache stays warm across frames. @@ -586,6 +596,7 @@ namespace lfs::vis { uint64_t camera_metrics_request_generation_ = 0; std::chrono::steady_clock::time_point last_camera_metrics_refresh_time_{}; bool initialized_ = false; + bool lod_available_ = false; ViewportInteractionContext viewport_interaction_context_; diff --git a/src/visualizer/rendering/rendering_manager_vulkan.cpp b/src/visualizer/rendering/rendering_manager_vulkan.cpp index 45bf54106..9f1ba3926 100644 --- a/src/visualizer/rendering/rendering_manager_vulkan.cpp +++ b/src/visualizer/rendering/rendering_manager_vulkan.cpp @@ -19,6 +19,7 @@ #include "vulkan_external_tensor.hpp" #include #include +#include #include #include #include @@ -864,6 +865,14 @@ namespace lfs::vis { auto request = request_override ? *request_override : buildViewportRenderRequest(frame_ctx, panel_size, &source_viewport, panel_id); + if (settings_.lod_enabled && lod_controller_ && lod_controller_->hasTree()) { + const auto& selected = lod_controller_->selectedIndices(); + if (!selected.empty()) { + request.lod_indices = selected.data(); + request.lod_count = selected.size(); + request.lod_debug_mode = settings_.lod_debug_colors; + } + } std::vector transforms_storage; if (model_transforms_override) { request.scene.model_transforms = model_transforms_override; @@ -1415,6 +1424,67 @@ namespace lfs::vis { request.raster_backend = lfs::rendering::normalizeViewerRasterBackend(request.raster_backend, request.gut); request.gut = lfs::rendering::isGutBackend(request.raster_backend); + + const bool lod_active = + settings_.lod_enabled && model && model->lod_tree && model->lod_tree->has_tree(); + if (lod_active) { + if (!lod_controller_) { + lod_controller_ = std::make_unique(); + } + if (lod_controller_model_ != model) { + lod_controller_.reset(); + lod_controller_ = std::make_unique(); + lod_controller_->attach(*model); + lod_controller_model_ = model; + lod_need_sync_fallback_ = true; + } + if (!lod_was_active_last_frame_) { + lod_need_sync_fallback_ = true; + } + + SparkLodController::LodParameters params; + params.max_splats = settings_.lod_max_splats; + params.lod_render_scale = settings_.lod_render_scale; + params.behind_camera_penalty = settings_.lod_behind_camera_penalty; + params.cone_foveation = settings_.lod_cone_foveation; + params.cone_inner_degrees = settings_.lod_cone_inner_degrees; + params.cone_outer_degrees = settings_.lod_cone_outer_degrees; + + // Compute pixel_scale_limit dynamically from camera FOV and viewport size, + // matching Spark's runtime computation. + { + const auto& fv = request.frame_view; + if (fv.orthographic) { + params.pixel_scale_limit = fv.ortho_scale / static_cast(fv.size.y); + } else { + float vfov = lfs::rendering::focalLengthToVFov(fv.focal_length_mm); + float half_tan_fov = std::tan(glm::radians(vfov) * 0.5f); + params.pixel_scale_limit = (2.0f * half_tan_fov) / static_cast(fv.size.y); + } + } + + // Get view matrix from the frame context + const glm::mat4 view_matrix = request.frame_view.getViewMatrix(); + if (lod_need_sync_fallback_) { + lod_controller_->update(view_matrix, params); + lod_need_sync_fallback_ = false; + } else { + lod_controller_->swapAsyncResults(); + lod_controller_->updateAsync(view_matrix, params); + } + const auto& selected = lod_controller_->selectedIndices(); + if (!selected.empty()) { + request.lod_indices = selected.data(); + request.lod_count = selected.size(); + } + request.lod_debug_mode = settings_.lod_debug_colors; + } else { + lod_controller_.reset(); + lod_controller_model_ = nullptr; + lod_need_sync_fallback_ = true; + } + lod_was_active_last_frame_ = lod_active; + if (lfs::rendering::isVkSplatBackend(request.raster_backend)) { if (!context.vulkan_context) { render_error = "VkSplat backend requires an active Vulkan context"; diff --git a/src/visualizer/rendering/rendering_types.hpp b/src/visualizer/rendering/rendering_types.hpp index e2069b366..1e5a48c6c 100644 --- a/src/visualizer/rendering/rendering_types.hpp +++ b/src/visualizer/rendering/rendering_types.hpp @@ -252,6 +252,18 @@ namespace lfs::vis { glm::vec3 depth_filter_min = glm::vec3(-50.0f, -10000.0f, 0.0f); glm::vec3 depth_filter_max = glm::vec3(50.0f, 10000.0f, 100.0f); lfs::geometry::EuclideanTransform depth_filter_transform; + + // ---- LOD (Spark-style) ---- + bool lod_enabled = false; // Master toggle + bool lod_auto_enable_rad = false; // Keep LOD off by default, even for .rad + size_t lod_max_splats = 1'500'000; // Splat budget (desktop default) + float lod_pixel_scale_limit = 0.0001f; + float lod_render_scale = 1.0f; + float lod_behind_camera_penalty = 2.0f; + float lod_cone_foveation = 1.0f; + float lod_cone_inner_degrees = 0.0f; + float lod_cone_outer_degrees = 0.0f; + bool lod_debug_colors = false; // Per-level color tinting }; inline void enforceProjectionBackend(RenderSettings& settings) { diff --git a/src/visualizer/rendering/spark_lod_controller.cpp b/src/visualizer/rendering/spark_lod_controller.cpp new file mode 100644 index 000000000..7ceca5b15 --- /dev/null +++ b/src/visualizer/rendering/spark_lod_controller.cpp @@ -0,0 +1,339 @@ +/* SPDX-FileCopyrightText: 2025 LichtFeld Studio Authors + * + * SPDX-License-Identifier: GPL-3.0-or-later */ + +#include "spark_lod_controller.hpp" +#include "core/logger.hpp" + +#include +#include +#include +#include +#include + +namespace lfs::vis { + +SparkLodController::SparkLodController() { + worker_ = std::jthread([this](std::stop_token stop_token) { + workerLoop(stop_token); + }); +} + +SparkLodController::~SparkLodController() { + cv_.notify_all(); +} + +void SparkLodController::attach(const lfs::core::SplatData& data) { + detach(); + if (!data.lod_tree || !data.lod_tree->has_tree()) { + return; + } + + data_ = &data; + const auto& tree = *data.lod_tree; + const size_t n = tree.total_nodes(); + if (n == 0 || n > static_cast(data.size())) { + detach(); + return; + } + if (tree.child_start.size() < n || tree.child_count.size() < n) { + detach(); + return; + } + nodes_.resize(n); + + const bool has_cached_centers = tree.centers.size() >= n; + const bool has_cached_sizes = tree.sizes.size() >= n; + const float* means_ptr = nullptr; + const float* scales_ptr = nullptr; + lfs::core::Tensor means_cpu; + lfs::core::Tensor scaling_cpu; + if (!has_cached_centers) { + means_cpu = data.means().cpu(); + means_ptr = means_cpu.ptr(); + } + if (!has_cached_sizes) { + scaling_cpu = data.scaling_raw().cpu(); + scales_ptr = scaling_cpu.ptr(); + } + + for (size_t i = 0; i < n; ++i) { + if (has_cached_centers) { + nodes_[i].center = tree.centers[i]; + } else { + nodes_[i].center = glm::vec3( + means_ptr[i * 3 + 0], + means_ptr[i * 3 + 1], + means_ptr[i * 3 + 2]); + } + + if (has_cached_sizes) { + nodes_[i].size = tree.sizes[i]; + } else { + float sx = std::exp(scales_ptr[i * 3 + 0]); + float sy = std::exp(scales_ptr[i * 3 + 1]); + float sz = std::exp(scales_ptr[i * 3 + 2]); + nodes_[i].size = 2.0f * std::max({sx, sy, sz}); + } + + nodes_[i].child_start = tree.child_start[i]; + nodes_[i].child_count = tree.child_count[i]; + nodes_[i].lod_level = (i < tree.lod_level.size()) ? tree.lod_level[i] : 0; + } + + // Compute lod_level via BFS if not provided by loader + if (tree.lod_level.empty()) { + std::vector bfs_level(n, 0); + std::queue q; + q.push(0); + bfs_level[0] = 0; + while (!q.empty()) { + uint32_t idx = q.front(); q.pop(); + uint8_t level = bfs_level[idx]; + nodes_[idx].lod_level = level; + for (uint32_t c = 0; c < nodes_[idx].child_count; ++c) { + uint32_t child_idx = nodes_[idx].child_start + c; + if (child_idx < n) { + bfs_level[child_idx] = level + 1; + q.push(child_idx); + } + } + } + } + + std::size_t non_leaf_count = 0; + std::uint16_t max_child_count = 0; + for (const auto& node : nodes_) { + if (node.child_count > 0) { + ++non_leaf_count; + max_child_count = std::max(max_child_count, node.child_count); + } + } + LOG_INFO( + "LOD attach: nodes={} non_leaf_nodes={} root_child_count={} max_child_count={}", + nodes_.size(), + non_leaf_count, + nodes_.empty() ? 0u : static_cast(nodes_[0].child_count), + static_cast(max_child_count)); +} + +void SparkLodController::detach() { + data_ = nullptr; + nodes_.clear(); + selected_indices_.clear(); + { + std::scoped_lock lock(mutex_); + pending_work_.reset(); + ready_available_ = false; + async_indices_.clear(); + ready_swap_indices_.clear(); + } +} + +float SparkLodController::computePixelScale(uint32_t node_index, + const glm::mat4& view_matrix, + const LodParameters& params) const { + const auto& node = nodes_[node_index]; + glm::vec4 center_vs = view_matrix * glm::vec4(node.center, 1.0f); + float radial_dist = glm::length(glm::vec3(center_vs)); + if (radial_dist <= 0.0f) { + return std::numeric_limits::max(); + } + + float pixel_scale = (node.size * params.lod_render_scale) / radial_dist; + + if (center_vs.z < 0.0f) { + pixel_scale *= params.behind_camera_penalty; + } + + // Cone foveation: penalize off-axis splats so peripheral regions use finer LOD. + float forward_dot = -center_vs.z; + if (forward_dot > 0.0f && (params.cone_inner_degrees > 0.0f || params.cone_outer_degrees > 0.0f)) { + float inv_distance = 1.0f / radial_dist; + float dot = forward_dot * inv_distance; + float inner_degrees = std::clamp(params.cone_inner_degrees, 0.0f, 180.0f); + float outer_degrees = std::clamp(params.cone_outer_degrees, 0.0f, 180.0f); + float cone_dot0 = inner_degrees > 0.0f ? std::cos(glm::radians(inner_degrees * 0.5f)) : 1.0f; + float cone_dot = outer_degrees > 0.0f ? std::cos(glm::radians(outer_degrees * 0.5f)) : 1.0f; + cone_dot = std::min(cone_dot, cone_dot0); + + float foveate; + if (dot >= cone_dot0) { + foveate = 1.0f; + } else if (dot >= cone_dot) { + float denom = cone_dot0 - cone_dot; + if (denom < 1.0e-6f) { + foveate = 1.0f; + } else { + float t = (dot - cone_dot) / denom; + foveate = params.cone_foveation + (1.0f - params.cone_foveation) * t; + } + } else { + if (cone_dot < 1.0e-6f) { + foveate = params.behind_camera_penalty; + } else { + float t = dot / cone_dot; + foveate = params.behind_camera_penalty + (params.cone_foveation - params.behind_camera_penalty) * t; + } + } + pixel_scale *= foveate; + } + + return pixel_scale; +} + +size_t SparkLodController::update(const glm::mat4& view_matrix, const LodParameters& params) { + { + std::scoped_lock lock(mutex_); + ready_available_ = false; + } + const size_t count = traverse(view_matrix, params, selected_indices_); + last_params_ = params; + return count; +} + +void SparkLodController::updateAsync(const glm::mat4& view_matrix, const LodParameters& params) { + { + std::scoped_lock lock(mutex_); + pending_work_ = WorkItem{view_matrix, params}; + } + cv_.notify_one(); +} + +bool SparkLodController::swapAsyncResults() { + std::scoped_lock lock(mutex_); + if (!ready_available_) { + return false; + } + selected_indices_.swap(ready_swap_indices_); + ready_available_ = false; + return true; +} + +bool SparkLodController::hasReadyResults() const { + std::scoped_lock lock(mutex_); + return ready_available_; +} + +void SparkLodController::workerLoop(std::stop_token stop_token) { + while (true) { + WorkItem work{}; + { + std::unique_lock lock(mutex_); + cv_.wait(lock, stop_token, [this]() { + return pending_work_.has_value(); + }); + if (stop_token.stop_requested()) { + return; + } + work = *pending_work_; + pending_work_.reset(); + } + + traverse(work.view_matrix, work.params, async_indices_); + + { + std::scoped_lock lock(mutex_); + ready_swap_indices_.swap(async_indices_); + ready_available_ = true; + } + } +} + +size_t SparkLodController::traverse(const glm::mat4& view_matrix, + const LodParameters& params, + std::vector& out_indices) const { + out_indices.clear(); + if (nodes_.empty() || params.max_splats == 0) { + return 0; + } + + out_indices.reserve(params.max_splats); + + struct HeapNode { + uint32_t index; + float pixel_scale; + }; + + struct HeapCompare { + bool operator()(const HeapNode& a, const HeapNode& b) const { + return a.pixel_scale < b.pixel_scale; // max-heap: larger scale first + } + }; + + std::priority_queue, HeapCompare> heap; + std::vector queued(nodes_.size(), 0); + + // Seed with root node + heap.push({0, computePixelScale(0, view_matrix, params)}); + queued[0] = 1; + + // Matches Spark semantics: this tracks output size after draining frontier. + size_t num_splats = 1; + + while (!heap.empty()) { + const auto top = heap.top(); + if (top.pixel_scale <= params.pixel_scale_limit) { + break; + } + + heap.pop(); + const auto& node = nodes_[top.index]; + + if (node.child_count == 0) { + // Leaf: output directly. + out_indices.push_back(top.index); + if (out_indices.size() >= params.max_splats) { + break; + } + } else { + // Internal node: check budget before expanding. + const size_t new_num_splats = num_splats - 1 + static_cast(node.child_count); + if (new_num_splats > params.max_splats) { + // Keep this node in the frontier output (Spark behavior). + heap.push(top); + break; + } + + // Expand children. Children already below threshold go directly to output. + for (uint32_t c = 0; c < node.child_count; ++c) { + const uint32_t child_idx = node.child_start + c; + if (child_idx < nodes_.size() && !queued[child_idx]) { + queued[child_idx] = 1; + const float scale = computePixelScale(child_idx, view_matrix, params); + if (scale <= params.pixel_scale_limit) { + out_indices.push_back(child_idx); + } else { + heap.push({child_idx, scale}); + } + } + } + num_splats = new_num_splats; + if (out_indices.size() >= params.max_splats) { + break; + } + } + } + + // Drain remaining frontier nodes while honoring the budget. + while (!heap.empty() && out_indices.size() < params.max_splats) { + out_indices.push_back(heap.top().index); + heap.pop(); + } + + return out_indices.size(); +} + +bool SparkLodController::hasTree() const { + return !nodes_.empty(); +} + +size_t SparkLodController::selectedCount() const { + return selected_indices_.size(); +} + +const std::vector& SparkLodController::selectedIndices() const { + return selected_indices_; +} + +} // namespace lfs::vis diff --git a/src/visualizer/rendering/spark_lod_controller.hpp b/src/visualizer/rendering/spark_lod_controller.hpp new file mode 100644 index 000000000..c3d35449c --- /dev/null +++ b/src/visualizer/rendering/spark_lod_controller.hpp @@ -0,0 +1,82 @@ +/* SPDX-FileCopyrightText: 2025 LichtFeld Studio Authors + * + * SPDX-License-Identifier: GPL-3.0-or-later */ + +#pragma once +#include "core/splat_data.hpp" +#include +#include +#include +#include +#include +#include +#include + +namespace lfs::vis { + +class SparkLodController { +public: + struct LodParameters { + size_t max_splats = 1'500'000; + float pixel_scale_limit = 0.0001f; + float lod_render_scale = 1.0f; + float behind_camera_penalty = 2.0f; + float cone_foveation = 1.0f; + float cone_inner_degrees = 0.0f; + float cone_outer_degrees = 0.0f; + }; + + SparkLodController(); + ~SparkLodController(); + + // Attach to a SplatData that has a lod_tree + void attach(const lfs::core::SplatData& data); + void detach(); + + // Synchronous traversal. Returns selected count. + size_t update(const glm::mat4& view_matrix, const LodParameters& params); + void updateAsync(const glm::mat4& view_matrix, const LodParameters& params); + bool swapAsyncResults(); + bool hasReadyResults() const; + + // Accessors + bool hasTree() const; + size_t selectedCount() const; + const std::vector& selectedIndices() const; + +private: + struct LodTreeNode { + glm::vec3 center; + float size; + uint32_t child_start; + uint16_t child_count; + uint8_t lod_level; + }; + + float computePixelScale(uint32_t node_index, + const glm::mat4& view_matrix, + const LodParameters& params) const; + size_t traverse(const glm::mat4& view_matrix, + const LodParameters& params, + std::vector& out_indices) const; + void workerLoop(std::stop_token stop_token); + + struct WorkItem { + glm::mat4 view_matrix; + LodParameters params; + }; + + const lfs::core::SplatData* data_ = nullptr; + std::vector nodes_; + std::vector selected_indices_; + mutable std::mutex mutex_; + std::condition_variable_any cv_; + std::jthread worker_; + std::optional pending_work_; + bool ready_available_ = false; + std::vector async_indices_; + std::vector ready_swap_indices_; + LodParameters last_params_; +}; + +} // namespace lfs::vis diff --git a/src/visualizer/rendering/vksplat_viewport_renderer.cpp b/src/visualizer/rendering/vksplat_viewport_renderer.cpp index 359f550fe..96cc60c9f 100644 --- a/src/visualizer/rendering/vksplat_viewport_renderer.cpp +++ b/src/visualizer/rendering/vksplat_viewport_renderer.cpp @@ -119,6 +119,32 @@ namespace lfs::vis { return std::format("{} failed: {}", operation, vkResultToString(result)); } + void recordUpdateBufferChunks( + VkCommandBuffer command_buffer, + const _VulkanBuffer& dst, + const void* src_data, + const std::size_t byte_size) { + if (command_buffer == VK_NULL_HANDLE || dst.buffer == VK_NULL_HANDLE || + src_data == nullptr || byte_size == 0) { + return; + } + + // Vulkan requires vkCmdUpdateBuffer update chunks <= 65536 bytes and + // 4-byte aligned. + constexpr std::size_t kMaxUpdateBytes = 65536; + const auto* src = static_cast(src_data); + std::size_t offset = 0; + while (offset < byte_size) { + const std::size_t chunk = std::min(kMaxUpdateBytes, byte_size - offset); + vkCmdUpdateBuffer(command_buffer, + dst.buffer, + dst.offset + offset, + chunk, + src + offset); + offset += chunk; + } + } + [[nodiscard]] std::uint32_t vksplatBaseCameraModel( const lfs::rendering::FrameView& frame_view, const bool equirectangular) { @@ -4384,16 +4410,65 @@ namespace lfs::vis { VulkanGSRendererUniforms uniforms{}; { LOG_TIMER("vksplat.render.populateUniforms"); + const bool lod_indices_present = request.lod_count > 0 && request.lod_indices != nullptr; + const std::size_t render_splat_count = lod_indices_present ? request.lod_count : buffers_.num_splats; populateVksplatCameraUniforms(uniforms, request.frame_view, request.scene, active_sh_degree, renderShNLayoutSlots(active_sh_degree, current_input_sh_degree_), - buffers_.num_splats, + render_splat_count, request.equirectangular, request.gut, request.mip_filter); uniforms.step = static_cast(modelTransformCount(request.scene.model_transforms)); + uniforms.lod_enabled = lod_indices_present ? 1u : 0u; + uniforms.lod_count = lod_indices_present ? static_cast(request.lod_count) : 0u; + if (lod_indices_present && + splat_data.lod_tree && + splat_data.lod_tree->lod_opacity_encoded) { + // Bit 2 (value 4): Spark LOD opacity encoding is active (opacity may exceed 1.0). + uniforms.lod_enabled |= 4u; + } + } + + // Stage LOD indices on host; actual GPU upload happens inside the + // active command batch right before projection dispatch. + if (request.lod_count > 0 && request.lod_indices != nullptr) { + auto& lod_buf = buffers_.lod_indices; + if (lod_buf.deviceSize() < request.lod_count) { + renderer_.resizeAndCopyDeviceBuffer(lod_buf, request.lod_count, false); + } + lod_buf.resize(request.lod_count); + std::memcpy(lod_buf.data(), request.lod_indices, request.lod_count * sizeof(uint32_t)); + buffers_.has_lod_indices = true; + } else { + buffers_.has_lod_indices = false; + } + + // lod_enabled bit flags: + // bit0 (1): LOD index indirection active + // bit1 (2): chunk debug coloring active + // bit2 (4): Spark LOD opacity encoding active + if ((uniforms.lod_enabled & 1u) != 0u && request.lod_debug_mode) { + uniforms.lod_enabled |= 2u; + } + + if (uniforms.lod_enabled != 0u) { + static std::uint32_t lod_dispatch_log_counter = 0; + const bool log_this_frame = (uniforms.lod_count == 0u) || ((++lod_dispatch_log_counter % 120u) == 0u); + if (log_this_frame) { + const std::uint32_t lod_count = uniforms.lod_count; + const std::uint32_t uniform_num_splats = uniforms.num_splats; + const std::uint32_t lod_mode = uniforms.lod_enabled; + LOG_INFO( + "LOD dispatch: uniform_lod_count={} uniform_num_splats={} model_num_splats={} has_lod_indices={} lod_mode={}", + lod_count, + uniform_num_splats, + buffers_.num_splats, + buffers_.has_lod_indices ? 1 : 0, + lod_mode); + } } const std::size_t target_sort_capacity = std::max(buffers_.num_indices, buffers_.num_splats); @@ -4522,6 +4597,12 @@ namespace lfs::vis { LOG_TIMER("vksplat.render.record"); { LOG_TIMER("vksplat.render.record.executeProjectionForward"); + if (buffers_.has_lod_indices && !buffers_.lod_indices.empty()) { + recordUpdateBufferChunks(renderer_.activeCommandBuffer(), + buffers_.lod_indices.deviceBuffer, + buffers_.lod_indices.data(), + buffers_.lod_indices.size() * sizeof(std::uint32_t)); + } renderer_.executeProjectionForward(uniforms, buffers_, overlay_bindings->transform_indices, @@ -4529,7 +4610,8 @@ namespace lfs::vis { overlay_bindings->overlay_params, overlay_bindings->model_transforms, 0, - request.gut); + request.gut, + buffers_.has_lod_indices ? buffers_.lod_indices.deviceBuffer : _VulkanBuffer()); } // Two-stage sort (Splatshop, matches gsplat_fwd reference): // 1. Depth-sort N primitives by radial distance (full 32-bit key). diff --git a/src/visualizer/scene/scene_manager.cpp b/src/visualizer/scene/scene_manager.cpp index 910f5f104..87032600b 100644 --- a/src/visualizer/scene/scene_manager.cpp +++ b/src/visualizer/scene/scene_manager.cpp @@ -634,6 +634,19 @@ namespace lfs::vis { const auto* splat_for_cropbox = scene_.getNode(name); if (splat_for_cropbox) { + // Keep LOD OFF by default. We only expose availability here. + const bool has_lod_tree = + (ext == ".rad" && splat_for_cropbox->model && + splat_for_cropbox->model->lod_tree && + splat_for_cropbox->model->lod_tree->has_tree()); + if (auto* rm = services().renderingOrNull()) { + rm->setLodAvailable(has_lod_tree); + rm->setLodEnabled(false); + } + if (has_lod_tree) { + LOG_INFO("RAD file loaded with LOD tree (available), LOD kept disabled by default"); + } + const core::NodeId cropbox_id = scene_.getCropBoxForSplat(splat_for_cropbox->id); if (cropbox_id != core::NULL_NODE) { const auto* cropbox_node = scene_.getNodeById(cropbox_id); diff --git a/tests/python/test_rendering_panel_localization_regressions.py b/tests/python/test_rendering_panel_localization_regressions.py index bd8f96457..0d9d8a583 100644 --- a/tests/python/test_rendering_panel_localization_regressions.py +++ b/tests/python/test_rendering_panel_localization_regressions.py @@ -83,8 +83,7 @@ def test_rendering_panel_section_headers_use_literals_without_missing_keys(rende "rendering_panel.simplify_source": "Input Source", "rendering_panel.simplify_select_source": "Pick a splat", "rendering_panel.simplify_target": "Goal", - "rendering_panel.simplify_knn_k": "Neighbors", - "rendering_panel.simplify_merge_cap": "Merge Limit", + "rendering_panel.simplify_lod_base": "LOD Base", "rendering_panel.simplify_opacity_prune": "Opacity Cutoff", "rendering_panel.simplify_original": "Before", "rendering_panel.simplify_output": "Result", @@ -106,8 +105,7 @@ def test_rendering_panel_section_headers_use_literals_without_missing_keys(rende assert model.func_bindings["label_simplify_select_source"]() == "Pick a splat" assert model.func_bindings["label_simplify_target"]() == "Goal:" assert model.func_bindings["label_simplify_target_stat"]() == "Goal" - assert model.func_bindings["label_simplify_knn_k"]() == "Neighbors:" - assert model.func_bindings["label_simplify_merge_cap"]() == "Merge Limit:" + assert model.func_bindings["label_simplify_lod_base"]() == "LOD Base:" assert model.func_bindings["label_simplify_opacity_prune"]() == "Opacity Cutoff:" assert model.func_bindings["label_simplify_original"]() == "Before" assert model.func_bindings["label_simplify_output"]() == "Result:" diff --git a/tests/python/test_rendering_panel_regressions.py b/tests/python/test_rendering_panel_regressions.py index 51bbf7914..ed7920a57 100644 --- a/tests/python/test_rendering_panel_regressions.py +++ b/tests/python/test_rendering_panel_regressions.py @@ -101,8 +101,7 @@ def test_rendering_panel_section_headers_use_literals_without_missing_keys(rende "rendering_panel.simplify_source": "Input Source", "rendering_panel.simplify_select_source": "Pick a splat", "rendering_panel.simplify_target": "Goal", - "rendering_panel.simplify_knn_k": "Neighbors", - "rendering_panel.simplify_merge_cap": "Merge Limit", + "rendering_panel.simplify_lod_base": "LOD Base", "rendering_panel.simplify_opacity_prune": "Opacity Cutoff", "rendering_panel.simplify_original": "Before", "rendering_panel.simplify_output": "Result", @@ -124,8 +123,7 @@ def test_rendering_panel_section_headers_use_literals_without_missing_keys(rende assert model.func_bindings["label_simplify_select_source"]() == "Pick a splat" assert model.func_bindings["label_simplify_target"]() == "Goal:" assert model.func_bindings["label_simplify_target_stat"]() == "Goal" - assert model.func_bindings["label_simplify_knn_k"]() == "Neighbors:" - assert model.func_bindings["label_simplify_merge_cap"]() == "Merge Limit:" + assert model.func_bindings["label_simplify_lod_base"]() == "LOD Base:" assert model.func_bindings["label_simplify_opacity_prune"]() == "Opacity Cutoff:" assert model.func_bindings["label_simplify_original"]() == "Before" assert model.func_bindings["label_simplify_output"]() == "Result:" @@ -321,8 +319,7 @@ def test_rendering_rml_exposes_simplify_tooltips_and_locale_labels(): assert 'data-tooltip="tooltip.simplify_source"' in content assert 'data-tooltip="tooltip.simplify_target"' in content - assert 'data-tooltip="tooltip.simplify_knn_k"' in content - assert 'data-tooltip="tooltip.simplify_merge_cap"' in content + assert 'data-tooltip="tooltip.simplify_lod_base"' in content assert 'data-tooltip="tooltip.simplify_opacity_prune"' in content assert 'data-tooltip="tooltip.simplify_output"' in content assert 'data-tooltip="tooltip.simplify_apply"' in content @@ -336,8 +333,7 @@ def test_rendering_rml_exposes_simplify_tooltips_and_locale_labels(): assert "{{label_simplify_source}}" in content assert "{{label_simplify_select_source}}" in content assert "{{label_simplify_target}}" in content - assert "{{label_simplify_knn_k}}" in content - assert "{{label_simplify_merge_cap}}" in content + assert "{{label_simplify_lod_base}}" in content assert "{{label_simplify_opacity_prune}}" in content assert "{{label_simplify_original}}" in content assert "{{label_simplify_target_stat}}" in content diff --git a/tests/python/test_rendering_panel_simplify.py b/tests/python/test_rendering_panel_simplify.py index 26d011c03..1257304c4 100644 --- a/tests/python/test_rendering_panel_simplify.py +++ b/tests/python/test_rendering_panel_simplify.py @@ -111,22 +111,19 @@ def test_rendering_panel_simplify_tracks_selected_splat_and_applies(rendering_pa assert panel._simplify_original_count == 608_640 assert panel._scrub_fields._specs["simplify_target"].min_value == 1.0 assert panel._scrub_fields._specs["simplify_target"].max_value == 608_640.0 - assert panel._scrub_fields._specs["simplify_knn_k"].max_value == 64.0 + assert panel._scrub_fields._specs["simplify_lod_base"].max_value == 10.0 assert panel._compute_simplify_target_count() == 304_320 - assert panel._compute_simplify_knn_k() == 16 - assert panel._compute_simplify_merge_cap() == pytest.approx(0.5) + assert panel._compute_simplify_lod_base() == pytest.approx(2.0) assert panel._compute_simplify_opacity_prune_threshold() == pytest.approx(0.1) assert panel._simplify_output_name() == "Patio_304320" assert panel._can_run_simplify() is True panel._set_scrub_value("simplify_target", 100_000) - panel._set_scrub_value("simplify_knn_k", 24) - panel._set_scrub_value("simplify_merge_cap", 0.25) + panel._set_scrub_value("simplify_lod_base", 3.5) panel._set_scrub_value("simplify_opacity_prune_threshold", 0.35) assert panel._compute_simplify_target_count() == 100_000 - assert panel._compute_simplify_knn_k() == 24 - assert panel._compute_simplify_merge_cap() == pytest.approx(0.25) + assert panel._compute_simplify_lod_base() == pytest.approx(3.5) assert panel._compute_simplify_opacity_prune_threshold() == pytest.approx(0.35) assert panel._simplify_output_name() == "Patio_100000" @@ -137,8 +134,7 @@ def test_rendering_panel_simplify_tracks_selected_splat_and_applies(rendering_pa "Patio", { "ratio": pytest.approx(100_000 / 608_640), - "knn_k": 24, - "merge_cap": pytest.approx(0.25), + "lod_base": pytest.approx(3.5), "opacity_prune_threshold": pytest.approx(0.35), }, ) @@ -155,21 +151,20 @@ def test_rendering_panel_simplify_target_input_clamps_when_source_changes(render assert panel._refresh_simplify_source(force=True) is True panel._set_scrub_value("simplify_target", 100_000) - panel._set_scrub_value("simplify_knn_k", 64) + panel._set_scrub_value("simplify_lod_base", 4.0) assert panel._compute_simplify_target_count() == 100_000 - assert panel._compute_simplify_knn_k() == 64 + assert panel._compute_simplify_lod_base() == pytest.approx(4.0) state.selected_name = "Small" state.nodes["Small"] = _make_splat_node(module.lf.scene.NodeType.SPLAT, "Small", 5) assert panel._refresh_simplify_source(force=False) is True assert panel._scrub_fields._specs["simplify_target"].max_value == 5.0 - assert panel._scrub_fields._specs["simplify_knn_k"].max_value == 4.0 assert panel._compute_simplify_target_count() == 5 - assert panel._compute_simplify_knn_k() == 4 + assert panel._compute_simplify_lod_base() == pytest.approx(4.0) assert panel._simplify_output_name() == "Small_5" -def test_rendering_panel_simplify_knn_defaults_to_16_when_source_appears(rendering_panel_module): +def test_rendering_panel_simplify_lod_base_defaults_to_2_when_source_appears(rendering_panel_module): module, state = rendering_panel_module panel = module.RenderingPanel() panel._handle = _HandleStub() @@ -180,7 +175,7 @@ def test_rendering_panel_simplify_knn_defaults_to_16_when_source_appears(renderi state.selected_name = "Patio" state.nodes["Patio"] = _make_splat_node(module.lf.scene.NodeType.SPLAT, "Patio", 608_640) assert panel._refresh_simplify_source(force=False) is True - assert panel._compute_simplify_knn_k() == 16 + assert panel._compute_simplify_lod_base() == pytest.approx(2.0) def test_rendering_panel_simplify_progress_and_cancel_update_retained_state(rendering_panel_module): diff --git a/tests/python/test_splat_lod_hierarchy.py b/tests/python/test_splat_lod_hierarchy.py index 772fc585e..3169aaa09 100644 --- a/tests/python/test_splat_lod_hierarchy.py +++ b/tests/python/test_splat_lod_hierarchy.py @@ -31,8 +31,7 @@ def __init__( requested_ratio: float, final_roots: list[int], pruned_leaf_ids: list[int], - merge_left: list[int], - merge_right: list[int], + merge_children: list[list[int]], merge_pass: list[int], ): self._leaf_count = leaf_count @@ -41,15 +40,16 @@ def __init__( self.requested_ratio = requested_ratio self.final_roots = final_roots self.pruned_leaf_ids = pruned_leaf_ids - self.merge_left = merge_left - self.merge_right = merge_right + self.merge_children = merge_children + self.merge_left = [c[0] for c in merge_children] + self.merge_right = [c[1] for c in merge_children] self.merge_pass = merge_pass def leaf_count(self) -> int: return self._leaf_count def merge_count(self) -> int: - return len(self.merge_left) + return len(self.merge_children) class _FakeSimplifyResult: @@ -92,13 +92,12 @@ def test_build_splat_lod_hierarchy_tracks_global_node_ids_across_levels(monkeypa calls = [] - def _simplify(source_splat, *, ratio, knn_k, merge_cap, opacity_prune_threshold, progress=None): + def _simplify(source_splat, *, ratio, lod_base, opacity_prune_threshold, progress=None): calls.append( { "source": source_splat.name, "ratio": ratio, - "knn_k": knn_k, - "merge_cap": merge_cap, + "lod_base": lod_base, "opacity_prune_threshold": opacity_prune_threshold, } ) @@ -112,8 +111,7 @@ def _simplify(source_splat, *, ratio, knn_k, merge_cap, opacity_prune_threshold, requested_ratio=0.75, final_roots=[2, 3, 4], pruned_leaf_ids=[], - merge_left=[0], - merge_right=[1], + merge_children=[[0, 1]], merge_pass=[0], ), ) @@ -127,8 +125,7 @@ def _simplify(source_splat, *, ratio, knn_k, merge_cap, opacity_prune_threshold, requested_ratio=1.0, final_roots=[3], pruned_leaf_ids=[1], - merge_left=[0], - merge_right=[2], + merge_children=[[0, 2]], merge_pass=[0], ), ) @@ -143,12 +140,11 @@ def _simplify(source_splat, *, ratio, knn_k, merge_cap, opacity_prune_threshold, [5], ] assert hierarchy.merge_node_ids == [4, 5] - assert hierarchy.merge_left == [0, 2] - assert hierarchy.merge_right == [1, 4] + assert hierarchy.merge_children == [[0, 1], [2, 4]] assert hierarchy.created_lod == [1, 2] assert hierarchy.created_pass == [0, 0] - assert hierarchy.children(4) == (0, 1) - assert hierarchy.children(5) == (2, 4) + assert hierarchy.children(4) == [0, 1] + assert hierarchy.children(5) == [2, 4] assert hierarchy.children(0) is None assert hierarchy.levels[2].pruned_input_node_ids == [3] @@ -156,20 +152,18 @@ def _simplify(source_splat, *, ratio, knn_k, merge_cap, opacity_prune_threshold, assert payload["format"] == "lichtfeld.splat_lod_hierarchy/v1" assert payload["levels"][1]["new_merge_node_ids"] == [4] assert payload["levels"][2]["pruned_input_node_ids"] == [3] - assert payload["merge_nodes"]["left_child"] == [0, 2] + assert payload["merge_nodes"]["children"] == [[0, 1], [2, 4]] assert calls == [ { "source": "lod0", "ratio": pytest.approx(0.75), - "knn_k": 16, - "merge_cap": pytest.approx(0.5), + "lod_base": pytest.approx(2.0), "opacity_prune_threshold": pytest.approx(0.1), }, { "source": "lod1", "ratio": pytest.approx(1.0), - "knn_k": 16, - "merge_cap": pytest.approx(0.5), + "lod_base": pytest.approx(2.0), "opacity_prune_threshold": pytest.approx(0.1), }, ] @@ -181,7 +175,7 @@ def test_hierarchy_save_writes_sidecar_and_node_id_attributes(monkeypatch, tmp_p saved_plys = [] - def _simplify(source_splat, *, ratio, knn_k, merge_cap, opacity_prune_threshold, progress=None): + def _simplify(source_splat, *, ratio, lod_base, opacity_prune_threshold, progress=None): assert source_splat is source return _FakeSimplifyResult( lod1, @@ -192,8 +186,7 @@ def _simplify(source_splat, *, ratio, knn_k, merge_cap, opacity_prune_threshold, requested_ratio=0.5, final_roots=[4, 5], pruned_leaf_ids=[], - merge_left=[0, 2], - merge_right=[1, 3], + merge_children=[[0, 1], [2, 3]], merge_pass=[0, 0], ), ) @@ -239,7 +232,7 @@ def test_build_splat_lod_hierarchy_resolves_source_by_scene_node_name(monkeypatc source = _FakeSplatData("lod0", 4) node = SimpleNamespace(name="SourceNode", id=17, splat_data=lambda: source) - def _simplify(source_splat, *, ratio, knn_k, merge_cap, opacity_prune_threshold, progress=None): + def _simplify(source_splat, *, ratio, lod_base, opacity_prune_threshold, progress=None): assert source_splat is source return _FakeSimplifyResult( _FakeSplatData("lod1", 2), @@ -250,8 +243,7 @@ def _simplify(source_splat, *, ratio, knn_k, merge_cap, opacity_prune_threshold, requested_ratio=0.5, final_roots=[4, 5], pruned_leaf_ids=[], - merge_left=[0, 2], - merge_right=[1, 3], + merge_children=[[0, 1], [2, 3]], merge_pass=[0, 0], ), ) @@ -271,7 +263,7 @@ def test_build_splat_lod_hierarchy_uses_selected_scene_node_when_source_is_omitt source = _FakeSplatData("lod0", 2) node = SimpleNamespace(name="SelectedNode", id=9, splat_data=lambda: source) - def _simplify(source_splat, *, ratio, knn_k, merge_cap, opacity_prune_threshold, progress=None): + def _simplify(source_splat, *, ratio, lod_base, opacity_prune_threshold, progress=None): assert source_splat is source return _FakeSimplifyResult( _FakeSplatData("lod1", 1), @@ -282,8 +274,7 @@ def _simplify(source_splat, *, ratio, knn_k, merge_cap, opacity_prune_threshold, requested_ratio=0.5, final_roots=[2], pruned_leaf_ids=[], - merge_left=[0], - merge_right=[1], + merge_children=[[0, 1]], merge_pass=[0], ), ) diff --git a/tests/test_python_integration.cpp b/tests/test_python_integration.cpp index 88823b17b..d37d19e0c 100644 --- a/tests/test_python_integration.cpp +++ b/tests/test_python_integration.cpp @@ -1037,21 +1037,21 @@ TEST_F(PythonIntegrationTest, DecoratorHookContextUsesLiveHookSnapshot) { const auto result = runPythonHookContextSnippet( R"PY( - import lichtfeld as lf - records = [] - - @lf.on_post_step - def _hook(hook): - ctx = lf.context() - records.append(( - hook["iter"], - hook["iteration"], - hook["num_splats"], - hook["num_gaussians"], - ctx.iteration, - ctx.num_gaussians, - )) - )PY", +import lichtfeld as lf +records = [] + +@lf.on_post_step +def _hook(hook): + ctx = lf.context() + records.append(( + hook["iter"], + hook["iteration"], + hook["num_splats"], + hook["num_gaussians"], + ctx.iteration, + ctx.num_gaussians, + )) +)PY", stale_snapshot, live_callback); @@ -1082,23 +1082,23 @@ TEST_F(PythonIntegrationTest, ScopedHandlerHookContextUsesLiveHookSnapshot) { const auto result = runPythonHookContextSnippet( R"PY( - import lichtfeld as lf - records = [] - handler = lf.ScopedHandler() - - def _hook(hook): - ctx = lf.context() - records.append(( - hook["iter"], - hook["iteration"], - hook["num_splats"], - hook["num_gaussians"], - ctx.iteration, - ctx.num_gaussians, - )) - - handler.on_post_step(_hook) - )PY", +import lichtfeld as lf +records = [] +handler = lf.ScopedHandler() + +def _hook(hook): + ctx = lf.context() + records.append(( + hook["iter"], + hook["iteration"], + hook["num_splats"], + hook["num_gaussians"], + ctx.iteration, + ctx.num_gaussians, + )) + +handler.on_post_step(_hook) +)PY", stale_snapshot, live_callback); diff --git a/tests/test_splat_simplify.cpp b/tests/test_splat_simplify.cpp index b999f7197..717548d47 100644 --- a/tests/test_splat_simplify.cpp +++ b/tests/test_splat_simplify.cpp @@ -23,7 +23,6 @@ using lfs::core::Tensor; namespace { - constexpr double kTwoPiPow1p5 = 15.749609945722419; constexpr double kEpsCov = 1e-8; constexpr double kMinScale = 1e-12; constexpr double kMinQuatNorm = 1e-12; @@ -95,80 +94,81 @@ namespace { out[8] = r20 * r20 * vx + r21 * r21 * vy + r22 * r22 * vz; } - [[nodiscard]] RefMerge reference_moment_match(const RefInput& input, const int i, const int j) { - const size_t i3 = static_cast(i) * 3; - const size_t j3 = static_cast(j) * 3; - const size_t i4 = static_cast(i) * 4; - const size_t j4 = static_cast(j) * 4; - - const double sxi = std::max(std::exp(input.scaling_raw[i3 + 0]), kMinScale); - const double syi = std::max(std::exp(input.scaling_raw[i3 + 1]), kMinScale); - const double szi = std::max(std::exp(input.scaling_raw[i3 + 2]), kMinScale); - const double sxj = std::max(std::exp(input.scaling_raw[j3 + 0]), kMinScale); - const double syj = std::max(std::exp(input.scaling_raw[j3 + 1]), kMinScale); - const double szj = std::max(std::exp(input.scaling_raw[j3 + 2]), kMinScale); - - const double alpha_i = sigmoid(input.opacity_raw[i]); - const double alpha_j = sigmoid(input.opacity_raw[j]); - const double wi = kTwoPiPow1p5 * alpha_i * sxi * syi * szi + 1e-12; - const double wj = kTwoPiPow1p5 * alpha_j * sxj * syj * szj + 1e-12; - const double W = std::max(wi + wj, 1e-12); + [[nodiscard]] RefMerge reference_moment_match(const RefInput& input, const std::vector& indices) { + double total_weight = 0.0; + std::array weighted_mean = {0.0, 0.0, 0.0}; + + // First pass: compute weights and weighted mean + for (int idx : indices) { + const size_t i3 = static_cast(idx) * 3; + const double sxi = std::max(std::exp(input.scaling_raw[i3 + 0]), kMinScale); + const double syi = std::max(std::exp(input.scaling_raw[i3 + 1]), kMinScale); + const double szi = std::max(std::exp(input.scaling_raw[i3 + 2]), kMinScale); + const double alpha_i = sigmoid(input.opacity_raw[idx]); + const double wi = sxi * syi * szi * alpha_i + 1e-12; + total_weight += wi; + + weighted_mean[0] += wi * input.means[i3 + 0]; + weighted_mean[1] += wi * input.means[i3 + 1]; + weighted_mean[2] += wi * input.means[i3 + 2]; + } RefMerge merge; merge.mean = { - (wi * input.means[i3 + 0] + wj * input.means[j3 + 0]) / W, - (wi * input.means[i3 + 1] + wj * input.means[j3 + 1]) / W, - (wi * input.means[i3 + 2] + wj * input.means[j3 + 2]) / W, + weighted_mean[0] / total_weight, + weighted_mean[1] / total_weight, + weighted_mean[2] / total_weight, }; - double qwi = input.rotation_raw[i4 + 0]; - double qxi = input.rotation_raw[i4 + 1]; - double qyi = input.rotation_raw[i4 + 2]; - double qzi = input.rotation_raw[i4 + 3]; - double qwj = input.rotation_raw[j4 + 0]; - double qxj = input.rotation_raw[j4 + 1]; - double qyj = input.rotation_raw[j4 + 2]; - double qzj = input.rotation_raw[j4 + 3]; - - const double inv_i = 1.0 / std::max(quat_norm(qwi, qxi, qyi, qzi), kMinQuatNorm); - const double inv_j = 1.0 / std::max(quat_norm(qwj, qxj, qyj, qzj), kMinQuatNorm); - qwi *= inv_i; - qxi *= inv_i; - qyi *= inv_i; - qzi *= inv_i; - qwj *= inv_j; - qxj *= inv_j; - qyj *= inv_j; - qzj *= inv_j; - - std::array Ri{}; - std::array Rj{}; - quat_to_rotmat(qwi, qxi, qyi, qzi, Ri); - quat_to_rotmat(qwj, qxj, qyj, qzj, Rj); - - std::array sig_i{}; - std::array sig_j{}; - sigma_from_rot_var(Ri, sxi * sxi, syi * syi, szi * szi, sig_i); - sigma_from_rot_var(Rj, sxj * sxj, syj * syj, szj * szj, sig_j); - - const double dix = input.means[i3 + 0] - merge.mean[0]; - const double diy = input.means[i3 + 1] - merge.mean[1]; - const double diz = input.means[i3 + 2] - merge.mean[2]; - const double djx = input.means[j3 + 0] - merge.mean[0]; - const double djy = input.means[j3 + 1] - merge.mean[1]; - const double djz = input.means[j3 + 2] - merge.mean[2]; + // Compute covariance + std::fill(merge.sigma.begin(), merge.sigma.end(), 0.0); + for (int idx : indices) { + const size_t i3 = static_cast(idx) * 3; + const size_t i4 = static_cast(idx) * 4; + + const double sxi = std::max(std::exp(input.scaling_raw[i3 + 0]), kMinScale); + const double syi = std::max(std::exp(input.scaling_raw[i3 + 1]), kMinScale); + const double szi = std::max(std::exp(input.scaling_raw[i3 + 2]), kMinScale); + const double alpha_i = sigmoid(input.opacity_raw[idx]); + const double wi = sxi * syi * szi * alpha_i + 1e-12; + + double qwi = input.rotation_raw[i4 + 0]; + double qxi = input.rotation_raw[i4 + 1]; + double qyi = input.rotation_raw[i4 + 2]; + double qzi = input.rotation_raw[i4 + 3]; + const double inv = 1.0 / std::max(quat_norm(qwi, qxi, qyi, qzi), kMinQuatNorm); + qwi *= inv; + qxi *= inv; + qyi *= inv; + qzi *= inv; + + std::array Ri{}; + quat_to_rotmat(qwi, qxi, qyi, qzi, Ri); + + std::array sig_i{}; + sigma_from_rot_var(Ri, sxi * sxi, syi * syi, szi * szi, sig_i); + + const double dix = input.means[i3 + 0] - merge.mean[0]; + const double diy = input.means[i3 + 1] - merge.mean[1]; + const double diz = input.means[i3 + 2] - merge.mean[2]; + + for (int a = 0; a < 9; ++a) + merge.sigma[a] += wi * sig_i[a]; + + merge.sigma[0] += wi * dix * dix; + merge.sigma[1] += wi * dix * diy; + merge.sigma[2] += wi * dix * diz; + merge.sigma[3] += wi * diy * dix; + merge.sigma[4] += wi * diy * diy; + merge.sigma[5] += wi * diy * diz; + merge.sigma[6] += wi * diz * dix; + merge.sigma[7] += wi * diz * diy; + merge.sigma[8] += wi * diz * diz; + } for (int a = 0; a < 9; ++a) - merge.sigma[a] = (wi * sig_i[a] + wj * sig_j[a]) / W; - merge.sigma[0] += (wi * dix * dix + wj * djx * djx) / W; - merge.sigma[1] += (wi * dix * diy + wj * djx * djy) / W; - merge.sigma[2] += (wi * dix * diz + wj * djx * djz) / W; - merge.sigma[3] += (wi * diy * dix + wj * djy * djx) / W; - merge.sigma[4] += (wi * diy * diy + wj * djy * djy) / W; - merge.sigma[5] += (wi * diy * diz + wj * djy * djz) / W; - merge.sigma[6] += (wi * diz * dix + wj * djz * djx) / W; - merge.sigma[7] += (wi * diz * diy + wj * djz * djy) / W; - merge.sigma[8] += (wi * diz * diz + wj * djz * djz) / W; + merge.sigma[a] /= total_weight; + merge.sigma[1] = merge.sigma[3] = 0.5 * (merge.sigma[1] + merge.sigma[3]); merge.sigma[2] = merge.sigma[6] = 0.5 * (merge.sigma[2] + merge.sigma[6]); merge.sigma[5] = merge.sigma[7] = 0.5 * (merge.sigma[5] + merge.sigma[7]); @@ -176,15 +176,38 @@ namespace { merge.sigma[4] += kEpsCov; merge.sigma[8] += kEpsCov; - merge.opacity = alpha_i + alpha_j - alpha_i * alpha_j; + // Opacity: 1 - prod(1 - alpha_i) + double one_minus_alpha_prod = 1.0; + for (int idx : indices) { + const double alpha_i = sigmoid(input.opacity_raw[idx]); + one_minus_alpha_prod *= (1.0 - alpha_i); + } + merge.opacity = 1.0 - one_minus_alpha_prod; + + // Appearance: weighted average merge.appearance.resize(static_cast(input.app_dim)); - const size_t ai = static_cast(i) * input.app_dim; - const size_t aj = static_cast(j) * input.app_dim; - for (int k = 0; k < input.app_dim; ++k) - merge.appearance[static_cast(k)] = (wi * input.appearance[ai + k] + wj * input.appearance[aj + k]) / W; + std::fill(merge.appearance.begin(), merge.appearance.end(), 0.0); + for (int idx : indices) { + const size_t i3 = static_cast(idx) * 3; + const double sxi = std::max(std::exp(input.scaling_raw[i3 + 0]), kMinScale); + const double syi = std::max(std::exp(input.scaling_raw[i3 + 1]), kMinScale); + const double szi = std::max(std::exp(input.scaling_raw[i3 + 2]), kMinScale); + const double alpha_i = sigmoid(input.opacity_raw[idx]); + const double wi = sxi * syi * szi * alpha_i + 1e-12; + const size_t ai = static_cast(idx) * static_cast(input.app_dim); + for (int k = 0; k < input.app_dim; ++k) + merge.appearance[static_cast(k)] += wi * input.appearance[ai + k]; + } + for (size_t k = 0; k < merge.appearance.size(); ++k) + merge.appearance[k] /= total_weight; + return merge; } + [[nodiscard]] RefMerge reference_moment_match(const RefInput& input, const int i, const int j) { + return reference_moment_match(input, std::vector{i, j}); + } + [[nodiscard]] std::unique_ptr make_test_splat(const RefInput& input, const int max_sh_degree = 1) { const size_t count = input.count(); const int shn_coeffs = max_sh_degree > 0 ? (max_sh_degree + 1) * (max_sh_degree + 1) - 1 : 0; @@ -236,9 +259,10 @@ namespace { [[nodiscard]] std::vector appearance_row(const SplatData& splat, const size_t row) { std::vector result = row_values(splat.sh0_raw().reshape({static_cast(splat.size()), 3}), row); - if (splat.shN_raw().is_valid()) { + if (splat.max_sh_coeffs_rest() > 0) { + const Tensor shN = splat.shN_canonical(); auto tail = row_values( - splat.shN_raw().reshape({static_cast(splat.size()), static_cast(splat.shN_raw().size(1) * 3)}), + shN.reshape({static_cast(splat.size()), static_cast(shN.size(1) * 3)}), row); result.insert(result.end(), tail.begin(), tail.end()); } @@ -355,8 +379,7 @@ TEST(SplatSimplify, TwoPointMergeMatchesReferenceMomentMatching) { const auto before_means = source->means_raw().cpu().to_vector(); SplatSimplifyOptions options; - options.ratio = 0.5f; - options.knn_k = 16; + options.ratio = 0.5; auto result = lfs::core::simplify_splats(*source, options, {}); ASSERT_TRUE(result) << result.error(); @@ -376,7 +399,7 @@ TEST(SplatSimplify, TwoPointMergeMatchesReferenceMomentMatching) { EXPECT_NEAR(appearance[i], expected.appearance[i], 5e-4); } -TEST(SplatSimplify, RandomizedTwoPointMergeMatchesReferenceMomentMatching) { +TEST(SplatSimplify, RandomizedTwoPointMergeMatchesReference) { std::mt19937 rng(12345); std::uniform_real_distribution mean_dist(-1.0, 1.0); std::uniform_real_distribution scale_dist(std::log(0.02), std::log(0.8)); @@ -416,8 +439,7 @@ TEST(SplatSimplify, RandomizedTwoPointMergeMatchesReferenceMomentMatching) { auto source = make_test_splat(input); SplatSimplifyOptions options; - options.ratio = 0.5f; - options.knn_k = 16; + options.ratio = 0.5; options.opacity_prune_threshold = 0.0f; auto result = lfs::core::simplify_splats(*source, options, {}); @@ -444,7 +466,7 @@ TEST(SplatSimplify, RandomizedTwoPointMergeMatchesReferenceMomentMatching) { } } -TEST(SplatSimplify, NoOpSimplifyPreservesAppearanceInPlyPropertyOrder) { +TEST(SplatSimplify, NoOpSimplifyPreservesAppearance) { RefInput input{ .means = { 0.0, @@ -507,8 +529,7 @@ TEST(SplatSimplify, NoOpSimplifyPreservesAppearanceInPlyPropertyOrder) { auto source = make_test_splat(input); SplatSimplifyOptions options; - options.ratio = 1.0f; - options.knn_k = 16; + options.ratio = 1.0; auto result = lfs::core::simplify_splats(*source, options, {}); ASSERT_TRUE(result) << result.error(); @@ -524,203 +545,19 @@ TEST(SplatSimplify, NoOpSimplifyPreservesAppearanceInPlyPropertyOrder) { } } -TEST(SplatSimplify, ThreePointSelectionChoosesClosestPairWhenAllPairsAreCandidates) { +TEST(SplatSimplify, FourPointVoxelMergeGroupsSpatiallyClosePoints) { RefInput input{ .means = { 0.00, 0.00, 0.00, - 0.03, - 0.01, - 0.00, - 2.50, - 0.50, - 0.20, - }, - .scaling_raw = { - std::log(0.10), - std::log(0.10), - std::log(0.10), - std::log(0.11), - std::log(0.10), - std::log(0.09), - std::log(0.15), - std::log(0.14), - std::log(0.16), - }, - .rotation_raw = { - 1.0, - 0.0, - 0.0, - 0.0, - 0.9807853, - 0.0, - 0.1950903, - 0.0, - 0.9659258, - 0.0, - 0.2588190, - 0.0, - }, - .opacity_raw = { - 0.8, - 0.75, - 0.9, - }, - .appearance = { - 0.15, - 0.16, - 0.17, - 0.16, - 0.15, - 0.18, - 0.95, - 0.05, - 0.10, - }, - .app_dim = 3, - }; - - const std::array mean0 = {input.means[0], input.means[1], input.means[2]}; - const std::array mean1 = {input.means[3], input.means[4], input.means[5]}; - const std::array mean2 = {input.means[6], input.means[7], input.means[8]}; - EXPECT_LT(euclidean_distance(mean0, mean1), euclidean_distance(mean0, mean2)); - EXPECT_LT(euclidean_distance(mean0, mean1), euclidean_distance(mean1, mean2)); - - auto source = make_test_splat(input, 0); - SplatSimplifyOptions options; - options.ratio = 0.5f; - options.knn_k = 16; - - auto result = lfs::core::simplify_splats(*source, options, {}); - ASSERT_TRUE(result) << result.error(); - ASSERT_EQ((*result)->size(), 2u); - - const RefMerge expected_merge = reference_moment_match(input, 0, 1); - const std::array expected_keep = { - input.means[6], - input.means[7], - input.means[8], - }; - - const auto output_mean0 = mean_from_output_row(**result, 0); - const auto output_mean1 = mean_from_output_row(**result, 1); - const bool first_is_keep = euclidean_distance(output_mean0, expected_keep) < euclidean_distance(output_mean1, expected_keep); - const auto& keep_row = first_is_keep ? output_mean0 : output_mean1; - const auto& merge_row = first_is_keep ? output_mean1 : output_mean0; - - expect_vec3_near(keep_row, expected_keep, 1e-5); - expect_vec3_near(merge_row, expected_merge.mean, 5e-4); -} - -TEST(SplatSimplify, ThreePointSelectionUsesMeansEvenForRotatedAnisotropicGaussians) { - RefInput input{ - .means = { - 0.0976270065, - 0.4303787351, - 0.2055267543, - 0.0897663683, - -0.1526903957, - 0.2917882204, - -0.1248255745, - 0.7835460305, - 0.9273255467, - }, - .scaling_raw = { - std::log(0.1982781291), - std::log(0.5071074367), - std::log(0.2770543098), - std::log(0.3031591177), - std::log(0.6899558306), - std::log(0.0966540575), - std::log(0.1002986953), - std::log(0.0859922841), - std::log(0.5571201444), - }, - .rotation_raw = { - 0.2761918604, - 0.2076273263, - 0.9296838641, - -0.1276587844, - 0.1122946069, - -0.3063565493, - -0.9157347083, - 0.2344471812, - 0.2953715622, - -0.2535923719, - 0.7755585909, - -0.4969461560, - }, - .opacity_raw = { - std::log(0.2140923440 / (1.0 - 0.2140923440)), - std::log(0.6632266045 / (1.0 - 0.6632266045)), - std::log(0.6590718031 / (1.0 - 0.6590718031)), - }, - .appearance = { - 0.1169339940, - 0.4437480867, - 0.1818203032, - -0.1404920965, - -0.0629680455, - 0.1976311952, - -0.4397745430, - 0.1667667180, - 0.1706378758, - -0.2896174490, - -0.3710736930, - -0.1845716536, - -0.1362892240, - 0.0701967701, - -0.0613984875, - 0.4883738458, - -0.3979551792, - -0.2911232412, - }, - .app_dim = 6, - }; - - const std::array mean0 = {input.means[0], input.means[1], input.means[2]}; - const std::array mean1 = {input.means[3], input.means[4], input.means[5]}; - const std::array mean2 = {input.means[6], input.means[7], input.means[8]}; - EXPECT_LT(euclidean_distance(mean0, mean1), euclidean_distance(mean0, mean2)); - EXPECT_LT(euclidean_distance(mean0, mean1), euclidean_distance(mean1, mean2)); - - auto source = make_test_splat(input, 1); - SplatSimplifyOptions options; - options.ratio = 0.5f; - options.knn_k = 16; - - auto result = lfs::core::simplify_splats(*source, options, {}); - ASSERT_TRUE(result) << result.error(); - ASSERT_EQ((*result)->size(), 2u); - - const RefMerge expected_merge = reference_moment_match(input, 0, 1); - const std::array expected_keep = { - input.means[6], - input.means[7], - input.means[8], - }; - - const auto output_mean0 = mean_from_output_row(**result, 0); - const auto output_mean1 = mean_from_output_row(**result, 1); - const bool first_is_keep = euclidean_distance(output_mean0, expected_keep) < euclidean_distance(output_mean1, expected_keep); - const auto& keep_row = first_is_keep ? output_mean0 : output_mean1; - const auto& merge_row = first_is_keep ? output_mean1 : output_mean0; - - expect_vec3_near(keep_row, expected_keep, 1e-5); - expect_vec3_near(merge_row, expected_merge.mean, 5e-4); -} - -TEST(SplatSimplify, IgnoresAppearanceWhenChoosingClosestPair) { - RefInput input{ - .means = { - 0.00, + 0.02, 0.00, 0.00, - 0.02, + 2.00, 0.00, 0.00, - 1.00, + 2.02, 0.00, 0.00, }, @@ -734,6 +571,9 @@ TEST(SplatSimplify, IgnoresAppearanceWhenChoosingClosestPair) { std::log(0.10), std::log(0.10), std::log(0.10), + std::log(0.10), + std::log(0.10), + std::log(0.10), }, .rotation_raw = { 1.0, @@ -748,56 +588,71 @@ TEST(SplatSimplify, IgnoresAppearanceWhenChoosingClosestPair) { 0.0, 0.0, 0.0, + 1.0, + 0.0, + 0.0, + 0.0, }, .opacity_raw = { std::log(0.5 / (1.0 - 0.5)), std::log(0.5 / (1.0 - 0.5)), std::log(0.5 / (1.0 - 0.5)), + std::log(0.5 / (1.0 - 0.5)), }, .appearance = { 0.0, 0.0, 0.0, + 1.0, + 1.0, + 1.0, 2.0, 2.0, 2.0, - -1.0, - -1.0, - -1.0, + 3.0, + 3.0, + 3.0, }, .app_dim = 3, }; auto source = make_test_splat(input, 0); SplatSimplifyOptions options; - options.ratio = 0.5f; - options.knn_k = 16; + options.ratio = 0.5; auto result = lfs::core::simplify_splats(*source, options, {}); ASSERT_TRUE(result) << result.error(); ASSERT_EQ((*result)->size(), 2u); - const RefMerge expected_merge = reference_moment_match(input, 0, 1); - const std::array expected_keep = { - input.means[6], - input.means[7], - input.means[8], - }; - - const std::array input_mean0 = {input.means[0], input.means[1], input.means[2]}; - const std::array input_mean1 = {input.means[3], input.means[4], input.means[5]}; - const std::array input_mean2 = {input.means[6], input.means[7], input.means[8]}; - EXPECT_LT(euclidean_distance(input_mean0, input_mean1), euclidean_distance(input_mean0, input_mean2)); - EXPECT_LT(euclidean_distance(input_mean0, input_mean1), euclidean_distance(input_mean1, input_mean2)); + const RefMerge expected_merge_01 = reference_moment_match(input, 0, 1); + const RefMerge expected_merge_23 = reference_moment_match(input, 2, 3); const auto output_mean0 = mean_from_output_row(**result, 0); const auto output_mean1 = mean_from_output_row(**result, 1); - const bool first_is_keep = euclidean_distance(output_mean0, expected_keep) < euclidean_distance(output_mean1, expected_keep); - const auto& keep_row = first_is_keep ? output_mean0 : output_mean1; - const auto& merge_row = first_is_keep ? output_mean1 : output_mean0; - expect_vec3_near(keep_row, expected_keep, 1e-5); - expect_vec3_near(merge_row, expected_merge.mean, 5e-4); + // Determine which output row corresponds to which expected merge by mean proximity + const bool first_is_01 = euclidean_distance(output_mean0, expected_merge_01.mean) < euclidean_distance(output_mean0, expected_merge_23.mean); + const auto& expected0 = first_is_01 ? expected_merge_01 : expected_merge_23; + const auto& expected1 = first_is_01 ? expected_merge_23 : expected_merge_01; + const size_t row0 = first_is_01 ? 0 : 1; + const size_t row1 = first_is_01 ? 1 : 0; + + expect_vec3_near(mean_from_output_row(**result, row0), expected0.mean, 5e-4); + expect_mat3_near(covariance_from_output_row(**result, row0), expected0.sigma, 8e-4); + EXPECT_NEAR(opacity_from_output_row(**result, row0), expected0.opacity, 5e-5); + + expect_vec3_near(mean_from_output_row(**result, row1), expected1.mean, 5e-4); + expect_mat3_near(covariance_from_output_row(**result, row1), expected1.sigma, 8e-4); + EXPECT_NEAR(opacity_from_output_row(**result, row1), expected1.opacity, 5e-5); + + const auto app0 = appearance_row(**result, row0); + const auto app1 = appearance_row(**result, row1); + ASSERT_EQ(app0.size(), expected0.appearance.size()); + ASSERT_EQ(app1.size(), expected1.appearance.size()); + for (size_t i = 0; i < app0.size(); ++i) + EXPECT_NEAR(app0[i], expected0.appearance[i], 5e-4); + for (size_t i = 0; i < app1.size(); ++i) + EXPECT_NEAR(app1[i], expected1.appearance[i], 5e-4); } TEST(SplatSimplify, AllowsOpacityPruneToFinishBelowTarget) { @@ -873,15 +728,14 @@ TEST(SplatSimplify, AllowsOpacityPruneToFinishBelowTarget) { auto source = make_test_splat(input, 0); SplatSimplifyOptions options; - options.ratio = 0.75f; - options.knn_k = 16; + options.ratio = 0.75; auto result = lfs::core::simplify_splats(*source, options, {}); ASSERT_TRUE(result) << result.error(); ASSERT_EQ((*result)->size(), 2u); } -TEST(SplatSimplify, HistoryTracksMultiPassMergeTree) { +TEST(SplatSimplify, HistoryTracksMultiPassVoxelMergeTree) { RefInput input{ .means = { 0.00, @@ -954,9 +808,7 @@ TEST(SplatSimplify, HistoryTracksMultiPassMergeTree) { auto source = make_test_splat(input, 0); SplatSimplifyOptions options; - options.ratio = 0.25f; - options.knn_k = 16; - options.merge_cap = 0.5f; + options.ratio = 0.25; options.opacity_prune_threshold = 0.0f; auto result = lfs::core::simplify_splats_with_history(*source, options, {}); @@ -1057,8 +909,7 @@ TEST(SplatSimplify, HistoryTracksOpacityPrunedLeaves) { auto source = make_test_splat(input, 0); SplatSimplifyOptions options; - options.ratio = 0.75f; - options.knn_k = 16; + options.ratio = 0.75; auto result = lfs::core::simplify_splats_with_history(*source, options, {}); ASSERT_TRUE(result) << result.error(); @@ -1147,8 +998,7 @@ TEST(SplatSimplify, UsesNativeBackendProgressStages) { auto source = make_test_splat(input, 0); SplatSimplifyOptions options; - options.ratio = 0.5f; - options.knn_k = 16; + options.ratio = 0.5; std::vector stages; auto result = lfs::core::simplify_splats( @@ -1161,9 +1011,11 @@ TEST(SplatSimplify, UsesNativeBackendProgressStages) { ASSERT_TRUE(result) << result.error(); ASSERT_EQ((*result)->size(), 2u); ASSERT_TRUE(std::find(stages.begin(), stages.end(), "Pruning opacity") != stages.end()); - ASSERT_TRUE(std::find(stages.begin(), stages.end(), "Pass 1: building kNN graph") != stages.end()); - ASSERT_TRUE(std::find(stages.begin(), stages.end(), "Pass 1: computing edge costs") != stages.end()); - ASSERT_TRUE(std::find(stages.begin(), stages.end(), "Pass 1: selecting pairs") != stages.end()); + ASSERT_TRUE(std::find_if(stages.begin(), + stages.end(), + [](const std::string& stage) { + return stage.starts_with("Pass 1: building voxel grid"); + }) != stages.end()); ASSERT_TRUE(std::find_if(stages.begin(), stages.end(), [](const std::string& stage) { @@ -1171,3 +1023,74 @@ TEST(SplatSimplify, UsesNativeBackendProgressStages) { }) != stages.end()); ASSERT_TRUE(std::find(stages.begin(), stages.end(), "Complete") != stages.end()); } + +TEST(SplatSimplify, VolumeBasedWeightsProduceDifferentResultThanAreaBased) { + // Two splats with very different isotropic scales (0.5 vs 0.1) and same opacity. + // Volume-based weights: 0.5^3 : 0.1^3 = 125 : 1 + // Area-based weights (sx*sy): 0.5^2 : 0.1^2 = 25 : 1 + // The merged center should be closer to the large splat than area-based would predict. + RefInput input{ + .means = { + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + }, + .scaling_raw = { + std::log(0.5), + std::log(0.5), + std::log(0.5), + std::log(0.1), + std::log(0.1), + std::log(0.1), + }, + .rotation_raw = { + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + }, + .opacity_raw = { + std::log(0.5 / (1.0 - 0.5)), + std::log(0.5 / (1.0 - 0.5)), + }, + .appearance = { + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + }, + .app_dim = 3, + }; + + auto source = make_test_splat(input, 0); + SplatSimplifyOptions options; + options.ratio = 0.5; + + auto result = lfs::core::simplify_splats(*source, options, {}); + ASSERT_TRUE(result) << result.error(); + ASSERT_EQ((*result)->size(), 1u); + + const auto actual_mean = mean_from_output_row(**result, 0); + + // Compute what the mean would be with area-based weights (alpha * sx * sy) + const double alpha = 0.5; + const double w0_area = alpha * 0.5 * 0.5; + const double w1_area = alpha * 0.1 * 0.1; + const double area_based_mean_x = (w0_area * 0.0 + w1_area * 1.0) / (w0_area + w1_area); + + // Volume-based should pull the mean closer to the large splat (x=0) than area-based + EXPECT_LT(std::abs(actual_mean[0] - 0.0), std::abs(area_based_mean_x - 0.0)); + EXPECT_LT(actual_mean[0], area_based_mean_x); + + // Also verify it is very close to the large splat (within ~0.02) + EXPECT_LT(actual_mean[0], 0.02); +}