Monday, March 7, 2011

Creating a "Bullet-time" Shader

I spent last weekend putting together a shader that would give the impression of time slowing down - an effect that I recently found the need for as I started adding new mechanics to the game (Biff! Bam!! Blammo!?!) I've been working on for the past 2+ years in my spare time.

My goal was to give a strong impression of the slowed-down motion of the ball in the game - this effect will become active whenever a player wants to 'boost' the ball. Players are given a certain number of boosts (which replenish over time) and when the player initiates the boosting mechanism (by using the right analog stick on a controller, or the arrow keys on the keyboard) bullet-time becomes activated and as a result gives time for the player to boost in the direction of their choice.

From previous movie going and videogame playing experience it's pretty obvious that the best visual cues used to make "bullet-time" happen are to desaturate and blur large parts of the scene. Since the focus of the bullet-time for my game is on the ball(s), I figured a radial blur would be the most appropriate.

For the desaturation effect a simple linear interpolation between a fully coloured scene and the mean average of the red, green and blue colour channels sufficed.

For the radial blur effect I was able to find some code here, which helped a lot - I converted it to CgFx and made some small tweaks.

I ended up adding another effect, which admittedly covers up a problem with the radial blur when you zoom in with a camera. The problem being that the radial blur effect causes ghost images/incorrect blurring around the screen edges as the camera zooms - the problem totally stumped me; it doesn't seem to have anything to do with the texture mapping mode (e.g., changing the mode to clamp/repeat/etc. has little to no effect); if someone can figure out what the actual problem is I would be interested to know. I attempted two solutions:

The first was to actually draw the fullscreen quad (after rendering the blurred scene to a texture) slightly larger than it actually was but maintaining the same texture coordinates (thus 'zooming' in on the image without changing the resolution) - this has obvious setbacks concerning the loss of resolution and I wasn't really pleased with the visual results - also it was a very fidgety solution - I had to tweak the amount larger that the fullscreen quad was in order to get rid of the ugly blur borders, in the end it all felt very hacky and I gave up on it.

The second method was to cover up the problem almost entirely - or at the very least take the player's attention away from the problem. This was done by darkening the edges of the screen by performing a smoothstep interpolation between zero (around the edges) to one (at the center) and then multiplying the colour by that value. This ended up working surprisingly well; though the problem still persists, it's arguably negligible now.

I've included the full shader code below.

 // Constant samples used in the radial blur
const float SAMPLES[10] = 
{-0.08, -0.05, -0.03, -0.02, -0.01, 
  0.01, 0.02, 0.03, 0.05, 0.08};
/*
 * For a value of zero the full colour of the scene is displayed,
 * for a value of one the entire scene will be in black and  
 * white, in between values are lerped.
 */
float DesaturateFraction <
    string UIWidget = "slider";
    float UIMin = 0.0;
    float UIMax = 1.0;
    float UIStep = 0.01;
    string UIName =  "Desaturation";
> = 0.0f;
/*
 * SampleDistance will break-up the sampling of the blur if it is made too high,
 * resulting in a very discretized and and poorly sampled blur. However, if made
 * high enough it will also result in a very strong blur.
 */
float SampleDistance <
    string UIWidget = "slider";
    float UIMin = 0.0;
    float UIMax = 2.0;
    float UIStep = 0.01;
    string UIName =  "Sample Distance";
> = 0.25f;
/*
 * SampleStrength is how much of the blur actually shows - this will be weighted
 * away from the center i.e., the center will always show the least amount of blur
 * unless SampleStrength is very high.
 */
float SampleStrength <
    string UIWidget = "slider";
    float UIMin = 0.0;
    float UIMax = 35.0;
    float UIStep = 0.01;
    string UIName =  "Sample Strength";
> = 2.1f;
// Texture and Sampler of the currently rendered scene
texture SceneTex <
    string ResourceName = "EarthDay.dds";
    string UIName = "Scene Texture";
    string ResourceType = "2D";
>;
sampler2D SceneSampler = sampler_state {
    Texture = ;   
    WrapS = ClampToEdge;
    WrapT = ClampToEdge;   
};


/**
 * Radial blur function, makes use of the current texture coordinate
 * to look up the current fragment colour and do a radial blur on it by
 * sampling away from the pixel in the direction of the center of the screen.
 * Executes a post-processing radial blur on a given fullscreen texture.
 * The two important tweakable parameters are SampleDistance and SampleStrength.
 */
float4 ToRadialBlur(float4 sceneColour, float2 UV) {
  float2 blurDirection = float2(0.5f, 0.5f) - UV;
float  blurLength    = length(blurDirection);
blurDirection = blurDirection/blurLength;

// Calculate the average colour along the radius towards the center
float4 sum = sceneColour;
for (int i = 0; i < 10; i++) {
sum += tex2D(SceneSampler, UV + (blurDirection * SAMPLES[i] * SampleDistance));
}
sum /= 11.0f;


// Weight the amount of 
float weightedAmt = blurLength * SampleStrength;
weightedAmt = saturate(weightedAmt);

return lerp(sceneColour, sum, weightedAmt) * smoothstep(1, 0, saturate(1.1f * blurLength));
}
/**
 * Black and White conversion function that desaturates a given colour.
 */
float4 ToBlackAndWhite(float3 sceneColour) {
float  sceneTone   = (sceneColour.r + sceneColour.g + sceneColour.b) / 3.0f;
float3 finalColour = lerp(sceneColour, float3(sceneTone), DesaturateFraction);
return float4(finalColour, 1.0f);
}
float4 PostBallBulletTimePS(float2 UV : TEXCOORD0) : COLOR {
float4 sceneColour        = tex2D(SceneSampler, UV).rgba;
float4 blurredSceneColour = ToRadialBlur(sceneColour, UV);
return ToBlackAndWhite(blurredSceneColour.rgb);
}
technique PostBulletTime {
    pass p0 {
        FragmentProgram = compile arbfp1 PostBallBulletTimePS();
    }
}


A picture of the "Bullet-time" effect in action.