package world import "errors" 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 }