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.
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.
There are three sources of movement:
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, when viewed from the side:
[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] . . . # -->
On screen, the projectile would appear to gradually creep upward, because that's what perspective projection does to objects as they move directly away from the viewer.
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, but those disappear quickly enough that small errors will go unnoticed.
To evaluate the behavior 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 velocity 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.
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:
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:
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.
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:
(Details TBD; math tables at $1000, $ac00, and $b500 require further examination. Tables in C++ form here.)
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.
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.
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:
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:
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.
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.
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.
This is a walk through the code when a projectile is fired. The code is traced in two scenarios to show how things change:
515a:90 00
.Other objects are ignored.
81fc
: start of main loop...8223
: draw status text, check for game-over conditions,
handle demo and time portal flight, check for misc keys, etc.84f0
: read joystick axes and buttons.860d
: check keyboard for spacebar.862b
: spacebar or both buttons hit.
Check to see if we have room in the object and element tables.8643
: set the "linked object index" to $7f,
indicating that it's linked to the player, then call CreateObject
to create an instance of object class 8.760f
CreateObject: set up pointers, generate a
random number. The AND mask for a player projectile is zero,
so we always use the first entry in the shape list.7670
: confirm we have space in object/element
tables for the shape. (The projectile only has one element, so the
previous test is sufficient.) Grab the "next object slot" index
and verify that it's actually empty. Add the new object
as the head of the object list.769a
: copy data from the shape header to the
object tables.7711
: set initial position, based on shape header +$07.
Player projectiles use $ff, and have their position set to (0,0,0).
These values are just held in local variables for now.792a
: finish object initialization, copying some
additional fields and zeroing out others. Add 2 to the initial
Z coordinate value.79fb
: do some player-projectile-specific init. In
particular, the Z movement is increased by the adjusted forward
speed. When speed=300, we add $1e (300/10).7a5c
: grab the "next element slot" index from $68
and verify that it's actually empty. Hook the new slot into the list.7a8b
: copy elements into element tables. Get the
left/top/right/bottom coordinates from the shape definition and offset
with the initial position computed earlier.
Player projectiles have only one element, defined as a rect
Z=128 L=-64 T=-64 R=64 B=64. The object is assigned an initial
position of xyz=(0,0,2). Final position of element 0:
zltrb=[$0082,ffc0,ffc0,0040,0040].86f5
: do general stuff with speed, fuel, and time portals.
Update the current region if it's time to switch.8a7e
: spawn new stars / ships / bases if appropriate.
Draw status text.8c2d
: start of object update loop...8c65
: call UpdateObject.5f66
: init flags, check object
lifetime counter (not used for projectiles), see if object is alive
and mobile. Projectiles use state 1, which indicates they move but
don't change direction (enemy ships can steer).608d
: TODO8c83
: start of element update loop...8c8b
: call UpdateElement.6455
: TODO8c8e
: element is a rect, but the mod type is $83 because
it hasn't been drawn yet, so we skip the call to EraseRect and jump
straight to DrawElement.8e52
: DrawElement: decide if we're drawing this
element or not.8e72
: rotate colors.8eb3
: check element type. Jump to DrawRect.90a6
: DrawRect: draw pixels on the screen.9216
: element drawing done. There are no additional
elements, so jump to NextObject.922a
: if none of the elements were visible on screen,
delete the object. If it crossed the "near" plane, check for a collision
with the player. Fly-through of time portals and friendly bases is
handled here, as is collision with enemy objects.94eb
: check to see if the player projectile has collided
with an enemy ship or base.95c3
: if we hit something, destroy it and get points.9655
: bottom of object update loop. If there are no more
objects to process, jump to top of main loop.second iteration:
third iteration:
Copyright 2020 by Andy McFadden