Yet another approach to text rendering (WIP)

So many good things above. SDF fonts (ok, ok not really SDF fonts which are a real thing, this is just a good hack toward one). Scale reversals.

We keep pushing these to do something new. It’s so much fun.

1 Like

:rofl:

I was thinking about this from another thread but never tried it. Good to know!

Just updated the code in the first post with the results of the Great Vector Renormalization, plus some other minor things.

Lots more to do before it’s library worthy, but this one’s about twice as fast, looks slightly better, and makes it easier to spell things!

1 Like

But I’ve been converting text characters to hexadecimal ASCII code and making up character bitmaps in my head for the last 40 years (literally!). :laughing:

Oh well, this looks like a really worthwhile improvement. I shall play with it tomorrow.

1 Like

I can steal those vertex coordinates for an antialiased-line font, right?

1 Like

By all means, please do! If you get around to expanding it with numbers and punctuation, I’ll steal it back from you! A good vector font would be a really useful resource.

1 Like

A true SDF font approach would be interesting too. Or even a version of the above using circles and lines.

The “real” sdf fonts that are popular in game engines lately are just textures containing distance fields generated from vector fonts by an algorithm similar to what’s in this pattern. The rendering is done at super high resolution and is then resampled and filtered by the GPU hardware for rendering at whatever size the current frame requires. It does look really good, and is way faster than drawing with TrueType or whatever.

We don’t have the enormous memory of a modern GPU, or the hardware texture compression/decompression mechanisms, so I figured it was reasonable to burn some CPU cycles generating the distance field at frame time instead.

(…and now, the part where I attempt to explain why you’d even want to use this crazy algorithm in the first place… Skip if you already know! :slight_smile: )

The interesting thing about this is that it’s still just a vector font, in this case drawn with an an algorithm that would be insanely inefficient if drawn by a single CPU on a large display. @sorcerer’s version, which will draw the vectors in a frame buffer using a “normal” line drawing algorithm would be tremendously faster. The SDF algorithm asks, for every pixel, “is this pixel inside or outside my shape”. If the number of pixels per processor is large, it’ll be slower than an algorithm that calculates and draws only the required pixels.

There’s context though – the SDF algorithm has some fun and useful features.

  • even with a single core and rendering thread, LED displays are low enough resolution that the pixels/processor ratio is still small enough to make SDFs practical.
  • Texturing the resulting shapes is very simple. The whole shape is treated as one “thing” – no polygons to worry about.
  • manipulating the geometry - collision detection, distortion, mirroring, repeating, constructive geometry, morphing between shapes, etc. - is very nearly trivial.
  • you get high quality anti-aliasing as an inherent feature of the algorithm.
  • and importantly, since it treats each pixel independently, it is intrinsically scalable to any number of processors. This is why it’s so widely used in the GPU world. (And eventually, our LED controllers will have more cores and threads too, and things written this way will simply go faster, without any software changes.)
4 Likes

I’m not sure that it is relevant for fonts on the PB, but thought some of you might appreciate this clever approach to drawing fonts with a GPU:

1 Like

Wow! Here’s a font editor in which each character is a list of lists of coordinates. Each list of N coordinates defines N-1 line(s): https://brutalita.com/

For example the right-pointing arrow glyph is the point of the arrow (2 lines) followed by the shaft:

{"→":[[[1,1],[2,2],[1,3]],[[0,2],[2,2]]], ...}

I’m thinking that to make a text scroller, on your Real Computer you could take only the used characters and put them in a list, and then the various strings you want to display are lists of indexes into that array.

I’m not sure I like it more than graph paper, but someone has already drawn an entire font so I’m going to make a renderer!

1 Like

Hey presto, here it is! Renderer coming up next!

( minor edit: the space character is no segments, and [] is not ok, so it gets magic number -1 )

