spaceship/LunarBackground.gd
Loïc Wisniewski 657ca9d035 Make the lunar backdrop cheap: 3x3 field, then bake and tile it (#68)
The crater shader ran on every pixel of a fullscreen rect every frame and
was by far the most expensive thing in the surface scene. Nothing else came
close: RockField keeps 14 rocks alive, Rock/Ship/Enemy already avoid
per-frame queue_redraw() for transform-only changes, and LunarBackground.gd
is a handful of scalar ops.

Three reductions to the field itself:

- 5x5 -> 3x3 neighbour scan. That width was never the geometric answer;
  it was insurance against craters seen clipping at cell boundaries
  on-device, i.e. a crater reaching past its nominal radius because a hash
  returned a value outside [0,1] and extrapolated mix(). Clamping the
  crater CENTRE into its own cell, alongside the radius clamp already
  there, bounds the reach whatever the hash returns - so a crater two cells
  out is >= 1.0 cells away, a normalized distance of >= 2.63, past every
  threshold the shading reacts to (the last saturates at 1.15). The outer
  ring provably cannot change a pixel. This removes the clipping bug's
  cause instead of hiding it behind 16 extra samples.
- One hash42 per cell instead of hash21 twice plus hash22, decorrelating
  on output lanes rather than input offsets.
- Squared distances in the loop, square-rooted twice at the end rather
  than once per sample.

Measured with the new tests/profile_shader.sh, above softpipe's own ~657ms
fullscreen floor: 9482ms -> 405ms of shader work, a 23x reduction.

Then the larger change: the field is a fixed function of world position, so
every frame recomputed an identical answer. It is now made periodic (the
cell index is wrapped before hashing, which makes a Worley field tile
exactly rather than merely blend), baked once at load into one tile, and
scrolled with repeat wrapping by lunar_tiled.gdshader - one texture fetch
per pixel. Two honest costs: the field repeats every 7040 world units, and
a fixed-resolution tile softens when zoomed past 1:1. Lunar regolith is
forgiving of both, having no landmarks and being smooth gradients already.

The bake waits a frame and asks the material whether it actually samples a
field_texture, so a gas giant - which swaps in its own background shader
(#51) - does not pay for a 2048-square tile it will never read. Headless
runs skip it entirely, having nothing to rasterize.

tests/test_lunar_field.gd guards the reasoning, since a fragment shader
cannot be asserted on directly: the premises are still in the source, the
worst-case ring-2 geometry stays clear of every threshold independent of
the hash, a 3x3 and a 5x5 scan render identically over 4000 sampled points,
the field repeats exactly in both axes, and the miss sentinel still cannot
overflow mediump into +Inf and then NaN.

test_surface_profiles.gd asserted "a rocky landing leaves the background
alone" by naming lunar_surface.gdshader; it now reads the shader from the
scene, which is the property it actually cares about.

PlanetSurface.tscn's edit also carries the planet-name relocation described
in the HUD commit - same scene file, so the two cannot be split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:14 +02:00

144 lines
6.7 KiB
GDScript

extends ColorRect
# Feeds the lunar shader the camera's position split into a CELL INDEX plus
# a fraction within that cell, rather than a raw world position. See the
# long note in Starfield.gd for why - identical reasoning, identical split.
#
# Forgejo #68: also bakes the crater field once, at load, instead of letting
# it be re-derived per pixel per frame. The field is a fixed function of
# world position, so every frame was recomputing an identical answer. It is
# rendered once into a square texture covering one period of the field (the
# field is periodic - see the cell wrap in lunar_surface.gdshader) and
# lunar_tiled.gdshader then scrolls that texture with repeat wrapping, which
# keeps the canvas infinite while the per-frame cost drops to one fetch.
# Single source of truth for the tile period, shared with BOTH shaders. The
# field repeats every TILE_CELLS * cell_size world units - at 32 cells of
# 220 that is 7040 units, several screens wide at default zoom.
const TILE_CELLS: float = 32.0
# Texture resolution for the bake, in pixels per cell. One cell is cell_size
# (220) world units, so this is a ~3.4x magnification at default zoom. That
# is affordable because the field is smooth by construction: the sharpest
# feature is the rim band, roughly 0.12 cells wide, which still lands across
# ~8 texels here and so survives bilinear magnification as a gradient rather
# than as steps.
const BAKE_PIXELS_PER_CELL: int = 64
# The palette, sampled from a NASA Artemis lunar photograph. These live on
# the node rather than as parameters of the drawing material because the
# shader that actually consumes them is the BAKE shader, which the scene
# never references directly - leaving them as orphan parameters on the tiled
# material would make them look live when they are only read once at load.
@export var base_color: Color = Color(0.75, 0.75, 0.76)
@export var crater_color: Color = Color(0.44, 0.44, 0.45)
@export var highlight_color: Color = Color(0.89, 0.89, 0.90)
@onready var camera: Camera2D = get_node("../../Camera2D")
func _ready() -> void:
_bake_field()
# Asked of the shader itself rather than by comparing against a known path,
# so this keeps working whichever shader a surface profile swaps in: the
# question is only ever "does what is drawing this background actually
# sample a baked field".
func _material_wants_baked_field() -> bool:
if material == null or material.shader == null:
return false
for uniform in material.shader.get_shader_uniform_list():
if String(uniform.get("name", "")) == "field_texture":
return true
return false
func _process(_delta: float) -> void:
var viewport_size: Vector2 = get_viewport_rect().size
var cell_size: float = material.get_shader_parameter("cell_size")
var p: Vector2 = camera.global_position / cell_size
var cell: Vector2 = Vector2(floor(p.x), floor(p.y))
# The tiled shader samples a repeating texture, so only the camera's
# position WITHIN the tile matters. Wrapping here rather than in the
# shader keeps the raw cell index - which reaches the thousands after a
# while - out of a mobile float entirely: GDScript does this in double
# precision. fposmod, not fmod, so flying into negative coordinates
# wraps to the far side of the tile instead of mirroring.
material.set_shader_parameter("camera_cell", Vector2(
fposmod(cell.x, TILE_CELLS), fposmod(cell.y, TILE_CELLS)))
material.set_shader_parameter("camera_frac", p - cell)
material.set_shader_parameter("camera_zoom", 1.0 / camera.zoom.x)
material.set_shader_parameter("viewport_size", viewport_size)
material.set_shader_parameter("tile_cells", TILE_CELLS)
# Renders exactly one period of the procedural field into a texture and hands
# it to the tiled shader. Runs once, during the planet-arrival fade, where a
# single expensive frame is invisible.
func _bake_field() -> void:
# Headless runs - every scene-based test - rasterize nothing, so a bake
# would allocate a render target, await a draw that never produces
# anything readable, and leave this coroutine suspended forever holding
# the SubViewport alive. Nothing is lost by skipping: there is no display
# for the background to appear on.
if DisplayServer.get_name() == "headless":
return
# One frame of grace before committing to the bake. A surface profile can
# replace this material's shader outright - a gas giant brings its own
# background (#51) - and PlanetSurfaceController does that in _ready(),
# alongside this one, with no ordering guarantee between them. Rendering
# a 2048-square lunar tile for a shader that will never sample it would
# waste exactly the frame the player is already waiting on to land.
await get_tree().process_frame
if not _material_wants_baked_field():
return
var procedural: Shader = load("res://lunar_surface.gdshader")
if procedural == null:
push_warning("lunar background: could not load the field shader to bake")
return
var side: int = int(TILE_CELLS) * BAKE_PIXELS_PER_CELL
var bake_material := ShaderMaterial.new()
bake_material.shader = procedural
# Carry the look across from whatever the scene configured, so the tile
# is baked with the colours actually set on this node rather than the
# shader's own defaults.
bake_material.set_shader_parameter("base_color", base_color)
bake_material.set_shader_parameter("crater_color", crater_color)
bake_material.set_shader_parameter("highlight_color", highlight_color)
bake_material.set_shader_parameter("cell_size", material.get_shader_parameter("cell_size"))
bake_material.set_shader_parameter("tile_cells", TILE_CELLS)
# Non-zero switches the field shader into bake mode: UV maps straight
# onto one tile and the per-screen-pixel dither is left out.
bake_material.set_shader_parameter("bake_cells", TILE_CELLS)
var sub_viewport := SubViewport.new()
sub_viewport.size = Vector2i(side, side)
sub_viewport.disable_3d = true
sub_viewport.transparent_bg = false
sub_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
var bake_rect := ColorRect.new()
bake_rect.material = bake_material
bake_rect.size = Vector2(side, side)
sub_viewport.add_child(bake_rect)
add_child(sub_viewport)
# One frame for the SubViewport to actually draw, then read it back.
await RenderingServer.frame_post_draw
var image: Image = sub_viewport.get_texture().get_image()
sub_viewport.queue_free()
# Headless runs (every scene-based test) render nothing at all, so there
# is no image to read back. That is not a failure: the background simply
# has nothing to show, and the rest of the scene is unaffected.
if image == null or image.is_empty():
return
# Mipmaps are what keep a crater field from shimmering when the camera
# zooms out and each screen pixel starts covering many texels.
image.generate_mipmaps()
var texture := ImageTexture.create_from_image(image)
material.set_shader_parameter("field_texture", texture)