REVDANCATT'S GENUARY 2023

01 - Perfect loop / Infinite loop / endless GIFs

/* global preloadImagesTmr fxhash fxrand palettes noise */

//
//  fxhash - Genurary 2023 - 01
//
//
//  HELLO!! Code is copyright revdancatt (that's me), so no sneaky using it for your
//  NFT projects.
//  But please feel free to unpick it, and ask me questions. A quick note, this is written
//  as an artist, which is a slightly different (and more storytelling way) of writing
//  code, than if this was an engineering project. I've tried to keep it somewhat readable
//  rather than doing clever shortcuts, that are cool, but harder for people to understand.
//
//  You can find me at...
//  https://twitter.com/revdancatt
//  https://instagram.com/revdancatt
//  https://youtube.com/revdancatt
//

const ratio = 1
// const startTime = new Date().getTime() // so we can figure out how long since the scene started
let drawn = false
let highRes = false // display high or low res
const features = {}
let resizeTmr = null
let aniFrame = null
let tick = 0

window.$fxhashFeatures = {}

const hexToRgb = (hex) => {
  const result = /([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
  return {
    r: parseInt(result[1], 16),
    g: parseInt(result[2], 16),
    b: parseInt(result[3], 16)
  }
}

const rgbToHsl = (rgb) => {
  rgb.r /= 255
  rgb.g /= 255
  rgb.b /= 255
  const max = Math.max(rgb.r, rgb.g, rgb.b)
  const min = Math.min(rgb.r, rgb.g, rgb.b)
  let h
  let s
  const l = (max + min) / 2

  if (max === min) {
    h = s = 0 // achromatic
  } else {
    const d = max - min
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
    switch (max) {
      case rgb.r:
        h = (rgb.g - rgb.b) / d + (rgb.g < rgb.b ? 6 : 0)
        break
      case rgb.g:
        h = (rgb.b - rgb.r) / d + 2
        break
      case rgb.b:
        h = (rgb.r - rgb.g) / d + 4
        break
    }
    h /= 6
  }

  return {
    h: h * 360,
    s: s * 100,
    l: l * 100
  }
}

//  Work out what all our features are
const makeFeatures = () => {
  // Pick a random palette
  features.palette = palettes[Math.floor(fxrand() * palettes.length)]
  // Set how many rings we are going to have
  features.rings = 32
  // And how many segments in each ring
  // A third of the time we'll have a handful of segments, the other half we'll have a lot
  if (fxrand() < 0.33) {
    // Have between 6 and 12 segments
    features.segments = Math.floor(fxrand() * 6) + 6
  } else {
    features.segments = 64
  }
  // Now we need a tunnel array to hold the rings
  features.tunnel = []

  // We need to do some noise related stuff, we want to have an x/y offset for where we
  // start in the noisefield, then a radius for the rings, and a radius for the segments
  const noiseOffset = {
    x: fxrand() * 2000 + 1000,
    y: fxrand() * 2000 + 1000
  }
  // The ring radius is how far we move from the noiseOffset for each ring
  const ringRadius = fxrand() * 10 + 1
  // The segment radius is how far we move from the ring radius for each segment
  const segmentRadius = fxrand() * 0.2 + 0.1
  // We also need the radius angle, so we can work out the angle for each ring through the noise field
  features.radiusAngle = 360 / features.rings

  // We need to do a similar thing for modifying the radius of the tunnel
  features.tunnelNoiseOffset = {
    x: fxrand() * 2000 + 1000,
    y: fxrand() * 2000 + 1000
  }
  features.tunnelNoiseRadius = fxrand() * 0.1 + 0.1

  // Loop through the rings adding a new ring to the tunnel
  for (let i = 0; i < features.rings; i++) {
    // The ring is made up of segments, so we need an array to hold them
    const ring = []
    // grab the x,y position for this ring in the noise field
    const ringNoisePos = {
      x: noiseOffset.x + (Math.cos((features.radiusAngle * i) * (Math.PI / 180)) * ringRadius),
      y: noiseOffset.y + (Math.sin((features.radiusAngle * i) * (Math.PI / 180)) * ringRadius)
    }
    // Loop through the segments adding a new segment to the ring
    for (let j = 0; j < features.segments; j++) {
      // Work out the x,y position for this segment in the noise field
      const segmentNoisePos = {
        x: ringNoisePos.x + (Math.cos((360 / features.segments * j) * (Math.PI / 180)) * segmentRadius),
        y: ringNoisePos.y + (Math.sin((360 / features.segments * j) * (Math.PI / 180)) * segmentRadius)
      }
      // Get the noise value for this segment, convert it to a number between 0 and 1
      const noiseVal = noise.simplex2(segmentNoisePos.x, segmentNoisePos.y) / 2 + 0.5
      // The segment is made up of a position and a colour
      const segment = {
        // The position is a random number between 0 and 1
        index: j,
        // The colour is a random number between 0 and 1
        colour: features.palette.colors[Math.floor(noiseVal * features.palette.colors.length)].value
      }
      // Add the segment to the ring
      ring.push(segment)
    }
    const thisRing = {
      index: i,
      segments: ring,
      position: -i
    }
    // Add the ring to the tunnel
    features.tunnel.push(thisRing)
  }
}

//  Call the above make features, so we'll have the window.$fxhashFeatures available
//  for fxhash
makeFeatures()

console.log(features)
console.table(window.$fxhashFeatures)

const init = async () => {
  //  I should add a timer to this, but really how often to people who aren't
  //  the developer resize stuff all the time. Stick it in a digital frame and
  //  have done with it!
  window.addEventListener('resize', async () => {
    //  If we do resize though, work out the new size...
    clearTimeout(resizeTmr)
    resizeTmr = setTimeout(async () => {
      await layoutCanvas()
    }, 100)
  })

  //  Now layout the canvas
  await layoutCanvas()
}

const layoutCanvas = async () => {
  const wWidth = window.innerWidth
  const wHeight = window.innerHeight
  let cWidth = wWidth
  let cHeight = cWidth * ratio
  if (cHeight > wHeight) {
    cHeight = wHeight
    cWidth = wHeight / ratio
  }
  const canvas = document.getElementById('target')
  if (highRes) {
    canvas.height = 8192
    canvas.width = 8192 / ratio
  } else {
    canvas.width = Math.min((8192 / 2), cWidth * 2)
    canvas.height = Math.min((8192 / ratio / 2), cHeight * 2)
    //  Minimum size to be half of the high rez cersion
    if (Math.min(canvas.width, canvas.height) < 8192 / 2) {
      if (canvas.width < canvas.height) {
        canvas.height = 8192 / 2
        canvas.width = 8192 / 2 / ratio
      } else {
        canvas.width = 8192 / 2
        canvas.height = 8192 / 2 / ratio
      }
    }
  }

  canvas.style.position = 'absolute'
  canvas.style.width = `${cWidth}px`
  canvas.style.height = `${cHeight}px`
  canvas.style.left = `${(wWidth - cWidth) / 2}px`
  canvas.style.top = `${(wHeight - cHeight) / 2}px`

  //  And draw it!!
  drawCanvas()
}

const drawCanvas = async () => {
  // Clear the animation frame
  if (aniFrame) window.cancelAnimationFrame(aniFrame)
  //  Let the preloader know that we've hit this function at least once
  drawn = true

  const canvas = document.getElementById('target')
  const ctx = canvas.getContext('2d')
  const w = canvas.width
  const h = canvas.height

  // Fill the canvas with black
  ctx.fillStyle = '#000000'
  ctx.fillRect(0, 0, w, h)

  // Set the line width
  ctx.lineWidth = w / 500
  ctx.lineCap = 'round'
  ctx.join = 'round'

  // Save the canvas state
  ctx.save()
  // translate the canvas to the center of the screen
  ctx.translate(w / 2, h / 2)
  // Grab the position of the ring with index 0
  let lastRing = null
  for (let i = 0; i < features.tunnel.length; i++) {
    if (features.tunnel[i].index === features.rings - 1) {
      lastRing = features.tunnel[i]
      break
    }
  }
  const percent = 1 - (lastRing.position / -(features.rings))
  const rotateRange = (percent * 90) + (tick * 90)
  // Rotate the canvas the percent of 360
  ctx.rotate(rotateRange * Math.PI / 180)
  const wobbleOffset = Math.sin(rotateRange * 1 * Math.PI / 180)

  // Loop through the rings
  for (let i = features.rings - 1; i >= 0; i--) {
    // Get the ring
    const ring = features.tunnel[i]
    // The segments are going to go around the origin in a cirlce
    // so we need to work out the angle between each segment
    const angle = Math.PI * 2 / features.segments
    // Work out the radius of the ring, based on the position of the ring
    let radiusOuter = (w / 2) * (1 - (ring.position / -features.rings))
    let radiusInner = (w / 2) * (1 - ((ring.position + 1) / -features.rings))
    // Now we want to add a little bit of perspective to the rings, so they bunch up the closer they are to the origin
    // We do this by scaling the radius of the ring by the position of the ring
    radiusOuter *= (1 + (ring.position / features.rings)) * 2
    radiusInner *= (1 + ((ring.position + 1) / features.rings)) * 2

    // Grab the x/y position source for the noise for this ring
    // This is the starting point for the noise, and is used to offset the noise
    // Loop through the segments
    for (let j = 0; j < features.segments; j++) {
      // Get the segment
      const segment = ring.segments[j]
      // Work out the four root corners of the segment, modified for the width and height
      const rootCorners = {
        c1: {
          x: radiusInner * Math.cos(angle * segment.index) / w,
          y: radiusInner * Math.sin(angle * segment.index) / h
        },
        c2: {
          x: radiusOuter * Math.cos(angle * segment.index) / w,
          y: radiusOuter * Math.sin(angle * segment.index) / h
        },
        c3: {
          x: radiusOuter * Math.cos(angle * (segment.index + 1)) / w,
          y: radiusOuter * Math.sin(angle * (segment.index + 1)) / h
        },
        c4: {
          x: radiusInner * Math.cos(angle * (segment.index + 1)) / w,
          y: radiusInner * Math.sin(angle * (segment.index + 1)) / h
        }
      }

      // Work out the radius adjutment for each corner
      const radiusAdjustment = {
        c1: noise.perlin3(rootCorners.c1.x + features.tunnelNoiseOffset.x, rootCorners.c1.y + features.tunnelNoiseOffset.y, wobbleOffset) * 0.5 + 1,
        c2: noise.perlin3(rootCorners.c2.x + features.tunnelNoiseOffset.x, rootCorners.c2.y + features.tunnelNoiseOffset.y, wobbleOffset) * 0.5 + 1,
        c3: noise.perlin3(rootCorners.c3.x + features.tunnelNoiseOffset.x, rootCorners.c3.y + features.tunnelNoiseOffset.y, wobbleOffset) * 0.5 + 1,
        c4: noise.perlin3(rootCorners.c4.x + features.tunnelNoiseOffset.x, rootCorners.c4.y + features.tunnelNoiseOffset.y, wobbleOffset) * 0.5 + 1
      }

      const x1 = (radiusInner * radiusAdjustment.c1) * Math.cos(angle * segment.index)
      const y1 = (radiusInner * radiusAdjustment.c1) * Math.sin(angle * segment.index)
      const x2 = (radiusOuter * radiusAdjustment.c2) * Math.cos(angle * segment.index)
      const y2 = (radiusOuter * radiusAdjustment.c2) * Math.sin(angle * segment.index)
      const x3 = (radiusOuter * radiusAdjustment.c3) * Math.cos(angle * (segment.index + 1))
      const y3 = (radiusOuter * radiusAdjustment.c3) * Math.sin(angle * (segment.index + 1))
      const x4 = (radiusInner * radiusAdjustment.c4) * Math.cos(angle * (segment.index + 1))
      const y4 = (radiusInner * radiusAdjustment.c4) * Math.sin(angle * (segment.index + 1))

      // Work out the colour of the segment, as hsl
      const hsl = rgbToHsl(hexToRgb(segment.colour))
      // Work out the luminosity of the segment, based on how far in the ring is
      // so it tends to be darker the closer it is to the origin
      const lumMod = radiusOuter / (w / 2)

      // Draw the segment
      ctx.beginPath()
      ctx.moveTo(x1, y1)
      ctx.lineTo(x2, y2)
      ctx.lineTo(x3, y3)
      ctx.lineTo(x4, y4)
      ctx.closePath()
      ctx.strokeStyle = `hsl(${hsl.h}, ${hsl.s * lumMod}%, ${hsl.l * lumMod}%)`
      ctx.fillStyle = `hsl(${hsl.h}, ${hsl.s * lumMod}%, ${hsl.l * lumMod}%)`
      ctx.fill()
      ctx.stroke()
      // Restore the canvas state
    }

    // add a little to the position of the ring
    ring.position += 0.1
  }

  // Loop through the rings, if any of them have a position greater than 1, then we need to remove it from the array and stick it on the end
  for (let i = 0; i < features.rings; i++) {
    if (features.tunnel[i].position > 0) {
      const ring = features.tunnel.splice(i, 1)[0]
      ring.position -= features.rings
      features.tunnel.push(ring)
      // if that was ring0 then we need to update the tick
      if (ring.index === features.rings - 1) {
        tick += 1
        if (tick > 3) {
          console.log('Loop finished')
          tick = 0
        }
      }
    }
  }

  // Restore the canvas state
  ctx.restore()

  // Call the draw function again
  aniFrame = window.requestAnimationFrame(drawCanvas)
}

const autoDownloadCanvas = async (showHash = false) => {
  const element = document.createElement('a')
  element.setAttribute('download', `Genuary_01_${fxhash}`)
  element.style.display = 'none'
  document.body.appendChild(element)
  let imageBlob = null
  imageBlob = await new Promise(resolve => document.getElementById('target').toBlob(resolve, 'image/png'))
  element.setAttribute('href', window.URL.createObjectURL(imageBlob, {
    type: 'image/png'
  }))
  element.click()
  document.body.removeChild(element)
}

//  KEY PRESSED OF DOOM
document.addEventListener('keypress', async (e) => {
  e = e || window.event
  // Save
  if (e.key === 's') autoDownloadCanvas()

  //   Toggle highres mode
  if (e.key === 'h') {
    highRes = !highRes
    console.log('Highres mode is now', highRes)
    await layoutCanvas()
  }
})

//  This preloads the images so we can get access to them
// eslint-disable-next-line no-unused-vars
const preloadImages = () => {
  if (!drawn) {
    clearInterval(preloadImagesTmr)
    init()
  }
}

Project files


Home
Changelog
Page created in: 1ms