The Foundations: Building a Tick System
The project began with implementing a simple tick system that fires once per second, a foundational element for any incremental game. However, my focus quickly shifted toward creating a navigable space environment.
Galactic Navigation: Efficiency in Generation
The Galactic Map scene was my first major challenge. To make it efficient, I implemented two dictionaries:
- Spawned Chunks: Tracks currently generated sectors
- Visible Chunks: Recalculated every frame
This approach allows non-visible chunks to be removed and despawned via queuefree()
, significantly optimizing performance. For consistency across gameplay sessions, I implemented seeded RNG to create "stably random" areas that persist in the same state over time, even through despawning and respawning cycles.
From Map to Sectors: Procedural Planet Generation
Clicking a sector on the galactic map transports players into that sector, revealing a star with orbiting planets. The procedural generation of these planets became one of my most rewarding challenges, pushing me to optimize for both visual quality and performance.
Solving the Noise Resolution Problem
Initially, I generated noise on spawn, but could only achieve low-resolution results (32×32 or 48×48) in real-time, far from the visual quality I wanted. This limitation led me to create custom Tools in Godot to pre-generate noise textures, which then needed to be loaded efficiently.
The Loading Screen Milestone
To solve the slow ROM data access issue, I implemented my first-ever loading screen, a small but significant milestone! The system preloads textures into RAM through a global GameManager script:
func load_star_files():
var noise_folder = DirAccess.open("res://Textures/Galactic Map/Star Noise/")
var files : PackedStringArray = noise_folder.get_files()
star_noise_textures_to_load = files.size()
for i in range(files.size()):
files[i] = str("res://Textures/Galactic Map/Star Noise/",files[i])
for f in files:
star_noise_textures_loaded += 1
if (not f.ends_with(".import")): continue
f = f.replace(".import", "")
var img : Texture2D = ResourceLoader.load(f, "CompressedTexture2D")
star_noise_textures.append(img)
call_deferred("star_loading_done")
func star_loading_done():
loading_bar.value = 100
loading_message.text = "Done Loading!"
sector_background_task_id = WorkerThreadPool.add_task(load_sector_backgrounds)
This loading system demonstrates clever resource management by scanning a directory for noise textures, loading them into memory, and tracking progress. The strength of this approach is that it front-loads the heavy resource loading, making runtime performance much smoother. It also provides visual feedback to the player with a loading bar.
For improvement, this could be refactored into a generic resource loader that handles different asset types through configuration rather than duplicating similar code for different resources. Adding error handling for missing files would also make it more robust.
Beautiful Worlds: The Planet Shader
With high-quality noise files now at my disposal, I created a custom shader for making visually interesting 2D planets that "rotate" and offer extensive configuration options:
shader_type canvas_item;
uniform sampler2D land_noise : filter_nearest, repeat_enable;
uniform sampler2D cloud_noise : filter_nearest, repeat_enable;
uniform vec2 spin = vec2(0.2, 0);
uniform vec2 sun_dir = vec2(1.0, 0.0);
uniform vec4 land_color : source_color;
uniform vec4 water_color : source_color;
uniform vec4 atomsphere_color : source_color;
uniform float water_level : hint_range(0.0, 1.0, 0.001);
uniform float snow_level : hint_range(0.0, 1.0, 0.001);
void fragment() {
vec4 mask = COLOR;
vec4 c = texture(land_noise, vec2(UV.x / 2.0 + TIME * spin.x, UV.y / 2.0 + TIME * spin.y)) * mask.a;
vec4 cc = texture(cloud_noise, vec2(UV.x + TIME * (-spin.x * 0.25), UV.y + TIME * (-spin.y * 0.25))) * mask.a;
float sun_dist = clamp(distance(vec2(0.5, 0.5) + normalize(sun_dir) * 0.3, UV), 0.05, 0.49);
float dist = clamp(distance(vec2(0.5, 0.5), UV), 0.05, 0.49);
float ydist = distance(vec2(UV.x, 0.5), UV);
vec2 centeredUV = UV * 2.0 - 1.0;
vec2 spin_vec = normalize(spin);
float distAlongSpin = dot(centeredUV, spin_vec);
float distFromEquator = length(centeredUV - distAlongSpin * spin_vec);
float mask2 = smoothstep(0.3, 0.0, distFromEquator);
float mask3 = smoothstep(0.5, 1.0, distFromEquator);
COLOR *= c;
COLOR = (c.r <= water_level) ?
(COLOR + 0.6) * mask.a * water_color :
(COLOR * (land_color));
//COLOR = (ydist > 0.95 / c.r * 0.175 && (c.r > water_level)) ? COLOR + vec4(0.75,0.75,0.75,1.0) * c.a : COLOR;
//COLOR = (ydist < 0.15 / c.r * 0.30 && (c.r > water_level)) ? COLOR + vec4(1.0,0.0,0.0,1.0) * (1.0 - ydist) * c.a : COLOR;
COLOR = (c.r >= water_level && c.r < water_level + 0.035) ? COLOR + vec4(.55, .55, 0.0, 1.0) : COLOR;
COLOR.rgb = (c.r > water_level + water_level * 0.33 && c.r < snow_level - snow_level * 0.05 && mask2 < 0.08 * (c.r * 7.0) && mask3 < 0.075 * (c.r * 3.0)) ? COLOR.rgb * 0.35 : COLOR.rgb;
COLOR.rgb = (c.r >= water_level + 0.075 && ((mask2 < 0.08 * (c.r * 7.0) && c.r >= snow_level) || mask3 > 0.1 * (c.r * 3.0))) ? COLOR.rgb * c.r + vec3(1.75) : COLOR.rgb;
COLOR.rgb = (c.r > water_level + 0.075 && mask2 > 0.1 * (c.r * 7.0)) ? COLOR.rgb + vec3(0.7, 0.5 , 0.0) : COLOR.rgb;
COLOR += (cc.r > 0.75 + sin(TIME * 0.25) * 0.1) ? vec4(0.7,0.7,0.7,1.0) : vec4(0.0,0.0,0.0,0.0);
vec4 atmo = atomsphere_color * 4.0;
atmo *= dist * 0.1;
COLOR.rgb *= (1.1 - dist * 2.2);
COLOR.rgb += (atmo.rgb);
COLOR.rgb *= (1.75 - sun_dist * 2.75);
}
This shader is quite impressive in how it simulates a rotating planet using 2D noise textures. It creates the illusion of a spherical world with dynamic features like rotating clouds, atmospheric effects, and geographically varied terrain.
The strength lies in its parameterization - everything from water levels to snow coverage can be configured. The way it handles atmospheric scattering based on the sun direction creates convincing lighting effects. The equatorial and polar region handling adds natural-looking variety to the planet's appearance.
A potential improvement might be to refactor some of the more complex conditional logic into functions for better readability. The shader could also benefit from performance optimizations for lower-end devices, perhaps with a quality setting parameter.
Planet Generation in Action
Here's how I use the shader to create unique planets:
class_name Planet
extends Node2D
@export var gfx : Sprite2D
@export var planet_arc : PlanetArc
var planet_seed : int = 0
var sun_pos : Vector2
var planet_mat : Material
var dist_from_star : float
var angle_from_star : float
var orbit_direction : float
var orbit_speed : float
func _process(_delta: float) -> void:
var elapsed_time_sec = Time.get_ticks_msec() / 1000.0
var new_angle_from_star = fmod(elapsed_time_sec * orbit_speed * orbit_direction + angle_from_star, 360)
global_position = sun_pos + Vector2.from_angle(deg_to_rad(new_angle_from_star)).normalized() * dist_from_star
if (planet_mat != null):
planet_mat.set("shader_parameter/sun_dir", sun_pos - global_position)
planet_arc.queue_redraw()
func generate_planet(_planet_seed : int, _sun_pos : Vector2, _sun_color: Color, _distance : float, _angle):
planet_seed = _planet_seed
sun_pos = _sun_pos
dist_from_star = _distance
angle_from_star = _angle
Game_Manager.rng.seed = planet_seed
planet_mat = gfx.material
planet_mat.set("shader_parameter/land_noise",
Game_Manager.planet_noise_textures[
Game_Manager.rng.randi_range(0, Game_Manager.planet_noise_textures.size()-1)
]
)
planet_mat.set("shader_parameter/cloud_noise",
Game_Manager.planet_noise_textures[
Game_Manager.rng.randi_range(0, Game_Manager.planet_noise_textures.size()-1)
]
)
planet_mat.set("shader_parameter/spin",
Vector2(
Game_Manager.rng.randf_range(0.15, 0.05),
Game_Manager.rng.randf_range(0.01, 0.075) * (1 if Game_Manager.rng.randi_range(1, 2) == 1 else -1)
) / 2
)
planet_mat.set("shader_parameter/sun_dir", sun_pos - global_position)
planet_mat.set("shader_parameter/land_color", Color.from_hsv(
Game_Manager.rng.randf_range(0.055, 0.389),
1.0, 1.0, 1.0
))
planet_mat.set("shader_parameter/water_color", Color.from_hsv(
Game_Manager.rng.randf_range(0.555, 0.778),
1.0, 1.0, 1.0
))
planet_mat.set("shader_parameter/atomsphere_color", _sun_color)
planet_mat.set("shader_parameter/water_level", Game_Manager.rng.randf_range(0.4, 0.7))
planet_mat.set("shader_parameter/snow_level", Game_Manager.rng.randf_range(0.8, 0.85))
gfx.scale *= Game_Manager.rng.randf_range(0.66, 1.33)
planet_arc.set_params(_sun_pos, _distance)
orbit_direction = -1 if Game_Manager.rng.randf_range(0, 100) < 50 else 1
orbit_speed = Game_Manager.rng.randf_range(0.5, 1.5)
This Planet class showcases excellent use of seeded randomization to create consistent yet unique celestial bodies. It handles both visual appearance and orbital mechanics, calculating the planet's position based on elapsed time, orbital distance, and speed.
The strength of this approach is how it ties visual elements to gameplay mechanics - each planet has a unique orbit determined by its parameters. The sun direction is dynamically updated each frame to ensure proper lighting effects.
For improvement, the planet generation could be expanded to include different planet types (gas giants, ice planets, etc.) with distinct visual characteristics. The orbital calculations could also account for elliptical orbits rather than perfect circles for more realism.
The Next Frontier: 3D Planets
After taking a short break for coursework, I returned with fresh inspiration: redesigning the system in 3D! The new spatial shader brings planets to life in a three-dimensional space:
shader_type spatial;
uniform sampler2D land_tex;
uniform sampler2D cloud_tex;
uniform vec4 land_color : source_color;
uniform vec4 water_color : source_color;
uniform float water_level : hint_range(-0.5, 0.5, 0.001);
uniform float snow_coverage : hint_range(0.0, 1.0, 0.0001);
uniform float cloud_coverage : hint_range(0.1, 1.0, 0.001);
uniform float continental_effect : hint_range(1.0, 8.0, 0.001);
uniform float continental_drift : hint_range(2.0, 256.0, 2);
uniform float cloud_speed : hint_range(1.0, 1024.0, 0.01);
uniform float water_speed : hint_range(1.0, 1024.0, 0.01);
uniform vec2 rotation_vector = vec2(1.0, 0.0);
void fragment() {
vec4 tex_color = texture(land_tex, UV);
vec4 water_offset = texture(land_tex, vec2(UV.x + TIME * rotation_vector.x / water_speed, UV.y + TIME * rotation_vector.y / water_speed));
vec4 water_offset2 = texture(land_tex, vec2(UV.x - TIME * rotation_vector.y / water_speed / 2.0, UV.y - TIME * rotation_vector.x / water_speed / 2.0));
vec4 cloud_color = texture(cloud_tex, vec2(UV.x + TIME * rotation_vector.x / cloud_speed, UV.y + TIME * rotation_vector.y / cloud_speed));
float dist_from_pole = distance(UV, vec2(UV.x, 0.5));
float polar_cap_cut = 0.3 + tex_color.r / 8.0;
float continental_mod = ((sin(UV.x * PI * continental_drift) + 1.75) / 2.0) / continental_effect;
float mod_water_lever = water_level + dist_from_pole * 1.5 + continental_mod;
//Base texture
ALBEDO = tex_color.rgb;
//Polar Caps
if (dist_from_pole > polar_cap_cut)
ALBEDO = mix(tex_color.rgb, vec3(1.0), clamp(dist_from_pole * 100.0, 0.0, 1.0));
// Water
if (tex_color.r < mod_water_lever && dist_from_pole <= polar_cap_cut)
ALBEDO = min(water_color.rgb, max(tex_color.r + 0.35, 0.8)
* max(water_offset.r + 0.25, 0.65)
* max(water_offset2.r + 0.15, 0.55));
//beaches
else if (tex_color.r < mod_water_lever * 1.10 && dist_from_pole <= polar_cap_cut)
ALBEDO *= vec3(1.0, 1.0, 0.0);
//snow
else if (tex_color.r * dist_from_pole >= (1.0 - snow_coverage) && dist_from_pole <= polar_cap_cut)
ALBEDO = min(vec3(1.0), ALBEDO * (vec3(1.4) * (1.4 - dist_from_pole)));
//land
else if (tex_color.r >= mod_water_lever && dist_from_pole <= polar_cap_cut)
ALBEDO *= land_color.rgb;
//Desert Equator
if (tex_color.r >= mod_water_lever && dist_from_pole < 0.175)
ALBEDO = mix(vec3(1.0, 0.55, 0.0), ALBEDO, clamp(dist_from_pole * 8.0, 0.3, 1.0));
//clouds
if (cloud_color.r >= 1.0 - cloud_coverage + ((sin(TIME / 4.0) + 2.0)/2.0) / 8.0)
ALBEDO = min(vec3(1.0), ALBEDO + cloud_color.rgb * .5);
}
This 3D shader represents a significant evolution in the planet rendering system. It introduces more sophisticated geographical features like continental drift, polar caps, beaches, and equatorial desert regions. The cloud and water movement systems create a convincing sense of planetary weather patterns.
The shader's strength is its layered approach to terrain visualization - handling water, land, snow, and clouds as distinct visual elements affected by geographic position. The continental drift parameter adds natural-looking landmass distributions.
To improve this further, the shader could incorporate normal mapping for surface detail and height-based coloration for mountains and valleys. Performance optimizations like level-of-detail could be implemented to maintain frame rates when viewing planets from different distances.
Looking Forward
The next step is rebuilding the galactic map and sectors entirely in 3D! Working on Astrogarch has been refreshingly different from the usual pressure of game jams or generic software development projects. It's given me space to learn and experiment at my own pace.
Stay tuned for more updates as this sandbox project continues to evolve. Who knows where this space adventure will lead next?
This devlog outlines my progress on Project Astrogarch, a procedural space-faring game built in Godot. Follow along as I continue developing and expanding this learning sandbox!