【Three.jsで季節の空気感を作る】:桜・花火・紅葉・雪で学ぶWebGL表現(フォグ/ブレンド/InstancedMesh)

はじめに

NTT西日本の中川 拓哉です。
本記事では、Three.js(WebGLをラップするJavaScriptライブラリ)で春夏秋冬を表すサンプルを作り、各シーンで使っているWebGL/Three.jsの機能を解説します。
本記事は2026年4月時点の情報に基づきます。

Webの特にフロントエンドに携わりたいと思う人の多くは、まず見た目の動く画面の魅力を感じてフロントエンドに関わりたいと思われたのではないでしょうか。
フロントエンドはデータの管理なども受け持つことが多いですが、なんと言ってもリッチな見た目やぬるぬる動く動作が最大の魅力の一つです。
私自身、ぬるぬる動く見た目からフロントエンドに魅力を感じた一人です。
今回ご紹介するThree.jsは「3Dが描けるライブラリ」です。ゲームなども作成でき、非常に多彩な使い方ができますが、個人的には、UIや可視化に 季節感・空気感 を少ない要素で足すための“表現”として使う場面が多いです。季節的なプロモーションサイトの背景や、一つの商品を多角的なイメージで伝えたいランディングページなどです。この記事では、WebGLの利用イメージを掴んでいただくための入口として四季を題材にしつつ、各ポイントについて解説していきます。

四季サンプルの完成イメージ(春・夏・秋・冬)

対象読者

本記事が想定する対象読者は以下の通りです。
本記事では、「ブレンディング」や「フォグ」といった聞きなれない用語が出てきますが、Webで3Dを表現するにあたっては専門的な用語が多数存在します。一つ一つを解説していると用語の解説記事になってしまうため、この記事は一定の知識がある方を前提としています。

  • フロントエンドのリッチな表現を試してみたい方
  • 3D描画について一定の知識がある方
  • Webサイトに「立体的な演出」を入れたい方

目次

1.背景・目的

Webサイトやデモに大きな動きを入れようとすると、派手に作ろうとするほど実装が破綻しがちです。一方で、表現を絞って「それっぽさ」を出すことに用途を絞ると、短い時間で体験を底上げできます。
本記事の目的は、四季の表現を題材にして、以下の2点を満たすことです。

  • できるだけ小さな仕組み(Points/InstancedMesh/Fog/Blending など)で演出を作る
  • 何を有効にすると何が起きるか(WebGLの機能)を説明できるようになる

2.前提条件・動作環境

  • Three.js: 0.164.1(今回のサンプルでの利用バージョン+CDNで読み込み)
  • 起動方法: file:// ではなく http://localhost などで配信する(VS CodeのLive ServerやApache HTTP Server環境など)
  • 対応ブラウザ: Chromium系 / Firefox / Safari など、WebGLが有効な環境(端末差が出る場合があります)

補足: Three.jsやWebGLは非常に奥深い技術です。本記事のサンプルは、概要レベルの説明であるため、外部画像の利用はせず、テクスチャもCanvasで生成します。
利点として、ネットワークが不安定な環境でも再現しやすいですが、画像を利用しない為、リアルな表現ではないことをご留意ください。

2.1 うまく動かないときの最短チェック

この手の記事は、コードの記述よりも前にそもそも「動かし方・開き方」でつまずくことがあります。
私自身も最初は Three.jsのコードより前に、実行環境で止まったり詰まったりすることが何度かありました。そのため、この記事ではできるだけ簡単に描画を体験していただけるような構成にしていますので、ぜひいろいろ試してみてください。
実行環境に関しては、まずは次の3点だけ見れば十分です。

  • file:// で開いていないか:原則として http://localhost などで開く
  • 画面が真っ黒なままか:コンソールエラーとWebGLの有効/無効を確認する
  • 動くが動作が重いなどがないか:まず描画の数やインスタンス数を半分にして、差が出るかを見る

「まず描画の数やインスタンス数を半分にして、差が出るかを見る」は、地味ですが一番効く切り分けです。

2.2 簡単な語句の説明

  1. 板ポリ:板ポリゴン(平面のポリゴン1枚に、透明テクスチャを貼ったもの)
  2. Fog(フォグ):3D描画で「遠いものほど霞んで見える」ようにする霧・空気遠近法の表現

3.まず動かす(四季サンプルの全体像)

本記事は この記事の中だけで完結するように、サンプルコードもすべて本文内に掲載します。
起動方法の詳細は前節の通りです。ローカルサーバー経由で開いてください。
画面左上の切り替えで、春(桜)・夏(花火)・秋(紅葉)・冬(雪)を切り替えられます。

季節切り替えUI

以降はポイントだけコード抜粋し、フルコードは最後にまとめます。

3.1 サンプルの構成(Input / Update / Render)

構成は、実務でも使えるように次のような構成にしています。

  • Input: ボタン操作(季節切り替え)を状態に落とす
  • Update: 季節ごとの「状態更新」
  • Render: renderer.render(scene, camera) は基本1行(重い処理を集中させない)

最初から全部の処理を追うより、以下のように一つの季節ずつその季節ごとの処理の特徴を理解しながら試すほうが全体的に理解しやすいです。

  1. 春の桜:透明・深度のクセが分かりやすい
  2. 夏の花火:ロケット、軌跡、バーストで「演出の組み立て」が分かりやすい
  3. 秋の紅葉:InstancedMesh の実務的な活用法が分かりやすい
  4. 冬の雪:Fogとライトだけでも十分に演出できることが分かりやすい

「全部を理解する」というより、「この季節のこの見え方は自分の案件でも使えそう」というようにいずれかの技術に注力した方が3D系は理解しやすいと思います。

4.春:桜の降る景色(パーティクル + アルファブレンディング)

