publicly available at inkfield.studio
InkField is a browser-based renderer for Gaussian splat scans. Load a .ply,
.spz, .splat, .ksplat, or .sog scene, orbit around it, and view it as a
field of text glyphs, hatch strokes, or flow-placed marks whose direction follows
the structure of the scan. A cinematographer-facing lens model, an animatable
camera rig, and an offline export pipeline turn those looks into stills, vector
art, and video.
As you may know, splats are not smooth meshes like STLs, but rather thousands (or more) little splotches averaged out into what looks like a mesh. Crucially, these splats are not spheres, as one may assume. They are ellipsoids, with it's eccentricity determined by a 3D covariance matrix split into independent scaling factors and rotation quaternions to match the geometric contours of the target scene.
This means we can match specific glyphs and flow not arbitrarily, like a default brightness-to-ASCII filter, but assign neighboring splats a specific glpyh that matches the eccentricity and orientation in 3D space. Flow mode bakes object-space streamlines over the scan before stamping glyphs along them. Marks can flow along surfaces, silhouettes, table edges, folds, and other geometry in the scene while staying attached as the camera orbits.
- Static Vite + TypeScript web app with no backend.
- Three.js camera, scene, and orbit controls.
- Spark-powered Gaussian splat loading and rendering.
- Bundled
public/sample.spzscene that loads on startup. - Local scan loading through a file picker.
- Four render modes:
Photo: normal Spark splat rendering.Glyph: structure-aware character rendering.Hatch: pen-style hatching driven by the same orientation field.Flow: object-space streamline placement with discrete glyph or hatch stamps that ride the surface, with relighting and depth of field.
- Two orientation sources, switchable with the
Stroke sourcecontrol:Per-frame(screen): per-frame direction from screen-space splat covariance.Locked to surface: direction from a precomputed 3D surface frame so strokes stay locked to surfaces while orbiting.
- A one-time surface/flow precompute that runs off the main thread in a Web Worker, with progress reported in the status readout and results cached per scan so reloads and mode toggles are instant.
- A thin-lens camera model: 24 / 35 / 50 / 85 mm focal-length presets, aperture stops, and a focus distance, with a focus-plane gizmo and an optional focus- peaking preview.
- A depth-of-field stage in Flow mode that modulates the marks by their circle of confusion instead of blurring pixels.
- An animatable camera rig with two paths —
Orbit(turntable) andDolly in— plus play / scrub / duration / speed controls. - An export pipeline: high-resolution PNG, vector SVG (instanced marks), and a deterministic frame sequence that encodes to WebM video or a PNG ZIP, with an export-side Orbit / Dolly motion toggle.
- An
Auto-tune lookbutton that picks reasonable grid, saturation, bin, orientation, and flow settings for the loaded scan. - A single roomy inspector of collapsible, self-summarizing sections, with the render mode promoted to a permanent left rail.
- Debug overlays for the derived per-splat normal and tangent fields.
- Temporal smoothing to reduce flicker while orbiting in per-frame mode.
Glyph and Hatch modes render the scene in a small grid, not at full screen resolution. Each grid cell becomes one glyph or hatch tile.
In the first pass, Spark projects the splats into an offscreen accumulation target. For each splat, InkField estimates the stretched oval it makes in screen space. The oval's long axis gives a local direction, and its opacity, size, and elongation decide how much influence it should have.
Line direction has no sign: a stroke at 10 degrees is the same as one at 190 degrees. To average those directions correctly, InkField stores each orientation as a double-angle vector:
(cos 2theta, sin 2theta) * weight
Those vectors are additively blended per grid cell. The renderer also accumulates weighted color and total coverage. A temporal pass blends the current orientation field with the previous frame so orbiting stays stable.
In the final pass, InkField reads each cell:
- low-structure or rounded cells become blob-like glyphs such as
.,o, or@; - elongated, confident cells become directional glyphs or hatch strokes;
- color comes from the average splat color in that cell;
- density comes from accumulated coverage.
The hatching mode uses the same orientation data as the glyph mode. It only swaps the atlas used for the final draw.
Flow mode is different from the grid modes above. It does not decide one mark
per screen cell. Instead, the worker uses the baked surface frame to trace evenly
spaced streamlines in object space:
- seed and trace sign-continuous surface streamlines through the tangent field;
- keep lines evenly spaced with a proximity grid, stopping when they approach existing accepted lines, low-confidence patches, loops, or off-surface areas;
- trace a second cross-hatch set along
normal x tangent; - sample the nearest splat color and normal at every streamline point;
- assign nested density levels so darker tones reveal finer lines.
At render time those streamlines are projected through the current camera and
splat transform, then converted into discrete atlas stamps. The active Mark style control chooses ASCII-like glyphs or hatch marks, and Mark spacing
controls the densest screen-space stamp interval. If a view would exceed the
stamp budget, the pass raises the effective gap for that frame rather than drawing
continuous strokes.
Flow mode is also relightable. The light azimuth/elevation controls produce a world-space light direction. Each frame the flow pass transforms that light into object space and computes a Lambert tone from the baked normals. Darkness reveals marks by fixed thresholds: coarse primary marks appear first, finer marks fill in as regions darken, and cross-hatch marks join in shadow. Because the thresholds are fixed, moving the light only adds or fades marks instead of replacing the whole pattern.
InkField carries a thin-lens camera model (thin-lens-camera.ts) with the
controls a cinematographer expects: a focal-length preset (24 / 35 / 50 / 85 mm),
an aperture in stops, and a focus distance. Focal length sets the camera's field
of view in every mode, the way real glass does. Aperture and focus distance drive
a depth-of-field stage that is live in Flow mode.
That stage never blurs pixels. Defocus is treated as a property of each mark: the pass gives every stamp a camera-space depth, turns it into a circle of confusion through the active lens, and resolves one shared per-mark rule:
- out-of-focus marks grow, soften, and jitter their orientation slightly;
- a stable, position-hashed thinning fades marks out as they defocus, so density drops where the lens is soft while staying exact at the focus plane;
- bright, strongly defocused survivors bloom into discrete round bokeh discs (one tile swap per mark, never a continuous blur).
Every driver is monotone in defocus and deterministic per mark, so a focus pull glides without flicker and an exported sequence is reproducible frame to frame. A focus-plane ring marks the focus distance in the viewport, and an optional focus-peaking preview tints in-focus marks magenta to confirm the plane — it is a preview assist only and is never written into an export.
The depth-of-field math lives in src/depth-of-field/ as pure, Three.js-free,
Node-testable modules (circle of confusion, defocus response, stable thinning, the
combined per-mark rule, bokeh glyphs, the animatable lens track, scene-level CoC
resolution, and cine presets), so the live pass and the offline export share one
implementation.
The camera rig (camera-rig.ts) drives deterministic motion for both live
preview and export. It samples a captured orbit basis — target, radius, elevation,
starting azimuth — and offers two paths: Orbit, a turntable that advances the
azimuth, and Dolly in, which holds the angle and eases the radius inward.
Scrubbing to a given progress always lands on the same pose, which is what makes
exports repeatable. Play, scrub, duration, and speed are all adjustable.
orbitexample.mp4
dollyexample.mp4
Export runs offline against an offscreen render target so output is independent of the on-screen canvas size:
- Still image — a high-resolution PNG at a resolution multiplier, or a vector SVG of the instanced marks (Glyph, Hatch, and Flow modes).
- Movie — choose the export
Motion(Orbit or Dolly, independent of the live preview path), set a frame count and frame rate, render a deterministic frame sequence, then save it as a WebM video (encoded with WebCodecs) or, as a fallback, a ZIP of PNG frames.
Each frame is captured only after its render fully completes and is hashed, so the sequence is stable and the exported colors match the viewport byte-for-byte.
The directions above come from one of two sources, chosen by the Stroke source
control.
In per-frame (Screen) mode, the direction of each splat is read from its
projected covariance every frame. This reads well in stills, but strokes tend to
swim across surfaces as the camera orbits, because the direction is a screen-
space measurement rather than a property of the geometry.
In Locked to surface mode, InkField computes a coherent 3D surface frame once
when a scan loads, in a background Web Worker:
- build a kd-tree over the splat centers and a k-nearest-neighbor graph;
- derive a per-splat normal from the smallest eigenvector of each splat's covariance, with a confidence score from its anisotropy;
- make the normal signs consistent across neighbors with minimum-spanning-tree propagation;
- smooth a tangent line field over the surface using the same nematic double-angle representation, so it has no 180-degree seams.
The resulting tangent, normal, and confidence are baked into per-splat textures.
Each frame the baked tangent is projected into view space and fed into the same
accumulation and glyph pass, blending back toward the screen-space direction
where confidence is low. Because the direction now lives in 3D, it rotates with
the camera and strokes stay attached to surfaces, so temporal smoothing is turned
off in this mode. The same surface field also seeds the flow-glyph bake. The
normal and tangent fields can be inspected directly through the Normals and
Tangents overlays.
InkField is useful anywhere a 3D scan should be read as drawing, notation, or texture instead of photorealistic imagery:
- stylized presentation of Gaussian splat captures;
- visual experiments with structure-aware ASCII art and flow-placed glyphs;
- sketch, hatch, and print-like render studies;
- cinematic motion studies — turntables, dolly pushes, and rack-focus looks where defocus is drawn as marks rather than blur;
- portfolio stills, vector exports, or short videos built from real scanned objects;
- research sketches around orientation fields, screen-space splat analysis, and non-photorealistic rendering.
It is especially suited to scans with clear edges, surface flow, folds, furniture, architecture, plants, fabric, or objects whose shape should remain legible after being reduced to marks.
Install dependencies:
npm installRun the development server:
npm run devBuild the static site:
npm run buildRun tests:
npm testThe repo also carries offline "spike" scripts that render validation SVGs into
output/ for the flow-glyph and depth-of-field pipelines (for example
npm run dof:focus-pull or npm run flow:streamlines); npm run dof:test runs
just the depth-of-field math tests. See the scripts block in package.json for
the full list.
index.html Vite entry; mounts #app and loads src/main.ts
src/
main.ts tiny entry: imports styles and calls
bootstrapInkField()
app/
bootstrap.ts wires everything together: scene context, UI,
passes, render loop, export controller, and
the initial control values
app-shell.ts builds the DOM (mode rail + collapsible
inspector sections) into #app
scene-context.ts Three.js renderer / scene / camera / orbit
controls, camera rig, thin-lens, focus-plane
render-loop.ts the per-frame loop and viewport resize
ui-bindings.ts connects every InkFieldUi event to its handler
export-controller.ts PNG / SVG / frame-sequence / video / PNG-ZIP
export, and the export motion toggle
control-panel.ts createUi / InkFieldUi: binds load buttons,
mode/source radios, sliders, lens, motion,
transform, export, and overlay controls,
plus the status readouts and section summaries
render-settings.ts shared types and defaults for render mode, grid
columns, direction bins, saturation,
orientation source, flow controls, light
direction, and splat transform
auto-tune.ts derives reasonable look settings from the scan
camera-rig.ts deterministic orbit/dolly motion for preview
and export
thin-lens-camera.ts cinematographer-facing lens model: focal length,
aperture, focus distance, FoV, presets
splat-formats.ts supported splat extensions and accept strings
splat-stage.ts Spark scene setup, sample/local loading, splat
transform, debug overlays; launches the
surface/flow worker and caches its results
orientation-math.ts nematic (double-angle) orientation helpers:
angle<->vector, averaging, and bin quantization
splat-attribute-shaders.ts per-splat vec3 attribute texture and the Spark
vertex-shader patch that reads it, plus
covariance/eigensolve normal helpers
depth-of-field/ pure, Three.js-free, Node-testable DoF stack:
circle-of-confusion.ts thin-lens CoC per mark
defocus-response.ts CoC -> how a present mark is drawn
stable-thinning.ts fixed per-mark rank + soft drop
defocused-mark.ts the single shared per-mark rule
bokeh-glyph.ts bright + defocused survivors bloom to discs
lens-track.ts animatable focus/aperture along the path
scene-defocus.ts resolves a scene CoC for the live pass
cine-presets.ts named lens/look presets
focus-peaking.ts in-focus preview tint math
focus-plane-indicator.ts world-space focus-plane gizmo (viewport only)
export/
offline-renderer.ts deterministic offscreen capture primitive
frame-sequence-renderer.ts walks a normalized camera path, one frame/pose
video-sequence-export.ts WebCodecs WebM encode + PNG-ZIP packaging
png-export.ts single-frame PNG encode + download helpers
orientation-svg-export.ts glyph/hatch SVG document emit
flow-svg-export.ts flow-mode SVG document emit
svg-glyph-instances.ts shared instanced-symbol SVG builder
surface-field/
precompute.ts kd-tree + k-NN graph, covariance normals, MST
sign orientation, nematic tangent smoothing
worker.ts Web Worker entry for surface + flow precompute
worker-client.ts main-thread client: spawn, progress, results
textures.ts packs normal/tangent/confidence into textures
flow-glyphs/
field/ sampler and singularity / low-coherence masking
streamlines/ tracing, seeds, even spacing, proximity grid,
and nested density assignment
bake/ worker-side object-space line bake + cache
render/ projection, discrete stamp placement, stamp
reveal thresholds, and tonal reveal math
math/vec.ts shared Vec2/Vec3 and scalar easing helpers
flow-runtime-status.ts flow bake/render status formatting
passes/
stylized-glyph-pass.ts OrientationGlyphPass: accumulation + temporal +
glyph/hatch draw (screen/surface)
orientation-glyph-pass.ts orientation accumulation pass entry
orientation-glyph-shaders.ts accumulation/draw shader sources
brightness-glyph-pass.ts brightness-ramp glyph scaffolding
flow-glyph-pass.ts Flow mode pass: projects baked streamlines,
stamps glyph/hatch quads, applies relight and
the live depth-of-field response
atlas/
glyph-atlas.ts builds glyph and hatch atlas textures + layout
glyph-definitions.ts brightness ramp, blob, and directional glyph sets
style.css app styling
public/
sample.spz bundled demo scan
scripts/ offline spike renderers (flow-* and dof-*) that
write validation SVGs into output/
tests/ Node test runner specs mirroring the source:
brightness-glyph-pass / orientation-glyph-pass / hatch-mode-and-ui
splat-attribute-shaders / surface-field-precompute / auto-tune
streamline-tracing / even-spacing / field-reliability / flow-glyph-stamping
tonal-reveal / flow-bake-cache
circle-of-confusion / stable-thinning / thin-lens-camera / defocus-response
defocused-mark / bokeh-glyph / lens-track / focus-peaking / cine-presets
scene-defocus
offline + sequence: sequence-export / frame-sequence-renderer /
video-sequence-export / png-export / flow-svg-export
