Modelspace - Demo 06

Objective

Learn about modelspace Modelspace.

Demo 06

Demo 06

How to Execute

Load src/modelviewprojection/demo06.py in Spyder and hit the play button.

Move the Paddles using the Keyboard

Keyboard Input

Action

w

Move Left Paddle Up

s

Move Left Paddle Down

k

Move Right Paddle Down

i

Move Right Paddle Up

Modelspace

NDC are not a natural system of numbers for use by humans. Imagine that the paddles in the previous chapters exist in real life, and are 2 meters wide and 6 meters tall. The graphics programmer should be able to use those numbers directly; they shouldn’t have to manually transform the distances into NDC.

Whatever a convenient numbering system is (i.e. coordinate system) for modeling objects is called modelspace. Since a paddle has four corners, which corner should be at the origin (0,0)? If you don’t already know what you want at the origin, then none of the corners should be; instead put the center of the object at the origin (Because by putting the center of the object at the origin, scaling and rotating the object are trivial, as shown in later chapters).

Representing a Paddle using Modelspace

Representing a Paddle using Modelspace

modelspace - the coordinate system (origin plus axes), in which some object’s vertices are defined.

WorldSpace

WorldSpace is a top-level space, independent of NDC, that we choose to use. It is arbitrary. If you were to model a racetrack for a racing game, the origin of WorldSpace may be the center of that racetrack. If you were modeling our solar system, the center of the sun could be the origin of “WorldSpace”. I personally would put the center of our flat earth at the origin, but reasonable people can disagree.

For our demo with paddles, the author arbitrarily defines the WorldSpace to be 20 units wide, 20 units tall, with the origin at the center.

Demo 06

Demo 06

Modelspace to WorldSpace

The author prefers to view transformations as changes to the graph paper, as compared to view transformations as changes to points.

As such, for placing paddle1, we can view the translation as a change to the graph paper relative to world space coordinates (only incidentally bringing the vertices along with it) and then resetting the graph paper (i.e. both origin and axes) back to its original position and orientation. Although we will think of the paddle’s vertices as relative to its own space (i.e. -1 to 1 horizontally, -3 to 3 vertically), we will not look at the numbers of what they are in world space coordinates, as doing so

  • Will not give us any insight

  • Will distract us from thinking clearly about what’s happening

  • As an example, figure out the world space coordinate of the upper rights corner of the paddle after it has been translated, and ask yourself what that means and what insight did you gain?

The animation above shows multiple steps, shown now without animation.

Modelspace of Paddle 1

Paddle 1's Modelspace

Paddle 1’s Modelspace

Vector

Coordinates

a

(1,3)

b

(-1,3)

c

(-1,-3)

d

(1,-3)

Modelspace of Paddle 1 Superimposed on Worldspace after the translation

Paddle 1’s graph paper gets translated -9 units in the x direction, and some number of units in the y direction, 0 during the first frame, based off of user input. The origin is translated, and the graph paper comes with it, onto which you can plot the vertices. Notice that the coordinate system labels below the plot and to the left of the plot is unchanged. That is world space, which has not changed.

Paddle 1's Modelspace Superimposed on World Space

Paddle 1’s Modelspace Superimposed on World Space

Vector

Coordinates (modelspace)

Coordinates (worldspace)

a

(1,3)

(1,3) + (9,3) = (-8,5)

b

(-1,3)

(-1,3) + (9,3) = (-10,5)

c

(-1,-3)

(-1,-3) + (9,3) = (-10,-1)

d

(1,-3)

(1,-3) + (9,3) = (-8,-1)

Paddle 1’s vertices in WorldSpace Coordinates

Paddle 1's Vertices in World Space

Paddle 1’s Vertices in World Space.

Vector

Coordinates (worldspace)

a

(-8,5)

b

(-10,5)

c

(-10,-1)

d

(-8,-1)

