ITSkillsCenter
Business Digital

Déployer une scène 3D : KTX2, glTF compressé et CDN statique

14 دقائق للقراءة

📍 Article principal de la série : Three.js, React Three Fiber et WebGPU en 2026 : 3D temps réel sur le web

Introduction

Une scène 3D qui tourne parfaitement en local n’est rien tant qu’elle ne s’affiche pas en moins de deux secondes pour un visiteur sur connexion 4G médiocre. Le déploiement d’une application R3F repose sur trois piliers techniques : un build de production optimisé via Vite, des assets compressés via gltf-transform et KTX2, et un hébergement statique avec les bons headers HTTP pour les caches navigateur et CDN. Ce tutoriel construit un pipeline complet, du npm run build au déploiement sur un CDN gratuit, avec mesure du Time To Interactive avant et après chaque optimisation.

Prérequis

  • Un projet R3F v9 fonctionnel avec au moins un modèle glTF chargé
  • Node.js 22 LTS et npm 10+
  • Un compte gratuit sur Cloudflare Pages, Netlify ou Vercel (ou un VPS avec Caddy/Nginx)
  • gltf-transform CLI installé (cf. tutoriel glTF)
  • Temps estimé : 60 à 90 minutes

Étape 1 — Configurer Vite pour la production

Vite produit déjà un bundle correct par défaut, mais quelques options spécifiques aux projets 3D améliorent significativement le résultat. On édite vite.config.ts pour activer le tree-shaking agressif, configurer le code splitting et désactiver le sourcemap en production (gain de 30-40% sur la taille livrée).

// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'node:path'

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: { '@': path.resolve(__dirname, './src') },
  },
  build: {
    target: 'es2022',
    sourcemap: false,
    rollupOptions: {
      output: {
        manualChunks: {
          three: ['three'],
          r3f: ['@react-three/fiber', '@react-three/drei'],
        },
      },
    },
  },
  assetsInclude: ['**/*.glb', '**/*.ktx2', '**/*.hdr'],
})

Le manualChunks sépare Three.js et R3F dans deux chunks distincts du bundle principal. C’est important parce que ces dépendances changent rarement entre deux déploiements : un visiteur qui revient ne re-télécharge que le chunk de l’application, le navigateur sert Three.js depuis son cache. Sans cette séparation, le moindre changement de code applicatif invalide tout le bundle. Le assetsInclude dit à Vite de traiter les fichiers glb, ktx2 et hdr comme des assets, ce qui les copie dans dist/ et leur applique un hash de cache busting.

Étape 2 — Compresser les assets en pré-build

Les modèles 3D et textures sont les plus gros contributeurs au poids final. On les compresse via un script scripts/optimize-assets.mjs qu’on lance avant chaque build. Le script utilise l’API Node de gltf-transform pour parcourir raw-assets/ et produire des versions optimisées dans public/models/.

// scripts/optimize-assets.mjs
import { NodeIO } from '@gltf-transform/core'
import { KHRONOS_EXTENSIONS } from '@gltf-transform/extensions'
import { dedup, draco, textureCompress } from '@gltf-transform/functions'
import draco3d from 'draco3dgltf'
import sharp from 'sharp'
import { readdir } from 'node:fs/promises'
import { join } from 'node:path'

const SRC = 'raw-assets'
const DST = 'public/models'

const io = new NodeIO()
  .registerExtensions(KHRONOS_EXTENSIONS)
  .registerDependencies({
    'draco3d.decoder': await draco3d.createDecoderModule(),
    'draco3d.encoder': await draco3d.createEncoderModule(),
  })

for (const file of await readdir(SRC)) {
  if (!file.endsWith('.glb')) continue
  const doc = await io.read(join(SRC, file))
  await doc.transform(
    dedup(),
    draco(),
    textureCompress({ encoder: sharp, targetFormat: 'webp', quality: 82 }),
  )
  await io.write(join(DST, file), doc)
  console.log(`compressed ${file}`)
}

Trois éléments méritent attention. L’extension Draco doit être enregistrée auprès de l’NodeIO via registerDependencies avant d’appeler la fonction draco() — sans cela, la sérialisation lèvera une exception au runtime parce que l’encodeur n’est pas disponible. La fonction textureCompress de glTF-Transform v3+ délègue le ré-encodage des images à Sharp côté Node.js, qu’il faut donc passer en option encoder. Enfin, dedup élimine les buffers dupliqués (fréquent dans les modèles exportés à la chaîne) et reste recommandé en première position du pipeline. On ajoute le script dans package.json :

