An icosahedron on HDMI, drawn by a TxHDL core

#TxHDL#FPGA#RISC-V#HDMI#Rust#auto

tl;dr: A RISC-V core written in TxHDL draws a turning icosahedron on a monitor, with the TxHDL logo in the corner. Watch it at https://youtu.be/YbHtntvvydk. The whole thing, core, memory, video, Ethernet and a serial loader, is one bitstream in the board’s flash, and the program that draws is 560 lines of Rust that go down the serial port in about a second. Read on for how it is put together.

What you are looking at

The board is an Alinx AX7A200B, with an Artix-7 200T on it. The core is Vreteno, an RV32IMC that I wrote in TxHDL together with Dragiša Janković. If you have not seen TxHDL before: you write the hardware as a Rust program, and the Verilog, the simulation and the checks all fall out of a Rust library and a few macros. I wrote a whole post about it if you want the long version.

Everything in the diagram below that computes is lowered from Rust. The only Verilog I wrote by hand is the top of the design: clocks, resets, pins, and a handful of FIFOs that carry signals between clocks. That’s it.

the flagship bitstream, xc7a200t Vreteno core 100 MHz boot memory the loader AXI router 5 ports DDR3 controller 1 GB, the program serial port 0x3000, 115200 bridge, page 0x3000 UART, PWM, video Ethernet MAC 125 MHz 5 FIFOs crossing video peripheral 25.2 MHz pixel clock 160 x 120 framebuffer 12-bit colour to the SiI9134, then the monitor serial from the laptop, into the loader

Here’s how it works

Place and route of this design takes about half an hour. If the program the core runs were baked into the netlist, every change to the picture would cost me half an hour, and I have better things to do with half hours. So the core does not come up running a program. It comes up running a small loader in its boot memory. The loader waits on the serial port, takes a program, writes it into the DDR3 memory and jumps to it. The hardware stays put. The software is whatever I sent last.

The core talks to the video peripheral through a third slot on the page at 0x3000, next to the serial port and the pulse width modulator (yes, there’s one of those too, it fades an LED). The interesting bit about that slot: the video peripheral runs on the pixel clock, 25.2 MHz, while the core runs at 100 MHz, and a VGA raster at 100 MHz would be a mess. So the slot leaves the lowered design as five channel ports, and each of the five AXI-Lite channels crosses to the pixel clock through its own asynchronous FIFO, with Gray-coded pointers, the same FIFO the Ethernet port uses. The five channels have no timing relationship to each other, which is why crossing each one on its own is all that’s needed.

The video peripheral: three registers and a cursor

The peripheral is three words on the bus, and I quite like how little that is.

0x3200 status (read) 0x3204 cursor (read, write) 0x3208 pixel (write) status: bit 0 is high in the vertical blanking; bits 31..16 count frames. the framebuffer, 160 columns by 120 rows cursor each pixel written moves the cursor right past the last column, the next row; past the last row, the top

Writing the pixel word puts a colour at the cursor and moves the cursor one column to the right. So a run of pixels along a scanline is one write of the cursor and then one write per pixel, and the whole screen is one write of the cursor and 19 200 writes of the pixel word. Each framebuffer pixel is four by four screen pixels, a colour is twelve bits, four each of red, green and blue, and there is exactly one framebuffer. Keep that last one in mind, it shapes the drawing.

Sending the program down the wire

The loader speaks a small protocol. Every number is four bytes, least significant byte first.

laptop, the sender board, the loader boot, once, at startup TXLD, address, length load, and K one word K, once the word is in memory … one word, one K, for every word … the sum of the words ok 40000000, or bad sum jump to 0x4000_0000

The K after every word is there because the serial port on the core buffers eight bytes, and a sender that streams without waiting overruns it right away. Waiting for a K per word costs about a second for the 2 500 words of this program. Build, send, watch. That’s the loop, and it is the reason the whole flagship exists.

If you want the board back in the loader without reprogramming it, you hold the serial line low for 30 ms (one zero byte at 300 baud does it) and the top resets the core. Or press the RESET key. Either way the memory keeps running and the loader is listening again.

Drawing the thing

The solid

An icosahedron has twelve vertices and twenty faces. The vertices are the corners of three golden rectangles: (0, ±1, ±φ) and its two cyclic rotations, where φ is the golden ratio.

