package world import ( "errors" "image/color" "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.buildRenderPlanStageA(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.buildRenderPlanStageA(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.buildRenderPlanStageA(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, }, } } func isEmptyRectPx(r RectPx) bool { return r.W <= 0 || r.H <= 0 } 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 }