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:
Anachronaut
2026-09-02 14:07:38 -04:00
co-authored by Claude Opus 5
parent f8c3db5d56
commit cb898450b5
6 changed files with 288 additions and 54 deletions
+133 -4
View File
@@ -149,12 +149,15 @@ pokeAtlasRun() {
for (( i = 0; i < $3; i++ )); do printf ' OUTA 0xE9\n'; done
}
# One entry of the sprite table, which is eight bytes at 0xC000 plus eight times its number.
# X and Y are signed and go in low byte first, so a negative one is written as its two's
# complement here rather than being worked out at every call.
# One entry of the sprite table, which is sixteen bytes at 0xC000 plus sixteen times its
# number. X and Y are signed and go in low byte first, so a negative one is written as its
# two's complement here rather than being worked out at every call.
#
# The last eight bytes are left alone, and mean what a cleared table means: natural size and
# no depth test. spriteSize below is what fills them in.
spriteAt() {
# spriteAt <n> <tile> <attribute> <x> <y> <size> <flags>
local base=$(( 0xC000 + $1 * 8 ))
local base=$(( 0xC000 + $1 * 16 ))
local x=$(( $4 & 0xFFFF ))
local y=$(( $5 & 0xFFFF ))
pokeAtlas "$base" "$2"
@@ -167,6 +170,25 @@ spriteAt() {
pokeAtlas "$(( base + 7 ))" "$7"
}
# How big a sprite is to be drawn, in pixels, and how far away it is. Nought either way means
# what nought means to the device: natural size, and no depth test.
spriteSize() {
# spriteSize <n> <width> <height> [depth]
local base=$(( 0xC000 + $1 * 16 ))
pokeAtlas "$(( base + 8 ))" "$(( $2 & 0xFF ))"
pokeAtlas "$(( base + 9 ))" "$(( ($2 >> 8) & 0xFF ))"
pokeAtlas "$(( base + 10 ))" "$(( $3 & 0xFF ))"
pokeAtlas "$(( base + 11 ))" "$(( ($3 >> 8) & 0xFF ))"
pokeAtlas "$(( base + 12 ))" "$(( ${4:-0} & 0xFF ))"
}
# One column of the depth buffer, which is one byte a screen column at 0xD000. Nought means
# nothing is in that column, which is what a program that never writes it says everywhere.
depthAt() {
# depthAt <column> <depth>
pokeAtlas "$(( 0xD000 + $1 ))" "$2"
}
# Every pixel of one tile the same index. Tile n begins at n times 64.
solidTile() {
# solidTile <tile> <index>
@@ -616,6 +638,113 @@ echo "Checking what the video device draws."
&& result ok "a sprite has pages too" "tile one of page one, not of page nought" \
|| result no "a sprite has pages too" "got $(pixel spritepage 16 24)"
# ---- Bigger and smaller than it is ----
#
# The same one tile sprite drawn at 16 by 16 and at 4 by 4. What is checked is where it stops
# as much as where it starts: a stretch that got the ratio right and the extent wrong would
# put the right colour in the right corner and run off the end.
{ prologue
spriteColours
solidTile 1 0x01
spriteAt 0 1 0x00 16 24 0x11 0x00
spriteSize 0 16 16
spriteAt 1 1 0x00 64 24 0x11 0x00
spriteSize 1 4 4
epilogue
} | run spritescale || exit 1
[ "$(pixel spritescale 31 39)" = "255,0,0" ] && [ "$(pixel spritescale 32 39)" = "0,0,0" ] \
&& result ok "a sprite can be drawn bigger" "sixteen pixels of it, and not seventeen" \
|| result no "a sprite can be drawn bigger" "at 31 $(pixel spritescale 31 39), at 32 $(pixel spritescale 32 39)"
[ "$(pixel spritescale 67 27)" = "255,0,0" ] && [ "$(pixel spritescale 68 27)" = "0,0,0" ] \
&& result ok "and smaller" "four pixels of it, and not five" \
|| result no "and smaller" "at 67 $(pixel spritescale 67 27), at 68 $(pixel spritescale 68 27)"
# The axes are separate, which is the shape a wall column in a pseudo-3D game is: one tile
# wide at its own size, and stretched to whatever height the distance says.
{ prologue
spriteColours
solidTile 1 0x01
spriteAt 0 1 0x00 16 24 0x11 0x00
spriteSize 0 8 64
epilogue
} | run spritecolumn || exit 1
[ "$(pixel spritecolumn 23 87)" = "255,0,0" ] && [ "$(pixel spritecolumn 24 87)" = "0,0,0" ] \
&& [ "$(pixel spritecolumn 16 88)" = "0,0,0" ] \
&& result ok "the two axes scale apart" "eight wide and sixty four tall" \
|| result no "the two axes scale apart" "$(pixel spritecolumn 23 87) $(pixel spritecolumn 24 87) $(pixel spritecolumn 16 88)"
# Stretched art still comes out of the right tile. Two tiles across, drawn at four times the
# width: the halves have to stay halves rather than one of them winning.
{ prologue
spriteColours
solidTile 4 0x01; solidTile 5 0x02
spriteAt 0 4 0x00 16 24 0x21 0x00
spriteSize 0 64 8
epilogue
} | run spritewide || exit 1
[ "$(pixel spritewide 16 24)" = "255,0,0" ] && [ "$(pixel spritewide 47 24)" = "255,0,0" ] \
&& [ "$(pixel spritewide 48 24)" = "0,255,0" ] && [ "$(pixel spritewide 79 24)" = "0,255,0" ] \
&& result ok "a stretched group keeps its tiles" "each half of it is half of the result" \
|| result no "a stretched group keeps its tiles" "$(pixel spritewide 16 24) $(pixel spritewide 47 24) $(pixel spritewide 48 24) $(pixel spritewide 79 24)"
# ---- How far away it is ----
#
# The depth buffer is a byte a column. Half of this sprite has a nearer wall in front of it
# and half has a further one, which is the case NO ORDERING OF THE TABLE CAN EXPRESS - and
# the whole reason the buffer is per column rather than one number per sprite.
{ prologue
spriteColours
solidTile 1 0x01
spriteAt 0 1 0x00 16 24 0x11 0x00
spriteSize 0 0 0 0x05
for i in 16 17 18 19; do depthAt $i 0x03; done
for i in 20 21 22 23; do depthAt $i 0x09; done
epilogue
} | run spritedepth || exit 1
[ "$(pixel spritedepth 16 24)" = "0,0,0" ] && [ "$(pixel spritedepth 20 24)" = "255,0,0" ] \
&& result ok "a nearer column hides a sprite" "hidden in four columns and drawn in four" \
|| result no "a nearer column hides a sprite" "near $(pixel spritedepth 16 24), far $(pixel spritedepth 20 24)"
# A depth of nought is no depth test at all, which is what a cleared table says and what
# every sprite that is not in a pseudo-3D scene wants. The same buffer, the same wall.
{ prologue
spriteColours
solidTile 1 0x01
spriteAt 0 1 0x00 16 24 0x11 0x00
for i in 16 17 18 19; do depthAt $i 0x03; done
epilogue
} | run spritenodepth || exit 1
[ "$(pixel spritenodepth 16 24)" = "255,0,0" ] \
&& result ok "and a depth of nought never asks" "drawn straight over the nearer column" \
|| result no "and a depth of nought never asks" "got $(pixel spritenodepth 16 24)"
# And a column with nothing in it does not hide anything, which is what makes a buffer nobody
# has written the same as no buffer at all.
{ prologue
spriteColours
solidTile 1 0x01
spriteAt 0 1 0x00 16 24 0x11 0x00
spriteSize 0 0 0 0x05
epilogue
} | run spriteemptydepth || exit 1
[ "$(pixel spriteemptydepth 16 24)" = "255,0,0" ] \
&& result ok "an empty column hides nothing" "a buffer nobody wrote is no buffer" \
|| result no "an empty column hides nothing" "got $(pixel spriteemptydepth 16 24)"
# A target size is sixteen bits and the screen is not. Asking for sixty thousand pixels must
# draw the part that fits and take no longer than that part deserves - the loop is clipped
# before it runs rather than inside it.
{ prologue
spriteColours
solidTile 1 0x01
spriteAt 0 1 0x00 0 0 0x11 0x00
spriteSize 0 60000 60000
epilogue
} | run spritehuge || exit 1
[ "$(pixel spritehuge 0 0)" = "255,0,0" ] && [ "$(pixel spritehuge 319 199)" = "255,0,0" ] \
&& result ok "a size past the screen is clipped" "it filled the screen and stopped there" \
|| result no "a size past the screen is clipped" "$(pixel spritehuge 0 0) $(pixel spritehuge 319 199)"
# ---- Two screens, and the flip between them ----
#
# One red cell in each screen, in different rows: row one of the screen being shown, and the