Friday, September 4, 2026
HomeProgrammingPast the Luminance Ramp: A Form-Conscious ASCII Renderer in Three.js

Past the Luminance Ramp: A Form-Conscious ASCII Renderer in Three.js



Editor’s Be aware: As our Three.js Convention celebration continues, we’re excited to welcome Edoardo Lunardi with an enchanting exploration of ASCII rendering. Going past easy luminance ramps, Edoardo exhibits how shape-aware sampling, GPU-based glyph looking out, and Three.js can remodel a 3D object right into a remarkably detailed, interactive ASCII print.

🥖 Pack your luggage. Paris is looking! The very first Three.js Convention is coming to Paris. Use code CODROPS for 15% off and get your ticket →

Each ASCII shader I’ve learn does the identical factor. Pattern the scene, compute luminance, index right into a ramp like .:-=+*#%@, achieved. Ten traces, and it holds up proper till the article has an edge.

Put a tough diagonal throughout the body and it comes out as a staircase of # and % alternating on a hair of brightness, noise sorted by weight. The ramp solely is aware of how brilliant a cell is, not the place contained in the cell that brightness sits, so a slash and a dot with the identical protection come out as the identical character. Any edge that isn’t horizontal or vertical dissolves into tone.

Recently I’ve been deep in ASCII, dithering, and retro tech aesthetics. All of them come right down to the identical constraint: a set grid and a small vocabulary of marks, the place the entire downside is deciding which mark goes the place.

I rebuilt the Codrops mark as a stable you’ll be able to drag round, printed totally in ASCII on the GPU. Each character cell samples six factors inside itself and ten within the cells round it, builds a six-value form vector, then searches all 95 printable glyphs for the closest match. Each cell, each body.

The mark printed twice from the identical pose, a luminance ramp on the left and the form search on the fitting. The ramp turns the diagonal edge into noise sorted by brightness; the search resolves the identical edge into characters that observe it.

Three passes, one glyph search

The renderer runs three passes per body, and splitting them is what makes the search low cost sufficient to run in any respect.

renderer.setRenderTarget(this.#sceneTarget);
renderer.render(this.#scene, this.#digital camera);

this.#quad.materials = this.#cellMaterial;
renderer.setRenderTarget(this.#cellTarget);
renderer.render(this.#body, this.#frameCamera);

this.#quad.materials = this.#postMaterial;
renderer.setRenderTarget(null);
renderer.render(this.#body, this.#frameCamera);
The three targets for one body: the scene move with its personal lighting, the cell goal false-coloured by the glyph index every cell selected, and the ultimate print.

The scene move attracts the stable into an offscreen goal with its personal lighting. The cell move runs one fragment per character cell, picks that cell’s glyph and writes the profitable index. The publish move reads these indices again and stamps the glyph from an atlas, in no matter ink the web page resolved.

The search runs as soon as per cell, and on the base cell of 6 by 10 CSS pixels that’s as soon as per 60 pixels fairly than as soon as per pixel. The cell goal is sized in cells fairly than system pixels too, so the fee holds flat because the system ratio climbs.

Constructing the mark as actual geometry

The mark is a lens with the droplet lower by it fairly than a disc extruded alongside Z. The cell move reads tone, and an extrusion offers it virtually nothing to learn.

Two flat faces and a straight wall resolve to a single tone every below any lighting mannequin, so each cell inside a face will get the identical form vector and the mark prints as a blob with a tough define. Each faces should curve so the tone runs throughout them.

A near-flat extrusion printed beside the domed lens, similar pose and digital camera. The flat faces resolve to a single tone every and print as a blob with an overview; the domed faces give each cell a special tone to learn.

Every face is lower from a sphere positioned to move by the rim and thru the center. Two constraints, so one radius:

const DOME_RADIUS = (OUTER_RADIUS * OUTER_RADIUS + domeSag * domeSag) / (2 * domeSag);
const DOME_CENTRE = Math.sqrt(DOME_RADIUS * DOME_RADIUS - OUTER_RADIUS * OUTER_RADIUS) - RIM_DEPTH / 2;

const domeZ = (r) => Math.sqrt(Math.max(DOME_RADIUS * DOME_RADIUS - r * r, 0)) - DOME_CENTRE;

domeZ offers the peak of both face at any distance from the centre, and on the outer radius it comes out at precisely half the rim thickness.

The droplet define is a polar operate. Previous the angle the place a tangent from the apex meets the bulb, the define is the bulb’s personal arc. Earlier than it, the define is the tangent line. The form is convex with the centre inside it, so each ray leaves precisely as soon as and the operate is single valued:

operate dropletRadius(theta) {
  const dy = Math.sin(theta);
  const flip = theta - TANGENT_FROM - Math.flooring((theta - TANGENT_FROM) / TAU) * TAU;

  if (flip <= TANGENT_SPAN) {
    return TANGENT_C / (TANGENT_NX * Math.abs(Math.cos(theta)) + TANGENT_NY * dy);
  }

  const alongside = dy * BULB_Y;

  return alongside + Math.sqrt(Math.max(alongside * alongside - BULB_Y * BULB_Y + BULB_RADIUS * BULB_RADIUS, 0));
}
The define as a polar operate: the bulb’s arc, the 2 tangent traces assembly on the apex, and the tangent factors the place one palms over to the opposite. Drawn from the identical constants the geometry is constructed from.

The Math.abs on the cosine is what lets the right-hand tangent serve either side of a mirror-symmetric define. Phase rely is a a number of of 4 so one step lands precisely on the apex and that nook stays sharp as a substitute of getting sanded off by the sampling.

Face normals come from the dome sphere’s personal radius by every level fairly than from differencing neighbours, in order that they’re actual. The rim and the droplet wall get a flat regular per step as a substitute, which is what retains the apex a tough nook:

const nz = (Math.abs(z) + DOME_CENTRE) * facet;
const size = Math.hypot(x, y, nz) || 1;

return [
  [x, y, z],
  [x / length, y / length, nz / length],
];

192 segments at 22 quads every offers 4,224 quads, or 8,448 triangles, constructed as soon as right into a non-indexed BufferGeometry. The curvature is what the print is studying, so flat geometry can’t be rescued later within the shader.

Baking the glyph atlas

The atlas is rasterized within the browser at runtime, from no matter monospace face the stylesheet resolved on the canvas. 95 glyphs, house by tilde, drawn into a ten by 10 grid with 8 pixels of bleed round every cell so a glyph overshooting its field isn’t clipped right into a false edge.

ctx.fillStyle = "#ffffff";
ctx.textAlign = "heart";
ctx.textBaseline = "center";
ctx.font = `${weight} ${Math.flooring(Math.min(cellH * 0.92, cellW / 0.58))}px ${font}`;

for (let glyph = 0; glyph < GLYPHS.size; glyph++) {
  ctx.fillText(GLYPHS[glyph], (glyph % cols) * padW + padW / 2, Math.flooring(glyph / cols) * padH + padH / 2);
}
The baked sheet, straight off the supply canvas, with the inside cell bins stroked. The bleed round every field is what retains an overshooting glyph from being clipped right into a false edge.

The scale is the smaller of two matches, 92% of the cell top or the cell width over 0.58, so tall glyphs and extensive glyphs each land contained in the field with out measuring something per glyph.

The face is loaded with doc.fonts.load fairly than awaited by doc.fonts.prepared, so the atlas bakes from the meant face as a substitute of from regardless of the fallback stack resolves to first. A failed fetch nonetheless bakes, in no matter it falls again to.

Six factors as a substitute of 1 common

Every glyph will get a six-value vector describing the place its ink sits, measured at six fastened factors contained in the cell:

const INNER_SAMPLES = [
  [0.28, 0.26],
  [0.72, 0.14],
  [0.28, 0.56],
  [0.72, 0.44],
  [0.28, 0.86],
  [0.72, 0.74],
];

The correct column rides greater than the left, and that asymmetry is doing the work. A diagonal operating backside left to high proper lands on the excessive proper samples and the low left samples, which is a special signature from two stacked dots even when the overall protection is an identical. Symmetrical factors would collapse each circumstances into the identical vector and put us again the place the ramp was.

Every worth is the protection of the glyph’s alpha channel inside a disc of radius 0.26 cell heights round its level, so a stroke passing close to a pattern nonetheless registers as a substitute of falling between faucets.

Normalized per pattern level, not globally

Normalizing the entire set in opposition to one international peak would collapse a lot of the vocabulary. Glyphs carrying heavy complete ink would win each slot, and a flat area of the mark would map onto one or two dense characters throughout its complete space.

for (let pattern = 0; pattern < INNER_SAMPLES.size; pattern++) {
  let peak = 0;

  for (let glyph = 0; glyph < rely; glyph++) {
    peak = Math.max(peak, vectors[glyph * INNER_SAMPLES.length + sample]);
  }

  if (peak > 0) {
    for (let glyph = 0; glyph < rely; glyph++) {
      vectors[glyph * INNER_SAMPLES.length + sample] /= peak;
    }
  }
}

Every of the six slots is scaled by its personal peak throughout the 95 glyphs, so a slot that no glyph fills closely nonetheless spans the complete vary, and flat tone retains spreading throughout the vocabulary as a substitute of collapsing onto a single glyph.

The cell shader does its personal normalization, and it isn’t the identical operation. It takes a single peak throughout that cell’s six values and raises every of them to a CONTRAST exponent in opposition to it.

The atlas vectors ship as a 6 by 95 single-channel float texture, one row per glyph, learn with texelFetch so nothing is filtered on the way in which in.

The search, one fragment per cell

The cell move is the place the body time goes. Every of its sixteen pattern positions is a couple of texel learn: a centre faucet plus six on a hexagonal ring, averaged:

vec4 sampleCircle(vec2 c) {
  vec2 center = cellBase + vec2(c.x, 1.0 - c.y) * uCellPx;
  float r = uCellPx.y * 0.161;
  vec4 acc = fetchTap(center);

  for (int okay = 0; okay < 6; okay++) {
    acc += fetchTap(center + RING[k] * r);
  }

  return acc / 7.0;
}

Seven faucets means a pattern measures a small disc fairly than some extent, and faucets falling exterior the scene goal return zero fairly than clamping to the sting.

Luminance comes out after unpremultiplying, then will get weighted again by protection:

float circleLum(vec4 acc) {
  vec3 straight = acc.rgb / max(acc.a, 1e-4);

  return clamp(dot(straight, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0) * acc.a;
}

Dividing by alpha recovers the stable’s personal shading impartial of how a lot of the disc it covers. Multiplying by alpha on the finish places the protection again into the quantity, so a cell half crammed by the silhouette returns a decrease worth than a full cell on the similar shading. The search sees the define and the shading in a single worth.

Neighbour faucets, so a cell is aware of which facet of an edge it’s on

The ten outer faucets are what make an edge snap as a substitute of smear. A cell sitting on a boundary averages either side of it, which leaves the sting domestically low distinction in precisely the place it must be excessive.

Every inside pattern is in contrast in opposition to the brightest neighbour mendacity within the instructions it faces, then pushed down if it loses:

float dirContrast(float worth, float ext) {
  float peak = max(worth, ext);

  if (peak < 1e-4) {
    return worth;
  }

  return pow(worth / peak, EDGE_CONTRAST) * peak;
}

v[0] = dirContrast(v[0], max(max(e[0], e[1]), max(e[2], e[4])));
v[1] = dirContrast(v[1], max(max(e[0], e[1]), max(e[3], e[5])));
// v[2] by v[5] observe the identical form
The six inside samples and ten outer faucets drawn in opposition to a 3 by 3 cell neighbourhood. The cell being solved is the centre one; each outer faucet sits inside a neighbour.

If a neighbour exterior the cell is brighter, this pattern sits on the dim facet of an edge operating by the area, and the exponent widens the hole between the 2 halves of the cell. That’s the distinction between a cell resolving to a slash and the identical cell resolving to a p.c signal.

Then the search itself, a plain linear scan with no early exit:

int greatest = 0;
float bestD = 1e9;

for (int g = 0; g < uGlyphCount; g++) {
  float d = 0.0;

  for (int i = 0; i < 6; i++) {
    float diff = v[i] - texelFetch(tShapes, ivec2(i, g), 0).r;

    d += diff * diff;
  }

  if (d < bestD) {
    bestD = d;
    greatest = g;
  }
}

outColor = vec4(colAcc / max(alphaAcc, 1e-4), float(greatest) / 255.0);

570 subtract-square-accumulate operations per cell, in opposition to a texture sufficiently small to take a seat in cache, and 112 texture fetches on high for the sixteen pattern discs.

The winner leaves within the alpha channel as float(greatest) / 255.0. 95 glyphs match below 255, so an bizarre 8-bit RGBA goal carries the index and there’s no want for a second attachment or a float format.

Compositing with out mip seams

The publish move has one entice in it, and something that samples an atlas per cell will hit the identical one.

Atlas UVs bounce discontinuously at each cell boundary. One cell holds a hash from the center of the sheet, the subsequent holds an L from the nook, so the UV is a sawtooth with a tough break on every edge. Let the GPU take derivatives of that UV by itself and each bounce reads as a texture minified into nothing, so it reaches for the smallest mip and faint seam traces seem alongside the grid. Worse, whether or not they present up in any respect depends upon cell dimension and system ratio, so the bug comes and goes because the structure modifications, which is strictly what makes it straightforward to overlook.

vec2 atlasStep = uAtlasInner / uAtlasGrid;
float masks = textureGrad(tAtlas, atlasUv, dFdx(cellPos) * atlasStep, dFdy(cellPos) * atlasStep).a;

cellPos is steady throughout the body, so its by-product is a sane per-pixel step, and scaling by the atlas cell dimension converts it into the fitting by-product in atlas house.

Preserving the fee tied to the cell grid

Price here’s a operate of the cell grid, not of the canvas. The scene goal is sized at twelve pixels per cell row:

const scale = SCENE_CELL_PX / cellHeight;
const sceneWidth = Math.max(Math.spherical(width * scale), 1);
const sceneHeight = Math.max(Math.spherical(top * scale), 1);

this.#renderer.setPixelRatio(dpr);
this.#renderer.setSize(width, top, false);

this.#sceneTarget.setSize(sceneWidth, sceneHeight);
this.#cellTarget.setSize(cols, rows);

The cell move wants sufficient scene decision to put its sixteen pattern discs and nothing past that. Sizing the goal off the canvas would imply rendering the stable at full system ratio and throwing virtually all of it away in averaging. Solely the publish move runs at canvas decision, and per pixel it does one texelFetch for the cell’s index plus one atlas lookup.

Cell rely is geared toward 4,600. Previous that the cell grows and the grid stays roughly the place it’s:

const uncooked = (this.#width / CONFIG.cellW) * (this.#top / (CONFIG.cellW * CONFIG.lineRatio));
const development = uncooked > CONFIG.maxCells ? Math.sqrt(uncooked / CONFIG.maxCells) : 1;

this.#cellW = Math.spherical(CONFIG.cellW * development);
this.#cellH = Math.spherical(this.#cellW * CONFIG.lineRatio);

maxCells is a goal fairly than a tough ceiling. Cell dimensions get rounded to entire pixels after the expansion issue is utilized, so the actual rely lands close to 4,600 and might sit above it. Progress is at all times computed from the bottom cell dimension, by no means from the earlier outcome, or a run of resizes would compound into cells the scale of tiles.

The loop is capped at 60fps, with slack:

const FRAME_SLACK_MS = 8;

if (dt < 1000 / CONFIG.frameHz - FRAME_SLACK_MS) {
  return;
}

A threshold of precisely 1000/60 lands on the show’s personal beat. One body arrives a fraction early, will get skipped, and the loop settles into 30fps. Eight milliseconds of slack retains the brink away from it. Easing runs on a frame-rate impartial exponential damp so the identical drag feels an identical at 60 and 144Hz, and an IntersectionObserver stops the loop totally as soon as the ingredient leaves the viewport.

Mild and darkish as a tone inversion

Switching theme inverts the scene tone fairly than swapping a color, as a result of density means reverse issues on the 2 grounds. On paper a dense glyph reads as darkish. On a darkish floor the identical glyph reads as mild. Hold the tone and alter solely the ink and the mark prints as its personal damaging on one in every of them.

outColor = vec4(combine(lit, combine(vec3(0.18), vec3(1.0), 1.0 - lit), uPaper), 1.0);

The 0.18 is the lit facet’s minimal ink. With out it, absolutely lit tone inverts to zero, the brightest area of the mark will get handed an area character, and the silhouette breaks open precisely the place the sunshine lands.

The identical pose in each themes. The scene tone inverts with the bottom and solely the ink color modifications, so the mark reads as the identical object on each as a substitute of as its personal damaging.

The ink by no means seems within the shader in any respect. The ingredient reads the computed color off the canvas, paints it right into a one-pixel canvas and reads the bytes again:

ctx.fillStyle = getComputedStyle(ingredient).colour;
ctx.fillRect(0, 0, 1, 1);

const [r, g, b] = ctx.getImageData(0, 0, 1, 1).knowledge;

return new Shade().setRGB(r / 255, g / 255, b / 255, LinearSRGBColorSpace);

Parsing that string could be a dropping recreation. The cascade can hand down color-mix(), a relative color, oklch(), or no matter ships subsequent yr. Solely the browser reliably is aware of what it resolved to, and a one-pixel canvas is the most cost effective approach to ask.

Every little thing inside one customized ingredient

There’s no framework right here and no element tree. The web page ships a canvas inside a customized ingredient, and the stylesheet owns the field:

<ascii-logo position="img" aria-label="The Codrops mark, drawn as a rotating stable in ASCII characters">
  <canvas data-logo-canvas class="is-hidden"></canvas>
</ascii-logo>

Three issues should be true in CSS. A hard and fast side ratio so the canvas isn’t a structure shift, a monospace household on the canvas as a result of the atlas bakes from it, and a colour on the ingredient as a result of the print is drawn in no matter ink resolves there.

There’s no loading state and no fallback. The canvas ships hidden and fades in as soon as a body lands cleanly. If WebGL is lacking, a shader is rejected, or the context is misplaced, the ingredient retains its field and stays empty, which is quieter than a skeleton that by no means resolves into something.

renderer.debug.onShaderError = () => {
  throw new Error("ascii-logo: shader did not compile");
};

Throwing on a shader error fairly than logging it’s deliberate. A rejected shader takes the identical path as a lacking context, so there’s one failure department to motive about as a substitute of two, and the pointer listener solely will get hooked up as soon as there’s one thing to show.

Diminished movement holds the body and skips the idle float, although a drag nonetheless runs, as a result of a drag is the customer’s personal doing fairly than movement imposed on them. As soon as the easing settles under a threshold the loop stops as a substitute of repainting an unchanged body perpetually.

What sampling for structure buys you

Sampling for structure turns an ASCII filter into an ASCII print. A diagonal comes out as a slash, a nook as an L, a flat face as a fair area that also varies throughout itself, and the mark holds its edges the entire method by a drag. That’s the explanation to render it as a stable as a substitute of operating a filter over an image of 1.

Not one of the equipment cares that the output occurs to be characters. The offscreen goal sized off the cell grid, the glyph index using in an 8-bit alpha channel, the neighbour faucets, the hand-computed derivatives. Swap the atlas for tiles, dominoes, or a set of hand-drawn strokes and the search doesn’t change.

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Most Popular

Recent Comments