Add tests for scenery surface caching and preview behavior

- Introduced `test_scenery_brick_preview.gd` to validate focused placement and CPU draw-cost for large brick walls.
- Added `test_scenery_surface_cache.gd` to assess texture budget, visual equivalence, and cache invalidation.
- Created `test_scenery_surface_preview.gd` to evaluate adaptive preview selection and placement behavior.
- Implemented various helper functions to measure drawing performance and validate expected behaviors during tests.
- Added unique identifiers for new test files to ensure proper tracking and management.
This commit is contained in:
2026-10-21 11:06:57 -04:00
parent 80ba83b703
commit c1758efd3b
54 changed files with 5941 additions and 526 deletions
+34 -2
View File
@@ -4,6 +4,29 @@ extends RefCounted
const SCHEMA_VERSION: String = "2.0"
const DEFAULT_PATH: String = "res://sandbox_theme.json"
const CONFIRMATION_DEFAULTS: Dictionary = {
"scenery_erase_size": [520, 208],
"padding": 18,
"body_gap": 10,
"footer_gap": 12,
"corner_radius": 16,
"border_width": 2,
"title_font_size": 26,
"message_font_size": 20,
"button_font_size": 20,
"button_height": 48,
"button_radius": 12,
"button_border_width": 2,
"remove_button_width": 170,
"keep_button_width": 132,
"surface_color": "#202633FA",
"border_color": "#22C6FF",
"accent_color": "#22C6FF",
"action_color": "#087DBA",
"danger_color": "#FF626B",
"danger_hover_surface": "#3B252C",
"danger_pressed_surface": "#4A252D"
}
const DEFAULT_CONFIG: Dictionary = {
"version": SCHEMA_VERSION,
@@ -13,14 +36,14 @@ const DEFAULT_CONFIG: Dictionary = {
"effects": {"impact_wobble": {"duration": 0.48, "maximum_angle_degrees": 7.0, "sideways_offset": 4.0}},
"grid": {"snap_size": 15.0},
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}},
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {"surface_color_size":[560,640], "surface_color_preview_size":[420,140], "surface_color_picker_min_size":[320,340], "action_min_target_size":48}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156], "surface_preview_size":[420,132], "look_cue_size":[80,42], "setup_gap":10}},
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {"surface_color_size":[560,640], "surface_color_preview_size":[420,140], "surface_color_picker_min_size":[320,340], "action_min_target_size":48}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156], "surface_preview_size":[420,132], "look_cue_size":[80,42], "setup_gap":8, "setup_title_size":30, "setup_heading_size":26, "setup_choice_font_size":22, "setup_choice_height":68, "setup_choice_corner_radius":22, "setup_choice_border_width":2, "tab_font_size":22, "tab_height":60, "filter_chip_font_size":20, "filter_chip_height":48, "filter_chip_gap":8, "setup_button_font_size":20, "button_border_width":2, "footer_gap":16, "surface_corner_radius":14, "look_corner_radius":8, "corner_radius":16, "compact_swatch_size":42, "color_group_gap":8, "action_color":"#087DBA", "separator_color":"#AAB2C0A0"}},
"palette": {"style_defaults": {}, "dock_width": 116, "card_size": [96, 94], "icon_size": 30, "drag_threshold": 8, "selected_border_width": 3},
"stage_panels": {
"shell": {"preferred_width": 380, "edge_inset": 12, "padding": 12, "header_gap": 8, "section_gap": 12, "corner_radius": 10, "surface_color": "#252B38FA", "border_color": "#FFFFFF33"},
"type": {"title_size": 24, "section_size": 20, "body_size": 20, "helper_size": 16, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0"},
"choice": {"minimum_height": 56, "row_gap": 6, "neutral_surface": "#171B24", "hover_surface": "#303A4B", "selected_surface": "#12394A", "disabled_surface": "#252B38", "border_color": "#FFFFFF33"},
"toggle": {"minimum_height": 64, "help_minimum_height": 72, "row_padding": 12, "switch_width": 64, "switch_height": 34, "knob_size": 26, "neutral_surface": "#202633", "hover_surface": "#2B3442", "selected_surface": "#173443", "disabled_surface": "#252B38", "border_color": "#FFFFFF33", "switch_on_color": "#22C6FF", "switch_off_color": "#4A5362", "switch_disabled_color": "#3B414D", "knob_color": "#F5F7FA"},
"action": {"min_target_size": 48, "close_width": 88, "surface_color": "#171B24", "hover_surface": "#303A4B", "selected_surface": "#12394A"},
"action": {"min_target_size": 48, "close_width": 88, "surface_color": "#171B24", "hover_surface": "#303A4B", "selected_surface": "#12394A", "primary_color": "#087DBA", "primary_hover_color": "#1596D3", "primary_pressed_color": "#066B9F", "destructive_color": "#FF626B", "destructive_hover_surface": "#3B252C", "destructive_pressed_surface": "#4A252D"},
"prop": {"stickman_cue_color": "#F5F7FA", "prop_cue_color": "#B7824D", "cue_outline_color": "#171B24"},
"spots": {"row_gap": 8, "pin_visual_size": 30, "pin_hit_size": 44, "pin_outline_width": 2, "focus_ring_width": 3, "name_pill_padding": 10, "pin_surface_color": "#F4F7FB", "pin_outline_color": "#171B24", "completion_duration": 0.6}
},
@@ -36,9 +59,13 @@ var _style_cache: Dictionary = {}
var _control_theme: Theme = null
var _warned: Dictionary = {}
func _init() -> void:
_apply_confirmation_defaults()
func load_from(path: String = DEFAULT_PATH) -> bool:
invalidate_caches()
_config = DEFAULT_CONFIG.duplicate(true)
_apply_confirmation_defaults()
if not FileAccess.file_exists(path):
_warn_once(path, "SandboxTheme: '%s' was not found; using defaults." % path)
return false
@@ -55,6 +82,11 @@ func load_from(path: String = DEFAULT_PATH) -> bool:
_config = _deep_merge(_config, parsed as Dictionary)
return true
func _apply_confirmation_defaults() -> void:
var popups: Dictionary = _config.get("popups", {}).duplicate(true)
popups["confirmation"] = CONFIRMATION_DEFAULTS.duplicate(true)
_config["popups"] = popups
func raw_config() -> Dictionary:
return _config.duplicate(true)
@@ -2,14 +2,20 @@ class_name StageBackgroundGround
extends "res://scripts/stage/scenery/stage_rect_surface.gd"
## Non-walkable rectangular scenery surface behind the set.
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const DRAW_ORDER := preload("res://scripts/stage/stage_draw_order.gd")
## Keeps procedural ground finishes aligned when this surface is split by the area eraser.
var motif_origin_offset: Vector2 = Vector2.ZERO
func configure(new_size: Vector2, finish: StringName, color: Color) -> void:
super.configure(new_size, finish if SURFACE_RENDERER.is_background_finish(finish) else &"finish_background_grass", color)
z_index = DRAW_ORDER.BACKGROUND_GROUND
func get_surface_draw_origin_world() -> Vector2:
return global_position + motif_origin_offset
func _draw() -> void:
SURFACE_RENDERER.draw_background(self, get_local_bounds(), finish_id, get_render_tint(), get_surface_draw_origin_world())
_draw_surface(false)
+182 -1
View File
@@ -2,17 +2,47 @@ class_name StageRectSurface
extends Node2D
## Shared rectangular geometry and transient tint preview for inert Stage scenery.
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const SURFACE_CACHE := preload("res://scripts/stage/scenery/stage_surface_cache.gd")
const PREVIEW_DRAW_BUDGET_USEC: int = 6000
## Each rectangle needs one handle; the image itself is shared across rectangles.
const MAX_RESIDENT_TILES: int = 1
var panel_size: Vector2 = Vector2(320.0, 400.0)
var finish_id: StringName = &""
var tint: Color = Color.WHITE
var _preview_tint: Color = Color.WHITE
var _has_preview_tint: bool = false
var _preview_over_budget: bool = false
var _surface_tiles: Dictionary = {}
var _cache_style: Array = []
var _cache_dirty: bool = true
var _last_view_transform: Transform2D
var _last_view_rect: Rect2
var _was_covered: bool = false
var _cover_regions_frame: int = -1
var _cover_regions: Array[Dictionary] = []
var _last_cover_regions: Array[Dictionary] = []
## Placement uses adaptive vector detail; committed surfaces share material textures.
var interactive_preview: bool = false:
set(value):
if interactive_preview == value:
return
interactive_preview = value
_preview_over_budget = false
queue_redraw()
func configure(new_size: Vector2, finish: StringName, color: Color) -> void:
panel_size = Vector2(maxf(1.0, new_size.x), maxf(1.0, new_size.y))
var next_size := Vector2(maxf(1.0, new_size.x), maxf(1.0, new_size.y))
if panel_size == next_size and finish_id == finish and tint == color:
return
if finish_id != finish:
_preview_over_budget = false
panel_size = next_size
finish_id = finish
tint = color
_cache_dirty = true
queue_redraw()
@@ -37,3 +67,154 @@ func get_render_tint() -> Color:
func get_surface_draw_origin_world() -> Vector2:
return global_position
func _exit_tree() -> void:
# Erase/Cancel retains detached geometry, never native drawing resources.
for tile: SURFACE_CACHE in _surface_tiles.values():
tile.dispose()
_surface_tiles.clear()
_cache_dirty = true
_cover_regions_frame = -1
_last_cover_regions.clear()
RenderingServer.canvas_item_clear(get_canvas_item())
func _process(_delta: float) -> void:
if not is_visible_in_tree():
for tile: SURFACE_CACHE in _surface_tiles.values():
tile.wanted = false
SURFACE_CACHE.build_next()
return
var covered: bool = not interactive_preview and _covered_by_surface()
var coverage_changed: bool = _get_cover_regions() != _last_cover_regions
if coverage_changed:
_last_cover_regions = _cover_regions.duplicate()
if covered:
for tile: SURFACE_CACHE in _surface_tiles.values():
tile.wanted = false
SURFACE_CACHE.build_next()
# Camera pans, zooms, and window resizing can reveal previously culled motifs.
if coverage_changed or covered != _was_covered or get_global_transform_with_canvas() != _last_view_transform or get_viewport_rect() != _last_view_rect:
queue_redraw()
_was_covered = covered
func _draw_surface(wall: bool) -> void:
_last_view_transform = get_global_transform_with_canvas()
_last_view_rect = get_viewport_rect()
if not interactive_preview and DisplayServer.get_name() != "headless" and finish_id not in [&"finish_wall_paint", &"finish_ground_paint"]:
if _covered_by_surface():
for tile: SURFACE_CACHE in _surface_tiles.values():
tile.wanted = false
return
_draw_cached_surface(wall)
return
var started_usec: int = Time.get_ticks_usec() if interactive_preview else 0
if wall:
SURFACE_RENDERER.draw_wall(self, get_local_bounds(), finish_id, get_render_tint(), get_surface_draw_origin_world(), Vector2.ONE, true, interactive_preview, _preview_over_budget)
else:
SURFACE_RENDERER.draw_background(self, get_local_bounds(), finish_id, get_render_tint(), get_surface_draw_origin_world(), Vector2.ONE, true, interactive_preview, _preview_over_budget)
# Latch for this placement session to avoid oscillating between detail levels.
# The complexity estimate handles large first draws before timing is available.
if interactive_preview and Time.get_ticks_usec() - started_usec > PREVIEW_DRAW_BUDGET_USEC:
_preview_over_budget = true
func _draw_cached_surface(wall: bool) -> void:
var visible_rect: Rect2 = get_local_bounds().intersection(SURFACE_RENDERER._visible_local_rect(self))
var color: Color = get_render_tint()
var style: Array = [wall, finish_id, color]
if _cache_style != style:
for previous: SURFACE_CACHE in _surface_tiles.values():
previous.dispose()
_surface_tiles.clear()
_cache_style = style
_cache_dirty = false
for previous: SURFACE_CACHE in _surface_tiles.values():
previous.wanted = false
if visible_rect.size.x <= 0.0 or visible_rect.size.y <= 0.0:
return
var period: Vector2 = SURFACE_RENDERER.pattern_size_for(finish_id)
if period.x <= 0.0 or period.y <= 0.0:
draw_rect(visible_rect, SURFACE_RENDERER.surface_base_color(finish_id, color, wall))
return
var pattern: SURFACE_CACHE = _surface_tiles.get(&"pattern") as SURFACE_CACHE
if pattern == null:
pattern = SURFACE_CACHE.new() as SURFACE_CACHE
pattern.attach_to(self)
pattern.request_pattern(wall, finish_id, color, period)
pattern.updated.connect(queue_redraw)
_surface_tiles[&"pattern"] = pattern
pattern.wanted = true
pattern.last_used_frame = Engine.get_process_frames()
pattern.ensure_queued()
var texture: Texture2D = pattern.get_texture()
if texture == null:
draw_rect(visible_rect, SURFACE_RENDERER.surface_base_color(finish_id, color, wall))
return
texture_repeat = CanvasItem.TEXTURE_REPEAT_ENABLED
texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
# One quad samples a repeating material. Camera motion, panel resizing and
# erase fragments change only these UVs, never the image or its mip levels.
var sample_origin: Vector2 = visible_rect.position + get_surface_draw_origin_world()
sample_origin = Vector2(fposmod(sample_origin.x, period.x), fposmod(sample_origin.y, period.y))
var pixels_per_unit: Vector2 = Vector2(pattern.size) / period
var source := Rect2(sample_origin * pixels_per_unit, visible_rect.size * pixels_per_unit)
draw_texture_rect_region(texture, visible_rect, source, Color.WHITE, false, false)
func _covered_by_surface(local_visible: Rect2 = Rect2()) -> bool:
var visible: Rect2 = local_visible
if visible.size.x <= 0.0 or visible.size.y <= 0.0:
visible = get_local_bounds().intersection(SURFACE_RENDERER._visible_local_rect(self))
if visible.size.x <= 0.0 or visible.size.y <= 0.0:
return true
var corners := PackedVector2Array([visible.position, Vector2(visible.end.x, visible.position.y), visible.end, Vector2(visible.position.x, visible.end.y)])
for region: Dictionary in _get_cover_regions():
var fully_covered: bool = true
var cover_bounds: Rect2 = region["bounds"]
var to_cover: Transform2D = region["transform"]
for corner: Vector2 in corners:
if not cover_bounds.has_point(to_cover * corner):
fully_covered = false
break
if fully_covered:
return true
return false
func _get_cover_regions() -> Array[Dictionary]:
var frame: int = Engine.get_process_frames()
if _cover_regions_frame == frame:
return _cover_regions
_cover_regions_frame = frame
_cover_regions.clear()
var parent: Node = get_parent()
if parent == null or (parent is Node2D and (parent as Node2D).y_sort_enabled):
return _cover_regions
# Transparent ancestors and custom materials can reveal underlying layers.
var ancestor: Node = self
while ancestor is CanvasItem:
if (ancestor as CanvasItem).modulate.a < 0.999 or (ancestor as CanvasItem).material != null:
return _cover_regions
ancestor = ancestor.get_parent()
for sibling: Node in parent.get_children():
if sibling == self or not (sibling is StageRectSurface):
continue
var cover: StageRectSurface = sibling as StageRectSurface
if cover.z_as_relative != z_as_relative:
continue
if not cover.is_visible_in_tree() or cover.interactive_preview or cover.material != null or cover.modulate.a < 0.999 or cover.self_modulate.a < 0.999:
continue
if cover.z_index < z_index or (cover.z_index == z_index and cover.get_index() < get_index()):
continue
if SURFACE_RENDERER.surface_base_color(cover.finish_id, cover.get_render_tint(), SURFACE_RENDERER.is_wall_finish(cover.finish_id)).a < 0.999:
continue
if absf(cover.global_transform.determinant()) < 0.000001:
continue
_cover_regions.append({
"bounds": cover.get_local_bounds().grow(-0.01),
"transform": cover.global_transform.affine_inverse() * global_transform,
})
return _cover_regions
@@ -0,0 +1,929 @@
class_name StageSurfaceCache
extends RefCounted
## Lightweight per-surface handle into a scene-scoped immutable bitmap cache.
const MAX_TEXTURE_EDGE: int = 4096
const MAX_TEXTURE_PIXELS: float = 4194304.0
const MAX_CACHE_BYTES: int = 64 * 1024 * 1024
const MAX_CACHE_ENTRIES: int = 512
const MAX_QUEUED_BUILDS: int = 512
const MAX_QUEUE_SCAN_PER_FRAME: int = 8
const RECENT_USE_PROTECTION_FRAMES: int = 3
const MIN_DETAILED_SCALE: float = 0.5
const PATTERN_CACHE_VERSION: int = 1
const PATTERN_SOURCE_OVERSCAN: float = 4.0
const PATTERN_PIXEL_SCALE: float = 2.0
static var _pool_refs: Dictionary = {}
static var _last_service_frame: int = -1
static var _active_pool_ref: WeakRef
signal updated
class TileEntry extends RefCounted:
var key: String
var canonical_rect: Rect2
var source_scale: Vector2
var wall: bool
var finish: StringName
var tint: Color
var detail_view_size: Vector2
var size: Vector2i
var byte_count: int = 0
var texture: Texture2D
var is_pattern: bool = false
var period: Vector2 = Vector2.ZERO
var watchers: Array[WeakRef] = []
var canvas_owners: Array[WeakRef] = []
var last_used_frame: int = 0
var build_usec: int = 0
var queued: bool = false
var building: bool = false
class SurfaceDrawing extends Node2D:
const RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
var source_rect: Rect2
var wall: bool
var finish: StringName
var tint: Color
var origin: Vector2 = Vector2.ZERO
var detail_view_size: Vector2
var low_detail: bool = false
var active: bool = false
var pool_ref: WeakRef
func _draw() -> void:
var pool: Variant = pool_ref.get_ref() if pool_ref != null else null
if not active or pool == null:
return
active = false
var started_usec: int = Time.get_ticks_usec()
if wall:
RENDERER.draw_wall(self, source_rect, finish, tint, origin, Vector2.ONE, false, low_detail, low_detail, detail_view_size)
else:
RENDERER.draw_background(self, source_rect, finish, tint, origin, Vector2.ONE, false, low_detail, low_detail)
pool._finish_vector_submission(Time.get_ticks_usec() - started_usec)
class BakePool extends Node:
const RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const BAKE_GUTTER_PIXELS: int = 4
var scope_id: int = 0
var entries: Dictionary = {}
var queue: Array[String] = []
var _waiting_handles: Array[WeakRef] = []
var resident_bytes: int = 0
var builds: int = 0
var cache_hits: int = 0
var evictions: int = 0
var refusals: int = 0
var readbacks: int = 0
var pattern_builds: int = 0
var pattern_readbacks: int = 0
var pattern_resident_bytes: int = 0
var pattern_retired_bytes: int = 0
var last_build_usec: int = 0
var vector_submission_usec: int = 0
var retired_bytes: int = 0
var budget_blocked_requests: int = 0
var budget_pressure: bool = false
var budget_pressure_frame: int = -1
var _viewport: SubViewport
var _drawing: SurfaceDrawing
var _building_entry: TileEntry
var _building_started_usec: int = 0
var _reserved_bytes: int = 0
var _retired_textures: Array[Texture2D] = []
var _retire_callback_pending: bool = false
var _capture_pending: bool = false
var _disposed: bool = false
func _ready() -> void:
set_process(true)
_ensure_baker()
func _process(_delta: float) -> void:
_prune_queue()
StageSurfaceCache._service_pools()
func _ensure_baker() -> void:
if is_instance_valid(_viewport):
return
_viewport = SubViewport.new()
_viewport.name = "SurfaceBitmapBaker"
_viewport.disable_3d = true
_viewport.transparent_bg = true
_viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ALWAYS
_viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
_viewport.world_2d = World2D.new()
add_child(_viewport)
_drawing = SurfaceDrawing.new()
_drawing.name = "SurfaceVectorSource"
_drawing.pool_ref = weakref(self)
_viewport.add_child(_drawing)
func queue_handle(handle: StageSurfaceCache) -> void:
if _disposed or handle._disposed:
return
var entry: TileEntry = _find_or_create_entry(handle)
if entry == null:
refusals += 1
_add_waiter(handle)
return
_bind_handle(handle, entry)
if not _handle_is_interested(handle):
return
handle.last_used_frame = Engine.get_process_frames()
entry.last_used_frame = Engine.get_process_frames()
if entry.texture != null or entry.queued or entry.building:
return
if queue.size() >= MAX_QUEUED_BUILDS:
refusals += 1
_add_waiter(handle)
return
entry.queued = true
queue.append(entry.key)
handle._enqueued = true
func _find_or_create_entry(handle: StageSurfaceCache) -> TileEntry:
var existing: TileEntry = entries.get(handle._cache_key) as TileEntry
if existing != null:
cache_hits += 1
return existing
while entries.size() + _retired_textures.size() >= MAX_CACHE_ENTRIES:
if not _retired_textures.is_empty():
return null
if not _evict_one(null):
return null
if not _retired_textures.is_empty():
return null
var arguments: Array = handle._pending_arguments
if arguments.size() < 6:
return null
var entry := TileEntry.new()
entry.key = handle._cache_key
entry.canonical_rect = arguments[0] as Rect2
entry.source_scale = arguments[1] as Vector2
entry.wall = bool(arguments[2])
entry.finish = arguments[3] as StringName
entry.tint = arguments[4] as Color
entry.detail_view_size = arguments[5] as Vector2
if arguments.size() >= 8:
entry.is_pattern = bool(arguments[6])
entry.period = arguments[7] as Vector2
entries[entry.key] = entry
return entry
func _bind_handle(handle: StageSurfaceCache, entry: TileEntry) -> void:
_register_canvas_owner(entry, handle)
var current: TileEntry = handle._entry_ref.get_ref() as TileEntry if handle._entry_ref != null else null
if current == entry:
handle.ready_for_display = entry.texture != null
handle._building = entry.building
handle._enqueued = entry.queued
handle.build_usec = entry.build_usec
return
if current != null:
_unwatch_handle(current, handle)
handle._entry_ref = weakref(entry)
handle.ready_for_display = entry.texture != null
handle._building = entry.building
handle._enqueued = entry.queued
handle.build_usec = entry.build_usec
var found: bool = false
for watcher_ref: WeakRef in entry.watchers:
if watcher_ref.get_ref() == handle:
found = true
break
if not found:
entry.watchers.append(weakref(handle))
func _unwatch_handle(entry: TileEntry, handle: StageSurfaceCache) -> void:
for index: int in range(entry.watchers.size() - 1, -1, -1):
var watcher: Variant = entry.watchers[index].get_ref()
if watcher == null or watcher == handle:
entry.watchers.remove_at(index)
func _register_canvas_owner(entry: TileEntry, handle: StageSurfaceCache) -> void:
var owner: Variant = handle._owner_ref.get_ref() if handle._owner_ref != null else null
if owner == null or not is_instance_valid(owner):
return
for index: int in range(entry.canvas_owners.size() - 1, -1, -1):
var registered_owner: Variant = entry.canvas_owners[index].get_ref()
if registered_owner == null or not is_instance_valid(registered_owner):
entry.canvas_owners.remove_at(index)
elif registered_owner == owner:
return
entry.canvas_owners.append(weakref(owner))
func _start_next_build() -> bool:
if _disposed or _capture_pending:
return false
_prune_queue()
var attempts: int = mini(queue.size(), MAX_QUEUE_SCAN_PER_FRAME)
while attempts > 0 and not queue.is_empty():
attempts -= 1
var key: String = _pop_next_build_key()
var entry: TileEntry = entries.get(key) as TileEntry
if entry == null or not entry.queued or entry.texture != null:
continue
entry.queued = false
if not _entry_has_live_watchers(entry):
_evict_entry(entry)
continue
var size: Vector2i = _baker_size_for(entry)
var required_bytes: int = _estimated_texture_bytes(entry)
if not _make_room(required_bytes, entry):
refusals += 1
if queue.size() < MAX_QUEUED_BUILDS:
entry.queued = true
queue.append(entry.key)
budget_blocked_requests += 1
budget_pressure = true
budget_pressure_frame = Engine.get_process_frames()
continue
_begin_build(entry, size)
return true
return false
func _baker_rect_for(entry: TileEntry) -> Rect2:
return entry.canonical_rect.grow(PATTERN_SOURCE_OVERSCAN) if entry.is_pattern else entry.canonical_rect
func _baker_size_for(entry: TileEntry) -> Vector2i:
var draw_rect: Rect2 = _baker_rect_for(entry)
return StageSurfaceCache.texture_size_for(draw_rect.size, entry.source_scale)
func _pattern_output_size(entry: TileEntry) -> Vector2i:
var draw_rect: Rect2 = _baker_rect_for(entry)
var baker_size: Vector2i = StageSurfaceCache.texture_size_for(draw_rect.size, entry.source_scale)
return Vector2i(
maxi(1, roundi(float(baker_size.x) * entry.period.x / draw_rect.size.x)),
maxi(1, roundi(float(baker_size.y) * entry.period.y / draw_rect.size.y))
)
func _estimated_texture_bytes(entry: TileEntry) -> int:
var base_size: Vector2i = _pattern_output_size(entry) if entry.is_pattern else _baker_size_for(entry)
return _mip_chain_byte_count(base_size) if entry.is_pattern else base_size.x * base_size.y * 4
func _crop_pattern_image(image: Image, entry: TileEntry) -> Image:
var draw_rect: Rect2 = _baker_rect_for(entry)
var output_size: Vector2i = _pattern_output_size(entry)
var scale: Vector2 = Vector2(image.get_size()) / draw_rect.size
var crop_position := Vector2i(
roundi((entry.canonical_rect.position.x - draw_rect.position.x) * scale.x),
roundi((entry.canonical_rect.position.y - draw_rect.position.y) * scale.y)
)
crop_position.x = clampi(crop_position.x, 0, maxi(0, image.get_width() - output_size.x))
crop_position.y = clampi(crop_position.y, 0, maxi(0, image.get_height() - output_size.y))
var safe_size := Vector2i(
mini(output_size.x, image.get_width() - crop_position.x),
mini(output_size.y, image.get_height() - crop_position.y)
)
if safe_size.x <= 0 or safe_size.y <= 0:
return Image.new()
return image.get_region(Rect2i(crop_position, safe_size))
func _mip_chain_byte_count(size: Vector2i) -> int:
var width: int = maxi(1, size.x)
var height: int = maxi(1, size.y)
var total: int = 0
while true:
total += width * height * 4
if width == 1 and height == 1:
break
width = maxi(1, floori(float(width) * 0.5))
height = maxi(1, floori(float(height) * 0.5))
return total
func _pop_next_build_key() -> String:
var selected_index: int = 0
var selected_scale: float = INF
for index: int in range(queue.size()):
var entry: TileEntry = entries.get(queue[index]) as TileEntry
if entry == null:
continue
var scale_rank: float = maxf(entry.source_scale.x, entry.source_scale.y)
if scale_rank < selected_scale:
selected_scale = scale_rank
selected_index = index
return queue.pop_at(selected_index)
func _begin_build(entry: TileEntry, texture_size: Vector2i) -> void:
_ensure_baker()
RenderingServer.canvas_item_clear(_drawing.get_canvas_item())
_viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
_viewport.size = texture_size
var draw_rect: Rect2 = _baker_rect_for(entry)
_drawing.source_rect = draw_rect
_drawing.wall = entry.wall
_drawing.finish = entry.finish
_drawing.tint = entry.tint
_drawing.origin = Vector2.ZERO
_drawing.detail_view_size = entry.period if entry.is_pattern else entry.detail_view_size
_drawing.low_detail = not entry.is_pattern and minf(entry.source_scale.x, entry.source_scale.y) < MIN_DETAILED_SCALE
var draw_scale := Vector2(texture_size) / draw_rect.size
_drawing.scale = draw_scale
_drawing.position = -draw_rect.position * draw_scale
_drawing.active = true
_building_entry = entry
_building_started_usec = Time.get_ticks_usec()
_reserved_bytes = _estimated_texture_bytes(entry)
entry.building = true
_capture_pending = true
_drawing.queue_redraw()
_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
if not RenderingServer.frame_post_draw.is_connected(_on_frame_post_draw):
RenderingServer.frame_post_draw.connect(_on_frame_post_draw, CONNECT_ONE_SHOT)
StageSurfaceCache._active_pool_ref = weakref(self)
func _finish_vector_submission(elapsed_usec: int) -> void:
vector_submission_usec = elapsed_usec
func _on_frame_post_draw() -> void:
if not _capture_pending or _building_entry == null:
return
var entry: TileEntry = _building_entry
var image: Image
if _entry_has_live_watchers(entry) and is_instance_valid(_viewport):
image = _viewport.get_texture().get_image()
if image != null and not image.is_empty():
if entry.is_pattern:
image = _crop_pattern_image(image, entry)
if image != null and not image.is_empty():
image.generate_mipmaps()
if image != null and not image.is_empty():
var texture := ImageTexture.create_from_image(image)
entry.texture = texture
entry.size = image.get_size()
entry.byte_count = image.get_data_size()
resident_bytes += entry.byte_count
if entry.is_pattern:
pattern_builds += 1
pattern_readbacks += 1
pattern_resident_bytes += entry.byte_count
_reserved_bytes = 0
builds += 1
readbacks += 1
budget_pressure = false
else:
_reserved_bytes = 0
refusals += 1
_notify_entry_failed(entry)
last_build_usec = Time.get_ticks_usec() - _building_started_usec
if entry.texture != null:
entry.build_usec = last_build_usec
_notify_entry_ready(entry)
entry.building = false
_building_entry = null
_capture_pending = false
if is_instance_valid(_viewport):
_viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
if is_instance_valid(_drawing):
_drawing.active = false
RenderingServer.canvas_item_clear(_drawing.get_canvas_item())
if StageSurfaceCache._active_pool_ref != null and StageSurfaceCache._active_pool_ref.get_ref() == self:
StageSurfaceCache._active_pool_ref = null
_wake_waiters()
StageSurfaceCache._service_pools()
func _notify_entry_ready(entry: TileEntry) -> void:
for index: int in range(entry.watchers.size() - 1, -1, -1):
var handle: Variant = entry.watchers[index].get_ref()
if handle == null:
entry.watchers.remove_at(index)
continue
handle._on_entry_ready(entry)
func _notify_entry_failed(entry: TileEntry) -> void:
for index: int in range(entry.watchers.size() - 1, -1, -1):
var handle: Variant = entry.watchers[index].get_ref()
if handle == null:
entry.watchers.remove_at(index)
continue
handle._on_entry_failed(entry)
func _entry_has_live_watchers(entry: TileEntry) -> bool:
var has_watcher: bool = false
for index: int in range(entry.watchers.size() - 1, -1, -1):
var handle: Variant = entry.watchers[index].get_ref()
if handle == null or handle._disposed:
entry.watchers.remove_at(index)
continue
if _handle_is_interested(handle):
has_watcher = true
return has_watcher
func _make_room(required_bytes: int, protected_entry: TileEntry) -> bool:
if resident_bytes + retired_bytes + _reserved_bytes + required_bytes <= MAX_CACHE_BYTES:
return true
if retired_bytes > 0:
return false
if not _evict_one(protected_entry):
return false
# Keep the old texture alive until the frame that releases its CanvasItem RIDs.
# The request will retry after the post-draw retirement callback.
return resident_bytes + retired_bytes + _reserved_bytes + required_bytes <= MAX_CACHE_BYTES
func _evict_one(protected_entry: TileEntry) -> bool:
var candidate: TileEntry
for value: Variant in entries.values():
var entry: TileEntry = value as TileEntry
if entry == null or entry == protected_entry or entry.building:
continue
if _entry_is_protected(entry):
continue
if candidate == null or entry.last_used_frame < candidate.last_used_frame:
candidate = entry
if candidate == null:
return false
_evict_entry(candidate)
return true
func _entry_is_protected(entry: TileEntry) -> bool:
if entry.texture == null:
return false
var current_frame: int = Engine.get_process_frames()
for index: int in range(entry.watchers.size() - 1, -1, -1):
var handle: Variant = entry.watchers[index].get_ref()
if handle == null or handle._disposed:
entry.watchers.remove_at(index)
continue
if _handle_is_visible(handle) and (handle.wanted or current_frame - handle.last_used_frame <= RECENT_USE_PROTECTION_FRAMES):
return true
return false
func _evict_entry(entry: TileEntry) -> void:
if entry == null or entry.building:
return
if entry.queued:
queue.erase(entry.key)
entry.queued = false
_clear_entry_canvas_owners(entry)
for index: int in range(entry.watchers.size() - 1, -1, -1):
var handle: Variant = entry.watchers[index].get_ref()
if handle == null:
entry.watchers.remove_at(index)
continue
handle._on_entry_evicted(entry)
var retired_texture: Texture2D = entry.texture
var retired_size_bytes: int = entry.byte_count
entry.texture = null
resident_bytes = maxi(0, resident_bytes - entry.byte_count)
if entry.is_pattern:
pattern_resident_bytes = maxi(0, pattern_resident_bytes - entry.byte_count)
entry.byte_count = 0
if retired_texture != null:
_retired_textures.append(retired_texture)
retired_bytes += retired_size_bytes
if entry.is_pattern:
pattern_retired_bytes += retired_size_bytes
if not _retire_callback_pending:
_retire_callback_pending = true
RenderingServer.frame_post_draw.connect(_on_retired_frame_post_draw, CONNECT_ONE_SHOT)
if entries.get(entry.key) == entry:
entries.erase(entry.key)
evictions += 1
func _clear_owner_canvas(owner: Variant) -> void:
if not (owner is CanvasItem) or not is_instance_valid(owner):
return
var canvas_item: CanvasItem = owner as CanvasItem
RenderingServer.canvas_item_clear(canvas_item.get_canvas_item())
canvas_item.queue_redraw()
func _clear_entry_canvas_owners(entry: TileEntry) -> void:
var cleared_owners: Dictionary = {}
for owner_ref: WeakRef in entry.canvas_owners:
var owner: Variant = owner_ref.get_ref()
if owner == null or not is_instance_valid(owner):
continue
var owner_id: int = owner.get_instance_id()
if cleared_owners.has(owner_id):
continue
_clear_owner_canvas(owner)
cleared_owners[owner_id] = true
entry.canvas_owners.clear()
func _stats() -> Dictionary:
var queued_count: int = 0
for value: Variant in entries.values():
var entry: TileEntry = value as TileEntry
if entry != null and entry.queued:
queued_count += 1
return {
"bytes": resident_bytes,
"resident_bytes": resident_bytes,
"retired_bytes": retired_bytes,
"entries": entries.size(),
"queued": queued_count,
"bakers": 1 if is_instance_valid(_viewport) else 0,
"live_vector_bakers": 1 if _capture_pending else 0,
"live_vector_items": 1 if _capture_pending else 0,
"builds": builds,
"cache_hits": cache_hits,
"evictions": evictions,
"refusals": refusals,
"budget_pressure": budget_pressure,
"budget_pressure_frame": budget_pressure_frame,
"budget_blocked_requests": budget_blocked_requests,
"waiting_handles": _waiting_handles.size(),
"readbacks": readbacks,
"pattern_builds": pattern_builds,
"pattern_readbacks": pattern_readbacks,
"pattern_resident_bytes": pattern_resident_bytes,
"pattern_retired_bytes": pattern_retired_bytes,
"last_build_usec": last_build_usec,
"vector_submission_usec": vector_submission_usec,
"active_build": _building_entry.key if _building_entry != null else ""
}
func _exit_tree() -> void:
_disposed = true
if RenderingServer.frame_post_draw.is_connected(_on_frame_post_draw):
RenderingServer.frame_post_draw.disconnect(_on_frame_post_draw)
if _capture_pending and _building_entry != null:
_building_entry.building = false
_notify_entry_failed(_building_entry)
_building_entry = null
_capture_pending = false
if StageSurfaceCache._active_pool_ref != null and StageSurfaceCache._active_pool_ref.get_ref() == self:
StageSurfaceCache._active_pool_ref = null
for value: Variant in entries.values():
var entry: TileEntry = value as TileEntry
if entry == null:
continue
_clear_entry_canvas_owners(entry)
for watcher_ref: WeakRef in entry.watchers:
var handle: Variant = watcher_ref.get_ref()
if handle != null:
handle._on_entry_evicted(entry)
entry.texture = null
entries.clear()
queue.clear()
_waiting_handles.clear()
_retired_textures.clear()
resident_bytes = 0
retired_bytes = 0
pattern_resident_bytes = 0
pattern_retired_bytes = 0
if is_instance_valid(_viewport):
_viewport.queue_free()
_viewport = null
_drawing = null
StageSurfaceCache._forget_pool(scope_id, self)
func _prune_queue() -> void:
if queue.is_empty():
return
var kept: Array[String] = []
for key: String in queue:
var entry: TileEntry = entries.get(key) as TileEntry
if entry == null or not entry.queued or entry.texture != null or not _entry_has_live_watchers(entry):
if entry != null:
entry.queued = false
_set_entry_handle_queue_state(entry, false)
if entry.texture == null and not entry.building and not _entry_has_live_watchers(entry):
_evict_entry(entry)
continue
kept.append(key)
queue = kept
if queue.size() < MAX_QUEUED_BUILDS and not _waiting_handles.is_empty():
_wake_waiters()
func _entry_has_any_handles(entry: TileEntry) -> bool:
for index: int in range(entry.watchers.size() - 1, -1, -1):
var handle: Variant = entry.watchers[index].get_ref()
if handle == null or handle._disposed:
entry.watchers.remove_at(index)
else:
return true
return false
func _set_entry_handle_queue_state(entry: TileEntry, is_queued: bool) -> void:
for watcher_ref: WeakRef in entry.watchers:
var handle: Variant = watcher_ref.get_ref()
if handle != null:
handle._enqueued = is_queued
func _handle_is_interested(handle: StageSurfaceCache) -> bool:
return handle != null and not handle._disposed and handle.wanted and _handle_is_visible(handle)
func _handle_is_visible(handle: StageSurfaceCache) -> bool:
if handle == null or handle._disposed:
return false
var owner: Variant = handle._owner_ref.get_ref() if handle._owner_ref != null else null
if owner == null or not is_instance_valid(owner) or not (owner is Node2D):
return false
if not owner.is_inside_tree() or not owner.is_visible_in_tree():
return false
var visible_rect: Rect2 = RENDERER._visible_local_rect(owner)
if handle.is_pattern:
if not owner.has_method("get_local_bounds"):
return false
var local_bounds: Variant = owner.call("get_local_bounds")
if not (local_bounds is Rect2):
return false
var bounds: Rect2 = local_bounds
return bounds.intersects(visible_rect)
return handle.source_rect.intersects(visible_rect) or handle.core_rect.intersects(visible_rect)
func _add_waiter(handle: StageSurfaceCache) -> void:
for waiter_ref: WeakRef in _waiting_handles:
if waiter_ref.get_ref() == handle:
return
_waiting_handles.append(weakref(handle))
func _wake_waiters() -> void:
if _waiting_handles.is_empty() or _disposed:
return
var pending: Array[WeakRef] = _waiting_handles
_waiting_handles = []
for waiter_ref: WeakRef in pending:
var handle: Variant = waiter_ref.get_ref()
if handle == null or handle._disposed or not _handle_is_interested(handle):
continue
queue_handle(handle)
func _on_retired_frame_post_draw() -> void:
_retired_textures.clear()
retired_bytes = 0
pattern_retired_bytes = 0
_retire_callback_pending = false
_wake_waiters()
StageSurfaceCache._service_pools()
var source_rect: Rect2
var source_scale: Vector2
var core_rect: Rect2
var ready_for_display: bool = false
var wanted: bool = false
var last_used_frame: int = 0
var build_usec: int = 0
var _enqueued: bool = false
var _building: bool = false
var _pending_arguments: Array = []
var _owner_ref: WeakRef
var _pool_ref: WeakRef
var _entry_ref: WeakRef
var _cache_key: String = ""
var _disposed: bool = false
var _finish: StringName
var _tint: Color
var _origin: Vector2
var is_pattern: bool = false
var size: Vector2i:
get:
var entry: TileEntry = _entry_ref.get_ref() as TileEntry if _entry_ref != null else null
return entry.size if entry != null else Vector2i.ZERO
var render_target_update_mode: int:
get:
return SubViewport.UPDATE_DISABLED
func attach_to(owner_node: Node) -> void:
_owner_ref = weakref(owner_node)
var pool: BakePool = _get_or_create_pool(owner_node)
_pool_ref = weakref(pool) if pool != null else null
func dispose() -> void:
wanted = false
_disposed = true
var entry: TileEntry = _entry_ref.get_ref() as TileEntry if _entry_ref != null else null
var pool: BakePool = _pool_ref.get_ref() as BakePool if _pool_ref != null else null
if entry != null and pool != null:
pool._unwatch_handle(entry, self)
_entry_ref = null
ready_for_display = false
_building = false
_enqueued = false
func get_texture() -> Texture2D:
var entry: TileEntry = _entry_ref.get_ref() as TileEntry if _entry_ref != null else null
return entry.texture if entry != null else null
func request_build(core: Rect2, rect: Rect2, pixel_scale: Vector2, wall: bool, finish: StringName, tint: Color, origin: Vector2, detail_view_size: Vector2) -> void:
if _disposed:
return
is_pattern = false
core_rect = core
source_rect = rect
source_scale = pixel_scale
wanted = true
_finish = finish
_tint = tint
_origin = origin
var canonical_position: Vector2 = _snap_canonical_position(rect.position + origin, pixel_scale)
var canonical_rect := Rect2(canonical_position, rect.size)
_pending_arguments = [canonical_rect, pixel_scale, wall, finish, tint, detail_view_size]
_cache_key = _make_cache_key(canonical_rect, pixel_scale, wall, finish, tint, detail_view_size)
queue_if_needed()
func request_pattern(wall: bool, finish: StringName, tint: Color, period: Vector2) -> void:
if _disposed:
return
var safe_period := Vector2(maxf(1.0, period.x), maxf(1.0, period.y))
is_pattern = true
core_rect = Rect2(Vector2.ZERO, safe_period)
source_rect = core_rect
source_scale = Vector2.ONE * PATTERN_PIXEL_SCALE
wanted = true
_finish = finish
_tint = tint
_origin = Vector2.ZERO
_pending_arguments = [core_rect, source_scale, wall, finish, tint, safe_period, true, safe_period]
_cache_key = _make_pattern_cache_key(wall, finish, tint, safe_period)
queue_if_needed()
func ensure_queued() -> void:
if _disposed:
return
queue_if_needed()
func queue_if_needed() -> void:
var pool: BakePool = _pool_ref.get_ref() as BakePool if _pool_ref != null else null
if pool == null:
var owner: Variant = _owner_ref.get_ref() if _owner_ref != null else null
if owner != null and is_instance_valid(owner):
pool = _get_or_create_pool(owner)
_pool_ref = weakref(pool) if pool != null else null
if pool != null:
pool.queue_handle(self)
func _on_entry_ready(entry: TileEntry) -> void:
if _disposed:
return
ready_for_display = true
_building = false
_enqueued = false
build_usec = entry.build_usec
updated.emit()
func _on_entry_failed(entry: TileEntry) -> void:
if _disposed:
return
if _entry_ref != null and _entry_ref.get_ref() == entry:
ready_for_display = false
_building = false
_enqueued = false
updated.emit()
func _on_entry_evicted(entry: TileEntry) -> void:
if _entry_ref != null and _entry_ref.get_ref() == entry:
_entry_ref = null
ready_for_display = false
_building = false
_enqueued = false
updated.emit()
static func build_next() -> void:
_service_pools()
static func _service_pools() -> void:
var frame: int = Engine.get_process_frames()
if frame == _last_service_frame:
return
_last_service_frame = frame
var active_pool: Variant = _active_pool_ref.get_ref() if _active_pool_ref != null else null
if active_pool != null and is_instance_valid(active_pool):
if not active_pool._capture_pending:
active_pool._start_next_build()
return
_active_pool_ref = null
for scope_id: Variant in _pool_refs.keys():
var pool: Variant = _pool_refs[scope_id].get_ref()
if pool == null or not is_instance_valid(pool):
_pool_refs.erase(scope_id)
continue
if pool._start_next_build():
return
static func _get_or_create_pool(owner_node: Node) -> BakePool:
if owner_node == null or not is_instance_valid(owner_node) or owner_node.get_tree() == null:
return null
var scope: Node = _get_scope_node(owner_node)
if scope == null or not is_instance_valid(scope):
return null
var scope_id: int = scope.get_instance_id()
var pool_ref: WeakRef = _pool_refs.get(scope_id) as WeakRef
var existing: BakePool = pool_ref.get_ref() as BakePool if pool_ref != null else null
if existing != null and is_instance_valid(existing):
return existing
var pool := BakePool.new()
pool.name = "StageSurfaceBakePool"
pool.scope_id = scope_id
scope.add_child(pool)
_pool_refs[scope_id] = weakref(pool)
return pool
static func _get_scope_node(owner_node: Node) -> Node:
var tree: SceneTree = owner_node.get_tree()
if tree == null:
return null
var current_scene: Node = tree.current_scene
if current_scene != null and (current_scene == owner_node or current_scene.is_ancestor_of(owner_node)):
return current_scene
var root: Window = tree.root
var scope: Node = owner_node
while scope.get_parent() != null and scope.get_parent() != root:
scope = scope.get_parent()
return scope
static func _forget_pool(scope_id: int, pool: BakePool) -> void:
var pool_ref: WeakRef = _pool_refs.get(scope_id) as WeakRef
if pool_ref != null and pool_ref.get_ref() == pool:
_pool_refs.erase(scope_id)
static func _make_cache_key(rect: Rect2, pixel_scale: Vector2, wall: bool, finish: StringName, tint: Color, detail_view_size: Vector2) -> String:
return var_to_str([wall, finish, tint, rect.position.x, rect.position.y, rect.size.x, rect.size.y, pixel_scale.x, pixel_scale.y, detail_view_size.x, detail_view_size.y])
static func _make_pattern_cache_key(wall: bool, finish: StringName, tint: Color, period: Vector2) -> String:
return var_to_str([&"periodic_style", PATTERN_CACHE_VERSION, wall, finish, tint, period.x, period.y, PATTERN_PIXEL_SCALE, &"mipmaps"])
static func _snap_canonical_position(position: Vector2, pixel_scale: Vector2) -> Vector2:
var scale_x: float = maxf(0.001, pixel_scale.x)
var scale_y: float = maxf(0.001, pixel_scale.y)
return Vector2(roundf(position.x * scale_x) / scale_x, roundf(position.y * scale_y) / scale_y)
static func get_stats(owner_node: Node = null) -> Dictionary:
if owner_node != null and is_instance_valid(owner_node):
var scope: Node = _get_scope_node(owner_node)
if scope != null:
var pool_ref: WeakRef = _pool_refs.get(scope.get_instance_id()) as WeakRef
var pool: BakePool = pool_ref.get_ref() as BakePool if pool_ref != null else null
return pool._stats() if pool != null else _empty_stats()
var totals: Dictionary = _empty_stats()
for scope_id: Variant in _pool_refs.keys():
var pool: BakePool = _pool_refs[scope_id].get_ref() as BakePool
if pool == null or not is_instance_valid(pool):
continue
var stats: Dictionary = pool._stats()
for key: String in totals.keys():
if key == "active_build":
if not str(stats[key]).is_empty():
totals[key] = stats[key]
elif key == "last_build_usec" or key == "vector_submission_usec" or key == "budget_pressure_frame":
totals[key] = maxi(int(totals[key]), int(stats[key]))
else:
totals[key] = int(totals[key]) + int(stats[key])
return totals
static func _empty_stats() -> Dictionary:
return {
"bytes": 0,
"resident_bytes": 0,
"retired_bytes": 0,
"entries": 0,
"queued": 0,
"bakers": 0,
"live_vector_bakers": 0,
"live_vector_items": 0,
"builds": 0,
"cache_hits": 0,
"evictions": 0,
"refusals": 0,
"budget_pressure": 0,
"budget_pressure_frame": -1,
"budget_blocked_requests": 0,
"waiting_handles": 0,
"readbacks": 0,
"pattern_builds": 0,
"pattern_readbacks": 0,
"pattern_resident_bytes": 0,
"pattern_retired_bytes": 0,
"last_build_usec": 0,
"vector_submission_usec": 0,
"active_build": ""
}
static func texture_size_for(local_size: Vector2, pixel_scale: Vector2) -> Vector2i:
var safe_size := Vector2(maxf(1.0, local_size.x), maxf(1.0, local_size.y))
var safe_scale := Vector2(maxf(0.001, pixel_scale.x), maxf(0.001, pixel_scale.y))
var pixels: Vector2 = safe_size * safe_scale
var reduction: float = maxf(1.0, maxf(pixels.x, pixels.y) / float(MAX_TEXTURE_EDGE))
reduction = maxf(reduction, sqrt(pixels.x * pixels.y / MAX_TEXTURE_PIXELS))
return Vector2i(maxi(1, floori(pixels.x / reduction)), maxi(1, floori(pixels.y / reduction)))
@@ -0,0 +1 @@
uid://cwcf7h5yf3xi0
+54 -1
View File
@@ -7,6 +7,8 @@ const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_ren
var surface_kind: StringName = &"wall"
var finish_id: StringName = &"finish_wall_paint"
var tint: Color = Color("#5A9D70")
var corner_radius: float = 0.0
var outside_color: Color = Color("#202633FA")
func configure(kind: StringName, finish: StringName, color: Color) -> void:
@@ -17,6 +19,12 @@ func configure(kind: StringName, finish: StringName, color: Color) -> void:
queue_redraw()
func set_rounded_frame(radius: float, background: Color) -> void:
corner_radius = maxf(0.0, radius)
outside_color = background
queue_redraw()
func _draw() -> void:
var rect: Rect2 = Rect2(Vector2(3.0, 3.0), size - Vector2(6.0, 6.0))
if rect.size.x <= 0.0 or rect.size.y <= 0.0:
@@ -25,4 +33,49 @@ func _draw() -> void:
SURFACE_RENDERER.draw_background(self, rect, finish_id, tint, Vector2.ZERO, Vector2.ONE, false)
else:
SURFACE_RENDERER.draw_wall(self, rect, finish_id, tint, Vector2.ZERO, Vector2.ONE, false)
draw_rect(rect, Color("#F5F7FA"), false, 2.0)
var radius: float = minf(corner_radius, minf(rect.size.x, rect.size.y) * 0.5)
if radius > 0.0:
var wall: bool = surface_kind != &"background_ground"
var base_color: Color = SURFACE_RENDERER.surface_base_color(finish_id, tint, wall)
_draw_rounded_corner(rect, radius, base_color, outside_color, 0)
_draw_rounded_corner(rect, radius, base_color, outside_color, 1)
_draw_rounded_corner(rect, radius, base_color, outside_color, 2)
_draw_rounded_corner(rect, radius, base_color, outside_color, 3)
var outline := StyleBoxFlat.new()
outline.bg_color = Color.TRANSPARENT
outline.draw_center = false
outline.border_color = Color("#F5F7FA")
outline.set_border_width_all(2)
outline.set_corner_radius_all(roundi(radius))
draw_style_box(outline, rect)
else:
draw_rect(rect, Color("#F5F7FA"), false, 2.0)
func _draw_rounded_corner(rect: Rect2, radius: float, base_color: Color, background: Color, corner: int) -> void:
var square := Rect2()
var center := Vector2.ZERO
var start_angle: float = 0.0
match corner:
0:
square = Rect2(rect.position, Vector2.ONE * radius)
center = rect.position + Vector2.ONE * radius
start_angle = PI
1:
square = Rect2(Vector2(rect.end.x - radius, rect.position.y), Vector2.ONE * radius)
center = Vector2(rect.end.x - radius, rect.position.y + radius)
start_angle = -PI * 0.5
2:
square = Rect2(rect.end - Vector2.ONE * radius, Vector2.ONE * radius)
center = rect.end - Vector2.ONE * radius
start_angle = 0.0
_:
square = Rect2(Vector2(rect.position.x, rect.end.y - radius), Vector2.ONE * radius)
center = Vector2(rect.position.x + radius, rect.end.y - radius)
start_angle = PI * 0.5
draw_rect(square, background, true)
var arc := PackedVector2Array([center])
for point_index: int in 13:
var angle: float = start_angle + PI * 0.5 * float(point_index) / 12.0
arc.append(center + Vector2(cos(angle), sin(angle)) * radius)
draw_colored_polygon(arc, base_color)
+173 -68
View File
@@ -13,12 +13,37 @@ const WALL_BRICK_WIDTH: float = 100.0
const WALL_BRICK_HEIGHT: float = 38.0
const MAX_WALL_BRICK_CELLS: int = 1200
const MAX_BACKGROUND_CELLS: int = 12000
## Finite material variations repeat independently of wall size and camera travel.
## Even row periods preserve staggered brick/plank courses across the seam.
const BRICK_PATTERN_CELLS := Vector2i(8, 8)
const WOOD_PATTERN_CELLS := Vector2i(3, 6)
const GRASS_PATTERN_CELLS := Vector2i(16, 16)
const CONCRETE_PATTERN_CELLS := Vector2i(4, 4)
const CARPET_PATTERN_CELLS := Vector2i(16, 16)
## Conservative work estimate to avoid an expensive first placement redraw.
## Runtime timing in StageRectSurface also catches slower machines.
const PREVIEW_WORK_BUDGET: float = 6000.0
const MAX_PREVIEW_TEXTURE_CELLS: int = 128
static func is_wall_finish(id: StringName) -> bool:
return WALL_FINISHES.has(id)
static func pattern_size_for(finish: StringName) -> Vector2:
match finish:
&"finish_wall_brick": return Vector2(BRICK_PATTERN_CELLS) * Vector2(WALL_BRICK_WIDTH, WALL_BRICK_HEIGHT)
&"finish_wall_wood": return Vector2(WOOD_PATTERN_CELLS) * Vector2(320.0, 64.0)
&"finish_background_grass": return Vector2(GRASS_PATTERN_CELLS) * Vector2(44.0, 36.0)
&"finish_background_concrete": return Vector2(CONCRETE_PATTERN_CELLS) * Vector2(280.0, 190.0)
&"finish_background_carpet": return Vector2(CARPET_PATTERN_CELLS) * Vector2(34.0, 26.0)
_: return Vector2.ZERO
static func _pattern_seed(row: int, column: int, cells: Vector2i, row_factor: int, column_factor: int, offset: int = 0) -> int:
return posmod(row, cells.y) * row_factor + posmod(column, cells.x) * column_factor + offset
static func is_background_finish(id: StringName) -> bool:
return BACKGROUND_FINISHES.has(id)
@@ -38,6 +63,28 @@ static func is_ground_finish(id: StringName) -> bool:
return GROUND_FINISHES.has(id) or id == &"finish_ground_legacy"
static func surface_base_color(finish: StringName, tint: Color, wall: bool) -> Color:
if finish == &"finish_ground_legacy":
return Color("#468B42")
if finish.ends_with("brick"):
return Color("#D2CBAF") if wall else Color("#994D35")
if finish.ends_with("wood"):
return Color("#9A673C") if wall else Color("#B88951")
if finish == &"finish_ground_grass" or finish == &"finish_background_grass":
return Color("#72A84C")
if finish == &"finish_background_concrete":
return Color("#A6AAA3")
if finish == &"finish_background_carpet":
return tint
if finish == &"finish_ground_dirt":
return Color("#8E633E")
if finish == &"finish_ground_sidewalk":
return Color("#B7B9B2")
if finish == &"finish_ground_asphalt":
return Color("#505A62")
return tint
static func draw_wall(
canvas: CanvasItem,
rect: Rect2,
@@ -45,9 +92,12 @@ static func draw_wall(
tint: Color,
world_origin: Vector2,
drawing_scale: Vector2 = Vector2.ONE,
cull_to_viewport: bool = true
cull_to_viewport: bool = true,
interactive_preview: bool = false,
force_simple_preview: bool = false,
detail_view_size: Vector2 = Vector2.ZERO
) -> void:
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, true, false, cull_to_viewport)
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, true, false, cull_to_viewport, interactive_preview, force_simple_preview, detail_view_size)
static func draw_ground(
@@ -68,9 +118,11 @@ static func draw_background(
tint: Color,
world_origin: Vector2,
drawing_scale: Vector2 = Vector2.ONE,
cull_to_viewport: bool = true
cull_to_viewport: bool = true,
interactive_preview: bool = false,
force_simple_preview: bool = false
) -> void:
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, false, true, cull_to_viewport)
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, false, true, cull_to_viewport, interactive_preview, force_simple_preview)
static func _draw_surface(
@@ -82,66 +134,47 @@ static func _draw_surface(
drawing_scale: Vector2,
wall: bool,
background: bool,
cull_to_viewport: bool
cull_to_viewport: bool,
interactive_preview: bool = false,
force_simple_preview: bool = false,
detail_view_size: Vector2 = Vector2.ZERO
) -> void:
var normalized := rect.abs()
if normalized.size.x <= 0.0 or normalized.size.y <= 0.0:
return
var motif_scale: Vector2 = _normalized_scale(drawing_scale)
var base: Color = tint
if finish == &"finish_ground_legacy":
base = Color("#468B42")
elif finish.ends_with("brick"):
base = Color("#D2CBAF") if wall else Color("#994D35")
elif finish.ends_with("wood"):
base = Color("#9A673C") if wall else Color("#B88951")
elif finish == &"finish_ground_grass" or finish == &"finish_background_grass":
base = Color("#72A84C")
elif finish == &"finish_background_concrete":
base = Color("#A6AAA3")
elif finish == &"finish_background_carpet":
base = tint
elif finish == &"finish_ground_dirt":
base = Color("#8E633E")
elif finish == &"finish_ground_sidewalk":
base = Color("#B7B9B2")
elif finish == &"finish_ground_asphalt":
base = Color("#505A62")
var base: Color = surface_base_color(finish, tint, wall)
canvas.draw_rect(normalized, base, true)
var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized
var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized
var simplified: bool = interactive_preview and (force_simple_preview or needs_light_preview(finish, pattern_rect.size, motif_scale))
if finish.ends_with("brick"):
var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized
var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized
if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0:
var detail_size: Vector2 = view_rect.size if not view_rect.size.is_zero_approx() else normalized.size
if detail_view_size.x > 0.0 and detail_view_size.y > 0.0:
detail_size = detail_view_size
var detail_scale: float = brick_detail_scale_for_view(detail_size, motif_scale, wall)
if wall:
_draw_wall_bricks(canvas, pattern_rect, world_origin, motif_scale, detail_scale, tint)
_draw_wall_bricks(canvas, pattern_rect, world_origin, motif_scale, detail_scale, tint, simplified)
else:
_draw_bricks(canvas, pattern_rect, world_origin, base, motif_scale, detail_scale)
elif finish.ends_with("wood"):
if wall:
var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized
var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized
if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0:
var detail_scale: float = wood_detail_scale_for_view(pattern_rect.size, motif_scale)
_draw_wall_wood(canvas, pattern_rect, world_origin, motif_scale, detail_scale, tint)
var detail_size: Vector2 = detail_view_size if detail_view_size.x > 0.0 and detail_view_size.y > 0.0 else pattern_rect.size
var detail_scale: float = wood_detail_scale_for_view(detail_size, motif_scale)
_draw_wall_wood(canvas, pattern_rect, world_origin, motif_scale, detail_scale, tint, simplified)
else:
_draw_wood(canvas, normalized, world_origin, base, motif_scale)
elif finish == &"finish_background_grass":
var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized
var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized
if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0:
_draw_background_grass(canvas, pattern_rect, world_origin, motif_scale)
_draw_background_grass(canvas, pattern_rect, world_origin, motif_scale, simplified)
elif finish == &"finish_background_concrete":
var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized
var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized
if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0:
_draw_background_concrete(canvas, pattern_rect, world_origin, motif_scale)
_draw_background_concrete(canvas, pattern_rect, world_origin, motif_scale, simplified)
elif finish == &"finish_background_carpet":
var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized
var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized
if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0:
_draw_background_carpet(canvas, pattern_rect, world_origin, motif_scale, tint)
_draw_background_carpet(canvas, pattern_rect, world_origin, motif_scale, tint, simplified)
elif finish == &"finish_ground_grass":
_draw_grass(canvas, normalized, world_origin, motif_scale)
elif finish == &"finish_ground_dirt":
@@ -154,6 +187,39 @@ static func _draw_surface(
push_warning("StageSurfaceRenderer: unknown finish '%s'; drawing paint." % String(finish))
static func needs_light_preview(finish: StringName, visible_size: Vector2, motif_scale: Vector2 = Vector2.ONE) -> bool:
if visible_size.x <= 0.0 or visible_size.y <= 0.0:
return false
var cell_size: Vector2
var work_per_cell: float
match finish:
&"finish_wall_brick":
cell_size = Vector2(WALL_BRICK_WIDTH, WALL_BRICK_HEIGHT)
work_per_cell = 240.0
&"finish_wall_wood":
cell_size = Vector2(320.0, 64.0)
work_per_cell = 750.0
&"finish_background_grass":
cell_size = Vector2(44.0, 36.0)
work_per_cell = 70.0
&"finish_background_concrete":
cell_size = Vector2(280.0, 190.0)
work_per_cell = 20.0
&"finish_background_carpet":
cell_size = Vector2(34.0, 26.0)
work_per_cell = 35.0
_:
return false
var world_size: Vector2 = visible_size * _normalized_scale(motif_scale)
var cells: float = (ceil(world_size.x / cell_size.x) + 1.0) * (ceil(world_size.y / cell_size.y) + 1.0)
return cells * work_per_cell > PREVIEW_WORK_BUDGET
static func _preview_texture_stride(size: Vector2, motif_scale: Vector2, cell_size: Vector2) -> int:
var cells: Vector2 = size * motif_scale / cell_size + Vector2(3.0, 3.0)
return maxi(1, ceili(sqrt(cells.x * cells.y / float(MAX_PREVIEW_TEXTURE_CELLS))))
static func brick_detail_scale_for_view(view_size: Vector2, motif_scale: Vector2 = Vector2.ONE, wall: bool = false) -> float:
var scale: float = 1.0
var safe_size: Vector2 = Vector2(maxf(1.0, view_size.x), maxf(1.0, view_size.y))
@@ -185,7 +251,7 @@ static func wood_detail_scale_for_view(view_size: Vector2, motif_scale: Vector2
return scale
static func _draw_wall_wood(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, detail_scale: float, tint: Color) -> void:
static func _draw_wall_wood(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, detail_scale: float, tint: Color, simplified: bool = false) -> void:
const BOARD_HEIGHT: float = 64.0
const PLANK_WIDTH: float = 320.0
var safe_scale: Vector2 = _normalized_scale(motif_scale)
@@ -234,14 +300,15 @@ static func _draw_wall_wood(canvas: CanvasItem, rect: Rect2, origin: Vector2, mo
Vector2(plank_local_left, board_local_top + board_world_height / safe_scale.y),
_color_with_alpha(seam_color, 0.88), 2.0 / safe_scale.x
)
_draw_wall_wood_grain(
canvas, rect, plank_world_left, board_world_top, plank_world_width, board_world_height,
origin, safe_scale, row, column, grain_color, highlight_color, detail_scale, tint, natural
)
_draw_wall_wood_knot(
canvas, rect, plank_world_left, board_world_top, plank_world_width, board_world_height,
origin, safe_scale, row, column, detail_scale, tint, natural
)
if not simplified:
_draw_wall_wood_grain(
canvas, rect, plank_world_left, board_world_top, plank_world_width, board_world_height,
origin, safe_scale, row, column, grain_color, highlight_color, detail_scale, tint, natural
)
_draw_wall_wood_knot(
canvas, rect, plank_world_left, board_world_top, plank_world_width, board_world_height,
origin, safe_scale, row, column, detail_scale, tint, natural
)
_draw_clipped_line(
canvas, rect,
Vector2(rect.position.x, board_local_top), Vector2(rect.end.x, board_local_top),
@@ -274,7 +341,7 @@ static func _draw_wall_wood_grain(
) -> void:
const TRACK_COUNT: int = 5
const SAMPLES: int = 32
var seed: int = row * 73856093 + column * 19349663
var seed: int = _pattern_seed(row, column, WOOD_PATTERN_CELLS, 73856093, 19349663)
for track: int in TRACK_COUNT:
var track_seed: int = seed + track * 83492791
var lane: float = 0.16 + _wood_hash(track_seed) * 0.68
@@ -312,7 +379,7 @@ static func _draw_wall_wood_knot(
tint: Color,
natural: Color
) -> void:
var seed: int = row * 92821 + column * 68917 + 31
var seed: int = _pattern_seed(row, column, WOOD_PATTERN_CELLS, 92821, 68917, 31)
if _wood_hash(seed) > 0.24:
return
var center_world := Vector2(
@@ -376,7 +443,7 @@ static func _material_color(source: Color, selected: Color, natural: Color) -> C
return Color.from_hsv(selected.h, saturation, value, source.a)
static func _draw_background_grass(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2) -> void:
static func _draw_background_grass(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, simplified: bool = false) -> void:
const CELL_WIDTH: float = 44.0
const CELL_HEIGHT: float = 36.0
var world_left: float = rect.position.x * motif_scale.x + origin.x
@@ -388,12 +455,16 @@ static func _draw_background_grass(canvas: CanvasItem, rect: Rect2, origin: Vect
var first_row: int = floori(world_top / CELL_HEIGHT) - 1
var last_row: int = floori(world_bottom / CELL_HEIGHT) + 1
var remaining: int = MAX_BACKGROUND_CELLS
for row: int in range(first_row, last_row + 1):
for column: int in range(first_column, last_column + 1):
var stride: int = _preview_texture_stride(rect.size, motif_scale, Vector2(CELL_WIDTH, CELL_HEIGHT)) if simplified else 1
# Sample the same world cells at a lower density, including in negative coordinates.
first_row = floori(float(first_row) / float(stride)) * stride
first_column = floori(float(first_column) / float(stride)) * stride
for row: int in range(first_row, last_row + 1, stride):
for column: int in range(first_column, last_column + 1, stride):
if remaining <= 0:
return
remaining -= 1
var seed: int = row * 73856093 + column * 19349663 + 197
var seed: int = _pattern_seed(row, column, GRASS_PATTERN_CELLS, 73856093, 19349663, 197)
var center_world := Vector2((float(column) + 0.5) * CELL_WIDTH, (float(row) + 0.5) * CELL_HEIGHT)
center_world += Vector2((_wood_hash(seed) - 0.5) * 34.0, (_wood_hash(seed + 1) - 0.5) * 26.0)
var center := Vector2((center_world.x - origin.x) / motif_scale.x, (center_world.y - origin.y) / motif_scale.y)
@@ -403,7 +474,7 @@ static func _draw_background_grass(canvas: CanvasItem, rect: Rect2, origin: Vect
var highlight: Color = Color("#C3DA74").darkened(_wood_hash(seed + 3) * 0.24)
var radius: float = 1.8 + _wood_hash(seed + 4) * 1.6
canvas.draw_circle(center, radius, _color_with_alpha(tuft_color, 0.56))
for blade: int in 4:
for blade: int in (1 if simplified else 4):
var blade_seed: int = seed + blade * 47 + 11
var x_offset: float = (_wood_hash(blade_seed) - 0.5) * 9.0 / motif_scale.x
var y_offset: float = (_wood_hash(blade_seed + 1) - 0.5) * 7.0 / motif_scale.y
@@ -413,7 +484,7 @@ static func _draw_background_grass(canvas: CanvasItem, rect: Rect2, origin: Vect
_draw_clipped_line(canvas, rect, start, tip, _color_with_alpha(highlight, 0.78), 1.25 / motif_scale.y)
static func _draw_background_concrete(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2) -> void:
static func _draw_background_concrete(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, simplified: bool = false) -> void:
const SLAB_WIDTH: float = 280.0
const SLAB_HEIGHT: float = 190.0
var world_left: float = rect.position.x * motif_scale.x + origin.x
@@ -431,11 +502,13 @@ static func _draw_background_concrete(canvas: CanvasItem, rect: Rect2, origin: V
var x: float = float(column) * SLAB_WIDTH
var local_x: float = (x - origin.x) / motif_scale.x
_draw_clipped_line(canvas, rect, Vector2(local_x, rect.position.y), Vector2(local_x, rect.end.y), Color("#68757A", 0.32), column_gap)
if simplified:
continue
for row: int in range(first_row, mini(last_row + 1, first_row + MAX_BACKGROUND_CELLS)):
if remaining <= 0:
return
remaining -= 1
var seed: int = column * 92821 + row * 68917 + 51
var seed: int = _pattern_seed(row, column, CONCRETE_PATTERN_CELLS, 68917, 92821, 51)
var world_point := Vector2(x + 16.0 + _wood_hash(seed) * (SLAB_WIDTH - 32.0), float(row) * SLAB_HEIGHT + 14.0 + _wood_hash(seed + 1) * (SLAB_HEIGHT - 28.0))
var point := Vector2((world_point.x - origin.x) / motif_scale.x, (world_point.y - origin.y) / motif_scale.y)
if rect.grow(3.0).has_point(point):
@@ -447,25 +520,28 @@ static func _draw_background_concrete(canvas: CanvasItem, rect: Rect2, origin: V
_draw_clipped_line(canvas, rect, Vector2(rect.position.x, local_y), Vector2(rect.end.x, local_y), Color("#68757A", 0.32), row_gap)
static func _draw_background_carpet(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, tint: Color) -> void:
static func _draw_background_carpet(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, tint: Color, simplified: bool = false) -> void:
const CELL_WIDTH: float = 34.0
const CELL_HEIGHT: float = 26.0
var world_left: float = rect.position.x * motif_scale.x + origin.x
var world_right: float = rect.end.x * motif_scale.x + origin.x
var world_top: float = rect.position.y * motif_scale.y + origin.y
var world_bottom: float = rect.end.y * motif_scale.y + origin.y
var first_column: int = floori(world_left / CELL_WIDTH)
var first_column: int = floori(world_left / CELL_WIDTH) - 1
var last_column: int = floori(world_right / CELL_WIDTH)
var first_row: int = floori(world_top / CELL_HEIGHT)
var last_row: int = floori(world_bottom / CELL_HEIGHT)
var remaining: int = MAX_BACKGROUND_CELLS
var natural: Color = default_tint(&"finish_background_carpet")
for row: int in range(first_row, last_row + 1):
for column: int in range(first_column, last_column + 1):
var stride: int = _preview_texture_stride(rect.size, motif_scale, Vector2(CELL_WIDTH, CELL_HEIGHT)) if simplified else 1
first_row = floori(float(first_row) / float(stride)) * stride
first_column = floori(float(first_column) / float(stride)) * stride
for row: int in range(first_row, last_row + 1, stride):
for column: int in range(first_column, last_column + 1, stride):
if remaining <= 0:
return
remaining -= 1
var seed: int = row * 92821 + column * 68917 + 71
var seed: int = _pattern_seed(row, column, CARPET_PATTERN_CELLS, 92821, 68917, 71)
var cell_left: float = float(column) * CELL_WIDTH
var cell_top: float = float(row) * CELL_HEIGHT
for fiber: int in 2:
@@ -479,7 +555,7 @@ static func _draw_background_carpet(canvas: CanvasItem, rect: Rect2, origin: Vec
_draw_clipped_line(canvas, rect, start, finish, _color_with_alpha(fiber_color, 0.42), 0.85 / motif_scale.y)
static func _draw_wall_bricks(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, detail_scale: float, tint: Color) -> void:
static func _draw_wall_bricks(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, detail_scale: float, tint: Color, interactive_preview: bool = false) -> void:
var brick_world_w: float = WALL_BRICK_WIDTH * detail_scale
var brick_world_h: float = WALL_BRICK_HEIGHT * detail_scale
var brick_w: float = brick_world_w / motif_scale.x
@@ -508,7 +584,7 @@ static func _draw_wall_bricks(canvas: CanvasItem, rect: Rect2, origin: Vector2,
var column_count: int = int(ceil(rect.size.x * motif_scale.x / brick_world_w)) + 2
for column_offset: int in column_count:
var column: int = first_column + column_offset
var seed: int = row * 92821 + column * 68917 + 31
var seed: int = _pattern_seed(row, column, BRICK_PATTERN_CELLS, 92821, 68917, 31)
var brick_world_left: float = float(column) * brick_world_w + stagger_world
var brick_local_left: float = (brick_world_left - origin.x) / motif_scale.x
var width_variation: float = 0.92 + _wood_hash(seed + 1) * 0.14
@@ -526,6 +602,15 @@ static func _draw_wall_bricks(canvas: CanvasItem, rect: Rect2, origin: Vector2,
continue
if face_rect.end.y < rect.position.y or face_rect.position.y > rect.end.y:
continue
var color_index: int = int(_wood_hash(seed + 5) * float(palette.size()))
var face_color: Color = palette[color_index]
if interactive_preview:
# Keep the committed pattern's world phase, dimensions, jitter, and palette.
# Fine outlines and texture are expensive to regenerate on every drag frame.
var visible_face: Rect2 = face_rect.intersection(rect)
if visible_face.size.x > 0.0 and visible_face.size.y > 0.0:
canvas.draw_rect(visible_face, face_color)
continue
var roughness := Vector2(1.7 * detail_scale / motif_scale.x, 1.2 * detail_scale / motif_scale.y)
var outline: PackedVector2Array = _rough_brick_outline(face_rect, seed, roughness)
var shadow_offset := Vector2(1.3 * detail_scale / motif_scale.x, 1.8 * detail_scale / motif_scale.y)
@@ -535,8 +620,6 @@ static func _draw_wall_bricks(canvas: CanvasItem, rect: Rect2, origin: Vector2,
var face_points: PackedVector2Array = _clip_polygon_to_rect(outline, rect)
if face_points.size() < 3:
continue
var color_index: int = int(_wood_hash(seed + 5) * float(palette.size()))
var face_color: Color = palette[color_index]
canvas.draw_colored_polygon(face_points, face_color)
var closed_outline := face_points.duplicate()
closed_outline.append(face_points[0])
@@ -628,12 +711,34 @@ static func _offset_polygon(points: PackedVector2Array, offset: Vector2) -> Pack
static func _clip_polygon_to_rect(points: PackedVector2Array, rect: Rect2) -> PackedVector2Array:
var fully_inside: bool = true
for point: Vector2 in points:
if not rect.has_point(point):
fully_inside = false
break
if fully_inside:
return points
var clipped := points
for edge: int in 4:
clipped = _clip_polygon_to_edge(clipped, rect, edge)
if clipped.is_empty():
return clipped
return clipped
# Clipping at a corner can produce repeated vertices or a zero-area sliver.
# These cannot be triangulated, even when the array still has three entries.
var cleaned := PackedVector2Array()
for point: Vector2 in clipped:
if cleaned.is_empty() or cleaned[cleaned.size() - 1].distance_squared_to(point) > 0.000001:
cleaned.append(point)
if cleaned.size() > 1 and cleaned[0].distance_squared_to(cleaned[cleaned.size() - 1]) <= 0.000001:
cleaned.remove_at(cleaned.size() - 1)
if cleaned.size() < 3:
return PackedVector2Array()
var area: float = 0.0
for index: int in range(1, cleaned.size() - 1):
area += (cleaned[index] - cleaned[0]).cross(cleaned[index + 1] - cleaned[0])
if absf(area) <= 0.001 or Geometry2D.triangulate_polygon(cleaned).is_empty():
return PackedVector2Array()
return cleaned
static func _clip_polygon_to_edge(points: PackedVector2Array, rect: Rect2, edge: int) -> PackedVector2Array:
+1 -2
View File
@@ -2,7 +2,6 @@ class_name StageWallPanel
extends "res://scripts/stage/scenery/stage_rect_surface.gd"
## Finite decorative wall panel with world-scale finish motifs.
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const DRAW_ORDER := preload("res://scripts/stage/stage_draw_order.gd")
## Saved source-wall anchor offset, retained to preserve erased-fragment metadata.
## Motif drawing uses the fragment's current world origin so its brick courses stay aligned.
@@ -23,4 +22,4 @@ func get_surface_draw_origin_world() -> Vector2:
func _draw() -> void:
SURFACE_RENDERER.draw_wall(self, get_local_bounds(), finish_id, get_render_tint(), get_surface_draw_origin_world())
_draw_surface(true)
+2
View File
@@ -307,6 +307,8 @@ static func _validate_root(data: Dictionary) -> Dictionary:
return {"ok": false, "error": "Background ground size or finish is invalid."}
if not _valid_color(entry.get("color", "")):
return {"ok": false, "error": "Background ground color is invalid."}
if entry.has("motif_origin_offset") and not is_vector_data(entry.get("motif_origin_offset")):
return {"ok": false, "error": "Background ground motif origin is invalid."}
elif String(entry.get("type", "")) == "scenery_artwork":
var asset_id: Variant = entry.get("asset_id", "")
if not asset_id is String or String(asset_id).strip_edges().is_empty():