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)