🎨

配信背景をTypeScript(Processing)で書いてみた

2021/12/13に公開

以下のような画像を生成できるWebサイトを作ったのでその紹介です。

ノードガーデン

リポジトリ: https://github.com/tamayura-souki/live-backgrounds
作ったWebサイト: https://tamayura-souki.github.io/live-backgrounds/

本記事はProcessing Advent Calendar 2021の13日目の記事です。
https://adventar.org/calendars/6163

この記事は何か

  • 画像・アニメーションを生成できるWebサイトを作ったので紹介
  • どうやって作ったか、工夫したところとか雑に説明
  • "クリエイティブコーディング × 配信用画像素材作成"はいいぞ!!

サイトの使い方

サイトURL: https://tamayura-souki.github.io/live-backgrounds/
サイトにアクセスすると、私の作った素材へのリンク一覧が出る。
例えば、node-gardenその1へ飛んで、色々いじると以下のようになる。

ノードガーデンGUI

ざっくり機能の説明

  • 画像・アニメーションの編集
    • 左にあるスライダーやカラーピッカーを使って、ノードの大きさを変えたり、色を変えたりできる。
  • 画像の保存
    • 画面上で、右クリックでメニューを出す。その後、名前を付けて画像の保存を押すと、左側のUIのない画像をダウンロードできる。
    • 画像の大きさはウィンドウサイズ依存。F11キーを押して、ブラウザを全画面表示にすればモニターの解像度と同じ大きさで保存できる。
  • OBSへの取り込み
    • 左のUIの一番下にある。copy urlを押すと、パラメータの変更を保持したまま、GUIがないバージョンのURLがクリップボードにコピーされる。
    • クリップボードのURLを、OBS等のストリーミングソフトのブラウザソースに貼り付ければ、そのまま取り込める。この場合の大きさは、OBSのブラウザソースの大きさに依存。

どうして作ったか

Vtuber活動[1]をしていて、いい感じにアニメーション素材を作れないかなと思ったのがきっかけ。
NodeCG[2]という、JavaScriptで配信画面を作るツールを見て(そしてその中でp5.jsが使えるのを見て)、画像・アニメーション素材をWebサイトとして作ると良いのではと思った。

実際、以下のような利点がある。

  • OBSのブラウザソースで表示すればダウンロードしなくていい
  • OBSのブラウザソースは透過しなくていい
    • 透過動画は作るのがめんどくさい
    • クロマキーは使える色が限られる
  • 描画処理をウィンドウサイズ依存にすれば、解像度調整簡単
  • ランダム要素を入れれば全く同じパターンの繰り返しにならない
    • 時刻とリンクしたりもできる。時計も作れる
  • サイト上でパラメータを調整できるようにすれば、素材のアレンジもできる
    • ただの画像配布だと使用する側でのアレンジが大変
  • ペンローズ・タイルのような複雑な幾何学模様(後述)が描ける
    • 通常のペイントソフトじゃ作りにくい図形が描ける
    • イラストスキルがなくても、プログラミングを生かして素材作製ができる

やりたいこと・実現方法

ざっとアプリに満たして欲しい要件は4つ。それぞれどのように実現するか考えてみる。

  1. OBSのブラウザソースで取り込める。
    -> Webサイト・アプリとして実装。
  2. 図形の描画。アニメーションの描画ができる。
    -> JavaScript, TypeScriptのグラフィック系のライブラリを使う。
    • 画像保存もできれば欲しい。
      -> canvasタグを操作するライブラリを使う。
      -> p5.jsを使うのがよさそう。
  3. パラメータの変更をOBSのブラウザソースにも適用できる。
    • OBSのブラウザソースは直接操作できない。
      -> NodeCG[2:1]のように別ページのUIからページを動的に更新する?
      -> カスタムCSSが使えるのでそれで頑張る?
      -> URLパラメータに必要な情報を詰め込む?
  4. URLコピペで取り込むだけで使えるようにしたい。
    -> URLパラメータに必要な情報を詰め込む方式に決定!

使ったもの

やることが決まったので、実際に作っていく。
まずは、使ったものの紹介。

  • p5.js - 今日の主役。今回はTypeScriptと一緒に。
  • parcel - TypeScriptをビルドしたりするやつ。webpack的なやつ。
  • GitHub Pages - ホスティング先。みんな大好き。

p5.jsとは

公式サイト↓
https://p5js.org/
p5.jsはJavaScriptのクリエイティブコーディング用ライブラリ。ProcessingのJS版。
四角形を描画したり、立方体を描画したり、画像を描画したり、マウス・キー入力を受け付けたり、音声を再生したりできる。また、canvas要素に描画してるので、名前を付けて画像を保存で保存できる。
ライブラリのコードをダウンロードして使う他、htmlに

<script src="https://cdn.jsdelivr.net/npm/p5@1.4.0/lib/p5.js"></script>

などと書けばすぐ使える。ウェブエディタもある。
npmで@types/p5をインストールすれば、TypeScriptでも使える。
シンプルなライブラリなので、ReactとかNodeCG[2:2]とかと合わせて使うこともできる。

クリエイティブコーディングは参考にできるものが多い

p5.jsはクリエイティブコーディング用のライブラリである。クリエイティブコーディングというのは、プログラミングでデザイン作ったりアニメーション作ったり、音声作ったりするやつである。
Twitter検索で#creativecodingと検索してみると、魅力的な作品をたくさん見ることができる。

さらにさらに、OpenProcessingに行くと、p5.jsによるたくさんの作例をソースとともに見ることができる。(※参考にする場合、ライセンスに注意)

環境構築

ということで環境を構築していく。
と言っても、以下のリンク先のとおりにやるだけなので、ここで説明することはなさそう。
ホスティングの設定は別途調べる。

https://ics.media/entry/210129/

GitHub Pages で公開

先ほど上げた記事は開発環境の構築だけなので、ここでGitHub Pagesで公開する方法をざっくり書いておく。
やることは.github/workflows/hogehoge.ymlに必要なスクリプトを書き込むだけ。
mainブランチにpushしたとき、ビルドの処理が走って、生成ファイルを指定のブランチに配置する処理を書く。

書いたスクリプト: https://github.com/tamayura-souki/live-backgrounds/blob/main/.github/workflows/push-main.yml

参考にしたファイル: https://github.com/VirtualProgrammersNetwork/vpn-website/blob/main/.github/workflows/push-main.yml

作ったプログラムの説明

ここからは実装上工夫したところをざっくり説明していく。
とりあえず、ノードガーデンコードを例に出しておく。

ノードガーデンのコード
src/canvases/node-garden1.ts
import p5 from "p5";
import { Node, collideNode, generateRandomNodes } from "./modules/node";
import { ParamNum, Color } from "./modules/param";
import { AnimationSketch } from "./modules/sketch";
import { isNear } from "./modules/utils";

type NodeGarden1Params = {
  nodeR: ParamNum;
  nodeColor: Color;
  nodeN: ParamNum;
  lineLength: ParamNum;
  lineWidth: ParamNum;
  lineColor: Color;
  maxV: ParamNum;
  minV: ParamNum;
};

class NodeGarden1 extends AnimationSketch {
  params: NodeGarden1Params = {
    nodeR: { val: 3, min: 1, max: 50, isInt: true },
    nodeColor: { r: 0, g: 0, b: 0 },
    nodeN: { val: 30, min: 1, max: 100, isInt: true },
    lineLength: { val: 150, min: 1, max: 400, isInt: true },
    lineWidth: { val: 2, min: 1, max: 20, isInt: true },
    lineColor: { r: 0, g: 0, b: 0 },
    maxV: { val: 5.0, min: 1.0, max: 20.0, isInt: false },
    minV: { val: 0.1, min: 0.1, max: 20.0, isInt: false },
  };

  nodes: Node[];
  nodeR: number;
  nodeColor: p5.Color;
  nodeN: number;
  lineLength: number;
  lineWidth: number;
  lineColor: p5.Color;
  maxV: number;
  minV: number;

  updateStat(p: p5): void {
    this.nodeR = this.params.nodeR.val;
    const nCol = this.params.nodeColor;
    this.nodeColor = p.color(nCol.r, nCol.g, nCol.b);
    this.nodeN = this.params.nodeN.val;
    this.lineLength = this.params.lineLength.val;
    this.lineWidth = this.params.lineWidth.val;
    const lCol = this.params.lineColor;
    this.lineColor = p.color(lCol.r, lCol.g, lCol.b);
    this.maxV = this.params.maxV.val;
    this.minV = this.params.minV.val;

    this.nodes = generateRandomNodes(p, this.nodeN, this.maxV, this.minV);
    p.fill(this.nodeColor);
  }

