Ok, silliness aside, is it possible to implement an efficient bloom effect on Windows Phone without custom shaders?
Bloom consists of three operations:
There are many choices for how each operation should be implemented, the combination of which determines the visual result. In the XNA bloom sample:
There is no good way to adjust saturation without shaders, but everything else in this design can be done with simple alpha blending operations:
using System; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; namespace BloomPostprocess { public class BloomComponent : DrawableGameComponent { // Adjust these values to change visual appearance. const float BloomThreshold = 0.25f; const float BloomIntensity = 1.5f; const int BlurPasses = 4; // result = source - destination static BlendState extractBrightColors = new BlendState { ColorSourceBlend = Blend.One, AlphaSourceBlend = Blend.One, ColorDestinationBlend = Blend.One, AlphaDestinationBlend = Blend.One, ColorBlendFunction = BlendFunction.Subtract, AlphaBlendFunction = BlendFunction.Subtract, }; // result = source + destination static BlendState additiveBlur = new BlendState { ColorSourceBlend = Blend.One, AlphaSourceBlend = Blend.One, ColorDestinationBlend = Blend.One, AlphaDestinationBlend = Blend.One, }; // result = source + (destination * (1 - source)) static BlendState combineFinalResult = new BlendState { ColorSourceBlend = Blend.One, AlphaSourceBlend = Blend.One, ColorDestinationBlend = Blend.InverseSourceColor, AlphaDestinationBlend = Blend.InverseSourceColor, }; SpriteBatch spriteBatch; RenderTarget2D scene; RenderTarget2D halfSize; RenderTarget2D quarterSize; RenderTarget2D quarterSize2; public BloomComponent(Game game) : base(game) { } protected override void LoadContent() { spriteBatch = new SpriteBatch(GraphicsDevice); PresentationParameters pp = GraphicsDevice.PresentationParameters; int w = pp.BackBufferWidth; int h = pp.BackBufferHeight; scene = new RenderTarget2D(GraphicsDevice, w, h, false, pp.BackBufferFormat, pp.DepthStencilFormat, pp.MultiSampleCount, RenderTargetUsage.DiscardContents); halfSize = new RenderTarget2D(GraphicsDevice, w / 2, h / 2, false, pp.BackBufferFormat, DepthFormat.None); quarterSize = new RenderTarget2D(GraphicsDevice, w / 4, h / 4, false, pp.BackBufferFormat, DepthFormat.None); quarterSize2 = new RenderTarget2D(GraphicsDevice, w / 4, h / 4, false, pp.BackBufferFormat, DepthFormat.None); } public void BeginDraw() { if (Visible) { GraphicsDevice.SetRenderTarget(scene); } } public override void Draw(GameTime gameTime) { // Shrink to half size. GraphicsDevice.SetRenderTarget(halfSize); DrawSprite(scene, BlendState.Opaque); // Shrink again to quarter size, at the same time applying the threshold subtraction. GraphicsDevice.SetRenderTarget(quarterSize); GraphicsDevice.Clear(new Color(BloomThreshold, BloomThreshold, BloomThreshold)); DrawSprite(halfSize, extractBrightColors); // Kawase blur filter (see http://developer.amd.com/media/gpu_assets/Oat-ScenePostprocessing.pdf) for (int i = 0; i < BlurPasses; i++) { GraphicsDevice.SetRenderTarget(quarterSize2); GraphicsDevice.Clear(Color.Black); int w = quarterSize.Width; int h = quarterSize.Height; float brightness = 0.25f; // On the first pass, scale brightness to restore full range after the threshold subtraction. if (i == 0) brightness /= (1 - BloomThreshold); // On the final pass, apply tweakable intensity adjustment. if (i == BlurPasses - 1) brightness *= BloomIntensity; Color tint = new Color(brightness, brightness, brightness); spriteBatch.Begin(0, additiveBlur); spriteBatch.Draw(quarterSize, new Vector2(0.5f, 0.5f), new Rectangle(i + 1, i + 1, w, h), tint); spriteBatch.Draw(quarterSize, new Vector2(0.5f, 0.5f), new Rectangle(-i, i + 1, w, h), tint); spriteBatch.Draw(quarterSize, new Vector2(0.5f, 0.5f), new Rectangle(i + 1, -i, w, h), tint); spriteBatch.Draw(quarterSize, new Vector2(0.5f, 0.5f), new Rectangle(-i, -i, w, h), tint); spriteBatch.End(); Swap(ref quarterSize, ref quarterSize2); } // Combine the original scene and bloom images. GraphicsDevice.SetRenderTarget(null); DrawSprite(scene, BlendState.Opaque); DrawSprite(quarterSize, combineFinalResult); } void DrawSprite(Texture2D source, BlendState blendState) { spriteBatch.Begin(0, blendState); spriteBatch.Draw(source, GraphicsDevice.Viewport.Bounds, Color.White); spriteBatch.End(); } static void Swap<T>(ref T a, ref T b) { T tmp = a; a = b; b = tmp; } } }
Notes:
High quality blur filters can be expensive, so most bloom implementations do a cheap bilinear downsample to lower resolution before the ‘real’ blur. The original XNA sample operates at half resolution, but in a nod to the limited bandwidth of mobile GPUs, I made this version shrink to quarter size.
It is possible to implement Gaussian blur (or indeed any multi-tap filter) as a series of additive and subtractive blends with suitable offsets, but that gets expensive for large filter kernels. Instead I chose a Kawase blur (originally developed by Masaki Kawase for the Xbox1 game Wreckless), which cunningly exploits GPU bilinear filtering to get a not-quite-as-good-but-still-ok result more cheaply.
The threshold subtraction is perhaps the least intuitive operation. We want (source - threshold), but the alpha blending hardware only gives us (source - destination). So, we simply clear the destination rendertarget to our desired threshold value before drawing the source image!
There are two scaling operations in this bloom design: one to preserve full color range after the threshold subtraction, and a second after the blur to adjust intensity of the bloom. For efficiency, I apply these adjustments during the first and last blur passes, getting them for free as a side effect of drawing work that was already taking place.
So how does it look?
With BloomThreshold = 0.25f, BloomIntensity = 1.5f, BlurPasses = 4:
With BloomThreshold = 0.5f, BloomIntensity = 3, BlurPasses = 6:
With BloomThreshold = 0, BloomIntensity = 1.5f, BlurPasses = 3: