Custom ray intersectors with Visionaray

This 2nd installment in a series of tutorials on the ray tracing framework Visionaray explains the reasoning behind custom intersectors and how to program with them.

Ray tracing algorithms typically implement two consecutive phases: intersection testing and shading. During intersection testing, rays are tested for intersection with the scene geometry. If the geometry with the minimum distance between ray origin and intersection position was found, shading is performed to find the color reflected from that position towards the eye, based on material and lighting properties. Scenes like the one depicted below, where billboards and impostors with mask textures are present, are specifically hard to accommodate with this kind of pipeline.

Billboard textures with an alpha mask require special attention with ray tracing algorithms.

This is because during ray intersection, the closest hit point is found by testing rays against the scene geometry. If the closest hit was found, the color at this position is obtained through shading. Herein lies the dilemma: texture maps, like the one the opacity mask is stored in, are first evaluated during shading — but then it is to late, because what we actually wanted was for the intersection testing routine to immediately continue, if the quad with the billboard was intersected, but the mask returned zero opacity. So what we actually want is some kind of feedback loop that interacts with the result from intersection and can carry user-defined logic.

Now you might argue: why not simply build this test into the ray tracing pipeline in general. Well, one could do so… but now consider that the user wanted some different action to be performed on intersection — e.g. not based on a texture lookup, but on uv-coordinates — what would be perfectly reasonable. And then, intersection testing in contemporary ray tracers is the predominant part of most ray tracing algorithms, and having an unnecessary runtime overhead in general, for scenes that don’t have the above demands, would imply a (potentially severe) performance overhead that affects every user.

What we came up with in Visionaray is custom intersectors —classes that overwrite the default behavior of the intersect(ray, primitive) intrinsics. Those intrinsics are template functions with overloads e.g. for testing rays against triangles or spheres. A typical intersect() function may look as follows:

// intersect intrinsic declaration
// those may be overwritten with custom intersectors
template <typename R, typename S>
hit_record<R, primitive<unsigned>> intersect(
R const& ray,
basic_triangle<3, S> const& tri
);

The default intersector that is used by the library simply calls the standard intersect()) routines that are chosen using C++ overload resolution. This is cheap because it may be inlined and thus completely eliminated by an optimizing compiler.

If the user however desires a custom operation to be performed during intersection, she can write an intersector specialization that intercepts the default behavior. Therefore, we derive from basic_intersector as follows:

struct mask_intersector : basic_intersector<mask_intersector>
{
using basic_intersector<mask_intersector>::operator();

Also note how we immediately pull in basic_intersector’s operator(), so that by default the standard intersect() handlers are called.

Then we implement operator() with the signature: ray-type, primitive-type, i.e. with the same signature that builtin and custom intersect() methods have. In our case, we desire a custom intersector for any ray type, and for any 3-D triangle type. Therefore we declare the following template inside the custom intersector class:

    template <typename R, typename S>
auto operator()(
R const& ray,
basic_triangle<3, S> const& tri
)
-> decltype( intersect(ray, tri) )
{
// call default intersect()
// returns a hit record
auto hr = intersect(ray, tri);
        // do things with hr, e.g. masking
// ...
        // manipulate the hit record
hr.hit &= ...;
        // after manipulation, return
return hr;
}
}; // mask_intersector

Note how we determine the return type of operator() by using C++-11 decltype() — operator() is an interceptor for the intersect(ray, triangle) function template from above, and thus mask_intersector’s operator() takes the same parameter and returns the same type. We can write similar interceptors that are called whenever a ray is tested for intersection with any other primitive.

Now, if we want to pass the custom intersector to our rendering algorithm, we have several options. The easiest option is to pass the custom intersector to one of the default kernels, by creating a scheduler with parameters that exchange the default intersector for the custom one:

mask_intersector intersector;
auto sparams = make_sched_params</*...*/>(
camera,
render_target,
intersector
);
// call frame() with a default kernel
sched.frame(kernel, sparams);

In that case, whenever the default kernel performs an intersection test, the custom intersector will intercept execution if there is an operator() for the ray / primitive type combination.

Custom intersectors can however also easily be incorporated into user-written kernels. In order for that to work, the traversal intrinsics (any_hit() and closest_hit()) also have overloads that accept a custom intersector:

using S = scalar_type;
mask_intersector intersector;
sched.frame([&](R ray) -> result_record<S>
{
// ray traversal code
// ...
    // now call closest_hit() with the intersector
auto hr = closest_hit(
ray,
prim_first,
prim_last,
intersector
);
    // and traverse on
// ...
}, sparams);

A complete and runnable source code example that demonstrates a user-written kernel with a custom interceptor can be found here: https://github.com/szellmann/visionaray/tree/master/src/examples/intersector

Applying these concepts to our initial scene, we get the result that can be seen in the picture below, which behaves just as we expected — transparent parts of the quads no longer obscure the geometry from behind.

The billboards from above handled correctly with Visionaray custom intersectors.

Note that composite intersection tests can also be intercepted quite flexibly. Ray / bounding volume hierarchy (BVH) intersections e.g. involve testing the ray against axis-aligned bounding boxes, and against the primitives that the hierarchy contains. A custom intersector for BVHs thus may have three different flavors: custom intersection routines for rays vs. boxes, for rays vs. primitives (triangles, spheres, …), and for rays vs. the BVH itself. With that, it is e.g. easy to write a debug kernel that determines how many boxes and triangles a packet of rays interacted with during ray / BVH traversal:

Debug kernel that visualizes BVH / ray packet traversal costs using a heat map.

Examples of custom intersectors can also be found in the source code of the Visionaray OpenCOVER plugin, which was used generate the screenshots for this tutorial.

Summary

Custom intersectors are great to intercept the default behavior implemented by the intersect(ray, primitive) functions. Custom intersectors inherit from basic_intersector and reimplement operator() with the very signature the intersect() function to overwrite has. Just like intersect(), operator() returns a hit record, which can however be manipulated before returning to the kernel. Custom intersectors may also have internal state, which can e.g. be used to implement debug kernels that monitor traversal costs.

Links