Stream the panel as masked png deltas and seqlock the scanout reads
This commit is contained in:
+41
-26
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
@@ -73,20 +72,18 @@ func activeCrtc(fd int) (uint32, int, error) {
|
|||||||
|
|
||||||
func (g *grabber) close() { unix.Close(g.fd) }
|
func (g *grabber) close() { unix.Close(g.fd) }
|
||||||
|
|
||||||
// Reading a buffer takes about as long as a frame, so where the read starts in
|
// A delta of 0 returns the current sequence without blocking.
|
||||||
// the flip cycle decides whether it stays ahead of the writer. Woken at a
|
func (g *grabber) vblank(delta uint32) (uint32, error) {
|
||||||
// blank, the buffer GETCRTC then names has just gone on screen.
|
|
||||||
func (g *grabber) waitVblank() error {
|
|
||||||
v := drm.WaitVblank{
|
v := drm.WaitVblank{
|
||||||
Type: drm.VblankRelative | uint32(g.index<<drm.VblankHighCrtcShift)&drm.VblankHighCrtcMask,
|
Type: drm.VblankRelative | uint32(g.index<<drm.VblankHighCrtcShift)&drm.VblankHighCrtcMask,
|
||||||
Sequence: 1,
|
Sequence: delta,
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
err := drm.Ioctl(g.fd, drm.WaitVblankIO, unsafe.Pointer(&v))
|
err := drm.Ioctl(g.fd, drm.WaitVblankIO, unsafe.Pointer(&v))
|
||||||
if err == unix.EINTR {
|
if err == unix.EINTR {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return err
|
return v.Sequence, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,29 +121,47 @@ func (g *grabber) mapFront() (mem []byte, pitch, pw, ph int, err error) {
|
|||||||
return mem, pitch, pw, ph, nil
|
return mem, pitch, pw, ph, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scanout memory is uncached, so a read costs a good fraction of a frame and
|
const overrunLimit = 10
|
||||||
// could be overtaken by the next redraw. Read twice and accept only if the two
|
|
||||||
// agree: the writer leaves a buffer alone for several frames after displaying
|
// The writer cycles its buffers in order, so the one on screen is not written
|
||||||
// it, which is long enough for both. Disagreement means that reasoning is
|
// again until three flips after it arrived, and GETCRTC can name the previous
|
||||||
// wrong, which is worth hearing about rather than retrying past.
|
// front for an instant around a flip. A copy whose start and end vblank
|
||||||
func (g *grabber) frame() (*image.NRGBA, error) {
|
// sequences differ by at most one therefore provably finished before the
|
||||||
if err := g.waitVblank(); err != nil {
|
// rewrite could begin. Overrunning that is a timing miss, not corruption:
|
||||||
return nil, fmt.Errorf("wait for vblank: %w", err)
|
// sample again, and treat only systematic misses as an error.
|
||||||
|
func (g *grabber) rawFrame(dst []byte) ([]byte, int, int, int, error) {
|
||||||
|
for tries := 0; tries < overrunLimit; tries++ {
|
||||||
|
s0, err := g.vblank(1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, 0, 0, fmt.Errorf("wait for vblank: %w", err)
|
||||||
|
}
|
||||||
|
mem, pitch, pw, ph, err := g.mapFront()
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, 0, 0, err
|
||||||
|
}
|
||||||
|
if len(dst) < len(mem) {
|
||||||
|
dst = make([]byte, len(mem))
|
||||||
|
}
|
||||||
|
dst = dst[:len(mem)]
|
||||||
|
copy(dst, mem)
|
||||||
|
unix.Munmap(mem)
|
||||||
|
s1, err := g.vblank(0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, 0, 0, fmt.Errorf("query vblank: %w", err)
|
||||||
|
}
|
||||||
|
if s1-s0 <= 1 {
|
||||||
|
return dst, pitch, pw, ph, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
mem, pitch, pw, ph, err := g.mapFront()
|
return nil, 0, 0, 0, fmt.Errorf("scanout read overran the writer %d times in a row", overrunLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *grabber) frame() (*image.NRGBA, error) {
|
||||||
|
raw, pitch, pw, ph, err := g.rawFrame(nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
a := make([]byte, len(mem))
|
return decode(raw, pitch, pw, ph), nil
|
||||||
copy(a, mem)
|
|
||||||
b := make([]byte, len(mem))
|
|
||||||
copy(b, mem)
|
|
||||||
unix.Munmap(mem)
|
|
||||||
|
|
||||||
if !bytes.Equal(a, b) {
|
|
||||||
return nil, fmt.Errorf("scanout buffer was redrawn while being read")
|
|
||||||
}
|
|
||||||
return decode(a, pitch, pw, ph), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Undoes the quarter turn drm.Offset applies, to get back what a person
|
// Undoes the quarter turn drm.Offset applies, to get back what a person
|
||||||
|
|||||||
+8
-2
@@ -1,5 +1,6 @@
|
|||||||
// Runs cabletest for a fixed time and dumps the panel to png, since the machine
|
// Runs cabletest for a fixed time, dumps the panel to png, and streams it as
|
||||||
// with the display on it is not the machine looking at it.
|
// png deltas on :8080, since the machine with the display on it is not the
|
||||||
|
// machine looking at it.
|
||||||
//
|
//
|
||||||
// sudo go run ./harness -for 20s -at 3s,15s -- -a enp1s0f0np0 -b enp1s0f1np1
|
// sudo go run ./harness -for 20s -at 3s,15s -- -a enp1s0f0np0 -b enp1s0f1np1
|
||||||
package main
|
package main
|
||||||
@@ -37,6 +38,11 @@ func main() {
|
|||||||
fuzzN := flag.Int("fuzz", 0, "repeat the whole lifecycle this many times; full output only for runs that fail or log module events")
|
fuzzN := flag.Int("fuzz", 0, "repeat the whole lifecycle this many times; full output only for runs that fail or log module events")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
if err := serveStream(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if *fuzzN > 0 {
|
if *fuzzN > 0 {
|
||||||
err = fuzz(*fuzzN, *runFor, flag.Args())
|
err = fuzz(*fuzzN, *runFor, flag.Args())
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"g.fc.run/theater/cabletest/internal/drm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const streamAddr = ":8080"
|
||||||
|
|
||||||
|
const page = `<!doctype html>
|
||||||
|
<meta name="viewport" content="width=device-width">
|
||||||
|
<title>cabletest</title>
|
||||||
|
<body style="margin:0;background:#000;min-height:100vh;display:grid;place-items:center">
|
||||||
|
<canvas style="max-width:100vw;max-height:100vh"></canvas>
|
||||||
|
<script>
|
||||||
|
(async () => {
|
||||||
|
const cv = document.querySelector('canvas'), g = cv.getContext('2d');
|
||||||
|
const rd = (await fetch('frames')).body.getReader();
|
||||||
|
let buf = new Uint8Array(0);
|
||||||
|
for (;;) {
|
||||||
|
const {value, done} = await rd.read();
|
||||||
|
if (done) return;
|
||||||
|
const b = new Uint8Array(buf.length + value.length);
|
||||||
|
b.set(buf); b.set(value, buf.length); buf = b;
|
||||||
|
for (;;) {
|
||||||
|
if (buf.length < 12) break;
|
||||||
|
const dv = new DataView(buf.buffer, buf.byteOffset);
|
||||||
|
const x = dv.getUint32(0, true), y = dv.getUint32(4, true), n = dv.getUint32(8, true);
|
||||||
|
if (buf.length < 12 + n) break;
|
||||||
|
const bmp = await createImageBitmap(new Blob([buf.slice(12, 12 + n)], {type: 'image/png'}));
|
||||||
|
if (x === 0 && y === 0 && cv.width !== bmp.width) { cv.width = bmp.width; cv.height = bmp.height; }
|
||||||
|
g.drawImage(bmp, x, y);
|
||||||
|
buf = buf.slice(12 + n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
`
|
||||||
|
|
||||||
|
func serveStream() error {
|
||||||
|
ln, err := net.Listen("tcp", streamAddr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
host, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
host = "localhost"
|
||||||
|
}
|
||||||
|
fmt.Printf("panel stream at http://%s%s/\n", host, streamAddr)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, page)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/frames", frames)
|
||||||
|
go http.Serve(ln, mux)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func frames(w http.ResponseWriter, r *http.Request) {
|
||||||
|
g, err := openGrabber()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer g.close()
|
||||||
|
|
||||||
|
cur, pitch, pw, ph, err := g.rawFrame(nil)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
if err := send(w, image.Pt(0, 0), decode(cur, pitch, pw, ph)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
prev := make([]byte, len(cur))
|
||||||
|
for {
|
||||||
|
prev, cur = cur, prev
|
||||||
|
for {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cur, pitch, pw, ph, err = g.rawFrame(cur); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "stream %s: %v\n", r.RemoteAddr, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rects := diffRects(prev, cur, pitch, pw, ph)
|
||||||
|
if len(rects) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, rect := range rects {
|
||||||
|
if err := send(w, rect.Min, maskRect(prev, cur, pitch, ph, rect)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encoding happens inside the vblank budget, and on rect-sized parts the
|
||||||
|
// dense level trades 3-6x the cpu for a few percent of size — enough to spill
|
||||||
|
// past a vblank on the once-per-second bursts and stutter the stream.
|
||||||
|
var encoder = png.Encoder{CompressionLevel: png.DefaultCompression}
|
||||||
|
|
||||||
|
func send(w http.ResponseWriter, at image.Point, img *image.NRGBA) error {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := encoder.Encode(&buf, img); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var hdr [12]byte
|
||||||
|
binary.LittleEndian.PutUint32(hdr[0:], uint32(at.X))
|
||||||
|
binary.LittleEndian.PutUint32(hdr[4:], uint32(at.Y))
|
||||||
|
binary.LittleEndian.PutUint32(hdr[8:], uint32(buf.Len()))
|
||||||
|
if _, err := w.Write(hdr[:]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Write(buf.Bytes()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.(http.Flusher).Flush()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distant regions changing together (a spinner up top, counters at the foot)
|
||||||
|
// would union into one near-full-height box, so changed portrait rows are
|
||||||
|
// merged into bands only across small gaps and each band gets its own box.
|
||||||
|
const bandGap = 32
|
||||||
|
|
||||||
|
// Compares in panel space, where a portrait column is one contiguous row, and
|
||||||
|
// reports the changed bounding boxes in portrait coordinates.
|
||||||
|
func diffRects(prev, cur []byte, pitch, pw, ph int) []image.Rectangle {
|
||||||
|
var rows []int
|
||||||
|
for row := 0; row < ph; row++ {
|
||||||
|
if !bytes.Equal(prev[row*pitch:row*pitch+pw*4], cur[row*pitch:row*pitch+pw*4]) {
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := make([]bool, pw)
|
||||||
|
for _, row := range rows {
|
||||||
|
a := prev[row*pitch : row*pitch+pw*4]
|
||||||
|
b := cur[row*pitch : row*pitch+pw*4]
|
||||||
|
for y := 0; y < pw; y++ {
|
||||||
|
if !changed[y] && !bytes.Equal(a[y*4:y*4+4], b[y*4:y*4+4]) {
|
||||||
|
changed[y] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rects []image.Rectangle
|
||||||
|
for y := 0; y < pw; {
|
||||||
|
if !changed[y] {
|
||||||
|
y++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
y0, y1 := y, y+1
|
||||||
|
for next := y1; next < pw && next < y1+bandGap; next++ {
|
||||||
|
if changed[next] {
|
||||||
|
y1 = next + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rMin, rMax := -1, -1
|
||||||
|
for _, row := range rows {
|
||||||
|
if !bytes.Equal(prev[row*pitch+y0*4:row*pitch+y1*4], cur[row*pitch+y0*4:row*pitch+y1*4]) {
|
||||||
|
if rMin < 0 {
|
||||||
|
rMin = row
|
||||||
|
}
|
||||||
|
rMax = row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rects = append(rects, image.Rect(ph-1-rMax, y0, ph-rMin, y1))
|
||||||
|
y = y1
|
||||||
|
}
|
||||||
|
return rects
|
||||||
|
}
|
||||||
|
|
||||||
|
// The box holds only the pixels that changed, transparent everywhere else, so
|
||||||
|
// the client composites it straight over the last frame.
|
||||||
|
func maskRect(prev, cur []byte, pitch, ph int, rect image.Rectangle) *image.NRGBA {
|
||||||
|
img := image.NewNRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy()))
|
||||||
|
for x := rect.Min.X; x < rect.Max.X; x++ {
|
||||||
|
row := (ph - 1 - x) * pitch
|
||||||
|
for y := rect.Min.Y; y < rect.Max.Y; y++ {
|
||||||
|
o := row + y*4
|
||||||
|
if bytes.Equal(prev[o:o+4], cur[o:o+4]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := uint32(cur[o]) | uint32(cur[o+1])<<8 |
|
||||||
|
uint32(cur[o+2])<<16 | uint32(cur[o+3])<<24
|
||||||
|
cr, cg, cb := drm.Unpack(v)
|
||||||
|
img.SetNRGBA(x-rect.Min.X, y-rect.Min.Y, color.NRGBA{R: cr, G: cg, B: cb, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user