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/