From fdcbb5d6f4846a553ceaec4563a3d801035c1560 Mon Sep 17 00:00:00 2001 From: IliaDenisov Date: Sun, 8 Mar 2026 15:58:57 +0200 Subject: [PATCH] fast background --- client/world/drawer.go | 2 + .../world/gg_drawer_background_bench_test.go | 149 ++++++++++ client/world/gg_drawer_background_fast.go | 255 ++++++++++++++++++ client/world/renderer_background.go | 5 + 4 files changed, 411 insertions(+) create mode 100644 client/world/gg_drawer_background_bench_test.go create mode 100644 client/world/gg_drawer_background_fast.go diff --git a/client/world/drawer.go b/client/world/drawer.go index 07bd614..6798dee 100644 --- a/client/world/drawer.go +++ b/client/world/drawer.go @@ -101,6 +101,8 @@ type GGDrawer struct { // scratch is a reusable buffer for CopyShift to avoid allocations. scratch *image.RGBA + + bgCache bgTileCache } // Save stores the current gg state and the current logical clip stack. diff --git a/client/world/gg_drawer_background_bench_test.go b/client/world/gg_drawer_background_bench_test.go new file mode 100644 index 0000000..5538400 --- /dev/null +++ b/client/world/gg_drawer_background_bench_test.go @@ -0,0 +1,149 @@ +package world + +import ( + "image" + "image/color" + "testing" + + "github.com/fogleman/gg" +) + +type benchBgTheme struct { + img image.Image + anchor BackgroundAnchorMode + tileMode BackgroundTileMode + scaleMode BackgroundScaleMode +} + +func (t benchBgTheme) ID() string { return "benchbg" } +func (t benchBgTheme) Name() string { return "benchbg" } + +func (t benchBgTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} } +func (t benchBgTheme) BackgroundImage() image.Image { return t.img } + +func (t benchBgTheme) BackgroundTileMode() BackgroundTileMode { return t.tileMode } +func (t benchBgTheme) BackgroundScaleMode() BackgroundScaleMode { return t.scaleMode } +func (t benchBgTheme) BackgroundAnchorMode() BackgroundAnchorMode { return t.anchor } + +func (t benchBgTheme) PointStyle() Style { + return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} +} +func (t benchBgTheme) LineStyle() Style { + return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} +} +func (t benchBgTheme) CircleStyle() Style { + return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} +} + +func (t benchBgTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { + return StyleOverride{}, false +} +func (t benchBgTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { + return StyleOverride{}, false +} +func (t benchBgTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { + return StyleOverride{}, false +} + +func BenchmarkRender_IncrementalPan_NoBackground(b *testing.B) { + w := NewWorld(600, 600) + w.IndexOnViewportChange(1200, 800, 1.0) + + // Some primitives to keep it realistic but not dominant. + for i := 0; i < 200; i++ { + _, _ = w.AddPoint(float64(i%600), float64((i*7)%600)) + } + w.Reindex() + + dc := gg.NewContext(1200, 800) + drawer := &GGDrawer{DC: dc} + + params := RenderParams{ + ViewportWidthPx: 1000, + ViewportHeightPx: 700, + MarginXPx: 250, + MarginYPx: 175, + CameraXWorldFp: 300 * SCALE, + CameraYWorldFp: 300 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + Incremental: &IncrementalPolicy{ + AllowShiftOnly: false, + CoalesceUpdates: false, + MaxCatchUpAreaPx: 0, + RenderBudgetMs: 0, + }, + }, + } + + // Initial render (commit state). + _ = w.Render(drawer, params) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + params.CameraXWorldFp += 1 * SCALE + _ = w.Render(drawer, params) + } +} + +func BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleNone(b *testing.B) { + benchRenderBg(b, BackgroundAnchorWorld, BackgroundTileRepeat, BackgroundScaleNone) +} + +func BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleFit(b *testing.B) { + benchRenderBg(b, BackgroundAnchorWorld, BackgroundTileRepeat, BackgroundScaleFit) +} + +func BenchmarkRender_IncrementalPan_BackgroundRepeat_ViewportAnchor_ScaleNone(b *testing.B) { + benchRenderBg(b, BackgroundAnchorViewport, BackgroundTileRepeat, BackgroundScaleNone) +} + +func benchRenderBg(b *testing.B, anchor BackgroundAnchorMode, tile BackgroundTileMode, scale BackgroundScaleMode) { + w := NewWorld(600, 600) + w.IndexOnViewportChange(1200, 800, 1.0) + + for i := 0; i < 200; i++ { + _, _ = w.AddPoint(float64(i%600), float64((i*7)%600)) + } + w.Reindex() + + // Background tile (RGBA) — typical texture size. + bg := image.NewRGBA(image.Rect(0, 0, 96, 96)) + // Make it semi-transparent so draw.Over has real work. + for y := 0; y < 96; y++ { + for x := 0; x < 96; x++ { + bg.SetRGBA(x, y, color.RGBA{R: 255, G: 255, B: 255, A: 18}) + } + } + + w.SetTheme(benchBgTheme{img: bg, anchor: anchor, tileMode: tile, scaleMode: scale}) + + dc := gg.NewContext(1200, 800) + drawer := &GGDrawer{DC: dc} + + params := RenderParams{ + ViewportWidthPx: 1000, + ViewportHeightPx: 700, + MarginXPx: 250, + MarginYPx: 175, + CameraXWorldFp: 300 * SCALE, + CameraYWorldFp: 300 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + Incremental: &IncrementalPolicy{ + AllowShiftOnly: false, + CoalesceUpdates: false, + MaxCatchUpAreaPx: 0, + RenderBudgetMs: 0, + }, + }, + } + + _ = w.Render(drawer, params) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + params.CameraXWorldFp += 1 * SCALE + _ = w.Render(drawer, params) + } +} diff --git a/client/world/gg_drawer_background_fast.go b/client/world/gg_drawer_background_fast.go new file mode 100644 index 0000000..f4dcb0f --- /dev/null +++ b/client/world/gg_drawer_background_fast.go @@ -0,0 +1,255 @@ +package world + +import ( + "image" + "image/draw" + "reflect" +) + +type bgTileCacheKey struct { + imgPtr uintptr + scaleMode BackgroundScaleMode + canvasW int + canvasH int + srcW int + srcH int +} + +type bgTileCache struct { + key bgTileCacheKey + valid bool + scaledTile *image.RGBA + tileW int + tileH int +} + +func (g *GGDrawer) drawBackgroundFast(w *World, params RenderParams, rect RectPx) bool { + th := w.Theme() + bgImg := th.BackgroundImage() + if bgImg == nil { + return false + } + + dst, ok := g.DC.Image().(*image.RGBA) + if !ok || dst == nil { + return false + } + + canvasW := params.CanvasWidthPx() + canvasH := params.CanvasHeightPx() + + // Clamp rect to canvas. + if rect.W <= 0 || rect.H <= 0 { + return true + } + if rect.X < 0 { + rect.W += rect.X + rect.X = 0 + } + if rect.Y < 0 { + rect.H += rect.Y + rect.Y = 0 + } + if rect.X+rect.W > canvasW { + rect.W = canvasW - rect.X + } + if rect.Y+rect.H > canvasH { + rect.H = canvasH - rect.Y + } + if rect.W <= 0 || rect.H <= 0 { + return true + } + + imgB := bgImg.Bounds() + srcW := imgB.Dx() + srcH := imgB.Dy() + if srcW <= 0 || srcH <= 0 { + return true + } + + tileMode := th.BackgroundTileMode() + anchor := th.BackgroundAnchorMode() + scaleMode := th.BackgroundScaleMode() + + // Compute scaled tile size in pixels (scale depends on canvas size). + tileW, tileH := backgroundScaledSize(srcW, srcH, canvasW, canvasH, scaleMode) + if tileW <= 0 || tileH <= 0 { + return true + } + + // Prepare the tile image (possibly scaled) from cache. + tile := bgImg + if scaleMode != BackgroundScaleNone || tileW != srcW || tileH != srcH { + rgbaTile := g.getOrBuildScaledTile(bgImg, srcW, srcH, tileW, tileH, scaleMode, canvasW, canvasH) + if rgbaTile == nil { + // Fallback to slow path if we cannot scale (non-RGBA weirdness). + return false + } + tile = rgbaTile + } + + offX, offY := w.backgroundAnchorOffsetPx(params, tileW, tileH, anchor) + + switch tileMode { + case BackgroundTileNone: + // Draw single image centered in full canvas, then clipped by rect. + x := (canvasW-tileW)/2 + offX + y := (canvasH-tileH)/2 + offY + w.drawOneTileRGBA(dst, tile, rect, x, y) + + case BackgroundTileRepeat: + originX := offX + originY := offY + + startX := floorDiv(rect.X-originX, tileW)*tileW + originX + startY := floorDiv(rect.Y-originY, tileH)*tileH + originY + + for yy := startY; yy < rect.Y+rect.H; yy += tileH { + for xx := startX; xx < rect.X+rect.W; xx += tileW { + w.drawOneTileRGBA(dst, tile, rect, xx, yy) + } + } + + default: + // Treat unknown as none. + x := (canvasW-tileW)/2 + offX + y := (canvasH-tileH)/2 + offY + w.drawOneTileRGBA(dst, tile, rect, x, y) + } + + return true +} + +func (g *GGDrawer) getOrBuildScaledTile(img image.Image, srcW, srcH, dstW, dstH int, mode BackgroundScaleMode, canvasW, canvasH int) *image.RGBA { + // Identify image pointer (themes typically provide *image.RGBA). + ptr := imagePointer(img) + + key := bgTileCacheKey{ + imgPtr: ptr, + scaleMode: mode, + canvasW: canvasW, + canvasH: canvasH, + srcW: srcW, + srcH: srcH, + } + if g.bgCache.valid && g.bgCache.key == key && g.bgCache.scaledTile != nil && + g.bgCache.tileW == dstW && g.bgCache.tileH == dstH { + return g.bgCache.scaledTile + } + + // Scale only from *image.RGBA fast; otherwise, try a generic slow path. + var scaled *image.RGBA + if srcRGBA, ok := img.(*image.RGBA); ok { + scaled = scaleNearestRGBA(srcRGBA, dstW, dstH) + } else { + scaled = scaleNearestGeneric(img, dstW, dstH) + } + + g.bgCache.key = key + g.bgCache.valid = true + g.bgCache.scaledTile = scaled + g.bgCache.tileW = dstW + g.bgCache.tileH = dstH + + return scaled +} + +func imagePointer(img image.Image) uintptr { + // Works well when img is a pointer type (e.g. *image.RGBA). + // If not pointer, Pointer() returns 0; cache will be less effective but still correct. + v := reflect.ValueOf(img) + if v.Kind() == reflect.Pointer || v.Kind() == reflect.UnsafePointer { + return v.Pointer() + } + return 0 +} + +// scaleNearestRGBA scales src -> dst with nearest-neighbor sampling. +// This is intended for background textures; performance > quality. +func scaleNearestRGBA(src *image.RGBA, dstW, dstH int) *image.RGBA { + if dstW <= 0 || dstH <= 0 { + return nil + } + sb := src.Bounds() + sw := sb.Dx() + sh := sb.Dy() + if sw <= 0 || sh <= 0 { + return nil + } + + dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH)) + + for y := 0; y < dstH; y++ { + sy := (y * sh) / dstH + srcOff := (sy+sb.Min.Y)*src.Stride + sb.Min.X*4 + dstOff := y * dst.Stride + for x := 0; x < dstW; x++ { + sx := (x * sw) / dstW + si := srcOff + sx*4 + di := dstOff + x*4 + dst.Pix[di+0] = src.Pix[si+0] + dst.Pix[di+1] = src.Pix[si+1] + dst.Pix[di+2] = src.Pix[si+2] + dst.Pix[di+3] = src.Pix[si+3] + } + } + + return dst +} + +func scaleNearestGeneric(src image.Image, dstW, dstH int) *image.RGBA { + if dstW <= 0 || dstH <= 0 { + return nil + } + sb := src.Bounds() + sw := sb.Dx() + sh := sb.Dy() + if sw <= 0 || sh <= 0 { + return nil + } + + dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH)) + for y := 0; y < dstH; y++ { + sy := sb.Min.Y + (y*sh)/dstH + for x := 0; x < dstW; x++ { + sx := sb.Min.X + (x*sw)/dstW + dst.Set(x, y, src.At(sx, sy)) + } + } + return dst +} + +// drawOneTileRGBA draws tile at (x,y) into dst, but only the portion that intersects rect. +// Uses draw.Over (alpha compositing), assuming caller already cleared rect to background color. +func (w *World) drawOneTileRGBA(dst *image.RGBA, tile image.Image, rect RectPx, x, y int) { + tileB := tile.Bounds() + tw := tileB.Dx() + th := tileB.Dy() + if tw <= 0 || th <= 0 { + return + } + + // Intersection of tile rect and target rect. + tx0 := x + ty0 := y + tx1 := x + tw + ty1 := y + th + + rx0 := rect.X + ry0 := rect.Y + rx1 := rect.X + rect.W + ry1 := rect.Y + rect.H + + ix0 := max(tx0, rx0) + iy0 := max(ty0, ry0) + ix1 := min(tx1, rx1) + iy1 := min(ty1, ry1) + if ix0 >= ix1 || iy0 >= iy1 { + return + } + + dstR := image.Rect(ix0, iy0, ix1, iy1) + srcPt := image.Point{X: tileB.Min.X + (ix0 - tx0), Y: tileB.Min.Y + (iy0 - ty0)} + + draw.Draw(dst, dstR, tile, srcPt, draw.Over) +} diff --git a/client/world/renderer_background.go b/client/world/renderer_background.go index 7bb0200..f154aff 100644 --- a/client/world/renderer_background.go +++ b/client/world/renderer_background.go @@ -5,6 +5,11 @@ import ( ) func (w *World) drawBackground(drawer PrimitiveDrawer, params RenderParams, rect RectPx) { + if gd, ok := drawer.(*GGDrawer); ok { + if gd.drawBackgroundFast(w, params, rect) { + return + } + } th := w.Theme() bgImg := th.BackgroundImage() if bgImg == nil {