ManyObjects

The signed distance to a scene of many objects is the smallest signed distance to any of them. Computed literally, every query visits every object, so the cost grows with the number of objects. This example builds two such scenes, 8,000 spheres on a lattice and a “city” of 3,600 randomly sized boxes, and evaluates each in two ways: a naive loop over every object, and a BVHUnion, which evaluates the same union through a bounding volume hierarchy over the objects’ boxes (see Bounding volume hierarchies). It times both. Its last section runs the unions on a GPU and shows how to time the queries there without timing the memory traffic around them.

The source is at Examples/ManyObjects/main.cpp. See Building for how to compile it with CMake, GNU Make, or a direct compiler invocation.

cd Examples/ManyObjects
./ManyObjects.ex
_images/example_ManyObjects.jpg

Fig. 29 The random city, a BVH union of boxes.

The query

The query is a small functor whose EBGEOMETRY_HOST_DEVICE call operator returns the union’s signed distance at a point. It holds the union by value. A BVHUnion is a descriptor of the objects and the BVH it stored in a Pool, so the copy is cheap, and a kernel can receive it once it describes a pool the device can read.

Building the scenes

A BVHUnion holds one primitive type, here SphereSDF for one scene and BoxSDF for the other. Its constructor takes the pool, the objects and one bounding box per object:

EBGeometry::Pool pool(EBGeometry::hostMemoryResource());

const EBGeometry::BVHUnion<T, Box, K> city(pool, buildings, buildingBoxes);

The union copies the objects into the pool and builds its BVH there, so the input vectors are not needed afterwards. The pool must outlive the union and every copy of it. To combine objects of different types in one union, compose them with the expression builder instead (Basic CSG).

Using the unions on the host

signedDistance(point) gives the same value as the naive minimum over every object: the BVH only skips objects whose boxes show that they cannot be the closest. For each scene the program prints the time per query point of both, and the speedup, which grows with the number of objects. Change spheresPerAxis, lotsPerAxis and numPoints at the top of the file to see this. computeBoundingVolume() gives the box around the whole union, which places the random query points.

Timing the queries on a GPU

The pool holding both unions is frozen, mirrored into GPU::memoryResource() (device memory in a CUDA or HIP build) and each union rebased onto the copy with rebasedView(), as in MeshSDF.

The one-call GPU::transform(query, points, results) takes the points in a host std::vector and writes the results into another. In a GPU build, every call allocates device memory, copies the points in, runs the kernel, copies the results out and frees the memory, so timing it times all of that. To time the queries alone, the example puts the points, and room for the results, on the device first. Both are PODVectors in a pool of their own, mirrored once:

in.reserveFrom(hostPool, n);
out.reserveFrom(hostPool, n);
in.assign(hostPool.base(), points.data(), n);
out.assign(hostPool.base(), zeros.data(), n);
hostPool.freeze();

const Pool devicePool = Pool::mirror(hostPool, GPU::memoryResource());

GPU::transform(query, devicePool, in, out);

A PODVector is an offset and a size, so the pool overload of GPU::transform takes the pool both arrays live in. It checks that the pool is frozen and in memory the device can reach, then reads and writes the arrays where they are, with no allocation and no copies. Mirroring devicePool back to host memory then makes the results readable. (The span overload, over PODSpans from in.bind(devicePool.base()), does the same without the check.)

The program prints both times per point. On a GPU the gap between them is the copying. In a plain C++ build the section runs on the host, where GPU::memoryResource() is host memory standing in for a device and GPU::transform is a loop, so the two times are about the same. In a GPU build the section uses about a million points, so that the time is the kernel’s rather than the launch’s; on the host it keeps numPoints. See Running on a GPU for building for a GPU.

Looking at it

The example ends with a window on the random city. Its buildings rise along \(z\), so it fits the camera with \(z\) up, and the floor goes under them:

const auto camera = Render::Camera<T>::fit(city.computeBoundingVolume(), Vec3(0, 0, 1));

Render::view(city, pool, camera, Render::Settings<T>(), argc, argv);

The window needs SDL2 and is built with make VIEWER=SDL2 or cmake -DEBGEOMETRY_VIEWER=SDL2 (see The viewer window (EBGEOMETRY_VIEWER)); otherwise, and with --no-view or without a display, view() prints why and returns. Drag to orbit and scroll to zoom; the window lists its other keys (see The viewer’s keys).