Dusk and night: FLUX sky panoramas, working street lamps, headlights

A Time column in the menu picks day / dusk / night per run:
- Dusk: low orange sun under a FLUX-generated equirectangular panorama (golden
  hour rakes the heritage facades -- best light in the game).
- Night: dim blue moonlight, city-glow panorama, headlight spotlights on the
  player, and every street lamp gets a real OmniLight under its lantern.
  Because the light is parented to the lamp's physics body, toppling the lamp
  takes its pool of light with it.

Panorama seams are wrap-blended in post (PanoramaSky tiles horizontally and
FLUX edges never match on their own); flux_local.py learned non-square
generation (1024x512) for exactly this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-30 13:54:34 +10:00
parent d886188475
commit 52f7705e13
14 changed files with 417 additions and 20 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bijmj1x4hi11a"
path="res://.godot/imported/sky_dusk.jpg-63e4d345e86325d5834f66bcdedd0851.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/textures/sky_dusk.jpg"
dest_files=["res://.godot/imported/sky_dusk.jpg-63e4d345e86325d5834f66bcdedd0851.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://2rackpqbvwp2"
path="res://.godot/imported/sky_night.jpg-79b8d08aa9423f4579159cfb2d221005.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/textures/sky_night.jpg"
dest_files=["res://.godot/imported/sky_night.jpg-79b8d08aa9423f4579159cfb2d221005.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

40
shot_dusk.png.import Normal file
View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ckgqxm67w4wjg"
path="res://.godot/imported/shot_dusk.png-f6af1b6e4e64591ef1de1c661224bed5.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_dusk.png"
dest_files=["res://.godot/imported/shot_dusk.png-f6af1b6e4e64591ef1de1c661224bed5.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ceecujmymhp7v"
path="res://.godot/imported/shot_new_bubbla_600.png-05b2db194b7a014fc14c973c912e5583.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_new_bubbla_600.png"
dest_files=["res://.godot/imported/shot_new_bubbla_600.png-05b2db194b7a014fc14c973c912e5583.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://mll58atwcrp6"
path="res://.godot/imported/shot_new_falcodore_gtho.png-e66ed2ab5a9d8d094ec961c70879ba93.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_new_falcodore_gtho.png"
dest_files=["res://.godot/imported/shot_new_falcodore_gtho.png-e66ed2ab5a9d8d094ec961c70879ba93.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c7vogu04epx1b"
path="res://.godot/imported/shot_new_hardon_waggo.png-070bed85dde44ad7d2157c19c292a0a0.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_new_hardon_waggo.png"
dest_files=["res://.godot/imported/shot_new_hardon_waggo.png-070bed85dde44ad7d2157c19c292a0a0.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bgjfj2d166iw"
path="res://.godot/imported/shot_new_hardon_wb_ute.png-7ed5d240147f85803b553146a1f90363.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_new_hardon_wb_ute.png"
dest_files=["res://.godot/imported/shot_new_hardon_wb_ute.png-7ed5d240147f85803b553146a1f90363.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

40
shot_night.png.import Normal file
View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ssfx4eea0hio"
path="res://.godot/imported/shot_night.png-18957e7f26c9433700879ac3d7e65fdb.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_night.png"
dest_files=["res://.godot/imported/shot_night.png-18957e7f26c9433700879ac3d7e65fdb.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@ -10,6 +10,7 @@ extends Node3D
static var car_path := ""
static var level_path := ""
static var mode := "cruise"
static var time_of_day := "day" # day | dusk | night
static var junction: Dictionary = {}
const LAPS := 3
@ -493,11 +494,22 @@ func _spawn_smashables(level: Node) -> void:
hmat.albedo_color = Color(0.86, 0.84, 0.72)
hmat.emission_enabled = true
hmat.emission = Color(0.9, 0.85, 0.6)
hmat.emission_energy_multiplier = 0.35
hmat.emission_energy_multiplier = 0.35 if time_of_day != "night" else 3.5
hm.material = hmat
head.mesh = hm
head.position = Vector3(1.45, size.y * 0.5 - 0.36, 0)
b.add_child(head)
if time_of_day == "night":
# a real light under the lantern -- and because it's parented
# to the physics body, toppling the lamp takes its pool of
# light with it, which is deeply satisfying
var ol := OmniLight3D.new()
ol.light_color = Color(1.0, 0.87, 0.55)
ol.light_energy = 1.6
ol.omni_range = 13.0
ol.omni_attenuation = 1.4
ol.position = head.position - Vector3(0, 0.5, 0)
b.add_child(ol)
add_child(b)
var t: Transform3D = (m as Node3D).global_transform
b.global_transform = Transform3D(
@ -507,12 +519,22 @@ func _spawn_smashables(level: Node) -> void:
n += 1
func _build_environment() -> void:
## Brisbane midday: hard high sun, deep blue zenith, everything a bit blown out.
## The bare default (no tonemap, grey ambient) rendered the city flat and dead.
## Three Brisbanes: midday (hard high sun), dusk (low orange sun under a FLUX
## panorama) and night (moonlight, working street lamps, headlights).
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-62, 38, 0)
sun.light_color = Color(1.0, 0.96, 0.88)
sun.light_energy = 1.25
match time_of_day:
"dusk":
sun.rotation_degrees = Vector3(-11, 55, 0)
sun.light_color = Color(1.0, 0.62, 0.32)
sun.light_energy = 0.85
"night":
sun.rotation_degrees = Vector3(-48, -20, 0)
sun.light_color = Color(0.55, 0.65, 0.90) # moon
sun.light_energy = 0.14
_:
sun.rotation_degrees = Vector3(-62, 38, 0)
sun.light_color = Color(1.0, 0.96, 0.88)
sun.light_energy = 1.25
sun.shadow_enabled = true
sun.directional_shadow_mode = DirectionalLight3D.SHADOW_PARALLEL_4_SPLITS
sun.directional_shadow_max_distance = 220.0
@ -533,14 +555,24 @@ func _build_environment() -> void:
fill.shadow_enabled = false
add_child(fill)
var sky_mat := ProceduralSkyMaterial.new()
sky_mat.sky_top_color = Color(0.27, 0.46, 0.74)
sky_mat.sky_horizon_color = Color(0.80, 0.86, 0.92)
sky_mat.sky_energy_multiplier = 1.0
sky_mat.ground_bottom_color = Color(0.28, 0.28, 0.30)
sky_mat.ground_horizon_color = Color(0.66, 0.72, 0.78)
sky_mat.sun_angle_max = 4.0
sky_mat.sun_curve = 0.08
# dusk/night ride FLUX panoramas; day keeps the procedural gradient
var sky_mat: Material
var pano := "res://assets/textures/sky_%s.jpg" % time_of_day
if time_of_day != "day" and ResourceLoader.exists(pano):
var pm := PanoramaSkyMaterial.new()
pm.panorama = load(pano)
pm.energy_multiplier = 1.3 if time_of_day == "dusk" else 0.9
sky_mat = pm
else:
var gm := ProceduralSkyMaterial.new()
gm.sky_top_color = Color(0.27, 0.46, 0.74)
gm.sky_horizon_color = Color(0.80, 0.86, 0.92)
gm.sky_energy_multiplier = 1.0
gm.ground_bottom_color = Color(0.28, 0.28, 0.30)
gm.ground_horizon_color = Color(0.66, 0.72, 0.78)
gm.sun_angle_max = 4.0
gm.sun_curve = 0.08
sky_mat = gm
var e := Environment.new()
e.background_mode = Environment.BG_SKY
@ -550,8 +582,16 @@ func _build_environment() -> void:
# a street canyon is lit almost entirely by ambient, so pure sky ambient
# painted the whole city blue -- blend it half-and-half with neutral
e.ambient_light_sky_contribution = 0.5
e.ambient_light_color = Color(0.88, 0.87, 0.84)
e.ambient_light_energy = 0.55
match time_of_day:
"dusk":
e.ambient_light_color = Color(0.80, 0.62, 0.55)
e.ambient_light_energy = 0.38
"night":
e.ambient_light_color = Color(0.35, 0.40, 0.55)
e.ambient_light_energy = 0.16
_:
e.ambient_light_color = Color(0.88, 0.87, 0.84)
e.ambient_light_energy = 0.55
e.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
e.tonemap_mode = Environment.TONE_MAPPER_ACES
e.tonemap_exposure = 0.6
@ -573,10 +613,28 @@ func _build_environment() -> void:
e.fog_depth_end = 900.0
e.fog_depth_curve = 0.7
e.fog_sun_scatter = 0.15
if time_of_day == "night":
e.glow_intensity = 0.8 # lamp heads and tail lights bloom
e.fog_light_color = Color(0.10, 0.11, 0.16)
elif time_of_day == "dusk":
e.fog_light_color = Color(0.55, 0.38, 0.30)
var env := WorldEnvironment.new()
env.environment = e
add_child(env)
if time_of_day == "night":
# headlights on the player: two warm spots raking the road ahead
for sx in [-0.55, 0.55]:
var hl := SpotLight3D.new()
hl.light_color = Color(1.0, 0.94, 0.78)
hl.light_energy = 4.0
hl.spot_range = 42.0
hl.spot_angle = 28.0
hl.spot_angle_attenuation = 1.6
hl.position = Vector3(sx, 0.15, -car.stats.length * 0.48)
hl.rotation_degrees = Vector3(-4.0, 180.0, 0)
car.add_child(hl)
func _exit_tree() -> void:
Engine.time_scale = 1.0

View File

@ -3,10 +3,12 @@ extends Control
const MODES := [["cruise", "Cruise"], ["crash", "Crash Mode"], ["race", "Race"],
["rage", "Road Rage"], ["drag", "Drag"], ["derby", "Derby"]]
const TIMES := [["day", "Day"], ["dusk", "Dusk"], ["night", "Night"]]
@onready var car_list: ItemList = $VBox/HBox/Cars
@onready var level_list: ItemList = $VBox/HBox/Levels
@onready var mode_list: ItemList = $VBox/HBox/Modes
@onready var time_list: ItemList = $VBox/HBox/Times
var car_paths: Array = []
var level_paths: Array = []
@ -29,6 +31,9 @@ func _ready() -> void:
junction_data.append(j.data)
for m in MODES:
mode_list.add_item(m[1])
for t in TIMES:
time_list.add_item(t[1])
time_list.select(0)
if not car_paths.is_empty():
car_list.select(0)
if not level_paths.is_empty():
@ -45,4 +50,6 @@ func _start() -> void:
Game.junction = junction_data[li]
# picking a CRASH JCT entry forces junction mode, whatever the mode column says
Game.mode = "junction" if not Game.junction.is_empty() else MODES[mode_list.get_selected_items()[0]][0]
var ti := time_list.get_selected_items()
Game.time_of_day = TIMES[ti[0]][0] if not ti.is_empty() else "day"
get_tree().change_scene_to_file("res://src/game.tscn")

View File

@ -51,6 +51,10 @@ size_flags_horizontal = 3
[node name="Modes" type="ItemList" parent="VBox/HBox"]
size_flags_horizontal = 3
[node name="Times" type="ItemList" parent="VBox/HBox"]
size_flags_horizontal = 3
size_flags_stretch_ratio = 0.5
[node name="Start" type="Button" parent="VBox"]
text = "RACE"
theme_override_font_sizes/font_size = 32

View File

@ -1,7 +1,10 @@
#!/usr/bin/env python3
"""Generate a texture on the local MODELBEAST farm (operator: flux_local).
Usage: flux_local.py "<prompt>" <out.jpg> [size_px] [target_luma]
Usage: flux_local.py "<prompt>" <out.jpg> [size_px] [target_luma] [WxH]
size_px is the output's max dimension; WxH overrides the GENERATION aspect
(e.g. 1024x512 for a 2:1 sky panorama). Output keeps the generation aspect.
The fallback for tools/flux_texture.sh when Cloudflare Workers AI returns 429 --
its free tier is 10,000 neurons/day and a full texture-set regen burns through
@ -48,9 +51,12 @@ def main():
luma = float(sys.argv[4]) if len(sys.argv) > 4 else 0.0
tok = token()
gen = max(size, 768) # generate at >=768 then downscale, same as the CF path
if len(sys.argv) > 5 and "x" in sys.argv[5]:
gw, gh = (int(v) for v in sys.argv[5].split("x"))
else:
gw = gh = max(size, 768) # generate at >=768 then downscale, like the CF path
job = call("/api/jobs", tok, {"operator": "flux_local", "params": {
"prompt": prompt, "width": gen, "height": gen, "steps": 6}})
"prompt": prompt, "width": gw, "height": gh, "steps": 6}})
jid = job["id"]
for _ in range(120):
time.sleep(3)
@ -75,7 +81,9 @@ def main():
open(tmp, "wb").write(blob)
from PIL import Image, ImageStat
im = Image.open(tmp).convert("RGB").resize((size, size), Image.LANCZOS)
im = Image.open(tmp).convert("RGB")
scale = size / max(im.size)
im = im.resize((max(1, int(im.width * scale)), max(1, int(im.height * scale))), Image.LANCZOS)
if luma:
mean = ImageStat.Stat(im.convert("L")).mean[0]
if mean > luma: