Hand-Drawn Doodle Style Without Custom Artwork: A Guide to Procedural Rendering with Three.js Shaders

Smart AI Code2026-09-275 min read

After reading this article, you will have a complete solution for procedurally generating a hand-drawn doodle style with shaders in Three.js—no need to pre-draw a large set of hand-drawn textures. With a two-pass rendering pipeline, any 3D scene can take on a consistent hand-drawn feel. It is suitable for WebGL developers exploring non-photorealistic rendering styles.

Core Idea: Two-Pass Rendering Pipeline

Traditional approaches require a large number of hand-drawn textures, but the Three.js implementation path decouples stylization from scene content:

  1. Pass 1 — Data Buffer: Render the scene into a custom buffer texture that stores only the raw information needed for drawing (shading, ink ID, normals), without any stylization.
  2. Pass 2 — Full-Screen Post-Processing: Use a separate orthographic camera scene to decide, per pixel, whether each location should be drawn as paper color, ink line, or contour line.

The advantage is that no matter how models in the scene move or how the camera changes, the final hand-drawn style remains consistent.

Source: cnblogs.com project implementation notes

Pass 1: Data Buffer

The data buffer uses a custom ShaderMaterial, and the fragment outputs an RGBA half-float vector. The meanings of the channels are as follows:

  • R: shading value (shade), calculated from hemispheric light via dot(n, L) * 0.5 + 0.5
  • G: ink color ID (inkId), distinguishing strokes of different colors
  • B/A: x and y components of the normal (n.x, n.y)

Key configuration:

// The depth texture must use FloatType; otherwise, inverse-depth contour detection will produce noise.
// The render target uses HalfFloatType + NearestFilter and does not generate mipmaps.

If you want an entire area painted in a solid color (such as a background fill), you can set shade to -1; the post-processing stage will recognize this and output the solid color directly.

Source: cnblogs.com data buffer implementation notes

Pass 2: Full-Screen Post-Processing — Five Stroke Layers

The post-processing stage overlays five effects in order:

1. Hand Jitter Sampling

To simulate the instability of hand drawing, value noise offsets the sampling UV. The key point is that the entire stroke should jitter slightly in sync, rather than each stroke being independently random; otherwise, the overall cohesion is lost.

// Value noise offsets UV, making the whole image jitter slightly in unison
vec2 wobOffset = noise(uv + time) * jitterStrength;

Source: cnblogs.com hand jitter sampling implementation; the vertex displacement approach from alanzucconi.com can serve as an alternative path

2. Contour Detection: Inverse-Depth Laplacian

This is the most critical algorithm for the entire effect. The principle is: for any plane in screen space, 1/z is an affine function, and its second-order difference is always 0, so no response is produced inside the plane. Only at depth discontinuities (i.e., object contours) is the second-order difference of inverse depth nonzero.

float lap = abs(1.0/linDepth(zl) + 1.0/linDepth(zr) - 2.0*iw)
           + abs(1.0/linDepth(zu) + 1.0/linDepth(zd) - 2.0*iw);
float edge = smoothstep(0.07, 0.30, lap / (iw + 1e-7));

After normalizing by dividing by 1/d, the contour strength is consistent at any distance, rather than strong up close and weak in the distance. A contour is also added at sudden normal changes to avoid gaps between objects that are touching.

Source: cnblogs.com contour detection principle notes

3. Hatching (Line Hatching)

Hatching lines need to be 'pinned' to the surface and not drift as the object rotates. The implementation reconstructs world coordinates and normals from depth, then performs surface-anchored stripe projection:

  • Use the axis the normal points toward most strongly to determine the projection plane, avoiding stripe stretching
  • Spacing is quantized by powers of 2 (LOD), automatically becoming coarser in the distance to prevent moiré patterns
  • Three directions (45° / -45° / 15°) are overlaid, with density decreasing with shading
float stripes(vec2 p, vec2 dir, float spacing, float width) {
  float t = dot(p, vec2(-dir.y, dir.x));
  float f = abs(fract(t / spacing) - 0.5) * spacing;
  float soft = width * 0.6;
  return 1.0 - smoothstep(width*0.5 - soft, width*0.5 + soft, f);
}

Source: cnblogs.com hatching implementation

4. Paper Effect

Paper texture uses two layers of value noise: low-frequency large patches simulate unevenness in paper texture, while high-frequency fine grain simulates the slight roughness of the paper surface. Horizontal rules are generated by taking the modulo of the screen y coordinate, with spacing equal to 1/13.5 of the canvas height and color a light blue (0.58, 0.70, 0.92). The red margin can be kept or removed as needed.

Source: cnblogs.com paper effect implementation

5. Composition Order

paper → mix(ink color, hatch * 0.72) → mix(contour ink color * 0.92, edge * ew)

Here, ew = 0.75 + 0.35 * vnoise makes the contour line edges a bit rougher, closer to a hand-drawn feel.

Common Pitfalls and Parameter Tuning Quick Reference

Configurations You Must Watch Out For

IssueCauseSolution
Noise in contoursDepth texture not FloatTypeSwitch to FloatType
Misalignment between contours and colorDepth and color sampling use different wob offsetsUse the same offset
Black screenEmpty scene not renderedEnsure a frame is rendered even for an empty scene
Contour lines too thickSampling radius too largeAdjust o = 1.15 * sc

Source: cnblogs.com common pitfalls notes

Key Parameter Quick Reference

ParameterPurposeEffect of Increasing
wob * 2.0 * sc * pxHand jitter amplitudeMore shaky hand
o = 1.15 * scContour sampling radiusThicker lines
smoothstep(0.07, 0.30, ...)Contour sensitivityEasier to outline
0.016 (LOD baseline)Hatching spacingSparser lines
w = sp * 0.17Stroke widthThicker hatching
0.64 / 0.42 / 0.24Shading thresholds for three directionsMore directional lines appear earlier
hatch * 0.72Ink densityDarker
grain / rule * 0.5Paper texture strength / horizontal rule opacityRougher paper / more visible rules
uPaper / uLineSpacingPaper color / horizontal rule spacingChange paper color / change density

Source: cnblogs.com parameter tuning quick reference table

Three.js Version Notes

  • r147 and earlier: Linear output by default; the color values in the example were manually brightened
  • r152 and later: texture2D must be changed to texture; renames such as outputEncoding must be updated accordingly
  • When switching to a dark color scheme, it is recommended to apply pow(col, 1/2.2) to colors or enable sRGBEncoding

Source: cnblogs.com version migration notes

Validation and References

Source: alanzucconi.com points out that this style has been successfully applied in many games such as GoNNER and Baba is You, validating the commercial viability of procedural doodle styles.