HTMLに高度なアニメーションエフェクトを実装した
3D空間に表示したHTMLに、自由なエフェクトを付けられるようにした。
SVGに変換すると、CSSアニメーションが動かない。課題だった。
Canvas上で独自のアニメーションエフェクトを実装した。
なぜ作ったか
midori255までで、HTMLを3D空間に表示できるようになった。
でも、静的だった。動きがない。
通常のWebページなら、CSSアニメーションで簡単に動きを付けられる。
.element {
animation: fadeIn 2s ease-in;
}
でも、SVG変換すると、CSSアニメーションは無効になる。
対策が必要だった。
写真展示では、動きのあるUIが欲しい。静的な表示では物足りない。
自前でアニメーションシステムを作ることにした。
エフェクトの種類
フェード
透明度を徐々に変える。
表示・非表示の切り替えに使う。
最初は0.0から、1秒かけて1.0まで上げる。
fadeIn(duration = 1000) {
const start = performance.now();
const animate = (now) => {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
this.opacity = progress;
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
シンプル。でも、効果的。
ページが現れる時、突然ではなく、じわっと表示される。
スライド
位置をアニメーションさせる。
画面外から、スライドして入ってくる。
slideIn(fromX, fromY, toX, toY, duration = 1000) {
const position = this.mesh.position;
position.set(fromX, fromY, position.z);
const deltaX = toX - fromX;
const deltaY = toY - fromY;
this.runAnimation((progress) => {
const eased = this.easeOutCubic(progress);
position.x = fromX + deltaX * eased;
position.y = fromY + deltaY * eased;
}, duration);
}
イージング関数も追加。
線形補間だけだと、機械的な動きになる。
イージング関数を使うと、自然な加減速ができる。
easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
開始時は速く、終了時は遅く。
気持ちいい動きになった。
スケール
大きさを変える。
小さい状態から、拡大して表示。
scaleUp(fromScale, toScale, duration = 800) {
const animate = (progress) => {
const scale = fromScale + (toScale - fromScale) * progress;
this.mesh.scale.set(scale, scale, 1);
};
this.runAnimation(animate, duration);
}
フェードと組み合わせると、さらに良い。
透明度0、スケール0.5から始めて、同時にアニメーション。
ポップアップ風の表示になる。
回転
メッシュを回転させる。
カードがめくれるような演出。
rotateCard(duration = 1200) {
const animate = (progress) => {
this.mesh.rotation.y = Math.PI * progress;
};
this.runAnimation(animate, duration);
}
でも、回転中は見づらい。
回転の中間点で、HTMLの内容を切り替える。
if (progress > 0.5 && !this.contentSwitched) {
this.updateContent(newContent);
this.contentSwitched = true;
}
カードが反転して、裏面が表示される感じ。
面白い。
パーティクル
HTMLの周囲にパーティクルを配置。
キラキラした演出。
createParticles(count = 20) {
for (let i = 0; i < count; i++) {
const particle = new THREE.Sprite(particleMaterial);
particle.position.set(
Math.random() * width - width/2,
Math.random() * height - height/2,
0.1
);
this.particles.push(particle);
this.mesh.add(particle);
}
}
毎フレーム、パーティクルの位置と透明度を更新。
ゆっくり上昇して、消えていく。
視覚的に美しい。
でも、負荷が高かった。パーティクル数を制限する必要があった。
アニメーション管理
タイムライン
複数のアニメーションを順番に実行。
フェードイン → 移動 → スケールアップ。
class Timeline {
constructor() {
this.animations = [];
this.currentIndex = 0;
}
add(animation, delay = 0) {
this.animations.push({ animation, delay });
}
play() {
const current = this.animations[this.currentIndex];
if (!current) return;
setTimeout(() => {
current.animation();
this.currentIndex++;
this.play();
}, current.delay);
}
}
複雑な演出も、簡単に組み立てられる。
イベント駆動
アニメーション完了時にコールバック。
次のアクションを連鎖できる。
fadeIn(duration, onComplete) {
const animate = (progress) => {
this.opacity = progress;
if (progress >= 1 && onComplete) {
onComplete();
}
};
this.runAnimation(animate, duration);
}
「フェードイン完了 → ボタンを有効化」のような処理を実装。
ハマったポイント
requestAnimationFrameのタイミング
最初、複数のアニメーションを同時に実行すると、カクカクした。
原因は、requestAnimationFrame()を複数回呼んでいたこと。
各アニメーションが、それぞれrequestAnimationFrame()を呼ぶ。
無駄が多い。
統一した。
class AnimationManager {
constructor() {
this.animations = [];
this.running = false;
}
register(animation) {
this.animations.push(animation);
if (!this.running) {
this.running = true;
this.loop();
}
}
loop() {
const now = performance.now();
this.animations.forEach(anim => anim.update(now));
this.animations = this.animations.filter(anim => !anim.finished);
if (this.animations.length > 0) {
requestAnimationFrame(() => this.loop());
} else {
this.running = false;
}
}
}
1つのrequestAnimationFrame()で、全てのアニメーションを更新。
スムーズになった。
テクスチャ更新のタイミング
アニメーション中、HTMLの内容が変わることがある。
でも、SVGからテクスチャへの変換は負荷が高かった。
毎フレーム変換すると、フレームレートが落ちる。
フラグを立てて、変更があった時だけ変換。
updateContent(newContent) {
this.content = newContent;
this.needsUpdate = true;
}
update() {
if (this.needsUpdate) {
this._regenerateTexture();
this.needsUpdate = false;
}
// アニメーション処理
this.animations.forEach(anim => anim.update());
}
これで、20fpsが30fpsに改善した。
メモリリーク
パーティクルを大量に生成すると、メモリが増え続けた。
消えたパーティクルを削除していなかった。
明示的にremove()とdispose()。
removeParticle(particle) {
this.mesh.remove(particle);
particle.material.dispose();
particle.geometry.dispose();
}
これで、安定した。
パフォーマンス測定
エフェクトごとにフレームレートを測定した。
フェードのみ
30fps。軽い。
透明度を変えるだけ。ほとんど負荷なし。
スライド + フェード
25fps。許容範囲。
位置と透明度を更新。まだ軽い。
パーティクル20個
15fps。負荷が高かった。
各パーティクルの位置、透明度、サイズを毎フレーム更新。
CPU使用率が跳ね上がる。
パーティクルは特別な時だけ使うことにした。
試してみた結果
ページ切り替え
フェードアウト → 内容更新 → フェードイン。
スムーズ。気持ちいい。
ユーザーからも好評だった。
ポップアップ通知
画面下から、スライドして現れる。
3秒表示して、スライドで消える。
実用的。
でも、複数の通知が重なると、位置調整が面倒だった。
選択エフェクト
カードを選択すると、スケールアップ + 光のパーティクル。
視覚的なフィードバック。
「選んだ感」がある。
でも、パーティクルは5個まで。それ以上は負荷が高かった。
使ってみて
実際にエフェクトシステムを使ってみて、分かったことがある。
アニメーションは、必要最小限にすべき。
やりすぎると、重くなる。ユーザーも疲れる。
ポイントは以下の3つ:
- ページ遷移でフェード(スムーズな切り替え)
- 重要な要素でスケール(視線誘導)
- 特別な瞬間だけパーティクル(記念撮影完了時など)
これだけで十分だった。
次のmidori257で、さらにシンプルに整理した。
同じようなカスタムアニメーションを実装している方の参考になれば嬉しいです。
まとめ
今回は、HTMLに高度なアニメーションエフェクトを実装しました。
ポイントは以下の4つ:
- 独自のアニメーションシステム構築(CSS不可の環境でも対応)
- タイムラインとイベント駆動(複雑な演出も組み立て可能)
- AnimationManagerで一元管理(1つのrAFで全更新、30fps達成)
- メモリ管理徹底(パーティクル削除、テクスチャ更新制限)
CSSアニメーションが使えなくても、自前で実装できる。
これが、midori257の基盤になった。
さらに深く学ぶには
この記事で興味を持った方におすすめのリンク:
自分の関連記事:
最後まで読んでくださり、ありがとうございました。