Now that the transformation has happened, the vertices are all in world space. You could calculate their values in world space, but that will not give you any insight. The only numbers that matter for insight as that the entire graph paper of modelspace, which originally was the same as world space, has changed, bringing the vertices along with it.

Same goes for Paddle 2’s modelspace, relative to its translation, which are different values.

Modelspace of Paddle 2

Paddle 1's Modelspace

Paddle 2’s Modelspace

Vector

Coordinates

a

(1,3)

b

(-1,3)

c

(-1,-3)

d

(1,-3)

Modelspace of Paddle 2 Superimposed on Worldspace after the translation

Paddle 1's Modelspace Superimposed on World Space

Paddle 2’s Modelspace Superimposed on World Space

Vector

Coordinates (modelspace)

Coordinates (worldspace)

a

(1,3)

(1,3) + (9,-4) = (10,-1)

b

(-1,3)

(-1,3) + (9,-4) = (8,-1)

c

(-1,-3)

(-1,-3) + (9,-4) = (8,-7)

d

(1,-3)

(1,-3) + (9,-4) = (10,-7)

Paddle 2’s vertices in WorldSpace Coordinates

Paddle 1's Vertices in World Space

Paddle 2’s Vertices in World Space.

Vector

Coordinates (worldspace)

a

(10,-1)

b

(8,-1)

c

(8,-7)

d

(10,-7)

Scaling

Our paddles are now well outside of NDC, and as such, they would not be displayed, as they would be clipped out. Their values are outside of -1.0 to 1.0. All we will need to do to convert them from world space to NDC is divide each component, x and y, by 10.

As a demonstration of how scaling works, let’s make an object’s width twice as large, and height three times as large. (The author tried doing the actual scaling of 1/10 in an animated gif, and it looked awful, therefore a different scaling gif is showed here, but the concept is the same).

We can expand or shrink the size of an object by “scale”ing each component of the vertices by some coefficient.

Modelspace

Modelspace

Modelspace Superimposed on World Space

Modelspace Superimposed on World Space

Worldspace

Worldspace. Don’t concern yourself with what the numbers are.

Our global space is -10 to 10 in both dimensions, and to get it into NDC, we need to scale by dividing by 10

Demo 06

Demo 06

\[\begin{split}\begin{bmatrix} x_{w} \\ y_{w} \end{bmatrix} = \vec{f}_{p1}^{w}( \begin{bmatrix} x_{p1} \\ y_{p1} \end{bmatrix}) = \begin{bmatrix} x_{p1} \\ y_{p1} \end{bmatrix} + \begin{bmatrix} {p1}_{x} \\ {p1}_{y} \end{bmatrix}\end{split}\]

where x_p1, y_p1 are the modelspace coordinates of the paddle’s vertices, and where p1_center_x_worldspace, p1_center_y_worldspace, are the offset from the world space’s origin to the center of the paddle, i.e. the translation.

\[\begin{split}\begin{bmatrix} x_{w} \\ y_{w} \end{bmatrix} = \vec{f}_{p2}^{w} ( \begin{bmatrix} x_{p2} \\ y_{p2} \end{bmatrix}) = \begin{bmatrix} x_{p2} \\ y_{p2} \end{bmatrix} + \begin{bmatrix} {p2}_{x} \\ {p2}_{y} \end{bmatrix}\end{split}\]

Now, the coordinates for paddle 1 and for paddle 2 are in world space, and we need the match to take any world space coordinates and convert them to NDC.

\[\begin{split}\begin{bmatrix} x_{ndc} \\ y_{ndc} \end{bmatrix} = \vec{f}_{w}^{ndc} ( \begin{bmatrix} x_{w} \\ y_{w} \end{bmatrix}) = 1/10 * \begin{bmatrix} x_{w} \\ y_{w} \end{bmatrix}\end{split}\]

Modelviewprojection comes with a math library, the 2D version is named “mathutils2d.py”. The main class in this module is “Vector2D”, which has two components: and x value, and a y value. To add a vector2d to another one on the right hand side of the ‘+’ symbol, we just add the respective components together, and create a new Vector2D.

