Input your search keywords and press Enter.

Epoch Graphics Engine

(back to project page)
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:
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.
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:

  • For each object:
    • For each element in the object:
      • Erase element from previous position
      • Draw element in new position

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:
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:
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.
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.

  • 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.
    • 760fCreateObject: 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.
    • UpdateObject: movement values at entry:
      1. xyz=[$0000,0000,0090]
      2. xyz=[$0000,0000,081e]
    • 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: TODO
    • movement values at exit:
      1. xyz=(unchanged)
      2. xyz=[$ffe9,ffe9,081e]
  • 8c83: start of element update loop…
  • 8c8b: call UpdateElement.
    • UpdateElement: position values at start:
      1. zltrb=[$0082,ffc0,ffc0,0040,0040]
      2. zltrb=[$0082,ffc0,ffc0,0040,0040]
    • 6455: TODO
    • position values at exit:
      1. zltrb=[$0112,ffc0,ffc0,0040,0040], screen ltrb=[$20,4a,20,76]={-32,74,32,118}
      2. zltrb=[$0882,ff90,ff90,0010,0010], screen ltrb=[$07,5b,01,60]={-7,91,1,96}
  • 8c8e: 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:

  • UpdateObject: movement values at exit:
    1. xyz=(unchanged)
    2. xyz=[$ffd1,ffe4,081e]
  • UpdateElement: position values at exit:
    1. zltrb=[$01a2,ffc0,ffc0,0040,0040], screen ltrb=[$15,52,15,6e]={-21,82,21,110}
    2. zltrb=[$1082,ff2f,ff69,ffaf,ffe9], screen ltrb=[$06,5d,02,60]={-6,93,-2,96}

third iteration:

  • UpdateObject: movement values at exit:
    1. xyz=(unchanged)
    2. xyz=[$ffb9,ffdf,081e]
  • UpdateElement: position values at exit:
    1. zltrb=[$0232,ffc0,ffc0,0040,0040], screen ltrb=[$10,55,10,6b]={-16,85,16,107}
    2. zltrb=[$1882,fe9f,ff39,ff1f,ffb9], screen ltrb=[$08,5d,05,60]={-8,93,-5,96}

Copyright 2020 by Andy McFaddenread more

Leave a Reply

Your email address will not be published. Required fields are marked *