内容的交互式星空背景

Avatar of Jhey Tompkins
Jhey Tompkins on

DigitalOcean 为您旅程的每个阶段提供云产品。从 $200 免费积分 开始!

去年我很幸运地得到了 Shawn Wang (swyx) 的联系,为 Temporal 做一些工作。想法是让我对网站上的内容进行创造性的思考,并提出 一些想法,这些想法可以为网站增添一些额外的“东西”。这是一个相当有趣的挑战,因为我认为自己更像是一个开发者而不是设计师。但我喜欢学习并提升我设计技能的水平。

我想到的一个想法是这个交互式星空背景。您可以在这个共享演示中看到它的工作原理

这个设计最棒的地方在于它被构建成一个可直接使用的 React 组件。并且它超级可配置,因为一旦你构建了它的基础,你就可以完全自定义它。不想要星星?放点别的东西。不想随机放置粒子?以构建的方式放置它们。你可以完全控制如何让它屈服于你的意志。

所以,让我们看看如何为你的网站创建这个可直接使用的组件!今天选择的武器是什么?ReactGreenSock 和 HTML <canvas>。当然,React 部分完全是可选的,但是,将这个交互式背景作为可直接使用的组件可以让它在其他项目中使用。

让我们从搭建一个基本应用程序开始

import React from 'https://cdn.skypack.dev/react'
import ReactDOM from 'https://cdn.skypack.dev/react-dom'
import gsap from 'https://cdn.skypack.dev/gsap'

const ROOT_NODE = document.querySelector('#app')

const Starscape = () => <h1>Cool Thingzzz!</h1>

const App = () => <Starscape/>

ReactDOM.render(<App/>, ROOT_NODE)

首先我们需要做的是渲染一个 <canvas> 元素,并获取一个对它的引用,以便我们在 React 的 useEffect 中使用。对于没有使用 React 的人,请将对 <canvas> 的引用存储在一个变量中。

const Starscape = () => {
  const canvasRef = React.useRef(null)
  return <canvas ref={canvasRef} />
}

我们的 <canvas> 也需要一些样式。首先,我们可以让画布占据整个视窗大小,并位于内容的后面

canvas {
  position: fixed;
  inset: 0;
  background: #262626;
  z-index: -1;
  height: 100vh;
  width: 100vw;
}

很酷!但目前还看不到什么。

我们需要天上的星星

我们在这里要稍微“作弊”。我们不会绘制“经典”的尖角星形。我们将使用不同透明度和大小的圆圈。

<canvas> 上绘制一个圆圈需要从 <canvas> 中获取上下文,并使用 arc 函数。让我们在中间渲染一个圆圈,也就是星星。我们可以在 React 的 useEffect 中做到这一点

const Starscape = () => {
  const canvasRef = React.useRef(null)
  const contextRef = React.useRef(null)
  React.useEffect(() => {
    canvasRef.current.width = window.innerWidth
    canvasRef.current.height = window.innerHeight
    contextRef.current = canvasRef.current.getContext('2d')
    contextRef.current.fillStyle = 'yellow'
    contextRef.current.beginPath()
    contextRef.current.arc(
      window.innerWidth / 2, // X
      window.innerHeight / 2, // Y
      100, // Radius
      0, // Start Angle (Radians)
      Math.PI * 2 // End Angle (Radians)
    )
    contextRef.current.fill()
  }, [])
  return <canvas ref={canvasRef} />
}

所以我们得到一个大黄圈

这是一个良好的开端!我们剩下的代码将在此 useEffect 函数中执行。这就是为什么 React 部分是可选的。你可以提取这段代码并以你喜欢的任何形式使用它。

我们需要考虑如何生成一组“星星”并渲染它们。让我们创建一个 LOAD 函数。此函数将处理生成我们的星星以及一般的 <canvas> 设置。我们也可以将 <canvas> 大小逻辑移到此函数中

const LOAD = () => {
  const VMIN = Math.min(window.innerHeight, window.innerWidth)
  const STAR_COUNT = Math.floor(VMIN * densityRatio)
  canvasRef.current.width = window.innerWidth
  canvasRef.current.height = window.innerHeight
  starsRef.current = new Array(STAR_COUNT).fill().map(() => ({
    x: gsap.utils.random(0, window.innerWidth, 1),
    y: gsap.utils.random(0, window.innerHeight, 1),
    size: gsap.utils.random(1, sizeLimit, 1),
    scale: 1,
    alpha: gsap.utils.random(0.1, defaultAlpha, 0.1),
  }))
}

我们的星星现在是一个对象数组。每个星星都有定义其特征的属性,包括

  • x: 星星在 x 轴上的位置
  • y: 星星在 y 轴上的位置
  • size: 星星的大小,以像素为单位
  • scale: 星星的比例,当我们与组件交互时会发挥作用
  • alpha: 星星的 alpha 值,或 opacity,在交互过程中也会发挥作用

我们可以使用 GreenSock 的 random() 方法 生成其中一些值。你可能也想知道 sizeLimitdefaultAlphadensityRatio 从哪里来。这些现在是我们可以传递给 Starscape 组件的 props。我们为它们提供了一些默认值

const Starscape = ({ densityRatio = 0.5, sizeLimit = 5, defaultAlpha = 0.5 }) => {

一个随机生成的星星 Object 可能看起来像这样

{
  "x": 1252,
  "y": 29,
  "size": 4,
  "scale": 1,
  "alpha": 0.5
}

但是,我们需要看到这些星星,我们通过渲染它们来做到这一点。让我们创建一个 RENDER 函数。此函数将遍历我们的星星,并使用 arc 函数将每个星星渲染到 <canvas>

const RENDER = () => {
  contextRef.current.clearRect(
    0,
    0,
    canvasRef.current.width,
    canvasRef.current.height
  )
  starsRef.current.forEach(star => {
    contextRef.current.fillStyle = `hsla(0, 100%, 100%, ${star.alpha})`
    contextRef.current.beginPath()
    contextRef.current.arc(star.x, star.y, star.size / 2, 0, Math.PI * 2)
    contextRef.current.fill()
  })
}

现在,我们不需要那个 clearRect 函数,因为我们只在空白 <canvas> 上渲染一次。但是,在渲染任何东西之前清除 <canvas> 并不是一个坏习惯,而且在我们使 canvas 具有交互性时,我们需要它。

请考虑这个演示,它展示了在帧之间不清除的效果。

我们的 Starscape 组件开始成形了。

查看代码
const Starscape = ({ densityRatio = 0.5, sizeLimit = 5, defaultAlpha = 0.5 }) => {
  const canvasRef = React.useRef(null)
  const contextRef = React.useRef(null)
  const starsRef = React.useRef(null)
  React.useEffect(() => {
    contextRef.current = canvasRef.current.getContext('2d')
    const LOAD = () => {
      const VMIN = Math.min(window.innerHeight, window.innerWidth)
      const STAR_COUNT = Math.floor(VMIN * densityRatio)
      canvasRef.current.width = window.innerWidth
      canvasRef.current.height = window.innerHeight
      starsRef.current = new Array(STAR_COUNT).fill().map(() => ({
        x: gsap.utils.random(0, window.innerWidth, 1),
        y: gsap.utils.random(0, window.innerHeight, 1),
        size: gsap.utils.random(1, sizeLimit, 1),
        scale: 1,
        alpha: gsap.utils.random(0.1, defaultAlpha, 0.1),
      }))
    }
    const RENDER = () => {
      contextRef.current.clearRect(
        0,
        0,
        canvasRef.current.width,
        canvasRef.current.height
      )
      starsRef.current.forEach(star => {
        contextRef.current.fillStyle = `hsla(0, 100%, 100%, ${star.alpha})`
        contextRef.current.beginPath()
        contextRef.current.arc(star.x, star.y, star.size / 2, 0, Math.PI * 2)
        contextRef.current.fill()
      })
    }
    LOAD()
    RENDER()
  }, [])
  return <canvas ref={canvasRef} />
}

在这个演示中尝试一下 props,看看它们如何影响星星的渲染方式。

在继续之前,你可能已经注意到演示中存在一个怪癖,即调整视窗大小会扭曲 <canvas>。为了快速解决问题,我们可以在 resize 时重新运行 LOADRENDER 函数。在大多数情况下,我们还希望对它进行 防抖。我们可以将以下代码添加到我们的 useEffect 调用中。注意我们如何在拆卸时也删除了事件侦听器。

// Naming things is hard...
const RUN = () => {
  LOAD()
  RENDER()
}

RUN()

// Set up event handling
window.addEventListener('resize', RUN)
return () => {
  window.removeEventListener('resize', RUN)
}

很酷。现在,当我们调整视窗大小,我们就会得到一个新的生成的星空。

与星空背景交互

现在是乐趣的开始!让我们让它变得交互式

想法是,当我们在屏幕上移动指针时,我们会检测星星与鼠标光标的接近程度。根据接近程度,星星会同时变亮和放大。

我们需要添加另一个事件侦听器来实现这一点。让我们把它叫做 UPDATE。这将计算指针与每个星星之间的距离,然后对每个星星的 scalealpha 值进行缓动。为了确保缓动的值是正确的,我们可以使用 GreenSock 的 mapRange() 工具。事实上,在我们的 LOAD 函数中,我们可以创建对一些映射函数的引用以及一个大小单位,然后在需要时在函数之间共享这些引用。

这是我们的新 LOAD 函数。请注意 scaleLimitproximityRatio 的新 props。它们用于限制星星可以变大和变小的范围,以及基于此的接近程度。

const Starscape = ({
  densityRatio = 0.5,
  sizeLimit = 5,
  defaultAlpha = 0.5,
  scaleLimit = 2,
  proximityRatio = 0.1
}) => {
  const canvasRef = React.useRef(null)
  const contextRef = React.useRef(null)
  const starsRef = React.useRef(null)
  const vminRef = React.useRef(null)
  const scaleMapperRef = React.useRef(null)
  const alphaMapperRef = React.useRef(null)
  
  React.useEffect(() => {
    contextRef.current = canvasRef.current.getContext('2d')
    const LOAD = () => {
      vminRef.current = Math.min(window.innerHeight, window.innerWidth)
      const STAR_COUNT = Math.floor(vminRef.current * densityRatio)
      scaleMapperRef.current = gsap.utils.mapRange(
        0,
        vminRef.current * proximityRatio,
        scaleLimit,
        1
      );
      alphaMapperRef.current = gsap.utils.mapRange(
        0,
        vminRef.current * proximityRatio,
        1,
        defaultAlpha
      );
    canvasRef.current.width = window.innerWidth
    canvasRef.current.height = window.innerHeight
    starsRef.current = new Array(STAR_COUNT).fill().map(() => ({
      x: gsap.utils.random(0, window.innerWidth, 1),
      y: gsap.utils.random(0, window.innerHeight, 1),
      size: gsap.utils.random(1, sizeLimit, 1),
      scale: 1,
      alpha: gsap.utils.random(0.1, defaultAlpha, 0.1),
    }))
  }
}

这是我们的 UPDATE 函数。它计算距离并为星星生成合适的 scalealpha

const UPDATE = ({ x, y }) => {
  starsRef.current.forEach(STAR => {
    const DISTANCE = Math.sqrt(Math.pow(STAR.x - x, 2) + Math.pow(STAR.y - y, 2));
    gsap.to(STAR, {
      scale: scaleMapperRef.current(
        Math.min(DISTANCE, vminRef.current * proximityRatio)
      ),
      alpha: alphaMapperRef.current(
        Math.min(DISTANCE, vminRef.current * proximityRatio)
      )
    });
  })
};

等等… 它什么也没做?

嗯,它做了。但是,我们还没有设置我们的组件来显示更新。我们需要在交互时渲染新的帧。我们可以经常使用 requestAnimationFrame。但是,因为我们使用的是 GreenSock,所以我们可以利用 gsap.ticker。这通常被称为“GSAP 引擎的心跳”,它是 requestAnimationFrame 的一个很好的替代品。

要使用它,我们将 RENDER 函数添加到 ticker,并确保在拆卸时将其删除。使用 ticker 的一个好处是我们可以指定每秒帧数 (fps)。我喜欢使用“电影”风格的 24fps

// Remove RUN
LOAD()
gsap.ticker.add(RENDER)
gsap.ticker.fps(24)

window.addEventListener('resize', LOAD)
document.addEventListener('pointermove', UPDATE)
return () => {
  window.removeEventListener('resize', LOAD)
  document.removeEventListener('pointermove', UPDATE)
  gsap.ticker.remove(RENDER)
}

请注意,我们现在也在 resize 时运行 LOAD。我们还需要确保在使用 arc 时,我们的 scaleRENDER 函数中被拾取

const RENDER = () => {
  contextRef.current.clearRect(
    0,
    0,
    canvasRef.current.width,
    canvasRef.current.height
  )
  starsRef.current.forEach(star => {
    contextRef.current.fillStyle = `hsla(0, 100%, 100%, ${star.alpha})`
    contextRef.current.beginPath()
    contextRef.current.arc(
      star.x,
      star.y,
      (star.size / 2) * star.scale,
      0,
      Math.PI * 2
    )
    contextRef.current.fill()
  })
}

它起作用了!🙌

这是一个非常微妙的效果。但是,这是故意的,因为虽然它超级酷,但我们不希望这种东西分散对实际内容的注意力。我建议尝试一下组件的 props,看看不同的效果。默认情况下将所有星星设置为低 alpha 也是有意义的。

以下演示允许您玩弄不同的 props。为了演示的目的,我在此选择了一些非常突出的默认值!但请记住,本文更多的是向您展示技巧,以便您可以自己创建酷炫的背景——同时注意它如何与内容交互。

改进

我们交互式星空背景存在一个问题。如果鼠标光标离开<canvas>,星星会保持明亮并放大,但我们希望它们恢复到原始状态。为了解决这个问题,我们可以为pointerleave添加一个额外的处理程序。当指针离开时,这会将所有星星的比例缩小到1,并将defaultAlpha设置的原始 alpha 值进行动画处理。

const EXIT = () => {
  gsap.to(starsRef.current, {
    scale: 1,
    alpha: defaultAlpha,
  })
}

// Set up event handling
window.addEventListener('resize', LOAD)
document.addEventListener('pointermove', UPDATE)
document.addEventListener('pointerleave', EXIT)
return () => {
  window.removeEventListener('resize', LOAD)
  document.removeEventListener('pointermove', UPDATE)
  document.removeEventListener('pointerleave', EXIT)
  gsap.ticker.remove(RENDER)
}

太棒了!现在,当鼠标光标离开场景时,我们的星星会缩小并恢复到之前的 alpha 值。

额外内容:添加彩蛋

在我们结束之前,让我们在交互式星空背景中添加一个小彩蛋。你听说过科纳米密码吗?这是一个著名的作弊码,也是在我们的组件中添加彩蛋的好方法。

一旦代码运行,我们几乎可以对背景做任何事情。例如,我们可以让所有星星以随机的方式闪烁。或者它们可以变成其他颜色?这是一个发挥创意的机会!

我们将监听键盘事件并检测是否输入了代码。让我们先创建一个用于代码的变量

const KONAMI_CODE =
  'arrowup,arrowup,arrowdown,arrowdown,arrowleft,arrowright,arrowleft,arrowright,keyb,keya';

然后我们在星空背景中创建第二个效果。这是一个很好的方法,可以将关注点分离,一个效果处理所有渲染,另一个效果处理彩蛋。具体来说,我们正在监听keyup事件并检查我们的输入是否与代码匹配。

const codeRef = React.useRef([])
React.useEffect(() => {
  const handleCode = e => {
    codeRef.current = [...codeRef.current, e.code]
      .slice(
        codeRef.current.length > 9 ? codeRef.current.length - 9 : 0
      )
    if (codeRef.current.join(',').toLowerCase() === KONAMI_CODE) {
      // Party in here!!!
    }
  }
  window.addEventListener('keyup', handleCode)
  return () => {
    window.removeEventListener('keyup', handleCode)
  }
}, [])

我们将用户输入存储在一个Array中,该Array存储在一个ref中。一旦我们击中代码,我们就可以清除Array并做任何我们想做的事情。例如,我们可以创建一个gsap.timeline,在给定时间内对我们的星星做一些操作。如果是这种情况,我们不希望在时间线处于活动状态时允许输入科纳米密码。相反,我们可以将timeline存储在一个ref中,并在运行代码之前进行另一个检查。

const partyRef = React.useRef(null)
const isPartying = () =>
  partyRef.current &&
  partyRef.current.progress() !== 0 &&
  partyRef.current.progress() !== 1;

在这个例子中,我创建了一个小的时间线,它会给每个星星上色并将其移动到一个新的位置。这需要更新我们的LOADRENDER函数。

首先,我们需要让每个星星都有自己的huesaturationlightness

// Generating stars! ⭐️
starsRef.current = new Array(STAR_COUNT).fill().map(() => ({
  hue: 0,
  saturation: 0,
  lightness: 100,
  x: gsap.utils.random(0, window.innerWidth, 1),
  y: gsap.utils.random(0, window.innerHeight, 1),
  size: gsap.utils.random(1, sizeLimit, 1),
  scale: 1,
  alpha: defaultAlpha
}));

其次,我们需要在渲染发生时考虑这些新值

starsRef.current.forEach((star) => {
  contextRef.current.fillStyle = `hsla(
    ${star.hue},
    ${star.saturation}%,
    ${star.lightness}%,
    ${star.alpha}
  )`;
  contextRef.current.beginPath();
  contextRef.current.arc(
    star.x,
    star.y,
    (star.size / 2) * star.scale,
    0,
    Math.PI * 2
  );
  contextRef.current.fill();
});

这是移动所有星星的有趣代码

partyRef.current = gsap.timeline().to(starsRef.current, {
  scale: 1,
  alpha: defaultAlpha
});

const STAGGER = 0.01;

for (let s = 0; s < starsRef.current.length; s++) {
  partyRef.current
    .to(
    starsRef.current[s],
    {
      onStart: () => {
        gsap.set(starsRef.current[s], {
          hue: gsap.utils.random(0, 360),
          saturation: 80,
          lightness: 60,
          alpha: 1,
        })
      },
      onComplete: () => {
        gsap.set(starsRef.current[s], {
          saturation: 0,
          lightness: 100,
          alpha: defaultAlpha,
        })
      },
      x: gsap.utils.random(0, window.innerWidth),
      y: gsap.utils.random(0, window.innerHeight),
      duration: 0.3
    },
    s * STAGGER
  );
}

从那里,我们生成一个新的时间线并对每个星星的值进行动画处理。这些新值会被RENDER接收。我们通过使用 GSAP 的 position 参数 在时间线上定位每个动画来添加交错效果。

就是这样!

这就是为您的网站创建交互式星空背景的一种方法。我们结合了 GSAP 和 HTML <canvas>,甚至还加入了一些 React,使其更具可配置性和可重用性。我们甚至还添加了一个彩蛋!

您从这里可以将此组件带到哪里?您如何在网站上使用它?GreenSock 和 <canvas> 的组合非常有趣,我期待着看到您的作品!以下是一些让您创意迸发的想法…