spaceship/surface/SurfaceBackground.gd
2026-09-03 22:31:32 +02:00

199 lines
9.2 KiB
GDScript

extends ColorRect
# THE surface backdrop. Not "the one PlanetSurface.tscn happens to use" -
# both the landed surface and the setup editor instance the SAME
# SurfaceBackground.tscn, and both configure it through the SAME entry point
# (apply_profile below). That is deliberate: what the editor shows is only
# useful if it is what the player will actually see, and the two drifted
# apart the moment they were two hand-written ShaderMaterials in two scene
# files - the editor kept drawing recoloured craters for a gas giant,
# because swapping in the profile's own shader had only ever been written on
# the game's side. There is now one place to change, so there is nothing
# left to keep in sync by hand.
#
# Feeds the 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 the scene starts with, before any profile is applied. Every
# host calls apply_profile() and overwrites these; they exist so the node
# still draws something sane on its own (and so the editor's own first frame
# is not undefined).
@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)
var camera: Camera2D = null
# What the current baked texture was baked FROM. A bake is only worth
# redoing when one of these changed - the editor re-applies a profile on
# every cycle of the profile button, and rocky and void share a shader while
# differing only in colour, so keying on the shader alone would show void's
# palette with rocky's crater texture.
var _baked_key: Array = []
var _provided_field_texture: Texture2D = null
# Applies a surface profile to this backdrop: which shader draws it, and the
# three colours that shader shades with. The single place either host is
# allowed to configure the background from.
#
# The profile's shader is authoritative and always applied. There used to be
# a "null means keep whatever the scene already had" convention here, which
# is precisely how the two scenes came apart: "whatever the scene already
# had" was a different shader in each of them.
func apply_profile(profile: Resource) -> void:
if profile == null or material == null:
return
base_color = profile.base_color
crater_color = profile.crater_color
highlight_color = profile.highlight_color
if profile.background_shader != null:
material.shader = profile.background_shader
_provided_field_texture = profile.background_field_texture
material.set_shader_parameter("cell_size", profile.background_cell_size)
material.set_shader_parameter("base_color", base_color)
material.set_shader_parameter("crater_color", crater_color)
material.set_shader_parameter("highlight_color", highlight_color)
if _provided_field_texture != null and _material_wants_baked_field():
material.set_shader_parameter("field_texture", _provided_field_texture)
_baked_key = [material.shader, _provided_field_texture]
return
_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:
# Resolved lazily from the viewport rather than by a hardcoded sibling
# path: this scene is instanced under two different hosts, and a path
# that happens to be right in both is a coincidence waiting to be broken
# by the next host.
if not is_instance_valid(camera):
camera = get_viewport().get_camera_2d()
if camera == null:
return
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.
#
# The procedural shaders (gas, research lab) get the same wrapped index.
# They hash it rather than sampling a texture with it, so the wrap makes
# their fields periodic too - which is invisible at this period, and is
# what keeps every value they hash small enough to stay exact in a
# mobile float.
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 when a profile that samples a baked field is
# applied - 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
if not _material_wants_baked_field():
return
var key: Array = [material.shader, base_color, crater_color, highlight_color]
if key == _baked_key:
return
_baked_key = key
var procedural: Shader = load("res://shaders/lunar_surface.gdshader")
if procedural == null:
push_warning("surface 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 profile configured, so the tile
# is baked with the colours actually in use 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()
if image == null or image.is_empty():
# Nothing rasterized, so nothing was learned - drop the key so a
# later apply of the same profile tries again rather than assuming
# a texture is already in place.
_baked_key = []
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)