Files
galaxy-game/client/world/renderer.go
T
2026-03-08 23:30:11 +02:00

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
}