{
  "scripts": {
    "build:assets": "node scripts/optimize-assets.mjs",
    "build": "npm run build:assets && tsc -b && vite build"
  }
}

Désormais npm run build compresse les assets puis bundle l’application. Le temps total ajouté est de quelques secondes par modèle, négligeable même sur un runner CI.

Étape 3 — Mesurer la taille du bundle

Après le build, on inspecte la taille du dossier dist/. Vite affiche déjà un récap dans la sortie console, mais pour une analyse fine on utilise rollup-plugin-visualizer qui produit un treemap interactif.

npm install --save-dev rollup-plugin-visualizer

On ajoute le plugin à la config Vite et on exécute le build :

// vite.config.ts (extrait)
import { visualizer } from 'rollup-plugin-visualizer'

plugins: [
  react(),
  visualizer({ filename: 'dist/stats.html', gzipSize: true, brotliSize: true }),
]

Après npm run build, on ouvre dist/stats.html dans un navigateur. Le treemap montre la part de chaque dépendance dans le bundle final. Pour une scène simple, on doit voir Three.js dominer (autour de 200 Ko gzippé), R3F + Drei autour de 80 Ko, le code applicatif sous 30 Ko. Si une dépendance inattendue prend 100 Ko (typique : moment.js ou lodash importés en entier), c’est le moment de la remplacer ou de l’importer ciblement.

Étape 4 — Déployer sur Cloudflare Pages

Cloudflare Pages fournit un hébergement statique gratuit avec un CDN global, le HTTPS automatique et des limites généreuses (500 builds/mois, bande passante illimitée). Pour un projet 3D web, c’est l’option la plus directe et la plus performante.

npm install --save-dev wrangler
npx wrangler pages deploy dist --project-name=ma-scene-3d

La première fois, Wrangler ouvre le navigateur pour s’authentifier auprès du compte Cloudflare. Une fois authentifié, le déploiement upload le contenu de dist/ et retourne une URL *.pages.dev. Pour les déploiements suivants, l’opération prend une vingtaine de secondes. Pour brancher un domaine custom, on configure le DNS depuis le dashboard Cloudflare. Le déploiement git automatique est aussi disponible : on lie le repo, Cloudflare exécute npm run build à chaque push et déploie le résultat.

Étape 5 — Configurer les headers de cache

Les fichiers d’application web ont deux profils de cache distincts. index.html doit être servi avec un cache court ou nul, parce qu’il contient les hashes des bundles JS — un cache trop long bloque les utilisateurs sur une ancienne version. Les bundles JS et les assets hashés (app-XXXXX.js, model-YYYYY.glb) doivent être servis avec un cache long parce que leur nom change à chaque modification.

# public/_headers (Cloudflare Pages, Netlify)
/index.html
  Cache-Control: public, max-age=0, must-revalidate

