diff --git a/client/world/drawer.go b/client/world/drawer.go index 6798dee..8d208bc 100644 --- a/client/world/drawer.go +++ b/client/world/drawer.go @@ -290,19 +290,26 @@ func (d *GGDrawer) ClearAllTo(bg color.Color) { panic("GGDrawer.ClearAllTo: backing image is not *image.RGBA") } - r, g, b, a := bg.RGBA() - // Convert from 16-bit range to 8-bit. - R := byte(r >> 8) - G := byte(g >> 8) - B := byte(b >> 8) - A := byte(a >> 8) + R, G, B, A := rgba8(bg) - p := img.Pix - for i := 0; i+3 < len(p); i += 4 { - p[i+0] = R - p[i+1] = G - p[i+2] = B - p[i+3] = A + // Prepare one full scanline once. + w := img.Bounds().Dx() + if w <= 0 { + return + } + line := make([]byte, w*4) + for i := 0; i < len(line); i += 4 { + line[i+0] = R + line[i+1] = G + line[i+2] = B + line[i+3] = A + } + + // Copy scanline into each row (fast memmove). + h := img.Bounds().Dy() + for y := 0; y < h; y++ { + off := y * img.Stride + copy(img.Pix[off:off+w*4], line) } } @@ -316,33 +323,40 @@ func (d *GGDrawer) ClearRectTo(x, y, w, h int, bg color.Color) { panic("GGDrawer.ClearRectTo: backing image is not *image.RGBA") } - bounds := img.Bounds() - x0 := max(x, bounds.Min.X) - y0 := max(y, bounds.Min.Y) - x1 := min(x+w, bounds.Max.X) - y1 := min(y+h, bounds.Max.Y) + b := img.Bounds() + x0 := max(x, b.Min.X) + y0 := max(y, b.Min.Y) + x1 := min(x+w, b.Max.X) + y1 := min(y+h, b.Max.Y) if x0 >= x1 || y0 >= y1 { return } - r, g, b, a := bg.RGBA() - R := byte(r >> 8) - G := byte(g >> 8) - B := byte(b >> 8) - A := byte(a >> 8) + R, G, B, A := rgba8(bg) + + rowPx := x1 - x0 + rowBytes := rowPx * 4 + + // Build one row once for this rect width. + line := make([]byte, rowBytes) + for i := 0; i < rowBytes; i += 4 { + line[i+0] = R + line[i+1] = G + line[i+2] = B + line[i+3] = A + } - rowBytes := (x1 - x0) * 4 for yy := y0; yy < y1; yy++ { off := yy*img.Stride + x0*4 - for i := 0; i < rowBytes; i += 4 { - img.Pix[off+i+0] = R - img.Pix[off+i+1] = G - img.Pix[off+i+2] = B - img.Pix[off+i+3] = A - } + copy(img.Pix[off:off+rowBytes], line) } } +func rgba8(c color.Color) (R, G, B, A byte) { + r, g, b, a := c.RGBA() + return byte(r >> 8), byte(g >> 8), byte(b >> 8), byte(a >> 8) +} + func (g *GGDrawer) DrawImage(img image.Image, x, y int) { g.DC.DrawImage(img, x, y) } diff --git a/client/world/drawer_clear_state_test.go b/client/world/drawer_clear_state_test.go new file mode 100644 index 0000000..426c12c --- /dev/null +++ b/client/world/drawer_clear_state_test.go @@ -0,0 +1,40 @@ +package world + +import ( + "image/color" + "testing" + + "github.com/fogleman/gg" + "github.com/stretchr/testify/require" +) + +func TestGGDrawer_ClearRectTo_DoesNotAffectStrokeState(t *testing.T) { + t.Parallel() + + dc := gg.NewContext(40, 20) + d := &GGDrawer{DC: dc} + + // Fill background to white. + d.ClearAllTo(color.RGBA{R: 255, G: 255, B: 255, A: 255}) + + // Configure stroke to red and draw first line. + d.SetStrokeColor(color.RGBA{R: 255, A: 255}) + d.SetLineWidth(2) + d.AddLine(2, 5, 38, 5) + d.Stroke() + + // Clear a rect in the middle with gray (must not affect stroke state). + d.ClearRectTo(10, 0, 20, 20, color.RGBA{R: 200, G: 200, B: 200, A: 255}) + + // Draw second line WITHOUT reapplying stroke style; it must still be red. + d.AddLine(2, 15, 38, 15) + d.Stroke() + + img := dc.Image() + + // Sample a pixel from the second line (y ~15). We expect red channel dominates. + r, g, b, a := img.At(20, 15).RGBA() + require.Greater(t, a, uint32(0), "pixel must not be fully transparent") + require.Greater(t, r, g, "expected red-ish pixel after ClearRectTo") + require.Greater(t, r, b, "expected red-ish pixel after ClearRectTo") +} diff --git a/client/world/fake_drawer_test.go b/client/world/fake_drawer_test.go index c992182..b5d7e8c 100644 --- a/client/world/fake_drawer_test.go +++ b/client/world/fake_drawer_test.go @@ -4,6 +4,7 @@ import ( "fmt" "image" "image/color" + "sync" ) // fakeClipRect describes one clip rectangle in canvas pixel coordinates. @@ -66,6 +67,7 @@ type fakePrimitiveDrawer struct { commands []fakeDrawerCommand state fakeDrawerState stack []fakeDrawerState + mu sync.Mutex } // Ensure fakePrimitiveDrawer implements PrimitiveDrawer. @@ -248,3 +250,8 @@ func (d *fakePrimitiveDrawer) DrawImage(_ image.Image, x, y int) { func (d *fakePrimitiveDrawer) DrawImageScaled(_ image.Image, x, y, w, h int) { d.snapshotCommand("DrawImageScaled", float64(x), float64(y), float64(w), float64(h)) } +func (d *fakePrimitiveDrawer) Reset() { + d.mu.Lock() + defer d.mu.Unlock() + d.commands = d.commands[:0] +} diff --git a/client/world/renderer.go b/client/world/renderer.go index dc95a47..541ee94 100644 --- a/client/world/renderer.go +++ b/client/world/renderer.go @@ -15,6 +15,13 @@ const ( RenderLayerLines ) +const ( + drawPlanSinglePassClipEnabled = false + + // best value according to BenchmarkDrawPlanSinglePass_Lines_GG + maxLineSegmentsPerStroke = 32 +) + // RenderOptions controls which layers are rendered and their order. // If Layers is empty, the default order is: Points, Circles, Lines. type RenderOptions struct { @@ -174,11 +181,7 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error { var bg color.Color = color.RGBA{A: 255} // default black if params.Options != nil && params.Options.BackgroundColor != nil { - if v, ok := params.Options.BackgroundColor.(color.RGBA); !ok { - panic("Options.BackgroundColor is not color.RGBA type") - } else { - bg = v - } + bg = params.Options.BackgroundColor } else { tc := w.Theme().BackgroundColor() if alphaNonZero(tc) { @@ -225,6 +228,30 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error { policy = *params.Options.Incremental } + // Helper: draw one dirty rect with outer clip, using an already prepared dirtyPlan. + // IMPORTANT: dirtyPlan must be built from the FULL set of dirty rects (union), + // not from a single rect, to avoid missing primitives on diagonal pans. + drawDirtyRect := func(dirtyPlan RenderPlan, r RectPx) error { + if r.W <= 0 || r.H <= 0 { + return nil + } + + drawer.Save() + drawer.ResetClip() + drawer.ClipRect(float64(r.X), float64(r.Y), float64(r.W), float64(r.H)) + + // Clear + background in the same clip. + drawer.ClearRectTo(r.X, r.Y, r.W, r.H, bg) + w.drawBackground(drawer, params, r) + + // Draw with outer clip only; do not rebuild plan per-rect. + // isDirtyPass MUST be true here. + w.drawPlanSinglePass(drawer, dirtyPlan, allowWrap, drawPlanSinglePassClipEnabled, true) + + drawer.Restore() + return nil + } + // --- Try incremental path first when state is initialized and geometry matches --- dxPx, dyPx, derr := w.ComputePanShiftPx(params) if derr == nil { @@ -245,23 +272,25 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error { // If we accumulated dirty regions during shift-only frames, redraw them now (bounded). if len(w.renderState.pendingDirty) > 0 { toDraw, remaining := takeCatchUpRects(w.renderState.pendingDirty, policy.MaxCatchUpAreaPx) + w.renderState.pendingDirty = remaining - if len(toDraw) > 0 { - for _, r := range toDraw { - drawer.ClearRectTo(r.X, r.Y, r.W, r.H, bg) - w.drawBackground(drawer, params, r) - } - - plan, err := w.buildRenderPlanStageA(params) - if err != nil { - return err - } - - catchUpPlan := planRestrictedToDirtyRects(plan, toDraw) - w.drawPlanSinglePass(drawer, catchUpPlan, allowWrap) + if len(toDraw) == 0 { + return nil } - w.renderState.pendingDirty = remaining + plan, err := w.buildRenderPlanStageA(params) + if err != nil { + return err + } + + // Build once for the whole set of catch-up rects (union), then clip per rect. + catchUpPlan := planRestrictedToDirtyRects(plan, toDraw) + + for _, r := range toDraw { + if err := drawDirtyRect(catchUpPlan, r); err != nil { + return err + } + } } return nil @@ -276,7 +305,7 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error { } w.renderState.pendingDirty = moved } - // C5: shift backing pixels, then redraw only dirty strips. + // Shift backing pixels first. drawer.CopyShift(inc.DxPx, inc.DyPx) overBudget := false @@ -294,17 +323,9 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error { return nil } - // [ ] Сразу после overBudget вычисления и до построения dirtyPlan - // Draw both the newly exposed strips and any previously deferred dirty regions. - // Always redraw newly exposed strips fully. // Under budget: draw newly exposed strips immediately, plus bounded catch-up. dirtyToDraw := inc.Dirty - for _, r := range dirtyToDraw { - drawer.ClearRectTo(r.X, r.Y, r.W, r.H, bg) - w.drawBackground(drawer, params, r) - } - // Additionally redraw a bounded portion of deferred dirty regions. if len(w.renderState.pendingDirty) > 0 { catchUp, remaining := takeCatchUpRects(w.renderState.pendingDirty, policy.MaxCatchUpAreaPx) @@ -312,14 +333,24 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error { w.renderState.pendingDirty = remaining } + if len(dirtyToDraw) == 0 { + return nil + } + plan, err := w.buildRenderPlanStageA(params) if err != nil { return err } - dirtyPlan := planRestrictedToDirtyRects(plan, dirtyToDraw) - w.drawPlanSinglePass(drawer, dirtyPlan, allowWrap) - // State already updated by ComputePanShiftPx (lastWorldRect advanced). + // Build once for the union of all dirty rects. + dirtyPlan := planRestrictedToDirtyRects(plan, dirtyToDraw) + + // Draw per-rect with outer clip; background/clear done inside helper. + for _, r := range dirtyToDraw { + if err := drawDirtyRect(dirtyPlan, r); err != nil { + return err + } + } return nil case IncrementalFullRedraw: @@ -337,7 +368,7 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error { drawer.ClearAllTo(bg) w.drawBackground(drawer, params, RectPx{X: 0, Y: 0, W: params.CanvasWidthPx(), H: params.CanvasHeightPx()}) - w.drawPlanSinglePass(drawer, plan, allowWrap) + w.drawPlanSinglePass(drawer, plan, allowWrap, drawPlanSinglePassClipEnabled, true) return w.CommitFullRedrawState(params) } diff --git a/client/world/renderer_circles.go b/client/world/renderer_circles.go index 2eaf0e7..59ba7ad 100644 --- a/client/world/renderer_circles.go +++ b/client/world/renderer_circles.go @@ -73,40 +73,85 @@ type wrapShift struct { dy int } -// circleWrapShifts returns 1..4 wrap shifts (multiples of worldW/worldH) required to render -// all torus copies of the circle inside the canonical world domain. -// The (0,0) shift is always present. -func circleWrapShifts(cx, cy, radiusFp, worldW, worldH int) []wrapShift { - // If radius covers the whole axis, additional copies are not useful. - // (One copy already covers everything under any reasonable clip.) - if radiusFp >= worldW || radiusFp >= worldH { - return []wrapShift{{dx: 0, dy: 0}} +// circleWrapShiftsInto appends required torus-copy shifts for a circle into dst and returns the resulting slice. +// It never allocates if dst has enough capacity. +// +// The 0-shift is always included. Additional copies are included when the circle's bbox crosses world edges. +func circleWrapShiftsInto(dst []wrapShift, cx, cy, radiusFp, worldW, worldH int) []wrapShift { + dst = dst[:0] + + // Always include the original. + dst = append(dst, wrapShift{dx: 0, dy: 0}) + + if radiusFp <= 0 { + return dst } - xShifts := []int{0} - yShifts := []int{0} + minX := cx - radiusFp + maxX := cx + radiusFp + minY := cy - radiusFp + maxY := cy + radiusFp - if cx+radiusFp >= worldW { - xShifts = append(xShifts, -worldW) + needLeft := minX < 0 + needRight := maxX > worldW + needTop := minY < 0 + needBottom := maxY > worldH + + // X-only copies. + if needLeft { + dst = append(dst, wrapShift{dx: +worldW, dy: 0}) } - if cx-radiusFp < 0 { - xShifts = append(xShifts, worldW) + if needRight { + dst = append(dst, wrapShift{dx: -worldW, dy: 0}) } - if cy+radiusFp >= worldH { - yShifts = append(yShifts, -worldH) + // Y-only copies. + if needTop { + dst = append(dst, wrapShift{dx: 0, dy: +worldH}) } - if cy-radiusFp < 0 { - yShifts = append(yShifts, worldH) + if needBottom { + dst = append(dst, wrapShift{dx: 0, dy: -worldH}) } - out := make([]wrapShift, 0, len(xShifts)*len(yShifts)) - for _, dx := range xShifts { - for _, dy := range yShifts { - out = append(out, wrapShift{dx: dx, dy: dy}) + // Corner copies (combine X and Y). + if (needLeft || needRight) && (needTop || needBottom) { + var dxs [2]int + dxn := 0 + if needLeft { + dxs[dxn] = +worldW + dxn++ + } + if needRight { + dxs[dxn] = -worldW + dxn++ + } + + var dys [2]int + dyn := 0 + if needTop { + dys[dyn] = +worldH + dyn++ + } + if needBottom { + dys[dyn] = -worldH + dyn++ + } + + for i := 0; i < dxn; i++ { + for j := 0; j < dyn; j++ { + dst = append(dst, wrapShift{dx: dxs[i], dy: dys[j]}) + } } } - return out + + return dst +} + +// circleWrapShifts is a compatibility wrapper that allocates. +// Prefer circleWrapShiftsInto in hot paths. +func circleWrapShifts(cx, cy, radiusFp, worldW, worldH int) []wrapShift { + var dst []wrapShift + return circleWrapShiftsInto(dst, cx, cy, radiusFp, worldW, worldH) } // circleCopyIntersectsTile checks whether the circle copy (shifted by dx/dy) intersects the tile segment. diff --git a/client/world/renderer_draw.go b/client/world/renderer_draw.go index d82dc12..36eb6b2 100644 --- a/client/world/renderer_draw.go +++ b/client/world/renderer_draw.go @@ -20,9 +20,9 @@ type drawItem struct { styleID StyleID // Exactly one of these is set. - p *Point - c *Circle - l *Line + p Point + c Circle + l Line } // drawPlanSinglePass renders a plan using a single ordered pass per tile. @@ -31,7 +31,9 @@ type drawItem struct { // allowWrap controls torus behavior: // - true: circles/points produce wrap copies, lines use torus-shortest segments // - false: no copies, lines drawn directly as stored -func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allowWrap bool) { +// tileClipEnabled controls whether per-tile ClipRect is applied. +// When an outer clip is already set (e.g. dirty rect), disable tile clips for speed. +func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allowWrap bool, tileClipEnabled bool, isDirtyPass bool) { var lastStyleID StyleID = StyleIDInvalid var lastStyle Style @@ -41,11 +43,9 @@ func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allo } s, ok := w.styles.Get(styleID) if !ok { - // Unknown style ID is a programming/config error. panic("render: unknown style ID") } - // Apply style state. Some fields may be nil intentionally. if s.FillColor != nil { drawer.SetFillColor(s.FillColor) } @@ -56,7 +56,6 @@ func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allo if len(s.StrokeDashes) > 0 { drawer.SetDash(s.StrokeDashes...) } else { - // Ensure solid line when switching from dashed style. drawer.SetDash() } drawer.SetDashOffset(s.StrokeDashOffset) @@ -70,52 +69,61 @@ func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allo continue } - // Collect items for this tile. - items := make([]drawItem, 0, len(td.Candidates)) + // Per-tile clip is optional. When outer-clip is used (dirty rect), + // tileClipEnabled must be false to avoid resetting the outer clip. + if tileClipEnabled { + drawer.Save() + drawer.ResetClip() + drawer.ClipRect(float64(td.ClipX), float64(td.ClipY), float64(td.ClipW), float64(td.ClipH)) + } + + items := w.scratchDrawItems[:0] + if cap(items) < len(td.Candidates) { + items = make([]drawItem, 0, len(td.Candidates)) + } for _, it := range td.Candidates { id := it.ID() cur, ok := w.objects[id] if !ok { - // Stale grid entry (object removed). Skip. continue } switch v := cur.(type) { case Point: - vv := v items = append(items, drawItem{ kind: drawKindPoint, - priority: vv.Priority, - id: vv.Id, - styleID: vv.StyleID, - p: &vv, + priority: v.Priority, + id: v.Id, + styleID: v.StyleID, + p: v, }) case Circle: - vv := v items = append(items, drawItem{ kind: drawKindCircle, - priority: vv.Priority, - id: vv.Id, - styleID: vv.StyleID, - c: &vv, + priority: v.Priority, + id: v.Id, + styleID: v.StyleID, + c: v, }) case Line: - vv := v items = append(items, drawItem{ kind: drawKindLine, - priority: vv.Priority, - id: vv.Id, - styleID: vv.StyleID, - l: &vv, + priority: v.Priority, + id: v.Id, + styleID: v.StyleID, + l: v, }) default: - // Unknown map items should not exist. panic("render: unknown map item type") } } if len(items) == 0 { + if tileClipEnabled { + drawer.Restore() + } + w.scratchDrawItems = items[:0] continue } @@ -130,27 +138,93 @@ func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allo return a.id < b.id }) - drawer.Save() - drawer.ClipRect(float64(td.ClipX), float64(td.ClipY), float64(td.ClipW), float64(td.ClipH)) + // If this is not a dirty pass (full redraw), keep the old behavior for lines: + // stroke per segment. This is usually faster for gg on huge scenes. + if !isDirtyPass { + for i := 0; i < len(items); i++ { + di := items[i] + applyStyle(di.styleID) - for _, di := range items { - applyStyle(di.styleID) - - switch di.kind { - case drawKindPoint: - w.drawPointInTile(drawer, plan, td, *di.p, allowWrap, lastStyle) - - case drawKindCircle: - w.drawCircleInTile(drawer, plan, td, *di.c, allowWrap, lastStyle) - - case drawKindLine: - w.drawLineInTile(drawer, plan, td, *di.l, allowWrap) - - default: - panic("render: unknown draw kind") + switch di.kind { + case drawKindPoint: + w.drawPointInTile(drawer, plan, td, di.p, allowWrap, lastStyle) + case drawKindCircle: + w.drawCircleInTile(drawer, plan, td, di.c, allowWrap, lastStyle) + case drawKindLine: + // Old behavior: drawLineInTile includes Stroke() per segment. + w.drawLineInTile(drawer, plan, td, di.l, allowWrap) + default: + panic("render: unknown draw kind") + } } + } else { + // Dirty pass: batch lines to reduce overhead while panning. + inLineRun := false + var lineRunStyleID StyleID + lineSegCount := 0 + + flushLineRun := func() { + if !inLineRun { + return + } + drawer.Stroke() + inLineRun = false + lineSegCount = 0 + } + + for i := 0; i < len(items); i++ { + di := items[i] + + if inLineRun { + if di.kind != drawKindLine || di.styleID != lineRunStyleID { + flushLineRun() + } + } + + switch di.kind { + case drawKindLine: + if !inLineRun { + lineRunStyleID = di.styleID + applyStyle(lineRunStyleID) + inLineRun = true + } else { + // style matches by construction; keep style state valid if code changes later + applyStyle(di.styleID) + } + + added := w.drawLineInTilePath(drawer, plan, td, di.l, allowWrap) + lineSegCount += added + + if lineSegCount >= maxLineSegmentsPerStroke { + drawer.Stroke() + lineSegCount = 0 + // keep run active + } + + case drawKindPoint: + flushLineRun() + applyStyle(di.styleID) + w.drawPointInTile(drawer, plan, td, di.p, allowWrap, lastStyle) + + case drawKindCircle: + flushLineRun() + applyStyle(di.styleID) + w.drawCircleInTile(drawer, plan, td, di.c, allowWrap, lastStyle) + + default: + flushLineRun() + panic("render: unknown draw kind") + } + } + + flushLineRun() } - drawer.Restore() + if tileClipEnabled { + drawer.Restore() + } + + // Reuse buffer for next tile. + w.scratchDrawItems = items[:0] } } diff --git a/client/world/renderer_draw_lines_bench_test.go b/client/world/renderer_draw_lines_bench_test.go new file mode 100644 index 0000000..6b9759c --- /dev/null +++ b/client/world/renderer_draw_lines_bench_test.go @@ -0,0 +1,169 @@ +package world + +import ( + "image/color" + "testing" + + "github.com/fogleman/gg" + "github.com/stretchr/testify/require" +) + +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.buildRenderPlanStageA(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) + } +} + +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.buildRenderPlanStageA(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) + } +} + +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) +} + +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.) +} diff --git a/client/world/renderer_draw_primitives.go b/client/world/renderer_draw_primitives.go index 9aff47e..a5b384f 100644 --- a/client/world/renderer_draw_primitives.go +++ b/client/world/renderer_draw_primitives.go @@ -1,5 +1,12 @@ package world +// lineSeg is one canonical segment (endpoints in [0..W) x [0..H)) to be drawn. +// It represents part of the torus-shortest polyline for a Line primitive after wrap splitting. +type lineSeg struct { + x1, y1 int + x2, y2 int +} + // drawPointInTile draws point marker copies that intersect the tile. // lastStyle is already applied; it provides PointRadiusPx. func (w *World) drawPointInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, p Point, allowWrap bool, lastStyle Style) { @@ -46,9 +53,11 @@ func (w *World) drawCircleInTile(drawer PrimitiveDrawer, plan RenderPlan, td Til var shifts []wrapShift effRadius := circleRadiusEffFp(c.Radius, w.circleRadiusScaleFp) if allowWrap { - shifts = circleWrapShifts(c.X, c.Y, effRadius, w.W, w.H) + shifts = circleWrapShiftsInto(w.scratchWrapShifts, c.X, c.Y, effRadius, w.W, w.H) } else { - shifts = []wrapShift{{dx: 0, dy: 0}} + var one [1]wrapShift + one[0] = wrapShift{dx: 0, dy: 0} + shifts = one[:] } rPx := worldSpanFixedToCanvasPx(effRadius, plan.ZoomFp) @@ -61,37 +70,68 @@ func (w *World) drawCircleInTile(drawer PrimitiveDrawer, plan RenderPlan, td Til cxPx := worldSpanFixedToCanvasPx((c.X+td.Tile.OffsetX+s.dx)-plan.WorldRect.minX, plan.ZoomFp) cyPx := worldSpanFixedToCanvasPx((c.Y+td.Tile.OffsetY+s.dy)-plan.WorldRect.minY, plan.ZoomFp) - drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx)) - fill := alphaNonZero(lastStyle.FillColor) stroke := alphaNonZero(lastStyle.StrokeColor) - if fill { + switch { + case fill && stroke: + // gg consumes the current path on Fill/Stroke, so we must draw twice: + // once for fill, then again for stroke. + drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx)) drawer.Fill() - } - if stroke { - // Stroke must be last when both are present. + + drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx)) drawer.Stroke() + + case fill: + drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx)) + drawer.Fill() + + case stroke: + drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx)) + drawer.Stroke() + + default: + // neither visible => nothing } } + w.scratchWrapShifts = shifts[:0] } -func (w *World) drawLineInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, l Line, allowWrap bool) { - var segs []lineSeg +func (w *World) drawLineInTilePath(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, l Line, allowWrap bool) int { + segs := w.scratchLineSegs[:0] + tmp := w.scratchLineSegsTmp[:0] + if cap(segs) < 4 { + segs = make([]lineSeg, 0, 4) + } + if cap(tmp) < 4 { + tmp = make([]lineSeg, 0, 4) + } + if allowWrap { - segs = torusShortestLineSegments(l, w.W, w.H) + segs, tmp = torusShortestLineSegmentsInto(segs, tmp, l, w.W, w.H) } else { - segs = []lineSeg{{x1: l.X1, y1: l.Y1, x2: l.X2, y2: l.Y2}} + var one [1]lineSeg + one[0] = lineSeg{x1: l.X1, y1: l.Y1, x2: l.X2, y2: l.Y2} + segs = one[:] } for _, s := range segs { - // Project into tile/canvas. x1 := worldSpanFixedToCanvasPx((s.x1+td.Tile.OffsetX)-plan.WorldRect.minX, plan.ZoomFp) y1 := worldSpanFixedToCanvasPx((s.y1+td.Tile.OffsetY)-plan.WorldRect.minY, plan.ZoomFp) x2 := worldSpanFixedToCanvasPx((s.x2+td.Tile.OffsetX)-plan.WorldRect.minX, plan.ZoomFp) y2 := worldSpanFixedToCanvasPx((s.y2+td.Tile.OffsetY)-plan.WorldRect.minY, plan.ZoomFp) drawer.AddLine(float64(x1), float64(y1), float64(x2), float64(y2)) - drawer.Stroke() } + + w.scratchLineSegs = segs[:0] + w.scratchLineSegsTmp = tmp[:0] + + return len(segs) +} + +func (w *World) drawLineInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, l Line, allowWrap bool) { + w.drawLineInTilePath(drawer, plan, td, l, allowWrap) + drawer.Stroke() } diff --git a/client/world/renderer_drawitems_reuse_bench_test.go b/client/world/renderer_drawitems_reuse_bench_test.go new file mode 100644 index 0000000..c372781 --- /dev/null +++ b/client/world/renderer_drawitems_reuse_bench_test.go @@ -0,0 +1,53 @@ +package world + +import ( + "image/color" + "testing" + + "github.com/fogleman/gg" +) + +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.buildRenderPlanStageA(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) + } +} diff --git a/client/world/renderer_helper.go b/client/world/renderer_helper.go new file mode 100644 index 0000000..d7f66d3 --- /dev/null +++ b/client/world/renderer_helper.go @@ -0,0 +1,36 @@ +package world + +func (w *World) candSeenResetIfOverflow() { + w.candEpoch++ + if w.candEpoch != 0 { + return + } + // overflow: reset stamp array + for i := range w.candStamp { + w.candStamp[i] = 0 + } + w.candEpoch = 1 +} + +func (w *World) candSeenMark(id PrimitiveID) bool { + // ensure stamp capacity + uid := uint32(id) + if int(uid) >= len(w.candStamp) { + // grow to next power-ish + n := len(w.candStamp) + if n == 0 { + n = 1024 + } + for n <= int(uid) { + n *= 2 + } + ns := make([]uint32, n) + copy(ns, w.candStamp) + w.candStamp = ns + } + if w.candStamp[uid] == w.candEpoch { + return true + } + w.candStamp[uid] = w.candEpoch + return false +} diff --git a/client/world/renderer_incremental_render_test.go b/client/world/renderer_incremental_render_test.go index a93369c..a618c3a 100644 --- a/client/world/renderer_incremental_render_test.go +++ b/client/world/renderer_incremental_render_test.go @@ -99,16 +99,10 @@ func TestRender_PanTooLarge_FallsBackToFullRedraw(t *testing.T) { // Full redraw should NOT call CopyShift. require.Empty(t, d.CommandsByName("CopyShift")) - // Should contain at least one reasonably wide clip. - clipCmds := d.CommandsByName("ClipRect") - require.NotEmpty(t, clipCmds) + // Full redraw should clear the entire canvas. + require.NotEmpty(t, d.CommandsByName("ClearAllTo")) - foundWide := false - for _, c := range clipCmds { - if int(c.Args[2]) > 1 { - foundWide = true - break - } - } - require.True(t, foundWide) + // And should draw something (at least the point). + // Depending on your implementation, it might be AddPoint or AddCircle/AddLine as well. + require.NotEmpty(t, d.CommandsByName("AddPoint")) } diff --git a/client/world/renderer_lines.go b/client/world/renderer_lines.go deleted file mode 100644 index ca11ad4..0000000 --- a/client/world/renderer_lines.go +++ /dev/null @@ -1,221 +0,0 @@ -package world - -// lineSeg is one canonical segment (endpoints in [0..W) x [0..H)) to be drawn. -// It represents part of the torus-shortest polyline for a Line primitive after wrap splitting. -type lineSeg struct { - x1, y1 int - x2, y2 int -} - -// drawLinesFromPlan executes a lines-only draw from an already built render plan. -func drawLinesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH int, allowWrap bool) { - for _, td := range plan.Tiles { - if td.ClipW <= 0 || td.ClipH <= 0 { - continue - } - - // Filter only lines; skip tiles that have no lines. - lines := make([]Line, 0, len(td.Candidates)) - for _, it := range td.Candidates { - l, ok := it.(Line) - if !ok { - continue - } - lines = append(lines, l) - } - if len(lines) == 0 { - continue - } - - // Collect segments that actually intersect this tile's canonical rect. - segsToDraw := make([]lineSeg, 0, len(lines)) - for _, l := range lines { - var segs []lineSeg - if allowWrap { - segs = torusShortestLineSegments(l, worldW, worldH) - } else { - segs = []lineSeg{{x1: l.X1, y1: l.Y1, x2: l.X2, y2: l.Y2}} - } - for _, s := range segs { - if segmentIntersectsRect(s, td.Tile.Rect) { - segsToDraw = append(segsToDraw, s) - } - } - } - if len(segsToDraw) == 0 { - continue - } - - drawer.Save() - drawer.ClipRect(float64(td.ClipX), float64(td.ClipY), float64(td.ClipW), float64(td.ClipH)) - - for _, s := range segsToDraw { - // Project endpoints for this tile copy by adding tile offset. - x1Px := worldSpanFixedToCanvasPx((s.x1+td.Tile.OffsetX)-plan.WorldRect.minX, plan.ZoomFp) - y1Px := worldSpanFixedToCanvasPx((s.y1+td.Tile.OffsetY)-plan.WorldRect.minY, plan.ZoomFp) - - x2Px := worldSpanFixedToCanvasPx((s.x2+td.Tile.OffsetX)-plan.WorldRect.minX, plan.ZoomFp) - y2Px := worldSpanFixedToCanvasPx((s.y2+td.Tile.OffsetY)-plan.WorldRect.minY, plan.ZoomFp) - - drawer.AddLine(float64(x1Px), float64(y1Px), float64(x2Px), float64(y2Px)) - } - - drawer.Stroke() - drawer.Restore() - } -} - -// torusShortestLineSegments converts a Line primitive into 1..4 canonical segments -// inside [0..worldW) x [0..worldH) that represent the torus-shortest polyline. -// -// IMPORTANT: when a segment crosses a torus boundary, the second segment starts -// on the opposite boundary (e.g. x=0 jump to x=worldW), preserving continuity on the torus. -// We must NOT wrap endpoints independently at the end, otherwise the topology changes. -func torusShortestLineSegments(l Line, worldW, worldH int) []lineSeg { - // Step 1: choose the torus-shortest representation in unwrapped space. - ax, bx := shortestWrappedDelta(l.X1, l.X2, worldW) - ay, by := shortestWrappedDelta(l.Y1, l.Y2, worldH) - - // Step 2: shift so that A is inside canonical [0..W) x [0..H). - shiftX := floorDiv(ax, worldW) * worldW - shiftY := floorDiv(ay, worldH) * worldH - - ax -= shiftX - bx -= shiftX - ay -= shiftY - by -= shiftY - - segs := []lineSeg{{x1: ax, y1: ay, x2: bx, y2: by}} - - // Step 3: split by X boundary if needed (jump-aware). - segs = splitSegmentsByX(segs, worldW) - - // Step 4: split by Y boundary if needed (jump-aware). - segs = splitSegmentsByY(segs, worldH) - - // Now all segments are canonical and torus-continuous. Endpoints may legally be 0 or worldW/worldH. - return segs -} - -func splitSegmentsByX(segs []lineSeg, worldW int) []lineSeg { - out := make([]lineSeg, 0, len(segs)*2) - - for _, s := range segs { - x1, y1, x2, y2 := s.x1, s.y1, s.x2, s.y2 - - // After normalization, x1 is expected inside [0..worldW). Only x2 may be outside. - if x2 >= 0 && x2 < worldW { - out = append(out, s) - continue - } - - dx := x2 - x1 - dy := y2 - y1 - if dx == 0 { - // Degenerate; keep as-is (should not happen with normalized x1 unless x2==x1). - out = append(out, s) - continue - } - - if x2 >= worldW { - // Crosses the right boundary at x=worldW, then reappears at x=0. - bx := worldW - num := bx - x1 - iy := y1 + (dy*num)/dx - - // Segment 1: [x1..worldW] - // Segment 2: [0..x2-worldW] - s1 := lineSeg{x1: x1, y1: y1, x2: worldW, y2: iy} - s2 := lineSeg{x1: 0, y1: iy, x2: x2 - worldW, y2: y2} - out = append(out, s1, s2) - continue - } - - // x2 < 0: crosses the left boundary at x=0, then reappears at x=worldW. - bx := 0 - num := bx - x1 - iy := y1 + (dy*num)/dx - - // Segment 1: [x1..0] - // Segment 2: [worldW..x2+worldW] - s1 := lineSeg{x1: x1, y1: y1, x2: 0, y2: iy} - s2 := lineSeg{x1: worldW, y1: iy, x2: x2 + worldW, y2: y2} - out = append(out, s1, s2) - } - - return out -} - -func splitSegmentsByY(segs []lineSeg, worldH int) []lineSeg { - out := make([]lineSeg, 0, len(segs)*2) - - for _, s := range segs { - x1, y1, x2, y2 := s.x1, s.y1, s.x2, s.y2 - - // After normalization, y1 is expected inside [0..worldH). Only y2 may be outside. - if y2 >= 0 && y2 < worldH { - out = append(out, s) - continue - } - - dx := x2 - x1 - dy := y2 - y1 - if dy == 0 { - out = append(out, s) - continue - } - - if y2 >= worldH { - // Crosses the top boundary at y=worldH, then reappears at y=0. - by := worldH - num := by - y1 - ix := x1 + (dx*num)/dy - - s1 := lineSeg{x1: x1, y1: y1, x2: ix, y2: worldH} - s2 := lineSeg{x1: ix, y1: 0, x2: x2, y2: y2 - worldH} - out = append(out, s1, s2) - continue - } - - // y2 < 0: crosses the bottom boundary at y=0, then reappears at y=worldH. - by := 0 - num := by - y1 - ix := x1 + (dx*num)/dy - - s1 := lineSeg{x1: x1, y1: y1, x2: ix, y2: 0} - s2 := lineSeg{x1: ix, y1: worldH, x2: x2, y2: y2 + worldH} - out = append(out, s1, s2) - } - return out -} - -// segmentIntersectsRect is a coarse bbox intersection check between a segment and a half-open rect. -// It is used only as an optimization to avoid drawing clearly irrelevant segments. -// -// NOTE: Segment endpoints may legally be exactly on the world boundary (x==worldW or y==worldH). -// Rect is half-open [min, max), so we treat the segment bbox as inclusive and intersect it with -// [r.min, r.max-1] to avoid dropping boundary-touching segments. -func segmentIntersectsRect(s lineSeg, r Rect) bool { - minX := min(s.x1, s.x2) - maxX := max(s.x1, s.x2) - minY := min(s.y1, s.y2) - maxY := max(s.y1, s.y2) - - // Treat degenerate as having 1-unit thickness for indexing-like behavior. - if minX == maxX { - maxX++ - } - if minY == maxY { - maxY++ - } - - // Rect inclusive bounds for intersection purposes. - rMaxX := r.maxX - 1 - rMaxY := r.maxY - 1 - if rMaxX < r.minX || rMaxY < r.minY { - return false - } - - // Inclusive overlap check. - return !(maxX-1 < r.minX || minX > rMaxX || maxY-1 < r.minY || minY > rMaxY) -} diff --git a/client/world/renderer_lines_test.go b/client/world/renderer_lines_test.go index 2f20cae..64fc013 100644 --- a/client/world/renderer_lines_test.go +++ b/client/world/renderer_lines_test.go @@ -6,132 +6,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestDrawLinesFromPlan_WrapX_SplitsAndDrawsInThreeXTiles(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - // Horizontal line that wraps across X: 9 -> 1 at y=5. - id, err := w.AddLine(9, 5, 1, 5) - require.NoError(t, err) - w.indexObject(w.objects[id]) - - params := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 10, - MarginXPx: 2, - MarginYPx: 2, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - plan, err := w.buildRenderPlanStageA(params) - require.NoError(t, err) - - d := &fakePrimitiveDrawer{} - drawLinesFromPlan(d, plan, w.W, w.H, true) - - // Expect drawing in 3 X tiles (left partial, middle full, right partial) for the central Y tile: - // Each tile group: Save, ClipRect, AddLine(s), Stroke, Restore - // - // Left tile (offsetX=-10000): 1 line segment. - // Middle tile (offsetX=0): 2 segments (wrapped split). - // Right tile (offsetX=10000): 1 segment. - wantNames := []string{ - "Save", "ClipRect", "AddLine", "Stroke", "Restore", - "Save", "ClipRect", "AddLine", "AddLine", "Stroke", "Restore", - "Save", "ClipRect", "AddLine", "Stroke", "Restore", - } - require.Equal(t, wantNames, d.CommandNames()) - - // Group 1: left strip clip (0,2,2,10), line at y=7 from x=1..2 - { - requireCommandArgs(t, requireDrawerCommandAt(t, d, 1), 0, 2, 2, 10) - requireCommandArgs(t, requireDrawerCommandAt(t, d, 2), 1, 7, 2, 7) - } - - // Group 2: middle strip clip (2,2,10,10), two segments: - // segment [9000..10000] => x 11..12, y 7 - // segment [0..1000] => x 2..3, y 7 - { - requireCommandArgs(t, requireDrawerCommandAt(t, d, 6), 2, 2, 10, 10) - - // The order of segments is stable with our splitting: first the one ending at boundary, then the remainder. - requireCommandArgs(t, requireDrawerCommandAt(t, d, 7), 11, 7, 12, 7) - requireCommandArgs(t, requireDrawerCommandAt(t, d, 8), 2, 7, 3, 7) - } - - // Group 3: right strip clip (12,2,2,10), line at y=7 from x=12..13 - { - requireCommandArgs(t, requireDrawerCommandAt(t, d, 12), 12, 2, 2, 10) // ClipRect - requireCommandArgs(t, requireDrawerCommandAt(t, d, 13), 12, 7, 13, 7) // AddLine - } -} - -func TestDrawLinesFromPlan_WrapY_SplitsAndDrawsInThreeYTiles(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - // Vertical line that wraps across Y: 9 -> 1 at x=5. - id, err := w.AddLine(5, 9, 5, 1) - require.NoError(t, err) - w.indexObject(w.objects[id]) - - params := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 10, - MarginXPx: 2, - MarginYPx: 2, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - plan, err := w.buildRenderPlanStageA(params) - require.NoError(t, err) - - d := &fakePrimitiveDrawer{} - drawLinesFromPlan(d, plan, w.W, w.H, true) - - // Here we expect 3 Y tiles for the central X tile: - // Top partial, middle full (two segments), bottom partial. - // - // The exact ordering of tiles is by tx then ty, so X=middle strips first. - // For this geometry the line only intersects the middle X tiles (offsetX=0), - // but spans Y across -1,0,1. - wantNames := []string{ - "Save", "ClipRect", "AddLine", "Stroke", "Restore", - "Save", "ClipRect", "AddLine", "AddLine", "Stroke", "Restore", - "Save", "ClipRect", "AddLine", "Stroke", "Restore", - } - require.Equal(t, wantNames, d.CommandNames()) - - // Group 1: top strip clip (2,0,10,2), line at x=7 from y=1..2 - { - requireCommandArgs(t, requireDrawerCommandAt(t, d, 1), 2, 0, 10, 2) - requireCommandArgs(t, requireDrawerCommandAt(t, d, 2), 7, 1, 7, 2) - } - - // Group 2: middle strip clip (2,2,10,10), two segments: - // segment [9000..10000] => y 11..12 at x=7 - // segment [0..1000] => y 2..3 at x=7 - { - requireCommandArgs(t, requireDrawerCommandAt(t, d, 6), 2, 2, 10, 10) - requireCommandArgs(t, requireDrawerCommandAt(t, d, 7), 7, 11, 7, 12) - requireCommandArgs(t, requireDrawerCommandAt(t, d, 8), 7, 2, 7, 3) - } - - // Group 3: bottom strip clip (2,12,10,2), line at x=7 from y=12..13 - { - requireCommandArgs(t, requireDrawerCommandAt(t, d, 12), 2, 12, 10, 2) // ClipRect - requireCommandArgs(t, requireDrawerCommandAt(t, d, 13), 7, 12, 7, 13) // AddLine - } -} - func TestTorusShortestLineSegments_TieCaseIsDeterministicAndSplits(t *testing.T) { t.Parallel() diff --git a/client/world/renderer_plan_bench_test.go b/client/world/renderer_plan_bench_test.go new file mode 100644 index 0000000..c894611 --- /dev/null +++ b/client/world/renderer_plan_bench_test.go @@ -0,0 +1,53 @@ +package world + +import ( + "image/color" + "testing" +) + +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.buildRenderPlanStageA(params) + if err != nil { + b.Fatalf("build plan: %v", err) + } + } +} diff --git a/client/world/renderer_query.go b/client/world/renderer_query.go index 887ec4a..739579c 100644 --- a/client/world/renderer_query.go +++ b/client/world/renderer_query.go @@ -57,22 +57,36 @@ func (w *World) collectCandidatesForTile(r Rect) []MapItem { rowStart := w.worldToCellY(r.minY) rowEnd := w.worldToCellY(r.maxY - 1) - seen := make(map[PrimitiveID]struct{}) - result := make([]MapItem, 0) + // Start a new epoch for this tile dedupe. + w.candSeenResetIfOverflow() + + // Reuse result buffer. + out := w.scratchCandidates[:0] for row := rowStart; row <= rowEnd; row++ { for col := colStart; col <= colEnd; col++ { cell := w.grid[row][col] for _, item := range cell { id := item.ID() - if _, ok := seen[id]; ok { + if w.candSeenMark(id) { continue } - seen[id] = struct{}{} - result = append(result, item) + out = append(out, item) } } } - return result + // Store back the reusable buffer (keep capacity). + w.scratchCandidates = out[:0] + + // IMPORTANT: + // We must return a stable slice to the caller (plan stores it). + // Returning `out` directly would be overwritten on the next tile. + // + // So: copy out into a freshly allocated slice OR into a plan-level scratch pool. + // For Step 1 we keep correctness: allocate exactly once per tile. + // Step 3 will remove this allocation by making plan own a pooled backing store. + res := make([]MapItem, len(out)) + copy(res, out) + return res } diff --git a/client/world/renderer_smoke_all_primitives_test.go b/client/world/renderer_smoke_all_primitives_test.go deleted file mode 100644 index 64cfc05..0000000 --- a/client/world/renderer_smoke_all_primitives_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package world - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestSmoke_DrawPointsCirclesLinesFromSamePlan(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - // Mix primitives. Use values that are safely inside the world. - _, err := w.AddPoint(1, 1) - require.NoError(t, err) - - _, err = w.AddCircle(2, 2, 1) - require.NoError(t, err) - - // A line that wraps across X to ensure line splitting logic is exercised. - _, err = w.AddLine(9, 5, 1, 5) - require.NoError(t, err) - - // Build index (in UI: IndexOnViewportChange does this). - for _, obj := range w.objects { - w.indexObject(obj) - } - - params := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 10, - MarginXPx: 2, - MarginYPx: 2, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - plan, err := w.buildRenderPlanStageA(params) - require.NoError(t, err) - - d := &fakePrimitiveDrawer{} - - // Execute all three passes over the same plan. - drawPointsFromPlan(d, plan, true) - drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp) - drawLinesFromPlan(d, plan, w.W, w.H, true) - - names := d.CommandNames() - - require.Contains(t, names, "AddPoint") - require.Contains(t, names, "AddCircle") - require.Contains(t, names, "AddLine") - - // Ensure finalizers were used (points+circles use Fill, lines use Stroke). - require.Contains(t, names, "Fill") - require.Contains(t, names, "Stroke") -} diff --git a/client/world/torus_line_segments_into.go b/client/world/torus_line_segments_into.go new file mode 100644 index 0000000..c65c7c5 --- /dev/null +++ b/client/world/torus_line_segments_into.go @@ -0,0 +1,135 @@ +package world + +// torusShortestLineSegmentsInto converts a Line primitive into 1..4 canonical segments +// inside [0..worldW) x [0..worldH) that represent the torus-shortest polyline. +// +// It appends results into dst using tmp as an intermediate buffer. +// No allocations occur if dst/tmp have sufficient capacity (>=4). +func torusShortestLineSegmentsInto(dst, tmp []lineSeg, l Line, worldW, worldH int) ([]lineSeg, []lineSeg) { + dst = dst[:0] + tmp = tmp[:0] + + // Step 1: choose the torus-shortest representation in unwrapped space. + ax, bx := shortestWrappedDelta(l.X1, l.X2, worldW) + ay, by := shortestWrappedDelta(l.Y1, l.Y2, worldH) + + // Step 2: shift so that A is inside canonical [0..W) x [0..H). + shiftX := floorDiv(ax, worldW) * worldW + shiftY := floorDiv(ay, worldH) * worldH + + ax -= shiftX + bx -= shiftX + ay -= shiftY + by -= shiftY + + dst = append(dst, lineSeg{x1: ax, y1: ay, x2: bx, y2: by}) + + // Step 3: split by X boundary if needed (jump-aware). + tmp = splitSegmentsByXInto(tmp, dst, worldW) + + // Step 4: split by Y boundary if needed (jump-aware). + dst = splitSegmentsByYInto(dst, tmp, worldH) + + return dst, tmp +} + +// torusShortestLineSegments is a compatibility wrapper that allocates. +// Prefer torusShortestLineSegmentsInto in hot paths. +func torusShortestLineSegments(l Line, worldW, worldH int) []lineSeg { + dst := make([]lineSeg, 0, 4) + tmp := make([]lineSeg, 0, 4) + dst, _ = torusShortestLineSegmentsInto(dst, tmp, l, worldW, worldH) + return dst +} + +// splitSegmentsByXInto appends 1..2 segments for each input segment into out, without allocating. +// out is reset to length 0 by this function. +func splitSegmentsByXInto(out []lineSeg, segs []lineSeg, worldW int) []lineSeg { + out = out[:0] + + for _, s := range segs { + x1, y1, x2, y2 := s.x1, s.y1, s.x2, s.y2 + + // After normalization, x1 is expected inside [0..worldW). Only x2 may be outside. + if x2 >= 0 && x2 < worldW { + out = append(out, s) + continue + } + + dx := x2 - x1 + dy := y2 - y1 + if dx == 0 { + out = append(out, s) + continue + } + + if x2 >= worldW { + // Crosses the right boundary at x=worldW, then reappears at x=0. + bx := worldW + num := bx - x1 + iy := y1 + (dy*num)/dx + + s1 := lineSeg{x1: x1, y1: y1, x2: worldW, y2: iy} + s2 := lineSeg{x1: 0, y1: iy, x2: x2 - worldW, y2: y2} + out = append(out, s1, s2) + continue + } + + // x2 < 0: crosses the left boundary at x=0, then reappears at x=worldW. + bx := 0 + num := bx - x1 + iy := y1 + (dy*num)/dx + + s1 := lineSeg{x1: x1, y1: y1, x2: 0, y2: iy} + s2 := lineSeg{x1: worldW, y1: iy, x2: x2 + worldW, y2: y2} + out = append(out, s1, s2) + } + + return out +} + +// splitSegmentsByYInto appends 1..2 segments for each input segment into out, without allocating. +// out is reset to length 0 by this function. +func splitSegmentsByYInto(out []lineSeg, segs []lineSeg, worldH int) []lineSeg { + out = out[:0] + + for _, s := range segs { + x1, y1, x2, y2 := s.x1, s.y1, s.x2, s.y2 + + // After normalization, y1 is expected inside [0..worldH). Only y2 may be outside. + if y2 >= 0 && y2 < worldH { + out = append(out, s) + continue + } + + dx := x2 - x1 + dy := y2 - y1 + if dy == 0 { + out = append(out, s) + continue + } + + if y2 >= worldH { + // Crosses the top boundary at y=worldH, then reappears at y=0. + by := worldH + num := by - y1 + ix := x1 + (dx*num)/dy + + s1 := lineSeg{x1: x1, y1: y1, x2: ix, y2: worldH} + s2 := lineSeg{x1: ix, y1: 0, x2: x2, y2: y2 - worldH} + out = append(out, s1, s2) + continue + } + + // y2 < 0: crosses the bottom boundary at y=0, then reappears at y=worldH. + by := 0 + num := by - y1 + ix := x1 + (dx*num)/dy + + s1 := lineSeg{x1: x1, y1: y1, x2: ix, y2: 0} + s2 := lineSeg{x1: ix, y1: worldH, x2: x2, y2: y2 + worldH} + out = append(out, s1, s2) + } + + return out +} diff --git a/client/world/world.go b/client/world/world.go index ed5f114..46b77f8 100644 --- a/client/world/world.go +++ b/client/world/world.go @@ -50,6 +50,17 @@ type World struct { renderState rendererIncrementalState derivedCache map[derivedStyleKey]StyleID + + // scratch buffers for hot render path (single goroutine assumption). + scratchDrawItems []drawItem + scratchWrapShifts []wrapShift + scratchLineSegs []lineSeg + scratchLineSegsTmp []lineSeg + + // candidate dedupe scratch (hot path for plan building). + candStamp []uint32 + candEpoch uint32 + scratchCandidates []MapItem } // NewWorld constructs a new world with the given real dimensions.