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:
- 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.
- 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
| Issue | Cause | Solution |
|---|---|---|
| Noise in contours | Depth texture not FloatType | Switch to FloatType |
| Misalignment between contours and color | Depth and color sampling use different wob offsets | Use the same offset |
| Black screen | Empty scene not rendered | Ensure a frame is rendered even for an empty scene |
| Contour lines too thick | Sampling radius too large | Adjust o = 1.15 * sc |
Source: cnblogs.com common pitfalls notes
Key Parameter Quick Reference
| Parameter | Purpose | Effect of Increasing |
|---|---|---|
wob * 2.0 * sc * px | Hand jitter amplitude | More shaky hand |
o = 1.15 * sc | Contour sampling radius | Thicker lines |
smoothstep(0.07, 0.30, ...) | Contour sensitivity | Easier to outline |
0.016 (LOD baseline) | Hatching spacing | Sparser lines |
w = sp * 0.17 | Stroke width | Thicker hatching |
0.64 / 0.42 / 0.24 | Shading thresholds for three directions | More directional lines appear earlier |
hatch * 0.72 | Ink density | Darker |
grain / rule * 0.5 | Paper texture strength / horizontal rule opacity | Rougher paper / more visible rules |
uPaper / uLineSpacing | Paper color / horizontal rule spacing | Change 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:
texture2Dmust be changed totexture; renames such asoutputEncodingmust be updated accordingly - When switching to a dark color scheme, it is recommended to apply
pow(col, 1/2.2)to colors or enablesRGBEncoding
Source: cnblogs.com version migration notes
Validation and References
- Online demo: https://zsj23333.github.io/doodleBreak/
- GitHub source: https://github.com/zsj23333/doodleBreak
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.