Give the gas planet surface its own background shader (#51)
lunar_surface.gdshader recoloured was the whole gas surface backdrop,
so a landed gas giant read as regolith - the crater field never went
away, just changed colour. New gas_surface.gdshader replaces it for
gas profiles only: a nearest-row-wins horizontal band field (same
single-scalar discipline as lunar's nearest-crater field), with
drifting per-row turbulence so the bands ripple instead of reading as
ruled lines. Keeps the exact same uniform interface as lunar
(camera_cell/camera_frac/camera_zoom/viewport_size/cell_size/
base_color/crater_color/highlight_color), so LunarBackground.gd needs
no changes at all.
PlanetSurfaceProfile gains background_shader (default null = keep the
scene's existing lunar shader); PlanetSurfaceController._compose_surface()
swaps the background material's shader in only when a profile names one.
GasSurfaceProfile.tres points at the new shader and retunes its palette
to a deep-atmosphere/shadowed-band/lit-crest read; RockySurfaceProfile.tres
is untouched, so the rocky surface renders exactly as before.
Precision: every hash input is either an exact integer cell/row index or
a bounded [0,1) local fraction, same split as lunar_surface.gdshader and
Starfield.gd use for the same reason (world coordinates reach the tens of
thousands, where mobile float precision collapses). TIME-based lateral
drift is folded into the lattice coordinate via the same floor/remainder
trick rather than ever being added to a large cell index directly - see
combine_lattice()'s header note.
Tests: new tests/test_surface_profiles.gd (rocky resolves no
background_shader and keeps the lunar shader; gas resolves and applies
its own, different from lunar's). Full suite: 78/78 passed. Screenshot
of the gas surface confirms drifting bands with no craters, rendered
well inside the softpipe performance budget (20 frames in ~2m40s,
against lunar's "minutes per frame" baseline the ticket warns about).
Repo notes for the next agent, confirmed empirically this session:
- Never reference a plain (non-autoload) class_name class by its bare
global name from a -s entry-point test script - `load()` it by path
into a local var instead, same as PlanetSurfaceController.gd and
PlanetSetups.gd already do for exactly this reason. Doing it the
unsafe way here didn't fail to compile - it corrupted something in
the resource-loading state badly enough that an UNRELATED scene's
Rock.tscn lost its script attachment and RockField's rock-spawning
loop exhausted the physics server's entire RID pool. If a test's
crash trace implicates code you never touched, check every bare
global class_name reference in the test first.
- Running a `tests/screenshot.sh` render (Mesa softpipe, genuinely
minutes for a crater-shader scene) at the same time as a
`run_tests.sh` full-suite run starves both for CPU on this device -
kill one before waiting on the other rather than running them
concurrently.
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
0582181f26
commit
4606c34439
7 changed files with 275 additions and 4 deletions
|
|
@ -1,14 +1,16 @@
|
||||||
[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=3 format=3]
|
[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=4 format=3]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://PlanetSurfaceProfile.gd" id="1"]
|
[ext_resource type="Script" path="res://PlanetSurfaceProfile.gd" id="1"]
|
||||||
[ext_resource type="PackedScene" path="res://GasSurfaceFeatures.tscn" id="2"]
|
[ext_resource type="PackedScene" path="res://GasSurfaceFeatures.tscn" id="2"]
|
||||||
|
[ext_resource type="Shader" path="res://gas_surface.gdshader" id="3"]
|
||||||
|
|
||||||
[resource]
|
[resource]
|
||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
profile_id = "gas"
|
profile_id = "gas"
|
||||||
features_scene = ExtResource("2")
|
features_scene = ExtResource("2")
|
||||||
base_color = Color(0.13, 0.025, 0.22, 1)
|
background_shader = ExtResource("3")
|
||||||
crater_color = Color(0.055, 0.008, 0.11, 1)
|
base_color = Color(0.1, 0.04, 0.22, 1)
|
||||||
highlight_color = Color(0.52, 0.17, 0.7, 1)
|
crater_color = Color(0.045, 0.015, 0.13, 1)
|
||||||
|
highlight_color = Color(0.62, 0.42, 0.85, 1)
|
||||||
caption_color = Color(0.78, 0.88, 0.94, 1)
|
caption_color = Color(0.78, 0.88, 0.94, 1)
|
||||||
caption_outline_color = Color(0.02, 0.01, 0.05, 0.9)
|
caption_outline_color = Color(0.02, 0.01, 0.05, 0.9)
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ func _ready() -> void:
|
||||||
|
|
||||||
func _compose_surface(profile: Resource) -> void:
|
func _compose_surface(profile: Resource) -> void:
|
||||||
var atmosphere: ShaderMaterial = background.material
|
var atmosphere: ShaderMaterial = background.material
|
||||||
|
if profile.background_shader != null:
|
||||||
|
atmosphere.shader = profile.background_shader
|
||||||
atmosphere.set_shader_parameter("base_color", profile.base_color)
|
atmosphere.set_shader_parameter("base_color", profile.base_color)
|
||||||
atmosphere.set_shader_parameter("crater_color", profile.crater_color)
|
atmosphere.set_shader_parameter("crater_color", profile.crater_color)
|
||||||
atmosphere.set_shader_parameter("highlight_color", profile.highlight_color)
|
atmosphere.set_shader_parameter("highlight_color", profile.highlight_color)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ extends Resource
|
||||||
|
|
||||||
@export var profile_id: String = "rocky"
|
@export var profile_id: String = "rocky"
|
||||||
@export var features_scene: PackedScene
|
@export var features_scene: PackedScene
|
||||||
|
# null means "keep the scene's existing background shader" (lunar craters).
|
||||||
|
@export var background_shader: Shader = null
|
||||||
@export var base_color: Color = Color(0.75, 0.75, 0.76, 1.0)
|
@export var base_color: Color = Color(0.75, 0.75, 0.76, 1.0)
|
||||||
@export var crater_color: Color = Color(0.44, 0.44, 0.45, 1.0)
|
@export var crater_color: Color = Color(0.44, 0.44, 0.45, 1.0)
|
||||||
@export var highlight_color: Color = Color(0.89, 0.89, 0.9, 1.0)
|
@export var highlight_color: Color = Color(0.89, 0.89, 0.9, 1.0)
|
||||||
|
|
|
||||||
141
gas_surface.gdshader
Normal file
141
gas_surface.gdshader
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
shader_type canvas_item;
|
||||||
|
|
||||||
|
// Gas giant cloud-band backdrop for a landed gas surface. Same uniform
|
||||||
|
// interface as lunar_surface.gdshader (camera_cell/camera_frac/camera_zoom/
|
||||||
|
// viewport_size/cell_size/base_color/crater_color/highlight_color), so
|
||||||
|
// LunarBackground.gd feeds this shader with no changes at all - only
|
||||||
|
// PlanetSurfaceController swaps which shader the material uses.
|
||||||
|
//
|
||||||
|
// Renders horizontal cloud bands instead of craters: each grid row (a
|
||||||
|
// horizontal strip one cell tall) hashes its own band centre and thickness,
|
||||||
|
// nearest-row-wins picks which band a pixel belongs to, and the resulting
|
||||||
|
// distance field drives colour exactly the way the crater distance field
|
||||||
|
// does in lunar_surface.gdshader - base_color is the deep atmosphere,
|
||||||
|
// crater_color the shadowed band body, highlight_color the lit band crest.
|
||||||
|
// A low-frequency turbulence value perturbs each row's edge so the bands
|
||||||
|
// ripple instead of reading as ruled lines, and slides sideways over time
|
||||||
|
// for the lateral drift.
|
||||||
|
//
|
||||||
|
// PRECISION: never reconstruct an absolute world position. Every value fed
|
||||||
|
// to a hash is either a small camera-relative fraction (local, in [0,1]) or
|
||||||
|
// an exact integer cell/row index (cell, camera_cell + cell_rel) - the same
|
||||||
|
// split lunar_surface.gdshader and Starfield.gd use, for the same reason:
|
||||||
|
// world coordinates reach the tens of thousands, where a mobile float loses
|
||||||
|
// the fractional precision this shader depends on and no `highp` fixes it.
|
||||||
|
// The one exception is TIME, which the drift is built from directly per the
|
||||||
|
// ticket - see combine_lattice() for why that's still safe to mix in.
|
||||||
|
|
||||||
|
uniform vec2 camera_cell = vec2(0.0, 0.0);
|
||||||
|
uniform vec2 camera_frac = vec2(0.0, 0.0);
|
||||||
|
uniform float camera_zoom = 1.0;
|
||||||
|
uniform vec2 viewport_size = vec2(720.0, 1280.0);
|
||||||
|
uniform float cell_size = 220.0;
|
||||||
|
uniform vec4 base_color : source_color = vec4(0.13, 0.025, 0.22, 1.0);
|
||||||
|
uniform vec4 crater_color : source_color = vec4(0.055, 0.008, 0.11, 1.0);
|
||||||
|
uniform vec4 highlight_color : source_color = vec4(0.52, 0.17, 0.7, 1.0);
|
||||||
|
|
||||||
|
// Row scan range. 4 rows x a handful of hashes each is far cheaper than
|
||||||
|
// lunar's 5x5 (25-sample) crater scan - this shader has to stay renderable
|
||||||
|
// through the softpipe software rasterizer the screenshot harness uses.
|
||||||
|
const int ROW_SCAN = 2; // rows -1..+2
|
||||||
|
const float MIN_THICKNESS = 0.25;
|
||||||
|
const float MAX_THICKNESS = 0.55;
|
||||||
|
const float TURBULENCE_STRENGTH = 0.22;
|
||||||
|
const float DRIFT_SPEED = 0.045; // cells per second, lateral
|
||||||
|
|
||||||
|
// Same hash21 formulation as lunar_surface.gdshader (Dave Hoskins, all
|
||||||
|
// multipliers < 1 so it stays accurate at any cell-index magnitude).
|
||||||
|
float hash21(vec2 p) {
|
||||||
|
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
|
||||||
|
p3 += dot(p3, p3.yzx + 33.33);
|
||||||
|
return fract((p3.x + p3.y) * p3.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Splits a float into an exact integer part and a [0,1) remainder - the
|
||||||
|
// same floor/subtract trick `rel` -> `cell_rel` + `local` already uses
|
||||||
|
// below, generalized so it can also fold TIME-based drift into a lattice
|
||||||
|
// coordinate without ever forming one large continuous float.
|
||||||
|
vec2 int_frac(float v) {
|
||||||
|
float i = floor(v);
|
||||||
|
return vec2(i, v - i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combines two already-safe (integer, fraction) pairs - e.g. a cell index
|
||||||
|
// plus a drift offset - by adding the integers (exact, since both are
|
||||||
|
// integers) and the fractions (both in [0,1), so the sum is in [0,2)),
|
||||||
|
// then re-normalizing any carry out of the fraction back into the integer
|
||||||
|
// part. Never adds an integer directly to a fraction.
|
||||||
|
vec2 combine_lattice(vec2 base, vec2 add) {
|
||||||
|
float f = base.y + add.y;
|
||||||
|
float carry = floor(f);
|
||||||
|
return vec2(base.x + add.x + carry, f - carry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1D value noise over an integer lattice, given as a pre-split (i, f) pair:
|
||||||
|
// hashes the two neighboring integers and blends by the fraction. `axis`
|
||||||
|
// varies the hash sequence so different callers (different rows, or the
|
||||||
|
// turbulence octaves) don't sample the same noise.
|
||||||
|
float value_noise(vec2 coord, float axis) {
|
||||||
|
float a = hash21(vec2(coord.x, axis));
|
||||||
|
float b = hash21(vec2(coord.x + 1.0, axis));
|
||||||
|
return mix(a, b, smoothstep(0.0, 1.0, coord.y));
|
||||||
|
}
|
||||||
|
|
||||||
|
void fragment() {
|
||||||
|
vec2 screen_px = UV * viewport_size;
|
||||||
|
vec2 centered = screen_px - viewport_size * 0.5;
|
||||||
|
|
||||||
|
// Identical split to lunar_surface.gdshader: `rel` is always small and
|
||||||
|
// camera-relative, `cell` is an exact integer built from two integers
|
||||||
|
// (camera_cell + cell_rel), and `local` is the bounded [0,1) remainder.
|
||||||
|
vec2 rel = camera_frac + centered * (camera_zoom / cell_size);
|
||||||
|
vec2 cell_rel = floor(rel);
|
||||||
|
vec2 local = rel - cell_rel;
|
||||||
|
vec2 cell = camera_cell + cell_rel;
|
||||||
|
|
||||||
|
// Lateral drift, folded into the X lattice coordinate the safe way:
|
||||||
|
// TIME is unbounded but session-scale (the ticket calls for using it
|
||||||
|
// directly, no extra uniform), so it gets its own int/frac split and is
|
||||||
|
// combined with the cell's via combine_lattice() rather than added to
|
||||||
|
// cell.x as one continuous float.
|
||||||
|
vec2 drift = int_frac(TIME * DRIFT_SPEED);
|
||||||
|
vec2 turb_x = combine_lattice(vec2(cell.x, local.x), drift);
|
||||||
|
|
||||||
|
// Nearest-row-wins band field, the same single-scalar discipline as
|
||||||
|
// lunar_surface.gdshader's nearest-crater distance: every visual below
|
||||||
|
// is a pure function of `nearest`, so the band body and its crest can
|
||||||
|
// never describe two different bands.
|
||||||
|
float nearest = 1000.0;
|
||||||
|
for (int dy = -1; dy <= ROW_SCAN; dy++) {
|
||||||
|
float row = cell.y + float(dy);
|
||||||
|
float band_centre = hash21(vec2(row, 5.0));
|
||||||
|
float thickness = mix(MIN_THICKNESS, MAX_THICKNESS, hash21(vec2(row, 8.0)));
|
||||||
|
|
||||||
|
// Two octaves of drifting turbulence, phased per-row so neighboring
|
||||||
|
// bands don't ripple in lockstep.
|
||||||
|
float turb = value_noise(turb_x, row * 3.7 + 11.0) * 0.65
|
||||||
|
+ value_noise(vec2(turb_x.x * 2.0, turb_x.y), row * 3.7 + 47.0) * 0.35;
|
||||||
|
|
||||||
|
// Safe: local.y is bounded [0,1), dy and row are small integers, so
|
||||||
|
// this stays a small camera-relative value with no huge magnitude.
|
||||||
|
float to_band = (local.y - float(dy)) - band_centre;
|
||||||
|
to_band += (turb - 0.5) * TURBULENCE_STRENGTH;
|
||||||
|
|
||||||
|
float normalized = abs(to_band) / thickness;
|
||||||
|
nearest = min(nearest, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
float core = 1.0 - smoothstep(0.0, 0.6, nearest);
|
||||||
|
float crest = smoothstep(0.5, 0.75, nearest) * (1.0 - smoothstep(0.75, 1.0, nearest));
|
||||||
|
|
||||||
|
vec3 color = base_color.rgb;
|
||||||
|
color = mix(color, crater_color.rgb, core * 0.85);
|
||||||
|
color = mix(color, highlight_color.rgb, crest * 0.6);
|
||||||
|
|
||||||
|
// Same dither as lunar_surface.gdshader, to mask 8-bit banding on the
|
||||||
|
// smooth band gradients.
|
||||||
|
float dither = (hash21(screen_px + vec2(7.0, 13.0)) - 0.5) / 128.0;
|
||||||
|
color += vec3(dither);
|
||||||
|
|
||||||
|
COLOR = vec4(color, 1.0);
|
||||||
|
}
|
||||||
20
tests/_rocky_surface_preview.gd
Normal file
20
tests/_rocky_surface_preview.gd
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
extends Node2D
|
||||||
|
|
||||||
|
# Preview, not a test - #51's "the rocky surface is unchanged" acceptance
|
||||||
|
# criterion. Mirrors _gas_surface_preview.gd exactly except for the planet
|
||||||
|
# type, so the two screenshots are a fair side-by-side comparison.
|
||||||
|
#
|
||||||
|
# bash tests/screenshot.sh res://tests/_rocky_surface_preview.tscn /tmp/rocky.png 20 720x1280
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
GameState.planet_name = "Kepler's Rest"
|
||||||
|
GameState.planet_definition_id = "rocky"
|
||||||
|
GameState.surviving_ship_count = 3
|
||||||
|
GameState.rocks = 128
|
||||||
|
GameState.gas = 46
|
||||||
|
GameState.fleet_cargo = 34
|
||||||
|
GameState.fleet_gas_cargo = 17
|
||||||
|
var surface: Node = load("res://PlanetSurface.tscn").instantiate()
|
||||||
|
surface.get_node("SurfaceController").fade_duration = 0.0
|
||||||
|
add_child(surface)
|
||||||
6
tests/_rocky_surface_preview.tscn
Normal file
6
tests/_rocky_surface_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://tests/_rocky_surface_preview.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="RockySurfacePreview" type="Node2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
98
tests/test_surface_profiles.gd
Normal file
98
tests/test_surface_profiles.gd
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Headless gameplay test: run with
|
||||||
|
# godot --headless -s res://tests/test_surface_profiles.gd
|
||||||
|
# Forgejo #51: a landed gas giant needs its own background shader instead of
|
||||||
|
# lunar_surface.gdshader recoloured (craters reading as regolith, not
|
||||||
|
# atmosphere). Verifies:
|
||||||
|
# 1. the gas profile resolves a background_shader different from rocky's
|
||||||
|
# (rocky's stays null - "keep the scene's existing shader")
|
||||||
|
# 2. PlanetSurfaceController._compose_surface() actually swaps the
|
||||||
|
# background material's shader when landing on a gas giant
|
||||||
|
# 3. landing on a rocky planet leaves the scene's own lunar shader in
|
||||||
|
# place - a null background_shader is a no-op, not a forced reset
|
||||||
|
#
|
||||||
|
# PlanetSurfaceController's _ready() (which calls _compose_surface()) does
|
||||||
|
# not fire synchronously inside add_child() for a -s entry-point script -
|
||||||
|
# it's deferred to the next frame, same as ResourceReadout's was for #49 -
|
||||||
|
# so every read of the composed shader below waits a frame after add_child()
|
||||||
|
# rather than checking on the same one.
|
||||||
|
|
||||||
|
var game_state: Node
|
||||||
|
var lunar_shader: Shader
|
||||||
|
var gas_shader: Shader
|
||||||
|
var rocky_shader_after_compose: Shader
|
||||||
|
var scene: Node
|
||||||
|
var stage: int = 0
|
||||||
|
var elapsed: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
game_state = root.get_node("GameState")
|
||||||
|
lunar_shader = load("res://lunar_surface.gdshader")
|
||||||
|
|
||||||
|
# Loaded by path into a local, not referenced by its bare global
|
||||||
|
# class_name - same load-order hazard PlanetSetups.gd and
|
||||||
|
# PlanetSurfaceController.gd already work around: the class_name cache
|
||||||
|
# isn't guaranteed warm before an import pass has run.
|
||||||
|
var surface_profiles: Script = load("res://PlanetSurfaceProfiles.gd")
|
||||||
|
var rocky_profile: Resource = surface_profiles.get_profile("rocky")
|
||||||
|
var gas_profile: Resource = surface_profiles.get_profile("gas")
|
||||||
|
if rocky_profile.background_shader != null:
|
||||||
|
print("TEST_SURFACE_PROFILES: FAIL (rocky profile sets a background_shader - it should stay null)")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
if gas_profile.background_shader == null:
|
||||||
|
print("TEST_SURFACE_PROFILES: FAIL (gas profile has no background_shader set)")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
gas_shader = gas_profile.background_shader
|
||||||
|
if gas_shader == lunar_shader:
|
||||||
|
print("TEST_SURFACE_PROFILES: FAIL (gas profile points at the lunar shader, not its own)")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
game_state.planet_definition_id = "rocky"
|
||||||
|
scene = load("res://PlanetSurface.tscn").instantiate()
|
||||||
|
root.add_child(scene)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> bool:
|
||||||
|
elapsed += delta
|
||||||
|
match stage:
|
||||||
|
0:
|
||||||
|
if elapsed < 0.2:
|
||||||
|
return false
|
||||||
|
var rocky_bg: ShaderMaterial = scene.get_node("BackgroundLayer/LunarBackground").material
|
||||||
|
rocky_shader_after_compose = rocky_bg.shader
|
||||||
|
scene.free()
|
||||||
|
|
||||||
|
game_state.planet_definition_id = "gas_giant"
|
||||||
|
scene = load("res://PlanetSurface.tscn").instantiate()
|
||||||
|
root.add_child(scene)
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 1
|
||||||
|
return false
|
||||||
|
1:
|
||||||
|
if elapsed < 0.2:
|
||||||
|
return false
|
||||||
|
var gas_bg: ShaderMaterial = scene.get_node("BackgroundLayer/LunarBackground").material
|
||||||
|
scene.free()
|
||||||
|
|
||||||
|
if rocky_shader_after_compose != lunar_shader:
|
||||||
|
print("TEST_SURFACE_PROFILES: FAIL (landing on a rocky planet changed the background shader)")
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
|
if gas_bg.shader != gas_shader:
|
||||||
|
print("TEST_SURFACE_PROFILES: FAIL (landing on a gas giant did not swap in gas_surface.gdshader)")
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
|
if gas_bg.shader == lunar_shader:
|
||||||
|
print("TEST_SURFACE_PROFILES: FAIL (gas surface still uses the lunar shader)")
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
|
|
||||||
|
print("TEST_SURFACE_PROFILES: PASS")
|
||||||
|
quit(0)
|
||||||
|
return true
|
||||||
|
return false
|
||||||
Loading…
Reference in a new issue