随笔之如何用一根灯带做出雷电的效果

160 阅读4分钟

前言

最近在做魔杖(横刀版)项目,想搞个比较帅的光效。但是我的刀只安装了一条灯带,许多效果就没法实现了,上网查了很多资料也没有相关的文章,问 gpt 更是啥也没有,于是自己写了个思路,仅供参考。

btw,我的灯带是 34 * 1 的,也就是有34个灯,下面叙述的步骤都以34灯为基准

核心思路

将一个视频压缩成34 * 1像素,不就可以搬上去了吗

步骤

  1. 找到一个雷电效果的视频 截屏2024-07-27 16.07.00.png

  2. 使用剪映将其处理为 340 * 100px 的视频,并将主元素(闪电)放在正中间,滤色掉无用的地方,并做一些拉伸

截屏2024-07-27 16.10.42.png

  1. 前置的准备素材到这里就够了,后续的代码直接贴上:
<!DOCTYPE html>
<html lang="zh">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>视频帧分析</title>
    <style>
      .square {
        width: 10px;
        height: 10px;
        display: inline-block; /* 使用块级元素展示 */
      }
    </style>
  </head>
  <body>
    <video id="video" width="340" height="100" controls>
      <source src="./test.mp4" type="video/mp4" />
      您的浏览器不支持视频标签。
    </video>
    <canvas id="canvas" style="display: none"></canvas>
    <canvas id="binaryCanvas" style="display: none"></canvas>
    <div id="finalAnimation"></div>

    <script>
      const video = document.getElementById("video");
      const canvas = document.getElementById("canvas");
      const binaryCanvas = document.getElementById("binaryCanvas");
      const context = canvas.getContext("2d");
      const binaryContext = binaryCanvas.getContext("2d");
      const finalAnimation = document.getElementById("finalAnimation");

      const frameInterval = 1; // 每隔10帧分析一次
      const binaryThreshold = 128; // 二值化阈值
      const frameResults = []; // 存储每帧分析结果
      const colors = []; // 存储颜色等级

      // 生成颜色等级
      for (let i = 0; i <= 10; i++) {
        // 11个等级
        const green = 255; // 固定绿色值
        const blue = 255 - i * 20; // 蓝色值逐渐降低
        colors.push(`rgb(255, ${green}, ${blue})`);
      }

      video.addEventListener("play", () => {
        const fps = 60;
        const interval = 1000 / fps; // 计算每帧间隔

        frameResults.length = 0; // 清空结果数组

        const intervalId = setInterval(() => {
          if (video.paused || video.ended) {
            clearInterval(intervalId);
            displayResults(); // 播放结束时显示结果
            return;
          }

          // 每隔frameInterval帧进行一次分析
          if (Math.floor(video.currentTime * fps) % frameInterval === 0) {
            analyzeFrame();
          }
        }, interval);
      });

      function analyzeFrame() {
        if (video.ended) return;

        const width = video.videoWidth;
        const height = video.videoHeight;
        canvas.width = width;
        canvas.height = height;
        binaryCanvas.width = width;
        binaryCanvas.height = height;

        context.drawImage(video, 0, 0, width, height); // 绘制当前帧
        const imageData = context.getImageData(0, 0, width, height);
        const data = imageData.data;
        const binaryNonBlackPixels = new Array(34).fill(0); // 记录每个段的非黑色像素数量

        // 二值化处理
        const binaryImageData = new Uint8ClampedArray(data.length);
        for (let i = 0; i < data.length; i += 4) {
          const brightness =
            0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
          const binaryValue = brightness > binaryThreshold ? 255 : 0;
          binaryImageData[i] = binaryValue;
          binaryImageData[i + 1] = binaryValue;
          binaryImageData[i + 2] = binaryValue;
          binaryImageData[i + 3] = 255; // 设置 alpha 通道
        }

        binaryContext.putImageData(
          new ImageData(binaryImageData, width, height),
          0,
          0
        );

        // 统计每个段的非黑色像素数量
        for (let y = 0; y < height; y++) {
          for (let x = 0; x < width; x++) {
            const index = (y * width + x) * 4;
            if (binaryImageData[index] === 255) {
              const segment = Math.floor(x / (width / 34)); // 根据宽度划分段
              binaryNonBlackPixels[segment]++;
            }
          }
        }

        // 根据非黑色像素数量进行分类
        const maxPixels = Math.max(...binaryNonBlackPixels);
        const levelThresholds = Array.from(
          { length: 11 },
          (_, i) => (maxPixels / 10) * i
        ); // 生成等级阈值

        const categorizedPixels = binaryNonBlackPixels.map((count) => {
          if (count === 0) return 0;
          return levelThresholds.findIndex((threshold) => count <= threshold);
        });

        frameResults.push(categorizedPixels); // 存储当前帧的结果
      }

      function displayResults() {
        // 打印整个结果数组的倒序格式
        const reversedFrameResults = frameResults.map((frame) =>
          frame.slice().reverse()
        ); // 倒序每一项,使用 slice() 保持原数组不变

        // 转换为 C 语言格式字符串
        const cStyleOutput = `uint8_t lightningAnimation[][34] = {\n${reversedFrameResults
          .map((frame) => `{ ${frame.join(", ")} }`)
          .join(",\n")}\n};`;

        console.log(cStyleOutput); // 打印 C 语言格式的结果数组

        if (finalAnimation.firstChild) {
          finalAnimation.removeChild(finalAnimation.firstChild); // 清除上一次的结果
        }

        if (frameResults.length > 0) {
          const finalSquaresContainer = document.createElement("div");
          frameResults[0].forEach(() => {
            const square = document.createElement("div");
            square.className = "square";
            finalSquaresContainer.appendChild(square);
          });
          finalAnimation.appendChild(finalSquaresContainer); // 添加方块容器

          let frameIndex = 0;
          const finalAnimationInterval = setInterval(() => {
            if (frameIndex < frameResults.length) {
              const currentFrame = frameResults[frameIndex];
              finalSquaresContainer.childNodes.forEach((square, index) => {
                square.style.backgroundColor = colors[currentFrame[index]]; // 更新方块颜色
              });
              frameIndex++;
            } else {
              clearInterval(finalAnimationInterval); // 停止动画
            }
          }, 300);
        }
      }

      // 根据视频播放时间更新方块颜色
      video.addEventListener("timeupdate", () => {
        if (finalAnimation.firstChild && frameResults.length > 0) {
          const frameIndex = Math.floor(
            (video.currentTime * 60) / frameInterval
          );
          if (frameIndex < frameResults.length) {
            const currentFrame = frameResults[frameIndex];
            finalAnimation.firstChild.childNodes.forEach((square, index) => {
              if (index < currentFrame.length) {
                square.style.backgroundColor = colors[currentFrame[index]]; // 更新方块颜色
              }
            });
          }
        }
      });
    </script>
  </body>
</html>

来稍微讲解一下这段代码做了哪些事情:

  1. 将视频放入网页中
  2. 对视频的每一帧,都进行处理
  • 二值化,将图片变为黑白方便后续操作
  • 二值化后,将图片横向分为10份,每份10px
  • 统计每份中,白点像素出现的数量
  • 根据白点数量,将其划分为10个等级
  1. 当处理完所有帧后,我们就得到了一个视频每帧的等级数组
uint8_t lightningAnimation[][34] = {
{ 0, 0, 0, 0, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
{ 2, 8, 10, 10, 9, 5, 2, 0, 1, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
{ 2, 8, 10, 10, 9, 5, 2, 0, 1, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 3, 7, 10, 9, 9, 7, 8, 7, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 3, 7, 10, 9, 9, 7, 8, 7, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
// ...此处省略若干行
};
  1. 让这34个灯按数组顺序亮起,并保持切换速度为 1000/60,这样我们的灯带就得到了对应的效果了

// 此处应该有个视频演示,但是掘金视频发不上来,就这样吧