Add CopyRect detection and emission for tile-aligned moves

This commit is contained in:
Viktor Liu
2026-05-17 08:13:52 +02:00
parent 44ed0c1992
commit cd005ef9a9
4 changed files with 458 additions and 24 deletions

View File

@@ -0,0 +1,189 @@
package server
import (
"hash/maphash"
"image"
)
// copyRectDetector finds tiles in the current frame that match the content
// of some tile-aligned region of the previous frame, so we can emit them as
// CopyRect rectangles (16 wire bytes) instead of re-encoding the pixels.
//
// The detector keeps two structures:
// - tileHash, a flat slice of one hash per tile-aligned position, used as
// the source of truth for the previous frame's tile content.
// - prevTiles, a hash → position lookup used during findTileMatch.
//
// updateDirty rehashes only the tiles that changed this frame, so the
// steady-state cost is proportional to the dirty set, not the framebuffer.
// A full rebuild from scratch is only done on the first frame or when the
// detector has not yet been initialized for the current resolution.
//
// Limitations:
// - Only tile-aligned source positions are considered. Sub-tile-aligned
// moves (e.g. window dragged by 7 pixels) are not detected. This still
// covers the common case of vertical/horizontal scrolling, which always
// produces tile-aligned matches at the tile granularity.
// - 64-bit maphash collisions are assumed not to happen. The probability
// for any single frame's hash universe is ~2^-32 * tileCount² which is
// vanishingly small at typical resolutions; if we ever observe one we
// can fall back to a full memcmp verification.
type copyRectDetector struct {
seed maphash.Seed
tileSize int
w, h int
cols, rows int
// tileHash[ty*cols + tx] is the current hash of the tile at (tx, ty)
// in the previous frame. Lookup uses this to detect stale prevTiles
// entries — incremental updates may leave hash→pos entries pointing
// at a tile whose content has since changed.
tileHash []uint64
// prevTiles maps a tile hash to a (x, y) origin in the previous frame.
prevTiles map[uint64][2]int
// hash is reused across hash computations to keep the per-tile lookup
// path allocation-free.
hash maphash.Hash
}
func newCopyRectDetector(tileSize int) *copyRectDetector {
d := &copyRectDetector{
seed: maphash.MakeSeed(),
tileSize: tileSize,
prevTiles: make(map[uint64][2]int),
}
d.hash.SetSeed(d.seed)
return d
}
// resize ensures the per-tile tables match the given framebuffer size.
// Called from rebuild before each full hash sweep.
func (d *copyRectDetector) resize(w, h int) {
if d.w == w && d.h == h && d.tileHash != nil {
return
}
d.w, d.h = w, h
d.cols = w / d.tileSize
d.rows = h / d.tileSize
d.tileHash = make([]uint64, d.cols*d.rows)
}
// hashTile computes the 64-bit maphash of one tile-aligned tile of frame.
func (d *copyRectDetector) hashTile(frame *image.RGBA, tx, ty int) uint64 {
d.hash.Reset()
ts := d.tileSize
stride := frame.Stride
rowBytes := ts * 4
base := ty*stride + tx*4
for row := 0; row < ts; row++ {
off := base + row*stride
_, _ = d.hash.Write(frame.Pix[off : off+rowBytes])
}
return d.hash.Sum64()
}
// rebuild discards everything and rehashes the whole frame. O(w*h). Use
// for the first frame or after the detector has been resized. Steady-state
// updates should go through updateDirty instead.
func (d *copyRectDetector) rebuild(frame *image.RGBA, w, h int) {
d.resize(w, h)
if d.prevTiles == nil {
d.prevTiles = make(map[uint64][2]int)
} else {
clear(d.prevTiles)
}
ts := d.tileSize
for ty := 0; ty+ts <= h; ty += ts {
for tx := 0; tx+ts <= w; tx += ts {
sum := d.hashTile(frame, tx, ty)
d.tileHash[(ty/ts)*d.cols+(tx/ts)] = sum
if _, exists := d.prevTiles[sum]; !exists {
d.prevTiles[sum] = [2]int{tx, ty}
}
}
}
}
// updateDirty rehashes only the tiles named in dirty (each entry is
// [x, y, w, h] with w and h equal to tileSize). O(len(dirty)) work, which
// in the common case is a tiny fraction of the whole framebuffer.
//
// The prevTiles map is replaced on collision rather than first-wins so a
// newly-hashed tile claims the slot. Old, stale entries pointing at tiles
// that no longer carry that hash are filtered at lookup time via tileHash.
func (d *copyRectDetector) updateDirty(frame *image.RGBA, w, h int, dirty [][4]int) {
if d.w != w || d.h != h || d.tileHash == nil {
d.rebuild(frame, w, h)
return
}
ts := d.tileSize
for _, r := range dirty {
if r[2] != ts || r[3] != ts {
continue
}
tx, ty := r[0], r[1]
if tx+ts > w || ty+ts > h {
continue
}
sum := d.hashTile(frame, tx, ty)
d.tileHash[(ty/ts)*d.cols+(tx/ts)] = sum
// Latest-wins on collision: ensures the most recent owner of this
// hash is the one we'll return on lookup. The previous owner's
// entry, if any, gets shadowed; if its content has changed it's
// stale anyway and findTileMatch's verification will skip it.
d.prevTiles[sum] = [2]int{tx, ty}
}
}
// findTileMatch hashes the current-frame tile at (dstX, dstY) and looks up
// its hash in the previous-frame map. Returns (srcX, srcY, true) when a
// matching tile-aligned tile exists at a different position whose stored
// hash still equals the requested hash (so the result is not stale).
func (d *copyRectDetector) findTileMatch(cur *image.RGBA, dstX, dstY int) (int, int, bool) {
if len(d.prevTiles) == 0 || d.tileHash == nil {
return 0, 0, false
}
ts := d.tileSize
if dstX+ts > cur.Rect.Dx() || dstY+ts > cur.Rect.Dy() {
return 0, 0, false
}
sum := d.hashTile(cur, dstX, dstY)
pos, ok := d.prevTiles[sum]
if !ok {
return 0, 0, false
}
if pos[0] == dstX && pos[1] == dstY {
return 0, 0, false
}
// Reject stale entries: the position the map points at must still
// carry the same hash according to our per-tile array.
if d.tileHash[(pos[1]/ts)*d.cols+(pos[0]/ts)] != sum {
return 0, 0, false
}
return pos[0], pos[1], true
}
// extractCopyRectTiles examines the diff-produced (per-tile) dirty list and
// pulls out any tiles whose current-frame content matches a prev-frame tile
// at a different position. Returns the CopyRect candidates and the residual
// dirty tiles that still need pixel encoding.
type copyRectMove struct {
srcX, srcY int
dstX, dstY int
}
func (d *copyRectDetector) extractCopyRectTiles(cur *image.RGBA, dirtyTiles [][4]int) (moves []copyRectMove, remaining [][4]int) {
ts := d.tileSize
remaining = dirtyTiles[:0:cap(dirtyTiles)]
for _, r := range dirtyTiles {
if r[2] == ts && r[3] == ts {
if sx, sy, ok := d.findTileMatch(cur, r[0], r[1]); ok {
moves = append(moves, copyRectMove{
srcX: sx, srcY: sy, dstX: r[0], dstY: r[1],
})
continue
}
}
remaining = append(remaining, r)
}
return moves, remaining
}