nyx.vfx: Feedback Shader Redesign

Overview: From Overlay Hack to Real Temporal Feedback

The original "Feedback Shader" in nyx.vfx didn't actually use a shader. It used an overlay technique: draw a full-screen rectangle every frame, then blend the current input on top. It worked, but it wasn't real temporal feedback.

Why?

  • BlendAlpha + an overlay quad isn't a true time-based decay. It fades whatever happens to be in the buffer.

  • BlendAdd continuously injects energy, but without a stable, dt-corrected attenuation model.

The goal of this overhaul was simple to state but annoying to execute:

Maintain a persistent history buffer, decay it predictably over time, and inject new input in a controlled way all on the GPU.

The Problem

The old approach was limited in capability and couldn't take advantage of PreShaders (density, velocity, masks). It also made it awkward to add GPU effects like swirl/rotation/flow without building weird side paths around the blend trick.

Here's what it looked like:

nyx.vfx.original.feedback.buffer.png

And here's the old implementation (SFML overlay quad):

m_outputTexture.draw(m_fadeQuad, sf::BlendAlpha);
m_outputTexture.draw(sf::Sprite(inputTexture->getTexture()), sf::BlendAdd);

Initial Solution: Make Feedback a Shader

The new feedback core is the canonical model:

  • sample history
  • apply decay
  • add injected input
  • clamp/compress for stability
uniform sampler2D u_history;
uniform sampler2D u_input;
uniform float u_decay;   // 0..1
uniform float u_inject;  // 0..1

uniform bool  u_useClamp;
uniform float u_clampMax;

void main()
{
  vec2 uv = gl_TexCoord[0].xy;

  vec4 hist = texture2D(u_history, uv);
  vec4 inC  = texture2D(u_input,   uv);

  vec4 outC = hist * u_decay + inC * u_inject;

  if (u_useClamp)
    outC = clamp(outC, 0.0, u_clampMax);
  else
  {
   // effectively a tone-map/compression. this makes trails linger b/c energy never truly goes to 0 the way a hard clamp + decay might. compression to avoid blowout.
    outC.rgb = outC.rgb / (1.0 + outC.rgb); // compression (soft knee)
  }
  gl_FragColor = outC;
}

Nothing fancy; just correct temporal behavior and stability.

Decay That's Stable Across Frame Rates (Half-Life)

If decay changes with FPS, feedback looks different on every machine. The fix is to compute u_decay from a half-life, meaning "how long until energy falls to 50%."

float dt = std::max(elapsedSeconds, 1e-5f);
float halfLife = std::max(halfLifeSec, 1e-4f);

// dt-stable decay coefficient in [0, 1]
float decay = std::exp2(-dt / halfLife); // == pow(0.5f, dt/halfLife)

This is the baseline behavior I consider "real feedback." But it needed some artistic flare.

Artistic Modulation: Pulsing / Breathing Feedback

Once the baseline is correct, you can modulate it for creative control. One effect I wanted was periodic clearing to create a "breathing" feedback field that ramps up, then relaxes.

For that, I built a simple up/down clock. Here's the heart of the class.

float getTime()  
{  
  // get frame latency (~16ms between restart)
  const auto delta = m_timer.restart();  
  
  if ( !m_reachedMax )  
  {  
    m_currentSeconds += delta.asSeconds();  
    m_reachedMax = m_currentSeconds >= m_maxSeconds;  
  }  
  else  
  { 
    m_currentSeconds -= delta.asSeconds();  
    m_reachedMax = m_currentSeconds >= m_minSeconds;  
  }  
  
  return m_currentSeconds;  
}

And then I use yt directly as a decay.

float t = m_dtClock.getTime();                  // seconds

That gives me:

  • sticky trails (high decay)
  • full clears (low decay)
  • slow bounce-back instead of a hard reset pulse

And once you combine that with velocity-driven warps, you get the kind of "magnetic" feedback behavior shown in the demos.

Magnetic pull:

Magnetic push:

Feedback + Rumble + Kaleido: