Yet another approach to text rendering (WIP)

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