# Standard visuel — Les Sept Horloges

Ce document définit le socle de rendu commun aux sept tableaux. Le tableau I
(Rue des Lanternes) en est l'implémentation de référence ; les tableaux suivants
ne changent que le **thème** (ciel, brume, lumière, accent) et les **options de
gabarit** (maisons, Reliques, décor). Le reste est hérité.

## 1. Modules

| Fichier | Rôle |
|---|---|
| `prototype/js/visual.js` | Palette, thèmes, textures procédurales, matériaux PBR, gabarit de façade, décor, ciel, lumière, bloom, fusion statique |
| `prototype/js/street.js` | Composition du tableau I : sol, murs, maisons, props, plaques, animation locale |
| `prototype/js/street2.js` | Composition du tableau II : dalles, palazzi, loggias, fontaine, place, plaques, aube |
| `prototype/js/street3.js` | Composition du tableau III : dalles, façades classiques, parterre, topiaires, fontaine, place, plaques, matin |
| `prototype/js/traveler.js` | Rig texturé d'Ivo, animation procédurale, API stable |
| `prototype/js/textures.js` | Enseignes de cuivre (emblèmes, scores), écrans CRT, étiquettes |
| `prototype/js/main.js` | Rendu, composer bloom, boucle et diagnostic `__ls7hStats` |

### API à reprendre

```js
import {
  THEMES, createTheme, createVisualMaterials,
  buildBuilding, makeStoneWall, makeArchWall, makeTorch,
  createSky, createLightingRig, createPostProcessing,
  mergeStaticMeshes,
} from "./visual.js";
import { createStreet3 } from "./street3.js";
```

Un tableau se compose ainsi :

```js
const theme = createTheme({
  id: "renaissance",
  sky: { horizon: 0x2c3a42, zenith: 0x070b12 },
  fog: { color: 0x2c3a42, density: 0.018 },
  light: { key: 0xffc98a, keyIntensity: 1.5 },
  bloom: { strength: 0.4, threshold: 0.88 },
});

createSky(scene, theme);
const rig = createLightingRig(scene, theme);
const mats = createVisualMaterials(theme);
// buildBuilding(mats, { side, z, width, height, wall, timber, shop, sign })
const street = createStreet(scene, theme);
const fx = createPostProcessing(renderer, scene, camera, theme);
```

Le tableau II est une **instance sœur** : `createStreet2(scene, THEMES.renaissance)`
construit un segment contigu (z 20 → 60), avec ses palazzi de 1520. Le tableau III
est de même nature : `createStreet3(scene, THEMES.grandSiecle)` construit le
segment z 60 → 100, avec ses façades classiques et son parterre de 1680.
`main.js` garde les trois quartiers en mémoire et n'en rend qu'un ; le passage est
un fondu de 0,3 s, suivi d'une brume qui se dissipe. La Porte des Âges du tableau
I est à z 19,4 ; celle du tableau II à z 59,4, qui annonce le Grand Siècle ; celle
du tableau III à z 99,4, qui annonce la Révolution industrielle.

## 2. Palette

Palette unique, valeurs documentées dans le GDD. On ne code jamais une couleur
en dur dans un tableau : elle est prise dans `PALETTE` ou dans le thème.

| Rôle | Hex | Usage |
|---|---|---|
| Nuit | `#0D1219` | fond, ombres |
| Brume | `#1E2A33` | brouillard, horizon |
| Pierre | `#6E655B` | murs, pavés |
| Pierre claire | `#A39A8C` | trottoirs, seuils |
| Plâtre | `#C9B995` | façades crépies |
| Bois | `#3B2A1E` | colombages, portes |
| Bois clair | `#6A533C` | poutres, étals |
| Ardoise | `#2F3D48` | toitures |
| Lanterne | `#FFB454` | lumière principale, halos |
| Braise | `#D9642E` | torches |
| Mousse | `#5A6B4A` | joints, humidité |
| Cuivre | `#B8893E` | plaques, sceaux |
| Sang | `#7D2530` | bannières, tissus |
| Terre cuite | `#A8603F` | toitures de 1520 |
| Cyprès | `#2F4A3A` | arbres de la place |
| Aube | `#B86F55` | horizon bas |
| Aube claire | `#E0AA76` | sol et horizon haut |

## 3. Textures procédurales

Toutes les textures sont générées en canvas, sans asset externe. Elles sont
mises en cache (`TEXTURES`, `cobbleSet`) et réutilisées entre tableaux.

| Texture | Génération | Repeat |
|---|---|---|
| Pavés | 24 × 22 pierres irrégulières, joints sombres, mousse, reflets d'eau + bump | `5 × 12` |
| Plâtre | plaques de ton, crasse verticale, humidité en pied, lézardes, grain | `1.25 × 1.25` |
| Pierre | appareil régulier, mortier sombre, arêtes vives | `2 × 2` |
| Ardoise | tuiles en écailles, reflet froid, ombre de joint | `3 × 2` |
| Bois | veines, nœuds, cernes | `1 × 2.4` |
| Porte | 5 lames, clous et ferrures | `1 × 1` |
| Tissu | trame, plis, salissures, rayures héraldiques | `1.5 × 1.5` |
| Cuir | grain, coutures, craquelures | `1.6 × 1.6` |
| Lierre | feuilles découpées sur alpha transparent | `1.4 × 1.4` |
| Eau | rides, reflets | `2 × 2` |
| Halo | dégradé radial avec alpha (sprite) | — |
| Vitre | dégradé 2700 K, croisillons | — |
| Dalles Renaissance | 2 × 4 blocs de pierre réguliers, joints adoucis, lumière rasante | `2 × 4` |
| Bossage | 3 × 3 blocs plats, ombre horizontale, sans chanfrein diagonal | `2 × 2` |
| Terre cuite | tuiles galbées, reflet chaud, joints | `3 × 2` |

**Règles.** `colorSpace = SRGBColorSpace` pour les couleurs, `NoColorSpace`
pour les bumps. Anisotropie 16. Aucun asset bitmap : les prochains tableaux
héritent gratuitement de la résolution de texture.

## 4. Matériaux PBR

| Matériau | Rugosité | Métalité | Notes |
|---|---|---|---|
| Pavés / pierre | 0,94 / 0,92 | 0,02 | bump 0,22 |
| Plâtre | 0,86 | 0,01 | carte procédurale par teinte |
| Bois / colombages | 0,74 | 0,02 | vernis discret |
| Ardoise | 0,82 | 0,05 | |
| Fer | 0,45 | 0,75 | serrures, chaînes |
| Cuivre | 0,34 | 0,86 | plaques et sceaux |
| Vitre chaude | 0,5 | 0 | `emissiveMap` = texture, intensité 1,7 |
| Tissu | 0,88 | 0 | `DoubleSide` |
| Lanterne | 0,25 | 0,1 | émissive 3,0 + bloom |
| Eau | 0,2 | 0,35 | opacité 0,86, UV animées |
| Verre | 0,16 | 0,55 | opacité 0,72 |
| Pierre de taille | 0,90 | 0,02 | `ashlar`, assises régulières |
| Pierre claire | 0,88 | 0,01 | trim mat sans carte : bandeaux, encadrements, arche |
| Bossage | 0,94 | 0,02 | `rusticated`, ombre horizontale |
| Terre cuite | 0,86 | 0,01 | `terracotta`, toitures du tableau II |
| Dalles | 0,93 | 0,01 | `paving`, rue de 1520 |

Les enseignes de cuivre sont **émissives à 42 %** (avec `emissiveMap`) pour
rester lisibles dans les zones non éclairées : un score gravé ne doit jamais
disparaître dans l'ombre.

## 5. Gabarit de façade

`buildBuilding(mats, options)` garantit une maison complète et cohérente.
Coordonnées locales : façade en `z = 0` vers la rue, corps vers `z < 0`,
largeur en `x`, hauteur en `y` ; le groupe est posé et tourné selon le côté.

