Files
galaxy-game/client/world/renderer_incremental_state.go
T
2026-03-07 00:29:06 +03:00

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
}