/assets/*
  Cache-Control: public, max-age=31536000, immutable

/models/*
  Cache-Control: public, max-age=31536000, immutable

Le max-age=31536000 représente un an. La directive immutable indique au navigateur que le fichier ne changera jamais à cette URL — donc inutile de revalider, même au refresh forcé. Cette politique permet à un visiteur récurrent de voir le site instantanément : seul l’index.html est re-téléchargé (quelques Ko), tout le reste vient du cache local.

Étape 6 — Activer la compression Brotli

Cloudflare Pages, Netlify et Vercel servent automatiquement les fichiers en Brotli quand le navigateur l’accepte. Sur un bundle JS de 250 Ko gzippé, Brotli descend typiquement à 220 Ko (environ 12% de gain). Pour un VPS Caddy ou Nginx, on l’active explicitement.

# Caddyfile
example.com {
  root * /var/www/site/dist
  encode br gzip
  file_server

  @assets path /assets/* /models/*
  header @assets Cache-Control "public, max-age=31536000, immutable"
}

La directive encode br gzip de Caddy compresse à la volée selon ce que le navigateur accepte. Sur Nginx, on configure brotli on; avec le module ngx_brotli. La compression supplémentaire est négligeable côté CPU serveur en regard du gain réseau, surtout pour des visiteurs sur connexions lentes.

Étape 7 — Mesurer le Time To Interactive

Une fois déployé, on mesure les performances réelles avec PageSpeed Insights et WebPageTest. Ces outils simulent diverses conditions réseau et fournissent des métriques standard : First Contentful Paint, Largest Contentful Paint, Time To Interactive, Total Blocking Time.

# Avec Lighthouse en CLI pour automatiser
npm install -g lighthouse
lighthouse https://ma-scene.pages.dev --output=html --output-path=./report.html

Le rapport généré donne un score sur 100 et identifie les optimisations restantes. Pour une scène 3D propre en 2026, on doit viser un score Performance supérieur à 80. Les freins typiques restants à ce stade sont : le poids du bundle Three.js (incompressible mais minimisable via tree-shaking ciblé), le téléchargement initial du décodeur Draco WASM, et la charge GPU du premier rendu. Un score de 90+ exige souvent du lazy loading agressif : on ne charge la scène 3D qu’au scroll vers la section concernée, pas au load initial.

Étape 8 — Lazy loading de la scène 3D

Pour une page hybride 2D + 3D, la scène 3D ne doit pas peser sur le chargement initial. On la charge dynamiquement quand l’utilisateur arrive à proximité du conteneur. La technique combine React.lazy, Suspense et IntersectionObserver.

// src/components/LazyScene.tsx
import { lazy, Suspense, useEffect, useRef, useState } from 'react'

const Scene = lazy(() => import('@/scenes/MainScene'))

export default function LazyScene() {
  const [ready, setReady] = useState(false)
  const ref = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (!ref.current) return
    const obs = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) setReady(true)
      },
      { rootMargin: '200px' },
    )
    obs.observe(ref.current)
    return () => obs.disconnect()
  }, [])

  return (
    <div ref={ref} style={{ minHeight: 600 }}>
      {ready && (
        <Suspense fallback={<div>Chargement de la scène…</div>}>
          <Scene />
        </Suspense>
      )}
    </div>
  )
}

L’IntersectionObserver détecte quand le conteneur arrive à 200 pixels de la zone visible. À ce moment, on monte la scène, ce qui déclenche le téléchargement du chunk correspondant. Le visiteur voit d’abord la version 2D du site qui charge en moins d’une seconde, puis le 3D apparaît au scroll. Cette technique seule peut faire passer un score Lighthouse de 65 à 92 sans toucher à la qualité de la scène elle-même.

Étape 8bis — Gérer la version des assets pour le cache

Le hash automatique de Vite couvre le code JavaScript, mais les fichiers placés dans public/ sont copiés tels quels, sans hash. Pour qu’un nouveau modèle remplace le précédent dans le cache des visiteurs récurrents, on a deux options. La plus simple : intégrer le hash dans le nom du fichier au moment de la compression — par exemple modele-3.2.glb. La plus propre : générer le hash à partir du contenu et écrire un manifest assets-manifest.json que l’application lit au démarrage pour résoudre les noms réels.

// scripts/generate-manifest.mjs
import { createHash } from 'node:crypto'
import { readFile, readdir, writeFile, rename } from 'node:fs/promises'
import { join } from 'node:path'

const dir = 'public/models'
const manifest = {}

for (const file of await readdir(dir)) {
  const buf = await readFile(join(dir, file))
  const hash = createHash('sha256').update(buf).digest('hex').slice(0, 8)
  const newName = file.replace(/(\.[^.]+)$/, `-${hash}$1`)
  await rename(join(dir, file), join(dir, newName))
  manifest[file] = newName
}

await writeFile('public/assets-manifest.json', JSON.stringify(manifest, null, 2))

Ce script renomme chaque modèle en y ajoutant un hash de son contenu, puis écrit un manifest qui mappe le nom logique (character.glb) vers le nom physique (character-a3f1c0d2.glb). Côté application, on charge d’abord le manifest puis on l’utilise pour résoudre les chemins. Le résultat est qu’un changement de modèle invalide automatiquement le cache, sans intervention manuelle dans la config CDN.

Erreurs fréquentes

Erreur Cause Solution
Modèle introuvable en production Asset placé dans src/ au lieu de public/ Déplacer dans public/models/ ou importer explicitement
Cache navigateur ne se renouvelle pas Pas de hash dans les noms de fichiers Vite hash automatiquement les assets : ne pas désactiver
Mixed content après déploiement HTTPS URL absolue HTTP dans le code Utiliser des URLs relatives ou protocoles relatifs
WASM Draco bloqué par CSP 'unsafe-eval' manquant ou WASM non whitelisté Adapter la Content Security Policy ou héberger le WASM en local
Score Lighthouse mauvais en mobile Pixel ratio non clampé, scène trop dense Profil low en mobile et lazy load

Tutoriels frères

Dans la continuité

FAQ

Cloudflare Pages, Netlify ou Vercel : lequel choisir ?
Pour un projet purement statique (build Vite + assets), Cloudflare Pages a la meilleure offre gratuite et le CDN le plus rapide en moyenne mondiale. Netlify est légèrement plus simple à configurer pour les débutants. Vercel est imbattable si on a aussi du Next.js avec rendu côté serveur. Pour R3F pur, Cloudflare est mon premier choix par défaut.

Faut-il un CDN dédié pour les assets 3D lourds ?
Pas nécessairement. Cloudflare Pages distribue déjà le contenu sur son réseau global. Pour des modèles très lourds (10+ Mo) consommés depuis plusieurs continents, un bucket S3 + CloudFront ou Cloudflare R2 + cache peut donner un meilleur contrôle des coûts à très haut volume.

Comment debugger une erreur de production qui ne se reproduit pas en dev ?
Lancer npm run build && npm run preview reproduit les conditions de production en local : minification, dpr clampé, sourcemap absente. La majorité des bugs « prod uniquement » viennent d’imports tree-shakés trop agressivement ou de chemins relatifs cassés au build. La preview locale les attrape.

Quand passer en SSR ou SSG ?
Pour le SEO d’une page d’accueil principalement 2D, oui : on rend la 2D côté serveur via Next.js et on charge le 3D côté client. Pour un dashboard ou une application interne, le SSR n’apporte rien et complique le déploiement. La règle est : SSR si le SEO est critique, sinon CSR pur sur Cloudflare Pages.

Faut-il monitorer les performances en production ?
Oui dès qu’on a du trafic réel. Web Vitals via Google Analytics 4 ou un outil dédié (Sentry, Datadog) capture les métriques perçues par les vrais utilisateurs sur leurs vrais devices. C’est la donnée la plus fiable, bien plus que les benchmarks de développement.

Étape 9 — Préchargement intelligent au survol

Au-delà du lazy loading, on peut anticiper la demande utilisateur en préchargeant la scène quand sa main approche du conteneur, typiquement quand le pointeur entre dans la zone du bouton « Voir en 3D » ou que l’utilisateur ralentit son scroll dans la section précédente. Cette technique, héritée du préfetching de liens dans Next.js, donne l’illusion d’une scène instantanée sans le coût d’un chargement systématique.

// Préchargement déclenché au mouseenter sur un bouton parent
<button
  onMouseEnter={() => import('@/scenes/MainScene')}
  onClick={() => setOpen(true)}
>
  Voir en 3D
</button>

L’import dynamique commence dès le survol. Le navigateur télécharge le chunk pendant que l’utilisateur amène le doigt vers le bouton, soit typiquement 200 à 500 ms d’avance. Au moment du clic, le chunk est souvent déjà en cache et le composant lazy se monte instantanément. Cette technique combinée au lazy loading et au préchargement progressif des assets glTF (en parallèle, depuis le moment où la section devient visible) couvre l’essentiel des cas d’optimisation perçue.

Étape 10 — Mettre en place une CI complète

Pour qu’aucune régression de performance ne passe inaperçue, on automatise le build et les vérifications dans GitHub Actions. Le workflow exécute le typecheck, le lint, le build, mesure la taille du bundle et compare au précédent. En cas de régression au-delà d’un seuil (par exemple +10 Ko gzippé sans justification), le PR est marqué comme à revoir.

# .github/workflows/ci.yml
name: ci
on: [push, pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22', cache: 'npm' }
      - run: npm ci
      - run: npm run typecheck
      - run: npm run lint
      - run: npm run build
      - run: du -sh dist/
      - uses: actions/upload-artifact@v4
        with: { name: dist, path: dist/ }

Cette configuration tourne sur les runners GitHub gratuits (2000 minutes par mois pour les comptes individuels). On y ajoute un job Lighthouse CI pour mesurer le Performance score à chaque PR : si le score chute en dessous d’un seuil défini (par exemple 80), la PR est bloquée jusqu’à correction. Cette automatisation transforme la qualité de production d’un effort ponctuel à une garantie continue, et c’est ce qui distingue un projet qui se dégrade silencieusement d’un projet qui reste fluide à mesure qu’il évolue.

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité