1 Commits

Author SHA1 Message Date
richard 271de1b045 ps1 filter variables, crowds 2026-07-30 15:12:22 +03:00
31 changed files with 872 additions and 17 deletions
+1
View File
@@ -2,6 +2,7 @@ extends Node3D
func _ready() -> void:
add_to_group(&"public") # excluded from debug overlays — see publikum_controller.gd
var anim := get_node_or_null(^"AnimationPlayer") as AnimationPlayer
if anim:
anim.play(&"Juubeldab")
Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d166u18p3wqiy"
path="res://.godot/imported/crowd_clap_sheet.png-a110e7d663af8fcbe9ebf5de44188c85.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://Assets/crowd_clap_sheet.png"
dest_files=["res://.godot/imported/crowd_clap_sheet.png-a110e7d663af8fcbe9ebf5de44188c85.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+20 -8
View File
@@ -175,7 +175,8 @@ horizontal_alignment = 1
[node name="Hint" type="Label" parent="OptionsPanel/VBox"]
layout_mode = 2
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 4
theme_override_constants/outline_size = 5
theme_override_font_sizes/font_size = 20
text = "Click a key, then press a new key. Esc cancels."
horizontal_alignment = 1
@@ -223,17 +224,28 @@ theme_override_font_sizes/font_size = 44
text = "Credits"
horizontal_alignment = 1
[node name="Names" type="Label" parent="CreditsPanel/VBox"]
[node name="Names" type="RichTextLabel" parent="CreditsPanel/VBox"]
layout_mode = 2
size_flags_vertical = 3
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 4
theme_override_font_sizes/font_size = 24
text = "Bullosseum
theme_override_colors/default_color = Color(0.93, 0.88, 0.72, 1)
theme_override_constants/outline_size = 5
theme_override_constants/line_separation = 6
theme_override_font_sizes/normal_font_size = 26
theme_override_font_sizes/bold_font_size = 30
bbcode_enabled = true
fit_content = true
scroll_active = false
text = "[center][font_size=32][color=#d9a833]Bullosseum[/color][/font_size]
A game by Siim & Richard"
horizontal_alignment = 1
vertical_alignment = 1
[b][color=#d9a833]Siim Raud[/color][/b] [url=https://instagram.com/siimpressionist]@siimpressionist[/url]
[font_size=20]Creative, art, idea guy[/font_size]
[b][color=#d9a833]Richard Aasa[/color][/b] [url=https://instagram.com/aasarichard]@aasarichard[/url]
[font_size=20]Programmer[/font_size]
[b][color=#d9a833]Kadi Rebane[/color][/b] [url=https://instagram.com/kadirebane]@kadirebane[/url]
[font_size=20]Arena asset[/font_size][/center]"
[node name="BackButton" type="Button" parent="CreditsPanel/VBox"]
custom_minimum_size = Vector2(0, 48)
+35 -3
View File
@@ -172,6 +172,18 @@ func _input(event: InputEvent) -> void:
if not _is_open:
return
# Ctrl+C clears the input line, Ctrl+L clears the log — familiar shell reflexes.
if ke.ctrl_pressed:
match ke.keycode:
KEY_C:
_clear_input()
get_viewport().set_input_as_handled()
return
KEY_L:
_log.clear()
get_viewport().set_input_as_handled()
return
match ke.keycode:
KEY_TAB:
_accept_completion()
@@ -187,6 +199,20 @@ func _input(event: InputEvent) -> void:
get_viewport().set_input_as_handled()
# Focus can be stolen a frame or more after we re-grab it (submit, arrows, the tree
# unpausing), so a one-shot deferred grab loses the race. Enforce it every frame
# while the console is open — cheap, and it never lets the command line go dark.
func _process(_delta: float) -> void:
if _is_open and not _input_field.has_focus():
# Diagnostic: name whatever currently holds focus before we snatch it back, so
# a persistent thief (a HUD button, a menu control) shows up in the log instead
# of us guessing. Remove once the culprit is identified.
var owner: Control = get_viewport().gui_get_focus_owner()
var who: String = String(owner.name) if owner != null else "<nothing>"
_log_warn("focus stolen by %s — reclaiming" % who)
_input_field.grab_focus()
func _toggle() -> void:
_is_open = not _is_open
get_tree().paused = _is_open
@@ -381,6 +407,15 @@ func _history_step(dir: int) -> void:
_set_input(_pending_line if _history_idx == -1 else _history[_history_idx])
# Wipe the live line (Ctrl+C) without submitting — also drops any suggestion popup
# and resets history browsing so the next ↑ starts fresh.
func _clear_input() -> void:
_input_field.clear()
_history_idx = -1
_pending_line = ""
_clear_suggestions()
func _set_input(text: String) -> void:
_input_field.text = text
_input_field.caret_column = text.length()
@@ -400,9 +435,6 @@ func _on_submitted(raw: String) -> void:
_input_field.clear()
_clear_suggestions()
_execute(raw)
# Defer: the LineEdit finishes handling the Enter keypress after this signal
# returns, so a synchronous grab_focus() gets clobbered. Re-grab next idle.
_input_field.grab_focus.call_deferred()
func _execute(cmd: String) -> void:
+35
View File
@@ -0,0 +1,35 @@
shader_type spatial;
// Flat crowd sprite. Each instance is oriented at build time to face the arena centre
// (see crowd_billboards.gd) — NOT the camera — so spectators on different segments of
// the ring face inward toward the action, like a real stand. Unshaded (the sheet was
// baked with lighting), alpha-tested (no transparency sorting across hundreds of
// quads), nearest-filtered for a crisp PS1 look that matches the ps1_filter effect.
render_mode unshaded, cull_disabled, shadows_disabled, depth_draw_opaque;
uniform sampler2D sheet : source_color, filter_nearest;
uniform int hframes = 8;
uniform float fps = 10.0;
uniform float alpha_clip = 0.4;
varying flat float v_frame;
varying flat vec3 v_tint;
void vertex() {
// Per-instance clap phase (0..1) and tint are packed into the MultiMesh custom data.
// The instance transform already carries the inward facing, so no billboard maths.
float phase = INSTANCE_CUSTOM.x;
v_tint = INSTANCE_CUSTOM.yzw;
v_frame = floor(mod(TIME * fps + phase * float(hframes), float(hframes)));
}
void fragment() {
// Sheet is two rows: top = front, bottom = back. Show the back when the camera is
// behind the spectator (they're facing into the arena, away from us).
float u = (UV.x + v_frame) / float(hframes);
float v = UV.y * 0.5 + (FRONT_FACING ? 0.0 : 0.5);
vec4 c = texture(sheet, vec2(u, v));
if (c.a < alpha_clip) {
discard;
}
ALBEDO = c.rgb * v_tint;
}
+1
View File
@@ -0,0 +1 @@
uid://hkdivctavovb
+137
View File
@@ -0,0 +1,137 @@
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
+1
View File
@@ -0,0 +1 @@
uid://cj0ytlkuavmf4
+4
View File
@@ -405,6 +405,10 @@ func _reconcile(delta: float) -> void:
func _collect(node: Node, col: bool, hit: bool, bone: bool, anim: bool, wanted: Dictionary) -> void:
# The audience ("public") is ambient decoration, not a debug target — skip the whole
# crowd subtree so its clapping AnimationPlayers/skeletons don't flood the overlays.
if node.is_in_group(&"public"):
return
if bone and node is Skeleton3D and (node as Skeleton3D).is_visible_in_tree():
_skeletons.append(node as Skeleton3D)
if anim and node is AnimationPlayer:
+12
View File
@@ -228,6 +228,18 @@ func _register_all() -> void:
_reg_f("Roll", "roll_knockup", 11.0, 0.0, 25.0) # vertical knock-up on the pop
# ── Debug overlays (see debug_draw.gd) ───────────────────────────────────
_reg_b("Debug", "show_collisions", false)
# PS1 post-process filter (see ps1_filter.gd on the fullscreen ColorRect):
# screen-covering CRT/PS1 shader — pixelation, colour-banding + dither, and a
# rolling scanline. Keys map 1:1 onto the shader uniforms; show_ps1 toggles the
# whole overlay. Ranges mirror the shader's hint_range so the console can't push
# a uniform out of bounds.
_reg_b("PS1", "show_ps1", true)
_reg_f("PS1", "ps1_pixel_scale", 4.0, 1.0, 8.0, 1.0)
_reg_f("PS1", "ps1_color_depth", 5.0, 2.0, 8.0, 1.0)
_reg_f("PS1", "ps1_dither_strength", 0.8, 0.0, 2.0, 0.05)
_reg_f("PS1", "ps1_scanline_strength", 0.2, 0.0, 1.0, 0.05)
_reg_f("PS1", "ps1_roll_speed", 3.0, 0.0, 10.0, 0.1)
_reg_f("PS1", "ps1_roll_strength", 1.0, 0.0, 1.0, 0.05)
_reg_b("Debug", "show_hitboxes", false)
_reg_b("Debug", "show_bones", false)
_reg_b("Debug", "show_raycasts", false)
+20 -6
View File
@@ -130,11 +130,24 @@ func _apply_theme() -> void:
if title != null:
title.add_theme_font_override("font", UiFonts.title())
title.add_theme_color_override("font_color", MENU_GOLD)
for path: String in ["OptionsPanel/VBox/Hint", "CreditsPanel/VBox/Names"]:
var lbl := get_node_or_null(path) as Label
if lbl != null:
lbl.add_theme_font_override("font", UiFonts.body())
lbl.add_theme_color_override("font_color", MENU_CREAM)
var hint := get_node_or_null("OptionsPanel/VBox/Hint") as Label
if hint != null:
hint.add_theme_font_override("font", UiFonts.body())
hint.add_theme_color_override("font_color", MENU_CREAM)
_style_credits(get_node_or_null("CreditsPanel/VBox/Names") as RichTextLabel)
# The credits use a RichTextLabel so the Instagram handles are clickable links
# ([url] tags). meta_clicked opens the profile — in the web export OS.shell_open
# pops a new browser tab.
func _style_credits(names: RichTextLabel) -> void:
if names == null:
return
names.add_theme_font_override("normal_font", UiFonts.body())
names.add_theme_font_override("bold_font", UiFonts.title())
names.add_theme_color_override("default_color", MENU_CREAM)
names.meta_clicked.connect(func(meta: Variant) -> void:
OS.shell_open(str(meta)))
func _style_button(btn: Button, big: bool) -> void:
@@ -182,9 +195,10 @@ func _build_rebind_rows() -> void:
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
name_label.add_theme_font_override("font", UiFonts.body())
name_label.add_theme_font_size_override("font_size", 20)
name_label.add_theme_color_override("font_color", MENU_CREAM)
name_label.add_theme_color_override("font_outline_color", Color.BLACK)
name_label.add_theme_constant_override("outline_size", 4)
name_label.add_theme_constant_override("outline_size", 5)
row.add_child(name_label)
var key_button := Button.new()
+36
View File
@@ -0,0 +1,36 @@
extends ColorRect
## Drives the fullscreen PS1/CRT post-process (ps1_filter.gdshader) from the DP
## registry so every uniform is live-tunable in the console. `show_ps1` toggles the
## whole overlay; the ps1_* floats feed the matching shader uniforms 1:1.
# DP param key → shader uniform name. Keys are registered in debug_params.gd with
# ranges that mirror the shader's hint_range, so nothing here can go out of bounds.
const _UNIFORMS := {
"ps1_pixel_scale": "pixel_scale",
"ps1_color_depth": "color_depth",
"ps1_dither_strength": "dither_strength",
"ps1_scanline_strength": "scanline_strength",
"ps1_roll_speed": "roll_speed",
"ps1_roll_strength": "roll_strength",
}
@onready var _mat: ShaderMaterial = material as ShaderMaterial
func _ready() -> void:
DP.any_changed.connect(_on_param_changed)
_apply_all()
# reset_all() fires with key "__all__"; a single edit fires with its own key. Either
# way just re-push everything — it's a handful of cheap uniform writes.
func _on_param_changed(_key: String, _value: Variant) -> void:
_apply_all()
func _apply_all() -> void:
visible = DP.b("show_ps1")
if _mat == null:
return
for key: String in _UNIFORMS:
_mat.set_shader_parameter(_UNIFORMS[key], DP.f(key))
+1
View File
@@ -0,0 +1 @@
uid://ce5n4qu88mhyx
+3
View File
@@ -2,6 +2,9 @@ extends Node3D
func _ready() -> void:
# Mark the crowd so debug overlays (show_animation_name / show_bones) skip it —
# 72 ambient clapping spectators would otherwise flood the view with tags.
add_to_group(&"public")
for child in get_children():
var anim := child.get_node_or_null(^"AnimationPlayer") as AnimationPlayer
if anim:
+4
View File
@@ -14,6 +14,10 @@ echo ""
echo "=== Logic tests ==="
"$GODOT" --headless --script tests/logic_test.gd
echo ""
echo "=== Console focus tests ==="
"$GODOT" --headless --script tests/console_focus_test.gd
echo ""
echo "=== Performance tests (frame-budget regression guard) ==="
"$GODOT" --headless --script tests/performance_test.gd
+6
View File
@@ -11,6 +11,8 @@
[ext_resource type="Script" uid="uid://di0jafakftp3n" path="res://publikum_controller.gd" id="9_pubctrl"]
[ext_resource type="PackedScene" uid="uid://dxe72ig8gfbcg" path="res://Assets/arena model2026.glb" id="10_5juve"]
[ext_resource type="Script" uid="uid://butd6b3tdwxrv" path="res://hud.gd" id="10_hud"]
[ext_resource type="Script" path="res://crowd_billboards.gd" id="11_crowd"]
[ext_resource type="Script" path="res://ps1_filter.gd" id="12_ps1ctl"]
[sub_resource type="CameraAttributesPractical" id="CameraAttributesPractical_73fnb"]
dof_blur_far_enabled = true
@@ -78,6 +80,7 @@ layer = 10
[node name="PS1Filter" type="ColorRect" parent="CanvasLayer" unique_id=1449116873]
material = SubResource("ShaderMaterial_ps1")
script = ExtResource("12_ps1ctl")
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
@@ -378,6 +381,9 @@ transform = Transform3D(0.97025216, 0, 0.24209678, 0, 1, 0, -0.24209678, 0, 0.97
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=755853425]
environment = SubResource("Environment_73fnb")
[node name="CrowdBillboards" type="MultiMeshInstance3D" parent="."]
script = ExtResource("11_crowd")
[editable path="Player"]
[editable path="Player/bull"]
[editable path="Node3D/arena_placeholder_v01"]
+110
View File
@@ -0,0 +1,110 @@
extends SceneTree
## Headless focus tests for the dev console — verifies the command line keeps (or
## reclaims) keyboard focus after the actions that were observed to drop it.
## Run: godot --headless --script tests/console_focus_test.gd
##
## Exit code 0 = all passed, 1 = any failure.
var _passed: int = 0
var _failed: int = 0
func _init() -> void:
_run.call_deferred()
func _run() -> void:
print("=".repeat(60))
print("CONSOLE FOCUS TESTS")
print("=".repeat(60))
await test_focus_after_submit()
await test_focus_after_external_steal()
print("=".repeat(60))
print("Results: %d passed, %d failed" % [_passed, _failed])
print("=".repeat(60))
quit(1 if _failed > 0 else 0)
func _assert_true(condition: bool, desc: String) -> void:
if condition:
_passed += 1
print(" PASS: %s" % desc)
else:
_failed += 1
print(" FAIL: %s" % desc)
func _get_console() -> Node:
var c: Node = root.get_node_or_null("/root/Console")
if c == null:
_assert_true(false, "Console autoload should exist")
return c
# Drive the real path: open via _toggle (pauses the tree, slide tween grabs focus),
# type a command as real key events, submit with a real Enter, and confirm the guard
# keeps the command line focused across the paused submit.
func test_focus_after_submit() -> void:
print("\n-- test_focus_after_submit --")
var menu := _get_console()
if menu == null:
return
menu._toggle()
# Let the open slide tween finish and its grab_focus callback fire.
for _i in 20:
await process_frame
_assert_true(paused, "tree paused while console open")
_assert_true(menu._input_field.has_focus(), "input has focus after open")
menu._input_field.text = "clear"
menu._input_field.caret_column = 5
_press(KEY_ENTER)
# The submit and any focus theft settle over the next few idle frames.
for _i in 5:
await process_frame
_assert_true(menu._input_field.has_focus(), "input still has focus after submit")
menu._toggle()
for _i in 20:
await process_frame
# Push a key down+up through the viewport so LineEdit / _input handle it for real.
func _press(keycode: Key) -> void:
var down := InputEventKey.new()
down.keycode = keycode
down.physical_keycode = keycode
down.pressed = true
root.push_input(down)
var up := InputEventKey.new()
up.keycode = keycode
up.physical_keycode = keycode
up.pressed = false
root.push_input(up)
# Simulate anything yanking focus away mid-session (a stray click, a UI button); the
# guard in _process must pull it straight back while the console is open.
func test_focus_after_external_steal() -> void:
print("\n-- test_focus_after_external_steal --")
var menu := _get_console()
if menu == null:
return
menu._is_open = true
menu._panel.visible = true
menu._input_field.grab_focus()
await process_frame
menu._input_field.release_focus()
_assert_true(not menu._input_field.has_focus(), "focus released (precondition)")
await process_frame
_assert_true(menu._input_field.has_focus(), "guard reclaims focus next frame")
menu._is_open = false
menu._panel.visible = false
+1
View File
@@ -0,0 +1 @@
uid://n6ymmhlqu6wp
+186
View File
@@ -0,0 +1,186 @@
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
+1
View File
@@ -0,0 +1 @@
uid://baa6bdq01fq20
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bx5urw8lnyisv"
path="res://.godot/imported/crowd_a.png-03adfaf2f7c0710faf98c43dece7bece.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tools/preview/crowd_a.png"
dest_files=["res://.godot/imported/crowd_a.png-03adfaf2f7c0710faf98c43dece7bece.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://4dauthuuuiyn"
path="res://.godot/imported/crowd_b.png-6a94563a7a2b29b054811408ccbfe711.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tools/preview/crowd_b.png"
dest_files=["res://.godot/imported/crowd_b.png-6a94563a7a2b29b054811408ccbfe711.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d16gmpff1kvwk"
path="res://.godot/imported/scene_crowd.png-79df05acf2fd99f031d42e7312b69019.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tools/preview/scene_crowd.png"
dest_files=["res://.godot/imported/scene_crowd.png-79df05acf2fd99f031d42e7312b69019.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+54
View File
@@ -0,0 +1,54 @@
extends SceneTree
## Quick visual check for the billboard crowd: builds the generated fallback stand and
## captures two frames a moment apart (so the flipbook visibly advances) from an angle
## (so we can confirm the sprites turn to face the camera).
##
## godot --script tools/preview_crowd.gd
## Output: tools/preview/crowd_a.png, crowd_b.png
const OUT := "res://tools/preview"
func _init() -> void:
_run.call_deferred()
func _run() -> void:
DirAccess.make_dir_recursive_absolute(OUT)
var we := WorldEnvironment.new()
var env := Environment.new()
env.background_mode = Environment.BG_COLOR
env.background_color = Color(0.10, 0.12, 0.16)
we.environment = env
root.add_child(we)
var crowd := MultiMeshInstance3D.new()
crowd.set_script(load("res://crowd_billboards.gd"))
crowd.set("source_path", NodePath()) # force the generated fallback stand
crowd.set("fallback_count", 90)
root.add_child(crowd)
var cam := Camera3D.new()
cam.fov = 62.0
cam.current = true
root.add_child(cam)
# Outside the ring edge, elevated, looking across the centre: the NEAR arc has its
# back to us (facing inward), the FAR arc faces us — so this shot proves both the
# per-segment inward facing and the front/back sheet selection.
cam.look_at_from_position(Vector3(0.0, 16.0, 44.0), Vector3(0.0, 3.0, 0.0), Vector3.UP)
await create_timer(0.15).timeout
_shot("crowd_a.png")
await create_timer(0.45).timeout # ~45 clap frames later
_shot("crowd_b.png")
quit(0)
func _shot(name: String) -> void:
var img := root.get_viewport().get_texture().get_image()
if img == null:
print("capture failed: ", name)
return
img.save_png(OUT + "/" + name)
print("captured ", name, " ", img.get_width(), "x", img.get_height())
+1
View File
@@ -0,0 +1 @@
uid://bsekj2pxutdqr
+42
View File
@@ -0,0 +1,42 @@
extends SceneTree
## Renders the real arena (scene.tscn) with the billboard crowd wired in, from a camera
## near the arena centre, to confirm the stands populate and face inward in-game.
## godot --script tools/preview_crowd_scene.gd
## Output: tools/preview/scene_crowd.png
const OUT := "res://tools/preview"
func _init() -> void:
_run.call_deferred()
func _run() -> void:
DirAccess.make_dir_recursive_absolute(OUT)
var ps := load("res://scene.tscn") as PackedScene
var scene := ps.instantiate()
root.add_child(scene)
current_scene = scene
await create_timer(0.4).timeout
var cam := Camera3D.new()
cam.fov = 74.0
scene.add_child(cam)
# Out past one stand looking across the arena: the near crowd has its back to us,
# the far crowd faces us — the case the front/back sheet must get right.
cam.look_at_from_position(Vector3(0.0, 9.0, -22.0), Vector3(0.0, 4.0, 6.0), Vector3.UP)
cam.current = true
await create_timer(0.3).timeout
var crowd := scene.get_node_or_null(^"CrowdBillboards") as MultiMeshInstance3D
if crowd != null and crowd.multimesh != null:
print("crowd instances = ", crowd.multimesh.instance_count)
else:
print("crowd node/multimesh missing")
var img := root.get_viewport().get_texture().get_image()
if img != null:
img.save_png(OUT + "/scene_crowd.png")
print("captured scene_crowd.png ", img.get_width(), "x", img.get_height())
quit(0)
+1
View File
@@ -0,0 +1 @@
uid://bapd1uxqlkrmq