2

I'm writing a ray-tracer in C++, and while writing a constructor for the Ray class:

class Ray {
public:
Ray(const glm::vec3& origin, const glm::vec3& direction) : o{origin}, d{direction} {}

private:
...
}

my IDE helpfully put a zigzag line, saying: enter image description here

This got me thinking, and I decided I wanted to be a little stricter with my types, so that Point could not possibly be confused with Vector (a fairly common mistake in graphics, as far as I've seen).

The essence is that in 3D space, points are position vectors, and are 4-tuples with the last element always 1 (i.e. (x, y, z, 1)), whereas vectors are 4-tuples with the last element always zero (i.e. (x, y, z, 0)). There are some other constraints on binary operators; for instance:

Point + Point = undefined
Point - Point = Vector 
Point ± Vector = Point
Vector ± Vector = Vector
dot(Vector, Vector) = float
cross(Vector, Vector) = Vector (not defined for Point)

I therefore thought of this, as a first attempt:

struct Point {
    glm::vec4 p{0, 0, 0, 1};
};

struct Vector {
    glm::vec4 v{0, 0, 0, 0};
};

and the constructor, rewritten:

Ray(const Point& origin, const Vector& direction) : o{origin.p}, d{direction.v} {}

However, this is clearly insufficient, as we now would either have to access the members to use glm's operator overloads. Plus, it is missing rewrites for all the operator overloads to satisfy the type constraints above.

Furthermore, this does not prevent consumers of the Point/Vector structs from violating the constraint on the w-coordinate (although trivially solved by making them classes and the corresponding members private).

Is there a neater way of doing this (maybe with some C++20 concepts), and more importantly, is this worthwhile at all?

4
  • 1
    "The essence is that in 3D space, points are position vectors, and are 4-tuples with the last element always 1 (i.e. (x, y, z, 1)), whereas vectors are 4-tuples with the last element always zero" - this is how people think of them in 2D & 3D graphics, but this is not a feature of the base concept of vectors, or the space itself. This comes from the use of homogenized coordinates, and the desire to have vectors representing locations and vectors representing directions behave differently when transformed. 1/2 Commented Dec 2, 2022 at 23:00
  • 2
    Now, how much you want to enforce that depends on the expected level of understanding your target audience will posses (meaning, the developers that are going to be the users of your library). The kinds of APIs I like are the ones that help me avoid having to deal with the w coordinate when I don't need to, but that don't prevent me to use it if I want to do manual perspective transform shenanigans, or color transformations, or whatever. 2/2 Commented Dec 2, 2022 at 23:00
  • > The kinds of APIs I like are the ones that help me avoid having to deal with the w coordinate when I don't need to — Do you happen to have any suggestions or examples of such APIs on hand? I'd love to see how this could be handled.
    – SRSR333
    Commented Dec 2, 2022 at 23:39
  • Is it possible to subclass glm::vec3?
    – Jasmijn
    Commented Jan 10, 2023 at 14:09

1 Answer 1

2

You've encountered the general problem with trying to do type-based sanity checking for generic constructs like "vectors": it requires a lot of work, basically rewriting the entirety of GLM, just to provide sane concepts for each interaction.

This can work in specific domains where the type of a value is key in how you use it. For example, the C++ timing chrono library does this with its duration and time_point types. durations are given a timescale, and time_points are given both a timescale and a clock that their tick-count is relative to.

But this works because the scale of a duration is simultaneously extremely important and entirely ephemeral. You can easily convert a duration of one scale into a duration of a different scale, with a possible loss of precision. So if an API asks for a time in milliseconds, and you have a time in seconds, you can still easily call the function and the function will still get the time it expects.

The distinction between a position and a direction isn't so ephemeral. It's an inherent property of the meaning of the data, and by its nature, it's not something that's easily convertible between. It's more like the distinction between time_point and duration. To convert a time point into a duration necessarily loses the epoch the point is relative to. To convert a duration to a time point necessarily adds information: an epoch that the duration is said to be relative to.

When you run into these sorts of things, it's a good idea to go back to the original problem. In this case, the problem can best be expressed as this: there is no way for the caller of this function to state that they are aware of the meaning behind the values it passes.

So you can create a fix specific to that problem. Rather than forcing the user of the library to say what the meaning of the value is at all times, make them say it when they call the function. Force the caller to have to say, "here is a vec3, and I know it's a position," but they only have to say it at the time they call the function.

This can be done by creating a wrapper type which is given a key-type and an arbitrary value. The catch is that the user needs to provide a value of the key-type at the time of the function call.

template<typename Key, typename Value>
struct KeyedValue
{
  Value val;

  KeyedValue(Key, Value const& v) : val(v) {}

  KeyedValue(int, Value const& v) = delete; //See below

  operator Value &() {return val;}
  operator Value const&() const {return val;}
};

struct position_key_t{};
struct direction_key_t{};

inline static position_key_t position_key = {};
inline static direction_key_t direction_key = {};

You can now declare your Ray class as follows:


using pos_vec3 = KeyedValue<position_key_t, glm::vec3>;
using dir_vec3 = KeyedValue<position_key_t, glm::vec3>;
class Ray
{
public:
   Ray(pos_vec3 origin, dir_vec3 direction) : o{origin}, d{direction} {}

private:
...
}

To call it, they must explicitly state if it is a position or a direction:

Ray r1({position_key, origin}, {direction_key, dir});
Ray r2({direction_key, origin}, {position_key, dir}); //Compile error

The "See below" constructor exists to stop users from being able to do this:

Ray r1({{}, origin}, {{}, dir});

The {} best fits the int constructor, but that's deleted. So you get a compile error.

5
  • 1
    I like the first half of the answer(+1), but to me the proposed solution looks pretty ugly. Plus, if I got this right, it does not prevent an instantiation like Ray r1({position_key, dir}, {direction_key, origin});, since dir and origin are both of type glm::vec3. And that IMHO misses the OP's original goal.
    – Doc Brown
    Commented Jan 9, 2023 at 19:09
  • 1
    @DocBrown: "it does not prevent an instantiation like" It's not supposed to prevent it. It's supposed to make someone who reads that declaration go, "hey, wait a minute; that looks like a bug." As opposed to Ray r1(dir, origin); which may or may not be a bug depending on the definition of Ray's constructors. No solution can make mistakes impossible; we can only make them more or less obvious. Commented Jan 9, 2023 at 19:29
  • Ok, you just suggested a new trick to simulate named parameters in C++ (here are some more: fluentcpp.com/2018/12/14/named-arguments-cpp, pdimov.github.io/blog/2020/09/07/named-parameters-in-c20 ) Still I doubt this addresses the original problem of the OP. I doubt there is a real solution apart from what you wrote initially: rewriting the entirety of GLM.
    – Doc Brown
    Commented Jan 9, 2023 at 21:13
  • 1
    This blog post (videocortex.io/2018/Affine-Space-Types) neatly defines the problem I'm trying to solve here: we want to define n-dimensional affine spaces in C++. That author (Shavit) comes to the same conclusion as @NicolBolas—strong type libraries and named parameter libraries are suggested, and it looks like such a well-typed library is sorely missing, at least as far as C++ is concerned. Very surprising, because point-vector misunderstandings are particularly common amongst beginner graphics programmers.
    – SRSR333
    Commented Jan 10, 2023 at 13:43
  • @SRSR333: I suspect that the main reason they're "missing" is that such a library would simply be extremely cumbersome to actually use. Commented Jan 10, 2023 at 14:23

Not the answer you're looking for? Browse other questions tagged or ask your own question.