src/modelviewprojection/mathutils2d.py
32@dataclass
33class Vector2D:
34    x: float  #: The x-component of the 2D Vector
35    y: float  #: The y-component of the 2D Vector

In a Python class, we can overload the ‘+’ symbol, to make objects addable, by implementing the ‘__add__’ method.

src/modelviewprojection/mathutils2d.py
39    def __add__(self, rhs: Vector2D) -> Vector2D:
40        """
41        Add together two Vector2Ds.
42
43        Let :math:`\\vec{a} = \\begin{pmatrix} a_x \\\\ a_y \\end{pmatrix}`
44        and :math:`\\vec{b} = \\begin{pmatrix} b_x \\\\ b_y \\end{pmatrix}`:
45
46        .. math::
47
48             \\vec{a} + \\vec{b} = \\begin{pmatrix} a_x + b_x \\\\ a_y + b_y \\end{pmatrix}
49
50        Args:
51            rhs (Vector2D): The vector on the right hand side of the addition
52                            symbol
53        Returns:
54            Vector2D: The Vector2D that represents the additon of the two
55                      input Vector2Ds
56        Raises:
57            Nothing
58        Example:
59            >>> from modelviewprojection.mathutils2d import Vector2D
60            >>> a = Vector2D(x=2.0, y=3.0)
61            >>> b = Vector2D(x=5.0, y=6.0)
62            >>> a + b
63            Vector2D(x=7.0, y=9.0)
64        """
65
66        return Vector2D(x=(self.x + rhs.x), y=(self.y + rhs.y))
67

We can also model the opposite procedure, subtraction, by implementing the “__sub__” method.

src/modelviewprojection/mathutils2d.py
71    def __sub__(self, rhs: Vector2D) -> Vector2D:
72        """
73        Subtract the right hand side Vector2D from the left hand side Vector2D.
74
75        Let :math:`\\vec{a} = \\begin{pmatrix} a_x \\\\ a_y \\end{pmatrix}`
76        and :math:`\\vec{b} = \\begin{pmatrix} b_x \\\\ b_y \\end{pmatrix}`:
77
78        .. math::
79
80             \\vec{a} - \\vec{b} = \\vec{a} + \\vec{b} = \\begin{pmatrix} a_x - b_x \\\\ a_y - b_y \\end{pmatrix}
81
82        Args:
83            rhs (Vector2D): The vector on the right hand side of the
84                            subtraction symbol
85        Returns:
86            Vector2D: The Vector2D that represents the subtraction of the
87                      right hand side Vector2D from the left hand side
88                      Vector2D
89        Raises:
90            Nothing
91        Example:
92            >>> from modelviewprojection.mathutils2d import Vector2D
93            >>> a = Vector2D(x=2.0, y=3.0)
94            >>> b = Vector2D(x=5.0, y=2.0)
95            >>> a - b
96            Vector2D(x=-3.0, y=1.0)
97        """
98        return Vector2D(x=(self.x - rhs.x), y=(self.y - rhs.y))
99

In our graphics code, instead of using “a+b”, we’ll use a more descriptive name: “translate”, which is implemented using the addition symbol. But a few things to note, “translate” is a function on the mathutils module, not a method on Vector2D class, and it’s wrapped in a class named “InvertibleFunction”

src/modelviewprojection/mathutils2d.py
165def translate(b: Vector2D) -> InvertibleFunction[Vector2D]:
166    def f(vector: Vector2D) -> Vector2D:
167        return vector + b
168
169    def f_inv(vector: Vector2D) -> Vector2D:
170        return vector - b
171
172    return InvertibleFunction[Vector2D](f, f_inv)

Notice in particular that the “b” parameter is passed as an argument to “translate”, but the function for translating, named “f”, and the inverse of “f” named “f_inv”, take a Vector2D. This is because we will be translating many Vector2Ds using the same amount.

