Density Field PreShader
Accumulation, Decay, and Blob Stamps
Remember from Series 01 (PreShader Pipeline Architecture) that a PreShader gathers one piece of information about the texture after the Mesh stage. A PreShader is an immutable adjunct texture that Shaders can choose to utilize.
The first PreShader I built for nyx.vfx was the DensityFieldPreShader, because it creates the most reusable texture. A density field's job is to answer one simple question: Where does energy occur right now?
Once we have that, a bunch of effects can immediately take advantage of it:
- warp only where particles exist (NoiseWarp)
- drive bloom/glow from density instead of scene luminance (DensityHeatMap, ShockBloom)
- gate feedback injection to avoid smearing the entire frame (Feedback)
- stabilize "impulse" strobing based on a real activity map (Strobe)
This post goes into detail about how the density field is generated cheaply and predictably in real-time.
What the Density Field Is (in nyx.vfx)
The density field is a render texture where each pixel holds an "activity" value. In practice, it's just a grayscale energy buffer:
- 0.0 = no activity
- higher = more particle presence / energy
- can persist across frames via decay, creating motion trails
We compute it once per frame per channel, before the ShaderPipeline.
Pipeline Placement
RT = render target
Particles + Mesh -> sceneTexture
sceneTexture -> DensityFieldPreShader -> densityRT
ShaderPipeline samples { sceneTexture + densityRT } -> finalRT
The key design point is that shaders can sample densityRT even after the scene has been heavily post-processed by other Shaders.
Implementation Overview
The DensityField pass is built from three steps. But the preliminary step is to understand that nyx.vfx uses a double-buffer ("front" and "back").
1. Clear the back buffer
Start by clearing the "back" render target (transparent and 0 energy). This guarantees the target is in a known state before we stamp anything.
2. Lay down history (prev * decay)
If accumulation is enabled, then we first draw the previous frame (u_prev) into the new frame, multiplied by a decay factor.
The decay shader is intentionally tiny:
uniform sampler2D u_prev; // previous frame
uniform float u_decay; // decay factor
void main()
{
vec2 uv = gl_TexCoord[0].xy;
vec4 p = texture2D(u_prev, uv);
gl_FragColor = p * u_decay;
}
That single multiply does the heavy lifting. It creates a stable temporal field where energy fades naturally.
It's good idea to clamp the decay to a safe range:
decay = std::clamp(decay, 0.80, 0.999)
That gives us short trails at the low end and long memory at the high end without runaway persistence. You could use something like this as a Feedback Buffer Shader as well, which I've already written a post about.
3. Add particle blobs (additive blending)
Next up, we stamp a soft radial blob sprite at each particle position using additive blending (sf::BlendAdd). This produces a smooth field without requiring expensive per-pixel distance math per particle.
For reference, a blob texture is a generated 64×64 radial falloff. Each stamp is scaled to match the particle's radius:
blobRadiusPx = particleRadiusPx * radiusScalespriteScale = blobRadiusPx / blobTextureRadiusPx
Each particle contributes an alpha-scaled energy value. In the current implementation the contribution is:
- base + energy
- optionally multiplied by particle alpha
- multiplied by intensity and easing
- clamped to [0, 1]
That means the density field responds to both how many particles are present and how "energetic" they are, while still being stable under load.
The "History Initialization" Gotcha
Ping-pong textures (i.e., A -> B -> A -> B) have a classic footgun: on the first frame, the "previous" texture might contain garbage. So we have to force both buffers (front and back) to become a known default value whenever the size changes:
- clear() + display() x2 (
display()swaps the front and back buffers).
That ensures:
- the front history texture is valid
- the back is also valid
- accumulation starts from a clean slate instead of random VRAM (aka garbage)
Controls That Matter
A few knobs are exposed to control each DensityField per Channel:
- radiusScale: how wide the field spreads
- decay: how long energy persists
- intensity: overall field strength
- clearEachFrame: disables temporal accumulation
- alphaFromParticle: lets visual alpha influence density
- blobFallOffPower: adjusts the blob kernel power from
a = a * atostd::power(a, blobFallOffPower)
How It All Looks
Now that we know the theory, let's have a look at it. We can isolate the DensityField by using an Empty Shader whose only job it to pass through all the PreShaders. In the video, I first disable all the other PreShaders, so can you see just the DensityField on its own. Afterwards, I go through each setting. There's even an easings option there for even more control.