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) }