GSplat: Implement SPZ format importer

The SPZ format is more space efficient storing gaussian splats, and is
quite popular on the Internet.

This commit implements it naively without using the SPZ library.
Motivation:

- Simplifies platform maintenance.

- Allows to have writes of different versions.
  For example, glTF currently mentions SPZ v2 in the extension draft
  while the current SPZ library is v4 (it is a bit unclear whether glTF
  is talking about library or format version tho).

- Lowers peak memory usage by avoiding multiple copies of the same data
  in memory.

Currently does not implement extensions reading, and does not handle
the anti-alias flag.

Expects the content to follow the convention of Right-Up-Back.
An observation was made that not all editors and tools create SPZ files
with this convention. Even the ply_to_spz command line tool from the
SPZ repository does not transform the typical PLY convention assumed by
the library to the SPZ convention. In practice it means that a manual
re-orientation of the import might be required.

The commit is limited to import via the File menu and dropping the
file onto the viewport. Support of importing SPZ via geometry nodes is
coming soon.

Ref #159470
This commit is contained in:
Sergey Sharybin 2026-09-08 12:21:53 +02:00 • committed by Sergey Sharybin
parent 9f130c92dc
commit 330ef38ef3
23 changed files with 1478 additions and 0 deletions

View file

@ -475,6 +475,7 @@ option(WITH_IO_PLY "Enable PLY 3D file format support (*.ply)" ON)
option(WITH_IO_STL "Enable STL 3D file format support (*.stl)" ON)
option(WITH_IO_FBX "Enable FBX 3D file format support (*.fbx)" ON)
option(WITH_IO_GREASE_PENCIL "Enable Grease Pencil file format IO (*.svg, *.pdf)" ON)
option(WITH_IO_SPZ "Enable SPZ compressed 3D gaussian splats file format support (*.spz)" ON)
# Sound output
option(WITH_SDL_AUDIO "Enable SDL audio support" OFF)

View file

@ -37,6 +37,7 @@ set(WITH_IO_FBX ON CACHE BOOL "" FORCE)
set(WITH_IO_GREASE_PENCIL ON CACHE BOOL "" FORCE)
set(WITH_IO_PLY ON CACHE BOOL "" FORCE)
set(WITH_IO_STL ON CACHE BOOL "" FORCE)
set(WITH_IO_SPZ ON CACHE BOOL "" FORCE)
set(WITH_IO_WAVEFRONT_OBJ ON CACHE BOOL "" FORCE)
set(WITH_LIBMV ON CACHE BOOL "" FORCE)
set(WITH_LIBMV_SCHUR_SPECIALIZATIONS ON CACHE BOOL "" FORCE)

View file

@ -35,6 +35,7 @@ set(WITH_INPUT_NDOF OFF CACHE BOOL "" FORCE)
set(WITH_INTERNATIONAL OFF CACHE BOOL "" FORCE)
set(WITH_IO_PLY OFF CACHE BOOL "" FORCE)
set(WITH_IO_STL OFF CACHE BOOL "" FORCE)
set(WITH_IO_SPZ OFF CACHE BOOL "" FORCE)
set(WITH_IO_WAVEFRONT_OBJ OFF CACHE BOOL "" FORCE)
set(WITH_IO_GREASE_PENCIL OFF CACHE BOOL "" FORCE)
set(WITH_IO_FBX OFF CACHE BOOL "" FORCE)

View file

@ -40,6 +40,7 @@ set(WITH_IO_FBX ON CACHE BOOL "" FORCE)
set(WITH_IO_GREASE_PENCIL ON CACHE BOOL "" FORCE)
set(WITH_IO_PLY ON CACHE BOOL "" FORCE)
set(WITH_IO_STL ON CACHE BOOL "" FORCE)
set(WITH_IO_SPZ ON CACHE BOOL "" FORCE)
set(WITH_IO_WAVEFRONT_OBJ ON CACHE BOOL "" FORCE)
set(WITH_LIBMV ON CACHE BOOL "" FORCE)
set(WITH_LIBMV_SCHUR_SPECIALIZATIONS ON CACHE BOOL "" FORCE)

View file

@ -402,6 +402,8 @@ class TOPBAR_MT_file_import(Menu):
if bpy.app.build_options.io_ply:
self.layout.operator(
"wm.ply_import", text=FileHandler.label_with_extensions("IO_FH_ply"))
if bpy.app.build_options.io_spz:
self.layout.operator("wm.spz_import", text=FileHandler.label_with_extensions("IO_FH_spz"))
if bpy.app.build_options.io_stl:
self.layout.operator(
"wm.stl_import", text=FileHandler.label_with_extensions("IO_FH_stl"))

View file

@ -9,6 +9,7 @@ set(INC
../../io/fbx
../../io/grease_pencil
../../io/ply
../../io/spz
../../io/stl
../../io/usd
../../io/wavefront_obj
@ -28,6 +29,7 @@ set(SRC
io_obj.cc
io_ops.cc
io_ply_ops.cc
io_spz_ops.cc
io_stl_ops.cc
io_usd.cc
io_utils.cc
@ -40,6 +42,7 @@ set(SRC
io_obj.hh
io_ops.hh
io_ply_ops.hh
io_spz_ops.hh
io_stl_ops.hh
io_usd.hh
io_utils.hh
@ -71,6 +74,13 @@ if(WITH_IO_PLY)
add_definitions(-DWITH_IO_PLY)
endif()
if(WITH_IO_SPZ)
list(APPEND LIB
bf_io_spz
)
add_definitions(-DWITH_IO_SPZ)
endif()
if(WITH_IO_STL)
list(APPEND LIB
bf_io_stl

View file

@ -27,6 +27,7 @@
#include "io_grease_pencil.hh"
#include "io_obj.hh"
#include "io_ply_ops.hh"
#include "io_spz_ops.hh"
#include "io_stl_ops.hh"
namespace blender {
@ -79,6 +80,11 @@ void ED_operatortypes_io()
ed::io::stl_file_handler_add();
#endif
#ifdef WITH_IO_SPZ
WM_operatortype_append(WM_OT_spz_import);
ed::io::spz_file_handler_add();
#endif
#ifdef WITH_IO_FBX
WM_operatortype_append(WM_OT_fbx_import);
ed::io::fbx_file_handler_add();

View file

@ -0,0 +1,105 @@
/* SPDX-FileCopyrightText: 2026 Blender Authors
*
* SPDX-License-Identifier: GPL-2.0-or-later */
/** \file
* \ingroup editor/io
*/
#ifdef WITH_IO_SPZ
# include "io_spz_ops.hh"
# include <memory>
# include "BLI_string_utf8.hh"
# include "BKE_context.hh"
# include "BKE_file_handler.hh"
# include "RNA_access.hh"
# include "RNA_define.hh"
# include "WM_api.hh"
# include "WM_types.hh"
# include "ED_outliner.hh"
# include "IO_spz.hh"
# include "io_utils.hh"
namespace blender {
static wmOperatorStatus wm_spz_import_exec(bContext *C, wmOperator *op)
{
SPZImportParams params{
.reports = op->reports,
};
const Vector<std::string> paths = ed::io::paths_from_operator_properties(op->ptr);
if (paths.is_empty()) {
BKE_report(op->reports, RPT_ERROR, "No filepath given");
return OPERATOR_CANCELLED;
}
for (const std::string &path : paths) {
params.filepath = path;
SPZ_import(C, &params);
}
Scene *scene = CTX_data_scene(C);
WM_event_add_notifier(C, NC_SCENE | ND_OB_SELECT, scene);
WM_event_add_notifier(C, NC_SCENE | ND_OB_ACTIVE, scene);
WM_event_add_notifier(C, NC_SCENE | ND_LAYER_CONTENT, scene);
ED_outliner_select_sync_from_object_tag(C);
return OPERATOR_FINISHED;
}
static void wm_spz_import_draw(bContext * /*C*/, wmOperator * /*op*/) {}
void WM_OT_spz_import(wmOperatorType *ot)
{
PropertyRNA *prop;
ot->name = "Import SPZ";
ot->description = "Import an SPZ file as a gaussian splat point cloud object";
ot->idname = "WM_OT_spz_import";
ot->invoke = ed::io::filesel_drop_import_invoke;
ot->exec = wm_spz_import_exec;
ot->ui = wm_spz_import_draw;
ot->poll = WM_operator_winactive;
ot->flag = OPTYPE_UNDO;
WM_operator_properties_filesel(ot,
FILE_TYPE_FOLDER,
FILE_BLENDER,
FILE_OPENFILE,
WM_FILESEL_FILEPATH | WM_FILESEL_FILES | WM_FILESEL_DIRECTORY,
FILE_DEFAULTDISPLAY,
FILE_SORT_DEFAULT);
/* Only show `.spz` files by default. */
prop = RNA_def_string(ot->srna, "filter_glob", "*.spz", 0, "Extension Filter", "");
RNA_def_property_flag(prop, PROP_HIDDEN);
}
namespace ed::io {
void spz_file_handler_add()
{
auto fh = std::make_unique<bke::FileHandlerType>();
STRNCPY_UTF8(fh->idname, "IO_FH_spz");
STRNCPY_UTF8(fh->import_operator, "WM_OT_spz_import");
STRNCPY_UTF8(fh->label, "SPZ");
STRNCPY_UTF8(fh->file_extensions_str, ".spz");
fh->poll_drop = poll_file_object_drop;
bke::file_handler_add(std::move(fh));
}
} // namespace ed::io
} // namespace blender
#endif /* WITH_IO_SPZ */

View file

@ -0,0 +1,21 @@
/* SPDX-FileCopyrightText: 2026 Blender Authors
*
* SPDX-License-Identifier: GPL-2.0-or-later */
/** \file
* \ingroup editor/io
*/
#pragma once
namespace blender {
struct wmOperatorType;
void WM_OT_spz_import(wmOperatorType *ot);
namespace ed::io {
void spz_file_handler_add();
}
} // namespace blender

View file

@ -14,6 +14,10 @@ if(WITH_IO_PLY)
add_subdirectory(ply)
endif()
if(WITH_IO_SPZ)
add_subdirectory(spz)
endif()
if(WITH_IO_STL)
add_subdirectory(stl)
endif()

View file

@ -0,0 +1,42 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
set(INC
.
intern
../common
../../makesrna
)
set(INC_SYS
)
set(SRC
IO_spz.cc
importer/spz_import.cc
IO_spz.hh
importer/spz_import.hh
intern/spz_read.cc
intern/spz_read_gzip.cc
intern/spz_read_ngsp.cc
intern/spz_read.hh
intern/spz_read_common.cc
intern/spz_read_common.hh
)
set(LIB
PRIVATE bf::blenlib
PRIVATE bf::blenkernel
PRIVATE bf::dna
PRIVATE bf::depsgraph
PRIVATE bf_io_common
PRIVATE bf::intern::guardedalloc # Indirect dependnecy of the bf::blenkernel.
PRIVATE bf::intern::clog
PRIVATE bf::dependencies::zlib
PRIVATE bf::dependencies::zstd
)
blender_add_lib(bf_io_spz "${SRC}" "${INC}" "${INC_SYS}" "${LIB}")

View file

@ -0,0 +1,25 @@
/* SPDX-FileCopyrightText: 2026 Blender Authors
*
* SPDX-License-Identifier: GPL-2.0-or-later */
/** \file
* \ingroup spz
*/
#include "IO_spz.hh"
#include "importer/spz_import.hh"
namespace blender {
void SPZ_import(bContext *C, const SPZImportParams *import_params)
{
io::spz::importer_main(C, *import_params);
}
PointCloud *SPZ_import_point_cloud(const SPZImportParams *import_params)
{
return io::spz::import_point_cloud(*import_params);
}
} // namespace blender

View file

@ -0,0 +1,29 @@
/* SPDX-FileCopyrightText: 2026 Blender Authors
*
* SPDX-License-Identifier: GPL-2.0-or-later */
/** \file
* \ingroup spz
*/
#pragma once
#include <string>
namespace blender {
struct bContext;
struct PointCloud;
struct ReportList;
struct SPZImportParams {
/* Full path to the source SPZ file to import. */
std::string filepath;
ReportList *reports = nullptr;
};
void SPZ_import(bContext *C, const SPZImportParams *import_params);
PointCloud *SPZ_import_point_cloud(const SPZImportParams *import_params);
} // namespace blender

View file

@ -0,0 +1,87 @@
/* SPDX-FileCopyrightText: 2026 Blender Authors
*
* SPDX-License-Identifier: GPL-2.0-or-later */
/** \file
* \ingroup spz
*/
#include "spz_import.hh"
#include "DNA_collection_types.h"
#include "DNA_object_types.h"
#include "DNA_pointcloud_types.h"
#include "DNA_scene_types.h"
#include "BLI_path_utils.hh"
#include "BLI_string.hh"
#include "BKE_context.hh"
#include "BKE_layer.hh"
#include "BKE_library.hh"
#include "BKE_object.hh"
#include "BKE_pointcloud.hh"
#include "BKE_report.hh"
#include "DEG_depsgraph.hh"
#include "DEG_depsgraph_build.hh"
#include "IO_spz.hh"
#include "spz_read.hh"
namespace blender::io::spz {
void importer_main(const bContext *C, const SPZImportParams &import_params)
{
PointCloud *point_cloud = read_spz_file(import_params.filepath, import_params.reports);
if (!point_cloud) {
return;
}
Main *bmain = CTX_data_main(C);
Scene *scene = CTX_data_scene(C);
ViewLayer *view_layer = CTX_data_view_layer(C);
/* Name used for both object and its data. */
char object_name[FILE_MAX];
STRNCPY(object_name, BLI_path_basename(import_params.filepath.c_str()));
BLI_path_extension_strip(object_name);
PointCloud *point_cloud_in_main = BKE_pointcloud_add(bmain, object_name);
BKE_pointcloud_nomain_to_pointcloud(point_cloud, point_cloud_in_main);
BKE_view_layer_base_deselect_all(*bmain, scene, view_layer);
LayerCollection *layer_collection = BKE_layer_collection_get_active_editable(view_layer);
if (!ID_IS_EDITABLE(layer_collection->collection)) {
BKE_report(import_params.reports,
RPT_WARNING,
"Could not find an editable collection in current scene, imported data will not be "
"instantiated");
}
Object *object = BKE_object_add_only_object(bmain, OB_POINTCLOUD, object_name);
object->data = id_cast<ID *>(point_cloud_in_main);
BKE_collection_object_add(bmain, layer_collection->collection, object);
BKE_view_layer_synced_ensure(*bmain, scene, view_layer);
if (Base *base = BKE_view_layer_base_find(view_layer, object)) {
/* `base` will be nullptr if the Object could not be instantiated in the current view layer. */
BKE_view_layer_base_select_and_set_active(view_layer, base);
}
DEG_id_tag_update(&layer_collection->collection->id, ID_RECALC_SYNC_TO_EVAL);
DEG_id_tag_update_ex(bmain,
&object->id,
ID_RECALC_TRANSFORM | ID_RECALC_GEOMETRY | ID_RECALC_ANIMATION |
ID_RECALC_BASE_FLAGS);
DEG_id_tag_update(&scene->id, ID_RECALC_BASE_FLAGS);
DEG_relations_tag_update(bmain);
}
PointCloud *import_point_cloud(const SPZImportParams &import_params)
{
return read_spz_file(import_params.filepath, import_params.reports);
}
} // namespace blender::io::spz

View file

@ -0,0 +1,25 @@
/* SPDX-FileCopyrightText: 2026 Blender Authors
*
* SPDX-License-Identifier: GPL-2.0-or-later */
/** \file
* \ingroup spz
*/
#pragma once
namespace blender {
struct bContext;
struct PointCloud;
struct SPZImportParams;
namespace io::spz {
/* Main import function used from within Blender. */
void importer_main(const bContext *C, const SPZImportParams &import_params);
PointCloud *import_point_cloud(const SPZImportParams &import_params);
} // namespace io::spz
} // namespace blender

View file

@ -0,0 +1,80 @@
/* SPDX-FileCopyrightText: 2026 Blender Authors
*
* SPDX-License-Identifier: GPL-2.0-or-later */
/** \file
* \ingroup spz
*/
#include "spz_read.hh"
#include <bit>
#include "BLI_fileops.hh"
#include "BKE_report.hh"
#include "CLG_log.h"
#include "spz_read_common.hh"
namespace blender::io::spz {
static CLG_LogRef LOG = {"io.spz"};
template<class T> static bool read_spz_scalar(FILE *file, T &data)
{
if (fread(&data, sizeof(T), 1, file) != 1) {
return false;
}
return true;
}
PointCloud *read_spz_file(const StringRefNull filepath, ReportList *reports)
{
if constexpr (std::endian::native != std::endian::little) {
BKE_report(reports, RPT_ERROR, "SPZ Read: Big endian machines are not supported");
return nullptr;
}
FILE *file = BLI_fopen(filepath.c_str(), "rb");
if (!file) {
BKE_report(reports, RPT_ERROR, "SPZ Read: Error opening file for read");
return nullptr;
}
uint32_t magic = 0;
if (!read_spz_scalar(file, magic)) {
BKE_report(reports, RPT_ERROR, "SPZ Read: Error reading magic value from file");
fclose(file);
return nullptr;
}
CLOG_DEBUG(&LOG, "Got file header magic: 0x%X", magic);
/* Rewind the file stream so that specialized version reading can start from its beginning. */
if (fseek(file, 0, SEEK_SET) == -1) {
fclose(file);
return nullptr;
}
PointCloud *point_cloud = nullptr;
if (magic == SPZ_HEADER_MAGIC) {
CLOG_DEBUG(&LOG, "Detected uncompressed NGSP magic");
point_cloud = read_spz_ngsp_file(file, reports);
}
else if ((magic & 0xffff) == 0x8b1f) {
/* Format prior to v4: single GZip stream. */
CLOG_DEBUG(&LOG, "Detected Gzip compressed stream");
point_cloud = read_spz_gzip_compressed_file(file, reports);
}
else {
BKE_reportf(reports, RPT_ERROR, "SPZ Read: Unrecognized file format");
}
fclose(file);
return point_cloud;
}
} // namespace blender::io::spz

View file

@ -0,0 +1,35 @@
/* SPDX-FileCopyrightText: 2026 Blender Authors
*
* SPDX-License-Identifier: GPL-2.0-or-later */
/** \file
* \ingroup spz
*/
#pragma once
#include <cstdio>
#include "BLI_string_ref.hh"
namespace blender {
struct PointCloud;
struct ReportList;
namespace io::spz {
/* Read the SPZ file pointed by the filepath.
* Supports multiple SPZ file formats. */
PointCloud *read_spz_file(StringRefNull filepath, ReportList *reports);
/* Read SZP file that uses a single GZip stream to compress the whole file.
* This applies to SPZ format versions 2 and 3. */
PointCloud *read_spz_gzip_compressed_file(FILE *file, ReportList *reports);
/* Read SZP file that uses NGSP format (introduced in version 4, uncompressed header, multiple
* zstd compressed streams). */
PointCloud *read_spz_ngsp_file(FILE *file, ReportList *reports);
} // namespace io::spz
} // namespace blender

View file

@ -0,0 +1,149 @@
/* SPDX-FileCopyrightText: 2026 Blender Authors
*
* SPDX-License-Identifier: GPL-2.0-or-later */
#include "spz_read_common.hh"
#include <array>
#include "BLI_array.hh"
#include "BLI_assert.hh"
#include "BLI_enumerable_thread_specific.hh"
#include "BLI_math_rotation.hh"
#include "IO_gsplat.hh"
namespace blender::io::spz {
namespace {
/* Spherical harmonics rotation matrices for R_x(+pi/2).
* Each entry operates on the 2l+1 coefficients of band l (l = 1..4).
*
* The table is adopted from the SPZ Library project:
*
* MIT License
* Copyright (c) 2024 Niantic Labs
*
* There are modifications in the code to use Blender utilities, and also to operate on float3
* elements rather than on scalars.
*
* The matrices structure seems to match description given in the
*
* Didier Pinchon1 and Philip E. Hoggan
* "Rotation matrices for real spherical harmonics: general rotations of atomic orbitals in
* space-fixed axes" (2007)
* Journal of Physics A: Mathematical and Theoretical.
* DOI:10.1088/1751-8113/40/7/011
*
* This could also be confirmed by checking against a naive implementation of the Wigner D-matrix
* [WignerDMatrix]. When doing so a basis conversion needs to be applied to convert complex
* spherical harmonics to real [SphericalRealForm].
*
* References:
* [WignerDMatrix] https://en.wikipedia.org/wiki/Wigner_D-matrix
* [SphericalRealForm] https://en.wikipedia.org/wiki/Spherical_harmonics#Real_form */
inline constexpr std::array<void (*)(MutableSpan<float3>), 4>
ANALYTIC_ROTATE_PLUS_PI_HALF_ABOUT_X_TABLE = {
[](const MutableSpan<float3> p) {
BLI_assert(p.size() == 3);
const float3 t0 = p[0], t1 = p[1], t2 = p[2];
p[0] = t1;
p[1] = -t0;
p[2] = t2;
},
[](const MutableSpan<float3> p) {
BLI_assert(p.size() == 5);
std::array<float3, 5> s;
MutableSpan(s).copy_from(p);
const float s3 = std::sqrt(3.0f);
p[0] = s[3];
p[1] = -s[1];
p[2] = -0.5f * s[2] - (s3 / 2.0f) * s[4];
p[3] = -s[0];
p[4] = -(s3 / 2.0f) * s[2] + 0.5f * s[4];
},
[](const MutableSpan<float3> p) {
BLI_assert(p.size() == 7);
std::array<float3, 7> s{};
MutableSpan(s).copy_from(p);
const float s15 = std::sqrt(15.0f);
p[0] = -std::sqrt(5.0f / 8.0f) * s[3] + std::sqrt(3.0f / 8.0f) * s[5];
p[1] = -s[1];
p[2] = -std::sqrt(3.0f / 8.0f) * s[3] - std::sqrt(5.0f / 8.0f) * s[5];
p[3] = std::sqrt(5.0f / 8.0f) * s[0] + std::sqrt(3.0f / 8.0f) * s[2];
p[4] = -0.25f * s[4] - (s15 / 4.0f) * s[6];
p[5] = -std::sqrt(3.0f / 8.0f) * s[0] + std::sqrt(5.0f / 8.0f) * s[2];
p[6] = -(s15 / 4.0f) * s[4] + 0.25f * s[6];
},
[](const MutableSpan<float3> p) {
BLI_assert(p.size() == 9);
std::array<float3, 9> s{};
MutableSpan(s).copy_from(p);
const float s2 = std::sqrt(2.0f);
const float s5 = std::sqrt(5.0f);
const float s7 = std::sqrt(7.0f);
const float s14 = std::sqrt(14.0f);
const float s35 = std::sqrt(35.0f);
p[0] = -(s14 / 4.0f) * s[5] + (s2 / 4.0f) * s[7];
p[1] = -0.75f * s[1] + (s7 / 4.0f) * s[3];
p[2] = -(s2 / 4.0f) * s[5] - (s14 / 4.0f) * s[7];
p[3] = (s7 / 4.0f) * s[1] + 0.75f * s[3];
p[4] = (3.0f / 8.0f) * s[4] + (s5 / 4.0f) * s[6] + (s35 / 8.0f) * s[8];
p[5] = (s14 / 4.0f) * s[0] + (s2 / 4.0f) * s[2];
p[6] = (s5 / 4.0f) * s[4] + 0.5f * s[6] - (s7 / 4.0f) * s[8];
p[7] = -(s2 / 4.0f) * s[0] + (s14 / 4.0f) * s[2];
p[8] = (s35 / 8.0f) * s[4] - (s7 / 4.0f) * s[6] + 0.125f * s[8];
},
};
} // namespace
void convert_axis_to_blender(const MutableSpan<float3> positions,
const MutableSpan<math::Quaternion> rotations,
const Span<MutableSpan<float3>> sh_attrs)
{
/* By default, SPZ stores data in an RUB (Right, Up, Back) coordinate system following the OpenGL
* and three.js convention. Blender is RFU (Right, Forward, Up). */
/* TODO(sergey): Handle extensions that define coordinate system. */
/* Example: SPZ_ADOBE_coordinate_system. */
const int sh_degrees = gsplat::degree_for_dimension(sh_attrs.size());
struct LocalData {
Array<float3> sh_data;
};
threading::EnumerableThreadSpecific<LocalData> all_tls;
threading::parallel_for(IndexRange(positions.size()), 32, [&](const IndexRange range) {
LocalData &tls = all_tls.local();
for (const int i : range) {
positions[i] = {positions[i].x, -positions[i].z, positions[i].y};
/* Simplified expression for the following:
* math::to_quaternion(math::AxisAngle(math::AxisSigned::X_POS, M_PI_2)) * rotations[i]. */
rotations[i] = math::Quaternion(M_SQRT1_2 * (rotations[i].w - rotations[i].x),
M_SQRT1_2 * (rotations[i].x + rotations[i].w),
M_SQRT1_2 * (rotations[i].y - rotations[i].z),
M_SQRT1_2 * (rotations[i].z + rotations[i].y));
if (sh_degrees == 0) {
continue;
}
if (tls.sh_data.is_empty()) {
tls.sh_data.reinitialize(sh_attrs.size());
}
MutableSpan<float3> sh_data = tls.sh_data;
gsplat::get_spherical_harmonics(sh_attrs, i, sh_data);
for (int band = 0; band < sh_degrees; ++band) {
ANALYTIC_ROTATE_PLUS_PI_HALF_ABOUT_X_TABLE[band](
sh_data.slice(band * (band + 2), 2 * (band + 1) + 1));
}
gsplat::set_spherical_harmonics(sh_attrs, i, sh_data);
}
});
}
} // namespace blender::io::spz

View file

@ -0,0 +1,254 @@
/* SPDX-FileCopyrightText: 2026 Blender Authors
*
* SPDX-License-Identifier: GPL-2.0-or-later */
/** \file
* \ingroup spz
*/
#pragma once
#include <cmath>
#include "BLI_array.hh"
#include "BLI_math_constants.hh"
#include "BLI_math_quaternion_types.hh"
#include "BLI_math_vector.hh"
#include "BLI_math_vector_types.hh"
#include "BLI_span.hh"
#include "BLI_unroll.hh"
#include "IO_gsplat.hh"
namespace blender::io::spz {
/* Magic number in the file header for the tile identification. Common for all format versions.
* It is bytes N, G, S, P in the file order. */
constexpr uint32_t SPZ_HEADER_MAGIC = 0x5053474e;
enum HeaderFlag {
SPZ_HEADER_ANTIALIASED = 0x01,
SPZ_HEADER_HAS_EXTENSIONS = 0x02,
};
namespace internal {
template<class ReaderType>
[[nodiscard]] inline bool read_single_position_as_fixed(ReaderType &reader, int3 &fixed)
{
using PackedFloat = std::array<uint8_t, 3>;
using PackedFloat3 = std::array<PackedFloat, 3>;
PackedFloat3 packed;
if (!reader.read(packed)) {
return false;
}
unroll<3>([&](auto i) {
fixed[i] = packed[i][0];
fixed[i] |= packed[i][1] << 8;
fixed[i] |= packed[i][2] << 16;
fixed[i] |= (fixed[i] & 0x800000) ? 0xff000000 : 0;
});
return true;
}
inline float inv_sigmoid(const float x)
{
return std::log(x / (1.0f - x));
}
} // namespace internal
template<class ReaderType>
[[nodiscard]] inline bool read_positions(ReaderType &reader,
const int fractional_bits,
const MutableSpan<float3> positions)
{
/* From documentation:
*
* Positions are represented as (x, y, z) coordinates, each as a 24-bit fixed point signed
* integer. The number of fractional bits is determined by the fractionalBits field in the
* header. */
BLI_assert(fractional_bits >= 0 && fractional_bits < 24);
const float scale = 1.0f / (1 << fractional_bits);
for (float3 &position : positions) {
int3 fixed;
if (!internal::read_single_position_as_fixed(reader, fixed)) {
return false;
}
position = float3(fixed) * scale;
}
return true;
}
template<class ReaderType>
[[nodiscard]] inline bool read_alphas(ReaderType &reader, const MutableSpan<float4> radiance_base)
{
for (float4 &base : radiance_base) {
uint8_t alpha;
if (!reader.read(alpha)) {
return false;
}
if (false) {
/* The naive implementation.
* The inv_sigmoid() and decode_opacity() are actually canceling each other.
* Code kept for the reference and possible situation when other activation functions are
* used in the future. */
base.w = internal::inv_sigmoid(float(alpha) / 255.0f);
base.w = gsplat::OriginalActivationFunctions::decode_opacity(base.w);
}
else {
base.w = float(alpha) / 255.0f;
}
}
return true;
}
template<class ReaderType>
[[nodiscard]] inline bool read_colors(ReaderType &reader, const MutableSpan<float4> radiance_base)
{
/* From SPZ:
* Scale factor for DC color components. To convert to RGB, we should multiply by 0.282, but it
* can be useful to represent base colors that are out of range if the higher spherical
* harmonics bands bring them back into range so we multiply by a smaller value. */
constexpr float COLOR_SCALE = 0.15f;
const float INV_COLOR_SCALE = 1.0f / COLOR_SCALE;
for (float4 &base : radiance_base) {
std::array<uint8_t, 3> color_buffer;
if (!reader.read_buffer(color_buffer)) {
return false;
}
base.x = ((color_buffer[0] / 255.0f) - 0.5f) * INV_COLOR_SCALE;
base.y = ((color_buffer[1] / 255.0f) - 0.5f) * INV_COLOR_SCALE;
base.z = ((color_buffer[2] / 255.0f) - 0.5f) * INV_COLOR_SCALE;
}
return true;
}
template<class ReaderType>
[[nodiscard]] inline bool read_scales(ReaderType &reader, const MutableSpan<float3> scales)
{
/* From documentation:
*
* Scales are represented as (x, y, z) components, each represented as an 8-bit log-encoded
* integer. */
for (float3 &scale : scales) {
std::array<uint8_t, 3> encoded;
if (!reader.read_buffer(encoded)) {
return false;
}
scale = float3(encoded[0], encoded[1], encoded[2]) / 16.0f - 10.0f;
scale = gsplat::OriginalActivationFunctions::decode_scale(scale);
}
return true;
}
template<class ReaderType>
[[nodiscard]] inline bool read_rotations(ReaderType &reader,
const int version,
const MutableSpan<math::Quaternion> rotations)
{
if (version == 2) {
/* From documentation:
*
* In version 2, rotations are represented as the (x, y, z) components of the normalized
* rotation quaternion. The w component can be derived from the others and is not stored.
* Each component is encoded as an 8-bit signed integer. */
for (math::Quaternion &rotation : rotations) {
std::array<uint8_t, 3> packed;
if (!reader.read_buffer(packed)) {
return false;
}
const float3 first3 = float3(packed[0], packed[1], packed[2]) / 127.5f - 1.0f;
rotation.w = std::sqrt(std::max(0.0f, 1.0f - math::dot(first3, first3)));
rotation.x = first3.x;
rotation.y = first3.y;
rotation.z = first3.z;
}
return true;
}
if (version >= 3) {
/* From documentation:
*
* In version 3, rotations are represented as the smallest three components of the normalized
* rotation quaternion, for optimal rotation accuracy. The largest component can be derived
* from the others and is not stored. Its index is stored on 2 bits and each of the smallest
* three components is encoded as a 10-bit signed integer. */
constexpr uint32_t COMPONENT_MASK = (1 << 9) - 1;
for (math::Quaternion &rotation : rotations) {
/* Read as individual bytes and re-pack to integer to ensure endianess. */
std::array<uint8_t, 4> packed_bytes;
if (!reader.read_buffer(packed_bytes)) {
return false;
}
float spz_rotation[4];
uint32_t packed = packed_bytes[0] + (packed_bytes[1] << 8) + (packed_bytes[2] << 16) +
(packed_bytes[3] << 24);
const int largest_index = packed >> 30;
float sum_sq = 0.0f;
for (int i = 3; i >= 0; --i) {
if (i == largest_index) {
continue;
}
const uint32_t mag = packed & COMPONENT_MASK;
const float sign = ((packed >> 9) & 0x1) == 1 ? -1.0f : 1.0f;
spz_rotation[i] = M_SQRT1_2 * ((float)mag) / float(COMPONENT_MASK) * sign;
sum_sq += spz_rotation[i] * spz_rotation[i];
packed = packed >> 10;
}
spz_rotation[largest_index] = sqrt(math::max(1.0f - sum_sq, 0.0f));
/* Convert to Blender's (w, x, y, z). */
rotation = math::Quaternion(
spz_rotation[3], spz_rotation[0], spz_rotation[1], spz_rotation[2]);
}
return true;
}
return false;
}
template<class ReaderType>
[[nodiscard]] inline bool read_sh(ReaderType &reader, const Span<MutableSpan<float3>> sh)
{
/* From documentation:
*
* The coefficients for a gaussian are organized such that the color channel is the inner
* (faster varying) axis, and the coefficient is the outer (slower varying) axis
* Each coefficient is represented as an 8-bit signed integer. Additional quantization can be
* performed to attain a higher compression ratio */
if (sh.is_empty()) {
return true;
}
const int64_t num_points = sh[0].size();
const int num_coefficients = sh.size();
Array<uint8_t> packed_coefficients(3 * num_coefficients);
for (int64_t i = 0; i < num_points; ++i) {
if (!reader.read_buffer(packed_coefficients)) {
return false;
}
for (int j = 0; j < num_coefficients; ++j) {
const float3 coefficients(packed_coefficients[3 * j + 0],
packed_coefficients[3 * j + 1],
packed_coefficients[3 * j + 2]);
sh[j][i] = (coefficients - 128.0f) / 128.0f;
}
}
return true;
}
void convert_axis_to_blender(MutableSpan<float3> positions,
MutableSpan<math::Quaternion> rotations,
Span<MutableSpan<float3>> sh_attrs);
} // namespace blender::io::spz

View file

@ -0,0 +1,210 @@
/* SPDX-FileCopyrightText: 2026 Blender Authors
*
* SPDX-License-Identifier: GPL-2.0-or-later */
/** \file
* \ingroup spz
*/
#include "spz_read.hh"
#include <zlib.h>
#include <array>
#include <cstdint>
#include <optional>
#include "BLI_array.hh"
#include "BLI_assert.hh"
#include "BLI_span.hh"
#include "BKE_lib_id.hh"
#include "BKE_pointcloud.hh"
#include "BKE_report.hh"
#include "CLG_log.h"
#include "IO_gsplat.hh"
#include "IO_validate.hh"
#include "spz_read_common.hh"
namespace blender::io::spz {
static CLG_LogRef LOG = {"io.spz"};
namespace {
struct PackedGaussiansHeader {
uint32_t magic;
uint32_t version;
uint32_t num_points;
uint8_t sh_degree;
uint8_t fractional_bits;
uint8_t flags;
uint8_t reserved;
};
static_assert(sizeof(PackedGaussiansHeader) == 16);
/* Helper class that takes care of reading data from FILE in chunks. */
class BufferedFileReader {
static constexpr int BUFFER_SIZE = 65536;
FILE *file_ = nullptr;
Array<uint8_t> buffer_;
public:
explicit BufferedFileReader(FILE *file) : file_(file), buffer_(BUFFER_SIZE) {}
std::optional<Span<uint8_t>> read()
{
const size_t num_read_bytes = fread(buffer_.data(), 1, buffer_.size(), file_);
if (ferror(file_)) {
return std::nullopt;
}
return buffer_.as_span().slice(0, num_read_bytes);
}
};
/* Helper class that allows reading a Gzip-compressed file from disk with the minimum amount of
* extra memory usage, but allowing to easily access data of specific size. */
class StreamedGzipReader {
BufferedFileReader buffered_reader_;
z_stream stream_{};
public:
explicit StreamedGzipReader(FILE *file) : buffered_reader_(file) {}
~StreamedGzipReader()
{
inflateEnd(&stream_);
}
bool initialize()
{
/* The window size matches the SPZ library: here 16 means enable automatic gzip header
* detection; consider switching this to 32 to enable both automated gzip and zlib header
* detection. */
return inflateInit2(&stream_, 16 | MAX_WBITS) == Z_OK;
}
bool read_buffer(const MutableSpan<uint8_t> buffer)
{
stream_.avail_out = buffer.size();
stream_.next_out = buffer.data();
do {
/* There might be left-over data in the input since previous read.
* If there is, process that data first. Otherwise, load the next chunk from the input file.
*/
if (stream_.avail_in == 0) {
std::optional<Span<uint8_t>> read_result = buffered_reader_.read();
if (!read_result.has_value() || read_result->size() == 0) {
return false;
}
stream_.avail_in = read_result->size();
stream_.next_in = const_cast<Bytef *>(read_result->data());
}
const int ret = inflate(&stream_, Z_NO_FLUSH);
BLI_assert(ret != Z_STREAM_ERROR);
if (ret == Z_STREAM_END) {
if (stream_.avail_out != 0) {
return false;
}
break;
}
if (ret != Z_OK) {
/* TODO(sergey): Report error. */
return false;
}
} while (stream_.avail_out != 0);
/* TODO(sergey): Investigate whether decompressing more data ahead of time helps performance.
*/
return true;
}
template<class T> bool read(T &data)
{
return read_buffer(MutableSpan<uint8_t>(reinterpret_cast<uint8_t *>(&data), sizeof(T)));
}
};
} // namespace
PointCloud *read_spz_gzip_compressed_file(FILE *file, ReportList *reports)
{
StreamedGzipReader reader(file);
if (!reader.initialize()) {
BKE_report(reports, RPT_ERROR, "SPZ Read: Error initializing Zlib decompression");
return nullptr;
}
PackedGaussiansHeader header;
if (!reader.read(header)) {
BKE_report(reports, RPT_ERROR, "SPZ Read: Error reading header from GZip stream");
return nullptr;
}
CLOG_DEBUG(&LOG, "SPZ header magic: 0x%X", header.magic);
CLOG_DEBUG(&LOG, "SPZ header version: %u", header.version);
if (header.magic != SPZ_HEADER_MAGIC) {
BKE_reportf(reports, RPT_ERROR, "SPZ Read: Unexpected SPZ header magic 0x%X", header.magic);
return nullptr;
}
if (header.version != 2 && header.version != 3) {
BKE_reportf(reports, RPT_ERROR, "SPZ Read: Unsupported SPZ version %u", header.version);
return nullptr;
}
CLOG_DEBUG(&LOG, "SPZ header num_points: %u", header.num_points);
CLOG_DEBUG(&LOG, "SPZ header sh_degree: %d", int(header.sh_degree));
CLOG_DEBUG(&LOG, "SPZ header fractional_bits: %d", int(header.fractional_bits));
CLOG_DEBUG(&LOG, "SPZ header flags: %d", header.flags);
if (header.flags & SPZ_HEADER_ANTIALIASED) {
/* TODO(sergey): Support antialiased data. */
CLOG_WARN(&LOG, "SPZ data was trained with antialiasing which is not fully supported");
}
if (header.flags & SPZ_HEADER_HAS_EXTENSIONS) {
/* TODO(sergey): Support extensions. */
CLOG_WARN(&LOG, "SPZ file contains extensions that are not yet supported");
}
if (!validate::size_fits_in_int(header.num_points)) {
BKE_report(reports, RPT_ERROR, "SPZ Read: Too many points");
return nullptr;
}
PointCloud *point_cloud = BKE_pointcloud_new_nomain(PointCloudType::GSplat, header.num_points);
gsplat::GsplatMutableAttributeAccessor accessor(*point_cloud, header.sh_degree);
const MutableSpan<float3> positions = accessor.positions_for_write();
const MutableSpan<math::Quaternion> rotations = accessor.rotations_for_write();
const Span<MutableSpan<float3>> sh_attrs = accessor.sh_for_write();
if (!read_positions(reader, header.fractional_bits, positions) ||
!read_alphas(reader, accessor.radiance_base_for_write()) ||
!read_colors(reader, accessor.radiance_base_for_write()) ||
!read_scales(reader, accessor.scales_for_write()) ||
!read_rotations(reader, header.version, rotations) || !read_sh(reader, sh_attrs))
{
accessor.finish();
BKE_id_free(nullptr, &point_cloud->id);
return nullptr;
}
/* TODO(sergey): Handle extensions. */
convert_axis_to_blender(positions, rotations, sh_attrs);
accessor.finish();
return point_cloud;
}
} // namespace blender::io::spz

View file

@ -0,0 +1,379 @@
/* SPDX-FileCopyrightText: 2026 Blender Authors
*
* SPDX-License-Identifier: GPL-2.0-or-later */
/** \file
* \ingroup spz
*/
#include "spz_read.hh"
#include <zstd.h>
#include <cstdint>
#include <utility>
#include "BLI_array.hh"
#include "BLI_fileops.hh"
#include "BLI_math_base.hh"
#include "BKE_lib_id.hh"
#include "BKE_pointcloud.hh"
#include "BKE_report.hh"
#include "IO_gsplat.hh"
#include "IO_validate.hh"
#include "CLG_log.h"
#include "spz_read_common.hh"
namespace blender::io::spz {
static CLG_LogRef LOG = {"io.spz"};
namespace {
struct NgspFileHeader {
uint32_t magic; /* 0x5053474e ("NGSP") */
uint32_t version; /* 4 */
uint32_t num_points;
uint8_t sh_degree;
uint8_t fractional_bits;
uint8_t flags;
uint8_t num_streams; /* The number of ZSTD-compressed attribute streams (typically 6). */
uint32_t toc_byte_offset; /* Byte offset from file start to the TOC */
uint8_t reserved[12]; /* zero, reserved for future use. */
};
static_assert(sizeof(NgspFileHeader) == 32);
struct StreamInfo {
/* Offset of the stream from the beginning of the file. */
uint64_t compressed_offset;
uint64_t compressed_size;
uint64_t uncompressed_size;
};
/* Helper class that takes care of reading data from SPZ stream from file in chunks. */
class BufferedStreamReader {
static constexpr int BUFFER_SIZE = 65536;
FILE *file_ = nullptr;
StreamInfo stream_info_;
Array<uint8_t> buffer_;
uint64_t num_read_bytes_ = 0;
public:
BufferedStreamReader(FILE *file, const StreamInfo &stream_info)
: file_(file), stream_info_(stream_info), buffer_(BUFFER_SIZE)
{
}
bool initialize()
{
return BLI_fseek(file_, int64_t(stream_info_.compressed_offset), SEEK_SET) != -1;
}
std::optional<Span<uint8_t>> read()
{
const uint64_t num_bytes_to_read = math::min(stream_info_.compressed_size - num_read_bytes_,
uint64_t(buffer_.size()));
const size_t num_read_bytes = fread(buffer_.data(), 1, num_bytes_to_read, file_);
if (ferror(file_) || num_bytes_to_read != num_read_bytes) {
return std::nullopt;
}
num_read_bytes_ += num_bytes_to_read;
return buffer_.as_span().slice(0, num_read_bytes);
}
};
/* Helper class that allows reading a Gzip-compressed file from disk with the minimum amount of
* extra memory usage, but allowing to easily access data of specific size. */
class StreamedZstdReader {
BufferedStreamReader buffered_reader_;
ZSTD_DCtx *dctx_ = nullptr;
ZSTD_inBuffer input_buffer_;
public:
StreamedZstdReader(FILE *file, const StreamInfo &stream_info)
: buffered_reader_(file, stream_info)
{
}
~StreamedZstdReader()
{
if (dctx_) {
ZSTD_freeDCtx(dctx_);
}
}
bool initialize()
{
if (!buffered_reader_.initialize()) {
return false;
}
dctx_ = ZSTD_createDCtx();
input_buffer_.src = nullptr;
input_buffer_.size = 0;
input_buffer_.pos = 0;
return true;
}
bool read_buffer(const MutableSpan<uint8_t> buffer)
{
ZSTD_outBuffer output_buffer = {.dst = buffer.data(), .size = size_t(buffer.size()), .pos = 0};
do {
/* There might be left-over data in the input since previous read.
* If there is, process that data first. Otherwise, load the next chunk from the input file.
*/
if (input_buffer_.pos == input_buffer_.size) {
std::optional<Span<uint8_t>> read_result = buffered_reader_.read();
if (!read_result.has_value() || read_result->size() == 0) {
return false;
}
input_buffer_.src = read_result->data();
input_buffer_.size = read_result->size();
input_buffer_.pos = 0;
}
const size_t ret = ZSTD_decompressStream(dctx_, &output_buffer, &input_buffer_);
if (ZSTD_isError(ret)) {
// TODO(sergey): Report error using ZSTD_getErrorName(lastResult) ?
return false;
}
} while (output_buffer.pos < output_buffer.size);
return true;
}
template<class T> bool read(T &data)
{
return read_buffer(MutableSpan<uint8_t>(reinterpret_cast<uint8_t *>(&data), sizeof(T)));
}
};
} // namespace
template<class T> static bool read_spz_struct(FILE *file, T &data)
{
if (fread(&data, sizeof(T), 1, file) != 1) {
return false;
}
return true;
}
static bool read_toc(FILE *file, const NgspFileHeader &header, Array<StreamInfo> &stream_infos)
{
struct PackedStreamInfo {
uint64_t compressed_size;
uint64_t uncompressed_size;
};
static_assert(sizeof(PackedStreamInfo) == 16);
if (header.num_streams == 0) {
return true;
}
if (BLI_fseek(file, header.toc_byte_offset, SEEK_SET) == -1) {
return false;
}
Vector<PackedStreamInfo> packed_stream_infos(header.num_streams);
if (fread(packed_stream_infos.data(), sizeof(PackedStreamInfo), header.num_streams, file) !=
header.num_streams)
{
return false;
}
stream_infos.reinitialize(header.num_streams);
stream_infos[0].compressed_offset = header.toc_byte_offset +
packed_stream_infos.size() * sizeof(PackedStreamInfo);
stream_infos[0].compressed_size = packed_stream_infos[0].compressed_size;
stream_infos[0].uncompressed_size = packed_stream_infos[0].uncompressed_size;
for (int i = 1; i < header.num_streams; ++i) {
stream_infos[i].compressed_offset = stream_infos[i - 1].compressed_offset +
stream_infos[i - 1].compressed_size;
stream_infos[i].compressed_size = packed_stream_infos[i].compressed_size;
stream_infos[i].uncompressed_size = packed_stream_infos[i].uncompressed_size;
}
return true;
}
static bool read_spz(FILE *file,
const NgspFileHeader &header,
const Span<StreamInfo> stream_infos,
ReportList *reports,
gsplat::GsplatMutableAttributeAccessor &accessor)
{
if (stream_infos.size() < 6) {
return false;
}
const MutableSpan<float3> positions = accessor.positions_for_write();
const MutableSpan<math::Quaternion> rotations = accessor.rotations_for_write();
const Span<MutableSpan<float3>> sh_attrs = accessor.sh_for_write();
/* Positions. */
{
StreamedZstdReader reader(file, stream_infos[0]);
if (!reader.initialize()) {
BKE_report(reports, RPT_ERROR, "SPZ Read: Error initializing positions reader");
return false;
}
if (!read_positions(reader, header.fractional_bits, positions)) {
BKE_report(reports, RPT_ERROR, "SPZ Read: Error reading positions");
return false;
}
}
/* Alphas and colors. */
{
StreamedZstdReader reader(file, stream_infos[1]);
if (!reader.initialize()) {
BKE_report(reports, RPT_ERROR, "SPZ Read: Error initializing alphas reader");
return false;
}
if (!read_alphas(reader, accessor.radiance_base_for_write())) {
BKE_report(reports, RPT_ERROR, "SPZ Read: Error reading alphas");
return false;
}
}
{
StreamedZstdReader reader(file, stream_infos[2]);
if (!reader.initialize()) {
BKE_report(reports, RPT_ERROR, "SPZ Read: Error initializing colors reader");
return false;
}
if (!read_colors(reader, accessor.radiance_base_for_write())) {
BKE_report(reports, RPT_ERROR, "SPZ Read: Error reading colors");
return false;
}
}
/* Scales. */
{
StreamedZstdReader reader(file, stream_infos[3]);
if (!reader.initialize()) {
BKE_report(reports, RPT_ERROR, "SPZ Read: Error initializing scales reader");
return false;
}
if (!read_scales(reader, accessor.scales_for_write())) {
BKE_report(reports, RPT_ERROR, "SPZ Read: Error reading scales");
return false;
}
}
/* Rotations. */
{
StreamedZstdReader reader(file, stream_infos[4]);
if (!reader.initialize()) {
BKE_report(reports, RPT_ERROR, "SPZ Read: Error initializing rotations reader");
return false;
}
if (!read_rotations(reader, header.version, rotations)) {
BKE_report(reports, RPT_ERROR, "SPZ Read: Error reading rotations");
return false;
}
}
/* SH. */
{
StreamedZstdReader reader(file, stream_infos[5]);
if (!reader.initialize()) {
BKE_report(reports, RPT_ERROR, "SPZ Read: Error initializing SH reader");
return false;
}
if (!read_sh(reader, sh_attrs)) {
BKE_report(reports, RPT_ERROR, "SPZ Read: Error reading spherical harmonics");
return false;
}
}
/* TODO(sergey): Handle extensions. */
convert_axis_to_blender(positions, rotations, sh_attrs);
return true;
}
PointCloud *read_spz_ngsp_file(FILE *file, ReportList *reports)
{
NgspFileHeader header;
if (!read_spz_struct(file, header)) {
BKE_report(reports, RPT_ERROR, "SPZ Read: Error reading NGSP header");
return nullptr;
}
CLOG_DEBUG(&LOG, "SPZ header magic: 0x%X", header.magic);
CLOG_DEBUG(&LOG, "SPZ header version: %u", header.version);
if (header.magic != SPZ_HEADER_MAGIC) {
BKE_reportf(reports, RPT_ERROR, "SPZ Read: Unexpected SPZ header magic 0x%X", header.magic);
return nullptr;
}
if (header.version != 4) {
BKE_reportf(reports, RPT_ERROR, "SPZ Read: Unsupported SPZ version %u", header.version);
return nullptr;
}
CLOG_DEBUG(&LOG, "SPZ header num_points: %u", header.num_points);
CLOG_DEBUG(&LOG, "SPZ header sh_degree: %d", int(header.sh_degree));
CLOG_DEBUG(&LOG, "SPZ header fractional_bits: %d", int(header.fractional_bits));
CLOG_DEBUG(&LOG, "SPZ header flags: %d", int(header.flags));
CLOG_DEBUG(&LOG, "SPZ header num_streams: %d", int(header.num_streams));
CLOG_DEBUG(&LOG, "SPZ header toc_byte_offset: %u", header.toc_byte_offset);
if (header.flags & SPZ_HEADER_ANTIALIASED) {
/* TODO(sergey): Support antialiased data. */
CLOG_WARN(&LOG, "SPZ data was trained with antialiasing which is not fully supported");
}
if (header.flags & SPZ_HEADER_HAS_EXTENSIONS) {
/* TODO(sergey): Support extensions. */
CLOG_WARN(&LOG, "SPZ file contains extensions that are not yet supported");
}
if (header.toc_byte_offset < sizeof(NgspFileHeader)) {
BKE_report(
reports, RPT_ERROR, "SPZ Read: TOC byte offset is less than the size of the header");
return nullptr;
}
Array<StreamInfo> stream_infos;
if (!read_toc(file, header, stream_infos)) {
return nullptr;
}
/* There is expected to be 6 streams: positions, alphas, colors, scales, rotations, sh. */
if (stream_infos.size() < 6) {
BKE_report(reports, RPT_ERROR, "SPZ Read: Unexpected number of Zstd streams");
return nullptr;
}
if (!validate::size_fits_in_int(header.num_points)) {
BKE_report(reports, RPT_ERROR, "SPZ Read: Too many points");
return nullptr;
}
PointCloud *pointcloud = BKE_pointcloud_new_nomain(PointCloudType::GSplat, header.num_points);
gsplat::GsplatMutableAttributeAccessor accessor(*pointcloud, header.sh_degree);
if (!read_spz(file, header, stream_infos, reports, accessor)) {
accessor.finish();
BKE_id_free(nullptr, &pointcloud->id);
return nullptr;
}
accessor.finish();
return pointcloud;
}
} // namespace blender::io::spz

View file

@ -298,6 +298,10 @@ if(WITH_IO_PLY)
add_definitions(-DWITH_IO_PLY)
endif()
if(WITH_IO_SPZ)
add_definitions(-DWITH_IO_SPZ)
endif()
if(WITH_IO_STL)
add_definitions(-DWITH_IO_STL)
endif()

View file

@ -50,6 +50,7 @@ static PyStructSequence_Field app_builtopts_info_fields[] = {
{"mod_remesh", nullptr},
{"io_wavefront_obj", nullptr},
{"io_ply", nullptr},
{"io_spz", nullptr},
{"io_stl", nullptr},
{"io_fbx", nullptr},
{"io_gpencil", nullptr},
@ -250,6 +251,12 @@ static PyObject *make_builtopts_info()
SetObjIncref(Py_False);
#endif
#ifdef WITH_IO_SPZ
SetObjIncref(Py_True);
#else
SetObjIncref(Py_False);
#endif
#ifdef WITH_IO_STL
SetObjIncref(Py_True);
#else