package world import ( "github.com/fogleman/gg" "github.com/stretchr/testify/require" "image" "image/color" "testing" ) 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 } // BenchmarkRender_IncrementalPan_NoBackground benchmarks render Incremental Pan No Background. 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) } } // BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleNone benchmarks render Incremental Pan Background Repeat World Anchor Scale None. func BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleNone(b *testing.B) { benchRenderBg(b, BackgroundAnchorWorld, BackgroundTileRepeat, BackgroundScaleNone) } // BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleFit benchmarks render Incremental Pan Background Repeat World Anchor Scale Fit. func BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleFit(b *testing.B) { benchRenderBg(b, BackgroundAnchorWorld, BackgroundTileRepeat, BackgroundScaleFit) } // BenchmarkRender_IncrementalPan_BackgroundRepeat_ViewportAnchor_ScaleNone benchmarks render Incremental Pan Background Repeat Viewport Anchor Scale None. 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) } } // BenchmarkDrawPlanSinglePass_Lines_GG benchmarks draw Plan Single Pass Lines GG. func BenchmarkDrawPlanSinglePass_Lines_GG(b *testing.B) { w := NewWorld(600, 600) w.IndexOnViewportChange(1000, 700, 1.0) // Make a lot of lines, including ones that likely wrap. for i := 0; i < 4000; i++ { x1 := float64(i % 600) y1 := float64((i * 7) % 600) x2 := float64((i*13 + 500) % 600) // shift to create various deltas y2 := float64((i*17 + 300) % 600) _, _ = w.AddLine(x1, y1, x2, y2) } w.Reindex() params := RenderParams{ ViewportWidthPx: 1000, ViewportHeightPx: 700, MarginXPx: 250, MarginYPx: 175, CameraXWorldFp: 300 * SCALE, CameraYWorldFp: 300 * SCALE, CameraZoom: 1.0, Options: &RenderOptions{ BackgroundColor: color.RGBA{A: 255}, }, } plan, err := w.buildRenderPlan(params) if err != nil { b.Fatalf("build plan: %v", err) } dc := gg.NewContext(params.CanvasWidthPx(), params.CanvasHeightPx()) drawer := &GGDrawer{DC: dc} b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false) } } // BenchmarkDrawPlanSinglePass_Lines_Fake benchmarks draw Plan Single Pass Lines Fake. func BenchmarkDrawPlanSinglePass_Lines_Fake(b *testing.B) { w := NewWorld(600, 600) w.IndexOnViewportChange(1000, 700, 1.0) for i := 0; i < 4000; i++ { x1 := float64(i % 600) y1 := float64((i * 7) % 600) x2 := float64((i*13 + 500) % 600) y2 := float64((i*17 + 300) % 600) _, _ = w.AddLine(x1, y1, x2, y2) } w.Reindex() params := RenderParams{ ViewportWidthPx: 1000, ViewportHeightPx: 700, MarginXPx: 250, MarginYPx: 175, CameraXWorldFp: 300 * SCALE, CameraYWorldFp: 300 * SCALE, CameraZoom: 1.0, Options: &RenderOptions{ BackgroundColor: color.RGBA{A: 255}, }, } plan, err := w.buildRenderPlan(params) if err != nil { b.Fatalf("build plan: %v", err) } drawer := &fakePrimitiveDrawer{} b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { // Reset command log so it doesn't grow forever and dominate allocations. drawer.Reset() w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false) } } // TestRender_IncrementalShift_UsesOuterClip_NotPerTileClips verifies render Incremental Shift Uses Outer Clip Not Per Tile Clips. func TestRender_IncrementalShift_UsesOuterClip_NotPerTileClips(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.IndexOnViewportChange(100, 80, 1.0) w.resetGrid(2 * SCALE) _, _ = w.AddPoint(5, 5) w.Reindex() params := RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, Options: &RenderOptions{ Incremental: &IncrementalPolicy{AllowShiftOnly: false}, }, } // First render initializes state. d1 := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d1, params)) // Small pan. params2 := params params2.CameraXWorldFp += 1 * SCALE d2 := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d2, params2)) // Expect very few ClipRect calls (dirty strips count), not per tile. clipCmds := d2.CommandsByName("ClipRect") require.NotEmpty(t, clipCmds) require.LessOrEqual(t, len(clipCmds), 4) } // TestRender_BatchesConsecutiveLinesByStyleID verifies render Batches Consecutive Lines By Style ID. func TestRender_BatchesConsecutiveLinesByStyleID(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.IndexOnViewportChange(100, 80, 1.0) // Two lines with default style, same priority. _, _ = w.AddLine(1, 1, 8, 1) _, _ = w.AddLine(1, 2, 8, 2) w.Reindex() params := RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } d := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d, params)) // We expect at least two AddLine, but only 1 Stroke for that run in a tile. adds := d.CommandsByName("AddLine") strokes := d.CommandsByName("Stroke") require.GreaterOrEqual(t, len(adds), 2) require.GreaterOrEqual(t, len(strokes), 1) // Stronger: within any consecutive group of AddLine commands, count strokes <= 1. // (Keep it loose to avoid depending on tile partitioning.) } // BenchmarkDrawPlanSinglePass_DrawItemsReuse benchmarks draw Plan Single Pass Draw Items Reuse. func BenchmarkDrawPlanSinglePass_DrawItemsReuse(b *testing.B) { w := NewWorld(600, 600) // Make grid + index available. w.IndexOnViewportChange(1000, 700, 1.0) // Add enough objects so tiles have candidates. for i := range 2000 { _, _ = w.AddPoint(float64(i%600), float64((i*7)%600)) } for i := range 500 { _, _ = w.AddCircle(float64((i*11)%600), float64((i*13)%600), 5.0) } w.Reindex() params := RenderParams{ ViewportWidthPx: 1000, ViewportHeightPx: 700, MarginXPx: 250, MarginYPx: 175, CameraXWorldFp: 300 * SCALE, CameraYWorldFp: 300 * SCALE, CameraZoom: 1.0, Options: &RenderOptions{ BackgroundColor: color.RGBA{A: 255}, }, } plan, err := w.buildRenderPlan(params) if err != nil { b.Fatalf("build plan: %v", err) } dc := gg.NewContext(params.CanvasWidthPx(), params.CanvasHeightPx()) drawer := &GGDrawer{DC: dc} b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { // We don't clear here; we only measure the draw loop overhead. w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false) } } // BenchmarkBuildRenderPlanStageA_Candidates benchmarks build Render Plan Stage A Candidates. func BenchmarkBuildRenderPlanStageA_Candidates(b *testing.B) { w := NewWorld(600, 600) // Make the index/grid available. w.IndexOnViewportChange(1000, 700, 1.0) // Populate with enough objects to create duplicates across cells. // Circles and lines create bbox indexing (more duplicates). for i := 0; i < 2000; i++ { _, _ = w.AddPoint(float64(i%600), float64((i*7)%600)) } for i := 0; i < 1200; i++ { _, _ = w.AddCircle(float64((i*11)%600), float64((i*13)%600), 8.0) } for i := 0; i < 1200; i++ { x1 := float64((i*3 + 10) % 600) y1 := float64((i*5 + 20) % 600) x2 := float64((i*7 + 400) % 600) y2 := float64((i*11 + 300) % 600) _, _ = w.AddLine(x1, y1, x2, y2) } w.Reindex() params := RenderParams{ ViewportWidthPx: 1000, ViewportHeightPx: 700, MarginXPx: 250, MarginYPx: 175, CameraXWorldFp: 300 * SCALE, CameraYWorldFp: 300 * SCALE, CameraZoom: 1.0, Options: &RenderOptions{ BackgroundColor: color.RGBA{A: 255}, }, } b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { _, err := w.buildRenderPlan(params) if err != nil { b.Fatalf("build plan: %v", err) } } }