Apr 25, 2020

Real-Time 3D with Python Part VIII: Finishing Touches

In the introduction I explained the idea: Using Python to program old skool demo style real-time vector graphics. In Part I I set up a simple 3D wireframe object, rotated it, and drew it on the screen, and in Part II added surfaces and perspective. Part III was about calculating object surface visibility and shading. Part IV set up an XML reader to define the objects and other properties using a common file format (XML), and added the code needed to work with multiple objects. In Part V, I added two special features: the ground and roads. Part VI calculated and drew shadows, and Part VII added moving in the cityscape and moving objects. This final part finishes the project with a number of small finishing touches.

The program code and XML files for all parts can be downloaded from github. Note that the full code is not found below - to run this, use github code.


Add Some Finishing


To polish the surface and round some corners, I am adding a number of smaller pieces to finish the project nicely:
  • full screen mode
  • music
  • color blends for shadows and ground
  • an info display for program resource use
  • fade in and fade out
I will go through these each below.

For a Fuller Look


So far we have been running the program in a window. A more demo-ish look would of course be full screen, and this is easy to set up with Pygame. I have also set it up so that full screen mode may be entered and exited with key f as follows:

key_to_function = {
    pygame.K_ESCAPE: (lambda x: x.terminate()),         # ESC key to quit
    pygame.K_SPACE:  (lambda x: x.pause()),             # SPACE to pause
    pygame.K_f:      (lambda x: x.toggleFullScreen()),  # f to switch between windowed and full screen display
    pygame.K_i:      (lambda x: x.toggleInfoDisplay())  # i to toggle info display on/off
    }

    def toggleFullScreen(self):
        
        # switch between a windowed display and full screen
        if self.fullScreen == True:
            self.fullScreen = False
            self.screen = pygame.display.set_mode((self.width,self.height))
        else:
            self.fullScreen = True
            self.screen_blends = pygame.display.set_mode((self.width,self.height), pygame.FULLSCREEN | pygame.DOUBLEBUF | pygame.HWSURFACE)

        self.screen_blends = self.screen.copy()
        self.screen_shadows = self.screen.copy()
        self.screen_blends.fill((255,255,255))
        self.screen_shadows.fill((255,255,255))

If switching to full screen, the same screen is set up but with some specific flags making it take up the whole display, but with original resolution. For best results, resolution should obviously be selected such that it is supported in full screen. The new support screens for blends and shadows need to be initialised (copied) as well (more on these below).

Play It Again, Sam


A demo needs some music. I have no talent whatsoever on that front, but the original tunes of the 1992 Amiga demo (see introduction) can be found in some Amiga archives like Janeway. I found it surprising that the Amiga music modules, made with NoiseTracker or ProTracker etc., can be played straight out with Pygame. Note I am not including this module in github, use the link above to download it and take a note of the credits for it! The composer Jellybean is a Norwegian musician who made some great Amiga tunes for our demos. The code needed here is simple:

    music_file = "sinking2.mod"  # this mod by Jellybean is available at e.g. http://janeway.exotica.org.uk/release.php?id=45536
    vv = VectorViewer(disp_size[0], disp_size[1], music_file, font_size)

        # start music player
        pygame.mixer.music.load(self.music_file)
        pygame.mixer.music.play()

The part after the blank line is in vv.Run just before the main loop - I am just telling pygame to start playing.

Blending in


So far, the shadows have been one color irrespective of where they fall (the blue ground or the gray road). Similarly, although the ground blends to the horizon, the roads do not - they are an even gray. In the Amiga and its bitplane graphics, this was easily solved, using the Copper to change the ground and road colors dynamically line by line - although that was strictly restricted to Y axis rotation only (see Part V). In pygame, I can use a blit operation with blend to add, subtract, or multiply one screen (image) to another.

            # draw shadows always first for all visible objects
            self.screen_shadows.lock()
            shadow_rect = None
            for VectorObj in (vobj for vobj in self.VectorObjs if vobj.visible == 1 and vobj.prio == prio_nr and vobj.shadow == 1):
                node_list = self.cropEdges(VectorObj.shadowTransNodes)                
                rect = self.drawPolygon(self.screen_shadows, self.shadowColor, node_list, 0)
                if shadow_rect is None:
                    shadow_rect = rect
                else:
                    shadow_rect = shadow_rect.union(rect)
            while self.screen_shadows.get_locked():
                self.screen_shadows.unlock()
            if shadow_rect is not None:
                # blit the "multiplied" screen copy to add shadows on main screen
                # release any locks on screen
                while self.screen.get_locked():
                    self.screen.unlock()
                self.screen.blit(self.screen_shadows, shadow_rect, shadow_rect, pygame.BLEND_MULT)
                # clear the surface copy after use back to "all white"
                self.screen_shadows.fill((255,255,255), shadow_rect)

When drawing the shadows, I am using a "drawing board" image screen_shadows. It has been prefilled with all white, and the shadowColor is now a light gray. I have also modified the drawPolygon to return the (rectangular) area it has modified, and am using union to build the minimum size rectangular area holding all the shadows. This is then blit (ie. copied) to the actual image using BLEND_MULT, which in effect multiplies the colors of the actual image with the colors of the shadow image. As the shadow image background is all white, the multiplier is 100% for all colors red, green and blue, so there's no effect. The shadows are light gray, so the multiplier is less (I am using 140 of 255, ca. 55 %) so those areas appear darker. If the actual image has a gray road, a shadow falling on it will be a darker shade of gray; if the image has blue ground, a shadow falling on it will be a darker shade of blue. In the end the area used for shadows is filled again with all white, to be ready for the next frame. All the shadows are processed in one blit; this is more efficient and also avoids overlapping shadows being darker still.

For the ground, I am using the same technique, but somewhat modified. In the first phase, I am drawing the ground (in blue) and the roads (in gray) in one solid color. Then, I will blit on top of these an image, which has darker shades of gray at the horizon and lighter shades of gray close to the viewer, with the same color-multiplying blend method. This will cause the far away parts of these surfaces to blend nicely to the horizon (ie. towards black). The blend image is actually drawn at the same time as the ground, but it "waits" until the roads have been processed, and only the is used in the blit.

Information Overload

Why is my program so slow? It is nice, from a code optimization point of view, to know what takes time and what goes quickly in the program. I added an info display, which can be toggled on or off (see the first code box above). This plots information on the relative processing time taken by some of the parts or operations on the screen, and includes fps (frames per second) and some other data points. Behind are some data collected by calling measureTime below with a predefined timer_name:   

    def measureTime(self, timer_name):
        # add time elapsed from previous call to selected timer
        i = self.timer_names.index(timer_name)
        new_time = pygame.time.get_ticks()
        self.timers[i, self.timer_frame] += (new_time - self.millisecs)
        self.millisecs = new_time
        
    def nextTimeFrame(self):
        # move to next timer and clear data
        self.timer_frame += 1
        if self.timer_frame >= self.timer_avg_frames:
            self.timer_frame = 0
        self.timers[:, self.timer_frame] = 0

It stores the time elapsed from previous call to an array, which can then be used to calculate a moving average of a selected number (I am using 180) of frames. Then I am using code below to add these to the display image:

        # add measured times as percentage of total
        tot_time = np.sum(self.timers)
        if tot_time > 0:
            for i in range(len(self.timer_names)):
                info_msg = (self.timer_names[i] + ' '*16)[:16] + (' '*10 + str(round(np.sum(self.timers[i,:]) * 100 / tot_time, 1)))[-7:]
                self.screen.blit(self.font.render(info_msg, False, [255,255,255]), (10, 110 + i * 20))

Note that I am using blit again, but without any blend functionality, to add the text on top of the cityscape. (And yes, I know the text formatting used here is not very elegant.)

Fading In, Fading Out


In the Amiga demo scene, decent productions always nicely faded in from black and ended in a same way by fading out. I added a fade based on viewer movement as follows:

                # check for fade at start and end
                if loop_pos < 255 / self.fadeSpeed:
                    self.fade = (loop_pos * self.fadeSpeed) / 255
                elif loop_pos > VectorMovement.loopEnd - 255 / self.fadeSpeed:
                    self.fade = ((VectorMovement.loopEnd - loop_pos) * self.fadeSpeed) / 255
                else:
                    self.fade = 1.0

The fade is simply a multiplier between 0 and 1 and used to multiply the color components, causing the desired effect.

And finally. The end result. Feel free to compare to the original (see introduction).

Finally, Some Thinking


What could be improved? Certainly, a lot. This was my first Python project and learning by doing is a sure method to not find the ultimately best solutions. I am sure there are a multitude of ways to make this program run faster / more clear or elegant / more pythonic / more versatile etc. Some thoughts I have had during the project include the following.

Parallelism / multi-threading. While on the Amiga parallelism was restricted to simultaneous use of the CPU and the co-processors, modern computers have multiple processor cores and could divide the CPU load to several threads being processed in parallel. Maybe I will try this in some future project.

OpenGL. Would using it instead of pygame standard drawing methods make a difference? Would it be difficult? Or some other way of using more hardware support (read: graphics card) instead of using the CPU - that would definitely speed things up.

Adding features. There's a lot one could add, of course, although in 1992 on the Amiga this was really something, and already quite a stretch for its capabilities (although, clever programmers certainly made improvements after that as well). But probably adding bitmaps / texture to the walls and shadows falling on buildings could be done with Python power. Of course one could have a larger city, and user controlled steering, and add some game elements to it...

Thanks for reading.

No comments:

Post a Comment