春は、落ちる粒を“桜の花びら”に見せられると、一気に季節感が出やすいです。
ただし THREE.Points は「点スプライト」なので、調整が甘いと ピンクの雪っぽくなってしまいます。そこで本サンプルでは、桜だけは InstancedMesh(板ポリ)で花びらを描くようにしています。
※余談ですが、こういった試行錯誤も3D描画の表現の楽しみだと個人的には感じます。
InstancedMeshはThree.jsのクラスとなります。( Three.js - InstancedMesh

4.1 利用する主な機能

  • BufferGeometry: 粒の位置・速度などをTypedArrayで持つ(更新が速い)
  • 透明(alpha): アルファ付きテクスチャで花びら形に見せる
  • Blending: NormalBlending(自然に重なる)
  • InstancedMesh: 花びらを“板ポリ”として大量描画する(回転が絵に乗る)

4.2 桜っぽく見せるコツ(形状 + 動き)

「ピンクの雪」に見える場合、原因は、次の2つが多いです。

  • 形が丸すぎる(粒に見えると雪に見えがちです)
  • 動きが単調すぎる(雪の落下に近い速度になってしまっている)

対策として、(1) 花びら形のテクスチャを作り、(2) 板ポリを回転させながら落とします(ひらひら感を出します)。

サンプル作成で苦労したこと

最初は Points にピンクの円形テクスチャを当てて「これで桜っぽいはず」と思っていましたが、実際に動かすとほぼ雪でした。
試行錯誤して、決定的な原因だなと思ったのは “形”(ほぼ丸) と “回転が絵に乗るか”でした。
そのため、桜だけ InstancedMesh に切り替えることで対策しました。
テクスチャ側も、輪郭を少し入れて「丸」ではない形にするだけで、印象がかなり変わりますので、是非色々試してみてください。

function makePetalTexture() {
  const c = document.createElement('canvas');
  c.width = 64; c.height = 64;
  const g = c.getContext('2d');
  g.clearRect(0, 0, 64, 64);
  g.translate(32, 32);
  g.rotate(-0.25);

  const base = g.createRadialGradient(0, -6, 2, 0, -6, 30);
  base.addColorStop(0, 'rgba(255,210,225,0.95)');
  base.addColorStop(0.55, 'rgba(255,175,205,0.75)');
  base.addColorStop(1, 'rgba(255,175,205,0.0)');
  g.fillStyle = base;

  g.beginPath();
  g.moveTo(0, -28);
  g.bezierCurveTo(18, -22, 18, 10, 0, 26);
  g.bezierCurveTo(-18, 10, -18, -22, 0, -28);
  g.closePath();
  g.fill();

  g.globalCompositeOperation = 'destination-out';
  g.beginPath();
  g.ellipse(0, 18, 7.5, 5.5, 0, 0, Math.PI * 2);
  g.fill();
  g.globalCompositeOperation = 'source-over';

  g.strokeStyle = 'rgba(255,120,160,0.28)';
  g.lineWidth = 2;
  g.stroke();

  return new THREE.CanvasTexture(c);
}

板ポリ(InstancedMesh)で「ひらひら」を出す最小例です。Points と違い、回転が絵に乗りやすいのがポイントです。

function makeSakuraPetals({ count = 820, texture }) {
  const geo = new THREE.PlaneGeometry(0.28, 0.28);
  const mat = new THREE.MeshStandardMaterial({
    map: texture,
    transparent: true,
    depthWrite: false,
    side: THREE.DoubleSide,
  });
  const mesh = new THREE.InstancedMesh(geo, mat, count);
  mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);

  // 位置・落下・回転などの個体差を持ち、updateで行列を更新する
  return mesh;
}

4.3 つまずきポイント(透明表現・深度)

透明表現は、有効にするだけでは不自然になりやすいです。崩れたときは、まず次の点を確認してください。

  • depthWrite: false: 透明が深度バッファに書き込むと、後ろの粒が不自然に欠けることがあります
  • transparent: true: ブレンディングを有効化する基本条件

4.4 カスタマイズする場合のポイント

桜の表現をカスタマイズするなら、まずは次の3つで十分です。

  • count: 花びらの枚数。増やすと華やかですが、やりすぎると一気に重くなります
  • fall / drift: 落ち方。ここを触ると「雪っぽさ」と「桜っぽさ」の差が出やすいです
  • テクスチャの輪郭色: 輪郭を少し入れるだけで、粒感が減って花びらに寄ります

実際、私もチューニングしたのはここです。モデルを凝るより、まず動きと密度を合わせたほうが“らしさ”が早く出ます。(本サンプルのような画像を使わずにコードだけで表現する場合は特に効果的かなと思います。)

5.夏:花火の打ち上げ(打ち上げ + 加算合成 + バースト)

花火は「点の集合」でもそれっぽく見えます。

5.1 利用する主な機能

  • AdditiveBlending: 明るいところほど光が足される(花火・光跡向き)
  • 寿命(life): 粒ごとにフェードアウトさせる(ずっと残り続けずに消す)
  • ガンマ/トーン(簡易): ここでは強いポスト処理は入れず、色設計とブレンディングで寄せる

5.2 「打ち上がり」を見せる(ロケット + 軌跡)

爆発(バースト)だけだと「花火が突然出た」ように見えます(ただの破裂みたいにしか見えず、花火には見えなくなります)。
そのため、ロケットを上昇させ、軌跡(トレイル)を残してから爆発させるのが重要です。
主に下記が大事かなと思います。

サンプル作成で苦労したこと

最初はバーストだけを描画していました。
でも、実際の花火の映像を見てみるとやっぱり打ち上げ中の光があって、それがバーストするという流れが大事だなと感じました。
そのため、ロケット(上昇する点)を一つ置いて視線の追従先を作り、トレイルを少し太く(1フレームで複数粒)することで花火っぽい演出が出るようにしています。

  • ロケット状態を一つ持つ(位置と速度)
  • 上昇中に、フェードアウトするトレイル粒を少しずつ生成する
  • 目標高度に達したら burst(origin) をコールするようにする
// ロケット(1発)
const rocket = { active: false, x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, targetY: 5.0 };

