500 lines
14 KiB
Go
500 lines
14 KiB
Go
package world
|
|
|
|
import (
|
|
"errors"
|
|
"image/color"
|
|
"time"
|
|
)
|
|
|
|
// RenderLayer identifies one drawing pass.
|
|
type RenderLayer int
|
|
|
|
const (
|
|
RenderLayerPoints RenderLayer = iota
|
|
RenderLayerCircles
|
|
RenderLayerLines
|
|
)
|
|
|
|
const (
|
|
drawPlanSinglePassClipEnabled = false
|
|
|
|
// best value according to BenchmarkDrawPlanSinglePass_Lines_GG
|
|
maxLineSegmentsPerStroke = 32
|
|
)
|
|
|
|
// 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
|
|
// DisableWrapScroll controls whether the world is treated as a torus (false)
|
|
// or as a bounded plane without wrap (true).
|
|
// Default is false.
|
|
DisableWrapScroll bool
|
|
|
|
// BackgroundColor is used to clear full redraw and dirty regions.
|
|
// If nil, default background is opaque black.
|
|
BackgroundColor color.Color
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
var bg color.Color = color.RGBA{A: 255} // default black
|
|
|
|
if params.Options != nil && params.Options.BackgroundColor != nil {
|
|
bg = params.Options.BackgroundColor
|
|
} else {
|
|
tc := w.Theme().BackgroundColor()
|
|
if alphaNonZero(tc) {
|
|
bg = tc
|
|
}
|
|
}
|
|
|
|
allowWrap := params.Options == nil || !params.Options.DisableWrapScroll
|
|
|
|
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
|
|
}
|
|
|
|
// Helper: draw one dirty rect with outer clip, using an already prepared dirtyPlan.
|
|
// IMPORTANT: dirtyPlan must be built from the FULL set of dirty rects (union),
|
|
// not from a single rect, to avoid missing primitives on diagonal pans.
|
|
drawDirtyRect := func(dirtyPlan RenderPlan, r RectPx) error {
|
|
if r.W <= 0 || r.H <= 0 {
|
|
return nil
|
|
}
|
|
|
|
drawer.Save()
|
|
drawer.ResetClip()
|
|
drawer.ClipRect(float64(r.X), float64(r.Y), float64(r.W), float64(r.H))
|
|
|
|
// Clear + background in the same clip.
|
|
drawer.ClearRectTo(r.X, r.Y, r.W, r.H, bg)
|
|
w.drawBackground(drawer, params, r)
|
|
|
|
// Draw with outer clip only; do not rebuild plan per-rect.
|
|
// isDirtyPass MUST be true here.
|
|
w.drawPlanSinglePass(drawer, dirtyPlan, allowWrap, drawPlanSinglePassClipEnabled, true)
|
|
|
|
drawer.Restore()
|
|
return nil
|
|
}
|
|
|
|
// --- 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)
|
|
w.renderState.pendingDirty = remaining
|
|
|
|
if len(toDraw) == 0 {
|
|
return nil
|
|
}
|
|
|
|
plan, err := w.buildRenderPlanStageA(params)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Build once for the whole set of catch-up rects (union), then clip per rect.
|
|
catchUpPlan := planRestrictedToDirtyRects(plan, toDraw)
|
|
|
|
for _, r := range toDraw {
|
|
if err := drawDirtyRect(catchUpPlan, r); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
// Shift backing pixels first.
|
|
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
|
|
}
|
|
|
|
// Under budget: draw newly exposed strips immediately, plus bounded catch-up.
|
|
dirtyToDraw := inc.Dirty
|
|
|
|
// 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
|
|
}
|
|
|
|
if len(dirtyToDraw) == 0 {
|
|
return nil
|
|
}
|
|
|
|
plan, err := w.buildRenderPlanStageA(params)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Build once for the union of all dirty rects.
|
|
dirtyPlan := planRestrictedToDirtyRects(plan, dirtyToDraw)
|
|
|
|
// Draw per-rect with outer clip; background/clear done inside helper.
|
|
for _, r := range dirtyToDraw {
|
|
if err := drawDirtyRect(dirtyPlan, r); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
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.ClearAllTo(bg)
|
|
w.drawBackground(drawer, params, RectPx{X: 0, Y: 0, W: params.CanvasWidthPx(), H: params.CanvasHeightPx()})
|
|
w.drawPlanSinglePass(drawer, plan, allowWrap, drawPlanSinglePassClipEnabled, true)
|
|
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
|
|
}
|
|
|
|
// tileWorldRectNoWrap returns 0..1 tiles for a bounded world (no wrap).
|
|
// It intersects the expanded unwrapped rect with the canonical world [0..W)x[0..H).
|
|
func tileWorldRectNoWrap(worldRect Rect, W, H int) []WorldTile {
|
|
ix0 := max(worldRect.minX, 0)
|
|
iy0 := max(worldRect.minY, 0)
|
|
ix1 := min(worldRect.maxX, W)
|
|
iy1 := min(worldRect.maxY, H)
|
|
|
|
if ix0 >= ix1 || iy0 >= iy1 {
|
|
return nil
|
|
}
|
|
|
|
return []WorldTile{
|
|
{
|
|
Rect: Rect{minX: ix0, maxX: ix1, minY: iy0, maxY: iy1},
|
|
OffsetX: 0,
|
|
OffsetY: 0,
|
|
},
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|