Skip to content

Commit be3d2b9

Browse files
harrismclaude
andcommitted
Add NanoVDB TEACHME interactive tutorial + docs-compile test harness
doc/nanovdb/TEACHME/ is an interactive, LLM-guided tutorial for the NanoVDB user API: 10 modules (reading grids, ReadAccessor, math/HDDA sampling, GPU kernels, GPU grid builders, topology operators + data re-homing via Injection, IndexGrid/VoxelBlockManager, OpenVDB conversion), a cheat sheet, and a GPU level-set ray-march capstone. To keep the tutorial from drifting from the API, the test harness in doc/nanovdb/TEACHME/test/ extracts every C++/CUDA code block from the docs and compiles it against the real NanoVDB headers, and validates that every #include and prose header reference resolves. ~64 blocks compile green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Mark Harris <mharris@nvidia.com>
1 parent 153c7ce commit be3d2b9

17 files changed

Lines changed: 3630 additions & 0 deletions

doc/nanovdb/TEACHME/README.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# TEACHME
2+
3+
This directory contains interactive lesson documents designed to be loaded by
4+
an LLM coding agent (Claude Code, Cursor, or similar) to teach a user NanoVDB
5+
interactively.
6+
7+
The lesson teaches how to *use* NanoVDB to read, build, transform, and render
8+
sparse volumetric data on CPU and GPU. It is a single self-contained markdown
9+
file the agent reads at the start of a session.
10+
11+
## Getting started
12+
13+
The easiest way to use this lesson is with [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
14+
(CLI or IDE extension) from the root of an OpenVDB checkout. The agent can
15+
read the lesson files *and* the NanoVDB source code, so it can verify API
16+
details and help you debug exercises in real time.
17+
18+
**Example prompts:**
19+
20+
To learn how to use NanoVDB:
21+
22+
```
23+
Read doc/nanovdb/TEACHME/nanovdb_user_lesson.md and teach me how to use NanoVDB.
24+
```
25+
26+
You can also give the agent context about your background so it can tailor
27+
the lesson:
28+
29+
```
30+
Read doc/nanovdb/TEACHME/nanovdb_user_lesson.md and teach me NanoVDB. I have
31+
C++ and CUDA experience but I've never used OpenVDB or any sparse 3D data
32+
structure before.
33+
```
34+
35+
Keep the matching cheatsheet (`*_cheatsheet.md`) open in your editor while
36+
working through exercises — it's a quick reference for the APIs and
37+
invariants covered in the lesson.
38+
39+
## How it works
40+
41+
The lesson is a self-contained markdown file that serves as both a
42+
curriculum and an instructor prompt. The LLM acts as an interactive
43+
instructor: teaching concepts module by module, quizzing the student, and
44+
adapting to their responses.
45+
46+
The lesson includes:
47+
48+
- Teacher instructions (persona, pacing, scope)
49+
- Module-by-module curriculum with embedded concepts and code examples
50+
- Quiz questions and an answer key
51+
- Exercises with progressive difficulty
52+
- A capstone project
53+
- A reference table at the end
54+
55+
## Available lessons
56+
57+
| Lesson | Cheat sheet | Covers |
58+
|---|---|---|
59+
| [nanovdb_user_lesson.md](nanovdb_user_lesson.md) | [nanovdb_user_cheatsheet.md](nanovdb_user_cheatsheet.md) | Reading `.nvdb` files, `ReadAccessor`, `NodeManager`, math + sampling, HDDA ray-march, GPU kernels with `Grid`, GPU topology builders (`PointsToGrid`, `MeshToGrid`, `DilateGrid`, etc.), `IndexGrid` + `VoxelBlockManager`, OpenVDB ↔ NanoVDB conversion |
60+
61+
## Not covered
62+
63+
- The OpenVDB side (the dynamic, mutable tree used at film/sim time) — this
64+
lesson treats OpenVDB only at the conversion boundary.
65+
- Houdini / Maya integration — outside the scope.
66+
- Gaussian splatting and fVDB-specific topics — see the fVDB repo's own
67+
TEACHME at <https://github.com/openvdb/fvdb-core/tree/main/docs/TEACHME>.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Build + editor + render artifacts (not source)
2+
build/
3+
compile_commands.json
4+
.clangd
5+
*.ppm
6+
sphere.png
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
cmake_minimum_required(VERSION 3.20)
2+
project(nanovdb_capstone LANGUAGES CXX CUDA)
3+
4+
set(CMAKE_CXX_STANDARD 17)
5+
set(CMAKE_CUDA_STANDARD 17)
6+
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # emit build/compile_commands.json
7+
if(NOT DEFINED CMAKE_CUDA_ARCHITECTURES)
8+
set(CMAKE_CUDA_ARCHITECTURES 89) # RTX 6000 Ada; override with -DCMAKE_CUDA_ARCHITECTURES=...
9+
endif()
10+
11+
# NanoVDB headers pull in CUDA lambdas / relaxed-constexpr paths.
12+
string(APPEND CMAKE_CUDA_FLAGS " --extended-lambda --expt-relaxed-constexpr")
13+
14+
# This file is at doc/nanovdb/TEACHME/capstone/CMakeLists.txt; the NanoVDB
15+
# include root (the dir containing the nanovdb/ header subdir) is the repo's
16+
# `nanovdb/` subdir — repo root is four dirs up.
17+
get_filename_component(_teachme "${CMAKE_CURRENT_SOURCE_DIR}" DIRECTORY) # TEACHME
18+
get_filename_component(_docnv "${_teachme}" DIRECTORY) # doc/nanovdb
19+
get_filename_component(_doc "${_docnv}" DIRECTORY) # doc
20+
get_filename_component(REPO_ROOT "${_doc}" DIRECTORY) # repo root
21+
set(NANOVDB_INCLUDE_ROOT "${REPO_ROOT}/nanovdb")
22+
message(STATUS "NanoVDB include root: ${NANOVDB_INCLUDE_ROOT}")
23+
24+
add_executable(raymarch raymarch.cu)
25+
target_include_directories(raymarch PRIVATE "${NANOVDB_INCLUDE_ROOT}")
26+
# Binary lands in the build dir (conventional). raymarch writes sphere.ppm to
27+
# the current working directory, so run it from wherever you want the output.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/usr/bin/env python3
2+
"""Convert a binary P6 PPM to PNG using only the Python standard library
3+
(the container has no ImageMagick / PIL). Usage: python3 ppm2png.py in.ppm out.png"""
4+
import sys, struct, zlib, binascii
5+
6+
def main(src, dst):
7+
data = open(src, "rb").read()
8+
i = 0
9+
def tok(i):
10+
n = len(data)
11+
while i < n and data[i] in b" \t\n\r": i += 1
12+
s = i
13+
while i < n and data[i] not in b" \t\n\r": i += 1
14+
if s == i: raise ValueError("truncated or non-binary PPM")
15+
return data[s:i], i
16+
magic, i = tok(i)
17+
assert magic == b"P6", "not a binary PPM"
18+
w, i = tok(i); h, i = tok(i); mx, i = tok(i)
19+
i += 1 # single whitespace after maxval
20+
W, H = int(w), int(h)
21+
px = data[i:i + W * H * 3]
22+
23+
def chunk(typ, payload):
24+
return (struct.pack(">I", len(payload)) + typ + payload +
25+
struct.pack(">I", binascii.crc32(typ + payload) & 0xffffffff))
26+
27+
raw = bytearray()
28+
for y in range(H):
29+
raw.append(0) # filter: none
30+
raw += px[y * W * 3:(y + 1) * W * 3]
31+
png = (b"\x89PNG\r\n\x1a\n"
32+
+ chunk(b"IHDR", struct.pack(">IIBBBBB", W, H, 8, 2, 0, 0, 0))
33+
+ chunk(b"IDAT", zlib.compress(bytes(raw), 9))
34+
+ chunk(b"IEND", b""))
35+
open(dst, "wb").write(png)
36+
print(f"wrote {dst} ({W}x{H})")
37+
38+
if __name__ == "__main__":
39+
main(sys.argv[1], sys.argv[2])
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Capstone scaffold: GPU level-set ray-march renderer.
2+
//
3+
// The host plumbing is DONE (build SDF, upload to device, launch, read back,
4+
// write PPM) and the light direction + ambient level are handed to the kernel
5+
// as parameters. Your job is the kernel body — the per-pixel ray-march + shade.
6+
// As shipped it compiles and runs but only writes a placeholder gradient.
7+
//
8+
// Build: cmake -S . -B build && cmake --build build
9+
// Run: ./build/raymarch [ambient] [lx ly lz] (writes sphere.ppm)
10+
// View: python3 ppm2png.py sphere.ppm sphere.png (then open sphere.png)
11+
#include <nanovdb/NanoVDB.h>
12+
#include <nanovdb/tools/CreatePrimitives.h>
13+
#include <nanovdb/cuda/DeviceBuffer.h>
14+
#include <nanovdb/cuda/GridHandle.cuh>
15+
#include <nanovdb/math/Ray.h>
16+
#include <nanovdb/math/HDDA.h> // nanovdb::math::ZeroCrossing
17+
#include <nanovdb/math/Stencils.h> // nanovdb::math::GradStencil
18+
19+
#include <cstdio>
20+
#include <cstdlib>
21+
#include <cmath>
22+
#include <vector>
23+
24+
using GridT = nanovdb::NanoGrid<float>;
25+
26+
__global__ void render(const GridT* grid, unsigned char* img, int W, int H,
27+
nanovdb::Vec3f lightDir, float ambient)
28+
{
29+
const int x = blockIdx.x * blockDim.x + threadIdx.x;
30+
const int y = blockIdx.y * blockDim.y + threadIdx.y;
31+
if (x >= W || y >= H) return;
32+
const int pid = (y * W + x) * 3;
33+
34+
// ===================== YOUR WORK STARTS HERE =====================
35+
// Placeholder so it compiles & runs: a horizontal grey gradient.
36+
unsigned char shade = (unsigned char)(255.f * x / W);
37+
38+
// The aesthetic inputs are given to you as parameters: `lightDir` and
39+
// `ambient` (set in main, overridable on the command line). You write the
40+
// ray-march and the shading math. Signatures are in the cheat sheet.
41+
//
42+
// 1. Camera ray (WORLD space). Pinhole: an eye point and, per pixel, a
43+
// direction through the image plane. You decide the math — remember
44+
// aspect ratio and that image y grows downward. Wrap it in a Ray.
45+
//
46+
// 2. The SDF lives in INDEX space, so convert your world ray to index
47+
// space before you trace it. (Ray has a method for this.)
48+
//
49+
// 3. Find where the ray first crosses the surface (the SDF sign change).
50+
// Module 5 gave you a helper in math/HDDA.h that drives an HDDA and
51+
// reports the hit voxel, the value, and the ray parameter.
52+
//
53+
// 4. On a hit, the surface normal is the (normalized) SDF gradient at the
54+
// hit voxel. Module 5's gradient stencil computes it. (Watch its
55+
// template parameter.)
56+
//
57+
// 5. Shade: a Lambertian intensity from the normal and the provided
58+
// `lightDir`, lifted off the floor by `ambient`; write it as greyscale.
59+
// On a MISS, leave the background.
60+
// ====================== YOUR WORK ENDS HERE ======================
61+
62+
img[pid] = shade; img[pid + 1] = shade; img[pid + 2] = shade;
63+
}
64+
65+
int main(int argc, char** argv)
66+
{
67+
// Shading parameters passed into the kernel. Defaults, overridable on the
68+
// command line: ./raymarch [ambient] [lx ly lz]
69+
float ambient = 0.15f;
70+
nanovdb::Vec3f lightDir(-0.577f, 0.577f, -0.577f); // (-1,1,-1) normalized
71+
if (argc >= 2) ambient = (float)std::atof(argv[1]);
72+
if (argc >= 5) lightDir = nanovdb::Vec3f((float)std::atof(argv[2]),
73+
(float)std::atof(argv[3]),
74+
(float)std::atof(argv[4]));
75+
lightDir.normalize();
76+
77+
// Build a level-set sphere SDF on the host (radius 100, voxel size 1).
78+
auto handle = nanovdb::tools::createLevelSetSphere<float>(
79+
/*radius=*/100.0, /*center=*/nanovdb::Vec3d(0.0),
80+
/*voxelSize=*/1.0, /*halfWidth=*/3.0);
81+
82+
// Move to the device. copy<DeviceBuffer>() fills the HOST side of the dual
83+
// buffer; deviceUpload() pushes it to the GPU — skip it and deviceGrid()
84+
// returns nullptr.
85+
auto devHandle = handle.copy<nanovdb::cuda::DeviceBuffer>();
86+
devHandle.deviceUpload();
87+
const GridT* dGrid = devHandle.deviceGrid<float>();
88+
if (!dGrid) { std::printf("no device grid\n"); return 1; }
89+
90+
const int W = 512, H = 512;
91+
unsigned char* dImg = nullptr;
92+
cudaMalloc(&dImg, size_t(W) * H * 3);
93+
const dim3 block(16, 16), gridDim((W + 15) / 16, (H + 15) / 16);
94+
render<<<gridDim, block>>>(dGrid, dImg, W, H, lightDir, ambient);
95+
cudaDeviceSynchronize();
96+
if (auto e = cudaGetLastError(); e != cudaSuccess) {
97+
std::printf("CUDA error: %s\n", cudaGetErrorString(e));
98+
return 1;
99+
}
100+
101+
std::vector<unsigned char> img(size_t(W) * H * 3);
102+
cudaMemcpy(img.data(), dImg, img.size(), cudaMemcpyDeviceToHost);
103+
cudaFree(dImg);
104+
105+
FILE* f = std::fopen("sphere.ppm", "wb");
106+
std::fprintf(f, "P6\n%d %d\n255\n", W, H);
107+
std::fwrite(img.data(), 1, img.size(), f);
108+
std::fclose(f);
109+
std::printf("wrote sphere.ppm (%dx%d)\n", W, H);
110+
return 0;
111+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Capstone: GPU level-set ray-march renderer.
2+
// Build an SDF sphere -> move to device -> one thread per pixel ->
3+
// ZeroCrossing to find the surface -> Lambertian shade via SDF gradient
4+
// normal -> write PPM.
5+
#include <nanovdb/NanoVDB.h>
6+
#include <nanovdb/tools/CreatePrimitives.h>
7+
#include <nanovdb/cuda/DeviceBuffer.h>
8+
#include <nanovdb/cuda/GridHandle.cuh>
9+
#include <nanovdb/math/Ray.h>
10+
#include <nanovdb/math/HDDA.h> // ZeroCrossing lives here
11+
#include <nanovdb/math/Stencils.h> // GradStencil
12+
13+
#include <cstdio>
14+
#include <cstdlib>
15+
#include <cmath>
16+
#include <vector>
17+
18+
using GridT = nanovdb::NanoGrid<float>;
19+
20+
__global__ void render(const GridT* grid, unsigned char* img, int W, int H,
21+
nanovdb::Vec3f lightDir, float ambient)
22+
{
23+
const int x = blockIdx.x * blockDim.x + threadIdx.x;
24+
const int y = blockIdx.y * blockDim.y + threadIdx.y;
25+
if (x >= W || y >= H) return;
26+
const int pid = (y * W + x) * 3;
27+
28+
// --- pinhole camera in WORLD space ---
29+
const nanovdb::Vec3f eye(0.f, 0.f, -300.f);
30+
const float aspect = float(W) / float(H);
31+
const float tanHalfFov = tanf(0.5f * 45.f * 3.14159265f / 180.f);
32+
const float u = (2.f * ((x + 0.5f) / W) - 1.f) * tanHalfFov * aspect;
33+
const float v = (1.f - 2.f * ((y + 0.5f) / H)) * tanHalfFov; // flip y
34+
nanovdb::Vec3f dir(u, v, 1.f);
35+
dir.normalize();
36+
37+
nanovdb::math::Ray<float> wRay(eye, dir);
38+
auto iRay = wRay.worldToIndexF(*grid); // ray in index space
39+
40+
auto acc = grid->getAccessor();
41+
nanovdb::Coord ijk;
42+
float t = 0.f, val = 0.f;
43+
44+
unsigned char shade = 0; // background (black)
45+
if (nanovdb::math::ZeroCrossing(iRay, acc, ijk, val, t)) {
46+
nanovdb::math::GradStencil<GridT> stencil(*grid);
47+
stencil.moveTo(ijk);
48+
nanovdb::Vec3f n = stencil.gradient(); // SDF gradient = outward normal
49+
n.normalize();
50+
const float diff = fmaxf(0.f, n.dot(lightDir));
51+
const float I = fminf(1.f, ambient + (1.f - ambient) * diff);
52+
shade = (unsigned char)(255.f * I); // greyscale
53+
}
54+
img[pid] = shade; img[pid + 1] = shade; img[pid + 2] = shade;
55+
}
56+
57+
int main(int argc, char** argv)
58+
{
59+
// Shading parameters (passed into the kernel). Defaults, overridable on the
60+
// command line: ./raymarch [ambient] [lx ly lz]
61+
float ambient = 0.15f;
62+
nanovdb::Vec3f lightDir(-0.577f, 0.577f, -0.577f); // (-1,1,-1) normalized
63+
if (argc >= 2) ambient = (float)std::atof(argv[1]);
64+
if (argc >= 5) lightDir = nanovdb::Vec3f((float)std::atof(argv[2]),
65+
(float)std::atof(argv[3]),
66+
(float)std::atof(argv[4]));
67+
lightDir.normalize();
68+
69+
// 1. Build a level-set sphere SDF on the host (radius 100, voxel size 1).
70+
auto handle = nanovdb::tools::createLevelSetSphere<float>(
71+
/*radius=*/100.0, /*center=*/nanovdb::Vec3d(0.0),
72+
/*voxelSize=*/1.0, /*halfWidth=*/3.0);
73+
74+
// 2. Move it to the device. copy<DeviceBuffer>() fills the HOST side of the
75+
// dual buffer; deviceUpload() pushes the bytes to the device. Only then
76+
// is deviceGrid() non-null.
77+
auto devHandle = handle.copy<nanovdb::cuda::DeviceBuffer>();
78+
devHandle.deviceUpload();
79+
const GridT* dGrid = devHandle.deviceGrid<float>();
80+
if (!dGrid) { std::printf("no device grid\n"); return 1; }
81+
82+
// 3. Render.
83+
const int W = 512, H = 512;
84+
unsigned char* dImg = nullptr;
85+
cudaMalloc(&dImg, size_t(W) * H * 3);
86+
const dim3 block(16, 16), grid((W + 15) / 16, (H + 15) / 16);
87+
render<<<grid, block>>>(dGrid, dImg, W, H, lightDir, ambient);
88+
cudaDeviceSynchronize();
89+
if (auto e = cudaGetLastError(); e != cudaSuccess) {
90+
std::printf("CUDA error: %s\n", cudaGetErrorString(e));
91+
return 1;
92+
}
93+
94+
// 4. Copy back and write PPM.
95+
std::vector<unsigned char> img(size_t(W) * H * 3);
96+
cudaMemcpy(img.data(), dImg, img.size(), cudaMemcpyDeviceToHost);
97+
cudaFree(dImg);
98+
99+
FILE* f = std::fopen("sphere.ppm", "wb");
100+
std::fprintf(f, "P6\n%d %d\n255\n", W, H);
101+
std::fwrite(img.data(), 1, img.size(), f);
102+
std::fclose(f);
103+
std::printf("wrote sphere.ppm (%dx%d)\n", W, H);
104+
return 0;
105+
}
27.6 KB
Loading

0 commit comments

Comments
 (0)