OVU – a 3D drawing studio in the browser, built with three.js + React Three Fiber

Hi everyone! :waving_hand:

I’d like to share OVU, a 3D drawing app I’ve been building with three.js and React Three Fiber. It started as a sumi-e style ink-ribbon experiment and grew into a full studio were you paint with strokes that are real 3D geometry.

Try it

What it does

  • Draw in a 3D space: a stroke is cast onto an invisible plane facing the camera by using a 3d guides. It snaps to the world axis most facing you, so straight lines stay straight from the side.
  • Stroke snapping: if the pointer ray passes close to an existing stroke, the new one locks to its depth so lines join exactly. It uses analytic ray-to-polyline distance with per-stroke bounding spheres.
  • 50+ brushes: ink, sumi, oil, watercolor, charcoal, pencil, calligraphy, neon, smoke, ember and more. You can also use your own PNG as a brush tip.
  • Draw directly on imported 3D models (.glb / .gltf / .obj); strokes follow the surface.
  • Lighting and post-processing: sun and accent lights with shadows, procedural sky and skyboxes, fog, and a custom multi-level bloom with vignette and film grain.
  • Time-lapse playback, export to .glb, GIF and video, and a native binary .ovu file format.
  • Real-time collaboration, accounts, and a public portfolio.

Technical notes

  • Stack: three.js r169, React Three Fiber, drei, Zustand, TypeScript, Vite. The desktop app is Electron.
  • Strokes are ribbon meshes rendered with a custom GLSL ink shader: fresnel rim, toon-banded lighting, a brush tip texture sampled along and across the line, and alpha-to-coverage edges so overlapping opaque strokes don’t show through each other.
  • Perfect-Freehand-style smoothing is implemented from scratch (streamline, thinning, pressure, tapered caps), plus a lazy-brush spring and Catmull-Rom finalize. Mouse and touch get simulated pressure from speed.
  • Ribbons are sampled by point index instead of by arc length. I originally resampled every frame and the live stroke visibly shook once it got long.
  • The sun shadow box is fitted to the scene bounds, which sharpens shadows without more shadow-map resolution.
  • Strokes are memoized per component with cached geometry. Merging them into a few big meshes would break per-stroke picking, the eraser and fading, so that is still open.
  • The .ovu format is a chunked binary container with CRC32 per chunk, deflate-compressed, with strokes in packed Float32/Float64 arrays. Strokes are checksummed in blocks of 250, so a damaged file loses a block instead of the whole drawing.

Open questions for you

  • Has anyone merged thousands of stroke meshes while keeping per-object picking? I’m considering batching with an ID buffer.
  • Any tips for keeping bloom and multi-pass post-processing cheap on integrated GPUs?

Feedback on performance and brush feel is very welcome. Thanks to the three.js community! :folded_hands: