Skip to content

VelkinaStudio/inkwell

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🖋️ Inkwell

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.


Why

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).


Quick start (React Three Fiber)

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>

Quick start (vanilla three.js)

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.


On twos — the part nobody ships

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

Pipeline

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

Correctness notes (the non-obvious bits)

  • 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).


API

Vanilla effects (extend postprocessing's Effect)

  • 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 })

React (@velkina/inkwell/react)

<Comic>, <Posterize>, <InkOutline>, <Halftone>, <Misregister> — drop inside <EffectComposer>.

Time (@velkina/inkwell)

steppedTime, steppedFrame, steppedJitter, makeStepper.


Performance

  • 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.
  • InkOutline needs 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.

Status & roadmap

v0.1 — the four core passes + on-twos, tested (npm test, 14 passing). Honest current limits:

  • InkOutline uses 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 materials entry.

PRs and issues welcome.

References

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 — the Effect / EffectPass system this builds on

License

MIT © Velkina (Ömer Can Nalbant, Baha Taşkın)

About

A hand-painted, comic (Spider-Verse) render toolkit for three.js + React Three Fiber

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors