⚡️

[背景編] ポートフォリオの解説

2021/11/18に公開

WebGL 総本山で、私の制作したポートフォリオを取り上げていただき、とても嬉しかったので その勢いのまま解説記事を Zenn 初投稿しようと思います。

https://webgl.souhonzan.org/entry/?v=2032

https://portfolio-shader.vercel.app/

このポートフォリオでお世話になっている技術

ホスティング

  • vercel

主なフロントエンドの技術

  • next.js@11.0.1
  • three.js@0.131.1
  • typescript@4.3.5
  • tailwindcss@2.2.7
  • gsap@3.7.1

正直、機能過多な気がしますが やりたかったのでやっちゃいました。


また、今回この背景解説用に背景だけを抜き出したものも作りました。

https://portfolio-shader-background.vercel.app/

https://github.com/noliaki/portfolio-shader-background

こちらは、Viteを使用し、Tween ライブラリにはTween24.jsを使用させていただきました。

https://vitejs.dev/

https://github.com/a24/tween24js

shader はポートフォリオで使用したものと全く同じものを使用しています。

JS(TS)

JS 側はthree.jsを使用しています。
そこで使用している主なものをピックアップします。

🎑 背景のオブジェクト

背景は四角の矩形のPlaneBufferGeometryをブラウザサイズいっぱいに配置してるだけのシンプルなものです。
背景はそこまで頂点自体を操作することは無く、テクスチャを貼り付けて その色を変更したり、テクスチャを切り替えたりするだけになるので、頂点の数は最小で問題無く、widthSegmentsheightSegments は、それぞれ 1を指定しています。

ソースコード
main.ts
const mesh = new Mesh(
  new PlaneBufferGeometry(winWidth, winHeight, 1, 1),
  new ShaderMaterial({
    vertexShader,
    fragmentShader,
    uniforms: {
      uTime: {
        value: 0,
      },
      uResolution: {
        value: [winWidth, winHeight],
      },
      uTexturePrev: {
        value: new Texture(),
      },
      uTextureNext: {
        value: new Texture(),
      },
      uTexturePrevResolution: {
        value: [0, 0],
      },
      uTextureNextResolution: {
        value: [0, 0],
      },
      uProgress: {
        value: 0,
      },
    },
  })
)

materialShaderMaterialで、vertexShaderfragmentShader、そして uniform 変数を指定しています。

uniform 変数のそれぞれの役割

uTime

  • 時間経過として扱う変数。
    requestAnimationFrame でひたすら+1 してるだけです。
    shader 内では この変数から乱数を生成したり、常に動いてるような表現をするために使用します

uResolution

  • ブラウザサイズを shader 内で扱うための変数。
    この値からテクスチャをブラウザサイズいっぱいにする計算をします

uTexturePrev / uTextureNext

  • 背景のテクスチャを切り替えるのに使用する変数。
    切り替える前・後のテクスチャを参照するために使用します。
    画像は外部から読み込むため、一旦は空のテクスチャ(new Texture())を初期値にしています

uTexturePrevResolution / uTextureNextResolution

  • 背景のテクスチャで使用する画像のサイズを shader 内で扱うための変数
    uResolutionと このサイズをゴニョゴニョしてブラウザいっぱいに表示します

uProgress

  • テクスチャを切り替えるために使用する変数
    この変数は 0 から 1 まで変化します。
    0 のときは uTexturePrev、1 のときはuTextureNextから色を抽出して出力します。
    この変数を 0 から 1 まで徐々に変化させることで、uTexturePrevの色からuTextureNextの色へ徐々に変化させることができます

ちなみに、個人的な命名ルールとして uniform 変数は わかりやすいように、uをプレフィックスとして命名しています。

🎥 Camera

今回作ったものは WebGL の 奥行きがあるような 3D っぽさを出す必要がそこまでなかったので、カメラはOrthographicCameraを使用しています。

ソースコード
main.ts
const camera = new OrthographicCamera(
  -winWidth / 2,
  winWidth / 2,
  winHeight / 2,
  -winHeight / 2
)

******

これ以外は、WebGLRendererSceneなどを初期化して、Sceneに背景のオブジェクトを追加し、requestAnimationFrameを使用して、毎フレーム毎にuniform変数: uTime+1してレンダリングしてるだけとなります

ソースコード
main.ts
const update = () => {
  const {
    material: {
      uniforms: { uTime },
    },
  } = mesh

  uTime.value += 1

  renderer.render(scene, camera)

  requestAnimationFrame(() => {
    update()
  })
}

背景で使用している画像

背景で使用している画像は contentful という、CMS で管理をしています。

https://www.contentful.com/

ここから GraphQL Content API を利用して画像の URL やサイズを取得しています。

https://www.contentful.com/developers/docs/references/graphql/

具体的な取得方法についてはここでは割愛させていただきます 🙇‍♂️
ソースコードだけ貼っておきます。

https://github.com/noliaki/portfolio-shader-background/blob/180a9c9c3c3416b841d195b8d83a334e7314b821/src/main.ts#L113-L129

shader

続いて、shader です。
vertex shader も fragment shader も perlin noiseというアルゴリズムを利用しています。
これは乱数を生成するものなのですが、引数が近似していれば生成される乱数も近似した値になるものです。
引数の値を少し変えれば、少し変化した乱数を得ることができます。

その乱数生成の関数は ↓ こちらを使用させていただいております。
https://github.com/ashima/webgl-noise/blob/master/src/noise3D.glsl

vertex shader

https://github.com/noliaki/portfolio-shader-background/blob/master/src/vertex-shader.glsl

vertex shader は 以下の処理をしています。

  1. varying 変数vUvPlaneBufferGeometryで定義されているuv変数を代入
  2. PlaneBufferGeometryで定義されているpositionの x 軸、y 軸の値を 0 から 1 へ変換
  3. 変換した x 軸・y 軸の値と、時間経過を表すuTime変数を引数に乱数を生成
  4. 変換した x 軸・y 軸の値から変化する遅延時間を算出
  5. x 軸、y 軸の位置を決定

vertex shader では主にテクスチャを入れ替える際、つまり uProgress変数が 0 から 1 へ変化する際に頂点の x 軸と y 軸の位置に変化をもたらしています。

一つずつ解説します。

varying 変数vUvPlaneBufferGeometryで定義されているuv変数を代入

vertex-shader.glsl
vUv = uv;

👉 ソース

uvの座標は fragment shader でも使用したいので、 vUvという varying 変数に代入しています。

three.jsを使用した場合、vertex shader や fragment shader に含まれる変数は以下を参照ください

https://threejs.org/docs/#api/en/renderers/webgl/WebGLProgram

今回の vertex shader では以下の変数を使用しています

  • uniform mat4 projectionMatrix
  • uniform mat4 modelViewMatrix
  • attribute vec3 position
  • attribute vec2 uv

PlaneBufferGeometryで定義されているpositionの x 軸、y 軸の値を 0 から 1 へ変換

vertex-shader.glsl
vec2 p = vec2(
  ((position.x / (uResolution.x * 0.5)) + 1.0) * 0.5,
  ((position.y / (uResolution.y * 0.5)) + 1.0) * 0.5
);

👉 ソース

positionPlaneBufferGeometryで定義されます。
xyzのプロパティを持つ、頂点の座標を表します。
PlaneBufferGeometryはシンプルな四角の矩形の形状です。左上、右上、左下、右下の頂点の座標を持ちます。
PlaneBufferGeometryの基準点は矩形の中心になります。
x 軸は左から右へ値が大きくなります。
y 軸は下から上へ値が大きくなります。
例えば、ブラウザサイズが横 1000px、縦 600px だった場合、左上、右上、左下、右下のそれぞれの頂点の座標(x, y, z)は
左上: (-500, 300, 0)
右上: (500, 300, 0)
左下: (-500, -300, 0)
右下: (500, -300, 0)
となります。

まず、x 軸の計算だけを見てみます。
↓ この部分

vertex-shader.glsl
((position.x / (uResolution.x * 0.5)) + 1.0) * 0.5

uResolutionはブラウザのサイズを JS から指定しています。

さらに ↓ この部分

vertex-shader.glsl
position.x / (uResolution.x * 0.5)

