Skip to content

Instantly share code, notes, and snippets.

@BenMakesGames
Last active September 1, 2026 14:50
Show Gist options
  • Select an option

  • Save BenMakesGames/3d00a6072c6ed1f4118d99217891429b to your computer and use it in GitHub Desktop.

Select an option

Save BenMakesGames/3d00a6072c6ed1f4118d99217891429b to your computer and use it in GitHub Desktop.
Astromino Pixel Shaders

A couple pixel shaders I had created for use in my game, Astromino: https://store.steampowered.com/app/4644350/Astromino/

Creation of these shaders was inspired by this YouTube video, which breaks down how Animal Well achieved its distinctive look.

Some notes:

  1. When using these shaders together, apply Bloom before Crt.
  2. If you're rendering with actual true pixels and scaling up as a final step for higher resolutions, apply these filters AFTER that scale-up (especially Crt).
  3. Astromino was made using MonoGame, which has some of its own quirks when it comes to shaders. It's possible these shaders will need to be adapted if you're using another framework.
  4. Astromino was made using PlayPlayMini, a framework which works on top of MonoGame - this doesn't affect the shader directly, however you may see comments in the shaders which reference PlayPlayMini APIs.
  5. Some additional work was done to ensure the shaders would work on (some) Macs and (some) lower-end Windows PCs. Graphics cards are tricky business. Test the devices you care about.
#if OPENGL
#define SV_POSITION POSITION
#define VS_SHADERMODEL vs_3_0
#define PS_SHADERMODEL ps_3_0
#else
#define VS_SHADERMODEL vs_4_0_level_9_1
#define PS_SHADERMODEL ps_4_0_level_9_1
#endif
// Kernel radius in taps: (2*RADIUS+1)^2 samples per pixel. 3 -> 49 taps, fine at monitor res.
// Compile-time constant so the [unroll] cost is bounded; runtime BlurRadius parameter scales
// the spacing between taps (in monitor pixels), so a small kernel-count still yields a soft halo.
#define RADIUS 3
// Runs on GraphicsManager.BackbufferPostProcessChain (post-upscale, monitor-pixel resolution).
// Per pixel: sample self as `original`, then tap a (2*RADIUS+1)^2 neighborhood, contrast-boost
// each tap's luminance so only bright pixels contribute, Gaussian-weight, sum -> bloom halo.
// Return original + bloom * Strength.
float2 ScreenSizePx;
// Below this luminance, a tap contributes nothing to the bloom. Above it, contribution ramps up
// smoothly to full at luminance 1.0. Higher threshold = only the brightest highlights bloom.
float Threshold;
// Spacing between taps, in monitor pixels. Larger = wider halo. The kernel itself is fixed at
// (2*RADIUS+1)^2 taps; increasing BlurRadius spreads those taps further, trading tightness for
// reach. Fractional values are fine (sampled with bilinear filtering).
float BlurRadius;
// How much of the bloom halo to add on top of the original color. 0 = off, 1 = full.
float Strength;
Texture2D SpriteTexture;
sampler2D SpriteTextureSampler = sampler_state
{
Texture = <SpriteTexture>;
// Bilinear so fractional-texel taps (BlurRadius not a whole pixel) smooth cleanly rather
// than snapping to the nearest texel. The wrap/border defaults are fine - saturated UVs
// below prevent sampling outside [0,1] regardless.
MinFilter = Linear;
MagFilter = Linear;
};
struct VertexShaderOutput
{
float4 Position : SV_POSITION;
float4 Color : COLOR0;
float2 TextureCoordinates : TEXCOORD0;
};
float4 MainPS(VertexShaderOutput input) : COLOR
{
float2 uv = input.TextureCoordinates;
float4 original = tex2D(SpriteTextureSampler, uv);
float3 bloom = float3(0.0, 0.0, 0.0);
float totalWeight = 0.0;
// Two-sigma Gaussian, sigma = RADIUS/2 (so the kernel edge is ~2 sigma out where the weight
// is near-negligible). invTwoSigmaSquared = 1 / (2 * sigma^2) with sigma = RADIUS/2 baked in.
float invTwoSigmaSquared = 2.0 / float(RADIUS * RADIUS);
// [unroll] required for old GL drivers (Apple, pre-DX10 chipsets) that reject loops with
// dynamic array indexing - matches the convention in LineClearRipple.fx / ScreenShake.fx.
[unroll]
for (int y = -RADIUS; y <= RADIUS; y++)
{
[unroll]
for (int x = -RADIUS; x <= RADIUS; x++)
{
float2 tapOffsetPx = float2(x, y) * BlurRadius;
float2 tapUv = saturate(uv + tapOffsetPx / ScreenSizePx);
float3 tapColor = tex2D(SpriteTextureSampler, tapUv).rgb;
// Rec. 601 luminance - cheap and matches what pixel-artists' eyes perceive as "bright".
float lum = dot(tapColor, float3(0.299, 0.587, 0.114));
// Contrast step (ticket step 1): smoothstep from Threshold to 1.0 zeroes anything
// below threshold and boosts anything above. Applied to the tap color's contribution
// to bloom - the tap color itself is preserved in `tapColor` for the weighted sum.
float contrast = smoothstep(Threshold, 1.0, lum);
// Gaussian weight based on tap distance from center. Distance-squared in the exponent
// avoids a sqrt.
float distSq = float(x * x + y * y);
float weight = exp(-distSq * invTwoSigmaSquared);
bloom += tapColor * contrast * weight;
totalWeight += weight;
}
}
// Normalize so `bloom` sits on the same 0..1 scale as tapColor regardless of RADIUS.
// Strength then chooses how much of that halo lands on top of the original.
bloom /= totalWeight;
// Step 3: add back in. Preserve original alpha - the composite blits with Opaque blending
// anyway, but keeping alpha honest lets a future author reuse this shader elsewhere.
return float4(original.rgb + bloom * Strength, original.a);
}
technique SpriteDrawing
{
pass P0
{
PixelShader = compile PS_SHADERMODEL MainPS();
}
};
#if OPENGL
#define SV_POSITION POSITION
#define VS_SHADERMODEL vs_3_0
#define PS_SHADERMODEL ps_3_0
#else
#define VS_SHADERMODEL vs_4_0_level_9_1
#define PS_SHADERMODEL ps_4_0_level_9_1
#endif
// Runs on GraphicsManager.BackbufferPostProcessChain, monitor-pixel resolution. Three effects
// stacked in one shader, in this order:
//
// 1. Column mask: darken source samples along vertical bands aligned with the "between-
// source-pixel" boundaries. Applied INSIDE the per-channel sample step, so the aberration
// in step 2 naturally bleeds bright neighboring columns across the darkened boundaries
// (produces the classic magenta / cyan fringing at column edges). Uniform strength - the
// column mask deliberately does NOT bright-shine-through (unlike scanlines below); a real
// shadow mask blocks every column equally regardless of what phosphor is behind it.
// 2. Chromatic aberration: sample R shifted right by ChromaticAberrationPx, B shifted left,
// G at center. Each channel's tap goes through the column mask above.
// 3. Scanlines: darken pixels along horizontal bands between source-pixel rows, sinusoidal
// so the darkening tapers smoothly toward each source pixel's center. Attenuated by
// luminance so bright pixels shine through almost unaffected.
//
// Column mask (1) is inside the sample function so it happens BEFORE aberration (2); scanlines
// (3) are applied last, over the aberrated result, so bloom / bright halos escape both masks.
#define TWO_PI 6.283185307179586
float2 ScreenSizePx;
// Size of one source (game) pixel in monitor pixels - i.e., GraphicsManager.Zoom. Isotropic
// (the upscale is point-clamp square), so this drives both the horizontal column period and
// the vertical scanline period.
float SourcePixelSizePx;
// R/B channel horizontal offset in monitor pixels. 0 disables aberration entirely.
float ChromaticAberrationPx;
// 0..1 darkening applied at column-boundary peaks. Uniform - not luminance-attenuated (unlike
// ScanlineIntensity below), so bright columns are darkened at boundaries just as much as dark
// ones. A real shadow mask blocks every column equally.
float ColumnIntensity;
// 0..1 maximum darkening applied at scanline peaks to a fully-black pixel. Same luminance
// shine-through as the column mask.
float ScanlineIntensity;
Texture2D SpriteTexture;
// No sampler_state override: inherit SamplerState.PointClamp from the SpriteBatch that invokes
// this shader (per GraphicsManager.EndDraw's chain loop). PointClamp keeps the R/G/B taps on
// exact monitor pixels so the aberration is a crisp channel shift, not a blurred smear.
sampler2D SpriteTextureSampler = sampler_state
{
Texture = <SpriteTexture>;
};
struct VertexShaderOutput
{
float4 Position : SV_POSITION;
float4 Color : COLOR0;
float2 TextureCoordinates : TEXCOORD0;
};
// Sample the source with the vertical column mask applied. Each aberration tap goes through
// here at its own shifted UV, so the R and B channels see the column darkening at THEIR
// sample positions - which is exactly what causes the aberration to bleed a bright neighbor's
// color across the darkened boundary in front of us.
float3 SampleWithColumnMask(float2 sampleUv)
{
float3 raw = tex2D(SpriteTextureSampler, sampleUv).rgb;
// Column mask: peaks at monitor-pixel centers that sit on a source-pixel-column boundary,
// troughs at pixel centers away from any boundary. The (monitorX - 0.5) phase shift is
// critical: pixel-shader input for monitor pixel n arrives at x = n + 0.5 (pixel CENTER).
// Without the shift, at Zoom == 2 those centers land at cos-wave zero-crossings uniformly
// (mask == 0.5 everywhere → invisible), and at Zoom == 3 the pattern is off-center. With
// the shift, each column-boundary monitor pixel gets mask == 1 as intended.
float monitorX = sampleUv.x * ScreenSizePx.x;
float phase = (monitorX - 0.5) / SourcePixelSizePx;
float mask = 0.5 + 0.5 * cos(phase * TWO_PI);
// No luminance shine-through here (unlike the scanline pass in MainPS): a shadow-mask
// column blocks bright and dark pixels alike.
return raw * (1.0 - ColumnIntensity * mask);
}
float4 MainPS(VertexShaderOutput input) : COLOR
{
float2 uv = input.TextureCoordinates;
// Aberration through the column mask: R shifted right, B shifted left, G stays put. Each
// channel's tap samples the column-masked source at its own X. Saturating the UVs makes
// near-edge shifts sample the edge pixel rather than wrap / clamp to black.
float aberrUv = ChromaticAberrationPx / ScreenSizePx.x;
float3 rSample = SampleWithColumnMask(saturate(uv + float2(aberrUv, 0.0)));
float3 gSample = SampleWithColumnMask(uv);
float3 bSample = SampleWithColumnMask(saturate(uv - float2(aberrUv, 0.0)));
float alpha = tex2D(SpriteTextureSampler, uv).a;
float3 color = float3(rSample.r, gSample.g, bSample.b);
// Horizontal scanline mask. Same (monitorY - 0.5) phase shift as the column mask above:
// pixel-shader input lands at pixel CENTERS (n + 0.5), and without the shift Zoom == 2
// samples every peak at a cos zero-crossing → the whole screen sits at mask == 0.5 and
// no scanlines are visible. With the shift, the top monitor row of each source-pixel row
// gets mask == 1 as intended.
float monitorY = uv.y * ScreenSizePx.y;
float scanPhase = (monitorY - 0.5) / SourcePixelSizePx;
float scanMask = 0.5 + 0.5 * cos(scanPhase * TWO_PI);
// Luminance of the aberrated color - dark pixels get the full ScanlineIntensity darkening
// at scan peaks, bright pixels get essentially none.
float scanLum = saturate(dot(color, float3(0.299, 0.587, 0.114)));
float scanDarkening = ScanlineIntensity * scanMask * (1.0 - scanLum);
color *= (1.0 - scanDarkening);
return float4(color, alpha);
}
technique SpriteDrawing
{
pass P0
{
PixelShader = compile PS_SHADERMODEL MainPS();
}
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment