Skip to content

Commit 86dc181

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. A CI workflow (.github/workflows/teachme.yml) runs the harness whenever the NanoVDB headers or the tutorial change, so API drift is caught automatically. It is compile-only and needs no GPU. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Mark Harris <mharris@nvidia.com>
1 parent 153c7ce commit 86dc181

18 files changed

Lines changed: 3687 additions & 0 deletions

.github/workflows/teachme.yml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
2+
name: TEACHME
3+
4+
# Compile-checks the NanoVDB TEACHME tutorial against the real headers. Runs
5+
# when EITHER the NanoVDB headers or the tutorial change, so an API change that
6+
# breaks a lesson code block is caught here (the whole point of the harness).
7+
# Compile-only: no GPU is required.
8+
on:
9+
push:
10+
branches:
11+
- 'master'
12+
- 'feature/**'
13+
- 'pr/**'
14+
paths:
15+
- 'nanovdb/nanovdb/**'
16+
- 'doc/nanovdb/TEACHME/**'
17+
- '.github/workflows/teachme.yml'
18+
pull_request:
19+
branches:
20+
- '**'
21+
paths:
22+
- 'nanovdb/nanovdb/**'
23+
- 'doc/nanovdb/TEACHME/**'
24+
- '.github/workflows/teachme.yml'
25+
workflow_dispatch:
26+
27+
# Allow subsequent pushes to the same PR or REF to cancel any previous jobs.
28+
concurrency:
29+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
30+
cancel-in-progress: true
31+
32+
jobs:
33+
teachme-docs-compile:
34+
runs-on: ${{ (github.repository_owner == 'AcademySoftwareFoundation' && 'ubuntu-22.04-8c-32g-300h') || 'ubuntu-latest' }}
35+
name: teachme-docs-compile
36+
container:
37+
image: aswf/ci-openvdb:2024-clang17.2
38+
env:
39+
# Compile-only target; matches the arch used by the other NanoVDB jobs.
40+
CUDAARCHS: "80"
41+
# Each TU compiles the kitchen-sink fixture (most of NanoVDB's heavy
42+
# headers), so nvcc is memory-hungry. Cap parallel compiles to stay
43+
# within the runner's RAM — unbounded -j OOMs the 8-core/32 GB runner.
44+
TEACHME_JOBS: "4"
45+
steps:
46+
- uses: actions/checkout@v3
47+
- name: setup_cuda_12
48+
run: |
49+
echo "/usr/local/cuda-12/bin" >> $GITHUB_PATH
50+
echo "LD_LIBRARY_PATH=/usr/local/cuda-12/lib64:$LD_LIBRARY_PATH" >> $GITHUB_ENV
51+
- name: run_harness
52+
# Reference-checks every #include / prose header path, then compiles
53+
# every tagged code block. The OpenVDB-dependent blocks are skipped
54+
# (still reference-checked) when OpenVDB is not installed in the image.
55+
run: bash doc/nanovdb/TEACHME/test/run.sh

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+
}

0 commit comments

Comments
 (0)