function updateRocket(dt) {
  if (!rocket.active) return;
  rocket.x += rocket.vx * dt;
  rocket.y += rocket.vy * dt;
  rocket.z += rocket.vz * dt;

  rocketTrail.userData.spawnTrail(
    rocket.x, rocket.y, rocket.z,
    (Math.random() - 0.5) * 0.2,
    -0.4 - Math.random() * 0.5,
    (Math.random() - 0.5) * 0.2,
    0.55 + Math.random() * 0.35
  );

  if (rocket.y >= rocket.targetY) {
    rocket.active = false;
    fireworks.userData.burst(new THREE.Vector3(rocket.x, rocket.y, rocket.z));
  }
}

5.3 注意点(加算は“使い過ぎると白飛び”する)

AdditiveBlendingは見栄えが良い反面、設定によっては白飛びしやすいです。次を意識するとコントロールしやすいかなと思います。

加算合成の白飛び例(粒の数が多く大きい場合、粒が重なって潰れ、白い大きな塊に見える)

白飛びを抑えた調整例(細かい粒子もしっかり見えてバランスがいい)

  • 粒の 数を増やしすぎない
  • opacitysize を控えめにする
  • 背景を真っ黒にせず、少し色を入れる(目が疲れにくい)

5.4 カスタマイズする場合のポイント

花火はパラメータの効き方が分かりやすく、調整も楽しいです。まずは次の3つから触るのがおすすめです。

  • targetY: どの高さで花火が開くか。これだけで印象がかなり変わります
  • トレイルの粒数と寿命: 打ち上がりの“筋”が見えるかどうかを決めます
  • バースト粒の数: 豪華さに直結しますが、白飛びと重さも増えます

個人的には、粒を増やすより 「打ち上がる途中が見えるか」 を先に整えたほうが、花火らしさは出しやすいです。

6.秋:紅葉の景色(InstancedMesh + 風の揺れ)

秋は、落ち葉や紅葉の「枚数」が重要です。
本サンプルでは InstancedMesh を使い、同じジオメトリを大量に描いても耐えやすい形にします。

サンプル作成で苦労したこと

紅葉のような舞い散る要素は、たくさん要素を増やした方が雰囲気が出ますが、CPU側で毎フレーム行列更新をしていると、急に重く感じることがあります。
そのため、本サンプルでは、まず “枚数を出す” を InstancedMesh で確保しつつ、更新部分は軽めにして「カクカクせずに動く範囲」にしています。

6.1 利用する主な機能

  • InstancedMesh: 1種類の葉を、行列(transform)だけ変えて大量描画する(ドローコール削減)
  • Matrix4: setMatrixAt で位置・回転・スケールをまとめて設定
  • 疑似風: sin で揺れを足す(物理の代わり)

6.2 注意点

インスタンシングは描画が強い一方、毎フレーム全個体の行列を更新するとCPU側が重くなることがあります。サンプルでは数を控えめにし、更新も軽い式にしています。カスタマイズする際は「更新が必要な個体だけ更新する」などの工夫が効果的です。

6.3 コードのポイント(InstancedMeshの更新)

紅葉は「枚数」が雰囲気に直結します。一方で、葉1枚1枚を Mesh で作るとドローコールが増えやすいので、InstancedMesh に寄せます。
ここでのポイントは、setMatrixAt(i, dummy.matrix)各個体の行列を更新している点です。

const mesh = new THREE.InstancedMesh(geo, mat, count);
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);

function update(dt, t) {
  for (let i = 0; i < count; i++) {
    // d.x / d.y / d.z を更新して落下させる
    // sinで揺れを足して風っぽくする
    dummy.position.set(d.x + sx, d.y, d.z);
    dummy.rotation.set(0, d.ry + sx * 0.7, d.rz + Math.sin(t * 1.2 + d.phase) * 0.4);
    dummy.updateMatrix();
    mesh.setMatrixAt(i, dummy.matrix);
  }
  mesh.instanceMatrix.needsUpdate = true;
}

6.4 カスタマイズする場合のポイント

秋は、見た目を変えるより「重くしすぎない」調整が大事です。

  • count: まずはここ。増やすと雰囲気は出ますが、CPU更新コストが増えます
  • sway: 風の強さ。ここが強すぎると、落ち葉というより紙吹雪っぽく見えます
  • 更新頻度: 本番では毎フレーム更新しなくても、十分それらしく見えます

7.冬:雪景色(フォグ + ライティング + パーティクル)

冬の表現は、雪そのものより 空気(白っぽさ・奥行き)が重要に感じます。ここではフォグとライトで「奥行き」を作ります。

サンプル作成で苦労したこと

雪は粒を増やすほど季節感が出る反面、増やしすぎると「画面がうるさい」方向に寄りがちです。
個人的には、雪そのものより フォグで遠景を溶かすほうが“冬っぽさ”が出やすく、粒は控えめにして揺れも弱める、くらいが扱いやすいと感じています。

7.1 利用する主な機能

  • Fog(フォグ): 遠景を白っぽく(雪/霧の空気感)
  • DirectionalLight + AmbientLight: 影は使わず、軽いライトで雰囲気を出す
  • 雪パーティクル: 春のパーティクルを流用しつつ、落下と揺れを弱める

7.2 コードのポイント(Fogと雪の見せ方)

雪景色は、雪の粒そのものより「遠景が白っぽい」「空気が冷たい」印象のほうが効くことが多いです。そこで次の2つを組み合わせます。

ogがほぼ効いてないケース(ほぼ霧が掛からず、遠景がくっきり残る)

Fogが効きすぎているケース(背景色に溶けすぎて、ものすごく霧がかかったような背景になる)

  • Fog: 遠いものほど背景色に溶ける(白/青寄りにすると冬っぽい)
  • 雪パーティクル: サイズを控えめにし、揺れも弱める(吹雪にしない)
// Fog(冬は近めから効かせる)
scene.fog = new THREE.Fog(0x0a1322, 6, 45);

// 雪(深度書き込みはしない:透明の欠けを避ける)
const mat = new THREE.PointsMaterial({
  map: circleTexture,
  transparent: true,
  depthWrite: false,
});

7.3 カスタマイズする場合のポイント

冬は、派手さより「足し算しすぎない」ほうがうまくいきます。

  • Fogの near / far: 空気感の主役です。雪粒の数より先にここを触る価値があります
  • 雪粒のサイズ: 大きくしすぎると急に人工的な見た目に寄ります
  • ライトの強さ: 青寄りの冷たさを出したいか、やわらかい雪景色にしたいかで調整します

冬の表現は、四季の中でも「少ない要素でそれらしく見える」ので、WebGLを触り始めた人が最初に成功体験を得やすいパートだと思っています。

8.まとめ

四季の表現は、複雑なモデルや高価なポスト処理がなくても、次の組み合わせでそれっぽく作れます。

  • 春(桜): 透明パーティクルと深度の扱い
  • 夏(花火): 加算合成とフェード
  • 秋(紅葉): インスタンシングで枚数を出す
  • 冬(雪): フォグとライトで空気を作る

本記事のサンプルは「再現しやすい」ことを優先して、1ファイル・外部画像なしで作っています。ここから、背景の作り込み、モデル差し替え、ポスト処理追加など、用途に応じて段階的に伸ばしてください。

最後に個人的な感覚ですが、四季のような“わかりやすいテーマ”は、WebGLの機能を用いて 「何を足すと体験がどう変わるか」を掴みやすい題材だと思っています。
まずはこのまま動かして、気に入った季節のパラメータ(枚数、速度、色)などを少しずつ触ってみてください。見た目の変化が素直なので、作っていて楽しいところでもあります。

最初の一歩としておすすめは、桜か雪です。春は「形と動き」で印象が変わる面白さがあり、冬は少ない要素で空気感が作れます。

付録:フルセットのコード

