Porting GLSL Shaders: Example #0

Here’s a short example that shows that illustrates how relatively simple it is to port a GLSL shader to Pixelblaze. Clearly, this isn’t the most complex shader out there, but it demonstrates the basic issues you’ll encounter while porting, and looks about the same on LEDs as it does on the screen.
(Annotated shader code at the top, scroll down to see the equivalent Pixelblaze pattern code!)

Not all shaders are appropriate for LED displays, but shadertoy.com and other shader repositories are great places to look for inspiration and mathematical tools. If you’re interested, I highly recommend The Book of Shaders as a place to start. It’s an excellent interactive tutorial – playing with GLSL for a while will actually help your Pixelblaze thinking too!

/************************************************************************
// Annotated GLSL fragment shader code to illustrate porting to Pixelblaze
// original https://www.shadertoy.com/view/3sSSRD

#version 120   // the required opengl version

// uniforms are constants passed into the shader by the calling program
//
uniform float time;       // usually in seconds since program start
uniform vec2 mouse;       // mouse screen coords
uniform vec2 resolution;  // physical resolution of the display

// evaluate the gyroid function at the current point.  Returns a vector
// containing the "brightness" at this point
//
vec3 cgyroid(vec3 p) {
  float g = (cos(p.x) * sin(p.y) + cos(p.y) * sin(p.z) + cos(p.z) * sin(p.x));
  return vec3(g);
}

// main -- this is equivalent to render2d() on the Pixelblaze
//
void main(void) {
    // pixel coords are passed in screen units.  Here, we normalize them
    // to the 0-1 range, and translate them so the origin is at  the center of
    // the screen.  It is also common in shaders to correct for screen aspect
    // ratio since basically nobody has a square screen.  Pixelblaze's mapper does
    // this for us
    //
    vec2 uv = 2.0*gl_FragCoord.xy/resolution.xy;
    float aspect = resolution.x/resolution.y;
    uv.x *= aspect;
    vec2 dc = uv -.5;
    
    // scale up so the gyroid function is a little smaller on the display.
    // this is arbitrary and should be changed to taste and display.
    //
    dc *= 8.0;

    // process current point, adding color values which are functions
    // of time, so we get an interesting animation.
    //
    vec3 col = cgyroid(vec3(dc.x,dc.y,cos(time)*5.0));
    col += vec3(sin(time), sin(time * 0.5), sin(time*0.15));
    
    // whatever RGBA color you assign to gl_FragColor at this pixel is what gets
    // displayed on the screen.
    //
    gl_FragColor = vec4(col,1.0);
}
************************************************************************/

// Begin Pixelblaze pattern!
// preset translation and scaling. Shaders have to do this for every pixel, but
// fortunately on Pixelblaze, we can do it once at pattern startup.  Note that I'm
// using a different scale value than the shader.  
translate(-0.5,-0.5);
scale(12,12);

// evaluate the gyroid function at the current point. Since it's hard
// for us to return vectors, and we don't actually need to in this case, we
// just return a single value for "brightness" at this pixel.
function cgyroid(x,y,t) {
  return cos(x) * sin(y) + cos(y) * sin(t) + cos(t) * sin(x);  
}

// create our time base by accumulating delta, then control speed with a 
// multiplier.  (Be careful about overflow here)
//
// Also, precompute trig results that are the same for every pixel.  Shaders
// can't do this - every pixel is completely independent.  But on the Pixelblaze, we can
// (and should) move as much repetitive math out of the render() function as 
// possible for better performance.
export var t = 0
export function beforeRender(delta) {
  t = (t + delta/1000) % 1000;
  t1 = t * 5;       // speed control
  s1 = sin(t1);
  s2 = sin(t1 + 0.5);
  s3 = sin(t1 * 0.15);
  c1 = cos(t1);
}

// render current pixel, generating our gyroid value, then
// adding in the time dependent color values that we generated
// in beforeRender().  Done!
export function render2D(index,x,y) {
  var v = cgyroid(x,y,c1);
  r = v + s1;
  g = v + s2;
  b = v + s3;

  rgb(r,g,b)
}
2 Likes

good overview for JS savvy people on how GLSL is different in The Book of Shaders

1 Like

just added this comment elsewhere, but also related to the above

(moved the below to here, as more relevant)

@zranger1,

You might find this useful, and I’m thinking it’s a good basis of a ‘library of shapes’

It’s basically a collection of GLSL code meant to start simple and stack into more complexity. Good for learning, but I’m thinking it’s also a perfect thing to port to PB. Imagine making a PB pattern that implements all of these: A PixelBlazeSpiritDeck pattern.

2 Likes

Fascinating topic area. Just exactly where I wish to go. Thank you wizards.

