extends MultiMeshInstance3D ## A whole stand of cheering spectators rendered as camera-facing billboards in a ## single draw call — the performant replacement for dozens of skinned crowd skeletons. ## The sprite sheet is baked from the existing crowd figure by tools/bake_crowd_sheet.gd. ## ## Placement mirrors an existing crowd node (default: the sibling PublikNode) so the ## billboards land exactly where the crowd was authored; if that node is missing it ## falls back to a generated tribune so the scene still populates. const SHEET_PATH := "res://Assets/crowd_clap_sheet.png" const SHADER_PATH := "res://crowd_billboard.gdshader" ## Node whose descendant figures (names starting with `figure_prefix`) donate their ## world positions. Empty = use the generated fallback stand. @export var source_path: NodePath = ^"../PublikNode" @export var figure_prefix: String = "mees" ## World height of a spectator billboard, in metres. Sheet aspect sets the width. @export var figure_height: float = 2.0 @export var frames: int = 8 @export var clap_fps: float = 10.0 ## Subtle per-spectator brightness spread so the crowd doesn't read as clones. @export_range(0.0, 0.5) var tint_variation: float = 0.18 # Fallback tribune (used only when source_path resolves to nothing) — concentric arcs # of seats ringing the arena. @export_group("Fallback stand") @export var fallback_count: int = 120 @export var fallback_inner_radius: float = 26.0 @export var fallback_rows: int = 6 @export var fallback_row_rise: float = 1.6 @export var fallback_row_step: float = 2.2 @export var fallback_seat_step: float = 2.4 var _rng := RandomNumberGenerator.new() func _ready() -> void: _rng.seed = hash("bullosseum-crowd") # deterministic layout/phases across runs _build() func _build() -> void: var sheet := load(SHEET_PATH) as Texture2D if sheet == null: push_warning("crowd_billboards: missing sheet %s — run tools/bake_crowd_sheet.gd" % SHEET_PATH) return # Sheet holds `frames` columns and 2 rows (front / back), so a cell is half-height. var aspect := (float(sheet.get_width()) / float(frames)) / (float(sheet.get_height()) / 2.0) var quad := QuadMesh.new() quad.size = Vector2(figure_height * aspect, figure_height) quad.center_offset = Vector3(0.0, figure_height * 0.5, 0.0) # pivot at the feet var mat := ShaderMaterial.new() mat.shader = load(SHADER_PATH) as Shader mat.set_shader_parameter("sheet", sheet) mat.set_shader_parameter("hframes", frames) mat.set_shader_parameter("fps", clap_fps) material_override = mat var seats := _seat_positions() var center := _arena_center(seats) var mm := MultiMesh.new() mm.transform_format = MultiMesh.TRANSFORM_3D mm.use_custom_data = true mm.mesh = quad mm.instance_count = seats.size() for i in seats.size(): var s := 1.0 + _rng.randf_range(-0.06, 0.06) # slight height variety mm.set_instance_transform(i, Transform3D(_facing_basis(seats[i], center, s), seats[i])) var b := 1.0 + _rng.randf_range(-tint_variation, tint_variation) mm.set_instance_custom_data(i, Color(_rng.randf(), b, b * 0.99, b * 0.98)) multimesh = mm # Orientation for a seat: the sprite's forward (+Z, the quad's face) points horizontally # inward at the arena centre, so each ring segment faces the action instead of the # camera. Upright (world +Y), uniformly scaled by `s`. func _facing_basis(seat: Vector3, center: Vector3, s: float) -> Basis: var look := center - seat look.y = 0.0 look = look.normalized() if look.length() > 0.001 else Vector3(0.0, 0.0, -1.0) var up := Vector3.UP var right := up.cross(look).normalized() var basis := Basis() basis.x = right basis.y = up basis.z = look return basis.scaled(Vector3.ONE * s) # Horizontal centre the crowd faces — the mean of the seat positions (the ring/stand # centroid), which for an arena crowd is the middle of the pitch. func _arena_center(seats: PackedVector3Array) -> Vector3: if seats.is_empty(): return global_position var sum := Vector3.ZERO for p in seats: sum += p return sum / float(seats.size()) # Global feet-positions for every spectator: harvested from the source crowd if present, # otherwise the generated fallback tribune. func _seat_positions() -> PackedVector3Array: var out := PackedVector3Array() var src := get_node_or_null(source_path) if src != null: _collect_figures(src, out) if out.is_empty(): _fallback_stand(out) return out func _collect_figures(node: Node, out: PackedVector3Array) -> void: if node is Node3D and node != self and (node as Node3D).name.begins_with(figure_prefix): out.append((node as Node3D).global_position) return # don't descend into a figure's own rig for c in node.get_children(): _collect_figures(c, out) func _fallback_stand(out: PackedVector3Array) -> void: var placed := 0 for row in fallback_rows: var radius := fallback_inner_radius + float(row) * fallback_row_step var y := float(row) * fallback_row_rise var circumference := TAU * radius var seats_in_row := int(circumference / fallback_seat_step) for s in seats_in_row: if placed >= fallback_count: return var ang := TAU * float(s) / float(seats_in_row) out.append(Vector3(cos(ang) * radius, y, sin(ang) * radius)) placed += 1