three golden rectangles, twelve corners A face is three vertices that are pairwise one edge apart. The program tries every triple at startup, 220 of them, keeps the ones whose three distances are the edge, and winds each so its normal points away from the centre. ico 20 faces is what the board says over the serial port when it starts. No face table anywhere.

I did not type the faces in. The program works them out from the vertices at startup and tells me how many it found, which is a nice sanity check to have for free. All the arithmetic is fixed point, ten fractional bits in an i32, because the core has no floating point, and a sine is a 65-entry quarter-wave table read forwards, backwards and negated.

One number does two jobs

The face normals are made unit vectors once and turned with the solid. The light sits at the viewer’s eye, so the z component of a turned normal is the cosine between the face and the viewer.

n, unit normal the eye, on +z n.z > 0: the face is seen, and it is lit by n.z n.z < 0: a back face, skipped. The solid is convex, so this is the whole of hidden surface removal.

A face is visible exactly when that number is positive, and how bright it is drawn is that same number. The solid is convex, so a face that faces you is never behind another one, and there is no depth buffer. Convexity is doing real work here. The eye sits eight radii from the solid, which is close enough that the near faces are visibly larger than the far ones and far enough that they don’t balloon.

Every pixel written exactly once

There is one framebuffer, and the raster reads it while the core writes it. Anything written twice within a frame can be seen in between, so I don’t clear the screen and draw on it. Instead a row is composed in a small buffer, the background, the spans of the faces and the logo together, and then written to the framebuffer once, left to right.

clear, then draw: what I don’t do clear: 19 200 writes of background draw the faces time within one frame a pixel is written twice; the raster can read it between the two compose a row, then write it once: what I do one row in a buffer: background, the spans of the faces, the logo 160 writes, once each every pixel goes straight from what it was to what it should be A moving edge can still tear where the raster and the writer cross, but nothing flashes.

The faces that pass the normal test tile the silhouette of a convex solid without overlapping, so a row is a set of disjoint spans on a background, and one pass can write it. Edges are interpolated in Q16 with a half-open fill rule, so two faces that share an edge paint each pixel of it exactly once, with no crack and no overlap. Nice side effect: half the writes of a clear-then-draw scheme are gone, and so is the flicker they would cause.

The colours

The solid is drawn in the tan of the hat in the logo. Twelve bits of colour is not a lot, so a face’s brightness is one of sixteen steps on a single ramp of that one tan, spaced by a square root so the steps look evenly spaced to the eye, with a floor of two fifths so no face goes near black. Between two steps, a two by two ordered dither.

one ramp of one tan, square-root spaced: only brightness moves the light picks a step; a step is a colour and between two steps, a 2 by 2 dither four times the levels the eye sees; the checker is eight screen pixels wide and reads as a level from a chair

The dither quadruples the levels the eye sees, at the cost of a fine checker on the face that you can spot up close and cannot from a chair. With it the solid turns rather than steps, and I’m happy with how it looks.

The monitor on my desk, showing
the icosahedron and the logo

Where it landed on the chip

Since I had the routed design anyway, I had Vivado tell me where each subsystem ended up. Every placed cell is attributed to a subsystem by the top-level instance it sits under, counted per slice column and row, and drawn.

A map of the FPGA with the
core, the memory controller, the Ethernet and the video as separate
coloured regions

The memory controller (orange) holds the right edge against the memory’s bank, the core (blue) sits next to it, the Ethernet (green) is between its PHY pins and the core, and the video (purple) is a few patches on the left near the encoder’s pins. Nobody told the placer any of this. Logic goes where its pins are, and where the thing it talks to is. The whole flagship uses 8 per cent of the part, so there is room for a lot more.

What’s next

A moving edge can still tear where the raster and the writer cross, because there is one framebuffer; a second one fixes that. There is no depth buffer, because a convex solid does not need one; the next solid will. And the Ethernet port is on the core’s bus now, so the next demo gets to talk to the network.

The code is in the TxHDL repository: the program is cpu/vreteno/rust/ico_hdmi.rs, the video peripheral is lib/parts/src/hdmi.rs, the board top is flagship/board/flagship.v, and the document on the flagship, placement map included, is built by //docs:flagship.