2d Sun with Bloom filter Coronal flares
This is a 2d sun shader I built for a game I am making. The shader uses procedural rendering to calculate a 3D noise coordinates to a 2D projection.
The attempt started with a 3D Fractal Brownian Motion (FBM) calculation but that was unnecessarily heavy on computation. This is an optimized version where we simplify the trignometry calculations by making a few assumptions.
In my game, the idea is to turn down the Octaves based on player zoom level. That way, we dont waste computation for generating details that the player cant see at a certain zoom level anyway.
Please use this as a learning template and modify it according to your game needs.
You can play with the shader setting controls in the live preview below. Use black as the background for the effects to be visible.
—
Far more details:
At a high level, this script is a procedural rendering engine. Instead of reading a pre-made image texture of a sun, it uses pure math to calculate exactly what color each pixel should be in real-time.
To do this efficiently, the shader splits your screen space into two distinct zones based on the pixel’s distance from the center: Inside the Sun (The Surface) and Outside the Sun (The Corona & Bloom).
Here is exactly how the optimized version brings your sun to life, broken down section by section.
1. The Mathematical Engine (Noise & FBM)
At the top of the script are the hash13, value_noise3, and fbm3 functions. These form the “foundry” of the shader.
-
hash13&value_noise3: These generate a pseudo-random grid of 3D noise blocks. By using smooth interpolation (smoothsteptransitions), it blends these blocks together so you get soft, organic-looking cloudy noise instead of harsh digital blocks. -
fbm3(Fractal Brownian Motion): This function takes multiple layers (“octaves”) of that noise, scales them down, rotates them using the globalROTmatrix to prevent ugly grid lines, and stacks them together.
Optimization Note: The maximum octaves were capped at
6. In the original script, it could loop up to 12 times. Because screen pixels are so small, those extra 6 loops were calculating micro-details the human eye literally couldn’t see, wasting massive amounts of GPU power.
2. Phase 1: The Sun Surface (dist <= sun_radius)
If a pixel falls inside the sun’s radius, the shader builds a fake 3D illusion on a flat 2D quad.
The 3D Sphere Trick
The shader takes the flat 2D uv coordinates and calculates a fake Z-depth (nz) using the Pythagorean theorem for a sphere:
By using the native dot(nxy, nxy) instruction, the GPU processes this instantly. This gives us a 3D coordinate (sp) mapped perfectly over a curved surface. When we apply time to the X and Z axes, the surface appears to rotate like a solid 3D globe, completely eliminating pinching at the poles.
Layering the Plasma and Sunspots
Once it has the 3D space, it generates the surface visuals:
-
Granulation: It blends a large noise layer (
cells) with a fine, fast-moving noise layer (fine). Optimization tweak: Thefinelayer is capped at 3 octaves because high-frequency noise doesn’t need deep complexity to look detailed. -
Sunspots: A slower, larger FBM noise layer runs over the top. The
smoothstep(0.62, 0.42, spots)acts as a sharp threshold mask, cutting dark “holes” into the plasma wherespot_coloris applied. -
Limb Darkening & Rim: Real stars look darker near their edges because you are looking through thicker layers of solar atmosphere. The shader multiplies the edge colors by
nz(which drops to 0 at the silhouette) to create a soft, cinematic falloff, then injects a tiny bit ofhot_colorright at the very edge to blend it seamlessly into space.
3. Phase 2: Corona and Flares (else)
If the pixel is outside the sun’s radius, the shader switches to rendering the atmosphere.
The Trigonometry Escape
To make the solar flares shoot outward radially, the shader needs to know which way is “out” from the center.
The old shader used atan() to find the angle, and then immediately used cos() and sin() to turn that angle back into a direction vector. The optimized version completely deletes all three heavy operations and replaces them with:
vec3(uv / dist, 0.0)
Dividing the coordinate by its own distance mathematically yields the exact same directional vector instantly, saving the GPU a massive amount of computational math per pixel.
Flares and Halo
-
Solar Flares: Using the directional vector, it creates two overlapping textures moving outward over time (
t * flare_speed). Multiplying them together (f1 * f2) creates high-contrast, erratic streams of fire that fade out as they get further from the surface (edge_fade). -
The Bloom Halo: An exponential decay function (
exp(-d_out / ...)) creates a seamless, infinite glow that smoothly transitions to transparent the further away it gets.
Shader code
shader_type canvas_item;
render_mode blend_add;
group_uniforms appearance;
uniform vec4 core_color : source_color = vec4(1.0, 0.85, 0.45, 1.0);
uniform vec4 hot_color : source_color = vec4(1.0, 0.55, 0.12, 1.0);
uniform vec4 corona_color : source_color = vec4(1.0, 0.42, 0.06, 1.0);
uniform vec4 spot_color : source_color = vec4(0.45, 0.18, 0.02, 1.0);
group_uniforms shape;
uniform float sun_radius : hint_range(0.05, 0.45) = 0.30;
uniform float corona_size : hint_range(0.0, 0.35) = 0.18;
uniform float bloom_strength : hint_range(0.0, 3.0) = 1.1;
group_uniforms surface;
uniform float surface_detail : hint_range(1.0, 12.0) = 5.0;
uniform float granulation : hint_range(0.0, 1.0) = 0.55;
uniform float spot_amount : hint_range(0.0, 1.0) = 0.35;
uniform float rotation_speed : hint_range(-1.0, 1.0) = 0.03;
group_uniforms flares;
uniform float flare_detail : hint_range(1.0, 12.0) = 4.0;
uniform float flare_intensity : hint_range(0.0, 4.0) = 1.6;
uniform float flare_speed : hint_range(0.0, 2.0) = 0.45;
// Global constants for optimization
const mat2 ROT = mat2(vec2(0.80, 0.60), vec2(-0.60, 0.80));
const int MAX_OCTAVES = 6; // 6 is visually indistinguishable from 12 but 2x faster
float hash13(vec3 p3) {
p3 = fract(p3 * 0.1031);
p3 += dot(p3, p3.zyx + 31.32);
return fract((p3.x + p3.y) * p3.z);
}
float value_noise3(vec3 p) {
vec3 i = floor(p);
vec3 f = fract(p);
vec3 u = f * f * (3.0 - 2.0 * f);
float n000 = hash13(i + vec3(0.0, 0.0, 0.0));
float n100 = hash13(i + vec3(1.0, 0.0, 0.0));
float n010 = hash13(i + vec3(0.0, 1.0, 0.0));
float n110 = hash13(i + vec3(1.0, 1.0, 0.0));
float n001 = hash13(i + vec3(0.0, 0.0, 1.0));
float n101 = hash13(i + vec3(1.0, 0.0, 1.0));
float n011 = hash13(i + vec3(0.0, 1.0, 1.0));
float n111 = hash13(i + vec3(1.0, 1.0, 1.0));
float nx00 = mix(n000, n100, u.x);
float nx10 = mix(n010, n110, u.x);
float nx01 = mix(n001, n101, u.x);
float nx11 = mix(n011, n111, u.x);
float nxy0 = mix(nx00, nx10, u.y);
float nxy1 = mix(nx01, nx11, u.y);
return mix(nxy0, nxy1, u.z);
}
float fbm3(vec3 p, int octaves) {
float value = 0.0;
float amp = 0.5;
for (int i = 0; i < MAX_OCTAVES; i++) {
if (i >= octaves) {
break;
}
value += amp * value_noise3(p);
p.xy = ROT * p.xy;
p.yz = ROT * p.yz;
p *= 2.02;
amp *= 0.5;
}
return value;
}
void fragment() {
vec2 uv = UV - vec2(0.5);
float dist = length(uv);
float t = TIME;
vec4 col = vec4(0.0);
if (dist <= sun_radius) {
// ---- Sun surface -------------------------------------------------
vec2 nxy = uv / sun_radius;
// Optimized to native dot product instruction
float nz = sqrt(max(0.0, 1.0 - dot(nxy, nxy)));
float spin = t * rotation_speed * TAU;
float cs = cos(spin);
float sn = sin(spin);
vec3 sp = vec3(cs * nxy.x + sn * nz, nxy.y, -sn * nxy.x + cs * nz);
sp *= (3.0 + surface_detail);
int oct = int(surface_detail);
float cells = fbm3(sp + vec3(0.0, t * 0.05, t * 0.03), oct);
// Optimization: Capping fine noise octaves at 3 since it's already high-frequency
float fine = fbm3(sp * 2.7 - vec3(t * 0.08, 0.0, 0.0), min(oct, 3));
float plasma = mix(cells, fine, 0.4);
vec3 base = mix(hot_color.rgb, core_color.rgb, smoothstep(0.2, 0.9, plasma));
base += core_color.rgb * granulation * pow(plasma, 3.0);
float spots = fbm3(sp * 0.6 + vec3(t * 0.02, 0.0, -t * 0.015), 4);
float spot_mask = smoothstep(0.62, 0.42, spots) * spot_amount;
base = mix(base, spot_color.rgb, spot_mask);
float limb = clamp(pow(nz, 0.55), 0.12, 1.0);
base *= limb;
float rim = smoothstep(sun_radius, sun_radius * 0.82, dist);
base += hot_color.rgb * (1.0 - rim) * 0.6;
col = vec4(base, 1.0);
} else {
// ---- Corona, flares and bloom -----------------------------------
float d_out = dist - sun_radius;
float d_norm = d_out / corona_size;
float ringscale = 4.0 + flare_detail;
// Optimization: Removed atan, cos, and sin. Directly normalized the UV vector.
vec3 rdir = vec3(uv / dist, 0.0) * ringscale;
vec3 acc = vec3(0.0);
float alpha = 0.0;
if (d_norm < 1.0) {
vec3 fuv1 = rdir + vec3(0.0, 0.0, d_norm * 3.0 - t * flare_speed);
vec3 fuv2 = rdir * 1.7 + vec3(0.0, 0.0, d_norm * 4.5 - t * flare_speed * 1.4);
float f1 = fbm3(fuv1, int(flare_detail));
float f2 = fbm3(fuv2, int(flare_detail));
float flare = pow(f1 * f2, 1.5) * flare_intensity;
float edge_fade = 1.0 - d_norm;
float corona_a = clamp(pow(edge_fade * (0.4 + flare), 1.8), 0.0, 1.0);
acc += mix(corona_color.rgb, hot_color.rgb, flare) * corona_a;
alpha = corona_a;
}
float halo = exp(-d_out / (corona_size * (0.45 + bloom_strength)));
halo = pow(halo, 1.5) * bloom_strength;
acc += corona_color.rgb * halo;
alpha = clamp(alpha + halo, 0.0, 1.0);
// Optimization: Removed conditional discard to prevent pipeline stalls
col = vec4(acc, alpha);
}
COLOR = col;
}
