バンドの練習風景撮ったのを webgl(three.js)で GPGPU 使いつつ curlnoise の particle で遊んでみました。

06/11/2020

仕事だったり育児でなかなか集まれず、
どこか目指してる訳でもないですが続けていきたいなーと思ってるバンドの練習風景撮ったのをwebgl(three.js)で GPGPU とか使いつつ curlnoise で遊んでみました。

ちょっと時間に余裕があった時に気まぐれに始めたらなかなか時間がかかってしまった。

普段独り言みたいなツイートでほぼ反応ない
僕のツイッターにしては多少反応があったので記事書いてみました。

成果物等

URL

https://nocebo.jp/band/prs/public/

YOUTUBE

ソース

CodeSandbox

ごちゃごちゃだけどcodesandbox用意してみました。

ざっくり概要

ざっくり概要としては

  • 複数 video からランダムでビデオテクスチャ切り替え
  • 画角もランダムに切り取ってビデオ描画(背景ビデオ)
  • その上にビデオテクスチャから色情報参照したパーティクル(カールノイズ)も描画
  • 切り替えや動きはなんとなく音に合わせたタイミング

という感じです。
そこそこ時間かかったので自分で忘れない用に実装内容ある程度記事にしてみます。

ざっくり環境

React

React 歴浅いので多分だめな所いっぱいあります。

styled-components

スタイル系はstyled-components使ってます。

react-three-fiber

ReactThree 使うならコレがいいっよて情報みかけたので使ってます。
Three とか GLSL も趣味で触るぐらい。

Typescript

Typescript でやってますが、
個人的には Typescript 嫌い、linter もちょっと頑張ろうかなーって一瞬思ったけど、
ものの数分であきらめました。
遠慮なく any 使って、自分勝手に動けばいいやという感じなのであしからずです。

————–以降はソースの軽いおぼえ書き————–

目次的にちょっとおかしくなるけど以降はソース各所の軽いおぼえ書きです。

codesandboxだとtsconfigpathsが効かない?気がするので
相対で色々importしてます。

あとローカルだとshader-loaderとか使ってたけど
これもcodesandboxだと駄目っぽい?かも

なのでtsでテキストとしてimportしてます。

index

// =======================================
// メインの呼び出し元
// =======================================

import React, { createContext, useState, useRef } from "react";
import ReactDOM from "react-dom";

import { library } from "@fortawesome/fontawesome-svg-core";
import { fab } from "@fortawesome/free-brands-svg-icons";
import { far } from "@fortawesome/free-regular-svg-icons";
import { fas } from "@fortawesome/free-solid-svg-icons";

import { Reset } from "styled-reset";
import styled from "styled-components";
import "./style.scss";

import GL from "./gl/index";
import StreamLoader from "./media/streamLoader";
import Video from "./media/video";
import Audio from "./media/audio";
// import Recorder from "./media/recorder";
import UI from "./ui/";
import Data from "./data/";

library.add(fab, fas, far);
// console.log(process.env.NODE_ENV);
// =======================================
export const Conf: any = {
  VIDEO_TEXTURE_NUM: 5,
  GL_SIZE_FIX: false, //サイズ固定
  QUALITY: "HIGH",
  TEXTURE_WIDTH: 1920,
  TEXTURE_HEIGHT: 1080,
  PARTICLE_COUNT: 1920 * 1080,
  NORMAL: {
    TEXTURE_WIDTH: 1280,
    TEXTURE_HEIGHT: 720,
    PARTICLE_COUNT: 1280 * 720
  },
  HIGH: {
    TEXTURE_WIDTH: 1920,
    TEXTURE_HEIGHT: 1080,
    PARTICLE_COUNT: 1920 * 1080
  }
};
// =======================================
const Container = styled.div`
  width: 100%;
  height: 100%;
`;

const Root = styled.div`
  width: 100%;
  height: 100%;
`;
// =======================================

const obj: any = {};
export const ResourceContext = createContext([obj, () => {}]);

const Content: React.FC = () => {
  const [resource, setResource] = useState({
    videoState: "pause",
    isDataShow: false,
    canvas: null,
    audio: null
  });

  const videoRef = new Array(Conf.VIDEO_TEXTURE_NUM).fill(
    useRef<HTMLVideoElement>()
  );
  const [videoBuffer, setVideoBuffer] = useState<Uint8Array[]>();
  const [isLoaded, setIsLoaded] = useState<boolean>(false);
  const [isReady, setIsReady] = useState<boolean>(false);

  return !isLoaded ? (
    <StreamLoader setIsLoaded={setIsLoaded} setVideoBuffer={setVideoBuffer} />
  ) : (
    <Root>
      <Video
        videoRef={videoRef}
        videoBuffer={videoBuffer}
        setIsReady={setIsReady}
      />
      {isReady && (
        <ResourceContext.Provider value={[resource, setResource]}>
          <Data videoRef={videoRef} />
          <UI videoRef={videoRef} />
          <Audio videoRef={videoRef} />
          <GL videoRef={videoRef} />
          {/* {process.env.NODE_ENV === "development" && (
            <Recorder videoRef={videoRef} />
          )} */}
        </ResourceContext.Provider>
      )}
    </Root>
  );
};
// =======================================
ReactDOM.render(
  <Container>
    <Reset />
    <Content />
  </Container>,
  document.getElementById("root")
);
// --------------------

indexです。呼び出し元
全体的なstyleとかfontawesome読み込んだり、
あとクオリティ設定とかできるようにしてみた。

あとなんとなくuseContext使ってみたくて入れてみた、が
思ったようなタイミングでsetできなかたりするので使い方間違ってるかも?
useReducer使わないと?なのか?

useContextで利用する部分のメインはRecorderなのだけども
Recorderは手元のローカルで使うだけで、
見るだけなら不要なので多分問題ないはず
コメントアウトしてるけど、一応どういった内容かは追って軽く覚えがき入れる予定。

reduxは使うと結構大げさになっちゃう気がするのでもうちょい暇な時にみてみよう、とは思っている。

data

// =======================================
// データ確認表示用
// あとアップデート値(updateValue)の
// tick処理もここでやっちゃってる
// =======================================

import React, { useEffect, useCallback, useContext, useState, useRef } from "react";
import { ResourceContext, Conf } from "../index";
import styled from "styled-components";

const Root = styled.div`
  position: fixed;
  z-index: 1000;
  top: 20px;
  left: 20px;
  background-color: rgba(0, 0, 0, 0.5);
  color: #fff;
  width: 300px;

  padding: 10px;
  opacity: 0.5;
  line-height: 1.4;
  border-radius: 10px;
  box-sizing: border-box;
`;

const BPM_NUM = 1.565;
const START_TIME = 5.0;

export const updateValue: any = {
  videoState: "pause",
  videoCurrentTime: 0,
  videoTempTime: 0,
  duration: 0,

  renderState: false,

  bar: 0,
  beat: 0,
  beatState: false,
  barState: false,
  lim01State: false,
  lim02State: false,
  kick01State: false,
  kick02State: false,
  copyTexState01: false,
  copyTexState02: false,
  copyTexState03: false,
  copyTexState04: false,

  videoId: 0,
  range: 1.0,
  originX: 0.0,
  originY: 0.0,
  zoom: 0.0,

  seed: Math.random() * 1.0,
  copyTex: false,
  bgOpacity: 1.0,
  referenceColor: 1.0,
  referenceVelocity: 1.0,
  delta: 0.02,
  lim: 0.0,
  blurStrength: 0.0,
  flow01: 1.0,
  flow02: 1.0,
  flow03: 1.0,
  flow04: 1.0,
  flow05: 1.0,
  prev: {},
};

const swicher = () => {
  // ビデオテクスチャ切り替え
  if (updateValue.bar === 28) return;

  // 前回のと重複しないように
  let nextId = Math.floor(Math.random() * Conf.VIDEO_TEXTURE_NUM);
  do {
    nextId = Math.floor(Math.random() * Conf.VIDEO_TEXTURE_NUM);
  } while (updateValue.videoId === nextId);
  updateValue.tempId = updateValue.videoId;
  updateValue.prev = { ...updateValue };
  updateValue.videoId = nextId;
  // ------
  // 拡大位置・スケール
  const range = Math.random() * 0.4 + 0.3;
  const originX = Math.random() * (1.0 - range);
  const originY = Math.random() * (1.0 - range);
  updateValue.range = range;
  updateValue.originX = originX;
  updateValue.originY = originY;
  updateValue.zoom = 0.085;
};

const clear = () => {
  updateValue.lim01State = false;
  updateValue.lim02State = false;
  updateValue.kick01State = false;
  updateValue.kick02State = false;
  updateValue.copyTexState01 = false;
  updateValue.copyTexState02 = false;
  updateValue.copyTexState03 = false;
  updateValue.copyTexState04 = false;
  updateValue.bgOpacity = 1.0;
  updateValue.referenceColor = 1.0;
  updateValue.referenceVelocity = 1.0;
  updateValue.blurStrength = 0.0;
};

const perticleMotion = (barNum: number, beatNum: number) => {
  //  パーティクル止めるやつ
  if (updateValue.videoTempTime === updateValue.videoCurrentTime) return;

  // 再生時
  const n = 0.0;
  const rc = 0.06;
  const bo = 0.06;

  // // 少し前のバッファ保存
  if (beatNum > n - 0.02 && !updateValue.copyTexState01) {
    updateValue.copyTexState01 = true;
    updateValue.copyTex = true;
  }
  // 止めるタイミング
  if (beatNum > n && !updateValue.copyTexState02) {
    updateValue.copyTexState02 = true;
    updateValue.bgOpacity = 0.0;
    updateValue.referenceColor = 0.0;
    updateValue.referenceVelocity = 0.0;
  }
  if (beatNum > 0.1) {
    updateValue.referenceColor += rc;
  }
  // 戻すタイミング
  if (beatNum > 0.23 && !updateValue.copyTexState03) {
    updateValue.copyTexState03 = true;
    updateValue.referenceVelocity = 1.0;
  }
  if (beatNum > 0.23 && beatNum < 1.4) {
    updateValue.bgOpacity += bo;
  }
  if (beatNum > 0.5) {
  }
  // リム2
  if (barNum >= 36) {
    if (!(barNum >= 68 && barNum < 76)) {
      if (beatNum > 1.4) {
        updateValue.bgOpacity = 0.0;
      }
    }
  }
};

const Data: React.FC<any> = (props) => {
  const [resource] = useContext(ResourceContext);
  const videoRef = props.videoRef[1];
  // ----------------------
  // render表示用
  // ----------------------
  const [bar, setBar] = useState(0);
  const [time, setTime] = useState<number>(0);
  const [millisecond, setMillisecond] = useState<number>(0);

  //   -----------------------
  //   update
  //   -----------------------
  const requestRef = useRef<any>();
  const tick = useCallback(() => {
    requestRef.current = requestAnimationFrame(tick);

    // onTimeUpdateだとchromeで?間隔が遅いので 自前tick処理でcurrentTime設定
    const time = videoRef.current.currentTime;
    updateValue.videoTempTime = updateValue.videoCurrentTime;
    updateValue.videoCurrentTime = time;

    setTime(updateValue.videoCurrentTime);
    setMillisecond(Math.abs((updateValue.videoCurrentTime - START_TIME) % BPM_NUM));

    if (time < START_TIME) return;

    // bar 頭 // beat 小節
    const beatNum = (updateValue.videoCurrentTime - START_TIME) % BPM_NUM;
    const barNum = Math.floor((updateValue.videoCurrentTime - START_TIME) / BPM_NUM);

    // 終盤まで再生で一旦クリア
    if (barNum >= 108) {
      updateValue.barState = false;
      clear();
      return;
    }

    // 小節頭
    if (Math.floor(beatNum) === 0 && !updateValue.barState) {
      updateValue.barState = true;
      updateValue.seed = Math.random() * 1.0;
      clear();
      setBar(barNum);
      swicher();
    }

    // 2小節目
    if (Math.floor(beatNum) === 1) {
      updateValue.barState = false;
    }

    // キック2
    if (beatNum > 1.2 && !updateValue.kick02State) {
      updateValue.kick02State = true;
      updateValue.blurStrength = Math.random() * 0.2 + 0.1;
      barNum >= 84 && swicher();
    }

    // リム1
    if (barNum >= 36) {
      if (!(barNum >= 68 && barNum < 76)) {
        if (beatNum > 0.5 && !updateValue.lim01State) {
          updateValue.lim01State = true;
          updateValue.lim = 1.0;
          updateValue.flow02 = 1.0;
          updateValue.seed = Math.random() * 1.0;
        }
      }
    }

    // リム2
    if (barNum >= 36) {
      if (!(barNum >= 68 && barNum < 76)) {
        if (beatNum > 1.4 && !updateValue.lim02State) {
          updateValue.lim02State = true;
          updateValue.lim = 1.0;
          updateValue.speedToggle = 1.0;
        }
      }
    }

    perticleMotion(barNum, beatNum);
  }, [setTime, setMillisecond, videoRef]);
  useEffect(() => {
    requestRef.current = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(requestRef.current);
  }, [tick]);

  // ----------------------
  // render
  // ----------------------
  return resource.isDataShow ? (
    <Root>
      <p>videoState: {resource.videoState} </p>
      <p>currentTime: {time} </p>
      <p>bar: {bar}</p>
      <p>Millisecond: {millisecond.toFixed(3)} </p>
    </Root>
  ) : null;
};

export default Data;

ここで毎フレームアップデートする数値とか色々まとめてます。
あとそれの確認用の数値とかの表示も
Reactstate とかで管理すると無駄に再レンダリングはしっちゃう?と思うので
(なんかそれっぽい hook とかあるかもですが)
素で export したupdateValueってのを各所で参照するような作りです。

元はaudioの波形データに合わせて変化付けようと思ってたけども
動画の音素材的にFFTやってもイマイチ描画に使えなさそうな気がしたので
videocurrentTimeから小節とか拍的なもの計算して若干無理やり変化つけてます。

ifネストだらけなのはご愛嬌

videoのontimeupdate

コメントにも書いてるけど、videoontimeupdateだと
eventの間隔が結構遅めなので
自前でrequestAnimationFrame処理入れてます。

gl

// =======================================
// threeの呼び出し元
// =======================================
import React, {
  useRef,
  useState,
  useCallback,
  useEffect,
  useContext
} from "react";
import { Vector3, PerspectiveCamera } from "three";
import { Canvas, useThree } from "react-three-fiber";
import styled from "styled-components";
import Compute from "./compute";
import Scene from "./scene";
import Particles from "./particles/index";
import Effect from "./effect/";
import { Conf } from "../index";
import { ResourceContext } from "../index";

const Root = styled.div`
  position: absolute;
  background-color: #fff;
  background-color: #000;
  z-index: 100;
  width: 100vw;
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  canvas {
    width: 100vw;
    height: calc(100vw * 9 / 16);
  }
`;

const Camera = () => {
  const ref = useRef<PerspectiveCamera>();
  const { setDefaultCamera } = useThree();
  useEffect(() => {
    if (!ref.current) return;
    setDefaultCamera(ref.current);
    ref.current.lookAt(new Vector3(0, 0, 0));
  }, [ref, setDefaultCamera]);

  return (
    <perspectiveCamera
      position={[0, 0, 10]}
      args={[45, 16 / 9, 1, 2000]}
      ref={ref}
    />
  );
};

// -------------------------------

const GL: React.FC<any> = (props) => {
  const [resource, setResource] = useContext(ResourceContext);
  const { videoRef } = props;
  const [canvas, setCanvas] = useState<any>();

  const [canvasSize, setCanvasSize] = useState({
    width: Conf.GL_SIZE_FIX ? Conf.TEXTURE_WIDTH : window.innerWidth,
    height: Conf.GL_SIZE_FIX
      ? Conf.TEXTURE_HEIGHT
      : window.innerWidth * (9 / 16)
  });
  const resize = useCallback(() => {
    setCanvasSize({
      width: Conf.GL_SIZE_FIX ? Conf.TEXTURE_WIDTH : window.innerWidth,
      height: Conf.GL_SIZE_FIX
        ? Conf.TEXTURE_HEIGHT
        : window.innerWidth * (9 / 16)
    });
  }, []);

  useEffect(() => {
    window.addEventListener("resize", resize);
    return () => window.removeEventListener("resize", resize);
  });

  const res = useCallback(() => {
    setResource({
      ...resource,
      canvas: canvas
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [resource, setResource, canvas]);

  useEffect(() => {
    // res();// なんかこうしないresourceに反映されない
  }, [canvas, res]);

  const setCanvasRef = useCallback(
    (node: any) => {
      if (node) setCanvas(node.getElementsByTagName("canvas")[0]);
    },
    [setCanvas]
  );

  const [compute, setCompute] = useState<any>({
    isSet: false
  });

  return (
    <Root ref={setCanvasRef} className="GL">
      <Canvas
        gl={{ preserveDrawingBuffer: true }}
        style={{ width: canvasSize.width, height: canvasSize.height }}
      >
        <Camera />
        <Compute
          videoRef={videoRef}
          compute={compute}
          setCompute={setCompute}
        />
        <Scene compute={compute} />
        <Particles compute={compute} />
        <Effect />
      </Canvas>
    </Root>
  );
};
export default GL;
// -------------------------------

three の 呼び出し元です。
ウィンドウに合わしてのリサイズ処理とか、カメラ配置したり、要素配置したり、等。
多分特別な事はしてないけど
react-three-fiber使ってっていうのが
あんまり情報が少なかった気がするのでなんか面倒くさかった気はする。

CanvasRef

react-three-fiberでcanvasのdom要素を取得する方法がよく分からなかったので
結構無理くりかもだけど、

node.getElementsByTagName("canvas")[0]

って感じで取れました。

preserveDrawingBuffer

preserveDrawingBufferってのをtrueにしないと
canvas.toDataURL(“image/jpeg”);
ができなかった。気がする。

compute

// =======================================
// GPGPU的なものは基本ここでまとめて処理
// =======================================
import React, { useRef, useEffect, useCallback } from "react";
import * as THREE from "three";
import { useFrame, useThree } from "react-three-fiber";

// npmからだとバージョンの問題っぽいのか何かエラーでちゃうので別途配置
// import { GPUComputationRenderer } from "gpucomputationrender-three";
import { GPUComputationRenderer } from "../../vendor/GPUComputationRenderer.js";
import { snoise } from "./shader/snoise"; // シンプレックスノイズ + カールノイズとか追記
import { PositionShader } from "./shader/position";
import { VelocityShader } from "./shader/velocity";
import { ColorShader } from "./shader/color";
import { MonoShader } from "./shader/mono";
import { RbgShader } from "./shader/rgb";
import { TempColorShader } from "./shader/color_temp";
import { TempPositionShader } from "./shader/position_temp";
import { updateValue } from "../../data";
import { Conf } from "../../index";

const Compute: React.FC<any> = (props) => {
  const { compute, setCompute, videoRef } = props;
  const { gl } = useThree(); // glってのがrenderer
  // gl.setPixelRatio(window.devicePixelRatio);// retinaだとここでテクスチャずれるかも

  const elapsedTime = useRef<any>(0);

  const videoTexture: THREE.VideoTexture[] = new Array(Conf.VIDEO_TEXTURE_NUM)
    .fill(0)
    .map((v: any, i: any) => new THREE.VideoTexture(videoRef[i].current));

  useEffect(() => {
    const computeRenderer = new GPUComputationRenderer(
      Conf.TEXTURE_WIDTH,
      Conf.TEXTURE_HEIGHT,
      gl
    );

    const textureArraySize = Conf.TEXTURE_WIDTH * Conf.TEXTURE_HEIGHT * 4;

    const tempColor = computeRenderer.createTexture();
    const color = computeRenderer.createTexture();
    const mono = computeRenderer.createTexture();
    const tempPosition = computeRenderer.createTexture();
    const position = computeRenderer.createTexture();
    const rgb = computeRenderer.createTexture();
    const velocity = computeRenderer.createTexture();

    // postion初期位置
    for (let i = 0; i < textureArraySize; i += 4) {
      position.image.data[i] = Math.random() * 16.0 - 8.0;
      position.image.data[i + 1] = Math.random() * 9.0 - 4.5;
      position.image.data[i + 2] = Math.random() * 6.0 - 3.0;
    }

    // initで作った配列渡す
    const textureTempColor = computeRenderer.addVariable(
      "textureTempColor",
      `${TempColorShader}`,
      tempColor
    );
    const textureColor = computeRenderer.addVariable(
      "textureColor",
      `${snoise} ${ColorShader}`,
      color
    );
    const textureMono = computeRenderer.addVariable(
      "textureMono",
      `${MonoShader}`,
      mono
    );
    const textureTempPosition = computeRenderer.addVariable(
      "textureTempPosition",
      `${TempPositionShader}`,
      tempPosition
    );
    const texturePosition = computeRenderer.addVariable(
      "texturePosition",
      `${snoise} ${PositionShader}`,
      position
    );
    const textureRgb = computeRenderer.addVariable(
      "textureRgb",
      `${snoise} ${RbgShader}`,
      rgb
    );
    const textureVelocity = computeRenderer.addVariable(
      "textureVelocity",
      `${snoise} ${VelocityShader}`,
      velocity
    );

    // テクスチャ参照の依存関係を設定
    computeRenderer.setVariableDependencies(textureTempColor, [
      texturePosition,
      textureColor,
      textureTempColor
    ]);
    computeRenderer.setVariableDependencies(textureColor, [
      texturePosition,
      textureColor,
      textureTempColor
    ]);
    computeRenderer.setVariableDependencies(textureMono, [textureMono]);
    computeRenderer.setVariableDependencies(texturePosition, [
      texturePosition,
      textureVelocity
    ]);
    computeRenderer.setVariableDependencies(textureTempPosition, [
      texturePosition,
      textureTempPosition
    ]);
    computeRenderer.setVariableDependencies(textureRgb, [
      textureRgb,
      textureMono
    ]);
    computeRenderer.setVariableDependencies(textureVelocity, [
      texturePosition,
      textureVelocity
    ]);

    textureTempColor.material.uniforms = {
      copyTex: { value: 0.0 }
    };
    textureColor.material.uniforms = {
      time: { value: 0.0 },
      vTex: { value: videoTexture[1] }, //ビデオテクスチャ
      originX: { value: 0.0 }, //切り取る画角の基点X
      originY: { value: 0.0 }, //切り取る画角の基点Y
      range: { value: 0.0 }, //切り取る範囲
      zoom: { value: 0.0 } //ズーム用
    };
    textureMono.material.uniforms = {
      time: { value: 0.0 },
      vTex: { value: 0.0 },
      range: { value: 0.0 },
      originX: { value: 0.0 },
      originY: { value: 0.0 },
      zoom: { value: 0.0 },
      blurStrength: { value: 0.0 }
    };
    textureTempPosition.material.uniforms = {
      copyTex: { value: 0.0 }
    };
    texturePosition.material.uniforms = {
      time: { value: 0.0 }
    };
    textureRgb.material.uniforms = {
      time: { value: 0.0 }
    };
    textureVelocity.material.uniforms = {
      time: { value: 0.0 },
      seed: { value: 0.0 },
      flow01: { value: 0.0 },
      flow02: { value: 0.0 },
      referenceVelocity: { value: 0.0 },
      lim: { value: 0.0 },
      renderState: { value: 0.0 }
    };

    const computeRendererError = computeRenderer.init();
    if (computeRendererError) {
      console.error("ERROR", computeRendererError);
    }

    setCompute({
      isSet: true,
      computeRenderer: computeRenderer,
      videoTexture: videoTexture,
      texturePosition: texturePosition,
      textureVelocity: textureVelocity,
      textureTempPosition: textureTempPosition,
      textureColor: textureColor,
      textureMono: textureMono,
      textureRgb: textureRgb,
      textureTempColor: textureTempColor
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // ----------------------
  const update = useCallback(() => {
    // ---------------
    compute.computeRenderer.compute();
    // ---------------
    if (compute.textureVelocity.material.uniforms.renderState.value === 1.0) {
      updateValue.flow01 =
        updateValue.flow01 <= 0 ? 0.001 : updateValue.flow01 - 0.095;
      updateValue.flow02 =
        updateValue.flow02 <= 0.03 ? 0.001 : updateValue.flow02 - 0.03;
      updateValue.zoom =
        updateValue.zoom <= 0.009 ? 0.009 : updateValue.zoom - 0.04;
      updateValue.lim = updateValue.lim <= 0.0 ? 0.0 : updateValue.lim - 0.04;
      updateValue.referenceColor =
        updateValue.referenceColor >= 1.0 ? 1.0 : updateValue.referenceColor;
      updateValue.bgOpacity =
        updateValue.bgOpacity >= 1.0 ? 1.0 : updateValue.bgOpacity;

      if (updateValue.kick02State) {
        updateValue.blurStrength =
          updateValue.lim >= 1.5 ? 1.5 : updateValue.blurStrength + 0.01;
      }
    }

    // ---------------
    compute.texturePosition.material.uniforms.time.value = elapsedTime.current;
    // ---------------
    compute.textureVelocity.material.uniforms.time.value = elapsedTime.current;
    compute.textureVelocity.material.uniforms.seed.value = updateValue.seed;
    compute.textureVelocity.material.uniforms.flow01.value = updateValue.flow01;
    compute.textureVelocity.material.uniforms.flow02.value = updateValue.flow02;
    compute.textureVelocity.material.uniforms.referenceVelocity.value =
      updateValue.referenceVelocity;
    compute.textureVelocity.material.uniforms.speedToggle.value =
      updateValue.speedToggle;
    compute.textureVelocity.material.uniforms.lim.value = updateValue.lim;
    // ---------------
    compute.textureColor.material.uniforms.time.value = elapsedTime.current;
    compute.textureColor.material.uniforms.vTex.value =
      videoTexture[updateValue.videoId];
    compute.textureColor.material.uniforms.originX.value = updateValue.originX;
    compute.textureColor.material.uniforms.originY.value = updateValue.originY;
    compute.textureColor.material.uniforms.range.value = updateValue.range;
    compute.textureColor.material.uniforms.zoom.value = updateValue.zoom;
    // ---------------
    compute.textureMono.material.uniforms.time.value = elapsedTime.current;
    compute.textureMono.material.uniforms.vTex.value =
      compute.videoTexture[updateValue.videoId];
    compute.textureMono.material.uniforms.range.value = updateValue.range;
    compute.textureMono.material.uniforms.originX.value = updateValue.originX;
    compute.textureMono.material.uniforms.originY.value = updateValue.originY;
    compute.textureMono.material.uniforms.blurStrength.value =
      updateValue.blurStrength;
    // ---------------
    compute.textureRgb.material.uniforms.time.value = elapsedTime.current;
    // ---------------

    if (updateValue.copyTex) {
      updateValue.copyTex = false;
      compute.textureTempColor.material.uniforms.copyTex.value = 1.0;
      compute.textureTempPosition.material.uniforms.copyTex.value = 1.0;
    } else {
      compute.textureTempColor.material.uniforms.copyTex.value = 0.0;
      compute.textureTempPosition.material.uniforms.copyTex.value = 0.0;
    }
    // ---------------
    compute.textureVelocity.material.uniforms.renderState.value = 0.0;
    // コマ送り用の対策
  }, [compute, videoTexture]);

  useFrame((frame) => {
    // elapsedTime.current = frame.clock.elapsedTime;

    update();
    if (updateValue.videoState === "play") {
      compute.textureVelocity.material.uniforms.renderState.value = 1.0;
      // なんかこうしないとレンダリングのコマ送りで
      // computeRendererがうまく動かない?ので
      // (updateはし続けてベロシティ係数を0.0,1.0でトグルしてる)
    }
    if (updateValue.renderState) {
      elapsedTime.current += 0.03;
      compute.textureVelocity.material.uniforms.renderState.value = 1.0;
      updateValue.renderState = false;
    }
  });

  return <></>;
};

export default Compute;

GPGPU 的な処理はここにまとめてます。
three 側の変化はほぼここでつけて他の箇所からはこの compute 参照するだけ。

react-three-fiberでTHREE.WebGLRenderer

react-three-fiberだと

const { gl } = useThree();

ってやればTHREE.WebGLRendererが取れるっぽい。

ちなみにsetPixelRatio設定すると
retinaの対応でshader側の調整も必要そうな感じだったので面倒なので無しにした。

GPUComputationRenderer

用語的にあってるかすら自信ないけど、GPGPU で色々計算すると早い、はず。GPUComputationRenderer っていうの使うと割と手軽にできました。

ライブラリ無しでやると
レンダーバッファのフリップとかが必要だったような?覚えがあるような?ないような。

コメントにも書いてるけど、npm で入れるとなんかエラーが出たので別途手動で配置してます。
多分バージョンとかの問題だとは思うけども、地味に苦戦した覚えがある。

const computeRenderer = new GPUComputationRenderer(Conf.TEXTURE_WIDTH, Conf.TEXTURE_HEIGHT, gl);

って感じで前述のTHREE.WebGLRenderer(gl)を渡す。

余談だけどaudio の波形なんかも GPU でなんか色々できるらしい。
今後なんかやってみようかな。

useFrame

react-three-fiber だとuseFrame で毎フレームの処理できる様子。

shader

glsl も色々自信ない部分が多いし散らかってるけど
一応なんとなくコメントは入れてる。

■snoise(とcurlnoise)

カールノイズと Simplex noise一緒に書いてる。

https://al-ro.github.io/projects/curl/

何となくこのあたり参考にはしました。

vec3 curlNoise(vec3 p) {
  const float e = .1;
  const float e2 = 2.0 * e;

  vec3 dx = vec3(e, 0.0, 0.0);
  vec3 dy = vec3(0.0, e, 0.0);
  vec3 dz = vec3(0.0, 0.0, e);

  vec3 p_x0 = snoiseVec3(p - dx);
  vec3 p_x1 = snoiseVec3(p + dx);
  vec3 p_y0 = snoiseVec3(p - dy);
  vec3 p_y1 = snoiseVec3(p + dy);
  vec3 p_z0 = snoiseVec3(p - dz);
  vec3 p_z1 = snoiseVec3(p + dz);

  float x = (p_y1.z - p_y0.z) - (p_z1.y - p_z0.y);
  float y = (p_z1.x - p_z0.x) - (p_x1.z - p_x0.z);
  float z = (p_x1.y - p_x0.y) - (p_y1.x - p_y0.x);

  return normalize(vec3(x, y, z) / e2);
}

発散とか内積とか回転とか色々よくはわかってないけど
一応何となく分かったような気がしてるのは

epsNumber.EPSILONって意味だと思う、偏微分?のほんの少しだけの数値

dxとかdyってのがxyz上のepsで少しだけ動かす、の値。

3点近似でなんやかんやして回転?を計算する。

と、あってる自信もないし適当なので参考にならないかもですが。

■position/velocity

パーティクルの動き周りでいうとここがメインです。
position の位置からカールノイズで速度を取得、
velocity に反映->position に反映。の繰り返し。

position は 16:9 の枠に収まるように適当な位置に更新している
が今気づいたけど、はZ軸考慮しないといけない?かも
けどパッと見は個人的に気にならないので良しとする

■color/mono

ビデオテクスチャ切り取りとりつつ
色情報とったりってのはここでやってます。
monoはモノクロ処理したやつ+何となく雰囲気でgaussianブラーかけてる

■rgb

rgb シフト拾ってきやつ、にsimplexnoiseで揺らぎ的なもの加えてみてます。
EffectComposer でやってもいいような処理かもですが
なんとなくパーティクルと背景ビデオで分けたかったのでここに配置

■color_temp/postion_temp

ここは位置・色情報コピーして一旦保持しつつ
参照切り替えて一瞬停止したっぽい演出したくてやってみました。

effect

// =======================================
// effectComposerはここで
// =======================================

import React, { useRef, useEffect } from "react";
import { useFrame, useThree } from "react-three-fiber";
import { extend } from "react-three-fiber";

import * as THREE from "three";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass";
import { ShaderPass } from "three/examples/jsm/postprocessing/ShaderPass";
import { FXAAShader } from "three/examples/jsm/shaders/FXAAShader";
import { SSAOPass } from "three/examples/jsm/postprocessing/SSAOPass";
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass";

extend({ EffectComposer, RenderPass, ShaderPass, FXAAShader, SSAOPass, UnrealBloomPass });

// インターフェイスIntrinsicElementsにorbitControls の定義を追加
// typescriptめんどい
declare global {
  namespace JSX {
    interface IntrinsicElements {
      effectComposer: any;
      renderPass: any;
      shaderPass: any;
      unrealBloomPass: any;
    }
  }
}

const Effect: React.FC<any> = (props) => {
  const { gl, scene, camera, size } = useThree();
  const composer: any = useRef();

  useFrame(() => {
    if (!composer.current) return;
    composer.current.render();
  }, 30);
  useEffect(() => {
    composer.current.setSize(size.width, size.height);
  });

  const bloom: any = {
    resolution: new THREE.Vector2(size.width, size.height),
    strength: 0.5,
    radius: 0.01,
    threshold: 0.3,
  };

  return (
    <effectComposer ref={composer} args={[gl]}>
      <renderPass attachArray="passes" scene={scene} camera={camera} />
      <unrealBloomPass attachArray="passes" args={[bloom.resolution, bloom.strength, bloom.radius, bloom.threshold]} />
    </effectComposer>
  );
};

export default Effect;

EffectComposer はココ
Typescript + react-three-fiber だと
IntrinsicElementsinterface 定義がないと
駄目っぽい、めんどくさい。

なんで Typescript 普及してるのか謎。
っていいつつ流れには逆らえないので頑張って使う。

particles

パーティクルの vertices の座標とか、
初期処理がほとんど。で前述だけど更新はほとんど compute 側でやってる

// =======================================
// Particles周り
// 動きは基本compute側で処理したの参照するだけ
// =======================================

import React, { useRef, useMemo, useEffect } from "react";
import * as THREE from "three";
import { useFrame } from "react-three-fiber";
import { FragmentShader } from "./shader/fragment";
import { VertexShader } from "./shader/vertex";
import { updateValue } from "../../data";
import { Conf } from "../../index";

const Particles: React.FC<any> = (props) => {
  //   console.log("Particles");
  const material: any = useRef();
  const mesh: any = useRef();
  const { compute } = props;
  // ==================================================================

  // オブジェクトの初期頂点とかUV座標の設定
  const particles = useMemo(() => {
    const vertices = new Float32Array(Conf.PARTICLE_COUNT * 3).fill(0);
    // vertices初期位置は0
    const uv = new Float32Array(Conf.PARTICLE_COUNT * 2);
    for (let i = 0; i < uv.length; i += 2) {
      const indexVertex = i / 2;
      uv[i] = (indexVertex % Conf.TEXTURE_HEIGHT) / Conf.TEXTURE_WIDTH;
      uv[i + 1] =
        Math.floor(indexVertex / Conf.TEXTURE_WIDTH) / Conf.TEXTURE_HEIGHT;
    }
    // uv座標
    // const colors = new Float32Array(Conf.PARTICLE_COUNT * 3).fill(0);// 色情報
    return {
      vertices: vertices,
      uv: uv,
      //   colors: colors,
      uniforms: {
        time: { value: 0.0 },
        lim: { value: 0.0 },
        bgOpacity: { value: 0.0 },
        referenceColor: { value: 0.0 },
        resolution: {
          value: new THREE.Vector2(Conf.TEXTURE_WIDTH, Conf.TEXTURE_HEIGHT)
        },
        texturePosition: { value: null },
        textureColor: { value: null },
        textureTempColor: { value: null },
        textureTempPosition: { value: null }
      },
      VertexShader: VertexShader,
      FragmentShader: FragmentShader
    };
  }, []);

  // ==================================================================
  useFrame((frame) => {
    if (material.current) return;
    // ===============
    particles.uniforms.lim.value = updateValue.lim;
    particles.uniforms.bgOpacity.value = updateValue.bgOpacity;
    particles.uniforms.referenceColor.value = updateValue.referenceColor;
    particles.uniforms.texturePosition.value = compute.computeRenderer.getCurrentRenderTarget(
      compute.texturePosition
    ).texture;
    particles.uniforms.textureColor.value = compute.computeRenderer.getCurrentRenderTarget(
      compute.textureColor
    ).texture;
    particles.uniforms.textureTempColor.value = compute.computeRenderer.getCurrentRenderTarget(
      compute.textureTempColor
    ).texture;
    particles.uniforms.textureTempPosition.value = compute.computeRenderer.getCurrentRenderTarget(
      compute.textureTempPosition
    ).texture;
  });
  // ==================================================================
  useEffect(() => {
    if (!mesh.current) return;
    mesh.current.position.z = 0.0;
  }, []);

  return (
    <points ref={mesh}>
      <bufferGeometry attach="geometry">
        <bufferAttribute
          attachObject={["attributes", "position"]}
          //   ref={attrib}
          count={particles.vertices.length / 3} //countはreact-three-fiberで必要っぽい
          array={particles.vertices}
          itemSize={3}
        />
        <bufferAttribute
          attachObject={["attributes", "uv"]}
          count={particles.uv.length / 2}
          array={particles.uv}
          itemSize={2}
        />
        {/* <bufferAttribute
          attachObject={["attributes", "colors"]}
          count={particles.colors.length / 3}
          array={particles.colors}
          itemSize={3}
        /> */}
      </bufferGeometry>

      <shaderMaterial
        attach="material"
        uniforms={particles.uniforms}
        vertexShader={particles.VertexShader}
        fragmentShader={particles.FragmentShader}
        transparent={true}
        blending={THREE.AdditiveBlending}
        side={THREE.DoubleSide}
        depthTest={false}
        depthWrite={false}
      />
    </points>
  );
};

export default Particles;

shader

■position

位置は compute 側参照してるだけ、

あと色情報 varying してる。

何といっていいか
パーティクルの動きは3Dなんだけど動画自体は2Dなので
そのままXY座標から色取るだけだと奥行きが変に見えるので
ちょっとz軸で何かやってる。
適当なんだけどパッ見でそれっぽくなったので
コレでいいや的な感じです。良くは分かってない。

■fragment

上の色情報こっちでも取れそうな物な気がしたのだけども知識不足か、
うまくいかなかったので varying で送られた色を mix で切り替えてます。
多分もっとスマートな方法あると思う。

scene

ここは背景(ベースとなる画角のビデオテクスチャ)をplaneBufferGeometryに反映しているだけ
shader側も透過とかちょっと変えてるぐらい。

// =======================================
// 背景の動画
// =======================================
import React, { useRef, useEffect, useMemo } from "react";
import * as THREE from "three";
import { useFrame } from "react-three-fiber";
import { updateValue } from "../../data";

import { Conf } from "../../index";

import { snoise } from "../compute/shader/snoise";
import { Fragment } from "./shader/fragment";
const FragmentShader = `${snoise} ${Fragment}`;

const Scene: React.FC<any> = (props) => {
  const { compute } = props;
  const material: any = useRef();
  const mesh: any = useRef();

  const args = useMemo(() => {
    return {
      uniforms: {
        textureRgb: { type: "t", value: null },
        time: { type: "f", value: 0.0 },
        bgOpacity: { type: "f", value: 0.0 },
        resolution: {
          type: "v2",
          value: new THREE.Vector2(Conf.TEXTURE_WIDTH, Conf.TEXTURE_HEIGHT)
        }
      },
      vertexShader: `
            varying vec2 vUv;
              void main() {
                  vUv = uv;
                  gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
              }  
            `,
      fragmentShader: FragmentShader
    };
  }, []);

  useFrame((frame) => {
    if (!material.current) return;

    material.current.uniforms.time.value = frame.clock.elapsedTime;
    material.current.uniforms.bgOpacity.value = updateValue.bgOpacity;
    material.current.uniforms.textureRgb.value = compute.computeRenderer.getCurrentRenderTarget(
      compute.textureRgb
    ).texture;
  });
  useEffect(() => {
    if (!mesh.current) return;
    mesh.current.position.z = -0.05;
    // z軸ちょっとずらしてパーティクルとかぶらなように
  }, []);

  return (
    <mesh ref={mesh}>
      <planeBufferGeometry attach="geometry" args={[16, 9]} />
      <shaderMaterial
        attach="material"
        ref={material}
        args={[args]}
        transparent={true}
      />
    </mesh>
  );
};

export default Scene;

media

video とか WebAudio、ストリーミングあと
youtube 用として連番の書き出しとか作ってみました。

audio

// =======================================
// WEB AUDIO API周り
// fftでなんか同期しようと思ったけど
// 現状ただグラフとか描画してるだけ
// =======================================

import React, {
  useEffect,
  useCallback,
  useRef,
  useState,
  useContext
} from "react";
import styled from "styled-components";
import { ResourceContext } from "../index";

const CV_WIDTH = 300;
const CV_HEIGHT = 150;

declare global {
  interface Window {
    webkitAudioContext: typeof AudioContext;
  }
}

const Root = styled.div`
  position: fixed;
  z-index: 1000;
  top: 0;
  left: 0;
`;
const LevelCanvas = styled.canvas`
  width: 300px;
  height: 50px;
  position: fixed;

  top: 140px;
  left: 20px;
  background-color: rgba(0, 0, 0, 0.5);
  border-radius: 10px;
`;

const TimeCanvas = styled.canvas`
  position: fixed;
  width: 300px;
  height: 150px;
  top: 205px;
  left: 20px;
  background-color: rgba(0, 0, 0, 0.5);
  border-radius: 10px;
`;
const FrequencyCanvas = styled.canvas`
  width: 300px;
  height: 150px;
  position: fixed;
  top: 372px;
  left: 20px;
  background-color: rgba(0, 0, 0, 0.5);
  border-radius: 10px;
`;

const Audio: React.FC<any> = (props) => {
  const [resource, setResource] = useContext(ResourceContext);
  const [audio, setAudio] = useState<any>();
  const videoRef = props.videoRef[1]; // audioはvideoBから
  const [isData, setIsData] = useState(false);
  const timeRef = useRef();
  const frequencyRef = useRef();
  const levelRef = useRef();

  const [levelCanvasCtx, setLevelCanvasCtx] = useState<
    CanvasRenderingContext2D
  >();
  const [timeCanvasCtx, setTimeCanvasCtx] = useState<
    CanvasRenderingContext2D
  >();
  const [frequencyCanvasCtx, setFrequencyCanvasCtx] = useState<
    CanvasRenderingContext2D
  >();

  const setLevelRef = useCallback(
    (node) => {
      console.log(node);
      levelRef.current = node;
      setLevelCanvasCtx(node.getContext("2d"));
    },
    [levelRef]
  );
  const setTimeRef = useCallback(
    (node) => {
      timeRef.current = node;
      setTimeCanvasCtx(node.getContext("2d"));
    },
    [timeRef]
  );
  const setFrequencyRef = useCallback(
    (node) => {
      frequencyRef.current = node;
      setFrequencyCanvasCtx(node.getContext("2d"));
    },
    [frequencyRef]
  );

  useEffect(() => {
    if (!timeRef || !frequencyRef || !levelRef || !!isData) return;

    const _AudioContext = window.AudioContext || window.webkitAudioContext;
    const audioCtx: AudioContext = new _AudioContext();
    const source = audioCtx.createMediaElementSource(videoRef.current);

    const gainNode = audioCtx.createGain();
    gainNode.gain.value = 1.0;

    const analyserNode: AnalyserNode = audioCtx.createAnalyser();
    analyserNode.fftSize = 512; // frequencyBinCountはナイキストレートでfftSizeの半分
    analyserNode.smoothingTimeConstant = 0.6;

    const levelBuffer: Float32Array = new Float32Array(analyserNode.fftSize);
    const timesData: Uint8Array = new Uint8Array(
      analyserNode.frequencyBinCount
    );
    const freqsData: Uint8Array = new Uint8Array(
      analyserNode.frequencyBinCount
    );

    const processor = audioCtx.createScriptProcessor(1024, 1, 1);
    source.connect(processor); // スクリプトプロセッサ。はいらないかも

    const mediaStreamDest = audioCtx.createMediaStreamDestination();
    source.connect(mediaStreamDest); // mediaStream(録音用)

    source.connect(gainNode);
    gainNode.connect(analyserNode);
    analyserNode.connect(audioCtx.destination);

    // なんか下のuseEffectと合わせて
    // こうしないとresourceに反映されない
    // 追ってuseContext確認
    //   window.setTimeout(() => {
    setAudio({
      audioCtx: audioCtx,
      processor: processor,
      analyserNode: analyserNode,
      levelBuffer: levelBuffer,
      timesData: timesData,
      freqsData: freqsData,
      mediaStreamDest: mediaStreamDest
    });
    setIsData(true);
  }, [levelRef, timeRef, frequencyRef, videoRef, setAudio, isData]);
  // ----------------------
  useEffect(() => {
    setResource({
      ...resource,
      audio: audio
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [audio, setResource]);
  // ----------------------
  // draw
  // ----------------------
  const levelDraw = useCallback(() => {
    // console.log("levelDraw");
    audio.analyserNode.getFloatTimeDomainData(audio.levelBuffer);
    // ---------------------
    // // Compute average power over the interval.
    // for (let i = 0; i < sampleBuffer.length; i++) {
    //   sumOfSquares += sampleBuffer[i] ** 2;
    // }
    // const avgPowerDecibels = 10 * Math.log10(sumOfSquares / sampleBuffer.length);
    // ---------------------
    // Compute peak instantaneous power over the interval.
    let peak = 0;
    // let sumOfSquares = 0;
    for (let i = 0; i < audio.levelBuffer.length; i++) {
      const power = audio.levelBuffer[i] ** 2;
      peak = Math.max(power, peak);
      //   sumOfSquares += power;
    }
    const peakDecibels = 10 * Math.log10(peak);
    // ピークデシベル
    // const avgDecibels = 10 * Math.log10(sumOfSquares / audio.levelBuffer.length);
    // デシベル平均値
    const peakLinear = Math.pow(10, peakDecibels / 20);
    // const avgLinear = Math.pow(10, avgDecibels / 20);
    // リニア値

    levelCanvasCtx!.clearRect(0, 0, 1000, 100);
    const grd = levelCanvasCtx!.createLinearGradient(0, 0, CV_WIDTH, 0);

    grd.addColorStop(0.0, "lime");
    grd.addColorStop(0.7, "yellow");
    grd.addColorStop(1.0, "red");
    levelCanvasCtx!.fillStyle = grd;
    levelCanvasCtx!.fillRect(0, 0, CV_WIDTH * peakLinear * 2.0, 50);
  }, [audio, levelCanvasCtx]);

  const timeDraw = useCallback(() => {
    // console.log("timeDraw");
    audio.analyserNode!.getByteTimeDomainData(audio.timesData!);
    timeCanvasCtx!.clearRect(0, 0, CV_WIDTH, CV_HEIGHT);
    timeCanvasCtx!.strokeStyle = "rgb(255, 255, 255)";
    timeCanvasCtx!.beginPath();
    for (var i = 0; i < audio.analyserNode!.frequencyBinCount; ++i) {
      var x = (i / audio.analyserNode!.frequencyBinCount) * CV_WIDTH;
      var val = 1.0 - audio.timesData![i] / 255; //   0.0 ~ 1.0の数値に
      val = (val - 0.5) * 1.0 + 0.5; //スケーリング
      var y = val * CV_HEIGHT;

      if (i === 0) {
        timeCanvasCtx!.moveTo(x, y);
      } else {
        timeCanvasCtx!.lineTo(x, y);
      }
    }
    timeCanvasCtx!.stroke();
  }, [audio, timeCanvasCtx]);

  const frequencyDraw = useCallback(() => {
    // console.log("frequencyDraw");
    audio.analyserNode!.getByteFrequencyData(audio.freqsData!);
    frequencyCanvasCtx!.clearRect(0, 0, CV_WIDTH, CV_HEIGHT);
    const frequencyArea = ~~(audio.analyserNode!.frequencyBinCount * 1.0);
    frequencyCanvasCtx!.strokeStyle = "rgb(255, 255, 255)";
    frequencyCanvasCtx!.beginPath();
    for (var i = 0; i < frequencyArea; ++i) {
      var x = (i / frequencyArea) * CV_WIDTH;
      var y = (1 - audio.freqsData![i] / 255) * CV_HEIGHT;

      if (i === 0) {
        frequencyCanvasCtx!.moveTo(x, y);
      } else {
        frequencyCanvasCtx!.lineTo(x, y);
      }
    }
    frequencyCanvasCtx!.stroke();
  }, [audio, frequencyCanvasCtx]);

  // ----------------------
  // update処理
  // ----------------------
  const tick = useCallback(() => {
    requestAnimationFrame(tick);
    if (!isData) return;
    if (!resource.isDataShow) return;
    levelDraw();
    timeDraw();
    frequencyDraw();
  }, [isData, resource, frequencyDraw, timeDraw, levelDraw]);
  useEffect(() => {
    const _tick = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(_tick);
  }, [tick, resource]);

  // ----------------------
  // render
  // ----------------------
  return (
    <Root
      className="Audio"
      style={{ display: resource.isDataShow ? "block" : "none" }}
    >
      <LevelCanvas ref={setLevelRef} width={CV_WIDTH} height={50} />
      <TimeCanvas ref={setTimeRef} width={CV_WIDTH} height={CV_HEIGHT} />
      <FrequencyCanvas
        ref={setFrequencyRef}
        width={CV_WIDTH}
        height={CV_HEIGHT}
      />
    </Root>
  );
};

export default Audio;

Web Audio APIとかFFTでなんかしようかなと思ってものの
あまり同期で使えなさそうな気がしたので
パーティクルとかの動作は連動はしてない。

のでおまけ的な内容だけど
せっかく作ったのでそのまま配置してる、
時間領域とか周波数領域のグラフ描画だけ、
あとあんまり自信ないけど、ピークメーターも作ってみた。

createMediaElementSource

因みに動画ファイルの音声データはcreateMediaElementSourcevideo要素渡せば
Web Audio APIで使えるっぽい。

createMediaStreamDestination

使えばMediaStreamで録音とか出来た、
けどやり方が悪いのかもだけど、
動画の録画側でコマ落ちがでてしまうので不使用。

recorder

YOUTUBE とかにもアップしよう思ったのですが
webgl ってどうやってファイルとして書き出せばいいのか分からず
ここが実は結構苦戦した。

// =======================================
// canvasを一コマづつjpegキャプチャとって
// 手動でffmpegで連結する。
// MediaStreamは一応動いたけどコマ落ちするので一旦無し
// 公開サーバーではいらないと思うのでdevelopmentだけ
// =======================================

import React, { useContext, useCallback } from "react";
import styled from "styled-components";
import { updateValue } from "../data";
import { ResourceContext } from "../index";

const APP_URL = process.env.APP_URL;

let currentTime = 0;
let currentFrame = 0;

const Root = styled.div`
  position: fixed;
  bottom: 30px;
  right: 30px;
  z-index: 20000;

  button {
    cursor: pointer;
    border: 0px solid #fff;
    margin: 0 10px;
    background-color: rgba(255, 255, 255, 0.9);
    color: #000;
    padding: 10px 15px;
    border-radius: 10px;
    font-size: 16px;
  }
`;

const Recorder: React.FC<any> = (props) => {
  const { videoRef } = props;
  const [resource] = useContext(ResourceContext);
  const { canvas } = resource;

  const output = useCallback(
    async (currentFrame: number) => {
      canvas.getContext("experimental-webgl", {
        preserveDrawingBuffer: true
      });
      const base64 = canvas
        .toDataURL("image/jpeg", 0.5)
        .replace(new RegExp("data:image/jpeg;base64,"), "");
      const formData = new FormData();
      formData.append("base64", base64);
      formData.append("currentFrame", "" + currentFrame);
      const response = await fetch(APP_URL + "/api/generator/", {
        method: "POST",
        body: formData
      });
      if (!response.ok) return;
      const path = await response.json();
      return new Promise((resolve) => {
        window.setTimeout(() => {
          resolve(path);
        }, 1000);
      });
    },
    [canvas]
  );

  const update = useCallback(() => {
    currentTime += 1 / 30; // 30FPS
    videoRef.forEach((video: any) => {
      video.current.currentTime = currentTime;
    });
    updateValue.renderState = true;
    // ここでthree側をコマ送りするようにしている
  }, [videoRef]);

  const rendering = useCallback(() => {
    currentFrame += 1;
    const per = ((currentTime / updateValue.duration) * 100).toFixed(2);
    console.log(per + "%");
    if (currentTime > updateValue.duration) return;
    (async () => {
      update();
      await output(currentFrame);
      rendering();
    })();
  }, [output, update]);

  const renderingStart = useCallback(() => {
    if (!canvas) return;
    currentTime = 0;
    rendering();
  }, [canvas, rendering]);

  // ----------------------------
  const screenshot = useCallback(() => {
    canvas.getContext("experimental-webgl", {
      preserveDrawingBuffer: true
    });
    const dataUrl = canvas.toDataURL("image/jpeg");
    const w: any = window.open("about:blank");
    w.document.write("<img src='" + dataUrl + "'/>");
  }, [canvas]);
  // ----------------------------

  return (
    <Root className="Recorder">
      <button onClick={renderingStart}>rendering</button>
      <button onClick={screenshot}>screenshot</button>
    </Root>
  );
};

export default React.memo(Recorder);

// ===========================================
// MediaStream動いたけどコマ落ちするので
// 一コマづつjpegキャプチャとってffmpegで連結する方向で

// const [href, setHref] = useState<any>();
//   const [download, setDownload] = useState<any>();

// const recorder = useMemo(() => {
//     if (!canvas) return;

//     const audioStream = audio.mediaStreamDest.stream;
//     const canvasStream = canvas.captureStream();

//     const mediaStream = new MediaStream();
//     [canvasStream, audioStream].forEach((stream) => {
//       stream.getTracks().forEach((track: any) => mediaStream.addTrack(track));
//     });

//     const recorder = new MediaRecorder(mediaStream, {
//       audioBitsPerSecond: 128000,
//       videoBitsPerSecond: 12800000,
//       mimeType: "video/webm;codecs=vp9",
//     });
//     recorder.ondataavailable = function (e) {
//       var videoBlob = new Blob([e.data], { type: e.data.type });
//       const blobUrl = window.URL.createObjectURL(videoBlob);
//       setDownload("movie.webm");
//       setHref(blobUrl);
//     };

//     return recorder;
//   }, [canvas, audio]);

//   const recorderStart = useCallback(() => {
//     console.log("start");
//     recorder!.start();
//   }, [recorder]);
//   const recorderStop = useCallback(() => {
//     console.log("stop");
//     recorder!.stop();
//   }, [recorder]);
//   // ----------------------------

webglの動画ファイルへの書き出し

がしたくて調べたら MediaRecorder/MediaStream なるものがあったので、コレだな、
と思って実装してみた録音録画自体はできたものの
どうにもコマ落ちだったりが起こってしまい微妙な気がした。
単に PC の負荷とかスペックだったり、設定が悪いだけかもしれないけど、

一応手元で動いたのはコメントとして残している。

一コマづつjpgで書き出し->ffmpeg

なので代替方法として three 側を await で1フレームづつ
コマ送りしつつ canvasbsae64POST。
phpjpg に書き出し。といゆう手段をとってみた。

<?php
/* =========================================
jsから受け取ったbase64をjpgに変換
手動でffmpegで動画にする
========================================= */
$TEMP_PATH = "どっかパス";

$base64 = $_POST['base64'];
$currentFrame = $_POST['currentFrame'];
$file_path = $_SERVER['DOCUMENT_ROOT'] . $TEMP_PATH . $currentFrame  . ".jpg";

$new_file_name = $file_path;
$fp = fopen($new_file_name, 'w');
fwrite($fp, base64_decode($base64));
fclose($fp);

PHP側はこんな感じ

その後手動で ffmpeg で連結。
で一応割と満足行く画質で書き出し?できた。
youtube にアップしたら自動圧縮で荒れましたが、
まぁ作ってる側だから気になる程度だと思う。

streamLoader

// =======================================
// サーバー配信
// 分割ロードとかシークバーなんかは未実装
// =======================================
import React, { useEffect, useCallback, useRef, useState } from "react";
import styled from "styled-components";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Conf } from "../index";

const BASE_PATH = "//nocebo.jp/band/prs/public/api/stream/";
let _contentLength = 0;
let _totalLength = 0;


const StreamLoader: React.FC<any> = (props) => {
  const [isLoadStart, setIsLoadStart] = useState<boolean>(false);
  const [percent, setPercent] = useState<number>(0);
  const [totalLength, setTotalLength] = useState<number>(0);
  const [currentLength, setCurrentLength] = useState<number>(0);
  const { setIsLoaded, setVideoBuffer } = props;

  const loadLength = async (
    request: string,
    id: string,
    quality: string
  ): Promise<number> => {
    const formData = new FormData();
    formData.append("id", id);
    formData.append("quality", quality);
    formData.append("isReadLength", "true");
    const response: any = await fetch(request, {
      method: "POST",
      body: formData
    });
    // const contentLength = +response.headers.get("Content-Length");
    if (!response.ok) return 0;
    const body = await response.json();
    const contentLength: number = +body;
    return new Promise((resolve) => {
      resolve(contentLength);
    });
  };
  const stream = async (request: string, id: string, quality: string) => {
    const formData = new FormData();
    formData.append("id", id);
    formData.append("quality", quality);
    const response: any = await fetch(request, {
      method: "POST",
      // headers: { Range: "bytes=0-22344789" },// 分割する場合はこの辺でなんとかなりそうな
      body: formData
    });
    const reader = response.body.getReader();

    let receivedLength = 0;
    const chunks = [];

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      chunks.push(value);
      receivedLength += value.length;
      _contentLength += value.length;
      //   console.log(`Received ${receivedLength} of ${_contentLength}`);
    }
    const chunksAll = new Uint8Array(receivedLength);
    let position = 0;
    for (const chunk of chunks) {
      chunksAll.set(chunk, position);
      position += chunk.length;
    }
    const arrayBuffer = chunksAll;

    return new Promise((resolve) => {
      resolve(arrayBuffer);
    });
  };

  const loadStart = (quality: string) => {
    setIsLoadStart(true);
    // -------------
    // 場所がいまいちな気がするけどここでクオリティ設定
    Conf.QUALITY = quality;
    Conf.TEXTURE_WIDTH = Conf[Conf.QUALITY].TEXTURE_WIDTH;
    Conf.TEXTURE_HEIGHT = Conf[Conf.QUALITY].TEXTURE_HEIGHT;
    Conf.PARTICLE_COUNT = Conf[Conf.QUALITY].PARTICLE_COUNT;
    // -------------
    (async () => {
      //   console.log("load start");
      for (let i = 0; i <= Conf.VIDEO_TEXTURE_NUM - 1; i++) {
        const l: number = await loadLength(BASE_PATH, "" + i, quality);
        _totalLength += l;
      }
      setTotalLength(_totalLength);

      const arrayBuffer = [];
      for (let j = 0; j <= Conf.VIDEO_TEXTURE_NUM - 1; j++) {
        arrayBuffer[j] = await stream(BASE_PATH, "" + j, quality);
      }
      setVideoBuffer(arrayBuffer);
    })();
  };

  const requestRef = useRef<any>();
  const tick = useCallback(() => {
    requestRef.current = requestAnimationFrame(tick);
    setCurrentLength(_contentLength);
    const p = (_contentLength / _totalLength) * 100;
    setPercent(p);
    if (p === 100) {
      window.setTimeout(() => {
        setIsLoaded(true);
      }, 1000);
      // setTimeoutは保険
    }
  }, [setCurrentLength, setIsLoaded]);

  useEffect(() => {
    requestRef.current = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(requestRef.current);
  }, [tick]);

  return isLoadStart ? (
    <Root>
      <Info>
        <p className="percent">
          {("000" + ~~percent).slice(-3)}
          <span>%</span>
        </p>
        <div className="bar">
          <span
            style={{
              width: percent + "%"
            }}
          ></span>
        </div>
        <div className="data">
          {currentLength} / {totalLength}
        </div>
      </Info>
    </Root>
  ) : (
    <Root>
      <Header>
        <h1>nocebo - [ prs ] studio rehearsal</h1>
        <p>
          バンドの練習風景撮ったのをwebgl(three.js)でGPGPU使いつつcurlnoiseのparticleで遊んでみました
        </p>
        <Share>
          <a
            href="//twitter.com/share?text=nocebo - [ prs ] studio rehearsal&url=https://nocebo.jp/band/prs/public/&hashtags=webgl,threejs"
            title="Tweet"
            target="_blank"
            rel="noopener noreferrer nofollow"
          >
            SHARE
            <FontAwesomeIcon icon={["fab", "twitter"]} />
          </a>
        </Share>
      </Header>
      <Select>
        <h4>QUALITY SELECT</h4>
        <nav>
          <button
            onClick={() => {
              loadStart("NORMAL");
            }}
          >
            720P
          </button>
          <button
            onClick={() => {
              loadStart("HIGH");
            }}
          >
            1080P
          </button>
        </nav>
        <div className="url">
          <h5>URL</h5>
          <a
            href="https://nocebo.jp/band/prs/public/"
            target="_blank"
            rel="noopener noreferrer"
          >
            https://nocebo.jp/band/prs/public/
          </a>
        </div>
        <div className="link">
          <h5>LINKS</h5>
          <a
            href="https://nocebo.jp/"
            target="_blank"
            rel="noopener noreferrer"
          >
            nocebo.jp
          </a>
          <a
            href="https://nocebo.jp/band/"
            target="_blank"
            rel="noopener noreferrer"
          >
            band
          </a>
          <a
            href="https://youtu.be/tD1X_cUVVrY"
            target="_blank"
            rel="noopener noreferrer"
          >
            youtube
          </a>
          <a
            href="https://twitter.com/nocebojp"
            target="_blank"
            rel="noopener noreferrer"
          >
            twitter
          </a>
          <a
            href="https://nocebo.jp/post-3924/"
            target="_blank"
            rel="noopener noreferrer"
          >
            explain
          </a>
        </div>
        <div className="iframe">
          <div className="iframe__inner">
            <iframe
              title="nocebo - [ prs ] studio rehearsal"
              src="https://www.youtube.com/embed/tD1X_cUVVrY"
              allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
            ></iframe>
          </div>
        </div>
      </Select>
    </Root>
  );
};

export default React.memo(StreamLoader);

動画のサーバー配信?を何となくやってみたくて
やってみたらここも地味に苦戦した。

ローダーの割合出すのにloadLengthってので先に
一旦リクエストだしてるけど何かいい方法ありそうな。

<?php
/* =========================================
サーバーから動画配信するやつ
読み込み速度そこそこな気がするので
結局分割とかはしてない。
========================================= */
$STORAGE_PATH = パスとか";
$id = $video[$_POST['id']];
$q = $quality[$_POST['quality']];

$file = $_SERVER['DOCUMENT_ROOT'] . $STORAGE_PATH . "{$id}_{$q}p.webm";
$fp = @fopen($file, 'rb');

$size   = filesize($file); // File size
$length = $size;           // Content length
$start  = 0;               // Start byte
$end    = $size - 1;       // End byte

// ローダー用にlengthだけ返す場合
if (isset($_POST['isReadLength'])) {
    echo $length;
    exit();
}

// =============================
// Content-Range返す
// (結局分割してない)
// =============================
header('Content-type: video/webm');
header("Accept-Ranges: 0-$length");

if (isset($_SERVER['HTTP_RANGE'])) {

    $c_start = $start;
    $c_end   = $end;

    list(, $range) = explode('=', $_SERVER['HTTP_RANGE'], 2);
    if (strpos($range, ',') !== false) {
        header('HTTP/1.1 416 Requested Range Not Satisfiable');
        header("Content-Range: bytes $start-$end/$size");
        exit;
    }
    if ($range == '-') {
        $c_start = $size - substr($range, 1);
    } else {
        $range  = explode('-', $range);
        $c_start = $range[0];
        $c_end   = (isset($range[1]) && is_numeric($range[1])) ? $range[1] : $size;
    }
    $c_end = ($c_end > $end) ? $ensd : $c_end;
    if ($c_start > $c_end || $c_start > $size - 1 || $c_end >= $size) {
        header('HTTP/1.1 416 Requested Range Not Satisfiable');
        header("Content-Range: bytes $start-$end/$size");
        exit;
    }
    $start  = $c_start;
    $end    = $c_end;
    $length = $end - $start + 1;
    fseek($fp, $start);
    header('HTTP/1.1 206 Partial Content');
}

header("Content-Range: bytes $start-$end/$size");
header("Content-Length: " . $length);

$buffer = 1024 * 8;
while (!feof($fp) && ($p = ftell($fp)) <= $end) {
    if ($p + $buffer > $end) {
        $buffer = $end - $p + 1;
    }
    // set_time_limit(0);
    echo fread($fp, $buffer);
    flush();
}

fclose($fp);
exit();

広い集めだけども、PHP側はこんな感じ。
分割配信みたいなのもやらないとなーと思ってたんだけど、
webm にしたら軽くなってそこまで気になる速度じゃないかなとも思ったので
一括で返してます。
一応headers送れば分割で返すようにはなっている。はず。


webm すごい、変換クソ時間かかるけど

video

ここは video の配置

// =======================================
// video要素として配置はしてるけど
// 実際には参照してGLでcanvasに描画したもの表示している
// =======================================
import React, { useEffect, useState } from "react";
import styled from "styled-components";
import { updateValue } from "../data";
import { Conf } from "../index";

const Root = styled.div`
  width: 640px;
  height: 360px;
  position: fixed;
  top: 0;
  right: 0;
  z-index: 10;
  visibility: hidden;
  /* noneだとtexture参照できない */
  video {
    position: absolute;
    right: 0;
    bottom: 0;
    width: 640px;
    z-index: 1000;
  }
`;

const createBlobURL = async (videoBuffer: Uint8Array) => {
  const mediaSource: any = new MediaSource();
  mediaSource.addEventListener("sourceopen", () => {
    const sourceBuffer = mediaSource.addSourceBuffer(
      `video/webm; codecs="opus, vp9"`
    );
    sourceBuffer.addEventListener("updateend", () => mediaSource.endOfStream());
    sourceBuffer.appendBuffer(videoBuffer);
  });
  const src = URL.createObjectURL(mediaSource);
  return new Promise((resolve) => {
    resolve(src);
  });
};

const Video: React.FC<any> = (props) => {
  const [isLoaded, setIsLoaded] = useState<any>();
  const [blobUrl, setBlobUrl] = useState<any>();
  const { videoRef, setIsReady, videoBuffer } = props;

  useEffect(() => {
    (async () => {
      const blob_url = [];
      for (let i = 0; i < Conf.VIDEO_TEXTURE_NUM; i++) {
        blob_url[i] = await createBlobURL(videoBuffer[i]);
      }
      setBlobUrl(blob_url);
      setIsLoaded(true);
    })();
    // return () => {// cleanup};
  }, [videoBuffer]);

  useEffect(() => {
    let isCurrent = false;
    videoRef.forEach((video: any) => {
      isCurrent = !!video.current;
    });

    if (!isCurrent) return;
    videoRef[0].current.addEventListener("loadedmetadata", function () {
      updateValue.duration = videoRef[0].current.duration;
      setIsReady(true);
    });
  }, [isLoaded, setIsReady, videoRef]);

  return isLoaded ? (
    <Root className="Video">
      {videoRef.map((v: any, i: number) => {
        return <video src={blobUrl[i]} ref={videoRef[i]} key={i} />;
      })}
    </Root>
  ) : (
    <></>
  );
};

export default React.memo(Video);

上のstreamLoaderで受け取った Bufferblob に変換して src に設定してる。
で合ってると思う。

ui

ここはなんか再生UIのドラッグとかフルスクリーンとかつけてみたぐらい。なので省く。

以上です。