Files
TeslaRel410/emulator/firmware-decomp/shade_render.py
T
CydandClaude Opus 4.8 32ac5ca9b8 Shaded frame: Gouraud raster of the i860's projected geometry
Captures the object cap7's death-camera view draws (4 VSTRIP strips, 45
verts) with its per-vertex normals straight off the emulated i860, and
shades it in software with a barycentric z-buffered fill (shade_render.py).
This is our rasteriser showing the firmware's geometry lit by the firmware's
own normals -- not the board's bit-serial Pixel-Planes array (that stays the
Tier-1 build). cap7-geometry.json is the portable capture; render-readout.html
is the published readout with the shaded frame as its hero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:19:43 -05:00

123 lines
5.7 KiB
Python

"""Shaded software rasteriser for geometry captured off the emulated i860.
Input: a JSON file of triangle strips, each vertex = [screenX, screenY, nx, ny, nz, modelZ]
(as produced by capture_wireframe.py / vfull dumps). Screen xy and the per-vertex
normal come straight out of the firmware's own VSTRIP transform; modelZ is the depth.
Output: a Gouraud-shaded PPM plus an ASCII preview. This is OUR rasteriser (a plain
barycentric fill with a z-buffer) — it is NOT the board's bit-serial Pixel-Planes
array. It exists to *show* the geometry the firmware projected, lit by the normals
the firmware computed, without needing the full IGC micro-code simulator.
Usage: python shade_render.py geometry.json [out.ppm]
"""
import sys, json, math
# The object recovered from cap7's death-camera frame (4 strips, 45 verts), embedded so
# the tool runs with no external capture. Regenerate by dumping vfull.pkl to this format.
DEFAULT_GEO = [
[[237.5,62,0.931,0.262,0.131,-8],[235.2,46.5,0.922,0.187,0.094,-6],[238,31,0.933,0.278,0.139,-8],[236.6,18.6,0.928,0.231,0.116,-6]],
[[240.9,43.4,0.945,0.372,0.186,-8],[238.9,6.2,0.937,0.307,0.154,-6],[237.5,31,0.931,0.26,0.13,-8],[238,9.3,0.933,0.278,0.139,-6],[237.1,62,0.93,0.249,0.124,-8],[242.2,21.7,0.95,0.415,0.208,-6],[238.4,37.2,0.935,0.291,0.146,-8],[242.3,15.5,0.95,0.417,0.209,-6],[237.6,31,0.932,0.263,0.431,-8],[246.5,0,0.966,0.554,0.377,-6],[242.8,31,0.952,0.943,0.517,-8],[246.9,12.4,0.968,0.957,0.685,-6],[241.7,40.3,0.948,0.98,0.65,-8],[236.3,0,0.927,0.936,0.678,-6],[253.7,62,0.995,0.246,0.123,-4],[252.1,43.4,0.988,0.324,0.162,-4]],
[[245.1,15.5,0.961,0.384,0.192,-4],[239.9,40.3,0.941,0.341,0.171,-4],[238.5,93,0.935,0.446,0.223,-4],[234.4,62,0.919,0.643,0.216,-4],[239.1,0,0.938,0.645,0.424,-4],[231.9,0,0.909,0.951,0.539,-4],[240.7,0,0.944,0.984,0.685,-4],[233.5,40.3,0.916,0.263,0.132,-2],[254.6,24.8,0.998,0.415,0.208,-2],[243.2,0,0.954,0.461,0.231,-2],[233.8,12.4,0.917,0.431,0.215,-2],[243.2,15.5,0.954,0.461,0.231,-2],[254.3,0,0.997,0.614,0.207,-2],[250.4,9.3,0.982,0.64,0.501,-2],[249.6,12.4,0.979,0.94,0.58,-2],[245.7,0,0.964,0.939,0.723,-2]],
[[126.1,62,0.494,0.914,0.206,0],[238.8,49.6,0.937,0.447,0.223,0],[239.1,62,0.938,0.448,0.224,0],[250.4,40.3,0.982,0.402,0.201,0],[234.1,31,0.918,0.432,0.216,0],[250.4,43.4,0.982,0.402,0.201,0],[231.3,46.5,0.907,0.639,0.37,0],[254.9,37.2,0.999,0.916,0.508,0],[252.7,34.1,0.991,0.926,0.663,0]],
]
LIGHT = (0.35, -0.55, 0.75) # upper-front key light
SCALE = 7 # px per screen unit
PAD = 6 # border in screen units
def _n(a, b, c):
m = math.sqrt(a * a + b * b + c * c) or 1.0
return a / m, b / m, c / m
def intensity(v):
n = _n(v[2], v[3], v[4])
L = _n(*LIGHT)
d = n[0] * L[0] + n[1] * L[1] + n[2] * L[2]
return max(0.18, min(1.0, 0.22 + 0.85 * abs(d))) # two-sided (abs) + ambient
def render(geo):
tris = []
for strip in geo:
for i in range(len(strip) - 2):
tris.append((strip[i], strip[i + 1], strip[i + 2]))
allv = [v for s in geo for v in s]
body = [v for v in allv if v[0] > 200] or allv # frame on body, ignore far outlier
mnx = min(v[0] for v in body); mxx = max(v[0] for v in body)
mny = min(v[1] for v in body); mxy = max(v[1] for v in body)
W = int(mxx - mnx) + 2 * PAD; H = int(mxy - mny) + 2 * PAD
IW, IH = W * SCALE, H * SCALE
fb = [[0.0] * IW for _ in range(IH)]
zb = [[-1e9] * IW for _ in range(IH)]
def SX(v): return (v[0] - mnx + PAD) * SCALE
def SY(v): return (v[1] - mny + PAD) * SCALE
for a, b, c in tris:
ax, ay, bx, by, cx, cy = SX(a), SY(a), SX(b), SY(b), SX(c), SY(c)
ia, ib, ic = intensity(a), intensity(b), intensity(c)
za, zbv, zc = a[5], b[5], c[5]
area = (bx - ax) * (cy - ay) - (cx - ax) * (by - ay)
if abs(area) < 1e-6:
continue
x0 = max(0, int(min(ax, bx, cx))); x1 = min(IW - 1, int(max(ax, bx, cx)))
y0 = max(0, int(min(ay, by, cy))); y1 = min(IH - 1, int(max(ay, by, cy)))
for py in range(y0, y1 + 1):
for px in range(x0, x1 + 1):
w0 = ((bx - px) * (cy - py) - (cx - px) * (by - py)) / area
w1 = ((cx - px) * (ay - py) - (ax - px) * (cy - py)) / area
w2 = 1 - w0 - w1
if w0 < -0.002 or w1 < -0.002 or w2 < -0.002:
continue
z = w0 * za + w1 * zbv + w2 * zc
if z > zb[py][px]:
zb[py][px] = z
fb[py][px] = w0 * ia + w1 * ib + w2 * ic
return fb, IW, IH
def write_ppm(fb, IW, IH, path):
img = bytearray(IW * IH * 3)
for y in range(IH):
for x in range(IW):
v = fb[y][x]
if v > 0:
r = int(min(255, 28 + v * 168)); g = int(min(255, 50 + v * 205)); b = int(min(255, 54 + v * 122))
else:
r, g, b = 7, 11, 17
o = (y * IW + x) * 3
img[o], img[o + 1], img[o + 2] = r, g, b
with open(path, 'wb') as f:
f.write(b'P6\n%d %d\n255\n' % (IW, IH))
f.write(bytes(img))
def ascii_preview(fb, IW, IH):
ramp = " .:-=+*#%@"
out = []
for ry in range(0, IH, SCALE * 2):
line = " "
for rx in range(0, IW, SCALE):
v = fb[ry][rx]
line += ramp[min(9, int(v * 9.99))] if v > 0 else ' '
out.append(line)
return "\n".join(out)
def main():
geo = DEFAULT_GEO
if len(sys.argv) > 1 and not sys.argv[1].endswith('.ppm'):
geo = json.load(open(sys.argv[1]))
out = next((a for a in sys.argv[1:] if a.endswith('.ppm')), 'shade.ppm')
fb, IW, IH = render(geo)
print(ascii_preview(fb, IW, IH))
write_ppm(fb, IW, IH, out)
print("\nwrote %s (%dx%d)" % (out, IW, IH))
if __name__ == '__main__':
main()