nyx.vfx: S02 PreShader Pipeline - Density Field

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 * radiusScale
  • spriteScale = 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 * a to std::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.