1890 lines
53 KiB
Go
1890 lines
53 KiB
Go
package world
|
||
|
||
import (
|
||
"errors"
|
||
"image"
|
||
"image/color"
|
||
"sort"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// RenderLayer identifies one drawing pass.
|
||
type RenderLayer int
|
||
|
||
const (
|
||
RenderLayerPoints RenderLayer = iota
|
||
RenderLayerCircles
|
||
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 {
|
||
Layers []RenderLayer
|
||
Style *RenderStyle
|
||
// Incremental controls incremental pan behavior. If nil, defaults are used.
|
||
Incremental *IncrementalPolicy
|
||
// DisableWrapScroll controls whether the world is treated as a torus (false)
|
||
// or as a bounded plane without wrap (true).
|
||
// Default is false.
|
||
DisableWrapScroll bool
|
||
|
||
// BackgroundColor is used to clear full redraw and dirty regions.
|
||
// If nil, default background is opaque black.
|
||
BackgroundColor color.Color
|
||
}
|
||
|
||
var (
|
||
errInvalidViewportSize = errors.New("render: invalid viewport size")
|
||
errInvalidMargins = errors.New("render: invalid margins")
|
||
errNilDrawer = errors.New("render: nil drawer")
|
||
)
|
||
|
||
// RenderParams describes one render request coming from the UI layer.
|
||
//
|
||
// Camera coordinates are expressed in world fixed-point units and point to the
|
||
// center of the visible viewport. Margins are expressed in canvas pixels and
|
||
// extend the rendered area around the viewport on each axis independently.
|
||
//
|
||
// The final canvas size is derived from viewport size and margins:
|
||
//
|
||
// canvasWidthPx = viewportWidthPx + 2*marginXPx
|
||
// canvasHeightPx = viewportHeightPx + 2*marginYPx
|
||
type RenderParams struct {
|
||
ViewportWidthPx int
|
||
ViewportHeightPx int
|
||
|
||
MarginXPx int
|
||
MarginYPx int
|
||
|
||
CameraXWorldFp int
|
||
CameraYWorldFp int
|
||
|
||
CameraZoom float64
|
||
|
||
// Optional render options. If nil, defaults are used.
|
||
Options *RenderOptions
|
||
|
||
// Used for various debugging purposes
|
||
Debug bool
|
||
}
|
||
|
||
// CanvasWidthPx returns the full expanded canvas width in pixels.
|
||
func (p RenderParams) CanvasWidthPx() int { return p.ViewportWidthPx + 2*p.MarginXPx }
|
||
|
||
// CanvasHeightPx returns the full expanded canvas height in pixels.
|
||
func (p RenderParams) CanvasHeightPx() int { return p.ViewportHeightPx + 2*p.MarginYPx }
|
||
|
||
// CameraZoomFp converts the UI-facing zoom value into the package fixed-point form.
|
||
func (p RenderParams) CameraZoomFp() (int, error) {
|
||
return CameraZoomToWorldFixed(p.CameraZoom)
|
||
}
|
||
|
||
// ExpandedCanvasWorldRect returns the world-space half-open rectangle covered by
|
||
// the full expanded canvas around the camera center.
|
||
//
|
||
// The returned rectangle is expressed in fixed-point world coordinates and is not
|
||
// wrapped into [0, W) x [0, H). It may extend beyond world bounds on either axis;
|
||
// torus normalization and tiling are handled later by the renderer pipeline.
|
||
func (p RenderParams) ExpandedCanvasWorldRect() (Rect, error) {
|
||
zoomFp, err := p.CameraZoomFp()
|
||
if err != nil {
|
||
return Rect{}, err
|
||
}
|
||
|
||
return expandedCanvasWorldRect(
|
||
p.CameraXWorldFp,
|
||
p.CameraYWorldFp,
|
||
p.CanvasWidthPx(),
|
||
p.CanvasHeightPx(),
|
||
zoomFp,
|
||
), nil
|
||
}
|
||
|
||
// Validate checks whether the render request is internally consistent.
|
||
// Camera coordinates are intentionally not restricted here because the renderer
|
||
// is expected to normalize them through torus wrap.
|
||
func (p RenderParams) Validate() error {
|
||
if p.ViewportWidthPx <= 0 || p.ViewportHeightPx <= 0 {
|
||
return errInvalidViewportSize
|
||
}
|
||
if p.MarginXPx < 0 || p.MarginYPx < 0 {
|
||
return errInvalidMargins
|
||
}
|
||
|
||
if _, err := p.CameraZoomFp(); err != nil {
|
||
return err
|
||
}
|
||
|
||
if p.CanvasWidthPx() <= 0 || p.CanvasHeightPx() <= 0 {
|
||
return errInvalidViewportSize
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// expandedCanvasWorldRect computes the world-space half-open rectangle covered by
|
||
// a full expanded canvas centered on the camera.
|
||
//
|
||
// The rectangle is returned in fixed-point world coordinates and is not wrapped.
|
||
// A later renderer step is expected to tile and normalize it against torus bounds.
|
||
func expandedCanvasWorldRect(
|
||
cameraXWorldFp, cameraYWorldFp int,
|
||
canvasWidthPx, canvasHeightPx int,
|
||
zoomFp int,
|
||
) Rect {
|
||
if canvasWidthPx <= 0 || canvasHeightPx <= 0 {
|
||
panic("expandedCanvasWorldRect: invalid canvas size")
|
||
}
|
||
if zoomFp <= 0 {
|
||
panic("expandedCanvasWorldRect: invalid zoom")
|
||
}
|
||
|
||
worldWidthFp := PixelSpanToWorldFixed(canvasWidthPx, zoomFp)
|
||
worldHeightFp := PixelSpanToWorldFixed(canvasHeightPx, zoomFp)
|
||
|
||
minX := cameraXWorldFp - worldWidthFp/2
|
||
minY := cameraYWorldFp - worldHeightFp/2
|
||
|
||
return Rect{
|
||
minX: minX,
|
||
maxX: minX + worldWidthFp,
|
||
minY: minY,
|
||
maxY: minY + worldHeightFp,
|
||
}
|
||
}
|
||
|
||
// Render draws the current world state onto the expanded canvas represented by drawer.
|
||
//
|
||
// Stage A implementation is expected to perform a full redraw of the entire
|
||
// expanded canvas. Incremental scrolling and canvas shifting are intentionally
|
||
// left for later stages.
|
||
//
|
||
// The renderer must treat the camera as looking at the center of the viewport,
|
||
// not the center of the full expanded canvas.
|
||
//
|
||
// The renderer performs three passes (layers) in a configurable order.
|
||
// The render plan (tiling + candidates + clips) is built once and reused.
|
||
func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
|
||
if drawer == nil {
|
||
return errNilDrawer
|
||
}
|
||
if err := params.Validate(); err != nil {
|
||
return err
|
||
}
|
||
|
||
var bg color.Color = color.RGBA{A: 255} // default black
|
||
|
||
if params.Options != nil && params.Options.BackgroundColor != nil {
|
||
bg = params.Options.BackgroundColor
|
||
} else {
|
||
tc := w.Theme().BackgroundColor()
|
||
if alphaNonZero(tc) {
|
||
bg = tc
|
||
}
|
||
}
|
||
|
||
allowWrap := params.Options == nil || !params.Options.DisableWrapScroll
|
||
|
||
defer func() {
|
||
if !params.Debug {
|
||
return
|
||
}
|
||
drawer.AddLine(
|
||
float64(params.MarginXPx),
|
||
float64(params.MarginYPx),
|
||
float64(params.MarginXPx+params.ViewportWidthPx),
|
||
float64(params.MarginYPx))
|
||
drawer.AddLine(
|
||
float64(params.MarginXPx),
|
||
float64(params.MarginYPx),
|
||
float64(params.MarginXPx),
|
||
float64(params.MarginYPx+params.ViewportHeightPx))
|
||
drawer.AddLine(
|
||
float64(params.MarginXPx+params.ViewportWidthPx),
|
||
float64(params.MarginYPx),
|
||
float64(params.MarginXPx+params.ViewportWidthPx),
|
||
float64(params.MarginYPx+params.ViewportHeightPx))
|
||
drawer.AddLine(
|
||
float64(params.MarginXPx),
|
||
float64(params.MarginYPx+params.ViewportHeightPx),
|
||
float64(params.MarginXPx+params.ViewportWidthPx),
|
||
float64(params.MarginYPx+params.ViewportHeightPx))
|
||
}()
|
||
|
||
startTs := time.Now()
|
||
defer func() {
|
||
// record dtRender for future overload heuristics
|
||
w.renderState.lastRenderDurationNs = time.Since(startTs).Nanoseconds()
|
||
}()
|
||
|
||
policy := DefaultIncrementalPolicy()
|
||
if params.Options != nil && params.Options.Incremental != nil {
|
||
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 {
|
||
inc, perr := PlanIncrementalPan(
|
||
params.CanvasWidthPx(),
|
||
params.CanvasHeightPx(),
|
||
params.MarginXPx,
|
||
params.MarginYPx,
|
||
dxPx,
|
||
dyPx,
|
||
)
|
||
if perr != nil {
|
||
return perr
|
||
}
|
||
|
||
switch inc.Mode {
|
||
case IncrementalNoOp:
|
||
// 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 {
|
||
return nil
|
||
}
|
||
|
||
plan, err := w.buildRenderPlan(params)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Build once for the whole set of catch-up rects (union), then clip per rect.
|
||
catchUpPlan := planRestrictedToDirtyRects(plan, toDraw)
|
||
|
||
for _, r := range toDraw {
|
||
if err := drawDirtyRect(catchUpPlan, r); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
|
||
case IncrementalShift:
|
||
// Move existing pending dirty rects together with the backing image shift.
|
||
if len(w.renderState.pendingDirty) > 0 {
|
||
moved := make([]RectPx, 0, len(w.renderState.pendingDirty))
|
||
for _, r := range w.renderState.pendingDirty {
|
||
if rr, ok := shiftAndClipRectPx(r, inc.DxPx, inc.DyPx, params.CanvasWidthPx(), params.CanvasHeightPx()); ok {
|
||
moved = append(moved, rr)
|
||
}
|
||
}
|
||
w.renderState.pendingDirty = moved
|
||
}
|
||
// Shift backing pixels first.
|
||
drawer.CopyShift(inc.DxPx, inc.DyPx)
|
||
|
||
overBudget := false
|
||
if policy.AllowShiftOnly && policy.RenderBudgetMs > 0 {
|
||
budgetNs := int64(policy.RenderBudgetMs) * 1_000_000
|
||
if w.renderState.lastRenderDurationNs > budgetNs {
|
||
overBudget = true
|
||
}
|
||
}
|
||
if overBudget {
|
||
// Shift-only: defer drawing; remember newly exposed strips.
|
||
if len(inc.Dirty) > 0 {
|
||
w.renderState.pendingDirty = append(w.renderState.pendingDirty, inc.Dirty...)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Under budget: draw newly exposed strips immediately, plus bounded catch-up.
|
||
dirtyToDraw := inc.Dirty
|
||
|
||
// Additionally redraw a bounded portion of deferred dirty regions.
|
||
if len(w.renderState.pendingDirty) > 0 {
|
||
catchUp, remaining := takeCatchUpRects(w.renderState.pendingDirty, policy.MaxCatchUpAreaPx)
|
||
dirtyToDraw = append(dirtyToDraw, catchUp...)
|
||
w.renderState.pendingDirty = remaining
|
||
}
|
||
|
||
if len(dirtyToDraw) == 0 {
|
||
return nil
|
||
}
|
||
|
||
plan, err := w.buildRenderPlan(params)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 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:
|
||
// Fall through to full redraw below.
|
||
default:
|
||
panic("render: unknown incremental mode")
|
||
}
|
||
}
|
||
|
||
// --- Full redraw path ---
|
||
plan, err := w.buildRenderPlan(params)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
drawer.ClearAllTo(bg)
|
||
w.drawBackground(drawer, params, RectPx{X: 0, Y: 0, W: params.CanvasWidthPx(), H: params.CanvasHeightPx()})
|
||
w.drawPlanSinglePass(drawer, plan, allowWrap, drawPlanSinglePassClipEnabled, true)
|
||
return w.CommitFullRedrawState(params)
|
||
}
|
||
|
||
// ForceFullRedrawNext resets internal incremental renderer state.
|
||
// After this call, the next Render() will use the full redraw path and
|
||
// re-initialize incremental state.
|
||
func (w *World) ForceFullRedrawNext() {
|
||
w.renderState.Reset()
|
||
}
|
||
|
||
// WorldTile describes one torus tile contribution for an unwrapped world rect.
|
||
//
|
||
// Rect is the portion of the unwrapped rect mapped into the canonical world domain
|
||
// [0, worldWidthFp) x [0, worldHeightFp) as a half-open rectangle.
|
||
// OffsetX/OffsetY are the world-space tile offsets (multiples of world width/height)
|
||
// that map this canonical rect back into the unwrapped coordinate space.
|
||
type WorldTile struct {
|
||
Rect Rect
|
||
OffsetX int
|
||
OffsetY int
|
||
}
|
||
|
||
// tileWorldRect splits an unwrapped world-space rect into a set of tiles,
|
||
// each mapped into the canonical world domain [0, worldWidthFp) x [0, worldHeightFp).
|
||
//
|
||
// Unlike splitByWrap, this function does NOT collapse spans wider than the world.
|
||
// If rect spans multiple world widths/heights, it returns multiple tiles.
|
||
// The returned tiles are ordered by increasing tile X index, then by increasing tile Y index.
|
||
func tileWorldRect(rect Rect, worldWidthFp, worldHeightFp int) []WorldTile {
|
||
if worldWidthFp <= 0 || worldHeightFp <= 0 {
|
||
panic("tileWorldRect: non-positive world size")
|
||
}
|
||
|
||
width := rect.maxX - rect.minX
|
||
height := rect.maxY - rect.minY
|
||
if width <= 0 || height <= 0 {
|
||
return nil
|
||
}
|
||
|
||
// Determine which torus tiles the rect intersects.
|
||
// Since rect is half-open, use (max-1) for inclusive end.
|
||
minTileX := floorDiv(rect.minX, worldWidthFp)
|
||
maxTileX := floorDiv(rect.maxX-1, worldWidthFp)
|
||
minTileY := floorDiv(rect.minY, worldHeightFp)
|
||
maxTileY := floorDiv(rect.maxY-1, worldHeightFp)
|
||
|
||
out := make([]WorldTile, 0, (maxTileX-minTileX+1)*(maxTileY-minTileY+1))
|
||
|
||
for tx := minTileX; tx <= maxTileX; tx++ {
|
||
tileBaseX := tx * worldWidthFp
|
||
segMinX := max(rect.minX, tileBaseX)
|
||
segMaxX := min(rect.maxX, tileBaseX+worldWidthFp)
|
||
if segMinX >= segMaxX {
|
||
continue
|
||
}
|
||
|
||
localMinX := segMinX - tileBaseX
|
||
localMaxX := segMaxX - tileBaseX
|
||
|
||
for ty := minTileY; ty <= maxTileY; ty++ {
|
||
tileBaseY := ty * worldHeightFp
|
||
segMinY := max(rect.minY, tileBaseY)
|
||
segMaxY := min(rect.maxY, tileBaseY+worldHeightFp)
|
||
if segMinY >= segMaxY {
|
||
continue
|
||
}
|
||
|
||
localMinY := segMinY - tileBaseY
|
||
localMaxY := segMaxY - tileBaseY
|
||
|
||
out = append(out, WorldTile{
|
||
Rect: Rect{
|
||
minX: localMinX, maxX: localMaxX,
|
||
minY: localMinY, maxY: localMaxY,
|
||
},
|
||
OffsetX: tileBaseX,
|
||
OffsetY: tileBaseY,
|
||
})
|
||
}
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
// tileWorldRectNoWrap returns 0..1 tiles for a bounded world (no wrap).
|
||
// It intersects the expanded unwrapped rect with the canonical world [0..W)x[0..H).
|
||
func tileWorldRectNoWrap(worldRect Rect, W, H int) []WorldTile {
|
||
ix0 := max(worldRect.minX, 0)
|
||
iy0 := max(worldRect.minY, 0)
|
||
ix1 := min(worldRect.maxX, W)
|
||
iy1 := min(worldRect.maxY, H)
|
||
|
||
if ix0 >= ix1 || iy0 >= iy1 {
|
||
return nil
|
||
}
|
||
|
||
return []WorldTile{
|
||
{
|
||
Rect: Rect{minX: ix0, maxX: ix1, minY: iy0, maxY: iy1},
|
||
OffsetX: 0,
|
||
OffsetY: 0,
|
||
},
|
||
}
|
||
}
|
||
|
||
// isEmptyRectPx reports whether r covers no canvas pixels.
|
||
func isEmptyRectPx(r RectPx) bool {
|
||
return r.W <= 0 || r.H <= 0
|
||
}
|
||
|
||
// intersectRectPx returns the intersection of two half-open canvas rectangles.
|
||
func intersectRectPx(a, b RectPx) (RectPx, bool) {
|
||
ax2 := a.X + a.W
|
||
ay2 := a.Y + a.H
|
||
bx2 := b.X + b.W
|
||
by2 := b.Y + b.H
|
||
|
||
x1 := max(a.X, b.X)
|
||
y1 := max(a.Y, b.Y)
|
||
x2 := min(ax2, bx2)
|
||
y2 := min(ay2, by2)
|
||
|
||
w := x2 - x1
|
||
h := y2 - y1
|
||
if w <= 0 || h <= 0 {
|
||
return RectPx{}, false
|
||
}
|
||
|
||
return RectPx{X: x1, Y: y1, W: w, H: h}, true
|
||
}
|
||
|
||
// RenderPlan describes the full expanded-canvas redraw plan for one RenderParams.
|
||
// It is a pure description: it does not execute any drawing.
|
||
type RenderPlan struct {
|
||
CanvasWidthPx int
|
||
CanvasHeightPx int
|
||
|
||
ZoomFp int
|
||
|
||
// WorldRect is the unwrapped world-space rect covered by the expanded canvas.
|
||
WorldRect Rect
|
||
|
||
// Tiles are ordered in the same order as produced by tileWorldRect:
|
||
// increasing tile X index, then increasing tile Y index.
|
||
Tiles []TileDrawPlan
|
||
}
|
||
|
||
// TileDrawPlan describes how to draw one torus tile contribution.
|
||
type TileDrawPlan struct {
|
||
Tile WorldTile
|
||
|
||
// Clip rect on the expanded canvas in pixel coordinates.
|
||
// It is half-open in spirit: [ClipX, ClipX+ClipW) x [ClipY, ClipY+ClipH).
|
||
ClipX int
|
||
ClipY int
|
||
ClipW int
|
||
ClipH int
|
||
|
||
// Candidates are unique per tile (deduped by ID).
|
||
Candidates []MapItem
|
||
}
|
||
|
||
// worldSpanFixedToCanvasPx converts a world fixed-point span into a canvas pixel span
|
||
// for the given fixed-point zoom. The conversion is truncating (floor).
|
||
func worldSpanFixedToCanvasPx(spanWorldFp, zoomFp int) int {
|
||
// spanWorldFp can be negative in some internal cases, but for clip computations
|
||
// we always pass non-negative spans.
|
||
return (spanWorldFp * zoomFp) / (SCALE * SCALE)
|
||
}
|
||
|
||
// buildRenderPlan builds a full expanded-canvas redraw plan.
|
||
//
|
||
// It assumes the world grid is already built (IndexOnViewportChange called).
|
||
// The plan contains per-tile clip rectangles and per-tile candidate lists
|
||
// from the spatial index.
|
||
func (w *World) buildRenderPlan(params RenderParams) (RenderPlan, error) {
|
||
if err := params.Validate(); err != nil {
|
||
return RenderPlan{}, err
|
||
}
|
||
|
||
zoomFp, err := params.CameraZoomFp()
|
||
if err != nil {
|
||
return RenderPlan{}, err
|
||
}
|
||
|
||
worldRect, err := params.ExpandedCanvasWorldRect()
|
||
if err != nil {
|
||
return RenderPlan{}, err
|
||
}
|
||
|
||
allowWrap := params.Options == nil || !params.Options.DisableWrapScroll
|
||
var tiles []WorldTile
|
||
if allowWrap {
|
||
tiles = tileWorldRect(worldRect, w.W, w.H)
|
||
} else {
|
||
tiles = tileWorldRectNoWrap(worldRect, w.W, w.H)
|
||
}
|
||
|
||
// Query candidates per tile.
|
||
batches, err := w.collectCandidatesForTiles(tiles)
|
||
if err != nil {
|
||
return RenderPlan{}, err
|
||
}
|
||
|
||
planTiles := make([]TileDrawPlan, 0, len(batches))
|
||
|
||
for _, batch := range batches {
|
||
tile := batch.Tile
|
||
|
||
// Convert the tile's canonical rect + offsets into the unwrapped segment.
|
||
segMinX := tile.Rect.minX + tile.OffsetX
|
||
segMaxX := tile.Rect.maxX + tile.OffsetX
|
||
segMinY := tile.Rect.minY + tile.OffsetY
|
||
segMaxY := tile.Rect.maxY + tile.OffsetY
|
||
|
||
// Map that segment into expanded canvas pixel coordinates relative to worldRect.minX/minY.
|
||
clipX := worldSpanFixedToCanvasPx(segMinX-worldRect.minX, zoomFp)
|
||
clipY := worldSpanFixedToCanvasPx(segMinY-worldRect.minY, zoomFp)
|
||
clipX2 := worldSpanFixedToCanvasPx(segMaxX-worldRect.minX, zoomFp)
|
||
clipY2 := worldSpanFixedToCanvasPx(segMaxY-worldRect.minY, zoomFp)
|
||
|
||
clipW := clipX2 - clipX
|
||
clipH := clipY2 - clipY
|
||
|
||
planTiles = append(planTiles, TileDrawPlan{
|
||
Tile: tile,
|
||
ClipX: clipX,
|
||
ClipY: clipY,
|
||
ClipW: clipW,
|
||
ClipH: clipH,
|
||
Candidates: batch.Items,
|
||
})
|
||
}
|
||
|
||
return RenderPlan{
|
||
CanvasWidthPx: params.CanvasWidthPx(),
|
||
CanvasHeightPx: params.CanvasHeightPx(),
|
||
ZoomFp: zoomFp,
|
||
WorldRect: worldRect,
|
||
Tiles: planTiles,
|
||
}, nil
|
||
}
|
||
|
||
var (
|
||
errGridNotBuilt = errors.New("render: grid not built; call IndexOnViewportChange first")
|
||
)
|
||
|
||
// TileCandidates binds one torus tile to the list of unique grid candidates
|
||
// that intersect the tile rectangle.
|
||
//
|
||
// Items are not guaranteed to be truly visible; the grid is a coarse spatial index.
|
||
// Exact visibility tests are performed later in the renderer pipeline.
|
||
type TileCandidates struct {
|
||
Tile WorldTile
|
||
Items []MapItem
|
||
}
|
||
|
||
// collectCandidatesForTiles queries the world grid for each tile rectangle
|
||
// and returns per-tile unique candidate lists.
|
||
//
|
||
// Deduplication is performed per tile (by MapItem.ID()) to avoid duplicates caused by
|
||
// bbox indexing into multiple cells. Dedup across tiles is intentionally NOT performed.
|
||
func (w *World) collectCandidatesForTiles(tiles []WorldTile) ([]TileCandidates, error) {
|
||
if w.grid == nil || w.rows <= 0 || w.cols <= 0 || w.cellSize <= 0 {
|
||
return nil, errGridNotBuilt
|
||
}
|
||
|
||
out := make([]TileCandidates, 0, len(tiles))
|
||
for _, tile := range tiles {
|
||
items := w.collectCandidatesForTile(tile.Rect)
|
||
out = append(out, TileCandidates{
|
||
Tile: tile,
|
||
Items: items,
|
||
})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// collectCandidatesForTile returns a unique set of grid candidates for a single
|
||
// canonical-world tile rectangle [0..W) x [0..H).
|
||
//
|
||
// The rectangle must be half-open and expressed in fixed-point world coordinates.
|
||
func (w *World) collectCandidatesForTile(r Rect) []MapItem {
|
||
// Empty rect => no candidates.
|
||
if r.maxX <= r.minX || r.maxY <= r.minY {
|
||
return nil
|
||
}
|
||
|
||
// Map rect to cell ranges using the same half-open conventions as indexing:
|
||
// the last included cell is computed from (max-1).
|
||
colStart := w.worldToCellX(r.minX)
|
||
colEnd := w.worldToCellX(r.maxX - 1)
|
||
|
||
rowStart := w.worldToCellY(r.minY)
|
||
rowEnd := w.worldToCellY(r.maxY - 1)
|
||
|
||
// 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 w.candSeenMark(id) {
|
||
continue
|
||
}
|
||
out = append(out, item)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// drawKind is used only for stable tie-breaking when priorities are equal.
|
||
type drawKind int
|
||
|
||
const (
|
||
drawKindLine drawKind = iota
|
||
drawKindCircle
|
||
drawKindPoint
|
||
)
|
||
|
||
// drawItem is the normalized per-tile render record used for stable ordering.
|
||
//
|
||
// Each instance stores exactly one primitive payload together with the sort key
|
||
// that drawPlanSinglePass uses before issuing final drawer commands.
|
||
type drawItem struct {
|
||
kind drawKind
|
||
priority int
|
||
id PrimitiveID
|
||
styleID StyleID
|
||
|
||
// Exactly one of these is set.
|
||
p Point
|
||
c Circle
|
||
l Line
|
||
}
|
||
|
||
// drawPlanSinglePass renders a plan using a single ordered pass per tile.
|
||
// Items in each tile are sorted by (Priority asc, Kind asc, ID asc) for determinism.
|
||
//
|
||
// allowWrap controls torus behavior:
|
||
// - true: circles/points produce wrap copies, lines use torus-shortest segments
|
||
// - false: no copies, lines drawn directly as stored
|
||
// 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
|
||
|
||
applyStyle := func(styleID StyleID) {
|
||
if styleID == lastStyleID {
|
||
return
|
||
}
|
||
s, ok := w.styles.Get(styleID)
|
||
if !ok {
|
||
panic("render: unknown style ID")
|
||
}
|
||
|
||
if s.FillColor != nil {
|
||
drawer.SetFillColor(s.FillColor)
|
||
}
|
||
if s.StrokeColor != nil {
|
||
drawer.SetStrokeColor(s.StrokeColor)
|
||
}
|
||
drawer.SetLineWidth(s.StrokeWidthPx)
|
||
if len(s.StrokeDashes) > 0 {
|
||
drawer.SetDash(s.StrokeDashes...)
|
||
} else {
|
||
drawer.SetDash()
|
||
}
|
||
drawer.SetDashOffset(s.StrokeDashOffset)
|
||
|
||
lastStyleID = styleID
|
||
lastStyle = s
|
||
}
|
||
|
||
for _, td := range plan.Tiles {
|
||
if td.ClipW <= 0 || td.ClipH <= 0 {
|
||
continue
|
||
}
|
||
|
||
// 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 {
|
||
continue
|
||
}
|
||
|
||
switch v := cur.(type) {
|
||
case Point:
|
||
items = append(items, drawItem{
|
||
kind: drawKindPoint,
|
||
priority: v.Priority,
|
||
id: v.Id,
|
||
styleID: v.StyleID,
|
||
p: v,
|
||
})
|
||
case Circle:
|
||
items = append(items, drawItem{
|
||
kind: drawKindCircle,
|
||
priority: v.Priority,
|
||
id: v.Id,
|
||
styleID: v.StyleID,
|
||
c: v,
|
||
})
|
||
case Line:
|
||
items = append(items, drawItem{
|
||
kind: drawKindLine,
|
||
priority: v.Priority,
|
||
id: v.Id,
|
||
styleID: v.StyleID,
|
||
l: v,
|
||
})
|
||
default:
|
||
panic("render: unknown map item type")
|
||
}
|
||
}
|
||
|
||
if len(items) == 0 {
|
||
if tileClipEnabled {
|
||
drawer.Restore()
|
||
}
|
||
w.scratchDrawItems = items[:0]
|
||
continue
|
||
}
|
||
|
||
sort.Slice(items, func(i, j int) bool {
|
||
a, b := items[i], items[j]
|
||
if a.priority != b.priority {
|
||
return a.priority < b.priority
|
||
}
|
||
if a.kind != b.kind {
|
||
return a.kind < b.kind
|
||
}
|
||
return a.id < b.id
|
||
})
|
||
|
||
// 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)
|
||
case drawKindCircle:
|
||
w.drawCircleInTile(drawer, plan, td, di.c, allowWrap, lastStyle)
|
||
case drawKindLine:
|
||
// Old behavior: drawLineInTile includes Stroke() per segment.
|
||
w.drawLineInTile(drawer, plan, td, di.l, allowWrap)
|
||
default:
|
||
panic("render: unknown draw kind")
|
||
}
|
||
}
|
||
} else {
|
||
// Dirty pass: batch lines to reduce overhead while panning.
|
||
inLineRun := false
|
||
var lineRunStyleID StyleID
|
||
lineSegCount := 0
|
||
|
||
flushLineRun := func() {
|
||
if !inLineRun {
|
||
return
|
||
}
|
||
drawer.Stroke()
|
||
inLineRun = false
|
||
lineSegCount = 0
|
||
}
|
||
|
||
for i := 0; i < len(items); i++ {
|
||
di := items[i]
|
||
|
||
if inLineRun {
|
||
if di.kind != drawKindLine || di.styleID != lineRunStyleID {
|
||
flushLineRun()
|
||
}
|
||
}
|
||
|
||
switch di.kind {
|
||
case drawKindLine:
|
||
if !inLineRun {
|
||
lineRunStyleID = di.styleID
|
||
applyStyle(lineRunStyleID)
|
||
inLineRun = true
|
||
} else {
|
||
// style matches by construction; keep style state valid if code changes later
|
||
applyStyle(di.styleID)
|
||
}
|
||
|
||
added := w.drawLineInTilePath(drawer, plan, td, di.l, allowWrap)
|
||
lineSegCount += added
|
||
|
||
if lineSegCount >= maxLineSegmentsPerStroke {
|
||
drawer.Stroke()
|
||
lineSegCount = 0
|
||
// keep run active
|
||
}
|
||
|
||
case drawKindPoint:
|
||
flushLineRun()
|
||
applyStyle(di.styleID)
|
||
w.drawPointInTile(drawer, plan, td, di.p, allowWrap, lastStyle)
|
||
|
||
case drawKindCircle:
|
||
flushLineRun()
|
||
applyStyle(di.styleID)
|
||
w.drawCircleInTile(drawer, plan, td, di.c, allowWrap, lastStyle)
|
||
|
||
default:
|
||
flushLineRun()
|
||
panic("render: unknown draw kind")
|
||
}
|
||
}
|
||
|
||
flushLineRun()
|
||
}
|
||
|
||
if tileClipEnabled {
|
||
drawer.Restore()
|
||
}
|
||
|
||
// Reuse buffer for next tile.
|
||
w.scratchDrawItems = items[:0]
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
rPx := lastStyle.PointRadiusPx
|
||
if rPx <= 0 {
|
||
// Nothing visible.
|
||
return
|
||
}
|
||
|
||
// Convert screen radius to world-fixed conservatively.
|
||
rWorldFp := PixelSpanToWorldFixed(int(rPx+0.999999), plan.ZoomFp)
|
||
|
||
var shifts []wrapShift
|
||
if allowWrap {
|
||
shifts = pointWrapShifts(p, rWorldFp, w.W, w.H)
|
||
} else {
|
||
shifts = []wrapShift{{dx: 0, dy: 0}}
|
||
}
|
||
|
||
for _, s := range shifts {
|
||
if allowWrap && !pointCopyIntersectsTile(p, rWorldFp, s.dx, s.dy, td.Tile) {
|
||
continue
|
||
}
|
||
|
||
px := worldSpanFixedToCanvasPx((p.X+td.Tile.OffsetX+s.dx)-plan.WorldRect.minX, plan.ZoomFp)
|
||
py := worldSpanFixedToCanvasPx((p.Y+td.Tile.OffsetY+s.dy)-plan.WorldRect.minY, plan.ZoomFp)
|
||
|
||
drawer.AddPoint(float64(px), float64(py), rPx)
|
||
|
||
fill := alphaNonZero(lastStyle.FillColor)
|
||
stroke := alphaNonZero(lastStyle.StrokeColor)
|
||
|
||
if fill {
|
||
drawer.Fill()
|
||
}
|
||
if stroke {
|
||
// Stroke must be last when both are present.
|
||
drawer.Stroke()
|
||
}
|
||
}
|
||
}
|
||
|
||
func (w *World) drawCircleInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, c Circle, allowWrap bool, lastStyle Style) {
|
||
var shifts []wrapShift
|
||
effRadius := circleRadiusEffFp(c.Radius, w.circleRadiusScaleFp)
|
||
if allowWrap {
|
||
shifts = circleWrapShiftsInto(w.scratchWrapShifts, c.X, c.Y, effRadius, w.W, w.H)
|
||
} else {
|
||
var one [1]wrapShift
|
||
one[0] = wrapShift{dx: 0, dy: 0}
|
||
shifts = one[:]
|
||
}
|
||
|
||
rPx := worldSpanFixedToCanvasPx(effRadius, plan.ZoomFp)
|
||
|
||
for _, s := range shifts {
|
||
if allowWrap && !circleCopyIntersectsTile(c.X, c.Y, effRadius, s.dx, s.dy, td.Tile, w.W, w.H) {
|
||
continue
|
||
}
|
||
|
||
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)
|
||
|
||
fill := alphaNonZero(lastStyle.FillColor)
|
||
stroke := alphaNonZero(lastStyle.StrokeColor)
|
||
|
||
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.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) 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, tmp = torusShortestLineSegmentsInto(segs, tmp, l, w.W, w.H)
|
||
} else {
|
||
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 {
|
||
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))
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
var (
|
||
errInvalidCanvasSize = errors.New("incremental: invalid canvas size")
|
||
)
|
||
|
||
// IncrementalMode describes how the renderer should update the backing image.
|
||
type IncrementalMode int
|
||
|
||
const (
|
||
// IncrementalNoOp means no visual change is needed (dx=0 and dy=0).
|
||
IncrementalNoOp IncrementalMode = iota
|
||
|
||
// IncrementalShift means the backing image can be shifted and only dirty rects must be redrawn.
|
||
IncrementalShift
|
||
|
||
// IncrementalFullRedraw means the change is too large/unsafe for shifting and needs a full redraw.
|
||
IncrementalFullRedraw
|
||
)
|
||
|
||
// RectPx is an integer rectangle in canvas pixel coordinates.
|
||
// Semantics are half-open: [X, X+W) x [Y, Y+H).
|
||
type RectPx struct {
|
||
X, Y int
|
||
W, H int
|
||
}
|
||
|
||
// IncrementalPolicy is a placeholder for future incremental tuning.
|
||
// It is intentionally not used in C2; we only fix geometry-based thresholding now.
|
||
type IncrementalPolicy struct {
|
||
// CoalesceUpdates indicates "latest wins" behavior (drop intermediate updates).
|
||
// This will be implemented later; kept here as a placeholder to lock the API shape.
|
||
CoalesceUpdates bool
|
||
|
||
// AllowShiftOnly allows a temporary mode where the backing image is shifted
|
||
// but dirty rects are not redrawn immediately under overload.
|
||
AllowShiftOnly bool
|
||
|
||
// RenderBudgetMs can be used later to compare dtRender against a budget and decide degradation.
|
||
RenderBudgetMs int
|
||
|
||
// MaxCatchUpAreaPx limits how many pixels of deferred dirty regions we redraw per frame.
|
||
// 0 means "no limit".
|
||
MaxCatchUpAreaPx int
|
||
}
|
||
|
||
// IncrementalPlan is the output of pure incremental planning.
|
||
// It does not perform any drawing. It only describes what should happen.
|
||
type IncrementalPlan struct {
|
||
Mode IncrementalMode
|
||
|
||
// Shift to apply to the backing image in canvas pixels.
|
||
// Positive dx shifts the existing image to the right (exposing a dirty strip on the left).
|
||
// Positive dy shifts the existing image down (exposing a dirty strip on the top).
|
||
DxPx int
|
||
DyPx int
|
||
|
||
// Dirty rects to redraw after shifting (in canvas pixel coordinates).
|
||
// Rects may overlap; overlapping is allowed and simplifies planning.
|
||
Dirty []RectPx
|
||
}
|
||
|
||
// PlanIncrementalPan computes whether the renderer can update by shifting the backing image
|
||
// and redrawing only exposed strips, or must fall back to a full redraw.
|
||
//
|
||
// Threshold rule (per-axis):
|
||
// - If abs(dxPx) > marginXPx/2 => full redraw
|
||
// - If abs(dyPx) > marginYPx/2 => full redraw
|
||
//
|
||
// Additional safety rules:
|
||
// - If abs(dxPx) >= canvasW or abs(dyPx) >= canvasH => full redraw
|
||
//
|
||
// Returned dirty rects follow the chosen shift direction:
|
||
//
|
||
// dxPx > 0 => dirty strip on the left (width=dxPx)
|
||
// dxPx < 0 => dirty strip on the right (width=-dxPx)
|
||
// dyPx > 0 => dirty strip on the top (height=dyPx)
|
||
// dyPx < 0 => dirty strip on the bottom(height=-dyPx)
|
||
func PlanIncrementalPan(
|
||
canvasW, canvasH int,
|
||
marginXPx, marginYPx int,
|
||
dxPx, dyPx int,
|
||
) (IncrementalPlan, error) {
|
||
if canvasW <= 0 || canvasH <= 0 {
|
||
return IncrementalPlan{}, errInvalidCanvasSize
|
||
}
|
||
if marginXPx < 0 || marginYPx < 0 {
|
||
return IncrementalPlan{}, errors.New("incremental: invalid margins")
|
||
}
|
||
|
||
// No movement => no work.
|
||
if dxPx == 0 && dyPx == 0 {
|
||
return IncrementalPlan{Mode: IncrementalNoOp, DxPx: 0, DyPx: 0, Dirty: nil}, nil
|
||
}
|
||
|
||
adx := abs(dxPx)
|
||
ady := abs(dyPx)
|
||
|
||
// Too large shift can’t be represented as "shift + stripes".
|
||
if adx >= canvasW || ady >= canvasH {
|
||
return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
|
||
}
|
||
|
||
// Thresholds: per axis, independently.
|
||
// Using integer division: margin/2 truncates down, which is fine and deterministic.
|
||
thrX := marginXPx / 2
|
||
thrY := marginYPx / 2
|
||
|
||
if (thrX > 0 && adx > thrX) || (thrY > 0 && ady > thrY) {
|
||
return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
|
||
}
|
||
|
||
// If margin is 0, thr is 0, and any non-zero delta should force full redraw
|
||
// (because we have no buffer area to shift into).
|
||
if marginXPx == 0 && dxPx != 0 {
|
||
return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
|
||
}
|
||
if marginYPx == 0 && dyPx != 0 {
|
||
return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
|
||
}
|
||
|
||
dirty := make([]RectPx, 0, 2)
|
||
|
||
// Horizontal exposed strip with 1px overdraw to avoid seams.
|
||
if dxPx > 0 {
|
||
// Image moved right => left strip is exposed.
|
||
w := min(dxPx+1, canvasW) // overdraw 1px into already-valid area
|
||
dirty = append(dirty, RectPx{X: 0, Y: 0, W: w, H: canvasH})
|
||
} else if dxPx < 0 {
|
||
// Image moved left => right strip is exposed.
|
||
w := min((-dxPx)+1, canvasW)
|
||
dirty = append(dirty, RectPx{X: canvasW - w, Y: 0, W: w, H: canvasH})
|
||
}
|
||
|
||
// Vertical exposed strip with 1px overdraw to avoid seams.
|
||
if dyPx > 0 {
|
||
// Image moved down => top strip is exposed.
|
||
h := min(dyPx+1, canvasH)
|
||
dirty = append(dirty, RectPx{X: 0, Y: 0, W: canvasW, H: h})
|
||
} else if dyPx < 0 {
|
||
// Image moved up => bottom strip is exposed.
|
||
h := min((-dyPx)+1, canvasH)
|
||
dirty = append(dirty, RectPx{X: 0, Y: canvasH - h, W: canvasW, H: h})
|
||
}
|
||
|
||
// Filter out any zero/negative rects defensively.
|
||
out := dirty[:0]
|
||
for _, r := range dirty {
|
||
if r.W <= 0 || r.H <= 0 {
|
||
continue
|
||
}
|
||
out = append(out, r)
|
||
}
|
||
|
||
return IncrementalPlan{
|
||
Mode: IncrementalShift,
|
||
DxPx: dxPx,
|
||
DyPx: dyPx,
|
||
Dirty: out,
|
||
}, nil
|
||
}
|
||
|
||
// shiftAndClipRectPx moves r by the supplied pixel delta and clips it to the
|
||
// current canvas bounds.
|
||
func shiftAndClipRectPx(r RectPx, dx, dy, canvasW, canvasH int) (RectPx, bool) {
|
||
n := RectPx{X: r.X + dx, Y: r.Y + dy, W: r.W, H: r.H}
|
||
inter, ok := intersectRectPx(n, RectPx{X: 0, Y: 0, W: canvasW, H: canvasH})
|
||
return inter, ok
|
||
}
|
||
|
||
// planRestrictedToDirtyRects returns a new plan that contains only tile draw entries
|
||
// whose clip rectangles intersect any dirty rect. Each intersected area becomes its own
|
||
// TileDrawPlan entry with the clip replaced by the intersection.
|
||
//
|
||
// This makes drawing functions naturally render only the dirty areas.
|
||
func planRestrictedToDirtyRects(plan RenderPlan, dirty []RectPx) RenderPlan {
|
||
if len(dirty) == 0 {
|
||
return RenderPlan{
|
||
CanvasWidthPx: plan.CanvasWidthPx,
|
||
CanvasHeightPx: plan.CanvasHeightPx,
|
||
ZoomFp: plan.ZoomFp,
|
||
WorldRect: plan.WorldRect,
|
||
Tiles: nil,
|
||
}
|
||
}
|
||
|
||
outTiles := make([]TileDrawPlan, 0)
|
||
|
||
for _, td := range plan.Tiles {
|
||
if td.ClipW <= 0 || td.ClipH <= 0 {
|
||
continue
|
||
}
|
||
|
||
tileClip := RectPx{X: td.ClipX, Y: td.ClipY, W: td.ClipW, H: td.ClipH}
|
||
|
||
for _, dr := range dirty {
|
||
if isEmptyRectPx(dr) {
|
||
continue
|
||
}
|
||
|
||
inter, ok := intersectRectPx(tileClip, dr)
|
||
if !ok {
|
||
continue
|
||
}
|
||
|
||
outTiles = append(outTiles, TileDrawPlan{
|
||
Tile: td.Tile,
|
||
ClipX: inter.X,
|
||
ClipY: inter.Y,
|
||
ClipW: inter.W,
|
||
ClipH: inter.H,
|
||
Candidates: td.Candidates,
|
||
})
|
||
}
|
||
}
|
||
|
||
return RenderPlan{
|
||
CanvasWidthPx: plan.CanvasWidthPx,
|
||
CanvasHeightPx: plan.CanvasHeightPx,
|
||
ZoomFp: plan.ZoomFp,
|
||
WorldRect: plan.WorldRect,
|
||
Tiles: outTiles,
|
||
}
|
||
}
|
||
|
||
// takeCatchUpRects selects a subset of pending rects whose total area does not exceed maxAreaPx.
|
||
// It returns (selected, remaining). If maxAreaPx <= 0, it selects all.
|
||
func takeCatchUpRects(pending []RectPx, maxAreaPx int) (selected []RectPx, remaining []RectPx) {
|
||
if len(pending) == 0 {
|
||
return nil, nil
|
||
}
|
||
if maxAreaPx <= 0 {
|
||
// No limit.
|
||
all := append([]RectPx(nil), pending...)
|
||
return all, nil
|
||
}
|
||
|
||
selected = make([]RectPx, 0, len(pending))
|
||
remaining = make([]RectPx, 0)
|
||
|
||
used := 0
|
||
for _, r := range pending {
|
||
if r.W <= 0 || r.H <= 0 {
|
||
continue
|
||
}
|
||
area := r.W * r.H
|
||
if area <= 0 {
|
||
continue
|
||
}
|
||
|
||
// If we cannot fit the whole rect, we stop (simple, deterministic).
|
||
// (We do not split rectangles here to keep logic simple.)
|
||
if used+area > maxAreaPx {
|
||
remaining = append(remaining, r)
|
||
continue
|
||
}
|
||
|
||
selected = append(selected, r)
|
||
used += area
|
||
}
|
||
|
||
// Also keep any rects we skipped due to invalid size (none) and those that didn't fit.
|
||
// Note: remaining preserves original order among non-selected entries.
|
||
return selected, remaining
|
||
}
|
||
|
||
var (
|
||
errIncrementalZoomMismatch = errors.New("incremental: zoom/viewport/margins changed; full redraw required")
|
||
errIncrementalStateNotReady = errors.New("incremental: state not initialized; full redraw required")
|
||
errIncrementalInvalidZoomFp = errors.New("incremental: invalid zoom")
|
||
errIncrementalInvalidCanvasPx = errors.New("incremental: invalid canvas size")
|
||
)
|
||
|
||
// rendererIncrementalState stores the minimum state needed for incremental pan.
|
||
type rendererIncrementalState struct {
|
||
initialized bool
|
||
|
||
// Last render geometry key.
|
||
lastZoomFp int
|
||
|
||
lastViewportW int
|
||
lastViewportH int
|
||
lastMarginX int
|
||
lastMarginY int
|
||
lastCanvasW int
|
||
lastCanvasH int
|
||
|
||
// Last unwrapped expanded world rect used for rendering.
|
||
lastWorldRect Rect
|
||
|
||
// Remainders in numerator space to make world->px conversion stable across many small pans.
|
||
// We keep them per axis and update them during conversion.
|
||
remXNum int64
|
||
remYNum int64
|
||
|
||
// Last measured render duration (nanoseconds). Used for overload heuristics.
|
||
lastRenderDurationNs int64
|
||
|
||
// Pending dirty areas accumulated during shift-only frames.
|
||
// These are in current canvas pixel coordinates.
|
||
pendingDirty []RectPx
|
||
}
|
||
|
||
// Reset clears incremental state, forcing next frame to use full redraw.
|
||
func (s *rendererIncrementalState) Reset() {
|
||
*s = rendererIncrementalState{}
|
||
}
|
||
|
||
// incrementalKeyFromParams extracts the geometry key that must match for incremental pan.
|
||
func incrementalKeyFromParams(params RenderParams, zoomFp int) (vw, vh, mx, my, cw, ch, z int) {
|
||
vw = params.ViewportWidthPx
|
||
vh = params.ViewportHeightPx
|
||
mx = params.MarginXPx
|
||
my = params.MarginYPx
|
||
cw = params.CanvasWidthPx()
|
||
ch = params.CanvasHeightPx()
|
||
z = zoomFp
|
||
return
|
||
}
|
||
|
||
// worldDeltaFixedToCanvasPx converts a world-fixed delta into a pixel delta using zoomFp,
|
||
// carrying a signed remainder in numerator space to avoid cumulative drift.
|
||
//
|
||
// The conversion is:
|
||
//
|
||
// px = floor((deltaWorldFp*zoomFp + rem) / (SCALE*SCALE))
|
||
//
|
||
// and rem is updated to the exact remainder.
|
||
//
|
||
// This function works for negative deltas too and uses floor division semantics.
|
||
func worldDeltaFixedToCanvasPx(deltaWorldFp int, zoomFp int, remNum *int64) int {
|
||
if zoomFp <= 0 {
|
||
panic("worldDeltaFixedToCanvasPx: invalid zoom")
|
||
}
|
||
|
||
den := int64(SCALE) * int64(SCALE)
|
||
num := int64(deltaWorldFp)*int64(zoomFp) + *remNum
|
||
|
||
q, r := floorDivRem64(num, den)
|
||
*remNum = r
|
||
return int(q)
|
||
}
|
||
|
||
// floorDivRem64 returns (q,r) such that:
|
||
//
|
||
// q = floor(a / b), r = a - q*b
|
||
//
|
||
// with b > 0 and r in [0, b) for a>=0, or r in (-b, 0] for a<0 (signed remainder).
|
||
func floorDivRem64(a, b int64) (q int64, r int64) {
|
||
if b <= 0 {
|
||
panic("floorDivRem64: non-positive divisor")
|
||
}
|
||
|
||
q = a / b
|
||
r = a % b
|
||
if r != 0 && a < 0 {
|
||
q--
|
||
r = a - q*b
|
||
}
|
||
return q, r
|
||
}
|
||
|
||
// ComputePanShiftPx computes the pixel shift that must be applied to the existing backing image
|
||
// when ONLY camera pan changed (no zoom/viewport/margins changes).
|
||
//
|
||
// Returned dxPx/dyPx are shifts to apply to the already rendered image:
|
||
//
|
||
// dxPx > 0 => shift image right
|
||
// dxPx < 0 => shift image left
|
||
//
|
||
// This function updates internal incremental state when possible.
|
||
// If it returns an error, the caller should fall back to a full redraw and call
|
||
// CommitFullRedrawState afterward.
|
||
func (w *World) ComputePanShiftPx(params RenderParams) (dxPx, dyPx int, err error) {
|
||
zoomFp, zerr := params.CameraZoomFp()
|
||
if zerr != nil {
|
||
return 0, 0, zerr
|
||
}
|
||
if zoomFp <= 0 {
|
||
return 0, 0, errIncrementalInvalidZoomFp
|
||
}
|
||
|
||
canvasW := params.CanvasWidthPx()
|
||
canvasH := params.CanvasHeightPx()
|
||
if canvasW <= 0 || canvasH <= 0 {
|
||
return 0, 0, errIncrementalInvalidCanvasPx
|
||
}
|
||
|
||
newRect, rerr := params.ExpandedCanvasWorldRect()
|
||
if rerr != nil {
|
||
return 0, 0, rerr
|
||
}
|
||
|
||
s := &w.renderState
|
||
|
||
// First call: no prior state => must full redraw.
|
||
if !s.initialized {
|
||
return 0, 0, errIncrementalStateNotReady
|
||
}
|
||
|
||
vw, vh, mx, my, cw, ch, z := incrementalKeyFromParams(params, zoomFp)
|
||
if s.lastZoomFp != z ||
|
||
s.lastViewportW != vw || s.lastViewportH != vh ||
|
||
s.lastMarginX != mx || s.lastMarginY != my ||
|
||
s.lastCanvasW != cw || s.lastCanvasH != ch {
|
||
return 0, 0, errIncrementalZoomMismatch
|
||
}
|
||
|
||
// Compute how much the unwrapped world rect moved.
|
||
dMinX := newRect.minX - s.lastWorldRect.minX
|
||
dMinY := newRect.minY - s.lastWorldRect.minY
|
||
|
||
// Convert world movement to pixel movement of the world content.
|
||
// If world rect moved +X (camera moved right), content appears shifted left,
|
||
// so the old image must be shifted left: shiftPx = -deltaPx.
|
||
deltaPxX := worldDeltaFixedToCanvasPx(dMinX, zoomFp, &s.remXNum)
|
||
deltaPxY := worldDeltaFixedToCanvasPx(dMinY, zoomFp, &s.remYNum)
|
||
|
||
dxPx = -deltaPxX
|
||
dyPx = -deltaPxY
|
||
|
||
// Update stored rect for the next incremental computation.
|
||
s.lastWorldRect = newRect
|
||
|
||
return dxPx, dyPx, nil
|
||
}
|
||
|
||
// CommitFullRedrawState updates incremental state after a full redraw.
|
||
// Call this after you finish a full Render() that draws the entire expanded canvas.
|
||
func (w *World) CommitFullRedrawState(params RenderParams) error {
|
||
zoomFp, err := params.CameraZoomFp()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if zoomFp <= 0 {
|
||
return errIncrementalInvalidZoomFp
|
||
}
|
||
|
||
rect, err := params.ExpandedCanvasWorldRect()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
s := &w.renderState
|
||
vw, vh, mx, my, cw, ch, z := incrementalKeyFromParams(params, zoomFp)
|
||
|
||
s.initialized = true
|
||
s.lastZoomFp = z
|
||
s.lastViewportW = vw
|
||
s.lastViewportH = vh
|
||
s.lastMarginX = mx
|
||
s.lastMarginY = my
|
||
s.lastCanvasW = cw
|
||
s.lastCanvasH = ch
|
||
s.lastWorldRect = rect
|
||
|
||
// Reset remainders on a full redraw to avoid stale accumulation when geometry changes.
|
||
s.remXNum = 0
|
||
s.remYNum = 0
|
||
|
||
s.pendingDirty = nil
|
||
|
||
return nil
|
||
}
|
||
|
||
func (w *World) drawBackground(drawer PrimitiveDrawer, params RenderParams, rect RectPx) {
|
||
if gd, ok := drawer.(*GGDrawer); ok {
|
||
if gd.drawBackgroundFast(w, params, rect) {
|
||
return
|
||
}
|
||
}
|
||
th := w.Theme()
|
||
bgImg := th.BackgroundImage()
|
||
if bgImg == nil {
|
||
return
|
||
}
|
||
|
||
canvasW := params.CanvasWidthPx()
|
||
canvasH := params.CanvasHeightPx()
|
||
|
||
// Clamp rect to canvas.
|
||
if rect.W <= 0 || rect.H <= 0 {
|
||
return
|
||
}
|
||
if rect.X < 0 {
|
||
rect.W += rect.X
|
||
rect.X = 0
|
||
}
|
||
if rect.Y < 0 {
|
||
rect.H += rect.Y
|
||
rect.Y = 0
|
||
}
|
||
if rect.X+rect.W > canvasW {
|
||
rect.W = canvasW - rect.X
|
||
}
|
||
if rect.Y+rect.H > canvasH {
|
||
rect.H = canvasH - rect.Y
|
||
}
|
||
if rect.W <= 0 || rect.H <= 0 {
|
||
return
|
||
}
|
||
|
||
imgB := bgImg.Bounds()
|
||
imgW := imgB.Dx()
|
||
imgH := imgB.Dy()
|
||
if imgW <= 0 || imgH <= 0 {
|
||
return
|
||
}
|
||
|
||
tileMode := th.BackgroundTileMode()
|
||
anchor := th.BackgroundAnchorMode()
|
||
scaleMode := th.BackgroundScaleMode()
|
||
|
||
// Compute scaled tile size.
|
||
tileW, tileH := backgroundScaledSize(imgW, imgH, canvasW, canvasH, scaleMode)
|
||
if tileW <= 0 || tileH <= 0 {
|
||
return
|
||
}
|
||
|
||
offX, offY := w.backgroundAnchorOffsetPx(params, tileW, tileH, anchor)
|
||
|
||
drawer.Save()
|
||
drawer.ResetClip()
|
||
drawer.ClipRect(float64(rect.X), float64(rect.Y), float64(rect.W), float64(rect.H))
|
||
|
||
switch tileMode {
|
||
case BackgroundTileNone:
|
||
// Center image within full canvas (not within rect), then clip handles partial.
|
||
// This is important so that dirty redraw matches full redraw.
|
||
x := (canvasW-tileW)/2 + offX
|
||
y := (canvasH-tileH)/2 + offY
|
||
drawBackgroundOne(drawer, bgImg, x, y, imgW, imgH, tileW, tileH, scaleMode)
|
||
|
||
case BackgroundTileRepeat:
|
||
originX := offX
|
||
originY := offY
|
||
|
||
startX := floorDiv(rect.X-originX, tileW)*tileW + originX
|
||
startY := floorDiv(rect.Y-originY, tileH)*tileH + originY
|
||
|
||
for yy := startY; yy < rect.Y+rect.H; yy += tileH {
|
||
for xx := startX; xx < rect.X+rect.W; xx += tileW {
|
||
drawBackgroundOne(drawer, bgImg, xx, yy, imgW, imgH, tileW, tileH, scaleMode)
|
||
}
|
||
}
|
||
|
||
default:
|
||
// Fallback: behave like none.
|
||
x := (canvasW-tileW)/2 + offX
|
||
y := (canvasH-tileH)/2 + offY
|
||
drawBackgroundOne(drawer, bgImg, x, y, imgW, imgH, tileW, tileH, scaleMode)
|
||
}
|
||
|
||
drawer.Restore()
|
||
}
|
||
|
||
// drawBackgroundOne draws one background-image instance at the requested
|
||
// canvas position, scaling it when needed.
|
||
func drawBackgroundOne(drawer PrimitiveDrawer, img image.Image, x, y, srcW, srcH, dstW, dstH int, scaleMode BackgroundScaleMode) {
|
||
if scaleMode == BackgroundScaleNone && dstW == srcW && dstH == srcH {
|
||
drawer.DrawImage(img, x, y)
|
||
return
|
||
}
|
||
// For Fit/Fill, or if dst size differs, draw scaled.
|
||
drawer.DrawImageScaled(img, x, y, dstW, dstH)
|
||
}
|
||
|
||
// backgroundScaledSize computes uniform scaled destination size for the background image.
|
||
// For None: returns source size.
|
||
// For Fit: fits inside canvas.
|
||
// For Fill: covers canvas.
|
||
func backgroundScaledSize(srcW, srcH, canvasW, canvasH int, mode BackgroundScaleMode) (int, int) {
|
||
if srcW <= 0 || srcH <= 0 || canvasW <= 0 || canvasH <= 0 {
|
||
return 0, 0
|
||
}
|
||
|
||
switch mode {
|
||
case BackgroundScaleNone:
|
||
return srcW, srcH
|
||
|
||
case BackgroundScaleFit, BackgroundScaleFill:
|
||
// Uniform scale: choose ratio based on min/max.
|
||
// Use integer math to avoid float; keep it stable across frames.
|
||
// We compute scale as rational and then round destination size.
|
||
// Let scale = canvasW/srcW vs canvasH/srcH.
|
||
// Fit uses min(scaleW, scaleH). Fill uses max(scaleW, scaleH).
|
||
//
|
||
// We'll compute dstW = round(srcW*scale), dstH = round(srcH*scale).
|
||
// Using float64 here is acceptable: this is UI-only and deterministic enough, and we already use gg float.
|
||
scaleW := float64(canvasW) / float64(srcW)
|
||
scaleH := float64(canvasH) / float64(srcH)
|
||
|
||
scale := scaleW
|
||
if mode == BackgroundScaleFit {
|
||
if scaleH < scale {
|
||
scale = scaleH
|
||
}
|
||
} else {
|
||
if scaleH > scale {
|
||
scale = scaleH
|
||
}
|
||
}
|
||
|
||
dstW := int(scale*float64(srcW) + 0.5)
|
||
dstH := int(scale*float64(srcH) + 0.5)
|
||
if dstW < 1 {
|
||
dstW = 1
|
||
}
|
||
if dstH < 1 {
|
||
dstH = 1
|
||
}
|
||
return dstW, dstH
|
||
|
||
default:
|
||
return srcW, srcH
|
||
}
|
||
}
|
||
|
||
// backgroundAnchorOffsetPx computes a stable pixel offset for background anchoring.
|
||
// - Viewport anchor: offset is always 0 (background fixed to viewport/canvas pixels).
|
||
// - World anchor: offset depends on camera world position and zoom so that background moves with pan.
|
||
func (w *World) backgroundAnchorOffsetPx(params RenderParams, tileW, tileH int, anchor BackgroundAnchorMode) (int, int) {
|
||
if anchor == BackgroundAnchorViewport {
|
||
return 0, 0
|
||
}
|
||
|
||
zoomFp, err := params.CameraZoomFp()
|
||
if err != nil || zoomFp <= 0 {
|
||
return 0, 0
|
||
}
|
||
|
||
canvasW := params.CanvasWidthPx()
|
||
canvasH := params.CanvasHeightPx()
|
||
|
||
spanW := PixelSpanToWorldFixed(canvasW, zoomFp)
|
||
spanH := PixelSpanToWorldFixed(canvasH, zoomFp)
|
||
|
||
worldLeft := params.CameraXWorldFp - spanW/2
|
||
worldTop := params.CameraYWorldFp - spanH/2
|
||
|
||
pxX := worldSpanFixedToCanvasPx(worldLeft, zoomFp)
|
||
pxY := worldSpanFixedToCanvasPx(worldTop, zoomFp)
|
||
|
||
if tileW > 0 {
|
||
pxX = -wrap(pxX, tileW)
|
||
}
|
||
if tileH > 0 {
|
||
pxY = -wrap(pxY, tileH)
|
||
}
|
||
return pxX, pxY
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// RenderScheduler is a toolkit-agnostic example of render-request coalescing.
|
||
//
|
||
// It keeps at most one render in flight and always collapses intermediate
|
||
// requests to the latest RenderParams snapshot. The scheduler is intentionally
|
||
// not a background renderer: real UI integrations must still execute World.Render
|
||
// on the UI thread and should replace the goroutine hand-off in runOnUIThread
|
||
// with toolkit-specific scheduling primitives.
|
||
// RenderScheduler keeps the latest requested RenderParams and serializes renders.
|
||
type RenderScheduler struct {
|
||
w *World
|
||
drawer PrimitiveDrawer
|
||
|
||
// Protects fields below.
|
||
mu sync.Mutex
|
||
|
||
inFlight bool
|
||
pending bool
|
||
latest RenderParams
|
||
}
|
||
|
||
// RequestRender stores the latest params and schedules rendering.
|
||
// If a render is already in progress, it coalesces (drops intermediate requests).
|
||
func (s *RenderScheduler) RequestRender(params RenderParams) {
|
||
s.mu.Lock()
|
||
s.latest = params
|
||
if s.inFlight {
|
||
s.pending = true
|
||
s.mu.Unlock()
|
||
return
|
||
}
|
||
s.inFlight = true
|
||
s.mu.Unlock()
|
||
|
||
// Schedule on the UI thread/event loop. Replace this with your toolkit method.
|
||
go s.runOnUIThread()
|
||
}
|
||
|
||
// runOnUIThread renders the latest known params and repeats if newer params
|
||
// arrived while the previous render was running.
|
||
//
|
||
// The example body uses a goroutine only as a placeholder. Real applications
|
||
// should run the body on their UI event loop.
|
||
func (s *RenderScheduler) runOnUIThread() {
|
||
for {
|
||
s.mu.Lock()
|
||
params := s.latest
|
||
s.mu.Unlock()
|
||
|
||
s.w.ClampRenderParamsNoWrap(¶ms)
|
||
_ = s.w.Render(s.drawer, params) // handle error in real code
|
||
|
||
s.mu.Lock()
|
||
if !s.pending {
|
||
s.inFlight = false
|
||
s.mu.Unlock()
|
||
return
|
||
}
|
||
// There was a newer request while we were rendering. Loop and render latest.
|
||
s.pending = false
|
||
s.mu.Unlock()
|
||
}
|
||
}
|
||
|
||
// RenderStyle describes visual parameters for renderer passes.
|
||
// It is intentionally screen-space oriented (pixels), since the renderer
|
||
// already projects world coordinates into canvas pixels.
|
||
type RenderStyle struct {
|
||
// PointRadiusPx is the screen-space radius for Point markers.
|
||
PointRadiusPx float64
|
||
|
||
// PointFill is the fill color for points.
|
||
PointFill color.Color
|
||
|
||
// CircleFill is the fill color for circles.
|
||
CircleFill color.Color
|
||
|
||
// LineStroke is the stroke color for lines.
|
||
LineStroke color.Color
|
||
|
||
// LineWidthPx is the stroke width for lines.
|
||
LineWidthPx float64
|
||
|
||
// LineDash is the dash pattern for lines. Empty => solid.
|
||
LineDash []float64
|
||
|
||
// LineDashOffset is the dash phase for lines.
|
||
LineDashOffset float64
|
||
}
|
||
|
||
// DefaultRenderStyle returns the default style used when UI does not provide one.
|
||
// Defaults are intentionally simple and stable for testing.
|
||
func DefaultRenderStyle() RenderStyle {
|
||
return RenderStyle{
|
||
PointRadiusPx: 2.0,
|
||
PointFill: color.White,
|
||
|
||
CircleFill: color.White,
|
||
|
||
LineStroke: color.White,
|
||
LineWidthPx: 2.0,
|
||
LineDash: nil,
|
||
LineDashOffset: 0,
|
||
}
|
||
}
|
||
|
||
// DefaultIncrementalPolicy returns the default incremental pan policy.
|
||
//
|
||
// The zero-friction default is conservative: no shift-only degradation, no
|
||
// render-budget heuristics, and no catch-up area cap.
|
||
func DefaultIncrementalPolicy() IncrementalPolicy {
|
||
return IncrementalPolicy{
|
||
CoalesceUpdates: false,
|
||
AllowShiftOnly: false,
|
||
RenderBudgetMs: 0,
|
||
MaxCatchUpAreaPx: 0,
|
||
}
|
||
}
|
||
|
||
// applyPointStyle configures drawer state for point rendering.
|
||
func applyPointStyle(drawer PrimitiveDrawer, style RenderStyle) {
|
||
drawer.SetFillColor(style.PointFill)
|
||
}
|
||
|
||
// applyCircleStyle configures drawer state for circle rendering.
|
||
func applyCircleStyle(drawer PrimitiveDrawer, style RenderStyle) {
|
||
drawer.SetFillColor(style.CircleFill)
|
||
}
|
||
|
||
// applyLineStyle configures drawer state for line rendering.
|
||
func applyLineStyle(drawer PrimitiveDrawer, style RenderStyle) {
|
||
drawer.SetStrokeColor(style.LineStroke)
|
||
drawer.SetLineWidth(style.LineWidthPx)
|
||
drawer.SetDash(style.LineDash...)
|
||
drawer.SetDashOffset(style.LineDashOffset)
|
||
}
|