Pencil Shader Development
Whilst undergoing my university final project, I decided to undergo coding a pencil shader in only a week! (Why? because I’m an over-ambitious fool)
As of writing this, it remains simply a dream.
I had previously watched videos on building shaders in Unreal Engine, and felt it a task worth completing, if only I could make it work in 3JS.
A quick google search found very few pencil shaders. One especially being a WebGL Pencil Shader (you can view the code in-page) which looks rather… unpencilly and very crosshatchy. In my personal work, I have a very distinct ink drawing style, which I wanted to emulate in a shader.




I found this youtube course which I could use as my basis. My main hurdle was that I simply didn’t know how to integrate the shader code, and the 3D code together.
I started off by setting up my javascript in the same way I would start a regular 3JS page – camera, orbit controls, geometry, render.
Then I took the shader code created in lesson 2 of the course, and stitched that into my code. The result was this:

Show JavaScript Code
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
//import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
//////////////////////////////////////////////////////////////////////////////////////////////
/// shaders //////////////////////////////////////////////////////////////////////////////////
const vshader = `
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}
`
const fshader = `
uniform vec3 u_color;
uniform vec2 u_mouse;
uniform vec2 u_resolution;
uniform float u_time;
void main (void)
{
vec3 color = vec3(u_mouse.x/u_resolution.x, 0.0, u_mouse.y/u_resolution.y);
//color = vec3((sin(u_time)+1.0)/2.0, 0.0, (cos(u_time)+1.0)/2.0);
gl_FragColor = vec4(color, 1.0);
}
`
//////////////////////////////////////////////////////////////////////////////////////////////
/// setup ////////////////////////////////////////////////////////////////////////////////////
const loader = new GLTFLoader();
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias:true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap; // default THREE.PCFShadowMap
const scene = new THREE.Scene();
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
// Camera - fov, aspect, near, far
const camera = new THREE.PerspectiveCamera(
45, //fov
window.innerWidth / window.innerHeight, //aspect
2, //near clip
10000 //far clip
);
// x y z
//camera.position.set(220, 260, 900);
camera.position.z = 100;
// orbit controls //
const controls = new OrbitControls(camera, renderer.domElement);
controls.update();
renderer.render(scene, camera);
const clock = new THREE.Clock();
//////////////////////////////////////////////////////////////////////////////////////////////
/// geometry /////////////////////////////////////////////////////////////////////////////////
const geometry = new THREE.TorusKnotGeometry(
3.5, //radius
2.2, //tube radius
62, //tubularSegments
10, //radialSegments
2, //p
3 //q
);
const uniforms = {
u_color: { value: new THREE.Color(0xff0000) },
u_time: { value: 0.0 },
u_mouse: { value:{ x:0.0, y:0.0 }},
u_resolution: { value:{ x:0, y:0 }}
}
const material = new THREE.ShaderMaterial( {
uniforms: uniforms,
vertexShader: vshader,
fragmentShader: fshader
} );
const torus = new THREE.Mesh( geometry, material );
scene.add( torus );
scene.background = new THREE.Color(0x333);
//////////////////////////////////////////////////////////////////////////////////////////////
/// Rendering ////////////////////////////////////////////////////////////////////////////////
onWindowResize();
if ('ontouchstart' in window){
document.addEventListener('touchmove', move);
}else{
window.addEventListener( 'resize', onWindowResize, false );
document.addEventListener('mousemove', move);
}
function move(evt){
uniforms.u_mouse.value.x = (evt.touches) ? evt.touches[0].clientX : evt.clientX;
uniforms.u_mouse.value.y = (evt.touches) ? evt.touches[0].clientY : evt.clientY;
}
console.log(scene);
animate();
function onWindowResize( event ) {
const aspectRatio = window.innerWidth/window.innerHeight;
let width, height;
if (aspectRatio>=1){
width = 1;
height = (window.innerHeight/window.innerWidth) * width;
}else{
width = aspectRatio;
height = 1;
}
camera.left = -width;
camera.right = width;
camera.top = height;
camera.bottom = -height;
camera.updateProjectionMatrix();
renderer.setSize( window.innerWidth, window.innerHeight );
uniforms.u_resolution.value.x = window.innerWidth;
uniforms.u_resolution.value.y = window.innerHeight;
}
function animate() {
requestAnimationFrame(animate);
uniforms.u_time.value = clock.getElapsedTime();
renderer.render(scene, camera);
}
animate();
Taking the shaders from the WebGL Pencil Shader Example, I was able to get this result:

