Pencil effect in Pixel Shader;

Antoine Fortin
9 min readJun 12, 2023

--

Hey dear readers, it has been a while since I spent time to writting.

Sorry for the lack of content, and I will try to post on a more regular basis. That being said, I wanted today to break down an effect I started thinking about while being quite bored in the train going back home. As I do most of my arts and tech exploration using the computer, I still find relevant to get back to the old pen and paper technique. So I wondered how I could achieve this kind of effect using a shader. This is a quick idea I had and implemetend when back home after a bit too much drinks, so it might not be the most accurate ways to achieve the effect, but wanted to share it and break down every part of it.

Let’s start with what we will be breaking into parts.

So, let’s start by showing the whole code of the shader.

#define size 25.

vec2 Hash12(float t)
{
float x = fract(sin(t * 674.3) * 453.2);
float y = fract(sin(t * 2674.3) * 453.2);
return vec2(x,y);
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
vec2 uv = (fragCoord-.5 * iResolution.xy)/iResolution.y;
vec3 col =
for(float i = 0.; i < size; i++)
{
vec2 dir= Hash12(i * Hash12(uv.x).x - sin(Hash12(uv.y).x)) -
float t = fract(iTime * .05);
float d = length(uv-dir * (t + uv.x * t * uv.y));
float brightness = 0.002;
col += vec3(brightness / d);
}


fragColor = vec4(vec3(1.0, 1.0, 1.0)- col * 2.,1.0);
}

The random part:

So let’s start be reviewing some of the code of the shader. While it is quite short, there is a bit to unpack. First, let’s start with the Hash12

vec2 Hash12(float t)
{
float x = fract(sin(t * 674.3) * 453.2);
float y = fract(sin(t * 2674.3) * 453.2);
return vec2(x,y);
}

In short, we receive a float t as argument and returns a vec2 of random number between zero and one. To visualize this, we can call this function in the mainImage() function of the shader.

From the main if we call the uv.x with the Hash12:

vec3 debug = vec3(Hash12(uv.x).x);

And from the mainImage function we can also build the uv.y

Full code:

#define size 25.

