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.
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.
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.
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
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.
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.
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:
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
| REGION | BRIGHTNESS | PHYSICS |
|---|---|---|
| Intergranular lane | 0.72 | Descending plasma, cool |
| Mean photosphere | 1.00 | Reference baseline |
| Hot granule center | 1.12 | Ascending 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
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:
// 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);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:
| PASS | INPUT MASK | OPERATION | TEMPERATURE RANGE |
|---|---|---|---|
| 1. Granulation | VoronoiF1 brightness | lerp(MinT, MaxT, bright) | 5500K – 6200K |
| 2. Spot darkening | SpotDarkening [0..1] | lerp(tGran, MinT, spot) | down to ~3800K (umbra) |
| 3. Faculae boost | SpotExterior > 0 | lerp(tBase, FaculaeT, mask) | up to ~6400K |
| 4. Clamp | — | clamp(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:
| SOLAR FEATURE | TEMPERATURE | WEIGHT w | VISUAL RESULT |
|---|---|---|---|
| Umbra | ~3800K | ≈ 0.14 | Physically dark |
| Mean photosphere | ~5778K | ≈ 0.57 | Reference baseline |
| Hot granule | ~6200K | 1.00 | Peak brightness |
| Faculae | ~6400K | ≈ 1.13 | Slightly 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:
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:
| CONDITION | μ VALUE | OPACITY | VISUAL RESULT |
|---|---|---|---|
| Disc center | μ = 1 | 0.00 | Chromosphere invisible — photosphere visible |
| Mid-disc | μ = 0.5 | ≈ 0.13 | Faint bleed, active regions only |
| Limb | μ = 0 | 1.00 | Full 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:
- Quiet chromosphere (~8,000K) — H-alpha orange-red, visible as the pink ring during total solar eclipses
- Active regions (~50,000K) — EUV blue-white, corresponding to extreme ultraviolet emission from superheated plasma in flare regions
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.
| PRESET | CHARACTER | USE CASE |
|---|---|---|
| PresetGolden | Warm cinematic sun | Realistic daytime, hero shots |
| PresetTealOrange | Split-tone grade | Cinematic contrast, stylized |
| PresetBlueGiant | Cold sci-fi star | O/B-type star simulation |
| PresetPhysical | Unmodified physics output | Scientific 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.
Gale-Shapley Stable Matching on GPU
The solution uses an adapted Gale-Shapley stable matching algorithm across three Generic Simulation Stages:
- FillGrid — populate a NeighborGrid3D with particle positions
- FindAndPropose — each particle proposes to its nearest neighbor
- ValidateAndPair — validate mutual proposals, write stable pairs via InterlockedMin / InterlockedMax
Compiler-Level Issues Encountered
| ISSUE | SYMPTOM | FIX |
|---|---|---|
| [unroll] on dynamic-bound loops | DXC compiler hang in UE 5.7 | Removed, replaced with explicit fixed-count loops |
| AttributeReader timing | Returns T-1 frame data | Explicit one-frame delay in ValidateAndPair stage |
| RWBuffer write collisions | Unstable pairs under contention | Lane ordering via InterlockedMin / InterlockedMax |
| NeighborGrid3D fill failure | Grid silently empty on GPU | Explicit 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
- 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
- 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
-
[01]
PAPER — Solar Surface Convection. Living Reviews in Solar Physics, 2009.
link.springer.com/article/10.12942/lrsp-2009-2 -
[02]
DOCS — Render Dependency Graph (RDG). Unreal Engine 5 Developer Documentation.
docs.unrealengine.com — Render Dependency Graph -
[03]
PAPER — The Magnetic Polarity of Sun-Spots. Astrophysical Journal, 49, 1919.
ui.adsabs.harvard.edu — Hale 1919 -
[04]
CODE — Sun Shader Granulation — Live WebGL Preview. GitHub Pages, 2026.
pavelzosim.github.io/sun-shader-granulation -
[05]
PAPER — Solar limb darkening 1986–1990. Solar Physics, 153, 1994.
link.springer.com — Neckel & Labs 1994 -
[06]
PAPER — A new non-linear limb-darkening law for LTE stellar atmosphere models. A&A, 363, 2000.
ui.adsabs.harvard.edu — Claret 2000 -
[07]
DOCS — Niagara Simulation Stages. Unreal Engine 5 Documentation.
docs.unrealengine.com — Niagara Simulation Stages








