Task #8: Serpentine Square!

This one is free form, and partly related to St. Paddy’s day (just passed):

Make a snake with your matrix… Could be a line that moves, could be a wriggle, could be a full-on Snake game (self playing).

You decide how to make it Snake-y.

I would be remiss to not point at and recommend the 3D printed 16x16 matrix frame

Very slick, and I might print one myself soon. (Need to get my printer working again)

This was really fun. Here’s what I came up with.

I’ll post the code in the library at the end of the week.

7 Likes

Fantastic! Brilliant! Organic motion. Love it!

As someone who lives around a lot of 'em, I heartily approve that snake! :snake:

(Dog is very well trained concerning snakes. She stays well back, but she is not letting that guy get up on the porch. I used a broom to sweep him into a bucket, popped the lid on and released him well away from the house. They eat the incredibly destructive packrats, so they’re good to have around. Just another normal day in the desert… )

3 Likes

Yup. That’s one big 'ol snake! And a very good dog. Glad you don’t kill 'em, those snakes.

1 Like

@jeff,
That is so cool! I couldn’t help but stare and wonder, and mentally try to reverse engineer it :slight_smile: I can’t wait to play with it when you post the code. The way it jumps in speed is pretty cool too! And the softness/antialiasing of the “head” :100:

1 Like

Cool, I just uploaded. I know we don’t want spoilers, so I’ve included it here using the forum’s handy “Hide details” thing.

Click to expand code for Snake 2D
/* 
  Snake - Generate a serpentine path around a matrix
  
  This pattern relies on functions present in Pixelblaze v3 (hypot(), 
  mod(), arrayMutate()) but can be adapted to v2.
  
  Demo on 16x16: https://youtu.be/RfDidPGp3Vg

  Jeff Vyduna 2021 / MIT License
*/

// User-adjustable parameters
var width = 16          // Width of matrix, in pixels
var edgePct = .15       // Edge width, in percentage of the lesser of width or height. Snake tries to avoid edges. 
var radius = sqrt(pixelCount) * 3/16   // Radius of the snake head in pixels. 3 on a 16x16
var nominalDecay = .98  // Fade each pixel per frame. Adjusted for speed and delta.
var maxDeflection = .2  // Radians per frame when escaping a boundary (edge)
var nominalDeflection = .1  // Radians per frame when slithering

var nominalSpeed = 0.02, speed = nominalSpeed
export function sliderSpeed (_v) { 
  speed = 2 * nominalSpeed * _v
}

var height = pixelCount / width // Height of matrix, in pixels
var posX = 0, posY = 0          // Positon of snakehead, in pixels. Set initial position.
var bearing = PI / 4            // Direction of travel, in radians
var deflection = nominalDeflection // Turn by deflecting the bearing each frame (radians)
var adjSpeed                    // User-commanded speed modified by the wandering fn and proximity to edges
var adjDeflection               // Deflection angle modified for the adjusted speed
var thisTurn                    // Accumulated radians in the current slither / turn
var nominalDelta = 15           // A reference delta used to adapt turns for running speed (e.g. number of pixels)
var cx = width / 2, cy = height / 2 // Centers in x and y, in pixels
var cornerDist = hypot(cx, cy)  // Distance from center to a corner
var edge = min(width, height) * edgePct // Edge border thickness, in pixels
var escapeDeflection =  0       // The current corrective deflection to escape edges or corners
var matrix = array(width)       // Array of pixels in matrix, [width][height]. Stores "heat".
matrix.mutate((v) => array(height))


export function beforeRender(delta) {
  t1 = time(6  / 65.535) // Hue modifier
  t2 = time(30 / 65.535) // Speed wandering period
  
  changeDirRandomly(delta)
  avoidWalls()
  
  // Speed wanders around the user-specified speed, and gets faster when near the edges
  adjSpeed = speed * wander(t2) * (.5 + hypot(posX - cx, posY - cy)/cornerDist) 
  // Adjust the bearing deflection (curve) for speed input and delta
  adjDeflection = deflection * adjSpeed / nominalSpeed * delta / nominalDelta
  bearing = mod(bearing + adjDeflection, PI2) // Make the turn, keep bearing in 0..PI2
  
  slither() // Reverse the turn so it doesn't normally eat its own tail
  
  posX += adjSpeed * delta * cos(bearing)
  posY += adjSpeed * delta * sin(bearing)
  
  bounceOffWalls()
  prerender()
}


// Moody snake doesn't always slither predictibly
function changeDirRandomly(_delta) {
  // At 100FPS, this is roughly reversing once within 3 seconds
  if(random(30 * _delta) < 1) deflection *= -1
}

function avoidWalls() {
  var towardsVecX = 0, towardsVecY = 0
  if (width - posX < edge) towardsVecX = 1
  if (height - posY < edge) towardsVecY = 1
  if (posX < edge) towardsVecX = -1
  if (posY < edge) towardsVecY = -1
  // If set, (towardsVecX, towardsVecY) is now the vector to the closest edge or corner

  if (towardsVecX == 0 && towardsVecY == 0) {  // Not near an edge
    if (escapeDeflection) { // Was just in an edge (but now not)
      deflection = nominalDeflection * ((random(2) > 1) ? -1 : 1)
      thisTurn = escapeDeflection = 0
    }
    return
  }
  
  // If we get here, we're in range of an edge or corner.
  
  // Angle towards nearest wall or corner, 0..PI2
  towardsAngle = (atan2(towardsVecY, towardsVecX) + PI2) % PI2
  
  if (towardsVecX * towardsVecY != 0) {
    // In a corner; keep an existing turn going or set one up
    escapeDeflection = escapeDeflection || maxDeflection
  } else {
    // Near an edge. Deflect away from it.
    deviation = angleDiff(bearing, towardsAngle)
    escapeDeflection = deviation > 0 ? maxDeflection : -maxDeflection
  }

  // We will escape if our bearing is within 45 degrees of the vector away from edge of corner
  var willEscape = abs(angleDiff(towardsAngle + PI, bearing)) < PI / 8

  deflection = willEscape ? 0 : escapeDeflection // End turns if headed away from the edge
}

// Pseudo gradient noise
// https://www.desmos.com/calculator/rvtrkskoqa
function wander(t) {
  t *= 49.261 // Define period for t in 0..1; see graph
  return .6 + 3 * wave(t/2)*wave(t/3)*wave(t/5)*wave(t/7)
}

// Turn the other direction if it's been a half turn
function slither() {
  thisTurn += adjDeflection
  if (abs(thisTurn) > PI) {
    deflection *= -1
    thisTurn = 0
  }
}

// Return (angle - targetAngle) within -PI..PI
function angleDiff(angle, target) {
  return mod(angle - target + PI, PI2) - PI
}

// Bounce like a billiard ball
function bounceOffWalls() {
  if (posX <= 0 || posX >= width) {
    bearing = -bearing + PI
    posX = clamp(posX, 0, width)
  }
  if (posY <= 0 || posY >= height) {
    bearing *= -1
    posY = clamp(posY, 0, height)
  }
}

// A 2D array holds each pixel's heat. Cool each pixel, and heat pixels 
// within `radius` of head (posX, posY). Since the inner loop runs as 
// much as render(), there are modifications for speed over readability.
function prerender() {
  // Adjust the exponential decay (tail cooling) for the speed of travel
  var decay = pow(2, log2(nominalDecay) / (nominalSpeed / adjSpeed))
  var speedFactor = adjSpeed / nominalSpeed

  // Heat up pixels near the snake's head
  for(x = 0; x < width; x++) {
    for(y = 0; y < height; y++) {
      matrix[x][y] *= decay
      var distanceToHead = hypot(posX - x, posY - y)
      if (distanceToHead > radius) continue // Boosts FPS
      proximity = 1 - distanceToHead / radius
      matrix[x][y] = min(1, matrix[x][y] + pow(proximity, 6) * speedFactor)
    }
  }
}



export function render2D(index, x, y) {
  v = matrix[x * width][y * height]
  s = 1 - pow(v, 8)
  h = v / 4 + t1
  hsv(h, s, v * v)
  
  // Uncomment to see a line showing the current commanded bearing.
  // Helpful when debugging edge avoidance. Costs half your FPS though!
  // plotAngle(bearing, x * width, y * height)
}


// Utility to plot an angle from the center of the matrix to the 
// boundary of the border `edge`. Can be called from within prerender() or render() 
// Usage: plotAngle(bearing, x * width, y * height)
function plotAngle(a, x, y) {
  x -= cx; y -= cy
  var pixelAng = atan2(y, x)
  var ad = abs(angleDiff(a, pixelAng)) // Angle difference between a and this pixel
  var hc = (width - 2 * edge) / 2      // Horizontal center box height
  var vc = (height - 2 * edge) / 2
  var bx = cos(a), by = sin(a)
  var dist = abs(x * by - y * bx) / hypot(bx, by) // Distance from pixel to the line to be plotted

  if (abs(x) < hc && abs(y) < vc && ad < PI/2) hsv(0, 0, 1 - dist * 2)
}
5 Likes

@jeff, this is simply beautiful code! And @scruffynerf, thanks again for managing the tasks. These challenges are helping everyone up their game, as well as being a whole lot of fun.

4 Likes

@jeff, you have created my new favorite desk pet!

2 Likes

My Pixelbaze2 does not like

return mod(angle - target + PI, PI2) - PI

tells me “Undefined symbol mod” is this a pizelblaze V3 code?

mod() is a PB3 thing – as I understand it, it’s actually a remainder operation as opposed to the usual %. From the PB3 docs:

mod(x,y)

The floored remainder of the division x/y. The result uses the same sign as y. For example mod(-3.5, 3)==2.5 whereas -3.5 % 3==-.5. This provides the same “wrapping” behavior used in the animation functions like triangle when y==1.

The % and mod functions very quite a bit from language to language. The new mod function is a floored remainder operation, which is what Python would use, but JavaScript would not. It is very handy and works well with the way Pixelblaze treads cyclical values between 0-1.

From Wikipedia:

“[…] the remainder would have the same sign as the divisor . Due to the floor function, the quotient is always rounded downwards, even if it is already negative.”

The implementation is pretty simple. Using %, if the result has a sign that doesn’t equal the sign of the divisor, add the divisor to it.

function flooredMod(dividend, divisor) {
  var res = dividend % divisor
  if ((res != 0) && (res > 0) != (divisor > 0)) { //this compares the sign of both
    res += divisor
  }
  return res
}

As you can see, they will only differ from % when there are negative numbers mixed with positive.
As a quick example, using the % operator if you take -1.2 % 1 you get -0.2. This makes sense if you think -1.2/1 == -1.2 == -1 + -0.2 so taking away the whole integer part, leaving -0.2 is the remainder.

Using mod on V3, or the equivalent flooredMod above gets you a different result. mod(-1.2, 1) results in 0.8 a positive number. This is very useful if you are trying to “wrap” a number between 0 and 1 (or some other range), and might end up with a negative number. This makes sense if you think about modulus math as a number line that wraps around in a circle with a circumference equal to the divisor. Imagine a circle with 0-360 degrees. Where is -45 degrees? 315 degrees! This is where the floored division modulus comes in handy: mod(-45, 360) == 315.

3 Likes

@jeff, on a fresh epe install from the pattern collection, v3.16 and ensuring it’s got the right number of pixels (16x16 map, 256 pixels in settings)
getting this error:

line 162: v = matrix[x * width][y * height]
Array index out of bounds

Hey @Scruffynerf! This actually might be related to a bug @wizard was chasing down today, having to do with how I’ve been allocating my 2D arrays lately.

Can you try replacing line 37:

matrix.mutate((v) => array(height))

With this instead?

for (i = 0; i < width; i++) matrix[i] = array(height)

weirdly, when I reloaded it today, from the .epe, it just worked. Clearly, a bug somewhere.

I’m trying to backport this to V2 (I’m running v2.25) by adding stubs for the V3 functions:

// V2 stubs
function hypot(x, y) {
  return sqrt(x*x + y*y)
}
function mod(dividend,divisor) {
  var res = dividend % divisor
  if ((res > 0) != (divisor > 0)) { //this compares the sign of both
    res += divisor
  }
  return res
}

but I’m getting an error message “lastSrc is not defined” which seems to be referring to the editor/parser code itself rather than my pattern. Bug?

Also, I love all the cool patterns people are generating but – please don’t take this as a criticism – I notice that a lot of them contain hardcoded “magic numbers” that negate the benefits of PB’s clever 0…1 world coordinate system. If these numbers were derived from pixelCount (or sqrt(pixelCount) for the width of square matrixes) then the patterns would work the same regardless of the number of LEDs.

2 Likes

Ooh, I like that idea, using sqrt of pixelcount as a easy default (with a commented out option to define directly for the non square Matrixes)

I agree, hard coded assumptions suck. Making patterns that don’t need any is better. I still plan on going thru patterns and doing some tweaks like this. On my to-do list (for way too long)

No idea where the lastSrc comes from. @wizard ?

Found the cause: the line matrix.mutate((v) => array(height)) uses the V3 feature array.mutate, but I still think the parser should give a better error message.

1 Like

If by “magic numbers”, you mean frame buffer dimensions, well… if your algorithm needs a frame buffer, you kind of need to know how large it’s going to be so you can allocate memory for it. You can determine this at runtime by deriving it from x,y and index on the first render pass, and I’ve done it this way in a few patterns, but it has always struck me as way too clever for supposedly tuturial code.

So in the interest of simplicity, yes, I declare height and width variables and use them for array allocation. It’s easier to understand than swapping render functions, and easy to change in the code if necessary. And at the incoming x and y are simply scaled to the array size to select a frame buffer value, effectively scaling the frame buffer to the actual size of the array.

Not just frame buffers, but everything. For example, I’m trying to get @scruffynerf’s cool port of tixy to run on 8x8 and 32x32 matrixes, but the the original tixy.land patterns assume a 16x16 grid so many of the pattern functions (which he copied directly from the original) have hardcoded numbers like 15, 7.5, 16, 8, 4, … which are really just the matrix width (sometimes zero-based, sometimes one-based) divided by 1, 2 and 4.

@wizard went to all the trouble of designing a scalable world coordinate system; I just think it would be a good thing to use it whenever possible.

1 Like