Files
CosmicClash/Game/tools/gpu_profile_harness.gd
Josh Creek b43ad207c1 docs(multiplayer): split spec into MULTIPLAYER_SPEC.md, trim task doc to outstanding work
multiplayer-next.md was a 1662-line mix of standing architecture spec
and task-completion tracking, most of which was dense per-task DONE
evidence for finished Phases 0-6. Split it:

- MULTIPLAYER_SPEC.md (new): the locked architecture decisions, wire
  format, server-side input handling, prediction/reconciliation,
  latency/frame-rate budget, and match lifecycle state machine -
  standing design reference, not task-tracked.
- multiplayer-next.md (trimmed 1662 -> ~370 lines): only outstanding
  work remains - §0 status, §7 Phase 7/8 task tables condensed to
  "what's left" per task, §8-11 reference material (refactoring notes,
  gotchas, testing, flagged items). Phases 0-6 collapsed to a pointer
  at git history instead of ~500 lines of DONE evidence.

Also:
- Repointed every `multiplayer-next.md §N` code comment (N 1-6) across
  Game/scripts, Game/tools and Game/tests to MULTIPLAYER_SPEC.md, since
  those sections moved. Task-number references (`task N.N`, §7-11)
  correctly still point at multiplayer-next.md.
- Updated CLAUDE.md's doc index and docs/TECH_STACK.md's spec-section
  citations to match.
- TODO.md: added a "what's left to actually finish multiplayer
  (human-actionable)" checklist pulled from multiplayer-next.md §0 and
  docs/MATCHMAKING.md - things that need a person (hardware, a design
  decision, a Steam App ID, hands on a controller), not more agent code.
2026-09-04 22:43:13 +01:00

183 lines
6.4 KiB
GDScript

