[背景編] ポートフォリオの解説
WebGL 総本山で、私の制作したポートフォリオを取り上げていただき、とても嬉しかったので その勢いのまま解説記事を Zenn 初投稿しようと思います。
このポートフォリオでお世話になっている技術
ホスティング
- vercel
主なフロントエンドの技術
- next.js@11.0.1
- three.js@0.131.1
- typescript@4.3.5
- tailwindcss@2.2.7
- gsap@3.7.1
正直、機能過多な気がしますが やりたかったのでやっちゃいました。
また、今回この背景解説用に背景だけを抜き出したものも作りました。
こちらは、Viteを使用し、Tween ライブラリにはTween24.js
を使用させていただきました。
shader はポートフォリオで使用したものと全く同じものを使用しています。
JS(TS)
JS 側はthree.js
を使用しています。
そこで使用している主なものをピックアップします。
🎑 背景のオブジェクト
背景は四角の矩形のPlaneBufferGeometry
をブラウザサイズいっぱいに配置してるだけのシンプルなものです。
背景はそこまで頂点自体を操作することは無く、テクスチャを貼り付けて その色を変更したり、テクスチャを切り替えたりするだけになるので、頂点の数は最小で問題無く、widthSegments
、heightSegments
は、それぞれ 1
を指定しています。
ソースコード
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,
},
},
})
)
material
はShaderMaterial
で、vertexShader
、fragmentShader
、そして 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
を使用しています。
ソースコード
const camera = new OrthographicCamera(
-winWidth / 2,
winWidth / 2,
winHeight / 2,
-winHeight / 2
)
******
これ以外は、WebGLRenderer
やScene
などを初期化して、Scene
に背景のオブジェクトを追加し、requestAnimationFrame
を使用して、毎フレーム毎にuniform
変数: uTime
に+1
してレンダリングしてるだけとなります
ソースコード
const update = () => {
const {
material: {
uniforms: { uTime },
},
} = mesh
uTime.value += 1
renderer.render(scene, camera)
requestAnimationFrame(() => {
update()
})
}
背景で使用している画像
背景で使用している画像は contentful という、CMS で管理をしています。
ここから GraphQL Content API を利用して画像の URL やサイズを取得しています。
具体的な取得方法についてはここでは割愛させていただきます 🙇♂️
ソースコードだけ貼っておきます。
shader
続いて、shader です。
vertex shader も fragment shader も perlin noise
というアルゴリズムを利用しています。
これは乱数を生成するものなのですが、引数が近似していれば生成される乱数も近似した値になるものです。
引数の値を少し変えれば、少し変化した乱数を得ることができます。
その乱数生成の関数は ↓ こちらを使用させていただいております。
vertex shader
vertex shader は 以下の処理をしています。
- varying 変数
vUv
にPlaneBufferGeometry
で定義されているuv
変数を代入 -
PlaneBufferGeometry
で定義されているposition
の x 軸、y 軸の値を 0 から 1 へ変換 - 変換した x 軸・y 軸の値と、時間経過を表す
uTime
変数を引数に乱数を生成 - 変換した x 軸・y 軸の値から変化する遅延時間を算出
- x 軸、y 軸の位置を決定
vertex shader では主にテクスチャを入れ替える際、つまり uProgress
変数が 0 から 1 へ変化する際に頂点の x 軸と y 軸の位置に変化をもたらしています。
一つずつ解説します。
vUv
にPlaneBufferGeometry
で定義されているuv
変数を代入
varying 変数vUv = uv;
uv
の座標は fragment shader でも使用したいので、 vUv
という varying 変数に代入しています。
three.js
を使用した場合、vertex shader や fragment shader に含まれる変数は以下を参照ください
今回の vertex shader では以下の変数を使用しています
uniform mat4 projectionMatrix
uniform mat4 modelViewMatrix
attribute vec3 position
attribute vec2 uv
PlaneBufferGeometry
で定義されているposition
の x 軸、y 軸の値を 0 から 1 へ変換
vec2 p = vec2(
((position.x / (uResolution.x * 0.5)) + 1.0) * 0.5,
((position.y / (uResolution.y * 0.5)) + 1.0) * 0.5
);
position
はPlaneBufferGeometry
で定義されます。
x
・y
・z
のプロパティを持つ、頂点の座標を表します。
PlaneBufferGeometry
はシンプルな四角の矩形の形状です。左上、右上、左下、右下の頂点の座標を持ちます。
PlaneBufferGeometry
の基準点は矩形の中心になります。
x 軸は左から右へ値が大きくなります。
y 軸は下から上へ値が大きくなります。
例えば、ブラウザサイズが横 1000px、縦 600px だった場合、左上、右上、左下、右下のそれぞれの頂点の座標(x, y, z
)は
左上: (-500, 300, 0)
右上: (500, 300, 0)
左下: (-500, -300, 0)
右下: (500, -300, 0)
となります。
まず、x 軸の計算だけを見てみます。
↓ この部分
((position.x / (uResolution.x * 0.5)) + 1.0) * 0.5
uResolution
はブラウザのサイズを JS から指定しています。
さらに ↓ この部分
position.x / (uResolution.x * 0.5)
これは、position
のx
座標をブラウザの横幅の半分(uResolution.x * 0.5
)で割ることで、-1 ~ 1
までの値へ変換しています。
そして、この値に +1
することで
- position.x / (uResolution.x * 0.5)
+ (position.x / (uResolution.x * 0.5)) + 1.0
0 ~ 2
の値へ変換しています。
さらに 0.5
を掛けることで
- (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
として計算することが可能となります。
((position.y / (uResolution.y * 0.5)) + 1.0) * 0.5
こうすることで、vec2 p
変数は p.x
、p.y
ともに 0 ~ 1
の値が代入されることになります。
uTime
変数を引数に乱数を生成
変換した x 軸・y 軸の値と、時間経過を表すfloat noise = snoise(vec3(p, uTime * 0.0003));
こちらは前述した perlin noise
を利用して乱数を生成しています。
生成方法はsnoise
という関数を利用します。
この関数は、vec3
つまり 3 つのプロパティをもった値を引数として乱数を生成します。
先程算出した 0 から 1 までの値が代入される vec2 p
と 時間経過を表すuTime
変数をプロパティとしています。
uTime
をそのまま使用すると変化が大きいので、0.0003
を掛けて小さい値にしています。(この0.0003
はアナログ的に、このくらいかなーって感じで決めました 🤪)
これで隣り合う座標の値も近似していることから、乱数も近似した値を得ることができます。
また、時間経過毎にも近似した乱数を得ることが可能となっています。
これにより、滑らかだけど 不規則な状態にすることが可能となります。
ここで得られる乱数は -1 ~ 1
の範囲の乱数になります。
変換した x 軸・y 軸の値から変化する遅延時間を算出
float delay = ((p.x + p.y) * 0.5) * maxDelay;
float tProgress = clamp(uProgress - delay, 0.0, duration) / duration;
ここは何をやっているかと言うと、座標位置によって変化の開始遅延時間を算出しています。
左下 → 右上へ変化の開始時間を遅らせています。
まず、↓ この部分
float delay = ((p.x + p.y) * 0.5) * maxDelay;
maxDelay
は定数として0.6
としています。
👉 ソース
先程算出した座標位置を 0 から 1 へ変換したvec2 p
を使っています。
vec2 p
のx
はブラウザの左端が 0、右端が 1、y
はブラウザの下端が0
、上端が1
になっているので、((p.x + p.y) * 0.5)
はブラウザの左下から右上に向かって 0 から 1 へ変化します。
これにmaxDelay
(0.6
)を掛けているので、この結果は左下から右上に向かって0 ~ 0.6
の数値を得ることが出来ます。
次に ↓ この部分
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
に注目してみます。
uProgress
が0 ~ 1
へと変化します。
delay
は座標位置によって0 ~ 0.6
の値となります。
これは、uProgress
がdelay
よりも大きな値にならないと、clamp(uProgress - delay, 0.0, duration)
は0
よりも大きな値にならないということです。
結果として、float tProgress
も0
よりも大きな値になりません。
float tProgress
が0
よりも値が大きくならないと、それ以降の計算で変化を加えられないようにしています。
delay
は座標の位置、左下から右上に向かって0 ~ 0.6
の値となるので、座標が右上にあるほど、uProgress
が0 ~ 1
へと変化するときに、0
よりも大きな値になるのが遅延する、という計算をしています。
x 軸、y 軸の位置を決定
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);
最後に位置を決定します。
まずは ↓ こちら
float tBezier = cubicBezier(1.0, ((noise + 1.0) * 0.5) + 1.0, 1.0, tProgress);
cubicBezier
という、こちらは独自に記述した関数を使用しています。
ベジェ曲線のやつです。
cubicBezier(a, b, c, d)
という感じで、引数を 4 つとります。
d
が0 ~ 1
へ変化すると、a → b → c
へと いい感じに値を変化してくれるものです。
つまり、↓ これは先程算出したtProgress
が0 ~ 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
noise
はvec2 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 です
fragment shader は 以下の処理をしています。
-
vUv
とuTime
から乱数を生成 -
vUv
からアニメーションの開始遅延時間を算出 -
uResolution
と テクスチャの画像のサイズからブラウザサイズいっぱいに表示させるテクスチャの座標を算出 - また、乱数を生成
- 変化前のテクスチャの色と変化後のテクスチャの色を抽出
- 色の決定
- 色を変化させる
fragment shader では テクスチャの画像から RGB の値を微妙にずらして抽出して、uProgress 変数が 0 から 1 へ変化する際に、変化前の色と変化後の色の決定をしています。
vUv
と uTime
から乱数を生成
float noise = snoise(vec3(vUv, uTime / 12000.0));
float fNoise = ((noise + 1.0) * 0.5);
vertex shader でも使用した、perlin noise
を使って、乱数を生成しています。
今回引数に使用したのはvUv
の値とuTime
の値です。
vertex shader ではuTime
に0.0003
を掛けていましたが、fragment shader では 12000.0
で割っています。
このへんの統一感の無さは私のズボラな性格を表現しています 🙇♂️
uTime
の値が十分に小さければ、どちらでも OK だと思います。
float noise
は -1 ~ 1
の値が生成されます。0 ~ 1
の乱数も使いたかったので、float fNoise
という変数で0 ~ 1
の値を算出しています。
vUv
からアニメーションの開始遅延時間を算出
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 tBezier
は tProgress
が 0 から 1 へと変化する際、0 → 1.0 + fNoise * 4.0 → 0
と値が変化します。
uResolution
と テクスチャの画像のサイズからブラウザサイズいっぱいに表示させるテクスチャの座標を算出
float stagger = (tBezier * noise + 1.0);
vec2 prevUv = imageUv(uResolution, uTexturePrevResolution, vUv) * stagger;
vec2 nextUv = imageUv(uResolution, uTextureNextResolution, vUv) * stagger;
float stagger
は算出する uv 座標をずらすための乱数です。
そして、肝心のブラウザサイズいっぱいに表示させるための uv 座標を算出する計算方法は、完全に ↓ こちらの神記事を参考にさせていただきました 🙇♂️
計算式も、この通りです…
また、乱数を生成
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
は、すりガラスのような表現をするために使用します。
乱数生成の関数は ↓ こちらのこれまた神記事を参考にさせていただいております 🙇♂️
そして、float noiseR
, float noiseG
, float noiseB
の乱数はテクスチャから色を抽出する座標を RGB それぞれで、ずらすための乱数です。
変化前のテクスチャの色と変化後のテクスチャの色を抽出
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 それぞれ別の座標から色を抽出することになります。
色の決定
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 darkness
はtBezier
が0 → 1.0 + fNoise * 4.0 → 0
と変化する際に1 → 1.0 + fNoise * 4.0 + 1 → 1
と変化する変数です。
変化の中腹あたりで大きめの変化をもたらすためのものです。
そして、RGB それぞれの色に対して、この変数を掛けています。
更に、テクスチャ画像のままの色だと背景としては明るすぎるので R、B に0.3
、G に0.06
を掛けて、暗くして紫色っぽくなるようにしています。
色を変化させる
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
を変化させている部分を抜き出してみます。
ソース
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()
}
ここでやっていることは以下になります
- 画像を読み込む
- 読み込んだ画像とサイズを
uTextureNext
,uTextureNextResolution
に代入 -
uProgress
をトゥイーンさせ、トゥイーンが終わったら、変化前のテクスチャとして使用していた変数に変化後のテクスチャの情報を代入
画像を読み込む
const nextImageEl = await loadImage(url)
画像を読み込んでHTMLImageElement
を返り値とするloadImage
という関数を作って使用しています。
uTextureNext
,uTextureNextResolution
に代入
読み込んだ画像とサイズをuTextureNext.value.image = nextImageEl
uTextureNext.value.needsUpdate = true
uTextureNextResolution.value = [width, height]
uTextureNext.value
には初期値として空のnew Texture()
を指定していました。
そのimage
プロパティに読み込んだ画像を代入、そしてneedsUpdate
をtrue
にすることで、テクスチャの更新をしています。
そして、画像サイズをuTextureNextResolution.value
に代入しています。
uProgress
をトゥイーンさせ、トゥイーンが終わったら、変化前のテクスチャとして使用していた変数に変化後のテクスチャの情報を代入
ここで、冒頭でも紹介させていただいたTween24
の登場です。
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 に戻し、uTexturePrev
、uTexturePrevResolution
のvalue
プロパティをuTextureNext
,uTextureNextResolution
と同じものにしています。
そして、setTimeout
で 1 秒後にまた同じように次の画像を読み込むようにしています。
ちなみにimageInfos
は ↓ このようなオブジェクトが配列になっている感じです。
{
url: string // 画像のURLパス
width: number // 画像の横幅
height: number // 画像の縦幅
}
ざっくりとではありますが、大まかにこんな感じで背景は成り立っています
使用させていただいた or 参考にさせていただいた記事
あとがき
背景だけでも結構大変でしたが、頑張ってコンテンツ編も書こうと思います 🙀
Discussion