-
Notifications
You must be signed in to change notification settings - Fork 0
06 Ray
A ray is a line with a fixed starting point (but no endpoint). This can be modelled using two vectors: a position and a direction.
A unit vector is simply one whose length is exactly 1.0
. They are useful to indicate directions, since the magnitude of such a vector is mostly irrelevant, and also for further computation as the magnitude is always 1. We shall use a right-handed coordinate system: +x goes right, +y goes up, and +z goes away from the screen (which means -z goes "ahead" in the scene), with the origin at the centre of the space. Let's establish that by defining some constant vectors:
template <typename Type>
constexpr auto zero_v = Type{};
template <typename Type>
constexpr auto one_v = static_cast<Type>(1);
template <typename Type = float>
constexpr auto left_v = Vec<Type, 3>{-one_v<Type>, zero_v<Type>, zero_v<Type>};
template <typename Type = float>
constexpr auto right_v = Vec<Type, 3>{one_v<Type>, zero_v<Type>, zero_v<Type>};
template <typename Type = float>
constexpr auto up_v = Vec<Type, 3>{zero_v<Type>, one_v<Type>, zero_v<Type>};
template <typename Type = float>
constexpr auto down_v = Vec<Type, 3>{zero_v<Type>, -one_v<Type>, zero_v<Type>};
template <typename Type = float>
constexpr auto forward_v = Vec<Type, 3>{zero_v<Type>, zero_v<Type>, -one_v<Type>};
template <typename Type = float>
constexpr auto backward_v = Vec<Type, 3>{zero_v<Type>, zero_v<Type>, one_v<Type>};
Now a class can encapsulate the unit vector invariant:
nvec3.hpp
class nvec3 {
public:
nvec3() = default;
nvec3(fvec3 const vec) : m_vec{normalize(vec)} {}
fvec3 const& vec() const { return m_vec; }
operator fvec3 const&() const { return vec(); }
private:
fvec3 m_vec{forward_v<>};
};
Defaults to pointing forwards (z = -1), and provides a constructor to normalize any arbitrary input. Ideally the input should not be zero: although we've handled that in normalize
, the resulting nvec3
would not really be a unit vector. (It would have otherwise held inf
/ NaN
or thrown a hardware floating point exception for division by zero; in my opinion this is a bit more acceptable.)
With all that in hand, the class for Ray is just a POD:
struct Ray {
fvec3 origin{};
nvec3 direction{};
};
It will be useful to obtain a point on a ray via its parametric form: p = ray.origin + t * ray.direction
, where t
is a positive float. (Negative t
means that the point will be in the opposite direction of the ray.)
Let's add a member function to compute that:
fvec3 at(float const t) const { return origin + t * direction.vec(); }
It is useful to decouple the scene view from the drawing dimensions: this way objects, cameras, etc in the scene need not change even as the target image / framebuffer size changes. Conventionally the view space is normalized (0 to 1 or -1 to +1), also known as Normalized Device Coordinates in graphics lingo. Since the target image may not be 1:1, we will crop the shorter side of the viewport to match their aspect ratios. Eg, since we are using a landscape oriented output image, the viewport width will be 1.0 and height smaller than 1.0.
400px | 1.0
+----------------+
| |
| | 300px | (1.0 * 300.0 / 400.0)
| |
+----------------+
Since the scene is in 3D space, the viewport needs to be a certain positive distance from the camera / eye, which we will encode as its depth. Counter-intuitively, we will emit rays from the camera to every pixel on the viewport (not towards the eye), and determine each one's colour based on objects in the ray's path.
viewport
|
ray |
[camera] = = = > |
|
<------depth------->
Encapsulating all this into a struct:
viewport.hpp
struct Viewport {
fvec2 extent{1.0f};
float depth{1.0f};
static constexpr Viewport make(uvec2 extent, float depth, float scale = 2.0f) {
auto const ar = static_cast<float>(extent.x()) / static_cast<float>(extent.y());
return ar > 1.0f ? Viewport{fvec2{scale, scale / ar}, depth} : Viewport{fvec2{scale * ar, scale}, depth};
}
};
With all that in hand, we are finally ready to trace rays!
main.cpp
Create the viewport and setup the camera origin:
static constexpr auto viewport = Viewport::make(extent, 2.0f, 2.0f);
static constexpr auto origin = fvec3{};
Compute the top-left from the camera; given our right-handed coordinate system, negative Z goes into the screen:
static constexpr auto horizontal = fvec3{viewport.extent.x(), 0.0f, 0.0f};
static constexpr auto vertical = fvec3{0.0f, viewport.extent.y(), 0.0f};
static constexpr auto top_left = origin + 0.5f * (-horizontal + vertical) + fvec3{0.0f, 0.0f, -viewport.depth};
Setup a gradient:
static constexpr fvec3 gradient[] = {Rgb::from_hex(0xffffff).to_f32(), Rgb::from_hex(0x002277).to_f32()};
Modify the loop to emit a ray for each pixel, scaled to the viewport, and use the y component of its direction to lerp between the gradient:
for (std::uint32_t row = 0; row < image.extent().y(); ++row) {
auto const yt = static_cast<float>(row) / static_cast<float>(image.extent().y() - 1);
for (std::uint32_t col = 0; col < image.extent().x(); ++col) {
auto const xt = static_cast<float>(col) / static_cast<float>(image.extent().x() - 1);
auto const dir = top_left + xt * horizontal - yt * vertical - origin;
auto const ray = Ray{origin, dir};
auto const t = 0.5f * (ray.direction.vec().y() + 1.0f);
image[{row, col}] = Rgb::from_f32(lerp(gradient[0], gradient[1], t));
}
}
There were a couple of small bugfixes / changes in the original commits, which are listed below. The wiki pages have since been updated to incorporate the fixes.
rgb.hpp
Fixed originally reversed /
and *
logic:
static constexpr float to_f32(std::uint8_t channel) { return static_cast<float>(channel) / 0xff; }
static constexpr std::uint8_t to_u8(float channel) { return static_cast<std::uint8_t>(channel * 0xff); }
vec.hpp
Added missing friend function for operator-
:
friend constexpr Vec operator-(Vec const& v) {
auto ret = v;
ret = -ret;
return ret;
}