Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3169331
Ajoute les cascades, l'anisotropie maximale et la courbe AgX aux nouv…
pasquelin Sep 10, 2026
a706a8b
Fait passer le rendu derrière un driver et ouvre le choix Compatible …
pasquelin Sep 10, 2026
b66cd96
Passe la porte : split du catalogue, cycle d'import cassé, gardes rem…
pasquelin Sep 10, 2026
bf8c100
Applique la revue adverse : les cascades ne sur-éclairaient plus qu'e…
pasquelin Sep 10, 2026
0b1f82c
Fait dessiner le moteur Avancé : WebGPU, patch matériau TSL et GTAO e…
pasquelin Sep 10, 2026
851d3d9
Applique les deux revues de l'étape 3 : la capture GPU sortait cisail…
pasquelin Sep 10, 2026
d75cbb8
Compare les deux moteurs image par image, et corrige ce que ça révèle
pasquelin Sep 11, 2026
5248fb7
Fusionne feat/render-engine : compare les deux moteurs image par imag…
pasquelin Sep 11, 2026
ee10965
Type le moteur du test plutôt que de le figer avec `as const`
pasquelin Sep 11, 2026
57c89f1
Fusionne fix/engine-as-const : type le moteur du test plutôt que de l…
pasquelin Sep 11, 2026
8da7808
Refuse les cascades au moteur qui ne les dessine pas, et rend au rapp…
pasquelin Sep 11, 2026
deaa4e2
Ne réécrit la chaîne d'une surface que lorsqu'elle a changé
pasquelin Sep 11, 2026
6934fa0
Fait dire au jeu exporté qu'il ne dessine pas avec le moteur de son a…
pasquelin Sep 11, 2026
4659e28
Applique la revue : le jeu bâtissait les cascades que l'éditeur venai…
pasquelin Sep 11, 2026
e69a84b
Fusionne feat/c6-relecture : applique la revue : le jeu bâtissait les…
pasquelin Sep 11, 2026
a5dcc2a
Mesure la fréquentation de www.aidesktopstudio.com, avec consentement
pasquelin Sep 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
558 changes: 558 additions & 0 deletions docs/fr/audits/moteur-rendu-c6/RAPPORT.md

Large diffs are not rendered by default.

Binary file added docs/fr/audits/moteur-rendu-c6/capture-avance.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/fr/audits/moteur-rendu-c6/film-avance.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
52 changes: 51 additions & 1 deletion llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,11 @@ Generate and edit images, videos, 3D models, audio, textures and skyboxes — in
</div>

<div align="center">
<img src="site/assets/images/studio-3d.png" alt="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" width="900">
<img src="site/assets/images/studio-3d.png" alt="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" width="900">
</div>

<div align="center">
<img src="site/assets/images/studio-scene.webp" alt="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" width="900">
</div>

---
Expand Down Expand Up @@ -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.

<table>
<tr>
<td width="50%" valign="top">
<b>Home</b><br>
<sub>The project shelf, the tools, and what your models already cover.</sub><br><br>
<img src="site/assets/images/studio-accueil.webp" alt="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" width="430">
</td>
<td width="50%" valign="top">
<b>Image</b><br>
<sub>A layered canvas, with document, layer and transform in the inspector.</sub><br><br>
<img src="site/assets/images/studio-image.webp" alt="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" width="430">
</td>
</tr>
<tr>
<td width="50%" valign="top">
<b>Video</b><br>
<sub>Source and programme viewers, over a timeline that decodes for real.</sub><br><br>
<img src="site/assets/images/studio-video.webp" alt="The Video workspace: source and programme viewers above a video and audio timeline, with a text-to-video generation form on the left" width="430">
</td>
<td width="50%" valign="top">
<b>Code</b><br>
<sub>Behaviour written script by script, and rewritten in place by a model.</sub><br><br>
<img src="site/assets/images/studio-code.webp" alt="The Code workspace: a TypeScript player script open in the editor, and a code-rewrite generation panel on the left targeting that same file" width="430">
</td>
</tr>
<tr>
<td width="50%" valign="top">
<b>Audio</b><br>
<sub>A spectrum, a clip editor and a multitrack montage, working on samples.</sub><br><br>
<img src="site/assets/images/studio-audio.webp" alt="The Audio workspace: a frequency spectrum above a clip editor, and three audio tracks on the timeline below" width="430">
</td>
<td width="50%" valign="top">
<b>Skyboxes</b><br>
<sub>A panorama, the sun that goes with it, and test objects lit on the spot.</sub><br><br>
<img src="site/assets/images/studio-skyboxes.webp" alt="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" width="430">
</td>
</tr>
</table>

