diff --git a/.gitmodules b/.gitmodules index 16e6ec4..1620722 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ -[submodule "thirdparty/pybind11"] - path = thirdparty/pybind11 - url = https://github.com/pybind/pybind11.git [submodule "thirdparty/meshoptimizer"] path = thirdparty/meshoptimizer url = https://github.com/zeux/meshoptimizer.git diff --git a/CMakeLists.txt b/CMakeLists.txt index 1e5b660..2b3befd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,9 +1,37 @@ cmake_minimum_required(VERSION 3.10...3.26) -set(CMAKE_CUDA_ARCHITECTURES "75;80;89" CACHE STRING "CUDA architectures") + +# Platform detection for GPU support +set(APPLE_SILICON FALSE) +if(APPLE OR CMAKE_SYSTEM_PROCESSOR MATCHES "arm64|aarch64") + set(APPLE_SILICON TRUE) +endif() + +# GPU backend options +if(APPLE_SILICON) + set(_DEFAULT_CUDA OFF) + set(_DEFAULT_METAL ON) +else() + set(_DEFAULT_CUDA ON) + set(_DEFAULT_METAL OFF) +endif() + +option(SPLAT_USE_CUDA "Use CUDA for GPU acceleration" ${_DEFAULT_CUDA}) +option(SPLAT_USE_METAL "Use Metal for GPU acceleration (macOS only)" ${_DEFAULT_METAL}) + +# Set CUDA architectures only if CUDA is enabled +if(SPLAT_USE_CUDA) + set(CMAKE_CUDA_ARCHITECTURES "75;80;89" CACHE STRING "CUDA architectures") +endif() + +# Configure project languages based on GPU backend +set(_PROJECT_LANGUAGES CXX C) +if(SPLAT_USE_CUDA) + list(APPEND _PROJECT_LANGUAGES CUDA) +endif() project(SplatLib - VERSION 1.3.0 - LANGUAGES CXX C CUDA + VERSION 1.3.1 + LANGUAGES ${_PROJECT_LANGUAGES} DESCRIPTION "Splat Transform") set(SPLAT_INFO "${PROJECT_NAME} ${PROJECT_VERSION}") @@ -11,17 +39,33 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) -set(CMAKE_CUDA_STANDARD 17) -set(CMAKE_CUDA_STANDARD_REQUIRED ON) -set(CMAKE_CUDA_EXTENSIONS OFF) +if(SPLAT_USE_CUDA) + set(CMAKE_CUDA_STANDARD 17) + set(CMAKE_CUDA_STANDARD_REQUIRED ON) + set(CMAKE_CUDA_EXTENSIONS OFF) +endif() + +if(SPLAT_USE_METAL) + enable_language(OBJC) + enable_language(OBJCXX) + set(CMAKE_OBJC_STANDARD 17) + set(CMAKE_OBJC_STANDARD_REQUIRED ON) + set(CMAKE_OBJCXX_STANDARD 17) + set(CMAKE_OBJCXX_STANDARD_REQUIRED ON) +endif() option(BUILD_SPLAT_SHARED "Build splat as a shared library" ON) option(BUILD_SPLAT_CAPI "Build C API" ON) -option(BUILD_SPLAT_PYTON_BINDING "Build Python bindings" OFF) -option(BUILD_SPLAT_VIEWER "Build ImGui + OpenGL splat viewer" OFF) +option(BUILD_SPLAT_VIEWER "Build ImGui + VTK splat viewer" OFF) option(BUILD_SPLAT_TRANSFORM_TOOL "Build splat file format transform tool" OFF) +option(BUILD_SPLAT_PLAYCANVAS_LOD "Build PlayCanvas LOD generation tool" OFF) option(BUILD_SPLAT_TESTS "Build unit tests" OFF) option(BUILD_SPLAT_VISUALIZATION "Build optional VTK visualization helpers" OFF) +option(BUILD_SPLAT_PLAYCANVAS_VIEWER "Build PlayCanvas-style OpenGL splat viewer" OFF) + +if(BUILD_SPLAT_VIEWER AND NOT BUILD_SPLAT_VISUALIZATION) + set(BUILD_SPLAT_VISUALIZATION ON CACHE BOOL "Build optional VTK visualization helpers" FORCE) +endif() if(BUILD_SPLAT_VISUALIZATION) set(SPLAT_VTK_COMPONENTS @@ -35,7 +79,15 @@ if(BUILD_SPLAT_VISUALIZATION) ) endif() -find_package(CUDAToolkit REQUIRED) +find_package(CUDAToolkit) +if(SPLAT_USE_CUDA AND NOT CUDAToolkit_FOUND) + message(FATAL_ERROR "CUDA requested but CUDAToolkit not found") +endif() + +if(SPLAT_USE_METAL AND NOT APPLE) + message(FATAL_ERROR "Metal is only supported on macOS") +endif() + find_package(Eigen3 CONFIG REQUIRED) find_package(nlohmann_json CONFIG REQUIRED) find_package(absl CONFIG REQUIRED) @@ -55,17 +107,22 @@ if(BUILD_SPLAT_SHARED) endif() add_subdirectory(thirdparty/meshoptimizer) +# Export GPU backend configuration to subdirectories +set(SPLAT_GPU_BACKEND "NONE") +if(SPLAT_USE_CUDA) + set(SPLAT_GPU_BACKEND "CUDA") +elseif(SPLAT_USE_METAL) + set(SPLAT_GPU_BACKEND "METAL") +endif() + +message(STATUS "SplatLib GPU Backend: ${SPLAT_GPU_BACKEND}") + add_subdirectory(src) if(BUILD_SPLAT_CAPI) add_subdirectory(capi) endif() -if(BUILD_SPLAT_PYTON_BINDING) - add_subdirectory(thirdparty/pybind11) - add_subdirectory(python) -endif() - if(DOXYGEN_FOUND) set(DOXYGEN_IN ${CMAKE_CURRENT_SOURCE_DIR}/doxygen/Doxygen.in) set(DOXYGEN_OUT ${CMAKE_CURRENT_BINARY_DIR}/Doxyfile) @@ -86,6 +143,19 @@ if(BUILD_SPLAT_TRANSFORM_TOOL) add_subdirectory(transform) endif() +if(BUILD_SPLAT_PLAYCANVAS_LOD) + add_subdirectory(playcanvasLOD) +endif() + +if(BUILD_SPLAT_PLAYCANVAS_VIEWER) + add_subdirectory(playcanvasViewer) +endif() + +if(BUILD_SPLAT_TESTS) + enable_testing() + add_subdirectory(tests) +endif() + if(BUILD_SPLAT_VIEWER) add_subdirectory(viewer) endif() diff --git a/README.md b/README.md index da594f9..6fd084b 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ SplatLib provides comprehensive support for various Gaussian splat formats, enab ### Advanced Features - **Level of Detail (LOD)**: Chunk-based organization supporting multi-resolution representations - **Data Compression**: Integrated WebP codec for efficient texture compression +- **VTK Visualization**: Optional `SplatGaussianProp` and `SplatVisualizer` APIs for Qt, ImGui, and VTK applications - **Parallel Processing**: Built-in thread pools and parallel algorithms - **Python Bindings**: pybind11-based Python interface @@ -129,6 +130,23 @@ auto rotated_sh = splat::rotateSHCoefficients(original_sh, rotation_matrix); auto transformed_data = splat::transform(*data, transformation_matrix); ``` +### VTK Visualization + +```cpp +#include +#include + +#include +#include + +auto data = splat::readPly("scene.ply"); +auto prop = vtkSmartPointer::New(); +prop->SetInputData(std::move(data)); + +vtkRenderer* renderer = /* owned by your Qt, ImGui, or VTK application */; +renderer->AddViewProp(prop); +``` + ## Extensibility and Future Development ### Adding New File Formats @@ -217,6 +235,8 @@ make -j$(nproc) ### CMake Options - `BUILD_SPLAT_TRANSFORM_TOOL` - Build command-line transform utility (default: OFF) +- `BUILD_SPLAT_VISUALIZATION` - Build VTK visualization helpers (default: OFF) +- `BUILD_SPLAT_PLAYCANVAS_VIEWER` - Build standalone PlayCanvas-style OpenGL viewer (default: OFF) - `BUILD_PYTHON_BINDINGS` - Build Python bindings (default: OFF) - `ENABLE_CLANG_TIDY` - Enable clang-tidy static analysis (default: OFF) @@ -238,6 +258,7 @@ SplatLib/ - [PLY Format Specification](docs/ply.md) - Industry standard for Gaussian splats - [SOG Format Specification](docs/sog.md) - Compressed format for web delivery +- [Visualization](docs/visualization.md) - VTK prop and managed viewer integration - [API Documentation](docs/index.md) - Library overview and format comparison Generate Doxygen documentation: diff --git a/cmake/MetalCompile.cmake.in b/cmake/MetalCompile.cmake.in new file mode 100644 index 0000000..503621e --- /dev/null +++ b/cmake/MetalCompile.cmake.in @@ -0,0 +1,37 @@ +/** + * CMake helper for Metal compilation + * This file should be included in src/CMakeLists.txt when SPLAT_USE_METAL is enabled + */ + +# NOTE: This file documents the Metal compilation process +# Integration with CMake's Metal support is still being developed + +# When Metal support is needed, add this to src/CMakeLists.txt: + +# if(SPLAT_USE_METAL) +# # Compile Metal kernels to AIR format +# find_program(METAL_COMPILER xcrun) +# if(METAL_COMPILER) +# # Create a custom command to compile Metal source to metallib +# add_custom_command(OUTPUT kmeans.metallib +# COMMAND ${METAL_COMPILER} -sdk macosx metal +# -ffast-math +# -O3 +# ${CMAKE_CURRENT_SOURCE_DIR}/spatial/kmeans.metal +# -o ${CMAKE_CURRENT_BINARY_DIR}/kmeans.air +# COMMAND ${METAL_COMPILER} -sdk macosx metallib +# ${CMAKE_CURRENT_BINARY_DIR}/kmeans.air +# -o ${CMAKE_CURRENT_BINARY_DIR}/kmeans.metallib +# DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/spatial/kmeans.metal +# COMMENT "Compiling Metal kernels" +# ) +# +# # Add the metallib to the target +# target_sources(splat PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/kmeans.metallib) +# +# # Ensure the metallib is bundled with the library +# add_custom_target(metal_kernels ALL DEPENDS kmeans.metallib) +# add_dependencies(splat metal_kernels) +# endif() +# endif() + diff --git a/docs/index.md b/docs/index.md index 102fd4f..06ebe07 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,6 +4,8 @@ title: Splat File Formats PlayCanvas supports two formats for 3D Gaussian Splat data: +For C++ rendering integration, see [Visualization](./visualization.md). + ## [PLY Format](./ply.md) - Source & Interchange The industry standard for Gaussian splat data. Uncompressed, full precision, and universally compatible. diff --git a/docs/visualization.md b/docs/visualization.md new file mode 100644 index 0000000..d558f20 --- /dev/null +++ b/docs/visualization.md @@ -0,0 +1,88 @@ +# Visualization + +SplatLib routes Gaussian splat rendering through VTK when `BUILD_SPLAT_VISUALIZATION` is enabled. The recommended integration point is `splat::SplatVisualizer`: it owns or attaches to VTK rendering objects, keeps mouse and camera interaction inside VTK, and exposes the renderer/window/interactor needed by Qt, ImGui, and custom viewers. + +Qt applications can place the visualizer's `vtkRenderWindow` in `QVTKOpenGLNativeWidget` or `QVTKRenderWidget`. ImGui or GLFW shells should treat VTK as the rendering canvas and use ImGui only for controls. + +## VTK Canvas Integration + +Use `splat::SplatVisualizer` when your application owns the UI shell but wants VTK to own rendering, camera, and mouse interaction. + +```cpp +#include +#include + +#include +#include +#include +#include + +auto data = splat::readPly("scene.ply"); + +auto renderer = vtkSmartPointer::New(); +auto renderWindow = vtkSmartPointer::New(); +auto interactor = vtkSmartPointer::New(); + +splat::SplatVisualizer viewer(renderer, renderWindow, interactor, "Splat Viewer"); + +splat::SplatRenderOptions options; +options.sortBackToFront = true; +options.depthTest = true; +options.depthWrite = false; +options.sizeScale = 1.0f; +viewer.addSplatCloud(std::move(data), "scene", options); +viewer.resetCamera(); + +// Qt: widget->setRenderWindow(viewer.getRenderWindow()); +// ImGui/GLFW shells: keep VTK's render window as the 3D canvas and use ImGui for controls. +``` + +`SplatVisualizer` wraps `vtkRenderer`, `vtkRenderWindow`, `vtkRenderWindowInteractor`, camera interaction, axes, and one or more `SplatGaussianProp` instances. + +## Managed Window + +Use `splat::SplatVisualizer` when you want SplatLib to own a simple VTK window and interactor. + +```cpp +#include +#include + +auto data = splat::readPly("scene.ply"); + +splat::SplatVisualizer viewer("Splat Viewer"); +viewer.addSplatCloud(std::move(data)); +viewer.resetCamera(); +viewer.spin(); +``` + +## Low-Level Prop + +Use `splat::SplatGaussianProp` only when your application already has a complete VTK renderer/window/interactor setup and wants to manage camera and input itself. + +```cpp +#include +#include + +#include +#include + +auto data = splat::readPly("scene.ply"); + +auto prop = vtkSmartPointer::New(); +prop->SetInputData(std::move(data)); + +vtkRenderer* renderer = /* owned by your application */; +renderer->AddViewProp(prop); +renderer->ResetCamera(); +``` + +`SplatGaussianProp` accepts the same `DataTable` columns used by PlayCanvas-style Gaussian splats: `x`, `y`, `z`, `rot_0..3`, `scale_0..2`, `f_dc_0..2`, and `opacity`. Internally it converts `DataTable` to `GSplatData` and renders through the shared PlayCanvas-compatible OpenGL path. + +## Build + +```bash +cmake -S . -B build -DBUILD_SPLAT_VISUALIZATION=ON +cmake --build build --target splat +``` + +When using the optional standalone PlayCanvas-style viewer, enable `BUILD_SPLAT_PLAYCANVAS_VIEWER` as well. diff --git a/images/image.png b/images/image.png new file mode 100644 index 0000000..de83d94 Binary files /dev/null and b/images/image.png differ diff --git a/include/splat/gpu/gpu_compute.h b/include/splat/gpu/gpu_compute.h new file mode 100644 index 0000000..c2b8c13 --- /dev/null +++ b/include/splat/gpu/gpu_compute.h @@ -0,0 +1,50 @@ +/** + * GPU Compute Abstraction Layer + * + * This file provides platform-agnostic GPU compute interfaces + * for CUDA and Metal backends. + */ + +#pragma once + +#include + +namespace splat { +namespace gpu { + +/** + * @brief Compute centroid norms for k-means clustering + * + * @param centroids Input centroids in column-major format [K×D] + * @param K Number of centroids + * @param D Number of dimensions + * @param norms Output norms [K] + * @return true on success, false on failure + */ +bool computeCentroidNorms(const float* centroids, uint32_t K, uint32_t D, float* norms); + +/** + * @brief Assign points to nearest centroids for k-means clustering + * + * @param points Input points in column-major format [N×D] + * @param centroids Centroid positions in column-major format [K×D] + * @param centroid_norms Pre-computed norms of centroids [K] + * @param N Number of points + * @param K Number of centroids + * @param D Number of dimensions + * @param results Output cluster assignments [N] + * @return true on success, false on failure + */ +bool assignPointsToCentroids(const float* points, const float* centroids, + const float* centroid_norms, + uint32_t N, uint32_t K, uint32_t D, + uint32_t* results); + +/** + * @brief Get backend type + * @return String describing the GPU backend (e.g., "CUDA", "Metal") + */ +const char* getBackendName(); + +} // namespace gpu +} // namespace splat diff --git a/include/splat/maths/gaussian-decimate-math.h b/include/splat/maths/gaussian-decimate-math.h index b718f32..b14926d 100644 --- a/include/splat/maths/gaussian-decimate-math.h +++ b/include/splat/maths/gaussian-decimate-math.h @@ -49,6 +49,8 @@ inline constexpr double kLog2Pi = 1.8378770664093453; inline constexpr double kEpsCov = 1e-8; /** @brief Pi constant (avoids dependency on M_PI). */ inline constexpr double kPi = 3.14159265358979323846; +/** @brief Knud Thomsen p parameter for ellipsoid surface area approximation. */ +inline constexpr double kEllipsoidP = 1.6075; /** @} */ /** @@ -82,6 +84,23 @@ inline double log_add_exp(double a, double b) { return m + std::log(std::exp(a - m) + std::exp(b - m)); } +/** + * @brief Approximate surface area of an ellipsoid with given semi-axes + * + * Uses Knud Thomsen's p=1.6075 approximation. Used as the per-splat + * screen-projection weight in pairwise merge cost and moment-matching, + * replacing the old volume·α NanoGS weighting. + * + * @param sx,sy,sz Semi-axes (scale) along each local axis + * @return Approximate ellipsoid surface area + */ +inline double ellipsoid_area(double sx, double sy, double sz) { + const double a = std::pow(sx * sy, kEllipsoidP); + const double b = std::pow(sx * sz, kEllipsoidP); + const double c = std::pow(sy * sz, kEllipsoidP); + return 4 * kPi * std::pow((a + b + c) / 3, 1 / kEllipsoidP); +} + /** * @brief 32-bit signed multiply returning low 32 bits * @@ -279,33 +298,27 @@ struct Symmetric3x3Eigen { }; /** - * @brief Eigendecompose a 3x3 symmetric matrix using Jacobi iteration + * @brief Eigendecompose a 3x3 symmetric matrix using caller-provided scratch * - * Iteratively diagonalizes the input symmetric matrix to extract - * eigenvalues and eigenvectors. Converges within 24 sweeps for - * well-conditioned matrices. + * Same Jacobi algorithm as the single-argument overload. On return: + * - A has eigenvalues on its diagonal (positions 0, 4, 8) + * - V holds eigenvectors as columns (row-major) * * @param Ain Input symmetric matrix (9 elements, row-major) - * @return Symmetric3x3Eigen with eigenvalues and eigenvector matrix + * @param A Scratch buffer (9 doubles), overwritten with eigenvalues on diagonal + * @param V Scratch buffer (9 doubles), overwritten with eigenvectors as columns */ -inline Symmetric3x3Eigen eigen_symmetric_3x3(const double* Ain) { - double A[9]; - std::memcpy(A, Ain, sizeof(A)); - double V[9] = {1, 0, 0, 0, 1, 0, 0, 0, 1}; +inline void eigen_symmetric_3x3(const double* Ain, double* A, double* V) { + std::memcpy(A, Ain, 9 * sizeof(double)); + V[0] = 1; V[1] = 0; V[2] = 0; + V[3] = 0; V[4] = 1; V[5] = 0; + V[6] = 0; V[7] = 0; V[8] = 1; for (int iter = 0; iter < 24; ++iter) { int p = 0, q = 1; double 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 (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-12) break; const int pp = 3 * p + p, qq = 3 * q + q, pq = 3 * p + q; @@ -320,10 +333,8 @@ inline Symmetric3x3Eigen eigen_symmetric_3x3(const double* Ain) { const int kp = 3 * k + p, kq = 3 * k + q; const int pk = 3 * p + k, qk = 3 * q + k; const double akp = A[kp], akq = A[kq]; - A[kp] = c * akp - s * akq; - A[pk] = A[kp]; - A[kq] = s * akp + c * akq; - A[qk] = A[kq]; + A[kp] = c * akp - s * akq; A[pk] = A[kp]; + A[kq] = s * akp + c * akq; A[qk] = A[kq]; } A[pp] = c * c * app - 2 * s * c * apq + s * s * aqq; A[qq] = s * s * app + 2 * s * c * apq + c * c * aqq; @@ -337,15 +348,31 @@ inline Symmetric3x3Eigen eigen_symmetric_3x3(const double* Ain) { V[kq] = s * vkp + c * vkq; } } +} + +/** + * @brief Eigendecompose a 3x3 symmetric matrix using Jacobi iteration + * + * Iteratively diagonalizes the input symmetric matrix to extract + * eigenvalues and eigenvectors. Converges within 24 sweeps for + * well-conditioned matrices. + * + * @param Ain Input symmetric matrix (9 elements, row-major) + * @return Symmetric3x3Eigen with eigenvalues and eigenvector matrix + */ +inline Symmetric3x3Eigen eigen_symmetric_3x3(const double* Ain) { Symmetric3x3Eigen ev{}; + double A[9]; + eigen_symmetric_3x3(Ain, A, ev.vectors); ev.values[0] = A[0]; ev.values[1] = A[4]; ev.values[2] = A[8]; - std::memcpy(ev.vectors, V, sizeof(V)); return ev; } + + /** * @brief Convert 3x3 rotation matrix to unit quaternion * diff --git a/include/splat/spatial/kdtree.h b/include/splat/spatial/kdtree.h index b2215bd..082bd4f 100644 --- a/include/splat/spatial/kdtree.h +++ b/include/splat/spatial/kdtree.h @@ -102,9 +102,22 @@ class KdTree { std::vector findKNearest(const std::vector& point, size_t k, std::function filterFunc = nullptr); + /** + * @brief Find up to k nearest neighbors into a caller-owned output vector. + * @param point Query coordinates. Must contain one value per tree column. + * @param k Maximum neighbors to return. + * @param out Output indices, replaced on each call. + * @param filterFunc Optional per-index filter (true = candidate allowed) + */ + void findKNearest(const float* point, size_t k, std::vector& out, + std::function filterFunc = nullptr); + private: DataTable* centroids; ///< Pointer to the DataTable containing the data points std::unique_ptr root; ///< Root node of the k-d tree + std::vector*> columnData; + std::vector heapDistances; + std::vector heapIndices; /** * @brief Recursively build the k-d tree from a set of indices. diff --git a/include/splat/visualization/splat_visualizer.h b/include/splat/visualization/splat_visualizer.h index 2e6c495..2e53eba 100644 --- a/include/splat/visualization/splat_visualizer.h +++ b/include/splat/visualization/splat_visualizer.h @@ -18,13 +18,16 @@ class DataTable; struct SplatRenderOptions { float globalOpacity{1.0f}; - float sizeScale{3.0f}; - float minPointSize{1.0f}; + float sizeScale{1.0f}; + float minPointSize{2.0f}; float maxPointSize{1024.0f}; - float alphaDiscardThreshold{0.001f}; + float alphaDiscardThreshold{1.0f / 255.0f}; bool visible{true}; bool depthTest{true}; bool depthWrite{false}; + bool sortBackToFront{true}; + bool clampColors{true}; + bool freezeSortOrder{false}; }; class SplatVisualizer { @@ -36,6 +39,8 @@ class SplatVisualizer { using MouseCallbackHandle = std::size_t; explicit SplatVisualizer(std::string windowName = "SplatVisualizer"); + SplatVisualizer(vtkRenderer* renderer, vtkRenderWindow* renderWindow, vtkRenderWindowInteractor* interactor, + std::string windowName = "SplatVisualizer"); ~SplatVisualizer(); SplatVisualizer(const SplatVisualizer&) = delete; @@ -69,8 +74,11 @@ class SplatVisualizer { void setAxesLength(double length); void setDefaultHotkeysEnabled(bool enabled); bool getDefaultHotkeysEnabled() const; + void setDefaultMouseWheelEnabled(bool enabled); + bool getDefaultMouseWheelEnabled() const; void resetCamera(); + void resetCameraToBounds(const double bounds[6]); void render(); void spin(); void spinOnce(int time = 1, bool forceRedraw = false); diff --git a/playcanvasLOD/CMakeLists.txt b/playcanvasLOD/CMakeLists.txt new file mode 100644 index 0000000..ce356db --- /dev/null +++ b/playcanvasLOD/CMakeLists.txt @@ -0,0 +1,2 @@ +add_executable(PlaycanvasLOD main.cpp) +target_link_libraries(PlaycanvasLOD PRIVATE SPLAT::splat) diff --git a/playcanvasLOD/main.cpp b/playcanvasLOD/main.cpp new file mode 100644 index 0000000..efd20e0 --- /dev/null +++ b/playcanvasLOD/main.cpp @@ -0,0 +1,346 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +struct Options { + bool help = false; + bool overwrite = false; + int iterations = 10; + size_t lodChunkCount = 64; + size_t lodChunkExtent = 16; + fs::path input; + fs::path output; + std::vector levels; +}; + +bool startsWith(const std::string& value, const std::string& prefix) { + return value.rfind(prefix, 0) == 0; +} + +bool matchesValueOption(const std::string& arg, const std::string& optionName) { + return arg == optionName || startsWith(arg, optionName + "="); +} + +bool endsWithIgnoreCase(const std::string& value, const std::string& suffix) { + if (suffix.size() > value.size()) { + return false; + } + + return std::equal(suffix.rbegin(), suffix.rend(), value.rbegin(), [](char a, char b) { + return std::tolower(static_cast(a)) == std::tolower(static_cast(b)); + }); +} + +std::string optionValue(const std::string& arg, int& index, int argc, char** argv, const std::string& optionName) { + const size_t equals = arg.find('='); + if (equals != std::string::npos) { + return arg.substr(equals + 1); + } + if (index + 1 >= argc) { + throw std::runtime_error(optionName + " requires a value"); + } + return argv[++index]; +} + +int parseInt(const std::string& value, const std::string& optionName) { + size_t consumed = 0; + int result = 0; + try { + result = std::stoi(value, &consumed); + } catch (const std::exception&) { + throw std::runtime_error(optionName + " must be an integer"); + } + if (consumed != value.size()) { + throw std::runtime_error(optionName + " must be an integer"); + } + if (result < 0) { + throw std::runtime_error(optionName + " must be a non-negative integer"); + } + return result; +} + +double parsePercent(std::string value) { + if (!value.empty() && value.back() == '%') { + value.pop_back(); + } + if (value.empty()) { + throw std::runtime_error("--levels contains an empty percentage"); + } + + size_t consumed = 0; + double result = 0.0; + try { + result = std::stod(value, &consumed); + } catch (const std::exception&) { + throw std::runtime_error("Invalid --levels percentage: " + value); + } + if (consumed != value.size() || result < 0.0 || result > 100.0) { + throw std::runtime_error("--levels percentages must be between 0% and 100%"); + } + return result; +} + +void appendLevels(const std::string& value, std::vector& levels) { + std::stringstream stream(value); + std::string part; + while (std::getline(stream, part, ',')) { + levels.push_back(parsePercent(part)); + } +} + +Options parseArguments(int argc, char** argv) { + Options options; + std::vector positionals; + + for (int i = 1; i < argc; ++i) { + const std::string arg(argv[i]); + + if (arg == "--help") { + options.help = true; + continue; + } + if (arg == "--overwrite") { + options.overwrite = true; + continue; + } + if (matchesValueOption(arg, "--iterations")) { + options.iterations = parseInt(optionValue(arg, i, argc, argv, "--iterations"), "--iterations"); + continue; + } + if (matchesValueOption(arg, "--lod-chunk-count")) { + options.lodChunkCount = + static_cast(parseInt(optionValue(arg, i, argc, argv, "--lod-chunk-count"), "--lod-chunk-count")); + continue; + } + if (matchesValueOption(arg, "--lod-chunk-extent")) { + options.lodChunkExtent = + static_cast(parseInt(optionValue(arg, i, argc, argv, "--lod-chunk-extent"), "--lod-chunk-extent")); + continue; + } + if (matchesValueOption(arg, "--levels")) { + appendLevels(optionValue(arg, i, argc, argv, "--levels"), options.levels); + while (i + 1 < argc && !startsWith(argv[i + 1], "--")) { + appendLevels(argv[++i], options.levels); + } + continue; + } + if (startsWith(arg, "--")) { + throw std::runtime_error("Unknown option: " + arg); + } + + positionals.emplace_back(fs::u8path(arg)); + } + + if (options.help) { + return options; + } + if (positionals.size() != 2) { + throw std::runtime_error("Expected exactly one input file and one output lod-meta.json file"); + } + if (options.levels.empty()) { + throw std::runtime_error("--levels requires at least one percentage"); + } + + options.input = positionals[0]; + options.output = positionals[1]; + if (!endsWithIgnoreCase(options.output.u8string(), "lod-meta.json")) { + throw std::runtime_error("Output file must end with lod-meta.json"); + } + return options; +} + +void printHelp() { + std::cout << "Usage: PlaycanvasLOD [OPTIONS] --levels 100% 50% 25%\n\n"; + std::cout << "Options:\n"; + std::cout << " --help Show this help and exit\n"; + std::cout << " --overwrite Remove the output parent directory if it exists\n"; + std::cout << " --iterations SOG SH compression iterations. Default: 10\n"; + std::cout << " --lod-chunk-count Approximate Gaussians per LOD chunk in K. Default: 64\n"; + std::cout << " --lod-chunk-extent Approximate LOD chunk extent in meters. Default: 16\n"; + std::cout << " --levels 100% 50% 25% LOD percentages, each between 0% and 100%\n"; + std::cout << " --levels=100%,50%,25% Inline comma-separated level form\n"; +} + +std::unique_ptr readInput(const fs::path& filename) { + const std::string u8 = filename.u8string(); + if (endsWithIgnoreCase(u8, ".ksplat")) { + return splat::readKsplat(filename); + } + if (endsWithIgnoreCase(u8, ".splat")) { + return splat::readSplat(filename); + } + if (endsWithIgnoreCase(u8, ".sog") || endsWithIgnoreCase(u8, "meta.json")) { + return splat::readSog(filename, filename); + } + if (endsWithIgnoreCase(u8, ".ply")) { + return splat::readPly(filename); + } + if (endsWithIgnoreCase(u8, ".spz")) { + return splat::readSpz(filename); + } + if (endsWithIgnoreCase(u8, ".voxel.json")) { + return splat::readVoxel(filename); + } + + throw std::runtime_error("Unsupported input file type: " + u8); +} + +bool isGaussianTable(const splat::DataTable& dataTable) { + static const std::vector requiredColumns = {"x", "y", "z", "rot_0", "rot_1", + "rot_2", "rot_3", "scale_0", "scale_1", "scale_2", + "f_dc_0", "f_dc_1", "f_dc_2", "opacity"}; + + return std::all_of(requiredColumns.begin(), requiredColumns.end(), + [&](const std::string& column) { return dataTable.hasColumn(column); }); +} + +int computeKeepCount(size_t sourceRows, double percent) { + const double scaled = static_cast(sourceRows) * percent / 100.0; + const double rounded = std::round(scaled); + if (rounded <= 0.0) { + return 0; + } + if (rounded >= static_cast(sourceRows)) { + return static_cast(sourceRows); + } + return static_cast(rounded); +} + +std::string formatPercent(double value) { + std::ostringstream stream; + const double rounded = std::round(value); + if (std::abs(value - rounded) < 1e-9) { + stream << static_cast(rounded); + } else { + stream.setf(std::ios::fixed); + stream.precision(2); + stream << value; + } + stream << '%'; + return stream.str(); +} + +std::string formatLevels(const std::vector& levels) { + std::ostringstream stream; + for (size_t i = 0; i < levels.size(); ++i) { + if (i > 0) { + stream << ", "; + } + stream << formatPercent(levels[i]); + } + return stream.str(); +} + +void setLodColumn(splat::DataTable& dataTable, int levelIndex) { + if (!dataTable.hasColumn("lod")) { + dataTable.addColumn({"lod", std::vector(dataTable.getNumRows())}); + } + + auto& lodValues = dataTable.getColumnByName("lod").asVector(); + std::fill(lodValues.begin(), lodValues.end(), static_cast(levelIndex)); +} + +void prepareOutput(const fs::path& output, bool overwrite) { + const fs::path outputFile = fs::absolute(output); + const fs::path outputDir = outputFile.parent_path(); + std::error_code ec; + + if (overwrite) { + if (fs::exists(outputDir)) { + fs::remove_all(outputDir, ec); + if (ec) { + throw std::runtime_error("Failed to remove output directory '" + outputDir.u8string() + "': " + ec.message()); + } + } + return; + } + + if (fs::exists(outputDir)) { + throw std::runtime_error("Output directory already exists: " + outputDir.u8string() + " (use --overwrite)"); + } + if (fs::exists(outputFile)) { + throw std::runtime_error("Output file already exists: " + outputFile.u8string() + " (use --overwrite)"); + } +} + +int run(int argc, char** argv) { + const Options options = parseArguments(argc, argv); + if (options.help) { + printHelp(); + return 0; + } + + const std::string inputPath = fs::absolute(options.input).u8string(); + const std::string outputPath = fs::absolute(options.output).u8string(); + const std::string levelsText = formatLevels(options.levels); + + LOG_INFO("PlaycanvasLOD start"); + LOG_INFO("input='%s' output='%s' overwrite=%s iterations=%d lodChunkCount=%zu lodChunkExtent=%zu levels=[%s]", + inputPath.c_str(), outputPath.c_str(), options.overwrite ? "true" : "false", options.iterations, + options.lodChunkCount, options.lodChunkExtent, levelsText.c_str()); + + prepareOutput(options.output, options.overwrite); + LOG_INFO("output path prepared"); + + LOG_INFO("reading input '%s'", inputPath.c_str()); + std::unique_ptr source = readInput(options.input); + if (!source || source->getNumRows() == 0 || !isGaussianTable(*source)) { + throw std::runtime_error("Unsupported Gaussian data in file: " + options.input.u8string()); + } + + const size_t sourceRows = source->getNumRows(); + LOG_INFO("loaded source rows=%zu", sourceRows); + + std::vector> levels; + levels.reserve(options.levels.size()); + + for (size_t levelIndex = 0; levelIndex < options.levels.size(); ++levelIndex) { + const double levelPercent = options.levels[levelIndex]; + const int keepCount = computeKeepCount(sourceRows, levelPercent); + const std::string levelText = formatPercent(levelPercent); + + LOG_INFO("building lodIndex=%zu level=%s targetRows=%d", levelIndex, levelText.c_str(), keepCount); + std::unique_ptr level = splat::simplifyGaussians(*source, keepCount); + setLodColumn(*level, static_cast(levelIndex)); + LOG_INFO("lodIndex=%zu ready rows=%zu", levelIndex, level->getNumRows()); + levels.emplace_back(std::move(level)); + } + + LOG_INFO("combining %zu LOD tables", levels.size()); + std::unique_ptr merged = splat::combine(levels); + if (!merged || merged->getNumRows() == 0) { + throw std::runtime_error("No splats to write"); + } + LOG_INFO("combined rows=%zu", merged->getNumRows()); + + LOG_INFO("writing LOD output to '%s'", outputPath.c_str()); + splat::writeLod(fs::absolute(options.output), merged.get(), nullptr, true, options.iterations, options.lodChunkCount, + options.lodChunkExtent); + LOG_INFO("PlaycanvasLOD finished successfully"); + return 0; +} + +} // namespace + +int main(int argc, char** argv) { + try { + return run(argc, argv); + } catch (const std::exception& e) { + std::cerr << "ERROR: " << e.what() << "\n"; + return 1; + } +} diff --git a/playcanvasViewer/CMakeLists.txt b/playcanvasViewer/CMakeLists.txt new file mode 100644 index 0000000..66c33ac --- /dev/null +++ b/playcanvasViewer/CMakeLists.txt @@ -0,0 +1,35 @@ +cmake_minimum_required(VERSION 3.14) + +# Adapter target first +add_library(playcanvas_viewer_adapter STATIC + gsplat_data_adapter.cpp +) +target_include_directories(playcanvas_viewer_adapter + PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${PROJECT_SOURCE_DIR}/src/visualization +) +target_link_libraries(playcanvas_viewer_adapter + PUBLIC + SPLAT::splat + Eigen3::Eigen +) + +# Camera controller target after adapter exists +add_library(playcanvas_viewer_camera STATIC + camera_controller.cpp +) +target_include_directories(playcanvas_viewer_camera PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${PROJECT_SOURCE_DIR}/src/visualization +) +target_link_libraries(playcanvas_viewer_camera PUBLIC SPLAT::splat Eigen3::Eigen) + +find_package(GLEW REQUIRED) +find_package(OpenGL REQUIRED) + +# PlaycanvasViewer executable +find_package(glfw3 CONFIG REQUIRED) +add_executable(PlaycanvasViewer main.cpp) +target_link_libraries(PlaycanvasViewer PRIVATE playcanvas_viewer_camera SPLAT::splat glfw GLEW::GLEW OpenGL::GL) +message(STATUS "PlaycanvasViewer executable configured.") diff --git a/playcanvasViewer/camera_controller.cpp b/playcanvasViewer/camera_controller.cpp new file mode 100644 index 0000000..da654f8 --- /dev/null +++ b/playcanvasViewer/camera_controller.cpp @@ -0,0 +1,230 @@ +#include "camera_controller.h" +#include +#include +#include +#include + +namespace playcanvas_viewer { + +static Eigen::Matrix4f makeLookAt(const Eigen::Vector3f& eye, const Eigen::Vector3f& center, const Eigen::Vector3f& up) { + Eigen::Vector3f f = (center - eye).normalized(); + Eigen::Vector3f s = f.cross(up).normalized(); + Eigen::Vector3f u = s.cross(f); + Eigen::Matrix4f mat = Eigen::Matrix4f::Identity(); + mat.block<1, 3>(0, 0) = s.transpose(); + mat.block<1, 3>(1, 0) = u.transpose(); + mat.block<1, 3>(2, 0) = -f.transpose(); + mat(0, 3) = -s.dot(eye); + mat(1, 3) = -u.dot(eye); + mat(2, 3) = f.dot(eye); + return mat; +} + +static Eigen::Matrix4f makePerspective(float fovy, float aspect, float zNear, float zFar) { + float tanHalfFovy = std::tan(fovy / 2.0f); + Eigen::Matrix4f mat = Eigen::Matrix4f::Zero(); + mat(0, 0) = 1.0f / (aspect * tanHalfFovy); + mat(1, 1) = 1.0f / tanHalfFovy; + mat(2, 2) = -(zFar + zNear) / (zFar - zNear); + mat(2, 3) = -2.0f * zFar * zNear / (zFar - zNear); + mat(3, 2) = -1.0f; + return mat; +} + +static std::pair robustRange(std::vector& values) { + std::sort(values.begin(), values.end()); + if (values.empty()) { + return {0.0f, 0.0f}; + } + + size_t trim = 0; + if (values.size() >= 1000) { + trim = std::max(1, values.size() / 1000); + } + + const size_t low = std::min(trim, values.size() - 1); + const size_t high = std::max(low, values.size() - 1 - trim); + return {values[low], values[high]}; +} + +static float robustScalePadding(std::vector& values) { + std::sort(values.begin(), values.end()); + if (values.empty()) { + return 0.0f; + } + + size_t index = values.size() - 1; + if (values.size() >= 1000) { + index = std::min(values.size() - 1, values.size() - 1 - values.size() / 1000); + } + + return values[index] * 2.0f; +} + +SceneBounds computeSceneBounds(const splat::visualization::GSplatData& data) { + SceneBounds bounds; + if (data.centers.empty()) { + bounds.center = Eigen::Vector3f(0, 0, 0); + bounds.radius = 1.0f; + return bounds; + } + std::vector xs; + std::vector ys; + std::vector zs; + std::vector maxScales; + xs.reserve(data.centers.size()); + ys.reserve(data.centers.size()); + zs.reserve(data.centers.size()); + maxScales.reserve(data.centers.size()); + + for (size_t i = 0; i < data.centers.size(); ++i) { + const auto& v = data.centers[i]; + const auto& s = data.scales[i]; + const float maxScale = std::max({s.x, s.y, s.z}); + if (std::isfinite(v.x) && std::isfinite(v.y) && std::isfinite(v.z) && std::isfinite(maxScale)) { + xs.push_back(v.x); + ys.push_back(v.y); + zs.push_back(v.z); + maxScales.push_back(maxScale); + } + } + + if (xs.empty()) { + bounds.center = Eigen::Vector3f(0, 0, 0); + bounds.radius = 1.0f; + return bounds; + } + + const auto [minX, maxX] = robustRange(xs); + const auto [minY, maxY] = robustRange(ys); + const auto [minZ, maxZ] = robustRange(zs); + const float padding = robustScalePadding(maxScales); + const Eigen::Vector3f minPt(minX - padding, minY - padding, minZ - padding); + const Eigen::Vector3f maxPt(maxX + padding, maxY + padding, maxZ + padding); + + bounds.center = (minPt + maxPt) * 0.5f; + const Eigen::Vector3f halfExtents = (maxPt - minPt) * 0.5f; + bounds.radius = std::max(0.01f, halfExtents.norm()); + return bounds; +} + +void CameraController::resetToBounds(const SceneBounds& bounds) { + m_target = bounds.center; + const float aspect = static_cast(std::max(m_width, 1)) / static_cast(std::max(m_height, 1)); + const float verticalHalfFov = m_fovYRadians * 0.5f; + const float horizontalHalfFov = std::atan(std::tan(verticalHalfFov) * aspect); + const float fitHalfFov = std::max(0.001f, std::min(verticalHalfFov, horizontalHalfFov)); + float dist = (bounds.radius * 1.5f) / std::sin(fitHalfFov); + dist = std::max(dist, 1.0f); + m_position = bounds.center + Eigen::Vector3f(0, 0, dist); + m_near = std::max(std::min(bounds.radius * 0.0005f, 0.01f), 0.0001f); + m_far = std::max({dist + bounds.radius * 4.0f, bounds.radius * 32.0f, 10.0f}); + m_flySpeed = std::max(bounds.radius * 0.5f, 0.25f); + m_minOrbitDistance = std::max({bounds.radius * 0.001f, m_near * 10.0f, 0.001f}); + m_minDollyStep = std::max(bounds.radius * 0.02f, 0.01f); + m_changed = true; +} + +void CameraController::orbit(float deltaX, float deltaY) { + Eigen::Vector3f offset = m_position - m_target; + float radius = offset.norm(); + if (radius < 1e-6f) return; + float yaw = -deltaX * 0.005f; + float pitch = -deltaY * 0.005f; + Eigen::Vector3f up = m_up; + Eigen::Vector3f fwd = (m_target - m_position).normalized(); + Eigen::Vector3f right = fwd.cross(up); + if (right.norm() < 1e-6f) right = Eigen::Vector3f(1,0,0); // degeneracy guard + right.normalize(); + Eigen::AngleAxisf yawRot(yaw, up); + Eigen::AngleAxisf pitchRot(pitch, right); + offset = yawRot * offset; + offset = pitchRot * offset; + m_position = m_target + offset; + m_changed = true; +} + +void CameraController::dolly(float amount) { + Eigen::Vector3f offset = m_position - m_target; + const float distance = offset.norm(); + if (distance < 1e-6f) { + return; + } + + const Eigen::Vector3f forward = (m_target - m_position) / distance; + const float stepMagnitude = std::max(distance * 0.12f, m_minDollyStep) * std::abs(amount); + + if (amount > 0.0f) { + const float desiredDistance = distance - stepMagnitude; + if (desiredDistance < m_minOrbitDistance) { + const float targetAdvance = m_minOrbitDistance - desiredDistance; + m_target += forward * targetAdvance; + m_position = m_target - forward * m_minOrbitDistance; + } else { + m_position += forward * stepMagnitude; + } + } else { + m_position -= forward * stepMagnitude; + } + m_changed = true; +} + +void CameraController::fly(const CameraInputState& input, float deltaSeconds) { + if (!(input.forward || input.backward || input.left || input.right || input.up || input.down)) return; + float dt = std::max(deltaSeconds, 0.0f); + Eigen::Vector3f fwd = (m_target - m_position).normalized(); + Eigen::Vector3f right = fwd.cross(m_up).normalized(); + Eigen::Vector3f up = m_up; + float fb = (input.forward ? 1.0f : 0.0f) - (input.backward ? 1.0f : 0.0f); + float lr = (input.right ? 1.0f : 0.0f) - (input.left ? 1.0f : 0.0f); + float ud = (input.up ? 1.0f : 0.0f) - (input.down ? 1.0f : 0.0f); + Eigen::Vector3f move = fwd * fb + right * lr + up * ud; + if (move.norm() > 1e-6f) { + move.normalize(); + move *= m_flySpeed * dt; + m_position += move; + m_target += move; + m_changed = true; + } +} + +void CameraController::resize(int width, int height) { + m_width = std::max(width, 1); + m_height = std::max(height, 1); + m_changed = true; +} + +Eigen::Matrix4f CameraController::viewMatrix() const { + return makeLookAt(m_position, m_target, m_up); +} + +Eigen::Matrix4f CameraController::projectionMatrix() const { + float aspect = float(m_width) / float(m_height); + return makePerspective(m_fovYRadians, aspect, m_near, m_far); +} + +Eigen::Vector3f CameraController::position() const { + return m_position; +} + +Eigen::Vector3f CameraController::forward() const { + return (m_target - m_position).normalized(); +} + +float CameraController::nearPlane() const { + return m_near; +} + +float CameraController::farPlane() const { + return m_far; +} + +bool CameraController::changed() const { + return m_changed; +} + +void CameraController::clearChanged() { + m_changed = false; +} + +} // namespace playcanvas_viewer diff --git a/playcanvasViewer/camera_controller.h b/playcanvasViewer/camera_controller.h new file mode 100644 index 0000000..7f0f96d --- /dev/null +++ b/playcanvasViewer/camera_controller.h @@ -0,0 +1,55 @@ +#pragma once +#include +#include "gsplat_data.h" + +namespace playcanvas_viewer { + +struct CameraInputState { + bool forward = false; + bool backward = false; + bool left = false; + bool right = false; + bool down = false; + bool up = false; +}; + +struct SceneBounds { + Eigen::Vector3f center{0, 0, 0}; + float radius = 1.0f; +}; + +class CameraController { +public: + void resetToBounds(const SceneBounds& bounds); + void orbit(float deltaX, float deltaY); + void dolly(float amount); + void fly(const CameraInputState& input, float deltaSeconds); + void resize(int width, int height); + Eigen::Matrix4f viewMatrix() const; + Eigen::Matrix4f projectionMatrix() const; + Eigen::Vector3f position() const; + Eigen::Vector3f forward() const; + float nearPlane() const; + float farPlane() const; + bool changed() const; + void clearChanged(); + +private: + Eigen::Vector3f m_position{0, 0, 3}; + Eigen::Vector3f m_target{0, 0, 0}; + Eigen::Vector3f m_up{0, -1, 0}; + int m_width = 1280; + int m_height = 720; + static constexpr float kFovYRadians = 1.0471975512f; // 60 deg in radians + float m_fovYRadians = kFovYRadians; + float m_near = 0.01f; + float m_far = 1000.0f; + float m_flySpeed = 3.0f; + float m_minOrbitDistance = 0.01f; + float m_minDollyStep = 0.01f; + bool m_changed = true; +}; + +SceneBounds computeSceneBounds(const splat::visualization::GSplatData& data); + +} // namespace playcanvas_viewer diff --git a/playcanvasViewer/gsplat_data_adapter.cpp b/playcanvasViewer/gsplat_data_adapter.cpp new file mode 100644 index 0000000..260e1c0 --- /dev/null +++ b/playcanvasViewer/gsplat_data_adapter.cpp @@ -0,0 +1,25 @@ +#include "gsplat_data_adapter.h" + +namespace playcanvas_viewer { + +float decodePlayCanvasColor(float dcValue) { + return splat::visualization::decodePlayCanvasColor(dcValue); +} + +float decodePlayCanvasOpacity(float opacityValue) { + return splat::visualization::decodePlayCanvasOpacity(opacityValue); +} + +float decodePlayCanvasScale(float scaleValue) { + return splat::visualization::decodePlayCanvasScale(scaleValue); +} + +Vec4f normalizePlayCanvasRotation(float w, float x, float y, float z) { + return splat::visualization::normalizePlayCanvasRotation(w, x, y, z); +} + +GSplatRenderData adaptDataTableToGSplat(const splat::DataTable& dataTable, size_t maxSplats) { + return splat::visualization::adaptDataTableToGSplatData(dataTable, maxSplats); +} + +} // namespace playcanvas_viewer diff --git a/playcanvasViewer/gsplat_data_adapter.h b/playcanvasViewer/gsplat_data_adapter.h new file mode 100644 index 0000000..1a06c03 --- /dev/null +++ b/playcanvasViewer/gsplat_data_adapter.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include "gsplat_data.h" + +#include + +namespace playcanvas_viewer { + +using Vec3f = splat::visualization::Vec3f; +using Vec4f = splat::visualization::Vec4f; +using GSplatRenderData = splat::visualization::GSplatData; + +GSplatRenderData adaptDataTableToGSplat(const splat::DataTable& dataTable, size_t maxSplats = 0); + +float decodePlayCanvasColor(float dcValue); +float decodePlayCanvasOpacity(float opacityValue); +float decodePlayCanvasScale(float scaleValue); +Vec4f normalizePlayCanvasRotation(float w, float x, float y, float z); + +} // namespace playcanvas_viewer diff --git a/playcanvasViewer/main.cpp b/playcanvasViewer/main.cpp new file mode 100644 index 0000000..60c07cb --- /dev/null +++ b/playcanvasViewer/main.cpp @@ -0,0 +1,277 @@ +// PlaycanvasViewer main.cpp +#include "camera_controller.h" +#include "splat/models/data-table.h" +#include +#include +#include +#include "gsplat_data.h" +#include "gsplat_gl_renderer.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Helper: print usage +void print_usage() { + std::cout << "Usage: PlaycanvasViewer [--width=N] [--height=N] [--max-splats=N] [--no-sort] \n" + << "Supported extensions: .ply, .splat, .sog, .spz, .ksplat\n" + << " --width=N Set window width (default 1280)\n" + << " --height=N Set window height (default 720)\n" + << " --max-splats=N Limit number of splats rendered\n" + << " --no-sort Disable back-to-front sorting, faster but visibly lower quality\n" + << " -h, --help Show this help message\n"; +} + + +struct ViewerOptions { + int width = 1280; + int height = 720; + size_t maxSplats = 0; // 0 means unlimited + bool sort = true; + std::filesystem::path inputFile; +}; + +size_t parseSizeOption(const std::string& optionName, const std::string& value) { + if (value.empty() || value.front() == '-') { + throw std::runtime_error("Invalid value for " + optionName + ": '" + value + "'"); + } + + size_t parsedChars = 0; + size_t parsedValue = 0; + try { + parsedValue = static_cast(std::stoull(value, &parsedChars)); + } catch (const std::exception&) { + throw std::runtime_error("Invalid value for " + optionName + ": '" + value + "'"); + } + + if (parsedChars != value.size()) { + throw std::runtime_error("Invalid value for " + optionName + ": '" + value + "'"); + } + + return parsedValue; +} + +int parseDimensionOption(const std::string& optionName, const std::string& value) { + const size_t parsedValue = parseSizeOption(optionName, value); + if (parsedValue == 0 || parsedValue > static_cast(std::numeric_limits::max())) { + throw std::runtime_error("Invalid value for " + optionName + ": '" + value + "'"); + } + return static_cast(parsedValue); +} + +ViewerOptions parse_args(int argc, char** argv) { + ViewerOptions opts; + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + if (arg == "-h" || arg == "--help") { + print_usage(); + std::exit(0); + } else if (arg.rfind("--width=", 0) == 0) { + opts.width = parseDimensionOption("--width", arg.substr(8)); + } else if (arg.rfind("--height=", 0) == 0) { + opts.height = parseDimensionOption("--height", arg.substr(9)); + } else if (arg.rfind("--max-splats=", 0) == 0) { + opts.maxSplats = parseSizeOption("--max-splats", arg.substr(14)); + } else if (arg == "--no-sort") { + opts.sort = false; + } else if (arg[0] == '-') { + throw std::runtime_error("Unknown option: " + arg); + } else { + if (!opts.inputFile.empty()) + throw std::runtime_error("Multiple input files specified"); + opts.inputFile = std::filesystem::path(arg); + } + } + if (opts.inputFile.empty()) + throw std::runtime_error("Missing input file"); + return opts; +} + +// Data loading dispatch +std::unique_ptr readInput(const std::filesystem::path& path) { + if (!path.has_extension()) + throw std::runtime_error("Input file has no extension"); + std::string ext = path.extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c){ return std::tolower(c); }); + if (ext == ".ply") + return splat::readPly(path.string()); + if (ext == ".splat") + return splat::readSplat(path.string()); + if (ext == ".sog") + return splat::readSog(path.string(), path.string()); + if (ext == ".spz") + return splat::readSpz(path.string()); + if (ext == ".ksplat") + return splat::readKsplat(path.string()); + throw std::runtime_error("Unsupported file extension: " + ext); +} + +struct AppState { + playcanvas_viewer::CameraController camera; + splat::visualization::GSplatGLRenderer renderer; + splat::visualization::GSplatGLRenderOptions renderOptions; + bool dragging = false; + double lastX = 0, lastY = 0; + playcanvas_viewer::SceneBounds bounds; +}; + +// GLFW callbacks +void framebuffer_size_callback(GLFWwindow* window, int width, int height) { + auto* state = static_cast(glfwGetWindowUserPointer(window)); + if (state) { + state->camera.resize(width, height); + glViewport(0, 0, width, height); + } +} + +void mouse_button_callback(GLFWwindow* window, int button, int action, int mods) { + (void)mods; + auto* state = static_cast(glfwGetWindowUserPointer(window)); + if (!state) return; + if (button == GLFW_MOUSE_BUTTON_LEFT) { + if (action == GLFW_PRESS) { + state->dragging = true; + double x, y; + glfwGetCursorPos(window, &x, &y); + state->lastX = x; + state->lastY = y; + } else if (action == GLFW_RELEASE) { + state->dragging = false; + } + } +} + +void cursor_pos_callback(GLFWwindow* window, double xpos, double ypos) { + auto* state = static_cast(glfwGetWindowUserPointer(window)); + if (!state || !state->dragging) return; + double dx = xpos - state->lastX; + double dy = ypos - state->lastY; + state->camera.orbit(dx, dy); + state->lastX = xpos; + state->lastY = ypos; +} + +void scroll_callback(GLFWwindow* window, double xoffset, double yoffset) { + (void)xoffset; + auto* state = static_cast(glfwGetWindowUserPointer(window)); + if (state) { + state->camera.dolly(yoffset); + } +} + +void key_callback(GLFWwindow* window, int key, int scancode, int action, int mods) { + (void)scancode; + (void)mods; + auto* state = static_cast(glfwGetWindowUserPointer(window)); + if (!state) return; + if (action == GLFW_PRESS) { + if (key == GLFW_KEY_ESCAPE) { + glfwSetWindowShouldClose(window, GLFW_TRUE); + } else if (key == GLFW_KEY_R) { + state->camera.resetToBounds(state->bounds); + } + } +} + + +playcanvas_viewer::CameraInputState get_camera_input(GLFWwindow* window) { + playcanvas_viewer::CameraInputState input; + input.forward = glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS; + input.backward = glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS; + input.left = glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS; + input.right = glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS; + input.up = glfwGetKey(window, GLFW_KEY_E) == GLFW_PRESS; + input.down = glfwGetKey(window, GLFW_KEY_Q) == GLFW_PRESS; + return input; +} + +splat::visualization::GSplatGLFrameState makeFrameState(const playcanvas_viewer::CameraController& camera, + int width, + int height) { + splat::visualization::GSplatGLFrameState frame; + frame.view = camera.viewMatrix(); + frame.projection = camera.projectionMatrix(); + frame.cameraPosition = camera.position(); + frame.cameraForward = camera.forward(); + frame.width = width; + frame.height = height; + frame.nearPlane = camera.nearPlane(); + frame.farPlane = camera.farPlane(); + return frame; +} + + +// RAII for GLFW +struct GlfwSession { + GlfwSession() { if (!glfwInit()) throw std::runtime_error("Failed to initialize GLFW"); } + ~GlfwSession() { glfwTerminate(); } +}; + +int main(int argc, char** argv) { + try { + auto options = parse_args(argc, argv); + auto table = readInput(options.inputFile); + if (!table || table->getNumRows() == 0) + throw std::runtime_error("Input data is empty or failed to load"); + auto data = splat::visualization::adaptDataTableToGSplatData(*table, options.maxSplats); + if (data.empty()) + throw std::runtime_error("Adapted data is empty"); + if (!options.sort) { + std::cerr << "PlaycanvasViewer warning: --no-sort disables alpha-depth ordering and reduces 3DGS quality.\n"; + } + + GlfwSession glfwSession; + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); + glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); + std::unique_ptr window( + glfwCreateWindow(options.width, options.height, "PlaycanvasViewer", nullptr, nullptr), + glfwDestroyWindow); + if (!window) + throw std::runtime_error("Failed to create GLFW window"); + glfwMakeContextCurrent(window.get()); + glewExperimental = GL_TRUE; + if (glewInit() != GLEW_OK) + throw std::runtime_error("Failed to initialize GLEW"); + glfwSwapInterval(1); // vsync + + AppState state; + state.camera.resize(options.width, options.height); + state.bounds = playcanvas_viewer::computeSceneBounds(data); + state.camera.resetToBounds(state.bounds); + state.renderOptions.sortBackToFront = options.sort; + state.renderer.setData(data); + glfwSetWindowUserPointer(window.get(), &state); + glfwSetFramebufferSizeCallback(window.get(), framebuffer_size_callback); + glfwSetMouseButtonCallback(window.get(), mouse_button_callback); + glfwSetCursorPosCallback(window.get(), cursor_pos_callback); + glfwSetScrollCallback(window.get(), scroll_callback); + glfwSetKeyCallback(window.get(), key_callback); + + auto lastTime = std::chrono::high_resolution_clock::now(); + while (!glfwWindowShouldClose(window.get())) { + auto now = std::chrono::high_resolution_clock::now(); + float delta = std::chrono::duration(now - lastTime).count(); + lastTime = now; + playcanvas_viewer::CameraInputState input = get_camera_input(window.get()); + state.camera.fly(input, delta); + int width, height; + glfwGetFramebufferSize(window.get(), &width, &height); + glViewport(0, 0, width, height); + glClearColor(0.1f, 0.1f, 0.1f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + state.renderer.render(makeFrameState(state.camera, width, height), state.renderOptions); + glfwSwapBuffers(window.get()); + glfwPollEvents(); + } + return 0; + } catch (const std::exception& ex) { + std::cerr << "PlaycanvasViewer error: " << ex.what() << std::endl; + return 1; + } +} diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt deleted file mode 100644 index 486c801..0000000 --- a/python/CMakeLists.txt +++ /dev/null @@ -1,7 +0,0 @@ -pybind11_add_module(splat_module binding.cpp) - -target_link_libraries(splat_module PRIVATE SPLAT::splat) - -set_target_properties(splat_module PROPERTIES - LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/python_lib" -) diff --git a/python/binding.cpp b/python/binding.cpp deleted file mode 100644 index 105d571..0000000 --- a/python/binding.cpp +++ /dev/null @@ -1,350 +0,0 @@ -/** - * @file binding.cpp - * @brief Python bindings for the splat library using pybind11. - * - * Exposes DataTable, Column, I/O functions, and operations to Python. - */ - -#include -#include -#include -#include -#include - -#include -#include -#include -#include - - -namespace py = pybind11; -using namespace splat; - -// Helper: Convert numpy array to Column -static Column numpy_to_column(const std::string& name, const py::array& arr) { - py::buffer_info buf = arr.request(); - - if (buf.ndim != 1) { - throw std::runtime_error("Array must be 1-dimensional"); - } - - if (buf.format == py::format_descriptor::format()) { - std::vector data(static_cast(buf.ptr), static_cast(buf.ptr) + buf.size); - return Column{name, std::move(data)}; - } else if (buf.format == py::format_descriptor::format()) { - std::vector data(static_cast(buf.ptr), static_cast(buf.ptr) + buf.size); - return Column{name, std::move(data)}; - } else if (buf.format == py::format_descriptor::format()) { - std::vector data(static_cast(buf.ptr), static_cast(buf.ptr) + buf.size); - return Column{name, std::move(data)}; - } else if (buf.format == py::format_descriptor::format()) { - std::vector data(static_cast(buf.ptr), static_cast(buf.ptr) + buf.size); - return Column{name, std::move(data)}; - } else if (buf.format == py::format_descriptor::format()) { - std::vector data(static_cast(buf.ptr), static_cast(buf.ptr) + buf.size); - return Column{name, std::move(data)}; - } else if (buf.format == py::format_descriptor::format()) { - std::vector data(static_cast(buf.ptr), static_cast(buf.ptr) + buf.size); - return Column{name, std::move(data)}; - } else if (buf.format == py::format_descriptor::format()) { - std::vector data(static_cast(buf.ptr), static_cast(buf.ptr) + buf.size); - return Column{name, std::move(data)}; - } else if (buf.format == py::format_descriptor::format()) { - std::vector data(static_cast(buf.ptr), static_cast(buf.ptr) + buf.size); - return Column{name, std::move(data)}; - } - throw std::runtime_error("Unsupported numpy array type: " + buf.format); -} - -// Helper: Convert Column to numpy array -static py::array column_to_numpy(const Column& col) { - switch (col.getType()) { - case ColumnType::FLOAT32: { - const auto& vec = col.asVector(); - auto arr = py::array_t(vec.size()); - std::memcpy(arr.mutable_data(), vec.data(), vec.size() * sizeof(float)); - return arr; - } - case ColumnType::FLOAT64: { - const auto& vec = col.asVector(); - auto arr = py::array_t(vec.size()); - std::memcpy(arr.mutable_data(), vec.data(), vec.size() * sizeof(double)); - return arr; - } - case ColumnType::INT32: { - const auto& vec = col.asVector(); - auto arr = py::array_t(vec.size()); - std::memcpy(arr.mutable_data(), vec.data(), vec.size() * sizeof(int32_t)); - return arr; - } - case ColumnType::UINT32: { - const auto& vec = col.asVector(); - auto arr = py::array_t(vec.size()); - std::memcpy(arr.mutable_data(), vec.data(), vec.size() * sizeof(uint32_t)); - return arr; - } - case ColumnType::INT16: { - const auto& vec = col.asVector(); - auto arr = py::array_t(vec.size()); - std::memcpy(arr.mutable_data(), vec.data(), vec.size() * sizeof(int16_t)); - return arr; - } - case ColumnType::UINT16: { - const auto& vec = col.asVector(); - auto arr = py::array_t(vec.size()); - std::memcpy(arr.mutable_data(), vec.data(), vec.size() * sizeof(uint16_t)); - return arr; - } - case ColumnType::INT8: { - const auto& vec = col.asVector(); - auto arr = py::array_t(vec.size()); - std::memcpy(arr.mutable_data(), vec.data(), vec.size() * sizeof(int8_t)); - return arr; - } - case ColumnType::UINT8: { - const auto& vec = col.asVector(); - auto arr = py::array_t(vec.size()); - std::memcpy(arr.mutable_data(), vec.data(), vec.size() * sizeof(uint8_t)); - return arr; - } - } - throw std::runtime_error("Unknown column type"); -} - -// Read a column from DataTable as numpy array -static py::array get_column_data(const DataTable& dt, int idx) { - if (idx < 0 || idx >= static_cast(dt.getNumColumns())) { - throw std::out_of_range("Column index out of range"); - } - return column_to_numpy(dt.getColumn(idx)); -} - -static py::array get_column_data_by_name(const DataTable& dt, const std::string& name) { - return column_to_numpy(dt.getColumnByName(name)); -} - -// Create DataTable from dict of {name: numpy_array} -static std::unique_ptr datatable_from_dict(const py::dict& d) { - std::vector cols; - cols.reserve(d.size()); - for (auto [key, val] : d) { - std::string name = py::cast(key); - py::array arr = py::cast(val); - cols.push_back(numpy_to_column(name, arr)); - } - return std::make_unique(cols); -} - -// Convert DataTable to dict of {name: numpy_array} -static py::dict datatable_to_dict(const DataTable& dt) { - py::dict d; - for (size_t i = 0; i < dt.getNumColumns(); i++) { - const auto& col = dt.getColumn(i); - d[col.name.c_str()] = column_to_numpy(col); - } - return d; -} - -// Get a row as tuple -static py::tuple get_row_tuple(const DataTable& dt, size_t idx) { - if (idx >= dt.getNumRows()) { - throw std::out_of_range("Row index out of range"); - } - py::tuple t(dt.getNumColumns()); - for (size_t j = 0; j < dt.getNumColumns(); j++) { - t[j] = dt.getColumn(j).getValue(idx); - } - return t; -} - -// Set row from tuple -static void set_row_tuple(DataTable& dt, size_t idx, const py::tuple& t) { - if (idx >= dt.getNumRows()) { - throw std::out_of_range("Row index out of range"); - } - if (t.size() != dt.getNumColumns()) { - throw std::runtime_error("Tuple size doesn't match column count"); - } - Row row; - for (size_t j = 0; j < dt.getNumColumns(); j++) { - row[dt.getColumn(j).name] = py::cast(t[j]); - } - dt.setRow(idx, row); -} - -// Get column type as string -static std::string column_type_str(const Column& col) { - switch (col.getType()) { - case ColumnType::INT8: - return "int8"; - case ColumnType::UINT8: - return "uint8"; - case ColumnType::INT16: - return "int16"; - case ColumnType::UINT16: - return "uint16"; - case ColumnType::INT32: - return "int32"; - case ColumnType::UINT32: - return "uint32"; - case ColumnType::FLOAT32: - return "float32"; - case ColumnType::FLOAT64: - return "float64"; - } - return "unknown"; -} - -PYBIND11_MODULE(splat_transform_cpp, m) { - m.doc() = "Python bindings for the splat library - 3D Gaussian Splatting I/O and operations"; - - // ========== Column class ========== - py::class_(m, "Column") - .def_readonly("name", &Column::name) - .def_property_readonly("dtype", &column_type_str) - .def_property_readonly("length", &Column::length) - .def("get_data", &column_to_numpy, "Get column data as numpy array") - .def("__len__", &Column::length) - .def("__repr__", [](const Column& c) { - return ""; - }); - - // ========== DataTable class ========== - py::class_(m, "DataTable") - .def(py::init<>(), "Create empty DataTable") - .def_readonly("columns", &DataTable::columns) - - // Properties - .def_property_readonly("num_rows", &DataTable::getNumRows) - .def_property_readonly("num_columns", &DataTable::getNumColumns) - .def_property_readonly("column_names", &DataTable::getColumnNames) - .def_property_readonly("column_types", - [](const DataTable& dt) { - std::vector types; - for (size_t i = 0; i < dt.getNumColumns(); i++) { - types.push_back(column_type_str(dt.getColumn(i))); - } - return types; - }) - - // Column access - .def("get_column", py::overload_cast(&DataTable::getColumn, py::const_), "Get column by index") - .def("get_column_by_name", py::overload_cast(&DataTable::getColumnByName, py::const_), - "Get column by name") - .def("has_column", &DataTable::hasColumn, "Check if column exists") - .def("get_column_index", &DataTable::getColumnIndex, "Get column index by name") - .def("get_column_data", &get_column_data, "Get column data as numpy array by index") - .def("get_column_data_by_name", &get_column_data_by_name, "Get column data as numpy array by name") - - // Row access - .def( - "get_row", - [](const DataTable& dt, size_t idx) -> std::map { - Row row; - dt.getRow(idx, row); - return row; - }, - "Get row as dict") - .def("get_row_tuple", &get_row_tuple, "Get row as tuple of floats") - .def("set_row_tuple", &set_row_tuple, "Set row from tuple of floats") - - // Modification - .def( - "add_column", - [](DataTable& dt, const std::string& name, const py::array& arr) { - dt.addColumn(numpy_to_column(name, arr)); - }, - "Add column from numpy array") - .def("remove_column", &DataTable::removeColumn, "Remove column by name") - .def("clone", &DataTable::clone, py::arg("column_names") = std::vector{}, "Create a deep copy") - - // Utility - .def( - "permute_rows", - [](const DataTable& dt, const std::vector& indices) { return dt.permuteRows(indices); }, - "Create new table with permuted rows") - .def("to_dict", &datatable_to_dict, "Convert to dict of {column_name: numpy_array}") - .def_static("from_dict", &datatable_from_dict, "Create from dict of {column_name: numpy_array}") - .def("__repr__", - [](const DataTable& dt) { - return ""; - }) - .def( - "__getitem__", - [](const DataTable& dt, const std::string& name) -> py::array { - return column_to_numpy(dt.getColumnByName(name)); - }, - "Get column data by name (dt['name'])") - .def( - "__contains__", [](const DataTable& dt, const std::string& name) -> bool { return dt.hasColumn(name); }, - "Check if column exists (name in dt)"); - - // ========== Rotation SH ========== - py::class_(m, "RotateSH") - .def(py::init(), "Create from 3x3 rotation matrix") - .def( - "apply", [](RotateSH& self, std::vector& result, std::vector src) { self.apply(result, src); }, - py::arg("result"), py::arg("src") = std::vector{}, "Apply rotation to SH coefficients") - .def_readwrite("sh1", &RotateSH::sh1) - .def_readwrite("sh2", &RotateSH::sh2) - .def_readwrite("sh3", &RotateSH::sh3); - - // ========== sigmoid ========== - m.def("sigmoid", &sigmoid, "Sigmoid activation function"); - - // ========== I/O Functions ========== - - // Readers - m.def("read_ply", &readPly, py::arg("filename"), "Read PLY file → DataTable"); - m.def("read_splat", &readSplat, py::arg("filename"), "Read .splat binary file → DataTable"); - m.def("read_sog", &readSog, py::arg("file"), py::arg("source_name"), "Read SOG file → DataTable"); - m.def("read_spz", &readSpz, py::arg("filename"), "Read SPZ file → DataTable"); - m.def("read_ksplat", &readKsplat, py::arg("filename"), "Read .ksplat file → DataTable"); - m.def("read_lcc", &readLcc, py::arg("filename"), py::arg("source_name"), py::arg("options"), - "Read LCC file → List[DataTable]"); - m.def("read_voxel", &readVoxel, py::arg("voxel_json_path"), "Read voxel file → DataTable"); - - // Writers - m.def("write_ply", &writePly, py::arg("filename"), py::arg("ply_data"), "Write PLY file"); - m.def("write_splat", &writeSplat, py::arg("datatable"), py::arg("filepath"), "Write .splat binary file"); - m.def("write_sog", &writeSog, py::arg("filename"), py::arg("data_table"), py::arg("bundle") = true, - py::arg("iterations") = 10, py::arg("indices") = std::vector{}, "Write SOG file"); - m.def("write_csv", &writeCSV, py::arg("path"), py::arg("data_table"), "Write CSV file"); - m.def("write_glb", &writeGlb, py::arg("filename"), py::arg("data_table"), "Write GLB file"); - - // ========== Operations ========== - m.def("combine", &combine, py::arg("data_tables"), "Merge multiple DataTables into one"); - m.def("simplify_gaussians", &simplifyGaussians, py::arg("data_table"), py::arg("target_count"), - "Simplify splat count using pairwise merging"); - m.def( - "sort_morton_order", [](const DataTable* dt, std::vector& indices) { sortMortonOrder(dt, indices); }, - py::arg("data_table"), py::arg("indices"), "Sort indices in Morton order (in-place)"); - m.def( - "transform", - [](DataTable* dt, const Eigen::Vector3f& t, const Eigen::Quaternionf& r, float s) { transform(dt, t, r, s); }, - py::arg("data_table"), py::arg("translation"), py::arg("rotation"), py::arg("scale"), - "Apply translation, rotation, and scaling"); - - // ========== WriteGlbOptions ========== - py::class_(m, "WriteGlbOptions") - .def(py::init<>()) - .def_readwrite("filename", &WriteGlbOptions::filename) - .def_readwrite("data_table", &WriteGlbOptions::data_table); - - // ========== WriteVoxelOptions ========== - py::class_(m, "WriteVoxelOptions") - .def(py::init<>()) - .def_readwrite("filename", &WriteVoxelOptions::filename) - .def_readwrite("data_table", &WriteVoxelOptions::data_table) - .def_readwrite("voxel_resolution", &WriteVoxelOptions::voxel_resolution) - .def_readwrite("opacity_cutoff", &WriteVoxelOptions::opacity_cutoff) - .def_readwrite("cuda_device_index", &WriteVoxelOptions::cuda_device_index) - .def_readwrite("collision_mesh", &WriteVoxelOptions::collision_mesh) - .def_readwrite("mesh_simplify", &WriteVoxelOptions::mesh_simplify); - - m.def("write_voxel", &writeVoxel, py::arg("options"), "Write voxel file (requires CUDA)"); - - // ========== Utility ========== - m.def("get_splat_version", []() -> std::string { return "0.1.0"; }, "Get library version string"); -} diff --git a/python/splat_transform/__init__.py b/python/splat_transform/__init__.py deleted file mode 100644 index 979ad79..0000000 --- a/python/splat_transform/__init__.py +++ /dev/null @@ -1,78 +0,0 @@ -""" -Python wrapper for the splat library - 3D Gaussian Splatting I/O and operations. - -This module provides a high-level Pythonic interface to the splat library. - -Usage: - import splat_transform as splat - - # Read a PLY file - dt = splat.read_ply("scene.ply") - print(f"Loaded {dt.num_rows} splats with {dt.num_columns} columns") - - # Access column data as numpy arrays - x = dt["x"] # or dt.get_column_data_by_name("x") - - # Transform it - import numpy as np - splat.transform(dt, np.array([0, 0, 0], dtype=np.float32), - np.array([1, 0, 0, 0], dtype=np.float32), 1.0) - - # Write it back - splat.write_splat(dt, "output.splat") -""" - -from splat_transform_cpp import ( - Column, - DataTable, - RotateSH, - WriteGlbOptions, - WriteVoxelOptions, - combine, - get_splat_version, - read_ksplat, - read_lcc, - read_ply, - read_sog, - read_splat, - read_spz, - read_voxel, - sigmoid, - simplify_gaussians, - sort_morton_order, - transform, - write_csv, - write_glb, - write_ply, - write_sog, - write_splat, - write_voxel, -) - -__version__ = get_splat_version() - -__all__ = [ - "Column", - "DataTable", - "RotateSH", - "WriteGlbOptions", - "WriteVoxelOptions", - "combine", - "read_ksplat", - "read_lcc", - "read_ply", - "read_sog", - "read_splat", - "read_spz", - "read_voxel", - "sigmoid", - "simplify_gaussians", - "sort_morton_order", - "transform", - "write_csv", - "write_glb", - "write_ply", - "write_sog", - "write_splat", - "write_voxel", -] diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ea5d653..9bf0348 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,25 +1,73 @@ -file(GLOB_RECURSE io_sources CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/io/*.cpp ${CMAKE_CURRENT_LIST_DIR}/io/*.cu) -file(GLOB_RECURSE maths_sources CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/maths/*.cpp ${CMAKE_CURRENT_LIST_DIR}/maths/*.cu) -file(GLOB_RECURSE models_sources CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/models/*.cpp ${CMAKE_CURRENT_LIST_DIR}/models/*.cu) -file(GLOB_RECURSE op_sources CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/op/*.cpp ${CMAKE_CURRENT_LIST_DIR}/op/*.cu) -file(GLOB_RECURSE spatial_sources CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/spatial/*.cpp ${CMAKE_CURRENT_LIST_DIR}/spatial/*.cu) -file(GLOB_RECURSE utils_sources CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/utils/*.cpp ${CMAKE_CURRENT_LIST_DIR}/utils/*.cu) -file(GLOB_RECURSE voxel_sources CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/voxel/*.cpp ${CMAKE_CURRENT_LIST_DIR}/voxel/*.cu) +file(GLOB_RECURSE io_sources CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/io/*.cpp) +file(GLOB_RECURSE maths_sources CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/maths/*.cpp) +file(GLOB_RECURSE models_sources CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/models/*.cpp) +file(GLOB_RECURSE op_sources CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/op/*.cpp) +file(GLOB_RECURSE spatial_sources CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/spatial/*.cpp) +file(GLOB_RECURSE utils_sources CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/utils/*.cpp) +file(GLOB_RECURSE voxel_sources CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/voxel/*.cpp) + +# Remove GPU backend implementations from spatial_sources - they'll be added selectively +list(FILTER spatial_sources EXCLUDE REGEX "kmeans_cpu\\.cpp$") +list(FILTER spatial_sources EXCLUDE REGEX "kmeans_metal\\.mm$") +list(FILTER spatial_sources EXCLUDE REGEX "kmeans\\.cpp$") + +# GPU-specific sources +set(_gpu_sources "") +if(SPLAT_USE_CUDA) + file(GLOB_RECURSE io_cuda CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/io/*.cu) + file(GLOB_RECURSE maths_cuda CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/maths/*.cu) + file(GLOB_RECURSE models_cuda CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/models/*.cu) + file(GLOB_RECURSE op_cuda CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/op/*.cu) + file(GLOB_RECURSE spatial_cuda CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/spatial/*.cu) + file(GLOB_RECURSE utils_cuda CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/utils/*.cu) + file(GLOB_RECURSE voxel_cuda CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/voxel/*.cu) + + # Remove kmeans_cuda from spatial_cuda since we add it explicitly + list(FILTER spatial_cuda EXCLUDE REGEX "kmeans_cuda\\.cu$") + list(FILTER spatial_cuda EXCLUDE REGEX "kmeans\\.cu$") + + set(_gpu_sources ${io_cuda} ${maths_cuda} ${models_cuda} ${op_cuda} ${spatial_cuda} ${utils_cuda} ${voxel_cuda}) + + # Add GPU compute abstraction implementation + list(APPEND _gpu_sources ${CMAKE_CURRENT_LIST_DIR}/spatial/kmeans_cuda.cu) + # CUDA build keeps the original kmeans frontend in .cu. + list(APPEND _gpu_sources ${CMAKE_CURRENT_LIST_DIR}/spatial/kmeans.cu) + message(STATUS "SplatLib: Using CUDA compute backend") +elseif(SPLAT_USE_METAL) + # Use macOS-specific voxelization implementation and avoid compiling CUDA-dependent source. + list(FILTER voxel_sources EXCLUDE REGEX "gpu-voxelization\\.cpp$") + list(FILTER voxel_sources EXCLUDE REGEX "gpu-voxelization-mac\\.cpp$") + list(APPEND voxel_sources ${CMAKE_CURRENT_LIST_DIR}/voxel/gpu-voxelization-mac.mm) + + file(GLOB_RECURSE spatial_metal CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/spatial/*.metal) + file(GLOB_RECURSE spatial_metal_impl CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/spatial/*_metal.mm) + set(_gpu_sources ${spatial_metal} ${spatial_metal_impl} ${CMAKE_CURRENT_LIST_DIR}/voxel/gpu-voxelization-metal.metal) + list(APPEND _gpu_sources ${CMAKE_CURRENT_LIST_DIR}/spatial/kmeans.cpp) + message(STATUS "SplatLib: Using Metal compute backend") +else() + # CPU fallback backend + list(APPEND _gpu_sources ${CMAKE_CURRENT_LIST_DIR}/spatial/kmeans_cpu.cpp) + list(APPEND _gpu_sources ${CMAKE_CURRENT_LIST_DIR}/spatial/kmeans.cpp) + message(STATUS "SplatLib: Using CPU fallback compute backend") +endif() if(BUILD_SPLAT_VISUALIZATION) - file(GLOB_RECURSE visualization_sources CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/visualization/*.cpp ${CMAKE_CURRENT_LIST_DIR}/visualization/*.cu) - set(_sources ${io_sources} ${maths_sources} ${models_sources} ${op_sources} ${spatial_sources} ${utils_sources} ${voxel_sources} ${visualization_sources}) + file(GLOB_RECURSE visualization_sources CONFIGURE_DEPEND ${CMAKE_CURRENT_LIST_DIR}/visualization/*.cpp) + set(_sources ${io_sources} ${maths_sources} ${models_sources} ${op_sources} ${spatial_sources} ${utils_sources} ${voxel_sources} ${visualization_sources} ${_gpu_sources}) else() - set(_sources ${io_sources} ${maths_sources} ${models_sources} ${op_sources} ${spatial_sources} ${utils_sources} ${voxel_sources}) + set(_sources ${io_sources} ${maths_sources} ${models_sources} ${op_sources} ${spatial_sources} ${utils_sources} ${voxel_sources} ${_gpu_sources}) endif() include(GNUInstallDirs) include(CMakePackageConfigHelpers) +include(CheckIncludeFileCXX) + +check_include_file_cxx(nanoflann.hpp SPLAT_HAS_NANOFLANN) if(BUILD_SPLAT_SHARED) - add_library(splat SHARED ${_sources} ${_cuda_sources}) + add_library(splat SHARED ${_sources}) else() - add_library(splat STATIC ${_sources} ${_cuda_sources}) + add_library(splat STATIC ${_sources}) endif() if(BUILD_SPLAT_SHARED AND WIN32) @@ -43,7 +91,6 @@ target_include_directories(splat target_link_libraries(splat PUBLIC - CUDA::cudart Eigen3::Eigen nlohmann_json::nlohmann_json WebP::webp @@ -54,6 +101,17 @@ target_link_libraries(splat meshoptimizer ) +# GPU backend specific linking +if(SPLAT_USE_CUDA) + target_link_libraries(splat PUBLIC CUDA::cudart) +elseif(SPLAT_USE_METAL) + target_link_libraries(splat PUBLIC + "-framework Metal" + "-framework MetalKit" + "-framework Foundation" + ) +endif() + if(BUILD_SPLAT_VISUALIZATION) target_link_libraries(splat PUBLIC @@ -87,6 +145,13 @@ if(MSVC) target_compile_options(splat PRIVATE "$<$:/utf-8>") endif() +if(SPLAT_HAS_NANOFLANN) + target_compile_definitions(splat PRIVATE SPLAT_HAS_NANOFLANN=1) + message(STATUS "nanoflann.hpp found: enabling nanoflann decimation KNN backend") +else() + message(STATUS "nanoflann.hpp not found: using built-in decimation KNN backend") +endif() + file(GLOB_RECURSE _headers ${PROJECT_SOURCE_DIR}/include/*.h) target_sources(splat PRIVATE ${_headers}) diff --git a/src/io/lod_writer.cpp b/src/io/lod_writer.cpp index e9aefc9..ce89826 100644 --- a/src/io/lod_writer.cpp +++ b/src/io/lod_writer.cpp @@ -257,7 +257,7 @@ void writeLod(const std::filesystem::path& filename, const DataTable* dataTable, meta["tree"] = metaToJson(rootMeta); std::ofstream ofs(filename, std::ios::binary); - ofs << meta.dump(4); + ofs << meta.dump(); ofs.flush(); ofs.close(); diff --git a/src/op/decimate.cpp b/src/op/decimate.cpp index 252a419..725a4ba 100644 --- a/src/op/decimate.cpp +++ b/src/op/decimate.cpp @@ -3,11 +3,18 @@ #include #include #include +#include + +#if defined(SPLAT_HAS_NANOFLANN) +#include +#endif #include #include +#include #include #include +#include #include #include #include @@ -18,7 +25,6 @@ namespace { namespace dm = decimate_math; -constexpr float kOpacityPruneThreshold = 0.1f; constexpr int kKnnK = 16; constexpr int kMcSamples = 1; @@ -26,6 +32,82 @@ constexpr const char* kRequiredCols[] = {"x", "y", "z", "scale_1", "scale_2", "rot_0", "rot_1", "rot_2", "rot_3"}; +using Clock = std::chrono::steady_clock; + +enum class KnnBackend { + BuiltIn, + Nanoflann, +}; + +bool decimate_profile_enabled() { + const char* value = std::getenv("SPLAT_DECIMATE_PROFILE"); + if (!value || value[0] == '\0') { + return false; + } + const std::string text(value); + return text != "0" && text != "false" && text != "FALSE"; +} + +KnnBackend decimate_knn_backend() { +#if defined(SPLAT_HAS_NANOFLANN) + const char* value = std::getenv("SPLAT_DECIMATE_KNN"); + if (value && value[0] != '\0') { + const std::string text(value); + if (text == "legacy" || text == "builtin" || text == "built-in" || text == "kdtree") { + return KnnBackend::BuiltIn; + } + } + return KnnBackend::Nanoflann; +#else + return KnnBackend::BuiltIn; +#endif +} + +const char* knn_backend_name(KnnBackend backend) { + switch (backend) { + case KnnBackend::Nanoflann: + return "nanoflann"; + case KnnBackend::BuiltIn: + default: + return "built-in"; + } +} + +double elapsed_ms(Clock::time_point start, Clock::time_point end = Clock::now()) { + return std::chrono::duration(end - start).count(); +} + +#if defined(SPLAT_HAS_NANOFLANN) +struct NanoflannPointCloud { + const std::vector& x; + const std::vector& y; + const std::vector& z; + + size_t kdtree_get_point_count() const { + return x.size(); + } + + float kdtree_get_pt(size_t idx, size_t dim) const { + if (dim == 0) { + return x[idx]; + } + if (dim == 1) { + return y[idx]; + } + return z[idx]; + } + + template + bool kdtree_get_bbox(BBOX&) const { + return false; + } +}; + +using NanoflannIndex = + nanoflann::KDTreeSingleIndexAdaptor, + NanoflannPointCloud, 3, size_t>; +#endif + bool has_required_columns(const DataTable& dt) { for (const char* name : kRequiredCols) { if (!dt.hasColumn(name)) { @@ -76,13 +158,12 @@ std::unique_ptr empty_table_like(const DataTable& src, size_t num_row } struct SplatCache { - std::vector R; - std::vector Rt; - std::vector v; - std::vector invdiag; - std::vector logdet; - std::vector sigma; - std::vector mass; + std::vector R; // row-major 3x3 rotation per splat (f32) + std::vector v; // variances (scale^2 + eps) per axis (f32) + std::vector invdiag; // 1/v per axis, precomputed (f32) + std::vector logdet; // log-determinant per splat (f32) + std::vector sigma; // full 9-element covariance per splat (f32) + std::vector mass; // area*alpha weight per splat (f32) }; SplatCache build_per_splat_cache(size_t n, const std::vector& cx, const std::vector& cy, @@ -93,7 +174,6 @@ SplatCache build_per_splat_cache(size_t n, const std::vector& cx, const s const std::vector& cr3) { SplatCache c; c.R.resize(n * 9); - c.Rt.resize(n * 9); c.v.resize(n * 3); c.invdiag.resize(n * 3); c.logdet.resize(n); @@ -104,44 +184,67 @@ SplatCache build_per_splat_cache(size_t n, const std::vector& cx, const s const size_t i3 = 3 * i; const size_t i9 = 9 * i; - const double lin_alpha = static_cast(splat::sigmoid(cop[i])); - const double sx = std::max(static_cast(std::exp(cs0[i])), 1e-12); - const double sy = std::max(static_cast(std::exp(cs1[i])), 1e-12); - const double sz = std::max(static_cast(std::exp(cs2[i])), 1e-12); + const float lin_alpha = splat::sigmoid(cop[i]); + const float sx = std::max(std::exp(cs0[i]), 1e-12f); + const float sy = std::max(std::exp(cs1[i]), 1e-12f); + const float sz = std::max(std::exp(cs2[i]), 1e-12f); - const double vx = sx * sx + dm::kEpsCov; - const double vy = sy * sy + dm::kEpsCov; - const double vz = sz * sz + dm::kEpsCov; + const float vx = sx * sx + static_cast(dm::kEpsCov); + const float vy = sy * sy + static_cast(dm::kEpsCov); + const float vz = sz * sz + static_cast(dm::kEpsCov); c.v[i3] = vx; c.v[i3 + 1] = vy; c.v[i3 + 2] = vz; - c.invdiag[i3] = 1 / std::max(vx, 1e-30); - c.invdiag[i3 + 1] = 1 / std::max(vy, 1e-30); - c.invdiag[i3 + 2] = 1 / std::max(vz, 1e-30); - c.logdet[i] = std::log(std::max(vx, 1e-30)) + std::log(std::max(vy, 1e-30)) + std::log(std::max(vz, 1e-30)); - - double qw = cr0[i], qx = cr1[i], qy = cr2[i], qz = cr3[i]; + c.invdiag[i3] = 1.0f / std::max(vx, 1e-30f); + c.invdiag[i3 + 1] = 1.0f / std::max(vy, 1e-30f); + c.invdiag[i3 + 2] = 1.0f / std::max(vz, 1e-30f); + c.logdet[i] = std::log(std::max(vx, 1e-30f)) + std::log(std::max(vy, 1e-30f)) + + std::log(std::max(vz, 1e-30f)); + + // Normalize quaternion, build rotation in double for precision + double qw = static_cast(cr0[i]), qx = static_cast(cr1[i]); + double qy = static_cast(cr2[i]), qz = static_cast(cr3[i]); const double qn = std::sqrt(qw * qw + qx * qx + qy * qy + qz * qz); const double invq = 1 / std::max(qn, 1e-12); - qw *= invq; - qx *= invq; - qy *= invq; - qz *= invq; + qw *= invq; qx *= invq; qy *= invq; qz *= invq; - dm::quat_to_rotmat(qw, qx, qy, qz, c.R.data(), i9); - dm::transpose3(c.R.data(), i9, c.Rt.data(), i9); - dm::sigma_from_rot_var(c.R.data(), i9, vx, vy, vz, c.sigma.data(), i9); + double Rtmp[9]; + dm::quat_to_rotmat(qw, qx, qy, qz, Rtmp, 0); + for (int k = 0; k < 9; ++k) c.R[i9 + static_cast(k)] = static_cast(Rtmp[k]); - c.mass[i] = dm::kTwoPiPow1_5 * lin_alpha * sx * sy * sz + 1e-12; + double sigtmp[9]; + dm::sigma_from_rot_var(Rtmp, 0, static_cast(vx), static_cast(vy), + static_cast(vz), sigtmp, 0); + for (int k = 0; k < 9; ++k) c.sigma[i9 + static_cast(k)] = static_cast(sigtmp[k]); + + // Area*alpha weighting (replaces volume*alpha NanoGS formula) + c.mass[i] = lin_alpha * static_cast(dm::ellipsoid_area( + static_cast(sx), static_cast(sy), static_cast(sz))) + 1e-12f; } return c; } +// Reusable scratch buffers for compute_edge_cost and moment_match. +// Allocated once per simplifyGaussians invocation -- avoids per-call +// heap allocations in the merge loop. +struct MergeScratch { + double sigm[9]; // compute_edge_cost: merged covariance + double sigI[9]; // moment_match: input i covariance + double sigJ[9]; // moment_match: input j covariance + double rI[9]; // moment_match: input i rotation + double rJ[9]; // moment_match: input j rotation + double sig[9]; // moment_match: merged covariance + double rM[9]; // moment_match: merged rotation + double eigA[9]; // eigen_symmetric_3x3: working matrix + double eigV[9]; // eigen_symmetric_3x3: eigenvectors +}; + double compute_edge_cost(size_t i, size_t j, const std::vector& cx, const std::vector& cy, const std::vector& cz, const SplatCache& cache, const std::vector>& Z, - const std::vector*>& app_cols) { + const std::vector*>& app_cols, + MergeScratch& scratch) { const size_t i3 = 3 * i, j3 = 3 * j; const size_t i9 = 9 * i, j9 = 9 * j; @@ -150,13 +253,10 @@ double compute_edge_cost(size_t i, size_t j, const std::vector& cx, const const double wi = cache.mass[i], wj = cache.mass[j]; const double W = wi + wj; - const double Wsafe = W > 0 ? W : 1.0; - - double pi = wi / Wsafe; - pi = std::max(1e-12, std::min(1.0 - 1e-12, pi)); - const double pj = 1.0 - pi; - const double log_pi = std::log(pi); - const double log_pj = std::log(pj); + const double pi = wi / (W > 0 ? W : 1.0); + const double pj = wj / (W > 0 ? W : 1.0); + const double log_pi = std::log(std::max(pi, 1e-12)); + const double log_pj = std::log(std::max(pj, 1e-12)); const double mmx = pi * mux + pj * mvx; const double mmy = pi * muy + pj * mvy; @@ -165,10 +265,10 @@ double compute_edge_cost(size_t i, size_t j, const std::vector& cx, const const double dix = mux - mmx, diy = muy - mmy, diz = muz - mmz; const double djx = mvx - mmx, djy = mvy - mmy, djz = mvz - mmz; - std::array sigm{}; + // Merged covariance (reuse scratch buffer) + double* sigm = scratch.sigm; for (int a = 0; a < 9; ++a) { - sigm[static_cast(a)] = pi * cache.sigma[i9 + static_cast(a)] + - pj * cache.sigma[j9 + static_cast(a)]; + sigm[a] = pi * cache.sigma[i9 + a] + pj * cache.sigma[j9 + a]; } sigm[0] += pi * dix * dix + pj * djx * djx; sigm[1] += pi * dix * diy + pj * djx * djy; @@ -183,47 +283,53 @@ double compute_edge_cost(size_t i, size_t j, const std::vector& cx, const sigm[1] = sigm[3] = 0.5 * (sigm[1] + sigm[3]); sigm[2] = sigm[6] = 0.5 * (sigm[2] + sigm[6]); sigm[5] = sigm[7] = 0.5 * (sigm[5] + sigm[7]); - sigm[0] += dm::kEpsCov; - sigm[4] += dm::kEpsCov; - sigm[8] += dm::kEpsCov; + sigm[0] += dm::kEpsCov; sigm[4] += dm::kEpsCov; sigm[8] += dm::kEpsCov; - const double detm = std::max(dm::det3(sigm.data(), 0), 1e-30); + const double detm = std::max(dm::det3(sigm, 0), 1e-30); const double logdetm = std::log(detm); const double ep_neg_log_q = 0.5 * (3 * dm::kLog2Pi + logdetm + 3); - const double stdix = std::sqrt(std::max(cache.v[i3], 0.0)); - const double stdiy = std::sqrt(std::max(cache.v[i3 + 1], 0.0)); - const double stdiz = std::sqrt(std::max(cache.v[i3 + 2], 0.0)); - const double stdjx = std::sqrt(std::max(cache.v[j3], 0.0)); - const double stdjy = std::sqrt(std::max(cache.v[j3 + 1], 0.0)); - const double stdjz = std::sqrt(std::max(cache.v[j3 + 2], 0.0)); + const double stdix = std::sqrt(std::max(static_cast(cache.v[i3]), 0.0)); + const double stdiy = std::sqrt(std::max(static_cast(cache.v[i3 + 1]), 0.0)); + const double stdiz = std::sqrt(std::max(static_cast(cache.v[i3 + 2]), 0.0)); + const double stdjx = std::sqrt(std::max(static_cast(cache.v[j3]), 0.0)); + const double stdjy = std::sqrt(std::max(static_cast(cache.v[j3 + 1]), 0.0)); + const double stdjz = std::sqrt(std::max(static_cast(cache.v[j3 + 2]), 0.0)); + + // Convert R to double once per edge (not per sample) + double Ri_d[9], Rj_d[9]; + for (int k = 0; k < 9; ++k) { + Ri_d[k] = cache.R[i9 + k]; + Rj_d[k] = cache.R[j9 + k]; + } + // Sample x = mu + R . diag(std) . z (uses R directly, no Rt) double sum_logp_on_i = 0, sum_logp_on_j = 0; for (const auto& zs : Z) { const double z0 = zs[0], z1 = zs[1], z2 = zs[2]; - const double xix = mux + z0 * stdix * cache.Rt[i9] + z1 * stdiy * cache.Rt[i9 + 3] + z2 * stdiz * cache.Rt[i9 + 6]; - const double xiy = muy + z0 * stdix * cache.Rt[i9 + 1] + z1 * stdiy * cache.Rt[i9 + 4] + z2 * stdiz * cache.Rt[i9 + 7]; - const double xiz = muz + z0 * stdix * cache.Rt[i9 + 2] + z1 * stdiy * cache.Rt[i9 + 5] + z2 * stdiz * cache.Rt[i9 + 8]; + const double xix = mux + z0 * stdix * Ri_d[0] + z1 * stdiy * Ri_d[1] + z2 * stdiz * Ri_d[2]; + const double xiy = muy + z0 * stdix * Ri_d[3] + z1 * stdiy * Ri_d[4] + z2 * stdiz * Ri_d[5]; + const double xiz = muz + z0 * stdix * Ri_d[6] + z1 * stdiy * Ri_d[7] + z2 * stdiz * Ri_d[8]; - const double xjx = mvx + z0 * stdjx * cache.Rt[j9] + z1 * stdjy * cache.Rt[j9 + 3] + z2 * stdjz * cache.Rt[j9 + 6]; - const double xjy = mvy + z0 * stdjx * cache.Rt[j9 + 1] + z1 * stdjy * cache.Rt[j9 + 4] + z2 * stdjz * cache.Rt[j9 + 7]; - const double xjz = mvz + z0 * stdjx * cache.Rt[j9 + 2] + z1 * stdjy * cache.Rt[j9 + 5] + z2 * stdjz * cache.Rt[j9 + 8]; + const double xjx = mvx + z0 * stdjx * Rj_d[0] + z1 * stdjy * Rj_d[1] + z2 * stdjz * Rj_d[2]; + const double xjy = mvy + z0 * stdjx * Rj_d[3] + z1 * stdjy * Rj_d[4] + z2 * stdjz * Rj_d[5]; + const double xjz = mvz + z0 * stdjx * Rj_d[6] + z1 * stdjy * Rj_d[7] + z2 * stdjz * Rj_d[8]; - const double log_ni_on_i = - dm::gauss_logpdf_diagrot(xix, xiy, xiz, mux, muy, muz, cache.R.data(), i9, cache.invdiag[i3], - cache.invdiag[i3 + 1], cache.invdiag[i3 + 2], cache.logdet[i]); - const double log_nj_on_i = - dm::gauss_logpdf_diagrot(xix, xiy, xiz, mvx, mvy, mvz, cache.R.data(), j9, cache.invdiag[j3], - cache.invdiag[j3 + 1], cache.invdiag[j3 + 2], cache.logdet[j]); + const double log_ni_on_i = dm::gauss_logpdf_diagrot( + xix, xiy, xiz, mux, muy, muz, Ri_d, 0, + cache.invdiag[i3], cache.invdiag[i3 + 1], cache.invdiag[i3 + 2], cache.logdet[i]); + const double log_nj_on_i = dm::gauss_logpdf_diagrot( + xix, xiy, xiz, mvx, mvy, mvz, Rj_d, 0, + cache.invdiag[j3], cache.invdiag[j3 + 1], cache.invdiag[j3 + 2], cache.logdet[j]); sum_logp_on_i += dm::log_add_exp(log_pi + log_ni_on_i, log_pj + log_nj_on_i); - const double log_ni_on_j = - dm::gauss_logpdf_diagrot(xjx, xjy, xjz, mux, muy, muz, cache.R.data(), i9, cache.invdiag[i3], - cache.invdiag[i3 + 1], cache.invdiag[i3 + 2], cache.logdet[i]); - const double log_nj_on_j = - dm::gauss_logpdf_diagrot(xjx, xjy, xjz, mvx, mvy, mvz, cache.R.data(), j9, cache.invdiag[j3], - cache.invdiag[j3 + 1], cache.invdiag[j3 + 2], cache.logdet[j]); + const double log_ni_on_j = dm::gauss_logpdf_diagrot( + xjx, xjy, xjz, mux, muy, muz, Ri_d, 0, + cache.invdiag[i3], cache.invdiag[i3 + 1], cache.invdiag[i3 + 2], cache.logdet[i]); + const double log_nj_on_j = dm::gauss_logpdf_diagrot( + xjx, xjy, xjz, mvx, mvy, mvz, Rj_d, 0, + cache.invdiag[j3], cache.invdiag[j3 + 1], cache.invdiag[j3 + 2], cache.logdet[j]); sum_logp_on_j += dm::log_add_exp(log_pi + log_ni_on_j, log_pj + log_nj_on_j); } @@ -246,7 +352,8 @@ void moment_match(size_t i, size_t j, const std::vector& cx, const std::v const std::vector& cs1, const std::vector& cs2, const std::vector& cr0, const std::vector& cr1, const std::vector& cr2, const std::vector& cr3, std::array& mu, std::array& sc, std::array& q, double& op_lin, - std::vector& sh_out, const std::vector*>& app_cols) { + std::vector& sh_out, const std::vector*>& app_cols, + MergeScratch& scratch) { const double sxi = std::max(static_cast(std::exp(cs0[i])), 1e-12); const double syi = std::max(static_cast(std::exp(cs1[i])), 1e-12); const double szi = std::max(static_cast(std::exp(cs2[i])), 1e-12); @@ -254,95 +361,109 @@ void moment_match(size_t i, size_t j, const std::vector& cx, const std::v const double syj = std::max(static_cast(std::exp(cs1[j])), 1e-12); const double szj = std::max(static_cast(std::exp(cs2[j])), 1e-12); - const double alpha_i = static_cast(splat::sigmoid(cop[i])); - const double alpha_j = static_cast(splat::sigmoid(cop[j])); + const double alpha_i = splat::sigmoid(cop[i]); + const double alpha_j = splat::sigmoid(cop[j]); - const double wi = dm::kTwoPiPow1_5 * alpha_i * sxi * syi * szi + 1e-12; - const double wj = dm::kTwoPiPow1_5 * alpha_j * sxj * syj * szj + 1e-12; - const double W = std::max(wi + wj, 1e-12); - - const double mux = (wi * cx[i] + wj * cx[j]) / W; - const double muy = (wi * cy[i] + wj * cy[j]) / W; - const double muz = (wi * cz[i] + wj * cz[j]) / W; + // Area-alpha weighting (replaces old volume-alpha NanoGS formula) + const double Ai = dm::ellipsoid_area(sxi, syi, szi); + const double Aj = dm::ellipsoid_area(sxj, syj, szj); + const double wi = alpha_i * Ai + 1e-30; + const double wj = alpha_j * Aj + 1e-30; + const double W = wi + wj; + const double pi = wi / W; + const double pj = wj / W; + + // Merged mean (area-alpha weighted) + const double mxi = cx[i], myi = cy[i], mzi = cz[i]; + const double mxj = cx[j], myj = cy[j], mzj = cz[j]; + const double mux = pi * mxi + pj * mxj; + const double muy = pi * myi + pj * myj; + const double muz = pi * mzi + pj * mzj; + + // Per-call scratch buffers + double* SigI = scratch.sigI; + double* SigJ = scratch.sigJ; + double* Ri = scratch.rI; + double* Rj = scratch.rJ; - std::array sig_i{}, sig_j{}, ri{}, rj{}; double qwi = cr0[i], qxi = cr1[i], qyi = cr2[i], qzi = cr3[i]; - double ni = std::sqrt(qwi * qwi + qxi * qxi + qyi * qyi + qzi * qzi); - ni = 1 / std::max(ni, 1e-12); - qwi *= ni; - qxi *= ni; - qyi *= ni; - qzi *= ni; + double ni = 1 / std::max(std::sqrt(qwi * qwi + qxi * qxi + qyi * qyi + qzi * qzi), 1e-12); + qwi *= ni; qxi *= ni; qyi *= ni; qzi *= ni; double qwj = cr0[j], qxj = cr1[j], qyj = cr2[j], qzj = cr3[j]; - double nj = std::sqrt(qwj * qwj + qxj * qxj + qyj * qyj + qzj * qzj); - nj = 1 / std::max(nj, 1e-12); - qwj *= nj; - qxj *= nj; - qyj *= nj; - qzj *= nj; - - dm::quat_to_rotmat(qwi, qxi, qyi, qzi, ri.data(), 0); - dm::quat_to_rotmat(qwj, qxj, qyj, qzj, rj.data(), 0); - dm::sigma_from_rot_var(ri.data(), 0, sxi * sxi, syi * syi, szi * szi, sig_i.data(), 0); - dm::sigma_from_rot_var(rj.data(), 0, sxj * sxj, syj * syj, szj * szj, sig_j.data(), 0); - - const double dix = cx[i] - mux, diy = cy[i] - muy, diz = cz[i] - muz; - const double djx = cx[j] - mux, djy = cy[j] - muy, djz = cz[j] - muz; - - std::array sig{}; - for (int a = 0; a < 9; ++a) { - sig[static_cast(a)] = (wi * sig_i[static_cast(a)] + wj * sig_j[static_cast(a)]) / W; - } - sig[0] += (wi * dix * dix + wj * djx * djx) / W; - sig[1] += (wi * dix * diy + wj * djx * djy) / W; - sig[2] += (wi * dix * diz + wj * djx * djz) / W; - sig[3] += (wi * diy * dix + wj * djy * djx) / W; - sig[4] += (wi * diy * diy + wj * djy * djy) / W; - sig[5] += (wi * diy * diz + wj * djy * djz) / W; - sig[6] += (wi * diz * dix + wj * djz * djx) / W; - sig[7] += (wi * diz * diy + wj * djz * djy) / W; - sig[8] += (wi * diz * diz + wj * djz * djz) / W; - - sig[1] = sig[3] = 0.5 * (sig[1] + sig[3]); - sig[2] = sig[6] = 0.5 * (sig[2] + sig[6]); - sig[5] = sig[7] = 0.5 * (sig[5] + sig[7]); - sig[0] += dm::kEpsCov; - sig[4] += dm::kEpsCov; - sig[8] += dm::kEpsCov; - - dm::Symmetric3x3Eigen ev = dm::eigen_symmetric_3x3(sig.data()); - double vals[3] = {ev.values[0], ev.values[1], ev.values[2]}; - int order[3] = {0, 1, 2}; - std::sort(order, order + 3, [&](int a, int b) { return vals[a] > vals[b]; }); - const double sorted_vals[3] = {std::max(vals[order[0]], 1e-18), std::max(vals[order[1]], 1e-18), - std::max(vals[order[2]], 1e-18)}; - - std::array rm{}; - for (int c = 0; c < 3; ++c) { - const int src = order[c]; - rm[static_cast(c)] = ev.vectors[src]; - rm[3 + static_cast(c)] = ev.vectors[3 + src]; - rm[6 + static_cast(c)] = ev.vectors[6 + src]; + double nj = 1 / std::max(std::sqrt(qwj * qwj + qxj * qxj + qyj * qyj + qzj * qzj), 1e-12); + qwj *= nj; qxj *= nj; qyj *= nj; qzj *= nj; + + dm::quat_to_rotmat(qwi, qxi, qyi, qzi, Ri, 0); + dm::quat_to_rotmat(qwj, qxj, qyj, qzj, Rj, 0); + dm::sigma_from_rot_var(Ri, 0, sxi * sxi, syi * syi, szi * szi, SigI, 0); + dm::sigma_from_rot_var(Rj, 0, sxj * sxj, syj * syj, szj * szj, SigJ, 0); + + const double dix = mxi - mux, diy = myi - muy, diz = mzi - muz; + const double djx = mxj - mux, djy = myj - muy, djz = mzj - muz; + + // Merged covariance: weighted sum of (delta*delta' + Sigma_k) -- scratch + double* Sig = scratch.sig; + Sig[0] = pi * (dix * dix + SigI[0]) + pj * (djx * djx + SigJ[0]); + Sig[1] = pi * (dix * diy + SigI[1]) + pj * (djx * djy + SigJ[1]); + Sig[2] = pi * (dix * diz + SigI[2]) + pj * (djx * djz + SigJ[2]); + Sig[3] = Sig[1]; + Sig[4] = pi * (diy * diy + SigI[4]) + pj * (djy * djy + SigJ[4]); + Sig[5] = pi * (diy * diz + SigI[5]) + pj * (djy * djz + SigJ[5]); + Sig[6] = Sig[2]; + Sig[7] = Sig[5]; + Sig[8] = pi * (diz * diz + SigI[8]) + pj * (djz * djz + SigJ[8]); + Sig[0] += dm::kEpsCov; Sig[4] += dm::kEpsCov; Sig[8] += dm::kEpsCov; + + // Eigendecompose -> scales (= sqrt(lambda)) and rotation + double* eigA = scratch.eigA; + double* eigV = scratch.eigV; + dm::eigen_symmetric_3x3(Sig, eigA, eigV); + + // Sort eigenvalue indices descending (hand-unrolled, no std::sort alloc) + const double v0 = eigA[0], v1 = eigA[4], v2 = eigA[8]; + int o0, o1, o2; + if (v0 >= v1) { + if (v1 >= v2) { o0 = 0; o1 = 1; o2 = 2; } + else if (v0 >= v2) { o0 = 0; o1 = 2; o2 = 1; } + else { o0 = 2; o1 = 0; o2 = 1; } + } else { + if (v0 >= v2) { o0 = 1; o1 = 0; o2 = 2; } + else if (v1 >= v2) { o0 = 1; o1 = 2; o2 = 0; } + else { o0 = 2; o1 = 1; o2 = 0; } } - - if (dm::det3(rm.data(), 0) < 0) { - rm[2] *= -1; - rm[5] *= -1; - rm[8] *= -1; + const double ev0 = std::max(eigA[3 * o0 + o0], 1e-18); + const double ev1 = std::max(eigA[3 * o1 + o1], 1e-18); + const double ev2 = std::max(eigA[3 * o2 + o2], 1e-18); + const double s0 = std::sqrt(ev0); + const double s1 = std::sqrt(ev1); + const double s2 = std::sqrt(ev2); + + // Mass-conserving opacity, capped at 1 + const double alpha_m = std::min(1.0, W / std::max(dm::ellipsoid_area(s0, s1, s2), 1e-30)); + + // Build rotation matrix from sorted eigenvectors (right-handed) -- scratch + double* Rm = scratch.rM; + Rm[0] = eigV[o0]; Rm[1] = eigV[o1]; Rm[2] = eigV[o2]; + Rm[3] = eigV[3 + o0]; Rm[4] = eigV[3 + o1]; Rm[5] = eigV[3 + o2]; + Rm[6] = eigV[6 + o0]; Rm[7] = eigV[6 + o1]; Rm[8] = eigV[6 + o2]; + if (dm::det3(Rm, 0) < 0) { + Rm[2] *= -1; Rm[5] *= -1; Rm[8] *= -1; } - std::array qtmp{}; - dm::rotmat_to_quat(rm.data(), 0, qtmp.data()); + double qtmp[4]; + dm::rotmat_to_quat(Rm, 0, qtmp); mu = {mux, muy, muz}; - sc = {0.5 * std::log(sorted_vals[0]), 0.5 * std::log(sorted_vals[1]), 0.5 * std::log(sorted_vals[2])}; + sc = {std::log(s0), std::log(s1), std::log(s2)}; q = {qtmp[0], qtmp[1], qtmp[2], qtmp[3]}; - op_lin = alpha_i + alpha_j - alpha_i * alpha_j; + op_lin = alpha_m; + // Color: area-alpha weighted normalized average sh_out.resize(app_cols.size()); for (size_t k = 0; k < app_cols.size(); ++k) { - sh_out[k] = (wi * static_cast((*app_cols[k])[i]) + wj * static_cast((*app_cols[k])[j])) / W; + sh_out[k] = pi * static_cast((*app_cols[k])[i]) + + pj * static_cast((*app_cols[k])[j]); } } @@ -379,18 +500,24 @@ void sortByVisibility(const DataTable& data_table, std::vector& indice std::unique_ptr simplifyGaussians(const DataTable& data_table, int targetCount) { const size_t N = data_table.getNumRows(); + LOG_INFO("simplifyGaussians start rows=%zu target=%d", N, targetCount); + if (targetCount <= 0) { + LOG_INFO("simplifyGaussians targetCount=%d, returning empty table", targetCount); return empty_table_like(data_table, 0); } if (N <= static_cast(targetCount)) { + LOG_INFO("simplifyGaussians source already within target rows=%zu", N); return data_table.clone(); } if (!has_required_columns(data_table)) { + LOG_WARN("simplifyGaussians missing required Gaussian columns, falling back to visibility sort"); std::vector indices(N); std::iota(indices.begin(), indices.end(), 0u); sortByVisibility(data_table, indices); indices.resize(static_cast(targetCount)); + LOG_INFO("simplifyGaussians fallback kept=%d dropped=%zu", targetCount, N - static_cast(targetCount)); return data_table.permuteRows(indices); } @@ -413,19 +540,47 @@ std::unique_ptr simplifyGaussians(const DataTable& data_table, int ta kept.push_back(static_cast(i)); } } + LOG_INFO("simplifyGaussians prune threshold=%.4f kept=%zu/%zu", prune_threshold, kept.size(), N); std::unique_ptr current; if (kept.size() < N && kept.size() > static_cast(targetCount)) { current = data_table.permuteRows(kept); + LOG_INFO("simplifyGaussians pre-pruned rows=%zu -> %zu before iterative merge", N, current->getNumRows()); } else { current = data_table.clone(); + LOG_INFO("simplifyGaussians starting iterative merge from cloned input rows=%zu", current->getNumRows()); } const std::vector> Z = dm::make_gaussian_samples(kMcSamples, 0); + const bool profile = decimate_profile_enabled(); + size_t iteration = 0; while (current->getNumRows() > static_cast(targetCount)) { + ++iteration; const size_t n = current->getNumRows(); const size_t k_eff = static_cast(std::min(std::max(1, kKnnK), static_cast(std::max(1, n - 1)))); + const KnnBackend knn_backend = decimate_knn_backend(); + LOG_INFO("simplifyGaussians iteration=%zu rows=%zu target=%d k=%zu backend=%s", iteration, n, targetCount, k_eff, + knn_backend_name(knn_backend)); + const auto iteration_start = Clock::now(); + auto phase_start = iteration_start; + auto log_phase = [&](const char* name) { + if (profile) { + LOG_INFO("decimate[%zu] %-18s %.3f ms", iteration, name, elapsed_ms(phase_start)); + } + phase_start = Clock::now(); + }; + auto log_total = [&]() { + if (profile) { + LOG_INFO("decimate[%zu] total rows=%zu target=%d elapsed=%.3f ms", iteration, n, targetCount, + elapsed_ms(iteration_start)); + } + }; + + if (profile) { + LOG_INFO("decimate[%zu] start rows=%zu target=%d k=%zu backend=%s", iteration, n, targetCount, k_eff, + knn_backend_name(knn_backend)); + } const auto& cx = current->getColumnByName("x").asVector(); const auto& cy = current->getColumnByName("y").asVector(); @@ -440,16 +595,7 @@ std::unique_ptr simplifyGaussians(const DataTable& data_table, int ta const auto& cr3 = current->getColumnByName("rot_3").asVector(); SplatCache cache = build_per_splat_cache(n, cx, cy, cz, cop, cs0, cs1, cs2, cr0, cr1, cr2, cr3); - - std::vector px(cx.begin(), cx.end()); - std::vector py(cy.begin(), cy.end()); - std::vector pz(cz.begin(), cz.end()); - std::vector pos_cols; - pos_cols.push_back(Column{"x", std::move(px)}); - pos_cols.push_back(Column{"y", std::move(py)}); - pos_cols.push_back(Column{"z", std::move(pz)}); - DataTable pos_table(pos_cols); - KdTree kd(&pos_table); + log_phase("cache"); size_t edge_capacity = (n * k_eff) / 2 + 8; std::vector edge_u(edge_capacity), edge_v(edge_capacity); @@ -466,24 +612,73 @@ std::unique_ptr simplifyGaussians(const DataTable& data_table, int ta edge_v.resize(edge_capacity); }; - std::vector query_point(3); - for (size_t i = 0; i < n; ++i) { - query_point[0] = cx[i]; - query_point[1] = cy[i]; - query_point[2] = cz[i]; - std::vector knn = kd.findKNearest(query_point, k_eff + 1); - for (size_t kj : knn) { - if (kj <= i) { - continue; +#if defined(SPLAT_HAS_NANOFLANN) + if (knn_backend == KnnBackend::Nanoflann) { + NanoflannPointCloud points{cx, cy, cz}; + NanoflannIndex index(3, points, nanoflann::KDTreeSingleIndexAdaptorParams(16)); + log_phase("kd_build"); + + float query_point[3] = {}; + std::vector knn_indices(k_eff + 1); + std::vector knn_distances(k_eff + 1); + for (size_t i = 0; i < n; ++i) { + query_point[0] = cx[i]; + query_point[1] = cy[i]; + query_point[2] = cz[i]; + const size_t found = index.knnSearch(query_point, k_eff + 1, knn_indices.data(), knn_distances.data()); + for (size_t ki = 0; ki < found; ++ki) { + const size_t kj = knn_indices[ki]; + if (kj <= i) { + continue; + } + ensure_edge_cap(edge_count + 1); + edge_u[edge_count] = static_cast(i); + edge_v[edge_count] = static_cast(kj); + ++edge_count; + } + } + } else +#endif + { + std::vector px(cx.begin(), cx.end()); + std::vector py(cy.begin(), cy.end()); + std::vector pz(cz.begin(), cz.end()); + std::vector pos_cols; + pos_cols.push_back(Column{"x", std::move(px)}); + pos_cols.push_back(Column{"y", std::move(py)}); + pos_cols.push_back(Column{"z", std::move(pz)}); + DataTable pos_table(pos_cols); + KdTree kd(&pos_table); + log_phase("kd_build"); + + float query_point[3] = {}; + std::vector knn; + knn.reserve(k_eff + 1); + for (size_t i = 0; i < n; ++i) { + query_point[0] = cx[i]; + query_point[1] = cy[i]; + query_point[2] = cz[i]; + kd.findKNearest(query_point, k_eff + 1, knn); + for (size_t kj : knn) { + if (kj <= i) { + continue; + } + ensure_edge_cap(edge_count + 1); + edge_u[edge_count] = static_cast(i); + edge_v[edge_count] = static_cast(kj); + ++edge_count; } - ensure_edge_cap(edge_count + 1); - edge_u[edge_count] = static_cast(i); - edge_v[edge_count] = static_cast(kj); - ++edge_count; } } + log_phase("knn"); + if (profile) { + LOG_INFO("decimate[%zu] candidate_edges=%zu", iteration, edge_count); + } if (edge_count == 0) { + LOG_WARN("simplifyGaussians iteration=%zu found no candidate edges, stopping at rows=%zu target=%d", iteration, n, + targetCount); + log_total(); break; } @@ -493,14 +688,17 @@ std::unique_ptr simplifyGaussians(const DataTable& data_table, int ta app_data.push_back(¤t->getColumnByName(name).asVector()); } + MergeScratch scratch; const size_t merges_needed = n - static_cast(targetCount); std::vector costs(edge_count); for (size_t e = 0; e < edge_count; ++e) { - costs[e] = static_cast(compute_edge_cost(edge_u[e], edge_v[e], cx, cy, cz, cache, Z, app_data)); + costs[e] = static_cast(compute_edge_cost(edge_u[e], edge_v[e], cx, cy, cz, cache, Z, app_data, scratch)); } + log_phase("edge_cost"); std::vector sorted(edge_count); const size_t valid_count = dm::radix_sort_indices_by_float(sorted.data(), edge_count, costs.data()); + log_phase("sort"); std::vector used(n, 0); std::vector> pairs; @@ -515,8 +713,16 @@ std::unique_ptr simplifyGaussians(const DataTable& data_table, int ta used[v] = 1; pairs.emplace_back(u, v); } + log_phase("pair_select"); + if (profile) { + LOG_INFO("decimate[%zu] pairs=%zu valid_edges=%zu", iteration, pairs.size(), valid_count); + } if (pairs.empty()) { + LOG_WARN("simplifyGaussians iteration=%zu found no mergeable pairs among %zu valid edges, stopping at rows=%zu " + "target=%d", + iteration, valid_count, n, targetCount); + log_total(); break; } @@ -533,9 +739,11 @@ std::unique_ptr simplifyGaussians(const DataTable& data_table, int ta keep_indices.push_back(static_cast(i)); } } + log_phase("keep_indices"); const size_t out_count = keep_indices.size() + pairs.size(); std::unique_ptr new_table = empty_table_like(*current, out_count); + log_phase("table_alloc"); size_t dst = 0; for (uint32_t src : keep_indices) { @@ -544,6 +752,7 @@ std::unique_ptr simplifyGaussians(const DataTable& data_table, int ta } ++dst; } + log_phase("copy_keep"); std::array mu{}, sc{}; std::array q{}; @@ -594,7 +803,7 @@ std::unique_ptr simplifyGaussians(const DataTable& data_table, int ta for (const auto& pr : pairs) { const uint32_t pi = pr.first, pj = pr.second; moment_match(pi, pj, cx, cy, cz, cop, cs0, cs1, cs2, cr0, cr1, cr2, cr3, mu, sc, q, op_lin, sh_merge, - app_data); + app_data, scratch); dst_x_col.setValue(dst, static_cast(mu[0])); dst_y_col.setValue(dst, static_cast(mu[1])); @@ -619,10 +828,16 @@ std::unique_ptr simplifyGaussians(const DataTable& data_table, int ta } ++dst; } + log_phase("merge_pairs"); + log_total(); + LOG_INFO("simplifyGaussians iteration=%zu complete rows=%zu -> %zu merges=%zu", iteration, n, new_table->getNumRows(), + pairs.size()); current = std::move(new_table); } + LOG_INFO("simplifyGaussians done source=%zu target=%d iterations=%zu final=%zu", N, targetCount, iteration, + current->getNumRows()); return current; } diff --git a/src/spatial/kdtree.cpp b/src/spatial/kdtree.cpp index 8d1d26a..24bc100 100644 --- a/src/spatial/kdtree.cpp +++ b/src/spatial/kdtree.cpp @@ -1,15 +1,20 @@ #include #include +#include #include #include -#include #include namespace splat { KdTree::KdTree(DataTable* table) : centroids(table) { assert(table); + columnData.reserve(centroids->getNumColumns()); + for (size_t i = 0; i < centroids->getNumColumns(); ++i) { + columnData.push_back(¢roids->getColumn(i).asVector()); + } + std::vector indices(centroids->getNumRows()); std::iota(indices.begin(), indices.end(), 0); this->root = build(absl::MakeSpan(indices), 0); @@ -25,32 +30,31 @@ std::tuple KdTree::findNearest(const std::vector& poi int mini = -1; size_t cnt = 0; - const size_t numColumns = centroids->getNumColumns(); + const size_t numColumns = columnData.size(); auto calcDistance = [&](size_t index) -> float { float l = 0.0f; for (size_t i = 0; i < numColumns; ++i) { - float v = centroids->getColumn(i).getValue(index) - point[i]; + float v = (*columnData[i])[index] - point[i]; l += v * v; } return l; }; - std::function recurse = [&](KdTreeNode* node, int depth) { + auto recurse = [&](auto&& self, KdTreeNode* node, size_t axis) -> void { if (!node) return; - const size_t axis = depth % numColumns; - - float node_split_value = centroids->getColumn(axis).getValue(node->index); + float node_split_value = (*columnData[axis])[node->index]; const float distance_on_axis = point[axis] - node_split_value; auto next = (distance_on_axis > 0) ? node->right.get() : node->left.get(); auto other = (next == node->right.get()) ? node->left.get() : node->right.get(); + const size_t next_axis = axis + 1 < numColumns ? axis + 1 : 0; cnt++; if (next) { - recurse(next, depth + 1); + self(self, next, next_axis); } if (!filterFunc || filterFunc(node->index)) { @@ -63,12 +67,12 @@ std::tuple KdTree::findNearest(const std::vector& poi if (distance_on_axis * distance_on_axis < mind) { if (other) { - recurse(other, depth + 1); + self(self, other, next_axis); } } }; - recurse(root.get(), 0); + recurse(recurse, root.get(), 0); return {mini, mind, cnt}; } @@ -78,80 +82,125 @@ std::vector KdTree::findKNearest(const std::vector& point, size_t if (!root || k == 0 || centroids->getNumColumns() == 0) { return {}; } - if (point.size() < centroids->getNumColumns()) { + if (point.size() < columnData.size()) { return {}; } - const size_t numColumns = centroids->getNumColumns(); + std::vector out; + findKNearest(point.data(), k, out, std::move(filterFunc)); + return out; +} + +void KdTree::findKNearest(const float* point, size_t k, std::vector& out, + std::function filterFunc) { + out.clear(); + if (!root || k == 0 || columnData.empty() || !point) { + return; + } + + k = std::min(k, centroids->getNumRows()); + heapDistances.assign(k, std::numeric_limits::infinity()); + heapIndices.assign(k, std::numeric_limits::max()); + size_t heapSize = 0; + const size_t numColumns = columnData.size(); auto calcDistance = [&](size_t index) -> float { float l = 0.0f; for (size_t i = 0; i < numColumns; ++i) { - float v = centroids->getColumn(i).getValue(index) - point[i]; + const float v = (*columnData[i])[index] - point[i]; l += v * v; } return l; }; - using DistIdx = std::pair; - struct MaxDistCmp { - bool operator()(const DistIdx& a, const DistIdx& b) const { return a.first < b.first; } - }; - std::priority_queue, MaxDistCmp> heap; - - auto worst_dist_sq = [&]() -> float { - return heap.size() < k ? std::numeric_limits::infinity() : heap.top().first; - }; + auto heap_push = [&](float dist, size_t idx) { + if (heapSize < k) { + size_t pos = heapSize++; + heapDistances[pos] = dist; + heapIndices[pos] = idx; + + while (pos > 0) { + const size_t parent = (pos - 1) >> 1; + if (heapDistances[parent] >= heapDistances[pos]) { + break; + } + std::swap(heapDistances[parent], heapDistances[pos]); + std::swap(heapIndices[parent], heapIndices[pos]); + pos = parent; + } + return; + } - auto try_push = [&](size_t idx, float d2) { - if (filterFunc && !filterFunc(idx)) { + if (dist >= heapDistances[0]) { return; } - if (heap.size() < k) { - heap.push({d2, idx}); - } else if (d2 < heap.top().first) { - heap.pop(); - heap.push({d2, idx}); + + heapDistances[0] = dist; + heapIndices[0] = idx; + + size_t pos = 0; + for (;;) { + const size_t left = pos * 2 + 1; + const size_t right = left + 1; + size_t largest = pos; + + if (left < heapSize && heapDistances[left] > heapDistances[largest]) largest = left; + if (right < heapSize && heapDistances[right] > heapDistances[largest]) largest = right; + if (largest == pos) break; + + std::swap(heapDistances[pos], heapDistances[largest]); + std::swap(heapIndices[pos], heapIndices[largest]); + pos = largest; } }; - std::function recurse = [&](KdTreeNode* node, int depth) { + auto recurse = [&](auto&& self, KdTreeNode* node, size_t axis) -> void { if (!node) { return; } - const size_t axis = depth % numColumns; - float node_split_value = centroids->getColumn(axis).getValue(node->index); + const float node_split_value = (*columnData[axis])[node->index]; const float distance_on_axis = point[axis] - node_split_value; KdTreeNode* next = (distance_on_axis > 0) ? node->right.get() : node->left.get(); KdTreeNode* other = (next == node->right.get()) ? node->left.get() : node->right.get(); + const size_t next_axis = axis + 1 < numColumns ? axis + 1 : 0; if (next) { - recurse(next, depth + 1); + self(self, next, next_axis); } if (!filterFunc || filterFunc(node->index)) { - try_push(node->index, calcDistance(node->index)); + heap_push(calcDistance(node->index), node->index); } - const float w = worst_dist_sq(); + const float w = heapSize < k ? std::numeric_limits::infinity() : heapDistances[0]; if (distance_on_axis * distance_on_axis < w) { if (other) { - recurse(other, depth + 1); + self(self, other, next_axis); } } }; - recurse(root.get(), 0); + recurse(recurse, root.get(), 0); - std::vector out; - out.reserve(heap.size()); - while (!heap.empty()) { - out.push_back(heap.top().second); - heap.pop(); + for (size_t i = 1; i < heapSize; ++i) { + const float dist = heapDistances[i]; + const size_t idx = heapIndices[i]; + size_t j = i; + while (j > 0 && heapDistances[j - 1] > dist) { + heapDistances[j] = heapDistances[j - 1]; + heapIndices[j] = heapIndices[j - 1]; + --j; + } + heapDistances[j] = dist; + heapIndices[j] = idx; + } + + out.resize(heapSize); + for (size_t i = 0; i < heapSize; ++i) { + out[i] = heapIndices[i]; } - return out; } std::unique_ptr KdTree::build(absl::Span indices, size_t depth) { @@ -159,13 +208,13 @@ std::unique_ptr KdTree::build(absl::Span indices, si return nullptr; } - const size_t axis = depth % centroids->getNumColumns(); - auto&& values_column = centroids->getColumn(axis); + const size_t axis = depth % columnData.size(); + const std::vector& values_column = *columnData[axis]; size_t mid = indices.size() >> 1; std::nth_element(indices.begin(), indices.begin() + mid, indices.end(), [&](size_t a, size_t b) { - return values_column.getValue(a) < values_column.getValue(b); + return values_column[a] < values_column[b]; }); size_t node_index = indices[mid]; diff --git a/src/spatial/kmeans.cpp b/src/spatial/kmeans.cpp new file mode 100644 index 0000000..5b0564d --- /dev/null +++ b/src/spatial/kmeans.cpp @@ -0,0 +1,212 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace splat { + +namespace { + +static std::vector> groupLabels(const std::vector& labels, int k) { + std::vector> groups(k); + for (uint32_t i = 0; i < labels.size(); ++i) { + groups[labels[i]].push_back(static_cast(i)); + } + return groups; +} + +static void initializeCentroids(const DataTable* dataTable, DataTable* centroids, Row& row) { + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution dis(0, dataTable->getNumRows() - 1); + + std::set chosenRows; + for (size_t i = 0; i < centroids->getNumRows(); ++i) { + size_t candidateRow; + do { + candidateRow = dis(gen); + } while (chosenRows.count(candidateRow)); + + chosenRows.insert(candidateRow); + dataTable->getRow(candidateRow, row); + centroids->setRow(i, row); + } +} + +static void initializeCentroids1D(const DataTable* dataTable, DataTable* centroids) { + float m = std::numeric_limits::infinity(); + float M = -std::numeric_limits::infinity(); + + const auto& data = dataTable->getColumn(0); + for (size_t i = 0; i < dataTable->getNumRows(); ++i) { + float value = data.getValue(i); + if (value < m) { + m = value; + } + if (value > M) { + M = value; + } + } + + auto& centroidsData = centroids->getColumn(0); + for (size_t i = 0; i < centroids->getNumRows(); ++i) { + float value = m + (M - m) * i / (centroids->getNumRows() - 1); + centroidsData.setValue(i, value); + } +} + +static void calcAverage(const DataTable* dataTable, const std::vector& cluster, + std::map& row) { + const auto keys = dataTable->getColumnNames(); + + for (size_t i = 0; i < keys.size(); ++i) { + row[keys[i]] = 0.f; + } + + Row dataRow; + for (size_t i = 0; i < cluster.size(); ++i) { + dataTable->getRow(static_cast(cluster[i]), dataRow); + for (size_t j = 0; j < keys.size(); ++j) { + const auto& key = keys[j]; + row[key] += dataRow[key]; + } + } + + if (!cluster.empty()) { + for (size_t i = 0; i < keys.size(); ++i) { + row[keys[i]] /= static_cast(cluster.size()); + } + } +} + +} // namespace + +std::pair, std::vector> kmeans(DataTable* points, size_t k, size_t iterations) { + if (points->getNumRows() < k) { + std::vector labels(points->getNumRows(), 0); + std::iota(labels.begin(), labels.end(), 0); + return {points->clone(), labels}; + } + + Row row; + std::unique_ptr centroids = std::make_unique(); + for (auto& c : points->columns) { + centroids->addColumn({c.name, std::vector(k, 0)}); + } + + if (points->getNumColumns() == 1) { + initializeCentroids1D(points, centroids.get()); + } else { + initializeCentroids(points, centroids.get(), row); + } + + std::vector labels(points->getNumRows(), 0); + bool converged = false; + size_t steps = 0; + + std::cout << "Running k-means clustering: dims=" << points->getNumColumns() << " points=" << points->getNumRows() + << " clusters=" << k << " iterations=" << iterations << "..." << "\n"; + + const uint32_t N = static_cast(points->getNumRows()); + const uint32_t K = static_cast(k); + const uint32_t D = static_cast(points->getNumColumns()); + + std::vector h_points(static_cast(N) * D); + std::vector h_centroids(static_cast(K) * D); + std::vector h_centroid_norms(K); + std::vector h_results(N); + + std::random_device rd; + std::mt19937 gen(rd()); + + const auto start_total = std::chrono::high_resolution_clock::now(); + + while (!converged) { + const auto start_iter = std::chrono::high_resolution_clock::now(); + + for (uint32_t d = 0; d < D; ++d) { + const auto& colData = points->getColumn(d).asVector(); + std::memcpy(&h_points[static_cast(d) * N], colData.data(), static_cast(N) * sizeof(float)); + } + + for (uint32_t d = 0; d < D; ++d) { + const auto& colData = centroids->getColumn(d).asVector(); + for (uint32_t c = 0; c < K; ++c) { + h_centroids[static_cast(c) + static_cast(d) * K] = colData[c]; + } + } + + if (!gpu::computeCentroidNorms(h_centroids.data(), K, D, h_centroid_norms.data())) { + std::cerr << "Error: Failed to compute centroid norms on GPU\n"; + std::vector fallback_labels(points->getNumRows(), 0); + std::iota(fallback_labels.begin(), fallback_labels.end(), 0); + return {points->clone(), fallback_labels}; + } + + if (!gpu::assignPointsToCentroids(h_points.data(), h_centroids.data(), h_centroid_norms.data(), N, K, D, + h_results.data())) { + std::cerr << "Error: Failed to assign points to centroids on GPU\n"; + std::vector fallback_labels(points->getNumRows(), 0); + std::iota(fallback_labels.begin(), fallback_labels.end(), 0); + return {points->clone(), fallback_labels}; + } + + labels.resize(N); + std::memcpy(labels.data(), h_results.data(), static_cast(N) * sizeof(uint32_t)); + + const auto mid_iter = std::chrono::high_resolution_clock::now(); + + auto groups = groupLabels(labels, static_cast(k)); + bool centroidChanged = false; + + for (size_t i = 0; i < centroids->getNumRows(); ++i) { + if (groups[i].empty()) { + std::uniform_int_distribution dis(0, points->getNumRows() - 1); + const auto idx = dis(gen); + points->getRow(idx, row); + centroids->setRow(i, row); + centroidChanged = true; + } else { + std::map new_row; + calcAverage(points, groups[i], new_row); + for (const auto& item : new_row) { + if (std::abs(item.second - centroids->getColumnByName(item.first).getValue(i)) > 0.001f) { + centroidChanged = true; + } + centroids->getColumnByName(item.first).setValue(i, item.second); + } + } + } + + const auto end_iter = std::chrono::high_resolution_clock::now(); + const auto gpu_time = std::chrono::duration_cast(mid_iter - start_iter); + const auto cpu_time = std::chrono::duration_cast(end_iter - mid_iter); + + std::cout << " Iteration " << (steps + 1) << ": GPU=" << gpu_time.count() << "ms, CPU=" << cpu_time.count() << "ms" + << "\n"; + + if (!centroidChanged || steps >= iterations) { + converged = true; + } + + ++steps; + } + + const auto end_total = std::chrono::high_resolution_clock::now(); + const auto total_time = std::chrono::duration_cast(end_total - start_total); + + std::cout << "k-means completed in " << total_time.count() << "ms, " << steps << " iterations\n"; + std::cout << "GPU Backend: " << gpu::getBackendName() << "\n"; + + return {std::move(centroids), labels}; +} + +} // namespace splat diff --git a/src/spatial/kmeans.cu b/src/spatial/kmeans.cu index 9a66f48..a11cbab 100644 --- a/src/spatial/kmeans.cu +++ b/src/spatial/kmeans.cu @@ -23,12 +23,10 @@ * For more information, visit the project's homepage or contact the author. */ -#include -#include -#include #include #include #include +#include #include #include @@ -294,17 +292,11 @@ std::pair, std::vector> kmeans(DataTable* p const uint32_t K = k; const uint32_t D = points->getNumColumns(); - float *d_points = nullptr, *d_centroids = nullptr, *d_centroid_norms = nullptr; - uint32_t* d_results = nullptr; - - cudaMalloc(&d_points, N * D * sizeof(float)); - cudaMalloc(&d_centroids, K * D * sizeof(float)); - cudaMalloc(&d_centroid_norms, K * sizeof(float)); - cudaMalloc(&d_results, N * sizeof(uint32_t)); - - float *h_points_pinned = nullptr, *h_centroids_pinned = nullptr; - cudaHostAlloc(&h_points_pinned, N * D * sizeof(float), cudaHostAllocDefault); - cudaHostAlloc(&h_centroids_pinned, K * D * sizeof(float), cudaHostAllocDefault); + // Allocate host memory for data in column-major layout + std::vector h_points(N * D); + std::vector h_centroids(K * D); + std::vector h_centroid_norms(K); + std::vector h_results(N); // Create random number generator for reseeding empty clusters std::random_device rd; @@ -315,37 +307,37 @@ std::pair, std::vector> kmeans(DataTable* p while (!converged) { auto start_iter = std::chrono::high_resolution_clock::now(); + // Copy data to column-major layout for (uint32_t d = 0; d < D; ++d) { const auto& colData = points->getColumn(d).asVector(); - memcpy(&h_points_pinned[d * N], colData.data(), N * sizeof(float)); + memcpy(&h_points[d * N], colData.data(), N * sizeof(float)); } for (uint32_t d = 0; d < D; ++d) { const auto& colData = centroids->getColumn(d).asVector(); for (uint32_t c = 0; c < K; ++c) { - h_centroids_pinned[c + d * K] = colData[c]; + h_centroids[c + d * K] = colData[c]; } } - cudaMemcpy(d_points, h_points_pinned, N * D * sizeof(float), cudaMemcpyHostToDevice); - cudaMemcpy(d_centroids, h_centroids_pinned, K * D * sizeof(float), cudaMemcpyHostToDevice); - - { - dim3 blockDim(256); - dim3 gridDim((K + blockDim.x - 1) / blockDim.x); - computeCentroidNormsColMajor<<>>(d_centroids, d_centroid_norms, K, D); + // Call GPU compute functions via abstraction layer + if (!gpu::computeCentroidNorms(h_centroids.data(), K, D, h_centroid_norms.data())) { + std::cerr << "Error: Failed to compute centroid norms on GPU\n"; + std::vector fallback_labels(points->getNumRows(), 0); + std::iota(fallback_labels.begin(), fallback_labels.end(), 0); + return {points->clone(), fallback_labels}; } - { - int threadsPerBlock = 256; - int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock; - clusterKernelColMajor<<>>(d_points, d_centroids, d_centroid_norms, d_results, N, - K, D); - cudaDeviceSynchronize(); + if (!gpu::assignPointsToCentroids(h_points.data(), h_centroids.data(), h_centroid_norms.data(), + N, K, D, h_results.data())) { + std::cerr << "Error: Failed to assign points to centroids on GPU\n"; + std::vector fallback_labels(points->getNumRows(), 0); + std::iota(fallback_labels.begin(), fallback_labels.end(), 0); + return {points->clone(), fallback_labels}; } labels.resize(N); - cudaMemcpy(labels.data(), d_results, N * sizeof(uint32_t), cudaMemcpyDeviceToHost); + memcpy(labels.data(), h_results.data(), N * sizeof(uint32_t)); // ====================================================== auto mid_iter = std::chrono::high_resolution_clock::now(); @@ -399,14 +391,8 @@ std::pair, std::vector> kmeans(DataTable* p auto end_total = std::chrono::high_resolution_clock::now(); auto duration = std::chrono::duration_cast(end_total - start_total); - std::cout << "\nk-means completed in " << duration.count() << "ms total" << "\n"; + std::cout << "\nk-means completed in " << duration.count() << "ms total (Backend: " << gpu::getBackendName() << ")\n"; - cudaFree(d_points); - cudaFree(d_centroids); - cudaFree(d_centroid_norms); - cudaFree(d_results); - cudaFreeHost(h_points_pinned); - cudaFreeHost(h_centroids_pinned); return {std::move(centroids), labels}; } diff --git a/src/spatial/kmeans.metal b/src/spatial/kmeans.metal new file mode 100644 index 0000000..266a191 --- /dev/null +++ b/src/spatial/kmeans.metal @@ -0,0 +1,71 @@ +/** + * Metal Compute Kernels for k-means + */ + +#include +using namespace metal; + +/** + * Compute norms of centroids for efficient distance calculation + * ||c||^2 = sum(c_i^2) + */ +kernel void computeCentroidNorms(const device float* centroids [[buffer(0)]], + device float* norms [[buffer(1)]], + constant uint& K [[buffer(2)]], + constant uint& D [[buffer(3)]], + uint k [[thread_position_in_grid]]) +{ + if (k >= K) return; + + float norm = 0.0f; + for (uint d = 0; d < D; d++) { + float val = centroids[k + d * K]; // Column-major layout + norm += val * val; + } + norms[k] = norm; +} + +/** + * Assign each point to its nearest centroid + * Uses optimized distance calculation: ||p - c||^2 = ||p||^2 + ||c||^2 - 2*p·c + */ +kernel void assignPointsToCentroids(const device float* points [[buffer(0)]], + const device float* centroids [[buffer(1)]], + const device float* centroid_norms [[buffer(2)]], + device uint* results [[buffer(3)]], + constant uint& N [[buffer(4)]], + constant uint& K [[buffer(5)]], + constant uint& D [[buffer(6)]], + uint pt_idx [[thread_position_in_grid]]) +{ + if (pt_idx >= N) return; + + float minDist = INFINITY; + uint bestIdx = 0; + + // Compute point norm: ||p||^2 + float pointNorm = 0.0f; + for (uint d = 0; d < D; d++) { + float p = points[d * N + pt_idx]; // Column-major layout + pointNorm += p * p; + } + + // Find nearest centroid + for (uint c = 0; c < K; c++) { + float dist = pointNorm + centroid_norms[c]; + + // Subtract 2*p·c term + for (uint d = 0; d < D; d++) { + float p = points[d * N + pt_idx]; + float centroid_val = centroids[c + d * K]; + dist -= 2.0f * p * centroid_val; + } + + if (dist < minDist) { + minDist = dist; + bestIdx = c; + } + } + + results[pt_idx] = bestIdx; +} diff --git a/src/spatial/kmeans_cpu.cpp b/src/spatial/kmeans_cpu.cpp new file mode 100644 index 0000000..690018f --- /dev/null +++ b/src/spatial/kmeans_cpu.cpp @@ -0,0 +1,70 @@ +/** + * Fallback CPU Implementation for GPU Compute (when no GPU backend is available) + */ + +#include +#include +#include +#include +#include + +namespace splat { +namespace gpu { + +bool computeCentroidNorms(const float* centroids, uint32_t K, uint32_t D, float* norms) { + // CPU-based implementation + for (uint32_t k = 0; k < K; k++) { + float norm = 0.0f; + for (uint32_t d = 0; d < D; d++) { + float val = centroids[k + d * K]; + norm += val * val; + } + norms[k] = norm; + } + return true; +} + +bool assignPointsToCentroids(const float* points, const float* centroids, + const float* centroid_norms, + uint32_t N, uint32_t K, uint32_t D, + uint32_t* results) +{ + // CPU-based implementation + for (uint32_t pt = 0; pt < N; pt++) { + float minDist = std::numeric_limits::infinity(); + uint32_t bestIdx = 0; + + // Compute point norm + float pointNorm = 0.0f; + for (uint32_t d = 0; d < D; d++) { + float p = points[d * N + pt]; + pointNorm += p * p; + } + + // Find nearest centroid + for (uint32_t c = 0; c < K; c++) { + float dist = pointNorm + centroid_norms[c]; + + for (uint32_t d = 0; d < D; d++) { + float p = points[d * N + pt]; + float centroid_val = centroids[c + d * K]; + dist -= 2.0f * p * centroid_val; + } + + if (dist < minDist) { + minDist = dist; + bestIdx = c; + } + } + + results[pt] = bestIdx; + } + return true; +} + +const char* getBackendName() { + return "CPU (Fallback)"; +} + +} // namespace gpu +} // namespace splat diff --git a/src/spatial/kmeans_cuda.cu b/src/spatial/kmeans_cuda.cu new file mode 100644 index 0000000..f3cd38e --- /dev/null +++ b/src/spatial/kmeans_cuda.cu @@ -0,0 +1,245 @@ +/** + * CUDA Backend Implementation for GPU Compute + */ + +#include +#include +#include +#include + +namespace splat { +namespace gpu { + +// CUDA kernel implementations from original kmeans.cu +__global__ void computeCentroidNormsColMajor(const float* __restrict__ centroids, + float* __restrict__ norms, + uint32_t K, uint32_t D) { + uint32_t k = blockIdx.x * blockDim.x + threadIdx.x; + if (k >= K) return; + + float norm = 0.0f; + for (uint32_t d = 0; d < D; d++) { + float val = centroids[k + d * K]; + norm += val * val; + } + norms[k] = norm; +} + +__global__ void clusterKernelColMajor(const float* __restrict__ points, + const float* __restrict__ centroids, + const float* __restrict__ centroid_norms, + uint32_t* __restrict__ results, + uint32_t numPoints, uint32_t numCentroids, + uint32_t numCols) { + uint32_t ptIdx = blockIdx.x * blockDim.x + threadIdx.x; + if (ptIdx >= numPoints) return; + + float minDist = 3.40282e+38f; + uint32_t bestIdx = 0; + + float pointNorm = 0.0f; + for (uint32_t d = 0; d < numCols; d++) { + float p = points[d * numPoints + ptIdx]; + pointNorm += p * p; + } + + uint32_t c = 0; + for (; c + 7 < numCentroids; c += 8) { + float norms[8]; + norms[0] = centroid_norms[c]; + norms[1] = centroid_norms[c + 1]; + norms[2] = centroid_norms[c + 2]; + norms[3] = centroid_norms[c + 3]; + norms[4] = centroid_norms[c + 4]; + norms[5] = centroid_norms[c + 5]; + norms[6] = centroid_norms[c + 6]; + norms[7] = centroid_norms[c + 7]; + + float dist[8] = {pointNorm + norms[0], pointNorm + norms[1], pointNorm + norms[2], pointNorm + norms[3], + pointNorm + norms[4], pointNorm + norms[5], pointNorm + norms[6], pointNorm + norms[7]}; + + for (uint32_t d = 0; d < numCols; d++) { + float p = points[d * numPoints + ptIdx]; + + float c0 = centroids[c + d * numCentroids]; + float c1 = centroids[c + 1 + d * numCentroids]; + float c2 = centroids[c + 2 + d * numCentroids]; + float c3 = centroids[c + 3 + d * numCentroids]; + float c4 = centroids[c + 4 + d * numCentroids]; + float c5 = centroids[c + 5 + d * numCentroids]; + float c6 = centroids[c + 6 + d * numCentroids]; + float c7 = centroids[c + 7 + d * numCentroids]; + + dist[0] -= 2.0f * p * c0; + dist[1] -= 2.0f * p * c1; + dist[2] -= 2.0f * p * c2; + dist[3] -= 2.0f * p * c3; + dist[4] -= 2.0f * p * c4; + dist[5] -= 2.0f * p * c5; + dist[6] -= 2.0f * p * c6; + dist[7] -= 2.0f * p * c7; + } + + if (dist[0] < minDist) { minDist = dist[0]; bestIdx = c; } + if (dist[1] < minDist) { minDist = dist[1]; bestIdx = c + 1; } + if (dist[2] < minDist) { minDist = dist[2]; bestIdx = c + 2; } + if (dist[3] < minDist) { minDist = dist[3]; bestIdx = c + 3; } + if (dist[4] < minDist) { minDist = dist[4]; bestIdx = c + 4; } + if (dist[5] < minDist) { minDist = dist[5]; bestIdx = c + 5; } + if (dist[6] < minDist) { minDist = dist[6]; bestIdx = c + 6; } + if (dist[7] < minDist) { minDist = dist[7]; bestIdx = c + 7; } + } + + for (; c + 3 < numCentroids; c += 4) { + float dist0 = pointNorm + centroid_norms[c]; + float dist1 = pointNorm + centroid_norms[c + 1]; + float dist2 = pointNorm + centroid_norms[c + 2]; + float dist3 = pointNorm + centroid_norms[c + 3]; + + for (uint32_t d = 0; d < numCols; d++) { + float p = points[d * numPoints + ptIdx]; + + float c0 = centroids[c + d * numCentroids]; + float c1 = centroids[c + 1 + d * numCentroids]; + float c2 = centroids[c + 2 + d * numCentroids]; + float c3 = centroids[c + 3 + d * numCentroids]; + + dist0 -= 2.0f * p * c0; + dist1 -= 2.0f * p * c1; + dist2 -= 2.0f * p * c2; + dist3 -= 2.0f * p * c3; + } + + if (dist0 < minDist) { minDist = dist0; bestIdx = c; } + if (dist1 < minDist) { minDist = dist1; bestIdx = c + 1; } + if (dist2 < minDist) { minDist = dist2; bestIdx = c + 2; } + if (dist3 < minDist) { minDist = dist3; bestIdx = c + 3; } + } + + for (; c < numCentroids; c++) { + float dist = pointNorm + centroid_norms[c]; + for (uint32_t d = 0; d < numCols; d++) { + float p = points[d * numPoints + ptIdx]; + float centroid_val = centroids[c + d * numCentroids]; + dist -= 2.0f * p * centroid_val; + } + + if (dist < minDist) { + minDist = dist; + bestIdx = c; + } + } + + results[ptIdx] = bestIdx; +} + +bool computeCentroidNorms(const float* centroids, uint32_t K, uint32_t D, float* norms) { + float *d_centroids = nullptr, *d_norms = nullptr; + + if (cudaMalloc(&d_centroids, K * D * sizeof(float)) != cudaSuccess) { + std::cerr << "CUDA: Failed to allocate centroid memory\n"; + return false; + } + if (cudaMalloc(&d_norms, K * sizeof(float)) != cudaSuccess) { + std::cerr << "CUDA: Failed to allocate norms memory\n"; + cudaFree(d_centroids); + return false; + } + + if (cudaMemcpy(d_centroids, centroids, K * D * sizeof(float), cudaMemcpyHostToDevice) != cudaSuccess) { + std::cerr << "CUDA: Failed to copy centroid data\n"; + cudaFree(d_norms); + cudaFree(d_centroids); + return false; + } + + dim3 blockDim(256); + dim3 gridDim((K + blockDim.x - 1) / blockDim.x); + computeCentroidNormsColMajor<<>>(d_centroids, d_norms, K, D); + + if (cudaDeviceSynchronize() != cudaSuccess) { + std::cerr << "CUDA: Kernel execution failed\n"; + cudaFree(d_norms); + cudaFree(d_centroids); + return false; + } + + if (cudaMemcpy(norms, d_norms, K * sizeof(float), cudaMemcpyDeviceToHost) != cudaSuccess) { + std::cerr << "CUDA: Failed to copy norms result\n"; + cudaFree(d_norms); + cudaFree(d_centroids); + return false; + } + + cudaFree(d_norms); + cudaFree(d_centroids); + return true; +} + +bool assignPointsToCentroids(const float* points, const float* centroids, + const float* centroid_norms, + uint32_t N, uint32_t K, uint32_t D, + uint32_t* results) +{ + float *d_points = nullptr, *d_centroids = nullptr, *d_centroid_norms = nullptr; + uint32_t *d_results = nullptr; + + if (cudaMalloc(&d_points, N * D * sizeof(float)) != cudaSuccess || + cudaMalloc(&d_centroids, K * D * sizeof(float)) != cudaSuccess || + cudaMalloc(&d_centroid_norms, K * sizeof(float)) != cudaSuccess || + cudaMalloc(&d_results, N * sizeof(uint32_t)) != cudaSuccess) { + std::cerr << "CUDA: Failed to allocate GPU memory\n"; + cudaFree(d_points); + cudaFree(d_centroids); + cudaFree(d_centroid_norms); + cudaFree(d_results); + return false; + } + + if (cudaMemcpy(d_points, points, N * D * sizeof(float), cudaMemcpyHostToDevice) != cudaSuccess || + cudaMemcpy(d_centroids, centroids, K * D * sizeof(float), cudaMemcpyHostToDevice) != cudaSuccess || + cudaMemcpy(d_centroid_norms, centroid_norms, K * sizeof(float), cudaMemcpyHostToDevice) != cudaSuccess) { + std::cerr << "CUDA: Failed to copy data to GPU\n"; + cudaFree(d_points); + cudaFree(d_centroids); + cudaFree(d_centroid_norms); + cudaFree(d_results); + return false; + } + + int threadsPerBlock = 256; + int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock; + clusterKernelColMajor<<>>( + d_points, d_centroids, d_centroid_norms, d_results, N, K, D); + + if (cudaDeviceSynchronize() != cudaSuccess) { + std::cerr << "CUDA: Kernel execution failed\n"; + cudaFree(d_points); + cudaFree(d_centroids); + cudaFree(d_centroid_norms); + cudaFree(d_results); + return false; + } + + if (cudaMemcpy(results, d_results, N * sizeof(uint32_t), cudaMemcpyDeviceToHost) != cudaSuccess) { + std::cerr << "CUDA: Failed to copy results from GPU\n"; + cudaFree(d_points); + cudaFree(d_centroids); + cudaFree(d_centroid_norms); + cudaFree(d_results); + return false; + } + + cudaFree(d_points); + cudaFree(d_centroids); + cudaFree(d_centroid_norms); + cudaFree(d_results); + return true; +} + +const char* getBackendName() { + return "CUDA"; +} + +} // namespace gpu +} // namespace splat diff --git a/src/spatial/kmeans_metal.mm b/src/spatial/kmeans_metal.mm new file mode 100644 index 0000000..ae113dc --- /dev/null +++ b/src/spatial/kmeans_metal.mm @@ -0,0 +1,223 @@ +/** + * Metal Backend Implementation for GPU Compute + */ + +#ifdef __APPLE__ + +#import +#import + +#include +#include +#include + +namespace splat { +namespace gpu { + +class MetalComputeContext { +public: + static MetalComputeContext& getInstance() { + static MetalComputeContext instance; + return instance; + } + + bool isAvailable() const { return device != nullptr; } + + id getDevice() { return device; } + id getCommandQueue() { return commandQueue; } + id getLibrary() { return library; } + +private: + MetalComputeContext() { + device = MTLCreateSystemDefaultDevice(); + if (device) { + commandQueue = [device newCommandQueue]; + + // Load the Metal library from the application bundle + NSError *error = nil; + NSBundle *bundle = [NSBundle bundleForClass:[NSObject class]]; + + // Try to load the compiled Metal library + NSURL *libraryURL = [bundle URLForResource:@"kmeans" withExtension:@"metallib"]; + if (libraryURL) { + library = [device newLibraryWithURL:libraryURL error:&error]; + } else { + // Fallback: compile from source if available + // This is typically used in development + std::cerr << "Warning: Could not find kmeans.metallib bundle resource\n"; + } + } + } + + ~MetalComputeContext() { + if (commandQueue) [commandQueue release]; + if (library) [library release]; + if (device) [device release]; + } + + id device = nullptr; + id commandQueue = nullptr; + id library = nullptr; +}; + +bool computeCentroidNorms(const float* centroids, uint32_t K, uint32_t D, float* norms) { + auto& context = MetalComputeContext::getInstance(); + if (!context.isAvailable()) { + std::cerr << "Metal device not available\n"; + return false; + } + + id device = context.getDevice(); + id commandQueue = context.getCommandQueue(); + id library = context.getLibrary(); + + if (!library) { + std::cerr << "Metal library not loaded\n"; + return false; + } + + // Create compute pipeline + id kernelFunction = [library newFunctionWithName:@"computeCentroidNorms"]; + if (!kernelFunction) { + std::cerr << "Could not find computeCentroidNorms kernel\n"; + return false; + } + + NSError *error = nil; + id pipelineState = [device newComputePipelineStateWithFunction:kernelFunction error:&error]; + [kernelFunction release]; + + if (!pipelineState) { + std::cerr << "Failed to create compute pipeline: " << [[error description] UTF8String] << "\n"; + return false; + } + + // Create buffers + id centroidsBuffer = [device newBufferWithBytes:centroids + length:K * D * sizeof(float) + options:MTLResourceStorageModeShared]; + id normsBuffer = [device newBufferWithLength:K * sizeof(float) + options:MTLResourceStorageModeShared]; + + // Create command buffer and encoder + id commandBuffer = [commandQueue commandBuffer]; + id encoder = [commandBuffer computeCommandEncoder]; + + [encoder setComputePipelineState:pipelineState]; + [encoder setBuffer:centroidsBuffer offset:0 atIndex:0]; + [encoder setBuffer:normsBuffer offset:0 atIndex:1]; + [encoder setBytes:&K length:sizeof(uint32_t) atIndex:2]; + [encoder setBytes:&D length:sizeof(uint32_t) atIndex:3]; + + // Dispatch threads + MTLSize threadgroupSize = MTLSizeMake(256, 1, 1); + MTLSize gridSize = MTLSizeMake((K + 255) / 256, 1, 1); + [encoder dispatchThreads:gridSize threadsPerThreadgroup:threadgroupSize]; + + [encoder endEncoding]; + [commandBuffer commit]; + [commandBuffer waitUntilCompleted]; + + // Copy results + memcpy(norms, [normsBuffer contents], K * sizeof(float)); + + // Cleanup + [normsBuffer release]; + [centroidsBuffer release]; + [pipelineState release]; + + return true; +} + +bool assignPointsToCentroids(const float* points, const float* centroids, + const float* centroid_norms, + uint32_t N, uint32_t K, uint32_t D, + uint32_t* results) +{ + auto& context = MetalComputeContext::getInstance(); + if (!context.isAvailable()) { + std::cerr << "Metal device not available\n"; + return false; + } + + id device = context.getDevice(); + id commandQueue = context.getCommandQueue(); + id library = context.getLibrary(); + + if (!library) { + std::cerr << "Metal library not loaded\n"; + return false; + } + + // Create compute pipeline + id kernelFunction = [library newFunctionWithName:@"assignPointsToCentroids"]; + if (!kernelFunction) { + std::cerr << "Could not find assignPointsToCentroids kernel\n"; + return false; + } + + NSError *error = nil; + id pipelineState = [device newComputePipelineStateWithFunction:kernelFunction error:&error]; + [kernelFunction release]; + + if (!pipelineState) { + std::cerr << "Failed to create compute pipeline: " << [[error description] UTF8String] << "\n"; + return false; + } + + // Create buffers + id pointsBuffer = [device newBufferWithBytes:points + length:N * D * sizeof(float) + options:MTLResourceStorageModeShared]; + id centroidsBuffer = [device newBufferWithBytes:centroids + length:K * D * sizeof(float) + options:MTLResourceStorageModeShared]; + id normsBuffer = [device newBufferWithBytes:(void*)centroid_norms + length:K * sizeof(float) + options:MTLResourceStorageModeShared]; + id resultsBuffer = [device newBufferWithLength:N * sizeof(uint32_t) + options:MTLResourceStorageModeShared]; + + // Create command buffer and encoder + id commandBuffer = [commandQueue commandBuffer]; + id encoder = [commandBuffer computeCommandEncoder]; + + [encoder setComputePipelineState:pipelineState]; + [encoder setBuffer:pointsBuffer offset:0 atIndex:0]; + [encoder setBuffer:centroidsBuffer offset:0 atIndex:1]; + [encoder setBuffer:normsBuffer offset:0 atIndex:2]; + [encoder setBuffer:resultsBuffer offset:0 atIndex:3]; + [encoder setBytes:&N length:sizeof(uint32_t) atIndex:4]; + [encoder setBytes:&K length:sizeof(uint32_t) atIndex:5]; + [encoder setBytes:&D length:sizeof(uint32_t) atIndex:6]; + + // Dispatch threads + MTLSize threadgroupSize = MTLSizeMake(256, 1, 1); + MTLSize gridSize = MTLSizeMake((N + 255) / 256, 1, 1); + [encoder dispatchThreads:gridSize threadsPerThreadgroup:threadgroupSize]; + + [encoder endEncoding]; + [commandBuffer commit]; + [commandBuffer waitUntilCompleted]; + + // Copy results + memcpy(results, [resultsBuffer contents], N * sizeof(uint32_t)); + + // Cleanup + [resultsBuffer release]; + [normsBuffer release]; + [centroidsBuffer release]; + [pointsBuffer release]; + [pipelineState release]; + + return true; +} + +const char* getBackendName() { + return "Metal"; +} + +} // namespace gpu +} // namespace splat + +#endif // __APPLE__ diff --git a/src/visualization/gsplat_data.cpp b/src/visualization/gsplat_data.cpp new file mode 100644 index 0000000..47d7c78 --- /dev/null +++ b/src/visualization/gsplat_data.cpp @@ -0,0 +1,100 @@ +#include "gsplat_data.h" + +#include +#include +#include +#include +#include + +namespace splat::visualization { +namespace { + +constexpr float kSHC0 = 0.28209479177387814f; +constexpr const char* kRequiredColumns[] = {"x", "y", "z", "rot_0", "rot_1", + "rot_2", "rot_3", "scale_0", "scale_1", "scale_2", + "f_dc_0", "f_dc_1", "f_dc_2", "opacity"}; + +void requireColumns(const DataTable& dataTable) { + for (const char* name : kRequiredColumns) { + if (!dataTable.hasColumn(std::string(name))) { + throw std::runtime_error(std::string("GSplatData: missing required column: ") + name); + } + } +} + +float columnValue(const DataTable& dataTable, const char* name, std::size_t row) { + return dataTable.getColumnByName(std::string(name)).getValue(row); +} + +} // namespace + +float decodePlayCanvasColor(float dcValue) { return 0.5f + dcValue * kSHC0; } + +float decodePlayCanvasOpacity(float value) { + if (value > 0.0f) { + return 1.0f / (1.0f + std::exp(-value)); + } + const float expValue = std::exp(value); + return expValue / (1.0f + expValue); +} + +float decodePlayCanvasScale(float scaleValue) { return std::exp(scaleValue); } + +Vec4f normalizePlayCanvasRotation(float w, float x, float y, float z) { + if (!(std::isfinite(w) && std::isfinite(x) && std::isfinite(y) && std::isfinite(z))) { + return {1.0f, 0.0f, 0.0f, 0.0f}; + } + + const float norm = std::sqrt(w * w + x * x + y * y + z * z); + if (norm < std::numeric_limits::epsilon()) { + return {1.0f, 0.0f, 0.0f, 0.0f}; + } + + float normalizedW = w / norm; + float normalizedX = x / norm; + float normalizedY = y / norm; + float normalizedZ = z / norm; + if (normalizedW < 0.0f) { + normalizedW = -normalizedW; + normalizedX = -normalizedX; + normalizedY = -normalizedY; + normalizedZ = -normalizedZ; + } + + return {normalizedW, normalizedX, normalizedY, normalizedZ}; +} + +GSplatData adaptDataTableToGSplatData(const DataTable& dataTable, std::size_t maxSplats) { + requireColumns(dataTable); + + const std::size_t rowCount = dataTable.getNumRows(); + const std::size_t count = maxSplats == 0 ? rowCount : std::min(rowCount, maxSplats); + + GSplatData out; + out.centers.reserve(count); + out.rotations.reserve(count); + out.scales.reserve(count); + out.colors.reserve(count); + out.sourceIndices.reserve(count); + + for (std::size_t i = 0; i < count; ++i) { + out.centers.push_back({columnValue(dataTable, "x", i), columnValue(dataTable, "y", i), + columnValue(dataTable, "z", i)}); + out.rotations.push_back(normalizePlayCanvasRotation(columnValue(dataTable, "rot_0", i), + columnValue(dataTable, "rot_1", i), + columnValue(dataTable, "rot_2", i), + columnValue(dataTable, "rot_3", i))); + out.scales.push_back({decodePlayCanvasScale(columnValue(dataTable, "scale_0", i)), + decodePlayCanvasScale(columnValue(dataTable, "scale_1", i)), + decodePlayCanvasScale(columnValue(dataTable, "scale_2", i))}); + out.colors.push_back({decodePlayCanvasColor(columnValue(dataTable, "f_dc_0", i)), + decodePlayCanvasColor(columnValue(dataTable, "f_dc_1", i)), + decodePlayCanvasColor(columnValue(dataTable, "f_dc_2", i)), + decodePlayCanvasOpacity(columnValue(dataTable, "opacity", i))}); + out.sourceIndices.push_back(static_cast(i)); + } + + return out; +} + +} // namespace splat::visualization diff --git a/src/visualization/gsplat_data.h b/src/visualization/gsplat_data.h new file mode 100644 index 0000000..938a8b7 --- /dev/null +++ b/src/visualization/gsplat_data.h @@ -0,0 +1,42 @@ +#pragma once + +#include + +#include +#include +#include + +namespace splat::visualization { + +struct Vec3f { + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; +}; + +struct Vec4f { + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; + float w = 0.0f; +}; + +struct GSplatData { + std::vector centers; + std::vector rotations; + std::vector scales; + std::vector colors; + std::vector sourceIndices; + + std::size_t size() const noexcept { return centers.size(); } + bool empty() const noexcept { return centers.empty(); } +}; + +GSplatData adaptDataTableToGSplatData(const DataTable& dataTable, std::size_t maxSplats = 0); + +float decodePlayCanvasColor(float dcValue); +float decodePlayCanvasOpacity(float opacityValue); +float decodePlayCanvasScale(float scaleValue); +Vec4f normalizePlayCanvasRotation(float w, float x, float y, float z); + +} // namespace splat::visualization \ No newline at end of file diff --git a/src/visualization/gsplat_gl_renderer.cpp b/src/visualization/gsplat_gl_renderer.cpp new file mode 100644 index 0000000..2b364c2 --- /dev/null +++ b/src/visualization/gsplat_gl_renderer.cpp @@ -0,0 +1,253 @@ +#include "gsplat_gl_renderer.h" +#include "gsplat_gl_shader_sources.h" + +#include +#include +#include +#include +#include + +namespace splat::visualization { +namespace { + +GLuint compileShader(GLenum type, const char* source) { + const GLuint shader = glCreateShader(type); + if (shader == 0) { + throw std::runtime_error("glCreateShader failed"); + } + + glShaderSource(shader, 1, &source, nullptr); + glCompileShader(shader); + GLint status = 0; + glGetShaderiv(shader, GL_COMPILE_STATUS, &status); + if (status == GL_TRUE) { + return shader; + } + + char log[2048] = {}; + glGetShaderInfoLog(shader, sizeof(log), nullptr, log); + glDeleteShader(shader); + throw std::runtime_error(std::string("GSplat shader compile error: ") + log); +} + +GLuint buildProgram() { + GLuint vertexShader = 0; + GLuint fragmentShader = 0; + GLuint program = 0; + try { + vertexShader = compileShader(GL_VERTEX_SHADER, kGSplatGLVertexShader); + fragmentShader = compileShader(GL_FRAGMENT_SHADER, kGSplatGLFragmentShader); + program = glCreateProgram(); + if (program == 0) { + throw std::runtime_error("glCreateProgram failed"); + } + glAttachShader(program, vertexShader); + glAttachShader(program, fragmentShader); + glLinkProgram(program); + GLint status = 0; + glGetProgramiv(program, GL_LINK_STATUS, &status); + if (status != GL_TRUE) { + char log[2048] = {}; + glGetProgramInfoLog(program, sizeof(log), nullptr, log); + throw std::runtime_error(std::string("GSplat shader link error: ") + log); + } + glDeleteShader(vertexShader); + glDeleteShader(fragmentShader); + return program; + } catch (...) { + if (vertexShader != 0) glDeleteShader(vertexShader); + if (fragmentShader != 0) glDeleteShader(fragmentShader); + if (program != 0) glDeleteProgram(program); + throw; + } +} + +void setMat4(GLint location, const Eigen::Matrix4f& matrix) { + if (location >= 0) { + glUniformMatrix4fv(location, 1, GL_FALSE, matrix.data()); + } +} + +void setVec4(GLint location, float x, float y, float z, float w) { + if (location >= 0) { + glUniform4f(location, x, y, z, w); + } +} + +void setFloat(GLint location, float value) { + if (location >= 0) { + glUniform1f(location, value); + } +} + +} // namespace + +GSplatGLRenderer::~GSplatGLRenderer() { this->releaseGraphicsResources(); } + +void GSplatGLRenderer::releaseGraphicsResources() { + if (vao_ != 0) glDeleteVertexArrays(1, &vao_); + if (cornerBuffer_ != 0) glDeleteBuffers(1, &cornerBuffer_); + if (instanceBuffer_ != 0) glDeleteBuffers(1, &instanceBuffer_); + if (program_ != 0) glDeleteProgram(program_); + vao_ = 0; + cornerBuffer_ = 0; + instanceBuffer_ = 0; + program_ = 0; + instancesDirty_ = true; + orderInitialized_ = false; +} + +void GSplatGLRenderer::setData(const GSplatData& data) { + data_ = data; + order_.resize(data_.size()); + std::iota(order_.begin(), order_.end(), 0u); + sortedInstances_.resize(data_.size()); + instancesDirty_ = true; + orderInitialized_ = false; +} + +void GSplatGLRenderer::ensureProgram() { + if (program_ == 0) { + program_ = buildProgram(); + } +} + +void GSplatGLRenderer::ensureBuffers() { + if (vao_ != 0) { + return; + } + + glGenVertexArrays(1, &vao_); + glBindVertexArray(vao_); + + static constexpr float corners[8] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f}; + glGenBuffers(1, &cornerBuffer_); + glBindBuffer(GL_ARRAY_BUFFER, cornerBuffer_); + glBufferData(GL_ARRAY_BUFFER, sizeof(corners), corners, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr); + + glGenBuffers(1, &instanceBuffer_); + glBindBuffer(GL_ARRAY_BUFFER, instanceBuffer_); + const auto stride = static_cast(sizeof(InstanceData)); + + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, stride, reinterpret_cast(offsetof(InstanceData, center))); + glVertexAttribDivisor(1, 1); + + glEnableVertexAttribArray(2); + glVertexAttribPointer(2, 4, GL_FLOAT, GL_FALSE, stride, reinterpret_cast(offsetof(InstanceData, rotation))); + glVertexAttribDivisor(2, 1); + + glEnableVertexAttribArray(3); + glVertexAttribPointer(3, 3, GL_FLOAT, GL_FALSE, stride, reinterpret_cast(offsetof(InstanceData, scale))); + glVertexAttribDivisor(3, 1); + + glEnableVertexAttribArray(4); + glVertexAttribPointer(4, 4, GL_FLOAT, GL_FALSE, stride, reinterpret_cast(offsetof(InstanceData, color))); + glVertexAttribDivisor(4, 1); + + glBindVertexArray(0); +} + +bool GSplatGLRenderer::needsInstanceOrderUpdate(const GSplatGLFrameState& frame, bool sortBackToFront) const { + if (!orderInitialized_ || lastSortBackToFront_ != sortBackToFront) { + return true; + } + if (!sortBackToFront) { + return false; + } + + const Eigen::Vector3f positionDelta = frame.cameraPosition - lastSortPosition_; + const float forwardDot = std::clamp(frame.cameraForward.normalized().dot(lastSortForward_.normalized()), -1.0f, 1.0f); + return positionDelta.squaredNorm() > 1e-4f || forwardDot < 0.9995f; +} + +void GSplatGLRenderer::updateSortedInstances(const GSplatGLFrameState& frame, bool sortBackToFront) { + if (data_.empty() || !needsInstanceOrderUpdate(frame, sortBackToFront)) { + return; + } + + const Eigen::Vector3f cameraForward = frame.cameraForward.normalized(); + if (sortBackToFront) { + std::sort(order_.begin(), order_.end(), [&](std::uint32_t lhs, std::uint32_t rhs) { + const auto& left = data_.centers[lhs]; + const auto& right = data_.centers[rhs]; + const Eigen::Vector4f leftCenter(left.x, left.y, left.z, 1.0f); + const Eigen::Vector4f rightCenter(right.x, right.y, right.z, 1.0f); + const float leftDepth = (frame.view * leftCenter).z(); + const float rightDepth = (frame.view * rightCenter).z(); + return leftDepth < rightDepth; + }); + } else { + std::iota(order_.begin(), order_.end(), 0u); + } + + for (std::size_t i = 0; i < order_.size(); ++i) { + const std::uint32_t source = order_[i]; + sortedInstances_[i] = {data_.centers[source], data_.rotations[source], data_.scales[source], data_.colors[source]}; + } + + orderInitialized_ = true; + lastSortBackToFront_ = sortBackToFront; + lastSortPosition_ = frame.cameraPosition; + lastSortForward_ = cameraForward; + instancesDirty_ = true; +} + +void GSplatGLRenderer::uploadInstances() { + if (!instancesDirty_) { + return; + } + glBindBuffer(GL_ARRAY_BUFFER, instanceBuffer_); + glBufferData(GL_ARRAY_BUFFER, static_cast(sortedInstances_.size() * sizeof(InstanceData)), + sortedInstances_.data(), GL_DYNAMIC_DRAW); + instancesDirty_ = false; +} + +void GSplatGLRenderer::render(const GSplatGLFrameState& frame, const GSplatGLRenderOptions& options) { + if (data_.empty()) { + return; + } + + this->ensureProgram(); + this->ensureBuffers(); + if (!options.freezeSortOrder || !orderInitialized_) { + this->updateSortedInstances(frame, options.sortBackToFront); + } + this->uploadInstances(); + + glUseProgram(program_); + glBindVertexArray(vao_); + + const int width = std::max(frame.width, 1); + const int height = std::max(frame.height, 1); + setMat4(glGetUniformLocation(program_, "uView"), frame.view); + setMat4(glGetUniformLocation(program_, "uProjection"), frame.projection); + setVec4(glGetUniformLocation(program_, "uViewport"), static_cast(width), static_cast(height), + 1.0f / static_cast(width), 1.0f / static_cast(height)); + setVec4(glGetUniformLocation(program_, "uCameraParams"), 1.0f / frame.farPlane, frame.farPlane, frame.nearPlane, 0.0f); + setFloat(glGetUniformLocation(program_, "uGlobalOpacity"), options.globalOpacity); + setFloat(glGetUniformLocation(program_, "uSizeScale"), options.sizeScale); + setFloat(glGetUniformLocation(program_, "uMinPixelSize"), options.minPixelSize); + setFloat(glGetUniformLocation(program_, "uMaxPixelSize"), options.maxPixelSize); + setFloat(glGetUniformLocation(program_, "uAlphaDiscardThreshold"), options.alphaDiscardThreshold); + setFloat(glGetUniformLocation(program_, "uClampColors"), options.clampColors ? 1.0f : 0.0f); + + glEnable(GL_BLEND); + glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA); + if (options.depthTest) { + glEnable(GL_DEPTH_TEST); + } else { + glDisable(GL_DEPTH_TEST); + } + glDepthMask(options.depthWrite ? GL_TRUE : GL_FALSE); + glDisable(GL_CULL_FACE); + + glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, static_cast(sortedInstances_.size())); + + glBindVertexArray(0); + glUseProgram(0); +} + +} // namespace splat::visualization diff --git a/src/visualization/gsplat_gl_renderer.h b/src/visualization/gsplat_gl_renderer.h new file mode 100644 index 0000000..46a15da --- /dev/null +++ b/src/visualization/gsplat_gl_renderer.h @@ -0,0 +1,77 @@ +#pragma once + +#include "gsplat_data.h" + +#include +#include + +#include +#include + +namespace splat::visualization { + +struct GSplatGLRenderOptions { + float globalOpacity = 1.0f; + float sizeScale = 1.0f; + float minPixelSize = 2.0f; + float maxPixelSize = 1024.0f; + float alphaDiscardThreshold = 1.0f / 255.0f; + bool sortBackToFront = true; + bool depthTest = true; + bool depthWrite = false; + bool clampColors = true; + bool freezeSortOrder = false; +}; + +struct GSplatGLFrameState { + Eigen::Matrix4f view = Eigen::Matrix4f::Identity(); + Eigen::Matrix4f projection = Eigen::Matrix4f::Identity(); + Eigen::Vector3f cameraPosition{0.0f, 0.0f, 0.0f}; + Eigen::Vector3f cameraForward{0.0f, 0.0f, -1.0f}; + int width = 1; + int height = 1; + float nearPlane = 0.001f; + float farPlane = 1000.0f; +}; + +class GSplatGLRenderer { + public: + GSplatGLRenderer() = default; + ~GSplatGLRenderer(); + + GSplatGLRenderer(const GSplatGLRenderer&) = delete; + GSplatGLRenderer& operator=(const GSplatGLRenderer&) = delete; + + void setData(const GSplatData& data); + void render(const GSplatGLFrameState& frame, const GSplatGLRenderOptions& options); + void releaseGraphicsResources(); + + private: + struct InstanceData { + Vec3f center; + Vec4f rotation; + Vec3f scale; + Vec4f color; + }; + + void ensureProgram(); + void ensureBuffers(); + bool needsInstanceOrderUpdate(const GSplatGLFrameState& frame, bool sortBackToFront) const; + void updateSortedInstances(const GSplatGLFrameState& frame, bool sortBackToFront); + void uploadInstances(); + + GSplatData data_; + std::vector order_; + std::vector sortedInstances_; + GLuint vao_ = 0; + GLuint cornerBuffer_ = 0; + GLuint instanceBuffer_ = 0; + GLuint program_ = 0; + bool instancesDirty_ = true; + bool orderInitialized_ = false; + bool lastSortBackToFront_ = true; + Eigen::Vector3f lastSortPosition_{0.0f, 0.0f, 0.0f}; + Eigen::Vector3f lastSortForward_{0.0f, 0.0f, -1.0f}; +}; + +} // namespace splat::visualization diff --git a/src/visualization/gsplat_gl_shader_sources.h b/src/visualization/gsplat_gl_shader_sources.h new file mode 100644 index 0000000..6ab09a4 --- /dev/null +++ b/src/visualization/gsplat_gl_shader_sources.h @@ -0,0 +1,138 @@ +#pragma once + +namespace splat::visualization { + +inline constexpr const char* kGSplatGLVertexShader = R"GLSL( +#version 330 core +layout(location = 0) in vec2 aCorner; +layout(location = 1) in vec3 aCenter; +layout(location = 2) in vec4 aRotation; +layout(location = 3) in vec3 aScale; +layout(location = 4) in vec4 aColor; + +uniform mat4 uView; +uniform mat4 uProjection; +uniform vec4 uViewport; +uniform vec4 uCameraParams; +uniform float uMinPixelSize; +uniform float uMaxPixelSize; +uniform float uSizeScale; +uniform float uClampColors; + +out vec2 vUv; +out vec4 vColor; + +mat3 quatToMat3(vec4 R) { + vec4 R2 = R + R; + float X = R2.x * R.w; + vec4 Y = R2.y * R; + vec4 Z = R2.z * R; + float W = R2.w * R.w; + + return mat3( + 1.0 - Z.z - W, + Y.z + X, + Y.w - Z.x, + Y.z - X, + 1.0 - Y.y - W, + Z.w + Y.x, + Y.w + Z.x, + Z.w - Y.x, + 1.0 - Y.y - Z.z + ); +} + +void computeCovariance(vec4 rotation, vec3 scale, out vec3 covA, out vec3 covB) { + mat3 rot = quatToMat3(rotation); + mat3 M = transpose(mat3(scale.x * rot[0], scale.y * rot[1], scale.z * rot[2])); + covA = vec3(dot(M[0], M[0]), dot(M[0], M[1]), dot(M[0], M[2])); + covB = vec3(dot(M[1], M[1]), dot(M[1], M[2]), dot(M[2], M[2])); +} + +bool initCorner(vec3 viewCenter, mat3 modelView3, vec4 centerProj, float projMat00, + vec3 covA, vec3 covB, out vec2 offset, out vec2 uv) { + mat3 Vrk = mat3( + covA.x, covA.y, covA.z, + covA.y, covB.x, covB.y, + covA.z, covB.y, covB.z + ); + float focal = uViewport.x * projMat00; + vec3 vp = (uCameraParams.w == 1.0) ? vec3(0.0, 0.0, 1.0) : viewCenter; + float J1 = focal / vp.z; + vec2 J2 = -J1 / vp.z * vp.xy; + mat3 J = mat3(J1, 0.0, J2.x, 0.0, J1, J2.y, 0.0, 0.0, 0.0); + mat3 W = transpose(modelView3); + mat3 T = W * J; + mat3 cov = transpose(T) * Vrk * T; + float diagonal1 = cov[0][0] + 0.3; + float offDiagonal = cov[0][1]; + float diagonal2 = cov[1][1] + 0.3; + float mid = 0.5 * (diagonal1 + diagonal2); + float radius = sqrt(0.25 * (diagonal1 - diagonal2) * (diagonal1 - diagonal2) + offDiagonal * offDiagonal); + float lambda1 = mid + radius; + float lambda2 = max(mid - radius, 0.1); + float vmin = min(1024.0, min(uViewport.x, uViewport.y)); + float maxPixelSize = max(uMaxPixelSize, uMinPixelSize); + float l1 = min(2.0 * min(sqrt(2.0 * lambda1), vmin) * uSizeScale, maxPixelSize); + float l2 = min(2.0 * min(sqrt(2.0 * lambda2), vmin) * uSizeScale, maxPixelSize); + if (max(l1, l2) < uMinPixelSize) return false; + vec2 c = centerProj.ww * uViewport.zw; + if (any(greaterThan(abs(centerProj.xy) - vec2(max(l1, l2)) * c, centerProj.ww))) return false; + vec2 diagonalVectorRaw = vec2(offDiagonal, lambda1 - diagonal1); + float diagonalVectorLength = length(diagonalVectorRaw); + vec2 diagonalVector = diagonalVectorLength > 1e-6 ? diagonalVectorRaw / diagonalVectorLength : vec2(1.0, 0.0); + vec2 v1 = l1 * diagonalVector; + vec2 v2 = l2 * vec2(diagonalVector.y, -diagonalVector.x); + offset = (aCorner.x * v1 + aCorner.y * v2) * c; + uv = aCorner; + return true; +} + +void main() { + vec4 centerView4 = uView * vec4(aCenter, 1.0); + if (uCameraParams.w == 0.0 && centerView4.z > 0.0) { + gl_Position = vec4(0.0, 0.0, 2.0, 1.0); + vUv = vec2(2.0); + vColor = vec4(0.0); + return; + } + vec4 centerProj = uProjection * centerView4; + centerProj.z = clamp(centerProj.z, -abs(centerProj.w), abs(centerProj.w)); + vec3 covA, covB; + computeCovariance(aRotation, aScale, covA, covB); + vec2 offset, uv; + if (!initCorner(centerView4.xyz, mat3(uView), centerProj, uProjection[0][0], covA, covB, offset, uv)) { + gl_Position = vec4(0.0, 0.0, 2.0, 1.0); + vUv = vec2(2.0); + vColor = vec4(0.0); + return; + } + gl_Position = centerProj + vec4(offset, 0.0, 0.0); + vUv = uv; + vec3 color = uClampColors > 0.5 ? clamp(aColor.rgb, 0.0, 1.0) : aColor.rgb; + vColor = vec4(color, aColor.a); +} +)GLSL"; + +inline constexpr const char* kGSplatGLFragmentShader = R"GLSL( +#version 330 core +in vec2 vUv; +in vec4 vColor; +uniform float uGlobalOpacity; +uniform float uAlphaDiscardThreshold; +out vec4 fragColor; + +const float EXP4 = exp(-4.0); +const float INV_EXP4 = 1.0 / (1.0 - EXP4); +float normExp(float x) { return (exp(x * -4.0) - EXP4) * INV_EXP4; } + +void main() { + float A = dot(vUv, vUv); + if (A > 1.0) discard; + float alpha = normExp(A) * clamp(vColor.a * uGlobalOpacity, 0.0, 1.0); + if (alpha < uAlphaDiscardThreshold) discard; + fragColor = vec4(max(vColor.rgb, vec3(0.0)) * alpha, alpha); +} +)GLSL"; + +} // namespace splat::visualization diff --git a/src/visualization/splat_gaussian_prop.cpp b/src/visualization/splat_gaussian_prop.cpp new file mode 100644 index 0000000..4c14728 --- /dev/null +++ b/src/visualization/splat_gaussian_prop.cpp @@ -0,0 +1,305 @@ +#include +#include +#include "gsplat_data.h" +#include "gsplat_gl_renderer.h" +#include "splat_gaussian_prop.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace splat { +namespace { + +std::shared_ptr cloneShared(const DataTable& dataTable) { + auto clone = dataTable.clone(); + return std::shared_ptr(clone.release()); +} + +bool isValidBounds(const std::array& bounds) { + return std::isfinite(bounds[0]) && std::isfinite(bounds[1]) && std::isfinite(bounds[2]) && std::isfinite(bounds[3]) && + std::isfinite(bounds[4]) && std::isfinite(bounds[5]) && bounds[0] <= bounds[1] && bounds[2] <= bounds[3] && + bounds[4] <= bounds[5]; +} + +std::array computeBoundsFromGSplatData(const visualization::GSplatData& data) { + if (data.empty()) { + return {1.0, -1.0, 1.0, -1.0, 1.0, -1.0}; + } + + std::array bounds = { + std::numeric_limits::max(), std::numeric_limits::lowest(), + std::numeric_limits::max(), std::numeric_limits::lowest(), + std::numeric_limits::max(), std::numeric_limits::lowest(), + }; + + for (const auto& center : data.centers) { + bounds[0] = std::min(bounds[0], static_cast(center.x)); + bounds[1] = std::max(bounds[1], static_cast(center.x)); + bounds[2] = std::min(bounds[2], static_cast(center.y)); + bounds[3] = std::max(bounds[3], static_cast(center.y)); + bounds[4] = std::min(bounds[4], static_cast(center.z)); + bounds[5] = std::max(bounds[5], static_cast(center.z)); + } + + return bounds; +} + +std::array computeSplatBounds(const DataTable& dataTable, const visualization::GSplatData& data) { + const auto extents = computeGaussianExtents(&dataTable); + const auto& minBound = extents.sceneBounds.min; + const auto& maxBound = extents.sceneBounds.max; + + std::array bounds = { + static_cast(minBound.x()), static_cast(maxBound.x()), static_cast(minBound.y()), + static_cast(maxBound.y()), static_cast(minBound.z()), static_cast(maxBound.z()), + }; + + if (isValidBounds(bounds)) { + return bounds; + } + + return computeBoundsFromGSplatData(data); +} + +Eigen::Matrix4f copyMatrixToEigen(const vtkMatrix4x4* matrix) { + Eigen::Matrix4f out = Eigen::Matrix4f::Identity(); + for (int row = 0; row < 4; ++row) { + for (int col = 0; col < 4; ++col) { + out(row, col) = static_cast(matrix->GetElement(row, col)); + } + } + return out; +} + +void transformPoint(const double matrix[16], const std::array& point, double out[3]) { + out[0] = matrix[0] * point[0] + matrix[1] * point[1] + matrix[2] * point[2] + matrix[3]; + out[1] = matrix[4] * point[0] + matrix[5] * point[1] + matrix[6] * point[2] + matrix[7]; + out[2] = matrix[8] * point[0] + matrix[9] * point[1] + matrix[10] * point[2] + matrix[11]; +} + +} // namespace + +class SplatGaussianProp::Impl { + public: + void SetInputData(std::shared_ptr dataTable) { + this->inputDataTable = std::move(dataTable); + this->RebuildCpuCache(); + } + + void SetRenderOptions(const SplatRenderOptions& newOptions) { + this->options = newOptions; + } + + double* GetBounds(vtkProp3D* owner) { + if (!this->boundsValid) { + return nullptr; + } + + const double localBounds[6] = { + this->localBounds[0], this->localBounds[1], this->localBounds[2], + this->localBounds[3], this->localBounds[4], this->localBounds[5], + }; + + double modelMatrix[16]; + owner->GetMatrix(modelMatrix); + + const std::array corners = { + localBounds[0], localBounds[2], localBounds[4], localBounds[1], localBounds[2], localBounds[4], + localBounds[0], localBounds[3], localBounds[4], localBounds[1], localBounds[3], localBounds[4], + localBounds[0], localBounds[2], localBounds[5], localBounds[1], localBounds[2], localBounds[5], + localBounds[0], localBounds[3], localBounds[5], localBounds[1], localBounds[3], localBounds[5], + }; + + this->worldBounds = { + std::numeric_limits::max(), std::numeric_limits::lowest(), + std::numeric_limits::max(), std::numeric_limits::lowest(), + std::numeric_limits::max(), std::numeric_limits::lowest(), + }; + + for (std::size_t i = 0; i < corners.size(); i += 3) { + double transformed[3]; + transformPoint(modelMatrix, {corners[i + 0], corners[i + 1], corners[i + 2]}, transformed); + this->worldBounds[0] = std::min(this->worldBounds[0], transformed[0]); + this->worldBounds[1] = std::max(this->worldBounds[1], transformed[0]); + this->worldBounds[2] = std::min(this->worldBounds[2], transformed[1]); + this->worldBounds[3] = std::max(this->worldBounds[3], transformed[1]); + this->worldBounds[4] = std::min(this->worldBounds[4], transformed[2]); + this->worldBounds[5] = std::max(this->worldBounds[5], transformed[2]); + } + + return this->worldBounds.data(); + } + + int RenderSplatGeometry(vtkProp3D* owner, vtkViewport* viewport) { + if (!owner->GetVisibility() || this->renderData.empty()) { + return 0; + } + + auto* renderer = vtkRenderer::SafeDownCast(viewport); + if (renderer == nullptr) { + return 0; + } + + auto* openGLWindow = vtkOpenGLRenderWindow::SafeDownCast(renderer->GetRenderWindow()); + if (openGLWindow == nullptr) { + throw std::runtime_error("SplatGaussianProp requires a vtkOpenGLRenderWindow."); + } + + openGLWindow->MakeCurrent(); + auto* state = openGLWindow->GetState(); + state->Push(); + + int viewportWidth = 1; + int viewportHeight = 1; + int viewportLowerLeftX = 0; + int viewportLowerLeftY = 0; + renderer->GetTiledSizeAndOrigin(&viewportWidth, &viewportHeight, &viewportLowerLeftX, &viewportLowerLeftY); + const double aspect = static_cast(std::max(viewportWidth, 1)) / static_cast(std::max(viewportHeight, 1)); + + auto* camera = renderer->GetActiveCamera(); + double clippingRange[2] = {0.001, 1000.0}; + camera->GetClippingRange(clippingRange); + auto* viewToClipMatrix = camera->GetProjectionTransformMatrix(aspect, clippingRange[0], clippingRange[1]); + + vtkNew modelMatrix; + vtkNew modelViewMatrix; + owner->GetMatrix(modelMatrix); + vtkMatrix4x4::Multiply4x4(camera->GetViewTransformMatrix(), modelMatrix, modelViewMatrix); + double cameraPosition[3] = {0.0, 0.0, 0.0}; + double cameraForward[3] = {0.0, 0.0, -1.0}; + camera->GetPosition(cameraPosition); + camera->GetDirectionOfProjection(cameraForward); + + visualization::GSplatGLFrameState frame; + frame.view = copyMatrixToEigen(modelViewMatrix); + frame.projection = copyMatrixToEigen(viewToClipMatrix); + frame.cameraPosition = Eigen::Vector3f(static_cast(cameraPosition[0]), static_cast(cameraPosition[1]), + static_cast(cameraPosition[2])); + frame.cameraForward = Eigen::Vector3f(static_cast(cameraForward[0]), static_cast(cameraForward[1]), + static_cast(cameraForward[2])); + frame.width = std::max(viewportWidth, 1); + frame.height = std::max(viewportHeight, 1); + frame.nearPlane = static_cast(clippingRange[0]); + frame.farPlane = static_cast(clippingRange[1]); + + visualization::GSplatGLRenderOptions renderOptions; + renderOptions.globalOpacity = this->options.globalOpacity; + renderOptions.sizeScale = this->options.sizeScale; + renderOptions.minPixelSize = this->options.minPointSize; + renderOptions.maxPixelSize = this->options.maxPointSize; + renderOptions.alphaDiscardThreshold = this->options.alphaDiscardThreshold; + renderOptions.sortBackToFront = this->options.sortBackToFront; + renderOptions.depthTest = this->options.depthTest; + renderOptions.depthWrite = this->options.depthWrite; + renderOptions.clampColors = this->options.clampColors; + renderOptions.freezeSortOrder = this->options.freezeSortOrder; + + this->renderer.render(frame, renderOptions); + state->Pop(); + return 1; + } + + void ReleaseGraphicsResources(vtkWindow* window) { + auto* openGLWindow = vtkOpenGLRenderWindow::SafeDownCast(window); + if (openGLWindow == nullptr) { + return; + } + + openGLWindow->MakeCurrent(); + this->renderer.releaseGraphicsResources(); + } + + void RebuildCpuCache() { + this->renderData = {}; + this->renderer.setData(this->renderData); + this->boundsValid = false; + this->localBounds = {1.0, -1.0, 1.0, -1.0, 1.0, -1.0}; + this->worldBounds = this->localBounds; + + if (!this->inputDataTable || this->inputDataTable->getNumRows() == 0) { + return; + } + + try { + this->renderData = visualization::adaptDataTableToGSplatData(*this->inputDataTable); + } catch (const std::runtime_error& error) { + std::string message = error.what(); + const std::string prefix = "GSplatData: "; + if (message.rfind(prefix, 0) == 0) { + message.replace(0, prefix.size(), "SplatGaussianProp: "); + } + throw std::runtime_error(message); + } + + if (this->renderData.empty()) { + throw std::runtime_error("SplatGaussianProp: upload produced zero splats"); + } + + this->renderer.setData(this->renderData); + this->localBounds = computeSplatBounds(*this->inputDataTable, this->renderData); + this->boundsValid = isValidBounds(this->localBounds); + } + + std::shared_ptr inputDataTable; + SplatRenderOptions options; + visualization::GSplatData renderData; + visualization::GSplatGLRenderer renderer; + bool boundsValid{false}; + std::array localBounds{1.0, -1.0, 1.0, -1.0, 1.0, -1.0}; + std::array worldBounds{1.0, -1.0, 1.0, -1.0, 1.0, -1.0}; +}; + +vtkStandardNewMacro(SplatGaussianProp); + +SplatGaussianProp::SplatGaussianProp() : impl_(std::make_unique()) {} + +SplatGaussianProp::~SplatGaussianProp() = default; + +void SplatGaussianProp::SetInputData(std::shared_ptr dataTable) { + this->impl_->SetInputData(std::move(dataTable)); + this->Modified(); +} + +void SplatGaussianProp::SetInputData(const DataTable& dataTable) { this->SetInputData(cloneShared(dataTable)); } + +void SplatGaussianProp::SetRenderOptions(const SplatRenderOptions& options) { + this->impl_->SetRenderOptions(options); + this->SetVisibility(options.visible ? 1 : 0); + this->Modified(); +} + +const SplatRenderOptions& SplatGaussianProp::GetRenderOptions() const noexcept { return this->impl_->options; } + +std::size_t SplatGaussianProp::GetSplatCount() const noexcept { return this->impl_->renderData.size(); } + +double* SplatGaussianProp::GetBounds() { return this->impl_->GetBounds(this); } + +int SplatGaussianProp::RenderOpaqueGeometry(vtkViewport* viewport) { + return this->impl_->RenderSplatGeometry(this, viewport); +} + +int SplatGaussianProp::RenderTranslucentPolygonalGeometry(vtkViewport*) { return 0; } + +vtkTypeBool SplatGaussianProp::HasTranslucentPolygonalGeometry() { return 0; } + +void SplatGaussianProp::ReleaseGraphicsResources(vtkWindow* window) { this->impl_->ReleaseGraphicsResources(window); } + +} // namespace splat diff --git a/src/visualization/splat_gaussian_prop.h b/src/visualization/splat_gaussian_prop.h new file mode 100644 index 0000000..1710ee7 --- /dev/null +++ b/src/visualization/splat_gaussian_prop.h @@ -0,0 +1,40 @@ +#pragma once + +#include + +#include +#include + +#include + +namespace splat { + +class DataTable; + +class SplatGaussianProp : public vtkProp3D { + public: + static SplatGaussianProp* New(); + vtkTypeMacro(SplatGaussianProp, vtkProp3D); + + void SetInputData(std::shared_ptr dataTable); + void SetInputData(const DataTable& dataTable); + void SetRenderOptions(const SplatRenderOptions& options); + const SplatRenderOptions& GetRenderOptions() const noexcept; + std::size_t GetSplatCount() const noexcept; + + double* GetBounds() override; + int RenderOpaqueGeometry(vtkViewport* viewport) override; + int RenderTranslucentPolygonalGeometry(vtkViewport* viewport) override; + vtkTypeBool HasTranslucentPolygonalGeometry() override; + void ReleaseGraphicsResources(vtkWindow* window) override; + + protected: + SplatGaussianProp(); + ~SplatGaussianProp() override; + + private: + class Impl; + std::unique_ptr impl_; +}; + +} // namespace splat diff --git a/src/visualization/splat_visualizer.cpp b/src/visualization/splat_visualizer.cpp index a027cfb..b6b48bf 100644 --- a/src/visualization/splat_visualizer.cpp +++ b/src/visualization/splat_visualizer.cpp @@ -1,11 +1,15 @@ #include #include +#include "gsplat_data.h" +#include "gsplat_gl_renderer.h" +#include "splat_gaussian_prop.h" #include #include #include #include #include #include +#include #include #include #include @@ -26,7 +30,6 @@ #include #include #include -#include #include #include #include @@ -38,261 +41,14 @@ namespace splat { namespace { -constexpr const char* kPositionColumns[3] = {"x", "y", "z"}; -constexpr const char* kColorColumns[3] = {"f_dc_0", "f_dc_1", "f_dc_2"}; -constexpr const char* kScaleColumns[3] = {"scale_0", "scale_1", "scale_2"}; -constexpr const char* kRotationColumns[4] = {"rot_0", "rot_1", "rot_2", "rot_3"}; -constexpr int kPositionAttribLocation = 0; -constexpr int kColorAttribLocation = 1; -constexpr int kScaleAttribLocation = 2; -constexpr int kOpacityAttribLocation = 3; -constexpr int kRotationAttribLocation = 4; - -constexpr const char* kVertexShaderSource = R"( -#version 330 core - -layout (location = 0) in vec3 aPosition; -layout (location = 1) in vec3 aShDc; -layout (location = 2) in vec3 aLogScale; -layout (location = 3) in float aOpacity; -layout (location = 4) in vec4 aRotation; - -uniform mat4 uModelView; -uniform mat4 uProjection; -uniform vec2 uViewportSize; -uniform float uParallelProjection; -uniform float uSizeScale; -uniform float uMinPointSize; -uniform float uMaxPointSize; - -flat out vec3 vColor; -flat out float vOpacity; -flat out vec3 vConic; -flat out float vPointSize; - -mat3 quaternionToMatrix(vec4 q) { - vec4 normalized = normalize(q); - float w = normalized.x; - float x = normalized.y; - float y = normalized.z; - float z = normalized.w; - - float xx = x * x; - float yy = y * y; - float zz = z * z; - float xy = x * y; - float xz = x * z; - float yz = y * z; - float wx = w * x; - float wy = w * y; - float wz = w * z; - - return mat3( - 1.0 - 2.0 * (yy + zz), 2.0 * (xy + wz), 2.0 * (xz - wy), - 2.0 * (xy - wz), 1.0 - 2.0 * (xx + zz), 2.0 * (yz + wx), - 2.0 * (xz + wy), 2.0 * (yz - wx), 1.0 - 2.0 * (xx + yy) - ); -} - -void main() { - vec4 cameraPos4 = uModelView * vec4(aPosition, 1.0); - vec3 cameraPos = cameraPos4.xyz; - - vColor = vec3(0.0); - vOpacity = 0.0; - vConic = vec3(1.0, 0.0, 1.0); - vPointSize = 0.0; - - if (cameraPos.z >= -1e-4) { - gl_Position = vec4(2.0, 2.0, 2.0, 1.0); - gl_PointSize = 0.0; - return; - } - - vec3 scale = exp(aLogScale); - mat3 rotation = quaternionToMatrix(aRotation); - mat3 scaleMatrix = mat3( - vec3(scale.x, 0.0, 0.0), - vec3(0.0, scale.y, 0.0), - vec3(0.0, 0.0, scale.z) - ); - mat3 basisCam = mat3(uModelView) * rotation * scaleMatrix; - mat3 covarianceCam = basisCam * transpose(basisCam); - - float fx = uProjection[0][0] * 0.5 * uViewportSize.x; - float fy = uProjection[1][1] * 0.5 * uViewportSize.y; - vec3 jx; - vec3 jy; - - if (uParallelProjection > 0.5) { - jx = vec3(fx, 0.0, 0.0); - jy = vec3(0.0, fy, 0.0); - } else { - float invNegZ = 1.0 / max(-cameraPos.z, 1e-4); - jx = vec3(fx * invNegZ, 0.0, fx * cameraPos.x * invNegZ * invNegZ); - jy = vec3(0.0, fy * invNegZ, fy * cameraPos.y * invNegZ * invNegZ); - } - - float cov00 = dot(jx, covarianceCam * jx); - float cov01 = dot(jx, covarianceCam * jy); - float cov11 = dot(jy, covarianceCam * jy); - - cov00 += 0.3; - cov11 += 0.3; - - float determinant = max(cov00 * cov11 - cov01 * cov01, 1e-6); - vConic = vec3(cov11 / determinant, -cov01 / determinant, cov00 / determinant); - - float trace = cov00 + cov11; - float discriminant = sqrt(max((cov00 - cov11) * (cov00 - cov11) + 4.0 * cov01 * cov01, 0.0)); - float maxEigenvalue = max(0.5 * (trace + discriminant), 1e-6); - float radius = uSizeScale * sqrt(maxEigenvalue); - vPointSize = clamp(radius * 2.0, uMinPointSize, uMaxPointSize); - - gl_Position = uProjection * cameraPos4; - gl_PointSize = vPointSize; - - const float SH_C0 = 0.28209479177387814; - vColor = clamp(aShDc * SH_C0 + vec3(0.5), 0.0, 1.0); - vOpacity = 1.0 / (1.0 + exp(-aOpacity)); -} -)"; - -constexpr const char* kFragmentShaderSource = R"( -#version 330 core - -flat in vec3 vColor; -flat in float vOpacity; -flat in vec3 vConic; -flat in float vPointSize; - -uniform float uGlobalOpacity; -uniform float uAlphaDiscardThreshold; - -out vec4 fragColor; - -void main() { - vec2 pixelOffset = (gl_PointCoord - vec2(0.5)) * vPointSize; - float quadForm = pixelOffset.x * (vConic.x * pixelOffset.x + vConic.y * pixelOffset.y) + - pixelOffset.y * (vConic.y * pixelOffset.x + vConic.z * pixelOffset.y); - quadForm = max(quadForm, 0.0); - float alpha = exp(-0.5 * quadForm) * vOpacity * uGlobalOpacity; - if (alpha < uAlphaDiscardThreshold) { - discard; - } - - fragColor = vec4(vColor, alpha); -} -)"; +constexpr double kPlayCanvasFovYDegrees = 60.0; +constexpr double kPi = 3.141592653589793238462643383279502884; std::shared_ptr cloneShared(const DataTable& dataTable) { auto clone = dataTable.clone(); return std::shared_ptr(clone.release()); } -std::vector extractScalarColumn(const DataTable& dataTable, const std::string& name) { - const auto& column = dataTable.getColumnByName(name); - std::vector values(column.length()); - for (size_t i = 0; i < values.size(); ++i) { - values[i] = column.getValue(i); - } - return values; -} - -std::vector extractVec3Columns(const DataTable& dataTable, const char* const (&names)[3]) { - const auto& c0 = dataTable.getColumnByName(names[0]); - const auto& c1 = dataTable.getColumnByName(names[1]); - const auto& c2 = dataTable.getColumnByName(names[2]); - const size_t count = dataTable.getNumRows(); - - std::vector values(count * 3); - for (size_t i = 0; i < count; ++i) { - const size_t base = i * 3; - values[base + 0] = c0.getValue(i); - values[base + 1] = c1.getValue(i); - values[base + 2] = c2.getValue(i); - } - return values; -} - -std::vector extractQuaternionColumns(const DataTable& dataTable, const char* const (&names)[4]) { - const auto& c0 = dataTable.getColumnByName(names[0]); - const auto& c1 = dataTable.getColumnByName(names[1]); - const auto& c2 = dataTable.getColumnByName(names[2]); - const auto& c3 = dataTable.getColumnByName(names[3]); - const size_t count = dataTable.getNumRows(); - - std::vector values(count * 4); - for (size_t i = 0; i < count; ++i) { - const size_t base = i * 4; - float w = c0.getValue(i); - float x = c1.getValue(i); - float y = c2.getValue(i); - float z = c3.getValue(i); - const float norm = std::sqrt(w * w + x * x + y * y + z * z); - - if (norm > 1e-8f && std::isfinite(norm)) { - const float invNorm = 1.0f / norm; - values[base + 0] = w * invNorm; - values[base + 1] = x * invNorm; - values[base + 2] = y * invNorm; - values[base + 3] = z * invNorm; - } else { - values[base + 0] = 1.0f; - values[base + 1] = 0.0f; - values[base + 2] = 0.0f; - values[base + 3] = 0.0f; - } - } - return values; -} - -std::array computeBoundsFromPositions(const std::vector& positions) { - if (positions.empty()) { - return {1.0, -1.0, 1.0, -1.0, 1.0, -1.0}; - } - - std::array bounds = { - std::numeric_limits::max(), std::numeric_limits::lowest(), - std::numeric_limits::max(), std::numeric_limits::lowest(), - std::numeric_limits::max(), std::numeric_limits::lowest(), - }; - - for (size_t i = 0; i < positions.size(); i += 3) { - bounds[0] = std::min(bounds[0], static_cast(positions[i + 0])); - bounds[1] = std::max(bounds[1], static_cast(positions[i + 0])); - bounds[2] = std::min(bounds[2], static_cast(positions[i + 1])); - bounds[3] = std::max(bounds[3], static_cast(positions[i + 1])); - bounds[4] = std::min(bounds[4], static_cast(positions[i + 2])); - bounds[5] = std::max(bounds[5], static_cast(positions[i + 2])); - } - - return bounds; -} - -bool isValidBounds(const std::array& bounds) { - return std::isfinite(bounds[0]) && std::isfinite(bounds[1]) && std::isfinite(bounds[2]) && std::isfinite(bounds[3]) && - std::isfinite(bounds[4]) && std::isfinite(bounds[5]) && bounds[0] <= bounds[1] && bounds[2] <= bounds[3] && - bounds[4] <= bounds[5]; -} - -std::array computeSplatBounds(const DataTable& dataTable, const std::vector& positions) { - const auto extents = computeGaussianExtents(&dataTable); - const auto& minBound = extents.sceneBounds.min; - const auto& maxBound = extents.sceneBounds.max; - - std::array bounds = { - static_cast(minBound.x()), static_cast(maxBound.x()), static_cast(minBound.y()), - static_cast(maxBound.y()), static_cast(minBound.z()), static_cast(maxBound.z()), - }; - - if (isValidBounds(bounds)) { - return bounds; - } - - return computeBoundsFromPositions(positions); -} - KeyModifier makeModifiers(vtkRenderWindowInteractor* interactor) { KeyModifier modifiers = KeyModifier::None; if (interactor->GetShiftKey()) { @@ -375,392 +131,9 @@ int makeWheelDelta(unsigned long eventId) { } } -GLuint compileShader(GLenum shaderType, const char* source) { - const GLuint shader = glCreateShader(shaderType); - glShaderSource(shader, 1, &source, nullptr); - glCompileShader(shader); - - GLint success = 0; - glGetShaderiv(shader, GL_COMPILE_STATUS, &success); - if (success == GL_TRUE) { - return shader; - } - - GLint logLength = 0; - glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &logLength); - std::string message(static_cast(std::max(logLength, 1)), '\0'); - glGetShaderInfoLog(shader, logLength, nullptr, message.data()); - glDeleteShader(shader); - throw std::runtime_error("Failed to compile splat shader: " + message); -} - -GLuint buildProgram() { - const GLuint vertexShader = compileShader(GL_VERTEX_SHADER, kVertexShaderSource); - const GLuint fragmentShader = compileShader(GL_FRAGMENT_SHADER, kFragmentShaderSource); - const GLuint program = glCreateProgram(); - - glAttachShader(program, vertexShader); - glAttachShader(program, fragmentShader); - glLinkProgram(program); - - glDeleteShader(vertexShader); - glDeleteShader(fragmentShader); - - GLint success = 0; - glGetProgramiv(program, GL_LINK_STATUS, &success); - if (success == GL_TRUE) { - return program; - } - - GLint logLength = 0; - glGetProgramiv(program, GL_INFO_LOG_LENGTH, &logLength); - std::string message(static_cast(std::max(logLength, 1)), '\0'); - glGetProgramInfoLog(program, logLength, nullptr, message.data()); - glDeleteProgram(program); - throw std::runtime_error("Failed to link splat shader program: " + message); -} - -void uploadArray(GLuint& bufferId, const void* data, size_t sizeInBytes) { - if (bufferId == 0) { - glGenBuffers(1, &bufferId); - } - glBindBuffer(GL_ARRAY_BUFFER, bufferId); - glBufferData(GL_ARRAY_BUFFER, static_cast(sizeInBytes), data, GL_STATIC_DRAW); -} - -void copyMatrixToRowMajor(const vtkMatrix4x4* matrix, float out[16]) { - for (int row = 0; row < 4; ++row) { - for (int col = 0; col < 4; ++col) { - out[static_cast(row * 4 + col)] = static_cast(matrix->GetElement(row, col)); - } - } -} - -void transformPoint(const double matrix[16], const std::array& point, double out[3]) { - out[0] = matrix[0] * point[0] + matrix[1] * point[1] + matrix[2] * point[2] + matrix[3]; - out[1] = matrix[4] * point[0] + matrix[5] * point[1] + matrix[6] * point[2] + matrix[7]; - out[2] = matrix[8] * point[0] + matrix[9] * point[1] + matrix[10] * point[2] + matrix[11]; -} - -class NativeSplatProp : public vtkProp3D { - public: - static NativeSplatProp* New(); - vtkTypeMacro(NativeSplatProp, vtkProp3D); - - void SetInputData(std::shared_ptr dataTable) { - this->InputDataTable = std::move(dataTable); - this->RebuildCpuCache(); - this->GpuUploadDirty = true; - this->Modified(); - } - - void SetRenderOptions(const SplatRenderOptions& options) { - this->RenderOptions = options; - this->SetVisibility(options.visible ? 1 : 0); - this->Modified(); - } - - size_t GetSplatCount() const noexcept { return this->SplatCount; } - - double* GetBounds() override { - if (!this->BoundsValid) { - return nullptr; - } - - const double localBounds[6] = { - this->LocalBounds[0], this->LocalBounds[1], this->LocalBounds[2], - this->LocalBounds[3], this->LocalBounds[4], this->LocalBounds[5], - }; - - double modelMatrix[16]; - this->GetMatrix(modelMatrix); - - const std::array corners = { - localBounds[0], localBounds[2], localBounds[4], localBounds[1], localBounds[2], localBounds[4], - localBounds[0], localBounds[3], localBounds[4], localBounds[1], localBounds[3], localBounds[4], - localBounds[0], localBounds[2], localBounds[5], localBounds[1], localBounds[2], localBounds[5], - localBounds[0], localBounds[3], localBounds[5], localBounds[1], localBounds[3], localBounds[5], - }; - - this->WorldBounds = { - std::numeric_limits::max(), std::numeric_limits::lowest(), - std::numeric_limits::max(), std::numeric_limits::lowest(), - std::numeric_limits::max(), std::numeric_limits::lowest(), - }; - - for (size_t i = 0; i < corners.size(); i += 3) { - double transformed[3]; - transformPoint(modelMatrix, {corners[i + 0], corners[i + 1], corners[i + 2]}, transformed); - this->WorldBounds[0] = std::min(this->WorldBounds[0], transformed[0]); - this->WorldBounds[1] = std::max(this->WorldBounds[1], transformed[0]); - this->WorldBounds[2] = std::min(this->WorldBounds[2], transformed[1]); - this->WorldBounds[3] = std::max(this->WorldBounds[3], transformed[1]); - this->WorldBounds[4] = std::min(this->WorldBounds[4], transformed[2]); - this->WorldBounds[5] = std::max(this->WorldBounds[5], transformed[2]); - } - - return this->WorldBounds.data(); - } - - int RenderOpaqueGeometry(vtkViewport*) override { return 0; } - - int RenderTranslucentPolygonalGeometry(vtkViewport* viewport) override { - if (!this->GetVisibility() || this->SplatCount == 0) { - return 0; - } - - auto* renderer = vtkRenderer::SafeDownCast(viewport); - if (renderer == nullptr) { - return 0; - } - - auto* openGLWindow = vtkOpenGLRenderWindow::SafeDownCast(renderer->GetRenderWindow()); - if (openGLWindow == nullptr) { - throw std::runtime_error("SplatVisualizer requires a vtkOpenGLRenderWindow."); - } - - openGLWindow->MakeCurrent(); - this->EnsureProgram(); - this->EnsureGpuBuffers(); - - if (this->ProgramId == 0 || this->VertexArrayId == 0) { - return 0; - } - - auto* state = openGLWindow->GetState(); - state->Push(); - state->vtkglEnable(GL_PROGRAM_POINT_SIZE); - state->vtkglEnable(GL_BLEND); - state->vtkglBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA); - state->SetEnumState(GL_DEPTH_TEST, this->RenderOptions.depthTest); - state->vtkglDepthMask(this->RenderOptions.depthWrite ? GL_TRUE : GL_FALSE); - state->vtkglDisable(GL_CULL_FACE); - - glUseProgram(this->ProgramId); - glBindVertexArray(this->VertexArrayId); - - auto* camera = renderer->GetActiveCamera(); - vtkNew modelMatrix; - vtkNew modelViewMatrix; - this->GetMatrix(modelMatrix); - vtkMatrix4x4::Multiply4x4(camera->GetViewTransformMatrix(), modelMatrix, modelViewMatrix); - - const vtkMatrix4x4* projectionMatrix = camera->GetProjectionTransformMatrix(renderer); - float modelView[16]; - float projection[16]; - copyMatrixToRowMajor(modelViewMatrix, modelView); - copyMatrixToRowMajor(projectionMatrix, projection); - - const int* renderWindowSize = openGLWindow->GetSize(); - const float viewportWidth = static_cast(std::max(renderWindowSize[0], 1)); - const float viewportHeight = static_cast(std::max(renderWindowSize[1], 1)); - - this->UpdateDrawOrder(modelViewMatrix); - - glUniformMatrix4fv(glGetUniformLocation(this->ProgramId, "uModelView"), 1, GL_TRUE, modelView); - glUniformMatrix4fv(glGetUniformLocation(this->ProgramId, "uProjection"), 1, GL_TRUE, projection); - glUniform2f(glGetUniformLocation(this->ProgramId, "uViewportSize"), viewportWidth, viewportHeight); - glUniform1f(glGetUniformLocation(this->ProgramId, "uParallelProjection"), - camera->GetParallelProjection() ? 1.0f : 0.0f); - glUniform1f(glGetUniformLocation(this->ProgramId, "uSizeScale"), this->RenderOptions.sizeScale); - glUniform1f(glGetUniformLocation(this->ProgramId, "uMinPointSize"), this->RenderOptions.minPointSize); - glUniform1f(glGetUniformLocation(this->ProgramId, "uMaxPointSize"), this->RenderOptions.maxPointSize); - glUniform1f(glGetUniformLocation(this->ProgramId, "uGlobalOpacity"), this->RenderOptions.globalOpacity); - glUniform1f(glGetUniformLocation(this->ProgramId, "uAlphaDiscardThreshold"), - this->RenderOptions.alphaDiscardThreshold); - - glDrawElements(GL_POINTS, static_cast(this->SplatCount), GL_UNSIGNED_INT, nullptr); - - glBindVertexArray(0); - glUseProgram(0); - state->Pop(); - return 1; - } - - vtkTypeBool HasTranslucentPolygonalGeometry() override { return this->SplatCount > 0 ? 1 : 0; } - - void ReleaseGraphicsResources(vtkWindow* window) override { - auto* openGLWindow = vtkOpenGLRenderWindow::SafeDownCast(window); - if (openGLWindow == nullptr) { - return; - } - - openGLWindow->MakeCurrent(); - - if (this->ProgramId != 0) { - glDeleteProgram(this->ProgramId); - this->ProgramId = 0; - } - if (this->PositionVboId != 0) { - glDeleteBuffers(1, &this->PositionVboId); - this->PositionVboId = 0; - } - if (this->ColorVboId != 0) { - glDeleteBuffers(1, &this->ColorVboId); - this->ColorVboId = 0; - } - if (this->ScaleVboId != 0) { - glDeleteBuffers(1, &this->ScaleVboId); - this->ScaleVboId = 0; - } - if (this->OpacityVboId != 0) { - glDeleteBuffers(1, &this->OpacityVboId); - this->OpacityVboId = 0; - } - if (this->RotationVboId != 0) { - glDeleteBuffers(1, &this->RotationVboId); - this->RotationVboId = 0; - } - if (this->IndexBufferId != 0) { - glDeleteBuffers(1, &this->IndexBufferId); - this->IndexBufferId = 0; - } - if (this->VertexArrayId != 0) { - glDeleteVertexArrays(1, &this->VertexArrayId); - this->VertexArrayId = 0; - } - - this->GpuUploadDirty = true; - } - - protected: - NativeSplatProp() = default; - ~NativeSplatProp() override = default; - - private: - void EnsureProgram() { - if (this->ProgramId == 0) { - this->ProgramId = buildProgram(); - } - } - - void EnsureGpuBuffers() { - if (!this->GpuUploadDirty || this->SplatCount == 0) { - return; - } - - if (this->VertexArrayId == 0) { - glGenVertexArrays(1, &this->VertexArrayId); - } - - glBindVertexArray(this->VertexArrayId); - - uploadArray(this->PositionVboId, this->Positions.data(), this->Positions.size() * sizeof(float)); - glVertexAttribPointer(kPositionAttribLocation, 3, GL_FLOAT, GL_FALSE, 0, nullptr); - glEnableVertexAttribArray(kPositionAttribLocation); - - uploadArray(this->ColorVboId, this->Colors.data(), this->Colors.size() * sizeof(float)); - glVertexAttribPointer(kColorAttribLocation, 3, GL_FLOAT, GL_FALSE, 0, nullptr); - glEnableVertexAttribArray(kColorAttribLocation); - - uploadArray(this->ScaleVboId, this->LogScales.data(), this->LogScales.size() * sizeof(float)); - glVertexAttribPointer(kScaleAttribLocation, 3, GL_FLOAT, GL_FALSE, 0, nullptr); - glEnableVertexAttribArray(kScaleAttribLocation); - - uploadArray(this->OpacityVboId, this->Opacities.data(), this->Opacities.size() * sizeof(float)); - glVertexAttribPointer(kOpacityAttribLocation, 1, GL_FLOAT, GL_FALSE, 0, nullptr); - glEnableVertexAttribArray(kOpacityAttribLocation); - - uploadArray(this->RotationVboId, this->Rotations.data(), this->Rotations.size() * sizeof(float)); - glVertexAttribPointer(kRotationAttribLocation, 4, GL_FLOAT, GL_FALSE, 0, nullptr); - glEnableVertexAttribArray(kRotationAttribLocation); - - glBindVertexArray(0); - glBindBuffer(GL_ARRAY_BUFFER, 0); - - this->GpuUploadDirty = false; - } - - void UpdateDrawOrder(vtkMatrix4x4* modelViewMatrix) { - if (this->SplatCount == 0) { - return; - } - - this->DrawIndices.resize(this->SplatCount); - this->DepthKeys.resize(this->SplatCount); - - for (size_t i = 0; i < this->SplatCount; ++i) { - const size_t base = i * 3; - const float x = this->Positions[base + 0]; - const float y = this->Positions[base + 1]; - const float z = this->Positions[base + 2]; - - this->DepthKeys[i] = static_cast(modelViewMatrix->GetElement(2, 0) * x + - modelViewMatrix->GetElement(2, 1) * y + - modelViewMatrix->GetElement(2, 2) * z + - modelViewMatrix->GetElement(2, 3)); - this->DrawIndices[i] = static_cast(i); - } - - std::stable_sort(this->DrawIndices.begin(), this->DrawIndices.end(), - [this](unsigned int lhs, unsigned int rhs) { return this->DepthKeys[lhs] < this->DepthKeys[rhs]; }); - - if (this->IndexBufferId == 0) { - glGenBuffers(1, &this->IndexBufferId); - } - - glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, this->IndexBufferId); - glBufferData(GL_ELEMENT_ARRAY_BUFFER, static_cast(this->DrawIndices.size() * sizeof(unsigned int)), - this->DrawIndices.data(), GL_DYNAMIC_DRAW); - } - - void RebuildCpuCache() { - this->Positions.clear(); - this->Colors.clear(); - this->LogScales.clear(); - this->Opacities.clear(); - this->Rotations.clear(); - this->DrawIndices.clear(); - this->DepthKeys.clear(); - this->SplatCount = 0; - this->BoundsValid = false; - this->LocalBounds = {1.0, -1.0, 1.0, -1.0, 1.0, -1.0}; - this->WorldBounds = this->LocalBounds; - - if (!this->InputDataTable || this->InputDataTable->getNumRows() == 0) { - return; - } - - this->Positions = extractVec3Columns(*this->InputDataTable, kPositionColumns); - this->Colors = extractVec3Columns(*this->InputDataTable, kColorColumns); - this->LogScales = extractVec3Columns(*this->InputDataTable, kScaleColumns); - this->Opacities = extractScalarColumn(*this->InputDataTable, "opacity"); - this->Rotations = extractQuaternionColumns(*this->InputDataTable, kRotationColumns); - this->SplatCount = this->InputDataTable->getNumRows(); - - this->LocalBounds = computeSplatBounds(*this->InputDataTable, this->Positions); - this->BoundsValid = isValidBounds(this->LocalBounds); - } - - std::shared_ptr InputDataTable; - SplatRenderOptions RenderOptions; - std::vector Positions; - std::vector Colors; - std::vector LogScales; - std::vector Opacities; - std::vector Rotations; - std::vector DrawIndices; - std::vector DepthKeys; - size_t SplatCount{0}; - bool GpuUploadDirty{true}; - bool BoundsValid{false}; - std::array LocalBounds{1.0, -1.0, 1.0, -1.0, 1.0, -1.0}; - std::array WorldBounds{1.0, -1.0, 1.0, -1.0, 1.0, -1.0}; - GLuint ProgramId{0}; - GLuint VertexArrayId{0}; - GLuint PositionVboId{0}; - GLuint ColorVboId{0}; - GLuint ScaleVboId{0}; - GLuint OpacityVboId{0}; - GLuint RotationVboId{0}; - GLuint IndexBufferId{0}; -}; - -vtkStandardNewMacro(NativeSplatProp); - struct CloudEntry { std::shared_ptr dataTable; - vtkSmartPointer prop; + vtkSmartPointer prop; SplatRenderOptions options; }; @@ -779,6 +152,28 @@ class SplatVisualizer::Impl { keyObserver(vtkSmartPointer::New()), mouseObserver(vtkSmartPointer::New()), exitObserver(vtkSmartPointer::New()) { + this->initializeVtkObjects(); + } + + Impl(vtkRenderer* externalRenderer, vtkRenderWindow* externalRenderWindow, + vtkRenderWindowInteractor* externalInteractor, std::string name) + : windowName(std::move(name)), + renderer(externalRenderer), + renderWindow(externalRenderWindow), + interactor(externalInteractor), + interactorStyle(vtkSmartPointer::New()), + axesActor(vtkSmartPointer::New()), + axesWidget(vtkSmartPointer::New()), + keyObserver(vtkSmartPointer::New()), + mouseObserver(vtkSmartPointer::New()), + exitObserver(vtkSmartPointer::New()) { + if (this->renderer == nullptr || this->renderWindow == nullptr || this->interactor == nullptr) { + throw std::invalid_argument("SplatVisualizer external VTK objects must not be null."); + } + this->initializeVtkObjects(); + } + + void initializeVtkObjects() { this->renderer->SetBackground(this->backgroundColor[0], this->backgroundColor[1], this->backgroundColor[2]); this->renderWindow->AddRenderer(this->renderer); this->renderWindow->SetWindowName(this->windowName.c_str()); @@ -802,24 +197,24 @@ class SplatVisualizer::Impl { this->mouseObserver->SetClientData(this); this->mouseObserver->SetCallback(&Impl::HandleMouseEvent); - this->interactor->AddObserver(vtkCommand::MouseMoveEvent, this->mouseObserver); - this->interactor->AddObserver(vtkCommand::LeftButtonPressEvent, this->mouseObserver); - this->interactor->AddObserver(vtkCommand::LeftButtonReleaseEvent, this->mouseObserver); - this->interactor->AddObserver(vtkCommand::MiddleButtonPressEvent, this->mouseObserver); - this->interactor->AddObserver(vtkCommand::MiddleButtonReleaseEvent, this->mouseObserver); - this->interactor->AddObserver(vtkCommand::RightButtonPressEvent, this->mouseObserver); - this->interactor->AddObserver(vtkCommand::RightButtonReleaseEvent, this->mouseObserver); - this->interactor->AddObserver(vtkCommand::FourthButtonPressEvent, this->mouseObserver); - this->interactor->AddObserver(vtkCommand::FourthButtonReleaseEvent, this->mouseObserver); - this->interactor->AddObserver(vtkCommand::FifthButtonPressEvent, this->mouseObserver); - this->interactor->AddObserver(vtkCommand::FifthButtonReleaseEvent, this->mouseObserver); - this->interactor->AddObserver(vtkCommand::LeftButtonDoubleClickEvent, this->mouseObserver); - this->interactor->AddObserver(vtkCommand::MiddleButtonDoubleClickEvent, this->mouseObserver); - this->interactor->AddObserver(vtkCommand::RightButtonDoubleClickEvent, this->mouseObserver); - this->interactor->AddObserver(vtkCommand::MouseWheelForwardEvent, this->mouseObserver); - this->interactor->AddObserver(vtkCommand::MouseWheelBackwardEvent, this->mouseObserver); - this->interactor->AddObserver(vtkCommand::MouseWheelLeftEvent, this->mouseObserver); - this->interactor->AddObserver(vtkCommand::MouseWheelRightEvent, this->mouseObserver); + this->interactor->AddObserver(vtkCommand::MouseMoveEvent, this->mouseObserver, 1.0f); + this->interactor->AddObserver(vtkCommand::LeftButtonPressEvent, this->mouseObserver, 1.0f); + this->interactor->AddObserver(vtkCommand::LeftButtonReleaseEvent, this->mouseObserver, 1.0f); + this->interactor->AddObserver(vtkCommand::MiddleButtonPressEvent, this->mouseObserver, 1.0f); + this->interactor->AddObserver(vtkCommand::MiddleButtonReleaseEvent, this->mouseObserver, 1.0f); + this->interactor->AddObserver(vtkCommand::RightButtonPressEvent, this->mouseObserver, 1.0f); + this->interactor->AddObserver(vtkCommand::RightButtonReleaseEvent, this->mouseObserver, 1.0f); + this->interactor->AddObserver(vtkCommand::FourthButtonPressEvent, this->mouseObserver, 1.0f); + this->interactor->AddObserver(vtkCommand::FourthButtonReleaseEvent, this->mouseObserver, 1.0f); + this->interactor->AddObserver(vtkCommand::FifthButtonPressEvent, this->mouseObserver, 1.0f); + this->interactor->AddObserver(vtkCommand::FifthButtonReleaseEvent, this->mouseObserver, 1.0f); + this->interactor->AddObserver(vtkCommand::LeftButtonDoubleClickEvent, this->mouseObserver, 1.0f); + this->interactor->AddObserver(vtkCommand::MiddleButtonDoubleClickEvent, this->mouseObserver, 1.0f); + this->interactor->AddObserver(vtkCommand::RightButtonDoubleClickEvent, this->mouseObserver, 1.0f); + this->interactor->AddObserver(vtkCommand::MouseWheelForwardEvent, this->mouseObserver, 1.0f); + this->interactor->AddObserver(vtkCommand::MouseWheelBackwardEvent, this->mouseObserver, 1.0f); + this->interactor->AddObserver(vtkCommand::MouseWheelLeftEvent, this->mouseObserver, 1.0f); + this->interactor->AddObserver(vtkCommand::MouseWheelRightEvent, this->mouseObserver, 1.0f); this->exitObserver->SetClientData(this); this->exitObserver->SetCallback(&Impl::HandleExitEvent); @@ -884,6 +279,9 @@ class SplatVisualizer::Impl { return; } + const bool isWheelEvent = makeMouseAction(eventId) == MouseAction::Wheel; + this->mouseObserver->AbortFlagOff(); + const int* eventPosition = vtkInteractor->GetEventPosition(); const int* lastEventPosition = vtkInteractor->GetLastEventPosition(); @@ -909,6 +307,10 @@ class SplatVisualizer::Impl { callback(event); } } + + if (isWheelEvent && !this->defaultMouseWheelEnabled) { + this->mouseObserver->AbortFlagOn(); + } } void requestClose() { @@ -976,12 +378,17 @@ class SplatVisualizer::Impl { double axesLength{1.0}; bool axesEnabled{true}; bool defaultHotkeysEnabled{true}; + bool defaultMouseWheelEnabled{true}; bool initialized{false}; bool stopped{false}; }; SplatVisualizer::SplatVisualizer(std::string windowName) : impl_(std::make_unique(std::move(windowName))) {} +SplatVisualizer::SplatVisualizer(vtkRenderer* renderer, vtkRenderWindow* renderWindow, + vtkRenderWindowInteractor* interactor, std::string windowName) + : impl_(std::make_unique(renderer, renderWindow, interactor, std::move(windowName))) {} + SplatVisualizer::~SplatVisualizer() = default; SplatVisualizer::SplatVisualizer(SplatVisualizer&&) noexcept = default; @@ -1000,7 +407,7 @@ bool SplatVisualizer::addSplatCloud(std::shared_ptr dataTable, return false; } - auto prop = vtkSmartPointer::New(); + auto prop = vtkSmartPointer::New(); prop->SetInputData(dataTable); prop->SetRenderOptions(options); @@ -1123,8 +530,44 @@ void SplatVisualizer::setDefaultHotkeysEnabled(bool enabled) { this->impl_->defa bool SplatVisualizer::getDefaultHotkeysEnabled() const { return this->impl_->defaultHotkeysEnabled; } +void SplatVisualizer::setDefaultMouseWheelEnabled(bool enabled) { this->impl_->defaultMouseWheelEnabled = enabled; } + +bool SplatVisualizer::getDefaultMouseWheelEnabled() const { return this->impl_->defaultMouseWheelEnabled; } + void SplatVisualizer::resetCamera() { this->impl_->renderer->ResetCamera(); } +void SplatVisualizer::resetCameraToBounds(const double bounds[6]) { + if (bounds == nullptr) { + this->resetCamera(); + return; + } + + const double dx = bounds[1] - bounds[0]; + const double dy = bounds[3] - bounds[2]; + const double dz = bounds[5] - bounds[4]; + const double radius = std::max(0.01, 0.5 * std::sqrt(dx * dx + dy * dy + dz * dz)); + const double center[3] = { + 0.5 * (bounds[0] + bounds[1]), + 0.5 * (bounds[2] + bounds[3]), + 0.5 * (bounds[4] + bounds[5]), + }; + + const int* size = this->impl_->renderWindow->GetSize(); + const double aspect = static_cast(std::max(size[0], 1)) / static_cast(std::max(size[1], 1)); + const double verticalHalfFov = (kPlayCanvasFovYDegrees * kPi / 180.0) * 0.5; + const double horizontalHalfFov = std::atan(std::tan(verticalHalfFov) * aspect); + const double fitHalfFov = std::max(0.001, std::min(verticalHalfFov, horizontalHalfFov)); + const double distance = std::max((radius * 1.5) / std::sin(fitHalfFov), 1.0); + + auto* camera = this->impl_->renderer->GetActiveCamera(); + camera->SetViewAngle(kPlayCanvasFovYDegrees); + camera->SetPosition(center[0], center[1], center[2] + distance); + camera->SetFocalPoint(center[0], center[1], center[2]); + camera->SetViewUp(0.0, -1.0, 0.0); + camera->SetClippingRange(std::max(std::min(radius * 0.0005, 0.01), 0.0001), + std::max({distance + radius * 4.0, radius * 32.0, 10.0})); +} + void SplatVisualizer::render() { this->impl_->ensureInitialized(); this->impl_->renderWindow->Render(); @@ -1140,14 +583,11 @@ void SplatVisualizer::spin() { void SplatVisualizer::spinOnce(int time, bool forceRedraw) { this->impl_->ensureInitialized(); - if (forceRedraw || this->impl_->renderWindow->GetNeverRendered()) { - this->impl_->renderWindow->Render(); - } this->impl_->interactor->ProcessEvents(); if (time > 0) { std::this_thread::sleep_for(std::chrono::milliseconds(time)); } - if (forceRedraw) { + if (forceRedraw || this->impl_->renderWindow->GetNeverRendered()) { this->impl_->renderWindow->Render(); } this->impl_->stopped = this->impl_->stopped || this->impl_->interactor->GetDone(); diff --git a/src/voxel/gpu-voxelization-mac.mm b/src/voxel/gpu-voxelization-mac.mm new file mode 100644 index 0000000..6f11200 --- /dev/null +++ b/src/voxel/gpu-voxelization-mac.mm @@ -0,0 +1,408 @@ +#import +#import + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace splat { + +namespace { + +struct BatchInfoHost { + std::uint32_t index_offset; + std::uint32_t index_count; + std::uint32_t num_blocks_x; + std::uint32_t num_blocks_y; + std::uint32_t num_blocks_z; + float block_min_x; + float block_min_y; + float block_min_z; +}; + +struct KernelParams { + float opacity_cutoff; + float voxel_resolution; + std::uint32_t max_blocks_per_batch; + std::uint32_t _pad; +}; + +static_assert(sizeof(BatchInfoHost) == 32, "BatchInfoHost layout"); +static_assert(sizeof(KernelParams) == 16, "KernelParams layout"); + +struct MetalVoxelContext { + id device = nil; + id queue = nil; + id library = nil; + id pipeline = nil; + id gaussians_buffer = nil; + size_t gaussians_bytes = 0; + + ~MetalVoxelContext() { + if (gaussians_buffer) { + [gaussians_buffer release]; + gaussians_buffer = nil; + } + if (pipeline) { + [pipeline release]; + pipeline = nil; + } + if (library) { + [library release]; + library = nil; + } + if (queue) { + [queue release]; + queue = nil; + } + if (device) { + [device release]; + device = nil; + } + } +}; + +static std::string metal_shader_path() { + std::string self = __FILE__; + const size_t slash = self.find_last_of('/'); + if (slash == std::string::npos) { + return "gpu-voxelization-metal.metal"; + } + return self.substr(0, slash + 1) + "gpu-voxelization-metal.metal"; +} + +static std::string load_file_text(const std::string& path) { + std::ifstream file(path, std::ios::in | std::ios::binary); + if (!file) { + throw std::runtime_error("GpuVoxelization(mac): cannot open Metal shader: " + path); + } + file.seekg(0, std::ios::end); + const std::streamsize size = file.tellg(); + file.seekg(0, std::ios::beg); + std::string text; + text.resize(static_cast(size)); + if (size > 0) { + file.read(&text[0], size); + } + return text; +} + +static MetalVoxelContext* context_from_stream(void* stream) { + return static_cast(stream); +} + +static const MetalVoxelContext* context_from_stream(const void* stream) { + return static_cast(stream); +} + +} // namespace + +bool gpuVoxelizationIsAvailable() noexcept { + id device = MTLCreateSystemDefaultDevice(); + if (device) { + [device release]; + return true; + } + return false; +} + +GpuVoxelization::GpuVoxelization(int cuda_device_index) : cuda_device_index_(cuda_device_index) { + if (cuda_device_index_ < 0) { + throw std::runtime_error("GpuVoxelization(mac): invalid device index"); + } + + MetalVoxelContext* ctx = new MetalVoxelContext(); + ctx->device = MTLCreateSystemDefaultDevice(); + if (!ctx->device) { + delete ctx; + throw std::runtime_error("GpuVoxelization(mac): no Metal device"); + } + + ctx->queue = [ctx->device newCommandQueue]; + if (!ctx->queue) { + delete ctx; + throw std::runtime_error("GpuVoxelization(mac): failed to create command queue"); + } + + NSError* error = nil; + const std::string shader_text = load_file_text(metal_shader_path()); + NSString* source = [NSString stringWithUTF8String:shader_text.c_str()]; + if (!source) { + delete ctx; + throw std::runtime_error("GpuVoxelization(mac): failed to prepare Metal shader source"); + } + + MTLCompileOptions* options = [[MTLCompileOptions alloc] init]; + options.fastMathEnabled = YES; + ctx->library = [ctx->device newLibraryWithSource:source options:options error:&error]; + [options release]; + if (!ctx->library) { + const char* err = error ? [[error localizedDescription] UTF8String] : "unknown"; + delete ctx; + throw std::runtime_error(std::string("GpuVoxelization(mac): Metal library compile failed: ") + err); + } + + id fn = [ctx->library newFunctionWithName:@"voxelize_multibatch_kernel"]; + if (!fn) { + delete ctx; + throw std::runtime_error("GpuVoxelization(mac): missing kernel voxelize_multibatch_kernel"); + } + + ctx->pipeline = [ctx->device newComputePipelineStateWithFunction:fn error:&error]; + [fn release]; + if (!ctx->pipeline) { + const char* err = error ? [[error localizedDescription] UTF8String] : "unknown"; + delete ctx; + throw std::runtime_error(std::string("GpuVoxelization(mac): failed to create pipeline: ") + err); + } + + stream_ = ctx; +} + +void GpuVoxelization::freeSlot(SlotBuffers& s) { + s.d_indices = nullptr; + s.d_results = nullptr; + s.d_batch_infos = nullptr; + s.index_cap = 0; + s.results_byte_cap = 0; + s.batch_info_bytes_cap = 0; +} + +void GpuVoxelization::destroyStream() { + if (stream_) { + MetalVoxelContext* ctx = context_from_stream(stream_); + delete ctx; + stream_ = nullptr; + } +} + +GpuVoxelization::~GpuVoxelization() { + freeSlot(slots_[0]); + freeSlot(slots_[1]); + delete[] d_gaussians_; + d_gaussians_ = nullptr; + gaussian_buffer_floats_ = 0; + destroyStream(); +} + +GpuVoxelization::GpuVoxelization(GpuVoxelization&& o) noexcept + : cuda_device_index_(o.cuda_device_index_), + stream_(o.stream_), + d_gaussians_(o.d_gaussians_), + gaussian_buffer_floats_(o.gaussian_buffer_floats_), + num_gaussians_(o.num_gaussians_) { + slots_[0] = o.slots_[0]; + slots_[1] = o.slots_[1]; + o.stream_ = nullptr; + o.d_gaussians_ = nullptr; + o.gaussian_buffer_floats_ = 0; + o.num_gaussians_ = 0; + o.slots_[0] = {}; + o.slots_[1] = {}; +} + +GpuVoxelization& GpuVoxelization::operator=(GpuVoxelization&& o) noexcept { + if (this != &o) { + freeSlot(slots_[0]); + freeSlot(slots_[1]); + delete[] d_gaussians_; + destroyStream(); + + cuda_device_index_ = o.cuda_device_index_; + stream_ = o.stream_; + d_gaussians_ = o.d_gaussians_; + gaussian_buffer_floats_ = o.gaussian_buffer_floats_; + num_gaussians_ = o.num_gaussians_; + slots_[0] = o.slots_[0]; + slots_[1] = o.slots_[1]; + + o.stream_ = nullptr; + o.d_gaussians_ = nullptr; + o.gaussian_buffer_floats_ = 0; + o.num_gaussians_ = 0; + o.slots_[0] = {}; + o.slots_[1] = {}; + } + return *this; +} + +void GpuVoxelization::uploadAllGaussians(const DataTable& data_table, const DataTable& extents) { + const size_t n = data_table.getNumRows(); + if (extents.getNumRows() != n) { + throw std::invalid_argument("GpuVoxelization::uploadAllGaussians: extents row count must match data_table"); + } + + const auto& x = data_table.getColumnByName("x").asVector(); + const auto& y = data_table.getColumnByName("y").asVector(); + const auto& z = data_table.getColumnByName("z").asVector(); + const auto& opacity = data_table.getColumnByName("opacity").asVector(); + const auto& rot_w = data_table.getColumnByName("rot_0").asVector(); + const auto& rot_x = data_table.getColumnByName("rot_1").asVector(); + const auto& rot_y = data_table.getColumnByName("rot_2").asVector(); + const auto& rot_z = data_table.getColumnByName("rot_3").asVector(); + const auto& scale_x = data_table.getColumnByName("scale_0").asVector(); + const auto& scale_y = data_table.getColumnByName("scale_1").asVector(); + const auto& scale_z = data_table.getColumnByName("scale_2").asVector(); + const auto& extent_x = extents.getColumnByName("extent_x").asVector(); + const auto& extent_y = extents.getColumnByName("extent_y").asVector(); + const auto& extent_z = extents.getColumnByName("extent_z").asVector(); + + const size_t need_floats = n * static_cast(kFloatsPerGaussian); + if (need_floats > gaussian_buffer_floats_) { + delete[] d_gaussians_; + d_gaussians_ = new float[need_floats]; + gaussian_buffer_floats_ = need_floats; + } + + for (size_t i = 0; i < n; ++i) { + const size_t o = i * static_cast(kFloatsPerGaussian); + d_gaussians_[o + 0] = x[i]; + d_gaussians_[o + 1] = y[i]; + d_gaussians_[o + 2] = z[i]; + d_gaussians_[o + 3] = opacity[i]; + float qw = rot_w[i], qx = rot_x[i], qy = rot_y[i], qz = rot_z[i]; + const float qlen = std::sqrt(qw * qw + qx * qx + qy * qy + qz * qz); + const float inv_len = qlen > 0.f ? 1.f / qlen : 0.f; + d_gaussians_[o + 4] = qw * inv_len; + d_gaussians_[o + 5] = qx * inv_len; + d_gaussians_[o + 6] = qy * inv_len; + d_gaussians_[o + 7] = qz * inv_len; + d_gaussians_[o + 8] = scale_x[i]; + d_gaussians_[o + 9] = scale_y[i]; + d_gaussians_[o + 10] = scale_z[i]; + d_gaussians_[o + 11] = extent_x[i]; + d_gaussians_[o + 12] = extent_y[i]; + d_gaussians_[o + 13] = extent_z[i]; + d_gaussians_[o + 14] = 0.f; + d_gaussians_[o + 15] = 0.f; + } + + MetalVoxelContext* ctx = context_from_stream(stream_); + const size_t bytes = need_floats * sizeof(float); + if (ctx->gaussians_buffer) { + [ctx->gaussians_buffer release]; + ctx->gaussians_buffer = nil; + } + ctx->gaussians_buffer = [ctx->device newBufferWithBytes:d_gaussians_ length:bytes options:MTLResourceStorageModeShared]; + if (!ctx->gaussians_buffer) { + throw std::runtime_error("GpuVoxelization(mac): failed to create gaussians buffer"); + } + ctx->gaussians_bytes = bytes; + num_gaussians_ = static_cast(n); +} + +MultiBatchResult GpuVoxelization::submitMultiBatch(int slot_index, const std::vector& concatenated_indices, + size_t total_indices, const std::vector& batches, + float voxel_resolution, float opacity_cutoff) { + if (slot_index < 0 || slot_index >= kNumSlots) { + throw std::out_of_range("GpuVoxelization::submitMultiBatch: invalid slot_index"); + } + if (!d_gaussians_ || num_gaussians_ <= 0) { + throw std::runtime_error("GpuVoxelization::submitMultiBatch: call uploadAllGaussians first"); + } + if (total_indices > concatenated_indices.size()) { + throw std::invalid_argument("GpuVoxelization::submitMultiBatch: total_indices exceeds buffer"); + } + + MultiBatchResult out; + out.max_blocks_per_batch = kMaxBlocksPerBatch; + + const int num_batches = static_cast(batches.size()); + if (num_batches == 0) { + return out; + } + + MetalVoxelContext* ctx = context_from_stream(stream_); + if (!ctx || !ctx->gaussians_buffer) { + throw std::runtime_error("GpuVoxelization(mac): gaussians Metal buffer is not initialized"); + } + + const size_t index_bytes = total_indices * sizeof(std::uint32_t); + const size_t batch_bytes = static_cast(num_batches) * sizeof(BatchInfoHost); + const size_t results_u32_count = static_cast(num_batches) * kMaxBlocksPerBatch * 2u; + const size_t results_bytes = results_u32_count * sizeof(std::uint32_t); + + std::vector batch_host(static_cast(num_batches)); + for (int i = 0; i < num_batches; ++i) { + const BatchSpec& b = batches[static_cast(i)]; + batch_host[static_cast(i)].index_offset = b.index_offset; + batch_host[static_cast(i)].index_count = b.index_count; + batch_host[static_cast(i)].num_blocks_x = b.num_blocks_x; + batch_host[static_cast(i)].num_blocks_y = b.num_blocks_y; + batch_host[static_cast(i)].num_blocks_z = b.num_blocks_z; + batch_host[static_cast(i)].block_min_x = b.block_min_x; + batch_host[static_cast(i)].block_min_y = b.block_min_y; + batch_host[static_cast(i)].block_min_z = b.block_min_z; + } + + id indices_buffer = [ctx->device newBufferWithBytes:concatenated_indices.data() + length:index_bytes + options:MTLResourceStorageModeShared]; + if (!indices_buffer) { + throw std::runtime_error("GpuVoxelization(mac): failed to create indices buffer"); + } + + id batch_buffer = [ctx->device newBufferWithBytes:batch_host.data() + length:batch_bytes + options:MTLResourceStorageModeShared]; + if (!batch_buffer) { + [indices_buffer release]; + throw std::runtime_error("GpuVoxelization(mac): failed to create batch buffer"); + } + + id results_buffer = [ctx->device newBufferWithLength:results_bytes options:MTLResourceStorageModeShared]; + if (!results_buffer) { + [batch_buffer release]; + [indices_buffer release]; + throw std::runtime_error("GpuVoxelization(mac): failed to create results buffer"); + } + std::memset([results_buffer contents], 0, results_bytes); + + KernelParams params{}; + params.opacity_cutoff = opacity_cutoff; + params.voxel_resolution = voxel_resolution; + params.max_blocks_per_batch = kMaxBlocksPerBatch; + + id command_buffer = [ctx->queue commandBuffer]; + id encoder = [command_buffer computeCommandEncoder]; + [encoder setComputePipelineState:ctx->pipeline]; + [encoder setBuffer:ctx->gaussians_buffer offset:0 atIndex:0]; + [encoder setBuffer:indices_buffer offset:0 atIndex:1]; + [encoder setBuffer:results_buffer offset:0 atIndex:2]; + [encoder setBuffer:batch_buffer offset:0 atIndex:3]; + [encoder setBytes:¶ms length:sizeof(params) atIndex:4]; + + const MTLSize tg_size = MTLSizeMake(64, 1, 1); + const MTLSize tg_count = MTLSizeMake(static_cast(kMaxBlocksPerBatch), static_cast(num_batches), 1); + [encoder dispatchThreadgroups:tg_count threadsPerThreadgroup:tg_size]; + [encoder endEncoding]; + + [command_buffer commit]; + [command_buffer waitUntilCompleted]; + + if (command_buffer.status == MTLCommandBufferStatusError) { + const char* err = command_buffer.error ? [[command_buffer.error localizedDescription] UTF8String] : "unknown"; + [results_buffer release]; + [batch_buffer release]; + [indices_buffer release]; + throw std::runtime_error(std::string("GpuVoxelization(mac): command buffer failed: ") + err); + } + + out.masks.resize(results_u32_count); + std::memcpy(out.masks.data(), [results_buffer contents], results_bytes); + + [results_buffer release]; + [batch_buffer release]; + [indices_buffer release]; + + return out; +} + +} // namespace splat diff --git a/src/voxel/gpu-voxelization-metal.metal b/src/voxel/gpu-voxelization-metal.metal new file mode 100644 index 0000000..2ce3749 --- /dev/null +++ b/src/voxel/gpu-voxelization-metal.metal @@ -0,0 +1,151 @@ +#include +using namespace metal; + +constexpr uint kTileSize = 64u; + +struct Gaussian { + float pos_x; + float pos_y; + float pos_z; + float opacity_logit; + float rot_w; + float rot_x; + float rot_y; + float rot_z; + float scale_x; + float scale_y; + float scale_z; + float extent_x; + float extent_y; + float extent_z; + float _pad0; + float _pad1; +}; + +struct BatchInfoGpu { + uint index_offset; + uint index_count; + uint num_blocks_x; + uint num_blocks_y; + uint num_blocks_z; + float block_min_x; + float block_min_y; + float block_min_z; +}; + +struct KernelParams { + float opacity_cutoff; + float voxel_resolution; + uint max_blocks_per_batch; + uint _pad; +}; + +inline uint3 morton_to_xyz(uint m) { + return uint3((m & 1u) | ((m >> 2u) & 2u), ((m >> 1u) & 1u) | ((m >> 3u) & 2u), + ((m >> 2u) & 1u) | ((m >> 4u) & 2u)); +} + +inline float3 cross3(float3 a, float3 b) { + return float3(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x); +} + +inline float evaluate_gaussian_for_voxel(float3 voxel_center, float voxel_half_size, thread const Gaussian& g) { + const float3 gc = float3(g.pos_x, g.pos_y, g.pos_z); + const float3 diff = voxel_center - gc; + const float3 ext = float3(g.extent_x, g.extent_y, g.extent_z); + if (fabs(diff.x) > ext.x + voxel_half_size || fabs(diff.y) > ext.y + voxel_half_size || + fabs(diff.z) > ext.z + voxel_half_size) { + return 0.f; + } + + const float3 vmin = voxel_center - float3(voxel_half_size); + const float3 vmax = voxel_center + float3(voxel_half_size); + const float3 closest = clamp(gc, vmin, vmax); + const float3 closest_diff = closest - gc; + + const float3 qxyz = float3(-g.rot_x, -g.rot_y, -g.rot_z); + const float3 cross0 = cross3(qxyz, closest_diff); + const float3 t = 2.f * cross0; + const float3 t2 = cross3(qxyz, t); + const float3 local_diff = closest_diff + g.rot_w * t + t2; + + const float inv_sx = exp(-g.scale_x); + const float inv_sy = exp(-g.scale_y); + const float inv_sz = exp(-g.scale_z); + const float sx = local_diff.x * inv_sx; + const float sy = local_diff.y * inv_sy; + const float sz = local_diff.z * inv_sz; + const float d2 = sx * sx + sy * sy + sz * sz; + const float opacity = 1.f / (1.f + exp(-g.opacity_logit)); + return opacity * exp(-0.5f * d2); +} + +kernel void voxelize_multibatch_kernel(device const Gaussian* all_gaussians [[buffer(0)]], + device const uint* indices [[buffer(1)]], + device atomic_uint* results [[buffer(2)]], + device const BatchInfoGpu* batch_infos [[buffer(3)]], + constant KernelParams& params [[buffer(4)]], + uint voxel_idx [[thread_index_in_threadgroup]], + uint3 group_pos [[threadgroup_position_in_grid]]) { + threadgroup Gaussian shared_gaussians[kTileSize]; + + const uint flat_block_id = group_pos.x; + const uint batch_idx = group_pos.y; + + const BatchInfoGpu info = batch_infos[batch_idx]; + const uint total_blocks = info.num_blocks_x * info.num_blocks_y * info.num_blocks_z; + if (flat_block_id >= total_blocks) { + return; + } + + const uint block_x = flat_block_id % info.num_blocks_x; + const uint block_y = (flat_block_id / info.num_blocks_x) % info.num_blocks_y; + const uint block_z = flat_block_id / (info.num_blocks_x * info.num_blocks_y); + + const uint3 local_pos = morton_to_xyz(voxel_idx); + const float3 block_min = float3(info.block_min_x, info.block_min_y, info.block_min_z); + const float br = params.voxel_resolution * 4.f; + const float3 block_offset = float3(float(block_x) * br, float(block_y) * br, float(block_z) * br); + const float3 voxel_center = + float3(block_min.x + block_offset.x + (float(local_pos.x) + 0.5f) * params.voxel_resolution, + block_min.y + block_offset.y + (float(local_pos.y) + 0.5f) * params.voxel_resolution, + block_min.z + block_offset.z + (float(local_pos.z) + 0.5f) * params.voxel_resolution); + const float voxel_half_size = params.voxel_resolution * 0.5f; + + float total_sigma = 0.f; + const uint num_indices = info.index_count; + const uint num_tiles = (num_indices + kTileSize - 1u) / kTileSize; + + for (uint tile = 0u; tile < num_tiles; ++tile) { + const uint load_idx = tile * kTileSize + voxel_idx; + if (load_idx < num_indices) { + const uint gaussian_idx = indices[info.index_offset + load_idx]; + shared_gaussians[voxel_idx] = all_gaussians[gaussian_idx]; + } + threadgroup_barrier(mem_flags::mem_threadgroup); + + if (total_sigma < 7.f) { + const uint rem = num_indices - tile * kTileSize; + const uint this_tile_size = rem < kTileSize ? rem : kTileSize; + for (uint c = 0u; c < this_tile_size; ++c) { + total_sigma += evaluate_gaussian_for_voxel(voxel_center, voxel_half_size, shared_gaussians[c]); + if (total_sigma >= 7.f) { + break; + } + } + } + threadgroup_barrier(mem_flags::mem_threadgroup); + } + + const float final_opacity = 1.f - exp(-total_sigma); + if (final_opacity < params.opacity_cutoff) { + return; + } + + const uint linear_idx = local_pos.z * 16u + local_pos.y * 4u + local_pos.x; + const uint result_base = batch_idx * params.max_blocks_per_batch * 2u; + const uint word_index = result_base + flat_block_id * 2u + (linear_idx >> 5u); + const uint bit_index = linear_idx & 31u; + + atomic_fetch_or_explicit(&(results[word_index]), (1u << bit_index), memory_order_relaxed); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..7923cf0 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,51 @@ +add_executable(SplatKdTreeTests kdtree_test.cpp) +target_link_libraries(SplatKdTreeTests PRIVATE SPLAT::splat) + +add_executable(SplatDecimateTests decimate_test.cpp) +target_link_libraries(SplatDecimateTests PRIVATE SPLAT::splat) + +add_executable(SplatVisualizerDataSemanticsTests visualization/splat_visualizer_data_semantics_test.cpp) +target_link_libraries(SplatVisualizerDataSemanticsTests PRIVATE SPLAT::splat) + +add_executable(SplatVisualizerSmokeTests visualization/splat_visualizer_smoke_test.cpp) +target_link_libraries(SplatVisualizerSmokeTests PRIVATE SPLAT::splat) + +add_executable(SplatGaussianPropTests visualization/splat_gaussian_prop_test.cpp) +target_include_directories(SplatGaussianPropTests PRIVATE ${PROJECT_SOURCE_DIR}/src/visualization) +target_link_libraries(SplatGaussianPropTests PRIVATE SPLAT::splat) + +add_executable(SplatVisualizationPublicHeadersTests visualization/visualization_public_headers_test.cpp) +target_link_libraries(SplatVisualizationPublicHeadersTests PRIVATE SPLAT::splat) + +add_executable(GSplatDataAdapterTests visualization/gsplat_data_adapter_test.cpp) +target_include_directories(GSplatDataAdapterTests PRIVATE ${PROJECT_SOURCE_DIR}/src/visualization) +target_link_libraries(GSplatDataAdapterTests PRIVATE SPLAT::splat) + +add_executable(GPUComputeBackendTest gpu_compute_test.cpp) +target_link_libraries(GPUComputeBackendTest PRIVATE SPLAT::splat) + +add_test(NAME SplatKdTreeTests COMMAND SplatKdTreeTests) +add_test(NAME SplatDecimateTests COMMAND SplatDecimateTests) +add_test(NAME SplatVisualizerDataSemanticsTests COMMAND SplatVisualizerDataSemanticsTests) +add_test(NAME SplatVisualizerSmokeTests COMMAND SplatVisualizerSmokeTests) +add_test(NAME SplatGaussianPropTests COMMAND SplatGaussianPropTests) +add_test(NAME SplatVisualizationPublicHeadersTests COMMAND SplatVisualizationPublicHeadersTests) +add_test(NAME GSplatDataAdapterTests COMMAND GSplatDataAdapterTests) +add_test(NAME GPUComputeBackendTest COMMAND GPUComputeBackendTest) + +if(TARGET playcanvas_viewer_adapter) + add_executable(PlaycanvasViewerAdapterTests playcanvas_viewer_adapter_test.cpp) + target_link_libraries(PlaycanvasViewerAdapterTests PRIVATE playcanvas_viewer_adapter) + add_test(NAME PlaycanvasViewerAdapterTests COMMAND PlaycanvasViewerAdapterTests) +endif() + +if(TARGET playcanvas_viewer_camera) + add_executable(PlaycanvasViewerCameraTests playcanvas_viewer_camera_test.cpp) + target_link_libraries(PlaycanvasViewerCameraTests PRIVATE playcanvas_viewer_camera) + add_test(NAME PlaycanvasViewerCameraTests COMMAND PlaycanvasViewerCameraTests) +endif() + +add_executable(PlaycanvasViewerShaderTests playcanvas_viewer_shader_test.cpp) +target_include_directories(PlaycanvasViewerShaderTests PRIVATE ${PROJECT_SOURCE_DIR}/src/visualization) +target_link_libraries(PlaycanvasViewerShaderTests PRIVATE SPLAT::splat) +add_test(NAME PlaycanvasViewerShaderTests COMMAND PlaycanvasViewerShaderTests) diff --git a/tests/decimate_test.cpp b/tests/decimate_test.cpp new file mode 100644 index 0000000..6b32660 --- /dev/null +++ b/tests/decimate_test.cpp @@ -0,0 +1,53 @@ +#include +#include + +#include +#include +#include +#include + +namespace { + +std::vector filled(size_t n, float value) { + return std::vector(n, value); +} + +void assertFiniteColumn(const splat::DataTable& table, const char* name) { + const auto& values = table.getColumnByName(name).asVector(); + for (float value : values) { + assert(std::isfinite(value)); + } +} + +} // namespace + +int main() { + constexpr size_t n = 8; + splat::DataTable table({ + {"x", std::vector{0.0f, 0.01f, 1.0f, 1.01f, 2.0f, 2.01f, 3.0f, 3.01f}}, + {"y", std::vector{0.0f, 0.01f, 0.0f, 0.01f, 0.0f, 0.01f, 0.0f, 0.01f}}, + {"z", filled(n, 0.0f)}, + {"opacity", filled(n, 4.0f)}, + {"scale_0", filled(n, -3.0f)}, + {"scale_1", filled(n, -3.0f)}, + {"scale_2", filled(n, -3.0f)}, + {"rot_0", filled(n, 1.0f)}, + {"rot_1", filled(n, 0.0f)}, + {"rot_2", filled(n, 0.0f)}, + {"rot_3", filled(n, 0.0f)}, + {"f_dc_0", filled(n, 0.2f)}, + {"f_dc_1", filled(n, 0.3f)}, + {"f_dc_2", filled(n, 0.4f)}, + }); + + std::unique_ptr simplified = splat::simplifyGaussians(table, 4); + assert(simplified->getNumRows() == 4); + + for (const char* name : {"x", "y", "z", "opacity", "scale_0", "scale_1", "scale_2", "rot_0", "rot_1", "rot_2", + "rot_3", "f_dc_0", "f_dc_1", "f_dc_2"}) { + assert(simplified->hasColumn(name)); + assertFiniteColumn(*simplified, name); + } + + return 0; +} diff --git a/tests/gpu_compute_test.cpp b/tests/gpu_compute_test.cpp new file mode 100644 index 0000000..ccadd08 --- /dev/null +++ b/tests/gpu_compute_test.cpp @@ -0,0 +1,166 @@ +/** + * GPU Compute Backend Test Example + * + * This demonstrates how to use the GPU compute abstraction layer + * with different backends (CUDA, Metal, or CPU). + */ + +#include +#include +#include +#include +#include + +int main() { + std::cout << "=== GPU Compute Backend Test ===" << std::endl; + std::cout << "Using backend: " << splat::gpu::getBackendName() << std::endl; + std::cout << std::endl; + + // Test parameters + const uint32_t K = 10; // Number of centroids + const uint32_t D = 3; // Dimensions (x, y, z) + const uint32_t N = 1000; // Number of points + + // Allocate host memory + std::vector centroids(K * D); + std::vector points(N * D); + std::vector centroid_norms(K); + std::vector assignments(N); + + // Initialize test data (column-major layout) + // Centroids: random positions + for (uint32_t i = 0; i < K * D; i++) { + centroids[i] = static_cast(rand()) / RAND_MAX * 100.0f; + } + + // Points: random positions around centroids + for (uint32_t i = 0; i < N * D; i++) { + points[i] = static_cast(rand()) / RAND_MAX * 100.0f; + } + + // Test 1: Compute centroid norms + std::cout << "Test 1: Computing centroid norms..." << std::endl; + if (!splat::gpu::computeCentroidNorms(centroids.data(), K, D, centroid_norms.data())) { + std::cerr << "ERROR: Failed to compute centroid norms" << std::endl; + return 1; + } + + // Verify norms + bool norms_ok = true; + for (uint32_t k = 0; k < K; k++) { + float expected_norm = 0.0f; + for (uint32_t d = 0; d < D; d++) { + float val = centroids[k + d * K]; + expected_norm += val * val; + } + float tolerance = 1e-5f * expected_norm; + if (std::fabs(centroid_norms[k] - expected_norm) > tolerance) { + norms_ok = false; + std::cerr << "Norm mismatch at centroid " << k << ": " + << centroid_norms[k] << " vs " << expected_norm << std::endl; + break; + } + } + + if (norms_ok) { + std::cout << "✓ Centroid norms are correct" << std::endl; + } else { + std::cerr << "✗ Centroid norms verification failed" << std::endl; + return 1; + } + + // Test 2: Assign points to centroids + std::cout << "\nTest 2: Assigning points to centroids..." << std::endl; + if (!splat::gpu::assignPointsToCentroids(points.data(), centroids.data(), + centroid_norms.data(), + N, K, D, assignments.data())) { + std::cerr << "ERROR: Failed to assign points" << std::endl; + return 1; + } + + // Verify assignments + bool assignments_ok = true; + for (uint32_t n = 0; n < N && n < 10; n++) { // Check first 10 points + uint32_t assigned_cluster = assignments[n]; + if (assigned_cluster >= K) { + assignments_ok = false; + std::cerr << "Invalid cluster assignment: " << assigned_cluster + << " >= " << K << std::endl; + break; + } + + // Verify this is indeed the nearest centroid + float best_dist = 3.40282e+38f; + uint32_t best_k = 0; + + for (uint32_t k = 0; k < K; k++) { + float dist = centroid_norms[k]; + for (uint32_t d = 0; d < D; d++) { + float p = points[d * N + n]; + float c = centroids[k + d * K]; + dist += p * p - 2.0f * p * c; + } + + if (dist < best_dist) { + best_dist = dist; + best_k = k; + } + } + + if (best_k != assigned_cluster) { + std::cerr << "Suboptimal assignment for point " << n << ": " + << assigned_cluster << " vs best " << best_k << std::endl; + assignments_ok = false; + break; + } + } + + if (assignments_ok) { + std::cout << "✓ Point assignments are correct" << std::endl; + } else { + std::cerr << "✗ Point assignment verification failed" << std::endl; + return 1; + } + + // Statistics + std::cout << "\nTest Statistics:" << std::endl; + std::cout << " Backend: " << splat::gpu::getBackendName() << std::endl; + std::cout << " Centroids: " << K << std::endl; + std::cout << " Dimensions: " << D << std::endl; + std::cout << " Points: " << N << std::endl; + + // Count assignments per cluster + std::vector cluster_counts(K, 0); + for (uint32_t n = 0; n < N; n++) { + cluster_counts[assignments[n]]++; + } + + std::cout << " Cluster distribution: "; + for (uint32_t k = 0; k < K; k++) { + if (k > 0) std::cout << ", "; + std::cout << cluster_counts[k]; + } + std::cout << std::endl; + + std::cout << "\n✓ All tests passed!" << std::endl; + return 0; +} + +/* +Build and run instructions: + +For CUDA backend: + cmake -B build_cuda -DSPLAT_USE_CUDA=ON -DSPLAT_USE_METAL=OFF + cmake --build build_cuda + cd build/tests && ./gpu_compute_test + +For Metal backend (macOS): + cmake -B build_metal -DSPLAT_USE_CUDA=OFF -DSPLAT_USE_METAL=ON + cmake --build build_metal + cd build/tests && ./gpu_compute_test + +For CPU backend: + cmake -B build_cpu -DSPLAT_USE_CUDA=OFF -DSPLAT_USE_METAL=OFF + cmake --build build_cpu + cd build/tests && ./gpu_compute_test +*/ diff --git a/tests/kdtree_test.cpp b/tests/kdtree_test.cpp new file mode 100644 index 0000000..08f7e4c --- /dev/null +++ b/tests/kdtree_test.cpp @@ -0,0 +1,58 @@ +#include +#include + +#include +#include +#include +#include + +namespace { + +std::vector bruteForceKNearest(const std::vector& xs, const std::vector& ys, + const std::vector& zs, const float* point, size_t k) { + std::vector> distances; + distances.reserve(xs.size()); + for (size_t i = 0; i < xs.size(); ++i) { + const float dx = xs[i] - point[0]; + const float dy = ys[i] - point[1]; + const float dz = zs[i] - point[2]; + distances.emplace_back(dx * dx + dy * dy + dz * dz, i); + } + + std::sort(distances.begin(), distances.end()); + + std::vector result; + result.reserve(k); + for (size_t i = 0; i < std::min(k, distances.size()); ++i) { + result.push_back(distances[i].second); + } + return result; +} + +} // namespace + +int main() { + std::vector xs = {0.0f, 3.0f, 1.0f, -2.0f, 0.5f, 2.0f}; + std::vector ys = {0.0f, 1.0f, 2.0f, -1.0f, 0.25f, 2.0f}; + std::vector zs = {0.0f, 1.0f, 0.5f, -1.0f, 0.25f, 3.0f}; + + splat::DataTable table({ + {"x", xs}, + {"y", ys}, + {"z", zs}, + }); + splat::KdTree kd(&table); + + const float point[3] = {0.25f, 0.2f, 0.1f}; + std::vector actual; + kd.findKNearest(point, 3, actual); + + const std::vector expected = bruteForceKNearest(xs, ys, zs, point, 3); + assert(actual == expected); + + actual.clear(); + kd.findKNearest(point, 32, actual); + assert(actual.size() == xs.size()); + + return 0; +} diff --git a/tests/playcanvas_viewer_adapter_test.cpp b/tests/playcanvas_viewer_adapter_test.cpp new file mode 100644 index 0000000..f84aa57 --- /dev/null +++ b/tests/playcanvas_viewer_adapter_test.cpp @@ -0,0 +1,128 @@ +// Correct assert-based tests for playcanvas_viewer_adapter + +#include "gsplat_data_adapter.h" +#include +#include +#include +#include +#include +#include +#include + +using splat::DataTable; +using playcanvas_viewer::decodePlayCanvasColor; +using playcanvas_viewer::decodePlayCanvasOpacity; +using playcanvas_viewer::decodePlayCanvasScale; +using playcanvas_viewer::normalizePlayCanvasRotation; +using playcanvas_viewer::adaptDataTableToGSplat; +using playcanvas_viewer::GSplatRenderData; +using playcanvas_viewer::Vec4f; + +void test_decodePlayCanvasColor() { + assert(std::abs(decodePlayCanvasColor(0) - 0.5f) < 1e-6f); +} + +void test_decodePlayCanvasOpacity() { + assert(std::abs(decodePlayCanvasOpacity(0) - 0.5f) < 1e-6f); +} + +void test_decodePlayCanvasScale() { + assert(std::abs(decodePlayCanvasScale(std::log(5.0f)) - 5.0f) < 1e-5f); +} + +void test_normalizePlayCanvasRotation() { + // -2,0,0,0 should return identity {1,0,0,0} + Vec4f out = normalizePlayCanvasRotation(-2, 0, 0, 0); + assert(std::abs(out.x - 1.0f) < 1e-6f); + assert(std::abs(out.y) < 1e-6f); + assert(std::abs(out.z) < 1e-6f); + assert(std::abs(out.w) < 1e-6f); + // NaN,0,0,0 should return identity + float nan = std::nanf(""); + Vec4f out2 = normalizePlayCanvasRotation(nan, 0, 0, 0); + assert(std::abs(out2.x - 1.0f) < 1e-6f); + assert(std::abs(out2.y) < 1e-6f); + assert(std::abs(out2.z) < 1e-6f); + assert(std::abs(out2.w) < 1e-6f); +} + +void test_adaptDataTableToGSplat_one_row() { + DataTable table; + table.addColumn({"x", std::vector{1.0f}}); + table.addColumn({"y", std::vector{2.0f}}); + table.addColumn({"z", std::vector{3.0f}}); + table.addColumn({"rot_0", std::vector{-2.0f}}); + table.addColumn({"rot_1", std::vector{0.0f}}); + table.addColumn({"rot_2", std::vector{0.0f}}); + table.addColumn({"rot_3", std::vector{0.0f}}); + table.addColumn({"scale_0", std::vector{std::log(2.0f)}}); + table.addColumn({"scale_1", std::vector{std::log(3.0f)}}); + table.addColumn({"scale_2", std::vector{std::log(4.0f)}}); + table.addColumn({"f_dc_0", std::vector{0.0f}}); + table.addColumn({"f_dc_1", std::vector{0.0f}}); + table.addColumn({"f_dc_2", std::vector{0.0f}}); + table.addColumn({"opacity", std::vector{0.0f}}); + GSplatRenderData data = adaptDataTableToGSplat(table); + assert(data.centers.size() == 1); + assert(std::abs(data.centers[0].x - 1.0f) < 1e-6f); + assert(std::abs(data.centers[0].y - 2.0f) < 1e-6f); + assert(std::abs(data.centers[0].z - 3.0f) < 1e-6f); + assert(std::abs(data.scales[0].x - 2.0f) < 1e-5f); + assert(std::abs(data.scales[0].y - 3.0f) < 1e-5f); + assert(std::abs(data.scales[0].z - 4.0f) < 1e-5f); + assert(std::abs(data.colors[0].x - 0.5f) < 1e-6f); + assert(std::abs(data.colors[0].y - 0.5f) < 1e-6f); + assert(std::abs(data.colors[0].z - 0.5f) < 1e-6f); + assert(std::abs(data.colors[0].w - 0.5f) < 1e-6f); // alpha + assert(data.sourceIndices.size() == 1); + assert(data.sourceIndices[0] == 0); + // Rotation should be identity + assert(std::abs(data.rotations[0].x - 1.0f) < 1e-6f); + assert(std::abs(data.rotations[0].y) < 1e-6f); + assert(std::abs(data.rotations[0].z) < 1e-6f); + assert(std::abs(data.rotations[0].w) < 1e-6f); +} + +void test_adaptDataTableToGSplat_maxSplats() { + DataTable table; + // Add all required columns with two rows + std::vector names = {"x","y","z","rot_0","rot_1","rot_2","rot_3","scale_0","scale_1","scale_2","f_dc_0","f_dc_1","f_dc_2","opacity"}; + for (const auto& name : names) { + table.addColumn({name, std::vector{}}); + } + for (const auto& name : names) { + table.getColumnByName(name).asVector().push_back(1.0f); + table.getColumnByName(name).asVector().push_back(2.0f); + } + GSplatRenderData data = adaptDataTableToGSplat(table, 1); // maxSplats=1 + assert(data.centers.size() == 1); +} + +void test_adaptDataTableToGSplat_missing_column() { + DataTable table; + // Only add some columns + table.addColumn({"x", std::vector{1.0f}}); + table.addColumn({"y", std::vector{2.0f}}); + table.addColumn({"z", std::vector{3.0f}}); + // Missing others + bool threw = false; + try { + GSplatRenderData data = adaptDataTableToGSplat(table); + } catch (const std::runtime_error& e) { + threw = std::string(e.what()).find("missing required column") != std::string::npos; + } + assert(threw); +} + +int main() { + test_decodePlayCanvasColor(); + test_decodePlayCanvasOpacity(); + test_decodePlayCanvasScale(); + test_normalizePlayCanvasRotation(); + test_adaptDataTableToGSplat_one_row(); + test_adaptDataTableToGSplat_maxSplats(); + test_adaptDataTableToGSplat_missing_column(); + std::cout << "All PlaycanvasViewerAdapter tests passed.\n"; + return 0; +} + diff --git a/tests/playcanvas_viewer_camera_test.cpp b/tests/playcanvas_viewer_camera_test.cpp new file mode 100644 index 0000000..a95293c --- /dev/null +++ b/tests/playcanvas_viewer_camera_test.cpp @@ -0,0 +1,168 @@ +#include "camera_controller.h" + +#include "gsplat_data.h" + +#include +#include +#include + +using playcanvas_viewer::CameraController; +using playcanvas_viewer::SceneBounds; +using playcanvas_viewer::computeSceneBounds; +using splat::visualization::GSplatData; +using splat::visualization::Vec3f; + +namespace { + +bool nearlyEqual(float lhs, float rhs, float epsilon = 1e-4f) { + return std::abs(lhs - rhs) <= epsilon; +} + +void require(bool condition, const char* message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +void test_computeSceneBounds_uses_half_diagonal_radius() { + GSplatData data; + data.centers = { + Vec3f{-2.0f, -4.0f, -8.0f}, + Vec3f{ 2.0f, 4.0f, 8.0f} + }; + data.scales = { + Vec3f{0.0f, 0.0f, 0.0f}, + Vec3f{0.0f, 0.0f, 0.0f} + }; + + const SceneBounds bounds = computeSceneBounds(data); + + require(nearlyEqual(bounds.center.x(), 0.0f), "bounds center x should be zero"); + require(nearlyEqual(bounds.center.y(), 0.0f), "bounds center y should be zero"); + require(nearlyEqual(bounds.center.z(), 0.0f), "bounds center z should be zero"); + require(nearlyEqual(bounds.radius, std::sqrt(2.0f * 2.0f + 4.0f * 4.0f + 8.0f * 8.0f)), "bounds radius should be half diagonal"); +} + +void test_computeSceneBounds_ignores_sparse_far_outliers_for_camera_framing() { + GSplatData data; + for (int i = 0; i < 2000; ++i) { + const float x = static_cast((i % 20) - 10); + const float y = static_cast(((i / 20) % 20) - 10); + const float z = static_cast(((i / 400) % 5) - 2); + data.centers.push_back(Vec3f{x, y, z}); + data.scales.push_back(Vec3f{0.01f, 0.01f, 0.01f}); + } + data.centers.push_back(Vec3f{10000.0f, 10000.0f, 10000.0f}); + data.scales.push_back(Vec3f{0.01f, 0.01f, 0.01f}); + + const SceneBounds bounds = computeSceneBounds(data); + + require(bounds.radius < 40.0f, "camera framing radius should ignore sparse far outliers"); + require(bounds.center.norm() < 5.0f, "camera framing center should stay near the dense scene body"); +} + +void test_resetToBounds_matches_playcanvas_focus_distance() { + CameraController camera; + camera.resize(1280, 720); + + SceneBounds bounds; + bounds.center = Eigen::Vector3f(1.0f, 2.0f, 3.0f); + bounds.radius = 10.0f; + camera.resetToBounds(bounds); + + const float expectedDistance = 30.0f; + const float actualDistance = (camera.position() - bounds.center).norm(); + + require(nearlyEqual(actualDistance, expectedDistance), "reset distance should match PlayCanvas focus distance"); +} + +void test_resetToBounds_far_plane_covers_narrow_view_fit() { + CameraController camera; + camera.resize(16, 1280); + + SceneBounds bounds; + bounds.center = Eigen::Vector3f(0.0f, 0.0f, 0.0f); + bounds.radius = 10.0f; + camera.resetToBounds(bounds); + + const float actualDistance = (camera.position() - bounds.center).norm(); + + require(camera.farPlane() > actualDistance + bounds.radius, "far plane should cover fitted narrow view distance"); +} + +void test_resetToBounds_keeps_near_plane_suitable_for_close_inspection() { + CameraController camera; + + SceneBounds bounds; + bounds.center = Eigen::Vector3f(0.0f, 0.0f, 0.0f); + bounds.radius = 5000.0f; + camera.resetToBounds(bounds); + + require(camera.nearPlane() <= 0.05f, "near plane should allow close inspection"); +} + +void test_dolly_can_continue_past_initial_target() { + CameraController camera; + + SceneBounds bounds; + bounds.center = Eigen::Vector3f(0.0f, 0.0f, 0.0f); + bounds.radius = 10.0f; + camera.resetToBounds(bounds); + + for (int i = 0; i < 120; ++i) { + camera.dolly(1.0f); + } + + require(camera.position().z() < 0.0f, "dolly should continue past the initial target"); + require(std::isfinite(camera.forward().x()), "forward x should stay finite"); + require(std::isfinite(camera.forward().y()), "forward y should stay finite"); + require(std::isfinite(camera.forward().z()), "forward z should stay finite"); +} + +void test_dolly_keeps_useful_close_range_step_size() { + CameraController camera; + + SceneBounds bounds; + bounds.center = Eigen::Vector3f(0.0f, 0.0f, 0.0f); + bounds.radius = 100.0f; + camera.resetToBounds(bounds); + + for (int i = 0; i < 80; ++i) { + camera.dolly(1.0f); + } + const float before = camera.position().z(); + camera.dolly(1.0f); + const float after = camera.position().z(); + + require(std::abs(after - before) > 0.5f, "close-range dolly should retain a useful world-space step"); +} + +void test_viewMatrix_uses_upright_screen_orientation() { + CameraController camera; + + SceneBounds bounds; + bounds.center = Eigen::Vector3f(0.0f, 0.0f, 0.0f); + bounds.radius = 10.0f; + camera.resetToBounds(bounds); + + const Eigen::Matrix4f view = camera.viewMatrix(); + const Eigen::Vector4f worldUp = view * Eigen::Vector4f(0.0f, 1.0f, 0.0f, 1.0f); + const Eigen::Vector4f origin = view * Eigen::Vector4f(0.0f, 0.0f, 0.0f, 1.0f); + + require(worldUp.y() < origin.y(), "world +Y should map downward to match the PlayCanvas-style upright view for this data"); +} + +} // namespace + +int main() { + test_computeSceneBounds_uses_half_diagonal_radius(); + test_computeSceneBounds_ignores_sparse_far_outliers_for_camera_framing(); + test_resetToBounds_matches_playcanvas_focus_distance(); + test_resetToBounds_far_plane_covers_narrow_view_fit(); + test_resetToBounds_keeps_near_plane_suitable_for_close_inspection(); + test_dolly_can_continue_past_initial_target(); + test_dolly_keeps_useful_close_range_step_size(); + test_viewMatrix_uses_upright_screen_orientation(); + std::cout << "All PlaycanvasViewerCamera tests passed.\n"; + return 0; +} diff --git a/tests/playcanvas_viewer_shader_test.cpp b/tests/playcanvas_viewer_shader_test.cpp new file mode 100644 index 0000000..654dcf9 --- /dev/null +++ b/tests/playcanvas_viewer_shader_test.cpp @@ -0,0 +1,34 @@ +#include "gsplat_gl_shader_sources.h" + +#include +#include +#include + +namespace { + +void require(bool condition, const char* message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +void test_quaternion_matrix_matches_playcanvas_column_major_formula() { + const std::string_view shader(splat::visualization::kGSplatGLVertexShader); + + require(shader.find("vec4 R2 = R + R;") != std::string_view::npos, + "quatToMat3 should use the PlayCanvas reference formula"); + require(shader.find("float X = R2.x * R.w;") != std::string_view::npos, + "quatToMat3 should treat rot_0..3 as PlayCanvas wxyz data"); + require(shader.find("Y.z + X") != std::string_view::npos, + "quatToMat3 should be written in GLSL column-major order"); + require(shader.find("xy - wz") == std::string_view::npos, + "quatToMat3 should not use the old transposed row-major formula"); +} + +} // namespace + +int main() { + test_quaternion_matrix_matches_playcanvas_column_major_formula(); + std::cout << "All PlaycanvasViewerShader tests passed.\n"; + return 0; +} diff --git a/tests/visualization/gsplat_data_adapter_test.cpp b/tests/visualization/gsplat_data_adapter_test.cpp new file mode 100644 index 0000000..a0b9b2e --- /dev/null +++ b/tests/visualization/gsplat_data_adapter_test.cpp @@ -0,0 +1,105 @@ +#include +#include "gsplat_data.h" + +#include +#include +#include +#include +#include + +namespace { + +void require(bool condition, const char* message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +bool nearlyEqual(float lhs, float rhs, float epsilon = 1e-5f) { return std::abs(lhs - rhs) <= epsilon; } + +splat::DataTable makeOneRowTable() { + splat::DataTable table; + table.addColumn({"x", std::vector{1.0f}}); + table.addColumn({"y", std::vector{2.0f}}); + table.addColumn({"z", std::vector{3.0f}}); + table.addColumn({"rot_0", std::vector{-2.0f}}); + table.addColumn({"rot_1", std::vector{0.0f}}); + table.addColumn({"rot_2", std::vector{0.0f}}); + table.addColumn({"rot_3", std::vector{0.0f}}); + table.addColumn({"scale_0", std::vector{std::log(2.0f)}}); + table.addColumn({"scale_1", std::vector{std::log(3.0f)}}); + table.addColumn({"scale_2", std::vector{std::log(4.0f)}}); + table.addColumn({"f_dc_0", std::vector{0.0f}}); + table.addColumn({"f_dc_1", std::vector{0.0f}}); + table.addColumn({"f_dc_2", std::vector{0.0f}}); + table.addColumn({"opacity", std::vector{0.0f}}); + return table; +} + +void testDecodeHelpers() { + using namespace splat::visualization; + require(nearlyEqual(decodePlayCanvasColor(0.0f), 0.5f), "DC color should decode through SH C0"); + require(nearlyEqual(decodePlayCanvasOpacity(0.0f), 0.5f), "opacity zero should decode to 0.5"); + require(nearlyEqual(decodePlayCanvasScale(std::log(5.0f)), 5.0f), "scale should decode with exp"); +} + +void testAdaptOneRow() { + const auto table = makeOneRowTable(); + const auto data = splat::visualization::adaptDataTableToGSplatData(table); + + require(data.size() == 1, "one input row should produce one splat"); + require(nearlyEqual(data.centers[0].x, 1.0f), "center x should pass through"); + require(nearlyEqual(data.centers[0].y, 2.0f), "center y should pass through"); + require(nearlyEqual(data.centers[0].z, 3.0f), "center z should pass through"); + require(nearlyEqual(data.scales[0].x, 2.0f), "scale x should decode"); + require(nearlyEqual(data.scales[0].y, 3.0f), "scale y should decode"); + require(nearlyEqual(data.scales[0].z, 4.0f), "scale z should decode"); + require(nearlyEqual(data.colors[0].x, 0.5f), "red should decode"); + require(nearlyEqual(data.colors[0].y, 0.5f), "green should decode"); + require(nearlyEqual(data.colors[0].z, 0.5f), "blue should decode"); + require(nearlyEqual(data.colors[0].w, 0.5f), "alpha should decode"); + require(nearlyEqual(data.rotations[0].x, 1.0f), "negative w should normalize to positive identity"); + require(nearlyEqual(data.rotations[0].y, 0.0f), "rotation x should be zero"); + require(nearlyEqual(data.rotations[0].z, 0.0f), "rotation y should be zero"); + require(nearlyEqual(data.rotations[0].w, 0.0f), "rotation z should be zero"); + require(data.sourceIndices[0] == 0, "source index should be preserved"); +} + +void testMaxSplatsLimit() { + splat::DataTable table; + const std::vector names = {"x", "y", "z", "rot_0", "rot_1", + "rot_2", "rot_3", "scale_0", "scale_1", "scale_2", + "f_dc_0", "f_dc_1", "f_dc_2", "opacity"}; + for (const auto& name : names) { + table.addColumn({name, std::vector{1.0f, 2.0f}}); + } + + const auto data = splat::visualization::adaptDataTableToGSplatData(table, 1); + require(data.size() == 1, "maxSplats should limit output count"); +} + +void testMissingColumnThrows() { + splat::DataTable table; + table.addColumn({"x", std::vector{1.0f}}); + table.addColumn({"y", std::vector{2.0f}}); + table.addColumn({"z", std::vector{3.0f}}); + + bool threw = false; + try { + static_cast(splat::visualization::adaptDataTableToGSplatData(table)); + } catch (const std::runtime_error& error) { + threw = std::string(error.what()).find("missing required column") != std::string::npos; + } + require(threw, "missing required column should throw"); +} + +} // namespace + +int main() { + testDecodeHelpers(); + testAdaptOneRow(); + testMaxSplatsLimit(); + testMissingColumnThrows(); + std::cout << "All GSplatData adapter tests passed.\n"; + return 0; +} diff --git a/tests/visualization/splat_gaussian_prop_test.cpp b/tests/visualization/splat_gaussian_prop_test.cpp new file mode 100644 index 0000000..97bde10 --- /dev/null +++ b/tests/visualization/splat_gaussian_prop_test.cpp @@ -0,0 +1,79 @@ +#include +#include "gsplat_gl_renderer.h" +#include "splat_gaussian_prop.h" + +#include +#include + +#include +#include +#include + +namespace { + +void require(bool condition, const char* message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +splat::DataTable makeSingleSplatTable() { + splat::DataTable table; + table.addColumn({"x", std::vector{0.0f}}); + table.addColumn({"y", std::vector{0.0f}}); + table.addColumn({"z", std::vector{0.0f}}); + table.addColumn({"f_dc_0", std::vector{0.5f}}); + table.addColumn({"f_dc_1", std::vector{0.25f}}); + table.addColumn({"f_dc_2", std::vector{0.0f}}); + table.addColumn({"opacity", std::vector{4.0f}}); + table.addColumn({"scale_0", std::vector{std::log(1.0f)}}); + table.addColumn({"scale_1", std::vector{std::log(1.0f)}}); + table.addColumn({"scale_2", std::vector{std::log(1.0f)}}); + table.addColumn({"rot_0", std::vector{1.0f}}); + table.addColumn({"rot_1", std::vector{0.0f}}); + table.addColumn({"rot_2", std::vector{0.0f}}); + table.addColumn({"rot_3", std::vector{0.0f}}); + return table; +} + +void test_public_prop_accepts_data_table_and_vtk_renderer() { + auto prop = vtkSmartPointer::New(); + prop->SetInputData(makeSingleSplatTable()); + + splat::SplatRenderOptions options; + options.sortBackToFront = true; + options.depthWrite = false; + prop->SetRenderOptions(options); + + require(prop->GetSplatCount() == 1, "public prop should expose adapted splat count"); + require(prop->GetBounds() != nullptr, "public prop should expose VTK bounds after data upload"); + + auto renderer = vtkSmartPointer::New(); + renderer->AddViewProp(prop); + require(renderer->HasViewProp(prop), "public prop should be accepted by vtkRenderer"); +} + +void test_public_render_options_match_shared_gl_defaults() { + const splat::SplatRenderOptions publicOptions; + const splat::visualization::GSplatGLRenderOptions glOptions; + + require(publicOptions.globalOpacity == glOptions.globalOpacity, "default opacity should match GL renderer"); + require(publicOptions.sizeScale == glOptions.sizeScale, "default size scale should match GL renderer"); + require(publicOptions.minPointSize == glOptions.minPixelSize, "default min point size should match GL renderer"); + require(publicOptions.maxPointSize == glOptions.maxPixelSize, "default max point size should match GL renderer"); + require(publicOptions.alphaDiscardThreshold == glOptions.alphaDiscardThreshold, + "default alpha discard should match GL renderer"); + require(publicOptions.depthTest == glOptions.depthTest, "default depth test should match GL renderer"); + require(publicOptions.depthWrite == glOptions.depthWrite, "default depth write should match GL renderer"); + require(publicOptions.sortBackToFront == glOptions.sortBackToFront, "default sorting should match GL renderer"); + require(publicOptions.clampColors == glOptions.clampColors, "default color clamp should match GL renderer"); + require(publicOptions.freezeSortOrder == glOptions.freezeSortOrder, "default sort freeze should match GL renderer"); +} + +} // namespace + +int main() { + test_public_render_options_match_shared_gl_defaults(); + test_public_prop_accepts_data_table_and_vtk_renderer(); + return 0; +} diff --git a/tests/visualization/splat_visualizer_data_semantics_test.cpp b/tests/visualization/splat_visualizer_data_semantics_test.cpp new file mode 100644 index 0000000..d2fc7e2 --- /dev/null +++ b/tests/visualization/splat_visualizer_data_semantics_test.cpp @@ -0,0 +1,224 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +struct VisibleBounds { + int minX = 0; + int maxX = -1; + int minY = 0; + int maxY = -1; + + bool valid() const { return maxX >= minX && maxY >= minY; } + int width() const { return valid() ? (maxX - minX + 1) : 0; } + int height() const { return valid() ? (maxY - minY + 1) : 0; } +}; + +VisibleBounds computeVisibleBounds(vtkImageData* image, int minX, int maxX, int minY, int maxY) { + VisibleBounds bounds; + int dims[3] = {0, 0, 0}; + image->GetDimensions(dims); + + auto* scalars = vtkUnsignedCharArray::SafeDownCast(image->GetPointData()->GetScalars()); + assert(scalars != nullptr); + + bool initialized = false; + const int clampedMinX = std::max(0, minX); + const int clampedMaxX = std::min(dims[0] - 1, maxX); + const int clampedMinY = std::max(0, minY); + const int clampedMaxY = std::min(dims[1] - 1, maxY); + for (int y = clampedMinY; y <= clampedMaxY; ++y) { + for (int x = clampedMinX; x <= clampedMaxX; ++x) { + const vtkIdType index = static_cast(y) * dims[0] + x; + unsigned char rgba[4] = {0, 0, 0, 0}; + scalars->GetTypedTuple(index, rgba); + if (static_cast(rgba[0]) + static_cast(rgba[1]) + static_cast(rgba[2]) < 160) { + continue; + } + + if (!initialized) { + bounds.minX = bounds.maxX = x; + bounds.minY = bounds.maxY = y; + initialized = true; + } else { + bounds.minX = std::min(bounds.minX, x); + bounds.maxX = std::max(bounds.maxX, x); + bounds.minY = std::min(bounds.minY, y); + bounds.maxY = std::max(bounds.maxY, y); + } + } + } + + return bounds; +} + +vtkSmartPointer renderReferenceFrame() { + using namespace splat; + + DataTable table; + const float quarterTurn = std::sqrt(0.5f); + table.addColumn(Column{"x", std::vector{-5.0f, 5.0f}}); + table.addColumn(Column{"y", std::vector{0.0f, 0.0f}}); + table.addColumn(Column{"z", std::vector{0.0f, 0.0f}}); + table.addColumn(Column{"f_dc_0", std::vector{0.5f, 0.5f}}); + table.addColumn(Column{"f_dc_1", std::vector{0.25f, 0.25f}}); + table.addColumn(Column{"f_dc_2", std::vector{0.0f, 0.0f}}); + table.addColumn(Column{"opacity", std::vector{4.0f, 4.0f}}); + table.addColumn(Column{"scale_0", std::vector{std::log(2.5f), std::log(2.5f)}}); + table.addColumn(Column{"scale_1", std::vector{std::log(0.35f), std::log(0.35f)}}); + table.addColumn(Column{"scale_2", std::vector{std::log(0.35f), std::log(0.35f)}}); + table.addColumn(Column{"rot_0", std::vector{1.0f, quarterTurn}}); + table.addColumn(Column{"rot_1", std::vector{0.0f, 0.0f}}); + table.addColumn(Column{"rot_2", std::vector{0.0f, 0.0f}}); + table.addColumn(Column{"rot_3", std::vector{0.0f, quarterTurn}}); + + SplatVisualizer visualizer("semantics"); + visualizer.getRenderWindow()->SetOffScreenRendering(1); + visualizer.setWindowSize(256, 256); + visualizer.setBackgroundColor(0.0, 0.0, 0.0); + visualizer.setAxesEnabled(false); + + SplatRenderOptions options; + options.sizeScale = 1.0f; + options.minPointSize = 1.0f; + options.maxPointSize = 128.0f; + options.depthTest = false; + options.depthWrite = false; + + const bool added = visualizer.addSplatCloud(table, "one", options); + assert(added); + + auto* camera = visualizer.getRenderer()->GetActiveCamera(); + camera->SetPosition(0.0, 0.0, 24.0); + camera->SetFocalPoint(0.0, 0.0, 0.0); + camera->SetViewUp(0.0, 1.0, 0.0); + visualizer.getRenderer()->ResetCameraClippingRange(); + visualizer.render(); + + vtkNew capture; + capture->SetInput(visualizer.getRenderWindow()); + capture->ReadFrontBufferOff(); + capture->Update(); + + auto output = vtkSmartPointer::New(); + output->DeepCopy(capture->GetOutput()); + return output; +} + +void expectNearClipSplatRemainsVisible() { + using namespace splat; + + DataTable table; + table.addColumn(Column{"x", std::vector{0.0f}}); + table.addColumn(Column{"y", std::vector{0.0f}}); + table.addColumn(Column{"z", std::vector{0.7f}}); + table.addColumn(Column{"f_dc_0", std::vector{1.0f}}); + table.addColumn(Column{"f_dc_1", std::vector{1.0f}}); + table.addColumn(Column{"f_dc_2", std::vector{1.0f}}); + table.addColumn(Column{"opacity", std::vector{8.0f}}); + table.addColumn(Column{"scale_0", std::vector{std::log(0.2f)}}); + table.addColumn(Column{"scale_1", std::vector{std::log(0.2f)}}); + table.addColumn(Column{"scale_2", std::vector{std::log(0.2f)}}); + table.addColumn(Column{"rot_0", std::vector{1.0f}}); + table.addColumn(Column{"rot_1", std::vector{0.0f}}); + table.addColumn(Column{"rot_2", std::vector{0.0f}}); + table.addColumn(Column{"rot_3", std::vector{0.0f}}); + + SplatVisualizer visualizer("near-clip"); + visualizer.getRenderWindow()->SetOffScreenRendering(1); + visualizer.setWindowSize(128, 128); + visualizer.setBackgroundColor(0.0, 0.0, 0.0); + visualizer.setAxesEnabled(false); + + SplatRenderOptions options; + options.depthTest = false; + options.depthWrite = false; + const bool added = visualizer.addSplatCloud(table, "near", options); + assert(added); + + auto* camera = visualizer.getRenderer()->GetActiveCamera(); + camera->SetPosition(0.0, 0.0, 1.0); + camera->SetFocalPoint(0.0, 0.0, 0.0); + camera->SetViewUp(0.0, 1.0, 0.0); + camera->SetClippingRange(0.5, 10.0); + visualizer.render(); + + vtkNew capture; + capture->SetInput(visualizer.getRenderWindow()); + capture->ReadFrontBufferOff(); + capture->Update(); + + auto* image = capture->GetOutput(); + int dims[3] = {0, 0, 0}; + image->GetDimensions(dims); + const VisibleBounds bounds = computeVisibleBounds(image, 0, dims[0] - 1, 0, dims[1] - 1); + assert(bounds.valid()); +} + +void expectMissingColumnFailure() { + using namespace splat; + + DataTable table; + table.addColumn(Column{"x", std::vector{0.0f}}); + table.addColumn(Column{"y", std::vector{0.0f}}); + table.addColumn(Column{"z", std::vector{0.0f}}); + table.addColumn(Column{"f_dc_0", std::vector{0.5f}}); + table.addColumn(Column{"f_dc_1", std::vector{0.25f}}); + table.addColumn(Column{"opacity", std::vector{4.0f}}); + table.addColumn(Column{"scale_0", std::vector{std::log(1.0f)}}); + table.addColumn(Column{"scale_1", std::vector{std::log(1.0f)}}); + table.addColumn(Column{"scale_2", std::vector{std::log(1.0f)}}); + table.addColumn(Column{"rot_0", std::vector{1.0f}}); + table.addColumn(Column{"rot_1", std::vector{0.0f}}); + table.addColumn(Column{"rot_2", std::vector{0.0f}}); + table.addColumn(Column{"rot_3", std::vector{0.0f}}); + + SplatVisualizer visualizer("missing-column"); + + bool threw = false; + try { + static_cast(visualizer.addSplatCloud(table, "broken")); + } catch (const std::runtime_error& error) { + threw = true; + const std::string message = error.what(); + assert(message.find("SplatVisualizer: missing required column: f_dc_2") != std::string::npos); + } + + assert(threw); +} + +} // namespace + +int main() { + // This test suite exists because the TS renderer is known-good while the + // current C++ path is known to produce no useful visible output. Any future + // change that keeps compilation green but breaks decode/render semantics + // should fail here first. + auto referenceImage = renderReferenceFrame(); + int dims[3] = {0, 0, 0}; + referenceImage->GetDimensions(dims); + + const VisibleBounds horizontalBounds = computeVisibleBounds(referenceImage, 0, dims[0] / 2 - 1, 0, dims[1] - 1); + assert(horizontalBounds.valid()); + const VisibleBounds verticalBounds = computeVisibleBounds(referenceImage, dims[0] / 2, dims[0] - 1, 0, dims[1] - 1); + assert(verticalBounds.valid()); + + assert(horizontalBounds.width() > horizontalBounds.height()); + assert(verticalBounds.height() > verticalBounds.width()); + expectNearClipSplatRemainsVisible(); + expectMissingColumnFailure(); + return 0; +} diff --git a/tests/visualization/splat_visualizer_smoke_test.cpp b/tests/visualization/splat_visualizer_smoke_test.cpp new file mode 100644 index 0000000..3649d79 --- /dev/null +++ b/tests/visualization/splat_visualizer_smoke_test.cpp @@ -0,0 +1,73 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +bool nearlyEqual(double lhs, double rhs, double epsilon = 1e-6) { return std::abs(lhs - rhs) <= epsilon; } + +bool matrixElementNearlyEqual(vtkMatrix4x4* matrix, int row, int column, double expected) { + return nearlyEqual(matrix->GetElement(row, column), expected); +} + +} // namespace + +int main() { + splat::SplatVisualizer visualizer("smoke"); + visualizer.setWindowSize(320, 240); + visualizer.setAxesEnabled(false); + + splat::SplatRenderOptions options; + options.sortBackToFront = true; + options.clampColors = true; + + auto renderer = vtkSmartPointer::New(); + auto renderWindow = vtkSmartPointer::New(); + auto interactor = vtkSmartPointer::New(); + splat::SplatVisualizer embedded(renderer, renderWindow, interactor, "embedded"); + if (embedded.getRenderer() != renderer || embedded.getRenderWindow() != renderWindow || embedded.getInteractor() != interactor) { + return 2; + } + + embedded.setWindowSize(1280, 720); + const double bounds[6] = {-10.0, 10.0, -10.0, 10.0, -10.0, 10.0}; + embedded.resetCameraToBounds(bounds); + auto* camera = embedded.getRenderer()->GetActiveCamera(); + double position[3] = {0.0, 0.0, 0.0}; + double focalPoint[3] = {0.0, 0.0, 0.0}; + double viewUp[3] = {0.0, 0.0, 0.0}; + double clippingRange[2] = {0.0, 0.0}; + camera->GetPosition(position); + camera->GetFocalPoint(focalPoint); + camera->GetViewUp(viewUp); + camera->GetClippingRange(clippingRange); + const double radius = std::sqrt(10.0 * 10.0 + 10.0 * 10.0 + 10.0 * 10.0); + const double expectedDistance = radius * 3.0; + if (!nearlyEqual(camera->GetViewAngle(), 60.0) || !nearlyEqual(viewUp[0], 0.0) || + !nearlyEqual(viewUp[1], -1.0) || !nearlyEqual(viewUp[2], 0.0) || !nearlyEqual(focalPoint[0], 0.0) || + !nearlyEqual(focalPoint[1], 0.0) || !nearlyEqual(focalPoint[2], 0.0) || + !nearlyEqual(position[2], expectedDistance) || !nearlyEqual(clippingRange[0], radius * 0.0005) || + !nearlyEqual(clippingRange[1], radius * 32.0)) { + return 3; + } + auto* viewMatrix = camera->GetViewTransformMatrix(); + if (!matrixElementNearlyEqual(viewMatrix, 0, 0, -1.0) || !matrixElementNearlyEqual(viewMatrix, 1, 1, -1.0) || + !matrixElementNearlyEqual(viewMatrix, 2, 2, 1.0) || !matrixElementNearlyEqual(viewMatrix, 2, 3, -expectedDistance)) { + return 4; + } + auto* projectionMatrix = camera->GetProjectionTransformMatrix(1280.0 / 720.0, clippingRange[0], clippingRange[1]); + const double tanHalfFov = std::tan((60.0 * 3.14159265358979323846 / 180.0) * 0.5); + if (!matrixElementNearlyEqual(projectionMatrix, 0, 0, 1.0 / ((1280.0 / 720.0) * tanHalfFov)) || + !matrixElementNearlyEqual(projectionMatrix, 1, 1, 1.0 / tanHalfFov) || + !matrixElementNearlyEqual(projectionMatrix, 3, 2, -1.0)) { + return 5; + } + return visualizer.wasStopped() || embedded.wasStopped() ? 1 : 0; +} diff --git a/tests/visualization/visualization_public_headers_test.cpp b/tests/visualization/visualization_public_headers_test.cpp new file mode 100644 index 0000000..487c9f5 --- /dev/null +++ b/tests/visualization/visualization_public_headers_test.cpp @@ -0,0 +1,52 @@ +#include +#include +#include + +#include + +#if __has_include() +#define SPLAT_HAS_PUBLIC_GSPLAT_DATA_HEADER 1 +#else +#define SPLAT_HAS_PUBLIC_GSPLAT_DATA_HEADER 0 +#endif + +#if __has_include() +#define SPLAT_HAS_PUBLIC_GSPLAT_GL_RENDERER_HEADER 1 +#else +#define SPLAT_HAS_PUBLIC_GSPLAT_GL_RENDERER_HEADER 0 +#endif + +#if __has_include() +#define SPLAT_HAS_PUBLIC_SPLAT_GAUSSIAN_PROP_HEADER 1 +#else +#define SPLAT_HAS_PUBLIC_SPLAT_GAUSSIAN_PROP_HEADER 0 +#endif + +#if __has_include() +#define SPLAT_HAS_PUBLIC_KEYEVENT_HEADER 1 +#else +#define SPLAT_HAS_PUBLIC_KEYEVENT_HEADER 0 +#endif + +#if __has_include() +#define SPLAT_HAS_PUBLIC_MOUSEEVENT_HEADER 1 +#else +#define SPLAT_HAS_PUBLIC_MOUSEEVENT_HEADER 0 +#endif + +static_assert(SPLAT_HAS_PUBLIC_GSPLAT_DATA_HEADER == 0, "GSplatData is an internal visualization detail"); +static_assert(SPLAT_HAS_PUBLIC_GSPLAT_GL_RENDERER_HEADER == 0, + "GSplatGLRenderer is an internal visualization detail"); +static_assert(SPLAT_HAS_PUBLIC_SPLAT_GAUSSIAN_PROP_HEADER == 0, + "SplatGaussianProp is an internal visualization detail"); +static_assert(SPLAT_HAS_PUBLIC_KEYEVENT_HEADER == 1, "KeyEvent is part of the public interaction API"); +static_assert(SPLAT_HAS_PUBLIC_MOUSEEVENT_HEADER == 1, "MouseEvent is part of the public interaction API"); + +int main() { + static_assert(std::is_class_v); + static_assert(std::is_same_v); + static_assert(std::is_same_v); + static_assert(std::is_enum_v); + static_assert(std::is_enum_v); + return 0; +} diff --git a/transform/main.cpp b/transform/main.cpp index 57fa04c..682e298 100644 --- a/transform/main.cpp +++ b/transform/main.cpp @@ -9,7 +9,10 @@ #include #include +#include #include +#include +#include #include #include "gpudevice.h" @@ -46,24 +49,139 @@ ABSL_FLAG(std::string, gpu, "-1", "Select device for SOG compression: GPU adapte ABSL_FLAG(std::string, lod_select, "", "Comma-separated LOD levels to read from LCC input"); ABSL_FLAG(std::string, viewer_settings, "", "HTML viewer settings JSON file"); -ABSL_FLAG(float, lod, {}, "Specify the level of detail, n >= 0. Can be repeated"); - static std::tuple, Options> parseArguments(int argc, char** argv) { auto parseInteger = [](absl::string_view value) { int result; if (!absl::SimpleAtoi(value, &result)) { - throw std::runtime_error("Invalid number value" + std::string(value)); + throw std::runtime_error("Invalid number value: " + std::string(value)); + } + return result; + }; + + auto parseNonNegativeInteger = [&](absl::string_view value, absl::string_view optionName) { + int result = parseInteger(value); + if (result < 0) { + throw std::runtime_error(std::string(optionName) + " must be a non-negative integer"); } return result; }; + auto stripOptionPrefix = [](absl::string_view arg) { + while (absl::ConsumePrefix(&arg, "-")) { + } + return arg; + }; + + auto optionName = [&](absl::string_view arg) { + absl::string_view name = stripOptionPrefix(arg); + const size_t equals = name.find('='); + if (equals != absl::string_view::npos) { + name = name.substr(0, equals); + } + return name; + }; + + auto normalizedOptionName = [&](absl::string_view arg) { + std::string name(optionName(arg)); + for (char& c : name) { + if (c == '-') { + c = '_'; + } + } + return name; + }; + + auto optionValue = [&](absl::string_view arg, int& i, absl::string_view optionName) { + const size_t equals = arg.find('='); + if (equals != absl::string_view::npos) { + return arg.substr(equals + 1); + } + if (i + 1 >= argc) { + throw std::runtime_error(std::string(optionName) + " requires a value"); + } + return absl::string_view(argv[++i]); + }; + + auto parseParams = [&](absl::string_view value, File& file) { + for (absl::string_view param : absl::StrSplit(value, ',', absl::SkipEmpty())) { + const size_t equals = param.find('='); + if (equals == absl::string_view::npos || equals == 0) { + throw std::runtime_error("--params values must be key=value pairs"); + } + file.processActions.push_back(Param{std::string(param.substr(0, equals)), std::string(param.substr(equals + 1))}); + } + }; + + auto parseDecimate = [&](absl::string_view value) { + if (absl::ConsumeSuffix(&value, "%")) { + float percent = -1.0f; + if (!absl::SimpleAtof(value, &percent) || percent < 0.0f || percent > 100.0f) { + throw std::runtime_error("--decimate percentage must be between 0 and 100"); + } + return Decimate{-1, percent}; + } + + return Decimate{parseNonNegativeInteger(value, "--decimate"), -1.0f}; + }; + absl::SetProgramUsageMessage( "Transform and Filter Gaussian Splats\nUSAGE: SplatTransform [GLOBAL] input [ACTIONS] ... output [ACTIONS]"); - std::vector remaining_args = absl::ParseCommandLine(argc, argv); + const std::unordered_set globalValueOptions = { + "iterations", "lod_chunk_count", "lod_chunk_extent", "gpu", "lod_select", "viewer_settings"}; + const std::unordered_set globalBoolOptions = {"overwrite", "help", "version", + "quiet", "list_gpus", "unbundled"}; + + std::vector files; + std::vector sanitizedArgv; + sanitizedArgv.push_back(argv[0]); + + for (int i = 1; i < argc; ++i) { + absl::string_view arg(argv[i]); + if (!absl::StartsWith(arg, "-") || arg == "-") { + files.push_back({std::filesystem::u8path(std::string(arg)), {}}); + continue; + } + + const std::string normalizedName = normalizedOptionName(arg); + const bool hasInlineValue = arg.find('=') != absl::string_view::npos; + + if (normalizedName == "params" || normalizedName == "lod" || normalizedName == "decimate") { + if (files.empty()) { + throw std::runtime_error("--" + normalizedName + " must follow a positional file"); + } + + File& current = files.back(); + if (normalizedName == "params") { + parseParams(optionValue(arg, i, "--params"), current); + } else if (normalizedName == "lod") { + current.processActions.push_back(Lod{parseNonNegativeInteger(optionValue(arg, i, "--lod"), "--lod")}); + } else { + current.processActions.push_back(parseDecimate(optionValue(arg, i, "--decimate"))); + } + continue; + } + + sanitizedArgv.push_back(argv[i]); + const bool isGlobalValueOption = globalValueOptions.find(normalizedName) != globalValueOptions.end(); + const bool isGlobalBoolOption = globalBoolOptions.find(normalizedName) != globalBoolOptions.end(); + + if (!hasInlineValue && isGlobalValueOption) { + if (i + 1 >= argc) { + throw std::runtime_error("--" + normalizedName + " requires a value"); + } + sanitizedArgv.push_back(argv[++i]); + } else if (!isGlobalBoolOption && !isGlobalValueOption) { + // Let Abseil preserve its normal unknown-flag diagnostics for options outside this translated subset. + } + } + + absl::ParseCommandLine(static_cast(sanitizedArgv.size()), sanitizedArgv.data()); Options options; options.overwrite = absl::GetFlag(FLAGS_overwrite); + options.help = absl::GetFlag(FLAGS_help); + options.version = absl::GetFlag(FLAGS_version); options.quiet = absl::GetFlag(FLAGS_quiet); options.listGpus = absl::GetFlag(FLAGS_list_gpus); options.unbundled = absl::GetFlag(FLAGS_unbundled); @@ -87,19 +205,8 @@ static std::tuple, Options> parseArguments(int argc, char** ar } } - std::vector files; - for (size_t i = 1; i < remaining_args.size(); ++i) { - absl::string_view arg = remaining_args[i]; - - if (!absl::StartsWith(arg, "-")) { - files.push_back({std::filesystem::u8path(std::string(arg)), {}}); - } else if (!files.empty()) { - File& current = files.back(); - - absl::string_view name = arg; - while (absl::ConsumePrefix(&name, "-")) { - } - } + if (!options.help && !options.version && !options.listGpus && files.size() < 2) { + throw std::runtime_error("Expected at least one input file and one output file"); } return {files, options}; @@ -118,7 +225,15 @@ static bool isGSDataTable(const DataTable* dataTable) { int main(int argc, char** argv) { std::chrono::time_point startTime = std::chrono::high_resolution_clock::now(); - auto [files, options] = parseArguments(argc, argv); + std::vector files; + Options options; + try { + std::tie(files, options) = parseArguments(argc, argv); + } catch (const std::exception& e) { + std::cerr << "ERROR: " << e.what() << "\n"; + return 1; + } + if (absl::GetFlag(FLAGS_help)) { std::cout << "Usage: SplatTransform [OPTIONS] input_file output_file\n\n"; std::cout << "GLOBAL OPTIONS:\n"; @@ -136,6 +251,7 @@ int main(int argc, char** argv) { std::cout << " --lod-chunk-extent Approximate size of an LOD chunk in world units (m). Default: 16\n"; std::cout << "\nFILE ACTIONS (can be specified between files):\n"; std::cout << " --lod Specify the level of detail, n >= 0\n"; + std::cout << " --decimate Keep n splats or n percent of splats\n"; std::cout << " --params Additional parameters\n"; return 0; } @@ -220,6 +336,11 @@ int main(int argc, char** argv) { for (const auto& inputArg : inputArgs) { std::vector params; + for (const auto& action : inputArg.processActions) { + if (const auto* param = std::get_if(&action)) { + params.push_back(*param); + } + } std::vector> dts = readFile(inputArg.filename, options, params); @@ -238,10 +359,12 @@ int main(int argc, char** argv) { // special-case the environment dataTable for (auto&& dt : inputDataTables) { - if (dt->hasColumn("lod") && dt->getColumnByName("lod").every(-1.0f)) + const bool isEnv = dt->hasColumn("lod") && dt->getColumnByName("lod").every(-1.0f); + if (isEnv) { envDataTables.emplace_back(dt.release()); - if (!dt->hasColumn("lod") || (dt->hasColumn("lod") && dt->getColumnByName("lod").some(-1.0f))) + } else { nonEnvDataTables.emplace_back(dt.release()); + } } // combine inputs into a single output dataTable diff --git a/transform/process.cpp b/transform/process.cpp index e4dc2f3..32007ed 100644 --- a/transform/process.cpp +++ b/transform/process.cpp @@ -1,10 +1,65 @@ #include "process.h" #include +#include +#include + +#include namespace splat { +namespace { + +Column* findColumnByName(DataTable* dataTable, const std::string& name) { + if (!dataTable) { + return nullptr; + } + + const int index = dataTable->getColumnIndex(name); + if (index < 0) { + return nullptr; + } + + return &dataTable->getColumn(static_cast(index)); +} + +Column& ensureLodColumn(DataTable* dataTable) { + Column* lodColumn = findColumnByName(dataTable, "lod"); + if (!lodColumn) { + dataTable->addColumn({"lod", std::vector(dataTable->getNumRows())}); + lodColumn = findColumnByName(dataTable, "lod"); + } + + return *lodColumn; +} + +int computeKeepCount(size_t numRows, const Decimate& action) { + int keepCount = static_cast(numRows); + + if (action.count >= 0) { + keepCount = action.count; + } else if (action.percent >= 0.0f) { + keepCount = static_cast( + std::lround(static_cast(numRows) * static_cast(action.percent) / 100.0)); + } else { + throw std::runtime_error("Decimate action missing count and percent"); + } + + if (keepCount < 0) { + keepCount = 0; + } + + const int maxRows = static_cast(numRows); + if (keepCount > maxRows) { + keepCount = maxRows; + } + + return keepCount; +} + +} // namespace + static std::unique_ptr filter(const DataTable* dataTable, std::function predicate) { std::vector indices; @@ -28,8 +83,26 @@ std::unique_ptr processDataTable(DataTable* dataTable, const std::vec std::unique_ptr result; result.reset(dataTable); + for (const auto& action : processActions) { + if (std::holds_alternative(action)) { + continue; + } + + if (const auto* lod = std::get_if(&action)) { + auto& lodValues = ensureLodColumn(result.get()).asVector(); + const float lodValue = static_cast(lod->value); + for (float& value : lodValues) { + value = lodValue; + } + continue; + } + + if (const auto* decimate = std::get_if(&action)) { + result = simplifyGaussians(*result, computeKeepCount(result->getNumRows(), *decimate)); + continue; + } - for (size_t i = 0; i < processActions.size(); i++) { + throw std::runtime_error("Unsupported process action in current translation"); } return result; diff --git a/transform/process.h b/transform/process.h index 5e12543..d6c47cd 100644 --- a/transform/process.h +++ b/transform/process.h @@ -49,8 +49,14 @@ struct Lod { int value; }; +struct Decimate { + int count = -1; + float percent = -1.0f; +}; + using ProcessAction = - std::variant; + std::variant; std::unique_ptr processDataTable(DataTable* dataTable, const std::vector& processActions); diff --git a/viewer/CMakeLists.txt b/viewer/CMakeLists.txt index 899682e..10355b5 100644 --- a/viewer/CMakeLists.txt +++ b/viewer/CMakeLists.txt @@ -4,8 +4,8 @@ cmake_minimum_required(VERSION 3.14) # Find vcpkg packages # ============================================================================ find_package(glfw3 CONFIG REQUIRED) -find_package(glm CONFIG REQUIRED) -find_package(glew CONFIG REQUIRED) +find_package(GLEW REQUIRED) +find_package(OpenGL REQUIRED) # ============================================================================ # Dear ImGui @@ -42,29 +42,25 @@ target_include_directories(imgui_lib PUBLIC ${IMGUI_SOURCE_DIR} ${IMGUI_SOURCE_DIR}/backends ) -target_link_libraries(imgui_lib PUBLIC glfw GLEW::GLEW opengl32) +target_link_libraries(imgui_lib PUBLIC glfw GLEW::GLEW OpenGL::GL) # ============================================================================ # Splat Viewer executable # ============================================================================ set(VIEWER_SOURCES main.cpp - camera.cpp - renderer.cpp viewer_ui.cpp ) set(VIEWER_HEADERS - camera.h - renderer.h viewer_ui.h - shader.h ) add_executable(splat_viewer ${VIEWER_SOURCES} ${VIEWER_HEADERS}) target_include_directories(splat_viewer PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} + ${PROJECT_SOURCE_DIR}/src/visualization ) target_link_libraries(splat_viewer PRIVATE @@ -72,14 +68,7 @@ target_link_libraries(splat_viewer PRIVATE imgui_lib glfw GLEW::GLEW - glm::glm - opengl32 + OpenGL::GL ) -# Post-build: create shaders directory -add_custom_command(TARGET splat_viewer POST_BUILD - COMMAND ${CMAKE_COMMAND} -E make_directory - $/shaders -) - -message(STATUS "Splat Viewer: Building with vcpkg dependencies (glfw3, glm, glew)") +message(STATUS "Splat Viewer: Building ImGui controls with VTK visualization renderer") diff --git a/viewer/README.md b/viewer/README.md index 298b687..20369e4 100644 --- a/viewer/README.md +++ b/viewer/README.md @@ -1,13 +1,16 @@ # Splat Viewer -An OpenGL + Dear ImGui viewer for 3D Gaussian Splatting data. +An ImGui control shell for the VTK-based `splat::SplatVisualizer`. + +The viewer intentionally does not implement a second splat renderer. It loads splat files into the shared +`DataTable` model, forwards them to `SplatVisualizer`, and uses ImGui for runtime controls. ## Features -- **Multi-format support**: PLY, .splat, SOG, SPZ, KSplat, and LCC -- **Interactive camera controls**: Orbit, pan, zoom -- **ImGui overlay**: Statistics and rendering controls -- **Cross-platform**: Windows, macOS, Linux +- Multi-format support: PLY, `.splat`, SOG, SPZ, and KSplat +- VTK rendering through `splat::SplatVisualizer` +- Dear ImGui control window for render options and status +- Shared visualization path with the C++ library API ## Building @@ -18,60 +21,41 @@ cmake .. -DBUILD_SPLAT_VIEWER=ON cmake --build . --target splat_viewer ``` -### Dependencies (automatically fetched) +`BUILD_SPLAT_VIEWER=ON` automatically enables `BUILD_SPLAT_VISUALIZATION=ON`. -- **GLFW 3.3** - Window management -- **GLAD 0.1** - OpenGL loader -- **Dear ImGui 1.90** - UI framework -- **GLM 1.0** - Mathematics library +## Dependencies -## Usage +- VTK: rendering, interaction style, widgets, and OpenGL2 backend +- GLFW: ImGui control window +- GLEW and OpenGL: ImGui OpenGL backend +- Dear ImGui 1.90.9: fetched by CMake -### Launch +## Usage ```bash -# Open with file argument splat_viewer scene.ply - -# Open without file (press O to browse) splat_viewer ``` -### Controls +The VTK render window handles camera interaction. The ImGui control window handles loading and render options. | Action | Input | |--------|-------| -| Rotate | Left mouse drag | -| Pan | Right mouse drag | -| Zoom | Scroll wheel | -| Open file | O key | -| Reset camera | R key | -| Toggle stats | 1 key | -| Toggle controls | 2 key | +| Open file | O key in the control window | +| Reset camera | R key in the control window | +| Toggle stats | 1 key in the control window | +| Toggle controls | 2 key in the control window | +| Rotate / pan / zoom | Use the VTK render window | ## Architecture -``` +```text viewer/ -├── CMakeLists.txt # Build configuration with FetchContent -├── main.cpp # Application entry point -├── camera.h/cpp # Orbit camera implementation -├── renderer.h/cpp # OpenGL splat renderer -├── viewer_ui.h/cpp # ImGui UI components -└── shader.h # Shader compilation helper +├── CMakeLists.txt # ImGui control app target +├── main.cpp # File loading, app loop, SplatVisualizer integration +├── viewer_ui.h # ImGui state and UI declarations +└── viewer_ui.cpp # ImGui panels ``` -## Rendering Pipeline - -1. Load DataTable from file (PLY, .splat, etc.) -2. Extract position, color, scale, rotation columns -3. Upload to GPU vertex buffers -4. Render as point sprites with Gaussian falloff shader -5. ImGui overlay for controls and statistics - -## Notes - -- Colors are derived from SH DC coefficients: `color = f_dc * 0.282 + 0.5` -- Scales are stored in log space: `scale = exp(log_scale)` -- Quaternions are normalized on load -- Point size is dynamically adjusted based on depth +Rendering lives in `SplatVisualizer`, `SplatGaussianProp`, and the shared `GSplatGLRenderer`, so viewer behavior stays +aligned with the library visualization module. diff --git a/viewer/camera.cpp b/viewer/camera.cpp deleted file mode 100644 index 59ffbf6..0000000 --- a/viewer/camera.cpp +++ /dev/null @@ -1,8 +0,0 @@ -/** - * @file camera.cpp - * @brief Camera implementation (mostly header-only). - */ - -#include "camera.h" - -// Camera is implemented header-only in camera.h diff --git a/viewer/camera.h b/viewer/camera.h deleted file mode 100644 index d227dad..0000000 --- a/viewer/camera.h +++ /dev/null @@ -1,105 +0,0 @@ -/** - * @file camera.h - * @brief 3D camera with orbit controls for splat viewing. - */ - -#pragma once - -#include -#include - -namespace viewer { - -/** - * @brief Orbit camera for 3D scene navigation. - * - * Provides rotation around a target point, zoom via scroll, - * and pan with right mouse button. Uses spherical coordinates - * for intuitive camera control. - */ -class Camera { - public: - glm::vec3 target{0.0f}; ///< Camera target (look-at point) - float radius{5.0f}; ///< Distance from target - float theta{0.0f}; ///< Horizontal rotation (radians) - float phi{1.0f}; ///< Vertical rotation (radians), range (0, PI) - glm::vec3 up{0.0f, 1.0f, 0.0f}; ///< Up direction - - /** - * @brief Construct camera at default position. - */ - Camera() = default; - - /** - * @brief Set camera to look at a specific target. - * @param newTarget Target point - */ - void setTarget(const glm::vec3& newTarget) { target = newTarget; } - - /** - * @brief Set camera distance from target. - * @param newRadius Distance (clamped to [1.0, 1000.0]) - */ - void setRadius(float newRadius) { radius = glm::clamp(newRadius, 1.0f, 1000.0f); } - - /** - * @brief Rotate camera by delta angles. - * @param dTheta Horizontal rotation delta (radians) - * @param dPhi Vertical rotation delta (radians) - */ - void rotate(float dTheta, float dPhi) { - theta += dTheta; - phi = glm::clamp(phi + dPhi, 0.01f, 3.14f - 0.01f); - } - - /** - * @brief Pan the camera target. - * @param dx Horizontal pan amount - * @param dy Vertical pan amount - */ - void pan(float dx, float dy) { - glm::vec3 right = glm::normalize(glm::cross(getFront(), up)); - target += right * dx * radius * 0.001f; - target += up * dy * radius * 0.001f; - } - - /** - * @brief Zoom the camera (change radius). - * @param delta Positive = zoom out, negative = zoom in - */ - void zoom(float delta) { - radius *= 1.0f + delta * 0.01f; - radius = glm::clamp(radius, 1.0f, 1000.0f); - } - - /** - * @brief Get camera position from spherical coordinates. - * @return Camera position in world space - */ - glm::vec3 getPosition() const { - float x = radius * std::sin(phi) * std::cos(theta); - float y = radius * std::cos(phi); - float z = radius * std::sin(phi) * std::sin(theta); - return target + glm::vec3(x, y, z); - } - - /** - * @brief Get view direction vector. - * @return Normalized front vector - */ - glm::vec3 getFront() const { return glm::normalize(target - getPosition()); } - - /** - * @brief Get right vector. - * @return Normalized right vector - */ - glm::vec3 getRight() const { return glm::normalize(glm::cross(getFront(), up)); } - - /** - * @brief Get view matrix (world to camera). - * @return 4x4 view matrix - */ - glm::mat4 getViewMatrix() const { return glm::lookAt(getPosition(), target, up); } -}; - -} // namespace viewer diff --git a/viewer/main.cpp b/viewer/main.cpp index 8bfd855..a2489cf 100644 --- a/viewer/main.cpp +++ b/viewer/main.cpp @@ -1,18 +1,6 @@ /** * @file main.cpp - * @brief Splat Viewer - OpenGL + ImGui viewer for 3D Gaussian Splatting data. - * - * A lightweight viewer for visualizing 3D Gaussian Splatting scenes. - * Supports PLY, .splat, SOG, SPZ, KSplat, and LCC formats. - * - * Controls: - * - Left mouse drag: Rotate camera - * - Right mouse drag: Pan camera - * - Scroll wheel: Zoom in/out - * - O: Open file dialog - * - R: Reset camera - * - 1: Toggle stats panel - * - 2: Toggle controls panel + * @brief ImGui control shell for the VTK-based splat visualizer. */ #include @@ -21,43 +9,53 @@ #include #include #include - +#include "gsplat_data.h" +#include +#include +#include +#include + +#include +#include +#include #include +#include +#include #include -#include -#include #include +#include #include +#include +#include -#include "camera.h" -#include "renderer.h" #include "viewer_ui.h" - #ifdef _WIN32 #include #endif -namespace fs = std::filesystem; -using namespace viewer; - -// ============================================================================ -// Global state -// ============================================================================ -static glm::mat4 projectionMatrix{1.0f}; -static int windowWidth = 1280; -static int windowHeight = 720; -static bool mouseLeftDown = false; -static bool mouseRightDown = false; -static float lastMouseX = 0.0f; -static float lastMouseY = 0.0f; -static bool wantQuit = false; -static char statusMessage[256] = ""; -static float statusMessageTimer = 0.0f; - -// ============================================================================ -// File dialog (simple platform-specific implementation) -// ============================================================================ +namespace { + +constexpr const char* kCloudId = "viewer-splat"; +constexpr double kInteractionSortDelaySeconds = 0.75; + +int controlWindowWidth = 360; +int controlWindowHeight = 420; +bool wantQuit = false; +char statusMessage[256] = ""; +float statusMessageTimer = 0.0f; +std::array currentCameraBounds{0.0, 0.0, 0.0, 0.0, 0.0, 0.0}; +bool hasCameraBounds = false; + +struct FlyNavigationState { + bool forward = false; + bool backward = false; + bool left = false; + bool right = false; + bool down = false; + bool up = false; +}; + std::filesystem::path openFileDialog() { #ifdef _WIN32 OPENFILENAME ofn; @@ -86,156 +84,318 @@ std::filesystem::path openFileDialog() { #endif } -// ============================================================================ -// Load splat file -// ============================================================================ -bool loadFile(Renderer& renderer, ViewerUIState& state, Camera& camera, const std::filesystem::path& filename) { - try { - std::unique_ptr dataTable; - std::string ext = filename.extension().u8string(); - std::cout << "Loading file: " << filename.u8string() << std::endl; - std::cout << "Extension: " << ext << std::endl; +std::string lowerExtension(const std::filesystem::path& filename) { + std::string ext = filename.extension().u8string(); + for (char& c : ext) { + c = static_cast(std::tolower(static_cast(c))); + } + return ext; +} - for (char& c : ext) { - c = static_cast(std::tolower(static_cast(c))); - } +std::unique_ptr readSplatFile(const std::filesystem::path& filename) { + const std::string ext = lowerExtension(filename); + std::unique_ptr dataTable; + + if (ext == ".ply") { + dataTable = splat::readPly(filename); + } else if (ext == ".splat") { + dataTable = splat::readSplat(filename); + } else if (ext == ".sog") { + dataTable = splat::readSog(filename, filename); + } else if (ext == ".spz") { + dataTable = splat::readSpz(filename); + } else if (ext == ".ksplat") { + dataTable = splat::readKsplat(filename); + } else { + throw std::runtime_error("Unsupported file format: " + ext); + } - if (ext == ".ply") { - std::cout << "Reading PLY file..." << std::endl; - dataTable = splat::readPly(filename); - } else if (ext == ".splat") { - std::cout << "Reading .splat file..." << std::endl; - dataTable = splat::readSplat(filename); - } else if (ext == ".sog") { - std::cout << "Reading SOG file..." << std::endl; - dataTable = splat::readSog(filename, filename); - } else if (ext == ".spz") { - std::cout << "Reading SPZ file..." << std::endl; - dataTable = splat::readSpz(filename); - } else if (ext == ".ksplat") { - std::cout << "Reading KSplat file..." << std::endl; - dataTable = splat::readKsplat(filename); - } else { - snprintf(statusMessage, sizeof(statusMessage), "Unsupported file format: %s", ext.c_str()); - statusMessageTimer = 3.0f; - std::cerr << "Unsupported format: " << ext << std::endl; - return false; + return dataTable; +} + +splat::SplatRenderOptions makeRenderOptions(const viewer::ViewerUIState& state) { + splat::SplatRenderOptions options; + options.globalOpacity = state.opacity; + options.sizeScale = state.sizeScale; + options.maxPointSize = state.maxPointSize; + options.sortBackToFront = state.sortBackToFront; + options.clampColors = state.clampColors; + return options; +} + +bool sameRenderOptions(const splat::SplatRenderOptions& lhs, const splat::SplatRenderOptions& rhs) { + return lhs.globalOpacity == rhs.globalOpacity && lhs.sizeScale == rhs.sizeScale && + lhs.maxPointSize == rhs.maxPointSize && lhs.sortBackToFront == rhs.sortBackToFront && + lhs.clampColors == rhs.clampColors && lhs.freezeSortOrder == rhs.freezeSortOrder; +} + +bool isKeyPressed(GLFWwindow* window, int key) { return glfwGetKey(window, key) == GLFW_PRESS; } + +double boundsRadius(const std::array& bounds) { + const double dx = bounds[1] - bounds[0]; + const double dy = bounds[3] - bounds[2]; + const double dz = bounds[5] - bounds[4]; + return std::max(1e-3, 0.5 * std::sqrt(dx * dx + dy * dy + dz * dz)); +} + +std::array boundsCenter(const std::array& bounds) { + return { + 0.5 * (bounds[0] + bounds[1]), + 0.5 * (bounds[2] + bounds[3]), + 0.5 * (bounds[4] + bounds[5]), + }; +} + +void setInteriorClippingRange(splat::SplatVisualizer& visualizer, const std::array& bounds) { + auto* renderer = visualizer.getRenderer(); + if (renderer == nullptr) { + return; + } + + auto* camera = renderer->GetActiveCamera(); + const double radius = boundsRadius(bounds); + const double nearClip = std::max(radius * 0.0002, 0.002); + const double farClip = std::max(radius * 64.0, nearClip * 1000.0); + camera->SetClippingRange(nearClip, farClip); +} + +void placeCameraInsideBounds(splat::SplatVisualizer& visualizer, const std::array& bounds) { + auto* renderer = visualizer.getRenderer(); + if (renderer == nullptr) { + return; + } + + auto* camera = renderer->GetActiveCamera(); + double position[3]; + double focalPoint[3]; + camera->GetPosition(position); + camera->GetFocalPoint(focalPoint); + + double forward[3] = {focalPoint[0] - position[0], focalPoint[1] - position[1], focalPoint[2] - position[2]}; + if (vtkMath::Normalize(forward) == 0.0) { + forward[0] = 0.0; + forward[1] = 0.0; + forward[2] = -1.0; + } + + const auto center = boundsCenter(bounds); + const double radius = boundsRadius(bounds); + camera->SetPosition(center[0], center[1], center[2]); + camera->SetFocalPoint(center[0] + forward[0] * radius * 0.25, center[1] + forward[1] * radius * 0.25, + center[2] + forward[2] * radius * 0.25); + camera->OrthogonalizeViewUp(); + setInteriorClippingRange(visualizer, bounds); +} + +bool applyFlyNavigation(splat::SplatVisualizer& visualizer, const viewer::ViewerUIState& state, + const FlyNavigationState& keyState, GLFWwindow* controlWindow, double deltaSeconds) { + if (!visualizer.contains(kCloudId)) { + return false; + } + + FlyNavigationState combined = keyState; + combined.forward = combined.forward || isKeyPressed(controlWindow, GLFW_KEY_W); + combined.backward = combined.backward || isKeyPressed(controlWindow, GLFW_KEY_S); + combined.left = combined.left || isKeyPressed(controlWindow, GLFW_KEY_A); + combined.right = combined.right || isKeyPressed(controlWindow, GLFW_KEY_D); + combined.down = combined.down || isKeyPressed(controlWindow, GLFW_KEY_Q); + combined.up = combined.up || isKeyPressed(controlWindow, GLFW_KEY_E); + + const double forwardAmount = static_cast(combined.forward) - static_cast(combined.backward); + const double rightAmount = static_cast(combined.right) - static_cast(combined.left); + const double upAmount = static_cast(combined.up) - static_cast(combined.down); + if (forwardAmount == 0.0 && rightAmount == 0.0 && upAmount == 0.0) { + return false; + } + + auto* renderer = visualizer.getRenderer(); + if (renderer == nullptr) { + return false; + } + + auto* camera = renderer->GetActiveCamera(); + double position[3]; + double focalPoint[3]; + double viewUp[3]; + camera->GetPosition(position); + camera->GetFocalPoint(focalPoint); + camera->GetViewUp(viewUp); + + double forward[3] = {focalPoint[0] - position[0], focalPoint[1] - position[1], focalPoint[2] - position[2]}; + if (vtkMath::Normalize(forward) == 0.0) { + return false; + } + vtkMath::Normalize(viewUp); + double right[3]; + vtkMath::Cross(forward, viewUp, right); + if (vtkMath::Normalize(right) == 0.0) { + return false; + } + + double move[3] = { + forward[0] * forwardAmount + right[0] * rightAmount + viewUp[0] * upAmount, + forward[1] * forwardAmount + right[1] * rightAmount + viewUp[1] * upAmount, + forward[2] * forwardAmount + right[2] * rightAmount + viewUp[2] * upAmount, + }; + vtkMath::Normalize(move); + const double distance = std::max(0.0, static_cast(state.flySpeed)) * std::max(deltaSeconds, 0.001); + for (double& value : move) { + value *= distance; + } + + camera->SetPosition(position[0] + move[0], position[1] + move[1], position[2] + move[2]); + camera->SetFocalPoint(focalPoint[0] + move[0], focalPoint[1] + move[1], focalPoint[2] + move[2]); + if (hasCameraBounds) { + setInteriorClippingRange(visualizer, currentCameraBounds); + } + return true; +} + +std::pair robustRange(std::vector& values) { + std::sort(values.begin(), values.end()); + if (values.empty()) { + return {0.0f, 0.0f}; + } + + size_t trim = 0; + if (values.size() >= 1000) { + trim = std::max(1, values.size() / 1000); + } + + const size_t low = std::min(trim, values.size() - 1); + const size_t high = std::max(low, values.size() - 1 - trim); + return {values[low], values[high]}; +} + +float robustScalePadding(std::vector& values) { + std::sort(values.begin(), values.end()); + if (values.empty()) { + return 0.0f; + } + + size_t index = values.size() - 1; + if (values.size() >= 1000) { + index = std::min(values.size() - 1, values.size() - 1 - values.size() / 1000); + } + + return values[index] * 2.0f; +} + +std::array computeCameraBounds(const splat::DataTable& dataTable) { + const auto renderData = splat::visualization::adaptDataTableToGSplatData(dataTable); + if (renderData.empty()) { + return {-1.0, 1.0, -1.0, 1.0, -1.0, 1.0}; + } + + std::vector xs; + std::vector ys; + std::vector zs; + std::vector maxScales; + xs.reserve(renderData.size()); + ys.reserve(renderData.size()); + zs.reserve(renderData.size()); + maxScales.reserve(renderData.size()); + + for (std::size_t i = 0; i < renderData.size(); ++i) { + const auto& center = renderData.centers[i]; + const auto& scale = renderData.scales[i]; + const float maxScale = std::max({scale.x, scale.y, scale.z}); + if (std::isfinite(center.x) && std::isfinite(center.y) && std::isfinite(center.z) && std::isfinite(maxScale)) { + xs.push_back(center.x); + ys.push_back(center.y); + zs.push_back(center.z); + maxScales.push_back(maxScale); } + } + + if (xs.empty()) { + return {-1.0, 1.0, -1.0, 1.0, -1.0, 1.0}; + } + + const auto [minX, maxX] = robustRange(xs); + const auto [minY, maxY] = robustRange(ys); + const auto [minZ, maxZ] = robustRange(zs); + const float padding = robustScalePadding(maxScales); + + return {static_cast(minX - padding), static_cast(maxX + padding), + static_cast(minY - padding), static_cast(maxY + padding), + static_cast(minZ - padding), static_cast(maxZ + padding)}; +} + +void resetCameraToBounds(splat::SplatVisualizer& visualizer, const std::array& bounds) { + visualizer.resetCameraToBounds(bounds.data()); +} - if (!dataTable) { - snprintf(statusMessage, sizeof(statusMessage), "Failed to load data"); +bool loadFile(splat::SplatVisualizer& visualizer, viewer::ViewerUIState& state, + const std::filesystem::path& filename) { + try { + std::cout << "Loading file: " << filename.u8string() << std::endl; + auto dataTable = readSplatFile(filename); + if (!dataTable || dataTable->getNumRows() == 0) { + std::snprintf(statusMessage, sizeof(statusMessage), "No splat data found"); statusMessageTimer = 3.0f; - std::cerr << "Failed to load data" << std::endl; return false; } - std::cout << "Data loaded: " << dataTable->getNumRows() << " rows, " << dataTable->getNumColumns() << " columns" - << std::endl; - std::cout << "Column names: "; - for (size_t i = 0; i < dataTable->getNumColumns(); ++i) { - std::cout << dataTable->getColumn(i).name << " "; - } - std::cout << std::endl; + currentCameraBounds = computeCameraBounds(*dataTable); + hasCameraBounds = true; + state.flySpeed = static_cast(std::clamp(boundsRadius(currentCameraBounds) * 0.25, 0.05, 20.0)); - if (dataTable->getNumRows() == 0) { - snprintf(statusMessage, sizeof(statusMessage), "No splat data found"); + auto sharedData = std::shared_ptr(std::move(dataTable)); + const auto options = makeRenderOptions(state); + const bool ok = visualizer.contains(kCloudId) ? visualizer.updateSplatCloud(sharedData, kCloudId, options) + : visualizer.addSplatCloud(sharedData, kCloudId, options); + if (!ok) { + std::snprintf(statusMessage, sizeof(statusMessage), "Failed to upload splats"); statusMessageTimer = 3.0f; return false; } - // Compute scene bounds for camera setup - const auto& xCol = dataTable->getColumnByName("x"); - const auto& yCol = dataTable->getColumnByName("y"); - const auto& zCol = dataTable->getColumnByName("z"); - - float minX = xCol.getValue(0), maxX = minX; - float minY = yCol.getValue(0), maxY = minY; - float minZ = zCol.getValue(0), maxZ = minZ; - - for (size_t i = 1; i < dataTable->getNumRows(); ++i) { - minX = std::min(minX, xCol.getValue(i)); - maxX = std::max(maxX, xCol.getValue(i)); - minY = std::min(minY, yCol.getValue(i)); - maxY = std::max(maxY, yCol.getValue(i)); - minZ = std::min(minZ, zCol.getValue(i)); - maxZ = std::max(maxZ, zCol.getValue(i)); - } - - std::cout << "Bounds: X[" << minX << ", " << maxX << "], Y[" << minY << ", " << maxY << "], Z[" << minZ << ", " - << maxZ << "]" << std::endl; - - glm::vec3 center((minX + maxX) * 0.5f, (minY + maxY) * 0.5f, (minZ + maxZ) * 0.5f); - float extent = std::max({maxX - minX, maxY - minY, maxZ - minZ, 1.0f}); - - renderer.loadSplats(*dataTable); state.currentFile = filename; + state.splatCount = visualizer.getSplatCount(kCloudId); + resetCameraToBounds(visualizer, currentCameraBounds); - // Setup camera - camera.setTarget(center); - camera.radius = glm::clamp(extent * 3.0f, 1.0f, 10000.0f); - camera.theta = 0.0f; - camera.phi = glm::pi() / 3.0f; // 60 degrees from above - - snprintf(statusMessage, sizeof(statusMessage), "Loaded %zu splats", renderer.getSplatCount()); + std::cout << "Loaded " << state.splatCount << " splats" << std::endl; + std::snprintf(statusMessage, sizeof(statusMessage), "Loaded %zu splats", state.splatCount); statusMessageTimer = 3.0f; return true; } catch (const std::exception& e) { - snprintf(statusMessage, sizeof(statusMessage), "Error: %s", e.what()); + std::snprintf(statusMessage, sizeof(statusMessage), "Error: %s", e.what()); statusMessageTimer = 5.0f; std::cerr << "Error: " << e.what() << std::endl; return false; } } -// ============================================================================ -// Input callbacks -// ============================================================================ void framebufferSizeCallback(GLFWwindow*, int width, int height) { - windowWidth = width; - windowHeight = height; - glViewport(0, 0, width, height); + controlWindowWidth = std::max(width, 1); + controlWindowHeight = std::max(height, 1); + glViewport(0, 0, controlWindowWidth, controlWindowHeight); } -void mouseButtonCallback(GLFWwindow*, int button, int action, int) { - if (ImGui::GetIO().WantCaptureMouse) return; - - if (button == GLFW_MOUSE_BUTTON_LEFT) { - mouseLeftDown = (action == GLFW_PRESS); - } else if (button == GLFW_MOUSE_BUTTON_RIGHT) { - mouseRightDown = (action == GLFW_PRESS); +void renderStatusToast() { + if (statusMessageTimer <= 0.0f) { + return; } -} - -void cursorPosCallback(GLFWwindow*, double xpos, double ypos) { - if (ImGui::GetIO().WantCaptureMouse) return; - - float dx = static_cast(xpos) - lastMouseX; - float dy = static_cast(ypos) - lastMouseY; - lastMouseX = static_cast(xpos); - lastMouseY = static_cast(ypos); - if (mouseLeftDown) { - // Don't call mutable camera methods here - handle in main loop - } + statusMessageTimer -= ImGui::GetIO().DeltaTime; + ImVec2 center(ImGui::GetIO().DisplaySize.x * 0.5f, ImGui::GetIO().DisplaySize.y - 42.0f); + ImGui::SetNextWindowPos(center, ImGuiCond_Always, ImVec2(0.5f, 0.5f)); + ImGui::Begin("Status", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_AlwaysAutoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBackground); + ImGui::TextColored(ImVec4(1, 1, 1, 1), "%s", statusMessage); + ImGui::End(); } -void scrollCallback(GLFWwindow* window, double, double yoffset) { - if (ImGui::GetIO().WantCaptureMouse) return; - Camera& cam = *static_cast(glfwGetWindowUserPointer(window)); - cam.zoom(-static_cast(yoffset)); -} +} // namespace -// ============================================================================ -// Main entry point -// ============================================================================ int main(int argc, char** argv) { - // Parse command line for file argument std::filesystem::path initialFile; for (int i = 1; i < argc; ++i) { initialFile = std::filesystem::path(argv[i]); } - // Initialize GLFW if (!glfwInit()) { std::cerr << "Failed to initialize GLFW" << std::endl; return -1; @@ -246,168 +406,225 @@ int main(int argc, char** argv) { glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE); - // Create window - GLFWwindow* window = glfwCreateWindow(windowWidth, windowHeight, "Splat Viewer", nullptr, nullptr); - if (!window) { - std::cerr << "Failed to create GLFW window" << std::endl; + GLFWwindow* controlWindow = + glfwCreateWindow(controlWindowWidth, controlWindowHeight, "Splat Viewer Controls", nullptr, nullptr); + if (!controlWindow) { + std::cerr << "Failed to create GLFW control window" << std::endl; glfwTerminate(); return -1; } - glfwMakeContextCurrent(window); - glfwSetFramebufferSizeCallback(window, framebufferSizeCallback); - glfwSetMouseButtonCallback(window, mouseButtonCallback); - glfwSetCursorPosCallback(window, cursorPosCallback); - glfwSetScrollCallback(window, scrollCallback); - glfwSwapInterval(1); // VSync - - // Initialize GLEW + + glfwMakeContextCurrent(controlWindow); + glfwSetFramebufferSizeCallback(controlWindow, framebufferSizeCallback); + glfwSwapInterval(1); + glewExperimental = GL_TRUE; if (glewInit() != GLEW_OK) { std::cerr << "Failed to initialize GLEW" << std::endl; + glfwDestroyWindow(controlWindow); + glfwTerminate(); return -1; } - // Setup Dear ImGui context IMGUI_CHECKVERSION(); ImGui::CreateContext(); ImGui::StyleColorsDark(); - - ImGuiIO& io = ImGui::GetIO(); - io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; - - ImGui_ImplGlfw_InitForOpenGL(window, true); + ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; + ImGui_ImplGlfw_InitForOpenGL(controlWindow, true); ImGui_ImplOpenGL3_Init("#version 410"); - // Initialize viewer components - Renderer renderer; - renderer.initialize(); - - Camera camera; - ViewerUIState state; - - // Set camera user pointer for scroll callback - glfwSetWindowUserPointer(window, &camera); + viewer::ViewerUIState state; + FlyNavigationState flyKeys; + splat::SplatVisualizer visualizer("Splat Viewer"); + visualizer.setWindowSize(1280, 720); + visualizer.setBackgroundColor(state.bgR, state.bgG, state.bgB); + visualizer.setAxesEnabled(state.showAxes); + visualizer.setDefaultHotkeysEnabled(false); + visualizer.setDefaultMouseWheelEnabled(false); + visualizer.registerKeyCallback([&flyKeys, &visualizer](const splat::KeyEvent& event) { + const bool pressed = event.action == splat::KeyAction::Press; + if (event.keySym == "w" || event.keySym == "W") { + flyKeys.forward = pressed; + } else if (event.keySym == "s" || event.keySym == "S") { + flyKeys.backward = pressed; + } else if (event.keySym == "a" || event.keySym == "A") { + flyKeys.left = pressed; + } else if (event.keySym == "d" || event.keySym == "D") { + flyKeys.right = pressed; + } else if (event.keySym == "q" || event.keySym == "Q") { + flyKeys.down = pressed; + } else if (event.keySym == "e" || event.keySym == "E") { + flyKeys.up = pressed; + } else if (pressed && (event.keySym == "c" || event.keySym == "C") && hasCameraBounds) { + placeCameraInsideBounds(visualizer, currentCameraBounds); + } + }); - // Load initial file if provided if (!initialFile.empty()) { - loadFile(renderer, state, camera, initialFile); + loadFile(visualizer, state, initialFile); } - // Main loop - while (!glfwWindowShouldClose(window) && !wantQuit) { - glfwPollEvents(); - - // Keyboard input - if (io.WantCaptureKeyboard) { - // Reset last mouse pos to avoid jumps - double lx, ly; - glfwGetCursorPos(window, &lx, &ly); - lastMouseX = static_cast(lx); - lastMouseY = static_cast(ly); - } else { - int keyState[GLFW_KEY_LAST + 1] = {0}; - for (int key = 0; key <= GLFW_KEY_LAST; ++key) { - keyState[key] = glfwGetKey(window, key); + bool previousO = false; + bool previousR = false; + bool previousC = false; + bool previousOne = false; + bool previousTwo = false; + bool visualizerNeedsRedraw = true; + bool lastRenderOptionsValid = false; + bool vtkMouseButtonDown = false; + splat::SplatRenderOptions lastRenderOptions; + auto lastCameraInteractionTime = std::chrono::steady_clock::time_point::min(); + auto lastSortFreezeInteractionTime = std::chrono::steady_clock::time_point::min(); + auto previousFrameTime = std::chrono::steady_clock::now(); + visualizer.registerMouseCallback([&](const splat::MouseEvent& event) { + const auto markCameraInteraction = [&](bool freezeSort) { + const auto interactionTime = std::chrono::steady_clock::now(); + lastCameraInteractionTime = interactionTime; + visualizerNeedsRedraw = true; + if (!freezeSort || !state.fastInteraction || !state.sortBackToFront || !visualizer.contains(kCloudId)) { + return; + } + lastSortFreezeInteractionTime = interactionTime; + auto fastOptions = makeRenderOptions(state); + fastOptions.freezeSortOrder = true; + if (!lastRenderOptionsValid || !sameRenderOptions(fastOptions, lastRenderOptions)) { + visualizer.setSplatRenderOptions(kCloudId, fastOptions); + lastRenderOptions = fastOptions; + lastRenderOptionsValid = true; } + }; - static bool prevO = false; - bool oPressed = (keyState[GLFW_KEY_O] == GLFW_PRESS); - if (oPressed && !prevO) { - std::filesystem::path file = openFileDialog(); - if (!file.empty()) { - loadFile(renderer, state, camera, file); - } + const auto applyWheelDolly = [&]() { + if (event.wheelDelta == 0) { + return; } - prevO = oPressed; - - if (keyState[GLFW_KEY_R] == GLFW_PRESS) { - camera.theta = 0.0f; - camera.phi = 1.0f; - camera.radius = 5.0f; - snprintf(statusMessage, sizeof(statusMessage), "Camera reset"); - statusMessageTimer = 2.0f; + auto* renderer = visualizer.getRenderer(); + if (renderer == nullptr) { + return; } - - if (keyState[GLFW_KEY_1] == GLFW_PRESS) { - state.showStats = !state.showStats; + auto* camera = renderer->GetActiveCamera(); + const double factor = std::pow(1.1, static_cast(event.wheelDelta)); + camera->Dolly(factor); + if (hasCameraBounds) { + setInteriorClippingRange(visualizer, currentCameraBounds); } - - if (keyState[GLFW_KEY_2] == GLFW_PRESS) { - state.showControls = !state.showControls; + }; + + if (event.action == splat::MouseAction::Press || event.action == splat::MouseAction::DoubleClick) { + vtkMouseButtonDown = true; + markCameraInteraction(true); + } else if (event.action == splat::MouseAction::Release) { + vtkMouseButtonDown = false; + markCameraInteraction(false); + } else if (event.action == splat::MouseAction::Wheel || + (event.action == splat::MouseAction::Move && vtkMouseButtonDown && (event.dx() != 0 || event.dy() != 0))) { + if (event.action == splat::MouseAction::Wheel) { + applyWheelDolly(); } + markCameraInteraction(event.action != splat::MouseAction::Wheel); + } + }); + + while (!glfwWindowShouldClose(controlWindow) && !visualizer.wasStopped() && !wantQuit) { + const auto now = std::chrono::steady_clock::now(); + const double deltaSeconds = std::chrono::duration(now - previousFrameTime).count(); + previousFrameTime = now; + + glfwPollEvents(); - // Mouse rotation - double mx, my; - glfwGetCursorPos(window, &mx, &my); - float dx = static_cast(mx) - lastMouseX; - float dy = static_cast(my) - lastMouseY; - lastMouseX = static_cast(mx); - lastMouseY = static_cast(my); - - if (mouseLeftDown) { - camera.rotate(dx * 0.01f, dy * 0.01f); - } else if (mouseRightDown) { - camera.pan(dx, -dy); + const bool oPressed = glfwGetKey(controlWindow, GLFW_KEY_O) == GLFW_PRESS; + if (oPressed && !previousO && !ImGui::GetIO().WantCaptureKeyboard) { + const std::filesystem::path file = openFileDialog(); + if (!file.empty()) { + visualizerNeedsRedraw = loadFile(visualizer, state, file) || visualizerNeedsRedraw; + lastRenderOptionsValid = false; } } + previousO = oPressed; + + const bool rPressed = glfwGetKey(controlWindow, GLFW_KEY_R) == GLFW_PRESS; + if (rPressed && !previousR && !ImGui::GetIO().WantCaptureKeyboard) { + if (hasCameraBounds) { + resetCameraToBounds(visualizer, currentCameraBounds); + } else { + visualizer.resetCamera(); + } + visualizerNeedsRedraw = true; + } + previousR = rPressed; - // Get framebuffer size for HiDPI - int fbWidth, fbHeight; - glfwGetFramebufferSize(window, &fbWidth, &fbHeight); - float scale = static_cast(fbWidth) / windowWidth; - - // Render - glClearColor(state.bgR, state.bgG, state.bgB, 1.0f); - glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + const bool cPressed = glfwGetKey(controlWindow, GLFW_KEY_C) == GLFW_PRESS; + if (cPressed && !previousC && !ImGui::GetIO().WantCaptureKeyboard && hasCameraBounds) { + placeCameraInsideBounds(visualizer, currentCameraBounds); + visualizerNeedsRedraw = true; + } + previousC = cPressed; - // Setup projection - float aspect = static_cast(windowWidth) / windowHeight; - projectionMatrix = glm::perspective(glm::radians(60.0f), aspect, 0.1f, 1000.0f); + const bool onePressed = glfwGetKey(controlWindow, GLFW_KEY_1) == GLFW_PRESS; + if (onePressed && !previousOne && !ImGui::GetIO().WantCaptureKeyboard) { + state.showStats = !state.showStats; + } + previousOne = onePressed; - // Render scene - if (renderer.hasSplats()) { - glm::mat4 vp = projectionMatrix * camera.getViewMatrix(); - renderer.render(vp, camera.getPosition()); + const bool twoPressed = glfwGetKey(controlWindow, GLFW_KEY_2) == GLFW_PRESS; + if (twoPressed && !previousTwo && !ImGui::GetIO().WantCaptureKeyboard) { + state.showControls = !state.showControls; } + previousTwo = twoPressed; + + visualizer.setBackgroundColor(state.bgR, state.bgG, state.bgB); + visualizer.setAxesEnabled(state.showAxes); + if (visualizer.contains(kCloudId)) { + const bool flyMoved = applyFlyNavigation(visualizer, state, flyKeys, controlWindow, deltaSeconds); + if (flyMoved) { + lastCameraInteractionTime = now; + lastSortFreezeInteractionTime = now; + } + visualizerNeedsRedraw = flyMoved || visualizerNeedsRedraw; + auto renderOptions = makeRenderOptions(state); + const bool suppressSortForInteraction = + state.fastInteraction && state.sortBackToFront && + lastSortFreezeInteractionTime != std::chrono::steady_clock::time_point::min() && + std::chrono::duration(now - lastSortFreezeInteractionTime).count() < kInteractionSortDelaySeconds; + if (suppressSortForInteraction) { + renderOptions.freezeSortOrder = true; + } + if (!lastRenderOptionsValid || !sameRenderOptions(renderOptions, lastRenderOptions)) { + visualizer.setSplatRenderOptions(kCloudId, renderOptions); + lastRenderOptions = renderOptions; + lastRenderOptionsValid = true; + visualizerNeedsRedraw = true; + } + } + visualizer.spinOnce(0, visualizerNeedsRedraw); + visualizerNeedsRedraw = false; + + glfwMakeContextCurrent(controlWindow); + glViewport(0, 0, controlWindowWidth, controlWindowHeight); + glClearColor(0.02f, 0.02f, 0.025f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); - // Build ImGui UI ImGui_ImplOpenGL3_NewFrame(); ImGui_ImplGlfw_NewFrame(); ImGui::NewFrame(); - renderUIOverlay(state, camera, renderer, renderer.hasSplats()); - - // Status message toast - if (statusMessageTimer > 0.0f) { - statusMessageTimer -= ImGui::GetIO().DeltaTime; - ImVec2 center(ImGui::GetIO().DisplaySize.x * 0.5f, ImGui::GetIO().DisplaySize.y - 50.0f); - ImGui::SetNextWindowPos(center, ImGuiCond_Always, ImVec2(0.5f, 0.5f)); - ImGui::Begin("Status", nullptr, - ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_AlwaysAutoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBackground); - ImGui::TextColored(ImVec4(1, 1, 1, 1), "%s", statusMessage); - ImGui::End(); + viewer::renderUIOverlay(state, visualizer.contains(kCloudId)); + if (!visualizer.contains(kCloudId)) { + viewer::renderOpenPrompt(); } + renderStatusToast(); - if (!renderer.hasSplats()) { - renderOpenPrompt(); - } - - // Render ImGui ImGui::Render(); ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); - glfwMakeContextCurrent(window); - glfwSwapBuffers(window); + glfwSwapBuffers(controlWindow); } - // Cleanup + visualizer.close(); ImGui_ImplOpenGL3_Shutdown(); ImGui_ImplGlfw_Shutdown(); ImGui::DestroyContext(); - - glfwDestroyWindow(window); + glfwDestroyWindow(controlWindow); glfwTerminate(); - return 0; } diff --git a/viewer/renderer.cpp b/viewer/renderer.cpp deleted file mode 100644 index 3b2b844..0000000 --- a/viewer/renderer.cpp +++ /dev/null @@ -1,229 +0,0 @@ -/** - * @file renderer.cpp - * @brief OpenGL renderer implementation for Gaussian splatting. - */ - -#include "renderer.h" - -#include - -#include - -#include "shader.h" - -namespace viewer { - -/** Vertex shader for point-based splat rendering */ -static const char* splatVertSrc = R"( -#version 410 core - -layout (location = 0) in vec3 aPos; -layout (location = 1) in vec3 aColor; -layout (location = 2) in vec3 aScale; -layout (location = 3) in vec4 aRotation; - -uniform mat4 uVP; -uniform vec3 uCameraPos; - -out vec3 vColor; -out vec3 vViewPos; -out vec3 vWorldPos; -out vec3 vScale; -out vec4 vRotation; - -void main() { - vColor = aColor; - vScale = aScale; - vRotation = aRotation; - vWorldPos = aPos; - vViewPos = (uVP * vec4(aPos, 1.0)).xyz; - gl_Position = uVP * vec4(aPos, 1.0); - gl_PointSize = max(2.0, 200.0 / length(vViewPos)); -} -)"; - -/** Fragment shader for splat visualization */ -static const char* splatFragSrc = R"( -#version 410 core - -in vec3 vColor; -in vec3 vViewPos; -in vec3 vWorldPos; -in vec3 vScale; -in vec4 vRotation; - -uniform float uOpacity; - -out vec4 fragColor; - -void main() { - // Compute point coordinates relative to circle center - vec2 screenPos = gl_PointCoord * 2.0 - 1.0; - float dist = length(screenPos); - - // Soft Gaussian falloff - float alpha = exp(-dist * dist * 2.0); - - if (alpha < 0.01) discard; - - fragColor = vec4(vColor * alpha, alpha * uOpacity); -} -)"; - -Renderer::Renderer() {} - -Renderer::~Renderer() { unloadSplats(); } - -void Renderer::initialize() { - if (initialized) return; - createShader(); - initialized = true; -} - -void Renderer::createShader() { shader = std::make_unique(splatVertSrc, splatFragSrc); } - -void Renderer::loadSplats(const splat::DataTable& dataTable) { - unloadSplats(); - - size_t count = dataTable.getNumRows(); - if (count == 0) return; - - // Allocate buffers - std::vector positions(count * 3); - std::vector colors(count * 3); - std::vector scales(count * 3); - std::vector rotations(count * 4); - - // Extract columns - const auto& xCol = dataTable.getColumnByName("x"); - const auto& yCol = dataTable.getColumnByName("y"); - const auto& zCol = dataTable.getColumnByName("z"); - const auto& f_dc0 = dataTable.getColumnByName("f_dc_0"); - const auto& f_dc1 = dataTable.getColumnByName("f_dc_1"); - const auto& f_dc2 = dataTable.getColumnByName("f_dc_2"); - const auto& opacity = dataTable.getColumnByName("opacity"); - const auto& scale0 = dataTable.getColumnByName("scale_0"); - const auto& scale1 = dataTable.getColumnByName("scale_1"); - const auto& scale2 = dataTable.getColumnByName("scale_2"); - const auto& rot0 = dataTable.getColumnByName("rot_0"); - const auto& rot1 = dataTable.getColumnByName("rot_1"); - const auto& rot2 = dataTable.getColumnByName("rot_2"); - const auto& rot3 = dataTable.getColumnByName("rot_3"); - - const float SH_C0 = 0.28209479177387814f; - - auto sigmoid = [](float x) { return 1.0f / (1.0f + std::exp(-x)); }; - - for (size_t i = 0; i < count; ++i) { - // Position - positions[i * 3 + 0] = xCol.getValue(i); - positions[i * 3 + 1] = yCol.getValue(i); - positions[i * 3 + 2] = zCol.getValue(i); - - // Colors from SH DC coefficients - colors[i * 3 + 0] = f_dc0.getValue(i) * SH_C0 + 0.5f; - colors[i * 3 + 1] = f_dc1.getValue(i) * SH_C0 + 0.5f; - colors[i * 3 + 2] = f_dc2.getValue(i) * SH_C0 + 0.5f; - - // Clamp colors - colors[i * 3 + 0] = std::clamp(colors[i * 3 + 0], 0.0f, 1.0f); - colors[i * 3 + 1] = std::clamp(colors[i * 3 + 1], 0.0f, 1.0f); - colors[i * 3 + 2] = std::clamp(colors[i * 3 + 2], 0.0f, 1.0f); - - // Scales (exp for log-scale) - scales[i * 3 + 0] = std::exp(scale0.getValue(i)); - scales[i * 3 + 1] = std::exp(scale1.getValue(i)); - scales[i * 3 + 2] = std::exp(scale2.getValue(i)); - - // Rotation (normalize quaternion) - float r0 = rot0.getValue(i); - float r1 = rot1.getValue(i); - float r2 = rot2.getValue(i); - float r3 = rot3.getValue(i); - float len = std::sqrt(r0 * r0 + r1 * r1 + r2 * r2 + r3 * r3); - if (len > 0.0f) { - rotations[i * 4 + 0] = r0 / len; - rotations[i * 4 + 1] = r1 / len; - rotations[i * 4 + 2] = r2 / len; - rotations[i * 4 + 3] = r3 / len; - } - } - - // Create VAO and buffers - glGenVertexArrays(1, &splatData.vao); - glBindVertexArray(splatData.vao); - - // Position buffer - glGenBuffers(1, &splatData.posVbo); - glBindBuffer(GL_ARRAY_BUFFER, splatData.posVbo); - glBufferData(GL_ARRAY_BUFFER, positions.size() * sizeof(GLfloat), positions.data(), GL_STATIC_DRAW); - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, nullptr); - glEnableVertexAttribArray(0); - - // Color buffer - glGenBuffers(1, &splatData.colorVbo); - glBindBuffer(GL_ARRAY_BUFFER, splatData.colorVbo); - glBufferData(GL_ARRAY_BUFFER, colors.size() * sizeof(GLfloat), colors.data(), GL_STATIC_DRAW); - glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, nullptr); - glEnableVertexAttribArray(1); - - // Scale buffer - glGenBuffers(1, &splatData.scaleVbo); - glBindBuffer(GL_ARRAY_BUFFER, splatData.scaleVbo); - glBufferData(GL_ARRAY_BUFFER, scales.size() * sizeof(GLfloat), scales.data(), GL_STATIC_DRAW); - glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, 0, nullptr); - glEnableVertexAttribArray(2); - - // Rotation buffer - glGenBuffers(1, &splatData.rotationVbo); - glBindBuffer(GL_ARRAY_BUFFER, splatData.rotationVbo); - glBufferData(GL_ARRAY_BUFFER, rotations.size() * sizeof(GLfloat), rotations.data(), GL_STATIC_DRAW); - glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, 0, nullptr); - glEnableVertexAttribArray(3); - - glBindVertexArray(0); - - splatData.splatCount = count; - splatData.isLoaded = true; - - std::cout << "Loaded " << count << " splats" << std::endl; -} - -void Renderer::unloadSplats() { - if (splatData.vao) glDeleteVertexArrays(1, &splatData.vao); - if (splatData.posVbo) glDeleteBuffers(1, &splatData.posVbo); - if (splatData.colorVbo) glDeleteBuffers(1, &splatData.colorVbo); - if (splatData.scaleVbo) glDeleteBuffers(1, &splatData.scaleVbo); - if (splatData.rotationVbo) glDeleteBuffers(1, &splatData.rotationVbo); - - splatData.vao = 0; - splatData.posVbo = 0; - splatData.colorVbo = 0; - splatData.scaleVbo = 0; - splatData.rotationVbo = 0; - splatData.splatCount = 0; - splatData.isLoaded = false; -} - -void Renderer::render(const glm::mat4& vp, const glm::vec3& eyePosition) { - if (!splatData.isLoaded || !shader) return; - - shader->use(); - shader->setMat4("uVP", vp); - shader->setVec3("uCameraPos", eyePosition); - shader->setFloat("uOpacity", 1.0f); - - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - glEnable(GL_DEPTH_TEST); - glDepthMask(GL_FALSE); - - glBindVertexArray(splatData.vao); - glDrawArrays(GL_POINTS, 0, static_cast(splatData.splatCount)); - glBindVertexArray(0); - - glDepthMask(GL_TRUE); - glDisable(GL_BLEND); -} - -} // namespace viewer diff --git a/viewer/renderer.h b/viewer/renderer.h deleted file mode 100644 index b6fe15b..0000000 --- a/viewer/renderer.h +++ /dev/null @@ -1,96 +0,0 @@ -/** - * @file renderer.h - * @brief OpenGL renderer for Gaussian splat visualization. - */ - -#pragma once - -#include - -#include -#include -#include -#include - -namespace splat { -class DataTable; -} - -namespace viewer { - -class Shader; - -/** - * @brief Splat data buffers for GPU rendering. - */ -struct SplatData { - GLuint vao = 0; ///< Vertex array object - GLuint posVbo = 0; ///< Position buffer - GLuint colorVbo = 0; ///< Color buffer - GLuint scaleVbo = 0; ///< Scale buffer - GLuint rotationVbo = 0; ///< Rotation buffer - size_t splatCount = 0; ///< Number of splats - bool isLoaded = false; ///< Data loaded flag -}; - -/** - * @brief OpenGL renderer for 3D Gaussian splats. - * - * Manages GPU buffers and rendering pipeline for Gaussian splat data. - */ -class Renderer { - public: - Renderer(); - ~Renderer(); - - /** - * @brief Load splat data from DataTable. - * @param dataTable Input splat data - */ - void loadSplats(const splat::DataTable& dataTable); - - /** - * @brief Unload current splat data. - */ - void unloadSplats(); - - /** - * @brief Render splats with given view-projection matrix. - * @param vp View-projection matrix - * @param eyePosition Camera position in world space - */ - void render(const glm::mat4& vp, const glm::vec3& eyePosition); - - /** - * @brief Check if splat data is loaded. - * @return true if data is loaded - */ - bool hasSplats() const { return splatData.isLoaded; } - - /** - * @brief Get number of loaded splats. - * @return Splat count - */ - size_t getSplatCount() const { return splatData.splatCount; } - - /** - * @brief Initialize the renderer (shaders, etc). - */ - void initialize(); - - /** - * @brief Set point size for fallback rendering. - * @param size Point size in pixels - */ - void setPointSize(float size) { pointSize = size; } - - private: - SplatData splatData; - std::unique_ptr shader; - float pointSize = 3.0f; - bool initialized = false; - - void createShader(); -}; - -} // namespace viewer diff --git a/viewer/shader.h b/viewer/shader.h deleted file mode 100644 index 7ac9113..0000000 --- a/viewer/shader.h +++ /dev/null @@ -1,130 +0,0 @@ -/** - * @file shader.h - * @brief OpenGL shader compilation and program management. - */ - -#pragma once - -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace viewer { - -/** - * @brief OpenGL shader program wrapper. - * - * Handles shader source loading, compilation, linking, and - * uniform variable setting. - */ -class Shader { - public: - GLuint id; ///< Program object ID - - /** - * @brief Construct shader program from source strings. - * - * @param vertexSource Vertex shader GLSL source - * @param fragmentSource Fragment shader GLSL source - */ - Shader(const char* vertexSource, const char* fragmentSource) { - // Compile vertex shader - GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER); - glShaderSource(vertexShader, 1, &vertexSource, nullptr); - glCompileShader(vertexShader); - checkCompileErrors(vertexShader, "VERTEX"); - - // Compile fragment shader - GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER); - glShaderSource(fragmentShader, 1, &fragmentSource, nullptr); - glCompileShader(fragmentShader); - checkCompileErrors(fragmentShader, "FRAGMENT"); - - // Link program - id = glCreateProgram(); - glAttachShader(id, vertexShader); - glAttachShader(id, fragmentShader); - glLinkProgram(id); - checkCompileErrors(id, "PROGRAM"); - - // Clean up shaders - glDeleteShader(vertexShader); - glDeleteShader(fragmentShader); - } - - /** - * @brief Load shader sources from files. - * - * @param vertexPath Path to vertex shader file - * @param fragmentPath Path to fragment shader file - */ - static Shader fromFiles(const std::filesystem::path& vertexPath, const std::filesystem::path& fragmentPath) { - std::string vertexCode = readFile(vertexPath); - std::string fragmentCode = readFile(fragmentPath); - return Shader(vertexCode.c_str(), fragmentCode.c_str()); - } - - /** - * @brief Activate shader program. - */ - void use() const { glUseProgram(id); } - - // Uniform setters - void setBool(const std::string& name, bool value) const { - glUniform1i(glGetUniformLocation(id, name.c_str()), static_cast(value)); - } - void setInt(const std::string& name, int value) const { glUniform1i(glGetUniformLocation(id, name.c_str()), value); } - void setFloat(const std::string& name, float value) const { - glUniform1f(glGetUniformLocation(id, name.c_str()), value); - } - void setVec3(const std::string& name, const glm::vec3& value) const { - glUniform3fv(glGetUniformLocation(id, name.c_str()), 1, glm::value_ptr(value)); - } - void setMat4(const std::string& name, const glm::mat4& value) const { - glUniformMatrix4fv(glGetUniformLocation(id, name.c_str()), 1, GL_FALSE, glm::value_ptr(value)); - } - - private: - /** - * @brief Read file contents as string. - */ - static std::string readFile(const std::filesystem::path& path) { - std::ifstream file(path); - if (!file.is_open()) { - throw std::runtime_error(std::string("Failed to open shader file: ") + path.u8string()); - } - std::stringstream stream; - stream << file.rdbuf(); - return stream.str(); - } - - /** - * @brief Check compilation/linking errors. - */ - static void checkCompileErrors(GLuint shader, const std::string& type) { - GLint success; - GLchar infoLog[1024]; - if (type != "PROGRAM") { - glGetShaderiv(shader, GL_COMPILE_STATUS, &success); - if (!success) { - glGetShaderInfoLog(shader, 1024, nullptr, infoLog); - std::cerr << "Shader compilation error (" << type << "): " << infoLog << std::endl; - } - } else { - glGetProgramiv(shader, GL_LINK_STATUS, &success); - if (!success) { - glGetProgramInfoLog(shader, 1024, nullptr, infoLog); - std::cerr << "Program linking error: " << infoLog << std::endl; - } - } - } -}; - -} // namespace viewer diff --git a/viewer/viewer_ui.cpp b/viewer/viewer_ui.cpp index c57f5e1..01f83f0 100644 --- a/viewer/viewer_ui.cpp +++ b/viewer/viewer_ui.cpp @@ -7,63 +7,55 @@ #include -#include -#include - -#include "camera.h" -#include "renderer.h" - namespace viewer { /** * @brief Render the ImGui UI overlay. * * @param state UI state - * @param camera Camera for display - * @param renderer Renderer for splat count info * @param hasFile Whether a file is loaded */ -void renderUIOverlay(ViewerUIState& state, const Camera& camera, const Renderer& renderer, bool hasFile) { +void renderUIOverlay(ViewerUIState& state, bool hasFile) { // Stats panel if (state.showStats) { ImGui::SetNextWindowPos(ImVec2(10, 10)); - ImGui::SetNextWindowSize(ImVec2(260, 120)); + ImGui::SetNextWindowSize(ImVec2(300, 96)); ImGui::Begin("Statistics", nullptr, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoTitleBar); if (hasFile) { const std::string displayName = state.currentFile.filename().u8string(); ImGui::Text("File: %s", displayName.c_str()); - ImGui::Text("Splats: %zu", renderer.getSplatCount()); + ImGui::Text("Splats: %zu", state.splatCount); } else { ImGui::Text("No file loaded"); } - ImGui::Text("Camera: (%.2f, %.2f, %.2f)", camera.getPosition().x, camera.getPosition().y, camera.getPosition().z); - ImGui::Text("Distance: %.2f", camera.radius); - ImGui::End(); } // Controls panel if (state.showControls) { - ImGui::SetNextWindowPos(ImVec2(10, 140)); - ImGui::SetNextWindowSize(ImVec2(260, 200)); + ImGui::SetNextWindowPos(ImVec2(10, 116)); + ImGui::SetNextWindowSize(ImVec2(300, 286)); ImGui::Begin("Controls", nullptr, ImGuiWindowFlags_NoResize); ImGui::TextUnformatted("Rendering"); ImGui::SliderFloat("Opacity", &state.opacity, 0.1f, 1.0f); - ImGui::SliderFloat("Point Size", &state.pointSize, 1.0f, 10.0f); - ImGui::Checkbox("Wireframe", &state.showWireframe); + ImGui::SliderFloat("Size Scale", &state.sizeScale, 0.1f, 10.0f); + ImGui::SliderFloat("Max Point Size", &state.maxPointSize, 32.0f, 1024.0f); + ImGui::SliderFloat("Fly Speed", &state.flySpeed, 0.05f, 20.0f); + ImGui::Checkbox("Sort Back To Front", &state.sortBackToFront); + ImGui::Checkbox("Fast Interaction", &state.fastInteraction); + ImGui::Checkbox("Clamp Colors", &state.clampColors); + ImGui::Checkbox("Axes", &state.showAxes); ImGui::TextUnformatted("Background Color"); ImGui::ColorEdit3("Color", &state.bgR, ImGuiColorEditFlags_NoInputs); - ImGui::TextUnformatted("Camera"); - ImGui::SliderFloat("Radius", &const_cast(camera).radius, 1.0f, 1000.0f); - ImGui::TextUnformatted(""); - ImGui::TextDisabled("Left-drag: Rotate | Right-drag: Pan"); - ImGui::TextDisabled("Scroll: Zoom"); + ImGui::TextDisabled("VTK window handles camera interaction"); + ImGui::TextDisabled("O: open | R: reset | C: center inside"); + ImGui::TextDisabled("WASD/QE: fly camera through rooms"); ImGui::End(); } diff --git a/viewer/viewer_ui.h b/viewer/viewer_ui.h index f9c4db1..b08f70a 100644 --- a/viewer/viewer_ui.h +++ b/viewer/viewer_ui.h @@ -6,10 +6,6 @@ #pragma once #include -#include - -#include "camera.h" -#include "renderer.h" namespace viewer { @@ -20,9 +16,14 @@ struct ViewerUIState { std::filesystem::path currentFile; ///< Currently loaded file path size_t splatCount = 0; ///< Number of loaded splats float opacity = 1.0f; ///< Global opacity control - float pointSize = 3.0f; ///< Point size + float sizeScale = 1.0f; ///< Gaussian size multiplier + float maxPointSize = 1024.0f; ///< Upper bound for projected splat quads + float flySpeed = 2.0f; ///< First-person camera movement speed float bgR = 0.05f, bgG = 0.05f, bgB = 0.08f; ///< Background color - bool showWireframe = false; ///< Wireframe rendering mode + bool showAxes = true; ///< Show VTK axes widget + bool sortBackToFront = true; ///< Sort splats for alpha blending + bool fastInteraction = true; ///< Temporarily relax sorting while camera is moving + bool clampColors = true; ///< Clamp SH DC colors to display range bool showStats = true; ///< Show statistics panel bool showControls = true; ///< Show controls panel }; @@ -30,7 +31,7 @@ struct ViewerUIState { /** * @brief Render the ImGui UI overlay. */ -void renderUIOverlay(ViewerUIState& state, const Camera& camera, const Renderer& renderer, bool hasFile); +void renderUIOverlay(ViewerUIState& state, bool hasFile); /** * @brief Render the file open dialog prompt.