Hi everyone! ![]()
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
Run it in the browser: https://ovu-bice.vercel.app/
Desktop download (Windows/macOS): https://eld122.itch.io
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! ![]()