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:
@@ -447,7 +447,7 @@ func _test_selector_setup_routes(controller: StageSceneryController) -> void:
|
||||
await process_frame
|
||||
await create_timer(0.2).timeout
|
||||
var ground_ghost: StageRectSurface = controller.get("_ghost") as StageRectSurface
|
||||
_expect(ground_ghost != null and ground_ghost.panel_size.is_equal_approx(Vector2(800.0, 320.0)), "background-ground placement displays its patterned cursor ghost after production mouse motion")
|
||||
_expect(ground_ghost != null and ground_ghost.panel_size.is_equal_approx(Vector2(240.0, 280.0)), "background-ground placement starts with a cursor ghost the same size as a wall panel")
|
||||
_capture_root_viewport("phase19_ground_placement.png")
|
||||
controller.exit_tool()
|
||||
selector.hide()
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
extends SceneTree
|
||||
## Focused placement regression and CPU draw-cost comparison for large brick walls.
|
||||
|
||||
const WALL := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
|
||||
const CONTROLLER := preload("res://scripts/editors/stage/stage_scenery_controller.gd")
|
||||
|
||||
class TimedWall extends StageWallPanel:
|
||||
var draw_times_usec: Array[int] = []
|
||||
|
||||
func _draw() -> void:
|
||||
var started: int = Time.get_ticks_usec()
|
||||
super._draw()
|
||||
draw_times_usec.append(Time.get_ticks_usec() - started)
|
||||
|
||||
var _failures: Array[String] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
root.size = Vector2i(2560, 1440)
|
||||
var world := Node2D.new()
|
||||
var ghosts := Node2D.new()
|
||||
var controller := CONTROLLER.new() as StageSceneryController
|
||||
root.add_child(world)
|
||||
root.add_child(ghosts)
|
||||
root.add_child(controller)
|
||||
controller.configure(world, ghosts, null)
|
||||
controller.set_grid_snap(true, 15.0)
|
||||
var tint := Color("#995544")
|
||||
controller.begin_wall(&"finish_wall_brick", Vector2(-300.0, -150.0), tint)
|
||||
var ghost: StageWallPanel = ghosts.get_child(0) as StageWallPanel
|
||||
_expect(ghost.interactive_preview, "placement uses the inexpensive brick preview")
|
||||
var press := InputEventMouseButton.new()
|
||||
press.button_index = MOUSE_BUTTON_LEFT
|
||||
press.pressed = true
|
||||
controller.handle_mouse_button(press, Vector2(-300.0, -150.0), false)
|
||||
controller.handle_mouse_motion(Vector2(2100.0, 1150.0))
|
||||
var preview_bounds: Rect2 = ghost.global_transform * ghost.get_local_bounds()
|
||||
var release := InputEventMouseButton.new()
|
||||
release.button_index = MOUSE_BUTTON_LEFT
|
||||
controller.handle_mouse_button(release, Vector2(2100.0, 1150.0), false)
|
||||
_expect(world.get_child_count() == 1, "release commits one large wall")
|
||||
var committed: StageWallPanel = world.get_child(0) as StageWallPanel
|
||||
_expect(not committed.interactive_preview, "committed wall restores the full brick artwork")
|
||||
_expect(committed.finish_id == &"finish_wall_brick" and committed.tint == tint, "commit preserves the chosen finish and tint")
|
||||
_expect(preview_bounds.is_equal_approx(committed.global_transform * committed.get_local_bounds()), "preview and commit use the same snapped bounds across negative world coordinates")
|
||||
controller.exit_tool()
|
||||
world.free()
|
||||
ghosts.free()
|
||||
controller.free()
|
||||
|
||||
var sample := TimedWall.new()
|
||||
sample.position = Vector2(1280.0, 720.0)
|
||||
sample.interactive_preview = true
|
||||
sample.configure(Vector2(2400.0, 1320.0), &"finish_wall_brick", tint)
|
||||
root.add_child(sample)
|
||||
await process_frame
|
||||
await process_frame
|
||||
var draw_count: int = sample.draw_times_usec.size()
|
||||
_expect(draw_count > 0, "large wall executes its drawing callback")
|
||||
sample.configure(sample.panel_size, sample.finish_id, sample.tint)
|
||||
await process_frame
|
||||
await process_frame
|
||||
_expect(sample.draw_times_usec.size() == draw_count, "unchanged snapped geometry does not rebuild the drawing")
|
||||
var preview_usec: float = await _measure_redraws(sample)
|
||||
sample.interactive_preview = false
|
||||
await process_frame
|
||||
await process_frame
|
||||
var detailed_usec: float = await _measure_redraws(sample)
|
||||
print("Brick draw CPU time (2400 x 1320): preview=%.2f ms, detailed=%.2f ms, speedup=%.1fx" % [preview_usec / 1000.0, detailed_usec / 1000.0, detailed_usec / maxf(1.0, preview_usec)])
|
||||
sample.free()
|
||||
if _failures.is_empty():
|
||||
print("PASS: scenery brick placement preview")
|
||||
else:
|
||||
for failure: String in _failures:
|
||||
push_error("FAIL: " + failure)
|
||||
quit(0 if _failures.is_empty() else 1)
|
||||
|
||||
|
||||
func _measure_redraws(sample: TimedWall) -> float:
|
||||
sample.draw_times_usec.clear()
|
||||
for _iteration: int in 5:
|
||||
sample.queue_redraw()
|
||||
await process_frame
|
||||
await process_frame
|
||||
var total_usec: float = 0.0
|
||||
for elapsed: int in sample.draw_times_usec:
|
||||
total_usec += float(elapsed)
|
||||
return total_usec / maxf(1.0, float(sample.draw_times_usec.size()))
|
||||
|
||||
|
||||
func _expect(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
_failures.append(message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://du36kyv22uneg
|
||||
@@ -0,0 +1,284 @@
|
||||
extends SceneTree
|
||||
## Texture budget, full-detail visual equivalence, reuse, and cache invalidation.
|
||||
|
||||
const CACHE := preload("res://scripts/stage/scenery/stage_surface_cache.gd")
|
||||
const RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
|
||||
|
||||
class ReferenceSurface extends Node2D:
|
||||
var finish: StringName
|
||||
var panel_size := Vector2(320.0, 240.0)
|
||||
var tint: Color
|
||||
var simplified: bool = false
|
||||
|
||||
func _draw() -> void:
|
||||
var rect := Rect2(-panel_size * 0.5, panel_size)
|
||||
if RENDERER.is_wall_finish(finish):
|
||||
RENDERER.draw_wall(self, rect, finish, tint, global_position, Vector2.ONE, true, simplified, simplified)
|
||||
else:
|
||||
RENDERER.draw_background(self, rect, finish, tint, global_position, Vector2.ONE, true, simplified, simplified)
|
||||
|
||||
var _failures: Array[String] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_test_texture_budget()
|
||||
_test_clipped_degenerate_polygon()
|
||||
if DisplayServer.get_name() == "headless":
|
||||
print("SKIP: texture rendering and invalidation checks require a GUI renderer")
|
||||
else:
|
||||
for finish: StringName in [&"finish_wall_brick", &"finish_wall_wood", &"finish_background_grass", &"finish_background_concrete", &"finish_background_carpet"]:
|
||||
await _test_visual_equivalence(finish)
|
||||
await _test_visual_equivalence(&"finish_wall_brick", 0.4)
|
||||
await _test_visual_equivalence(&"finish_wall_wood", 0.4)
|
||||
await _test_visual_equivalence(&"finish_wall_brick", 0.4, 0.2)
|
||||
await _test_invalidation()
|
||||
await _test_camera_tile_budget()
|
||||
await _test_covered_surfaces()
|
||||
if _failures.is_empty():
|
||||
print("PASS: full-detail scenery surface texture cache")
|
||||
else:
|
||||
for failure: String in _failures:
|
||||
push_error("FAIL: " + failure)
|
||||
quit(0 if _failures.is_empty() else 1)
|
||||
|
||||
|
||||
func _test_texture_budget() -> void:
|
||||
for extent: Vector2 in [Vector2(320.0, 240.0), Vector2(100000.0, 80000.0), Vector2(100000.0, 12.0), Vector2(1.0, 1.0)]:
|
||||
var pixels: Vector2i = CACHE.texture_size_for(extent, Vector2(3.0, 3.0))
|
||||
_expect(pixels.x > 0 and pixels.y > 0 and pixels.x <= CACHE.MAX_TEXTURE_EDGE and pixels.y <= CACHE.MAX_TEXTURE_EDGE, "cache dimensions stay positive and bounded")
|
||||
_expect(float(pixels.x) * float(pixels.y) <= CACHE.MAX_TEXTURE_PIXELS, "cache pixel allocation stays bounded")
|
||||
_expect(CACHE.texture_size_for(Vector2(320.0, 240.0), Vector2(2.0, 2.0)) == Vector2i(640, 480), "ordinary surfaces rasterize at their current screen resolution")
|
||||
|
||||
|
||||
func _test_clipped_degenerate_polygon() -> void:
|
||||
var rect := Rect2(Vector2.ZERO, Vector2(100.0, 100.0))
|
||||
var touching := PackedVector2Array([Vector2(-10.0, -10.0), Vector2(0.0, 0.0), Vector2(-10.0, 10.0)])
|
||||
_expect(RENDERER._clip_polygon_to_rect(touching, rect).is_empty(), "a polygon touching only the clip edge is discarded")
|
||||
var crossing := PackedVector2Array([Vector2(-10.0, 20.0), Vector2(0.0, 20.0), Vector2(30.0, 70.0), Vector2(-10.0, 70.0)])
|
||||
var clipped: PackedVector2Array = RENDERER._clip_polygon_to_rect(crossing, rect)
|
||||
_expect(clipped.size() >= 3 and not Geometry2D.triangulate_polygon(clipped).is_empty(), "clipped polygons with vertices on the boundary remain triangulatable")
|
||||
|
||||
|
||||
func _make_viewport() -> SubViewport:
|
||||
var viewport := SubViewport.new()
|
||||
viewport.size = Vector2i(384, 256)
|
||||
viewport.transparent_bg = true
|
||||
viewport.world_2d = World2D.new()
|
||||
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
root.add_child(viewport)
|
||||
return viewport
|
||||
|
||||
|
||||
func _test_visual_equivalence(finish: StringName, zoom: float = 1.0, starting_zoom: float = 1.0) -> void:
|
||||
var cached_view := _make_viewport()
|
||||
var direct_view := _make_viewport()
|
||||
var surface: StageRectSurface = StageWallPanel.new() if RENDERER.is_wall_finish(finish) else StageBackgroundGround.new()
|
||||
var reference := ReferenceSurface.new()
|
||||
var tint: Color = RENDERER.default_tint(finish)
|
||||
var view_transform := Transform2D(Vector2(zoom, 0.0), Vector2(0.0, zoom), Vector2(192.0, 128.0) * (1.0 - zoom))
|
||||
cached_view.canvas_transform = Transform2D(Vector2(starting_zoom, 0.0), Vector2(0.0, starting_zoom), Vector2(192.0, 128.0) * (1.0 - starting_zoom))
|
||||
direct_view.canvas_transform = view_transform
|
||||
surface.position = Vector2(192.0, 128.0)
|
||||
surface.configure(Vector2(320.0, 240.0) / zoom, finish, tint)
|
||||
cached_view.add_child(surface)
|
||||
reference.position = surface.position
|
||||
reference.finish = finish
|
||||
reference.tint = tint
|
||||
reference.panel_size = surface.panel_size
|
||||
direct_view.add_child(reference)
|
||||
await _settle()
|
||||
if not is_equal_approx(starting_zoom, zoom):
|
||||
cached_view.canvas_transform = view_transform
|
||||
await _settle()
|
||||
var cached_image: Image = cached_view.get_texture().get_image()
|
||||
var direct_image: Image = direct_view.get_texture().get_image()
|
||||
var difference: float = 0.0
|
||||
var samples: int = 0
|
||||
for y: int in range(16, 240, 2):
|
||||
for x: int in range(40, 344, 2):
|
||||
var cached_pixel: Color = cached_image.get_pixel(x, y)
|
||||
var direct_pixel: Color = direct_image.get_pixel(x, y)
|
||||
difference += absf(cached_pixel.r - direct_pixel.r) + absf(cached_pixel.g - direct_pixel.g) + absf(cached_pixel.b - direct_pixel.b)
|
||||
samples += 3
|
||||
var mean_error: float = difference / float(samples)
|
||||
print("%s zoom=%.2f->%.2f cached/reference mean color difference=%.5f" % [finish, starting_zoom, zoom, mean_error])
|
||||
# Fractional zoom resamples the cached artwork; compare it with both detailed
|
||||
# and simplified references so a flat preview cannot pass as a settled texture.
|
||||
var tolerance: float = 0.015 if is_equal_approx(zoom, 1.0) else 0.02
|
||||
_expect(mean_error < tolerance, "%s zoom=%.2f->%.2f cache restores full-detail appearance" % [finish, starting_zoom, zoom])
|
||||
if zoom < 1.0:
|
||||
# Shared immutable bitmaps cannot be changed through a tile's private baker.
|
||||
# Draw the simplified reference separately, keeping the full image above.
|
||||
reference.simplified = true
|
||||
reference.queue_redraw()
|
||||
await _settle()
|
||||
var simplified_image: Image = direct_view.get_texture().get_image()
|
||||
var simplified_difference: float = 0.0
|
||||
for y: int in range(16, 240, 2):
|
||||
for x: int in range(40, 344, 2):
|
||||
var simple_pixel: Color = simplified_image.get_pixel(x, y)
|
||||
var full_pixel: Color = direct_image.get_pixel(x, y)
|
||||
simplified_difference += absf(simple_pixel.r - full_pixel.r) + absf(simple_pixel.g - full_pixel.g) + absf(simple_pixel.b - full_pixel.b)
|
||||
var simple_error: float = simplified_difference / float(samples)
|
||||
_expect(mean_error < simple_error * 0.95, "%s normal zoom retains more artwork detail than the flat preview" % finish)
|
||||
cached_image.save_png("res://tmp/surface_detail_%s.png" % finish)
|
||||
_expect(cached_image.get_pixel(192, 128).a > 0.95, "%s comparison includes visible surface artwork" % finish)
|
||||
var tiles: Dictionary = surface.get("_surface_tiles")
|
||||
_expect(not tiles.is_empty(), "%s has cached texture tiles" % finish)
|
||||
# UPDATE_ONCE is consumed by the rendering server; the node may retain the enum.
|
||||
for cache: CACHE in tiles.values():
|
||||
_expect(cache.ready_for_display and cache.render_target_update_mode in [SubViewport.UPDATE_ONCE, SubViewport.UPDATE_DISABLED], "%s tile requests a single rasterization" % finish)
|
||||
cached_view.queue_free()
|
||||
direct_view.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _test_invalidation() -> void:
|
||||
var viewport := _make_viewport()
|
||||
var surface := StageWallPanel.new()
|
||||
surface.position = Vector2(192.0, 128.0)
|
||||
surface.configure(Vector2(1600.0, 1200.0), &"finish_wall_brick", Color("#B95F3D"))
|
||||
viewport.add_child(surface)
|
||||
await _settle()
|
||||
var initial_tiles: Dictionary = (surface.get("_surface_tiles") as Dictionary).duplicate()
|
||||
var initial_builds: int = int(CACHE.get_stats(surface).get("builds", 0))
|
||||
surface.queue_redraw()
|
||||
await _settle()
|
||||
_expect(int(CACHE.get_stats(surface).get("builds", 0)) == initial_builds, "unchanged surfaces reuse the rasterized texture")
|
||||
viewport.canvas_transform.origin = Vector2(-16.0, 0.0)
|
||||
await _settle()
|
||||
_expect((surface.get("_surface_tiles") as Dictionary).size() == initial_tiles.size() and int(CACHE.get_stats(surface).get("builds", 0)) == initial_builds, "small camera pans reuse cached tiles")
|
||||
viewport.canvas_transform.origin = Vector2(-300.0, 0.0)
|
||||
await _settle()
|
||||
var tiles: Dictionary = surface.get("_surface_tiles")
|
||||
var reused: int = 0
|
||||
for key: Variant in initial_tiles:
|
||||
if tiles.get(key) == initial_tiles[key]:
|
||||
reused += 1
|
||||
_expect(reused > 0, "large pans retain the same repeating material")
|
||||
viewport.canvas_transform = Transform2D(Vector2(2.0, 0.0), Vector2(0.0, 2.0), Vector2.ZERO)
|
||||
await _settle()
|
||||
tiles = surface.get("_surface_tiles")
|
||||
for cache: CACHE in tiles.values():
|
||||
if cache.wanted:
|
||||
_expect(cache.ready_for_display and cache.source_scale.is_equal_approx(Vector2(2.0, 2.0)), "zoom keeps the same full-resolution mipmapped material")
|
||||
viewport.size = Vector2i(512, 384)
|
||||
await _settle()
|
||||
_expect(_pending_tiles(root) == 0, "window resizing completes newly exposed tiles")
|
||||
surface.set_preview_tint(Color("#22C6FF"))
|
||||
await _settle()
|
||||
for cache: CACHE in (surface.get("_surface_tiles") as Dictionary).values():
|
||||
_expect(cache.get("_tint") == Color("#22C6FF"), "temporary color edits refresh the tiles")
|
||||
surface.clear_preview_tint()
|
||||
await _settle()
|
||||
for cache: CACHE in (surface.get("_surface_tiles") as Dictionary).values():
|
||||
_expect(cache.get("_tint") == surface.tint, "canceling a color preview restores tile colors")
|
||||
viewport.canvas_transform = Transform2D.IDENTITY
|
||||
var retained_material: CACHE = (surface.get("_surface_tiles") as Dictionary).values()[0] as CACHE
|
||||
surface.configure(Vector2(48.0, 64.0), surface.finish_id, surface.tint)
|
||||
await _settle()
|
||||
for cache: CACHE in (surface.get("_surface_tiles") as Dictionary).values():
|
||||
_expect(cache == retained_material, "shrinking a panel keeps the shared material and changes only clipping")
|
||||
surface.position += Vector2(30.0, 20.0)
|
||||
await _settle()
|
||||
for cache: CACHE in (surface.get("_surface_tiles") as Dictionary).values():
|
||||
_expect(cache == retained_material, "moving a panel keeps the material and changes only pattern coordinates")
|
||||
surface.configure(surface.panel_size, &"finish_wall_wood", Color("#C87137"))
|
||||
await _settle()
|
||||
for cache: CACHE in (surface.get("_surface_tiles") as Dictionary).values():
|
||||
_expect(cache.get("_finish") == &"finish_wall_wood", "changing finish replaces cached artwork")
|
||||
viewport.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _settle() -> void:
|
||||
for frame: int in 1000:
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
if frame >= 3 and _pending_tiles(root) == 0:
|
||||
return
|
||||
_expect(false, "progressive tile builds complete")
|
||||
|
||||
|
||||
func _test_camera_tile_budget() -> void:
|
||||
var viewport := _make_viewport()
|
||||
var surface := StageWallPanel.new()
|
||||
surface.position = Vector2(192.0, 128.0)
|
||||
surface.configure(Vector2(12000.0, 6000.0), &"finish_wall_brick", Color("#B95F3D"))
|
||||
viewport.add_child(surface)
|
||||
await _settle()
|
||||
var original: Dictionary = (surface.get("_surface_tiles") as Dictionary).duplicate()
|
||||
# Camera zoom uses mipmaps from the same material image.
|
||||
for scale: float in [0.95, 0.9, 0.85]:
|
||||
viewport.canvas_transform = Transform2D(Vector2(scale, 0.0), Vector2(0.0, scale), Vector2.ZERO)
|
||||
await _settle()
|
||||
for key: Variant in original:
|
||||
_expect((surface.get("_surface_tiles") as Dictionary).get(key) == original[key], "wheel steps reuse the same material image")
|
||||
var builds_by_frame: Dictionary = {}
|
||||
for index: int in 28:
|
||||
var builds_before: int = int(CACHE.get_stats(surface).get("builds", 0))
|
||||
viewport.canvas_transform.origin = Vector2(-float(index) * 220.0, -float(index % 5) * 70.0)
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
var frame: int = Engine.get_process_frames()
|
||||
var tiles: Dictionary = surface.get("_surface_tiles")
|
||||
_expect(tiles.size() <= surface.MAX_RESIDENT_TILES, "camera traversal keeps the tile cache bounded")
|
||||
# Eviction also emits updated; count actual shared bitmap builds instead.
|
||||
builds_by_frame[frame] = int(CACHE.get_stats(surface).get("builds", 0)) - builds_before
|
||||
# Queued work is metadata only; all tiles share a single GPU viewport.
|
||||
for cache: CACHE in tiles.values():
|
||||
if not cache.ready_for_display and not cache.get("_building"):
|
||||
_expect(cache.get_texture() == null and int(CACHE.get_stats(surface).get("bakers", 0)) <= 1, "unbuilt tiles share one render target during camera input")
|
||||
_expect(frame >= 0, "camera traversal advances frames")
|
||||
await _settle()
|
||||
for count: int in builds_by_frame.values():
|
||||
_expect(count <= 1, "the shared queue admits at most one tile build per frame")
|
||||
viewport.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _test_covered_surfaces() -> void:
|
||||
var viewport := _make_viewport()
|
||||
var back := StageWallPanel.new()
|
||||
back.position = Vector2(192.0, 128.0)
|
||||
back.configure(Vector2(1600.0, 1200.0), &"finish_wall_wood", Color("#C87137"))
|
||||
viewport.add_child(back)
|
||||
var front := StageWallPanel.new()
|
||||
front.position = back.position
|
||||
front.configure(Vector2(2000.0, 1600.0), &"finish_wall_brick", Color("#B95F3D"))
|
||||
viewport.add_child(front)
|
||||
await _settle()
|
||||
_expect((back.get("_surface_tiles") as Dictionary).is_empty(), "fully covered walls do not prepare hidden texture tiles")
|
||||
front.hide()
|
||||
await _settle()
|
||||
_expect(not (back.get("_surface_tiles") as Dictionary).is_empty(), "revealing a wall resumes its tile preparation")
|
||||
front.show()
|
||||
front.modulate.a = 0.5
|
||||
await _settle()
|
||||
_expect(not back.call("_covered_by_surface"), "transparent walls keep underlying artwork visible")
|
||||
front.modulate.a = 1.0
|
||||
await _settle()
|
||||
_expect(back.call("_covered_by_surface"), "opaque coverage is detected again after transparency is removed")
|
||||
viewport.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _pending_tiles(node: Node) -> int:
|
||||
var count: int = 0
|
||||
if node is StageRectSurface:
|
||||
for cache: CACHE in (node.get("_surface_tiles") as Dictionary).values():
|
||||
if cache.wanted and not cache.ready_for_display:
|
||||
count += 1
|
||||
return count
|
||||
for child: Node in node.get_children():
|
||||
count += _pending_tiles(child)
|
||||
return count
|
||||
|
||||
|
||||
func _expect(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
_failures.append(message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cuhlecbuev43k
|
||||
@@ -0,0 +1,149 @@
|
||||
extends SceneTree
|
||||
## Adaptive preview selection, placement/commit behavior, and per-finish CPU costs.
|
||||
|
||||
const RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
|
||||
const CONTROLLER := preload("res://scripts/editors/stage/stage_scenery_controller.gd")
|
||||
|
||||
class TimedWall extends StageWallPanel:
|
||||
var draw_times_usec: Array[int] = []
|
||||
|
||||
func _draw() -> void:
|
||||
var started: int = Time.get_ticks_usec()
|
||||
super._draw()
|
||||
draw_times_usec.append(Time.get_ticks_usec() - started)
|
||||
|
||||
class TimedGround extends StageBackgroundGround:
|
||||
var draw_times_usec: Array[int] = []
|
||||
|
||||
func _draw() -> void:
|
||||
var started: int = Time.get_ticks_usec()
|
||||
super._draw()
|
||||
draw_times_usec.append(Time.get_ticks_usec() - started)
|
||||
|
||||
class SlowWall extends StageWallPanel:
|
||||
var inject_delay: bool = true
|
||||
|
||||
func get_surface_draw_origin_world() -> Vector2:
|
||||
if inject_delay:
|
||||
OS.delay_usec(PREVIEW_DRAW_BUDGET_USEC + 1000)
|
||||
return super.get_surface_draw_origin_world()
|
||||
|
||||
var _failures: Array[String] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
root.size = Vector2i(2560, 1440)
|
||||
for finish: StringName in RENDERER.WALL_FINISHES + RENDERER.BACKGROUND_FINISHES:
|
||||
_expect(not RENDERER.needs_light_preview(finish, Vector2(16.0, 16.0)), "tiny %s keeps its detail" % finish)
|
||||
_expect(not RENDERER.needs_light_preview(finish, Vector2.ZERO), "invisible %s has no preview work" % finish)
|
||||
_test_placement(finish)
|
||||
await _benchmark_finish(finish)
|
||||
for finish: StringName in [&"finish_wall_brick", &"finish_wall_wood", &"finish_background_grass", &"finish_background_carpet"]:
|
||||
_expect(RENDERER.needs_light_preview(finish, Vector2(2400.0, 1320.0)), "large %s reduces detail automatically" % finish)
|
||||
_expect(not RENDERER.needs_light_preview(&"finish_wall_paint", Vector2(100000.0, 100000.0)), "paint stays detailed at any size")
|
||||
_expect(not RENDERER.needs_light_preview(&"finish_background_concrete", Vector2(2400.0, 1320.0)), "ordinary concrete slab patterns stay detailed")
|
||||
_expect(RENDERER.needs_light_preview(&"finish_background_concrete", Vector2(24000.0, 13200.0)), "very large concrete patterns can also simplify")
|
||||
_expect(RENDERER.needs_light_preview(&"finish_wall_wood", Vector2(240.0, 132.0), Vector2(10.0, 10.0)), "work estimate accounts for motif scale")
|
||||
await _test_runtime_fallback()
|
||||
if _failures.is_empty():
|
||||
print("PASS: adaptive scenery surface previews")
|
||||
else:
|
||||
for failure: String in _failures:
|
||||
push_error("FAIL: " + failure)
|
||||
quit(0 if _failures.is_empty() else 1)
|
||||
|
||||
|
||||
func _test_placement(finish: StringName) -> void:
|
||||
var world := Node2D.new()
|
||||
var ghosts := Node2D.new()
|
||||
var controller := CONTROLLER.new() as StageSceneryController
|
||||
root.add_child(world)
|
||||
root.add_child(ghosts)
|
||||
root.add_child(controller)
|
||||
controller.configure(world, ghosts, null)
|
||||
var wall: bool = RENDERER.is_wall_finish(finish)
|
||||
var tint: Color = RENDERER.default_tint(finish)
|
||||
var start := Vector2(-300.0, -150.0)
|
||||
if wall:
|
||||
controller.begin_wall(finish, start, tint)
|
||||
else:
|
||||
controller.begin_background_ground(finish, start, tint)
|
||||
var ghost: StageRectSurface = ghosts.get_child(0) as StageRectSurface
|
||||
_expect(ghost.interactive_preview, "%s placement opts into adaptive rendering" % finish)
|
||||
var press := InputEventMouseButton.new()
|
||||
press.button_index = MOUSE_BUTTON_LEFT
|
||||
press.pressed = true
|
||||
controller.handle_mouse_button(press, start, false)
|
||||
controller.handle_mouse_motion(Vector2(2100.0, 1170.0))
|
||||
var bounds: Rect2 = ghost.global_transform * ghost.get_local_bounds()
|
||||
var release := InputEventMouseButton.new()
|
||||
release.button_index = MOUSE_BUTTON_LEFT
|
||||
controller.handle_mouse_button(release, Vector2(2100.0, 1170.0), false)
|
||||
_expect(world.get_child_count() == 1, "%s commits one surface" % finish)
|
||||
var placed: StageRectSurface = world.get_child(0) as StageRectSurface
|
||||
_expect(not placed.interactive_preview, "%s commit uses full detail" % finish)
|
||||
_expect(placed.finish_id == finish and placed.tint == tint, "%s preserves finish and tint" % finish)
|
||||
_expect(bounds.is_equal_approx(placed.global_transform * placed.get_local_bounds()), "%s preserves preview bounds" % finish)
|
||||
controller.exit_tool()
|
||||
world.free()
|
||||
ghosts.free()
|
||||
controller.free()
|
||||
|
||||
|
||||
func _benchmark_finish(finish: StringName) -> void:
|
||||
var sample: StageRectSurface = TimedWall.new() if RENDERER.is_wall_finish(finish) else TimedGround.new()
|
||||
sample.position = Vector2(1280.0, 720.0)
|
||||
sample.interactive_preview = true
|
||||
sample.configure(Vector2(2400.0, 1320.0), finish, RENDERER.default_tint(finish))
|
||||
root.add_child(sample)
|
||||
await process_frame
|
||||
await process_frame
|
||||
var preview_usec: float = await _measure_redraws(sample)
|
||||
sample.interactive_preview = false
|
||||
await process_frame
|
||||
await process_frame
|
||||
var full_usec: float = await _measure_redraws(sample)
|
||||
print("%s CPU redraw: adaptive=%.2f ms, full=%.2f ms" % [finish, preview_usec / 1000.0, full_usec / 1000.0])
|
||||
sample.free()
|
||||
|
||||
|
||||
func _measure_redraws(sample: StageRectSurface) -> float:
|
||||
var timings: Array[int] = sample.get("draw_times_usec")
|
||||
timings.clear()
|
||||
for _iteration: int in 3:
|
||||
sample.queue_redraw()
|
||||
await process_frame
|
||||
await process_frame
|
||||
_expect(not timings.is_empty(), "surface draws during benchmark")
|
||||
var total: float = 0.0
|
||||
for elapsed: int in timings:
|
||||
total += float(elapsed)
|
||||
return total / maxf(1.0, float(timings.size()))
|
||||
|
||||
|
||||
func _test_runtime_fallback() -> void:
|
||||
var sample := SlowWall.new()
|
||||
sample.position = Vector2(100.0, 100.0)
|
||||
sample.interactive_preview = true
|
||||
sample.configure(Vector2(16.0, 16.0), &"finish_wall_wood", Color.WHITE)
|
||||
root.add_child(sample)
|
||||
await process_frame
|
||||
await process_frame
|
||||
_expect(sample.get("_preview_over_budget"), "measured slow redraw enables the fallback even for a small panel")
|
||||
sample.inject_delay = false
|
||||
sample.configure(Vector2(20.0, 20.0), sample.finish_id, sample.tint)
|
||||
await process_frame
|
||||
await process_frame
|
||||
_expect(sample.get("_preview_over_budget"), "fallback stays stable during the current placement session")
|
||||
sample.interactive_preview = false
|
||||
_expect(not sample.get("_preview_over_budget"), "leaving preview resets the timing fallback")
|
||||
sample.free()
|
||||
|
||||
|
||||
func _expect(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
_failures.append(message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://du5qxtgupm60f
|
||||
Reference in New Issue
Block a user