threejs-graphics-optimizer について
THREE.js のグラフィックス開発者向けに、描画呼び出し、テクスチャ、レンダーループ、メモリ管理の最適化手法を示すスキルです。モバイル対応と性能計測も扱います。
主な機能
- 01描画呼び出しの削減とジオメトリ統合
- 02LOD と視錐台カリングの活用
- 03テクスチャとマテリアルの最適化
- 04モバイル向け描画設定
- 05リソース管理とメモリリーク防止
- 06FPS と GPU メモリの計測
活用シーン
- 01反復オブジェクトが多いシーンの最適化
- 02モバイル端末での描画負荷軽減
- 03フレームレート低下や GPU ボトルネックの調査
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ ovachiever/ droid-tings --skill threejs-graphics-optimizerインストール後、Claude Code で/threejs-graphics-optimizerを入力して読み込まれたか確認します。
詳細
THREE.js グラフィックス最適化
バージョン:1.0
対象:THREE.js とグラフィックスアプリケーションのパフォーマンス最適化
目的:モバイルを含むすべてのデバイスで、滑らかな 60fps のグラフィックス体験を実現する
哲学:パフォーマンス優先のグラフィックス
16ms の予算
目標:60 FPS = 1 フレームあたり 16.67ms
フレーム予算の内訳:
- JavaScript のロジック:約 5~8ms
- レンダリング(GPU):約 8~10ms
- ブラウザーのオーバーヘッド:約 2ms
16ms を超えると:フレームがドロップし、カクつきが発生します。
モバイルとデスクトップの現実
デスクトップ:高性能な GPU、大容量の VRAM、高いピクセル比
モバイル:制約のある GPU、限られた VRAM、バッテリーへの配慮、サーマルスロットリング
設計思想:モバイル向けに最適化し、デスクトップ向けにはスケールアップします(逆にしないでください)。
パート 1:最適化の基本原則
1. ドローコールを最小限に抑える
問題:各オブジェクトが 1 回のドローコールになります。オブジェクトが 1000 個なら、コールも 1000 回となり、処理が遅くなります。
解決策:ジオメトリの結合
// ❌ Bad: 100 draw calls for 100 cubes
for (let i = 0; i < 100; i++) {
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshBasicMaterial({ color: 0xff0000 })
const cube = new THREE.Mesh(geometry, material)
cube.position.set(i * 2, 0, 0)
scene.add(cube)
}
// ✅ Good: 1 draw call via InstancedMesh
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshBasicMaterial({ color: 0xff0000 })
const instancedMesh = new THREE.InstancedMesh(geometry, material, 100)
for (let i = 0; i < 100; i++) {
const matrix = new THREE.Matrix4()
matrix.setPosition(i * 2, 0, 0)
instancedMesh.setMatrixAt(i, matrix)
}
instancedMesh.instanceMatrix.needsUpdate = true
scene.add(instancedMesh)
使用する場面:
- 類似したオブジェクトが多数ある場合(パーティクル、木、敵)
- 配置が静的または準静的な場合
- マテリアルやジオメトリを共有している場合
2. 詳細度(LOD)
オブジェクトが遠くにある場合は、より単純なジオメトリを描画します。
const lod = new THREE.LOD()
// High detail (near camera)
const highDetailGeo = new THREE.IcosahedronGeometry(1, 3) // Many faces
const highDetailMesh = new THREE.Mesh(
highDetailGeo,
new THREE.MeshStandardMaterial({ color: 0x00d9ff })
)
lod.addLevel(highDetailMesh, 0) // Distance 0-10
// Medium detail
const medDetailGeo = new THREE.IcosahedronGeometry(1, 1)
const medDetailMesh = new THREE.Mesh(
medDetailGeo,
new THREE.MeshBasicMaterial({ color: 0x00d9ff })
)
lod.addLevel(medDetailMesh, 10) // Distance 10-50
// Low detail (far from camera)
const lowDetailGeo = new THREE.IcosahedronGeometry(1, 0)
const lowDetailMesh = new THREE.Mesh(
lowDetailGeo,
new THREE.MeshBasicMaterial({ color: 0x00d9ff })
)
lod.addLevel(lowDetailMesh, 50) // Distance 50+
scene.add(lod)
// Update LOD in render loop
function animate() {
lod.update(camera)
renderer.render(scene, camera)
}
3. 視錐台カリング(自動)
THREE.js は、カメラの視野外にあるオブジェクトを自動的にスキップします。次の方法で支援できます。
// ❌ Bad: Unnecessarily large bounding volumes
mesh.geometry.computeBoundingSphere()
mesh.geometry.boundingSphere.radius = 1000 // Too large!
// ✅ Good: Accurate bounding volumes
mesh.geometry.computeBoundingSphere() // Uses actual geometry size
mesh.geometry.computeBoundingBox()
4. テクスチャの最適化
テクスチャサイズは重要です:
- 4K テクスチャ(4096x4096):VRAM 64MB(非圧縮)
- 2K テクスチャ(2048x2048):VRAM 16MB
- 1K テクスチャ(1024x1024):VRAM 4MB
ルール:
- 見た目を損なわない範囲で、最小サイズのテクスチャを使用する
- 2 の累乗の寸法を使用する(512、1024、2048)
- テクスチャを圧縮する(basis/KTX2 形式を使用)
const textureLoader = new THREE.TextureLoader()
// ❌ Bad: Loading 4K texture for small object
const texture = textureLoader.load('texture-4k.jpg')
// ✅ Good: Appropriate size for use case
const texture = textureLoader.load('texture-1k.jpg')
// ✅ Better: Set appropriate filtering
texture.minFilter = THREE.LinearFilter // No mipmaps (saves VRAM)
texture.anisotropy = renderer.capabilities.getMaxAnisotropy()
// ✅ Best: Dispose when done
function cleanup() {
texture.dispose()
}
パート 2:モバイル固有の最適化
モバイルの検出と適応
/**
* Detect mobile device.
* @returns {boolean}
*/
export function isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile/i.test(navigator.userAgent)
|| window.innerWidth < 768
}
/**
* Get optimal pixel ratio for device.
* @returns {number}
*/
export function getOptimalPixelRatio() {
const mobile = isMobile()
const deviceRatio = window.devicePixelRatio
// Cap pixel ratio on mobile to save performance
return mobile
? Math.min(deviceRatio, 1.5) // Max 1.5x on mobile
: Math.min(deviceRatio, 2) // Max 2x on desktop
}
// Apply to renderer
renderer.setPixelRatio(getOptimalPixelRatio())
モバイル向けパフォーマンス設定
/**
* Configure renderer for mobile performance.
*/
function setupMobileOptimizations(renderer, scene, camera) {
const mobile = isMobile()
if (mobile) {
// Disable expensive features
renderer.shadowMap.enabled = false
renderer.antialias = false
// Lower pixel ratio
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5))
// Simpler tone mapping
renderer.toneMapping = THREE.NoToneMapping
// Remove fog (expensive pixel shader)
scene.fog = null
// Reduce lights (expensive)
// Keep only 1-2 lights max on mobile
console.log('[Mobile] Performance optimizations applied')
} else {
// Desktop: enable high-quality features
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
renderer.antialias = true
renderer.toneMapping = THREE.ACESFilmicToneMapping
console.log('[Desktop] High-quality features enabled')
}
}
フォールバックパターン
/**
* Create geometry with fallback for low-end devices.
*/
export function createOptimizedGeometry(options = {}) {
const { size = 1, mobile = false } = options
if (mobile) {
// Simple geometry for mobile
return new THREE.SphereGeometry(size, 8, 8) // Low poly
} else {
// Detailed geometry for desktop
return new THREE.IcosahedronGeometry(size, 2) // High poly
}
}
// Usage
const mobile = isMobile()
const geometry = createOptimizedGeometry({ size: 1, mobile })
const material = new THREE.MeshBasicMaterial({ color: 0x00d9ff })
const mesh = new THREE.Mesh(geometry, material)
パート 3:レンダーループの最適化
効率的なアニメーションループ
class SceneManager {
constructor() {
this.clock = new THREE.Clock()
this.animationId = null
this.lastFrameTime = 0
this.fps = 60
this.frameInterval = 1000 / this.fps
}
/**
* Main render loop with delta time.
*/
animate() {
this.animationId = requestAnimationFrame(() => this.animate())
const now = performance.now()
const delta = now - this.lastFrameTime
// Throttle to target FPS if needed
if (delta < this.frameInterval) return
this.lastFrameTime = now - (delta % this.frameInterval)
// Update logic with delta
const deltaSeconds = this.clock.getDelta()
this.update(deltaSeconds)
// Render
this.renderer.render(this.scene, this.camera)
}
/**
* Update scene objects.
* @param {number} delta - Time since last frame (seconds)
*/
update(delta) {
// Update animations, physics, etc.
this.animatedObjects.forEach(obj => {
if (obj.update) obj.update(delta)
})
}
/**
* Cleanup and stop animation.
*/
dispose() {
if (this.animationId) {
cancelAnimationFrame(this.animationId)
}
}
}
条件付きレンダリング
/**
* Only render when something changed (for static scenes).
*/
class ConditionalRenderer {
constructor(renderer, scene, camera) {
this.renderer = renderer
this.scene = scene
this.camera = camera
this.needsRender = true
}
/**
* Mark scene as needing re-render.
*/
invalidate() {
this.needsRender = true
}
/**
* Render only if needed.
*/
render() {
if (this.needsRender) {
this.renderer.render(this.scene, this.camera)
this.needsRender = false
}
}
/**
* Use with controls.
*/
connectControls(controls) {
controls.addEventListener('change', () => this.invalidate())
}
}
// Usage
const conditionalRenderer = new ConditionalRenderer(renderer, scene, camera)
conditionalRenderer.connectControls(controls)
function animate() {
requestAnimationFrame(animate)
controls.update()
conditionalRenderer.render() // Only renders if camera moved
}
パート 4:メモリ管理
破棄パターン
/**
* Properly dispose THREE.js resources.
*/
export function disposeObject(object) {
if (!object) return
// Traverse and dispose children
object.traverse((child) => {
// Dispose geometry
if (child.geometry) {
child.geometry.dispose()
}
// Dispose materials
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach(material => disposeMaterial(material))
} else {
disposeMaterial(child.material)
}
}
// Dispose textures
if (child.texture) {
child.texture.dispose()
}
})
// Remove from parent
if (object.parent) {
object.parent.remove(object)
}
}
/**
* Dispose material and its textures.
*/
function disposeMaterial(material) {
material.dispose()
// Dispose textures
Object.keys(material).forEach(key => {
const value = material[key]
if (value && typeof value === 'object' && 'minFilter' in value) {
value.dispose() // It's a texture
}
})
}
メモリリークの防止
class SafeSceneManager {
constructor() {
this.scene = new THREE.Scene()
this.renderer = new THREE.WebGLRenderer()
this.objects = new Set()
}
/**
* Add object and track it.
*/
add(object) {
this.scene.add(object)
this.objects.add(object)
}
/**
* Remove and dispose object.
*/
remove(object) {
this.scene.remove(object)
this.objects.delete(object)
disposeObject(object)
}
/**
* Cleanup all resources.
*/
dispose() {
// Dispose all tracked objects
this.objects.forEach(obj => disposeObject(obj))
this.objects.clear()
// Dispose renderer
this.renderer.dispose()
// Clear scene
this.scene.clear()
}
}
パート 5:マテリアルの最適化
マテリアルの共有
// ❌ Bad: New material for each object
for (let i = 0; i < 100; i++) {
const material = new THREE.MeshBasicMaterial({ color: 0xff0000 })
const mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)
}
// ✅ Good: Share single material
const sharedMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 })
for (let i = 0; i < 100; i++) {
const mesh = new THREE.Mesh(geometry, sharedMaterial)
scene.add(mesh)
}
より軽量なマテリアルの種類
パフォーマンスの順(速いものから遅いもの):
- MeshBasicMaterial - ライティングなし、フラットシェーディング
- MeshLambertMaterial - シンプルな拡散光ライティング
- MeshPhongMaterial - 鏡面ハイライト
- MeshStandardMaterial - PBR(高負荷)
- MeshPhysicalMaterial - 高度な PBR(非常に高負荷)
// Mobile: Use cheaper materials
const material = isMobile()
? new THREE.MeshBasicMaterial({ color: 0x00d9ff })
: new THREE.MeshStandardMaterial({
color: 0x00d9ff,
roughness: 0.5,
metalness: 0.1
})
ブレンディングモード
// Additive blending for glows (cheaper than transparent)
material.blending = THREE.AdditiveBlending
material.transparent = true
material.depthWrite = false // Don't write to depth buffer
パート 6:ポストプロセスの最適化
選択的なポストプロセス
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'
function setupPostProcessing(renderer, scene, camera, mobile) {
const composer = new EffectComposer(renderer)
// Always add render pass
composer.addPass(new RenderPass(scene, camera))
// Bloom only on desktop
if (!mobile) {
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
1.5, // strength
0.4, // radius
0.85 // threshold
)
composer.addPass(bloomPass)
}
return composer
}
パート 7:グラフィックス全般のベストプラクティス
1. オブジェクトプーリング
/**
* Object pool to reuse objects instead of creating/destroying.
*/
class ObjectPool {
constructor(createFn, resetFn) {
this.pool = []
this.createFn = createFn
this.resetFn = resetFn
}
/**
* Get object from pool or create new one.
*/
acquire() {
if (this.pool.length > 0) {
return this.pool.pop()
}
return this.createFn()
}
/**
* Return object to pool.
*/
release(obj) {
this.resetFn(obj)
this.pool.push(obj)
}
}
// Usage: Particle pool
const particlePool = new ObjectPool(
// Create function
() => {
const geometry = new THREE.SphereGeometry(0.1)
const material = new THREE.MeshBasicMaterial({ color: 0xffffff })
return new THREE.Mesh(geometry, material)
},
// Reset function
(particle) => {
particle.position.set(0, 0, 0)
particle.visible = false
}
)
// Spawn particle
const particle = particlePool.acquire()
particle.position.set(Math.random(), Math.random(), Math.random())
particle.visible = true
scene.add(particle)
// Later: Return to pool
scene.remove(particle)
particlePool.release(particle)
2. 可視性カリング
/**
* Hide objects far from camera.
*/
function updateVisibility(camera, objects, maxDistance = 50) {
const cameraPos = camera.position
objects.forEach(obj => {
const distance = obj.position.distanceTo(cameraPos)
obj.visible = distance < maxDistance
})
}
3. 遅延読み込み
/**
* Load textures on demand.
*/
class LazyTextureLoader {
constructor() {
this.loader = new THREE.TextureLoader()
this.cache = new Map()
}
async load(url) {
// Check cache
if (this.cache.has(url)) {
return this.cache.get(url)
}
// Load texture
return new Promise((resolve, reject) => {
this.loader.load(
url,
(texture) => {
this.cache.set(url, texture)
resolve(texture)
},
undefined,
reject
)
})
}
}
パート 8:パフォーマンスの監視
FPS カウンター
/**
* Simple FPS monitor.
*/
class FPSMonitor {
constructor() {
this.frames = 0
this.lastTime = performance.now()
this.fps = 60
}
update() {
this.frames++
const now = performance.now()
if (now >= this.lastTime + 1000) {
this.fps = Math.round((this.frames * 1000) / (now - this.lastTime))
this.frames = 0
this.lastTime = now
// Warn if FPS drops
if (this.fps < 30) {
console.warn(`Low FPS: ${this.fps}`)
}
}
}
getFPS() {
return this.fps
}
}
// Usage
const fpsMonitor = new FPSMonitor()
function animate() {
requestAnimationFrame(animate)
fpsMonitor.update()
renderer.render(scene, camera)
}
GPU メモリの監視
/**
* Monitor GPU memory usage.
*/
function logMemoryUsage(renderer) {
const info = renderer.info
console.log('GPU Memory:', {
geometries: info.memory.geometries,
textures: info.memory.textures,
programs: info.programs.length,
drawCalls: info.render.calls,
triangles: info.render.triangles
})
}
// Call periodically
setInterval(() => logMemoryUsage(renderer), 5000)
重要な最適化チェックリスト
最適化の前に
- まずプロファイリングを行う(Chrome DevTools の Performance タブ)
- ボトルネックを特定する(CPU と GPU のどちらか?)
- 目標 FPS を設定する(通常は 60fps = 1 フレームあたり 16ms)
ジオメトリ
- 繰り返し配置するオブジェクトには InstancedMesh を使用する
- 遠くにあるオブジェクトには LOD を実装する
- 静的なジオメトリを結合する
- BufferGeometry を使用する(Geometry ではなく)
- 未使用のジオメトリを破棄する
テクスチャ
- 必要最小限のテクスチャサイズを使用する
- 2 の累乗の寸法を使用する
- テクスチャを圧縮する(basis/KTX2)
- ミップマップを使用しない場合は、minFilter = LinearFilter に設定する
- 未使用のテクスチャを破棄する
マテリアル
- 複数のオブジェクト間でマテリアルを共有する
- モバイルでは、より軽量なマテリアルの種類を使用する
- 透明なオブジェクトを制限する
- グローには加算ブレンディングを使用する
- 未使用のマテリアルを破棄する
ライティング
- ライトの数を制限する(モバイルでは 1~2 個、デスクトップでは 3~5 個)
- モバイルではシャドウを無効にする
- 可能な場合はベイク済みライティングを使用する
- スポットライトよりもディレクショナルライトやポイントライトを優先する
レンダリング
- ピクセル比を制限する(モバイルでは 1.5 倍、デスクトップでは 2 倍)
- モバイルではアンチエイリアスを無効にする
- 静的なシーンには条件付きレンダリングを使用する
- 視錐台カリングを実装する
- モバイルではポストプロセスを制限する
モバイル固有
- モバイルデバイスを検出する
- ジオメトリの複雑さを軽減する
- 負荷の高い機能を無効にする
- ピクセル比を下げる
- 実機でテストする(デスクトップブラウザーだけでテストしない)
よくあるパフォーマンス低下の原因
- ドローコールが多すぎる → InstancedMesh を使用する
- テクスチャの解像度が高すぎる → 1K または 2K にリサイズする
- ライトが多すぎる → 2~3 個に制限する
- 透明なオブジェクト → 控えめに使用し、最後にレンダリングする
- モバイルでのポストプロセス → 無効にするか、簡素化する
- メモリリーク → ジオメトリ、マテリアル、テクスチャは必ず破棄する
- 不要な再レンダリング → 条件付きレンダリングを使用する
- モバイルでのピクセル比が高すぎる → 1.5 倍に制限する
パフォーマンステストのワークフロー
1. 対象デバイスでテストする
// Detect and log device info
console.log('Device Info:', {
userAgent: navigator.userAgent,
pixelRatio: window.devicePixelRatio,
screen: `${window.screen.width}x${window.screen.height}`,
gpu: renderer.capabilities.getMaxAnisotropy()
})
2. Chrome DevTools でプロファイリングする
- DevTools を開き、Performance タブを選択する
- 5~10 秒間のレンダリングを記録する
- 次の項目を確認する:
- 長いフレーム(>16ms)
- GPU のボトルネック
- メモリリーク
3. 最適化を A/B テストする
// Feature flag for testing
const ENABLE_SHADOWS = !isMobile()
const ENABLE_BLOOM = !isMobile()
const MAX_PARTICLE_COUNT = isMobile() ? 100 : 500
リソース
- THREE.js ドキュメント:https://threejs.org/docs/
- THREE.js のパフォーマンス向上のヒント:https://discoverthreejs.com/tips-and-tricks/
- WebGL の基礎:https://webglfundamentals.org/
- GPU のパフォーマンス:https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/WebGL_best_practices
コメント
Markdown 対応