[背景編] ポートフォリオの解説
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 軸の位置に変化をもたらしています。
一つずつ解説します。
varying 変数vUvにPlaneBufferGeometryで定義されているuv変数を代入
vUv = uv;
uvの座標は fragment shader でも使用したいので、 vUvという varying 変数に代入しています。
three.jsを使用した場合、vertex shader や fragment shader に含まれる変数は以下を参照ください
今回の vertex shader では以下の変数を使用しています
uniform mat4 projectionMatrixuniform mat4 modelViewMatrixattribute vec3 positionattribute 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の値が代入されることになります。
変換した x 軸・y 軸の値と、時間経過を表すuTime変数を引数に乱数を生成
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