266 lines
7.8 KiB
Go
266 lines
7.8 KiB
Go
package world
|
||
|
||
import "errors"
|
||
|
||
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
|
||
}
|
||
|
||
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
|
||
}
|