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>
144 lines
6.7 KiB
GDScript
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)
|