extends Node
## Schema-driven settings.
##
## Every option used to cost four edits in lockstep — a row and its [connection]s in
## game.tscn, a button array plus a handler in menu_main.gd, a field plus three match
## arms here. That does not scale to the set of world settings we actually want
## (path tracing, GI, time of day, gametype, ...), so options are DATA now: declare
## one entry in SCHEMA and the panel, persistence and apply all follow.
##
## THREE LAYERS, resolved in this order — the model the user asked for, where a map
## sets its own default and the settings tab overrides whatever that default is:
##
## player override (_overrides — only keys explicitly changed; PERSISTED)
## > map default (_map_defaults — set by the active map; NOT persisted)
## > schema default (SCHEMA[].default)
##
## Storing only EXPLICIT overrides is what makes that work: a key the player never
## touched keeps following the map, while one they did touch keeps following them
## across maps. A flat "current value" dictionary cannot express that difference.
const SAVE_PATH := "user://settings.json"
## type: "enum" (choices = button labels, value = index) or "bool" (checkbox).
## values: OPTIONAL real values parallel to choices. Without it an enum's value IS
## the button index, which is right for quality tiers but wrong for a number the
## engine actually consumes — samples-per-pixel 1/2/4/8 must reach the renderer as
## 1/2/4/8, not as 0/1/2/3.
## apply: method on this node, called as call(apply, value).
## requires_env: only offered when the active Environment exposes this property —
## path tracing lives on our godot-rtx build and does not exist on stock Godot,
## so the row hides rather than silently doing nothing.
const SCHEMA: Array[Dictionary] = [
# An ACTION row, not a stored value: choosing Low/High writes every key in
# PRESETS[i] as a player override (set_value), and get_value("preset") reads
# back the preset whose keys ALL match, else -1 (custom — nothing highlighted).
# Never persisted. No auto-detect: everyone starts on High (= the defaults).
#
# PANEL LAYOUT is data too (settings page audit, docs/settings-page-audit.md):
# tab which tab the row is on (default "Graphics"), in TABS order
# group "" = visible on the tab; else the collapsible group it starts hidden in
# widget optional override: segmented | dropdown | slider | toggle. Default:
# bool -> toggle, 2-3 choices -> segmented, 4+ -> dropdown
# help one sentence for the help line, with the measured cost where we have one
# depends a key that must be on for this row to be enabled
# Choices name the REAL setting ("Half res", "2048 px"), never Low/High (user, 2026-10-03).
{"key": "preset", "label": "Quality preset", "type": "enum",
"choices": ["Low", "Medium", "High"], "default": 2,
"help": "Sets the rows below to a tested combination. Low suits a GTX 1060; High is the default. Any change afterwards reads as Custom."},
{"key": "shadows", "label": "Shadow map size", "type": "enum",
"choices": ["Off", "1024 px", "2048 px", "4096 px"], "default": 1, "apply": "_apply_shadows",
"help": "Resolution of the sun's shadow map. Bigger is sharper and uses more video memory (~0.4 ms at 1024 px on a GTX 1060)."},
{"key": "aa", "label": "Anti-aliasing", "type": "enum",
"choices": ["Off", "2× MSAA", "4× MSAA", "8× MSAA"], "default": 1, "apply": "_apply_aa",
"group": "More graphics options",
"help": "Smooths jagged geometry edges. Each step multiplies the samples per pixel; forced off while DLSS is on."},
{"key": "bloom", "label": "Bloom", "type": "enum",
"choices": ["Off", "60 % glow", "100 % glow"], "default": 2, "apply": "_apply_bloom",
"group": "More graphics options",
"help": "Glow around bright light sources, as a share of full intensity."},
{"key": "pathtracing", "label": "Path tracing", "type": "bool",
"default": false, "apply": "_apply_pathtracing", "requires_env": "pathtracing_enabled",
"group": "Ray tracing (RTX)",
"help": "Replaces rasterized lighting with a path tracer (RTX cards only). ~23 ms at 2 samples, 3 bounces on an RTX 2070 without DLSS."},
{"key": "sky_area_lights", "label": "Sky area lights", "type": "bool",
"default": false, "apply": "_apply_sky_area_lights",
"requires_env": "pathtracing_enabled", "group": "Ray tracing (RTX)", "depends": "pathtracing",
"help": "Area lights for sky openings in imported levels; turning on takes effect on the next map load."},
# Path-tracing quality. All gated on the Environment property so they vanish
# entirely on a build without the pathtracer rather than showing dead controls.
# Sample/bounce defaults MATCH scenes/test/rt_minimal_test.tscn, the canonical
# RT smoke-test scene: 1 sample, 1 bounce.
#
# DLSS was ONE switch until 2026-08-10, on the reasoning that Super Resolution
# under path tracing "would upscale the path tracer's noise without removing
# any", so the engine should pick: RR under PT, SR under rasterization.
#
# MEASURED, and the reasoning does not survive (benchmarks/pt/RESULTS.md, all
# path-traced at 1914x1053, spp2/b3):
#
# no DLSS 23.50 ms 42.6 fps --
# SR @ 50% 12.21 ms 81.9 fps 1.92x <- best config measured
# RR @ 50% 18.69 ms 53.5 fps 1.26x
# RR @ 67% 33.89 ms 29.5 fps 0.69x slower than no DLSS
# RR @ 100% 73.63 ms 13.6 fps 0.32x much slower
#
# On Turing, Ray Reconstruction costs ~2.2x the path tracing it denoises, so RR
# above 50% is a net LOSS against no upscaler at all. At 2spp the SR and RR
# stills were close to indistinguishable. Auto-picking RR therefore pinned the
# player to the slower option and made the fastest config unreachable.
#
# So the mode is now the player's choice. It is a 3-way enum rather than a
# bundled ladder because SR vs RR is exactly ONE engine flag --
# env.pathtracing_denoiser (0 = none, 1 = Ray Reconstruction) -- while the
# render scale is a separate viewport property. Mode x scale spans the whole
# ladder above: SR+50% = "SR Performance", RR+100% = "DLAA", and so on.
#
# Render scale stays a SEPARATE row below, and that part of the old decision was
# right: bundling them -- the old "SR Performance (50%)" style list -- meant
# turning DLSS off also snapped the scale back to 1.0, so an on/off comparison
# silently measured a 4x resolution change instead of DLSS.
#
# CAVEAT, not yet measured: stills cannot show ghosting, boiling or disocclusion.
# SR under PT has NOT been assessed in motion. That is why the default stays Off
# rather than jumping to the fastest config.
{"key": "dlss", "label": "DLSS", "type": "enum",
"choices": ["Off", "Super Resolution", "Ray Reconstruction"], "values": [0, 1, 2],
"default": 0, "apply": "_apply_dlss", "requires_env": "pathtracing_denoiser",
"group": "Ray tracing (RTX)", "widget": "dropdown",
"help": "NVIDIA upscaling. Super Resolution at 50 % scale measured 12.2 ms vs 23.5 ms without (path traced, RTX 2070); Ray Reconstruction is slower above 50 %."},
{"key": "render_scale", "label": "Render scale", "type": "enum",
"choices": ["50 %", "67 %", "75 %", "100 %"], "values": [0.5, 0.67, 0.75, 1.0],
"default": 3, "apply": "_apply_render_scale", "widget": "slider",
"help": "3D resolution as a share of the window. 75 % draws about half the pixels of 100 %; the UI stays sharp."},
{"key": "pt_samples", "label": "Samples per pixel", "type": "enum",
"choices": ["1", "2", "4", "8"], "values": [1, 2, 4, 8], "default": 0,
"apply": "_apply_pt_samples", "requires_env": "pathtracing_samples_per_pixel",
"group": "Ray tracing (RTX)", "depends": "pathtracing",
"help": "Path-tracer rays per pixel per frame. Cost scales roughly linearly; more is less noisy."},
{"key": "pt_bounces", "label": "Max bounces", "type": "enum",
"choices": ["1", "2", "3", "4", "6", "8"], "values": [1, 2, 3, 4, 6, 8], "default": 0,
"apply": "_apply_pt_bounces", "requires_env": "pathtracing_max_bounces",
"group": "Ray tracing (RTX)", "depends": "pathtracing",
"help": "How many times a path-traced ray may bounce. More gives brighter, more accurate indirect light at more cost."},
{"key": "interest_management", "label": "Interest management", "type": "bool",
"default": true, "tab": "Game", "group": "Network",
"help": "Sends far-away players less often (20 Hz near, 5 / 2 Hz farther, none past 3 km) to save bandwidth."},
# The bridged terrain material (hyper-games #90/#91). Full = every noise per
# pixel, the original look. Vertex = the position-only noise evaluated ONCE PER
# VERTEX when a chunk meshes (the mesh-time bake). Measured on an RTX 2070:
# close-up GPU 22.8-23.0 ms -> 1.9-2.7 ms, far view 12.5-12.7 -> 1.4 ms. The
# cost is detail finer than a far chunk's vertex spacing: far view mean pixel
# difference 1.35/255, 4.7 % of pixels over 8. Full is the default because the
# original look wins unless the player chooses otherwise.
# No apply: map_mountain listens to setting_changed and switches live (the
# terrain remeshes). A map whose material carries no bake ignores it.
{"key": "terrain_material", "label": "Terrain shading", "type": "enum",
"choices": ["Per pixel", "Per vertex"], "default": 0,
"help": "Where the mountain's material noise is computed. Per vertex measured 2.7 ms vs 22.8 ms close up (RTX 2070), with slightly softer distant detail."},
# SunshineClouds2 volumetric clouds. Measured on a GTX 1060 before it throttled
# (hyper-games #95 correction): 5.9 of an 18 ms GPU frame — the largest optional
# cost. Maps still decide WHETHER they have clouds (map_mountain's export,
# map_dc_terrain's scene); this row turns off or rescales whatever they added.
# QUALITY = SunshineClouds2 resolution_scale (values: -1 off, 2 Quarter, 1 Half,
# 0 Native). Measured on an RTX 2070 at 1594x873 with the mountain's clouds
# (scenes/test/_probe_cloud_quality_cost.gd): off 0.15, Quarter 2.85, Half 4.66,
# Native 11.26 GPU ms. Eighth (2.50) saves too little over Quarter to offer.
# Was a bool until 2026-10-03; load_settings migrates true -> High, false -> Off.
{"key": "clouds", "label": "Clouds", "type": "enum",
"choices": ["Off", "Quarter res", "Half res", "Full res"], "values": [-1, 2, 1, 0],
"default": 2, "apply": "_apply_clouds",
"help": "Volumetric cloud resolution. Measured on an RTX 2070: quarter 2.9 ms, half 4.7 ms, full 11.3 ms."},
# Brush texture layers (hyper-games #94 P1). Each loaded texture costs three
# res x res RGB8 layers + mips (albedo, normal, ORM): ~12.6 MB at 1024, ~3.1 MB
# at 512. No apply: LevelPackMaterial listens to setting_changed and rebuilds.
{"key": "texture_res", "label": "Brush texture size", "type": "enum",
"choices": ["512 px", "1024 px"], "values": [512, 1024], "default": 1,
"group": "More graphics options",
"help": "Size of brush textures in video memory: ~3.1 MB per texture at 512 px, ~12.6 MB at 1024 px."},
# Brush surface detail: 0 = normal maps, 1 = parallax occlusion (relief from each
# material's height map, fading out by 14 m). Compiled shader variants
# (dc_level.gdshader / dc_level_pom.gdshader). Measured on a real level
# (scenes/test/_probe_pom_level.gd, RTX 2070, 2x supersampled 1594x873 = 5.6 Mpx):
# +0.66 ms with a brick wall filling the screen, +0.34 grazing, 0 at 16 m (faded) —
# about +0.24 ms full-screen at 1080p. No apply: map_dc_level listens and
# LevelPackMaterial swaps the shader.
{"key": "surface_detail", "label": "Brush surface relief", "type": "enum",
"choices": ["Normal maps", "Parallax"], "default": 1,
"group": "More graphics options",
"help": "Parallax gives bricks and tiles real depth up close: +0.24 ms full-screen at 1080p (RTX 2070), none past 16 m."},
# How the level editor SHOWS lengths (editor redesign E2, user decision 2026-10-02).
# Display + input only: the snapping lattice stays Quake units underneath.
{"key": "editor_units", "label": "Editor units", "type": "enum",
"choices": ["Quake units", "Metres"], "default": 0, "tab": "Game",
"help": "How the level editor shows lengths. Snapping stays on the Quake-unit grid either way."},
# ---- DISPLAY / CONTROLS / AUDIO (#93 §7 player options; settings audit §2) ----
# "range" = a continuous slider: min / max / step / unit, stored as a float.
# Window mode and resolution ask "keep these?" after Apply and revert in 15 s
# (menu_main._ask_keep_display) — a bad mode must not lock the player out.
{"key": "window_mode", "label": "Window mode", "type": "enum", "tab": "Display",
"choices": ["Windowed", "Fullscreen", "Exclusive fullscreen"], "values": [0, 3, 4],
"default": 0, "apply": "_apply_window_mode",
"help": "Fullscreen is a borderless window; exclusive fullscreen can lower latency but switches the display mode. Reverts in 15 s unless you keep it."},
{"key": "resolution", "label": "Window size", "type": "enum", "tab": "Display",
"choices": ["1280 × 720", "1600 × 900", "1920 × 1080", "2560 × 1440"], "default": 0,
"apply": "_apply_resolution",
"help": "Size of the window when windowed (fullscreen uses the display's size). Reverts in 15 s unless you keep it."},
{"key": "vsync", "label": "VSync", "type": "enum", "tab": "Display",
"choices": ["Off", "On", "Adaptive"], "values": [0, 1, 2], "default": 1, "apply": "_apply_vsync",
"help": "On stops tearing but can add a frame of latency; adaptive syncs only when the frame rate keeps up."},
{"key": "max_fps", "label": "Frame rate cap", "type": "enum", "tab": "Display",
"choices": ["Unlimited", "30", "60", "120", "144", "240"], "values": [0, 30, 60, 120, 144, 240],
"default": 0, "apply": "_apply_max_fps", "widget": "dropdown",
"help": "Caps frames per second; a cap saves power and heat on laptops and handhelds."},
{"key": "fov", "label": "Field of view", "type": "range", "tab": "Display",
"min": 60.0, "max": 110.0, "step": 1.0, "unit": "°", "default": 90.0, "apply": "_apply_fov",
"help": "Vertical field of view of the player camera. Wider shows more and makes motion feel faster."},
{"key": "mouse_sensitivity", "label": "Mouse sensitivity", "type": "range", "tab": "Controls",
"min": 0.05, "max": 1.0, "step": 0.01, "unit": "°/px", "default": 0.3,
"help": "Degrees the camera turns per pixel of mouse movement (was a fixed 0.3)."},
{"key": "invert_y", "label": "Invert mouse Y", "type": "bool", "tab": "Controls", "default": false,
"help": "Moving the mouse forward looks down instead of up."},
{"key": "master_volume", "label": "Master volume", "type": "range", "tab": "Audio",
"min": 0.0, "max": 100.0, "step": 1.0, "unit": "%", "default": 100.0, "apply": "_apply_master_volume",
"help": "Overall volume (the Master bus). Separate music / effects volumes come with their own audio buses."},
]
## Tab order in the panel. A tab with no offered rows is not shown.
const TABS: PackedStringArray = ["Graphics", "Display", "Controls", "Audio", "Game"]
## What each preset (SCHEMA "preset" choice index) writes. High is exactly the
## schema defaults. Low drops the two big optional costs measured on a GTX 1060 —
## clouds (5.9 ms) and the per-pixel terrain material (Vertex: 22.8 → 2.7 ms
## close-up on a 2070) — plus MSAA, bloom and 75 % render scale, and 512 px brush
## textures (the low-end fallback the #94 decision asked for). Medium keeps the
## Vertex material (the single biggest cost) and Quarter clouds (2.85 vs 4.66 ms)
## at full resolution and textures. Ultra clouds (Native, 11.26 ms) is in no
## preset — a high-end choice set by hand. (User decisions 2026-10-03.)
const PRESETS: Array[Dictionary] = [
{"terrain_material": 1, "clouds": 0, "render_scale": 2, "aa": 0, "bloom": 1, "shadows": 1,
"texture_res": 0, "surface_detail": 0},
{"terrain_material": 1, "clouds": 1, "render_scale": 3, "aa": 0, "bloom": 2, "shadows": 1,
"texture_res": 1, "surface_detail": 0},
{"terrain_material": 0, "clouds": 2, "render_scale": 3, "aa": 1, "bloom": 2, "shadows": 1,
"texture_res": 1, "surface_detail": 1},
]
var _overrides: Dictionary = {} # explicitly set by the player; persisted
var _map_defaults: Dictionary = {} # declared by the active map; not persisted
signal setting_changed(key: String, value: Variant)
# ---- Back-compat properties -------------------------------------------------
# game.gd and menu_main.gd address these by name. Kept as proxies so the storage
# could become generic without touching every call site.
var shadows_quality: int:
get: return int(get_value("shadows"))
set(v): set_value("shadows", v)
var aa_quality: int:
get: return int(get_value("aa"))
set(v): set_value("aa", v)
var bloom_quality: int:
get: return int(get_value("bloom"))
set(v): set_value("bloom", v)
var interest_management: bool:
get: return bool(get_value("interest_management"))
set(v): set_value("interest_management", v)
func _ready() -> void:
load_settings()
# ---- Schema access ----------------------------------------------------------
static func schema_for(key: String) -> Dictionary:
for o in SCHEMA:
if o["key"] == key:
return o
return {}
## Effective value: player override, else the active map's default, else the schema's.
func get_value(key: String) -> Variant:
if key == "preset":
return _matching_preset()
if _overrides.has(key):
return _overrides[key]
if _map_defaults.has(key):
return _map_defaults[key]
var o := schema_for(key)
return o.get("default", null)
## The value the ENGINE receives: values[index] when the option declares a values
## table, otherwise the stored value itself. get_value stays the UI-facing index so
## button highlighting keeps working unchanged.
func resolved_value(key: String) -> Variant:
var o := schema_for(key)
var v = get_value(key)
if o.has("values"):
var vals: Array = o["values"]
var i := int(v)
return vals[i] if i >= 0 and i < vals.size() else vals[0]
return v
## Set a PLAYER override. This is what the settings tab writes, and it deliberately
## outranks the map — "should just override whatever the default is in each map".
func set_value(key: String, value: Variant) -> void:
if key == "preset":
_apply_preset(int(value))
return
if _overrides.get(key) == value:
return
_overrides[key] = value
setting_changed.emit(key, value)
## Write every key of a preset as a player override (the panel's Apply persists them).
func _apply_preset(index: int) -> void:
if index < 0 or index >= PRESETS.size():
return
for k: String in PRESETS[index]:
set_value(k, PRESETS[index][k])
## The preset whose keys all match the effective values, or -1 (custom).
func _matching_preset() -> int:
for i in PRESETS.size():
var all := true
for k: String in PRESETS[i]:
if get_value(k) != PRESETS[i][k]:
all = false
break
if all:
return i
return -1
## True when the player has explicitly set this key (so it is following them, not the map).
func is_overridden(key: String) -> bool:
return _overrides.has(key)
## Drop a player override so the key follows the map again.
func clear_override(key: String) -> void:
if _overrides.erase(key):
setting_changed.emit(key, get_value(key))
## Declare the active map's defaults, e.g. a level that wants path tracing on by
## default. Replaces the previous map's set — these are per-map, not cumulative —
## and never touches player overrides.
func set_map_defaults(defaults: Dictionary) -> void:
_map_defaults = defaults.duplicate()
apply_settings()
func clear_map_defaults() -> void:
_map_defaults.clear()
## Is this option offered on this build? An option gated on an Environment property
## is only available when the running engine actually has it.
func is_available(key: String) -> bool:
var o := schema_for(key)
if not o.has("requires_env"):
return true
var we := _find_world_environment()
if we == null or we.environment == null:
return false
return String(o["requires_env"]) in we.environment
# ---- Persistence ------------------------------------------------------------
# Only OVERRIDES are written. Persisting effective values would silently freeze
# whatever the map happened to default to at the moment the file was saved.
func load_settings() -> void:
_overrides.clear()
if not FileAccess.file_exists(SAVE_PATH):
return
var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
if file == null:
return
var parsed = JSON.parse_string(file.get_as_text())
if not parsed is Dictionary:
return
# Same key names the old format used, so existing user://settings.json still loads.
for o in SCHEMA:
var k: String = o["key"]
if not parsed.has(k):
continue
var raw: Variant = parsed[k]
# `dlss` was a bool until 2026-08-10 and is now a 3-way mode. A stored `true`
# meant "DLSS on", which under path tracing evaluated Ray Reconstruction — so
# it migrates to 2, NOT to int(true) == 1 (Super Resolution). Falling through
# to the generic enum branch would silently change what an existing player's
# saved config does the first time they load it.
if k == "dlss" and typeof(raw) == TYPE_BOOL:
_overrides[k] = 2 if bool(raw) else 0
continue
# `clouds` was an on/off bool until 2026-10-03. On meant what ships as High;
# int(true) == 1 would silently drop an existing player to Low.
if k == "clouds" and typeof(raw) == TYPE_BOOL:
_overrides[k] = 2 if bool(raw) else 0
continue
match String(o["type"]):
"enum":
_overrides[k] = int(raw)
"range":
_overrides[k] = clampf(float(raw), float(o.get("min", -INF)), float(o.get("max", INF)))
_:
_overrides[k] = bool(raw)
func save_settings() -> void:
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file:
file.store_string(JSON.stringify(_overrides, "\t"))
# ---- Apply ------------------------------------------------------------------
## Order matters: everything else applies before `pathtracing`, so the pathtracer
## initialises against its final denoiser/sample/bounce configuration rather than
## being switched on and reconfigured underneath.
func apply_settings() -> void:
for o in SCHEMA:
if String(o["key"]) == "pathtracing":
continue
if o.has("apply"):
call(String(o["apply"]), resolved_value(String(o["key"])))
if is_available("pathtracing"):
_apply_pathtracing(get_value("pathtracing"))
return
func _apply_shadows(v: Variant) -> void:
match int(v):
0: RenderingServer.directional_shadow_atlas_set_size(512, false)
1: RenderingServer.directional_shadow_atlas_set_size(1024, true)
2: RenderingServer.directional_shadow_atlas_set_size(2048, true)
3: RenderingServer.directional_shadow_atlas_set_size(4096, true)
## MSAA and a temporal upscaler do not compose. DLSS wants jittered, unresolved
## samples and performs its own antialiasing; resolving with MSAA first destroys
## the sub-pixel information it needs and costs bandwidth for an effect DLSS is
## about to redo. So MSAA is forced off whenever DLSS is on, rather than letting
## the two rows contradict each other silently.
func _apply_aa(v: Variant) -> void:
var vp := get_viewport()
if vp == null:
return
# int, not bool: `dlss` is a 3-way mode now (0 Off / 1 SR / 2 RR). bool(2) happens
# to be true, so this would still work by luck — spell it out so it keeps working
# if a mode is ever added or reordered.
if is_available("dlss") and int(resolved_value("dlss")) != 0:
vp.msaa_3d = Viewport.MSAA_DISABLED
return
match int(v):
0: vp.msaa_3d = Viewport.MSAA_DISABLED
1: vp.msaa_3d = Viewport.MSAA_2X
2: vp.msaa_3d = Viewport.MSAA_4X
3: vp.msaa_3d = Viewport.MSAA_8X
func _apply_bloom(v: Variant) -> void:
var env := _active_environment()
if env == null:
return
match int(v):
0: env.glow_enabled = false
1:
env.glow_enabled = true
env.glow_intensity = 0.6
2:
env.glow_enabled = true
env.glow_intensity = 1.0
## Path tracing is a property of the Environment RESOURCE, not a project setting —
## and game.gd hands WorldEnvironment ownership to any map that brings its own
## (_remove_map_world_environment), so the active one is resolved on every apply
## rather than cached. Absent on stock Godot; the guard makes 4.6 a no-op.
func _apply_pathtracing(v: Variant) -> void:
var env := _active_environment()
if env == null or not ("pathtracing_enabled" in env):
return
env.pathtracing_enabled = bool(v)
## 0 = Off, 1 = Super Resolution, 2 = Ray Reconstruction. Two independent engine
## controls, which is why this is a mode rather than a bundle:
## - viewport.scaling_3d_mode picks whether the DLSS resolve runs at all
## - env.pathtracing_denoiser picks WHICH NGX feature it evaluates
## render_forward_clustered.cpp sets params.dlss_rr only when the RR guide buffers
## exist, and those are allocated only while path tracing runs with the RR denoiser.
## So denoiser 1 under PT evaluates Ray Reconstruction, and denoiser 0 evaluates
## plain Super Resolution — which is the config that measured fastest by a wide
## margin (SCHEMA note above). Under rasterization there is nothing to denoise and
## both non-Off modes come out as Super Resolution.
##
## The render SCALE is deliberately not touched here — it is its own row, so every
## mode is comparable at a fixed scale. Mirrors scenes/test/rtx_test.gd's DLSSMode,
## which is the harness the numbers came from.
## SCALING_3D_MODE_DLSS RESOLVED AT RUNTIME, not named as a constant.
##
## It exists only in the godot-rtx 4.7 build. Naming it directly is a COMPILE error
## on stock 4.6 — and because this is an AUTOLOAD, that error does not merely
## disable this setting, it aborts autoload registration, so GameState and every
## singleton after it never come up. Every headless harness on 4.6 then dies with
## "Identifier not found: GameState", nowhere near the actual cause. Measured: both
## local 4.6 builds report the constant absent.
##
## Same defensive shape map_dc_level.gd already uses for AreaLight3D (4.7+, checked
## with ClassDB.class_exists so it simply does not appear on 4.6). On the 4.7 build
## this resolves to exactly the value the constant had; on 4.6 DLSS is not
## selectable at all, so bilinear is what the row means there.
static func _dlss_scaling_mode() -> int:
var consts: PackedStringArray = ClassDB.class_get_integer_constant_list("Viewport", true)
if consts.has("SCALING_3D_MODE_DLSS"):
return ClassDB.class_get_integer_constant("Viewport", "SCALING_3D_MODE_DLSS")
return Viewport.SCALING_3D_MODE_BILINEAR
func _apply_dlss(v: Variant) -> void:
var mode := int(v)
var vp := get_viewport()
if vp != null:
vp.scaling_3d_mode = Viewport.SCALING_3D_MODE_BILINEAR if mode == 0 \
else _dlss_scaling_mode()
var env := _active_environment()
if env != null and "pathtracing_denoiser" in env:
env.pathtracing_denoiser = 1 if mode == 2 else 0
# MSAA is mutually exclusive with DLSS, so its row has to be re-resolved
# whenever this one moves -- schema order applies "aa" before "dlss".
_apply_aa(get_value("aa"))
## The lights are built during map import (map_dc_level._spawn_sky_area_lights),
## so switching this ON cannot conjure them -- that takes effect on the next load.
## Switching OFF is applied immediately by hiding the container, so the row is not
## inert the way it was when it had no apply at all and was read only at import.
func _apply_sky_area_lights(v: Variant) -> void:
var container := get_tree().root.find_child("SkyAreaLights", true, false)
if container != null and container is Node3D:
(container as Node3D).visible = bool(v)
## Independent of the DLSS mode: at 100% with DLSS on you get DLAA, at 100% with
## DLSS off you get native rendering, and any scale is comparable across modes.
func _apply_render_scale(v: Variant) -> void:
var vp := get_viewport()
if vp != null:
vp.scaling_3d_scale = float(v)
func _apply_pt_samples(v: Variant) -> void:
var env := _active_environment()
if env != null and "pathtracing_samples_per_pixel" in env:
env.pathtracing_samples_per_pixel = int(v)
func _apply_pt_bounces(v: Variant) -> void:
var env := _active_environment()
if env != null and "pathtracing_max_bounces" in env:
env.pathtracing_max_bounces = int(v)
## Volumetric clouds are a SunshineCloudsGD CompositorEffect that a map adds to a
## WorldEnvironment's compositor (the shared Sky3DExtended). `v` is the resolved
## resolution_scale (0 Native, 1 Half, 2 Quarter) or -1 for off, which toggles
## `enabled`. Re-enable ONLY what this row turned off: the addon itself sets
## enabled=false when it fails to initialise (no rendering device / missing
## shaders, SunshineClouds.gd initialize_compute), and that must stay off.
const _CLOUDS_OFF_META := &"_settings_clouds_off"
func _apply_clouds(v: Variant) -> void:
var scale := int(v)
var on := scale >= 0
for we: Node in get_tree().root.find_children("*", "WorldEnvironment", true, false):
var comp: Compositor = (we as WorldEnvironment).compositor
if comp == null:
continue
for e: CompositorEffect in comp.compositor_effects:
if not (e is SunshineCloudsGD):
continue
if not on and e.enabled:
e.enabled = false
e.set_meta(_CLOUDS_OFF_META, true)
elif on and e.has_meta(_CLOUDS_OFF_META):
e.enabled = true
e.remove_meta(_CLOUDS_OFF_META)
if on and e.get("resolution_scale") != scale:
e.set("resolution_scale", scale)
## Display / Controls / Audio applies. Window calls are skipped on the headless
## display server (gates, --daemon-only), which has no window to change.
func _windowed_ok() -> bool:
return DisplayServer.get_name() != "headless"
func _apply_window_mode(v: Variant) -> void:
if _windowed_ok():
DisplayServer.window_set_mode(int(v) as DisplayServer.WindowMode)
const _RESOLUTIONS := [Vector2i(1280, 720), Vector2i(1600, 900), Vector2i(1920, 1080), Vector2i(2560, 1440)]
func _apply_resolution(v: Variant) -> void:
var i := clampi(int(v), 0, _RESOLUTIONS.size() - 1)
if not _windowed_ok() or DisplayServer.window_get_mode() != DisplayServer.WINDOW_MODE_WINDOWED:
return
DisplayServer.window_set_size(_RESOLUTIONS[i])
# Keep it on screen: centre on the current display.
var scr := DisplayServer.screen_get_usable_rect(DisplayServer.window_get_current_screen())
DisplayServer.window_set_position(scr.position + (scr.size - _RESOLUTIONS[i]) / 2)
func _apply_vsync(v: Variant) -> void:
if _windowed_ok():
DisplayServer.window_set_vsync_mode(int(v) as DisplayServer.VSyncMode)
func _apply_max_fps(v: Variant) -> void:
Engine.max_fps = int(v)
func _apply_master_volume(v: Variant) -> void:
var lin := clampf(float(v) / 100.0, 0.0, 1.0)
AudioServer.set_bus_mute(0, lin <= 0.0)
AudioServer.set_bus_volume_db(0, linear_to_db(maxf(lin, 0.0001)))
## Mouse look in DEGREES per pixel, (x, y) — y negative when inverted. The player
## scripts multiply event.relative by this (it replaced their fixed 0.3).
## FOV follows the setting live: every Camera3D in the "player_camera" group. The
## player scripts add their camera to the group; the setting applies on change and on
## every apply_settings (map load), so a camera spawned later is picked up too.
func _apply_fov(v: Variant) -> void:
if not is_inside_tree():
return
for c: Node in get_tree().get_nodes_in_group("player_camera"):
if c is Camera3D:
(c as Camera3D).fov = float(v)
func mouse_look_scale() -> Vector2:
var s := float(get_value("mouse_sensitivity"))
return Vector2(s, -s if bool(get_value("invert_y")) else s)
func _active_environment() -> Environment:
var we := _find_world_environment()
return we.environment if we != null else null
func _find_world_environment() -> WorldEnvironment:
for node in get_tree().root.get_children():
var we := _search_for_world_env(node)
if we:
return we
return null
func _search_for_world_env(node: Node) -> WorldEnvironment:
if node is WorldEnvironment:
return node
for child in node.get_children():
var result := _search_for_world_env(child)
if result:
return result
return null