LoginSignup
2
2

three.jsとGSAPで実現した、インタラクティブな名言集

Last updated at Posted at 2023-12-28

画像のピクセルデータをパーティクルとして3D空間に配置し、シェーダーを用いて表現力豊かなグラフィックを実現しています。

スクリーンショット 2023-12-28 18.55.26.png

このウェブサイトは、three.jsによる3Dグラフィックと、GSAPによるアニメーション、SplitTextによるテキストの分割表示を組み合わせて、美しい名言集を作成しています。

HTMLでは、three.jsのcanvas、名言テキスト、ボタンなどの要素を配置しています。CSSでは、コンテンツを中央に配置し、アニメーションしやすい基本的なレイアウトを構築しています。JavaScriptでは、three.jsでグラフィックを描画し、GSAPでアニメーションを制御し、SplitTextでテキストを分割表示しています。

使用した技術

  • three.jsによる3Dグラフィックの実装
  • GSAPによるアニメーション制御
  • SplitTextによるテキストの分割表示

制作意図

  • three.jsの可能性を探ること
  • アニメーションとインタラクションの組み合わせ方を学ぶこと
  • 視覚的に美しいデザインを実現すること

こだわりポイント

  • シェーダーを使ったパーティクルの表現
  • マウスインタラクションによるパーティクルの動き
  • 名言とアニメーションの調和

以上のように、使用技術、制作意図、こだわりポイントを簡潔にまとめられると思います。グラフィックと動きの表現力、インタラクティブ性に注力していることが伝わる文章になっていると思います。ポートフォリオとしての訴求力が高まるような形で技術をアピールできる内容だと思います。

HTMLの解説

<!DOCTYPE html>
<html lang="ja" >
<head>
  <meta charset="UTF-8">
  <title>名言集 - prodouga.com</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="preconnect" href="https://fonts.gstatic.com">
    <link href="https://fonts.googleapis.com/css2?family=Comfortaa:wght@400;700&display=swap" rel="stylesheet"><link rel="stylesheet" href="./style.css">

</head>
<body>
<!-- partial:index.partial.html -->
<div id="wrapper">

    <div id="canvas_container"></div>

    <div class="text">
        <p class="quoteText">物語は<span style="font-style: italic; font-size: 1.4rem; color:#ED760D">ここ</span>から始まるのだ<br><small>手塚治虫</small></p>
    </div>
    <p class="clickInfo">クリックすると物語が始まります</p>

    <img id="first" src="https://i.imgur.com/TwYg3BB.jpg" style="display:none;">

    <a href="https://prodouga.com/contact" target="_blank" class="btn btn_works">お問い合わせ</a>
    <button class="btn" id="fullscr">フルスクリーン</button>

</div>
<!-- partial -->
<script src='https://cdnjs.cloudflare.com/ajax/libs/three.js/r125/three.min.js'></script>
<script src='https://unpkg.co/gsap@3/dist/gsap.min.js'></script>
<script src='https://assets.codepen.io/16327/SplitText3.min.js'></script><script  src="./script.js"></script>

</body>
</html>

このHTMLは、名言集のウェブサイトのマークアップを定義しています。

主な内容:

  • headにサイトのタイトル、フォント、CSSファイルを読み込んでいる

  • body内にメインの#wrapper divを定義

  • #canvas_containerはthree.jsのキャンバス用

  • .textは名言を表示するdiv

  • .clickInfoはクリックを促すテキスト

  • #firstは初期テクスチャの画像

  • .btnがフルスクリーンボタンなど

  • scriptでthree.js、GSAP、SplitTextのJSライブラリを読み込み

  • script.jsにアプリのメインLOGICを記述

このHTMLで、three.jsによるグラフィックと、名言テキスト、ボタンなどの要素が配置されています。
CSSとJavaScriptで動作を定義する基本的な構成になっています。

three.jsのcanvas、テキスト、ボタンなどのdivが適切に配置されており、シンプルでわかりやすいマークアップになっています。

CSSの解説

* {
  margin: 0;
  padding: 0;
}

body {
  overflow: hidden;
}

#wrapper {
  position: relative;
  height: 100vh;
  background: #000;
  font-family: 'Comfortaa', serif;
}

#canvas_container {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100vh;
  z-index: 0;
}

canvas {
  display: block;
}

.text {
  width: 100%;
  height: 100px;
  text-align: center;
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: 10;
  cursor: pointer;
  color: rgb(255, 255, 255);
  background-color: rgba(0, 0, 0, 0.81);
  font-size: 1.6rem;
  line-height: 1.5;
  user-select: none;
  overflow: hidden;
  display: flex;
  align-items:center;
}

.text span {
  color: #e7aa27;
}

.text .quoteText {
  width: 85%;
  margin: 0 auto;
}

.text .quoteText span {
  font-weight: 700;
}

.clickInfo {
  width: 100%;
  text-align: center;
  position: absolute;
  left: 0;
  top: -80px;
  z-index: 11;
  cursor: pointer;
  color: #F9B31C;
  background-color: rgba(23, 23, 23, 0.9);
  padding: 10px 0;
  font-size: 20px;
}

.btn {
  position: absolute;
  bottom: 5%;
  right: 0px;
  transform: translateX(-50%);
  border: 1px solid white;
  border-radius: 5px;
  font-size: 0.9rem;
  padding: 0.5rem 0.7em;
  background: transparent;
  color: #F9B31C;
  -webkit-font-smoothing: antialiased;
  font-weight: 700;
  cursor: pointer;
  transition: all .3s;
  z-index: 11;
}

.btn_works {
  left: 100px;
  right: unset;
  text-decoration: none;
}

.btn:hover {
  background: #ffffff;
  color: #2a2b2f;
}

@media only screen and (max-width:815px) {
  .text {
    font-size: 1.2rem;
  }

  .text span {
    afont-size: 0.8rem !important;
  }

  .clickInfo {
    font-size: 1rem;
  }
}

主なポイント:

  • *でマージン・パディングをリセットしている

  • bodyoverflow: hiddenにしてコンテンツがはみ出ないようにしている

  • #wrapperがメインのコンテナー。position: relativeにして子要素の基準位置を設定できるようにする

  • #canvas_containerがThree.jsのキャンバスを置く領域。position: absoluteで親要素の中に配置。

  • .textが名言を表示するブロック。position: absoluteで中央に配置し、スタイル設定。

  • .clickInfoがクリックアドバイスのテキスト。初期状態では非表示にしている。

  • .btnがフルスクリーンボタンなどのスタイル定義。

  • @mediaクエリでスマホ版のフォントサイズを調整。

このCSSにより、コンテンツを中央に配置し、アニメーションしやすい基本的なレイアウトが構築されています。
three.jsの描画領域と、テキスト、ボタンなどの要素が適切に配置され、スタイル設定されています。

JavaScriptの解説

gsap.registerPlugin(SplitText);

let webgl = {};
let tail = {};