$ ./packstring.rb < brutalita-1642105672773.json 'PixelBlaze Salamanders'
chars = [[[[0,2.5],[2,2.5],[2,1],[1,0],[0,0],[0,4]]],[[[1,0]],[[0.5,1],[1,1],[1,4],[0.5,4],[1.5,4]]],[[[0,1],[2,4]],[[2,1],[0,4]]],[[[0,2.5],[2,2.5],[2,1.5],[1.5,1],[0.5,1],[0,1.5],[0,3.5],[0.5,4],[1.5,4],[2,3.5]]],[[[0.5,0],[1,0],[1,4],[0.5,4],[1.5,4]]],[[[0,0],[1,0],[2,1],[2,2],[1,2],[2,3],[2,4],[0,4],[0,0]]],[[[0.5,1],[1.5,1],[2,1.5],[2,3],[1,4],[0.5,4],[0,3.5],[0,1.5],[0.5,1]],[[2,1],[2,4]]],[[[0,1],[2,1],[0,4],[2,4]]],[[[2,0],[0.5,0],[0,0.5],[0,1.5],[0.5,2],[1.5,2],[2,2.5],[2,3.5],[1.5,4],[0,4]]],[[[0,1],[0,4]],[[0,1.5],[0.5,1],[1,1.5],[1,3],[1,1.5],[1.5,1],[2,1.5],[2,4]]],[[[0,1],[0,4]],[[0,1.5],[0.5,1],[1.5,1],[2,1.5],[2,4]]],[[[2,0],[2,1.5],[1.5,1],[0.5,1],[0,1.5],[0,3.5],[0.5,4],[1.5,4],[2,3.5],[2,1.5],[2,4]]],[[[0,1],[0,4]],[[0,2],[1,1],[2,1]]],[[[2,1],[0.5,1],[0,1.5],[0,2],[0.5,2.5],[1.5,2.5],[2,3],[2,3.5],[1.5,4],[0,4]]]]
texts = [[0,1,2,3,4,5,4,6,7,3,-1,8,6,4,6,9,6,10,11,3,12,13]]
#!/usr/bin/env ruby

require 'multi_json'

# Output variables
chars = []
texts = []

# Character -> chars[] index tracker
cnums = { ' ' => -1 }
cnum = 0

font = MultiJson.load(STDIN.read)

ARGV.each do |text|
  tcnums = []
  text.chars.each do |char|
    if cnums.key?(char)
      tcnums.append(cnums[char])
    elsif font.key?(char)
      chars[cnum] = font[char]
      cnums[char] = cnum
      tcnums.append(cnum)
      cnum += 1
    else
      warn('Character not in font: ' + char)
    end
  end
  texts.append(tcnums)
end

puts 'chars = ' + MultiJson.dump(chars)
puts 'texts = ' + MultiJson.dump(texts)

Aaaaand here we go! Not bad for 4h from start to finish.

Code coming as soon as I clean it up!

We’re definitely in demoscene territory.

2 Likes

Wow, that’s awesome! Nice find and I can’t wait for the code. Making a custom font, you could do all sorts of fun stuff with it.

Ok! Now with a tripod video showing the motion blur and italicization sliders, and … well who doesn’t like rainbows?

// PixelBlaze pattern by Tom Rathborne <tom.rathborne@gmail.com>
// Made for fun. Share and enjoy!

// This pattern creates a rectangular framebuffer
// and does its own integer coordinate mapping
// and draws a text scroller with fonts made at https://brutalita.com/
// using Xiaolin Wu's antialiased line algorithm.

