7.1 Primitive Interface and Geometric Primitives

The Primitive class defines the Primitive interface. It and the Primitive implementations that are described in this section are defined in the files cpu/primitive.h and cpu/primitive.cpp.

<<Primitive Definition>>= 
class Primitive : public TaggedPointer<SimplePrimitive, GeometricPrimitive, TransformedPrimitive, AnimatedPrimitive, BVHAggregate, KdTreeAggregate> { public: <<Primitive Interface>> 
using TaggedPointer::TaggedPointer; Bounds3f Bounds() const; pstd::optional<ShapeIntersection> Intersect(const Ray &r, Float tMax = Infinity) const; bool IntersectP(const Ray &r, Float tMax = Infinity) const;
};

The Primitive interface is composed of only three methods, each of which corresponds to a Shape method. The first, Bounds(), returns a bounding box that encloses the primitive’s geometry in rendering space. There are many uses for such a bound; one of the most important is to place the Primitive in the acceleration data structures.

<<Primitive Interface>>= 
Bounds3f Bounds() const;

The other two methods provide the two types of ray intersection tests.

<<Primitive Interface>>+= 
pstd::optional<ShapeIntersection> Intersect(const Ray &r, Float tMax = Infinity) const; bool IntersectP(const Ray &r, Float tMax = Infinity) const;

Upon finding an intersection, a Primitive’s Intersect() method is also responsible for initializing a few member variables in the SurfaceInteraction in the ShapeIntersection that it returns. The first two are representations of the shape’s material and its emissive properties, if it is itself an emitter. For convenience, SurfaceInteraction provides a method to set these, which reduces the risk of inadvertently not setting all of them. The second two are related to medium scattering properties and the fragment that initializes them will be described later, in Section 11.4.

<<SurfaceInteraction Public Methods>>+= 
void SetIntersectionProperties(Material mtl, Light area, const MediumInterface *primMediumInterface, Medium rayMedium) { material = mtl; areaLight = area; <<Set medium properties at surface intersection>> 
if (primMediumInterface && primMediumInterface->IsMediumTransition()) mediumInterface = primMediumInterface; else medium = rayMedium;
}

<<SurfaceInteraction Public Members>>+=  
Material material; Light areaLight;

7.1.1 Geometric Primitives

The GeometricPrimitive class provides a basic implementation of the Primitive interface that stores a variety of properties that may be associated with a shape.

<<GeometricPrimitive Definition>>= 
class GeometricPrimitive { public: <<GeometricPrimitive Public Methods>> 
GeometricPrimitive(Shape shape, Material material, Light areaLight, const MediumInterface &mediumInterface, FloatTexture alpha = nullptr); Bounds3f Bounds() const; pstd::optional<ShapeIntersection> Intersect(const Ray &r, Float tMax) const; bool IntersectP(const Ray &r, Float tMax) const;
private: <<GeometricPrimitive Private Members>> 
Shape shape; Material material; Light areaLight; MediumInterface mediumInterface; FloatTexture alpha;
};

Each GeometricPrimitive holds a Shape with a description of its appearance properties, including its material, its emissive properties if it is a light source, the participating media on each side of its surface, and an optional alpha texture, which can be used to make some parts of a shape’s surface disappear.

<<GeometricPrimitive Private Members>>= 
Shape shape; Material material; Light areaLight; MediumInterface mediumInterface; FloatTexture alpha;

The GeometricPrimitive constructor initializes these variables from the parameters passed to it. It is straightforward, so we do not include it here.

Most of the methods of the Primitive interface start out with a call to the corresponding Shape method. For example, its Bounds() method directly returns the bounds from the Shape.

<<GeometricPrimitive Method Definitions>>= 
Bounds3f GeometricPrimitive::Bounds() const { return shape.Bounds(); }

GeometricPrimitive::Intersect() calls the Intersect() method of its Shape to do the actual intersection test and to initialize a ShapeIntersection to describe the intersection, if any. If an intersection is found, then additional processing specific to the GeometricPrimitive is performed.

<<GeometricPrimitive Method Definitions>>+=  
pstd::optional<ShapeIntersection> GeometricPrimitive::Intersect(const Ray &r, Float tMax) const { pstd::optional<ShapeIntersection> si = shape.Intersect(r, tMax); if (!si) return {}; <<Test intersection against alpha texture, if present>> 
if (alpha) { if (Float a = alpha.Evaluate(si->intr); a < 1) { <<Possibly ignore intersection based on stochastic alpha test>> 
Float u = (a <= 0) ? 1.f : HashFloat(r.o, r.d); if (u > a) { <<Ignore this intersection and trace a new ray>> 
Ray rNext = si->intr.SpawnRay(r.d); pstd::optional<ShapeIntersection> siNext = Intersect(rNext, tMax - si->tHit); if (siNext) siNext->tHit += si->tHit; return siNext;
}
} }
<<Initialize SurfaceInteraction after Shape intersection>>  return si; }

If an alpha texture is associated with the shape, then the intersection point is tested against the alpha texture before a successful intersection is reported. (The definition of the texture interface and a number of implementations are in Chapter 10.) The alpha texture can be thought of as a scalar function over the shape’s surface that indicates whether the surface is actually present at each point. An alpha value of 0 indicates that it is not, and 1 that it is. Alpha textures are useful for representing objects like leaves: a leaf might be modeled as a single triangle or bilinear patch, with an alpha texture cutting out the edges so that a detailed outline of a leaf remains.

<<Test intersection against alpha texture, if present>>= 
if (alpha) { if (Float a = alpha.Evaluate(si->intr); a < 1) { <<Possibly ignore intersection based on stochastic alpha test>> 
Float u = (a <= 0) ? 1.f : HashFloat(r.o, r.d); if (u > a) { <<Ignore this intersection and trace a new ray>> 
Ray rNext = si->intr.SpawnRay(r.d); pstd::optional<ShapeIntersection> siNext = Intersect(rNext, tMax - si->tHit); if (siNext) siNext->tHit += si->tHit; return siNext;
}
} }

If the alpha texture has a value of 0 or 1 at the intersection point, then it is easy to decide whether or not the intersection reported by the shape is valid. For intermediate alpha values, the correct answer is less clear.

One possibility would be to use a fixed threshold—for example, accepting all intersections with an alpha of 1 and ignoring them otherwise. However, this approach leads to hard transitions at the resulting boundary. Another option would be to return the alpha from the intersection method and leave calling code to handle it, effectively treating the surface as partially transparent at such points. However, that approach would not only make the Primitive intersection interfaces more complex, but it would place a new burden on integrators, requiring them to compute the shading at such intersection points as well as to trace an additional ray to find what was visible behind them.

A stochastic alpha test addresses these issues. With it, intersections with the shape are randomly reported with probability proportional to the value of the alpha texture. This approach is easy to implement, gives the expected results for an alpha of 0 or 1, and with a sufficient number of samples gives a better result than using a fixed threshold. Figure 7.1 compares the approaches.

Figure 7.1: Comparison of Stochastic Alpha Testing to Using a Fixed Threshold. (a) Example scene: the two fir branches are modeled using a single quadrilateral with an alpha texture. (b) If a fixed threshold is used for the alpha test, the shape is not faithfully reproduced. Here a threshold of 1 was used, leading to shrinkage and jagged edges. (c) If a stochastic alpha test is used, the result is a smoother and more realistic transition.

One challenge in performing the stochastic alpha test is generating a uniform random number to apply it. For a given ray and shape, we would like this number to be the same across multiple runs of the system; doing so is a part of making the set of computations performed by pbrt be deterministic, which is a great help for debugging. If a different random number was used on different runs of the system, then we might hit a runtime error on some runs but not others. However, it is important that different random numbers be used for different rays; otherwise, the approach could devolve into the same as using a fixed threshold.

The HashFloat() utility function provides a solution to this problem. Here it is used to compute a random floating-point value between 0 and 1 for the alpha test; this value is determined by the ray’s origin and direction.

<<Possibly ignore intersection based on stochastic alpha test>>= 
Float u = (a <= 0) ? 1.f : HashFloat(r.o, r.d); if (u > a) { <<Ignore this intersection and trace a new ray>> 
Ray rNext = si->intr.SpawnRay(r.d); pstd::optional<ShapeIntersection> siNext = Intersect(rNext, tMax - si->tHit); if (siNext) siNext->tHit += si->tHit; return siNext;
}

If the alpha test indicates that the intersection should be ignored, then another intersection test is performed with the current GeometricPrimitive, with a recursive call to Intersect(). This additional test is important for shapes like spheres, where we may reject the closest intersection but then intersect the shape again further along the ray. This recursive call requires adjustment of the tMax value passed to it to account for the distance along the ray to the initial alpha tested intersection point. Then, if it reports an intersection, the reported tHit value should account for that segment as well.

<<Ignore this intersection and trace a new ray>>= 
Ray rNext = si->intr.SpawnRay(r.d); pstd::optional<ShapeIntersection> siNext = Intersect(rNext, tMax - si->tHit); if (siNext) siNext->tHit += si->tHit; return siNext;

Given a valid intersection, the GeometricPrimitive can go ahead and finalize the SurfaceInteraction’s representation of the intersection.

<<Initialize SurfaceInteraction after Shape intersection>>= 

The IntersectP() method must also handle the case of the GeometricPrimitive having an alpha texture associated with it. In that case, it may be necessary to consider all the intersections of the ray with the shape in order to determine if there is a valid intersection. Because IntersectP() implementations in shapes return early when they find any intersection and because they do not return the geometric information associated with an intersection, a full intersection test is performed in this case. In the more common case of no alpha texture, Shape::IntersectP() can be called directly.

<<GeometricPrimitive Method Definitions>>+= 
bool GeometricPrimitive::IntersectP(const Ray &r, Float tMax) const { if (alpha) return Intersect(r, tMax).has_value(); else return shape.IntersectP(r, tMax); }

Most objects in a scene are neither emissive nor have alpha textures. Further, only a few of them typically represent the boundary between two different types of participating media. It is wasteful to store nullptr values for the corresponding member variables of GeometricPrimitive in that common case. Therefore, pbrt also provides SimplePrimitive, which also implements the Primitive interface but does not store those values. The code that converts the parsed scene representation into the scene for rendering uses a SimplePrimitive in place of a GeometricPrimitive when it is possible to do so.

<<SimplePrimitive Definition>>= 
class SimplePrimitive { public: <<SimplePrimitive Public Methods>> 
Bounds3f Bounds() const; pstd::optional<ShapeIntersection> Intersect(const Ray &r, Float tMax) const; bool IntersectP(const Ray &r, Float tMax) const; SimplePrimitive(Shape shape, Material material);
private: <<SimplePrimitive Private Members>> 
Shape shape; Material material;
};

Because SimplePrimitive only stores a shape and a material, it saves 32 bytes of memory. For scenes with millions of primitives, the overall savings can be meaningful.

<<SimplePrimitive Private Members>>= 
Shape shape; Material material;

We will not include the remainder of the SimplePrimitive implementation here; it is effectively a simplified subset of GeometricPrimitive’s.

7.1.2 Object Instancing and Primitives in Motion

Figure 7.2: This outdoor scene makes heavy use of instancing as a mechanism for compressing the scene’s description. There are only 24 million unique triangles in the scene, although, thanks to object reuse through instancing, the total geometric complexity is 3.1 billion triangles. (Scene courtesy of Laubwerk.)

Object instancing is a classic technique in rendering that reuses transformed copies of a single collection of geometry at multiple positions in a scene. For example, in a model of a concert hall with thousands of identical seats, the scene description can be compressed substantially if all the seats refer to a shared geometric representation of a single seat. The ecosystem scene in Figure 7.2 has 23,241 individual plants of various types, although only 31 unique plant models. Because each plant model is instanced multiple times with a different transformation for each instance, the complete scene has a total of 3.1 billion triangles. However, only 24 million triangles are stored in memory thanks to primitive reuse through object instancing. pbrt uses just over 4 GB of memory when rendering this scene with object instancing (1.7 GB for BVHs, 707 MB for Primitives, 877 MB for triangle meshes, and 846 MB for texture images), but would need upward of 516 GB to render it without instancing.

The TransformedPrimitive implementation of the Primitive interface makes object instancing possible in pbrt. Rather than holding a shape, it stores a single Primitive as well as a Transform that is injected in between the underlying primitive and its representation in the scene. This extra transformation enables object instancing.

Recall that the Shapes of Chapter 6 themselves had rendering from object space transformations applied to them to place them in the scene. If a shape is held by a TransformedPrimitive, then the shape’s notion of rendering space is not the actual scene rendering space—only after the TransformedPrimitive’s transformation is also applied is the shape actually in rendering space. For this application here, it makes sense for the shape to not be at all aware of the additional transformation being applied. For instanced primitives, letting Shapes know all the instance transforms is of limited utility: we would not want the TriangleMesh to make a copy of its vertex positions for each instance transformation and transform them all the way to rendering space, since this would negate the memory savings of object instancing.

<<TransformedPrimitive Definition>>= 
class TransformedPrimitive { public: <<TransformedPrimitive Public Methods>> 
TransformedPrimitive(Primitive primitive, const Transform *renderFromPrimitive) : primitive(primitive), renderFromPrimitive(renderFromPrimitive) { } pstd::optional<ShapeIntersection> Intersect(const Ray &r, Float tMax) const; bool IntersectP(const Ray &r, Float tMax) const; Bounds3f Bounds() const { return (*renderFromPrimitive)(primitive.Bounds()); }
private: <<TransformedPrimitive Private Members>> 
Primitive primitive; const Transform *renderFromPrimitive;
};

The TransformedPrimitive constructor takes a Primitive that represents the model and the transformation that places it in the scene. If the instanced geometry is described by multiple Primitives, the calling code is responsible for placing them in an aggregate so that only a single Primitive needs to be stored here.

<<TransformedPrimitive Public Methods>>= 
TransformedPrimitive(Primitive primitive, const Transform *renderFromPrimitive) : primitive(primitive), renderFromPrimitive(renderFromPrimitive) { }

<<TransformedPrimitive Private Members>>= 
Primitive primitive; const Transform *renderFromPrimitive;

The key task of TransformedPrimitive is to bridge between the Primitive interface that it implements and the Primitive that it holds, accounting for the effects of the rendering from primitive space transformation. If the primitive member has its own transformation, that should be interpreted as the transformation from object space to the TransformedPrimitive’s coordinate system. The complete transformation to rendering space requires both of these transformations together.

<<TransformedPrimitive Public Methods>>+= 
Bounds3f Bounds() const { return (*renderFromPrimitive)(primitive.Bounds()); }

The Intersect() method also must account for the transformation, both for the ray passed to the held primitive and for any intersection information it returns.

<<TransformedPrimitive Method Definitions>>= 
pstd::optional<ShapeIntersection> TransformedPrimitive::Intersect(const Ray &r, Float tMax) const { <<Transform ray to primitive-space and intersect with primitive>> 
Ray ray = renderFromPrimitive->ApplyInverse(r, &tMax); pstd::optional<ShapeIntersection> si = primitive.Intersect(ray, tMax); if (!si) return {};
<<Return transformed instance’s intersection information>> 
si->intr = (*renderFromPrimitive)(si->intr); return si;
}

The method first transforms the given ray to the primitive’s coordinate system and passes the transformed ray to its Intersect() routine.

<<Transform ray to primitive-space and intersect with primitive>>= 
Ray ray = renderFromPrimitive->ApplyInverse(r, &tMax); pstd::optional<ShapeIntersection> si = primitive.Intersect(ray, tMax); if (!si) return {};

Given an intersection, the SurfaceInteraction needs to be transformed to rendering space; the primitive’s intersection method will already have transformed the SurfaceInteraction to its notion of rendering space, so here we only need to apply the effect of the additional transformation held by TransformedPrimitive.

Note that any returned ShapeIntersection::tHit value from the primitive can be returned to the caller as is; recall the discussion of intersection coordinate spaces and ray t values in Section 6.1.4.

<<Return transformed instance’s intersection information>>= 
si->intr = (*renderFromPrimitive)(si->intr); return si;

The IntersectP() method is similar and is therefore elided.

The AnimatedPrimitive class uses an AnimatedTransform in place of the Transform stored by TransformedPrimitives. It thus enables rigid-body animation of primitives in the scene. See Figure fig:spinning-spheres for an image that exhibits motion blur due to animated transformations.

<<AnimatedPrimitive Definition>>= 
class AnimatedPrimitive { public: <<AnimatedPrimitive Public Methods>> 
Bounds3f Bounds() const { return renderFromPrimitive.MotionBounds(primitive.Bounds()); } AnimatedPrimitive(Primitive primitive, const AnimatedTransform &renderFromPrimitive); pstd::optional<ShapeIntersection> Intersect(const Ray &r, Float tMax) const; bool IntersectP(const Ray &r, Float tMax) const;
private: <<AnimatedPrimitive Private Members>> 
Primitive primitive; AnimatedTransform renderFromPrimitive;
};

The AnimatedTransform class uses substantially more memory than Transform. On the system used to develop pbrt, the former uses 696 bytes of memory, while the latter uses 128. Thus, just as was the case with GeometricPrimitive and SimplePrimitive, it is worthwhile to only use AnimatedPrimitive for shapes that actually are animated. Making this distinction is the task of the code that constructs the scene specification used for rendering.

<<AnimatedPrimitive Private Members>>= 
Primitive primitive; AnimatedTransform renderFromPrimitive;

A bounding box of the primitive over the frame’s time range is found via the AnimatedTransform::MotionBounds() method.

<<AnimatedPrimitive Public Methods>>= 
Bounds3f Bounds() const { return renderFromPrimitive.MotionBounds(primitive.Bounds()); }

We will also skip past the rest of the implementations of the AnimatedPrimitive intersection methods; they parallel those of TransformedPrimitive, just using an AnimatedTransform.