Procedural Sun Surface — UE5 HLSL Compute Shader

pavelzosim:~/atlas_SYS.ONLINE / UTC+3

01 Physical-Based Sun on Unreal Engine

This post covers an ongoing R&D project: a no-texture, physically-based sun surface plugin for Unreal Engine 5.7. The goal was not to make "a nice-looking sun" — it was to build a system where every visual parameter is grounded in solar_physics and expressible as math, not painted by hand.

I am documenting this process in real-time. As the project progresses, this report will be updated to reflect the evolving C++ architecture and the math driving the solar photosphere.

Unreal RAW alpha footage
Fig 01: Unreal RAW alpha footage — photosphere surface with granulation and sunspots.
Houdini Prototype
Fig 02: Houdini Prototype — Biot-Savart magnetic field simulation used as reference for the GPU compute shader.

Why Not Use Textures?

The short answer: "Textures lie." A tiled noise texture can look like solar granulation — but it won't behave like it. It won't flow around sunspots, won't respond to a magnetic field map, won't darken physically at the limb. The moment you ask it to do anything beyond "look similar," you hit a wall.

Δ1 // THE TEXTURE WALL (STATIC)
• Locked resolution and bit-depth
• No awareness of magnetic topology
• Static spectral response
• High VRAM footprint
Δ2 // THE PROCEDURAL BET (DYNAMIC)
• Infinite detail (ALU-bound)
• Responsive to magnetic_field_maps
• Blackbody-driven color curves
• Zero VRAM overhead — runtime generation

Procedural generation from first principles costs more in ALU, but gives you a system with actual degrees of freedom. Change the temperature range — the color responds via a blackbody curve. Change the spot seed — the magnetic topology changes, and so does the chromosphere above it, and the corona loops above that.

That's the architectural bet: pay the compute cost upfront, gain full physical controllability downstream.

02 Plugin Architecture: UE5 HLSL Compute Shaders

The system is split across three layers: a C++ ownership layer, a GPU compute pipeline, and an HLSL material library. Data flows strictly downward — the CPU dispatches, the GPU executes, the material assembles.

[ 1. CPU LAYER // C++ Architecture ]
SunTextureManager
UWorldSubsystem
BlackbodyLUT_RGBA
BFieldRT_R16F
SunTextureHelper
BlackbodyToLinearRGB()
BakeLUT_Analytic()
// Data Flux: Ownership & Lifecycle //
FSunTextureParams
Single_Source_Of_Truth
Seed · Radii · Octaves
[ 2. GPU LAYER // Compute Pipeline ]
CPU_Dispatcher
GenerateSpotPositions()
Apply_Hales_Law()
FSunBFieldCSParams
Uniform_Buffer
SpotData_Array[8]
// Execution: Kernel Dispatch //
SunBFieldCompute_USF
R_Magnetic_Bz — Magnetic Pressure
GB_FlowVectors — Plasma Velocity
[ 3. HLSL MATERIAL LIBRARY // Final Assembly ]
Material_Graph
Custom_Node → #include SunMaster_USH
SunMaster_USH
Canonical_Entry_Point
Module Linker
Modules (16 .ush)
SunMath_Projection
SunMagnetic_Flow
Granulation_V08
SunBlackbody · SunLimb…

03 Geometric Layering

The solar body is simulated using three concentric spherical meshes. This cross-section illustrates how the volumes are nested to provide the necessary spatial depth for the photosphere and atmospheric effects.

PHOTOSPHERE CORONA
Photosphere Base radius ×1.0. Primary surface where granular noise is calculated.
Chromosphere Scale ×1.005–1.015. Transition layer for spicules and limb darkening logic.
Corona Scale ×1.5–3.0. Extended volume for volumetric prominences and solar wind.
SYNC_STAMP
All three layers share the same BFieldRT (Magnetic Field Render Target). Sunspots, spicules, and coronal loops are spatially synchronized at zero extra cost.

04 The HLSL Layer Stack: UV Contract

Spatial synchronization across all three atmospheric shells is maintained via a Canonical UV Derivation. This ensures that magnetic field data aligns perfectly between the photosphere and the outer corona.

Critical Pitfall: UV Sliding

Using normalize(WorldPos) fixes UVs in World Space. When the actor rotates, all surface patterns "slide" instead of rotating with the mesh.

Fix: derive the local direction vector as (WorldPos - ActorPos) before normalizing. This keeps UVs object-relative.

Gallery 04: Modular HLSL system — material graph reroute node and .ush include structure.

05 Layer 1: Photosphere — Granulation

Hot plasma rises through the solar interior and forms convective cells — granules. Cell centers are bright and hot; boundaries are dark and cool — descending, spent plasma. Mathematically this is inverted Voronoi F1 with domain warping:

$$G(p) = \left(1 - \text{VoronoiF1}(p')\right)^k, \quad p' = p + \text{CurlNoise}(p \cdot s_1 + t \cdot t_1) \cdot I_{\text{warp}}$$

The exponent k ∈ [2, 4] shapes cell interiors — wider flat tops, sharp dark boundaries. The Curl Noise warp (SunFlow.ush) ensures cells deform and push each other organically instead of simply blinking in and out.

Physical Brightness Mapping

REGIONBRIGHTNESSPHYSICS
Intergranular lane0.72Descending plasma, cool
Mean photosphere1.00Reference baseline
Hot granule center1.12Ascending plasma, peak heat

Live Shader Preview

HLSL Implementation

// SunGranulation.ush — Granulation_V08
// Inverted Voronoi F1 + domain warp via curl noise

float2 warpedUV = SunUV + CurlNoise(SunUV * WarpScale + Time * WarpSpeed) * WarpIntensity;
float2 voronoi  = VoronoiF1F2(warpedUV * GranuleScale);
float  F1       = voronoi.x;
float  F2       = voronoi.y;

// Invert F1: bright centers, dark boundaries
float cell   = pow(saturate(1.0 - F1), GranuleExponent);

// Intergranular lane glow: F2-F1 peaks between cells
float lane   = saturate((F2 - F1) * 3.5);
float bright = lerp(0.78, 1.12, cell) + lane * 0.05;
// bright ∈ [0.78..1.12] → feeds temperature pipeline downstream
Unreal RAW alpha footage
Fig 05: Procedural sun surface simulation using UE5 HLSL Compute Shaders and Niagara.

06 Layer 2: Sunspots — Magnetic Suppression

Sunspots are regions where concentrated magnetic flux suppresses convection. Temperature drops from ~5800K at the photosphere to ~3800K in the umbra — dark not because they are painted dark, but because they radiate less energy per unit area.

Spot positions are generated on CPU (GenerateSpotPositions()) with a deterministic RNG from a seed. Each spot is stored as FVector4f(U, V, Radius, Polarity). Polarity alternates following Hale's Polarity Law.

Hale's Law implementation: spot pairs in each active region are assigned opposite magnetic polarities. The leading polarity in each hemisphere is consistent within a solar cycle and reverses each cycle (~11 years). In the shader this is a simple sign() alternation on the spot index.

In the compute shader, each pixel accumulates magnetic field contributions from all spots using a Biot-Savart approximation:

$$B_z(p) = \sum_{i=0}^{N-1} \text{Polarity}_i \cdot \frac{R_i^2}{(|p - c_i|^2 + R_i^2)^{3/2}}$$
// SunBFieldCompute.usf — GPU Kernel
// Accumulate Bz from all spot dipoles

float Bz = 0.0;
for (int i = 0; i < SpotCount; i++)
{
    float2 delta  = UV - SpotData[i].xy;          // UV distance to spot center
    float  r2     = dot(delta, delta);
    float  R2     = SpotData[i].z * SpotData[i].z; // spot radius²
    float  polarity = SpotData[i].w;               // +1 or -1 (Hale's Law)

    Bz += polarity * R2 / pow(r2 + R2, 1.5);
}
// Write to R16F render target — sampled by material layer stack
RWMagneticField[DispatchID.xy] = saturate(Bz * FieldStrength);
Gallery 05: Modular HLSL system — Debuging visuals.

Live Shader Preview

Sunspot Fibril Structure

The penumbra is not a uniform grey ring. It's made of fibrils — dark filaments of plasma aligned along magnetic field lines that splay outward from the umbral core. Three independent fibril layers are composited via a Z-buffer winner approach:

// 3 independent fibril layers, composited via Z-buffer.
// Each layer has a different angular offset — strands interleave.
// Waviness: sin(Bz * waveFreq) — buckle frequency is higher near the umbra,
// where field lines are denser. Physically correct, not hand-tuned.

[unroll]
for (int li = 0; li < 3; li++)
{
    float lSectorF = (angle + float(li) * 0.31) * (FibrilFreq / SUN_TWO_PI);
    float lAngleID = floor(lSectorF);

    // 5 independent randoms per strand: phase, drift, depth, waviness, erosion
    float lr1 = frac(sin(lAngleID * 12.9898 + lSeed) * 43758.5453);
    float lr2 = frac(sin(lAngleID * 78.233  + lSeed) * 43758.5453);
    // ...
}

// Z-buffer winner: highest strand surface wins — produces hard occlusion
// between tubes instead of blurred sum. Makes them read as "spaghetti".

07 Layer 3: Temperature Pipeline

This is where physics becomes color. Every surface region — granule center, intergranular lane, umbra, penumbra, facula — carries a temperature value in Kelvin. The pipeline in SunTemperature.ush maps that value to a physically grounded radiance output.

Granule → Spot → Facula Compositing

Three passes composite in order: granulation sets the base temperature field, sunspot darkening suppresses it inside spot regions, faculae brighten the exterior ring. Each pass is a lerp() gated by a mask:

PASSINPUT MASKOPERATIONTEMPERATURE RANGE
1. GranulationVoronoiF1 brightnesslerp(MinT, MaxT, bright)5500K – 6200K
2. Spot darkeningSpotDarkening [0..1]lerp(tGran, MinT, spot)down to ~3800K (umbra)
3. Faculae boostSpotExterior > 0lerp(tBase, FaculaeT, mask)up to ~6400K
4. Clampclamp(finalT, MinT, MaxT)hard physical bounds

Blackbody LUT Sampling

Temperature maps to color via a 256×1 BlackbodyLUT sampled at (T − MinT) / (MaxT − MinT). The LUT is baked on CPU at startup using an analytic blackbody curve (BlackbodyToLinearRGB in SunTextureHelper.cpp), normalized to linear working space — no sRGB conversion problems, no gamma drift.

Stefan-Boltzmann Radiance Weighting

Before cinematic grade, each pixel's brightness is weighted by its temperature raised to the fourth power — matching the physical relationship between temperature and total radiated power:

$$\omega = \left( \frac{T}{T_{\max}} \right)^4$$
[ STEFAN-BOLTZMANN WEIGHTS // SOLAR FEATURES ]
SOLAR FEATURETEMPERATUREWEIGHT wVISUAL RESULT
Umbra~3800K≈ 0.14Physically dark
Mean photosphere~5778K≈ 0.57Reference baseline
Hot granule~6200K1.00Peak brightness
Faculae~6400K≈ 1.13Slightly brighter

The umbra at weight 0.14 is not manually darkened — it emerges directly from the T⁴ law. The same formula that makes a tungsten filament dim when it cools makes a sunspot dark. No artistic override required.

08 Layer 4: Limb Darkening

The solar disc appears darker at the edges because at oblique viewing angles we look through a greater column of photospheric plasma — deeper, hotter layers are hidden. This is not an artistic choice. It is a direct consequence of photospheric opacity.

The viewing angle cosine μ = dot(N_world, -V_world) drives two physical models selectable per quality preset:

Δ1 // QUADRATIC — Neckel & Labs 1994
Fast, one shader ALU pass. Good for background / mid-distance suns.
Δ2 // CLARET 4-TERM — V-Band accurate
Physically accurate for solar V-band close-ups. Four coefficients, four MADs.
$$\\frac{I(\\mu)}{I(1)} = 0.294 + 0.884\\mu - 0.178\\mu^2 \\quad \\text{(Quadratic)}$$
$$\\frac{I(\\mu)}{I(1)} = 1 - \\sum_{k=1}^{4} a_k\\left(1 - \\mu^{k/2}\\right), \\quad a = [0.4361,\\; 0.2860,\\; 0.1567,\\; {-0.0885}]$$

UE ACES Fix — limbFloor parameter

UE's ACES tonemapper has an aggressive toe curve that converts subtle limb darkening into a hard black crust at the disc edge. The fix is a limbFloor parameter that prevents I(μ) from dropping below a minimum value — an artistic override that restores control without breaking the physics in the bright range.

09 Layer 5: Parallax Occlusion Mapping on a Sphere

POM on a sphere requires latitude-corrected tangent space. A flat TBN doesn't account for the fact that a UV step at the equator covers different physical area than the same step near the poles — which causes POM to stretch at the poles and compress at the equator.

Latitude-Corrected TBN

The corrected tangent frame in SunProjection.ush derives T and B analytically from the sphere's local position vector, then projects the view vector into that frame:

float  sinTheta = max(length(P.xy), 0.05);   // guard against pole singularity
float3 T        = float3(-P.y, P.x, 0) / sinTheta;  // dP/dU — latitude-scaled
float3 B        = cross(P, normalize(T));             // dP/dV — unit

// View vector must be in LOCAL space before projection:
float3 V_local  = mul(float4(V_world, 0), WorldToLocal).xyz;
float2 dir;
dir.x   = dot(V_local, T);
dir.y   = dot(V_local, B);
float2 offset = dir * HeightScale;

Pole singularity: the max(..., 0.05) clamp limits the singularity to a ~3° polar cap. At any reasonable camera distance this cap is visually invisible — the alternative (per-vertex TBN baked in Houdini) costs an extra vertex stream with no perceptible quality gain outside of extreme close-ups.

10 Layer 6: Chromosphere

The chromosphere is a thin shell of hot plasma above the photosphere, visible primarily at the limb during eclipses. On the disc center it's hidden behind the optically thick photosphere — only at the limb, where we look tangentially through the shell, does it become visible.

Fresnel Limb Gate

Fresnel opacity gates the chromosphere off the disc center and opens it fully at the limb:

$$\\text{opacity} = (1 - \\mu)^k, \\quad k \\approx 3$$
CONDITIONμ VALUEOPACITYVISUAL RESULT
Disc centerμ = 10.00Chromosphere invisible — photosphere visible
Mid-discμ = 0.5≈ 0.13Faint bleed, active regions only
Limbμ = 01.00Full chromosphere emission

Temperature-Driven Color

Unlike the photosphere which stays in the 5500–6400K range, the chromosphere spans a much wider temperature range depending on activity level. Colors are computed analytically to support up to 50,000K — a LUT at that range would require impractical resolution:

Spicules are flow-aligned anisotropic FBM, oriented by BFieldRT.gb flow vectors. World Position Offset extrudes them along the vertex normal — no additional mesh geometry required.

11 Layer 7: Corona

The K-corona — the inner white corona visible during total eclipses — is not thermal emission. It's Thomson scattering of photosphere light off free electrons in the coronal plasma. The color is approximately the photosphere's spectral distribution — white — not a blackbody curve at coronal temperatures.

// K-corona: scattered photosphere light, not temperature emission.
// ThomsonScatterFactor encodes electron column density along the view ray.
float3 ScatteredColor = SunColor * ThomsonScatterFactor;

Magnetic Synchronization

Coronal loops are gated by BFieldRT.r (Bz magnitude) — they only appear above active regions where the magnetic field is strong enough to confine plasma along closed field lines. Since photosphere spots, chromosphere plages, and coronal loops all sample the same BFieldRT, spatial alignment is guaranteed with zero additional synchronization cost.

Streamers are multi-layer FBM noise with flow-aligned anisotropy — the same flow vectors from BFieldRT.gb that drive chromospheric spicules. Three parallax depth layers provide visual depth without raymarching cost: the parallax offset fakes the volumetric feel of a thick streamer belt.

12 Cinematic Grade

Physical blackbody output for a real sun at 5778K is perceptually nearly white — correct physics, poor cinematics. A grade module (SunGrade.ush) sits between the LUT sample and the final compositor, operating entirely in linear space to preserve HDR fidelity.

[ CINEMATIC_PRESETS // SUNGRADE.USH ]
PRESETCHARACTERUSE CASE
PresetGoldenWarm cinematic sunRealistic daytime, hero shots
PresetTealOrangeSplit-tone gradeCinematic contrast, stylized
PresetBlueGiantCold sci-fi starO/B-type star simulation
PresetPhysicalUnmodified physics outputScientific visualization

13 Niagara: Solar Prominences

A separate Niagara emitter system handles solar prominences — large magnetic plasma arcs above the chromosphere. The core challenge was GPU-stable particle pairing: prominences require particle pairs (arc start / arc end), and maintaining stable pairs across frames on the GPU is a non-trivial synchronization problem.

Fig 13: Niagara solar prominences — GPU particle pairing via adapted Gale-Shapley algorithm.

Gale-Shapley Stable Matching on GPU

The solution uses an adapted Gale-Shapley stable matching algorithm across three Generic Simulation Stages:

Compiler-Level Issues Encountered

ISSUESYMPTOMFIX
[unroll] on dynamic-bound loopsDXC compiler hang in UE 5.7Removed, replaced with explicit fixed-count loops
AttributeReader timingReturns T-1 frame dataExplicit one-frame delay in ValidateAndPair stage
RWBuffer write collisionsUnstable pairs under contentionLane ordering via InterlockedMin / InterlockedMax
NeighborGrid3D fill failureGrid silently empty on GPUExplicit SimulationToUnit matrix recalibration

Arc geometry is shaped via a sine-envelope lift along interpolated surface normals, with a shared RibbonID per pair for the ribbon renderer.

14 Current Status

✓ DONE
  • Photosphere: granulation, sunspots (umbra / penumbra / fibrils), temperature pipeline, limb darkening, POM, cinematic grade
  • Chromosphere: spicules, plages, Fresnel limb opacity, WPO extrusion
  • Corona: K-corona, streamers, coronal loops, Bz synchronization
  • Compute shader: BField RT, spot generation, RDG dispatch pipeline
  • C++ plugin: Blueprint API, WorldSubsystem lifecycle, editor subsystem
⟳ IN PROGRESS
  • Niagara prominences: pairing algorithm stable, arc ribbon renderer in progress
  • Houdini reference simulation: separate post — VEX magnetic field sim used as ground truth for the HLSL approximations
// END OF LOG // SOL_RND_01 // SECTIONS 01–14 // EOF