chore(multiplayer): Phase 0 refactors + graphics/perf settings groundwork

Lands the non-networked Phase 0 tasks from multiplayer-todo.md (ship/camera/
arena refactors, sim constants, background FPS handling) plus a first pass
at exposing graphics/performance settings (presets, resolution scaling,
vsync, FPS cap, perf overlay) and a GPU profiling harness for the
real-hardware follow-up in task 0.15b.
This commit is contained in:
Josh Creek
2026-08-19 22:37:17 +01:00
parent 88591e031f
commit 04691aaa48
29 changed files with 2212 additions and 134 deletions
+111 -8
View File
@@ -24,6 +24,15 @@ signal impact_feedback(intensity: float)
@export_group("Impact Shake")
@export var max_shake_offset := 0.32
@export var shake_decay := 8.0
@export_group("Impact Punch")
# Replaces Engine.time_scale hit-stop / goal slow-mo (see game_mode.gd):
# a camera-only FOV kick + PostFX flash that decays over real time, so it
# works identically for hit-stop and goal moments without touching global
# simulation speed — which a networked client could never do to a shared sim.
@export var punch_fov_kick := 9.0
@export var punch_vignette_kick := 0.28
@export var punch_chroma_kick := 0.012
@export var punch_decay := 5.0
var target: Ship:
set(value):
@@ -33,6 +42,16 @@ var target: Ship:
target.ball_contact.disconnect(_on_target_ball_contact)
target = value
_connect_target()
# Priming: a freshly assigned target's Visual has no interpolation
# history yet (or is about to be reparented mid-spawn), and the rig
# itself would otherwise lerp in from wherever it was previously
# (world origin on first spawn, the old target on a Spectate switch)
# over camera_smoothing seconds. Both read as a visible swoop/smear;
# neither is a real camera move.
if is_instance_valid(target) and is_instance_valid(target.visual):
target.visual.reset_physics_interpolation()
if is_inside_tree():
snap_to_target()
var ball_cam_enabled := true
@onready var camera: Camera3D = $Camera3D
@@ -50,14 +69,22 @@ var _shake_strength := 0.0
var _shake_noise := FastNoiseLite.new()
var _shake_time := 0.0
var _last_shake_offset := Vector3.ZERO
var _punch_strength := 0.0
var _goal_cut_active := false
var _goal_cut_position := Vector3.ZERO
var _goal_cut_look_at := Vector3.ZERO
const SHAKE_UPDATE_HZ := 60.0
func _ready():
# Group lets the HUD discover the rig for the camera-mode instrument
add_to_group("ship_camera")
# The rig moves itself every rendered frame in _process now (task 0.16),
# not on the physics tick — Godot's built-in physics interpolation would
# otherwise smooth between _physics_process-era transforms this rig no
# longer writes, fighting the manual smoothing below.
physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF
camera.fov = base_fov
_effective_distance = camera_distance
_shake_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
@@ -83,7 +110,7 @@ func _input(event):
camera_mode_changed.emit(ball_cam_enabled)
func _physics_process(delta):
func _process(delta):
# Camera position smoothing operates on the unshaken chase position. Remove
# last frame's presentation-only offset first so shake never accumulates or
# exceeds its configured amplitude during a low-time-scale hit-stop.
@@ -93,6 +120,10 @@ func _physics_process(delta):
return
if _goal_cut_active:
_smooth_look_at(_goal_cut_look_at, delta)
# The cinematic cut freezes camera position, but leftover shake from
# an impact right before the goal must still bleed off in real time —
# otherwise it resumes at full pre-cut magnitude when play returns.
_decay_shake(delta)
return
_update_speed_feel(delta)
var ball := _get_ball()
@@ -100,6 +131,7 @@ func _physics_process(delta):
_update_ball_cam(delta, ball)
else:
_update_ship_cam(delta)
_apply_punch(delta)
func _get_ball() -> Node3D:
@@ -112,7 +144,14 @@ func _update_ball_cam(delta, ball: Node3D):
# Ball cam keeps the ship between the camera and the ball: the camera sits
# on the ball→ship line (horizontal component only), looking at the ball,
# so the ship stays low-centre in frame and the ball stays centred.
var ship_pos: Vector3 = target.global_transform.origin
# Reads target.visual, not target itself: once prediction correction
# (task 0.14) lands, the body can be offset from what's on screen — the
# camera must always frame what the player sees, not the collider.
# get_global_transform_interpolated() (not global_transform) because this
# now runs in _process: the physics body only moves once per 60Hz tick,
# so sampling its raw transform every rendered frame at 240fps would
# repeat the same value 4 times in a row and read as stutter.
var ship_pos: Vector3 = target.visual.get_global_transform_interpolated().origin
var ball_pos: Vector3 = ball.global_transform.origin
# Smooth the orbit direction in angle space rather than lerping the camera
@@ -145,9 +184,12 @@ func _update_ball_cam(delta, ball: Node3D):
func _update_ship_cam(delta):
# In ship cam, camera follows and looks in the same direction as the ship
var ship_pos: Vector3 = target.global_transform.origin
var ship_forward: Vector3 = -target.global_transform.basis.z
# In ship cam, camera follows and looks in the same direction as the ship.
# Reads target.visual, not target itself, via the interpolated transform —
# see _update_ball_cam.
var visual_xform := target.visual.get_global_transform_interpolated()
var ship_pos: Vector3 = visual_xform.origin
var ship_forward: Vector3 = -visual_xform.basis.z
# Position camera behind and above the ship
var camera_target_pos := ship_pos - ship_forward * _effective_distance + Vector3.UP * camera_height
@@ -189,6 +231,7 @@ func _update_speed_feel(delta: float) -> void:
func _on_target_ball_contact(intensity: float, _world_position: Vector3) -> void:
_shake_strength = maxf(_shake_strength, clampf(intensity, 0.0, 1.0))
_punch_strength = maxf(_punch_strength, clampf(intensity, 0.0, 1.0))
for device in Input.get_connected_joypads():
Input.start_joy_vibration(
device, lerpf(0.18, 0.65, intensity), lerpf(0.32, 1.0, intensity),
@@ -201,17 +244,77 @@ func _apply_shake(delta: float) -> void:
if _shake_strength <= 0.001:
_shake_strength = 0.0
return
_shake_time += delta * 60.0
_shake_time += delta
# Quantized to a fixed 60Hz cadence rather than sampled once per rendered
# frame. The noise domain's total distance travelled per second is the
# same either way, but that's not what "reads the same" means here: at
# 60fps, consecutive samples are frequency (2.5) domain-units apart —
# far enough that FastNoiseLite's simplex correlation has decayed, so it
# reads as sharp, uncorrelated jitter. At 240fps the same per-second
# distance is split across 4x the samples, so consecutive samples are
# ~4x closer together and highly correlated — a completely different,
# much gentler wobble. Freezing the domain input to whole 60Hz ticks
# makes every render frame within one tick reuse the exact same sample,
# so the perceived shake texture is identical at any frame rate.
var tick: float = floori(_shake_time * SHAKE_UPDATE_HZ)
var amplitude := max_shake_offset * _shake_strength * _shake_strength
var noise := Vector2(
_shake_noise.get_noise_1d(_shake_time),
_shake_noise.get_noise_1d(_shake_time + 100.0)
_shake_noise.get_noise_1d(tick),
_shake_noise.get_noise_1d(tick + 100.0)
).limit_length(1.0) * amplitude
_last_shake_offset = camera.global_basis.x * noise.x + camera.global_basis.y * noise.y
camera.global_position += _last_shake_offset
_decay_shake(delta)
func _decay_shake(delta: float) -> void:
_shake_strength = move_toward(_shake_strength, 0.0, shake_decay * delta)
# Additive FOV kick + PostFX flash on top of _update_speed_feel's base
# values, decaying over real time. Replaces the weight that Engine.time_scale
# hit-stop used to sell on ball impact — see the Impact Punch export group.
func _apply_punch(delta: float) -> void:
if _punch_strength <= 0.001:
_punch_strength = 0.0
return
camera.fov += punch_fov_kick * _punch_strength
var chroma: float = post_material.get_shader_parameter("chromatic_aberration")
var vignette: float = post_material.get_shader_parameter("vignette_strength")
post_material.set_shader_parameter("chromatic_aberration", chroma + punch_chroma_kick * _punch_strength)
post_material.set_shader_parameter("vignette_strength", vignette + punch_vignette_kick * _punch_strength)
_punch_strength = move_toward(_punch_strength, 0.0, punch_decay * delta)
# Places the camera at its resting chase position instantly, bypassing
# camera_smoothing/orbit_smoothing/look_smoothing entirely. Used whenever the
# thing being framed just teleported (kickoff, target reassignment) — without
# this the rig would lerp smoothly across the whole arena over
# camera_smoothing seconds, which reads as an unintended camera move rather
# than a reset.
func snap_to_target() -> void:
if not is_instance_valid(target) or not is_instance_valid(camera):
return
_shake_strength = 0.0
camera.global_position -= _last_shake_offset
_last_shake_offset = Vector3.ZERO
var visual_xform := target.visual.get_global_transform_interpolated()
var ship_pos: Vector3 = visual_xform.origin
var ball := _get_ball()
if ball_cam_enabled and ball:
var ball_pos: Vector3 = ball.global_transform.origin
var flat := Vector3(ship_pos.x - ball_pos.x, 0.0, ship_pos.z - ball_pos.z)
_orbit_dir = flat.normalized() if flat.length() > 0.01 else Vector3.BACK
camera.global_position = ship_pos + _orbit_dir * _effective_distance + Vector3.UP * camera_height
camera.global_position.y = maxf(camera.global_position.y, min_camera_height)
camera.look_at(ball_pos + Vector3.UP * 0.5, Vector3.UP)
else:
var ship_forward: Vector3 = -visual_xform.basis.z
camera.global_position = ship_pos - ship_forward * _effective_distance + Vector3.UP * camera_height
camera.global_position.y = maxf(camera.global_position.y, min_camera_height)
camera.look_at(ship_pos + ship_forward * 10.0, Vector3.UP)
func begin_goal_cut(goal_position: Vector3) -> void:
_goal_cut_active = true
# The hard cut replaces the chase camera's presentation offset entirely;