DEV Community

ndesmic
ndesmic

Posted on

Creating debug text in WGSL

When 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 print functionality you typically have to write out pixel colors. This is good for getting a sense of a gradient but is not very useful for seeing if an actual value is what is expected. The latter I typically find myself recompiling with different if statements to hone in on what the value actually contains and it's pretty awful.

I did one day encounter a little trick on Twitter but of course with engagement algorithms I could never find it again. But the idea was adding functions into shader code that could actually print values. While I don't remember the complete details, I set out to recreate the idea.

Making an inline font

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

Image description

Probably too small to see. Here's it blown up 25x:

Image description

Each character is 3x5 pixels which I determined to be the minimum necessary for a legible font. The order is important too, 0 should be at the beginning so that we can easily calculate offsets. We can convert that into 5 binary numbers representing the rows:

0b11101011111110111110011111111100
0b10101000100110110010000110110100
0b10101001001111111011100111111100
0b10101010000100100110100110100100
0b11101011111100111111100111100100
Enter fullscreen mode Exit fullscreen mode

Which is the following in hex:

0xebfbe7fc
0xa89b21b4
0xa93fb9fc
0xaa1269a4
0xebf3f9e4
Enter fullscreen mode Exit fullscreen mode

And defined as the following WGSL constant:

const font = array(
    0xebfbe7fcu,
    0xa89b21b4u,
    0xa93fb9fcu,
    0xaa1269a4u,
    0xebf3f9e4u
);
Enter fullscreen mode Exit fullscreen mode

This is 20 bytes of data.

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

Writing a character

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

First we take the character and use it to grab the offset into the font. We know each character is 3 pixels wide and we've laid it out horizontally.

let offset = char * 3u;
Enter fullscreen mode Exit fullscreen mode

Then 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
);
Enter fullscreen mode Exit fullscreen mode

Now there might be a better way to do this but I'm not a shader wizard. What I'm doing is making a new array by shifting the pixels and grabbing the last 3 bits with a bitmask 0x07 which if WGSL supported binary literals would look like 0b111. Sadly, it seems like I have to use an array instead of a vector because vectors top out at 4 elements. The shift is by default 29 bits which would leave us with the last 3 bits of the 0 character if offset by 0.

When we get the fragment coordinates using a WGSL built-in they come to use a f32s meaning we need 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;
Enter fullscreen mode Exit fullscreen mode

We also subtract the position such that we start writing from the specified position. If we go out-of-bounds then we can immediately fail:

if x > 2 { return 0.0; }
if y > 4 { return 0.0; }
Enter fullscreen mode Exit fullscreen mode

And finally we can return the value:

return ((rows[y] >> (2 - u32(x))) & 0x01) == 1;
Enter fullscreen mode Exit fullscreen mode

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

The function:

fn is_in_digit(frag_position: vec2<f32>, char: u32, position: vec2<u32>) -> 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;
}
Enter fullscreen mode Exit fullscreen mode

And we can draw a digit:

Image description

Writing a whole string

To write multiple digits as would be present in an actual number we need to loop over them.

fn is_in_number(frag_position: vec2<f32>, digits: array<u32, max_number_length>, position: vec2<u32>) -> 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

This will loop over the array of digits. If we're outside the space that would be rendered then we return false or true if it lies in any digit. We test each digit until we exhaust them all or find a match. Note the value max_number_length. WGSL does not let us have dynamic arrays but we might want more than 4 digits so we can't use a vec4. This constant allows us to configure how big we want it to be. We can configure it as a compile constant.

const max_number_length: u32 = 4;
Enter fullscreen mode Exit fullscreen mode

If you tried to pass around an array without a size defined then you will get an error about it not being the proper storage and we'd have to configure something more complicated so I went with this for the initial design. The other thing to note is that we have the term + i after getting the current position plus (i *3) (we move right 3 pixels for each new digit). The extra i adds spacing between the digits. You can multiply that by some other constant to change the character spacing.

Turning a number into a "string"

Now sadly we need to do more manual work. We don't want to deal with arrays of digits, we want to just use numbers. So now we need a function that turns numbers into arrays of digits.

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

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

    var i: u32 = 0;
    loop{
        if num < 0 || i >= max_number_length { break; }
        digits[max_number_length - i - 1] = u32(num % 10);
        num = floor(num / 10);
        i = i + 1;
    }
    return digits;
}
Enter fullscreen mode Exit fullscreen mode

Again we use that max_number_length constant to define the maximum digit length. Note that unlike javascript we cannot define digits with let (immutable) as the array elements are also immutable! With this implementation numbers larger max_number_length will be truncated on the left and numbers smaller than that will get padded with 0s so be careful. The algorithm divides the number by 10 and adds the remainder to the array and continues until the remainder is 0 (after flooring). This can be tweaked a little. If you need floating point you can remove the flooring and set some minimum size to stop at (not 0 or you'll have a ton of digits to carry and probably get stuck in a long loop). I'm not doing this because I don't have a way to place the . yet so it's just integers. You can also change the base. If you change 10 in the division and modulo to another number you'll 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.

Making it bigger

So now we can write the numbers to the screen entirely in the pixel shader! However, this isn't great because on any sensible screen without zooming the text is far too small. We need a way to draw it bigger. We'll need a scaling factor when drawing.

fn is_in_digit(frag_position: vec2<f32>, char: u32, position: vec2<u32>, 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 < 0 { return false; }
    if y > 4 || y < 0 { return false; }

    return ((rows[y] >> (2 - u32(x))) & 0x01) == 1;
}
Enter fullscreen mode Exit fullscreen mode

We take in a scaling factor parameter. We use it to scale down the x and y values. By dividing by the scaling factor we essentially slice up the whole number into fractional values. For example if scaling factor was 4 then values 1,2,3,4 would be (0.25, 0.5 0.75, 1) and values 5,6,7,8 would be (1.25, 1.5, 1.75, 2). That is the whole digit is 0 or 1 for those 4 values...almost. The final digit is always a whole number so by subtracting the bump value we make it slightly smaller so the whole number is 0 and 1 respectively. Maybe there was a more elegant way to do that?

We also need to take care of negative values as they matter this time so we just discard them as false.

Lastly, we can pipe it through our is_in_digit function:

fn is_in_number(frag_position: vec2<f32>, digits: array<u32, max_number_length>, position: vec2<u32>, 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;
    }
}

Enter fullscreen mode Exit fullscreen mode

We need to move 3 * scale units per digit. I also scaled the spacing value and pulled them out as variables so they are a little more readable. I'm not in love with the amount of type conversion though, maybe I should have used f32s for position? Lessons learned.

Image description

With this we can render whole integers with 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<f32>
}
struct Params {
    height: f32,
    width: f32,
    percent: f32
}

@group(0) @binding(0) var<uniform> params: Params;

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

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

fn is_in_digit(frag_position: vec2<f32>, char: u32, position: vec2<u32>, 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 < 0 { return false; }
    if y > 4 || y < 0 { return false; }

    return ((rows[y] >> (2 - u32(x))) & 0x01) == 1;
}

fn is_in_number(frag_position: vec2<f32>, digits: array<u32, max_number_length>, position: vec2<u32>, 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<u32, max_number_length> {
    var digits = array<u32, max_number_length>();
    var num = value;

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

    var i: u32 = 0;
    loop{
        if num < 0 || i >= 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<f32>
{
    return vec4(screen_tri[vertexIndex], 0.0, 1.0);
}

@fragment
fn fragment_main(fragData: FragIn) -> @location(0) vec4<f32>
{
    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);
}
Enter fullscreen mode Exit fullscreen mode

And a real demo:

Top comments (0)