extends Node3D ## Automated screenshot cycle -- segmented bodies x outfits x angles x zoom levels. ## Launch: godot --path . scenes/screenshot_cycle.tscn const ANGLES := { "dramatic": Vector3(-30.0, 45.0, 0.0), "frontal": Vector3(-5.0, 45.0, 0.0), } const ZOOMS := { "gameplay": 5.0, "heavy": 2.5, } const ANIM_PATH := "res://models/quaternius/animations/UAL2_Standard.glb" const CAM_DIST: float = 20.0 const PIVOT_Y_ZOOMED_OUT: float = 0.9 const PIVOT_Y_ZOOMED_IN: float = 1.5 const ZOOM_MIN: float = 1.5 const ZOOM_MAX: float = 30.0 ## Bodies to cycle through. Each uses the segmented architecture. const BODIES := [ { "name": "male_segmented", "segmented": "res://models/quaternius/segmented-male/", }, { "name": "female_segmented", "segmented": "res://models/quaternius/segmented-female/", }, ] ## Outfits directory -- scan for Male_*.gltf files. const OUTFITS_DIR := "res://models/quaternius/outfits-fantasy/full/" var _camera: Camera3D var _character: CharacterVisual var _light: DirectionalLight3D var _world_env: WorldEnvironment var _shots: Array[Dictionary] = [] var _shot_index: int = 0 var _settle_frames: int = 0 var _out_dir: String var _current_body: String = "" func _ready() -> void: var git_output: Array = [] OS.execute("git", ["rev-parse", "--show-toplevel"], git_output) var root := (git_output[0] as String).strip_edges() var timestamp := Time.get_datetime_string_from_system().replace(":", "").replace("T", "-").substr(0, 15) _out_dir = "%s/.tmp/quaternius-spike-%s" % [root, timestamp] DirAccess.make_dir_recursive_absolute(_out_dir) print("Output: %s" % _out_dir) _camera = Camera3D.new() _camera.projection = Camera3D.PROJECTION_ORTHOGONAL _camera.near = 0.1 _camera.far = 100.0 _camera.current = true add_child(_camera) _build_floor() _light = DirectionalLight3D.new() _light.transform = Transform3D( Basis(Vector3(0.866, -0.354, 0.354), Vector3(0, 0.707, 0.707), Vector3(-0.5, -0.612, 0.612)), Vector3(5, 10, 5) ) _light.light_energy = 0.8 add_child(_light) var env := Environment.new() env.background_mode = Environment.BG_COLOR env.background_color = Color(0.18, 0.2, 0.24) env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR env.ambient_light_color = Color(0.5, 0.5, 0.55) env.ambient_light_energy = 0.6 _world_env = WorldEnvironment.new() _world_env.environment = env add_child(_world_env) _build_shot_list() _load_body_for_shot(_shots[0]) _setup_shot(_shots[0]) print("Screenshot cycle: %d shots queued" % _shots.size()) func _process(_delta: float) -> void: if _shot_index >= _shots.size(): print("Screenshot cycle complete: %d shots captured" % _shots.size()) get_tree().quit() return _settle_frames += 1 if _settle_frames < 15: return _capture_shot(_shots[_shot_index]) _shot_index += 1 if _shot_index < _shots.size(): _load_body_for_shot(_shots[_shot_index]) _setup_shot(_shots[_shot_index]) _settle_frames = 0 func _build_shot_list() -> void: # Scan available outfits var outfit_paths: Array[String] = [] var dir := DirAccess.open(OUTFITS_DIR) if dir: dir.list_dir_begin() var file_name := dir.get_next() while file_name != "": if file_name.ends_with(".gltf") and file_name.begins_with("Male_"): outfit_paths.append(OUTFITS_DIR + file_name) file_name = dir.get_next() dir.list_dir_end() outfit_paths.sort() for body in BODIES: var body_name: String = body["name"] # Bare body at both angles for angle_name in ANGLES: _shots.append({ "body": body, "angle_name": angle_name, "angle": ANGLES[angle_name], "zoom_name": "gameplay", "zoom": ZOOMS["gameplay"], "outfit_name": "bare", "outfit_path": "", }) # Each outfit at dramatic gameplay + frontal heavy zoom for outfit_path in outfit_paths: var outfit_name := outfit_path.get_file().get_basename() _shots.append({ "body": body, "angle_name": "dramatic", "angle": ANGLES["dramatic"], "zoom_name": "gameplay", "zoom": ZOOMS["gameplay"], "outfit_name": outfit_name, "outfit_path": outfit_path, }) _shots.append({ "body": body, "angle_name": "frontal", "angle": ANGLES["frontal"], "zoom_name": "heavy", "zoom": ZOOMS["heavy"], "outfit_name": outfit_name, "outfit_path": outfit_path, }) func _load_body_for_shot(shot: Dictionary) -> void: var body: Dictionary = shot["body"] if body["name"] == _current_body: return _current_body = body["name"] if _character: remove_child(_character) _character.free() _character = null _character = CharacterVisual.new() _character.set_shaders( preload("res://shaders/spike/toon.gdshader"), preload("res://shaders/spike/toon_masked.gdshader"), preload("res://shaders/spike/outline.gdshader"), ) _character.position = Vector3(0.0, 0.04, 0.0) _character.rotation_degrees.y = 45.0 add_child(_character) if body.has("segmented"): _character.load_body(body["segmented"]) else: _character.load_unsegmented_body(body["path"]) print(" Body loaded: %s (skeleton: %d bones)" % [body["name"], _character.get_bone_count()]) var anims := _character.load_animation_library(ANIM_PATH) for anim_name in anims: if "Idle" in anim_name: _character.play_animation(anim_name) break func _setup_shot(shot: Dictionary) -> void: _character.detach_all_outfits() _character.set_body_visible(true) if shot["outfit_path"] != "": _character.attach_outfit(shot["outfit_path"]) _character.shader_mode = CharacterVisual.ShaderMode.TEXTURED _character.outline_enabled = true var zoom := float(shot["zoom"]) _camera.size = zoom var zoom_t := 1.0 - (zoom - ZOOM_MIN) / (ZOOM_MAX - ZOOM_MIN) var pivot_y := lerpf(PIVOT_Y_ZOOMED_OUT, PIVOT_Y_ZOOMED_IN, clampf(zoom_t, 0.0, 1.0)) var pivot := Vector3(0.0, pivot_y, 0.0) var rot := shot["angle"] as Vector3 _camera.rotation_degrees = rot var basis := Basis.from_euler(rot * (PI / 180.0)) _camera.position = pivot + basis * Vector3(0, 0, CAM_DIST) func _capture_shot(shot: Dictionary) -> void: var img := get_viewport().get_texture().get_image() var body_name: String = (shot["body"] as Dictionary)["name"] var desc := "%02d-%s-%s-%s-%s" % [ _shot_index + 1, body_name, shot["outfit_name"], shot["angle_name"], shot["zoom_name"] ] var filename := "%s.png" % desc img.save_png(_out_dir.path_join(filename)) print(" [%d/%d] %s" % [_shot_index + 1, _shots.size(), filename]) func _build_floor() -> void: const FLOOR_HALF_SIZE := 4 for x in range(-FLOOR_HALF_SIZE, FLOOR_HALF_SIZE + 1): for z in range(-FLOOR_HALF_SIZE, FLOOR_HALF_SIZE + 1): var mi := MeshInstance3D.new() var quad := PlaneMesh.new() quad.size = Vector2(0.95, 0.95) mi.mesh = quad mi.position = Vector3(float(x), 0.0, float(z)) var mat := StandardMaterial3D.new() mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED if (abs(x) + abs(z)) % 2 == 0: mat.albedo_color = Color(0.35, 0.38, 0.42) else: mat.albedo_color = Color(0.28, 0.30, 0.34) mi.material_override = mat add_child(mi)