https://github.com/mlekhi/termcade
Software 3D renderer for the terminal
https://github.com/mlekhi/termcade
3d ascii rasterizer renderer terminal truecolor tui typescript
Last synced: 23 days ago
JSON representation
Software 3D renderer for the terminal
- Host: GitHub
- URL: https://github.com/mlekhi/termcade
- Owner: mlekhi
- License: mit
- Created: 2026-06-26T22:36:38.000Z (about 1 month ago)
- Default Branch: main
- Last Pushed: 2026-06-29T07:13:52.000Z (about 1 month ago)
- Last Synced: 2026-06-29T09:12:05.657Z (about 1 month ago)
- Topics: 3d, ascii, rasterizer, renderer, terminal, truecolor, tui, typescript
- Language: TypeScript
- Homepage: https://www.npmjs.com/package/ascii-3d
- Size: 238 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
- Notice: NOTICE.md
Awesome Lists containing this project
README
# termcade
A spinning coin, rendered entirely in the terminal. See examples/coin.
termcade renders real 3D in your terminal. It rasterizes triangles in plain
TypeScript and paints them with truecolor half-blocks or shape-matched glyphs.
There's no GPU, no WebGL, and no native dependencies, just math and characters.
The renderer is stateless. It doesn't run a loop or own the screen; you call it
with a scene and it hands back a buffer of pixels (or a string of characters)
that you can do whatever you like with.
```
┌───────────┐ ┌──────────────┐ ┌─────────────────────┐
│ rasterize │ → │ RenderTarget │ → │ toHalfBlock / glyph │ → terminal
└───────────┘ └──────────────┘ └─────────────────────┘
```
## Install
```bash
npm install termcade
```
It's ESM only and runs on Node 18+ or Bun, with zero runtime dependencies.
## Getting started
Here's a spinning, lit cube. It's really only three steps: make a buffer,
rasterize a mesh into it, and turn the pixels into characters.
```ts
import {
RenderTarget, rasterize, downsample, toHalfBlock,
cube, lambertMaterial, cameraMatrices,
mat4Multiply, mat4RotX, mat4RotY, normalize3, type Camera,
} from 'termcade';
const SS = 2;
const cols = process.stdout.columns ?? 80;
const rows = process.stdout.rows ?? 24;
// A buffer rendered at 2 pixels per terminal cell, which is the half-block trick.
const target = new RenderTarget(cols * SS, (rows - 1) * 2 * SS);
const mesh = cube(1);
const camera: Camera = {
eye: { x: 0, y: 0, z: 4.5 }, target: { x: 0, y: 0, z: 0 }, up: { x: 0, y: 1, z: 0 },
fovy: Math.PI / 3, near: 0.1, far: 100,
};
const light = normalize3({ x: -0.4, y: 0.7, z: 0.6 });
let t = 0;
setInterval(() => {
t += 1 / 30;
target.clear(0, 0, 0);
const { viewProjection } = cameraMatrices(camera, target.width / target.height);
const model = mat4Multiply(mat4RotY(t * 0.6), mat4RotX(t * 0.35));
const mvp = mat4Multiply(viewProjection, model);
rasterize(target, mesh, lambertMaterial, { mvp, model, lightDir: light, ambient: 0.15 });
process.stdout.write('\x1b[H' + toHalfBlock(downsample(target, SS)));
}, 1000 / 30);
```
The animation is nothing more than nudging `t` each tick and rebuilding the
model matrix from it. There's no scene graph and no framework to learn. If you
want it to spin faster, change a number; if you want it to stop, stop calling
`rasterize`.
## What's in the box
| Export | What it does |
| --- | --- |
| `RenderTarget` | RGB color buffer plus a depth buffer (render at 2x height for half-blocks) |
| `rasterize` | perspective-correct software triangle rasterizer, depth-tested |
| `Material` (`{ vertex, fragment }`) | the style hook, a shader pair. Bring your own, or use a built-in |
| `lambertMaterial` / `glassMaterial` / `wispMaterial` / `pieceMaterial` | ready-made looks |
| `cube` / `quad` / `tetrahedron` / `parseObj` | meshes, or load your own `.obj` |
| `cameraMatrices` plus `mat4*` / `vec*` helpers | camera and linear algebra |
| `toHalfBlock` | `▀` upper half-block: two stacked pixels per cell, with coalesced truecolor escapes |
| `toShapeGlyph` | picks the character whose ink *shape* best matches each cell |
| `toLuminance` | the classic brightness-ramp ASCII look |
| `downsample` | box-averages a supersampled buffer down for antialiased edges |
| `bloom` | an additive glow post-pass |
## Writing a material
A material is just two functions, a vertex shader and a fragment shader. The
vertex stage projects each point into clip space, and the fragment stage decides
the color of each pixel.
```ts
import { type Material, type Mat4, mat4MulVec4 } from 'termcade';
const flat: Material<{ mvp: Mat4 }> = {
vertex: (u, v) => ({
clip: mat4MulVec4(u.mvp, { ...v.position, w: 1 }),
world: v.position, normal: v.normal, uv: v.uv, color: v.color,
bary: { x: 0, y: 0, z: 0 },
}),
fragment: () => ({ r: 255, g: 80, b: 200, a: 1 }), // RGBA, or return null to discard
};
```
Because every visual style lives in the material, one renderer can drive all of
them. The built-ins are a good place to start reading if you want to write a
fancier one.
## Try it
```bash
npm run example # live spinning cube (press q to quit)
npm run snapshot # render one frame to .snapshots/cube.ppm (headless, no TTY)
```
## Loading textures (Node only)
PNG decoding is the one piece that needs a Node builtin (`node:zlib`), so it
lives behind its own subpath and the core renderer never imports it. That keeps
the main entry safe to bundle for the browser.
```ts
import { decodePng } from 'termcade/png'; // Node and Bun only
import { sampleTexture } from 'termcade'; // platform-neutral
const tex = decodePng(await readFile('logo.png'));
const rgba = sampleTexture(tex, 0.5, 0.5);
```
## Compatibility
Each of these was checked by packing the tarball, installing it into a fresh
project, and running it for real.
| Target | Status |
| --- | --- |
| Node 18+ (ESM) | ✅ |
| Bun (ESM and native TS) | ✅ |
| TypeScript via `tsx` | ✅ |
| `tsc` types (`Bundler` and `NodeNext` resolution) | ✅ |
| esbuild and other bundlers, Node target | ✅ |
| esbuild and other bundlers, **browser** (core, no `termcade/png`) | ✅ ~13 kB, no Node builtins |
| `require()` (CommonJS) | ❌ ESM only, use `import` or a dynamic `import()` |
| `termcade/png` in the browser | ❌ needs `node:zlib` |
In short, the main entry is pure compute and bundles for the browser, while
`termcade/png` is the only thing tied to Node or Bun. If you're on CommonJS,
reach it through a dynamic `import()`.
## Development
```bash
npm install
npm run type-check # tsc --noEmit
npm run build # emit dist/ (js and d.ts)
npm run example # run the cube example via tsx
```
The source is plain ESM TypeScript with explicit `.ts` import specifiers, and
the build rewrites those to `.js` on emit. Everything lives in `src/`, the
public API is the `src/index.ts` barrel, and `src/png.ts` is the only Node-bound
module.
Contributions are welcome. The main things to keep in mind are to leave the
renderer dependency-free and the public surface small, so please open an issue
before any large changes.
## License
MIT, see [LICENSE](LICENSE). Attributions are in [NOTICE.md](NOTICE.md).