203 lines
5.6 KiB
Go
203 lines
5.6 KiB
Go
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
|
|
}
|