120 lines
4.8 KiB
GDScript
120 lines
4.8 KiB
GDScript
extends MultiMeshInstance3D
|
|
## A whole stand of cheering spectators rendered as flat 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 comes from CrowdMarker nodes the designer drops into the level (collected
|
|
## via the &"crowd_marker" group): each marker gives one spectator its world position and
|
|
## a chosen facing (one of six 60°-spaced headings). If no markers are present the scene
|
|
## falls back to a generated tribune so it still populates.
|
|
|
|
const SHEET_PATH := "res://Assets/crowd_clap_sheet.png"
|
|
const SHADER_PATH := "res://crowd_billboard.gdshader"
|
|
|
|
## 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
|
|
# Deferred so every CrowdMarker has run _ready and joined the group before we harvest.
|
|
_build.call_deferred()
|
|
|
|
|
|
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
|
|
|
|
# Each seat is a (global feet-position, unit forward) pair.
|
|
var positions := PackedVector3Array()
|
|
var facings: Array[Vector3] = []
|
|
_collect_seats(positions, facings)
|
|
|
|
var mm := MultiMesh.new()
|
|
mm.transform_format = MultiMesh.TRANSFORM_3D
|
|
mm.use_custom_data = true
|
|
mm.mesh = quad
|
|
mm.instance_count = positions.size()
|
|
|
|
for i in positions.size():
|
|
var s := 1.0 + _rng.randf_range(-0.06, 0.06) # slight height variety
|
|
mm.set_instance_transform(i, Transform3D(_upright_basis(facings[i], s), positions[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
|
|
|
|
|
|
# Upright basis whose forward (+Z, the quad's face) points along `look`, uniformly
|
|
# scaled by `s`. World +Y stays up so spectators never tilt.
|
|
func _upright_basis(look: Vector3, s: float) -> Basis:
|
|
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 basis := Basis()
|
|
basis.x = up.cross(look).normalized()
|
|
basis.y = up
|
|
basis.z = look
|
|
return basis.scaled(Vector3.ONE * s)
|
|
|
|
|
|
# Seats from designer-placed CrowdMarker nodes: each contributes its world position and
|
|
# its chosen 60°-spaced facing. With no markers present, fall back to a generated ring
|
|
# that faces inward so the scene still shows a crowd.
|
|
func _collect_seats(positions: PackedVector3Array, facings: Array[Vector3]) -> void:
|
|
for node in get_tree().get_nodes_in_group(&"crowd_marker"):
|
|
var marker := node as Node3D
|
|
if marker == null or not marker.has_method(&"facing_dir"):
|
|
continue
|
|
positions.append(marker.global_position)
|
|
facings.append(marker.facing_dir())
|
|
if positions.is_empty():
|
|
_fallback_stand(positions, facings)
|
|
|
|
|
|
func _fallback_stand(positions: PackedVector3Array, facings: Array[Vector3]) -> 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)
|
|
var pos := Vector3(cos(ang) * radius, y, sin(ang) * radius)
|
|
positions.append(pos)
|
|
facings.append(-pos) # face the ring centre (origin)
|
|
placed += 1
|