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.
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.
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.
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.
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.
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.
| 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 |
// 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
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);
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".
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.
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 |
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.
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.
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.
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.
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.
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 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 |
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.
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;
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.
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 |
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.
The solution uses an adapted Gale-Shapley stable matching algorithm across three Generic Simulation Stages:
| 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.