extends Node
# One-off GPU frame-time profiling harness for task 0.15b's real-hardware
# follow-up (MULTIPLAYER_SPEC.md §5.5.1) — the automated Mac passes gave
# inconsistent, sometimes implausible numbers (stale-process contention,
# and Apple Silicon's tile-based GPU architecture is a poor stand-in for the
# target reference hardware). Run this directly on a machine with a real
# discrete desktop GPU instead:
#
# godot --path Game res://tools/gpu_profile_harness.tscn
#
# On a Linux box with no attached physical display, wrap it in a virtual
# framebuffer so it still gets a real windowing/rendering context (NOT
# --headless — that uses a dummy renderer with no GPU rendering at all,
# see CLAUDE.md's "Headless smoke test" note):
#
# xvfb-run -a --server-args="-screen 0 1920x1080x24" \
# godot --path Game res://tools/gpu_profile_harness.tscn
#
# Prints a report to stdout and also writes it to
# user://gpu_profile_report.txt — on Linux that's typically
# ~/.local/share/godot/app_userdata/Cosmic Clash/gpu_profile_report.txt; the
# exact resolved path is printed at the end of the run, so just paste that
# back. Quits itself automatically when done (~90 seconds total).
const SAMPLE_SECONDS := 4.0
const SETTLE_SECONDS := 1.5
var _match: Node
var _env: Environment
var _shadow_lights: Array[Light3D] = []
var _postfx: CanvasItem
var _viewport: Viewport
var _report_lines: Array[String] = []
func _ready() -> void:
var adapter := RenderingServer.get_video_adapter_name()
var vendor := RenderingServer.get_video_adapter_vendor()
_log("GPU adapter: %s (%s)" % [adapter, vendor])
if not ("NVIDIA" in adapter.to_upper() or "NVIDIA" in vendor.to_upper()):
_log("WARNING: this doesn't look like a real NVIDIA GPU context.")
_log(" If this is llvmpipe/softpipe/Mesa software rendering, every")
_log(" number below is meaningless for GPU profiling purposes —")
_log(" check `glxinfo | grep -i renderer` and your Xorg/Xvfb/driver")
_log(" setup before trusting this report.")
var match_scene := load("res://scenes/match.tscn") as PackedScene
_match = match_scene.instantiate()
# 3v3 = 6 ships, matching the scenario MULTIPLAYER_SPEC.md §5.5 measures.
_match.team_size = 3
# Direct-scene-run fallback path (see match_mode.gd:_make_opponent_controller)
# — gives every AI ship a real trained policy so thruster VFX/movement
# load matches actual play, not six stationary hulls.
_match.bot_model_path = "res://bots/promoted/medium.json"
add_child(_match)
_log("Waiting for kickoff and bots to start moving...")
await get_tree().create_timer(5.0).timeout
_viewport = get_tree().root
var world_env := _match.arena.get_node_or_null("WorldEnvironment") as WorldEnvironment
if not world_env or not world_env.environment:
_log("ERROR: no WorldEnvironment found on the spawned arena — aborting.")
get_tree().quit(1)
return
_env = world_env.environment
for light in _match.arena.find_children("*", "Light3D", true, false):
if (light as Light3D).shadow_enabled:
_shadow_lights.append(light)
var rig := get_tree().get_first_node_in_group("ship_camera")
_postfx = rig.get_node_or_null("PostProcess/PostFX") if rig else null
if not _postfx:
_log("WARNING: PostFX node not found — that pass won't be profiled.")
await _run_all_configs()
var report := "\n".join(_report_lines)
var f := FileAccess.open("user://gpu_profile_report.txt", FileAccess.WRITE)
if f:
f.store_string(report)
f.close()
_log("")
_log("Report written to: %s" % ProjectSettings.globalize_path("user://gpu_profile_report.txt"))
get_tree().quit()
func _log(s: String) -> void:
_report_lines.append(s)
print(s)
func _run_all_configs() -> void:
var base_sdfgi := _env.sdfgi_enabled
var base_ssil := _env.ssil_enabled
var base_ssao := _env.ssao_enabled
var base_glow := _env.glow_enabled
var base_msaa := _viewport.msaa_3d
var base_aa := _viewport.screen_space_aa
var base_postfx_visible: bool = _postfx.visible if _postfx else true
await _measure("baseline_all_on")
_env.sdfgi_enabled = false
_env.ssil_enabled = false
_env.ssao_enabled = false
_env.glow_enabled = false
for l in _shadow_lights:
l.shadow_enabled = false
_viewport.msaa_3d = Viewport.MSAA_DISABLED
_viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED
if _postfx:
_postfx.visible = false
await _measure("all_off_floor")
_restore_baseline(base_sdfgi, base_ssil, base_ssao, base_glow, base_msaa, base_aa, base_postfx_visible)
_env.sdfgi_enabled = false
await _measure("sdfgi_off")
_env.sdfgi_enabled = base_sdfgi
_env.ssil_enabled = false
await _measure("ssil_off")
_env.ssil_enabled = base_ssil
_env.ssao_enabled = false
await _measure("ssao_off")
_env.ssao_enabled = base_ssao
_env.glow_enabled = false
await _measure("glow_off")
_env.glow_enabled = base_glow
for l in _shadow_lights:
l.shadow_enabled = false
await _measure("shadows_off_all_%d_lights" % _shadow_lights.size())
for l in _shadow_lights:
l.shadow_enabled = true
_viewport.msaa_3d = Viewport.MSAA_DISABLED
await _measure("msaa_off")
_viewport.msaa_3d = base_msaa
_viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED
await _measure("fxaa_off")
_viewport.screen_space_aa = base_aa
if _postfx:
_postfx.visible = false
await _measure("postfx_off")
_postfx.visible = base_postfx_visible
func _restore_baseline(sdfgi: bool, ssil: bool, ssao: bool, glow: bool, msaa: Viewport.MSAA, aa: Viewport.ScreenSpaceAA, postfx_visible: bool) -> void:
_env.sdfgi_enabled = sdfgi
_env.ssil_enabled = ssil
_env.ssao_enabled = ssao
_env.glow_enabled = glow
for l in _shadow_lights:
l.shadow_enabled = true
_viewport.msaa_3d = msaa
_viewport.screen_space_aa = aa
if _postfx:
_postfx.visible = postfx_visible
# Raw get_process_delta_time() per rendered frame, not Performance.TIME_FPS —
# TIME_FPS is itself a smoothed/rounded value, which would understate exactly
# the p99 variance this is trying to measure.
func _measure(label: String) -> void:
await get_tree().create_timer(SETTLE_SECONDS).timeout
var samples: PackedFloat32Array = []
var elapsed := 0.0
while elapsed < SAMPLE_SECONDS:
await get_tree().process_frame
var dt := get_process_delta_time()
samples.append(dt * 1000.0)
elapsed += dt
samples.sort()
var p50 := samples[samples.size() / 2]
var p99 := samples[mini(int(samples.size() * 0.99), samples.size() - 1)]
_log("%-28s p50=%6.2fms p99=%6.2fms fps(p50)=%6.1f n=%d" % [label, p50, p99, 1000.0 / p50, samples.size()])