| Option | Défaut | Effet |
|---|---|---|
| `side`, `z`, `x` | `west`, 0, ±8 | position dans la rue |
| `width` | 6 | largeur le long de la rue |
| `depth` | 8 | profondeur du corps |
| `height` | 6 | hauteur totale (socle inclus) |
| `wall` | plâtre | teinte de la façade |
| `stories` | 2 | nombre d'étages du gabarit |
| `timber` | true | colombages + contreventements |
| `timberColor` | 0x6B4C31 | teinte du bois |
| `roofRise` | 2,0 | hauteur des pans de toit |
| `dormer` | false | lucarne sur le pan avant |
| `shop` | false | grande vitre chaude au rez |
| `doorWidth/Height` | 1,25 / 2,3 | porte archée (tracé Shape) |
| `doorSteps` | 1 | marches de pierre |
| `sign` | null | `{ texture, draw }` de `makePlaque` |
| `banner` | null | couleur du tissu suspendu |
| `lantern` | true | lanterne de façade (émissive) |
| `chimney` | true | cheminée + émetteur de fumée |
| `balcony` | false | balcon de bois à l'étage |
| `awning` | null | auvent de tissu rayé au-dessus de la vitrine |
| `ivy` | false | lierre procédural sur le mur |
| `flowers` / `flowerColor` | false / rouge | jardinières sous les fenêtres |
| `quoins` | false | pierres d'angle en saillie |
| `secondChimney` | false | deuxième cheminée, pour varier les silhouettes |
| `renaissance` | false | façade de pierre de taille, sans colombages ; pierres et bossage |
| `pilasterBays` | 3 | nombre de travées de pilastres engagés |
| `rusticatedBase` | false | socle en bossage, comme un palazzo de 1520 |
| `archedWindows` | false | fenêtres à cintre et encadrement de pierre |
| `pediments` | false | frontons alternés au-dessus des fenêtres |
| `cornice` | false | corniche saillante à denticules |
| `loggia` | 0 | galerie d'arcades en saillie, à mi-étage |
| `medallions` | 0 | médaillons de pierre sous la corniche |
| `doorPediment` | false | fronton au-dessus de la porte |
| `tallChimneys` | false | cheminées hautes à pot de terre cuite |
| `roofMat` | null | matériau de toiture (terre cuite pour le tableau II) |

Le gabarit produit : socle de pierre, corps, façade plaquée, colombages,
fenêtres à volets, porte archée à vantaux, seuil/marches, toiture à deux pans,
faîtage, égouts, cheminée, lucarne, enseigne suspendue, bannière et lanterne.
Il renvoie `{ group, doorMesh, signMesh, smokePositions, frontX }`.

## 6. Vocabulaire de décor

On compose avec les fabriques du standard, jamais avec des boîtes ad hoc :

- `makeStoneWall` / `makeArchWall` — enceinte, créneaux, archère, herse ;
- `makeBarrel`, `makeCrate`, `makeSack` — tonneaux, caisses, grain ;
- `makeCart` — charrette à roues cerclées ;
- `makeFountain` — vasque, margelle, eau ridée, torche ;
- `makeStall` — étal à toit de toile à deux pans fixé aux montants, lanterne
  suspendue sous l'auvent, miches et légumes ;
- `makeCat`, `makeChicken` — vie de village animée ;
- `makeLanternPost` — lampadaire de rue (base de pierre, fût de fer, lanterne) ;
- `makeBench`, `makeWell` — mobilité du quotidien ;
- `makeHayBale`, `makeAnvil` — écurie et forge ;
- `makeGarlicString`, `makeClothesline` — herboriste et tisserand ;
- `makeFence` — palissade de jardinet ;
- `makeTorch` — torche murale ;
- `addLantern` — lanterne + halo + point light optionnel ;
- `makePilaster`, `makeCornice`, `makePedimentGeometry` — ordres, corniche, frontons du gabarit Renaissance ;
- `makeObelisk`, `makeCypress`, `makeUrn`, `makeBalustrade`, `makeRenaissanceFountain` — place de 1520, fontaine et silhouette italienne ;
- `makeHedge`, `makeTopiary`, `makeStatue` — parterre et statues du Grand Siècle : haies taillées, cônes de buis, silhouettes de pierre sur socle.

Les objets animés (chat, poule, lanterne, enseigne, eau) portent
`userData.keep` ou `userData.noMerge` et sont exclus de la fusion statique.

## 7. Ciel, lumière et post-traitement

| Élément | Réglage |
|---|---|
| Dôme de ciel | shader dégradé zénith/horizon/sol, 95 m, sans fog |
| Étoiles | 900 points, taille 1,2, opacité 0,75, sans fog |
| Lune | sprite halo 9 m, teinte `#CFDBE6` |
| Soleil d'aube | sprite halo et cœur, position basse, `yStart` 10,6 → `yEnd` 17,5, horizon `#B86F55` → `#E0AA76`, étoiles 0,34 → 0,05 |
| Soleil de matin | sprite halo et cœur, position haute, `yStart` 28 → `yEnd` 33, zénith `#3F7AB8` → `#6AA7DE` horizon `#F0C17A` → `#FFECC4`, étoiles 0,02 → 0 |
| Hémisphère | 0x3D4D5C / 0x191008, intensité 0,9 |
| Lune directionnelle | 0x6D8FB3, 0,65 |
| Remplissage froid | 0x4F6D8F, 0,22 |
| Lanterne clé | 0xFFB454, 1,5, ombre 2048, biais −0,0006 |
| Lanternes ponctuelles | ≤ 5 visibles (dont celle du Voyageur) : 9–11 d'intensité, portée 9–10, sans ombre |
| Brouillard | `FogExp2` 0x1E2A33, densité 0,021 |
| Bloom | force 0,35, rayon 0,72, seuil 0,90, **résolution 50 %** |
| Tonemapping | ACESFilmic, exposition 1,18 |
| Vignette + grain | calque CSS non interactif |

**Budget lumière.** Une seule ombre : la lanterne clé. Toute lanterne décorative
est émissive + halo, seules cinq positions reçoivent un point light. Les toits,
corps, portes et cheminées projettent une ombre ; menuiseries et petits objets
non.

**Thème Renaissance — aube.** `THEMES.renaissance` : zénith `#1A2747`, horizon
`#B86F55`, sol `#2A1E18`, brouillard `#8F7A6E` densité 0,014, hémisphère 0,95,
clé `#FFCF9A` 2,35 (position `[12, 20, 80]`, cible au centre de la rue), remplissage
`#7F8FAB` 0,35, exposition 1,14, bloom 0,42 / seuil 0,86. Le soleil est un sprite
de halo 32 m et un cœur de 12 m, sans lune. Les toitures sont en terre cuite, les
façades en pierre de taille. `createSky` expose `userData.updateSun(progress)` :
couleurs, hauteur du soleil et opacité des étoiles sont interpolées. `main.js`
pilote la progression avec la position du voyageur (z 21,2 → 58,4), et l'intensité
de la clé monte de 0,5 en fin de rue.

**Thème Grand Siècle — matin clair.** `THEMES.grandSiecle` : zénith `#3F7AB8`,
horizon `#FFE0A6`, sol `#9A9484`, brouillard `#B9C6C2` densité 0,005, hémisphère
1,08, clé `#FFF4D8` 2,9 (position `[18, 32, 120]`, cible au centre de la rue),
remplissage `#88A8C8` 0,35, exposition 1,26, bloom 0,26 / seuil 0,92. Le soleil
est un sprite de halo 32 m et un cœur de 12 m, sans lune, étoiles éteintes. Les
toitures sont en ardoise mansardée, les façades en pierre de taille, les palais
portent une balustrade sommitale et un finial de cuivre. `createSky` expose
`userData.updateSun(progress)` : couleurs, hauteur du soleil et opacité des
étoiles sont interpolées. `main.js` pilote la progression avec la position du
voyageur (z 61,2 → 98,4), et l'intensité de la clé monte de 0,35 en fin de rue.

## 8. Caméra et déplacement

- Troisième personne orbitale, `distance` 4,6 m, `pitch` 0,3, FOV 65°.
- Marche **2,6 m/s**, course **6,4 m/s** (`Maj`), accélération 12/s.
- **Axes corrigés** : la droite écran est `(-cos yaw, 0, -sin yaw)`. `D` va à
  droite, `A` à gauche. La caméra regarde vers `+z` au spawn ; `-x` est la
  droite de l'écran.
- **Anti-clipping** : `setCameraBounds(bounds)` rapproche la caméra le long du
  rayon quand l'orbite sort du volume. Rue I : x ±7,7 (façades à ±8), z ±20,2
  (murs à ±20,4/20,6). Rue II : x ±7,7, z 21,0–58,7. Rue III : x ±7,7,
  z 61,0–98,7 (façades à ±8, arches murées à 60,4 et 99,4). Pièce : x/z ±2,25.
  Facteur plancher 0,12 : la caméra reste devant la surface, jamais dans le mur.
- Souris : glisser à gauche fait tourner la caméra à gauche (signe du yaw
  aligné sur le déplacement horizontal) ; molette pour la distance (1,6–8,5 m).
- Vitres stables (émissif fixe) : aucun clignotement de texture près des
  portes. Les flaques en disques ont été retirées : le pavé procédural garde
  ses reflets mouillés.
- **Collision personnage** : cercle de 0,32 m ; le tableau expose `colliders`
  (cercles et boîtes 2D : tonneaux, caisses, étals, fontaine, puits, bancs,
  lampadaires, clôtures, vitrines, piliers de porte). `setColliders` est appelé
  à l'entrée dans la rue et remis à `[]` dans une pièce. Le personnage ne
  traverse aucun objet.
- **Collision caméra** : le rayon caméra→personnage est raccourci au premier
  obstacle haut (`h ≥ 2 m` : fontaine, lampadaires, étals, vitrines, piliers)
  pour que la caméra reste du côté du personnage et ne traverse pas l'objet.
- **Zoom plaque** : clic gauche sur une enseigne de cuivre ; la caméra glisse sur
  1,4 m devant la plaque et regarde son centre ; une plaque lisible en grand
  (emblème, top 3, année) s'affiche. `Échap`, le bouton ou un clic sur le fond
  revient au voyageur. Aucun déplacement du personnage pendant le zoom.
- **Z-fighting** : toute surface plaquée (vitre, verre, enseigne, cadre de
  porte) est écartée de 4 à 10 cm de son support. Marches de seuil disjointes,
  fût de lampadaire enfoncé dans le socle de pierre, couvercle de tonneau à
  rayon réduit. Anisotropie 16 sur les textures pour supprimer le
  scintillement des poteaux et du sol en orbite.

## 9. Personnage

Humanoïde réaliste **Quaternius UAL / Universal Base Characters** (`human_male.glb`,
CC0), 1,82 m ramené à 1,78 m. Le corps est animé par `AnimationMixer` et habillé
par une tenue peinte dans la texture du maillage : elle épouse le torse et les
jambes sans coque. Le visage et les cheveux restent dégagés.

- silhouette 2026 : veste techwear teal, pantalon sombre, sneakers claires à
  semelle cyan, col et cuffs LED, badge cyan, ceinture, sac à dos LED, cheveux
  courts et **drone-compagnon** (orbe LED + point light cyan) ;
- modèle : ~14 k triangles de peau + tenue, ~24 k triangles avec accessoires ;
- animation : clips Quaternius UAL CC0 — `Idle_No_Loop` (attente détendue),
  `Walk_Loop`, `Sprint_Loop`, fondus de 0,24 s ; le mixer est piloté par la
  vitesse du contrôleur ;
- collisions : cercle 2D de 0,36 m ; caméra raccourcie avant les props ;
- API stable `{ group, update(dt, speedFactor) }`, groupe chargé en asynchrone
  (`userData.ready` une fois le mixer démarré).

**Remplacement production.** Conserver un rig humanoïde **adulte** (1,7–1,8 m),
pas les packs chibi (*KayKit Adventurers*, NoLimitNexus) ; habiller le corps
par texture ou par pièces portées, sans masquer le visage. Les animations
UAL1/UAL2 sont CC0 et rejouables ; le drone reste le point light diégétique du
personnage.

## 10. Performance et budget

Mesures du tableau I après fusion statique et nouveau personnage (fenêtre
1280 × 800) :

| Métrique | Prototype | Cible standard |
|---|---|---|
| Meshes | 262 | < 320 |
| Draw calls visibles | ~230–330 selon cadrage | < 360 |
| Triangles | ~78 k (dont ~24 k personnage) | < 120 k |
| Casters d'ombre | 104 | < 110 |
| Frame | < 20 ms (onglet visible) | ≤ 20 ms |

**Tableau III (Rue du Roi-Soleil, onglet actif).** Mesures après chargement :
245 draw calls visibles, ~78 k triangles comptés par le renderer (dont le pass
d'ombre ; géométrie visible ≈ 55 k rue + ciel + Ivo). Le budget reste sous 360
appels et 120 k triangles. Une seule ombre, une fusion statique (134 groupes
dans `street3`) et quatre point lights de rue gardent la frame sous 20 ms.

**Tableau II (Rue des Perspectives, onglet visible).** Mesures après chargement du
voyageur : 268 draw calls, 151 k triangles comptés par le renderer (dont le pass
d'ombre ; géométrie visible ≈ 83 k : rue 55 k, ciel 3,5 k, Ivo 24 k), frame
17,1 ms à 58,6 fps. Le budget d'appels reste sous 360 ; le rendu des palazzi est
plus dense que le tableau I, mais une seule ombre et une fusion statique gardent
la frame sous 20 ms.

Le gain vient de `mergeStaticMeshes(root, keep)` pour le décor (886 meshes
fusionnés dans le tableau I) et de la tenue peinte : pas de coque de veste ni
de pantalon en pièces multiples. Les meshes animés (crops/accessoires) gardent
leur individualité.

**Budget du personnage.**
1. `human_male.glb` pèse 35 Mo (80+ clips) ; le prototype ne joue que trois
   clips — prévoir un export ne gardant que `Idle`, `Walk`, `Sprint`.
2. Tenue peinte : une seule texture 2048², pas de bitmap externe.
3. Un seul point light personnage (drone) ; le reste vient du décor.

Le gain vient de `mergeStaticMeshes(root, keep)` pour le décor (886 meshes
fusionnés dans le tableau I) et de la fusion par joint du rig : **117 → 57
meshes** sur Ivo. Les meshes animés (cape), les enseignes, les portes et les
paupières gardent leur individualité.

**Règles de budget.**
1. Fusionner le décor statique par matériau en fin de composition.
2. Marquer `userData.keep` sur ce qui est animé ou référencé.
3. Une seule ombre directionnelle ; pas d'ombre sur les petites pièces.
4. Cinq point lights visibles maximum ; le reste en émissif + halo.
5. Bloom à demi-résolution.
6. Aucun asset externe pour les textures.

## 11. Checklist d'un nouveau tableau

1. Définir un `theme` dérivé (`createTheme`) : ciel, brume, lumière, accent.
2. Poser le sol et les trottoirs avec `mats.road()` et `mats.stoneLight()`, ou
   `mats.paving()` et `mats.ashlar()` pour un tableau Renaissance.
3. Construire les maisons avec `buildBuilding` : mêmes options, mêmes
   matériaux, mêmes textures. Pour 1520, activer `renaissance`, `pilasterBays`,
   `rusticatedBase`, `archedWindows`, `pediments`, `cornice`, `loggia`,
   `medallions` et `roofMat: mats.terracotta()`.
4. Composer le décor avec les fabriques du vocabulaire. Pour 1680, ajouter
   `makeHedge`, `makeTopiary` et `makeStatue` autour du parterre.
5. Ajouter les enseignes `makePlaque` (emblème propre à la Relique) et les
   médaillons de sceau.
6. Appeler `mergeStaticMeshes` en fin de composition.
7. Vérifier `__ls7hStats` (meshes, calls, frameMs) et la lisibilité de nuit,
   d'aube ou de matin clair : plaques et fenêtres doivent rester visibles sans
   point light dédié.
8. Pour un tableau ensoleillé, vérifier `userData.updateSun` et la position du
   soleil par rapport à la rue : le disque doit rester visible au-dessus de la
   porte, jamais derrière un toit. Pour 1680, le soleil monte de 28 à 33.
9. Fermer toute ouverture d'arche ou de façade par un panneau de pierre mate
   placé en retrait (z −0,1, épaisseur 0,6) : aucun ciel ni vide au travers, sans
   engloutir bannière ni médaillons placés plus en avant. `buildBuilding`
   conserve les surfaces plaquées à 0,15 et les pilastres à 0,03 pour éviter le
   z-fighting. Cadre CRT borné par `calc((100vh − 96px) × 4 / 3)` pour ne jamais
   couper le bas du jeu sur une fenêtre courte.
10. Documenter tout écart de fidélité visuelle dans le GDD.

## 12. Anti-patterns

- Couleur codée en dur hors palette.
- Texture bitmap ou sprite déposé : dessiner en primitives.
- Plus d'une ombre ou plus de cinq point lights visibles.
- Meshes non fusionnés sur le décor statique.
- Enseigne dépendante d'une lumière locale (elle doit être émissive).
- Animation du personnage indexée sur le temps plutôt que sur la vitesse.
- Caméra libre de traverser les murs.