// See the bottom of this pattern for a Ruby program which generates these two lines:
chars = [[[[0,2.5],[2,2.5],[2,1],[1,0],[0,0],[0,4]]],[[[1,0]],[[0.5,1],[1,1],[1,4],[0.5,4],[1.5,4]]],[[[0,1],[2,4]],[[2,1],[0,4]]],[[[0,2.5],[2,2.5],[2,1.5],[1.5,1],[0.5,1],[0,1.5],[0,3.5],[0.5,4],[1.5,4],[2,3.5]]],[[[0.5,0],[1,0],[1,4],[0.5,4],[1.5,4]]],[[[0,0],[1,0],[2,1],[2,2],[1,2],[2,3],[2,4],[0,4],[0,0]]],[[[0.5,1],[1.5,1],[2,1.5],[2,3],[1,4],[0.5,4],[0,3.5],[0,1.5],[0.5,1]],[[2,1],[2,4]]],[[[0,1],[2,1],[0,4],[2,4]]],[[[1,3],[0,2],[0,1.5],[0.5,1],[1,1.5],[1.5,1],[2,1.5],[2,2],[1,3]]],[[[0,1],[0,4]],[[0,2],[1,1],[2,1]]],[[[0,1],[0,3.5],[0.5,4],[1.5,4],[2,3.5],[2,1]]],[[[0,1],[2,1]],[[1,0],[1,3.5],[1.5,4],[2,4]]]]
texts = [[0,1,2,3,4,5,4,6,7,3,-1,8,-1,5,9,10,11,6,4,1,11,6,-1,8,-1]]
// FIXME: So far this pattern only uses texts[0]

// value by which we mulitply each pixel on every frame for motion blur
// more than 50% blur is excessive so /2
var blur = 0
export function sliderMotionBlur(v) { blur = v/2 }

// Duplicate PixelBlaze 'Mapper' functionality without normalizing

var width = 32
var height = 8
// whoops = 1/(pixelCount == (width * height)) // crash on user error :P

// Some constants to avoid repeated arithmetic (esp.division) on constants
var widthm1 = width - 1
var widthm1_inv = 1/widthm1
var heightm1_inv = 1/(height-1)
var heightm1_invR = 0.33333333/(height-1) // How much of a rainbow we want

// Italicization
var italic = 0
export function sliderItalicization(v) { italic = 0.5-v }

// RGB framebuffer
// ... Storage
var FB = array(pixelCount)
FB.mutate(()=>{return array(3)})

// ... direct references to pixel by index for a quick render
var IndexPixel = array(pixelCount)
IndexPixel.mutate((foo,index)=>{
  var x = floor(index / height)
  var y = index % height
  y = (x % 2 == 1) ? (height - 1 - y) : y // I have a zigzag LED matrix
  return FB[x + y * width]
})

// HSV to RGB using global variables
var r, g, b // filled by HSVtoRGB

function HSVtoRGB(h, s, v) {
  var i, f, p, q, t;
  i = floor(h * 6);
  f = h * 6 - i;
  p = v * (1 - s);
  q = v * (1 - f * s);
  t = v * (1 - (1 - f) * s);
  im6 = i % 6
  if (im6 == 0) {
    r = v; g = t; b = p;
  } else { if (im6 == 1) {
    r = q; g = v; b = p;
  } else { if (im6 == 2) {
    r = p; g = v; b = t;
  } else { if (im6 == 3) {
    r = p; g = q; b = v;
  } else { if (im6 == 4) {
    r = t; g = p; b = v;
  } else {if (im6 == 5) {
    r = v; g = p; b = q;
  }}}}}}
}

// ... Render a pixel
//  - Only used by WuLine, so we do 'alpha' here to keep WuLine readable
//  - Uses max() so overlapping line pixels don't make bright spots
//  - XadjustSV makes the left/right areas fade to black via S and V.

var XadjustSV = array(width)
XadjustSV.mutate((foo,x) => { return clamp(1.1 - pow(2*((x*widthm1_inv)-0.5),2),0,1) })

function PixelMax(x,y,a,h,s,v) {
  if (x >= 0 && y >= 0 && x < width && y < height) {
    adjSV = XadjustSV[x]
    HSVtoRGB(
      (h + y*heightm1_invR) % 1, // rainbow gradient in Y 
      s*adjSV,
      v*adjSV*adjSV*a*a*a // ^2 and ^3 here are some excuse for gamma
    )
    var Pixel = FB[x + y * width]
    Pixel[0] = max(Pixel[0], r)
    Pixel[1] = max(Pixel[1], g)
    Pixel[2] = max(Pixel[2], b)
  }
}

