Yet another approach to text rendering (WIP)

Here’s a quick demo of a new text renderer I’m working on. This is an early preview – it’s a long way from optimized, and needs to have more animation tooling added. I’m posting it now because I just got to a good place: the anti-aliased font rendering works, and the morphing just looks cool!

It’s set up for a 16x16 matrix. Should work on any 2D display though – you might have to use the sliders to adjust line width and size for best results on 8x8.

(Edit: 4pm, 1/4/2022 - updated code)

SDF Font Demo (WIP)
// SDF Font Demo with Morphing Characters
// (Work-in-Progress v2)
// 1/04/2022 ZRanger1

// UI control variables
export var objectSize = 0.85;
export var lineWidth = 0.085;
export var speed = 500;

// shape function selection - your message here!

//var shapeSdf = [_A,_B,_C,_D,_E,_F,_G,_H,_I,_J,_K,_L,_M,_N,_O,_P,_Q,_R,_S,_T,_U,_V,_W,_X,_Y,_Z];
var shapeSdf = [_P,_I,_X,_E,_L,_B,_L,_A,_Z,_E];

// animation control
var shape = 0;
var nextShape = 1;
var hue = 0;
var morphClock = 0;
var wait = 0; 

var lerpPct = 0;
var t1, timebase,t2;

// move coordinate origin to center.  
translate(-0.5,-0.5);

// comment/uncomment this line to flip charaters on x axis as necessary
// thanks @pixie, for the reminder!  
scale(-1,1);

// UI

export function sliderSpeed(v) {
  speed = 100+(v*1900);
}

export function sliderSize(v) {
  objectSize = v;
}

export function sliderLineWidth(v){
  lineWidth = 0.25 * v * v;
}


export function beforeRender(delta) {
  timebase = (timebase + delta/1000) % 1000;
  t1 = timebase * 10;
  t2 = time(0.04);  
  morphClock += delta

// morph to a new shape every other second...
  if (morphClock > speed) {
    if (!wait) {
      shape = nextShape;                      // set to next shape
      nextShape = (nextShape+1) % shapeSdf.length;  
    }
    morphClock = 0;    
    wait = !wait;
  }

  lerpPct = morphClock / speed;
}

export function render2D(index,x,y) {
  if (wait) {
    d = shapeSdf[shape](x,y);
  } else {
    d = shapeSdf[shape](x,y) * (1-lerpPct) + shapeSdf[nextShape](x,y) * lerpPct;
  }  

  v = (d <= lineWidth) ? 1-d/lineWidth : 0;

  hsv((x+y+t2), 1, v)
}

// SDF function for line segment
function line(x,y,x1,y1,x2,y2) {
  x1 *= objectSize; y1 *= objectSize;
  x2 *= objectSize; y2 *= objectSize;
  
  ax = x - x1; ay = y - y1;
  bx = x2 - x1; by = y2 - y1;
  h = clamp((ax * bx + ay * by)/(bx * bx + by * by),0,1);
  return hypot(ax - bx * h,ay - by * h);
}

// original sdf text demo shader: https://www.shadertoy.com/view/lsXXRs
// font from https://dl.dropboxusercontent.com/u/14645664/files/glsl-text.txt , which no longer
// seems to exist.
// TODO -- need to rescale the segments to save calculation

function _A(px,py) {
  var d = 1;
  d=min(d,line(px,py,-0.28571,0.5,-0.28571,-0.42857));
  d=min(d,line(px,py,-0.28571,-0.42857,0.28571,-0.42857));
  d=min(d,line(px,py,0.28571,-0.42857,0.28571,0.07143));
  d=min(d,line(px,py,0.28571,0.07143,-0.28571,0.07143));
  d=min(d,line(px,py,-0.28571,0.07143,0.28571,0.07143));
  d=min(d,line(px,py,0.28571,0.07143,0.28571,0.5));
  return d;
}
function _B(px,py) {
  var d = 1;
  d=min(d,line(px,py,0.14286,0.07143,0.14286,-0.42857));
  d=min(d,line(px,py,0.14286,-0.42857,-0.28571,-0.42857));
  d=min(d,line(px,py,-0.28571,-0.42857,-0.28571,0.5));
  d=min(d,line(px,py,-0.28571,0.5,0.28571,0.5));
  d=min(d,line(px,py,0.28571,0.5,0.28571,0.07143));
  d=min(d,line(px,py,0.28571,0.07143,-0.28571,0.07143));
  return d;
}
function _C(px,py) {
  var d = 1;
  d=min(d,line(px,py,0.28571,-0.42857,-0.28571,-0.42857));
  d=min(d,line(px,py,-0.28571,-0.42857,-0.28571,0.5));
  d=min(d,line(px,py,-0.28571,0.5,0.28571,0.5));
  return d;
}
function _D(px,py) {
  var d = 1;
  d=min(d,line(px,py,-0.28571,0.5,0.14286,0.5));
  d=min(d,line(px,py,0.14286,0.5,0.21429,0.42857));
  d=min(d,line(px,py,0.21429,0.42857,0.28571,0.25));
  d=min(d,line(px,py,0.28571,0.25,0.28571,-0.10714));
  d=min(d,line(px,py,0.28571,-0.10714,0.21429,-0.35714));
  d=min(d,line(px,py,0.21429,-0.35714,0.14286,-0.42857));
  d=min(d,line(px,py,0.14286,-0.42857,-0.28571,-0.42857));
  d=min(d,line(px,py,-0.28571,-0.42857,-0.28571,0.5));
  return d;
}
function _E(px,py) {
  var d = 1;
  d=min(d,line(px,py,0.28571,-0.42857,-0.28571,-0.42857));
  d=min(d,line(px,py,-0.28571,-0.42857,-0.28571,0.07143));
  d=min(d,line(px,py,-0.28571,0.07143,0,0.07143));
  d=min(d,line(px,py,0,0.07143,-0.28571,0.07143));
  d=min(d,line(px,py,-0.28571,0.07143,-0.28571,0.5));
  d=min(d,line(px,py,-0.28571,0.5,0.28571,0.5));
  return d;
}
function _F(px,py) {
  var d = 1;
  d=min(d,line(px,py,0.28571,-0.42857,-0.28571,-0.42857));
  d=min(d,line(px,py,-0.28571,-0.42857,-0.28571,0.07143));
  d=min(d,line(px,py,-0.28571,0.07143,0,0.07143));
  d=min(d,line(px,py,0,0.07143,-0.28571,0.07143));
  d=min(d,line(px,py,-0.28571,0.07143,-0.28571,0.5));
  return d;
}
function _G(px,py) {
  var d = 1;
  d=min(d,line(px,py,0.28571,-0.28571,0.28571,-0.42857));
  d=min(d,line(px,py,0.28571,-0.42857,-0.28571,-0.42857));
  d=min(d,line(px,py,-0.28571,-0.42857,-0.28571,0.5));
  d=min(d,line(px,py,-0.28571,0.5,0.28571,0.5));
  d=min(d,line(px,py,0.28571,0.5,0.28571,0.07143));
  d=min(d,line(px,py,0.28571,0.07143,0.07143,0.07143));
  return d;
}
function _H(px,py) {
  var d = 1;
  d=min(d,line(px,py,-0.28571,-0.42857,-0.28571,0.5));
  d=min(d,line(px,py,-0.28571,0.5,-0.28571,0.07143));
  d=min(d,line(px,py,-0.28571,0.07143,0.28571,0.07143));
  d=min(d,line(px,py,0.28571,0.07143,0.28571,-0.42857));
  d=min(d,line(px,py,0.28571,-0.42857,0.28571,0.5));
  return d;
}
function _I(px,py) {
  var d = 1;
  d=min(d,line(px,py,-0.21429,-0.42857,0.21429,-0.42857));
  d=min(d,line(px,py,0.21429,-0.42857,0,-0.42857));
  d=min(d,line(px,py,0,-0.42857,0,0.5));
  d=min(d,line(px,py,0,0.5,-0.21429,0.5));
  d=min(d,line(px,py,-0.21429,0.5,0.21429,0.5));
  return d;
}
function _J(px,py) {
  var d = 1;
  d=min(d,line(px,py,-0.21429,0.5,0,0.5));
  d=min(d,line(px,py,0,0.5,0.14286,0.35714));
  d=min(d,line(px,py,0.14286,0.35714,0.14286,-0.42857));
  d=min(d,line(px,py,0.14286,-0.42857,-0.21429,-0.42857));
  return d;
}
function _K(px,py) {
  var d = 1;
  d=min(d,line(px,py,-0.28571,-0.42857,-0.28571,0.5));
  d=min(d,line(px,py,-0.28571,0.5,-0.28571,0.07143));
  d=min(d,line(px,py,-0.28571,0.07143,-0.07143,0.07143));
  d=min(d,line(px,py,-0.07143,0.07143,0.28571,-0.42857));
  d=min(d,line(px,py,0.28571,-0.42857,-0.07143,0.07143));
  d=min(d,line(px,py,-0.07143,0.07143,0.28571,0.5));
  return d;
}
function _L(px,py) {
  var d = 1;
  d=min(d,line(px,py,-0.28571,-0.42857,-0.28571,0.5));
  d=min(d,line(px,py,-0.28571,0.5,0.28571,0.5));
  return d;
}
function _M(px,py) {
  var d = 1;
  d=min(d,line(px,py,-0.28571,0.5,-0.28571,-0.42857));
  d=min(d,line(px,py,-0.28571,-0.42857,0,-0.07143));
  d=min(d,line(px,py,0,-0.07143,0.28571,-0.42857));
  d=min(d,line(px,py,0.28571,-0.42857,0.28571,0.5));
  return d;
}
function _N(px,py) {
  var d = 1;
  d=min(d,line(px,py,-0.28571,0.5,-0.28571,-0.42857));
  d=min(d,line(px,py,-0.28571,-0.42857,0.28571,0.5));
  d=min(d,line(px,py,0.28571,0.5,0.28571,-0.42857));
  return d;
}
function _O(px,py) {
  var d = 1;
  d=min(d,line(px,py,0.28571,-0.42857,-0.28571,-0.42857));
  d=min(d,line(px,py,-0.28571,-0.42857,-0.28571,0.5));
  d=min(d,line(px,py,-0.28571,0.5,0.28571,0.5));
  d=min(d,line(px,py,0.28571,0.5,0.28571,-0.42857));
  return d;
}
function _P(px,py) {
  var d = 1;
  d=min(d,line(px,py,-0.28571,0.5,-0.28571,-0.42857));
  d=min(d,line(px,py,-0.28571,-0.42857,0.28571,-0.42857));
  d=min(d,line(px,py,0.28571,-0.42857,0.28571,0.07143));
  d=min(d,line(px,py,0.28571,0.07143,-0.28571,0.07143));
  return d;
}
function _Q(px,py) {
  var d = 1;
  d=min(d,line(px,py,0.28571,0.5,0.28571,-0.42857));
  d=min(d,line(px,py,0.28571,-0.42857,-0.28571,-0.42857));
  d=min(d,line(px,py,-0.28571,-0.42857,-0.28571,0.5));
  d=min(d,line(px,py,-0.28571,0.5,0.28571,0.5));
  d=min(d,line(px,py,0.28571,0.5,0.07143,0.28571));
  return d;
}
function _R(px,py) {
  var d = 1;
  d=min(d,line(px,py,-0.28571,0.5,-0.28571,-0.42857));
  d=min(d,line(px,py,-0.28571,-0.42857,0.28571,-0.42857));
  d=min(d,line(px,py,0.28571,-0.42857,0.28571,0.07143));
  d=min(d,line(px,py,0.28571,0.07143,-0.28571,0.07143));
  d=min(d,line(px,py,-0.28571,0.07143,0.07143,0.07143));
  d=min(d,line(px,py,0.07143,0.07143,0.28571,0.5));
  return d;
}
function _S(px,py) {
  var d = 1;
  d=min(d,line(px,py,0.28571,-0.42857,-0.28571,-0.42857));
  d=min(d,line(px,py,-0.28571,-0.42857,-0.28571,0.07143));
  d=min(d,line(px,py,-0.28571,0.07143,0.28571,0.07143));
  d=min(d,line(px,py,0.28571,0.07143,0.28571,0.5));
  d=min(d,line(px,py,0.28571,0.5,-0.28571,0.5));
  return d;
}
function _T(px,py) {
  var d = 1;
  d=min(d,line(px,py,0,0.5,0,-0.42857));
  d=min(d,line(px,py,0,-0.42857,-0.28571,-0.42857));
  d=min(d,line(px,py,-0.28571,-0.42857,0.28571,-0.42857));
  return d;
}
function _U(px,py) {
  var d = 1;
  d=min(d,line(px,py,-0.28571,-0.42857,-0.28571,0.5));
  d=min(d,line(px,py,-0.28571,0.5,0.28571,0.5));
  d=min(d,line(px,py,0.28571,0.5,0.28571,-0.42857));
  return d;
}
function _V(px,py) {
  var d = 1;
  d=min(d,line(px,py,-0.28571,-0.42857,0,0.5));
  d=min(d,line(px,py,0,0.5,0.28571,-0.42857));
  return d;
}
function _W(px,py) {
  var d = 1;
  d=min(d,line(px,py,-0.28571,-0.42857,-0.28571,0.5));
  d=min(d,line(px,py,-0.28571,0.5,0,0.21429));
  d=min(d,line(px,py,0,0.21429,0.28571,0.5));
  d=min(d,line(px,py,0.28571,0.5,0.28571,-0.42857));
  return d;
}
function _X(px,py) {
  var d = 1;
  d=min(d,line(px,py,-0.28571,-0.42857,0.28571,0.5));
  d=min(d,line(px,py,0.28571,0.5,0,0.03571));
  d=min(d,line(px,py,0,0.03571,0.28571,-0.42857));
  d=min(d,line(px,py,0.28571,-0.42857,-0.28571,0.5));
  return d;
}
function _Y(px,py) {
  var d = 1;
  d=min(d,line(px,py,-0.28571,-0.42857,0,0.07143));
  d=min(d,line(px,py,0,0.07143,0,0.5));
  d=min(d,line(px,py,0,0.5,0,0.07143));
  d=min(d,line(px,py,0,0.07143,0.28571,-0.42857));
  return d;
}
function _Z(px,py) {
  var d = 1;
  d=min(d,line(px,py,-0.28571,-0.42857,0.28571,-0.42857));
  d=min(d,line(px,py,0.28571,-0.42857,0,0.07143));
  d=min(d,line(px,py,0,0.07143,-0.21429,0.07143));
  d=min(d,line(px,py,-0.21429,0.07143,0.21429,0.07143));
  d=min(d,line(px,py,0.21429,0.07143,0,0.07143));
  d=min(d,line(px,py,0,0.07143,-0.28571,0.5));
  d=min(d,line(px,py,-0.28571,0.5,0.28571,0.5));
  return d;
}
2 Likes

