close
close

Create debug text in WGSL

When you’re dealing with code on the GPU, one of the more difficult things is that you have very few ways to debug code. Since there is no printing functionality, you usually have to write out pixel colors. This is good for getting an idea of ​​a gradient, but not very useful for seeing if an actual value is what is expected. The latter I usually find myself recompiling with different if statements to tighten up what the value actually contains, and it’s pretty terrible.

One day I came across a little trick on Twitter, but of course with engagement algorithms I could never find it again. But the idea was to add functions to shader code that could actually print values. Although I don’t remember the full details, I wanted to recreate the idea.

Create an inline font

I’m going to start by defining a simple font for the numbers 0-9. It looks like this:

Image description

Probably too small to see. Here it is inflated 25x:

Image description

Each character is 3×5 pixels, which I have found to be the minimum needed for a readable font. The order is also important; 0 should be at the beginning so we can easily calculate offsets. We can convert that into 5 binary numbers representing the rows:

0b11101011111110111110011111111100
0b10101000100110110010000110110100
0b10101001001111111011100111111100
0b10101010000100100110100110100100
0b11101011111100111111100111100100
Go to full screen mode

Exit full screen mode

That’s the following in hexadecimal form:

0xebfbe7fc
0xa89b21b4
0xa93fb9fc
0xaa1269a4
0xebf3f9e4
Go to full screen mode

Exit full screen mode

And defined as the following WGSL constant:

const font = array(
    0xebfbe7fcu,
    0xa89b21b4u,
    0xa93fb9fcu,
    0xaa1269a4u,
    0xebf3f9e4u
);
Go to full screen mode

Exit full screen mode

This is 20 bytes of data.

We could try compressing it even further by removing the whitespace on the 1 and reusing the columns on 8, 9, 0. This can save us 30 bits, but then we have to keep track of the offsets and lengths for each character, which will add more than 30 bits unless they do that too. are compressed somehow. Given that bytes usually need to be aligned anyway, there’s really no good reason to try to compress it any further, because we can’t really gain much and the complexity will skyrocket.

Writing a character

To write a character from our font, we need the character, the position where we need to draw (the top left corner) and the position of the current pixel being drawn in this call to the fragment shader.

First we take the character and use it to grab the offset in the font. We know that each character is 3 pixels wide and we have formatted it horizontally.

let offset = char * 3u;
Go to full screen mode

Exit full screen mode

Next we want to get the image data for the character at that offset:

let rows = array(
    (font(0) >> (29 - offset)) & 0x07,
    (font(1) >> (29 - offset)) & 0x07,
    (font(2) >> (29 - offset)) & 0x07,
    (font(3) >> (29 - offset)) & 0x07,
    (font(4) >> (29 - offset)) & 0x07
);
Go to full screen mode

Exit full screen mode

There may be a better way to do this, but I’m no shader wizard. What I do is create a new array by shifting the pixels and grabbing the last 3 bits with a bitmask 0x07 what would look like if WGSL supported binary literals 0b111. Unfortunately, it looks like I have to use an array instead of a vector, because vectors contain a maximum of 4 elements. The offset is 29 bits by default, which gives us the last 3 bits of the 0 sign if offset by 0.

When we get the fragment coordinates using a built-in WGSL, they are going to use a f32This means we have to cast them, because bit math doesn’t really make sense with floats.

var x = u32(frag_position.x) - position.x;
var y = u32(frag_position.y) - position.y;
Go to full screen mode

Exit full screen mode

We also draw the position so that we start writing from the specified position. If we go beyond the limits, we can fail immediately:

if x > 2 { return 0.0; }
if y > 4 { return 0.0; }
Go to full screen mode

Exit full screen mode

And finally we can return the value:

return ((rows(y) >> (2 - u32(x))) & 0x01) == 1;
Go to full screen mode

Exit full screen mode

This further reduces the range to 0-2 in x and 0-4 in y and indexes the font, using the same shift and mask technique as above. Again, I’m sure this can be more efficient if you play around with it, but I think this is easy enough to understand.

The function:

fn is_in_digit(frag_position: vec2, char: u32, position: vec2) -> f32 {
    let offset = char * 3u;
    let rows = array(
        (font(0) >> (29 - offset)) & 0x07,
        (font(1) >> (29 - offset)) & 0x07,
        (font(2) >> (29 - offset)) & 0x07,
        (font(3) >> (29 - offset)) & 0x07,
        (font(4) >> (29 - offset)) & 0x07
    );

    let x = u32(frag_position.x) - position.x;
    let y = u32(frag_position.y) - position.y;

    if(x > 2){ return 0.0; }
    if(y > 4){ return 0.0; }

    return ((rows(y) >> (2 - u32(x))) & 0x01) == 1;
}
Go to full screen mode