// Xiaolin Wu's antialiased line algorithm
// Adapted from https://gist.github.com/polyamide/3f33cb4dc69e22fbf8b66cee39b78d60
// (replaced utility functions with PixelBlaze built-ins)

function WuLine(DrawPixel, x0, y0, x1, y1, h, s, v) {
  if (x0 == x1 && y0 == y1) return

  steep = abs(y1 - y0) > abs(x1 - x0);

  if (steep) {
    tmp = y0; y0 = x0; x0 = tmp;
    tmp = y1; y1 = x1; x1 = tmp;
  }

  if (x0 > x1) {
    tmp = x0; x0 = x1; x1 = tmp;
    tmp = y0; y0 = y1; y1 = tmp;
  }

  dx = x1 - x0;
  dy = y1 - y0;
  gradient = dy / dx;

  xEnd = round(x0);
  yEnd = y0 + gradient * (xEnd - x0);
  xGap = 1 - frac(x0 + 0.5);
  xPx1 = xEnd;
  yPx1 = trunc(yEnd);

  if (steep) {
    DrawPixel(yPx1, xPx1, 1 - frac(yEnd) * xGap, h, s, v )
    DrawPixel(yPx1 + 1, xPx1, frac(yEnd) * xGap, h, s, v )
  } else {
    DrawPixel(xPx1, yPx1, 1 - frac(yEnd) * xGap, h, s, v )
    DrawPixel(xPx1, yPx1 + 1, frac(yEnd) * xGap, h, s, v )
  }

  intery = yEnd + gradient;

  xEnd = round(x1);
  yEnd = y1 + gradient * (xEnd - x1);
  xGap = frac(x1 + 0.5);

  xPx2 = xEnd;
  yPx2 = trunc(yEnd);

  if (steep) {
    DrawPixel(yPx2, xPx2, 1 - frac(yEnd) * xGap, h, s, v )
    DrawPixel(yPx2 + 1, xPx2, frac(yEnd) * xGap, h, s, v )
  } else {
    DrawPixel(xPx2, yPx2, 1 - frac(yEnd) * xGap, h, s, v )
    DrawPixel(xPx2, yPx2 + 1, frac(yEnd) * xGap, h, s, v )
  }

  if (steep) {
    for (x = xPx1 + 1; x <= xPx2 - 1; x++) {
      DrawPixel(trunc(intery), x, 1 - frac(intery), h, s, v )
      DrawPixel(trunc(intery) + 1, x, frac(intery), h, s, v )
      intery = intery + gradient;
    }
  } else {
    for (x = xPx1 + 1; x <= xPx2 - 1; x++) {
      DrawPixel(x, trunc(intery), 1 - frac(intery), h, s, v )
      DrawPixel(x, trunc(intery) + 1, frac(intery), h, s, v )
      intery = intery + gradient
    }
  }
}

function WarpPoint(x,y,out) {
  xt = x * widthm1_inv
  out[0] = widthm1 * 0.5 * (cos(PI + PI * xt) + 1) + y * italic
  out[1] = y * clamp((1 - pow(2*(xt-0.5),2)),0,1)
}

// Draw a Character
// - starting at x,y
// - draw WuLine with r,g,b
var pstart = array(2)
var pend = array(2)

function DrawCharacter(chr,x,y,sx,sy,h,s,v) {
  var segments = chr.length

  for (N=0; N<segments; N++) {
    var segment = chr[N]
    points = segment.length

    if (points == 1) {
      WarpPoint(x + segment[0][0] * sx, y + segment[0][1] * sy, pstart)
      PixelMax(floor(pstart[0]),floor(pstart[1]),1,h,s,v)
    } else {
      WarpPoint(x + segment[0][0] * sx, y + segment[0][1] * sy, pstart)
      for (L=1; L<points; L++) {
        WarpPoint(x + segment[L][0] * sx, y + segment[L][1] * sy, pend)
        WuLine(
          PixelMax,
          pstart[0], pstart[1],
          pend[0], pend[1],
          h, s, v
        )
        pstart[0] = pend[0]
        pstart[1] = pend[1]
      }
    }
  }
}

