draw optimizations

This commit is contained in:
IliaDenisov
2026-03-08 23:30:11 +02:00
parent fdcbb5d6f4
commit ac35360d60
18 changed files with 875 additions and 566 deletions
+43 -29
View File
@@ -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)
}
+40
View File
@@ -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")
}
+7
View File
@@ -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]
}
+56 -25
View File
@@ -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,11 +272,10 @@ 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)
if len(toDraw) == 0 {
return nil
}
plan, err := w.buildRenderPlanStageA(params)
@@ -257,11 +283,14 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
return err
}
// Build once for the whole set of catch-up rects (union), then clip per rect.
catchUpPlan := planRestrictedToDirtyRects(plan, toDraw)
w.drawPlanSinglePass(drawer, catchUpPlan, allowWrap)
}
w.renderState.pendingDirty = remaining
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)
}
+78 -33
View File
@@ -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.
// 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
}
minX := cx - radiusFp
maxX := cx + radiusFp
minY := cy - radiusFp
maxY := cy + radiusFp
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 needRight {
dst = append(dst, wrapShift{dx: -worldW, dy: 0})
}
// Y-only copies.
if needTop {
dst = append(dst, wrapShift{dx: 0, dy: +worldH})
}
if needBottom {
dst = append(dst, wrapShift{dx: 0, dy: -worldH})
}
// 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 dst
}
// circleWrapShifts is a compatibility wrapper that allocates.
// Prefer circleWrapShiftsInto in hot paths.
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}}
}
xShifts := []int{0}
yShifts := []int{0}
if cx+radiusFp >= worldW {
xShifts = append(xShifts, -worldW)
}
if cx-radiusFp < 0 {
xShifts = append(xShifts, worldW)
}
if cy+radiusFp >= worldH {
yShifts = append(yShifts, -worldH)
}
if cy-radiusFp < 0 {
yShifts = append(yShifts, 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})
}
}
return out
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.
+110 -36
View File
@@ -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))
for _, di := range items {
// 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)
switch di.kind {
case drawKindPoint:
w.drawPointInTile(drawer, plan, td, *di.p, allowWrap, lastStyle)
w.drawPointInTile(drawer, plan, td, di.p, allowWrap, lastStyle)
case drawKindCircle:
w.drawCircleInTile(drawer, plan, td, *di.c, allowWrap, lastStyle)
w.drawCircleInTile(drawer, plan, td, di.c, allowWrap, lastStyle)
case drawKindLine:
w.drawLineInTile(drawer, plan, td, *di.l, allowWrap)
// 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()
}
if tileClipEnabled {
drawer.Restore()
}
// Reuse buffer for next tile.
w.scratchDrawItems = items[:0]
}
}
@@ -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.)
}
+54 -14
View File
@@ -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()
}
@@ -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)
}
}
+36
View File
@@ -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
}
@@ -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"))
}
-221
View File
@@ -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)
}
-126
View File
@@ -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()
+53
View File
@@ -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)
}
}
}
+20 -6
View File
@@ -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
}
@@ -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")
}
+135
View File
@@ -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
}
+11
View File
@@ -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.