davideisinger.com

My personal website
Log | Files | Refs | README

alexharri-com-d1kmv9.txt (44568B)


      1 [1]Alex Harri
      2 [2]About[3]Blog
      3 
      4 ASCII characters are not pixels: a deep dive into ASCII rendering
      5 
      6 January 17, 2026
      7 
      8 Recently, I’ve been spending my time building an image-to-ASCII renderer. Below
      9 is the result — try dragging it around, the demo is interactive!
     10 
     11 One thing I spent a lot of effort on is getting edges looking sharp. Take a
     12 look at this rotating cube example:
     13 
     14 Try opening the “split” view. Notice how well the characters follow the contour
     15 of the square.
     16 
     17 This renderer works well for animated scenes, like the ones above, but we can
     18 also use it to render static images:
     19 
     20 The image of Saturn was [4]generated with ChatGPT.
     21 
     22 Then, to get better separation between different colored regions, I also
     23 implemented a [5]cel shading-like effect to enhance contrast between edges. Try
     24 dragging the contrast slider below:
     25 
     26 The contrast enhancement makes the separation between different colored regions
     27 far clearer. That was key to making the 3D scene above look as good as it does.
     28 
     29 I put so much focus on sharp edges because they’re an aspect of ASCII rendering
     30 that is often overlooked when programmatically rendering images as ASCII.
     31 Consider this animated 3D scene from Cognition’s landing page that is rendered
     32 via ASCII characters:
     33 
     34 Source: [6]cognition.ai
     35 
     36 It’s a cool effect, especially while in motion, but take a look at those blurry
     37 edges! The characters follow the cube contours very poorly, and as a result,
     38 the edges look blurry and jagged in places:
     39 
     40 [cube-logo-zoomed-in]
     41 
     42 This blurriness happens because the ASCII characters are being treated like
     43 pixels — their shape is ignored. It’s disappointing to see because ASCII art
     44 looks so much better when shape is utilized. I don’t believe I’ve ever seen
     45 shape utilized in generated ASCII art, and I think that’s because it’s not
     46 really obvious how to consider shape when building an ASCII renderer.
     47 
     48 I started building my ASCII renderer to prove to myself that it’s possible to
     49 utilize shape in ASCII rendering. In this post, I’ll cover the techniques and
     50 ideas I used to capture shape and build this ASCII renderer in detail.
     51 
     52 We’ll start with the basics of image-to-ASCII conversion and see where the
     53 common issue of blurry edges comes from. After that, I’ll show you the approach
     54 I used to fix that and achieve sharp, high-quality ASCII rendering. At the end,
     55 we’ll improve on that by implementing the contrast enhancement effect I showed
     56 above.
     57 
     58 Let’s get to it!
     59 
     60 Image to ASCII conversion
     61 
     62 ASCII contains [7]95 printable characters that we can use. Let’s start off by
     63 rendering the following image containing a white circle using those ASCII
     64 characters:
     65 
     66 ASCII art is (almost) always rendered using a [8]monospace font. Since every
     67 character in a monospace font is equally wide and tall, we can split the image
     68 into a grid. Each grid cell will contain a single ASCII character.
     69 
     70 The image with the circle is pixels. For the ASCII grid, I’ll pick a row height
     71 of pixels and a column width of pixels. That splits the canvas into rows and
     72 columns — an grid:
     73 
     74 Monospace characters are typically taller than they are wide, so I made each
     75 grid cell a bit taller than it is wide.
     76 
     77 Our task is now to pick which character to place in each cell. The simplest
     78 approach is to calculate a lightness value for each cell and pick a character
     79 based on that.
     80 
     81 We can get a lightness value for each cell by sampling the lightness of the
     82 pixel at the cell’s center:
     83 
     84 We want each pixel’s lightness as a numeric value between and , but our image
     85 data consists of pixels with [9]RGB color values.
     86 
     87 We can use the following formula to convert an RGB color (with component values
     88 between and ) to a lightness value:
     89 
     90 See [10]relative luminance.
     91 
     92 Mapping lightness values to ASCII characters
     93 
     94 Now that we have a lightness value for each cell, we want to use those values
     95 to pick ASCII characters. As mentioned before, ASCII has 95 printable
     96 characters, but let’s start simple with just these characters:
     97 
     98 : - # = + @ * % .
     99 
    100 We can sort them in approximate density order like so, with lower-density
    101 characters to the left, and high-density characters to the right:
    102 
    103 . : - = + * # % @
    104 
    105 We’ll put these characters in a CHARS array:
    106 
    107 const CHARS = [" ", ".", ":", "-", "=", "+", "*", "#", "%", "@"]
    108 
    109 I added space as the first (least dense) character.
    110 
    111 We can then map lightness values between and to one of those characters like
    112 so:
    113 
    114 function getCharacterFromLightness(lightness: number) {
    115   const index = Math.floor(lightness * (CHARS.length - 1));
    116   return CHARS[index];
    117 }
    118 
    119 This maps low lightness values to low-density characters and high lightness
    120 values to high-density characters.
    121 
    122 Rendering the circle from above with this method gives us:
    123 
    124 That works... but the result is pretty ugly. We seem to always get @ for cells
    125 that fall within the circle and a space for cells that fall outside.
    126 
    127 That is happening because we’ve pretty much just implemented nearest-neighbor
    128 downsampling. Let’s see what that means.
    129 
    130 Nearest neighbor downsampling
    131 
    132 Downsampling, in the context of image processing, is taking a larger image (in
    133 our case, the image with the circle) and using that image’s data to construct a
    134 lower resolution image (in our case, the ASCII grid). The pixel values of the
    135 lower resolution image are calculated by sampling values from the higher
    136 resolution image.
    137 
    138 The simplest and fastest method of sampling is [15]nearest-neighbor
    139 interpolation, where, for each cell (pixel), we only take a single sample from
    140 the higher resolution image.
    141 
    142 Consider the circle example again. Using nearest-neighbor interpolation, every
    143 sample either falls inside or outside of the shape, resulting in either or
    144 lightness:
    145 
    146 If, instead of picking an ASCII character for each grid cell, we color each
    147 grid cell (pixel) according to the sampled value, we get the following
    148 pixelated rendering:
    149 
    150 This pixelated rendering is pretty much equivalent to the ASCII rendering from
    151 before. The only difference is that instead of @s we have white pixels, and
    152 instead of spaces we have black pixels.
    153 
    154 These square, jagged looking edges are aliasing artifacts, commonly called [16]
    155 jaggies. They’re a common result of using nearest-neighbor interpolation.
    156 
    157 Supersampling
    158 
    159 To get rid of jaggies, we can collect more samples for each cell. Consider this
    160 line:
    161 
    162 The line’s slope on the axis is . When we pixelate it with nearest-neighbor
    163 interpolation, we get the following:
    164 
    165 Let’s try to get rid of the jagginess by taking multiple samples within each
    166 cell and using the average sampled lightness value as the cell’s lightness. The
    167 example below lets you vary the number of samples using the slider:
    168 
    169 With multiple samples, cells that lie on the edge of a shape will have some of
    170 their samples fall within the shape, and some outside of it. Averaging those,
    171 we get gray in-between colors that smooth the downsampled image. Below is the
    172 same example, but with an overlay showing where the samples are taken:
    173 
    174 This method of collecting multiple samples from the larger image is called [17]
    175 supersampling. It’s a common method of [18]spatial anti-aliasing (avoiding
    176 jaggies at edges). Here’s what the rotating square looks like with
    177 supersampling (using samples for each cell):
    178 
    179 Let’s look at what supersampling does for the circle example from earlier. Try
    180 dragging the sample quality slider:
    181 
    182 The circle becomes less jagged, but the edges feel blurry. Why’s that?
    183 
    184 Well, they feel blurry because we’re pretty much just rendering a
    185 low-resolution, pixelated image of a circle. Take a look at the pixelated view:
    186 
    187 The ASCII and pixelated views are mirror images of each other. Both are just
    188 low-resolution versions of the original high-resolution image, scaled up to the
    189 original’s size — it’s no wonder they both look blurry.
    190 
    191 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    192 
    193 Increasing the number of samples is insufficient. No matter how many samples we
    194 take per cell, the samples will be averaged into a single lightness value, used
    195 to render a single pixel.
    196 
    197 And that’s the core problem: treating each grid cell as a pixel in an image.
    198 It’s an obvious and simple method, but it disregards that ASCII characters have
    199 shape.
    200 
    201 We can make our ASCII renderings far more crisp by picking characters based on
    202 their shape. Here’s the circle rendered that way:
    203 
    204 The characters follow the contour of the circle very well. By picking
    205 characters based on shape, we get a far higher effective resolution. The result
    206 is also more visually interesting.
    207 
    208 Let’s see how we can implement this.
    209 
    210 Shape
    211 
    212 So what do I mean by shape? Well, consider the characters T, L, and O placed
    213 within grid cells:
    214 
    215 The character T is top-heavy. Its visual density in the upper half of the grid
    216 cell is higher than in the lower half. The opposite can be said for L — it’s
    217 bottom-heavy. O is pretty much equally dense in the upper and lower halves of
    218 the cell.
    219 
    220 We might also compare characters like L and J. The character L is heavier
    221 within the left half of the cell, while J is heavier in the right half:
    222 
    223 We also have more “extreme” characters, such as _ and ^, that only occupy the
    224 lower or upper portion of the cell, respectively:
    225 
    226 This is, roughly, what I mean by “shape” in the context of ASCII rendering.
    227 Shape refers to which regions of a cell a given character visually occupies.
    228 
    229 Quantifying shape
    230 
    231 To pick characters based on their shape, we’ll somehow need to quantify (put
    232 numbers to) the shape of each character.
    233 
    234 Let’s start by only considering how much characters occupy the upper and lower
    235 regions of our cell. To do that, we’ll define two “sampling circles” for each
    236 grid cell — one placed in the upper half and one in the lower half:
    237 
    238 It may seem odd or arbitrary to use circles instead of just splitting the cell
    239 into two rectangles, but using circles will give us more flexibility later on.
    240 
    241 A character placed within a cell will overlap each of the cell’s sampling
    242 circles to some extent.
    243 
    244 One can compute that overlap by taking a bunch of samples within the circle
    245 (for example, at every pixel). The fraction of samples that land inside the
    246 character gives us the overlap as a numeric value between and :
    247 
    248 For T, we get an overlap of approximately for the upper circle and for the
    249 lower. Those overlap values form a -dimensional vector:
    250 
    251 We can generate such a -dimensional vector for each character within the ASCII
    252 alphabet. These vectors quantify the shape of each ASCII character along these
    253 dimensions (upper and lower). I’ll call these vectors shape vectors.
    254 
    255 Below are some ASCII characters and their shape vectors. I’m coloring the
    256 sampling circles using the component values of the shape vectors:
    257 
    258 We can use the shape vectors as 2D coordinates — here’s every ASCII character
    259 on a 2D plot:
    260 
    261 000.050.050.10.10.150.150.20.20.250.250.30.30.350.350.40.4UpperLower^@qTMuX$g=C
    262 
    263 Shape-based lookup
    264 
    265 Let’s say that we have our ASCII characters and their associated shape vectors
    266 in a CHARACTERS array:
    267 
    268 const CHARACTERS: Array<{
    269   character: string,
    270   shapeVector: number[],
    271 }> = [...];
    272 
    273 We can then perform a nearest neighbor search like so:
    274 
    275 function findBestCharacter(inputVector: number[]) {
    276   let bestCharacter = "";
    277   let bestDistance = Infinity;
    278   
    279   for (const { character, shapeVector } of CHARACTERS) {
    280     const dist = getDistance(shapeVector, inputVector);
    281     if (dist < bestDistance) {
    282       bestDistance = dist;
    283       bestCharacter = character;
    284     }
    285   }
    286   
    287   return bestCharacter;
    288 }
    289 
    290 The findBestCharacter function gives us the ASCII character whose shape best
    291 matches the input lookup vector.
    292 
    293 Note: this brute force search is not very performant. This becomes a bottleneck
    294 when we start rendering thousands of ASCII characters at FPS. I’ll talk more
    295 about this later.
    296 
    297 To make use of this in our ASCII renderer, we’ll calculate a lookup vector for
    298 each cell in the ASCII grid and pass it to findBestCharacter to determine the
    299 character to display.
    300 
    301 Let’s try it out. Consider the following zoomed-in circle as an example. It is
    302 split into three grid cells:
    303 
    304 Overlaying our sampling circles, we see varying degrees of overlap:
    305 
    306 When calculating the shape vector of each ASCII character, we took a huge
    307 number of samples. We could afford to do that because we only need to calculate
    308 those shape vectors once up front. After they’re calculated, we can use them
    309 again and again.
    310 
    311 However, if we’re converting an animated image (e.g. canvas or video) to ASCII,
    312 we need to be mindful of performance when calculating the lookup vectors. An
    313 ASCII rendering might have hundreds or thousands of cells. Multiplying that by
    314 tens or hundreds of samples would be incredibly costly in terms of performance.
    315 
    316 With that being said, let’s pick a sampling quality of with the samples placed
    317 like so:
    318 
    319 For the top sampling circle of the leftmost cell, we get one white sample and
    320 two black, giving us an average lightness of . Doing the same calculation for
    321 all of the sampling circles, we get the following 2D vectors:
    322 
    323 From now on, instead of using the term “lookup vectors”, I’ll call these
    324 vectors, sampled from the image that we’re rendering as ASCII, sampling vectors
    325 . One sampling vector is calculated for each cell in the grid.
    326 
    327 Anyway, we can use these sampling vectors to find the best-matching ASCII
    328 character. Let’s see what that looks like on our 2D plot — I’ll label the
    329 sampling vectors (from left to right) C0, C1, and C2:
    330 
    331 000.10.10.20.20.30.30.40.40.50.50.60.60.70.70.80.80.90.911UpperLowerC0C1C2P$
    332 
    333 Hmm... this is not what we want. Since none of the ASCII shape vector
    334 components exceed , they’re all clustered towards the bottom-left region of our
    335 plot. This makes our sampling vectors map to a few characters on the edge of
    336 the cluster.
    337 
    338 We can fix this by normalizing the shape vectors. We’ll do that by taking the
    339 maximum value of each component across all shape vectors, and dividing the
    340 components of each shape vector by the maximum. Expressed in code, that looks
    341 like so:
    342 
    343 const max = [0, 0]
    344 
    345 for (const vector of characterVectors) {
    346   for (const [i, value] of Object.entries(vector)) {
    347     if (value > max[i]) {
    348       max[i] = value;
    349     }
    350   }
    351 }
    352 
    353 const normalizedCharacterVectors = characterVectors.map(
    354   vector => vector.map((value, i) => value / max[i])
    355 )
    356 
    357 Here’s what the plot looks like with the shape vectors normalized:
    358 
    359 000.10.10.20.20.30.30.40.40.50.50.60.60.70.70.80.80.90.911UpperLower^@qTMuX$g=C
    360 
    361 If we now map the sampling vectors to their nearest neighbors, we get a much
    362 more sensible result:
    363 
    364 000.10.10.20.20.30.30.40.40.50.50.60.60.70.70.80.80.90.911UpperLowerC0C1C2M$'
    365 
    366 We get ', M and $. Let’s see how well those characters match the circle:
    367 
    368 Nice! They match very well.
    369 
    370 Let’s try rendering the full circle from before with the same method:
    371 
    372 Much better than before! The picked characters follow the contour of the circle
    373 very well.
    374 
    375 Limits of a 2D shape vector
    376 
    377 Using two sampling circles — one upper and one lower — produces a much better
    378 result than the -dimensional (pixelated) approach. However, it still falls
    379 short when trying to capture other aspects of a character’s shape.
    380 
    381 For example, two circles don’t capture the shape of characters that fall in the
    382 middle of the cell. Consider -:
    383 
    384 For -, we get a shape vector of . That doesn’t represent the character very
    385 well at all.
    386 
    387 The two upper-lower sampling circles also don’t capture left-right differences,
    388 such as the difference between p and q:
    389 
    390 We could use such differences to get better character picks, but our two
    391 sampling circles don’t capture them. Let’s add more dimensions to our shape to
    392 fix that.
    393 
    394 Increasing to 6 dimensions
    395 
    396 Since cells are taller than they are wide (at least with the monospace font I’m
    397 using), we can use sampling circles to cover the area of each cell quite well:
    398 
    399 sampling circles capture left-right differences, such as between p and q, while
    400 also capturing differences across the top, bottom, and middle regions of the
    401 cell, differentiating ^, -, and _. They also capture the shape of “diagonal”
    402 characters like / to a reasonable degree.
    403 
    404 One problem with this grid-like configuration for the sampling circles is that
    405 there are gaps. For example, . falls between the sampling circles:
    406 
    407 To compensate for this, we can stagger the sampling circles vertically (e.g.
    408 lowering the left sampling circles and raising the right ones) and make them a
    409 bit larger. This causes the cell to be almost fully covered while not causing
    410 excessive overlap across the sampling circles:
    411 
    412 We can use the same procedure as before to generate character vectors using
    413 these sampling circles, this time yielding a -dimensional vector. Consider the
    414 character L:
    415 
    416 For L, we get the vector:
    417 
    418 I’m presenting -dimensional shape vectors in a matrix form because it’s easier
    419 to grok geometrically, but the actual vector is a flat list of numbers.
    420 
    421 The lightness values certainly look L-shaped! The 6D shape vector captures L’s
    422 shape very well.
    423 
    424 Nearest neighbor lookups in a 6D space
    425 
    426 Now we have a 6D shape vector for every ASCII character. Does that affect
    427 character lookups (how we find the best matching character)?
    428 
    429 Earlier, in the findBestCharacter function, I referenced a getDistance
    430 function. That function returns the [22]Euclidean distance between the input
    431 points. Given two 2D points and , the formula to calculate their Euclidean
    432 distance looks like so:
    433 
    434 This generalizes to higher dimensions:
    435 
    436 Put into code, this looks like so:
    437 
    438 function getDistance(a: number[], b: number[]): number {
    439   let sum = 0;
    440   for (let i = 0; i < a.length; i++) {
    441     sum += (a[i] - b[i]) ** 2;
    442   }
    443   return Math.sqrt(sum);
    444 }
    445 
    446 Note: since we’re just using this for the purposes of finding the closest
    447 point, we can skip the expensive Math.sqrt() call and just return the squared
    448 distance. It does not affect the result.
    449 
    450 So, no, the dimensionality of our shape vector does not change lookups at all.
    451 We can use the same getDistance function for both 2D and 6D.
    452 
    453 With that out of the way, let’s see what the 6D approach yields!
    454 
    455 Trying out the 6D approach
    456 
    457 Our new 6D approach works really well for flat shapes, like the circle example
    458 we’ve been using:
    459 
    460 Now let’s see how this approach works when we render a 3D scene with more
    461 shades of gray:
    462 
    463 Firstly, the outer contours look nice and sharp. I also like how well the
    464 gradients across the sphere and cone look.
    465 
    466 However, internally, the objects all kind of blend together. The edges between
    467 surfaces with different lightnesses aren’t sharp enough. For example, the
    468 lighter faces of the cubes all kind of blend into one solid color. When there
    469 is a change in color — like when two faces of a cube meet — I’d like to see
    470 more sharpness in the ASCII rendering.
    471 
    472 To demonstrate what I mean, consider the following split:
    473 
    474 It’s currently rendered like so:
    475 
    476 The different shades result in is on the left and Bs on the right, but the
    477 boundary is not very sharp.
    478 
    479 By applying some effects to the sampling vector, we can enhance the contrast at
    480 the boundary so that it appears sharper:
    481 
    482 The added contrast makes a big difference in readability for the 3D scene.
    483 Let’s look at how we can implement this contrast enhancement effect.
    484 
    485 Contrast enhancement
    486 
    487 Consider cells overlapping a color boundary like so:
    488 
    489 For the cells on the boundary, we get a 6D sampling vector that looks like so:
    490 
    491 To make future examples easier to visualize, I’ll start drawing the sampling
    492 vector using circles like so:
    493 
    494 0.65
    495 0.65
    496 0.31
    497 0.31
    498 0.22
    499 0.22
    500 
    501 Currently, this sampling vector resolves to the character T:
    502 
    503 0.65
    504 0.65
    505 0.31
    506 0.31
    507 0.22
    508 0.22
    509    510 T
    511 Picked character: T
    512 
    513 That’s a sensible choice. The character T is visually dense in the top half and
    514 less so in the bottom half, so it matches the image fairly well.
    515 
    516 Still, I want the picked character to emphasize the shape of the boundary
    517 better. We can achieve that by enhancing the contrast of the sampling vector.
    518 
    519 To increase the contrast of our sampling vector, we might raise each component
    520 of the vector to the power of some exponent.
    521 
    522 Consider how an exponent affects values between and . Numbers close to
    523 experience a strong pull towards while larger numbers experience less pull. For
    524 example, , a 90% reduction, while , only a reduction of 10%.
    525 
    526 The level of pull depends on the exponent. Here’s a chart of for values of
    527 between and :
    528 
    529 [x-pow-2-chart]
    530 
    531 This effect becomes more pronounced with higher exponents:
    532 
    533 [x-pow-n-chart]
    534 
    535 A higher exponent translates to a stronger pull towards zero.
    536 
    537 Applying an exponent should make dark values darker more quickly than light
    538 ones. The example below allows you to vary the exponent applied to the sampling
    539 vector:
    540 
    541 0.65
    542 0.65
    543 0.31
    544 0.31
    545 0.22
    546 0.22
    547 Exponent
    548 [24][1                   ]121
    549 
    550 As the exponent is increased to , the darker components of the sampling vector
    551 quickly become much darker, just like we wanted. However, the lighter
    552 components also get pulled towards zero by a significant amount.
    553 
    554 I don’t want that. I want to increase the contrast between the lighter and
    555 darker components of the sampling vector, not the vector in its entirety.
    556 
    557 To achieve that, we can normalize the sampling vector to the range prior to
    558 applying the exponent, and then “denormalize” the vector back to the original
    559 range afterwards.
    560 
    561 The normalization to can be done by dividing each component by the maximum
    562 component value. After applying the exponent, mapping back to the original
    563 range is done by multiplying each component by the same max value:
    564 
    565 const maxValue = Math.max(...samplingVector)
    566 
    567 samplingVector = samplingVector.map((value) => {
    568   value = value / maxValue; // Normalize
    569   value = Math.pow(value, exponent);
    570   value = value * maxValue; // Denormalize
    571   return value;
    572 })
    573 
    574 Here’s the same example, but with this normalization applied:
    575 
    576 0.65
    577 0.65
    578 0.31
    579 0.31
    580 0.22
    581 0.22
    582 Exponent
    583 [26][1                   ]121
    584 
    585 Very nice! The lightest component values are retained, and the contrast between
    586 the lighter and darker components is increased by “crunching” the lower values.
    587 
    588 This affects which character is picked. The following example shows how the
    589 selected character changes as the contrast is increased:
    590 
    591 0.65
    592 0.65
    593 0.31
    594 0.31
    595 0.22
    596 0.22
    597    598 T
    599 Picked character: T
    600 Exponent
    601 [27][1                   ]121
    602 
    603 Awesome! The pick of " over T emphasizes the separation between the lighter
    604 region above and the darker region below!
    605 
    606 By enhancing the contrast of the sampling vector, we exaggerate its shape. This
    607 gives us a character that less faithfully represents the underlying image, but
    608 improves readability as a whole by enhancing the separation between different
    609 colored regions.
    610 
    611 Let’s look at another example. Observe how the L-shape of the sampling vector
    612 below becomes more pronounced as the exponent increases, and how that affects
    613 the picked character:
    614 
    615 0.68
    616 0.31
    617 0.76
    618 0.31
    619 0.77
    620 0.78
    621    622 &
    623 Picked character: &
    624 Exponent
    625 [28][1                   ]121
    626 
    627 Works really nicely! I love the transition from & -> b -> L as the L-shape of
    628 the vector becomes clearer.
    629 
    630 What’s nice about applying exponents to normalized sampling vectors is that it
    631 barely affects vectors that are uniform in value. If all component values are
    632 similar, applying an exponent has a minimal effect:
    633 
    634 0.64
    635 0.52
    636 0.62
    637 0.51
    638 0.60
    639 0.50
    640    641 &
    642 Picked character: &
    643 Exponent
    644 [29][1                   ]121
    645 
    646 Because the vector is fairly uniform, the exponent only has a slight effect and
    647 doesn’t change the picked character.
    648 
    649 This is a good thing! If we have a smooth gradient in our image, we want to
    650 retain it. We very much do not want to introduce unnecessary choppiness.
    651 
    652 Compare the 3D scene ASCII rendering with and without this contrast
    653 enhancement:
    654 
    655 We do see more contrast at boundaries, but this is not quite there yet. Some
    656 edges are still not sharp enough, and we also observe a “staircasing” effect
    657 happening at some boundaries.
    658 
    659 Let’s look at the staircasing effect first. We can reproduce it with a boundary
    660 like so:
    661 
    662 Below is the ASCII rendering of that boundary. Notice how the lower edge (the !
    663 s) becomes “staircase-y” as you increase the exponent:
    664 
    665 We see a staircase pattern like so:
    666 
    667                !!!!!
    668           !!!!!!!!!!
    669      !!!!!!!!!!!!!!!
    670 !!!!!!!!!!!!!!!!!!!!
    671 
    672 To understand why that’s happening, let’s consider the row in the middle of the
    673 canvas, progressing from left to right. As we start off, every sample is
    674 equally light, giving us Us:
    675 
    676 UUUUUUUU ->
    677 
    678 As we reach the boundary, the lower right samples become a bit darker. Those
    679 darker components are crunched by contrast enhancement, giving us some Ys:
    680 
    681 0.60
    682 0.60
    683 0.60
    684 0.60
    685 0.40
    686 0.30
    687    688 A
    689 Picked character: A
    690 Exponent
    691 [32][1                   ]121
    692 
    693 So we get:
    694 
    695 UUUUUUUUYY ->
    696 
    697 As we progress further right, the middle and lower samples get darker, so we
    698 get some fs:
    699 
    700 0.60
    701 0.60
    702 0.55
    703 0.46
    704 0.32
    705 0.26
    706    707 f
    708 Picked character: f
    709 
    710 This trend continues towards ", ', and finally, `:
    711 
    712 0.54
    713 0.45
    714 0.34
    715 0.25
    716 0.20
    717 0.20
    718    719 !
    720 Picked character: !
    721 Exponent
    722 [34][1                   ]121
    723 
    724 Giving us a sequence like so:
    725 
    726 UUUUUUUUYYf""''` ->
    727 
    728 That looks good, but at some point we get no light samples. Once we get no
    729 light samples, our contrast enhancement has no effect because every component
    730 is equally light. This causes us to always get !s:
    731 
    732 0.20
    733 0.20
    734 0.20
    735 0.20
    736 0.20
    737 0.20
    738    739 !
    740 Picked character: !
    741 Exponent
    742 [36][1                   ]121
    743 
    744 Making our sequence look like so:
    745 
    746 UUUUUUUUYYf""''`!!!!!!!!!! ->
    747 
    748 This sudden stop in contrast enhancement having an effect is what causes the
    749 staircasing effect:
    750 
    751                !!!!!
    752           !!!!!!!!!!
    753      !!!!!!!!!!!!!!!
    754 !!!!!!!!!!!!!!!!!!!!
    755 
    756 Let’s see how we can counteract this staircasing effect with another layer of
    757 contrast enhancement, this time looking outside of the boundary of each cell.
    758 
    759 Directional contrast enhancement
    760 
    761 We currently have sampling circles arranged like so:
    762 
    763 For each of those sampling circles, we’ll specify an “external sampling
    764 circle”, placed outside of the cell’s boundary, like so:
    765 
    766 Each of those external sampling circles is “reaching” into the region of a
    767 neighboring cell. Together, the samples that are collected by the external
    768 sampling circles constitute an “external sampling vector”.
    769 
    770 Let’s simplify the visualization and consider a single example. Imagine that we
    771 collected a sampling vector and an external sampling vector that look like so:
    772 
    773 0.51
    774 0.51
    775 0.52
    776 0.52
    777 0.53
    778 0.53
    779 0.80
    780 0.51
    781 0.57
    782 0.52
    783 0.53
    784 0.53
    785    786 U
    787 Picked character: U
    788 
    789 The circles colored red are the external sampling vector components. Currently,
    790 they have no effect.
    791 
    792 The “internal” sampling vector itself is fairly uniform, with values ranging
    793 from to . The external vector’s values are similar, except in the upper left
    794 region where the values are significantly lighter ( and ). This indicates a
    795 color boundary above and to the left of the cell.
    796 
    797 To enhance this apparent boundary, we’ll darken the top-left and middle-left
    798 components of the sampling vector. We can do that by applying component-wise
    799 contrast enhancement using the values from the external vector.
    800 
    801 In the previous contrast enhancement, we calculated the maximum component value
    802 across the sampling vector and normalized the vector using that value:
    803 
    804 const maxValue = Math.max(...samplingVector)
    805 
    806 samplingVector = samplingVector.map((value) => {
    807   value = value / maxValue; // Normalize
    808   value = Math.pow(value, exponent);
    809   value = value * maxValue; // Denormalize
    810   return value;
    811 })
    812 
    813 But the new component-wise contrast enhancement will take the maximum value
    814 between each component of the sampling vector and the corresponding component
    815 in the external sampling vector:
    816 
    817 samplingVector = samplingVector.map((value, i) => {
    818   const maxValue = Math.max(value, externalSamplingVector[i])
    819   // ...
    820 });
    821 
    822 Aside from that, the contrast enhancement is performed in the same way:
    823 
    824 samplingVector = samplingVector.map((value, i) => {
    825   const maxValue = Math.max(value, externalSamplingVector[i]);
    826   value = value / maxValue;
    827   value = Math.pow(value, exponent);
    828   value = value * maxValue;
    829   return value;
    830 });
    831 
    832 The example below shows how light values in the external sampling vector push
    833 values in the sampling vector down:
    834 
    835 0.51
    836 0.51
    837 0.52
    838 0.52
    839 0.53
    840 0.53
    841 0.80
    842 0.51
    843 0.57
    844 0.52
    845 0.53
    846 0.53
    847    848 U
    849 Picked character: U
    850 Exponent
    851 [42][1                   ]141
    852 
    853 I call this “directional contrast enhancement”, since each of the external
    854 sampling circles reaches outside of the cell in the direction of the sampling
    855 vector component that it is enhancing the contrast of. I describe the other
    856 effect as “global contrast enhancement” since it acts on all of the sampling
    857 vector’s components together.
    858 
    859 Let’s see what this directional contrast enhancement does to get rid of the
    860 staircasing effect:
    861 
    862 Hmm, that’s not doing what I wanted. I wanted to see a sequence like so:
    863 
    864             ..::!!
    865       ..::!!!!!!!!
    866 ..::!!!!!!!!!!!!!!
    867 
    868 But we just see ! changing to :
    869 
    870 0.20
    871 0.20
    872 0.20
    873 0.20
    874 0.20
    875 0.20
    876 0.40
    877 0.35
    878 0.20
    879 0.20
    880 0.20
    881 0.20
    882    883 !
    884 Picked character: !
    885 Exponent
    886 [44][1                   ]141
    887 
    888 This happens because the directional contrast enhancement doesn’t reach far
    889 enough into our sampling vector. The light upper values in the external vector
    890 do push the upper values of the sampling vector down, but because the lightness
    891 of the four bottom components is retained, we don’t get to ., just :.
    892 
    893 Widening the directional contrast enhancement
    894 
    895 I’d like to “widen” the directional contrast enhancement so that, for example,
    896 light external values at the top spread to the middle components of the
    897 sampling vector.
    898 
    899 To do that, I’ll introduce a few more external sampling circles, arranged like
    900 so:
    901 
    902 These are a total of external sampling circles. Each of the external sampling
    903 circles will affect one or more of the internal sampling circles. Here’s an
    904 illustration showing which internal circles each external circle affects:
    905 
    906 0.30
    907 0.30
    908 0.30
    909 0.30
    910 0.30
    911 0.30
    912 0.30
    913 0.30
    914 0.30
    915 0.30
    916 0.30
    917 0.30
    918 0.30
    919 0.30
    920 0.30
    921 0.30
    922 
    923 For each component of the internal sampling vector, we’ll calculate the maximum
    924 value across the external sampling vector components that affect it, and use
    925 that maximum to perform the contrast enhancement.
    926 
    927 Let’s implement that. I’ll order the internal and external sampling circles
    928 like so:
    929 
    930 0
    931 1
    932 2
    933 3
    934 4
    935 5
    936 0
    937 1
    938 2
    939 3
    940 4
    941 5
    942 6
    943 7
    944 8
    945 9
    946 
    947 We can then define a mapping from the internal circles to the external sampling
    948 circles that affect them:
    949 
    950 const AFFECTING_EXTERNAL_INDICES = [
    951   [0, 1, 2, 4],
    952   [0, 1, 3, 5],
    953   [2, 4, 6],
    954   [3, 5, 7],
    955   [4, 6, 8, 9],
    956   [5, 7, 8, 9],
    957 ];
    958 
    959 With this, we can change the calculation of maxValue to take the maximum
    960 affecting external value:
    961 
    962 // Before
    963 const maxValue = Math.max(value, externalSamplingVector[i]);
    964 
    965 // After
    966 let maxValue = value;
    967 for (const externalIndex of AFFECTING_EXTERNAL_INDICES[i]) {
    968   maxValue = Math.max(maxValue, externalSamplingVector[externalIndex]);
    969 }
    970 
    971 Now look what happens if the top four external sampling circles are light: it
    972 causes the contrast enhancement to reach into the middle of the sampling
    973 vector, giving us the desired effect:
    974 
    975 0.20
    976 0.20
    977 0.20
    978 0.20
    979 0.20
    980 0.20
    981 0.58
    982 0.52
    983 0.31
    984 0.26
    985 0.20
    986 0.20
    987 0.20
    988 0.20
    989 0.20
    990 0.20
    991    992 !
    993 Picked character: !
    994 Exponent
    995 [47][1                   ]141
    996 
    997 We now smoothly transition from ! -> : -> . — beautiful stuff!
    998 
    999 Let’s see if this change resolves the staircasing effect:
   1000 
   1001 Oh yeah, looks awesome! We get the desired effect. The boundary is nice and
   1002 sharp while not being too jagged.
   1003 
   1004 Here’s the 3D scene again. The contrast slider now applies both types of
   1005 contrast enhancement at the same time — try it out:
   1006 
   1007 This really enhances the contrast at boundaries, making the image far more
   1008 readable!
   1009 
   1010 Together, the 6D shape vector approach and contrast enhancement techniques have
   1011 given us a really nice final ASCII rendering.
   1012 
   1013 Final words
   1014 
   1015 This post was really fun to build and write! I hope you enjoyed reading it.
   1016 
   1017 ASCII rendering is perhaps not the most useful topic to write about, but I
   1018 think the idea of using a high-dimensional vector to capture shape is
   1019 interesting and could easily be applied to many other problems. There are
   1020 parallels to be drawn to [48]word embeddings.
   1021 
   1022 I started writing this ASCII renderer to see if the idea of using a vector to
   1023 capture the shape of characters would work at all. That approach turned out to
   1024 work very well, but the initial prototype was terribly slow — I only got
   1025 single-digit FPS on my iPhone. To get the ASCII renderer running at a smooth
   1026 FPS on mobile required a lot of optimization work. I describe some of that
   1027 optimization work in the appendices on [49]character lookup performance and 
   1028 [50]GPU acceleration below.
   1029 
   1030 My colleagues, after reading a draft of this post, suggested many alternatives
   1031 to the approaches I described in this post. For example, why not make the
   1032 sampling vector ? That would capture the shape of T far better — just look how
   1033 T’s stem falls between the two sampling circles in each row:
   1034 
   1035 And yeah, he’s right! A layout would certainly capture it better. They also
   1036 suggested many alternative approaches to the contrast enhancement methods I
   1037 described, but I won’t explore those in this post.
   1038 
   1039 It’s really fun how large the solution space to the problem of ASCII rendering
   1040 is. There are so, so many approaches and trade-offs to explore. I imagine you
   1041 probably thought of a few yourself while reading this post!
   1042 
   1043 One dimension I intentionally did not explore was using different colors or
   1044 lightnesses for the ASCII characters themselves. This is for many reasons, but
   1045 the two primary ones are that 1) it would have expanded the scope of this post
   1046 too much, and 2) it’s just a different effect, and I personally don’t like the
   1047 look.
   1048 
   1049 At the time of writing these final words, around months have elapsed since I
   1050 started working on this post. This has been my longest writing process to date.
   1051 Much of that can be explained by the birth of my now -month-old daughter. I’ve
   1052 needed to be a lot more intentional about finding time to write — and
   1053 disciplined when spending it. I intend to write some smaller posts next. Let’s
   1054 see if I manage to stick to that promise.
   1055 
   1056 Thanks for reading! And huge thanks to [51]Gunnlaugur Þór Briem and [52]Eiríkur
   1057 Fannar Torfason for reading and providing feedback on a draft of this post.
   1058 
   1059 — Alex Harri
   1060 
   1061 Mailing list
   1062 
   1063 To be notified of new posts, subscribe to my mailing list.
   1064 
   1065 [53][                    ]Subscribe
   1066 Appendix I: Character lookup performance
   1067 
   1068 Earlier in this post, I showed how to find the best character by finding the
   1069 character with the shortest Euclidean distance to our sampling vector.
   1070 
   1071 function findBestCharacter(inputVector: number[]) {
   1072   let bestCharacter = "";
   1073   let bestDistance = Infinity;
   1074   
   1075   for (const { character, shapeVector } of CHARACTERS) {
   1076     const dist = getDistance(shapeVector, inputVector);
   1077     if (dist < bestDistance) {
   1078       bestDistance = dist;
   1079       bestCharacter = character;
   1080     }
   1081   }
   1082   
   1083   return bestCharacter;
   1084 }
   1085 
   1086 I tried benchmarking this for input sampling vectors on my MacBook — K
   1087 invocations of this function consistently take about ms. If we want to be able
   1088 to use this for an animated canvas at FPS, we only have ms to render each
   1089 frame. We can use this to get a rough budget for how many lookups we can
   1090 perform each frame:
   1091 
   1092 If we allow ourselves of the performance budget for just lookups, this gives us
   1093 a budget of about K characters. Not terrible, but far from great, especially
   1094 considering that we’re using numbers from a powerful laptop. A mobile device
   1095 might have a times lower budget. Let’s see how we can improve this.
   1096 
   1097 k-d trees
   1098 
   1099 -d trees are a data structure that enables nearest-neighbor lookups in
   1100 multi-dimensional (-dimensional) space. Their performance [56]degrades in
   1101 higher dimensions (e.g. ), but they perform well in dimensions — perfect for
   1102 our purpose.
   1103 
   1104 Internally, -d trees are a binary tree where each node is a -dimensional point.
   1105 Each node can be thought to split the -dimensional space in half with a
   1106 hyperplane, with the left subtree on one side of the hyperplane and the right
   1107 subtree on the other.
   1108 
   1109 I won’t go into much detail on -d trees here. You’ll have to look at other
   1110 resources if you’re interested in learning more.
   1111 
   1112 One could also look at the [57]hierarchical navigable small worlds (HNSW)
   1113 algorithm, which [58]Eiríkur pointed me to. It is used for approximate nearest
   1114 neighbor lookups in vector databases, so definitely relevant.
   1115 
   1116 Let’s see how it performs! We’ll construct a -d tree with our characters and
   1117 their associated vectors:
   1118 
   1119 const kdTree = new KdTree(
   1120   CHARACTERS.map(({ character, shapeVector }) => ({
   1121     point: shapeVector,
   1122     data: character,
   1123   }))
   1124 );
   1125 
   1126 We can now perform nearest-neighbor lookups on the -d tree:
   1127 
   1128 const result = kdTree.findNearest(samplingVector);
   1129 
   1130 Running K such lookups takes about ms on my MacBook. That’s about x faster than
   1131 the brute-force approach. We can use this to calculate, roughly, the number of
   1132 lookups we can perform per frame:
   1133 
   1134 That’s a lot of lookups per frame, but again, we’re benchmarking on a powerful
   1135 machine. This is still not good enough.
   1136 
   1137 Let’s see how we can eke out even more performance.
   1138 
   1139 Caching
   1140 
   1141 An obvious avenue for speeding up lookups is to cache the result:
   1142 
   1143 function searchCached(samplingVector: number[]) {
   1144   const key = generateCacheKey(samplingVector)
   1145   
   1146   if (cache.has(key)) {
   1147     return cache.get(key)!;
   1148   }
   1149   
   1150   const result = search(samplingVector);
   1151   cache.set(key, result);
   1152   return result;
   1153 }
   1154 
   1155 But how does one generate a cache key for a -dimensional vector?
   1156 
   1157 Well, one way is to quantize each vector component so that it fits into a set
   1158 number of bits and packing those bits into a single number. JavaScript numbers
   1159 give us bits to work with, so each vector component gets bits.
   1160 
   1161 We can quantize a numeric value between and to the range to (the most that bits
   1162 can store) like so:
   1163 
   1164 const BITS = 5;
   1165 const RANGE = 2 ** BITS;
   1166 
   1167 function quantizeTo5Bits(value: number) {
   1168   return Math.min(RANGE - 1, Math.floor(value * RANGE));
   1169 }
   1170 
   1171 Applying a max of RANGE - 1 is done so that a value of exactly is mapped to
   1172 instead of .
   1173 
   1174 We can quantize each of the sampling vector components in this manner and use
   1175 bit shifting to pack all of the quantized values into a single number like so:
   1176 
   1177 const BITS = 5;
   1178 const RANGE = 2 ** BITS;
   1179 
   1180 function generateCacheKey(vector: number[]): number {
   1181   let key = 0;
   1182   for (let i = 0; i < vector.length; i++) {
   1183     const quantized = Math.min(RANGE - 1, Math.floor(vector[i] * RANGE));
   1184     key = (key << BITS) | quantized;
   1185   }
   1186   return key;
   1187 }
   1188 
   1189 The RANGE is current set to 2 ** 5, but consider how large that makes our key
   1190 space. Each vector component is one of possible values. With vector components,
   1191 that makes the total number of possible keys , which equals . If the cache were
   1192 to be fully saturated, just storing the keys would take GB of memory! I’d also
   1193 expect the cache hit rate to be incredibly low if we were to lazily fill the
   1194 cache.
   1195 
   1196 Alright, is too high, but what value should we pick? We can pick any number
   1197 under for our range. To help, here’s a table showing the number of possible
   1198 keys (and the memory needed to store them) for range values between and :
   1199 
   1200 Range Number of keys Memory needed to store keys
   1201 6     46,656         364 KB
   1202 7     117,649        919 KB
   1203 8     262,144        2.00 MB
   1204 9     531,441        4.05 MB
   1205 10    1,000,000      7.63 MB
   1206 11    1,771,561      13.52 MB
   1207 12    2,985,984      22.78 MB
   1208 
   1209 There are trade-offs to consider here. As the range gets smaller, the quality
   1210 of the results drops. If we pick a range of , for example, the only possible
   1211 lightness values are , , , , and . That noticeably affects the quality of
   1212 character picks.
   1213 
   1214 At the same time, if we increase the possible number of keys, we need more
   1215 memory to store them. Additionally, the cache hit rate might be very low,
   1216 especially when the cache is relatively empty.
   1217 
   1218 I ended up picking a range of . It’s a large enough range that quality doesn’t
   1219 suffer too much while keeping the cache size reasonably low.
   1220 
   1221 Cached lookups are incredibly fast — fast enough that lookup performance just
   1222 isn’t a concern anymore (K lookups take a few ms on my MacBook). And if we
   1223 prepopulate the cache, we can expect consistently fast performance, though I
   1224 encountered no problems just lazily populating the cache.
   1225 
   1226 Appendix II: GPU acceleration
   1227 
   1228 Lookups were not the only performance concern. Just collecting the sampling
   1229 vectors (internal and external) turned out to be terribly expensive.
   1230 
   1231 Just consider the sheer amount of samples that need to be collected. The 3D
   1232 scene I’ve been using as an example uses a grid, which equals cells. For each
   1233 of those cells, we compute a -dimensional sampling vector and a -dimensional
   1234 external sampling vector. That is more than K vector components to compute on
   1235 every frame!
   1236 
   1237 And that’s if we use a sampling quality of . If we increase the sampling
   1238 quality, this number just gets bigger.
   1239 
   1240 Collecting these samples absolutely crushed performance on my iPhone, so I
   1241 needed to either collect fewer samples or speed up the collection of samples.
   1242 Collecting fewer samples would have meant rendering fewer ASCII characters or
   1243 removing the directional contrast enhancement, neither of which was an
   1244 appealing solution.
   1245 
   1246 My initial implementation ran on the CPU, which could only collect one sample
   1247 at a time. To speed this up, I moved the work of sampling collection and
   1248 applying the contrast enhancement to the GPU. The pipeline for that looks like
   1249 so (each of the steps listed is a single shader pass):
   1250 
   1251  1. Collect the raw internal sampling vectors into a texture, using the canvas
   1252     (image) as the input texture.
   1253  2. Do the same for the external sampling vectors.
   1254  3. Calculate the maximum external value affecting each internal vector
   1255     component into a texture.
   1256  4. Apply directional contrast enhancement to each sampling vector component,
   1257     using the maximum external values texture.
   1258  5. Calculate the maximum value for each internal sampling vector into a
   1259     texture.
   1260  6. Apply global contrast enhancement to each sampling vector component, using
   1261     the maximum internal values texture.
   1262 
   1263 I’m glossing over the details because I could spend a whole other post covering
   1264 them, but moving work to the GPU made the renderer many times more performant
   1265 than it was when everything ran on the CPU.
   1266 
   1267 Mailing list
   1268 
   1269 To be notified of new posts, subscribe to my mailing list.
   1270 
   1271 [64][                    ]Subscribe
   1272 [66]Alex Harri
   1273 
   1274 © 2026 Alex Harri Jónsson
   1275 
   1276 Links
   1277 
   1278 [67]GitHub
   1279 
   1280 [68]LinkedIn
   1281 
   1282 [69]RSS
   1283 
   1284 Pages
   1285 
   1286 [70]Home
   1287 
   1288 [71]Blog
   1289 
   1290 [72]About
   1291 
   1292 [73]Snippets
   1293 
   1294 The monospace font on this website is [74]MonoLisa, courtesy of [75]FaceType.
   1295 
   1296 
   1297 References:
   1298 
   1299 [1] https://alexharri.com/
   1300 [2] https://alexharri.com/about
   1301 [3] https://alexharri.com/blog
   1302 [4] https://chatgpt.com/share/69524279-7564-800f-ae22-a2f433794abe
   1303 [5] https://en.wikipedia.org/wiki/Cel_shading
   1304 [6] https://cognition.ai/
   1305 [7] https://www.ascii-code.com/characters/printable-characters
   1306 [8] https://en.wikipedia.org/wiki/Monospaced_font
   1307 [9] https://en.wikipedia.org/wiki/RGB_color_model
   1308 [10] https://en.wikipedia.org/wiki/Relative_luminance#Relative_luminance_and_%22gamma_encoded%22_colorspaces
   1309 [15] https://en.wikipedia.org/wiki/Nearest-neighbor_interpolation
   1310 [16] https://en.wikipedia.org/wiki/Jaggies
   1311 [17] https://en.wikipedia.org/wiki/Supersampling
   1312 [18] https://en.wikipedia.org/wiki/Spatial_anti-aliasing
   1313 [22] https://en.wikipedia.org/wiki/Euclidean_distance
   1314 [48] https://en.wikipedia.org/wiki/Word_embedding
   1315 [49] https://alexharri.com/blog/ascii-rendering#character-lookup-performance
   1316 [50] https://alexharri.com/blog/ascii-rendering#appendix-gpu-acceleration
   1317 [51] https://www.linkedin.com/in/gunnlaugur-briem/
   1318 [52] https://eirikur.dev/
   1319 [56] https://graphics.stanford.edu/~tpurcell/pubs/search.pdf
   1320 [57] https://en.wikipedia.org/wiki/Hierarchical_navigable_small_world
   1321 [58] https://eirikur.dev/
   1322 [66] https://alexharri.com/
   1323 [67] https://github.com/alexharri
   1324 [68] https://www.linkedin.com/in/alex-harri-j%C3%B3nsson-b1273613b/
   1325 [69] https://alexharri.com/rss.xml
   1326 [70] https://alexharri.com/
   1327 [71] https://alexharri.com/blog
   1328 [72] https://alexharri.com/about
   1329 [73] https://alexharri.com/snippets
   1330 [74] https://www.monolisa.dev/
   1331 [75] https://www.facetype.org/