Show JavaScript Code
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
//import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
//////////////////////////////////////////////////////////////////////////////////////////////
/// shaders //////////////////////////////////////////////////////////////////////////////////
// Shader code adapted from --> https://webgl-shaders.com/pencil-example.html
const vshader = `
#define GLSLIFY 1
// Common varyings
varying vec3 v_position;
varying vec3 v_normal;
/*
* The main program
*/
void main() {
// Save the varyings
v_position = position;
v_normal = normalize(normalMatrix * normal);
// Vertex shader output
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`
const fshader = `
#define GLSLIFY 1
// Common uniforms
uniform vec2 u_resolution;
uniform vec2 u_mouse;
uniform float u_time;
uniform float u_frame;
// Common varyings
varying vec3 v_position;
varying vec3 v_normal;
/*
* Returns a rotation matrix for the given angle
*/
mat2 rotate(float angle) {
return mat2(cos(angle), -sin(angle), sin(angle), cos(angle));
}
/*
* Calculates the diffuse factor produced by the light illumination
*/
float diffuseFactor(vec3 normal, vec3 light_direction) {
float df = dot(normalize(normal), normalize(light_direction));
if (gl_FrontFacing) {
df = -df;
}
return max(0.0, df);
}
/*
* Returns a value between 1 and 0 that indicates if the pixel is inside the horizontal line
*/
float horizontalLine(vec2 pixel, float y_pos, float width) {
return 1.0 - smoothstep(-1.0, 1.0, abs(pixel.y - y_pos) - 0.5 * width);
}
/*
* The main program
*/
void main() {
// Use the mouse position to define the light direction
float min_resolution = min(u_resolution.x, u_resolution.y);
vec3 light_direction = -vec3((u_mouse - 0.5 * u_resolution) / min_resolution, 0.5);
// Calculate the light diffusion factor
float df = diffuseFactor(v_normal, light_direction);
// Move the pixel coordinates origin to the center of the screen
vec2 pos = gl_FragCoord.xy - 0.5 * u_resolution;
// Rotate the coordinates 20 degrees
pos = rotate(radians(20.0)) * pos;
// Define the first group of pencil lines
float line_width = 7.0 * (1.0 - smoothstep(0.0, 0.3, df)) + 0.5;
float lines_sep = 16.0;
vec2 grid_pos = vec2(pos.x, mod(pos.y, lines_sep));
float line_1 = horizontalLine(grid_pos, lines_sep / 2.0, line_width);
grid_pos.y = mod(pos.y + lines_sep / 2.0, lines_sep);
float line_2 = horizontalLine(grid_pos, lines_sep / 2.0, line_width);
// Rotate the coordinates 50 degrees
pos = rotate(radians(-50.0)) * pos;
// Define the second group of pencil lines
lines_sep = 12.0;
grid_pos = vec2(pos.x, mod(pos.y, lines_sep));
float line_3 = horizontalLine(grid_pos, lines_sep / 2.0, line_width);
grid_pos.y = mod(pos.y + lines_sep / 2.0, lines_sep);
float line_4 = horizontalLine(grid_pos, lines_sep / 2.0, line_width);
// Calculate the surface color
float surface_color = 1.0;
surface_color -= 0.8 * line_1 * (1.0 - smoothstep(0.5, 0.75, df));
surface_color -= 0.8 * line_2 * (1.0 - smoothstep(0.4, 0.5, df));
surface_color -= 0.8 * line_3 * (1.0 - smoothstep(0.4, 0.65, df));
surface_color -= 0.8 * line_4 * (1.0 - smoothstep(0.2, 0.4, df));
surface_color = clamp(surface_color, 0.05, 1.0);
// Fragment shader output
gl_FragColor = vec4(vec3(surface_color), 1.0);
}
`
//////////////////////////////////////////////////////////////////////////////////////////////
/// setup ////////////////////////////////////////////////////////////////////////////////////
const loader = new GLTFLoader();
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias:true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap; // default THREE.PCFShadowMap
const scene = new THREE.Scene();
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
// Camera - fov, aspect, near, far
const camera = new THREE.PerspectiveCamera(
45, //fov
window.innerWidth / window.innerHeight, //aspect
2, //near clip
10000 //far clip
);
// x y z
//camera.position.set(220, 260, 900);
camera.position.z = 100;
// orbit controls //
const controls = new OrbitControls(camera, renderer.domElement);
controls.update();
renderer.render(scene, camera);
const clock = new THREE.Clock();
//////////////////////////////////////////////////////////////////////////////////////////////
/// geometry /////////////////////////////////////////////////////////////////////////////////
const geometry = new THREE.TorusKnotGeometry(
3.5, //radius
2.2, //tube radius
62, //tubularSegments
10, //radialSegments
2, //p
3 //q
);
const uniforms = {
u_color: { value: new THREE.Color(0xff0000) },
u_time: { value: 0.0 },
u_mouse: { value:{ x:0.0, y:0.0 }},
u_resolution: { value:{ x:0, y:0 }}
}
const material = new THREE.ShaderMaterial( {
uniforms: uniforms,
vertexShader: vshader,
fragmentShader: fshader
} );
const torus = new THREE.Mesh( geometry, material );
scene.add( torus );
scene.background = new THREE.Color(0x333);
//////////////////////////////////////////////////////////////////////////////////////////////
/// Rendering ////////////////////////////////////////////////////////////////////////////////
onWindowResize();
if ('ontouchstart' in window){
document.addEventListener('touchmove', move);
}else{
window.addEventListener( 'resize', onWindowResize, false );
document.addEventListener('mousemove', move);
}
function move(evt){
uniforms.u_mouse.value.x = (evt.touches) ? evt.touches[0].clientX : evt.clientX;
uniforms.u_mouse.value.y = (evt.touches) ? evt.touches[0].clientY : evt.clientY;
}
console.log(scene);
animate();
function onWindowResize( event ) {
const aspectRatio = window.innerWidth/window.innerHeight;
let width, height;
if (aspectRatio>=1){
width = 1;
height = (window.innerHeight/window.innerWidth) * width;
}else{
width = aspectRatio;
height = 1;
}
camera.left = -width;
camera.right = width;
camera.top = height;
camera.bottom = -height;
camera.updateProjectionMatrix();
renderer.setSize( window.innerWidth, window.innerHeight );
uniforms.u_resolution.value.x = window.innerWidth;
uniforms.u_resolution.value.y = window.innerHeight;
}
function animate() {
requestAnimationFrame(animate);
uniforms.u_time.value = clock.getElapsedTime();
renderer.render(scene, camera);
}
animate();
This perfectly combines the shader with the code that I wrote.
Issues:
- cursor position changes the light direction
- appearance of the lines.
By changing the rotation coordinates of the lines, I’m able to lay them on top in a similar fashion to how I render my drawings.

Issues:
- lines are too uniform
- not pencil-like
- lines still follow the cursor.
By removing the mouse variable, I was able to stop the light direction from following the mouse. However, some code once removed broke the shader, so I left the remnants in.
const uniforms = {
u_color: { value: new THREE.Color(0xff0000) },
u_time: { value: 0.0 },
// u_mouse: { value:{ x:5, y:5 }}, // removes the mouse being used to change light direction
u_resolution: { value:{ x:0, y:0 }}
}
Then, through playing with the settings of light direction, I was able to adjust the angle and contrast to my liking

// mouse var changes nothing changes light direction if prev val is low, does nothing. If high, adjusts contrast
vec3 light_direction = vec3((u_mouse - 0.3 * u_resolution) / min_resolution, -0.99);
Issues:
- Lines too uniform
- Lines not pencil-like
These problems proved to be quite difficult for me, this being my first time trying to code a shader. I spent a long few hours playing with values and trying to use randomness.
My thinking is as follows – if I can randomise the angles and sizes slightly, it’ll look more hand drawn. I also really want to have a more pencil-like texture; this, I currently think can be achieved using Noise. I watched this video on using noise to blend textures using glsl and javascript.

Another thing I want to be able to do is blend line weight, where the line will subtly be thinner and thicker in areas, as if the hand drawing it was adding variable pressure
I think I will also need to vary the line seperation, so that the gaps feel more natural and less uniform

