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)