Invertible functions are stored in pairs, with the “active” function being the first one passed to the constructor. So for translate above, the adding of the Vector2Ds will be the function, but InvertibleFunction holds onto the second function, for later use to be able to undo the function’s application.

tests/test_mathutils2d.py
115def test_translate():
116    fn: InvertibleFunction[Vector2D] = translate(Vector2D(x=2.0, y=3.0))
117    fn_inv: InvertibleFunction[Vector2D] = inverse(fn)
118
119    input_output_pairs = [
120        [[0.0, 0.0], [2.0, 3.0]],
121        [[1.0, 0.0], [3.0, 3.0]],
122        [[0.0, 1.0], [2.0, 4.0]],
123    ]
124
125    for input_val, output_val in input_output_pairs:
126        wrap_vec2_test(fn, input_val, output_val)
127        wrap_vec2_test(fn_inv, output_val, input_val)
128
129

The above is a unit test that shows how the translate function can be used. We call “translate”, a function which takes a translate amount, both in the x direction and the y direction, but we have not yet specified what needs to be translated by that amount. “translate” returns an InvertibleFunction, which is Callable[Vector2D, Vector2D]. Callable[Vector2D, Vector2D] is a type which is a function that takes a Vector2D as input, and returns a Vector2D (in this case, the output is the input, translated by Vector2D(x=2.0, y=3.0).

On the next 3 lines, we call the function t, passing in a Vector2D to be translated, and we test if the result is equal to the specified amount. (“approx”, is a function from the pytest module, which when tested for equality, returns true if the two floating point numbers under comparison are “close enough”).

We then define a function t_inv, by calling “inverse” on function “t”. We then see that composing t_inv and t results in no transformation.

Here’s how InvertibleFunction is implemented:

src/modelviewprojection/mathutils.py
 25# Define a generic type variable
 26T = TypeVar("T")
 27
 28
 29@dataclass
 30class InvertibleFunction(Generic[T]):
 31    """
 32    Class that wraps a function and its
 33    inverse function.  The function takes
 34    type T as it's argument and it's evaluation
 35    results in a value of type T.
 36    """
 37
 38    func: Callable[[T], T]  #: The wrapped function
 39    inverse: Callable[[T], T]  #: The inverse of the wrapped function
 40
 41    def __call__(self, x: T) -> T:
 42        """
 43        Execute a function with the given value.
 44
 45        Args:
 46            func (Callable[[T], T]): A function that takes a value of type T
 47                                     and returns a value of the same type T.
 48            value (T): The input value to pass to the function
 49        Returns:
 50            T: The result of calling func(value). Will be the same type as the
 51                input value.
 52        Raises:
 53            Nothing
 54        Example:
 55            >>> from modelviewprojection.mathutils import InvertibleFunction
 56            >>> from modelviewprojection.mathutils import inverse
 57            >>> def f(x):
 58            ...     return 2 + x
 59            ...
 60            >>> def f_inv(x):
 61            ...     return x - 2
 62            ...
 63            >>> foo = InvertibleFunction(func=f, inverse=f_inv)
 64            >>> foo # doctest: +ELLIPSIS
 65            InvertibleFunction(func=<function f at 0x...>, inverse=<function f_inv at 0x...>)
 66            >>> foo(5)
 67            7
 68            >>> inverse(foo) # doctest: +ELLIPSIS
 69            InvertibleFunction(func=<function f_inv at 0x...>, inverse=<function f at 0x...>)
 70            >>> inverse(foo)(foo(5))
 71            5
 72        """
 73        return self.func(x)
 74
 75
 76def inverse(f: InvertibleFunction[T]) -> InvertibleFunction[T]:
 77    """
 78    Get the inverse of the InvertibleFunction
 79
 80    Args:
 81        f: InvertibleFunction[T]: A function with it's associated inverse
 82           function.
 83    Returns:
 84        InvertibleFunction[T]: The Inverse of the function
 85           function.
 86    Raises:
 87        Nothing
 88    Example:
 89        >>> from modelviewprojection.mathutils import InvertibleFunction
 90        >>> from modelviewprojection.mathutils import inverse
 91        >>> def f(x):
 92        ...     return 2 + x
 93        ...
 94        >>> def f_inv(x):
 95        ...     return x - 2
 96        ...
 97        >>> foo = InvertibleFunction(func=f, inverse=f_inv)
 98        >>> foo # doctest: +ELLIPSIS
 99        InvertibleFunction(func=<function f at 0x...>, inverse=<function f_inv at 0x...>)
100        >>> foo(5)
101        7
102        >>> inverse(foo) # doctest: +ELLIPSIS
103        InvertibleFunction(func=<function f_inv at 0x...>, inverse=<function f at 0x...>)
104        >>> inverse(foo)(foo(5))
105        5
106    """
107
108    return InvertibleFunction(f.inverse, f.func)

Just as Python allows an object to override the ‘+’ and ‘-’ syntax, in Python, any object can be treated as a function, by implementing the “__call__” method

Back to method’s on the Vector2D class. We can also define scaling of a Vector2D, by implementing multiplication of Vector2D’s by a scalar, meaning a real number that scaled the Vector2D by the same amount in all directions. We do this by implementing the ‘__mul__’ and ‘__rmul__’ methods, where __rmul__ just means that this object is on the right hand side of the multiplication.

src/modelviewprojection/mathutils2d.py
103    def __mul__(self, scalar: float) -> Vector2D:
104        """
105        Multiply the Vector2D by a scalar number
106
107        Let :math:`\\vec{a} = \\begin{pmatrix} a_x \\\\ a_y \\end{pmatrix}` and constant scalar :math:`s`:
108
109        .. math::
110
111             s*\\vec{a} = \\begin{pmatrix} s*a_x \\\\ s*a_y \\end{pmatrix}
112
113        Args:
114            rhs (Vector2D): The scalar to be multiplied to the Vector's component
115                            subtraction symbol
116        Returns:
117            Vector2D: The Vector2D that represents scalar times the amount of the input Vector2D
118
119        Raises:
120            Nothing
121        Example:
122            >>> from modelviewprojection.mathutils2d import Vector2D
123            >>> a = Vector2D(x=2.0, y=3.0)
124            >>> a * 4
125            Vector2D(x=8.0, y=12.0)
126        """
127        return Vector2D(x=(self.x * scalar), y=(self.y * scalar))
128
129    def __rmul__(self, scalar: float) -> Vector2D:
130        return self * scalar
131

Just like we made a top level invertible function called “translate” for addition, we are going to do the same for multiplication, and call it “uniform_scale”. Notice in particular that the scalar is passed as an argument to “uniform_scale”, but the function for scaling “f”, and the inverse of “f” named “f_inv”, take a Vector2D. This is because we will be scaling many Vector2Ds using the same scaling factor.

src/modelviewprojection/mathutils2d.py
177def uniform_scale(m: float) -> InvertibleFunction[Vector2D]:
178    def f(vector: Vector2D) -> Vector2D:
179        return vector * m
180
181    def f_inv(vector: Vector2D) -> Vector2D:
182        if m == 0.0:
183            raise ValueError("Note invertible.  Scaling factor cannot be zero.")
184
185        return vector * (1.0 / m)
186
187    return InvertibleFunction[Vector2D](f, f_inv)
  • NEW – Add the ability to scale a vector, to stretch or to shrink

src/modelviewprojection/demo06.py
119paddle1: Paddle = Paddle(
120    vertices=[
121        Vector2D(x=-1.0, y=-3.0),
122        Vector2D(x=1.0, y=-3.0),
123        Vector2D(x=1.0, y=3.0),
124        Vector2D(x=-1.0, y=3.0),
125    ],
126    color=Color3(r=0.578123, g=0.0, b=1.0),
127    position=Vector2D(-9.0, 0.0),
128)
129
130paddle2: Paddle = Paddle(
131    vertices=[
132        Vector2D(x=-1.0, y=-3.0),
133        Vector2D(x=1.0, y=-3.0),
134        Vector2D(x=1.0, y=3.0),
135        Vector2D(x=-1.0, y=3.0),
136    ],
137    color=Color3(r=1.0, g=1.0, b=0.0),
138    position=Vector2D(9.0, 0.0),
139)
  • paddles are using modelspace coordinates instead of NDC

src/modelviewprojection/demo06.py
144def handle_movement_of_paddles() -> None:
145    global paddle1, paddle2
146
147    if glfw.get_key(window, glfw.KEY_S) == glfw.PRESS:
148        paddle1.position.y -= 1.0
149    if glfw.get_key(window, glfw.KEY_W) == glfw.PRESS:
150        paddle1.position.y += 1.0
151    if glfw.get_key(window, glfw.KEY_K) == glfw.PRESS:
152        paddle2.position.y -= 1.0
153    if glfw.get_key(window, glfw.KEY_I) == glfw.PRESS:
154        paddle2.position.y += 1.0
155
156
  • Movement code needs to happen in modelspace’s units.

Code

The Event Loop

src/modelviewprojection/demo06.py
164while not glfw.window_should_close(window):
165    while (
166        glfw.get_time()
167        < time_at_beginning_of_previous_frame + 1.0 / TARGET_FRAMERATE
168    ):
169        pass
170    time_at_beginning_of_previous_frame = glfw.get_time()
171
172    glfw.poll_events()
173
174    width, height = glfw.get_framebuffer_size(window)
175    glViewport(0, 0, width, height)
176    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
177
178    draw_in_square_viewport()
179    handle_movement_of_paddles()

Rendering Paddle 1

src/modelviewprojection/demo06.py
183    glColor3f(*astuple(paddle1.color))
184
185    glBegin(GL_QUADS)
186    for p1_v_ms in paddle1.vertices:
187        fn: InvertibleFunction[Vector2D] = compose(
188            uniform_scale(1.0 / 10.0), translate(paddle1.position)
189        )
190        paddle1_vector_ndc: Vector2D = fn(p1_v_ms)
191        glVertex2f(paddle1_vector_ndc.x, paddle1_vector_ndc.y)
192
193    glEnd()
Paddle 1's Modelspace

Paddle 1’s Modelspace

Paddle 1's Modelspace Superimposed on World Space

Paddle 1’s Modelspace Superimposed on World Space

Paddle 1's Modelspace Superimposed on World Space

Reset coordinate system.

The coordinate system now resets back to the coordinate system specified on the left and below. Now, we must scale everything by 1/10. I have not included a picture of that here. Scaling happens relative to the origin, so the picture would be the same, just with different labels on the bottom and on the left.

Rendering Paddle 2

src/modelviewprojection/demo06.py
197    glColor3f(*astuple(paddle2.color))
198
199    glBegin(GL_QUADS)
200    for p2_v_ms in paddle2.vertices:
201        fn: InvertibleFunction[Vector2D] = compose(
202            uniform_scale(1.0 / 10.0), translate(paddle2.position)
203        )
204        paddle2_vector_ndc: Vector2D = fn(p2_v_ms)
205        glVertex2f(paddle2_vector_ndc.x, paddle2_vector_ndc.y)
206    glEnd()
Paddle 2's Modelspace

Paddle 2’s Modelspace

Paddle 2's Modelspace Superimposed on World Space

Paddle 2’s Modelspace Superimposed on World Space

Paddle 2's Modelspace Superimposed on World Space

Reset coordinate system.

The coordinate system is reset. Now scale everything by 1/10. I have not included a picture of that here. Scaling happens relative to the origin, so the picture would be the same, just with different labels on the bottom and on the left.

src/modelviewprojection/demo06.py
211    glfw.swap_buffers(window)