(function initThree() {
    webgl.container = document.getElementById("canvas_container");
    webgl.quoteText = document.querySelector(".quoteText");
    webgl.text = document.querySelector(".text");

    webgl.scene = new THREE.Scene();
    webgl.camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 10000);
    webgl.camera.position.z = 180;
    webgl.renderer = new THREE.WebGLRenderer({ alpha: true });  //, antialias: true
    webgl.renderer.setSize(webgl.container.clientWidth, webgl.container.clientHeight);
    webgl.renderer.setPixelRatio(window.devicePixelRatio);
    webgl.container.appendChild(webgl.renderer.domElement);

    webgl.loader = new THREE.TextureLoader();
    webgl.clock = new THREE.Clock(true);
    webgl.loader.crossOrigin = '';

    webgl.textureIndex = 1;
    webgl.threshold = 30;
    webgl.lastClick = 0;
    tail.on = false;

    webgl.texture = webgl.loader.load(document.getElementById("first").src, setup);

    Promise.all([
        webgl.texture,                               
        webgl.loader.load('https://i.ibb.co/4V78z5k/1.jpg'),
        webgl.loader.load('https://i.ibb.co/Z6X88jp/2.jpg'),
        loadAsync("https://i.imgur.com/9ARnFpN.mp4"),
        webgl.loader.load('https://i.ibb.co/HKvbKt4/3.jpg'),
        webgl.loader.load('https://i.ibb.co/gZQ9k2m/4.jpg'),
        webgl.loader.load('https://i.ibb.co/DR3Qyw7/5.jpg'),
        webgl.loader.load('https://i.ibb.co/rvsqP8p/6.jpg'),
        webgl.loader.load('https://i.ibb.co/6vc30x1/7.jpg'),
        webgl.loader.load('https://i.ibb.co/PwjPx6m/8.jpg'),
        webgl.loader.load('https://i.ibb.co/nLYQ4JX/9.jpg'),
        webgl.loader.load('https://i.ibb.co/6B0C9Gs/10.jpg'),
        webgl.loader.load('https://i.ibb.co/HpGg7DL/11.jpg'),
        loadAsync("https://i.imgur.com/PLucJrT.mp4"),
        webgl.loader.load('https://i.ibb.co/5WyBXgK/12.jpg'),
        webgl.loader.load('https://i.ibb.co/sVd7vzP/13.jpg'),
        loadAsync("https://i.imgur.com/1qtxhDP.mp4"),
        webgl.loader.load('https://i.ibb.co/F4S8fmg/14.jpg'),
        loadAsync("https://i.imgur.com/KWZrhwI.mp4"), 
        loadAsync("https://i.imgur.com/Fls1ycS.mp4"),
        loadAsync("https://i.imgur.com/z5rxRyR.mp4"),
        webgl.loader.load('https://i.ibb.co/wYcDqBq/15.jpg'),
        loadAsync("https://i.imgur.com/uRGT8YT.mp4"),
        webgl.loader.load('https://i.ibb.co/Yb3V64c/16.jpg'),
        webgl.loader.load('https://i.ibb.co/4KLVnmM/17.jpg'),
        webgl.loader.load('https://i.ibb.co/XkqQRnG/18.jpg'),
        webgl.loader.load('https://i.ibb.co/cThDKjG/19.jpg'),
        loadAsync("https://i.imgur.com/GYnydpJ.mp4"),
        loadAsync("https://i.imgur.com/qdUDoDi.mp4"),
        loadAsync("https://i.imgur.com/xfMqL6d.mp4"),
        webgl.loader.load('https://i.ibb.co/s3144Qp/20.jpg'),
        loadAsync("https://i.imgur.com/SwigSgy.mp4"),
        loadAsync("https://i.imgur.com/fblYDoP.mp4"),
        loadAsync("https://i.imgur.com/548iVXp.mp4"),  
    ]).then(result => {
        webgl.texturesArray = result;
        document.getElementById("wrapper").addEventListener("click", changeTexture, false);
    });

    async function loadAsync(url) {
        let video = document.createElement("video");
        video.muted = true;
        video.loop = true;
        video.playsinline = true;
        video.crossOrigin = "anonymous";
        video.src = url;
        return new THREE.VideoTexture(video);
    }
       
    webgl.texturesOptions = [
        { index: 0, texture: "image", quote: '遅すぎる<span style="color:#666666">決断</span>というのは、<em>決断</em>をしないに等しい<br><small>孫正義</small>', threshold: 20, random: 4.0, depth: 30.0, size: 1.7, square: 0 },
        { index: 1, texture: "image", quote: '芸術は長く、<span style="color:#f4852a">人生</span>は短し<br><small>ヒポクラテス</small>', threshold: 100, random: 3.0, depth: 4.0, size: 2.0, square: 0 },
        { index: 2, texture: "image", quote: '何事も、成し遂げるまではいつも<span style="color:#3ac5f8">不可能</span>に思える<br><small>ネルソン・マンデラ</small>', threshold: 10, random: 1.0, depth: 2.0, size: 1.2, square: 0},
        { index: 3, texture: "video", quote: '努力なくして<span style="color:#a87171; font-style: italic;">力</span>なし<br><small>オプラ・ウィンフリー</small>', threshold: 100, random: 2.0, depth: 2.0, maxDepth: 60, size: 1.5, square: 0 },
        { index: 4, texture: "image", quote: '平和は<span style="color:#f81b1b">微笑み</span>から始まります<br><small>マザー・テレサ</small>', threshold: 80, random: 1.0, depth: 4.0, maxDepth:120, size: 1.5, square: 0 },
        { index: 5, texture: "image", quote: '夢見ることができれば、それは<span style="color:#c89c5b">実現</span>できる<br><small>ウォルト・ディズニー</small>', threshold: 30, random: 2.0, depth: 4.0, size: 1.5, square: 0 },
        { index: 6, texture: "image", quote: 'すべては<span style="color:#f14646">練習</span>のなかにある<br><small>ペレ</small>', threshold: 30, random: 2.0, depth: 3.0, size: 1.0, square: 1 },
        { index: 7, texture: "image", quote: 'いつやるか?<span style="color:#e7aa27">今</span>でしょ!<br><small>林修</small>', threshold: 60, random: 0.8, depth: 4.0, size: 0.6, square: 1 },
        { index: 8, texture: "image", quote: '何事も<span style="color:#f4852a">実現</span>するまでが一番楽しい<br><small>ジョージ・エリオット</small>', threshold: 20, random: 2.0, depth: 4.0, size: 1.5, square: 0 },
        { index: 9, texture: "image", quote: '天才とは、1%の<span style="color:#C32B59">ひらめき</span>と99%の努力である<br><small>トーマス・エジソン</small>', threshold: 20, random: 2.0, depth: 8.0, size: 1.5, square: 0, a2: true },
        { index: 10, texture: "image", quote: '<span style="color:#eeb9b9">安定</span>は<span style="color:#e89191">愛を殺し、</span><span style="color:#e76e6e">不安</span>は<span style="color:#e33131">愛をかきたてる</span><br><small>マルセル・プルースト</small>', threshold: 30, random: 2.0, depth: 30.0, maxDepth: 100, size: 1.5, square: 0, a3: true, a4: true, stagger: 0.3 },  //72
        { index: 11, texture: "image", quote: '虹を見たかったら、<span style="font-style: italic; color:#f91a1a">雨</span>も我慢しなくちゃね<br><small>ドリー・パートン</small>', threshold: 60, random: 2.0, depth: 2.0, maxDepth: 80, size: 1.5, square: 0, color: "transparent", stagger: 1 },
        { index: 12, texture: "image", quote: '愛に触れると誰でも<span style="color:#f3ee62">詩人</span>になる<br><small>プラトン</small>', threshold: 60, random: 2.0, depth: 4.0, maxDepth: 60, size: 1.5, square: 0 },
        { index: 13, texture: "video", quote: 'あなたの<span style="color:#f91a1a">傷</span>を<span style="font-style: italic; color:#e89191">知恵</span>に変えなさい<br><small>オプラ・ウィンフリー</small>', threshold: 60, random: 2.0, depth: 15.0, maxDepth: 120, size: 2.0, square: 0, stagger: 1 },
        { index: 14, texture: "image", quote: '私達の財産、それは私達の<span style="font-style: italic; color:#c8664c">頭の中</span>にある<br><small>モーツァルト</small>', threshold: 20, random: 2.0, depth: 4.0, maxDepth:180, size: 1.5, square: 0 },
        { index: 15, texture: "image", quote: '知識は束縛からの<span style="color:#b3be1e">解放</span>であり...', threshold: 0, random: 2.0, depth: 3.0, maxDepth: 100, size: 1.5, square: 0, a5: true },
        { index: 16, texture: "video", quote: '無知は奴隷だ<br><small>マイルス・デイヴィス</small>', threshold: 100, random: 4.0, depth: 20.0, maxDepth: 100, size: 1.5, square: 0, a4: true },
        { index: 17, texture: "image", quote: '結婚するやつは<span style="color:#f17946">馬鹿</span>だ...', threshold: 60, random: 2.0, depth: 40.0, maxDepth: -100, size: 1.5, square: 0, a6: true },
        { index: 18, texture: "video", quote: 'しないやつはもっと<span style="color:#259be9">馬鹿</span>だ<br><small>バーナード・ショー</small>', threshold: 30, random: 2.0, depth: 20.0, maxDepth: 80, size: 1.5, square: 0 },
        { index: 19, texture: "video", quote: '環境より学ぶ<span style="color:#b3be1e">意志</span>があればいい<br><small>津田梅子</small>', threshold: 30, random: 2.0, depth: 30.0, maxDepth: 100, size: 1.5, square: 0, a8:true },
        { index: 20, texture: "video", quote: '困難の中に、<span style="color:#fac370">機会</span>がある<br><small>アインシュタイン</small>', threshold: 100, random: 2.0, depth: 10.0, size: 1.5, square: 0 },
        { index: 21, texture: "image", quote: '<span style="color:#979f28">希望</span>を生かし続けなさい<br><small>ジェシー・ジャクソン</small>', threshold: 30, random: 1.0, depth: 4.0, size: 1.5, square: 0 },
        { index: 22, texture: "video", quote: '旅の<span style="font-style: italic; color:#3bbbd8">過程</span>にこそ価値がある<small>スティーブ・ジョブズ</small>', threshold: 30, random: 2.0, depth: 30.0, maxDepth: -50, size: 1.5, square: 0 },
        { index: 23, texture: "image", quote: "夢なき者に<span style='color:#f2f637'>成功</span>なし<small>吉田松陰</small>", threshold: 30, random: 2.0, depth: 4.0, size: 1.5, square: 0 },
        { index: 24, texture: "image", quote: 'あきらめたらそこで<span style="color:#ea1f66">試合終了</span>ですよ…?<small>安西先生</small>', threshold: 10, random: 2.0, depth: 4.0, size: 1.5, square: 0 },
        { index: 25, texture: "image", quote: 'やれることは、<span style="color:#ec1713">すべて</span>やる<br><small>イチロー</small>', threshold: 20, random: 2.0, depth: 4.0, size: 1.5, square: 0, a1: true },
        { index: 26, texture: "image", quote: '<span style="color:#D873EE">前進</span>をしない人は、<span style="color:#1BACBF">後退</span>をしているのだ<br><small>ゲーテ</small>', threshold: 20, random: 2.0, depth: 4.0, maxDepth:150, size: 1.5, square: 0, a10:true },
        { index: 27, texture: "video", quote: '可能性を超えたものが、人の心に残る<small>アインシュタイン</small>', threshold: 30, random: 2.0, depth: 15.0, maxDepth: 50, size: 1.5, square: 0, a6:true },
        { index: 28, texture: "video", quote: '笑いのない日、それは人生の<span id="paint_only" style="color:#D234EB">無駄</span>な日である<br><small>アインシュタイン</small>', threshold: 60, random: 2.0, depth: 18.0, size: 1.5, square: 0, a9:true },
        { index: 29, texture: "video", quote: '歩け、歩け。<span style="color:#f1ee46">続ける事</span>の大切さ<br><small>伊能忠敬</small>', threshold: 150, random: 2.0, depth: 20.0, maxDepth: 70, bg: '#000', size: 1.5, square: 0 },
        { index: 30, texture: "image", quote: '<span style="color:#ea1f66">プロ</span>はいかなる時でも、言い訳をしない<br><small>千代の富士</small>', threshold: 60, random: 2.0, depth: 4.0, size: 1.5, square: 0, a4:true, a1:true },
        { index: 31, texture: "video", quote: '<span style="color:#f6a62c">才能</span>とは、 情熱を<span style="color:#f6a62c">持続</span>させる能力のこと<br><small>アインシュタイン</small>', threshold: 100, random: 2.0, depth: 10.0, maxDepth: 60, size: 1.5, square: 0, a1: true },
        { index: 32, texture: "video", quote: '明日死んでもいい<span style="color:#f91a1a">今日</span>を、いくつ創れるか<br><small>長渕剛</small>', threshold: 160, random: 2.0, depth: 3.0, size: 1.5, square: 0 },
        { index: 33, texture: "video", quote: '商売とは、<span style="color:#f4e25d">感動</span>を与えることである<br><small>松下幸之助</small>', threshold: 60, random: 2.0, depth: 5.0, maxDepth: 40.0, size: 1.5, square: 0 },
    ];
})();


