A lot of folks like to apply a pincushion/curvature effect to their emulator image to try and capture a bit of the old CRT TV curved tube effect. This can be a problem, though, when paired with a scanline effect, since the curvature introduces an aliasing artifact on the scanlines known as a "moire pattern":
![]() |
This is a worst-case scenario, used for illustrative purposes. Notice the ugly pattern fanning out from the middle of the outside edges of the screen |
Graphics programmer Timothy Lottes (author of the popular FXAA algorithm and a lovely public domain CRT shader, among others) was working on ways of dealing with the aliasing/moire problem for VR and published this shadertoy showing how to hide it by introducing jitter/noise in where the texture is sampled. Turns out that same strategy can be used to mitigate our scanline moire.
When applied to an actual game image, this:
becomes this (caution, big animated gif; the noise effect doesn't really work in still images):
You can see that the noise is concentrated along the same areas where the moire pattern was visible before, but it's much less obtrusive and sort of melts away when viewed at a distance.
When curvature is added to an existing, normally flat CRT shader (Themaister's crtglow-gaussian shader, one of my favorites), we go from this:
to this, with the noise/jitter-based mitigation:
In this example, the noise also adds a touch of analog warmth/sparkle to the CRT effect, which I like.
Here's the code snippet that does it (all copyrights belong to Mr. Lottes):
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#define moire_mitigation_factor 64.0 | |
#define warpX 0.031 | |
#define warpY 0.041 | |
// Convert from linear to sRGB. | |
//float Srgb(float c){return(c<0.0031308?c*12.92:1.055*pow(c,0.41666)-0.055);} | |
vec4 Srgb(vec4 c){return pow(c, vec4(1.0 / 2.2));} | |
// Convert from sRGB to linear. | |
//float Linear(float c){return(c<=0.04045)?c/12.92:pow((c+0.055)/1.055,2.4);} | |
float Linear(float c){return pow(c, 2.2);} | |
// | |
// Semi-Poor Quality Temporal Noise | |
// | |
// Base. | |
// Ripped ad modified from: https://www.shadertoy.com/view/4djSRW | |
float Noise(vec2 p,float x){p+=x; | |
vec3 p3=fract(vec3(p.xyx)*10.1031); | |
p3+=dot(p3,p3.yzx+19.19); | |
return (fract((p3.x+p3.y)*p3.z)*2.0-1.0) / moire_mitigation_factor;} | |
// Step 1 in generation of the dither source texture. | |
float Noise1(vec2 uv,float n){ | |
float a=1.0,b=2.0,c=-12.0,t=1.0; | |
return (1.0/max(a*4.0+b*4.0,-c))*( | |
Noise(uv+vec2(-1.0,-1.0)*t,n)*a+ | |
Noise(uv+vec2( 0.0,-1.0)*t,n)*b+ | |
Noise(uv+vec2( 1.0,-1.0)*t,n)*a+ | |
Noise(uv+vec2(-1.0, 0.0)*t,n)*b+ | |
Noise(uv+vec2( 0.0, 0.0)*t,n)*c+ | |
Noise(uv+vec2( 1.0, 0.0)*t,n)*b+ | |
Noise(uv+vec2(-1.0, 1.0)*t,n)*a+ | |
Noise(uv+vec2( 0.0, 1.0)*t,n)*b+ | |
Noise(uv+vec2( 1.0, 1.0)*t,n)*a+ | |
0.0);} | |
// Step 2 in generation of the dither source texture. | |
float Noise2(vec2 uv,float n){ | |
float a=1.0,b=2.0,c=-2.0,t=1.0; | |
return (1.0/(a*4.0+b*4.0))*( | |
Noise1(uv+vec2(-1.0,-1.0)*t,n)*a+ | |
Noise1(uv+vec2( 0.0,-1.0)*t,n)*b+ | |
Noise1(uv+vec2( 1.0,-1.0)*t,n)*a+ | |
Noise1(uv+vec2(-1.0, 0.0)*t,n)*b+ | |
Noise1(uv+vec2( 0.0, 0.0)*t,n)*c+ | |
Noise1(uv+vec2( 1.0, 0.0)*t,n)*b+ | |
Noise1(uv+vec2(-1.0, 1.0)*t,n)*a+ | |
Noise1(uv+vec2( 0.0, 1.0)*t,n)*b+ | |
Noise1(uv+vec2( 1.0, 1.0)*t,n)*a+ | |
0.0);} | |
// Compute temporal dither from integer pixel position uv. | |
float Noise3(vec2 uv){return Noise2(uv,fract(iTime));} | |
// Energy preserving dither, for {int pixel pos,color,amount}. | |
vec2 Noise4(vec2 uv,vec2 c,float a){ | |
// Grain value {-1 to 1}. | |
vec2 g=vec2(Noise3(uv)*2.0); | |
// Step size for black in non-linear space. | |
float rcpStep=1.0/(256.0-1.0); | |
// Estimate amount negative which still quantizes to zero. | |
vec2 black=vec2(0.5*Linear(rcpStep)); | |
// Estimate amount above 1.0 which still quantizes to 1.0. | |
vec2 white=vec2(2.0-Linear(1.0-rcpStep)); | |
// Add grain. | |
return vec2(clamp(c+g*min(c+black,min(white-c,a)),0.0,1.0));} | |
// | |
// Pattern | |
// | |
// 4xMSAA pattern for quad given integer coordinates. | |
// | |
// . x . . | < pixel | |
// . . . x | | |
// x . . . | |
// . . x . | |
// | |
// 01 | |
// 23 | |
// | |
vec2 Quad4(vec2 pp){ | |
int q=(int(pp.x)&1)+((int(pp.y)&1)<<1); | |
if(q==0)return pp+vec2( 0.25,-0.25); | |
if(q==1)return pp+vec2( 0.25, 0.25); | |
if(q==2)return pp+vec2(-0.25,-0.25); | |
return pp+vec2(-0.25, 0.25);} | |
// Rotate {0.0,r} by a {-1.0 to 1.0}. | |
vec2 Rot(float r,float a){return vec2(r*cos(a*3.14159),r*sin(a*3.14159));} | |
// | |
// POOR QUALITY JITTERED | |
// | |
// Jittered position. | |
vec2 Jit(vec2 pp){ | |
// Start with better baseline pattern. | |
pp=Quad4(pp); | |
// Very poor quality (clumping) move in disc around pixel. | |
float n=Noise(pp,fract(iTime)); | |
float m=Noise(pp,fract(iTime*0.333))*0.5+0.5; | |
m = sqrt(m) / 4.0; | |
return pp+Rot(0.707*0.5*m,n);} | |
// | |
// POOR QUALITY JITTERED 4x | |
// | |
// Gaussian filtered jittered tap. | |
void JitGaus4(inout vec2 sumC,inout vec2 sumW,vec2 pp,vec2 mm){ | |
vec2 jj=Jit(pp); | |
vec2 c=jj; | |
vec2 vv=mm-jj; | |
float w=exp2(-1.0*dot(vv,vv)); | |
sumC+=c*vec2(w); sumW+=vec2(w);} | |
// Many tap gaussian from poor quality jittered 4/sample per pixel | |
// | |
// . x x x . | |
// x x x x x | |
// x x x x x | |
// x x x x x | |
// . x x x . | |
// | |
vec2 ResolveJitGaus4(vec2 pp){ | |
vec2 ppp=(pp); | |
vec2 sumC=vec2(0.0); | |
vec2 sumW=vec2(0.0); | |
JitGaus4(sumC,sumW,ppp+vec2(-1.0,-2.0),pp); | |
JitGaus4(sumC,sumW,ppp+vec2( 0.0,-2.0),pp); | |
JitGaus4(sumC,sumW,ppp+vec2( 1.0,-2.0),pp); | |
JitGaus4(sumC,sumW,ppp+vec2(-2.0,-1.0),pp); | |
JitGaus4(sumC,sumW,ppp+vec2(-1.0,-1.0),pp); | |
JitGaus4(sumC,sumW,ppp+vec2( 0.0,-1.0),pp); | |
JitGaus4(sumC,sumW,ppp+vec2( 1.0,-1.0),pp); | |
JitGaus4(sumC,sumW,ppp+vec2( 2.0,-1.0),pp); | |
JitGaus4(sumC,sumW,ppp+vec2(-2.0, 0.0),pp); | |
JitGaus4(sumC,sumW,ppp+vec2(-1.0, 0.0),pp); | |
JitGaus4(sumC,sumW,ppp+vec2( 0.0, 0.0),pp); | |
JitGaus4(sumC,sumW,ppp+vec2( 1.0, 0.0),pp); | |
JitGaus4(sumC,sumW,ppp+vec2( 2.0, 0.0),pp); | |
JitGaus4(sumC,sumW,ppp+vec2(-2.0, 1.0),pp); | |
JitGaus4(sumC,sumW,ppp+vec2(-1.0, 1.0),pp); | |
JitGaus4(sumC,sumW,ppp+vec2( 0.0, 1.0),pp); | |
JitGaus4(sumC,sumW,ppp+vec2( 1.0, 1.0),pp); | |
JitGaus4(sumC,sumW,ppp+vec2( 2.0, 1.0),pp); | |
JitGaus4(sumC,sumW,ppp+vec2(-1.0, 2.0),pp); | |
JitGaus4(sumC,sumW,ppp+vec2( 0.0, 2.0),pp); | |
JitGaus4(sumC,sumW,ppp+vec2( 1.0, 2.0),pp); | |
return sumC/sumW;} | |
vec2 moire_resolve(vec2 coord){ | |
vec2 pp = coord; | |
vec2 cc = vec2(0.0, 0.0); | |
cc = ResolveJitGaus4(pp); | |
cc = Noise4(pp, cc, 1.0 / 32.0); | |
cc = cc + vec2(0.0105, 0.015); | |
return cc; | |
} | |
// Distortion of scanlines, and end of screen alpha. | |
vec2 Warp(vec2 pos) | |
{ | |
pos = pos*2.0-1.0; | |
pos *= vec2(1.0 + (pos.y*pos.y)*warpX, 1.0 + (pos.x*pos.x)*warpY); | |
return pos*0.5 + 0.5; | |
} |
Update 12/1/2018: A more traditional--and highly effective--method of moire removal is through elliptical weighted average (EWA) filtering, as described here. Fellow shader enthusiast torridgristle whipped up a GLSL version with curvature, which can be added after CRT/scanline shaders to look like this:
It's quite fast, too, making it a very good, flexible choice for curvature plus moire-mitigation.