图形/仿真

使用 NanoVDB 在 GPU上加速 OpenVDB

 
Walt Disney Animation Studio's cloud openvdb dataset rendered on the GPU using nanovdb

开放式 VDB 是奥斯卡奖获奖的稀疏动态卷的行业标准库。在整个视觉效果行业中,它被用于模拟和渲染水、火、烟、云和大量其他依赖于稀疏体积数据的效果。该库包括一个分层的、动态的数据结构和一套工具,用于高效地存储和操作三维网格上离散的稀疏体数据。库由 学院软件基金会( ASWF ) 维护。有关详细信息,请参见 VDB :具有动态拓扑的高分辨率稀疏卷

尽管 OpenVDB 提供了性能优势,但它的设计并没有考虑到 GPUs 。它对几个外部库的依赖使得利用 GPUs 上的 VDB 数据变得很麻烦,这正是本文主题的动机。我们将向您介绍 NanoVDB 库,并提供一些如何在光线跟踪和碰撞检测上下文中使用它的示例。

NanoVDB 简介

最初在 NVIDIA 开发的 NanoVDB 库是一个 ASWF OpenVDB 项目的新增功能 。它提供了一个与 OpenVDB 的核心数据结构完全兼容的简化表示,具有在 NanoVDB 和 OpenVDB 数据结构之间来回转换、创建和可视化数据的功能。

OpenVDB has a tree with pointer indirections, however NanoVDB uses a linearized tree with indices instead of pointers.
图 1 。 OpenVDB 和 NanoVDB 数据结构的图示。

NanoVDB 采用了 VDB 树结构的压缩、线性化、只读表示(图 1 ),这使得它适合于树层次结构的快速传输和快速、无指针遍历。为了提高效率,数据流经过调整,可以在 GPUs 和 CPU 上使用。

创建 NanoVDB 网格

尽管 NanoVDB 网格是一种只读数据结构,但该库包含生成或加载数据的功能。

所有的 OpenVDB 网格类 – LevelSetsFogVolumesPointIndexGridsPointDataGrids ——都支持 NanoVDB 表示,并且可以直接从 OpenVDB 文件(即 . vdb 系统 文件)加载。还可以将数据加载或保存到 NanoVDB 自己的文件格式中或从中保存,该格式本质上是其内存流的一个转储,其中包含用于高效检查的附加元数据。

以下代码示例从 OpenVDB 文件转换:

openvdb::io::File file(fileName);
auto vdbGrid = file.readGrid(gridName);
auto handle = nanovdb::openToNanoVDB(vdbGrid);

虽然从现有的 OpenVDB 数据加载是典型的用例,但是附带的 网格生成器 工具允许您直接在内存中构建 NanoVDB 网格。提供了一些简单原语的函数来帮助您入门:

// generate a sparse narrow-band level set (i.e. truncated signed distance field) representation of a sphere.
auto handle = nanovdb::createLevelSetSphere(50, nanovdb::Vec3f(0));

下面的示例显示了如何使用 lambda 函数生成小而密集的体积(图 2 ):

nanovdb::GridBuilder builder(0);
auto op = [](const nanovdb::Coord& ijk) -> float { return menger(nanovdb::Vec3f(ijk) * 0.01f);
};
builder(op, nanovdb::CoordBBox(nanovdb::Coord(-100), nanovdb::Coord(100)));
// create a FogVolume grid called "menger" with voxel-size 1
auto handle = builder.getHandle<>(1.0, nanovdb::Vec3d(0), "menger", nanovdb::GridClass::FogVolume);
The GridBuilder can create volumes and levelsets from a custom function.
图 2 。由 Menger 海绵函数生成的雾体积网格。

网格控制柄

网格句柄 是一个简单的类,它拥有它分配的缓冲区的所有权,允许网格的范围划分( RAII )。

它还用于封装不透明的网格数据。尽管网格数据本身是以数据类型(如 浮动 为模板的),但句柄提供了一种方便的方法来访问网格的元数据,而不必知道网格的数据类型 MIG 是什么。这很有用,因为您可以纯粹从句柄确定 GridType 。

下面的代码示例验证是否有包含级别集函数的 32 位浮点网格:

const nanovdb::GridMetaData* metadata = handle.gridMetaData();
if (!metadata->isLevelSet() || !metadata->gridType() == GridType::Float) throw std::runtime_error("Not the right stuff!");

网格缓冲区

NanoVDB 被设计成支持许多不同的平台, CPU , CUDA 甚至图形 api 。为了实现这一点,数据结构被存储在一个平坦的连续内存缓冲区中。

使这个缓冲区驻留在 CUDA 设备上很简单。为了完全控制,您可以使用 CUDA api 分配设备内存,然后将句柄的数据上载到其中。

void* d_gridData;
cudaMalloc(&d_gridData, handle.size());
cudaMemcpy(d_gridData, handle.data(), handle.size(), cudaMemcpyHostToDevice);
const nanovdb::FloatGrid* d_grid = reinterpret_cast<const nanovdb::FloatGrid*>(d_gridData);

NanoVDB 的 GridHandle 模板化在缓冲区类型上,缓冲区类型是其内存分配的包装器。它默认为使用主机系统内存的 主机缓冲区 ;然而, NanoVDB 还提供了 CUDA 缓冲器 ,以便轻松创建 CUDA 设备缓冲区。

auto handle = nanovdb::openToNanoVDB<nanovdb::CudaDeviceBuffer>(vdbGrid);
handle->deviceUpload();
const nanovdb::FloatGrid* grid = handle->deviceGrid<float>();

将数据流解释为纳米网格类型后,可以使用这些方法访问网格中的数据。有关更多详细信息,请参阅相关 API 的文档。本质上,它反映了 OpenVDB 中只读方法的基本子集。

auto hostOrDeviceOp = [grid] __host__ __device__ (nanovdb::Coord ijk) -> float {
    // Note that ReadAccessor (see below) should be used for performance.
    return grid->tree().getValue(ijk);
};

可以构造自定义缓冲区来处理不同的内存空间。有关创建可与图形 API 交互操作的缓冲区的示例的更多信息,请参阅存储库中的示例。

致使

The bunny_cloud.vdb dataset rendered on the GPU using path-tracing.
图 3 。一条小路 —在 CUDA 上使用 NanoVDB 渲染的跟踪雾体积。

由于 NanoVDB 网格提供了一个紧凑的只读 VDB 树,因此它们很适合渲染任务。光线将 VDB 网格跟踪到图像中。使用每线程一条光线,并使用一个自定义的 雷吉诺 functor 生成光线,该函数接受像素偏移并创建世界空间光线。完整的代码在存储库示例中可用。

考虑到沿射线采样具有空间相干性这一事实,可以使用 读写器 来加速采样。当光线向前移动时,这会缓存树遍历堆栈,从而允许自底向上的树遍历,这比传统的自上而下遍历要快得多,后者涉及相对较慢的无界根节点。

auto renderTransmittanceOp = [image, grid, w, h, rayGenOp, imageOp, dt] __host__ __device__ (int i) {
    nanovdb::Ray<float> wRay = rayGenOp(i, w, h);
    // transform the ray to the grid's index-space...
    nanovdb::Ray<float> iRay = wRay.worldToIndexF(*grid);
    // clip to bounds.
    if (iRay.clip(grid->tree().bbox()) == false) {
        imageOp(image, i, w, h, 1.0f);
        return;
    }
    // get an accessor.
    auto acc = grid->tree().getAccessor();
    // integrate along ray interval...
    float transmittance = 1.0f;
    for (float t = iRay.t0(); t < iRay.t1(); t+=dt) {
        float sigma = acc.getValue(nanovdb::Coord::Floor(iRay(t)));
        transmittance *= 1.0f - sigma * dt;
    }
    imageOp(image, i, w, h, transmittance );
};
The dragon.vdb dataset rendered on the GPU as a levelset.
图 4 。在 CUDA 上使用 NanoVDB 渲染的 LevelSet 。

由于光线与水平集网格相交是一项常见任务, NanoVDB 实现了一个零交叉功能,并使用分层 DDA ( HDDA )作为沿光线的根搜索的一部分来清空空间跳跃(图 5 )。有关 HDDA 的更多信息,请参阅 OpenVDB 中高效光线行进的分层数字微分分析仪 。下面是代码示例:

...
    auto acc = grid->tree().getAccessor();
    // intersect with zero level-set...
    float iT0;
    nanovdb::Coord ijk;
    float v;
    if (nanovdb::ZeroCrossing(iRay, acc, ijk, v, iT0)) { 
        // convert intersection distance (iT0) to world-space
        float wT0 = iT0 * grid->voxelSize();
        imageOp(image, i, w, h, wT0);
    } else {
        imageOp(image, i, w, h, 0.0f);
    }
...
The ray uses the hierarchical VDB tree to achieve empty-space skipping.
图 5 。水平集网格的 HDDA 。

碰撞检测

碰撞检测和解决是 NanoVDB 的另一项任务,因为它们通常需要有效地查找实体碰撞对象的有符号距离值。窄带电平集表示非常理想,因为它们用符号对内部/外部拓扑信息(碰撞检测所需)进行了紧凑编码。此外,最近点变换(冲突解决所需的)很容易从水平集函数的梯度计算。

下面的代码示例是一个用于处理冲突的函数。使用 读写器 是很有用的,因为用于冲突解决的梯度计算涉及到同一空间附近的多个提取。

auto collisionOp = [grid, positions, velocities, dt] __host__ __device__ (int i) {
    nanovdb::Vec3f wPos = positions[i];
    nanovdb::Vec3f wVel = velocities[i];
    nanovdb::Vec3f wNextPos = wPos + wVel * dt;
    // transform the position to a custom space...
    nanovdb::Vec3f iNextPos = grid.worldToIndexF(wNextPos);
    // the grid index coordinate.
    nanovdb::Coord ijk = nanovdb::Coord::Floor(iNextPos);
    // get an accessor.
    auto acc = grid->tree().getAccessor();
    if (tree.isActive(ijk)) { // are you inside the narrow band?
        float wDistance = acc.getValue(ijk);
        if (wDistance <= 0) { // are you inside the levelset?
            // get the normal for collision resolution.
            nanovdb::Vec3f normal(wDistance);
            ijk[0] += 1;
            normal[0] += acc.getValue(ijk);
            ijk[0] -= 1;
            ijk[1] += 1;
            normal[1] += acc.getValue(ijk);
            ijk[1] -= 1;
            ijk[2] += 1;
            normal[2] += acc.getValue(ijk);
            normal.normalize();
            
            // handle collision response with the surface.
            collisionResponse(wPos, wNextPos, normal, wDistance, wNextPos, wNextVel);
        }
    }
    positions[i] = wNextPos;
    velocities[i] = wNextVel;
};

同样,完整的代码可以在存储库中找到。

基准

NanoVDB 被开发成在主机和设备上同样运行良好。使用 modernCUDA 中的扩展 lambda 支持,您可以轻松地在两个平台上运行相同的代码。

本节包括比较英特尔线程构建块和 CPU CUDA 上光线跟踪和碰撞检测性能的基准测试。计时以毫秒为单位,与 NVIDIA NVIDIA 8000 相比,是在 Xeon E5-2696 v4 x2 –( 88 个 CPU 线程)上生成的。使用的 FogVolume 是兔子云, LevelSet 是 dragon 数据集。两者都可以从 OpenVDB 网站下载。渲染的分辨率为 1024×1024 。这次碰撞试验模拟了一亿颗弹道粒子。

虽然基准测试(图 6 和下表)显示了 NanoVDB 高效表示加速 CPU 上 OpenVDB 的好处,但它真正突出了使用 GPU 对 VDB 数据进行只读访问以进行碰撞检测和光线跟踪的好处。

CUDA consistently out performs TBB on all tested operations.
图 6 。 Intel TBB 与 CUDA 的比较(越小越好)。
  OpenVDB (TBB) NanoVDB (TBB) NanoVDB (CUDA) CUDA Speed-up
LevelSet 148.182  11.554 2.427 5X
FogVolume 243.985 223.195 4.971 44X
Collision 120.324 10.131 12X
表。英特尔 TBB 与 CUDA 之间所有测试的基准计时(毫秒)。
 

结论

NanoVDB 是一个小而强大的库,它通过使用 GPUs 来加速某些 OpenVDB 应用程序。开源存储库现在可用了!要下载源代码、构建示例并体验 GPU – 加速 NanoVDB 可以为稀疏卷工作流提供的强大功能,请参见 纳米 VDB

Tags