これは、positionx座標をブラウザの横幅の半分(uResolution.x * 0.5)で割ることで、-1 ~ 1 までの値へ変換しています。
そして、この値に +1することで

vertex-shader.glsl
- position.x / (uResolution.x * 0.5)
+ (position.x / (uResolution.x * 0.5)) + 1.0

0 ~ 2の値へ変換しています。

さらに 0.5を掛けることで

vertex-shader.glsl
- (position.x / (uResolution.x * 0.5)) + 1.0
+ ((position.x / (uResolution.x * 0.5)) + 1.0) * 0.5

0 ~ 1へ値を変換しています。

これで、ブラウザの左端は0、右端は1として計算することが可能となりました。

y 軸もほぼ同じようにすることで、ブラウザの下端が0、上端が1として計算することが可能となります。

vertex-shader.glsl
((position.y / (uResolution.y * 0.5)) + 1.0) * 0.5

こうすることで、vec2 p 変数は p.xp.yともに 0 ~ 1の値が代入されることになります。

変換した x 軸・y 軸の値と、時間経過を表すuTime変数を引数に乱数を生成

vertex-shader.glsl
float noise = snoise(vec3(p, uTime * 0.0003));

👉 ソース

こちらは前述した perlin noiseを利用して乱数を生成しています。
生成方法はsnoiseという関数を利用します。

https://github.com/ashima/webgl-noise/blob/3e2528debc5e5e51a35bf154e7d27c8f98078f8a/src/noise3D.glsl#L30

この関数は、vec3つまり 3 つのプロパティをもった値を引数として乱数を生成します。
先程算出した 0 から 1 までの値が代入される vec2 p と 時間経過を表すuTime変数をプロパティとしています。
uTimeをそのまま使用すると変化が大きいので、0.0003を掛けて小さい値にしています。(この0.0003はアナログ的に、このくらいかなーって感じで決めました 🤪)

これで隣り合う座標の値も近似していることから、乱数も近似した値を得ることができます。
また、時間経過毎にも近似した乱数を得ることが可能となっています。
これにより、滑らかだけど 不規則な状態にすることが可能となります。

ここで得られる乱数は -1 ~ 1の範囲の乱数になります。

変換した x 軸・y 軸の値から変化する遅延時間を算出

vertex-shader.glsl
float delay = ((p.x + p.y) * 0.5) * maxDelay;
float tProgress = clamp(uProgress - delay, 0.0, duration) / duration;

👉 ソース

ここは何をやっているかと言うと、座標位置によって変化の開始遅延時間を算出しています。
左下 → 右上へ変化の開始時間を遅らせています。

まず、↓ この部分

vertex-shader.glsl
float delay = ((p.x + p.y) * 0.5) * maxDelay;

maxDelay は定数として0.6としています。
👉 ソース

先程算出した座標位置を 0 から 1 へ変換したvec2 pを使っています。
vec2 pxはブラウザの左端が 0、右端が 1、yはブラウザの下端が0、上端が1になっているので、((p.x + p.y) * 0.5)はブラウザの左下から右上に向かって 0 から 1 へ変化します。
これにmaxDelay0.6)を掛けているので、この結果は左下から右上に向かって0 ~ 0.6の数値を得ることが出来ます。

次に ↓ この部分

vertex-shader.glsl
float tProgress = clamp(uProgress - delay, 0.0, duration) / duration;

clampという組み込み関数を使っています。
引数を 3 つ取り、clamp(a, b, c) という風に使用します。
これを JS 的に表現すると、Math.min(Math.max(a, b), c)となります。

この部分だけを見てみます

clamp(uProgress - delay, 0.0, duration)

durationは定数として1.0 - maxDelayつまり0.4を指定しています。
👉 ソース

ですので、clamp(uProgress - delay, 0.0, duration)これは0 ~ 0.4の数値となります。
そして、これをdurationで割っているので、0 ~ 1の数値を得ることが出来ます。

clamp(uProgress - delay, 0.0, duration) / duration

uProgress - delayに注目してみます。

uProgress0 ~ 1へと変化します。
delayは座標位置によって0 ~ 0.6の値となります。

これは、uProgressdelayよりも大きな値にならないと、clamp(uProgress - delay, 0.0, duration)0よりも大きな値にならないということです。
結果として、float tProgress0よりも大きな値になりません。

float tProgress0よりも値が大きくならないと、それ以降の計算で変化を加えられないようにしています。

delayは座標の位置、左下から右上に向かって0 ~ 0.6の値となるので、座標が右上にあるほど、uProgress0 ~ 1へと変化するときに、0よりも大きな値になるのが遅延する、という計算をしています。

x 軸、y 軸の位置を決定

vertex-shader.glsl
float tBezier = cubicBezier(1.0, ((noise + 1.0) * 0.5) + 1.0, 1.0, tProgress);

gl_Position = projectionMatrix * modelViewMatrix * vec4(position.xy * tBezier, position.z, 1.0);

👉 ソース

最後に位置を決定します。

まずは ↓ こちら

vertex-shader.glsl
float tBezier = cubicBezier(1.0, ((noise + 1.0) * 0.5) + 1.0, 1.0, tProgress);

cubicBezierという、こちらは独自に記述した関数を使用しています。

👉 ソース

ベジェ曲線のやつです。

cubicBezier(a, b, c, d)

という感じで、引数を 4 つとります。

d0 ~ 1へ変化すると、a → b → cへと いい感じに値を変化してくれるものです。
つまり、↓ これは先程算出したtProgress0 ~ 1へと変化すると、1.0 → ((noise + 1.0) * 0.5) + 1.0 → 1.0 と変化する値を得ることが出来ます。

cubicBezier(1.0, ((noise + 1.0) * 0.5) + 1.0, 1.0, tProgress)

↓ こちらに注目します

((noise + 1.0) * 0.5) + 1.0

noisevec2 pと 時間経過を表すuTimeから生成した-1 ~ 1の乱数です。
ですので、この式は最小で1、最大で2となります。

そして最後に出力する座標の位置を決定しています

gl_Position = projectionMatrix * modelViewMatrix * vec4(position.xy * tBezier, position.z, 1.0);

tBezierは最大で2となるので、position.xy * tBezierは、最大でも座標位置に2を掛けた位置となります。
z軸への変化は何もしていないです。

fragment shader

続いて、fragment shader です

https://github.com/noliaki/portfolio-shader-background/blob/180a9c9c3c3416b841d195b8d83a334e7314b821/src/fragment-shader.glsl

fragment shader は 以下の処理をしています。

  1. vUvuTimeから乱数を生成
  2. vUv からアニメーションの開始遅延時間を算出
  3. uResolutionと テクスチャの画像のサイズからブラウザサイズいっぱいに表示させるテクスチャの座標を算出
  4. また、乱数を生成
  5. 変化前のテクスチャの色と変化後のテクスチャの色を抽出
  6. 色の決定
  7. 色を変化させる

fragment shader では テクスチャの画像から RGB の値を微妙にずらして抽出して、uProgress 変数が 0 から 1 へ変化する際に、変化前の色と変化後の色の決定をしています。

vUvuTimeから乱数を生成

fragment-shader.glsl
float noise = snoise(vec3(vUv, uTime / 12000.0));
float fNoise = ((noise + 1.0) * 0.5);

👉 ソース

vertex shader でも使用した、perlin noiseを使って、乱数を生成しています。
今回引数に使用したのはvUvの値とuTimeの値です。
vertex shader ではuTime0.0003を掛けていましたが、fragment shader では 12000.0で割っています。
このへんの統一感の無さは私のズボラな性格を表現しています 🙇‍♂️
uTimeの値が十分に小さければ、どちらでも OK だと思います。

float noise-1 ~ 1の値が生成されます。0 ~ 1の乱数も使いたかったので、float fNoiseという変数で0 ~ 1の値を算出しています。

vUv からアニメーションの開始遅延時間を算出

fragment-shader.glsl
float delay = (vUv.x + vUv.y) * 0.5 * maxDelay;
float tProgress = clamp(uProgress - delay, 0.0, duration) / duration;
float tBezier = cubicBezier(0.0, 1.0 + fNoise * 4.0, 0.0, tProgress);

👉 ソース

ここもほぼ vertex shader と同じことをしています。
float tBeziertProgressが 0 から 1 へと変化する際、0 → 1.0 + fNoise * 4.0 → 0 と値が変化します。

uResolutionと テクスチャの画像のサイズからブラウザサイズいっぱいに表示させるテクスチャの座標を算出

fragment-shader.glsl
float stagger = (tBezier * noise + 1.0);

vec2 prevUv = imageUv(uResolution, uTexturePrevResolution, vUv) * stagger;
vec2 nextUv = imageUv(uResolution, uTextureNextResolution, vUv) * stagger;

👉 ソース

float staggerは算出する uv 座標をずらすための乱数です。

そして、肝心のブラウザサイズいっぱいに表示させるための uv 座標を算出する計算方法は、完全に ↓ こちらの神記事を参考にさせていただきました 🙇‍♂️

https://qiita.com/ykob/items/4ede3cb11684c8a403f8

計算式も、この通りです…

また、乱数を生成

fragment-shader.glsl
float r = rand(vUv) * 0.01;

float noiseR = snoise(vec3(vUv, uTime / 5000.0)) * 0.07 + r;
float noiseG = snoise(vec3(vUv * 0.9, uTime / 7000.0)) * 0.8 + r;
float noiseB = snoise(vec3(vUv * 0.2, uTime / 4500.0)) * 0.07 + r;

👉 ソース

float rは、すりガラスのような表現をするために使用します。
乱数生成の関数は ↓ こちらのこれまた神記事を参考にさせていただいております 🙇‍♂️

https://ics.media/entry/5535/

そして、float noiseR, float noiseG, float noiseBの乱数はテクスチャから色を抽出する座標を RGB それぞれで、ずらすための乱数です。

変化前のテクスチャの色と変化後のテクスチャの色を抽出

fragment-shader.glsl
float pr = texture2D(uTexturePrev, prevUv + noiseR).r;
float pg = texture2D(uTexturePrev, prevUv + noiseG).g;
float pb = texture2D(uTexturePrev, prevUv + noiseB).b;

float nr = texture2D(uTextureNext, nextUv + noiseR).r;
float ng = texture2D(uTextureNext, nextUv + noiseG).g;
float nb = texture2D(uTextureNext, nextUv + noiseB).b;

👉 ソース

float pr, float pg, float pbは変化前のテクスチャの RGB の色、float nr, float ng, float nbは変化後のテクスチャの RGB の色となります。
抽出する座標に乱数を加えているので、RGB それぞれ別の座標から色を抽出することになります。

色の決定

fragment-shader.glsl
float darkness = (tBezier * 2.0 + 1.0);

vec4 prevColor = vec4(pr * darkness * 0.3, pg * darkness * 0.06, pb * darkness * 0.3, 1.0);
vec4 nextColor = vec4(nr * darkness * 0.3, ng * darkness * 0.06, nb * darkness * 0.3, 1.0);

👉 ソース

float darknesstBezier0 → 1.0 + fNoise * 4.0 → 0と変化する際に1 → 1.0 + fNoise * 4.0 + 1 → 1と変化する変数です。
変化の中腹あたりで大きめの変化をもたらすためのものです。
そして、RGB それぞれの色に対して、この変数を掛けています。
更に、テクスチャ画像のままの色だと背景としては明るすぎるので R、B に0.3、G に0.06を掛けて、暗くして紫色っぽくなるようにしています。

色を変化させる

fragment-shader.glsl
gl_FragColor = mix(prevColor, nextColor, tProgress);

👉 ソース

最後にブラウザに表示される色を決定しています。
mixという組み込み関数を使用しています。mix(a, b, c)と 3 つの引数を取ります。
簡単に言うと、cが 0 から 1 へ変化すると結果がaからbへ変化するといったものです。
mix(prevColor, nextColor, tProgress)これはtProgressが 0 から 1 へ変化するとprevColorからnextColorへ変化するというものになります。

これで、shader 部分は全部です。
tProgressを変化させているのは JS からになるので、その部分を見てみましょう

再度 JS(TS)

実際にuProgressを変化させている部分を抜き出してみます。

ソース
main.ts
const nextImage = async ({ url, width, height }: ImageInfo): Promise<void> => {
  const nextImageEl = await loadImage(url)
  const {
    material: {
      uniforms: {
        uProgress,
        uTexturePrev,
        uTexturePrevResolution,
        uTextureNext,
        uTextureNextResolution,
      },
    },
  } = mesh

  const progress = {
    value: 0,
  }

  uTextureNext.value.image = nextImageEl
  uTextureNext.value.needsUpdate = true
  uTextureNextResolution.value = [width, height]

  Tween24.tween(progress, 2.5, Ease24._2_QuadInOut, { value: 1 })
    .onUpdate(() => {
      uProgress.value = progress.value
    })
    .onComplete(() => {
      uProgress.value = 0
      uTexturePrev.value.image = nextImageEl
      uTexturePrev.value.needsUpdate = true
      uTexturePrevResolution.value = [width, height]

      setTimeout(() => {
        const { length: imageLen } = imageInfos

        currentImageIndex = (currentImageIndex + 1) % imageLen

        nextImage(imageInfos[currentImageIndex])
      }, 1000)
    })
    .play()
}

ここでやっていることは以下になります

  1. 画像を読み込む
  2. 読み込んだ画像とサイズをuTextureNext,uTextureNextResolutionに代入
  3. uProgressをトゥイーンさせ、トゥイーンが終わったら、変化前のテクスチャとして使用していた変数に変化後のテクスチャの情報を代入

画像を読み込む

main.ts
const nextImageEl = await loadImage(url)

画像を読み込んでHTMLImageElementを返り値とするloadImageという関数を作って使用しています。

https://github.com/noliaki/portfolio-shader-background/blob/180a9c9c3c3416b841d195b8d83a334e7314b821/src/utils.ts#L18-L40

読み込んだ画像とサイズをuTextureNext,uTextureNextResolutionに代入

main.ts
uTextureNext.value.image = nextImageEl
uTextureNext.value.needsUpdate = true
uTextureNextResolution.value = [width, height]

uTextureNext.valueには初期値として空のnew Texture()を指定していました。
そのimageプロパティに読み込んだ画像を代入、そしてneedsUpdatetrueにすることで、テクスチャの更新をしています。

そして、画像サイズをuTextureNextResolution.valueに代入しています。

uProgressをトゥイーンさせ、トゥイーンが終わったら、変化前のテクスチャとして使用していた変数に変化後のテクスチャの情報を代入

ここで、冒頭でも紹介させていただいたTween24の登場です。

https://github.com/a24/tween24js

main.ts
const progress = {
  value: 0,
}

// ...

Tween24.tween(progress, 2.5, Ease24._2_QuadInOut, { value: 1 })
  .onUpdate(() => {
    uProgress.value = progress.value
  })
  .onComplete(() => {
    uProgress.value = 0
    uTexturePrev.value.image = nextImageEl
    uTexturePrev.value.needsUpdate = true
    uTexturePrevResolution.value = [width, height]

    setTimeout(() => {
      const { length: imageLen } = imageInfos

      currentImageIndex = (currentImageIndex + 1) % imageLen

      nextImage(imageInfos[currentImageIndex])
    }, 1000)
  })
  .play()

progressというオブジェクトを生成し、そのvalueプロパティの値を 0 から 1 へトゥイーンさせています。
onUpdateで、値が更新される度に、uProgress.valueにトゥイーン中の値を代入するようにしています。
onCompleteで、トゥイーンが完了したときにuProgress.valueの値を 0 に戻し、uTexturePrevuTexturePrevResolutionvalueプロパティをuTextureNext,uTextureNextResolutionと同じものにしています。

そして、setTimeoutで 1 秒後にまた同じように次の画像を読み込むようにしています。

ちなみにimageInfosは ↓ このようなオブジェクトが配列になっている感じです。

{
  url: string // 画像のURLパス
  width: number // 画像の横幅
  height: number // 画像の縦幅
}

ざっくりとではありますが、大まかにこんな感じで背景は成り立っています

使用させていただいた or 参考にさせていただいた記事

https://qiita.com/ykob/items/4ede3cb11684c8a403f8

https://ics.media/entry/5535/

あとがき

背景だけでも結構大変でしたが、頑張ってコンテンツ編も書こうと思います 🙀

Discussion