Files
CosmicClash/Game/scripts/video_settings.gd
T
Josh Creek 04691aaa48 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.
2026-08-19 22:37:17 +01:00

240 lines
9.6 KiB
GDScript

extends Node
# Autoload: persisted player-facing video preferences (preset, AA, glow,
# brightness, vsync, fps cap, resolution scale). AA/vsync/fps-cap/resolution
# scale are Viewport- or DisplayServer-wide and applied immediately. Glow and
# brightness instead scale each arena's own tuned Environment values (see
# arena.gd's _ready(), which calls apply_to_environment() once per arena
# load) rather than overwriting them outright, so the per-arena bloom tuning
# in arena_01/02/03.tscn survives underneath the user's preference.
signal settings_changed # Arenas re-apply preset-gated Environment/light state live.
# MSAA_2X appended at the end, not inserted, so existing user://settings.cfg
# files (which store this as a bare integer ordinal) keep meaning the same
# thing after this rung was added — see task 0.19.
enum AAMode { OFF, FXAA, MSAA, MSAA_FXAA, MSAA_2X }
# Ordinals are persisted the same way as AAMode above — append only.
enum Preset { LOW, MEDIUM, HIGH, CUSTOM }
enum VsyncMode { DISABLED, ENABLED, ADAPTIVE }
const SETTINGS_PATH := "user://settings.cfg"
# preset -> bundle applied to the individual fields below. CUSTOM has no
# bundle: selecting it just stops future preset changes from overwriting
# whatever the individual fields currently hold. Task 0.15b's measured
# per-effect costs (multiplayer-todo.md §5.5.1) were too noisy to rank these
# against each other, so each rung is "meaningfully fewer full-screen passes
# than the one above it" rather than a precisely tuned ladder.
const PRESET_BUNDLES := {
Preset.LOW: {
"sdfgi_enabled": false, "ssil_enabled": false, "ssao_enabled": false,
"shadows_enabled": false, "glow_enabled": false, "aa_mode": AAMode.OFF,
"resolution_scale": 0.8,
},
Preset.MEDIUM: {
"sdfgi_enabled": false, "ssil_enabled": false, "ssao_enabled": true,
"shadows_enabled": true, "glow_enabled": true, "aa_mode": AAMode.FXAA,
"resolution_scale": 1.0,
},
Preset.HIGH: {
"sdfgi_enabled": true, "ssil_enabled": true, "ssao_enabled": true,
"shadows_enabled": true, "glow_enabled": true, "aa_mode": AAMode.FXAA,
"resolution_scale": 1.0,
},
}
var preset: Preset = Preset.HIGH
var sdfgi_enabled: bool = true
var ssil_enabled: bool = true
var ssao_enabled: bool = true
var shadows_enabled: bool = true
var glow_enabled: bool = true
# FXAA alone, not MSAA_FXAA: 4x MSAA *and* FXAA stacked is redundant blur for
# most scenes and costs more than either alone (see multiplayer-todo.md 0.19).
var aa_mode: AAMode = AAMode.FXAA
var glow_scale: float = 1.0
var brightness: float = 1.0
# 0.17b: Viewport.scaling_3d_scale, 0.5-1.0. Distinct from window stretch
# (0.17c) — this scales the 3D viewport's own internal render resolution
# before the fixed-1080p blit, so it works regardless of the stretch
# decision. FSR2 rather than bilinear: a fixed-1080p target already discards
# native resolution (see 0.17c), so FSR2's per-pixel sharpening recovers more
# of that loss than a plain bilinear upscale at the same internal scale.
var resolution_scale: float = 1.0
var fsr_sharpness: float = 0.2
var vsync_mode: VsyncMode = VsyncMode.ADAPTIVE
# 0 = uncapped; otherwise divides DisplayServer.screen_get_refresh_rate() at
# apply time (not stored as a raw fps number) so the same preference re-derives
# correctly if the game later runs on a different-refresh-rate display. Only
# takes effect when vsync_mode == DISABLED — vsync itself already caps to the
# refresh rate (or an unpredictable multiple of it, for ADAPTIVE) otherwise.
var fps_cap_divisor: int = 0
var _applying_preset := false
func _ready() -> void:
_load()
# A headless server never renders; applying any of this to its root
# viewport or window is pure waste (mirrors the same guard at ship.gd and
# arena_boundary.gd).
if DisplayServer.get_name() != "headless":
apply_aa()
apply_resolution_scale()
apply_vsync()
func _load() -> void:
var cfg := ConfigFile.new()
if cfg.load(SETTINGS_PATH) != OK:
return
preset = cfg.get_value("video", "preset", preset) as Preset
sdfgi_enabled = cfg.get_value("video", "sdfgi_enabled", sdfgi_enabled)
ssil_enabled = cfg.get_value("video", "ssil_enabled", ssil_enabled)
ssao_enabled = cfg.get_value("video", "ssao_enabled", ssao_enabled)
shadows_enabled = cfg.get_value("video", "shadows_enabled", shadows_enabled)
glow_enabled = cfg.get_value("video", "glow_enabled", glow_enabled)
aa_mode = cfg.get_value("video", "aa_mode", aa_mode) as AAMode
glow_scale = cfg.get_value("video", "glow_scale", glow_scale)
brightness = cfg.get_value("video", "brightness", brightness)
resolution_scale = cfg.get_value("video", "resolution_scale", resolution_scale)
fsr_sharpness = cfg.get_value("video", "fsr_sharpness", fsr_sharpness)
vsync_mode = cfg.get_value("video", "vsync_mode", vsync_mode) as VsyncMode
fps_cap_divisor = cfg.get_value("video", "fps_cap_divisor", fps_cap_divisor)
func save() -> void:
var cfg := ConfigFile.new()
cfg.set_value("video", "preset", preset)
cfg.set_value("video", "sdfgi_enabled", sdfgi_enabled)
cfg.set_value("video", "ssil_enabled", ssil_enabled)
cfg.set_value("video", "ssao_enabled", ssao_enabled)
cfg.set_value("video", "shadows_enabled", shadows_enabled)
cfg.set_value("video", "glow_enabled", glow_enabled)
cfg.set_value("video", "aa_mode", aa_mode)
cfg.set_value("video", "glow_scale", glow_scale)
cfg.set_value("video", "brightness", brightness)
cfg.set_value("video", "resolution_scale", resolution_scale)
cfg.set_value("video", "fsr_sharpness", fsr_sharpness)
cfg.set_value("video", "vsync_mode", vsync_mode)
cfg.set_value("video", "fps_cap_divisor", fps_cap_divisor)
cfg.save(SETTINGS_PATH)
# Pushes a preset's bundle into the individual fields and applies everything
# live. CUSTOM is a no-op bundle-wise — it only matters as a marker so
# set_custom_field() below knows not to silently revert to Custom itself.
func apply_preset(new_preset: Preset) -> void:
preset = new_preset
if PRESET_BUNDLES.has(new_preset):
var bundle: Dictionary = PRESET_BUNDLES[new_preset]
_applying_preset = true
sdfgi_enabled = bundle["sdfgi_enabled"]
ssil_enabled = bundle["ssil_enabled"]
ssao_enabled = bundle["ssao_enabled"]
shadows_enabled = bundle["shadows_enabled"]
glow_enabled = bundle["glow_enabled"]
aa_mode = bundle["aa_mode"]
resolution_scale = bundle["resolution_scale"]
_applying_preset = false
apply_aa()
apply_resolution_scale()
settings_changed.emit()
# Called by the settings menu whenever the player edits an individual
# preset-gated field directly (not via the preset dropdown) — flips to
# Custom so the dropdown reflects reality instead of silently lying about
# which preset is "selected". No-ops during apply_preset's own writes above.
func mark_custom() -> void:
if not _applying_preset:
preset = Preset.CUSTOM
func apply_aa() -> void:
if DisplayServer.get_name() == "headless":
return
var viewport := get_tree().root
match aa_mode:
AAMode.OFF:
viewport.msaa_3d = Viewport.MSAA_DISABLED
viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED
AAMode.FXAA:
viewport.msaa_3d = Viewport.MSAA_DISABLED
viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_FXAA
AAMode.MSAA:
viewport.msaa_3d = Viewport.MSAA_4X
viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED
AAMode.MSAA_FXAA:
viewport.msaa_3d = Viewport.MSAA_4X
viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_FXAA
AAMode.MSAA_2X:
viewport.msaa_3d = Viewport.MSAA_2X
viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED
func apply_resolution_scale() -> void:
if DisplayServer.get_name() == "headless":
return
var viewport := get_tree().root
if resolution_scale >= 0.999:
viewport.scaling_3d_mode = Viewport.SCALING_3D_MODE_BILINEAR
viewport.scaling_3d_scale = 1.0
else:
viewport.scaling_3d_mode = Viewport.SCALING_3D_MODE_FSR2
viewport.scaling_3d_scale = clampf(resolution_scale, 0.5, 1.0)
viewport.fsr_sharpness = fsr_sharpness
func apply_vsync() -> void:
if DisplayServer.get_name() == "headless":
return
match vsync_mode:
VsyncMode.DISABLED:
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
VsyncMode.ENABLED:
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_ENABLED)
VsyncMode.ADAPTIVE:
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_ADAPTIVE)
apply_fps_cap()
# Public (not just called from apply_vsync) because gameplay-scene transitions
# (main_menu.gd's _leave_to_gameplay) need to apply the player's chosen cap
# rather than hardcoding an uncapped 0 — the menu's own refresh-rate cap is a
# separate, menu-only concern (settings_menu.gd/main_menu.gd _ready()).
func apply_fps_cap() -> void:
if DisplayServer.get_name() == "headless":
return
if vsync_mode != VsyncMode.DISABLED or fps_cap_divisor <= 0:
Engine.max_fps = 0
return
var refresh := DisplayServer.screen_get_refresh_rate()
# refresh_rate query returning -1 (or 0, unlikely but not contractually
# excluded) falls back to uncapped rather than dividing by a negative
# number into a nonsense cap.
if refresh <= 0.0:
Engine.max_fps = 0
return
Engine.max_fps = maxi(1, roundi(refresh / float(fps_cap_divisor)))
# Called once by each arena's _ready() (and again on settings_changed, so an
# already-loaded arena updates live) to fold the user's glow/brightness
# preference into that arena's own baked Environment tuning, and to gate the
# preset-controlled full-screen passes (§5.5 of multiplayer-todo.md).
func apply_to_environment(env: Environment) -> void:
if env == null:
return
env.glow_enabled = glow_enabled and glow_scale > 0.0
env.glow_intensity *= glow_scale
env.adjustment_brightness *= brightness
env.sdfgi_enabled = sdfgi_enabled
env.ssil_enabled = ssil_enabled
env.ssao_enabled = ssao_enabled