Exit full screen mode

And we can draw a figure:

Image description

Write an entire string

To write multiple digits as they would be present in a real number, we need to create a loop over them.

fn is_in_number(frag_position: vec2, digits: array, position: vec2) -> bool {
    var i: u32 = 0;
    var current_position = position.xy;

    loop {
        if i > max_number_length - 1 { return false; }
        if is_in_digit(frag_position, digits(i), vec2(current_position.x + (i * 3) + i, current_position.y)) {
      return true;
    }
        i = i + 1;
    }
}
Go to full screen mode

Exit full screen mode

This runs over the series of numbers. If we are outside the space that would be displayed, we return false or true if it is in a number. We test each number until we exhaust them all or find a match. Note the value max_number_length. WGSL doesn’t let us have dynamic arrays, but we might want more than 4 digits, so we can’t vec4. This constant allows us to configure how large we want it to be. We can configure it as a compile constant.

const max_number_length: u32 = 4;
Go to full screen mode

Exit full screen mode

If you try to pass an array without a certain size defined, you’ll get an error saying it’s not the right storage and we’ll need to configure something more complex, so I used this for the initial design. The other thing to note is that we have the term + i after obtaining the current position plus (i *3) (we move 3 pixels to the right for each new digit). The extra i adds distance between the numbers. You can multiply that by another constant to change the character spacing.

Convert a number into a string

Unfortunately, we now have to do more manual work. We don’t want to deal with strings of numbers, we just want to use numbers. So now we need a function that converts numbers into series of numbers.

fn number_to_digits(value: f32) -> array {
    var digits = array();
    var num = value;

    if(num == 0){
        return digits;
    }

    var i: u32 = 0;
    loop{
        if num = max_number_length { break; }
        digits(max_number_length - i - 1) = u32(num % 10);
        num = floor(num / 10);
        i = i + 1;
    }
    return digits;
}
Go to full screen mode

Exit full screen mode

We use that again max_number_length constant to define the maximum digit length. Please note that unlike JavaScript, we cannot define digits of let (immutable) because the array elements are also immutable! With this implementation the numbers will increase max_number_length will be truncated on the left and numbers smaller will be padded with zeros, so be careful. The algorithm divides the number by 10 and adds the remainder to the array and continues until the remainder is present 0 (after carpeting). This can be adjusted a bit. If you need a floating point, you can remove the floor and set a minimum size where you need to stop (not 0, otherwise you’ll have a lot of digits to carry around and you’ll probably get stuck in a long loop). I don’t do this because I have no way to do the . yet they are simply integers. You can also change the base. When you change 10 when dividing and modulo to another number, use that base. So 2 would give you the number in binary and 16 will give it in hex if you want to use those bases.

Make it bigger

So now we can write the numbers completely in the pixel shader on the screen! This isn’t great though, as on any sensible screen without zooming in the text is far too small. We need a way to make it bigger. When drawing we need a scale factor.

fn is_in_digit(frag_position: vec2, char: u32, position: vec2, scale: f32) -> bool {
    let offset = char * 3u;
    let rows = array(
        (font(0) >> (29 - offset)) & 0x07,
        (font(1) >> (29 - offset)) & 0x07,
        (font(2) >> (29 - offset)) & 0x07,
        (font(3) >> (29 - offset)) & 0x07,
        (font(4) >> (29 - offset)) & 0x07
    );

    let bump = -0.0001; //this make fractions like 3/3 fall under a whole number.
    let x = i32(floor(((frag_position.x - f32(position.x)) - bump) / scale));
    let y = i32(floor(((frag_position.y - f32(position.y)) - bump) / scale));

    if x > 2 || x  4 || y > (2 - u32(x))) & 0x01) == 1;
}
Go to full screen mode

Exit full screen mode

We include a scale factor parameter. We use it to reduce the x and y values. By dividing by the scale factor we actually divide the whole number into partial values. For example, if the scale factor was 4, the values ​​would be 1,2,3,4 (0.25, 0.5 0.75, 1) and the values ​​would be 5,6,7,8 (1.25, 1, 5, 1.75, 2). That is, the whole number is 0 or 1 for those 4 values…almost. The last digit is always an integer, so by the bump value we make it a little smaller, so that the integer is 0 and 1 respectively. Maybe there was a more elegant way to do that?

We also need to take negative values ​​into account, because they matter this time, so we just throw them away as false.

Finally, we can do it through our pipes is_in_digit function:

fn is_in_number(frag_position: vec2, digits: array, position: vec2, scale: f32) -> bool {
    var i: u32 = 0;
    var current_position = position.xy;

    loop {
        if i > max_number_length - 1 { return false; }
        let digit_size = u32(3 * scale);
        let spacing_size = u32(f32(i) * scale);
        if is_in_digit(frag_position, digits(i), vec2(current_position.x + (i * digit_size) + spacing_size, current_position.y), scale) {
            return true;
        }
        i = i + 1;
    }
}

Go to full screen mode

Exit full screen mode

We have to move 3 * scale units per digit. I also scaled the distance value and took them out as variables so they are a little easier to read. I’m not in love with the amount of type conversion though, maybe I should have used that f32s for position? Lessons learned.

Image description

It allows us to represent whole integers in a fixed set of digits. The full WGSL code:

const screen_tri = array(
    vec2f(-3.0, -1.0),   // bottom left
    vec2f( 1.0, -1.0),   // bottom right
    vec2f( 1.0,  3.0),   // top right
);
const font = array(
    0xebfbe7fcu,
    0xa89b21b4u,
    0xa93fb9fcu,
    0xaa1269a4u,
    0xebf3f9e4u
);
const max_number_length: u32 = 4;

struct FragIn {
    @builtin(position) position : vec4
}
struct Params {
    height: f32,
    width: f32,
    percent: f32
}

@group(0) @binding(0) var params: Params;

fn get_sd_circle(pos: vec2, r: f32) -> f32 {
    return length(pos) - r;
}

fn get_view_coords(coords: vec2, screen_dims: vec2) -> vec2{
    return ((coords / screen_dims) * 2) - 1;
}

fn is_in_digit(frag_position: vec2, char: u32, position: vec2, scale: f32) -> bool {
    let offset = char * 3u;
    let rows = array(
        (font(0) >> (29 - offset)) & 0x07,
        (font(1) >> (29 - offset)) & 0x07,
        (font(2) >> (29 - offset)) & 0x07,
        (font(3) >> (29 - offset)) & 0x07,
        (font(4) >> (29 - offset)) & 0x07
    );

    let bump = -0.0001; //this make fractions like 3/3 fall under a whole number.
    let x = i32(floor(((frag_position.x - f32(position.x)) - bump) / scale));
    let y = i32(floor(((frag_position.y - f32(position.y)) - bump) / scale));

    if x > 2 || x  4 || y > (2 - u32(x))) & 0x01) == 1;
}

fn is_in_number(frag_position: vec2, digits: array, position: vec2, scale: f32) -> bool {
    var i: u32 = 0;
    var current_position = position.xy;

    loop {
        if i > max_number_length - 1 { return false; }
        let digit_size = u32(3 * scale);
        let spacing_size = u32(f32(i) * scale);
        if is_in_digit(frag_position, digits(i), vec2(current_position.x + (i * digit_size) + spacing_size, current_position.y), scale) {
            return true;
        }
        i = i + 1;
    }
}

fn number_to_digits(value: f32) -> array {
    var digits = array();
    var num = value;

    if(num == 0){
        return digits;
    }

    var i: u32 = 0;
    loop{
        if num = max_number_length { break; }
        digits(max_number_length - i - 1) = u32(num % 10);
        num = floor(num / 10);
        i = i + 1;
    }
    return digits;
}

@vertex
fn vertex_main(@builtin(vertex_index) vertexIndex : u32) -> @builtin(position) vec4
{
    return vec4(screen_tri(vertexIndex), 0.0, 1.0);
}

@fragment
fn fragment_main(fragData: FragIn) -> @location(0) vec4
{
    var dims = vec2(params.width, params.height);
    var view_coords = get_view_coords(fragData.position.xy, dims);
    var digits = number_to_digits(75);

    if is_in_number(fragData.position.xy, number_to_digits(75), vec2(10, 10), 3.0) {
        return vec4(1.0, 0.0, 0.0, 1.0);
    }

    return vec4(0.0, 0.0, 0.0, 0.0);
}
Go to full screen mode

Exit full screen mode

And a real demo: