Wireframe 3D viewer. Spins a cube by default and opens STL files from the SD card.
Its real job is to be a load generator for the GPU line rasterizer
(rtl/gpu/gpu_raster.v): hundreds of clipped line commands per frame,
from a process that is simultaneously servicing wm messages and
reading a file. The FPS readout in the window's top-left corner is
the point of the app -- it is the number that says whether a change
to the rasterizer, the blitter, the cache or the CPU made anything
faster.
| Input | Action |
|---|---|
| click on the object | stop spinning, begin rotating |
| drag | rotate (horizontal = Y axis, vertical = X axis) |
| Ctrl + drag | rotate without needing to hit the object |
| Shift + move | scale (away from you = bigger); no button needed |
| Space | toggle spin |
r |
reset rotation and scale |
s |
toggle dithering shader (experimental) |
e |
switch erase mode (interleaved / clear) |
o, or the titlebar open icon |
open an STL file |
+ / - |
scale in steps |
| Alt | suppresses all of the above |
Alt is deliberately inert: Alt+arrows is wm's own window-move
gesture (wm.c's alt_move_focused()), and an app that also acted on
Alt would be fighting the window manager for one gesture.
Clicking the object both stops the spin and starts a rotation, because that is one intention rather than two -- clicking a spinning object is how a user says "hold still, I want to look at this".
Ctrl exists because the bounding-box hit test is not always a convenient target: a heavily decimated model is mostly empty space, and a scaled-down one is small. Ctrl means "I mean this window, not that pixel".
Z_WM_MOUSE carries x, y, buttons and an inside flag. It does not
carry modifiers, and it cannot be made to without changing the packed
layout that exists precisely to avoid allocating a Z_MAP at
pointer-movement rates.
Nor can modifier state be tracked from Z_WM_KEY: wm drops bare
modifier changes outright (if (keysym == Z_KEY_NONE) continue; in
dispatch_keys()), because a modifier press has no keysym of its own.
An app watching only Z_WM_KEY learns Shift is held on the next
Shift+letter and never otherwise -- no use at all for "hold Shift and
move the mouse".
So kbd_mods() reads the live modifier byte from the USB HID report
register, which is level state maintained in hardware
(rtl/usb_hid.v) and always current. It picks the port by asking
which one reports itself a keyboard, exactly as wm.c's own
mouse_port() picks the mouse -- there is no fixed port-to-device
mapping, so assuming port 0 is wrong about half the time.
This is read-only level state that no other process can be confused
by, unlike the rasterizer registers -- whose shared mutable state is
why this app no longer touches them and goes through
z_win_hw_line() instead.
The original added a fixed number of degrees per frame. That couples rotation speed to frame rate: the cube spun far too fast (hundreds of frames per second at 5 degrees each is many revolutions a second), and any heavier model would have silently slowed the rotation as the frame rate dropped.
The fix is not simply "add rate * dt / TICK_HZ each frame" either,
and this is worth knowing because it looks correct and is not. A frame
takes 1-2 ticks (Z_TICK_HZ is 732), so that division truncates to
zero most frames -- at 5 degrees/second the Z axis would never
move, and the other two would run at whatever rate the truncation
left. The error gets worse the faster the app runs.
So the angle is computed from total accumulated spinning time
(spin_ticks), with manual rotation kept as a separate offset. One
division against a large number instead of one per frame against a
tiny one: no truncation, no drift. Verified exact at 1, 5 and 10
seconds against 11/17/5 degrees per second.
There is no double buffer and there cannot be one without a
gateware change. VRAM is 9600 words (rtl/mem/vram.v), which is
exactly 640*480/32 — one frame, nothing left over. A back buffer
needs another 9600 words of BRAM, plus a base-address register in
gpu_video.v, plus retargeting the rasterizer and blitter, plus a
flip at vblank. That is the right long-term answer and it is a real
project.
Without it, each frame must remove the last one in place. Two ways:
ERASE_CLEAR: one blitter fill of the content area, then redraw. Cheapest possible in commands — one fill regardless of edge count.ERASE_INTERLEAVED(default): for each edge, redraw the previous frame's version in colour 0 and immediately draw the new one. Costs one extra line command per edge.
ERASE_CLEAR was the default for anything over ~96 edges and it
flickers badly. The phase breakdown shows why: at 290 edges the
draw phase is ~31ms of a ~41ms frame, so the window is blank or
half-drawn for roughly three quarters of every frame, at ~24Hz —
squarely in the band the eye is most sensitive to. Raw frame rate was
never the problem; duty cycle was.
Interleaved erase never blanks anything. At any instant exactly one edge is missing, for the ~100us it takes to issue two line commands. It roughly halves the frame rate, which the eye wins easily against a 75% blank duty cycle.
Known artifact. Where two edges cross, erasing this frame's old edge punches a one-pixel hole in a new edge already drawn this frame — a 1bpp framebuffer cannot know a pixel is owed to two lines. The hole is repaired next frame, so crossings shimmer slightly. That is the price of not double-buffering, and a much smaller one than the whole object flashing.
Press e to switch modes on the running system. The choice is a
judgement about how something looks, which the numbers cannot
settle.
Drawn last, it was on screen only from the end of one frame until the clear early in the next — about 10ms of a 41ms frame, so it flashed at ~24Hz despite costing under a millisecond to draw. It is now drawn immediately after the erase, so it is present for essentially the whole frame. The cost is that edges drawn afterwards can cross over it; in the top-left corner of a centred projection that is rare, and a stable readout with an occasional line through it beats a correct one that strobes.
See stl.c's own header comment for the full design. The summary:
Nothing is ever held whole. teapot.stl is ~400KB; main memory is
1MB total and an app's malloc heap is 16KB
(Z_PROC_STACK_SIZE_DEFAULT -- that tier is stack and heap,
together, for the process's whole life). fs_mallocfile(), which
every other file-reading app here uses, would need one 400KB
allocation. The file is instead pulled through a 4KB window with
fs_read_chunk(), and what is kept is a decimated model bounded by
MODEL_MAX_VERTS/MODEL_MAX_EDGES.
STL_CHUNK was one 512-byte SD sector and is now 4KB, after a 400KB
teapot took ~60 seconds to load. Every chunk costs a syscall plus a
pump callback; at 512 bytes over ~1MB of reads that was ~2400 of each,
against ~300 at 4KB. The per-byte SPI cost is unchanged — what goes
away is the fixed cost paid per chunk. The ceiling on this is the pump
interval, not memory: 4KB is roughly 4ms of SD time, two orders of
magnitude clear of wm's REDRAW_ACK_TIMEOUT, so there is headroom to
go further if the load report says I/O still dominates. The ceiling on what can be opened
is the SD card, not RAM.
Decimation is vertex clustering, not "every Nth triangle." The stride approach is the first thing anyone reaches for and it does not work: adjacent STL triangles share vertices exactly, so keeping every 6th one keeps a scattering that shares almost nothing. The vertex count does not drop to a sixth, and the result looks like confetti in the shape of a teapot. Clustering (Rossignac-Borrel) snaps vertices to a grid, collapses each cell to one vertex, drops degenerate triangles and dedupes edges -- yielding a genuine low-resolution wireframe of the whole object. The edge dedup alone is a 2x frame-rate win, since a closed mesh otherwise draws every shared edge twice.
The grid resolution is estimated, not laddered, and the
extrapolation is deliberately not linear. Occupied cells scale with
the square of grid resolution, and an overflow tells you the pool
filled after a known fraction f of the file. The tempting
extrapolation — "it filled after a fraction f, so it wanted 1/f
times the budget" — is badly wrong, because occupied cells
saturate: early triangles each land in a fresh cell, later ones
mostly hit cells already taken. Linear extrapolation over-predicts and
the correction overshoots.
The two honest bounds are fully saturated (final = what we already
have) and fully linear (final = budget/f). Their geometric mean,
budget/sqrt(f), is what's used, which — since count goes as grid
squared — means scaling the grid by the fourth root of f. Two
integer square roots, no floating point.
This is measurable and was measured. On the 384-edge budget, the linear model produced 104 edges from the binary teapot (27% of budget, three quarters of the affordable detail discarded); the fourth-root model produces 267, and 382/384 on the ASCII one. Failed attempts stay cheap — the pass aborts the moment the pool fills — so total I/O is about 2.5x the file across all passes.
Format detection is arithmetic, not the solid prefix. A binary
STL's size is exactly 84 + 50*count. Several common exporters write
binary files whose 80-byte header begins with the ASCII text
solid ..., and such a file fed to an ASCII parser yields nothing at
all. The size test gets these right; the prefix test does not.
No 64-bit division in the hot loops. Two separate instances of
the same mistake, both worth several seconds or milliseconds:
cell_key() divided an int64_t by the cell size nine times per
triangle (three axes, three vertices) on every build pass — ~85,000
__divdi3 calls per pass on a 9438-triangle teapot. It is now a
32-bit divide, safe by construction: both terms are fixed_t and
their difference is bounded by the axis extent, which is itself a
fixed_t. Verified identical over 200k sampled coordinate/grid
combinations.
One hardware divide per vertex, not two software ones.
project() used to call fixed_div() twice per vertex, and
fixed_div computes ((int64_t)a << 12) / b — rv32im has no 64-bit
divide, so each was a call into libgcc's __divdi3, several hundred
cycles. It now computes a Q18 reciprocal 2^30 / zo instead: a
constant numerator that fits a uint32, so it is a single hardware
DIV (~32 cycles on picorv32's sequential divider), once per vertex.
The multiply stays 64-bit and that is fine — rv32im has a widening
multiply (mul/mulh), so a 64-bit product is two instructions. Only
division falls off the hardware. Verified against the old path over
396k sampled points: worst case 1px, mean 0.002px.
fixed_div() is retained for model_normalize(), where the int64
numerator is genuinely needed (raw STL coordinates can be in the
thousands) and which runs once per load rather than once per vertex
per frame.
No floats anywhere. This target is rv32im -- no F extension.
Touching a float pulls newlib's soft-float support and, for the ASCII
path, strtod and most of stdio's conversion machinery, into an image
where every byte of .rodata is a byte of the 1MB pool for the
process's lifetime. f32_to_fixed() takes the IEEE bit pattern apart
by hand and parse_fixed() parses ASCII decimals directly into Q12;
both are a few dozen integer instructions and add nothing to the link.
Clustering needs the bounding box before it can quantize, and the box is only known after reading every vertex -- so this makes more than one pass over the file (two normally, occasionally three). On 400KB that is seconds, not milliseconds.
That makes stl_load()'s pump callback mandatory, not politeness.
wm blocks waiting for a redraw ack, and an app that stops acking
freezes the whole screen until REDRAW_ACK_TIMEOUT fires (see
docs/window_manager.md, "content z-order"). A multi-second load is
far more than long enough to hit that. load_pump() drains the queue
every 512-byte chunk.
paint_full() must not render the model while loading is set: the
pump runs mid-build, so nverts/nedges and the arrays they index
are inconsistent at that moment. This is also why the loader's hash
tables are not unioned with the projection arrays to save 7KB --
that saving turns into a corrupted model the first time a frame is
drawn during a load, which the pump makes possible.
Z_WIN_FLAG_OPEN_ICON needed no wm changes -- the flag,
Z_WM_TITLEBAR_ICON and Z_WM_TBICON_OPEN already existed, and
wm.c already draws and hit-tests the icon. gpu3d sets the flag and
handles the message, as sw/apps/text does.
The close icon is set without Z_WIN_FLAG_CLOSE_KILLS_OWNER. The
old version could safely use the killing form because it had no
dialogs; this one owns a second window whenever the file dialog is
open, and the killing form takes every window of a pid down the
instant any one of them is clicked closed.
The title shows the model name, prefixed with ~ when the model was
decimated to fit -- without that, a teapot visibly coarser than its
source file looks like a parsing bug rather than a stated tradeoff.
gpu3d also honours Z_WM_SET_ARG, so the file browser can launch it
with an STL directly.
Once a second, gpu3d prints a frame breakdown to the serial console:
gpu3d: 16.42 fps frame 60.9ms 2924 kcyc CPI 5.8
gpu3d: xform 4.1ms 6% erase 5.0ms 8% draw 41.2ms 67% text 1.1ms 1% other 9.5ms 15%
gpu3d: 382 edges/frame 107.8us/edge 135 verts
and after every load:
gpu3d: loaded /teapot.stl
gpu3d: ascii, 1589 tris, grid 9 -> 135 verts 382 edges
gpu3d: 4 passes, 974 KB read, 8.1s total (io 6.2s 76%, cpu 1.9s)
gpu3d: read rate 157 KB/s
These answer different questions and are worth reading differently.
The frame breakdown splits time by phase because the phases have
unrelated fixes: xform is arithmetic, draw is uncached MMIO,
erase is one blitter command, text is glyphs. other is the
message loop, the spin update, loop overhead — and time this process
did not get. A large other is the signature of preemption, not of
slow drawing.
us/edge is the number to watch when changing MODEL_MAX_EDGES or
the line-issue path, since frame time is very nearly linear in it.
The load report splits I/O from everything else. If io
dominates, the fix is in the SD path or STL_CHUNK; if the remainder
dominates, it is the parser; if passes is the outlier, it is the
clustering strategy reading the file again. Three unrelated pieces of
work, and this says which one to do.
picorv32's rdcycle/rdinstret are single global hardware
counters — not virtualised per process, not saved across context
switches (sw/os/sh.c has the full note). Every figure above
therefore includes cycles and instructions burned by other processes
while gpu3d was preempted.
For "how long did the user wait", that is the honest number. For "how expensive is my edge loop", it is inflated by however many other runnable processes there are — so measure with a quiet system, ideally just wm and gpu3d.
CPI survives this. Both columns are scaled by the same inflation, so the ratio divides it out and is comparable between runs. A CPI far above picorv32's ~4–6 means memory stalls or preemption rather than more work.
Build with -DGPU3D_PERF=0 to compile all of it out.
.bss is roughly 15KB: ~4.5KB of model arrays, ~3KB of loader hash
tables and keys, ~1KB of projection arrays, and a 4KB stream buffer. Every byte
of that is a byte of the 1MB pool for the app's lifetime, which is why
the Makefile now builds with --gc-sections (this app links
zdialog.o/zflist.o for one dialog, and those pull in the whole
widget toolkit) and emits a .zexe rather than a padded raw binary --
the header carries the .bss size as a number instead of writing
30KB of literal zeros to be read off the SD card at every launch.
MODEL_MAX_VERTS/MODEL_MAX_EDGES (256/384) are sized for
interactive rate, not fidelity. They were 768/1536 and measured
4 FPS on hardware. Profiling put almost none of that in the
rasterizer and almost all of it in CPU-side per-edge cost — twelve
uncached MMIO writes per line, times 1428 edges, every frame. That
cost is linear in edge count and essentially nothing else, so the
edge budget is the frame-rate dial.
The vertex budget follows the edge budget rather than leading it: a
cluster-decimated closed surface comes out around 0.34 vertices per
edge (measured 490/1428 and 307/911 on two real teapots), so 256
against 384 leaves headroom without wasting .bss.
If you raise them, watch the FPS readout and the us/edge figure in
the console report.
A truncated binary STL is rejected ("no triangles found") rather
than partially loaded: its size no longer matches 84 + 50*count, so
detection falls through to ASCII and finds no vertex tokens.
Recovering the readable prefix would be friendlier, but guessing at
damaged files is not obviously the right default. Reported honestly
rather than silently half-loaded.
sw/apps/gpu3d/test/ builds the STL loader against host stdio so the
parser and decimator can be exercised on a workstation, with an
ASCII-art wireframe render to eyeball the result:
$ cd sw/apps/gpu3d/test
$ make
$ python3 gen_teapot.py 0.58 teapot.stl teapot_bin.stl
$ ./stl_test teapot.stl teapot_bin.stl
It asserts normalization (half-extent exactly 1.0, centred on the origin) and edge-list integrity (in range, non-degenerate, deduplicated, index-ordered).
model_t now carries an optional face list (tris / ntris) alongside
its edges.
Optional is the important word. ntris == 0 means "wireframe only",
and the renderer falls back to it rather than refusing to draw. Nothing
is synthesised or converted: a model without faces simply cannot be
shaded, which is the honest answer and keeps every existing model
working untouched.
Deliberately, and in that order.
The cube is 12 triangles and its winding can be checked by hand — all twelve were verified to have outward normals before anything was drawn with them. That proves the shading pipeline against something whose correct output is obvious.
STL import is a bigger problem than it looks. The loader already reads
triangles — that is what an STL is — and then converts them to a
deduplicated edge list, discarding the faces. Keeping them is not just
an array: the triangle count after cluster decimation is far above
MODEL_MAX_TRIS, so it needs its own budget and its own decimation
pass. stl.c sets ntris = 0 explicitly so an imported model degrades
to wireframe rather than drawing garbage.
Counter-clockwise seen from outside. Backface culling tests the sign of the projected 2D cross product, so this convention has to hold for every model that wants shading.
A face wound the wrong way does not look like a winding error. It is culled when it should be drawn, so the solid gets a hole and you see the inside of the far side through it. Worth checking each face against the vertex table rather than trusting a pattern.
S toggles it. Software edge-walking, hardware span fills.
- Backface cull on the projected 2D cross product. Projection preserves winding, so the sign is the same as it would be in view space, and only the sign matters.
- Light level from the face's true 3D normal against a fixed light direction in view space, computed from the rotated vertices. See below for the version that did not work.
- Painter's algorithm, back to front, insertion sorted. Exact for a convex solid; wrong for interpenetrating geometry, which is the classic failure and worth knowing before pointing this at a complicated mesh. There is no Z-buffer and nowhere to put one.
- Always clears.
ERASE_INTERLEAVEDerases by redrawing last frame's edges in colour 0, which has no meaning for filled faces. Painting new faces over old almost works and fails exactly where it matters: a face that shrinks as the model turns leaves a fringe of the previous frame behind it.
The first version had no normal at all. The projected cross product's magnitude is twice the triangle's screen area, which for a face of fixed 3D size falls to zero as it turns edge-on — genuinely the cosine term a diffuse light wants, free, since the value is already computed for culling.
It was scaled against a running maximum over the visible faces, and that is where it fell apart. A single face brightened and dimmed correctly, but the scale moved with the model, because which face is largest changes as it turns. Every face's level then shifted together, which reads exactly as a light source orbiting the object rather than as shading.
The fix is a real normal against a fixed direction: no shared term, so a face's brightness depends only on its own orientation. Computed in view space from the rotated vertices, not the projected ones — perspective divide distorts angles, so projected coordinates are good enough to decide facing (a sign) but not angle (a magnitude).
Checked numerically over a rotation: coplanar triangle pairs always agree, and faces move independently — one falls 15 to 3 while another rises 3 to 14, with no common drift.
There is an ambient floor of 3. A face turned fully from the light still has a silhouette, and painting it black makes the solid look like it has a bite taken out of it against a black background.
Clearing the window and redrawing leaves the object absent for the part of each frame between the two. At 15fps that is a large fraction of the frame, and it flashes badly.
ERASE_INTERLEAVED already solves this for wireframes by erasing and
redrawing one edge at a time, so the object is never fully gone. The
equivalent for solid faces is to erase only what is no longer
covered.
Each frame records, per scanline, the leftmost and rightmost pixel the faces reached. The next frame draws the object first, then clears only the slivers of the previous silhouette the new one does not cover.
The object is therefore never blanked — nothing flashes — and the erase is proportional to how far the silhouette moved rather than to the window area, so it is also faster than the clear it replaces.
Exact for a convex silhouette, which a cube has: per scanline the coverage is a single interval, so a min and a max describe it completely. A non-convex model can have two intervals on one scanline and the gap between them would not be erased — a real limitation, and the reason to revisit this when STL faces arrive.
Verified by simulating a shape moving across a buffer for six frames and checking for both leftover pixels and holes: zero of each.
Also fixed by reverting. Last frame's faces extended to last frame's bbox, and as the model turns the two differ — so a crescent of the previous frame survives outside the new box, and the fresh black rectangle reads as a box cutting into the shape.
Clearing the union of the two bboxes would fix it properly and is worth revisiting, but only once the shading itself is settled: a partial clear makes every other rendering bug look like a clearing bug.
Screen y runs down, which flips the handedness, and it is genuinely easy to argue yourself into either answer — I did, and wrote a confident comment for the wrong one.
It was settled by computing each face's true 3D normal independently
and checking which sign agreed. cross > 0 matches on all twelve cube
faces, from a head-on view (2 triangles visible, since a cube seen down
an axis shows one face) and from a corner view (6 visible, 6 culled).
Worth recording because getting it backwards does not look like an inverted test. You see the inside of the far side of the solid, which reads as a hole.
The triangle filler was checked the same way: rendered to an ASCII buffer and scanned for interior gaps, including the degenerate single-scanline case that an edge-on face produces.
First hardware run was 12-15fps. Two changes, both about the CPU rather than the blitter:
Spans write three registers, not six. z_fb_hw_span_begin(level)
hoists height and grey level out of the loop, so each scanline writes
only dst_x, dst_y and width. At several hundred spans a frame,
each register write is a stalled bus cycle from the CPU, and the
register traffic costs as much as the blitting.
Clipping moved out of zwin and into the rasterizer for the same
reason: z_fb_hw_span() deliberately does not clip or validate, so
that the inner loop of something which has already clipped pays nothing
for checks it does not need.
The scan range is clipped, not just each span. A face partly outside the window used to run its whole interpolation for rows that were then discarded — which for a model scaled past the window edge is most of them.
The clear is the bounding box, not the window. The model is normalised to half-extent 1 and the projection is centred, so at default scale it occupies well under half the content area.
The gateware has no dither support — z_fb_hw_dither_available()
returned false and z_fb_hw_fill_shade() fell back to a plain fill,
white above level 8 and black below. As the model turns and levels cross
that threshold, faces flip between the two.
That is the fallback working as designed on old gateware, not a bug.
make flash, not make dev-flash — the dither is CTRL bit 8 in
gpu_blit.v.
A flat-shaded face is a set of horizontal spans, and a shaded span is
exactly one z_win_hw_fill_shade() call. So software edge-walking plus
hardware span fills gets the whole feature with no new gates.
Whether that is fast enough decides whether a hardware rasterizer is ever worth building — and it can now be measured rather than guessed, which was the point of doing the pattern hardware first.