// Font data is on a 3x6 grid,
// so we divide our target size by 2 and 5 to get a scaling factor:
cws = 2.3 / 2
chs = 8.3 / 5
// FIXME: just walk over the coordinate array and do this once
// Character spacing
csp = 3.6

// Clear (or fade if blur enabled) framebuffer
// and render the string

export function beforeRender(delta) {
  FB.forEach((Pixel) => { Pixel.mutate((v) => { return blur * v } )})

  text = texts[0] // FIXME: handle multiple strings?
  t1 = time(0.09)

  // FIXME: speed depends on string length
  // FIXME: we start waaaaay offscreen to the left but could just start -1 character
  xoff = -text.length * csp * t1

  while (xoff < width) {
    text.forEach((nchr) => {
      if (xoff > -csp && nchr != -1 && xoff < width) {
        DrawCharacter(chars[nchr],xoff,0,cws,chs,t1,1,1)
      }
      xoff = xoff + csp
    })
  }
}

// Draw from the framebuffer
export function render(index) {
  Pixel = IndexPixel[index]
  rgb(Pixel[0], Pixel[1], Pixel[2])
}

// #!/usr/bin/env ruby
//
// # Usage: $0 < font.json "string" ...
//
// require 'multi_json'
//
// # Output variables
// chars = []
// texts = []
//
// # Character -> chars[] index tracker
// cnums = { ' ' => -1 }
// cnum = 0
//
// font = MultiJson.load(STDIN.read)
//
// ARGV.each do |text|
//   tcnums = []
//   text.chars.each do |char|
//     if cnums.key?(char)
//       tcnums.append(cnums[char])
//     elsif font.key?(char)
//       chars[cnum] = font[char]
//       cnums[char] = cnum
//       tcnums.append(cnum)
//       cnum += 1
//     else
//       warn('Character not in font: ' + char)
//     end
//   end
//   texts.append(tcnums)
// end
//
// puts 'chars = ' + MultiJson.dump(chars)
// puts 'texts = ' + MultiJson.dump(texts)
6 Likes

The fun thing about this is that you can do all sorts of things just by replacing the WarpPoint function:

var xmid = widthm1 / 2
var ymid = (height-1) / 2
var angsin = sin(PI/18)
var angcos = cos(PI/18)

export function sliderAngle(v) {
  var angle = PI * (v-0.5)/4
  angsin = sin(angle)
  angcos = cos(angle)
}

function WarpPoint(x,y,out) {
  rx = x - xmid
  ry = y - ymid
  out[0] = xmid + rx*angcos - ry*angsin
  out[1] = ymid + rx*angsin + ry*angcos
}
2 Likes

Hey Sorceror,

Are you planning on uploading this to the patterns page? Pretty please? :slight_smile:

It looks great!

Oh! I keep forgetting about the pattern library! Will do that soon!

Edit: I have a bunch of different projection modes and I’ll wrap them all up in new widgets when the new PB firmware comes out.

For now, you can just copy-and-paste it, right?

1 Like

Not to mention that one of these days, a new pattern library is coming, with more of a git backend hopefully. @pixie has been awesomely writing code to make this easier.

1 Like

There’s a lot of really good progress here with SDF and text that I most certainly will find useful to use for my window display matrix (I’ve already used some previous iteration stuff from @zranger1 and @Scruffynerf)

It would be really nice to have these things wrapped up in a more portable format though, since it’s a pain to dig through threads to get all the functions out etc. Makes me want some kind of reusable function library for pixelblaze built-in :sweat_smile:

1 Like

Just in case you haven’t seen it, all the SDF related stuff that we’ve done in the forums, plus newer related things I’ve been working on is available on github at https://github.com/zranger1/SDF-LED .

2 Likes