Super cool, John! Hey, what’s with underscore E? Is E a reserved symbol?

yeah, E is reserved. I’ll probably convert everything to _A, _B, etc, just to be consistent.

Is this one of those “Not for V2” patterns? I’m locked out of mine, now. I can find it on discovery, but the webpage won’t load, and I can’t get it into AP mode. :frowning:

[EDIT] I was eventually able to get back into it.

Neato!

But how is your pixelmap function oriented? On my matrices, which are defined with (0,0) at the upper left and (1,1) at the lower right, the letters appear flipped in the X direction.

@mebejedi, there’s nothing in there that would hurt a v2. It uses the new array initialization features, so firmware has to be up to date, and that’s about it. It does use a lot of CPU though - not lightning fast yet, even on a v3 - so it may be dragging the v2 to a slow crawl. I’m setting one up to test today – backing it up and preparing to reflash if I kill it! Hopefully not though.

(Edit: It doesn’t seem to kill v2s. Frame rate drops to a screamin’ 6 or 7 fps at 16x16 though, and that might reduce UI responsiveness.)

@pixie, the short answer is, my matrix (a BTF-Lighting 16x16) has x flipped the wrong way. I use the default PB matrix map, and the way the board is wired and most easily mounted, that’s just how it comes out. It’d be great if everybody stuck to one standard orientation, but it’s kind of hard to insist. Look at any thread on text display, and you’ll find multiple complaints about characters being flipped.

For release, I’ll set this up with a single flag that lets you easily set x orientation. For now, prior to the coming “Great Font Vector Renormalization of 2022”, to flip x to the correct orientation, substitute this version of the line function for the original (starting at around line 20).

// SDF function for line segment
function line(x,y,x1,y1,x2,y2) {
  x1 = -0.75 + (x1 / 4);   y1 = -1.2 + (y1 / 4)
  x2 = -0.75 + (x2 / 4);   y2 = -1.2 + (y2 / 4)  
  
  x1 *= objectSize; y1 *= objectSize;
  x2 *= objectSize; y2 *= objectSize;
  
  ax = x - x1; ay = y - y1;
  bx = x2 - x1; by = y2 - y1;
  h = clamp((ax * bx + ay * by)/(bx * bx + by * by),0,1);
  return hypot(ax - bx * h,ay - by * h);
}
2 Likes

Yeah, that’s what happened. I’ll wait 'til my V3 comes in. :slight_smile:

And I thought this was weird for AP mode…

@zranger1, I took the easier way out and put scale(-1,1); into beforeRender().

1 Like

@zranger1, I took the easier way out and put scale(-1,1); into beforeRender().

What?! You mean I climbed inch by inch, up this icy, treacherous mountain, reached the summit and… there’s an escalator on the other side?

3 Likes

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)