🪐 Solo indie dev building Project: Hadean where Space 4X meets Strategic Roguelite.

⚙️ High-performance tools for Godot, Unity, UE.

🛠️ Creator of Whappens app.

  • 4 posts
  • 0 comments
Joined 30 days ago
Cake day: July 25th, 2026

Project: Hadean chronicles.

Particle-based glow system retired. Shader-based flares and sunspots now in production, fully verified visually.

First Obstacle: The Edge-On Mesh

The Sun’s CPU particle system rendered as a tiny red speck instead of a soft corona. After ruling out data issues, configuration problems, and surface artifacts, the real culprit revealed itself via a diagnostic force-opaque test. The glow quad mesh was facing edge-on to the camera, rendering to near-zero width under the fixed top-down perspective. Once this geometry issue was diagnosed, other mechanical problems became clear.

The death-alpha on the color ramp had been commented out—particles just popped rather than fading. Outward radial acceleration was an entire hemisphere pointed inward, creating a frozen shell instead of a breathing corona.

The Pivot: Swap Particles for Shaders

CPU particles bring hidden costs: per-element simulation, alpha sorting, lifetime checks, and overdraw stacking. For a feature meant to complement a shader-driven surface, the limitations outweighed the benefits.

The final decision: add flares and sunspots directly into the fragment shader. Eliminates draw calls, removes CPU simulation, and lifts the performance and visual ceiling.

Shader Suite: Intrinsic Sun Effects

The new sun sphere shader introduces two independent organic effects:

  • Flare Overlay — bright patches sampled from value noise and masked in via additive blending with soft fwidth() edges. Controls handle frequency, threshold, and intensity.
  • Sunspot Darkening — dark patches applied as shadows on the surface. To avoid visual clashes with flares, a separate noise sample uses different scale and a fixed phase offset, applied as a multiplicative dimmer after all additive light contributions.

Both effects share a 2-hash, 3-octave value-noise utility—cheap, localized, and flickering in real time.

Visual Duels: Tuning by Screenshot

Several subtle regressions emerged with each adjustment, each caught and corrected through visual verification:

  • After decoupling flare frequency from texture tiling, the result looked fractured along hash-lattice edges. Switched to separate spherical UV sampling to fix aliasing.
  • With wavelength-limited scale, large regions of the disc interpolated into giant blocky polygons. Increased scale and added a third finer noise octave for smoother, smaller patches.
  • Early mixing caused sunspots to be invisible, hidden by additive core and rim passes that ignored the darkening mask. Moved the pass after all additive contributions.
  • Sunspot coverage was too heavy, producing oversized, blurry blotches. Tightened threshold and raised scale for sparse, smaller, crisper spots.

Outcome

The particle system code remains in place but dormant, with the glow emission call commented out. A planned hybrid (shader + dedicated arc sprites) was discussed but not adopted. The shader implementation now delivers a living, breathing corona without expensive particle infrastructure.

Project: Hadean chronicles.

Drunk yaw. Ships were flying like they’d had one too many at the cantin: pure proportional yaw meant no drag on angular velocity, so every heading correction sailed past the target and wobbled back. Dropped a D term on angular velocity. Now they settle like they mean it.

Docking death-spiral. The behavior planner checked emergency HP before docked status. A docked ship still patching its hull got reclassified as “emergency” every tick, which silently disabled both healing and hazard immunity. Reordered: docked wins. Always. No exceptions.

The overshoot plague. Ships barreled into docks and loot at max throttle, tunneling clean through the interaction radius on pure momentum. Same thing at hazards: they started the avoidance turn fine, but inertia carried them into the danger zone anyway. One shared cause, one shared fix — STEERING_ARRIVAL_RADIUS now clamps approach speed as targets get close. It’ll tune per capture radius later, as long as it stays under 65 so combat orbit hysteresis doesn’t get confused.

Combat strobe light. A single hard distance threshold meant ships at the boundary flickered between orbit and charge — two headings ~90° apart, flipping every frame. Replaced with hysteresis: separate enter/exit radii, orbit state remembered between ticks. Stable at all ranges.

“0 units from hazard.” Every planet was screaming about a collision that didn’t exist because Position was first assigned in _PhysicsProcess, and synchronous scene-load spawns beat it to the punch. Seeded position eagerly at setup with the same orbital formula and delta=0; physics ticks just update from there.

The invisible fleet. Everything rendered: ships, sun, background, all sharing the correct World3D. But there was no Camera3D — only a leftover Camera2D from the before-times. Zero errors, zero output, zero fleet on screen. Added a proper top-down Camera3D, evicted the dead legacy background scene that was still being instanced alongside the new one, and the armada reappeared.

Tiny win. Velocity-clamp check was doing a sqrt every tick for the 99% happy path where nothing needed clamping. Swapped to a squared-threshold compare. Minimal, but it adds up.

Archived footage of star system view before the global refactoring.

How global singletons were killed, async race conditions resolved, and the door for custom player modifications opened

❌ The Problem

  • Development stagnation: Monolithic generation and logic controller bottlenecked project progress.
  • High risks: Minor changes threatened to break completely unrelated systems.
  • Cascading failures: Removing the legacy global singleton caused endless compilation errors.
  • Pointer errors: Instantiating subsystems before scene tree attachment broke configuration loading ($NullPointer$).

🔄 The Struggle

  • Dead-end refactoring: Trimming the singleton without changing architecture immediately broke builds.
  • Rigid hardcoding: Directory paths only functioned within one strictly defined file structure.
  • Modding unviability: Static paths completely prevented players from installing custom user mods.

The Solution

  • Separation of concerns: Monolith split into independent submodules via Single Responsibility Principle.
  • Dependency injection: Global singletons replaced with explicit, controlled dependency passing.
  • Asynchronous readiness: Added scene tree checks before generation, eliminating race conditions.
  • Dynamic discovery: Engine now automatically locates resources regardless of folder structure.
  • Language migration: Rewrote code from GDScript to C# for efficient modular development.

The architectural evolution of the rendering pipeline in the space 4X/roguelite hybrid Project: Hadean recently shifted from an isolated multi-viewport structure to a single, high-performance 3D scene.

The Discarded Path: The Hybrid 2D/3D Viewport Setup

The initial rendering layer nested an isolated 3D SubViewport inside a 2D SubViewportContainer for every single planet. This approach aimed to let 2D orbit physics coexist with fully shaded 3D planetary surfaces. However, scaling this architecture introduced severe performance bottlenecks:

  • Lifecycle Race Conditions: Initialization logic inside _enter_tree() triggered frequent ERR_FILE_NOT_FOUND engine failures because global configuration files loaded before @onready node references were resolved. Moving setup to _ready() and caching node references resolved the issue, eliminating frame micro-stutters caused by repeated get_node_or_null() lookups.
  • VRAM and Resolution Spikes: Every planet rendered its own isolated World3D, duplicating render passes and light setups. Forcing runtime viewport resizing between 64×64 and 2048×2048 via UPDATE_WHEN_VISIBLE failed to keep VRAM overhead within stable bounds as the simulated solar systems expanded.

The Breakthrough: Migration to a Unified World3D

To ensure stable VRAM usage and fluid frame rates, the multi-viewport architecture was completely dismantled. All planetary bodies now inhabit a single, monolithic Node3D scene sharing a common World3D instance, viewed through a locked top-down orthographic Camera3D that handles panning and zoom. Real-time shadows are now efficiently handled via distance-culling rather than being baked per-object.

Shading and Orthographic Lighting Refactors

Planet shading completely overrides Godot’s default PBR lighting pass for maximum performance. Custom lighting logic is executed directly inside the light() shader function, utilizing the ATTENUATION parameter for pure occlusion mapping.

Fixing the sun lighting system to track correct orbital positions under top-down orthography required three major iterations:

  1. DirectionalLight3D: Produced parallel light rays from infinity, causing the day/night terminator line to twist 90–180° during planet orbits.
  2. Synced Global Vectors: Rebuilding shaders to use MODEL_MATRIX * vec4(TANGENT, 0.0) isolated normal mapping from camera transformations, preventing normals from collapsing into funnel-shaped artifacts at the poles. However, directional lighting still failed to track the real on-screen position of a nearby point-source sun.
  3. OmniLight3D Nodes: Spawning an OmniLight3D node at the exact spatial coordinates of the sun successfully resolved the polar vector collapse and fixed the terminator tracking.

Resolved Migration Traps

  • Tree Membership Trap: Querying GlobalPosition during synchronous node setup before integration into the active scene graph caused engine failures with !is_inside_tree() warnings. Implementing explicit is_inside_tree() validation guards resolved the hidden failures.
  • Unit Scaling Discrepancy: A legacy sprite-era pixel scale factor was accidentally mapped to the 3D mesh transformation, shrinking planets down to ~6 world units. Metrics are now strictly locked to canonical world-unit radius fields.