diff --git a/docs/fr/audits/moteur-rendu-c6/RAPPORT.md b/docs/fr/audits/moteur-rendu-c6/RAPPORT.md new file mode 100644 index 000000000..86ec3264a --- /dev/null +++ b/docs/fr/audits/moteur-rendu-c6/RAPPORT.md @@ -0,0 +1,558 @@ +# Chantier C6 — Moteur de rendu Compatible (WebGL) / Avancé (WebGPU/TSL) + +Date : 10 septembre 2026. Branche `feat/render-engine`, worktree `worktrees/render-engine`. +Machine : Apple M2 Max, macOS 26.5.2 (Darwin 25.6.0), arm64. three.js 0.185.1. + +Relu le 11 septembre 2026 (`feat/c6-relecture`). Cette relecture n'a mesuré ni redessiné quoi que +ce soit : elle a confronté chaque affirmation au code livré, corrigé six énoncés qui ne tenaient +pas — noms de fichiers et de colonnes périmés, nombres de tests, plafonds présentés comme des +arrondis, export jeu présenté comme honorant le moteur — et refusé les cascades au moteur Avancé, +où elles n'auraient rien dessiné en éteignant le soleil. Les chiffres du 10 et du 11 septembre sont +inchangés. + +## Verdict + +| Étape | Statut | Motif | +| --- | --- | --- | +| 1 — Gains WebGL indépendants | livrée, 1 MUST refusé sur mesure, 1 critère non tenu | Cascades, anisotropie et AgX livrés. `PCFSoftShadowMap` n'est pas le mode doux dans cette version de three : appliquer le MUST 1.1 aurait durci les ombres. Le critère « un projet existant est visuellement identique » ne tient pas — l'anisotropie maximale change son image, § 1.1. | +| 2 — Interface driver + choix moteur | livrée, 1 écart d'emplacement, 1 critère non vérifié | `RenderDriver`, `glDriver`, `gpuDriver` (stub), repli silencieux, `engines` dans le registre. Le sélecteur est dans les préférences 3D et non à la création de projet — motif plus bas. Le critère « un projet `'gl'` est bit-identique à avant ce chantier » n'a **jamais été mesuré** : aucun banc du dépôt ne compare une révision à la précédente. | +| 3 — Premier contenu GPU réel | livrée | `WebGPURenderer` monté, patch matériau en TSL, GTAO en nœud natif, lecture de pixels GPU, budget qualité partagé. Chiffres mesurés sur cette machine, plus bas. | +| 4 — Compléments (hors spec) | livrée | Parité visuelle GL/GPU mesurée et tenue par une porte, capture d'export Avancée jointe, moteur choisi à la création du document, TRAA porté et bibliothèque d'effets filtrée par moteur. | + +## Étape 1 + +### 1.1 — Mode doux des ombres : MUST refusé, avec la mesure + +Le spec demande de remplacer `soft: PCFShadowMap` par `PCFSoftShadowMap`. **Ce serait une +régression**, vérifié dans la source livrée le 10 septembre 2026 : + +- `node_modules/three/src/renderers/webgl/WebGLProgram.js:345` — `shadowMapTypeDefines` ne nomme + que `PCFShadowMap → SHADOWMAP_TYPE_PCF` et `VSMShadowMap → SHADOWMAP_TYPE_VSM` ; + `generateShadowMapTypeDefine` retombe sur `SHADOWMAP_TYPE_BASIC` pour tout le reste, + `PCFSoftShadowMap` compris. +- `shadowmap_pars_fragment.glsl.js:94` — la branche `SHADOWMAP_TYPE_PCF` est celle qui contient + le disque de Vogel à cinq taps avec rotation par bruit de gradient entrelacé, et la seule qui + lise `shadowRadius`. + +Autrement dit `PCFShadowMap` **est** le mode doux de three 0.185, et `PCFSoftShadowMap` y compile +une comparaison non filtrée. `WelcomeBackdrop.ts:171` portait déjà cette note ; elle est désormais +sur `MAP_TYPES` dans `shadows.ts`, à l'endroit où quelqu'un rouvrirait le sujet. + +Conséquence pour le critère d'acceptation « un projet existant est visuellement identique sauf les +ombres, qui passent en doux » : **les ombres ne changent pas**, elles étaient déjà douces. + +🛑 Le critère n'est pas tenu pour autant, et par un autre MUST de la même étape : le § 1.3 force +l'anisotropie au maximum de la carte **à l'import de chaque texture**, donc dans les projets +existants comme dans les neufs. Une surface vue en biais y gagne de la netteté — c'est le but — et +change donc d'image. **Non mesuré** : aucune capture avant/après n'a été prise, ni ici ni par +`engines:parity`, qui compare les deux moteurs entre eux et jamais une révision à la précédente. + +### 1.2 — Cascades (CSM) + +`RenderPolicy.csm`, faux par défaut. `src/renderer/src/engines/scene/csm.ts` enveloppe +`three/addons/csm/CSM.js` : + +- trois bandes, `CASCADES = 3`, non réglable : le nombre est un `define` de matériau, le changer + recompile toute la scène ; +- la taille des cartes passe par `shadowMapSizeFor`, le même plafond qualité qu'une carte unique ; +- `dress` habille les matériaux standard et fait **remplacer** le soleil par les bandes : elles + prennent sa couleur et son intensité, il cesse d'éclairer et de projeter. `CSM` ajoute trois + lumières à 3 d'intensité chacune ; laissées à côté d'un soleil qui éclaire encore, une scène à + 1 passait à 10. L'intensité n'est pas divisée par trois pour autant : le chunk n'éclaire un + fragment que depuis UNE bande, celle où tombe sa profondeur ; +- `dress` **compose** avec un matériau qui porte déjà un `onBeforeCompile` au lieu de le sauter. + `setupMaterial` écrase ce crochet et `dispose` le supprime ; sauté, le splat de relief — le + seul matériau de scène dans ce cas — perdait à la fois les cascades et l'ombre de son soleil, + c'est-à-dire exactement le cas que les cascades existent pour servir ; +- `dress` force `material.needsUpdate` — l'addon ne le fait pas, et un `define` posé sur un + matériau déjà compilé n'atteint aucun programme ; +- `release` rend au soleil sa lumière et sa carte, rend leur crochet aux matériaux composés, + retire les trois lumières et fait recompiler. + +Câblage : construction au montage du renderer, reconstruction dans `configure` quand `csm`, +`shadows` ou la taille des cartes bougent, `follow(camera)` par panneau dans `dressPane` et sur +chaque rendu hors écran (film, capture, validation), `aim` depuis `tuneShadows`, habillage limité +aux nœuds qui ont changé plus les lots instanciés, libération à la fermeture. + +`follow` répond si les bandes ont bougé, et cette réponse remonte par `dressPane` : c'est ce qui +dit à la frame que ses cartes d'ombre valent une passe. Sans ça un orbite — qui déplace les +bandes sans rien changer d'autre — aurait affiché des ombres périmées, la frame ne dessinant la +passe d'ombres que sur `shadowsStale`. + +L'export jeu honore `csm` : `webRender` construit les mêmes cascades quand la politique portée +par le manifeste le demande. Sans ça un projet exporté aurait perdu ses cascades en silence. + +**Non mesuré** : le coût réel des trois passes de profondeur. Aucun banc GPU n'a été lancé. + +**Limites connues, écrites plutôt que découvertes plus tard** : + +- `CSM._injectInclude` réécrit `ShaderChunk.lights_fragment_begin` pour tout le processus et + l'addon ne le restaure jamais. `release` rend la scène, pas le processus. Sans effet visible : + le chunk est gardé par `#ifdef USE_CSM`. +- `CSM.shaders` est une `Map` forte : un matériau habillé y reste jusqu'à la libération, même si + son nœud a disparu entre-temps. +- Le splat de relief et les cascades écrivent tous deux dans `onBeforeCompile`. Les deux se + composent — le splat réécrit `map_fragment`, les cascades lisent `lights_fragment_begin` — et + `release` ne rend un patch que si celui posé est encore le nôtre. Reste un ordre fragile si + `bindReliefSplat` s'exécute pour la première fois APRÈS un habillage. + +### 1.3 — Anisotropie + +Le spec désigne `resourceContent.ts`. Ce fichier n'importe aucune texture : il empreinte des +ressources pour les dédupliquer, et ne fait que **lire** `texture.anisotropy` dans ses métadonnées. +Le seul endroit où le studio importe une texture est `scene/textureCache.ts`, que les trois moteurs +(scène, matériau, ciel) partagent. C'est là que la valeur est écrite, via un fournisseur +`anisotropyOf` demandé à chaque chargement — un cache est construit avant que son viewport ait un +renderer, donc la valeur ne peut pas être capturée une fois pour toutes. + +**Impact mémoire : nul, et ce n'est pas une estimation** — le filtrage anisotrope prend plusieurs +échantillons dans la chaîne de mips existante ; il ne crée aucune texture et n'alloue rien. Le coût +est en bande passante d'échantillonnage, sur les surfaces vues en biais. **Non mesuré** : ce coût, +faute de banc GPU dans cet environnement. + +### 1.4 — Tone mapping des nouvelles scènes + +`AgXToneMapping` **est** disponible en three 0.185 (`three/src/constants.js:472`, valeur 6) : c'est +donc AgX et non ACES. `agx` rejoint l'union `ToneMapping`, la table de `worldBinding` et les quinze +bundles de langue. + +`DEFAULT_WORLD.toneMapping` reste `'none'` : c'est le repli d'une scène **relue**. Le nouveau défaut +vit dans `NEW_SCENE_WORLD` (`defaultScene.ts`), lu par `createDefaultScene` et par +`sceneFromTemplate`. Un modèle qui nomme sa propre courbe garde la sienne — les cinq presets +d'environnement demandent explicitement `aces`, ce qui reste un tone mapping actif. + +## Étape 2 + +### Ce qui est derrière l'interface + +`src/renderer/src/engines/render/` — même forme que `src/game/ports/` : l'interface d'un côté, +chaque implémentation dans son fichier. + +| Point | Avant | Derrière `RenderDriver` | +| --- | --- | --- | +| Construction du renderer | `new WebGLRenderer` dans `ViewportSurface` | `createRenderer(request)` | +| Lecture de pixels | `readRenderPixels(gl, …)` appelé par 3 modules | `readPixels(…): Promise` | +| Environnement IBL | `createEnvironment` appelé par 3 moteurs | `createEnvironment(…)` | +| Patch matériau | `onBeforeCompile` posé dans le constructeur de `MaterialRenderer` | `patchMaterial(material, uniforms, onMissingAnchor)` | + +`readPixels` rend une promesse **des deux côtés** bien que WebGL réponde immédiatement : la lecture +GPU mappe un buffer et résout une frame plus tard, et une signature qui changerait avec le moteur +remettrait le choix chez chaque appelant. Les trois appelants +(`SceneRendererFilm`, `SceneRendererFlight`, `SceneRendererValidation`) étaient déjà `async`. + +Le patch matériau passe du constructeur au `mount` : quel driver tourne est réglé par ce montage. +Le matériau n'a encore rien dessiné, donc aucun programme n'a à être reconstruit pour ça. + +### Repli + +`mountRenderer` : Compatible si le moteur demandé est `gl`, si aucun adaptateur n'a répondu, ou si +le driver Avancé lève. Jamais d'écran noir. La raison part en trace `render.fallback` — une trace +et non un `LogScope`, donc pas de toast : l'image est juste, rien n'est perdu, et un panneau par +panneau qui se plaint serait la spécification d'une machine présentée comme un défaut du document. + +`navigator.gpu?.requestAdapter()` est le seul signal qui décide, interrogé une seule fois par +session (`gpuAdapter.ts`). `adapter.info` n'entre pas dans le choix. Le montage ne peut pas attendre +la réponse : le premier viewport d'une session ouvre en Compatible et la réponse est là pour le +suivant. Quatre tests dans `gpuAdapter.test.ts` couvrent six cas, dont l'adaptateur refusé et +`requestAdapter` qui lève ; quatre cas de repli par `mountRenderer.test.ts`. + +### Écart assumé : où vit le sélecteur + +Le MUST 7 demande un sélecteur **à la création de projet**, verrouillé ensuite. Deux faits du dépôt +s'y opposent : + +1. `RenderPolicy` vit dans `Settings.three` (`settings.ts:178`), un réglage d'application, pas dans + le manifeste de projet — lequel ne porte que `version`, `createdAt`, `updatedAt` + (`domain/project.ts:101`). +2. Créer un projet, c'est choisir un dossier (`stores/project.ts:434`, `createPicked`). Il n'y a + aucun dialogue de création où poser deux options. + +Le sélecteur était donc dans l'espace 3D des préférences, avec la copie demandée +(« Compatible » / « Avancé »). + +**Écart refermé à l'étape 4 : § 4.6.** Le choix est passé dans le monde du document de scène et +la préférence ne fait plus que pré-remplir le champ de « Nouveau document ». + +Deux revues sur trois ont demandé de ne pas livrer le réglage tant qu'Avancé ne dessinait rien. +L'étape 3 l'a rendu caduc : le moteur Avancé dessine. Son aide dit maintenant ce qui reste vrai — +une machine sans adaptateur WebGPU retombe d'elle-même sur le Compatible et le note au journal. + +### Registre post-processing + +`PostEffectMeta.engines`, les trente effets existants en `['gl']`. Les `PostSlot` et la règle +`EXCLUSIVE` ne bougent pas. Le filtrage d'un effet par moteur n'était pas écrit à cette étape : il +n'avait aucun effet tant qu'aucun effet GPU n'existait, et un filtre qu'on ne peut pas voir tourner +est un filtre qu'on ne peut pas relire. **Écrit à l'étape 4, avec `traa` : § 4.8.** + +## Étape 3 + +### Ce qui a été mesuré, et sur quoi + +Machine : Apple M2 Max, Electron du dépôt, `navigator.gpu.requestAdapter()` répond. Banc : +`pnpm engines:bench`, qui pilote `engineBenchmark.browser.ts` par CDP sur `pnpm start:debug`. +Surface 1280×720, qualité `high`, une pile portant GTAO sur les deux moteurs. + +**Ce que chaque colonne mesure, et rien de plus** : + +- `submitMs` — ce que le THREAD UI dépense à assembler et enfiler une image, moyenne sur + 60 images après 10 de chauffe. 🛑 **Pas** le coût de l'image sur la carte : les deux `render()` + rendent la main dès les commandes enfilées. +- `firstStillMs` — la PREMIÈRE image fixe : `captureStill` en entier. Elle dessine dans une + cible, relit les pixels et encode un PNG hors thread, donc elle vide la file — et absorbe du + même coup tout ce qui restait à compiler. +- `stillMs` — la MOYENNE des suivantes, une fois plus rien à compiler. Ce qu'un export paie par + image fixe. 🛑 L'encodage PNG est dedans et il est le même sur les deux moteurs : ce chiffre + SOUS-ESTIME l'écart entre eux au lieu de le montrer. + +| Profil | Moteur | `submitMs` | `firstStillMs` | `stillMs` | +| --- | --- | ---: | ---: | ---: | +| Un modèle (4 nœuds) | Compatible | 0,050 | 63,9 | 44,47 | +| Un modèle (4 nœuds) | Avancé | 0,088 | 48,5 | 45,66 | +| Monde ouvert C5 (20 000 nœuds) | Compatible | 0,087 | 52,8 | 41,18 | +| Monde ouvert C5 (20 000 nœuds) | Avancé | 0,122 | 65,9 | 42,63 | + +`submitMs` est la moyenne de 60 images, `stillMs` celle de 10 images fixes, `firstStillMs` un +échantillon unique. + +**Ce que ces chiffres disent, sans arrangement** : + +- **Le moteur Avancé coûte plus cher côté CPU par image** : 0,088 contre 0,050 sur un modèle, + 0,122 contre 0,087 sur le monde ouvert. Soit 1,4 à 1,8 fois. Les deux restent très en dessous + d'un budget d'image. +- **Il monte moins vite avec la scène** : de 4 à 20 000 nœuds, le Compatible passe de 0,050 à + 0,087 (+74 %) et l'Avancé de 0,088 à 0,122 (+39 %). Il monte quand même. Une machine, deux + profils : c'est une observation, pas une loi, et surtout pas une extrapolation. +- **À chaud, les deux moteurs sortent une image fixe au même prix** (41 à 46 ms) : l'écart est + dans le bruit. L'encodage PNG est dedans, identique des deux côtés, et pèse l'essentiel de + ces millisecondes — ce chiffre sous-estime donc l'écart entre les moteurs au lieu de le montrer. +- **`firstStillMs` n'est pas reproductible d'une exécution à l'autre.** Un premier passage sur + cette révision a mesuré **640,8 ms** côté Avancé ; celui du tableau en mesure 48,5. La + différence est le cache de pipelines du navigateur, pas le moteur. À lire comme un ordre de + grandeur du coût de compilation à froid, jamais comme une comparaison. +- **Aucun seuil de gain n'est atteint.** Sur ces deux profils, le moteur Avancé n'est plus rapide + que le Compatible sur aucune des trois mesures. Ce qu'il apporte — la qualité d'éclairage et de + reflets — n'est pas ce que ce banc mesure, et n'a été comparé par aucune mesure de ce chantier. + +### 3.1 — Patch matériau en TSL + +`materialNodes.ts`. Ce n'est pas une traduction du GLSL, et deux écarts sont délibérés : + +- **Aucune recompilation quand un canal se remplit.** Le patch GLSL est gardé par + `#ifdef USE_ROUGHNESSMAP` : chaque slot rempli reconstruit le programme. Ici un uniforme `has` + choisit entre le texel remappé et le facteur nu, et remplir un slot déplace un nombre. +- **La cavité tombe sur la COULEUR diffuse** et non sur `reflectedLight`, sur quoi un matériau de + nœuds n'ouvre aucune couture. Identique pour un diélectrique — le cas où une cavité sert ; + sur un métal, dont three tire la teinte spéculaire de cette même couleur, l'Avancé assombrit + un peu ce que le Compatible laisse tranquille. **Écart connu, pas une équivalence.** + +Les uniformes sont ceux du moteur, partagés : un `Vector2` par référence, un scalaire et une +texture relus à chaque rendu parce qu'ils sont remplacés et non écrits dedans. Quatre tests +tiennent ce pont, dont celui qui vérifie que le graphe ne se reconstruit pas quand un canal arrive. + +**Mesurée à l'étape 4** : la comparaison visuelle GL/GPU du patch, § 4.1 et § 4.3. + +### 3.2 — GTAO en TSL + +`gpuComposer.ts` construit un `RenderPipeline` dont la passe de scène écrit ses normales en MRT, +puis multiplie l'occlusion `ao()` — la fonction native de three, pas une réécriture du `GTAOPass`. +`gtao.engines` devient `['gl', 'gpu']` ; c'est le seul effet des trente dans ce cas. + +Aucune logique de fusion façon `fuseShader` : `RenderPipeline` partage déjà profondeur et +normales entre les nœuds qui les lisent. + +### 3.3 — Lecture de pixels GPU + +`readRenderTargetPixelsAsync`, derrière la même signature promise des deux côtés — décidée à +l'étape 2 pour cette raison exacte. Les trois appelants (film, capture de vol, validation) +étaient déjà asynchrones et n'ont pas bougé. + +🛑 Un renderer de nœuds ne relit pas le canevas : la lecture veut une cible. Le studio en passe +toujours une, donc le chemin d'export est intact — mesuré ci-dessus par `firstStillMs` et +`stillMs`, qui sont `captureStill` en entier. + +### 3.4 — Budget qualité de la chaîne `RenderPipeline` + +`gpuPostQuality.ts`, **dérivé** de `postQuality` et jamais une seconde table : un réglage doit +acheter la même chose sur les deux moteurs. La division de résolution du chaînage GL devient le +`resolutionScale` du nœud, la part d'échantillons est la même valeur. Deux tests, qui comparent +les deux lectures réglage par réglage. + +Une limite honnête pour la suite : **TRAA n'expose aucun nombre d'échantillons** dans +three 0.185 — ses échantillons sont des IMAGES, une par gigue d'une séquence fixe. Son seul +levier est la correction sous-pixel. **Branchée à l'étape 4**, quand l'effet a été porté : +`GpuBudget.samples` décide de `TRAANode.useSubpixelCorrection` — le budget n'a pas de membre à lui +pour ça, la correction étant tout ou rien. + +### 3.5 — Ce qui a dû être réparé pour que l'Avancé dessine + +Trouvés en faisant tourner le banc, pas en lisant le code : + +- Le montage demandait au renderer son contexte WebGL2 pour la minuterie GPU. Un renderer de + nœuds **lève** si on lui demande son contexte avant que son backend soit prêt. +- La scène préfiltrait la salle neutre (`setStudio`) dans la foulée du montage : `fromScene` + refuse avant l'init. L'éclairage du montage attend désormais `settled()` — et passe tout droit + quand le moteur peut déjà dessiner, ce qui est le cas de chaque montage WebGL. +- Les images sont retenues tant que le backend n'est pas là (`canDraw`), sinon chaque `render()` + lève. +- Le banc lui-même attendait deux `requestAnimationFrame` : une fenêtre qui n'est pas à l'écran + n'en reçoit aucun, et le banc restait pendu au lieu de rendre un chiffre. + +Et neuf autres trouvés par la revue adverse, tous dans le chemin GPU — les plus graves : +la lecture de pixels rendait les lignes **paddées à 256 octets et à l'endroit** là où le +Compatible les rend serrées et à l'envers (toute capture cisaillée et retournée) ; la chaîne +gelait la **caméra** avec laquelle elle avait été bâtie (un film qui change de caméra en cours +continuait sur la première) ; `RenderPipeline.dispose` ne libère que son quad, donc chaque +chaîne évincée fuyait un G-buffer plein écran ; et `colorNode` écrasait la **carte de couleur** +de base, ce qui aurait rendu tout matériau texturé plat. + +### 3.6 — TRAA : écarté à l'étape 3, porté à l'étape 4 + +Le spec le donne en SHOULD, « si le motif GTAO n'a pas révélé de problème ». Il en a révélé un : +`traa` n'existe pas côté Compatible, donc l'ajouter au catalogue publiait un effet que la moitié +des projets ne peuvent pas dessiner — et le rendre visible demandait une bibliothèque d'effets +consciente du moteur, c'est-à-dire l'UX que le spec met hors périmètre. + +**Cette UX a été autorisée depuis, et les deux sont livrés ensemble : § 4.8.** + +### 3.7 — Ce que l'Avancé ne fait pas encore, écrit plutôt que découvert + +- **Un ciel corrigé s'affiche tel que son fichier le contient.** La correction est une chaîne de + passes GLSL écrites à la main (`skyGrading`) ; il n'y a pas d'équivalent en nœuds. Dit une fois + dans le journal, jamais en silence. +- **Pas de minuterie GPU** : `EXT_disjoint_timer_query_webgl2` est au Compatible. +- **Pas de porte globale sur la passe d'ombres** : un renderer de nœuds n'a pas + `shadowMap.needsUpdate`. Le resserrement lumière par lumière de `limitShadowUpdates` reste, + et c'est sur lui que l'éditeur s'appuyait déjà. +- **Les vingt-neuf effets GLSL restent au Compatible** : le registre le dit effet par effet, et la + chaîne Avancée laisse simplement de côté ce qu'elle ne sait pas bâtir. +- **L'aperçu incrusté n'est retenu par rien.** `ViewportDrawing.renderInset` ne regarde pas le + moteur, et son quad porte un `MeshBasicMaterial`, que les deux savent dessiner. **Non mesuré** : + ce que l'aperçu donne sur une scène Avancée n'a jamais été regardé, `engines:parity` ne l'ouvrant + sur aucun de ses six cas. +- **Les cascades sont refusées au moteur Avancé**, depuis la relecture du 11/09/2026 : + `CSM` passe par `onBeforeCompile`, un crochet que seul `WebGLRenderer` appelle — three 0.185 ne + le nomme nulle part sous `renderers/common` ni `renderers/webgpu`. Bâties là, le patch n'aurait + atteint aucun programme tandis que `dress` aurait quand même éteint le soleil du document et posé + trois bandes à son intensité : la scène éclairée trois fois et son ombre perdue, exactement la + panne corrigée côté GL le 08/09/2026. `cascadesWanted` refuse plutôt que de dessiner faux. + **Non mesuré** : ce que cette combinaison donnait n'a pas été rendu. + +## Étape 4 — Compléments (hors périmètre du spec, demandés après) + +### 4.1 — Parité visuelle GL/GPU : `pnpm engines:parity` + +`engineParity.browser.ts`, piloté par CDP sur `pnpm start:debug`, comme `world:validate`. Six cas, +chacun par la couture que le studio emprunte lui-même — pas une reconstitution. + +🛑 **Condition : la fenêtre du studio doit être au premier plan.** Le harnais l'ATTEND (90 s) et +refuse de mesurer sans elle. Motif mesuré le 11 septembre 2026 : masquée, la même révision +rapportait 58 % de pixels différents sur une scène qu'elle dessine à l'identique devant. Une +fenêtre derrière une autre ne reçoit aucune image d'animation ; three fait avancer depuis SA +boucle d'animation le compteur sur lequel sont gardées les mises à jour `NodeUpdateType.FRAME` +(GTAO et TRAA en sont), et le moteur Compatible ne redessine une carte d'ombre que sur une image +qu'il juge périmée — sans image, toute surface reste dans une carte jamais dessinée, c'est-à-dire +noire. + +| Cas | Taille | Pixels différents | Écart max sur un canal | +| --- | ---: | ---: | ---: | +| `scene` — la scène nue, sans composition | 128² | **0 %** | 3 | +| `occlusion` — la même sous GTAO | 128² | 2,76 % | 98 | +| `still` — `captureStill`, le chemin d'export | 1024² | 0,22 % | 65 | +| `film` — `renderFilm`, une image | 642 × 362 | **0 %** | 4 | +| `material` — patch matériau, cartes tuilées | 128² | 24,75 % | 126 | +| `temporal` — un effet temporel laissé hors d'une image unique | 1024² | 13,61 % | 74 | + +Tolérance : 8 niveaux par canal. Les plafonds du runner sont des MARGES posées au-dessus de ces +mesures, et non ces mesures arrondies : 1 % pour `scene` (mesuré 0), 6 % pour `occlusion` (2,76), +2 % pour `still` (0,22) et pour `film` (0), 20 % pour `temporal` (13,61). Ils gardent contre une +image noire ou plate, pas contre une dérive de quelques pour cent — un tel resserrement demanderait +plusieurs exécutions sur plusieurs machines, et **aucune n'a été faite**. `material` n'a pas de +plafond du tout : son écart est CONNU, et transformer une divergence documentée en réussite ou en +échec serait mentir dans les deux sens. + +**Ce que ces chiffres disent, sans arrangement** : sur cette machine, les deux moteurs dessinent +la même scène. `scene` et `film` sont à zéro pixel différent — pas « proches » : identiques à la +tolérance d'encodage près. L'export est à 0,22 %. + +### 4.2 — Trois défauts que cette comparaison a trouvés, et rien d'autre + +1. **L'occlusion sortait ROUGE côté Avancé.** `GTAONode` rend son occlusion dans une cible + `RedFormat` ; `getTextureNode()` donne donc `(ao, 0, 0, 1)`, et le multiplier tel quel dans + l'image tuait le vert et le bleu. La fiche de three écrit `colour.mul(vec4(vec3(ao.r), 1))`. + **Le banc mesurait ce que cette chaîne COÛTE ; personne n'avait regardé ce qu'elle dessine.** +2. **`blend` ne faisait rien côté Avancé.** Le paramètre est dans la fiche `gtao` et le `GTAOPass` + GL l'applique (`blendIntensity`) ; la chaîne de nœuds l'ignorait — un curseur vivant qui ne + changeait rien sur la moitié des projets. +3. **Un effet temporel donnait une image PLATE à l'export.** Un nœud qui résout contre les images + précédentes reçoit un historique vide quand la chaîne est bâtie, dessinée puis libérée : une + capture d'une scène portant `traa` revenait en un gris uni. `PostEffectMeta.temporal` le + déclare et `gpuComposer` laisse ces effets hors de la surface `offscreen`. + +Le harnais lui-même en a livré un quatrième, sur lui : une capture prise avant toute image lisait +des cartes d'ombre jamais dessinées côté Compatible — image noire — et aurait accusé l'autre moteur. + +### 4.3 — L'écart connu, mesuré, non corrigé + +La cavité tombe sur la couleur diffuse côté nœuds (§ 3.1). Sur un métal, dont three tire la teinte +spéculaire de cette même couleur, l'Avancé assombrit ce que le Compatible laisse tranquille : +**24,75 % des pixels, 126 d'écart maximal sur un canal**, sur une sphère à métallicité 0,6 portant +une rugosité et une métallicité tuilées quatre fois plus une cavité. Images : +`materiau-compatible.png`, `materiau-avance.png` — la différence se voit sur les faces sombres du +damier, pas sur sa forme. + +### 4.4 — Une divergence antérieure à ce lot, mise au jour par le harnais + +`temporal` mesure 13,61 % là où l'on attendrait zéro : les deux côtés sont le moteur Avancé, l'un +avec une pile dont la chaîne retire tout, l'autre sans pile. **Même image, deux tons.** Le rendu +droit du composeur passe par `setOutputRenderTarget`, celui du viewport sans composeur n'y passe +pas, et la transformation de sortie ne suit pas le même chemin. Retirer cette pose empire +franchement le résultat — 100 % des pixels, la lecture revient linéaire, mesuré le 11/09/2026 — +donc elle reste. + +Cela touche **toute** scène Avancée portant une pile d'effets GLSL que ce moteur ne sait pas bâtir, +c'est-à-dire le cas courant : c'est antérieur à ce lot et hors de son périmètre. La correction +propre est que `SceneComposer.draw` RÉPONDE s'il a composé, et que l'appelant dessine droit +lui-même quand il n'a rien composé — un seul rendu droit dans le dépôt au lieu de trois. À faire +au lot suivant. + +Le plafond de cette ligne est à 20 % pour cette raison : ce qu'elle garde est l'absence du gris +uni, qui porterait la ligne à 100 %. + +### 4.5 — Capture d'export sur un projet Avancé + +`capture-avance.png` (1024², `captureStill`) et `film-avance.png` (642 × 362, `renderFilm`), +toutes deux dessinées par le moteur Avancé, jointes à ce rapport. + +642 délibérément : 642 × 4 = 2 568 octets, qui n'est pas un multiple de 256. WebGPU aligne une +copie texture → tampon sur 256 octets par ligne, et un lecteur qui garde le mou cisaille l'image +un peu plus à chaque ligne. 1 024 et 640 divisent proprement et ne prouvent rien là-dessus. + +### 4.6 — Le moteur vit dans le DOCUMENT + +L'écart de l'étape 2 est refermé. `SceneWorld.engine` : choisi à la création du document, écrit +dans son monde et relu à chaque montage de viewport. `Settings.three.engine` ne sert plus qu'à +pré-remplir le champ ; son aide le dit, dans les quinze langues. + +🛑 **L'export jeu PORTE le champ et ne l'honore pas.** `gameExportCompiler` écrit dans le manifeste +le moteur de la scène d'ENTRÉE — un jeu ne tenant qu'un renderer — et `readRenderPolicy` le relit à +l'ouverture, mais `createWebRender` construit un `WebGLRenderer` sans jamais regarder `policy.engine` +(vérifié le 11/09/2026 : le membre n'est lu nulle part dans le runtime de jeu). Un jeu exporté +dessine donc en Compatible quel que soit le moteur de son document. + +Corrigé le 11/09/2026 dans ce qui pouvait l'être sans embarquer le bundle de nœuds dans une page +exportée : **le jeu le DIT** (`sayEngineIgnored`), une fois au chargement, par le port de journal +que `webRender` ouvre déjà pour la chaîne d'effets qui ne bâtit pas (`policyOf`). Même doctrine — un jeu qui +joue sans ce que son auteur a demandé le dit au lieu de jouer quand même. Honorer le champ pour +de bon reste ouvert. + +Le verrou « pas de switch après création » est donc vrai au sens fort : changer la préférence ne +touche aucune scène existante. Un document lu sur une machine dont la préférence dit le contraire +dessine comme son auteur l'a dessiné. Un fichier écrit avant que ce membre existe lit `gl`, qui est +ce avec quoi il a été dessiné. + +🛑 **Un onglet monte AVANT que son fichier ait atterri** (`restoreDocument` lit le disque). Un +document enregistré en Avancé ouvre donc en Compatible et **reconstruit son renderer une fois** +quand son monde arrive — `useMountedSceneRenderer` prend le moteur en dépendance pour cela, au +prix d'un contexte graphique jeté. Un document neuf est semé avant que son onglet s'ouvre : il +monte une seule fois. + +Attendre l'état plutôt que remonter a été essayé le 11 septembre 2026 et remis en arrière : un +onglet dont le document n'arrive jamais ne dessinerait alors plus rien du tout, ce qui est pire +qu'un contexte gâché (dix tests de `SceneDocument` l'ont montré). La correction propre est de +faire répondre `useRestoredDocument` la prêtitude que `restoreDocument` calcule déjà — à faire au +lot suivant. + +Le champ est dans « Nouveau document », sous le modèle de départ et au-dessus de l'emplacement, +pour la seule sorte scène. Chaque option porte sa description ; aucune ligne d'aide sous le champ. + +### 4.7 — Ce que `/code-review` a trouvé en plus + +Neuf points, dont un **haut** : **un document en Avancé n'obtenait jamais le moteur Avancé dans +la session qui l'ouvre.** Le viewport lançait le chargement du bundle `three/webgpu` et, sur la +ligne suivante, demandait s'il était là — non — donc montait le Compatible, écrivait un repli au +journal qui n'en était pas un, et ne redemandait plus. `useRenderEngineReady` retient le montage +jusqu'à ce que ce chargement ABOUTISSE, y compris sur « cette machine n'a pas d'adaptateur », qui +est un repli et non une attente : rien ne peut donc rester en suspens. + +Les huit autres, tous corrigés : + +- `GTAONode` possède une cible plein écran `RedFormat` et un matériau que `RenderPipeline.dispose` + n'atteint pas : chaque chaîne évincée en fuyait une ; +- une surface qui change la forme de sa pile abandonnait sa chaîne précédente jusqu'au balayage ; +- `maxSamples` répondait à DEUX questions — le plafond de la carte et ce à quoi le tampon de + dessin est lissé — et une capture fixe perdait son anticrénelage ; +- `PostComposer` n'empilait pas d'applicateur pour un effet qu'il ne sait pas bâtir, alors que + `draw` parcourt les deux listes par le même index : un effet GPU-only dans un créneau plus haut + aurait donné les paramètres de l'un à la passe de l'autre ; +- supprimer le soleil et en ajouter un autre laissait les cascades debout pour un soleil mort, et + la scène éclairée deux fois ; +- la liste d'effets lisait un registre non réactif dans un mémo, donc gelait ce qu'il disait au + premier rendu. + +### 4.8 — TRAA, et la bibliothèque filtrée par moteur + +`traa()` porté tel quel (`three/addons/tsl/display/TRAANode.js`), catégorie `aa`, créneau `aa`, +`engines: ['gpu']`, sans paramètre — **trois 0.185 n'expose aucun nombre d'échantillons**, ses +échantillons étant des IMAGES d'une séquence de gigues fixe. Son seul levier qualité, +`useSubpixelCorrection`, est branché sur `gpuPostQuality` : le réglage qui coupe des échantillons +ailleurs coupe la correction ici. + +La chaîne ajoute la vélocité au MRT **seulement** quand quelque chose reproject, et bâtit sa passe +de scène en `samples: 0` — un `PassNode` prend sinon le multi-échantillonnage du renderer, et +résoudre deux fois étale l'image. + +**Ce que TRAA dessine, mesuré** : sur une chaîne persistante rendue une image par image +d'animation, **403 couleurs distinctes contre 101 sans lui** (sphère de 128², 12 mises à jour, +historique 128²) — c'est exactement ce qu'un anticrénelage temporel fait, remplir les teintes +intermédiaires le long des arêtes. Mesuré à la main le 11/09/2026 ; `engines:parity` ne peut pas +le mesurer, ses captures libérant leur chaîne à chaque image. + +**La bibliothèque est filtrée par le moteur du document** (`effectsForEngine`) : un effet que la +chaîne laisserait tomber n'est plus proposé. Les lignes DÉJÀ dans une pile ne bougent pas — un +moteur ne rend pas un document faux. + +**Angle mort assumé** : le filtre lit le moteur du DOCUMENT, pas celui qui a été monté. Une machine +sans adaptateur WebGPU retombe sur le Compatible et se voit encore proposer les effets Avancés, +que la chaîne écarte ensuite. Le repli est au journal ; cette liste dit ce que le document demande. + +## Ce qui reste ouvert + +- **La transformation de sortie du rendu droit du composeur** contre celle du viewport : 13,6 % + d'écart de ton, § 4.4. Antérieure à ce lot, correction nommée, à faire au suivant. +- Les vingt-neuf autres effets et la correction de ciel côté Avancé. L'aperçu incrusté, lui, + n'est retenu par rien et n'a jamais été regardé sur une scène Avancée — § 3.7. +- Le switch en direct du moteur : hors périmètre, et désormais impossible par construction. +- SSGI : hors périmètre par décision du spec. +- **TRAA à l'export** : laissé de côté hors écran par construction (§ 4.2), y compris sur un film, + dont la chaîne vivrait pourtant assez longtemps pour le résoudre à partir de la deuxième image. + Distinguer un film d'une image fixe demanderait une surface de plus ; non fait. +- **Ni la fenêtre de jeu ni l'export ne suivent le moteur du document.** Deux causes distinctes : + la fenêtre bâtit son renderer avant que la scène arrive sur `gameChannel` (écrit dans + `GameWindow.tsx` ; la correction est de retenir ce montage jusqu'à la première scène), et le + runtime de jeu ne lit `policy.engine` nulle part — `createWebRender` construit toujours un + `WebGLRenderer`. Depuis le 11/09/2026 il le DIT au journal au lieu de se taire ; l'honorer + demande de porter le bundle de nœuds dans une page exportée, ce qui est un chantier à part. + § 4.6. +- **Les cascades sont refusées au moteur Avancé** plutôt que portées : `CSM` passe par + `onBeforeCompile`, que seul `WebGLRenderer` appelle. § 3.7. +- Coût réel des cascades et de l'anisotropie : à mesurer sur un banc GPU, qui n'existe pas encore + dans ce dépôt. +- **Cinq coûts par image relevés par la relecture du 11/09/2026, tous NON MESURÉS** — aucun banc + du dépôt ne les chiffre, et ils sont écrits ici plutôt que corrigés à l'aveugle : + 1. En vue quadruple avec cascades, chaque pane recoupe les bandes et redoit ses trois cartes + d'ombre : les trois vues ajoutées ont leurs propres caméras, donc la projection diffère à + chaque pane. C'est le prix d'un seul `CSM` pour quatre panes, pas un gaspillage — le corriger + demande une instance par caméra. Écrit dans `csm.ts`. + 2. `materialNodes.placedUv` refait à la main ce que `TextureNode` fait déjà : un `texture(map)` + sans uv porte sa propre matrice (`setUpdateMatrix(uvNode === null)`), et le `.sample(...)` + désactive ce chemin pour le réécrire. Lu dans la source de three 0.185, jamais rendu : + vérifier demande `pnpm engines:parity` sur une machine WebGPU, fenêtre au premier plan. + 3. Dix rappels `onRenderUpdate` par matériau sont invoqués à chaque appel de rendu ; huit + relisent une valeur qui ne bouge qu'à l'écriture du panneau. Même condition de vérification. + 4. `dressCascades` rebalaie tous les hôtes instanciés à chaque `applyState`, y compris pour un + delta d'un nœud ; après la première passe c'est un no-op complet. + 5. `gpuComposer.draw` filtre la pile par moteur à chaque image et par surface. La correction + est de faire porter le moteur à `planStack`, qui est déjà mémoïsé par pile — c'est la même + correction que le bourrage `() => {}` de `PostComposer` et que le champ `oneShot`, et elle + règle au passage qu'une pile Compatible portant `traa` recompile toute la chaîne GLSL pour + une image identique. Chantier à part. diff --git a/docs/fr/audits/moteur-rendu-c6/capture-avance.png b/docs/fr/audits/moteur-rendu-c6/capture-avance.png new file mode 100644 index 000000000..4f7cff308 Binary files /dev/null and b/docs/fr/audits/moteur-rendu-c6/capture-avance.png differ diff --git a/docs/fr/audits/moteur-rendu-c6/film-avance.png b/docs/fr/audits/moteur-rendu-c6/film-avance.png new file mode 100644 index 000000000..b7200adb9 Binary files /dev/null and b/docs/fr/audits/moteur-rendu-c6/film-avance.png differ diff --git a/docs/fr/audits/moteur-rendu-c6/materiau-avance.png b/docs/fr/audits/moteur-rendu-c6/materiau-avance.png new file mode 100644 index 000000000..3b28a7203 Binary files /dev/null and b/docs/fr/audits/moteur-rendu-c6/materiau-avance.png differ diff --git a/docs/fr/audits/moteur-rendu-c6/materiau-compatible.png b/docs/fr/audits/moteur-rendu-c6/materiau-compatible.png new file mode 100644 index 000000000..8b6989aaa Binary files /dev/null and b/docs/fr/audits/moteur-rendu-c6/materiau-compatible.png differ diff --git a/llms-full.txt b/llms-full.txt index cb7ae3bcf..4d6512616 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -70,7 +70,11 @@ Generate and edit images, videos, 3D models, audio, textures and skyboxes — in
- AI Desktop Studio in the Modelling workspace: the model catalogue and the project explorer on the left, a generated car standing in the scene viewport in the centre, the scene outliner and the inspector on the right, and across the bottom the timeline with one row per light and object + AI Desktop Studio in the Modelling workspace: the asset library and the project explorer on the left, a rigged robot character standing in the model workshop in the centre with its skeleton drawn over it, and the inspector on the right showing its mesh counts, its skeleton and its attachment points +
+ +
+ The same studio with a third-person level open: blocked-out platforms in the viewport, the character selected and drawn in wireframe, and the scene environment, background and post-processing stack in the inspector
--- @@ -109,6 +113,52 @@ to come. --- +## The workspaces + +Above: the model workshop, and a scene built from the third-person starter. The rest, one panel +arrangement per kind of work. + + + + + + + + + + + + + + +
+Home
+The project shelf, the tools, and what your models already cover.

+The Home surface: the project shelf on the left, the tool cards in the centre, and panels reporting installed models, connected services and per-workspace coverage +
+Image
+A layered canvas, with document, layer and transform in the inspector.

+The Image workspace: a render of the robot open on a layered canvas over a transparency checkerboard, the tool column on the left, document and layer properties on the right +
+Video
+Source and programme viewers, over a timeline that decodes for real.

+The Video workspace: source and programme viewers above a video and audio timeline, with a text-to-video generation form on the left +
+Code
+Behaviour written script by script, and rewritten in place by a model.

+The Code workspace: a TypeScript player script open in the editor, and a code-rewrite generation panel on the left targeting that same file +
+Audio
+A spectrum, a clip editor and a multitrack montage, working on samples.

+The Audio workspace: a frequency spectrum above a clip editor, and three audio tracks on the timeline below +
+Skyboxes
+A panorama, the sun that goes with it, and test objects lit on the spot.

+The Skyboxes workspace: a mountain panorama projected around two test spheres, one matte and one mirrored, with sun, adjustment and environment controls in the inspector +
+ +--- + ## Getting started **Requirements** — Node **24** (the version in `.nvmrc`, which is also what CI runs), [pnpm 12.3.4 installed with its standalone installer](https://pnpm.io/installation) (Corepack does not yet run pnpm 12), macOS / Windows / Linux, and a diff --git a/package.json b/package.json index b44575408..9c2711e91 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "start:debug": "node scripts/fetch-engine.mjs --sources-only && node scripts/dev-app-identity.mjs && electron-vite dev --watch --remoteDebuggingPort 9222", "cdp": "node scripts/cdp.mjs", "world:validate": "node scripts/run-world-safe-validation.mjs", + "engines:bench": "node scripts/run-engine-benchmark.mjs", + "engines:parity": "node scripts/run-engine-parity.mjs", "picking:validate": "electron out/main/pickingValidation.js", "build": "pnpm typecheck && electron-vite build && pnpm game:runtime && node scripts/check-artefact.mjs", "game:runtime": "vite build --config config/vite.game.config.ts", diff --git a/scripts/banc/scenariosRestAnimation.ts b/scripts/banc/scenariosRestAnimation.ts index 88abcf28a..e1b478aad 100644 --- a/scripts/banc/scenariosRestAnimation.ts +++ b/scripts/banc/scenariosRestAnimation.ts @@ -85,7 +85,10 @@ export const REST_ANIMATION_SCENARIOS: readonly Scenario[] = [ name: '48.4 puts the render at its highest quality', said: ['Passe le rendu en qualité maximale.'], setup: cubeScene, - passed: run => read.world(run)?.toneMapping !== 'none', + // The CALL as well as the state: a scene created since C6 opens on AgX, so « a curve is + // active » became true of the decor itself and the oracle measured nothing. + passed: run => + read.answeredWith(run, 'world.setToneMapping') && read.world(run)?.toneMapping !== 'none', }, { name: '48.5 adds a scatter layer to the world', diff --git a/scripts/cdp.mjs b/scripts/cdp.mjs index 91cb78f1c..917d12869 100644 --- a/scripts/cdp.mjs +++ b/scripts/cdp.mjs @@ -21,6 +21,25 @@ async function rendererTarget() { return page.webSocketDebuggerUrl } +/** + * Charge un harnais du renderer et appelle ce qu'il a posé sur `window`. + * + * Trois scripts en avaient chacun leur copie : un harnais vit dans le renderer parce que ni les + * moteurs ni les mondes ne se mesurent sous node, et le seul moyen de l'atteindre est l'import + * dynamique que le serveur de dev sert, suivi de la lecture du handle. + */ +export async function harness(modulePath, { handle, timeout = 30_000 }) { + return await evaluate( + `(async () => { + await import(${JSON.stringify(modulePath)}) + const run = Reflect.get(window, ${JSON.stringify(handle)}) + if (typeof run !== 'function') throw new Error('harnais absent : ${handle}') + return await run() + })()`, + { timeout }, + ) +} + export async function evaluate(expression, { timeout = 30_000 } = {}) { const socket = new WebSocket(await rendererTarget()) await new Promise((resolve, reject) => { diff --git a/scripts/run-engine-benchmark.mjs b/scripts/run-engine-benchmark.mjs new file mode 100644 index 000000000..b960eab75 --- /dev/null +++ b/scripts/run-engine-benchmark.mjs @@ -0,0 +1,28 @@ +import { harness } from './cdp.mjs' + +// Le harnais vit dans le renderer : les deux moteurs ont besoin d'un vrai périphérique, et une +// mesure prise sous node parlerait du chargement des modules, pas d'une image. +const result = await harness('/src/engines/render/engineBenchmark.browser.ts', { + handle: '__iaBenchmarkRenderEngines', + timeout: 300_000, +}) + +console.log(JSON.stringify(result, null, 2)) + +if (!Array.isArray(result) || result.length === 0) { + throw new Error('le banc n’a mesuré aucun profil') +} + +// Le moteur Avancé peut être indisponible — c'est un résultat, pas un échec. Ce qui serait une +// panne, c'est un profil dont AUCUNE colonne n'a de chiffre. +const silent = result.filter(entry => entry.measures.every(measure => measure.submitMs === null)) +if (silent.length > 0) { + throw new Error(`aucun moteur n’a dessiné : ${silent.map(entry => entry.profile).join(', ')}`) +} + +const fellBack = result.flatMap(entry => + entry.measures + .filter(measure => measure.engine !== measure.drawnWith) + .map(measure => `${entry.profile} : ${measure.engine} a été dessiné en ${measure.drawnWith}`), +) +if (fellBack.length > 0) console.log(`\nReplis :\n${fellBack.join('\n')}`) diff --git a/scripts/run-engine-parity.mjs b/scripts/run-engine-parity.mjs new file mode 100644 index 000000000..87638d03d --- /dev/null +++ b/scripts/run-engine-parity.mjs @@ -0,0 +1,90 @@ +import { Buffer } from 'node:buffer' +import { mkdirSync, writeFileSync } from 'node:fs' +import { evaluate, harness } from './cdp.mjs' + +// Le harnais vit dans le renderer : les deux moteurs ont besoin d'un vrai périphérique, et une +// comparaison faite sous node parlerait du chargement des modules, pas d'une image. +const result = await harness('/src/engines/render/engineParity.browser.ts', { + handle: '__iaCompareRenderEngines', + timeout: 300_000, +}) + +console.log(JSON.stringify(result, null, 2)) + +if (!Array.isArray(result) || result.length === 0) { + throw new Error('la parité n’a comparé aucun cas') +} + +// Le moteur Avancé peut être indisponible — c'est un résultat, pas un échec. Ce qui serait une +// panne, c'est un cas où l'un des deux côtés n'a rien dessiné du tout. +const blank = result.flatMap(entry => + entry.failed + ? [] + : ['gl', 'gpu'] + .filter(engine => !entry.drew?.[engine]) + .map(engine => `${entry.case} : le côté ${engine} n’a dessiné qu’une couleur`), +) + +const fellBack = result.filter(entry => entry.drawnWith?.gpu === 'gl') +if (fellBack.length > 0) { + console.log( + `\nRepli : l'Avancé a été dessiné en Compatible sur ${fellBack.map(one => one.case).join(', ')}` + + ` — sur cette machine la comparaison ne compare rien.`, + ) +} + +// Les deux images de chaque cas, parce qu'un taux n'est pas un diagnostic : un côté qui n'a rien +// dessiné, une image retournée et une image simplement plus sombre donnent le même nombre. La +// capture d'export que le critère d'acceptation demande est l'une d'elles, `still-gpu.png`. +const frames = process.env.PARITY_FRAMES_DIR +if (frames) { + const held = await evaluate(`Reflect.get(window, '__iaEngineParityFrames') ?? null`) + if (!held) throw new Error('aucune image n’a été retenue') + mkdirSync(frames, { recursive: true }) + for (const [name, sides] of Object.entries(held)) { + for (const [engine, bytes] of Object.entries(sides)) { + writeFileSync(`${frames}/${name}-${engine}.png`, Buffer.from(bytes)) + } + } + console.log(`\nImages écrites dans ${frames}`) +} + +if (blank.length > 0) throw new Error(`un côté n’a rien dessiné —\n${blank.join('\n')}`) + +/** + * Ce qu'un cas a le droit de faire bouger. Ce sont des MARGES posées au-dessus des mesures du + * 11 septembre 2026 sur cette machine, et non ces mesures arrondies : elles gardent contre une + * image noire ou plate, pas contre une dérive de quelques pour cent — resserrer demanderait + * plusieurs exécutions sur plusieurs machines, et aucune n'a été faite. Le cas `material` n'a pas + * de plafond : l'écart y est CONNU et attendu (la cavité tombe sur la couleur diffuse côté + * nœuds, donc un métal diffère), il est rapporté et jamais transformé en réussite ou en échec. + */ +// `temporal` est haut, et c'est délibéré : cette ligne garde l'absence de la COULEUR PLATE qu'un +// nœud temporel donne sur une image unique — un retour en arrière la porterait à 100 %. Les 13,6 % +// mesurés sont un écart de TON entre le rendu droit du composeur et celui du viewport sans +// composeur, antérieur à ce lot et écrit dans le rapport. +const CEILINGS = { scene: 0.01, occlusion: 0.06, still: 0.02, film: 0.02, temporal: 0.2 } + +const drifted = result.flatMap(entry => { + if (entry.failed) return [] + const ceiling = CEILINGS[entry.case] + if (ceiling === undefined) return [] + return entry.changedPixelRatio <= ceiling + ? [] + : [ + `${entry.case} : ${(entry.changedPixelRatio * 100).toFixed(2)} % des pixels diffèrent,` + + ` plafond ${(ceiling * 100).toFixed(2)} %`, + ] +}) + +const failed = result.filter(entry => entry.failed) +if (failed.length > 0) { + throw new Error( + `des cas n’ont pas pu être comparés —\n${failed.map(one => `${one.case} : ${one.failed}`).join('\n')}`, + ) +} + +// Après les images : un écart se regarde avant de se discuter, et le répertoire est déjà écrit. +if (drifted.length > 0) { + throw new Error(`les deux moteurs ne dessinent plus la même chose —\n${drifted.join('\n')}`) +} diff --git a/scripts/run-world-safe-validation.mjs b/scripts/run-world-safe-validation.mjs index 6a805f4ea..9360dbe8d 100644 --- a/scripts/run-world-safe-validation.mjs +++ b/scripts/run-world-safe-validation.mjs @@ -1,14 +1,9 @@ -import { evaluate } from './cdp.mjs' +import { harness } from './cdp.mjs' -const result = await evaluate( - `(async () => { - await import('/src/engines/scene/worldSafeValidation.browser.ts') - const validate = Reflect.get(window, '__iaValidateWorldBenchmarks') - if (typeof validate !== 'function') throw new Error('le harnais SAFE WebGL est absent') - return await validate() - })()`, - { timeout: 180_000 }, -) +const result = await harness('/src/engines/scene/worldSafeValidation.browser.ts', { + handle: '__iaValidateWorldBenchmarks', + timeout: 180_000, +}) console.log(JSON.stringify(result, null, 2)) diff --git a/site/template.html b/site/template.html index d08254956..46f2535b6 100644 --- a/site/template.html +++ b/site/template.html @@ -57,6 +57,14 @@ if (!r.classList.contains('fx-ready')) r.classList.remove('js'); }, 2500); + + + diff --git a/src/main/export/gameExport.test.ts b/src/main/export/gameExport.test.ts index 7c5e44568..87822152c 100644 --- a/src/main/export/gameExport.test.ts +++ b/src/main/export/gameExport.test.ts @@ -410,9 +410,11 @@ describe('a game written to run with no studio', () => { it('writes the render policy it was handed', async () => { const { ports, written } = writing() const render = { + engine: 'gl', shadows: true, shadowQuality: 'soft', shadowMapSize: 1024, + csm: false, quality: 'performance', fieldOfView: 50, gridSize: 30, diff --git a/src/main/localizedErrors.i18n.test.ts b/src/main/localizedErrors.i18n.test.ts index dbe965bf0..26a68fbab 100644 --- a/src/main/localizedErrors.i18n.test.ts +++ b/src/main/localizedErrors.i18n.test.ts @@ -86,8 +86,14 @@ function parsed(path: string): ts.SourceFile { ) } -// These modules drive validation probes or fake bridges, never user-facing diagnostic text. -const TECHNICAL_RENDERER = /(?:Validation|visualRegression|fakeBridge)/ +/** + * These modules drive validation probes or fake bridges, never user-facing diagnostic text. + * + * A HARNESS is never imported by the app, only by a script that drives a running window over CDP + * (`world:validate`, `engines:bench`, `engines:parity`), and what it throws is read by whoever + * ran that script from a terminal — `engineParity` covers the harness and the stage it draws. + */ +const TECHNICAL_RENDERER = /(?:Validation|visualRegression|fakeBridge|engineParity)/ // Main-only failures outside these paths can be logs or errors reduced to existing codes. const NATIVE_DIAGNOSTICS = [ diff --git a/src/main/settings/validation.ts b/src/main/settings/validation.ts index bcc99bff3..79c4c5c7e 100644 --- a/src/main/settings/validation.ts +++ b/src/main/settings/validation.ts @@ -38,6 +38,7 @@ import { SHADOW_QUALITIES, VIEWPORT_QUALITIES, } from '@shared/domain/scene' +import { RENDER_ENGINES } from '@shared/domain/renderEngine' import { HEX_COLOR } from '@shared/domain/color' import { localModelSchema } from '@main/ai/localModelSchema' import { migratedRoleChoices } from './migratedRoleChoices' @@ -211,8 +212,10 @@ const three = z.object({ gizmoSize: z.number().min(handles.min).max(handles.max).optional(), snapSurfaceAlign: z.boolean().optional(), snapSurfaceOffset: z.number().min(surfaceOffset.min).max(surfaceOffset.max).optional(), + engine: z.enum(RENDER_ENGINES).optional(), shadows: z.boolean().optional(), shadowQuality: z.enum(SHADOW_QUALITIES).optional(), + csm: z.boolean().optional(), // Read from the shared list, never retyped: what the panel offers and what this refuses have // to be the same numbers. shadowMapSize: z diff --git a/src/renderer/src/engines/gpu/gpuPipeline.ts b/src/renderer/src/engines/gpu/gpuPipeline.ts index 263460a5f..5fca54fe1 100644 --- a/src/renderer/src/engines/gpu/gpuPipeline.ts +++ b/src/renderer/src/engines/gpu/gpuPipeline.ts @@ -8,8 +8,8 @@ import { UnsignedByteType, type Material, type TextureDataType, - type WebGLRenderer, } from 'three' +import { drawInto, type StudioRenderer } from '../render/renderDriver' import { WebGLRenderTarget } from 'three' /** @@ -48,7 +48,7 @@ const PRECISION_TYPES: Record = { float: HalfFloatType, } -export function createGpuPipeline(renderer: WebGLRenderer): GpuPipeline { +export function createGpuPipeline(renderer: StudioRenderer): GpuPipeline { // A 2×2 plane seen by a camera spanning -1..1 covers the frame exactly, so `vUv` runs 0..1 // across the destination whatever its size. const camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1) @@ -60,14 +60,13 @@ export function createGpuPipeline(renderer: WebGLRenderer): GpuPipeline { const draw = (material: Material, target: WebGLRenderTarget | null): void => { quad.material = material - const previous = renderer.getRenderTarget() - renderer.setRenderTarget(target) + const restore = drawInto(renderer, target) try { renderer.render(scene, camera) } finally { // In a `finally`: a throw would otherwise leave the viewport drawing into this target // instead of the screen, and the window would freeze on its last frame. - renderer.setRenderTarget(previous) + restore() } } diff --git a/src/renderer/src/engines/material/MaterialRenderer.test.ts b/src/renderer/src/engines/material/MaterialRenderer.test.ts index b4008f289..d2ffae96f 100644 --- a/src/renderer/src/engines/material/MaterialRenderer.test.ts +++ b/src/renderer/src/engines/material/MaterialRenderer.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GlDriverModule from '../render/glDriver' import { PerspectiveCamera, RepeatWrapping, Vector3 } from 'three' import { PBR_CHANNELS, type PbrChannel } from '@shared/domain/material' import type { ViewportEnvironment } from '../viewport/environment' -import { fakeEnvironment, fakeTextureSource } from '../viewport/viewport-fixtures' +import { fakeEnvironment, fakeRenderer, fakeTextureSource } from '../viewport/viewport-fixtures' import { ViewportEngine } from '../viewport/ViewportEngine' import { MaterialRenderer } from './MaterialRenderer' import { newMaterial, slotFor, type ChannelMap, type MaterialState } from './materialState' @@ -33,12 +34,20 @@ let environment: ViewportEnvironment * context). Stubbing the viewport's mount and its `gl` accessor is enough — nothing here * dereferences the renderer, so what the engine decides is reachable and what it draws is not. */ -vi.mock('../viewport/environment', () => ({ - createEnvironment: () => { - environment = fakeEnvironment() - return environment - }, -})) +// Mocked at the DRIVER, which is what makes an environment now: mocking the module below it +// would still let the driver build a `PMREMGenerator` on a renderer jsdom cannot give. +vi.mock('../render/glDriver', async importOriginal => { + const actual = await importOriginal() + return { + glDriver: { + ...actual.glDriver, + createEnvironment: () => { + environment = fakeEnvironment() + return environment + }, + }, + } +}) const skyOf = (assetId: string): MaterialState => { const state = newMaterial() @@ -62,7 +71,7 @@ beforeEach(() => { source = fakeTextureSource() vi.spyOn(ViewportEngine.prototype, 'mount').mockImplementation(() => {}) // The mocked environment never reads the renderer fields. - vi.spyOn(ViewportEngine.prototype, 'gl', 'get').mockReturnValue({} as never) + vi.spyOn(ViewportEngine.prototype, 'gl', 'get').mockReturnValue(fakeRenderer()) host = document.createElement('div') }) diff --git a/src/renderer/src/engines/material/MaterialRenderer.ts b/src/renderer/src/engines/material/MaterialRenderer.ts index 780f5262c..a57dde1a8 100644 --- a/src/renderer/src/engines/material/MaterialRenderer.ts +++ b/src/renderer/src/engines/material/MaterialRenderer.ts @@ -14,15 +14,13 @@ import { reportFailure } from '@/services/diagnostics' import { createTextureBinding, type TextureBinding } from '../scene/textureBinding' import { createTextureCache, type TextureCache, type TextureSource } from '../scene/textureCache' import { createSkyBinding, type SkyBinding } from '../viewport/skyBinding' -import { createEnvironment, type ViewportEnvironment } from '../viewport/environment' +import { type ViewportEnvironment } from '../viewport/environment' import { SCHEME_OF, type NavigationScheme } from '@shared/domain/navigationPreset' import { ViewportEngine } from '../viewport/ViewportEngine' import { - bindUniforms, createUniforms, - EDGE_DEFINE, + declareEdgeMap, materialFrameOf, - patchFragment, syncEdgeTransform, } from './materialShader' import { previewGeometry } from './previewGeometry' @@ -129,6 +127,7 @@ export class MaterialRenderer { (assetId, error) => reportFailure('material.map', assetId, error), options.assetVersion, options.livePreview, + () => this.viewport.anisotropy, ) this.sky = createSkyBinding(this.cache, () => this.paintBackground()) // One per channel, built with the cache and never after: the reference, the race and the @@ -141,27 +140,6 @@ export class MaterialRenderer { } this.viewport.camera.position.set(CAMERA_HOME.x, CAMERA_HOME.y, CAMERA_HOME.z) this.viewport.scene.add(this.mesh) - - // Bound once on the material, not per compile: three hands the hook a fresh uniform object - // each time the program is rebuilt, and the engine's values have to survive that. - this.material.onBeforeCompile = shader => { - const { source, missing } = patchFragment(shader.fragmentShader) - shader.fragmentShader = source - bindUniforms(shader.uniforms, this.uniforms) - - // Once per anchor per engine, and the `Set` is what makes that true: a program is rebuilt - // whenever a channel is filled, and a repeated report would bury the journal. A remap that - // quietly stopped applying is a slider that looks alive and does nothing. - for (const anchor of missing) { - if (this.reported.has(anchor)) continue - this.reported.add(anchor) - reportFailure( - 'material.shader', - anchor, - localizedError('shaderAnchorMissing', { name: anchor }), - ) - } - } } mount(host: HTMLElement): void { @@ -170,12 +148,38 @@ export class MaterialRenderer { const renderer = this.viewport.gl if (!renderer) return - this.environment = createEnvironment(renderer, this.viewport.scene, this.viewport.requestRender) + // At MOUNT and not at construction: how the remaps reach the shader is the driver's, and + // which driver is running is settled by the very mount above. The material has drawn + // nothing yet, so no program exists that would have to be rebuilt for it. + this.viewport.driver.patchMaterial(this.material, this.uniforms, anchor => + this.reportAnchor(anchor), + ) + + this.environment = this.viewport.driver.createEnvironment( + renderer, + this.viewport.scene, + this.viewport.requestRender, + ) this.environment.setStudio() // The studio preset has no picture behind it, so the backdrop is the viewport's own colour. this.paintBackground() } + /** + * Once per anchor per engine, and the `Set` is what makes that true: a program is rebuilt + * whenever a channel is filled, and a repeated report would bury the journal. A remap that + * quietly stopped applying is a slider that looks alive and does nothing. + */ + private reportAnchor(anchor: string): void { + if (this.reported.has(anchor)) return + this.reported.add(anchor) + reportFailure( + 'material.shader', + anchor, + localizedError('shaderAnchorMissing', { name: anchor }), + ) + } + /** The engine holds no truth: everything it shows comes back through here. */ apply(texture: MaterialState): void { this.applyGeometry(texture) @@ -291,26 +295,11 @@ export class MaterialRenderer { this.material.needsUpdate = true } - /** - * The define, not just the uniform: an unbound sampler is undefined behaviour on some drivers, - * so the cavity code has to be absent from the program rather than merely inert. - */ + /** The define, not just the uniform — `declareEdgeMap` says why, and both engines call it. */ private setEdgeMap(map: Texture | null): void { if (this.uniforms.edgeMap.value === map) return this.uniforms.edgeMap.value = map - - const defines = this.material.defines ?? {} - if (map) { - defines[EDGE_DEFINE] = '' - // `vUv` exists only where something asks for it, and no slot asks on this mask's behalf. - defines.USE_UV = '' - } else { - delete defines[EDGE_DEFINE] - delete defines.USE_UV - } - - this.material.defines = defines - this.material.needsUpdate = true + declareEdgeMap(this.material, map !== null) } private async applyEnvironment({ preview }: MaterialState): Promise { diff --git a/src/renderer/src/engines/material/materialShader.ts b/src/renderer/src/engines/material/materialShader.ts index 692fe8fbc..c001d2fc1 100644 --- a/src/renderer/src/engines/material/materialShader.ts +++ b/src/renderer/src/engines/material/materialShader.ts @@ -7,7 +7,7 @@ * it release after release. The anchors were checked against three 0.185 — chunk names move * between versions, which is why a missing one is reported rather than silently skipped. */ -import { Matrix3, Vector2, type IUniform, type Texture } from 'three' +import { Matrix3, Vector2, type IUniform, type Material, type Texture } from 'three' import type { ValueRange, Vector2 as Vector2Like } from '@shared/domain/material' import type { MaterialState } from './materialState' @@ -75,6 +75,33 @@ export function createUniforms(): MaterialUniforms { } } +/** + * Whether the cavity code is IN the program at all, which is not the same as whether its uniform + * holds a picture: an unbound sampler is undefined behaviour on some drivers, so the mask has to + * be absent from the source rather than merely inert. + * + * Written here beside the chunk it guards rather than in the engine that calls it: whoever + * patches a material this way needs the same pair of defines, and two copies of a pair is one + * copy free to be spelt half. + * + * 🛑 The node engine reads none of this — a TSL graph carries no preprocessor — and calling it + * there is harmless: `applyMaterialNodes` samples a blank texture where nothing is bound. + */ +export function declareEdgeMap(material: Material, bound: boolean): void { + const defines = material.defines ?? {} + if (bound) { + defines[EDGE_DEFINE] = '' + // `vUv` exists only where something asks for it, and no slot asks on this mask's behalf. + defines.USE_UV = '' + } else { + delete defines[EDGE_DEFINE] + delete defines.USE_UV + } + + material.defines = defines + material.needsUpdate = true +} + /** * The cavity mask carries its own matrix. It sits in no slot, so three builds none for it, and * this uniform is the only thing keeping it repeating in step with the seven maps that do have diff --git a/src/renderer/src/engines/postfx/PostComposer.ts b/src/renderer/src/engines/postfx/PostComposer.ts index 37c959274..34ac73012 100644 --- a/src/renderer/src/engines/postfx/PostComposer.ts +++ b/src/renderer/src/engines/postfx/PostComposer.ts @@ -21,8 +21,8 @@ import { OutputPass } from 'three/addons/postprocessing/OutputPass.js' import { RenderPass } from 'three/addons/postprocessing/RenderPass.js' import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js' import { planStack, slotOf, type PostEffect, type PostStack } from '@shared/domain/postProcessing' -import type { ViewportQuality } from '@shared/domain/scene' import { QUAD_VERTEX_SHADER } from '@/engines/gpu/passes/quad' +import type { ComposerJob, SceneComposer } from '../render/sceneComposer' import { onePass, type EffectInstance, type ViewInfo } from './effectInstance' import { fuseShader, type FusableChunk } from './fuseShader' import { createLutCache, type LutCache, type LutSource } from './lutCache' @@ -32,32 +32,6 @@ import { heaviestCost, stepsOf, wantsFloat, type PostStep } from './postPlan' import { fusableFor, fusableKind } from './shaders/fusableChunks' import { standaloneFor, type BuildContext } from './standaloneEffects' -/** - * Where on the CANVAS a composition lands, in CSS pixels — `setViewport` and `setScissor` - * multiply by the device ratio themselves. The `width`/`height` of the job beside it are device - * pixels; pre-multiplying this rect too would scissor a pane off screen on any HiDPI display. - */ -type PostRect = { x: number; y: number; width: number; height: number } - -export type PostDrawJob = { - /** Stable destination identity, independent of dimensions, cameras and temporary targets. */ - surface: string - scene: Scene - camera: Camera - stack: PostStack - /** `null` draws on the canvas — into `rect` when one is given, over the whole of it when not. */ - target: WebGLRenderTarget | null - rect?: PostRect - /** The destination, in pixels. The chain may be built smaller — see `budgetFor`. */ - width: number - height: number - quality: ViewportQuality - /** Whether the world asks for a tone curve. Decides the precision the chain carries. */ - toneMapped: boolean - /** Seconds. What grain and tape jitter advance on — the playhead during a film. */ - time: number -} - export type PostComposerOptions = { loadLut?: LutSource /** What the asset is worth right now — `textureCache.versionOf`. See `lutCache`. */ @@ -72,7 +46,7 @@ type Applier = EffectInstance['apply'] const SCRATCH_SCENE = new Scene() const SCRATCH_CAMERA = new Camera() -export class PostComposer { +export class PostComposer implements SceneComposer { private readonly chains = new PostChainCache() private readonly luts: LutCache private readonly output = new OutputPass() @@ -106,7 +80,7 @@ export class PostComposer { * A stack that plans no pass draws straight — which is what the ON/OFF switch and the bypass * come down to: no target allocated, no chain compiled for a composition nobody asks to see. */ - draw(job: PostDrawJob): void { + draw(job: ComposerJob): void { const plan = planStack(job.stack) if (plan.effects.length === 0 || job.width < 1 || job.height < 1) { this.drawStraight(job) @@ -209,7 +183,7 @@ export class PostComposer { * to end, so no intermediate buffer has to lie about its colour space — and the copy a blit * would cost is the one the output pass was going to make anyway. */ - private finish(job: PostDrawJob, read: WebGLRenderTarget): void { + private finish(job: ComposerJob, read: WebGLRenderTarget): void { const renderer = this.renderer const rect = job.rect @@ -227,7 +201,7 @@ export class PostComposer { } /** No composition to draw: the scene, straight into wherever the job pointed. */ - private drawStraight(job: PostDrawJob): void { + private drawStraight(job: ComposerJob): void { const renderer = this.renderer this.hold() try { @@ -300,7 +274,13 @@ export class PostComposer { ): void { if (step.kind === 'own') { const factory = standaloneFor(step.effect.effect) - if (!factory) return + // 🛑 An applier per PLANNED effect, even where this engine builds no pass: `draw` walks the + // two lists by the same index, and an effect silently skipped here would hand the next + // one's parameters to this one's pass. Reachable since an effect can be GPU-only. + if (!factory) { + appliers.push(() => {}) + return + } const instance = factory(context) instances.push(instance) for (const pass of instance.passes) composer.addPass(pass) @@ -325,7 +305,11 @@ export class PostComposer { for (const [index, effect] of step.effects.entries()) { const fusable = fusableFor(effect.effect) const naming = fused.naming[index] - if (!fusable || !naming) continue + // Same alignment rule as above: a skipped chunk still owes its slot. + if (!fusable || !naming) { + appliers.push(() => {}) + continue + } // `ShaderPass` CLONES the uniforms it is given, so the objects the applier writes into are // the pass's own — read back here, under the effect's own names. const own: Record = {} diff --git a/src/renderer/src/engines/postfx/postFactories.test.ts b/src/renderer/src/engines/postfx/postFactories.test.ts index a18292f27..7ef5d8e0a 100644 --- a/src/renderer/src/engines/postfx/postFactories.test.ts +++ b/src/renderer/src/engines/postfx/postFactories.test.ts @@ -7,17 +7,34 @@ import { standaloneFor } from './standaloneEffects' /** * The compiler already holds the partition — `STANDALONE_EFFECTS` is typed on - * `Exclude`. This says the same thing at RUNTIME, which is what catches - * the one shape a type cannot: a widened lookup that answers `undefined` where the table has a key. + * `Exclude`. This says the same thing at RUNTIME, which + * is what catches the two shapes a type cannot: a widened lookup that answers `undefined` where + * the table has a key, and an id excluded from that table by a hand-written union while its + * fiche says the Compatible engine builds it. */ describe('the two tables that give an effect its implementation', () => { - it('covers every effect of the catalogue exactly once', () => { - const uncovered = POST_EFFECT_IDS.filter(id => !fusableFor(id) && !standaloneFor(id)) - const twice = POST_EFFECT_IDS.filter(id => fusableFor(id) && standaloneFor(id)) + /** The catalogue the Compatible engine has to answer for — the others have no GLSL to write. */ + const drawnByGl = POST_EFFECT_IDS.filter(id => POST_EFFECTS[id].engines.includes('gl')) + + it('covers every effect the Compatible engine claims, exactly once', () => { + const uncovered = drawnByGl.filter(id => !fusableFor(id) && !standaloneFor(id)) + const twice = drawnByGl.filter(id => fusableFor(id) && standaloneFor(id)) expect({ uncovered, twice }).toEqual({ uncovered: [], twice: [] }) }) + /** + * The other half of the same door: an effect the Compatible engine cannot build must be absent + * from both tables. Left in one, it would draw under an engine its fiche says it cannot. + */ + it('implements nothing the Compatible engine does not claim', () => { + const strays = POST_EFFECT_IDS.filter( + id => !POST_EFFECTS[id].engines.includes('gl') && (fusableFor(id) || standaloneFor(id)), + ) + + expect(strays).toEqual([]) + }) + it('builds a chunk with fresh uniforms each time, so two of one effect never collide', () => { const grade = fusableFor('colorGrading') const first = grade?.make().uniforms.exposure diff --git a/src/renderer/src/engines/postfx/postQuality.test.ts b/src/renderer/src/engines/postfx/postQuality.test.ts index 212713b10..0f5ba2111 100644 --- a/src/renderer/src/engines/postfx/postQuality.test.ts +++ b/src/renderer/src/engines/postfx/postQuality.test.ts @@ -51,7 +51,7 @@ describe('the arithmetic a budget drives', () => { }) it('cuts a sample count, and never below one sample', () => { - expect(samplesOf(16, { divisor: 1, samples: 0.4 })).toBe(6) - expect(samplesOf(1, { divisor: 1, samples: 0.4 })).toBe(1) + expect(samplesOf(16, { samples: 0.4 })).toBe(6) + expect(samplesOf(1, { samples: 0.4 })).toBe(1) }) }) diff --git a/src/renderer/src/engines/postfx/postQuality.ts b/src/renderer/src/engines/postfx/postQuality.ts index 3c62be68d..8ea284f41 100644 --- a/src/renderer/src/engines/postfx/postQuality.ts +++ b/src/renderer/src/engines/postfx/postQuality.ts @@ -29,8 +29,13 @@ export function budgetFor(heaviest: PostCost | null, quality: ViewportQuality): return heaviest === 'high' ? HALF : FEWER_SAMPLES } -/** A count asked for by a parameter, brought down to what the budget allows. Never below one. */ -export function samplesOf(asked: number, budget: PostBudget): number { +/** + * A count asked for by a parameter, brought down to what the budget allows. Never below one. + * + * The SHARE alone: a node chain carries its own resolution scale rather than a divisor, and + * asking it to build one back just to be read past would be an invented number. + */ +export function samplesOf(asked: number, budget: Pick): number { return Math.max(1, Math.round(asked * budget.samples)) } diff --git a/src/renderer/src/engines/postfx/postSurfaces.test.ts b/src/renderer/src/engines/postfx/postSurfaces.test.ts index 9d92156b8..094f7b93f 100644 --- a/src/renderer/src/engines/postfx/postSurfaces.test.ts +++ b/src/renderer/src/engines/postfx/postSurfaces.test.ts @@ -6,7 +6,8 @@ import { OutputPass } from 'three/addons/postprocessing/OutputPass.js' import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js' import { GlitchPass } from 'three/addons/postprocessing/GlitchPass.js' import { postEffect } from '@shared/domain/postProcessing' -import { PostComposer, type PostDrawJob } from './PostComposer' +import { PostComposer } from './PostComposer' +import type { ComposerJob } from '../render/sceneComposer' vi.mock('three', async importOriginal => ({ ...(await importOriginal()), @@ -40,9 +41,10 @@ function composer(): PostComposer { return result } -function job(surface: string, width: number, height: number): PostDrawJob { +function job(surface: string, width: number, height: number): ComposerJob { return { surface, + oneShot: false, scene: new Scene(), camera: new Camera(), stack, diff --git a/src/renderer/src/engines/postfx/standaloneEffects.ts b/src/renderer/src/engines/postfx/standaloneEffects.ts index cef5afc24..adf4b8f48 100644 --- a/src/renderer/src/engines/postfx/standaloneEffects.ts +++ b/src/renderer/src/engines/postfx/standaloneEffects.ts @@ -17,7 +17,12 @@ import { SSAARenderPass } from 'three/addons/postprocessing/SSAARenderPass.js' import { SSAOPass } from 'three/addons/postprocessing/SSAOPass.js' import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js' import { RGBShiftShader } from 'three/addons/shaders/RGBShiftShader.js' -import { HALFTONE_SHAPES, type PostEffect, type PostEffectId } from '@shared/domain/postProcessing' +import { + HALFTONE_SHAPES, + type GpuOnlyEffectId, + type PostEffect, + type PostEffectId, +} from '@shared/domain/postProcessing' import type { FusedId } from './shaders/fusableChunks' import { onePass, type EffectInstance, type ViewInfo } from './effectInstance' import { samplesOf } from './postQuality' @@ -204,10 +209,17 @@ const passOnly = onePass(make(), () => {}) /** - * The catalogue MINUS what fuses, and typed on that difference: an effect added to `PostEffectId` - * fails to compile until one of the two tables implements it, and neither may claim it twice. + * The catalogue MINUS what fuses and MINUS what only a node chain can build, typed on that + * difference: an effect added to `PostEffectId` fails to compile until one of the two tables + * implements it, and neither may claim it twice. + * + * `GpuOnlyEffectId` is the third door and not a hole: an effect the Compatible engine cannot + * build has no GLSL pass to write, and `postFactories.test.ts` holds that exclusion to what the + * registry's `engines` actually say. */ -const OWN_PASS: Readonly, EffectFactory>> = { +type OwnPassId = Exclude + +const OWN_PASS: Readonly> = { gtao, ssao, ssaa, diff --git a/src/renderer/src/engines/render/engineBenchmark.browser.ts b/src/renderer/src/engines/render/engineBenchmark.browser.ts new file mode 100644 index 000000000..59282c33b --- /dev/null +++ b/src/renderer/src/engines/render/engineBenchmark.browser.ts @@ -0,0 +1,145 @@ +/** + * What a frame COSTS on each engine, on the two profiles the render chantier is judged on. + * + * 🛑 A browser harness and not a `.bench.ts`: neither engine draws under node or jsdom, and a + * figure measured without a device would be a figure about module loading. It is driven the way + * `world:validate` is — `pnpm start`, then the script beside it. + * + * Two numbers per profile per engine, and each says exactly what it measures — no more: + * + * - `submitMs` — what the UI THREAD spends assembling and queueing one frame. 🛑 NOT the frame's + * cost on the card: both `render()` calls return once the commands are queued, and a figure + * calling itself a frame time would be off by whatever the GPU then does unwatched. + * - `firstStillMs` — the FIRST still: `captureStill`, whole. It draws the scene into a target, + * reads the pixels back and encodes a PNG off the thread, so it forces the queue empty — and + * therefore also absorbs whatever the engine had left to compile. On a node chain that is + * every pipeline of the graph, so reading this as a copy cost would be reading a compile. + * - `stillMs` — the MEAN of the stills after it, once nothing is left to compile. What an export + * pays per still. 🛑 Two cautions on this one: the PNG encode is inside it and is the same on + * both engines, so it understates the difference rather than showing it; and a single sample + * swung by a factor of two between runs, which is why it is a mean and not one reading. + * + * `failed` where an engine could not be built or drawn at all — a machine with no WebGPU adapter + * answers that for the Advanced column, and that is a result rather than a crash. + */ +import { messageOf } from '@shared/guards' +import type { RenderEngine } from '@shared/domain/renderEngine' +import type { PostStack } from '@shared/domain/postProcessing' +import { postEffect } from '@shared/domain/postProcessing' +import type { SceneState } from '../scene/sceneState' +import { createDefaultScene } from '../scene/defaultScene' +import { meshNode } from '../scene/nodeFactory' +import { worldBenchmarkScenes } from '../scene/worldBenchmarkScenes.fixture' +import { loadGpuModule } from './gpuModule' +import { mountedScene, type MountedScene, type StageShape } from './engineParityStage' + +type EngineMeasure = { + engine: RenderEngine + /** What the viewport ACTUALLY mounted: `gl` here under `gpu` is the silent fallback. */ + drawnWith: RenderEngine + submitMs: number | null + firstStillMs: number | null + stillMs: number | null + /** Why this column is empty, when it is. Never swallowed: an empty column has to explain itself. */ + failed?: string +} + +type ProfileMeasure = { + profile: 'model' | 'openWorld' + nodes: number + measures: readonly EngineMeasure[] +} + +/** How many frames `submitMs` is the mean of, after the ones that only compile shaders. */ +const MEASURED_FRAMES = 60 + +/** How many stills `stillMs` is the mean of. One alone swung by a factor of two between runs. */ +const MEASURED_STILLS = 10 +/** A viewport-sized surface, warmed enough that nothing is left to compile — see `mountedScene`. */ +const SURFACE: StageShape = { width: 1280, height: 720, warmup: 10 } + +/** + * The occlusion, on both engines: the one effect the Advanced chain builds, so a profile + * carrying it compares two chains rather than two plain renders. + */ +const STACK: PostStack = { enabled: true, effects: [postEffect('bench-ao', 'gtao')] } + +async function benchmarkEngines(): Promise { + // Asked for up front: the Advanced engine is only chosen once its bundle is in, and a mount + // that raced the import would measure the Compatible one twice. + await loadGpuModule() + + const profiles: readonly { profile: ProfileMeasure['profile']; state: SceneState }[] = [ + { profile: 'model', state: oneModelScene() }, + { profile: 'openWorld', state: openWorldScene() }, + ] + + const results: ProfileMeasure[] = [] + for (const { profile, state } of profiles) { + const measures: EngineMeasure[] = [] + for (const engine of ['gl', 'gpu'] as readonly RenderEngine[]) { + measures.push(await measureEngine(engine, state)) + } + results.push({ profile, nodes: state.nodes.length, measures }) + } + return results +} + +async function measureEngine(engine: RenderEngine, state: SceneState): Promise { + let mounted: MountedScene | null = null + try { + mounted = await mountedScene(engine, state, STACK, SURFACE) + const renderer = mounted.renderer + const drawnWith = renderer.renderEngine + + const started = performance.now() + for (let frame = 0; frame < MEASURED_FRAMES; frame += 1) renderer.drawFrom(null, frame) + const submitMs = (performance.now() - started) / MEASURED_FRAMES + + const cold = performance.now() + await renderer.captureStill('view') + const firstStillMs = performance.now() - cold + + const warm = performance.now() + for (let still = 0; still < MEASURED_STILLS; still += 1) await renderer.captureStill('view') + const stillMs = (performance.now() - warm) / MEASURED_STILLS + + return { engine, drawnWith, submitMs, firstStillMs, stillMs } + } catch (error) { + // A machine with no adapter, or a chain that would not build: that IS the measurement for + // this column, and the profile beside it still has to be reported — with the reason. + return { + engine, + drawnWith: engine, + submitMs: null, + firstStillMs: null, + stillMs: null, + failed: messageOf(error), + } + } finally { + mounted?.release() + } +} + +/** One model, lit, on the quality the spec judges this profile at. */ +function oneModelScene(): SceneState { + const base = createDefaultScene() + return { + ...base, + nodes: [ + ...base.nodes, + meshNode({ kind: 'sphere', radius: 1, widthSegments: 64, heightSegments: 32 }), + ], + } +} + +/** The open world of C5, through the very partition `pnpm world:validate` walks. */ +function openWorldScene(): SceneState { + const scenes = worldBenchmarkScenes() + const widest = scenes.reduce((held, one) => + one.state.nodes.length > held.state.nodes.length ? one : held, + ) + return widest.state +} + +Reflect.set(window, '__iaBenchmarkRenderEngines', benchmarkEngines) diff --git a/src/renderer/src/engines/render/engineParity.browser.ts b/src/renderer/src/engines/render/engineParity.browser.ts new file mode 100644 index 000000000..08c5fc676 --- /dev/null +++ b/src/renderer/src/engines/render/engineParity.browser.ts @@ -0,0 +1,335 @@ +/** + * Whether the Advanced engine DRAWS what the Compatible one draws — the same reference content, + * rendered by both, compared pixel to pixel. + * + * 🛑 The one claim the render chantier could not make until this existed. `pnpm engines:bench` + * says what a frame COSTS on each engine and nothing about what it looks like; `world:validate` + * compares two REPRESENTATIONS of a scene on one engine. This is the missing third: one content, + * two engines, and `compareVisualFrames` — the very comparator those two already answer to. + * + * A browser harness for the same reason the bench is one: neither engine draws under node or + * jsdom, and a figure measured without a device would be a figure about module loading. It is + * driven the way `world:validate` is — `pnpm start:debug`, then the script beside it. + * + * Six cases, each through the seam the studio itself uses: + * + * - `scene` — the plain scene, no composition. What separates « the engines disagree about this + * effect » from « the engines disagree about everything ». + * - `occlusion` — the same scene under GTAO, the one effect both engines build. + * - `material` — a standard material carrying the studio's three additions (the roughness and + * metalness remaps and the cavity mask, with TILED maps), patched through `driver.patchMaterial` + * and drawn by `driver.createRenderer`. The material window's own path, minus its window. + * - `still` and `film` — the two EXPORT paths, `captureStill` and `renderFilm` on both engines: + * a picture that shears, flips or comes back at the canvas's size instead of the target's shows + * here and nowhere else. + * - `temporal` — that an effect which resolves against the frames before it is LEFT OUT of a + * picture drawn once, rather than drawing the flat colour an empty history gives. + * + * 🛑 A known, EXPECTED difference, written rather than hidden: the node patch lands the cavity on + * the diffuse COLOUR, where the GLSL one lands it on `reflectedLight` — a node material opens no + * seam on the latter. Identical for a dielectric; on a metal, whose specular tint three derives + * from that same colour, the Advanced engine darkens a little of what the Compatible one leaves + * alone. So the material case is reported as a MEASUREMENT and never as a pass or a fail: the + * number is what a reader compares against the next run. + */ +import { WebGLRenderTarget } from 'three' +import { messageOf } from '@shared/guards' +import type { RenderEngine } from '@shared/domain/renderEngine' +import { EMPTY_STACK, postEffect, type PostStack } from '@shared/domain/postProcessing' +import { compareVisualFrames, hasPixelVariation, type VisualFrame } from '../scene/visualRegression' +import { createUniforms, declareEdgeMap, syncEdgeTransform } from '../material/materialShader' +import type { SceneState } from '../scene/sceneState' +import { drawInto } from './renderDriver' +import { loadGpuModule } from './gpuModule' +import { + ANIMATED_WAIT_MS, + animationFramesArrive, + decodePng, + driverOf, + FRAME, + keepForTheEye, + materialStage, + mountedScene, + referenceCamera, + referenceScene, + tiledMask, +} from './engineParityStage' + +type ParityCase = 'scene' | 'occlusion' | 'material' | 'still' | 'film' | 'temporal' + +type ParityResult = { + case: ParityCase + /** What each side actually mounted. `gl` twice means the Advanced engine never ran. */ + drawnWith?: Readonly> + width?: number + height?: number + /** Share of pixels differing by more than `CHANNEL_TOLERANCE` on any channel. */ + changedPixelRatio?: number + maximumChannelDifference?: number + /** + * Whether each side drew more than one colour. Per SIDE and not as one flag: a blank frame is + * the failure a comparison hides best, and knowing WHICH side blanked is the whole diagnosis. + */ + drew?: Readonly> + /** Why this row is empty, when it is. An empty row has to explain itself. */ + failed?: string +} + +/** Eight levels of encoding noise are not a difference of engines. */ +const CHANNEL_TOLERANCE = 8 + +/** The occlusion, the one effect both chains build. */ +const OCCLUSION: PostStack = { + enabled: true, + effects: [postEffect('parity-ao', 'gtao')], +} + +/** The temporal anti-aliaser, which only the Advanced engine builds. */ +const ANTIALIAS: PostStack = { enabled: true, effects: [postEffect('parity-aa', 'traa')] } + +async function compareRenderEngines(): Promise { + // 🛑 WAITED FOR rather than measured badly. A window behind another is handed no animation + // frame, and both engines draw differently without them: three advances the node frame its + // `FRAME` updates are gated on from an animation loop of its own, and the Compatible engine + // only redraws a shadow map on a frame it judges stale. Measured 2026-09-11 — occluded, the + // very same revision reported 58 % of pixels differing on a scene it draws identically in + // front. Waited for and not merely asserted: the operator is at a terminal, and bringing the + // window forward is the gesture this pause exists to leave room for. + if (!(await animationFramesArrive())) { + throw new Error( + `no animation frame in ${ANIMATED_WAIT_MS / 1000}s: bring the studio window to the front`, + ) + } + + // Asked for up front: the Advanced engine is only chosen once its bundle is in, and a mount + // that raced the import would compare the Compatible one with itself. + await loadGpuModule() + + const state = referenceScene() + + return [ + await sceneCase('scene', state, EMPTY_STACK), + await sceneCase('occlusion', state, OCCLUSION), + await temporalCase(state), + await stillCase(state), + await filmCase(state), + await materialCase(), + ] +} + +/** One scene, drawn off screen by both engines through the viewport's own validation pass. */ +async function sceneCase( + which: ParityCase, + state: SceneState, + post: PostStack, +): Promise { + const view = referenceCamera() + return await bothEngines(which, async engine => { + const mounted = await mountedScene(engine, state, post) + try { + return { + drawnWith: mounted.renderer.renderEngine, + frame: await mounted.renderer.captureRuntimeValidationFrame(view), + } + } finally { + mounted.release() + } + }) +} + +/** + * The export path rather than the viewport's: `captureStill` draws into a target of its own, + * reads the pixels back and encodes a PNG. Decoded here so the two engines' PNGs are compared as + * PICTURES — the bytes differ whatever happens, an encoder being free to pack them how it likes. + */ +async function stillCase(state: SceneState): Promise { + return await stillsOf('still', state, engine => ({ engine, post: EMPTY_STACK })) +} + +/** + * That a TEMPORAL effect is left out of a picture drawn once: a still of the scene carrying + * `traa` against a still of the same scene carrying nothing, both on the Advanced engine, and + * the two have to be the SAME picture. + * + * 🛑 What this keeps is a measured failure. A temporal node resolves the frame against the ones + * before it; a still builds its chain, draws and frees it, so that history is empty and the node + * answers one flat colour — measured 2026-09-11, a grey square where the scene should be. The + * chain therefore leaves such an effect out off screen (`survivesOneShot`), and this row is what + * says it still does. + * + * 🛑 It says NOTHING about what TRAA draws on screen, where the chain lives across frames and + * resolves properly — that was measured by hand, and the report carries the figure. + * + * The Compatible column of this row is the Advanced engine drawing without the effect. The shape + * of the result cannot say so; this note does. + */ +async function temporalCase(state: SceneState): Promise { + return await stillsOf('temporal', state, engine => ({ + engine: 'gpu', + post: engine === 'gpu' ? ANTIALIAS : EMPTY_STACK, + })) +} + +/** + * Two stills of one scene, compared as PICTURES: the PNGs are decoded, the bytes of two encodes + * differing whatever happens. What each side MOUNTS and what stack it carries is the caller's, + * which is the only thing the two rows above disagree about. + */ +async function stillsOf( + which: ParityCase, + state: SceneState, + sideOf: (side: RenderEngine) => { engine: RenderEngine; post: PostStack }, +): Promise { + return await bothEngines(which, async side => { + const { engine, post } = sideOf(side) + const mounted = await mountedScene(engine, state, post) + try { + const png = await mounted.renderer.captureStill('view') + return { drawnWith: mounted.renderer.renderEngine, frame: await decodePng(png) } + } finally { + mounted.release() + } + }) +} + +/** + * The other export path: one frame of a film, drawn through a CAMERA OF THE DOCUMENT rather than + * the view in hand, and at the film's own size rather than the canvas's. + * + * One frame and not a sequence: what is being compared is the picture, and a second frame of a + * scene nothing animates is the same picture again. + */ +async function filmCase(state: SceneState): Promise { + const camera = state.nodes.find(node => node.type === 'camera') + if (!camera) throw new Error('the reference scene carries no camera to film through') + + return await bothEngines('film', async engine => { + const mounted = await mountedScene(engine, state, EMPTY_STACK) + let png: Uint8Array | null = null + try { + await mounted.renderer.renderFilm( + () => camera.id, + { width: FILM.width, height: FILM.height, fps: 1, duration: ONE_FRAME_US }, + async (_index, frame) => { + png = frame + }, + ) + if (png === null) throw new Error('the film drew no frame') + return { drawnWith: mounted.renderer.renderEngine, frame: await decodePng(png) } + } finally { + mounted.release() + } + }) +} + +/** + * A film's own size — deliberately not the canvas's, and deliberately a width whose ROW is not a + * multiple of 256 bytes: WebGPU pads a texture-to-buffer copy to that alignment, and a reader + * that keeps the slack shears the picture a little further sideways on every row down. 642 × 4 + * is 2 568 bytes, so this case pays the padding; 640 and 1 024 both divide cleanly and prove + * nothing about it. + */ +const FILM = { width: 642, height: 362 } + +/** One frame at one image a second — `frameTimes` yields a single instant for this. */ +const ONE_FRAME_US = 1 + +/** + * The material patch, through the driver seam and nothing else: a sphere wearing a standard + * material with tiled roughness and metalness maps, a remap on each and a cavity mask over both. + * + * Built here rather than driven through `MaterialRenderer`, which mounts a window, an orbit and a + * texture cache: what differs between the engines is `patchMaterial`, and everything around it is + * three.js objects both engines share. + */ +async function materialCase(): Promise { + return await bothEngines('material', async engine => { + const driver = driverOf(engine) + const canvas = document.createElement('canvas') + canvas.width = FRAME + canvas.height = FRAME + const renderer = driver.createRenderer({ canvas, alpha: false }) + const target = new WebGLRenderTarget(FRAME, FRAME) + try { + await driver.ready(renderer) + renderer.setPixelRatio(1) + renderer.setSize(FRAME, FRAME, false) + + const { scene, camera, material } = materialStage() + const uniforms = createUniforms() + uniforms.roughnessRemap.value.set(0.2, 0.9) + uniforms.metalnessRemap.value.set(0.1, 0.7) + uniforms.edgeMap.value = tiledMask() + uniforms.edgeIntensity.value = 0.8 + syncEdgeTransform(uniforms) + // Both engines, as `MaterialRenderer.setEdgeMap` calls it: the node one reads no define + // and the GLSL one draws no cavity without this pair. + declareEdgeMap(material, true) + driver.patchMaterial(material, uniforms, () => {}) + + // Neutral light with no picture behind it, the same room the material window opens on: + // a metal with no environment reflects nothing and the remaps would judge a black sphere. + const environment = driver.createEnvironment(renderer, scene, () => {}) + environment.setStudio() + try { + const restore = drawInto(renderer, target) + try { + renderer.render(scene, camera) + } finally { + restore() + } + const pixels = await driver.readPixels(renderer, target, FRAME, FRAME) + return { drawnWith: driver.engine, frame: { width: FRAME, height: FRAME, pixels } } + } finally { + environment.dispose() + } + } finally { + target.dispose() + driver.releaseContext(renderer) + renderer.dispose() + canvas.remove() + } + }) +} + +type DrawnFrame = { drawnWith: RenderEngine; frame: VisualFrame } + +/** + * Runs one case on both engines and compares what came back. + * + * The two runs are SEQUENTIAL and each frees its renderer: a machine holding two devices and two + * G-buffers at once measures its own memory pressure rather than two engines. + */ +async function bothEngines( + which: ParityCase, + draw: (engine: RenderEngine) => Promise, +): Promise { + try { + const compatible = await draw('gl') + const advanced = await draw('gpu') + await keepForTheEye(which, compatible.frame, advanced.frame) + const result = compareVisualFrames(compatible.frame, advanced.frame, { + channelTolerance: CHANNEL_TOLERANCE, + maximumChangedPixelRatio: 1, + }) + return { + case: which, + drawnWith: { gl: compatible.drawnWith, gpu: advanced.drawnWith }, + width: compatible.frame.width, + height: compatible.frame.height, + changedPixelRatio: result.changedPixelRatio, + maximumChannelDifference: result.maximumChannelDifference, + drew: { + gl: hasPixelVariation(compatible.frame.pixels), + gpu: hasPixelVariation(advanced.frame.pixels), + }, + } + } catch (error) { + // A machine with no adapter, or a chain that would not build: that IS the result for this + // row, and the rows beside it still have to be reported — with the reason and NO numbers. + // A ratio invented here reads as a measurement in the JSON the runner prints. + return { case: which, failed: messageOf(error) } + } +} + +Reflect.set(window, '__iaCompareRenderEngines', compareRenderEngines) diff --git a/src/renderer/src/engines/render/engineParityStage.ts b/src/renderer/src/engines/render/engineParityStage.ts new file mode 100644 index 000000000..6402e570f --- /dev/null +++ b/src/renderer/src/engines/render/engineParityStage.ts @@ -0,0 +1,278 @@ +/** + * The SET the parity harness compares on, and the plumbing around it: the reference scene, the + * material stage, a scene renderer mounted off screen on the engine asked for, and the two + * conversions between a frame and a PNG. + * + * Apart from `engineParity.browser.ts`, which holds the CASES alone: what is compared is a short + * list one should be able to read in one screen, and it was buried under the decor. + */ +import { + AmbientLight, + DataTexture, + DirectionalLight, + Mesh, + MeshStandardMaterial, + PerspectiveCamera, + RepeatWrapping, + Scene, + SphereGeometry, + type Texture, +} from 'three' +import { DEFAULT_SETTINGS } from '@shared/domain/settings' +import type { PostStack } from '@shared/domain/postProcessing' +import type { RenderEngine } from '@shared/domain/renderEngine' +import type { VisualFrame } from '../scene/visualRegression' +import { flipRows } from '../scene/film' +import { encodeFilmFrameOffThread } from '../scene/filmEncodePort' +import { offScreenHost } from '../core/offScreenHost' +import { SceneRenderer } from '../scene/SceneRenderer' +import type { SceneState } from '../scene/sceneState' +import type { RuntimeRenderCamera } from '../scene/runtimeRepresentationValidation' +import { createDefaultScene } from '../scene/defaultScene' +import { cameraNode, meshNode, transformAt } from '../scene/nodeFactory' +import type { RenderDriver } from './renderDriver' +import { glDriver } from './glDriver' +import { gpuDriver } from './gpuDriver' + +/** Wide enough to compare and small enough to walk on the UI thread — 128² is 16 384 pixels. */ +export const FRAME = 128 + +/** Where both frames of every case are left, for the runner to write beside the report. */ +const FRAMES_HANDLE = '__iaEngineParityFrames' + +/** + * Keeps the two frames as pictures, because a ratio is not a diagnosis: a side that drew nothing, + * a picture that came back upside down and one that is merely a shade darker all read as one + * number, and only the images tell them apart. + */ +export async function keepForTheEye( + which: string, + gl: VisualFrame, + gpu: VisualFrame, +): Promise { + const held: Record> = Reflect.get( + window, + FRAMES_HANDLE, + ) ?? {} + const [left, right] = await Promise.all([encodePng(gl), encodePng(gpu)]) + held[which] = { gl: [...left], gpu: [...right] } + Reflect.set(window, FRAMES_HANDLE, held) +} + +/** + * A frame to PNG bytes, put back the right way up — the very encoder a film uses, so the pictures + * this harness writes are made the way the studio makes them, and off the UI thread. + * + * 🛑 The flip is the encoder's: `readPixels` answers bottom-up on both engines — the Advanced + * driver turns its own read over to match — so a buffer written straight into a canvas comes out + * upside down, and a reader comparing two upside-down pictures would report the flip as agreement. + * + * The buffer is COPIED first: the worker takes ownership of what it is handed, and the frame it + * came from is still being compared. + */ +async function encodePng(frame: VisualFrame): Promise { + return await encodeFilmFrameOffThread( + new Uint8Array(frame.pixels), + frame.width, + frame.height, + true, + ) +} + +export type MountedScene = { renderer: SceneRenderer; release: () => void } + +/** + * How big the host is and how many frames are drawn into it before anything is read. The bench + * measures a viewport-sized frame and warms longer; the parity harness compares a small square. + */ +export type StageShape = { width: number; height: number; warmup: number } + +/** A scene renderer on the engine asked for, off screen and sized like a viewport. */ +export async function mountedScene( + engine: RenderEngine, + state: SceneState, + post: PostStack, + shape: StageShape = PARITY_STAGE, +): Promise { + const host = offScreenHost(shape.width, shape.height) + const renderer = new SceneRenderer({ + engine, + onSelect: () => {}, + onTransform: () => {}, + // The workshop is out of every pass below anyway; `false` keeps it out of the scene graph + // too, so neither engine is compared on a grid it drew for its own reasons. + chrome: false, + }) + renderer.configure({ ...DEFAULT_SETTINGS.three, quality: 'high' }) + renderer.mount(host) + // The node backend comes up a beat after the mount: drawn before it does, every render would + // throw and the row would report a race rather than an engine. + await renderer.settled() + renderer.apply({ ...state, world: { ...state.world, engine, post } }) + await quiet() + // 🛑 Frames BEFORE the capture, exactly as a viewport draws them. The Compatible engine only + // redraws its shadow maps on a frame it judges stale, so a capture taken before any frame + // reads maps that have never been drawn — every surface fully in shadow, a black picture, and + // a comparison that would have blamed the other engine. Measured 2026-09-11. + for (let frame = 0; frame < shape.warmup; frame += 1) renderer.drawFrom(null, frame) + + return { + renderer, + release: () => { + renderer.dispose() + host.remove() + }, + } +} + +/** + * What both engines are asked to draw: a lit set with a camera in it, two spheres close enough + * for one to occlude the other, and a floor for the occlusion to land on. + */ +export function referenceScene(): SceneState { + const base = createDefaultScene() + return { + ...base, + nodes: [ + ...base.nodes, + cameraNode(), + meshNode( + { kind: 'sphere', radius: 1, widthSegments: 48, heightSegments: 24 }, + { transform: transformAt({ x: -0.7, y: 1, z: 0 }) }, + ), + meshNode( + { kind: 'sphere', radius: 0.7, widthSegments: 48, heightSegments: 24 }, + { transform: transformAt({ x: 0.8, y: 0.7, z: 0.4 }) }, + ), + meshNode( + { kind: 'box', width: 6, height: 0.2, depth: 6 }, + { transform: transformAt({ x: 0, y: -0.1, z: 0 }) }, + ), + ], + } +} + +/** Framed on the two spheres, at an angle where the contact shadow between them is visible. */ +export function referenceCamera(): RuntimeRenderCamera { + return { + id: 'parity', + position: { x: 2.6, y: 2.2, z: 4.2 }, + target: { x: 0, y: 0.8, z: 0 }, + projection: 'perspective', + fieldOfView: 50, + near: 0.1, + far: 100, + width: FRAME, + height: FRAME, + cameraMask: 1, + } +} + +/** The sphere the material case wears, lit hard enough for a remap to be readable. */ +export function materialStage(): { + scene: Scene + camera: PerspectiveCamera + material: MeshStandardMaterial +} { + const material = new MeshStandardMaterial({ color: '#c8b48c', roughness: 0.9, metalness: 0.6 }) + // ONE texture for both slots: the same picture read twice, which is what a packed map is. + const checker = tiledMask() + material.roughnessMap = checker + material.metalnessMap = checker + + const scene = new Scene() + scene.add(new Mesh(new SphereGeometry(1, 64, 32), material)) + scene.add(new AmbientLight('#ffffff', 0.4)) + const sun = new DirectionalLight('#ffffff', 2.4) + sun.position.set(3, 4, 5) + scene.add(sun) + + const camera = new PerspectiveCamera(45, 1, 0.1, 100) + camera.position.set(0, 0, 3.2) + camera.lookAt(0, 0, 0) + return { scene, camera, material } +} + +/** + * A four-texel checker, REPEATED — tiling is the half of the patch that a plain map cannot show: + * the two remapped maps are read through nodes of the studio's own, so a matrix left behind makes + * them read untiled while every other map of the material tiles. + */ +export function tiledMask(): Texture { + const texture = new DataTexture( + new Uint8Array([20, 20, 20, 255, 235, 235, 235, 255, 235, 235, 235, 255, 20, 20, 20, 255]), + 2, + 2, + ) + texture.wrapS = RepeatWrapping + texture.wrapT = RepeatWrapping + texture.repeat.set(4, 4) + texture.needsUpdate = true + texture.updateMatrix() + return texture +} + +/** + * A PNG back to pixels, through the browser's own decoder rather than a second reader. + * + * Turned over on the way in: a PNG is stored top-down and every other frame in this harness is a + * `readPixels` read, which is bottom-up. ONE convention, or the encoder above would put the + * stills back upside down while the frames beside them came out right. + */ +export async function decodePng(png: Uint8Array): Promise { + // `as`: the bytes come from this process's own encoder and are backed by a plain ArrayBuffer. + const blob = new Blob([png.buffer as ArrayBuffer], { type: 'image/png' }) + const bitmap = await createImageBitmap(blob) + try { + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height) + const context = canvas.getContext('2d') + if (!context) throw new Error('no 2d context to decode a still with') + context.drawImage(bitmap, 0, 0) + const data = context.getImageData(0, 0, bitmap.width, bitmap.height) + return { + width: bitmap.width, + height: bitmap.height, + pixels: new Uint8Array(flipRows(new Uint8Array(data.data), bitmap.width, bitmap.height)), + } + } finally { + bitmap.close() + } +} + +/** How long the harness waits for the window to come forward before it gives up. */ +export const ANIMATED_WAIT_MS = 90_000 + +/** How long a settling pause waits when no frame comes — a hidden window paints nothing. */ +const SETTLE_MS = 400 + +/** + * Whether TWO animation frames arrive within `within` — the only honest way to ask whether the + * window is being painted. `document.hidden` answers for a minimised window and says nothing + * about a throttled one. + * + * Both readings of the harness go through it: the gate that refuses to measure a window nobody + * is painting, and the pause that lets a texture, a worker and a shader land. + */ +export async function animationFramesArrive(within = ANIMATED_WAIT_MS): Promise { + return await new Promise(resolve => { + const timer = setTimeout(() => resolve(false), within) + requestAnimationFrame(() => + requestAnimationFrame(() => { + clearTimeout(timer) + resolve(true) + }), + ) + }) +} + +/** Two frames of quiet, or `SETTLE_MS`, whichever comes first. */ +async function quiet(): Promise { + await animationFramesArrive(SETTLE_MS) +} + +/** What the parity cases open on. The four frames are the shadow warm-up `mountedScene` explains. */ +const PARITY_STAGE: StageShape = { width: FRAME * 4, height: FRAME * 4, warmup: 4 } + +export function driverOf(engine: RenderEngine): RenderDriver { + return engine === 'gpu' ? gpuDriver : glDriver +} diff --git a/src/renderer/src/engines/render/glDriver.ts b/src/renderer/src/engines/render/glDriver.ts new file mode 100644 index 000000000..c0b97752d --- /dev/null +++ b/src/renderer/src/engines/render/glDriver.ts @@ -0,0 +1,110 @@ +/** + * The Compatible engine: WebGL, and what the studio has always drawn with. + * + * A wrapper and nothing else — every call here forwards to the module that already held that + * work, so a project on `gl` draws exactly what it drew before the driver existed. + */ +import { PMREMGenerator, WebGLRenderer } from 'three' +import { PostComposer } from '../postfx/PostComposer' +import { createEnvironment, ROOM_SIGMA, type EnvironmentPort } from '../viewport/environment' +import { readRenderPixels } from '../scene/readRenderPixels' +import { bindUniforms, patchFragment } from '../material/materialShader' +import { createSkyGrading, type SkyGrading } from '../gpu/skyGrading' +import { createGpuTimer, isGpuTimerContext } from '../viewport/gpuTimer' +import type { RenderDriver, StudioRenderer } from './renderDriver' + +export const glDriver: RenderDriver = { + engine: 'gl', + + createRenderer: ({ canvas, alpha }) => new WebGLRenderer({ canvas, antialias: true, alpha }), + + // Nothing to wait for: a WebGL context is up on the line after `new`. + ready: () => null, + + readPixels: async (renderer, target, width, height) => + readRenderPixels(asWebGL(renderer), target, width, height), + + createComposer: (renderer, options) => new PostComposer(asWebGL(renderer), options), + + createEnvironment: (renderer, scene, requestRender) => + createEnvironment(glEnvironmentPort(asWebGL(renderer)), scene, requestRender), + + drawOverlay: (renderer, draw) => { + const gl = asWebGL(renderer) + gl.autoClear = false + try { + draw(gl) + } finally { + gl.autoClear = true + } + }, + + // The ceiling comes from three rather than from `gl.MAX_SAMPLES`, which the WebGL1 typing has + // no name for. + maxSamples: renderer => Math.max(0, capsOf(renderer).maxSamples), + + drawingBufferSamples: renderer => { + const gl = asWebGL(renderer).getContext() + return Math.max( + 0, + Math.min(Number(gl.getParameter(gl.SAMPLES) ?? 0), capsOf(renderer).maxSamples), + ) + }, + + // Never under one: three answers 0 — not 1 — on a context without + // `EXT_texture_filter_anisotropic`, and 0 is not a number of samples. + maxAnisotropy: renderer => Math.max(1, capsOf(renderer).getMaxAnisotropy()), + + frameTimer: renderer => { + const context = asWebGL(renderer).getContext() + return isGpuTimerContext(context) ? createGpuTimer(context) : null + }, + + releaseContext: renderer => asWebGL(renderer).forceContextLoss(), + + patchMaterial: (material, uniforms, onMissingAnchor) => { + // Bound once on the material, not per compile: three hands the hook a fresh uniform object + // each time the program is rebuilt, and the engine's values have to survive that. + material.onBeforeCompile = shader => { + const { source, missing } = patchFragment(shader.fragmentShader) + shader.fragmentShader = source + bindUniforms(shader.uniforms, uniforms) + for (const anchor of missing) onMissingAnchor(anchor) + } + }, +} + +/** + * The mip chain and the grading pass, together: both are the ENGINE's, and both are freed with + * it. The grading is built on the first sky nobody left neutral — most are, and a pass built for + * them would be a program compiled for a picture it never touches. + */ +function glEnvironmentPort(renderer: WebGLRenderer): EnvironmentPort { + const generator = new PMREMGenerator(renderer) + // Compiled up front: the first `fromEquirectangular` would otherwise stall the frame that + // asked for it, which is the frame where the user has just chosen a sky. + generator.compileEquirectangularShader() + let grading: SkyGrading | null = null + + return { + fromEquirectangular: texture => generator.fromEquirectangular(texture), + fromScene: scene => generator.fromScene(scene, ROOM_SIGMA), + grade: (given, stack) => (grading ??= createSkyGrading(renderer)).of(given, stack) ?? given, + dispose: () => { + grading?.dispose() + grading = null + generator.dispose() + }, + } +} + +/** + * `as`: this driver is only ever handed the renderer it built itself, which is a `WebGLRenderer` + * — the interface is widened for the Advanced engine, and narrowing it back is what says so. + */ +function asWebGL(renderer: StudioRenderer): WebGLRenderer { + return renderer as WebGLRenderer +} + +const capsOf = (renderer: StudioRenderer): WebGLRenderer['capabilities'] => + asWebGL(renderer).capabilities diff --git a/src/renderer/src/engines/render/gpuAdapter.test.ts b/src/renderer/src/engines/render/gpuAdapter.test.ts new file mode 100644 index 000000000..faaad84c7 --- /dev/null +++ b/src/renderer/src/engines/render/gpuAdapter.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { askedGpuAdapter, forgetGpuAdapter, probeGpuAdapter } from './gpuAdapter' + +/** What `navigator.gpu` answers for the length of one test. jsdom exposes none of its own. */ +function gpuAnswering(requestAdapter: () => Promise): void { + Reflect.set(navigator, 'gpu', { requestAdapter }) +} + +afterEach(() => { + forgetGpuAdapter() + Reflect.deleteProperty(navigator, 'gpu') +}) + +describe('whether this machine has a WebGPU adapter', () => { + it('answers nothing at all until somebody asks', () => { + expect(askedGpuAdapter()).toBe(null) + }) + + it('reads no adapter whether the browser has none, refuses, or throws', async () => { + // One answer for the three: none of them can draw, and the caller has one fallback. + expect(await probeGpuAdapter()).toBe(false) + + forgetGpuAdapter() + gpuAnswering(() => Promise.resolve(null)) + expect(await probeGpuAdapter()).toBe(false) + + forgetGpuAdapter() + gpuAnswering(() => Promise.reject(new Error('no device'))) + expect(await probeGpuAdapter()).toBe(false) + }) + + it('reads the adapter the browser handed back', async () => { + gpuAnswering(() => Promise.resolve({})) + + expect(await probeGpuAdapter()).toBe(true) + expect(askedGpuAdapter()).toBe(true) + }) + + it('asks the driver once however many viewports mount', async () => { + const asking = vi.fn(() => Promise.resolve({})) + gpuAnswering(asking) + + await Promise.all([probeGpuAdapter(), probeGpuAdapter()]) + await probeGpuAdapter() + + expect(asking).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/engines/render/gpuAdapter.ts b/src/renderer/src/engines/render/gpuAdapter.ts new file mode 100644 index 000000000..f4e348a6d --- /dev/null +++ b/src/renderer/src/engines/render/gpuAdapter.ts @@ -0,0 +1,46 @@ +/** + * Whether this machine has a WebGPU adapter at all. + * + * 🛑 `navigator.gpu?.requestAdapter()` answering is the ONE signal that decides. `adapter.info` + * can enrich a sentence shown to a person, and must never enter the decision: the vendor and + * device strings are not standardised in a way that holds across platforms. + * + * Asked once and remembered: a viewport mounts per panel, and an adapter request per mount + * would ask the driver the same question a dozen times an opening. + */ + +/** `null` before anybody asked. A viewport reads it without waiting — see `askedGpuAdapter`. */ +let answered: boolean | null = null +let asking: Promise | null = null + +/** What the last probe found, or `null` while nobody has asked yet. Never waits. */ +export function askedGpuAdapter(): boolean | null { + return answered +} + +/** + * Asks the browser, once. A refusal, a throw and a browser with no `navigator.gpu` at all are + * one answer here: none of the three can draw, and the caller has one fallback either way. + */ +export async function probeGpuAdapter(): Promise { + if (answered !== null) return answered + asking ??= askAdapter() + return await asking +} + +async function askAdapter(): Promise { + try { + answered = (await navigator.gpu?.requestAdapter()) != null + } catch { + // A browser that exposes `navigator.gpu` and refuses to answer has no adapter to give: the + // reason belongs to the console, and the studio's answer to all of them is the same. + answered = false + } + return answered +} + +/** For a test that has to open on a known answer. Production never calls it. */ +export function forgetGpuAdapter(): void { + answered = null + asking = null +} diff --git a/src/renderer/src/engines/render/gpuComposer.ts b/src/renderer/src/engines/render/gpuComposer.ts new file mode 100644 index 000000000..0ab06d2aa --- /dev/null +++ b/src/renderer/src/engines/render/gpuComposer.ts @@ -0,0 +1,279 @@ +/** + * The Advanced engine's composition chain: a `RenderPipeline` of TSL nodes, where the Compatible + * one builds an `EffectComposer` of GLSL passes. + * + * 🛑 NO fusion pass here, and that is not an omission. `fuseShader` exists on the GL side to + * share bandwidth between hand-written passes; `RenderPipeline` already shares depth and normals + * between the nodes that read them, so writing one would be work for nothing. + * + * Only the effects the registry says this engine can build enter the chain — the others are left + * out rather than refused: a stack carries what a document says, and an engine cannot make a + * document wrong. + */ +import { Vector4 } from 'three' +import type { WebGPURenderer } from 'three/webgpu' +import { + planStack, + runsOnEngine, + stackShapeKey, + survivesOneShot, + type PostEffect, +} from '@shared/domain/postProcessing' +import { paramNumber } from '../postfx/uniforms' +import { samplesOf } from '../postfx/postQuality' +import { heaviestCost } from '../postfx/postPlan' +import { gpuBudgetFor, type GpuBudget } from './gpuPostQuality' +import type { GpuModule } from './gpuModule' +import { asNodeTarget, drawInto } from './renderDriver' +import type { ComposerJob, SceneComposer } from './sceneComposer' + +type Occlusion = ReturnType + +/** One built chain, kept per shape of stack and per surface, as the Compatible one keeps its own. */ +type GpuChain = { + /** + * The camera the pass and the occlusion were BUILT with. A node chain bakes it in where the + * GL one rebinds it per draw, so a surface handed another camera — a film whose shot list + * changes camera mid-way — needs the chain built again rather than reused. + */ + camera: ComposerJob['camera'] + pipeline: { render: () => void; dispose: () => void } + /** + * The nodes freed by hand: `RenderPipeline.dispose` frees its quad material and no target. The + * scene pass owns the MRT the whole frame is drawn into, the occlusion its own half-resolution + * buffer, and the temporal anti-aliaser a history and a resolve buffer. + */ + owned: readonly { dispose: () => void }[] + /** Written before every draw: the nodes read them, so a slider moves a number and nothing else. */ + apply: (effects: readonly PostEffect[], budget: GpuBudget, width: number, height: number) => void +} + +/** A built chain and the shape of stack it was built for. Kept per SURFACE — see `chainFor`. */ +type HeldChain = { shape: string; chain: GpuChain } + +export function createGpuComposer(gpu: GpuModule, renderer: WebGPURenderer): SceneComposer { + const chains = new Map() + // Scratch, so a frame allocates nothing: `draw` runs once per surface, per image — the same + // reason `PostComposer` keeps its own held rectangles as fields. + const heldViewport = new Vector4() + const heldScissor = new Vector4() + + /** + * Points the renderer at where this job lands, and hands back the call that puts back what + * was there. Held and restored around every draw, as `PostComposer.hold`/`restore` does: this + * runs INSIDE the pane loop, which has already set a scissor for the pane after this one. + */ + const aimAt = (job: ComposerJob): (() => void) => { + renderer.getViewport(heldViewport) + renderer.getScissor(heldScissor) + const heldScissorTest = renderer.getScissorTest() + const restoreTarget = drawInto(renderer, job.target) + // 🛑 The OUTPUT target and not only the render target: a `PassNode` sizes its own buffers + // from `getOutputRenderTarget()` when there is one and from the DRAWING BUFFER when there + // is not. Left unsaid, a film at 1920×1080 would compose out of a G-buffer the size of the + // canvas behind it — the GL chain is compiled at the job's own size for the same reason. + // + // 🛑 Set for a PLAIN render too, and it is not tidiness: rendering into a target without it, + // three writes the working colour space and the read-back comes out linear — measured + // 2026-09-11, every pixel of a still differed. What remains with it set is a tonal + // difference on 13.6 % of the pixels between this straight render and the one the viewport + // makes when no composer is asked at all. Same picture, two tones; see the report. + renderer.setOutputRenderTarget(asNodeTarget(job.target)) + const restore = (): void => { + renderer.setOutputRenderTarget(null) + restoreTarget() + renderer.setViewport(heldViewport) + renderer.setScissor(heldScissor) + renderer.setScissorTest(heldScissorTest) + } + if (!job.rect) return restore + renderer.setViewport(job.rect.x, job.rect.y, job.rect.width, job.rect.height) + renderer.setScissor(job.rect.x, job.rect.y, job.rect.width, job.rect.height) + renderer.setScissorTest(true) + return restore + } + + const free = (surface: string): void => { + const held = chains.get(surface) + if (!held) return + held.chain.pipeline.dispose() + // The MRT the scene pass draws into is a full-frame colour, normal and depth buffer, and + // nothing in `RenderPipeline.dispose` reaches it — evicted chains would leak one each. + for (const node of held.chain.owned) node.dispose() + chains.delete(surface) + } + + /** + * The chain this job draws through, built or found. + * + * ONE per surface: a node chain holds the pass that draws the scene, and two panes sharing one + * would each see the other's camera. A surface whose stack changed shape — or that is handed + * another camera — frees what it held HERE rather than leaving it for a sweep, which would + * otherwise leave a full-frame MRT behind on every edit. + */ + const chainFor = (job: ComposerJob, effects: readonly PostEffect[], shape: string): GpuChain => { + const held = chains.get(job.surface) + // Answered before anything is written: this runs once per surface per IMAGE, and the steady + // state — same stack, same camera — has nothing to say. + if (held?.shape === shape && held.chain.camera === job.camera) return held.chain + + if (held) free(job.surface) + const chain = build(gpu, renderer, job, effects) + chains.set(job.surface, { shape, chain }) + return chain + } + + return { + draw: job => { + const plan = planStack(job.stack) + // A surface drawn ONCE loses whatever resolves against the frames before it: an export + // builds a chain, draws a picture and frees it, and a temporal node handed an empty + // history draws a flat colour. See `survivesOneShot`. + // + // Two module-level predicates rather than one closure over `job`: this runs once per + // surface per IMAGE, and the viewport — the only surface that runs at 60 Hz — takes the + // branch that allocates nothing. + const effects = job.oneShot + ? plan.effects.filter(drawableOnce) + : plan.effects.filter(drawableOnGpu) + if (effects.length === 0 || job.width < 1 || job.height < 1) { + const restore = aimAt(job) + try { + renderer.render(job.scene, job.camera) + } finally { + restore() + } + return + } + + const chain = chainFor(job, effects, plan.shapeKey) + chain.apply(effects, gpuBudgetFor(heaviestCost(effects), job.quality), job.width, job.height) + + const restore = aimAt(job) + try { + chain.pipeline.render() + } finally { + restore() + } + }, + + sweep: live => { + const shapes = new Set(live.map(stackShapeKey)) + for (const [surface, held] of [...chains]) if (!shapes.has(held.shape)) free(surface) + }, + + releaseSurface: free, + + dispose: () => { + for (const surface of [...chains.keys()]) free(surface) + }, + } +} + +/** + * The chain: the scene drawn into a pass that writes its normals alongside the picture, then the + * occlusion multiplied into it. The slot order the registry fixes, unchanged — `ao` reads the + * depth and the normals of the render and darkens before anything spreads light around. + */ +function build( + gpu: GpuModule, + renderer: WebGPURenderer, + job: ComposerJob, + effects: readonly PostEffect[], +): GpuChain { + const { float, mix, uniform, vec3, vec4 } = gpu.tsl + const wantsAntialias = effects.some(one => one.effect === 'traa') + const scene = scenePass(gpu, job, wantsAntialias) + + const occlusion = effects.some(one => one.effect === 'gtao') + ? gpu.gtao.ao(scene.getTextureNode('depth'), scene.getTextureNode('normal'), job.camera) + : null + + const colour = scene.getTextureNode('output') + /** How much of the occlusion lands, `GTAOPass.blendIntensity` on the other engine. */ + const blend = uniform(1) + // 🛑 `.r` broadcast over three channels, and NEVER the texture node whole: `GTAONode` renders + // its occlusion into a `RedFormat` target, so sampling it gives `(ao, 0, 0, 1)` — multiplied + // into the picture as it stands, that leaves a RED image. three's own fiche spells it this way. + // Caught by `engines:parity` and by nothing else: the bench measured what this chain COSTS. + const lit = occlusion + ? colour.mul(vec4(vec3(mix(float(1), occlusion.getTextureNode().r, blend)), 1)) + : colour + // Last, and the registry already says so: `aa` is the final slot, and an anti-aliaser reads + // finished pixels — the occlusion has to have darkened them before their edges are resolved. + const antialias = wantsAntialias + ? gpu.traa.traa( + lit, + scene.getTextureNode('depth'), + scene.getTextureNode('velocity'), + job.camera, + ) + : null + + const pipeline = new gpu.webgpu.RenderPipeline(renderer, antialias ?? lit) + + return { + camera: job.camera, + pipeline, + // 🛑 The occlusion too: `GTAONode` owns a full-frame `RedFormat` target and a material, and + // `RenderPipeline.dispose` reaches neither — every evicted chain leaked one. + owned: [scene, ...(occlusion ? [occlusion] : []), ...(antialias ? [antialias] : [])], + apply: (held, budget, width, height) => { + const asked = held.find(one => one.effect === 'gtao') + if (occlusion && asked) { + blend.value = paramNumber(asked, 'blend') + applyOcclusion(occlusion, asked, budget, width, height) + } + // The one lever a `TRAANode` offers: its samples are FRAMES, of a fixed sequence, and no + // number of them is exposed — so it is kept where nothing is being cut. See `gpuPostQuality`. + if (antialias) antialias.useSubpixelCorrection = budget.samples === 1 + }, + } +} + +/** + * The pass that draws the scene, and what it writes alongside the picture. + * + * The normals come from the SAME pass: read from a second one they would cost the scene twice, + * which is the whole reason a node chain exists. The velocity joins them only where something + * reprojects — it is a full-frame buffer nobody else reads. + * + * 🛑 `samples: 0` where the temporal anti-aliaser runs, and it is three.js that says so: a + * `PassNode` takes the RENDERER's multisampling unless told otherwise, and TRAA resolves its own + * edges from the previous frames — the two together resolve twice and smear. + */ +function scenePass(gpu: GpuModule, job: ComposerJob, wantsAntialias: boolean) { + const { mrt, normalView, output, pass, velocity } = gpu.tsl + const scene = pass(job.scene, job.camera, wantsAntialias ? { samples: 0 } : undefined) + scene.setMRT(mrt({ output, normal: normalView, ...(wantsAntialias ? { velocity } : {}) })) + return scene +} + +/** Everything the occlusion node reads off its fiche, through the budget the setting allows. */ +function applyOcclusion( + occlusion: Occlusion, + effect: PostEffect, + budget: GpuBudget, + width: number, + height: number, +): void { + occlusion.radius.value = paramNumber(effect, 'radius') + occlusion.distanceExponent.value = paramNumber(effect, 'distanceExponent') + occlusion.thickness.value = paramNumber(effect, 'thickness') + occlusion.scale.value = paramNumber(effect, 'scale') + occlusion.samples.value = samplesOf(paramNumber(effect, 'samples'), budget) + // Said to the NODE rather than to a target: a node chain carries its own scale, where the GL + // chain is compiled at a size. The same reading either way — see `gpuPostQuality`. + occlusion.resolutionScale = budget.resolutionScale + occlusion.setSize(width, height) +} + +/** Whether the Advanced engine can build this one at all — the registry answers, nothing else. */ +function drawableOnGpu(effect: PostEffect): boolean { + return runsOnEngine(effect.effect, 'gpu') +} + +/** The same, for a picture drawn once: a temporal node has no history there. */ +function drawableOnce(effect: PostEffect): boolean { + return drawableOnGpu(effect) && survivesOneShot(effect.effect) +} diff --git a/src/renderer/src/engines/render/gpuDriver.ts b/src/renderer/src/engines/render/gpuDriver.ts new file mode 100644 index 000000000..effaccde4 --- /dev/null +++ b/src/renderer/src/engines/render/gpuDriver.ts @@ -0,0 +1,139 @@ +/** + * The Advanced engine: WebGPU, drawn through TSL nodes. + * + * 🛑 Every method here needs the bundle `gpuModule` loads, and a driver is only ever CHOSEN once + * that bundle is in — see `driverFor`, which reads the same answer. A call that arrives without + * it is a defect of the chooser, and says so rather than drawing nothing. + * + * What differs from the Compatible engine, said here rather than found later: + * + * - The renderer asks the browser for a device, so it cannot draw on the frame it was built on. + * `ready` is what the viewport holds its frames on. + * - Reading pixels back is asynchronous by nature: a GPU buffer is mapped, and the map resolves + * a frame later. The interface promises a promise on both sides for exactly this. + * - A sky the document GRADES is shown ungraded: the grading is a hand-written GLSL pass, and + * porting it is a chantier of its own. Said once in the journal rather than silently. + */ +import { localizedError } from '@shared/localizedError' +import { reportFailure } from '@/services/diagnostics' +import { createEnvironment, ROOM_SIGMA, type EnvironmentPort } from '../viewport/environment' +import { createGpuComposer } from './gpuComposer' +import { loadedGpuModule, type GpuModule } from './gpuModule' +import { applyMaterialNodes } from './materialNodes' +import { asNodeTarget, type RenderDriver, type StudioRenderer } from './renderDriver' +import type { WebGPURenderer } from 'three/webgpu' + +export const gpuDriver: RenderDriver = { + engine: 'gpu', + + createRenderer: ({ canvas, alpha }) => + new (loaded().webgpu.WebGPURenderer)({ canvas, antialias: true, alpha }), + + // The backend, asked for once per renderer. `render()` throws until it answers. + ready: async renderer => { + await asNodeRenderer(renderer).init() + }, + + readPixels: async (renderer, target, width, height) => { + const read = await asNodeRenderer(renderer).readRenderTargetPixelsAsync( + asNodeTarget(target), + 0, + 0, + width, + height, + ) + return sameShapeAsGl( + new Uint8Array(read.buffer, read.byteOffset, read.byteLength), + width, + height, + ) + }, + + createComposer: renderer => createGpuComposer(loaded(), asNodeRenderer(renderer)), + + createEnvironment: (renderer, scene, requestRender) => + createEnvironment(gpuEnvironmentPort(loaded(), asNodeRenderer(renderer)), scene, requestRender), + + patchMaterial: (material, uniforms) => applyMaterialNodes(loaded(), material, uniforms), + + // No overlay on this engine yet, and `ViewHelper` is a `WebGLRenderer` of three's own. + drawOverlay: () => {}, + + // A node renderer sizes the attachments of a render target itself, and keeps the card's + // sampling ceiling on the renderer rather than under a `capabilities`. + maxSamples: () => 0, + drawingBufferSamples: () => 0, + maxAnisotropy: renderer => Math.max(1, asNodeRenderer(renderer).getMaxAnisotropy()), + frameTimer: () => null, + // Nothing to give back: the device is the browser's, and it reclaims it with the page. + releaseContext: () => {}, +} + +/** + * The mip chain, and no grading pass. + * + * `createSkyGrading` is a chain of hand-written GLSL passes on a `WebGLRenderer`; there is no + * node equivalent yet. A graded sky therefore lights and hangs behind an Advanced scene as its + * FILE holds it. Reported once, under the scope a sky already speaks through — a picture that + * quietly ignores the dials of the panel beside it is worse than one that says it did. + */ +function gpuEnvironmentPort(gpu: GpuModule, renderer: WebGPURenderer): EnvironmentPort { + const generator = new gpu.webgpu.PMREMGenerator(renderer) + let said = false + + return { + fromEquirectangular: texture => generator.fromEquirectangular(texture), + fromScene: scene => generator.fromScene(scene, ROOM_SIGMA), + grade: given => { + if (!said) { + said = true + reportFailure('skybox.source', 'grading', localizedError('renderEngineGradingMissing')) + } + return given + }, + dispose: () => generator.dispose(), + } +} + +/** Read once per call rather than held: a driver outlives the session that loaded its bundle. */ +function loaded(): GpuModule { + const held = loadedGpuModule() + if (!held) throw localizedError('renderEngineUnavailable') + return held +} + +/** + * `as`: this driver is only ever handed the renderer it built itself, which is a node renderer — + * the interface is widened for the Compatible engine, and narrowing it back is what says so. + */ +function asNodeRenderer(renderer: StudioRenderer): WebGPURenderer { + return renderer as WebGPURenderer +} + +/** + * The buffer a node renderer hands back, laid out the way the Compatible one lays its own out. + * The callers encode a PNG from it and assume ONE shape; two would be two readers to keep in + * step, and the one that drifted would shear or mirror a whole export. + * + * 🛑 Two differences, both silent if left alone: + * + * - **Rows are padded.** WebGPU copies a texture to a buffer at 256-byte row alignment, so a + * width that is not a multiple of 64 pixels comes back with slack at the end of every row. + * Kept, the picture shears a little further to the side on each row down. + * - **Rows come top-down**, where `readRenderTargetPixels` answers bottom-up. The film encoder + * flips unconditionally, so left alone every Advanced frame comes out upside down. + */ +function sameShapeAsGl(read: Uint8Array, width: number, height: number): Uint8Array { + const row = width * 4 + const padded = Math.ceil(row / BYTES_PER_ROW_ALIGNMENT) * BYTES_PER_ROW_ALIGNMENT + const pixels = new Uint8Array(row * height) + for (let line = 0; line < height; line += 1) { + const from = line * padded + // Written bottom-up: the last line read is the first line of what a GL read would give. + pixels.set(read.subarray(from, from + row), (height - 1 - line) * row) + } + return pixels +} + +/** What WebGPU aligns a texture-to-buffer copy to, per row. */ +const BYTES_PER_ROW_ALIGNMENT = 256 diff --git a/src/renderer/src/engines/render/gpuModule.ts b/src/renderer/src/engines/render/gpuModule.ts new file mode 100644 index 000000000..3bc9d7f7c --- /dev/null +++ b/src/renderer/src/engines/render/gpuModule.ts @@ -0,0 +1,57 @@ +/** + * The Advanced engine's own three.js, loaded only when a machine can run it. + * + * 🛑 A SEPARATE bundle, and a large one: `three/webgpu` re-exports the whole library with the + * node system on top. Imported at the head of any module the editor always loads, every session + * would pay for it — so it is asked for beside the adapter, and the answer is remembered. + * + * The four are asked for together because they arrive together: a viewport that has the renderer + * but not the occlusion node would build a chain it cannot finish. + */ +import type * as WebGpuModule from 'three/webgpu' +import type * as TslModule from 'three/tsl' +import type * as GtaoModule from 'three/addons/tsl/display/GTAONode.js' +import type * as TraaModule from 'three/addons/tsl/display/TRAANode.js' +import { askedGpuAdapter, probeGpuAdapter } from './gpuAdapter' + +export type GpuModule = { + webgpu: typeof WebGpuModule + tsl: typeof TslModule + gtao: typeof GtaoModule + traa: typeof TraaModule +} + +let held: GpuModule | null = null +let loading: Promise | null = null + +/** What was loaded, or `null` while nobody has finished asking. Never waits — a mount cannot. */ +export function loadedGpuModule(): GpuModule | null { + return held +} + +/** + * Asks for the adapter and the bundle together, once. Answers `null` on a machine with no + * adapter: loading two megabytes of renderer for a driver that will never draw is the cost this + * exists to avoid. + */ +export async function loadGpuModule(): Promise { + if (held) return held + // A machine already known to have no adapter is not asked twice, and its two megabytes of + // renderer are never fetched at all. + if (askedGpuAdapter() === false) return null + loading ??= importGpuModule() + return await loading +} + +async function importGpuModule(): Promise { + if (!(await probeGpuAdapter())) return null + + const [webgpu, tsl, gtao, traa] = await Promise.all([ + import('three/webgpu'), + import('three/tsl'), + import('three/addons/tsl/display/GTAONode.js'), + import('three/addons/tsl/display/TRAANode.js'), + ]) + held = { webgpu, tsl, gtao, traa } + return held +} diff --git a/src/renderer/src/engines/render/gpuPostQuality.test.ts b/src/renderer/src/engines/render/gpuPostQuality.test.ts new file mode 100644 index 000000000..2640f0996 --- /dev/null +++ b/src/renderer/src/engines/render/gpuPostQuality.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { VIEWPORT_QUALITIES } from '@shared/domain/scene' +import { budgetFor } from '../postfx/postQuality' +import { gpuBudgetFor } from './gpuPostQuality' + +describe('what the Advanced chain is allowed to spend', () => { + // 🛑 The point of the module: a setting has to buy the same thing on both engines, or the two + // pictures cannot be compared and « Performance » means whichever chain happens to be running. + it('answers the same reading as the Compatible chain, at every setting', () => { + for (const quality of VIEWPORT_QUALITIES) { + const gl = budgetFor('high', quality) + const gpu = gpuBudgetFor('high', quality) + + expect(gpu.resolutionScale).toBe(1 / gl.divisor) + expect(gpu.samples).toBe(gl.samples) + } + }) +}) diff --git a/src/renderer/src/engines/render/gpuPostQuality.ts b/src/renderer/src/engines/render/gpuPostQuality.ts new file mode 100644 index 000000000..4759920f0 --- /dev/null +++ b/src/renderer/src/engines/render/gpuPostQuality.ts @@ -0,0 +1,34 @@ +/** + * What the Advanced engine's chain is allowed to spend, by the quality the viewport is set to. + * + * 🛑 DERIVED from `postQuality`, never a second table. The setting has to buy the same thing on + * both engines: a level that halves the pixels an occlusion is worked out at, and takes 40 % of + * the samples it asks for, must do so whichever chain is running — otherwise « Performance » + * means two different pictures and neither can be compared with the other. + * + * The shapes differ because the chains do. A GLSL chain is built at a SIZE, so the GL budget + * says by how much to divide it; a `RenderPipeline` node carries its own `resolutionScale`, a + * share of the frame. The two are the same number, read the other way up. + */ +import type { PostCost } from '@shared/domain/postProcessing' +import type { ViewportQuality } from '@shared/domain/scene' +import { budgetFor } from '../postfx/postQuality' + +export type GpuBudget = { + /** What share of the frame an occlusion is worked out at — `GTAONode.resolutionScale`. */ + resolutionScale: number + /** + * What share of the samples a sampling effect asks for it actually takes. + * + * Also what the temporal anti-aliaser reads, there being nothing else to read: three 0.185 + * exposes no sample COUNT on a `TRAANode` — its samples are frames of a fixed jitter sequence + * — so its one lever, the sub-pixel correction, is kept exactly where nothing is being cut. + */ + samples: number +} + +/** The same reading as the GL chain's, in the units a node chain takes. */ +export function gpuBudgetFor(heaviest: PostCost | null, quality: ViewportQuality): GpuBudget { + const budget = budgetFor(heaviest, quality) + return { resolutionScale: 1 / budget.divisor, samples: budget.samples } +} diff --git a/src/renderer/src/engines/render/materialNodes.test.ts b/src/renderer/src/engines/render/materialNodes.test.ts new file mode 100644 index 000000000..7773c6074 --- /dev/null +++ b/src/renderer/src/engines/render/materialNodes.test.ts @@ -0,0 +1,96 @@ +import { MeshStandardMaterial, Texture } from 'three' +import type { Node } from 'three/webgpu' +import { beforeAll, describe, expect, it } from 'vitest' +import { createUniforms } from '../material/materialShader' +import { applyMaterialNodes } from './materialNodes' +import type { GpuModule } from './gpuModule' + +/** + * The bundle itself, imported rather than mocked: building a node graph needs no device, and a + * doubled TSL would prove the double rather than the graph. Nothing here draws. + */ +let gpu: GpuModule + +beforeAll(async () => { + const [webgpu, tsl, gtao, traa] = await Promise.all([ + import('three/webgpu'), + import('three/tsl'), + import('three/addons/tsl/display/GTAONode.js'), + import('three/addons/tsl/display/TRAANode.js'), + ]) + gpu = { webgpu, tsl, gtao, traa } +}) + +/** Every uniform of a built graph, which is where the bridge to the engine's own values shows. */ +function uniformsOf( + node: Node | null, +): readonly { value: unknown; update: (frame: never) => void }[] { + const found: { value: unknown; update: (frame: never) => void }[] = [] + node?.traverse(one => { + if ('isUniformNode' in one && one.isUniformNode === true) { + // `as`: what a uniform node holds is its `value`, and the type of the graph's members is + // the base `Node` — the narrowing is the `isUniformNode` flag three itself writes. + found.push(one as unknown as { value: unknown; update: (frame: never) => void }) + } + }) + return found +} + +const holding = (nodes: ReturnType, value: unknown): boolean => + nodes.some(node => node.value === value) + +describe('the material patch as nodes', () => { + // 🛑 Shared, never copied: the material window writes into these very objects, and a copy + // would leave the Advanced engine showing the remap the panel opened on for ever. + it('reads the remaps out of the objects the Compatible engine writes into', () => { + const material = new MeshStandardMaterial() + const uniforms = createUniforms() + applyMaterialNodes(gpu, material, uniforms) + + expect(holding(uniformsOf(material.roughnessNode), uniforms.roughnessRemap.value)).toBe(true) + expect(holding(uniformsOf(material.metalnessNode), uniforms.metalnessRemap.value)).toBe(true) + }) + + it('re-reads what is replaced rather than written into, on every render', () => { + // A scalar and a texture are assigned, not mutated: shared by reference they would freeze. + const material = new MeshStandardMaterial() + const uniforms = createUniforms() + applyMaterialNodes(gpu, material, uniforms) + const mask = new Texture() + uniforms.edgeIntensity.value = 0.75 + uniforms.edgeMap.value = mask + + for (const node of uniformsOf(material.colorNode)) node.update(EMPTY_FRAME) + + const held = uniformsOf(material.colorNode) + expect(holding(held, 0.75)).toBe(true) + expect(holding(held, mask)).toBe(true) + }) + + it('follows the material own factors, which the window writes onto it directly', () => { + const material = new MeshStandardMaterial() + applyMaterialNodes(gpu, material, createUniforms()) + material.roughness = 0.35 + + for (const node of uniformsOf(material.roughnessNode)) node.update(EMPTY_FRAME) + + expect(holding(uniformsOf(material.roughnessNode), 0.35)).toBe(true) + }) + + // 🛑 What the GLSL patch cannot do: it is guarded by `#ifdef USE_ROUGHNESSMAP`, so every slot + // filled or emptied rebuilds the program. Here it moves a number. + it('keeps one graph when a channel is filled', () => { + const material = new MeshStandardMaterial() + applyMaterialNodes(gpu, material, createUniforms()) + const built = material.roughnessNode + material.roughnessMap = new Texture() + + for (const node of uniformsOf(material.roughnessNode)) node.update(EMPTY_FRAME) + + expect(material.roughnessNode).toBe(built) + expect(holding(uniformsOf(material.roughnessNode), 1)).toBe(true) + }) +}) + +/** What a node update reads of the frame here: nothing. Every callback of this module ignores it. */ +const EMPTY_FRAME = {} as never diff --git a/src/renderer/src/engines/render/materialNodes.ts b/src/renderer/src/engines/render/materialNodes.ts new file mode 100644 index 000000000..f995c344e --- /dev/null +++ b/src/renderer/src/engines/render/materialNodes.ts @@ -0,0 +1,99 @@ +/** + * The three things the standard material does not offer, written as TSL nodes: the roughness and + * metalness remaps, and the cavity mask. + * + * The node rewrite of `materialShader.ts`, not a translation of its GLSL. Two differences are + * deliberate and are the whole reason this is a rewrite: + * + * - NO recompilation when a channel is filled or emptied. The GLSL patch is guarded by + * `#ifdef USE_ROUGHNESSMAP`, so every slot that goes from empty to filled rebuilds the program. + * Here a `has` uniform selects between the remapped texel and the plain factor, and filling a + * slot moves a number. + * - The cavity lands on the DIFFUSE COLOUR rather than on `reflectedLight`, which a node material + * exposes no seam on. Identical for a dielectric, where the cavity is used; on a metal, whose + * specular tint three derives from that same colour, the Advanced engine darkens a little of + * what the Compatible one leaves alone. + * + * The two remapped maps are read through nodes of our own, so they carry their own placement — + * `placedUv`. Left to the default `uv()` they would read untiled while every other map of the + * material tiles, and the cavity mask beside them would drift out of step. + * + * The uniforms are the ENGINE's — the very objects `materialShader.createUniforms` builds and the + * material window writes into. A `Vector2` is shared by reference and needs nothing; a scalar and + * a texture are replaced rather than written into, so those are read back on every render. + */ +import { Matrix3, Texture, type MeshStandardMaterial } from 'three' +import type { MaterialUniforms } from '../material/materialShader' +import type { GpuModule } from './gpuModule' + +/** What a slot samples where no picture is bound. Its `has` uniform is zero there. */ +const NO_MASK = new Texture() + +/** The placement of a map with none — no repeat, no offset, no rotation. */ +const NO_TRANSFORM = new Matrix3() + +/** + * Writes the studio's three additions onto a node material. + * + * Everything the material itself holds — its two factors and its colour — is read back on every + * render rather than copied once: the window writes them onto the material like any other + * property, and a copy would freeze the sliders at what they held when the panel opened. + */ +export function applyMaterialNodes( + { tsl }: GpuModule, + material: MeshStandardMaterial, + uniforms: MaterialUniforms, +): void { + const { float, materialColor, mix, texture, uniform, uv, vec3 } = tsl + + /** + * Where a map is READ, matrix included. Every PBR map of the window carries a repeat, an + * offset and a rotation (`placeMap`), and three applies them for the maps it owns; these + * three are read through nodes of our own, so the matrix has to be carried with them or a + * tiled roughness reads untiled while the cavity beside it tiles. + */ + const placedUv = (mapOf: () => Texture | null) => { + const matrix = uniform(new Matrix3()).onRenderUpdate(() => transformOf(mapOf())) + return matrix.mul(vec3(uv(), 1)).xy + } + + const roughnessRemap = uniform(uniforms.roughnessRemap.value) + const metalnessRemap = uniform(uniforms.metalnessRemap.value) + const edgeIntensity = uniform(0).onRenderUpdate(() => uniforms.edgeIntensity.value) + const edgeTransform = uniform(uniforms.edgeTransform.value) + const edgeMap = texture(NO_MASK).onRenderUpdate(() => uniforms.edgeMap.value ?? NO_MASK) + + const roughness = uniform(0).onRenderUpdate(() => material.roughness) + const metalness = uniform(0).onRenderUpdate(() => material.metalness) + const hasRoughnessMap = uniform(0).onRenderUpdate(() => (material.roughnessMap ? 1 : 0)) + const hasMetalnessMap = uniform(0).onRenderUpdate(() => (material.metalnessMap ? 1 : 0)) + const roughnessTexel = texture(NO_MASK).onRenderUpdate(() => material.roughnessMap ?? NO_MASK) + const metalnessTexel = texture(NO_MASK).onRenderUpdate(() => material.metalnessMap ?? NO_MASK) + + // The channels three itself reads: green for roughness, blue for metalness — an ORM picture + // packs them that way, and reading red would answer with the occlusion. + const roughnessRead = roughnessTexel.sample(placedUv(() => material.roughnessMap)) + const metalnessRead = metalnessTexel.sample(placedUv(() => material.metalnessMap)) + material.roughnessNode = roughness.mul( + mix(float(1), mix(roughnessRemap.x, roughnessRemap.y, roughnessRead.g), hasRoughnessMap), + ) + material.metalnessNode = metalness.mul( + mix(float(1), mix(metalnessRemap.x, metalnessRemap.y, metalnessRead.b), hasMetalnessMap), + ) + + // Its own transform and its own uv: the mask sits in no three slot, so nothing computes a + // coordinate for it, and the matrix is what keeps it repeating in step with the eight maps + // that do have one. + const masked = edgeMap.sample(edgeTransform.mul(vec3(uv(), 1)).xy) + const cavity = float(1).sub(masked.r.mul(edgeIntensity)) + // `materialColor` and not the material's own colour: it is where three multiplies the base + // colour MAP in, and a plain uniform here would render every textured material flat. + material.colorNode = materialColor.mul(cavity) +} + +/** A map's placement, refreshed as three does before reading it, or the identity for no map. */ +function transformOf(map: Texture | null): Matrix3 { + if (!map) return NO_TRANSFORM + map.updateMatrix() + return map.matrix +} diff --git a/src/renderer/src/engines/render/mountRenderer.test.ts b/src/renderer/src/engines/render/mountRenderer.test.ts new file mode 100644 index 000000000..f54690ba9 --- /dev/null +++ b/src/renderer/src/engines/render/mountRenderer.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest' +import type { WebGLRenderer } from 'three' +import { mountRenderer, type RenderDrivers } from './mountRenderer' +import type { RenderDriver, RendererRequest } from './renderDriver' + +/** + * The two casts of this file, and their one reason: which driver answered is settled by + * IDENTITY, so nothing here reads a canvas or a renderer — and a graphics context is exactly + * what a test may not need, this suite running under node. + */ +const NOTHING = {} + +const request: RendererRequest = { canvas: canvasStub(), alpha: false } + +function canvasStub(): HTMLCanvasElement { + return NOTHING as HTMLCanvasElement +} + +function drivers(gpu: Partial = {}): RenderDrivers { + const stub = (engine: 'gl' | 'gpu'): RenderDriver => ({ + engine, + createRenderer: () => rendererStub(engine), + ready: () => null, + readPixels: () => Promise.resolve(new Uint8Array()), + createComposer: () => { + throw new Error('not asked for') + }, + createEnvironment: () => { + throw new Error('not asked for') + }, + patchMaterial: () => {}, + drawOverlay: () => {}, + maxSamples: () => 0, + drawingBufferSamples: () => 0, + maxAnisotropy: () => 1, + frameTimer: () => null, + releaseContext: () => {}, + }) + return { gl: stub('gl'), gpu: { ...stub('gpu'), ...gpu } } +} + +function rendererStub(engine: 'gl' | 'gpu'): WebGLRenderer { + return { engine } as unknown as WebGLRenderer +} + +describe('mounting a renderer', () => { + it('draws with the Advanced engine once an adapter has answered', () => { + const two = drivers() + + expect(mountRenderer(request, 'gpu', true, vi.fn(), two).driver).toBe(two.gpu) + }) + + it('falls back to the Compatible engine when the Advanced one throws', () => { + const two = drivers({ + createRenderer: () => { + throw new Error('no device') + }, + }) + const said = vi.fn() + + const mounted = mountRenderer(request, 'gpu', true, said, two) + + expect(mounted.driver).toBe(two.gl) + expect(said).toHaveBeenCalledOnce() + }) + + // The same branch a mount takes before anybody has asked the adapter: a viewport cannot wait + // on `requestAdapter`, and one that waited would show nothing while it did. + it('says why when the Advanced bundle is not in hand', () => { + const said = vi.fn() + + mountRenderer(request, 'gpu', false, said, drivers()) + + expect(said).toHaveBeenCalledOnce() + }) + + it('says nothing at all for a project that asked for the Compatible engine', () => { + const said = vi.fn() + + const mounted = mountRenderer(request, 'gl', false, said, drivers()) + + expect(mounted.driver.engine).toBe('gl') + expect(said).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/engines/render/mountRenderer.ts b/src/renderer/src/engines/render/mountRenderer.ts new file mode 100644 index 000000000..ea2b5a814 --- /dev/null +++ b/src/renderer/src/engines/render/mountRenderer.ts @@ -0,0 +1,55 @@ +/** + * Which driver draws, and what happens when the one asked for cannot. + * + * Apart from `renderDriver.ts`, which holds the interface alone: the implementations import that + * interface, so a chooser living beside it would close the graph into a cycle — see + * `main/import-cycles.test.ts`. + */ +import type { RenderEngine } from '@shared/domain/renderEngine' +import { localizedError } from '@shared/localizedError' +import { glDriver } from './glDriver' +import { gpuDriver } from './gpuDriver' +import type { RenderDriver, RendererRequest, StudioRenderer } from './renderDriver' + +/** The two implementations, named together so a caller — or a test — can swap either. */ +export type RenderDrivers = { gl: RenderDriver; gpu: RenderDriver } + +/** + * Not exported: a caller choosing its own pair could read pixels with an engine that did not + * draw them. A test passes its own, which is the only reason the parameter exists. + */ +const RENDER_DRIVERS: RenderDrivers = { gl: glDriver, gpu: gpuDriver } + +/** What was mounted, which is not always what was asked for. */ +export type MountedRenderer = { renderer: StudioRenderer; driver: RenderDriver } + +/** + * Builds the renderer, and falls back rather than failing: a driver that throws leaves the + * Compatible one to draw the very same scene. SILENT on screen and loud in the journal — a + * person who chose Advanced on a machine that cannot run it gets a picture, not a black panel. + * + * `gpuReady` is whether `loadGpuModule` has both an adapter and the node bundle in hand. A mount + * cannot wait for either, so the first viewport of a session opens Compatible — false here — and + * the answer is there for the next. + */ +export function mountRenderer( + request: RendererRequest, + engine: RenderEngine, + gpuReady: boolean, + onFallback: (error: unknown) => void, + drivers: RenderDrivers = RENDER_DRIVERS, +): MountedRenderer { + const wanted = engine === 'gpu' && gpuReady ? drivers.gpu : drivers.gl + // Said even when nothing throws: choosing Advanced and being handed Compatible is the one + // case a reader has to be able to explain, and a machine with no adapter raises nothing. + if (engine === 'gpu' && wanted === drivers.gl) { + onFallback(localizedError('renderEngineUnavailable')) + } + + try { + return { renderer: wanted.createRenderer(request), driver: wanted } + } catch (error) { + onFallback(error) + return { renderer: drivers.gl.createRenderer(request), driver: drivers.gl } + } +} diff --git a/src/renderer/src/engines/render/renderDriver.ts b/src/renderer/src/engines/render/renderDriver.ts new file mode 100644 index 000000000..330ab101a --- /dev/null +++ b/src/renderer/src/engines/render/renderDriver.ts @@ -0,0 +1,141 @@ +/** + * What DRAWS, behind one interface — the seam between the studio's engines and the graphics API + * underneath them. + * + * Everything that depends on which API is running is HERE, and nothing outside asks: building + * the renderer, reading its pixels back, composing a stack, prefiltering an environment, + * patching the standard material, and the four capabilities the two engines keep in different + * places or not at all. Everything else in `engines/` speaks three.js objects, which both APIs + * share — a scene, a camera, a light, a geometry and a render target are the same on both sides. + * + * 🛑 A feature-detect written at a call site is what this exists to prevent: `'capabilities' in + * renderer` scattered over the tree is five copies of one question, each with its own comment, + * and none of them findable from here. + * + * The same shape as `game/ports/`: the interface here, each implementation in a file of its own. + */ +import type { MeshStandardMaterial, Scene, WebGLRenderer, WebGLRenderTarget } from 'three' +import type { RenderTarget, WebGPURenderer } from 'three/webgpu' +import type { RenderEngine } from '@shared/domain/renderEngine' +import type { ViewportEnvironment } from '../viewport/environment' +import type { MaterialUniforms } from '../material/materialShader' +import type { PostComposerOptions } from '../postfx/PostComposer' +import type { SceneComposer } from './sceneComposer' +import type { GpuTimer } from '../viewport/gpuTimer' + +/** + * What the studio draws with, whichever engine built it. + * + * 🛑 A UNION and not a common base: three declares `WebGLRenderer` and the node renderer apart, + * sharing no ancestor. What the studio uses of them is nearly the same surface, and the handful + * of places where it is not are exactly what this driver covers. + */ +export type StudioRenderer = WebGLRenderer | WebGPURenderer + +/** What a canvas is given at construction. The rest a viewport writes onto the renderer itself. */ +export type RendererRequest = { + canvas: HTMLCanvasElement + /** Whether the frame keeps an alpha channel — a scene drawn to be composited over something. */ + alpha: boolean +} + +export type RenderDriver = { + readonly engine: RenderEngine + /** Throws when this engine cannot run here. The caller falls back — see `mountRenderer`. */ + createRenderer: (request: RendererRequest) => StudioRenderer + /** + * Resolves once the renderer may be drawn with, and `null` when it already can be. + * + * 🛑 A node renderer THROWS on `render()` before its backend is up — it asks the browser for a + * device, which is asynchronous — where a WebGL one draws on the line after `new`. A mount + * cannot wait, so the viewport holds its frames until this settles. + */ + ready: (renderer: StudioRenderer) => Promise | null + /** + * One disposable RGBA buffer, ready to be transferred without another UI-thread copy. + * + * A promise on both sides although WebGL answers at once: a GPU read maps a buffer and + * resolves a frame later, and a signature that changed with the engine would put the choice + * back in every caller. All three of them already sit in an async path. + */ + readPixels: ( + renderer: StudioRenderer, + target: WebGLRenderTarget, + width: number, + height: number, + ) => Promise + /** The chain a stack is drawn through: GLSL passes on one side, TSL nodes on the other. */ + createComposer: (renderer: StudioRenderer, options: PostComposerOptions) => SceneComposer + createEnvironment: ( + renderer: StudioRenderer, + scene: Scene, + requestRender: () => void, + ) => ViewportEnvironment + /** + * The three things the standard material does not offer: the roughness and metalness remaps + * and the cavity mask. `onMissingAnchor` is told once per anchor the shipped shader no longer + * carries — the node engine patches no source, so it never calls it. + */ + patchMaterial: ( + material: MeshStandardMaterial, + uniforms: MaterialUniforms, + onMissingAnchor: (anchor: string) => void, + ) => void + /** + * Draws whatever is laid over a finished frame — trihedrons and other screen-space helpers — + * with the clear turned off around it. NOTHING on the Advanced engine, which has no overlay + * yet: `ViewHelper` is declared against a `WebGLRenderer` and three types no node equivalent. + */ + drawOverlay: (renderer: StudioRenderer, draw: (renderer: WebGLRenderer) => void) => void + /** + * The CARD's ceiling: how many samples an off-screen target this engine allocates may ask for. + * ZERO on a node renderer, which sizes the attachments of a render target itself. + */ + maxSamples: (renderer: StudioRenderer) => number + /** + * What the DRAWING BUFFER is actually antialiased to, which is a different question and a + * different answer — zero whenever a render target is bound. + * + * 🛑 The two were one call until 2026-09-11, and a still lost its antialiasing: it asks for a + * ceiling and was handed the sample count of whatever framebuffer happened to be bound. + */ + drawingBufferSamples: (renderer: StudioRenderer) => number + /** + * How many samples the card may take across a texel's footprint. The two engines keep the + * same answer in two places — under `capabilities` on one, on the renderer on the other. + */ + maxAnisotropy: (renderer: StudioRenderer) => number + /** + * The frame timer, or nothing. `EXT_disjoint_timer_query_webgl2` is the Compatible engine's, + * and asking a node renderer for its context at all THROWS until its backend is up. + */ + frameTimer: (renderer: StudioRenderer) => GpuTimer | null + /** Gives the context back before it is collected. A node renderer holds a device instead. */ + releaseContext: (renderer: StudioRenderer) => void +} + +/** + * `as`: the studio allocates `WebGLRenderTarget`, which extends the `RenderTarget` a node + * renderer takes — three declares the pair apart and both engines draw into the same object. + */ +export function asNodeTarget(target: WebGLRenderTarget): RenderTarget +export function asNodeTarget(target: WebGLRenderTarget | null): RenderTarget | null +export function asNodeTarget(target: WebGLRenderTarget | null): RenderTarget | null { + return target as unknown as RenderTarget | null +} + +/** + * Points a renderer at a target and hands back the call that puts the previous one back. + * + * 🛑 Written once because the two engines declare the SAME object apart: `getRenderTarget` + * answers a `WebGLRenderTarget` on one side and a `RenderTarget` on the other, so a save and + * restore written against the union is refused although both accept what both returned. + */ +export function drawInto(renderer: StudioRenderer, target: WebGLRenderTarget | null): () => void { + const previous: unknown = renderer.getRenderTarget() + // `as`: what is put back is exactly what this renderer just handed over, and a renderer takes + // back its own target whichever of the two shapes three declares it under. + const restore = (): void => renderer.setRenderTarget(previous as WebGLRenderTarget | null) + renderer.setRenderTarget(target) + return restore +} diff --git a/src/renderer/src/engines/render/sceneComposer.ts b/src/renderer/src/engines/render/sceneComposer.ts new file mode 100644 index 000000000..4da40aed1 --- /dev/null +++ b/src/renderer/src/engines/render/sceneComposer.ts @@ -0,0 +1,54 @@ +/** + * What a composition is, to the scene engine — the one contract both chains answer. + * + * The Compatible engine builds an `EffectComposer` of GLSL passes and the Advanced one a + * `RenderPipeline` of TSL nodes; neither shape reaches the scene, which asks for a picture on a + * surface and is told nothing about how it was made. + */ +import type { Camera, Scene, WebGLRenderTarget } from 'three' +import type { PostStack } from '@shared/domain/postProcessing' +import type { ViewportQuality } from '@shared/domain/scene' + +/** + * Where on the CANVAS a composition lands, in CSS pixels — both renderers multiply by the device + * ratio themselves, so a rect pre-multiplied here scissors a pane off screen on a HiDPI display. + * + * The same four members as `PaneRect`, and written apart on purpose: this file is the contract + * both chains answer, and a pane is a thing of the editor's viewport that a game does not have. + */ +type ComposerRect = { x: number; y: number; width: number; height: number } + +export type ComposerJob = { + /** Stable destination identity, independent of dimensions, cameras and temporary targets. */ + surface: string + /** + * Whether this chain will be built, drawn and freed for ONE picture — a still, a film frame, a + * validation capture. Said by the caller rather than guessed from `surface`: what an effect + * that resolves against the frames before it needs is FRAMES, and a destination name is a + * different fact that happens to correlate. See `survivesOneShot`. + */ + oneShot: boolean + scene: Scene + camera: Camera + stack: PostStack + /** `null` draws on the canvas — into `rect` when one is given, over the whole of it when not. */ + target: WebGLRenderTarget | null + rect?: ComposerRect + /** The destination, in pixels. A chain may be built smaller — see the two quality budgets. */ + width: number + height: number + quality: ViewportQuality + /** Whether the world asks for a tone curve. Decides the precision a chain carries. */ + toneMapped: boolean + /** Seconds. What grain and tape jitter advance on — the playhead during a film. */ + time: number +} + +export type SceneComposer = { + draw: (job: ComposerJob) => void + /** Frees every chain no live stack asks for — a scene closed, a camera stopped overriding. */ + sweep: (live: readonly PostStack[]) => void + /** A closed preview or completed export must not retain its potentially large buffers. */ + releaseSurface: (surface: string) => void + dispose: () => void +} diff --git a/src/renderer/src/engines/scene/SceneRendererAids.ts b/src/renderer/src/engines/scene/SceneRendererAids.ts index ef7a4b55e..5d3ceea87 100644 --- a/src/renderer/src/engines/scene/SceneRendererAids.ts +++ b/src/renderer/src/engines/scene/SceneRendererAids.ts @@ -32,6 +32,18 @@ function shadowChangeBetween( } } +/** + * Whether the cascades have to be built again. Only what `CSM` reads at CONSTRUCTION: a rebuild + * takes its three lights out of the scene and recompiles every material they dressed. + */ +function cascadesMoved( + held: ViewportOptions, + next: ViewportOptions, + shadowsResized: boolean, +): boolean { + return next.csm !== held.csm || next.shadows !== held.shadows || shadowsResized +} + export abstract class SceneRendererAids extends SceneRendererValidation { protected abstract applySnap(): void protected abstract applyGizmoSize(): void @@ -81,6 +93,9 @@ export abstract class SceneRendererAids extends SceneRendererValidation { this.driveRenderer(next) // Every light, not only the ones built after the change: a map is allocated per light, and // the grid is the floor under the reach a directional one is given. + // Rebuilt BEFORE the tuning: `tuneShadows` ends by aiming the cascades, and aiming ones + // about to be dropped fits frustums nothing will draw with. + if (cascadesMoved(held, next, shadowsResized)) this.syncCascades() if (shadowsResized || gridMoved) this.tuneShadows() if (gridMoved && this.viewport.canvas) this.applyPalette() if (aidsMoved(held, next)) this.refreshAids() diff --git a/src/renderer/src/engines/scene/SceneRendererConstruction.ts b/src/renderer/src/engines/scene/SceneRendererConstruction.ts index c92d9780e..47c9486c2 100644 --- a/src/renderer/src/engines/scene/SceneRendererConstruction.ts +++ b/src/renderer/src/engines/scene/SceneRendererConstruction.ts @@ -53,6 +53,7 @@ export class SceneRendererConstruction extends SceneRendererFrame { (assetId, error) => reportFailure('scene.texture', assetId, error), options.assetVersion, options.livePreview, + () => this.viewport.anisotropy, ) this.buildModelSources() this.buildShapeWorkers() diff --git a/src/renderer/src/engines/scene/SceneRendererDisplay.ts b/src/renderer/src/engines/scene/SceneRendererDisplay.ts index 6cd9ab670..d43c56557 100644 --- a/src/renderer/src/engines/scene/SceneRendererDisplay.ts +++ b/src/renderer/src/engines/scene/SceneRendererDisplay.ts @@ -1,4 +1,5 @@ import { type AnimationClip, type Object3D } from 'three' +import type { RenderEngine } from '@shared/domain/renderEngine' import { ViewHelper } from 'three/addons/helpers/ViewHelper.js' import { type DrawRequest, @@ -142,6 +143,9 @@ export abstract class SceneRendererDisplay extends SceneRendererExport { // Before the dressing, and both answers kept: a cell that just came into the zone is a body // the shadow maps were drawn without. const zoned = this.instances.follow?.(camera, this.shadowThrow) ?? false + // The bands are cut out of THIS camera's frustum, so they are refitted per pane like the + // zone above — and, like it, their answer says whether the shadow maps are owed a pass. + const cascaded = this.cascades?.follow(camera) ?? false this.zonedTo = camera const mode = this.displays[index] ?? this.displays[0] ?? 'shaded' @@ -158,6 +162,12 @@ export abstract class SceneRendererDisplay extends SceneRendererExport { camera, studio => this.environment?.borrowStudio(studio), ) + this.syncFirstPersonBody() + return dressed || zoned || cascaded + } + + /** The body a played camera looks out of, or none — a pane drawn with the chrome shows all. */ + private syncFirstPersonBody(): void { const body = this.options.chrome === false && this.world.play.camera === 'firstPerson' ? playerPartsOf(this.documentOrder)?.body @@ -165,7 +175,19 @@ export abstract class SceneRendererDisplay extends SceneRendererExport { this.firstPersonBody.sync(body ? this.objects.get(body.id) : undefined, signature => this.retarget.profileOf(signature), ) - return dressed || zoned + } + + /** + * Which engine actually mounted — `gl` where `gpu` was asked for and could not run. Read by + * the benchmark harness, which must not report a fallback as an Advanced measurement. + */ + get renderEngine(): RenderEngine { + return this.viewport.driver.engine + } + + /** Resolves once this scene may be drawn — a node backend comes up a beat after the mount. */ + async settled(): Promise { + await this.viewport.settled() } /** @@ -210,6 +232,8 @@ export abstract class SceneRendererDisplay extends SceneRendererExport { // A render is never drawn at the cheap end: what is written out is what the quality // setting means at its top, whatever the viewport is set to. quality: request.surface === 'offscreen' ? 'high' : this.view.quality, + // Every off-screen pass builds its chain, draws one picture and frees it. + oneShot: request.surface === 'offscreen', toneMapped: this.world.toneMapping !== 'none', // The PLAYHEAD, not a wall clock: a film written twice has the same grain twice, and a // frame still shows grain because the head moves between them. diff --git a/src/renderer/src/engines/scene/SceneRendererFilm.ts b/src/renderer/src/engines/scene/SceneRendererFilm.ts index 677356e99..57855aa81 100644 --- a/src/renderer/src/engines/scene/SceneRendererFilm.ts +++ b/src/renderer/src/engines/scene/SceneRendererFilm.ts @@ -8,7 +8,6 @@ import { evenSize, frameTimes, type FilmRequest } from './film' import { encodeFilmFrameOffThread } from './filmEncodePort' import './bvhPatches' import { SceneRendererPreview } from './SceneRendererPreview' -import { readRenderPixels } from './readRenderPixels' export abstract class SceneRendererFilm extends SceneRendererPreview { protected abstract applyVisibility(): void /** @@ -118,6 +117,9 @@ export abstract class SceneRendererFilm extends SceneRendererPreview { width: number, height: number, ): boolean { + // The bands are cut out of the camera that draws: an off-screen pass never goes through + // `dressPane`, and one left fitted to the editor's own view lights this frame from it. + this.cascades?.follow(camera) return this.viewport.drawScene({ scene: this.viewport.scene, camera, @@ -170,7 +172,7 @@ export abstract class SceneRendererFilm extends SceneRendererPreview { loan.frame(camera) this.setPlayhead(time) const composed = this.drawFilmFrame(camera, cameraAt(time), target, width, height) - const pixels = readRenderPixels(gl, target, width, height) + const pixels = await this.viewport.driver.readPixels(gl, target, width, height) index += 1 await onFrame(index, await encodeFilmFrameOffThread(pixels, width, height, composed)) } diff --git a/src/renderer/src/engines/scene/SceneRendererFlight.ts b/src/renderer/src/engines/scene/SceneRendererFlight.ts index 0a2a46674..82976f41d 100644 --- a/src/renderer/src/engines/scene/SceneRendererFlight.ts +++ b/src/renderer/src/engines/scene/SceneRendererFlight.ts @@ -1,5 +1,6 @@ import { localizedError } from '@shared/localizedError' -import { PerspectiveCamera, WebGLRenderTarget, type WebGLRenderer } from 'three' +import { PerspectiveCamera, WebGLRenderTarget } from 'three' +import type { StudioRenderer } from '../render/renderDriver' import type { MotionId } from '@shared/domain/shortcut' import { anglesFromDirection } from '@shared/domain/angles' import { aimAlong, turnBy } from '../viewport/lookAround' @@ -12,7 +13,6 @@ import { captureSize, type CaptureQuality } from '@shared/domain/sceneCapture' import './bvhPatches' import { flightGaze } from './sceneRendererSupport2' import { SceneRendererFilm } from './SceneRendererFilm' -import { readRenderPixels } from './readRenderPixels' export abstract class SceneRendererFlight extends SceneRendererFilm { protected abstract syncPaneFreeze(): void public abstract get flying(): boolean @@ -25,7 +25,7 @@ export abstract class SceneRendererFlight extends SceneRendererFilm { * drawn at the buffer's own: « view size » on a 2× display gave back half the definition. */ private captureShape( - gl: WebGLRenderer, + gl: StudioRenderer, quality: CaptureQuality, ): { width: number; height: number } { const canvas = gl.domElement @@ -56,7 +56,7 @@ export abstract class SceneRendererFlight extends SceneRendererFilm { // Antialiased, unlike a film's frames: a still is looked at, and the resolve happens at the // end of `render` — so the read below already has the resolved texture. Capped at four, // which is where the eye stops paying for the memory a 4K target multiplies. - const samples = Math.min(4, gl.capabilities.maxSamples) + const samples = Math.min(4, this.viewport.driver.maxSamples(gl)) const target = new WebGLRenderTarget(width, height, { samples }) const restore = this.hideWorkshop() const loan = aspectLoan(width, height) @@ -64,6 +64,9 @@ export abstract class SceneRendererFlight extends SceneRendererFilm { // Only a perspective one is lent an aspect, and only for the rounding: the size asked for // keeps the view's own shape, so an orthographic frustum is already framed for it. if (camera instanceof PerspectiveCamera) loan.frame(camera) + // The bands are cut out of the camera that draws: an off-screen pass never goes through + // `dressPane`, and one left fitted to the editor's own view lights this frame from it. + this.cascades?.follow(camera) const composed = this.viewport.drawScene({ scene: this.viewport.scene, camera, @@ -77,7 +80,7 @@ export abstract class SceneRendererFlight extends SceneRendererFilm { width, height, }) - const pixels = readRenderPixels(gl, target, width, height) + const pixels = await this.viewport.driver.readPixels(gl, target, width, height) return await encodeFilmFrameOffThread(pixels, width, height, composed) } finally { gl.setRenderTarget(null) diff --git a/src/renderer/src/engines/scene/SceneRendererLifecycle.ts b/src/renderer/src/engines/scene/SceneRendererLifecycle.ts index a76fd9d9c..c051648d0 100644 --- a/src/renderer/src/engines/scene/SceneRendererLifecycle.ts +++ b/src/renderer/src/engines/scene/SceneRendererLifecycle.ts @@ -3,10 +3,8 @@ import { onPaletteChange } from '../core/palette' import { type SceneWorld } from '@shared/domain/scene' import { springArmRigsOf } from './springArmRigs' import type { Vector3 as TurnedVector } from '@shared/domain/transform' -import { createEnvironment } from '../viewport/environment' import type { ViewportCamera } from '../viewport/viewportEngineSupport1' import { type SceneNode, type SceneState } from './sceneState' -import { PostComposer } from '../postfx/PostComposer' import { loadLutTexture } from '../postfx/lutSource' import './bvhPatches' import { STUDIO_INTENSITY } from './sceneRendererSupport1' @@ -41,6 +39,10 @@ export abstract class SceneRendererLifecycle extends SceneRendererResources { protected abstract readonly onPointerUp: (event: PointerEvent) => void protected abstract readonly onPointerCancel: (event: PointerEvent) => void public abstract dispose(): void + + protected abstract syncCascades(): void + + protected abstract dressCascades(changed: readonly SceneNode[] | null): void protected abstract sweepCompositions(state: SceneState): void protected abstract syncNode(node: SceneNode): void protected abstract release(id: string): void @@ -103,26 +105,51 @@ export abstract class SceneRendererLifecycle extends SceneRendererResources { /** Nothing at all without a renderer: a viewport may be mounted before WebGL answers. */ private mountRenderer(): void { - // Lit before anything is added: a scene with no light of its own still shows its materials, - // exactly as the texture viewport does. `apply` replaces this the moment a document says so. const renderer = this.viewport.gl if (!renderer) return - this.post = new PostComposer(renderer, { + this.post = this.viewport.driver.createComposer(renderer, { loadLut: assetId => loadLutTexture(assetId, this.textureCache.versionOf(assetId)), lutStamp: assetId => this.textureCache.versionOf(assetId), // A grade that finished loading changes the picture, and nothing else would ask for the // frame that shows it: the loop is asleep by then. onReady: () => this.redraw(), }) - this.environment = createEnvironment(renderer, this.viewport.scene, () => this.redraw()) - this.environment.setStudio() + this.environment = this.viewport.driver.createEnvironment(renderer, this.viewport.scene, () => + this.redraw(), + ) + // Straight through where the engine can already draw, which is every WebGL mount: the + // deferral below is one microtask, and one microtask is enough for `apply` to arrive first + // and light the document twice. + if (this.viewport.canDraw) this.lightMountedScene() + else void this.lightWhenSettled() + } + + /** + * Prefiltering a map DRAWS, so none of this may run before the backend is up — a node renderer + * refuses `fromScene` until then, and refusing is the kind thing: it would otherwise be a map + * built out of nothing. + */ + private async lightWhenSettled(): Promise { + await this.viewport.settled() + // `canDraw` and not only the environment: a backend that REFUSED settles too, and + // prefiltering on it would throw inside a call nobody awaited. + if (this.environment && this.viewport.canDraw) this.lightMountedScene() + } + + private lightMountedScene(): void { + // Lit before anything is added: a scene with no light of its own still shows its materials, + // exactly as the texture viewport does. `apply` replaces this the moment a document says so. + this.environment?.setStudio() // Half strength, unlike the texture preview: image-based light comes from everywhere and // is occluded by nothing, so at full intensity it fills the very shadows the lights cast. - this.environment.setIntensity(STUDIO_INTENSITY) + this.environment?.setIntensity(STUDIO_INTENSITY) // A document applied before the viewport had a renderer lit none of this: it opened on the // procedural studio whatever sky it names. `SkyboxRenderer.mount` replays its own the same way. this.lit = null this.applyEnvironment(this.world) + // After the environment and never before: cascades dress the materials of the scene, and + // one built before there is a renderer would have nothing to draw its bands with. + this.syncCascades() } private hookInput(canvas: HTMLCanvasElement): void { @@ -208,6 +235,9 @@ export abstract class SceneRendererLifecycle extends SceneRendererResources { // Before the counters and after every placement: the instance matrices are copied from the // world matrices, which nothing past here moves. this.regroupInstances() + // A mesh that just arrived receives cascades through a define on its material, so it has to + // be dressed before it draws. Nothing at all while the option is off. + this.dressCascades(changed) this.playheadMovesShadows = this.canPlayheadMoveShadows(state.nodes) this.reportStats() if (allShadowsChanged) this.redraw() diff --git a/src/renderer/src/engines/scene/SceneRendererMaterials.ts b/src/renderer/src/engines/scene/SceneRendererMaterials.ts index 82fb6b09b..45342e9b5 100644 --- a/src/renderer/src/engines/scene/SceneRendererMaterials.ts +++ b/src/renderer/src/engines/scene/SceneRendererMaterials.ts @@ -96,6 +96,9 @@ export abstract class SceneRendererMaterials extends SceneRendererFlight { this.firstPersonBody.dispose() for (const id of [...this.objects.keys()]) this.release(id) this.sky.release() + // Before the materials go: `release` writes the defines it added back off each of them. + this.cascades?.release() + this.cascades = null this.environment?.dispose() this.environment = null this.animations.clear() diff --git a/src/renderer/src/engines/scene/SceneRendererPreview.ts b/src/renderer/src/engines/scene/SceneRendererPreview.ts index 49295fe81..c805c3c73 100644 --- a/src/renderer/src/engines/scene/SceneRendererPreview.ts +++ b/src/renderer/src/engines/scene/SceneRendererPreview.ts @@ -167,7 +167,8 @@ export abstract class SceneRendererPreview extends SceneRendererSkinning { drawFrom(cameraNodeId: string | null, time: Us): HTMLCanvasElement | null { const gl = this.viewport.gl const canvas = this.viewport.canvas - if (!gl || !canvas) return null + // `drawScene` guards itself; this one reaches the renderer directly, so it asks too. + if (!gl || !canvas || !this.viewport.canDraw) return null const camera = this.cameraObject(cameraNodeId) ?? this.viewport.perspective diff --git a/src/renderer/src/engines/scene/SceneRendererResources.ts b/src/renderer/src/engines/scene/SceneRendererResources.ts index e3f44ea7c..63f2e04fd 100644 --- a/src/renderer/src/engines/scene/SceneRendererResources.ts +++ b/src/renderer/src/engines/scene/SceneRendererResources.ts @@ -32,6 +32,7 @@ import './bvhPatches' import { type CsgEvaluator } from '../csg/csgEvaluator' import { createGeometryCache, type GeometryCache } from './geometryCache' import { type InstancedGroups, type ShadowThrow } from './grouping' +import { type CascadeShadows } from './csm' import { type TransformMode, type TransformSpace } from './gizmoTarget' import { NOTHING_SNAPPED, type Snapping } from '@shared/domain/snap' import type { Marquee } from './sceneRendererSupport1' @@ -117,6 +118,9 @@ export abstract class SceneRendererResources extends SceneRendererState { */ protected shadowThrow: ShadowThrow | null = null + /** Cascades, built only while `view.csm` says so — see `syncCascades`. */ + protected cascades: CascadeShadows | null = null + /** * The camera the zone was last narrowed to. A preview narrows it to ITS own on every frame it * is shown, and a zone left there makes the next pane widen it again — which reads as « cells diff --git a/src/renderer/src/engines/scene/SceneRendererShadows.ts b/src/renderer/src/engines/scene/SceneRendererShadows.ts index a10cb9e52..b2c2ead40 100644 --- a/src/renderer/src/engines/scene/SceneRendererShadows.ts +++ b/src/renderer/src/engines/scene/SceneRendererShadows.ts @@ -17,8 +17,10 @@ import { dressWithRail, type RailColours, helperFor } from './threeFactory' import { aimLightMarker, holdMarkerSize } from './markerPose' import { applyMaterial, applyNegative, applySprite, lightFor, standTarget } from './threeSync' import { createMaterialTextures, createSpriteTexture } from './materialTextures' -import { reportFailure } from '@/services/diagnostics' +import { localizedError } from '@shared/localizedError' +import { reportFailure, traceFailure } from '@/services/diagnostics' import { limitShadowUpdates, throwsOf, tuneShadowMaps } from './shadows' +import { cascadeSettingsFor, cascadesWanted, createCascadeShadows } from './csm' import { applyWireOverlay } from './sceneView' import './bvhPatches' import { isNegative } from '../csg/carve' @@ -106,6 +108,57 @@ export abstract class SceneRendererShadows extends SceneRendererModels { () => ({ bounds: boundsOnce(), floor: this.view.gridSize }), ) this.shadowThrow = tuned && throwsOf(tuned.framed, boundsOnce(), tuned.reach) + this.cascades?.aim(this.shadowThrow) + } + + /** + * Builds the cascades or drops them — the one door `mount` and `configure` both come through. + * Rebuilt from scratch rather than adjusted, `CSM` reading its cascade count and its map size + * once at construction; the caller is what keeps that to the passes where one of them moved. + */ + protected syncCascades(): void { + const engine = this.viewport.driver.engine + const wanted = this.viewport.gl !== null && cascadesWanted(this.view, engine) + // Said and not swallowed: « Cascaded shadows » stays an offered preference, and on a document + // this engine cannot cascade it now changes nothing at all. Traced rather than reported — the + // picture is whole, as with the fallback itself. + if (this.view.csm && this.view.shadows && !wanted && this.viewport.gl !== null) { + traceFailure('render.fallback', 'cascades', localizedError('renderEngineCascadesMissing')) + } + this.cascades?.release() + this.cascades = wanted + ? createCascadeShadows(this.viewport.scene, cascadeSettingsFor(this.view), () => + this.redraw(), + ) + : null + // Whether they arrived or left, the picture moved: three lights come and go with them, and + // no other signal of `configure` covers the cascade flag on its own. + this.redraw() + if (!this.cascades) return + this.cascades.aim(this.shadowThrow) + this.cascades.dress(this.viewport.scene) + } + + /** + * Dresses what ARRIVED, never the whole graph: `applyState` runs per play frame with a small + * delta, and a full `traverse` there is the very cost `heldShadowBounds` documents removing — + * 23.8 ms of 38.7 on 50 000 nodes. The whole scene is walked once, by `syncCascades`. + */ + protected dressCascades(changed: readonly SceneNode[] | null): void { + const cascades = this.cascades + if (!cascades) return + if (!changed) { + cascades.dress(this.viewport.scene) + return + } + for (const node of changed) { + const object = this.objects.get(node.id) + if (object) cascades.dress(object) + } + // The batches too: `regroupInstances` runs just before this and rebuilds them, and they are + // deliberately out of `objects` — undressed, they read the three bands as three ordinary + // suns and draw the shadow three times. + for (const drawn of this.instances.drawn()) cascades.dress(drawn) } /** diff --git a/src/renderer/src/engines/scene/SceneRendererState.ts b/src/renderer/src/engines/scene/SceneRendererState.ts index 8e893a4f7..a9078067a 100644 --- a/src/renderer/src/engines/scene/SceneRendererState.ts +++ b/src/renderer/src/engines/scene/SceneRendererState.ts @@ -8,6 +8,7 @@ import { Vector3, type Vector3 as ThreeVector3, } from 'three' +import type { SceneComposer } from '../render/sceneComposer' import { type ViewHelper } from 'three/addons/helpers/ViewHelper.js' import type { MotionId } from '@shared/domain/shortcut' import { SCHEME_OF, type NavigationScheme } from '@shared/domain/navigationPreset' @@ -38,7 +39,6 @@ import { createSkySun, type SkySun } from './skySun' import { type GltfSource } from './gltfSource' import { SceneAnimations } from './animation' import { postAt } from './animationEval' -import { type PostComposer } from '../postfx/PostComposer' import { EMPTY_TIMELINE, type AnimationTimeline } from '@shared/domain/animation' import { type ModelCache } from './modelCache' import { ownedByAnotherNode } from './shadows' @@ -92,6 +92,10 @@ export abstract class SceneRendererState { protected options!: SceneRendererOptions protected viewport = new ViewportEngine({ + // The DOCUMENT's engine, read at mount and never again: the scene lives inside the + // renderer's context, so the choice is settled for as long as this panel is open. A surface + // that draws no scene document falls back on the preference. See `SceneWorld.engine`. + engine: () => this.options.engine ?? this.view.engine, onFrame: delta => this.advance(delta), onOverlay: renderer => this.viewHelper?.render(renderer), onPane: (index, camera) => this.dressPane(index, camera), @@ -252,7 +256,7 @@ export abstract class SceneRendererState { protected playheadMovesShadows = false /** Built at mount, when there is a renderer to build passes with. */ - protected post: PostComposer | null = null + protected post: SceneComposer | null = null /** * The temporary comparison — hold to see the frame without its composition. diff --git a/src/renderer/src/engines/scene/SceneRendererValidation.ts b/src/renderer/src/engines/scene/SceneRendererValidation.ts index 8df1e77e2..1b9734d5c 100644 --- a/src/renderer/src/engines/scene/SceneRendererValidation.ts +++ b/src/renderer/src/engines/scene/SceneRendererValidation.ts @@ -18,7 +18,6 @@ import { } from './sceneRuntimeSnapshot' import { nodeIdOf, withEveryLayer } from './sceneRendererSupport2' import { SceneRendererOptimization } from './SceneRendererOptimization' -import { readRenderPixels } from './readRenderPixels' const VALIDATION_PICK_SAMPLES = 32 @@ -33,6 +32,9 @@ export abstract class SceneRendererValidation extends SceneRendererOptimization const target = new WebGLRenderTarget(spec.width, spec.height) const restore = this.hideWorkshop(camera) try { + // The bands are cut out of the camera that draws: an off-screen pass never goes through + // `dressPane`, and one left fitted to the editor's own view lights this frame from it. + this.cascades?.follow(camera) this.viewport.drawScene({ scene: this.viewport.scene, camera, @@ -44,7 +46,7 @@ export abstract class SceneRendererValidation extends SceneRendererOptimization width: spec.width, height: spec.height, }) - const pixels = readRenderPixels(gl, target, spec.width, spec.height) + const pixels = await this.viewport.driver.readPixels(gl, target, spec.width, spec.height) this.observeRuntimeValidationPicks(spec.id, camera) return { width: spec.width, height: spec.height, pixels } } finally { diff --git a/src/renderer/src/engines/scene/csm.test.ts b/src/renderer/src/engines/scene/csm.test.ts new file mode 100644 index 000000000..5ccc0d6a2 --- /dev/null +++ b/src/renderer/src/engines/scene/csm.test.ts @@ -0,0 +1,240 @@ +import { + DirectionalLight, + Mesh, + MeshStandardMaterial, + PerspectiveCamera, + Scene, + type WebGLProgramParametersWithUniforms, + type WebGLRenderer, +} from 'three' +import { describe, expect, it, vi } from 'vitest' +import { DEFAULT_RENDER_POLICY } from '@shared/domain/renderPolicy' +import { cascadeSettingsFor, cascadesWanted, createCascadeShadows } from './csm' + +const settings = cascadeSettingsFor(DEFAULT_RENDER_POLICY) + +function litScene(): { scene: Scene; sun: DirectionalLight; mesh: Mesh } { + const scene = new Scene() + const sun = new DirectionalLight() + sun.castShadow = true + const mesh = new Mesh(undefined, new MeshStandardMaterial()) + scene.add(sun, mesh) + return { scene, sun, mesh } +} + +describe('what a policy buys in cascades', () => { + it('caps the maps by the quality level, exactly as a single shadow map is capped', () => { + const asked = { ...DEFAULT_RENDER_POLICY, shadowMapSize: 4096 } + + expect(cascadeSettingsFor({ ...asked, quality: 'high' }).mapSize).toBe(4096) + expect(cascadeSettingsFor({ ...asked, quality: 'performance' }).mapSize).toBeLessThan(4096) + }) + + it('never reaches past what the camera draws', () => { + expect(cascadeSettingsFor(DEFAULT_RENDER_POLICY, 300).maxFar).toBe(300) + }) +}) + +describe('which scenes are given cascades at all', () => { + it('gives them to a scene that asks for them and draws shadows', () => { + const asked = { ...DEFAULT_RENDER_POLICY, csm: true, shadows: true } + + expect(cascadesWanted(asked, 'gl')).toBe(true) + expect(cascadesWanted({ ...asked, shadows: false }, 'gl')).toBe(false) + expect(cascadesWanted({ ...asked, csm: false }, 'gl')).toBe(false) + }) + + // `CSM` patches materials through `onBeforeCompile`, which only `WebGLRenderer` calls. Built on + // the Advanced engine it would reach no program while still taking the sun off lighting. + it('refuses them to the Advanced engine, which calls no compile hook', () => { + expect(cascadesWanted({ ...DEFAULT_RENDER_POLICY, csm: true, shadows: true }, 'gpu')).toBe( + false, + ) + }) +}) + +describe('cascaded shadows on a scene', () => { + it('lights the scene with one shadow-casting light per band', () => { + const { scene, sun } = litScene() + createCascadeShadows(scene, settings, () => {}) + + expect(cascadeLightsOf(scene, sun).filter(light => light.castShadow)).toHaveLength( + settings.cascades, + ) + }) + + it('takes the scene sun off casting, so nothing is darkened twice', () => { + const { scene, sun } = litScene() + const shadows = createCascadeShadows(scene, settings, () => {}) + + shadows.dress(scene) + + expect(sun.castShadow).toBe(false) + }) + + it('stands in for the sun rather than lighting beside it', () => { + // Three lights of their own at three's default intensity would add nine units of white on + // top of a scene lit by one — the picture jumps the moment the option is switched on. + const { scene, sun } = litScene() + sun.intensity = 2 + sun.color.set('#ff8800') + const shadows = createCascadeShadows(scene, settings, () => {}) + + shadows.dress(scene) + + expect(sun.intensity).toBe(0) + for (const band of cascadeLightsOf(scene, sun)) { + expect(band.intensity).toBe(2) + expect(band.color.getHexString()).toBe('ff8800') + } + }) + + /** + * Deleting the sun and adding another is one gesture in the tree, and the bands must follow it: + * left standing for a light no document holds, they keep its reading while the new sun keeps + * its own, and the scene is lit twice. + */ + it('stands for the sun that REPLACED the one it stood for', () => { + const { scene, sun } = litScene() + sun.intensity = 2 + const shadows = createCascadeShadows(scene, settings, () => {}) + shadows.dress(scene) + + scene.remove(sun) + const replacement = new DirectionalLight('#00ff00', 3) + replacement.castShadow = true + scene.add(replacement) + shadows.dress(scene) + + expect(replacement.intensity).toBe(0) + expect(sun.intensity).toBe(2) + expect(sun.castShadow).toBe(true) + for (const band of cascadeLightsOf(scene, replacement)) { + expect(band.intensity).toBe(3) + expect(band.color.getHexString()).toBe('00ff00') + } + }) + + it('gives the sun back its light and its map when the cascades go', () => { + const { scene, sun } = litScene() + sun.intensity = 2 + const shadows = createCascadeShadows(scene, settings, () => {}) + shadows.dress(scene) + + shadows.release() + + expect(sun.intensity).toBe(2) + expect(sun.castShadow).toBe(true) + expect(scene.children.filter(child => child instanceof DirectionalLight)).toEqual([sun]) + }) + + it('marks a dressed material for a rebuild: a define alone reaches no program', () => { + const { scene, mesh } = litScene() + const material = oneMaterialOf(mesh) + material.needsUpdate = false + const shadows = createCascadeShadows(scene, settings, () => {}) + + shadows.dress(scene) + + expect(material.defines?.USE_CSM).toBe(1) + expect(material.version).toBeGreaterThan(0) + }) + + it('composes with a material that carries a patch of its own', () => { + // The relief splat is the one that does: it rewrites `map_fragment` where cascades read + // `lights_fragment_begin`, so terrain must receive both rather than lose either. + const { scene, mesh } = litScene() + const material = oneMaterialOf(mesh) + const own = vi.fn() + material.onBeforeCompile = own + const shadows = createCascadeShadows(scene, settings, () => {}) + + shadows.dress(scene) + material.onBeforeCompile(shaderStub(), rendererStub()) + + expect(own).toHaveBeenCalledOnce() + expect(material.defines?.USE_CSM).toBe(1) + }) + + it('hands that patch back when the cascades go', () => { + const { scene, mesh } = litScene() + const material = oneMaterialOf(mesh) + const own = () => {} + material.onBeforeCompile = own + const shadows = createCascadeShadows(scene, settings, () => {}) + shadows.dress(scene) + + shadows.release() + + expect(material.onBeforeCompile).toBe(own) + }) + + it('dresses a material again once something has rebound its patch', () => { + const { scene, mesh } = litScene() + const material = oneMaterialOf(mesh) + const shadows = createCascadeShadows(scene, settings, () => {}) + shadows.dress(scene) + // What `bindReliefSplat` does when the ground is painted again: it writes over the hook. + const rebound = vi.fn() + material.onBeforeCompile = rebound + + shadows.dress(scene) + material.onBeforeCompile(shaderStub(), rendererStub()) + + expect(rebound).toHaveBeenCalledOnce() + }) + + it('cuts each band a frustum of its own out of the camera it follows', () => { + const { scene, sun } = litScene() + const shadows = createCascadeShadows(scene, settings, () => {}) + const camera = new PerspectiveCamera() + camera.position.set(0, 4, 12) + + shadows.follow(camera) + + // Growing, near band to far one: a set close to the eye is framed tightly and gets the + // texels a single map stretched over the whole view could never give it. + const widths = cascadeLightsOf(scene, sun).map(light => light.shadow.camera.right) + expect(widths).toHaveLength(settings.cascades) + expect([...widths].sort((one, other) => one - other)).toEqual(widths) + }) + + it('turns the cascades to where the scene says the light comes from', () => { + const { scene, sun } = litScene() + const shadows = createCascadeShadows(scene, settings, () => {}) + + shadows.aim({ along: [{ x: 0, y: -1, z: 0 }], floor: 0, reach: 50 }) + shadows.follow(new PerspectiveCamera()) + + const cascade = cascadeLightsOf(scene, sun)[0] + const aimed = cascade?.target.position.clone().sub(cascade.position) + expect(aimed?.normalize().y).toBeCloseTo(-1) + }) +}) + +function oneMaterialOf(mesh: Mesh): MeshStandardMaterial { + const material = mesh.material + if (Array.isArray(material) || !(material instanceof MeshStandardMaterial)) { + throw new Error('this mesh was built with one standard material') + } + return material +} + +/** + * The two arguments three hands a compile hook. `as` twice: what the hooks under test do with + * them is call each other, and neither a program nor a renderer can be built under node. + */ +function shaderStub(): WebGLProgramParametersWithUniforms { + return { uniforms: {} } as WebGLProgramParametersWithUniforms +} + +function rendererStub(): WebGLRenderer { + return {} as WebGLRenderer +} + +/** The lights the cascades brought — everything directional in the scene but the document's sun. */ +function cascadeLightsOf(scene: Scene, sun: DirectionalLight): readonly DirectionalLight[] { + return scene.children.filter( + (child): child is DirectionalLight => child instanceof DirectionalLight && child !== sun, + ) +} diff --git a/src/renderer/src/engines/scene/csm.ts b/src/renderer/src/engines/scene/csm.ts new file mode 100644 index 000000000..555b4c853 --- /dev/null +++ b/src/renderer/src/engines/scene/csm.ts @@ -0,0 +1,330 @@ +/** + * Cascaded shadow maps: the sun's shadow split into one map per depth band of the view, instead + * of one map stretched over everything the camera sees. + * + * OFF unless `RenderPolicy.csm` says otherwise, and that is not a taste. `CSM` adds three + * directional lights of its own, rewrites a three.js shader chunk for the whole process, and + * needs a define on every material that receives it — none of which a scene that was authored + * without cascades may inherit silently. + */ +import { + DirectionalLight, + Matrix4, + PerspectiveCamera, + Vector3, + type Camera, + type Material, + type Object3D, +} from 'three' +import { CSM } from 'three/addons/csm/CSM.js' +import type { RenderEngine } from '@shared/domain/renderEngine' +import type { RenderPolicy } from '@shared/domain/renderPolicy' +import { VIEW_DISTANCE } from '@shared/domain/renderPolicy' +import { shadowMapSizeFor } from './viewportQuality' +import { materialsOf } from './shadows' +import type { ShadowThrow } from './grouping' + +/** What a policy buys: how many bands, how big each map, and how far the last one reaches. */ +export type CascadeSettings = { cascades: number; mapSize: number; maxFar: number } + +/** + * Three bands and no setting for it: two leave a visible seam in the middle distance, four cost + * a depth pass each for a band most sets never fill. The number is what the material's + * `CSM_CASCADES` define holds, so moving it recompiles every dressed material. + */ +const CASCADES = 3 + +/** + * Whether this scene is to be given cascades at all: what the policy asks for, AND what the + * engine drawing it can put on screen. + * + * 🛑 The Compatible engine alone. `CSM` works through `onBeforeCompile`, a hook only + * `WebGLRenderer` calls — three 0.185 names it nowhere under `renderers/common` or + * `renderers/webgpu`. Built on the Advanced engine the patch would reach no program, while + * `dress` would still take the document's sun off lighting and put three bands at its intensity + * in its place: the scene lit three times and its shadow gone, which is the very fault the GL + * path was fixed for on 2026-09-08. Refused rather than drawn wrong. **Not measured** — what an + * Advanced scene under cascades looks like was never rendered, here or in the parity harness. + */ +export function cascadesWanted( + policy: Pick, + engine: RenderEngine, +): boolean { + return policy.csm && policy.shadows && engine === 'gl' +} + +/** + * The maps a policy asks for, through the very cap a single shadow map goes through — a quality + * level that halves one light's map has to halve all three, or the setting means nothing here. + */ +export function cascadeSettingsFor( + policy: Pick, + reach = VIEW_DISTANCE, +): CascadeSettings { + return { + cascades: CASCADES, + mapSize: shadowMapSizeFor(policy.quality, policy.shadowMapSize), + // Never past what the camera draws: a band beyond the far plane is a depth pass for pixels + // that are clipped before they are lit. + maxFar: reach, + } +} + +export type CascadeShadows = { + /** + * Where the sun comes from, off the same reading the ordinary shadow pass fits its frustums + * with. Nothing at all while the scene has no directional light: with no direction to give, + * the cascades would light it from three's own default and contradict the lamps on screen. + */ + aim: (throwing: ShadowThrow | null) => void + /** + * The pane about to be drawn, and whether the bands moved — which is what tells the frame its + * shadow maps are worth drawing again, exactly as a display mode does. Cascades follow the + * EYE, so an orbit alone moves them, and the frame gates the whole shadow pass on this answer. + */ + follow: (camera: Camera) => boolean + /** + * Walks the scene once: dresses every material that can receive cascades, and takes the + * document's own directional lights off casting. Both are needed and both are idempotent — + * a sun still drawing its single map would darken, a second time, everything the bands + * already darkened. + */ + dress: (root: Object3D) => void + /** + * Puts the SCENE back: lights out, defines off, patches handed back, materials rebuilt. Not + * the process — `CSM` rewrites `ShaderChunk.lights_fragment_begin` for good, and nothing in + * the addon restores it. Guarded by `USE_CSM`, so a material nobody dressed is unchanged. + */ + release: () => void +} + +export function createCascadeShadows( + parent: Object3D, + settings: CascadeSettings, + /** Asked for a frame when the cascades moved something the picture shows. */ + requestRender: () => void, +): CascadeShadows { + const csm = new CSM({ + // Replaced at the first `follow`, which is the pane's own camera. A placeholder rather than + // nothing: the constructor fits its frustums against whatever it is given. + camera: PLACEHOLDER, + parent, + cascades: settings.cascades, + maxFar: settings.maxFar, + shadowMapSize: settings.mapSize, + }) + // A cascade light is drawn on the pass the engine asks for, like every other one — the scene + // viewport keeps `shadowMap.autoUpdate` off, and a map left on three's own cadence would be + // the only thing in the frame redrawn sixty times a second. + for (const light of csm.lights) light.shadow.autoUpdate = false + + /** + * Per dressed material, the hook it carried before and the composed one installed over it. + * Read as the DRESSED test: a material whose hook is no longer ours has been rebound since — + * `bindReliefSplat` does exactly that when the ground is painted again — so it is dressed again. + */ + const patched = new WeakMap() + /** + * The sun the bands STAND IN FOR, and what it was lighting with before they did. + * + * Cascades are three directional lights of their own: left beside a sun that goes on lighting, + * the scene gains their intensity on top of its own — and `CSM`'s default is 3 a piece. So the + * first sun hands over its colour and its strength and stops lighting; a second one is left + * exactly as the document wrote it. + */ + let stood: StoodFor | null = null + const own = new Set(csm.lights) + /** Where `update` last put each cascade light — see `follow`, which redraws on the move. */ + const placed = csm.lights.map(light => light.position.clone()) + /** The projection the bands were cut out of, which is all `updateFrustums` reads of a camera. */ + const fitted = new Matrix4() + + return { + aim: throwing => { + const along = throwing?.along[0] + if (!along) return + + // Compared AFTER normalising and on all three axes: `lightDirection` is kept normalised, + // so measuring the raw reading against it refits on every pass — and a sun that only + // rises, moving in `y` alone, refitted on none. + AIMED.set(along.x, along.y, along.z).normalize() + if (csm.lightDirection.equals(AIMED)) return + + csm.lightDirection.copy(AIMED) + csm.updateFrustums() + requestRender() + }, + + follow: camera => { + csm.camera = camera + // The PROJECTION and not the camera's identity: a zoom or a resize moves the frustum + // under the same object, and bands left cut for the previous lens shadow the wrong depths. + // + // 🛑 A quad layout pays this on EVERY pane: its three extra views own their own cameras + // (`ExtraPane`), so the projection differs from the one fitted a moment ago and the bands + // are cut again — `updateFrustums` walks every dressed material, and the three maps are + // then owed a pass. That is the price of ONE `CSM` shared by four panes, not waste: the + // bands have to belong to the camera being drawn. **Not measured.** + const refitted = !fitted.equals(camera.projectionMatrix) + if (refitted) { + csm.updateFrustums() + fitted.copy(camera.projectionMatrix) + } + csm.update() + if (!refitted && !lightsMoved(csm.lights, placed)) return false + + // Their own `autoUpdate` is off like every other light's, so a moved band has to ask. + for (const light of csm.lights) light.shadow.needsUpdate = true + return true + }, + + dress: root => { + root.traverse(child => { + if (child instanceof DirectionalLight && !own.has(child)) { + stood = standFor(csm.lights, child, stood) + } + for (const material of materialsOf(child)) { + if (!receivesCascades(material)) continue + if (patched.get(material)?.composed === material.onBeforeCompile) continue + dressOne(csm, patched, material) + } + }) + }, + + release: () => { + // Read BEFORE `dispose`, which deletes the hook off every material it dressed: after it, + // nothing on the material says any more whether the patch there was ours to take back. + const restoring: { material: Material; own: MaterialHook }[] = [] + parent.traverse(child => { + for (const material of materialsOf(child)) { + const held = patched.get(material) + // Only what is still OURS: `clearReliefSplat` puts its own hook back when the terrain + // goes, and writing over that would reinstall a patch whose uniforms are gone. + if (held?.own && material.onBeforeCompile === held.composed) { + restoring.push({ material, own: held.own }) + } + patched.delete(material) + } + }) + // `dispose` deletes the hook and the defines off every material it dressed and marks them + // for a rebuild; `remove` takes the three lights and their targets out of the scene. + csm.dispose() + csm.remove() + for (const { material, own } of restoring) material.onBeforeCompile = own + if (stood) { + stood.light.castShadow = stood.castShadow + stood.light.intensity = stood.intensity + stood = null + } + fitted.identity() + requestRender() + }, + } +} + +type MaterialHook = Material['onBeforeCompile'] + +/** What a band replaced, so the sun can be given its light and its map back. */ +type StoodFor = { light: DirectionalLight; castShadow: boolean; intensity: number } + +/** + * The bands take a sun's place. Idempotent, and it has to be: `dress` runs on every pass, and a + * light the document has just rewritten carries an intensity again — which is the reading to + * keep, not the zero this left behind. A SECOND sun is left exactly as the document wrote it. + */ +function standFor( + bands: readonly DirectionalLight[], + light: DirectionalLight, + stood: StoodFor | null, +): StoodFor | null { + const previous = releasedIfGone(stood, light) + if (previous && previous.light !== light) return previous + const held = previous ?? { light, castShadow: light.castShadow, intensity: light.intensity } + if (previous && light.intensity !== 0) held.intensity = light.intensity + + for (const band of bands) { + band.color.copy(light.color) + // Not divided: the patched chunk lights a fragment from ONE band, the one its depth falls + // in — see `CSMShader.lights_fragment_begin`, which masks `RE_Direct` per cascade. + band.intensity = held.intensity + } + light.castShadow = false + light.intensity = 0 + return held +} + +/** + * The sun the bands stand for, or `null` where it has LEFT the document — handed back what it + * had on the way out, so a light reused elsewhere is not stuck at zero. + * + * 🛑 Without it, deleting the sun and adding another lights the scene TWICE: the bands keep the + * dead light's colour and intensity while the new sun keeps its own, and `release` then restores + * a light no document holds. A second sun standing beside the first is a different case and is + * left exactly as the document wrote it. + */ +function releasedIfGone(stood: StoodFor | null, light: DirectionalLight): StoodFor | null { + if (!stood || stood.light === light || stood.light.parent !== null) return stood + stood.light.castShadow = stood.castShadow + stood.light.intensity = stood.intensity + return null +} + +/** Whether `update` moved a band since the last frame — the reading `placed` is refreshed from. */ +function lightsMoved(bands: readonly DirectionalLight[], placed: readonly Vector3[]): boolean { + let moved = false + for (const [at, band] of bands.entries()) { + const held = placed[at] + if (!held || held.equals(band.position)) continue + held.copy(band.position) + moved = true + } + return moved +} + +/** + * Cascades on top of whatever the material already did. `setupMaterial` OVERWRITES the hook, so + * the relief splat — the one material of a scene with a patch of its own — would lose its + * program. The two compose: the splat rewrites `map_fragment` and `normal_fragment_maps`, + * cascades add uniforms and read `lights_fragment_begin`. + */ +function dressOne( + csm: CSM, + patched: WeakMap, + material: Material, +): void { + const before = ownHookOf(material) + csm.setupMaterial(material) + const cascade = material.onBeforeCompile + const composed: MaterialHook = before + ? (shader, renderer) => { + cascade.call(material, shader, renderer) + before.call(material, shader, renderer) + } + : cascade + material.onBeforeCompile = composed + patched.set(material, { own: before, composed }) + // The addon leaves this out, and a define written onto a material already compiled reaches no + // program: a scene switched to cascades mid-session went on drawing the sun's single map until + // something else invalidated it. + material.needsUpdate = true +} + +/** A lit material only: a helper's line or a sprite receives no shadow to cascade. */ +function receivesCascades(material: Material): boolean { + return 'isMeshStandardMaterial' in material +} + +/** + * The hook a material carries of its OWN, or nothing. `hasOwn` rather than a comparison: three + * declares one on `Material.prototype`, so a material has its own exactly when somebody assigned + * it — and calling the prototype's empty body on every compile would be work for nothing. + */ +function ownHookOf(material: Material): MaterialHook | null { + return Object.hasOwn(material, 'onBeforeCompile') ? material.onBeforeCompile : null +} + +/** Fitted against nothing until the first pane says which camera it draws with. */ +const PLACEHOLDER = new PerspectiveCamera() + +/** Scratch: `aim` runs on every tuning pass, and allocates nothing on the way. */ +const AIMED = new Vector3() diff --git a/src/renderer/src/engines/scene/defaultScene.test.ts b/src/renderer/src/engines/scene/defaultScene.test.ts index f1727e6fd..10bcc35cf 100644 --- a/src/renderer/src/engines/scene/defaultScene.test.ts +++ b/src/renderer/src/engines/scene/defaultScene.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { DEFAULT_WORLD } from '@shared/domain/scene' import { createDefaultScene } from './defaultScene' describe('createDefaultScene', () => { @@ -39,6 +40,13 @@ describe('createDefaultScene', () => { expect(sun?.transform.position).toEqual({ x: 5, y: 10, z: 7.5 }) }) + // A document READ off a file falls back to `DEFAULT_WORLD`, which stays on `none` so an older + // project opens exactly as it was authored. Only a scene MADE from here gets the newer curve. + it('opens a new scene on a tone curve, where a read one keeps none', () => { + expect(createDefaultScene().world.toneMapping).toBe('agx') + expect(DEFAULT_WORLD.toneMapping).toBe('none') + }) + it('shows every light', () => { expect(createDefaultScene().nodes.every(node => node.visible)).toBe(true) }) diff --git a/src/renderer/src/engines/scene/defaultScene.ts b/src/renderer/src/engines/scene/defaultScene.ts index acce52621..f43b9d90c 100644 --- a/src/renderer/src/engines/scene/defaultScene.ts +++ b/src/renderer/src/engines/scene/defaultScene.ts @@ -1,9 +1,27 @@ import { EMPTY_TIMELINE } from '@shared/domain/animation' import { LIGHT_TYPES } from './lightTypes' import { lightNode } from './nodeFactory' -import { DEFAULT_WORLD, type LightDescriptor, type Vector3 } from '@shared/domain/scene' +import { + DEFAULT_WORLD, + type LightDescriptor, + type SceneWorld, + type Vector3, +} from '@shared/domain/scene' import type { SceneState } from './sceneState' +/** + * What a scene MADE from now on opens on, against `DEFAULT_WORLD`, which is what a scene READ + * off a file falls back to. The two are deliberately apart: a document written before this + * keeps the picture it was authored under, and only a new one gets the newer curve. + * + * AgX and not ACES: three 0.185 ships both, and AgX is the one that holds a saturated colour + * together as it brightens where ACES turns it toward white. Verified in `three/src/constants.js` + * on 2026-09-10 — `AgXToneMapping` is 6 and `worldBinding` maps it. + */ +// Frozen like the object it copies: one instance is handed to every scene this module makes, +// and a write into one of them would reach the next. +export const NEW_SCENE_WORLD: SceneWorld = Object.freeze({ ...DEFAULT_WORLD, toneMapping: 'agx' }) + /** Which lights a new scene opens with, and where. A kind absent here is simply not one of them. */ const DEFAULT_LIGHT_POSITIONS: ReadonlyMap = new Map([ ['ambient', { x: 0, y: 0, z: 0 }], @@ -19,7 +37,7 @@ export function createDefaultScene(): SceneState { return position ? [lightNode(type.create(), position)] : [] }), selectedIds: [], - world: DEFAULT_WORLD, + world: NEW_SCENE_WORLD, animation: EMPTY_TIMELINE, } } diff --git a/src/renderer/src/engines/scene/gltfSource.ts b/src/renderer/src/engines/scene/gltfSource.ts index fae88bde0..827634d7e 100644 --- a/src/renderer/src/engines/scene/gltfSource.ts +++ b/src/renderer/src/engines/scene/gltfSource.ts @@ -1,13 +1,7 @@ import { localizedError } from '@shared/localizedError' import { Group, Mesh, MeshStandardMaterial, type Material, type Object3D } from 'three' -import type { - AnimationClip, - BufferGeometry, - LoadingManager, - Skeleton, - Texture, - WebGLRenderer, -} from 'three' +import type { StudioRenderer } from '../render/renderDriver' +import type { AnimationClip, BufferGeometry, LoadingManager, Skeleton, Texture } from 'three' import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js' import { GLTFLoader, type GLTF } from 'three/addons/loaders/GLTFLoader.js' import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js' @@ -63,7 +57,7 @@ export type GltfSource = { * this source is built in the engine's constructor. */ export function createGltfSource( - rendererOf: () => WebGLRenderer | null, + rendererOf: () => StudioRenderer | null, onFailure: (scope: string, error: unknown) => void = () => undefined, decoderRoot = DECODER_ROOT, ): GltfSource { diff --git a/src/renderer/src/engines/scene/sceneRendererSupport1.ts b/src/renderer/src/engines/scene/sceneRendererSupport1.ts index d6341d3e9..479702f97 100644 --- a/src/renderer/src/engines/scene/sceneRendererSupport1.ts +++ b/src/renderer/src/engines/scene/sceneRendererSupport1.ts @@ -12,6 +12,7 @@ import { import { type Settings } from '@shared/domain/settings' import type { SelectionMode } from '@/helpers/selection' import { type NodeMove } from './sceneState' +import type { RenderEngine } from '@shared/domain/renderEngine' import type { Vector3 as PlainVector3 } from '@shared/domain/scene' import type { EnvironmentDress } from '@shared/domain/skybox' import type { FontLibrary } from '../core/fonts' @@ -50,6 +51,15 @@ export type PartitionMode = 'off' | 'grid' export type SceneRendererOptions = { optimization?: 'auto' | 'off' + /** + * Which engine this renderer is built on, taken from the DOCUMENT it draws — see + * `SceneWorld.engine`. Absent for a surface that draws no scene document of its own (the + * character workshop, a retarget preview), which falls back on the preference. + * + * An option and not a reading of `view`: it is wanted at `mount`, before `configure` has run + * and before any state has been applied. + */ + engine?: RenderEngine /** * What the click asked for, in the shape `Tree` reports it — a click in the void is an empty * list. The mode says what the modifier keys meant; a viewport draws no rows, so never a range. diff --git a/src/renderer/src/engines/scene/sceneTemplates.ts b/src/renderer/src/engines/scene/sceneTemplates.ts index 043ac12dd..8c95ea223 100644 --- a/src/renderer/src/engines/scene/sceneTemplates.ts +++ b/src/renderer/src/engines/scene/sceneTemplates.ts @@ -25,7 +25,7 @@ import { isSceneTemplateId, type SceneTemplateId, } from '@shared/domain/sceneTemplate' -import { createDefaultScene } from './defaultScene' +import { createDefaultScene, NEW_SCENE_WORLD } from './defaultScene' import { airfieldNodes } from './airfieldLevel' import { carNodes } from './carNodes' import { CIRCUIT_START, CIRCUIT_START_YAW, circuitNodes } from './circuitLevel' @@ -470,7 +470,9 @@ export function sceneFromTemplate( nodes: graph ? withAnimatorGraph(template.nodes, graph) : [...template.nodes], selectedIds: [], world: { - ...DEFAULT_WORLD, + // The NEW scene's world, never `DEFAULT_WORLD`: this is a creation flow. A template that + // names a curve of its own — every environment preset does — still wins over it. + ...NEW_SCENE_WORLD, ...template.world, play: { ...DEFAULT_WORLD.play, ...template.play }, }, diff --git a/src/renderer/src/engines/scene/sceneWorld.test.ts b/src/renderer/src/engines/scene/sceneWorld.test.ts index 65d36b95b..29b348a57 100644 --- a/src/renderer/src/engines/scene/sceneWorld.test.ts +++ b/src/renderer/src/engines/scene/sceneWorld.test.ts @@ -23,6 +23,22 @@ describe('reading a world back', () => { expect(readWorld(undefined, undefined)).toEqual(DEFAULT_WORLD) }) + /** + * The engine belongs to the DOCUMENT from the day it is created — a file read on a machine + * whose preference says otherwise still draws the way its author drew it. + */ + it('opens a document written before the engine was a document member on the Compatible one', () => { + expect(readWorld({ toneMapping: 'agx' }, undefined).engine).toBe('gl') + }) + + it('keeps the engine a document was saved under', () => { + expect(readWorld({ engine: 'gpu' }, undefined).engine).toBe('gpu') + }) + + it('falls back rather than trusting an engine name this build has never heard of', () => { + expect(readWorld({ engine: 'vulkan' }, undefined).engine).toBe('gl') + }) + it('keeps the sky of a document that spelled it at the root', () => { // Every scene saved so far: `environment` beside `nodes`, with no `world` at all. const held = readWorld(undefined, { kind: 'skybox', assetId: 'sky-1' }) @@ -93,7 +109,9 @@ describe('reading a world back', () => { it('reads a tone mapping this build knows, and falls back on one it does not', () => { expect(readWorld({ toneMapping: 'reinhard' }, undefined).toneMapping).toBe('reinhard') - expect(readWorld({ toneMapping: 'agx' }, undefined).toneMapping).toBe('none') + // `agx` stood here until three 0.185 was mapped: a word the build LEARNS stops being a + // fallback case, and the guard has to keep naming one the build really does not know. + expect(readWorld({ toneMapping: 'filmic' }, undefined).toneMapping).toBe('none') }) it('opens a document written before layers existed on none', () => { diff --git a/src/renderer/src/engines/scene/sceneWorld.ts b/src/renderer/src/engines/scene/sceneWorld.ts index a5f099ad1..1d1fb2ad6 100644 --- a/src/renderer/src/engines/scene/sceneWorld.ts +++ b/src/renderer/src/engines/scene/sceneWorld.ts @@ -63,6 +63,7 @@ import { UNLOCKED_TERRAIN, } from '@shared/domain/scene' import { readStack } from '@shared/domain/postProcessing' +import { RENDER_ENGINES } from '@shared/domain/renderEngine' import { readReliefGrain, readReliefMask, readReliefSculpt } from '@shared/domain/relief' import { isRecord, @@ -88,6 +89,9 @@ export function readWorld(value: unknown, legacyEnvironment: unknown): SceneWorl const held = isRecord(value) ? value : {} return { + // The engine the document was MADE under, and never the setting: a file opened on a machine + // whose preference says otherwise still draws the way its author drew it. + engine: oneOf(RENDER_ENGINES, held.engine, DEFAULT_WORLD.engine), // The nested one wins when it is there; a file that only has the old root key keeps its sky. environment: readEnvironment('environment' in held ? held.environment : legacyEnvironment), envIntensity: readBounded(held, 'envIntensity', DEFAULT_WORLD.envIntensity, ENV_INTENSITY), diff --git a/src/renderer/src/engines/scene/shadowLevels.ts b/src/renderer/src/engines/scene/shadowLevels.ts index ffb9cbb97..06b7f67f5 100644 --- a/src/renderer/src/engines/scene/shadowLevels.ts +++ b/src/renderer/src/engines/scene/shadowLevels.ts @@ -14,8 +14,12 @@ export type ShadowLevel = 'off' | 'fast' | 'standard' | 'high' export const SHADOW_LEVELS: readonly ShadowLevel[] = ['off', 'fast', 'standard', 'high'] -/** The four an export carries too — the lens apart, this IS a render policy. */ -export type ShadowPreference = Omit +/** + * The four an export carries too — the lens apart, this IS a render policy. `csm` and `engine` + * are left out with them: cascades answer how far the set REACHES rather than how fine its maps + * are, and which API draws is not a shadow preference at all. + */ +export type ShadowPreference = Omit /** What a level writes. The quality is the person's own and is deliberately left where it is. */ type LevelPatch = Omit diff --git a/src/renderer/src/engines/scene/shadows.ts b/src/renderer/src/engines/scene/shadows.ts index 21fb513dc..fb29128a4 100644 --- a/src/renderer/src/engines/scene/shadows.ts +++ b/src/renderer/src/engines/scene/shadows.ts @@ -1,11 +1,19 @@ import { BasicShadowMap, Box3, Light, Object3D, PCFShadowMap, Vector3 } from 'three' -import type { LightShadow, Matrix4, ShadowMapType } from 'three' +import type { LightShadow, Material, Matrix4, ShadowMapType } from 'three' import type { ShadowQuality } from '@shared/domain/scene' import type { RenderPolicy } from '@shared/domain/renderPolicy' import { isRecord } from '@shared/guards' import type { ShadowThrow } from './grouping' -/** The one place the studio's words meet three.js's map types. */ +/** + * The one place the studio's words meet three.js's map types. + * + * 🛑 `soft` is `PCFShadowMap` and NOT `PCFSoftShadowMap`, which reads backwards and is not: + * `WebGLProgram.shadowMapTypeDefines` of three 0.185 names PCF and VSM alone, so anything else + * — `PCFSoftShadowMap` included — falls to `SHADOWMAP_TYPE_BASIC`, one unfiltered compare with + * `shadow.radius` ignored. `PCFShadowMap` is what compiles the five-tap Vogel disk. Checked in + * the shipped source on 2026-09-10; `WelcomeBackdrop` carries the same note. + */ const MAP_TYPES: Record = { hard: BasicShadowMap, soft: PCFShadowMap, @@ -14,6 +22,15 @@ const MAP_TYPES: Record = { /** What of a renderer this reads — narrower than `WebGLRenderer`, which jsdom cannot build. */ type ShadowMapHolder = { shadowMap: { type: ShadowMapType } } +/** + * 🛑 `autoUpdate` is OPTIONAL, and only the Compatible engine has one. A node renderer draws the + * shadow maps its LIGHTS ask for and offers no global gate over the pass — `limitShadowUpdates` + * still narrows it light by light, which is what the editor actually relies on. + */ +type ShadowSwitchHolder = ShadowMapHolder & { + shadowMap: { enabled: boolean; autoUpdate?: boolean } +} + /** * Points the renderer at the map type a setting asks for, and nothing more: three.js watches the * type itself and recompiles what it has to. @@ -33,12 +50,26 @@ export function applyShadowQuality(renderer: ShadowMapHolder, quality: ShadowQua * editor, `draw` for a game. */ export function applyShadowPolicy( - renderer: ShadowMapHolder & { shadowMap: { enabled: boolean; autoUpdate: boolean } }, + renderer: ShadowSwitchHolder, policy: Pick, ): void { renderer.shadowMap.enabled = policy.shadows applyShadowQuality(renderer, policy.shadowQuality) - renderer.shadowMap.autoUpdate = false + if ('autoUpdate' in renderer.shadowMap) renderer.shadowMap.autoUpdate = false +} + +/** + * Whether the renderer runs a shadow pass AT ALL this frame. + * + * 🛑 The Compatible engine alone has this gate. A node renderer draws the maps its LIGHTS ask + * for and offers nothing over the pass as a whole, so on the Advanced engine this writes + * nothing and `limitShadowUpdates`, which narrows light by light, is the whole of the saving. + */ +export function oweShadowPassOnce( + renderer: { shadowMap: { enabled: boolean; needsUpdate?: boolean } }, + owed: boolean, +): void { + if ('needsUpdate' in renderer.shadowMap) renderer.shadowMap.needsUpdate = owed } type ShadowSwitch = { shadowMap: { enabled: boolean } } @@ -57,14 +88,22 @@ export function applyShadows(renderer: ShadowSwitch, enabled: boolean, root: Obj renderer.shadowMap.enabled = enabled root.traverse(child => { - const material: unknown = Reflect.get(child, 'material') - for (const one of Array.isArray(material) ? material : [material]) { - if (isMaterial(one)) one.needsUpdate = true - } + for (const material of materialsOf(child)) material.needsUpdate = true }) } -function isMaterial(value: unknown): value is { needsUpdate: boolean } { +/** + * The materials one object wears — one, several, or none. Read off the SLOT rather than by a + * class test: a mesh, a sprite, a line and an instanced batch all carry it without sharing a + * base. Shared with `csm.ts`, which marks the very same materials of the same feature. + */ +export function materialsOf(object: Object3D): readonly Material[] { + const material: unknown = Reflect.get(object, 'material') + if (Array.isArray(material)) return material.filter(isMaterial) + return isMaterial(material) ? [material] : [] +} + +function isMaterial(value: unknown): value is Material { return isRecord(value) && 'isMaterial' in value } diff --git a/src/renderer/src/engines/scene/textureCache.test.ts b/src/renderer/src/engines/scene/textureCache.test.ts index 71797e6f4..e0764bb77 100644 --- a/src/renderer/src/engines/scene/textureCache.test.ts +++ b/src/renderer/src/engines/scene/textureCache.test.ts @@ -195,6 +195,16 @@ describe('createTextureCache', () => { expect((await loading)?.colorSpace).toBe(LinearSRGBColorSpace) }) + it('reads a texture with everything the card allows across a texel', async () => { + const source = deferredSource() + const cache = createTextureCache(source.load, silent, undefined, undefined, () => 16) + + const loading = cache.acquire('tex-1', SRGBColorSpace) + source.settle('tex-1') + + expect((await loading)?.anisotropy).toBe(16) + }) + it('frees everything it still holds when the engine goes', async () => { const source = deferredSource() const cache = createTextureCache(source.load, silent) diff --git a/src/renderer/src/engines/scene/textureCache.ts b/src/renderer/src/engines/scene/textureCache.ts index 9531bd1b0..3ebea04a8 100644 --- a/src/renderer/src/engines/scene/textureCache.ts +++ b/src/renderer/src/engines/scene/textureCache.ts @@ -195,6 +195,14 @@ export function createTextureCache( * the disk, which is what a workspace with no editor — and every test — wants. */ previewOf: (assetId: string) => ImageBitmap | null = () => null, + /** + * How many samples the GPU may take across a texel's footprint — the DRIVER's answer, asked at + * each load rather than once, since a cache is built before its viewport has a renderer. + * + * Absent leaves three's own `1`, which is what a headless test wants and what the studio + * showed until now: a floor seen at a grazing angle blurred to grey a few metres out. + */ + anisotropyOf: () => number = () => 1, ): TextureCache { const cache = createRefCache({ load: async key => { @@ -220,6 +228,11 @@ export function createTextureCache( // the sky turns by a node rather than by its UVs, so neither leaves 0..1 behind. texture.wrapS = RepeatWrapping texture.wrapT = RepeatWrapping + // The GPU's own ceiling, never a number of ours: anisotropic sampling costs bandwidth on + // the taps it takes and NOT a byte of texture memory — the same mip chain is read more + // than once — so a cap below what the card offers buys nothing back. Inert on a texture + // with no mip chain, which is what `DataTexture` and every `.exr` come back as. + texture.anisotropy = anisotropyOf() return texture }, free: texture => texture.dispose(), diff --git a/src/renderer/src/engines/scene/visualRegression.ts b/src/renderer/src/engines/scene/visualRegression.ts index 64501a96a..efd3129c7 100644 --- a/src/renderer/src/engines/scene/visualRegression.ts +++ b/src/renderer/src/engines/scene/visualRegression.ts @@ -77,3 +77,22 @@ function completeFrame(frame: VisualFrame): boolean { frame.width > 0 && frame.height > 0 && frame.pixels.length === frame.width * frame.height * 4 ) } + +/** + * Whether a frame holds more than one colour. + * + * 🛑 What every visual comparison needs beside its ratio: two BLANK frames compare perfectly, so + * a side that drew nothing reads as a side that drew the same thing. Alpha is out of it — a frame + * read off an opaque target carries 255 everywhere and would never vary. + */ +export function hasPixelVariation(pixels: Uint8Array): boolean { + for (let offset = 4; offset < pixels.length; offset += 4) { + if ( + pixels[offset] !== pixels[0] || + pixels[offset + 1] !== pixels[1] || + pixels[offset + 2] !== pixels[2] + ) + return true + } + return false +} diff --git a/src/renderer/src/engines/scene/worldBinding.test.ts b/src/renderer/src/engines/scene/worldBinding.test.ts index 001ebe963..c9e94fc04 100644 --- a/src/renderer/src/engines/scene/worldBinding.test.ts +++ b/src/renderer/src/engines/scene/worldBinding.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { ACESFilmicToneMapping, Fog, FogExp2, NoToneMapping } from 'three' +import { ACESFilmicToneMapping, AgXToneMapping, Fog, FogExp2, NoToneMapping } from 'three' import { applyFog, applyToneMapping, toneMappingOf } from './worldBinding' describe('fog on a scene', () => { @@ -47,6 +47,10 @@ describe('tone mapping', () => { expect(toneMappingOf('none')).toBe(NoToneMapping) }) + it('maps the curve a new scene opens on', () => { + expect(toneMappingOf('agx')).toBe(AgXToneMapping) + }) + it('writes the mapping and the exposure together', () => { const renderer = { toneMapping: NoToneMapping, toneMappingExposure: 1 } applyToneMapping(renderer, 'aces', 1.6) diff --git a/src/renderer/src/engines/scene/worldBinding.ts b/src/renderer/src/engines/scene/worldBinding.ts index 2ba4b8b02..9c5789243 100644 --- a/src/renderer/src/engines/scene/worldBinding.ts +++ b/src/renderer/src/engines/scene/worldBinding.ts @@ -7,6 +7,7 @@ */ import { ACESFilmicToneMapping, + AgXToneMapping, CineonToneMapping, Fog, FogExp2, @@ -24,6 +25,7 @@ const TONE_MAPPINGS: Record = { reinhard: ReinhardToneMapping, cineon: CineonToneMapping, aces: ACESFilmicToneMapping, + agx: AgXToneMapping, } export function toneMappingOf(mapping: ToneMapping): ThreeToneMapping { diff --git a/src/renderer/src/engines/scene/worldSafeValidation.browser.ts b/src/renderer/src/engines/scene/worldSafeValidation.browser.ts index a0f97c8d6..870d07133 100644 --- a/src/renderer/src/engines/scene/worldSafeValidation.browser.ts +++ b/src/renderer/src/engines/scene/worldSafeValidation.browser.ts @@ -12,6 +12,7 @@ import { validateRuntimeRepresentation, type RuntimeRenderCamera, } from './runtimeRepresentationValidation' +import { hasPixelVariation } from './visualRegression' import { createSceneRuntimeValidationDriver } from './sceneRuntimeValidationDriver' import { benchmarkModel, benchmarkTexture, camerasFor } from './worldBenchmarkBrowserFixtures' @@ -169,16 +170,3 @@ function movedFromStart( } Reflect.set(window, '__iaValidateWorldBenchmarks', validateWorldBenchmarksInBrowser) - -function hasPixelVariation(pixels: Uint8Array): boolean { - for (let offset = 4; offset < pixels.length; offset += 4) { - if ( - pixels[offset] !== pixels[0] || - pixels[offset + 1] !== pixels[1] || - pixels[offset + 2] !== pixels[2] || - pixels[offset + 3] !== pixels[3] - ) - return true - } - return false -} diff --git a/src/renderer/src/engines/skybox/SkyboxRenderer.ts b/src/renderer/src/engines/skybox/SkyboxRenderer.ts index cfcefd9a2..6cdf6d8e7 100644 --- a/src/renderer/src/engines/skybox/SkyboxRenderer.ts +++ b/src/renderer/src/engines/skybox/SkyboxRenderer.ts @@ -7,7 +7,7 @@ import { createGpuPipeline, type GpuPipeline } from '../gpu/gpuPipeline' import { reportFailure } from '@/services/diagnostics' import { createTextureBinding, type TextureBinding } from '../scene/textureBinding' import { createTextureCache, type TextureCache, type TextureSource } from '../scene/textureCache' -import { createEnvironment, type ViewportEnvironment } from '../viewport/environment' +import { type ViewportEnvironment } from '../viewport/environment' import { createTestObjects, type TestObjects } from '../viewport/testObjects' import { aimAlong } from '../viewport/lookAround' import { ViewportEngine } from '../viewport/ViewportEngine' @@ -120,6 +120,7 @@ export class SkyboxRenderer { (assetId, error) => reportFailure('skybox.source', assetId, error), options.assetVersion, options.livePreview, + () => this.viewport.anisotropy, ) // The reference, the race and the version are all the binding's: written here too, the sky // would be the third copy of a rule the studio already keeps in one place. @@ -160,7 +161,11 @@ export class SkyboxRenderer { if (!renderer || !canvas) return this.pipeline = createGpuPipeline(renderer) - this.environment = createEnvironment(renderer, this.viewport.scene, this.viewport.requestRender) + this.environment = this.viewport.driver.createEnvironment( + renderer, + this.viewport.scene, + this.viewport.requestRender, + ) this.pointer.mount() diff --git a/src/renderer/src/engines/skybox/SkyboxRenderer01.test.ts b/src/renderer/src/engines/skybox/SkyboxRenderer01.test.ts index 313832e1e..013a68c83 100644 --- a/src/renderer/src/engines/skybox/SkyboxRenderer01.test.ts +++ b/src/renderer/src/engines/skybox/SkyboxRenderer01.test.ts @@ -7,10 +7,10 @@ import { createSkyboxContent, type SkyboxContent } from '@shared/domain/skybox' import type * as AdjustModule from '../gpu/passes/adjust' import type { AdjustPass } from '../gpu/passes/adjust' import type { GpuPipeline } from '../gpu/gpuPipeline' -import type * as EnvironmentModule from '../viewport/environment' +import type * as GlDriverModule from '../render/glDriver' import type * as TestObjectsModule from '../viewport/testObjects' import type { TestObjects } from '../viewport/testObjects' -import { fakeEnvironment, fakeTextureSource } from '../viewport/viewport-fixtures' +import { fakeEnvironment, fakeRenderer, fakeTextureSource } from '../viewport/viewport-fixtures' import { ViewportEngine } from '../viewport/ViewportEngine' import { SkyboxRenderer } from './SkyboxRenderer' @@ -51,11 +51,12 @@ vi.mock('../viewport/testObjects', async importOriginal => { } }) -vi.mock('../viewport/environment', async importOriginal => ({ - // Partial: the quiet this engine debounces on is the module's, and a total mock hides it. - ...(await importOriginal()), - createEnvironment: () => environment, -})) +// Mocked at the DRIVER, which is what makes an environment now: mocking the module below it +// would still let the driver build a `PMREMGenerator` on a renderer jsdom cannot give. +vi.mock('../render/glDriver', async importOriginal => { + const actual = await importOriginal() + return { glDriver: { ...actual.glDriver, createEnvironment: () => environment } } +}) vi.mock('../gpu/gpuPipeline', () => ({ createGpuPipeline: () => pipeline })) vi.mock('../gpu/passes/adjust', async importOriginal => { const actual = await importOriginal() @@ -104,7 +105,7 @@ describe('the renderer of a skybox', () => { vi.spyOn(ViewportEngine.prototype, 'mount').mockImplementation(() => {}) // `as`: neither the pipeline nor the environment is real here, and nothing else reads it. - vi.spyOn(ViewportEngine.prototype, 'gl', 'get').mockReturnValue({} as never) + vi.spyOn(ViewportEngine.prototype, 'gl', 'get').mockReturnValue(fakeRenderer()) vi.spyOn(ViewportEngine.prototype, 'canvas', 'get').mockReturnValue(canvas) vi.spyOn(ViewportEngine.prototype, 'pointerNdcOf').mockImplementation(function ( this: ViewportEngine, diff --git a/src/renderer/src/engines/skybox/SkyboxRenderer02.test.ts b/src/renderer/src/engines/skybox/SkyboxRenderer02.test.ts index ffb2f8652..ad2697491 100644 --- a/src/renderer/src/engines/skybox/SkyboxRenderer02.test.ts +++ b/src/renderer/src/engines/skybox/SkyboxRenderer02.test.ts @@ -7,10 +7,10 @@ import { createSkyboxContent, type SkyboxContent } from '@shared/domain/skybox' import type * as AdjustModule from '../gpu/passes/adjust' import type { AdjustPass } from '../gpu/passes/adjust' import type { GpuPipeline } from '../gpu/gpuPipeline' -import type * as EnvironmentModule from '../viewport/environment' +import type * as GlDriverModule from '../render/glDriver' import type * as TestObjectsModule from '../viewport/testObjects' import type { TestObjects } from '../viewport/testObjects' -import { fakeEnvironment, fakeTextureSource } from '../viewport/viewport-fixtures' +import { fakeEnvironment, fakeRenderer, fakeTextureSource } from '../viewport/viewport-fixtures' import { ViewportEngine } from '../viewport/ViewportEngine' import { SkyboxRenderer } from './SkyboxRenderer' @@ -51,11 +51,12 @@ vi.mock('../viewport/testObjects', async importOriginal => { } }) -vi.mock('../viewport/environment', async importOriginal => ({ - // Partial: the quiet this engine debounces on is the module's, and a total mock hides it. - ...(await importOriginal()), - createEnvironment: () => environment, -})) +// Mocked at the DRIVER, which is what makes an environment now: mocking the module below it +// would still let the driver build a `PMREMGenerator` on a renderer jsdom cannot give. +vi.mock('../render/glDriver', async importOriginal => { + const actual = await importOriginal() + return { glDriver: { ...actual.glDriver, createEnvironment: () => environment } } +}) vi.mock('../gpu/gpuPipeline', () => ({ createGpuPipeline: () => pipeline })) vi.mock('../gpu/passes/adjust', async importOriginal => { const actual = await importOriginal() @@ -121,7 +122,7 @@ describe('the renderer of a skybox', () => { vi.spyOn(ViewportEngine.prototype, 'mount').mockImplementation(() => {}) // `as`: neither the pipeline nor the environment is real here, and nothing else reads it. - vi.spyOn(ViewportEngine.prototype, 'gl', 'get').mockReturnValue({} as never) + vi.spyOn(ViewportEngine.prototype, 'gl', 'get').mockReturnValue(fakeRenderer()) vi.spyOn(ViewportEngine.prototype, 'canvas', 'get').mockReturnValue(canvas) vi.spyOn(ViewportEngine.prototype, 'pointerNdcOf').mockImplementation(function ( this: ViewportEngine, diff --git a/src/renderer/src/engines/skybox/SkyboxRenderer03.test.ts b/src/renderer/src/engines/skybox/SkyboxRenderer03.test.ts index bca442630..ba57b9d45 100644 --- a/src/renderer/src/engines/skybox/SkyboxRenderer03.test.ts +++ b/src/renderer/src/engines/skybox/SkyboxRenderer03.test.ts @@ -7,10 +7,10 @@ import { createSkyboxContent, type SkyboxContent } from '@shared/domain/skybox' import type * as AdjustModule from '../gpu/passes/adjust' import type { AdjustPass } from '../gpu/passes/adjust' import type { GpuPipeline } from '../gpu/gpuPipeline' -import type * as EnvironmentModule from '../viewport/environment' +import type * as GlDriverModule from '../render/glDriver' import type * as TestObjectsModule from '../viewport/testObjects' import type { TestObjects } from '../viewport/testObjects' -import { fakeEnvironment, fakeTextureSource } from '../viewport/viewport-fixtures' +import { fakeEnvironment, fakeRenderer, fakeTextureSource } from '../viewport/viewport-fixtures' import { ViewportEngine } from '../viewport/ViewportEngine' import { SkyboxRenderer } from './SkyboxRenderer' @@ -51,11 +51,12 @@ vi.mock('../viewport/testObjects', async importOriginal => { } }) -vi.mock('../viewport/environment', async importOriginal => ({ - // Partial: the quiet this engine debounces on is the module's, and a total mock hides it. - ...(await importOriginal()), - createEnvironment: () => environment, -})) +// Mocked at the DRIVER, which is what makes an environment now: mocking the module below it +// would still let the driver build a `PMREMGenerator` on a renderer jsdom cannot give. +vi.mock('../render/glDriver', async importOriginal => { + const actual = await importOriginal() + return { glDriver: { ...actual.glDriver, createEnvironment: () => environment } } +}) vi.mock('../gpu/gpuPipeline', () => ({ createGpuPipeline: () => pipeline })) vi.mock('../gpu/passes/adjust', async importOriginal => { const actual = await importOriginal() @@ -121,7 +122,7 @@ describe('the renderer of a skybox', () => { vi.spyOn(ViewportEngine.prototype, 'mount').mockImplementation(() => {}) disposeViewport = vi.spyOn(ViewportEngine.prototype, 'dispose') // `as`: neither the pipeline nor the environment is real here, and nothing else reads it. - vi.spyOn(ViewportEngine.prototype, 'gl', 'get').mockReturnValue({} as never) + vi.spyOn(ViewportEngine.prototype, 'gl', 'get').mockReturnValue(fakeRenderer()) vi.spyOn(ViewportEngine.prototype, 'canvas', 'get').mockReturnValue(canvas) vi.spyOn(ViewportEngine.prototype, 'pointerNdcOf').mockImplementation(function ( this: ViewportEngine, diff --git a/src/renderer/src/engines/skybox/SkyboxRenderer04.test.ts b/src/renderer/src/engines/skybox/SkyboxRenderer04.test.ts index 0d91c1559..38d835c3d 100644 --- a/src/renderer/src/engines/skybox/SkyboxRenderer04.test.ts +++ b/src/renderer/src/engines/skybox/SkyboxRenderer04.test.ts @@ -6,10 +6,10 @@ import { createSkyboxContent, type SkyboxContent } from '@shared/domain/skybox' import type * as AdjustModule from '../gpu/passes/adjust' import type { AdjustPass } from '../gpu/passes/adjust' import type { GpuPipeline } from '../gpu/gpuPipeline' -import type * as EnvironmentModule from '../viewport/environment' +import type * as GlDriverModule from '../render/glDriver' import type * as TestObjectsModule from '../viewport/testObjects' import type { TestObjects } from '../viewport/testObjects' -import { fakeEnvironment, fakeTextureSource } from '../viewport/viewport-fixtures' +import { fakeEnvironment, fakeRenderer, fakeTextureSource } from '../viewport/viewport-fixtures' import { ViewportEngine } from '../viewport/ViewportEngine' import { SkyboxRenderer } from './SkyboxRenderer' @@ -50,11 +50,12 @@ vi.mock('../viewport/testObjects', async importOriginal => { } }) -vi.mock('../viewport/environment', async importOriginal => ({ - // Partial: the quiet this engine debounces on is the module's, and a total mock hides it. - ...(await importOriginal()), - createEnvironment: () => environment, -})) +// Mocked at the DRIVER, which is what makes an environment now: mocking the module below it +// would still let the driver build a `PMREMGenerator` on a renderer jsdom cannot give. +vi.mock('../render/glDriver', async importOriginal => { + const actual = await importOriginal() + return { glDriver: { ...actual.glDriver, createEnvironment: () => environment } } +}) vi.mock('../gpu/gpuPipeline', () => ({ createGpuPipeline: () => pipeline })) vi.mock('../gpu/passes/adjust', async importOriginal => { const actual = await importOriginal() @@ -95,7 +96,7 @@ describe('the test objects of a skybox', () => { vi.spyOn(ViewportEngine.prototype, 'mount').mockImplementation(function (this: ViewportEngine) { painted = vi.spyOn(this, 'requestRender') }) - vi.spyOn(ViewportEngine.prototype, 'gl', 'get').mockReturnValue({} as never) + vi.spyOn(ViewportEngine.prototype, 'gl', 'get').mockReturnValue(fakeRenderer()) vi.spyOn(ViewportEngine.prototype, 'canvas', 'get').mockReturnValue( document.createElement('canvas'), ) @@ -197,7 +198,7 @@ describe('the views of a skybox', () => { vi.clearAllMocks() vi.useFakeTimers() vi.spyOn(ViewportEngine.prototype, 'mount').mockImplementation(() => {}) - vi.spyOn(ViewportEngine.prototype, 'gl', 'get').mockReturnValue({} as never) + vi.spyOn(ViewportEngine.prototype, 'gl', 'get').mockReturnValue(fakeRenderer()) vi.spyOn(ViewportEngine.prototype, 'canvas', 'get').mockReturnValue( document.createElement('canvas'), ) diff --git a/src/renderer/src/engines/viewport/ViewportDrawing.ts b/src/renderer/src/engines/viewport/ViewportDrawing.ts index 3ef16c354..fd64e639b 100644 --- a/src/renderer/src/engines/viewport/ViewportDrawing.ts +++ b/src/renderer/src/engines/viewport/ViewportDrawing.ts @@ -1,4 +1,6 @@ -import { type WebGLRenderer, type WebGLRenderTarget } from 'three' +import { type WebGLRenderTarget } from 'three' +import { oweShadowPassOnce } from '../scene/shadows' +import type { StudioRenderer } from '../render/renderDriver' import { glRect } from './panes' import { INSET_CADENCE_MS } from './viewportEngineSupport1' import type { DrawRequest, InsetPane } from './viewportEngineSupport1' @@ -8,13 +10,13 @@ export abstract class ViewportDrawing extends ViewportRenderLoop { protected abstract readonly renderFrame: () => void protected abstract insetTargetOf( - renderer: WebGLRenderer, + renderer: StudioRenderer, width: number, height: number, ): WebGLRenderTarget protected abstract drawInset( - renderer: WebGLRenderer, + renderer: StudioRenderer, inset: InsetPane, target: WebGLRenderTarget, panesDrawn: boolean, @@ -22,7 +24,7 @@ export abstract class ViewportDrawing extends ViewportRenderLoop { protected abstract catchUpInset(now: number): void - protected abstract compositeInset(renderer: WebGLRenderer, inset: InsetPane): void + protected abstract compositeInset(renderer: StudioRenderer, inset: InsetPane): void /** Whether the surface was actually taken: a panel folded to nothing is turned back. */ protected readonly onResize = (): boolean => { @@ -68,7 +70,7 @@ export abstract class ViewportDrawing extends ViewportRenderLoop { */ drawScene(request: DrawRequest): boolean { const renderer = this.renderer - if (!renderer) return false + if (!renderer || !this.rendererReady) return false // BEFORE `onDraw`, and it is the whole contract: a film and a still hand over a target and // then read its pixels back, so whoever draws must be pointed at it. Bound here rather than @@ -88,7 +90,7 @@ export abstract class ViewportDrawing extends ViewportRenderLoop { * the same scene, and a consumer GPU drops the oldest context when it runs out. The scissor is * what keeps a pane from clearing the three beside it. */ - protected renderPanes(renderer: WebGLRenderer, refreshAllShadows: () => void): void { + protected renderPanes(renderer: StudioRenderer, refreshAllShadows: () => void): void { const ratio = renderer.getPixelRatio() if (this.extras.length === 0) { @@ -113,7 +115,7 @@ export abstract class ViewportDrawing extends ViewportRenderLoop { // it: what THIS pane wears is what its maps have to be drawn from. if (this.options.onPane?.(index, camera) === true) { refreshAllShadows() - renderer.shadowMap.needsUpdate = true + oweShadowPassOnce(renderer, true) } this.drawScene({ scene: this.scene, @@ -136,13 +138,13 @@ export abstract class ViewportDrawing extends ViewportRenderLoop { } private renderSinglePane( - renderer: WebGLRenderer, + renderer: StudioRenderer, ratio: number, refreshAllShadows: () => void, ): void { if (this.options.onPane?.(0, this.camera) === true) { refreshAllShadows() - renderer.shadowMap.needsUpdate = true + oweShadowPassOnce(renderer, true) } this.drawScene({ scene: this.scene, @@ -169,7 +171,7 @@ export abstract class ViewportDrawing extends ViewportRenderLoop { * drawn instead of dividing the surface, and a context per preview is what `scene-stage` pays * elsewhere and says why. */ - protected renderInset(renderer: WebGLRenderer, panesDrawn: boolean): void { + protected renderInset(renderer: StudioRenderer, panesDrawn: boolean): void { const inset = this.inset if (!inset) return diff --git a/src/renderer/src/engines/viewport/ViewportEngine01Split06.test.ts b/src/renderer/src/engines/viewport/ViewportEngine01Split06.test.ts index dd7946a94..f895342f5 100644 --- a/src/renderer/src/engines/viewport/ViewportEngine01Split06.test.ts +++ b/src/renderer/src/engines/viewport/ViewportEngine01Split06.test.ts @@ -179,7 +179,11 @@ describe('a viewport', () => { it('leaves them armed for whatever renders between two frames', () => { const engine = shadowed() const renderer = engine.gl - if (!renderer) throw new Error('the viewport mounts a renderer') + // The gate belongs to the Compatible engine, which is what this suite mounts: a node + // renderer has none, and `oweShadowPassOnce` writes nothing there. + if (!renderer || !('needsUpdate' in renderer.shadowMap)) { + throw new Error('the viewport mounts a WebGL renderer') + } engine.requestCameraRender() drawFrames() diff --git a/src/renderer/src/engines/viewport/ViewportFrame.ts b/src/renderer/src/engines/viewport/ViewportFrame.ts index 9df384a0e..09f0ce97a 100644 --- a/src/renderer/src/engines/viewport/ViewportFrame.ts +++ b/src/renderer/src/engines/viewport/ViewportFrame.ts @@ -1,4 +1,6 @@ -import { MeshBasicMaterial, NoToneMapping, type WebGLRenderer } from 'three' +import { MeshBasicMaterial, NoToneMapping } from 'three' +import { oweShadowPassOnce } from '../scene/shadows' +import type { StudioRenderer } from '../render/renderDriver' import { createGpuPipeline } from '../gpu/gpuPipeline' import { frameDelta } from './frameClock' import { recordFrame } from './gpuStats' @@ -13,7 +15,7 @@ export class ViewportFrame extends ViewportInset { * `GpuPipeline` is the studio's own full-frame quad — the same one every image filter draws * through — rather than a second scene and camera written here. */ - protected insetBlitOf(renderer: WebGLRenderer): InsetBlit { + protected insetBlitOf(renderer: StudioRenderer): InsetBlit { if (this.insetBlit) return this.insetBlit this.insetBlit = { @@ -55,7 +57,7 @@ export class ViewportFrame extends ViewportInset { * the NEXT one to close, timing two frames as if they were one. Hence the `finally`. */ private drawTimedFrame( - renderer: WebGLRenderer, + renderer: StudioRenderer, panesDrawn: boolean, refreshAllShadows: () => void, ): boolean { @@ -78,6 +80,22 @@ export class ViewportFrame extends ViewportInset { return timesGpu } + /** + * Opens the frame's shadow pass and hands back the call that closes it — narrowed to the + * lights that moved, and restored for whatever renders off screen afterwards. + */ + private armShadowPass(renderer: StudioRenderer): () => void { + const stale = this.shadowsStale + oweShadowPassOnce(renderer, stale) + this.shadowsStale = false + let restore = stale ? this.options.onShadowFrame?.(this.allShadowsStale) : undefined + this.allShadowsStale = false + return () => { + restore?.() + restore = undefined + } + } + /** * On demand, not on a permanent loop: a studio whose viewport burns a frame at rest heats the * machine for nothing. The loop keeps going only while something is actually moving. @@ -85,7 +103,10 @@ export class ViewportFrame extends ViewportInset { protected readonly renderFrame = (): void => { this.frame = null const renderer = this.renderer - if (!renderer) return + // Not until the backend answers: a node renderer throws on `render()` before it does, and + // the frames it would have drawn are dropped rather than queued — `settleRenderer` asks + // for a fresh one once it can draw. + if (!renderer || !this.rendererReady) return // The engine clears, not three.js — see `autoReset` at mount. renderer.info.reset() @@ -100,23 +121,13 @@ export class ViewportFrame extends ViewportInset { const moving = this.options.onFrame?.(delta) ?? false const settling = this.updateControls() - const shadowsStale = this.shadowsStale - renderer.shadowMap.needsUpdate = shadowsStale - this.shadowsStale = false - let restoreShadows = shadowsStale - ? this.options.onShadowFrame?.(this.allShadowsStale) - : undefined - this.allShadowsStale = false - const refreshAllShadows = (): void => { - restoreShadows?.() - restoreShadows = undefined - } + const refreshAllShadows = this.armShadowPass(renderer) const panesDrawn = !this.insetCoversAll() const renderStarted = performance.now() const timedGpu = this.drawTimedFrame(renderer, panesDrawn, refreshAllShadows) recordFrame(renderer.info, this.stats, performance.now() - renderStarted) this.stats.gpuFrameMs = timedGpu ? (this.gpuTimer?.read() ?? null) : null - renderer.shadowMap.needsUpdate = true + oweShadowPassOnce(renderer, true) if (moving || settling) { this.requestCameraRender() return @@ -132,14 +143,8 @@ export class ViewportFrame extends ViewportInset { return settling } - private renderOverlay(renderer: WebGLRenderer): void { + private renderOverlay(renderer: StudioRenderer): void { const overlay = this.options.onOverlay - if (!overlay) return - renderer.autoClear = false - try { - overlay(renderer) - } finally { - renderer.autoClear = true - } + if (overlay) this.renderDriver.drawOverlay(renderer, overlay) } } diff --git a/src/renderer/src/engines/viewport/ViewportInset.ts b/src/renderer/src/engines/viewport/ViewportInset.ts index 548725491..f29344554 100644 --- a/src/renderer/src/engines/viewport/ViewportInset.ts +++ b/src/renderer/src/engines/viewport/ViewportInset.ts @@ -1,17 +1,12 @@ -import { - LinearSRGBColorSpace, - NoToneMapping, - SRGBColorSpace, - type WebGLRenderer, - WebGLRenderTarget, -} from 'three' +import { LinearSRGBColorSpace, NoToneMapping, SRGBColorSpace, WebGLRenderTarget } from 'three' +import type { StudioRenderer } from '../render/renderDriver' import { aspectLoan } from './aspectLoan' import { glRect } from './panes' import type { InsetPane, InsetBlit } from './viewportEngineSupport1' import { ViewportDrawing } from './ViewportDrawing' export abstract class ViewportInset extends ViewportDrawing { - protected abstract insetBlitOf(renderer: WebGLRenderer): InsetBlit + protected abstract insetBlitOf(renderer: StudioRenderer): InsetBlit /** * The target the preview is drawn into, at the size it is shown — one device pixel per pixel, @@ -27,7 +22,7 @@ export abstract class ViewportInset extends ViewportDrawing { * runs out first. */ protected insetTargetOf( - renderer: WebGLRenderer, + renderer: StudioRenderer, width: number, height: number, ): WebGLRenderTarget { @@ -35,13 +30,9 @@ export abstract class ViewportInset extends ViewportDrawing { if (held && held.width === width && held.height === height) return held held?.dispose() - // What the DRAWING BUFFER is antialiased to, held to what the context can offer. The ceiling - // comes from three rather than from `gl.MAX_SAMPLES`, which the WebGL1 typing has no name for. - const gl = renderer.getContext() - const samples = Math.max( - 0, - Math.min(Number(gl.getParameter(gl.SAMPLES) ?? 0), renderer.capabilities.maxSamples), - ) + // What the DRAWING BUFFER is antialiased to, which is not the card's ceiling: this target + // is blitted onto the canvas, so it matches what the canvas has. + const samples = this.renderDriver.drawingBufferSamples(renderer) const target = new WebGLRenderTarget(width, height, { samples }) // Linear, which is what a render into a target writes whatever the texture says — three picks // the WORKING space for anything but the canvas (`WebGLRenderer`, the `colorSpace` it hands @@ -61,7 +52,7 @@ export abstract class ViewportInset extends ViewportDrawing { /** Draws the preview into its target. The costly half, and the one the cache exists to skip. */ protected drawInset( - renderer: WebGLRenderer, + renderer: StudioRenderer, inset: InsetPane, target: WebGLRenderTarget, panesDrawn: boolean, @@ -119,7 +110,7 @@ export abstract class ViewportInset extends ViewportDrawing { * it once, which is the identity. */ protected dressInsetBlit( - renderer: WebGLRenderer, + renderer: StudioRenderer, target: WebGLRenderTarget, composed: boolean, ): void { @@ -144,7 +135,7 @@ export abstract class ViewportInset extends ViewportDrawing { * This is what a frame costs when only the view moved — one draw call against the second full * traversal of the scene the direct pass paid for. */ - protected compositeInset(renderer: WebGLRenderer, inset: InsetPane): void { + protected compositeInset(renderer: StudioRenderer, inset: InsetPane): void { const surface = renderer.domElement.clientHeight const gl = glRect(inset.rect, surface) const blit = this.insetBlitOf(renderer) diff --git a/src/renderer/src/engines/viewport/ViewportState.ts b/src/renderer/src/engines/viewport/ViewportState.ts index 08dd8097a..813047d3d 100644 --- a/src/renderer/src/engines/viewport/ViewportState.ts +++ b/src/renderer/src/engines/viewport/ViewportState.ts @@ -1,18 +1,13 @@ import { VIEW_DISTANCE } from '@shared/domain/renderPolicy' -import { - Color, - OrthographicCamera, - PerspectiveCamera, - Scene, - type WebGLRenderer, - type WebGLRenderTarget, -} from 'three' +import { Color, OrthographicCamera, PerspectiveCamera, Scene, type WebGLRenderTarget } from 'three' import { type OrbitControls } from 'three/addons/controls/OrbitControls.js' import { type Gesture } from './gestures' import { emptyGpuStats, type GpuStats } from './gpuStats' import type { GpuTimer } from './gpuTimer' import { type PaneLayout, type PaneRect } from './panes' import { type PointerPosition } from './pointer' +import { glDriver } from '../render/glDriver' +import { type RenderDriver, type StudioRenderer } from '../render/renderDriver' import { ViewportNavigationTarget } from './ViewportNavigationTarget' import { ORIGIN, @@ -54,7 +49,19 @@ export abstract class ViewportState { protected projection: ProjectionKind = 'perspective' - protected renderer: WebGLRenderer | null = null + protected renderer: StudioRenderer | null = null + + /** + * Whether the renderer may be drawn with. False only while a node backend is coming up — a + * WebGL one is ready on the line after `new`. See `holdFramesUntilReady`. + */ + protected rendererReady = false + + /** The wait `settled` hands out, so a caller can hold off rather than draw into nothing. */ + protected rendererSettling: Promise | null = null + + /** What built that renderer, and therefore what reads its pixels and lights its scene. */ + protected renderDriver: RenderDriver = glDriver protected readonly navigationTarget: ViewportNavigationTarget diff --git a/src/renderer/src/engines/viewport/ViewportSurface.ts b/src/renderer/src/engines/viewport/ViewportSurface.ts index afb3109fa..0047d7e76 100644 --- a/src/renderer/src/engines/viewport/ViewportSurface.ts +++ b/src/renderer/src/engines/viewport/ViewportSurface.ts @@ -1,9 +1,12 @@ -import { ACESFilmicToneMapping, Color, NoToneMapping, WebGLRenderer } from 'three' +import { ACESFilmicToneMapping, Color, NoToneMapping } from 'three' +import { loadedGpuModule, loadGpuModule } from '../render/gpuModule' import { OrbitControls } from 'three/addons/controls/OrbitControls.js' import { DEFAULT_RENDER_POLICY } from '@shared/domain/renderPolicy' +import { traceFailure } from '@/services/diagnostics' import { applyShadowPolicy } from '../scene/shadows' import { token } from '../core/palette' -import { createGpuTimer, isGpuTimerContext } from './gpuTimer' +import { mountRenderer } from '../render/mountRenderer' +import { type RenderDriver, type StudioRenderer } from '../render/renderDriver' import { ViewportMounting } from './ViewportMounting' export abstract class ViewportSurface extends ViewportMounting { @@ -28,8 +31,8 @@ export abstract class ViewportSurface extends ViewportMounting { const canvas = this.canvasIn(host) const renderer = this.rendererFor(canvas) this.renderer = renderer - const context = renderer.getContext() - this.gpuTimer = isGpuTimerContext(context) ? createGpuTimer(context) : null + this.gpuTimer = this.renderDriver.frameTimer(renderer) + this.holdFramesUntilReady(renderer) this.mountControls(canvas) this.mountNavigation(host) this.observeCanvas(canvas) @@ -46,8 +49,27 @@ export abstract class ViewportSurface extends ViewportMounting { return canvas } - private rendererFor(canvas: HTMLCanvasElement): WebGLRenderer { - const renderer = new WebGLRenderer({ canvas, antialias: true, alpha: this.output.alpha }) + /** + * The renderer, and the driver that built it. An engine asked for and not available is not an + * error a person has to read: the Compatible one draws the same scene, and the journal keeps + * the reason. The adapter is asked for in the background, so the NEXT mount can honour it — + * a mount cannot wait, and a viewport that waited would show nothing while it did. + */ + private rendererFor(canvas: HTMLCanvasElement): StudioRenderer { + const wanted = this.options.engine?.() ?? 'gl' + const held = loadedGpuModule() + // Asked for in the BACKGROUND: the adapter and the node bundle both arrive a beat later, + // and a viewport that waited for them would show nothing while it did. + if (wanted === 'gpu' && !held) void loadGpuModule() + + const mounted = mountRenderer( + { canvas, alpha: this.output.alpha ?? false }, + wanted, + held !== null, + error => traceFailure('render.fallback', wanted, error), + ) + const { renderer, driver } = mounted + this.renderDriver = driver renderer.setPixelRatio(this.output.pixelRatio ?? window.devicePixelRatio) // Clear to nothing rather than to a colour, so a scene drawn for compositing hands back the // pixels it painted and nothing else. `setClearAlpha` alone is ignored without `alpha`. @@ -69,6 +91,60 @@ export abstract class ViewportSurface extends ViewportMounting { return renderer } + /** + * A node renderer throws on `render()` until its backend is up. The frames it would have drawn + * are dropped rather than queued — what a viewport shows is its CURRENT state, and one asked + * for again is one asked for now. + */ + private holdFramesUntilReady(renderer: StudioRenderer): void { + const settling = this.renderDriver.ready(renderer) + if (!settling) { + this.rendererReady = true + return + } + this.rendererSettling = this.settleRenderer(renderer, settling) + } + + /** + * How many samples the card may take across a texel's footprint, or `1` before there is a + * card to ask. Read here by the three engines that build a texture cache, so none of them + * has to know where its renderer keeps the answer. + */ + get anisotropy(): number { + return this.renderer ? this.renderDriver.maxAnisotropy(this.renderer) : 1 + } + + /** Whether the renderer may be drawn with at all — false while a node backend comes up. */ + get canDraw(): boolean { + return this.rendererReady + } + + /** + * Resolves once the backend has ANSWERED — not once it can draw. A refusal settles too, and + * leaves `canDraw` false: whoever waits has to read that before it touches the GPU. + */ + settled(): Promise { + return this.rendererSettling ?? Promise.resolve() + } + + private async settleRenderer(renderer: StudioRenderer, settling: Promise): Promise { + try { + await settling + } catch (error) { + // Nothing to fall back to from here: the canvas is built and the scene hangs off this + // renderer. The panel stays empty and the journal says why, which beats throwing into a + // mount nobody awaited. `canDraw` stays false, so nothing draws into a dead backend. + traceFailure('render.fallback', 'gpu', error) + return + } + // The one it was waiting for, not whichever is mounted now: a panel closed and reopened + // while a backend came up would otherwise arm the NEW renderer on the OLD one's answer. + if (this.renderer !== renderer) return + this.rendererReady = true + this.onResize() + this.requestRender() + } + private mountControls(canvas: HTMLCanvasElement): void { if (this.options.controls !== 'none') { this.controls = new OrbitControls(this.camera, canvas) @@ -145,9 +221,13 @@ export abstract class ViewportSurface extends ViewportMounting { this.disposeInset() const canvas = this.renderer?.domElement - this.renderer?.forceContextLoss() - this.renderer?.dispose() + const renderer = this.renderer + if (renderer) this.renderDriver.releaseContext(renderer) + renderer?.dispose() this.renderer = null + // Both, or a second mount of this engine would draw on the first renderer's permission. + this.rendererReady = false + this.rendererSettling = null this.gpuTimer = null // The canvas goes with the engine that made it: left behind, the next mount would stack a @@ -160,10 +240,19 @@ export abstract class ViewportSurface extends ViewportMounting { } /** The renderer itself, for the passes and overlays that have to draw with it. */ - get gl(): WebGLRenderer | null { + get gl(): StudioRenderer | null { return this.renderer } + /** + * What is drawing — the five calls that differ between the two engines. Read rather than + * chosen by whoever needs one: the driver is settled at mount, and a caller picking its own + * would be free to read pixels with an engine that did not draw them. + */ + get driver(): RenderDriver { + return this.renderDriver + } + get orbit(): OrbitControls | null { return this.controls } diff --git a/src/renderer/src/engines/viewport/environment.test.ts b/src/renderer/src/engines/viewport/environment.test.ts index b9b0cc60f..dcfac5eeb 100644 --- a/src/renderer/src/engines/viewport/environment.test.ts +++ b/src/renderer/src/engines/viewport/environment.test.ts @@ -1,13 +1,17 @@ -import { Color, EquirectangularReflectionMapping, Scene, Texture, type WebGLRenderer } from 'three' -import type * as ThreeModule from 'three' +import { Color, EquirectangularReflectionMapping, Scene, Texture } from 'three' import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { NEUTRAL_ADJUSTMENTS } from '@shared/domain/adjustments' -import { createEnvironment, PMREM_QUIET_MS, type ViewportEnvironment } from './environment' +import { + createEnvironment, + PMREM_QUIET_MS, + type EnvironmentPort, + type ViewportEnvironment, +} from './environment' /** - * `PMREMGenerator` prefilters by rendering a mip chain, which needs a GL context jsdom cannot - * give. The stand-in hands back a target carrying a recognisable texture, so the tests can - * follow which map the scene ends up reading and when the previous one is freed. + * Prefiltering renders a mip chain and grading draws a quad: both need a device jsdom cannot + * give, and both are what the PORT stands for. The stand-in hands back a target carrying a + * recognisable texture, so the tests can follow which map the scene reads and when it is freed. */ type FakeTarget = { texture: Texture; dispose: Mock<() => void>; boundWhenFreed: boolean } @@ -40,23 +44,14 @@ const disposeGenerator = vi.fn() * on, for ever, unless something says it was redrawn. */ const graded = Object.assign(new Texture(), { isRenderTargetTexture: true }) -const gradeOf = vi.fn((source: Texture | null) => (source ? graded : null)) -const disposeGrading = vi.fn() - -/** No GL context in jsdom, and what the pass DOES is `skyGrading.test.ts`. */ -vi.mock('../gpu/skyGrading', () => ({ - createSkyGrading: () => ({ of: gradeOf, dispose: disposeGrading }), -})) - -vi.mock('three', async importOriginal => ({ - ...(await importOriginal()), - PMREMGenerator: class { - compileEquirectangularShader(): void {} - fromEquirectangular = fromEquirectangular - fromScene = fromScene - dispose = disposeGenerator - }, -})) +const gradeOf = vi.fn(() => graded) + +const port: EnvironmentPort = { + fromEquirectangular, + fromScene, + grade: gradeOf, + dispose: disposeGenerator, +} describe('the environment of a viewport', () => { let scene: Scene @@ -70,9 +65,7 @@ describe('the environment of a viewport', () => { requestRender = vi.fn<() => void>() }) - // `as`: the generator is mocked above, and it is the only thing the renderer is handed to. - const environmentOf = (): ViewportEnvironment => - createEnvironment({} as WebGLRenderer, scene, requestRender) + const environmentOf = (): ViewportEnvironment => createEnvironment(port, scene, requestRender) const withPrefilteredMap = (): ViewportEnvironment => { const environment = environmentOf() @@ -372,14 +365,16 @@ describe('the environment of a viewport', () => { expect(scene.environment).toBe(room) }) - it('frees the pass it built', () => { + // The pass belongs to the ENGINE now, not to the environment: whoever built the port frees + // it — see `glDriver`, which owns the grading and the generator together. + it('frees the engine port it was built on', () => { const environment = environmentOf() environment.setTexture(new Texture()) environment.setAdjustments(GRADED) environment.dispose() - expect(disposeGrading).toHaveBeenCalled() + expect(disposeGenerator).toHaveBeenCalled() }) }) diff --git a/src/renderer/src/engines/viewport/environment.ts b/src/renderer/src/engines/viewport/environment.ts index aa254d899..48f817a5f 100644 --- a/src/renderer/src/engines/viewport/environment.ts +++ b/src/renderer/src/engines/viewport/environment.ts @@ -1,14 +1,34 @@ -import { - EquirectangularReflectionMapping, - PMREMGenerator, - type Scene, - type Texture, - type WebGLRenderer, - type WebGLRenderTarget, -} from 'three' +import { EquirectangularReflectionMapping, type Scene, type Texture } from 'three' import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js' import { isNeutral, NEUTRAL_ADJUSTMENTS, type AdjustmentStack } from '@shared/domain/adjustments' -import { createSkyGrading, type SkyGrading } from '../gpu/skyGrading' + +/** + * A prefiltered map, whichever engine built it. `WebGLRenderTarget` and the node renderer's + * `RenderTarget` are declared apart by three and share exactly this much of a shape. + */ +type PrefilteredMap = { texture: Texture; dispose: () => void } + +/** How far the neutral room is blurred as it is prefiltered — three's own value for one. */ +export const ROOM_SIGMA = 0.04 + +/** + * What an environment needs OF an engine, and the whole of it: prefiltering a picture, prefiltering + * the neutral room, and grading a sky before either. + * + * A port rather than a renderer, for the reason `SqliteDriver` is one: the debounce, the backdrop, + * the rotation and the intensity are the same on both engines, and the day they were written twice + * is the day a sky graded under one stopped matching the other. + */ +export type EnvironmentPort = { + fromEquirectangular: (texture: Texture) => PrefilteredMap + fromScene: (scene: Scene) => PrefilteredMap + /** + * The picture as the sky DOCUMENT grades it, or the source itself where an engine has no + * grading of its own — see `gpuDriver`, which says what that costs a reader. + */ + grade: (given: Texture, stack: AdjustmentStack) => Texture + dispose: () => void +} /** * Image-based lighting for a viewport: the equirectangular picture behind the scene, and the @@ -90,26 +110,19 @@ function backdropRedrawn(texture: Texture): void { } export function createEnvironment( - renderer: WebGLRenderer, + port: EnvironmentPort, scene: Scene, requestRender: () => void, ): ViewportEnvironment { - const generator = new PMREMGenerator(renderer) - // Compiled up front: the first `fromEquirectangular` would otherwise stall the frame that - // asked for it, which is the frame where the user has just chosen a sky. - generator.compileEquirectangularShader() - /** What was handed in, before grading — `source` is what is shown and prefiltered. */ let given: Texture | null = null let source: Texture | null = null let stack: AdjustmentStack = NEUTRAL_ADJUSTMENTS - /** Built on the first stack that is not neutral, and never for a sky nobody has graded. */ - let grading: SkyGrading | null = null let quiet: ReturnType | null = null - let prefiltered: WebGLRenderTarget | null = null + let prefiltered: PrefilteredMap | null = null let backgroundVisible = true /** The neutral room, prefiltered on the first ask and kept — see `borrowStudio`. */ - let room: WebGLRenderTarget | null = null + let room: PrefilteredMap | null = null /** What the document asks for, so a borrowed pass has something to give back. */ let owned: Texture | null = null let intensity = 1 @@ -130,8 +143,7 @@ export function createEnvironment( } const regrade = (): void => { - if (given && !isNeutral(stack)) grading ??= createSkyGrading(renderer) - const shown = grading ? grading.of(given, stack) : given + const shown = given && !isNeutral(stack) ? port.grade(given, stack) : given // A target redrawn IN PLACE, which is what both engines hand back: three caches the backdrop's // cubemap on the texture and expires it never — see `backdropRedrawn`. if (shown !== null && shown === source) backdropRedrawn(shown) @@ -146,7 +158,7 @@ export function createEnvironment( const roomMap = (): Texture => { if (!room) { const built = new RoomEnvironment() - room = generator.fromScene(built, 0.04) + room = port.fromScene(built) // `fromScene` reads the room and leaves it alone: its dozen boxes and materials are ours. built.dispose() } @@ -163,7 +175,7 @@ export function createEnvironment( cancelQuiet() const previous = prefiltered - prefiltered = source ? generator.fromEquirectangular(source) : null + prefiltered = source ? port.fromEquirectangular(source) : null owned = prefiltered?.texture ?? null scene.environment = owned // Disposed after the new one is in place, never before: releasing the target still bound @@ -263,15 +275,13 @@ export function createEnvironment( cancelQuiet() scene.background = null scene.environment = null - grading?.dispose() - grading = null prefiltered?.dispose() prefiltered = null // The room outlives every sky, so it is freed here and nowhere else. room?.dispose() room = null owned = null - generator.dispose() + port.dispose() }, } } diff --git a/src/renderer/src/engines/viewport/viewport-fixtures.ts b/src/renderer/src/engines/viewport/viewport-fixtures.ts index b8f472030..ba7859a0b 100644 --- a/src/renderer/src/engines/viewport/viewport-fixtures.ts +++ b/src/renderer/src/engines/viewport/viewport-fixtures.ts @@ -1,4 +1,4 @@ -import { Texture } from 'three' +import { Texture, type WebGLRenderer } from 'three' import { vi, type Mock } from 'vitest' import type { TextureSource } from '../scene/textureCache' import type { ViewportEnvironment } from './environment' @@ -24,6 +24,16 @@ export function fakeEnvironment(): ViewportEnvironment { } } +/** + * A renderer for a suite that never draws: jsdom gives no graphics context, and the three 3D + * workspaces only ask their renderer what the CARD allows before uploading a texture. + * + * `as`: a real `WebGLRenderer` cannot be built here, and what an engine reads off one is this. + */ +export function fakeRenderer(): WebGLRenderer { + return { capabilities: { getMaxAnisotropy: () => 16 } } as WebGLRenderer +} + export type FakeTextureSource = { load: Mock /** One spy per texture handed out, in the order they were asked for. */ diff --git a/src/renderer/src/engines/viewport/viewportEngineSupport1.ts b/src/renderer/src/engines/viewport/viewportEngineSupport1.ts index b56336aac..f992c333b 100644 --- a/src/renderer/src/engines/viewport/viewportEngineSupport1.ts +++ b/src/renderer/src/engines/viewport/viewportEngineSupport1.ts @@ -6,14 +6,15 @@ import { type PerspectiveCamera, type Scene, Vector3, - type WebGLRenderer, type WebGLRenderTarget, } from 'three' +import type { WebGLRenderer } from 'three' import { type OrbitControls } from 'three/addons/controls/OrbitControls.js' import { type GpuPipeline } from '../gpu/gpuPipeline' import { type PinchReading } from './pinch' import { type Gesture } from './gestures' import { type NavigationScheme } from '@shared/domain/navigationPreset' +import { type RenderEngine } from '@shared/domain/renderEngine' import { gazeTargetOf, type PivotMode } from './orbitPivot' import { type PaneRect } from './panes' @@ -39,7 +40,11 @@ export type ViewportEngineOptions = { * keeps the loop alive for another frame; returning false lets the viewport go back to sleep. */ onFrame?: (delta: number) => boolean - /** Drawn after the scene with `autoClear` off — trihedrons and other screen-space overlays. */ + /** + * Drawn after the scene with `autoClear` off — trihedrons and other screen-space overlays. + * WebGL only: `ViewHelper` is declared against that renderer, and the Advanced engine draws + * no overlay yet — `renderOverlay` skips it there rather than casting. + */ onOverlay?: (renderer: WebGLRenderer) => void /** * Called just before each pane is drawn, so whoever owns the scene can say how THIS view shows @@ -59,6 +64,12 @@ export type ViewportEngineOptions = { onInset?: (camera: ViewportCamera) => () => void /** Narrows a requested shadow pass, then restores the scene for off-screen renders. */ onShadowFrame?: (refreshAll: boolean) => () => void + /** + * Which engine draws, read at MOUNT and never again: a renderer cannot be handed the context + * of another API. Absent is the Compatible one, which is what every viewport but the scene's + * has always used. + */ + engine?: () => RenderEngine /** * Filmic tone mapping. Off by default because it changes how every existing colour lands, * and the scene editor was built and reviewed without it; a viewport that judges an HDR diff --git a/src/renderer/src/features/assistant/sceneHandlers03.test.ts b/src/renderer/src/features/assistant/sceneHandlers03.test.ts index 0626e2469..7dedf2cc0 100644 --- a/src/renderer/src/features/assistant/sceneHandlers03.test.ts +++ b/src/renderer/src/features/assistant/sceneHandlers03.test.ts @@ -255,6 +255,8 @@ describe('the world of the scene', () => { ) const reached: Record = { + // Chosen when the document is CREATED — see `SceneWorld.engine`. + engine: false, environment: written.has('kind'), envIntensity: written.has('intensity'), envRotation: written.has('rotation'), @@ -277,10 +279,11 @@ describe('the world of the scene', () => { Object.entries(reached) .filter(([, held]) => !held) .map(([member]) => member), - // `post` is written by the composition's own actions — `post.add`, `post.set`, `post.applyPreset` - // — which name an effect and a parameter rather than a field of the world. `play` and + // `engine` is settled when the DOCUMENT is created and written by nothing after. `post` is + // written by the composition's own actions — `post.add`, `post.set`, `post.applyPreset` — + // which name an effect and a parameter rather than a field of the world. `play` and // `layers` are written by nothing at all yet. - ).toEqual(['post', 'play', 'layers']) + ).toEqual(['engine', 'post', 'play', 'layers']) }) }) diff --git a/src/renderer/src/features/document/components/NewDocument/NewDocumentEngineField.tsx b/src/renderer/src/features/document/components/NewDocument/NewDocumentEngineField.tsx new file mode 100644 index 000000000..7fa0cf113 --- /dev/null +++ b/src/renderer/src/features/document/components/NewDocument/NewDocumentEngineField.tsx @@ -0,0 +1,53 @@ +import { useId } from 'react' +import { useTranslation } from 'react-i18next' +import { oneOf } from '@shared/guards' +import type { DocumentKind } from '@shared/domain/document' +import { RENDER_ENGINES, type RenderEngine } from '@shared/domain/renderEngine' +import { Select } from '@/components/Select' + +export type NewDocumentEngineFieldProps = { + kind: DocumentKind + value: RenderEngine + onChange: (engine: RenderEngine) => void +} + +/** + * Which engine a SCENE is drawn by, asked here because here is the only place it can be asked. + * + * A whole scene lives inside one graphics context, so nothing hands a mounted viewport over to + * the other API: the choice is written into the document's world and read at every mount from + * then on. The field opens on the preference and never writes back to it — see `SceneWorld.engine`. + * + * Each option carries its own description rather than a help line under the field: what the two + * words mean is what one is choosing between, and a sentence below the closed list describes + * whichever option is already picked. + * + * Drawn for no other kind: the five that are not scenes have no viewport of their own to draw. + */ +export function NewDocumentEngineField({ kind, value, onChange }: NewDocumentEngineFieldProps) { + const { t } = useTranslation() + const fieldId = useId() + if (kind !== 'scene') return null + + return ( +
+ + +
+ ) +} diff --git a/src/renderer/src/features/document/components/NewDocument/NewDocumentForm.tsx b/src/renderer/src/features/document/components/NewDocument/NewDocumentForm.tsx index 2405aa234..a837c6dba 100644 --- a/src/renderer/src/features/document/components/NewDocument/NewDocumentForm.tsx +++ b/src/renderer/src/features/document/components/NewDocument/NewDocumentForm.tsx @@ -6,6 +6,8 @@ import { checkDocumentName } from '@shared/domain/documentName' import { DEFAULT_ROLE_PATHS } from '@shared/domain/folderRole' import type { KnownFormat } from '@shared/domain/formatCapability' import type { DocumentTemplateId, NamedDocumentPlace } from '@shared/domain/newDocument' +import type { RenderEngine } from '@shared/domain/renderEngine' +import { DEFAULT_WORLD } from '@shared/domain/scene' import { DEFAULT_SCENE_TEMPLATE, type SceneTemplateId } from '@shared/domain/sceneTemplate' import { DEFAULT_UI_TEMPLATE, type UiTemplateId } from '@shared/domain/uiTemplates' import { Button } from '@/components/Button' @@ -15,6 +17,7 @@ import { getBridge } from '@/services/bridge' import { useDocuments } from '@/stores/documents' import { takenDocumentNames, untitledDocumentName } from '@/stores/documentNames' import { DOCUMENT_NAME_REFUSALS } from '../../documentName' +import { NewDocumentEngineField } from './NewDocumentEngineField' import { NewDocumentNameField } from './NewDocumentNameField' import { NewDocumentTemplateField } from './NewDocumentTemplateField' @@ -33,6 +36,8 @@ export type NewDocumentFormProps = { * document, which opens on a free name and has one format per kind to show rather than offer. */ saveAs?: { title: string; formats: readonly KnownFormat[] } + /** What the scene's engine field opens on — the preference where one was carried. */ + engine?: RenderEngine onCancel: () => void onSubmit: (place: NamedDocumentPlace) => void } @@ -50,6 +55,7 @@ export function NewDocumentForm({ projectName, open, saveAs, + engine, onCancel, onSubmit, }: NewDocumentFormProps) { @@ -60,6 +66,7 @@ export function NewDocumentForm({ const [format, setFormat] = useState(saveAs?.formats[0] ?? null) const [template, setTemplate] = useState(DEFAULT_SCENE_TEMPLATE) const [uiTemplate, setUiTemplate] = useState(DEFAULT_UI_TEMPLATE) + const [renderEngine, setRenderEngine] = useState(engine ?? DEFAULT_WORLD.engine) const stored = useDocuments(state => state.stored) // Pulled out of the object so the effect below depends on the NAME rather than on a prop // rebuilt at every render of the window — which would re-seed the field on each keystroke. @@ -129,9 +136,12 @@ export function NewDocumentForm({ commit() } - /** What this kind answers with, or nothing at all — never the other kind's id. */ - const templateOf = (): { template?: DocumentTemplateId } => { - if (kind === 'scene') return { template } + /** + * What this kind answers with, or nothing at all — never the other kind's id, and never an + * engine for a kind that draws no scene. + */ + const templateOf = (): { template?: DocumentTemplateId; engine?: RenderEngine } => { + if (kind === 'scene') return { template, engine: renderEngine } if (kind === 'gui') return { template: uiTemplate } return {} } @@ -189,6 +199,10 @@ export function NewDocumentForm({ onUi={setUiTemplate} /> + {/* After what the scene HOLDS and before where it goes: the engine is a property of the + document being made, and the one answer this form cannot be asked for again. */} + +
{t('documents.folderField')} diff --git a/src/renderer/src/features/document/components/NewDocument/NewDocumentWindow.test.tsx b/src/renderer/src/features/document/components/NewDocument/NewDocumentWindow.test.tsx index 3df8175bc..206f04f58 100644 --- a/src/renderer/src/features/document/components/NewDocument/NewDocumentWindow.test.tsx +++ b/src/renderer/src/features/document/components/NewDocument/NewDocumentWindow.test.tsx @@ -94,7 +94,13 @@ describe('NewDocumentWindow', () => { await userEvent.type(field, 'Niveau{Enter}') expect(answer).toHaveBeenCalledWith( - made({ kind: 'scene', title: 'Niveau', folder: 'documents', template: 'basic' }), + made({ + kind: 'scene', + title: 'Niveau', + folder: 'documents', + template: 'basic', + engine: 'gl', + }), ) }) @@ -105,7 +111,37 @@ describe('NewDocumentWindow', () => { await userEvent.click(screen.getByRole('button', { name: 'Créer' })) expect(answer).toHaveBeenCalledWith( - made({ kind: 'scene', title: 'Scène 1', folder: 'documents', template: 'cinematic' }), + made({ + kind: 'scene', + title: 'Scène 1', + folder: 'documents', + template: 'cinematic', + engine: 'gl', + }), + ) + }) + + // The one answer this form takes that its document can never be asked for again. + it('opens the engine on the preference it was handed', async () => { + open({ ...ASK, engine: 'gpu' }) + + expect(await screen.findByLabelText('Moteur de rendu')).toHaveValue('gpu') + }) + + it('answers the engine that was picked, not the one it opened on', async () => { + open(ASK) + + await userEvent.selectOptions(await screen.findByLabelText('Moteur de rendu'), 'gpu') + await userEvent.click(screen.getByRole('button', { name: 'Créer' })) + + expect(answer).toHaveBeenCalledWith( + made({ + kind: 'scene', + title: 'Scène 1', + folder: 'documents', + template: 'basic', + engine: 'gpu', + }), ) }) @@ -115,6 +151,8 @@ describe('NewDocumentWindow', () => { await screen.findByRole('textbox') expect(screen.queryByRole('button', { name: 'Base' })).toBeNull() + // Nor an engine: the five kinds that are not scenes draw no viewport of their own. + expect(screen.queryByLabelText('Moteur de rendu')).toBeNull() await userEvent.click(screen.getByRole('button', { name: 'Créer' })) expect(answer).toHaveBeenCalledWith( @@ -209,7 +247,13 @@ describe('NewDocumentWindow', () => { await userEvent.click(await screen.findByRole('button', { name: 'Créer' })) expect(answer).toHaveBeenCalledWith( - made({ kind: 'scene', title: 'Scène 1', folder: 'Dossiers/scenes', template: 'basic' }), + made({ + kind: 'scene', + title: 'Scène 1', + folder: 'Dossiers/scenes', + template: 'basic', + engine: 'gl', + }), ) }) @@ -224,7 +268,13 @@ describe('NewDocumentWindow', () => { await userEvent.keyboard('{Enter}') expect(answer).toHaveBeenCalledWith( - made({ kind: 'scene', title: 'Scène 1', folder: 'documents', template: 'cinematic' }), + made({ + kind: 'scene', + title: 'Scène 1', + folder: 'documents', + template: 'cinematic', + engine: 'gl', + }), ) }) diff --git a/src/renderer/src/features/document/components/NewDocument/NewDocumentWindow.tsx b/src/renderer/src/features/document/components/NewDocument/NewDocumentWindow.tsx index 99f9d2fad..1e1d5fdf2 100644 --- a/src/renderer/src/features/document/components/NewDocument/NewDocumentWindow.tsx +++ b/src/renderer/src/features/document/components/NewDocument/NewDocumentWindow.tsx @@ -124,6 +124,7 @@ export function NewDocumentWindow() { picked={ask.picked} projectName={project} open={ask.open} + engine={ask.engine} saveAs={saveAs ?? undefined} onCancel={() => settle(null)} onSubmit={place => settle({ answer: 'made', place })} diff --git a/src/renderer/src/features/game/components/GameWindow/GameWindow.tsx b/src/renderer/src/features/game/components/GameWindow/GameWindow.tsx index 82ea4b376..7dbbaddde 100644 --- a/src/renderer/src/features/game/components/GameWindow/GameWindow.tsx +++ b/src/renderer/src/features/game/components/GameWindow/GameWindow.tsx @@ -52,6 +52,11 @@ export function GameWindow() { prepareModelDress: prepareExtractedModelDress, environmentDress: environmentDressOf, }) + // 🛑 This window draws on the studio's DEFAULT engine, not on the one the played document + // carries: the engine is read when the renderer is built, and the scene only lands on + // `gameChannel` afterwards. An export of that same document honours it (see + // `gameExportCompiler`), so the two disagree. Closing it means holding this mount until the + // first scene arrives — not done, and written down rather than found. See the C6 report. renderer.mount(element) engineRef.current = renderer diff --git a/src/renderer/src/features/scene/components/Post/PostProcessingSection.tsx b/src/renderer/src/features/scene/components/Post/PostProcessingSection.tsx index 5f7dcaa28..11eb1c51d 100644 --- a/src/renderer/src/features/scene/components/Post/PostProcessingSection.tsx +++ b/src/renderer/src/features/scene/components/Post/PostProcessingSection.tsx @@ -2,8 +2,8 @@ import { mdiCompare, mdiRhombus, mdiRhombusOutline } from '@mdi/js' import { useCallback, useMemo, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { + effectsForEngine, POST_CATEGORIES, - POST_EFFECT_IDS, POST_EFFECTS, type PostEffectId, type PostStack, @@ -37,6 +37,7 @@ import { newId } from '@/helpers/ids' import { sceneKeyingAt } from '@/helpers/sceneKeyingAt' import type { SceneEdit } from '@/hooks/useSceneEdit' import { sceneEngineOf } from '@/stores/sceneEngines' +import { sceneOf, useScenes } from '@/stores/scenes' import { HINT_LEFT, TIP_LEFT } from '@/helpers/tooltip' import { choicesOf } from '../../../shell/components/unionChoices' import { DescriptorSection } from '../../../../components/DescriptorSection' @@ -66,6 +67,7 @@ export function PostProcessingSection({ }: PostProcessingSectionProps) { const { t } = useTranslation() const [selectedId, setSelectedId] = useState(null) + const engine = useScenes(state => sceneOf(state, documentId).world.engine) const selected = stack.effects.find(effect => effect.id === selectedId) ?? null const subject = postSubjectOf(target) @@ -88,8 +90,18 @@ export function PostProcessingSection({ return found }, [keying.state, selected, target]) + /** + * What this DOCUMENT can compose, which is not the whole catalogue: the two engines build + * different passes, and offering one the chain would silently leave out is offering nothing. + * The rows already in the stack are untouched — a document is never made wrong by an engine. + * + * 🛑 The document's engine and not the MOUNTED one: what a viewport ended up on is held in a + * plain registry nothing re-renders from, so reading it here would freeze whatever it happened + * to say on the first render. A machine with no WebGPU adapter therefore still lists the + * Advanced effects, which its chain then leaves out — the fallback says so in the journal. + */ const effects = useMemo(() => { - const ordered = [...POST_EFFECT_IDS].sort( + const ordered = [...effectsForEngine(engine)].sort( (left, right) => POST_CATEGORIES.indexOf(POST_EFFECTS[left].category) - POST_CATEGORIES.indexOf(POST_EFFECTS[right].category), @@ -102,7 +114,7 @@ export function PostProcessingSection({ group: t(`postfx.category_${POST_EFFECTS[option.value].category}`), })), } - }, [t]) + }, [engine, t]) const run = edit.run diff --git a/src/renderer/src/features/scene/components/Scene/Document/hooks/useMountedSceneRenderer.ts b/src/renderer/src/features/scene/components/Scene/Document/hooks/useMountedSceneRenderer.ts index 54f951bd3..4fa75cbb0 100644 --- a/src/renderer/src/features/scene/components/Scene/Document/hooks/useMountedSceneRenderer.ts +++ b/src/renderer/src/features/scene/components/Scene/Document/hooks/useMountedSceneRenderer.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react' import type { Dispatch, MutableRefObject, SetStateAction } from 'react' +import type { RenderEngine } from '@shared/domain/renderEngine' import type { SceneRenderer } from '@/engines/scene/SceneRenderer' import type { SceneStats } from '@/engines/scene/sceneStats' import type { ScreenBox } from '@/engines/scene/marqueeSelection' @@ -14,29 +15,52 @@ export type RuntimeSetters = { flySpeed: Dispatch> } +/** + * Mounts the renderer the document asks for, and rebuilds it when that answer changes. + * + * 🛑 `engine` is a DEPENDENCY, and it has to be: a whole scene lives inside one graphics context, + * so a document that opens on the Advanced engine cannot be handed the Compatible renderer that + * is already up. A tab mounts before its file has landed — `restoreDocument` reads the disk — so + * such a document comes up Compatible and rebuilds once when its world arrives, at the cost of + * one graphics context discarded. Every Compatible document, and every new one of either kind, + * mounts once: a new scene is seeded before its tab opens. + * + * 🛑 `null` holds the mount off until the engine asked for can actually be built — see + * `useRenderEngineReady`, without which the rebuild above lands before the Advanced bundle does + * and falls back for the rest of the session. + * + * 🛑 Waiting for the document STATE instead was tried on 2026-09-11 and put back: a tab whose + * document never lands would then never draw at all, which is a blank viewport rather than a + * wasted context. + */ export function useMountedSceneRenderer( documentId: string, + engine: RenderEngine | null, hostRef: MutableRefObject, - engineRef: MutableRefObject, + rendererRef: MutableRefObject, setLive: Dispatch>, setters: RuntimeSetters, - createRenderer: (documentId: string, setters: RuntimeSetters) => SceneRenderer, + createRenderer: ( + documentId: string, + setters: RuntimeSetters, + engine: RenderEngine, + ) => SceneRenderer, ): void { useEffect(() => { const element = hostRef.current - if (!element) return - const renderer = createRenderer(documentId, setters) + if (!element || engine === null) return + const renderer = createRenderer(documentId, setters, engine) renderer.mount(element) - engineRef.current = renderer + rendererRef.current = renderer setLive(renderer) registerSceneEngine(documentId, renderer) return () => { usePlay.getState().stop(documentId) renderer.dispose() - engineRef.current = null + rendererRef.current = null setLive(null) forgetSceneEngine(documentId) useModelFiles.getState().forget(documentId) } - }, [createRenderer, documentId, engineRef, hostRef, setLive, setters]) + }, [createRenderer, documentId, engine, hostRef, rendererRef, setLive, setters]) } diff --git a/src/renderer/src/features/scene/components/Scene/Document/hooks/useSceneRuntime.ts b/src/renderer/src/features/scene/components/Scene/Document/hooks/useSceneRuntime.ts index 72e075366..5f4099883 100644 --- a/src/renderer/src/features/scene/components/Scene/Document/hooks/useSceneRuntime.ts +++ b/src/renderer/src/features/scene/components/Scene/Document/hooks/useSceneRuntime.ts @@ -8,7 +8,7 @@ import { assetVersionOf } from '@/stores/assets' import { livePreviewOf } from '@/stores/livePreviews' import { useModelFiles } from '@/stores/modelFiles' import { useProject } from '@/stores/project' -import { selectIn, useScenes } from '@/stores/scenes' +import { sceneOf, selectIn, useScenes } from '@/stores/scenes' import { useSceneViews } from '@/stores/sceneViews' import { skeletonProfilesOf, useSkeletonProfiles } from '@/stores/skeletonProfiles' import { environmentDressOf } from '@/features/skybox/components/environmentDress' @@ -27,15 +27,24 @@ import { openPointMenu, recordTransform, } from '../sceneRuntimeActions' +import { useRenderEngineReady } from '@/hooks/useRenderEngineReady' import { useMountedSceneRenderer, type RuntimeSetters } from './useMountedSceneRenderer' import { loadGroundPaint, saveGroundPaint } from '@/features/scene/groundPaintAsset' import type { GroundPaint } from '@shared/domain/groundPaint' +import type { RenderEngine } from '@shared/domain/renderEngine' -function sceneRendererFor(documentId: string, set: RuntimeSetters): SceneRenderer { +function sceneRendererFor( + documentId: string, + set: RuntimeSetters, + engine: RenderEngine, +): SceneRenderer { const projectPath = useProject.getState().project?.path ?? null let pendingGroundPaint: { terrainId: string; paint: GroundPaint } | null = null let groundSave: Promise = Promise.resolve(true) return new SceneRenderer({ + // The document's own, never the setting's: it is read once here and the renderer is built on + // it — see `SceneWorld.engine`. + engine, onSelect: (ids, mode) => selectIn(documentId, ids, mode), onTransform: moves => recordTransform(documentId, moves), onReliefSculpt: (terrainId, editId, chunks) => @@ -130,7 +139,19 @@ export function useSceneRuntime(documentId: string) { }), [], ) - useMountedSceneRenderer(documentId, host, engine, setLive, setters, sceneRendererFor) + // Subscribed rather than read once: a tab mounts before its file lands, and a document saved + // under the Advanced engine says so only when its world arrives — see the mount below. + const renderEngine = useScenes(state => sceneOf(state, documentId).world.engine) + const ready = useRenderEngineReady(renderEngine) + useMountedSceneRenderer( + documentId, + ready ? renderEngine : null, + host, + engine, + setLive, + setters, + sceneRendererFor, + ) const paneInHand = useCallback(() => engine.current?.activePane() ?? 0, []) const canAdd = useCallback(() => !engine.current?.flightHeld, []) diff --git a/src/renderer/src/features/shell/createScript.ts b/src/renderer/src/features/shell/createScript.ts index 0ef773299..19218030f 100644 --- a/src/renderer/src/features/shell/createScript.ts +++ b/src/renderer/src/features/shell/createScript.ts @@ -3,6 +3,7 @@ import type { DocumentDescriptor } from '@shared/domain/document' import { documentPathFor } from '@shared/domain/documentName' import { SCRIPT_STARTER } from '@shared/domain/game' import type { DocumentTemplateId } from '@shared/domain/newDocument' +import type { RenderEngine } from '@shared/domain/renderEngine' import { getBridge } from '@/services/bridge' import { documentAtPath, useDocuments } from '@/stores/documents' import { openDocument } from './components/dockviewApi' @@ -16,7 +17,13 @@ import { openDocument } from './components/dockviewApi' * Here rather than beside the window that fills it: three modules share it now, and a type both * halves of a split re-import from the other is exactly how an import cycle appears. */ -export type NamedCreation = { title: string; folder?: string; template?: DocumentTemplateId } +export type NamedCreation = { + title: string + folder?: string + template?: DocumentTemplateId + /** A scene's engine, absent for a caller that never saw the field — see `NamedDocumentPlace`. */ + engine?: RenderEngine +} /** * The file first, then the tab: `relist` is what gives the document the id its path spells. diff --git a/src/renderer/src/features/shell/newDocument.ts b/src/renderer/src/features/shell/newDocument.ts index 6b5ad3825..076e68179 100644 --- a/src/renderer/src/features/shell/newDocument.ts +++ b/src/renderer/src/features/shell/newDocument.ts @@ -11,11 +11,7 @@ import type { WorkspaceId } from '@shared/domain/workspace' import { checkDocumentName, type DocumentNameFailure } from '@shared/domain/documentName' import { parentOf } from '@shared/domain/folder' import { DEFAULT_SCENE_TEMPLATE, isSceneTemplateId } from '@shared/domain/sceneTemplate' -import type { - DocumentTemplateId, - NewDocumentAnswer, - NewDocumentAsk, -} from '@shared/domain/newDocument' +import type { NewDocumentAnswer, NewDocumentAsk } from '@shared/domain/newDocument' import { DEFAULT_UI_TEMPLATE, isUiTemplateId } from '@shared/domain/uiTemplates' import { ensureProjectInstalls } from '@/engines/scene/projectInstalls' import { seedGuiTemplate } from '@/stores/gui' @@ -64,6 +60,9 @@ async function askFor( // The tabs, which the window cannot read: it lists the project FOLDER for itself, and a // document opened and never saved is in no folder to be found. open: Object.values(useDocuments.getState().documents), + // What the scene field opens on. A DEFAULT and no longer a rule: the value the person leaves + // it at is written into the document, and the setting is never read for it again. + engine: useSettings.getState().settings.three.engine, } } @@ -147,10 +146,11 @@ async function made( async function seedCreated( created: DocumentDescriptor, - template: DocumentTemplateId | undefined, + of: NamedCreation, /** Everything the app ships that a template's shapes and modules read — awaited before seeding. */ shipped: Promise, ): Promise { + const template = of.template if (created.kind === 'scene') { await shipped const scene = isSceneTemplateId(template) ? template : DEFAULT_SCENE_TEMPLATE @@ -160,7 +160,13 @@ async function seedCreated( // The files FIRST: they answer the folder the role resolved to, and the scene's `Script` // components must name the very paths that were just written. const seeded = await seedTemplateFiles(scene) - seedSceneTemplate(created.id, scene, seeded.scripts, seeded.graph) + seedSceneTemplate(created.id, scene, { + // The preference where nobody was asked — the assistant and the MCP wire name their own + // documents and see no field. + engine: of.engine ?? useSettings.getState().settings.three.engine, + scripts: seeded.scripts, + graph: seeded.graph, + }) } if (created.kind === 'gui') { seedGuiTemplate(created.id, isUiTemplateId(template) ? template : DEFAULT_UI_TEMPLATE) @@ -188,7 +194,7 @@ async function create(kind: DocumentKind, of: NamedCreation): Promise { const [compiled, textureOverrides, modelTextureOverrides] = await Promise.all([ compiledScripts(), @@ -177,7 +182,7 @@ async function compileExportRequest( options, projectPath, projectScenes, - entryScene, + entry, compiled.modules, compiled.inputMaps, graphsHeldForExport(projectScenes.nodes, compiled.animationGraphs), @@ -190,7 +195,7 @@ function exportRequestOf( options: GameExportOptions, projectPath: string, projectScenes: CompiledProjectScenes, - entryScene: string, + entry: ExportEntry, modules: Awaited>['modules'], inputMaps: Awaited>['inputMaps'], animationGraphs: readonly AnimationGraphModule[], @@ -198,7 +203,7 @@ function exportRequestOf( ): GameExportRequest { return { title: options.title ?? projectName(projectPath), - entryScene, + entryScene: entry.id, scenes: projectScenes.scenes, scripts: modules.map(module => ({ script: module.script, code: module.code })), ...(inputMaps.length ? { inputMaps } : {}), @@ -210,8 +215,9 @@ function exportRequestOf( ? { lossyOptimization: options.lossyOptimization } : {}), // 🛑 Carried rather than defaulted: a game drawn under another policy than the editor is the - // same scene lit two ways, and nothing compared the two. - render: renderPolicyOf(useSettings.getState().settings.three), + // same scene lit two ways, and nothing compared the two. The ENGINE alone comes off the + // document rather than the settings. + render: renderPolicyOf(useSettings.getState().settings.three, entry.engine), ...(assetOverrides?.length ? { assetOverrides } : {}), ...(options.folder ? { folder: options.folder } : {}), } diff --git a/src/renderer/src/game/webRender.test.ts b/src/renderer/src/game/webRender.test.ts index f1c991642..5736dad3d 100644 --- a/src/renderer/src/game/webRender.test.ts +++ b/src/renderer/src/game/webRender.test.ts @@ -76,13 +76,23 @@ const CANVAS: HTMLCanvasElement = Object.create(null) async function stagedGame(policy: Partial = DEFAULT_RENDER_POLICY) { const crate = meshNode(BOX, { name: 'Crate', transform: at(1, 0.5, 1) }) - const render = createWebRender(CANVAS, NOTHING, policy) + const said: string[] = [] + const render = createWebRender(CANVAS, NOTHING, policy, (_level, message) => said.push(message)) const renderer = fake.renderers[fake.renderers.length - 1] if (!renderer) throw new Error('no renderer was built') await render.show(sceneOf([crate, lightNode(SUN, { x: 0, y: 4, z: 0 })])) render.resize(640, 360) render.view({ position: { x: 0, y: 5, z: 10 }, target: { x: 0, y: 0, z: 0 } }) - return { render, renderer, crate } + return { render, renderer, crate, said } +} + +/** How many lights of a drawn frame cast a shadow — one sun, or one per cascade band. */ +const castersOf = (scene: unknown): number => { + let casting = 0 + ;(scene as Scene).traverse(object => { + if ('isDirectionalLight' in object && object.castShadow) casting += 1 + }) + return casting } const sunOf = (scene: unknown): { shadow: { camera: { right: number; far: number } } } => { @@ -113,6 +123,29 @@ describe('what an exported game pays for an image', () => { expect(renderer.shadowMap.autoUpdate).toBe(false) }) + // The field travels in the manifest, so it has to be HONOURED here: a project exported with + // cascades would otherwise play under one stretched map and nobody would be told. + it('builds the cascades the author chose, and none when they chose otherwise', async () => { + const withBands = await stagedGame({ ...DEFAULT_RENDER_POLICY, csm: true }) + const withOne = await stagedGame({ ...DEFAULT_RENDER_POLICY, csm: false }) + withBands.render.draw() + withOne.render.draw() + + expect(castersOf(withBands.renderer.frames[0])).toBeGreaterThan( + castersOf(withOne.renderer.frames[0]), + ) + }) + + // The field travels in the manifest and nothing here reads it. A game whose author chose the + // Advanced engine plays on WebGL, and the one person who would want to know is the author. + it('says out loud that it does not draw with the engine its author chose', async () => { + const advanced = await stagedGame({ ...DEFAULT_RENDER_POLICY, engine: 'gpu' }) + const compatible = await stagedGame({ ...DEFAULT_RENDER_POLICY, engine: 'gl' }) + + expect(advanced.said.some(one => one.includes('plays on WebGL'))).toBe(true) + expect(compatible.said).toEqual([]) + }) + // 🛑 A manifest is a JSON file on disk: a size somebody typed as a word gave `NaN` for the // shadow maps and the pixel ratio, which draws nothing and says nothing. it('reads a policy member by member, keeping the default for what does not read', async () => { diff --git a/src/renderer/src/game/webRender.ts b/src/renderer/src/game/webRender.ts index b876ca377..6b7dd0873 100644 --- a/src/renderer/src/game/webRender.ts +++ b/src/renderer/src/game/webRender.ts @@ -4,6 +4,7 @@ import { Box3, Mesh, MeshBasicMaterial, + type Object3D, OrthographicCamera, PerspectiveCamera, PlaneGeometry, @@ -19,6 +20,12 @@ import type { CameraView, EntityPlacement, RenderPort } from '@game/ports/render import { copyCameraView, NOWHERE, sameCameraView } from '@shared/domain/transform' import { applyToneMapping } from '@/engines/scene/worldBinding' import { applyShadowPolicy, throwsOf, tuneShadowMaps } from '@/engines/scene/shadows' +import { + cascadeSettingsFor, + cascadesWanted, + createCascadeShadows, + type CascadeShadows, +} from '@/engines/scene/csm' import type { ShadowThrow } from '@/engines/scene/grouping' import { frameOwesDraw, frameOwesShadows } from './gameSceneFrame' import { pixelRatioFor, shadowMapSizeFor } from '@/engines/scene/viewportQuality' @@ -62,6 +69,11 @@ const NEAR = 0.1 * * 🛑 One `apply`-free port: outside the studio nothing edits, so the scene is built once per * load and only the entity poses move. That is what makes an exported frame cheap. + * + * 🛑 The Compatible engine, always: `policy.engine` travels in the manifest and nothing here + * reads it, so an exported game draws WebGL whatever its entry scene was made under. The + * editor's viewport honours the field and this does not — closing that means carrying the node + * bundle into an exported page. Said out loud rather than silently: see `policyOf`. */ export function createWebRender( canvas: HTMLCanvasElement, @@ -71,7 +83,7 @@ export function createWebRender( /** Where a fault goes. A game that draws without its grading has to SAY so, not play on. */ say: LogPort['write'] = () => {}, ): WebRender { - const policy = readRenderPolicy({ ...DEFAULT_RENDER_POLICY, ...carried }) + const policy = policyOf(carried, say) const renderer = new WebGLRenderer({ canvas, antialias: true }) const gltf = createGltfSource(() => renderer) applyShadowPolicy(renderer, policy) @@ -83,6 +95,7 @@ export function createWebRender( /** The canvas differs from the next frame for a reason the scene cannot see: size, lens, veil. */ let pictureStale = true let cast: ShadowThrow | null = null + let cascades: CascadeShadows | null = null const watched: CameraView = { position: { ...NOWHERE }, target: { ...NOWHERE } } /** 🛑 Dynamic: its three.js passes are weight every game without effects would carry for nothing. */ const chain = composerHold(renderer, assets, say) @@ -112,6 +125,10 @@ export function createWebRender( } held?.dispose() + cascades?.release() + cascades = cascadesFor(built.scene, policy, () => { + pictureStale = true + }) held = built pictureStale = true // A head the scene that left had already seen: the one that arrived has not. @@ -219,8 +236,12 @@ export function createWebRender( // On the frame the scene lands, and again whenever a caster or a light left its frustum. if (settled.reframed && policy.shadows) { cast = tuneSceneShadows(held, policy) + cascades?.aim(cast) if (held.flush(camera, cast).zoned) settled = { ...settled, zoned: true } } + // The bands follow the EYE, so a camera that moved owes their maps a pass — the very + // answer `dressPane` gives the editor's frame. + if (cascades?.follow(camera) === true) settled = { ...settled, shadowed: true } // 🛑 Nothing changed, nothing drawn — the canvas keeps the frame it shows, as the viewport at // rest. A composed frame is drawn regardless: its grain and jitter run on the clock. const composer = chain.current() @@ -235,6 +256,8 @@ export function createWebRender( dispose: () => { // The build in flight with it: what it lands on has just been thrown away. building += 1 + cascades?.release() + cascades = null held?.dispose() held = null veil.dispose() @@ -305,6 +328,8 @@ function paintHeld( if (composer) { composer.draw({ surface: 'game', + // A game draws the same chain frame after frame, exactly as a viewport does. + oneShot: false, scene: held.scene, camera, stack: held.world.post, @@ -323,6 +348,41 @@ function paintHeld( } } +/** + * What this game plays under — and, once per load, what it owes its author about it. + * + * The engine is the one member read nowhere below: a game made on the Advanced engine plays on + * WebGL. Said rather than swallowed, on the doctrine `composerHold` already follows — a game + * that plays without what its author asked for says so instead of playing on, and nothing else + * would ever mention it: the picture is whole, only lit by the other engine. + */ +function policyOf(carried: Partial, say: LogPort['write']): RenderPolicy { + const policy = readRenderPolicy({ ...DEFAULT_RENDER_POLICY, ...carried }) + if (policy.engine !== 'gl') { + say('warn', `this game was made on the ${policy.engine} engine and plays on WebGL`) + } + return policy +} + +/** + * The cascades a scene opens under, or nothing. Built per scene and only when the author's + * policy asks: the field travels in the export, so a game draws the shadows the editor drew + * rather than one map stretched over everything the camera sees. + */ +function cascadesFor( + scene: Object3D, + policy: RenderPolicy, + onStale: () => void, +): CascadeShadows | null { + // 🛑 The DOCUMENT's engine and not the one drawing: this renderer is always a WebGL one, so + // it could build cascades for a scene the editor refuses them to — and the same document + // would then be lit two ways, which is the one accident `exportRequestOf` exists to prevent. + if (!cascadesWanted(policy, policy.engine)) return null + const cascades = createCascadeShadows(scene, cascadeSettingsFor(policy), onStale) + cascades.dress(scene) + return cascades +} + /** * Sizes the maps and fits the frustums of the scene's own lights — as the editor's `tuneShadows`, * floored on the author's grid — and answers how they throw, what `follow` needs to keep casters. diff --git a/src/renderer/src/hooks/useRenderEngineReady.test.tsx b/src/renderer/src/hooks/useRenderEngineReady.test.tsx new file mode 100644 index 000000000..09c519b6d --- /dev/null +++ b/src/renderer/src/hooks/useRenderEngineReady.test.tsx @@ -0,0 +1,67 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RenderEngine } from '@shared/domain/renderEngine' +import { useRenderEngineReady } from './useRenderEngineReady' + +const gpuModule = vi.hoisted(() => ({ + loaded: null as object | null, + settle: () => {}, +})) + +vi.mock('@/engines/render/gpuModule', () => ({ + loadedGpuModule: () => gpuModule.loaded, + loadGpuModule: () => + new Promise(resolve => { + gpuModule.settle = () => { + gpuModule.loaded = {} + resolve(gpuModule.loaded) + } + }), +})) + +beforeEach(() => { + gpuModule.loaded = null + gpuModule.settle = () => {} +}) + +describe('whether a viewport may be built on an engine yet', () => { + it('never holds the Compatible engine, which needs no bundle at all', () => { + expect(renderHook(() => useRenderEngineReady('gl')).result.current).toBe(true) + }) + + /** + * 🛑 The defect this exists for: a viewport mounted before the Advanced bundle landed was + * handed the Compatible one and never asked again, so a document saved under Advanced drew + * WebGL for the whole session that opened it. + */ + it('holds the Advanced engine until its bundle has landed', async () => { + const { result } = renderHook(() => useRenderEngineReady('gpu')) + expect(result.current).toBe(false) + + gpuModule.settle() + + await waitFor(() => expect(result.current).toBe(true)) + }) + + it('holds nothing once the bundle is already in', () => { + gpuModule.loaded = {} + + expect(renderHook(() => useRenderEngineReady('gpu')).result.current).toBe(true) + }) + + /** + * A tab reads `gl` until its file lands. Latched from that phase, the answer would let the + * Advanced engine mount on a bundle nobody had asked for. + */ + it('holds again when a document turns out to ask for the Advanced engine', () => { + const { result, rerender } = renderHook( + ({ engine }: { engine: RenderEngine }) => useRenderEngineReady(engine), + { initialProps: { engine: 'gl' } }, + ) + expect(result.current).toBe(true) + + rerender({ engine: 'gpu' }) + + expect(result.current).toBe(false) + }) +}) diff --git a/src/renderer/src/hooks/useRenderEngineReady.ts b/src/renderer/src/hooks/useRenderEngineReady.ts new file mode 100644 index 000000000..85fef3c0a --- /dev/null +++ b/src/renderer/src/hooks/useRenderEngineReady.ts @@ -0,0 +1,38 @@ +import { useEffect, useState } from 'react' +import type { RenderEngine } from '@shared/domain/renderEngine' +import { loadedGpuModule, loadGpuModule } from '@/engines/render/gpuModule' + +/** + * Whether a viewport may be BUILT on this engine yet. + * + * 🛑 The Advanced engine is only chosen once its bundle is in, and that bundle is an import of + * its own — `three/webgpu` re-exports the whole library, fetched the first time anything asks. A + * viewport that mounted before it landed was handed the Compatible one and never asked again: + * measured 2026-09-11, a scene saved under Advanced drew WebGL for the whole session that opened + * it, and wrote a fallback to the journal that was not one. + * + * `true` at once for the Compatible engine, and for the Advanced one as soon as the load SETTLES + * — including on « this machine has no adapter », which is a fallback and not a wait. So nothing + * can hang here: the answer always arrives. + */ +export function useRenderEngineReady(engine: RenderEngine): boolean { + const [settled, setSettled] = useState(() => loadedGpuModule() !== null) + + useEffect(() => { + if (settled || engine !== 'gpu') return + + let live = true + const ask = async (): Promise => { + await loadGpuModule() + if (live) setSettled(true) + } + void ask() + return () => { + live = false + } + }, [engine, settled]) + + // Derived rather than latched: a tab reads `gl` until its file lands, and a `ready` left true + // from that phase would mount the Advanced engine on a bundle nobody had asked for. + return engine === 'gl' || settled +} diff --git a/src/renderer/src/stores/scenes.test.ts b/src/renderer/src/stores/scenes.test.ts index 65b12a540..9c4345c7f 100644 --- a/src/renderer/src/stores/scenes.test.ts +++ b/src/renderer/src/stores/scenes.test.ts @@ -169,18 +169,26 @@ describe('seedSceneTemplate', () => { }) it('fills a new document with what its template opens on', () => { - seedSceneTemplate('doc-1', 'topDown') + seedSceneTemplate('doc-1', 'topDown', { engine: 'gl' }) const scene = sceneOf(useScenes.getState(), 'doc-1') expect(scene.nodes.some(node => node.type === 'camera')).toBe(true) expect(scene.world.play.camera).toBe('topDown') }) + // The one answer the creation form takes that no template can give, and the document keeps it + // for good: nothing hands a mounted viewport to the other graphics API. + it('writes the engine the document was created under into its world', () => { + seedSceneTemplate('doc-1', 'basic', { engine: 'gpu' }) + + expect(sceneOf(useScenes.getState(), 'doc-1').world.engine).toBe('gpu') + }) + // The tab may already have been restored from disk by the time this runs on a slow machine, // and a template written over a saved scene would be the work lost. it('never writes over a scene that is already there', () => { useScenes.getState().runCommand('doc-1', addNode(box)) - seedSceneTemplate('doc-1', 'basic') + seedSceneTemplate('doc-1', 'basic', { engine: 'gl' }) expect(sceneOf(useScenes.getState(), 'doc-1').nodes).toEqual([box]) }) diff --git a/src/renderer/src/stores/scenes.ts b/src/renderer/src/stores/scenes.ts index 70af3ffd9..32eefcd74 100644 --- a/src/renderer/src/stores/scenes.ts +++ b/src/renderer/src/stores/scenes.ts @@ -7,6 +7,7 @@ import { newId } from '@/helpers/ids' import { modelNode } from '@/engines/scene/nodeFactory' import { EMPTY_SCENE, type SceneState } from '@/engines/scene/sceneState' import { sceneFromTemplate } from '@/engines/scene/sceneTemplates' +import type { RenderEngine } from '@shared/domain/renderEngine' import type { SceneTemplateId } from '@shared/domain/sceneTemplate' import type { SelectionMode } from '@/helpers/selection' import { useAnimationViews } from './animationView' @@ -21,6 +22,19 @@ export const sceneOf = store.stateOf export const sceneHistoryOf = store.historyOf export const isSceneDirty = store.isDirty +/** What the creation flow settled that a template cannot answer for itself. */ +export type SeededScene = { + /** + * The engine the document is MADE under, written into its world and its own from then on — + * see `SceneWorld.engine`. Seeded here rather than defaulted by the template because this is + * the one moment it can still be chosen. + */ + engine: RenderEngine + /** Where the template's modules were written, so its `Script` components name real paths. */ + scripts?: string + graph?: string +} + /** * Fills a freshly made document with what its template opens on, before any editor mounts. * @@ -30,10 +44,12 @@ export const isSceneDirty = store.isDirty export function seedSceneTemplate( documentId: string, template: SceneTemplateId, - scriptFolder?: string, - graph?: string, + seeded: SeededScene, ): void { - store.use.getState().ensure(documentId, () => sceneFromTemplate(template, scriptFolder, graph)) + store.use.getState().ensure(documentId, () => { + const scene = sceneFromTemplate(template, seeded.scripts, seeded.graph) + return { ...scene, world: { ...scene.world, engine: seeded.engine } } + }) } /** diff --git a/src/shared/domain/newDocument.ts b/src/shared/domain/newDocument.ts index 73ba91cfd..15f3686e6 100644 --- a/src/shared/domain/newDocument.ts +++ b/src/shared/domain/newDocument.ts @@ -1,4 +1,5 @@ import type { DocumentDescriptor, DocumentKind } from './document' +import type { RenderEngine } from './renderEngine' import type { KnownFormat } from './formatCapability' import type { RecentProject } from './project' import type { SceneTemplateId } from './sceneTemplate' @@ -76,6 +77,14 @@ export type NewDocumentAsk = { * itself, and a name that exists only in a tab is nowhere on disk for it to find. */ open: readonly DocumentDescriptor[] + /** + * Which engine the scene field OPENS on — the preference under Settings, and nothing more. + * Carried like the recent projects are: this window holds no settings subscription of its own. + * + * Absent from an ask that draws no such field: a Save as… names an existing document, and a + * file arrival asks where a file belongs. + */ + engine?: RenderEngine } /** @@ -106,6 +115,14 @@ export type NamedDocumentPlace = { * its kind names one file, and the form shows that extension rather than offering it. */ format?: KnownFormat + /** + * The engine a SCENE is made under, absent for every other kind and for a caller that names + * its own document — the assistant, the MCP wire — which takes the preference instead. + * + * Answered here because this is the only moment it can be: the choice is written into the + * document's world and a mounted viewport is never handed to the other API. + */ + engine?: RenderEngine } /** diff --git a/src/shared/domain/postParamSpec.ts b/src/shared/domain/postParamSpec.ts new file mode 100644 index 000000000..e304a2e7a --- /dev/null +++ b/src/shared/domain/postParamSpec.ts @@ -0,0 +1,68 @@ +/** + * How one knob of one effect is SHOWN, and the six shapes a fiche builds one with. + * + * Apart from the catalogue that uses them: `postProcessingRegistry` is the thirty fiches, this is + * the vocabulary they are written in — and a file holding both went past what the size guard + * allows for one module. + */ +import type { FieldValue, PropertySpec } from './propertySpec' + +/** What a parameter holds. The same four shapes the inspector already renders. */ +export type PostParamValue = FieldValue + +/** + * One knob of one effect: how it is shown, what it opens on, and whether the timeline may drive + * it. + * + * A colour is `animatable: false` in this version — a keyframe carries a `Vector3` and a colour + * is stored as a hexadecimal string, so keying one would need a conversion at both ends that + * nothing yet asks for. + */ +export type PostParamSpec = PropertySpec & { default: PostParamValue; animatable: boolean } + +export const slider = ( + min: number, + max: number, + step: number, + value: number, + animatable = true, +): PostParamSpec => ({ control: 'slider', min, max, step, default: value, animatable }) + +export const number = (min: number, max: number, step: number, value: number): PostParamSpec => ({ + control: 'number', + min, + max, + step, + default: value, + animatable: true, +}) + +export const toggle = (value: boolean): PostParamSpec => ({ + control: 'toggle', + default: value, + animatable: false, +}) + +export const colour = (value: string): PostParamSpec => ({ + control: 'color', + default: value, + animatable: false, +}) + +export const choice = (options: readonly string[], value: string): PostParamSpec => ({ + control: 'choice', + options, + labelPrefix: 'postfx.option_', + default: value, + animatable: false, +}) + +export const picture = (value = ''): PostParamSpec => ({ + control: 'asset', + assetType: 'image', + default: value, + animatable: false, +}) + +export const HALFTONE_SHAPES: readonly string[] = ['dot', 'ellipse', 'line', 'square'] +export const BLUR_KINDS: readonly string[] = ['gaussian', 'box'] diff --git a/src/shared/domain/postProcessing.test.ts b/src/shared/domain/postProcessing.test.ts index 85d368f3e..ab76b0003 100644 --- a/src/shared/domain/postProcessing.test.ts +++ b/src/shared/domain/postProcessing.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { boundParam, defaultParamsOf, + effectsForEngine, EMPTY_STACK, planStack, postEffect, @@ -52,6 +53,17 @@ describe('the catalogue', () => { expect(wrong).toEqual([]) }) + // 🛑 A `gpu` written here without a node factory behind it is a slot the Advanced chain + // leaves empty with nothing said. Two have one, both built in `gpuComposer`: the occlusion, + // which the Compatible engine builds too, and the temporal anti-aliaser, which it cannot. + it('names an engine for every effect, and the Advanced one only where a node builds it', () => { + const engines = POST_EFFECT_IDS.map(id => POST_EFFECTS[id].engines) + const advanced = POST_EFFECT_IDS.filter(id => POST_EFFECTS[id].engines.includes('gpu')) + + expect(engines.every(named => named.length > 0)).toBe(true) + expect(advanced).toEqual(['gtao', 'traa']) + }) + it('gives a fresh instance the defaults of its own effect', () => { expect(defaultParamsOf('vignette')).toEqual({ offset: 1, darkness: 1 }) }) @@ -184,3 +196,26 @@ describe('which composition a camera films through', () => { expect(readCameraPost(null, mintId)).toEqual({ mode: 'inherit' }) }) }) + +describe('what each engine is offered, and what it is left out of', () => { + /** + * The library draws this list, and the Advanced chain filters itself by the same call: an + * effect one of them offered and the other could not build would be a row that does nothing. + */ + it('leaves an effect out of the engine that cannot build it', () => { + expect(effectsForEngine('gl')).not.toContain('traa') + expect(effectsForEngine('gpu')).toContain('traa') + }) + + it('offers the occlusion on both, which is what makes the two comparable at all', () => { + expect(effectsForEngine('gl')).toContain('gtao') + expect(effectsForEngine('gpu')).toContain('gtao') + }) + + // The order is what a picker draws; a filter that sorted would reorder the library silently. + it('keeps the order the catalogue declares', () => { + const offered = effectsForEngine('gl') + + expect(offered).toEqual(POST_EFFECT_IDS.filter(id => offered.includes(id))) + }) +}) diff --git a/src/shared/domain/postProcessing.ts b/src/shared/domain/postProcessing.ts index 453a5ecc8..1b4801b3b 100644 --- a/src/shared/domain/postProcessing.ts +++ b/src/shared/domain/postProcessing.ts @@ -13,6 +13,7 @@ import { } from './postProcessingRegistry' export * from './postProcessingRegistry' +export * from './postProcessingEngines' /** One effect placed in a stack. `id` is the INSTANCE — what a keyframe aims at. */ export type PostEffect = { diff --git a/src/shared/domain/postProcessingEngines.ts b/src/shared/domain/postProcessingEngines.ts new file mode 100644 index 000000000..52dd8ba6e --- /dev/null +++ b/src/shared/domain/postProcessingEngines.ts @@ -0,0 +1,54 @@ +/** + * What each ENGINE can build of the catalogue, and what a surface drawn once has to leave out. + * + * Apart from the catalogue itself, which is a table of fiches: these are the three questions the + * two chains and the effect library all ask of it, and asking them by hand is how one caller + * ends up offering what another cannot draw. + */ +import { POST_EFFECTS, POST_EFFECT_IDS, type PostEffectId } from './postProcessingRegistry' +import type { RenderEngine } from './renderEngine' + +/** + * The ids the Compatible engine cannot build, spelled as a TYPE and not only read off `engines`. + * + * `engines` is data, and the tables that implement an effect are `Record`s keyed on the union — + * which is what makes a new effect fail to compile until somebody writes its pass. A GPU-only one + * has no GL pass to write, so it is excluded from that door here; `postFactories.test.ts` holds + * the two readings to the same answer. + */ +export type GpuOnlyEffectId = 'traa' + +/** Whether an engine can build that effect at all. The registry answers, and nothing else does. */ +export function runsOnEngine(effect: PostEffectId, engine: RenderEngine): boolean { + return POST_EFFECTS[effect].engines.includes(engine) +} + +/** + * Whether an effect can be built for a surface drawn ONCE — a still, an export, a thumbnail. + * + * A temporal one cannot: it resolves the picture against the frames before it, and a chain built, + * drawn and freed has none. Left in, it draws a flat colour; left out, the picture is simply not + * anti-aliased that way, which is what an export of it has always been. + */ +export function survivesOneShot(effect: PostEffectId): boolean { + return POST_EFFECTS[effect].temporal !== true +} + +/** + * The catalogue an engine can actually build, in the order the registry declares them. + * + * What a LIBRARY offers, and what a chain keeps out of itself: offering an effect the chain will + * silently leave out is offering nothing, and a chain that tried to build one would throw where + * a document is simply carrying more than this engine knows. + * + * Worked out ONCE: the catalogue is frozen at load and there are two engines, so an answer built + * per call would be a fresh array nobody could use as a memo dependency. + */ +export function effectsForEngine(engine: RenderEngine): readonly PostEffectId[] { + return BY_ENGINE[engine] +} + +const BY_ENGINE: Record = { + gl: POST_EFFECT_IDS.filter(id => runsOnEngine(id, 'gl')), + gpu: POST_EFFECT_IDS.filter(id => runsOnEngine(id, 'gpu')), +} diff --git a/src/shared/domain/postProcessingRegistry.ts b/src/shared/domain/postProcessingRegistry.ts index 0e814ba76..7b8c63b19 100644 --- a/src/shared/domain/postProcessingRegistry.ts +++ b/src/shared/domain/postProcessingRegistry.ts @@ -6,20 +6,20 @@ * those may pull three.js in, so nothing here knows a `Pass` exists — `engines/postfx/` is the * one folder that does. */ -import type { FieldValue, PropertySpec } from './propertySpec' +import { GL_ONLY, GPU_ONLY, RENDER_ENGINES, type RenderEngine } from './renderEngine' +import { + BLUR_KINDS, + HALFTONE_SHAPES, + choice, + colour, + number, + picture, + slider, + toggle, + type PostParamSpec, +} from './postParamSpec' -/** What a parameter holds. The same four shapes the inspector already renders. */ -export type PostParamValue = FieldValue - -/** - * One knob of one effect: how it is shown, what it opens on, and whether the timeline may drive - * it. - * - * A colour is `animatable: false` in this version — a keyframe carries a `Vector3` and a colour - * is stored as a hexadecimal string, so keying one would need a conversion at both ends that - * nothing yet asks for. - */ -export type PostParamSpec = PropertySpec & { default: PostParamValue; animatable: boolean } +export { HALFTONE_SHAPES, type PostParamSpec, type PostParamValue } from './postParamSpec' export type PostCategory = 'lighting' | 'lens' | 'light' | 'color' | 'image' | 'film' | 'stylized' | 'aa' @@ -90,13 +90,26 @@ export type PostEffectId = | 'vhs' | 'fxaa' | 'smaa' + | 'traa' export type PostEffectMeta = { category: PostCategory cost: PostCost slot: PostSlot + /** + * Which engines can actually build it. The SLOT is the same on both sides — a GPU occlusion + * occupies the `ao` slot its GL twin occupies — so this says nothing about where an effect + * sits in the chain, only about who can make one. + */ + engines: readonly RenderEngine[] /** Whether two of them in one stack mean anything. An anti-aliaser twice does not. */ duplicable: boolean + /** + * Whether it resolves against the FRAMES BEFORE IT, so cannot be built for a surface drawn + * once. 🛑 Handed a history it has not filled, such a node draws a FLAT COLOUR — measured + * 2026-09-11. See `survivesOneShot`, which `gpuComposer` reads. + */ + temporal?: boolean /** * Whether it works ABOVE white — a bloom thresholds highlights, a defocus spreads them, an * opened exposure pulls values back from over one. On bytes all three read as clipping, so the @@ -107,53 +120,6 @@ export type PostEffectMeta = { params: Readonly> } -const slider = ( - min: number, - max: number, - step: number, - value: number, - animatable = true, -): PostParamSpec => ({ control: 'slider', min, max, step, default: value, animatable }) - -const number = (min: number, max: number, step: number, value: number): PostParamSpec => ({ - control: 'number', - min, - max, - step, - default: value, - animatable: true, -}) - -const toggle = (value: boolean): PostParamSpec => ({ - control: 'toggle', - default: value, - animatable: false, -}) - -const colour = (value: string): PostParamSpec => ({ - control: 'color', - default: value, - animatable: false, -}) - -const choice = (options: readonly string[], value: string): PostParamSpec => ({ - control: 'choice', - options, - labelPrefix: 'postfx.option_', - default: value, - animatable: false, -}) - -const picture = (value = ''): PostParamSpec => ({ - control: 'asset', - assetType: 'image', - default: value, - animatable: false, -}) - -export const HALFTONE_SHAPES: readonly string[] = ['dot', 'ellipse', 'line', 'square'] -const BLUR_KINDS: readonly string[] = ['gaussian', 'box'] - /** * Every effect the studio knows, and everything a panel needs to draw one. * @@ -165,6 +131,9 @@ export const POST_EFFECTS: Record = { category: 'lighting', cost: 'high', slot: 'ao', + // The one effect both engines build: GLSL `GTAOPass` against the native `ao()` node. Same + // slot, same exclusivity, same parameters — and `engines:parity` compares the two pictures. + engines: RENDER_ENGINES, duplicable: false, params: { radius: slider(0.01, 2, 0.01, 0.25), @@ -179,6 +148,7 @@ export const POST_EFFECTS: Record = { category: 'lighting', cost: 'high', slot: 'ao', + engines: GL_ONLY, duplicable: false, params: { radius: slider(0.01, 32, 0.01, 8), @@ -190,6 +160,7 @@ export const POST_EFFECTS: Record = { category: 'aa', cost: 'high', slot: 'render', + engines: GL_ONLY, duplicable: false, params: { level: number(1, 4, 1, 2) }, }, @@ -198,6 +169,7 @@ export const POST_EFFECTS: Record = { category: 'light', cost: 'medium', slot: 'image', + engines: GL_ONLY, duplicable: true, params: { strength: slider(0, 4, 0.01, 0.6), @@ -210,6 +182,7 @@ export const POST_EFFECTS: Record = { category: 'lens', cost: 'high', slot: 'image', + engines: GL_ONLY, duplicable: false, params: { focusDistance: number(0.01, 1000, 0.01, 10), @@ -221,6 +194,7 @@ export const POST_EFFECTS: Record = { category: 'lens', cost: 'low', slot: 'image', + engines: GL_ONLY, duplicable: true, params: { amount: slider(0, 0.05, 0.0005, 0.003), @@ -231,6 +205,7 @@ export const POST_EFFECTS: Record = { category: 'lens', cost: 'low', slot: 'image', + engines: GL_ONLY, duplicable: false, params: { distortion: slider(-0.5, 0.5, 0.005, 0.1), @@ -243,6 +218,7 @@ export const POST_EFFECTS: Record = { category: 'lens', cost: 'low', slot: 'image', + engines: GL_ONLY, duplicable: true, params: { amount: slider(0, 0.05, 0.001, 0.008), @@ -255,6 +231,7 @@ export const POST_EFFECTS: Record = { category: 'color', cost: 'low', slot: 'image', + engines: GL_ONLY, duplicable: true, params: { exposure: slider(-4, 4, 0.01, 0), @@ -273,6 +250,7 @@ export const POST_EFFECTS: Record = { category: 'color', cost: 'low', slot: 'image', + engines: GL_ONLY, duplicable: false, params: { texture: picture(), @@ -283,6 +261,7 @@ export const POST_EFFECTS: Record = { category: 'image', cost: 'low', slot: 'image', + engines: GL_ONLY, duplicable: true, params: { amount: slider(0, 3, 0.01, 0.5) }, }, @@ -290,6 +269,7 @@ export const POST_EFFECTS: Record = { category: 'image', cost: 'medium', slot: 'image', + engines: GL_ONLY, duplicable: true, params: { kind: choice(BLUR_KINDS, 'gaussian'), @@ -301,6 +281,7 @@ export const POST_EFFECTS: Record = { category: 'image', cost: 'medium', slot: 'image', + engines: GL_ONLY, duplicable: true, params: { amount: slider(0, 1, 0.01, 0.25), @@ -315,6 +296,7 @@ export const POST_EFFECTS: Record = { category: 'image', cost: 'low', slot: 'image', + engines: GL_ONLY, duplicable: false, params: { size: number(1, 64, 1, 6) }, }, @@ -322,6 +304,7 @@ export const POST_EFFECTS: Record = { category: 'image', cost: 'low', slot: 'image', + engines: GL_ONLY, duplicable: false, params: { levels: number(2, 64, 1, 8) }, }, @@ -329,6 +312,7 @@ export const POST_EFFECTS: Record = { category: 'image', cost: 'low', slot: 'image', + engines: GL_ONLY, duplicable: false, params: { amount: slider(0, 1, 0.01, 0.5), levels: number(2, 32, 1, 8) }, }, @@ -336,6 +320,7 @@ export const POST_EFFECTS: Record = { category: 'film', cost: 'low', slot: 'image', + engines: GL_ONLY, duplicable: true, params: { offset: slider(0, 3, 0.01, 1), @@ -347,6 +332,7 @@ export const POST_EFFECTS: Record = { category: 'film', cost: 'low', slot: 'image', + engines: GL_ONLY, duplicable: false, params: { aspect: slider(1, 3, 0.01, 2.39), @@ -357,6 +343,7 @@ export const POST_EFFECTS: Record = { category: 'film', cost: 'low', slot: 'image', + engines: GL_ONLY, duplicable: false, params: { intensity: slider(0, 1, 0.01, 0.3), @@ -369,6 +356,7 @@ export const POST_EFFECTS: Record = { category: 'film', cost: 'low', slot: 'image', + engines: GL_ONLY, duplicable: false, params: { intensity: slider(0, 1, 0.01, 0.3), @@ -379,6 +367,7 @@ export const POST_EFFECTS: Record = { category: 'stylized', cost: 'medium', slot: 'image', + engines: GL_ONLY, duplicable: false, params: { thickness: slider(0.5, 4, 0.1, 1), @@ -391,6 +380,7 @@ export const POST_EFFECTS: Record = { category: 'stylized', cost: 'medium', slot: 'image', + engines: GL_ONLY, duplicable: false, params: { shape: choice(HALFTONE_SHAPES, 'dot'), @@ -403,6 +393,7 @@ export const POST_EFFECTS: Record = { category: 'stylized', cost: 'low', slot: 'image', + engines: GL_ONLY, duplicable: false, params: { scale: slider(0.1, 4, 0.05, 0.8), @@ -414,6 +405,7 @@ export const POST_EFFECTS: Record = { category: 'stylized', cost: 'high', slot: 'image', + engines: GL_ONLY, duplicable: false, params: { radius: number(1, 6, 1, 3) }, }, @@ -421,6 +413,7 @@ export const POST_EFFECTS: Record = { category: 'stylized', cost: 'low', slot: 'image', + engines: GL_ONLY, duplicable: false, params: { wild: toggle(false) }, }, @@ -428,6 +421,7 @@ export const POST_EFFECTS: Record = { category: 'stylized', cost: 'low', slot: 'image', + engines: GL_ONLY, duplicable: true, params: { amount: slider(0, 0.05, 0.0005, 0.0015), @@ -438,6 +432,7 @@ export const POST_EFFECTS: Record = { category: 'stylized', cost: 'low', slot: 'image', + engines: GL_ONLY, duplicable: false, params: { curvature: slider(0, 1, 0.01, 0.25), @@ -450,6 +445,7 @@ export const POST_EFFECTS: Record = { category: 'stylized', cost: 'low', slot: 'image', + engines: GL_ONLY, duplicable: false, params: { bleed: slider(0, 0.05, 0.0005, 0.006), @@ -462,6 +458,7 @@ export const POST_EFFECTS: Record = { category: 'aa', cost: 'low', slot: 'aa', + engines: GL_ONLY, duplicable: false, params: {}, }, @@ -469,14 +466,33 @@ export const POST_EFFECTS: Record = { category: 'aa', cost: 'medium', slot: 'aa', + engines: GL_ONLY, + duplicable: false, + params: {}, + }, + /** + * Temporal reprojection: the picture is jittered by a sub-pixel offset each frame and the + * previous frames are reprojected onto it through the scene's velocity. It removes every edge, + * including the ones inside a texture that no edge filter can see. + * + * No sample count, and that is three.js and not an omission: a `TRAANode`'s samples are FRAMES, + * one per jitter of a fixed sequence. Its only quality lever is the sub-pixel correction, which + * the budget holds — see `gpuPostQuality`. + */ + traa: { + category: 'aa', + cost: 'medium', + slot: 'aa', + engines: GPU_ONLY, duplicable: false, + // Its whole method: the previous frames are reprojected onto this one. + temporal: true, params: {}, }, } -export const POST_EFFECT_IDS: readonly PostEffectId[] = Object.keys( - POST_EFFECTS, -) as readonly PostEffectId[] +// `as`: `Object.keys` widens to `string[]`, and the object it walks is keyed on the union. +export const POST_EFFECT_IDS = Object.keys(POST_EFFECTS) as readonly PostEffectId[] export function isPostEffectId(value: unknown): value is PostEffectId { return typeof value === 'string' && value in POST_EFFECTS diff --git a/src/shared/domain/renderEngine.ts b/src/shared/domain/renderEngine.ts new file mode 100644 index 000000000..56701fa48 --- /dev/null +++ b/src/shared/domain/renderEngine.ts @@ -0,0 +1,16 @@ +/** + * Which engine draws — the studio's two words for WebGL and WebGPU. + * + * A module of its own, and small on purpose: a render policy names one, and so does every + * post-processing effect. Declared inside either of them, the other would have to import it and + * `renderPolicy → scene → postProcessing → postProcessingRegistry` would close into a cycle. + */ +export type RenderEngine = 'gl' | 'gpu' + +export const RENDER_ENGINES: readonly RenderEngine[] = ['gl', 'gpu'] + +/** What every effect written before the Advanced engine existed runs on, and only that. */ +export const GL_ONLY: readonly RenderEngine[] = ['gl'] + +/** What only a node chain can build. The Compatible engine leaves these out of its catalogue. */ +export const GPU_ONLY: readonly RenderEngine[] = ['gpu'] diff --git a/src/shared/domain/renderPolicy.test.ts b/src/shared/domain/renderPolicy.test.ts new file mode 100644 index 000000000..20b7e0cc9 --- /dev/null +++ b/src/shared/domain/renderPolicy.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { readRenderPolicy } from './renderPolicy' + +describe('a render policy read off a manifest', () => { + it('plays an export written before either option existed as it was authored', () => { + const held = readRenderPolicy({ shadows: true, shadowQuality: 'soft', shadowMapSize: 1024 }) + + expect(held.engine).toBe('gl') + expect(held.csm).toBe(false) + }) + + it('refuses an engine this build has never heard of rather than drawing nothing', () => { + expect(readRenderPolicy({ engine: 'vulkan' }).engine).toBe('gl') + }) + + it('carries the engine an export names', () => { + expect(readRenderPolicy({ engine: 'gpu' }).engine).toBe('gpu') + }) +}) diff --git a/src/shared/domain/renderPolicy.ts b/src/shared/domain/renderPolicy.ts index 1ff23be6c..8414ca45b 100644 --- a/src/shared/domain/renderPolicy.ts +++ b/src/shared/domain/renderPolicy.ts @@ -1,4 +1,5 @@ import { isRecord, oneOf, readBoolean, readNumber } from '../guards' +import { RENDER_ENGINES, type RenderEngine } from './renderEngine' import { SHADOW_QUALITIES, type ShadowQuality } from './scene' import { VIEWPORT_QUALITIES, type ViewportQuality } from './sceneViewport' @@ -11,10 +12,27 @@ import { VIEWPORT_QUALITIES, type ViewportQuality } from './sceneViewport' * while an exported game drew none at all and paid the screen's whole pixel ratio. */ export type RenderPolicy = { + /** + * Which engine draws the frame. Read once, when a viewport builds its renderer: there is no + * switching a mounted one, the whole scene living inside a GPU context that cannot be handed + * over. A machine with no WebGPU adapter falls back to `gl` and says so — see `renderDriver`. + */ + engine: RenderEngine shadows: boolean shadowQuality: ShadowQuality /** Side of the square map each casting light allocates, before the quality level caps it. */ shadowMapSize: number + /** + * Whether a sun's shadow is split into cascades — one map per depth band of the view rather + * than one map over the whole set. What an open world needs and a single set never does: the + * one map a directional light owns is stretched over the whole frustum, so a distance that + * doubles halves the texels a shadow near the camera gets. + * + * OFF by default, and it is not a taste: cascades replace the sun with three lights of their + * own and patch every material that receives them, so a scene that was fine without them must + * not inherit them — see `csm.ts`. + */ + csm: boolean /** How finely the frame is drawn — it moves `pixelRatio` and caps the shadow maps. */ quality: ViewportQuality /** Vertical field of view, in degrees. The editor reads it off the same setting. */ @@ -50,9 +68,11 @@ export const SCATTER_DISTANCE = VIEW_DISTANCE * The viewport's own defaults, so the two sides open on the same picture. */ export const DEFAULT_RENDER_POLICY: RenderPolicy = Object.freeze({ + engine: 'gl', shadows: true, shadowQuality: 'soft', shadowMapSize: 2048, + csm: false, quality: 'balanced', fieldOfView: 60, gridSize: 20, @@ -61,12 +81,21 @@ export const DEFAULT_RENDER_POLICY: RenderPolicy = Object.freeze({ /** * The values, taken off the larger object a viewport reads: an export carries these and not * the twenty settings that only mean something in front of an editor. + * + * `engine` is a PARAMETER because it stopped being a preference the day a scene started carrying + * its own: an export names the one its document holds. Spread over the result instead, a third + * caller would forget to — see `SceneWorld.engine`. */ -export function renderPolicyOf(view: RenderPolicy): RenderPolicy { +export function renderPolicyOf( + view: RenderPolicy, + engine: RenderEngine = view.engine, +): RenderPolicy { return { + engine, shadows: view.shadows, shadowQuality: view.shadowQuality, shadowMapSize: view.shadowMapSize, + csm: view.csm, quality: view.quality, fieldOfView: view.fieldOfView, gridSize: view.gridSize, @@ -83,6 +112,7 @@ export function renderPolicyOf(view: RenderPolicy): RenderPolicy { export function readRenderPolicy(value: unknown): RenderPolicy { if (!isRecord(value)) return { ...DEFAULT_RENDER_POLICY } return { + engine: oneOf(RENDER_ENGINES, value.engine, DEFAULT_RENDER_POLICY.engine), shadows: readBoolean(value, 'shadows', DEFAULT_RENDER_POLICY.shadows), shadowQuality: oneOf( SHADOW_QUALITIES, @@ -90,6 +120,7 @@ export function readRenderPolicy(value: unknown): RenderPolicy { DEFAULT_RENDER_POLICY.shadowQuality, ), shadowMapSize: readNumber(value, 'shadowMapSize', DEFAULT_RENDER_POLICY.shadowMapSize), + csm: readBoolean(value, 'csm', DEFAULT_RENDER_POLICY.csm), quality: oneOf(VIEWPORT_QUALITIES, value.quality, DEFAULT_RENDER_POLICY.quality), fieldOfView: readNumber(value, 'fieldOfView', DEFAULT_RENDER_POLICY.fieldOfView), gridSize: readNumber(value, 'gridSize', DEFAULT_RENDER_POLICY.gridSize), diff --git a/src/shared/domain/scene.ts b/src/shared/domain/scene.ts index ae6aeabcc..e132699e6 100644 --- a/src/shared/domain/scene.ts +++ b/src/shared/domain/scene.ts @@ -1,5 +1,6 @@ import type { FontRef } from './font' import { EMPTY_STACK, type PostStack } from './postProcessing' +import type { RenderEngine } from './renderEngine' import { RELIEF_CHUNK_TEXELS } from './relief' import type { Vector3 } from './transform' import type { GeometryDescriptor } from './geometry' @@ -119,10 +120,10 @@ export const DEFAULT_EXP2_FOG: Exp2Fog = Object.freeze({ }) /** - * How high dynamic range is brought down to a screen. The five three.js 0.185 actually maps — - * a sixth word here would be a control that changes nothing. + * How high dynamic range is brought down to a screen. The six three.js 0.185 actually maps — + * a seventh word here would be a control that changes nothing. */ -export type ToneMapping = 'none' | 'linear' | 'reinhard' | 'cineon' | 'aces' +export type ToneMapping = 'none' | 'linear' | 'reinhard' | 'cineon' | 'aces' | 'agx' export const TONE_MAPPINGS: readonly ToneMapping[] = [ 'none', @@ -130,6 +131,7 @@ export const TONE_MAPPINGS: readonly ToneMapping[] = [ 'reinhard', 'cineon', 'aces', + 'agx', ] /** @@ -259,6 +261,15 @@ export const GRAVITY = Object.freeze({ min: 0, max: 50, step: 0.01 }) * document written without it changes nothing on screen. */ export type SceneWorld = { + /** + * Which engine draws this document, chosen when it was CREATED and belonging to it from then + * on — the preference under Settings only pre-fills that form. + * + * Here and not in `Settings.three` because the lock is the point: a whole scene lives inside + * one graphics context, so nothing hands a mounted viewport over to the other API. A setting + * the studio re-read would promise a switch it cannot make. + */ + engine: RenderEngine environment: EnvironmentRef /** Multiplies both what the environment lights with and what it draws behind the scene. */ envIntensity: number @@ -281,6 +292,9 @@ export type SceneWorld = { } export const DEFAULT_WORLD: SceneWorld = Object.freeze({ + // Same reasoning as `toneMapping` below: this object is what a document READ falls back to, + // and a file that says nothing about its engine was drawn with WebGL. + engine: 'gl', environment: STUDIO_ENVIRONMENT, envIntensity: 1, envRotation: 0, diff --git a/src/shared/domain/settingsRegistrySecond.ts b/src/shared/domain/settingsRegistrySecond.ts index 396655afd..8d0f94bcb 100644 --- a/src/shared/domain/settingsRegistrySecond.ts +++ b/src/shared/domain/settingsRegistrySecond.ts @@ -1,4 +1,5 @@ import { DICTATION_MODES } from './dictation' +import { RENDER_ENGINES } from './renderEngine' import { LOG_VERBOSITIES } from './settings' import { DISPLAY_UNITS, SHADOW_MAP_SIZES, SHADOW_QUALITIES, VIEWPORT_QUALITIES } from './scene' import { setting } from './settingDescriptor' @@ -60,6 +61,17 @@ export const SETTING_REGISTRY_SECOND = [ max: 1, step: 0.01, }), + setting({ + path: 'three.engine', + kind: 'choice', + section: 'spaces.three', + titleKey: 'settings.renderEngine.title', + helpKey: 'settings.renderEngine.help', + options: RENDER_ENGINES.map(value => ({ + value, + labelKey: `settings.renderEngine.${value}`, + })), + }), setting({ path: 'three.shadows', kind: 'boolean', @@ -90,6 +102,14 @@ export const SETTING_REGISTRY_SECOND = [ options: SHADOW_MAP_SIZES.map(value => ({ value, label: String(value) })), dependsOn: { path: 'three.shadows', equals: true }, }), + setting({ + path: 'three.csm', + kind: 'boolean', + section: 'spaces.three', + titleKey: 'settings.csm.title', + helpKey: 'settings.csm.help', + dependsOn: { path: 'three.shadows', equals: true }, + }), setting({ path: 'three.quality', kind: 'choice', diff --git a/src/shared/i18n/ar/assets.json b/src/shared/i18n/ar/assets.json index d52d49dab..7492abb3a 100644 --- a/src/shared/i18n/ar/assets.json +++ b/src/shared/i18n/ar/assets.json @@ -240,6 +240,10 @@ "deleteHint": "يزيل المستند من المشروع، بما في ذلك ملفه", "nameField": "اسم", "templateField": "قالب البداية", + "engines": { + "gl": "متوافق — يرسم على كل الأجهزة", + "gpu": "متقدّم — إضاءة وانعكاسات أفضل، ويتطلّب بطاقة رسوميات حديثة" + }, "templateGroups": { "general": "عام", "character": "شخصية", diff --git a/src/shared/i18n/ar/diagnostics.json b/src/shared/i18n/ar/diagnostics.json index 22cadfbd5..d63302285 100644 --- a/src/shared/i18n/ar/diagnostics.json +++ b/src/shared/i18n/ar/diagnostics.json @@ -16,6 +16,9 @@ "workerStopped": "توقفت المهمة الخلفية", "csgGraphMissing": "لا يوجد مخطط CSG مسجل للعنصر {{name}}", "shaderAnchorMissing": "لم يعد محرّك الرسم يوفر {{name}}", + "renderEngineUnavailable": "محرّك «متقدّم» لم يُبنَ بعد", + "renderEngineGradingMissing": "محرّك «متقدّم» يعرض سماءً مصحَّحة كما يحتويها ملفها", + "renderEngineCascadesMissing": "محرّك «متقدّم» لا يرسم ظلالًا متدرّجة", "channelShaderMissing": "لا يوجد مظلّل يشتق القناة {{channel}}", "channelSourceEmpty": "مصدر القناة {{channel}} لا يحتوي على أي بكسل", "passSourceMissing": "تحتاج مرحلة الرسم إلى مصدر تقرأ منه", diff --git a/src/shared/i18n/ar/environment.json b/src/shared/i18n/ar/environment.json index 8ff32af34..a6ec1a4aa 100644 --- a/src/shared/i18n/ar/environment.json +++ b/src/shared/i18n/ar/environment.json @@ -108,6 +108,8 @@ "tone_cineonHint": "منحنى مأخوذ من الفيلم السينمائي، بتباين أعلى", "tone_aces": "ACES", "tone_acesHint": "منحنى السينما الرقمية، وهو الذي يمنع الإضاءة العالية من الانقطاع إلى الأبيض الخالص", + "tone_agx": "AgX", + "tone_agxHint": "منحنى فيلمي أحدث، يحافظ على اللون المشبع وهو يسطع، حيث يحرفه ACES", "exposure": "تعريض", "quality": "جودة", "quality_performance": "أداء", diff --git a/src/shared/i18n/ar/postfx.json b/src/shared/i18n/ar/postfx.json index 023efa151..4c1e334dd 100644 --- a/src/shared/i18n/ar/postfx.json +++ b/src/shared/i18n/ar/postfx.json @@ -63,7 +63,9 @@ "effect_sharpen": "زيادة الحدة", "effect_sharpenHint": "يبرز التفاصيل الدقيقة عبر قناع غير حاد", "effect_smaa": "منع تسنّن SMAA", + "effect_traa": "منع تسنّن TRAA", "effect_smaaHint": "حواف أنظف من FXAA، بكلفة أعلى قليلًا", + "effect_traaHint": "يلطّف كل حافة اعتمادًا على الصور السابقة، ويتطلّب المحرّك المتقدّم", "effect_ssaa": "فرط أخذ العينات", "effect_ssaaHint": "يرسم المشهد عدة مرات ويأخذ متوسطها، لأنظف الحواف", "effect_ssao": "انحجاب محيطي (SSAO)", diff --git a/src/shared/i18n/ar/settings.json b/src/shared/i18n/ar/settings.json index 7780b5848..b0bba22e5 100644 --- a/src/shared/i18n/ar/settings.json +++ b/src/shared/i18n/ar/settings.json @@ -246,6 +246,12 @@ "title": "تسريع", "help": "العدد الذي تُضرب فيه السرعة ما دمت ممسكًا بمفتاح التسريع. عند 3 تمضي أسرع بثلاث مرات — وهو ما يكفي لعبور مشهد كبير دون تغيير الإعداد أعلاه." }, + "renderEngine": { + "title": "محرّك العرض", + "help": "المحرّك الذي تفتح به المشاهد الجديدة. يُطرح السؤال عند إنشاء كل مستند، ويُكتب الجواب في المستند نفسه: هذا الإعداد يملأ الحقل مسبقًا فحسب، ولا يغيّر أي مشهد موجود. «متوافق» يعمل على كل الأجهزة، و«متقدّم» يعطي إضاءة وانعكاسات أفضل ويتطلّب بطاقة رسوميات حديثة. الجهاز الذي لا يملك مهايئ WebGPU يعود من تلقاء نفسه إلى «متوافق»، ويسجّل ذلك في السجل.", + "gl": "متوافق", + "gpu": "متقدّم" + }, "shadows": { "title": "ظلال مسقطة", "help": "يحسب الظلال التي تلقيها الأضواء. وكل ضوء يلقي ظلًّا يكلّف مرور تصيير إضافيًا لكل إطار: وإيقاف هذا الخيار أقصر طريق لتخفيف مشهد ثقيل." @@ -274,6 +280,10 @@ "title": "دقة الظلال", "help": "ضلع خريطة الظل، بالبكسل، التي يحسبها كل ضوء يلقي ظلًّا. ومضاعفته تكلّف أربعة أضعاف الذاكرة: 2048 يكفي لمشهد من بضعة كائنات، و4096 يستحق ثمنه حين يغطي ضوء واحد ديكورًا كاملًا." }, + "csm": { + "title": "ظلال متدرّجة", + "help": "يقسّم ظل الشمس إلى ثلاث خرائط، واحدة لكل نطاق عمق من المشهد، بدل خريطة واحدة ممدودة على كل ما تراه الكاميرا. ما يحتاجه منظر مفتوح؛ أما مشهد واحد فلا يكسب شيئًا ويدفع ثلاث تمريرات عمق." + }, "snapTranslate": { "title": "خطوة النقل", "help": "كم يقطع الكائن في خطوة واحدة والتجاذب مفعّل، بالأمتار. أما التجاذب نفسه فيُشغَّل من شريط أدوات المشهد؛ وهذه القيمة تقول مدى دقته فقط." diff --git a/src/shared/i18n/de/assets.json b/src/shared/i18n/de/assets.json index 0416ca93d..a7202fb07 100644 --- a/src/shared/i18n/de/assets.json +++ b/src/shared/i18n/de/assets.json @@ -219,6 +219,10 @@ "deleteHint": "Entfernt das Dokument aus dem Projekt, seine Datei eingeschlossen", "nameField": "Name", "templateField": "Ausgangsvorlage", + "engines": { + "gl": "Kompatibel — zeichnet auf jedem Rechner", + "gpu": "Erweitert — bessere Beleuchtung und Spiegelungen, verlangt eine aktuelle Grafikkarte" + }, "templateGroups": { "general": "Allgemein", "character": "Charakter", diff --git a/src/shared/i18n/de/diagnostics.json b/src/shared/i18n/de/diagnostics.json index 1462cefb0..8ffb79ab3 100644 --- a/src/shared/i18n/de/diagnostics.json +++ b/src/shared/i18n/de/diagnostics.json @@ -16,6 +16,9 @@ "workerStopped": "Die Hintergrundaufgabe wurde gestoppt", "csgGraphMissing": "Für {{name}} ist kein CSG-Graph gespeichert", "shaderAnchorMissing": "Der Renderer stellt {{name}} nicht mehr bereit", + "renderEngineUnavailable": "Die erweiterte Engine ist noch nicht gebaut", + "renderEngineGradingMissing": "Die erweiterte Engine zeigt einen korrigierten Himmel so, wie seine Datei ihn enthält", + "renderEngineCascadesMissing": "Die erweiterte Engine zeichnet keine kaskadierten Schatten", "channelShaderMissing": "Kein Shader leitet den Kanal {{channel}} ab", "channelSourceEmpty": "Die Quelle des Kanals {{channel}} enthält keine Pixel", "passSourceMissing": "Dieser Durchgang benötigt eine Quelle zum Lesen", diff --git a/src/shared/i18n/de/environment.json b/src/shared/i18n/de/environment.json index 9f0da2cf4..a5ffeb761 100644 --- a/src/shared/i18n/de/environment.json +++ b/src/shared/i18n/de/environment.json @@ -108,6 +108,8 @@ "tone_cineonHint": "Eine Kurve aus dem Filmmaterial, mit mehr Kontrast", "tone_aces": "ACES", "tone_acesHint": "Die Kurve des digitalen Kinos, die verhindert, dass ein Glanzlicht auf reines Weiß beschnitten wird", + "tone_agx": "AgX", + "tone_agxHint": "Eine neuere Filmkurve, die eine gesättigte Farbe beim Aufhellen hält, wo ACES sie kippen lässt", "exposure": "Belichtung", "quality": "Qualität", "quality_performance": "Leistung", diff --git a/src/shared/i18n/de/postfx.json b/src/shared/i18n/de/postfx.json index fcce0f824..2f0ce8295 100644 --- a/src/shared/i18n/de/postfx.json +++ b/src/shared/i18n/de/postfx.json @@ -63,7 +63,9 @@ "effect_sharpen": "Schärfen", "effect_sharpenHint": "Hebt feine Details über eine Unscharfmaskierung hervor", "effect_smaa": "SMAA-Kantenglättung", + "effect_traa": "TRAA-Kantenglättung", "effect_smaaHint": "Sauberere Kanten als FXAA, für etwas mehr Aufwand", + "effect_traaHint": "Glättet jede Kante anhand der vorherigen Bilder; verlangt die erweiterte Engine", "effect_ssaa": "Supersampling", "effect_ssaaHint": "Zeichnet die Szene mehrfach und mittelt sie, für die saubersten Kanten", "effect_ssao": "Umgebungsokklusion (SSAO)", diff --git a/src/shared/i18n/de/settings.json b/src/shared/i18n/de/settings.json index 019c7f7ab..8e9f05895 100644 --- a/src/shared/i18n/de/settings.json +++ b/src/shared/i18n/de/settings.json @@ -246,6 +246,12 @@ "title": "Beschleunigung", "help": "Womit die Geschwindigkeit multipliziert wird, solange Sie die Beschleunigungstaste halten. Bei 3 fliegen Sie dreimal so schnell — genug, um eine große Szene zu durchqueren, ohne die Einstellung darüber zu ändern." }, + "renderEngine": { + "title": "Render-Engine", + "help": "Womit neue Szenen geöffnet werden. Die Frage wird bei jeder Dokumenterstellung gestellt und die Antwort im Dokument selbst abgelegt: diese Einstellung füllt das Feld nur vor und ändert keine bestehende Szene. Kompatibel läuft auf jedem Rechner; Erweitert liefert bessere Beleuchtung und Spiegelungen und verlangt eine aktuelle Grafikkarte. Ein Rechner ohne WebGPU-Adapter fällt von selbst auf Kompatibel zurück und vermerkt das im Journal.", + "gl": "Kompatibel", + "gpu": "Erweitert" + }, "shadows": { "title": "Schattenwurf", "help": "Berechnet die Schatten, die die Lichter werfen. Jedes Licht, das einen wirft, kostet einen zusätzlichen Renderdurchgang pro Frame: diese Option abzuschalten ist der direkteste Weg, eine schwere Szene zu entlasten." @@ -274,6 +280,10 @@ "title": "Schattendetail", "help": "Die Kantenlänge in Pixeln der Shadow-Map, die jedes schattenwerfende Licht berechnet. Sie zu verdoppeln kostet das Vierfache an Speicher: 2048 reicht für eine Szene aus wenigen Objekten, 4096 lohnt sich, wenn ein Licht ein ganzes Set abdeckt." }, + "csm": { + "title": "Kaskadierte Schatten", + "help": "Teilt den Sonnenschatten in drei Karten auf, eine je Tiefenband der Ansicht, statt einer einzigen über alles, was die Kamera sieht. Was eine offene Landschaft braucht; ein einzelnes Set gewinnt nichts und zahlt drei Tiefendurchgänge dafür." + }, "snapTranslate": { "title": "Verschiebeschritt", "help": "Wie weit ein Objekt bei aktivem Fang in einem Schritt wandert, in Metern. Der Fang selbst wird in der Werkzeugleiste der Szene eingeschaltet; dieser Wert sagt nur, wie fein er ist." diff --git a/src/shared/i18n/en/assets.json b/src/shared/i18n/en/assets.json index 417aed744..77b761d31 100644 --- a/src/shared/i18n/en/assets.json +++ b/src/shared/i18n/en/assets.json @@ -219,6 +219,10 @@ "deleteHint": "Removes the document from the project, its file included", "nameField": "Name", "templateField": "Starting template", + "engines": { + "gl": "Compatible — draws on every machine", + "gpu": "Advanced — better lighting and reflections, needs a recent graphics card" + }, "templateGroups": { "general": "General", "character": "Character", diff --git a/src/shared/i18n/en/diagnostics.json b/src/shared/i18n/en/diagnostics.json index 4f259dc81..6c034e925 100644 --- a/src/shared/i18n/en/diagnostics.json +++ b/src/shared/i18n/en/diagnostics.json @@ -16,6 +16,9 @@ "workerStopped": "the worker was stopped", "csgGraphMissing": "no CSG graph recorded for {{name}}", "shaderAnchorMissing": "The renderer no longer provides {{name}}", + "renderEngineUnavailable": "The Advanced engine is not built yet", + "renderEngineGradingMissing": "The Advanced engine shows a graded sky as its file holds it", + "renderEngineCascadesMissing": "The Advanced engine draws no cascaded shadows", "channelShaderMissing": "no shader derives {{channel}}", "channelSourceEmpty": "{{channel}} source has no pixels", "passSourceMissing": "a pass needs a source to read", diff --git a/src/shared/i18n/en/environment.json b/src/shared/i18n/en/environment.json index 2cc3beee3..9ddf45e03 100644 --- a/src/shared/i18n/en/environment.json +++ b/src/shared/i18n/en/environment.json @@ -117,6 +117,8 @@ "tone_cineonHint": "A curve taken from film stock, with more contrast", "tone_aces": "ACES", "tone_acesHint": "The digital cinema curve, the one that keeps a highlight from clipping to pure white", + "tone_agx": "AgX", + "tone_agxHint": "A newer film curve that holds a saturated colour together as it brightens, where ACES turns it", "exposure": "Exposure", "quality": "Quality", "quality_performance": "Performance", diff --git a/src/shared/i18n/en/postfx.json b/src/shared/i18n/en/postfx.json index 7d75f0cbf..39b314577 100644 --- a/src/shared/i18n/en/postfx.json +++ b/src/shared/i18n/en/postfx.json @@ -63,7 +63,9 @@ "effect_sharpen": "Sharpen", "effect_sharpenHint": "Brings out fine detail through an unsharp mask", "effect_smaa": "SMAA", + "effect_traa": "TRAA", "effect_smaaHint": "Cleaner edges than FXAA, for a little more", + "effect_traaHint": "Smooths every edge using the frames before it; wants the Advanced engine", "effect_ssaa": "Supersampling", "effect_ssaaHint": "Draws the scene several times and averages them, for the cleanest edges", "effect_ssao": "Ambient Occlusion (SSAO)", diff --git a/src/shared/i18n/en/settings.json b/src/shared/i18n/en/settings.json index 549d6733a..f1d041821 100644 --- a/src/shared/i18n/en/settings.json +++ b/src/shared/i18n/en/settings.json @@ -246,6 +246,12 @@ "title": "Boost", "help": "What the speed is multiplied by while you hold the boost key. At 3 you go three times faster — enough to cross a large scene without changing the setting above." }, + "renderEngine": { + "title": "Render engine", + "help": "What new scenes open on. The question is put at every document creation and the answer is written into the document itself: this setting only pre-fills the field, and changes no existing scene. Compatible runs on every machine; Advanced gives better lighting and reflections and wants a recent graphics card. A machine with no WebGPU adapter falls back to Compatible on its own, and says so in the journal.", + "gl": "Compatible", + "gpu": "Advanced" + }, "shadows": { "title": "Cast shadows", "help": "Works out the shadows the lights throw. Every light that casts one costs an extra render pass per frame: turning this off is the most direct way to lighten a heavy scene." @@ -274,6 +280,10 @@ "title": "Shadow detail", "help": "The side, in pixels, of the shadow map each shadow-casting light computes. Doubling it costs four times the memory: 2048 is plenty for a scene of a few objects, 4096 earns its keep when one light covers a whole set." }, + "csm": { + "title": "Cascaded shadows", + "help": "Splits the sun’s shadow into three maps, one per depth band of the view, instead of one map stretched over everything the camera sees. What an open landscape needs; a single set gains nothing and pays three depth passes for it." + }, "snapTranslate": { "title": "Move step", "help": "How far an object travels in one step while snapping is on, in metres. Snapping itself is switched on from the scene toolbar; this only says how fine it is." diff --git a/src/shared/i18n/englishCognates.testFixtures.json b/src/shared/i18n/englishCognates.testFixtures.json index 155fbb141..81d39c4dd 100644 --- a/src/shared/i18n/englishCognates.testFixtures.json +++ b/src/shared/i18n/englishCognates.testFixtures.json @@ -142,6 +142,7 @@ "postfx.effect_fxaa", "postfx.effect_lut", "postfx.effect_smaa", + "postfx.effect_traa", "postfx.effect_vhs", "postfx.preset_anime", "postfx.preset_natural", @@ -163,6 +164,7 @@ "postfx.effect_fxaa", "postfx.effect_lut", "postfx.effect_smaa", + "postfx.effect_traa", "postfx.effect_vhs", "settings.dictationModelFolder.placeholder", "settings.gitUserEmail.placeholder", @@ -328,6 +330,7 @@ "postfx.effect_fxaa", "postfx.effect_lut", "postfx.effect_smaa", + "postfx.effect_traa", "postfx.effect_vhs", "postfx.param_lift", "postfx.preset", @@ -367,6 +370,7 @@ "postfx.effect_fxaa", "postfx.effect_lut", "postfx.effect_smaa", + "postfx.effect_traa", "postfx.effect_vhs", "settings.dictationModelFolder.placeholder", "settings.gitUserEmail.placeholder", @@ -384,6 +388,7 @@ "postfx.effect_fxaa", "postfx.effect_lut", "postfx.effect_smaa", + "postfx.effect_traa", "postfx.effect_vhs", "settings.dictationModelFolder.placeholder", "settings.gitUserEmail.placeholder", @@ -434,6 +439,7 @@ "postfx.effect_fxaa", "postfx.effect_lut", "postfx.effect_smaa", + "postfx.effect_traa", "postfx.effect_vhs", "postfx.param_lift", "postfx.preset_anime", @@ -467,6 +473,7 @@ "postfx.effect_fxaa", "postfx.effect_lut", "postfx.effect_smaa", + "postfx.effect_traa", "postfx.effect_vhs", "settings.dictationModelFolder.placeholder", "settings.gitUserEmail.placeholder" @@ -503,6 +510,7 @@ "postfx.effect_fxaa", "postfx.effect_lut", "postfx.effect_smaa", + "postfx.effect_traa", "postfx.effect_vhs", "postfx.preset_anime", "settings.dictationModelFolder.placeholder", @@ -582,6 +590,7 @@ "postfx.effect_fxaa", "postfx.effect_lut", "postfx.effect_smaa", + "postfx.effect_traa", "postfx.effect_vhs", "settings.dictationModelFolder.placeholder", "settings.gitUserEmail.placeholder", diff --git a/src/shared/i18n/es/assets.json b/src/shared/i18n/es/assets.json index dcbd6b765..3f38f3527 100644 --- a/src/shared/i18n/es/assets.json +++ b/src/shared/i18n/es/assets.json @@ -219,6 +219,10 @@ "deleteHint": "Retira el documento del proyecto, su archivo incluido", "nameField": "Nombre", "templateField": "Plantilla de partida", + "engines": { + "gl": "Compatible — dibuja en cualquier equipo", + "gpu": "Avanzado — mejor iluminación y reflejos, requiere una tarjeta gráfica reciente" + }, "templateGroups": { "general": "General", "character": "Personaje", diff --git a/src/shared/i18n/es/diagnostics.json b/src/shared/i18n/es/diagnostics.json index 7e108dd0c..91e95124e 100644 --- a/src/shared/i18n/es/diagnostics.json +++ b/src/shared/i18n/es/diagnostics.json @@ -16,6 +16,9 @@ "workerStopped": "la tarea en segundo plano se ha detenido", "csgGraphMissing": "no hay ningún grafo CSG registrado para {{name}}", "shaderAnchorMissing": "El motor de render ya no proporciona {{name}}", + "renderEngineUnavailable": "El motor Avanzado aún no está construido", + "renderEngineGradingMissing": "El motor Avanzado muestra un cielo corregido tal como lo contiene su archivo", + "renderEngineCascadesMissing": "El motor Avanzado no dibuja sombras en cascada", "channelShaderMissing": "ningún shader deriva {{channel}}", "channelSourceEmpty": "la fuente de {{channel}} no tiene píxeles", "passSourceMissing": "una pasada necesita una fuente que leer", diff --git a/src/shared/i18n/es/environment.json b/src/shared/i18n/es/environment.json index fa1c2caec..0ffe73041 100644 --- a/src/shared/i18n/es/environment.json +++ b/src/shared/i18n/es/environment.json @@ -108,6 +108,8 @@ "tone_cineonHint": "Una curva tomada de la película fotoquímica, con más contraste", "tone_aces": "ACES", "tone_acesHint": "La curva del cine digital, la que evita que un reflejo se recorte a blanco puro", + "tone_agx": "AgX", + "tone_agxHint": "Una curva fílmica más reciente, que sostiene un color saturado al subir, donde ACES lo desvía", "exposure": "Exposición", "quality": "Calidad", "quality_performance": "Rendimiento", diff --git a/src/shared/i18n/es/postfx.json b/src/shared/i18n/es/postfx.json index 9dab57ebb..241c18289 100644 --- a/src/shared/i18n/es/postfx.json +++ b/src/shared/i18n/es/postfx.json @@ -63,7 +63,9 @@ "effect_sharpen": "Enfoque", "effect_sharpenHint": "Realza los detalles finos mediante una máscara de enfoque", "effect_smaa": "SMAA", + "effect_traa": "TRAA", "effect_smaaHint": "Bordes más limpios que FXAA, por un poco más", + "effect_traaHint": "Suaviza cada borde con los fotogramas anteriores; pide el motor Avanzado", "effect_ssaa": "Supermuestreo", "effect_ssaaHint": "Dibuja la escena varias veces y las promedia, para los bordes más limpios", "effect_ssao": "Oclusión ambiental (SSAO)", diff --git a/src/shared/i18n/es/settings.json b/src/shared/i18n/es/settings.json index 3910822bc..f1d4386d0 100644 --- a/src/shared/i18n/es/settings.json +++ b/src/shared/i18n/es/settings.json @@ -246,6 +246,12 @@ "title": "Aceleración", "help": "Por cuánto se multiplica la velocidad mientras mantienes la tecla de aceleración. En 3 vas tres veces más rápido — suficiente para cruzar una escena grande sin cambiar el ajuste de arriba." }, + "renderEngine": { + "title": "Motor de renderizado", + "help": "Con qué se abren las escenas nuevas. La pregunta se hace al crear cada documento y la respuesta se escribe en el propio documento: este ajuste solo rellena el campo de antemano y no cambia ninguna escena existente. Compatible funciona en cualquier equipo; Avanzado da mejor iluminación y reflejos y pide una tarjeta gráfica reciente. Un equipo sin adaptador WebGPU vuelve por sí solo a Compatible y lo indica en el registro.", + "gl": "Compatible", + "gpu": "Avanzado" + }, "shadows": { "title": "Sombras proyectadas", "help": "Calcula las sombras que proyectan las luces. Cada luz que proyecta una cuesta una pasada de render más por fotograma: desactivar esto es la manera más directa de aligerar una escena cargada." @@ -274,6 +280,10 @@ "title": "Detalle de las sombras", "help": "El lado, en píxeles, del mapa de sombra que calcula cada luz que proyecta sombra. Doblarlo cuesta cuatro veces más memoria: 2048 basta y sobra para una escena de unos pocos objetos, 4096 se gana su sitio cuando una sola luz cubre todo un decorado." }, + "csm": { + "title": "Sombras en cascada", + "help": "Divide la sombra del sol en tres mapas, uno por franja de profundidad de la vista, en lugar de uno solo estirado sobre todo lo que ve la cámara. Lo que necesita un paisaje abierto; un decorado único no gana nada y paga tres pasadas de profundidad." + }, "snapTranslate": { "title": "Paso de desplazamiento", "help": "Cuánto avanza un objeto en un paso mientras el magnetismo está activo, en metros. El magnetismo se enciende desde la barra de herramientas de la escena; esto solo dice lo fino que es." diff --git a/src/shared/i18n/fr/assets.json b/src/shared/i18n/fr/assets.json index a809a7716..3f0e92314 100644 --- a/src/shared/i18n/fr/assets.json +++ b/src/shared/i18n/fr/assets.json @@ -219,6 +219,10 @@ "deleteHint": "Retire le document du projet, son fichier compris", "nameField": "Nom", "templateField": "Modèle de départ", + "engines": { + "gl": "Compatible — dessine sur toutes les machines", + "gpu": "Avancé — meilleurs éclairage et reflets, demande une carte graphique récente" + }, "templateGroups": { "general": "Général", "character": "Personnage", diff --git a/src/shared/i18n/fr/diagnostics.json b/src/shared/i18n/fr/diagnostics.json index 40851632a..46a57533e 100644 --- a/src/shared/i18n/fr/diagnostics.json +++ b/src/shared/i18n/fr/diagnostics.json @@ -16,6 +16,9 @@ "workerStopped": "La tâche d’arrière-plan a été arrêtée", "csgGraphMissing": "Aucun graphe CSG enregistré pour {{name}}", "shaderAnchorMissing": "Le moteur de rendu ne fournit plus {{name}}", + "renderEngineUnavailable": "Le moteur Avancé n’est pas encore construit", + "renderEngineGradingMissing": "Le moteur Avancé affiche un ciel corrigé tel que son fichier le contient", + "renderEngineCascadesMissing": "Le moteur Avancé ne dessine pas d’ombres en cascade", "channelShaderMissing": "Aucun shader ne permet de dériver le canal {{channel}}", "channelSourceEmpty": "La source du canal {{channel}} ne contient aucun pixel", "passSourceMissing": "Cette passe nécessite une source à lire", diff --git a/src/shared/i18n/fr/environment.json b/src/shared/i18n/fr/environment.json index 2bc194a7d..4f2b5fd6d 100644 --- a/src/shared/i18n/fr/environment.json +++ b/src/shared/i18n/fr/environment.json @@ -117,6 +117,8 @@ "tone_cineonHint": "Une courbe issue du cinéma argentique, contrastée", "tone_aces": "ACES", "tone_acesHint": "La courbe du cinéma numérique, celle qui évite qu’un reflet vire au blanc pur", + "tone_agx": "AgX", + "tone_agxHint": "Une courbe filmique plus récente, qui tient une couleur saturée quand elle monte, là où ACES la fait virer", "exposure": "Exposition", "quality": "Qualité", "quality_performance": "Performance", diff --git a/src/shared/i18n/fr/postfx.json b/src/shared/i18n/fr/postfx.json index 7d95d6b74..d428f9e45 100644 --- a/src/shared/i18n/fr/postfx.json +++ b/src/shared/i18n/fr/postfx.json @@ -63,7 +63,9 @@ "effect_sharpen": "Netteté", "effect_sharpenHint": "Accentue les détails fins par un masque flou", "effect_smaa": "Anticrénelage SMAA", + "effect_traa": "Anticrénelage TRAA", "effect_smaaHint": "Des bords plus propres que FXAA, pour un peu plus cher", + "effect_traaHint": "Lisse chaque arête à partir des images précédentes ; demande le moteur Avancé", "effect_ssaa": "Suréchantillonnage", "effect_ssaaHint": "Dessine la scène plusieurs fois et les moyenne, pour les bords les plus propres", "effect_ssao": "Occlusion ambiante (SSAO)", diff --git a/src/shared/i18n/fr/settings.json b/src/shared/i18n/fr/settings.json index da316d029..24301d684 100644 --- a/src/shared/i18n/fr/settings.json +++ b/src/shared/i18n/fr/settings.json @@ -246,6 +246,12 @@ "title": "Accélération", "help": "Par combien la vitesse est multipliée tant que vous maintenez la touche d’accélération. À 3, vous allez trois fois plus vite : de quoi traverser une grande scène sans changer le réglage du dessus." }, + "renderEngine": { + "title": "Moteur de rendu", + "help": "Avec quoi les nouvelles scènes s’ouvrent. La question est posée à chaque création de document et la réponse est écrite dans le document lui-même : ce réglage ne fait que pré-remplir le champ, et ne change aucune scène existante. Compatible fonctionne sur toutes les machines ; Avancé donne un éclairage et des reflets de meilleure qualité et demande une carte graphique récente. Une machine sans adaptateur WebGPU retombe d’elle-même sur le Compatible, et le dit dans le journal.", + "gl": "Compatible", + "gpu": "Avancé" + }, "shadows": { "title": "Ombres portées", "help": "Calcule les ombres que les lumières projettent. Chaque lumière qui en projette une coûte une passe de rendu supplémentaire par image, et couper cette option reste le moyen le plus direct d’alléger une scène chargée." @@ -274,6 +280,10 @@ "title": "Finesse des ombres", "help": "La taille de la carte d’ombre que calcule chaque lumière qui en projette une, en pixels de côté. Doubler ce nombre quadruple la mémoire utilisée : 2048 suffit à une scène de quelques objets, 4096 sert quand une lumière éclaire un décor entier." }, + "csm": { + "title": "Ombres en cascade", + "help": "Découpe l’ombre du soleil en trois cartes, une par tranche de profondeur de la vue, au lieu d’une seule étirée sur tout ce que voit la caméra. Ce qu’il faut à un paysage ouvert ; un décor unique n’y gagne rien et paie trois passes de profondeur." + }, "snapTranslate": { "title": "Pas de déplacement", "help": "De combien un objet avance d’un cran quand le magnétisme est actif, en mètres. Le magnétisme s’allume dans la barre d’outils de la scène ; cette valeur dit seulement à quel point il est fin." diff --git a/src/shared/i18n/hi/assets.json b/src/shared/i18n/hi/assets.json index 72eb65db1..38c293aa9 100644 --- a/src/shared/i18n/hi/assets.json +++ b/src/shared/i18n/hi/assets.json @@ -219,6 +219,10 @@ "deleteHint": "दस्तावेज़ को प्रोजेक्ट से हटाता है, उसकी फ़ाइल सहित", "nameField": "नाम", "templateField": "शुरुआती टेम्पलेट", + "engines": { + "gl": "संगत — हर मशीन पर चलता है", + "gpu": "उन्नत — बेहतर प्रकाश और परावर्तन, नया ग्राफ़िक्स कार्ड चाहिए" + }, "templateGroups": { "general": "सामान्य", "character": "कैरेक्टर", diff --git a/src/shared/i18n/hi/diagnostics.json b/src/shared/i18n/hi/diagnostics.json index 03c9d9965..3675c3a8f 100644 --- a/src/shared/i18n/hi/diagnostics.json +++ b/src/shared/i18n/hi/diagnostics.json @@ -16,6 +16,9 @@ "workerStopped": "बैकग्राउंड टास्क रोक दिया गया", "csgGraphMissing": "{{name}} के लिए कोई CSG ग्राफ़ दर्ज नहीं है", "shaderAnchorMissing": "रेंडरर अब {{name}} नहीं देता", + "renderEngineUnavailable": "उन्नत इंजन अभी बना नहीं है", + "renderEngineGradingMissing": "उन्नत इंजन सुधारे हुए आकाश को उसकी फ़ाइल में जैसा है वैसा ही दिखाता है", + "renderEngineCascadesMissing": "उन्नत इंजन कैस्केड छायाएँ नहीं बनाता", "channelShaderMissing": "कोई शेडर {{channel}} चैनल नहीं निकालता", "channelSourceEmpty": "{{channel}} चैनल के स्रोत में कोई पिक्सेल नहीं है", "passSourceMissing": "इस पास को पढ़ने के लिए एक स्रोत चाहिए", diff --git a/src/shared/i18n/hi/environment.json b/src/shared/i18n/hi/environment.json index 9dd968212..0c3293e2d 100644 --- a/src/shared/i18n/hi/environment.json +++ b/src/shared/i18n/hi/environment.json @@ -108,6 +108,8 @@ "tone_cineonHint": "फ़िल्म स्टॉक से ली गई एक कर्व, ज़्यादा कंट्रास्ट के साथ", "tone_aces": "ACES", "tone_acesHint": "डिजिटल सिनेमा की कर्व, वह जो हाइलाइट को शुद्ध सफ़ेद पर कटने से रोकती है", + "tone_agx": "AgX", + "tone_agxHint": "एक नई फ़िल्म कर्व, जो चटख रंग को चमकते हुए भी थामे रखती है, जहाँ ACES उसे मोड़ देता है", "exposure": "एक्सपोज़र", "quality": "गुणवत्ता", "quality_performance": "परफ़ॉर्मेंस", diff --git a/src/shared/i18n/hi/postfx.json b/src/shared/i18n/hi/postfx.json index 1dbb818a2..27cf86886 100644 --- a/src/shared/i18n/hi/postfx.json +++ b/src/shared/i18n/hi/postfx.json @@ -63,7 +63,9 @@ "effect_sharpen": "शार्पन", "effect_sharpenHint": "अनशार्प मास्क के ज़रिए बारीक़ डिटेल उभारता है", "effect_smaa": "SMAA", + "effect_traa": "TRAA", "effect_smaaHint": "FXAA से साफ़ किनारे, थोड़े ज़्यादा दाम पर", + "effect_traaHint": "पिछली फ़्रेमों से हर किनारा चिकना करता है, उन्नत इंजन चाहिए", "effect_ssaa": "सुपरसैंपलिंग", "effect_ssaaHint": "सीन को कई बार बनाता है और उनका औसत लेता है, ताकि किनारे सबसे साफ़ मिलें", "effect_ssao": "एंबिएंट ऑक्लूज़न (SSAO)", diff --git a/src/shared/i18n/hi/settings.json b/src/shared/i18n/hi/settings.json index d388994a1..d2f4659e6 100644 --- a/src/shared/i18n/hi/settings.json +++ b/src/shared/i18n/hi/settings.json @@ -246,6 +246,12 @@ "title": "बूस्ट", "help": "बूस्ट कुंजी दबाए रखने पर गति किससे गुणा होती है। 3 पर आप तीन गुना तेज़ चलते हैं — ऊपर की सेटिंग बदले बिना बड़े सीन को पार करने के लिए काफ़ी।" }, + "renderEngine": { + "title": "रेंडर इंजन", + "help": "नए दृश्य किस पर खुलते हैं। यह सवाल हर दस्तावेज़ बनाते समय पूछा जाता है और जवाब दस्तावेज़ में ही लिखा जाता है: यह सेटिंग सिर्फ़ फ़ील्ड को पहले से भरती है और किसी मौजूदा दृश्य को नहीं बदलती। संगत हर मशीन पर चलता है; उन्नत बेहतर प्रकाश और परावर्तन देता है और नया ग्राफ़िक्स कार्ड चाहता है। WebGPU अडैप्टर के बिना मशीन खुद ही संगत पर लौट आती है और लॉग में यह बता देती है।", + "gl": "संगत", + "gpu": "उन्नत" + }, "shadows": { "title": "छाया डालें", "help": "लाइट जो छाया डालती हैं उनकी गणना करता है। छाया डालने वाली हर लाइट प्रति फ़्रेम एक अतिरिक्त रेंडर पास ख़र्च करती है: भारी सीन को हल्का करने का सबसे सीधा तरीक़ा यही है कि इसे बंद कर दिया जाए।" @@ -274,6 +280,10 @@ "title": "छाया की बारीकी", "help": "छाया डालने वाली हर लाइट जो शैडो मैप बनाती है उसकी भुजा, पिक्सेल में। इसे दोगुना करने पर मेमोरी चार गुना लगती है: कुछ ऑब्जेक्ट वाले सीन के लिए 2048 काफ़ी है, और जब एक ही लाइट पूरे सेट को ढके तो 4096 अपनी क़ीमत वसूल करता है।" }, + "csm": { + "title": "कैस्केड छायाएँ", + "help": "सूरज की छाया को कैमरे के देखे हर हिस्से पर खिंची एक ही मैप की जगह, दृश्य की हर गहराई-पट्टी के लिए एक, यानी तीन मैप में बाँट देता है। खुले दृश्य को यही चाहिए; एक अकेले सेट को इससे कुछ नहीं मिलता और तीन डेप्थ पास चुकाने पड़ते हैं।" + }, "snapTranslate": { "title": "चलने का स्टेप", "help": "स्नैपिंग चालू होने पर कोई ऑब्जेक्ट एक स्टेप में कितनी दूर जाता है, मीटर में। स्नैपिंग ख़ुद सीन के टूलबार से चालू होती है; यह केवल बताता है कि वह कितनी बारीक है।" diff --git a/src/shared/i18n/id/assets.json b/src/shared/i18n/id/assets.json index 58991ded8..1aa35caa8 100644 --- a/src/shared/i18n/id/assets.json +++ b/src/shared/i18n/id/assets.json @@ -219,6 +219,10 @@ "deleteHint": "Menghapus dokumen dari proyek, termasuk filenya", "nameField": "Nama", "templateField": "Templat awal", + "engines": { + "gl": "Kompatibel — menggambar di semua mesin", + "gpu": "Lanjutan — pencahayaan dan pantulan lebih baik, perlu kartu grafis terbaru" + }, "templateGroups": { "general": "Umum", "character": "Karakter", diff --git a/src/shared/i18n/id/diagnostics.json b/src/shared/i18n/id/diagnostics.json index bdf3043a1..3e3bb323d 100644 --- a/src/shared/i18n/id/diagnostics.json +++ b/src/shared/i18n/id/diagnostics.json @@ -16,6 +16,9 @@ "workerStopped": "tugas latar belakang telah dihentikan", "csgGraphMissing": "tidak ada graf CSG yang tercatat untuk {{name}}", "shaderAnchorMissing": "Mesin render tidak lagi menyediakan {{name}}", + "renderEngineUnavailable": "Mesin Lanjutan belum dibangun", + "renderEngineGradingMissing": "Mesin Lanjutan menampilkan langit terkoreksi seperti isi berkasnya", + "renderEngineCascadesMissing": "Mesin Lanjutan tidak menggambar bayangan bertingkat", "channelShaderMissing": "tidak ada shader yang menurunkan {{channel}}", "channelSourceEmpty": "sumber {{channel}} tidak memiliki piksel", "passSourceMissing": "sebuah tahap perlu sumber untuk dibaca", diff --git a/src/shared/i18n/id/environment.json b/src/shared/i18n/id/environment.json index 28d63b4db..db4ec8b0e 100644 --- a/src/shared/i18n/id/environment.json +++ b/src/shared/i18n/id/environment.json @@ -108,6 +108,8 @@ "tone_cineonHint": "Kurva yang diambil dari film seluloid, dengan kontras lebih tinggi", "tone_aces": "ACES", "tone_acesHint": "Kurva sinema digital, yang menjaga bagian terang tidak terpotong menjadi putih murni", + "tone_agx": "AgX", + "tone_agxHint": "Kurva film yang lebih baru, yang menahan warna jenuh saat menguat, di mana ACES membelokkannya", "exposure": "Eksposur", "quality": "Kualitas", "quality_performance": "Performa", diff --git a/src/shared/i18n/id/postfx.json b/src/shared/i18n/id/postfx.json index a01282dae..2b31fc961 100644 --- a/src/shared/i18n/id/postfx.json +++ b/src/shared/i18n/id/postfx.json @@ -63,7 +63,9 @@ "effect_sharpen": "Tajam", "effect_sharpenHint": "Membawa detail halus melalui topeng blur", "effect_smaa": "Antialiasing SMAA", + "effect_traa": "Antialiasing TRAA", "effect_smaaHint": "Tepi yang lebih bersih dari FXAA, untuk sedikit lebih banyak", + "effect_traaHint": "Menghaluskan setiap tepi dari frame sebelumnya, perlu mesin Lanjutan", "effect_ssaa": "Supersampling", "effect_ssaaHint": "Menggambar adegan beberapa kali dan meratakan mereka, untuk tepi yang paling bersih", "effect_ssao": "Oklusi Ambiental (SSAO)", diff --git a/src/shared/i18n/id/settings.json b/src/shared/i18n/id/settings.json index 414a52779..6ba8fc545 100644 --- a/src/shared/i18n/id/settings.json +++ b/src/shared/i18n/id/settings.json @@ -246,6 +246,12 @@ "title": "Boost", "help": "Apa kecepatan dikalikan dengan saat Anda memegang kunci boost. Pada 3 Anda pergi tiga kali lebih cepat — cukup untuk menyeberang adegan besar tanpa mengubah pengaturan di atas." }, + "renderEngine": { + "title": "Mesin render", + "help": "Yang dipakai scene baru saat dibuka. Pertanyaannya muncul di setiap pembuatan dokumen dan jawabannya ditulis ke dalam dokumen itu sendiri: pengaturan ini hanya mengisi kolomnya lebih dulu, dan tidak mengubah scene mana pun yang sudah ada. Kompatibel berjalan di semua mesin; Lanjutan memberi pencahayaan dan pantulan yang lebih baik dan meminta kartu grafis terbaru. Mesin tanpa adaptor WebGPU kembali sendiri ke Kompatibel, dan mencatatnya di jurnal.", + "gl": "Kompatibel", + "gpu": "Lanjutan" + }, "shadows": { "title": "Ciptakan bayangan", "help": "Menghitung bayangan yang dilemparkan lampu. Setiap lampu yang melemparkan satu biaya lulus render ekstra per bingkai: mematikan ini adalah cara paling langsung untuk meringankan adegan berat." @@ -274,6 +280,10 @@ "title": "Detail bayangan", "help": "Sisi, dalam piksel, peta bayangan setiap cahaya yang melempar bayangan menghitung. Menggandakannya menggandakan empat kali memori: 2048 banyak untuk adegan beberapa objek, 4096 memperoleh yang layaknya ketika satu cahaya mencakup seluruh set." }, + "csm": { + "title": "Bayangan bertingkat", + "help": "Memecah bayangan matahari menjadi tiga peta, satu per lapis kedalaman tampilan, alih-alih satu peta yang direntang ke seluruh yang dilihat kamera. Yang dibutuhkan lanskap terbuka; satu set tunggal tidak mendapat apa-apa dan membayar tiga lintasan kedalaman." + }, "snapTranslate": { "title": "Langkah gerakan", "help": "Seberapa jauh objek bepergian dalam satu langkah saat magnet aktif, dalam meter. Magnet itu sendiri beralih dari bilah alat adegan; ini hanya mengatakan seberapa halus itu." diff --git a/src/shared/i18n/it/assets.json b/src/shared/i18n/it/assets.json index ed264f2db..62932baaa 100644 --- a/src/shared/i18n/it/assets.json +++ b/src/shared/i18n/it/assets.json @@ -219,6 +219,10 @@ "deleteHint": "Toglie il documento dal progetto, compreso il suo file", "nameField": "Nome", "templateField": "Modello di partenza", + "engines": { + "gl": "Compatibile — disegna su qualsiasi macchina", + "gpu": "Avanzato — illuminazione e riflessi migliori, richiede una scheda grafica recente" + }, "templateGroups": { "general": "Generale", "character": "Personaggio", diff --git a/src/shared/i18n/it/diagnostics.json b/src/shared/i18n/it/diagnostics.json index 83b49a8bc..8cabb4a09 100644 --- a/src/shared/i18n/it/diagnostics.json +++ b/src/shared/i18n/it/diagnostics.json @@ -16,6 +16,9 @@ "workerStopped": "l’attività in background è stata arrestata", "csgGraphMissing": "nessun grafo CSG registrato per {{name}}", "shaderAnchorMissing": "Il motore di rendering non fornisce più {{name}}", + "renderEngineUnavailable": "Il motore Avanzato non è ancora costruito", + "renderEngineGradingMissing": "Il motore Avanzato mostra un cielo corretto così come lo contiene il suo file", + "renderEngineCascadesMissing": "Il motore Avanzato non disegna ombre a cascata", "channelShaderMissing": "nessuno shader deriva il canale {{channel}}", "channelSourceEmpty": "la sorgente del canale {{channel}} non contiene nessun pixel", "passSourceMissing": "una passata ha bisogno di una sorgente da leggere", diff --git a/src/shared/i18n/it/environment.json b/src/shared/i18n/it/environment.json index bba5b19f6..4003b0475 100644 --- a/src/shared/i18n/it/environment.json +++ b/src/shared/i18n/it/environment.json @@ -108,6 +108,8 @@ "tone_cineonHint": "Una curva presa dalla pellicola cinematografica, più contrastata", "tone_aces": "ACES", "tone_acesHint": "La curva del cinema digitale, quella che evita a una luce alta di virare al bianco puro", + "tone_agx": "AgX", + "tone_agxHint": "Una curva filmica più recente, che tiene un colore saturo mentre sale, dove ACES lo fa virare", "exposure": "Esposizione", "quality": "Qualità", "quality_performance": "Prestazioni", diff --git a/src/shared/i18n/it/postfx.json b/src/shared/i18n/it/postfx.json index 4007949eb..fe62c1f44 100644 --- a/src/shared/i18n/it/postfx.json +++ b/src/shared/i18n/it/postfx.json @@ -63,7 +63,9 @@ "effect_sharpen": "Nitidezza", "effect_sharpenHint": "Accentua i dettagli fini con una maschera di contrasto", "effect_smaa": "SMAA", + "effect_traa": "TRAA", "effect_smaaHint": "Bordi più puliti di FXAA, per un po’ di più", + "effect_traaHint": "Leviga ogni bordo a partire dai fotogrammi precedenti; chiede il motore Avanzato", "effect_ssaa": "Supercampionamento", "effect_ssaaHint": "Disegna la scena più volte e ne fa la media, per i bordi più puliti", "effect_ssao": "Occlusione ambientale (SSAO)", diff --git a/src/shared/i18n/it/settings.json b/src/shared/i18n/it/settings.json index 46a226c07..ba1ce282a 100644 --- a/src/shared/i18n/it/settings.json +++ b/src/shared/i18n/it/settings.json @@ -246,6 +246,12 @@ "title": "Accelerazione", "help": "Per quanto viene moltiplicata la velocità mentre tieni premuto il tasto di accelerazione. A 3 vai tre volte più veloce — abbastanza per attraversare una scena grande senza cambiare l’impostazione qui sopra." }, + "renderEngine": { + "title": "Motore di rendering", + "help": "Con che cosa si aprono le scene nuove. La domanda viene posta a ogni creazione di documento e la risposta è scritta nel documento stesso: questa impostazione si limita a precompilare il campo e non cambia nessuna scena esistente. Compatibile gira su qualsiasi macchina; Avanzato dà illuminazione e riflessi migliori e chiede una scheda grafica recente. Una macchina senza adattatore WebGPU torna da sola a Compatibile, e lo dice nel giornale.", + "gl": "Compatibile", + "gpu": "Avanzato" + }, "shadows": { "title": "Ombre proiettate", "help": "Calcola le ombre che le luci proiettano. Ogni luce che ne proietta una costa una passata di rendering in più per fotogramma: disattivare questa opzione è il modo più diretto di alleggerire una scena carica." @@ -274,6 +280,10 @@ "title": "Finezza delle ombre", "help": "Il lato, in pixel, della mappa d’ombra che calcola ogni luce che proietta ombre. Raddoppiarlo costa quattro volte la memoria: 2048 basta e avanza per una scena di pochi oggetti, 4096 si ripaga quando una sola luce copre un intero set." }, + "csm": { + "title": "Ombre a cascata", + "help": "Divide l’ombra del sole in tre mappe, una per fascia di profondità della vista, invece di una sola stesa su tutto ciò che vede la camera. Quello che serve a un paesaggio aperto; una scena singola non ci guadagna e paga tre passate di profondità." + }, "snapTranslate": { "title": "Passo di spostamento", "help": "Di quanto un oggetto avanza in un passo mentre il magnetismo è attivo, in metri. Il magnetismo stesso si accende dalla barra degli strumenti della scena; questo dice soltanto quanto è fine." diff --git a/src/shared/i18n/ja/assets.json b/src/shared/i18n/ja/assets.json index 06fd99c30..d98ce0ffa 100644 --- a/src/shared/i18n/ja/assets.json +++ b/src/shared/i18n/ja/assets.json @@ -219,6 +219,10 @@ "deleteHint": "ドキュメントをファイルごとプロジェクトから削除します。", "nameField": "名前", "templateField": "元のテンプレート", + "engines": { + "gl": "互換 — どのマシンでも描画する", + "gpu": "上級 — 照明と反射がより高品質、新しいグラフィックスカードが必要" + }, "templateGroups": { "general": "一般", "character": "キャラクター", diff --git a/src/shared/i18n/ja/diagnostics.json b/src/shared/i18n/ja/diagnostics.json index 8e83db3c2..4c9a038b7 100644 --- a/src/shared/i18n/ja/diagnostics.json +++ b/src/shared/i18n/ja/diagnostics.json @@ -16,6 +16,9 @@ "workerStopped": "ワーカーが停止されました", "csgGraphMissing": "{{name}}のCSGグラフが記録されていません", "shaderAnchorMissing": "レンダラーは{{name}}を提供しなくなりました", + "renderEngineUnavailable": "「上級」エンジンはまだ実装されていません", + "renderEngineGradingMissing": "「上級」エンジンは、補正した空をファイルのままの状態で表示します", + "renderEngineCascadesMissing": "「上級」エンジンはカスケードシャドウを描きません", "channelShaderMissing": "{{channel}}チャンネルを導き出せるシェーダーがありません", "channelSourceEmpty": "{{channel}}チャンネルのソースにピクセルがありません", "passSourceMissing": "パスには読み取るソースが必要です", diff --git a/src/shared/i18n/ja/environment.json b/src/shared/i18n/ja/environment.json index cf4c5e4b9..20919fbe6 100644 --- a/src/shared/i18n/ja/environment.json +++ b/src/shared/i18n/ja/environment.json @@ -108,6 +108,8 @@ "tone_cineonHint": "フィルムから取られたカーブで、コントラストが強めです。", "tone_aces": "ACES", "tone_acesHint": "デジタルシネマのカーブです。ハイライトが純白に飛ぶのを防ぎます。", + "tone_agx": "AgX", + "tone_agxHint": "より新しいフィルムのカーブです。ACES では転んでしまう鮮やかな色を、明るくなっても保ちます。", "exposure": "露出", "quality": "品質", "quality_performance": "パフォーマンス", diff --git a/src/shared/i18n/ja/postfx.json b/src/shared/i18n/ja/postfx.json index ea2218b05..6dffd3a01 100644 --- a/src/shared/i18n/ja/postfx.json +++ b/src/shared/i18n/ja/postfx.json @@ -63,7 +63,9 @@ "effect_sharpen": "シャープ", "effect_sharpenHint": "アンシャープマスクで細部を引き立てます。", "effect_smaa": "SMAA", + "effect_traa": "TRAA", "effect_smaaHint": "FXAAより端がきれいになりますが、コストは少し上がります。", + "effect_traaHint": "前のフレームを使ってすべての端をなめらかにします。上級エンジンが必要です。", "effect_ssaa": "スーパーサンプリング", "effect_ssaaHint": "シーンを何度も描いて平均します。端がもっともきれいになります。", "effect_ssao": "アンビエントオクルージョン(SSAO)", diff --git a/src/shared/i18n/ja/settings.json b/src/shared/i18n/ja/settings.json index 347292e38..0508a46bb 100644 --- a/src/shared/i18n/ja/settings.json +++ b/src/shared/i18n/ja/settings.json @@ -246,6 +246,12 @@ "title": "ブースト", "help": "ブーストキーを保持している間、速度が掛けられる内容。3で3倍速く移動します。上記の設定を変更せずに大規模シーン全体を横切るのに十分です。" }, + "renderEngine": { + "title": "レンダーエンジン", + "help": "新しいシーンが何で開くか。質問はドキュメントを作るたびに出され、答えはそのドキュメント自身に書き込まれます。この設定は入力欄をあらかじめ埋めるだけで、既存のシーンは変えません。互換はどのマシンでも動き、上級は照明と反射がより高品質でありながら新しいグラフィックスカードを必要とします。WebGPU アダプターのないマシンは自分で互換に戻り、そのことをジャーナルに書きます。", + "gl": "互換", + "gpu": "上級" + }, "shadows": { "title": "シャドウをキャスト", "help": "ライトがスローするシャドウを機能させます。シャドウをキャストするすべての光は、フレームあたりの余分なレンダーパスをコストします。これをオフにすることは、重いシーンを軽くする最も直接的な方法です。" @@ -274,6 +280,10 @@ "title": "シャドウディテール", "help": "シャドウマップのサイド。ピクセルで。各シャドウキャストライトがコンピュート。倍増は4倍のメモリをコストしています。2048は数個のオブジェクトのシーンに多くあります。4096は1本の光がセット全体をカバーするとき稼ぎます。" }, + "csm": { + "title": "カスケードシャドウ", + "help": "カメラが見るすべてに一枚の影マップを引き伸ばす代わりに、太陽の影を視界の奥行き帯ごとに三枚へ分けます。開けた風景に必要なもので、単一のセットでは得るものがなく、奥行きパスを三回払うだけです。" + }, "snapTranslate": { "title": "ステップを移動", "help": "スナップがオンのときにオブジェクトが1ステップで移動する距離。メートル単位。スナップ自体がシーンツールバーからオンになります。これだけは細かいもの。" diff --git a/src/shared/i18n/ko/assets.json b/src/shared/i18n/ko/assets.json index aaf2ec20b..39ea21b68 100644 --- a/src/shared/i18n/ko/assets.json +++ b/src/shared/i18n/ko/assets.json @@ -219,6 +219,10 @@ "deleteHint": "문서를 프로젝트에서 제거합니다", "nameField": "이름", "templateField": "시작 템플릿", + "engines": { + "gl": "호환 — 모든 컴퓨터에서 그립니다", + "gpu": "고급 — 조명과 반사가 더 좋고, 최신 그래픽 카드가 필요합니다" + }, "templateGroups": { "general": "일반", "character": "캐릭터", diff --git a/src/shared/i18n/ko/diagnostics.json b/src/shared/i18n/ko/diagnostics.json index 5c2d4c984..8103e61f4 100644 --- a/src/shared/i18n/ko/diagnostics.json +++ b/src/shared/i18n/ko/diagnostics.json @@ -16,6 +16,9 @@ "workerStopped": "워커가 정지되었습니다", "csgGraphMissing": "{{name}}에 대해 기록된 CSG 그래프가 없습니다", "shaderAnchorMissing": "렌더러가 {{name}} 앵커를 더 이상 제공하지 않습니다", + "renderEngineUnavailable": "고급 엔진은 아직 만들어지지 않았습니다", + "renderEngineGradingMissing": "고급 엔진은 보정한 하늘을 파일에 담긴 그대로 보여 줍니다", + "renderEngineCascadesMissing": "고급 엔진은 캐스케이드 그림자를 그리지 않습니다", "channelShaderMissing": "{{channel}} 채널을 이끌어 내는 셰이더가 없습니다", "channelSourceEmpty": "{{channel}} 채널의 소스에 픽셀이 없습니다", "passSourceMissing": "패스에는 읽을 소스가 필요합니다", diff --git a/src/shared/i18n/ko/environment.json b/src/shared/i18n/ko/environment.json index 8c5d3892b..86125fc9b 100644 --- a/src/shared/i18n/ko/environment.json +++ b/src/shared/i18n/ko/environment.json @@ -108,6 +108,8 @@ "tone_cineonHint": "필름에서 가져온 커브로, 대비가 더 강합니다", "tone_aces": "ACES", "tone_acesHint": "디지털 시네마 커브로, 밝은 부분이 순백으로 뭉개지는 것을 막아 줍니다", + "tone_agx": "AgX", + "tone_agxHint": "더 새로운 필름 커브로, ACES에서는 색이 틀어지는 지점에서도 진한 색을 그대로 지켜 줍니다", "exposure": "노출", "quality": "품질", "quality_performance": "성능", diff --git a/src/shared/i18n/ko/postfx.json b/src/shared/i18n/ko/postfx.json index 0ac9bb2a5..12d25a67f 100644 --- a/src/shared/i18n/ko/postfx.json +++ b/src/shared/i18n/ko/postfx.json @@ -63,7 +63,9 @@ "effect_sharpen": "선명하게", "effect_sharpenHint": "언샤프 마스크로 미세한 디테일을 살립니다", "effect_smaa": "SMAA", + "effect_traa": "TRAA", "effect_smaaHint": "FXAA보다 깨끗한 가장자리, 비용은 조금 더", + "effect_traaHint": "이전 프레임을 이용해 모든 가장자리를 다듬습니다. 고급 엔진이 필요합니다", "effect_ssaa": "슈퍼샘플링", "effect_ssaaHint": "씬을 여러 번 그려 평균을 내어 가장 깨끗한 가장자리를 만듭니다", "effect_ssao": "앰비언트 오클루전 (SSAO)", diff --git a/src/shared/i18n/ko/settings.json b/src/shared/i18n/ko/settings.json index 65f6d4b6c..e1cc356fa 100644 --- a/src/shared/i18n/ko/settings.json +++ b/src/shared/i18n/ko/settings.json @@ -246,6 +246,12 @@ "title": "가속", "help": "가속 키를 누르고 있는 동안 속도에 곱해지는 값입니다. 3이면 세 배 빨라지며 — 위의 설정을 바꾸지 않고도 넓은 씬을 가로지를 만합니다." }, + "renderEngine": { + "title": "렌더 엔진", + "help": "새 장면이 무엇으로 열리는지. 이 질문은 문서를 만들 때마다 나오고 답은 문서 자체에 기록됩니다. 이 설정은 입력란을 미리 채울 뿐이며 기존 장면은 바꾸지 않습니다. 호환은 모든 컴퓨터에서 돌아가고, 고급은 조명과 반사가 더 좋은 대신 최신 그래픽 카드를 요구합니다. WebGPU 어댑터가 없는 컴퓨터는 스스로 호환으로 돌아가며 그 사실을 기록에 남깁니다.", + "gl": "호환", + "gpu": "고급" + }, "shadows": { "title": "그림자 투사", "help": "조명이 던지는 그림자를 계산합니다. 그림자를 드리우는 조명은 프레임마다 렌더 패스를 하나씩 더 쓰므로, 이 옵션을 끄는 것이 무거운 씬을 가볍게 하는 가장 직접적인 방법입니다." @@ -274,6 +280,10 @@ "title": "그림자 정밀도", "help": "그림자를 드리우는 조명마다 계산하는 그림자 맵의 한 변 길이로, 픽셀 단위입니다. 두 배로 하면 메모리는 네 배가 듭니다: 오브젝트가 몇 개뿐인 씬에는 2048로 충분하고, 조명 하나가 세트 전체를 덮을 때는 4096이 값을 합니다." }, + "csm": { + "title": "캐스케이드 그림자", + "help": "카메라가 보는 전부에 한 장을 늘여 붙이는 대신, 태양의 그림자를 시야의 깊이 구간마다 한 장씩 세 장으로 나눕니다. 열린 풍경에 필요한 방식이며, 단일 세트에서는 얻는 것 없이 깊이 패스만 세 번 치릅니다." + }, "snapTranslate": { "title": "이동 간격", "help": "스냅이 켜져 있을 때 오브젝트가 한 칸에 나아가는 거리로, 미터 단위입니다. 스냅 자체는 씬 도구 모음에서 켜며, 이 값은 그것이 얼마나 고운지만 정합니다." diff --git a/src/shared/i18n/pt/assets.json b/src/shared/i18n/pt/assets.json index 803320e35..0f2872e0d 100644 --- a/src/shared/i18n/pt/assets.json +++ b/src/shared/i18n/pt/assets.json @@ -219,6 +219,10 @@ "deleteHint": "Retira o documento do projeto, inclusive o seu arquivo", "nameField": "Nome", "templateField": "Template inicial", + "engines": { + "gl": "Compatível — desenha em qualquer máquina", + "gpu": "Avançado — melhor iluminação e reflexos, exige uma placa gráfica recente" + }, "templateGroups": { "general": "Geral", "character": "Personagem", diff --git a/src/shared/i18n/pt/diagnostics.json b/src/shared/i18n/pt/diagnostics.json index 4023e53ce..d2951e4c0 100644 --- a/src/shared/i18n/pt/diagnostics.json +++ b/src/shared/i18n/pt/diagnostics.json @@ -16,6 +16,9 @@ "workerStopped": "a tarefa em segundo plano foi interrompida", "csgGraphMissing": "nenhum grafo CSG registrado para {{name}}", "shaderAnchorMissing": "O renderizador não fornece mais {{name}}", + "renderEngineUnavailable": "O motor Avançado ainda não está construído", + "renderEngineGradingMissing": "O motor Avançado mostra um céu corrigido tal como o seu ficheiro o contém", + "renderEngineCascadesMissing": "O motor Avançado não desenha sombras em cascata", "channelShaderMissing": "nenhum shader deriva {{channel}}", "channelSourceEmpty": "a fonte de {{channel}} não tem pixels", "passSourceMissing": "uma passagem precisa de uma fonte para ler", diff --git a/src/shared/i18n/pt/environment.json b/src/shared/i18n/pt/environment.json index 8794d3e54..193ca18a4 100644 --- a/src/shared/i18n/pt/environment.json +++ b/src/shared/i18n/pt/environment.json @@ -108,6 +108,8 @@ "tone_cineonHint": "Uma curva vinda do cinema em película, mais contrastada", "tone_aces": "ACES", "tone_acesHint": "A curva do cinema digital, a que evita que uma alta luz estoure em branco puro", + "tone_agx": "AgX", + "tone_agxHint": "Uma curva fílmica mais recente, que segura uma cor saturada ao subir, onde ACES a desvia", "exposure": "Exposição", "quality": "Qualidade", "quality_performance": "Desempenho", diff --git a/src/shared/i18n/pt/postfx.json b/src/shared/i18n/pt/postfx.json index 6219d4d9d..107b33b3b 100644 --- a/src/shared/i18n/pt/postfx.json +++ b/src/shared/i18n/pt/postfx.json @@ -63,7 +63,9 @@ "effect_sharpen": "Nitidez", "effect_sharpenHint": "Realça os detalhes finos com uma máscara de nitidez", "effect_smaa": "SMAA", + "effect_traa": "TRAA", "effect_smaaHint": "Bordas mais limpas que o FXAA, por um pouco mais", + "effect_traaHint": "Suaviza cada borda a partir dos quadros anteriores; pede o motor Avançado", "effect_ssaa": "Superamostragem", "effect_ssaaHint": "Desenha a cena várias vezes e faz a média delas, para as bordas mais limpas", "effect_ssao": "Oclusão ambiente (SSAO)", diff --git a/src/shared/i18n/pt/settings.json b/src/shared/i18n/pt/settings.json index 5da08c9bb..ec35ca134 100644 --- a/src/shared/i18n/pt/settings.json +++ b/src/shared/i18n/pt/settings.json @@ -246,6 +246,12 @@ "title": "Aceleração", "help": "Por quanto a velocidade é multiplicada enquanto você mantém a tecla de aceleração. Em 3 você vai três vezes mais rápido — o bastante para atravessar uma cena grande sem mudar a configuração acima." }, + "renderEngine": { + "title": "Motor de renderização", + "help": "Com o que as cenas novas abrem. A pergunta é feita a cada criação de documento e a resposta é escrita no próprio documento: este ajuste apenas preenche o campo de antemão e não muda nenhuma cena existente. Compatível funciona em qualquer máquina; Avançado dá melhor iluminação e reflexos e pede uma placa gráfica recente. Uma máquina sem adaptador WebGPU volta sozinha ao Compatível, e diz isso no diário.", + "gl": "Compatível", + "gpu": "Avançado" + }, "shadows": { "title": "Sombras projetadas", "help": "Calcula as sombras que as luzes projetam. Cada luz que projeta uma custa uma passagem de renderização a mais por quadro: desligar isto é a maneira mais direta de aliviar uma cena pesada." @@ -274,6 +280,10 @@ "title": "Detalhe das sombras", "help": "O lado, em pixels, do mapa de sombra que cada luz projetora calcula. Dobrá-lo custa quatro vezes a memória: 2048 basta para uma cena de alguns objetos, 4096 vale a pena quando uma luz cobre um cenário inteiro." }, + "csm": { + "title": "Sombras em cascata", + "help": "Divide a sombra do sol em três mapas, um por faixa de profundidade da vista, em vez de um só esticado sobre tudo o que a câmara vê. O que uma paisagem aberta precisa; um cenário único não ganha nada e paga três passagens de profundidade." + }, "snapTranslate": { "title": "Passo de deslocamento", "help": "Quanto um objeto percorre em um passo enquanto o encaixe está ativo, em metros. O encaixe em si é ligado na barra de ferramentas da cena; isto só diz o quanto ele é fino." diff --git a/src/shared/i18n/ru/assets.json b/src/shared/i18n/ru/assets.json index 44e0529fe..8152288f4 100644 --- a/src/shared/i18n/ru/assets.json +++ b/src/shared/i18n/ru/assets.json @@ -226,6 +226,10 @@ "deleteHint": "Убирает документ из проекта вместе с его файлом", "nameField": "Имя", "templateField": "Начальный шаблон", + "engines": { + "gl": "Совместимый — рисует на любой машине", + "gpu": "Продвинутый — лучше свет и отражения, нужна современная видеокарта" + }, "templateGroups": { "general": "Общее", "character": "Персонаж", diff --git a/src/shared/i18n/ru/diagnostics.json b/src/shared/i18n/ru/diagnostics.json index 6657086e2..2ebfe1266 100644 --- a/src/shared/i18n/ru/diagnostics.json +++ b/src/shared/i18n/ru/diagnostics.json @@ -16,6 +16,9 @@ "workerStopped": "фоновая задача была остановлена", "csgGraphMissing": "для {{name}} не записан граф CSG", "shaderAnchorMissing": "Движок рендеринга больше не предоставляет {{name}}", + "renderEngineUnavailable": "Продвинутый движок ещё не собран", + "renderEngineGradingMissing": "Продвинутый движок показывает откорректированное небо таким, каким оно лежит в файле", + "renderEngineCascadesMissing": "Продвинутый движок не рисует каскадные тени", "channelShaderMissing": "ни один шейдер не выводит {{channel}}", "channelSourceEmpty": "в источнике канала {{channel}} нет пикселей", "passSourceMissing": "проходу нужен источник для чтения", diff --git a/src/shared/i18n/ru/environment.json b/src/shared/i18n/ru/environment.json index 2ef858b5d..5e27deda8 100644 --- a/src/shared/i18n/ru/environment.json +++ b/src/shared/i18n/ru/environment.json @@ -108,6 +108,8 @@ "tone_cineonHint": "Кривая, взятая из плёночного кино, — более контрастная", "tone_aces": "ACES", "tone_acesHint": "Кривая цифрового кино — та, что не даёт блику уйти в чистый белый", + "tone_agx": "AgX", + "tone_agxHint": "Более новая плёночная кривая: насыщенный цвет держится при подъёме там, где ACES его уводит", "exposure": "Экспозиция", "quality": "Качество", "quality_performance": "Производительность", diff --git a/src/shared/i18n/ru/postfx.json b/src/shared/i18n/ru/postfx.json index 9cf7984cb..c7a4148bb 100644 --- a/src/shared/i18n/ru/postfx.json +++ b/src/shared/i18n/ru/postfx.json @@ -63,7 +63,9 @@ "effect_sharpen": "Резкость", "effect_sharpenHint": "Проявляет мелкие детали нерезкой маской", "effect_smaa": "SMAA", + "effect_traa": "TRAA", "effect_smaaHint": "Края чище, чем у FXAA, за чуть большую цену", + "effect_traaHint": "Сглаживает каждый край по предыдущим кадрам, нужен продвинутый движок", "effect_ssaa": "Суперсэмплинг", "effect_ssaaHint": "Рисует сцену несколько раз и усредняет, ради самых чистых краёв", "effect_ssao": "Окклюзия окружения (SSAO)", diff --git a/src/shared/i18n/ru/settings.json b/src/shared/i18n/ru/settings.json index 6d594e39f..1555acb33 100644 --- a/src/shared/i18n/ru/settings.json +++ b/src/shared/i18n/ru/settings.json @@ -246,6 +246,12 @@ "title": "Ускорение", "help": "На сколько умножается скорость, пока удерживается клавиша ускорения. При 3 движение втрое быстрее — этого хватает, чтобы пересечь большую сцену, не меняя настройку выше." }, + "renderEngine": { + "title": "Движок отрисовки", + "help": "С чем открываются новые сцены. Вопрос задаётся при создании каждого документа, а ответ записывается в сам документ: эта настройка лишь заполняет поле заранее и не меняет ни одну существующую сцену. Совместимый работает на любой машине; Продвинутый даёт лучше свет и отражения и просит современную видеокарту. Машина без адаптера WebGPU сама возвращается к Совместимому и пишет об этом в журнал.", + "gl": "Совместимый", + "gpu": "Продвинутый" + }, "shadows": { "title": "Отбрасывать тени", "help": "Вычисляет тени, отбрасываемые источниками света. Каждый источник, который её отбрасывает, стоит дополнительного прохода рендера на кадр: отключить это — самый прямой способ облегчить тяжёлую сцену." @@ -274,6 +280,10 @@ "title": "Детализация теней", "help": "Сторона карты теней в пикселях, которую вычисляет каждый источник света, отбрасывающий тень. Удвоение стоит вчетверо больше памяти: 2048 с запасом хватает сцене из нескольких объектов, 4096 оправдывает себя, когда один источник освещает целую декорацию." }, + "csm": { + "title": "Каскадные тени", + "help": "Делит тень солнца на три карты, по одной на каждый слой глубины вида, вместо одной, растянутой на всё, что видит камера. То, что нужно открытому пейзажу; одиночной сцене это ничего не даёт, а стоит трёх проходов глубины." + }, "snapTranslate": { "title": "Шаг перемещения", "help": "На сколько объект продвигается за один шаг при включённой привязке, в метрах. Сама привязка включается на панели инструментов сцены; это значение говорит только о том, насколько она мелкая." diff --git a/src/shared/i18n/tr/assets.json b/src/shared/i18n/tr/assets.json index d2bab8988..b1954f853 100644 --- a/src/shared/i18n/tr/assets.json +++ b/src/shared/i18n/tr/assets.json @@ -219,6 +219,10 @@ "deleteHint": "Belgeyi projeden kaldırır, dosyası dahil", "nameField": "Ad", "templateField": "Başlangıç şablonu", + "engines": { + "gl": "Uyumlu — her makinede çizer", + "gpu": "Gelişmiş — daha iyi ışık ve yansımalar, güncel bir ekran kartı ister" + }, "templateGroups": { "general": "Genel", "character": "Karakter", diff --git a/src/shared/i18n/tr/diagnostics.json b/src/shared/i18n/tr/diagnostics.json index 263e29687..ae6f1b794 100644 --- a/src/shared/i18n/tr/diagnostics.json +++ b/src/shared/i18n/tr/diagnostics.json @@ -16,6 +16,9 @@ "workerStopped": "arka plan işlemi durduruldu", "csgGraphMissing": "şunun için kayıtlı CSG grafiği yok: {{name}}", "shaderAnchorMissing": "Render motoru artık şunu sağlamıyor: {{name}}", + "renderEngineUnavailable": "Gelişmiş motor henüz hazır değil", + "renderEngineGradingMissing": "Gelişmiş motor, düzeltilmiş bir gökyüzünü dosyasındaki hâliyle gösterir", + "renderEngineCascadesMissing": "Gelişmiş motor kademeli gölgeler çizmez", "channelShaderMissing": "hiçbir gölgelendirici şu kanalı türetmiyor: {{channel}}", "channelSourceEmpty": "{{channel}} kanalının kaynağında piksel yok", "passSourceMissing": "bir geçişin okuyacak bir kaynağa ihtiyacı var", diff --git a/src/shared/i18n/tr/environment.json b/src/shared/i18n/tr/environment.json index d33f214a4..1883af263 100644 --- a/src/shared/i18n/tr/environment.json +++ b/src/shared/i18n/tr/environment.json @@ -108,6 +108,8 @@ "tone_cineonHint": "Sinema filminden alınmış, daha kontrastlı bir eğri", "tone_aces": "ACES", "tone_acesHint": "Dijital sinema eğrisi; bir yüksek ışığın saf beyaza kırpılmasını önleyen eğri", + "tone_agx": "AgX", + "tone_agxHint": "Daha yeni bir film eğrisi; doygun bir rengi yükselirken tutar, ACES onu kaydırırken", "exposure": "Pozlama", "quality": "Kalite", "quality_performance": "Performans", diff --git a/src/shared/i18n/tr/postfx.json b/src/shared/i18n/tr/postfx.json index 2e74a466e..3e5789e1c 100644 --- a/src/shared/i18n/tr/postfx.json +++ b/src/shared/i18n/tr/postfx.json @@ -63,7 +63,9 @@ "effect_sharpen": "Keskinleştirme", "effect_sharpenHint": "İnce ayrıntıları bir keskinlik maskesiyle öne çıkarır", "effect_smaa": "SMAA", + "effect_traa": "TRAA", "effect_smaaHint": "FXAA’dan daha temiz kenarlar, biraz daha pahalıya", + "effect_traaHint": "Her kenarı önceki karelerden yumuşatır, gelişmiş motor ister", "effect_ssaa": "Süper örnekleme", "effect_ssaaHint": "En temiz kenarlar için sahneyi birkaç kez çizer ve ortalamasını alır", "effect_ssao": "Ortam oklüzyonu (SSAO)", diff --git a/src/shared/i18n/tr/settings.json b/src/shared/i18n/tr/settings.json index 0bdca679d..ff379b7b9 100644 --- a/src/shared/i18n/tr/settings.json +++ b/src/shared/i18n/tr/settings.json @@ -246,6 +246,12 @@ "title": "Hızlan", "help": "Hızlanma tuşu tutulduğu sürece hız kaç ile çarpılır. 3’te üç kat hızlı gidersiz — yukarıda ayarı değiştirecek kadar geniş sahneyi geçmek." }, + "renderEngine": { + "title": "Render motoru", + "help": "Yeni sahnelerin neyle açıldığı. Soru her belge oluşturmada sorulur ve yanıt belgenin kendisine yazılır: bu ayar yalnızca alanı önceden doldurur, var olan hiçbir sahneyi değiştirmez. Uyumlu her makinede çalışır; Gelişmiş daha iyi ışık ve yansımalar verir ve güncel bir ekran kartı ister. WebGPU bağdaştırıcısı olmayan bir makine kendiliğinden Uyumlu’ya döner ve bunu günlüğe yazar.", + "gl": "Uyumlu", + "gpu": "Gelişmiş" + }, "shadows": { "title": "Gölge at", "help": "Işıkların attığı gölgeleri hesaplar. Gölge atan her ışık kare başına fazladan render geçişi maliyeti: bunu kapatmak ağır sahneyi hafifletmek en doğrudan yoludur." @@ -274,6 +280,10 @@ "title": "Gölge ayrıntısı", "help": "Her gölge atan ışık hesapladığı gölge haritasının tarafı, piksel cinsi. Dörtlemek dört katı bellek maliyeti: 2048 birkaç nesnenin sahnesi yeterlidir, 4096 bir ışık tüm seti kapsarken kendi kazancını yapar." }, + "csm": { + "title": "Kademeli gölgeler", + "help": "Güneşin gölgesini, kameranın gördüğü her şeye tek bir harita germek yerine, görüşün her derinlik dilimi için bir tane olmak üzere üç haritaya böler. Açık bir manzaranın ihtiyacı budur; tek bir dekor bundan hiçbir şey kazanmaz ve üç derinlik geçişi öder." + }, "snapTranslate": { "title": "Taşı adımı", "help": "Manyetizma açıkken nesne bir adımda meter seyahat ediyor. Manyetizma sahne araç çubuğundan açılır; bu sadece ince olduğunu söyler." diff --git a/src/shared/i18n/vi/assets.json b/src/shared/i18n/vi/assets.json index cd5961810..af2c12d1d 100644 --- a/src/shared/i18n/vi/assets.json +++ b/src/shared/i18n/vi/assets.json @@ -219,6 +219,10 @@ "deleteHint": "Xóa tài liệu khỏi dự án, kể cả tệp của nó", "nameField": "Tên", "templateField": "Mẫu ban đầu", + "engines": { + "gl": "Tương thích — vẽ được trên mọi máy", + "gpu": "Nâng cao — ánh sáng và phản chiếu tốt hơn, cần card đồ hoạ đời mới" + }, "templateGroups": { "general": "Chung", "character": "Nhân vật", diff --git a/src/shared/i18n/vi/diagnostics.json b/src/shared/i18n/vi/diagnostics.json index 7baeedcc5..91f096cc0 100644 --- a/src/shared/i18n/vi/diagnostics.json +++ b/src/shared/i18n/vi/diagnostics.json @@ -16,6 +16,9 @@ "workerStopped": "Tác vụ nền đã được dừng", "csgGraphMissing": "Không có đồ thị CSG được ghi lại cho {{name}}", "shaderAnchorMissing": "Render không còn cung cấp {{name}}", + "renderEngineUnavailable": "Bộ máy Nâng cao chưa được dựng", + "renderEngineGradingMissing": "Bộ máy Nâng cao hiển thị bầu trời đã chỉnh đúng như tệp của nó chứa", + "renderEngineCascadesMissing": "Bộ máy Nâng cao không vẽ bóng đổ phân tầng", "channelShaderMissing": "Không shader nào lấy nguồn từ kênh {{channel}}", "channelSourceEmpty": "Nguồn kênh {{channel}} không chứa pixel", "passSourceMissing": "Lệnh này cần một nguồn để đọc", diff --git a/src/shared/i18n/vi/environment.json b/src/shared/i18n/vi/environment.json index fda3895d9..bbc0cff50 100644 --- a/src/shared/i18n/vi/environment.json +++ b/src/shared/i18n/vi/environment.json @@ -108,6 +108,8 @@ "tone_cineonHint": "Đường cong từ phim quay, có tương phản hơn", "tone_aces": "ACES", "tone_acesHint": "Đường cong điện ảnh số, cái giữ cho ánh sáng cao khỏi cắt tới trắng thuần", + "tone_agx": "AgX", + "tone_agxHint": "Đường cong phim mới hơn, giữ được màu bão hòa khi sáng lên, chỗ mà ACES làm nó lệch", "exposure": "Phơi sáng", "quality": "Chất lượng", "quality_performance": "Hiệu suất", diff --git a/src/shared/i18n/vi/postfx.json b/src/shared/i18n/vi/postfx.json index 2128ad523..bc34b3f5b 100644 --- a/src/shared/i18n/vi/postfx.json +++ b/src/shared/i18n/vi/postfx.json @@ -63,7 +63,9 @@ "effect_sharpen": "Độ sắc", "effect_sharpenHint": "Mang ra chi tiết tốt thông qua mặt nạ mờ", "effect_smaa": "Antialiasing SMAA", + "effect_traa": "Antialiasing TRAA", "effect_smaaHint": "Các cạnh sạch hơn so với FXAA, cho một chút nhiều hơn", + "effect_traaHint": "Làm mượt mọi cạnh dựa trên các khung hình trước, cần bộ máy Nâng cao", "effect_ssaa": "Siêu lấy mẫu", "effect_ssaaHint": "Vẽ cảnh nhiều lần và trung bình chúng, cho các cạnh sạch nhất", "effect_ssao": "Che phủ môi trường (SSAO)", diff --git a/src/shared/i18n/vi/settings.json b/src/shared/i18n/vi/settings.json index 334d6fe72..aa830892e 100644 --- a/src/shared/i18n/vi/settings.json +++ b/src/shared/i18n/vi/settings.json @@ -246,6 +246,12 @@ "title": "Tăng tốc", "help": "Tốc độ được nhân với bao nhiêu khi bạn giữ phím tăng tốc. Ở mức 3, bạn đi nhanh gấp ba — đủ để băng qua một cảnh lớn mà không phải đổi cài đặt phía trên." }, + "renderEngine": { + "title": "Bộ máy dựng hình", + "help": "Cảnh mới mở bằng gì. Câu hỏi được đặt ở mỗi lần tạo tài liệu và câu trả lời được ghi vào chính tài liệu đó: thiết lập này chỉ điền sẵn ô chọn, và không đổi bất kỳ cảnh nào đã có. Tương thích chạy trên mọi máy; Nâng cao cho ánh sáng và phản chiếu tốt hơn và cần card đồ hoạ đời mới. Máy không có bộ điều hợp WebGPU tự quay về Tương thích, và ghi điều đó vào nhật ký.", + "gl": "Tương thích", + "gpu": "Nâng cao" + }, "shadows": { "title": "Bóng đổ", "help": "Tính các bóng mà đèn hắt ra. Mỗi đèn có đổ bóng tốn thêm một lượt render cho mỗi khung hình: tắt tùy chọn này là cách trực tiếp nhất để làm nhẹ một cảnh nặng." @@ -274,6 +280,10 @@ "title": "Độ chi tiết của bóng đổ", "help": "Cạnh của map bóng mà mỗi đèn có đổ bóng tính ra, tính bằng pixel. Nhân đôi con số này thì tốn gấp bốn lần bộ nhớ: 2048 là quá đủ cho một cảnh vài đối tượng, 4096 mới đáng dùng khi một đèn phủ cả một phim trường." }, + "csm": { + "title": "Bóng đổ phân tầng", + "help": "Chia bóng của mặt trời thành ba bản đồ, mỗi bản một lớp chiều sâu của khung nhìn, thay vì một bản trải khắp những gì máy quay thấy. Điều một cảnh mở cần; một bối cảnh đơn lẻ chẳng được gì mà vẫn trả ba lượt chiều sâu." + }, "snapTranslate": { "title": "Bước di chuyển", "help": "Một đối tượng đi được bao xa trong một bước khi bắt dính đang bật, tính bằng mét. Bản thân việc bắt dính được bật từ thanh công cụ của cảnh; giá trị này chỉ nói nó mịn đến mức nào." diff --git a/src/shared/i18n/zh/assets.json b/src/shared/i18n/zh/assets.json index 2a1bc9da3..f49fedd69 100644 --- a/src/shared/i18n/zh/assets.json +++ b/src/shared/i18n/zh/assets.json @@ -219,6 +219,10 @@ "deleteHint": "把文档从项目中移除,包括它的文件", "nameField": "名称", "templateField": "起始模板", + "engines": { + "gl": "兼容 — 在所有机器上都能绘制", + "gpu": "高级 — 光照和反射更好,需要较新的显卡" + }, "templateGroups": { "general": "通用", "character": "角色", diff --git a/src/shared/i18n/zh/diagnostics.json b/src/shared/i18n/zh/diagnostics.json index cae9ca27a..8ba06403c 100644 --- a/src/shared/i18n/zh/diagnostics.json +++ b/src/shared/i18n/zh/diagnostics.json @@ -16,6 +16,9 @@ "workerStopped": "后台任务已被停止", "csgGraphMissing": "没有为 {{name}} 记录 CSG 图", "shaderAnchorMissing": "渲染器不再提供 {{name}}", + "renderEngineUnavailable": "高级引擎尚未构建", + "renderEngineGradingMissing": "高级引擎按文件里的原样显示已校正的天空", + "renderEngineCascadesMissing": "高级引擎不绘制级联阴影", "channelShaderMissing": "没有着色器能推导出 {{channel}} 通道", "channelSourceEmpty": "{{channel}} 通道的来源不含任何像素", "passSourceMissing": "这一道处理需要一个可读取的来源", diff --git a/src/shared/i18n/zh/environment.json b/src/shared/i18n/zh/environment.json index a80249270..f7dcf366e 100644 --- a/src/shared/i18n/zh/environment.json +++ b/src/shared/i18n/zh/environment.json @@ -108,6 +108,8 @@ "tone_cineonHint": "取自胶片的曲线,对比更强", "tone_aces": "ACES", "tone_acesHint": "数字电影的曲线,能避免高光被削平成纯白", + "tone_agx": "AgX", + "tone_agxHint": "更新的胶片曲线,在 ACES 会让浓郁色彩偏移的地方,它仍能守住这些颜色", "exposure": "曝光", "quality": "质量", "quality_performance": "性能", diff --git a/src/shared/i18n/zh/postfx.json b/src/shared/i18n/zh/postfx.json index 8b24f0140..4bc02bef9 100644 --- a/src/shared/i18n/zh/postfx.json +++ b/src/shared/i18n/zh/postfx.json @@ -63,7 +63,9 @@ "effect_sharpen": "锐化", "effect_sharpenHint": "通过 USM 锐化蒙版突出细部", "effect_smaa": "SMAA", + "effect_traa": "TRAA", "effect_smaaHint": "边缘比 FXAA 更干净,代价略高", + "effect_traaHint": "用之前的帧把每条边缘磨平,需要高级引擎", "effect_ssaa": "超采样", "effect_ssaaHint": "多次绘制场景并取平均,得到最干净的边缘", "effect_ssao": "环境光遮蔽(SSAO)", diff --git a/src/shared/i18n/zh/settings.json b/src/shared/i18n/zh/settings.json index c71814f61..d750748fc 100644 --- a/src/shared/i18n/zh/settings.json +++ b/src/shared/i18n/zh/settings.json @@ -246,6 +246,12 @@ "title": "加速", "help": "按住加速键期间速度乘以多少。设为 3 就快三倍——足以穿过一个大场景而不用改上面那个设置。" }, + "renderEngine": { + "title": "渲染引擎", + "help": "新场景以什么打开。这个问题在每次创建文档时都会提出,答案写进文档本身:本设置只是预先填好该字段,不会改动任何已有场景。兼容在所有机器上都能运行;高级的光照和反射更好,但需要较新的显卡。没有 WebGPU 适配器的机器会自行回落到兼容,并在日志中说明。", + "gl": "兼容", + "gpu": "高级" + }, "shadows": { "title": "投射阴影", "help": "计算灯光投出的阴影。每一盏投射阴影的灯光每帧都要多花一次渲染通道:关掉这一项是给沉重场景减负最直接的办法。" @@ -274,6 +280,10 @@ "title": "阴影精细度", "help": "每一盏投射阴影的灯光所计算的阴影贴图的边长,以像素计。加倍会让内存变成四倍:几个对象的场景用 2048 就绰绰有余,一盏灯要照亮整个布景时 4096 才值得。" }, + "csm": { + "title": "级联阴影", + "help": "把太阳的阴影切成三张贴图,视野的每个深度层各一张,而不是用一张铺满相机所见的一切。开阔场景需要它;单一布景毫无所得,却要多付三次深度渲染。" + }, "snapTranslate": { "title": "移动步长", "help": "吸附打开时,对象一档走多远,以米计。吸附本身在场景工具栏里打开;这个值只说明它有多细。" diff --git a/src/shared/ipcDiagnostics.ts b/src/shared/ipcDiagnostics.ts index 29997418b..982b8c7da 100644 --- a/src/shared/ipcDiagnostics.ts +++ b/src/shared/ipcDiagnostics.ts @@ -144,9 +144,13 @@ export type TraceScope = // The renderer's own SILENCE, whether or not anything awaited: the calls that cross to the main // process throw their answer away, so a full disk on a rename reaches no `catch` — and a caught // rejection that ends in a state rather than a sentence says nothing either. - 'shell.dropped' + | 'shell.dropped' + // A viewport that opened on the Compatible engine after being asked for the Advanced one. A + // TRACE and not a scope, deliberately: the picture is right, nothing was lost, and a toast + // per panel would report a machine's specification as a failure of the document. + | 'render.fallback' -/** No level: a trace is always a failure, and a field with one legal value is a branch to test. */ +/** No level: a trace never reaches a surface, so nothing would read one. */ export type TraceEntry = { scope: TraceScope; message: string } export const LOG_LEVELS: readonly LogLevel[] = ['info', 'warn', 'error']