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)