Three.jsのVibe Codingでビジュアルイメージを検討する

はじめに


本記事はSIOS Tech Labアドベントカレンダー23日目の投稿です。

サイオステクノロジーの曽根田です。

普段はデザイン、フロントエンドコーディングや、CMSのセキュリティ保守の一部対応などを行っています。

近年、CopilotやGeminiなどの生成AIの進化により、Photoshop,Illustratorなどでポチポチ作業する手間を省略して、

“フロントエンドのモックアップで直にビジュアルを作る”

ということが手軽にできるようになったと感じています。

この記事ではthree.jsでの実例について書きます。

three.jsって?

three.jsはブラウザ上で3D表現を実装するのに便利なJavaScriptライブラリです。

ブラウザ上でのリッチな演出やゲーム制作までカバーしています。公式ドキュメントも充実しています。

アイデアの種

美術展が好きでよく行くのですが、六本木にある21_21 DESIGN SIGHTというギャラリーのショップで購入した、アスタリスクを3D化して3Dプリンタで出力したオブジェクトようなもの。直径は5cmくらいです。机に飾っていたこれがアイデアの種になりました。

このオブジェクトとthree.jsを使って作れそうなイメージとして以下が浮かびました。

  • htmlの一部にビジュアル要素として埋め込めるcanvas要素
  • 鮮やかな大きな色のエリアが複雑なパターンで動く
  • 動きはゆっくり
  • 3Dにも見えるが2D的でもある。全体像ははっきりわからない
  • 見ているだけで飽きない万華鏡のような動き
完成形のビジュアルイメージ

当初はBlenderでこの3Dアスタリスクをモデリングを作成し、それを外部ファイルとしてthree.jsに取り込むやり方を検討したのですが、オブジェクトが幾何学的であれば数学的な計算だけで生成できるはずなので、一旦生成AIのプロンプトのみで作る方法を選びました。

いわゆる”Vibe Coding”のフェーズへ

今回の開発プロセスの特徴は以下のようなものです。

  • AIとの共同作業: GeminiとCopilotという複数の生成AIをプロンプトで制御し、コードを発展させました。
  • ナレッジベースの雰囲気コーディング: 過去のthree.jsライブラリのコーディング経験や、Blenderなどの3Dナレッジを活かした「雰囲気で進めるコーディング」です。

開発フェーズ 1: オブジェクトの基本配置と初期プロンプト

難しいことを考えず、ことばのイメージでやりたいことを伝えます。

以下、かなり指示がフランクですが、平日は生成AIと会話している時間が一番多く、”話が通じる仲間”という感覚なので、自然と砕けた口調になります笑

abstractなローポリゴンのオブジェクトが画面いっぱいに表示され、ゆっくりと回転するような、ウェブサイトのファーストビューイメージを作りたい。three.jsを活用。背景は#feeb0bにしてほしい。ライティングはリアルではなくフラットでうすくグラデーションが掛かっている感じ。

オブジェクトは添付写真のような、”アスタリスクの3D版”みたいにしてほしい(面ごとに色が違う。色の明度と彩度あげる)。正二十面体のような幾何学図形のそれぞれの面を外側にExtrudeしたような形。カメラワークはもっとゴリっと拡大したい。

バイブコーディングなので”ゴリッと拡大したい”など、雰囲気主体の擬音も直さずそのまま渡してみますが、なんとなく察してくれます。

上記のプロンプト以外にも細かい指示はいくつか与えていますが省略しています。

途中、エラーで上手く描画されないケースもありますが、そのときは都度修正指示を出します。

ほんの3~4ステップのプロンプトでイメージに近いthree.jsの動きを実装してくれました。3D座標空間でオブジェクトが回転しています。

途中経過のキャプチャ。まだ色々と変。

Extrudeという用語について

ちなみに上記プロンプトにある”Extrude”というのはBlenderやMayaなどの3D作成ツールのポリゴンモデリングで使われるコマンドで、”押し出し”を意味します。ビジュアル的には以下のようなイメージです。

Extrudeの図解。選択した面が押し出されています

開発フェーズ 2: 形・色・ライティングの調整

面単位ではなく、ポリゴン毎に色が違っていたり、面が重なってチカチカしてたりするのが作りたいゴールのイメージと異なるので、引き続きプロンプトで修正していきます。

カラーリングだが、ポリゴンごとに変えるのではなく、オブジェクトの同一平面の面は同じ色にする。あと、面が重なっていることでちらつきが発生しているようなので解消してほしい。色の彩度と明度をもっと上げる。

修正後が以下。ちらつきとカラーリングは解消されましたが、形が気に食わない

1つのつながったオブジェクトになっていない気がする。アスタリスクの3D版だけど、一つの多面体の各面を個別にExtrudeして作られたアスタリスク、という感じにできない?

あとShadingやライティングの感じもフラットすぎて面白くないので、以下のようなプロンプトで改善を加えてみます。

もっと“照明っぽい”陰影にしたい。flatShadingじゃなくていいのでは。vertexColorsも使えば?あと、スポットライト複数使ってオブジェクトを照らせば、それっぽくなるんじゃない?

面にグラデーションが付き、スポットライトによるライティングで陰影が生まれました。

形はガラッと変わり、”3D版アスタリスク”ではなくトゲトゲのスターのような形になりましたが、数学的計算によって生成できるシームレスな1つのオブジェクトとなったのでOKと判断しました。

欲しかったのはシームレスなオブジェクトと、色のグラデーションの方です。

最後に、カメラを被写体にぐっと近づけて、起動するたびに毎回異なるカラーリングが施されるようなプロンプトを渡します。

完成版のキャプチャ

望んでいた動くキービジュアル要素が作成できました!

最終成果物:three.jsコード

最終版コードは以下です。1枚のhtmlなのでコピペして保存し、開くだけで動きます。three.jsは実際にブラウザで動かさないとよくわからないと思います。

リロードするたびにランダムにカラーが割り当てられます。3Dオブジェクトは数学的に生成されているので、外部読み込みの3Dファイルは不要です(ただし、オンライン上のCDNのthree.jsライブラリを読み込んで使っています)

一応モバイル対応も考慮しているので、モダンなデバイスならば処理落ちなどせずに動くと思います。

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Extruded Poly Star – three.js</title>
  <style>
    html, body { height: 100%; margin: 0; }
    body {
      background: #feeb0b; /* 指定の背景色 */
      overflow: hidden;
      font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Noto Sans JP", sans-serif;
    }
    #hero { position: fixed; inset: 0; }
  </style>

  <!-- Import Map(正しい閉じタグ) -->
  <script type="importmap">
  {
    "imports": {
      "three": "https://unpkg.com/three@0.160.1/build/three.module.js"
    }
  }
  </script>
</head>
<body>
  <canvas id="hero"></canvas>

  <script type="module">
    import * as THREE from 'three';

    // ===== ランダム化ユーティリティ(毎回違う画に) =====
    // seed は URL ?seed=12345 で固定可能。未指定なら毎回ランダム。
    // FIX: URLSearchParams.get('seed') が null のとき Number(null) は 0 になるので、
    //      「seed未指定なのに毎回0固定」というバグを避ける。
    const params = new URLSearchParams(location.search);
    const seedParam = params.get('seed');
    const urlSeed = (seedParam !== null && seedParam !== '') ? Number(seedParam) : null;
    const autoSeed = (crypto && crypto.getRandomValues)
      ? crypto.getRandomValues(new Uint32Array(1))[0]
      : Math.floor(Math.random() * 1e9);
    const SEED = (urlSeed !== null && Number.isFinite(urlSeed)) ? Math.floor(urlSeed) : autoSeed;
    function mulberry32(a){ return function(){ let t = a += 0x6D2B79F5; t = Math.imul(t ^ t >>> 15, t | 1); t ^= t + Math.imul(t ^ t >>> 7, t | 61); return ((t ^ t >>> 14) >>> 0) / 4294967296; } }
    const rand = mulberry32(SEED);
    const randRange = (min, max) => min + (max - min) * rand();
    console.log('%c[Hero Seed]', 'color:#555', SEED, urlSeed !== null ? '(from URL)' : '(random)');


    // ===== 基本セットアップ =====
    const canvas = document.getElementById('hero');
    const renderer = new THREE.WebGLRenderer({
      canvas,
      antialias: true,
      alpha: true,
      powerPreference: 'high-performance'
    });

    // --- DPR 上限(PC/モバイル) + reduced-motion 対応 + 低FPSダウングレード ---
    const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Windows Phone/i.test(navigator.userAgent) || matchMedia('(pointer: coarse)').matches;
    const DPR_CAP_DESKTOP = 1.5;
    const DPR_CAP_MOBILE  = 1.25;
    let dprCap = isMobile ? DPR_CAP_MOBILE : DPR_CAP_DESKTOP;
    const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;

    function applyDPR(cap = dprCap) {
      const target = Math.min(window.devicePixelRatio || 1, cap);
      renderer.setPixelRatio(target);
      renderer.setSize(window.innerWidth, window.innerHeight);
      return target;
    }
    let currentDPR = applyDPR();

    const scene = new THREE.Scene();

    // カメラ:右側を大きく覆うフレーミング
    const camera = new THREE.PerspectiveCamera(48, window.innerWidth / window.innerHeight, 0.01, 100);
    // 構図を毎回微妙に変える(少しだけジッター)
    const camX = -1.0 + randRange(-0.15, 0.15);
    const camY =  0.12 + randRange(-0.12, 0.12);
    const camZ =  2.0  + randRange(-0.2, 0.2);
    camera.position.set(camX, camY, camZ);
    const lookX = 0.4 + randRange(-0.08, 0.08);
    camera.lookAt(lookX, randRange(-0.05,0.05), randRange(-0.05,0.05));

    renderer.physicallyCorrectLights = true;

    // ===== ライティング(複数スポットで“照明っぽい”陰影) =====
    const amb = new THREE.AmbientLight(0xffffff, 0.08);
    scene.add(amb);

    const keyColor = new THREE.Color().setHSL(0.08 + randRange(-0.03,0.03), 0.9, 0.7);
    const rimColor = new THREE.Color().setHSL(0.58 + randRange(-0.04,0.04), 0.6, 0.7);
    const fillColor= new THREE.Color().setHSL(0.9 + randRange(-0.03,0.03), 0.8, 0.7);

    const spotKey = new THREE.SpotLight(keyColor, 40 + randRange(-8,8), 6.0, Math.PI * (0.26 + randRange(-0.03,0.03)), 0.45, 1.0);
    spotKey.position.set(2.4 + randRange(-0.3,0.2), 3.2 + randRange(-0.3,0.2), 1.8 + randRange(-0.2,0.2));
    scene.add(spotKey);

    const spotRim = new THREE.SpotLight(rimColor, 20 + randRange(-6,6), 6.0, Math.PI * (0.34 + randRange(-0.04,0.04)), 0.5, 1.0);
    spotRim.position.set(-2.8 + randRange(-0.2,0.2), 1.6 + randRange(-0.2,0.2), -1.6 + randRange(-0.2,0.2));
    scene.add(spotRim);

    const spotFill = new THREE.SpotLight(fillColor, 12 + randRange(-6,6), 6.0, Math.PI * (0.44 + randRange(-0.05,0.05)), 0.8, 1.0);
    spotFill.position.set(0.0 + randRange(-0.3,0.3), -2.4 + randRange(-0.3,0.3), 2.2 + randRange(-0.3,0.3));
    scene.add(spotFill);

    // ====== 単一メッシュの「多面体→各面を外向きにエクストルードしたスター」生成 ======
    const baseRadius = randRange(0.7, 0.95);
    const base = new THREE.IcosahedronGeometry(baseRadius, 0); // 20面(すべて三角形)

    function buildExtrudedStarFromTriPoly(geo, extrude = 1.2) {
      // 非インデックスでも動作
      const pos = geo.attributes.position;
      const idxArray = (geo.index && geo.index.array)
        ? geo.index.array
        : (function(){ const a = new Uint32Array(pos.count); for (let i=0;i<pos.count;i++) a[i]=i; return a; })();

      const positions = [];
      const colors = [];
      const color = new THREE.Color();
      // グローバル色相オフセット&勾配方向もランダム
      const hueShift = randRange(0, 1);
      const gradDir = new THREE.Vector3(randRange(-1,1), randRange(0.3,1), randRange(-1,1)).normalize();

      for (let f = 0; f < idxArray.length; f += 3) {
        const ai = idxArray[f], bi = idxArray[f+1], ci = idxArray[f+2];
        const a = new THREE.Vector3(pos.getX(ai), pos.getY(ai), pos.getZ(ai));
        const b = new THREE.Vector3(pos.getX(bi), pos.getY(bi), pos.getZ(bi));
        const c = new THREE.Vector3(pos.getX(ci), pos.getY(ci), pos.getZ(ci));

        const ab = new THREE.Vector3().subVectors(b, a);
        const ac = new THREE.Vector3().subVectors(c, a);
        const n = new THREE.Vector3().crossVectors(ab, ac).normalize();
        const centroid = new THREE.Vector3().addVectors(a, b).add(c).multiplyScalar(1/3);
        const tip = new THREE.Vector3().copy(centroid).addScaledVector(n, extrude);

        pushTriWithFaceGradient(a, b, c, f);
        pushTriWithFaceGradient(a, b, tip, f + 1);
        pushTriWithFaceGradient(b, c, tip, f + 2);
        pushTriWithFaceGradient(c, a, tip, f + 3);
      }

      function pushTriWithFaceGradient(v1, v2, v3, seed) {
        const arr = [v1, v2, v3];
        let minD = Infinity, maxD = -Infinity;
        const ds = arr.map(v => { const d = v.dot(gradDir); if (d<minD) minD = d; if (d>maxD) maxD = d; return d; });
        const span = Math.max(1e-6, maxD - minD);
        const baseH = (hueShift + ((seed * 19.23) % 360) / 360) % 1.0;
        const S = 1.0; const L0 = 0.63;
        for (let i = 0; i < 3; i++) {
          const f = (ds[i] - minD) / span;           // 0..1
          const h = (baseH + f * 1.4) % 1.0;         // 虹っぽく周回
          const L = THREE.MathUtils.clamp(L0 + (Math.cos((f - 0.5) * Math.PI) * 0.22), 0, 1);
          color.setHSL(h, S, L);
          positions.push(arr[i].x, arr[i].y, arr[i].z);
          colors.push(color.r, color.g, color.b);
        }
      }

      const out = new THREE.BufferGeometry();
      out.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
      out.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
      out.computeVertexNormals();
      return out;
    }

    const starGeom = buildExtrudedStarFromTriPoly(base, 1.0);

    // ===== マテリアル(“照明っぽい”陰影:PBR マット) =====
    const mat = new THREE.MeshStandardMaterial({
      vertexColors: true,
      roughness: 0.85,
      metalness: 0.05,
      side: THREE.DoubleSide
    });

    const star = new THREE.Mesh(starGeom, mat);
    // 開始角度&サイズ・位置をランダム化(右寄せは維持)
    star.scale.setScalar(randRange(2.2, 2.8));
    star.position.x = randRange(0.55, 0.9);
    star.rotation.x = randRange(0, Math.PI*2);
    star.rotation.y = randRange(0, Math.PI*2);
    star.rotation.z = randRange(0, Math.PI*2);
    scene.add(star);

    // ===== アニメーション(単純なリニア回転) + 低FPSダウングレード =====
    const clock = new THREE.Clock();
    let rafId = null;

    // FPS 測定用
    let fpsFrames = 0;
    let fpsStart = performance.now();
    let degradeSteps = 0;
    const MAX_DEGRADE_STEPS = 3;

    // --- FIX: 乱数は「毎フレーム」じゃなく「起動時に1回だけ」決める(チカチカ防止) ---
    const rotX0 = star.rotation.x;
    const rotY0 = star.rotation.y;
    const rotZ0 = star.rotation.z;
    const rotXSpeed = randRange(0.015, 0.028);   // x軸 [rad/s]
    const rotZSpeed = -randRange(0.012, 0.020);  // z軸 [rad/s](逆方向)

    function maybeDowngrade() {
      const now = performance.now();
      const elapsed = now - fpsStart;
      if (elapsed >= 1000) {
        const fps = fpsFrames * 1000 / elapsed;
        fpsFrames = 0; fpsStart = now;
        // 45fpsを下回ったら DPR を 0.25 刻みで下げる(最小 1.0)
        if (fps < 45 && degradeSteps < MAX_DEGRADE_STEPS && currentDPR > 1.0) {
          dprCap = Math.max(1.0, +(dprCap - 0.25).toFixed(2));
          currentDPR = applyDPR(dprCap);
          degradeSteps++;
          // 30fps を著しく下回るならライトも少し弱める
          if (fps < 30) {
            spotKey.intensity *= 0.9;
            spotFill.intensity *= 0.85;
          }
        }
      }
    }

    function renderFrame() {
      renderer.render(scene, camera);
    }

    function tick() {
      if (reduceMotion) { // 動きに弱いユーザーは静止
        renderFrame();
        return;
      }
      const t = clock.getElapsedTime();
      star.rotation.x = rotX0 + t * rotXSpeed;
      star.rotation.y = rotY0; // Yは固定(開始角度は保持)
      star.rotation.z = rotZ0 + t * rotZSpeed;
      renderFrame();
      fpsFrames++;
      maybeDowngrade();
      rafId = requestAnimationFrame(tick);
    }

    // ===== リサイズ対応(DPR も再適用) =====
    function onResize() {
      currentDPR = applyDPR();
      camera.aspect = window.innerWidth / window.innerHeight;
      camera.updateProjectionMatrix();
    }
    window.addEventListener('resize', onResize);

    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        if (rafId) cancelAnimationFrame(rafId);
      } else {
        tick();
      }
    });

    onResize();
    tick();

    // ====== テスト(DPR上限・reduced-motion・低FPSダウングレードの土台を検証) ======
    (function runTests(){
      console.groupCollapsed('%c[Hero Tests] DPR cap + reduced-motion + perf', 'color:#111');
      try {
        console.assert(renderer instanceof THREE.WebGLRenderer, 'renderer created');
        console.assert(scene && camera && star, 'scene/camera/star exist');
        console.assert(star.material instanceof THREE.MeshStandardMaterial, 'using MeshStandardMaterial');
        console.assert(star.material.vertexColors === true, 'vertexColors enabled');
        // DPR 上限が PC:1.5 / モバイル:1.25 を超えていない
        const capExpected = isMobile ? DPR_CAP_MOBILE : DPR_CAP_DESKTOP;
        console.assert(renderer.getPixelRatio() <= capExpected + 1e-6, 'pixelRatio is capped');
        // reduced-motion フラグが boolean
        console.assert(typeof reduceMotion === 'boolean', 'reduceMotion flag present');
        // 【追加】seed未指定時に urlSeed が null になる(= 0 固定バグが無い)
        const hasSeed = new URLSearchParams(location.search).has('seed');
        if (!hasSeed) console.assert(seedParam === null, 'no-seed should not default to 0');
        // エクストルード済み(頂点数増加)
        const vcount = star.geometry.getAttribute('position').count;
        console.assert(vcount > 60, 'geometry extruded (vertex count increased)');
        // 回転が進行(reduced-motion でない環境のみチェック)
        const rx0 = star.rotation.x, rz0 = star.rotation.z;
        setTimeout(() => {
          const rx1 = star.rotation.x, rz1 = star.rotation.z;
          if (!reduceMotion) console.assert(rx1 !== rx0 || rz1 !== rz0, 'rotation progressing over time');
          console.log('All tests passed ✅');
          console.groupEnd();
        }, 350);
      } catch (e) {
        console.error('Test failure:', e);
        console.groupEnd();
      }
    })();
  </script>
</body>
</html>

補足

今回、tech-lab.sios.jpのデザインリニューアルを担当させていただいたのですが、トップページの一部背景に上記のビジュアルを画像として使用しています。

元々、キーのキャラクターであるサイをポリゴン化する、というのもこのthree.jsの実験から着想したアイデアでした。

結局、こちらのサイがメインのキービジュアルという形になりました。

ポリゴン化したサイ
デザイン案のバリエーション検討(Adobe DXのキャプチャ)

さいごに

three.jsはリッチコンテンツ向きである分、敷居が高く、使いどころが難しいように見えるかもしれませんが、ビジュアルイメージ検討のためのツールの1つとして気軽に使うと面白いと思います。

ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

0人がこの投稿は役に立ったと言っています。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です