Lambda Stack - Demo 16

Objective

Remove repetition in the coordinate transformations, as previous demos had very similar transformations, especially from camera space to NDC space. Each edge of the graph of objects should only be specified once per frame.

Demo 12

Full Cayley graph.

Noticing in the previous demos that the lower parts of the transformations have a common pattern, we can create a stack of functions for later application. Before drawing geometry, we add any functions to the top of the stack, apply all of our functions in the stack to our modelspace data to get NDC data, and before we return to the parent node, we pop the functions we added off of the stack, to ensure that we return the stack to the state that the parent node gave us.

To explain in more detail —

What’s the difference between drawing paddle 1 and the square?

Here is paddle 1 code

src/modelviewprojection/demo14.py
236    glColor3f(*astuple(paddle1.color))
237    glBegin(GL_QUADS)
238    for p1_v_ms in paddle1.vertices:
239        ms_to_ndc: InvertibleFunction[Vector3D] = compose(
240            # camera space to NDC
241            uniform_scale(1.0 / 10.0),
242            # world space to camera space
243            inverse(translate(camera.position_ws)),
244            # model space to world space
245            compose(translate(paddle1.position), rotate_z(paddle1.rotation)),
246        )
247
248        paddle1_vector_ndc: Vector3D = ms_to_ndc(p1_v_ms)
249        glVertex3f(
250            paddle1_vector_ndc.x, paddle1_vector_ndc.y, paddle1_vector_ndc.z
251        )
252    glEnd()

Here is the square’s code:

src/modelviewprojection/demo14.py
256    # draw square
257    glColor3f(0.0, 0.0, 1.0)
258    glBegin(GL_QUADS)
259    for ms in square:
260        ms_to_ndc: InvertibleFunction[Vector3D] = compose(
261            # camera space to NDC
262            uniform_scale(1.0 / 10.0),
263            # world space to camera space
264            inverse(translate(camera.position_ws)),
265            # model space to world space
266            compose(translate(paddle1.position), rotate_z(paddle1.rotation)),
267            # square space to paddle 1 space
268            compose(
269                translate(Vector3D(x=0.0, y=0.0, z=-1.0)),
270                rotate_z(rotation_around_paddle1),
271                translate(Vector3D(x=2.0, y=0.0, z=0.0)),
272                rotate_z(square_rotation),
273            ),
274        )
275        square_vector_ndc: Vector3D = ms_to_ndc(ms)
276        glVertex3f(
277            square_vector_ndc.x, square_vector_ndc.y, square_vector_ndc.z
278        )
279    glEnd()

The only difference is the square’s modelspace to paddle1 space. Everything else is exactly the same. In a graphics program, because the scene is a hierarchy of relative objects, it is unwise to put this much repetition in the transformation sequence. Especially if we might change how the camera operates, or from perspective to ortho. It would required a lot of code changes. And I don’t like reading from the bottom of the code up. Code doesn’t execute that way. I want to read from top to bottom.

When reading the transformation sequences in the previous demos from top down the transformation at the top is applied first, the transformation at the bottom is applied last, with the intermediate results method-chained together. (look up above for a reminder)

With a function stack, the function at the top of the stack (f5) is applied first, the result of this is then given as input to f4 (second on the stack), all the way down to f1, which was the first fn to be placed on the stack, and as such, the last to be applied. (Last In First Applied - LIFA)

             |-------------------|
(MODELSPACE) |                   |
  (x,y,z)->  |       f5          |--
             |-------------------| |
                                   |
          -------------------------
          |
          |  |-------------------|
          |  |                   |
           ->|       f4          |--
             |-------------------| |
                                   |
          -------------------------
          |
          |  |-------------------|
          |  |                   |
           ->|       f3          |--
             |-------------------| |
                                   |
          -------------------------
          |
          |  |-------------------|
          |  |                   |
           ->|       f2          |--
             |-------------------| |
                                   |
          -------------------------
          |
          |  |-------------------|
          |  |                   |
           ->|       f1          |-->  (x,y,z) NDC
             |-------------------|

So, in order to ensure that the functions in a stack will execute in the same order as all of the previous demos, they need to be pushed onto the stack in reverse order.

This means that from modelspace to world space, we can now read the transformations FROM TOP TO BOTTOM!!!! SUCCESS!

Then, to draw the square relative to paddle one, those six transformations will already be on the stack, therefore only push the differences, and then apply the stack to the paddle’s modelspace data.

How to Execute

Load src/modelviewprojection/demo16.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

d

Increase Left Paddle’s Rotation

a

Decrease Left Paddle’s Rotation

l

Increase Right Paddle’s Rotation

j

Decrease Right Paddle’s Rotation

UP

Move the camera up, moving the objects down

DOWN

Move the camera down, moving the objects up

LEFT

Move the camera left, moving the objects right

RIGHT

Move the camera right, moving the objects left

q

Rotate the square around its center

e

Rotate the square around paddle 1’s center

Description

Function stack. Internally it has a list, where index 0 is the bottom of the stack. In python we can store any object as a variable, and we will be storing functions which transform a vector to another vector, through the “modelspace_to_ndc” method.

src/modelviewprojection/mathutils3d
368@dataclass
369class FunctionStack:
370    stack: List[InvertibleFunction[Vector3D]] = field(
371        default_factory=lambda: []
372    )
373
374    def push(self, o: object):
375        self.stack.append(o)
376
377    def pop(self):
378        return self.stack.pop()
379
380    def clear(self):
381        self.stack.clear()
382
383    def modelspace_to_ndc_fn(self) -> InvertibleFunction[Vector3D]:
384        return compose(*self.stack)
385
386
387fn_stack = FunctionStack()

Define four functions, which we will compose on the stack.

Push identity onto the stack, which will will never pop off of the stack.

tests/test_mathutils3d.py
175def test_fn_stack():
176    def identity(x):
177        return x
178
179    fn_stack.push(identity)
180    assert 1 == fn_stack.modelspace_to_ndc_fn()(1)
181
182    def add_one(x):
183        return x + 1
184
185    fn_stack.push(add_one)
186    assert 2 == fn_stack.modelspace_to_ndc_fn()(1)  # x + 1 = 2
187
188    def multiply_by_2(x):
189        return x * 2
190
191    fn_stack.push(multiply_by_2)  # (x * 2) + 1 = 3
192    assert 3 == fn_stack.modelspace_to_ndc_fn()(1)
193
194    def add_5(x):
195        return x + 5
196
197    fn_stack.push(add_5)  # ((x + 5) * 2) + 1 = 13
198    assert 13 == fn_stack.modelspace_to_ndc_fn()(1)
199
200    fn_stack.pop()
201    assert 3 == fn_stack.modelspace_to_ndc_fn()(1)  # (x * 2) + 1 = 3
202
203    fn_stack.pop()
204    assert 2 == fn_stack.modelspace_to_ndc_fn()(1)  # x + 1 = 2
205
206    fn_stack.pop()
207    assert 1 == fn_stack.modelspace_to_ndc_fn()(1)  # x = 1

Event Loop

src/modelviewprojection/demo16.py
217while not glfw.window_should_close(window):
...

In previous demos, camera_space_to_ndc_space_fn was always the last function called in the method chained pipeline. Put it on the bottom of the stack, by pushing it first, so that “modelspace_to_ndc” calls this function last. Each subsequent push will add a new function to the top of the stack.

\[\vec{f}_{c}^{ndc}\]
Demo 12
src/modelviewprojection/demo16.py
237    # camera space to NDC
238    fn_stack.push(uniform_scale(1.0 / 10.0))

Unlike in previous demos in which we read the transformations from modelspace to world space backwards; this time because the transformations are on a stack, the fns on the model stack can be read forwards, where each operation translates/rotates/scales the current space

The camera’s position and orientation are defined relative to world space like so, read top to bottom:

\[\vec{f}_{c}^{w}\]
Demo 12

But, since we need to transform world-space to camera space, they must be inverted by reversing the order, and negating the arguments

Therefore the transformations to put the world space into camera space are.

\[\vec{f}_{w}^{c}\]
Demo 12
src/modelviewprojection/demo16.py
242    # world space to camera space
243    fn_stack.push(inverse(translate(camera.position_ws)))

draw paddle 1

Unlike in previous demos in which we read the transformations from modelspace to world space backwards; because the transformations are on a stack, the fns on the model stack can be read forwards, where each operation translates/rotates/scales the current space

\[\vec{f}_{p1}^{w}\]
Demo 12
src/modelviewprojection/demo16.py
247    # paddle 1 model space to world space
248    fn_stack.push(
249        compose(translate(paddle1.position), rotate_z(paddle1.rotation))
250    )

for each of the modelspace coordinates, apply all of the procedures on the stack from top to bottom this results in coordinate data in NDC space, which we can pass to glVertex3f

src/modelviewprojection/demo16.py
254    glColor3f(*astuple(paddle1.color))
255    glBegin(GL_QUADS)
256    for p1_v_ms in paddle1.vertices:
257        paddle1_vector_ndc = fn_stack.modelspace_to_ndc_fn()(p1_v_ms)
258        glVertex3f(
259            paddle1_vector_ndc.x,
260            paddle1_vector_ndc.y,
261            paddle1_vector_ndc.z,
262        )
263    glEnd()

draw the square

since the modelstack is already in paddle1’s space, and since the blue square is defined relative to paddle1, just add the transformations relative to it before the blue square is drawn. Draw the square, and then remove these 4 transformations from the stack (done below)

\[\vec{f}_{s}^{p1}\]
Demo 12
src/modelviewprojection/demo16.py
267    fn_stack.push(
268        compose(
269            translate(Vector3D(x=0.0, y=0.0, z=-1.0)),
270            rotate_z(rotation_around_paddle1),
271            translate(Vector3D(x=2.0, y=0.0, z=0.0)),
272            rotate_z(square_rotation),
273        )
274    )
src/modelviewprojection/demo16.py
277    glColor3f(0.0, 0.0, 1.0)
278    glBegin(GL_QUADS)
279    for ms in square:
280        square_vector_ndc = fn_stack.modelspace_to_ndc_fn()(ms)
281        glVertex3f(
282            square_vector_ndc.x,
283            square_vector_ndc.y,
284            square_vector_ndc.z,
285        )
286    glEnd()

Now we need to remove fns from the stack so that the lambda stack will convert from world space to NDC. This will allow us to just add the transformations from world space to paddle2 space on the stack.

src/modelviewprojection/demo16.py
290    fn_stack.pop()  # pop off square space to paddle 1 space
291    # current space is paddle 1 space
292    fn_stack.pop()  # # pop off paddle 1 model space to world space
293    # current space is world space

since paddle2’s modelspace is independent of paddle 1’s space, only leave the view and projection fns (1) - (4)

draw paddle 2

\[\vec{f}_{p2}^{w}\]
Demo 12
src/modelviewprojection/demo16.py
297    fn_stack.push(
298        compose(translate(paddle2.position), rotate_z(paddle2.rotation))
299    )
src/modelviewprojection/demo16.py
303    # draw paddle 2
304    glColor3f(*astuple(paddle2.color))
305    glBegin(GL_QUADS)
306    for p2_v_ms in paddle2.vertices:
307        paddle2_vector_ndc = fn_stack.modelspace_to_ndc_fn()(p2_v_ms)
308        glVertex3f(
309            paddle2_vector_ndc.x,
310            paddle2_vector_ndc.y,
311            paddle2_vector_ndc.z,
312        )
313    glEnd()

remove all fns from the function stack, as the next frame will set them clear makes the list empty, as the list (stack) will be repopulated the next iteration of the event loop.

src/modelviewprojection/demo16.py
317    # done rendering everything for this frame, just go ahead and clear all functions
318    # off of the stack, back to NDC as current space
319    fn_stack.clear()
320

Swap buffers and execute another iteration of the event loop

Notice in the above code, adding functions to the stack is creating a shared context for transformations, and before we call “glVertex3f”, we always call “modelspace_to_ndc” on the modelspace vector. In Demo 19, we will be using OpenGL 2.1 matrix stacks. Although we don’t have the code for the OpenGL driver, given that you’ll see that we pass modelspace data directly to “glVertex3f”, it should be clear that the OpenGL implementation must fetch the modelspace to NDC transformations from the ModelView and Projection matrix stacks.