class_name LunePortraits extends Node ## Portraits from the real game models. One offscreen SubViewport renders one model at a time ## with the same orthographic three-quarter camera, the same key, fill and rim light and a ## transparent background; every model is framed from its own bounds, so small creatures and ## the Schlundkrone fill the card alike. Each portrait is rendered once and kept as a texture, ## so cards never hold live viewports. Without a display (headless tests) nothing renders and ## callers keep their placeholder. signal portrait_ready(key: String) const SMALL: int = 160 const LARGE: int = 400 const YAW: float = .42 const PITCH: float = .2 const Materials = preload("res://scripts/luneraria/view/lune_materials.gd") static var _shared: LunePortraits static var textures: Dictionary = {} var rendered: int = 0 var _viewport: SubViewport var _stage: Node3D var _camera: Camera3D var _queue: Array[String] = [] var _busy: bool = false var _current: String = "" static func shared() -> LunePortraits: if _shared == null or not is_instance_valid(_shared): _shared = LunePortraits.new() _shared.name = "LunePortraits" var tree := Engine.get_main_loop() as SceneTree tree.root.add_child.call_deferred(_shared) return _shared static func key_for(model_id: String, large: bool = false) -> String: return model_id + ("@L" if large else "") static func available() -> bool: return DisplayServer.get_name() != "headless" ## The cached portrait, or null while it is queued (portrait_ready follows). func portrait(model_id: String, large: bool = false) -> Texture2D: var key: String = key_for(model_id, large) if textures.has(key): return textures[key] if available() and not _queue.has(key) and key != _current: _queue.append(key) return null func pending() -> int: return _queue.size() + int(_busy) func _ready() -> void: _viewport = SubViewport.new() _viewport.own_world_3d = true _viewport.transparent_bg = true _viewport.msaa_3d = Viewport.MSAA_4X _viewport.size = Vector2i(SMALL, SMALL) _viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED add_child(_viewport) _stage = Node3D.new() _viewport.add_child(_stage) var world := WorldEnvironment.new() var settings := Environment.new() settings.background_mode = Environment.BG_CLEAR_COLOR settings.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR settings.ambient_light_color = Color("a8b4c8") settings.ambient_light_energy = .62 settings.tonemap_mode = Environment.TONE_MAPPER_ACES settings.tonemap_white = 4.0 settings.adjustment_enabled = true settings.adjustment_contrast = 1.08 settings.adjustment_saturation = 1.12 world.environment = settings _viewport.add_child(world) for spec: Array in [[Vector3(-38, -32, 0), Color("ffe2bc"), 1.75], [Vector3(-18, 150, 0), Color("9fc4ff"), .9], [Vector3(-8, 40, 0), Color("c8d4e4"), .35]]: var light := DirectionalLight3D.new() light.rotation_degrees = spec[0] light.light_color = spec[1] light.light_energy = spec[2] _viewport.add_child(light) _camera = Camera3D.new() _camera.projection = Camera3D.PROJECTION_ORTHOGONAL _camera.current = true _viewport.add_child(_camera) func _process(_delta: float) -> void: if not _busy and not _queue.is_empty(): _render(_queue.pop_front()) func _render(key: String) -> void: _busy = true _current = key var large: bool = key.ends_with("@L") var model_id: String = key.trim_suffix("@L") var edge: int = LARGE if large else SMALL _viewport.size = Vector2i(edge, edge) var model: Node3D = _instantiate(model_id) if model == null: textures[key] = AssetLibrary.icon(model_id) _busy = false _current = "" portrait_ready.emit(key) return _stage.add_child(model) _pose(model) _frame(model) _viewport.render_target_update_mode = SubViewport.UPDATE_ONCE await RenderingServer.frame_post_draw await RenderingServer.frame_post_draw var image: Image = _viewport.get_texture().get_image() model.queue_free() if image != null and not image.is_empty(): image.generate_mipmaps() textures[key] = ImageTexture.create_from_image(image) rendered += 1 else: textures[key] = AssetLibrary.icon(model_id) _busy = false _current = "" portrait_ready.emit(key) func _instantiate(model_id: String) -> Node3D: var model: Node3D = AssetLibrary.instantiate_fighter(model_id) if AssetLibrary.has_model(model_id) else null if model == null: return null if model_id == "schlundkrone": for surface: GeometryInstance3D in Materials.dress(model): surface.set_instance_shader_parameter("tint", Materials.team_color(2, "crystal")) surface.set_instance_shader_parameter("seed", 1.7) surface.set_instance_shader_parameter("energy", 1.0) return model func _pose(model: Node3D) -> void: if model.has_method("preview"): model.preview("idle", .4) elif model.has_method("pose"): model.pose(.4, false, 0.0, 0.0, 0.0) ## Frames the model's bounds: the same view direction for every portrait, size from bounds. func _frame(model: Node3D) -> void: var box: AABB = _bounds(model) var center: Vector3 = box.get_center() var radius: float = maxf(.3, box.size.length() * .5) var direction := Vector3(sin(YAW) * cos(PITCH), sin(PITCH), cos(YAW) * cos(PITCH)) _camera.size = maxf(box.size.y, maxf(box.size.x, box.size.z) * .92) * 1.12 _camera.near = .05 _camera.far = radius * 8.0 + 10.0 _camera.position = center + direction * (radius * 3.0 + 2.0) _camera.look_at(center, Vector3.UP) func _bounds(model: Node3D) -> AABB: if model.has_method("get_aabb"): var own: AABB = model.get_aabb() if own.size.length() > .01: return own var result := AABB() var first: bool = true for node: Node in model.find_children("*", "VisualInstance3D", true, false): var visual := node as VisualInstance3D var local: Transform3D = model.global_transform.affine_inverse() * visual.global_transform var box: AABB = local * visual.get_aabb() result = box if first else result.merge(box) first = false return result if not first else AABB(Vector3(-.5, 0, -.5), Vector3(1, 1.6, 1))