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>
This commit is contained in:
parent
672907ea0c
commit
657ca9d035
11 changed files with 845 additions and 74 deletions
|
|
@ -3,17 +3,142 @@ 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))
|
||||
|
||||
material.set_shader_parameter("camera_cell", cell)
|
||||
# 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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue