diff --git a/devices/rtx/device/gpu/shadingState.h b/devices/rtx/device/gpu/shadingState.h index 23b0ba48..5133d874 100644 --- a/devices/rtx/device/gpu/shadingState.h +++ b/devices/rtx/device/gpu/shadingState.h @@ -161,7 +161,8 @@ struct NvdbRegularSamplerState const GridType *grid; AccessorType accessor; SamplerType sampler; - nanovdb::Vec3f offset; + nanovdb::Vec3f offsetDown; + nanovdb::Vec3f offsetUp; nanovdb::Vec3f scale; nanovdb::Vec3f indexMin; nanovdb::Vec3f indexMax; @@ -178,8 +179,10 @@ struct NvdbRectilinearSamplerState const GridType *grid; AccessorType accessor; SamplerType sampler; - nanovdb::Vec3f offset; - nanovdb::Vec3f scale; + nanovdb::Vec3f offsetDown; + nanovdb::Vec3f offsetUp; + nanovdb::Vec3f scaleDown; + nanovdb::Vec3f scaleUp; nanovdb::Vec3f indexMin; nanovdb::Vec3f indexMax; cudaTextureObject_t axisLUT[3]; diff --git a/devices/rtx/device/spatial_field/NvdbRectilinearSampler_ptx.cu b/devices/rtx/device/spatial_field/NvdbRectilinearSampler_ptx.cu index 541a83a3..ffd0b32c 100644 --- a/devices/rtx/device/spatial_field/NvdbRectilinearSampler_ptx.cu +++ b/devices/rtx/device/spatial_field/NvdbRectilinearSampler_ptx.cu @@ -29,6 +29,8 @@ * POSSIBILITY OF SUCH DAMAGE. */ +#include +#include #include "gpu/gpu_decl.h" #include "gpu/gpu_objects.h" #include "gpu/shadingState.h" @@ -61,19 +63,25 @@ VISRTX_DEVICE void initNvdbRectilinearSampler( typename NvdbRectilinearSamplerState::SamplerType( nanovdb::math::createSampler<1>(state.accessor)); - const bool cellCentered = field->data.nvdbRectilinear.cellCentered; const nanovdb::CoordBBox indexBBox = grid->indexBBox(); const nanovdb::Vec3f dims = nanovdb::Vec3f(indexBBox.dim()); - state.indexMin = indexBBox.min().asVec3d(); - state.indexMax = indexBBox.max().asVec3d(); - if (cellCentered) { - state.offset = nanovdb::Vec3f(-0.5f); - state.scale = nanovdb::Vec3f(1.0f); + // NanoVDB samplers get exact values at 0, 1, ... N, which works for + // node centered data. For cell centered data, we need to offset by -0.5 + // and clamp to artificially create the full voxel, extrapolating the + // outermost voxel values. + // ScaleDown moves from index space to normalized space [0, 1] + // ScaleUp moves from normalized space [0, 1] to index space - 1 + state.scaleDown = 1.0f / dims; + state.scaleUp = dims - nanovdb::Vec3f(1.0f); + state.offsetDown = -nanovdb::Vec3f(indexBBox.min()); + if (field->data.nvdbRectilinear.cellCentered) { + state.offsetUp = nanovdb::Vec3f(-0.5f) - state.offsetDown; } else { - state.offset = nanovdb::Vec3f(0.0f); - state.scale = (dims - nanovdb::Vec3f(1.0f)) / dims; + state.offsetUp = -state.offsetDown; } + state.indexMin = nanovdb::Vec3f(indexBBox.min()); + state.indexMax = nanovdb::Vec3f(indexBBox.max()); state.axisLUT[0] = field->data.nvdbRectilinear.axisLUT[0]; state.axisLUT[1] = field->data.nvdbRectilinear.axisLUT[1]; @@ -84,37 +92,22 @@ template VISRTX_DEVICE float sampleNvdbRectilinear( const NvdbRectilinearSamplerState &state, const vec3 *location) { - nanovdb::Vec3f samplePos(location->x, location->y, location->z); - - // Apply rectilinear mapping to samplePosition - if (state.axisLUT[0]) { - samplePos[0] -= state.indexMin[0]; - samplePos[0] /= (state.indexMax[0] - state.indexMin[0]); - samplePos[0] = tex1D(state.axisLUT[0], samplePos[0]); - samplePos[0] *= (state.indexMax[0] - state.indexMin[0]); - samplePos[0] += state.indexMin[0]; - } - if (state.axisLUT[1]) { - samplePos[1] -= state.indexMin[1]; - samplePos[1] /= (state.indexMax[1] - state.indexMin[1]); - samplePos[1] = tex1D(state.axisLUT[1], samplePos[1]); - samplePos[1] *= (state.indexMax[1] - state.indexMin[1]); - samplePos[1] += state.indexMin[1]; - } - if (state.axisLUT[2]) { - samplePos[2] -= state.indexMin[2]; - samplePos[2] /= (state.indexMax[2] - state.indexMin[2]); - samplePos[2] = tex1D(state.axisLUT[2], samplePos[2]); - samplePos[2] *= (state.indexMax[2] - state.indexMin[2]); - samplePos[2] += state.indexMin[2]; - } + const auto indexPos0 = state.grid->worldToIndexF( + nanovdb::Vec3f(location->x, location->y, location->z)); + + // Recenter and normalize + const auto normalizedPos = (indexPos0 - state.offsetDown) * state.scaleDown; - auto indexPos = state.grid->worldToIndexF(samplePos); + // Apply rectilinear mapping + const auto normalizedPosRect = + nanovdb::Vec3f(tex1D(state.axisLUT[0], normalizedPos[0]), + tex1D(state.axisLUT[1], normalizedPos[1]), + tex1D(state.axisLUT[2], normalizedPos[2])); - indexPos = indexPos * state.scale + state.offset; - indexPos = clamp(indexPos, state.indexMin, state.indexMax); + // Back to index space + const auto indexPos = normalizedPosRect * state.scaleUp + state.offsetUp; - return state.sampler(indexPos); + return state.sampler(clamp(indexPos, state.indexMin, state.indexMax)); } // Fp4 rectilinear sampler diff --git a/devices/rtx/device/spatial_field/NvdbRegularSampler_ptx.cu b/devices/rtx/device/spatial_field/NvdbRegularSampler_ptx.cu index b8615269..ccb64c0b 100644 --- a/devices/rtx/device/spatial_field/NvdbRegularSampler_ptx.cu +++ b/devices/rtx/device/spatial_field/NvdbRegularSampler_ptx.cu @@ -38,12 +38,12 @@ using namespace visrtx; VISRTX_DEVICE nanovdb::Vec3f clamp(const nanovdb::Vec3f &v, - const nanovdb::Vec3f &min, - const nanovdb::Vec3f &max) + const nanovdb::Vec3f &min, + const nanovdb::Vec3f &max) { return nanovdb::Vec3f(nanovdb::math::Clamp(v[0], min[0], max[0]), - nanovdb::math::Clamp(v[1], min[1], max[1]), - nanovdb::math::Clamp(v[2], min[2], max[2])); + nanovdb::math::Clamp(v[1], min[1], max[1]), + nanovdb::math::Clamp(v[2], min[2], max[2])); } template @@ -61,32 +61,37 @@ VISRTX_DEVICE void initNvdbSampler( new (&state.sampler) typename NvdbRegularSamplerState::SamplerType( nanovdb::math::createSampler<1>(state.accessor)); - const bool cellCentered = field->data.nvdbRegular.cellCentered; const nanovdb::CoordBBox indexBBox = grid->indexBBox(); const nanovdb::Vec3f dims = nanovdb::Vec3f(indexBBox.dim()); - state.indexMin = indexBBox.min().asVec3d(); - state.indexMax = indexBBox.max().asVec3d(); - - if (cellCentered) { - state.offset = nanovdb::Vec3f(-0.5f); + // NanoVDB samplers get exact values at 0, 1, ... N, which works for + // node centered data. For cell centered data, we need to offset by -0.5 + // and clamp to artificially create the full voxel, extrapolating the + // outermost voxel values. + // Scale moves from index space to index space - 1 + state.offsetDown = -nanovdb::Vec3f(indexBBox.min()); + if (field->data.nvdbRectilinear.cellCentered) { + state.offsetUp = nanovdb::Vec3f(-0.5f) - state.offsetDown; state.scale = nanovdb::Vec3f(1.0f); } else { - state.offset = nanovdb::Vec3f(0.0f); - state.scale = (dims - nanovdb::Vec3f(1.0f)) / dims; + state.offsetUp = -state.offsetDown; + state.scale = (dims) / (dims + nanovdb::Vec3f(1.0f)); } + + state.indexMin = nanovdb::Vec3f(indexBBox.min()); + state.indexMax = nanovdb::Vec3f(indexBBox.max()); } template VISRTX_DEVICE float sampleNvdb( const NvdbRegularSamplerState &state, const vec3 *location) { - auto indexPos = state.grid->worldToIndexF( + const auto indexPos0 = state.grid->worldToIndexF( nanovdb::Vec3f(location->x, location->y, location->z)); - indexPos = indexPos * state.scale + state.offset; - indexPos = clamp(indexPos, state.indexMin, state.indexMax); + const auto indexPos = + (indexPos0 - state.offsetDown) * state.scale + state.offsetUp; - return state.sampler(indexPos); + return state.sampler(clamp(indexPos, state.indexMin, state.indexMax)); } // Fp4 sampler diff --git a/devices/rtx/device/spatial_field/StructuredRectilinearSampler_ptx.cu b/devices/rtx/device/spatial_field/StructuredRectilinearSampler_ptx.cu index 65dc3044..83d81f22 100644 --- a/devices/rtx/device/spatial_field/StructuredRectilinearSampler_ptx.cu +++ b/devices/rtx/device/spatial_field/StructuredRectilinearSampler_ptx.cu @@ -65,13 +65,9 @@ VISRTX_CALLABLE float __direct_callable__sampleStructuredRectilinear( vec3 normalizedPos = (*location - state.axisBoundsMin) / (state.axisBoundsMax - state.axisBoundsMin); - normalizedPos = - vec3(state.axisLUT[0] ? tex1D(state.axisLUT[0], normalizedPos.x) - : normalizedPos.x, - state.axisLUT[1] ? tex1D(state.axisLUT[1], normalizedPos.y) - : normalizedPos.y, - state.axisLUT[2] ? tex1D(state.axisLUT[2], normalizedPos.z) - : normalizedPos.z); + normalizedPos = vec3(tex1D(state.axisLUT[0], normalizedPos.x), + tex1D(state.axisLUT[1], normalizedPos.y), + tex1D(state.axisLUT[2], normalizedPos.z)); // Sample texture with transformed coordinates auto sampleCoord = normalizedPos * state.dims + state.offset; diff --git a/tsd/apps/tools/tsdVolumeToNanoVDB.cpp b/tsd/apps/tools/tsdVolumeToNanoVDB.cpp index ff435295..88f40ff9 100644 --- a/tsd/apps/tools/tsdVolumeToNanoVDB.cpp +++ b/tsd/apps/tools/tsdVolumeToNanoVDB.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -65,14 +66,16 @@ int main(int argc, const char *argv[]) if (arg == "--undefined" || arg == "-u") { if (i + 1 >= argc) { - tsd::core::logError("Option %s requires a value", std::string(arg).c_str()); + tsd::core::logError( + "Option %s requires a value", std::string(arg).c_str()); printUsage(argv[0]); return 1; } try { undefinedValue = std::stof(std::string(argv[++i])); } catch (const std::exception &e) { - tsd::core::logError("Invalid undefined value: %s", std::string(argv[i] ).c_str()); + tsd::core::logError( + "Invalid undefined value: %s", std::string(argv[i]).c_str()); printUsage(argv[0]); return 1; } @@ -96,7 +99,8 @@ int main(int argc, const char *argv[]) } else if (precStr == "float32") { precision = tsd::io::VDBPrecision::Float32; } else { - tsd::core::logError("Unknown precision type: %s", std::string(precStr).c_str()); + tsd::core::logError( + "Unknown precision type: %s", std::string(precStr).c_str()); printUsage(argv[0]); return 1; } @@ -108,7 +112,8 @@ int main(int argc, const char *argv[]) } else if (!outputFile) { outputFile = std::string(arg); } else { - tsd::core::logError("Unexpected positional argument: %s", std::string(arg).c_str()); + tsd::core::logError( + "Unexpected positional argument: %s", std::string(arg).c_str()); printUsage(argv[0]); return 1; } @@ -143,7 +148,16 @@ int main(int argc, const char *argv[]) return 1; } - tsd::io::export_StructuredRegularVolumeToNanoVDB(spatialField, + const auto subtype = spatialField->subtype(); + const bool isRectilinear = + subtype == tsd::core::tokens::spatial_field::structuredRectilinear; + + if (isRectilinear) { + tsd::core::logStatus( + "Detected rectilinear grid; writing NanoVDB sidecar with axis coordinates."); + } + + tsd::io::export_StructuredVolumeToNanoVDB(spatialField, outputFile->c_str(), undefinedValue.has_value(), undefinedValue.value_or(0.0f), diff --git a/tsd/src/tsd/core/scene/objects/SpatialField.cpp b/tsd/src/tsd/core/scene/objects/SpatialField.cpp index 0824feb8..5fa9fe47 100644 --- a/tsd/src/tsd/core/scene/objects/SpatialField.cpp +++ b/tsd/src/tsd/core/scene/objects/SpatialField.cpp @@ -92,7 +92,7 @@ SpatialField::SpatialField(Token stype) : Object(ANARI_SPATIAL_FIELD, stype) .setValue({ANARI_ARRAY1D, INVALID_INDEX}) .setDescription("array of floats containing the scalar voxel data"); } else if (stype == tokens::spatial_field::nanovdb) { - addParameter("gridData") + addParameter("data") .setValue({ANARI_ARRAY1D, INVALID_INDEX}) .setDescription("array containing serialzed NanoVDB grid"); addParameter("filter").setValue("linear").setStringValues( @@ -108,6 +108,34 @@ SpatialField::SpatialField(Token stype) : Object(ANARI_SPATIAL_FIELD, stype) .setValue(tsd::math::box3( tsd::math::float3(-math::inf, -math::inf, -math::inf), tsd::math::float3(math::inf, math::inf, math::inf))); + } else if (stype == tokens::spatial_field::nanovdbRectilinear) { + addParameter("data") + .setValue({ANARI_ARRAY1D, INVALID_INDEX}) + .setDescription("array containing serialzed NanoVDB grid"); + addParameter("filter") + .setValue("linear") + .setStringValues({"linear", "nearest"}) + .setStringSelection(0); + addParameter("dataCentering") + .setValue("cell") + .setStringValues({"node", "cell"}) + .setStringSelection(1) + .setDescription( + "whether data values are node-centered or cell-centered"); + addParameter("roi") + .setDescription("ROI box in object space") + .setValue(tsd::math::box3( + tsd::math::float3(-math::inf, -math::inf, -math::inf), + tsd::math::float3(math::inf, math::inf, math::inf))); + addParameter("coordsX") + .setValue({ANARI_ARRAY1D, INVALID_INDEX}) + .setDescription("X-axis coordinates"); + addParameter("coordsY") + .setValue({ANARI_ARRAY1D, INVALID_INDEX}) + .setDescription("Y-axis coordinates"); + addParameter("coordsZ") + .setValue({ANARI_ARRAY1D, INVALID_INDEX}) + .setDescription("Z-axis coordinates"); } } @@ -150,7 +178,8 @@ tsd::math::float2 SpatialField::computeValueRange() retval = *r; else if (auto r = getDataRangeFromParameter(parameter("cell.data")); r) retval = *r; - } else if (subtype() == tokens::spatial_field::nanovdb) { + } else if (subtype() == tokens::spatial_field::nanovdb + || subtype() == tokens::spatial_field::nanovdbRectilinear) { if (auto *range = parameter("range"); range) retval = range->value().get(); } else if (subtype() == tokens::spatial_field::amr) { @@ -175,6 +204,7 @@ const Token structuredRectilinear = "structuredRectilinear"; const Token unstructured = "unstructured"; const Token amr = "amr"; const Token nanovdb = "nanovdb"; +const Token nanovdbRectilinear = "nanovdbRectilinear"; } // namespace tokens::spatial_field diff --git a/tsd/src/tsd/core/scene/objects/SpatialField.hpp b/tsd/src/tsd/core/scene/objects/SpatialField.hpp index 274caeb0..341c60ca 100644 --- a/tsd/src/tsd/core/scene/objects/SpatialField.hpp +++ b/tsd/src/tsd/core/scene/objects/SpatialField.hpp @@ -34,6 +34,7 @@ extern const Token structuredRectilinear; extern const Token unstructured; extern const Token amr; extern const Token nanovdb; +extern const Token nanovdbRectilinear; } // namespace tokens::spatial_field diff --git a/tsd/src/tsd/core/scene/objects/Volume.cpp b/tsd/src/tsd/core/scene/objects/Volume.cpp index e1f65b98..edc1bb62 100644 --- a/tsd/src/tsd/core/scene/objects/Volume.cpp +++ b/tsd/src/tsd/core/scene/objects/Volume.cpp @@ -53,6 +53,7 @@ anari::Object Volume::makeANARIObject(anari::Device d) const namespace tokens::volume { const Token structuredRegular = "structuredRegular"; +const Token structuredRectilinear = "structuredRectilinear"; const Token transferFunction1D = "transferFunction1D"; } // namespace tokens::volume diff --git a/tsd/src/tsd/core/scene/objects/Volume.hpp b/tsd/src/tsd/core/scene/objects/Volume.hpp index 69afc41e..7fdd7d53 100644 --- a/tsd/src/tsd/core/scene/objects/Volume.hpp +++ b/tsd/src/tsd/core/scene/objects/Volume.hpp @@ -26,6 +26,7 @@ using VolumeRef = ObjectPoolRef; namespace tokens::volume { extern const Token structuredRegular; +extern const Token structuredRectilinear; extern const Token transferFunction1D; } // namespace tokens::volume diff --git a/tsd/src/tsd/io/CMakeLists.txt b/tsd/src/tsd/io/CMakeLists.txt index 29d54d3b..67ca9711 100644 --- a/tsd/src/tsd/io/CMakeLists.txt +++ b/tsd/src/tsd/io/CMakeLists.txt @@ -49,7 +49,8 @@ PRIVATE procedural/generate_randomSpheres.cpp procedural/generate_rtow.cpp procedural/generate_sphereSetVolume.cpp - serialization/export_RegularVolumeToNanoVDB.cpp + serialization/export_StructuredVolumeToNanoVDB.cpp + serialization/NanoVdbSidecar.cpp serialization/export_SceneToUSD.cpp serialization/serialization_datatree.cpp ) diff --git a/tsd/src/tsd/io/importers/import_NVDB.cpp b/tsd/src/tsd/io/importers/import_NVDB.cpp index 20ce16d2..e5722e8e 100644 --- a/tsd/src/tsd/io/importers/import_NVDB.cpp +++ b/tsd/src/tsd/io/importers/import_NVDB.cpp @@ -1,9 +1,10 @@ // Copyright 2024-2026 NVIDIA Corporation // SPDX-License-Identifier: Apache-2.0 +#include "tsd/core/Logging.hpp" #include "tsd/io/importers.hpp" #include "tsd/io/importers/detail/importer_common.hpp" -#include "tsd/core/Logging.hpp" +#include "tsd/io/serialization/NanoVdbSidecar.hpp" // nanovdb #include @@ -12,6 +13,7 @@ #include #include +#include #include namespace tsd::io { @@ -24,7 +26,25 @@ SpatialFieldRef import_NVDB(Scene &scene, const char *filepath) if (file.empty()) return {}; - auto field = scene.createObject(tokens::spatial_field::nanovdb); + const std::filesystem::path nvdbPath(filepath); + const auto sidecarPath = makeSidecarPath(nvdbPath); + + std::optional sidecar; + std::string sidecarError; + + if (std::filesystem::exists(sidecarPath)) { + sidecar = readSidecar(sidecarPath, sidecarError); + if (!sidecar) { + logWarning("[import_NVDB] Found sidecar but failed to parse: %s (%s)", + sidecarPath.string().c_str(), + sidecarError.c_str()); + } + } + + const bool hasRectCoords = sidecar && sidecar->hasCoords(); + auto field = scene.createObject(hasRectCoords + ? tokens::spatial_field::nanovdbRectilinear + : tokens::spatial_field::nanovdb); field->setName(file.c_str()); try { @@ -106,6 +126,32 @@ SpatialFieldRef import_NVDB(Scene &scene, const char *filepath) gridData->unmap(); field->setParameterObject("data", *gridData); + if (sidecar) { + if (!sidecar->dataCentering.empty()) + field->setParameter("dataCentering", sidecar->dataCentering.c_str()); + + if (sidecar->roi) + field->setParameter("roi", *sidecar->roi); + + if (hasRectCoords) { + auto makeCoordArray = [&](const std::vector &src) { + auto arr = scene.createArray(ANARI_FLOAT32, src.size()); + auto *dst = arr->mapAs(); + for (size_t i = 0; i < src.size(); ++i) + dst[i] = static_cast(src[i]); + arr->unmap(); + return arr; + }; + + auto coordsX = makeCoordArray(sidecar->coordsX); + auto coordsY = makeCoordArray(sidecar->coordsY); + auto coordsZ = makeCoordArray(sidecar->coordsZ); + field->setParameterObject("coordsX", *coordsX); + field->setParameterObject("coordsY", *coordsY); + field->setParameterObject("coordsZ", *coordsZ); + } + } + logStatus("[import_NVDB] ...done!"); } catch (const std::exception &e) { logStatus("[import_NVDB] failed: %s", e.what()); @@ -115,4 +161,4 @@ SpatialFieldRef import_NVDB(Scene &scene, const char *filepath) return field; } -} // namespace tsd +} // namespace tsd::io diff --git a/tsd/src/tsd/io/serialization.hpp b/tsd/src/tsd/io/serialization.hpp index 7b948e48..1e25239c 100644 --- a/tsd/src/tsd/io/serialization.hpp +++ b/tsd/src/tsd/io/serialization.hpp @@ -37,7 +37,7 @@ void load_Scene(Scene &scene, core::DataNode &root); void export_SceneToUSD( Scene &scene, const char *filename, int framesPerSecond = 30); -void export_StructuredRegularVolumeToNanoVDB( +void export_StructuredVolumeToNanoVDB( const SpatialField* spatialField, std::string_view outputFilename, bool useUndefinedValue = false, diff --git a/tsd/src/tsd/io/serialization/NanoVdbSidecar.cpp b/tsd/src/tsd/io/serialization/NanoVdbSidecar.cpp new file mode 100644 index 00000000..6a57b644 --- /dev/null +++ b/tsd/src/tsd/io/serialization/NanoVdbSidecar.cpp @@ -0,0 +1,194 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "NanoVdbSidecar.hpp" +#include "tsd/core/DataTree.hpp" + +// std +#include + +namespace tsd::io { + +namespace { + +bool isFinite(const math::float3 &v) +{ + return std::isfinite(v.x) && std::isfinite(v.y) && std::isfinite(v.z); +} + +bool isFinite(const math::box3 &b) +{ + return isFinite(b.lower) && isFinite(b.upper); +} + +std::optional parseFloat3(const core::DataNode *node) +{ + if (!node) + return std::nullopt; + + math::float3 result; + if (!node->getValue(ANARI_FLOAT32_VEC3, &result)) + return std::nullopt; + + return result; +} + +std::optional parseBox3(const core::DataNode *node) +{ + if (!node) + return std::nullopt; + + const auto *lower = node->child("lower"); + const auto *upper = node->child("upper"); + + const auto lowerVec = parseFloat3(lower); + const auto upperVec = parseFloat3(upper); + + if (!lowerVec || !upperVec) + return std::nullopt; + + return math::box3(*lowerVec, *upperVec); +} + +bool parseCoords(const core::DataNode *node, std::vector &out) +{ + out.clear(); + if (!node) + return true; + + const double *dataPtr = nullptr; + size_t numElements = 0; + node->getValueAsArray(&dataPtr, &numElements); + + if (dataPtr && numElements > 0) { + out.assign(dataPtr, dataPtr + numElements); + return true; + } + + return true; +} + +} // namespace + +std::filesystem::path makeSidecarPath(const std::filesystem::path &nvdbPath) +{ + auto path = nvdbPath; + path.replace_extension("tsd"); + return path; +} + +bool writeSidecar(const NanoVdbSidecar &sidecar, + const std::filesystem::path &path, + std::string &errorMessage) +{ + core::DataTree tree; + auto &root = tree.root(); + + root["schemaVersion"] = static_cast(sidecar.schemaVersion); + root["volumeType"] = sidecar.volumeType; + + if (!sidecar.dataCentering.empty()) + root["dataCentering"] = sidecar.dataCentering; + + if (sidecar.origin) + root["origin"] = *sidecar.origin; + + if (sidecar.spacing) + root["spacing"] = *sidecar.spacing; + + if (sidecar.roi && isFinite(*sidecar.roi)) { + auto &roiNode = root["roi"]; + roiNode["lower"] = sidecar.roi->lower; + roiNode["upper"] = sidecar.roi->upper; + } + + if (!sidecar.coordsX.empty()) { + root["coordsX"].setValueAsArray( + sidecar.coordsX.data(), sidecar.coordsX.size()); + } + + if (!sidecar.coordsY.empty()) { + root["coordsY"].setValueAsArray( + sidecar.coordsY.data(), sidecar.coordsY.size()); + } + + if (!sidecar.coordsZ.empty()) { + root["coordsZ"].setValueAsArray( + sidecar.coordsZ.data(), sidecar.coordsZ.size()); + } + + if (!tree.save(path.string().c_str())) { + errorMessage = "failed to save sidecar file"; + return false; + } + + return true; +} + +std::optional readSidecar( + const std::filesystem::path &path, std::string &errorMessage) +{ + core::DataTree tree; + if (!tree.load(path.string().c_str())) { + errorMessage = "failed to load sidecar file"; + return std::nullopt; + } + + auto &root = tree.root(); + NanoVdbSidecar sidecar; + + // Read schema version + const auto *schemaVersion = root.child("schemaVersion"); + if (!schemaVersion) { + errorMessage = "sidecar missing schemaVersion"; + return std::nullopt; + } + int version = 0; + if (!schemaVersion->getValue(ANARI_INT32, &version)) { + errorMessage = "sidecar schemaVersion must be numeric"; + return std::nullopt; + } + sidecar.schemaVersion = static_cast(version); + + // Read volume type + const auto *volumeType = root.child("volumeType"); + if (!volumeType) { + errorMessage = "sidecar missing volumeType"; + return std::nullopt; + } + sidecar.volumeType = volumeType->getValue().is(ANARI_STRING) + ? volumeType->getValueAs() + : "structuredRegular"; + + // Read data centering (optional) + if (const auto *dc = root.child("dataCentering")) { + sidecar.dataCentering = dc->getValueAs(); + } + + // Read origin (optional) + sidecar.origin = parseFloat3(root.child("origin")); + + // Read spacing (optional) + sidecar.spacing = parseFloat3(root.child("spacing")); + + // Read ROI (optional) + if (const auto *roi = root.child("roi")) { + sidecar.roi = parseBox3(roi); + if (!sidecar.roi) { + errorMessage = "sidecar roi must contain lower/upper float3 vectors"; + return std::nullopt; + } + } + + // Read coordinate arrays (optional) + if (!parseCoords(root.child("coordsX"), sidecar.coordsX) + || !parseCoords(root.child("coordsY"), sidecar.coordsY) + || !parseCoords(root.child("coordsZ"), sidecar.coordsZ)) { + errorMessage = "sidecar coords arrays must be numeric"; + return std::nullopt; + } + + return sidecar; +} + +} // namespace tsd::io diff --git a/tsd/src/tsd/io/serialization/NanoVdbSidecar.hpp b/tsd/src/tsd/io/serialization/NanoVdbSidecar.hpp new file mode 100644 index 00000000..a7595392 --- /dev/null +++ b/tsd/src/tsd/io/serialization/NanoVdbSidecar.hpp @@ -0,0 +1,42 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/core/TSDMath.hpp" + +#include +#include +#include +#include + +namespace tsd::io { + +struct NanoVdbSidecar +{ + int schemaVersion = 1; + std::string volumeType; + std::string dataCentering; + std::optional origin; + std::optional spacing; + std::optional roi; + std::vector coordsX; + std::vector coordsY; + std::vector coordsZ; + + bool hasCoords() const + { + return !coordsX.empty() && !coordsY.empty() && !coordsZ.empty(); + } +}; + +std::filesystem::path makeSidecarPath(const std::filesystem::path &nvdbPath); + +bool writeSidecar(const NanoVdbSidecar &sidecar, + const std::filesystem::path &path, + std::string &errorMessage); + +std::optional readSidecar( + const std::filesystem::path &path, std::string &errorMessage); + +} // namespace tsd::io diff --git a/tsd/src/tsd/io/serialization/export_RegularVolumeToNanoVDB.cpp b/tsd/src/tsd/io/serialization/export_StructuredVolumeToNanoVDB.cpp similarity index 57% rename from tsd/src/tsd/io/serialization/export_RegularVolumeToNanoVDB.cpp rename to tsd/src/tsd/io/serialization/export_StructuredVolumeToNanoVDB.cpp index a92fe357..76226024 100644 --- a/tsd/src/tsd/io/serialization/export_RegularVolumeToNanoVDB.cpp +++ b/tsd/src/tsd/io/serialization/export_StructuredVolumeToNanoVDB.cpp @@ -6,6 +6,7 @@ #include "tsd/core/scene/objects/Array.hpp" #include "tsd/core/scene/objects/SpatialField.hpp" #include "tsd/io/serialization.hpp" +#include "tsd/io/serialization/NanoVdbSidecar.hpp" // nanovdb #include @@ -14,14 +15,20 @@ #include // std +#include #include +#include +#include #include #include +#include + +using namespace std::string_view_literals; namespace tsd::io { template -void doExportStructuredRegularVolumeToNanoVDB(const T *data, +void doExportStructuredVolumeToNanoVDB(const T *data, math::float3 origin, math::float3 spacing, math::int3 dims, @@ -32,24 +39,21 @@ void doExportStructuredRegularVolumeToNanoVDB(const T *data, bool enableDithering) { tsd::core::logStatus( - "[export_StructuredRegularVolumeToNanoVDB] Volume dimensions: %u x %u x %u", + "[export_StructuredVolumeToNanoVDB] Volume dimensions: %u x %u x %u", dims.x, dims.y, dims.z); tsd::core::logStatus( - "[export_StructuredRegularVolumeToNanoVDB] Origin: (%.3f, %.3f, %.3f)", + "[export_StructuredVolumeToNanoVDB] Origin: (%.3f, %.3f, %.3f)", origin.x, origin.y, origin.z); tsd::core::logStatus( - "[export_StructuredRegularVolumeToNanoVDB] Spacing: (%.3f, %.3f, %.3f)", + "[export_StructuredVolumeToNanoVDB] Spacing: (%.3f, %.3f, %.3f)", spacing.x, spacing.y, spacing.z); - // Adjust spacing to account node vs cell storage - spacing = spacing / dims * (dims - math::int3(1)); - // Create a build grid, for now, always float. We can always use nanovdb // toolings to convert later if needed. auto buildGrid = std::make_shared>( @@ -138,7 +142,7 @@ void doExportStructuredRegularVolumeToNanoVDB(const T *data, : 0.0f; tsd::core::logStatus( - "[export_StructuredRegularVolumeToNanoVDB] Active voxels: %zu/%zu (%.1f%% inactive cell compression)", + "[export_StructuredVolumeToNanoVDB] Active voxels: %zu/%zu (%.1f%% inactive cell compression)", activeVoxelsCount, totalVoxelsCount, inactiveCellCompression * 100.0f); @@ -148,11 +152,11 @@ void doExportStructuredRegularVolumeToNanoVDB(const T *data, // Validate quantization choice if (precision == VDBPrecision::Fp4 && valueRange > 30.0f) { tsd::core::logWarning( - "[export_StructuredRegularVolumeToNanoVDB] Fp4 quantization with value range %.2f may introduce significant error (recommended < 30.0)", + "[export_StructuredVolumeToNanoVDB] Fp4 quantization with value range %.2f may introduce significant error (recommended < 30.0)", valueRange); } else if (precision == VDBPrecision::Fp8 && valueRange > 500.0f) { tsd::core::logWarning( - "[export_StructuredRegularVolumeToNanoVDB] Fp8 quantization with value range %.2f may introduce significant error (recommended < 500.0)", + "[export_StructuredVolumeToNanoVDB] Fp8 quantization with value range %.2f may introduce significant error (recommended < 500.0)", valueRange); } @@ -199,7 +203,7 @@ void doExportStructuredRegularVolumeToNanoVDB(const T *data, break; case VDBPrecision::Half: tsd::core::logWarning( - "[export_StructuredRegularVolumeToNanoVDB] Half precision not supported in this NanoVDB build, using Fp16 instead"); + "[export_StructuredVolumeToNanoVDB] Half precision not supported in this NanoVDB build, using Fp16 instead"); handle = nanovdb::tools::createNanoGrid, nanovdb::Fp16>(*buildGrid, nanovdb::tools::StatsMode::All, @@ -211,7 +215,7 @@ void doExportStructuredRegularVolumeToNanoVDB(const T *data, if (!handle) { tsd::core::logError( - "[export_StructuredRegularVolumeToNanoVDB] Failed to create NanoVDB grid."); + "[export_StructuredVolumeToNanoVDB] Failed to create NanoVDB grid."); return; } @@ -225,12 +229,12 @@ void doExportStructuredRegularVolumeToNanoVDB(const T *data, : 0.0f; tsd::core::logStatus( - "[export_StructuredRegularVolumeToNanoVDB] Quantization compression: %.1f%% (%.2f MB -> %.2f MB)", + "[export_StructuredVolumeToNanoVDB] Quantization compression: %.1f%% (%.2f MB -> %.2f MB)", quantizationCompression * 100.0f, uncompressedActiveSize / (1024.0f * 1024.0f), finalSize / (1024.0f * 1024.0f)); tsd::core::logStatus( - "[export_StructuredRegularVolumeToNanoVDB] Total compression: %.1f%% (%.2f MB -> %.2f MB)", + "[export_StructuredVolumeToNanoVDB] Total compression: %.1f%% (%.2f MB -> %.2f MB)", totalCompression * 100.0f, uncompressedSize / (1024.0f * 1024.0f), finalSize / (1024.0f * 1024.0f)); @@ -240,53 +244,149 @@ void doExportStructuredRegularVolumeToNanoVDB(const T *data, nanovdb::io::writeGrid( std::string(outputFilename).c_str(), handle, nanovdb::io::Codec::NONE); tsd::core::logStatus( - "[export_StructuredRegularVolumeToNanoVDB] Successfully wrote VDB file: %s", + "[export_StructuredVolumeToNanoVDB] Successfully wrote VDB file: %s", std::string(outputFilename).c_str()); } catch (const std::exception &e) { tsd::core::logError( - "[export_StructuredRegularVolumeToNanoVDB] Failed to write VDB file: %s", + "[export_StructuredVolumeToNanoVDB] Failed to write VDB file: %s", e.what()); } } -void export_StructuredRegularVolumeToNanoVDB(const SpatialField *spatialField, +void export_StructuredVolumeToNanoVDB(const SpatialField *spatialField, std::string_view outputFilename, bool useUndefinedValue, float undefinedValue, VDBPrecision precision, bool enableDithering) { - if (spatialField->subtype() != tokens::volume::structuredRegular) { + const auto subtype = spatialField->subtype(); + const bool isStructuredRegular = + subtype == tokens::spatial_field::structuredRegular; + const bool isStructuredRectilinear = + subtype == tokens::spatial_field::structuredRectilinear; + + if (!isStructuredRegular && !isStructuredRectilinear) { tsd::core::logError( - "[export_StructuredRegularVolumeToNanoVDB] Not a structuredRegularVolume."); + "[export_StructuredVolumeToNanoVDB] Not a structured volume."); return; } - tsd::core::logStatus("Exporting StructuredRegularVolume to VDB file: %s", + tsd::core::logStatus("Exporting StructuredVolume to VDB file: %s", std::string(outputFilename).c_str()); - // Get volume data array object + const auto dataCentering = + spatialField->parameter("dataCentering")->value().getString(); + + // Dumb heuristic here: everything but cell is node-centered + const bool cellCentered = dataCentering == "cell"sv; + + tsd::core::logStatus("[export_StructuredVolumeToNanoVDB] Data centering: %s", + dataCentering.c_str()); + const auto *volumeData = spatialField->parameterValueAsObject("data"); if (!volumeData) { tsd::core::logError( - "[export_StructuredRegularVolumeToNanoVDB] No volume data found."); + "[export_StructuredVolumeToNanoVDB] No volume data found."); return; } const auto dims = math::int3(volumeData->dim(0), volumeData->dim(1), volumeData->dim(2)); - const auto origin = - spatialField->parameterValueAs("origin").value_or( - math::float3(0.0f)); - const auto spacing = - spatialField->parameterValueAs("spacing").value_or( - math::float3(1.0f)); + math::float3 origin{}; + math::float3 spacing{}; - switch (volumeData->elementType()) { - case ANARI_UFIXED8: - doExportStructuredRegularVolumeToNanoVDB( - reinterpret_cast(volumeData->data()), + NanoVdbSidecar sidecar; + sidecar.schemaVersion = 1; + sidecar.dataCentering = dataCentering; + + auto isFinite3 = [](const math::float3 &v) { + return std::isfinite(v.x) && std::isfinite(v.y) && std::isfinite(v.z); + }; + + if (auto roi = spatialField->parameterValueAs("roi")) { + if (isFinite3(roi->lower) && isFinite3(roi->upper)) + sidecar.roi = *roi; + } + + std::vector coordsX; + std::vector coordsY; + std::vector coordsZ; + + if (isStructuredRegular) { + sidecar.volumeType = "structuredRegular"; + origin = spatialField->parameterValueAs("origin").value_or( + math::float3(0.0f)); + spacing = spatialField->parameterValueAs("spacing").value_or( + math::float3(1.0f)); + spacing = cellCentered ? spacing : spacing / dims * (dims - math::int3(1)); + + sidecar.origin = origin; + sidecar.spacing = spacing; + } else { + sidecar.volumeType = "structuredRectilinear"; + + const auto *axisX = spatialField->parameterValueAsObject("coordsX"); + const auto *axisY = spatialField->parameterValueAsObject("coordsY"); + const auto *axisZ = spatialField->parameterValueAsObject("coordsZ"); + + if (!axisX || !axisY || !axisZ) { + tsd::core::logError( + "[export_StructuredVolumeToNanoVDB] Missing rectilinear coordinates."); + return; + } + + auto copyAxis = [](const Array *axis, std::vector &dst) { + switch (axis->elementType()) { + case ANARI_FLOAT32: { + const auto *ptr = axis->dataAs(); + dst.assign(ptr, ptr + axis->size()); + break; + } + case ANARI_FLOAT64: { + const auto *ptr = axis->dataAs(); + dst.assign(ptr, ptr + axis->size()); + break; + } + default: + return false; + } + return true; + }; + + if (!copyAxis(axisX, coordsX) || !copyAxis(axisY, coordsY) + || !copyAxis(axisZ, coordsZ)) { + tsd::core::logError( + "[export_StructuredVolumeToNanoVDB] Rectilinear coordinates must be float or double."); + return; + } + + if (coordsX.size() < 2 || coordsY.size() < 2 || coordsZ.size() < 2) { + tsd::core::logError( + "[export_StructuredVolumeToNanoVDB] Rectilinear coordinates must have at least two entries per axis."); + return; + } + + const auto extent = + math::float3(static_cast(coordsX.back() - coordsX.front()), + static_cast(coordsY.back() - coordsY.front()), + static_cast(coordsZ.back() - coordsZ.front())); + + origin = math::float3(static_cast(coordsX.front()), + static_cast(coordsY.front()), + static_cast(coordsZ.front())); + + // Compute a uniform voxel spacing equivalent for nanovdb + spacing = extent / dims; + + sidecar.coordsX = coordsX; + sidecar.coordsY = coordsY; + sidecar.coordsZ = coordsZ; + } + + auto dispatchExport = [&](auto ptr) { + doExportStructuredVolumeToNanoVDB(ptr, origin, spacing, dims, @@ -295,72 +395,45 @@ void export_StructuredRegularVolumeToNanoVDB(const SpatialField *spatialField, undefinedValue, precision, enableDithering); + }; + + switch (volumeData->elementType()) { + case ANARI_UFIXED8: + dispatchExport(reinterpret_cast(volumeData->data())); break; case ANARI_FIXED8: - doExportStructuredRegularVolumeToNanoVDB( - reinterpret_cast(volumeData->data()), - origin, - spacing, - dims, - outputFilename, - useUndefinedValue, - undefinedValue, - precision, - enableDithering); + dispatchExport(reinterpret_cast(volumeData->data())); break; case ANARI_UFIXED16: - doExportStructuredRegularVolumeToNanoVDB( - reinterpret_cast(volumeData->data()), - origin, - spacing, - dims, - outputFilename, - useUndefinedValue, - undefinedValue, - precision, - enableDithering); + dispatchExport(reinterpret_cast(volumeData->data())); break; case ANARI_FIXED16: - doExportStructuredRegularVolumeToNanoVDB( - reinterpret_cast(volumeData->data()), - origin, - spacing, - dims, - outputFilename, - useUndefinedValue, - undefinedValue, - precision, - enableDithering); + dispatchExport(reinterpret_cast(volumeData->data())); break; case ANARI_FLOAT32: - doExportStructuredRegularVolumeToNanoVDB( - reinterpret_cast(volumeData->data()), - origin, - spacing, - dims, - outputFilename, - useUndefinedValue, - undefinedValue, - precision, - enableDithering); + dispatchExport(reinterpret_cast(volumeData->data())); break; case ANARI_FLOAT64: - doExportStructuredRegularVolumeToNanoVDB( - reinterpret_cast(volumeData->data()), - origin, - spacing, - dims, - outputFilename, - useUndefinedValue, - undefinedValue, - precision, - enableDithering); + dispatchExport(reinterpret_cast(volumeData->data())); break; default: tsd::core::logError( - "[export_StructuredRegularVolumeToNanoVDB] Volume data is not of a supported float type."); + "[export_StructuredVolumeToNanoVDB] Volume data is not of a supported float type."); return; } + + const auto sidecarPath = + makeSidecarPath(std::filesystem::path(outputFilename)); + std::string sidecarError; + if (!writeSidecar(sidecar, sidecarPath, sidecarError)) { + tsd::core::logWarning( + "[export_StructuredVolumeToNanoVDB] Failed to write sidecar %s: %s", + sidecarPath.string().c_str(), + sidecarError.c_str()); + } else { + tsd::core::logStatus("[export_StructuredVolumeToNanoVDB] Wrote sidecar: %s", + sidecarPath.string().c_str()); + } } } // namespace tsd::io diff --git a/tsd/src/tsd/ui/imgui/modals/ExportNanoVDBFileDialog.cpp b/tsd/src/tsd/ui/imgui/modals/ExportNanoVDBFileDialog.cpp index d93b369d..5a709dff 100644 --- a/tsd/src/tsd/ui/imgui/modals/ExportNanoVDBFileDialog.cpp +++ b/tsd/src/tsd/ui/imgui/modals/ExportNanoVDBFileDialog.cpp @@ -111,8 +111,10 @@ void ExportNanoVDBFileDialog::buildUI() auto spatialFieldObject = selectedObject->parameterValueAsObject("value"); if (!spatialFieldObject - || spatialFieldObject->subtype() - != core::tokens::volume::structuredRegular) { + || (spatialFieldObject->subtype() + != core::tokens::volume::structuredRegular + && spatialFieldObject->subtype() + != core::tokens::volume::structuredRectilinear)) { core::logError( "[ExportVDBFileDialog] Selected TransferFunction1D does not reference a structured regular volume."); return; @@ -144,7 +146,7 @@ void ExportNanoVDBFileDialog::buildUI() break; } - io::export_StructuredRegularVolumeToNanoVDB( + io::export_StructuredVolumeToNanoVDB( static_cast(spatialFieldObject), m_filename.c_str(), m_enableUndefinedValue, diff --git a/tsd/src/tsd/ui/imgui/tsd_ui_imgui.cpp b/tsd/src/tsd/ui/imgui/tsd_ui_imgui.cpp index 2884473a..85d8321f 100644 --- a/tsd/src/tsd/ui/imgui/tsd_ui_imgui.cpp +++ b/tsd/src/tsd/ui/imgui/tsd_ui_imgui.cpp @@ -650,7 +650,8 @@ bool buildUI_parameter(tsd::core::Object &o, ImGui::BeginTooltip(); if (isArray) { const auto idx = pVal.getAsObjectIndex(); - buildUI_array_info_tooltip_text(scene, idx); + if (idx != TSD_INVALID_INDEX) + buildUI_array_info_tooltip_text(scene, idx); } else if (type == ANARI_FLOAT32_MAT4) { auto *value_f = (const float *)value; ImGui::Text("[%.3f %.3f %.3f %.3f]", diff --git a/tsd/src/tsd/ui/imgui/windows/LayerTree.cpp b/tsd/src/tsd/ui/imgui/windows/LayerTree.cpp index 680a87b9..eafdb03c 100644 --- a/tsd/src/tsd/ui/imgui/windows/LayerTree.cpp +++ b/tsd/src/tsd/ui/imgui/windows/LayerTree.cpp @@ -775,8 +775,10 @@ void LayerTree::buildUI_objectSceneMenu() auto tf1D = (*menuNode)->getObject(); auto spatialFieldObject = tf1D->parameterValueAsObject("value"); if (spatialFieldObject - && spatialFieldObject->subtype() - == core::tokens::volume::structuredRegular) { + && (spatialFieldObject->subtype() + == core::tokens::volume::structuredRegular + || spatialFieldObject->subtype() + == core::tokens::volume::structuredRectilinear)) { ImGui::Separator(); if (ImGui::MenuItem("export to NanoVDB")) { appCore()->windows.exportNanoVDBDialog->show();