vec2 Hash12(float t)
{
float x = fract(sin(t * 674.3) * 453.2);
float y = fract(sin(t * 2674.3) * 453.2);

return vec2(x,y);
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{

vec2 uv = (fragCoord-.5 * iResolution.xy)/iResolution.y;
vec3 debug = vec3(0.0f);
int isX = 0;
if(isX == 1)
{
debug = vec3(Hash12(uv.x).x);
}
else
{
debug = vec3(Hash12(uv.y).x);
}
fragColor = vec4(vec3(debug),1.0);
}

You can change the isX to 0 or 1.

The main image:

Let’s start by building a bit more intuition on the loop and randomness we have. x

Let’s work this from left to right:

In green: we have the random function being called on another random seed, multiplied by the uv.x represented in blue. We subtract the sinus of another hash based on the uv.y. Let’s break it down even simpler:

For every pixel:
loop 12 times
- Calculate a 2D direction.
-> Create a random 2D vector
a : multiply i with every uv.x with a random seed
b : apply sin() on every uv.y with a random seed

result: vec2(a-b)

Let’s dive deeper:

Next step if to calculate the length of the UV, but I find it a bit counter intuitive without putting in image what we had in the creation of distances based on the randomness.

Our whole loop looks like this:

    vec3 col = vec3(0.0);
for(float i = 0.; i < size; i++)
{
vec2 dir= Hash12(i);
float d = length(uv-dir);

float brightness = 0.002;
col += vec3(brightness / d);


}

Let’s take a look to the brigthness:

float brightness = 0.002;
col += vec3(brightness / d);

We compute a very small amount for the output color as the distance increase(between 0 and 1).

Then it gives us this kind of “particles”

To see the code in action:

#define size 25.

vec2 Hash12(float t)
{
float x = fract(sin(t * 674.3) * 453.2);
float y = fract(sin(t * 2674.3) * 453.2);

return vec2(x,y);
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{

vec2 uv = (fragCoord-.5 * iResolution.xy)/iResolution.y;
vec3 col = vec3(0.0);
for(float i = 0.; i < size; i++)
{
vec2 dir= Hash12(i);
float d = length(uv-dir);
float brightness = 0.002;
col += vec3(brightness / d);
}
fragColor = vec4(vec3(col),1.0);
}

We can also place it to the center by removing .5 on the distance.

We can start to see some usage of the loops, and why we stack color with a super low amount.

Full code:

#define size 25.

vec2 Hash12(float t)
{
float x = fract(sin(t * 674.3) * 453.2);
float y = fract(sin(t * 2674.3) * 453.2);

return vec2(x,y);
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{

vec2 uv = (fragCoord-.5 * iResolution.xy)/iResolution.y;
vec3 col = vec3(0.0);
for(float i = 0.; i < size; i++)
{
vec2 dir= Hash12(i) -.5; // HERE Remove .5
float d = length(uv-dir);

float brightness = 0.002;
col += vec3(brightness / d);


}
fragColor = vec4(vec3(col),1.0);
}

Invert to be white to black:

This task will be simple enough, from the previous code we can simply remove the result to 1. or 1 — result.

fragColor = vec4(vec3(1.0f) - vec3(col),1.0);

As we see, the only difference is that we fade to white when reaching full length on those randomnly generate 2d items.

Green drives blue.

Here is the whole code from the previous frame: you can paste it into a Shadertoy program and explore. :D

#define size 25.

vec2 Hash12(float t)
{
float x = fract(sin(t * 674.3) * 453.2);
float y = fract(sin(t * 2674.3) * 453.2);

return vec2(x,y);
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{

vec2 uv = (fragCoord-.5 * iResolution.xy)/iResolution.y;
vec3 col = vec3(0.0);
for(float i = 0.; i < size; i++)
{
vec2 dir= Hash12(i) -.5;
float d = length(uv-dir);

float brightness = 0.002;
col += vec3(brightness / d);


}
fragColor = vec4(vec3(1.0f) - vec3(col),1.0);
}

Some notes(try to answer them without testing in code):

  • What will happen when you crank the number of particles up?
  • How would you combine this particle technique with many colors?
  • How can you optimized the Hash12 function in the current code?

The combination:

To get back into our 2D pencil effect, we need to get back to this idea of randomness and then abstract it into creative idea.

Without any timing we can achieve this effect.

To build the X randomness:

    for(float i = 0.; i < size; i++)
{
vec2 dir= Hash12(i * Hash12(uv.x).x) - .5;

To build the Y randomness:

for(float i = 0.; i < size; i++)
{
vec2 dir= Hash12(i * Hash12(uv.y).x) - .5;

We can see particles being squished, giving us our effect, but we are not quite there yet.

Full code

#define size 25.

vec2 Hash12(float t)
{
float x = fract(sin(t * 674.3) * 453.2);
float y = fract(sin(t * 2674.3) * 453.2);

return vec2(x,y);
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{

vec2 uv = (fragCoord-.5 * iResolution.xy)/iResolution.y;
vec3 col = vec3(0.0);
vec3 debug = vec3(Hash12(uv.y).x);


for(float i = 0.; i < size; i++)
{
vec2 dir= Hash12(i * Hash12(uv.y).x) - .5;

float t = fract(iTime * .05);
float d = length(uv-dir);

float brightness = 0.002;

col += vec3(brightness / d);


}
// col = vec3(Hash12(12.).x);
// Output to screen


fragColor = vec4(vec3(1.0, 1.0, 1.0)- col * 2.,1.0);
}

Note for every axis:

If we multiply randomness with uv.y we got a result, but not the one we want.

    
for(float i = 0.; i < size; i++)
{
vec2 dir= Hash12(i * Hash12(uv.x).x * uv.y /*HERE*/) - .5;

float t = fract(iTime * .05);
float d = length(uv-dir * (t));

float brightness = 0.002;

col += vec3(brightness / d);


}

Giving us this simple frame, wich is clearly too noisy.

But from this idea, we can start to make our own particles usage.

Here is the code for the previous gif

    for(float i = 0.; i < size; i++)
{
vec2 dir= Hash12(i) - .5;

float t = fract(iTime * .05);
float d = length(uv-dir * (t + uv.x * t * uv.y));

float brightness = 0.002;

col += vec3(brightness / d);


}
float d = length(uv-dir * (t + uv.x * t * uv.y));

As you can see, we indeed fuck around timing for the distance. But still compute the distance based on a hash on the loop counter.

float d = length(uv-dir * (t + uv.x * t * uv.y));

But, let’s change the direction vec2.

vec2 dir= Hash12(i * Hash12(uv.x).x - sin(Hash12(uv.y).x)) - .5;

As we can see here, I change the direction to use a random multiplier(using uv.x) and remove from that a sin() operation that goes from -1 to 1 using a Hash12() with the uv.y.

I mean, at this point, the loop serve as a composition.

The keypoint:

We generate a new direction every step of the loop. We then use the index of the loop i to altern the x and y behaviour of the loop from injecting the uv.x and uv.x into the direction.

We then compute a distance based on these offset by manipulating time to add dynamism.

    for(float i = 0.; i < size; i++)
{
vec2 dir= Hash12(i * Hash12(uv.x).x + sin(Hash12(uv.y).x)) - .5;

float t = fract(iTime * .05);
float d = length(uv-dir * (t + uv.x * t * uv.y));

float brightness = 0.002;

col += vec3(brightness / d);


And, we then remove the 1.0f — what we computed.

Final code:

Once all built, the last step will be to remove 1.0f for every channel to create the desired effect.

#define size 25.

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{


fragColor = vec4(vec3(1.0, 1.0, 1.0)- col,1.0);
}

But for now, I will simply share the final shader:

#define size 25.

vec2 Hash12(float t)
{
float x = fract(sin(t * 674.3) * 453.2);
float y = fract(sin(t * 2674.3) * 453.2);

return vec2(x,y);
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{

vec2 uv = (fragCoord-.5 * iResolution.xy)/iResolution.y;
vec3 col = vec3(0.0);
vec3 debug = vec3(Hash12(uv.y).x);


for(float i = 0.; i < size; i++)
{
vec2 dir= Hash12(i * Hash12(uv.x).x + sin(Hash12(uv.y).x)) - .5;

float t = fract(iTime * .05);
float d = length(uv-dir * (t + uv.x * t * uv.y));
float brightness = 0.002;
col += vec3(brightness / d);
}

fragColor = vec4(vec3(1.0, 1.0, 1.0)- col,1.0);
}

I wanted to explore a bit more on this article, as I think, we use drawing algorithms daily, yet far being close to got the key on those. I wanted to explore more, and dive into shaders with this idea of 2D effect.

Back at writting, with love and passion ❤

--

--

Antoine Fortin

In between Montreal and London, I love to write, read, learn and explore.