← Learn · Build · 22 September 2026 · 18 min

ASCII Studio: Images, 3D Models and a Live Webcam Turned Into Editable ASCII

A browser studio that converts images, GLB/OBJ models and a live camera feed into ASCII you can edit in real time. Why every source has to collapse into one converter, why density is a cell size and not a character count, why monospace cells are not square, how the three exports are three different problems, and the full Replit Agent brief.

Overview

ASCII Studio is a single-page browser tool that takes a picture, a 3D model or whatever your webcam is pointed at and redraws it as text. Not as a one-shot filter — as a live render you keep editing. Drag the density slider and the grid gets finer under your hand. Swap the character ramp from the detailed set to 01 and the same torus knot turns into a binary readout. Point it at your face and it does the same thing at thirty frames a second.

It was built in an afternoon on Replit, which is the whole reason it is worth writing about. A tool like this looks like a weekend of fiddly canvas work, and most of it is. The part that decides whether you ship it or abandon it is a single architectural call you make in the first ten minutes, and if you get that call wrong no amount of agent time rescues it.

What it does: three sources (upload a PNG/JPEG/WebP, import a GLB/OBJ, or start the camera), nine built-in 3D shapes with auto-rotation and drag-to-rotate, five character-ramp presets plus a free-text ramp of your own, live density / brightness / contrast / invert, monochrome or source-colour output, foreground and background colour pickers, three one-click looks, and three exports — TXT, PNG and WebM.

Stack: React + TypeScript on Vite, Three.js for the model viewer, getUserMedia for the camera, MediaRecorder for the video export. No backend. Nothing leaves the tab.

The one decision that matters: every source collapses to pixels

There are three sources. The tempting shape is three renderers — one that walks an uploaded image, one that reads the WebGL canvas, one that samples video frames. It feels natural because the three sources really are different: one is static, one is a scene graph, one is a stream.

It is also the version you throw away. The moment there are three renderers, there are three places where the density slider is interpreted, three ramp lookups, three subtly different ideas of what "contrast" means, and your invert toggle works on images and does nothing to the webcam. Every feature you add after that costs three times as much, and the bug reports are all "it looks different on the camera".

So the brief names one function and forbids the alternative by name:

function toAscii( frame: ImageData, // already sampled down to the grid cols: number, rows: number, opts: RenderOpts, // ramp, brightness, contrast, invert, colour mode ): AsciiGrid

Everything upstream of that is a source adapter whose only job is to produce an ImageData at grid resolution. An uploaded image draws itself into an offscreen canvas. A model renders into an offscreen WebGL target and gets read back. The webcam draws its current video frame into the same offscreen canvas every animation frame. Three adapters, about fifteen lines each, and one converter that never learns where the pixels came from.

That is what makes "start the camera and every control you were already using keeps working" free instead of a feature.

Density is a cell size, not a character count

The slider says 12px, and that is deliberate. The obvious alternative is to let the user pick a column count — "render at 120 characters wide" — and it breaks the first time someone resizes the window, because the characters either stretch or the image stops filling the frame.

Cell size in pixels is the stable unit. The grid falls out of it:

const cols = Math.floor(viewport.width / cellW); const rows = Math.floor(viewport.height / cellH);

Resize the window and you get more characters at the same weight, which is what everyone expects and nobody can articulate. It also means the number in the corner of the viewport (12px · MONO) is a real, physical description of the output rather than an abstraction.

And the cell is not square

This is the bug everyone writes at least once. A monospace character cell is roughly twice as tall as it is wide — the exact ratio depends on the font, but it is in the region of 0.5 to 0.6. If you sample the source on a square grid and then print the result into non-square cells, everything comes out stretched vertically by a factor of two. Faces get long. Circles become eggs. It looks almost right, which is why it survives so long.

The fix is to measure the real cell rather than guess it — render one character, take its advance width, take the line height, and derive the sampling box from the ratio:

const aspect = cellH / cellW; // ~2.0 for most mono fonts const sampleH = sampleW * aspect; // sample a tall box per cell

Write it down in the brief with the number in it. An agent left to its own devices will sample square, because square is what every ASCII tutorial does.

Luminance to a character, in the right order

Per cell: average the pixels, convert to perceptual luminance, apply the adjustments, then index the ramp. The order of those four steps is the whole thing.

// 1. perceptual, not (r+g+b)/3 — greens read far brighter than blues let l = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; // 2. contrast pivots around mid-grey, or it doubles as a brightness control l = (l - 0.5) * contrast + 0.5; l = l * brightness; // 3. invert before quantising, never after if (invert) l = 1 - l; // 4. now, and only now, quantise const i = Math.round(clamp01(l) * (ramp.length - 1)); const ch = ramp[i];

Two things go wrong here and both are quiet. If contrast is applied as a plain multiply instead of pivoting around 0.5, it behaves as a second brightness slider and the two controls fight each other. And if invert is applied to the ramp index rather than the luminance, it stops being an inversion the moment the ramp has an uneven number of steps.

The ramp is the art direction

The default ramp is ordered dark to light by ink coverage, not by ASCII value:

. `-_':,;^=+/"|\<>()iv%xclrs{*}I?!|

A period covers almost nothing. An I covers a lot. The order is a judgement about how much of the cell each glyph fills in this font, which is why a ramp copied from a 1990s aalib table looks muddy in a modern grotesk mono. Ship a good default, then let people type their own — the free-text ramp field is three lines of code and it is the single most-played-with control in the app.

The five presets are five different tools, not five skins:

  • Standard Detailed — the long ramp. Photographs, faces, anything with gradients.
  • Simple — a short ramp. Reads better at low density and on a phone.
  • Binary — 01. Two steps, so it is effectively a dither, and it is the only preset that makes a 3D model look like a readout instead of a drawing.
  • Blocks — the Unicode shade blocks. Nearly continuous tone, barely reads as text at all.
  • Matrix — katakana and digits. Pure costume, and pure fun, and worth including for exactly that reason.

The 3D side: nine shapes before any file import

The app opens on a rotating torus knot, and that is a product decision rather than a default. A studio that opens on an empty canvas with an upload button asks the visitor to do work before they know whether the thing is any good. A studio that opens already running lets them grab the density slider in the first two seconds.

So there are nine parametric shapes built in — knot, sphere, cube, ring, orbital rings, double helix, diamond, wave surface, icosahedron — all generated in code, none of them an asset to load. They cost nothing to ship and they cover the interesting cases: something with a self- occluding silhouette (the knot), something with flat shaded faces (the cube), something with fine repeated structure that tests the density slider (the helix, the rings).

Imported GLB and OBJ files go through the same viewer. Auto-rotate, rotation speed, drag to rotate, scroll to zoom. The one thing worth specifying carefully is the readback: render the scene into an offscreen render target already sized to the grid, then readPixels that. Rendering at full resolution and sampling down afterwards works, and it burns a pile of GPU and memory for an image you are about to reduce to a few thousand characters.

The built-in shapes also have a software-rendered fallback, so a browser without WebGL still gets a rotating object. Imported models need WebGL and say so plainly instead of failing into a blank frame.

The webcam is an image source with a clock

Once the converter is source-agnostic, live camera input is almost nothing: get a stream, draw the video element into the offscreen canvas each frame, convert, paint. The work is not in the pixels. It is in the permission and shutdown behaviour, and this is where most hobby tools quietly lose people's trust.

  • Never request audio. getUserMedia({ video: true, audio: false }). Asking for a microphone you do not use lights the recording indicator and turns a fun toy into something the user closes the tab on.
  • Gate it behind a button. The Cam tab shows a Start Camera button and one line of copy. No getUserMedia call fires on page load, on tab switch, or on any path the user did not explicitly take.
  • Stop the tracks on every exit path. Not just the Stop button — also on switching source, on unmount, and on pagehide. Call track.stop() on every track in the stream and drop the reference. A camera light that stays on after the user has moved to the Image tab is the single worst bug this app can have, and it is invisible in testing because the developer knows what they just clicked.
  • Handle the denial properly. NotAllowedError is not the same as NotFoundError. And in an embedded preview iframe the permission prompt may not appear at all, which is why there is an Open in New Tab escape hatch next to the retry guidance — a Replit-specific problem worth naming in the brief, because the agent will otherwise test in the preview pane, see the failure, and start rewriting working camera code.
  • Mirror it. Unmirrored self-view feels wrong to everyone and nobody can say why.

Three exports, three different problems

These look like one feature on the toolbar. They are not.

TXT is the real output

Join the grid with newlines, wrap in a Blob, download. It is the cheapest of the three and the one people actually use, because it is the only export that survives being pasted into a README, a commit message or a terminal.

PNG has to draw, not screenshot

If the on-screen output is DOM text, there is no way to turn it into an image directly — no html2canvas shortcut that gets the metrics right. The export path has to render the same grid into a <canvas> with fillText, at an export scale independent of the viewport, using the same font, the same cell metrics and the same foreground and background colours. Which means the cell geometry has to live in one module that both the screen renderer and the export renderer call. Two implementations of the grid, and the PNG is subtly off from the preview forever.

WebM is a stream, and it is conditional

canvas.captureStream(fps) into a MediaRecorder, collect the chunks, Blob, download. The thing to specify is the degradation: check MediaRecorder.isTypeSupported against a list of candidate mime types, pick the first that passes, and if none do, disable the button with a tooltip explaining why rather than letting it throw. Codec support genuinely differs across browsers and this is the one export that cannot be made universal.

All three are greyed out until there is a frame to export. Obvious in hindsight, and an agent will happily ship three live buttons that download an empty file.

Quick Looks are presets you are allowed to break

Terminal, Ember, Inverted. Each is one click, and each one just writes values into the same controls you were already using — ramp, colours, invert, density. Nothing locks. You click Ember because it gets you close, then you drag the contrast because it was not quite there.

A preset that takes over the UI is a filter. A preset that seeds the UI is a starting point. The difference is a few lines of state management and it is most of what separates a toy from something people come back to.

On the light mode

This went dark studio first, then orange-on-black, and ended up light: warm white #FDFBF9 background, #CC4400 ink. Every instinct says ASCII belongs on black. A terminal is black. The reference images are all black.

The light version is better, and the reason is specific rather than aesthetic. On black, the ASCII art and the UI chrome are competing glowing objects and the art loses. On warm white, the sidebar recedes into paper and the orange character grid is the only saturated thing on the screen. It reads as a print of the art rather than a screenshot of an app.

The one requirement is that the ink stay dark and saturated. Orange at #CC4400 on #FDFBF9 holds at 12px monospace. Anything lighter and the fine ramp characters — the periods, the backticks, the commas — dissolve into the background and the low end of the tonal range disappears.

The prompt

Paste it whole. The section numbering matters more than it looks — the agent builds in that order, and the order is chosen so that the first thing that works is the thing everything else depends on.

Build a single-page browser studio called ASCII STUDIO that converts images, 3D models and a live webcam feed into editable ASCII art. React 18 + TypeScript on Vite. Everything runs client-side: no backend, no uploads, no network calls with user content in them. ## 0. The rule that governs the whole codebase There is exactly ONE ASCII conversion function: ```ts function toAscii(frame: ImageData, cols: number, rows: number, opts: RenderOpts): AsciiGrid ``` Every source is normalised into an `ImageData` at grid resolution and passed to it. Image, 3D model and webcam are SOURCE ADAPTERS whose only job is to fill an offscreen canvas. If you are about to write `asciiFromVideo` alongside `asciiFromImage`, stop — that is the bug. The converter must never know where its pixels came from. Likewise, cell geometry (font metrics, cell width, cell height, cols, rows) lives in ONE module that both the screen renderer and the PNG exporter import. Never compute the grid twice. ## 1. Layout Left sidebar, fixed ~300px, scrollable, with a pinned "Reset Defaults" button at the bottom. Right pane is the viewport: a label reading `VIEWPORT` with a live `{density}px · MONO` subtitle, the ASCII output centred in a rounded card, and a floating export bar at the bottom with TXT / PNG / WebM. Sidebar sections in this order: SOURCE, (source-specific controls), QUICK LOOKS, CHARACTERS, RENDER PARAMS, COLOR. Below 820px the sidebar becomes a collapsible drawer above the viewport. The viewport is never smaller than the sidebar on mobile. ## 2. Sources Three segmented tabs: **3D**, **Image**, **Cam**, plus an "Upload" affordance in the SOURCE header. - **Image** — accept PNG, JPEG, WebP via file picker and drag-and-drop onto the viewport. Read with `createImageBitmap`, draw into the offscreen canvas with `object-fit: contain` semantics. - **3D** — see section 3. - **Cam** — see section 5. Switching source must tear down the previous one completely (stop rAF loops, stop camera tracks, dispose Three.js geometries and materials). ## 3. The 3D viewer Three.js. Nine built-in parametric shapes, generated in code, zero assets: knot (TorusKnotGeometry, the default) · sphere · cube · ring (torus) · orbit (three nested rings on different axes) · helix (two offset parametric curves with spheres along them) · diamond (octahedron) · wave (a plane with a sin/cos displacement animated over time) · ico (icosahedron) Controls: Auto Rotate toggle (on by default), Speed slider 0.1x–3x, drag to rotate, scroll to zoom. Lighting should be simple and high-contrast — one key light, one rim — because flat, evenly-lit geometry converts to a featureless block of one character. Also accept imported **GLB** and **OBJ** files. Centre and scale imported models to fit the frame automatically; do not trust their units. **Readback:** render the scene into an offscreen `WebGLRenderTarget` already sized to `cols x rows`, then `readRenderTargetPixels` into a Uint8Array and hand it to `toAscii`. Do NOT render at full viewport resolution and downsample afterwards. **Fallback:** if WebGL is unavailable, the nine built-in shapes fall back to a software rasteriser (project vertices, depth-sort, fill) so the app still runs. Imported models require WebGL — say so in the UI with a clear message rather than showing an empty frame. ## 4. The converter Per cell, in this exact order: ```ts let l = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; // perceptual luminance l = (l - 0.5) * contrast + 0.5; // contrast pivots at mid-grey l = l * brightness; if (invert) l = 1 - l; // invert luminance, NOT the index const ch = ramp[Math.round(clamp01(l) * (ramp.length - 1))]; ``` **Cell aspect ratio.** Measure the real monospace cell at boot (`measureText` for advance width, line-height for height) and sample a box of that aspect from the source. Monospace cells are roughly 2:1 tall — sampling on a square grid produces output stretched vertically by 2x. This is not optional. **Density** is a cell size in pixels (range 4–24, default 12), NOT a column count. `cols = floor(width / cellW)`, `rows = floor(height / cellH)`. On window resize the character count changes; the character size does not. **Colour modes:** Monochrome (every glyph the foreground colour) or Source Color (each glyph takes the average colour of its cell). Foreground and background are colour pickers. Defaults: foreground `#CC4400`, background `#FDFBF9`. ## 5. The webcam — get this exactly right - `navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' }, audio: false })`. **Never request audio.** - The Cam tab renders a placeholder card with one line of copy and a **START CAMERA** button. No `getUserMedia` call happens on mount, on tab switch, or on any path the user did not explicitly click. - While live: show a status indicator and a **STOP CAMERA** control. - Mirror the feed horizontally. - Draw the `<video>` into the offscreen canvas inside the existing rAF loop. Do not add a second loop. - **Teardown:** call `track.stop()` on every track and null the stream reference on (a) Stop Camera, (b) source change, (c) component unmount, (d) `pagehide`. Verify the OS camera indicator goes out in all four cases. - **Errors:** distinguish `NotAllowedError`, `NotFoundError` and `NotReadableError` with different copy. Because permission prompts can be suppressed inside an embedded preview iframe, include an **Open in New Tab** button alongside a Retry button in the error state. ## 6. Characters A **Preset** select with five options, and a free-text **Character Ramp (Dark → Light)** input that is always editable and always reflects the active preset: - Standard Detailed: `. \`-_':,;^=+/"|\<>()iv%xclrs{*}I?!|` - Simple: `.:-=+*#%@` - Binary: `01` - Blocks: ` ░▒▓█` - Matrix: `アイウエオカキ0123456789` Typing in the ramp field re-renders live and switches the preset label to "Custom". ## 7. Quick Looks Three one-click looks shown as small live thumbnails: **Terminal** (green on black, simple ramp), **Ember** (orange on black, detailed ramp, raised contrast), **Inverted** (dark glyphs on light, invert on). A Quick Look WRITES ITS VALUES INTO THE NORMAL CONTROLS and then gets out of the way. It does not lock anything, and every slider stays free afterwards. There is no "active look" mode. ## 8. Exports - **TXT** — join the grid rows with `\n`, Blob, download as `ascii-studio.txt`. - **PNG** — render the grid into a fresh canvas with `fillText` at 2x export scale, using the shared cell-geometry module, the current font, and the current foreground/background. Do not screenshot the DOM. - **WebM** — `canvas.captureStream(30)` into `MediaRecorder`. Probe `MediaRecorder.isTypeSupported` over `['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm']` and use the first supported type. If none are supported, render the button disabled with a tooltip explaining that the browser cannot record. Recording must work for the webcam source too. Show elapsed time while recording. All three buttons are disabled until a frame has been rendered. ## 9. Appearance Light mode. Background `#FDFBF9`, ink `#CC4400`, sidebar and cards in warm greys. Uppercase letter-spaced mono labels for section headings, sentence-case for control labels. The ASCII output is the only saturated element on the page — keep the chrome quiet. ## 10. Performance and privacy - One `requestAnimationFrame` loop for the whole app. Nothing renders when the source is static and no control is moving. - Reuse a single offscreen canvas and a single `ImageData` buffer; do not allocate per frame. - Debounce the ramp text input; do not debounce the sliders. - Images, models and camera frames are processed entirely in the browser and never uploaded. State this in the UI. ## Build order Build and show me section 4 (the converter) first, wired only to a static test image, before you write any 3D or camera code. I want to see a correct, non-stretched ASCII render of one photograph before anything else exists.

Where it goes wrong

  • Everything looks vertically stretched. The cell aspect ratio was ignored and the source is being sampled on a square grid. This is the first bug and it is worth failing the build on.
  • The camera works, the image upload does not, or vice versa. Two converters got written. Delete one and route both adapters into the survivor.
  • The PNG does not match the preview. The exporter computed its own grid instead of importing the shared cell-geometry module.
  • Brightness and contrast fight each other. Contrast is a plain multiply rather than pivoting around 0.5.
  • The 3D object renders as a solid block of one character. The scene is lit flat. ASCII has maybe twenty tonal steps to work with; it needs a key and a rim light to find them.
  • The camera indicator stays on. Tracks are stopped on the Stop button only. All four exit paths need it.
  • The camera never prompts at all. You are testing in an embedded preview pane. Open the deployment in a real tab before you touch the code.
  • Frame rate collapses at low density. A new canvas, ImageData or string array is being allocated every frame. At 4px cells that is a lot of garbage per second.
  • WebM export throws on Safari. isTypeSupported was never probed and a vp9 mime type was hardcoded.

Recap

  • Collapse every source to ImageData and write one converter. Name the forbidden alternative in the brief, because the obvious decomposition is the wrong one.
  • Density is a cell size. Column counts break on resize.
  • Measure the monospace cell. It is about twice as tall as it is wide, and assuming otherwise stretches everything by 2x in a way that looks almost correct.
  • Luminance, then contrast around mid-grey, then brightness, then invert, then quantise. Any other order produces controls that interfere with each other.
  • Order the ramp by ink coverage in your actual font, then let people replace it. The custom ramp field earns more play than any slider.
  • Read the 3D scene back at grid resolution, not at viewport resolution.
  • The camera is ten lines of pixels and a page of permission and teardown behaviour. Specify the teardown on all four exit paths, and never ask for audio.
  • Share cell geometry between the screen and the PNG exporter, and probe codec support before offering the video one.
  • Ship something already running on first paint. Nine generated shapes cost nothing and remove the empty-canvas problem entirely.
  • Ask for the converter first, against one static image. It is the only checkpoint in this build you can fail on sight.

If you want more in this shape, there is a 3D ASCII wordmark that animates inside a GitHub README, a liquid-metal webcam filter in Python and OpenCV, and a Replit prompt for faking old CRT hardware in the browser.