Thanks, @JustPete! I play with this crazy stuff because it’s just fun to think about, but Pixelblaze makes it pretty easy – it shows off a little of what you can do with render() and the mapping system.

Patterns written this way work at any resolution, with any number of LEDs in any physical layout. If you’ve got a mapping function configured, it’ll just work. And although the per-pixel calculations can be CPU intensive, it balances in the end because some things - like anti-aliasing and complex polygon filling - are easy and inexpensive to implement.

Plus you can do a lot of really cool things that we have yet to explore, like constructive geometry. And this: (it’s… hard to describe, you just have to run it on a matrix and take a look. And it’d be hard to duplicate on LEDs using “normal” polygon drawing techniques. )

Real time morphing + deformation: Very strange!

// UI control variables
export var objectSize = 0.4;
export var lineWidth = 0.05;
var filled = 1;

// shape function selection 
var numShapes = 6;
var shapeSdf = array(numShapes)
var shapeCompare = array(2);

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

shapeCompare[0] = (f) => (abs(f) > lineWidth); // unfilled shapes
shapeCompare[1] = (f) => (f > lineWidth);      // filled shapes

shapeSdf[0] = circle;
shapeSdf[1] = cross;
shapeSdf[2] = hexStar;
shapeSdf[3] = square;
shapeSdf[4] = triangle;
shapeSdf[5] = hexagon;

function signum(a) {
  return (a > 0) - (a < 0)
}

// signed distance functions for various shapes, adapted for 2D. 
// Math from https://iquilezles.org/www/articles/distfunctions/distfunctions.htm
function circle(x,y,r) {
  return hypot(x,y) - r;
}

function square(x,y,size) {
  dx = abs(x) - size;  d1 = max(dx,0);
  dy = abs(y) - size;  d2 = max(dy,0);
	return min(max(dx, dy), 0.0) + hypot(d1,d2);
}

function triangle(x,y,r) {
	return max((abs(x) * 0.866025) - (y * 0.5), y) - r / 2;
}

function hexagon(x,y,r){
     x = abs(x); y = abs(y);
     return  max((x * 0.5 + y * 0.866025),x) - r;
}

function hexStar(x,y,r) {
  // rescale to pointy parts of star
  x = abs(x*1.73205); y = abs(y*1.73205); 
  dot = 2 * min(-0.5*x + 0.866025 * y,0);
  x -= dot * -0.5; y -= dot * 0.866025;
  
  dot = 2 * min(0.866025*x + -0.5 * y,0);
  x -= dot * 0.866025; y -= dot * -0.5;
  
  x -= clamp(x, r * 0.57735, r * 1.73205);
  y -= r;
  return signum(y) * hypot(x,y) / 1.73205;
}

// interior distance on this is slightly weird. Still
// looking for a reasonable fix.
function cross(x,y,size) {
  x = abs(x); y = abs(y);
  
  if (y > x) { tmp = x; x = y; y = tmp; }
  qx = x - size; qy = y - size / 5;
  k = max(qy,qx);
  if (k > 0) {
    wx = max(qx,0); wy = max(qy,0);
    return hypot(wx,wy);
  } else {
    wx = max(size - x,0); wy = max(-k,0);
    return -hypot(wx,wy);
  }
}

// UI
export function sliderSize(v) {
  objectSize = 0.4 * v;
}

export function sliderFilled(v) {
  filled = (v >= 0.5);
}

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

var t1, timebase,zoom;
export function beforeRender(delta) {
  timebase = (timebase + delta/1000) % 1000;
  t1 = timebase * 10;
  zoom = wave(time(0.075));  
  morphClock += delta

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

  lerpPct = morphClock / 1000;

// rotate entire scene
  theta = PI2 * time(0.1);
  resetTransform();
  translate(-0.5,-0.5);  
  rotate(theta);  
}

export function render2D(index,x,y) {
  var d;
  var v = 0;
  
  // introduce a sinewave "wiggle" along the x axis
  radius = 1.5-hypot(x,y)*2.4;
  th = radius * radius * sin(radius + t1);
  x = (cos(th) * x) - (sin(th)* y);      

  // draw one our shapes, interpolating between two SDFs when switching shapes
  if (wait) {

    d = shapeSdf[shape](x,y,objectSize);
  } else {
    
    d = shapeSdf[shape](x,y,objectSize) * (1-lerpPct) + shapeSdf[nextShape](x,y,objectSize) * lerpPct;
  }
  
  // fill or just draw boundary based on UI seting.  
  if (!shapeCompare[filled](d)) {;
     v = 1-(d/lineWidth);
//    s = 1.5-abs(d)/objectSize
//    h = d + time(0.1);   
     h = (radius * zoom)+ zoom + theta/PI2;
  }

  hsv(h, 1, v)
}
1 Like

Hmm… Now I want to see what non-lerp easings would do…