目次
映えるサイトが作りたい!!!!!
今回作ったのは、泡のような3Dオブジェクトが画面内でゆっくり揺れ、ユーザーがドラッグやスワイプで向きを変えながら眺められるWebページです。
表現したいイメージが先にあったので、ブラウザ上でそれをどう実現するかを模索しました。
こういう表現は端末スペックにかなり左右されます。
開発者としてはスペックの良いPCで触ってほしいところですが、せっかくなら現代で圧倒的普及率を誇るスマホからも体験してもらいたい。
そこで、見ためだけでなく、スマホから触ったときの負荷や操作感も調整しました。
この記事はThree.jsの入門解説ではなく、作りたい見ためをWeb上でどのように形にしていったかをまとめた制作記録です。
作ったもの
このサイトにアクセスすると、実際に触って遊べます🫧
PCからこのWebサイトを操作しているデモ動画です。シャボン玉のような3Dオブジェクトのまとまりをドラッグして回している様子です。またスライダーで見ためを調整できます。
スマホからもタッチ操作で回転できます。
実装について
今回のページは、Next.js で制作しています。
3D描画には React Three Fiber を使い、できあがったページは Netlify で配信しています。
ページ側の page.tsx では、メタ情報を設定して BubblesSim コンポーネントを呼び出すだけにしました。
3Dオブジェクトの見ためや動き、操作、スマホ向けの負荷調整は、ほぼ BubblesSim.tsx にまとめています。
背景や操作パネルまわりの見ためは page.module.css に置いています。
最初から細かくコンポーネントを分けすぎると、どの値が見ためや操作感に効いているのか追いにくくなります。
今回のような実験的な3Dオブジェクトでは、泡の数、動きの強さ、透明感、スマホでの負荷などを、実際に画面で見ながら何度も調整する必要がありました。
そのため、きれいな部品分けよりも、まずは触って楽しい状態まで持っていくことを優先しています。
使った技術
- React Three Fiber
- Three.js
- @react-three/drei
透明な泡を表現🫧
透明だけでは足りない
最初は、3Dオブジェクトを透明にすれば、それだけで泡っぽく見えると思っていました。
しかし実際に表示してみると、思ったようなシャボン玉感にはなりませんでした。
透明ではあるものの、全体が濁って見えたり、青っぽいガラス玉のように見えたりして、軽さがありません。

この時点では、オブジェクトそのものの設定だけを見直せば解決すると思っていました。
色を薄くしたり、透過や反射の値を変えたりしましたが、それだけではなかなか泡らしくなりません。
そこで、マテリアルだけでなく、周囲の光も含めて調整することにしました。 現在の実装では MeshPhysicalMaterial を使い、透け感、表面のツヤ、うっすらした虹色の反射を重ねています。
const material = useMemo(
() =>
new MeshPhysicalMaterial({
color: '#d6e3ec',
roughness: 0.16,
transmission: 1,
thickness: 0.18,
clearcoat: 0.9,
envMapIntensity: 1.35,
iridescence: 0.6,
sheen: 0.36
}),
[]
);ただ、透明なオブジェクトはマテリアルの数値だけでは印象が決まりません。
オブジェクトに何が反射するか、背景にどんな色があるかが、見え方に大きな影響を与えます。
そのため、Environment と Lightformer を使って、反射として映り込む光を置きました。
<Environment background={false} resolution={64} blur={0.4}>
<Lightformer
intensity={3.6}
position={[2.5, 0.5, 2]}
scale={[3, 3, 1]}
color="#e1f1ff"
/>
<Lightformer
intensity={1.1}
position={[0.6, 1.2, -2]}
scale={[2.4, 2.4, 1]}
color="#ffd6ef"
/>
</Environment>
Lightformer を入れることで、表面に明るいハイライトが入り、ただ濁った球体だった見ためが少しずつ泡に近づきました。

この調整でわかったのは、泡っぽさは「透明度」だけでは作れないということです。
透明にするだけでは、むしろ重たく見えたり、濁って見えたりします。
最終的にわかったのは、透明な泡っぽさは、1つの設定だけでは作れないということです。
形、点の配置、透明度、反射、光、背景をまとめて調整して、ようやくそれらしく見えるようになりました。
ランダムすぎない配置
一方で、点の置き方を完全にランダムにすると、形が偏りやすくなります。
見ためが一部に寄ったり、意図しない穴が空いたりして、きれいな泡というより崩れたかたまりに見えてしまいました。