以下に、四季切り替え版のフルコードを掲載します。

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Four Seasons (Three.js)</title>
  <style>
    html, body { height: 100%; }
    body { margin: 0; overflow: hidden; background: #0b1020; font-family: system-ui, -apple-system, "Hiragino Sans", "Noto Sans JP", sans-serif; }
    canvas { display: block; }
    .ui {
      position: fixed;
      top: 12px; left: 12px;
      display: flex; gap: 8px; flex-wrap: wrap;
      z-index: 2;
      padding: 10px;
      border-radius: 14px;
      background: rgba(0,0,0,0.35);
      border: 1px solid rgba(255,255,255,0.10);
      backdrop-filter: blur(10px);
      color: rgba(255,255,255,0.85);
    }
    button {
      appearance: none;
      border: 1px solid rgba(255,255,255,0.14);
      background: rgba(255,255,255,0.06);
      color: rgba(255,255,255,0.9);
      padding: 8px 10px;
      border-radius: 12px;
      font-weight: 650;
      cursor: pointer;
    }
    button[aria-pressed="true"] {
      background: linear-gradient(135deg, rgba(61,220,151,.22), rgba(122,162,255,.18));
      border-color: rgba(255,255,255,0.18);
    }
    .note {
      max-width: 420px;
      font-size: 12px;
      line-height: 1.5;
      opacity: 0.9;
    }
  </style>
</head>
<body>
  <div class="ui" role="group" aria-label="season switch">
    <button id="spring" aria-pressed="true">春(桜)</button>
    <button id="summer" aria-pressed="false">夏(花火)</button>
    <button id="autumn" aria-pressed="false">秋(紅葉)</button>
    <button id="winter" aria-pressed="false">冬(雪)</button>
    <div class="note">起動は Live Server などで http://localhost として開いてください(file:// は不可)。</div>
  </div>

  <script type="importmap">
    {
      "imports": {
        "three": "https://unpkg.com/three@0.164.1/build/three.module.js"
      }
    }
  </script>
  <script type="module">
    import * as THREE from 'three';

    const Season = Object.freeze({ spring: 'spring', summer: 'summer', autumn: 'autumn', winter: 'winter' });
    let season = Season.spring;

    const btns = {
      spring: document.getElementById('spring'),
      summer: document.getElementById('summer'),
      autumn: document.getElementById('autumn'),
      winter: document.getElementById('winter'),
    };
    function setPressed(s) {
      for (const [k, el] of Object.entries(btns)) el.setAttribute('aria-pressed', String(k === s));
    }
    for (const [k, el] of Object.entries(btns)) {
      el.addEventListener('click', () => { season = k; setPressed(k); applySeasonLook(); });
    }

    const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' });
    renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
    renderer.setSize(innerWidth, innerHeight);
    document.body.appendChild(renderer.domElement);

    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.1, 200);
    camera.position.set(0, 3.0, 10.5);

    const amb = new THREE.AmbientLight(0xffffff, 0.55);
    const dir = new THREE.DirectionalLight(0xffffff, 1.0);
    dir.position.set(6, 10, 6);
    scene.add(amb, dir);

    const ground = new THREE.Mesh(
      new THREE.PlaneGeometry(120, 120),
      new THREE.MeshStandardMaterial({ color: 0x0e1733, roughness: 1.0, metalness: 0.0 })
    );
    ground.rotation.x = -Math.PI / 2;
    ground.position.y = -0.6;
    scene.add(ground);

    function makeCircleTexture({ color = '#ffffff', soft = true } = {}) {
      const c = document.createElement('canvas');
      c.width = 64; c.height = 64;
      const g = c.getContext('2d');
      g.clearRect(0, 0, c.width, c.height);
      const r = 26;
      const cx = 32, cy = 32;
      const grd = g.createRadialGradient(cx, cy, soft ? 4 : 20, cx, cy, r);
      grd.addColorStop(0, color);
      grd.addColorStop(1, 'rgba(255,255,255,0)');
      g.fillStyle = grd;
      g.beginPath(); g.arc(cx, cy, r, 0, Math.PI * 2); g.fill();
      const tex = new THREE.CanvasTexture(c);
      tex.needsUpdate = true;
      return tex;
    }
    function makePetalTexture() {
      const c = document.createElement('canvas');
      c.width = 64; c.height = 64;
      const g = c.getContext('2d');
      g.clearRect(0, 0, 64, 64);
      g.translate(32, 32);
      g.rotate(-0.25);

      // 花びら感を出すために「先端が丸く、根元に切れ込みがある」形を描く
      const base = g.createRadialGradient(0, -6, 2, 0, -6, 30);
      base.addColorStop(0, 'rgba(255,210,225,0.95)');
      base.addColorStop(0.55, 'rgba(255,175,205,0.75)');
      base.addColorStop(1, 'rgba(255,175,205,0.0)');
      g.fillStyle = base;

      g.beginPath();
      g.moveTo(0, -28);
      g.bezierCurveTo(18, -22, 18, 10, 0, 26);
      g.bezierCurveTo(-18, 10, -18, -22, 0, -28);
      g.closePath();
      g.fill();

      // 根元の切れ込み(少しだけ透明に抜く)
      g.globalCompositeOperation = 'destination-out';
      g.fillStyle = 'rgba(0,0,0,0.6)';
      g.beginPath();
      g.ellipse(0, 18, 7.5, 5.5, 0, 0, Math.PI * 2);
      g.fill();
      g.globalCompositeOperation = 'source-over';

      // 輪郭をほんの少し(雪っぽさを減らす)
      g.strokeStyle = 'rgba(255,120,160,0.28)';
      g.lineWidth = 2;
      g.beginPath();
      g.moveTo(0, -27);
      g.bezierCurveTo(17, -21, 17, 9, 0, 25);
      g.bezierCurveTo(-17, 9, -17, -21, 0, -27);
      g.closePath();
      g.stroke();
      const tex = new THREE.CanvasTexture(c);
      tex.needsUpdate = true;
      return tex;
    }
    function makeLeafTexture() {
      const c = document.createElement('canvas');
      c.width = 64; c.height = 64;
      const g = c.getContext('2d');
      g.clearRect(0, 0, 64, 64);
      g.translate(32, 32);
      const grd = g.createLinearGradient(-10, -26, 12, 26);
      grd.addColorStop(0, 'rgba(255,122,48,0.95)');
      grd.addColorStop(1, 'rgba(170,40,0,0.0)');
      g.fillStyle = grd;
      g.beginPath();
      g.ellipse(0, 0, 14, 24, 0.6, 0, Math.PI * 2);
      g.fill();
      g.strokeStyle = 'rgba(255,255,255,0.25)';
      g.lineWidth = 2;
      g.beginPath(); g.moveTo(-6, -18); g.lineTo(8, 18); g.stroke();
      const tex = new THREE.CanvasTexture(c);
      tex.needsUpdate = true;
      return tex;
    }

    function makeFallingPoints({ count, texture, color, size, area }) {
      const geo = new THREE.BufferGeometry();
      const pos = new Float32Array(count * 3);
      const vel = new Float32Array(count * 3);
      for (let i = 0; i < count; i++) {
        const x = (Math.random() - 0.5) * area.x;
        const y = Math.random() * area.y;
        const z = (Math.random() - 0.5) * area.z;
        pos[i*3+0] = x; pos[i*3+1] = y; pos[i*3+2] = z;
        vel[i*3+0] = (Math.random() - 0.5) * 0.3;
        vel[i*3+1] = -(0.25 + Math.random() * 0.35);
        vel[i*3+2] = (Math.random() - 0.5) * 0.3;
      }
      geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
      geo.setAttribute('velocity', new THREE.BufferAttribute(vel, 3));
      const mat = new THREE.PointsMaterial({
        color,
        size,
        map: texture,
        transparent: true,
        depthWrite: false,
        blending: THREE.NormalBlending,
      });
      const pts = new THREE.Points(geo, mat);
      // 毎フレーム getAttribute を呼ばないよう、参照を保持しておく
      pts.userData.area = area;
      pts.userData.posAttr = geo.getAttribute('position');
      pts.userData.velAttr = geo.getAttribute('velocity');
      return pts;
    }

    // 桜は Points だと「粒」に寄りやすいので、板ポリ(InstancedMesh)で花びら感を出す
    function makeSakuraPetals({ count = 700, texture }) {
      const geo = new THREE.PlaneGeometry(0.28, 0.28);
      const mat = new THREE.MeshStandardMaterial({
        color: 0xffffff,
        map: texture,
        transparent: true,
        depthWrite: false,
        side: THREE.DoubleSide,
        roughness: 0.95,
        metalness: 0.0
      });
      const mesh = new THREE.InstancedMesh(geo, mat, count);
      mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
      mesh.frustumCulled = false;

      const data = [];
      const dummy = new THREE.Object3D();
      for (let i = 0; i < count; i++) {
        data.push({
          x: (Math.random() - 0.5) * 18,
          y: Math.random() * 10,
          z: (Math.random() - 0.5) * 18,
          fall: 0.55 + Math.random() * 0.55,
          drift: 0.35 + Math.random() * 0.55,
          spin: 1.2 + Math.random() * 2.6,
          wobble: 1.0 + Math.random() * 1.6,
          phase: Math.random() * 10,
          ry: Math.random() * Math.PI * 2,
        });
      }

      function update(dt, t) {
        for (let i = 0; i < count; i++) {
          const d = data[i];
          d.phase += dt;
          d.y -= d.fall * dt;
          const wx = Math.sin(d.phase * d.wobble) * d.drift;
          const wz = Math.cos(d.phase * (d.wobble * 0.9)) * d.drift;
          d.x += wx * dt;
          d.z += wz * dt;
          if (d.y < -0.6) {
            d.x = (Math.random() - 0.5) * 18;
            d.y = 10 + Math.random() * 4;
            d.z = (Math.random() - 0.5) * 18;
          }

          const tilt = Math.sin(d.phase * 2.1) * 0.7;
          dummy.position.set(d.x, d.y, d.z);
          dummy.rotation.set(tilt, d.ry + d.phase * 0.35, d.phase * d.spin);
          const s = 0.75 + Math.sin(d.phase * 1.7) * 0.08;
          dummy.scale.set(s, s, s);
          dummy.updateMatrix();
          mesh.setMatrixAt(i, dummy.matrix);
        }
        mesh.instanceMatrix.needsUpdate = true;
      }

      mesh.userData.update = update;
      return mesh;
    }

    const sakura = makeSakuraPetals({ count: 820, texture: makePetalTexture() });
    scene.add(sakura);

    const snow = makeFallingPoints({
      count: 1600,
      texture: makeCircleTexture({ color: 'rgba(255,255,255,0.9)', soft: true }),
      color: 0xffffff,
      size: 0.08,
      area: new THREE.Vector3(18, 10, 18)
    });
    snow.visible = false;
    scene.add(snow);

    function makeFireworks({ maxParticles = 1800 }) {
      const geo = new THREE.BufferGeometry();
      const pos = new Float32Array(maxParticles * 3);
      const vel = new Float32Array(maxParticles * 3);
      const life = new Float32Array(maxParticles);
      for (let i = 0; i < maxParticles; i++) {
        pos[i*3+0] = 0; pos[i*3+1] = -999; pos[i*3+2] = 0;
        vel[i*3+0] = 0; vel[i*3+1] = 0; vel[i*3+2] = 0;
        life[i] = 0;
      }
      geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
      geo.setAttribute('velocity', new THREE.BufferAttribute(vel, 3));
      geo.setAttribute('life', new THREE.BufferAttribute(life, 1));

      const mat = new THREE.PointsMaterial({
        color: 0xfff4b0,
        size: 0.12,
        map: makeCircleTexture({ color: 'rgba(255,220,120,0.95)', soft: true }),
        transparent: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending
      });
      const pts = new THREE.Points(geo, mat);
      pts.frustumCulled = false;
      // 参照をキャッシュ(update/burst で高速化)
      const posAttr = geo.getAttribute('position');
      const velAttr = geo.getAttribute('velocity');
      const lifeAttr = geo.getAttribute('life');

      function burst(origin) {
        const p = posAttr;
        const v = velAttr;
        const l = lifeAttr;
        const need = 600;
        let spawned = 0;
        for (let i = 0; i < l.count && spawned < need; i++) {
          if (l.array[i] > 0) continue;
          const theta = Math.random() * Math.PI * 2;
          const phi = Math.acos(THREE.MathUtils.randFloat(-1, 1));
          const sp = 2.8 + Math.random() * 2.4;
          v.array[i*3+0] = Math.cos(theta) * Math.sin(phi) * sp;
          v.array[i*3+1] = Math.cos(phi) * sp;
          v.array[i*3+2] = Math.sin(theta) * Math.sin(phi) * sp;
          p.array[i*3+0] = origin.x;
          p.array[i*3+1] = origin.y;
          p.array[i*3+2] = origin.z;
          l.array[i] = 1.6 + Math.random() * 0.8;
          spawned++;
        }
        p.needsUpdate = true;
        v.needsUpdate = true;
        l.needsUpdate = true;
      }

      pts.userData.burst = burst;
      pts.userData.posAttr = posAttr;
      pts.userData.velAttr = velAttr;
      pts.userData.lifeAttr = lifeAttr;
      return pts;
    }

    const fireworks = makeFireworks({ maxParticles: 2200 });
    fireworks.visible = false;
    scene.add(fireworks);

    // 打ち上げ(ロケット)+軌跡(トレイル)
    function makeRocketTrail({ max = 900 }) {
      const geo = new THREE.BufferGeometry();
      const pos = new Float32Array(max * 3);
      const vel = new Float32Array(max * 3);
      const life = new Float32Array(max);
      for (let i = 0; i < max; i++) {
        pos[i*3+0] = 0; pos[i*3+1] = -999; pos[i*3+2] = 0;
        vel[i*3+0] = 0; vel[i*3+1] = 0; vel[i*3+2] = 0;
        life[i] = 0;
      }
      geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
      geo.setAttribute('velocity', new THREE.BufferAttribute(vel, 3));
      geo.setAttribute('life', new THREE.BufferAttribute(life, 1));

      const mat = new THREE.PointsMaterial({
        color: 0xfff0c8,
        size: 0.11,
        map: makeCircleTexture({ color: 'rgba(255,220,160,0.85)', soft: true }),
        transparent: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending
      });
      const pts = new THREE.Points(geo, mat);
      pts.frustumCulled = false;
      const posAttr = geo.getAttribute('position');
      const velAttr = geo.getAttribute('velocity');
      const lifeAttr = geo.getAttribute('life');

      function spawnTrail(x, y, z, vx, vy, vz, ttl) {
        const p = posAttr;
        const v = velAttr;
        const l = lifeAttr;
        for (let i = 0; i < l.count; i++) {
          if (l.array[i] > 0) continue;
          p.array[i*3+0] = x;
          p.array[i*3+1] = y;
          p.array[i*3+2] = z;
          v.array[i*3+0] = vx;
          v.array[i*3+1] = vy;
          v.array[i*3+2] = vz;
          l.array[i] = ttl;
          p.needsUpdate = true;
          v.needsUpdate = true;
          l.needsUpdate = true;
          return;
        }
      }

      pts.userData.spawnTrail = spawnTrail;
      pts.userData.posAttr = posAttr;
      pts.userData.velAttr = velAttr;
      pts.userData.lifeAttr = lifeAttr;
      return pts;
    }

    const rocketTrail = makeRocketTrail({ max: 1100 });
    rocketTrail.visible = false;
    scene.add(rocketTrail);

    const rocket = {
      active: false,
      x: 0, y: 0, z: 0,
      vx: 0, vy: 0, vz: 0,
      targetY: 5.0
    };

    // ロケット本体(明るい点を動かして「打ち上げ」を視認させる)
    const rocketSprite = new THREE.Sprite(
      new THREE.SpriteMaterial({
        map: makeCircleTexture({ color: 'rgba(255,255,255,0.95)', soft: true }),
        color: 0xfff0d5,
        transparent: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending
      })
    );
    rocketSprite.scale.set(0.6, 0.6, 0.6);
    rocketSprite.visible = false;
    scene.add(rocketSprite);

    function makeLeaves({ count = 420, texture }) {
      const geo = new THREE.PlaneGeometry(0.5, 0.5);
      const mat = new THREE.MeshStandardMaterial({
        color: 0xffffff,
        map: texture,
        transparent: true,
        depthWrite: false,
        side: THREE.DoubleSide,
        roughness: 0.9,
        metalness: 0.0
      });
      const mesh = new THREE.InstancedMesh(geo, mat, count);
      mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
      mesh.frustumCulled = false;

      const data = [];
      const dummy = new THREE.Object3D();
      for (let i = 0; i < count; i++) {
        data.push({
          x: (Math.random() - 0.5) * 16,
          y: Math.random() * 8 + 1,
          z: (Math.random() - 0.5) * 16,
          ry: Math.random() * Math.PI * 2,
          rz: (Math.random() - 0.5) * 0.6,
          fall: 0.4 + Math.random() * 0.6,
          sway: 0.6 + Math.random() * 1.4,
          phase: Math.random() * 10
        });
      }
      function update(dt, t) {
        for (let i = 0; i < count; i++) {
          const d = data[i];
          d.phase += dt;
          d.y -= d.fall * dt;
          if (d.y < -0.2) {
            d.y = 8 + Math.random() * 3;
            d.x = (Math.random() - 0.5) * 16;
            d.z = (Math.random() - 0.5) * 16;
          }
          const sx = Math.sin(d.phase * d.sway) * 0.35;
          dummy.position.set(d.x + sx, d.y, d.z);
          dummy.rotation.set(0, d.ry + sx * 0.7, d.rz + Math.sin(t * 1.2 + d.phase) * 0.4);
          const s = 0.65 + Math.sin(d.phase) * 0.08;
          dummy.scale.set(s, s, s);
          dummy.updateMatrix();
          mesh.setMatrixAt(i, dummy.matrix);
        }
        mesh.instanceMatrix.needsUpdate = true;
      }
      mesh.userData.update = update;
      return mesh;
    }

    const leaves = makeLeaves({ count: 520, texture: makeLeafTexture() });
    leaves.visible = false;
    scene.add(leaves);

    function resetSummerArtifacts() {
      // ロケット状態を止める
      rocket.active = false;
      rocketSprite.visible = false;

      // trail / fireworks の残り粒を強制的に消す
      for (const pts of [rocketTrail, fireworks]) {
        const p = pts.userData.posAttr;
        const l = pts.userData.lifeAttr;
        if (!p || !l) continue;
        for (let i = 0; i < l.count; i++) {
          l.array[i] = 0;
          p.array[i*3+1] = -999;
        }
        p.needsUpdate = true;
        l.needsUpdate = true;
      }
    }

    function applySeasonLook() {

      if (season === Season.spring) {
        resetSummerArtifacts();
        renderer.setClearColor(0x0b1020, 1);
        scene.fog = new THREE.Fog(0x0b1020, 10, 60);
        ground.material.color.setHex(0x0e1733);
        amb.intensity = 0.55; dir.intensity = 0.9;
        sakura.visible = true; snow.visible = false; fireworks.visible = false; leaves.visible = false;
      } else if (season === Season.summer) {
        renderer.setClearColor(0x070a14, 1);
        scene.fog = new THREE.Fog(0x070a14, 14, 70);
        ground.material.color.setHex(0x0a0f22);
        amb.intensity = 0.35; dir.intensity = 0.55;
        sakura.visible = false; snow.visible = false; fireworks.visible = true; leaves.visible = false;
        rocketTrail.visible = true;
        rocketSprite.visible = true;
      } else if (season === Season.autumn) {
        resetSummerArtifacts();
        renderer.setClearColor(0x120a08, 1);
        scene.fog = new THREE.Fog(0x120a08, 9, 55);
        ground.material.color.setHex(0x2a160f);
        amb.intensity = 0.6; dir.intensity = 1.05;
        sakura.visible = false; snow.visible = false; fireworks.visible = false; leaves.visible = true;
      } else if (season === Season.winter) {
        resetSummerArtifacts();
        renderer.setClearColor(0x0a1322, 1);
        scene.fog = new THREE.Fog(0x0a1322, 6, 45);
        ground.material.color.setHex(0x1a2438);
        amb.intensity = 0.75; dir.intensity = 0.95;
        sakura.visible = false; snow.visible = true; fireworks.visible = false; leaves.visible = false;
        rocketTrail.visible = false;
        rocketSprite.visible = false;
      }
    }
    applySeasonLook();

    function resize() {
      camera.aspect = innerWidth / innerHeight;
      camera.updateProjectionMatrix();
      renderer.setSize(innerWidth, innerHeight);
    }
    addEventListener('resize', resize);

    function updateFalling(points, dt) {
      const p = points.userData.posAttr;
      const v = points.userData.velAttr;
      const a = points.userData.area;
      for (let i = 0; i < p.count; i++) {
        p.array[i*3+0] += v.array[i*3+0] * dt;
        p.array[i*3+1] += v.array[i*3+1] * dt;
        p.array[i*3+2] += v.array[i*3+2] * dt;
        if (p.array[i*3+1] < -0.2) {
          p.array[i*3+0] = (Math.random() - 0.5) * a.x;
          p.array[i*3+1] = a.y;
          p.array[i*3+2] = (Math.random() - 0.5) * a.z;
        }
        v.array[i*3+0] += (Math.random() - 0.5) * 0.02 * dt;
        v.array[i*3+2] += (Math.random() - 0.5) * 0.02 * dt;
      }
      p.needsUpdate = true;
      v.needsUpdate = true;
    }

    function updateFireworks(dt) {
      const p = fireworks.userData.posAttr;
      const v = fireworks.userData.velAttr;
      const l = fireworks.userData.lifeAttr;
      for (let i = 0; i < l.count; i++) {
        const life = l.array[i];
        if (life <= 0) continue;
        l.array[i] = Math.max(0, life - dt);
        p.array[i*3+0] += v.array[i*3+0] * dt;
        p.array[i*3+1] += v.array[i*3+1] * dt;
        p.array[i*3+2] += v.array[i*3+2] * dt;
        v.array[i*3+1] -= 3.6 * dt;
        v.array[i*3+0] *= (1 - 0.18 * dt);
        v.array[i*3+1] *= (1 - 0.18 * dt);
        v.array[i*3+2] *= (1 - 0.18 * dt);
        if (l.array[i] === 0) p.array[i*3+1] = -999;
      }
      p.needsUpdate = true;
      v.needsUpdate = true;
      l.needsUpdate = true;
    }

    function updateRocket(dt) {
      if (!rocket.active) {
        rocketSprite.visible = false;
        return;
      }
      rocket.x += rocket.vx * dt;
      rocket.y += rocket.vy * dt;
      rocket.z += rocket.vz * dt;

      // 軌跡(少し散らす)
      // 1フレームで複数粒を出して、筋っぽく見せる
      for (let i = 0; i < 3; i++) {
        rocketTrail.userData.spawnTrail(
          rocket.x + (Math.random() - 0.5) * 0.05,
          rocket.y + (Math.random() - 0.5) * 0.05,
          rocket.z + (Math.random() - 0.5) * 0.05,
          (Math.random() - 0.5) * 0.22,
          -0.55 - Math.random() * 0.65,
          (Math.random() - 0.5) * 0.22,
          0.75 + Math.random() * 0.45
        );
      }

      rocketSprite.position.set(rocket.x, rocket.y, rocket.z);
      rocketSprite.visible = true;

      // 到達したら爆発
      if (rocket.y >= rocket.targetY) {
        rocket.active = false;
        rocketSprite.visible = false;
        fireworks.userData.burst(new THREE.Vector3(rocket.x, rocket.y, rocket.z));
      }
    }

    function updateTrailPoints(points, dt) {
      const p = points.userData.posAttr;
      const v = points.userData.velAttr;
      const l = points.userData.lifeAttr;
      for (let i = 0; i < l.count; i++) {
        const life = l.array[i];
        if (life <= 0) continue;
        l.array[i] = Math.max(0, life - dt);
        p.array[i*3+0] += v.array[i*3+0] * dt;
        p.array[i*3+1] += v.array[i*3+1] * dt;
        p.array[i*3+2] += v.array[i*3+2] * dt;
        v.array[i*3+1] -= 0.9 * dt;
        v.array[i*3+0] *= (1 - 0.35 * dt);
        v.array[i*3+1] *= (1 - 0.35 * dt);
        v.array[i*3+2] *= (1 - 0.35 * dt);
        if (l.array[i] === 0) p.array[i*3+1] = -999;
      }
      p.needsUpdate = true;
      v.needsUpdate = true;
      l.needsUpdate = true;
    }

    let t = 0;
    let last = performance.now();
    let fireTimer = 0;
    function loop(now) {
      const dt = Math.min((now - last) / 1000, 0.05);
      last = now;
      t += dt;

      camera.position.x = Math.sin(t * 0.25) * 0.35;
      camera.lookAt(0, 1.0, 0);

      if (season === Season.spring) {
        sakura.userData.update(dt, t);
      } else if (season === Season.winter) {
        updateFalling(snow, dt);
        snow.material.size = 0.075 + Math.sin(t * 0.6) * 0.01;
      } else if (season === Season.summer) {
        // 打ち上げ→爆発→余韻(軌跡)まで見せる
        updateFireworks(dt);
        updateTrailPoints(rocketTrail, dt);
        updateRocket(dt);
        fireTimer -= dt;
        if (fireTimer <= 0) {
          fireTimer = 1.0 + Math.random() * 0.9;
          // ロケットを再発射(発射中は追加しない)
          if (!rocket.active) {
            rocket.active = true;
            rocket.x = (Math.random() - 0.5) * 3.2;
            rocket.y = -1.2;
            rocket.z = -4.0 + (Math.random() - 0.5) * 1.4;
            rocket.vx = (Math.random() - 0.5) * 0.18;
            rocket.vy = 6.2 + Math.random() * 0.8;
            rocket.vz = (Math.random() - 0.5) * 0.12;
            rocket.targetY = 3.9 + Math.random() * 1.7;
          }
        }
      } else if (season === Season.autumn) {
        leaves.userData.update(dt, t);
      }

      renderer.render(scene, camera);
      requestAnimationFrame(loop);
    }
    requestAnimationFrame(loop);
  </script>
</body>
</html>

執筆者

中川 拓哉(NTT西日本 デジタル革新本部 デジタル改革推進部所属)
NTT西日本のWebアプリケーションの開発・運営に従事。
好きな技術スタック:TypeScript, Vue.js, GraphQL, Laravel

参考資料・出典

本記事を執筆するにあたり、以下のサイト・資料を参考にしました。

商標

  • 「JavaScript」は、Oracle Corporation およびその子会社の米国およびその他の国における商標または登録商標です。
  • 「Firefox」は、Mozilla Foundation の商標です。
  • 「Safari」はApple Inc. の商標です。
  • 「Chromium」はGoogle LLC に関連するプロジェクト名です。
  • 「Google Chrome」は、Google LLC の商標です。
  • 「Three.js」は、Three.jsプロジェクトに関連する名称です(詳細は公式ドキュメントを参照してください)。
  • 「Visual Studio Code」はMicrosoft Corporation の商標です。
  • 「Apache HTTP Server」および「Apache」は、Apache Software Foundation の商標です。
  • 記載のその他の会社名・製品名は、それぞれ各社の商標もしくは登録商標です。

© NTT WEST, Inc.