@sorrell/ink-three
v1.0.0
Published
Render ThreeJS scenes in the terminal with ink.
Readme
@sorrell/ink-three
Render Three.js scenes in the terminal with Ink.
@sorrell/ink-three uses Three.js for scene graphs, geometry, transforms, and
camera projection, then rasterizes the result into terminal cells. It can render
wireframes, flat-shaded faces, material and object colors, and several Unicode
glyph formats without WebGL, Canvas, DOM APIs, or native graphics dependencies.
The package includes three ways to render:
InkThreeView.InkThreeView, a React component for Ink applications.Terminal.Renderer, a synchronous API for producing text or structured frames.SceneRendererandRendererRuntime, Effect services for validation, dependency injection, frame sinks, terminal capabilities, and interruptible render loops.
Installation
Using NodeJS 24.18.0 or later, run
npm install @sorrell/ink-three effect ink react threeink, react, and three are peer dependencies; these are all required.
Ink example
The following is a complete Ink application. It creates a small Three.js scene,
updates its transforms, and lets InkThreeView redraw it as terminal cells.
import * as Ink from "ink";
import * as React from "react";
import * as THREE from "three";
import {
InkThreeView,
Terminal
} from "@sorrell/ink-three";
interface ExampleScene
{
readonly Camera: THREE.PerspectiveCamera;
readonly Group: THREE.Group;
readonly Scene: THREE.Scene;
}
function App(): React.ReactElement
{
const { exit } = Ink.useApp();
const { columns, rows } = Ink.useWindowSize();
const Width = Math.max(1, columns);
const Height = Math.max(1, rows - 1);
const Example = React.useMemo<ExampleScene>(() =>
{
const Scene = new THREE.Scene();
const Camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100);
const Group = new THREE.Group();
const Cube = new THREE.Mesh(
new THREE.BoxGeometry(1.8, 1.8, 1.8),
new THREE.MeshBasicMaterial({ color: "#55aaff" })
);
const Orbit = new THREE.Line(
new THREE.BufferGeometry().setFromPoints(
new THREE.EllipseCurve(0, 0, 2.2, 1.1)
.getPoints(64)
.map(({ x, y }) => new THREE.Vector3(x, y, 0))
),
new THREE.LineBasicMaterial({ color: "#ffd166" })
);
Cube.userData.Color = "#55aaff";
Orbit.userData.Color = "#ffd166";
Group.add(Cube, Orbit);
Scene.add(Group);
Camera.position.z = 5;
return { Camera, Group, Scene };
}, [ ]);
React.useEffect(() =>
{
Example.Camera.aspect = Width / Height;
Example.Camera.updateProjectionMatrix();
}, [ Example, Height, Width ]);
React.useEffect(() =>
{
const Interval = setInterval(() =>
{
Example.Group.rotation.x += 0.015;
Example.Group.rotation.y += 0.03;
}, 1000 / 30);
return () => clearInterval(Interval);
}, [ Example ]);
Ink.useInput((Input) =>
{
if (Input === "q")
{
exit();
}
});
return (
<Ink.Box flexDirection="column">
<InkThreeView.InkThreeView
Camera={ Example.Camera }
Color="#66ccff"
Fps={ 30 }
Height={ Height }
Lighting={{
AmbientIntensity: 0.2,
DiffuseIntensity: 0.8,
Direction: new THREE.Vector3(0.35, 0.6, 1)
}}
RenderColor
RenderFaces
RenderMode={ Terminal.RenderMode.RenderMode.Braille() }
RenderWireframe
Scene={ Example.Scene }
Width={ Width }
/>
<Ink.Text dimColor>q: quit</Ink.Text>
</Ink.Box>
);
}
Ink.render(<App />);The component owns its redraw timer. Mutating a scene object does not require a React state update: the next redraw reads the current Three.js transforms. The application is still responsible for updating the camera aspect and projection matrix when its viewport changes.
Component options
InkThreeView.InkThreeView accepts the following props. Scene and Camera are
required; the rest are optional.
| Prop | Default | Purpose |
| --- | --- | --- |
| Scene | — | The THREE.Scene to traverse. |
| Camera | — | The THREE.Camera used to project geometry. |
| Width | 80 | Output width in terminal cells. |
| Height | 40 | Output height in terminal cells. |
| Fps | 30 | How often the component reads the scene and redraws. |
| RenderMode | Fixed() | Glyph encoder used for each terminal cell. |
| Character | "█" | Glyph used by fixed mode. Only the first character is used. |
| CharacterRamp | " ░▒▓█" | Coverage ramp used by shade mode. |
| RenderFaces | false | Rasterizes mesh triangle interiors. |
| RenderWireframe | !RenderFaces | Draws line objects and mesh feature edges. |
| Lighting | enabled flat light | Controls face brightness. |
| Color | "#66CCFF" | Fallback color when an object supplies no color. |
| ColorOptions | see Color | Controls object and material color selection. |
| RenderColor | false | Emits colored Ink spans instead of one plain text string. |
| AutoResize | false | Uses stdout.columns and stdout.rows when available. |
AutoResize changes the renderer dimensions, but it does not change the Three.js
camera. For a perspective camera, update Camera.aspect and call
Camera.updateProjectionMatrix() in the application as shown above.
Render modes
Render modes are tagged values created through
Terminal.RenderMode.RenderMode.
| Mode | Subcells per character | Best suited to |
| --- | --- | --- |
| Fixed() | 1 × 1 | Bold, low-resolution output or custom single-character drawing. |
| Shade() | 2 × 4 | Filled surfaces and coverage-based brightness ramps. |
| Braille() | 2 × 4 | Fine wireframes and dense terminal scenes. |
| Quadrant() | 2 × 2 | Unicode block output with balanced horizontal and vertical detail. |
| HalfBlock() | 1 × 2 | Simple Unicode output with extra vertical resolution. |
For example:
<InkThreeView.InkThreeView
Camera={ Camera }
CharacterRamp=" .:-=+*#%@"
Height={ 32 }
RenderFaces
RenderMode={ Terminal.RenderMode.RenderMode.Shade() }
Scene={ Scene }
Width={ 100 }
/>Character applies to Fixed(). CharacterRamp applies to Shade(). Braille,
quadrant, and half-block modes choose their glyphs from occupied subcells.
Unicode modes assume the output terminal can display their characters. The Ink
component does not perform capability detection; the Effect runtime can enforce
Unicode and truecolor requirements through Terminal.InkThreeTerminal.
Faces, wireframes, and lighting
The renderer can draw mesh faces, wireframes, or both:
<InkThreeView.InkThreeView
Camera={ Camera }
Height={ 30 }
Lighting={{
AmbientIntensity: 0.25,
DiffuseIntensity: 0.75,
Direction: new THREE.Vector3(-0.25, 0.5, 1)
}}
RenderFaces
RenderMode={ Terminal.RenderMode.RenderMode.Braille() }
RenderWireframe
Scene={ Scene }
Width={ 90 }
/>Face rendering projects triangles into a supersampled coverage buffer. Each sample stores coverage, depth, brightness, and color, so nearer triangles occlude farther triangles. Lighting is a renderer-local flat light calculated from the triangle's world-space normal.
Lighting options are:
Enabled, which defaults totrue. Disabled lighting gives faces full brightness.Direction, which defaults to(0.35, 0.6, 1)and is normalized internally.AmbientIntensity, which defaults to0.25and is clamped from0to1.DiffuseIntensity, which defaults to0.75and is clamped from0to1.
When RenderFaces is false, wireframes are on by default. When faces are on,
wireframes are off unless RenderWireframe is explicitly enabled. A wireframe
overlay receives a small depth bias so visible edges sit over their faces.
Color
Terminal color is separate from glyph selection. Set RenderColor on the Ink
component to render colored spans:
const Mesh = new THREE.Mesh(
new THREE.TorusKnotGeometry(1, 0.3, 80, 12),
new THREE.MeshBasicMaterial({ color: "#44dd88" })
);
Mesh.userData.Color = "#ff8844";
<InkThreeView.InkThreeView
Camera={ Camera }
Color="#66ccff"
ColorOptions={{
Enabled: true,
UseMaterialColor: true
}}
Height={ 32 }
RenderColor
RenderFaces
Scene={ Scene }
Width={ 100 }
/>The renderer resolves an object's color in this order:
- A six-digit hex color in
Object.userData.Color. - The first material's Three.js
color, when the object is a mesh andUseMaterialColoris enabled. ColorOptions.DefaultColor, then the top-levelColoroption, then the package default#66CCFF.
ColorOptions.Enabled and ColorOptions.UseMaterialColor both default to
true. Setting Enabled to false uses the configured fallback for every
object. Lighting scales the selected base color by the cell brightness.
The pure renderer always preserves color metadata in a structured frame.
SceneToString intentionally returns only glyphs.
Pure rendering
Use Terminal.Renderer when the viewport is already known and rendering should
be a deterministic synchronous operation.
import * as THREE from "three";
import { Terminal } from "@sorrell/ink-three";
const Scene = new THREE.Scene();
const Camera = new THREE.PerspectiveCamera(60, 80 / 24, 0.1, 100);
const Cube = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshBasicMaterial({ color: "#5fd7ff" })
);
Camera.position.z = 4;
Scene.add(Cube);
const Frame = Terminal.Renderer.SceneToFrame(Scene, Camera, {
Height: 24,
RenderFaces: true,
RenderMode: Terminal.RenderMode.RenderMode.Braille(),
RenderWireframe: true,
Width: 80
});
const Text = Terminal.Frame.ToString(Frame);A frame contains an array of lines. Each cell has a Character, a normalized
Brightness, and an optional RGB Color. This is the stable data model to use
when testing renderer output or writing a custom terminal adapter.
To produce plain text directly:
const Text = Terminal.Renderer.SceneToString(Scene, Camera, {
CharacterRamp: " .oO@",
Height: 24,
RenderFaces: true,
RenderMode: Terminal.RenderMode.RenderMode.Shade(),
Width: 80
});The pure renderer also accepts AspectRatioCorrection, which defaults to 2 to
compensate for terminal cells usually being taller than they are wide. A larger
value narrows the projected scene horizontally; 1 disables the correction.
Effect rendering
The Effect API adds validated inputs, typed failures, replaceable services, and managed animation scheduling around the pure renderer.
Render one frame
import * as Effect from "effect/Effect";
import {
SceneRenderer,
Terminal
} from "@sorrell/ink-three";
const Program = SceneRenderer.RenderSceneToFrameEffect({
Camera,
Options: {
RenderFaces: true,
RenderMode: Terminal.RenderMode.RenderMode.Braille()
},
Scene,
Viewport: {
Height: 32,
Width: 100
}
}).pipe(
Effect.provide(SceneRenderer.SceneRendererLive)
);
const Frame = await Effect.runPromise(Program);RenderSceneToStringEffect accepts the same input and returns plain text.
SceneRenderer.SceneRenderer is a Context.Service, so tests can provide a
layer that returns fixture frames without traversing a Three.js scene.
Run an interruptible render loop
RendererRuntime.RunSceneRenderLoop reads the viewport from the terminal
service, renders through the scene renderer service, and writes each frame to a
frame sink. MakeDefaultRendererLayer provides the standard implementations.
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import {
RendererRuntime,
Terminal
} from "@sorrell/ink-three";
const Live = RendererRuntime.MakeDefaultRendererLayer(
() => ({ Height: 30, Width: 90 }),
(Frame) =>
{
const Text = Terminal.Frame.ToString(Frame);
process.stdout.write(`\u001B[H${ Text }`);
},
{
TrueColor: true,
Unicode: true
}
);
const RenderLoop = RendererRuntime.RunSceneRenderLoop({
Animation: { Fps: 30 },
Camera,
RendererOptions: {
Color: "#66ccff",
RenderFaces: true,
RenderMode: Terminal.RenderMode.RenderMode.Braille(),
RenderWireframe: true
},
Scene
});
const Main = Effect.gen(function*()
{
const RenderFiber = yield* RenderLoop.pipe(Effect.forkChild);
yield* Effect.sleep("5 seconds");
yield* Fiber.interrupt(RenderFiber);
}).pipe(
Effect.provide(Live)
);
await Effect.runPromise(Main);The render loop reads current scene transforms on each tick. Scene mutation is
owned by the application; the loop handles render timing, interruption, and the
service boundaries. In an Ink application, prefer InkThreeView unless the
services or a custom frame sink are useful to the larger program.
Services and layers
The runtime is composed from four services:
| Module | Service | Standard provider |
| --- | --- | --- |
| SceneRenderer | SceneRenderer.SceneRenderer | SceneRenderer.SceneRendererLive |
| Terminal | Terminal.InkThreeTerminal | Terminal.MakeInkThreeTerminalLayer |
| FrameSink | FrameSink.FrameSink | FrameSink.MakeFrameSinkLayer or MakeStringFrameSinkLayer |
| AnimationDriver | AnimationDriver.AnimationDriver | AnimationDriver.AnimationDriverLive |
Provide these individually when a test or host application needs different behavior. For example, a test can supply a fixed viewport and collect frames in memory:
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import {
FrameSink,
RendererRuntime,
SceneRenderer,
Terminal
} from "@sorrell/ink-three";
const Frames: Array<Terminal.Frame.Frame> = [ ];
const TestLayer = Layer.mergeAll(
SceneRenderer.SceneRendererLive,
FrameSink.MakeFrameSinkLayer((Frame) => Frames.push(Frame)),
Terminal.MakeInkThreeTerminalLayer(
() => ({ Height: 12, Width: 40 }),
{ TrueColor: true, Unicode: true }
)
);
const TestProgram = Effect.gen(function*()
{
const Frame = yield* RendererRuntime.RenderCurrentFrame(Scene, Camera);
const Sink = yield* FrameSink.FrameSink;
yield* Sink.WriteFrame(Frame);
}).pipe(Effect.provide(TestLayer));
await Effect.runPromise(TestProgram);Validation and typed errors
Viewport.ValidateViewport parses an unknown value and validates positive
integer dimensions:
import * as Effect from "effect/Effect";
import {
InkThreeError,
Viewport
} from "@sorrell/ink-three";
const Program = Viewport.ValidateViewport({
Height: 24,
Width: 0
}).pipe(
Effect.catchTag("InvalidViewport", (Error: InkThreeError.InvalidViewport) =>
Effect.succeed(`Invalid viewport: ${ Error.Message }`)
)
);Expected runtime failures are exported from InkThreeError:
| Error | Meaning |
| --- | --- |
| InvalidRenderOptions | Options could not be parsed or failed a domain check. |
| InvalidViewport | Width or height is not a positive integer. |
| UnsupportedTerminal | A requested Unicode or truecolor capability is unavailable. |
| SceneRenderFailed | Scene traversal or rasterization failed. |
| FrameCompositionFailed | A frame sink rejected a frame. |
| AnimationLoopFailed | A render loop tick failed unexpectedly. |
| InkRenderFailed | An Ink render lifecycle operation failed. |
These are tagged Effect errors rather than thrown control-flow exceptions.
Terminal.MakeInkThreeTerminalLayer accepts explicit Unicode and TrueColor
capabilities. The runtime requires Unicode for braille, quadrant, and half-block
modes, and requires truecolor when a top-level render color is requested.
Supported Three.js input
The scene traversal respects Three.js world transforms, nested groups, and object visibility. The renderer reads:
THREE.LineandTHREE.LineSegmentsbacked byBufferGeometryposition attributes.THREE.Meshbacked by indexed or non-indexed triangleBufferGeometry.BufferAttributeandInterleavedBufferAttributeposition data with at least three components.- Any Three.js camera compatible with
Vector3.project; the examples usePerspectiveCamera.
For wireframe meshes, shared coplanar triangle edges are removed so a box or other triangulated surface displays its feature edges rather than every internal diagonal. Unsupported object types and geometry without usable positions are skipped.
Package modules
The root entry point uses namespaces to keep the public API grouped by purpose:
| Namespace | Contents |
| --- | --- |
| InkThreeView | Ink component and its props. |
| Terminal | Pure renderer, frame and color helpers, render modes, character encoders, and terminal service. |
| SceneRenderer | Effect scene-renderer service and one-frame workflows. |
| RendererRuntime | Current-frame rendering, render loops, and the default layer bundle. |
| AnimationDriver | Animation options, validation, service, and live layer. |
| FrameSink | Structured-frame and string sink layers. |
| Viewport | Viewport model, schema, and validation. |
| InkThreeError | Tagged renderer errors. |
| Geometry | Edge and triangle extraction helpers. |
| CharacterBuffer | Mutable character-grid helpers. |
| CoverageBuffer | Coverage, depth, brightness, color, and line-rasterization helpers. |
Most applications only need InkThreeView and Terminal. The buffer and
geometry namespaces are useful for custom renderers, encoders, and focused
renderer tests.
Lower-level helpers
The lower-level modules are public for applications that need to build or adapt frames without using the complete scene renderer:
Terminal.Frame.ToStringjoins a structured frame into newline-delimited glyphs.Terminal.ColorincludesParseColor,ColorFromThreeColor,ScaleColor,ColorToHex,AverageColors, andDefaultColor.Terminal.CharacterEncoder.GetCharacterEncoderreturns the encoder for a tagged render mode. Encoders expose their subcell dimensions and can produce either a frame or text from a coverage buffer.Geometry.ExtractEdgesandGeometry.ExtractTrianglesreturn local-space primitives from supported Three.js objects.CharacterBufferprovidesMakeCharacterBuffer,ClearCharacterBuffer,SetCharacter,DrawCharacterLine, andCharacterBufferToStringfor a mutable fixed-size glyph grid.CoverageBufferprovidesMakeCoverageBuffer,ClearCoverageBuffer,AddCoverage,GetCoverage,GetIntensity,GetColor, andDrawCoverageLinefor raster samples with depth and color metadata.RendererRuntime.MakeSceneRenderInputpackages a scene, camera, viewport, and fixed renderer options into aSceneRendererinput.
Character-buffer and coverage-buffer operations support both direct and pipe-friendly call forms. Coordinates outside a buffer are ignored by write operations and return empty values from reads.
Renderer boundaries
The package is a CPU terminal renderer, not a Three.js WebGL renderer. It uses Three.js scene data and projection math but does not execute shaders or use Three.js renderer, light, or texture pipelines.
Current rendering behavior is deliberately narrower than browser Three.js:
- Face color comes from
userData.Color, a material's basiccolorproperty, or a configured fallback. Textures, transparency, blending, and material shaders are not evaluated. - Lighting is one renderer-local directional flat light. Three.js light objects do not affect the frame.
- Mesh skinning, morph targets, and per-instance transforms are not applied.
- Clipping is conservative rather than a full homogeneous clip-plane pass, so geometry crossing extreme camera bounds may be omitted.
- Larger viewports and supersampled modes perform more CPU raster work. Fixed mode uses one sample per cell; braille and shade use eight.
These boundaries keep the output deterministic and make frames usable in Ink, plain-text output, snapshots, and custom terminal sinks.
Demo and development
Run the repository demo:
npm run demoUse Left and Right to cycle render modes. Press q or Ctrl+C to exit.
Repository checks are:
npm run check
npm run lint
npm test
npm run build