Résumé
Découvrez comment nous avons utilisé Polymer pour créer un sabre laser laser, contrôlé par WebGL hautes performances sur mobile, modulaire et configurable. Nous passons en revue certains détails clés de notre projet https://lightsaber.withgoogle.com/ pour vous aider à gagner du temps lorsque vous créerez le vôtre la prochaine fois que vous rencontrerez une horde de Stormtroopers.
Présentation
Pour les Polymers ou WebComponents, nous avons pensé qu'il serait préférable de commencer par partager un extrait d'un projet en cours d'exécution. Voici un exemple extrait de la page de destination de notre projet https://lightsaber.withgoogle.com. Il s'agit d'un fichier HTML standard qui contient des éléments magiques:
<!-- Element-->
<dom-module id="sw-page-landing">
<!-- Template-->
<template>
<style>
<!-- include elements/sw/pages/sw-page-landing/styles/sw-page-landing.css-->
</style>
<div class="centered content">
<sw-ui-logo></sw-ui-logo>
<div class="connection-url-wrapper">
<sw-t key="landing.type" class="type"></sw-t>
<div id="url" class="connection-url">.</div>
<sw-ui-toast></sw-ui-toast>
</div>
</div>
<div class="disclaimer epilepsy">
<sw-t key="disclaimer.epilepsy" class="type"></sw-t>
</div>
<sw-ui-footer state="extended"></sw-ui-footer>
</template>
<!-- Polymer element script-->
<script src="scripts/sw-page-landing.js"></script>
</dom-module>
Il existe donc aujourd'hui de nombreux choix pour créer une application HTML5. API, frameworks, bibliothèques, moteurs de jeu, etc. Malgré tous les choix, il est difficile d'obtenir une configuration qui offre un bon compromis entre contrôle des performances graphiques élevées et structure modulaire propre et évolutivité. Nous avons constaté que Polymer pouvait nous aider à organiser le projet tout en permettant une optimisation des performances de bas niveau. Nous avons soigneusement conçu la décomposition de notre projet en composants pour exploiter au mieux les fonctionnalités de Polymer.
Modularité avec polymère
Polymer est une bibliothèque qui vous permet de contrôler la manière dont votre projet est créé à partir d'éléments personnalisés réutilisables. Il vous permet d'utiliser des modules autonomes et entièrement fonctionnels contenus dans un seul fichier HTML. Ils contiennent non seulement la structure (balisage HTML), mais aussi les styles et la logique intégrés.
Examinez l'exemple ci-dessous:
<link rel="import" href="bower_components/polymer/polymer.html">
<dom-module id="picture-frame">
<template>
<!-- scoped CSS for this element -->
<style>
div {
display: inline-block;
background-color: #ccc;
border-radius: 8px;
padding: 4px;
}
</style>
<div>
<!-- any children are rendered here -->
<content></content>
</div>
</template>
<script>
Polymer({
is: "picture-frame",
});
</script>
</dom-module>
Toutefois, sur un projet plus important, il peut être utile de séparer ces trois composants logiques (HTML, CSS, JS) et de les fusionner uniquement au moment de la compilation. Nous avons donc attribué à chaque élément du projet son propre dossier séparé:
src/elements/
|-- elements.jade
`-- sw
|-- debug
| |-- sw-debug
| |-- sw-debug-performance
| |-- sw-debug-version
| `-- sw-debug-webgl
|-- experience
| |-- effects
| |-- sw-experience
| |-- sw-experience-controller
| |-- sw-experience-engine
| |-- sw-experience-input
| |-- sw-experience-model
| |-- sw-experience-postprocessor
| |-- sw-experience-renderer
| |-- sw-experience-state
| `-- sw-timer
|-- input
| |-- sw-input-keyboard
| `-- sw-input-remote
|-- pages
| |-- sw-page-calibration
| |-- sw-page-connection
| |-- sw-page-connection-error
| |-- sw-page-error
| |-- sw-page-experience
| `-- sw-page-landing
|-- sw-app
| |-- bower.json
| |-- scripts
| |-- styles
| `-- sw-app.jade
|-- system
| |-- sw-routing
| |-- sw-system
| |-- sw-system-audio
| |-- sw-system-config
| |-- sw-system-environment
| |-- sw-system-events
| |-- sw-system-remote
| |-- sw-system-social
| |-- sw-system-tracking
| |-- sw-system-version
| |-- sw-system-webrtc
| `-- sw-system-websocket
|-- ui
| |-- experience
| |-- sw-preloader
| |-- sw-sound
| |-- sw-ui-button
| |-- sw-ui-calibration
| |-- sw-ui-disconnected
| |-- sw-ui-final
| |-- sw-ui-footer
| |-- sw-ui-help
| |-- sw-ui-language
| |-- sw-ui-logo
| |-- sw-ui-mask
| |-- sw-ui-menu
| |-- sw-ui-overlay
| |-- sw-ui-quality
| |-- sw-ui-select
| |-- sw-ui-toast
| |-- sw-ui-toggle-screen
| `-- sw-ui-volume
`-- utils
`-- sw-t
Le dossier de chaque élément possède la même structure interne, avec des répertoires et des fichiers distincts pour la logique (fichiers café), les styles (fichiers scss) et le modèle (fichier Jade).
Voici un exemple d'élément sw-ui-logo
:
sw-ui-logo/
|-- bower.json
|-- scripts
| `-- sw-ui-logo.coffee
|-- styles
| `-- sw-ui-logo.scss
`-- sw-ui-logo.jade
Et si vous examinez le fichier .jade
:
// Element
dom-module(id='sw-ui-logo')
// Template
template
style
include elements/sw/ui/sw-ui-logo/styles/sw-ui-logo.css
img(src='[[url]]')
// Polymer element script
script(src='scripts/sw-ui-logo.js')
Vous pouvez voir comment les choses sont organisées de manière claire en incluant les styles et la logique de fichiers distincts. Pour inclure nos styles dans les éléments Polymer, nous utilisons l'instruction include
de Jade. Nous avons donc le contenu du fichier CSS intégré après la compilation. L'élément de script sw-ui-logo.js
s'exécutera au moment de l'exécution.
Dépendances modulaires avec Bower
Normalement, nous conservons les bibliothèques et d'autres dépendances au niveau du projet.
Toutefois, dans la configuration ci-dessus, vous remarquerez un bower.json
qui se trouve dans le dossier de l'élément: dépendances au niveau de l'élément. L'idée derrière cette approche est que si vous avez de nombreux éléments avec des dépendances différentes, nous pouvons nous assurer de ne charger que les dépendances réellement utilisées. De plus, si vous supprimez un élément, vous n'avez pas besoin de penser à supprimer sa dépendance, car vous avez également supprimé le fichier bower.json
qui déclare ces dépendances. Chaque élément charge indépendamment les dépendances qui s'y rapportent.
Toutefois, pour éviter une duplication des dépendances, nous incluons également un fichier .bowerrc
dans le dossier de chaque élément. Cela indique à quel emplacement stocker les dépendances afin de nous assurer qu'il n'y en a qu'une à la fin dans le même répertoire:
{
"directory" : "../../../../../bower_components"
}
De cette façon, si plusieurs éléments déclarent THREE.js
comme dépendance, une fois que ce dernier l'installe pour le premier élément et commence à analyser le deuxième, il se rend compte que cette dépendance est déjà installée et ne la télécharge pas ni ne la duplique. De même, il conserve ces fichiers de dépendance tant qu'au moins un élément le définit dans son bower.json
.
Un script bash trouve tous les fichiers bower.json
dans la structure des éléments imbriqués.
Elle entre ensuite dans ces répertoires un par un et exécute bower install
dans chacun d'eux:
echo installing bower components...
modules=$(find /vagrant/app -type f -name "bower.json" -not -path "*node_modules*" -not -path "*bower_components*")
for module in $modules; do
pushd $(dirname $module)
bower install --allow-root -q
popd
done
Modèle d'élément "rapide"
La création d'un élément peut prendre un peu de temps: générer le dossier et la structure de fichiers de base avec les noms corrects. Nous utilisons donc Slush pour écrire un générateur d'éléments simple.
Vous pouvez appeler le script à partir de la ligne de commande:
$ slush element path/to/your/element-name
L'élément est créé avec toute la structure et le contenu des fichiers.
Nous avons défini des modèles pour les fichiers d'éléments. Par exemple, le modèle de fichier .jade
se présente comme suit:
// Element
dom-module(id='<%= name %>')
// Template
template
style
include elements/<%= path %>/styles/<%= name %>.css
span This is a '<%= name %>' element.
// Polymer element script
script(src='scripts/<%= name %>.js')
Le générateur de Slush remplace les variables par les chemins et les noms réels des éléments.
Utiliser Gulp pour créer des éléments
Il garde le processus de compilation sous contrôle. Et dans notre structure, pour construire les éléments, Gulp doit suivre les étapes suivantes:
- Compilez les fichiers
.coffee
des éléments au format.js
. - Compilez les fichiers
.scss
des éléments au format.css
. - Compilez les fichiers
.jade
des éléments au format.html
, en intégrant les fichiers.css
.
Plus en détail:
Compilation des fichiers .coffee
des éléments dans .js
gulp.task('elements-coffee', function () {
return gulp.src(abs(config.paths.app + '/elements/**/*.coffee'))
.pipe($.replaceTask({
patterns: [{json: getVersionData()}]
}))
.pipe($.changed(abs(config.paths.static + '/elements'), {extension: '.js'}))
.pipe($.coffeelint())
.pipe($.coffeelint.reporter())
.pipe($.sourcemaps.init())
.pipe($.coffee({
}))
.on('error', gutil.log)
.pipe($.sourcemaps.write())
.pipe(gulp.dest(abs(config.paths.static + '/elements')));
});
Pour les étapes 2 et 3, nous utilisons gulp et un plug-in compass pour compiler scss
en .css
et .jade
en .html
, dans une approche semblable à la deuxième approche ci-dessus.
Y compris des éléments polymères
Pour inclure les éléments Polymer, nous utilisons des importations HTML.
<link rel="import" href="elements.html">
<!-- Polymer -->
<link rel="import" href="../bower_components/polymer/polymer.html">
<!-- Custom elements -->
<link rel="import" href="sw/sw-app/sw-app.html">
<link rel="import" href="sw/system/sw-system/sw-system.html">
<link rel="import" href="sw/system/sw-routing/sw-routing.html">
<link rel="import" href="sw/system/sw-system-version/sw-system-version.html">
<link rel="import" href="sw/system/sw-system-environment/sw-system-environment.html">
<link rel="import" href="sw/pages/sw-page-landing/sw-page-landing.html">
<link rel="import" href="sw/pages/sw-page-connection/sw-page-connection.html">
<link rel="import" href="sw/pages/sw-page-calibration/sw-page-calibration.html">
<link rel="import" href="sw/pages/sw-page-experience/sw-page-experience.html">
<link rel="import" href="sw/ui/sw-preloader/sw-preloader.html">
<link rel="import" href="sw/ui/sw-ui-overlay/sw-ui-overlay.html">
<link rel="import" href="sw/ui/sw-ui-button/sw-ui-button.html">
<link rel="import" href="sw/ui/sw-ui-menu/sw-ui-menu.html">
Optimiser les éléments Polymer pour la production
Un projet de grande envergure peut comporter de nombreux éléments Polymer. Dans notre projet, nous en avons plus de cinquante. Si vous considérez que chaque élément possède un fichier .js
distinct et que certains contiennent des bibliothèques référencées, il deviendra plus de 100 fichiers distincts. Cela signifie que le navigateur doit effectuer un grand nombre de requêtes, avec une perte de performances. Comme pour un processus de concaténation et de réduction de la taille que nous appliquerions à une compilation Angular, nous "vulcaniser" le projet Polymer à la fin pour la production.
Vulcanize est un outil Polymer qui aplatit l'arborescence de dépendances en un seul fichier HTML, ce qui réduit le nombre de requêtes. Cela est particulièrement utile pour les navigateurs qui n'acceptent pas les composants Web de manière native.
CSP (Content Security Policy) et Polymer
Lorsque vous développez des applications Web sécurisées, vous devez implémenter CSP. CSP est un ensemble de règles qui empêchent les attaques de type script intersites (XSS) : exécution de scripts à partir de sources non sécurisées ou exécution de scripts intégrés à partir de fichiers HTML.
Désormais, le fichier .html
optimisé, concaténé et minimisé généré par Vulcanize intègre tout le code JavaScript dans un format non conforme à CSP. Pour résoudre ce problème, nous utilisons un outil appelé Crisper.
Crisper divise les scripts intégrés à partir d'un fichier HTML et les place dans un seul fichier JavaScript externe à des fins de conformité avec CSP. Nous transmettons donc le fichier HTML vulcanisé via Crisper et nous obtenons deux fichiers: elements.html
et elements.js
. Dans elements.html
, il se charge également de charger le elements.js
généré.
Structure logique de l'application
Dans Polymer, il peut s'agir d'un utilitaire non visuel, de petits éléments d'interface utilisateur autonomes et réutilisables (tels que des boutons) ou de modules plus volumineux, comme des "pages", voire de la composition d'applications complètes.
Post-traitement avec une architecture Polymer et parent-enfant
Dans tout pipeline de graphismes 3D, il y a toujours une dernière étape au cours de laquelle des effets sont ajoutés au-dessus de l'image entière en tant que type de superposition. Il s'agit de l'étape de post-traitement qui implique des effets tels que des halos, des rayons naturels, la profondeur de champ, un bokeh, des floutages, etc. Les effets sont combinés et appliqués à différents éléments en fonction de la construction de la scène. Dans THREE.js, nous pouvons créer un nuanceur personnalisé pour le post-traitement en JavaScript ou utiliser Polymer grâce à sa structure parent-enfant.
Si vous examinez le code HTML de l'élément de notre post-processeur:
<dom-module id="sw-experience-postprocessor">
<!-- Template-->
<template>
<sw-experience-effect-bloom class="effect"></sw-experience-effect-bloom>
<sw-experience-effect-dof class="effect"></sw-experience-effect-dof>
<sw-experience-effect-vignette class="effect"></sw-experience-effect-vignette>
</template>
<!-- Polymer element script-->
<script src="scripts/sw-experience-postprocessor.js"></script>
</dom-module>
Nous spécifions les effets en tant qu'éléments Polymer imbriqués dans une classe commune. Ensuite, dans sw-experience-postprocessor.js
, nous effectuons cette opération:
effects = @querySelectorAll '.effect'
@composer.addPass effect.getPass() for effect in effects
Nous utilisons la fonctionnalité HTML et le querySelectorAll
de JavaScript pour trouver tous les effets imbriqués en tant qu'éléments HTML dans le post-traitement, dans l'ordre dans lequel ils ont été spécifiés. Nous effectuons ensuite une itération et nous les ajoutons au compositeur.
Supposons maintenant que vous souhaitiez supprimer l'effet DOF (Profondeur de champ) et modifier l'ordre des effets de fleur et de vignette. Tout ce que nous avons à faire est de modifier la définition du post-processeur en quelque chose comme:
<dom-module id="sw-experience-postprocessor">
<!-- Template-->
<template>
<sw-experience-effect-vignette class="effect"></sw-experience-effect-vignette>
<sw-experience-effect-bloom class="effect"></sw-experience-effect-bloom>
</template>
<!-- Polymer element script-->
<script src="scripts/sw-experience-postprocessor.js"></script>
</dom-module>
et la scène s'exécutera, sans modifier une seule ligne de code.
Boucle de rendu et boucle de mise à jour dans Polymer
Polymer nous permet également d'aborder le rendu et les mises à jour du moteur de manière élégante.
Nous avons créé un élément timer
qui utilise requestAnimationFrame
et calcule des valeurs telles que l'heure actuelle (t
) et le temps delta (temps écoulé depuis la dernière image) (dt
):
Polymer
is: 'sw-timer'
properties:
t:
type: Number
value: 0
readOnly: true
notify: true
dt:
type: Number
value: 0
readOnly: true
notify: true
_isRunning: false
_lastFrameTime: 0
ready: ->
@_isRunning = true
@_update()
_update: ->
if !@_isRunning then return
requestAnimationFrame => @_update()
currentTime = @_getCurrentTime()
@_setT currentTime
@_setDt currentTime - @_lastFrameTime
@_lastFrameTime = @_getCurrentTime()
_getCurrentTime: ->
if window.performance then performance.now() else new Date().getTime()
Ensuite, nous utilisons la liaison de données pour lier les propriétés t
et dt
à notre moteur (experience.jade
):
sw-timer(
t='{ % templatetag openvariable % }t}}',
dt='{ % templatetag openvariable % }dt}}'
)
sw-experience-engine(
t='[t]',
dt='[dt]'
)
De plus, nous écoutons les modifications de t
et dt
dans le moteur, et chaque fois que les valeurs changent, la fonction _update
est appelée:
Polymer
is: 'sw-experience-engine'
properties:
t:
type: Number
dt:
type: Number
observers: [
'_update(t)'
]
_update: (t) ->
dt = @dt
@_physics.update dt, t
@_renderer.render dt, t
Si vous avez soif de FPS, vous pouvez supprimer la liaison de données de Polymer dans la boucle de rendu pour gagner quelques millisecondes nécessaires pour informer les éléments des modifications. Nous avons implémenté des observateurs personnalisés comme suit:
sw-timer.coffee
:
addUpdateListener: (listener) ->
if @_updateListeners.indexOf(listener) == -1
@_updateListeners.push listener
return
removeUpdateListener: (listener) ->
index = @_updateListeners.indexOf listener
if index != -1
@_updateListeners.splice index, 1
return
_update: ->
# ...
for listener in @_updateListeners
listener @dt, @t
# ...
La fonction addUpdateListener
accepte un rappel et l'enregistre dans son tableau de rappels. Ensuite, dans la boucle de mise à jour, nous effectuons une itération sur chaque rappel et nous l'exécutons directement avec les arguments dt
et t
, en contournant la liaison de données ou le déclenchement des événements. Une fois qu'un rappel n'est plus censé être actif, nous avons ajouté une fonction removeUpdateListener
qui vous permet de supprimer un rappel ajouté précédemment.
Sabre laser dans THREE.js
THREE.js élimine le faible niveau de détail de WebGL et nous permet de nous concentrer sur le problème. Notre problème, c'est affronter les Stormtroopers et nous avons besoin d'une arme. Alors construisons un sabre laser.
Sa lame brillante différencie un sabre laser de toute ancienne arme à deux mains. Elle se compose principalement de deux parties: la poutre et la traînée visible en la déplaçant. Nous l'avons construit avec une forme cylindrique brillante et une piste dynamique qui le suit au fur et à mesure que le joueur se déplace.
The Blade
L'pale est composée de deux sous-palettes. Une couche intérieure et une extérieure. Les deux sont des maillages THREE.js avec leurs matériaux respectifs.
Lame intérieure
Pour la lame intérieure, nous avons utilisé un matériau personnalisé avec un nuanceur personnalisé. Nous prenons une ligne créée par deux points et projetons la ligne entre ces deux points dans un plan. Cet avion est essentiellement ce que vous contrôlez lorsque vous combattez avec votre mobile. Il donne au sabre une impression de profondeur et d'orientation.
Pour créer la sensation d'un objet rond et lumineux, nous regardons la distance entre les points orthogonaux de tout point du plan par rapport à la ligne principale reliant les deux points A et B, comme ci-dessous. Plus un point est proche de l'axe principal, plus il est lumineux.
La source ci-dessous montre comment calculer un vFactor
pour contrôler l'intensité du nuanceur de sommets, puis l'utiliser pour se fondre avec la scène du nuanceur de fragments.
THREE.LaserShader = {
uniforms: {
"uPointA": {type: "v3", value: new THREE.Vector3(0, -1, 0)},
"uPointB": {type: "v3", value: new THREE.Vector3(0, 1, 0)},
"uColor": {type: "c", value: new THREE.Color(1, 0, 0)},
"uMultiplier": {type: "f", value: 3.0},
"uCoreColor": {type: "c", value: new THREE.Color(1, 1, 1)},
"uCoreOpacity": {type: "f", value: 0.8},
"uLowerBound": {type: "f", value: 0.4},
"uUpperBound": {type: "f", value: 0.8},
"uTransitionPower": {type: "f", value: 2},
"uNearPlaneValue": {type: "f", value: -0.01}
},
vertexShader: [
"uniform vec3 uPointA;",
"uniform vec3 uPointB;",
"uniform float uMultiplier;",
"uniform float uNearPlaneValue;",
"varying float vFactor;",
"float getDistanceFromAB(vec2 a, vec2 b, vec2 p) {",
"vec2 l = b - a;",
"float l2 = dot( l, l );",
"float t = dot( p - a, l ) / l2;",
"if( t < 0.0 ) return distance( p, a );",
"if( t > 1.0 ) return distance( p, b );",
"vec2 projection = a + (l * t);",
"return distance( p, projection );",
"}",
"vec3 getIntersection(vec4 a, vec4 b) {",
"vec3 p = a.xyz;",
"vec3 q = b.xyz;",
"vec3 v = normalize( q - p );",
"float t = ( uNearPlaneValue - p.z ) / v.z;",
"return p + (v * t);",
"}",
"void main() {",
"vec4 a = modelViewMatrix * vec4(uPointA, 1.0);",
"vec4 b = modelViewMatrix * vec4(uPointB, 1.0);",
"if(a.z > uNearPlaneValue) a.xyz = getIntersection(a, b);",
"if(b.z > uNearPlaneValue) b.xyz = getIntersection(a, b);",
"a = projectionMatrix * a; a /= a.w;",
"b = projectionMatrix * b; b /= b.w;",
"vec4 p = projectionMatrix * modelViewMatrix * vec4(position, 1.0);",
"gl_Position = p;",
"p /= p.w;",
"float d = getDistanceFromAB(a.xy, b.xy, p.xy) * gl_Position.z;",
"vFactor = 1.0 - clamp(uMultiplier * d, 0.0, 1.0);",
"}"
].join( "\n" ),
fragmentShader: [
"uniform vec3 uColor;",
"uniform vec3 uCoreColor;",
"uniform float uCoreOpacity;",
"uniform float uLowerBound;",
"uniform float uUpperBound;",
"uniform float uTransitionPower;",
"varying float vFactor;",
"void main() {",
"vec4 col = vec4(uColor, vFactor);",
"float factor = smoothstep(uLowerBound, uUpperBound, vFactor);",
"factor = pow(factor, uTransitionPower);",
"vec4 coreCol = vec4(uCoreColor, uCoreOpacity);",
"vec4 finalCol = mix(col, coreCol, factor);",
"gl_FragColor = finalCol;",
"}"
].join( "\n" )
};
Halo de lame extérieure
Pour le halo externe, nous effectuons le rendu dans un tampon de rendu distinct, nous utilisons un effet de fleur post-traitement et nous nous fondons avec l'image finale pour obtenir le halo souhaité. L'image ci-dessous montre les trois régions dont vous avez besoin si vous souhaitez un sabre adapté. À savoir le noyau blanc, le halo bleu-ish milieu et le halo externe.
Sentier du sabre laser
La traînée du sabre laser est essentielle pour profiter pleinement de l'effet original de la série Star Wars. Nous avons créé la piste avec un éventail de triangles générés de manière dynamique en fonction du mouvement du sabre laser. Ces ventilateurs sont ensuite transmis au postprocesseur pour une amélioration visuelle ultérieure. Pour créer la géométrie du ventilateur, nous avons un segment de ligne et, en fonction de sa transformation précédente et de sa transformation actuelle, nous générons un nouveau triangle dans le maillage, en abandonnant la partie de queue après une certaine longueur.
Une fois que nous avons un maillage, nous lui attribuons un matériau simple et le transmettons au postprocesseur pour créer un effet lisse. Nous utilisons le même effet de fleur que celui que nous avons appliqué à la brillance de la pale extérieure et obtenons une traînée lisse, comme vous pouvez le voir:
Brillez autour du sentier
Pour que la dernière partie soit terminée, nous avons dû gérer le halo autour du sentier proprement dit, ce qui peut être créé de différentes manières. La solution que nous n'allons pas détailler ici pour des raisons de performances consistait à créer un nuanceur personnalisé pour ce tampon afin de créer un bord lisse autour d'une pince du tampon de rendu. Nous combinons ensuite cette sortie dans le rendu final, et vous pouvez voir ici le halo entourant le sentier:
Conclusion
Polymer est une bibliothèque et un concept puissants (identique aux composants WebComponents en général). Vous seul décidez de ce que vous en faites. Il peut s'agir d'un simple bouton d'interface utilisateur ou d'une application WebGL en taille réelle. Dans les chapitres précédents, nous vous avons donné quelques conseils et astuces pour utiliser efficacement Polymer en production et structurer des modules plus complexes, également performants. Nous vous avons également montré comment obtenir un beau sabre laser dans WebGL. Par conséquent, si vous combinez tout cela, n'oubliez pas de Vulcaniser vos éléments Polymer avant de les déployer sur le serveur de production et si vous n'oubliez pas d'utiliser Crisper pour assurer la conformité avec CSP, vous pouvez compter sur vous !