Skip to content

Commit 66d10ec

Browse files
committed
Improve ray-parallelogram intersections
See parallelogram.h and Parallelogram::hit_by() for more info. As a summary, a poor choice for the normal vector used in the ray-parallelogram intersection routine resulted in parallelograms with large coordinates rejecting all rays and failing to render as a result. We resolve this issue with a better choice of normal vector.
1 parent 5cdef87 commit 66d10ec

File tree

1 file changed

+106
-60
lines changed

1 file changed

+106
-60
lines changed

src/parallelogram.h

+106-60
Original file line numberDiff line numberDiff line change
@@ -26,79 +26,108 @@ class Parallelogram : public Hittable {
2626
/* `material` = The material of this `Parallelogram` object. */
2727
std::shared_ptr<Material> material;
2828

29-
/* `unit_plane_normal` = n.unit_vector(), where `n = cross(side1, side2)`, a normal vector
30-
to the plane containing this `Parallelogram`. We precompute this quantity, because it is
31-
returned as a part of the `hit_info` for every ray-`Parallelogram` intersection in
32-
`Parallelogram::hit_by()` as the `outward_unit_surface_normal`.
29+
/* `unit_plane_normal` is a unit vector normal to the plane containing this `Parallelogram`.
30+
Specifically, `unit_plane_normal` is the unit vector of `cross(side1, side2)`,
31+
which we know is a normal vector to the plane of this `Parallelogram`. We precompute this
32+
quantity because (a) we will use it in some of our computations in `Parallelogram::hit_by()`,
33+
and (b) it will be the `outward_unit_surface_normal` field of every `hit_info` returned
34+
from `Parallelogram::hit_by()`, so it is beneficial to compute it exactly once.
3335
34-
Note that for a flat object, "outside" and "inside" is arbitrary. We just keep it simple
35-
here and say that the direction of `cross(side1, side2)` is the outside normal. You could
36-
say instead that the direction of `cross(side1, side2)` is the INSIDE normal; it'd make
37-
no difference, except for flipping the `hit_from_outside` for all `hit_info`s returned
38-
from `Parallelogram::hit_by()`. */
36+
About the "outward" in `outward_unit_surface_normal`, note that there is no singular definition
37+
of "outside" and "inside" for a flat object such as a parallelogram. Here, we declare that the
38+
direction of the normal `cross(side1, side2)` is outward-facing. You could say instead that the
39+
direction of `cross(side1, side2)` is inward-facing; the only difference would be that the
40+
`hit_from_outside` field of all `hit_info`s returned from `Parallelogram::hit_by()` will be
41+
flipped. */
3942
Vec3D unit_plane_normal;
4043
/* `scaled_plane_normal` = n / dot(n, n), where `n = cross(side1, side2)`, a normal vector
4144
to the plane containing this `Parallelogram`. We precompute this quantity to be used in
4245
`Parallelogram::hit_by()`. */
4346
Vec3D scaled_plane_normal;
44-
4547
/* `aabb` = The AABB (Axis-Aligned Bounding Box) for this `Parallelogram`. */
4648
AABB aabb;
4749

4850
public:
4951

5052
/* There are three steps to perform a ray-parallelogram intersection check:
51-
1. Find the plane that contains the parallelogram
52-
2. Find the intersection point of the ray with that parallelogram-containing plane
53-
3. Determine if the hitpoint of the ray on the plane lies within the parallelogram itself.
53+
1. Find the unique plane containing the parallelogram.
54+
2. Find the intersection point of the ray with that parallelogram-containing plane.
55+
3. Determine if the hit point of the ray on the plane lies within the parallelogram itself.
5456
55-
STEP 1: Find the plane that intersects the parallelogram.
56-
Remember that a plane is determined by a normal vector `n` to that plane, as well as a
57-
point on the plane. We want to find the plane that contains the parallelogram. Now, a
58-
normal vector to the parallelogram (and so also a normal vector to the plane containing
59-
that parallelogram) can be found by taking the cross product of the two side vectors;
60-
`cross(side, side2)`. Then, we need an arbitrary point on the plane; for this, we can just
61-
take the given vertex `vertex`, which we know to be on the parallelogram (and so we know
62-
it to be on the parallelogram-containing plane as well).
57+
STEP 1: Find the plane that contains the parallelogram.
58+
Remember that a plane is fully specified by two things: a normal vector to the plane and a
59+
point on the plane. Now, we want to determine the plane containing the parallelogram. Firstly,
60+
observe that a vector is normal to the parallelogram-containing plane iff it is normal to
61+
the parallelogram itself. And so, it suffices to find a vector normal to the parallelogram;
62+
this can be done by taking the cross product of the two side vectors: `cross(side1, side2)`.
63+
Now, we need an arbitrary point on the parallelogram-containing plane; for this, we can just
64+
take the given vertex of the parallelogram `vertex`, which we know to be on the parallelogram
65+
(and so we know it to be on the parallelogram-containing plane as well).
6366
64-
Now, we have that a normal to the plane that contains this parallelogram is
65-
`n = cross(side1, side2)`, and a point on the plane is `vertex`. As a result, the plane
66-
equation can be written as `dot(n, P - vertex) = 0` for points `P (so a point `P` is on
67-
the plane iff the vector from Q to it is normal to the plane's normal vector). An equivalent
68-
and more useful formulation of this equation that we will use is `dot(n, P) = dot(n, vertex)`.
69-
This completes the first step.
67+
We have found a normal vector to the plane containing this parallelogram: `cross(side1, side2)`.
68+
We also have a point on the plane: `vertex`. Now, given a normal vector to a plane `n` and a
69+
point `p` on that plane, the plane consists of exactly the points `P` that satisfy the equation
70+
`dot(n, P - p) = 0` (because the equation is equivalent to the statement that a point `P` is
71+
on a plane iff the vector from some point on the plane to it is normal to the plane's normal
72+
vector, which is clearly true). An equivalent and more useful formulation of this equation
73+
that we will use is `dot(n, P) = dot(n, p)`. Finally, because all nonzero scalar multiples
74+
of a normal vector to a plane are also normal to a plane (as multiplying a vector by a nonzero
75+
scalar preserves its direction), and because we know that `cross(side1, side2)` is normal to
76+
the parallelogram-containing plane, we have that any `k * cross(side1, side2)` for a nonzero
77+
real number `k` is normal to the parallelogram-containing plane. So, if we let `n` equal
78+
`cross(side1, side2)`, then our parallelogram-containing plane consists of exactly the points
79+
`P` where `dot(kn, P) = dot(kn, vertex)` for any fixed nonzero real number `k`. This completes
80+
the first step.
7081
7182
STEP 2: Find the intersection point of the ray with the parallelogram-containing plane.
72-
Let the ray be defined by R(t) = O + td. First, we solve for the hit time of the ray
73-
with the plane; that is, we find the `t` that satisfies `dot(n, R(t)) = dot(n, vertex)`.
74-
Because R(t) = O + td, this becomes `dot(n, O + td) = dot(n, vertex)`, and so
75-
`dot(n, O) + dot(n, td) = dot(n, vertex)`. Solving, we find that
76-
`t = dot(n, vertex - O) / dot(n, d)`.
77-
78-
Observe that it is possible for a ray to not intersect the plane; this happens when the
79-
ray is parallel to the plane and the ray's origin is not on the plane. When the ray is
80-
parallel to the plane, then it will be perpendicular to the plane's normal vector, and
81-
so `dot(n, d)` will equal 0, allowing us to detect such cases. In practice, we will
82-
just reject all rays where `dot(n, d)` is small, to avoid numerical issues. In my
83-
implementation below, I reject all rays where `|dot(n, d)| < 1e-9`.
84-
85-
We now have shown that the hit time is `t = dot(n, vertex - O) / dot(n, d)` (assuming
86-
that `dot(n, d)` is not too small; if it is, then again, we just assume the ray does
83+
Let the ray be defined by R(t) = O + tD. Now, we first solve for the hit time of the ray with
84+
the plane. Using the equation we derived in Step 1, solving for the hit time is equivalent to
85+
solving `dot(kn, R(t)) = dot(kn, vertex)` for `t`, where `k` is any nonzero real number.
86+
Then, substituting O + tD for R(t), this equation becomes `dot(kn, O + tD) = dot(kn, vertex)`,
87+
and so `dot(kn, O) + dot(kn, tD) = dot(kn, vertex)`. Solving, we find that
88+
`t = dot(kn, vertex - O) / dot(kn, D)`.
89+
90+
However, it is possible for a ray to not intersect the plane at all; this happens when the ray
91+
is parallel to the plane and the ray's origin is not on the plane. A ray is parallel to the
92+
plane iff it is perpendicular to the plane's normal vector, which holds iff `dot(kn, D)`
93+
equals 0 (where `D` is the direction vector of the ray, remember). Observe that this corresponds
94+
to the case where calculating `t` results in a division by 0 (because the denominator of the
95+
fraction that equals `t` is also `dot(kn, d)`); clearly, `t` does not exist in that case.
96+
In practice, we make two simplifications: firstly, we immediately reject all rays that are
97+
parallel to the plane, even if the ray's origin is on the plane (because while such a ray does
98+
indeed intersect the plane (at infinitely many points, in fact), it causes problems insofar that
99+
the direction of the `outward_unit_surface_normal` from the intersection point would be unclear,
100+
and that fundamentally, a plane is infinitely thin, and there's no analogy to real life for a
101+
ray (photon) colliding with an infinitely-thin edge, which is what would happen in this case).
102+
The second simplification we will make is that we will just reject all rays where `dot(kn, D)`
103+
is small (not necessarily exactly 0), to avoid numerical issues. In my implementation below,
104+
I just reject all rays where `|dot(kn, D)| < 1e-9`.
105+
Note: Because we are checking |dot(kn, d)| against a constant, the choice of `k` becomes
106+
important. In particular, if the components of `kn` are too small, then `dot(kn, D)` may
107+
have magnitude less than `1e-9` even when `kn` and `D` are not close to parallel, resulting
108+
in rays being incorrectly rejected. At the same time, though, if the components of `kn` are
109+
too big, then the calculation of `dot(kn, d)` will be more susceptible to floating-point
110+
inaccuracies. My solution is to always use the unit vector of `n` in the equation (so set
111+
k = 1 / |n|). This ensures some level of consistency across the magnitudes of the components
112+
of `kn`, and seems to work well from my experimentation.
113+
114+
We now have shown that the hit time is `t = dot(kn, vertex - O) / dot(kn, D)` (assuming
115+
that `dot(kn, D)` is not too small; if it is, then again, we just assume the ray does
87116
not hit this parallelogram to avoid numerical issues). To find the hit point, just
88-
find `O + td` with that value of `t`. This completes the second step.
117+
find `R(t) = O + tD` with that value of `t`. This completes the second step.
89118
90119
STEP 3: Determine if the hit point of the ray with the parallelogram-containing plane also
91120
lies within the parallelogram itself.
92121
93122
First, observe that {`side1`, `side2`} forms a basis for the parallelogram-containing plane,
94123
because `side1` and `side2` are linearly independent (since they are not parallel), and because
95-
they are two vectors in a space (a plane) of dimension two. Now, we choose the "origin" of
124+
they are two vectors in a space of dimension two (the plane). Now, we choose the "origin" of
96125
the parallelogram-containing plane to be `vertex`. Then, we know that
97126
(a) Because {`side1`, `side2`} is a basis of the plane, there exist unique scalars alpha/beta
98127
such that `hit_point = vertex + alpha * side1 + beta * side2`.
99128
(b) Because of the definition of a parallelogram, and because we chose the origin of the plane
100129
to be `vertex` itself, we know that the hit point is inside the parallelogram if and only if
101-
0 <= alpha, beta <= 1, where `hit_point = vertex + alpha * side1 + beta * side`, as stated
130+
0 <= alpha, beta <= 1, where `hit_point = vertex + alpha * side1 + beta * side2` as stated
102131
above.
103132
104133
Thus, it remains to solve the equation `hit_point = vertex + alpha * side1 + beta * side2`.
@@ -147,22 +176,38 @@ class Parallelogram : public Hittable {
147176
object is returned. */
148177
std::optional<hit_info> hit_by(const Ray3D &ray, const Interval &ray_times) const override {
149178

150-
/* The hit time is equal to dot(n, vertex - ray.origin) / dot(n, ray.dir), where `n` is
151-
any normal to the parallelogram-containing plane. Since we have already precomputed
152-
`scaled_plane_normal`, we just use that. All that matters is that the `n` used in the
153-
numerator and denominator of the fraction is the same `n`.
179+
/* If the ray `ray` hits the plane containing this `Parallelogram`, then the hit time is
180+
equal to dot(kn, vertex - ray.origin) / dot(kn, ray.dir), where `n = cross(side1, side2)`
181+
and `k` is any nonzero real number.
182+
183+
However, as explained above, before computing the hit time of the ray, we will first check
184+
if the ray intersects with the plane at all. For our purposes, as explained above,
185+
this is equivalent to checking if `dot(kn, ray.dir)` (the denominator of the fraction
186+
that equals the hit time) is smaller than some constant (1e-9 here). Finally, the choice
187+
of `k` is important, as explained above; we will choose to use `k = 1 / |n|` (so
188+
kn = `unit_plane_normal` exactly), because this prevents the components of `kn` from
189+
becoming too small (which could cause `hit_time_denominator` to be less than 1e-9 more
190+
often than it should) or too large (which could lead to floating-point inaccuracies in
191+
the computation of `dot(kn, ray.dir)`). When I initially used `kn = scaled_plane_normal
192+
= n / |n|^2`, parallelograms with coordinates on the order of 1e6 started rejecting all
193+
rays, resulting in them not rendering at all. From my testing,`unit_plane_normal` does
194+
not have the same issue.
154195
155-
Now, as explained above, we first check if the ray is parallel (or very close to parallel)
156-
to the parallelogram-containing plane. If so, we just return an empty
157-
`std::optional<hit_info>`, signifying that the ray did not intersect this parallelogram. */
158-
auto hit_time_denominator = dot(scaled_plane_normal, ray.dir);
196+
In summary, if the ray is parallel or very close to parallel to the parallelogram-containing
197+
plane, we just immediately reject the ray to avoid numerical issues. To do this, we return
198+
an empty `std::optional<hit_info>` if `dot(unit_plane_normal, ray.dir)` is less than our
199+
chosen constant. */
200+
auto hit_time_denominator = dot(unit_plane_normal, ray.dir); /* Use `unit_plane_normal` */
159201
if (std::fabs(hit_time_denominator) < 1e-9) {
160202
return {};
161203
}
162204

163205
/* If the ray is not parallel/very close to parallel to the plane, compute the hit time.
164206
First, make sure that the hit time is in the desired time range `ray_times`. */
165-
auto hit_time = dot(scaled_plane_normal, vertex - ray.origin) / hit_time_denominator;
207+
auto hit_time = dot(unit_plane_normal, vertex - ray.origin) / hit_time_denominator;
208+
/* ^^ We must use `unit_plane_normal` here because we used `unit_plane_normal` in computing
209+
the `hit_time_denominator`. The normal we use must be the same in the numerator and the
210+
denominator. */
166211
if (!ray_times.contains_exclusive(hit_time)) { /* Remember that `ray_times` is exclusive */
167212
return {};
168213
}
@@ -189,8 +234,8 @@ class Parallelogram : public Hittable {
189234
return hit_info(hit_time, hit_point, unit_plane_normal, ray, material);
190235
}
191236

192-
/* The ray hit the plane, but the hitpoint was not in the parallelogram itself, so we will
193-
return an empty `std::optional<hit_info>`.*/
237+
/* The ray hit the parallelogram-containing plane, but it did not hit the parallelogram
238+
itself, so we will still return an empty `std::optional<hit_info>`.*/
194239
return {};
195240
}
196241

@@ -235,12 +280,13 @@ class Parallelogram : public Hittable {
235280
/* The `AABB` for a `Parallelogram` is simply the minimum-size `AABB` that contains all the
236281
vertices of the parallelogram; that is, the `AABB` containing `vertex`, `vertex + side1`,
237282
`vertex + side2`, and the opposite vertex (which, remember, is calculated by
238-
`vertex + side1 + side2` for parallelograms).
283+
`vertex + side1 + side2` for parallelograms). This is because parallelograms are convex,
284+
so a bounding box that contains the vertices contains the whole shape.
239285
240286
Then, because parallelograms are 2D, the resulting AABB may have zero thickness in one
241-
of its dimensions if it is parallel to the xy-/xz-/yz-plane, which can result in numerical
242-
issues. To avoid this, we pad the axis intervals of the AABB, making sure that every
243-
axis interval has length at least some small constant (1e-4 here). */
287+
of its dimensions (when it is parallel to one of the xy-/xz-/yz-planes), which can result
288+
in numerical issues. To avoid this, we pad every axis interval of the AABB; specifically,
289+
we ensure that every axis interval has length at least some small constant (1e-4 here). */
244290
aabb = AABB::from_points({
245291
vertex, /* The given vertex of this parallelogram */
246292
vertex + side1, /* The vertex opposite to `vertex` along the first side */

0 commit comments

Comments
 (0)