May 30, 2021

Sound Vision Demo in Python

In July 1992, the Amiga demo group Reflect released Sound Vision at the first Assembly demo competition. Having programmed a large part of the demo, which was, at the time, quite awesome (if I may say so), I wondered if I could do the same on PC with Python and pygame. The demo consists of a number of independent parts with real-time calculated graphics, so I chose just one to get to grips with the programming environment (I have no previous experience on Python). Of course, I picked the most complex part - the 3D Vector World with light sourcing. You can read about this in a previous series of blogs. Next, I did the Fractal Landscapes part - again, read more here. Then I embarked on coding the rest of the parts, and indeed, now having some practice, they were finished quite a bit quicker than the ones already mentioned.



All the code and graphics and music files can be downloaded from github. The video captures below are from the Python version at 1280x720 @60Hz.


Programming vs. Programming


Programming demos on 1992 Amiga hardware (actually released 1985) using Assembly language was quite different from using today's rather hardware-agnostic Python & pygame setup. At the time, the first thing we demo programmers usually did was to take over from the operating system and use the memory normally dedicated to OS routines for our own purposes. This was possible because all hardware setups were the same (maybe someone had more RAM but that's about it) and no drivers etc. were needed. Even if, back then, the Motorola MC68000 running at ~7.1 Mhz was great, and all you had to do was fill a screen with a resolution of 352 x 286 pixels and a maximum of 32 colors, there were quite a few obstacles to cross before getting to real time 3D graphics. Consider these:
  • There was no floating point support - integers only. And even with 32 bit registers, you could only perform multiplication on 16 bit integers (0 to 65,535, or -32,768 to 32,767 if signed). Any division result had to fit into that as well. 3D rotations employ a lot of multiplication and division, how do you avoid overflow and losing precision?
  • There were no mathematical functions available at all, apart from instructions for addition, subtraction, multiplication and division. For 3D rotations sine and cosine are essential and used a lot. The sine of any number is, by definition, between -1 and +1 - but all you have are integers anyway! How do you get the sine and cosine, perform the operations, and most importantly: how do you do it fast enough? And how do you ever get a square root if you need one? I discuss this a bit more here.
  • And of course, things like CPU cache (today's computers have more of that than the Amiga had total RAM), multiprocessing and multithreading were only in our dreams. Basically, any number to be used in, say, multiplication had to be first fetched from RAM to one of the eight data registers in the CPU.
  • The standard setup was only 512 kB of RAM, which is about 0.006 % of today's standard 8 GB.
For the Python project, I had a number of goals on how it should work. First, it should run in a window and in full screen mode - so using "Amiga style" bitplane graphics modes was probably not a good idea. Second, it should run in any resolution selected (you can set this at the end of SoundVision.py). Third, it should be "standard Python" not requiring any Cython-type supersets or any other such additions.

Sound Vision


The demo consists of a number of independent parts just like the original. The SoundVision.py program simply imports these parts as modules and runs them one after another. In addition it handles setting up the screen and music. The parts are shortly described here.

It must also be said that I only did part of the original Amiga programming, and credits for the additional code, the music and the still pictures are given in The Stars and End Credits parts.

Stripy Plane Vectors (Title texts)




The stripy plane vectors are created in a very similar manner as they were realized in the original Amiga demo. There are two planar (2D) vector objects - the letter and the stripes - and they are combined on top of each other to create the striped letter. On the Amiga, this was easy to do with two separate bitplanes; on a regular PC screen, the stripes were "subtracted" from the letter using pygame's blit command. Note the nice copperbars imitation in the background! 

Metamorphing Title Text




This was an invention - or, rather, an addition to an existing Shadebobs routine - that was able to shift from one 1-color image to another gradually, using a 32-color palette. In essence it stored all the 31 images, adding them on top of each other so that the highest number color was used when all images overlapped, and the other colors in a similar way based on the number of images overlapping. The invention was merely to also subtract the oldest image, so that instead of continuously adding images, you would only have 31 in use at any one time.

Moving and/or rotating the images slightly combined to this creates the effect. Changing from one text to another is a matter of simply defining the letters so that each letter coordinate can be interpolated to the coordinate of the new letter. I improved on the original by using a 256 color palette.

The Stars




The movable 3D stars were easy, the race was about the number of them. I think I got up to something like 650 movable 3D stars with true perspective in two bitplanes on the Amiga. Movable here means they can go sideways and up or down, but not rotate around any axis. Adding some bitplanes for the credits meant using some precious cycles for having more colors, so in the demo there are maybe 630 stars or so.

In Python, using NumPy here is quite efficient. The number of stars is 1 % of window resolution, so for a Full HD screen you get 20,760 stars. In addition, the closest ones are plotted in 2, 3 or 4 pixel size - but still, if you watch the video above, you need to enlarge it to actually see them. For plotting the stars, it would be way too slow to use a loop and setting each pixel separately. Instead, I am using pygame's surfarray, which can be operated as if it were a standard NumPy array. This way all the stars can be plotted simultaneously! I have also improved on the original code so that moving the stars works better (albeit not perfectly) and they can also be rotated around the Z-axis (the axis from viewer to distance).

Side Effect Cube




On the Amiga, this was programmed by my friend and colleague Overlander. The aim is to have a number of 3D objects; a star field, a rotating 2D star, and a 3D cube, and then project these on the sides of another, bigger 3D cube. The projection can be done in two ways: 1) rotate the "side objects", project them to a stationary big cube's sides, and the rotate the big cube including these; or 2) rotate all objects separately, and use interpolation to add the smaller objects to the big cube. The latter is faster, if perhaps slightly less accurate. I am not sure which method Overlander used, but I used the latter here.