function setup() {
    pixelExtraction();
    initParticles();
    initTail();
    initRaycaster();

    window.addEventListener("resize", () => {
        clearTimeout(webgl.timeout_Debounce);
        webgl.timeout_Debounce = setTimeout(resize, 50);
    });
    resize();

    webgl.scene.add(webgl.particlesMesh);

    webgl.firstAnimation1 = gsap.to(webgl.particlesMesh.rotation, 30, { z: 2, repeat: -1, yoyo: true });
    webgl.firstAnimation2 = gsap.fromTo(webgl.particlesMesh.material.uniforms.uDepth, 2, { value: 30 }, { value: 45.0, ease: "elastic.in(1, 0.3)", delay: 2 });

    webgl.timeoutClickInfo = window.setTimeout(() => {
        if (webgl.textureIndex == 1) gsap.to(document.querySelector(".clickInfo"), 1.5, { top: 0, ease: "power4.out" });
        else return;
    }, 4000);

    animate();
}


function pixelExtraction() {
    webgl.width = webgl.texture.image.width;
    webgl.height = webgl.texture.image.height;
    webgl.totalPoints = webgl.width * webgl.height;
    webgl.visiblePoints = 0;
    webgl.threshold = webgl.texturesOptions[webgl.textureIndex].threshold;

    const img = webgl.texture.image;
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    canvas.width = webgl.width;
    canvas.height = webgl.height;
    ctx.scale(1, -1);
    ctx.drawImage(img, 0, 0, webgl.width, webgl.height * -1);
    const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    webgl.arrayOfColors = Float32Array.from(imgData.data);
    for (let i = 0; i < webgl.totalPoints; i++) {
        if (webgl.arrayOfColors[i * 4 + 0] > webgl.threshold) webgl.visiblePoints++;
    }
}


function initParticles() {
    webgl.geometryParticles = new THREE.InstancedBufferGeometry();

    const positions = new THREE.BufferAttribute(new Float32Array(4 * 3), 3);
    positions.setXYZ(0, -0.5, 0.5, 0.0);
    positions.setXYZ(1, 0.5, 0.5, 0.0);
    positions.setXYZ(2, -0.5, -0.5, 0.0);
    positions.setXYZ(3, 0.5, -0.5, 0.0);
    webgl.geometryParticles.setAttribute('position', positions);

    const uvs = new THREE.BufferAttribute(new Float32Array(4 * 2), 2);
    uvs.setXYZ(0, 0.0, 0.0);
    uvs.setXYZ(1, 1.0, 0.0);
    uvs.setXYZ(2, 0.0, 1.0);
    uvs.setXYZ(3, 1.0, 1.0);
    webgl.geometryParticles.setAttribute('uv', uvs);

    webgl.geometryParticles.setIndex(new THREE.BufferAttribute(new Uint16Array([0, 2, 1, 2, 3, 1]), 1));

    const offsets = new Float32Array(webgl.totalPoints * 3); 
    const indices = new Uint16Array(webgl.totalPoints);
    const angles = new Float32Array(webgl.totalPoints);
    for (let i = 0, j = 0; i < webgl.totalPoints; i++) {
        if (webgl.arrayOfColors[i * 4 + 0] <= webgl.threshold) continue;
        offsets[j * 3 + 0] = i % webgl.width;
        offsets[j * 3 + 1] = Math.floor(i / webgl.width);
        indices[j] = i;
        angles[j] = Math.random() * Math.PI;
        j++;
    }

    webgl.geometryParticles.setAttribute('offset', new THREE.InstancedBufferAttribute(offsets, 3, false));
    webgl.geometryParticles.setAttribute('angle', new THREE.InstancedBufferAttribute(angles, 1, false));
    webgl.geometryParticles.setAttribute('pindex', new THREE.InstancedBufferAttribute(indices, 1, false));

    const uniforms = {
        uTime: { value: 0 },
        uRandom: { value: 3.0 },
        uDepth: { value: 30.0 },
        uSize: { value: 1.5 },    
        uTextureSize: { value: new THREE.Vector2(webgl.width, webgl.height) },
        uTexture: { value: webgl.texture },
        uTouch: { value: null },            
        uAlphaCircle: { value: 0.0 },        
        uAlphaSquare: { value: 1.0 },
        uCircleORsquare: { value: 0.0 }, 
    };

    const materialParticles = new THREE.RawShaderMaterial({
        uniforms: uniforms,
        vertexShader: vertexShader(),
        fragmentShader: fragmentShader(),
        depthTest: false,
        transparent: true,
    });
    webgl.particlesMesh = new THREE.Mesh(webgl.geometryParticles, materialParticles);
}


function initTail() {
    tail.array = [];
    tail.size = 80;
    tail.maxAge = 70;
    tail.radius = 0.08;
    tail.red = 255;
    tail.canvas = document.createElement('canvas');
    tail.canvas.width = tail.canvas.height = tail.size;
    tail.ctx = tail.canvas.getContext('2d');
    tail.ctx.fillStyle = 'black';
    tail.ctx.fillRect(0, 0, tail.canvas.width, tail.canvas.height);
    tail.texture = new THREE.Texture(tail.canvas);
    webgl.particlesMesh.material.uniforms.uTouch.value = tail.texture;
}


function initRaycaster() {
    const geometryPlate = new THREE.PlaneGeometry(webgl.width, webgl.height, 1, 1);
    const materialPlate = new THREE.MeshBasicMaterial({ color: 0xFFFFFF, wireframe: true, depthTest: false });
    materialPlate.visible = false;
    webgl.hoverPlate = new THREE.Mesh(geometryPlate, materialPlate)
    webgl.scene.add(webgl.hoverPlate);
    webgl.raycaster = new THREE.Raycaster();
    webgl.mouse = new THREE.Vector2(0, 0);
    window.addEventListener("mousemove", onMouseMove, false);
}


function onMouseMove(event) {
    webgl.mouse.x = (event.clientX / webgl.renderer.domElement.clientWidth) * 2 - 1;
    webgl.mouse.y = - (event.clientY / webgl.renderer.domElement.clientHeight) * 2 + 1;
    webgl.raycaster.setFromCamera(webgl.mouse, webgl.camera);
    let intersects = webgl.raycaster.intersectObjects([webgl.hoverPlate]);
    webgl.particlesMesh.rotation.y = webgl.mouse.x / 8;
    webgl.particlesMesh.rotation.x = -webgl.mouse.y / 8;
    if (intersects[0] && tail.on) buildTail(intersects[0].uv);
}


function buildTail(uv) {
    let force = 0;
    const last = tail.array[tail.array.length - 1];
    if (last) {
        const dx = last.x - uv.x;
        const dy = last.y - uv.y;
        const dd = dx * dx + dy * dy;
        force = Math.min(dd * 10000, 1);
    }
    tail.array.push({ x: uv.x, y: uv.y, age: 0, force });
}



function changeTexture(e) {

    if (Date.now() - webgl.lastClick < 800) return;
    webgl.lastClick = Date.now();

    if (webgl.texturaAnimation0) webgl.texturaAnimation0.kill();
    if (webgl.texturaAnimation1) webgl.texturaAnimation1.kill();
    if (webgl.texturaAnimation2) webgl.texturaAnimation2.kill();
    if (webgl.texturaAnimation5) webgl.texturaAnimation5.kill();
    webgl.particlesMesh.rotation.z = 0.0;

    if (e.target.classList.contains("btn")) return;

    let opt = webgl.texturesOptions[webgl.textureIndex];
    let t = webgl.texturesArray[webgl.textureIndex];

    if (webgl.textureIndex == 1) {
        webgl.firstAnimation1.kill(null, "z");
        webgl.firstAnimation2.kill();
        gsap.fromTo(webgl.particlesMesh.rotation, 0.3, { z: 0.5 }, { z: 0 });
        clearTimeout(webgl.timeoutClickInfo);
        gsap.to(document.querySelector(".clickInfo"), 1, { top: -80, ease: "power4.out" }, 0);
    }

    tail.on = true;

    webgl.width = 250;   
    webgl.height = 145;   

    if (opt.texture == "video") {
        webgl.video = t.image;
        webgl.video.currentTime = 0;
        webgl.texture = t;
        webgl.particlesMesh.material.uniforms.uTexture.value = t;
        webgl.totalPoints = webgl.width * webgl.height;
        //webgl.texture.needsUpdate = true; 
        webgl.video.play(); 
    } else {
        webgl.texture = t;
        webgl.particlesMesh.material.uniforms.uTexture.value = t;
    }

    webgl.particlesMesh.material.uniforms.uTextureSize.value.x = webgl.width;
    webgl.particlesMesh.material.uniforms.uTextureSize.value.y = webgl.height;
    webgl.particlesMesh.material.uniforms.uRandom.value = opt.random;
    webgl.particlesMesh.material.uniforms.uDepth.value = opt.depth;         
    webgl.particlesMesh.material.uniforms.uSize.value = opt.size;
    webgl.particlesMesh.material.uniforms.uCircleORsquare.value = opt.square;

    if (opt.texture != "video") pixelExtraction();

    const offsets = new Float32Array(webgl.totalPoints * 3);
    const indices = new Uint16Array(webgl.totalPoints);
    const angles = new Float32Array(webgl.totalPoints);

    for (let i = 0, j = 0; i < webgl.totalPoints; i++) {
        if (opt.texture != "video") if (webgl.arrayOfColors[i * 4 + 0] <= webgl.threshold) continue;
        if (webgl.textureIndex === 18) if (webgl.arrayOfColors[i * 4 + 0] <= webgl.threshold) continue; 
        offsets[j * 3 + 0] = i % webgl.width;
        offsets[j * 3 + 1] = Math.floor(i / webgl.width);
        indices[j] = i;
        angles[j] = Math.random() * Math.PI;

        j++;
    }
    webgl.geometryParticles.setAttribute('offset', new THREE.InstancedBufferAttribute(offsets, 3, false));
    webgl.geometryParticles.setAttribute('angle', new THREE.InstancedBufferAttribute(angles, 1, false));
    webgl.geometryParticles.setAttribute('pindex', new THREE.InstancedBufferAttribute(indices, 1, false));

    webgl.textureIndex++;

    if (webgl.textureIndex === webgl.texturesOptions.length) webgl.textureIndex = 0;
    if (!opt.maxDepth) opt.maxDepth = 30;

    let tl = gsap.timeline();
    tl.fromTo(webgl.quoteText, 0.5, { opacity: 1 }, { opacity: 0 }, 0);
    tl.fromTo(webgl.text, 1, { rotation: 0 }, { rotation: 180, transformOrigin: "center", ease: "power2.out" }, 0);
    tl.call(() => {
        webgl.quoteText.innerHTML = opt.quote;
        gsap.set(webgl.quoteText, { rotation: 180, opacity: 1, transformOrigin: "center" });
        let split = new SplitText(".quoteText", { type: "lines,words,chars" });
        if (!opt.stagger) opt.stagger = 0.3;
        if (opt.a4) {
            webgl.texturaAnimation5 = gsap.from(split.words, { duration: 0.5, y: 100, rotationX: -60, stagger: opt.stagger });
        } else {
            gsap.set(".quoteText", { perspective: 400 });
            gsap.from(split.lines, { duration: 0.5, opacity: 0, rotationX: -60, force3D: true, transformOrigin: "0 center -150", stagger: opt.stagger });
        }
    }, null, 0.5);

    if (opt.a1) {
        webgl.texturaAnimation1 = gsap.fromTo(webgl.particlesMesh.material.uniforms.uDepth, 1, { value: -20 }, { value: 20, ease: "power2.out", repeat: -1, yoyo: true });
    } else if (opt.a2) {
        webgl.texturaAnimation1 = gsap.fromTo(webgl.particlesMesh.material.uniforms.uRandom, 1, { value: 0 }, { value: -40, ease: "power2.out", repeatDelay: 0.5, repeat: -1, yoyo: true });
    } else if (opt.a3) {
        webgl.texturaAnimation1 = gsap.fromTo(webgl.particlesMesh.material.uniforms.uDepth, 1, { value: 20 }, { value: 50, ease: "power2.out", repeat: -1, repeatDelay: 0.5, yoyo: true });
        webgl.texturaAnimation2 = gsap.fromTo(webgl.particlesMesh.material.uniforms.uSize, 1, { value: 0 }, { value: 1.5, ease: "power2.out", repeatDelay: 0.5, repeat: -1, yoyo: true });
    } else if (opt.a5) {
        webgl.texturaAnimation1 = gsap.fromTo(webgl.particlesMesh.material.uniforms.uDepth, 4, { value: opt.depth }, { value: opt.maxDepth, ease: "power3.out", delay: 2 });
        webgl.texturaAnimation5 = gsap.fromTo(webgl.particlesMesh.rotation, 5, { z: 0.0 }, { z: THREE.Math.degToRad(360), ease: "bounce.out", delay: 5 });
        webgl.texturaAnimation2 = gsap.fromTo(webgl.particlesMesh.position, 5, { z: 0.0 }, { z: -80, ease: "bounce.out", delay: 5 });
    } else if (opt.a6) {
        webgl.texturaAnimation5 = gsap.fromTo(webgl.particlesMesh.material.uniforms.uDepth, 6, { value: 20 }, { value: -200, ease: "power3.out", yoyoEase: "bounce.out", delay: 2, yoyo: true, repeat: 1 });
    } else if (opt.a7) {
        webgl.texturaAnimation1 = gsap.fromTo(webgl.particlesMesh.material.uniforms.uDepth, 4, { value: opt.depth }, { value: 10, ease: "bounce.out", delay: 2, yoyo: true, repeat: 1 });
        webgl.texturaAnimation2 = gsap.fromTo(webgl.particlesMesh.position, 2, { z: 0.0 }, { z: 60.0, ease: "elastic.in(1, 0.3)" });
    } else if (opt.a8) {
            webgl.video.loop = false;
    } else if (opt.a11) { 
            webgl.texturaAnimation1 = gsap.fromTo(webgl.particlesMesh.material.uniforms.uDepth, 1, { value: 4 }, { value: 40, ease: "power2.out", repeat: -1, yoyo: true, repeatDelay:0.5 });
    } else {
        webgl.texturaAnimation0 = tl.fromTo(webgl.text, 1, { scale: 1 }, { scale: 1.3, transformOrigin: "center", ease: "elastic.in(1, 0.3)", yoyo: true, repeat: 1 }, 2.8);
        webgl.texturaAnimation1 = gsap.fromTo(webgl.particlesMesh.position, 4, { z: 0.0 }, { z: 15.0, ease: "elastic.in(1, 0.3)", yoyo: true, repeat: 1, repeatDelay: 5 });

        if (opt.texture === "video") {
            webgl.texturaAnimation2 = gsap.fromTo(webgl.particlesMesh.material.uniforms.uDepth, 4, { value: opt.depth }, { value: opt.maxDepth / 2, ease: "elastic.in(1, 0.3)", repeatDelay: 5, repeat: 1, yoyo: true });
        } else
            webgl.texturaAnimation2 = gsap.fromTo(webgl.particlesMesh.material.uniforms.uDepth, 4, { value: opt.depth }, { value: opt.maxDepth, ease: "elastic.in(1, 0.3)", repeatDelay: 5, repeat: 1, yoyo: true });

        if (opt.a10) {
            webgl.texturaAnimation1 = gsap.set(webgl.particlesMesh.material.uniforms.uTexture, { value: webgl.loader.load("https://i.ibb.co/0qhkwkd/20cc.jpg"), delay: 4 })
        }
    }
}


function animate() {
    webgl.particlesMesh.material.uniforms.uTime.value += webgl.clock.getDelta();

    if (tail.on) drawTail();
    tail.texture.needsUpdate = true;
    webgl.texture.needsUpdate = true;
    webgl.renderer.render(webgl.scene, webgl.camera);
    webgl.raf = requestAnimationFrame(animate);
}


function drawTail() {
    tail.ctx.fillStyle = 'black';
    tail.ctx.fillRect(0, 0, tail.canvas.width, tail.canvas.height);
    tail.array.forEach((point, i) => {
        point.age++;
        if (point.age > tail.maxAge) {
            tail.array.splice(i, 1);
        } else {
            const pos = {
                x: point.x * tail.size,
                y: (1 - point.y) * tail.size
            };

            let intensity = 1;
            if (point.age < tail.maxAge * 0.3) {
                intensity = easeOutSine(point.age / (tail.maxAge * 0.3), 0, 1, 1);
            } else {
                intensity = easeOutSine(1 - (point.age - tail.maxAge * 0.3) / (tail.maxAge * 0.7), 0, 1, 1);
            }
            intensity *= point.force;
            const radius = tail.size * tail.radius * intensity;
            const grd = tail.ctx.createRadialGradient(pos.x, pos.y, radius * 0.25, pos.x, pos.y, radius);
            grd.addColorStop(0, 'rgba(' + tail.red + ', 255, 255, 0.2)');
            grd.addColorStop(1, 'rgba(0, 0, 0, 0.0)');

            tail.ctx.beginPath();
            tail.ctx.fillStyle = grd;
            tail.ctx.arc(pos.x, pos.y, radius, 0, Math.PI * 2);
            tail.ctx.fill();
        }
    });
}

const easeOutSine = (t, b, c, d) => {
    return c * Math.sin(t / d * (Math.PI / 2)) + b;
};


function resize() {
    let f = 0.1;
    webgl.camera.aspect = webgl.container.clientWidth / webgl.container.clientHeight;
    webgl.camera.updateProjectionMatrix();
    webgl.renderer.setSize(webgl.container.clientWidth, webgl.container.clientHeight);
    if (window.innerWidth / window.innerHeight < 2.8) f = -0.2;
    const fovHeight = 2 * Math.tan((webgl.camera.fov * Math.PI) / 180 / 2) * webgl.camera.position.z;
    const scale = fovHeight / webgl.height + f;        
    webgl.particlesMesh.scale.set(scale, scale, 1);
    if (webgl.hoverPlate) webgl.hoverPlate.scale.set(scale, scale, 1);
}


let fsEnter = document.getElementById('fullscr');
fsEnter.addEventListener('click', function (e) {
    e.preventDefault();
    if (!webgl.fullscreen) {
        webgl.fullscreen = true;
        document.documentElement.requestFullscreen();
        fsEnter.innerHTML = "Exit Fullscreen";
    }
    else {
        webgl.fullscreen = false;
        document.exitFullscreen();
        fsEnter.innerHTML = "Go Fullscreen";
    }
});


function vertexShader() {
    return `
        precision highp float;
        attribute float pindex;
        attribute vec3 position;
        attribute vec3 offset;
        attribute vec2 uv;
        attribute float angle;
        uniform mat4 modelViewMatrix;
        uniform mat4 projectionMatrix;
        uniform float uTime;
        uniform float uRandom;
        uniform float uDepth;
        uniform float uSize;
        uniform vec2 uTextureSize;
        uniform sampler2D uTexture;
        uniform sampler2D uTouch;
        varying vec2 vPUv;
        varying vec2 vUv;
        
        vec3 mod289(vec3 x) {
            return x - floor(x * (1.0 / 289.0)) * 289.0;
        }
        
        vec2 mod289(vec2 x) {
            return x - floor(x * (1.0 / 289.0)) * 289.0;
        }
        
        vec3 permute(vec3 x) {
            return mod289(((x*34.0)+1.0)*x);
        }
        
        float snoise(vec2 v)
            {
            const vec4 C = vec4(0.211324865405187, 
                                0.366025403784439, 
                            -0.577350269189626,  
                                0.024390243902439); 
            vec2 i  = floor(v + dot(v, C.yy) );
            vec2 x0 = v -   i + dot(i, C.xx);
        
            vec2 i1;
            i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
            vec4 x12 = x0.xyxy + C.xxzz;
            x12.xy -= i1;
        
            i = mod289(i); // Avoid truncation effects in permutation
            vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 ))
            + i.x + vec3(0.0, i1.x, 1.0 ));
        
            vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
            m = m*m ;
            m = m*m ;
        
            vec3 x = 2.0 * fract(p * C.www) - 1.0;
            vec3 h = abs(x) - 0.5;
            vec3 ox = floor(x + 0.5);
            vec3 a0 = x - ox;
            m *= 1.79284291400159 - 0.85373472095314 * ( a0*a0 + h*h );
        
            vec3 g;
            g.x  = a0.x  * x0.x  + h.x  * x0.y;
            g.yz = a0.yz * x12.xz + h.yz * x12.yw;
            return 130.0 * dot(m, g);
        }

        float random(float n) {
            return fract(sin(n) * 43758.5453123);
        }
        
        void main() {
            vUv = uv;
            
            vec2 puv = offset.xy / uTextureSize;
            vPUv = puv;
        
            vec4 colA = texture2D(uTexture, puv);
            float grey = colA.r * 0.21 + colA.g * 0.71 + colA.b * 0.07;
        
            vec3 displaced = offset;     
            displaced.xy += vec2(random(pindex) - 0.5, random(offset.x + pindex) - 0.5) * uRandom;
            float rndz = (random(pindex) + snoise(vec2(pindex * 0.1, uTime * 0.1)));  
            displaced.z += rndz * (random(pindex) * 2.0 * uDepth);               
            displaced.xy -= uTextureSize * 0.5;
        
            float t = texture2D(uTouch, puv).r;
            displaced.z += t * -40.0 * rndz;
            displaced.x += cos(angle) * t * 40.0 * rndz;
            displaced.y += sin(angle) * t * 40.0 * rndz;     //20
        
            float psize = (snoise(vec2(uTime, pindex) * 0.5) + 2.0);
            psize *= max(grey, 0.2);
            psize *= uSize;
        
            vec4 mvPosition = modelViewMatrix * vec4(displaced, 1.0);
            mvPosition.xyz += position * psize;
            gl_Position = projectionMatrix * mvPosition;
        }
    `
}

function fragmentShader() {
    return `
        precision highp float;
        uniform sampler2D uTexture;
        uniform float uAlphaCircle;        
        uniform float uAlphaSquare;          
        uniform float uCircleORsquare;
        varying vec2 vPUv;
        varying vec2 vUv;
        void main() {
            vec4 color = vec4(0.0);
            vec2 uv = vUv;
            vec2 puv = vPUv;
            vec4 colA = texture2D(uTexture, puv);
            float border = 0.3;
            float radius = 0.5;
            float dist = radius - distance(uv, vec2(0.5));   
            float t = smoothstep(uCircleORsquare, border, dist);
            color = colA;
            color.a = t;
            //gl_FragColor = vec4(color.r, color.g, color.b, uAlphaSquare);
            gl_FragColor = vec4(color.r, color.g, color.b, t - uAlphaCircle);
        }
    `
}

概要:

  • THREE.jsを使用して3Dグラフィックを生成
  • 名言テクスチャをロード
  • 画像のピクセルデータからパーティクルを生成
  • パーティクルの位置・動きをシェーダーで制御
  • GSAPを使用したアニメーション
  • マウスインタラクション

主な機能:

  • initThree(): THREE.jsのセットアップ
  • pixelExtraction(): 画像のピクセルデータ取得
  • initParticles(): パーティクルのジオメトリとシェーダー定義
  • initTail(): マウス軌跡のためのテクスチャ
  • initRaycaster(): マウス検出
  • changeTexture(): テクスチャとアニメーション変更
  • vertexShader(): パーティクルの位置計算
  • fragmentShader(): パーティクルの描画
  • animate(): メインループ
  • resize(): ウィンドウサイズ変更時の調整

これにより、画像ピクセルをパーティクルとして3D空間に配置し、
シェーダーを用いた表現力豊かなグラフィックを実現しています。
GSAPによるタイムラインアニメーションとマウスインタラクションで、
インタラクティブで洗練されたエフェクトを作り出しています。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2