After trying almost every method of importing the full model, including glb, fbx, and obj… I finally managed to get the model to load with the shader material.
See the code that worked!
const uniforms = {
u_color: { value: new THREE.Color(0xff0000) },
u_time: { value: 0.0 },
// u_mouse: { value:{ x:5, y:5 }}, // removes the mouse being used to change light direction
u_resolution: { value:{ x:0, y:0 }}
}
const material = new THREE.ShaderMaterial( {
uniforms: uniforms,
vertexShader: vshader,
fragmentShader: fshader
} );
const fbxLoader = new FBXLoader();
fbxLoader.load('Model_Low.fbx', (object) => {
////Now we find each Mesh...
object.traverse( function ( child ) {
if ( child instanceof THREE.Mesh ) {
////...and we replace the material with our custom one
child.material = material;
child.material.side = THREE.DoubleSide;
}
});
object.position.set(0, -10, 0)
object.scale.set(.07, .07, .07)
scene.add(object);
});


Currently, it looks great from the top, but terrible front on. This is because the scale has changed. The model is so much bigger compared to the torus knot
At this point I think it really needs an outline shader so that you can clearly see the mesh in its entirety
In an effort to not have to code all that (lol) I did the tried and tested process of duplicating your object, and flipping the normals.

However, this resulted in this. While a cool rim light effect, I don’t think this quite worked 🙁
code here
const uniforms = {
u_color: { value: new THREE.Color(0xff0000) },
u_time: { value: 0.0 },
// u_mouse: { value:{ x:5, y:5 }}, // removes the mouse being used to change light direction
u_resolution: { value:{ x:0, y:0 }}
}
const material = new THREE.ShaderMaterial( {
uniforms: uniforms,
vertexShader: vshader,
fragmentShader: fshader
} );
const outline = new THREE.MeshPhongMaterial();
outline.color.setHSL(0, 1, .5); // red
outline.flatShading = true;
const fbxLoader = new FBXLoader();
fbxLoader.load('Model_Low.fbx', (object) => {
////Now we find each Mesh...
object.traverse( function ( child ) {
if ( child instanceof THREE.Mesh ) {
////...and we replace the material with our custom one
child.material = material;
child.material.side = THREE.DoubleSide;
}
});
object.position.set(0, -10, 0)
object.scale.set(0.07, 0.07, 0.07)
scene.add(object);
});
fbxLoader.load('Model_Low.fbx', (outlineobject) => {
////Now we find each Mesh...
outlineobject.traverse( function ( child ) {
if ( child instanceof THREE.Mesh ) {
////...and we replace the material with our custom one
child.material = outline;
}
});
outlineobject.position.set(0, -10, 0)
outlineobject.scale.set(0.07, 0.07, 0.07)
scene.add(outlineobject);
});

Leave a Reply