---

## 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
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"start:debug": "node scripts/fetch-engine.mjs --sources-only && node scripts/dev-app-identity.mjs && electron-vite dev --watch --remoteDebuggingPort 9222",
"cdp": "node scripts/cdp.mjs",
"world:validate": "node scripts/run-world-safe-validation.mjs",
"engines:bench": "node scripts/run-engine-benchmark.mjs",
"engines:parity": "node scripts/run-engine-parity.mjs",
"picking:validate": "electron out/main/pickingValidation.js",
"build": "pnpm typecheck && electron-vite build && pnpm game:runtime && node scripts/check-artefact.mjs",
"game:runtime": "vite build --config config/vite.game.config.ts",
Expand Down
5 changes: 4 additions & 1 deletion scripts/banc/scenariosRestAnimation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
19 changes: 19 additions & 0 deletions scripts/cdp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,25 @@ async function rendererTarget() {
return page.webSocketDebuggerUrl
}

/**
* Charge un harnais du renderer et appelle ce qu'il a posé sur `window`.
*
* Trois scripts en avaient chacun leur copie : un harnais vit dans le renderer parce que ni les
* moteurs ni les mondes ne se mesurent sous node, et le seul moyen de l'atteindre est l'import
* dynamique que le serveur de dev sert, suivi de la lecture du handle.
*/
export async function harness(modulePath, { handle, timeout = 30_000 }) {
return await evaluate(
`(async () => {
await import(${JSON.stringify(modulePath)})
const run = Reflect.get(window, ${JSON.stringify(handle)})
if (typeof run !== 'function') throw new Error('harnais absent : ${handle}')
return await run()
})()`,
{ timeout },
)
}

export async function evaluate(expression, { timeout = 30_000 } = {}) {
const socket = new WebSocket(await rendererTarget())
await new Promise((resolve, reject) => {
Expand Down
28 changes: 28 additions & 0 deletions scripts/run-engine-benchmark.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { harness } from './cdp.mjs'

// Le harnais vit dans le renderer : les deux moteurs ont besoin d'un vrai périphérique, et une
// mesure prise sous node parlerait du chargement des modules, pas d'une image.
const result = await harness('/src/engines/render/engineBenchmark.browser.ts', {
handle: '__iaBenchmarkRenderEngines',
timeout: 300_000,
})

console.log(JSON.stringify(result, null, 2))

if (!Array.isArray(result) || result.length === 0) {
throw new Error('le banc n’a mesuré aucun profil')
}

// Le moteur Avancé peut être indisponible — c'est un résultat, pas un échec. Ce qui serait une
// panne, c'est un profil dont AUCUNE colonne n'a de chiffre.
const silent = result.filter(entry => entry.measures.every(measure => measure.submitMs === null))
if (silent.length > 0) {
throw new Error(`aucun moteur n’a dessiné : ${silent.map(entry => entry.profile).join(', ')}`)
}

const fellBack = result.flatMap(entry =>
entry.measures
.filter(measure => measure.engine !== measure.drawnWith)
.map(measure => `${entry.profile} : ${measure.engine} a été dessiné en ${measure.drawnWith}`),
)
if (fellBack.length > 0) console.log(`\nReplis :\n${fellBack.join('\n')}`)
90 changes: 90 additions & 0 deletions scripts/run-engine-parity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Buffer } from 'node:buffer'
import { mkdirSync, writeFileSync } from 'node:fs'
import { evaluate, harness } from './cdp.mjs'

// Le harnais vit dans le renderer : les deux moteurs ont besoin d'un vrai périphérique, et une
// comparaison faite sous node parlerait du chargement des modules, pas d'une image.
const result = await harness('/src/engines/render/engineParity.browser.ts', {
handle: '__iaCompareRenderEngines',
timeout: 300_000,
})

console.log(JSON.stringify(result, null, 2))

if (!Array.isArray(result) || result.length === 0) {
throw new Error('la parité n’a comparé aucun cas')
}

// Le moteur Avancé peut être indisponible — c'est un résultat, pas un échec. Ce qui serait une
// panne, c'est un cas où l'un des deux côtés n'a rien dessiné du tout.
const blank = result.flatMap(entry =>
entry.failed
? []
: ['gl', 'gpu']
.filter(engine => !entry.drew?.[engine])
.map(engine => `${entry.case} : le côté ${engine} n’a dessiné qu’une couleur`),
)

const fellBack = result.filter(entry => entry.drawnWith?.gpu === 'gl')
if (fellBack.length > 0) {
console.log(
`\nRepli : l'Avancé a été dessiné en Compatible sur ${fellBack.map(one => one.case).join(', ')}` +
` — sur cette machine la comparaison ne compare rien.`,
)
}

// Les deux images de chaque cas, parce qu'un taux n'est pas un diagnostic : un côté qui n'a rien
// dessiné, une image retournée et une image simplement plus sombre donnent le même nombre. La
// capture d'export que le critère d'acceptation demande est l'une d'elles, `still-gpu.png`.
const frames = process.env.PARITY_FRAMES_DIR
if (frames) {
const held = await evaluate(`Reflect.get(window, '__iaEngineParityFrames') ?? null`)
if (!held) throw new Error('aucune image n’a été retenue')
mkdirSync(frames, { recursive: true })
for (const [name, sides] of Object.entries(held)) {
for (const [engine, bytes] of Object.entries(sides)) {
writeFileSync(`${frames}/${name}-${engine}.png`, Buffer.from(bytes))
}
}
console.log(`\nImages écrites dans ${frames}`)
}

if (blank.length > 0) throw new Error(`un côté n’a rien dessiné —\n${blank.join('\n')}`)

/**
* Ce qu'un cas a le droit de faire bouger. Ce sont des MARGES posées au-dessus des mesures du
* 11 septembre 2026 sur cette machine, et non ces mesures arrondies : elles gardent contre une
* image noire ou plate, pas contre une dérive de quelques pour cent — resserrer demanderait
* plusieurs exécutions sur plusieurs machines, et aucune n'a été faite. Le cas `material` n'a pas
* de plafond : l'écart y est CONNU et attendu (la cavité tombe sur la couleur diffuse côté
* nœuds, donc un métal diffère), il est rapporté et jamais transformé en réussite ou en échec.
*/
// `temporal` est haut, et c'est délibéré : cette ligne garde l'absence de la COULEUR PLATE qu'un
// nœud temporel donne sur une image unique — un retour en arrière la porterait à 100 %. Les 13,6 %
// mesurés sont un écart de TON entre le rendu droit du composeur et celui du viewport sans
// composeur, antérieur à ce lot et écrit dans le rapport.
const CEILINGS = { scene: 0.01, occlusion: 0.06, still: 0.02, film: 0.02, temporal: 0.2 }

const drifted = result.flatMap(entry => {
if (entry.failed) return []
const ceiling = CEILINGS[entry.case]
if (ceiling === undefined) return []
return entry.changedPixelRatio <= ceiling
? []
: [
`${entry.case} : ${(entry.changedPixelRatio * 100).toFixed(2)} % des pixels diffèrent,` +
` plafond ${(ceiling * 100).toFixed(2)} %`,
]
})

const failed = result.filter(entry => entry.failed)
if (failed.length > 0) {
throw new Error(
`des cas n’ont pas pu être comparés —\n${failed.map(one => `${one.case} : ${one.failed}`).join('\n')}`,
)
}

// Après les images : un écart se regarde avant de se discuter, et le répertoire est déjà écrit.
if (drifted.length > 0) {
throw new Error(`les deux moteurs ne dessinent plus la même chose —\n${drifted.join('\n')}`)
}
15 changes: 5 additions & 10 deletions scripts/run-world-safe-validation.mjs
Original file line number Diff line number Diff line change
@@ -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))

Expand Down
8 changes: 8 additions & 0 deletions site/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@
if (!r.classList.contains('fx-ready')) r.classList.remove('js');
}, 2500);
</script>
<!-- Mesure d'audience. Le bandeau de consentement et le chargement de Google Analytics
tiennent dans un seul fichier, servi par www.pasquelin.com et partagé par les quatre
sites : une correction s'y fait une fois, pas quatre. Rien n'est demandé à Google avant
un accord explicite du visiteur, et le panneau emprunte les couleurs de cette page.
L'identifiant de mesure n'est pas un secret — il est public dans toute page qui le
porte. Source, traductions et tests : dépôt `pasquelin/site`, `public/shared/`. -->
<script defer src="https://www.pasquelin.com/shared/consent.v1.js" data-ga="G-YRFNHZLMJH"></script>

</head>

<body>
Expand Down
2 changes: 2 additions & 0 deletions src/main/export/gameExport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,9 +410,11 @@ describe('a game written to run with no studio', () => {
it('writes the render policy it was handed', async () => {
const { ports, written } = writing()
const render = {
engine: 'gl',
shadows: true,
shadowQuality: 'soft',
shadowMapSize: 1024,
csm: false,
quality: 'performance',
fieldOfView: 50,
gridSize: 30,
Expand Down
10 changes: 8 additions & 2 deletions src/main/localizedErrors.i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
3 changes: 3 additions & 0 deletions src/main/settings/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
9 changes: 4 additions & 5 deletions src/renderer/src/engines/gpu/gpuPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -48,7 +48,7 @@ const PRECISION_TYPES: Record<TargetPrecision, TextureDataType> = {
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)
Expand All @@ -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()
}
}

Expand Down
25 changes: 17 additions & 8 deletions src/renderer/src/engines/material/MaterialRenderer.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GlDriverModule from '../render/glDriver'
import { PerspectiveCamera, RepeatWrapping, Vector3 } from 'three'
import { PBR_CHANNELS, type PbrChannel } from '@shared/domain/material'
import type { ViewportEnvironment } from '../viewport/environment'
import { fakeEnvironment, fakeTextureSource } from '../viewport/viewport-fixtures'
import { fakeEnvironment, fakeRenderer, fakeTextureSource } from '../viewport/viewport-fixtures'
import { ViewportEngine } from '../viewport/ViewportEngine'
import { MaterialRenderer } from './MaterialRenderer'
import { newMaterial, slotFor, type ChannelMap, type MaterialState } from './materialState'
Expand Down Expand Up @@ -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<typeof GlDriverModule>()
return {
glDriver: {
...actual.glDriver,
createEnvironment: () => {
environment = fakeEnvironment()
return environment
},
},
}
})

const skyOf = (assetId: string): MaterialState => {
const state = newMaterial()
Expand All @@ -62,7 +71,7 @@ beforeEach(() => {
source = fakeTextureSource()
vi.spyOn(ViewportEngine.prototype, 'mount').mockImplementation(() => {})
// The mocked environment never reads the renderer fields.
vi.spyOn(ViewportEngine.prototype, 'gl', 'get').mockReturnValue({} as never)
vi.spyOn(ViewportEngine.prototype, 'gl', 'get').mockReturnValue(fakeRenderer())
host = document.createElement('div')
})

Expand Down
Loading
Loading