Sprites that scale, and a depth buffer to hide them behind
A target size in PIXELS rather than a multiplier, which is the whole of why this is usable here. A billboard at distance d wants to be k/d pixels tall, and that is a number a program has anyway - out of a lookup table, most likely. A multiplier would have to be a fixed point fraction arrived at by dividing, and this CPU cannot divide. Zero on an axis means the natural size, so every sprite written before scaling existed still means what it meant. The two axes are independent, and that shape - one tile wide at its own size, stretched to whatever height a distance says - is a wall column in a pseudo-3D game. Measured: a DDA step costs 85 cycles, so 80 columns of ray casting is about 85,000 cycles, or 12fps. Drawing those walls from the CPU instead would be 256,000 writes, fifteen frames of cycles for one frame of screen. The device doing the pixels is what makes such a game possible at all here, not merely faster. And a depth buffer, one byte a screen column at 0xD000, written by the program. A sprite with a depth draws only in the columns it is in front of. PER COLUMN, and that is the point: a billboard is nearer than the wall at one end of itself and further at the other, and no ordering of the table can say that. Table order settles sprites against each other; the buffer settles them against the scenery. Zero means no test at both ends, so a program that never writes it behaves as it did before it existed. The entry grew from 8 bytes to 16 - now, while two programs use the table, rather than once a game is written on it. Bytes 0 to 7 kept their meanings, so Sprite.asm needed no change. The pass is rewritten to walk where a sprite is GOING rather than where it came from, which is what makes a stretch and a squash one operation. It also made flipping fall out: turning the source coordinate round mirrors the tile order and the pixels inside each tile in one step, where drawing tile by tile had to be told to do both. All 111 checks passed unchanged at natural size, which is what says the rewrite changed nothing it should not. Clipping moved out of the inner loop and had to: a target size is sixteen bits, so a sprite asked to be 60,000 pixels tall would have been sixty thousand turns of a loop that drew eight rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
This commit is contained in:
co-authored by
Claude Opus 5
parent
f8c3db5d56
commit
cb898450b5
+69
-40
@@ -377,6 +377,7 @@ uint8_t videoRead(uint8_t port) {
|
||||
// nothing say so in a byte and skipping them costs one test.
|
||||
static void drawSprites(uint8_t *pixels, int width, int height) {
|
||||
const uint8_t *palette = videoAtlas + VIDEO_PALETTE_BASE;
|
||||
const uint8_t *depths = videoAtlas + VIDEO_DEPTH_BASE;
|
||||
for (int n = VIDEO_SPRITE_COUNT - 1; n >= 0; n--) {
|
||||
const uint8_t *entry = videoAtlas + VIDEO_SPRITE_BASE + n * VIDEO_SPRITE_BYTES;
|
||||
const int wide = (entry[VIDEO_SPRITE_SIZE] >> 4) & 0x0F;
|
||||
@@ -384,6 +385,15 @@ static void drawSprites(uint8_t *pixels, int width, int height) {
|
||||
if (wide == 0 || tall == 0) {
|
||||
continue;
|
||||
}
|
||||
// How big the art is, and how big it is being asked to look. Nought means the one
|
||||
// is the other, which is what every sprite written before scaling existed says.
|
||||
const int naturalWide = wide * VIDEO_CELL_PIXELS;
|
||||
const int naturalTall = tall * VIDEO_CELL_PIXELS;
|
||||
int drawWide = entry[VIDEO_SPRITE_WIDTH] | (entry[VIDEO_SPRITE_WIDTH + 1] << 8);
|
||||
int drawTall = entry[VIDEO_SPRITE_HEIGHT] | (entry[VIDEO_SPRITE_HEIGHT + 1] << 8);
|
||||
if (drawWide == 0) { drawWide = naturalWide; }
|
||||
if (drawTall == 0) { drawTall = naturalTall; }
|
||||
|
||||
// Signed, and low byte first like everything else this machine writes to a device.
|
||||
const int left = (int16_t)(uint16_t)(entry[VIDEO_SPRITE_X]
|
||||
| (entry[VIDEO_SPRITE_X + 1] << 8));
|
||||
@@ -395,52 +405,71 @@ static void drawSprites(uint8_t *pixels, int width, int height) {
|
||||
const int mirrored = (flags & VIDEO_SPRITE_HFLIP) != 0;
|
||||
const int inverted = (flags & VIDEO_SPRITE_VFLIP) != 0;
|
||||
const int behind = (flags & VIDEO_SPRITE_BEHIND) != 0;
|
||||
const uint8_t depth = entry[VIDEO_SPRITE_DEPTH];
|
||||
|
||||
for (int downTile = 0; downTile < tall; downTile++) {
|
||||
for (int acrossTile = 0; acrossTile < wide; acrossTile++) {
|
||||
// ---- Flipping moves the tiles as well as the pixels ----
|
||||
//
|
||||
// A mirrored sprite is not each of its tiles mirrored in place; the tile at
|
||||
// the left end has to come out at the right end too, or a thing made of more
|
||||
// than one tile turns inside out instead of round.
|
||||
const int readAcross = mirrored ? (wide - 1 - acrossTile) : acrossTile;
|
||||
const int readDown = inverted ? (tall - 1 - downTile) : downTile;
|
||||
// In reading order from the first, and wrapping, because a byte plus a byte
|
||||
// is a byte and the tile number is one.
|
||||
// ---- Only the part that lands on the screen is walked ----
|
||||
//
|
||||
// Clipped BEFORE the loop rather than inside it, which used to be enough and is not
|
||||
// any more: a target size is sixteen bits, so a sprite asked to be 60,000 pixels
|
||||
// tall would otherwise be sixty thousand turns of a loop that drew eight rows.
|
||||
const int fromDown = (top < 0) ? -top : 0;
|
||||
const int toDown = (drawTall < height - top) ? drawTall : height - top;
|
||||
const int fromAcross = (left < 0) ? -left : 0;
|
||||
const int toAcross = (drawWide < width - left) ? drawWide : width - left;
|
||||
|
||||
// ---- Walked over where it is GOING, not over where it came from ----
|
||||
//
|
||||
// Every destination pixel asks which source pixel it is showing, which is what makes
|
||||
// a stretch and a squash the same operation and needs no accumulator carried between
|
||||
// rows. It also makes flipping fall out: turning the source coordinate round mirrors
|
||||
// the tile ORDER and the pixels inside each tile in one step, where drawing tile by
|
||||
// tile had to be told to do both.
|
||||
for (int down = fromDown; down < toDown; down++) {
|
||||
const int atY = top + down;
|
||||
int sourceDown = (int)((long)down * naturalTall / drawTall);
|
||||
if (inverted) {
|
||||
sourceDown = naturalTall - 1 - sourceDown;
|
||||
}
|
||||
for (int across = fromAcross; across < toAcross; across++) {
|
||||
const int atX = left + across;
|
||||
int sourceAcross = (int)((long)across * naturalWide / drawWide);
|
||||
if (mirrored) {
|
||||
sourceAcross = naturalWide - 1 - sourceAcross;
|
||||
}
|
||||
// Which tile of the group, in reading order, and where inside it. Wrapping,
|
||||
// because a byte plus a byte is a byte and the tile number is one.
|
||||
const uint8_t tile = (uint8_t)(entry[VIDEO_SPRITE_TILE]
|
||||
+ readDown * wide + readAcross);
|
||||
+ (sourceDown / VIDEO_CELL_PIXELS) * wide
|
||||
+ (sourceAcross / VIDEO_CELL_PIXELS));
|
||||
const uint8_t *art = tileArt(entry[VIDEO_SPRITE_ATTRIBUTE], tile);
|
||||
|
||||
for (int y = 0; y < VIDEO_CELL_PIXELS; y++) {
|
||||
const int atY = top + downTile * VIDEO_CELL_PIXELS + y;
|
||||
if (atY < 0 || atY >= height) {
|
||||
const uint8_t pixel = art[(sourceDown % VIDEO_CELL_PIXELS) * VIDEO_CELL_PIXELS
|
||||
+ (sourceAcross % VIDEO_CELL_PIXELS)];
|
||||
// Nought is not a colour here, it is the absence of one, and it is tested
|
||||
// before the attribute is added so that it stays the same hole in all
|
||||
// sixteen schemes.
|
||||
if (pixel == 0) {
|
||||
continue;
|
||||
}
|
||||
if (behind && !backgroundEmpty[atY * width + atX]) {
|
||||
continue;
|
||||
}
|
||||
// ---- And whether anything nearer is already in this column ----
|
||||
//
|
||||
// Per column, which is the whole reason this is a buffer and not a number:
|
||||
// a billboard is in front of the wall at one end of itself and behind it at
|
||||
// the other, and no ordering of the table can say that.
|
||||
if (depth != 0) {
|
||||
const uint8_t there = depths[atX];
|
||||
if (there != 0 && depth >= there) {
|
||||
continue;
|
||||
}
|
||||
const int fromY = inverted ? (VIDEO_CELL_PIXELS - 1 - y) : y;
|
||||
for (int x = 0; x < VIDEO_CELL_PIXELS; x++) {
|
||||
const int atX = left + acrossTile * VIDEO_CELL_PIXELS + x;
|
||||
if (atX < 0 || atX >= width) {
|
||||
continue;
|
||||
}
|
||||
const int fromX = mirrored ? (VIDEO_CELL_PIXELS - 1 - x) : x;
|
||||
const uint8_t pixel = art[fromY * VIDEO_CELL_PIXELS + fromX];
|
||||
// Nought is not a colour here, it is the absence of one, and it is
|
||||
// tested before the attribute is added so that it stays the same
|
||||
// hole in all sixteen schemes.
|
||||
if (pixel == 0) {
|
||||
continue;
|
||||
}
|
||||
if (behind && !backgroundEmpty[atY * width + atX]) {
|
||||
continue;
|
||||
}
|
||||
const uint8_t index = (uint8_t)(pixel + bank);
|
||||
const uint8_t *colour = palette + index * VIDEO_PALETTE_BYTES;
|
||||
uint8_t *out = pixels + (atY * width + atX) * 3;
|
||||
out[0] = colour[0];
|
||||
out[1] = colour[1];
|
||||
out[2] = colour[2];
|
||||
}
|
||||
}
|
||||
const uint8_t index = (uint8_t)(pixel + bank);
|
||||
const uint8_t *colour = palette + index * VIDEO_PALETTE_BYTES;
|
||||
uint8_t *out = pixels + (atY * width + atX) * 3;
|
||||
out[0] = colour[0];
|
||||
out[1] = colour[1];
|
||||
out[2] = colour[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+54
-5
@@ -129,13 +129,17 @@
|
||||
// character is four tiles and a program that wants it in the background too just names the
|
||||
// same four.
|
||||
//
|
||||
// The table is 256 entries of 8 bytes. Eight so that entry n begins at n times eight, which
|
||||
// is a shift - the same no-multiply argument that makes a palette entry four bytes and a map
|
||||
// row a page. It sits above the tiles with the whole of 0x4000 to 0xBFFF still clear beneath
|
||||
// it, which is two more 16K pages of tiles if they are ever wanted.
|
||||
// The table is 256 entries of 16 bytes. Sixteen so that entry n begins at n times sixteen,
|
||||
// which is a shift - the same no-multiply argument that makes a palette entry four bytes and
|
||||
// a map row a page.
|
||||
//
|
||||
// It was eight, and grew when scaling arrived: four bytes for a target size and one for a
|
||||
// depth would not fit beside what was already there. Grown NOW rather than later, because
|
||||
// the cost of moving it is a rebuild of the two programs that use it, and the cost of moving
|
||||
// it once somebody has written a game on top of it is not.
|
||||
#define VIDEO_SPRITE_BASE 0xC000
|
||||
#define VIDEO_SPRITE_COUNT 256
|
||||
#define VIDEO_SPRITE_BYTES 8
|
||||
#define VIDEO_SPRITE_BYTES 16
|
||||
|
||||
// Byte 0 is the top left tile, byte 1 the attribute, which means what a map cell's attribute
|
||||
// means: its low nibble times sixteen is added to every index in the art.
|
||||
@@ -169,6 +173,51 @@
|
||||
// below for what "nothing" means.
|
||||
#define VIDEO_SPRITE_BEHIND 0x04
|
||||
|
||||
// ---- How big to draw it, which is not the same as how big it is ----
|
||||
//
|
||||
// Bytes 8 to 11: a target width and a target height in PIXELS, low byte first. The device
|
||||
// stretches the m by n tiles to fill that, so a program says how big the thing should look
|
||||
// and never works out a ratio.
|
||||
//
|
||||
// A TARGET IN PIXELS RATHER THAN A MULTIPLIER, which is the whole of why this is usable on
|
||||
// a machine with no divide. A billboard at distance d wants to be k/d pixels tall, and that
|
||||
// is a number the program has anyway - out of a lookup table, most likely. A multiplier
|
||||
// would have to be a fixed point fraction, computed by dividing, which is the one thing
|
||||
// this CPU cannot do.
|
||||
//
|
||||
// NOUGHT MEANS NATURAL SIZE, eight times the tile count on that axis. So every sprite
|
||||
// written before scaling existed still means what it meant, and the common case - a thing
|
||||
// drawn at the size it was drawn at - costs nothing to say.
|
||||
#define VIDEO_SPRITE_WIDTH 8
|
||||
#define VIDEO_SPRITE_HEIGHT 10
|
||||
|
||||
// ---- And how far away it is ----
|
||||
//
|
||||
// Byte 12. Nought means no depth test at all, which is what every ordinary sprite wants and
|
||||
// what a cleared table already says.
|
||||
//
|
||||
// Otherwise it is compared against the DEPTH BUFFER below, one column at a time, and the
|
||||
// sprite draws only in the columns it is in front of. That is the thing table order cannot
|
||||
// do: a billboard can be nearer than the wall in one column and further in the next, and no
|
||||
// amount of sorting the table expresses that.
|
||||
#define VIDEO_SPRITE_DEPTH 12
|
||||
// Bytes 13 to 15 are reserved and should be left at nought.
|
||||
|
||||
// ---- The depth buffer ----
|
||||
//
|
||||
// One byte a screen column, written by the program and read by the device. A wall pass says
|
||||
// how far away the thing it drew in each column was; a sprite with a depth says how far away
|
||||
// it is; and a sprite pixel is drawn only where it is nearer.
|
||||
//
|
||||
// NOUGHT IN A COLUMN MEANS NOTHING IS THERE, so a program that never writes this has a
|
||||
// buffer of noughts and every sprite draws - which is the behaviour there was before it
|
||||
// existed. It is not cleared between frames: it belongs to the program, and a program that
|
||||
// draws walls rewrites all of it every frame anyway.
|
||||
//
|
||||
// It is in the atlas because that is where the things a program sets up live, and because
|
||||
// the alternative is a port and this is 640 bytes.
|
||||
#define VIDEO_DEPTH_BASE 0xD000
|
||||
|
||||
// ---- What a sprite does not cover ----
|
||||
//
|
||||
// A PIXEL OF ZERO IS NOT DRAWN. Without that every sprite is a rectangle, and there is no
|
||||
|
||||
Reference in New Issue
Block a user