extends SceneTree ## Bakes an animated-billboard sprite sheet from the existing clapping crowd figure ## (Animations/mees_plaksutab.glb, clip "ArmatureAction") so a whole stand of ## spectators can render as camera-facing quads in one draw call instead of 72 skinned ## skeletons. Renders FRAMES evenly-spaced poses from a front orthographic camera on a ## transparent background and packs them into a single-row atlas. ## ## Needs a display — script mode renders to a real window (headless is blank): ## godot --script tools/bake_crowd_sheet.gd ## ## Output: res://Assets/crowd_clap_sheet.png — FRAMES cells wide, TWO rows: the top row ## is the front view, the bottom row is the back view. The crowd shader picks the row ## with FRONT_FACING so spectators show their backs when the camera is behind them. const SRC := "res://Animations/mees_plaksutab.glb" const CLIP := &"ArmatureAction" const OUT := "res://Assets/crowd_clap_sheet.png" const FRAMES := 8 const CELL_H := 192 # sprite cell height in px; width derived from the figure aspect const PAD := 1.12 # framing margin around the figure's bounds const FACE_Z := 6.0 # camera distance in front (+Z looks toward -Z) func _init() -> void: _run.call_deferred() func _run() -> void: var ps := load(SRC) as PackedScene if ps == null: push_error("bake_crowd_sheet: cannot load %s" % SRC) quit(1) return var fig: Node3D = ps.instantiate() var vp := SubViewport.new() vp.own_world_3d = true vp.transparent_bg = true vp.msaa_3d = Viewport.MSAA_4X vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS root.add_child(vp) var world := World3D.new() var env := Environment.new() env.background_mode = Environment.BG_CLEAR_COLOR env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR env.ambient_light_color = Color(1, 1, 1) env.ambient_light_energy = 0.7 world.environment = env vp.world_3d = world var key := DirectionalLight3D.new() key.rotation_degrees = Vector3(-30, 18, 0) key.light_energy = 1.1 vp.add_child(key) vp.add_child(fig) var ap := _find_anim(fig) if ap == null: push_error("bake_crowd_sheet: no AnimationPlayer in %s" % SRC) quit(1) return ap.play(CLIP) ap.pause() var length := ap.get_animation(CLIP).length await process_frame await process_frame # Frame the figure generously from its bind bounds (get_aabb() is the rest pose, so # arms read wide); the tight crop comes from the rendered alpha afterwards. var bounds := _visual_aabb(fig) var center := bounds.get_center() var cap_h := bounds.size.y * 1.25 var cap_w := maxf(bounds.size.x, bounds.size.y) * 1.15 var vw := int(round(CELL_H * (cap_w / cap_h))) vp.size = Vector2i(vw, CELL_H) var cam := Camera3D.new() cam.projection = Camera3D.PROJECTION_ORTHOGONAL cam.size = cap_h # KEEP_HEIGHT: vertical world extent cam.current = true vp.add_child(cam) # Render the loop from the front (camera at +Z, a Camera3D looks down its own -Z) and # from the back (camera at -Z, yawed 180° to look back at the figure). cam.position = center + Vector3(0, 0, FACE_Z) cam.rotation = Vector3.ZERO var fronts: Array = await _grab_frames(vp, ap, length) cam.position = center + Vector3(0, 0, -FACE_Z) cam.rotation = Vector3(0, PI, 0) var backs: Array = await _grab_frames(vp, ap, length) # One shared tight crop across every front and back pose so the cells line up. var all := fronts.duplicate() all.append_array(backs) var box := _alpha_bounds(all, 0.12) if box.size == Vector2i.ZERO: push_error("bake_crowd_sheet: rendered frames are fully transparent — check framing/facing") quit(1) return # One pixel of transparent margin so filtering doesn't bleed a hard edge. box = box.grow(1).intersection(Rect2i(0, 0, vw, CELL_H)) var cw := box.size.x var ch := box.size.y var sheet := Image.create(cw * FRAMES, ch * 2, false, Image.FORMAT_RGBA8) sheet.fill(Color(0, 0, 0, 0)) for i in FRAMES: sheet.blit_rect(fronts[i], box, Vector2i(i * cw, 0)) # top row = front sheet.blit_rect(backs[i], box, Vector2i(i * cw, ch)) # bottom row = back var err := sheet.save_png(OUT) if err != OK: push_error("bake_crowd_sheet: save_png failed (%d)" % err) quit(1) return print("baked %s %dx%d (%d frames x 2 rows, cell %dx%d, aspect %.3f, figure %.2fm tall)" % [ OUT, sheet.get_width(), sheet.get_height(), FRAMES, cw, ch, float(cw) / float(ch), bounds.size.y]) quit(0) # Render every clap pose from the currently-positioned camera into an RGBA8 image array. func _grab_frames(vp: SubViewport, ap: AnimationPlayer, length: float) -> Array: var out: Array[Image] = [] for i in FRAMES: ap.seek((float(i) / FRAMES) * length, true) await process_frame await RenderingServer.frame_post_draw var img := vp.get_texture().get_image() img.convert(Image.FORMAT_RGBA8) out.append(img) return out # Union bounding box of pixels with alpha above `thresh` across every frame — the # tight crop that holds the whole clap loop. func _alpha_bounds(frames: Array[Image], thresh: float) -> Rect2i: var w := frames[0].get_width() var h := frames[0].get_height() var min_x := w var min_y := h var max_x := -1 var max_y := -1 for img in frames: for y in h: for x in w: if img.get_pixel(x, y).a > thresh: min_x = mini(min_x, x) min_y = mini(min_y, y) max_x = maxi(max_x, x) max_y = maxi(max_y, y) if max_x < 0: return Rect2i() return Rect2i(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1) func _find_anim(n: Node) -> AnimationPlayer: if n is AnimationPlayer: return n as AnimationPlayer for c in n.get_children(): var r := _find_anim(c) if r != null: return r return null func _visual_aabb(n: Node) -> AABB: var acc := AABB() var have := false for mi in _all_mesh_instances(n): var waabb: AABB = (mi as MeshInstance3D).global_transform * (mi as MeshInstance3D).get_aabb() acc = waabb if not have else acc.merge(waabb) have = true return acc func _all_mesh_instances(n: Node) -> Array: var out: Array = [] if n is MeshInstance3D: out.append(n) for c in n.get_children(): out.append_array(_all_mesh_instances(c)) return out