Go Back

Varun Narayanan

November 27, 2025

Ascii art using webgpu and 3js

How I created this ascii art using 3js and webgpu

Ascii art using webgpu and 3js

How to get started

To get started which is the hard part we need a initial setup template which I already have, which only sets up the palette for us to work on, kindly clone this link. I have learned this from yuri artiukh's youtube video on ascii art so do check him out, if you have the starter template you are ready to go

The Core ConceptThe effect works by:

  1. Analyzing the brightness of each pixel in a source image

  2. Mapping that brightness to an ASCII character

  3. Applying a synthwave-inspired color palette based on brightness levels

  4. Rendering everything using GPU-instanced geometry for performance

  5. Let's dive into how each piece works.

Part 1: Setting Up the ASCII Character Texture

TypeScript

1

createAsciiTexture() {

2

let dict = `.-':_,^=;><+!rc*/z?sLTv)J7(|Fi{C}fI31tlu[neoZ5Yxjya]2ESwqkP6h9d4VpOGbUAKXHm8RD#$Bg0MNWQ%&@`;

3

let canvas = document.createElement("canvas");

4

let ctx = canvas.getContext("2d");

5

this.length = dict.length;

6

canvas.width = this.length * 64;

7

canvas.height = 64;

8

}

The ASCII Dictionary: The characters are ordered from darkest (.) to brightest (@). Each character represents a different brightness level. Canvas Texture Creation: We create a horizontal strip texture where each character occupies a 64x64 pixel cell. This allows the shader to sample different characters by adjusting the UV coordinates.

Blur Effect for Dense Characters: Notice the special handling for characters after index 50:

TypeScript

1

if (i > 50) {

2

for (let j = 0; j < 6; j++) {

3

ctx.filter = `blur(${j}px)`;

4

ctx.fillText(dict[i], 32 + i * 64, 40);

5

}

6

}

This creates a glow effect for the densest characters, adding visual depth.

Part 2: Creating the Instanced Mesh Grid

TypeScript

1

let rows = 180;

2

let columns = 180;

3

let instances = rows * columns; // 32,400 instances

4

let size = 0.1;

Instead of creating 32,400 individual plane geometries, we use InstancedMesh for massive performance gains. Each instance represents one "pixel" in our ASCII grid.

Position Calculation:

TypeScript

1

this.positions[index * 3] = i * size - (size * (rows - 1)) / 2;

2

this.positions[index * 3 + 1] = j * size - (size * (columns - 1)) / 2;

3

this.positions[index * 3 + 2] = 0;

This centers the grid around the origin, creating a plane from -9 to +9 in both X and Y directions.

Custom Attributes: We add two crucial custom attributes:

  1. aPixelUV: Maps each instance to a coordinate in the source image
  2. aRandom: Adds subtle randomness to prevent banding artifacts
TypeScript

1

this.geometry.setAttribute(

2

GeometryAttribute.aPixelUV,

3

new THREE.InstancedBufferAttribute(uv, 2)

4

);

Part 3: The Shader Magic

Now for the most interesting part - the material shader that brings everything together. Color Palette Setup

TypeScript

1

const palette = ["#8c1dff", "#f223ff", "#ff2976", "#ff901f", "#ffd318"];

2

const uColor1 = uniform(color(palette[0])); // color changes hex color code to three.js compatible color code

3

const uColor2 = uniform(color(palette[1]));

4

const uColor3 = uniform(color(palette[2]));

5

const uColor4 = uniform(color(palette[3]));

6

const uColor5 = uniform(color(palette[4]));

We define a synthwave gradient from purple through pink, red, orange, to yellow. These colors will be applied based on brightness.

Brightness Calculation

TypeScript

1

const textureColor = texture( uTexture, attribute(GeometryAttribute.aPixelUV) );

2

const brightness = pow(textureColor.r, 1.4).add( attribute(GeometryAttribute.aRandom).mul(0.01) );

Key points:

We sample the source image using our custom aPixelUV attribute pow(textureColor.r, 1.4) applies a gamma correction to enhance contrast Adding a tiny random value breaks up color banding

ASCII Character Selection

TypeScript

1

const asciiUV = vec2(

2

uv().x.div(length).add(floor(brightness.mul(length)).div(length)),

3

uv().y

4

);

This is the clever part! We manipulate the UV coordinates to select the right character:

TypeScript

1

floor(brightness.mul(length)) //converts brightness (0-1) to a character index (0-92)

2

.div(length) //normalizes it back to UV space

3

uv().x.div(length) //scales down the X coordinate to fit within one character cell

Adding them together gives us the correct horizontal position in our texture strip

Color Mixing Based on Brightness

TypeScript

1

let finalColor = uColor1;

2

finalColor = mix(finalColor, uColor2, step(0.2, brightness));

3

finalColor = mix(finalColor, uColor3, step(0.4, brightness));

4

finalColor = mix(finalColor, uColor4, step(0.6, brightness));

5

finalColor = mix(finalColor, uColor5, step(0.8, brightness));

The step() function creates hard transitions:

Below 0.2 brightness: Color 1 (purple) 0.2-0.4: Color 2 (pink) 0.4-0.6: Color 3 (red) 0.6-0.8: Color 4 (orange) Above 0.8: Color 5 (yellow)

Final Composition

TypeScript

1

return asciiColor.mul(finalColor);

We multiply the ASCII texture color by our gradient color, creating the final colored ASCII art.

Part 4: Rendering Loop

TypeScript

1

render() {

2

if (!this.isPlaying) return;

3

this.time += 0.05;

4

requestAnimationFrame(this.render.bind(this));

5

this.renderer.renderAsync(this.scene, this.camera);

6

}

Using renderAsync() is crucial for WebGPU - it allows the GPU to work asynchronously for better performance. Performance Considerations

Why This Approach is Fast:

GPU Instancing: One draw call renders 32,400 squares

Texture-based ASCII: No text rendering per frame

Shader-based computation: All brightness calculations happen in parallel on the GPU

WebGPU: Modern API with lower overhead than WebGL

Conclusion

This ASCII art effect combines clever texture manipulation, GPU instancing, and procedural color grading to create a stunning visual effect that runs smoothly even with tens of thousands of instances. The key insights are:

1.Using a horizontal texture strip for efficient character lookup

2.Leveraging brightness-to-UV mapping for character selection

3.Applying instanced rendering for performance

4.Creating smooth color transitions with step functions

Live demo is right here