ui: basic map scroller
This commit is contained in:
@@ -0,0 +1,473 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RenderLayer identifies one drawing pass.
|
||||
type RenderLayer int
|
||||
|
||||
const (
|
||||
RenderLayerPoints RenderLayer = iota
|
||||
RenderLayerCircles
|
||||
RenderLayerLines
|
||||
)
|
||||
|
||||
// RenderOptions controls which layers are rendered and their order.
|
||||
// If Layers is empty, the default order is: Points, Circles, Lines.
|
||||
type RenderOptions struct {
|
||||
Layers []RenderLayer
|
||||
Style *RenderStyle
|
||||
// Incremental controls incremental pan behavior. If nil, defaults are used.
|
||||
Incremental *IncrementalPolicy
|
||||
}
|
||||
|
||||
var (
|
||||
errInvalidViewportSize = errors.New("render: invalid viewport size")
|
||||
errInvalidMargins = errors.New("render: invalid margins")
|
||||
errNilDrawer = errors.New("render: nil drawer")
|
||||
)
|
||||
|
||||
// RenderParams describes one render request coming from the UI layer.
|
||||
//
|
||||
// Camera coordinates are expressed in world fixed-point units and point to the
|
||||
// center of the visible viewport. Margins are expressed in canvas pixels and
|
||||
// extend the rendered area around the viewport on each axis independently.
|
||||
//
|
||||
// The final canvas size is derived from viewport size and margins:
|
||||
//
|
||||
// canvasWidthPx = viewportWidthPx + 2*marginXPx
|
||||
// canvasHeightPx = viewportHeightPx + 2*marginYPx
|
||||
type RenderParams struct {
|
||||
ViewportWidthPx int
|
||||
ViewportHeightPx int
|
||||
|
||||
MarginXPx int
|
||||
MarginYPx int
|
||||
|
||||
CameraXWorldFp int
|
||||
CameraYWorldFp int
|
||||
|
||||
CameraZoom float64
|
||||
|
||||
// Optional render options. If nil, defaults are used.
|
||||
Options *RenderOptions
|
||||
|
||||
// Used for various debugging purposes
|
||||
Debug bool
|
||||
}
|
||||
|
||||
// CanvasWidthPx returns the full expanded canvas width in pixels.
|
||||
func (p RenderParams) CanvasWidthPx() int { return p.ViewportWidthPx + 2*p.MarginXPx }
|
||||
|
||||
// CanvasHeightPx returns the full expanded canvas height in pixels.
|
||||
func (p RenderParams) CanvasHeightPx() int { return p.ViewportHeightPx + 2*p.MarginYPx }
|
||||
|
||||
// CameraZoomFp converts the UI-facing zoom value into the package fixed-point form.
|
||||
func (p RenderParams) CameraZoomFp() (int, error) {
|
||||
return cameraZoomToWorldFixed(p.CameraZoom)
|
||||
}
|
||||
|
||||
// ExpandedCanvasWorldRect returns the world-space half-open rectangle covered by
|
||||
// the full expanded canvas around the camera center.
|
||||
//
|
||||
// The returned rectangle is expressed in fixed-point world coordinates and is not
|
||||
// wrapped into [0, W) x [0, H). It may extend beyond world bounds on either axis;
|
||||
// torus normalization and tiling are handled later by the renderer pipeline.
|
||||
func (p RenderParams) ExpandedCanvasWorldRect() (Rect, error) {
|
||||
zoomFp, err := p.CameraZoomFp()
|
||||
if err != nil {
|
||||
return Rect{}, err
|
||||
}
|
||||
|
||||
return expandedCanvasWorldRect(
|
||||
p.CameraXWorldFp,
|
||||
p.CameraYWorldFp,
|
||||
p.CanvasWidthPx(),
|
||||
p.CanvasHeightPx(),
|
||||
zoomFp,
|
||||
), nil
|
||||
}
|
||||
|
||||
// Validate checks whether the render request is internally consistent.
|
||||
// Camera coordinates are intentionally not restricted here because the renderer
|
||||
// is expected to normalize them through torus wrap.
|
||||
func (p RenderParams) Validate() error {
|
||||
if p.ViewportWidthPx <= 0 || p.ViewportHeightPx <= 0 {
|
||||
return errInvalidViewportSize
|
||||
}
|
||||
if p.MarginXPx < 0 || p.MarginYPx < 0 {
|
||||
return errInvalidMargins
|
||||
}
|
||||
|
||||
if _, err := p.CameraZoomFp(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if p.CanvasWidthPx() <= 0 || p.CanvasHeightPx() <= 0 {
|
||||
return errInvalidViewportSize
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// expandedCanvasWorldRect computes the world-space half-open rectangle covered by
|
||||
// a full expanded canvas centered on the camera.
|
||||
//
|
||||
// The rectangle is returned in fixed-point world coordinates and is not wrapped.
|
||||
// A later renderer step is expected to tile and normalize it against torus bounds.
|
||||
func expandedCanvasWorldRect(
|
||||
cameraXWorldFp, cameraYWorldFp int,
|
||||
canvasWidthPx, canvasHeightPx int,
|
||||
zoomFp int,
|
||||
) Rect {
|
||||
if canvasWidthPx <= 0 || canvasHeightPx <= 0 {
|
||||
panic("expandedCanvasWorldRect: invalid canvas size")
|
||||
}
|
||||
if zoomFp <= 0 {
|
||||
panic("expandedCanvasWorldRect: invalid zoom")
|
||||
}
|
||||
|
||||
worldWidthFp := PixelSpanToWorldFixed(canvasWidthPx, zoomFp)
|
||||
worldHeightFp := PixelSpanToWorldFixed(canvasHeightPx, zoomFp)
|
||||
|
||||
minX := cameraXWorldFp - worldWidthFp/2
|
||||
minY := cameraYWorldFp - worldHeightFp/2
|
||||
|
||||
return Rect{
|
||||
minX: minX,
|
||||
maxX: minX + worldWidthFp,
|
||||
minY: minY,
|
||||
maxY: minY + worldHeightFp,
|
||||
}
|
||||
}
|
||||
|
||||
// Render draws the current world state onto the expanded canvas represented by drawer.
|
||||
//
|
||||
// Stage A implementation is expected to perform a full redraw of the entire
|
||||
// expanded canvas. Incremental scrolling and canvas shifting are intentionally
|
||||
// left for later stages.
|
||||
//
|
||||
// The renderer must treat the camera as looking at the center of the viewport,
|
||||
// not the center of the full expanded canvas.
|
||||
//
|
||||
// The renderer performs three passes (layers) in a configurable order.
|
||||
// The render plan (tiling + candidates + clips) is built once and reused.
|
||||
func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
|
||||
if drawer == nil {
|
||||
return errNilDrawer
|
||||
}
|
||||
if err := params.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if !params.Debug {
|
||||
return
|
||||
}
|
||||
drawer.AddLine(
|
||||
float64(params.MarginXPx),
|
||||
float64(params.MarginYPx),
|
||||
float64(params.MarginXPx+params.ViewportWidthPx),
|
||||
float64(params.MarginYPx))
|
||||
drawer.AddLine(
|
||||
float64(params.MarginXPx),
|
||||
float64(params.MarginYPx),
|
||||
float64(params.MarginXPx),
|
||||
float64(params.MarginYPx+params.ViewportHeightPx))
|
||||
drawer.AddLine(
|
||||
float64(params.MarginXPx+params.ViewportWidthPx),
|
||||
float64(params.MarginYPx),
|
||||
float64(params.MarginXPx+params.ViewportWidthPx),
|
||||
float64(params.MarginYPx+params.ViewportHeightPx))
|
||||
drawer.AddLine(
|
||||
float64(params.MarginXPx),
|
||||
float64(params.MarginYPx+params.ViewportHeightPx),
|
||||
float64(params.MarginXPx+params.ViewportWidthPx),
|
||||
float64(params.MarginYPx+params.ViewportHeightPx))
|
||||
}()
|
||||
|
||||
startTs := time.Now()
|
||||
defer func() {
|
||||
// record dtRender for future overload heuristics
|
||||
w.renderState.lastRenderDurationNs = time.Since(startTs).Nanoseconds()
|
||||
}()
|
||||
|
||||
policy := DefaultIncrementalPolicy()
|
||||
if params.Options != nil && params.Options.Incremental != nil {
|
||||
policy = *params.Options.Incremental
|
||||
}
|
||||
|
||||
// --- Prepare style / layers (same as before) ---
|
||||
style := DefaultRenderStyle()
|
||||
if params.Options != nil && params.Options.Style != nil {
|
||||
style = *params.Options.Style
|
||||
}
|
||||
|
||||
layers := []RenderLayer{RenderLayerPoints, RenderLayerCircles, RenderLayerLines}
|
||||
if params.Options != nil && len(params.Options.Layers) > 0 {
|
||||
layers = params.Options.Layers
|
||||
}
|
||||
|
||||
// --- Try incremental path first when state is initialized and geometry matches ---
|
||||
dxPx, dyPx, derr := w.ComputePanShiftPx(params)
|
||||
if derr == nil {
|
||||
inc, perr := PlanIncrementalPan(
|
||||
params.CanvasWidthPx(),
|
||||
params.CanvasHeightPx(),
|
||||
params.MarginXPx,
|
||||
params.MarginYPx,
|
||||
dxPx,
|
||||
dyPx,
|
||||
)
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
|
||||
switch inc.Mode {
|
||||
case IncrementalNoOp:
|
||||
// If we accumulated dirty regions during shift-only frames, redraw them now (bounded).
|
||||
if len(w.renderState.pendingDirty) > 0 {
|
||||
toDraw, remaining := takeCatchUpRects(w.renderState.pendingDirty, policy.MaxCatchUpAreaPx)
|
||||
|
||||
if len(toDraw) > 0 {
|
||||
for _, r := range toDraw {
|
||||
drawer.ClearRect(r.X, r.Y, r.W, r.H)
|
||||
}
|
||||
|
||||
plan, err := w.buildRenderPlanStageA(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
catchUpPlan := planRestrictedToDirtyRects(plan, toDraw)
|
||||
|
||||
for _, layer := range layers {
|
||||
switch layer {
|
||||
case RenderLayerPoints:
|
||||
applyPointStyle(drawer, style)
|
||||
drawPointsFromPlanWithRadius(drawer, catchUpPlan, w.W, w.H, style.PointRadiusPx)
|
||||
case RenderLayerCircles:
|
||||
applyCircleStyle(drawer, style)
|
||||
drawCirclesFromPlan(drawer, catchUpPlan, w.W, w.H)
|
||||
case RenderLayerLines:
|
||||
applyLineStyle(drawer, style)
|
||||
drawLinesFromPlan(drawer, catchUpPlan, w.W, w.H)
|
||||
default:
|
||||
panic("render: unknown layer")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.renderState.pendingDirty = remaining
|
||||
}
|
||||
return nil
|
||||
|
||||
case IncrementalShift:
|
||||
// Move existing pending dirty rects together with the backing image shift.
|
||||
if len(w.renderState.pendingDirty) > 0 {
|
||||
moved := make([]RectPx, 0, len(w.renderState.pendingDirty))
|
||||
for _, r := range w.renderState.pendingDirty {
|
||||
if rr, ok := shiftAndClipRectPx(r, inc.DxPx, inc.DyPx, params.CanvasWidthPx(), params.CanvasHeightPx()); ok {
|
||||
moved = append(moved, rr)
|
||||
}
|
||||
}
|
||||
w.renderState.pendingDirty = moved
|
||||
}
|
||||
// C5: shift backing pixels, then redraw only dirty strips.
|
||||
drawer.CopyShift(inc.DxPx, inc.DyPx)
|
||||
|
||||
overBudget := false
|
||||
if policy.AllowShiftOnly && policy.RenderBudgetMs > 0 {
|
||||
budgetNs := int64(policy.RenderBudgetMs) * 1_000_000
|
||||
if w.renderState.lastRenderDurationNs > budgetNs {
|
||||
overBudget = true
|
||||
}
|
||||
}
|
||||
if overBudget {
|
||||
// Shift-only: defer drawing; remember newly exposed strips.
|
||||
if len(inc.Dirty) > 0 {
|
||||
w.renderState.pendingDirty = append(w.renderState.pendingDirty, inc.Dirty...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// [ ] Сразу после overBudget вычисления и до построения dirtyPlan
|
||||
// Draw both the newly exposed strips and any previously deferred dirty regions.
|
||||
// Always redraw newly exposed strips fully.
|
||||
// Under budget: draw newly exposed strips immediately, plus bounded catch-up.
|
||||
dirtyToDraw := inc.Dirty
|
||||
|
||||
for _, r := range dirtyToDraw {
|
||||
drawer.ClearRect(r.X, r.Y, r.W, r.H)
|
||||
}
|
||||
|
||||
// Additionally redraw a bounded portion of deferred dirty regions.
|
||||
if len(w.renderState.pendingDirty) > 0 {
|
||||
catchUp, remaining := takeCatchUpRects(w.renderState.pendingDirty, policy.MaxCatchUpAreaPx)
|
||||
dirtyToDraw = append(dirtyToDraw, catchUp...)
|
||||
w.renderState.pendingDirty = remaining
|
||||
}
|
||||
|
||||
plan, err := w.buildRenderPlanStageA(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dirtyPlan := planRestrictedToDirtyRects(plan, dirtyToDraw)
|
||||
|
||||
for _, layer := range layers {
|
||||
switch layer {
|
||||
case RenderLayerPoints:
|
||||
applyPointStyle(drawer, style)
|
||||
drawPointsFromPlanWithRadius(drawer, dirtyPlan, w.W, w.H, style.PointRadiusPx)
|
||||
case RenderLayerCircles:
|
||||
applyCircleStyle(drawer, style)
|
||||
drawCirclesFromPlan(drawer, dirtyPlan, w.W, w.H)
|
||||
case RenderLayerLines:
|
||||
applyLineStyle(drawer, style)
|
||||
drawLinesFromPlan(drawer, dirtyPlan, w.W, w.H)
|
||||
default:
|
||||
panic("render: unknown layer")
|
||||
}
|
||||
}
|
||||
|
||||
// State already updated by ComputePanShiftPx (lastWorldRect advanced).
|
||||
return nil
|
||||
|
||||
case IncrementalFullRedraw:
|
||||
// Fall through to full redraw below.
|
||||
default:
|
||||
panic("render: unknown incremental mode")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Full redraw path ---
|
||||
plan, err := w.buildRenderPlanStageA(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
drawer.ClearAll()
|
||||
|
||||
for _, layer := range layers {
|
||||
switch layer {
|
||||
case RenderLayerPoints:
|
||||
applyPointStyle(drawer, style)
|
||||
drawPointsFromPlanWithRadius(drawer, plan, w.W, w.H, style.PointRadiusPx)
|
||||
case RenderLayerCircles:
|
||||
applyCircleStyle(drawer, style)
|
||||
drawCirclesFromPlan(drawer, plan, w.W, w.H)
|
||||
case RenderLayerLines:
|
||||
applyLineStyle(drawer, style)
|
||||
drawLinesFromPlan(drawer, plan, w.W, w.H)
|
||||
default:
|
||||
panic("render: unknown layer")
|
||||
}
|
||||
}
|
||||
|
||||
return w.CommitFullRedrawState(params)
|
||||
}
|
||||
|
||||
// ForceFullRedrawNext resets internal incremental renderer state.
|
||||
// After this call, the next Render() will use the full redraw path and
|
||||
// re-initialize incremental state.
|
||||
func (w *World) ForceFullRedrawNext() {
|
||||
w.renderState.Reset()
|
||||
}
|
||||
|
||||
// WorldTile describes one torus tile contribution for an unwrapped world rect.
|
||||
//
|
||||
// Rect is the portion of the unwrapped rect mapped into the canonical world domain
|
||||
// [0, worldWidthFp) x [0, worldHeightFp) as a half-open rectangle.
|
||||
// OffsetX/OffsetY are the world-space tile offsets (multiples of world width/height)
|
||||
// that map this canonical rect back into the unwrapped coordinate space.
|
||||
type WorldTile struct {
|
||||
Rect Rect
|
||||
OffsetX int
|
||||
OffsetY int
|
||||
}
|
||||
|
||||
// tileWorldRect splits an unwrapped world-space rect into a set of tiles,
|
||||
// each mapped into the canonical world domain [0, worldWidthFp) x [0, worldHeightFp).
|
||||
//
|
||||
// Unlike splitByWrap, this function does NOT collapse spans wider than the world.
|
||||
// If rect spans multiple world widths/heights, it returns multiple tiles.
|
||||
// The returned tiles are ordered by increasing tile X index, then by increasing tile Y index.
|
||||
func tileWorldRect(rect Rect, worldWidthFp, worldHeightFp int) []WorldTile {
|
||||
if worldWidthFp <= 0 || worldHeightFp <= 0 {
|
||||
panic("tileWorldRect: non-positive world size")
|
||||
}
|
||||
|
||||
width := rect.maxX - rect.minX
|
||||
height := rect.maxY - rect.minY
|
||||
if width <= 0 || height <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Determine which torus tiles the rect intersects.
|
||||
// Since rect is half-open, use (max-1) for inclusive end.
|
||||
minTileX := floorDiv(rect.minX, worldWidthFp)
|
||||
maxTileX := floorDiv(rect.maxX-1, worldWidthFp)
|
||||
minTileY := floorDiv(rect.minY, worldHeightFp)
|
||||
maxTileY := floorDiv(rect.maxY-1, worldHeightFp)
|
||||
|
||||
out := make([]WorldTile, 0, (maxTileX-minTileX+1)*(maxTileY-minTileY+1))
|
||||
|
||||
for tx := minTileX; tx <= maxTileX; tx++ {
|
||||
tileBaseX := tx * worldWidthFp
|
||||
segMinX := max(rect.minX, tileBaseX)
|
||||
segMaxX := min(rect.maxX, tileBaseX+worldWidthFp)
|
||||
if segMinX >= segMaxX {
|
||||
continue
|
||||
}
|
||||
|
||||
localMinX := segMinX - tileBaseX
|
||||
localMaxX := segMaxX - tileBaseX
|
||||
|
||||
for ty := minTileY; ty <= maxTileY; ty++ {
|
||||
tileBaseY := ty * worldHeightFp
|
||||
segMinY := max(rect.minY, tileBaseY)
|
||||
segMaxY := min(rect.maxY, tileBaseY+worldHeightFp)
|
||||
if segMinY >= segMaxY {
|
||||
continue
|
||||
}
|
||||
|
||||
localMinY := segMinY - tileBaseY
|
||||
localMaxY := segMaxY - tileBaseY
|
||||
|
||||
out = append(out, WorldTile{
|
||||
Rect: Rect{
|
||||
minX: localMinX, maxX: localMaxX,
|
||||
minY: localMinY, maxY: localMaxY,
|
||||
},
|
||||
OffsetX: tileBaseX,
|
||||
OffsetY: tileBaseY,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func isEmptyRectPx(r RectPx) bool {
|
||||
return r.W <= 0 || r.H <= 0
|
||||
}
|
||||
|
||||
func intersectRectPx(a, b RectPx) (RectPx, bool) {
|
||||
ax2 := a.X + a.W
|
||||
ay2 := a.Y + a.H
|
||||
bx2 := b.X + b.W
|
||||
by2 := b.Y + b.H
|
||||
|
||||
x1 := max(a.X, b.X)
|
||||
y1 := max(a.Y, b.Y)
|
||||
x2 := min(ax2, bx2)
|
||||
y2 := min(ay2, by2)
|
||||
|
||||
w := x2 - x1
|
||||
h := y2 - y1
|
||||
if w <= 0 || h <= 0 {
|
||||
return RectPx{}, false
|
||||
}
|
||||
|
||||
return RectPx{X: x1, Y: y1, W: w, H: h}, true
|
||||
}
|
||||
Reference in New Issue
Block a user