そのため、点は3Dグリッド上に大まかに配置し、そこに少しだけランダムな揺らぎを加えています。
// 3Dグリッドにジッターを入れて分散配置(ドーナツ化の防止)
const buildAnchors = (count: number) => {
const gridSize = Math.max(1, Math.ceil(Math.cbrt(count)));
const pad = 0.06;
const span = 1 - pad * 2;
return Array.from({ length: count }, (_, index) => {
const seed = index + 1;
const xIndex = index % gridSize;
const yIndex = Math.floor(index / gridSize) % gridSize;
const zIndex = Math.floor(index / (gridSize * gridSize));
const jitterX = (hash(seed * 1.3) - 0.5) * 0.7;
const jitterY = (hash(seed * 3.1) - 0.5) * 0.7;
const jitterZ = (hash(seed * 5.7) - 0.5) * 0.7;
return {
x: pad + ((xIndex + 0.5 + jitterX) / gridSize) * span,
y: pad + ((yIndex + 0.5 + jitterY) / gridSize) * span,
z: pad + ((zIndex + 0.5 + jitterZ) / gridSize) * span,
wobble: 0.1 + 0.12 * hash(seed * 2.9),
phase: Math.PI * 2 * hash(seed * 7.1)
};
});
};
泡の元になる点は、完全にランダムに置くのではなく、まず見えない3Dのマス目に沿って配置し、そこから少しだけ位置をずらしています。
こうすると、全体のまとまりを保ちながら、機械的すぎない自然なばらつきを出せるようになりました。
泡をふよふよ動かす
泡の形ができたので、次は動きです。
今回のデモでは、厳密な物理シミュレーションは使っていません。
水中の泡のような挙動を正確に再現するというより、画面で見たときに「ふよふよしている」と感じられることを優先しました。
揺れ周期をずらす
動きの中心になっているのは、useFrameの中で各点の位置を少しずつずらす処理です。
useFrame(({ clock }) => {
const time = clock.getElapsedTime() * speed;
marching.reset();
marching.isolation = isolation;
anchors.forEach((anchor, index) => {
const wobble = anchor.wobble * wobbleScale;
const phase = anchor.phase + index * 0.15;
const x = anchor.x + wobble * Math.sin(time * 0.7 + phase);
const y = anchor.y + wobble * Math.cos(time * 0.9 + phase * 1.1);
const z = anchor.z + wobble * Math.sin(time * 0.6 + phase * 0.7);
marching.addBall(x, y, z, effectiveBubbleSize, 10);
});
marching.update();
});
元になる点の位置に sin / cos の揺れを足しています。
ただ、全ての点を同じタイミングで動かすと、全体が一斉に揺れるだけになってしまいます。
それだと少し機械的で、「ふよふよ」というより、決まったパターンで動くオブジェクトに見えてしまいました。
そこで、各点ごとに wobble と phase を持たせています。
return {
x: pad + ((xIndex + 0.5 + jitterX) / gridSize) * span,
y: pad + ((yIndex + 0.5 + jitterY) / gridSize) * span,
z: pad + ((zIndex + 0.5 + jitterZ) / gridSize) * span,
wobble: 0.1 + 0.12 * hash(seed * 2.9),
phase: Math.PI * 2 * hash(seed * 7.1)
};
wobble は揺れ幅、phase は揺れ始めるタイミングのようなものです。
点ごとに少しずつ違う値を持たせることで、同じ形がそのまま移動するのではなく、泡のかたまり全体がゆるく変形しているように見せています。
また、X/Y/Zで揺れ方も少し変えています。
const x = anchor.x + wobble * Math.sin(time * 0.7 + phase);
const y = anchor.y + wobble * Math.cos(time * 0.9 + phase * 1.1);
const z = anchor.z + wobble * Math.sin(time * 0.6 + phase * 0.7);3方向すべてを同じ式で動かすと、動きが単調になります。
そこで、軸ごとに少し違う速さや位相を使い、同じ場所を往復しているだけに見えないようにしました。
このあたりは、数値としての正しさよりも、見たときの気持ちよさを優先して調整しています。
実際、途中で泡の揺れを強める調整も入れています。
動きが小さすぎると上品には見えますが、画面上では少し静かすぎて、「ふよふよ動く」感じが弱くなってしまうためです。
ぐりぐり回して操作できるように
3Dオブジェクトは、ただ動いているだけでなく、触って回せるようにしました。
回転操作には OrbitControls を使っています。
R3Fでは @react-three/drei から読み込むことで、比較的少ないコードでカメラ操作を追加できます。
現在位置を迷わせない
<OrbitControls enablePan={false} enableDamping />ここでは enablePan={false} を指定しています。
パン操作を有効にすると、画面内でカメラを横や縦にずらせるようになります。
自由度は上がりますが、今回のような範囲の小さな3Dオブジェクトでは、触っているうちに対象を画面の外へ動かしてしまい、何を見ているのかわかりにくくなる可能性があります。
そのため、今回はパン操作を止めて、回転を中心にしました。
一方で、ズームイン・ズームアウトはできます。
近づいて質感を見たり、少し引いて全体の形を見たりできるので、見る距離の自由度は残しています。
自由に動かせること自体よりも、触ったときに迷子にならないことの方が大事だと感じました。
「触れる」案内をする
また、初めてページを開いた人にもぐりぐりと操作できることが伝わるように、画面上には Drag to rotateのガイドも表示しています。
{showGuide && (
<div className={`${styles.dragGuide} ${isGuideFading ? styles.dragGuideFade : ''}`} aria-hidden="true">
<div className={styles.dragGuideText}>
Drag to rotate
</div>
</div>
)}
この表示は一定時間が経過するか、操作したら、フェードアウトするようにしています。
ずっと表示されていると邪魔ですが、何も案内がないと「触れるページ」だと気づいてもらえないためです。
スマホ向け負荷調整
PCでそれなりに動いても、スマホで触ると急に印象が変わります。
画面は小さく、指で操作するので、少しの引っかかりや操作のぶつかりが気になります。
特に今回のように MarchingCubes で形を毎回作り直す処理は、見ためが面白いぶん負荷も高くなりやすいです。
更新頻度を落とす
まず、スマホではメッシュの更新頻度を少し落としています。
ここも実機を触りながら調整した部分です。
const MOBILE_UPDATE_INTERVAL_SECONDS = 1 / 30;
const updateInterval = deviceTier === 'mobile' ? MOBILE_UPDATE_INTERVAL_SECONDS : 0;
const canUpdate = updateInterval === 0 || elapsed - lastUpdateTimeRef.current >= updateInterval;
if (!canUpdate) {
return;
}PCでは毎フレーム更新しても問題になりにくいですが、スマホでは同じ処理が重く感じることがあります。
そこで、スマホの場合は更新を30fps相当に間引き、毎フレーム MarchingCubes を再生成しないようにしました。
見ためを少しでもなめらかにしたくて全部更新したくなりますが、スマホでは触ったときに破綻しないことを優先しました。
さらに、FPS(フレームレート)を見ながらDPR(デバイスピクセル比)の上限も調整しています。
const ADAPTIVE_DPR = {
min: 1.1,
max: 2,
downStep: 0.15,
upStep: 0.1,
lowFps: 45,
recoverFps: 54
} as const;DPRを上げると画面はきれいになりますが、そのぶん描画負荷も上がります。逆に下げると軽くなりますが、ぼやけて見えやすくなります。
そのため、FPSが落ちたらDPRの上限を下げ、余裕が戻ったら少しずつ上げるようにしました。
この数値が絶対の正解というより、実機で触りながら「このくらいなら見ためと操作感のバランスが取れそう」と検討を重ねつつ、調整した部分です。
操作をぶつけない
スマホで困ったのが、3D操作とUI操作のぶつかりです。
このページでは、画面をドラッグすると3Dオブジェクトを回せます。
一方で、操作パネルにはスライダーもあります。
何も対策しないと、スライダー(UI)を動かしたいだけなのに、Canvas側(3D)の操作まで反応してしまうことがあります。
そこで、操作パネル・スライダーともにイベントの伝播を止めています。
<aside
className={styles.panel}
onPointerDown={(event) => event.stopPropagation()}
onPointerMove={(event) => event.stopPropagation()}
onTouchStart={(event) => event.stopPropagation()}
onTouchMove={(event) => event.stopPropagation()}
>スマホで触ることを考えると、かなり大事な改善でした。
画面をドラッグしたら3Dオブジェクトが回る。
スライダーを触ったら数値だけが変わる。
この当たり前を実現する地味な調整のおかげで、操作感が向上したと感じています。
また3Dオブジェクトを操作している間は、「いまは3D側を触っている」という状態を視覚的に伝えるために、操作パネルを少し透過させています。

おわりに
今回作ってみて感じたのは、3Dオブジェクトを表示することと、触って楽しいWebページにすることは別物だということです。
わたし自身、3DやWebGLに詳しい状態から始めたわけではありません。
それでも作りたいと思える理想形が先にあったので、必要な技術を調べながら少しずつ実装できました。
Webサイトは、文章や画像を載せるだけでなく、芸術的な表現も置ける場所です。
しかも一般的な作品と違って、ユーザーが直接触っても壊れることはありません。
自由にぐりぐり回して、好きなだけ遊んでもらえます✌️
(もちろん、ライブラリの更新やブラウザの仕様変更など、別の意味で壊れる要素はたくさんありますが...)
React Three Fiber、Three.js、@react-three/drei のようなライブラリがあると、ブラウザ上でも映える3D表現に挑戦しやすくなります。
興味が湧いた方はぜひ、Web上で自由に形にしてみてください🦭🤗





