(back to project page)

Epoch Graphics Engine

The graphics may be a bit flickery, with a lot of color clashing when things overlap, but the pixel fill rate is outstanding.

The per-object overhead is fairly high, especially for objects that move. You can observe this by slowing the movement rate of a player projectile to a crawl (515a:20 00) and tapping the space bar rapidly. Performance with two dozen projectiles in flight is rather poor; compare it to the very good performance of the title demo (19-rectangle Epoch logo and 8 stars), and space region 5 (1 ship and up to 32 stars).

Even so, the cost of moving and projecting objects is lower than it is in games like Elite or Stellar 7, because Epoch takes a few shortcuts.

General

Epoch uses a left-handed three-dimensional coordinate system, with the viewer at (0,0,0), +X to the right, +Y downward, and +Z away (into the screen). The viewer is always at (0,0,0), looking directly down the Z axis with +Y upward, so object coordinates are always in eye (camera) space. The view area is treated as square, using a perspective projection with a 45-degree FOV, so an object at (100,100,100) would be at the bottom-right corner of the screen, as would an object at (200,200,200).

The "far" plane is at Z=+32767, so X/Y are limited to (-32767,+32767). Anything that moves outside the view frustum is discarded. If it moves past the near plane at Z=0, player collision tests are applied.

All math, including object movement and perspective projection, is done with simple arithmetic and about 4KB of lookup tables.

The points, lines, and rectangles that make up an object are always drawn parallel to the viewer.

The game uses the full hi-res screen, which is 280 pixels wide. That's more than will fit in a single byte, so screen X coordinates are expressed as a signed value in the range [-139,139] (one byte holds the magnitude, another byte holds the sign). Y coordinates are simply [0,191]. Because the screen itself is not square, rendered element widths are effectively multiplied by (280/192)=~1.46 (assuming ideal projection).

The objects page explains how objects are defined. The most important thing to know is that each object is defined as a set of elements, which may be points, horizontal lines, vertical lines, or filled rectangles in different colors.

Movement

There are three sources of movement:

  1. Projectiles, enemy ships, and explosion chunks have nonzero movement vectors. (Stars, bases, and time portals don't move on their own.)
  2. The player can move forward.
  3. The player can turn.

If the coordinates were maintained in world space, player rotation would be something applied at the very end, right before projection. Because they're always in eye space, every object's position and motion vector must be updated when the player rotates.

To see why this is, consider what happens when a player projectile ('#') is fired:

  [player] ...# -->

The projectile is moving directly away from the player, with a velocity vector (0,0,N). If the player instantly pitched up 45 degrees, the projectile should be at the bottom of the screen, moving down and away:

  [player] .
            .
             .
              #

                \
                 v

This clearly requires updating the object's position, using a rotation equation that keeps it at a constant distance. We also need to update the movement vector, because if we don't, the projectile will move away as if it were fired from a position below the player, rather than being fired by the player:

  [player] .
            .
             .
              # -->

The effects of player rotation on movement are absent or minimal for most things, because the object is either motionless or moving relatively slowly. The main exception is player projectiles, which disappear quickly enough that small errors will go unnoticed.

To see this in the game, reduce the player projectile velocity, fire a shot, then pitch the camera so the projectile is at the edge of the screen and check the new vector. In one test, the vector changed from xyz=[$0000,0000,0090] to [$0000,0064,0072], which isn't perfect (speed of 151 vs. 144) but is pretty close, and the projectile doesn't appear to rise or fall as it recedes into the distance.

One potential area of difficulty is that the game is always dealing with movement deltas. When pitching upward in the earlier experiment, the game updated the vector each frame based on the joystick offset in that frame. This is different from a conventional world-space approach, in which the rotations are based on absolute position and orientation. The reason deltas can be problematic is that, if the math is imprecise, small errors can add up over time.

Double-Plane Quirk

The 100-point alien ship is defined as two planes, one behind the other. The rear plane is defined last, which means it's drawn on top of the front plane, which causes some erasure artifacts. But there's a far stranger quirk.

If you disable the ship's forward movement in its object definition (4aa2:00 00), and apply the game tweaks mentioned on the main page to create a region of space with one alien ship and nothing else, you can fly right up to the ship and examine it. As you get close, the difference in Z-depth between the two planes starts to expose the limitations of the coordinate accuracy.

Because shapes are defined without a common center point, the planes will start to move independently. If you get fairly close and move the crosshairs around for a bit, you can see the pieces start to separate:

drift-before drift-after

If you get really close, the rear part (which you can identify by the lack of a flashing dot in the middle) will actually start to wander off:

drift-active

In other games that scale with distance, such as Elite or Stellar 7, this doesn't happen because the position is determined by a point at the center of the object. Epoch applies position adjustment to the element coordinates individually, which works fine so long as all points are at the same Z depth. In practice this effect isn't really noticeable when playing the game because the enemy ship will fly past before it has time for the two parts to drift noticeably.

Movement Implementation

There are two parts, object movement and element movement.

Epoch doesn't store a position for the object, so the object move routine is only responsible for updating the object movement vectors. For enemy ships this is done periodically, to give the ships a little more life than a homing drone (which they mostly feel like anyway). For everything else, this is only done when the joystick causes the view angles to change. This change must be applied to the object's movement vector, for the reasons discussed earlier.

The element movement code updates each element's position (defined as left/top and possibly right and/or bottom) with the object's motion vector, adds the player's forward motion, and applies the joystick angles to the positions. The latter isn't quite right, for a couple of reasons:

  1. Changes to the joystick position don't change the Z coordinates. So rotating something from the center to the side makes it farther away. However, the projection scaling is based purely on Z distance, so things don't actually get smaller as they move away from the center of the screen.
  2. All elements remain parallel to the screen. Elements should appear to rotate so they face toward the viewer as the viewer rotates, not slide sideways. This doesn't feel off because it's just Epoch's visual style. (FWIW, Doom did the same thing with equipment and corpses.)

(Details TBD; math tables at $1000, $ac00, and $b500 require further examination. Tables in C++ form here.)

Drawing and Erasing

The basic redraw loop looks like this:

Pretty straightforward, notable only in that we erase and redraw each element before moving on to the next, which means that we'll create some visual artifacts when an object is in motion if the new position of an element overlaps the old position of a later element. Doing it this way is important to reduce flicker when not using page-flipping. Erasing the entire object before redrawing it would increase the amount of time that the pixels are black, making it more likely that the screen refresh would show a blank space.

Every element can have one or two of the six hi-res colors assigned. If two colors are assigned, the renderer will alternate between them at a rate specified within the element. As an optimization, black elements are erased, but not explicitly drawn. This can cause some interesting effects when elements overlap.

Unrolled Loops

The fastest way to draw a rectangle on the hi-res screen is to draw it in vertical stripes. Part of this is because, with 7 pixels per byte (more or less), the bit patterns for odd columns and even columns are different. By drawing columns, we reduce the number of times we have to switch color patterns.

But the most important reason is that we can use something like this:

    sta   $2000,y
    sta   $2400,y
    sta   $2800,y
    sta   $2c00,y
    sta   $3000,y
    sta   $3400,y
    sta   $3800,y
    sta   $3c00,y
    ...

This is faster than doing the same operation in a loop, because we don't have to update the loop counter or execute a branch instruction. Call this with the value to store in the A-reg and the column in the Y-reg, and we can write bytes to the screen about as fast as can be done on a 6502 (5 cycles per byte).

If we want to blend with existing pixels rather than overwrite them, we can do something like this:

    txa
    ora   $2000,y
    sta   $2000,y
    txa
    ora   $2400,y
    sta   $2400,y
    txa
    ora   $2800,y
    sta   $2800,y
    ...

This time we pass the value to blend in the X-reg. For this we have to spend 11 cycles per byte.

This is a great way to write all 192 lines, but what if we want to write to fewer? If you set up the instructions so they modify the screen rows in order, from top to bottom, you can JSR into the part of the function that writes the top line, after overwriting the instruction after the last line with an RTS. When you're done with the entire rectangle, you restore the last instruction to its original value. We can keep the address at which each line starts in a lookup table.

Epoch uses this approach to draw filled rectangles and vertical lines. Points and horizontal lines use a different routine.

Performance vs. Artifacts

For points, lines, and narrow rectangles, drawing and erasing is done with bitwise AND and OR operations, merging the bits with the contents of the screen. For rectangles that are at least 4 bytes wide, we use STA instructions instead. (Oddly, there is no limit on height, but in this game rectangles are generally as wide as they are tall.) The advantage of STA is that it's more than twice as fast. The disadvantage of STA is we no longer blend the pixels at the edges of the byte, so adjacent pixels may be set to black.

To see the effects of individual-element erase/draw, and STA vs. ORA/AND, it's useful to look at the Epoch logo that zooms in on the title screen. If you disable the use of STA by setting 8d95:28 and 9133:28, the logo looks much better:

title-orig title-mod

However, the frame rate drops a little as the logo gets larger. We're drawing lots of relatively small rectangles, so a comparatively high proportion of the cycles are spent on on per-rect overhead rather than pixel writes, so the effect is not too dramatic for this shape. (If you move right up to a friendly base, so it fills most of the screen, the impact is more noticeable.)

The green fringe happens because the code that inverts the color mask for erasure uses $ff instead of $7f, flipping the high bit. You can fix this with 8d8c:7f 8e24:7f 8e41:7f (this may affect the way colors distort elsewhere).

The shape itself can be improved. The middle bar in the 'H' looks notched even without the use of STA because it's defined incorrectly in the shape. You can fix that with 48d7:80. The right side of the 'P' doesn't extend all the way down; fix with 481d:20. The remaining notches are the result of erasing, e.g. the vertical lines in 'P'/'O'/'C' are redrawn before the horizontal parts, so there's a gap at the inside top/bottom where the previous horizontal bars were erased before the new ones were drawn. The problem is magnified because the horizontal parts are wide enough to be erased with STA, so it clears the rect and potentially additional pixels to the left and right. This effect isn't visible on the 'E' because, being near the edge of the screen, the vertical part is moving left fast enough to outrun the erasure of the horizontal parts. We can reduce the "notchiness" in 'P' and 'C' if the horizontal parts are extended to overlap the vertical lines: 482b:00 ff 483b:00 ff 4897:00 48a7:00. (It might be enough to change the order of the elements so that the vertical portions are drawn last, because they're narrow enough to be drawn with OR/AND operations and won't trample as much of the horizontal parts when erased.)

Putting it all together:

title-fixes

The Mysterious Middle Window

If you look at the definition for a friendly base, you'll see that the main body is a large green rect, and there are three windows with flashing colors in the middle:

    .bulk $80,$40,$00,$03 ...  ;left
    .bulk $80,$30,$00,$02 ...  ;middle
    .bulk $80,$10,$00,$01 ...  ;right

The window on the left is a little wider. All three flash colors, at different rates. The left window alternates orange/black, the middle window alternates purple/black, and the right window alternates white and black.

friendly-base

The white fringing on the purple window is a result of putting green and purple pixels next to each other, which result in adjacent '1' bits, which the Apple II hi-res screen outputs as white.

Curiously, the middle window doesn't follow the expected pattern. Instead of being black for 3 frames and purple for 3 frames, it actually shows purple, white, white, green, black, black. It's still a 6-frame sequence, but the colors are sometimes wrong. The reason for this has to do with the way rectangles are drawn and erased, and the way colors are represented on the hi-res screen.

Let's examine 6 consecutive frames, starting with the erasure of the first white frame and the drawing of the second. Each frame begins by drawing the body of the shape as a green rectangle with STA, so we can count on the window pixels to be green initially. The window is small enough to be drawn and erased with OR and AND operations.

  1. Current color is purple. Erase window, ANDing all purple pixels to zero. Because green is 00101010, and purple is 01010101, this has no effect. Draw window, ORing all purple pixels to one; this sets the color to 01111111. Result: white.
  2. Current color is black. Erase window, ANDing all purple pixels to zero. Again, this has no effect. We don't draw black pixels, so we skip drawing this frame. Result: green.
  3. Current color is black. Erase window, ANDing all pixels to zero, because the color mask for black is the same as the color mask for white. Don't draw the black rect. Result: black.
  4. Current color is black. Same as previous frame. Result: black.
  5. Current color is purple. Erase window, ANDing all pixels to zero. Draw window, ORing all purple pixels to one. Result: purple.
  6. Current color is purple. Erase window, ANDing all purple pixels to zero, which has no effect. Draw window, ORing all purple pixels to one; this sets the color to 01111111. Result: white.

As you can see, the strange color sequence is a result of the way colors are erased and blended. You don't see this happening in the left window because orange and green occupy the same bits, differing only in whether the high bit is set. If you change it to blue, with $4ffa:50, it also gets pretty weird. The right window is using white/black and so is fully erasing the window every time. If you get really close to the base, the windows become wide enough that they're drawn with STA, so you get the expected black/purple color sequence (albeit with black borders around the purple where the STA tramples nearby pixels).

While using a color mask to erase points and vertical lines is beneficial, it's of dubious value when erasing a rectangle. If a purple ship flies in front of a green base, their colors will merge to form white unless one of them is big enough to cause STA to be used (even if they're drawn from back to front). In practice such blending events are rare. Using the AND/OR operations on the left and right edges is important for blending with nearby objects, but erasing everything with a white mask would avoid the odd behavior.

The Life of a Player Projectile

This is a walk through the code when a projectile is fired. The code is traced in two scenarios to show how things change:

  1. No movement, but projectile speed reduced from $0800 to $0090 with 515a:90 00.
  2. Regular projectile speed, joystick pressed fully down and right with turn rate set to 1, forward speed set to 300.

Other objects are ignored.

second iteration:

third iteration:


Copyright 2020 by Andy McFadden