Skip to content

Chargement progressif des ressources pour les jeux web

Les joueurs n'attendront pas le téléchargement de 100 Mo. Chargez progressivement les ressources afin qu'ils puissent jouer pendant que le reste se charge en arrière-plan.

1) La stratégie de chargement

Répartissez les ressources en plusieurs catégories :

  1. Critiques — Nécessaires pour afficher le premier écran (< 1 Mo)
  2. Gameplay — Nécessaires pour jouer (< 10 Mo)
  3. Améliorations — Facultatives (à charger en arrière-plan)

2) Chargeur de ressources de base

js
class AssetLoader {
  constructor() {
    this.cache = new Map()
    this.loading = new Map()
  }
  
  async loadImage(url) {
    if (this.cache.has(url)) return this.cache.get(url)
    if (this.loading.has(url)) return this.loading.get(url)
    
    const promise = new Promise((resolve, reject) => {
      const img = new Image()
      img.onload = () => {
        this.cache.set(url, img)
        this.loading.delete(url)
        resolve(img)
      }
      img.onerror = reject
      img.src = url
    })
    
    this.loading.set(url, promise)
    return promise
  }
  
  async loadJSON(url) {
    if (this.cache.has(url)) return this.cache.get(url)
    
    const response = await fetch(url)
    const data = await response.json()
    this.cache.set(url, data)
    return data
  }
  
  async loadAudio(url, audioCtx) {
    if (this.cache.has(url)) return this.cache.get(url)
    
    const response = await fetch(url)
    const buffer = await response.arrayBuffer()
    const audioBuffer = await audioCtx.decodeAudioData(buffer)
    this.cache.set(url, audioBuffer)
    return audioBuffer
  }
}

3) Chargement avec suivi de la progression

js
async function loadWithProgress(url, onProgress) {
  const response = await fetch(url)
  const contentLength = response.headers.get('Content-Length')
  const total = parseInt(contentLength, 10)
  
  const reader = response.body.getReader()
  const chunks = []
  let received = 0
  
  while (true) {
    const { done, value } = await reader.read()
    if (done) break
    
    chunks.push(value)
    received += value.length
    onProgress(received / total)
  }
  
  const blob = new Blob(chunks)
  return blob
}

Attention : lorsque le serveur compresse la réponse (avec gzip ou brotli, comme le font la plupart des CDN par défaut), Content-Length correspond à la taille compressée, tandis que les fragments lus sont déjà décompressés. Le rapport received / total dépasse alors 100 %. Pour obtenir une barre de progression précise au niveau des octets avec les réponses compressées, servez la ressource sans compression, envoyez votre propre en-tête indiquant la taille décompressée ou utilisez l'événement onprogress de XMLHttpRequest, qui indique la progression réelle du transfert.

4) Chargement par lots avec progression globale

js
async function loadAssets(manifest, onProgress) {
  const total = manifest.length
  let completed = 0
  const results = {}
  
  const promises = manifest.map(async (item) => {
    const asset = await loadAsset(item.url, item.type)
    results[item.name] = asset
    completed++
    onProgress(completed / total, item.name)
  })
  
  await Promise.all(promises)
  return results
}

// Utilisation
const manifest = [
  { name: 'player', url: 'player.png', type: 'image' },
  { name: 'level1', url: 'level1.json', type: 'json' },
  { name: 'music', url: 'music.mp3', type: 'audio' },
]

const assets = await loadAssets(manifest, (progress, name) => {
  console.log(`Chargement : ${Math.round(progress * 100)}% (${name})`)
})

5) Chargeur avec file d'attente prioritaire

js
class PriorityLoader {
  constructor(concurrency = 4) {
    this.queue = []
    this.active = 0
    this.concurrency = concurrency
  }
  
  add(url, priority = 0) {
    return new Promise((resolve, reject) => {
      this.queue.push({ url, priority, resolve, reject })
      this.queue.sort((a, b) => b.priority - a.priority)
      this.process()
    })
  }
  
  async process() {
    if (this.active >= this.concurrency || this.queue.length === 0) return
    
    this.active++
    const { url, resolve, reject } = this.queue.shift()
    
    try {
      const response = await fetch(url)
      const blob = await response.blob()
      resolve(blob)
    } catch (err) {
      reject(err)
    }
    
    this.active--
    this.process()
  }
}

// Utilisation
const loader = new PriorityLoader()
loader.add('critical.png', 10)  // Charger en premier
loader.add('optional.png', 1)   // Charger plus tard