Shadow Bobs




This effect, probably better known as Shadebobs, was something of a filler as there was really nothing new to it. It was needed, because the next part, the Globe, relied heavily on the processor to rotate a (back then) huge 3D object and that could not be achieved real time with a decent frame rate. However, shadebobs was very light on the processor and heavy on the Blitter coprocessor instead; so when the Blitter was busy creating shadebobs, the CPU was used for precalculating the Globe.

I improved the original by moving to a 256 color palette (from 32 on the Amiga). You could actually easily use even more colors, but 256 is convenient when using a NumPy array with data type uint8 (unsigned 8-bit integer), as when adding to color 255 it drops back to 0 automatically and no additional code is needed to check for that limit. Again I am using pygame's surfarray to do this and able to process hundreds of bobs per frame.

The Globe




On the Amiga, the 300 frames (pictures) in 240x240 pixels and EHB (extra half-brite, six bitplanes) would have used 12MB RAM. Of course, four bitplanes (the blue ball) are stationary, but still there was not nearly enough RAM to store the pictures, even with extra RAM as pictures needed to reside in the so-called Chip RAM. Instead, during the Shadow Bobs part, the globe was rotated and the necessary Blitter data was stored. Even this required a 512kB memory extension (so 1MB RAM in total). Then, using the precomputed Blitter data, the object was drawn fresh for each frame. Amiga's EHB mode allowed for a sixth bitplane to be used, but it had no color registers - it simply halved the color brightness. This is how I was able to show Finland separately. The blue ball in the background was an image created in a ray tracing program.

With Python I create the blue ball first. It is actually a one-color blue ball - but then I also create a shaded gray ball, which is then used to create the shading or "light source" effect using pygame's multiplicative blit operation. No precalculations are needed, the 3D land masses are rotated and drawn real time. There's also a small routine to convert a Mercator projection world map to 3D globe Cartesian coordinates. The bit that needed quite a lot of work is how to define the edges of a continent when parts of it are on the back side. On the Amiga this was actually easier!

The World




This was really the part that took the most time and effort. There's a lot happening; there's a 3D city the viewer can fly through, there are moving objects like a car, the light source is moving and the buildings are shaded accordingly, and cast shadows. Nothing is precalculated, at least in the sense that you could fly another route or give control of the flight to the viewer. On the Amiga the rotation of the city is 2D only for two reasons: 1) it would have been slower in 3D and 2) the blue "ground" is created using the Copper coprocessor, and going 3D would have meant not using this effect at all. While all the other parts run in "full frame rate" i.e. 50 frames per second (PAL Amigas had a fixed 50Hz refresh rate), this part is so complex it, at times, runs at something like 13-15 frames per second - a bit jerky, that is. I still like it, though.

The Python implementation was my first Python project - read more about it here - and now I would probably make some changes to this. In the Amiga Assembly code it was important to avoid unnecessary calculations, but it seems using Python to (for example) figure out which rotations are not required is often slower than rotating everything using a single NumPy operation. Anyway, I improved on the original by keeping the code fully 3D, so there's some Z-axis rotation as well. Also, the data on which the city, movement etc. are based, are now in a separate xml file. 

The Milky Way




This is a variation of the Metamorphing Title Text - there's a rather simple planar vector object, which is rotated, and the last 31 (on the Amiga) or 127 (Python) images are added on top of each other to form the final image. Here, instead of using a surfarray, I was able to use some clever additive and subtracting blits to generate the final true color image, due to the limited palette.

Box in a Box in a Box...




There are eight boxes rotating inside each other, within a frame. On the Amiga, I needed the frame to make the area updated smaller than full screen - otherwise it would not have worked at 50 fps in five bitplanes. Even if there are only eight boxes with three different color sides showing each, i.e. 24 colors, the opacity of each box must be taken into account as well. The solution, in bitplane graphics, was simple (if I recall correctly): the area covered by each box was drawn using three bitplanes (that is, using eight colors - one for each box), to give each box its color. Then, each box had two sides drawn in bitplane number four and two in bitplane number five, so that they added bits 01, 10 and 11 to the total color, separating the sides. This was rather efficient as drawing filled polygons on the Amiga was achieved by drawing the vertical lines (one pixel per horizontal line), and then invoking a Blitter fill operation. The fill operation worked in an XOR fashion so that it always switched on/off when it encountered a new pixel on a single horizontal line. Thus, I could draw all the edges of the boxes, and use just one fill operation to achieve the result, where the color always changes if the outer box edge crosses the box's surface.

With Python, I could have used a surfarray again, but instead there's rather simply just an alpha value in use. The biggest box is drawn directly on the screen, but the next ones are copied so that about one third of the existing image remains and about two thirds (the alpha value) come from the new, smaller box, creating the effect of seeing through the outer boxes. There are some limitations on how this works, and I had to kind of "work backwards" from the colors I needed to define the colors to be actually used, as they blend with the previously drawn boxes' colors.

Fractal Landscapes




This is one of favorite routines, and I have written more about it here. As a fractal it is very simple, but I was able to calculate it really fast considering Amiga resources, and I still like the zoomer (try it with the mouse) a lot. You can actually zoom into it infinitely. I improved the routine on Python by adding controls on the resolution of the grid.

Raytracing




Raytracing was performed by Overlander on an Amiga ray tracing program Real3D. It only consists of ten images - remember memory and disk space were scarce resources - and, if I remember correctly, those ten images took 37 hours to calculate on his Amiga 500. All I did here was that I ran the original demo in a Amiga emulator (WinUAE) and snipped the ten images from the screen... and added a simple scroll text to get the original feel. The Reflect logo was drawn by Nighthawk.

End Credits




Not much happening here, there was some space left on the original Amiga 880 kB floppy disk, so this was added as a finishing touch. The disk was quite fully used and we actually used our own track loader (coded by Overlander) - no file system, but actually giving commands to the disk drive: move the magnetic head one step further, read a sector, etc... you probably would not do that with modern day computers any more.

The funny thing is that, despite requiring a huge amount of operating system code, the files for the Python implementation take more space, about 1.7 MB, although only 268 kB of that is Python source code and 1.0 MB and 289 kB are taken by the still graphics and music, respectively.

Thank you for reading.

No comments:

Post a Comment