198 lines
9.2 KiB
GDScript
198 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("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)
|