A hand-painted, comic-illustrated render toolkit for the web. Make 3D look like art, not a render.
Inkwell is a small, focused set of postprocessing effects and time utilities that recreate the non-photorealistic look of films like Spider-Verse and Arcane — halftone dots, comic-print misregistration, ink outlines, posterised toon bands, and the stepped "on-twos" timing that makes animation feel hand-drawn. It works with vanilla three.js and with React Three Fiber.
Built by Velkina — Ömer Can Nalbant & Baha Taşkın.
npm i @velkina/inkwell
Peer deps:
three,postprocessing, and (for the React entry)@react-three/fiber+@react-three/postprocessing.
Most "stylised" WebGL slaps one filter on a photoreal render and calls it a day — it reads as a photo with an Instagram filter, not as a drawing. The film look is a stack of deliberate passes on top of an already-stylised (toon-banded) base, plus a temporal trick almost nobody ports to the web: characters hold frames on twos (≈12 fps) while the camera stays smooth. Inkwell gives you both, in the correct order, as composable pieces.
The pass order matters and is fixed for a reason (see Pipeline).
import { Canvas } from "@react-three/fiber";
import { EffectComposer } from "@react-three/postprocessing";
import { Comic } from "@velkina/inkwell/react";
export default function Scene() {
return (
<Canvas>
<ambientLight intensity={0.6} />
<directionalLight position={[3, 5, 2]} />
<mesh>
<torusKnotGeometry args={[1, 0.35, 200, 32]} />
<meshStandardMaterial color="#e84d3c" />
</mesh>
<EffectComposer>
<Comic intensity={1} halftoneMode="cmyk" />
</EffectComposer>
</Canvas>
);
}<Comic> is the batteries-included preset. To art-direct, compose the passes yourself:
import { EffectComposer } from "@react-three/postprocessing";
import { Posterize, InkOutline, Halftone, Misregister } from "@velkina/inkwell/react";
<EffectComposer>
<Posterize levels={5} /> {/* 1. stylise the base */}
<InkOutline thickness={1} threshold={0.16} /> {/* 2. silhouettes + creases */}
<Halftone scale={1.4} mode="cmyk" /> {/* 3. printed dots */}
<Misregister strength={1.6} /> {/* 4. comic mis-print */}
</EffectComposer>import { EffectComposer, RenderPass, EffectPass } from "postprocessing";
import { PosterizeEffect, InkOutlineEffect, HalftoneEffect, MisregisterEffect } from "@velkina/inkwell";
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(new EffectPass(camera,
new PosterizeEffect({ levels: 5 }),
new InkOutlineEffect({ thickness: 1 }),
new HalftoneEffect({ scale: 1.4, mode: "cmyk" }),
new MisregisterEffect({ strength: 1.6 }),
));All four merge into one fullscreen fragment pass via
EffectPass, so the whole comic look costs roughly one extra draw — see Performance.
Hand-drawn animation is often shot on twos: a new drawing every other frame (~12 fps) for characters, while camera moves stay smooth (on ones, 60 fps). Spider-Verse made the contrast a signature. Drive your character/object animation from steppedTime, your camera from raw time — the difference is the effect.
import { steppedTime, steppedJitter } from "@velkina/inkwell";
useFrame(({ clock }) => {
const t = clock.elapsedTime;
character.rotation.y = steppedTime(t, 12) * 0.8; // holds on twos
camera.position.x = Math.sin(t) * 2; // smooth on ones
});
// optional "boiling" line wobble, deterministic per held frame:
const { t, wobble } = steppedJitter(clock.elapsedTime, 12, /*seed*/ 3);| fn | what it gives you |
|---|---|
steppedTime(t, fps) |
continuous time quantised to the frame grid (monotonic) |
steppedFrame(t, fps) |
the integer frame index (for sprite/pose swaps) |
steppedJitter(t, fps, seed) |
{ t, frame, wobble∈[-1,1] } — held time + deterministic wobble |
makeStepper(fps) |
a reusable (elapsed) => steppedTime getter |
The passes are designed to run in this order. Each assumes the previous one ran.
| # | Pass | What it does | Key options |
|---|---|---|---|
| 1 | Posterize | Quantises colour into bands so the base reads as drawn, not photographed. | levels, gamma, blend |
| 2 | InkOutline | Sobel edge detection over depth (silhouettes) + luma (interior edges), with a noise-warped sample grid so lines look hand-inked. | color, thickness, threshold, jitter |
| 3 | Halftone | Ben-Day dots. CMYK mode lays four channel grids at the classic print angles (15° / 75° / 0° / 45°). | scale, mode (cmyk|mono), blending |
| 4 | Misregister | Radial RGB offset from a focal point — comic mis-print standing in for depth-of-field, not a uniform glitch. | strength, focus, falloff |
- Halftone dot radius uses
sqrt(coverage). Perceived ink is dot area (πr²), so radius must scale with the square root of the target coverage — using coverage linearly makes shadows too light. - Dot edges use
fwidth()antialiasing, which is resolution-independent, so dots stay crisp and don't shimmer when the camera moves. - CMYK grids are rotated to 15° / 75° / 0° / 45°, the standard print angles that minimise Moiré.
- Misregistration is radial and grows toward the edges (it replaced depth-of-field in Spider-Verse), not a flat full-frame channel split — that flat version reads as a cheap RGB glitch.
- Ink lines jitter their sample origin by noise so they "boil" like hand inking instead of looking like a clean filter.
These choices come from a written engineering spec (sources below).
new PosterizeEffect({ levels=5, gamma=1, blend=1 })new InkOutlineEffect({ color="#0a0a0a", thickness=1, depthEdge=0.6, lumaEdge=0.35, threshold=0.18, jitter=0.6 })— requests the depth buffer.new HalftoneEffect({ scale=1.4, mode="cmyk", angle=0.26, color, background, blending=1 })new MisregisterEffect({ strength=1.6, focus=[0.5,0.5], falloff=1.4, redBlueOnly=1 })
<Comic>, <Posterize>, <InkOutline>, <Halftone>, <Misregister> — drop inside <EffectComposer>.
steppedTime, steppedFrame, steppedJitter, makeStepper.
- The four effects compile into one fragment shader through
EffectPass/<EffectComposer>— about one extra fullscreen draw, not four. - Halftone and misregister are visually fine at half resolution (dot cells are much larger than a pixel); run them on a half-res buffer for ~4× savings on those reads if you need headroom.
InkOutlineneeds the depth buffer; that's the only added render-target dependency.- Toggling an effect on/off at runtime forces a shader recompile (a one-frame stutter). Prefer animating uniforms (e.g.
blend,strength) over mounting/unmounting passes.
v0.1 — the four core passes + on-twos, tested (npm test, 14 passing). Honest current limits:
InkOutlineuses depth + luma edges (silhouettes + strong contrast). Interior crease lines on smooth surfaces want an additional normal-buffer Sobel — planned, documented here so you know what it does not yet catch.- Painterly Kuwahara (the softer Arcane brush look) is intentionally not in v0.1: a good anisotropic Kuwahara isn't reliably real-time at full res. A half-res / WebGPU-compute version is on the roadmap.
- Glossy/"bubbly" 3D type helpers (MatCap + fresnel + inflation) are coming in a
materialsentry.
PRs and issues welcome.
The techniques are documented from primary sources — the production breakdowns of Spider-Verse's look and shader writeups by Maxime Heckel, Codrops, and the open NPR community. Full annotated spec lives with the project. Key starting points:
- Spider-Verse VFX breakdown — fxguide
- "Shades of Halftone" & "On Crafting Painterly Shaders" — Maxime Heckel
- "Sketchy Pencil Effect with Three.js Post-Processing" — Codrops
pmndrs/postprocessing— theEffect/EffectPasssystem this builds on
MIT © Velkina (Ömer Can Nalbant, Baha Taşkın)