From 3169331effb3e920f07bcc15912e1f753a6e7e3a Mon Sep 17 00:00:00 2001 From: Alban Pasquelin Date: Thu, 10 Sep 2026 20:11:19 +0200 Subject: [PATCH 01/13] =?UTF-8?q?Ajoute=20les=20cascades,=20l'anisotropie?= =?UTF-8?q?=20maximale=20et=20la=20courbe=20AgX=20aux=20nouvelles=20sc?= =?UTF-8?q?=C3=A8nes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Étape 1 du chantier C6 (moteur de rendu). - `RenderPolicy.csm`, désactivé par défaut, et `csm.ts` qui enveloppe l'addon `CSM` : trois lumières de cascade, le soleil du document retiré du casting, les matériaux habillés une seule fois et remis en état à la libération. Réglage exposé dans l'espace 3D, dépendant de « Ombres portées ». - Les textures sont lues avec `capabilities.getMaxAnisotropy()` : le filtrage anisotrope coûte de la bande passante d'échantillonnage, pas un octet de mémoire de texture, donc plafonner sous ce que la carte offre ne rend rien. - `agx` rejoint `ToneMapping` et devient la courbe des scènes CRÉÉES, via `NEW_SCENE_WORLD` ; `DEFAULT_WORLD` reste sur `none`, donc un projet relu s'ouvre exactement comme il a été écrit. Le point 1.1 du spec n'est pas appliqué et c'est mesuré, pas un arbitrage : `WebGLProgram.shadowMapTypeDefines` de three 0.185 ne nomme que PCF et VSM, donc `PCFSoftShadowMap` retombe sur `SHADOWMAP_TYPE_BASIC` — une comparaison non filtrée, `shadow.radius` ignoré. `PCFShadowMap` EST le mode doux (disque de Vogel à cinq taps). Le commentaire de `MAP_TYPES` porte la vérification. --- src/main/export/gameExport.test.ts | 1 + .../src/engines/material/MaterialRenderer.ts | 8 +- .../src/engines/scene/SceneRendererAids.ts | 5 + .../scene/SceneRendererConstruction.ts | 3 +- .../src/engines/scene/SceneRendererDisplay.ts | 3 + .../engines/scene/SceneRendererLifecycle.ts | 8 + .../engines/scene/SceneRendererMaterials.ts | 3 + .../engines/scene/SceneRendererResources.ts | 4 + .../src/engines/scene/SceneRendererShadows.ts | 20 +++ src/renderer/src/engines/scene/csm.test.ts | 120 +++++++++++++ src/renderer/src/engines/scene/csm.ts | 168 ++++++++++++++++++ .../src/engines/scene/defaultScene.test.ts | 8 + .../src/engines/scene/defaultScene.ts | 20 ++- .../src/engines/scene/sceneTemplates.ts | 6 +- .../src/engines/scene/shadowLevels.ts | 8 +- src/renderer/src/engines/scene/shadows.ts | 10 +- .../src/engines/scene/textureCache.test.ts | 10 ++ .../src/engines/scene/textureCache.ts | 24 +++ .../src/engines/scene/worldBinding.test.ts | 6 +- .../src/engines/scene/worldBinding.ts | 2 + .../src/engines/skybox/SkyboxRenderer.ts | 8 +- src/shared/domain/renderPolicy.ts | 14 ++ src/shared/domain/scene.ts | 7 +- src/shared/domain/settingsRegistrySecond.ts | 8 + src/shared/i18n/ar/environment.json | 2 + src/shared/i18n/ar/settings.json | 4 + src/shared/i18n/de/environment.json | 2 + src/shared/i18n/de/settings.json | 4 + src/shared/i18n/en/environment.json | 2 + src/shared/i18n/en/settings.json | 4 + src/shared/i18n/es/environment.json | 2 + src/shared/i18n/es/settings.json | 4 + src/shared/i18n/fr/environment.json | 2 + src/shared/i18n/fr/settings.json | 4 + src/shared/i18n/hi/environment.json | 2 + src/shared/i18n/hi/settings.json | 4 + src/shared/i18n/id/environment.json | 2 + src/shared/i18n/id/settings.json | 4 + src/shared/i18n/it/environment.json | 2 + src/shared/i18n/it/settings.json | 4 + src/shared/i18n/ja/environment.json | 2 + src/shared/i18n/ja/settings.json | 4 + src/shared/i18n/ko/environment.json | 2 + src/shared/i18n/ko/settings.json | 4 + src/shared/i18n/pt/environment.json | 2 + src/shared/i18n/pt/settings.json | 4 + src/shared/i18n/ru/environment.json | 2 + src/shared/i18n/ru/settings.json | 4 + src/shared/i18n/tr/environment.json | 2 + src/shared/i18n/tr/settings.json | 4 + src/shared/i18n/vi/environment.json | 2 + src/shared/i18n/vi/settings.json | 4 + src/shared/i18n/zh/environment.json | 2 + src/shared/i18n/zh/settings.json | 4 + 54 files changed, 550 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/engines/scene/csm.test.ts create mode 100644 src/renderer/src/engines/scene/csm.ts diff --git a/src/main/export/gameExport.test.ts b/src/main/export/gameExport.test.ts index 7c5e44568..b6e41699d 100644 --- a/src/main/export/gameExport.test.ts +++ b/src/main/export/gameExport.test.ts @@ -413,6 +413,7 @@ describe('a game written to run with no studio', () => { shadows: true, shadowQuality: 'soft', shadowMapSize: 1024, + csm: false, quality: 'performance', fieldOfView: 50, gridSize: 30, diff --git a/src/renderer/src/engines/material/MaterialRenderer.ts b/src/renderer/src/engines/material/MaterialRenderer.ts index 780f5262c..6f38ab2eb 100644 --- a/src/renderer/src/engines/material/MaterialRenderer.ts +++ b/src/renderer/src/engines/material/MaterialRenderer.ts @@ -12,7 +12,12 @@ import { import { PBR_CHANNELS, type PbrChannel } from '@shared/domain/material' import { reportFailure } from '@/services/diagnostics' import { createTextureBinding, type TextureBinding } from '../scene/textureBinding' -import { createTextureCache, type TextureCache, type TextureSource } from '../scene/textureCache' +import { + createTextureCache, + maxAnisotropyOf, + type TextureCache, + type TextureSource, +} from '../scene/textureCache' import { createSkyBinding, type SkyBinding } from '../viewport/skyBinding' import { createEnvironment, type ViewportEnvironment } from '../viewport/environment' import { SCHEME_OF, type NavigationScheme } from '@shared/domain/navigationPreset' @@ -129,6 +134,7 @@ export class MaterialRenderer { (assetId, error) => reportFailure('material.map', assetId, error), options.assetVersion, options.livePreview, + () => maxAnisotropyOf(this.viewport.gl), ) this.sky = createSkyBinding(this.cache, () => this.paintBackground()) // One per channel, built with the cache and never after: the reference, the race and the diff --git a/src/renderer/src/engines/scene/SceneRendererAids.ts b/src/renderer/src/engines/scene/SceneRendererAids.ts index ef7a4b55e..5ba145b58 100644 --- a/src/renderer/src/engines/scene/SceneRendererAids.ts +++ b/src/renderer/src/engines/scene/SceneRendererAids.ts @@ -82,6 +82,11 @@ export abstract class SceneRendererAids extends SceneRendererValidation { // 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. if (shadowsResized || gridMoved) this.tuneShadows() + // Only on what `CSM` reads at construction: a rebuild takes the three lights out of the + // scene and recompiles every material they dressed. + if (next.csm !== held.csm || next.shadows !== held.shadows || shadowsResized) { + this.syncCascades() + } if (gridMoved && this.viewport.canvas) this.applyPalette() if (aidsMoved(held, next)) this.refreshAids() if (helperVisibilityMoved(held, next)) this.showAidsForSelection() diff --git a/src/renderer/src/engines/scene/SceneRendererConstruction.ts b/src/renderer/src/engines/scene/SceneRendererConstruction.ts index c92d9780e..12dc2ecde 100644 --- a/src/renderer/src/engines/scene/SceneRendererConstruction.ts +++ b/src/renderer/src/engines/scene/SceneRendererConstruction.ts @@ -15,7 +15,7 @@ import { createSkinWeights } from '../character/skinWeights' import { createBvhBuilder } from './bvhBuilder' import './bvhPatches' import { createCsgEvaluator } from '../csg/csgEvaluator' -import { createTextureCache, loadTexture } from './textureCache' +import { createTextureCache, loadTexture, maxAnisotropyOf } from './textureCache' import { createReliefSurface } from './reliefSurface' import { createScatterSurface } from './scatterSurface' import { createReliefBuilder } from './reliefBuilder' @@ -53,6 +53,7 @@ export class SceneRendererConstruction extends SceneRendererFrame { (assetId, error) => reportFailure('scene.texture', assetId, error), options.assetVersion, options.livePreview, + () => maxAnisotropyOf(this.viewport.gl), ) this.buildModelSources() this.buildShapeWorkers() diff --git a/src/renderer/src/engines/scene/SceneRendererDisplay.ts b/src/renderer/src/engines/scene/SceneRendererDisplay.ts index 6cd9ab670..0989a4ae6 100644 --- a/src/renderer/src/engines/scene/SceneRendererDisplay.ts +++ b/src/renderer/src/engines/scene/SceneRendererDisplay.ts @@ -142,6 +142,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, before the dressing that decides what the pass draws. + this.cascades?.follow(camera) this.zonedTo = camera const mode = this.displays[index] ?? this.displays[0] ?? 'shaded' diff --git a/src/renderer/src/engines/scene/SceneRendererLifecycle.ts b/src/renderer/src/engines/scene/SceneRendererLifecycle.ts index a76fd9d9c..3826e7bfd 100644 --- a/src/renderer/src/engines/scene/SceneRendererLifecycle.ts +++ b/src/renderer/src/engines/scene/SceneRendererLifecycle.ts @@ -41,6 +41,8 @@ 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 sweepCompositions(state: SceneState): void protected abstract syncNode(node: SceneNode): void protected abstract release(id: string): void @@ -123,6 +125,9 @@ export abstract class SceneRendererLifecycle extends SceneRendererResources { // 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 +213,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.cascades?.dress(this.viewport.scene) 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/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..af0ba2576 100644 --- a/src/renderer/src/engines/scene/SceneRendererShadows.ts +++ b/src/renderer/src/engines/scene/SceneRendererShadows.ts @@ -19,6 +19,7 @@ import { applyMaterial, applyNegative, applySprite, lightFor, standTarget } from import { createMaterialTextures, createSpriteTexture } from './materialTextures' import { reportFailure } from '@/services/diagnostics' import { limitShadowUpdates, throwsOf, tuneShadowMaps } from './shadows' +import { cascadeSettingsFor, createCascadeShadows } from './csm' import { applyWireOverlay } from './sceneView' import './bvhPatches' import { isNegative } from '../csg/carve' @@ -106,6 +107,25 @@ 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 wanted = this.view.csm && this.view.shadows && this.viewport.gl !== null + this.cascades?.release() + this.cascades = wanted + ? createCascadeShadows(this.viewport.scene, cascadeSettingsFor(this.view), () => + this.redraw(), + ) + : null + if (!this.cascades) return + this.cascades.aim(this.shadowThrow) + this.cascades.dress(this.viewport.scene) } /** 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..7a05ee35c --- /dev/null +++ b/src/renderer/src/engines/scene/csm.test.ts @@ -0,0 +1,120 @@ +import { DirectionalLight, Mesh, MeshStandardMaterial, PerspectiveCamera, Scene } from 'three' +import { describe, expect, it } from 'vitest' +import { DEFAULT_RENDER_POLICY } from '@shared/domain/renderPolicy' +import { cascadeSettingsFor, 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('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('gives the sun its own map back when the cascades go', () => { + const { scene, sun } = litScene() + const shadows = createCascadeShadows(scene, settings, () => {}) + shadows.dress(scene) + + shadows.release() + + 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 = mesh.material + if (Array.isArray(material)) throw new Error('one material') + material.needsUpdate = false + const shadows = createCascadeShadows(scene, settings, () => {}) + + shadows.dress(scene) + + expect(material.defines?.USE_CSM).toBe(1) + expect(material.version).toBeGreaterThan(0) + }) + + it('leaves a material that carries a patch of its own alone', () => { + const { scene, mesh } = litScene() + const material = mesh.material + if (Array.isArray(material)) throw new Error('one material') + const patch = () => {} + material.onBeforeCompile = patch + const shadows = createCascadeShadows(scene, settings, () => {}) + + shadows.dress(scene) + + expect(material.onBeforeCompile).toBe(patch) + expect(material.defines?.USE_CSM).toBeUndefined() + }) + + 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) + }) +}) + +/** 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..26fe95f6d --- /dev/null +++ b/src/renderer/src/engines/scene/csm.ts @@ -0,0 +1,168 @@ +/** + * 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, Object3D, PerspectiveCamera, type Camera, type Material } from 'three' +import { CSM } from 'three/addons/csm/CSM.js' +import type { RenderPolicy } from '@shared/domain/renderPolicy' +import { VIEW_DISTANCE } from '@shared/domain/renderPolicy' +import { shadowMapSizeFor } from './viewportQuality' +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 + +/** + * 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. Refits only when that camera is not the one already fitted. */ + follow: (camera: Camera) => void + /** + * 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 exactly as it was: lights out, defines off, materials rebuilt. */ + 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 + + /** What was dressed, so a scene of ten thousand meshes is walked without dressing twice. */ + const dressed = new WeakSet() + /** The suns that were casting when the cascades took over, to be given their maps back. */ + const held = new Set() + const own = new Set(csm.lights) + let fitted: Camera | null = null + + return { + aim: throwing => { + const along = throwing?.along[0] + if (!along) return + if (csm.lightDirection.x === along.x && csm.lightDirection.z === along.z) return + + csm.lightDirection.set(along.x, along.y, along.z).normalize() + csm.updateFrustums() + requestRender() + }, + + follow: camera => { + // The identity, not the matrix: a quad layout hands four cameras and each one wants its + // own bands, while an orbit on one camera moves the position the update already reads. + if (fitted !== camera) { + csm.camera = camera + csm.updateFrustums() + fitted = camera + } + csm.update() + for (const light of csm.lights) light.shadow.needsUpdate = true + }, + + dress: root => { + root.traverse(child => { + if (child instanceof DirectionalLight && !own.has(child)) { + if (child.castShadow) held.add(child) + child.castShadow = false + } + for (const material of materialsOf(child)) { + if (dressed.has(material) || !receivesCascades(material)) continue + dressed.add(material) + csm.setupMaterial(material) + // 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 + } + }) + }, + + release: () => { + // `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 light of held) light.castShadow = true + held.clear() + fitted = null + requestRender() + }, + } +} + +/** + * Whether a material may be dressed. Anything already carrying an `onBeforeCompile` is left + * alone: `setupMaterial` OVERWRITES that hook and `dispose` deletes it outright, so the relief + * splat — the one material of the scene with a patch of its own — would lose its program for + * good. A lit material only: a helper's line or a sprite receives no shadow to cascade. + */ +function receivesCascades(material: Material): boolean { + // `hasOwn` rather than a comparison: three declares the hook on `Material.prototype`, so a + // material carries one of its OWN exactly when somebody assigned it. + return 'isMeshStandardMaterial' in material && !Object.hasOwn(material, 'onBeforeCompile') +} + +function materialsOf(object: Object3D): readonly Material[] { + const material: unknown = Reflect.get(object, 'material') + if (Array.isArray(material)) return material + return isMaterial(material) ? [material] : [] +} + +function isMaterial(value: unknown): value is Material { + return typeof value === 'object' && value !== null && 'isMaterial' in value +} + +/** Fitted against nothing until the first pane says which camera it draws with. */ +const PLACEHOLDER = new PerspectiveCamera() 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..9b9177211 100644 --- a/src/renderer/src/engines/scene/defaultScene.ts +++ b/src/renderer/src/engines/scene/defaultScene.ts @@ -1,9 +1,25 @@ 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. + */ +export const NEW_SCENE_WORLD: SceneWorld = { ...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 +35,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/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/shadowLevels.ts b/src/renderer/src/engines/scene/shadowLevels.ts index ffb9cbb97..b3606dff4 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` is left out + * with them: cascades answer how far the set REACHES, not how fine its maps are, so an open + * landscape wants them at every level and a single set at none. + */ +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..c041021fb 100644 --- a/src/renderer/src/engines/scene/shadows.ts +++ b/src/renderer/src/engines/scene/shadows.ts @@ -5,7 +5,15 @@ 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, 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..689698ee1 100644 --- a/src/renderer/src/engines/scene/textureCache.ts +++ b/src/renderer/src/engines/scene/textureCache.ts @@ -142,6 +142,17 @@ async function tiffTexture(bytes: Uint8Array, orientation: PictureOrientation): return texture } +/** What of a renderer this reads — narrower than `WebGLRenderer`, which jsdom cannot build. */ +type AnisotropyHolder = { capabilities: { getMaxAnisotropy: () => number } } + +/** + * What a card allows, or `1` before there is a card to ask. Read through here by the three + * engines that build a cache, so none of them has to reach into `capabilities` itself. + */ +export function maxAnisotropyOf(renderer: AnisotropyHolder | null | undefined): number { + return renderer?.capabilities.getMaxAnisotropy() ?? 1 +} + export type TextureCache = { /** * Takes a reference on an asset read in a given colour space, loading it if nobody holds it @@ -195,6 +206,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 — `maxAnisotropyOf`, 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 +239,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/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/skybox/SkyboxRenderer.ts b/src/renderer/src/engines/skybox/SkyboxRenderer.ts index cfcefd9a2..dc0c98856 100644 --- a/src/renderer/src/engines/skybox/SkyboxRenderer.ts +++ b/src/renderer/src/engines/skybox/SkyboxRenderer.ts @@ -6,7 +6,12 @@ import { createRefCache, type RefCache } from '../core/refCache' 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 { + createTextureCache, + maxAnisotropyOf, + type TextureCache, + type TextureSource, +} from '../scene/textureCache' import { createEnvironment, type ViewportEnvironment } from '../viewport/environment' import { createTestObjects, type TestObjects } from '../viewport/testObjects' import { aimAlong } from '../viewport/lookAround' @@ -120,6 +125,7 @@ export class SkyboxRenderer { (assetId, error) => reportFailure('skybox.source', assetId, error), options.assetVersion, options.livePreview, + () => maxAnisotropyOf(this.viewport.gl), ) // 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. diff --git a/src/shared/domain/renderPolicy.ts b/src/shared/domain/renderPolicy.ts index 1ff23be6c..e30e48625 100644 --- a/src/shared/domain/renderPolicy.ts +++ b/src/shared/domain/renderPolicy.ts @@ -15,6 +15,17 @@ export type RenderPolicy = { 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. */ @@ -53,6 +64,7 @@ export const DEFAULT_RENDER_POLICY: RenderPolicy = Object.freeze({ shadows: true, shadowQuality: 'soft', shadowMapSize: 2048, + csm: false, quality: 'balanced', fieldOfView: 60, gridSize: 20, @@ -67,6 +79,7 @@ export function renderPolicyOf(view: RenderPolicy): RenderPolicy { shadows: view.shadows, shadowQuality: view.shadowQuality, shadowMapSize: view.shadowMapSize, + csm: view.csm, quality: view.quality, fieldOfView: view.fieldOfView, gridSize: view.gridSize, @@ -90,6 +103,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..4558391f3 100644 --- a/src/shared/domain/scene.ts +++ b/src/shared/domain/scene.ts @@ -119,10 +119,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 +130,7 @@ export const TONE_MAPPINGS: readonly ToneMapping[] = [ 'reinhard', 'cineon', 'aces', + 'agx', ] /** diff --git a/src/shared/domain/settingsRegistrySecond.ts b/src/shared/domain/settingsRegistrySecond.ts index 396655afd..24f400370 100644 --- a/src/shared/domain/settingsRegistrySecond.ts +++ b/src/shared/domain/settingsRegistrySecond.ts @@ -90,6 +90,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/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/settings.json b/src/shared/i18n/ar/settings.json index 7780b5848..6453e58ae 100644 --- a/src/shared/i18n/ar/settings.json +++ b/src/shared/i18n/ar/settings.json @@ -274,6 +274,10 @@ "title": "دقة الظلال", "help": "ضلع خريطة الظل، بالبكسل، التي يحسبها كل ضوء يلقي ظلًّا. ومضاعفته تكلّف أربعة أضعاف الذاكرة: 2048 يكفي لمشهد من بضعة كائنات، و4096 يستحق ثمنه حين يغطي ضوء واحد ديكورًا كاملًا." }, + "csm": { + "title": "ظلال متدرّجة", + "help": "يقسّم ظل الشمس إلى ثلاث خرائط، واحدة لكل نطاق عمق من المشهد، بدل خريطة واحدة ممدودة على كل ما تراه الكاميرا. ما يحتاجه منظر مفتوح؛ أما مشهد واحد فلا يكسب شيئًا ويدفع ثلاث تمريرات عمق." + }, "snapTranslate": { "title": "خطوة النقل", "help": "كم يقطع الكائن في خطوة واحدة والتجاذب مفعّل، بالأمتار. أما التجاذب نفسه فيُشغَّل من شريط أدوات المشهد؛ وهذه القيمة تقول مدى دقته فقط." 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/settings.json b/src/shared/i18n/de/settings.json index 019c7f7ab..feef710a4 100644 --- a/src/shared/i18n/de/settings.json +++ b/src/shared/i18n/de/settings.json @@ -274,6 +274,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/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/settings.json b/src/shared/i18n/en/settings.json index 549d6733a..d1bbffbe6 100644 --- a/src/shared/i18n/en/settings.json +++ b/src/shared/i18n/en/settings.json @@ -274,6 +274,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/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/settings.json b/src/shared/i18n/es/settings.json index 3910822bc..d523f1b4d 100644 --- a/src/shared/i18n/es/settings.json +++ b/src/shared/i18n/es/settings.json @@ -274,6 +274,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/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/settings.json b/src/shared/i18n/fr/settings.json index da316d029..12a4be739 100644 --- a/src/shared/i18n/fr/settings.json +++ b/src/shared/i18n/fr/settings.json @@ -274,6 +274,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/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/settings.json b/src/shared/i18n/hi/settings.json index d388994a1..fa15b720b 100644 --- a/src/shared/i18n/hi/settings.json +++ b/src/shared/i18n/hi/settings.json @@ -274,6 +274,10 @@ "title": "छाया की बारीकी", "help": "छाया डालने वाली हर लाइट जो शैडो मैप बनाती है उसकी भुजा, पिक्सेल में। इसे दोगुना करने पर मेमोरी चार गुना लगती है: कुछ ऑब्जेक्ट वाले सीन के लिए 2048 काफ़ी है, और जब एक ही लाइट पूरे सेट को ढके तो 4096 अपनी क़ीमत वसूल करता है।" }, + "csm": { + "title": "कैस्केड छायाएँ", + "help": "सूरज की छाया को कैमरे के देखे हर हिस्से पर खिंची एक ही मैप की जगह, दृश्य की हर गहराई-पट्टी के लिए एक, यानी तीन मैप में बाँट देता है। खुले दृश्य को यही चाहिए; एक अकेले सेट को इससे कुछ नहीं मिलता और तीन डेप्थ पास चुकाने पड़ते हैं।" + }, "snapTranslate": { "title": "चलने का स्टेप", "help": "स्नैपिंग चालू होने पर कोई ऑब्जेक्ट एक स्टेप में कितनी दूर जाता है, मीटर में। स्नैपिंग ख़ुद सीन के टूलबार से चालू होती है; यह केवल बताता है कि वह कितनी बारीक है।" 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/settings.json b/src/shared/i18n/id/settings.json index 414a52779..2ba3565ba 100644 --- a/src/shared/i18n/id/settings.json +++ b/src/shared/i18n/id/settings.json @@ -274,6 +274,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/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/settings.json b/src/shared/i18n/it/settings.json index 46a226c07..7ffd98d3b 100644 --- a/src/shared/i18n/it/settings.json +++ b/src/shared/i18n/it/settings.json @@ -274,6 +274,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/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/settings.json b/src/shared/i18n/ja/settings.json index 347292e38..cd72da38f 100644 --- a/src/shared/i18n/ja/settings.json +++ b/src/shared/i18n/ja/settings.json @@ -274,6 +274,10 @@ "title": "シャドウディテール", "help": "シャドウマップのサイド。ピクセルで。各シャドウキャストライトがコンピュート。倍増は4倍のメモリをコストしています。2048は数個のオブジェクトのシーンに多くあります。4096は1本の光がセット全体をカバーするとき稼ぎます。" }, + "csm": { + "title": "カスケードシャドウ", + "help": "カメラが見るすべてに一枚の影マップを引き伸ばす代わりに、太陽の影を視界の奥行き帯ごとに三枚へ分けます。開けた風景に必要なもので、単一のセットでは得るものがなく、奥行きパスを三回払うだけです。" + }, "snapTranslate": { "title": "ステップを移動", "help": "スナップがオンのときにオブジェクトが1ステップで移動する距離。メートル単位。スナップ自体がシーンツールバーからオンになります。これだけは細かいもの。" 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/settings.json b/src/shared/i18n/ko/settings.json index 65f6d4b6c..a20aff67e 100644 --- a/src/shared/i18n/ko/settings.json +++ b/src/shared/i18n/ko/settings.json @@ -274,6 +274,10 @@ "title": "그림자 정밀도", "help": "그림자를 드리우는 조명마다 계산하는 그림자 맵의 한 변 길이로, 픽셀 단위입니다. 두 배로 하면 메모리는 네 배가 듭니다: 오브젝트가 몇 개뿐인 씬에는 2048로 충분하고, 조명 하나가 세트 전체를 덮을 때는 4096이 값을 합니다." }, + "csm": { + "title": "캐스케이드 그림자", + "help": "카메라가 보는 전부에 한 장을 늘여 붙이는 대신, 태양의 그림자를 시야의 깊이 구간마다 한 장씩 세 장으로 나눕니다. 열린 풍경에 필요한 방식이며, 단일 세트에서는 얻는 것 없이 깊이 패스만 세 번 치릅니다." + }, "snapTranslate": { "title": "이동 간격", "help": "스냅이 켜져 있을 때 오브젝트가 한 칸에 나아가는 거리로, 미터 단위입니다. 스냅 자체는 씬 도구 모음에서 켜며, 이 값은 그것이 얼마나 고운지만 정합니다." 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/settings.json b/src/shared/i18n/pt/settings.json index 5da08c9bb..5afd9484a 100644 --- a/src/shared/i18n/pt/settings.json +++ b/src/shared/i18n/pt/settings.json @@ -274,6 +274,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/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/settings.json b/src/shared/i18n/ru/settings.json index 6d594e39f..7e809adf3 100644 --- a/src/shared/i18n/ru/settings.json +++ b/src/shared/i18n/ru/settings.json @@ -274,6 +274,10 @@ "title": "Детализация теней", "help": "Сторона карты теней в пикселях, которую вычисляет каждый источник света, отбрасывающий тень. Удвоение стоит вчетверо больше памяти: 2048 с запасом хватает сцене из нескольких объектов, 4096 оправдывает себя, когда один источник освещает целую декорацию." }, + "csm": { + "title": "Каскадные тени", + "help": "Делит тень солнца на три карты, по одной на каждый слой глубины вида, вместо одной, растянутой на всё, что видит камера. То, что нужно открытому пейзажу; одиночной сцене это ничего не даёт, а стоит трёх проходов глубины." + }, "snapTranslate": { "title": "Шаг перемещения", "help": "На сколько объект продвигается за один шаг при включённой привязке, в метрах. Сама привязка включается на панели инструментов сцены; это значение говорит только о том, насколько она мелкая." 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/settings.json b/src/shared/i18n/tr/settings.json index 0bdca679d..77a8c1924 100644 --- a/src/shared/i18n/tr/settings.json +++ b/src/shared/i18n/tr/settings.json @@ -274,6 +274,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/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/settings.json b/src/shared/i18n/vi/settings.json index 334d6fe72..52e774abd 100644 --- a/src/shared/i18n/vi/settings.json +++ b/src/shared/i18n/vi/settings.json @@ -274,6 +274,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/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/settings.json b/src/shared/i18n/zh/settings.json index c71814f61..d6d72d248 100644 --- a/src/shared/i18n/zh/settings.json +++ b/src/shared/i18n/zh/settings.json @@ -274,6 +274,10 @@ "title": "阴影精细度", "help": "每一盏投射阴影的灯光所计算的阴影贴图的边长,以像素计。加倍会让内存变成四倍:几个对象的场景用 2048 就绰绰有余,一盏灯要照亮整个布景时 4096 才值得。" }, + "csm": { + "title": "级联阴影", + "help": "把太阳的阴影切成三张贴图,视野的每个深度层各一张,而不是用一张铺满相机所见的一切。开阔场景需要它;单一布景毫无所得,却要多付三次深度渲染。" + }, "snapTranslate": { "title": "移动步长", "help": "吸附打开时,对象一档走多远,以米计。吸附本身在场景工具栏里打开;这个值只说明它有多细。" From a706a8be76534fbc7e1ee4e479d00900dc2f1d48 Mon Sep 17 00:00:00 2001 From: Alban Pasquelin Date: Thu, 10 Sep 2026 20:22:00 +0200 Subject: [PATCH 02/13] =?UTF-8?q?Fait=20passer=20le=20rendu=20derri=C3=A8r?= =?UTF-8?q?e=20un=20driver=20et=20ouvre=20le=20choix=20Compatible=20/=20Av?= =?UTF-8?q?anc=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Étape 2 du chantier C6 : l'interface, pas encore de contenu GPU. `RenderDriver` (engines/render/) couvre les quatre points qui dépendent de l'API graphique et rien d'autre : construire le renderer, relire ses pixels, préfiltrer l'environnement, patcher le matériau standard. Même forme que `game/ports/` : l'interface d'un côté, chaque implémentation dans son fichier. - `glDriver` enveloppe le code existant tel quel — `readRenderPixels`, `environment.ts` et le patch de `materialShader` passent derrière sans que leur logique bouge ; un projet `gl` dessine exactement ce qu'il dessinait. - `gpuDriver` refuse les quatre appels sur la même phrase localisée. - `mountRenderer` retombe sur Compatible quand Avancé lève ou qu'aucun adaptateur n'a répondu : jamais d'écran noir, une trace `render.fallback` qui ne remonte pas en toast — l'image est juste, rien n'est perdu. - `gpuAdapter` interroge `navigator.gpu?.requestAdapter()` une seule fois. C'est le SEUL signal qui décide ; `adapter.info` n'entre pas dans le choix. - `PostEffectMeta.engines` : les trente effets existants sont `['gl']`. Les slots et la règle d'exclusivité ne bougent pas. `readPixels` rend une promesse des deux côtés bien que WebGL réponde tout de suite : la lecture GPU mappe un buffer et résout une frame plus tard, et les trois appelants étaient déjà dans un chemin asynchrone. Écart assumé sur le MUST 7 : `RenderPolicy` vit dans `Settings.three`, un réglage d'application, et la création de projet est un choix de dossier sans dialogue. Le sélecteur est donc dans l'espace 3D des préférences, et le verrou « pas de switch en direct » tient par construction — le moteur est lu au montage du viewport, ce que dit son texte d'aide. --- src/main/export/gameExport.test.ts | 1 + .../src/engines/material/MaterialRenderer.ts | 60 +++++----- src/renderer/src/engines/render/glDriver.ts | 33 ++++++ .../src/engines/render/gpuAdapter.test.ts | 51 ++++++++ src/renderer/src/engines/render/gpuAdapter.ts | 46 ++++++++ src/renderer/src/engines/render/gpuDriver.ts | 30 +++++ .../src/engines/render/renderDriver.test.ts | 95 +++++++++++++++ .../src/engines/render/renderDriver.ts | 109 ++++++++++++++++++ .../src/engines/scene/SceneRendererFilm.ts | 3 +- .../src/engines/scene/SceneRendererFlight.ts | 3 +- .../engines/scene/SceneRendererLifecycle.ts | 5 +- .../src/engines/scene/SceneRendererState.ts | 3 + .../engines/scene/SceneRendererValidation.ts | 3 +- src/renderer/src/engines/scene/csm.ts | 8 +- .../src/engines/scene/shadowLevels.ts | 8 +- .../src/engines/skybox/SkyboxRenderer.ts | 8 +- .../src/engines/viewport/ViewportState.ts | 5 + .../src/engines/viewport/ViewportSurface.ts | 32 ++++- .../viewport/viewportEngineSupport1.ts | 7 ++ src/shared/domain/postProcessing.test.ts | 9 ++ src/shared/domain/postProcessingRegistry.ts | 37 ++++++ src/shared/domain/renderEngine.ts | 13 +++ src/shared/domain/renderPolicy.test.ts | 19 +++ src/shared/domain/renderPolicy.ts | 10 ++ src/shared/domain/settingsRegistrySecond.ts | 12 ++ src/shared/i18n/ar/diagnostics.json | 1 + src/shared/i18n/ar/settings.json | 6 + src/shared/i18n/de/diagnostics.json | 1 + src/shared/i18n/de/settings.json | 6 + src/shared/i18n/en/diagnostics.json | 1 + src/shared/i18n/en/settings.json | 6 + src/shared/i18n/es/diagnostics.json | 1 + src/shared/i18n/es/settings.json | 6 + src/shared/i18n/fr/diagnostics.json | 1 + src/shared/i18n/fr/settings.json | 6 + src/shared/i18n/hi/diagnostics.json | 1 + src/shared/i18n/hi/settings.json | 6 + src/shared/i18n/id/diagnostics.json | 1 + src/shared/i18n/id/settings.json | 6 + src/shared/i18n/it/diagnostics.json | 1 + src/shared/i18n/it/settings.json | 6 + src/shared/i18n/ja/diagnostics.json | 1 + src/shared/i18n/ja/settings.json | 6 + src/shared/i18n/ko/diagnostics.json | 1 + src/shared/i18n/ko/settings.json | 6 + src/shared/i18n/pt/diagnostics.json | 1 + src/shared/i18n/pt/settings.json | 6 + src/shared/i18n/ru/diagnostics.json | 1 + src/shared/i18n/ru/settings.json | 6 + src/shared/i18n/tr/diagnostics.json | 1 + src/shared/i18n/tr/settings.json | 6 + src/shared/i18n/vi/diagnostics.json | 1 + src/shared/i18n/vi/settings.json | 6 + src/shared/i18n/zh/diagnostics.json | 1 + src/shared/i18n/zh/settings.json | 6 + src/shared/ipcDiagnostics.ts | 8 +- 56 files changed, 673 insertions(+), 50 deletions(-) create mode 100644 src/renderer/src/engines/render/glDriver.ts create mode 100644 src/renderer/src/engines/render/gpuAdapter.test.ts create mode 100644 src/renderer/src/engines/render/gpuAdapter.ts create mode 100644 src/renderer/src/engines/render/gpuDriver.ts create mode 100644 src/renderer/src/engines/render/renderDriver.test.ts create mode 100644 src/renderer/src/engines/render/renderDriver.ts create mode 100644 src/shared/domain/renderEngine.ts create mode 100644 src/shared/domain/renderPolicy.test.ts diff --git a/src/main/export/gameExport.test.ts b/src/main/export/gameExport.test.ts index b6e41699d..87822152c 100644 --- a/src/main/export/gameExport.test.ts +++ b/src/main/export/gameExport.test.ts @@ -410,6 +410,7 @@ 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, diff --git a/src/renderer/src/engines/material/MaterialRenderer.ts b/src/renderer/src/engines/material/MaterialRenderer.ts index 6f38ab2eb..20a064999 100644 --- a/src/renderer/src/engines/material/MaterialRenderer.ts +++ b/src/renderer/src/engines/material/MaterialRenderer.ts @@ -19,17 +19,10 @@ import { 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, - materialFrameOf, - patchFragment, - syncEdgeTransform, -} from './materialShader' +import { createUniforms, EDGE_DEFINE, materialFrameOf, syncEdgeTransform } from './materialShader' import { previewGeometry } from './previewGeometry' import { DEFAULT_TEXTURE_MATERIAL } from '@shared/domain/material' import type { EnvironmentRef } from '@shared/domain/scene' @@ -147,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 { @@ -176,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) diff --git a/src/renderer/src/engines/render/glDriver.ts b/src/renderer/src/engines/render/glDriver.ts new file mode 100644 index 000000000..ff6e6adb4 --- /dev/null +++ b/src/renderer/src/engines/render/glDriver.ts @@ -0,0 +1,33 @@ +/** + * 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 { WebGLRenderer } from 'three' +import { createEnvironment } from '../viewport/environment' +import { readRenderPixels } from '../scene/readRenderPixels' +import { bindUniforms, patchFragment } from '../material/materialShader' +import type { RenderDriver } from './renderDriver' + +export const glDriver: RenderDriver = { + engine: 'gl', + + createRenderer: ({ canvas, alpha }) => new WebGLRenderer({ canvas, antialias: true, alpha }), + + readPixels: async (renderer, target, width, height) => + readRenderPixels(renderer, target, width, height), + + createEnvironment, + + 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) + } + }, +} 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..c881b488b --- /dev/null +++ b/src/renderer/src/engines/render/gpuAdapter.test.ts @@ -0,0 +1,51 @@ +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 on a browser that exposes no WebGPU', async () => { + expect(await probeGpuAdapter()).toBe(false) + }) + + it('reads no adapter when the request is refused', async () => { + gpuAnswering(() => Promise.resolve(null)) + + expect(await probeGpuAdapter()).toBe(false) + }) + + it('reads no adapter when the request throws, which is the same answer', async () => { + 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/gpuDriver.ts b/src/renderer/src/engines/render/gpuDriver.ts new file mode 100644 index 000000000..8db8de750 --- /dev/null +++ b/src/renderer/src/engines/render/gpuDriver.ts @@ -0,0 +1,30 @@ +/** + * The Advanced engine: WebGPU, and nothing of it is built yet. + * + * A stub that refuses, deliberately: the seam is what this step delivers, and a half-built + * renderer behind it would show a black canvas where the fallback shows a picture. Every call + * raises the same sentence, and `ViewportSurface` answers it by mounting the Compatible engine. + */ +import { localizedError } from '@shared/localizedError' +import type { RenderDriver } from './renderDriver' + +/** Said once, so the four refusals cannot drift into four different sentences. */ +function notBuiltYet(): Error { + return localizedError('renderEngineUnavailable') +} + +export const gpuDriver: RenderDriver = { + engine: 'gpu', + createRenderer: () => { + throw notBuiltYet() + }, + readPixels: () => { + throw notBuiltYet() + }, + createEnvironment: () => { + throw notBuiltYet() + }, + patchMaterial: () => { + throw notBuiltYet() + }, +} diff --git a/src/renderer/src/engines/render/renderDriver.test.ts b/src/renderer/src/engines/render/renderDriver.test.ts new file mode 100644 index 000000000..5ba33f46b --- /dev/null +++ b/src/renderer/src/engines/render/renderDriver.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest' +import type { WebGLRenderer } from 'three' +import { + driverFor, + mountRenderer, + type RenderDriver, + type RenderDrivers, + type 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), + readPixels: () => Promise.resolve(new Uint8Array()), + createEnvironment: () => { + throw new Error('not asked for') + }, + patchMaterial: () => {}, + }) + return { gl: stub('gl'), gpu: { ...stub('gpu'), ...gpu } } +} + +function rendererStub(engine: 'gl' | 'gpu'): WebGLRenderer { + return { engine } as unknown as WebGLRenderer +} + +describe('which driver a policy gets', () => { + it('draws with the Compatible engine unless the Advanced one is asked for', () => { + const two = drivers() + expect(driverFor('gl', true, two)).toBe(two.gl) + }) + + it('draws with the Advanced engine when an adapter answered', () => { + const two = drivers() + expect(driverFor('gpu', true, two)).toBe(two.gpu) + }) + + it('keeps the Compatible engine while nobody has asked the adapter yet', () => { + // A mount cannot wait on `requestAdapter`, and a viewport that waited would show nothing. + const two = drivers() + expect(driverFor('gpu', null, two)).toBe(two.gl) + }) + + it('keeps the Compatible engine when the adapter refused', () => { + const two = drivers() + expect(driverFor('gpu', false, two)).toBe(two.gl) + }) +}) + +describe('mounting a renderer', () => { + 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() + }) + + it('says why when a machine with no adapter was asked for the Advanced engine', () => { + 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/renderDriver.ts b/src/renderer/src/engines/render/renderDriver.ts new file mode 100644 index 000000000..7f2c9e74e --- /dev/null +++ b/src/renderer/src/engines/render/renderDriver.ts @@ -0,0 +1,109 @@ +/** + * What DRAWS, behind one interface — the seam between the studio's engines and the graphics API + * underneath them. + * + * Four things depend on which API is running, and nothing else does: building the renderer, + * reading its pixels back, prefiltering an environment, and patching the standard material. + * Everything else in `engines/` speaks three.js objects, which both APIs share. + * + * The same shape as `game/ports/`: the interface here, each implementation in a file of its own. + */ +import type { Scene, WebGLRenderer, WebGLRenderTarget } from 'three' +import type { MeshStandardMaterial } from 'three' +import type { RenderEngine } from '@shared/domain/renderEngine' +import { localizedError } from '@shared/localizedError' +import { glDriver } from './glDriver' +import { gpuDriver } from './gpuDriver' +import type { ViewportEnvironment } from '../viewport/environment' +import type { MaterialUniforms } from '../material/materialShader' + +/** 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 `ViewportSurface`. */ + createRenderer: (request: RendererRequest) => WebGLRenderer + /** + * 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: WebGLRenderer, + target: WebGLRenderTarget, + width: number, + height: number, + ) => Promise + createEnvironment: ( + renderer: WebGLRenderer, + 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 — an engine that patches by nodes rather than by source never calls it. + */ + patchMaterial: ( + material: MeshStandardMaterial, + uniforms: MaterialUniforms, + onMissingAnchor: (anchor: string) => void, + ) => void +} + +/** The two implementations, named together so a caller — or a test — can swap either. */ +export type RenderDrivers = { gl: RenderDriver; gpu: RenderDriver } + +export const RENDER_DRIVERS: RenderDrivers = { gl: glDriver, gpu: gpuDriver } + +/** + * The driver a policy asks for — the Compatible one whenever the Advanced engine has nothing to + * draw with. `gpuReady` is what `probeGpuAdapter` found, `null` meaning nobody has asked yet: + * a mount cannot wait for an adapter, so the first one of a session opens Compatible and the + * answer is there for the next. + */ +export function driverFor( + engine: RenderEngine, + gpuReady: boolean | null, + drivers: RenderDrivers = RENDER_DRIVERS, +): RenderDriver { + return engine === 'gpu' && gpuReady === true ? drivers.gpu : drivers.gl +} + +/** What was mounted, which is not always what was asked for. */ +export type MountedRenderer = { renderer: WebGLRenderer; 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. + */ +export function mountRenderer( + request: RendererRequest, + engine: RenderEngine, + gpuReady: boolean | null, + onFallback: (error: unknown) => void, + drivers: RenderDrivers = RENDER_DRIVERS, +): MountedRenderer { + const wanted = driverFor(engine, gpuReady, drivers) + if (wanted === drivers.gl) { + // Said even with nothing thrown: choosing Advanced and being handed Compatible is the one + // case a reader has to be able to explain, and no adapter throws to explain it. + if (engine === 'gpu') onFallback(localizedError('renderEngineUnavailable')) + return { renderer: drivers.gl.createRenderer(request), driver: drivers.gl } + } + + 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/scene/SceneRendererFilm.ts b/src/renderer/src/engines/scene/SceneRendererFilm.ts index 677356e99..7510d199b 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 /** @@ -170,7 +169,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..ccbf9b2bb 100644 --- a/src/renderer/src/engines/scene/SceneRendererFlight.ts +++ b/src/renderer/src/engines/scene/SceneRendererFlight.ts @@ -12,7 +12,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 @@ -77,7 +76,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 3826e7bfd..faa9cbb28 100644 --- a/src/renderer/src/engines/scene/SceneRendererLifecycle.ts +++ b/src/renderer/src/engines/scene/SceneRendererLifecycle.ts @@ -3,7 +3,6 @@ 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' @@ -116,7 +115,9 @@ export abstract class SceneRendererLifecycle extends SceneRendererResources { // frame that shows it: the loop is asleep by then. onReady: () => this.redraw(), }) - this.environment = createEnvironment(renderer, this.viewport.scene, () => this.redraw()) + this.environment = this.viewport.driver.createEnvironment(renderer, this.viewport.scene, () => + this.redraw(), + ) 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. diff --git a/src/renderer/src/engines/scene/SceneRendererState.ts b/src/renderer/src/engines/scene/SceneRendererState.ts index 8e893a4f7..5dce717be 100644 --- a/src/renderer/src/engines/scene/SceneRendererState.ts +++ b/src/renderer/src/engines/scene/SceneRendererState.ts @@ -92,6 +92,9 @@ export abstract class SceneRendererState { protected options!: SceneRendererOptions protected viewport = new ViewportEngine({ + // 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. See `RenderPolicy.engine`. + engine: () => this.view.engine, onFrame: delta => this.advance(delta), onOverlay: renderer => this.viewHelper?.render(renderer), onPane: (index, camera) => this.dressPane(index, camera), diff --git a/src/renderer/src/engines/scene/SceneRendererValidation.ts b/src/renderer/src/engines/scene/SceneRendererValidation.ts index 8df1e77e2..75b7858d8 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 @@ -44,7 +43,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.ts b/src/renderer/src/engines/scene/csm.ts index 26fe95f6d..2f3aa8b65 100644 --- a/src/renderer/src/engines/scene/csm.ts +++ b/src/renderer/src/engines/scene/csm.ts @@ -7,7 +7,13 @@ * needs a define on every material that receives it — none of which a scene that was authored * without cascades may inherit silently. */ -import { DirectionalLight, Object3D, PerspectiveCamera, type Camera, type Material } from 'three' +import { + DirectionalLight, + PerspectiveCamera, + type Camera, + type Material, + type Object3D, +} from 'three' import { CSM } from 'three/addons/csm/CSM.js' import type { RenderPolicy } from '@shared/domain/renderPolicy' import { VIEW_DISTANCE } from '@shared/domain/renderPolicy' diff --git a/src/renderer/src/engines/scene/shadowLevels.ts b/src/renderer/src/engines/scene/shadowLevels.ts index b3606dff4..06b7f67f5 100644 --- a/src/renderer/src/engines/scene/shadowLevels.ts +++ b/src/renderer/src/engines/scene/shadowLevels.ts @@ -15,11 +15,11 @@ 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. `csm` is left out - * with them: cascades answer how far the set REACHES, not how fine its maps are, so an open - * landscape wants them at every level and a single set at none. + * 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 +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/skybox/SkyboxRenderer.ts b/src/renderer/src/engines/skybox/SkyboxRenderer.ts index dc0c98856..c29865acb 100644 --- a/src/renderer/src/engines/skybox/SkyboxRenderer.ts +++ b/src/renderer/src/engines/skybox/SkyboxRenderer.ts @@ -12,7 +12,7 @@ import { 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' @@ -166,7 +166,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/viewport/ViewportState.ts b/src/renderer/src/engines/viewport/ViewportState.ts index 08dd8097a..beff35f11 100644 --- a/src/renderer/src/engines/viewport/ViewportState.ts +++ b/src/renderer/src/engines/viewport/ViewportState.ts @@ -13,6 +13,8 @@ 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 } from '../render/renderDriver' import { ViewportNavigationTarget } from './ViewportNavigationTarget' import { ORIGIN, @@ -56,6 +58,9 @@ export abstract class ViewportState { protected renderer: WebGLRenderer | 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..2c3092f9d 100644 --- a/src/renderer/src/engines/viewport/ViewportSurface.ts +++ b/src/renderer/src/engines/viewport/ViewportSurface.ts @@ -1,8 +1,11 @@ -import { ACESFilmicToneMapping, Color, NoToneMapping, WebGLRenderer } from 'three' +import { ACESFilmicToneMapping, Color, NoToneMapping, type WebGLRenderer } from 'three' 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 { askedGpuAdapter, probeGpuAdapter } from '../render/gpuAdapter' +import { mountRenderer, type RenderDriver } from '../render/renderDriver' import { createGpuTimer, isGpuTimerContext } from './gpuTimer' import { ViewportMounting } from './ViewportMounting' @@ -46,8 +49,24 @@ export abstract class ViewportSurface extends ViewportMounting { return canvas } + /** + * 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): WebGLRenderer { - const renderer = new WebGLRenderer({ canvas, antialias: true, alpha: this.output.alpha }) + const wanted = this.options.engine?.() ?? 'gl' + if (wanted === 'gpu' && askedGpuAdapter() === null) void probeGpuAdapter() + + const mounted = mountRenderer( + { canvas, alpha: this.output.alpha === true }, + wanted, + askedGpuAdapter(), + error => traceFailure('render.fallback', wanted, error), + ) + this.renderDriver = mounted.driver + const renderer = mounted.renderer 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`. @@ -164,6 +183,15 @@ export abstract class ViewportSurface extends ViewportMounting { return this.renderer } + /** + * What is drawing — the four 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/viewportEngineSupport1.ts b/src/renderer/src/engines/viewport/viewportEngineSupport1.ts index b56336aac..b3a7d25ee 100644 --- a/src/renderer/src/engines/viewport/viewportEngineSupport1.ts +++ b/src/renderer/src/engines/viewport/viewportEngineSupport1.ts @@ -14,6 +14,7 @@ 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' @@ -59,6 +60,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/shared/domain/postProcessing.test.ts b/src/shared/domain/postProcessing.test.ts index 85d368f3e..34e5a77db 100644 --- a/src/shared/domain/postProcessing.test.ts +++ b/src/shared/domain/postProcessing.test.ts @@ -52,6 +52,15 @@ describe('the catalogue', () => { expect(wrong).toEqual([]) }) + it('names an engine for every effect, and only the Compatible one so far', () => { + // The Advanced engine builds none of them yet. A `gpu` appearing here without a factory + // behind it is a slot the chain would leave empty with nothing said. + const engines = POST_EFFECT_IDS.map(id => POST_EFFECTS[id].engines) + + expect(engines.every(named => named.length > 0)).toBe(true) + expect(engines.flat().filter(engine => engine !== 'gl')).toEqual([]) + }) + it('gives a fresh instance the defaults of its own effect', () => { expect(defaultParamsOf('vignette')).toEqual({ offset: 1, darkness: 1 }) }) diff --git a/src/shared/domain/postProcessingRegistry.ts b/src/shared/domain/postProcessingRegistry.ts index 0e814ba76..097067f20 100644 --- a/src/shared/domain/postProcessingRegistry.ts +++ b/src/shared/domain/postProcessingRegistry.ts @@ -6,6 +6,7 @@ * those may pull three.js in, so nothing here knows a `Pass` exists — `engines/postfx/` is the * one folder that does. */ +import { GL_ONLY, type RenderEngine } from './renderEngine' import type { FieldValue, PropertySpec } from './propertySpec' /** What a parameter holds. The same four shapes the inspector already renders. */ @@ -95,6 +96,12 @@ 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, and the exclusivity rule holds unchanged — 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 /** @@ -165,6 +172,7 @@ export const POST_EFFECTS: Record = { category: 'lighting', cost: 'high', slot: 'ao', + engines: GL_ONLY, duplicable: false, params: { radius: slider(0.01, 2, 0.01, 0.25), @@ -179,6 +187,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 +199,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 +208,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 +221,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 +233,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 +244,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 +257,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 +270,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 +289,7 @@ export const POST_EFFECTS: Record = { category: 'color', cost: 'low', slot: 'image', + engines: GL_ONLY, duplicable: false, params: { texture: picture(), @@ -283,6 +300,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 +308,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 +320,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 +335,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 +343,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 +351,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 +359,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 +371,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 +382,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 +395,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 +406,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 +419,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 +432,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 +444,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 +452,7 @@ export const POST_EFFECTS: Record = { category: 'stylized', cost: 'low', slot: 'image', + engines: GL_ONLY, duplicable: false, params: { wild: toggle(false) }, }, @@ -428,6 +460,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 +471,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 +484,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 +497,7 @@ export const POST_EFFECTS: Record = { category: 'aa', cost: 'low', slot: 'aa', + engines: GL_ONLY, duplicable: false, params: {}, }, @@ -469,6 +505,7 @@ export const POST_EFFECTS: Record = { category: 'aa', cost: 'medium', slot: 'aa', + engines: GL_ONLY, duplicable: false, params: {}, }, diff --git a/src/shared/domain/renderEngine.ts b/src/shared/domain/renderEngine.ts new file mode 100644 index 000000000..3463d0c95 --- /dev/null +++ b/src/shared/domain/renderEngine.ts @@ -0,0 +1,13 @@ +/** + * 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'] 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 e30e48625..6394a0f76 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,6 +12,12 @@ 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. */ @@ -61,6 +68,7 @@ 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, @@ -76,6 +84,7 @@ export const DEFAULT_RENDER_POLICY: RenderPolicy = Object.freeze({ */ export function renderPolicyOf(view: RenderPolicy): RenderPolicy { return { + engine: view.engine, shadows: view.shadows, shadowQuality: view.shadowQuality, shadowMapSize: view.shadowMapSize, @@ -96,6 +105,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, diff --git a/src/shared/domain/settingsRegistrySecond.ts b/src/shared/domain/settingsRegistrySecond.ts index 24f400370..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', diff --git a/src/shared/i18n/ar/diagnostics.json b/src/shared/i18n/ar/diagnostics.json index 22cadfbd5..0a2a1f50b 100644 --- a/src/shared/i18n/ar/diagnostics.json +++ b/src/shared/i18n/ar/diagnostics.json @@ -16,6 +16,7 @@ "workerStopped": "توقفت المهمة الخلفية", "csgGraphMissing": "لا يوجد مخطط CSG مسجل للعنصر {{name}}", "shaderAnchorMissing": "لم يعد محرّك الرسم يوفر {{name}}", + "renderEngineUnavailable": "محرّك «متقدّم» لم يُبنَ بعد", "channelShaderMissing": "لا يوجد مظلّل يشتق القناة {{channel}}", "channelSourceEmpty": "مصدر القناة {{channel}} لا يحتوي على أي بكسل", "passSourceMissing": "تحتاج مرحلة الرسم إلى مصدر تقرأ منه", diff --git a/src/shared/i18n/ar/settings.json b/src/shared/i18n/ar/settings.json index 6453e58ae..9364dde67 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": "بأي واجهة رسومية ترسم العرض ثلاثي الأبعاد. «متوافق» يعمل على كل التهيئات، وهو ما يُبقى عليه للمشاركة الواسعة أو للأجهزة الأقدم. «متقدّم» يعطي إضاءة وانعكاسات أفضل ويطلب بطاقة رسومات حديثة. اللوحة المفتوحة أصلًا تحتفظ بمحرّكها: أغلقها ثم افتحها من جديد لتغييره.", + "gl": "متوافق", + "gpu": "متقدّم" + }, "shadows": { "title": "ظلال مسقطة", "help": "يحسب الظلال التي تلقيها الأضواء. وكل ضوء يلقي ظلًّا يكلّف مرور تصيير إضافيًا لكل إطار: وإيقاف هذا الخيار أقصر طريق لتخفيف مشهد ثقيل." diff --git a/src/shared/i18n/de/diagnostics.json b/src/shared/i18n/de/diagnostics.json index 1462cefb0..c85a44cd6 100644 --- a/src/shared/i18n/de/diagnostics.json +++ b/src/shared/i18n/de/diagnostics.json @@ -16,6 +16,7 @@ "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", "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/settings.json b/src/shared/i18n/de/settings.json index feef710a4..7a9b51823 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": "Mit welcher Grafikschnittstelle die 3D-Ansicht zeichnet. Kompatibel läuft auf jeder Konfiguration und ist die Wahl für breites Teilen oder ältere Rechner. Erweitert liefert bessere Beleuchtung und Spiegelungen und verlangt eine aktuelle Grafikkarte. Ein bereits offenes Panel behält seine Engine: schließen und wieder öffnen, um zu wechseln.", + "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." diff --git a/src/shared/i18n/en/diagnostics.json b/src/shared/i18n/en/diagnostics.json index 4f259dc81..5989f7ed0 100644 --- a/src/shared/i18n/en/diagnostics.json +++ b/src/shared/i18n/en/diagnostics.json @@ -16,6 +16,7 @@ "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", "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/settings.json b/src/shared/i18n/en/settings.json index d1bbffbe6..31a0818a9 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": "Which graphics interface the 3D viewport draws with. Compatible works on every configuration and is the one to keep for sharing widely or for older machines. Advanced gives better lighting and reflections and asks for a recent graphics card. A panel already open keeps the engine it opened with: close and reopen it to change.", + "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." diff --git a/src/shared/i18n/es/diagnostics.json b/src/shared/i18n/es/diagnostics.json index 7e108dd0c..9332d1b9e 100644 --- a/src/shared/i18n/es/diagnostics.json +++ b/src/shared/i18n/es/diagnostics.json @@ -16,6 +16,7 @@ "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", "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/settings.json b/src/shared/i18n/es/settings.json index d523f1b4d..cfe489c10 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é interfaz gráfica dibuja la vista 3D. Compatible funciona en cualquier configuración y es la opción para compartir ampliamente o para máquinas más antiguas. Avanzado da mejor iluminación y reflejos y pide una tarjeta gráfica reciente. Un panel ya abierto conserva su motor: ciérrelo y vuelva a abrirlo para cambiarlo.", + "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." diff --git a/src/shared/i18n/fr/diagnostics.json b/src/shared/i18n/fr/diagnostics.json index 40851632a..d5a3a4378 100644 --- a/src/shared/i18n/fr/diagnostics.json +++ b/src/shared/i18n/fr/diagnostics.json @@ -16,6 +16,7 @@ "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", "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/settings.json b/src/shared/i18n/fr/settings.json index 12a4be739..65ac4205b 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 quelle interface graphique la vue 3D dessine. Compatible fonctionne sur toutes les configurations : c’est celui à garder pour partager largement ou pour les machines plus anciennes. Avancé donne un éclairage et des reflets de meilleure qualité et demande une carte graphique récente. Un panneau déjà ouvert conserve le moteur avec lequel il s’est ouvert : refermez-le et rouvrez-le pour en changer.", + "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." diff --git a/src/shared/i18n/hi/diagnostics.json b/src/shared/i18n/hi/diagnostics.json index 03c9d9965..b9d563d0c 100644 --- a/src/shared/i18n/hi/diagnostics.json +++ b/src/shared/i18n/hi/diagnostics.json @@ -16,6 +16,7 @@ "workerStopped": "बैकग्राउंड टास्क रोक दिया गया", "csgGraphMissing": "{{name}} के लिए कोई CSG ग्राफ़ दर्ज नहीं है", "shaderAnchorMissing": "रेंडरर अब {{name}} नहीं देता", + "renderEngineUnavailable": "उन्नत इंजन अभी बना नहीं है", "channelShaderMissing": "कोई शेडर {{channel}} चैनल नहीं निकालता", "channelSourceEmpty": "{{channel}} चैनल के स्रोत में कोई पिक्सेल नहीं है", "passSourceMissing": "इस पास को पढ़ने के लिए एक स्रोत चाहिए", diff --git a/src/shared/i18n/hi/settings.json b/src/shared/i18n/hi/settings.json index fa15b720b..bfd00158c 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": "3D दृश्य किस ग्राफ़िक्स इंटरफ़ेस से बनता है। संगत हर कॉन्फ़िगरेशन पर चलता है और व्यापक साझेदारी या पुरानी मशीनों के लिए यही रखना चाहिए। उन्नत बेहतर रोशनी और परावर्तन देता है और नया ग्राफ़िक्स कार्ड माँगता है। पहले से खुला पैनल अपना इंजन बनाए रखता है: बदलने के लिए उसे बंद करके फिर खोलें।", + "gl": "संगत", + "gpu": "उन्नत" + }, "shadows": { "title": "छाया डालें", "help": "लाइट जो छाया डालती हैं उनकी गणना करता है। छाया डालने वाली हर लाइट प्रति फ़्रेम एक अतिरिक्त रेंडर पास ख़र्च करती है: भारी सीन को हल्का करने का सबसे सीधा तरीक़ा यही है कि इसे बंद कर दिया जाए।" diff --git a/src/shared/i18n/id/diagnostics.json b/src/shared/i18n/id/diagnostics.json index bdf3043a1..ec56b76b6 100644 --- a/src/shared/i18n/id/diagnostics.json +++ b/src/shared/i18n/id/diagnostics.json @@ -16,6 +16,7 @@ "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", "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/settings.json b/src/shared/i18n/id/settings.json index 2ba3565ba..266b700ce 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": "Antarmuka grafis mana yang dipakai tampilan 3D untuk menggambar. Kompatibel berjalan di setiap konfigurasi dan itulah yang dipertahankan untuk berbagi luas atau mesin lama. Lanjutan memberi pencahayaan dan pantulan yang lebih baik dan meminta kartu grafis terkini. Panel yang sudah terbuka mempertahankan mesinnya: tutup dan buka lagi untuk berganti.", + "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." diff --git a/src/shared/i18n/it/diagnostics.json b/src/shared/i18n/it/diagnostics.json index 83b49a8bc..17b6dc97a 100644 --- a/src/shared/i18n/it/diagnostics.json +++ b/src/shared/i18n/it/diagnostics.json @@ -16,6 +16,7 @@ "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", "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/settings.json b/src/shared/i18n/it/settings.json index 7ffd98d3b..681de1055 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 quale interfaccia grafica disegna la vista 3D. Compatibile funziona su ogni configurazione ed è quello da tenere per condividere ampiamente o per macchine più vecchie. Avanzato dà illuminazione e riflessi migliori e chiede una scheda grafica recente. Un pannello già aperto conserva il suo motore: chiuderlo e riaprirlo per cambiarlo.", + "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." diff --git a/src/shared/i18n/ja/diagnostics.json b/src/shared/i18n/ja/diagnostics.json index 8e83db3c2..3fde729c4 100644 --- a/src/shared/i18n/ja/diagnostics.json +++ b/src/shared/i18n/ja/diagnostics.json @@ -16,6 +16,7 @@ "workerStopped": "ワーカーが停止されました", "csgGraphMissing": "{{name}}のCSGグラフが記録されていません", "shaderAnchorMissing": "レンダラーは{{name}}を提供しなくなりました", + "renderEngineUnavailable": "「上級」エンジンはまだ実装されていません", "channelShaderMissing": "{{channel}}チャンネルを導き出せるシェーダーがありません", "channelSourceEmpty": "{{channel}}チャンネルのソースにピクセルがありません", "passSourceMissing": "パスには読み取るソースが必要です", diff --git a/src/shared/i18n/ja/settings.json b/src/shared/i18n/ja/settings.json index cd72da38f..7bd080d10 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": "3D ビューがどのグラフィックス インターフェースで描くかです。「互換」はあらゆる構成で動き、広く共有する場合や古いマシンではこちらを保ちます。「上級」は照明と反射の質が上がり、新しめのグラフィックスカードを求めます。すでに開いているパネルは開いたときのエンジンのままです。切り替えるには閉じて開き直してください。", + "gl": "互換", + "gpu": "上級" + }, "shadows": { "title": "シャドウをキャスト", "help": "ライトがスローするシャドウを機能させます。シャドウをキャストするすべての光は、フレームあたりの余分なレンダーパスをコストします。これをオフにすることは、重いシーンを軽くする最も直接的な方法です。" diff --git a/src/shared/i18n/ko/diagnostics.json b/src/shared/i18n/ko/diagnostics.json index 5c2d4c984..c8c344059 100644 --- a/src/shared/i18n/ko/diagnostics.json +++ b/src/shared/i18n/ko/diagnostics.json @@ -16,6 +16,7 @@ "workerStopped": "워커가 정지되었습니다", "csgGraphMissing": "{{name}}에 대해 기록된 CSG 그래프가 없습니다", "shaderAnchorMissing": "렌더러가 {{name}} 앵커를 더 이상 제공하지 않습니다", + "renderEngineUnavailable": "고급 엔진은 아직 만들어지지 않았습니다", "channelShaderMissing": "{{channel}} 채널을 이끌어 내는 셰이더가 없습니다", "channelSourceEmpty": "{{channel}} 채널의 소스에 픽셀이 없습니다", "passSourceMissing": "패스에는 읽을 소스가 필요합니다", diff --git a/src/shared/i18n/ko/settings.json b/src/shared/i18n/ko/settings.json index a20aff67e..627e7acf9 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": "3D 뷰가 어떤 그래픽 인터페이스로 그릴지 정합니다. 호환은 모든 구성에서 동작하며, 널리 공유하거나 오래된 기기에서는 이쪽을 유지합니다. 고급은 조명과 반사의 품질이 높아지고 최신 그래픽 카드를 요구합니다. 이미 열린 패널은 열릴 때의 엔진을 그대로 씁니다. 바꾸려면 닫았다가 다시 여세요.", + "gl": "호환", + "gpu": "고급" + }, "shadows": { "title": "그림자 투사", "help": "조명이 던지는 그림자를 계산합니다. 그림자를 드리우는 조명은 프레임마다 렌더 패스를 하나씩 더 쓰므로, 이 옵션을 끄는 것이 무거운 씬을 가볍게 하는 가장 직접적인 방법입니다." diff --git a/src/shared/i18n/pt/diagnostics.json b/src/shared/i18n/pt/diagnostics.json index 4023e53ce..bebab1235 100644 --- a/src/shared/i18n/pt/diagnostics.json +++ b/src/shared/i18n/pt/diagnostics.json @@ -16,6 +16,7 @@ "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", "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/settings.json b/src/shared/i18n/pt/settings.json index 5afd9484a..02f064ad7 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 que interface gráfica a vista 3D desenha. Compatível funciona em qualquer configuração e é o que se guarda para partilhar amplamente ou para máquinas mais antigas. Avançado dá melhor iluminação e reflexos e pede uma placa gráfica recente. Um painel já aberto conserva o seu motor: feche-o e volte a abri-lo para mudar.", + "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." diff --git a/src/shared/i18n/ru/diagnostics.json b/src/shared/i18n/ru/diagnostics.json index 6657086e2..a7e2a6634 100644 --- a/src/shared/i18n/ru/diagnostics.json +++ b/src/shared/i18n/ru/diagnostics.json @@ -16,6 +16,7 @@ "workerStopped": "фоновая задача была остановлена", "csgGraphMissing": "для {{name}} не записан граф CSG", "shaderAnchorMissing": "Движок рендеринга больше не предоставляет {{name}}", + "renderEngineUnavailable": "Продвинутый движок ещё не собран", "channelShaderMissing": "ни один шейдер не выводит {{channel}}", "channelSourceEmpty": "в источнике канала {{channel}} нет пикселей", "passSourceMissing": "проходу нужен источник для чтения", diff --git a/src/shared/i18n/ru/settings.json b/src/shared/i18n/ru/settings.json index 7e809adf3..5027a59ed 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": "С каким графическим интерфейсом рисует 3D-вид. «Совместимый» работает на любой конфигурации — его и стоит держать для широкого обмена и старых машин. «Продвинутый» даёт лучше свет и отражения и требует свежей видеокарты. Уже открытая панель сохраняет свой движок: закройте и откройте её заново, чтобы сменить.", + "gl": "Совместимый", + "gpu": "Продвинутый" + }, "shadows": { "title": "Отбрасывать тени", "help": "Вычисляет тени, отбрасываемые источниками света. Каждый источник, который её отбрасывает, стоит дополнительного прохода рендера на кадр: отключить это — самый прямой способ облегчить тяжёлую сцену." diff --git a/src/shared/i18n/tr/diagnostics.json b/src/shared/i18n/tr/diagnostics.json index 263e29687..40ed83706 100644 --- a/src/shared/i18n/tr/diagnostics.json +++ b/src/shared/i18n/tr/diagnostics.json @@ -16,6 +16,7 @@ "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", "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/settings.json b/src/shared/i18n/tr/settings.json index 77a8c1924..36d2997e2 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": "3B görünümün hangi grafik arabirimiyle çizdiği. Uyumlu her yapılandırmada çalışır; geniş paylaşım ve eski makineler için tutulacak olan budur. Gelişmiş daha iyi aydınlatma ve yansıma verir ve yeni bir ekran kartı ister. Zaten açık bir panel motorunu korur: değiştirmek için kapatıp yeniden açın.", + "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." diff --git a/src/shared/i18n/vi/diagnostics.json b/src/shared/i18n/vi/diagnostics.json index 7baeedcc5..51e0677d8 100644 --- a/src/shared/i18n/vi/diagnostics.json +++ b/src/shared/i18n/vi/diagnostics.json @@ -16,6 +16,7 @@ "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", "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/settings.json b/src/shared/i18n/vi/settings.json index 52e774abd..d8ac922c8 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": "Giao diện đồ họa nào vẽ khung nhìn 3D. Tương thích chạy trên mọi cấu hình và là lựa chọn để chia sẻ rộng hay cho máy cũ. Nâng cao cho ánh sáng và phản chiếu tốt hơn, và đòi một card đồ họa đời mới. Bảng đã mở vẫn giữ bộ máy của nó: đóng rồi mở lại để đổi.", + "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." diff --git a/src/shared/i18n/zh/diagnostics.json b/src/shared/i18n/zh/diagnostics.json index cae9ca27a..059c33ecc 100644 --- a/src/shared/i18n/zh/diagnostics.json +++ b/src/shared/i18n/zh/diagnostics.json @@ -16,6 +16,7 @@ "workerStopped": "后台任务已被停止", "csgGraphMissing": "没有为 {{name}} 记录 CSG 图", "shaderAnchorMissing": "渲染器不再提供 {{name}}", + "renderEngineUnavailable": "高级引擎尚未构建", "channelShaderMissing": "没有着色器能推导出 {{channel}} 通道", "channelSourceEmpty": "{{channel}} 通道的来源不含任何像素", "passSourceMissing": "这一道处理需要一个可读取的来源", diff --git a/src/shared/i18n/zh/settings.json b/src/shared/i18n/zh/settings.json index d6d72d248..016af6ecb 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": "3D 视图用哪一套图形接口绘制。「兼容」在所有配置上都能运行,要广泛分享或使用较旧的机器就保留它。「高级」的光照与反射更好,需要较新的显卡。已经打开的面板会保留打开时的引擎:关掉再打开才会更换。", + "gl": "兼容", + "gpu": "高级" + }, "shadows": { "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'] From b66cd96fe74705c8e148540a9ba84d9fa726dc31 Mon Sep 17 00:00:00 2001 From: Alban Pasquelin Date: Thu, 10 Sep 2026 20:50:03 +0200 Subject: [PATCH 03/13] =?UTF-8?q?Passe=20la=20porte=20:=20split=20du=20cat?= =?UTF-8?q?alogue,=20cycle=20d'import=20cass=C3=A9,=20gardes=20remises=20d?= =?UTF-8?q?'aplomb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ce que `pnpm check` puis `pnpm validate` ont trouvé sur les deux étapes. - `postParamSpec.ts` sort du registre les six fabriques de spec et leurs types : 520 lignes pour une limite à 500 une fois `engines` posé sur trente fiches. - `mountRenderer.ts` sort de `renderDriver.ts` : les implémentations importent l'interface, un chooseur posé à côté d'elle fermait le graphe en cycle. - `cascadesMoved` sort de `configure`, qui dépassait la complexité autorisée. - Zod ignorait `three.engine` et `three.csm` : un réglage absent du schéma est effacé silencieusement à l'écriture, et `validation.test.ts` le disait. - `fakeRenderer()` rejoint les fixtures viewport : neuf points stubaient `gl` par `{}`, ce qui ne répond plus depuis que le cache lit `getMaxAnisotropy`. - Le banc 48.4 vérifiait « une courbe est active », désormais vrai du décor lui-même : il vérifie aussi l'APPEL. `sceneWorld` citait `agx` comme mot inconnu du build — remplacé par un mot qui l'est encore. - Typographie française des deux nouveaux textes de réglage. `RAPPORT-C6.md` : `docs/fr/audits/moteur-rendu-c6/RAPPORT.md`. L'étape 3 n'est pas livrée et le rapport dit pourquoi, mesures à l'appui. --- docs/fr/audits/moteur-rendu-c6/RAPPORT.md | 181 ++++++++++++++++++ llms-full.txt | 52 ++++- scripts/banc/scenariosRestAnimation.ts | 5 +- src/main/settings/validation.ts | 3 + .../engines/material/MaterialRenderer.test.ts | 4 +- ...erDriver.test.ts => mountRenderer.test.ts} | 9 +- .../src/engines/render/mountRenderer.ts | 67 +++++++ .../src/engines/render/renderDriver.ts | 53 ----- .../src/engines/scene/SceneRendererAids.ts | 18 +- .../src/engines/scene/sceneWorld.test.ts | 4 +- .../engines/skybox/SkyboxRenderer01.test.ts | 4 +- .../engines/skybox/SkyboxRenderer02.test.ts | 4 +- .../engines/skybox/SkyboxRenderer03.test.ts | 4 +- .../engines/skybox/SkyboxRenderer04.test.ts | 6 +- .../src/engines/viewport/ViewportSurface.ts | 3 +- .../src/engines/viewport/viewport-fixtures.ts | 12 +- src/shared/domain/postParamSpec.ts | 68 +++++++ src/shared/domain/postProcessingRegistry.ts | 72 ++----- src/shared/i18n/en/settings.json | 2 +- src/shared/i18n/fr/settings.json | 4 +- 20 files changed, 431 insertions(+), 144 deletions(-) create mode 100644 docs/fr/audits/moteur-rendu-c6/RAPPORT.md rename src/renderer/src/engines/render/{renderDriver.test.ts => mountRenderer.test.ts} (94%) create mode 100644 src/renderer/src/engines/render/mountRenderer.ts create mode 100644 src/shared/domain/postParamSpec.ts 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..185f218b8 --- /dev/null +++ b/docs/fr/audits/moteur-rendu-c6/RAPPORT.md @@ -0,0 +1,181 @@ +# 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. + +## Verdict + +| Étape | Statut | Motif | +| --- | --- | --- | +| 1 — Gains WebGL indépendants | livrée, 1 MUST refusé sur mesure | 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. | +| 2 — Interface driver + choix moteur | livrée, 1 écart d'emplacement | `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. | +| 3 — Premier contenu GPU réel | **non livrée** | Le préalable — un `WebGPURenderer` réellement monté — est le port de toute la chaîne de rendu, pas un spike. Chiffré plus bas. Aucun chiffre, aucune capture : rien n'a été mesuré, donc rien n'est affirmé. | + +## É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. Un projet +existant est donc identique, point. + +### 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` parcourt la scène une fois : habille les matériaux standard et **retire le soleil du + document du casting** — sans ça la même occlusion serait assombrie deux fois ; +- `dress` ignore tout matériau portant déjà un `onBeforeCompile` : `setupMaterial` l'écrase et + `dispose` le supprime, ce qui coûterait son programme au splat de relief ; +- `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 carte, retire les trois lumières et fait recompiler les matériaux. + +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` (chaque +vue d'un quadrant veut ses propres bandes), `aim` depuis `tuneShadows`, libération à la fermeture. + +**Non mesuré** : le coût réel des trois passes de profondeur. Aucun banc GPU n'a été lancé. + +### 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. Six cas couverts par `gpuAdapter.test.ts`, dont l'adaptateur refusé et +`requestAdapter` qui lève ; sept cas de repli par `renderDriver.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 est donc dans l'espace 3D des préférences, avec la copie demandée +(« Compatible » / « Avancé »), le nom de l'API dans le texte d'aide faute de sous-titre dans ce +contrôle. Le verrou « pas de switch en direct » tient **par construction** : le moteur est lu au +montage du viewport et jamais relu ; l'aide le dit. Mettre le choix dans le projet demanderait un +champ de manifeste et sa validation — hors périmètre de cette étape, à décider. + +### 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'est pas écrit : il n'a aucun effet +tant qu'aucun effet GPU n'existe, et un filtre qu'on ne peut pas voir tourner est un filtre qu'on +ne peut pas relire. + +## Étape 3 — non livrée, et pourquoi + +Le point 3.1 (patch matériau en TSL), 3.2 (GTAO en TSL), 3.3 (lecture de pixels GPU) et le +budget qualité `RenderPipeline` sont tous **derrière un préalable** : que `GPUDriver.createRenderer` +rende un `WebGPURenderer` réellement monté. Ce préalable n'est pas un spike. + +Mesuré dans ce dépôt et dans three 0.185 : + +- `EffectComposer` est **WebGL uniquement**, et three le dit dans sa propre documentation : + `examples/jsm/postprocessing/EffectComposer.js:18` — « This module can only be used with + WebGLRenderer ». Toute la chaîne de composition du studio est construite dessus : + **1 941 lignes** dans `engines/postfx/` hors tests, plus **291 lignes** de passes GLSL dans + `engines/gpu/`, dont `fuseShader` qui n'a aucun équivalent souhaitable côté TSL. +- **29 fichiers hors tests** nomment `WebGLRenderer`. `ViewportSurface.gl` est typé + `WebGLRenderer | null` et lu par les passes, les overlays, `TransformControls` et `ViewHelper`. +- Le viewport lit encore `renderer.getContext()` pour la minuterie GPU (`gpuTimer.ts`, extension + WebGL2) et `renderer.info.autoReset`, qui n'ont pas le même contrat côté WebGPU. + +Une chaîne `RenderPipeline` parallèle, un typage `Renderer` propagé sur ces 29 fichiers, et une +seconde implémentation des effets : c'est un chantier, pas une étape. **Il faut le décider, pas le +commencer en fin de lot.** + +S'ajoute une limite d'environnement, indépendante du volume : les critères d'acceptation de +l'étape 3 sont un test de non-régression **visuel** GL vs GPU, une **table de chiffres** sur deux +profils et deux moteurs, et une **capture** d'export. Aucun des trois n'est productible ici — la +suite tourne sous jsdom, sans WebGPU ni GPU. Les écrire sans les mesurer serait précisément ce que +le spec interdit. + +## Ce qui reste ouvert + +- Le port WebGPU lui-même (étape 3), à ouvrir comme chantier avec sa propre branche de banc. +- Le switch en direct du moteur : hors périmètre, et il le reste tant que le patch matériau n'est + pas porté en TSL. +- SSGI : hors périmètre par décision du spec. +- TRAA : non porté, l'effet n'existe même pas côté GL dans `PostEffectId`. +- Le choix du moteur par PROJET plutôt que par application, si le sélecteur doit vraiment vivre à + la création : demande un champ de manifeste et sa validation. +- 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. 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/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/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/material/MaterialRenderer.test.ts b/src/renderer/src/engines/material/MaterialRenderer.test.ts index b4008f289..3ec94e324 100644 --- a/src/renderer/src/engines/material/MaterialRenderer.test.ts +++ b/src/renderer/src/engines/material/MaterialRenderer.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' 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' @@ -62,7 +62,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/render/renderDriver.test.ts b/src/renderer/src/engines/render/mountRenderer.test.ts similarity index 94% rename from src/renderer/src/engines/render/renderDriver.test.ts rename to src/renderer/src/engines/render/mountRenderer.test.ts index 5ba33f46b..a7f7150c0 100644 --- a/src/renderer/src/engines/render/renderDriver.test.ts +++ b/src/renderer/src/engines/render/mountRenderer.test.ts @@ -1,12 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { WebGLRenderer } from 'three' -import { - driverFor, - mountRenderer, - type RenderDriver, - type RenderDrivers, - type RendererRequest, -} from './renderDriver' +import { driverFor, 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 diff --git a/src/renderer/src/engines/render/mountRenderer.ts b/src/renderer/src/engines/render/mountRenderer.ts new file mode 100644 index 000000000..8a4e2f720 --- /dev/null +++ b/src/renderer/src/engines/render/mountRenderer.ts @@ -0,0 +1,67 @@ +/** + * 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 { WebGLRenderer } from 'three' +import type { RenderEngine } from '@shared/domain/renderEngine' +import { localizedError } from '@shared/localizedError' +import { glDriver } from './glDriver' +import { gpuDriver } from './gpuDriver' +import type { RenderDriver, RendererRequest } 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 } + +/** + * The driver a policy asks for — the Compatible one whenever the Advanced engine has nothing to + * draw with. `gpuReady` is what `probeGpuAdapter` found, `null` meaning nobody has asked yet: + * a mount cannot wait for an adapter, so the first one of a session opens Compatible and the + * answer is there for the next. + */ +export function driverFor( + engine: RenderEngine, + gpuReady: boolean | null, + drivers: RenderDrivers = RENDER_DRIVERS, +): RenderDriver { + return engine === 'gpu' && gpuReady === true ? drivers.gpu : drivers.gl +} + +/** What was mounted, which is not always what was asked for. */ +export type MountedRenderer = { renderer: WebGLRenderer; 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. + */ +export function mountRenderer( + request: RendererRequest, + engine: RenderEngine, + gpuReady: boolean | null, + onFallback: (error: unknown) => void, + drivers: RenderDrivers = RENDER_DRIVERS, +): MountedRenderer { + const wanted = driverFor(engine, gpuReady, drivers) + if (wanted === drivers.gl) { + // Said even with nothing thrown: choosing Advanced and being handed Compatible is the one + // case a reader has to be able to explain, and no adapter throws to explain it. + if (engine === 'gpu') onFallback(localizedError('renderEngineUnavailable')) + return { renderer: drivers.gl.createRenderer(request), driver: drivers.gl } + } + + 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 index 7f2c9e74e..962b3f132 100644 --- a/src/renderer/src/engines/render/renderDriver.ts +++ b/src/renderer/src/engines/render/renderDriver.ts @@ -11,9 +11,6 @@ import type { Scene, WebGLRenderer, WebGLRenderTarget } from 'three' import type { MeshStandardMaterial } from 'three' import type { RenderEngine } from '@shared/domain/renderEngine' -import { localizedError } from '@shared/localizedError' -import { glDriver } from './glDriver' -import { gpuDriver } from './gpuDriver' import type { ViewportEnvironment } from '../viewport/environment' import type { MaterialUniforms } from '../material/materialShader' @@ -57,53 +54,3 @@ export type RenderDriver = { onMissingAnchor: (anchor: string) => void, ) => void } - -/** The two implementations, named together so a caller — or a test — can swap either. */ -export type RenderDrivers = { gl: RenderDriver; gpu: RenderDriver } - -export const RENDER_DRIVERS: RenderDrivers = { gl: glDriver, gpu: gpuDriver } - -/** - * The driver a policy asks for — the Compatible one whenever the Advanced engine has nothing to - * draw with. `gpuReady` is what `probeGpuAdapter` found, `null` meaning nobody has asked yet: - * a mount cannot wait for an adapter, so the first one of a session opens Compatible and the - * answer is there for the next. - */ -export function driverFor( - engine: RenderEngine, - gpuReady: boolean | null, - drivers: RenderDrivers = RENDER_DRIVERS, -): RenderDriver { - return engine === 'gpu' && gpuReady === true ? drivers.gpu : drivers.gl -} - -/** What was mounted, which is not always what was asked for. */ -export type MountedRenderer = { renderer: WebGLRenderer; 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. - */ -export function mountRenderer( - request: RendererRequest, - engine: RenderEngine, - gpuReady: boolean | null, - onFallback: (error: unknown) => void, - drivers: RenderDrivers = RENDER_DRIVERS, -): MountedRenderer { - const wanted = driverFor(engine, gpuReady, drivers) - if (wanted === drivers.gl) { - // Said even with nothing thrown: choosing Advanced and being handed Compatible is the one - // case a reader has to be able to explain, and no adapter throws to explain it. - if (engine === 'gpu') onFallback(localizedError('renderEngineUnavailable')) - return { renderer: drivers.gl.createRenderer(request), driver: drivers.gl } - } - - 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/scene/SceneRendererAids.ts b/src/renderer/src/engines/scene/SceneRendererAids.ts index 5ba145b58..1a15a2640 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 @@ -82,11 +94,7 @@ export abstract class SceneRendererAids extends SceneRendererValidation { // 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. if (shadowsResized || gridMoved) this.tuneShadows() - // Only on what `CSM` reads at construction: a rebuild takes the three lights out of the - // scene and recompiles every material they dressed. - if (next.csm !== held.csm || next.shadows !== held.shadows || shadowsResized) { - this.syncCascades() - } + if (cascadesMoved(held, next, shadowsResized)) this.syncCascades() if (gridMoved && this.viewport.canvas) this.applyPalette() if (aidsMoved(held, next)) this.refreshAids() if (helperVisibilityMoved(held, next)) this.showAidsForSelection() diff --git a/src/renderer/src/engines/scene/sceneWorld.test.ts b/src/renderer/src/engines/scene/sceneWorld.test.ts index 65d36b95b..be5131e34 100644 --- a/src/renderer/src/engines/scene/sceneWorld.test.ts +++ b/src/renderer/src/engines/scene/sceneWorld.test.ts @@ -93,7 +93,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/skybox/SkyboxRenderer01.test.ts b/src/renderer/src/engines/skybox/SkyboxRenderer01.test.ts index 313832e1e..47303b59a 100644 --- a/src/renderer/src/engines/skybox/SkyboxRenderer01.test.ts +++ b/src/renderer/src/engines/skybox/SkyboxRenderer01.test.ts @@ -10,7 +10,7 @@ import type { GpuPipeline } from '../gpu/gpuPipeline' import type * as EnvironmentModule from '../viewport/environment' 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' @@ -104,7 +104,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..660f76b81 100644 --- a/src/renderer/src/engines/skybox/SkyboxRenderer02.test.ts +++ b/src/renderer/src/engines/skybox/SkyboxRenderer02.test.ts @@ -10,7 +10,7 @@ import type { GpuPipeline } from '../gpu/gpuPipeline' import type * as EnvironmentModule from '../viewport/environment' 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' @@ -121,7 +121,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..d8853211b 100644 --- a/src/renderer/src/engines/skybox/SkyboxRenderer03.test.ts +++ b/src/renderer/src/engines/skybox/SkyboxRenderer03.test.ts @@ -10,7 +10,7 @@ import type { GpuPipeline } from '../gpu/gpuPipeline' import type * as EnvironmentModule from '../viewport/environment' 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' @@ -121,7 +121,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..656ed7181 100644 --- a/src/renderer/src/engines/skybox/SkyboxRenderer04.test.ts +++ b/src/renderer/src/engines/skybox/SkyboxRenderer04.test.ts @@ -9,7 +9,7 @@ import type { GpuPipeline } from '../gpu/gpuPipeline' import type * as EnvironmentModule from '../viewport/environment' 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' @@ -95,7 +95,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 +197,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/ViewportSurface.ts b/src/renderer/src/engines/viewport/ViewportSurface.ts index 2c3092f9d..cf36e0436 100644 --- a/src/renderer/src/engines/viewport/ViewportSurface.ts +++ b/src/renderer/src/engines/viewport/ViewportSurface.ts @@ -5,7 +5,8 @@ import { traceFailure } from '@/services/diagnostics' import { applyShadowPolicy } from '../scene/shadows' import { token } from '../core/palette' import { askedGpuAdapter, probeGpuAdapter } from '../render/gpuAdapter' -import { mountRenderer, type RenderDriver } from '../render/renderDriver' +import { mountRenderer } from '../render/mountRenderer' +import { type RenderDriver } from '../render/renderDriver' import { createGpuTimer, isGpuTimerContext } from './gpuTimer' import { ViewportMounting } from './ViewportMounting' 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/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/postProcessingRegistry.ts b/src/shared/domain/postProcessingRegistry.ts index 097067f20..b25e056db 100644 --- a/src/shared/domain/postProcessingRegistry.ts +++ b/src/shared/domain/postProcessingRegistry.ts @@ -7,20 +7,19 @@ * one folder that does. */ import { GL_ONLY, type RenderEngine } from './renderEngine' -import type { FieldValue, PropertySpec } from './propertySpec' +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' @@ -114,53 +113,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. * diff --git a/src/shared/i18n/en/settings.json b/src/shared/i18n/en/settings.json index 31a0818a9..c86039e34 100644 --- a/src/shared/i18n/en/settings.json +++ b/src/shared/i18n/en/settings.json @@ -282,7 +282,7 @@ }, "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." + "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", diff --git a/src/shared/i18n/fr/settings.json b/src/shared/i18n/fr/settings.json index 65ac4205b..4b8ccf4c6 100644 --- a/src/shared/i18n/fr/settings.json +++ b/src/shared/i18n/fr/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Moteur de rendu", - "help": "Avec quelle interface graphique la vue 3D dessine. Compatible fonctionne sur toutes les configurations : c’est celui à garder pour partager largement ou pour les machines plus anciennes. Avancé donne un éclairage et des reflets de meilleure qualité et demande une carte graphique récente. Un panneau déjà ouvert conserve le moteur avec lequel il s’est ouvert : refermez-le et rouvrez-le pour en changer.", + "help": "Avec quelle interface graphique la vue 3D dessine. Compatible fonctionne sur toutes les configurations : c’est celui à garder pour partager largement ou pour les machines plus anciennes. Avancé donne un éclairage et des reflets de meilleure qualité et demande une carte graphique récente. Un panneau déjà ouvert conserve le moteur avec lequel il s’est ouvert : refermez-le et rouvrez-le pour en changer.", "gl": "Compatible", "gpu": "Avancé" }, @@ -282,7 +282,7 @@ }, "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." + "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", From bf8c100569b2706ce7820bd898ffc09f5f78c41d Mon Sep 17 00:00:00 2001 From: Alban Pasquelin Date: Thu, 10 Sep 2026 21:15:54 +0200 Subject: [PATCH 04/13] =?UTF-8?q?Applique=20la=20revue=20adverse=20:=20les?= =?UTF-8?q?=20cascades=20ne=20sur-=C3=A9clairaient=20plus=20qu'elles=20n'?= =?UTF-8?q?=C3=A9clairaient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cinq relectures indépendantes (réutilisation, simplification, efficacité, altitude, code-review). Ce qui a été retenu, du plus grave au moins : - **Les cascades sur-éclairaient la scène d'environ dix fois.** `CSM` ajoute trois lumières à 3 d'intensité chacune, et le soleil du document continuait d'éclairer à côté. Les bandes REMPLACENT désormais le soleil : sa couleur et son intensité passent sur elles, il cesse d'éclairer et de projeter, et `release` lui rend les deux. Pas de division par trois : le chunk n'éclaire un fragment que depuis la bande où tombe sa profondeur. - **Le terrain perdait ses deux ombres.** `dress` sautait tout matériau portant un `onBeforeCompile`, c'est-à-dire le splat de relief — donc pas de cascades ET plus de carte du soleil, sur le seul cas que les cascades servent. Les deux crochets se composent maintenant, et `release` ne rend le sien qu'à un matériau que personne n'a réattaché depuis. - **Un orbite affichait des ombres périmées** : `follow` répond si les bandes ont bougé, et `dressPane` remonte cette réponse — c'est ce qui dit à la frame que la passe d'ombres vaut d'être jouée. - **Les rendus hors écran** (film, capture de vol, validation) ne recadraient jamais les bandes : elles restaient ajustées à la vue de l'éditeur. - **L'export jeu ignorait `csm`** : `webRender` construit les mêmes cascades. - Le garde de `aim` comparait x et z d'un vecteur brut contre un normalisé : un soleil qui ne bougeait qu'en y ne recadrait jamais. - Habillage limité aux nœuds changés plus les lots instanciés, au lieu d'un `traverse` complet à chaque `applyState` — y compris par frame de jeu. - `follow` recadrait par identité de caméra : quatre recadrages par frame en quadrant, chacun parcourant tous les matériaux habillés. C'est la PROJECTION qui est comparée. - `materialsOf` remonte dans `shadows.ts`, partagé par les deux passes qui marquent les mêmes matériaux ; `NEW_SCENE_WORLD` retrouve son gel ; zod ignorait deux réglages ; l'anisotropie ne descend plus sous 1 (three répond 0 sans l'extension) ; `driverFor` n'est plus exporté. - L'aide du sélecteur dit désormais que le moteur Avancé n'est pas encore construit et que le choisir dessine en Compatible. Refusés, avec le motif : retirer `engines` du registre et le réglage `engine` (deux revues le demandent — le spec les exige à cette étape), et retirer la sonde d'adaptateur (MUST 5, avec son test). --- docs/fr/audits/moteur-rendu-c6/RAPPORT.md | 46 +++- .../src/engines/render/gpuAdapter.test.ts | 11 +- .../src/engines/render/mountRenderer.test.ts | 20 +- .../src/engines/render/mountRenderer.ts | 11 +- .../src/engines/scene/SceneRendererAids.ts | 4 +- .../src/engines/scene/SceneRendererDisplay.ts | 11 +- .../src/engines/scene/SceneRendererFilm.ts | 3 + .../src/engines/scene/SceneRendererFlight.ts | 3 + .../engines/scene/SceneRendererLifecycle.ts | 4 +- .../src/engines/scene/SceneRendererShadows.ts | 25 +++ .../engines/scene/SceneRendererValidation.ts | 3 + src/renderer/src/engines/scene/csm.test.ts | 100 +++++++-- src/renderer/src/engines/scene/csm.ts | 196 ++++++++++++++---- .../src/engines/scene/defaultScene.ts | 4 +- src/renderer/src/engines/scene/shadows.ts | 20 +- .../src/engines/scene/textureCache.ts | 4 +- .../src/engines/viewport/ViewportSurface.ts | 6 +- src/renderer/src/game/webRender.test.ts | 22 ++ src/renderer/src/game/webRender.ts | 29 +++ src/shared/i18n/ar/settings.json | 2 +- src/shared/i18n/de/settings.json | 2 +- src/shared/i18n/en/settings.json | 2 +- src/shared/i18n/es/settings.json | 2 +- src/shared/i18n/fr/settings.json | 2 +- src/shared/i18n/hi/settings.json | 2 +- src/shared/i18n/id/settings.json | 2 +- src/shared/i18n/it/settings.json | 2 +- src/shared/i18n/ja/settings.json | 2 +- src/shared/i18n/ko/settings.json | 2 +- src/shared/i18n/pt/settings.json | 2 +- src/shared/i18n/ru/settings.json | 2 +- src/shared/i18n/tr/settings.json | 2 +- src/shared/i18n/vi/settings.json | 2 +- src/shared/i18n/zh/settings.json | 2 +- 34 files changed, 433 insertions(+), 119 deletions(-) diff --git a/docs/fr/audits/moteur-rendu-c6/RAPPORT.md b/docs/fr/audits/moteur-rendu-c6/RAPPORT.md index 185f218b8..a0eac39a7 100644 --- a/docs/fr/audits/moteur-rendu-c6/RAPPORT.md +++ b/docs/fr/audits/moteur-rendu-c6/RAPPORT.md @@ -42,20 +42,47 @@ existant est donc identique, point. - 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` parcourt la scène une fois : habille les matériaux standard et **retire le soleil du - document du casting** — sans ça la même occlusion serait assombrie deux fois ; -- `dress` ignore tout matériau portant déjà un `onBeforeCompile` : `setupMaterial` l'écrase et - `dispose` le supprime, ce qui coûterait son programme au splat de relief ; +- `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 carte, retire les trois lumières et fait recompiler les matériaux. +- `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` (chaque -vue d'un quadrant veut ses propres bandes), `aim` depuis `tuneShadows`, libération à la fermeture. +`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 @@ -133,6 +160,11 @@ contrôle. Le verrou « pas de switch en direct » tient **par construction** : montage du viewport et jamais relu ; l'aide le dit. Mettre le choix dans le projet demanderait un champ de manifeste et sa validation — hors périmètre de cette étape, à décider. +Deux revues sur trois ont demandé de ne pas livrer le réglage du tout tant qu'Avancé ne dessine +rien. Le spec le demande à cette étape, donc il est livré — mais son aide dit désormais en toutes +lettres que le moteur Avancé n'est pas encore construit et que le choisir dessine en Compatible. +Un réglage qui promet sans tenir est le défaut que ces revues visaient. + ### Registre post-processing `PostEffectMeta.engines`, les trente effets existants en `['gl']`. Les `PostSlot` et la règle diff --git a/src/renderer/src/engines/render/gpuAdapter.test.ts b/src/renderer/src/engines/render/gpuAdapter.test.ts index c881b488b..faaad84c7 100644 --- a/src/renderer/src/engines/render/gpuAdapter.test.ts +++ b/src/renderer/src/engines/render/gpuAdapter.test.ts @@ -16,19 +16,16 @@ describe('whether this machine has a WebGPU adapter', () => { expect(askedGpuAdapter()).toBe(null) }) - it('reads no adapter on a browser that exposes no WebGPU', async () => { + 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) - }) - it('reads no adapter when the request is refused', async () => { + forgetGpuAdapter() gpuAnswering(() => Promise.resolve(null)) - expect(await probeGpuAdapter()).toBe(false) - }) - it('reads no adapter when the request throws, which is the same answer', async () => { + forgetGpuAdapter() gpuAnswering(() => Promise.reject(new Error('no device'))) - expect(await probeGpuAdapter()).toBe(false) }) diff --git a/src/renderer/src/engines/render/mountRenderer.test.ts b/src/renderer/src/engines/render/mountRenderer.test.ts index a7f7150c0..7a057d737 100644 --- a/src/renderer/src/engines/render/mountRenderer.test.ts +++ b/src/renderer/src/engines/render/mountRenderer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { WebGLRenderer } from 'three' -import { driverFor, mountRenderer, type RenderDrivers } from './mountRenderer' +import { mountRenderer, type RenderDrivers } from './mountRenderer' import type { RenderDriver, RendererRequest } from './renderDriver' /** @@ -33,30 +33,20 @@ function rendererStub(engine: 'gl' | 'gpu'): WebGLRenderer { return { engine } as unknown as WebGLRenderer } -describe('which driver a policy gets', () => { - it('draws with the Compatible engine unless the Advanced one is asked for', () => { +describe('mounting a renderer', () => { + it('draws with the Advanced engine once an adapter has answered', () => { const two = drivers() - expect(driverFor('gl', true, two)).toBe(two.gl) - }) - it('draws with the Advanced engine when an adapter answered', () => { - const two = drivers() - expect(driverFor('gpu', true, two)).toBe(two.gpu) + expect(mountRenderer(request, 'gpu', true, vi.fn(), two).driver).toBe(two.gpu) }) it('keeps the Compatible engine while nobody has asked the adapter yet', () => { // A mount cannot wait on `requestAdapter`, and a viewport that waited would show nothing. const two = drivers() - expect(driverFor('gpu', null, two)).toBe(two.gl) - }) - it('keeps the Compatible engine when the adapter refused', () => { - const two = drivers() - expect(driverFor('gpu', false, two)).toBe(two.gl) + expect(mountRenderer(request, 'gpu', null, vi.fn(), two).driver).toBe(two.gl) }) -}) -describe('mounting a renderer', () => { it('falls back to the Compatible engine when the Advanced one throws', () => { const two = drivers({ createRenderer: () => { diff --git a/src/renderer/src/engines/render/mountRenderer.ts b/src/renderer/src/engines/render/mountRenderer.ts index 8a4e2f720..5cdab9c9c 100644 --- a/src/renderer/src/engines/render/mountRenderer.ts +++ b/src/renderer/src/engines/render/mountRenderer.ts @@ -27,7 +27,7 @@ const RENDER_DRIVERS: RenderDrivers = { gl: glDriver, gpu: gpuDriver } * a mount cannot wait for an adapter, so the first one of a session opens Compatible and the * answer is there for the next. */ -export function driverFor( +function driverFor( engine: RenderEngine, gpuReady: boolean | null, drivers: RenderDrivers = RENDER_DRIVERS, @@ -51,11 +51,10 @@ export function mountRenderer( drivers: RenderDrivers = RENDER_DRIVERS, ): MountedRenderer { const wanted = driverFor(engine, gpuReady, drivers) - if (wanted === drivers.gl) { - // Said even with nothing thrown: choosing Advanced and being handed Compatible is the one - // case a reader has to be able to explain, and no adapter throws to explain it. - if (engine === 'gpu') onFallback(localizedError('renderEngineUnavailable')) - return { renderer: drivers.gl.createRenderer(request), driver: 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 { diff --git a/src/renderer/src/engines/scene/SceneRendererAids.ts b/src/renderer/src/engines/scene/SceneRendererAids.ts index 1a15a2640..5d3ceea87 100644 --- a/src/renderer/src/engines/scene/SceneRendererAids.ts +++ b/src/renderer/src/engines/scene/SceneRendererAids.ts @@ -93,8 +93,10 @@ 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. - if (shadowsResized || gridMoved) this.tuneShadows() + // 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() if (helperVisibilityMoved(held, next)) this.showAidsForSelection() diff --git a/src/renderer/src/engines/scene/SceneRendererDisplay.ts b/src/renderer/src/engines/scene/SceneRendererDisplay.ts index 0989a4ae6..5ac90e396 100644 --- a/src/renderer/src/engines/scene/SceneRendererDisplay.ts +++ b/src/renderer/src/engines/scene/SceneRendererDisplay.ts @@ -143,8 +143,8 @@ export abstract class SceneRendererDisplay extends SceneRendererExport { // 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, before the dressing that decides what the pass draws. - this.cascades?.follow(camera) + // 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' @@ -161,6 +161,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 @@ -168,7 +174,6 @@ export abstract class SceneRendererDisplay extends SceneRendererExport { this.firstPersonBody.sync(body ? this.objects.get(body.id) : undefined, signature => this.retarget.profileOf(signature), ) - return dressed || zoned } /** diff --git a/src/renderer/src/engines/scene/SceneRendererFilm.ts b/src/renderer/src/engines/scene/SceneRendererFilm.ts index 7510d199b..57855aa81 100644 --- a/src/renderer/src/engines/scene/SceneRendererFilm.ts +++ b/src/renderer/src/engines/scene/SceneRendererFilm.ts @@ -117,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, diff --git a/src/renderer/src/engines/scene/SceneRendererFlight.ts b/src/renderer/src/engines/scene/SceneRendererFlight.ts index ccbf9b2bb..0ca95c5a8 100644 --- a/src/renderer/src/engines/scene/SceneRendererFlight.ts +++ b/src/renderer/src/engines/scene/SceneRendererFlight.ts @@ -63,6 +63,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, diff --git a/src/renderer/src/engines/scene/SceneRendererLifecycle.ts b/src/renderer/src/engines/scene/SceneRendererLifecycle.ts index faa9cbb28..cffa2c2cc 100644 --- a/src/renderer/src/engines/scene/SceneRendererLifecycle.ts +++ b/src/renderer/src/engines/scene/SceneRendererLifecycle.ts @@ -42,6 +42,8 @@ export abstract class SceneRendererLifecycle extends SceneRendererResources { 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 @@ -216,7 +218,7 @@ export abstract class SceneRendererLifecycle extends SceneRendererResources { 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.cascades?.dress(this.viewport.scene) + this.dressCascades(changed) this.playheadMovesShadows = this.canPlayheadMoveShadows(state.nodes) this.reportStats() if (allShadowsChanged) this.redraw() diff --git a/src/renderer/src/engines/scene/SceneRendererShadows.ts b/src/renderer/src/engines/scene/SceneRendererShadows.ts index af0ba2576..87b6d49a0 100644 --- a/src/renderer/src/engines/scene/SceneRendererShadows.ts +++ b/src/renderer/src/engines/scene/SceneRendererShadows.ts @@ -123,11 +123,36 @@ export abstract class SceneRendererShadows extends SceneRendererModels { 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) + } + /** * 🛑 Walked in FULL only when the content changed. Reading the box off every object on every * pass was 23.8 ms of the 38.7 one `apply` cost on 50 000 lit nodes — a whole frame budget diff --git a/src/renderer/src/engines/scene/SceneRendererValidation.ts b/src/renderer/src/engines/scene/SceneRendererValidation.ts index 75b7858d8..1b9734d5c 100644 --- a/src/renderer/src/engines/scene/SceneRendererValidation.ts +++ b/src/renderer/src/engines/scene/SceneRendererValidation.ts @@ -32,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, diff --git a/src/renderer/src/engines/scene/csm.test.ts b/src/renderer/src/engines/scene/csm.test.ts index 7a05ee35c..679b6a0ef 100644 --- a/src/renderer/src/engines/scene/csm.test.ts +++ b/src/renderer/src/engines/scene/csm.test.ts @@ -1,5 +1,13 @@ -import { DirectionalLight, Mesh, MeshStandardMaterial, PerspectiveCamera, Scene } from 'three' -import { describe, expect, it } from 'vitest' +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, createCascadeShadows } from './csm' @@ -46,21 +54,39 @@ describe('cascaded shadows on a scene', () => { expect(sun.castShadow).toBe(false) }) - it('gives the sun its own map back when the cascades go', () => { + 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') + } + }) + + 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 = mesh.material - if (Array.isArray(material)) throw new Error('one material') + const material = oneMaterialOf(mesh) material.needsUpdate = false const shadows = createCascadeShadows(scene, settings, () => {}) @@ -70,18 +96,48 @@ describe('cascaded shadows on a scene', () => { expect(material.version).toBeGreaterThan(0) }) - it('leaves a material that carries a patch of its own alone', () => { + 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 = mesh.material - if (Array.isArray(material)) throw new Error('one material') - const patch = () => {} - material.onBeforeCompile = patch + 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 - expect(material.onBeforeCompile).toBe(patch) - expect(material.defines?.USE_CSM).toBeUndefined() + 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', () => { @@ -112,6 +168,26 @@ describe('cascaded shadows on a scene', () => { }) }) +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( diff --git a/src/renderer/src/engines/scene/csm.ts b/src/renderer/src/engines/scene/csm.ts index 2f3aa8b65..c5a9cadd0 100644 --- a/src/renderer/src/engines/scene/csm.ts +++ b/src/renderer/src/engines/scene/csm.ts @@ -9,7 +9,9 @@ */ import { DirectionalLight, + Matrix4, PerspectiveCamera, + Vector3, type Camera, type Material, type Object3D, @@ -18,6 +20,7 @@ import { CSM } from 'three/addons/csm/CSM.js' 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. */ @@ -54,8 +57,12 @@ export type CascadeShadows = { * 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. Refits only when that camera is not the one already fitted. */ - follow: (camera: Camera) => 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 — @@ -63,7 +70,11 @@ export type CascadeShadows = { * already darkened. */ dress: (root: Object3D) => void - /** Puts the scene back exactly as it was: lights out, defines off, materials rebuilt. */ + /** + * 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 } @@ -87,88 +98,191 @@ export function createCascadeShadows( // the only thing in the frame redrawn sixty times a second. for (const light of csm.lights) light.shadow.autoUpdate = false - /** What was dressed, so a scene of ten thousand meshes is walked without dressing twice. */ - const dressed = new WeakSet() - /** The suns that were casting when the cascades took over, to be given their maps back. */ - const held = new Set() + /** + * 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) - let fitted: Camera | null = null + /** 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 - if (csm.lightDirection.x === along.x && csm.lightDirection.z === along.z) return - csm.lightDirection.set(along.x, along.y, along.z).normalize() + // 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 => { - // The identity, not the matrix: a quad layout hands four cameras and each one wants its - // own bands, while an orbit on one camera moves the position the update already reads. - if (fitted !== camera) { - csm.camera = camera + csm.camera = camera + // The PROJECTION and not the camera's identity: a quad layout hands four objects sharing + // one lens, and identity refitted for each of them — `updateFrustums` walks every dressed + // material, so that was the scene's material count, four times a frame, for nothing. + const refitted = !fitted.equals(camera.projectionMatrix) + if (refitted) { csm.updateFrustums() - fitted = camera + 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)) { - if (child.castShadow) held.add(child) - child.castShadow = false + stood = standFor(csm.lights, child, stood) } for (const material of materialsOf(child)) { - if (dressed.has(material) || !receivesCascades(material)) continue - dressed.add(material) - csm.setupMaterial(material) - // 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 + 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 light of held) light.castShadow = true - held.clear() - fitted = null + 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 } + /** - * Whether a material may be dressed. Anything already carrying an `onBeforeCompile` is left - * alone: `setupMaterial` OVERWRITES that hook and `dispose` deletes it outright, so the relief - * splat — the one material of the scene with a patch of its own — would lose its program for - * good. A lit material only: a helper's line or a sprite receives no shadow to cascade. + * 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 receivesCascades(material: Material): boolean { - // `hasOwn` rather than a comparison: three declares the hook on `Material.prototype`, so a - // material carries one of its OWN exactly when somebody assigned it. - return 'isMeshStandardMaterial' in material && !Object.hasOwn(material, 'onBeforeCompile') +function standFor( + bands: readonly DirectionalLight[], + light: DirectionalLight, + stood: StoodFor | null, +): StoodFor | null { + if (stood && stood.light !== light) return stood + const held = stood ?? { light, castShadow: light.castShadow, intensity: light.intensity } + if (stood && 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 } -function materialsOf(object: Object3D): readonly Material[] { - const material: unknown = Reflect.get(object, 'material') - if (Array.isArray(material)) return material - return isMaterial(material) ? [material] : [] +/** 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 } -function isMaterial(value: unknown): value is Material { - return typeof value === 'object' && value !== null && 'isMaterial' in value +/** + * 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.ts b/src/renderer/src/engines/scene/defaultScene.ts index 9b9177211..f43b9d90c 100644 --- a/src/renderer/src/engines/scene/defaultScene.ts +++ b/src/renderer/src/engines/scene/defaultScene.ts @@ -18,7 +18,9 @@ import type { SceneState } from './sceneState' * 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. */ -export const NEW_SCENE_WORLD: SceneWorld = { ...DEFAULT_WORLD, toneMapping: 'agx' } +// 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([ diff --git a/src/renderer/src/engines/scene/shadows.ts b/src/renderer/src/engines/scene/shadows.ts index c041021fb..7c1b012a0 100644 --- a/src/renderer/src/engines/scene/shadows.ts +++ b/src/renderer/src/engines/scene/shadows.ts @@ -1,5 +1,5 @@ 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' @@ -65,14 +65,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.ts b/src/renderer/src/engines/scene/textureCache.ts index 689698ee1..b8548a5bd 100644 --- a/src/renderer/src/engines/scene/textureCache.ts +++ b/src/renderer/src/engines/scene/textureCache.ts @@ -150,7 +150,9 @@ type AnisotropyHolder = { capabilities: { getMaxAnisotropy: () => number } } * engines that build a cache, so none of them has to reach into `capabilities` itself. */ export function maxAnisotropyOf(renderer: AnisotropyHolder | null | undefined): number { - return renderer?.capabilities.getMaxAnisotropy() ?? 1 + // Never under one: three answers 0 — not 1 — on a context without + // `EXT_texture_filter_anisotropic`, and 0 is not a number of samples. + return Math.max(1, renderer?.capabilities.getMaxAnisotropy() ?? 1) } export type TextureCache = { diff --git a/src/renderer/src/engines/viewport/ViewportSurface.ts b/src/renderer/src/engines/viewport/ViewportSurface.ts index cf36e0436..557414d19 100644 --- a/src/renderer/src/engines/viewport/ViewportSurface.ts +++ b/src/renderer/src/engines/viewport/ViewportSurface.ts @@ -61,13 +61,13 @@ export abstract class ViewportSurface extends ViewportMounting { if (wanted === 'gpu' && askedGpuAdapter() === null) void probeGpuAdapter() const mounted = mountRenderer( - { canvas, alpha: this.output.alpha === true }, + { canvas, alpha: this.output.alpha ?? false }, wanted, askedGpuAdapter(), error => traceFailure('render.fallback', wanted, error), ) - this.renderDriver = mounted.driver - const renderer = mounted.renderer + 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`. diff --git a/src/renderer/src/game/webRender.test.ts b/src/renderer/src/game/webRender.test.ts index f1c991642..58b417f1e 100644 --- a/src/renderer/src/game/webRender.test.ts +++ b/src/renderer/src/game/webRender.test.ts @@ -85,6 +85,15 @@ async function stagedGame(policy: Partial = DEFAULT_RENDER_POLICY) return { render, renderer, crate } } +/** 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 } } } => { let found: Object3D | null = null ;(scene as Scene).traverse(object => { @@ -113,6 +122,19 @@ 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]), + ) + }) + // 🛑 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..48d4db1c3 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,7 @@ 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, 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' @@ -83,6 +85,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 +115,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 +226,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 +246,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() @@ -323,6 +336,22 @@ function paintHeld( } } +/** + * 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 { + if (!policy.csm || !policy.shadows) 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/shared/i18n/ar/settings.json b/src/shared/i18n/ar/settings.json index 9364dde67..f5bd9308e 100644 --- a/src/shared/i18n/ar/settings.json +++ b/src/shared/i18n/ar/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "محرّك العرض", - "help": "بأي واجهة رسومية ترسم العرض ثلاثي الأبعاد. «متوافق» يعمل على كل التهيئات، وهو ما يُبقى عليه للمشاركة الواسعة أو للأجهزة الأقدم. «متقدّم» يعطي إضاءة وانعكاسات أفضل ويطلب بطاقة رسومات حديثة. اللوحة المفتوحة أصلًا تحتفظ بمحرّكها: أغلقها ثم افتحها من جديد لتغييره.", + "help": "بأي واجهة رسومية ترسم العرض ثلاثي الأبعاد. «متوافق» يعمل على كل التهيئات، وهو ما يُبقى عليه للمشاركة الواسعة أو للأجهزة الأقدم. «متقدّم» يعطي إضاءة وانعكاسات أفضل ويطلب بطاقة رسومات حديثة. اللوحة المفتوحة أصلًا تحتفظ بمحرّكها: أغلقها ثم افتحها من جديد لتغييره. محرّك «متقدّم» لم يُبنَ بعد في هذه النسخة: اختياره يرسم بمحرّك «متوافق».", "gl": "متوافق", "gpu": "متقدّم" }, diff --git a/src/shared/i18n/de/settings.json b/src/shared/i18n/de/settings.json index 7a9b51823..1150ffbd6 100644 --- a/src/shared/i18n/de/settings.json +++ b/src/shared/i18n/de/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Render-Engine", - "help": "Mit welcher Grafikschnittstelle die 3D-Ansicht zeichnet. Kompatibel läuft auf jeder Konfiguration und ist die Wahl für breites Teilen oder ältere Rechner. Erweitert liefert bessere Beleuchtung und Spiegelungen und verlangt eine aktuelle Grafikkarte. Ein bereits offenes Panel behält seine Engine: schließen und wieder öffnen, um zu wechseln.", + "help": "Mit welcher Grafikschnittstelle die 3D-Ansicht zeichnet. Kompatibel läuft auf jeder Konfiguration und ist die Wahl für breites Teilen oder ältere Rechner. Erweitert liefert bessere Beleuchtung und Spiegelungen und verlangt eine aktuelle Grafikkarte. Ein bereits offenes Panel behält seine Engine: schließen und wieder öffnen, um zu wechseln. Die erweiterte Engine ist in dieser Version noch nicht gebaut: Wer sie wählt, zeichnet mit der kompatiblen.", "gl": "Kompatibel", "gpu": "Erweitert" }, diff --git a/src/shared/i18n/en/settings.json b/src/shared/i18n/en/settings.json index c86039e34..8daebcb7d 100644 --- a/src/shared/i18n/en/settings.json +++ b/src/shared/i18n/en/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Render engine", - "help": "Which graphics interface the 3D viewport draws with. Compatible works on every configuration and is the one to keep for sharing widely or for older machines. Advanced gives better lighting and reflections and asks for a recent graphics card. A panel already open keeps the engine it opened with: close and reopen it to change.", + "help": "Which graphics interface the 3D viewport draws with. Compatible works on every configuration and is the one to keep for sharing widely or for older machines. Advanced gives better lighting and reflections and asks for a recent graphics card. A panel already open keeps the engine it opened with: close and reopen it to change. The Advanced engine is not built yet in this version: picking it draws with the Compatible one.", "gl": "Compatible", "gpu": "Advanced" }, diff --git a/src/shared/i18n/es/settings.json b/src/shared/i18n/es/settings.json index cfe489c10..e3806ef31 100644 --- a/src/shared/i18n/es/settings.json +++ b/src/shared/i18n/es/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Motor de renderizado", - "help": "Con qué interfaz gráfica dibuja la vista 3D. Compatible funciona en cualquier configuración y es la opción para compartir ampliamente o para máquinas más antiguas. Avanzado da mejor iluminación y reflejos y pide una tarjeta gráfica reciente. Un panel ya abierto conserva su motor: ciérrelo y vuelva a abrirlo para cambiarlo.", + "help": "Con qué interfaz gráfica dibuja la vista 3D. Compatible funciona en cualquier configuración y es la opción para compartir ampliamente o para máquinas más antiguas. Avanzado da mejor iluminación y reflejos y pide una tarjeta gráfica reciente. Un panel ya abierto conserva su motor: ciérrelo y vuelva a abrirlo para cambiarlo. El motor Avanzado aún no está construido en esta versión: elegirlo dibuja con el Compatible.", "gl": "Compatible", "gpu": "Avanzado" }, diff --git a/src/shared/i18n/fr/settings.json b/src/shared/i18n/fr/settings.json index 4b8ccf4c6..2ad2f74f3 100644 --- a/src/shared/i18n/fr/settings.json +++ b/src/shared/i18n/fr/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Moteur de rendu", - "help": "Avec quelle interface graphique la vue 3D dessine. Compatible fonctionne sur toutes les configurations : c’est celui à garder pour partager largement ou pour les machines plus anciennes. Avancé donne un éclairage et des reflets de meilleure qualité et demande une carte graphique récente. Un panneau déjà ouvert conserve le moteur avec lequel il s’est ouvert : refermez-le et rouvrez-le pour en changer.", + "help": "Avec quelle interface graphique la vue 3D dessine. Compatible fonctionne sur toutes les configurations : c’est celui à garder pour partager largement ou pour les machines plus anciennes. Avancé donne un éclairage et des reflets de meilleure qualité et demande une carte graphique récente. Un panneau déjà ouvert conserve le moteur avec lequel il s’est ouvert : refermez-le et rouvrez-le pour en changer. Le moteur Avancé n’est pas encore construit dans cette version : le choisir dessine avec le Compatible.", "gl": "Compatible", "gpu": "Avancé" }, diff --git a/src/shared/i18n/hi/settings.json b/src/shared/i18n/hi/settings.json index bfd00158c..8ca76a847 100644 --- a/src/shared/i18n/hi/settings.json +++ b/src/shared/i18n/hi/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "रेंडर इंजन", - "help": "3D दृश्य किस ग्राफ़िक्स इंटरफ़ेस से बनता है। संगत हर कॉन्फ़िगरेशन पर चलता है और व्यापक साझेदारी या पुरानी मशीनों के लिए यही रखना चाहिए। उन्नत बेहतर रोशनी और परावर्तन देता है और नया ग्राफ़िक्स कार्ड माँगता है। पहले से खुला पैनल अपना इंजन बनाए रखता है: बदलने के लिए उसे बंद करके फिर खोलें।", + "help": "3D दृश्य किस ग्राफ़िक्स इंटरफ़ेस से बनता है। संगत हर कॉन्फ़िगरेशन पर चलता है और व्यापक साझेदारी या पुरानी मशीनों के लिए यही रखना चाहिए। उन्नत बेहतर रोशनी और परावर्तन देता है और नया ग्राफ़िक्स कार्ड माँगता है। पहले से खुला पैनल अपना इंजन बनाए रखता है: बदलने के लिए उसे बंद करके फिर खोलें। उन्नत इंजन इस संस्करण में अभी बना नहीं है: उसे चुनने पर भी संगत इंजन से ही बनता है।", "gl": "संगत", "gpu": "उन्नत" }, diff --git a/src/shared/i18n/id/settings.json b/src/shared/i18n/id/settings.json index 266b700ce..a9c99b8df 100644 --- a/src/shared/i18n/id/settings.json +++ b/src/shared/i18n/id/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Mesin render", - "help": "Antarmuka grafis mana yang dipakai tampilan 3D untuk menggambar. Kompatibel berjalan di setiap konfigurasi dan itulah yang dipertahankan untuk berbagi luas atau mesin lama. Lanjutan memberi pencahayaan dan pantulan yang lebih baik dan meminta kartu grafis terkini. Panel yang sudah terbuka mempertahankan mesinnya: tutup dan buka lagi untuk berganti.", + "help": "Antarmuka grafis mana yang dipakai tampilan 3D untuk menggambar. Kompatibel berjalan di setiap konfigurasi dan itulah yang dipertahankan untuk berbagi luas atau mesin lama. Lanjutan memberi pencahayaan dan pantulan yang lebih baik dan meminta kartu grafis terkini. Panel yang sudah terbuka mempertahankan mesinnya: tutup dan buka lagi untuk berganti. Mesin Lanjutan belum dibangun di versi ini: memilihnya tetap menggambar dengan yang Kompatibel.", "gl": "Kompatibel", "gpu": "Lanjutan" }, diff --git a/src/shared/i18n/it/settings.json b/src/shared/i18n/it/settings.json index 681de1055..5b79b6ee9 100644 --- a/src/shared/i18n/it/settings.json +++ b/src/shared/i18n/it/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Motore di rendering", - "help": "Con quale interfaccia grafica disegna la vista 3D. Compatibile funziona su ogni configurazione ed è quello da tenere per condividere ampiamente o per macchine più vecchie. Avanzato dà illuminazione e riflessi migliori e chiede una scheda grafica recente. Un pannello già aperto conserva il suo motore: chiuderlo e riaprirlo per cambiarlo.", + "help": "Con quale interfaccia grafica disegna la vista 3D. Compatibile funziona su ogni configurazione ed è quello da tenere per condividere ampiamente o per macchine più vecchie. Avanzato dà illuminazione e riflessi migliori e chiede una scheda grafica recente. Un pannello già aperto conserva il suo motore: chiuderlo e riaprirlo per cambiarlo. Il motore Avanzato non è ancora costruito in questa versione: sceglierlo disegna con il Compatibile.", "gl": "Compatibile", "gpu": "Avanzato" }, diff --git a/src/shared/i18n/ja/settings.json b/src/shared/i18n/ja/settings.json index 7bd080d10..80343ff69 100644 --- a/src/shared/i18n/ja/settings.json +++ b/src/shared/i18n/ja/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "レンダーエンジン", - "help": "3D ビューがどのグラフィックス インターフェースで描くかです。「互換」はあらゆる構成で動き、広く共有する場合や古いマシンではこちらを保ちます。「上級」は照明と反射の質が上がり、新しめのグラフィックスカードを求めます。すでに開いているパネルは開いたときのエンジンのままです。切り替えるには閉じて開き直してください。", + "help": "3D ビューがどのグラフィックス インターフェースで描くかです。「互換」はあらゆる構成で動き、広く共有する場合や古いマシンではこちらを保ちます。「上級」は照明と反射の質が上がり、新しめのグラフィックスカードを求めます。すでに開いているパネルは開いたときのエンジンのままです。切り替えるには閉じて開き直してください。「上級」エンジンはこのバージョンではまだ実装されていません。選んでも「互換」で描かれます。", "gl": "互換", "gpu": "上級" }, diff --git a/src/shared/i18n/ko/settings.json b/src/shared/i18n/ko/settings.json index 627e7acf9..df00818bc 100644 --- a/src/shared/i18n/ko/settings.json +++ b/src/shared/i18n/ko/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "렌더 엔진", - "help": "3D 뷰가 어떤 그래픽 인터페이스로 그릴지 정합니다. 호환은 모든 구성에서 동작하며, 널리 공유하거나 오래된 기기에서는 이쪽을 유지합니다. 고급은 조명과 반사의 품질이 높아지고 최신 그래픽 카드를 요구합니다. 이미 열린 패널은 열릴 때의 엔진을 그대로 씁니다. 바꾸려면 닫았다가 다시 여세요.", + "help": "3D 뷰가 어떤 그래픽 인터페이스로 그릴지 정합니다. 호환은 모든 구성에서 동작하며, 널리 공유하거나 오래된 기기에서는 이쪽을 유지합니다. 고급은 조명과 반사의 품질이 높아지고 최신 그래픽 카드를 요구합니다. 이미 열린 패널은 열릴 때의 엔진을 그대로 씁니다. 바꾸려면 닫았다가 다시 여세요. 고급 엔진은 이 버전에서 아직 만들어지지 않았습니다. 선택해도 호환 엔진으로 그립니다.", "gl": "호환", "gpu": "고급" }, diff --git a/src/shared/i18n/pt/settings.json b/src/shared/i18n/pt/settings.json index 02f064ad7..46ca47e46 100644 --- a/src/shared/i18n/pt/settings.json +++ b/src/shared/i18n/pt/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Motor de renderização", - "help": "Com que interface gráfica a vista 3D desenha. Compatível funciona em qualquer configuração e é o que se guarda para partilhar amplamente ou para máquinas mais antigas. Avançado dá melhor iluminação e reflexos e pede uma placa gráfica recente. Um painel já aberto conserva o seu motor: feche-o e volte a abri-lo para mudar.", + "help": "Com que interface gráfica a vista 3D desenha. Compatível funciona em qualquer configuração e é o que se guarda para partilhar amplamente ou para máquinas mais antigas. Avançado dá melhor iluminação e reflexos e pede uma placa gráfica recente. Um painel já aberto conserva o seu motor: feche-o e volte a abri-lo para mudar. O motor Avançado ainda não está construído nesta versão: escolhê-lo desenha com o Compatível.", "gl": "Compatível", "gpu": "Avançado" }, diff --git a/src/shared/i18n/ru/settings.json b/src/shared/i18n/ru/settings.json index 5027a59ed..cdfd69a77 100644 --- a/src/shared/i18n/ru/settings.json +++ b/src/shared/i18n/ru/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Движок отрисовки", - "help": "С каким графическим интерфейсом рисует 3D-вид. «Совместимый» работает на любой конфигурации — его и стоит держать для широкого обмена и старых машин. «Продвинутый» даёт лучше свет и отражения и требует свежей видеокарты. Уже открытая панель сохраняет свой движок: закройте и откройте её заново, чтобы сменить.", + "help": "С каким графическим интерфейсом рисует 3D-вид. «Совместимый» работает на любой конфигурации — его и стоит держать для широкого обмена и старых машин. «Продвинутый» даёт лучше свет и отражения и требует свежей видеокарты. Уже открытая панель сохраняет свой движок: закройте и откройте её заново, чтобы сменить. Продвинутый движок в этой версии ещё не собран: выбрать его — значит рисовать Совместимым.", "gl": "Совместимый", "gpu": "Продвинутый" }, diff --git a/src/shared/i18n/tr/settings.json b/src/shared/i18n/tr/settings.json index 36d2997e2..59e533f39 100644 --- a/src/shared/i18n/tr/settings.json +++ b/src/shared/i18n/tr/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Render motoru", - "help": "3B görünümün hangi grafik arabirimiyle çizdiği. Uyumlu her yapılandırmada çalışır; geniş paylaşım ve eski makineler için tutulacak olan budur. Gelişmiş daha iyi aydınlatma ve yansıma verir ve yeni bir ekran kartı ister. Zaten açık bir panel motorunu korur: değiştirmek için kapatıp yeniden açın.", + "help": "3B görünümün hangi grafik arabirimiyle çizdiği. Uyumlu her yapılandırmada çalışır; geniş paylaşım ve eski makineler için tutulacak olan budur. Gelişmiş daha iyi aydınlatma ve yansıma verir ve yeni bir ekran kartı ister. Zaten açık bir panel motorunu korur: değiştirmek için kapatıp yeniden açın. Gelişmiş motor bu sürümde henüz hazır değil: onu seçmek Uyumlu olanla çizer.", "gl": "Uyumlu", "gpu": "Gelişmiş" }, diff --git a/src/shared/i18n/vi/settings.json b/src/shared/i18n/vi/settings.json index d8ac922c8..f4d82d58f 100644 --- a/src/shared/i18n/vi/settings.json +++ b/src/shared/i18n/vi/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Bộ máy dựng hình", - "help": "Giao diện đồ họa nào vẽ khung nhìn 3D. Tương thích chạy trên mọi cấu hình và là lựa chọn để chia sẻ rộng hay cho máy cũ. Nâng cao cho ánh sáng và phản chiếu tốt hơn, và đòi một card đồ họa đời mới. Bảng đã mở vẫn giữ bộ máy của nó: đóng rồi mở lại để đổi.", + "help": "Giao diện đồ họa nào vẽ khung nhìn 3D. Tương thích chạy trên mọi cấu hình và là lựa chọn để chia sẻ rộng hay cho máy cũ. Nâng cao cho ánh sáng và phản chiếu tốt hơn, và đòi một card đồ họa đời mới. Bảng đã mở vẫn giữ bộ máy của nó: đóng rồi mở lại để đổi. Bộ máy Nâng cao chưa được dựng trong phiên bản này: chọn nó vẫn vẽ bằng bộ Tương thích.", "gl": "Tương thích", "gpu": "Nâng cao" }, diff --git a/src/shared/i18n/zh/settings.json b/src/shared/i18n/zh/settings.json index 016af6ecb..35855acb8 100644 --- a/src/shared/i18n/zh/settings.json +++ b/src/shared/i18n/zh/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "渲染引擎", - "help": "3D 视图用哪一套图形接口绘制。「兼容」在所有配置上都能运行,要广泛分享或使用较旧的机器就保留它。「高级」的光照与反射更好,需要较新的显卡。已经打开的面板会保留打开时的引擎:关掉再打开才会更换。", + "help": "3D 视图用哪一套图形接口绘制。「兼容」在所有配置上都能运行,要广泛分享或使用较旧的机器就保留它。「高级」的光照与反射更好,需要较新的显卡。已经打开的面板会保留打开时的引擎:关掉再打开才会更换。「高级」引擎在这个版本里尚未构建:选它仍会用「兼容」来绘制。", "gl": "兼容", "gpu": "高级" }, From 0b1f82c72614fdba6d895af8937f23747ce310a2 Mon Sep 17 00:00:00 2001 From: Alban Pasquelin Date: Thu, 10 Sep 2026 22:36:25 +0200 Subject: [PATCH 05/13] =?UTF-8?q?Fait=20dessiner=20le=20moteur=20Avanc?= =?UTF-8?q?=C3=A9=20:=20WebGPU,=20patch=20mat=C3=A9riau=20TSL=20et=20GTAO?= =?UTF-8?q?=20en=20n=C5=93uds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Étape 3 du chantier C6. Le moteur Avancé dessine réellement, mesuré sur cette machine (M2 Max, adaptateur WebGPU présent). - `gpuModule` charge `three/webgpu`, TSL et les nœuds d'affichage à la demande, une seule fois, et seulement là où un adaptateur a répondu : deux mégaoctets de renderer ne sont jamais cherchés sur une machine qui ne peut pas s'en servir. `three/webgpu` et `three` partagent `three.core.js`, donc le renderer de nœuds dessine les scènes que le studio construit déjà. - `materialNodes` réécrit le patch matériau en TSL. Deux écarts assumés : plus aucune recompilation quand un canal se remplit (un uniforme remplace le `#ifdef`), et la cavité tombe sur la couleur diffuse faute de couture sur `reflectedLight` — identique sur un diélectrique, un peu plus sombre sur un métal. Les uniformes sont ceux du moteur, partagés, pas copiés. - `gpuComposer` bâtit un `RenderPipeline` : passe de scène en MRT avec ses normales, puis `ao()` natif multiplié dedans. `gtao.engines` passe à `['gl','gpu']`. Aucune fusion façon `fuseShader` : le pipeline partage déjà profondeur et normales. - `gpuPostQuality` est DÉRIVÉ de `postQuality`, jamais une seconde table : le même réglage achète la même chose des deux côtés. - Lecture de pixels par `readRenderTargetPixelsAsync`, sous la signature promise dès l'étape 2. Les trois appelants n'ont pas bougé. - `StudioRenderer` élargit le type du renderer sur toute la chaîne viewport. Les différences réelles sont nommées une par une : pas de porte globale sur la passe d'ombres, pas de minuterie GPU, pas de `forceContextLoss`, l'anisotropie lue à deux endroits, les échantillons MSAA à zéro. Trois pannes trouvées en faisant tourner le banc, pas en relisant le code : le montage demandait son contexte WebGL2 au renderer de nœuds avant son init, la scène préfiltrait la salle neutre avant l'init, et les images n'étaient pas retenues tant que le backend n'était pas là. Banc : `pnpm engines:bench`, harnais navigateur piloté par CDP, comme `world:validate`. Chiffres dans le rapport, y compris celui qui dérange : sur ces deux profils l'Avancé coûte plus cher côté CPU (0,12 ms contre 0,045 à 0,080) et n'est plus rapide sur rien. Il ne se dégrade pas avec la scène, lui, mais aucun seuil de gain n'est atteint et le rapport le dit tel quel. TRAA écarté, motif écrit : pas de jumeau GL, donc pas de place dans un catalogue partagé sans une UI par moteur, que le spec met hors périmètre. --- docs/fr/audits/moteur-rendu-c6/RAPPORT.md | 162 +++++++++++++--- package.json | 1 + scripts/run-engine-benchmark.mjs | 33 ++++ src/renderer/src/engines/gpu/gpuPipeline.ts | 9 +- .../engines/material/MaterialRenderer.test.ts | 21 +- .../engines/render/engineBenchmark.browser.ts | 182 ++++++++++++++++++ src/renderer/src/engines/render/glDriver.ts | 51 ++++- .../src/engines/render/gpuComposer.ts | 154 +++++++++++++++ src/renderer/src/engines/render/gpuDriver.ts | 113 +++++++++-- src/renderer/src/engines/render/gpuModule.ts | 57 ++++++ .../src/engines/render/gpuPostQuality.test.ts | 40 ++++ .../src/engines/render/gpuPostQuality.ts | 45 +++++ .../src/engines/render/materialNodes.test.ts | 105 ++++++++++ .../src/engines/render/materialNodes.ts | 71 +++++++ .../src/engines/render/mountRenderer.test.ts | 4 + .../src/engines/render/mountRenderer.ts | 11 +- .../src/engines/render/renderDriver.ts | 74 ++++++- .../src/engines/render/sceneComposer.ts | 44 +++++ .../src/engines/scene/SceneRendererDisplay.ts | 14 ++ .../src/engines/scene/SceneRendererFlight.ts | 7 +- .../engines/scene/SceneRendererLifecycle.ts | 29 ++- .../src/engines/scene/SceneRendererPreview.ts | 3 +- .../src/engines/scene/SceneRendererState.ts | 9 +- src/renderer/src/engines/scene/gltfSource.ts | 12 +- src/renderer/src/engines/scene/shadows.ts | 27 ++- .../src/engines/scene/textureCache.ts | 18 +- .../engines/skybox/SkyboxRenderer01.test.ts | 13 +- .../engines/skybox/SkyboxRenderer02.test.ts | 13 +- .../engines/skybox/SkyboxRenderer03.test.ts | 13 +- .../engines/skybox/SkyboxRenderer04.test.ts | 13 +- .../src/engines/viewport/ViewportDrawing.ts | 22 ++- .../viewport/ViewportEngine01Split06.test.ts | 6 +- .../src/engines/viewport/ViewportFrame.ts | 45 +++-- .../src/engines/viewport/ViewportInset.ts | 29 +-- .../src/engines/viewport/ViewportState.ts | 22 ++- .../src/engines/viewport/ViewportSurface.ts | 81 ++++++-- .../src/engines/viewport/environment.test.ts | 51 +++-- .../src/engines/viewport/environment.ts | 59 +++--- .../viewport/viewportEngineSupport1.ts | 4 +- src/shared/domain/postProcessing.test.ts | 9 +- src/shared/domain/postProcessingRegistry.ts | 6 +- src/shared/domain/renderEngine.ts | 3 + src/shared/i18n/ar/diagnostics.json | 1 + src/shared/i18n/ar/settings.json | 2 +- src/shared/i18n/de/diagnostics.json | 1 + src/shared/i18n/de/settings.json | 2 +- src/shared/i18n/en/diagnostics.json | 1 + src/shared/i18n/en/settings.json | 2 +- src/shared/i18n/es/diagnostics.json | 1 + src/shared/i18n/es/settings.json | 2 +- src/shared/i18n/fr/diagnostics.json | 1 + src/shared/i18n/fr/settings.json | 2 +- src/shared/i18n/hi/diagnostics.json | 1 + src/shared/i18n/hi/settings.json | 2 +- src/shared/i18n/id/diagnostics.json | 1 + src/shared/i18n/id/settings.json | 2 +- src/shared/i18n/it/diagnostics.json | 1 + src/shared/i18n/it/settings.json | 2 +- src/shared/i18n/ja/diagnostics.json | 1 + src/shared/i18n/ja/settings.json | 2 +- src/shared/i18n/ko/diagnostics.json | 1 + src/shared/i18n/ko/settings.json | 2 +- src/shared/i18n/pt/diagnostics.json | 1 + src/shared/i18n/pt/settings.json | 2 +- src/shared/i18n/ru/diagnostics.json | 1 + src/shared/i18n/ru/settings.json | 2 +- src/shared/i18n/tr/diagnostics.json | 1 + src/shared/i18n/tr/settings.json | 2 +- src/shared/i18n/vi/diagnostics.json | 1 + src/shared/i18n/vi/settings.json | 2 +- src/shared/i18n/zh/diagnostics.json | 1 + src/shared/i18n/zh/settings.json | 2 +- 72 files changed, 1448 insertions(+), 282 deletions(-) create mode 100644 scripts/run-engine-benchmark.mjs create mode 100644 src/renderer/src/engines/render/engineBenchmark.browser.ts create mode 100644 src/renderer/src/engines/render/gpuComposer.ts create mode 100644 src/renderer/src/engines/render/gpuModule.ts create mode 100644 src/renderer/src/engines/render/gpuPostQuality.test.ts create mode 100644 src/renderer/src/engines/render/gpuPostQuality.ts create mode 100644 src/renderer/src/engines/render/materialNodes.test.ts create mode 100644 src/renderer/src/engines/render/materialNodes.ts create mode 100644 src/renderer/src/engines/render/sceneComposer.ts diff --git a/docs/fr/audits/moteur-rendu-c6/RAPPORT.md b/docs/fr/audits/moteur-rendu-c6/RAPPORT.md index a0eac39a7..7770852ef 100644 --- a/docs/fr/audits/moteur-rendu-c6/RAPPORT.md +++ b/docs/fr/audits/moteur-rendu-c6/RAPPORT.md @@ -9,7 +9,7 @@ Machine : Apple M2 Max, macOS 26.5.2 (Darwin 25.6.0), arm64. three.js 0.185.1. | --- | --- | --- | | 1 — Gains WebGL indépendants | livrée, 1 MUST refusé sur mesure | 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. | | 2 — Interface driver + choix moteur | livrée, 1 écart d'emplacement | `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. | -| 3 — Premier contenu GPU réel | **non livrée** | Le préalable — un `WebGPURenderer` réellement monté — est le port de toute la chaîne de rendu, pas un spike. Chiffré plus bas. Aucun chiffre, aucune capture : rien n'a été mesuré, donc rien n'est affirmé. | +| 3 — Premier contenu GPU réel | livrée, TRAA écarté | `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. TRAA non porté : motif plus bas. | ## Étape 1 @@ -160,10 +160,9 @@ contrôle. Le verrou « pas de switch en direct » tient **par construction** : montage du viewport et jamais relu ; l'aide le dit. Mettre le choix dans le projet demanderait un champ de manifeste et sa validation — hors périmètre de cette étape, à décider. -Deux revues sur trois ont demandé de ne pas livrer le réglage du tout tant qu'Avancé ne dessine -rien. Le spec le demande à cette étape, donc il est livré — mais son aide dit désormais en toutes -lettres que le moteur Avancé n'est pas encore construit et que le choisir dessine en Compatible. -Un réglage qui promet sans tenir est le défaut que ces revues visaient. +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 @@ -172,41 +171,142 @@ Un réglage qui promet sans tenir est le défaut que ces revues visaient. tant qu'aucun effet GPU n'existe, et un filtre qu'on ne peut pas voir tourner est un filtre qu'on ne peut pas relire. -## Étape 3 — non livrée, et pourquoi +## Étape 3 -Le point 3.1 (patch matériau en TSL), 3.2 (GTAO en TSL), 3.3 (lecture de pixels GPU) et le -budget qualité `RenderPipeline` sont tous **derrière un préalable** : que `GPUDriver.createRenderer` -rende un `WebGPURenderer` réellement monté. Ce préalable n'est pas un spike. +### Ce qui a été mesuré, et sur quoi -Mesuré dans ce dépôt et dans three 0.185 : +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. -- `EffectComposer` est **WebGL uniquement**, et three le dit dans sa propre documentation : - `examples/jsm/postprocessing/EffectComposer.js:18` — « This module can only be used with - WebGLRenderer ». Toute la chaîne de composition du studio est construite dessus : - **1 941 lignes** dans `engines/postfx/` hors tests, plus **291 lignes** de passes GLSL dans - `engines/gpu/`, dont `fuseShader` qui n'a aucun équivalent souhaitable côté TSL. -- **29 fichiers hors tests** nomment `WebGLRenderer`. `ViewportSurface.gl` est typé - `WebGLRenderer | null` et lu par les passes, les overlays, `TransformControls` et `ViewHelper`. -- Le viewport lit encore `renderer.getContext()` pour la minuterie GPU (`gpuTimer.ts`, extension - WebGL2) et `renderer.info.autoReset`, qui n'ont pas le même contrat côté WebGPU. +**Ce que chaque colonne mesure, et rien de plus** : -Une chaîne `RenderPipeline` parallèle, un typage `Renderer` propagé sur ces 29 fichiers, et une -seconde implémentation des effets : c'est un chantier, pas une étape. **Il faut le décider, pas le -commencer en fin de lot.** +- `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. +- `firstReadbackMs` — la PREMIÈRE lecture de pixels. Elle vide la file, donc elle absorbe aussi + ce qui restait à compiler. +- `readbackMs` — la seconde, une fois plus rien à compiler. Ce qu'un export paie par image. -S'ajoute une limite d'environnement, indépendante du volume : les critères d'acceptation de -l'étape 3 sont un test de non-régression **visuel** GL vs GPU, une **table de chiffres** sur deux -profils et deux moteurs, et une **capture** d'export. Aucun des trois n'est productible ici — la -suite tourne sous jsdom, sans WebGPU ni GPU. Les écrire sans les mesurer serait précisément ce que -le spec interdit. +| Profil | Moteur | `submitMs` | `firstReadbackMs` | `readbackMs` | +| --- | --- | ---: | ---: | ---: | +| Un modèle (4 nœuds) | Compatible | 0,045 | 63,3 | 45,2 | +| Un modèle (4 nœuds) | Avancé | 0,125 | 640,8 | 41,2 | +| Monde ouvert C5 (20 000 nœuds) | Compatible | 0,080 | 52,8 | 39,3 | +| Monde ouvert C5 (20 000 nœuds) | Avancé | 0,122 | 60,1 | 40,4 | + +**Ce que ces chiffres disent, sans arrangement** : + +- **Le moteur Avancé coûte plus cher côté CPU par image** : 0,12 ms contre 0,045–0,080. C'est + 1,5 à 2,8 fois, et cela reste très en dessous d'un budget d'image. +- **Il ne se dégrade pas avec la scène** : 0,125 ms sur 4 nœuds et 0,122 ms sur 20 000, quand le + Compatible passe de 0,045 à 0,080. Le coût par objet du renderer de nœuds est plat sur ces deux + profils. Une seule machine, deux profils : c'est une observation, pas une loi. +- **La première lecture de l'Avancé est chère — 640,8 ms** — parce qu'elle paie la compilation + de tous les pipelines de nœuds du graphe. Le second profil ne la repaie pas (60,1 ms) : les + pipelines sont déjà là. **Ce n'est pas un coût de lecture, et il ne doit pas être lu comme tel.** +- **À chaud, les deux moteurs lisent au même prix** (~40–45 ms) : la lecture est dominée par la + synchronisation, pas par l'API. +- **Aucun seuil de gain n'est atteint sur ces mesures.** Le moteur Avancé n'est, ici, pas plus + rapide que le Compatible. 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. + +### 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. Cinq tests tiennent +ce pont, dont celui qui vérifie que le graphe ne se reconstruit pas quand un canal arrive. + +**Non mesuré** : la comparaison visuelle GL/GPU du patch. Elle demande deux rendus de la même +scène de référence à comparer pixel à pixel, comme `world:validate` le fait déjà entre deux +représentations — le harnais existe, l'entrée pour les deux moteurs n'a pas été écrite. + +### 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 `readbackMs`, qui est +exactement `captureStill`. + +### 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. Cinq tests, dont deux qui +comparent les deux lectures réglage par réglage. + +Une limite honnête : **TRAA n'expose aucun nombre d'échantillons** dans three 0.185 — ses +échantillons sont des IMAGES, une par gigue d'une séquence fixe. Le seul levier de qualité est +la correction sous-pixel, et c'est ce que le budget pilote. + +### 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. + +### 3.6 — TRAA : écarté, avec le motif + +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 publierait un effet que la +moitié des projets ne peuvent pas dessiner — et le rendre visible demanderait une bibliothèque +d'effets consciente du moteur, c'est-à-dire l'UX que le spec met hors périmètre. Le budget +qualité prévoit déjà son levier ; l'effet attend son jumeau GL ou une UI par moteur. + +### 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à. +- **L'aperçu incrusté** et 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. ## Ce qui reste ouvert -- Le port WebGPU lui-même (étape 3), à ouvrir comme chantier avec sa propre branche de banc. -- Le switch en direct du moteur : hors périmètre, et il le reste tant que le patch matériau n'est - pas porté en TSL. +- **La comparaison visuelle GL/GPU**, sur le patch matériau comme sur GTAO. Le harnais de + `world:validate` compare déjà deux représentations pixel à pixel ; l'entrée qui compare deux + MOTEURS n'est pas écrite. Tant qu'elle ne l'est pas, « visuellement équivalent » n'est affirmé + par personne dans ce rapport. +- **La capture d'export sur un projet `'gpu'`** : le chemin est mesuré (`readbackMs` EST + `captureStill`), l'image n'est pas jointe. +- Les vingt-neuf autres effets, l'aperçu incrusté et la correction de ciel côté Avancé. +- Le switch en direct du moteur : hors périmètre. - SSGI : hors périmètre par décision du spec. -- TRAA : non porté, l'effet n'existe même pas côté GL dans `PostEffectId`. +- TRAA : écarté, motif au § 3.6. - Le choix du moteur par PROJET plutôt que par application, si le sélecteur doit vraiment vivre à la création : demande un champ de manifeste et sa validation. - Coût réel des cascades et de l'anisotropie : à mesurer sur un banc GPU, qui n'existe pas encore diff --git a/package.json b/package.json index b44575408..b694046d0 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "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", "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/run-engine-benchmark.mjs b/scripts/run-engine-benchmark.mjs new file mode 100644 index 000000000..b6d63bd11 --- /dev/null +++ b/scripts/run-engine-benchmark.mjs @@ -0,0 +1,33 @@ +import { evaluate } 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 evaluate( + `(async () => { + await import('/src/engines/render/engineBenchmark.browser.ts') + const bench = Reflect.get(window, '__iaBenchmarkRenderEngines') + if (typeof bench !== 'function') throw new Error('le harnais de banc moteur est absent') + return await bench() + })()`, + { 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/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 3ec94e324..d2ffae96f 100644 --- a/src/renderer/src/engines/material/MaterialRenderer.test.ts +++ b/src/renderer/src/engines/material/MaterialRenderer.test.ts @@ -1,4 +1,5 @@ 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' @@ -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() 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..5ce707755 --- /dev/null +++ b/src/renderer/src/engines/render/engineBenchmark.browser.ts @@ -0,0 +1,182 @@ +/** + * 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. + * - `firstReadbackMs` — the FIRST still drawn and read back. It forces the queue empty, so it + * also absorbs whatever the engine had left to compile: on a node chain that is every pipeline + * of the graph, and reading it as a readback cost would be reading a compile as a copy. + * - `readbackMs` — the second one, once nothing is left to compile. What an export actually pays + * per frame. Apart from the submit on purpose: WebGL reads synchronously and a node renderer + * maps a buffer, so folded together they would hide which half moved. + * + * `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 { DEFAULT_SETTINGS } from '@shared/domain/settings' +import type { RenderEngine } from '@shared/domain/renderEngine' +import type { PostStack } from '@shared/domain/postProcessing' +import { postEffect } from '@shared/domain/postProcessing' +import { SceneRenderer } from '../scene/SceneRenderer' +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' + +type EngineMeasure = { + engine: RenderEngine + /** What the viewport ACTUALLY mounted: `gl` here under `gpu` is the silent fallback. */ + drawnWith: RenderEngine + submitMs: number | null + firstReadbackMs: number | null + readbackMs: 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 each figure is the mean of, after the ones that only compile shaders. */ +const MEASURED_FRAMES = 60 +const WARMUP_FRAMES = 10 + +const OFFSCREEN_HOST_OFFSET_PX = -100_000 +const SURFACE = { width: 1280, height: 720 } + +/** + * 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 { + const host = offscreenHost() + const renderer = new SceneRenderer({ onSelect: () => {}, onTransform: () => {}, chrome: false }) + try { + // Before the mount, never after: the engine is read once, when the renderer is built. + renderer.configure({ ...DEFAULT_SETTINGS.three, engine, quality: 'high' }) + renderer.mount(host) + // The node backend comes up a beat after the mount: measured before it does, every draw + // would throw and the column would report a race rather than an engine. + await renderer.settled() + renderer.apply({ ...state, world: { ...state.world, post: STACK } }) + await settled() + + const drawnWith = renderer.renderEngine + for (let frame = 0; frame < WARMUP_FRAMES; frame += 1) renderer.drawFrom(null, 0) + + 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 firstReadbackMs = performance.now() - cold + + const warm = performance.now() + await renderer.captureStill('view') + const readbackMs = performance.now() - warm + + return { engine, drawnWith, submitMs, firstReadbackMs, readbackMs } + } 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, + firstReadbackMs: null, + readbackMs: null, + failed: error instanceof Error ? error.message : String(error), + } + } finally { + renderer.dispose() + host.remove() + } +} + +/** Off screen and sized like a viewport: what is measured is a frame, not a thumbnail. */ +function offscreenHost(): HTMLElement { + const host = document.createElement('div') + host.style.position = 'fixed' + host.style.left = `${OFFSCREEN_HOST_OFFSET_PX}px` + host.style.top = '0' + host.style.width = `${SURFACE.width}px` + host.style.height = `${SURFACE.height}px` + document.body.appendChild(host) + return host +} + +/** 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 +} + +/** + * Two frames of quiet — a texture, a worker and a shader all land between them — or a fixed + * wait, whichever comes first. + * + * 🛑 The race is not belt and braces: a window that is not on screen is handed no animation + * frame at all, and a bench that waited for one hung for as long as the harness allowed rather + * than reporting anything. + */ +async function settled(): Promise { + await Promise.race([ + new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))), + new Promise(resolve => setTimeout(resolve, SETTLE_MS)), + ]) +} + +/** How long the quiet above is given when nothing paints — a hidden window paints nothing. */ +const SETTLE_MS = 250 + +Reflect.set(window, '__iaBenchmarkRenderEngines', benchmarkEngines) diff --git a/src/renderer/src/engines/render/glDriver.ts b/src/renderer/src/engines/render/glDriver.ts index ff6e6adb4..9eadc3b64 100644 --- a/src/renderer/src/engines/render/glDriver.ts +++ b/src/renderer/src/engines/render/glDriver.ts @@ -4,10 +4,12 @@ * 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 { WebGLRenderer } from 'three' -import { createEnvironment } from '../viewport/environment' +import { PMREMGenerator, WebGLRenderer, type Scene } from 'three' +import { PostComposer } from '../postfx/PostComposer' +import { createEnvironment, type EnvironmentPort } from '../viewport/environment' import { readRenderPixels } from '../scene/readRenderPixels' import { bindUniforms, patchFragment } from '../material/materialShader' +import { createSkyGrading, type SkyGrading } from '../gpu/skyGrading' import type { RenderDriver } from './renderDriver' export const glDriver: RenderDriver = { @@ -15,10 +17,16 @@ export const glDriver: RenderDriver = { 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(renderer, target, width, height), + readRenderPixels(asWebGL(renderer), target, width, height), + + createComposer: (renderer, options) => new PostComposer(asWebGL(renderer), options), - createEnvironment, + createEnvironment: (renderer, scene, requestRender) => + createEnvironment(glEnvironmentPort(asWebGL(renderer)), scene, requestRender), patchMaterial: (material, uniforms, onMissingAnchor) => { // Bound once on the material, not per compile: three hands the hook a fresh uniform object @@ -31,3 +39,38 @@ export const glDriver: RenderDriver = { } }, } + +/** + * 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 as Scene, ROOM_SIGMA), + grade: (given, stack) => (grading ??= createSkyGrading(renderer)).of(given, stack) ?? given, + dispose: () => { + grading?.dispose() + grading = null + generator.dispose() + }, + } +} + +/** How far the neutral room is blurred as it is prefiltered — three's own value for one. */ +const ROOM_SIGMA = 0.04 + +/** + * `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: object): WebGLRenderer { + return renderer as WebGLRenderer +} diff --git a/src/renderer/src/engines/render/gpuComposer.ts b/src/renderer/src/engines/render/gpuComposer.ts new file mode 100644 index 000000000..ed4ae0f2b --- /dev/null +++ b/src/renderer/src/engines/render/gpuComposer.ts @@ -0,0 +1,154 @@ +/** + * 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 type { RenderTarget, Renderer } from 'three/webgpu' +import { planStack, POST_EFFECTS, type PostEffect } from '@shared/domain/postProcessing' +import { paramNumber } from '../postfx/uniforms' +import { heaviestCost } from '../postfx/postPlan' +import { gpuBudgetFor, gpuSamplesOf, type GpuBudget } from './gpuPostQuality' +import type { GpuModule } from './gpuModule' +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 = { + pipeline: { render: () => void; 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 +} + +export function createGpuComposer(gpu: GpuModule, renderer: Renderer): SceneComposer { + const chains = new Map() + /** Which surface draws through which chain, so a closed panel frees what only it was using. */ + const bound = new Map() + + const free = (key: string): void => { + chains.get(key)?.pipeline.dispose() + chains.delete(key) + } + + return { + draw: job => { + const plan = planStack(job.stack) + const effects = plan.effects.filter(runsOnGpu) + if (effects.length === 0 || job.width < 1 || job.height < 1) { + aimAt(renderer, job) + renderer.render(job.scene, job.camera) + return + } + + // The SURFACE belongs to the key: a node chain holds the pass that draws the scene, and + // two panes sharing one would each see the other's camera. + const key = `${plan.shapeKey}#${job.surface}` + const chain = chains.get(key) ?? build(gpu, renderer, job, effects) + chains.set(key, chain) + bound.set(job.surface, key) + chain.apply(effects, gpuBudgetFor(heaviestCost(effects), job.quality), job.width, job.height) + + aimAt(renderer, job) + chain.pipeline.render() + }, + + sweep: live => { + const shapes = new Set(live.map(stack => planStack(stack).shapeKey)) + for (const key of [...chains.keys()]) { + if (!shapes.has(key.split('#')[0] ?? '')) free(key) + } + }, + + releaseSurface: surface => { + const key = bound.get(surface) + bound.delete(surface) + // Only once nobody else draws through it: a chain is keyed on the surface, but a sweep + // may have bound two of them to one shape. + if (key && ![...bound.values()].includes(key)) free(key) + }, + + dispose: () => { + for (const key of [...chains.keys()]) free(key) + bound.clear() + }, + } +} + +/** + * 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: Renderer, + job: ComposerJob, + effects: readonly PostEffect[], +): GpuChain { + const { mrt, normalView, output, pass } = gpu.tsl + + const scene = pass(job.scene, job.camera) + // The normals written by the SAME pass that draws the picture: read from a second pass they + // would cost the scene twice, which is the whole reason a node chain exists. + scene.setMRT(mrt({ output, normal: normalView })) + + 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') + const pipeline = new gpu.webgpu.RenderPipeline( + renderer, + occlusion ? occlusion.getTextureNode().mul(colour) : colour, + ) + + return { + pipeline, + apply: (held, budget, width, height) => { + const asked = held.find(one => one.effect === 'gtao') + if (occlusion && asked) applyOcclusion(occlusion, asked, budget, width, height) + }, + } +} + +/** 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 = gpuSamplesOf(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 runsOnGpu(effect: PostEffect): boolean { + return POST_EFFECTS[effect.effect].engines.includes('gpu') +} + +/** Where on the canvas this job lands. Restored by the caller's frame, as on the GL side. */ +function aimAt(renderer: Renderer, job: ComposerJob): 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. + renderer.setRenderTarget((job.target as RenderTarget | null) ?? null) + if (!job.rect) return + 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) +} diff --git a/src/renderer/src/engines/render/gpuDriver.ts b/src/renderer/src/engines/render/gpuDriver.ts index 8db8de750..187bae284 100644 --- a/src/renderer/src/engines/render/gpuDriver.ts +++ b/src/renderer/src/engines/render/gpuDriver.ts @@ -1,30 +1,101 @@ /** - * The Advanced engine: WebGPU, and nothing of it is built yet. + * The Advanced engine: WebGPU, drawn through TSL nodes. * - * A stub that refuses, deliberately: the seam is what this step delivers, and a half-built - * renderer behind it would show a black canvas where the fallback shows a picture. Every call - * raises the same sentence, and `ViewportSurface` answers it by mounting the Compatible engine. + * 🛑 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 type { RenderDriver } from './renderDriver' - -/** Said once, so the four refusals cannot drift into four different sentences. */ -function notBuiltYet(): Error { - return localizedError('renderEngineUnavailable') -} +import { reportFailure } from '@/services/diagnostics' +import { createEnvironment, type EnvironmentPort } from '../viewport/environment' +import { createGpuComposer } from './gpuComposer' +import { loadedGpuModule, type GpuModule } from './gpuModule' +import { applyMaterialNodes } from './materialNodes' +import type { RenderDriver, StudioRenderer } from './renderDriver' +import type { Renderer } from 'three/webgpu' export const gpuDriver: RenderDriver = { engine: 'gpu', - createRenderer: () => { - throw notBuiltYet() - }, - readPixels: () => { - throw notBuiltYet() - }, - createEnvironment: () => { - throw notBuiltYet() - }, - patchMaterial: () => { - throw notBuiltYet() + + createRenderer: ({ canvas, alpha }) => + new (loaded().webgpu.WebGPURenderer)({ canvas, antialias: true, alpha }), + + // The backend, asked for once per renderer. `render()` throws until it answers. + ready: renderer => asNodeRenderer(renderer).init().then(NOTHING), + + readPixels: async (renderer, target, width, height) => { + // `as`: a node renderer takes the `RenderTarget` a `WebGLRenderTarget` extends — three + // declares the pair apart and the studio allocates only the latter. + const pixels = await asNodeRenderer(renderer).readRenderTargetPixelsAsync( + target as unknown as Parameters[0], + 0, + 0, + width, + height, + ) + return new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength) }, + + createComposer: renderer => createGpuComposer(loaded(), asNodeRenderer(renderer)), + + createEnvironment: (renderer, scene, requestRender) => + createEnvironment(gpuEnvironmentPort(loaded(), asNodeRenderer(renderer)), scene, requestRender), + + patchMaterial: (material, uniforms) => applyMaterialNodes(loaded(), material, uniforms), } + +/** + * 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: Renderer): 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(), + } +} + +/** How far the neutral room is blurred as it is prefiltered — three's own value for one. */ +const ROOM_SIGMA = 0.04 + +/** 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): Renderer { + return renderer as Renderer +} + +/** `init` resolves with the renderer; what the caller awaits is that it is up, and nothing more. */ +const NOTHING = (): void => {} 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..19498de2c --- /dev/null +++ b/src/renderer/src/engines/render/gpuPostQuality.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { VIEWPORT_QUALITIES } from '@shared/domain/scene' +import { budgetFor, samplesOf } from '../postfx/postQuality' +import { gpuBudgetFor, gpuSamplesOf } from './gpuPostQuality' + +describe('what the Advanced chain is allowed to spend', () => { + it('spends the whole frame at the top setting', () => { + expect(gpuBudgetFor('high', 'high')).toEqual({ resolutionScale: 1, samples: 1, subpixel: true }) + }) + + it('works the occlusion out at half the frame where the setting says so', () => { + expect(gpuBudgetFor('high', 'performance').resolutionScale).toBe(0.5) + }) + + it('drops the temporal correction at the cheap end, the one lever TRAA has', () => { + expect(gpuBudgetFor('high', 'performance').subpixel).toBe(false) + expect(gpuBudgetFor('high', 'balanced').subpixel).toBe(true) + }) + + // 🛑 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) + } + }) + + it('brings a count down exactly as the Compatible chain does', () => { + for (const quality of VIEWPORT_QUALITIES) { + const asked = 16 + expect(gpuSamplesOf(asked, gpuBudgetFor('high', quality))).toBe( + samplesOf(asked, budgetFor('high', quality)), + ) + } + }) +}) diff --git a/src/renderer/src/engines/render/gpuPostQuality.ts b/src/renderer/src/engines/render/gpuPostQuality.ts new file mode 100644 index 000000000..2e25ff1a5 --- /dev/null +++ b/src/renderer/src/engines/render/gpuPostQuality.ts @@ -0,0 +1,45 @@ +/** + * 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, samplesOf } 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. */ + samples: number + /** + * Whether the temporal anti-aliaser pays for subpixel correction. + * + * 🛑 The one lever TRAA has. `TRAANode` of three 0.185 exposes no sample count — its samples + * are FRAMES, taken one per jitter of a fixed sequence — so a quality level cannot buy fewer + * of them. What it can drop is the per-pixel correction, which is the expensive half. + */ + subpixel: boolean +} + +/** 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, + subpixel: quality !== 'performance', + } +} + +/** A count asked for by a parameter, brought down to what the budget allows. Never below one. */ +export function gpuSamplesOf(asked: number, budget: GpuBudget): number { + return samplesOf(asked, { divisor: 1 / budget.resolutionScale, 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..4b8632918 --- /dev/null +++ b/src/renderer/src/engines/render/materialNodes.test.ts @@ -0,0 +1,105 @@ +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', () => { + it('gives the standard material the three slots the studio adds', () => { + const material = new MeshStandardMaterial() + applyMaterialNodes(gpu, material, createUniforms()) + + expect(material.roughnessNode).toBeTruthy() + expect(material.metalnessNode).toBeTruthy() + expect(material.colorNode).toBeTruthy() + }) + + // 🛑 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..9b7124f87 --- /dev/null +++ b/src/renderer/src/engines/render/materialNodes.ts @@ -0,0 +1,71 @@ +/** + * 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 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 { Color, Texture, type MeshStandardMaterial } from 'three' +import type { MaterialUniforms } from '../material/materialShader' +import type { GpuModule } from './gpuModule' + +/** What the mask samples where no picture is bound. Its intensity is zero there, so it is unlit. */ +const NO_MASK = new Texture() + +/** + * 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, mix, texture, uniform, uv, vec3 } = tsl + + 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 colour = uniform(new Color()).onRenderUpdate(() => material.color) + 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. + material.roughnessNode = roughness.mul( + mix(float(1), mix(roughnessRemap.x, roughnessRemap.y, roughnessTexel.g), hasRoughnessMap), + ) + material.metalnessNode = metalness.mul( + mix(float(1), mix(metalnessRemap.x, metalnessRemap.y, metalnessTexel.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)) + material.colorNode = colour.mul(cavity) +} diff --git a/src/renderer/src/engines/render/mountRenderer.test.ts b/src/renderer/src/engines/render/mountRenderer.test.ts index 7a057d737..3a5b1cee3 100644 --- a/src/renderer/src/engines/render/mountRenderer.test.ts +++ b/src/renderer/src/engines/render/mountRenderer.test.ts @@ -20,7 +20,11 @@ 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') }, diff --git a/src/renderer/src/engines/render/mountRenderer.ts b/src/renderer/src/engines/render/mountRenderer.ts index 5cdab9c9c..120ec2db8 100644 --- a/src/renderer/src/engines/render/mountRenderer.ts +++ b/src/renderer/src/engines/render/mountRenderer.ts @@ -5,12 +5,11 @@ * interface, so a chooser living beside it would close the graph into a cycle — see * `main/import-cycles.test.ts`. */ -import type { WebGLRenderer } from 'three' import type { RenderEngine } from '@shared/domain/renderEngine' import { localizedError } from '@shared/localizedError' import { glDriver } from './glDriver' import { gpuDriver } from './gpuDriver' -import type { RenderDriver, RendererRequest } from './renderDriver' +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 } @@ -23,9 +22,9 @@ const RENDER_DRIVERS: RenderDrivers = { gl: glDriver, gpu: gpuDriver } /** * The driver a policy asks for — the Compatible one whenever the Advanced engine has nothing to - * draw with. `gpuReady` is what `probeGpuAdapter` found, `null` meaning nobody has asked yet: - * a mount cannot wait for an adapter, so the first one of a session opens Compatible and the - * answer is there for the next. + * draw with. `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 and + * the answer is there for the next. */ function driverFor( engine: RenderEngine, @@ -36,7 +35,7 @@ function driverFor( } /** What was mounted, which is not always what was asked for. */ -export type MountedRenderer = { renderer: WebGLRenderer; driver: RenderDriver } +export type MountedRenderer = { renderer: StudioRenderer; driver: RenderDriver } /** * Builds the renderer, and falls back rather than failing: a driver that throws leaves the diff --git a/src/renderer/src/engines/render/renderDriver.ts b/src/renderer/src/engines/render/renderDriver.ts index 962b3f132..c4b0b3fff 100644 --- a/src/renderer/src/engines/render/renderDriver.ts +++ b/src/renderer/src/engines/render/renderDriver.ts @@ -2,17 +2,29 @@ * What DRAWS, behind one interface — the seam between the studio's engines and the graphics API * underneath them. * - * Four things depend on which API is running, and nothing else does: building the renderer, - * reading its pixels back, prefiltering an environment, and patching the standard material. - * Everything else in `engines/` speaks three.js objects, which both APIs share. + * Five things depend on which API is running, and nothing else does: building the renderer, + * reading its pixels back, composing a stack, prefiltering an environment, and patching the + * standard material. 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. * * The same shape as `game/ports/`: the interface here, each implementation in a file of its own. */ -import type { Scene, WebGLRenderer, WebGLRenderTarget } from 'three' -import type { MeshStandardMaterial } from 'three' +import type { MeshStandardMaterial, Scene, WebGLRenderer, WebGLRenderTarget } from 'three' +import type { 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' + +/** + * 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 = { @@ -23,8 +35,16 @@ export type RendererRequest = { export type RenderDriver = { readonly engine: RenderEngine - /** Throws when this engine cannot run here. The caller falls back — see `ViewportSurface`. */ - createRenderer: (request: RendererRequest) => WebGLRenderer + /** 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. * @@ -33,20 +53,22 @@ export type RenderDriver = { * back in every caller. All three of them already sit in an async path. */ readPixels: ( - renderer: WebGLRenderer, + 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: WebGLRenderer, + 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 — an engine that patches by nodes rather than by source never calls it. + * carries — the node engine patches no source, so it never calls it. */ patchMaterial: ( material: MeshStandardMaterial, @@ -54,3 +76,35 @@ export type RenderDriver = { onMissingAnchor: (anchor: string) => void, ) => void } + +/** + * 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 +} + +/** + * How many samples an off-screen target may be antialiased to. + * + * The ceiling comes from three rather than from `gl.MAX_SAMPLES`, which the WebGL1 typing has no + * name for. ZERO on a node renderer: it sizes the attachments of a render target itself, and has + * no context to ask. + */ +export function maxSamplesOf(renderer: StudioRenderer): number { + if (!('capabilities' in renderer)) return 0 + const gl = renderer.getContext() + return Math.max( + 0, + Math.min(Number(gl.getParameter(gl.SAMPLES) ?? 0), renderer.capabilities.maxSamples), + ) +} diff --git a/src/renderer/src/engines/render/sceneComposer.ts b/src/renderer/src/engines/render/sceneComposer.ts new file mode 100644 index 000000000..1990372a2 --- /dev/null +++ b/src/renderer/src/engines/render/sceneComposer.ts @@ -0,0 +1,44 @@ +/** + * 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. + */ +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 + 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/SceneRendererDisplay.ts b/src/renderer/src/engines/scene/SceneRendererDisplay.ts index 5ac90e396..b93b5d592 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, @@ -176,6 +177,19 @@ export abstract class SceneRendererDisplay extends SceneRendererExport { ) } + /** + * 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() + } + /** * Holds the composition off for as long as the caller says, without touching the document. * diff --git a/src/renderer/src/engines/scene/SceneRendererFlight.ts b/src/renderer/src/engines/scene/SceneRendererFlight.ts index 0ca95c5a8..151f62ef6 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 { maxSamplesOf, type StudioRenderer } from '../render/renderDriver' import type { MotionId } from '@shared/domain/shortcut' import { anglesFromDirection } from '@shared/domain/angles' import { aimAlong, turnBy } from '../viewport/lookAround' @@ -24,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 @@ -55,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, maxSamplesOf(gl)) const target = new WebGLRenderTarget(width, height, { samples }) const restore = this.hideWorkshop() const loan = aspectLoan(width, height) diff --git a/src/renderer/src/engines/scene/SceneRendererLifecycle.ts b/src/renderer/src/engines/scene/SceneRendererLifecycle.ts index cffa2c2cc..766c3e8cd 100644 --- a/src/renderer/src/engines/scene/SceneRendererLifecycle.ts +++ b/src/renderer/src/engines/scene/SceneRendererLifecycle.ts @@ -5,7 +5,6 @@ import { springArmRigsOf } from './springArmRigs' import type { Vector3 as TurnedVector } from '@shared/domain/transform' 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' @@ -106,11 +105,9 @@ 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 @@ -120,10 +117,30 @@ export abstract class SceneRendererLifecycle extends SceneRendererResources { this.environment = this.viewport.driver.createEnvironment(renderer, this.viewport.scene, () => this.redraw(), ) - this.environment.setStudio() + // 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() + if (this.environment) 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 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/SceneRendererState.ts b/src/renderer/src/engines/scene/SceneRendererState.ts index 5dce717be..26e4a8cb2 100644 --- a/src/renderer/src/engines/scene/SceneRendererState.ts +++ b/src/renderer/src/engines/scene/SceneRendererState.ts @@ -8,6 +8,8 @@ import { Vector3, type Vector3 as ThreeVector3, } from 'three' +import type { SceneComposer } from '../render/sceneComposer' +import type { WebGLRenderer } from 'three' 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 +40,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' @@ -96,7 +97,9 @@ export abstract class SceneRendererState { // choice is settled for as long as this panel is open. See `RenderPolicy.engine`. engine: () => this.view.engine, onFrame: delta => this.advance(delta), - onOverlay: renderer => this.viewHelper?.render(renderer), + // `as`: `ViewHelper` is declared against a `WebGLRenderer` and uses `clearDepth` and + // `render`, which both engines have — three types the addon before the node renderer. + onOverlay: renderer => this.viewHelper?.render(renderer as WebGLRenderer), onPane: (index, camera) => this.dressPane(index, camera), // Before `TransformControls` reads the same event — see `onPaneArmed`, which says why the // viewport owns this call rather than a listener of this file. @@ -255,7 +258,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/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/shadows.ts b/src/renderer/src/engines/scene/shadows.ts index 7c1b012a0..fb29128a4 100644 --- a/src/renderer/src/engines/scene/shadows.ts +++ b/src/renderer/src/engines/scene/shadows.ts @@ -22,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. @@ -41,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 } } diff --git a/src/renderer/src/engines/scene/textureCache.ts b/src/renderer/src/engines/scene/textureCache.ts index b8548a5bd..84c4e4986 100644 --- a/src/renderer/src/engines/scene/textureCache.ts +++ b/src/renderer/src/engines/scene/textureCache.ts @@ -142,17 +142,27 @@ async function tiffTexture(bytes: Uint8Array, orientation: PictureOrientation): return texture } -/** What of a renderer this reads — narrower than `WebGLRenderer`, which jsdom cannot build. */ -type AnisotropyHolder = { capabilities: { getMaxAnisotropy: () => number } } +/** + * What of a renderer this reads — narrower than either renderer class, which jsdom cannot build. + * The two engines put the same answer in two places: under `capabilities` on the Compatible one, + * on the renderer itself on the Advanced one. + */ +type AnisotropyHolder = + { capabilities: { getMaxAnisotropy: () => number } } | { getMaxAnisotropy: () => number } /** * What a card allows, or `1` before there is a card to ask. Read through here by the three - * engines that build a cache, so none of them has to reach into `capabilities` itself. + * engines that build a cache, so none of them has to know where its renderer keeps it. */ export function maxAnisotropyOf(renderer: AnisotropyHolder | null | undefined): number { + if (!renderer) return 1 + const asked = + 'capabilities' in renderer + ? renderer.capabilities.getMaxAnisotropy() + : renderer.getMaxAnisotropy() // Never under one: three answers 0 — not 1 — on a context without // `EXT_texture_filter_anisotropic`, and 0 is not a number of samples. - return Math.max(1, renderer?.capabilities.getMaxAnisotropy() ?? 1) + return Math.max(1, asked) } export type TextureCache = { diff --git a/src/renderer/src/engines/skybox/SkyboxRenderer01.test.ts b/src/renderer/src/engines/skybox/SkyboxRenderer01.test.ts index 47303b59a..013a68c83 100644 --- a/src/renderer/src/engines/skybox/SkyboxRenderer01.test.ts +++ b/src/renderer/src/engines/skybox/SkyboxRenderer01.test.ts @@ -7,7 +7,7 @@ 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, fakeRenderer, fakeTextureSource } from '../viewport/viewport-fixtures' @@ -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() diff --git a/src/renderer/src/engines/skybox/SkyboxRenderer02.test.ts b/src/renderer/src/engines/skybox/SkyboxRenderer02.test.ts index 660f76b81..ad2697491 100644 --- a/src/renderer/src/engines/skybox/SkyboxRenderer02.test.ts +++ b/src/renderer/src/engines/skybox/SkyboxRenderer02.test.ts @@ -7,7 +7,7 @@ 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, fakeRenderer, fakeTextureSource } from '../viewport/viewport-fixtures' @@ -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() diff --git a/src/renderer/src/engines/skybox/SkyboxRenderer03.test.ts b/src/renderer/src/engines/skybox/SkyboxRenderer03.test.ts index d8853211b..ba57b9d45 100644 --- a/src/renderer/src/engines/skybox/SkyboxRenderer03.test.ts +++ b/src/renderer/src/engines/skybox/SkyboxRenderer03.test.ts @@ -7,7 +7,7 @@ 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, fakeRenderer, fakeTextureSource } from '../viewport/viewport-fixtures' @@ -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() diff --git a/src/renderer/src/engines/skybox/SkyboxRenderer04.test.ts b/src/renderer/src/engines/skybox/SkyboxRenderer04.test.ts index 656ed7181..38d835c3d 100644 --- a/src/renderer/src/engines/skybox/SkyboxRenderer04.test.ts +++ b/src/renderer/src/engines/skybox/SkyboxRenderer04.test.ts @@ -6,7 +6,7 @@ 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, fakeRenderer, fakeTextureSource } from '../viewport/viewport-fixtures' @@ -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() 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..6297bf08f 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,7 +143,7 @@ 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 diff --git a/src/renderer/src/engines/viewport/ViewportInset.ts b/src/renderer/src/engines/viewport/ViewportInset.ts index 548725491..2b1242350 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 { maxSamplesOf, 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,14 +30,8 @@ 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), - ) - const target = new WebGLRenderTarget(width, height, { samples }) + // What the DRAWING BUFFER is antialiased to, held to what the engine can offer. + const target = new WebGLRenderTarget(width, height, { samples: maxSamplesOf(renderer) }) // 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 // its output pass). Declared rather than left at the default so the quad below does not @@ -61,7 +50,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 +108,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 +133,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 beff35f11..813047d3d 100644 --- a/src/renderer/src/engines/viewport/ViewportState.ts +++ b/src/renderer/src/engines/viewport/ViewportState.ts @@ -1,12 +1,5 @@ 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' @@ -14,7 +7,7 @@ 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 } from '../render/renderDriver' +import { type RenderDriver, type StudioRenderer } from '../render/renderDriver' import { ViewportNavigationTarget } from './ViewportNavigationTarget' import { ORIGIN, @@ -56,7 +49,16 @@ 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 diff --git a/src/renderer/src/engines/viewport/ViewportSurface.ts b/src/renderer/src/engines/viewport/ViewportSurface.ts index 557414d19..590ff5c0f 100644 --- a/src/renderer/src/engines/viewport/ViewportSurface.ts +++ b/src/renderer/src/engines/viewport/ViewportSurface.ts @@ -1,12 +1,12 @@ -import { ACESFilmicToneMapping, Color, NoToneMapping, type 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 { askedGpuAdapter, probeGpuAdapter } from '../render/gpuAdapter' import { mountRenderer } from '../render/mountRenderer' -import { type RenderDriver } from '../render/renderDriver' +import { type RenderDriver, type StudioRenderer } from '../render/renderDriver' import { createGpuTimer, isGpuTimerContext } from './gpuTimer' import { ViewportMounting } from './ViewportMounting' @@ -32,8 +32,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 = gpuTimerFor(renderer) + this.holdFramesUntilReady(renderer) this.mountControls(canvas) this.mountNavigation(host) this.observeCanvas(canvas) @@ -56,14 +56,16 @@ export abstract class ViewportSurface extends ViewportMounting { * 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): WebGLRenderer { + private rendererFor(canvas: HTMLCanvasElement): StudioRenderer { const wanted = this.options.engine?.() ?? 'gl' - if (wanted === 'gpu' && askedGpuAdapter() === null) void probeGpuAdapter() + // 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' && !loadedGpuModule()) void loadGpuModule() const mounted = mountRenderer( { canvas, alpha: this.output.alpha ?? false }, wanted, - askedGpuAdapter(), + loadedGpuModule() !== null, error => traceFailure('render.fallback', wanted, error), ) const { renderer, driver } = mounted @@ -89,6 +91,45 @@ 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(settling) + } + + /** Whether the renderer may be drawn with at all — false while a node backend comes up. */ + get canDraw(): boolean { + return this.rendererReady + } + + /** Resolves once this viewport may draw. Already settled where the engine needs no backend. */ + settled(): Promise { + return this.rendererSettling ?? Promise.resolve() + } + + private async settleRenderer(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. + traceFailure('render.fallback', 'gpu', error) + 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) @@ -165,8 +206,11 @@ export abstract class ViewportSurface extends ViewportMounting { this.disposeInset() const canvas = this.renderer?.domElement - this.renderer?.forceContextLoss() - this.renderer?.dispose() + // The Compatible engine alone can give its context back before it is collected; a node + // renderer holds a device the browser reclaims with the page. + const renderer = this.renderer + if (renderer && 'forceContextLoss' in renderer) renderer.forceContextLoss() + renderer?.dispose() this.renderer = null this.gpuTimer = null @@ -180,12 +224,12 @@ 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 four calls that differ between the two engines. Read rather than + * 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. */ @@ -241,3 +285,16 @@ export abstract class ViewportSurface extends ViewportMounting { this.requestRender() } } + +/** + * The frame timer, where the engine has one. + * + * 🛑 `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 — which is a beat after the mount that + * would ask. A frame drawn on the Advanced engine is therefore untimed for now. + */ +function gpuTimerFor(renderer: StudioRenderer): ReturnType | null { + if (!('capabilities' in renderer)) return null + const context = renderer.getContext() + return isGpuTimerContext(context) ? createGpuTimer(context) : null +} 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..3f7b81e1c 100644 --- a/src/renderer/src/engines/viewport/environment.ts +++ b/src/renderer/src/engines/viewport/environment.ts @@ -1,14 +1,31 @@ -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 } + +/** + * 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 +107,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 +140,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 +155,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 +172,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 +272,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/viewportEngineSupport1.ts b/src/renderer/src/engines/viewport/viewportEngineSupport1.ts index b3a7d25ee..fbabc54c6 100644 --- a/src/renderer/src/engines/viewport/viewportEngineSupport1.ts +++ b/src/renderer/src/engines/viewport/viewportEngineSupport1.ts @@ -6,9 +6,9 @@ import { type PerspectiveCamera, type Scene, Vector3, - type WebGLRenderer, type WebGLRenderTarget, } from 'three' +import type { StudioRenderer } from '../render/renderDriver' import { type OrbitControls } from 'three/addons/controls/OrbitControls.js' import { type GpuPipeline } from '../gpu/gpuPipeline' import { type PinchReading } from './pinch' @@ -41,7 +41,7 @@ export type ViewportEngineOptions = { */ onFrame?: (delta: number) => boolean /** Drawn after the scene with `autoClear` off — trihedrons and other screen-space overlays. */ - onOverlay?: (renderer: WebGLRenderer) => void + onOverlay?: (renderer: StudioRenderer) => void /** * Called just before each pane is drawn, so whoever owns the scene can say how THIS view shows * it, and answering whether that changed what the scene wears — which is what tells the frame diff --git a/src/shared/domain/postProcessing.test.ts b/src/shared/domain/postProcessing.test.ts index 34e5a77db..dc6aade39 100644 --- a/src/shared/domain/postProcessing.test.ts +++ b/src/shared/domain/postProcessing.test.ts @@ -52,13 +52,14 @@ describe('the catalogue', () => { expect(wrong).toEqual([]) }) - it('names an engine for every effect, and only the Compatible one so far', () => { - // The Advanced engine builds none of them yet. A `gpu` appearing here without a factory - // behind it is a slot the chain would leave empty with nothing said. + // 🛑 A `gpu` written here without a node factory behind it is a slot the Advanced chain + // leaves empty with nothing said. The occlusion is the only one that has one. + 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(engines.flat().filter(engine => engine !== 'gl')).toEqual([]) + expect(advanced).toEqual(['gtao']) }) it('gives a fresh instance the defaults of its own effect', () => { diff --git a/src/shared/domain/postProcessingRegistry.ts b/src/shared/domain/postProcessingRegistry.ts index b25e056db..9e8a09139 100644 --- a/src/shared/domain/postProcessingRegistry.ts +++ b/src/shared/domain/postProcessingRegistry.ts @@ -6,7 +6,7 @@ * those may pull three.js in, so nothing here knows a `Pass` exists — `engines/postfx/` is the * one folder that does. */ -import { GL_ONLY, type RenderEngine } from './renderEngine' +import { BOTH_ENGINES, GL_ONLY, type RenderEngine } from './renderEngine' import { BLUR_KINDS, HALFTONE_SHAPES, @@ -124,7 +124,9 @@ export const POST_EFFECTS: Record = { category: 'lighting', cost: 'high', slot: 'ao', - engines: GL_ONLY, + // The one effect both engines build: GLSL `GTAOPass` on the Compatible side, the native + // `ao()` node on the Advanced one. Same slot, same exclusivity, same parameters. + engines: BOTH_ENGINES, duplicable: false, params: { radius: slider(0.01, 2, 0.01, 0.25), diff --git a/src/shared/domain/renderEngine.ts b/src/shared/domain/renderEngine.ts index 3463d0c95..d0890951c 100644 --- a/src/shared/domain/renderEngine.ts +++ b/src/shared/domain/renderEngine.ts @@ -11,3 +11,6 @@ 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 an effect both engines can build says. One so far: the ground-truth occlusion. */ +export const BOTH_ENGINES: readonly RenderEngine[] = ['gl', 'gpu'] diff --git a/src/shared/i18n/ar/diagnostics.json b/src/shared/i18n/ar/diagnostics.json index 0a2a1f50b..63a77b657 100644 --- a/src/shared/i18n/ar/diagnostics.json +++ b/src/shared/i18n/ar/diagnostics.json @@ -17,6 +17,7 @@ "csgGraphMissing": "لا يوجد مخطط CSG مسجل للعنصر {{name}}", "shaderAnchorMissing": "لم يعد محرّك الرسم يوفر {{name}}", "renderEngineUnavailable": "محرّك «متقدّم» لم يُبنَ بعد", + "renderEngineGradingMissing": "محرّك «متقدّم» يعرض سماءً مصحَّحة كما يحتويها ملفها", "channelShaderMissing": "لا يوجد مظلّل يشتق القناة {{channel}}", "channelSourceEmpty": "مصدر القناة {{channel}} لا يحتوي على أي بكسل", "passSourceMissing": "تحتاج مرحلة الرسم إلى مصدر تقرأ منه", diff --git a/src/shared/i18n/ar/settings.json b/src/shared/i18n/ar/settings.json index f5bd9308e..399131b4a 100644 --- a/src/shared/i18n/ar/settings.json +++ b/src/shared/i18n/ar/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "محرّك العرض", - "help": "بأي واجهة رسومية ترسم العرض ثلاثي الأبعاد. «متوافق» يعمل على كل التهيئات، وهو ما يُبقى عليه للمشاركة الواسعة أو للأجهزة الأقدم. «متقدّم» يعطي إضاءة وانعكاسات أفضل ويطلب بطاقة رسومات حديثة. اللوحة المفتوحة أصلًا تحتفظ بمحرّكها: أغلقها ثم افتحها من جديد لتغييره. محرّك «متقدّم» لم يُبنَ بعد في هذه النسخة: اختياره يرسم بمحرّك «متوافق».", + "help": "بأي واجهة رسومية ترسم العرض ثلاثي الأبعاد. «متوافق» يعمل على كل التهيئات، وهو ما يُبقى عليه للمشاركة الواسعة أو للأجهزة الأقدم. «متقدّم» يعطي إضاءة وانعكاسات أفضل ويطلب بطاقة رسومات حديثة. اللوحة المفتوحة أصلًا تحتفظ بمحرّكها: أغلقها ثم افتحها من جديد لتغييره. الجهاز الذي لا يملك مهايئ WebGPU يعود وحده إلى محرّك «متوافق»، ويسجّل ذلك في السجل.", "gl": "متوافق", "gpu": "متقدّم" }, diff --git a/src/shared/i18n/de/diagnostics.json b/src/shared/i18n/de/diagnostics.json index c85a44cd6..66c653843 100644 --- a/src/shared/i18n/de/diagnostics.json +++ b/src/shared/i18n/de/diagnostics.json @@ -17,6 +17,7 @@ "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", "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/settings.json b/src/shared/i18n/de/settings.json index 1150ffbd6..2f97b7172 100644 --- a/src/shared/i18n/de/settings.json +++ b/src/shared/i18n/de/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Render-Engine", - "help": "Mit welcher Grafikschnittstelle die 3D-Ansicht zeichnet. Kompatibel läuft auf jeder Konfiguration und ist die Wahl für breites Teilen oder ältere Rechner. Erweitert liefert bessere Beleuchtung und Spiegelungen und verlangt eine aktuelle Grafikkarte. Ein bereits offenes Panel behält seine Engine: schließen und wieder öffnen, um zu wechseln. Die erweiterte Engine ist in dieser Version noch nicht gebaut: Wer sie wählt, zeichnet mit der kompatiblen.", + "help": "Mit welcher Grafikschnittstelle die 3D-Ansicht zeichnet. Kompatibel läuft auf jeder Konfiguration und ist die Wahl für breites Teilen oder ältere Rechner. Erweitert liefert bessere Beleuchtung und Spiegelungen und verlangt eine aktuelle Grafikkarte. Ein bereits offenes Panel behält seine Engine: schließen und wieder öffnen, um zu wechseln. Ein Rechner ohne WebGPU-Adapter fällt von selbst auf die kompatible Engine zurück und schreibt es ins Journal.", "gl": "Kompatibel", "gpu": "Erweitert" }, diff --git a/src/shared/i18n/en/diagnostics.json b/src/shared/i18n/en/diagnostics.json index 5989f7ed0..f13ec9029 100644 --- a/src/shared/i18n/en/diagnostics.json +++ b/src/shared/i18n/en/diagnostics.json @@ -17,6 +17,7 @@ "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", "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/settings.json b/src/shared/i18n/en/settings.json index 8daebcb7d..a1315af19 100644 --- a/src/shared/i18n/en/settings.json +++ b/src/shared/i18n/en/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Render engine", - "help": "Which graphics interface the 3D viewport draws with. Compatible works on every configuration and is the one to keep for sharing widely or for older machines. Advanced gives better lighting and reflections and asks for a recent graphics card. A panel already open keeps the engine it opened with: close and reopen it to change. The Advanced engine is not built yet in this version: picking it draws with the Compatible one.", + "help": "Which graphics interface the 3D viewport draws with. Compatible works on every configuration and is the one to keep for sharing widely or for older machines. Advanced gives better lighting and reflections and asks for a recent graphics card. A panel already open keeps the engine it opened with: close and reopen it to change. A machine with no WebGPU adapter falls back to the Compatible engine on its own, and says so in the journal.", "gl": "Compatible", "gpu": "Advanced" }, diff --git a/src/shared/i18n/es/diagnostics.json b/src/shared/i18n/es/diagnostics.json index 9332d1b9e..6fa08c958 100644 --- a/src/shared/i18n/es/diagnostics.json +++ b/src/shared/i18n/es/diagnostics.json @@ -17,6 +17,7 @@ "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", "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/settings.json b/src/shared/i18n/es/settings.json index e3806ef31..215ad2553 100644 --- a/src/shared/i18n/es/settings.json +++ b/src/shared/i18n/es/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Motor de renderizado", - "help": "Con qué interfaz gráfica dibuja la vista 3D. Compatible funciona en cualquier configuración y es la opción para compartir ampliamente o para máquinas más antiguas. Avanzado da mejor iluminación y reflejos y pide una tarjeta gráfica reciente. Un panel ya abierto conserva su motor: ciérrelo y vuelva a abrirlo para cambiarlo. El motor Avanzado aún no está construido en esta versión: elegirlo dibuja con el Compatible.", + "help": "Con qué interfaz gráfica dibuja la vista 3D. Compatible funciona en cualquier configuración y es la opción para compartir ampliamente o para máquinas más antiguas. Avanzado da mejor iluminación y reflejos y pide una tarjeta gráfica reciente. Un panel ya abierto conserva su motor: ciérrelo y vuelva a abrirlo para cambiarlo. Una máquina sin adaptador WebGPU vuelve sola al motor Compatible, y lo indica en el diario.", "gl": "Compatible", "gpu": "Avanzado" }, diff --git a/src/shared/i18n/fr/diagnostics.json b/src/shared/i18n/fr/diagnostics.json index d5a3a4378..64f4f1e21 100644 --- a/src/shared/i18n/fr/diagnostics.json +++ b/src/shared/i18n/fr/diagnostics.json @@ -17,6 +17,7 @@ "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", "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/settings.json b/src/shared/i18n/fr/settings.json index 2ad2f74f3..91c1608a5 100644 --- a/src/shared/i18n/fr/settings.json +++ b/src/shared/i18n/fr/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Moteur de rendu", - "help": "Avec quelle interface graphique la vue 3D dessine. Compatible fonctionne sur toutes les configurations : c’est celui à garder pour partager largement ou pour les machines plus anciennes. Avancé donne un éclairage et des reflets de meilleure qualité et demande une carte graphique récente. Un panneau déjà ouvert conserve le moteur avec lequel il s’est ouvert : refermez-le et rouvrez-le pour en changer. Le moteur Avancé n’est pas encore construit dans cette version : le choisir dessine avec le Compatible.", + "help": "Avec quelle interface graphique la vue 3D dessine. Compatible fonctionne sur toutes les configurations : c’est celui à garder pour partager largement ou pour les machines plus anciennes. Avancé donne un éclairage et des reflets de meilleure qualité et demande une carte graphique récente. Un panneau déjà ouvert conserve le moteur avec lequel il s’est ouvert : refermez-le et rouvrez-le pour en changer. Une machine sans adaptateur WebGPU retombe d’elle-même sur le moteur Compatible, et le dit dans le journal.", "gl": "Compatible", "gpu": "Avancé" }, diff --git a/src/shared/i18n/hi/diagnostics.json b/src/shared/i18n/hi/diagnostics.json index b9d563d0c..1118abdd2 100644 --- a/src/shared/i18n/hi/diagnostics.json +++ b/src/shared/i18n/hi/diagnostics.json @@ -17,6 +17,7 @@ "csgGraphMissing": "{{name}} के लिए कोई CSG ग्राफ़ दर्ज नहीं है", "shaderAnchorMissing": "रेंडरर अब {{name}} नहीं देता", "renderEngineUnavailable": "उन्नत इंजन अभी बना नहीं है", + "renderEngineGradingMissing": "उन्नत इंजन सुधारे हुए आकाश को उसकी फ़ाइल में जैसा है वैसा ही दिखाता है", "channelShaderMissing": "कोई शेडर {{channel}} चैनल नहीं निकालता", "channelSourceEmpty": "{{channel}} चैनल के स्रोत में कोई पिक्सेल नहीं है", "passSourceMissing": "इस पास को पढ़ने के लिए एक स्रोत चाहिए", diff --git a/src/shared/i18n/hi/settings.json b/src/shared/i18n/hi/settings.json index 8ca76a847..42cbe186a 100644 --- a/src/shared/i18n/hi/settings.json +++ b/src/shared/i18n/hi/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "रेंडर इंजन", - "help": "3D दृश्य किस ग्राफ़िक्स इंटरफ़ेस से बनता है। संगत हर कॉन्फ़िगरेशन पर चलता है और व्यापक साझेदारी या पुरानी मशीनों के लिए यही रखना चाहिए। उन्नत बेहतर रोशनी और परावर्तन देता है और नया ग्राफ़िक्स कार्ड माँगता है। पहले से खुला पैनल अपना इंजन बनाए रखता है: बदलने के लिए उसे बंद करके फिर खोलें। उन्नत इंजन इस संस्करण में अभी बना नहीं है: उसे चुनने पर भी संगत इंजन से ही बनता है।", + "help": "3D दृश्य किस ग्राफ़िक्स इंटरफ़ेस से बनता है। संगत हर कॉन्फ़िगरेशन पर चलता है और व्यापक साझेदारी या पुरानी मशीनों के लिए यही रखना चाहिए। उन्नत बेहतर रोशनी और परावर्तन देता है और नया ग्राफ़िक्स कार्ड माँगता है। पहले से खुला पैनल अपना इंजन बनाए रखता है: बदलने के लिए उसे बंद करके फिर खोलें। उन्नत इंजन इस संस्करण में जिस मशीन में WebGPU अडैप्टर नहीं है वह खुद ही संगत इंजन पर लौट आती है, और यह बात लॉग में लिख देती है।", "gl": "संगत", "gpu": "उन्नत" }, diff --git a/src/shared/i18n/id/diagnostics.json b/src/shared/i18n/id/diagnostics.json index ec56b76b6..86f3e1b49 100644 --- a/src/shared/i18n/id/diagnostics.json +++ b/src/shared/i18n/id/diagnostics.json @@ -17,6 +17,7 @@ "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", "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/settings.json b/src/shared/i18n/id/settings.json index a9c99b8df..c362d6449 100644 --- a/src/shared/i18n/id/settings.json +++ b/src/shared/i18n/id/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Mesin render", - "help": "Antarmuka grafis mana yang dipakai tampilan 3D untuk menggambar. Kompatibel berjalan di setiap konfigurasi dan itulah yang dipertahankan untuk berbagi luas atau mesin lama. Lanjutan memberi pencahayaan dan pantulan yang lebih baik dan meminta kartu grafis terkini. Panel yang sudah terbuka mempertahankan mesinnya: tutup dan buka lagi untuk berganti. Mesin Lanjutan belum dibangun di versi ini: memilihnya tetap menggambar dengan yang Kompatibel.", + "help": "Antarmuka grafis mana yang dipakai tampilan 3D untuk menggambar. Kompatibel berjalan di setiap konfigurasi dan itulah yang dipertahankan untuk berbagi luas atau mesin lama. Lanjutan memberi pencahayaan dan pantulan yang lebih baik dan meminta kartu grafis terkini. Panel yang sudah terbuka mempertahankan mesinnya: tutup dan buka lagi untuk berganti. Mesin tanpa adaptor WebGPU kembali sendiri ke mesin Kompatibel, dan mencatatnya di jurnal.", "gl": "Kompatibel", "gpu": "Lanjutan" }, diff --git a/src/shared/i18n/it/diagnostics.json b/src/shared/i18n/it/diagnostics.json index 17b6dc97a..caeb116c8 100644 --- a/src/shared/i18n/it/diagnostics.json +++ b/src/shared/i18n/it/diagnostics.json @@ -17,6 +17,7 @@ "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", "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/settings.json b/src/shared/i18n/it/settings.json index 5b79b6ee9..36bb66c32 100644 --- a/src/shared/i18n/it/settings.json +++ b/src/shared/i18n/it/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Motore di rendering", - "help": "Con quale interfaccia grafica disegna la vista 3D. Compatibile funziona su ogni configurazione ed è quello da tenere per condividere ampiamente o per macchine più vecchie. Avanzato dà illuminazione e riflessi migliori e chiede una scheda grafica recente. Un pannello già aperto conserva il suo motore: chiuderlo e riaprirlo per cambiarlo. Il motore Avanzato non è ancora costruito in questa versione: sceglierlo disegna con il Compatibile.", + "help": "Con quale interfaccia grafica disegna la vista 3D. Compatibile funziona su ogni configurazione ed è quello da tenere per condividere ampiamente o per macchine più vecchie. Avanzato dà illuminazione e riflessi migliori e chiede una scheda grafica recente. Un pannello già aperto conserva il suo motore: chiuderlo e riaprirlo per cambiarlo. Una macchina senza adattatore WebGPU torna da sola al motore Compatibile, e lo scrive nel giornale.", "gl": "Compatibile", "gpu": "Avanzato" }, diff --git a/src/shared/i18n/ja/diagnostics.json b/src/shared/i18n/ja/diagnostics.json index 3fde729c4..f1e81acba 100644 --- a/src/shared/i18n/ja/diagnostics.json +++ b/src/shared/i18n/ja/diagnostics.json @@ -17,6 +17,7 @@ "csgGraphMissing": "{{name}}のCSGグラフが記録されていません", "shaderAnchorMissing": "レンダラーは{{name}}を提供しなくなりました", "renderEngineUnavailable": "「上級」エンジンはまだ実装されていません", + "renderEngineGradingMissing": "「上級」エンジンは、補正した空をファイルのままの状態で表示します", "channelShaderMissing": "{{channel}}チャンネルを導き出せるシェーダーがありません", "channelSourceEmpty": "{{channel}}チャンネルのソースにピクセルがありません", "passSourceMissing": "パスには読み取るソースが必要です", diff --git a/src/shared/i18n/ja/settings.json b/src/shared/i18n/ja/settings.json index 80343ff69..8bc18441f 100644 --- a/src/shared/i18n/ja/settings.json +++ b/src/shared/i18n/ja/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "レンダーエンジン", - "help": "3D ビューがどのグラフィックス インターフェースで描くかです。「互換」はあらゆる構成で動き、広く共有する場合や古いマシンではこちらを保ちます。「上級」は照明と反射の質が上がり、新しめのグラフィックスカードを求めます。すでに開いているパネルは開いたときのエンジンのままです。切り替えるには閉じて開き直してください。「上級」エンジンはこのバージョンではまだ実装されていません。選んでも「互換」で描かれます。", + "help": "3D ビューがどのグラフィックス インターフェースで描くかです。「互換」はあらゆる構成で動き、広く共有する場合や古いマシンではこちらを保ちます。「上級」は照明と反射の質が上がり、新しめのグラフィックスカードを求めます。すでに開いているパネルは開いたときのエンジンのままです。切り替えるには閉じて開き直してください。WebGPU アダプターのないマシンは自動的に「互換」エンジンに戻り、その旨を記録に残します。", "gl": "互換", "gpu": "上級" }, diff --git a/src/shared/i18n/ko/diagnostics.json b/src/shared/i18n/ko/diagnostics.json index c8c344059..e72a9c2ca 100644 --- a/src/shared/i18n/ko/diagnostics.json +++ b/src/shared/i18n/ko/diagnostics.json @@ -17,6 +17,7 @@ "csgGraphMissing": "{{name}}에 대해 기록된 CSG 그래프가 없습니다", "shaderAnchorMissing": "렌더러가 {{name}} 앵커를 더 이상 제공하지 않습니다", "renderEngineUnavailable": "고급 엔진은 아직 만들어지지 않았습니다", + "renderEngineGradingMissing": "고급 엔진은 보정한 하늘을 파일에 담긴 그대로 보여 줍니다", "channelShaderMissing": "{{channel}} 채널을 이끌어 내는 셰이더가 없습니다", "channelSourceEmpty": "{{channel}} 채널의 소스에 픽셀이 없습니다", "passSourceMissing": "패스에는 읽을 소스가 필요합니다", diff --git a/src/shared/i18n/ko/settings.json b/src/shared/i18n/ko/settings.json index df00818bc..dcaf1b426 100644 --- a/src/shared/i18n/ko/settings.json +++ b/src/shared/i18n/ko/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "렌더 엔진", - "help": "3D 뷰가 어떤 그래픽 인터페이스로 그릴지 정합니다. 호환은 모든 구성에서 동작하며, 널리 공유하거나 오래된 기기에서는 이쪽을 유지합니다. 고급은 조명과 반사의 품질이 높아지고 최신 그래픽 카드를 요구합니다. 이미 열린 패널은 열릴 때의 엔진을 그대로 씁니다. 바꾸려면 닫았다가 다시 여세요. 고급 엔진은 이 버전에서 아직 만들어지지 않았습니다. 선택해도 호환 엔진으로 그립니다.", + "help": "3D 뷰가 어떤 그래픽 인터페이스로 그릴지 정합니다. 호환은 모든 구성에서 동작하며, 널리 공유하거나 오래된 기기에서는 이쪽을 유지합니다. 고급은 조명과 반사의 품질이 높아지고 최신 그래픽 카드를 요구합니다. 이미 열린 패널은 열릴 때의 엔진을 그대로 씁니다. 바꾸려면 닫았다가 다시 여세요. WebGPU 어댑터가 없는 기기는 스스로 호환 엔진으로 돌아가며, 그 사실을 기록에 남깁니다.", "gl": "호환", "gpu": "고급" }, diff --git a/src/shared/i18n/pt/diagnostics.json b/src/shared/i18n/pt/diagnostics.json index bebab1235..70ec045b4 100644 --- a/src/shared/i18n/pt/diagnostics.json +++ b/src/shared/i18n/pt/diagnostics.json @@ -17,6 +17,7 @@ "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", "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/settings.json b/src/shared/i18n/pt/settings.json index 46ca47e46..420524f97 100644 --- a/src/shared/i18n/pt/settings.json +++ b/src/shared/i18n/pt/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Motor de renderização", - "help": "Com que interface gráfica a vista 3D desenha. Compatível funciona em qualquer configuração e é o que se guarda para partilhar amplamente ou para máquinas mais antigas. Avançado dá melhor iluminação e reflexos e pede uma placa gráfica recente. Um painel já aberto conserva o seu motor: feche-o e volte a abri-lo para mudar. O motor Avançado ainda não está construído nesta versão: escolhê-lo desenha com o Compatível.", + "help": "Com que interface gráfica a vista 3D desenha. Compatível funciona em qualquer configuração e é o que se guarda para partilhar amplamente ou para máquinas mais antigas. Avançado dá melhor iluminação e reflexos e pede uma placa gráfica recente. Um painel já aberto conserva o seu motor: feche-o e volte a abri-lo para mudar. Uma máquina sem adaptador WebGPU volta sozinha ao motor Compatível, e di-lo no diário.", "gl": "Compatível", "gpu": "Avançado" }, diff --git a/src/shared/i18n/ru/diagnostics.json b/src/shared/i18n/ru/diagnostics.json index a7e2a6634..d6c8b4de9 100644 --- a/src/shared/i18n/ru/diagnostics.json +++ b/src/shared/i18n/ru/diagnostics.json @@ -17,6 +17,7 @@ "csgGraphMissing": "для {{name}} не записан граф CSG", "shaderAnchorMissing": "Движок рендеринга больше не предоставляет {{name}}", "renderEngineUnavailable": "Продвинутый движок ещё не собран", + "renderEngineGradingMissing": "Продвинутый движок показывает откорректированное небо таким, каким оно лежит в файле", "channelShaderMissing": "ни один шейдер не выводит {{channel}}", "channelSourceEmpty": "в источнике канала {{channel}} нет пикселей", "passSourceMissing": "проходу нужен источник для чтения", diff --git a/src/shared/i18n/ru/settings.json b/src/shared/i18n/ru/settings.json index cdfd69a77..0e4c95916 100644 --- a/src/shared/i18n/ru/settings.json +++ b/src/shared/i18n/ru/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Движок отрисовки", - "help": "С каким графическим интерфейсом рисует 3D-вид. «Совместимый» работает на любой конфигурации — его и стоит держать для широкого обмена и старых машин. «Продвинутый» даёт лучше свет и отражения и требует свежей видеокарты. Уже открытая панель сохраняет свой движок: закройте и откройте её заново, чтобы сменить. Продвинутый движок в этой версии ещё не собран: выбрать его — значит рисовать Совместимым.", + "help": "С каким графическим интерфейсом рисует 3D-вид. «Совместимый» работает на любой конфигурации — его и стоит держать для широкого обмена и старых машин. «Продвинутый» даёт лучше свет и отражения и требует свежей видеокарты. Уже открытая панель сохраняет свой движок: закройте и откройте её заново, чтобы сменить. Машина без адаптера WebGPU сама возвращается к Совместимому движку и пишет об этом в журнал.", "gl": "Совместимый", "gpu": "Продвинутый" }, diff --git a/src/shared/i18n/tr/diagnostics.json b/src/shared/i18n/tr/diagnostics.json index 40ed83706..1825bfd6f 100644 --- a/src/shared/i18n/tr/diagnostics.json +++ b/src/shared/i18n/tr/diagnostics.json @@ -17,6 +17,7 @@ "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", "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/settings.json b/src/shared/i18n/tr/settings.json index 59e533f39..4660dff24 100644 --- a/src/shared/i18n/tr/settings.json +++ b/src/shared/i18n/tr/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Render motoru", - "help": "3B görünümün hangi grafik arabirimiyle çizdiği. Uyumlu her yapılandırmada çalışır; geniş paylaşım ve eski makineler için tutulacak olan budur. Gelişmiş daha iyi aydınlatma ve yansıma verir ve yeni bir ekran kartı ister. Zaten açık bir panel motorunu korur: değiştirmek için kapatıp yeniden açın. Gelişmiş motor bu sürümde henüz hazır değil: onu seçmek Uyumlu olanla çizer.", + "help": "3B görünümün hangi grafik arabirimiyle çizdiği. Uyumlu her yapılandırmada çalışır; geniş paylaşım ve eski makineler için tutulacak olan budur. Gelişmiş daha iyi aydınlatma ve yansıma verir ve yeni bir ekran kartı ister. Zaten açık bir panel motorunu korur: değiştirmek için kapatıp yeniden açın. WebGPU bağdaştırıcısı olmayan bir makine kendiliğinden Uyumlu motora döner ve bunu günlüğe yazar.", "gl": "Uyumlu", "gpu": "Gelişmiş" }, diff --git a/src/shared/i18n/vi/diagnostics.json b/src/shared/i18n/vi/diagnostics.json index 51e0677d8..d93c6c352 100644 --- a/src/shared/i18n/vi/diagnostics.json +++ b/src/shared/i18n/vi/diagnostics.json @@ -17,6 +17,7 @@ "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", "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/settings.json b/src/shared/i18n/vi/settings.json index f4d82d58f..1a394a507 100644 --- a/src/shared/i18n/vi/settings.json +++ b/src/shared/i18n/vi/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Bộ máy dựng hình", - "help": "Giao diện đồ họa nào vẽ khung nhìn 3D. Tương thích chạy trên mọi cấu hình và là lựa chọn để chia sẻ rộng hay cho máy cũ. Nâng cao cho ánh sáng và phản chiếu tốt hơn, và đòi một card đồ họa đời mới. Bảng đã mở vẫn giữ bộ máy của nó: đóng rồi mở lại để đổi. Bộ máy Nâng cao chưa được dựng trong phiên bản này: chọn nó vẫn vẽ bằng bộ Tương thích.", + "help": "Giao diện đồ họa nào vẽ khung nhìn 3D. Tương thích chạy trên mọi cấu hình và là lựa chọn để chia sẻ rộng hay cho máy cũ. Nâng cao cho ánh sáng và phản chiếu tốt hơn, và đòi một card đồ họa đời mới. Bảng đã mở vẫn giữ bộ máy của nó: đóng rồi mở lại để đổi. Máy không có bộ điều hợp WebGPU tự quay về bộ Tương thích, và ghi điều đó vào nhật ký.", "gl": "Tương thích", "gpu": "Nâng cao" }, diff --git a/src/shared/i18n/zh/diagnostics.json b/src/shared/i18n/zh/diagnostics.json index 059c33ecc..a9f05f0d0 100644 --- a/src/shared/i18n/zh/diagnostics.json +++ b/src/shared/i18n/zh/diagnostics.json @@ -17,6 +17,7 @@ "csgGraphMissing": "没有为 {{name}} 记录 CSG 图", "shaderAnchorMissing": "渲染器不再提供 {{name}}", "renderEngineUnavailable": "高级引擎尚未构建", + "renderEngineGradingMissing": "高级引擎按文件里的原样显示已校正的天空", "channelShaderMissing": "没有着色器能推导出 {{channel}} 通道", "channelSourceEmpty": "{{channel}} 通道的来源不含任何像素", "passSourceMissing": "这一道处理需要一个可读取的来源", diff --git a/src/shared/i18n/zh/settings.json b/src/shared/i18n/zh/settings.json index 35855acb8..a6146ccad 100644 --- a/src/shared/i18n/zh/settings.json +++ b/src/shared/i18n/zh/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "渲染引擎", - "help": "3D 视图用哪一套图形接口绘制。「兼容」在所有配置上都能运行,要广泛分享或使用较旧的机器就保留它。「高级」的光照与反射更好,需要较新的显卡。已经打开的面板会保留打开时的引擎:关掉再打开才会更换。「高级」引擎在这个版本里尚未构建:选它仍会用「兼容」来绘制。", + "help": "3D 视图用哪一套图形接口绘制。「兼容」在所有配置上都能运行,要广泛分享或使用较旧的机器就保留它。「高级」的光照与反射更好,需要较新的显卡。已经打开的面板会保留打开时的引擎:关掉再打开才会更换。没有 WebGPU 适配器的机器会自行退回「兼容」引擎,并在日志里说明。", "gl": "兼容", "gpu": "高级" }, From 851d3d97fa45b647f5f3281952c17ff6e052806a Mon Sep 17 00:00:00 2001 From: Alban Pasquelin Date: Thu, 10 Sep 2026 22:59:39 +0200 Subject: [PATCH 06/13] =?UTF-8?q?Applique=20les=20deux=20revues=20de=20l'?= =?UTF-8?q?=C3=A9tape=203=20:=20la=20capture=20GPU=20sortait=20cisaill?= =?UTF-8?q?=C3=A9e=20et=20=C3=A0=20l'envers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revue de correction (neuf défauts, tous dans le chemin GPU) et revue de simplification. Du plus grave au moins : - **La lecture de pixels rendait des lignes paddées et à l'endroit.** WebGPU copie une texture vers un buffer en alignant chaque ligne sur 256 octets, et les rend de haut en bas ; le Compatible les rend serrées et de bas en haut, et l'encodeur de film retourne sans condition. Toute capture Avancé sortait donc retournée, et cisaillée dès que la largeur n'était pas un multiple de 64 pixels. `sameShapeAsGl` rend une seule forme aux deux moteurs. - **La chaîne gelait sa caméra.** Le GL réattache scène et caméra à chaque dessin ; un chaînage de nœuds les cuit dans le graphe. Un film dont la liste de plans change de caméra en cours continuait sur la première. La caméra entre dans la chaîne et la fait reconstruire quand elle bouge. - **`RenderPipeline.dispose` ne libère que son quad** : chaque chaîne évincée fuyait un G-buffer plein écran. La passe est libérée à la main avec elle. - **`colorNode` écrasait la carte de couleur de base** : tout matériau texturé aurait rendu plat. C'est `materialColor` qu'il faut multiplier, pas la teinte. - **Les deux cartes remappées lisaient sans leur matrice** : une rugosité tuilée lisait non tuilée pendant que le masque de cavité à côté tuilait — exactement le décalage que `syncEdgeTransform` existe pour empêcher. - **La cible de SORTIE manquait** : une passe de nœuds se dimensionne sur le drawing buffer faute de mieux, donc un film 1920×1080 se composait dans un G-buffer de la taille du canevas. Et rien ne restaurait viewport ni ciseaux. - Le montage pouvait armer un renderer sur la réponse du précédent ; `settled()` résout aussi sur un refus, donc l'éclairage attend `canDraw`, pas la promesse. Revue de simplification, retenu : une seule déclaration de `ComposerJob` pour les deux chaînes (elle était copiée champ par champ), `ROOM_SIGMA` et `BOTH_ENGINES` déduplitués, les quatre capacités qui différaient entre moteurs (`maxSamples`, `maxAnisotropy`, `frameTimer`, `releaseContext`) passent dans `RenderDriver` — cinq `'capabilities' in renderer` disparaissent, dont un qui vivait dans le fichier d'interface lui-même. TRAA est retiré jusqu'à son lot : son module de nœuds n'était plus chargé pour rien, ni son champ de budget lu par personne. Le banc moyenne désormais dix images fixes : un échantillon unique variait du simple au double. Chiffres du rapport remesurés sur cette révision, et une affirmation du tour précédent corrigée — l'Avancé monte moins vite avec la scène, mais il monte. --- docs/fr/audits/moteur-rendu-c6/RAPPORT.md | 75 +++++++----- .../src/engines/material/MaterialRenderer.ts | 9 +- .../src/engines/postfx/PostComposer.ts | 36 +----- .../src/engines/postfx/postSurfaces.test.ts | 5 +- .../engines/render/engineBenchmark.browser.ts | 35 +++--- src/renderer/src/engines/render/glDriver.ts | 38 ++++-- .../src/engines/render/gpuComposer.ts | 110 ++++++++++++++---- src/renderer/src/engines/render/gpuDriver.ts | 75 +++++++++--- src/renderer/src/engines/render/gpuModule.ts | 9 +- .../src/engines/render/gpuPostQuality.test.ts | 13 --- .../src/engines/render/gpuPostQuality.ts | 14 +-- .../src/engines/render/materialNodes.test.ts | 14 +-- .../src/engines/render/materialNodes.ts | 42 +++++-- .../src/engines/render/mountRenderer.test.ts | 4 + .../src/engines/render/renderDriver.ts | 45 ++++--- .../scene/SceneRendererConstruction.ts | 4 +- .../src/engines/scene/SceneRendererFlight.ts | 4 +- .../engines/scene/SceneRendererLifecycle.ts | 4 +- .../src/engines/scene/SceneRendererState.ts | 5 +- .../src/engines/scene/textureCache.ts | 25 +--- .../src/engines/skybox/SkyboxRenderer.ts | 9 +- .../src/engines/viewport/ViewportFrame.ts | 4 +- .../src/engines/viewport/ViewportInset.ts | 5 +- .../src/engines/viewport/ViewportSurface.ts | 46 ++++---- .../src/engines/viewport/environment.ts | 3 + .../viewport/viewportEngineSupport1.ts | 10 +- src/shared/domain/postProcessingRegistry.ts | 4 +- src/shared/domain/renderEngine.ts | 3 - 28 files changed, 377 insertions(+), 273 deletions(-) diff --git a/docs/fr/audits/moteur-rendu-c6/RAPPORT.md b/docs/fr/audits/moteur-rendu-c6/RAPPORT.md index 7770852ef..85924624a 100644 --- a/docs/fr/audits/moteur-rendu-c6/RAPPORT.md +++ b/docs/fr/audits/moteur-rendu-c6/RAPPORT.md @@ -184,32 +184,41 @@ Surface 1280×720, qualité `high`, une pile portant GTAO sur les deux moteurs. - `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. -- `firstReadbackMs` — la PREMIÈRE lecture de pixels. Elle vide la file, donc elle absorbe aussi - ce qui restait à compiler. -- `readbackMs` — la seconde, une fois plus rien à compiler. Ce qu'un export paie par image. - -| Profil | Moteur | `submitMs` | `firstReadbackMs` | `readbackMs` | +- `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,045 | 63,3 | 45,2 | -| Un modèle (4 nœuds) | Avancé | 0,125 | 640,8 | 41,2 | -| Monde ouvert C5 (20 000 nœuds) | Compatible | 0,080 | 52,8 | 39,3 | -| Monde ouvert C5 (20 000 nœuds) | Avancé | 0,122 | 60,1 | 40,4 | +| 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,12 ms contre 0,045–0,080. C'est - 1,5 à 2,8 fois, et cela reste très en dessous d'un budget d'image. -- **Il ne se dégrade pas avec la scène** : 0,125 ms sur 4 nœuds et 0,122 ms sur 20 000, quand le - Compatible passe de 0,045 à 0,080. Le coût par objet du renderer de nœuds est plat sur ces deux - profils. Une seule machine, deux profils : c'est une observation, pas une loi. -- **La première lecture de l'Avancé est chère — 640,8 ms** — parce qu'elle paie la compilation - de tous les pipelines de nœuds du graphe. Le second profil ne la repaie pas (60,1 ms) : les - pipelines sont déjà là. **Ce n'est pas un coût de lecture, et il ne doit pas être lu comme tel.** -- **À chaud, les deux moteurs lisent au même prix** (~40–45 ms) : la lecture est dominée par la - synchronisation, pas par l'API. -- **Aucun seuil de gain n'est atteint sur ces mesures.** Le moteur Avancé n'est, ici, pas plus - rapide que le Compatible. 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. +- **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 @@ -257,9 +266,10 @@ acheter la même chose sur les deux moteurs. La division de résolution du chaî `resolutionScale` du nœud, la part d'échantillons est la même valeur. Cinq tests, dont deux qui comparent les deux lectures réglage par réglage. -Une limite honnête : **TRAA n'expose aucun nombre d'échantillons** dans three 0.185 — ses -échantillons sont des IMAGES, une par gigue d'une séquence fixe. Le seul levier de qualité est -la correction sous-pixel, et c'est ce que le budget pilote. +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 serait la correction sous-pixel. Rien n'est écrit pour lui tant qu'il n'est pas porté : +un champ de budget que personne ne lit est un champ qui ment. ### 3.5 — Ce qui a dû être réparé pour que l'Avancé dessine @@ -275,13 +285,22 @@ Trouvés en faisant tourner le banc, pas en lisant le code : - 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é, avec le motif 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 publierait un effet que la moitié des projets ne peuvent pas dessiner — et le rendre visible demanderait une bibliothèque -d'effets consciente du moteur, c'est-à-dire l'UX que le spec met hors périmètre. Le budget -qualité prévoit déjà son levier ; l'effet attend son jumeau GL ou une UI par moteur. +d'effets consciente du moteur, c'est-à-dire l'UX que le spec met hors périmètre. Rien n'a été +laissé en place pour lui : ni son module de nœuds, ni un champ de budget. L'effet attend son +jumeau GL ou une UI par moteur. ### 3.7 — Ce que l'Avancé ne fait pas encore, écrit plutôt que découvert @@ -301,7 +320,7 @@ qualité prévoit déjà son levier ; l'effet attend son jumeau GL ou une UI par `world:validate` compare déjà deux représentations pixel à pixel ; l'entrée qui compare deux MOTEURS n'est pas écrite. Tant qu'elle ne l'est pas, « visuellement équivalent » n'est affirmé par personne dans ce rapport. -- **La capture d'export sur un projet `'gpu'`** : le chemin est mesuré (`readbackMs` EST +- **La capture d'export sur un projet `'gpu'`** : le chemin est mesuré (`stillMs` EST `captureStill`), l'image n'est pas jointe. - Les vingt-neuf autres effets, l'aperçu incrusté et la correction de ciel côté Avancé. - Le switch en direct du moteur : hors périmètre. diff --git a/src/renderer/src/engines/material/MaterialRenderer.ts b/src/renderer/src/engines/material/MaterialRenderer.ts index 20a064999..be67ac44c 100644 --- a/src/renderer/src/engines/material/MaterialRenderer.ts +++ b/src/renderer/src/engines/material/MaterialRenderer.ts @@ -12,12 +12,7 @@ import { import { PBR_CHANNELS, type PbrChannel } from '@shared/domain/material' import { reportFailure } from '@/services/diagnostics' import { createTextureBinding, type TextureBinding } from '../scene/textureBinding' -import { - createTextureCache, - maxAnisotropyOf, - type TextureCache, - type TextureSource, -} from '../scene/textureCache' +import { createTextureCache, type TextureCache, type TextureSource } from '../scene/textureCache' import { createSkyBinding, type SkyBinding } from '../viewport/skyBinding' import { type ViewportEnvironment } from '../viewport/environment' import { SCHEME_OF, type NavigationScheme } from '@shared/domain/navigationPreset' @@ -127,7 +122,7 @@ export class MaterialRenderer { (assetId, error) => reportFailure('material.map', assetId, error), options.assetVersion, options.livePreview, - () => maxAnisotropyOf(this.viewport.gl), + () => 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 diff --git a/src/renderer/src/engines/postfx/PostComposer.ts b/src/renderer/src/engines/postfx/PostComposer.ts index 37c959274..16d6854d0 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 { diff --git a/src/renderer/src/engines/postfx/postSurfaces.test.ts b/src/renderer/src/engines/postfx/postSurfaces.test.ts index 9d92156b8..dc46980da 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,7 +41,7 @@ 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, scene: new Scene(), diff --git a/src/renderer/src/engines/render/engineBenchmark.browser.ts b/src/renderer/src/engines/render/engineBenchmark.browser.ts index 5ce707755..2516da929 100644 --- a/src/renderer/src/engines/render/engineBenchmark.browser.ts +++ b/src/renderer/src/engines/render/engineBenchmark.browser.ts @@ -10,12 +10,14 @@ * - `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. - * - `firstReadbackMs` — the FIRST still drawn and read back. It forces the queue empty, so it - * also absorbs whatever the engine had left to compile: on a node chain that is every pipeline - * of the graph, and reading it as a readback cost would be reading a compile as a copy. - * - `readbackMs` — the second one, once nothing is left to compile. What an export actually pays - * per frame. Apart from the submit on purpose: WebGL reads synchronously and a node renderer - * maps a buffer, so folded together they would hide which half moved. + * - `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. @@ -36,8 +38,8 @@ type EngineMeasure = { /** What the viewport ACTUALLY mounted: `gl` here under `gpu` is the silent fallback. */ drawnWith: RenderEngine submitMs: number | null - firstReadbackMs: number | null - readbackMs: 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 } @@ -48,8 +50,11 @@ type ProfileMeasure = { measures: readonly EngineMeasure[] } -/** How many frames each figure is the mean of, after the ones that only compile shaders. */ +/** 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 const WARMUP_FRAMES = 10 const OFFSCREEN_HOST_OFFSET_PX = -100_000 @@ -104,13 +109,13 @@ async function measureEngine(engine: RenderEngine, state: SceneState): Promise createEnvironment(glEnvironmentPort(asWebGL(renderer)), scene, requestRender), + // The ceiling comes from three rather than from `gl.MAX_SAMPLES`, which the WebGL1 typing has + // no name for. + maxSamples: 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. @@ -54,7 +76,7 @@ function glEnvironmentPort(renderer: WebGLRenderer): EnvironmentPort { return { fromEquirectangular: texture => generator.fromEquirectangular(texture), - fromScene: scene => generator.fromScene(scene as Scene, ROOM_SIGMA), + fromScene: scene => generator.fromScene(scene, ROOM_SIGMA), grade: (given, stack) => (grading ??= createSkyGrading(renderer)).of(given, stack) ?? given, dispose: () => { grading?.dispose() @@ -64,13 +86,13 @@ function glEnvironmentPort(renderer: WebGLRenderer): EnvironmentPort { } } -/** How far the neutral room is blurred as it is prefiltered — three's own value for one. */ -const ROOM_SIGMA = 0.04 - /** * `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: object): WebGLRenderer { +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/gpuComposer.ts b/src/renderer/src/engines/render/gpuComposer.ts index ed4ae0f2b..ae5b48d00 100644 --- a/src/renderer/src/engines/render/gpuComposer.ts +++ b/src/renderer/src/engines/render/gpuComposer.ts @@ -10,30 +10,82 @@ * out rather than refused: a stack carries what a document says, and an engine cannot make a * document wrong. */ -import type { RenderTarget, Renderer } from 'three/webgpu' -import { planStack, POST_EFFECTS, type PostEffect } from '@shared/domain/postProcessing' +import { Vector4, type WebGLRenderTarget } from 'three' +import type { RenderTarget, WebGPURenderer } from 'three/webgpu' +import { + planStack, + POST_EFFECTS, + stackShapeKey, + type PostEffect, +} from '@shared/domain/postProcessing' import { paramNumber } from '../postfx/uniforms' import { heaviestCost } from '../postfx/postPlan' import { gpuBudgetFor, gpuSamplesOf, type GpuBudget } from './gpuPostQuality' import type { GpuModule } from './gpuModule' +import { 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 scene pass, freed by hand: `RenderPipeline.dispose` frees its quad material and no target. */ + pass: { 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 } -export function createGpuComposer(gpu: GpuModule, renderer: Renderer): SceneComposer { +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. + 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 + } /** Which surface draws through which chain, so a closed panel frees what only it was using. */ const bound = new Map() const free = (key: string): void => { - chains.get(key)?.pipeline.dispose() + const chain = chains.get(key) + 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. + chain?.pass.dispose() chains.delete(key) } @@ -42,27 +94,37 @@ export function createGpuComposer(gpu: GpuModule, renderer: Renderer): SceneComp const plan = planStack(job.stack) const effects = plan.effects.filter(runsOnGpu) if (effects.length === 0 || job.width < 1 || job.height < 1) { - aimAt(renderer, job) - renderer.render(job.scene, job.camera) + const restore = aimAt(job) + try { + renderer.render(job.scene, job.camera) + } finally { + restore() + } return } // The SURFACE belongs to the key: a node chain holds the pass that draws the scene, and // two panes sharing one would each see the other's camera. - const key = `${plan.shapeKey}#${job.surface}` + const key = `${plan.shapeKey}${SURFACE_MARK}${job.surface}` + const held = chains.get(key) + if (held && held.camera !== job.camera) free(key) const chain = chains.get(key) ?? build(gpu, renderer, job, effects) chains.set(key, chain) bound.set(job.surface, key) chain.apply(effects, gpuBudgetFor(heaviestCost(effects), job.quality), job.width, job.height) - aimAt(renderer, job) - chain.pipeline.render() + const restore = aimAt(job) + try { + chain.pipeline.render() + } finally { + restore() + } }, sweep: live => { - const shapes = new Set(live.map(stack => planStack(stack).shapeKey)) + const shapes = new Set(live.map(stackShapeKey)) for (const key of [...chains.keys()]) { - if (!shapes.has(key.split('#')[0] ?? '')) free(key) + if (!shapes.has(key.slice(0, key.indexOf(SURFACE_MARK)))) free(key) } }, @@ -88,7 +150,7 @@ export function createGpuComposer(gpu: GpuModule, renderer: Renderer): SceneComp */ function build( gpu: GpuModule, - renderer: Renderer, + renderer: WebGPURenderer, job: ComposerJob, effects: readonly PostEffect[], ): GpuChain { @@ -110,7 +172,9 @@ function build( ) return { + camera: job.camera, pipeline, + pass: scene, apply: (held, budget, width, height) => { const asked = held.find(one => one.effect === 'gtao') if (occlusion && asked) applyOcclusion(occlusion, asked, budget, width, height) @@ -137,18 +201,18 @@ function applyOcclusion( occlusion.setSize(width, height) } +/** What tells a shape from the surface it was built for, in a chain key. */ +const SURFACE_MARK = '#' + +/** + * `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. + */ +function asNodeTarget(target: WebGLRenderTarget | null): RenderTarget | null { + return target as unknown as RenderTarget | null +} + /** Whether the Advanced engine can build this one at all — the registry answers, nothing else. */ function runsOnGpu(effect: PostEffect): boolean { return POST_EFFECTS[effect.effect].engines.includes('gpu') } - -/** Where on the canvas this job lands. Restored by the caller's frame, as on the GL side. */ -function aimAt(renderer: Renderer, job: ComposerJob): 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. - renderer.setRenderTarget((job.target as RenderTarget | null) ?? null) - if (!job.rect) return - 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) -} diff --git a/src/renderer/src/engines/render/gpuDriver.ts b/src/renderer/src/engines/render/gpuDriver.ts index 187bae284..a24276ebc 100644 --- a/src/renderer/src/engines/render/gpuDriver.ts +++ b/src/renderer/src/engines/render/gpuDriver.ts @@ -16,12 +16,13 @@ */ import { localizedError } from '@shared/localizedError' import { reportFailure } from '@/services/diagnostics' -import { createEnvironment, type EnvironmentPort } from '../viewport/environment' +import { createEnvironment, ROOM_SIGMA, type EnvironmentPort } from '../viewport/environment' import { createGpuComposer } from './gpuComposer' import { loadedGpuModule, type GpuModule } from './gpuModule' import { applyMaterialNodes } from './materialNodes' import type { RenderDriver, StudioRenderer } from './renderDriver' -import type { Renderer } from 'three/webgpu' +import type { WebGLRenderTarget } from 'three' +import type { RenderTarget, WebGPURenderer } from 'three/webgpu' export const gpuDriver: RenderDriver = { engine: 'gpu', @@ -30,19 +31,23 @@ export const gpuDriver: RenderDriver = { new (loaded().webgpu.WebGPURenderer)({ canvas, antialias: true, alpha }), // The backend, asked for once per renderer. `render()` throws until it answers. - ready: renderer => asNodeRenderer(renderer).init().then(NOTHING), + ready: async renderer => { + await asNodeRenderer(renderer).init() + }, readPixels: async (renderer, target, width, height) => { - // `as`: a node renderer takes the `RenderTarget` a `WebGLRenderTarget` extends — three - // declares the pair apart and the studio allocates only the latter. - const pixels = await asNodeRenderer(renderer).readRenderTargetPixelsAsync( - target as unknown as Parameters[0], + const read = await asNodeRenderer(renderer).readRenderTargetPixelsAsync( + asNodeTarget(target), 0, 0, width, height, ) - return new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength) + return sameShapeAsGl( + new Uint8Array(read.buffer, read.byteOffset, read.byteLength), + width, + height, + ) }, createComposer: renderer => createGpuComposer(loaded(), asNodeRenderer(renderer)), @@ -51,6 +56,14 @@ export const gpuDriver: RenderDriver = { createEnvironment(gpuEnvironmentPort(loaded(), asNodeRenderer(renderer)), scene, requestRender), patchMaterial: (material, uniforms) => applyMaterialNodes(loaded(), material, uniforms), + + // 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, + 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: () => {}, } /** @@ -61,7 +74,7 @@ export const gpuDriver: RenderDriver = { * 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: Renderer): EnvironmentPort { +function gpuEnvironmentPort(gpu: GpuModule, renderer: WebGPURenderer): EnvironmentPort { const generator = new gpu.webgpu.PMREMGenerator(renderer) let said = false @@ -79,9 +92,6 @@ function gpuEnvironmentPort(gpu: GpuModule, renderer: Renderer): EnvironmentPort } } -/** How far the neutral room is blurred as it is prefiltered — three's own value for one. */ -const ROOM_SIGMA = 0.04 - /** Read once per call rather than held: a driver outlives the session that loaded its bundle. */ function loaded(): GpuModule { const held = loadedGpuModule() @@ -93,9 +103,42 @@ function loaded(): GpuModule { * `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): Renderer { - return renderer as Renderer +function asNodeRenderer(renderer: StudioRenderer): WebGPURenderer { + return renderer as WebGPURenderer } -/** `init` resolves with the renderer; what the caller awaits is that it is up, and nothing more. */ -const NOTHING = (): void => {} +/** + * 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 + +/** + * `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. + */ +function asNodeTarget(target: WebGLRenderTarget): RenderTarget { + return target as unknown as RenderTarget +} diff --git a/src/renderer/src/engines/render/gpuModule.ts b/src/renderer/src/engines/render/gpuModule.ts index 3bc9d7f7c..d2d7fbd34 100644 --- a/src/renderer/src/engines/render/gpuModule.ts +++ b/src/renderer/src/engines/render/gpuModule.ts @@ -5,20 +5,18 @@ * 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 + * The three 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 @@ -46,12 +44,11 @@ export async function loadGpuModule(): Promise { async function importGpuModule(): Promise { if (!(await probeGpuAdapter())) return null - const [webgpu, tsl, gtao, traa] = await Promise.all([ + const [webgpu, tsl, gtao] = 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 } + held = { webgpu, tsl, gtao } return held } diff --git a/src/renderer/src/engines/render/gpuPostQuality.test.ts b/src/renderer/src/engines/render/gpuPostQuality.test.ts index 19498de2c..7cbcc1ed9 100644 --- a/src/renderer/src/engines/render/gpuPostQuality.test.ts +++ b/src/renderer/src/engines/render/gpuPostQuality.test.ts @@ -4,19 +4,6 @@ import { budgetFor, samplesOf } from '../postfx/postQuality' import { gpuBudgetFor, gpuSamplesOf } from './gpuPostQuality' describe('what the Advanced chain is allowed to spend', () => { - it('spends the whole frame at the top setting', () => { - expect(gpuBudgetFor('high', 'high')).toEqual({ resolutionScale: 1, samples: 1, subpixel: true }) - }) - - it('works the occlusion out at half the frame where the setting says so', () => { - expect(gpuBudgetFor('high', 'performance').resolutionScale).toBe(0.5) - }) - - it('drops the temporal correction at the cheap end, the one lever TRAA has', () => { - expect(gpuBudgetFor('high', 'performance').subpixel).toBe(false) - expect(gpuBudgetFor('high', 'balanced').subpixel).toBe(true) - }) - // 🛑 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', () => { diff --git a/src/renderer/src/engines/render/gpuPostQuality.ts b/src/renderer/src/engines/render/gpuPostQuality.ts index 2e25ff1a5..6509091c2 100644 --- a/src/renderer/src/engines/render/gpuPostQuality.ts +++ b/src/renderer/src/engines/render/gpuPostQuality.ts @@ -19,24 +19,12 @@ export type GpuBudget = { resolutionScale: number /** What share of the samples a sampling effect asks for it actually takes. */ samples: number - /** - * Whether the temporal anti-aliaser pays for subpixel correction. - * - * 🛑 The one lever TRAA has. `TRAANode` of three 0.185 exposes no sample count — its samples - * are FRAMES, taken one per jitter of a fixed sequence — so a quality level cannot buy fewer - * of them. What it can drop is the per-pixel correction, which is the expensive half. - */ - subpixel: boolean } /** 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, - subpixel: quality !== 'performance', - } + return { resolutionScale: 1 / budget.divisor, samples: budget.samples } } /** A count asked for by a parameter, brought down to what the budget allows. Never below one. */ diff --git a/src/renderer/src/engines/render/materialNodes.test.ts b/src/renderer/src/engines/render/materialNodes.test.ts index 4b8632918..7eb1840b2 100644 --- a/src/renderer/src/engines/render/materialNodes.test.ts +++ b/src/renderer/src/engines/render/materialNodes.test.ts @@ -12,13 +12,12 @@ import type { GpuModule } from './gpuModule' let gpu: GpuModule beforeAll(async () => { - const [webgpu, tsl, gtao, traa] = await Promise.all([ + const [webgpu, tsl, gtao] = 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 } + gpu = { webgpu, tsl, gtao } }) /** Every uniform of a built graph, which is where the bridge to the engine's own values shows. */ @@ -40,15 +39,6 @@ const holding = (nodes: ReturnType, value: unknown): boolean nodes.some(node => node.value === value) describe('the material patch as nodes', () => { - it('gives the standard material the three slots the studio adds', () => { - const material = new MeshStandardMaterial() - applyMaterialNodes(gpu, material, createUniforms()) - - expect(material.roughnessNode).toBeTruthy() - expect(material.metalnessNode).toBeTruthy() - expect(material.colorNode).toBeTruthy() - }) - // 🛑 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', () => { diff --git a/src/renderer/src/engines/render/materialNodes.ts b/src/renderer/src/engines/render/materialNodes.ts index 9b7124f87..f995c344e 100644 --- a/src/renderer/src/engines/render/materialNodes.ts +++ b/src/renderer/src/engines/render/materialNodes.ts @@ -14,17 +14,24 @@ * 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 { Color, Texture, type MeshStandardMaterial } from 'three' +import { Matrix3, Texture, type MeshStandardMaterial } from 'three' import type { MaterialUniforms } from '../material/materialShader' import type { GpuModule } from './gpuModule' -/** What the mask samples where no picture is bound. Its intensity is zero there, so it is unlit. */ +/** 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. * @@ -37,7 +44,18 @@ export function applyMaterialNodes( material: MeshStandardMaterial, uniforms: MaterialUniforms, ): void { - const { float, mix, texture, uniform, uv, vec3 } = tsl + 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) @@ -47,7 +65,6 @@ export function applyMaterialNodes( const roughness = uniform(0).onRenderUpdate(() => material.roughness) const metalness = uniform(0).onRenderUpdate(() => material.metalness) - const colour = uniform(new Color()).onRenderUpdate(() => material.color) 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) @@ -55,11 +72,13 @@ export function applyMaterialNodes( // 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, roughnessTexel.g), hasRoughnessMap), + mix(float(1), mix(roughnessRemap.x, roughnessRemap.y, roughnessRead.g), hasRoughnessMap), ) material.metalnessNode = metalness.mul( - mix(float(1), mix(metalnessRemap.x, metalnessRemap.y, metalnessTexel.b), hasMetalnessMap), + 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 @@ -67,5 +86,14 @@ export function applyMaterialNodes( // that do have one. const masked = edgeMap.sample(edgeTransform.mul(vec3(uv(), 1)).xy) const cavity = float(1).sub(masked.r.mul(edgeIntensity)) - material.colorNode = colour.mul(cavity) + // `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 index 3a5b1cee3..862cd6a34 100644 --- a/src/renderer/src/engines/render/mountRenderer.test.ts +++ b/src/renderer/src/engines/render/mountRenderer.test.ts @@ -29,6 +29,10 @@ function drivers(gpu: Partial = {}): RenderDrivers { throw new Error('not asked for') }, patchMaterial: () => {}, + maxSamples: () => 0, + maxAnisotropy: () => 1, + frameTimer: () => null, + releaseContext: () => {}, }) return { gl: stub('gl'), gpu: { ...stub('gpu'), ...gpu } } } diff --git a/src/renderer/src/engines/render/renderDriver.ts b/src/renderer/src/engines/render/renderDriver.ts index c4b0b3fff..37eaa0600 100644 --- a/src/renderer/src/engines/render/renderDriver.ts +++ b/src/renderer/src/engines/render/renderDriver.ts @@ -2,11 +2,16 @@ * What DRAWS, behind one interface — the seam between the studio's engines and the graphics API * underneath them. * - * Five things depend on which API is running, and nothing else does: building the renderer, - * reading its pixels back, composing a stack, prefiltering an environment, and patching the - * standard material. Everything else in `engines/` speaks three.js objects, which both APIs + * 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' @@ -16,6 +21,7 @@ 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. @@ -75,6 +81,23 @@ export type RenderDriver = { uniforms: MaterialUniforms, onMissingAnchor: (anchor: string) => void, ) => void + /** + * How many samples an off-screen target may be antialiased to. ZERO on a node renderer: it + * sizes the attachments of a render target itself, and has no context to ask. + */ + maxSamples: (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 } /** @@ -92,19 +115,3 @@ export function drawInto(renderer: StudioRenderer, target: WebGLRenderTarget | n renderer.setRenderTarget(target) return restore } - -/** - * How many samples an off-screen target may be antialiased to. - * - * The ceiling comes from three rather than from `gl.MAX_SAMPLES`, which the WebGL1 typing has no - * name for. ZERO on a node renderer: it sizes the attachments of a render target itself, and has - * no context to ask. - */ -export function maxSamplesOf(renderer: StudioRenderer): number { - if (!('capabilities' in renderer)) return 0 - const gl = renderer.getContext() - return Math.max( - 0, - Math.min(Number(gl.getParameter(gl.SAMPLES) ?? 0), renderer.capabilities.maxSamples), - ) -} diff --git a/src/renderer/src/engines/scene/SceneRendererConstruction.ts b/src/renderer/src/engines/scene/SceneRendererConstruction.ts index 12dc2ecde..47c9486c2 100644 --- a/src/renderer/src/engines/scene/SceneRendererConstruction.ts +++ b/src/renderer/src/engines/scene/SceneRendererConstruction.ts @@ -15,7 +15,7 @@ import { createSkinWeights } from '../character/skinWeights' import { createBvhBuilder } from './bvhBuilder' import './bvhPatches' import { createCsgEvaluator } from '../csg/csgEvaluator' -import { createTextureCache, loadTexture, maxAnisotropyOf } from './textureCache' +import { createTextureCache, loadTexture } from './textureCache' import { createReliefSurface } from './reliefSurface' import { createScatterSurface } from './scatterSurface' import { createReliefBuilder } from './reliefBuilder' @@ -53,7 +53,7 @@ export class SceneRendererConstruction extends SceneRendererFrame { (assetId, error) => reportFailure('scene.texture', assetId, error), options.assetVersion, options.livePreview, - () => maxAnisotropyOf(this.viewport.gl), + () => this.viewport.anisotropy, ) this.buildModelSources() this.buildShapeWorkers() diff --git a/src/renderer/src/engines/scene/SceneRendererFlight.ts b/src/renderer/src/engines/scene/SceneRendererFlight.ts index 151f62ef6..82976f41d 100644 --- a/src/renderer/src/engines/scene/SceneRendererFlight.ts +++ b/src/renderer/src/engines/scene/SceneRendererFlight.ts @@ -1,6 +1,6 @@ import { localizedError } from '@shared/localizedError' import { PerspectiveCamera, WebGLRenderTarget } from 'three' -import { maxSamplesOf, type StudioRenderer } from '../render/renderDriver' +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' @@ -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, maxSamplesOf(gl)) + 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) diff --git a/src/renderer/src/engines/scene/SceneRendererLifecycle.ts b/src/renderer/src/engines/scene/SceneRendererLifecycle.ts index 766c3e8cd..c051648d0 100644 --- a/src/renderer/src/engines/scene/SceneRendererLifecycle.ts +++ b/src/renderer/src/engines/scene/SceneRendererLifecycle.ts @@ -131,7 +131,9 @@ export abstract class SceneRendererLifecycle extends SceneRendererResources { */ private async lightWhenSettled(): Promise { await this.viewport.settled() - if (this.environment) this.lightMountedScene() + // `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 { diff --git a/src/renderer/src/engines/scene/SceneRendererState.ts b/src/renderer/src/engines/scene/SceneRendererState.ts index 26e4a8cb2..9bb935c9d 100644 --- a/src/renderer/src/engines/scene/SceneRendererState.ts +++ b/src/renderer/src/engines/scene/SceneRendererState.ts @@ -9,7 +9,6 @@ import { type Vector3 as ThreeVector3, } from 'three' import type { SceneComposer } from '../render/sceneComposer' -import type { WebGLRenderer } from 'three' 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' @@ -97,9 +96,7 @@ export abstract class SceneRendererState { // choice is settled for as long as this panel is open. See `RenderPolicy.engine`. engine: () => this.view.engine, onFrame: delta => this.advance(delta), - // `as`: `ViewHelper` is declared against a `WebGLRenderer` and uses `clearDepth` and - // `render`, which both engines have — three types the addon before the node renderer. - onOverlay: renderer => this.viewHelper?.render(renderer as WebGLRenderer), + onOverlay: renderer => this.viewHelper?.render(renderer), onPane: (index, camera) => this.dressPane(index, camera), // Before `TransformControls` reads the same event — see `onPaneArmed`, which says why the // viewport owns this call rather than a listener of this file. diff --git a/src/renderer/src/engines/scene/textureCache.ts b/src/renderer/src/engines/scene/textureCache.ts index 84c4e4986..3ebea04a8 100644 --- a/src/renderer/src/engines/scene/textureCache.ts +++ b/src/renderer/src/engines/scene/textureCache.ts @@ -142,29 +142,6 @@ async function tiffTexture(bytes: Uint8Array, orientation: PictureOrientation): return texture } -/** - * What of a renderer this reads — narrower than either renderer class, which jsdom cannot build. - * The two engines put the same answer in two places: under `capabilities` on the Compatible one, - * on the renderer itself on the Advanced one. - */ -type AnisotropyHolder = - { capabilities: { getMaxAnisotropy: () => number } } | { getMaxAnisotropy: () => number } - -/** - * What a card allows, or `1` before there is a card to ask. Read through here by the three - * engines that build a cache, so none of them has to know where its renderer keeps it. - */ -export function maxAnisotropyOf(renderer: AnisotropyHolder | null | undefined): number { - if (!renderer) return 1 - const asked = - 'capabilities' in renderer - ? renderer.capabilities.getMaxAnisotropy() - : renderer.getMaxAnisotropy() - // Never under one: three answers 0 — not 1 — on a context without - // `EXT_texture_filter_anisotropic`, and 0 is not a number of samples. - return Math.max(1, asked) -} - export type TextureCache = { /** * Takes a reference on an asset read in a given colour space, loading it if nobody holds it @@ -219,7 +196,7 @@ export function createTextureCache( */ previewOf: (assetId: string) => ImageBitmap | null = () => null, /** - * How many samples the GPU may take across a texel's footprint — `maxAnisotropyOf`, asked at + * 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 diff --git a/src/renderer/src/engines/skybox/SkyboxRenderer.ts b/src/renderer/src/engines/skybox/SkyboxRenderer.ts index c29865acb..6cdf6d8e7 100644 --- a/src/renderer/src/engines/skybox/SkyboxRenderer.ts +++ b/src/renderer/src/engines/skybox/SkyboxRenderer.ts @@ -6,12 +6,7 @@ import { createRefCache, type RefCache } from '../core/refCache' import { createGpuPipeline, type GpuPipeline } from '../gpu/gpuPipeline' import { reportFailure } from '@/services/diagnostics' import { createTextureBinding, type TextureBinding } from '../scene/textureBinding' -import { - createTextureCache, - maxAnisotropyOf, - type TextureCache, - type TextureSource, -} from '../scene/textureCache' +import { createTextureCache, type TextureCache, type TextureSource } from '../scene/textureCache' import { type ViewportEnvironment } from '../viewport/environment' import { createTestObjects, type TestObjects } from '../viewport/testObjects' import { aimAlong } from '../viewport/lookAround' @@ -125,7 +120,7 @@ export class SkyboxRenderer { (assetId, error) => reportFailure('skybox.source', assetId, error), options.assetVersion, options.livePreview, - () => maxAnisotropyOf(this.viewport.gl), + () => 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. diff --git a/src/renderer/src/engines/viewport/ViewportFrame.ts b/src/renderer/src/engines/viewport/ViewportFrame.ts index 6297bf08f..a842321e0 100644 --- a/src/renderer/src/engines/viewport/ViewportFrame.ts +++ b/src/renderer/src/engines/viewport/ViewportFrame.ts @@ -145,7 +145,9 @@ export class ViewportFrame extends ViewportInset { private renderOverlay(renderer: StudioRenderer): void { const overlay = this.options.onOverlay - if (!overlay) return + // `ViewHelper` is declared against a `WebGLRenderer` and the Advanced engine draws no + // overlay yet: skipped there rather than cast into a renderer three never typed it for. + if (!overlay || !('capabilities' in renderer)) return renderer.autoClear = false try { overlay(renderer) diff --git a/src/renderer/src/engines/viewport/ViewportInset.ts b/src/renderer/src/engines/viewport/ViewportInset.ts index 2b1242350..f7005fdad 100644 --- a/src/renderer/src/engines/viewport/ViewportInset.ts +++ b/src/renderer/src/engines/viewport/ViewportInset.ts @@ -1,5 +1,5 @@ import { LinearSRGBColorSpace, NoToneMapping, SRGBColorSpace, WebGLRenderTarget } from 'three' -import { maxSamplesOf, type StudioRenderer } from '../render/renderDriver' +import type { StudioRenderer } from '../render/renderDriver' import { aspectLoan } from './aspectLoan' import { glRect } from './panes' import type { InsetPane, InsetBlit } from './viewportEngineSupport1' @@ -31,7 +31,8 @@ export abstract class ViewportInset extends ViewportDrawing { held?.dispose() // What the DRAWING BUFFER is antialiased to, held to what the engine can offer. - const target = new WebGLRenderTarget(width, height, { samples: maxSamplesOf(renderer) }) + const samples = this.renderDriver.maxSamples(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 // its output pass). Declared rather than left at the default so the quad below does not diff --git a/src/renderer/src/engines/viewport/ViewportSurface.ts b/src/renderer/src/engines/viewport/ViewportSurface.ts index 590ff5c0f..25f8abd04 100644 --- a/src/renderer/src/engines/viewport/ViewportSurface.ts +++ b/src/renderer/src/engines/viewport/ViewportSurface.ts @@ -7,7 +7,6 @@ import { applyShadowPolicy } from '../scene/shadows' import { token } from '../core/palette' import { mountRenderer } from '../render/mountRenderer' import { type RenderDriver, type StudioRenderer } from '../render/renderDriver' -import { createGpuTimer, isGpuTimerContext } from './gpuTimer' import { ViewportMounting } from './ViewportMounting' export abstract class ViewportSurface extends ViewportMounting { @@ -32,7 +31,7 @@ export abstract class ViewportSurface extends ViewportMounting { const canvas = this.canvasIn(host) const renderer = this.rendererFor(canvas) this.renderer = renderer - this.gpuTimer = gpuTimerFor(renderer) + this.gpuTimer = this.renderDriver.frameTimer(renderer) this.holdFramesUntilReady(renderer) this.mountControls(canvas) this.mountNavigation(host) @@ -102,7 +101,16 @@ export abstract class ViewportSurface extends ViewportMounting { this.rendererReady = true return } - this.rendererSettling = this.settleRenderer(settling) + 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. */ @@ -110,21 +118,27 @@ export abstract class ViewportSurface extends ViewportMounting { return this.rendererReady } - /** Resolves once this viewport may draw. Already settled where the engine needs no backend. */ + /** + * 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(settling: Promise): Promise { + 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. + // 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() @@ -206,12 +220,13 @@ export abstract class ViewportSurface extends ViewportMounting { this.disposeInset() const canvas = this.renderer?.domElement - // The Compatible engine alone can give its context back before it is collected; a node - // renderer holds a device the browser reclaims with the page. const renderer = this.renderer - if (renderer && 'forceContextLoss' in renderer) renderer.forceContextLoss() + 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 @@ -285,16 +300,3 @@ export abstract class ViewportSurface extends ViewportMounting { this.requestRender() } } - -/** - * The frame timer, where the engine has one. - * - * 🛑 `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 — which is a beat after the mount that - * would ask. A frame drawn on the Advanced engine is therefore untimed for now. - */ -function gpuTimerFor(renderer: StudioRenderer): ReturnType | null { - if (!('capabilities' in renderer)) return null - const context = renderer.getContext() - return isGpuTimerContext(context) ? createGpuTimer(context) : null -} diff --git a/src/renderer/src/engines/viewport/environment.ts b/src/renderer/src/engines/viewport/environment.ts index 3f7b81e1c..48f817a5f 100644 --- a/src/renderer/src/engines/viewport/environment.ts +++ b/src/renderer/src/engines/viewport/environment.ts @@ -8,6 +8,9 @@ import { isNeutral, NEUTRAL_ADJUSTMENTS, type AdjustmentStack } from '@shared/do */ 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. diff --git a/src/renderer/src/engines/viewport/viewportEngineSupport1.ts b/src/renderer/src/engines/viewport/viewportEngineSupport1.ts index fbabc54c6..f992c333b 100644 --- a/src/renderer/src/engines/viewport/viewportEngineSupport1.ts +++ b/src/renderer/src/engines/viewport/viewportEngineSupport1.ts @@ -8,7 +8,7 @@ import { Vector3, type WebGLRenderTarget, } from 'three' -import type { StudioRenderer } from '../render/renderDriver' +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' @@ -40,8 +40,12 @@ 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. */ - onOverlay?: (renderer: StudioRenderer) => void + /** + * 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 * it, and answering whether that changed what the scene wears — which is what tells the frame diff --git a/src/shared/domain/postProcessingRegistry.ts b/src/shared/domain/postProcessingRegistry.ts index 9e8a09139..74fd854a6 100644 --- a/src/shared/domain/postProcessingRegistry.ts +++ b/src/shared/domain/postProcessingRegistry.ts @@ -6,7 +6,7 @@ * those may pull three.js in, so nothing here knows a `Pass` exists — `engines/postfx/` is the * one folder that does. */ -import { BOTH_ENGINES, GL_ONLY, type RenderEngine } from './renderEngine' +import { GL_ONLY, RENDER_ENGINES, type RenderEngine } from './renderEngine' import { BLUR_KINDS, HALFTONE_SHAPES, @@ -126,7 +126,7 @@ export const POST_EFFECTS: Record = { slot: 'ao', // The one effect both engines build: GLSL `GTAOPass` on the Compatible side, the native // `ao()` node on the Advanced one. Same slot, same exclusivity, same parameters. - engines: BOTH_ENGINES, + engines: RENDER_ENGINES, duplicable: false, params: { radius: slider(0.01, 2, 0.01, 0.25), diff --git a/src/shared/domain/renderEngine.ts b/src/shared/domain/renderEngine.ts index d0890951c..3463d0c95 100644 --- a/src/shared/domain/renderEngine.ts +++ b/src/shared/domain/renderEngine.ts @@ -11,6 +11,3 @@ 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 an effect both engines can build says. One so far: the ground-truth occlusion. */ -export const BOTH_ENGINES: readonly RenderEngine[] = ['gl', 'gpu'] From d75cbb86d47dde0a32e6681a3927494d570ea274 Mon Sep 17 00:00:00 2001 From: Alban Pasquelin Date: Fri, 11 Sep 2026 09:18:48 +0200 Subject: [PATCH 07/13] =?UTF-8?q?Compare=20les=20deux=20moteurs=20image=20?= =?UTF-8?q?par=20image,=20et=20corrige=20ce=20que=20=C3=A7a=20r=C3=A9v?= =?UTF-8?q?=C3=A8le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm engines:parity` rend la même scène de référence sur le Compatible et sur l'Avancé et compare pixel à pixel, dans le harnais que `world:validate` utilise déjà entre deux représentations. Six cas, chacun par la couture que le studio emprunte lui-même : la scène nue, la même sous GTAO, la capture d'export, une image de film, le patch matériau et la règle temporelle. Mesuré le 11 septembre 2026, fenêtre au premier plan : scène nue 0 % de pixels différents, film 642x362 0 %, export 0,22 %, GTAO 2,7 %. Le patch matériau diffère de 24,75 % — l'écart connu, la cavité tombant sur la couleur diffuse côté nœuds. Le harnais refuse de mesurer sans image d'animation : une fenêtre derrière une autre n'en reçoit aucune, et les deux moteurs y dessinent autrement — 58 % d'écart sur une scène identique, mesuré. Le moteur vit désormais dans le DOCUMENT (`SceneWorld.engine`), choisi à la création et relu à chaque montage ; la préférence ne fait plus que pré-remplir le champ. L'export jeu lit celui de la scène d'entrée. TRAA est porté, sans paramètre — three 0.185 n'expose aucun nombre d'échantillons — et la bibliothèque d'effets ne propose plus ce que le moteur du document ne sait pas bâtir. Ce que la comparaison a trouvé, que le banc ne pouvait pas voir parce qu'il mesurait ce que la chaîne coûte et non ce qu'elle dessine : - l'occlusion sortait ROUGE : `GTAONode` rend dans une cible `RedFormat`, donc `getTextureNode()` donne (ao, 0, 0, 1) et multiplier ça tuait le vert et le bleu ; - `blend` ne faisait rien côté Avancé, alors que le `GTAOPass` GL l'applique ; - un effet temporel donnait une image PLATE à l'export : bâtie, dessinée puis libérée, la chaîne ne lui laisse aucun historique. `PostEffectMeta.temporal` le déclare et le composeur laisse ces effets hors d'une image unique. Et ce que la revue a trouvé ensuite, dont un défaut 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 puis demandait s'il était là, sur la ligne suivante. `useRenderEngineReady` retient le montage jusqu'à ce que ce chargement aboutisse. Suivent la cible de l'occlusion qui fuyait, la chaîne abandonnée quand une pile change de forme, `maxSamples` qui répondait à deux questions et faisait perdre son anticrénelage à une capture, l'applicateur manquant qui aurait décalé les paramètres d'un effet sur la passe d'un autre, et un soleil remplacé qui laissait la scène éclairée deux fois. Rapport : docs/fr/audits/moteur-rendu-c6/RAPPORT.md --- docs/fr/audits/moteur-rendu-c6/RAPPORT.md | 221 ++++++++++-- .../audits/moteur-rendu-c6/capture-avance.png | Bin 0 -> 92272 bytes .../fr/audits/moteur-rendu-c6/film-avance.png | Bin 0 -> 36311 bytes .../moteur-rendu-c6/materiau-avance.png | Bin 0 -> 13460 bytes .../moteur-rendu-c6/materiau-compatible.png | Bin 0 -> 13995 bytes package.json | 1 + scripts/cdp.mjs | 19 + scripts/run-engine-benchmark.mjs | 15 +- scripts/run-engine-parity.mjs | 88 +++++ scripts/run-world-safe-validation.mjs | 15 +- src/main/localizedErrors.i18n.test.ts | 10 +- .../src/engines/material/MaterialRenderer.ts | 26 +- .../src/engines/material/materialShader.ts | 29 +- .../src/engines/postfx/PostComposer.ts | 14 +- .../src/engines/postfx/postFactories.test.ts | 27 +- .../src/engines/postfx/postSurfaces.test.ts | 1 + .../src/engines/postfx/standaloneEffects.ts | 20 +- .../engines/render/engineParity.browser.ts | 325 ++++++++++++++++++ .../src/engines/render/engineParityStage.ts | 287 ++++++++++++++++ src/renderer/src/engines/render/glDriver.ts | 4 +- .../src/engines/render/gpuComposer.ts | 134 ++++++-- src/renderer/src/engines/render/gpuDriver.ts | 1 + src/renderer/src/engines/render/gpuModule.ts | 9 +- .../src/engines/render/gpuPostQuality.ts | 8 +- .../src/engines/render/materialNodes.test.ts | 5 +- .../src/engines/render/mountRenderer.test.ts | 1 + .../src/engines/render/renderDriver.ts | 12 +- .../src/engines/render/sceneComposer.ts | 7 + .../src/engines/scene/SceneRendererDisplay.ts | 2 + .../src/engines/scene/SceneRendererState.ts | 7 +- src/renderer/src/engines/scene/csm.test.ts | 26 ++ src/renderer/src/engines/scene/csm.ts | 23 +- .../engines/scene/sceneRendererSupport1.ts | 10 + .../src/engines/scene/sceneWorld.test.ts | 16 + src/renderer/src/engines/scene/sceneWorld.ts | 4 + .../src/engines/scene/visualRegression.ts | 19 + .../scene/worldSafeValidation.browser.ts | 14 +- .../src/engines/viewport/ViewportInset.ts | 5 +- .../assistant/sceneHandlers03.test.ts | 9 +- .../NewDocument/NewDocumentEngineField.tsx | 53 +++ .../NewDocument/NewDocumentForm.tsx | 20 +- .../NewDocument/NewDocumentWindow.test.tsx | 58 +++- .../NewDocument/NewDocumentWindow.tsx | 1 + .../game/components/GameWindow/GameWindow.tsx | 5 + .../components/Post/PostProcessingSection.tsx | 18 +- .../Document/hooks/useMountedSceneRenderer.ts | 38 +- .../Scene/Document/hooks/useSceneRuntime.ts | 27 +- .../src/features/shell/createScript.ts | 9 +- .../src/features/shell/newDocument.ts | 22 +- src/renderer/src/game/gameExportCompiler.ts | 32 +- src/renderer/src/game/webRender.ts | 2 + .../src/hooks/useRenderEngineReady.test.tsx | 65 ++++ .../src/hooks/useRenderEngineReady.ts | 38 ++ src/renderer/src/stores/scenes.test.ts | 12 +- src/renderer/src/stores/scenes.ts | 22 +- src/shared/domain/newDocument.ts | 17 + src/shared/domain/postProcessing.test.ts | 29 +- src/shared/domain/postProcessing.ts | 1 + src/shared/domain/postProcessingEngines.ts | 54 +++ src/shared/domain/postProcessingRegistry.ts | 41 ++- src/shared/domain/renderEngine.ts | 3 + src/shared/domain/renderPolicy.ts | 11 +- src/shared/domain/scene.ts | 13 + src/shared/i18n/ar/assets.json | 4 + src/shared/i18n/ar/postfx.json | 2 + src/shared/i18n/ar/settings.json | 2 +- src/shared/i18n/de/assets.json | 4 + src/shared/i18n/de/postfx.json | 2 + src/shared/i18n/de/settings.json | 2 +- src/shared/i18n/en/assets.json | 4 + src/shared/i18n/en/postfx.json | 2 + src/shared/i18n/en/settings.json | 2 +- .../i18n/englishCognates.testFixtures.json | 9 + src/shared/i18n/es/assets.json | 4 + src/shared/i18n/es/postfx.json | 2 + src/shared/i18n/es/settings.json | 2 +- src/shared/i18n/fr/assets.json | 4 + src/shared/i18n/fr/postfx.json | 2 + src/shared/i18n/fr/settings.json | 2 +- src/shared/i18n/hi/assets.json | 4 + src/shared/i18n/hi/postfx.json | 2 + src/shared/i18n/hi/settings.json | 2 +- src/shared/i18n/id/assets.json | 4 + src/shared/i18n/id/postfx.json | 2 + src/shared/i18n/id/settings.json | 2 +- src/shared/i18n/it/assets.json | 4 + src/shared/i18n/it/postfx.json | 2 + src/shared/i18n/it/settings.json | 2 +- src/shared/i18n/ja/assets.json | 4 + src/shared/i18n/ja/postfx.json | 2 + src/shared/i18n/ja/settings.json | 2 +- src/shared/i18n/ko/assets.json | 4 + src/shared/i18n/ko/postfx.json | 2 + src/shared/i18n/ko/settings.json | 2 +- src/shared/i18n/pt/assets.json | 4 + src/shared/i18n/pt/postfx.json | 2 + src/shared/i18n/pt/settings.json | 2 +- src/shared/i18n/ru/assets.json | 4 + src/shared/i18n/ru/postfx.json | 2 + src/shared/i18n/ru/settings.json | 2 +- src/shared/i18n/tr/assets.json | 4 + src/shared/i18n/tr/postfx.json | 2 + src/shared/i18n/tr/settings.json | 2 +- src/shared/i18n/vi/assets.json | 4 + src/shared/i18n/vi/postfx.json | 2 + src/shared/i18n/vi/settings.json | 2 +- src/shared/i18n/zh/assets.json | 4 + src/shared/i18n/zh/postfx.json | 2 + src/shared/i18n/zh/settings.json | 2 +- 109 files changed, 1918 insertions(+), 216 deletions(-) create mode 100644 docs/fr/audits/moteur-rendu-c6/capture-avance.png create mode 100644 docs/fr/audits/moteur-rendu-c6/film-avance.png create mode 100644 docs/fr/audits/moteur-rendu-c6/materiau-avance.png create mode 100644 docs/fr/audits/moteur-rendu-c6/materiau-compatible.png create mode 100644 scripts/run-engine-parity.mjs create mode 100644 src/renderer/src/engines/render/engineParity.browser.ts create mode 100644 src/renderer/src/engines/render/engineParityStage.ts create mode 100644 src/renderer/src/features/document/components/NewDocument/NewDocumentEngineField.tsx create mode 100644 src/renderer/src/hooks/useRenderEngineReady.test.tsx create mode 100644 src/renderer/src/hooks/useRenderEngineReady.ts create mode 100644 src/shared/domain/postProcessingEngines.ts diff --git a/docs/fr/audits/moteur-rendu-c6/RAPPORT.md b/docs/fr/audits/moteur-rendu-c6/RAPPORT.md index 85924624a..a2d7ec659 100644 --- a/docs/fr/audits/moteur-rendu-c6/RAPPORT.md +++ b/docs/fr/audits/moteur-rendu-c6/RAPPORT.md @@ -9,7 +9,8 @@ Machine : Apple M2 Max, macOS 26.5.2 (Darwin 25.6.0), arm64. three.js 0.185.1. | --- | --- | --- | | 1 — Gains WebGL indépendants | livrée, 1 MUST refusé sur mesure | 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. | | 2 — Interface driver + choix moteur | livrée, 1 écart d'emplacement | `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. | -| 3 — Premier contenu GPU réel | livrée, TRAA écarté | `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. TRAA non porté : motif plus bas. | +| 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 @@ -154,11 +155,11 @@ s'y opposent : 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 est donc dans l'espace 3D des préférences, avec la copie demandée -(« Compatible » / « Avancé »), le nom de l'API dans le texte d'aide faute de sous-titre dans ce -contrôle. Le verrou « pas de switch en direct » tient **par construction** : le moteur est lu au -montage du viewport et jamais relu ; l'aide le dit. Mettre le choix dans le projet demanderait un -champ de manifeste et sa validation — hors périmètre de cette étape, à décider. +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 — @@ -167,9 +168,9 @@ une machine sans adaptateur WebGPU retombe d'elle-même sur le Compatible et le ### 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'est pas écrit : il n'a aucun effet -tant qu'aucun effet GPU n'existe, et un filtre qu'on ne peut pas voir tourner est un filtre qu'on -ne peut pas relire. +`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 @@ -236,9 +237,7 @@ Les uniformes sont ceux du moteur, partagés : un `Vector2` par référence, un texture relus à chaque rendu parce qu'ils sont remplacés et non écrits dedans. Cinq tests tiennent ce pont, dont celui qui vérifie que le graphe ne se reconstruit pas quand un canal arrive. -**Non mesuré** : la comparaison visuelle GL/GPU du patch. Elle demande deux rendus de la même -scène de référence à comparer pixel à pixel, comme `world:validate` le fait déjà entre deux -représentations — le harnais existe, l'entrée pour les deux moteurs n'a pas été écrite. +**Mesurée à l'étape 4** : la comparaison visuelle GL/GPU du patch, § 4.1 et § 4.3. ### 3.2 — GTAO en TSL @@ -268,8 +267,8 @@ 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 serait la correction sous-pixel. Rien n'est écrit pour lui tant qu'il n'est pas porté : -un champ de budget que personne ne lit est un champ qui ment. +levier est la correction sous-pixel. **Branchée à l'étape 4**, quand l'effet a été porté : +`GpuBudget.subpixelCorrection`. ### 3.5 — Ce qui a dû être réparé pour que l'Avancé dessine @@ -293,14 +292,14 @@ continuait sur la première) ; `RenderPipeline.dispose` ne libère que son quad, 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é, avec le motif +### 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 publierait un effet que la -moitié des projets ne peuvent pas dessiner — et le rendre visible demanderait une bibliothèque -d'effets consciente du moteur, c'est-à-dire l'UX que le spec met hors périmètre. Rien n'a été -laissé en place pour lui : ni son module de nœuds, ni un champ de budget. L'effet attend son -jumeau GL ou une UI par moteur. +`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 @@ -314,19 +313,181 @@ jumeau GL ou une UI par moteur. - **L'aperçu incrusté** et 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. +## É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 ces mesures arrondies vers le haut, +jamais des cibles théoriques ; `material` n'en a pas — 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, relu à chaque montage de viewport et porté par l'export jeu (la scène d'ENTRÉE +décide, un jeu ne tenant qu'un renderer). `Settings.three.engine` ne sert plus qu'à pré-remplir le +champ ; son aide le dit, dans les quinze langues. + +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 comparaison visuelle GL/GPU**, sur le patch matériau comme sur GTAO. Le harnais de - `world:validate` compare déjà deux représentations pixel à pixel ; l'entrée qui compare deux - MOTEURS n'est pas écrite. Tant qu'elle ne l'est pas, « visuellement équivalent » n'est affirmé - par personne dans ce rapport. -- **La capture d'export sur un projet `'gpu'`** : le chemin est mesuré (`stillMs` EST - `captureStill`), l'image n'est pas jointe. +- **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, l'aperçu incrusté et la correction de ciel côté Avancé. -- Le switch en direct du moteur : hors périmètre. +- 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 : écarté, motif au § 3.6. -- Le choix du moteur par PROJET plutôt que par application, si le sélecteur doit vraiment vivre à - la création : demande un champ de manifeste et sa validation. +- **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. +- **La fenêtre de jeu ne suit pas le moteur du document** qu'elle joue, là où un export du même + document le suit : son renderer est bâti 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. - 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. 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 0000000000000000000000000000000000000000..4f7cff308480354b3dcf71bd65852b3d69a695c1 GIT binary patch literal 92272 zcmZsDbzD?i_xCV@fQ6JuONsOqq)X{WxG!Y|Cs#a-uMB2jo+z`qN3g*;L~{ct{vD_)B^ zT27*A;prOI0{Q~yHHW<=m+gW?i}~g9ylH`r$tbt=hKy zgz8^IB>>BL6SaB+js}m#5CG#l!X#-zA%KWWfYbs(El!4Soz+3OOoULun@*xSJNKc% z@8AhtJ#q-l;$dlx3o~Rd_wH*D3T+nHWU>QFY0%pTU}-clS2q%G`g$>oQ2afee>WJG zM1Cm-q(Slf2fUC7Gii*C0Fu*j*Z{0* z!q>qVbx_Ghm<^n1QTdA`K3gi#$M-4*U;qoyX?#5C@1MfkNPwS=d}Yf&j5EPzp>m#&-Q=T?=uA zGD?9JA18vO^O%7m=_+&If=%cSjCSqKO<&rX^!s2lp+iln#$tw~v;~>}2h+Q*KbQfg zl>c=72h*p>JP#xknfE0ZY`30#Fxm}YfGL$BNT8?43`{;RFMw%pFgDmw!e};w2LVI5 z{>1NJQ~x(+2t>jih5&!y9=Lz)-(UWX*#}(EKF#=>zNRk>?}FVd1B1^-2VmMBqVOL~ zDK>jl0HzPQ{GNe~Uf>nnVHhy_y1Dz{=;i7I?bD3_m}VG005fgE4G+L4}Lv-81`u~F| z#pWj!fGNdOKX3@2dJUHz2F^4Kl@JTe)C0<*3j>%^8U2TsYT>#bo@hfFlPK80$2JuU zXXxoggEKre`VSx0!f_3$1C)O|um=Uql^0sV3WUd!R}qqb5BmS|w2dgzX<;6UbmC&Y z3D&+9M&q|aKqefCY;a0yM1u_{MBVfqrtIVct7({a)0_>6W#*6KF+g6kIfklhBLHYP zjD`(tF1m4WhTb*+>?foD2p$$Vu82B7|FQ`^;K1z44=v%r1;7SIb%Mc`0ALpZH+OPl zB?g0a!f5=(2!LgMo(&G_jn`qrDX#&r54$|UVB>IIcL@QRoHAuFzz0AF8zu*`Etz$qbo-&?|v$SXT(i)CMT=o^m`cSc#rc8`j91zMLPsGQlD(;}fK<&6WwXsUo(!Zx+dHtDV!$4XFxdpb;@UxNbU&iO6RAu(RizD$05?9KBJH z074E)rn%|{$YxB>FXO;jU=L`39CJM$?%&7@EPRzL3R>um>DiMAEPOnnfe+#s14nP- z1Qw#E7=soHV0sQ{0Sgu8G(c7UdIa3R2?+7zekpH33(qh-Cy0TCcB>kMAdVq$^kx=d zAx3HzXkjyk=iE3Dd?I!U}4#b1}SLaTR3_PEwJze zrIhcgiBF)z!n~!KAp%FZ`d(L$u@)Uleio_?@jMWRU%z_($URKY6JmhpRw@eb|7EvJ z6M|{c+M;X#ri$i?VTp28BI`srO(Q7*ByN+g6dlCW7)r1OG^5&I+TjmDOr0^c+Y;?i zYzb4ZHGrAY*ivk?qQT#K-n9hvkJeG}tVVo*=`YDrED%#sD8aTIz_dsFqZo+k9)@-= zF~F2N6^$2gbxK)^vcI!{|M|BtEh^h*8=nExUiU3Q0mC{7PSbQ75MalwlmH|E4U}M) z84zHg{iDhMYzjm|q%(&0P@*l0twt((!_`;=)1t6_u>nNQw_Xn{(Lt(y4bN&q1DNgw zl#+v(qCpAv?*L3E#6S6gm>ywjPZ0x5-BK}muZAL+1+6XY7)U!%LA+|ztHF9$7K!5m zglZaZ`c=%U5*5e8Mc0#W`Uah-kAP+hK)2dG!3~IeSXasb5?2*UP;bKwVcvQDLhEX% zvQ0*1b{Yn-EYp5&24Nw>)Lu=ggVc_M`Gs7Kj__UV+6htz6x+Jg+q_rHkc)=(<1tXJ zy%Hp;z77H^ii~st(hiLe&njrDQXBvm-2gh)pflGIlB<5FjRotTq62`Q9+vWgfZl@= zG|K}(Pp%nwfPj*sGJC}VK#yp@oPmL2XrBNrYwbbUyW3Zb8=z&aJt4J6u{})1=Dj*6 zfl1Taq8-mdkv9aWt3ef2oGg;a8E|KIPpMT<*k+E0tE`6H^es4eKSB)x$_!f>AtZnd zZ*iANgMcbS)yi3zA;ojA4H7{><57!^fu2>no3>Q|6!CB{wGk=6q$?pTFa!(~yEcUs za9I9SeBP_27)+YVR`eLi_<;h9)in7>NUd#5KuzYVk-gw=s0bgHy9{}_9Phqv_35O_SG^C z-i}+_ObYm@S1KW|-<5I!7WUBg?FwMmZ^?ZiLX|iuzgzF zCI*1mluF7ACO))nT>(zJm7GiqOr)_z6aYj-DwzkENNqc40T2ht$rHguD%(j0fH;{- z-T)?2+Rm>4ik~NcFb*bC*sc`-#I@81$6(?E+g%HQxJypK0vfv6$Rm%`fWqlWP?CwV??! z#Cq0YjtLY{iZCWR8nq!49q64FSDR$`_}$tV4ItXY$VcvjvT@A=TQ&#a%-G0jSU_{- z5_zPv2{4~<7Lc9-O*?fH;kOY$w7Ko|zVQYKC^Jke0f;tQM^D^9Y1CB~%E?Xu0Nu6w z#d_7xHr-LJEgk|uSGMP@KtQW7CPw%Gpd;vADU_e&L|1Z^lMPv}iw;!}M$rmJDfl?r_BG^78C$q`7(^NtCvj(J zr40iR9#y)jU8UW}oQOf7!F9YQ8bN835p}&AIE!Who2h$OWqAm783GcGDLGdOXr34X zZ!!b0ui-9wUBwPZU6%v&*$|dQ0Ln7&jtzmUnNzQY#>ZFHIYEH}Y`7`B7;|OA30|0o zrw>5eQd&&DO5=nzczOV|UxtfU`?(1=*s&*YFSEYwuvm0;9Se-t%K@P6yk4xjN@IX| zcmZ|p`fk)>(^VQRtij6ypzUQWUTp&>s9?ulrT}ffVi9q59XX8G+Yq1~d|n*9N+X7O zc%tTY8|nX2e4XAZ#~o zfrMm-Huebt?B1f?1DWzYlqr)3fT2UK;r_3FA*j{?kPTKG;O;UL1{PD;ms0~9puN2Y z?j+wrHp(6XdnTZhf%siVnKBRtWVwA;8zd(hwDEH+fI*A$0EF)p)%7(MR2xD-HV``5N1s3Y5S0Hh>JVUnx5 zFAjt73P=Kaih3ylGRP`y{gxcS>TDBk6N2zELs$1ufL-|@{@|t=7iI70E+98qy6_zk zOMeKjS2&<4tREDZSIu%*$k$6i29U@9FdU5d0i2nf0#Fr|uf*@h18#+T@QeIJ((`b| z2dBm2@Uss#@Zb8QXGwxMA;+ZM8|T{x$Ya6X0r& zAI`hTPt6#Ak&XtD2plus@$aLgQS7H4WQlZpM{LK@;^`1cR;9%y zNufPzrPVF(`vd6WB4X<9rWKh3Br1k=&H)jCS5UaQkC6t4ha!qtg3M+EvFtn zB>!Fp@x6sqEnZkDiPXSv1B3fxZ>F7F@a*`kIh?-{G~`h-HM!N>7Rr^Z6_%J2fMHCn zp*WS)vmo}UrlU*2iatEP>1mT!kU{(}rWsnQ3P~do6OMpND(){f=Tm|Vheai)b|-CW z&4mBQ@u}~C#s#9AxT3!iZKj|$AB70sCO=?G`)v19n`D_yDS7_uQMi{PrSg=h6P+OC zK#@@>W9toLBTXYQ5kGRXR)bg!Q4>r-{_wz9&ao-p2@YtFR()`9cMkXj4rzly*^8Hm5o11_HN zr5-p?J&`q@dvE(F*@WpS-B|?LsGK$t%H*#z^G!NC_>_d4B1b%)h)b}l7-H@kCL8qA zRLqzE`u0nUSx8s%AU;`HCh|tTs;-2k3H{4}cRkS}9;`;D2WpS3J7v&z$7|fr10Z^+ zz+trD|Fy1T8hB!=^wa5QNm^2*moXaYgyFeH>_a55qm?aeCFk!+YRSfytw+JDxRT!& z{aErQ4~3i1cRaF$_=r)O0qJz=SX$#^^SLnDx^U(AAL~?^_yx?LbOthP9TbzP@vAzb zRCp?&_H3*)<@v@UdwL3XZp&6$cgnpm^)bmVgBH9A8|l?|ktjxUAN?*pVR1MIO?@p& z=;uji?|e4A-i35v1l_3sG^r!hL_;<3_ETTfyG^{ZeJ%|(J^YXG~ zKO+M*C^H#UW1DitXBIMfBM3Q4tpF?sseTog|L$aBPX(ZvQ>_w#(uLEiOy^%3?$J; z*{}TTQPp)&H+`@$E|pO025m&*V*KPSrBzn?Gh=e4giQ~BJl}6Qwwr1qdi;kH4 z6-90;JuWDG?=PzH&7PHQEGmM|OirXeinU8zJRC^^{sKk0n$Rsn;?VVX5$+Cl=P%! z(ECSfJde+E4bi?`xRoBOT%>)pnj%xi>5bit%AbbrBxNII-mK{?!$;5cY5K|DF4o`E zuvlbgrYU%XP8VzVwT$d#^3KC-iI?gkd5omR0eeRDv53%)aALyKh=@HB<<$5zW+C$< zc_=FzYXvjP*-~5!_Fw1GdI0C;3xUs=%`5*Iv&rFZ&Il@@BRVin2e?@W6GX)LZ-_=I zvtUX~hr=>bOtFn^0NL%_#r4 zq5Mg@mb0Z)7bA^0J_nmL?}N@qYN{e~Ov`kf8NU?EjdBzT(VhIm?I+S9K16?V5%LIg z^SV5>t*Zh5{O^bYfl)zyHW3?b}`uYF9!OmGRhqsb$K-$_y?(D~54+fvAm zkysQHI@2^b&D8GXs?tWAw3Iby2@+D#D^AkPNP471QN?QLN&h}j%TGjh$bkDT!w^Zk zV#b6No}4rtA^%T`eyjcX(WXjbdtud4RRl9>{5p-fe|ThS6X0FIDgK{;QkbP{IXGN= zPLSO{0tWc{{5VISKC*su=Pce|;`|#58+9I~6NeSMLTRg_ous;uJ6?MNV?UR>VnkJ8 z(|gm9c?C%=!B1WT8TvAcu+o*rjCroRr*_ZNImEQSJrgakh{)!TXKZImBdYl+FDJL> zThB^>pF&vy?aJ@8*8LtRqM=h&>}XUUmn$fN_uRr;q^-o~a2qLj^RFjzKv(xWQSZo; zeS}=bCtr*MWNLhbz(E0)WcFz(9lks3>Z3yD&nH= zf9F2-j+@&+ z5|Ty-B!r`1GlON8ZfRkE>rahtlIR$Z$};F1Bknh_+sUMi>QGMP+qa)IGHNCaC$^lhBukS#A-Rn%I)gVsjWzkuJ#Y@ zKHboiak6%ftz~3yXIy$!StU`I<)?m_rTIKDiJ#CsP`aRVv+!}=jXZ(n&i6genBmyg z{O`>c?_LP_=&yhTlD@M1K^FpkYI|@Y;JzqwW%^9_&7HULdCZBV?sE(es3;fp(r6nh znb{UCtHV!R%fHmg>t(ul4UDaPbi;bA{+ORAy@-KI*!_muM!3PC%7+57pOZX~Z1BmG z2DGi|6{IDf)WkpLpXxJG6I?W|%-*ytTzLM3wMYd=qs5ZcNHoB)&orBq8RgYw1IHWz z@o*=DectwaeB*a(unvO{x9m;=k&TUtO}MM*i&R9%8N;&VQm}5mu5+e=aK*7qo*Vlh zm-hP7(9ImXqEhrkwiOz4i#wdc6zrq2-7kwKCYoQ!z=Ge)5bmXuX>l8u3uSdN8rKkJ zNplZTJKQoFK}bo~sOol9DnVG+fPrmS}9oBzHMlGb;-HibJ`<`OQfR_l%mWZf0Hg~=vwZv z?8u3~H`VP_5zE81aEGk zj(l{$BuU{7xBDIFY#UIQFGBFWw9oq4SU&EWb^Zc@_E*)`fwE>*WzMZ~MX<*@d8BOc;Qtno=1xZATD1YnhYjgl5i z(F)64tFjbk&NIZ5X8 zQDqVY%rE6lF>UoPcTUk6N>64R7un${>(fTv*ynivSg!xaH}l7Q^Rb$*oI@BwY2O_x zz*Z7i3fSu^%7RrT0v22vxI@K^_C6dPEqs_z=3DOUDUFIhk@UUn*I;4P@UcGH=Pm7m zK3r74yL@CU#>q@gOAg!AGgi_)O2w80Yt^5S`=_1CjE1*bPacucDCXakmZw*Y^^oSq zq9d>}a-@355}V`iUh_q~Tk^2TLUTPoYoS*~L=H`naMWw0$#e55|m)KtTBGHKyoF_(fW@sn0DBf!| z2OcQFsJ}Z*mqqs=H;(X-y9Y;{Ex5BYdW#L-Kf5KG3^j8?D)Hl&X#yFU zgtsJFWAy?>JM>-@DG2N~IST~OVzDNkBV8N@GTTSYPsZNsHVr$Em6{0KkagPPNIoJ}Cv5Q)F zNJt)uWA3ZG4>WmW)md`3u<>t!cZyN^O&Wtlm-T(wrG=iNo^5JPhwza&rtrRr);j2X z*+R+22vn5Vdl~$Bz1EV^i|^({3WNp`P0Mq=!t>QbukyY;q7bZ>r`KQ7QI+i)uOzcK z9W~1E6U`k*h^-p?ef#KWCeueRfHs)3phTd=Nl5seP2D)wTr%DqQN1p=VUXYeY&{=W zo;nluF_xx?@kVUX)Oy-})A#$v^G2E92+zh2W2R5|NB_**;JkPB0|>Ccx_Gb<_38gk z&b0Uu`nho)n~Z?jmlD1UsoN$vDk z;zV41poIt8YvYsq7sE58`9-jDdv_}U%bu`2ODN28lfd^?$uQ5}xm34=poip^Q|$|_xBm1N74JQ^PdY99p*M$Kzzb(Ik%eRMR53yV(U$M?rN7<4l<`0RI>Yomr}o`I$+us$y!FXx?Ol{?l;b7QG!dcC zGbQ;pYh4+b``jG6l0_Emh$Hh9VrMBu6~Afa7bi-`AskK3bcgXDRdJRRw1!($^`|aK z7VJKEB33KAXQsJsSR@dZmWySXT$xb8y~dbW^HQr_(U^>iYO+DZqAqBv#^ye&5YvhB zgq)a(pYjR451syH^A6X-(O-|ldD)WM;&`v^a>%5D-C>Dd-Lq52lv(Aoq)c0q;4oR$ z@}5vi)M&?)Bf-Yy9wBpzD9w$*k-E6Fdn@{HP8iGZ+vSla`8r-#Ki!b_eBqxNgZy%K{M-;{hm0nf zwr6!;*+Q6H6mOVhGou_xqr820d6{MN%$Hm5Vo>(?A@Q&%GVILtQ15hbR5quf=T=pz zJ&U}vh!MLB4sYzwu+@7ZL{v3L9W!bj0!)tu=u8=Ih^D@fYCR}?l_%A${&j7&ss3Yh zjdgn+O}YBYV=OoJ?16f7T4gk*DMz$SMVl;ZGs1C+1BH>v2cygOn72*PPTgW27vxbM zj}*#ql&t=A_9K%G-(xHMEPo-W;I;c`m(^N{jPH`J93W^Mo6+?PpHdUn=Q;7eW5!cq zHciJiD84%xsl=2(j-4O2o*5iz`JxUx3oloo*E<_q$>)h}Oet=aP=$dTNvSgRzJ+_Q zj;3t6eR^BlPEt=1E+R@b(XU!y+;g;1f!J)@e@?$TsS>Dlvxge9Jqme8RfF<^4m(>l z?-i?eOVp^AnKcSM9xduSlt+02RQQ^btZYJFx#ucpeULwGV2PA(r!EYrmr5WJbQjrQ z`x|EbES=-~WV9pPRkMV7JZmL`BJJ#&m9ID=VDx*Jwxkt_UIW>y< zU?XYc8Zol_;gp0&k(dQ~hlmoizpb(p#y{N_cextdsQp1QQp?zoyx%@ed~1}@Dr;{{xx9;89cH4gMgJsER)0QL zDL>7ys_(kOyFRfL{e3J#6UKs)AiZE(>uJ)jDq^+(jHl8;0J0Kv)KZDqu(Eq#7QH=nolP?242Yg z(CG@4;AD^#F#_h1#@Rg5A|1>Ddt77G^n5vNWM10kF+`Qbg)(_`I-!Fkr87_8FiFeE zb7#?)tJ|x|4UD``QL0ngGMo5nBefW;f#}s=9bR zn+${H9}$bC^p+K}JW8<%(yU5x5Irv=jz3@0!2B`8y-1<{!|{F^m!%yJCXn}=ae>A# zU_UORKjFBNFrIKXx4VN?dbgHJHgholix+;Aj9Y@_LVPHc$pO##1IPDYL-^${G`P!( zS2ymRjz;gjT#=jKo8=#C;P{;G^4a4fx&hPR&Xetwn$Xsx)3p$^@<9W)Z}`~BO8b;T zs$w2~%{`JT8GQ|IA`HwV}ZtG*ExF1b?$j>7bY-clXL#Fmt$2a;c0r6<=K_h}tJMnQFypTB zj=nDAldcjUQL;Y|$TpRs11d2G!~KN7VpSCv=ziy?kSZbHDyV@Y{hLIe?k-nKtG_CO9pO^nI7(3zd-l)x&o|kH^eCn39@cbZtT2{=9 zs^4O)Hn_)8LsCqvqtDXo@8C5>%J)c(>UoPz%b2Ci<=X~=XGmh7iL-`t9#=E*__fWL zG`&3dfQy!OEpY8(IURJz|9^+4B#)oj5v4?y4Pf1lxdeP4Y8&o#L^K|XZM{6|p>HQPyqsL_Gh z*bwEC_MK*{08OkTd6dmmsN;Rd%R1;GN|$cfMTlvSjQ<^CP*KRwsCG;CWysqv2D6GS z6LW8b6~fX9)9$^n67Gyn?k6YC*xCFh+eu>|Iw&S-!O!Gk|BhbMnj?XC2@p}~;Zdbj zZtKD*rWJkYq>^C@o}fzMJedSt0SDf#Y@8yMLgnqEkYTT=$8{q?gZW~~$v^H}IPWnF z>sggCbvt_BJ&gY~=zTcs!}+&uG{yO_ijO(4Fe&xB^YzZE zSpF?1?V!qy4}tldZq!4E3yBV1LU|@lv+q>LRuk4?E8=(N=F}@Xe>WqfTS8=uFbzfi zsvsbB>9w4%H5ZIb;f|@u9k+;~cS&N+A@fVkS@OJD?^3%Saj%Zinb($L@qO1d)nC(;pSwI1v$r*3{VhP)6kC8QX zgwH(M-``Y=Q(jFaf51-mt+bWH=)$Dme9m0eSYDl6Yn+o{>-u$a^82RzyxXg9RF&2i zwVo^@m}AjvZ0P;ey^@^-4cV6;Y{dD5oa6nS&^?1v>&3xmoN^2~e%G%DkD&4X9RqX> z)XQvx-PNj#_pB+w&*BX6A%xhQEMyJe^@DNG?LFoUZb9Od8TGbG zT6%?1irLhY<&BOHf388DG4FaB17R&CZ0Ry~T*L6xJ?|DwEoWV}jIqM4CVf-`On+1r z#5ASzI*G=43~V9>Y8CjHv&e8`b{4v<*BzO(j;A|$c9OIc2Q&Jj(XS^-@w0m`$mOpN zOvI23#=jjeU*KF@y4OARKq?mjE0?AlIPNidLb|()6ByFmf+?w^{WqEF$3Z?0KSWM@ z=y}L3{C=YYbD>7I9I)(04ZORRTq4BtX)N^kYgFVYb55h~k2$IGa*8I29{Jv(ms~tu z@3kz|3Y(maIz@|^z3LyBpKnzz4miq(Y?Qbk<(wYxVjr(~^I(Tfn%aaq7&hRNp^aP0 zlGCWcGHe*l!=uOYzcvd#y?rpRH_w8qUH0*^w-bY$E~D;@uNU-nzWE&>HbYStKDP|X zkP|!QcFDM5Hw6{yVW&M6%Z*YELd-rFvrdmHsBKfzTzK8&7ffUCztDs1;tPGm^@N4D zmSKBfB+5|kwmPETIn+-X`Mv(9Y?E=3rhCJXwJp77n_NVO_kF?l;>P1o2V=(&191`( z@-#(#7nc=BPT|hSv@O5J|CB;^?_=t>2*5tC$ae-`%B71ca6Y>sX2L6x7t~=sL#q@T zJSEzZ`|jvIS^L9)Qv8*6iZHvD-lRQk-VP(?Ya|+cTA}Ke8=ajKog%AEvInE2=^qll z^OpO(6HqzV+)57F`FiHINBajaAIfoc zrVskRm?SBLE+dhM@7ZmP&4xANr^ZqjHoox;C^QaPDA_)@WKx=FVUMQeI1%=128RjV z!J-i7u$B#1iIWPU+&@`><*z3*)>JLNGZxGb#xZ9N9nU+kn|BgT`K7x!l%lo&DDP-& zM441}>e})^zU#V`Iv)SeHu(1~0_ctFm${G!rM~9k6@%7uW4YhFpCUI$O|b>oMSEL} zMBY-mr?O5A_><9CY0TX&5{)jl8x&twi~iCZ6=)@z_8rmMRbSDeti{Ofpp_oPPP>0A zCXdtj8+2hohv+M)J2z!jk&U!pAy4U6fr>xL!|x z?C#b2boe0Dc*a%~kTZj<c@6-EaTJT`~MdPGa=RNvh|F?9MC(-`K&0#=Oi^4 zFX^YbW2?-Fm-i6A+mOY>npCH;T(SNky>MuPtP@!?PP?-ay0~9loG^R- zue@=$enkqeO^q~-lxlPC@8i5>(mhB@IG(GNCn;r`KFVRHT&N!%S$#>{{HA+mI!}iZ(6lYvhZ7G2@tK9X7bKCj) zg?S5WHLboDMCh6HkZwqFa`3DTtNoc~2bcFrDvHQ)I=>vwW#BocfG0K+!e*xvr!DCo z{(hGR+>36?pHq1{mcL11EK%-K>0T8>!~cm;kgltEQNp|a(5|+P_j&L40X|C(vSh0~ zKHX*I^}>U^^jYI{4ni!!nc+~T|Z;eP8S z8r^))+62#HrW5!tUl7%@KpWS_FJ>5jz8Hk5(IVLtvqNQQu;b0}^MK(rO_4Dde#A_U znO0GCpVKYA2dWP z87l0VY3XT7F)82mR$eI1{9!B^f>}3{*}BIl4&&Z7%E+d#BB~E8Ov+_y?X93EPWm{I zyGW`$?C>&SD)b!MoZ?!kwP$C(u5^C;G($|NwZ${sieF#UFB(XIP!Gtk^6X8b3w0f^2f@^KdXcb+Y9E$_v6NUD|Xw* zO!^#FW7n^b!*S1PVY_eJg=deS3|7^Q5@5afj-dRX% z`|p>)uyI;AaWft4b7F7r++{{EM+I(sS>)pt=~{T`YSzc2GmWh0qs=K0gTdL!l89J) zCRRhdIC-2tH@*YRS#zwuJfCGa`tVEiaVNgWUuB*J3A?!6 zi*~Q=+)J8XDwW!K^jBM`3@x#I3O*R)JU8yF#<_f)XB`Hy*{Qy`uln=7CsGCDAnaV6 z)X$N~o*^R8wo=*c^g}We(XP>w=KW9qn(xqwPV+_Ew{3*%97%6ynj6zeO z=1i1^gYL>Y=9cuP3N=sA8KvhG3c95EHQmdRd7tHnS0L`u%BwHun?@A+<&br=)k?{1 zebpu_f*gy&yIAwp+v^9WcgO_;{hLSCm+wh)La@)qoIAWo1e;MyNlf1X`4BLdT^#*I z8(C!9bkwxF)xz(`jxaV1&+V`l-0l~TKJ6{b%n2(>&^;o3!F%)_iijv0T2-)^;4+d) zHuR8_x072_8s3^PMHHkD7wO2=$;a;x=&kN=l5}gjheWN-a1VVRsY@Gf%RREU|Dn`!?dul;xLM4(R7BwLCPD1?3@k@1LMkKnXXQP6E0go6z@Y z7rCo3+*eLS0Ky)>5E+M9PY#tpr0z!Bid82l0v9`0v(?D$%Id>L7?H~OBO24eUT(hP z>m=MOX8Vi0L{F~E-QkYEFK)By&}L~-eP+^?aX$BS*L*yGAyuYwCscPk@kVd_Vp4*@ zbT8%Eb(+(vl}NUh+FFc%7%v|9UusDn-qO;1N?&;Sg0qiu6Jc|1R#_o0mircXGq9Jd z#^ABst-+APxRTp=BQ_G3y`V|qM?hHh{ugZNx?!EhMU55i6kCW6Qw^szZd8MAH}0dK z*3rpd-wW*0ULRr)a}6s_+gcpRwY0_>u$lH)>2pZ>Cq+|Ge)|mzGc(l0{QiX}6$d~( zywEQa_%);0ix;DtmQbFZ%+Q79%#1Hx-ajQJAALRg`n{+wDlPlXnB^Nk5eaTq$yGij z>s6O7Rf-M7ZUM4iK5ux`zj3y1FQ1&NY&|CR-C9W%=f3XhmrRG+u%$_o>c{OS8Pm6K zo#pq28b=BKXhe7f8xfy9x!zmGE?!ku4P{Y08`F?|PA-vmWPXZuzJFdOCAc6%fU-_r zKy&$r49I1Dcc)(ir?>Gep8ewV_@bU>V+lsNMi#z)u5LP@MDweAG+!6jQ1@}wV;p%m zzP+R)hZVWL-OZU_OWP%jHa6*vFdkwbCPpLk~lb!~F4rM8k@Hf6J2)Em4m$DZX@b z?L6@t$!B;sSLE+F60VWCE7k~lYgm|;XKXyz+&w?GIUAJQ+7{AAXyb@7zD&NjY&^_X` zgd=F2Tqqd-RfZ|fBaXf+WxRSi^T2`2)5L52*5aTnU_X7t&+vaIwnkX__Wrv z1@KZ4FmwnE9b<0huE4qJj$Yo6d*S_wr`B@G-CgTq8$#c|Xz_0o(cW`Ccsg|_aQ18a z0_+guM0-+dZ)Id-^`Y2bh3v16zm)N_^eYO>G6o_(00#C z;U@-D{u$kTgRI!Grqpm7seD)b6}gR`H1oZIsS^(;@iI4iUBqmTW1X#K0ZIjDCxWX3zY26S9%8`9Tgw#cN=%qzQ)fMDSRT~LhBPH(6dO9y< z_GV1KOQ}15Ql{vjK5IhKh#B#*HL)ne7Huq`tfx+~P2p*o_!yht%eLy~VDtp@fzTVO zWic7wib}pJj1O6!W}w{QQ>{Q?dB zg2&cI&C@k3&8Y9^TuntTy$~PcpH^NR*OuvL(_2~U7nk^OcHEcer#^4`cxY71_=sz8 z#y_8Evk|MP0v8Bd{?YB~Wcha1D%+d!a`PT&Ow|eEF^g3kd;MeL-PLGX=l%G~w!QDI z5JSUHtUYF$M*4mBTeRi~T{4IBwN6h_3Iv~hnCtDZddcpc$mrvfvcxvM$37>7M}R`` z>scxA_Y;t97@IsB7mt|tGZC7<74{(9e@PF)b7MEk%gH{Y812VS22s^y<7}){ub7)n z2AQUzQ>ka$FOS)<-k2M?{CvmN`YRhD-kByWUX^M?B3DTwtIE-b4?#Mi}5 z4f@?Bexv?T&}GyxgT}A<&Q>%Rsm9y0Wf~km;_(;rDCEaaMNcv7+3sl*zLxV^ufWZ- zqrN$0?7N4PM_E&uVIdH^-)nfk7ohHDtfJ?42JHRZ^~-F;i%?c+rq6rK_hT(9ID|>~ z?u0~y@Yj=_;Bj+1%LMOn)iv7>w7qL_>vTEkgS1uERI{Kkj&oi90k9---~4?VT`56hs`uD3iY2QNmQFF9qM}#Xl;wdikOJ;!~<)%RD`KYbB1XWqN0YKLgn1-i+gn z&kfnAU@d37KG3aNP|;$Y)No!$zA{v^}ap_jKJd9csDhMzs6v3G#1ZRxd}+?sxMPG`QFlWe^3m7{KV z@e}J<-Pue6J)a`ccg?;fz(rc;p57Y z*WBm~s^)jr5Vb{I#R_l7SS4M6hXlsS!B#lp{-?5Mc+>O@S*-)W+q`zG%6XfKS+g26 zqFHCW6ljy+*!lVcbb?XR@88G|_aT*}Nkjg9cV>bh-AnYpKQCNQf|Q)EUzEl!bBq^X zO(UPcdUngab~y3|sSR~#LZN}Gg>lm^F@I+P(MdH{#|1mW$o_9RKgK`Fua&l7`e+_n zY%~ry_YCPF>_7G2ucE25kKNQS@+lXfHgSm0(2(sjOYKri1Fo|2#w$2P8C)FYMooL_ z)L2{1#}t+30-BmXEt6yH%99*o&tJq2sv%R^i*t^X^7U#G1je{isoV?~4?|qT;d>Gl zyEz|oew;}HJ-^vWkbCmv-tcn+mmd@Y@26I*4;5_Lb4_c?K9cw7PBd5<%-T#4?ZD+f zFl@YbWvd^gUDA!PcbYi-|0`FS1g#ITF#Y$4H~~b*~OFASE*svY|QcuC@ReR zu-~L5N*rJ^nt4XlxLADpZ8b*{(##f8*{Ft`dDIAAV_uh(_b~oF0w)(|5 z5^xalqmSiQ_A4wERWjeDm4_50P}q&#frQ{*>N2jsZ8{mOTVTD{prAuXb26J(2i0?)_(TV#<`8om_;eCJi<*0Y0aS{fs7^x{Z<$Ml z;pYfjUNz39Xd}||?!IpfRky89%SP(Z&v{RDSL?0UT}~UT2X^7gP4;TN%4uiH+IcF( z%hw&~&C;@SMig2Sr>?u4SpDn|IQE??3hl;csOl-VW3a;;zn(B%Db5(%6l19*?o+N` zNERgSk?UsLxH2@WI@M)YWX$>VOU4fy{e|Ol@dB0_q%CseM(u>We=-p4Y=C=I!8g7> z=O^KBH7>vC8$z|`Uf^Vi*~e^e>M0|A8wxdasu-pTBX1nqwF>n*{i>Z~4;Uc+kQ2hf zJ#XTGxMtAmHSZaHrTrM5c>VBBum7o2XVTA^R=vWMg!+tYd)4K~O)RLhm(EUdAP-vt8;A@uCco3xpD*g2v(T~*6QrCe z!*|z`NG~q=U3pN%JZ}MZDd5LjaAB8R&mN4==#8$XuZwioIn#G^j_l<8NGvz4#UdSW zw#TR6-Kl!A`ct0e(cVvyZ3@Tyfd^HcyrY8Z6|#4Llp34IX-yMqF#dEqZjH00Etcry zW1({1Jxfj4XgELl7b`49_AL6^GhUQfR~m7ZJVn|K9PcNC`{kYd1wOs+D;_rYq%Tn- z3sQYHSUEe2FT!DC5`@k3;)@yq48HE)0#YKC>RSWyYEBLMm&{xQTIESm$u+-!HrFO> zkHJ54bmX?v9-&Hg_ZhIlU73B!Fg;ZHGo8Gp61dEaurQ;6}weSbB%^M1jSrDkpX@SNcz!}+aIAl29gLbk#vfihVlCfA9Hb`aCyEmPesBqPm+ zR@HesbR}aA2_y8)N5+)&tz$HlTNAbFPrRxSf=C;sl+8!gw>#7v%%b`U*J5>c-di|- z&;Kd55$jtx3ruL{g+7qfDQ#ev4_cB2;l|DeQ?={)gifW%_|U^o*P2K#K7;-}ocny? z`Y(QJT>ObYxDYaN`Y381(!$+uIbrfq-6Y#Y z;)X8f3qLxahm6fFeW`n29*T8bV4%YHEx&Q#`N8{=Zz@25&sqJ3r=)cQkt*ebeJ`lTja`6oc%LohI)-Cw~1wpI3eG z+G*&hcsfy^WnnC&uuC;G^yB2lr03m~ocFlxc})p{w+&axn=*cx%Y~G`xmPg1rZu-~ zT@@dQ7`{jEUbs6(v^X|eN=0~T=n$j0@4@GrjG6UBaA&&y|}`Nm$7Jw z&t7hT%aS`k6^cb8KNtFG<>_`pimF+YfjMI8JO3ze(}Au+5P}`SUljMLeBt%#ll1hc z4Bh60U?*Pe@tY-kI%rw3haA>}wo9gv&>+zI%U`)k8-;5h4KPRo<>Fu(7z{MT&>tx4SxD9gI z)o2iA^kY5$+R?WuW%&~*IY!Flo`lJ}?9kdH%r;M1==XD16{Nac-re=1Ri z%?=}xWb(oN$!ruSw#da_g8zg_(#Z`rX9SMtg86|FTH^8Jz^oR3#U&$p9ca1H<#`qYB4%wnBeqd|2DOk+ z(?@!Kkc*c;WBE*C`<73}CKpmNmv_dsJdCH{u7KKP?rPuuQ*H&&r*4ceZhu^LQlkw$ zCM{PG!gx6-O-`ItF}w`nI-=8?G*kXjX#)X-@XBG4Y)tOX)}E$h30cO+wgyw}=;xO0 zxyL~>-gwl)LfI!=$r<#+<<6Mqi`x|Epx9@=2gRboE}+d-1F=ggzTGnOOm`bYkU!bf zZ8q?m)p!j=;(W6Khac`0(a zxU0XR`P2U@;N{|`sm zZQ?G20;d-mdlU)!+*`fe#2!xHnfa%@WX_f!TB?YLt0W8RU8C2J5la~V`f!|H9g@-< zqjLL^WFcTeogU!K@>vDZ{31u-DwHW0|o`GughncyNR&F9;+6;j1h9s@o)bsGY_h1{G8GnQ3^e@<7+sTg=Q{i$nt%$ zKhE~(WT~e&x926ebg|*L@m5@ASa56K$kMvR8O~N6S8U4!rwrH5YQx(nr_LliG%GAh z+!W^>$M2AG;NS2u9vL6^;%OjIE;P;~qt7>r`mCN8!YrwnS1b@hd;lIV@($bwlFYQ zSY_7Irg#*A8WypfB^8KU%|Ar@>jXK5<+Rbx?!G(6H`F~=Fi1IYr>*5H3HwC7OM$|e zqIi?0@G#UnP0o9WN5trL=z_+BrTI-#Kl3K*-z##7O(HP7Lnk2dKq(<@{K)j zt5z2wt--UPy4O}9Y3P-tMN zKDWG3%xb_1zKh9|J4H7yK)KAgzFN75Zdr(-&UyT2hl3;%sE-hRNjii1_z{4#D%gc; zfWLMq=@j-xckRE@qRh3%;GrgPwnUS!6A6SN^GCjI^=D#r+jC|x29%-2`Srg4!JLJq z%vGM|%pgyX%zSOEp+lK)VzWu|>eRUCZa>Sf)elSFd;O+!u{ce=Q3YbK5{{3l!hB zM)x=oda+`lW)Z&-x5Frlj0G|Dr9&Q6TlN3_@!nyGZ7a}D!>evM;P%bopGxW+;*NTL zfM@@0jBfuB34dA&P7}s`4^GdF*>IzB0$EQC1wz|}1*_Gc@N)9Vvf6-ZQUB`ZL>bJY zGqpd$vY4~flt0Sn0m*v{)#9-RH+W-n7SAMC)pq@Ca*u;cQ}bL_cqLP*?Rfd*H>ZVlA=$lbMG}91z^swd!fnte@aTI1Y@Y}J~wskS#us>2gpkOO;^&{<~$da@$mxDh! zX9>$KZ<)!zMpm*NUx2{kj2ac2Uv@H4r0Pv;mO=BvXU**+!ykg0AM|_pPsJ9isx*4S z(-OtIH=r;QTQbGD*xH@5iRCxi4rN89o`U?5YWxT&%o&7pxB0k9T)NrNg2-t*D)ews zC5mGs$w*&mG~$0;05z&y-JvdOz&Fo`KZuo{Nh-OO;8)9)Luc)@-&E_2%WEsy64$*g zDx#XIqGr+hpu3@^L_KEOy3?sCFe)%TEM1+&dM)eTe~@7^|9tDH&tJK$Vm93CH7HHF zWUk4AzuXn2MT1ZCA~Y*2i){kdN(+JwriFp!zx4@{r+0be0ReNi7v)(Ki=|Z>MNFCr zZ32BrQI?t3I(dYFKOYznzb(`^#J17N0Jm+%aM3?uY%Tw%bd!Xkj&478G>n!Eq{CkFX2s9@h+eXlS2qbw+fXRlrm;QTAPSO}@N`7w#PvMsE_ zF|zMWPnev_w9iDBCrgZFB#uyhr*=t5IIAygf^f8G#J;MQSN1u-Xir$1f$O1wp6xlp zNXTX_0=k$ugyO?9dE`^(Q{Vc^2<%^%?6ew{mffAzw7{9l|EMjT*I!oIbEhaOS_8Zk z8$_GcXc}I5Q8Y}mQT;q0P4Aj9TKt_CF1*6xKBq{Z>T-VqU;3=Nsp{+{A^O>!P2Qy2<3Q zXtjN^8KP-s@|hMW!v*y4futRebE>Ptk!z`~-LO<&K+5S<&XSiH)1ZE`noot?=KQS+1Dz%zM-4NQUGS~%9>x%?qD@H zv1^CgY!%F(V8V|yXj>V}cjl=!3VJotW9)7OKkhRL>ZiJ>DZzeHhqKf4tCCz(F+`AM ze4k!T8ih%@IynXpWsMc3m%44jfX^?-t&j1UX1u+BapBTlihDg_3PoTxzg$$s4lN>S zuY{EiCYKxO6vcTt;CB0n_V61{oW7V{+Qe=jR^!;MI}&hE{1r8zx)Xvp6G%6Os7kh| z<$zlIzfZTE6`MJVE?Vjn95Cz{QLCU7+vcEwrL2A3VU8{6w!Yf(0rE3G&+BMgrsheWX#=O|@73U3Ce-`;WFpCVh+ z)?(NwgHxYl+)NzF!lWrFb2O@*9LsB~tFXNrFyB;^P;Ji{oXdEXx&L!ns>`t>nr{Sq z6R|aD6b+S!4%*wS0TqW_!4V*jN&7NzVoF-($21)|a~qzlR8>I5EDJQMv^ZE8XBy|K z6)0etnHMqPJUPgWc&wYh)%Flu-_)|8t;p|FFA>S9!Wk;?MBCh^!3yhpApn{BL9p^& z$%EbF1)GtsoS#VM@fkFgth~|K^9J za~i+Y5F29L9Hu`SbgzU0SNx1J=l~!4y;)ThGhvRpgWHC;2%Uk=Kw{i4d`dm=-O0{% z2Gq_{en$Glw!CT3Y)M~qC{^Ft9#E^E3ISBIhI;3_=&Z_FpQS=!@26U{1U|@Jc5p7| z^l4Ue?XlooF8d|v^@PpRqxeEv47@hB$E`TE)#-p*J2@klUsqO!jAIjpnOmvQ;Q7a* z!by-BPjQ>f-W2+fbTk5eu$G&z=-hi+as0%+rO6erR&mT!&8d_yR#@jSnLB2x&2JRzA!IVEq_*UGwxjO3nA2@ku2v^y*qvy zN6Z0LFKEQyFITIINA5H{&>5Mt4 zpLU~+hVUs#HHPT9WR^UDTdzfVn_NwTFxFR zq#Ckgn_pQ4QuSCUGk|Ml$;W|CbPIQ*Qp)3f@A_^v8!~}zVCzymYXTnTEJg2M3?M6W zAEj`gMUj9D{fgZCigJ*q57}IPBjkd(`G>=uF6;l#%YM-g%)qJvnFq*fv>LpLZWr;SlZ$RtGu;sWG2On+!rkj zqT?5WKF8R)bkk|*gV16jvo9@DA$9<=i!u4^>JU#&h0djlHeHU2sN$&fVId#8cxH3a z&Uh1@0sqj}fr7bI2}91Ht^1o(@8$Fy$(3`QL+P|fB5mkKVdfR8P?=*EJE|>;`|JqQ zd)a;HQot@?UM(%0zN}me8C`E4yl-~=0hLyg)Zq6~5`AB#M=WTB8Yu{VAQmdykh9l)v&`$RoZ}2K1i`Y{pbllaLZ9l-HEa0b1z45=cTBi*jm7$P+H0iq zF2$QJQfG_%1_z)GZ%TyLj?kW`SeLTPu;2Tl<|)2Gv-KI995#UuZ|3(ttsLBUJxt*{ zhhW|wOvySNo_R9hEbyQ&?O{CnIt5YV>Y}t=RUsWa{*vV(aRd^=6Q$2SPVo5ZMHTPb z7fqL`?+o-HgOHn6WLgFF3{wejVf2Ic#8VslrJ4@z76tHu|rlZ4fXi;}eb(7$=@jhy6C%^(YANHQJVYVMxaKGSXUpHXGL;%9pk)UGABu-|;l za!_6IcM$fpZ9{hIfn;X&wE7N=SH9Dtt*rR$jOi3GE?GR3GT=ZwZj(vx`WskIapo|w z)etA7uD0c~MW4H?a5lnH{O5X+LdGGbW9Q!ILW}vx;AP`$jNLWD$Aw1b;`*Q#%8}Y} z3NN{tUM|Gv6_dL9V-B$cn;5!N`V6`&9OaH z4Jtla7oF4-+8U~@JGIKtO5N(Nhl*9gCN-iblZ(QvPIQCnmt}Kw&E;kxGqp3Y*GGH7 zeg3RagTi8FKsVJ<>yBn51(ZMNVmt>~NufSf++3FO;g@9(&|j0HJ^Z&{?^__zi(1Y^(8-Cj%Bn`Qp$Z9Ht^ti+kYH=D0q{Nm-x{i zK6ob1`>QYEV*nKb!G`pYUH!4Cu{x@gFM8#<FdpJ1& zy>9xc^%$CCVFOvQO~qtGui+lsgb*gx@=?YT0b*tmq0Ba~=+vUkV1BK*Og~~r!Qsf% zB^xmGo8G*I9rsP$B(xN-$v(&-ZFyJq!)_}D0`^h4;gx2|7ch6|dPDC0e0m`ZW64>? z-G{@ zr^a9MiDEP31Qp3Y=LEcd7TrZ$pQz9_oa)}O8rTEtIUicj(_sOUL(R#iGS{~HHpwgs zmJDogCS8wh@14JW^yh6!t)9w3PB~n7mtcVuue9ETmP6|Lf#j(vCB=ba5@hP?_C3<= zvxEIspsdurl7tba-$iytCR$_oJ>3bVi=|e zrH+Nx>4HFt+j-WN{x-{ow3~ifImw_6zHn^Ct81bMTm|bF$pX+QC45oLRlf(n7fz_! z`wF9wGx@O=Uw77^d{I}{a2{>WuJ4z>AlcNze+Z+csBh)vzw>Zr0k6$_z8oJbFR7@p zo7Virn4Wx+IuHPQ_|p#>ir!ajikuda;r=X8n<@7fS#&;S#m%tAk5`(`0;B2M(&n8T zhp~UsMj21AuJB*RIV>FUex0McRRzq0o=n;Swv`Ubmm*;k@J?aH!(7LsLxd9hCJ^G zRZYW)W7Z6@V^x2+EiI8SLJ1}a9t9i)I5qo4CtP<8tm$}VI)HjGHI`HaE{{@byf zKrm0yT;ZzzkK{TVp4Si>RY*0lkAgu@<@J(1%g$T#p(wEizac%75~){E9{B-P$gD*U zWhh80EcM^fW%K~h>e8Bei83zXFR**bNdyDI*$)t;y9EOe4@8_vC>(I%N*|{x0$_H z+XJjaRDN;>se{5~npmsJ>vp0)HVKVi=rQ*!+BIOAv4=XpW^dOJ67vS;6WEbi1Bfg0 z?;M|LG$q=M45_k~T<`hf+Td^4=8CdTA2H@4!yn*U275Ql5B|4@;XGPyV2l?*+sh~W zc_^Ommngbcquyidy>Hnyf@-}5=vN#u*c}_YlS&-6QkC5fYg6+eYSAM}h@dZjaq*fQ zU|$!li8PAf&3ZLsv=H~R_bw)*|3X{Ud(6(-JcfAGKl;GRUV#B6?MlzxTDw8;?WXL8 z(;$SKVGiiCMbXEj3=Ekzj3JZq#A^7Lp?&ZNaFW&zncHGU%dE_cwQT9s;kB|dD zD|;DBRS>uZMh|9Yl$`4lnXO4&NVNrh?)s(KK%bmFNLl}0L%)o<>}QsV4J^5IDy5lO zm97wP=B%Jb<(Z`k-hEbwxkL3Xl76@oUxF9>aU{Mj-bK3?@|D zcK3hENR(>d=oahIm-4?)0{r3#;(Yk)vGmZs7cj{B&!}J%qLAcOi7zHLyZxtNDqr(G z1fyt^dqc=D%tmA?r!aKhH|Ym9OTeT^-}}s=wK-CzYn-gq{i@?QT^7qO+7get&CNUe zK3H8b)c3|17VDKPBj~8Al9L&eXT1tLph`MWT}+yl=G!Y}1L#6r+ug=(*tarT)N@ik z&!R|y#pO!<@EOF7RhvQ8EARl&_FSVPFw;b_p{AFGrvYSptl_avAe6xl7M?}C*0!)a z5_a^d-5gBJV~m*ijszpyryKIhkqZP+mr6V)wjZ*7;6<&Irgk|C)ZIRj+;#bYoA$Em zXlhiB9FYVZZ5>ITxiCM)RC;;H&_T~6hl3i5jM8@elz%jU`|q;|p2Qdv0-lDKzb&hI zmUR{DC@^n2d$E6Vh!FzDkWsQb9lVGXOK5U`MTl;-{j+JH$yZ921m`_1*T%QennlmT zHUrJo1%MhOyfZcL*Oc-ZEv<;(BZ7H$C~fxN94ja?F8{z~^59}MGdx(Y@@2h3=;)l^ ziv9F3=uVl7q$7mlo<=kqOb`~4XcpOL;HEkv#9VZf{>4r+034s|=xXTZq?n(Yw|H?! z+ecb}z0A&wzfcP^4RRuE`23H|%Fd*yR|BQM*r$N)DBSMz_t!$yu1g_c})4FirWU024?Ww7a}@f!Nlj*BMC9cHs=QIJk$Hl*xo zlUJQgQi9b-!{=KOuKi@T`TKQ)#Tg2<*d=M!x$Oam^D3;p)44a_-4*D}p%(2nne(n# zS=RPIzk_dGsh>M->Hy)2I>^EnP8r4fnqpBbfU@Q zMadi)7HCU+^o^YbY|$P%lrVqOw4~E>8sfM8Bdx$G=LXTxhzF%+{s(1+Wp+u5hdzj* zNdOsMU~Ccf4-eg|n{2h?ro6mV!C>>Bee5@ON5dCUMOm4C<;erRGKKf64YA9H*nd{M zxlXQp5PfXQKDYj|d}fD|9^K-3q_;HPh9|yT9NcYC#LN~;)AM^_Zw0UrURx0U7C1ue z&W3xcl$yB%ST5j3lzLm)OHJU8hV9zO|I`_`Nnv4-0V^=KTe73bRa-0Xd=m(ZX}@h^ z8RCOt+%@>%>e|U1(OjUU= zAX_ZB!$4hHS9_l#ogEOgwKt$;Xz6j5f`s}l?K7X0J=7SwiI$L*HYNkLO0wB0FSizM z)dMzIs_GO^mY*Dw1YG5Yi`wHl(H!CgZC|{)h)+&LjmxJn4>=x%{3Q42wJ15)AvY{ZXMcN@h|3aQbywD0f$}ZOz6Fs{Fpx^Py>QgOFVNN%>D0Z)xM7=YK^DL z5qGFWL6vCoI$7Eivqb=d*pL|ul=2Hit_SS8<-Hv;#Ty`Z}lV#T5p;C$?DooWTXZ+5S zVXYd22x9fB%zPX3)Wm{Ko`Z@=!l)Mzmde22;DLP?*xQ?tQb(eJTCft>kA2XoDt_YI z*!{9^Yn4Y8?(_g@AEGGOgnc^NmcxATuJ8jo*){V+$y-j{2j5BRhcxk?TN1P>KJqhJ z=<>ro0l%sqj!16D=}qj-*68mqI17U41_!H;pzMhH6*)@62E)9Cr!r5Gb>_8d4 zRc52uNhod<{d4i0n*wJOrkV95qsA-q;!Q6z;LM=&u8(}w2)LZT^IBvRmn_TIzVImv zP*KTtLvu;1r|>k@T|hr{&T{5rzrQ9`W=aq4q?JE6+dyLtc2)yhQ)%+ggZ$mJmLhs* zvTX-BizFL)S`AWRGpiE@nM`2nIiSBvRC(p8P4>uXRk@xyz}=vQhnT%0Sr5tsCMrN;BIJq7f<|m(P#6RSbW1$;NTZ)g%6bVp+8hgN@;ltZDnJ3e$dvf z50xgDYCy!vSu%FrRjNOxOilwK<%Q;jZh;;@r&4WiAnjKtN2UQMB&&V?NRqYZ;%5!3 z(c*W8-6R3M$A-P}50x8^een+q%aOU4Is>B*iuAJ%kjCW zW+y85R=2&77*OV)Z`kmPr$Fg}XEIr2*1weGvXfv=T%9TU%f}Tg%;M#=g?6L2VV_pC za1Naq1h$veEj$mQ_TMIP^Pdjt;nc$hVqN|1iFZErxR>p z#44c+Z!6kFUZ}7_CU+Z+5}1EYb87abBDXg9PjacJM*zxM3t^vY-=w?2fy}JYHYi|N zI=QG;+M%sph0&TTj3A&VPTBG@gEE#tH?KuE=B@Q7@Ed04E`mi(CKwoqC+195Sckw0 zi2ku@DS-;xrnmf;1}(i>3gx)H5xr&1Yie%JlV+u7&ByWxoq1cQ67bjY z*4I}e{EqV$ZWzOfta&gMziU|SbDeqEJq+#dvSi4m#6OyqNDLRW`+U?zf2A`tG%7ZA zr+l~fAnbpAa2s-2)_y^-YDq7CbMat&yTEx@a5?*cq=wpO|7oj11+k6#J_+ryOIktT zTwat{``vRI+KVv;qu!u0>YM(!;`AcREdFsU`07wV_1*$`zoh558+U?UEe|^>*Ybv$eHg#>ijf5TSzAY4R)=s$ecFPF4cnd~_{JsN9C)Cgb$D#-GaQ^R zR$I8I3F>qUuCC=9A4gEbETtIJs& z%WeGmX5Lm#N&Eek7biEoOvJwxx4sI!Tvfg|p*-~aqD(pG*QiW+=<)fgTs5FhNod;h zmo0vVYTA?S=#UtV+&0pGcM|8yw=*QGO#Zu&%ScYD)joz=uiG^AdxYT~lmi_Z&umxM z_|nrHgY1lCd};5%usSAbyDA24z7_Woob>c{GtxLwW8S`)pwDy6-(LgAFOOquMUR;8 zt!ReVYHh7$uI&k0=qG4R%bCqv9gPFp*ccY%Db%-`-l+aul{{GIw=n!#UG;2QQSxpL znF`@81nj@BO7sT81vDH)%=uvL=@ih`ujSS!tOpi`CN6K{b%k(a{0*BvXYJEyg73W8 z!`WFXl~OFY_8LsI-fYnW+@S)WcSiwmr!N~e@M%ID7&~rD*_Q%)K!Rg|<`<9lo^=7q z=2`YdJ1pp4=afyZ-}crjr2WX{Z|>>z-xmDs+|OD85yRP^!*u`oa;V;NeX*!F+Y#9R zo_SoBzLn<5uGrl6x<9k}JWmhF^A2&t(?Noc@}J^F_rH|9|I`r|vY&&a(4N~AP{N?| z6oXnWu%{Yj?kNl#cpNY2GOjqru{GSrDSI_g-*$6O(hOspQty^eGs9a6Qr$$7)V@q; zg?a<9UZ>_+NVYMKzf$r(Ps!57+K6pks1*WSx-(fDdl^^~f{VY%D>iSPuP)tb@Sokd zscW@v>c&q*L@@*HcYrD{%PED*wG=>#^T3_3eCNW}OjGkzXDR92KTSgU>34%lU0D-u zHxwuc(>o$*_ho@TQ-Rn#xlHa#a>9r62eO${-q;#IW<$j+W51xm>Y>8mfP1;Mo{Lbv z4ydBMb}K#qd#w_F7;~B@M#poatwEGJ*+4Q_(Gd!q&X z@S~zdPIm|$ZSSK_@?GJ*JKdHMuJ~tE%qW>+{%^O~Ae;|t(ypSSbeG5NPg0phHGZrj zb5%c6e9q^fg3W}!)v?q}TdHbds5Nd@>gG@;^7VJ7J;sVYbD^3<&D~NZooT#swEuAd zJ%u-%?%4w98w>LolRjTLvCacnXSHsD|6`)sZG&WoJ$gSM=WknXmkY}k0Zd*S89 zFTWw4!Z%K(1Uqdznv<9F3H^?SB%1pjW>%GbvXjXwJJN1ismu%ZO;<$Xl;KxQci~rD zIc_VX{S=?C=nQNB0roG<0KL$+1=_AC8%Fy5mT%WMeT5L&_o*4k%>F;<;w==v{WodG z)zUD?0?vQ|WC8bE56t(sqxYtVkBrizBrbmL$@lHqrxEi7sMAm{+7NK&jujMBBQ0qsIK_qw3(e-r%Dr~HC;Eke#$5VSK6s$ zu#s2Lx|r{6QP-b`vVaDC(oU0q!}NGF;Atf++il`H2;|B83T9x#r<-%Wv=1#QR!UjD zu+Oq?<#u7NtoE;380=MebtM9@!}&TFW?}D9>KjYe6`hm502X|46jO&fIeSjkPbdg( z&8bo5LFM$2E0l|QYAkh}C`VpxlG*w7zt-D-`6!R3{h`R1hyiFq269f!>@+&CGQaYU zWttP?(|ztki8xBbmW%R85lig2z4s@_$>u<49 zIy$c_l%P<7EAuMNXm4@2vsS9KR;3>Ep7REloU-!)2)1m z58uACqlu&~TEpm}llsn^zM(;xml0w91jV#fZUQkng;DNaZy|U(-$rOc({OV?eu4J0 zw`4MuD2y5RPWBM%-bX~sYB`Yar;jIWy-GFH(*t%FQce>GS<4isP;w){&_dmB{Y*+A zf9qWhtZy`WRV8x_2_ieKbUc(%(!r56+2a|^ghb!uze2#h0T(?U!`7~8u@Cr4id=I( zd&N&@RS5Mq_aUU0AGJ-M>Pj71KsQ81T znM=a;vV)D|6%YO2Z^c60X0OGQ^<*%wFW*JN(xmEQzkD(R06WdrGY4h2glLt8Baw|x zm_>xIW#+g&Y|UgSn*pHj*}n)8G#4oh?eycAG`S|fjpTHhL*H$pOVINN>ToBp2E9vc z%YK$Az@q9&>Pb2n*4bf%(^162AC+Le-ti}}bV})=W|?G}zoa&$ev`U|zil7C(qx6j zDb=JqJ&SB^QI$Dxg&%tK4tCMBCG1fz8xi@}!imsBK;SvIXnBxD2QSeRBhM(J_eyVO za>|T-jY3?)IqM+Z`wshr>3@C;EMQ)$-$aX33Selo(l`QxuKhHx_msHZsfbC58w+uF zIkruDp)t66o>)r9s5>rF&WYhIfs%?onPSjzKDXxVdaDC7y4gR!gExSse0VP`Y0UF2b$0q={$`bU}VM86r6o2j%`A>o-8LA$Aleh<&Kpv(y5E5v@hU|7 z!70XH)L*s6;_$x_ib{wg3lOFZ6_i*t3tG&;P5GmkIXetL%|ato&sl)KTU;dFY?cj< z5r1BDB$$Bv&j-9N6~`OL&BDdvR7^aRv)$BJD}z;4gHhr#a4mCpN+abgT+%$8bbHGA zKn=OO)kPa4pI|Hh8?&iQ%1u$%^Ta&g3Ts0WD?uS^D~j2Offdqw?h0?35)N5KW=?Wt zf3{R;8Iies6CFSXV&`j%cmnI(cY?3m+!PCrMCGb}>VL)kHz-N8!pySj9R{g!4K-KT zZlX3dSarx8~<`0jP^^Kzj^Oyg(uPbG#xt3=_Bead%^s|U9pe6=WGZnvcq`eNUFV8DMvG6P?v!HXp&onMr|{|n>gUBq$naPi=^+1L-Ftjt1NYe2UX zQ7Xi^Ueeu=isSpgkVzB&x%kB^yf26>?H~rOU~;tjulx~5dSLW5MbTY0JP5;c9nf~S zUs&@a*2i`vobP6kuR}tlN}lzSlY&bl2!t7%UV3>OKVNlu&#}Qpdli$l#h_a$NJPd( z#-NSU*|$Owim!1gr^(*D`ldmcbAXf~3M)#Idxeh<4=)VucYh7WVZWAz61zN?)GJx8 zK52H0_FshW_S5MY@zhpLHRSKPObN5zrQyi0s18XR>NRj$_&A9Bchw!Wb1%c;6({O; z7d9;!(h*rLS!BWP9;WZCDombA-3FPY9+hgZZpLZjRzkx=UU~?4bc;OO{N}J9AuB~Y z6V~|71gp@w%Y3u9OEQ$Qe|V9d>b-C3B9V)SkhQZzc74dJ{%Fnt;OQT;7VaF*_2ar6fPt2rrWCDc35XRVwC<>FL<8^ znf#fnp-#F;yMb|`G&UfeJ@q75_C<`uyIJi;_}`gy9<3eQUrtF#iz1`7fl3>^U_F9OmZwn$ePV|6D1FH!=y$H1Nf^Oh@8B9cZ6gRZ~nUn6qg@WV*LW?Q<#^b>&F{FBcXbdit~O> zHp5uPwRz5vophov)*VaSAWd^6SZ;O1|lQFE~qh%-Hv< zZX+{UK2VdphK?;(53@6HEQTvP_xhmi7PfcVe&+Iaa=ftl`GJ+*m_uF$=@KT)==&-| znBl5LDbw>ZI~v%LQGCqTiGW_av%ePm4p+JGc+`;LyYv@@Dktn_?hNjrVNr{TE`8E( zgsv{Ufmz|j&akdtE|0G!!(60sySt-j1enJZ-4n2)$Cxb@W31g=%uo953bQzIpaM1| z(IhGyYai!8o7xEeYHV@8=BdEA04@ z57=m#e>$QtWq#y`V2AgP${jt>@*R+0X==R~RlhM-ndy&1yr`&qt=vBQwZe(tX-U z>X$xkxNq~ZH1O}Cei>WV@>V0#xZnjdMNAWL-m_8SorP9jea3K#;Q3b}J>!!u_>B7- zUD3>C#RoFT_Fn}y*NZ{zK$Dj3&KP}_Mi-uB-A0q&?hyLIg@wT5dpi zR~%lx`&5^l_q)oCuMTAd1)h#3{HS6Yk{*i0B*vvmcHJCp|VAO25XtS3y4QJY3kL;61UPjM?| z8XW!azODLVbN}Ta@)@M=%&&pUSh7MsNj)#&l8Iq_W~&N%F_`xm$8}CFkdlQ|q$hQD z>QmC9yP^NktLfunIP9QK$oYEZQNaZnq! zvcU5Y`$T-V72v(j80gn4IeMnP&I_ZE=Y(LK-P{la-0G%z1+A4oZbvFj^fn(+7Jtlm zR3-Z*fn{Jbbe*@dY4MBrmqu(Z>%l$wa*5%hE~=m7&Tf2VWEGj5`CArE719_=O!UGc zQYYKKVoc?bn?`0I?y%BqxLf-LZ(?iTH$R1r{#$*!#l{+7Wrb^ zifJ8Jp2%eq7%lPTI1u^r8y-TilETaZ&5p+Hw<#Ndmu8c1xCSf4+}SR6U4PJ(=p!xF z(-HQmZbdYNVF2Nw%(-R2vEd$^`V4xka<_?1h3yH(ZMh>~@s+OQ^bpMGTV~x^x8Wnd#ot7fT}uB?8Wn4jn!oOt1jek&$>}hka zc`UA;3f7M;wN>)F_RE12dU73u5{wFhjhpIrW${^oJDZp4PF7AFyH-3pF}?XRiZMwr z!l%3;PgeRQ?VLW75FGU=_HSxqrEb)6thJzJkMTvzF+uQhp1}j((=|nK#wgKd@x?Yp zXA$Ie4^FcaeH+mWe2Yg<$Qh_pe?BS3BdMLtZTy#(ztLnXpX=~v#!+hp^xiIb#)}g2 z2QO0oWz4`o9~HRUBxM&y_89{?_VV(l|4FXrT}dy@YXweMiBZRh6L`*-81IU)9A*;F zv~F6MRzId3lg0{f+CTD^b>4}XjZvMTa9>ZY;ocwWx2J{z=;x=An4w^I4bs^0y2vmh zRujUm--s}^wiRN|A8(S4f(Kt-phb#Qf`whA)Clv)8clUOLvwFq4qpwcU}6k(R6U8K z=S_n-i$>7-#<2?ZB&Z7oCn$ceqO(&ZhvCbY{=$hM{b&}YSC^MW^pUh~BmOoghhk}k zPu3`<#H^M1VX(br!br~DDl*GD0=u!rLeO<3@F^d!K#~y`s@-~k?EhV14_YD(PY73F z3$gg{{Mjd>LfMYe*>H5?CxR!P+H67H=6gZx3ym0sde8NilJOKj&4|{%N{bK*e-9b# z!s5t8ntFSSz9S0pr4qp4xMd6!UCB#3mRv_KlkOh^cKF?TbqoQ$c2WJuThwvd%iYWT zSn%RBSD1h8uUBlRiJ0GMpO6uI{w4S|G2L$T=7IDbm|ihI(-TlcNcwzn1dXmzx~{u; zASMSWi|4=bDy7-!kbaoULJLrA@{c0LDwBfV%tDclmy)rHA-;&FQ&9>2StZIb`be7M zHYfWQN0L>|?7PC2T(DPHobc*kC#}ghiT4(>@A2Yj1G&t06cP+!-y+rz-T6~toeXNU zDDnVt+v1N1*n&x7oJcI@Z@aqWVub|Ri-`2ZKG62!nQL1NPf_7JlSbynM`p4Af?Wy5 zAjwjVHibI4-_deD@9Qp1 zKvN(&K?9BlB&UfiEGvs@{~ztXXJ>#r?gL{WzQtJ@Xa><(;%-vZjqgcdDdzWNnAnp+Z)yA{Wm z@9u=^dsae%W>BY$nwagK%**pb_EU-=4ZI75+7~B&H zzqTd7mQ}%{k^Mb`n_h6p6S)1Pzbbg3H1#VcZ*j&G?wIf{+izS#6ml+KW+)T*InWA% zI=IQkurM)rJ++(~n_9O2?ng$u|A2fY`j~R1Fh+v#JI>IJ*_bCT&1HqPhESrwx?mq} z_eGV9$fe*(X@{G}%6&!Ua(2-{Jk_fUtrZF}GyfCp)5I$#GIH zTK+F-{;Qb&VFO>e%4pgfl7gxtl{JeQQJZ6`R_?zK0#YVD>lut=42)K zYevIGVcwhFwW`YPKfC`DJ?0CSyYKf;Ev8u+E#cZeOYW#6))(W!1FhvSyls6y@p=1g zTAPSp@Aunl$J@vx}cgtEn@Em8=#w* zO?P`GR&O)@QCm}4^Vph3V&$|`qWyU%P11=aovl#QJguUz`V75O`sq|&k7Rz0PD2OJ z9BuXr;vMZVB^x7$AI8sS32ELg+e%MWb`sS}voQOgAEb44g@S|Wesm&wvY!P8u7L24 zI{y-3vQFTI(wp3nautYCckuLAE%Zcb&cytFKi#c--)=g4TamEvnRlDRu6MXw&yIC2 z7F*Cx;`!4;ni#8Zf=et<{j+Ck!`D8pl0F;4dsZ%)+C4X75$vW(SkOH^#<#S z30efJ3q(|iQ$+6Or!!{mAE&|rijcDA!lM$xc&_2}6^~Q`54)dT*k;asbK8s^)GK6b zaF*|e21n+=w&b)+IKI09`GF_<4kUd2my(#$2NV!fVO43j)b#oHcythzcU-@}4%0Q? zBXKLkF4tAu+u15Qq(GTM?#C*d1Q@`t3KjBoi9%o1I#Kg;J^%-gB}h z{-DizTRb3#+RYo8-|vWD*TIJclVF&^7f)@n)Y+y!(GNePdi@?e}+`Y}+;2w(Xj1PPT0ulUB?I(KyrDlNN$BFH2MecFq)~)u#UDUl^zS+4TD0tUE_;X?$p<-!J6ESQA*wcUf z-FSvx`zxvi-F-HIe`$;B4MCdY&$-^>j66}kJmZ%45B;U&p|*vBDn`ewVIVQ#Rgw@R zpmqcOz(9UKjuun-m#*j6SQpz{4ZPbp7afFH`f7mPF%0Y~P$v`(VJn z@x&Pm2dNuzAExP0sp=soHGvilRbB~=$52XgK)#4C&ScN$VS$C}WKXsv1V5{>C)jR+ zlAK`?YQ{l`0%9s7xUFmyk?*}IeGMUjsZ(pfeCf5wp+l4iZp}pdT|XW<$kSOiFa;@t zLmobOooOaFI)S!X~(<3Mw^mwpa#F498v3-OF5`S$Q zaYbGJLudE29!%=pjf3&XBC>y;$o6-0GC0$r5#GYFa5O@EU&2tS-o zZh|5FC!6Nl>5lCbBks?Z$f@{QAT)z%t?#HtzsZlR$<{1URt91obawW^a5^=bWZ0r? z#WO{esB89%5Vz2j5fLlyP$$oyYY7D4dsION(-b}PFWE;aOSq@#(jSBr{?SX^b=K zhpC0!ZR3T)G-=Z3Uu$g5Y|O{CAJhB~!2k%HvCutzv^ z?V(P$9v%J61bS?B1Dz@#Ukt`p^1PldjD7{ya)VnvS{s(2rEA908ia{NWUJ;E_J>0a z_eIX^<4Dq|BdPv{fs@r<6=QXEGY>zRK~&@V$9ZgLD!$#ulgIxOW%`#;Pk#_Sxn$5J z$i)JMNZaEQ$@JS2m^(Qkn_2HpjP?9X8~FdQ0E;vP1jni0l;o(jAJ-306Q2C8(`89` zcCa+SDn?>$EAF`@;h?PGc$nsJ7D^~sXiH(b+Cnq*Gq#z&ru*pL{1iINOz)_EI(T&{ zvfTCXllhnhyc@n0Gl)rb3;nfrmZK?ZQ!b1HhUuc8CE9CY#u+=l0#&f?}?@yC|51&Q=xzAg(YE3DY+wZx2Eg%W;sZ+!kRT_yaL^REw9P z&loWHFP9C2N#W(2NcyopS$~wx%32KiQ^4#ys9|aw|8t;P`xR_K+FQO-j>a7}mmJyt>3y~0ufFBaRXlEn|!7Tl@6PhHl&=lGyq%55PCt*L6Uuo>f zb)mdcd;S0R4Ww{P@bb08-r|r(QSJMODbCqSWAK!T48;|Dd2u_sMphtBUGp8%1C+%pcb$PEkZv8Rsvww3l4l&{wn=A9ubZX zT)x`O+vMAh6V~o@KiRbT%DMXdJyZZbSO7R~1G~Qhmzl4)4f}TuuQO{S5>DAaLnyfS zTBd1h3Nz|eaj12)AZE}MjcZ^t@PjD3C+~(A_l7)7 z1cdQBgfgyZaxS8Y3SIo+Fr8bzdGG9(U^ z>8J?qpjjK)aJv3?b}hR-G#08jwqj@~d7@?V>W1`WO|WVI&JuLp@zCi>dj68a+@s}P zRR!BKMCgHy=pCxwzz=3F;u660ckoS%U3=@=0?L;=>RA-=DuKYqWM>eOYFrKIV03XR z0e~I?5r**>A8$`((|(UZBh}$G!ldeN3&<>7K2z}L-6g_Mg(pW;y|-;(aujk$ylGpo zDkbo-yRwnxrAPw7p-a>rqZc6b45vJ)uuziZD+!&%s%rX*@?RIQ_ zGP>J$qCsz&BMdi1?0f(t@emDNMQ+L%FP>S;O=dfzgb3bFd?hN4L0Ou>@BqIZ4^bV7 z!)6Y`xzHOr>rq21gDEK^$&Dz&97XTsbw`J~F%^Hu7If1MPY3)p7p9}0t-lz~cVXtd z|08yoegkg&Psr=-^ye#j3ADD^yORaK#+J6Ot3TfPEF*? z!l*5g&K}XR5Spd02a`{N)QoXb$&&iUQW`q!q9;bRfDN1n_~AjM92BgXrtvG29iC-@ zP@5ap0M94_i~;dX4h0n4280L;xaB?z2W)gTJ_VFWSZDUxCg9}P4*P)6Pw{_~@Vpc` zB;*6P=vY!p`G2oDtOR1nD1%wWCS2&67>UP$ZtdiP&Uu!uQVTfv*=P+)lT4)Q3S~r@ zF(w;kqLWgRRUOvufLItMDUpFA^p|8|ydEgyy&Gt(3P(lT4`}I1z5My3a-WJKX(*-& zKnKE~bZ6QUfTLmXtD^ktN#sjh+L`0_!e`u1vlBecLaxs@yU#sk2s&xcRqoB^hWmj| zmF)%MaV<%YC_lvt>7m(~-yZTynZ>qB)?Di7=ES?z69ETPxG#%-WmHmydBGSkSZYib zi$1c4LB^x$4S;H7I`kI{JzI;I&Q*joLQ*?7M5>jPpnVKYzVm14FF(u6<^aiw$sn?9 z7DW_p?t#9ze|FF_4PH_3Jvrh$O&tVuL-)Og=;T*FC$ZAbU7~HG&1GVQjom$@U>L<1 zN#Y%QecBeJkLuKiYYFMZ5x{EfE4+WUNKX0{n!6(Rzv{=su+;F*HK?+F*#xVz1AlEosfQhht;OE0s_jL>)E(4r-aVj(AL4f|+@TYgD2G7j>rMC`Ca~)8t4GcpR#hfDk*;0elXEKypN5#s7+?nO>PilMPlfx zJl~Kbs!KO%!3HZn*(y3J=N$W$J423&>v7$?p&x8UUSne{jO~WYDmfa*f)g=BTo(Ck z3WeF0!uT0bCxNM<_8r?;8i40?CzF#!)=JZtwCQnGgDhpN5RN!Bh^;4=jRjHQd)%azb~Gh;3xd|=p*)sciuU-Vq4KK% zAKb*AfpyIfIfvhcjUJOv;OQIQrkHPhiUmu-<1K)vU7yV0?DbS#FAs1DJkIFF#+f^q z6ikrm`=wP6bDQ6rVk!oB7n4yeMNWilsK&H*MZyjU-#qRCn;O; zPN)*KHA@x6R$}CkA*rcpXfEjWq?|wo08*0f8^N{NF>lK-i{gurtIqUH+kSa)Dm(YifXh)62Sk*vg-}S97@`2Zu?cPY^>X^0YR3FL z#T})qk2DzUsE}O8VI0`0xTOhDaM)-Xpj&fzzjXXZY5N{KAj|tzBsPRGsiQ}s`(r9q z0dj;jlhhcFcO_X!%d^_Py(RK<#T{nfq){oHvl3T_(CEta8%9@dZtE0O5h_3DdC!Uw z(3>-x;rbhh!hGW!Za}J{PIv|Vf4YVFeD^B~w*yb>Y2%<=33tbF(Ad_ljP2t?iBJcf~oyAgfbrrME!Tnb*Uf5gYGl&R?~m7}y#8 zqBqosl8g#**_(`&@eMBOE4l>E`($Pr(AE1H1$nWKV`lcZc!_NXqYbTrD~Yg#6O>A4 z4SY(7R5$Hh7skz0AwzIZ|I{Cc+5(2xy-{Yb4UC^y&eWq7#?&QWT3_jh|r;! zKE1uV&42sDrjR3i;l@i%!z8XHH$vWH-Q;4&Y0}C# zO~@iB$JhRL z6#m%(=ZiR1s7=u21eOE~SOw3}jbgBnmKaNf1uOx)9+I_Fn6Eu#ZS}UMB73NB-_aF< z(6n;p`x+ZL@8o|qi&Xlnpcw7EPl9o_HS*gcstUwRTkh*l^ZZL>W~p}N7cpPSw6U|f zm<@Z^m5Gtq*MHrYBoC7Pj0!U==b$&@c{xZzE$N%$IRgGY(KQuDt5MT;A&-hQ+adgJ zSE(Sp0;0`qH?u$U+WGrmXN+ADK?AvTPL}Q9H`2NFMn#n7h{4>iL9%Z23GbS`$ktR^ ziMQv@-_pgDVUKRx+YpnfJY#&Hs_VH-Dm8*J{f)enXa6?Np;L^enp~wgO^4K*PVpn^ z)Q>YsX=6GNaYCHf?IL);E<7gK>_7^JIw0U~t!C54(pyCK9#82uXaTZ{58QhAV;?#Rm^&@b@kk9YG00q7;}^~ksO&X zFf!kB`Y9W7OTz$~tp0U_gKeUIOus6p*lD3DT+?_2R3FqK&&K_tm*Jh|mP<6BN0#bPjA{=t88W=*}!~O$!%- zeQ2ykd-9;!2{U6NSK}v%fgnOgMvF*GTLkxDBxur1aB~aodDR{$md`{m%pY;8!9Sq9 zzU&>Fu$U>bHH4RBxm|)(gX!!4!^RGngPdO;8;o$Ow~T#0&W8x#q@fI30dt?J7mkkB zK1YF&sG&vBp4ev&kmh>|elN|GzGT-Q8BjVQyk#ZcRK?buGct!^lz0}jkWx4~tWI%= zsK(UT4E|%OlPS|yD=EXYHHV3WUcSgAZu`CB=(+ew=hL`^OrAo{+9e_DAQ*>bDkn7;%|5D03IQq=$iw zPy|Z#!O)m30Ia%^7RmzUB&aGcsR!3GCoVjkQ3aVxqPVJWpMvC8Fa{gpi3r`7@W%uq zX|?Z-c>lu!LgfUub4L5Jk4U8wRD*HqdLs)yu*P%1eAlj?j3p)+Ou6>#lB})T(Ttxh z^azbeov`B_`hJtpGE#ew{!L3oAoSK_VmRgiUtmTWo4MgyTy)>S55Xh2iCKVqT~?@M z-yJdz>lik%{TaqCj~d@Y_UFg%j#=UNwk8jZAWoZ$^)6CcZciB8RWBf~E8=tSal;L8 za1#H=2SlW-&!t1nG2L3S!>au6gRNbIU{<(aVR&k*BZsv~kuGPsdS{ohI`ukhAFrEj zy?L{u>DbjYu(7+@BZ0Vw$R2xWsiMmyU!P`QiC~?`51hBgjsI2W33xkyTBp*7y8@0w zTu{H#0&#H$H^A-cx}eQ-%34_IZeETr`jEzjirynF<1?tPeWCaBtyb$VIMsoI5EB~@ z%Eb-XAaiqHg6{4esMs}3{KVfj5O{=I_#>v|C^;fj34Rju4?JwR+#FQIi!9X4zn@=Q zAa^adtNmQa2UCj<`WAZ^b*_`rfDlZu|F$X8vw;1x_iB826=U7+i$+DBYh=Y%hgRo^ zKmMtN467@+jitjUa&!1evAMrD=ks;= z^*u*O=t`TF&Pf{T73ad>IRP`TdIJ)8039UJgCrtOFzGmJ@!V^ZKeWcNrq&lBZ2l3h z@iJrA$-@@$tZl|RfQgjiC~l*sB%uRI=e#hYgs>H6)Vl$D0*{ zqC6O#EUCcp@o$$(gFymk?{(|*q32UMs)Jx4Gd`LuEQ+J4SghkFYK%c$VUd_kjj(5M zrqzeXBW6#LHRNk=N{lsEot@~MKi@)1s zL`iwVPIk`|%n}X+HHDSp)B>;ZNI8dR66D(jOuc7U?^Rlx@Ci>}MQV`qJWF&iDyyS%eubq^$o6}A(UOv;v2oh&s}lje!~;-CYA~ZD{D(Ys;W)gfy`3M z0H4_2$*|N6G}h8)t#1GCLYfYG;E=Y^qQ~za!(%j3P^Mr6*2wnuOS>4{j}IbtAu zx=r|%`Kxd0sE9f9ZQrlXBKQ6eor1 zTl>08VCogG2`#{l045=SvX?67{<;y36~*=jCZvcTfuXNBzYcu1&y848xz-b+)o8OH zjkI3!yK3D$P#&Jo5w_~vc65c@$8JidVMs<1^)IlrYOl)?UaHndyqP%h!T}N40dR&jY%+ySf9{xm z8GS`USGkLCXBL?PEvO=rXq7}(C!r=;!oIbF>MfT80vZ=<5Sw{zDKNbXx(|MpF7XDH z8GH&Ngmn#SaveK6)WUIxYo6S~#17JPK2%Ig6agbtRH_SEC9WBqpyY{Yz+LC6UjOvo zYkn)#rb(#M1laMh6Zk3I?d+IXpOtmYv(4`vinki=Of4=3H%i_oC{xW#xv)!Rxa#Bzc8V0+I zXIE8b6p{v^^kyae(0V@(QtW_& zplQ4g<~3xRSesuC@Y$=q6~No#8(;pir)LoosQM%c{JdJO{X`Vu20X4qs)Q;-)NDBy z06+>@3(u4p8u{uQB-&PBNpU8O49D6znm2#fsC<6ZT;JZwIJA(dkI}QGk)CUBtjOs@ zu?|#GAM^p?ItFSmh|VQ)UCdf2Z0~3z1v zqCXsCyW&={O*@+L$t}Wm@S*hL;P}@z-An&mb`sx@motVKD3YOJK^Ai6EdO?;T*!Re zSOTI;(y00rg)A~NgK{MO&hPRFWfXi-6eHLf`EN)3OW2mG-_*KP3Ct2Irv{b> zGV%4?d&))@zT7}}cI;)(YE6q?6T2#ab3UL4e&21pMfsZRNd&U`J3xWL0ymfpUW9>*%_;etRi!k6Y2PDn-6)#@S$6Blt%2l9%pf0VQDJo*KOE zMh}dm{Xi;c_W5jSM?B0rp}ZK0A#0|lki_Ef1XgdoY=)irxmgEsTTWZpEDj$*_A#@? zk~s{r=ith}QPleNOv&7nxdMK z!y$HJRZ7_HH4lvxgb6E_m>eKgT#9*&>CkCp{x*McaUH!m;t)WHt?1C0tE0euAL0rw-dfv9}!j)YXqB-3=ax}ia~cd z$l>_D)w1?M?PH{?m%UrL4x2fDztulf0I%Y?vZGba@J3w38h81MtoZ0)RFlG%ff*{8 z2E)wJbdJgthl&o_ixKbP_R` z>hO?>mBX}-7IJm9WF~^d>S5MQe+FSVL{C+Ab$|rsjPakYf0%F?rP2Edt(2;szBA{7 zDZW)5^rcf8ZhmOUrKacL#?Bx9qw}t>T-O?vSVs}Nzq6-Sah29VOjR<6I z{Un?UbcQ>@i}#QC%;Cf3dVTl@etrj2x=Fd-!Ky=7u0KsSU4^|Q z*m{FCDoa= z@=r3I{&A|m2zUKKI7~d;)?RoFpYU^?UoI9SBEZqMSlV20BE)F$yA(iNm2HO3+5CvGC3QlKrX{aezF3 z@?D$5twzhA!9cp<*!X8Q#}nxv8+*v}>>oLP7V_||;VlfY**LPPF9lPwds*BmVPJPf z&S+T2q10VNn>sUwy=o=HDc~jr$OH~|^0V4yRdHwQonDw`l4}Eh;M=(a)@WG-F&F!? z7ffa)2rJS^&p{3 zT&lR_w@22b0$#Qw1KB?xntk-C-@<28V>pLPwM6RU8QbsAx)aXkEKSD#8@!gB`}XLR zK?V)z-Z25J41d(F_W!8e_VZZ3`-Qx{kXjV--KiVh;Vcq0aL+d8fSs$gi?%Pl*u#Px zFbo%Yik|Y&&1&m1Sx_uIeT6{We9sGG;jurV1tFH{CQs+yM`MY4aBqsbQ{$--OJ>%u0kM z4AG}(KwFX1L$X{40IlXN8)k+M-SO827APHTD}Yuo5ziklXf54 z5pJ7l6cd>W%&FKFOY*owZB7w;s%q7W5bgShN@|%%VVq?kKSYpWs9wq@Ye4|ieU^^P zD>V_dz!CiU)XVkWdASaFm$L@%Bu4&lrP2}R-a!9}08Y7o3X$Rki1B><{P2A{Y5Pk5 z2;6$7e6`he!2gmB#77Mz52}rbI_&7d1Iwx9?hG$jGMMvadQTS1|A@fVhwVuIiDv(& zQZ)n2YIB(&QOb*VSVI%;;=e&u>^#OV%E7`*N6tTbx7B_t($_Y9$ZF-(6K1 zr$RT=N%5b4Q}_ke40=%;55{C>{JXtHXPk50i^OA`cd$G%3%kUvLIW9%Bd!+rAFg9K zbY=0BBhT?OzLnY4$Hl83!8X$QV~ZrC$n|Y3l3AR_dmyaA6YAb{Eu}*X>1qvhmr=s! z0|;Ah;~!jpF{N+#3~+74;bS^KpjX+dF$^e=UEV=rGCj-b0dn*FUB0!WZ&+*hf4}Z? zn300G&DeVVv9=XZth$*K0AZZ{9Q`>w307jAem)B$))dfdKlGsQrt0&7-BJJ=R@iBlOufo=LRFgY(EFJb}G9o<}KYGHgc zlPt^PMc?A4mrk41&W%%CqNNb)`sNKil1u@~v7zO4;)@1sB7xZKwx3FXnpcsR+c&#k(UtMdjl<@`Ds0p5b6%+NDxO#d`R;b-vdw7) zpmm+f&?~!?Km7IRmC*H$-(k6#fPU<+te%R)6fLoXe}fN5;pifEA%4PCyJn5~F`kPmy|i_(rj5D7ie;IIfOU#XqEy!LPQUfn{ASoo zMWtqZ)$K-vk=^Hxc5eSwZeq+t# zBuaQv8F`c=On%yBaCWN->(MeZ%rvf%_n!$o2grcb+XRPioGVO$aMEbDBXB6 z!^=?kL}zWxVVbkmOUuFp$L1+)DfFL^I(81y-e3BLhpP75l+K-lGj`xUQX)&M&>pA| zJfH6!*Xsx0%zYlwKhLqnJ9aBquN~B``cT%iF9AwtpsK}w!-&x8uJ;Z!C%^AJ zz&qb30HSSXSC8YF!RWbBUc0I#&o1lAEj?3d11;b37;rD(A#b@nG+`de7Z?gAg>dRj z*#0O#QTjFw%oc=;EuZMRxS;`U9851`>EWJ~8BOP1V2n@4r{E zD-mLia;Fz8KU)X>+~X~S*+DL6Y!-)8o?-@I&5#@zbw&hGSJCDT5C5(nUtFpYJyes{3P| z@(OHX+VzL|Qd>!~A%bMs#f(v8Tk56z?9F|$kGVy1xdT88q%cHQ^h3f+z`=RpL{gm{ zJPO>%@UI6A>`OU0X2K@t3HP`HE%-{Vf68eci(7lPm(X0uvu_pxL#|o_mfdWX2qd5l zS$n+9*GdcZEATa+RqPf`m$7#i>v^v4dW4=*oj^4$_kzx6vSDay&EQ(C!DtZPn6S6E z1rG!s-5)SnfuM!<)qMZM)#rkb@Z5E%dzjT%D%!e4yyx$9g_e&(yd$>$Uprm?+fpK* zyuIs-ey^ML%jH*}_OBNBFF-sI*ZP9Tv+~b%L={)A$`=Q2sB1H24$-CtF4O8OAXoM) zM6{oK{OtJLW-5tD<#kJPRlOtI$%*rL|@jiTwYiIIz!tV#G;`0ELhTHx&d zX$+1eknlo>uHgaRD}9h+tO~Gq_f|zZAW-oiQKXV`Ms=DKaqq6ROIg?LcNd%JkJU3M zL~*32Q7BYBa<7+n#`);~^l9);)vIOXXVQ_^-7>)_w+{PkxCy)LO0kWdrri0j6rs_8I))v`ZIik~rK>&HGZJ(6=Hc@pH8;Hi5?(5v@J_~jgwZN_0p53dpRtWbua--bLxY#g z+lQ6JL82MK#{SGWAzB~{$egYhPvItoWvMzQXtJq~GIqvp6gcAg=j05e0qI*9)>>ut zf5hQoGA;q=O+QL47v`&j`==}81rlHX0vKKwk3ZKr_bJcV8t8V5zVElRc7*sR1gdp} z#P%%9uls2FUYat$2jh6$T@Hh=hARh$i47FZ6RF-$-yVSQtl8FTJ^^^!AMnD6SC8VJ z9!9Yeq=GC4HHIUOW7MCIICb}7|_Ps2{13n?6J&x}kZu7+<*9#TYkdR`D=kGOZo z$9tNW`*80r&UA68YRy1ulRhN-$8*0&YaVaJnz(QnNmO;lrEde{_=rcGZrT)C1?_We<|qtM1vq-vD@_2-V9zR9c-A9k-)fzo5$!c+0SzAPEuZ{8Xi}nU8Bcrw~T4@afiA z+xuPgmnj_un{Yg$>)m)CU;*rPy5P>mQjE;iEuz%AVDwJnqJe#^m`upiyk-zH^jHLbwuET zV(>}21yRw>CU=o{IbMA${w-Zvhw{?%jLq$sV6rQ`qFNNP(LxNZ<=hL;PiLR%jD^*D zXuAMA=cXr`)y|?qoU>W1$b>yT<%i=~$=MoaT)fmWs@3(v3d#1{POW!@FKy6iKr z8@=3TvKBzqjKTN42%&O#JHe78x6d}g>Q9raRg}&P+2Kpa|1V)-tonrZU1WAS;a>iX zYx6sFWU_7Czxo-pM7Cp0+LpcO)K=l@AD9wOB05xccZK9+1v)YHnHE-9lhw6Z5=UnPV* zG1>sl0!oRIkV_;MiUE8X_QU*Y;3=%OBK=nXd3;FhsS&l@=(v&%C8O66${j713TNV& z+HE|iqek#U)3fFgPrd&FfjVz}YT}$w%YQSb8oejg*)Yz%S$@R>H}XoNPGI196bNWJ z##>D}+W2AqmJ8qM>uF}>_ilW(*~9IL(oVm1&)#D*XQarwftchZZk!DS4?1@gUJ-JD zKWEHwdoBqiRxvv~G(-xJGZ<0!ev*7XsmAwH8(9b=G`^A0c?FVf>xV=Bu!@*rKt;6c z5>s{mrcyXdSJ0&N9SXQT*;^$M+U1+34JvMa-vK#2!fZYfY`}b-=|6*aSflt(( zG|YC3+A>8XU77K*NeBlK?PNjjZ;$INDrOfSoX>d~A*ueE8D^*K3wEt+OBqs9aNjUWZt=r2e74+}u>2{^;4zK1ecWHdvlh%@rrCg!E3)IQHXtFbaZ-A z$<5~%S5KBFY^-pCh^w~hIq5u%KT{9RUS(P7%rba0A&*6Ct{rWbKr7<_=LVfF_3&#^ z-&6$=Bwr^xCpH_d(4W2d6H}WR#5#5gf}C0mWWv{D;g{1njC;PWzS^APPdGqlAq#n> zJ-0o~NGxq(y-2jBzZK;bF14J%!82lRFs5xCBBM+_y$`qH9Mm?MN z?03csg*hkMxD0yl=qfvm(sZ6{tZk4JOQ;s3HbjY9h$3>Ga%1Yt@Q( zQVKWHp`?{#5T3-(Spwq-LPOUNg1Q5LMaAv1QT zpQ`cmc+eK+A`l0>#{~#4Rp!lU6*frzn^+aom%0T{8V0K;BTZ@rvpy6ee_X^1wEpw! z-1&4D?|gxl_*y)9Hd}gtPrIx)`C$a4(+dxMJF_fVE%#bBRcMQx;h7Knt6QC zGFn|S3V86ovKRhBnGE<`5+fuM6L*yKnA`M#(sHOZ5RRxdeP9J&1T3Jm`@Z!7nR0>7 z&QIj!C-OSOiVJpy=z6a*DjvV|5cnKgJQ}I|c|GL;{cHeY1s>euw{)#X21;WpbfTiU zm1#(}6l<`i?PAh6^vJ)~2e9mAE=HuztS)e_CZd!Epw3sI8XYu8LVUKNzT%Gh30EwEIrBg}r}Lsa1DumPFmXPDmj+CtCb0M_X-RM1e+-B4xK z{>CY2f5xr7Qq|u+_EMmgwv}?%^V=E9(Exm_{bU*-aN*+Ms-eB_e0*r~ERr#{KBfd75gJRGSL^M|-v;Xin(0F+0rid*FwK0G zEV{#MZnm23qe@=>DiZKaN(7Q6xj-Pl^a=mYN$VU4cnba6 z^wG&L4M>y8nWP@Fm}tETGIlWg!POmXHc5?ucN&)QvL+@1r=lWLq;f|Z!MdhrMHDod zNx_QSv_U}_XO7>?u!N0sxVabt#tHy-K>;@kMg zY%gmzC2zBR6S|1C{^JmwAAHiR{AXnhe>8D#?elFCZgoybBRAA_pHmct5@UHSz@K|K zTy6EVF?T;_azlXD27#|_7(EbrI(m#~#p$D?|J1~3CZ;6C=l453R$;9=n}MaGhT5RS zE7ehTCs|=MZ@W{dCASd>A+B`xYI2Sk^cVstK`(7&xVpf!N$z zcXSihAC-Ru5fF^fvWpgqOj8zQ4`y;WW8ZgjafV*DeF_xF7kL-C$jsv4o8gatG^HOB zZe!6q8BB-x;EmB^d)Yno{wFy>VVIYXJVDrx)VfK0HCW0q^4`b|)73J3lqIwyG*#cu zhd-9;jZ7p_0F6O6127KoerR7$K~GnkS#Ov53(OaX^wG0dzdzmY?`dUH7DEr2-s_<2 z5%}|4q2OfXtY8qt&Gcqhb(Ch00Wnq8-*MQ~6=sJ$#t;;Y>avc0tC79pvQQ8q{i?*X z>B5#Hew3e|b_pX=)1CE1z}DHfooX$VkY#LtaaUn?`^+vs_IIwOK&ahsvQp&9411^t zgyoAv<6{Bq0i8a(w!fF2YVkgg>O_qEcaA{z*O!3%y@LH76R84Md}{o1uQO#OtuMs; zuauq~BV)n)h{bUz!`-fo`#<3RExzx6%5ghDi=6_Y?)AWphQEZaOZ-0`w9150W*lKV z13k?^1D#!42dk&08Qf`UWOwhY;G)MB#^!zfSXviIPR|x0@NScFc6EuD(dahaxFk^f zF@)i!Aeam+56y}`(6JDDw5KA4sRo}oHHMFwVH<<6%cnGCAa4%mWJ&$#!>39RMa=?3 zuACr~&EF+Il4(x-E^K}WL=R`0?A zUXEMO!`wi!>lXhz0;3^E=yMN#*9>60FLcj*4YSy4uL83;xx6lUXF3*%g~C~Hb1Z1# zlk6X2j<$$f@Y!U(GA3XX#PH8gg|)P9AlN-%G<6>j1X5;FykpzC)F|Q%P7gu zgeBL799``1qbjT?NioRGQpdY+`t4OX-QkVM%9g}v6H$b+=1xu_LC+6h%@b71f?M_W ztPr3r3(8PlEkjFU^##;-qb`9^uT=AJv5R z_NL0Xiw%j}UUiEL&iLNswBGgGrSm}!@Y*0>uIG-t9Q?2P?sxJ}X22i3>jfO6;Q|D| z=*1oq(v=}cIDwHip$`E~qv+W`D>u@^$0V`iGIqnW&(}lEh`fao`*<8}>mMU-oZ%@y z%9@)6W(B0rSDhofTo?&HA&xqgbDhU0I6rb#$M%1R%&|^8Kh}sX@)@Y+v_e+z?4t0O zX)8$ZxisNrB%wnZkrR+$rb^w=!hG6TGnN68@Gh`mQpG#JF;1FTi#)LUOZTW zEfD*tZ)4FFYVGJ6nyfWSb{gHM316iEuZBSm#y+)Y!h(>^?d7AUANH@M(?|0oN~=9P zuq3erepDV2L4dxVKo-ub+yP&`i& zk<{lTL)JmmmEr!5>m8GcU{?e}cb(@DGB?To{cxri3oKNduC~R9?H`a6=5S|(-I?%e ztb8zj5at!Q@h~CnBbnr(F>uAlKEq|sE!r%85^SszP9)+oA$Sz5MVO4vGYV?&Twul^ z;W9TU-_vZp2ln*G7BkeGALKOZ_Q!mi#@D-6rf|A%esl&gA1NMO7&<&%b=Lhpf76PO z`ftC)h-UA`#m1YbGdt+x=>C5+ePvjb-S_n{3@Oqr-6bt064DLA&@gmKBhn5nF@Q7z z0)hh4CEd~~A|)v;Bhn2s@6Gdj|6lk7T-UzWK0DUgXYI3u>W%1b@vaP%FiI z5nUV#kS?cW__3aMkHJ!cjXUMgyLR**5=iXDpcsDig6ZW zX?<_~drq}Z=VR*`NnoM60iAw1m@U>senwaQg+bkzP*ynX4{Z`ZL!krt&F|^%WZMlm zI@8O${`D_2QFMHKs>e`ix3fX0k#qZ}pb%Qlwx9Z^>69{)$aaLx-m90g?jY$CADh<nMic#23%Hq>zajaHpb zHqj6A!qTx8!GfB(adz@VVL@*z%&eWTan-gk?+-Qd{gEH1=887vZg|OKcPX*0xGROP>&A>5Nc8`c48UDVoib<|-aGjl8d|Rt> zER}rRf4#{=#y3HjV{=qRqDKt z)U5r5;1J&^stMpp79fvihqd%|*6JfTsLODcKt+hgWa`G7B6FwKuo^DO{Obquq zA_u3;=LJp-9erX<)+Sgm(xw_R7DYgz-~6C=lF6~YM=-5LC=2UtTrAxW(w2DaI$ebr z>Mwh|t(yYcSj4d4f)Ph{9!6_Tn||ia*wtekS_$+fqe9lu0lE@q%K+$o2J?28w}dF+ z<{zZzUJpZ;Ew%6313H0%Tk5?KT>HM@o-AFFy>dkJ(4v?B<&Mn5ryjtSF$R7pVHu34 z^WA=M5R%2s*sZQ2@Z zv+j9e3~>k-O;+$XK4P-63Owo`oAD3-+vo`pJWi%e>kUS&!PbT6*stAjQ1#iwKE zh2>l&)ynwzIMV(4)AF@|>u*kWns`GBUG}*;e4+ZDd$7%4W2qNWQoT2>UwKq$LWcW( zp~d+pizLN+Q%&6E^)YgTw88@ln<8$uNxDbxZG)E7CJm)*GmOIZ$hlPJg&zO4+UBl% zt-nwXBkfVMv$BG6%W3urf`$7(7f=oF{fdeI()_?s`WB+wQKkAG7HFN zM;m|vK6i;Wnm$$gIS~)v7d)woXToJ;Pq67_L~R4KXGIEV{AmeO)r#)#)97eL{_)8_ zety^#(jyp9qmXMI#aUXY8r}1KPjEEy178XD%h2#R=ZWXXR^MyI9_A&2_|h^!SAXF0 z)C4r9Z@*vEn?u@B`~`9qLcp7xDsbS{g|{!3|I~wjyu7!CInSi7^3f|@A*b23O!w`R z9eti?{(u%JME?BvlLk$EEB@(BMYE4Abz=8@7$2?bPWwPj^~DdLyfRN&Rr{T2M6am( ztH5~#DL;`Rf&u4aY_l3NQXw4ol`D(QH!O`j`zlGk3y$I!^aOEVU*%%h;y-;?MKIUC zW|g$izvZV~HO96k$u5Lg$4z&bh!p0@$l%hHs#c_wSLzYz4JDA!$yLi^g1n2HBEZFF z!pXweE*$2d!=@e&P3ASwJnJ>gMltwVFd0t}rmGgJl~D< zFGXIQY2f@Pv3-cISU;Mhd}^slP(}TGJeypqxqGxzu%b7Uzo9~@Qu(h!)%)4r&!y~X ziYqaX-_V1GvNCZt+0z-a(TAdct4%vz4sv|j4Ay|EL70? z?3)cs@r`EujE$FYc$hwMR_$t2*&3tm{lk9?j84tVk$#T~YasA{)_(97c`Oz_%%4;Ig(Rx*~kD36xDFibXkgIJU(`>V`0a4VkyM`LkkNv$BNF> za>S2ZUTT#pZNM_rDlPQ{ds|^N5RzX-ee|{uJ#2`*vzmr*TK)4H#^6nJwzum46;Qa4 z!HSFV$>!B4xA`<1$2-@ttQq|jtc47ovH=4vMMB)@+39KpYeA518AzFs>#r*Q$6V6C z{*{WWuz-y9n}G{-^dAn;gTea{>o143XQW%rSmPSGWEPfL?c|0!cs7FDTz^jsf7l9W zJ_+yupTgvNiDU<3%njvckoJE<O!OwAj^_NPk7=IKxLi$;@kb4D zE4crq>kp+$dB^XJb*(oi0@>9Lp^TWre(>`@zuMJ@jcZvp9}NTPiv=Jd@{#l4n}oA!x+26ZRBKF;9^`mVOhAT9>_7B7@9Sd zE)~wpcjQ%Utf%`dsINa#+*g@x_IN1M4=2WYDC!XZ+t9}+sry>i`r5C9+Em;{tj6w3 z3Y{_#%;m`5_$)OAp=5#O!e0K*TqiPqyQ{rPMrdY60HD8QHrRF11O>(Z*IJ?1{d&IR z5~sGjy1d3`wsW#ZxBm-ZSQM877cE0yL?fL-f)PaQfjB9=zPiV1DZRtEV|_8(L~(Xw zBT+Bfe|8i?%zo4{OzLL#TMFYo<}tDrw2CDLBp;`VdL2O27@}%Fs*ED~xujL+epO(5 z+V#wP3@~BK6M$qu3dS!oK&fNcvbE>Vj`p9i!w7CAX${SDsThO@Sd7dx0W;Ktku?~9?rjQ3*e&zrteF;eV}qMhYo3@73BCd zaA>{KjYZLXcl+?Wr(?E&DBS<90_>K*d4PBOVOy$o@~dWHraff`7Kc(8wwd?wjQ2}u z_BW@WBRRTUv|`x%G#%#ep@jV$3TfbQO^#k~(lE}tXnx40SA>Nam8eW8hZ^3)(hkhD z%7gm3m*tgoqzgYrSP?&wIQ#-;UM%bsFX^+AalnG)B&5cvkugf{l?hxNfQcM|QPXyb zQ`Ls9#T6*MXB5klP?gjL z#-2)a8i8%9MoeHJKLM&^niO0x-|LYF0mThQxy9AH%l5|NXg?LA0|g!+dP{#&PeLqo z;(hP>9>w_nRip}*L5pR$bc-{c)BUkFAs4DYELXYpTWHxlsi6b$6)QvB3k3d4E8N z_yyKFGN~glL2@7!2+w-{+I2IxI$O`Y5@~m9DX7Ik3}&^^+v`bC*;4>&CG*dL(Qh=d zT+YhgjQtXir+;)W5@@c4#X(yaOT^lt=vOLU`65bfIUl0!bzJ``U@L{hs}I^l-^8MM)n7;M`W(_ z6GDmm^5hvsRwqjGTD!xHnV=)+3paF{(lp^Cn`t91lVWQOxy(Z~i%bdv&LiVZTt?3z zuGb0;gtT8xv1uNuPx5~-d1c?gNo73#>MMIF|BWpAvwg)ls^98oOU-X1zSZsdYm2cHA(^gT-t1{cP zx2*pi_(J1+GAVbk-5Ec6T%g+Lzl<=aALcaRa?+o(^iyy@OE8yKK4dc@xtAQTZ<4M^ z^8<@13U|i9zPxcJGdn$aNRw`gLcJPK!i`L{d>!QJoY}Gfw#&q)lEaMkt+ga1A)Pm1 z5&DE5#*!M7c17Ih%djpiC!$PRb;#9<;BpJ|N0w^HDW`@Y#5OUk$2};5s?$@U@JU0*D_rFPn=la^39$->*=QZ<@L^4eoHWlI z26e9z31v(m*yN*0iZ4;5fUO-jTV?Ka)#agd4|&r$G{)-7}W~cX8(`YMPtq(gG zNX{F47Cmov2oOxhQ=Flg|D)x&5GMGk0f*~e|&gZNk8 zS#_Tr66|RhxxNjtmK&Lp^kNgt605KG`IC;>Sw&L(ZPk@>ChduyM9h9?)b zBwWKxU$Kjjc5dj(3_6LY(M}hd-lCKox!~#?3 z<9+b;{V;ye0SdsS-B^Zl9yCz?tz*Dq*vMJlh%dnXdz@GOCZJj15s=3^J^wx`X?g5~ zTK#7N)TMc~=R|iRp{-jjOOV}Ft4QS4TT7_be?-}wS(pbZe1C%j^w<>68XB#Y@F?!r znr&inB8Rrrc<4mOQ*L|7#?{GEW=v zF{|dJ&<<2)?)86M00)l@n1=ela#Z5ew_h2d{BLtfS*WzVNTce4E=iHOf274zY@L5wRD-DC9)4YI2Ox5!!P2`LGTRZYRRk1;b-I9yarTnvFwb%!I ztoDWbR1~!pA`4KJTj~7>@#kM(Z{z%jU?To5Z&#*%*WREgIxvZ$3x#f<+yz>(_Ok|! zW}sjT!Ps{E8Vb~ak`eRX6)k0JzHSPiV$*CXP%Bj(I=^{P|_%4vp&($eT^WuEoC(q|Bl z4QCZB31IG2oxfZF3&pYWKh<)G#{D4Ji2IaPjhd;nktmryZ;qHELam&oc(6sIPb7-d zW{h$iE`=a|`0a~phU3&qR$plN=vQNx?Uq=G+OFNyQYx$(t6#Gz-?*6}b$RLU02<56 zBEuR`*L246e;Ir6p-krsL6S4d_uuN(To>z;O&Z>spaFAM=$G!6Wk{(9AvAN8de4i3 zdg?x`8CixkV|;(}Ak@#`i5FL^L}1G^VGL%c?bBfbUP(msmLdHV9&}Jn-Iurg!9c6SsT&j7;+Uynhny7R6`R)TTEd{%GL- zgqO-ycHg=mH+oPcxu60j>1D5~C)ZB)fP2dTi7DU1DUgcMvtX@0XR6N`EG2n}h8h0fBG zu~1iVR`Ka(5Wm>&rV6l5wc@9X7xI3`9_Bz$>5RgBs`E*AKd08ZpR{-SyG-nwHH|aS zmmBJO+bB;2T6)(=JiapiND>rX?ft6*c(>Sv$&+1M57BIH_hts}Qh z-hN9Y(f>8o%nQ+u{q+0+c`{2j7xsIJripynyTP!^6V0Ye!8rvqRriPDueS5|a2A0d zQG7SkPJeh2uM2zw$sP}2y|R8(CJoS=jsAR6m-hpRbxM}Lbe@`kRAI97J&9ou3N+%C z{T|@MBJ(Gn#RCs5O~Z-T&Jr&fKXq22Y^zl9(?E;UAnFKRJau!1)QSs0kiSKCsxFwR}(Q`s) zrDB1{GbUU%6#AIo|6p=~U~QB&rH#>%zvI8lHQT8|^vf+5GHeIGM>^dppD@0k=lyCD zZH2e%TjU#~M$SpNnm3oBcKi+~uIQWpcV>}ylw=mXTb zek09G2IDXwrG-TWy3Cs`L;Mp2wo7I=N#_z?-bV;(iWvN0Km$G^29Lbp3zwxEBO`j)ebZ3w3vh4Y`xlIt{k(!=_aB0q!ii%3;NsT6uP(M&J7N7( z{@Rc4T1pj83)e9eQY+mi5jv=~jUuYV5xlwwWq~g?j1s>pG6avzJZP&)WdG^LkvN@Q|vFFLo>YM+w{W{_=U_%-{hEIvHwbJW`aYR!URE@01FmT`>SfXunpqtwqrIe9^~%8AAgKG1g4J~M(0 zA|vslmGkFgGPl2Cs^OM*2W7*rSat>)7MHVf!#+%E}S<#5>KghupeecXO2! zl%8hFFxxkI$4OjGzdiKrVJNUM+|Q3RYs(dQ`XqSo3^o+s7Yzjb+)lH$;p;bBmpSOR zva9ya|H5H_W8)4miTYij^(m2X?xa~*vfF>LzzQ1XJoylW$LPXA`z)4L@<}M^S3he% znf~2pme2Bo>poLdpPHt!C?_ioCBKZ}N&8z+OCv$3vYpaI<^7h1EdB-WTri~}Ha(e; zua=x!2h%o^3#Cap(2K+PI&fb5%D%v7NVUfyHCj@y$D2Gpm`%;HBqf2uGAdRfg)rRH zITp6K|xjo`X}s{#&1YPbnaj_QWHw6U6T+MLC0l>P)>d%kno)(9P6<2oQfr{~RpT}erlW67)R9n3qFR=OPu+BPLM$Bm#j-zq%gOnB6 z(|fEoZ_Th-?Ku3zn*@3}V(m>HBA(J+*vD;JNC&}N^b~?GD{u` zb&pKC5JY>wz|v*FCYJGM!qhNE#>C$}7*OM(A$_d#(56NkqhdHLsRukid|Yxt9O8c0 zEfewnNQi7Kr4OH7DB{ff`;w0IDzUNtK#vgE@njMiosWVCYFLb$U;6|%*L~3>Vw&AVZHg3Ec9`Qrhc9Q0gCRk4=A1B_; zXEEm=s}5mRcj&Radq4VB7qO7^k)C#Hup~(=8;{R83%HIhEG`EBZk zCrl{w{LgWTv8dN6DUnK>PA*H|QHQLhNZrRI#N{;lXd2BrL-QZ;5%7IwepfJMUzC^>Yf zRC7v;1@>LF9}E;FKPLbYX=JGt&POR4{?dN#g~_AmD`iaoo<%N z67oYQm)}${{ukk!i@-OFX7R;cca(C@&$OipCa!(&*g$n>gmt?JUp~Jj_bY5k z&3XmANzzx

mQAw9Pgor5pd~Zyio8ig=QkF#N+jGFmoTq*Fo(m8P|MPSEpQC<4!B z5)sx%o}`|k87tH@=>3N!OY_rT@KE&EVfi|-cE#HC3637pM{fifm^3eqS+uEng5A`K z=lE33*J7so{(M?XSK_&iV}~Uz=(nnXR;f~lJ>;;lOO^Dj2^;|(BF69v)HvhP z*JAc3*ztAxMmUozck;JoCvXSxUkIx3FKdC~^W7IxA1TD`_F=>bp;o0`m?{I`G?=*+wBMS2VOFYq57spyQ+}raz{BsKRM%D^=+W@Ob4Modb}5{tDT8;-J_phoiWo>MNDfmj}^YS&q>!QrGzsYqaP^5#saw7r=Dl;$A+CG;`MTT8U zoD$^E?3lic4ESnI`{uj#fK+^q02i5tSvE!yXL7-#rShK4Sgi?eo7#w#oby-_MRyw~ zRLNpT$Wa7r#?UV=Nyp1?)=}cWEhbf9fOl&7)%j}ssB+^o@9og-21hsRgY%5vwg>-1 z6~|Bxowb;kzpL%?ukdJfTHptM_(MHRE}HGbgi!%T-gM~75%YrYsimLdQ?7y8otmfa;X2fO<~FXb;=_^kXc9VeenNr0spz` zTvfLd3f8th*|z#$KOUUn3fNtrcGRDWcz9pD0c#OW!-k^P;B;`YeFPns)j}bPQXjWq zha@VH?Dz8Tl)u`BISqv?470to*?)mM`}Nk!BBI^S%0SWe!+an&imBT!IX#%<7GV4c z7JT^ThKod2Rf=y`68|y2azE(+O~+hUsFp-w*dZUcLRW}8hZ<;b!Ap2eY1l4y*@ z!pvrzFLXM4%|7Gycv}aR>1a?9JkqjLa#t6{nGLrtI?6w9I^TNM1*~nYUv_r{)@OS% zqAwYBep27tTWxS}?Te2v{~Vy|jCyb@`Daiobn?rJMm7D_{TTqDhJ{@XL&+b&8tiZ9^2mqw&zlgt77 zRL2|QJQOBKcxN9HO+q4O{kM$nh4XNxp0#aserTi+FNn56y6$_cwmcod2ZIb77P4r; z7v3b~bWH}h#7W#RQcv0_qc_1V*bF%|WN@pZq`Bt{n|O&L23n~e$nE^#AX@a@KBtdh z`9FGK{`Fr14aWa~1lIjc-O4&XSrwZDfNVVL^1B#Zfn5H3yBOq?80uKst@{6~@JRfn z;Dbyp*{$ViTy4=1edl9N+CK7SNI}@rnwF4g43~T)2iYU56my+=yrw+7wFY(uxJQ$3 za&sgJndI80(VJ4wI32Sqb&ejEEVh!=9V*8ElS#c9~-AiL+z` z-&nU(j~(~)|LNxIBNb@U4lOwpEQWO%5mackUWG$AI0~ntMJrtApD6$G*S?5Z_bi$x zD)6DXDqf$qBx^jPd%2wV2&~@&yIni@BcY;{fPPDK01OdcY|Fg4hBxSb`>(}xrO@3F z2yp7XzZZGqv>OvtbYEV37+emz-el!~MxMHZOYsGGS#SPVUF!of*Z%~jZuYgt*M9=? zI&Lo%VpWZVXusjrCQ=dp27SliloKrx&5bH(`h*cx{kqSp`bpSG+K*ddlj>k4?+^Zw zBr92ht-xa(FP390gqm3Gx|r+KFV@L#d@SqUTG!p{ht4kxso6b#`*sN86ELPqb5*{> z8NVjAavAcnS>IUESi`jr9}liE#Z*e;l4kN^Dr(uGF(3wRMT-~m5q*6w)!f=eyTj)$ zD36|Y!p`=RD^4#&(!uJY`|UpbrwuTF_uqnyK~a>{uoDUzHJu5#@@amN#P4Q2Vta{n z=iL8agVpmKqEV}GWwI{=)nv4Hf8;pU%k0P`r&N1I8QwYS`TUB+?e``1ntw@uwWgrr zqfY8f{_4S`R^1@fRf7Dj9o>U&#YkQoF*g=t!JBD^iO1PQ^;Y(T@cHM0R-bp&DRA*{ zKDN+-G(-_L6zmCj8Yj=9a8=;iMGRS4YP?e;(}L!?Cc*ut^Vx7QC+Tu>l?gQ=t0(B> zeE+S5>D$G9yH>o@@7u7am+rx@^y#DjYZhFwluyugXCb41H`<2G4AIF!Njab-zWMm5 zq`v=odA?Ct^_#;BH}oH}ho$bIG&qr50yEf|>>(>zg!|WJ@&c2{{9f%mkn&cLR{0lV zJQby>>|VFEuMv-j-q%KA8b6ez2*Wj%A8hs=`>2I~JVR$3nHI7acKB`020MNmKRzKG z_i1pLs*#Ns(v_GATgbXzd1(o;5zo;9NGr$wmW!lhsH*hHdL%3)tjt*|^pRBnW3%E$ zOST}tOCnC%2a52`up@Nwi&+(jP67+p@gK)<9+mn zpPx{r|B6b&GN)WJ8>0 z@F0PzBEJwo6un}U^y|%DnYi1E#o+9g{+#V*Kn9%{GW*0o5J46nvo{l9zSBV`u-Gr? zWxV{7KO4ELcVYH*@}!xhdQfH2)d*)-=*80Hb%o17#iSurYq7PnYT`VAbY-fG@NW@3 zL^g5ZiwsOm?L&LjVxM^#^bm$7?UqgwV?RHc#zAkwWGd5?S~@$qoBW!#_I|-zDd4!o z67NIT&~^^&xFG7 zB*c8j1HQJtfoAqNoQ?;%sh@}NFZfxA4OAU;l~2Bt0E zOfjp(7JEUH7$2UvgfI+d3KeR7B2xdWZ-RsNSRC+;z0u0IA9fsQO6h^e)(T%%Ur5Ma zz@DW~!jJPco`n&0T3A@5FD@eMj4%7){fuCUHpk&A`YNB0cF_?n#5BsI%)}Up{M-ey#=Co1bAJOXY(m^yLy6OKmg8zB&F5>34-yaiMUHL&lR;w6CwP z#lMYobPh6Sod^Rkoc%ily`PE*N9*tR{TWOLNpu_y=P}`V3Y%h*G0Ck}fM=$gt~WqX zYy^$#EQT%idHJz<{v7lR=47jukEK$6DPmsKa^sCYtg@O_E@AedQ>DcCC8T4Df6(8Z zaV`KAENfPYTyL(L6f>zisA_G`|LjawSqKlIua7M+O?Y(G%1ueA(63!F_SzXa8FqeUR)O4+^t|WUqDI z;fABn4HaCuF$DW8B(1blKsZ*;>Y<>pF#IJ{5*(WG9^xO!2gOb-uS0Zy9U;YdgPn;a zO;Gn?==r0})M_%9t3sUBnt&#(i`9jB-^LTP<4qWzr6l9(k4G#7t+31sK+8ojMPcj|dO&>t)WF zs3YpfkTZ)Ys;w_?T<_ctnv_~PTHM3YN;gJZak9X{m5^Iwzx24cK&+ubOJQQ^AnM%# zI^oA^p$ZKR>_Ds&#QK+)3OXwDdIJFlpo`G zVX-Ghf;HIDK5=7meCsxR-Kv;RU5n~mC}t?EO0a6~j4|{wzlg;dnA5N@?=zYfTkKN` z-U4PCsBNEYh|f)lXxqTO$F41X^4#it8IYtG*^Rz#J`^GAY)=($>Cei$pDlXGv`J1n zOmSRGC!GW{7_#$;U(d;h5Tceif|T0klX5*p9H4uz@6Bc@*I&U#=c6{OH-3-LbsiWQ z8QIqk{rD$4a*?pb0JwM~UONQ6_^-2??6yms#&&#$n$vCK0E-P{C8v}bFnb;%mI@jj zXOp8hP}Zdl3rV*Rp_g|F`4HxuM)11cz{@fd*A!_R^5L$$xudObJjOAnTVfK~DXE-6 z&5E35S04Tay?K{-gFM&Dfm$r{3oX2uh!F3{uQqye=8MSmL!Rt1 z?u;G|rBv>aT5A7NeD)Ud5Jr}qUo4cC4gKlXZGNvw^f8{bd+d&i@ScVMRU-U~{)_Zjc(P52pEGlKbMY&#z@^$x{I=g0|9uwDks zR7Z>xXp8u}B|?|QCQhJ#X9BQGwT54tuvjGqX<0NRob0@Lw1d1k69WHrqA59g_Nz~g zo?x(PdjiLIinq00T71INqQq;0ndjt{?N~cvMF=%|mw$Ogm6<_!@Vw$UYeQnQ*trW1=K3C;;*3Gd}t{>!MI*@o^ClxMT+_ZFCBKf|aUE z=DSJ=P#O?4+L7O`$W^u@&Ir|Y6AU}1LR-LKnf>bQ&-& zX6;_~j1O?w6B!e14zgbZkmp|<9|qGijPhyOFC`YV{hK)*723b8bI@N%n**yXa58U%Mfv8G(<~gF zwX;~AgL`>Gg|dLHrbWT7FER$7V1S?D-d>>3&-1Sr^XX1Sx)8v{Q3n>6Eq+Mh|z<% zf#tZG<PDpRBAd^2l=x=u9LS{FMKHTmaM4h;w5vQHycbUt4#sne57m-*SScihqg;B}pfF ztpl>y7GIQv{RJd5D-vuia;l~p6*qWH(@SB>784)iT1jI=dDLTD?di(5nw;tgQBCk| zSr9B_ix*g>B!0$=Zb4VOw*mmm5rKUVyJT13XXHI--o#;ZRgk6wFHAs@bnWX~Ucf;R zT1=~`kUQC1rGVS>mwo9;XDZ+fUlyj`6{%f>EhHBTDVkyePw=S!?C+>3H@w(8DdqKz z1=OGxQ$qGg!#qO}*AilGwz*Cxw;@p2?48k^@1m2@dZ5K#Yx6{>2XdrrAIOjNZ7KWp z75^N&TF1A2#YREFP@8mnnvgoT#MFK9Nm$j!zgCN}u91|Ds)2=E$HW#%<=95{hxKj< zy~+{cz070;cR7-DPI5x{*JF{($!Bpv`5RF5v;3r7kgeDrv0F?5+z>WPuZ0YIwO^(|*P=T_x0&S_0I zC!Vyz4L1YQf?sEl`tY`N0{-OHlDFu>uh%v9ru>D{VoKxDn5#))XW3xPfgY@`vnq&( z{gU7ZC8Fx^3vmKE`F^G z=M;m88AEVw{Ew|m3Mhn&HUfkJ&LlDBqdY1*)Ph8dZ=vr_2e29G>~$P0-~#OZJ=4+> zjb{R|Z;C(RQC}*(sYdQ0|B}i3YSyMnWHe8Jo{gT|?V4RA1({^-NGj(!e#xGgdNTLH zZ~dygwrn@YZQX$YHp4zy9(*Q)dFN#GENSsnK*+K+vw8K@W3Rl%+@WhJNn-oXu=-jS zE%BJ;u*>=U@_Mgj77s{i*QEZ0sMUwf&mJ*d!f~Fo;GTX@dWFEH-k6lb29tZbgix@F^}E#92{ z60aUiDI_$LXHOk+H;Q{*t~z4iWv*zkxYpH|-@jhAWDZMjYisik^3ez4^wC=YzYzR( ze3aQO1?%O3KD^mh4MgDQ5%}Rfn_P7+HY*U(O|abEtB^*qva73^gRAKJ6sD39V zQ}_f=3TPBp6UinwyA|+LCC#)<8irK`4H@f}2wyHu$~mH7_Rcl(9X=`UOE_*cC`U~W z66Dn@uDafsGwv7q3?EnV|LQD{Uu!i@|C?(2*cQQuM7HY4@Mu~BqoYbl5M%czQ>v?%^eyXS@|yd8T@ZPPD6|AO)o^|GIdP3i0F3+7ZC^J#2pVAO)0{UdvE5SD8i;iE$vJigYM546EB`NLrQy)0 z8WdqZ2+KNHhCL#gbvqzqM@BDy&ZI7c;)GAH47U?7M|$$8aC4r0uiS|&VsK8ltk9P_ zy>#?C__e_RqAX=@%E=e8COcrP92<9OsvN)r&C0;N%#>7?Fgo)xIfkNj;Md|fx73eE z+&EtSb7#H07(rurW|J~~f3XHVTrEsm>%#gGnGJ+)Cy_-yCDMX~6tVmMv$%WRg`}Ep zbrxfDgYh;15TLq&jT|4^sL_M!M1p~@(5=cRAbXJQOT)uPNzG5g0 zdSkJUK_URO#pN?u?1}6HV=v~Em@Jfrt)6P+&7Do|mk}&)7>ZcNUh{lA4n!2Yhgi(` zN@kYEzGkns{TnzYG4N@3nX64p+C=Nq+#uADrr zt}gCUG*%{4c6?t=5?sx>GsUZ;~@)QR?-)fq^>hWvo05u}Xre6RA> zE^jnxLLlwU9l5>6T6q#pvXRuhzYO$*sV7@qLSzTVYENjcmfABvx2#sM4_eHIY)Jib zT>N`-EvZ$=hRhT?ut(%imi>5qJsYwfd_~USC5yzY;{rCLy|1< z9ldW)pXqI4a)%iJ)y!X$pML}YP(pu8+PKP&LhSwXc}qtk>j24M9vS$mPzJC z4k`Mn{-n^rXn^t}(3^JV+YXAHNS^te1kM!49o1_!S`1tX4{)0lYAuW1C@s3CvL8Ub zWDmCdN1Nw<2-ECR$Q@z(^m2k1|CW~jO9Yk1BR2Zc5vtsAU^SpC~q(2x^wOwz(8^Y_UO3H*ry@UAk?q=U-D>7rCRN*HD z`8E0Z3NvAj!_edT=_MX$7*MxwMC}dYnlfr|U}TW|6$|?-3|=aE;l=@B8LDLQuP)Tm zFMH7srxRme^vKw@jBP~zXn46dx5Iwz_$B|5Tc+0scBIV~)kNy%M9Mz`qe0;9VVPTSh%GT9wC9Bx!qwISO=dDT^X&#>U0^3*)JgkMBv zcEKU&15XDi3HqgI`I+h8M`4=aO}K8ymz4l+yqeU3hrloAS}?8RdHTW4{;_tqcee5X zE+vgx?BeS}<)FXvoh*ZViEoTEu5@OU1ltg6Vwfwf6%*ySS>?Eq<*tw9Jwx*5CfQL& zh3=_?_FwkI0#?#WxsY{;*HeW*2X|qGZW*mQ5TJ$ml?MH?PF>Vz583o$;3R1s#HDGj z9=U$g_*l}iVd$hx1~yP%r-&{Us4eEXw&xbIgl5{09dPs8erkrTqlC8`$5630;HMGj+tHV!_3iuc z2P0!;RSu1oqsuIZ-!i*K(XPL|cYJ5A50W*(b^#%C6u|MvHCJETbJ07#0B;Ur06)`k z&ARTk;Yo_!`RSSv1CsCj;wu!oTZ<@GP!-Fr#Xv1L)MVQ?qm{pkRn9$ptK`^wEHubM z40%Fv4vjThEWd0i(MeAodqhisR(A^$KN>sM!(8aS&La%Pn#|*f7 zD}TP3(loWm_9M>Eakdm28C+1v@l2Tog{=cO$)-S&JIzJ5n2_O5X!)DbU%OwVmDYfi zj`Fa?MqS|CMWbKfp!}M|w{9l$N0zTM?4xWO5*pnpfkzNW=#(VpOSjfOzt5b!5HYAzA>AXM` z9t_aOaspsRY?K*lkL32^xooUF8;GMe;f{|*mhf;&)dMY)47pSt3%NH|<=)u-nL?D? z$we|`$lKTVEEm`Rwh69Rc5B?dQO%D2(`}DEL_+7t)H!A)0shTH3Wm;Bs|Un6Nx(7R zWZ)LgVFbjU0Hw)}Z9{ck#r2Lcd-&O2i(TkS8YxD=nR(v&F}LOJWRO?hNow9_=moP(Q_0KKJu7*Pb;FUc5axt^w(7@3a;o>8K;3$#er z+&I-jn^>ywfUqtZ24ut~Wx?z&sL@i zmQOJp<*yuj{DTu&mNnYShdd$efj@f-UCor4lKD4tf&3(IKKTEty7IV~xA#Bu@o5=_ znox+MP0>P@EY;Yh&0b0xTUpvDik6wtz2RDNBMqf;UCNTOwMaE_twpZvNkc+XjiRP) z=J!4`@w?xD?yH-#Jm)#j`hK4CDWXhxYy9iyjh7vqiys(lj`*Whv%B@}R7pk67oOZq z?!g|u^y0;fWsMKF-FVOQZOQAd`DaIG@bK9tP{@M=Hw@~=Uj}FMTs35qr>8up*o=v> z!O7GyI#YgYC;OK@t0>Ri;WMXX?3NROUq0R4QGKR> z#2^mSUL0y~&A%S>{F`3rp4@RVq;WM9CB9=XZmM|b(WCokgn6Vae-5zEwX~OteAs|{ zCEI&)<^8lWDsZ}rfo_vjio2z^KCV9>>cn5}GL+o)1*+rd_rm1lsoEitk;uYc;4jsX zJN(k}-Lpkk)xkkFlqI;kr#5Xt;*7APH*`zG4jb-?)ZQ@R%!3U-u>g1e8q?r18kSiRFj-t;@d2nT8HBwXH9 zjjR39BsQqdH_g|eczaCh!OFq;$J<&iH0cSV%tRMu=SzIwRv)D!=KZw`cc-hHUtC~y zOkEl#KP!9qoyh!`&Z!@-?RgSqDVI1N9lLCVyxR9@Mh4Ha;}JjO?%l;DJ5x(IHoF=# zMlVx+5xF3ySa#NJ=NiCY>(=Yhwll zCTW+yFk$!IHq)@`V%3Ln7n3@^UiTR^L%w9=s(N|y)`-U?qkqWf@Znc|FGpQ3856$f z;;rCIo)aPrVqWk;Y&GqrCO`i!ZA8)>Ny*KSmllT1oWwbGUR+-`xB6q_BDOed4=H4< zTm?&m*y4KBLCz%keNprKh+%sL_`|YmE@k$qez;;ePnx#Cw9h%mZlvU#6YD#qo6n9K zDf#|A>nSs4_&UeLsfIOn!Bktuo=@=Owvt76KuLSN*Dqgj!zM1`fa^Ly{hj#6+pGq!<@FKmNnu5yKT-q>Hzs1KJ z#D!_ujh6cMJo_#w+}+>($$B@)U+C+`gPW@pY0n<}E8CVj@w3KS5IeF(0Badj)%mc| zwP#;Li>^wHyd!k@n~&aa%8oidD~VsnIl?~O*)G4dj=QRE8a1LMalKDVqAY3g?eneM z?ic(7;&Co>}GmlS5Mt#i*1h$UJUta{h~DHgnGpO2wZ#SjHrDc?Y1v{ zlWy@&`dUR^%D3?FcyXTR^sYFny*j*ZiSdG_n_s3Tn4GRPs3u`+rf5c~e3PU`_BBvG zrtfZh$|JI=V2*eN%16&ymlf(b@wdBq5e;Hf2x`_XjEoHTmR}ZisfRW2?8U^(y?J{2 zYiH{``8@ON2fRP@rmt$O;1uD5|90{0`vQwyZ5hK)JB{e}N`3#VYunl4=*^vT)4nEW zH_bbG@`!q;?rDn^L2_w!S))OAY|qF1&F8urY!iz{@0+88;Hv|w(Hea}eEyr=Wy_46 z2QOOS*=XS3>@%5g^pL2uRQ?x?_l$q81dV0dG&DK}d~=NIsFJ#8zleU{ocv-?@I|2J z{Cs2N+Z>gP+1c-(H5Il@i4{lGbvdw^D+s0vYj-+IGFfKO^XalQEjOtlv*g?e=OwA{ zrAtRWus;3IrLk*2WL&<-;B{oZnH?f8?sk1za^!Qw(-&(ljox>ARD%8slRiBwKeg9H zTXaOS$FmX!@6+sdHsI}_0sGWO?N*h?$H${|bHgi+BeUMAAEmv)>=3+*rEc(;w=12g z8_X?E7}1;tWCN1xf*)+V@fWY>ZuQ&i^3qccRr{XM-OzgN?=^|dj=YO6l34NXX-y9v{%|d7 zy1pf?WQlw83ju)kL?SO|AV@ z(f;Sv{Ih*z=g*(%Y2{?tkDCgj90q=7ck}ErRq5^OD;qOCT+{7DO51B~X1 zDD432QG1Gui!C~ibtG3MZ$-raMbs5aziDr;YcG};$DFo5Os5%*lSDt|9rUQSrsT-ojakL;rP2Y&9ft7G~^ixwJ}NApKI=h)=p{gXb*Lm zJo&}8w1$Rf(jT@Dj>#kRL13GPx~#n`zp6yEOg?MqmLRIZSoBxjO%tZq%#Cw#vwyP2 z4&Jx8qi+r2-m*qzeWrqSH6gDuMfRXmxkBe9@jjl z!{Je@ky=U8-T4+zUu?Nh1$ZY?*GVvXiY(gLFlf}{?6l#bqi1!j9f4r@rIDeuCbstZ zefpx?q{{+-lN>kds?Swpj<ax7cnbQwr!m;#EEkYMc z|MK#mwL*sAo4U{(!`_02HOZM%vN3E&k^i@=2F2ZtGa8Q2c6*%H!lG;G#qy^R**}1G zf_{6-_NmWjto$vyb3h_3l#TEni6zv(4h|s*Uou)P&|Hx;@DL;aZ`nU-mMoiZ{c@+1 zMP8bur(#3}M)>zN>!Wpup(iqR+0i$FvE;4KX25?8K_j&ZM(nY=x@?$Q&2A$@rcFwb z@T-e4)3or~VB98?s-mC5?i$jxTp->Y0?!g#m9Ww6v&2|bJ7MI7KQMwewdM-2omO|( z5WRCa(ZX=QU9D5cvT{b36db07;&_1xmf(J&p3mcp%2jB&t(>VRZ82e}7SC9c{C2ey zf3X?Um^#z8glkWDdYOMs+t9`XU*Nc+e_2-J5M}>&K`wo8@F9lX@yCqaeXqJxV^en{ z;5ZX9laM%Tu+3zO_A?LJAWoaVOTKjBBTGYONYIvw^C(wtQmuhZaJXxzqd*h7agNri z)^XXM6t}!^a!Wy;J9T61(`n3AV^csQUnHho_Ams;aPR)P?{qvb(jt1&mU8C}NpwWV zXwJoDY{NkWfzr}TZW#Xdq66 zYvfJxwlDa70mCK8`EEga{9#6Rjmn|(N9g$~zY`wDjp9PI%V%*Uj`7i{Uo1=*XRco{ zTKVEGu<3mXKQT<&YLxgShH)?~)-_eiHer|+m#+1GD>D{#g)={N8Z#HqK+4Pzl=Y2( zHCdvo)$Ja=`_m-{7JtXr*39Jvj;x$$^8`kf&r^sOUv!L~9>)+kK^)DBimrI2(4q^u zk<<58pP*mLtoSPd6kCkOX$?bI%nD-;_c}zWzNt~)!<1=r*T>-g@o+Qdc54osn|ENE zV%T^l`X6i-fh)5oZ@*KPzz}}>7{9u{>M)}a$K9Op^SF=9#7?xj%Ode(aY^6baK#6j zi`6wfI21QOa;@JOrW1eAZ+(cAYc3I800UD*Crf!$)Szy&*zqAu7GDs2sD2`aWgiy# z)D-D(llJc#tr#nlU6XhFwG+lN~WJB7k_hvOlv$^tl%&i>>jI-$w2)>A= zqUp*P{RWB9dF|r-syFmosPfi~>5zKXSaf%;7a7O#VD^B5KX8Q>(>4z8qKz4C_4$+M z3tl+!<8i2chkhPvr>>15lR6AVa9=ab@~_|dG)KeOHO@>jmGw9%CV|nIa&;8Bn>NY| zm|hH+ByE4(_qE1+#?`zhTe*}qqMT;xUcEE*_b!EC+q3j=JAdMK<`oOh@N%GArR%0@ zI*CnKk7Jf&!7La(EEr8SK^FZ4vts2IgB$XC)OKYr!m9gscDXpH95?1bG}ak7@s}NMP)?oWT~`SZBd5aKzX6DEr+p zHpzkeVsorNx%kKGQa8Rx9lCAwX2O!DWgIHq!!6dw zi^CQ_nI`VQ5aB-lHk^8rAU0Nh&f;yGW3JDnns_kNfxVR%M)@3OyzOUmJ!{fx+~Z0& z(P5ByfTJo~(f@=5pMG|@y048^wT69?T8=NZqI?n<9s0jcWW;R8@ZFpwk)y}Qyq=6* zfRXnteO?A)0T56vFD5)HS0so^yl$TPJF9P zo!}K$l{z|~FG^Rrt}2gtSCgLi?88p>A;xFc6PsIu57ByUGU0ISeaD!45n}Cuz26j# zyRsUVU30BL`Fl-JDw)Q7#Awte+{)!Y!<8-+Se5duZjX_lO)sAV<*=F-c;XsI=EirS zpX#bbwQk|}$(cZ!Q2tgKdypX***AnK z2~jEE&k&Td!urY7^0ahDg{WeXtzqOpHn5d(1=fBfI2)-U9EK#Et<^nXvtzlR6xV*M z#>jU1Nxrd&wWXgchSak>wV6~OGGv3T%lZiza_W_Zj(sAAoLzUo8AHypJkcvPOB+Mh zo0YE(z>u|7<67v?D8w-sYux6Ocn!ho`sM@Zw$(O@U=PfaUiQ+Dx zp3mfq>L6K3HGORL@}L9x?G;}m!P3t|o`w)!9pop%Lal~A{XSV#Yc>*6vN!PR9tWJL z^XY`aVEUO++~E+b#T*IGDY_yx0gD{jUg@<{2a6nd!Xb;q@t0wdaaaV;_ab(I5}h|o zoxjWjB?j|Ok^AjSJqH8V!(Jq9X@6SZ7ZsN9;H(R3Fjgb9`jJ5Un}#P72{4Z1%cwRo zBjVa&nJZcgLJkgkng%<+R@a6{(eO`F$GrSc4}ae2Jl1?l|8|Bgkq=958<4GKAa!}r zfaGH!wS3Z>t%Vx%sj$4t!wlwy$bNwgh8DwV_Kc;}+QByASkDaWrCl?7xCAni+737n}jh*qu^ z&oz?}6(NbUntGTk)%~&Q8$V+M?i;*eOFYp&XM9M&J34E(yz$nHS%n+i`Jy-+Q#Ec0 zYp<@AT7>_YYNgv<@A>gi2mU#snIBzp?(8LAXo{<5qAJ{ zO8kG4mc#`xnXadreD^_PspC?YNU9M=`L*{(CovZi7JEO8_$)2DT)+?*sZ5HM&sx=^ z+ik$go+cPTtSDn^vgi^PyxaGSJ5C&`5rHnQySUW&e=Czj8?lyK!{)I6uZKuvLe__YB-I)vcTP1-Fb;m4&sOkkMTQaGtTgQ=};2WQq%}1zUU-XwM#xJ zTc#l!koY`R^bG^=4jU>EmEsI)hwF)3YgLcv%cRs;0UEL@O+#t$slWRPWH?CYC;DE_pm*O5Wt>37i@%7`<<3yogB_R5mGAYD7>>$`ZE3^5g^~ z8ynoKAaGB8xjQ15n8={pq+wNbLtSfwQO+2tJTu!v=E&ZfCaNdQ3;xPNZgz;49-7|= z0+6ywq@%7SUC8y7lr&_?m+iNZ4xoK$RYT|(-_=HiGB;!$>I?E|D>LzZMC-aQb-SCt zbToeR?&y?kdlC2pYpj5pl#55qcWi4gRBewTDo5~Pqu#hsSzg^4v z3N?M1QA>LI+zh$%NsbaVusgWn`@rmO$L>+m8oyHqMW?Z{RZ*W*ckXTu3S?#7*QQJx z`ZZJbu)6l(v~wgB;3n}AV!hS4it=OekCOhfq89mxCH({f;=tA!xx|Vl2@H6htG$2h z-j={HU~eQh=*mRb_YLh0=pn3MoO2ci-2FAWk&Gw`+^5Df4pF zo5dP9QMr$(lcnXUu}++}KDwwmzvBz9*#FzRq_p;l;qeRslWfSw1Dd`G9{RsIy%{qh~{Stl3*2YUYu?nM%d zTxEA>?3&5F0THjQo@Q&d5QCm-79)X4-^X`ACiHlsw7pE`*HOFXUmaK*;mbI$N7G>r zKNd7EN6dSP>eLaYA)r+V{UqG|q=_jydY2%DdnQ*rWohN_=V!2m=w;{m4GSuubHVa} z8H?s{FF=>pR!?b#J780(PFy$mgei@=*Vr%NS8}TE45v;(MfB=&k4<1wpL8X&sVi{* z6@KR}kBD}IS|S$QC@fN2WowH?-cYIYxFfL0j;~jiu{Vr?QCYrV8f65L)q53p%nFMP zl1!j%;ZZBNhb{jU39yLTu8nS77edKv%TN=JIE6}`gXD6T{A_d15ADyUSmdFrHFZiG zi#%L@Y%~_pkr-2l2O}=u8EJ`+#bS|p-|Ahs`h=2~W><_k;+0hDtlsn{D^dINECjHm z_fJm%o~Tc{KM>w6o}Zy5%mh#V8n$@_y%P!dXYI;`bexqT%jWy=@s9ArBgI4LGouZq zY40z3>HNQ~nt}V#+!^VD5RI51!F{BcW_hOv&lax(DQ2f8+6dnD#gOn|5}Ym3&`T3t zMVL|f+Wq{Iuh-Gz zV+dnX=|FYOIbxx)D7b0F?#r63#CI)cCen8-i08Lzm3K<6IZuv8+?9#(vnM~ltR6P# z^Kts#CnrVngTuP)6OBeJEi3R;IDYj2;;lE?nQ?w;7coxJ7)HNO(qN8^mOFa(1 z*R8&CKJt#4k`d0m)U6}b1q$ogdV4Ws{-g&JSSx2@2>VMaJdruQ-wE=xOFpjgXaQR< z2}APR$^uv^J{a=#!-)BOj+~lnY%>(G;fCA?vswEWBg{Hd<@bbsgqD+#Rl)8?ob+I3 zt+w87NQZvcx})?RVs1WG+lEvAPzXDt+7&$fZM_uE3o-9lsodIeK#)C=xL;P1dx+TX_l_>t zUEF#N>s6h{e?at*Jr(=`Ja#;A2YqwkIL7HswE6@X?eAOob8jNrRW;&{qE(GUTqMnb z@;94r5^wfl8Z$LD^?8!o@SX;S z+Lnzje9U7gSR z-Hw*~JY?3nfg@bFlW?djuQq9zpgP=rn_nX9+?>IRFjM5Nv`D4} zca@Z1++iWufn%0$F3CD7x=qU(5_)F+Fvkq)qz} zmuBc9m@c|xre&DJzE{IKw0_%;$AAY6tq!oty+rR>Dc-~p{&;u3rj6J1uw^=BiwVMx z0sK0h{NF_5oEUrg&R>Q8Y`sMOeYRaPN$9Unw?WX{qewl*;Rts>*Tehz3obhGqtcal zQ-JP5XYq8(r;j}4gxHY)ZdOR@LRi~?LH*MHba&=jZM#^c9Gdq=%;1Zhpb2g_H!TFN z4_kT_(I-PNiF&PNcMU_H-n?VR5g);J8BZsosF?+Q@*1}hB6*kc(y}{?A%EXc#Sl#l z@#nOu1Bizsm%f~jh{S;%f*=qEIpfO2@x+^eC&;$64JE`osBwZ2u=$F7C^M2|sXxy_ zQ09ZkPGLGk4hVdsrvjhJaTnQB8SrFjGoJ}biwcG;Zb^lWa>I}l6N12FZ_#k72yjoN z*+fGmOF%sHC8`nAfx^PD=N4v~D8sNG=yunS2}4Pt98t<02cZg+^2U*J(=(xzg?ul{ z9z!DknJ^I9HS!_7r`v^^Oez?wy!|5|Ax|=YDisYc)Ri!yKXP>VGQIZ~g-BU`g(c!5 z{!pq2`9tLD3F^r7L5rT`qY!&q!_Zg{M*DQc81Zog?z`l*{SLssOQ05UCG-3Mf-FEl zt?~}P+syPp@m~vhO6D}QV3L?h>5_kj$H~+qmOk)&$GPsl2#?^rZE}a(;>SOX`34k4 zPoh)UpT{D#bICAbQbvQYK>eL#lLQ4MF6uEuq@HiiV|o_@4K^qLig&_b@rKl})zuJmIWX#URV#RBqHQlSWW zgk^5tpQ2PQq=*Q>Nb*rFVFvb_-d=rv(To@>s1f+`b7h{NM;1@SVC<KJPI^hm+lUGnwUzH7++ zw^6oc=V2Xx*GA)fQygQ|J#WaB}E24ZQa@Wx69Sx== z;9&8K=Nv#uWrNB=Cu;1PX9)~}cpnM;?41?lE$b+kkL7I|dz>Ng!7&ThJ%#W(ZHDG@odW~Vd z(gz)$i+sp6eKXt8vF{<^(E!#70v`%HSg|oLD-kdRg0_?;RaYQW1R2cCEnAmi#f`qA zZD%Uwb+`;&E7Yn3cRrn4p*p_U1DNcwvVg!6aN zNxWem*i65C8sa8qFkQ_oLjoj@8};{*M2|H5*vAfq!>QNMPoQ5$>2e;-CvmLUhMAQy z2KhkKTdyIo*2KaiwA>H}>%k)_zGYK>`=9(M9ImHLua!FzcUO^qow>Ma(5PW84^ZXt0bHRt>S>qQ$L!V*@dg!1Dc4?xE#+<4l*wXv# zpI6VXNe#8Q^d`Rejlr*c1jS&;9uZz#t1T4#cE||-`CbR_Fr+7t<78f z`s$Rff9yk+a3m#X-=!b|{Dp%sDL@Id-pjaL*3`*WngN}a$K1*i1DfU}w;sK2hS&f zJ_3F-LmW1{6C{vCQkD}8R91Um&m3ALiWqEjC9MKh;N`RIl5YK)Xhn(l7uE$&mH*N#r&0dta*%JDQ4>oRFRDz$=>NoNt=O-2z(h_Gs(*J+9cBz!88Bu_ z{>=1nW}ofsmu>P>D_(5OIdfQCvPE(NGnYU>gao(15w$%_bi{u<@ihnMCf*?KR)9&^lmx+&{Z!cqVltri;FSD*Z3Gduyj3WD7nM??0;e=mqVS| z>LidBzWXe$ar_Z}fB5$RY;zT{Xm2IYYD`ENTP(ZOJ>y8(#haC^0G);5<^o>FC!WQM zgF9p%rSfr6odk5g0B!NFYqP<0ebV_318cKR7ik0?eJg%szD~cA?x1n zpIv?gOt~gXvgUah#CLo=K@yd!JpQ*w)`(V13d~4xN*$}tdZ`jh;0*!g%1)n}^P=G+ zS^(@<`nZTUcHEtQ5UcednE39$(HKOeEEAU{89dlpF#XH11cFAr*~g#p z$Q(|kq+BddN~@T%zCuwf9)XZyry`I?*+^39?t3uiZ)4HFzp2{I<oWF%SM*nRkjGk) zdFq50S@=^sx(}x-ngwi_FaWH8zf1^hY$-m$*vz`SNL~2BNu^UAurcq6<#;=yxI}Wc zZyiAT;>f2M868Kf*QUO&RWN!vr?8TxKdjq&OmLTDb(7vPurCRTSAI&78TrD(I?A!9 zh&NokE?>3DS!8F%2D#R!5hX@c$@k{oMGmHwPoSka9rnbEW$56k%+f?m2)jAg!Hv_r zL|xyYJE`TzyH8789wHrsEHXefGhmlF30jv@jxy;iJaTQ!d>5@R9_?@_+lCy?K-{>AqrUivrY?!a6@u}Z{U`8~)Bj>(?m`2$6JT3HH>N7+XHB47s4AAQB(j|@M zACX7eR75-0oU^ZA2zPvP&1*;pA6Yj`nRRoxaa6Q1Qzk|Uscvk?EM`A1Qdl^>ZMS7V zBs|rN&np=Gk(}yNb>4QY{}rOoTKM3v`P4w(TlM?^Gh!XwwUyREkwh~TNi+_L{)Tiv zNtPqi=o%9HJkFEfKs6eskuwm`j7e4M+BvWm-q7K8I+|K~WWJgQPQ?HA#*AHN;wROp9bzMu zu&G-UxEVL9d=NleL2^UPcT|vOhuJ_nOfUbL;T5+9BY4Uxu=+|*n5Ub=#0Vd>gu6I- z#-fJANHfvq__Hfl%roD`b5v1i`9Kn!eEn?)QJ4?ZZZqa8-ax}75@;T_xZ=BC%xiO` z7=+iqYE&5b2D(!FwnXOd*sQH0Z+*)cKK;_;xbot!OFI(wvElq29$Zu{}Gz+qs_ zY+%gKKmF}L<*1l= z2#k1;;LM{xXYtW=$BM2GQ<1=`RGn=dXsIDQyAr^al-yyC0q;ypO6BYoYozP5s`Zi9T z`*%sh@~8fI8Jf~L^q^>+e*$V2Yjr>C%7%~0LX65(eA2XwA8G4)^ru92d;!+_J&pTJWKa!poR^RULl}Q7ZU(>EH2`m6@V#`Or zUo?}xbl4?d_bpeMSCPzx%}9FMPU0P{OyV6+zixM@gJySbi*KwUU|`CX?VBpT7)gDX zJC{g;H#zD(C!G#|BHr8|R_l@srY+GS0bupf@1~Q{xNzW7#;U_Z&!mYeRE+ z6OiD4DiuIzT+(CAM2{vG?fpKvs;Iz(m9sbWQEUD5^YLQGi6gpm`_xC~F zylCmvof^m)vFb(~*Co_9ix~04TGDSB2HP*Jb^f~~sG#nsv1sV0BX_sNSECs)P+lt= zX@m3;^;>A8YI49{qE1&h#X5#%T@wTLey^Fl^WvXqDYbief6u%|BWZ<4Pf6$RJR_O8 z%{DUa7c`G)MWQxi(ZThN_xjDJzDmBF+<9RZ=!)Og^AW94OsW-#tMEmPw>e%HyrJx=D+oZus>il{NP?+thAYeeV4 zcxv5dn0UM5{Tu00xo~^gN}|C34<2C$f1L=ld+O9TkS1-A9k*~S%$Q!f@IVbS@# zimVj$=2={|iVXq}zqbiVKf3x#+^CrPjHVJCCPQaKA}tkvLkj}KzGg(zUNMh!lq!~) z0@GjiDiRPr&N3fOlsUCbgBu!#k*GP|R9l~(ry}oDtyigF;6C{9l~EclWKG&_aLG3_`4-Q%1gc>$cz&NBYjsYu+rXFHL&S+R}agcJW(oOx@9ok(C~KS3AY@Yz7}6)i(8FddL6 zxx$Z@ITKkS;^sc{OIH{(x1RJLyGw4nCKB}#n7vh?sl_`$K;}Fq_Uyo7^G^XLQpb#3 ze+3`)LDI*ZvgZi-N$n`tlAKqI;&VR_dT08kS&E-AT1#8?M=cxZnCJ* zn6n!wMt&t8Yd>)FMF~k!p~b*om^>L64?gryv0bVmtKffCu*Sw4H}fi}==a`m7D2<* zjEnUq((H3cS{lw~Y>HAG(Pjb0Eec9@QjD8Izcpb_bqE_}p!PYxb9?npmaKM>FRx=t zaTk$l95xpTOTdyv^A!KHoZ7}t_wotrj%K`y>~`X>yuEQjaA}E$%>BxYxr*@YKB++a zjj>eftO>zcSH-1nW{c;fzqkU2bvm!7{koYdKu}O09oKjW`GA zp%y9m={)$q8bK5!3>A1Z(;LoBjW&39mpL^n)HlOn7caZJS!a7ZSw{T^RG{|*9*E2b zWUkC%?-Y&Y;=8~VTTtv8ZdFI#q_rKToTZ!ekgaU_D7_~_gPggu`ZrMpodtW{H- z+3E8NK`)*gz6Z(q=5Y!we}&_flkOBUvvXjC%ub|;Mqbig>-c@5Ni9(X zhF}FaSH7vx@~CnwD0zH~-$!o888Bw-=_IkEA1WCfi6Zu;r-Bt>zg3b9k5>O%*-ok4x`Clwca=CJt@uXvSIFr(4$wFfCw zZ@&Me=`49kNXYSlT3<)&Ci$7}KTywyoqoHsQ&Z+W-Hm+viL5vu6c{nLnJLBxs@yq3 zOE31(rD|3m~c?H!8pfK zxpu^L<*99<-6`5tr<5CyqoPfj=y~<|WFl?u%?Y%CiqN8thxP;mAWgQP$FL-R)NbG9 z%e1=<9-To$3K;&Yok}@K>$4*(L~2|tYiWIV;=58pX)EMrA&X)LC?h9>GrOxwqCSnG-1}#=DR-PkiKWz`suvwn|;o8Ep3KT1`F!1d%orj6myb z+9z9i*4YV4eUBxDsSmkAg*(*RU`;TD>QH>*RQX~BkqAKxTbjtT04%b>{P7gl<26yG zJiKpFazP#JpPS=!MrmwbtA{yJUx&Z2?yl-ZF|_m$o{C;>FOO0R)(AnL!;HweEgJH+ z_xVc0Hx~sxo13n->&X^qmmrB~`{=iAC8V#A{K{rIlu=o`A(vxMWg!;s{I9Z*c&S76 zxPyYqmnIUt-}Z4#>)226I$>^-hxIa z{)?;|jdJDp??A)Pmn8}lBA84yCon47&ySMw?kFFl(Waz4UEg@kPC>I|R1PWxuQM{% z({jg8XL|+uzexzpf@RJE>SxsO5M* zqM+daD?;GB8FMp1h##u#X0&m7Ym z_Nh;Y=bgU9j-($17|0pXyRnEQW&3`&RZg&Eo>z{*w?GjZ1t>u}OdvfSN|HopFyTyG zsB*$^=9r%oZXoN8xc!cN0s^GY5l#I`YBr9%Dl%V5Nu^ef(ldek!*GVlhE__0Fp$1L z@Mp5gujeC*L&Px0D1{TjApA-1S0;!*5p^rYlSwsW3*~odu|(})MWU~`;KA6!aoP-jMK{RBCO|`mW%*XcdSmnjWO| zSC`aai*DNDJij0DOaHQ_`k=ne(5Gh6bV0vyNCLS zvY6r|znaU*o;K5&wm&uFKh;Ed)8qUO1#2;<_hF`zLz=jbpT|&xYUuM%lU^juNZ=Rz zoY5b_n%!(?@=Xrxq0hAasT+7s!|Z)d0|Pgj`l!flG0M&b*hTMSOMFV2E>|)QU{E^l zL%=<*FwBCH>cT!h?_(iYGVAtLrNAcX+K|^Miu))96O}J#Fx1LY@#>Ojj^GyVqyOYe z@;y(WCCf#XKjmsT?qh0tz4AV+%*C&iD=`G5|4jKh40DonB0-fPpF_0>*?vF7ES);@gm%ZQHt`W6^{mb@8dH9o5#!C&^3=yfWF+7kq8Nv zfUPRJ{qQe#dHhd5P1|*-hw=#==A?LtDiz1ZKJ&rez zL=Nt#r1JM(S6{y0*myhVKUE1y*OqqJxOegqQR1evzKqV{Z`rq?c&VL)Y(FgJK^+1n{qfK6>nk`_qup9o}qV zIS8i1gIoObTi7GUE^5EGvW37xB=*lQ_t=+_?zC5%=>J(JVG3&Vg1cLT7Qst&SBV+^ zeH*#l43P9Q-JL3vyajsT`j@H}8lYNsVHEL~BEZ~F1?1}FA|Cuata$4`{>Wu4j)KLt zkp)qFTPpg)YFik8DGMTD2)xg-{q(T!oU(h)IH|E3O0fICW0ZV5u*gQWks>DMat6x$l7tgV(p$1m+81kO?D+cEk zl-q(m;1vKBXi@ysIP4&N8M(I_1R}+G=pTzb0mPJkEgtmbMIg4r)+D|9M9bP)bB2_Xd`t9{+>u z-<UX1^O_<20w6qAT) z!hcPIwpYS_{SpE^{@(TG={X7(y8wk|-)B9HLjSp60RKxIZYgqc>zue2T9kg{NOvuP zJ+XhSfCqO^!kKGu_Orro{wyH8Yd~aTb8!Lp>Q3bI^O}8^2Q&+04}7xqpZ{aAOE6%m zn|YkL-JbwPF7Hw9#Zz1Y)QvdqXfyd27g+Ej1HLQBjSNNmf5JvY<1iTLwS)u4A17LX zav|t4M^K)JM%jOM0e;)Y11Nl1=JdPt+txD5*acxA!NW4lx_%wG?;%N*e%T^2o#)Vf4#qy>rai7Qx>lu;6T7eA(tdf@NO^m}~vG zg8eA|FB?q)G;^6ZyqN%+b>6BZID%o%w}k-+{|h5D4^B)@>3ipK?T35jygTzz@L!s- zSTfv0g?y&JCbeLN?B&K{>)8O|jmEvI&l!rG4lp$F*+JX!`4r+Vg;SoH$*lKWu=|E! znGJm4ei6&9|78{n&)n^!CVe%uXn9OAj>LD9(FT z7%vuievz}S~fz5gOhD+YUU ztpy@okhW8F&a)P{7!+kl1=vgcEk#&WAXxS|%wJIa&eMP3j*gsi0sS~RFXjr4%RcJ` z)_1>MaTR=;4cO0A5s*kp-T6FHRB@2txN}&+GWl=)umDx#BK6kB++UAi!A#`%OP{E5 z;E!VW3O;A1cw7nU-)9LV`46ag@I6vMf(Jx<*}u#5KXNJnq0P{xfLyr$AO2&pEI98y zz7ziZALG$NY5>roUnl?~ytUjK!Z{xAj|=CVaCPZ;OY-&T*K;u$h|@t<`-I`&rPwv_ z+@S?d1c8nAnsd^!;IcDCOy_O!^XcwBu8PU{U0gb^7z;l7yAz0Y#rw|GzXAY?Eb9U? z+#!i${=;mK7c4Ogi2Fks5cg*%e0TiZV&7nZt`z!MKjzyxaoKTbNmxq#^4B&Nd6av1 z$XNi%h<}j;UN{F=X=VVfdF0~u^yvRkgofk118G-hJs+G}!Pw)tMtvF{JF#w*f`(Gk;kH#UX_xWexe*o_QU!Gz_ z;|dIUXvVphHOT<+%TD?Nm;MtJh!;HIDJ@%V*Eyp^#A8QKc9DB_5|Tsb9xF!xG>h(+ z$R^yPSx&G#kd(QRS;=dl1;6Qb$0 z^VyXfrxZqfEim^g{=dqirErMLk=uItFN}a5PZ#uAd?{}E$I8y@a};XC0Sb#*x8KL% zGTq-YkB-1OMuC0T=UgoAVR0}_pj}ov|8NiY@YHvi`46-#DBA6Z@C%K^yc0bGe$y*Aem)X%>W zinRrnwvo~={GX_UICt6HX{7jzcU&e~Zr}rI;rAckkN$i6A}+mzns+>Q{jZEzfN(XR zkv#XjM4sSm6}X@0?rY+|EibMNG~$@kz=;15vx|M13;?Y=9M^e)xrqNuxX9>KFy z_efuB^<3Yi6$zN+3TWj0eTh6|m)hG{!!}HC{K~ z#rWEu3nMAz<#>iJsdvwDX!+|`AP$hYID|ja{1b=hNC7bLD-#D`8H$5O|Ecp!@8On8 zg_jw3ovSG1On_yGf8w_X{zWFxj^38Uk!AT{a-4BaUv~nW$1ZS0(49|uACQ0!9-ZcS zW9MQc4Oa6Se22k4;v9x!v5B|@J8-A`Ph0H=12H)R@KD8}tJu7g;=insEdbbD6K;7o5^QNt>kOQIDH^^bS;d zVU(TgpO&xr3h2loNK4J19st70Mf9^Ez0tf4HyDzDR>ZpyrWd7~5B5`p#^Hz9XhmZ$ zq<}*`2?tpNNc75dQ5gI%u$1*%y260p#*L>(6W_>U>>>^#TmrU-H$WXf<2uPjADMq- z#Ok6z`lzU{k8KipIq_-AwN;~oRYSEECDi1uj>cbPx1znmi$Yu?^wb5B%BS5Ztblyo zrLnU<-RV?5{9|_X0?s)u@XY|E#Vbm`!}iYDXU9YW(|1!M>sL9~Z`6;itAQM`g_ue1 zLJw~c6p1e64cbcyen`)ksIr7Tiju+lN_;t+IoEm-xLNnOJP)Sq|mzjWKCBeOQ zkk)k!liFz$-|>KQqAtAXEoC?>E_|`VI5g7ZdACC5Xh9v{KKsgRSRgo2MtUMLHDp)2 z^nz=+lhRR^s1`*8r6(p*mjv`Ul1e)B`upf&4KMF6T>a&9Z<@!tbfWPEgc1s&c0`WQ zABZG|5;Z-&!-X#2&L7XsgoW`b;vUV4<453nm2UsrOBIl2D4r;KGX^CYlcZk=)e&Lg zJi_45J`yg3gmc#p2o4*<^QhhuJqAw~k55s-t5<5J;l7~}q;Ji%Q3$+CGK6wZRyPg` zt>qB4(z_3r?cC+MlW_688~_@trtk;_{+?yu)BY*G^fksX6H9fZ1PMc5NM>u)d7hG# zyTR+9JRHs>U3Z23RT0R;wjh!la+_KSh0sAcplhWKYl_q!IjHEDuSQZOscn4nvK9)> z)^MrM?DD;Fn)P>bHH%9^0kKS$pc5)UmlEi)T*7JeoCjiKMF-X@E#bIKG0BU+e4u8@pY>H&@I#83aZjN#sI;*oHH6rgo9o*LyBY#ZV5@B*bxcnWtCL!y8aI2P>OrB@h=%v)jZpnvcs zWcLpefLRf)J$c(a=lDk>xhcJacLcHAB_pAfoG<|;MTa15lp;nqKayKAJ-UaxBs7=m z43UI?QT}r^n74c+iJ{To@I$nHPvmm})ezWIWTb=48*6E9CGt<42_(=RMC|}XqmM>< z&@;MW7R>O{4htnZR zPzk|VNG7;i*QJJ-^Yp&IsyEJ1GGAtgxT~t3@sOU?V=wyQH7}b>xHfP-4!&q+0+e1*X zk<(tS$oP|^2K@lzhr%1-0_^+0L-%hP0?Kra2s#X@0(DPZ4b}!=Kca=;Tf?0$T=)F#gvjtr`0E_RLE@r zsrU%}TG?|;KeUPV$D4!~d!t={2l91wqsM4iAaAL=X{GB@xK&6E5w>^f=#+XwnLiCi9H^LIL8bV& zCpa$%YPjW!J>wgoqX(0Mh~z7WT?Z8CJ=}s0uXW@4gexJBC|Yro1-_e=4Csj8?BFOw z4nDu}mPRT80@a}0rovModBhf7AYQ@d0HRjw@k5aWh6_##X*moyh{@#KB_(w_#(uZT>a86DvBzwQlbf*Vf`IpI4e43o~~4LoS<6kloXUEW4*elgqmxzwstnO zXGTvv0LO9(n{#>bwh0VZ#M9vubWp0% zRDxi==n2-AR=NWv=ubsiC1Gl~&EoPzZi8wzAtFoUg`MS85WMPRbX4`o8%RyJcR|nf zQp;NUx)Zkm`MsXA@rxbD9YIRcA3OwtaXWDA6s~YOBzooII_i$SHK1^+Aqe@Sxof|w zuwq!CFF!Wx3KYYwB84BH@Io|Zr5Ehv49d5xV7!oeO2)?{FYdzca+7QypkiYhKlQ-) z)75_V-+|?EcHW5`e&kfffHK`wRH2Z0dph$yh<)^TB9REd<;fpBvSS1+-4!Ur5i2J? zi!|qH$VeoTGm}!9bDp(Fc4(P|MM#G#!ofX5x$W#yRi)W>WmqLN%E=Lr_M=bis-#tk z3UsuC`6b~m!fv}s{x6M9l8@H4*RJcm`#N(!h(G4{5sdpZ4jr4bTirW6_~xev3oVVh z(E$zIC5i%84>JpmFdh6HdQA8Vx~<}2)8F)IZlvO`rH4&>&I|Y`sblc81QEBPo7McQ zQ~shK2_KU>%{F=#q$U}b_42oljB+_>$dn4Vhu3OlERt5&);WV%pm^6FDg!Lr;1*^x zVAF8y$6rT$rkuu8iB_1t>(>SaM~m2^GK)mQHHwZ9k_Wa-v&h_|3>B-9C!kNTK`GTS z(H&CbSdzC(H|J9m7*!l!fnRWVr1_`uIX;_u!!&gaV9|s3)1PYd?@r}}0S$Dby1}Y& zk|234OJkjI3uB6wh+I$2?#Zt;6_3=-u30m`X)1T#S_tR+HTC4h5Pwt4C8xEI{|e!O+N^5u_<{v%FRhQ_7bfUL85YQt!JqCP(FtbKTJExB0g zis=H=RQ!bFV?)C2)VHK0jLAS|>W{?AiU#*bK6_;YvIG5Gjd4Sc%cXD&vP@5tcy%>% zP5X1Z8gOt^F40d`3ub1bj?I3~Sv~_GN-O$whFWxIh4)>4?~B!8u3x9GpB^Efrl-t3 z&=P;{b8l_t;pf)jmZ|l3l)It5NFdAqd%hF;LwULL!TYC>wOFFilVYgERq1NKp;Wi3 zXKTF3?HaQ9Pf`80Ep;)~e9a_|E1&J2eyy%TVtdcdPAFcLe6T`Mv`gW^or?2!$%k!hD#p} zZy!W8{iv6{lqh4T_nIsUHc2-t6J13=e=xi6Q^h9!<4LpgPh-0M;k%OE6MlVmDpPvf zdNFB24?>=~B*DM1Y}qrnHktgMH!um{w`P(1eP8-k!0sOX{l|w_osbetapa_OA~c;g z8rSI(`CPtd$-Nrv@!dQZZsE%m@9*p@q46$f_JP+Xq1?j>LS=E@4g=Pli5zj@T%`?i z9nY` zHOdaxYnvdb!lO7R?!{&HLDER34)_>Q@ib{GCzxL^v(I3Xy;4zw4N5NUE+3mZG>yN9 zOP^ll{{jmJs;&NEQ`?wjAOpx5tk|lhv z|JSRu1-y+=Iu|w+^vqE<9g@4L^)N?|7pt{b&C1_ZToV5Sf2;r&^KL z>U)=0hEsQSbE=8>_UO#V`L8L?Ja-N33ue<~Hc^R*v-@4UAv1BLDNyj;!$S-3uBwAHd#8e|}#Ad^VBy z0D&q93`zeOR}_9E*UcU{ApUWy)Q0m}PsxnthZW6Fb#!ltZI?NxA|dZC*VR4i;q-K2 z63VXaQF)2Y=SlWVoQ!A@_de65we25};6eu6e=x*IjrTSJ>h|!*53*;Mf3qxrxiiKs z7v#SEE{5+Ld(&y>fVGaxj-a`vd_KB&vYVzmTz~Lcj4h|Dm?9(jiuiF;pOw>f%R_8` z`s+)<*&(Y;b z-QB-SbvbyI*{3|~OGg~G1GX0tbksX89#p$or#td#)66Y&JXD1F{bs|lt`to9;m4d& z^g@7}pWUyLmtj3IiEl(*dR;2sWRGSzT@kXrS0X@~V;{&A0&kl5@wDFb+h{%&@@H6s zWrTkM)!$hF8@G~%Xveq4F9O+{zt6lSXmEvRIO|CAMfj{T~ud}*LhMX=DW^=l;)x07jFNi!rwy5|;wcvg7T?AteU zaa4m|u)sa8(itb`pSPBeD2r>8lq*MaHpxwVW$38rhy?#50&%kLD9)d!1WB4j4*Cxb@#km>Pg?d_8a|av@y&duHX)bH>|XS{gl{@c^cNG^dR;j|m>gcbSnDiHe-`~4H* zAYb%S7Y*)2Ucv}8rg^dooE0`Zfl%&0?sTZMEHf}8*Csobx5MZCOx%Px7{u=K%VIuSuqC=GI_gVq4N}t4jv4nKgY` zF3i(Ag9aXEv`&&L8GSuBtwk(H1|1~oQCCB`{ReX1tv!qAJUhwjzrFPJ(uUOLYqp0Z zr*E!yvF=mhg$!{Keq+N&$z78XgK0l2jr2?FbEkZQ8we`nM*r}KEwb}J;Cp#NzqC9j zs5t$d9<+iP4HS`ETw*Ti*BJTQ{OeWaOc%68Gb=_n&1_8c#Kh~-5{@OAdQ%yC_h(dn z>e@ngMvJX0^UZ9vg-2lc9wT!Z^b6@voHL{5n7Xf@?xAQ^S(*GJ2fbdFqel{^XFj9s zM;}TZrgi+>Ih`~KqH)AmlZ??F;)KpA@dRPO<&gs+4vNu)6dWarze0+jHWM{bY2Z0^ zS1_<^kZ=Tz#eegb&4x6)fxM&cG}(!fOIE>}jz6R7OOcr`h?{0wN%eS2O1ZNxAFV3B zl6G;hNP@{v=f9!Kx+v4RV;nZp!AQbqPDpgzqx0hF;m`2uJ&ZjbS%zSLwQZYZ`HL<+ zcDOaMvSqQ8{R3YvYbzUP{DqVZcI^*5Zf&pSb1i>PJqx4^LIFMSkR8c%T?nl3E(VxC z$;74MUqiR+sMuXCQA8AiK}4RF+<_aMFuNATp9qI&-(TCi3L2KU>DKaH%5k|xFbQEY zq?r>m)_h&LVt?yh>1;}w*p#%Id18;V^!kO?boR1(9t~G7@lpS768lP$SnSx;o4UJ> zO&^TCRAV0$#wzZ%_Of(wY*P4VP6rE` zubn48{eGUXxadn^+G?;)v|>q)cYF!Gnoy}&X8WMl^`QNy@1n1De3k2I{mS6kKz!ME zt1mr~;Hqj@2*wkUG)bwX9-y0_#%-&uh>3iwUx?9U*Q5)$$tlmfv_5; z;qLcNmG>?J2hMDJ_kVqT>CN3gN<; zF8$KqDd}k8(-(Y{g*37_cI1~*b#B$E4)s+?^(t~)dx(*3eOhA65-;uhr8rijX!vai zVN~K^h_wcDE|=d_#Eieaiz?>M3(@9`p3ofs_{6rt&vDlRzmBFHJMJHv``_r;>k|it zDu`um-hdHV=Lr_|^pY5uNLnF%%gru$V1#LOY+)o6j_)G=o}05wg$D1Ms>+}(g$&q{ zNJ&=%cPPOE%vLFOb7UssnI8m!F}l@;&JCpQNHH4WJi>#B#on-q;eiPD2%AVWoNvqF z=j-jeuAW^GIbFu?niYxSs3?wC#bVVuCSD+staVBK;6>PA%g@SUTkD~~dqG2^Qh6a~ zN_}Ys>ak6?$D$uZQ#$9kMGcT1LV+gi_cDuNK&k)Rioee$s*{1LITQR9#*EjYvW0;DX4575c~8 zsEf!AgzZ3Fn%y*$D-ku37IJx+QbMYk`F=Ojf&y~d8T93g*KL167+Ji^hlE4NCj*7d z{L+${j5_!t3{c*pUkMVo**Ml#N0S4h-!@k#Dz(a3&rkDaN+HT(P!2j(b-?&-mj$6J zX~|@oSZ<=g_qtEq-PkQ&ry44upsPD1SzFSV#$e=OD7=(Rov2q2584#d(~qLQDJJ zs)6Y4gy6*d-8;!=IStP*+x|q^<569{w55<){xqj{YGmt)K}znE!nHJ5%dM1D%go|f zzf%KDo1*B}RmSf%?D29BqX+kejWtP{mq|lqi)(He+$Fm^_2Ll!`-dBWQqwnc1XGqN zq?5px9|u#iduR`U1a(Q76p%o~$;xZZM;Aw8iVzy{Yh9+ODBbF!IOqx-wSkax6d=~a zBu(;KgJpIDzq|FSh_jbfQ+Pq)Wl`*&a6ca<`t@-b%=Ap%M__h@#93;5KhCx^T%!u$A6S>ayCUU?R-@ z6h7O~Rmu08rY+3^tJ!+3>2!6^8;OVvCpB%Z+Yxrt&Cyjr>LZi%m8IKV-$~7-V&63(|Re2Yyy?hKQiCr<3G$&Y1UUkWPTgRpq zFNIqPx_4JA3Zn)Eg`af^A7bIG&<9edUGD_$0~4c~U2ua|dEmQ}FZQ4lCbVwzSsmlJ zVtxJ(l&SdD3X1;9ft$Yg3qpe`taTET@0@_s9liw2XV%u>fTgy}7Y`_;zVX8J6pd8t zC{W#^Q<0a=GIO=~`?wfthmgv$@lCjZ!YBw7aCL(2)XbRjqnQ5vQ>_Upz7Q zq?8S&%+{R6v!-MfMY{)7J>#AfykdnVLHjZ0srIEezP+3+!r&`yEMOz4%C$d2bYS^n z#9D{Sk;2M{cT;D$-AltN6jiN_JQfSe1Vkk{8?>jRNe;wZ`b6Sdyh&IFGY~CoQT<$L zeJi^y^rSa{yPK5FqnW_z9ks@+5Pp#kol0>owF}6F0QMu~8_{)21Ze0HErGDuI%z_r zR6kS5%$kkv<<6Ci82%0&c@I=(9GEn0%t?(FaW7`>M|jB^!mB_@H&HXE@rQRwJ!h`t zyh$}1y%o2*f27~I5Yi~g{%I&nxY>N= zF@kRXwCLIcK9pR<5xWUWF74Uf&Cpnxo*FC%tTB5o5OgUB!BDO6mDw6?GSx)teuFW2{_l_55_fG-=iPN7M=LG zEu}#uz@Ud8eIGvC)vKDMe9aU&p1d}Fc@Q@JS|x@Ha$-PMLdM&`A;OMW4TZOG*$_c= zH+*&Fn59PpUy99s5>g+t)gUD$(NV~ii_xhqq!HNeArti1#5g4SU33!*)vw>*{GJ%G z++dG)Xfcq=TO=y#@|Ek^C!@|6CZoAgS?DWO?%RRwV|@n8isVHwy*{qTHh1;E zcE8%X!KqgWm_VZ{KBmKvMIFN4-eA~+uji!ByvbVR-0At!O1qG4<6lX=D_K_xu*F2{EETRL0P z%h7vG>`~9-bBMqLkRzscyrjW&RBUGl+R*T^SYi9rFl$D^yU|lE(0Z}qlG7Wf$>Nj_ z52rzL_MCS^*;b+Q6v*1kgSuc!Lg`3*#i*0wY}CcUI+zlMNfJ1!&Bd&l4hu!MYFM$C zI&Yvk=ab`;0vuAa^~MlZOKO|8C28Zy!qf_T12({gN#J-CxHx2g&BnR^>=7j2cl@(* zGM7=D!cyoai``3wO8sJg3s|-!v%9|qDTf`Ad`x132bE$2;*+_bu`n0&rmc-5$J9`o z<|oR_^Qk7QGucsr6lR|x8cv6;cYM3|bXYA}Dy&M`#0-`YA4YG=)6wI<61}dq5J_(; ztC=Lb@5;Jcwr@N7*`SyvRPO>DLRHh{Y0Dib`FpOTeGb)a>yyJJcKB8Y3 zn){yO$hULNPT<~+CI&Py?xJ!C_OsGsa`ZN%XYDI%^1riyLXBl_>S+!;W6ZjvXWnJO zY zB;Hkd)0*!3Zlx4sck2B~7TXLT=xXimsxjrE5O^Sw+D@XLJV6)e)N@awkO)b`Tk2a} zyG8?~nCxU~RR*uj9!P*j^r)axCjpnx0t**WT!CGz$PEV7@@|H1%3*lk-DZxM;w!hg zfq8T+k<*6I9j*u_xE%U6RaflDCl6E#LLfROxGcZ>8&e#uM`@(?ouey!>bnoOp0(o6 zLeLSo`vD4_c+Y}B;BmIf%;dt+yp)a5b5inM+3XAoCu>ES}V=i{8uzwX9|0{ltrAA5D%#YQ(eW zv*<*SeF#NJj(Doq4RPnyd)1;f>jP1H2A3@Pmuz<2dPHbX?hs#06XJY*I-yl25kpmi zu^_SY8_~*~Aj!p_r8G}=c1m*7>z_ml>p?}=RwW5}6lHZU%B6}Jrs?!+k0@K-v zrDTU;vR?N7Yh#nvA*XAqr_ja$I?n-A@uoJMzOAJOF0z19A&_mZyWBCTM?guof_cIg zi76Gui_!1(4i5Zvg*BWZKYzG@f-1&vw652dLXw`uzPOoOBdgSv$?5~|(oTrzeQn|> z$Ooh0xnf6i*fbkEhnu#u3@SQXzEZ9BVYthlkLkQ8Nrp9W2jEajK|~z?3K}cRG8wll z+i`jK6P9vWxBBag<$<6p38@8vMa9+wvy%5387FB9Av#5g)0H;fc<%;Oi@2PlISXqG zVyyMsUiRqdg+8r-$y1qNQ3p!QX$O0Vhg@%r*U3H_1dZ(;*XTyIY+X&gKQ~D&^)fSP zL~*FpP@P_(adF?do1O9cM6*aT27U#9hKW8#7d)0o{Z+_qpib^dxu2rEBXEevRz=L! z!OoPb@$5lHjYGU;vJv#@6|eB69Hon#BEl(saE5EOhlxhh!S}*>k@)nXj-S_4xQDfq zKZg43rKFe2auzwdbBE`1Oafy;;208z+QQw&$6z#rH+n}pl4`TKNsgp=3@=sOeCTv} zE1b){G}q(wmSVBQ1IbkrPzg6aKe1#_;dqFs%o+{5NB7cD0$fgTC@fQ{K2|%`>ZLN? zrssB}nP*qtKS39glFZwA8_4a)G6h0<|{A{!461aI{M;cZ6kIv`Zonu#Ci#i9G26Wg|TvF`mE5KX>MGv)nJOQ zXG3b8-V-(KJeUEda=-a($?l7@cWj2+ZAGXHo~Tj5Q$%2p%-pfsRSQ_Kg`Eu%UM%#w z`D*2(p@CC>iuQV`;ZE*JldLiDNS}SlkT(LMiNQ15sLU`xnKbPn$KErTHi^0{lu#J= ztFqkl_~ai6EsUy%uiK~3cNF&auIm}h2)&^Bl;d{R>)eRDqVofH(wMq`uVqSp*{k#G zLhTQYe98ek_T9~F`M6TE z#DuHeqbthd2Ha21Gn5J<3V_V_*tN>V?w6_Xn3DC`HD3_P!%)b~HQM z{VU}fJe0j;VZ!rnaOB|KB;qRq%9AOA@MG?dHK#7}8G7BNvQ^BA><+G^m%rWa|4M(H?NNJinHMi( zZMOK^l_w$QQ9S~C7Fu}mrLcG32VcQYNn#-ZNCmj$4gFan`ez(7ZPT3#;%U3`_KdeO zt3H-ZuElXfzbZz)=xcH^xjk0na@eR2ZrU^RbtP`qFRm7~QojFWYb_y|KkDVX zZagR25PpQYAMQMK+b?-S*Qj}WRPi3k3UpkBnZ3ope6T{OgyIE_rvGYpZPg8lXO6D= zXfx41(}7!UU>~O%*KdrhV?Nu)nn9oWO2o{-b7D{L)jJrRs)I>0ZcnddwGU2UZjG$1 zT=y0ky~NVKwD#!)chi6ZTzTWwXG_UzmcSt*`ZZ+wv~n8y`e-!m?E{^yNk)vNy2fp% zg1(Z8?5IcftJL!|3{72^!;^`uPH!pGs57`QliK8$ zwW|niq^So7swW~bE?;;?LzBT?frxsdc_c(Yh(>B?tl%_7qGjqB`^$hlZymEH@S;v zbIqd-eAXTGQurndU}s)olNrefuIQOy3d0gT;8Jmh0gqSB>pR>Ht?%X zV?yDvujot%1z}?MDd)XG{(=INJs6*fsrm{h+aZCP4{04->ZmqI)gi|9OvJ!;UUhf1(*I<9dW*VP@oGxmsCWITj|JeNb}F5SQ|>_CEAmz@%#S;keGPPs%r} zSrqmQBPW4t8YPJN_Z->}AuB0Gz0R|9GtYPhrF>^MKd&oRh}^63pEg2WxIM4bE~)Hw z#f3knE>$PZ2b*#GYVz-WQhyse@V&^{y}+jLayMz`&KRxuN6t``V^v&f^TZC0G+B%5 zYxqi61hOQXw+$_9*2XcZ_gJiSlQTp&jBBjBG^wt}yY@#@@Nf|c340h{a+nb|og##V z7F-wSe0f{pDKAF_AG>OCd)FIk-SsL(^~&p{?nHuockJ07$;C#mU_ck%&M1gKm}}Lf z3^5iO6@zXr4@_`b)d&{NIGY*^>)rmbWtk*>V78wL^-_-846%Rg zz1j)ePmWSJbRMCM6?Stt)x*MdzQSHps%qY7ET-;GAbgdO8*gtgfwFD1#+@me=t}9S;a&QN$WBESsr{wd^Y$Ol2-k+U3X5?d(6om?l z>fgOq(;&%+Vc}7WS^nJDm+hgRzCY9lBQ@PHOvy`n09wx+2%#>gOs|U!eUH2?6k2Gg zBYg5G#jL{xR!{MIQN!gu%gmRVP49&|C=t{OQ)y3a)i!mjj_(px69bM z-p9z`z%m^nebCDuJ2Dr=Zhgm%`Pbtcrw-ev3<;D6>4^Lmh)-sI%XdA+rM*G0rCo;= z_ss?#n>k|hei0MZWZ)*9OVagnqi>6`d|F(e_Qn$E7)b&c)#AN-BRO^pV>+I7{-`oT z7p?)D?hRt;}V$n0IJC9zYSeucLS&O=kpLz_!9Y3U9 zh2)l8-D((e6MT{(=K|wZm{Xo{BU#|k6g%Tq0d7wvXL`Vq;5XQ0|xE4l;nPPq;=Ij zoWFRR%y2$wHxs$Hr^+hg9I{(CJaeX&EbhEu>e0>BqL(RBRq>r)px0Dj9@k$mvKqC|D@m z!J_B_dk^a7ZD+>;`e6=6RT*-(Z~8Y?cF8PI?x;Y3{DOTM$;^2Q5wzM6$6e$=a-H2#UeEX74@-6LSLWcgi0?Y zM7U%nUv+~c3QHS}zOfmFlfEnOJW=fkosq<(7tNf!;g*wgN^((3TSM4h7K^E8eqU!A zoEA>SNxVO}>vA9uJu&#C?R&l8x@v$a1Dc=-e?nk z>GeItjoZOyv#~tdq(15SwhVIXJz8+MRNGdyub8aiP^BQ1PB65AX?0yo&Q3Q{f%8!Y z==IBFUdg6m3Aqe1x39I|Xc8t@d;Oro!wQtQ1O2|eh<&n+tf=HMv%+SJE;ATiz2Y#Q z@LaO`cz^+Jw=>5{owm=zT3RY<`SleRs9zmiPPxom@Zky7hdQ^VzR#QN`VQhYwJ&hn z1GB=d`R`#^<{f0`!6=eF?A~kzJEWv^*jqy+#Q)G*FP7Wqq-?9^lcOA))v7`vwEjqh z%{M+|EhP#Q8`WH?5ZkGF_9)w+;rU^YMp@Qm7=N`juqZAU1rpYzD<{X zeQT~|z2@W4Q;E$4Rg%PLxmPl;gzlIY+mdEDXp`bA(BMa=DN+`wq`McQ68Pi8B*+focB74bSN?37liRP_e zsy@08%(s@CaNkWVM7AzngeA-xKbMe)M4-Gbg?i&1rvT6BG2cL5jGVkTXLZX(@v&{g%jR!ul?kZ zm+IL>$IuBd0gF;e?k|H!&Ub;XxGKW#bnHF8`=Q`XV*w|_! z`(}_$U3jd;HC%X5bXzwH&gIm-uWL0tvvWYEdnRvKznXM~)qn=uwWMdjtkO~Zrn)9K ztErK{MdxWxB|}dI5pRx0DN#6vAfvhVa}%H0Q$zB&QeF7t5z?f1nfA)j@4d+il4juJ zvuIRK&Fat~!$C2`>*Odp@#~1m%{U9?P8U4Y^)BLh8NR1A=tF zwvIO=w02UhiyBW7TpLhba3t3c6|?iF$qdZ3r5z2wV?V)%D{$X&$_J{O?Q=l1)$-NU zd#*ivEwM4VtzU1O;FcU&{0u9$vMlYlvEe5%0%I>_P zEk4a>C=VlZA3>h0F3~+)@A7#=$!IM>uY^)`xu|7VlBr`;E)dDSo~v=2gHEYP!g^ye zW3`S4bjZZmX=B@$9mOi>`7BS$9=C~bGf5Wpum^jTSSKIy|N2gGyi0sFSgB8&`#yWK zNx7JFUHN)t*-bpZ`QD4cwhuqf!*8_`AOl-C5*HgRRdT6aJtEQa*>*ZlQ=ObmJ_d3- zD81#6xhf*xJr+A}7b+XxvC?zzirR1oEUJZeMKVvHIsyKnd^Z~+v{%E0o)Pu=%#9($UW80gLr&ZT?)E5LN3rgIdrnk)7n{)n< z)amd5^`)xk&J@6k)~GO|8TyosYc@<)p4r9KXQ=+cVd=cFTBrOk;dkC$pk}v*uViWq zTkR)Kk$gbf-3dPm<8b-dsPE54w7}6W*2hat{IYMrf|s=6^+vc&CWAw6NHOV4?%EwC zsvbjce7aGLaJA^Jkf22R1U-1$P?*DPT8U19Xo8Y6W?Tlio75}}+~iuKZ{UBR3)T;O zer2-4$DiHa>(K~d)h`=$^#@Lo*|cAxw{9rPh{H`&5WY9wES9vMCe{pI(GaDnraXf` z_v*FFKBy6G>5X4>rRnZkigNK#WE1`{6)Tly@v=Jotz!1j5wEQyYawSx|Bwb|~ zYvH=I_h6yrmG&yd_!*s?uGs_UEm4)1RnF3@@d3j9satzL{bJh0tmuoCE!1+Uk-eqr zw`|t)n~4n0M!IL>KH0zIy%C$3RLa3cwEo)SnbA|J>%*CfYY*RhnVks9+Zf#YW&<5m zk$I!^NrBefI~b}~Wv6WZxM4Uy|MKqUcIwkvO^?)z`jot{60^Z?gorZs`xxB>-AH+G zyEPcFTXTb`r(HJ#{e1uH$1m^ONGE9X#DP%J{s;LfPCi{>!>=9XLt@nFkZS4P)i8+% zPw>cI9Jq&BWmeL|h~Rc4?2k`-OGS-Mzv&nEyKLH&gWezBye-i?RdKpi-m7c53Ue$T z>X|f(xmbS^#!;ja)78%a($*B@80%=biU5or`I&GPB;Zuciq-~>Tk0Vpv0)Rdr7&xUqG79q8K?vEj!TqIFR6;KuCL{*LPI(S zbu%g=Bv&zt_hTwI>h`9}P(~DK1~>`v1ty2tmuNzMmUNLxY$l~mV-~(ePt`y{&pu5P z(Z-NIAreDbQ6%|AXjR1T{|e;}++E`3Y}Di_To#o zCWaVB^PN9kkzMP$+?k5Vc=(2WP5&(9m8`SEx?P_JX64OFcy1H#^?q)x^$ekp2T{(R z7186K3&u%PU3d0i)!TKokF1jPS#sEj!t8?Vge<$Y2yKEn6+k>x;jF5S+H{Jgs!Zs) zbvSdz1tm3y?4L64h-G_!Y?N)8lkwzTc)%erJ~+gXE+|k_lGpw|x%|57q+orr5saNL zPS`#`)8RI6msz-jw)b4r2*+#Xfr1~!Y*%U#$!S*~czJJKbv=Di#+QdQ$oa4*RChvj zn=L$60Xag~(|14a{j1HnJ*OGT$=+gp2>D2h4d7oexR^S4kPaNtpNL#{h|FEIc-7PG4%x zGUV}`ljP~g`xdNE5~t?_$c2%;^7rMPN9XH`;K`-*1OZpwdFS@xr3>8zsS=$ddH0>OeyaJS&@?hqJUg1bX-2s*(TTmuXi+}+*XVesH? z0S5QOx6b*`Mc+-YS!;Lo?%Hp?_0$>SNV+oT7aE$iebJ(n`B`w`i+HzSi|BuQ2-$+6 zO)SQ=g%jSY4IbG9$>Lw@uYrve)9yKAU27<#JWt_eT=MoOeI%b&P~;mITB07)Dc=Mn7U{oKez?*i726SNM(* z)z&l71#K6M%j|8*8Q@~#BtqQ(I&7*}etnU2TyM)dKjz%3ygPf8+0=PYBk#)*1>XK( zOow$Gvhch_zTNURFE_V0_uu!T>y$ZR<_e(3Rz>hk3>Q_hAIcQXDu;+gDyJ% zw#x@w$NNjN-mA*#=F1D6IgYF5cq5&WyF@qC=MnLMPUh%KtDdMR_f@oG8~F>@yc8 ze(6#Zzfa|+t^`XfjmcanG@tyrcg3ZqAPN;07Qhpp1W82iYWCf}6MMABSnE?W>_t39 z{jYqwulbY8GrIf5x4X=2TE5@z zs|a6N;m(I)tq<<`jI`9~dZTK)2F|Aq?;QcLy2vLtdydDZjy!H5*i9u?TzYZXxv#|) ze6_=e?DUw4893udMtjDl7*s?FGT5O03>}-etWYh7CAGfF8HcpFGjn%(M$g6SQ4gS- zXg2PhCux=WJ8@;Rhp?xN$08fE831>k!_qndPMLok06yvceebWAF1|d`PG?#FEh(9! zP@PDK^uXYN2#Nyd8<8QjK+WRd>_i6g@*SL(FLE0j)E5aO0u`pb8Pkm6b+ng~Wa0FR zSQ_O1pm6YOkM=XLf|knXy!N+>FeowmSoBhGSw&%@z65oP{d4{rWx)zHHm)*Pqw7nh zHtmtNKh8Y2{fLXA3GP0+^vcLQqN*LRGJ)28@uj4-XqnOZpc&zzW|pf|M_;#$6!%eb z$S6@IIUDlVriT|==4(Kg_=krIgMlDHbjO@VQoz!N!>WQdzXZfwd1e;R$>YlHzEWC5 z{KbmNUp{SBOO8x4aV+cg-cyRYHU&bi5{M*IT8*r*sv~UV#=B45=IqDs`^GCTQ zOlP@_Fu+G_(?n$W;QZ;`GFC@n-Sor!Tx_mLf$L2FXHF=bVGV*l*V9OPde2s(!?4pp zOvbLB|8nl?LQR4+6hw_q$Uk$(b*kK32mkbWK}lkLvddVT$$4~QsUeF)lpe4U&Uec<;rk*24JA@WJ1tFrpf>|38N0a2{1d;i=)=Wq@n5Ma2o$yJkvI9OLX9~o zM(KuFvUYOKmQ8WzEye2b|Cm3&%!E5d+NIH5I_=>ed4Vkfv{(c%*fA9LCgOnps_B(Kr(j|Rmtv{yQ}k&vx~dg^)qkv> zBF0OfBHX5Az*sr1un3t9PN&|kwjWD%v7S)Ub%cywm}q07ps0n%k)&_SURtWb`TMTy zkPnP(@Ueeoj%XRf@vqLA!kP(k=>AL;Ao!o2K@#W#Eg(bu753WwI$-qRpj0^)#Tcic zxIsE&C})$e5a*a8fo9H4wx63&wZL`;#b(t*i4nc&Y0ErG#IRhhvG7k> zyp-CUH6%hcV5iK`v6|ov1*bS;;a9B7WG&#`=r0kcG{`N7(;1)UJRp&0Q}Wdw`kv6fmX)Cn=7>^H3||veSHLEOc!(Mk=<+Oib~<{l;Dt zWF5y}Q!uYYUX@`UMjINsE#DXP=byxVj+M!s{bigAY-oNa{9b{~t{;q>Q24o~G{)9U zJD=x|s^#=iqf zJ`4Q&a;g@I6$oeeA%g>ZJFx6}WkXz(#hvjowg}z%GVmI0;KBs4Bse&HwBmPE|2SYH zTrs;m^FZ`xP*+#uw(oPen1FjE}wVH>$K zIG%-pGiQHzG&Mgfi6M!(bdh6CU$dWrI_k@G^vjGgrX6Q^fXA0_dPNs{3VXyUQ;+Vm zJS6;6IqX0DP@j+0`Xw6N?NyKt9zJsh3twfOJb;)(>mNUhi2X5zt)molHZfZ!e`HkH zv1V!BI$!u|Sex_~8P#X>en8r02q;c#d6uVJS!be7>TH+&tgP4(ddp^ANs+y9lytjX z$c)77!S;NQljy#{@mlYxH`(GD6hX&>I-Hi$ zK3`tWjJzC|oNbrD*tUS9OiAmfC*)cKYQlp2_^8FbI^XAk&QZycB21a zH19*{nmy?6zkiA?u|AgbpSr8Eo^;oO4daI&=^;QieIMBcZP`4btYl~52ZxVZe6bEF z|IA7~#ltfyExQ`lVwAAV^==p2R#%eNQm05LcdSgPEf-@ zlIK+k+wU4v`_?e}C;r$U6DUP+IH>zzR)+>ZXgcP5o7@74>!dJC%<>rFGQkmZIKZ={ zL_z87B(lPz^Y_BJ>hqZB5bFePnV~~EchCp;wk+s7+dWFlxx45pZLTXUK-S^rZuI}p$z!W z9rR*~ycwfHLXT1Oc^>UyhGq)lerD2GGuTMH1YZy<^aW{IEro2gQRulDWBIEy!5oUs zSY2*pQ7oH=ZC&p-((T6B1S0n&KHDvFVt{yphLnXBNnc}BjOI*|L_(*y4*5%qQGUwn zfEC-n^QF6~+!yId!Bqsf`y`)Q2fh_VA+C>))40OdB0uUyCSZa-vm($Z%LID>w8X!(8Cm+E~CsQY1nIGZr{7XC}(OnZzqJuvpUp9F7TK?G@9i{t`Lrop5T-d z49kz=<&;e-C6JBYYyPWT`ZBi&3pZfD^xbNqg#W26qt%=Z8S}EQu>BJicXGXbA8X~$ zJ<5|abJ8cj=jOK!sh@&#{2M5>to_8NIF)zQwm19knhM;S%E6VwD8SJ^mJM=yU7zW2 z1p{{7(xE87(W2(vey$kpn!|~qG%Fp!6!rej8s0Hm%joe{sH(e&tIQqWva7*Sgo@4H zx3EyhOe#Heb%C)hnlCUU$|<-hjjFMaPa@Z8)GK#>-ar}S|4a(+{rVeraA;>iw|H-B z=Ye!7BzU^h=Hbpe(mV*ZZ03Ebcw~TJFh7=)E6A08w9+1hA`RrM1fRnH3VKG0iSLnK zu`?$*RZxmFa?=bMri%|yoR2UZ>B9)h*S^)An~D;3SiG#rP*oPX(!MdrRwWr!bb$L~ zUAxVCIC_4NtjsQT&=~^y>PC8t2_X`RklD`jC8wfbaDE(gr}Pj&@Cl@~$9|8WE({S{ zOc^xst3Pq4qcd5xJdCDgl=g}Clt*@I?5AeYQA1=9WIoM)C#oEU5&XSB3O^bfc@@=M zt)L(|%X;4<`OytZ%HbU*MxP4on~U)fexzDkj40)T5dt3Py+}#n4>F3%!}3PJA*sN2Wt8J#)DIzSj^OOO^4*p!!pOz_;F)$9^%xeB5MbVZ9qSiv$g&xS&+>p zxo|{G*LZ}^V>0ZL+EodvFhkiUdcb7nX0p*p%)^tWes&vYGRscy(|>U4kkn& z71 zd|`NMG9w*H7I%ew;Y3vHqQM+i&idRSkT}{U!I;3v%t$Zq_O(BlNfZs&uAcw{zrv$h zd5;w9sP3DaoX@SH@_*p5qNjhXq7PYKXnU|XvZ9a9zUMw0vB2k|^8r!6H-yV6(WjEi zo}ZT~{>ci_k+HOJ6s0UYhzip!yA61U`jIoZCG8;*dMOO=*da}+UxW!sGq9A%JeZME zBsekvwVtTOm58&XTNdp-l@z#8XyIgh8wo7^nW>g9)w64KL;G&k)z@FGF?KiRLp|Sp zR~sSxl35n(*eByIfO1Kz0EQ>z;%#M}4Bz<@r~)3HCDxYuX_)An3Pr^iM{?_Yi|KD_ zGzf-!aQry}huiXy*;a8wX{`zBk$z>riR%$~3tty4dC~KX*4kLU`K-NQW;zK0qX9GX z>f>UNatm9sO?g4Zwk0e(vo^nu1PdB#8XUa8=12!xi}Rj}F>a7Bc1`abtCBxWwiv%L zbLD6gpuhH6JVx^2R6x$D<4Es6^k0SekwjDUFA*u}CWE`i;}VBD0SVXY7KNiXAhIyROH^?2HCL`v(&>cu(Rs~Y_aBT6sXLi3C2@ojLw0iWR63 zIT@iRY=OozcFApE4PMY#iT1-iA-WCNC!Xe-Y{Y3dU^=U!i`R%x4DwgmepbFziq|hX z^LJZ@r}uOqyzQA2tr}>Bd$0CFq+>e~mZbxtDS|Qd&;m;@yup>t;?Pgr67rOku)@yc z3}O67oQn15ZFjFq|1aY19(SJ}drA~e5Jc}ycz~kMm$5sNFQ{`w=Axi-F2X4!GX%OqnaEa$Sw8)yDd)G{9GQTw5YZn`G zokip{6F#MHA2ik^Za&0=*@YP-0wu~G-t=9P)vr4!%@GFG57l2cQ!r|IVK0HM&u>R) zku_;WEGCNx#WSvv$y_iJQA15<{YhhOc9YTnVlKli+!su}k~&6oaD>^OtjYZb5$YHT zK%f}aW&XQwwJ`>Hc4y#rwbt#z4+0-8(nX%PYt2F$_?B(|VKaJ-A4#4Pe~vI^c=tdo z2MR4qC76w|`e0d=P9?#?=fJn8OXcU|yG#BJT4Cu4 zdw6#Wmt}Z7VoFU0N<10>yu>k9jSJ{oMKKp_e;#IF7G5n_Vgv&aNtoaqFqu-iK;bbf zk@*`RTFoBj5=7WTm@r36)&gUszJCzIfsB`tT94k@r%ZmZMcgkpd5<~Ns;gIDj{eSH zi8f1_*9V*`NQr-IxWU91j`7wB?=8A1rlL>pb~gjWyoO#-j|YFM>Gh$VrwldC z@q>Sj3Q<_}l3+hPEuPSg)P*`=8188;lnz5b)liNEed##Q#(w}C@YL3DXJ%5e`ak?m zb!fZhE*Vrjm4~hv@#__IPBM#pZOxo_VrP`8^3tTLo+p*XI1X1Q=csYWQltFEX6~}$ zvu`iUalK3{kg#))FZ&--mC7rA$SKP2`PX}}0cSg3o@Qcq^rO4$J_5|*@JIk+cH|n> zlrA=A>acyx!^OeeL=tV}4I*4QfUCU~!rmxVhv%z;F=<`gAzpQC7c7^-(HOw1v=zYi zfV8-;Py(t51#zT&|EVugnx20z4k2#P&`T2F=J+CVjA|g%O>;NtUqnjPCU?1g8p>u_ zA?~}fm7FpCDD0?f7A(9o%)bZ}1OG%FKBF@`x}KZF?JEF@YHVQ`vBSwmM*6F*I4~9a4~dF zoT;{%iAR@)rdlO?CZBxa;aKF~`OQddJR4*mA60PHRwMqhFB&k3zAaDH!8|Wd-E1Va zb!2wjj{wQU-O3yxO%qK!`P!3uu2t@p4*FB-e@K7W4V8mX?vLisVBlikbsa8%A}W?HU?QzLAo zja9TSe+GU)wT29>n6Y^Pn=CqanMWIFd-YJ^2C;&2>IL#4T-DVZL;|l*oJw@Ye2pp!McsyPgA(d9Qw%`cqec0=+{z;9_@u&KU&FP_g=P_w#Bm5 zJFp$b?;ga$%qjBIv6q$E1$(HrTN(H`cfXGA|1&Y)DyC%##RcYA##B(hNM8|LV^O4f zpun~NSwm07sUR(`0ZS4s#d>#-K?m@~16=TD(i-fxWr=bkU)hh~)#^)>i9NYuqn#;! z5DY4o_wD$N!$|mW(fKyKa!{^LRD)MR0>QCxu-~rBva+_StcwLhO1a`9u<46xW5DI0 zL6KP_sxwH{X3l+tA7$14n=qWvOxg~fryo)H&Ro}{Ps<{Cw(04Is8pIr%vpH5VJ)-0 zl<`+dJGat)P`$FVs$06b>hbQvcu8@P+dqev<-jGd`*+cn?S#_KT&Ln@^NU1g6YW2!sUE+LdWgoE66b+K653a?FoG3`>~!r!Xx8 z4599p9Ru9iyA53hx0L;0&wTfA;rIAm>MsB#)MbRd*Xp(4*bMGPFByZ&t+EV?yV<*>1B*=X z(~l(x#NFkZg;CZj&APoXssKyhoe@=fKm>j868um_ROh`3OYw-|_!*Y6VgA$ftXqepX zU%W>Bwt-kMk&mj-#9yhuQc71hKQl>m?GKWMWR++-P1bKOMPJUlutlGir?$gHpH+R| zd_;%e6ysji#aFM7tzIQ_%fK^spWBe^R@_8JGE5{w_XJ65a95M;l!GG~+A%+x47Z5h zO51BP;iv%oYKF?21lUcNv?CpE8;H}W+QJ*ux&rSyAmykYuD)Zj^-a%*or5V$!>M14 zcujQ;SH8`oG1*swaNxM{Iw1 zH42kU*^~C(c4-1%Bm!S;gv_z|(5|-W-jtaC6f76!xPDMaQYY0CiAwfB2*?7)r*~V)m6+2B5+Nr?hk(2V{_9%8E(JD+`}Pk z-VYb~Ghf${&jP6uaLqe2im_MJv(#B(x)*sC%1$YEQ=;@-R;C-Os7+m1PxER;<4+TJ zuNzj9iQ|1WJM$w(m1ycb>c$3530Sjt2g~er>IU*F^;MhH;~W@@;&1i#ly#xGLq)8M zza7gt?^kGbxc63t)Gds}qm84HTF{s#L2b{d#LWmy*LBX!pRzMI00I}VK2QH6SB15A zEPVKPfHc`MPPzdF67K6LdFK%9n(jVEwNu*&xM{=VSvYy^?xk_0&>ReZCTjDIh&O7X zJQA;vm%`>!EsmJR#eRMNZ3A8}YtfNwp#6n?yk{CUPWu^^dmjR>D3WNz}qF46s5 za+#C~_IuekyE}~c5u4-=-)Z;yeQgr9NPWCnmcg#(*hvmd06Yxe5DRu;Wrg$aMA?4K zzJ5P?_V}_wu&W@Lx7c>p!Xx4@#-e}1bXEDYtGf$dZ2d0DW!u%t64bZg%xJA{Da=x; zCV3dJtCs4ic#Z0`k48uCE%i*(*G-r3YT};~Iqo#%nVAxOc4EDBoG^C$<%a4)ul2P`ie*H8 z;!n7s22Z8VKP{ZeByYn)F60Z3?)|cR={jb+#Z>FK1Z$L=6D@89SIIbji0KR1WuigN zl67QG#AKt8bWA`&zv+fJVTprhpqMmT*J6W`+uk-9p5CJ}@5)MlEZPg02UE^oVz?4Q z)8F)~r!`0aa2ebko+8>;b+sIHTk>{huv%9d#Cw3WfaYvH%C{91E39EZb60ewIS>fM zMGEpJtp$Oz+oR|bmr4ocD=g>2+9#S^mLj)xew~fbsZCl?1F7~P^N%$NcK@vUOTPFh zb@}^)J3I`&%WHL36L?Q>DqYqn`!&9#iVTdGO5Dj@8Y+%Ei2yQ5c& zX~Tl?;{#Qqv?3e;SOZ;IGJTdN)tN81>3|gmv-&Hx_$rjFN`&}F*go0Rt~LA7L;acx z_(ca2GSNpFG`t|6DH3FO~(6>ws0Q_m4a~=DucW$ei=z?wh{2_NH!D} z>u8@82MAhCv|{j!uQe{Zat+WU=rll2lQA$wxa*hb=7+RrT)SLhi#f3nd=_Zqu>5{C zO@6@Mr--7dL-)Xqe~Qb2H`%QQPU5KSN#*dF7Vh?stnWn#@5p7u6=?6ap3%|GDi7e2 zHP}5aid5wJg`0QL(~~}HoiwdXbPnfbo!Su?Y<#?Z2KfQJW##W@Sth4G%B>B3Kd{0Ef6C;gAGx7!!1Js!q&K*a)zi~RB9mc#`72OT_XC+AQ? zM3%ou$fq`ahBZZ5Jku7zEC$!eA=|Q!kQ14G>k8bG-%x>)!QhPd8bcA0s>Mi%hOCCq zm3m14FN04B7c3mrqMYCfB!32HW^)D(kYS=$?y{Fs?wbi~Fhp#HjKlql)&H3H$BT5w zGXc0qA1|%}@3!FPqs~F9#Vz%4I_i7J_BT=fj4yb@9zMyaYMG(xf^SLw1LF5(3ziiO zUK6+}A`Df#aT(6LkwxofEbop`T_;n@E3m%0R_C?2Bm(xC-DD=;&UP83^r&y}DZOy0 z>DB*V8#A%b1zf$!;2W3xz*bd%<(d)4uLHU2@ouM<*2gpmdhj~O1Ma#?EoQfX^_o-- zpAcBF-%Vqy#%_x*ulwY(NBka;X988*)tZxFpWmop zzVvMd?$YNa26o&mkLNM}vHJgM0oc%43}o0+OCe}R5Gwy4FvO33EKDgA>^0kiv4OQ= zpH{|q1_L}!m@CBh->-$gnces~ob6#6*RUb~`r26=^L&U4jx&1W<(g;Ih}DE+_ouv8 zcjgzKSiVVn=JJ|kf3J1Fa9&6u`ntcKe>8lhXng(Sle>L~^A6~#^KpdRNh9?WEFN=} z$&9RdYki!Y+mm0M5Ov!B;(e`Re%ANoddK;;VM@(2-s4`D7tx^IMhE@>yY7FvMLlIHr= z(2HQs9^1zOP;N>wuxscZ13D9lS$vxS?iP5Vz=Z$OoPx^;a4H<$r{VqIfoBv? zR&WrwZv7A3p8?xV5M6;GTtDYvsh- z%mb^dVa+Eg^hvIoH{&VXZ40*WcvRq^iMHZ|W$9wRO=YR1{xEogpn+`HACWp~S)dBpMcRKZ#&HZ%&j7Pmas~r{ zukGDHwD^y1%PyE@BXS9u3g0JS7TGJp{Iyz^T71oDmjmrt4sH|;&u}irtoz4(4PaX| zt+EzRPU?uL_X$@v^s}eC8Ehv$!%yPs*zvRK@J(AxNvIdY_+HL+g}n5@uR^kxFnlU| zvtIxLg7{ir;}{!-99>Mel7A+!-}$=e`OviP zXb&(biHwYZCY}l31;Xc)Sq2&Yv{-;Xe@o~>#$&6xeR;`anrDnbaCW=A*FkI_U~}!Q zob<}-el5?FkntUD9ewxQUjzvYI|N~)f89DgWis@7*yCfoyJj?9yHx(Bz4`F5>(O^h zLb5pWcO>|8YI>SIHz5WdFE4xHO8CBHv~2OwwrPw3HJRJ|Z=GM_e%l@k`0k)tV^66@ zVQbMARHhbm=WW-r6`(+az)iX;P4y_e$pYAC=EMFZrN|?JKM9~O`Cx{i-qEAnZ&Yh< zjI$PKzJ(bPP`XVq5UwkP+5hPlNzJ~1qMSgKuq>iwfLl=D6?6{@Wu#x!XqWT<>M+_C zD1*|VYbM{h5LV0LlaABp&CTx~mH}Y6Jf-mC3dZ(2nA=Ic=efXsTXpn+jQ=Bq@%tV5 z&zodXRPn!x#h8!a>|gcAZa$yuuZDg%xa+&>TtR>LT6Y{3%U$qxLZU9+i_tV){ec0U zON7P<)^6k6b*ZfDh$_p(0?WvsgcT889&b`giI)@#*%!*YC<3yTTvh}{?P&)ijmL4-&p|9nF zCM(i7!wRpKzLbs3H4wERwgqebG1aP1rlqG6!I_~5lICa>i1;+&7&aUq)y7^wc76O} zvNl%p176aqfZ$`YoX&w1dS=EIoVRHhsqy!{#LRXn_*MH9bnpeBU&9x-qjV(jIt8S* zzuFv^-CPRKZdOdo(#F?PD%eAJ4Jc(V=(ubg#Hpw$i*G$H?L4-);swh-*Q(adUIU== zcn6C;$+QASaH`CZvEu$cm0jRsZmqT>_62AR7YlY9KGda{jsKtI4n^ng37&R{?|#?|)?`gY%e z>RT?v^zrc&PxzFj{ZaD2aWgp^-LRGC=)cxBMQ59W$lkvDUQ@B2q4BZV*&!|hwyejk zg{-NM-yOr5F~Qg|sa8A5-bt~e)Dq#;gp|P(?$)Fbyiar1ul+~@hnF)`&ssQ6h?(yX zrzX9dimwP<6-NS;MhCOcP!ptAWBtfY8mUqV=cqFM0Xc@GLc(WK8wxLkcnBq-Nk%2Y z?xm*%r69dT`+LTGzRUO&j$3=mWXU={8-V@6Xkykay~bUJBaC#{1>@V%pN$=M$92!1 zg;inp`RGkhhZBkR+jJy#`uh;7ffU22MH|E$vg}Tw<7y!V^?NnY8& z8J{{^y!XJ5>Rpib%{M&lPm)EVatotQ^m4b-#=X{nTLdX@jM6gGuP%+sPg5gS=H>+O!E6rAXSw1$&N3jwwHAU`{%@&N?iV1+WtV)usvZZG0#A2 z^rn60wf|DmbNo#|#&h6w_C<6O)+4%d8HMlV03O=w2AO{LYlt#1#)#x3y;mlrQZZ=) z0SeZ>F0&@_5iissC*hn>9#zM=zsi2Cu)cop?SSm-E1bM+}D;_i2ltL-q?Tr5gU4uNA@D;dumoCQAMmF*<41kY~k}SF+1`= zMBcSg+7``fdsZ8p4tdvrdIPLmCu=}Bbs#8=&=sBXau_9(3IDP~=0o%Tjiu_bYmKh; z)ZL-!BtZd}{&`4;bX#!pNf+>!C4`iL1+Ryjky58}UKgt8mE3|*PW!dup&G7*SLEy^ zYP|HWQ<8vHa&F&T&^|z)%Xh>Exh&k$QH_K<;D)`RBnU9E(cc72G%XJ3+jt*xsywsy zWzm45byZW}&j^EWLdMBxx~s_Vp6qZHwlxXs;T)s$w&s4RtSY#ZCd%htVNm}@|MF4; zXX?HlUKKzo1zSX`#0743VA%j?IX`3`*?thc9UbG^s|qE&u9J=34#6`F*EaI!-;ERo zLQp#Ud2_{gc%utZb-H9KyvMloYzrACC`t6Fx3a61xeRlNw`=9c27he#bNO)vMO0jb z&{9g5jw>g)XlukXx=SY#wi`&fYU_9g_A{bpcd{o{?kn5DKP6VFoa2d{7=5~uK=Z%< z$9J#&GY|HCQ^!;cBghAaONX^Qy=>g%9gtdJLm!8XA{Xyvj;UBX@Wu1+d4D#?v}EiI zx& zxj!CtYt84+m|N6I2hoY-C@)c!yl^!P#yTEauLKgx_lJCSx81gzFpEqq-{&;L=)X460a#fS0hyCdA_?0kK+yX5G~ z>KXx_Lm%+6VwEwfx*iOWS+@qu)_}&ZZQzHqO-sq|^Qkm5-81G5mXV?C>D>OM*2r}6 z;97gcOLxNud4*o1rf{@avccc1UaQ$&yG^vNCGvLox{g1HZC)Yx5r+4>RReZ!Wp7k7 zcW-m9K*mR<7SZx^ADaKLME4|BMZoXUp6%l>Nx)|2tjx@T&#axP8@hfOMijt|!3$}^ zL|H4EXZ=_)`jF7`g6XDm+x_eJ$LZH0a%I|oA1OjM-_(G&=3~*agaNI{B;-)#3FfjG ze%uEDOp1@j3Y{WAj*pOh&>>V}LT9)uh@p|&AWD4)spj`_!^+``Bv0o18W%EQO}(5L z1^Ej*Un_g|IzbZueVI`ugTkR7$XtXQw!Xovb*1Qh+e+`L`S(@YZ{`!r?saOZkuKQXFz0p12Pe5AIwFi<5w`A3mTm z$xDfAWM9d4Dp`uf=r66d?GHP5mF+xa|80}FB|SMN`!*xYa#WCo^*({6xW_BrvDonK z-tOUKY|#eIXiIyN&@+>24d|4w0;Sd%hBI7%0PsMYD~-0xS~W#YmF z>@=CIj*j?te$oKs-tcD32aDWQ0sZn|h!On$wH!bXybt$>Vt@T3p1I7vzE}R~@hmI! z5BrgwrjvTi5p9 zu;4+Luv7bLBM-ux&+1gDHd;q~SmJt9W@*A4CHhUBluO}BaSHg@wDX|+Mtk5#yo^#R zjy*HpK>JQyjhNixa`I)ABfsvn15>F1A~LAQGVp%;qJfb~K8C8;xp~}&}2|EvrerCbnnh~r zhv-CJoxu)g?m*Em*>Bh9(-^Nj*Xi;K>PtlZ>*Jc>mT#QeRnP8h=P!Y88T;qXJbt#% zQrhD8fAbRF7zgWQpCXqvpPJ4+Fs2QtTQhAdT{aMj7eqVmq9?N|4}hDu*8_id8o$v$ zlr`7Y$>umVLF_xAqhk-}lo4e}J~#8)2ZPQ(Acl_7=EuWTbL|~UJB3}4TuZsypIFiH z_th`xx0OtS5yXh!I*iTkOv!%vT<{HalRfwSefxMNqguFP%WpSdi7c9sP@6wK9QW;; z#@fA0OL3VE`qM#Il?&Op4WZI4jz0>m=8sN<;2qOyszz;Ws{5a8NX-i}{OgP-YfXs~ z)S(!O#Qt7*4ha&`+M}M(?4Bo)eQnieE@r*gc!|?>U2!T4v7Lxrc#1p6~dkw`#c0B=GPo)x@a$ku?FQtZ+wB7US zjWW7KM9Q>Y-h_Da&QAS#0;6O0s^8AQuGKW&a^1a$nKhe@ODC0kQxvC;)ZKH_1RY-( zYsZU&9_cD(j$gv4v!?b9L-l3CzZNd8#BCg;um^Trky8*U*A7{Z1W99#x3VG*RRM6Es142mg!CYau*c6NjVt#>MXiD%os;V_)fQnqph|5TGB^*Kst6{&f8 zLOpmTP3Yvr5~Gq$6eEq5y;||Cs+YfwW0Qb@$vpo7<`?gcvkM8j4H0Yvh9d%3HG>;; zihaq!Br8wdg*=K+uYSr^go23yJ36}IAY-OfAzgJO&x49ES=EE3=mLJk#N8!hUc<-BdX-qx!jNS*a5qJjQP$3*9Pczah2fm(h}cq=8MtkIN#G zPeEG8YQnPDYwBMZTs374vCjy#-g>`HjG3-nvb5vA#iGc@OB1hJBQ^-EHS&P~-&D*S z&0$TYEAW~}zfB*bfA)`)V>BnFA{;Ccg2_2XWc`OvVZ+s zmw0m<1{L*%(C@NmAD(X;h`M3sC!>G)dsxiE9<2x0%uW*2fj29|^^j{!dD!EJja?lv zV1`J2O$xj8Q@grOhOo$@Zp)b^nD~*NNAeL`&kZ*}XD`2&z7)xcH7e!w=^@oE4%gty zo!*}v|0ANG$jUb;3hi$>`6W<+68!J+rcQ^<_)wX(Itl~HCtaH=V{1Fg-YgWIInNWn zR792x7*YS1wrbh!{n^C;DuiJos_@mHBp5EBva>4%9q< zTvsiMVMD12{*=3qm^<;iD#BU0gt7*?VV5Vxe*A{c=*GDNT163~2AwoepQv3YR8o)k zOTepfkPD+%ve0Bbcm6#*^R{&3!D-7NScvmEosY|Tf^2YhOI>84*MuS!xDTRC`(HfP zhI19vB2XHrZ_Ax4L@X`jWRT(tCj{K$ch_;zoJliFbM*&8cyg2ilc!&j7*E&K9ex_o z(*{!2Z4On5G8{=IkTM*~unl&7`jF6nmoXNEQ&BM%66yaQFZaX0UhL75MQ?4T)s6c@ z#Cu86KtkJ^=~k3{Y`$y#ATQ*v!c4H=XNzgXrVB2!Wa)`mN7l!2kO3Htg&Mij1Oipqt7 zx+fGQgAh+MO?2OUB8Z8gcM~*V(z%jt_+22VX9E+&F(Md}AP#7R43Ggbu$TeA4sEV! z1w|!6SuteD0akQts>?(M)Ksi@!C>$qcS=+OO)i-7m!iOUha*J;ows2cAOmE83{=7Z z*1RF9Bm@Db6p_#@w#tPu(;spH$js)=HQQt&cpGqc(x9GYh~Ze0EMUByN|8Y4Z8)+4 znkNHffDGhh0PCbAIWevGT|guYA>49<^+8wJb5n_6_l;ouinxAqWEryFxqZz7#M`MI zfnD}COao+q43GgbaAUw;U+wa(K{W*%eP2!tzIXd}RSH)N0+K<9rE0#onMCl1NdUFd zi3b!j%$39t0=RP{=<5MY(*zkH17v^2Sy%cc{-cmrr= zNSEl@*QDd-?aW4Qm%Rri!wU`jOLqGBQtK6zoZI9Z%mWq|Q$N(8217si_1NOeqm(zLo@zkTWuRiw1`y!`+ zjJK-uZgYvC|3KN3sxQ|uxIJ3;S_gWfBm-oC41{2SHhdXy_Yl;5ZKmlDq^^YZ?4M(V z#S_9>Gl?Ju2eOP5cfz2)%tEf#KqW8A3=xCP1c4R;mbyXj|*mR%^A++G=lGi&e;U&i;n``@d_gZ-0AdpOBoKBnOh? z`hV}R-nG89-v9dzd!Iw>GqdV24&5B60O0>{J5)1pxBw1S=HtE(7eGwJp(+j+z@f@~ z-1p%Eh>18<#o+=tRGE+aK3o7X5r>*c`#oF$DHDgNJX`>WDDi3AhYKKO;t-XG3*ZnX zK5hGO0i;YEqVkX`fXmN(yInT(ZFZ?JTXPx>+zsiduuSrL-imRGKWTBJz zZs^{*u*%RMrvrqr9m0ay*yv37d zTIJd0!gGlODW8C<=A8{&K8hpJ>!uxrI5Of0hYmRh8`C$5Tkd8?;$@4z6{J3Pzaasanp@ac+gHp)v~I8 zyThs-58BLWw{it>wLv~gSfg^z6})E4MYekL#dhDO%WT!AD{R%qt8C@Qt8K-GKLXc4 z{&mEsD_K`@y24+H|389X2LA>83h3R^T|!d0c=`=q2p3PEkGvTy01NG+X$cFMNRNrF zNPVQ#rwbLrm+X%FKW!JC{uaCZ%(u}uy4t8Jfazxlg$d^>VYgf)Wmi`0RhB{N-S5ttym2@1qyr+!KKBsEFz9cA70XT9U( zRhJ+=466_oW*53l^T!xrJig{mn`(D|WT`D${UmpTx5{}?p^P201x!2PbvAA8U8XDJ zp>^l84zBioNFU3x#zCBj??=yolbfVy*d3UPWQVJeyNC($4GrB~9E7-JdPx|@)2oT) zUHzH6bw~)S;KQNmMOL_Q>Lm>Pgx&eTX7^znw5E(6l#@?6?zMch3*X*7yyjf9j5lgs zs7G-7J`#+@7)ueD9-xe?saD9DU1&>Rn2-}Ly7E`y3$3gpeug|0ZQ(Vkoq3~+em$#D}3Dv^E-eJ z9Wbj+YyZd>tjw~Yw2x^yzJ3`%q$Av=NVjo#tN3l@ZFu#q;${>anl3 zif{8o{$uydHv9RXHT#8MWyik-eiQsWP+G}OoiW)0T^%V`jC_)UuK0xRBG6snd4lz= zouE5`iUJ>uX3U$>+lj30&CCe1^wZRhzT^+O)>3<-BA>qn_^y&=u^29xvWPf+ckuVP z{s#TPwF6cFPCmb^@iZs{zrsheSG|Fqz8$;<=;NAh&mvI-DhT&(n_{|=#%qoQ-3=1- z&0QN#rbq6WbDrl|l#PVx^W$(nab z3L;)okS-KNND9)MX#27(Gn!>Y*FcfaIAo^(g-%RYf;0;<_S?`uT9x*m3RZSNIKn?& zm7PCr1x3LA2laUR0Yjb`deRZ<*{=mKef%5zmpSajH|T5r#n&MOMBVgjRXqds!ltCu z9X*uPFWC(do1ng=cS{vR@MReU4h7Cm7evRHWSeXZn)(rjOUW~yfsAPid7l#P!38v1n%(5&)GxH;Qkj76Id}xN2S^KA{x4m2g zGSc#p`cf!>j%c5!yFfoii*H1CPz1)uW4iZO+iwLh{;j`lB`!bee z?ps)T1Smj+VUkcC1A3)Xu_J?wb7dLFCh&R7;tJ?+7a^4BH{yV=C`ACc#d9hy#VDfRVGyU;KoBYZ?`stOdp3nOndC+nE zBKQu_=X2eX1x!l`loXmi=?Nq%JUnp>Ul)fuOA}f`mSqT#nW0+7|6((1E7EM}AJ#=v ze-OG4N&`F=!qty2n4~Kx=bmrZe-N_JJhThV(r_J)ld;EX#(5WoB8%!7`KJh=Y~^ zGeg9K@q-l_db??LZjlFeM0Q{+uMNe%nnPMtC|Fqlfg2}hr zg;SQ;+^I|K;%Q6llIe@>($g2&WoIn1cg9t#-9= z)-87RSvT8)HSY!Q1MiQx#%^0P&#`drwYG5Wb+%}oaJ?;Ff4wbPe}gT#|9ZRqfe+Zy z2d}jcKlsOX$49R5g=xjE3~>;-@T}$$%2BurV5&1X$cNe4r{2X!Wr{!ym3)*P;G){L zn!u55ntnuQ%=PEl+6dd!<`CTRxsBOhbDnR2e>U6xZ}_O}f}&vKQ;)yHrX9D`rXPR1 zoqoa+n{nb|n>l{5oq5tCn|1Ocn|;b+J8QyXJA2|1JLlBf?Yv31Gf!w4#ga+o;8!UJ zn&AeSNzXE*89FkH(lX1k%&h)>L)OrhmVRuW#Rdtc-%T8eeN=>W`P8SFe!KIbdEgqm z>*4p?-H*KAmVtX7y~dV5Cd{*YH(X~cHr!|{HZHW48&`o1%*Ez)Bwxx^fcWQ*fX~K^ zGk+B5eW1n1!xy#@y=QGtqx$wVa8Kfnf6H%jF>6zRrZJCie3#kI9d`0t=h-Q5yWS?g z{boD$q&sX9IBoo$Hu=Om6xKjxacIN08&$kPq(q9!O#d^E#!m)lib2irqME}pbwnr? z3Ekw2c)qqaeOsxot0iB_IK82#LeLnEPjk{;H|KybOKhnr0rc*@|2ntL)x+$xsYxq9=n zwtDk?n>~4%%|2_5&A#w{?wULbw(>r(YxuA|yI;j#OndCn|I_AQeylCN>K!(wzt8%V z2ff^hNphguTQe890`pRcp|x#4FC8>j8wX7*Gs}V&jwVhm{A88|#g7?QhL%ZYQZh6o zEz2^FNuY@sw+J~S8#%Sx(Y?E?dC^ux+5r7dUr~-;sg~yg1-FXlhAHvHq%|@m$+7xC7=Q* zE52i?Z05<`U#jhPq9HrGS36|j?m7zI=0$$LU;Ta7N2Zlq*304<$^NCI#a4M$N5l1| zr&AA6o#TTq9CfCk4S8&Yurw#XebAMwM2ozBrVvyFvgOD1qD@7jc{Dbij7|H_bj3@T z6jT6u4Cog=DgfOHc6WlK$RQbt3DBc7V0UBoyl%_HGWv#0J`lA%R;OR=% zZC&K62n4$Ks43Pr-iX#J1raYPi2Q`-h~*dpEofJ|ML+4y%o%)(J*L!vZ+ZD6INcSc)! zijYnWn^kA<%8q^s^{LY#M-TX7n*QVnG93RC0eZ>PpQHT;^i%*!O*%8~7j23a6e!sw zJ=yXJDgb`tXZp{{^eEU}5wz@q$Cr36op|;uS@I}o<(fn1Yh!OU?Q(C&bj0U*X&Geb zLd&v{QZ0~L^*GnHe+S+u*$0~vsW@nDn_<{QqR_9S+fo4Mie8MS=4R)Cah;A%tFv2I zy^2A10lfe!HG7(15vsTPf)fZOQnM#1U63s(Mq@O;$k!jk2r3Bq7;gHcpkI;e5ivln z$cUx@A*az+=srNZ9QN?F^JlWK!t`6eKFFtWH(-U%W*|qFPQY^IMU|HNLa&a&OE=AR zoX)k|I^bXi9kINne%!&b>F*TutnLx!F7*lenpOp)F(o|-T1i6kNh&Vg1@uBF zqWPNKR0RC4#PmzEA2W}Ns$_rcgT-sz2)9d=+`oJyY?y7I3ZRdVeM+le`8vc!On%Qs zU!d~KvZGg#$qG_wkyioI-HXD$aLdA=GuXJ29;N&q;RpJYq@M-Qq61sAX+5ZQ)A-ux zP*ea)jY!g86zdArl_{ciOO{V}2Khvou43H*^cWDS8Yhy6g4U%NLGdac6@lj0Tsnv5 zOK91{PrQfaSKMv2&&v0vVko%_bS!-@4efF-hqS{7TLsVmk{7@X7(ZcQED>GDN(UB; zAlo!4(Ix~34LgogI1X(L6~BiE$@ZlXiJ+G_dR?!5#Aqx|H0Gx%Og@)*YduXt%&tvu z^de8>=oeWcM@69juHd_be;cH$RD~eI6sJ3Y#uW5xd^b3$Uo^?DIK^w6ibv2qB3?z7 zBzJ+TrEO^kNOsIr+6yPzr#9>YxMSWF@*i$ZT7f4xE-L6U2$8@+b3-zqy$M1zMcABc z9_k08@NguEhL46qBN6E8#h?Wng@#^nwNx&5IUf!sPhfbDmeL}ENWTBqMS4Ng)v2pg z(Ct};A@W6(;zXvPaY7=xm((g#jHHS{bEG0j1)zce`loTyx>>Q-j_G@xdi?9~46mh~ z?)_*!_g8|COo^^oP$UJvwpcl>7*Qr)^0>{RVmCAhV?2u61fd3Bt%n)CKXxX)Q)-?B z#x%~MXJ?9K+94VP{Av|BO;U8b7U_~A33B`wJR)#+0FwZ!YaT{ z^quKNPxP?zz~~wQTMP5ATQV~8P(Ph zEY7(4D=U>*PFq2t`Yg)Y#@Qr=ZXfGDAEyyg7~vBK+f|K?1L$QOM>>3Lhh&Um9qpLz z!jR3GdNc?y5iJQRCUPZ*0=eLKAyy+2J0$5^7%|EfU!$9#w|PjmLC(eP-gv(<*c4}`nePRq zL^t?k~D_g)9D$lH&Xt#-h;3nDO>gQaI zyTF$FX794^wAabUy_WOseH&H*lq^~=9-d5+SSpzgJ{7QriUhD_KL%+*#|R6?W*m(T z_0T<9eu3EtY%w zbzj}Jtv)tufO8qSaX88N$e-Kt%jYusW$y;sLx7Qw3Iy4;i9I%lRR9?qQ}R+0wV;Ac z2-4|hVQF!!g(T2$on5V^hJD`%=GH8ZZoz?uza#r6q3E5_k(yR z(6j-S?@@ae{GE)m?`<1a0XX?ASXWn_P;B|dsumO$$#PI=aj_yz3r{lY3<_zJEvR3R zp3r5JxaN7%T7s`4?+p;EwP~#>PPTOO@7(p}j{d<2{bSJj9==%s=@Fm;fV_^YTy|JF ztJ7u!+{Ja)P3POnjdN|qhD+?;4VT&S$F2ZZ+44u<4X(2D zPx~Oa5&Q|b#m+lzq3J(7Id{^XcJ8FR?3`2YwsR)lV`qc2Cfs9lCM>hrr!2EsC*N&n zp0vznj=vi$vl+yZPKKB?bJytY$9^PX)A2P zICG7iIBSiaFlViuaQ1pT{`?2*__+^~!zs3D z!6bY5PbS#vYfrE{-gB(oeA(M<{$;;!!}%Fj0r-h1IIl(ysJJTXk~~6@f*c$PQa69Reti)r{_2T4Ts&FM2yvbL(vh`NKowaTS zFMzF_vSJB!LNDD_zJD-T;Tjgc-e%zO@(lcfBFbAzaWkt<07oeo;? zqj;<$7>|jjm;lcmdn`axFLY9Nw6|?o1rQdc#RJah3--lUe0(h<&`D@>ktNH8+H2{$nM>)QyC@Xbgkpnj5M6;Np_k#<@&V6h?4u)$ksn}(+?8yt z^gL@#B41$WUbkTtfS&~pXufdv;F9&RFo-2-X`w;lgP4MZx*hx7#-~kG^w-4Uvl^Vm zb)<-PPz)ZAkGX7jP`vsjHAZ&^y;-{~Oy@4>mvpcRCrdtb5i47LA|ouN3i~C75Z6(C zE=k3?AHyXFnrWW84+q^o)_o)uSKDT5G;9?^1Tng3JcsNhXHUAI@~G3j*KJq@aKsVg z#JR7ky=a*g9F|_0T3EpX9b`>1Pqr4^@OdoRP_oIA)gYb;&5~q{IYf=@cjL}24Z zp%F_wiqD7TGTJKEHcE0`v9-#6^_PEkugTYdVHH5Bgfp78hlRT?3$948$aWAXa&vr= zg(N5-O_0AxHi<0om%tM6SvS0|c?XcrxtYhuHRJ2p)K9F6paypU5-YN5Furt@oVqgC z6(b+ACLg;dhH1lih;u)Z*x;)|j#z@?>5evj<&8LL2&fL@6z8^&kxzj^7VIOU$*ovDUw(!%d8wK4EHGx(8y5*EcD}`VFzDtz0pTG zXg){OH{3FjM{LPeU%FI211kHqQx?I*-nU^DKspVavmpZ&Xv?su()1_eEt2jw1Qd`Z zpGbBeiIpsno#5&V*)_5kz*rNAt$nUj1d^P`5;&I$waqPGdebKxILVCH`GX$L-7Er; zpuP?XgNkVquYTO*qGE{^(m+$bm`;2oJIsSkl3ge1%9Ws;w%ChunUj;8_0v79Hv9s} zSe&1-u_Afm<4S#fw`>)G0bmGa9&Po zoX~dhr#R*+^$CCsi@Xx)kRQ}ObobNW^wqliEs|atQ(Eei6qv$&^b%*HIojyjo;-0L zMC>CPL*s=&cxg$h2!y_D_%sfGBxJKnHBhm9Z3M|?oIJ=WxQYvQ9>}jO$P!sO`FV`V zJ+6eOGWOBWxKIq72;H+blw=-rPzX*uGXC;}3C)W@FMWx4m%dQbIdl~#={iZT3Fn;B zMSx^8FtQNoK z0q(EZT0qYFbM~f3LGF4aVi;oB1@QIn{tL@u@#&+RW}C z0@N=l^eh7AGv+b~bTGHy_T`tJr1!JnT2_QdkZ>nllDX2kl27NwZL+oBAk92ZW0 zuOe}hf?*?_Ty*7$F^Wgf=ME$>J`XXqlrJD>A36KcV?fXT!Mw6D{rwCce%q%4u=rmB z8b4tpTBzBLEB}H=Z`p;)Z54roY>LQ{6M#}>hYlSELhs}EcL;1gL@2YJC?`SLt25zkoJ0U8Iqui}WF z`msVx+rRp{@t!{wK)CgnrXpx*U*vzdZJ!Dt%eVt@X4cKIu>l-fXhE`rgnW`M64PCw zVBv-LS0=x7$y|OCTqG&IiRjnuuVPNmvP;-wKBM1yNyQ{T^M!FFSOoQ|ilgH#NC%(aVMlicWcU$n_+mW)U0D2EdC)Q%6OA1=3P!ky!FI)bc#pn2B7w96tfTV&%7Ey}ix{p}5y+0NWx_lLV zFk%koGTAPB6a{0vpL0lxPzQ?^fi;NJT|vYpUnO6RLzry!g%~r*Hn1VC!)6dA5##7j zh2e40CW&bbo;p^!65;9guW$ZZJOZY@=RrmczwJ{2=(K(hU?bAmpM)>EAX!?pWE&;# z27u%@eI0iN#uX5#{LumGls2^a&|OdIG*7i05>19I<3ndG$TB=9a+KPqVCVpXsxG zpMftSe8DVKO^cO%^P10@$l0|BDvBV2Y_31uqNbdf>Zi1-ya9XhW;>h&>@vRS|qSxlbkxHNj$-| zpTuDr-D=Dc#xK9-1TX%awddre1^dEa?w21k55Mhm0etiO|5~4&<)U+B7qs}&0y!*P zWQ6Z#ut|KdOLJMAP$4opvxwZ7UJ4{Ohq0mP&&f1 zzp{Z=Wpm~|%!qw!`&^y3!5Wz zaq6hc76QR^wf!V1#G$~l&w(Sv#CQyv=Rmm>2KlYu&cVXA4-^`>&lr&c_~!TZ-_ElZ ztrpNQEH1pPKduxcy#^U~BJqj9Kz`|C3Xv+mOZlo$3@QYC>JO;=)>sf*gLy>~p;i}y zd=B$Fj{KDeR94}4DEULej4%fwmdMh1f^PT(<|82ZWk>WRnAqA+92sM1mUU5C7vHp*5&PaoqyV&xX~(}t%Xa9XB3Sak=UIF#0KSXYCUR|tY>ND#mrlCt z^c4^sbonbMEO(#ml0qnBe2^g?-RCSlUURn*NDp@EM{+LBE3$;1MZlcu6A~FgS}cYN zo&rLQVMmaJ5Uc*^BPs6lKC&iTLK60g^gvW1Q0ZIxhsU$uPy`~s#qBf)F zrrU9{f%h|}?t?Y=sSiJcIfn7_t6LH<#PXQ47CF3O`Dbjwy1%l|e?|X*6oDhqMyvp| zmO}q-U0=V=KKZqF&Z)1nxl>+mOCJ2Z-L~#?rVX^X3j_(#Y^bQoh7btOW#d;&j&wWv z+p#5AIZ!N&i-hAH9QY zh@<)b=t43YCB#D{otXHg$3D#;{rBHE!Pebyimmy;$+qg+lWfHwkGFfSInnNV?}_B3 zvdeGzgrYkr`r=ph?-X^o_Q@F00?Cf!E8y>f_um70Mx$aMG*0mqCne(>TjB7TNpb}Iy z3Qm?oc){hFM?Ht&0-vP%T+-L{5grK=Nj<;U)uT4y4<5Bskf$I|zVcB!`N~J^ zq$?hQGzhzGvTb$(C)*bEEeoeu4eZT=`oR|9w#df6QH0+*nLU>s5P`Xfq~LPU03WmXA-{+dq49u`rB8tViQt~WMw{9=ZwfjOam>E1946k(rx)1We3R~!`K!` zdQP0%$Q-}lnb4EW(R2I(74z+i7BSj0TV&B}n7io_ZSok6LPPoVjq*d${G{{Ub3QW3^5@2#Ox5iY+U{{)jU>c90=)=%?ntix@ zykskq;a6-SGQ#_a{6ZkAgeZZ2ibB3RKG|`s`U2UC7Z~H`NK!tbvd;PG=k}j7&(({% zul~$F_htRp%OQUwdE74rpap*Ljx9Dwa`ZmXg5?$)Q`Rd;QooW9-?;FgxXpp4?%+q0 zO$0WCcG;SWq4W_!APt>3lu#)0%Wk5gU@GDO6SS|~(mHe}4PTP%Mg7`Go_sDL4jD)f zdd^)|%OlCDfWmwf1M^F7(a-W(pEe7)`oHvb{cEJsM;`mF0JPMt&;PrP8F!Rf zHpaBE^CrI$Ce$iYZ&G}tfh;$%>4Vjfgl;|98th|GLR@n!F`BwLhsWV3n8rC$yjdWn zL`L!;!hIquq2cQ!R^xo|jz0&=P0Q`d{^<5kvRXDkV+(e~ z5o6KzU)!$*uyE~XZQJ&N@F?IHkAtluY)(5vtAMr^NWns(g)5Xk>3AVbM@VCwG$&uW z!#%{Leo1@`_Bc*}+V01o7n>uxhzKYtf*ydN9MTe6(%lzHkGA{{uHcsXL6DWnpF^<7 z&OJW)^th3(xq@U#tc^v}vMjUD|E>N>T8#bF_Gg&5~CQCEzLLB1CAwx#Ysk^J0j-W{K0g|TIQ_T_KrUo46C zeiHk=0JQF#?*3CNs?7X-fCTg^0{C!ZcwIn7Vl5H~l_D#|s86t63&G;wyHT$iuDY6)uFbjv0@ z^haAhf;xO2qkd*pUn(Mxl^+n}1VM^R2+E@_+aVh`fk^qJ*HR=#f1Y$~k3q_(q6nZ! zI7iFlbmbc_%4P%jSYB8VYx>GJ_5T%sasb)^xdWtqT)X&DJL(m$qzJ~@wBuis+`jj4 zP!1rB9ysGl#zvtvbTKGDK@6%%#|&{A4=`w9T(b|SkMXe@p6iN^ zae0&?HtD&K3nb^ns!#7dgZvIezsHws;V;UJ$6>~~&E)^!vZq)Li?q!C?pyjljgWp| za6k({Tbj4%5gT{pQ9KUD*wo{GM=l4nnr9&@5^xm=M1j(v6P<3~;c=m1^eRT69}!|v z=t1YmgqY1!xIY(ZMcIBA@T(JBzAmUpL|`k9kreqYNNvg2z+g>!=_IQVaPipcvw{N9 zZGV8@>)&|KliC2QhWu|ISn@gPfqMk#WC_c)Wu?LcZxCf9 z&X9Oukw#CKO`!WZ_!TajnK2m`V;L3tG>?47RamykZxGn(p?4HZ?ALc4b!<_Zboq2; z55Te>cR~T_C0~wFD~8)d{!PoDWJP?xpJn#dgFrs%IXDHN6JNh{gB@|?&u}LgXOrKl zA|Mg!0B{F8ZAEs)p^Yk%8pG&tVl^biky`h8Uy4J<{&W`+VY{G!$ZkNmi1k?R5-ph2 z)t`!@g-@gpa+&LDck*F+0J|d1CCDj)LEiTD=Unq4-?-%q2a9~;=YT%~IA`5Hc*iDt z_~U#B% z#5Zm9L~>2Id>$8`_nJeb3%c6%H+zDL!fXs*OZWLRe~=>CF;LhoE5kE?@^P=RuYITY zZJ(tbkk)rV&a#`6eD?>>*`_DIWE-FQicNZlia^0C0s-zZ;VUSCWVuk z+|M*B2A_dGbdlPE%s?`4<|4NXA~<1?Sn0YO2zrB0SG!2x$t5nk*uP6e4hETb-TZ1#gq2@wn3YA;_GeRkO^_{000CfNkl+0op8phu zS3?*JulOSAxDx55>%s4YLrv-n(gLYm@glzy6>CT+E%aeiD1*f7D&NWX89O0&zx|y{p2SI)kh#X z-0C}u1jG9&h!*p;cs95E)(*k0OO}!JAaA_)S*}{J;#vEX6`!#Wt@^Cpvii?$!P>vD zMe9EY{<2+!_52hc+dA!?ueEEJe$?i#7#&wVOR8I6t&K88cY}{T`!ySTg#MBh*yU#&ZI{nD#@+?4n0bs{ zdFGq#%9(GroA3LqlPN;yulkJ5U)clrnVNz$2dAKPY*)hRP+@5;q4>;B`n}(P;jIfX@ceitB>M;B#p{Paxmyr;i@O>_I1yg{xdYaSiP`)ke8~Rwv}Hu@7S4#XL(_K+%gmCa0|Iv2MXTC`B-im z9|{8UU^%=vN{@i{bOq~*9vT(%8$iN5Q`xuw@!uRv){SoBQ*K`p1&|gixoFKZUI=5x zj3x1z>FrxZ@YAh3c~=5hO*dKvXZZV7uexN`)5#Z}!6!`FuK zWsd%tw(tDlIVaxs@#jDff5{a^FpzN znh-peW!QavHfGG2nsnXj?Pc)uvM7Ki4@=j7+7_>Q+6!Xw8r>nDu_e4aEL#0EkA=#A z38T;MJGfhH=Tk+NW!49>ERfs>(SBufORptY`L}tY)4Pbh%zj>W1+Y7QmaO5i0Twr) zTez-f2PD6gZ|A$)`%ry{Q~?Z?ghSbXxBw1i_SyU40w4*8aywiAqlEo~wT#0BaIi8z zO3r(@07eNB2WuIJ3*ca7ew3W|Z~=@GA`aFv4i~_|%KRuf@8JR%B}5#oWgISmgO&MF za^AxQaFB^O*r)&h00030{{Tk9Jpcdz21!IgR09CB>ovNSmo0w)0000IM^H>m`; z<)Q<0^M(JQn=aTNyfx4?wVI|G!lsl9ypB(i(09lLsTbW~MYs;)TFYXH$rz(fIdwbxb@ z4N1|cR|qLyzXZQ%;Zwl)Z_57#ehB_AxEFjCd<*;-q^qINjAVK8y+7#^V8P5cY3|8; z>KDIUq)~QwT~+ML8Zr~Hg_88bOmYvxhjfVVQNg92D^$Xo(MOH~-v*W;mw?BB6Xa(| zCxpp=82xVGQ{o56bUj%UAwrOru<^b(i!ak`NdM%CyX)t_SfaYFxKYl8!Oy{e0N*z5Kz;={K}0g9 z2g>^|e=oL;|JtY%p!wNF`s9gwYWA7esP^&R9_!u>9YWVmncd!oFtzBxV{ z<=mRZSLoyCUa!&qs7j9c8uAP7xh6W3pQUZ^s~{M~a*reS*Y4B*QLjDx9eT}S@7At| z9;jCza*%d8pdmM3qB=2Oy zyV>v#{|)TF3;q}Q-{214ug`Jd5E=U=6p&ikA`-{QuZ zSJBB*@F?&t;=9OO!9#l0fpg+x>Fpo$yfAG<*G829&Clja(|%nSojeDgYrXThJ@oct zU#qtrx2p~~{?*$5gkAO46JJC4Jna!=hIi#4yi*Mm|5W^*2L2p85Bvpq26#Gnn)#Ja z47k@IsEo@A7KBb=C_U5}E)p@gS)~qwG)PYCtE>+8=rq9^i zUyE9rLYSxYq@D>jxt5e=4C=XTTL#_8Z4B!xB8cRz2e_2cD01MOna}U*Pcm{dd0T=6rSsVV@z){nNdd)n17tNe?Edi_b{v+7&g7_Y! zPi0zLK%$8^3d))Y1yj#<>lv8afJ>Yr0oztYQ552u7`PV(Z{edtNve}7&?RNNa#_3v z^(Zz}HzdIsly258@0-QrO@qGSE=O;JjDGH+uZnN(#J6<^9kvT|V8?s6Z^`k!71#ny zB?*SeC!&!Emg7MeK{^Q(pWm}|0+3i?v(GTR%X_d_6oo7^ilR_aBpX77q$JVO)s`hu z7B33}HB4#OE9q3E_AWaKE|GL6cvuaeZcsMJwLuc#>_c9usw!jQ2a^bz2GLLDv-q#` zq`t*d2!!w5o*H%$xs?LfxV($T!wEoA$sf`D@$4sfJ_5MK@Sd((MN!D#2%7k!D56{x z*bJtCh{Py*t`qf2;{|!JoD(AlI|iF{`7#lo-dZT zen&k0{X41TOC4wZVI+c{!o%9{{GK%J|46;xXEKy|7a+-9RxD+o>wNrAT(RJs14y)(%&&22J=%31sIeXczI3(#jF-dX@x8u3rops{{;@;Aqo830_u9W)=L7M{{EfS z@0WZGZdi1qBTn5%+Tt%+o)f^|&Y{Xbt}I3|F(wf;E{Dg6Li^SrCxY7N~lvPMYQGixTDx^Sq9$>zleAfB7 zNa=+Q#eVb8xmocMxONhJk0kgaHEVu7UJvU$0X}r#PFl`y^}gq`Djjvw>!hc?fO)+L za|qKHH&B&1Rz6p{@2<+&iUumoI0gm)4lP6SjKSG21*sh0bh<>#3AdR*)7 zwN4Y@2S4HC7oYPZ0a)lUA3chdYy$?bf1=>y-yee_3Y2|0f`;|+$bKu6nRM4@eNg%| zNaZObCfO)70;wZC^bvsGTNxm|2b=^5NS7>&BF)$Dg$k|s@_YtBOt^ekNXbBhYiHgn z@#B+9PJ$&)0)H&lI(x0N1UUBX+wyVGf33rUW0hWmkABMx|77aFyZbo?{0IZexNB8` zv}LbIObL^jurwTsg8XOy2pt z&wIN32NeEH{!@x3%0P`!ICO@~#-L7sY>H&Dz9tZ4_cVq3zcB*IBRvQoF$#?!d)P8Y zdcC0}D2hTwF#xrCNUiXSnfi^n`&1l*dFMtzta#ToXWb-C?P27k>hs0bvPEj@D^}=l zZ5SA3ZFDo-#}9t;bH3@Tz081EnB*^c?B|EQFQI&o1wwwqB3JS?tq5d-R`PN*Gwd>` zySyqCQ8{)C=#dD$Abkp?kMz(jNsbXq%00;L^@>OW)HwPn%6f+UN?lv);F+ZY6jR`0 z{ah$xiQs~5$fQ{rT(Ppuc$IK6=(ZZ_;!2-B-`uZ(lv{E&J+k_TN{3cfgzU z!nf_C7r*_DBtZd`AgK^9-ndY>;FQ4lVp9l7BA{jRYhu8a>?l{Caf39CByb0H$tTvO zY8KC?qqTdj^#oY5Y!qH0H5TW|vhv2+Yx4gE11G>z9uD~}fMddu&_@oxN*7L_r;CrA zr;i^!PnRC^S{%94Q4}hAMM{7oNt52| z_0+Symt-b;q}NmE$eak0Ly$2dL8gLu6AJ;_5}TjI{e_jkt@PK`mi|j4+gRMoVjb2Cjbw_CdG172&cu6h=$(f{9j4A3E>k)+o)RERhXbcDuCZCGvHc13Pw>BN$>U4>!E{@fXhARmQkR)Gl#$!kz{GG z>*Bu`@R9G`ZnF8cQYPFdflmSdP2nHW#(!$&Byh60j{=*4hy_|2P67iG>|IzfhFhTlY&gHfpcAA}bLx&hId^nxk$u%&WOA-i&s$~~Oo$hR~`q$peZ`1kHN z=|1kMTH$hn_(bptVZwY0xSwLc(<{ty0vP_v+Zb@0P<^`gJ8pW!H4=dz(j22pFXqdl z3H?{!=-IA*t8p!Ey6A1{ac}GKurifn)<-P@PJIF-unMkLy3+x&000mGNklRMPIL#kKobQ9-YKO#i}Shvjt zuZ(maJ6P2KPf5g+1O;w>$aX?F<`mcX8l3H%o~(OC z8(4^JNjBq&=xiG!6&I?pn_Ob$G6tm=c5F%ayF)N0b15uQ@Ehoe7K9r`lB~8UikM#y z$tCGefd4xIKj7v2`Cu|o{e-ST@;&MC@Cjg)^k(p}PXCSmQ?ROQL?+$ws|k(~I^UmM zdeUC2YWRfe2+0%jTdC8OUZq}91IfGJL4`IQb>3}az7qqo?K6gy5MCHnzPfgK;Ov{q zO%m;U5wcK$^!On|FH{sjz!LbBDvGhPz%I?ETIhdQ9|3UXZ0!{$y9@l+(lQkqj3l?Sa4gWJZG??_YMhQ^xC+@@ovr>XIouAA0vveE#SSDM8f7AKePB)a@pY1U+<*?d_o@{>4eYw_UgI3{Z zeML9{92yg8V!cEC&&8M!?{t5WlFZt*Igxc4~E(SFJ8wvoyAGk~|8C&k=?fbr5~ zx<1RSvo8Fh1SM=)60g(;btsr+peBD5t|8s^35_CHIOOqMuBfy$tdCn#Tl5osjqcsK z3~6$_L%ln{L)m}FFt|%inEeJ5!0jIoP6EU2{LP@@eoVZR!qT9=Nq2#4`bfleq#4d_b$yC+^vwLlI~1s z#$W=F&77F50w3FRIr}=<+{a)Ncw8p{NijOADxUruX4b^qJ%3~7^jFj8I!Hk`3D%9Qdy)--583%hm_Q@f**}0WwR3%w>f1JM@4752`x>sd90WGkSIp7= zRa;k4t~%W$c=i*)btcLLWl~Itce~*o>aqzkaV9V)fZIJ0xbN)e_U+zY$i4kx_7(LuyaTxd|a3Mtg#tPhWSr?nFtdyNQ`X;gvc`2+b5H6QjJWk zrN>#7#J@4)=4LT$V3w;4y5)< z1TsmJ2nX^6L$dxP8@bN*4EI5kF}r;+gdcj6E4B-J>o-Vj&Ae)|sdgPmb#)MKo68lS zpN$&7i9-YtBJgqio7~3(6XcRiZ4oO`fec}k2e0E)z0FV^-cdvOoT$f=Y2I1fg zmeEO27ArB&kgQG2<%uB2#p}#^O@Cg`#Oru`>!cFk^3z_&+#8wLSR!wUkVdCna?e;D zWZ9=i=6dU$&GtbM4A+Nmw%gXiyEYb2IqMa{E1d|)(2)+v3V>`AZhMy2Ck~Mx_;M1k zBfH&g@-x|%5ZcvQHwcE0#DL2NHi#1Q7IRgWP6FmD+d3Uz+cKDUPL89ezd~Jn=wncy zIepiOm-0B)NhN^)Io6s<2a%pghg#mXaK}WI(AI@76@!zGnB;m(*AK`b8=GZ?tX&79 zY)Q~UN0u#e5+vEEB!B?|T7pb|Na|LM4+O|h_kPH85)3AQNzX8`hW#za!*;;DQy|b8 zU(8ck5${enY@v zsEm)3Baz+^007BVhU*;wU>_4(5iH9L@Ad%pFd;-+mF;WWAZ#;ivoz2vq)`))kZrkQe8cztL~^5$ z?T!zU(fjQ7&}kpiwgHTyOa4=Jlkac%{DX{lzdz^uA<#iJasoh5ke($ZN7=H41@n}_ z8bVq(@gZr;e1;Oib;eLVeo>~wsH04~h$(i*bUap*`*5FB0+>t#9+W0gt7<1@J9h_B zjH5q26Eu_o##jJ>u|2iLPC4wZZ;|`6kM)FF-rW@eDM3X*$`%#ntcQf$xc9fR&;7JR zzv5F?f@HFd{H0DN-p9US@(F$rJ&}YU*Rci`>Fif5oON{N6exB-?L()HC36?^mSrw? z?eNWZ*R^)XBVxsPmL2R9l&;>}q!QrnuYFs0e(ig@@t$w%s#_n{>;>P{#aBP5^FRF+ zePG`g=vXjm^Y-i0pd}p1<)9iUizCts=IpEAA zUZK;c@2pe7$2@2VHQ z?KNOm-F@+u;4@&J?z-sO_^*uaxbXA3{eoL`+edEIEwgUZP3PUF8)w|6h3DL=`Dfjt zYtOt{pZoAlns@5wb>%5H=u;=%pt&b5)F+R>UY8uVKp#7Hfi40U95Y|Dz|3P7=v;6% zwlk5Ze{jA|10SO9WaLT66TtD*A9w709ZMg_oUl+IJZYhhrvIZE=cvFs-Lt$kkkZ0)tvb5@V}*1Olr)eKqNn@vyxv>k$ z?h4&-IV4V&hNu_y0cUX5EnOZ$!w$-*CD%>Vo*mEqK(h3MQNJ%$qpWWk;A>U+;cgMS z|IlKQ`U9sycS6wC`rrv--4Bgk$B@n(yq2(gZOp6c(6F?IsU}oWpKR7+H)C5t65thV z__>Y8socNE4OypCk$M)j+SjBKz@x-b4kdW&3x!DQr6-H-dv=uZ&9;U* zA_PfvhFux951}QUMTd{;tXpQdY(yD4)5ip@}zaH(EXFT?SQ* zS9jxAwzV)XtBXk`z;FJ>mh5_O!bYqq>7Fczpm;)_D3zrHk|lbH-HR|v`%zb8XRBxk z*rRiNG>9QzCk;do)(`58CHG^Q!LvY|4k0TDA4E;L1^(Xg@$iW5UGKSf(l+3A7$x(n z492Tk)JUUZPBpNs!qzaYj2y>YZY8mFn@9}WhQwC3V`f=Vw*JRY^ItNpF8$J^62Q#} zJ;XCvCgsT?D4WBy8%h>hwE^)2D<+!Iq#eYfa@j#CDfmn?DpuZCEt1wjD6@7z`u`=>pXXp?qsmcWlPWB((oFLlJP2o`BVnFiL`EM zgigQ)ced>Trtf%mI!laGmcDFrTgR$m%`q0TqOM=kaP|40TmqB?$dmGrmY#5y6?F;C z*fE8zWTI|oSrL@bZ4cnfB5bcIi~0`OJ2FEjB7;8k8@^G;j4a*WcI%cdM;qEgb~*vj zxjiJJD|UW{Pk>56cAEs5{E+lh(qD$>Yy!G6W4fH}p<^?76M{r={5oV`+tDMgOx5+) zE5=W7%xZaD_uKcn)>i^ted{-4DCQ^+o;F;=3DD@m&ptqW8zxpG>)|A5C!dmlIu;lK zYV8$dRoH5zkQ0L7CwoMNBgdqp(}xTP!ZKrgIon(5z>VT#nUKg&dL4-o$$)3YS!X)878vE9y<2ALwhSAsL7{cnZWh7vBZ<#^YVzR$B6NncSvILj9q60 zC#(B5$y)$7fuab)nO{56u?2Ji>n2#swu*@+N!dC9^#UA>@u&$B9n+}^Zb6%28&R>J z?es;)DUu-Tj6YPieH{9+%PWnxvQL|Av+nt(avf{%e22jJ6@6BnY)E=QU}{hWYNp*N zmxExFYEZvI*~=QNYkl&#K8a#(jO+RJdd2GU-uX{D0ieWRdZr|3Cy0<2lP78!6XpJ7 zJ%E#C1dRox05vIS5SfFN?2d%NXtbmAv4p@^9q^$qWk6yio(pY5000mGNkl404K~TlE4e{{mJC}c2GG8Oss0*$(HCP_5=b-KuT;? zYpW;+!q&iLBlk=780j96z{nWXX`=v%FrH+0V4Gn*z~tQrdI|b1LdS1VS|3C=U)prU zhhC>7rr(%1Hm@i2)@I$P#+4nn4z6Qu2>GEn>=djA@e|FD`qkvUbDw+y(1l57u475S zL_A>!CV~d3r9m*FjC25v62g*f1LrdBEBqZKqoQnS1nHC~mJm)Ar(~=$=D_%&L&hYpNhg563MPaxtS88ty2(aBn0-hlSP_&o;f4Sj zbqXGf2p{Sz`v-g*Z{V3XE8275?B{?Gp>avj5=*dU5F7?(BG@5Nj3?V%$C%+`iM_<`B*##269H!V%i9O~5*}ipE$SQ2fFU_T9Z4JmW>2>R8f6uXIO#9cY(*zpb@o!rO+uwB* znP#KVDPUG^>-GA_wUYUijN=n3z-iZ~tTlM8Spx7;&<_BfY=bhGv85+VhFUr(5l|LZ z0td#n%03{<4j79`x`Wz80J<{m9FVH@u`X;05RHA9dI^HNgGSsD*C8$8NH^y4%w)a1R?A8w?2II zi#Ro7pgm1wk~3U3T=r>TcwEn=&-i_I|0v}5h&|z6qic;5Km`fV6r@0!N1piH>%Sof zz>~I2Q0(-i6MPnLJ&scr*6Fqf0fRm@{k4cXbhiNxGF-^geT2kr-8+kh5`9ht`%Hw~ zM@`4BuW4^tQMPVW1GX`JY|bXo@Em-Un@GcLZKpX?YFbFy$MyD0v6PNHV4H43m8{(f zV2oBp3i=wnuW>=>jXCHS)kiEDN6KY zOFpIjjKRLFw?Gg~l8j9ryAFfLqicf8X+Sv~24v+iaIjwo+l*}Q#;oOaRZ*_Vt0rJu zfW?QT{~*|sxr9Dcr|2iy6>~G;UD>_{?QQqEOXg#Yvip4sjP@J7<(PS0>?f5d<)Jl2Qt4Bi!U2CfW+$0PRck=nuWhz!r97g?~lkghrKZAo_2mZ81(;&boJg z*2A9wMcMO1cfDgpvzU%`W324?E7sX3mVM?j+OM^IwBq1hE7YyoLm+I&b=QO67i$6y zkPiT4qdo!cJ7WzdU$$_-I@rdFOMFY~A?sLl8G3_mBwGa;$zCTiXfsAq6Sgkc^ox74 zF+raS6}I>*B-%OZ_O)$1#_YAS-bpga{y`=KbawhqwkFziI%5y&L&Vd@q#LL*tQ&4) znKzQj_X)6^WO&Q5S4ZsE^lj~=5wqCt@Bf1Lq9s2i!Ihu?=0FluEs;$~2e5>MiU3v+ zU0Fw#0sGY9=W-RcI-Va&{Jc9uw)QgmXrjI%f|!H!q&EtJXjd83mw_68;I;wUW6y0W zY&ED_TfGWtqFg^D+2Lb0De6cBE_BNDXWL-eWIH*iVgo5~=v!V)4855eezC0O4Rza> z{0~$R?zM2OwFEf*k}qh-mpn&1zr_CskZJN>oP+L3XOys$;kpu15^9y46s7A!HVWNk ze3CCdlq-(d^K|^?UjLA;`NG4x{KoY67;Az5 zwQ~!|6RFC+ief~sc*$RC*KMDrJ$87Gdg*h{2+OqNkG{vd2K36{27y6oGC z^(?L1J|+c`jRwn^Xoi4n0qS9#3tfu#6q299Hhuq>=#aN;qZyaqrwgvUUzc3-K!kZq z|NjYvwszju+6mz4oPNpeYDj=$WC}Y?pwV7C{Z%Yz&it?IlFvP$i|2h+7k=i;I{%9M zHDm6Vbk3an62T;eNr?pD2kt&_xq>Lm64EMQxgzQyxWqU4)bt7IP6DD{4#4#l_Ok73 zh)Dc`xB(gUkTYgiBFsM6gEXG>lWd~SyWOPPX5gCa_}lKX?WeqVJDvRAZFS-+UFiROO>6D7RujOp>ih5TNrIZm!bcy{Uh7Eh3Bu^=QB4#c^0@6*{N!r8O$(^=q*%kI^OFS$n_`uN>C>EgR|;zf7r_>bP9 zV=uT}$9x3L`T_x}2hgB5T@skwioq?*kag50TRr4!Vk;Q29FHM8bB+0jZ#)0cn;`e> zLwDBMhk-+P(pknKJL$}WchVUL@2E2l+Ciruw4+XY{|-9!ecS6p2W|)P+s8c0HW5o6 z?6Tg+)@R<14Z{*EsVE9fojRhm{aWh@aQY{1B_KVGjBFv+A=c5I0$xl65rs|2!W_I5 zOfQyrhK*|aNOku2j`G8ieGJ=xB*RhX-=-tayH!WbybT~{+@iy0+^WORMS{c5 zxkZP9L(aZghXTvMSvLns{;!tKgfPpqATlFJrQAt;&W2p$tV4Fz+0>nd%_v!D=~FC- ztb<&WXj`8^M7@1NZ>YzIAdI*B1azC3YzoJrf$@whZ~7LcwR^4g1n~40FJ8osFF=pa z@GQ2r)kG(lzyS6{sDkjMH=(;eNGB_-CnP`O*75DOP0hQxGT zlZXgnYV`4C+dvRoD-k*7)6eZ(AH?Eoo68y42Tz_M!bz5X3)@qF?!WPWZAkox8*3?w zLcjZ4ll!}WdRXPPP7>gp*>|dJdYUqIOGqgquzT$ISIRGEc94RS$wZZHmQDokdY9oX z?48Vn;+WgoFC_tiBVdC>yDrLfkaQ`^ZVw{xCaY>7xR!U{3V_r^4)xZx16Yp4*X^IU=Brv4uXU0Dp6DqTEQrsoy(wGB4`B|J z7emZl%>p5Uf=LOoAt=jCEYO!oOX9EXX&~Lbas2`q=-I-Pn16PI1P|Yi}lLqw$xJNbZb)B0#w z&psjBy3n&fBn25r6CZjV#Fmszu3>#ev~BQz=W6}%S?9IJ3TP%XK17rz$kMPr4zAA_^0yytOk%*g0ipKqL$H%0OWR6p;cFjszV49gjgQUy zQpCognKCk>>+X2i>N>?bPXN#U)Qc9XY6|uEXgJEkU-hzQ#X{jnX5TVkk{~qh&lpS+ zCM7~~h225h(7oUO1`SNC>q62dN!ru5`!d3YuSw}cT1W^jt*-=~pX>2;ImmR`z?R#) zF6)-z$J#qt`4j-m!Mi`m7q6m_|Ip*QJJ&t=;XkU3uO z{9M-&4vD#JQmum$vP)zi*$+vdv>8P&@k15A?8^Ui!6-gnqhlg&mDk_>uUcQP^_Bo$ z(D5^`)>gmvR27^A+rIG6Y=R&K@qTj<;1Xh*i2;$B;3S*W9#T`8zUUz7)P=uoP1m2t z@abS5Cqj?}8zl&$kk%(c+SL#afy4FKAlCK$wysYv!in zzQzVg01tf9tZOuF%U_dk2QU5G;UsV(m`M9s7lj~^zzsbFhCQ7E-t|E?ZzeVoOkFP1 zQTB1Y;l6^ziX9Ajp+Alcg7I;Z^tpFOVhpq&^={{!4>?K_;M+ia%{QYZ9R_5 zue;yNQn7({zGG`_umtenr(AHI?)vh>>P?-dZT@B~30wzX5ugTztJRy>#ZHDqAOzz8 z$6>dPBD(NTzAKiK-ECagF9*mUAacl#Pu}?^K6GqBK7`tLl=5QZw#4lT3sb4i} ziV?ooAK_C_k8eh$CD=c;0PDyWV9J9FmHVv_&O31X9~R9R;C~Wog(ph9-#Y zQ*WXJw6XO4J$AZ@H|e7#-+2J^ihF&-H=uR9^PL=vmzDa=!Y^z47yp(1e(OKfhc3R^ z%iEyX;0fT>-~N?v>&xHzvF?4q|6FUK7yr$l%C`mhN~GI~0+%(&O9VC;eAJs<6YC>A z^jahWz?hOj(Z+RtX0W6xY(eC}F7CJF+TL&NeRj9W?o%E$Al&6q>?U?O#4lYA@qp7` z+}D^lJ0J2n5H#o7`}C>#U(#bsM)l&q{YxEl-U6Na@jJ9Jyf%si$bq>3TR+lPTW+EI z9{diwPTJ=Ae(6WJmu6v>dv4Z39F`myVq(90lyfOwK) z6x%1SeL$bQ?g7mPbLKy&xz~SPSKRQBuKfHrH1FnbY2HoWkQ0mHWH>Q%!oA?H|4hf9 zf4x3@$;Pqk9p{XVE&=kyzQnV^Ll6HzQ>Sf3BJ@avQceQHyU#}}+^DJxRorirluUw; zVJF8PJ3d!??qqBoNwGIc^14@St%T?4^{?Doa~D7+n6uyk&7J>%Ozxa0Kd3C{QkTed z(j+*EjLr`0iF66I!(Tt^={n;Rx8!AQbaJCj07vSMuY5=6TzZQhdiY1`P1#bTRih=# z`g&~fa^$Fd|1Ggq-18Bt+GMDm5Wvz&(x=iVhj((IE!!3+MQu_uap*2*z-l5n*AGa> zb;1QW=KTkx{yRdC_xxx7f$nQHQkO zcsLOY75d`Wf1rCG`oTs|e2(ptA_4MbEoaQRlSH^vQ>ILXphA8CTeM`Een~Pc;wgI> zpR;{3I7upquj-yEp2jn3bR+7hk52`VIz(>OXgt9#`!Aae>jur@fwb2f(Y@dJq3(L{ z`<~(&VSP7#(j`C+(2Ut%(0Oz3AQ|pb(VGfk;G-4s$`NJ1!d!VWx7#(ri@FLK5_Py&yOwyLx0~ME7x=XZUEp2g2{>c=xl&lQ=XJPW@Ld0VZ5! zYdmibw+%3J046uU!`nUV>Mufc>%K{dO)3F42}W0Qk((1>H3Rmm=Ds-re$^OV&Gl?f zfYl7xx^mx8{ccWx4Hfx~WZcaOu#ph4p{B7p0X9_RH5zh z-JAd$2@xA=8k-YfLq&cg8FzC6Y+xcb^zi>500960P)e|C00006Nkl { + 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 index b6d63bd11..b960eab75 100644 --- a/scripts/run-engine-benchmark.mjs +++ b/scripts/run-engine-benchmark.mjs @@ -1,16 +1,11 @@ -import { evaluate } from './cdp.mjs' +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 evaluate( - `(async () => { - await import('/src/engines/render/engineBenchmark.browser.ts') - const bench = Reflect.get(window, '__iaBenchmarkRenderEngines') - if (typeof bench !== 'function') throw new Error('le harnais de banc moteur est absent') - return await bench() - })()`, - { timeout: 300_000 }, -) +const result = await harness('/src/engines/render/engineBenchmark.browser.ts', { + handle: '__iaBenchmarkRenderEngines', + timeout: 300_000, +}) console.log(JSON.stringify(result, null, 2)) diff --git a/scripts/run-engine-parity.mjs b/scripts/run-engine-parity.mjs new file mode 100644 index 000000000..65ee43163 --- /dev/null +++ b/scripts/run-engine-parity.mjs @@ -0,0 +1,88 @@ +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, plafonds MESURÉS le 11 septembre 2026 sur cette + * machine puis arrondis vers le haut — jamais des cibles théoriques. Le cas `material` n'en a + * pas : 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/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/renderer/src/engines/material/MaterialRenderer.ts b/src/renderer/src/engines/material/MaterialRenderer.ts index be67ac44c..a57dde1a8 100644 --- a/src/renderer/src/engines/material/MaterialRenderer.ts +++ b/src/renderer/src/engines/material/MaterialRenderer.ts @@ -17,7 +17,12 @@ import { createSkyBinding, type SkyBinding } from '../viewport/skyBinding' import { type ViewportEnvironment } from '../viewport/environment' import { SCHEME_OF, type NavigationScheme } from '@shared/domain/navigationPreset' import { ViewportEngine } from '../viewport/ViewportEngine' -import { createUniforms, EDGE_DEFINE, materialFrameOf, syncEdgeTransform } from './materialShader' +import { + createUniforms, + declareEdgeMap, + materialFrameOf, + syncEdgeTransform, +} from './materialShader' import { previewGeometry } from './previewGeometry' import { DEFAULT_TEXTURE_MATERIAL } from '@shared/domain/material' import type { EnvironmentRef } from '@shared/domain/scene' @@ -290,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 16d6854d0..34ac73012 100644 --- a/src/renderer/src/engines/postfx/PostComposer.ts +++ b/src/renderer/src/engines/postfx/PostComposer.ts @@ -274,7 +274,13 @@ export class PostComposer implements SceneComposer { ): 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) @@ -299,7 +305,11 @@ export class PostComposer implements SceneComposer { 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/postSurfaces.test.ts b/src/renderer/src/engines/postfx/postSurfaces.test.ts index dc46980da..094f7b93f 100644 --- a/src/renderer/src/engines/postfx/postSurfaces.test.ts +++ b/src/renderer/src/engines/postfx/postSurfaces.test.ts @@ -44,6 +44,7 @@ function composer(): PostComposer { 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/engineParity.browser.ts b/src/renderer/src/engines/render/engineParity.browser.ts new file mode 100644 index 000000000..eda382acf --- /dev/null +++ b/src/renderer/src/engines/render/engineParity.browser.ts @@ -0,0 +1,325 @@ +/** + * 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 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 bothEngines('still', async engine => { + const mounted = await mountedScene(engine, state, EMPTY_STACK) + try { + const png = await mounted.renderer.captureStill('view') + return { drawnWith: mounted.renderer.renderEngine, frame: await decodePng(png) } + } finally { + mounted.release() + } + }) +} + +/** + * 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 bothEngines('temporal', async engine => { + const mounted = await mountedScene('gpu', state, engine === 'gpu' ? ANTIALIAS : EMPTY_STACK) + 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: error instanceof Error ? error.message : String(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..3e099f99e --- /dev/null +++ b/src/renderer/src/engines/render/engineParityStage.ts @@ -0,0 +1,287 @@ +/** + * 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 { 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 + +/** Far enough off screen that nothing of the harness is ever drawn over the studio. */ +const OFFSCREEN_HOST_OFFSET_PX = -100_000 + +/** 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 } + +/** 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, +): Promise { + const host = offscreenHost() + 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 < WARMUP_FRAMES; frame += 1) renderer.drawFrom(null, frame) + + return { + renderer, + release: () => { + renderer.dispose() + host.remove() + }, + } +} + +function offscreenHost(): HTMLElement { + const host = document.createElement('div') + host.style.position = 'fixed' + host.style.left = `${OFFSCREEN_HOST_OFFSET_PX}px` + host.style.top = '0' + host.style.width = `${FRAME * 4}px` + host.style.height = `${FRAME * 4}px` + document.body.appendChild(host) + return host +} + +/** + * 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) + // 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 below would put + // the stills back upside down while the frames beside them came out right. + 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) +} + +/** How many frames are drawn before anything is captured — see `mountedScene`. */ +const WARMUP_FRAMES = 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 index 50c69fec1..5cadfc94e 100644 --- a/src/renderer/src/engines/render/glDriver.ts +++ b/src/renderer/src/engines/render/glDriver.ts @@ -31,7 +31,9 @@ export const glDriver: RenderDriver = { // The ceiling comes from three rather than from `gl.MAX_SAMPLES`, which the WebGL1 typing has // no name for. - maxSamples: renderer => { + maxSamples: renderer => Math.max(0, capsOf(renderer).maxSamples), + + drawingBufferSamples: renderer => { const gl = asWebGL(renderer).getContext() return Math.max( 0, diff --git a/src/renderer/src/engines/render/gpuComposer.ts b/src/renderer/src/engines/render/gpuComposer.ts index ae5b48d00..ade2451ac 100644 --- a/src/renderer/src/engines/render/gpuComposer.ts +++ b/src/renderer/src/engines/render/gpuComposer.ts @@ -14,8 +14,9 @@ import { Vector4, type WebGLRenderTarget } from 'three' import type { RenderTarget, WebGPURenderer } from 'three/webgpu' import { planStack, - POST_EFFECTS, + runsOnEngine, stackShapeKey, + survivesOneShot, type PostEffect, } from '@shared/domain/postProcessing' import { paramNumber } from '../postfx/uniforms' @@ -36,8 +37,12 @@ type GpuChain = { */ camera: ComposerJob['camera'] pipeline: { render: () => void; dispose: () => void } - /** The scene pass, freed by hand: `RenderPipeline.dispose` frees its quad material and no target. */ - pass: { 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 } @@ -63,6 +68,12 @@ export function createGpuComposer(gpu: GpuModule, renderer: WebGPURenderer): Sce // 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) @@ -85,14 +96,45 @@ export function createGpuComposer(gpu: GpuModule, renderer: WebGPURenderer): Sce 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. - chain?.pass.dispose() + for (const node of chain?.owned ?? []) node.dispose() chains.delete(key) } + /** + * The chain this job draws through, built or found, and the one this surface was drawing + * through before it freed. + * + * The SURFACE belongs to the key: a node chain holds the pass that draws the scene, and two + * panes sharing one would each see the other's camera. The previous chain is freed HERE rather + * than left for a sweep — a stack whose shape changes would otherwise leave a full-frame MRT + * behind on every edit. + */ + const chainFor = (job: ComposerJob, effects: readonly PostEffect[], shape: string): GpuChain => { + const key = `${shape}${SURFACE_MARK}${job.surface}` + const held = chains.get(key) + if (held && held.camera !== job.camera) free(key) + const chain = chains.get(key) ?? build(gpu, renderer, job, effects) + chains.set(key, chain) + + const previous = bound.get(job.surface) + bound.set(job.surface, key) + if (previous && previous !== key && ![...bound.values()].includes(previous)) free(previous) + return chain + } + return { draw: job => { const plan = planStack(job.stack) - const effects = plan.effects.filter(runsOnGpu) + // 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 { @@ -103,14 +145,7 @@ export function createGpuComposer(gpu: GpuModule, renderer: WebGPURenderer): Sce return } - // The SURFACE belongs to the key: a node chain holds the pass that draws the scene, and - // two panes sharing one would each see the other's camera. - const key = `${plan.shapeKey}${SURFACE_MARK}${job.surface}` - const held = chains.get(key) - if (held && held.camera !== job.camera) free(key) - const chain = chains.get(key) ?? build(gpu, renderer, job, effects) - chains.set(key, chain) - bound.set(job.surface, key) + const chain = chainFor(job, effects, plan.shapeKey) chain.apply(effects, gpuBudgetFor(heaviestCost(effects), job.quality), job.width, job.height) const restore = aimAt(job) @@ -154,34 +189,74 @@ function build( job: ComposerJob, effects: readonly PostEffect[], ): GpuChain { - const { mrt, normalView, output, pass } = gpu.tsl - - const scene = pass(job.scene, job.camera) - // The normals written by the SAME pass that draws the picture: read from a second pass they - // would cost the scene twice, which is the whole reason a node chain exists. - scene.setMRT(mrt({ output, normal: normalView })) + 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') - const pipeline = new gpu.webgpu.RenderPipeline( - renderer, - occlusion ? occlusion.getTextureNode().mul(colour) : colour, - ) + /** 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, - pass: scene, + // 🛑 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) applyOcclusion(occlusion, asked, budget, width, height) + 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, @@ -213,6 +288,11 @@ function asNodeTarget(target: WebGLRenderTarget | null): RenderTarget | null { } /** Whether the Advanced engine can build this one at all — the registry answers, nothing else. */ -function runsOnGpu(effect: PostEffect): boolean { - return POST_EFFECTS[effect.effect].engines.includes('gpu') +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 index a24276ebc..3a2a5ac14 100644 --- a/src/renderer/src/engines/render/gpuDriver.ts +++ b/src/renderer/src/engines/render/gpuDriver.ts @@ -60,6 +60,7 @@ export const gpuDriver: RenderDriver = { // 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. diff --git a/src/renderer/src/engines/render/gpuModule.ts b/src/renderer/src/engines/render/gpuModule.ts index d2d7fbd34..3bc9d7f7c 100644 --- a/src/renderer/src/engines/render/gpuModule.ts +++ b/src/renderer/src/engines/render/gpuModule.ts @@ -5,18 +5,20 @@ * 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 three are asked for together because they arrive together: a viewport that has the renderer + * 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 @@ -44,11 +46,12 @@ export async function loadGpuModule(): Promise { async function importGpuModule(): Promise { if (!(await probeGpuAdapter())) return null - const [webgpu, tsl, gtao] = await Promise.all([ + 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 } + held = { webgpu, tsl, gtao, traa } return held } diff --git a/src/renderer/src/engines/render/gpuPostQuality.ts b/src/renderer/src/engines/render/gpuPostQuality.ts index 6509091c2..7f63aba96 100644 --- a/src/renderer/src/engines/render/gpuPostQuality.ts +++ b/src/renderer/src/engines/render/gpuPostQuality.ts @@ -17,7 +17,13 @@ import { budgetFor, samplesOf } 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. */ + /** + * 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 } diff --git a/src/renderer/src/engines/render/materialNodes.test.ts b/src/renderer/src/engines/render/materialNodes.test.ts index 7eb1840b2..7773c6074 100644 --- a/src/renderer/src/engines/render/materialNodes.test.ts +++ b/src/renderer/src/engines/render/materialNodes.test.ts @@ -12,12 +12,13 @@ import type { GpuModule } from './gpuModule' let gpu: GpuModule beforeAll(async () => { - const [webgpu, tsl, gtao] = await Promise.all([ + 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 } + gpu = { webgpu, tsl, gtao, traa } }) /** Every uniform of a built graph, which is where the bridge to the engine's own values shows. */ diff --git a/src/renderer/src/engines/render/mountRenderer.test.ts b/src/renderer/src/engines/render/mountRenderer.test.ts index 862cd6a34..a51e060f3 100644 --- a/src/renderer/src/engines/render/mountRenderer.test.ts +++ b/src/renderer/src/engines/render/mountRenderer.test.ts @@ -30,6 +30,7 @@ function drivers(gpu: Partial = {}): RenderDrivers { }, patchMaterial: () => {}, maxSamples: () => 0, + drawingBufferSamples: () => 0, maxAnisotropy: () => 1, frameTimer: () => null, releaseContext: () => {}, diff --git a/src/renderer/src/engines/render/renderDriver.ts b/src/renderer/src/engines/render/renderDriver.ts index 37eaa0600..861e35339 100644 --- a/src/renderer/src/engines/render/renderDriver.ts +++ b/src/renderer/src/engines/render/renderDriver.ts @@ -82,10 +82,18 @@ export type RenderDriver = { onMissingAnchor: (anchor: string) => void, ) => void /** - * How many samples an off-screen target may be antialiased to. ZERO on a node renderer: it - * sizes the attachments of a render target itself, and has no context to ask. + * 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. diff --git a/src/renderer/src/engines/render/sceneComposer.ts b/src/renderer/src/engines/render/sceneComposer.ts index 1990372a2..e0d3bc5a6 100644 --- a/src/renderer/src/engines/render/sceneComposer.ts +++ b/src/renderer/src/engines/render/sceneComposer.ts @@ -18,6 +18,13 @@ 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 diff --git a/src/renderer/src/engines/scene/SceneRendererDisplay.ts b/src/renderer/src/engines/scene/SceneRendererDisplay.ts index b93b5d592..d43c56557 100644 --- a/src/renderer/src/engines/scene/SceneRendererDisplay.ts +++ b/src/renderer/src/engines/scene/SceneRendererDisplay.ts @@ -232,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/SceneRendererState.ts b/src/renderer/src/engines/scene/SceneRendererState.ts index 9bb935c9d..a9078067a 100644 --- a/src/renderer/src/engines/scene/SceneRendererState.ts +++ b/src/renderer/src/engines/scene/SceneRendererState.ts @@ -92,9 +92,10 @@ export abstract class SceneRendererState { protected options!: SceneRendererOptions protected viewport = new ViewportEngine({ - // 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. See `RenderPolicy.engine`. - engine: () => this.view.engine, + // 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), diff --git a/src/renderer/src/engines/scene/csm.test.ts b/src/renderer/src/engines/scene/csm.test.ts index 679b6a0ef..e249f60d4 100644 --- a/src/renderer/src/engines/scene/csm.test.ts +++ b/src/renderer/src/engines/scene/csm.test.ts @@ -71,6 +71,32 @@ describe('cascaded shadows on a scene', () => { } }) + /** + * 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 diff --git a/src/renderer/src/engines/scene/csm.ts b/src/renderer/src/engines/scene/csm.ts index c5a9cadd0..82b43f7b2 100644 --- a/src/renderer/src/engines/scene/csm.ts +++ b/src/renderer/src/engines/scene/csm.ts @@ -212,9 +212,10 @@ function standFor( light: DirectionalLight, stood: StoodFor | null, ): StoodFor | null { - if (stood && stood.light !== light) return stood - const held = stood ?? { light, castShadow: light.castShadow, intensity: light.intensity } - if (stood && light.intensity !== 0) held.intensity = light.intensity + 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) @@ -227,6 +228,22 @@ function standFor( 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 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/sceneWorld.test.ts b/src/renderer/src/engines/scene/sceneWorld.test.ts index be5131e34..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' }) 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/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/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/viewport/ViewportInset.ts b/src/renderer/src/engines/viewport/ViewportInset.ts index f7005fdad..f29344554 100644 --- a/src/renderer/src/engines/viewport/ViewportInset.ts +++ b/src/renderer/src/engines/viewport/ViewportInset.ts @@ -30,8 +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 engine can offer. - const samples = this.renderDriver.maxSamples(renderer) + // 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 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.ts b/src/renderer/src/game/webRender.ts index 48d4db1c3..4954dba27 100644 --- a/src/renderer/src/game/webRender.ts +++ b/src/renderer/src/game/webRender.ts @@ -318,6 +318,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, diff --git a/src/renderer/src/hooks/useRenderEngineReady.test.tsx b/src/renderer/src/hooks/useRenderEngineReady.test.tsx new file mode 100644 index 000000000..bad1327e6 --- /dev/null +++ b/src/renderer/src/hooks/useRenderEngineReady.test.tsx @@ -0,0 +1,65 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +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 }) => useRenderEngineReady(engine), { + initialProps: { engine: 'gl' as const }, + }) + expect(result.current).toBe(true) + + rerender({ engine: 'gpu' as unknown as 'gl' }) + + 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/postProcessing.test.ts b/src/shared/domain/postProcessing.test.ts index dc6aade39..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, @@ -53,13 +54,14 @@ describe('the catalogue', () => { }) // 🛑 A `gpu` written here without a node factory behind it is a slot the Advanced chain - // leaves empty with nothing said. The occlusion is the only one that has one. + // 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']) + expect(advanced).toEqual(['gtao', 'traa']) }) it('gives a fresh instance the defaults of its own effect', () => { @@ -194,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 74fd854a6..7b8c63b19 100644 --- a/src/shared/domain/postProcessingRegistry.ts +++ b/src/shared/domain/postProcessingRegistry.ts @@ -6,7 +6,7 @@ * those may pull three.js in, so nothing here knows a `Pass` exists — `engines/postfx/` is the * one folder that does. */ -import { GL_ONLY, RENDER_ENGINES, type RenderEngine } from './renderEngine' +import { GL_ONLY, GPU_ONLY, RENDER_ENGINES, type RenderEngine } from './renderEngine' import { BLUR_KINDS, HALFTONE_SHAPES, @@ -90,6 +90,7 @@ export type PostEffectId = | 'vhs' | 'fxaa' | 'smaa' + | 'traa' export type PostEffectMeta = { category: PostCategory @@ -97,12 +98,18 @@ export type PostEffectMeta = { 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, and the exclusivity rule holds unchanged — so - * this says nothing about where an effect sits in the chain, only about who can make one. + * 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 @@ -124,8 +131,8 @@ export const POST_EFFECTS: Record = { category: 'lighting', cost: 'high', slot: 'ao', - // The one effect both engines build: GLSL `GTAOPass` on the Compatible side, the native - // `ao()` node on the Advanced one. Same slot, same exclusivity, same parameters. + // 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: { @@ -463,11 +470,29 @@ export const POST_EFFECTS: Record = { 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 index 3463d0c95..56701fa48 100644 --- a/src/shared/domain/renderEngine.ts +++ b/src/shared/domain/renderEngine.ts @@ -11,3 +11,6 @@ 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.ts b/src/shared/domain/renderPolicy.ts index 6394a0f76..8414ca45b 100644 --- a/src/shared/domain/renderPolicy.ts +++ b/src/shared/domain/renderPolicy.ts @@ -81,10 +81,17 @@ 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: view.engine, + engine, shadows: view.shadows, shadowQuality: view.shadowQuality, shadowMapSize: view.shadowMapSize, diff --git a/src/shared/domain/scene.ts b/src/shared/domain/scene.ts index 4558391f3..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' @@ -260,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 @@ -282,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/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/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 399131b4a..b0bba22e5 100644 --- a/src/shared/i18n/ar/settings.json +++ b/src/shared/i18n/ar/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "محرّك العرض", - "help": "بأي واجهة رسومية ترسم العرض ثلاثي الأبعاد. «متوافق» يعمل على كل التهيئات، وهو ما يُبقى عليه للمشاركة الواسعة أو للأجهزة الأقدم. «متقدّم» يعطي إضاءة وانعكاسات أفضل ويطلب بطاقة رسومات حديثة. اللوحة المفتوحة أصلًا تحتفظ بمحرّكها: أغلقها ثم افتحها من جديد لتغييره. الجهاز الذي لا يملك مهايئ WebGPU يعود وحده إلى محرّك «متوافق»، ويسجّل ذلك في السجل.", + "help": "المحرّك الذي تفتح به المشاهد الجديدة. يُطرح السؤال عند إنشاء كل مستند، ويُكتب الجواب في المستند نفسه: هذا الإعداد يملأ الحقل مسبقًا فحسب، ولا يغيّر أي مشهد موجود. «متوافق» يعمل على كل الأجهزة، و«متقدّم» يعطي إضاءة وانعكاسات أفضل ويتطلّب بطاقة رسوميات حديثة. الجهاز الذي لا يملك مهايئ WebGPU يعود من تلقاء نفسه إلى «متوافق»، ويسجّل ذلك في السجل.", "gl": "متوافق", "gpu": "متقدّم" }, 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/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 2f97b7172..8e9f05895 100644 --- a/src/shared/i18n/de/settings.json +++ b/src/shared/i18n/de/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Render-Engine", - "help": "Mit welcher Grafikschnittstelle die 3D-Ansicht zeichnet. Kompatibel läuft auf jeder Konfiguration und ist die Wahl für breites Teilen oder ältere Rechner. Erweitert liefert bessere Beleuchtung und Spiegelungen und verlangt eine aktuelle Grafikkarte. Ein bereits offenes Panel behält seine Engine: schließen und wieder öffnen, um zu wechseln. Ein Rechner ohne WebGPU-Adapter fällt von selbst auf die kompatible Engine zurück und schreibt es ins Journal.", + "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" }, 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/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 a1315af19..f1d041821 100644 --- a/src/shared/i18n/en/settings.json +++ b/src/shared/i18n/en/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Render engine", - "help": "Which graphics interface the 3D viewport draws with. Compatible works on every configuration and is the one to keep for sharing widely or for older machines. Advanced gives better lighting and reflections and asks for a recent graphics card. A panel already open keeps the engine it opened with: close and reopen it to change. A machine with no WebGPU adapter falls back to the Compatible engine on its own, and says so in the journal.", + "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" }, 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/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 215ad2553..f1d4386d0 100644 --- a/src/shared/i18n/es/settings.json +++ b/src/shared/i18n/es/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Motor de renderizado", - "help": "Con qué interfaz gráfica dibuja la vista 3D. Compatible funciona en cualquier configuración y es la opción para compartir ampliamente o para máquinas más antiguas. Avanzado da mejor iluminación y reflejos y pide una tarjeta gráfica reciente. Un panel ya abierto conserva su motor: ciérrelo y vuelva a abrirlo para cambiarlo. Una máquina sin adaptador WebGPU vuelve sola al motor Compatible, y lo indica en el diario.", + "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" }, 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/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 91c1608a5..24301d684 100644 --- a/src/shared/i18n/fr/settings.json +++ b/src/shared/i18n/fr/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Moteur de rendu", - "help": "Avec quelle interface graphique la vue 3D dessine. Compatible fonctionne sur toutes les configurations : c’est celui à garder pour partager largement ou pour les machines plus anciennes. Avancé donne un éclairage et des reflets de meilleure qualité et demande une carte graphique récente. Un panneau déjà ouvert conserve le moteur avec lequel il s’est ouvert : refermez-le et rouvrez-le pour en changer. Une machine sans adaptateur WebGPU retombe d’elle-même sur le moteur Compatible, et le dit dans le journal.", + "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é" }, 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/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 42cbe186a..d2f4659e6 100644 --- a/src/shared/i18n/hi/settings.json +++ b/src/shared/i18n/hi/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "रेंडर इंजन", - "help": "3D दृश्य किस ग्राफ़िक्स इंटरफ़ेस से बनता है। संगत हर कॉन्फ़िगरेशन पर चलता है और व्यापक साझेदारी या पुरानी मशीनों के लिए यही रखना चाहिए। उन्नत बेहतर रोशनी और परावर्तन देता है और नया ग्राफ़िक्स कार्ड माँगता है। पहले से खुला पैनल अपना इंजन बनाए रखता है: बदलने के लिए उसे बंद करके फिर खोलें। उन्नत इंजन इस संस्करण में जिस मशीन में WebGPU अडैप्टर नहीं है वह खुद ही संगत इंजन पर लौट आती है, और यह बात लॉग में लिख देती है।", + "help": "नए दृश्य किस पर खुलते हैं। यह सवाल हर दस्तावेज़ बनाते समय पूछा जाता है और जवाब दस्तावेज़ में ही लिखा जाता है: यह सेटिंग सिर्फ़ फ़ील्ड को पहले से भरती है और किसी मौजूदा दृश्य को नहीं बदलती। संगत हर मशीन पर चलता है; उन्नत बेहतर प्रकाश और परावर्तन देता है और नया ग्राफ़िक्स कार्ड चाहता है। WebGPU अडैप्टर के बिना मशीन खुद ही संगत पर लौट आती है और लॉग में यह बता देती है।", "gl": "संगत", "gpu": "उन्नत" }, 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/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 c362d6449..6ba8fc545 100644 --- a/src/shared/i18n/id/settings.json +++ b/src/shared/i18n/id/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Mesin render", - "help": "Antarmuka grafis mana yang dipakai tampilan 3D untuk menggambar. Kompatibel berjalan di setiap konfigurasi dan itulah yang dipertahankan untuk berbagi luas atau mesin lama. Lanjutan memberi pencahayaan dan pantulan yang lebih baik dan meminta kartu grafis terkini. Panel yang sudah terbuka mempertahankan mesinnya: tutup dan buka lagi untuk berganti. Mesin tanpa adaptor WebGPU kembali sendiri ke mesin Kompatibel, dan mencatatnya di jurnal.", + "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" }, 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/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 36bb66c32..ba1ce282a 100644 --- a/src/shared/i18n/it/settings.json +++ b/src/shared/i18n/it/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Motore di rendering", - "help": "Con quale interfaccia grafica disegna la vista 3D. Compatibile funziona su ogni configurazione ed è quello da tenere per condividere ampiamente o per macchine più vecchie. Avanzato dà illuminazione e riflessi migliori e chiede una scheda grafica recente. Un pannello già aperto conserva il suo motore: chiuderlo e riaprirlo per cambiarlo. Una macchina senza adattatore WebGPU torna da sola al motore Compatibile, e lo scrive nel giornale.", + "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" }, 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/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 8bc18441f..0508a46bb 100644 --- a/src/shared/i18n/ja/settings.json +++ b/src/shared/i18n/ja/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "レンダーエンジン", - "help": "3D ビューがどのグラフィックス インターフェースで描くかです。「互換」はあらゆる構成で動き、広く共有する場合や古いマシンではこちらを保ちます。「上級」は照明と反射の質が上がり、新しめのグラフィックスカードを求めます。すでに開いているパネルは開いたときのエンジンのままです。切り替えるには閉じて開き直してください。WebGPU アダプターのないマシンは自動的に「互換」エンジンに戻り、その旨を記録に残します。", + "help": "新しいシーンが何で開くか。質問はドキュメントを作るたびに出され、答えはそのドキュメント自身に書き込まれます。この設定は入力欄をあらかじめ埋めるだけで、既存のシーンは変えません。互換はどのマシンでも動き、上級は照明と反射がより高品質でありながら新しいグラフィックスカードを必要とします。WebGPU アダプターのないマシンは自分で互換に戻り、そのことをジャーナルに書きます。", "gl": "互換", "gpu": "上級" }, 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/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 dcaf1b426..e1cc356fa 100644 --- a/src/shared/i18n/ko/settings.json +++ b/src/shared/i18n/ko/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "렌더 엔진", - "help": "3D 뷰가 어떤 그래픽 인터페이스로 그릴지 정합니다. 호환은 모든 구성에서 동작하며, 널리 공유하거나 오래된 기기에서는 이쪽을 유지합니다. 고급은 조명과 반사의 품질이 높아지고 최신 그래픽 카드를 요구합니다. 이미 열린 패널은 열릴 때의 엔진을 그대로 씁니다. 바꾸려면 닫았다가 다시 여세요. WebGPU 어댑터가 없는 기기는 스스로 호환 엔진으로 돌아가며, 그 사실을 기록에 남깁니다.", + "help": "새 장면이 무엇으로 열리는지. 이 질문은 문서를 만들 때마다 나오고 답은 문서 자체에 기록됩니다. 이 설정은 입력란을 미리 채울 뿐이며 기존 장면은 바꾸지 않습니다. 호환은 모든 컴퓨터에서 돌아가고, 고급은 조명과 반사가 더 좋은 대신 최신 그래픽 카드를 요구합니다. WebGPU 어댑터가 없는 컴퓨터는 스스로 호환으로 돌아가며 그 사실을 기록에 남깁니다.", "gl": "호환", "gpu": "고급" }, 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/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 420524f97..ec35ca134 100644 --- a/src/shared/i18n/pt/settings.json +++ b/src/shared/i18n/pt/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Motor de renderização", - "help": "Com que interface gráfica a vista 3D desenha. Compatível funciona em qualquer configuração e é o que se guarda para partilhar amplamente ou para máquinas mais antigas. Avançado dá melhor iluminação e reflexos e pede uma placa gráfica recente. Um painel já aberto conserva o seu motor: feche-o e volte a abri-lo para mudar. Uma máquina sem adaptador WebGPU volta sozinha ao motor Compatível, e di-lo no diário.", + "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" }, 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/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 0e4c95916..1555acb33 100644 --- a/src/shared/i18n/ru/settings.json +++ b/src/shared/i18n/ru/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Движок отрисовки", - "help": "С каким графическим интерфейсом рисует 3D-вид. «Совместимый» работает на любой конфигурации — его и стоит держать для широкого обмена и старых машин. «Продвинутый» даёт лучше свет и отражения и требует свежей видеокарты. Уже открытая панель сохраняет свой движок: закройте и откройте её заново, чтобы сменить. Машина без адаптера WebGPU сама возвращается к Совместимому движку и пишет об этом в журнал.", + "help": "С чем открываются новые сцены. Вопрос задаётся при создании каждого документа, а ответ записывается в сам документ: эта настройка лишь заполняет поле заранее и не меняет ни одну существующую сцену. Совместимый работает на любой машине; Продвинутый даёт лучше свет и отражения и просит современную видеокарту. Машина без адаптера WebGPU сама возвращается к Совместимому и пишет об этом в журнал.", "gl": "Совместимый", "gpu": "Продвинутый" }, 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/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 4660dff24..ff379b7b9 100644 --- a/src/shared/i18n/tr/settings.json +++ b/src/shared/i18n/tr/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Render motoru", - "help": "3B görünümün hangi grafik arabirimiyle çizdiği. Uyumlu her yapılandırmada çalışır; geniş paylaşım ve eski makineler için tutulacak olan budur. Gelişmiş daha iyi aydınlatma ve yansıma verir ve yeni bir ekran kartı ister. Zaten açık bir panel motorunu korur: değiştirmek için kapatıp yeniden açın. WebGPU bağdaştırıcısı olmayan bir makine kendiliğinden Uyumlu motora döner ve bunu günlüğe yazar.", + "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ş" }, 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/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 1a394a507..aa830892e 100644 --- a/src/shared/i18n/vi/settings.json +++ b/src/shared/i18n/vi/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "Bộ máy dựng hình", - "help": "Giao diện đồ họa nào vẽ khung nhìn 3D. Tương thích chạy trên mọi cấu hình và là lựa chọn để chia sẻ rộng hay cho máy cũ. Nâng cao cho ánh sáng và phản chiếu tốt hơn, và đòi một card đồ họa đời mới. Bảng đã mở vẫn giữ bộ máy của nó: đóng rồi mở lại để đổi. Máy không có bộ điều hợp WebGPU tự quay về bộ Tương thích, và ghi điều đó vào nhật ký.", + "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" }, 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/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 a6146ccad..d750748fc 100644 --- a/src/shared/i18n/zh/settings.json +++ b/src/shared/i18n/zh/settings.json @@ -248,7 +248,7 @@ }, "renderEngine": { "title": "渲染引擎", - "help": "3D 视图用哪一套图形接口绘制。「兼容」在所有配置上都能运行,要广泛分享或使用较旧的机器就保留它。「高级」的光照与反射更好,需要较新的显卡。已经打开的面板会保留打开时的引擎:关掉再打开才会更换。没有 WebGPU 适配器的机器会自行退回「兼容」引擎,并在日志里说明。", + "help": "新场景以什么打开。这个问题在每次创建文档时都会提出,答案写进文档本身:本设置只是预先填好该字段,不会改动任何已有场景。兼容在所有机器上都能运行;高级的光照和反射更好,但需要较新的显卡。没有 WebGPU 适配器的机器会自行回落到兼容,并在日志中说明。", "gl": "兼容", "gpu": "高级" }, From ee10965e92d3ffca8afbe1d5feeb486d5273ae4c Mon Sep 17 00:00:00 2001 From: Alban Pasquelin Date: Fri, 11 Sep 2026 09:42:04 +0200 Subject: [PATCH 08/13] =?UTF-8?q?Type=20le=20moteur=20du=20test=20plut?= =?UTF-8?q?=C3=B4t=20que=20de=20le=20figer=20avec=20`as=20const`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La porte a viré au rouge sur `develop` : `check-as-const` lit `git ls-files`, donc il ne voit pas un fichier neuf tant qu'il n'est pas indexé. La validate lancée avant le premier `git add` était aveugle à ce test, qui figeait son paramètre avec `as const` et le rerendait avec un `as unknown as`. Annoter l'argument de `renderHook` supprime les deux d'un coup. --- src/renderer/src/hooks/useRenderEngineReady.test.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/hooks/useRenderEngineReady.test.tsx b/src/renderer/src/hooks/useRenderEngineReady.test.tsx index bad1327e6..09c519b6d 100644 --- a/src/renderer/src/hooks/useRenderEngineReady.test.tsx +++ b/src/renderer/src/hooks/useRenderEngineReady.test.tsx @@ -1,5 +1,6 @@ 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(() => ({ @@ -53,12 +54,13 @@ describe('whether a viewport may be built on an engine yet', () => { * 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 }) => useRenderEngineReady(engine), { - initialProps: { engine: 'gl' as const }, - }) + const { result, rerender } = renderHook( + ({ engine }: { engine: RenderEngine }) => useRenderEngineReady(engine), + { initialProps: { engine: 'gl' } }, + ) expect(result.current).toBe(true) - rerender({ engine: 'gpu' as unknown as 'gl' }) + rerender({ engine: 'gpu' }) expect(result.current).toBe(false) }) From 8da7808eb56aaa03bec530ad485dc52919c27e83 Mon Sep 17 00:00:00 2001 From: Alban Pasquelin Date: Fri, 11 Sep 2026 10:25:33 +0200 Subject: [PATCH 09/13] Refuse les cascades au moteur qui ne les dessine pas, et rend au rapport ce qu'il affirmait sans le tenir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relecture du chantier C6. Six énoncés du rapport ne tenaient pas devant le code : un fichier de test qui n'existe pas (`renderDriver.test.ts`), une colonne de banc qui n'existe pas (`readbackMs`), trois nombres de tests gonflés, des plafonds de porte présentés comme des mesures arrondies alors que ce sont des marges, un critère d'acceptation (« un projet existant est identique ») démenti par l'anisotropie maximale de sa propre étape, et l'export jeu présenté comme honorant le moteur du document alors que `createWebRender` construit un `WebGLRenderer` sans jamais lire `policy.engine`. Un défaut, corrigé : les cascades se construisaient sur le moteur Avancé. `CSM` passe par `onBeforeCompile`, que seul `WebGLRenderer` appelle — three 0.185 ne le nomme nulle part sous `renderers/common` ni `renderers/webgpu`. Le patch n'atteignait aucun programme pendant que `dress` éteignait le soleil du document et posait trois bandes à son intensité : scène éclairée trois fois, ombre perdue. `cascadesWanted(policy, engine)` porte désormais la règle, et le port de jeu la lit au lieu de la réécrire. /simplify, quatre angles : - `gpuComposer` tenait deux maps pour une relation une-à-une ; la clé contenant déjà la surface, les deux gardes `[...bound.values()].includes(...)` protégeaient un cas impossible. Une `Map` suffit. - `gpuSamplesOf` fabriquait un `divisor` que `samplesOf` ne lit jamais : `samplesOf` s'élargit à `Pick` et la fonction disparaît. - `drawOverlay` rejoint `RenderDriver` : `'capabilities' in renderer` au point d'appel était l'expression que l'en-tête du driver cite comme ce qu'il existe pour empêcher. - `asNodeTarget` était écrit deux fois, JSDoc comprise ; il rejoint `drawInto`. - Le banc réutilise `mountedScene`, `offScreenHost` et l'attente d'images du harnais de parité au lieu de les réécrire ; `messageOf` remplace deux copies. - `mountRenderer` perd un troisième état que seul le test exerçait. Cinq coûts par image relevés et NON MESURÉS sont écrits dans le rapport plutôt que corrigés à l'aveugle : ils demandent `pnpm engines:parity` sur une machine WebGPU. `pnpm validate` verte en 174 s. --- docs/fr/audits/moteur-rendu-c6/RAPPORT.md | 107 ++++++++++++++---- .../src/engines/postfx/postQuality.test.ts | 4 +- .../src/engines/postfx/postQuality.ts | 9 +- .../engines/render/engineBenchmark.browser.ts | 60 ++-------- .../engines/render/engineParity.browser.ts | 34 ++++-- .../src/engines/render/engineParityStage.ts | 33 ++---- src/renderer/src/engines/render/glDriver.ts | 10 ++ .../src/engines/render/gpuComposer.ts | 79 +++++-------- src/renderer/src/engines/render/gpuDriver.ts | 16 +-- .../src/engines/render/gpuPostQuality.test.ts | 4 +- .../src/engines/render/gpuPostQuality.ts | 7 +- .../src/engines/render/mountRenderer.test.ts | 12 +- .../src/engines/render/mountRenderer.ts | 22 +--- .../src/engines/render/renderDriver.ts | 18 ++- .../src/engines/render/sceneComposer.ts | 3 + .../src/engines/scene/SceneRendererShadows.ts | 5 +- src/renderer/src/engines/scene/csm.test.ts | 20 +++- src/renderer/src/engines/scene/csm.ts | 31 ++++- .../src/engines/viewport/ViewportFrame.ts | 10 +- .../src/engines/viewport/ViewportSurface.ts | 5 +- src/renderer/src/game/webRender.ts | 16 ++- 21 files changed, 279 insertions(+), 226 deletions(-) diff --git a/docs/fr/audits/moteur-rendu-c6/RAPPORT.md b/docs/fr/audits/moteur-rendu-c6/RAPPORT.md index a2d7ec659..c7c618547 100644 --- a/docs/fr/audits/moteur-rendu-c6/RAPPORT.md +++ b/docs/fr/audits/moteur-rendu-c6/RAPPORT.md @@ -3,11 +3,18 @@ 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 | 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. | +| 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 | `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. | | 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. | @@ -32,8 +39,13 @@ une comparaison non filtrée. `WelcomeBackdrop.ts:171` portait déjà cette note 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. Un projet -existant est donc identique, point. +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) @@ -141,8 +153,8 @@ panneau qui se plaint serait la spécification d'une machine présentée comme u `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. Six cas couverts par `gpuAdapter.test.ts`, dont l'adaptateur refusé et -`requestAdapter` qui lève ; sept cas de repli par `renderDriver.test.ts`. +suivant. Quatre tests dans `gpuAdapter.test.ts` couvrent six cas, dont l'adaptateur refusé et +`requestAdapter` qui lève ; cinq cas de repli par `mountRenderer.test.ts`. ### Écart assumé : où vit le sélecteur @@ -234,8 +246,8 @@ Surface 1280×720, qualité `high`, une pile portant GTAO sur les deux moteurs. 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. Cinq tests tiennent -ce pont, dont celui qui vérifie que le graphe ne se reconstruit pas quand un canal arrive. +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. @@ -255,20 +267,21 @@ l'étape 2 pour cette raison exacte. Les trois appelants (film, capture de vol, é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 `readbackMs`, qui est -exactement `captureStill`. +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. Cinq tests, dont deux qui -comparent les deux lectures réglage par réglage. +`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.subpixelCorrection`. +`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 @@ -310,8 +323,19 @@ consciente du moteur, c'est-à-dire l'UX que le spec met hors périmètre. - **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à. -- **L'aperçu incrusté** et 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. +- **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) @@ -338,9 +362,13 @@ noire. | `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 ces mesures arrondies vers le haut, -jamais des cibles théoriques ; `material` n'en a pas — son écart est CONNU, et transformer une -divergence documentée en réussite ou en échec serait mentir dans les deux sens. +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 @@ -402,9 +430,15 @@ un peu plus à chaque ligne. 1 024 et 640 divisent proprement et ne prouvent rie ### 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, relu à chaque montage de viewport et porté par l'export jeu (la scène d'ENTRÉE -décide, un jeu ne tenant qu'un renderer). `Settings.three.engine` ne sert plus qu'à pré-remplir le -champ ; son aide le dit, dans les quinze langues. +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. Écrit dans `webRender.ts` et +repris dans « Ce qui 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 @@ -480,14 +514,39 @@ que la chaîne écarte ensuite. Le repli est au journal ; cette liste dit ce que - **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, l'aperçu incrusté et la correction de ciel côté Avancé. +- 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. -- **La fenêtre de jeu ne suit pas le moteur du document** qu'elle joue, là où un export du même - document le suit : son renderer est bâti 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. +- **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` (écrit là ; la correction 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/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/render/engineBenchmark.browser.ts b/src/renderer/src/engines/render/engineBenchmark.browser.ts index 2516da929..59282c33b 100644 --- a/src/renderer/src/engines/render/engineBenchmark.browser.ts +++ b/src/renderer/src/engines/render/engineBenchmark.browser.ts @@ -22,16 +22,16 @@ * `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 { DEFAULT_SETTINGS } from '@shared/domain/settings' +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 { SceneRenderer } from '../scene/SceneRenderer' 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 @@ -55,10 +55,8 @@ 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 -const WARMUP_FRAMES = 10 - -const OFFSCREEN_HOST_OFFSET_PX = -100_000 -const SURFACE = { width: 1280, height: 720 } +/** 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 @@ -88,20 +86,11 @@ async function benchmarkEngines(): Promise { } async function measureEngine(engine: RenderEngine, state: SceneState): Promise { - const host = offscreenHost() - const renderer = new SceneRenderer({ onSelect: () => {}, onTransform: () => {}, chrome: false }) + let mounted: MountedScene | null = null try { - // Before the mount, never after: the engine is read once, when the renderer is built. - renderer.configure({ ...DEFAULT_SETTINGS.three, engine, quality: 'high' }) - renderer.mount(host) - // The node backend comes up a beat after the mount: measured before it does, every draw - // would throw and the column would report a race rather than an engine. - await renderer.settled() - renderer.apply({ ...state, world: { ...state.world, post: STACK } }) - await settled() - + mounted = await mountedScene(engine, state, STACK, SURFACE) + const renderer = mounted.renderer const drawnWith = renderer.renderEngine - for (let frame = 0; frame < WARMUP_FRAMES; frame += 1) renderer.drawFrom(null, 0) const started = performance.now() for (let frame = 0; frame < MEASURED_FRAMES; frame += 1) renderer.drawFrom(null, frame) @@ -125,26 +114,13 @@ async function measureEngine(engine: RenderEngine, state: SceneState): Promise { - await Promise.race([ - new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))), - new Promise(resolve => setTimeout(resolve, SETTLE_MS)), - ]) -} - -/** How long the quiet above is given when nothing paints — a hidden window paints nothing. */ -const SETTLE_MS = 250 - 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 index eda382acf..08c5fc676 100644 --- a/src/renderer/src/engines/render/engineParity.browser.ts +++ b/src/renderer/src/engines/render/engineParity.browser.ts @@ -33,6 +33,7 @@ * 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' @@ -142,15 +143,7 @@ async function sceneCase( * PICTURES — the bytes differ whatever happens, an encoder being free to pack them how it likes. */ async function stillCase(state: SceneState): Promise { - return await bothEngines('still', async engine => { - const mounted = await mountedScene(engine, state, EMPTY_STACK) - try { - const png = await mounted.renderer.captureStill('view') - return { drawnWith: mounted.renderer.renderEngine, frame: await decodePng(png) } - } finally { - mounted.release() - } - }) + return await stillsOf('still', state, engine => ({ engine, post: EMPTY_STACK })) } /** @@ -171,8 +164,25 @@ async function stillCase(state: SceneState): Promise { * of the result cannot say so; this note does. */ async function temporalCase(state: SceneState): Promise { - return await bothEngines('temporal', async engine => { - const mounted = await mountedScene('gpu', state, engine === 'gpu' ? ANTIALIAS : EMPTY_STACK) + 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) } @@ -318,7 +328,7 @@ async function bothEngines( // 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: error instanceof Error ? error.message : String(error) } + return { case: which, failed: messageOf(error) } } } diff --git a/src/renderer/src/engines/render/engineParityStage.ts b/src/renderer/src/engines/render/engineParityStage.ts index 3e099f99e..6402e570f 100644 --- a/src/renderer/src/engines/render/engineParityStage.ts +++ b/src/renderer/src/engines/render/engineParityStage.ts @@ -24,6 +24,7 @@ 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' @@ -36,9 +37,6 @@ 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 -/** Far enough off screen that nothing of the harness is ever drawn over the studio. */ -const OFFSCREEN_HOST_OFFSET_PX = -100_000 - /** Where both frames of every case are left, for the runner to write beside the report. */ const FRAMES_HANDLE = '__iaEngineParityFrames' @@ -83,13 +81,20 @@ async function encodePng(frame: VisualFrame): Promise { 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() + const host = offScreenHost(shape.width, shape.height) const renderer = new SceneRenderer({ engine, onSelect: () => {}, @@ -109,7 +114,7 @@ export async function mountedScene( // 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 < WARMUP_FRAMES; frame += 1) renderer.drawFrom(null, frame) + for (let frame = 0; frame < shape.warmup; frame += 1) renderer.drawFrom(null, frame) return { renderer, @@ -120,17 +125,6 @@ export async function mountedScene( } } -function offscreenHost(): HTMLElement { - const host = document.createElement('div') - host.style.position = 'fixed' - host.style.left = `${OFFSCREEN_HOST_OFFSET_PX}px` - host.style.top = '0' - host.style.width = `${FRAME * 4}px` - host.style.height = `${FRAME * 4}px` - document.body.appendChild(host) - return host -} - /** * 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. @@ -235,9 +229,6 @@ export async function decodePng(png: Uint8Array): Promise { 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) - // 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 below would put - // the stills back upside down while the frames beside them came out right. return { width: bitmap.width, height: bitmap.height, @@ -279,8 +270,8 @@ async function quiet(): Promise { await animationFramesArrive(SETTLE_MS) } -/** How many frames are drawn before anything is captured — see `mountedScene`. */ -const WARMUP_FRAMES = 4 +/** 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 index 5cadfc94e..c0b97752d 100644 --- a/src/renderer/src/engines/render/glDriver.ts +++ b/src/renderer/src/engines/render/glDriver.ts @@ -29,6 +29,16 @@ export const glDriver: RenderDriver = { 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), diff --git a/src/renderer/src/engines/render/gpuComposer.ts b/src/renderer/src/engines/render/gpuComposer.ts index ade2451ac..8ab899b14 100644 --- a/src/renderer/src/engines/render/gpuComposer.ts +++ b/src/renderer/src/engines/render/gpuComposer.ts @@ -10,8 +10,8 @@ * out rather than refused: a stack carries what a document says, and an engine cannot make a * document wrong. */ -import { Vector4, type WebGLRenderTarget } from 'three' -import type { RenderTarget, WebGPURenderer } from 'three/webgpu' +import { Vector4 } from 'three' +import type { WebGPURenderer } from 'three/webgpu' import { planStack, runsOnEngine, @@ -20,10 +20,11 @@ import { type PostEffect, } from '@shared/domain/postProcessing' import { paramNumber } from '../postfx/uniforms' +import { samplesOf } from '../postfx/postQuality' import { heaviestCost } from '../postfx/postPlan' -import { gpuBudgetFor, gpuSamplesOf, type GpuBudget } from './gpuPostQuality' +import { gpuBudgetFor, type GpuBudget } from './gpuPostQuality' import type { GpuModule } from './gpuModule' -import { drawInto } from './renderDriver' +import { asNodeTarget, drawInto } from './renderDriver' import type { ComposerJob, SceneComposer } from './sceneComposer' type Occlusion = ReturnType @@ -47,8 +48,11 @@ type GpuChain = { 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() + 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() @@ -88,37 +92,30 @@ export function createGpuComposer(gpu: GpuModule, renderer: WebGPURenderer): Sce renderer.setScissorTest(true) return restore } - /** Which surface draws through which chain, so a closed panel frees what only it was using. */ - const bound = new Map() - const free = (key: string): void => { - const chain = chains.get(key) - chain?.pipeline.dispose() + 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 chain?.owned ?? []) node.dispose() - chains.delete(key) + for (const node of held.chain.owned) node.dispose() + chains.delete(surface) } /** - * The chain this job draws through, built or found, and the one this surface was drawing - * through before it freed. + * The chain this job draws through, built or found. * - * The SURFACE belongs to the key: a node chain holds the pass that draws the scene, and two - * panes sharing one would each see the other's camera. The previous chain is freed HERE rather - * than left for a sweep — a stack whose shape changes would otherwise leave a full-frame MRT - * behind on every edit. + * 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 key = `${shape}${SURFACE_MARK}${job.surface}` - const held = chains.get(key) - if (held && held.camera !== job.camera) free(key) - const chain = chains.get(key) ?? build(gpu, renderer, job, effects) - chains.set(key, chain) - - const previous = bound.get(job.surface) - bound.set(job.surface, key) - if (previous && previous !== key && ![...bound.values()].includes(previous)) free(previous) + const held = chains.get(job.surface) + if (held && (held.shape !== shape || held.chain.camera !== job.camera)) free(job.surface) + const chain = chains.get(job.surface)?.chain ?? build(gpu, renderer, job, effects) + chains.set(job.surface, { shape, chain }) return chain } @@ -158,22 +155,13 @@ export function createGpuComposer(gpu: GpuModule, renderer: WebGPURenderer): Sce sweep: live => { const shapes = new Set(live.map(stackShapeKey)) - for (const key of [...chains.keys()]) { - if (!shapes.has(key.slice(0, key.indexOf(SURFACE_MARK)))) free(key) - } + for (const [surface, held] of [...chains]) if (!shapes.has(held.shape)) free(surface) }, - releaseSurface: surface => { - const key = bound.get(surface) - bound.delete(surface) - // Only once nobody else draws through it: a chain is keyed on the surface, but a sweep - // may have bound two of them to one shape. - if (key && ![...bound.values()].includes(key)) free(key) - }, + releaseSurface: free, dispose: () => { - for (const key of [...chains.keys()]) free(key) - bound.clear() + for (const surface of [...chains.keys()]) free(surface) }, } } @@ -269,24 +257,13 @@ function applyOcclusion( occlusion.distanceExponent.value = paramNumber(effect, 'distanceExponent') occlusion.thickness.value = paramNumber(effect, 'thickness') occlusion.scale.value = paramNumber(effect, 'scale') - occlusion.samples.value = gpuSamplesOf(paramNumber(effect, 'samples'), budget) + 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) } -/** What tells a shape from the surface it was built for, in a chain key. */ -const SURFACE_MARK = '#' - -/** - * `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. - */ -function asNodeTarget(target: WebGLRenderTarget | null): RenderTarget | null { - return target as unknown as RenderTarget | null -} - /** 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') diff --git a/src/renderer/src/engines/render/gpuDriver.ts b/src/renderer/src/engines/render/gpuDriver.ts index 3a2a5ac14..effaccde4 100644 --- a/src/renderer/src/engines/render/gpuDriver.ts +++ b/src/renderer/src/engines/render/gpuDriver.ts @@ -20,9 +20,8 @@ import { createEnvironment, ROOM_SIGMA, type EnvironmentPort } from '../viewport import { createGpuComposer } from './gpuComposer' import { loadedGpuModule, type GpuModule } from './gpuModule' import { applyMaterialNodes } from './materialNodes' -import type { RenderDriver, StudioRenderer } from './renderDriver' -import type { WebGLRenderTarget } from 'three' -import type { RenderTarget, WebGPURenderer } from 'three/webgpu' +import { asNodeTarget, type RenderDriver, type StudioRenderer } from './renderDriver' +import type { WebGPURenderer } from 'three/webgpu' export const gpuDriver: RenderDriver = { engine: 'gpu', @@ -57,6 +56,9 @@ export const gpuDriver: RenderDriver = { 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, @@ -135,11 +137,3 @@ function sameShapeAsGl(read: Uint8Array, width: number, height: number): Uint8Ar /** What WebGPU aligns a texture-to-buffer copy to, per row. */ const BYTES_PER_ROW_ALIGNMENT = 256 - -/** - * `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. - */ -function asNodeTarget(target: WebGLRenderTarget): RenderTarget { - return target as unknown as RenderTarget -} diff --git a/src/renderer/src/engines/render/gpuPostQuality.test.ts b/src/renderer/src/engines/render/gpuPostQuality.test.ts index 7cbcc1ed9..8402e4a87 100644 --- a/src/renderer/src/engines/render/gpuPostQuality.test.ts +++ b/src/renderer/src/engines/render/gpuPostQuality.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { VIEWPORT_QUALITIES } from '@shared/domain/scene' import { budgetFor, samplesOf } from '../postfx/postQuality' -import { gpuBudgetFor, gpuSamplesOf } from './gpuPostQuality' +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 @@ -19,7 +19,7 @@ describe('what the Advanced chain is allowed to spend', () => { it('brings a count down exactly as the Compatible chain does', () => { for (const quality of VIEWPORT_QUALITIES) { const asked = 16 - expect(gpuSamplesOf(asked, gpuBudgetFor('high', quality))).toBe( + expect(samplesOf(asked, gpuBudgetFor('high', quality))).toBe( samplesOf(asked, budgetFor('high', quality)), ) } diff --git a/src/renderer/src/engines/render/gpuPostQuality.ts b/src/renderer/src/engines/render/gpuPostQuality.ts index 7f63aba96..4759920f0 100644 --- a/src/renderer/src/engines/render/gpuPostQuality.ts +++ b/src/renderer/src/engines/render/gpuPostQuality.ts @@ -12,7 +12,7 @@ */ import type { PostCost } from '@shared/domain/postProcessing' import type { ViewportQuality } from '@shared/domain/scene' -import { budgetFor, samplesOf } from '../postfx/postQuality' +import { budgetFor } from '../postfx/postQuality' export type GpuBudget = { /** What share of the frame an occlusion is worked out at — `GTAONode.resolutionScale`. */ @@ -32,8 +32,3 @@ export function gpuBudgetFor(heaviest: PostCost | null, quality: ViewportQuality const budget = budgetFor(heaviest, quality) return { resolutionScale: 1 / budget.divisor, samples: budget.samples } } - -/** A count asked for by a parameter, brought down to what the budget allows. Never below one. */ -export function gpuSamplesOf(asked: number, budget: GpuBudget): number { - return samplesOf(asked, { divisor: 1 / budget.resolutionScale, samples: budget.samples }) -} diff --git a/src/renderer/src/engines/render/mountRenderer.test.ts b/src/renderer/src/engines/render/mountRenderer.test.ts index a51e060f3..f54690ba9 100644 --- a/src/renderer/src/engines/render/mountRenderer.test.ts +++ b/src/renderer/src/engines/render/mountRenderer.test.ts @@ -29,6 +29,7 @@ function drivers(gpu: Partial = {}): RenderDrivers { throw new Error('not asked for') }, patchMaterial: () => {}, + drawOverlay: () => {}, maxSamples: () => 0, drawingBufferSamples: () => 0, maxAnisotropy: () => 1, @@ -49,13 +50,6 @@ describe('mounting a renderer', () => { expect(mountRenderer(request, 'gpu', true, vi.fn(), two).driver).toBe(two.gpu) }) - it('keeps the Compatible engine while nobody has asked the adapter yet', () => { - // A mount cannot wait on `requestAdapter`, and a viewport that waited would show nothing. - const two = drivers() - - expect(mountRenderer(request, 'gpu', null, vi.fn(), two).driver).toBe(two.gl) - }) - it('falls back to the Compatible engine when the Advanced one throws', () => { const two = drivers({ createRenderer: () => { @@ -70,7 +64,9 @@ describe('mounting a renderer', () => { expect(said).toHaveBeenCalledOnce() }) - it('says why when a machine with no adapter was asked for the Advanced engine', () => { + // 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()) diff --git a/src/renderer/src/engines/render/mountRenderer.ts b/src/renderer/src/engines/render/mountRenderer.ts index 120ec2db8..ea2b5a814 100644 --- a/src/renderer/src/engines/render/mountRenderer.ts +++ b/src/renderer/src/engines/render/mountRenderer.ts @@ -20,20 +20,6 @@ export type RenderDrivers = { gl: RenderDriver; gpu: RenderDriver } */ const RENDER_DRIVERS: RenderDrivers = { gl: glDriver, gpu: gpuDriver } -/** - * The driver a policy asks for — the Compatible one whenever the Advanced engine has nothing to - * draw with. `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 and - * the answer is there for the next. - */ -function driverFor( - engine: RenderEngine, - gpuReady: boolean | null, - drivers: RenderDrivers = RENDER_DRIVERS, -): RenderDriver { - return engine === 'gpu' && gpuReady === true ? drivers.gpu : drivers.gl -} - /** What was mounted, which is not always what was asked for. */ export type MountedRenderer = { renderer: StudioRenderer; driver: RenderDriver } @@ -41,15 +27,19 @@ 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 | null, + gpuReady: boolean, onFallback: (error: unknown) => void, drivers: RenderDrivers = RENDER_DRIVERS, ): MountedRenderer { - const wanted = driverFor(engine, gpuReady, drivers) + 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) { diff --git a/src/renderer/src/engines/render/renderDriver.ts b/src/renderer/src/engines/render/renderDriver.ts index 861e35339..330ab101a 100644 --- a/src/renderer/src/engines/render/renderDriver.ts +++ b/src/renderer/src/engines/render/renderDriver.ts @@ -15,7 +15,7 @@ * 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 { WebGPURenderer } from 'three/webgpu' +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' @@ -81,6 +81,12 @@ export type RenderDriver = { 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. @@ -108,6 +114,16 @@ export type RenderDriver = { 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. * diff --git a/src/renderer/src/engines/render/sceneComposer.ts b/src/renderer/src/engines/render/sceneComposer.ts index e0d3bc5a6..4da40aed1 100644 --- a/src/renderer/src/engines/render/sceneComposer.ts +++ b/src/renderer/src/engines/render/sceneComposer.ts @@ -12,6 +12,9 @@ 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 } diff --git a/src/renderer/src/engines/scene/SceneRendererShadows.ts b/src/renderer/src/engines/scene/SceneRendererShadows.ts index 87b6d49a0..f15735544 100644 --- a/src/renderer/src/engines/scene/SceneRendererShadows.ts +++ b/src/renderer/src/engines/scene/SceneRendererShadows.ts @@ -19,7 +19,7 @@ import { applyMaterial, applyNegative, applySprite, lightFor, standTarget } from import { createMaterialTextures, createSpriteTexture } from './materialTextures' import { reportFailure } from '@/services/diagnostics' import { limitShadowUpdates, throwsOf, tuneShadowMaps } from './shadows' -import { cascadeSettingsFor, createCascadeShadows } from './csm' +import { cascadeSettingsFor, cascadesWanted, createCascadeShadows } from './csm' import { applyWireOverlay } from './sceneView' import './bvhPatches' import { isNegative } from '../csg/carve' @@ -116,7 +116,8 @@ export abstract class SceneRendererShadows extends SceneRendererModels { * once at construction; the caller is what keeps that to the passes where one of them moved. */ protected syncCascades(): void { - const wanted = this.view.csm && this.view.shadows && this.viewport.gl !== null + const wanted = + this.viewport.gl !== null && cascadesWanted(this.view, this.viewport.driver.engine) this.cascades?.release() this.cascades = wanted ? createCascadeShadows(this.viewport.scene, cascadeSettingsFor(this.view), () => diff --git a/src/renderer/src/engines/scene/csm.test.ts b/src/renderer/src/engines/scene/csm.test.ts index e249f60d4..5ccc0d6a2 100644 --- a/src/renderer/src/engines/scene/csm.test.ts +++ b/src/renderer/src/engines/scene/csm.test.ts @@ -9,7 +9,7 @@ import { } from 'three' import { describe, expect, it, vi } from 'vitest' import { DEFAULT_RENDER_POLICY } from '@shared/domain/renderPolicy' -import { cascadeSettingsFor, createCascadeShadows } from './csm' +import { cascadeSettingsFor, cascadesWanted, createCascadeShadows } from './csm' const settings = cascadeSettingsFor(DEFAULT_RENDER_POLICY) @@ -35,6 +35,24 @@ describe('what a policy buys in cascades', () => { }) }) +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() diff --git a/src/renderer/src/engines/scene/csm.ts b/src/renderer/src/engines/scene/csm.ts index 82b43f7b2..555b4c853 100644 --- a/src/renderer/src/engines/scene/csm.ts +++ b/src/renderer/src/engines/scene/csm.ts @@ -17,6 +17,7 @@ import { 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' @@ -33,6 +34,25 @@ export type CascadeSettings = { cascades: number; mapSize: number; maxFar: numbe */ 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. @@ -137,9 +157,14 @@ export function createCascadeShadows( follow: camera => { csm.camera = camera - // The PROJECTION and not the camera's identity: a quad layout hands four objects sharing - // one lens, and identity refitted for each of them — `updateFrustums` walks every dressed - // material, so that was the scene's material count, four times a frame, for nothing. + // 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() diff --git a/src/renderer/src/engines/viewport/ViewportFrame.ts b/src/renderer/src/engines/viewport/ViewportFrame.ts index a842321e0..09f0ce97a 100644 --- a/src/renderer/src/engines/viewport/ViewportFrame.ts +++ b/src/renderer/src/engines/viewport/ViewportFrame.ts @@ -145,14 +145,6 @@ export class ViewportFrame extends ViewportInset { private renderOverlay(renderer: StudioRenderer): void { const overlay = this.options.onOverlay - // `ViewHelper` is declared against a `WebGLRenderer` and the Advanced engine draws no - // overlay yet: skipped there rather than cast into a renderer three never typed it for. - if (!overlay || !('capabilities' in renderer)) 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/ViewportSurface.ts b/src/renderer/src/engines/viewport/ViewportSurface.ts index 25f8abd04..0047d7e76 100644 --- a/src/renderer/src/engines/viewport/ViewportSurface.ts +++ b/src/renderer/src/engines/viewport/ViewportSurface.ts @@ -57,14 +57,15 @@ export abstract class ViewportSurface extends ViewportMounting { */ 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' && !loadedGpuModule()) void loadGpuModule() + if (wanted === 'gpu' && !held) void loadGpuModule() const mounted = mountRenderer( { canvas, alpha: this.output.alpha ?? false }, wanted, - loadedGpuModule() !== null, + held !== null, error => traceFailure('render.fallback', wanted, error), ) const { renderer, driver } = mounted diff --git a/src/renderer/src/game/webRender.ts b/src/renderer/src/game/webRender.ts index 4954dba27..47e938ea0 100644 --- a/src/renderer/src/game/webRender.ts +++ b/src/renderer/src/game/webRender.ts @@ -20,7 +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, createCascadeShadows, type CascadeShadows } from '@/engines/scene/csm' +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' @@ -64,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. See the C6 report. */ export function createWebRender( canvas: HTMLCanvasElement, @@ -348,7 +358,9 @@ function cascadesFor( policy: RenderPolicy, onStale: () => void, ): CascadeShadows | null { - if (!policy.csm || !policy.shadows) return null + // `'gl'` in hand rather than `policy.engine`: what draws here is the renderer built above, + // which is a WebGL one whatever the manifest asks for. + if (!cascadesWanted(policy, 'gl')) return null const cascades = createCascadeShadows(scene, cascadeSettingsFor(policy), onStale) cascades.dress(scene) return cascades From deaa4e201d3e546f63ab667e333958be1bcd7584 Mon Sep 17 00:00:00 2001 From: Alban Pasquelin Date: Fri, 11 Sep 2026 10:28:39 +0200 Subject: [PATCH 10/13] =?UTF-8?q?Ne=20r=C3=A9=C3=A9crit=20la=20cha=C3=AEne?= =?UTF-8?q?=20d'une=20surface=20que=20lorsqu'elle=20a=20chang=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La map unique du commit précédent réécrivait son entrée à chaque image : en régime établi, `{ shape, chain }` reconstruit était identique à celui qu'il remplaçait. La réponse se donne maintenant avant toute écriture — un objet et un `Map.set` de moins par surface et par image, sur le chemin à 60 Hz. Constat de la revue d'efficacité, introduit par la simplification elle-même. --- src/renderer/src/engines/render/gpuComposer.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/engines/render/gpuComposer.ts b/src/renderer/src/engines/render/gpuComposer.ts index 8ab899b14..0ab06d2aa 100644 --- a/src/renderer/src/engines/render/gpuComposer.ts +++ b/src/renderer/src/engines/render/gpuComposer.ts @@ -113,8 +113,12 @@ export function createGpuComposer(gpu: GpuModule, renderer: WebGPURenderer): Sce */ const chainFor = (job: ComposerJob, effects: readonly PostEffect[], shape: string): GpuChain => { const held = chains.get(job.surface) - if (held && (held.shape !== shape || held.chain.camera !== job.camera)) free(job.surface) - const chain = chains.get(job.surface)?.chain ?? build(gpu, renderer, job, effects) + // 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 } From 6934fa02062a8a4db1ecebca2a985c00918fe094 Mon Sep 17 00:00:00 2001 From: Alban Pasquelin Date: Fri, 11 Sep 2026 12:27:37 +0200 Subject: [PATCH 11/13] =?UTF-8?q?Fait=20dire=20au=20jeu=20export=C3=A9=20q?= =?UTF-8?q?u'il=20ne=20dessine=20pas=20avec=20le=20moteur=20de=20son=20aut?= =?UTF-8?q?eur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `policy.engine` voyage dans le manifeste et `webRender` ne le lit nulle part : un jeu fait sur le moteur Avancé joue en WebGL. Le champ était rempli par `gameExportCompiler`, relu par `readRenderPolicy`, et ignoré en silence. L'honorer demande de porter le bundle de nœuds dans une page exportée — un chantier à part. Ce qui pouvait être corrigé ici l'est : le jeu le DIT au chargement, par le port de journal que `webRender` ouvre déjà pour la chaîne d'effets qui ne bâtit pas, et sur la même doctrine — un jeu qui joue sans ce que son auteur a demandé le dit au lieu de jouer quand même. Le rapport note au passage un critère d'acceptation de l'étape 2 qui n'a jamais été mesuré : « un projet `'gl'` est bit-identique à avant ce chantier ». Aucun banc du dépôt ne compare une révision à la précédente ; `engines:parity` compare les deux moteurs entre eux. `pnpm validate` verte en 167 s. --- docs/fr/audits/moteur-rendu-c6/RAPPORT.md | 16 +++++++++++----- src/renderer/src/game/webRender.test.ts | 15 +++++++++++++-- src/renderer/src/game/webRender.ts | 20 ++++++++++++++++++-- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/docs/fr/audits/moteur-rendu-c6/RAPPORT.md b/docs/fr/audits/moteur-rendu-c6/RAPPORT.md index c7c618547..89bb9e1a2 100644 --- a/docs/fr/audits/moteur-rendu-c6/RAPPORT.md +++ b/docs/fr/audits/moteur-rendu-c6/RAPPORT.md @@ -15,7 +15,7 @@ inchangés. | É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 | `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. | +| 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. | @@ -437,8 +437,13 @@ pré-remplir le champ ; son aide le dit, dans les quinze langues. 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. Écrit dans `webRender.ts` et -repris dans « Ce qui reste ouvert ». +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. 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 @@ -525,8 +530,9 @@ que la chaîne écarte ensuite. Le repli est au journal ; cette liste dit ce que 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` (écrit là ; la correction demande de porter le bundle de nœuds dans une page - exportée, ce qui est un chantier à part). § 4.6. + `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 diff --git a/src/renderer/src/game/webRender.test.ts b/src/renderer/src/game/webRender.test.ts index 58b417f1e..5736dad3d 100644 --- a/src/renderer/src/game/webRender.test.ts +++ b/src/renderer/src/game/webRender.test.ts @@ -76,13 +76,14 @@ 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. */ @@ -135,6 +136,16 @@ describe('what an exported game pays for an image', () => { ) }) + // 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 47e938ea0..095c61c26 100644 --- a/src/renderer/src/game/webRender.ts +++ b/src/renderer/src/game/webRender.ts @@ -73,7 +73,7 @@ const NEAR = 0.1 * 🛑 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. See the C6 report. + * bundle into an exported page. Said out loud rather than silently: see `policyOf`. */ export function createWebRender( canvas: HTMLCanvasElement, @@ -83,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) @@ -348,6 +348,22 @@ 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 From 4659e2869e86572ea5a53282666ca8f53bd33b32 Mon Sep 17 00:00:00 2001 From: Alban Pasquelin Date: Fri, 11 Sep 2026 12:48:24 +0200 Subject: [PATCH 12/13] =?UTF-8?q?Applique=20la=20revue=20:=20le=20jeu=20b?= =?UTF-8?q?=C3=A2tissait=20les=20cascades=20que=20l'=C3=A9diteur=20venait?= =?UTF-8?q?=20de=20refuser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six constats de `/code-review`, tous retenus. Le seul qui coûtait une image : en passant `'gl'` en dur à `cascadesWanted`, le port de jeu bâtissait les cascades d'un document Avancé pendant que l'éditeur les lui refusait — même scène éclairée de deux façons, ce que le commentaire d'`exportRequestOf` désigne comme l'accident à ne pas avoir. C'est le moteur du DOCUMENT qui décide des deux côtés. Le refus était par ailleurs muet : « Ombres en cascade » reste une préférence offerte, et sur un document Avancé elle ne changeait plus rien sans le dire. Elle le dit maintenant au journal, par la même trace que le repli de moteur, dans les quinze langues. Quatre suivis de texte : un test de `gpuPostQuality` devenu tautologique après la suppression de `gpuSamplesOf` (il n'assertait plus que ce que son voisin assertait déjà), deux références du rapport fausses depuis leur propre commit (`sayEngineIgnored` renommé `policyOf`, « cinq cas » devenus quatre), et `run-engine-parity.mjs` qui gardait sur ses plafonds l'affirmation que le rapport venait de corriger. `pnpm validate` verte en 185 s, 0 lien caché. --- docs/fr/audits/moteur-rendu-c6/RAPPORT.md | 4 ++-- scripts/run-engine-parity.mjs | 10 ++++++---- .../src/engines/render/gpuPostQuality.test.ts | 11 +---------- .../src/engines/scene/SceneRendererShadows.ts | 13 ++++++++++--- src/renderer/src/game/webRender.ts | 7 ++++--- src/shared/i18n/ar/diagnostics.json | 1 + src/shared/i18n/de/diagnostics.json | 1 + src/shared/i18n/en/diagnostics.json | 1 + src/shared/i18n/es/diagnostics.json | 1 + src/shared/i18n/fr/diagnostics.json | 1 + src/shared/i18n/hi/diagnostics.json | 1 + src/shared/i18n/id/diagnostics.json | 1 + src/shared/i18n/it/diagnostics.json | 1 + src/shared/i18n/ja/diagnostics.json | 1 + src/shared/i18n/ko/diagnostics.json | 1 + src/shared/i18n/pt/diagnostics.json | 1 + src/shared/i18n/ru/diagnostics.json | 1 + src/shared/i18n/tr/diagnostics.json | 1 + src/shared/i18n/vi/diagnostics.json | 1 + src/shared/i18n/zh/diagnostics.json | 1 + 20 files changed, 38 insertions(+), 22 deletions(-) diff --git a/docs/fr/audits/moteur-rendu-c6/RAPPORT.md b/docs/fr/audits/moteur-rendu-c6/RAPPORT.md index 89bb9e1a2..86ec3264a 100644 --- a/docs/fr/audits/moteur-rendu-c6/RAPPORT.md +++ b/docs/fr/audits/moteur-rendu-c6/RAPPORT.md @@ -154,7 +154,7 @@ panneau qui se plaint serait la spécification d'une machine présentée comme u 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 ; cinq cas de repli par `mountRenderer.test.ts`. +`requestAdapter` qui lève ; quatre cas de repli par `mountRenderer.test.ts`. ### Écart assumé : où vit le sélecteur @@ -441,7 +441,7 @@ 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. Même doctrine — un jeu qui +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. diff --git a/scripts/run-engine-parity.mjs b/scripts/run-engine-parity.mjs index 65ee43163..87638d03d 100644 --- a/scripts/run-engine-parity.mjs +++ b/scripts/run-engine-parity.mjs @@ -52,10 +52,12 @@ if (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, plafonds MESURÉS le 11 septembre 2026 sur cette - * machine puis arrondis vers le haut — jamais des cibles théoriques. Le cas `material` n'en a - * pas : 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. + * 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 % diff --git a/src/renderer/src/engines/render/gpuPostQuality.test.ts b/src/renderer/src/engines/render/gpuPostQuality.test.ts index 8402e4a87..2640f0996 100644 --- a/src/renderer/src/engines/render/gpuPostQuality.test.ts +++ b/src/renderer/src/engines/render/gpuPostQuality.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { VIEWPORT_QUALITIES } from '@shared/domain/scene' -import { budgetFor, samplesOf } from '../postfx/postQuality' +import { budgetFor } from '../postfx/postQuality' import { gpuBudgetFor } from './gpuPostQuality' describe('what the Advanced chain is allowed to spend', () => { @@ -15,13 +15,4 @@ describe('what the Advanced chain is allowed to spend', () => { expect(gpu.samples).toBe(gl.samples) } }) - - it('brings a count down exactly as the Compatible chain does', () => { - for (const quality of VIEWPORT_QUALITIES) { - const asked = 16 - expect(samplesOf(asked, gpuBudgetFor('high', quality))).toBe( - samplesOf(asked, budgetFor('high', quality)), - ) - } - }) }) diff --git a/src/renderer/src/engines/scene/SceneRendererShadows.ts b/src/renderer/src/engines/scene/SceneRendererShadows.ts index f15735544..b2c2ead40 100644 --- a/src/renderer/src/engines/scene/SceneRendererShadows.ts +++ b/src/renderer/src/engines/scene/SceneRendererShadows.ts @@ -17,7 +17,8 @@ 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' @@ -116,8 +117,14 @@ export abstract class SceneRendererShadows extends SceneRendererModels { * once at construction; the caller is what keeps that to the passes where one of them moved. */ protected syncCascades(): void { - const wanted = - this.viewport.gl !== null && cascadesWanted(this.view, this.viewport.driver.engine) + 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), () => diff --git a/src/renderer/src/game/webRender.ts b/src/renderer/src/game/webRender.ts index 095c61c26..6b7dd0873 100644 --- a/src/renderer/src/game/webRender.ts +++ b/src/renderer/src/game/webRender.ts @@ -374,9 +374,10 @@ function cascadesFor( policy: RenderPolicy, onStale: () => void, ): CascadeShadows | null { - // `'gl'` in hand rather than `policy.engine`: what draws here is the renderer built above, - // which is a WebGL one whatever the manifest asks for. - if (!cascadesWanted(policy, 'gl')) return 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 diff --git a/src/shared/i18n/ar/diagnostics.json b/src/shared/i18n/ar/diagnostics.json index 63a77b657..d63302285 100644 --- a/src/shared/i18n/ar/diagnostics.json +++ b/src/shared/i18n/ar/diagnostics.json @@ -18,6 +18,7 @@ "shaderAnchorMissing": "لم يعد محرّك الرسم يوفر {{name}}", "renderEngineUnavailable": "محرّك «متقدّم» لم يُبنَ بعد", "renderEngineGradingMissing": "محرّك «متقدّم» يعرض سماءً مصحَّحة كما يحتويها ملفها", + "renderEngineCascadesMissing": "محرّك «متقدّم» لا يرسم ظلالًا متدرّجة", "channelShaderMissing": "لا يوجد مظلّل يشتق القناة {{channel}}", "channelSourceEmpty": "مصدر القناة {{channel}} لا يحتوي على أي بكسل", "passSourceMissing": "تحتاج مرحلة الرسم إلى مصدر تقرأ منه", diff --git a/src/shared/i18n/de/diagnostics.json b/src/shared/i18n/de/diagnostics.json index 66c653843..8ffb79ab3 100644 --- a/src/shared/i18n/de/diagnostics.json +++ b/src/shared/i18n/de/diagnostics.json @@ -18,6 +18,7 @@ "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/en/diagnostics.json b/src/shared/i18n/en/diagnostics.json index f13ec9029..6c034e925 100644 --- a/src/shared/i18n/en/diagnostics.json +++ b/src/shared/i18n/en/diagnostics.json @@ -18,6 +18,7 @@ "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/es/diagnostics.json b/src/shared/i18n/es/diagnostics.json index 6fa08c958..91e95124e 100644 --- a/src/shared/i18n/es/diagnostics.json +++ b/src/shared/i18n/es/diagnostics.json @@ -18,6 +18,7 @@ "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/fr/diagnostics.json b/src/shared/i18n/fr/diagnostics.json index 64f4f1e21..46a57533e 100644 --- a/src/shared/i18n/fr/diagnostics.json +++ b/src/shared/i18n/fr/diagnostics.json @@ -18,6 +18,7 @@ "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/hi/diagnostics.json b/src/shared/i18n/hi/diagnostics.json index 1118abdd2..3675c3a8f 100644 --- a/src/shared/i18n/hi/diagnostics.json +++ b/src/shared/i18n/hi/diagnostics.json @@ -18,6 +18,7 @@ "shaderAnchorMissing": "रेंडरर अब {{name}} नहीं देता", "renderEngineUnavailable": "उन्नत इंजन अभी बना नहीं है", "renderEngineGradingMissing": "उन्नत इंजन सुधारे हुए आकाश को उसकी फ़ाइल में जैसा है वैसा ही दिखाता है", + "renderEngineCascadesMissing": "उन्नत इंजन कैस्केड छायाएँ नहीं बनाता", "channelShaderMissing": "कोई शेडर {{channel}} चैनल नहीं निकालता", "channelSourceEmpty": "{{channel}} चैनल के स्रोत में कोई पिक्सेल नहीं है", "passSourceMissing": "इस पास को पढ़ने के लिए एक स्रोत चाहिए", diff --git a/src/shared/i18n/id/diagnostics.json b/src/shared/i18n/id/diagnostics.json index 86f3e1b49..3e3bb323d 100644 --- a/src/shared/i18n/id/diagnostics.json +++ b/src/shared/i18n/id/diagnostics.json @@ -18,6 +18,7 @@ "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/it/diagnostics.json b/src/shared/i18n/it/diagnostics.json index caeb116c8..8cabb4a09 100644 --- a/src/shared/i18n/it/diagnostics.json +++ b/src/shared/i18n/it/diagnostics.json @@ -18,6 +18,7 @@ "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/ja/diagnostics.json b/src/shared/i18n/ja/diagnostics.json index f1e81acba..4c9a038b7 100644 --- a/src/shared/i18n/ja/diagnostics.json +++ b/src/shared/i18n/ja/diagnostics.json @@ -18,6 +18,7 @@ "shaderAnchorMissing": "レンダラーは{{name}}を提供しなくなりました", "renderEngineUnavailable": "「上級」エンジンはまだ実装されていません", "renderEngineGradingMissing": "「上級」エンジンは、補正した空をファイルのままの状態で表示します", + "renderEngineCascadesMissing": "「上級」エンジンはカスケードシャドウを描きません", "channelShaderMissing": "{{channel}}チャンネルを導き出せるシェーダーがありません", "channelSourceEmpty": "{{channel}}チャンネルのソースにピクセルがありません", "passSourceMissing": "パスには読み取るソースが必要です", diff --git a/src/shared/i18n/ko/diagnostics.json b/src/shared/i18n/ko/diagnostics.json index e72a9c2ca..8103e61f4 100644 --- a/src/shared/i18n/ko/diagnostics.json +++ b/src/shared/i18n/ko/diagnostics.json @@ -18,6 +18,7 @@ "shaderAnchorMissing": "렌더러가 {{name}} 앵커를 더 이상 제공하지 않습니다", "renderEngineUnavailable": "고급 엔진은 아직 만들어지지 않았습니다", "renderEngineGradingMissing": "고급 엔진은 보정한 하늘을 파일에 담긴 그대로 보여 줍니다", + "renderEngineCascadesMissing": "고급 엔진은 캐스케이드 그림자를 그리지 않습니다", "channelShaderMissing": "{{channel}} 채널을 이끌어 내는 셰이더가 없습니다", "channelSourceEmpty": "{{channel}} 채널의 소스에 픽셀이 없습니다", "passSourceMissing": "패스에는 읽을 소스가 필요합니다", diff --git a/src/shared/i18n/pt/diagnostics.json b/src/shared/i18n/pt/diagnostics.json index 70ec045b4..d2951e4c0 100644 --- a/src/shared/i18n/pt/diagnostics.json +++ b/src/shared/i18n/pt/diagnostics.json @@ -18,6 +18,7 @@ "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/ru/diagnostics.json b/src/shared/i18n/ru/diagnostics.json index d6c8b4de9..2ebfe1266 100644 --- a/src/shared/i18n/ru/diagnostics.json +++ b/src/shared/i18n/ru/diagnostics.json @@ -18,6 +18,7 @@ "shaderAnchorMissing": "Движок рендеринга больше не предоставляет {{name}}", "renderEngineUnavailable": "Продвинутый движок ещё не собран", "renderEngineGradingMissing": "Продвинутый движок показывает откорректированное небо таким, каким оно лежит в файле", + "renderEngineCascadesMissing": "Продвинутый движок не рисует каскадные тени", "channelShaderMissing": "ни один шейдер не выводит {{channel}}", "channelSourceEmpty": "в источнике канала {{channel}} нет пикселей", "passSourceMissing": "проходу нужен источник для чтения", diff --git a/src/shared/i18n/tr/diagnostics.json b/src/shared/i18n/tr/diagnostics.json index 1825bfd6f..ae6f1b794 100644 --- a/src/shared/i18n/tr/diagnostics.json +++ b/src/shared/i18n/tr/diagnostics.json @@ -18,6 +18,7 @@ "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/vi/diagnostics.json b/src/shared/i18n/vi/diagnostics.json index d93c6c352..91f096cc0 100644 --- a/src/shared/i18n/vi/diagnostics.json +++ b/src/shared/i18n/vi/diagnostics.json @@ -18,6 +18,7 @@ "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/zh/diagnostics.json b/src/shared/i18n/zh/diagnostics.json index a9f05f0d0..8ba06403c 100644 --- a/src/shared/i18n/zh/diagnostics.json +++ b/src/shared/i18n/zh/diagnostics.json @@ -18,6 +18,7 @@ "shaderAnchorMissing": "渲染器不再提供 {{name}}", "renderEngineUnavailable": "高级引擎尚未构建", "renderEngineGradingMissing": "高级引擎按文件里的原样显示已校正的天空", + "renderEngineCascadesMissing": "高级引擎不绘制级联阴影", "channelShaderMissing": "没有着色器能推导出 {{channel}} 通道", "channelSourceEmpty": "{{channel}} 通道的来源不含任何像素", "passSourceMissing": "这一道处理需要一个可读取的来源", From a5dcc2aab51f87f52ec0cdc7485621643f200de0 Mon Sep 17 00:00:00 2001 From: Alban Pasquelin Date: Wed, 23 Sep 2026 22:38:29 +0200 Subject: [PATCH 13/13] =?UTF-8?q?Mesure=20la=20fr=C3=A9quentation=20de=20w?= =?UTF-8?q?ww.aidesktopstudio.com,=20avec=20consentement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une ligne dans le gabarit, donc dans les quinze langues à la fois : le bandeau de choix et le chargement de Google Analytics vivent dans un seul fichier partagé, servi par www.pasquelin.com et commun aux quatre sites. Une correction s'y fait une fois et non quatre ; son code et ses tests sont dans le dépôt `pasquelin/site`, sous `public/shared/`. Rien n'est demandé à Google avant un accord explicite du visiteur, et le panneau emprunte les couleurs, la police et les arrondis de cette page plutôt que d'imposer les siens. Propriété GA4 : G-YRFNHZLMJH — un identifiant de mesure n'est pas un secret, toute page qui le porte le montre à qui lit sa source. --- site/template.html | 8 ++++++++ 1 file changed, 8 insertions(+) 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); + + +