CRT Scanline minimal thickness in x4 scale


Modern CRT shaders simulate beam size variation depending on luminosity, I really like this feature since it adds dynamism to the picture. Most People have 1080p displays, and concerning 240p games we are limited to x4 scale to get even scanlines. I noticed that in x3 or x5 scale the minimal scanline size will be 1 pixel but in x4 it’s 2 pixel only so the dynamic is reduced.

I have no skills at coding but if I make simulation in Photoshop I can create different patterns for testing and making 1 pixel min in x4 scale is actually possible !

x4-pattern x4 pattern

x4-alternative-pattern x4 alternative pattern

I admit that it’s a “personnal whim” but I have this in mind since many years ! Maybe I’m not the only one who is interested.

Scanline Overlays for More Dynamic Scanlines

That does make for a more dynamic and interesting image. Good stuff! :slight_smile:


Thanks ! Here is another example with a contrasted picture. Just added a bit of channel offset and sharpen.


And a final example for comparison with aperture grille effect. I hope that shaders creators find it interesting enough to adapt their shaders someday ( Hyllian, Aperture, Easymode etc…) Also x6 factor for 1440p displays would deserve similar treatment. But no need for x3, x5, x7 as far as I tested.


Hey Flamex,

I tried recreating your first example by editing crt-aperture. I think I got it pretty close, although the gamma doesn’t quite match up.

At 4x scale the middle of the scanline was landing between pixels 2 & 3, so the results were always a minimum of 2 pixels thick. So I shifted the coordinates up to make the middle of the scanline land on pixel 2, which resulted in nice thin lines.

Here’s an edited crt-aperture.glsl you can try out:

    CRT Shader by EasyMode
    License: GPL

#pragma parameter SHARPNESS_IMAGE "Sharpness Image" 1.0 0.5 5.0 0.25
#pragma parameter SHARPNESS_EDGES "Sharpness Edges" 3.0 0.5 5.0 0.25
#pragma parameter GLOW_WIDTH "Glow Width" 0.5 0.05 0.65 0.05
#pragma parameter GLOW_HEIGHT "Glow Height" 0.5 0.05 0.65 0.05
#pragma parameter GLOW_HALATION "Glow Halation" 0.35 0.0 1.0 0.01
#pragma parameter GLOW_DIFFUSION "Glow Diffusion" 0.05 0.0 1.0 0.01
#pragma parameter MASK_COLORS "Mask Colors" 2.0 2.0 3.0 1.0
#pragma parameter MASK_STRENGTH "Mask Strength" 0.0 0.0 1.0 0.05
#pragma parameter MASK_SIZE "Mask Size" 1.0 1.0 9.0 1.0
#pragma parameter SCANLINE_SIZE_MIN "Scanline Size Min." 0.5 0.25 1.5 0.05
#pragma parameter SCANLINE_SIZE_MAX "Scanline Size Max." 1.5 0.25 1.5 0.05
#pragma parameter SCANLINE_SHAPE "Scanline Shape" 2.5 1.0 100.0 0.1
#pragma parameter SCANLINE_OFFSET "Scanline Offset" 1.0 0.0 1.0 1.0
#pragma parameter GAMMA_INPUT "Gamma Input" 2.5 1.0 5.0 0.1
#pragma parameter GAMMA_OUTPUT "Gamma Output" 2.5 1.0 5.0 0.1
#pragma parameter BRIGHTNESS "Brightness" 1.5 0.0 2.0 0.05

#define Coord TEX0

#if defined(VERTEX)

#if __VERSION__ >= 130
#define OUT out
#define IN  in
#define tex2D texture
#define OUT varying 
#define IN attribute 
#define tex2D texture2D

#ifdef GL_ES
#define PRECISION mediump

IN  vec4 VertexCoord;
IN  vec4 Color;
IN  vec2 TexCoord;
OUT vec4 color;
OUT vec2 Coord;

uniform mat4 MVPMatrix;
uniform PRECISION int FrameDirection;
uniform PRECISION int FrameCount;
uniform PRECISION vec2 OutputSize;
uniform PRECISION vec2 TextureSize;
uniform PRECISION vec2 InputSize;

void main()
    gl_Position = MVPMatrix * VertexCoord;
    color = Color;
    Coord = TexCoord;

#elif defined(FRAGMENT)

#if __VERSION__ >= 130
#define IN in
#define tex2D texture
out vec4 FragColor;
#define IN varying
#define FragColor gl_FragColor
#define tex2D texture2D

#ifdef GL_ES
precision highp float;
precision mediump float;
#define PRECISION mediump

uniform PRECISION int FrameDirection;
uniform PRECISION int FrameCount;
uniform PRECISION vec2 OutputSize;
uniform PRECISION vec2 TextureSize;
uniform PRECISION vec2 InputSize;
uniform sampler2D Texture;
IN vec2 Coord;

uniform PRECISION float MASK_SIZE;
#define GLOW_WIDTH 0.5
#define GLOW_HEIGHT 0.5
#define GLOW_HALATION 0.35
#define GLOW_DIFFUSION 0.05
#define MASK_COLORS 2.0
#define MASK_STRENGTH 0.0
#define MASK_SIZE 1.0
#define SCANLINE_SHAPE 2.5
#define GAMMA_INPUT 2.5
#define GAMMA_OUTPUT 2.5
#define BRIGHTNESS 1.5

#define FIX(c) max(abs(c), 1e-5)
#define PI 3.141592653589
#define saturate(c) clamp(c, 0.0, 1.0)
#define TEX2D(c) pow(tex2D(tex, c).rgb, vec3(GAMMA_INPUT))

mat3 get_color_matrix(sampler2D tex, vec2 co, vec2 dx)
    return mat3(TEX2D(co - dx), TEX2D(co), TEX2D(co + dx));

vec3 blur(mat3 m, float dist, float rad)
    vec3 x = vec3(dist - 1.0, dist, dist + 1.0) / rad;
    vec3 w = exp2(x * x * -1.0);

    return (m[0] * w.x + m[1] * w.y + m[2] * w.z) / (w.x + w.y + w.z);

vec3 filter_gaussian(sampler2D tex, vec2 co, vec2 tex_size)
    vec2 dx = vec2(1.0 / tex_size.x, 0.0);
    vec2 dy = vec2(0.0, 1.0 / tex_size.y);
    vec2 pix_co = co * tex_size;
    vec2 tex_co = (floor(pix_co) + 0.5) / tex_size;
    vec2 dist = (fract(pix_co) - 0.5) * -1.0;

    mat3 line0 = get_color_matrix(tex, tex_co - dy, dx);
    mat3 line1 = get_color_matrix(tex, tex_co, dx);
    mat3 line2 = get_color_matrix(tex, tex_co + dy, dx);
    mat3 column = mat3(blur(line0, dist.x, GLOW_WIDTH),
                               blur(line1, dist.x, GLOW_WIDTH),
                               blur(line2, dist.x, GLOW_WIDTH));

    return blur(column, dist.y, GLOW_HEIGHT);

vec3 filter_lanczos(sampler2D tex, vec2 co, vec2 tex_size, float sharp)
    vec2 dx = vec2(1.0 / tex_size.x, 0.0);
    vec2 pix_co = co * tex_size - vec2(0.5, 0.0);
    vec2 tex_co = (floor(pix_co) + vec2(0.5, 0.0)) / tex_size;
    vec2 dist = saturate(fract(pix_co) * sharp - (sharp - 1.0) * 0.5);
    vec4 coef = PI * vec4(dist.x + 1.0, dist.x, dist.x - 1.0, dist.x - 2.0);

    coef = FIX(coef);
    coef = 2.0 * sin(coef) * sin(coef / 2.0) / (coef * coef);
    coef /= dot(coef, vec4(1.0));

    vec4 col1 = vec4(TEX2D(tex_co), 1.0);
    vec4 col2 = vec4(TEX2D(tex_co + dx), 1.0);

    return (mat4(col1, col1, col2, col2) * coef).rgb;

vec3 get_scanline_weight(float x, vec3 col)
    vec3 beam = mix(vec3(SCANLINE_SIZE_MIN), vec3(SCANLINE_SIZE_MAX), pow(col, vec3(1.0 / SCANLINE_SHAPE)));
    vec3 x_mul = 2.0 / beam;
    vec3 x_offset = x_mul * 0.5;

    return smoothstep(0.0, 1.0, 1.0 - abs(x * x_mul - x_offset)) * x_offset;

vec3 get_mask_weight(float x)
    float i = mod(floor(x * OutputSize.x * TextureSize.x / (InputSize.x * MASK_SIZE)), MASK_COLORS);

    if (i == 0.0) return mix(vec3(1.0, 0.0, 1.0), vec3(1.0, 0.0, 0.0), MASK_COLORS - 2.0);
    else if (i == 1.0) return vec3(0.0, 1.0, 0.0);
    else return vec3(0.0, 0.0, 1.0);

void main()
    float scale = floor((OutputSize.y / InputSize.y) + 0.001);
    float offset = 1.0 / scale * 0.5;
    if (mod(scale, 2.0)) offset = 0.0;
    vec2 co = (Coord * TextureSize - vec2(0.0, offset * SCANLINE_OFFSET)) / TextureSize;

    vec3 col_glow = filter_gaussian(Texture, co, TextureSize);
    vec3 col_soft = filter_lanczos(Texture, co, TextureSize, SHARPNESS_IMAGE);
    vec3 col_sharp = filter_lanczos(Texture, co, TextureSize, SHARPNESS_EDGES);
    vec3 col = sqrt(col_sharp * col_soft);

    col *= get_scanline_weight(fract(co.y * TextureSize.y), col_soft);
    col_glow = saturate(col_glow - col);
    col += col_glow * col_glow * GLOW_HALATION;
    col = mix(col, col * get_mask_weight(co.x) * MASK_COLORS, MASK_STRENGTH);
    col += col_glow * GLOW_DIFFUSION;
    col = pow(col * BRIGHTNESS, vec3(1.0 / GAMMA_OUTPUT));

    FragColor = vec4(col, 1.0);


Different Scanlines for crt-apeture?

:open_mouth: that’s some hot shit, buddy! How would you feel about that being the default version?


Thanks hunter,

I’m leaning towards making it the default version, although I should probably add a few parameters so that the current default look can still be achieved.


I’ve just tested it’s perfect !

Thank you so very much You made my day (and night) !


@EasyMode @hunterk Was this the only change?

    Coord = (Coord * TextureSize - vec2(0.0, 0.125)) / TextureSize;

I quickly skimmed it on my phone and this seems like the only thing different, this is based off of memory though.

EDIT: I think the scanline min and max settings minimum setting have also been changed to 0.25 from 0.50, again this is from memory.


Very nice! I’ve played around with overlays to achieve similar results in the past, but this is much easier to use.


You can also get very similar results with zfast crt at 5x scale if you set scanline darkness min to 9.00 and scanline darkness max to 8.00. It’s basically the same pattern. I’m not sure but I think it does something similar at 4x scale.


Thanks, I already get nice thin scanlines at x5 scales with : aperture, hyllian and royale but never at x4 until today :slight_smile: maybe it’s possible with some other shaders but I don’t know how you do that with zfast with your settings. If scanlines are too thin the picture will be really dark, there’s nothing to compensate, glow and gamma.


Huh, yeah zfast also has the 2px wide scanlines at 4x scale.

Never noticed because I never use 4x scale for anything. I prefer to use all of the vertical area of the screen and crop the area outside of the CRT safe zone.

Also, the best way (IMO) to compensate for the lost brightness resulting from scanlines is to crank up the display backlight, which leaves the color values unaltered. No need to mess with the gamma or add glow or bloom effects to get an acceptably bright image.

Thanks for your contribution, though! Your scanlines look very nice.


@hunterk I’ve updated the above code to include a few new parameters. We can make it the new default. When used at an even scale the shader will now adjust the coordinates to display thinner scanlines.

To get the previous default look change the following parameters:

Glow Halation 0.1 Mask Strength 0.3 Scanline Shape 1.0 Scanline Offset 0.0 Gamma Input 2.4 Gamma Output 2.4

The coordinate change was the main fix to display thinner scanlines at 4x scale.

I also added a pow function call to get_scanline_weight, which was necessary to match the dynamics shown in @Flamex 's photoshop. This can be adjusted with the Scanline Shape parameter.

The scanline min threshold was lowered to 0.25, although I kept the min setting at 0.5.

The sharpness settings were also tweaked to allow non-integer values.


Awesome. I’ll get those changes pushed up to the repos soon.


What a bummer, I was all happy in my ignorance with crt-royale, now I won’t see it with the same eyes when playing on TV, lol. Crap give me back the dynamics!


@EasyMode I copied your update and ran it as a glsl and I’m getting this error

ERROR: 3:209: error(#164) l-value required: assign "TEX0" (can't modify an input)
ERROR: error(#237) 1 compilation errors. No code generated

Also I’m running an AMD card.


I’ll work on it when I push it up to the repos. GLSL, particularly, can be very picky (or not at all!) with a lot of variance among drivers and compilers.


@Syh @hunterk Sorry about that. Sounds like it was because I modified the input coord directly. I updated the code again — hopefully it works now.


The shader compiles now on amd but scanline offset is ineffective for me. Seems still ok on nvidia.