  setup(p: p5): void {
    p.smooth();
  }

  draw(p: p5): void {
    p.clear()

    // 当たり判定
    this.nodes.forEach((n, i) => {
      this.nodes.slice(i+1).forEach((n2) => {
        collideNode(this.nodeR*2, n, n2);
      });
    });

    p.stroke(this.lineColor);
    p.strokeWeight(this.lineWidth);
    this.nodes.forEach((n, i) => {
      this.nodes.slice(i+1).forEach((n2) => {
        if(isNear(this.lineLength, n.pos, n2.pos)) {
          p.line(n.pos.x, n.pos.y, n2.pos.x, n2.pos.y);
        }
      });
    });

    this.nodes.forEach((n) => {
      n.pos.x += n.v.x;
      n.pos.y += n.v.y;
      if (n.pos.x < 0 || n.pos.x > p.width) {
        n.v.x *= -1;
        n.pos.x += n.v.x;
      }
      if (n.pos.y < 0 || n.pos.y > p.height) {
        n.v.y *= -1;
        n.pos.y += n.v.y;
      }
    });

    p.noStroke();
    this.nodes.forEach((n) => {
      p.ellipse(n.pos.x, n.pos.y, this.nodeR*2);
    });
  }
}

new NodeGarden1().showSketch();

p5.jsを書いたことがある人なら分かると思うが、普通にp5.jsで書いた場合と見た目がかなり違う。
主に、パラメータ周りに関するやつと、p5.js独特の関数(setup, drawとか)をラップしてあって、
おかげで新たに作品を作成するときに結構楽ができるようになった。
そのへんの、ラップした話をざっくりしてから、特に実際の作例2つを紹介する。

パラメータ

プログラミングで画像を作る利点はパラメータをいじって、色とか数とか比率とかサイズとかを簡単に変えられるところなので、がんばった。
p5.jsにはGUIを表示する機能があるのでそれを利用する(リファレンスのDOMの部分に書いてある)。

完成形は下の画像の通り。
guiサンプル

GUI自動生成

いちいち新たに作品を作るごとに、GUI周りのコードを書くのはだるい。
なので、パラメータを定義したら、自動でGUIが生えるようにした。
とりあえず、GUIはスライドバーとカラーピッカーがあれば足りると思うので、スライドバーで操作するようにParamNum、カラーピッカーで操作するようにColorなど定義。

例として出したノードガーデンのパラメータ定義部分はこんな感じ。

パラメータ定義部分抜粋
class NodeGarden1 extends AnimationSketch {
  params: NodeGarden1Params = {
    nodeR: { val: 3, min: 1, max: 50, isInt: true },
    nodeColor: { r: 0, g: 0, b: 0 },
    nodeN: { val: 30, min: 1, max: 100, isInt: true },
    lineLength: { val: 150, min: 1, max: 400, isInt: true },
    lineWidth: { val: 2, min: 1, max: 20, isInt: true },
    lineColor: { r: 0, g: 0, b: 0 },
    maxV: { val: 5.0, min: 1.0, max: 20.0, isInt: false },
    minV: { val: 0.1, min: 0.1, max: 20.0, isInt: false },
  };
  // 以下略

ここで、書いた情報を元に、AnimationSketchの方でbuildGUIなるものが呼び出されて知らないうちにGUIが生える。

buildGUI
buildGUI抜粋
export const buildGUI = (p5: p5, params: Object): ParamsGUI => {
  let paramsGUI: ParamsGUI = {};
  let i: number = 0;
  Object.entries(params).forEach(([key, value]) => {
    const p = p5.createP(key);
    p.position(10, 30*i);
    i++;

    if (isParamNum(value)) {
      const slider = p5.createSlider(
        value.min, value.max, value.val,
        value.isInt ? 1 : 0
      );
      slider.position(10, 30*i+10);
      paramsGUI[key] = slider;
    }else if (isColor(value)) {
      const colorPicker = p5.createColorPicker(
        p5.color(value.r, value.g, value.b)
      );
      colorPicker.position(10, 30*i+10);
      paramsGUI[key] = colorPicker;
    }
    i++;
  });
  paramsGUI["button"] = p5.createButton("copy url");
  paramsGUI["button"].position(10,30*i+10);
  paramsGUI["button"].mousePressed(()=>{copyURLwithParam(params)});
  return paramsGUI;
}

もちろん、GUIから値をParamsオブジェクトに移動させるのもupdateParamsByGUIなるものを作って、適宜裏で呼び出す。

updateParamsByGUI
updateParamsByGUI抜粋
export const updateParamsByGUI = (params: Object, paramsGUI: ParamsGUI): boolean => {
  let isChanged = false;
  Object.entries(params).forEach(([key, value]) => {
    if (isParamNum(value)) {
      isChanged = isChanged || params[key].val !== paramsGUI[key].value();
      params[key].val = paramsGUI[key].value();
    }else if (isColor(value)) {
      const code: string = paramsGUI[key].value().toString();
      const color = hexToColor(code)
      isChanged = isChanged || !eqColor(params[key], color);
      params[key] = color;
    }
  });
  return isChanged;
}

さらに、パラメータが更新された時にAnimationSketchupdateStatが自動で呼び出されるようにしてあるので、更新されたパラメータを読み出す処理はupdateStatをオーバーライドすればいい。
こうして、パラメータ定義+updateStatをオーバーライドするだけで、自動でGUIが生成されて値が同期するようにできた。

URLから取得

パラメータをGUIで操作できるようになったので、次はこれの初期値をURLパラメータで入れられるようにしたい。
(前述の通り、OBSのブラウザソース内でスライバーがいじれないので)

前章で、がっつりパラメータ周りを書いたおかげで、URLパラメータ対応も簡単にできた。

URLパラメータからParamsオブジェクトを初期化する関数は以下のとおり。

URLParamsToParams
URLParamsToParams抜粋
export const URLParamsToParams = (params: Object) => {
  const URLParams = new URLSearchParams(window.location.search);
  Object.entries(params).forEach(([key, value]) => {
    const URLValue = URLParams.get(key);
    if (!URLValue) return;
    if (isParamNum(value)) {
      params[key].val = Number(URLValue);
    } else if (isColor(value)) {
      params[key] = hexToColor(URLValue);
    }
  });
}

それから、GUIを消す用の設定もURLパラメータの方で仕込む。

isGUIHidden
isGUIHidden抜粋
export const isGUIHidden = (): boolean => {
  const URLParams = new URLSearchParams(window.location.search);
  return parseBool(URLParams.get(hideGUI));
}

p5.jsをラップする

TypeScriptでp5.jsを素朴に書くと以下のようなコードになる。

import p5 from "p5";

const sketch = (p: p5) => {
  p.setup = () => {
    // draw の前に1度だけ実行される
    // 解像度の設定やオブジェクトの初期化とかをする
  };

  p.draw = () => {
    // 毎フレーム実行される処理
  };
};

new p5(sketch);

さらにここに、さっき述べたGUI周りの処理が決まって入る。毎回同じ関数を書くんだから、共通化したいし、setupとかdrawとか抽象メソッドとして置いておいて、書かないとエラーが出るくらいの方が安心する。

そんなわけで、かったるい処理を全部親クラスに押し付ける。

親クラスに押し付ける

さて、親クラスを作るんだけど、実は、静止画とアニメーションで描画更新タイミングがちょっと違う。

  • 静止画 - 更新はパラメータが変わったときだけ
  • アニメーション - 更新は60fpsなら1秒間に60回

これを同じクラスにしてしまうと、静止画が必要以上に重くなって、後述のペンローズ・タイルなんかが使い物にならない。
なので、一番大元となるクラスを作って、さらにそれを継承する形で、静止画クラス、アニメーションクラスなんかを作る。

一番大元となるSketchクラスはこんな感じ。

Sketchクラス
abstract class Sketch {
  // URLパラメータに使う用のやつ
  // 基本的に ColorかParamNumのメンバだけ持つ
  abstract params: Object | null;
  paramsGUI: ParamsGUI | null;
  readonly p5instance: p5;

  // パラメータが変更されて
  // ステータスを更新しないといけないときに呼ばれる
  abstract updateStat(p: p5): void;

  // 最初に1度だけ呼び出される処理
  abstract setup(p: p5): void;
  // 画面更新の処理
  abstract draw(p: p5): void;

  // p5jsのインスタンスつくるためのやつ;
  abstract sketch(p: p5): void;

  // 実際にp5jsのインスタンスをつくる
  // コンストラクタに組み込みたかったけど
  // 変数初期化のタイミングがコンストラクタの後っぽいので無理
  showSketch = () => {
    const sketch = (p: p5) => {
      this.sketch(p)
    }
    new p5(sketch)
  }
}

Sketchを継承して作った抽象クラスはそれぞれ以下の通り。
ウィンドウサイズが変わった時に呼びされるwindowedResizedの設定も足しておく。
(何か説明することあるかなと思ったけど、draw関数の呼び出し位置が違うだけだった)

静止画用の抽象クラス
// 静止画用の抽象クラス
export abstract class StillSketch extends Sketch {
  sketch(p: p5) {
    if (this.params) {
      URLParamsToParams(this.params);
    }

    const updateStat = (p: p5) => {
      this.updateStat(p);
      this.draw(p);
    }

    let isGUIShown = this.params && !isGUIHidden();
    let prepareGUI = isGUIShown ? () => {
      this.paramsGUI = buildGUI(p, this.params);
    } : () => {};
    let handleGUI = isGUIShown ? () => {
      if(updateParamsByGUI(this.params, this.paramsGUI)) {
        updateStat(p);
      }
    } : () => {};

    p.setup = () => {
      p.createCanvas(p.windowWidth, p.windowHeight);
      p.frameRate(FPS);

      prepareGUI();

      this.setup(p);
      updateStat(p);
    }

    p.draw = () => {
      handleGUI();
    };

    p.windowResized = () => {
      p.resizeCanvas(p.windowWidth, p.windowHeight);
      updateStat(p);
    }
  }
}
アニメーション用の抽象クラス
// アニメーション用の抽象クラス
export abstract class AnimationSketch extends Sketch {
  sketch(p: p5) {
    if (this.params) {
      URLParamsToParams(this.params);
    }

    let isGUIShown = this.params && !isGUIHidden();
    let prepareGUI = isGUIShown ? () => {
      this.paramsGUI = buildGUI(p, this.params);
    } : () => {};
    let handleGUI = isGUIShown ? () => {
      if(updateParamsByGUI(this.params, this.paramsGUI)) {
        this.updateStat(p);
      }
    } : () => {};

    p.setup = () => {
      p.createCanvas(p.windowWidth, p.windowHeight);
      p.frameRate(FPS);

      prepareGUI();

      this.setup(p);
      this.updateStat(p);
    }

    p.draw = () => {
      handleGUI();
      this.draw(p);
    };

    p.windowResized = () => {
      p.resizeCanvas(p.windowWidth, p.windowHeight);
      this.updateStat(p);
    }
  }
}

実際に図形を書いてみる

流石に、一個もクリエイティブコーディング的な内容を書かないのは寂しいので、2例ほど作品を紹介する。

ノードガーデン

みんな大好きノードガーデン(クリエイティブコーディングの定番らしい)。
一番最初の例に出した、丸と線のやつ。

ノードガーデン
ノードガーデンのページ

ノードガーデンのコード
import p5 from "p5";
import { Node, collideNode, generateRandomNodes } from "./modules/node";
import { ParamNum, Color } from "./modules/param";
import { AnimationSketch } from "./modules/sketch";
import { isNear } from "./modules/utils";

type NodeGarden1Params = {
  nodeR: ParamNum;
  nodeColor: Color;
  nodeN: ParamNum;
  lineLength: ParamNum;
  lineWidth: ParamNum;
  lineColor: Color;
  maxV: ParamNum;
  minV: ParamNum;
};

class NodeGarden1 extends AnimationSketch {
  params: NodeGarden1Params = {
    nodeR: { val: 3, min: 1, max: 50, isInt: true },
    nodeColor: { r: 0, g: 0, b: 0 },
    nodeN: { val: 30, min: 1, max: 100, isInt: true },
    lineLength: { val: 150, min: 1, max: 400, isInt: true },
    lineWidth: { val: 2, min: 1, max: 20, isInt: true },
    lineColor: { r: 0, g: 0, b: 0 },
    maxV: { val: 5.0, min: 1.0, max: 20.0, isInt: false },
    minV: { val: 0.1, min: 0.1, max: 20.0, isInt: false },
  };

  nodes: Node[];
  nodeR: number;
  nodeColor: p5.Color;
  nodeN: number;
  lineLength: number;
  lineWidth: number;
  lineColor: p5.Color;
  maxV: number;
  minV: number;

  updateStat(p: p5): void {
    this.nodeR = this.params.nodeR.val;
    const nCol = this.params.nodeColor;
    this.nodeColor = p.color(nCol.r, nCol.g, nCol.b);
    this.nodeN = this.params.nodeN.val;
    this.lineLength = this.params.lineLength.val;
    this.lineWidth = this.params.lineWidth.val;
    const lCol = this.params.lineColor;
    this.lineColor = p.color(lCol.r, lCol.g, lCol.b);
    this.maxV = this.params.maxV.val;
    this.minV = this.params.minV.val;

    this.nodes = generateRandomNodes(p, this.nodeN, this.maxV, this.minV);
    p.fill(this.nodeColor);
  }

  setup(p: p5): void {
    p.smooth();
  }

  draw(p: p5): void {
    p.clear()

    // 当たり判定
    this.nodes.forEach((n, i) => {
      this.nodes.slice(i+1).forEach((n2) => {
        collideNode(this.nodeR*2, n, n2);
      });
    });

    p.stroke(this.lineColor);
    p.strokeWeight(this.lineWidth);
    this.nodes.forEach((n, i) => {
      this.nodes.slice(i+1).forEach((n2) => {
        if(isNear(this.lineLength, n.pos, n2.pos)) {
          p.line(n.pos.x, n.pos.y, n2.pos.x, n2.pos.y);
        }
      });
    });

    this.nodes.forEach((n) => {
      n.pos.x += n.v.x;
      n.pos.y += n.v.y;
      if (n.pos.x < 0 || n.pos.x > p.width) {
        n.v.x *= -1;
        n.pos.x += n.v.x;
      }
      if (n.pos.y < 0 || n.pos.y > p.height) {
        n.v.y *= -1;
        n.pos.y += n.v.y;
      }
    });

    p.noStroke();
    this.nodes.forEach((n) => {
      p.ellipse(n.pos.x, n.pos.y, this.nodeR*2);
    });
  }
}

new NodeGarden1().showSketch();

色んな記事が出ているが、今回は以下の記事を参考にした。

https://jp.deconbatch.com/2021/10/node-garden.01.html

記事の内容をそのまま実装するだけだとあれなので、ノード間の衝突の処理を入れてみる。
ノードが衝突したら、お互い逆方向に跳ね返されるという処理。
めんどくさいので、弾性衝突じゃなくて、単純にぶつかったノード間で速度をスワップする処理を実装する。
実装した衝突処理は以下のとおり。
JSは変数のスワップが[a,b]=[b,a]でできて便利。
collisionNowとかごちゃごちゃしているのは、単に速度を反転させるだけだと、ノードの円同士がめり込んでる間に無限に衝突判定が起こるから。フラグを作って、1度衝突したら、衝突し終えるまで衝突処理をしないようにする。

export const collideNode = (r: number, a: Node, b: Node) => {
  if(isNear(r, a.pos, b.pos)) {
    if(a.collisionNow || b.collisionNow) {
      a.collisionNow = true;
      b.collisionNow = true;
      return;
    }
    [a.v, b.v] = [b.v, a.v];
    a.collisionNow = true;
    b.collisionNow = true;

  } else {
    if(!(a.collisionNow && b.collisionNow)) return;
    a.collisionNow = false;
    b.collisionNow = false;
  }
}

ペンローズ・タイル

みんな大好きペンローズ・タイル(コード書かないと描けなさそう)。
非周期的タイリングということで、同じパターンを繰り返さないことが特徴(wikipediaの解説)。

ペンローズ・タイル
ペンローズ・タイルのページ

ソースコードは以下のサイトと同等なので割愛。複雑な幾何学図形だといじるところが特に見つからない。
3角形を使って再帰的に分割していくので、処理が重い。前述のノードガーデンのようにアニメーション用のクラスで実装すると処理がやばいので、静止画用クラス(StillSketch)の出番。
アニメーション用と静止画用でクラス分けて良かった。
https://preshing.com/20110831/penrose-tiling-explained/

さいごに

長々と書いてしまったんですけど、結局言いたいこととしては

  • "クリエイティブコーディング × 配信用画像素材作成"はいいぞ!! ってことです。
脚注
  1. YouTubeのリンク: https://www.youtube.com/channel/UCRQ6fe53K3Qh-6FzPaGMUXw ↩︎

  2. NodeCG: 配信レイアウトを作るのに便利なフレームワーク。参考: https://zenn.dev/cma2819/articles/start-nodecg-01 ↩︎ ↩︎ ↩︎

Discussion