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

266 lines
7.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 cant 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
}