Add the optional Forward+ desktop HQ build
Full build / Import + headless tests (push) Successful in 40s
Full build / Full desktop build + optional release (push) Successful in 5m20s

Package a Forward+ preset with volumetric fog, SDFGI and a volumetric flashlight
for a separate Linux/Windows Desktop HQ zip. Gate the effects on a real
RenderingDevice, tune LATE fog, and adapt local light falloff for Godot 4.7.
Record the Compatibility/Forward+ A/B captures and metrics, add the artifact to
the build/publish scripts, and keep the default Compatibility/Web path
unchanged. Headless 231/231, Python 87/87; exported HQ binary boots with Vulkan.
This commit is contained in:
zwitschi committed 2026-11-01 19:40:42 +01:00
1 parent 64f0c6a198
commit 28f2a48811
23 files changed
+337 -46

No files matched your search

+9 -6
View File
@@ -54,7 +54,7 @@ const PHASE_LIGHTING := {
"sky_ground": Color(0.45, 0.5, 0.55), "ambient": Color(0.75, 0.78, 0.85), "night": false,
"exposure": 0.6, "glow": 0.45, "fog_color": Color(0.87, 0.83, 0.76),
"fog_density": 0.0034, "fog_energy": 0.5, "contrast": 1.05, "saturation": 1.04,
"ambient_energy": 0.1,
"ambient_energy": 0.1, "volumetric_density": 0.004, "volumetric_energy": 0.0,
},
Rules.Phase.MIDDAY: {
"sun_energy": 1.0, "sun_angle": -62.0, "sun_yaw": -30.0, "sun_color": Color(1.0, 0.99, 0.96),
@@ -62,7 +62,7 @@ const PHASE_LIGHTING := {
"sky_ground": Color(0.5, 0.54, 0.58), "ambient": Color(0.85, 0.88, 0.95), "night": false,
"exposure": 0.6, "glow": 0.4, "fog_color": Color(0.88, 0.87, 0.83),
"fog_density": 0.0028, "fog_energy": 0.5, "contrast": 1.06, "saturation": 1.04,
"ambient_energy": 0.28,
"ambient_energy": 0.28, "volumetric_density": 0.003, "volumetric_energy": 0.0,
},
# Evening is dusk, not a bright sunset: the sun is almost down and the street
# is carried by the marquee, the lamps and the lit windows (realism pass).
@@ -72,7 +72,7 @@ const PHASE_LIGHTING := {
"sky_ground": Color(0.1, 0.09, 0.12), "ambient": Color(0.2, 0.19, 0.26), "night": true,
"exposure": 0.72, "glow": 0.8, "fog_color": Color(0.34, 0.27, 0.3),
"fog_density": 0.0026, "fog_energy": 0.45, "contrast": 1.1, "saturation": 1.05,
"ambient_energy": 0.12,
"ambient_energy": 0.12, "volumetric_density": 0.014, "volumetric_energy": 0.10,
},
Rules.Phase.LATE: {
"sun_energy": 0.02, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5),
@@ -80,7 +80,7 @@ const PHASE_LIGHTING := {
"sky_ground": Color(0.03, 0.03, 0.05), "ambient": Color(0.06, 0.07, 0.12), "night": true,
"exposure": 0.95, "glow": 1.15, "fog_color": Color(0.08, 0.1, 0.2),
"fog_density": 0.0028, "fog_energy": 0.45, "contrast": 1.12, "saturation": 1.0,
"ambient_energy": 0.22,
"ambient_energy": 0.22, "volumetric_density": 0.020, "volumetric_energy": 0.14,
},
}
var hud: Hud = null
@@ -196,11 +196,14 @@ func _build_guard() -> GuardController:
# Aim straight ahead: tilting it down made the cone hit the ground right in
# front of the guard instead of the person or door being checked.
flashlight.rotation_degrees = Vector3.ZERO
flashlight.spot_range = 20.0
flashlight.spot_range = 30.0 if Materials.modern_light_falloff() else 20.0
flashlight.spot_angle = 14.0
flashlight.light_energy = 4.2
flashlight.spot_attenuation = 0.9
flashlight.spot_attenuation = 0.0 if Materials.modern_light_falloff() else 0.9
flashlight.spot_angle_attenuation = 0.9
# The volumetric shaft is a Forward+ (desktop HQ) effect. Compatibility and
# headless builds leave the light non-volumetric, so their look is unchanged.
flashlight.light_volumetric_fog_energy = 3.0 if RenderingServer.get_rendering_device() != null else 0.0
head.add_child(flashlight)
var collider := CollisionShape3D.new()
var shape := CapsuleShape3D.new()
+34 -1
View File
@@ -152,9 +152,24 @@ static func window_pane() -> StandardMaterial3D:
mat.emission_energy_multiplier = 0.0
return mat
## Godot 4.7 changed the Compatibility light falloff in practice: the same
## attenuation/range that read well on 4.3 leaves the night scene nearly black.
## Use this switch for local lights until the project moves its baseline to 4.7.
static func modern_light_falloff() -> bool:
var info := Engine.get_version_info()
return int(info.get("major", 4)) > 4 \
or (int(info.get("major", 4)) == 4 and int(info.get("minor", 0)) >= 7)
## True only when a real RenderingDevice exists. The Compatibility renderer and
## the headless dummy renderer return null, so the desktop-only effects can be
## gated without Web/CI builds ever enabling them.
static func forward_plus_active() -> bool:
return RenderingServer.get_rendering_device() != null
## Environment look shared by every phase: ACES tonemapping, soft glow, aerial
## fog for depth, and a mild contrast/saturation lift. Phase values come from
## `GameManager.PHASE_LIGHTING`.
## `GameManager.PHASE_LIGHTING`. Volumetric fog and SDFGI are only enabled when
## Forward+ is actually available (the optional desktop preset).
static func configure_environment(env: Environment, cfg: Dictionary) -> void:
if env == null:
return
@@ -180,6 +195,24 @@ static func configure_environment(env: Environment, cfg: Dictionary) -> void:
env.fog_density = float(cfg.get("fog_density", 0.0035))
env.fog_aerial_perspective = 0.25
env.fog_sky_affect = 0.4
var advanced := forward_plus_active()
env.volumetric_fog_enabled = advanced
env.sdfgi_enabled = advanced
if advanced:
# Desktop HQ: a shallow, cool fog gives the torch a visible shaft while
# keeping the entrance facade readable. Phase values are tuned per phase.
env.volumetric_fog_density = float(cfg.get("volumetric_density", 0.012))
env.volumetric_fog_albedo = cfg.get("volumetric_albedo", Color(0.55, 0.62, 0.72))
env.volumetric_fog_emission = cfg.get("volumetric_emission", Color.BLACK)
env.volumetric_fog_emission_energy = float(cfg.get("volumetric_energy", 0.0))
env.volumetric_fog_anisotropy = 0.25
env.volumetric_fog_length = 48.0
env.volumetric_fog_ambient_inject = 0.15
env.volumetric_fog_sky_affect = 0.2
env.sdfgi_use_occlusion = true
env.sdfgi_cascades = 4
env.sdfgi_min_cell_size = 0.2
env.sdfgi_energy = 0.7
env.adjustment_enabled = true
env.adjustment_brightness = 1.0
env.adjustment_contrast = float(cfg.get("contrast", 1.06))
+6 -3
View File
@@ -548,7 +548,8 @@ func _build_bulb_canopy() -> void:
for x in [-2.2, 2.2]:
var omni := OmniLight3D.new()
omni.position = Vector3(x, 5.15, front + 1.4)
omni.omni_range = 8.0
omni.omni_range = 12.0 if Materials.modern_light_falloff() else 8.0
omni.omni_attenuation = 0.0 if Materials.modern_light_falloff() else 1.0
omni.light_energy = 0.0
omni.light_color = Color(1.0, 0.72, 0.36)
root.add_child(omni)
@@ -586,7 +587,8 @@ func _add_wall_lantern(pos: Vector3, with_light: bool) -> void:
var omni := OmniLight3D.new()
omni.name = "LanternGlow"
omni.position = pos + Vector3(0.0, -0.1, 0.45)
omni.omni_range = 9.0
omni.omni_range = 14.0 if Materials.modern_light_falloff() else 9.0
omni.omni_attenuation = 0.0 if Materials.modern_light_falloff() else 1.0
omni.light_energy = 0.0
omni.light_color = Color(1.0, 0.72, 0.38)
root.add_child(omni)
@@ -666,7 +668,8 @@ func _build_street_furniture() -> void:
holder.name = "Lamp_%d" % int(side)
var omni := OmniLight3D.new()
omni.position = Vector3(-0.85, 4.65, 0)
omni.omni_range = 12.0
omni.omni_range = 16.0 if Materials.modern_light_falloff() else 12.0
omni.omni_attenuation = 0.0 if Materials.modern_light_falloff() else 1.0
omni.light_energy = 0.0
omni.light_color = Color(1, 0.93, 0.75)
holder.add_child(omni)
+9
View File
@@ -30,6 +30,9 @@ const HOODED_TYPES := [5, 6, 7] # TRESPASSER, VIOLENT_THREAT, MENTAL_CRISIS
## Optional `--portrait-type=N` picks the NpcType a portrait should feature.
var _portrait_type := -1
## Optional `--flashlight` turns the guard lamp on for night stages; the desktop
## HQ A/B uses it to show the volumetric shaft.
var _flashlight := false
## Where the portrait subject is placed, relative to the guard's spawn.
var _portrait_spot := Vector3(0.0, 0.5, 9.0)
var _portrait_subject: Node3D = null
@@ -114,6 +117,8 @@ func _parse_args() -> void:
_only_stages[name.strip_edges()] = true
elif text.begins_with("--portrait-type="):
_portrait_type = int(text.substr(16))
elif text == "--flashlight":
_flashlight = true
func _pair(text: String, into: Dictionary) -> void:
var cut := text.find("=", 6) # skip the "res://" scheme
@@ -168,6 +173,10 @@ func _begin_stage() -> void:
if venue != "" and venue != str(gm.get("active_scenario_id")):
gm.call("switch_scenario", venue)
gm.coordinator.elapsed_s = float(stage["elapsed"])
if _flashlight:
var want := bool(stage["night"])
if want != bool(gm.flashlight_on):
gm.call("_toggle_flashlight")
_apply(swaps)
if bool(stage["night"]):
_apply(night_swaps)
+4 -1
View File
@@ -43,8 +43,11 @@ func test_flashlight_aims_a_tight_forward_spot() -> bool:
var c := TestUtils.check(lamp != null and lamp.spot_angle <= 20.0,
"flashlight cone is narrow enough to read as a torch")
var d := TestUtils.check(lamp != null and not lamp.visible, "flashlight starts off")
var e := TestUtils.check(lamp != null
and is_zero_approx(lamp.light_volumetric_fog_energy),
"headless/compatibility builds leave the flashlight non-volumetric")
root.free()
return a and b and c and d
return a and b and c and d and e
func test_flashlight_widens_perception() -> bool:
## Without the flashlight, an NPC at AWARE_RADIUS + 3 m is unaware.
+7 -2
View File
@@ -77,14 +77,19 @@ func test_environment_tuning_applies_phase_values() -> bool:
var f := TestUtils.check(
env.fog_light_color.is_equal_approx(late["fog_color"]),
"phase fog colour is used")
return a and b and c and d and e and f
var advanced := Materials.forward_plus_active()
var g := TestUtils.check(env.volumetric_fog_enabled == advanced,
"volumetric fog is gated to Forward+ (advanced=%s)" % advanced)
var h := TestUtils.check(env.sdfgi_enabled == advanced,
"SDFGI is gated to Forward+ (advanced=%s)" % advanced)
return a and b and c and d and e and f and g and h
func test_every_phase_has_environment_keys() -> bool:
var ok := true
for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]:
var cfg: Dictionary = GameManager.PHASE_LIGHTING.get(phase, {})
for key in ["exposure", "glow", "fog_color", "fog_density", "fog_energy",
"contrast", "saturation"]:
"contrast", "saturation", "volumetric_density", "volumetric_energy"]:
if not cfg.has(key):
ok = false
return TestUtils.check(ok, "all phases set exposure/glow/fog/grading")