Files
galaxy-game/client/world/renderer.go
T
Ilia Denisov 5029857fe4 world refactor
2026-03-17 12:48:05 +03:00

1890 lines
53 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 cant 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(&params)
_ = 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)
}