draw optimizations
This commit is contained in:
+42
-28
@@ -290,19 +290,26 @@ func (d *GGDrawer) ClearAllTo(bg color.Color) {
|
|||||||
panic("GGDrawer.ClearAllTo: backing image is not *image.RGBA")
|
panic("GGDrawer.ClearAllTo: backing image is not *image.RGBA")
|
||||||
}
|
}
|
||||||
|
|
||||||
r, g, b, a := bg.RGBA()
|
R, G, B, A := rgba8(bg)
|
||||||
// Convert from 16-bit range to 8-bit.
|
|
||||||
R := byte(r >> 8)
|
|
||||||
G := byte(g >> 8)
|
|
||||||
B := byte(b >> 8)
|
|
||||||
A := byte(a >> 8)
|
|
||||||
|
|
||||||
p := img.Pix
|
// Prepare one full scanline once.
|
||||||
for i := 0; i+3 < len(p); i += 4 {
|
w := img.Bounds().Dx()
|
||||||
p[i+0] = R
|
if w <= 0 {
|
||||||
p[i+1] = G
|
return
|
||||||
p[i+2] = B
|
}
|
||||||
p[i+3] = A
|
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,31 +323,38 @@ func (d *GGDrawer) ClearRectTo(x, y, w, h int, bg color.Color) {
|
|||||||
panic("GGDrawer.ClearRectTo: backing image is not *image.RGBA")
|
panic("GGDrawer.ClearRectTo: backing image is not *image.RGBA")
|
||||||
}
|
}
|
||||||
|
|
||||||
bounds := img.Bounds()
|
b := img.Bounds()
|
||||||
x0 := max(x, bounds.Min.X)
|
x0 := max(x, b.Min.X)
|
||||||
y0 := max(y, bounds.Min.Y)
|
y0 := max(y, b.Min.Y)
|
||||||
x1 := min(x+w, bounds.Max.X)
|
x1 := min(x+w, b.Max.X)
|
||||||
y1 := min(y+h, bounds.Max.Y)
|
y1 := min(y+h, b.Max.Y)
|
||||||
if x0 >= x1 || y0 >= y1 {
|
if x0 >= x1 || y0 >= y1 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
r, g, b, a := bg.RGBA()
|
R, G, B, A := rgba8(bg)
|
||||||
R := byte(r >> 8)
|
|
||||||
G := byte(g >> 8)
|
rowPx := x1 - x0
|
||||||
B := byte(b >> 8)
|
rowBytes := rowPx * 4
|
||||||
A := byte(a >> 8)
|
|
||||||
|
// 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++ {
|
for yy := y0; yy < y1; yy++ {
|
||||||
off := yy*img.Stride + x0*4
|
off := yy*img.Stride + x0*4
|
||||||
for i := 0; i < rowBytes; i += 4 {
|
copy(img.Pix[off:off+rowBytes], line)
|
||||||
img.Pix[off+i+0] = R
|
|
||||||
img.Pix[off+i+1] = G
|
|
||||||
img.Pix[off+i+2] = B
|
|
||||||
img.Pix[off+i+3] = A
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func (g *GGDrawer) DrawImage(img image.Image, x, y int) {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeClipRect describes one clip rectangle in canvas pixel coordinates.
|
// fakeClipRect describes one clip rectangle in canvas pixel coordinates.
|
||||||
@@ -66,6 +67,7 @@ type fakePrimitiveDrawer struct {
|
|||||||
commands []fakeDrawerCommand
|
commands []fakeDrawerCommand
|
||||||
state fakeDrawerState
|
state fakeDrawerState
|
||||||
stack []fakeDrawerState
|
stack []fakeDrawerState
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure fakePrimitiveDrawer implements PrimitiveDrawer.
|
// 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) {
|
func (d *fakePrimitiveDrawer) DrawImageScaled(_ image.Image, x, y, w, h int) {
|
||||||
d.snapshotCommand("DrawImageScaled", float64(x), float64(y), float64(w), float64(h))
|
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
@@ -15,6 +15,13 @@ const (
|
|||||||
RenderLayerLines
|
RenderLayerLines
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
drawPlanSinglePassClipEnabled = false
|
||||||
|
|
||||||
|
// best value according to BenchmarkDrawPlanSinglePass_Lines_GG
|
||||||
|
maxLineSegmentsPerStroke = 32
|
||||||
|
)
|
||||||
|
|
||||||
// RenderOptions controls which layers are rendered and their order.
|
// RenderOptions controls which layers are rendered and their order.
|
||||||
// If Layers is empty, the default order is: Points, Circles, Lines.
|
// If Layers is empty, the default order is: Points, Circles, Lines.
|
||||||
type RenderOptions struct {
|
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
|
var bg color.Color = color.RGBA{A: 255} // default black
|
||||||
|
|
||||||
if params.Options != nil && params.Options.BackgroundColor != nil {
|
if params.Options != nil && params.Options.BackgroundColor != nil {
|
||||||
if v, ok := params.Options.BackgroundColor.(color.RGBA); !ok {
|
bg = params.Options.BackgroundColor
|
||||||
panic("Options.BackgroundColor is not color.RGBA type")
|
|
||||||
} else {
|
|
||||||
bg = v
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
tc := w.Theme().BackgroundColor()
|
tc := w.Theme().BackgroundColor()
|
||||||
if alphaNonZero(tc) {
|
if alphaNonZero(tc) {
|
||||||
@@ -225,6 +228,30 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
|
|||||||
policy = *params.Options.Incremental
|
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 ---
|
// --- Try incremental path first when state is initialized and geometry matches ---
|
||||||
dxPx, dyPx, derr := w.ComputePanShiftPx(params)
|
dxPx, dyPx, derr := w.ComputePanShiftPx(params)
|
||||||
if derr == nil {
|
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 we accumulated dirty regions during shift-only frames, redraw them now (bounded).
|
||||||
if len(w.renderState.pendingDirty) > 0 {
|
if len(w.renderState.pendingDirty) > 0 {
|
||||||
toDraw, remaining := takeCatchUpRects(w.renderState.pendingDirty, policy.MaxCatchUpAreaPx)
|
toDraw, remaining := takeCatchUpRects(w.renderState.pendingDirty, policy.MaxCatchUpAreaPx)
|
||||||
|
w.renderState.pendingDirty = remaining
|
||||||
|
|
||||||
if len(toDraw) > 0 {
|
if len(toDraw) == 0 {
|
||||||
for _, r := range toDraw {
|
return nil
|
||||||
drawer.ClearRectTo(r.X, r.Y, r.W, r.H, bg)
|
|
||||||
w.drawBackground(drawer, params, r)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
plan, err := w.buildRenderPlanStageA(params)
|
plan, err := w.buildRenderPlanStageA(params)
|
||||||
@@ -257,11 +283,14 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build once for the whole set of catch-up rects (union), then clip per rect.
|
||||||
catchUpPlan := planRestrictedToDirtyRects(plan, toDraw)
|
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
|
return nil
|
||||||
|
|
||||||
@@ -276,7 +305,7 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
|
|||||||
}
|
}
|
||||||
w.renderState.pendingDirty = moved
|
w.renderState.pendingDirty = moved
|
||||||
}
|
}
|
||||||
// C5: shift backing pixels, then redraw only dirty strips.
|
// Shift backing pixels first.
|
||||||
drawer.CopyShift(inc.DxPx, inc.DyPx)
|
drawer.CopyShift(inc.DxPx, inc.DyPx)
|
||||||
|
|
||||||
overBudget := false
|
overBudget := false
|
||||||
@@ -294,17 +323,9 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
|
|||||||
return nil
|
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.
|
// Under budget: draw newly exposed strips immediately, plus bounded catch-up.
|
||||||
dirtyToDraw := inc.Dirty
|
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.
|
// Additionally redraw a bounded portion of deferred dirty regions.
|
||||||
if len(w.renderState.pendingDirty) > 0 {
|
if len(w.renderState.pendingDirty) > 0 {
|
||||||
catchUp, remaining := takeCatchUpRects(w.renderState.pendingDirty, policy.MaxCatchUpAreaPx)
|
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
|
w.renderState.pendingDirty = remaining
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(dirtyToDraw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
plan, err := w.buildRenderPlanStageA(params)
|
plan, err := w.buildRenderPlanStageA(params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
|
|
||||||
case IncrementalFullRedraw:
|
case IncrementalFullRedraw:
|
||||||
@@ -337,7 +368,7 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
|
|||||||
|
|
||||||
drawer.ClearAllTo(bg)
|
drawer.ClearAllTo(bg)
|
||||||
w.drawBackground(drawer, params, RectPx{X: 0, Y: 0, W: params.CanvasWidthPx(), H: params.CanvasHeightPx()})
|
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)
|
return w.CommitFullRedrawState(params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,40 +73,85 @@ type wrapShift struct {
|
|||||||
dy int
|
dy int
|
||||||
}
|
}
|
||||||
|
|
||||||
// circleWrapShifts returns 1..4 wrap shifts (multiples of worldW/worldH) required to render
|
// circleWrapShiftsInto appends required torus-copy shifts for a circle into dst and returns the resulting slice.
|
||||||
// all torus copies of the circle inside the canonical world domain.
|
// It never allocates if dst has enough capacity.
|
||||||
// The (0,0) shift is always present.
|
//
|
||||||
|
// 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 {
|
func circleWrapShifts(cx, cy, radiusFp, worldW, worldH int) []wrapShift {
|
||||||
// If radius covers the whole axis, additional copies are not useful.
|
var dst []wrapShift
|
||||||
// (One copy already covers everything under any reasonable clip.)
|
return circleWrapShiftsInto(dst, cx, cy, radiusFp, worldW, worldH)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// circleCopyIntersectsTile checks whether the circle copy (shifted by dx/dy) intersects the tile segment.
|
// circleCopyIntersectsTile checks whether the circle copy (shifted by dx/dy) intersects the tile segment.
|
||||||
|
|||||||
+110
-36
@@ -20,9 +20,9 @@ type drawItem struct {
|
|||||||
styleID StyleID
|
styleID StyleID
|
||||||
|
|
||||||
// Exactly one of these is set.
|
// Exactly one of these is set.
|
||||||
p *Point
|
p Point
|
||||||
c *Circle
|
c Circle
|
||||||
l *Line
|
l Line
|
||||||
}
|
}
|
||||||
|
|
||||||
// drawPlanSinglePass renders a plan using a single ordered pass per tile.
|
// drawPlanSinglePass renders a plan using a single ordered pass per tile.
|
||||||
@@ -31,7 +31,9 @@ type drawItem struct {
|
|||||||
// allowWrap controls torus behavior:
|
// allowWrap controls torus behavior:
|
||||||
// - true: circles/points produce wrap copies, lines use torus-shortest segments
|
// - true: circles/points produce wrap copies, lines use torus-shortest segments
|
||||||
// - false: no copies, lines drawn directly as stored
|
// - 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 lastStyleID StyleID = StyleIDInvalid
|
||||||
var lastStyle Style
|
var lastStyle Style
|
||||||
|
|
||||||
@@ -41,11 +43,9 @@ func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allo
|
|||||||
}
|
}
|
||||||
s, ok := w.styles.Get(styleID)
|
s, ok := w.styles.Get(styleID)
|
||||||
if !ok {
|
if !ok {
|
||||||
// Unknown style ID is a programming/config error.
|
|
||||||
panic("render: unknown style ID")
|
panic("render: unknown style ID")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply style state. Some fields may be nil intentionally.
|
|
||||||
if s.FillColor != nil {
|
if s.FillColor != nil {
|
||||||
drawer.SetFillColor(s.FillColor)
|
drawer.SetFillColor(s.FillColor)
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,6 @@ func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allo
|
|||||||
if len(s.StrokeDashes) > 0 {
|
if len(s.StrokeDashes) > 0 {
|
||||||
drawer.SetDash(s.StrokeDashes...)
|
drawer.SetDash(s.StrokeDashes...)
|
||||||
} else {
|
} else {
|
||||||
// Ensure solid line when switching from dashed style.
|
|
||||||
drawer.SetDash()
|
drawer.SetDash()
|
||||||
}
|
}
|
||||||
drawer.SetDashOffset(s.StrokeDashOffset)
|
drawer.SetDashOffset(s.StrokeDashOffset)
|
||||||
@@ -70,52 +69,61 @@ func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allo
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect items for this tile.
|
// Per-tile clip is optional. When outer-clip is used (dirty rect),
|
||||||
items := make([]drawItem, 0, len(td.Candidates))
|
// 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 {
|
for _, it := range td.Candidates {
|
||||||
id := it.ID()
|
id := it.ID()
|
||||||
cur, ok := w.objects[id]
|
cur, ok := w.objects[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
// Stale grid entry (object removed). Skip.
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
switch v := cur.(type) {
|
switch v := cur.(type) {
|
||||||
case Point:
|
case Point:
|
||||||
vv := v
|
|
||||||
items = append(items, drawItem{
|
items = append(items, drawItem{
|
||||||
kind: drawKindPoint,
|
kind: drawKindPoint,
|
||||||
priority: vv.Priority,
|
priority: v.Priority,
|
||||||
id: vv.Id,
|
id: v.Id,
|
||||||
styleID: vv.StyleID,
|
styleID: v.StyleID,
|
||||||
p: &vv,
|
p: v,
|
||||||
})
|
})
|
||||||
case Circle:
|
case Circle:
|
||||||
vv := v
|
|
||||||
items = append(items, drawItem{
|
items = append(items, drawItem{
|
||||||
kind: drawKindCircle,
|
kind: drawKindCircle,
|
||||||
priority: vv.Priority,
|
priority: v.Priority,
|
||||||
id: vv.Id,
|
id: v.Id,
|
||||||
styleID: vv.StyleID,
|
styleID: v.StyleID,
|
||||||
c: &vv,
|
c: v,
|
||||||
})
|
})
|
||||||
case Line:
|
case Line:
|
||||||
vv := v
|
|
||||||
items = append(items, drawItem{
|
items = append(items, drawItem{
|
||||||
kind: drawKindLine,
|
kind: drawKindLine,
|
||||||
priority: vv.Priority,
|
priority: v.Priority,
|
||||||
id: vv.Id,
|
id: v.Id,
|
||||||
styleID: vv.StyleID,
|
styleID: v.StyleID,
|
||||||
l: &vv,
|
l: v,
|
||||||
})
|
})
|
||||||
default:
|
default:
|
||||||
// Unknown map items should not exist.
|
|
||||||
panic("render: unknown map item type")
|
panic("render: unknown map item type")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
|
if tileClipEnabled {
|
||||||
|
drawer.Restore()
|
||||||
|
}
|
||||||
|
w.scratchDrawItems = items[:0]
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,27 +138,93 @@ func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allo
|
|||||||
return a.id < b.id
|
return a.id < b.id
|
||||||
})
|
})
|
||||||
|
|
||||||
drawer.Save()
|
// If this is not a dirty pass (full redraw), keep the old behavior for lines:
|
||||||
drawer.ClipRect(float64(td.ClipX), float64(td.ClipY), float64(td.ClipW), float64(td.ClipH))
|
// stroke per segment. This is usually faster for gg on huge scenes.
|
||||||
|
if !isDirtyPass {
|
||||||
for _, di := range items {
|
for i := 0; i < len(items); i++ {
|
||||||
|
di := items[i]
|
||||||
applyStyle(di.styleID)
|
applyStyle(di.styleID)
|
||||||
|
|
||||||
switch di.kind {
|
switch di.kind {
|
||||||
case drawKindPoint:
|
case drawKindPoint:
|
||||||
w.drawPointInTile(drawer, plan, td, *di.p, allowWrap, lastStyle)
|
w.drawPointInTile(drawer, plan, td, di.p, allowWrap, lastStyle)
|
||||||
|
|
||||||
case drawKindCircle:
|
case drawKindCircle:
|
||||||
w.drawCircleInTile(drawer, plan, td, *di.c, allowWrap, lastStyle)
|
w.drawCircleInTile(drawer, plan, td, di.c, allowWrap, lastStyle)
|
||||||
|
|
||||||
case drawKindLine:
|
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:
|
default:
|
||||||
panic("render: unknown draw kind")
|
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()
|
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.)
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
package world
|
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.
|
// drawPointInTile draws point marker copies that intersect the tile.
|
||||||
// lastStyle is already applied; it provides PointRadiusPx.
|
// lastStyle is already applied; it provides PointRadiusPx.
|
||||||
func (w *World) drawPointInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, p Point, allowWrap bool, lastStyle Style) {
|
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
|
var shifts []wrapShift
|
||||||
effRadius := circleRadiusEffFp(c.Radius, w.circleRadiusScaleFp)
|
effRadius := circleRadiusEffFp(c.Radius, w.circleRadiusScaleFp)
|
||||||
if allowWrap {
|
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 {
|
} 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)
|
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)
|
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)
|
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)
|
fill := alphaNonZero(lastStyle.FillColor)
|
||||||
stroke := alphaNonZero(lastStyle.StrokeColor)
|
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()
|
drawer.Fill()
|
||||||
}
|
|
||||||
if stroke {
|
drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx))
|
||||||
// Stroke must be last when both are present.
|
|
||||||
drawer.Stroke()
|
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) 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *World) drawLineInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, l Line, allowWrap bool) {
|
|
||||||
var segs []lineSeg
|
|
||||||
if allowWrap {
|
if allowWrap {
|
||||||
segs = torusShortestLineSegments(l, w.W, w.H)
|
segs, tmp = torusShortestLineSegmentsInto(segs, tmp, l, w.W, w.H)
|
||||||
} else {
|
} 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 {
|
for _, s := range segs {
|
||||||
// Project into tile/canvas.
|
|
||||||
x1 := worldSpanFixedToCanvasPx((s.x1+td.Tile.OffsetX)-plan.WorldRect.minX, plan.ZoomFp)
|
x1 := worldSpanFixedToCanvasPx((s.x1+td.Tile.OffsetX)-plan.WorldRect.minX, plan.ZoomFp)
|
||||||
y1 := worldSpanFixedToCanvasPx((s.y1+td.Tile.OffsetY)-plan.WorldRect.minY, 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)
|
x2 := worldSpanFixedToCanvasPx((s.x2+td.Tile.OffsetX)-plan.WorldRect.minX, plan.ZoomFp)
|
||||||
y2 := worldSpanFixedToCanvasPx((s.y2+td.Tile.OffsetY)-plan.WorldRect.minY, 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.AddLine(float64(x1), float64(y1), float64(x2), float64(y2))
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
// Full redraw should NOT call CopyShift.
|
||||||
require.Empty(t, d.CommandsByName("CopyShift"))
|
require.Empty(t, d.CommandsByName("CopyShift"))
|
||||||
|
|
||||||
// Should contain at least one reasonably wide clip.
|
// Full redraw should clear the entire canvas.
|
||||||
clipCmds := d.CommandsByName("ClipRect")
|
require.NotEmpty(t, d.CommandsByName("ClearAllTo"))
|
||||||
require.NotEmpty(t, clipCmds)
|
|
||||||
|
|
||||||
foundWide := false
|
// And should draw something (at least the point).
|
||||||
for _, c := range clipCmds {
|
// Depending on your implementation, it might be AddPoint or AddCircle/AddLine as well.
|
||||||
if int(c.Args[2]) > 1 {
|
require.NotEmpty(t, d.CommandsByName("AddPoint"))
|
||||||
foundWide = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
require.True(t, foundWide)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -6,132 +6,6 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"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) {
|
func TestTorusShortestLineSegments_TieCaseIsDeterministicAndSplits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,22 +57,36 @@ func (w *World) collectCandidatesForTile(r Rect) []MapItem {
|
|||||||
rowStart := w.worldToCellY(r.minY)
|
rowStart := w.worldToCellY(r.minY)
|
||||||
rowEnd := w.worldToCellY(r.maxY - 1)
|
rowEnd := w.worldToCellY(r.maxY - 1)
|
||||||
|
|
||||||
seen := make(map[PrimitiveID]struct{})
|
// Start a new epoch for this tile dedupe.
|
||||||
result := make([]MapItem, 0)
|
w.candSeenResetIfOverflow()
|
||||||
|
|
||||||
|
// Reuse result buffer.
|
||||||
|
out := w.scratchCandidates[:0]
|
||||||
|
|
||||||
for row := rowStart; row <= rowEnd; row++ {
|
for row := rowStart; row <= rowEnd; row++ {
|
||||||
for col := colStart; col <= colEnd; col++ {
|
for col := colStart; col <= colEnd; col++ {
|
||||||
cell := w.grid[row][col]
|
cell := w.grid[row][col]
|
||||||
for _, item := range cell {
|
for _, item := range cell {
|
||||||
id := item.ID()
|
id := item.ID()
|
||||||
if _, ok := seen[id]; ok {
|
if w.candSeenMark(id) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[id] = struct{}{}
|
out = append(out, item)
|
||||||
result = append(result, 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")
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -50,6 +50,17 @@ type World struct {
|
|||||||
|
|
||||||
renderState rendererIncrementalState
|
renderState rendererIncrementalState
|
||||||
derivedCache map[derivedStyleKey]StyleID
|
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.
|
// NewWorld constructs a new world with the given real dimensions.
|
||||||
|
|||||||
Reference in New Issue
Block a user