6) Chargement différé des niveaux

js
class LevelManager {
  constructor(loader) {
    this.loader = loader
    this.levels = new Map()
  }
  
  async preload(levelId) {
    if (this.levels.has(levelId)) return
    
    const manifest = await this.loader.loadJSON(`levels/${levelId}/manifest.json`)
    const assets = await this.loadLevelAssets(manifest)
    this.levels.set(levelId, { manifest, assets })
  }
  
  async loadLevelAssets(manifest) {
    // Charger uniquement ce qui est nécessaire pour ce niveau
    const assets = {}
    
    for (const texture of manifest.textures) {
      assets[texture.name] = await this.loader.loadImage(texture.url)
    }
    
    return assets
  }
  
  unload(levelId) {
    this.levels.delete(levelId)
    // Les ressources peuvent être récupérées par le ramasse-miettes
  }
}

7) Streaming de fichiers volumineux

Pour les fichiers volumineux (modèles 3D, audio), utilisez le streaming et traitez-les progressivement :

js
async function streamLargeFile(url, onChunk) {
  const response = await fetch(url)
  const reader = response.body.getReader()
  
  while (true) {
    const { done, value } = await reader.read()
    if (done) break
    onChunk(value)
  }
}

// Pour l'audio : utiliser Media Source Extensions
// Pour la 3D : traiter les données du maillage à mesure qu'elles arrivent

8) Mise en cache des ressources chargées

Associez le chargeur à IndexedDB pour une mise en cache persistante :

js
class CachedLoader {
  constructor() {
    this.memCache = new Map()
    this.dbName = 'AssetCache'
  }
  
  async loadImage(url) {
    // Vérifier la mémoire
    if (this.memCache.has(url)) return this.memCache.get(url)
    
    // Vérifier IndexedDB
    const cached = await this.getFromDB(url)
    if (cached) {
      const img = await this.blobToImage(cached)
      this.memCache.set(url, img)
      return img
    }
    
    // Récupérer et mettre en cache
    const response = await fetch(url)
    const blob = await response.blob()
    await this.saveToDB(url, blob)
    
    const img = await this.blobToImage(blob)
    this.memCache.set(url, img)
    return img
  }
  
  blobToImage(blob) {
    return new Promise((resolve) => {
      const img = new Image()
      img.onload = () => {
        URL.revokeObjectURL(img.src)
        resolve(img)
      }
      img.src = URL.createObjectURL(blob)
    })
  }
  
  // Méthodes IndexedDB...
}

9) Modèle d'écran de chargement

js
class LoadingScreen {
  constructor(canvas) {
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
    this.progress = 0
    this.message = 'Chargement...'
  }
  
  update(progress, message) {
    this.progress = progress
    this.message = message || this.message
    this.render()
  }
  
  render() {
    const { ctx, canvas } = this
    ctx.fillStyle = '#1a1a2e'
    ctx.fillRect(0, 0, canvas.width, canvas.height)
    
    // Barre de progression
    const barWidth = canvas.width * 0.6
    const barHeight = 20
    const x = (canvas.width - barWidth) / 2
    const y = canvas.height / 2
    
    ctx.fillStyle = '#333'
    ctx.fillRect(x, y, barWidth, barHeight)
    
    ctx.fillStyle = '#4ade80'
    ctx.fillRect(x, y, barWidth * this.progress, barHeight)
    
    // Texte
    ctx.fillStyle = '#fff'
    ctx.font = '16px sans-serif'
    ctx.textAlign = 'center'
    ctx.fillText(this.message, canvas.width / 2, y - 20)
    ctx.fillText(`${Math.round(this.progress * 100)}%`, canvas.width / 2, y + 50)
  }
}

10) Bonnes pratiques

  • Affichez immédiatement quelque chose — même une image statique
  • Chargez d'abord ce qui est visible — les textures de la vue actuelle
  • Utilisez des espaces réservés — des images en basse résolution remplacées ensuite
  • Préchargez le niveau suivant — pendant que le joueur joue encore
  • Gérez les échecs sans interrompre l'expérience — nouvelles tentatives et solutions de repli
js
async function loadWithRetry(url, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fetch(url)
    } catch (err) {
      if (i === maxRetries - 1) throw err
      await new Promise(r => setTimeout(r, 1000 * (i + 1)))
    }
  }
}

Articles connexes

Ressources externes