canvasframework 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,781 +1,780 @@
1
1
  /**
2
- * Adaptateur WebGL pour le rendu de texte ultra-optimisé
3
- * Version améliorée avec optimisations supplémentaires
2
+ * WebGLCanvasAdapter Rendu de texte haute performance
3
+ *
4
+ * Architecture :
5
+ * - Formes / images → Canvas 2D standard (ctx)
6
+ * - Texte → WebGL Instanced Rendering
7
+ *
8
+ * Principe du instanced rendering :
9
+ * 1. Tous les glyphes sont rasterisés UNE fois dans un atlas de textures WebGL
10
+ * 2. À chaque frame, on envoie UN seul tableau de données (position, UV, couleur)
11
+ * vers le GPU via gl.drawArraysInstanced()
12
+ * 3. Un seul draw call GPU dessine les 500 glyphes simultanément
13
+ * 4. Aucune copie CPU↔GPU pendant le rendu
14
+ *
15
+ * Comparaison des approches :
16
+ * Ancien pipeline : Canvas→texture→WebGL→drawImage = 3 copies/glyphe × 500 = 1500 copies
17
+ * Canvas 2D atlas : drawImage sous-rectangle = 0 copie (mais 500 draw calls)
18
+ * Instanced WebGL : 1 draw call GPU total = 0 copie + 1 seul draw call ← cette implémentation
19
+ *
4
20
  * @class WebGLCanvasAdapter
5
21
  */
6
22
  class WebGLCanvasAdapter {
7
23
  constructor(canvasElement, options = {}) {
8
24
  this.canvas = canvasElement;
9
- this.dpr = options.dpr || window.devicePixelRatio || 1;
25
+ this.dpr = options.dpr || window.devicePixelRatio || 1;
10
26
 
11
- // Contexte 2D principal pour les formes
27
+ // ── Canvas 2D pour tout sauf le texte ──────────────────────────────
12
28
  this.ctx = this.canvas.getContext('2d', {
13
- alpha: options.alpha !== false,
14
- desynchronized: true,
15
- willReadFrequently: false
29
+ alpha: options.alpha !== false,
30
+ desynchronized: true,
31
+ willReadFrequently: false
16
32
  });
17
33
 
18
- // OPTIONS D'OPTIMISATION
19
- this.useTextAtlas = options.useTextAtlas !== false;
20
- this.enableCulling = options.enableCulling !== false;
21
- this.enableBatching = options.enableBatching !== false;
22
- this.useOffscreenCanvas = options.useOffscreenCanvas !== false && typeof OffscreenCanvas !== 'undefined';
23
-
24
- // WebGL pour le texte
25
- this._initWebGLTextRenderer();
26
-
27
- // Cache optimisé avec LRU
28
- this.textCache = new Map();
29
- this.charAtlas = new Map();
30
- this.maxTextCacheSize = options.maxCacheSize || 400;
31
- this.lruKeys = []; // NOUVEAU : Tracking LRU pour meilleur cache eviction
32
-
33
- // Text Atlas optimisé (utilise plusieurs atlas si nécessaire)
34
- this.atlases = [this._createAtlas()]; // ✅ NOUVEAU : Support multi-atlas
35
- this.currentAtlasIndex = 0;
36
-
37
- // Batch rendering optimisé
38
- this.textBatch = [];
39
- this.batchMode = false;
40
- this.maxBatchSize = options.maxBatchSize || 1000; // ✅ NOUVEAU : Limite batch size
41
-
42
- // ✅ NOUVEAU : Pré-calcul des métriques communes
43
- this.fontMetricsCache = new Map();
44
- this.baselineRatios = {
45
- 'alphabetic': 0.85,
46
- 'top': 1.0,
47
- 'middle': 0.65,
48
- 'bottom': 0,
49
- 'hanging': 0.9,
50
- 'ideographic': 0.1
51
- };
34
+ // ── Options ────────────────────────────────────────────────────────
35
+ this.enableCulling = options.enableCulling !== false;
36
+ this.enableBatching = options.enableBatching !== false;
37
+ this.maxTextCacheSize = options.maxCacheSize || 512; // glyphes dans l'atlas
38
+ this.maxBatchSize = options.maxBatchSize || 2048; // glyphes par frame
39
+ this.atlasSize = options.atlasSize || 2048; // px côté atlas texture
40
+
41
+ // ── WebGL ──────────────────────────────────────────────────────────
42
+ this._glReady = false;
43
+ try {
44
+ this._initWebGL();
45
+ this._glReady = true;
46
+ } catch (err) {
47
+ console.warn('[WebGLCanvasAdapter] WebGL indisponible, fallback Canvas 2D :', err.message);
48
+ }
52
49
 
53
- // États pour le texte
54
- this._currentFont = '16px sans-serif';
55
- this._currentFillStyle = '#000';
56
- this._currentTextAlign = 'start';
50
+ // ── Atlas de glyphes (CPU-side) ────────────────────────────────────
51
+ // Canvas hors-écran qui sert de source pour la texture GPU
52
+ this._atlasCanvas = typeof OffscreenCanvas !== 'undefined'
53
+ ? new OffscreenCanvas(this.atlasSize, this.atlasSize)
54
+ : Object.assign(document.createElement('canvas'), {
55
+ width: this.atlasSize, height: this.atlasSize
56
+ });
57
+ this._atlasCtx = this._atlasCanvas.getContext('2d', { alpha: true });
58
+
59
+ // Curseur de remplissage de l'atlas
60
+ this._atlasCursor = { x: 0, y: 0, rowH: 0 };
61
+ this._atlasGlyphs = new Map(); // key → { u0,v0,u1,v1, w,h, advance }
62
+ this._atlasDirty = false; // faut-il re-uploader la texture ?
63
+
64
+ // ── Cache LRU O(1) ────────────────────────────────────────────────
65
+ // Map JS = ordre d'insertion → delete+set = "remonter" une entrée
66
+ this._glyphCache = new Map(); // key → metadata (LRU)
67
+
68
+ // ── Batch CPU : tableaux pré-alloués pour le draw call ─────────────
69
+ // Chaque instance = 8 floats :
70
+ // [dstX, dstY, dstW, dstH, u0, v0, u1, v1, r, g, b, a] → 12 floats
71
+ this._FLOATS_PER_INSTANCE = 12;
72
+ this._instanceData = new Float32Array(this.maxBatchSize * this._FLOATS_PER_INSTANCE);
73
+ this._instanceCount = 0;
74
+ this._batchMode = false;
75
+
76
+ // ── État de rendu courant ──────────────────────────────────────────
77
+ this._currentFont = '16px sans-serif';
78
+ this._currentFillStyle = '#000000';
79
+ this._currentFillRGBA = [0, 0, 0, 1];
80
+ this._currentTextAlign = 'start';
57
81
  this._currentTextBaseline = 'alphabetic';
58
82
 
59
- // NOUVEAU : Pool d'objets pour réduire GC
60
- this.objectPool = {
61
- points: [],
62
- rects: [],
63
- maxPoolSize: 100
64
- };
83
+ // ── Métriques polices (cache) ──────────────────────────────────────
84
+ this._fontMetrics = new Map();
65
85
 
66
- // NOUVEAU : Viewport cache pour culling
67
- this.viewportBounds = {
68
- left: 0,
69
- right: this.canvas.width,
70
- top: 0,
71
- bottom: this.canvas.height
72
- };
86
+ // ── Viewport ──────────────────────────────────────────────────────
87
+ this._viewport = { l: 0, t: 0, r: this.canvas.width, b: this.canvas.height };
73
88
 
74
- // Stats
75
- this.stats = {
76
- cacheHits: 0,
77
- cacheMisses: 0,
78
- drawCalls: 0,
79
- culledTexts: 0,
80
- batchedDraws: 0,
81
- atlasCount: 1
82
- };
89
+ // ── Stats ─────────────────────────────────────────────────────────
90
+ this.stats = { drawCalls: 0, glyphsCached: 0, culled: 0, instancesDrawn: 0 };
83
91
 
84
- // NOUVEAU : Debounced cleanup
85
- this._cleanupScheduled = false;
86
- this._textCleanupInterval = setInterval(() => this._cleanOldCache(), 60000);
92
+ // Nettoyage périodique de l'atlas si saturation
93
+ this._cleanupTimer = setInterval(() => this._maybeRebuildAtlas(), 30000);
87
94
  }
88
95
 
89
- // ────────────────────────────────────────────────
90
- // NOUVEAU : Gestion multi-atlas
91
- // ────────────────────────────────────────────────
92
- _createAtlas() {
93
- const canvas = this.useOffscreenCanvas
94
- ? new OffscreenCanvas(2048, 2048)
95
- : document.createElement('canvas');
96
-
97
- if (!this.useOffscreenCanvas) {
98
- canvas.width = 2048;
99
- canvas.height = 2048;
100
- }
101
-
102
- const ctx = canvas.getContext('2d', { alpha: true, willReadFrequently: false });
103
-
104
- return {
105
- canvas,
106
- ctx,
107
- x: 0,
108
- y: 0,
109
- rowHeight: 0,
110
- usage: 0 // ✅ Track utilization
111
- };
112
- }
113
-
114
- // ────────────────────────────────────────────────
115
- // Initialisation WebGL
116
- // ────────────────────────────────────────────────
117
- _initWebGLTextRenderer() {
118
- this.textCanvas = this.useOffscreenCanvas
119
- ? new OffscreenCanvas(256, 256)
120
- : document.createElement('canvas');
121
-
122
- if (!this.useOffscreenCanvas) {
123
- this.textCanvas.width = 256;
124
- this.textCanvas.height = 256;
125
- }
126
-
127
- this.textCtx = this.textCanvas.getContext('2d', {
128
- alpha: true,
129
- willReadFrequently: false
96
+ // ══════════════════════════════════════════════════════════════════════
97
+ // INIT WebGL shaders + buffers instanciés
98
+ // ══════════════════════════════════════════════════════════════════════
99
+ _initWebGL() {
100
+ // Canvas WebGL dédié, jamais affiché (hors DOM)
101
+ this._glCanvas = typeof OffscreenCanvas !== 'undefined'
102
+ ? new OffscreenCanvas(this.canvas.width, this.canvas.height)
103
+ : Object.assign(document.createElement('canvas'), {
104
+ width: this.canvas.width, height: this.canvas.height
105
+ });
106
+
107
+ const gl = this._glCanvas.getContext('webgl2', {
108
+ alpha: true,
109
+ premultipliedAlpha: false,
110
+ antialias: false,
111
+ preserveDrawingBuffer: true, // nécessaire pour drawImage final
112
+ powerPreference: 'high-performance'
130
113
  });
131
114
 
132
- this.glCanvas = this.useOffscreenCanvas
133
- ? new OffscreenCanvas(256, 256)
134
- : document.createElement('canvas');
135
-
136
- if (!this.useOffscreenCanvas) {
137
- this.glCanvas.width = 256;
138
- this.glCanvas.height = 256;
139
- }
140
-
141
- this.gl = this.glCanvas.getContext('webgl', {
142
- alpha: true,
143
- premultipliedAlpha: true,
144
- antialias: false,
145
- preserveDrawingBuffer: false,
146
- powerPreference: 'high-performance' // ✅ NOUVEAU
115
+ // Fallback WebGL1 si WebGL2 indispo
116
+ this.gl = gl || this._glCanvas.getContext('webgl', {
117
+ alpha: true, premultipliedAlpha: false,
118
+ antialias: false, preserveDrawingBuffer: true
147
119
  });
148
120
 
149
- if (!this.gl) {
150
- throw new Error('WebGL non disponible');
121
+ if (!this.gl) throw new Error('WebGL non disponible');
122
+
123
+ // Vérifier l'extension instanced rendering (WebGL1 uniquement)
124
+ if (!gl) {
125
+ this._ext = this.gl.getExtension('ANGLE_instanced_arrays');
126
+ if (!this._ext) throw new Error('ANGLE_instanced_arrays non disponible');
127
+ } else {
128
+ this._ext = null; // WebGL2 a l'instancing natif
151
129
  }
152
130
 
153
- this._setupWebGL();
131
+ this._isWebGL2 = !!gl;
132
+ this._setupShaders();
133
+ this._setupBuffers();
134
+ this._setupAtlasTexture();
154
135
  }
155
136
 
156
- _setupWebGL() {
137
+ _setupShaders() {
157
138
  const gl = this.gl;
139
+ const isGL2 = this._isWebGL2;
140
+
141
+ // ── Vertex shader ──────────────────────────────────────────────────
142
+ // Chaque instance reçoit : position destination, UV dans l'atlas, couleur RGBA
143
+ const vs = isGL2 ? `#version 300 es
144
+ precision highp float;
145
+
146
+ // Quad unitaire [0..1, 0..1] — commun à toutes les instances
147
+ in vec2 a_quad;
148
+
149
+ // Par instance
150
+ in vec4 a_dst; // x, y, w, h (pixels écran)
151
+ in vec4 a_uv; // u0,v0,u1,v1 (coordonnées atlas normalisées)
152
+ in vec4 a_color; // r,g,b,a
158
153
 
159
- // Shaders identiques
160
- const vertexShaderSource = `
161
- attribute vec2 a_position;
162
- attribute vec2 a_texCoord;
163
154
  uniform vec2 u_resolution;
164
- varying vec2 v_texCoord;
165
-
155
+
156
+ out vec2 v_uv;
157
+ out vec4 v_color;
158
+
166
159
  void main() {
167
- vec2 clipSpace = (a_position / u_resolution) * 2.0 - 1.0;
168
- gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
169
- v_texCoord = a_texCoord;
160
+ // Position pixel dans le repère écran
161
+ vec2 pos = a_dst.xy + a_quad * a_dst.zw;
162
+
163
+ // → clip space [-1..1]
164
+ vec2 clip = (pos / u_resolution) * 2.0 - 1.0;
165
+ gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
166
+
167
+ // UV interpolé dans l'atlas
168
+ v_uv = a_uv.xy + a_quad * (a_uv.zw - a_uv.xy);
169
+ v_color = a_color;
170
+ }
171
+ ` : `
172
+ precision highp float;
173
+ attribute vec2 a_quad;
174
+ attribute vec4 a_dst;
175
+ attribute vec4 a_uv;
176
+ attribute vec4 a_color;
177
+ uniform vec2 u_resolution;
178
+ varying vec2 v_uv;
179
+ varying vec4 v_color;
180
+ void main() {
181
+ vec2 pos = a_dst.xy + a_quad * a_dst.zw;
182
+ vec2 clip = (pos / u_resolution) * 2.0 - 1.0;
183
+ gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
184
+ v_uv = a_uv.xy + a_quad * (a_uv.zw - a_uv.xy);
185
+ v_color = a_color;
170
186
  }
171
187
  `;
172
188
 
173
- const fragmentShaderSource = `
189
+ // ── Fragment shader ────────────────────────────────────────────────
190
+ const fs = isGL2 ? `#version 300 es
174
191
  precision mediump float;
175
- uniform sampler2D u_texture;
176
- varying vec2 v_texCoord;
177
-
192
+ uniform sampler2D u_atlas;
193
+ in vec2 v_uv;
194
+ in vec4 v_color;
195
+ out vec4 outColor;
178
196
  void main() {
179
- gl_FragColor = texture2D(u_texture, v_texCoord);
197
+ // Atlas en niveaux de gris (canal alpha = opacité du glyphe)
198
+ float alpha = texture(u_atlas, v_uv).a;
199
+ outColor = vec4(v_color.rgb, v_color.a * alpha);
200
+ }
201
+ ` : `
202
+ precision mediump float;
203
+ uniform sampler2D u_atlas;
204
+ varying vec2 v_uv;
205
+ varying vec4 v_color;
206
+ void main() {
207
+ float alpha = texture2D(u_atlas, v_uv).a;
208
+ gl_FragColor = vec4(v_color.rgb, v_color.a * alpha);
180
209
  }
181
210
  `;
182
211
 
183
- const vertexShader = this._createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
184
- const fragmentShader = this._createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
212
+ this._program = this._linkProgram(vs, fs);
185
213
 
186
- this.program = gl.createProgram();
187
- gl.attachShader(this.program, vertexShader);
188
- gl.attachShader(this.program, fragmentShader);
189
- gl.linkProgram(this.program);
190
-
191
- if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) {
192
- throw new Error('Erreur de linkage du programme WebGL');
193
- }
214
+ // Locations
215
+ this._loc = {
216
+ quad: gl.getAttribLocation (this._program, 'a_quad'),
217
+ dst: gl.getAttribLocation (this._program, 'a_dst'),
218
+ uv: gl.getAttribLocation (this._program, 'a_uv'),
219
+ color: gl.getAttribLocation (this._program, 'a_color'),
220
+ resolution: gl.getUniformLocation(this._program, 'u_resolution'),
221
+ atlas: gl.getUniformLocation(this._program, 'u_atlas')
222
+ };
223
+ }
194
224
 
195
- this.positionLocation = gl.getAttribLocation(this.program, 'a_position');
196
- this.texCoordLocation = gl.getAttribLocation(this.program, 'a_texCoord');
197
- this.resolutionLocation = gl.getUniformLocation(this.program, 'u_resolution');
225
+ _linkProgram(vsSrc, fsSrc) {
226
+ const gl = this.gl;
227
+ const vs = this._compileShader(gl.VERTEX_SHADER, vsSrc);
228
+ const fs = this._compileShader(gl.FRAGMENT_SHADER, fsSrc);
229
+ const prg = gl.createProgram();
230
+ gl.attachShader(prg, vs);
231
+ gl.attachShader(prg, fs);
232
+ gl.linkProgram(prg);
233
+ if (!gl.getProgramParameter(prg, gl.LINK_STATUS))
234
+ throw new Error('WebGL link error: ' + gl.getProgramInfoLog(prg));
235
+ gl.deleteShader(vs);
236
+ gl.deleteShader(fs);
237
+ return prg;
238
+ }
198
239
 
199
- this.positionBuffer = gl.createBuffer();
200
- this.texCoordBuffer = gl.createBuffer();
240
+ _compileShader(type, src) {
241
+ const gl = this.gl;
242
+ const sh = gl.createShader(type);
243
+ gl.shaderSource(sh, src);
244
+ gl.compileShader(sh);
245
+ if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS))
246
+ throw new Error('Shader compile error: ' + gl.getShaderInfoLog(sh));
247
+ return sh;
248
+ }
201
249
 
202
- gl.bindBuffer(gl.ARRAY_BUFFER, this.texCoordBuffer);
203
- gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0,0, 1,0, 0,1, 1,1]), gl.STATIC_DRAW);
250
+ _setupBuffers() {
251
+ const gl = this.gl;
204
252
 
205
- gl.enable(gl.BLEND);
206
- gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
253
+ // Quad unitaire — 2 triangles = 6 sommets, commun à toutes les instances
254
+ // (0,0)→(1,0)→(0,1) + (1,0)→(1,1)→(0,1)
255
+ const quadVerts = new Float32Array([0,0, 1,0, 0,1, 1,0, 1,1, 0,1]);
256
+ this._quadBuf = gl.createBuffer();
257
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._quadBuf);
258
+ gl.bufferData(gl.ARRAY_BUFFER, quadVerts, gl.STATIC_DRAW);
259
+
260
+ // Buffer d'instances — mis à jour chaque frame avec DYNAMIC_DRAW
261
+ this._instanceBuf = gl.createBuffer();
262
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._instanceBuf);
263
+ gl.bufferData(
264
+ gl.ARRAY_BUFFER,
265
+ this._instanceData.byteLength,
266
+ gl.DYNAMIC_DRAW
267
+ );
207
268
  }
208
269
 
209
- _createShader(gl, type, source) {
210
- const shader = gl.createShader(type);
211
- gl.shaderSource(shader, source);
212
- gl.compileShader(shader);
213
-
214
- if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
215
- const info = gl.getShaderInfoLog(shader);
216
- gl.deleteShader(shader);
217
- throw new Error('Erreur de compilation shader: ' + info);
218
- }
219
-
220
- return shader;
270
+ _setupAtlasTexture() {
271
+ const gl = this.gl;
272
+ this._atlasTex = gl.createTexture();
273
+ gl.bindTexture(gl.TEXTURE_2D, this._atlasTex);
274
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
275
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
276
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
277
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
278
+ // Allouer la texture vide
279
+ gl.texImage2D(
280
+ gl.TEXTURE_2D, 0, gl.RGBA,
281
+ this.atlasSize, this.atlasSize, 0,
282
+ gl.RGBA, gl.UNSIGNED_BYTE, null
283
+ );
221
284
  }
222
285
 
223
- // ────────────────────────────────────────────────
224
- // OPTIMISATION : Text Atlas avec cache de métriques
225
- // ────────────────────────────────────────────────
226
- _getFontMetrics(font) {
227
- if (this.fontMetricsCache.has(font)) {
228
- return this.fontMetricsCache.get(font);
229
- }
230
-
231
- const fontSize = parseFloat(font) || 16;
232
- const metrics = {
233
- fontSize,
234
- lineHeight: fontSize * 1.5,
235
- padding: 4
236
- };
286
+ // ══════════════════════════════════════════════════════════════════════
287
+ // ATLAS rasterisation des glyphes côté CPU
288
+ // ══════════════════════════════════════════════════════════════════════
237
289
 
238
- this.fontMetricsCache.set(font, metrics);
239
- return metrics;
290
+ _getFontMetrics(font) {
291
+ if (this._fontMetrics.has(font)) return this._fontMetrics.get(font);
292
+ const size = parseFloat(font) || 16;
293
+ const m = { size, lineH: size * 1.4, pad: 3 };
294
+ this._fontMetrics.set(font, m);
295
+ return m;
240
296
  }
241
297
 
242
- _rasterizeChar(char, font, color) {
243
- const key = `${char}|${font}|${color}`; // NOUVEAU : Key plus court
244
-
245
- if (this.charAtlas.has(key)) {
246
- this.stats.cacheHits++;
247
- return this.charAtlas.get(key);
298
+ /**
299
+ * Retourne les données UV d'un glyphe dans l'atlas.
300
+ * Si absent, le rasterise dans l'atlas CPU et marque la texture dirty.
301
+ */
302
+ _getGlyph(char, font) {
303
+ const key = char + '|' + font;
304
+
305
+ if (this._atlasGlyphs.has(key)) {
306
+ // LRU : remonter
307
+ const g = this._atlasGlyphs.get(key);
308
+ this._atlasGlyphs.delete(key);
309
+ this._atlasGlyphs.set(key, g);
310
+ return g;
248
311
  }
249
312
 
250
- this.stats.cacheMisses++;
251
-
252
- const metrics = this._getFontMetrics(font);
253
- const atlas = this.atlases[this.currentAtlasIndex];
254
-
255
- atlas.ctx.font = font;
256
- const textMetrics = atlas.ctx.measureText(char);
257
-
258
- const width = Math.ceil(textMetrics.width) + metrics.padding;
259
- const height = Math.ceil(metrics.lineHeight) + metrics.padding;
260
-
261
- // ✅ NOUVEAU : Gestion intelligente multi-atlas
262
- if (atlas.x + width > 2048) {
263
- atlas.x = 0;
264
- atlas.y += atlas.rowHeight + 2;
265
- atlas.rowHeight = 0;
313
+ // LRU éviction si atlas plein
314
+ if (this._atlasGlyphs.size >= this.maxTextCacheSize) {
315
+ const oldest = this._atlasGlyphs.keys().next().value;
316
+ this._atlasGlyphs.delete(oldest);
266
317
  }
267
318
 
268
- if (atlas.y + height > 2048) {
269
- // Créer un nouvel atlas au lieu de clear
270
- if (this.atlases.length < 4) { // ✅ Maximum 4 atlas
271
- this.currentAtlasIndex++;
272
- this.atlases.push(this._createAtlas());
273
- this.stats.atlasCount++;
274
- return this._rasterizeChar(char, font, color); // Retry
275
- } else {
276
- // Réutiliser l'atlas le moins utilisé
277
- this.currentAtlasIndex = this._findLeastUsedAtlas();
278
- this._clearAtlas(this.currentAtlasIndex);
279
- return this._rasterizeChar(char, font, color);
280
- }
319
+ // Rasteriser dans le canvas atlas CPU
320
+ const m = this._getFontMetrics(font);
321
+ const ctx = this._atlasCtx;
322
+ ctx.font = font;
323
+
324
+ const tm = ctx.measureText(char);
325
+ const gw = Math.ceil(tm.width) + m.pad * 2;
326
+ const gh = Math.ceil(m.lineH) + m.pad * 2;
327
+ const advance = tm.width;
328
+
329
+ // Retour à la ligne si dépassement horizontal
330
+ if (this._atlasCursor.x + gw > this.atlasSize) {
331
+ this._atlasCursor.x = 0;
332
+ this._atlasCursor.y += this._atlasCursor.rowH + 1;
333
+ this._atlasCursor.rowH = 0;
281
334
  }
282
335
 
283
- // Dessiner le caractère
284
- atlas.ctx.font = font;
285
- atlas.ctx.fillStyle = color;
286
- atlas.ctx.textBaseline = 'alphabetic';
287
- atlas.ctx.fillText(char, atlas.x + 2, atlas.y + metrics.fontSize);
288
-
289
- const charData = {
290
- atlasIndex: this.currentAtlasIndex,
291
- x: atlas.x,
292
- y: atlas.y,
293
- width,
294
- height,
295
- textWidth: textMetrics.width
296
- };
336
+ // Atlas saturé verticalement → rebuild complet
337
+ if (this._atlasCursor.y + gh > this.atlasSize) {
338
+ this._rebuildAtlas();
339
+ return this._getGlyph(char, font); // retry après rebuild
340
+ }
297
341
 
298
- this.charAtlas.set(key, charData);
299
- atlas.usage++;
342
+ const ax = this._atlasCursor.x;
343
+ const ay = this._atlasCursor.y;
344
+
345
+ // Dessiner le glyphe en blanc (couleur appliquée dans le shader via a_color)
346
+ ctx.clearRect(ax, ay, gw, gh);
347
+ ctx.fillStyle = '#ffffff';
348
+ ctx.textBaseline = 'alphabetic';
349
+ ctx.fillText(char, ax + m.pad, ay + m.pad + m.size);
350
+
351
+ this._atlasCursor.x += gw + 1;
352
+ this._atlasCursor.rowH = Math.max(this._atlasCursor.rowH, gh);
353
+
354
+ const S = this.atlasSize;
355
+ const glyph = {
356
+ u0: ax / S,
357
+ v0: ay / S,
358
+ u1: (ax + gw) / S,
359
+ v1: (ay + gh) / S,
360
+ w: gw,
361
+ h: gh,
362
+ advance,
363
+ baselineY: m.pad + m.size // offset baseline dans le glyphe
364
+ };
300
365
 
301
- atlas.x += width + 2;
302
- atlas.rowHeight = Math.max(atlas.rowHeight, height);
366
+ this._atlasGlyphs.set(key, glyph);
367
+ this._atlasDirty = true;
368
+ this.stats.glyphsCached++;
303
369
 
304
- return charData;
370
+ return glyph;
305
371
  }
306
372
 
307
- // ✅ NOUVEAU : Trouve l'atlas le moins utilisé
308
- _findLeastUsedAtlas() {
309
- let minUsage = Infinity;
310
- let minIndex = 0;
311
-
312
- for (let i = 0; i < this.atlases.length; i++) {
313
- if (this.atlases[i].usage < minUsage) {
314
- minUsage = this.atlases[i].usage;
315
- minIndex = i;
316
- }
373
+ /**
374
+ * Reconstruit l'atlas depuis zéro avec uniquement les glyphes actuels.
375
+ * Appelé quand l'atlas est saturé.
376
+ */
377
+ _rebuildAtlas() {
378
+ const ctx = this._atlasCtx;
379
+ ctx.clearRect(0, 0, this.atlasSize, this.atlasSize);
380
+
381
+ this._atlasCursor = { x: 0, y: 0, rowH: 0 };
382
+
383
+ // Conserver les glyphes actuels mais les repositionner
384
+ const existing = [...this._atlasGlyphs.entries()];
385
+ this._atlasGlyphs.clear();
386
+
387
+ // Re-rasteriser les plus récents (fin de Map = plus récents)
388
+ const toKeep = existing.slice(-Math.floor(this.maxTextCacheSize * 0.7));
389
+ for (const [key] of toKeep) {
390
+ const [char, font] = key.split('|');
391
+ if (char && font) this._getGlyph(char, font);
317
392
  }
318
-
319
- return minIndex;
393
+
394
+ this._atlasDirty = true;
320
395
  }
321
396
 
322
- // ✅ NOUVEAU : Clear un atlas spécifique
323
- _clearAtlas(index) {
324
- const atlas = this.atlases[index];
325
- atlas.ctx.clearRect(0, 0, 2048, 2048);
326
- atlas.x = 0;
327
- atlas.y = 0;
328
- atlas.rowHeight = 0;
329
- atlas.usage = 0;
330
-
331
- // Supprimer les entrées du cache pour cet atlas
332
- for (let [key, value] of this.charAtlas.entries()) {
333
- if (value.atlasIndex === index) {
334
- this.charAtlas.delete(key);
335
- }
397
+ _maybeRebuildAtlas() {
398
+ // Rebuild si > 90% plein
399
+ if (this._atlasGlyphs.size > this.maxTextCacheSize * 0.9) {
400
+ this._rebuildAtlas();
336
401
  }
337
402
  }
338
403
 
339
- // ────────────────────────────────────────────────
340
- // OPTIMISATION : Culling amélioré avec marge
341
- // ────────────────────────────────────────────────
342
- _isInViewport(x, y, width, height) {
343
- if (!this.enableCulling) return true;
344
-
345
- const margin = 50; // ✅ NOUVEAU : Marge pour pré-render
346
-
347
- return !(
348
- x + width < -margin ||
349
- x > this.viewportBounds.right + margin ||
350
- y + height < -margin ||
351
- y > this.viewportBounds.bottom + margin
404
+ /**
405
+ * Upload la texture atlas vers le GPU si elle a changé.
406
+ * Appelé UNE seule fois par frame, juste avant le draw call.
407
+ */
408
+ _uploadAtlasIfDirty() {
409
+ if (!this._atlasDirty) return;
410
+ const gl = this.gl;
411
+ gl.bindTexture(gl.TEXTURE_2D, this._atlasTex);
412
+ gl.texImage2D(
413
+ gl.TEXTURE_2D, 0, gl.RGBA,
414
+ gl.RGBA, gl.UNSIGNED_BYTE,
415
+ this._atlasCanvas
352
416
  );
417
+ this._atlasDirty = false;
353
418
  }
354
419
 
355
- // ✅ NOUVEAU : Update viewport bounds
356
- updateViewport(left = 0, top = 0, right = this.canvas.width, bottom = this.canvas.height) {
357
- this.viewportBounds = { left, top, right, bottom };
358
- }
420
+ // ══════════════════════════════════════════════════════════════════════
421
+ // BATCH accumulation des instances
422
+ // ══════════════════════════════════════════════════════════════════════
359
423
 
360
- // ────────────────────────────────────────────────
361
- // ✅ OPTIMISATION : Batch Rendering avec auto-flush
362
- // ────────────────────────────────────────────────
363
424
  beginTextBatch() {
364
- this.batchMode = true;
365
- this.textBatch = [];
425
+ this._batchMode = true;
426
+ this._instanceCount = 0;
366
427
  }
367
428
 
368
- flushTextBatch() {
369
- if (this.textBatch.length === 0) {
370
- this.batchMode = false;
429
+ /**
430
+ * Ajoute tous les glyphes d'un texte au batch courant.
431
+ * Aucun draw call GPU ici — juste écriture dans _instanceData (CPU).
432
+ */
433
+ _enqueueText(text, x, y) {
434
+ if (!this._glReady) {
435
+ // Fallback Canvas 2D
436
+ this.ctx.font = this._currentFont;
437
+ this.ctx.fillStyle = this._currentFillStyle;
438
+ this.ctx.textAlign = this._currentTextAlign;
439
+ this.ctx.textBaseline = this._currentTextBaseline;
440
+ this.ctx.fillText(text, x, y);
371
441
  return;
372
442
  }
373
443
 
374
- // ✅ NOUVEAU : Tri par font/color pour réduire les changements d'état
375
- this.textBatch.sort((a, b) => {
376
- const keyA = `${a.font}|${a.color}`;
377
- const keyB = `${b.font}|${b.color}`;
378
- return keyA.localeCompare(keyB);
379
- });
380
-
381
- let lastFont = '';
382
- let lastColor = '';
383
-
384
- // Dessiner tous les textes du batch
385
- for (let item of this.textBatch) {
386
- // ✅ NOUVEAU : Éviter les changements d'état inutiles
387
- if (item.font !== lastFont) {
388
- this._currentFont = item.font;
389
- lastFont = item.font;
390
- }
391
- if (item.color !== lastColor) {
392
- this._currentFillStyle = item.color;
393
- lastColor = item.color;
394
- }
395
-
396
- this._currentTextAlign = item.align;
397
- this._currentTextBaseline = item.baseline;
398
-
399
- this._drawTextImmediate(item.text, item.x, item.y);
400
- }
401
-
402
- this.stats.batchedDraws += this.textBatch.length;
403
- this.textBatch = [];
404
- this.batchMode = false;
405
- }
406
-
407
- // ────────────────────────────────────────────────
408
- // fillText : MÉTHODE PRINCIPALE
409
- // ────────────────────────────────────────────────
410
- fillText(text, x, y) {
411
- if (!text) return;
412
-
413
- const font = this._currentFont;
414
- const color = this._currentFillStyle;
415
- const align = this._currentTextAlign;
444
+ const font = this._currentFont;
445
+ const [r,g,b,a] = this._currentFillRGBA;
446
+ const align = this._currentTextAlign;
416
447
  const baseline = this._currentTextBaseline;
448
+ const m = this._getFontMetrics(font);
417
449
 
418
- // Mode batch
419
- if (this.batchMode) {
420
- this.textBatch.push({ text, x, y, font, color, align, baseline });
421
-
422
- // NOUVEAU : Auto-flush si batch trop grand
423
- if (this.textBatch.length >= this.maxBatchSize) {
424
- this.flushTextBatch();
425
- this.beginTextBatch(); // Redémarrer le batch
426
- }
427
- return;
450
+ // Calcul largeur totale pour alignement
451
+ let totalW = 0;
452
+ for (let i = 0; i < text.length; i++) {
453
+ const g = this._getGlyph(text[i], font);
454
+ if (g) totalW += g.advance;
428
455
  }
429
456
 
430
- this._drawTextImmediate(text, x, y);
431
- }
432
-
433
- _drawTextImmediate(text, x, y) {
434
- const font = this._currentFont;
435
- const color = this._currentFillStyle;
436
- const align = this._currentTextAlign;
437
- const baseline = this._currentTextBaseline;
438
-
439
- // Culling optimisé
440
- const metrics = this._getFontMetrics(font);
441
- const estimatedWidth = text.length * metrics.fontSize * 0.6;
457
+ let curX = x;
458
+ if (align === 'center') curX -= totalW / 2;
459
+ else if (align === 'right' || align === 'end') curX -= totalW;
460
+
461
+ // Offset vertical selon baseline
462
+ const baselineOffsets = {
463
+ alphabetic: 0,
464
+ top: m.size * 0.85,
465
+ middle: m.size * 0.35,
466
+ bottom: -m.size * 0.15,
467
+ hanging: m.size * 0.75,
468
+ ideographic:-m.size * 0.1
469
+ };
470
+ const baseOff = baselineOffsets[baseline] ?? 0;
471
+
472
+ // Écrire chaque glyphe comme une instance dans le tableau CPU
473
+ for (let i = 0; i < text.length; i++) {
474
+ if (this._instanceCount >= this.maxBatchSize) {
475
+ // Auto-flush si batch saturé
476
+ this._flushGPU();
477
+ this._instanceCount = 0;
478
+ }
442
479
 
443
- if (!this._isInViewport(x - estimatedWidth/2, y - metrics.fontSize, estimatedWidth, metrics.fontSize * 2)) {
444
- this.stats.culledTexts++;
445
- return;
446
- }
480
+ const glyph = this._getGlyph(text[i], font);
481
+ if (!glyph) { curX += m.size * 0.5; continue; }
482
+
483
+ // Culling par glyphe
484
+ if (this.enableCulling) {
485
+ const sx = curX;
486
+ const sy = y - glyph.baselineY + baseOff;
487
+ if (sx + glyph.w < this._viewport.l ||
488
+ sx > this._viewport.r ||
489
+ sy + glyph.h < this._viewport.t ||
490
+ sy > this._viewport.b) {
491
+ curX += glyph.advance;
492
+ this.stats.culled++;
493
+ continue;
494
+ }
495
+ }
447
496
 
448
- // Mode atlas par défaut
449
- if (this.useTextAtlas) {
450
- this._drawTextWithAtlas(text, x, y, font, color, align, baseline);
451
- } else {
452
- this._drawTextCached(text, x, y, font, color, align, baseline);
497
+ const off = this._instanceCount * this._FLOATS_PER_INSTANCE;
498
+ const d = this._instanceData;
499
+
500
+ // Position destination (pixels)
501
+ d[off + 0] = Math.round(curX);
502
+ d[off + 1] = Math.round(y - glyph.baselineY + baseOff);
503
+ d[off + 2] = glyph.w;
504
+ d[off + 3] = glyph.h;
505
+
506
+ // UV dans l'atlas
507
+ d[off + 4] = glyph.u0;
508
+ d[off + 5] = glyph.v0;
509
+ d[off + 6] = glyph.u1;
510
+ d[off + 7] = glyph.v1;
511
+
512
+ // Couleur RGBA
513
+ d[off + 8] = r;
514
+ d[off + 9] = g;
515
+ d[off + 10] = b;
516
+ d[off + 11] = a;
517
+
518
+ this._instanceCount++;
519
+ curX += glyph.advance;
453
520
  }
454
-
455
- this.stats.drawCalls++;
456
521
  }
457
522
 
458
- // ✅ Dessiner avec Text Atlas (optimisé)
459
- _drawTextWithAtlas(text, x, y, font, color, align, baseline) {
460
- // NOUVEAU : Pré-calcul des métriques
461
- const metrics = this._getFontMetrics(font);
462
- let totalWidth = 0;
463
- const chars = Array.from(text); // Support Unicode
464
- const charData = [];
465
-
466
- // Phase 1 : Rasterization (peut être mise en cache)
467
- for (let char of chars) {
468
- const data = this._rasterizeChar(char, font, color);
469
- charData.push(data);
470
- totalWidth += data.textWidth;
523
+ /**
524
+ * Envoie le batch au GPU en UN SEUL draw call.
525
+ * C'est ici que se trouve le gain de performance réel.
526
+ */
527
+ _flushGPU() {
528
+ if (this._instanceCount === 0 || !this._glReady) return;
529
+
530
+ const gl = this.gl;
531
+ const loc = this._loc;
532
+ const ext = this._ext; // null si WebGL2
533
+
534
+ // 1. Upload atlas si modifié
535
+ this._uploadAtlasIfDirty();
536
+
537
+ // 2. Resize glCanvas si nécessaire
538
+ const cw = this.canvas.width;
539
+ const ch = this.canvas.height;
540
+ if (this._glCanvas.width !== cw || this._glCanvas.height !== ch) {
541
+ this._glCanvas.width = cw;
542
+ this._glCanvas.height = ch;
543
+ gl.viewport(0, 0, cw, ch);
471
544
  }
472
545
 
473
- // Phase 2 : Calcul positions
474
- let startX = x;
475
- if (align === 'center') {
476
- startX -= totalWidth / 2;
477
- } else if (align === 'right') {
478
- startX -= totalWidth;
479
- } else if (align === 'end') {
480
- startX -= totalWidth; // ✅ Support 'end'
481
- }
546
+ // 3. Clear + blend
547
+ gl.clearColor(0, 0, 0, 0);
548
+ gl.clear(gl.COLOR_BUFFER_BIT);
549
+ gl.enable(gl.BLEND);
550
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
551
+ gl.useProgram(this._program);
482
552
 
483
- const baselineOffset = metrics.fontSize * (this.baselineRatios[baseline] || 0.85);
553
+ // 4. Résolution
554
+ gl.uniform2f(loc.resolution, cw / this.dpr, ch / this.dpr);
484
555
 
485
- // Phase 3 : Rendu
486
- let offsetX = 0;
487
- for (let i = 0; i < chars.length; i++) {
488
- const data = charData[i];
489
- const atlas = this.atlases[data.atlasIndex];
556
+ // 5. Atlas texture
557
+ gl.activeTexture(gl.TEXTURE0);
558
+ gl.bindTexture(gl.TEXTURE_2D, this._atlasTex);
559
+ gl.uniform1i(loc.atlas, 0);
560
+
561
+ // 6. Buffer quad (commun à toutes les instances)
562
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._quadBuf);
563
+ gl.enableVertexAttribArray(loc.quad);
564
+ gl.vertexAttribPointer(loc.quad, 2, gl.FLOAT, false, 0, 0);
565
+ if (ext) ext.vertexAttribDivisorANGLE(loc.quad, 0);
566
+ else gl.vertexAttribDivisor(loc.quad, 0);
567
+
568
+ // 7. Buffer instances — upload uniquement les instances actives
569
+ const stride = this._FLOATS_PER_INSTANCE * 4; // bytes
570
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._instanceBuf);
571
+ gl.bufferSubData(
572
+ gl.ARRAY_BUFFER, 0,
573
+ this._instanceData.subarray(0, this._instanceCount * this._FLOATS_PER_INSTANCE)
574
+ );
490
575
 
491
- this.ctx.drawImage(
492
- atlas.canvas,
493
- data.x, data.y, data.width, data.height,
494
- Math.round(startX + offsetX), Math.round(y - baselineOffset),
495
- data.width, data.height
496
- );
576
+ // a_dst : vec4 @ offset 0
577
+ gl.enableVertexAttribArray(loc.dst);
578
+ gl.vertexAttribPointer(loc.dst, 4, gl.FLOAT, false, stride, 0);
579
+ if (ext) ext.vertexAttribDivisorANGLE(loc.dst, 1);
580
+ else gl.vertexAttribDivisor(loc.dst, 1);
497
581
 
498
- offsetX += data.textWidth;
499
- }
500
- }
582
+ // a_uv : vec4 @ offset 16
583
+ gl.enableVertexAttribArray(loc.uv);
584
+ gl.vertexAttribPointer(loc.uv, 4, gl.FLOAT, false, stride, 16);
585
+ if (ext) ext.vertexAttribDivisorANGLE(loc.uv, 1);
586
+ else gl.vertexAttribDivisor(loc.uv, 1);
501
587
 
502
- // Ancien système avec LRU
503
- _drawTextCached(text, x, y, font, color, align, baseline) {
504
- const key = `${text}|${font}|${color}|${align}|${baseline}`;
505
-
506
- // ✅ NOUVEAU : LRU tracking
507
- this._touchLRU(key);
508
-
509
- let cached = this.textCache.get(key);
510
-
511
- if (!cached) {
512
- const rasterized = this._rasterizeText(text, font, color, align, baseline);
513
- const texture = this._createWebGLTexture(rasterized.canvas, rasterized.width, rasterized.height);
514
-
515
- cached = {
516
- texture,
517
- width: rasterized.width,
518
- height: rasterized.height,
519
- textWidth: rasterized.textWidth,
520
- baselineOffset: rasterized.baselineOffset,
521
- createdAt: Date.now()
522
- };
523
-
524
- this.textCache.set(key, cached);
525
-
526
- // ✅ NOUVEAU : Eviction immédiate si trop grand
527
- if (this.textCache.size > this.maxTextCacheSize) {
528
- this._scheduleCleanup();
529
- }
530
- }
588
+ // a_color : vec4 @ offset 32
589
+ gl.enableVertexAttribArray(loc.color);
590
+ gl.vertexAttribPointer(loc.color, 4, gl.FLOAT, false, stride, 32);
591
+ if (ext) ext.vertexAttribDivisorANGLE(loc.color, 1);
592
+ else gl.vertexAttribDivisor(loc.color, 1);
531
593
 
532
- let finalX = x - 8;
533
- if (align === 'center') finalX -= cached.textWidth / 2;
534
- else if (align === 'right' || align === 'end') finalX -= cached.textWidth;
594
+ // 8. LE draw call unique — dessine N instances du quad unitaire
595
+ if (ext) ext.drawArraysInstancedANGLE(gl.TRIANGLES, 0, 6, this._instanceCount);
596
+ else gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this._instanceCount);
535
597
 
536
- const finalY = y - 8 - cached.baselineOffset;
598
+ // 9. Copier le résultat WebGL sur le canvas 2D principal
599
+ this.ctx.drawImage(this._glCanvas, 0, 0);
537
600
 
538
- this._drawTextureToCanvas(cached.texture, cached.width, cached.height,
539
- Math.round(finalX), Math.round(finalY));
601
+ this.stats.drawCalls++;
602
+ this.stats.instancesDrawn += this._instanceCount;
540
603
  }
541
604
 
542
- // ✅ NOUVEAU : LRU tracking
543
- _touchLRU(key) {
544
- const index = this.lruKeys.indexOf(key);
545
- if (index > -1) {
546
- this.lruKeys.splice(index, 1);
547
- }
548
- this.lruKeys.push(key);
605
+ flushTextBatch() {
606
+ this._flushGPU();
607
+ this._instanceCount = 0;
608
+ this._batchMode = false;
549
609
  }
550
610
 
551
- // ────────────────────────────────────────────────
552
- // Méthodes auxiliaires
553
- // ────────────────────────────────────────────────
554
- _rasterizeText(text, font, color, align, baseline) {
555
- const metrics = this._getFontMetrics(font);
556
- this.textCtx.font = font;
557
- const textMetrics = this.textCtx.measureText(text);
558
-
559
- const width = Math.ceil(textMetrics.width) + 16;
560
- const height = Math.ceil(metrics.lineHeight) + 16;
561
-
562
- // ✅ NOUVEAU : Resize seulement si nécessaire
563
- if (this.textCanvas.width < width) {
564
- this.textCanvas.width = Math.min(width, 4096); // ✅ Limite max
565
- }
566
- if (this.textCanvas.height < height) {
567
- this.textCanvas.height = Math.min(height, 4096);
568
- }
611
+ // ══════════════════════════════════════════════════════════════════════
612
+ // API PUBLIQUE — fillText
613
+ // ══════════════════════════════════════════════════════════════════════
569
614
 
570
- this.textCtx.clearRect(0, 0, width, height);
571
- this.textCtx.font = font;
572
- this.textCtx.fillStyle = color;
573
- this.textCtx.textAlign = 'left';
574
- this.textCtx.textBaseline = 'alphabetic';
575
-
576
- const offsetY = metrics.fontSize * (this.baselineRatios[baseline] || 0.85);
577
- this.textCtx.fillText(text, 8, 8 + offsetY);
578
-
579
- return {
580
- canvas: this.textCanvas,
581
- width,
582
- height,
583
- textWidth: textMetrics.width,
584
- baselineOffset: offsetY
585
- };
586
- }
615
+ fillText(text, x, y) {
616
+ if (!text) return;
587
617
 
588
- _createWebGLTexture(canvas, width, height) {
589
- const gl = this.gl;
590
- const texture = gl.createTexture();
591
-
592
- gl.bindTexture(gl.TEXTURE_2D, texture);
593
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
594
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
595
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
596
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
597
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
618
+ if (this._batchMode) {
619
+ this._enqueueText(text, x, y);
620
+ return;
621
+ }
598
622
 
599
- return texture;
623
+ // Hors batch : flush immédiat après accumulation
624
+ this._enqueueText(text, x, y);
625
+ this._flushGPU();
626
+ this._instanceCount = 0;
600
627
  }
601
628
 
602
- _drawTextureToCanvas(texture, width, height, x, y) {
603
- const gl = this.gl;
604
-
605
- if (this.glCanvas.width !== width || this.glCanvas.height !== height) {
606
- this.glCanvas.width = width;
607
- this.glCanvas.height = height;
629
+ // ══════════════════════════════════════════════════════════════════════
630
+ // UTILITAIRES
631
+ // ══════════════════════════════════════════════════════════════════════
632
+
633
+ /**
634
+ * Parse une couleur CSS → [r, g, b, a] normalisés [0..1]
635
+ * Supporte : #rgb #rrggbb rgba() rgb() et les couleurs nommées communes
636
+ */
637
+ _parseColor(color) {
638
+ if (!color) return [0, 0, 0, 1];
639
+
640
+ // Cache
641
+ if (this._colorCache) {
642
+ const cached = this._colorCache.get(color);
643
+ if (cached) return cached;
644
+ } else {
645
+ this._colorCache = new Map();
608
646
  }
609
647
 
610
- gl.viewport(0, 0, width, height);
611
- gl.clearColor(0, 0, 0, 0);
612
- gl.clear(gl.COLOR_BUFFER_BIT);
613
- gl.useProgram(this.program);
614
-
615
- gl.activeTexture(gl.TEXTURE0);
616
- gl.bindTexture(gl.TEXTURE_2D, texture);
617
-
618
- gl.bindBuffer(gl.ARRAY_BUFFER, this.positionBuffer);
619
- gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0,0, width,0, 0,height, width,height]), gl.STATIC_DRAW);
620
-
621
- gl.enableVertexAttribArray(this.positionLocation);
622
- gl.vertexAttribPointer(this.positionLocation, 2, gl.FLOAT, false, 0, 0);
623
-
624
- gl.bindBuffer(gl.ARRAY_BUFFER, this.texCoordBuffer);
625
- gl.enableVertexAttribArray(this.texCoordLocation);
626
- gl.vertexAttribPointer(this.texCoordLocation, 2, gl.FLOAT, false, 0, 0);
627
-
628
- gl.uniform2f(this.resolutionLocation, width, height);
629
- gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
648
+ let r = 0, g = 0, b = 0, a = 1;
649
+
650
+ if (color[0] === '#') {
651
+ const hex = color.slice(1);
652
+ if (hex.length === 3) {
653
+ r = parseInt(hex[0] + hex[0], 16) / 255;
654
+ g = parseInt(hex[1] + hex[1], 16) / 255;
655
+ b = parseInt(hex[2] + hex[2], 16) / 255;
656
+ } else if (hex.length === 6) {
657
+ r = parseInt(hex.slice(0, 2), 16) / 255;
658
+ g = parseInt(hex.slice(2, 4), 16) / 255;
659
+ b = parseInt(hex.slice(4, 6), 16) / 255;
660
+ } else if (hex.length === 8) {
661
+ r = parseInt(hex.slice(0, 2), 16) / 255;
662
+ g = parseInt(hex.slice(2, 4), 16) / 255;
663
+ b = parseInt(hex.slice(4, 6), 16) / 255;
664
+ a = parseInt(hex.slice(6, 8), 16) / 255;
665
+ }
666
+ } else if (color.startsWith('rgb')) {
667
+ const nums = color.match(/[\d.]+/g);
668
+ if (nums) {
669
+ r = +nums[0] / 255;
670
+ g = +nums[1] / 255;
671
+ b = +nums[2] / 255;
672
+ a = nums[3] !== undefined ? +nums[3] : 1;
673
+ }
674
+ }
630
675
 
631
- this.ctx.drawImage(this.glCanvas, x, y, width, height);
676
+ const result = [r, g, b, a];
677
+ this._colorCache.set(color, result);
678
+ return result;
632
679
  }
633
680
 
634
- // ────────────────────────────────────────────────
635
- // Nettoyage optimisé avec debounce
636
- // ────────────────────────────────────────────────
637
- _scheduleCleanup() {
638
- if (this._cleanupScheduled) return;
639
-
640
- this._cleanupScheduled = true;
641
- requestIdleCallback(() => {
642
- this._cleanOldCache();
643
- this._cleanupScheduled = false;
644
- }, { timeout: 1000 });
681
+ updateViewport(l = 0, t = 0, r = this.canvas.width, b = this.canvas.height) {
682
+ this._viewport = { l, t, r, b };
645
683
  }
646
684
 
647
- _cleanOldCache() {
648
- if (this.textCache.size <= this.maxTextCacheSize) return;
649
-
650
- const gl = this.gl;
651
- const toRemove = this.textCache.size - this.maxTextCacheSize;
652
-
653
- // ✅ NOUVEAU : Utiliser LRU pour supprimer les moins utilisés
654
- const keysToRemove = this.lruKeys.splice(0, toRemove);
655
-
656
- keysToRemove.forEach(key => {
657
- const entry = this.textCache.get(key);
658
- if (entry?.texture) {
659
- gl.deleteTexture(entry.texture);
660
- }
661
- this.textCache.delete(key);
662
- });
685
+ measureText(text) {
686
+ if (this.ctx.font !== this._currentFont) this.ctx.font = this._currentFont;
687
+ return this.ctx.measureText(text);
663
688
  }
664
689
 
665
- // ────────────────────────────────────────────────
666
- // Stats & utils
667
- // ────────────────────────────────────────────────
668
690
  getStats() {
669
691
  return {
670
692
  ...this.stats,
671
- atlasSize: this.charAtlas.size,
672
- cacheSize: this.textCache.size,
673
- cacheHitRate: this.stats.cacheHits / (this.stats.cacheHits + this.stats.cacheMisses) || 0,
674
- atlasCount: this.atlases.length,
675
- avgAtlasUsage: this.atlases.reduce((sum, a) => sum + a.usage, 0) / this.atlases.length
693
+ glyphsInAtlas: this._atlasGlyphs.size,
694
+ batchPending: this._instanceCount
676
695
  };
677
696
  }
678
697
 
679
698
  resetStats() {
680
- this.stats = {
681
- cacheHits: 0,
682
- cacheMisses: 0,
683
- drawCalls: 0,
684
- culledTexts: 0,
685
- batchedDraws: 0,
686
- atlasCount: this.atlases.length
687
- };
699
+ this.stats = { drawCalls: 0, glyphsCached: 0, culled: 0, instancesDrawn: 0 };
688
700
  }
689
701
 
690
- // ✅ NOUVEAU : Clear all caches
691
- clearCaches() {
692
- this.charAtlas.clear();
693
- this.fontMetricsCache.clear();
694
-
695
- const gl = this.gl;
696
- this.textCache.forEach(entry => {
697
- if (entry.texture) gl.deleteTexture(entry.texture);
698
- });
699
- this.textCache.clear();
700
- this.lruKeys = [];
702
+ // ══════════════════════════════════════════════════════════════════════
703
+ // SETTERS / GETTERS Canvas 2D standard
704
+ // ══════════════════════════════════════════════════════════════════════
701
705
 
702
- // Clear all atlases
703
- this.atlases.forEach((atlas, i) => this._clearAtlas(i));
704
- this.currentAtlasIndex = 0;
705
- }
706
+ set font(v) { this._currentFont = v; this.ctx.font = v; }
707
+ get font() { return this._currentFont; }
706
708
 
707
- // ────────────────────────────────────────────────
708
- // API Canvas 2D standard
709
- // ────────────────────────────────────────────────
710
- measureText(text) {
711
- const oldFont = this.ctx.font;
712
- this.ctx.font = this._currentFont;
713
- const metrics = this.ctx.measureText(text);
714
- this.ctx.font = oldFont;
715
- return metrics;
709
+ set fillStyle(v) {
710
+ this._currentFillStyle = v;
711
+ this._currentFillRGBA = this._parseColor(v);
712
+ this.ctx.fillStyle = v;
716
713
  }
717
-
718
- set font(value) { this._currentFont = value; this.ctx.font = value; }
719
- get font() { return this._currentFont; }
720
- set fillStyle(value) { this._currentFillStyle = value; this.ctx.fillStyle = value; }
721
- get fillStyle() { return this._currentFillStyle; }
722
- set textAlign(value) { this._currentTextAlign = value; this.ctx.textAlign = value; }
723
- get textAlign() { return this._currentTextAlign; }
724
- set textBaseline(value) { this._currentTextBaseline = value; this.ctx.textBaseline = value; }
725
- get textBaseline() { return this._currentTextBaseline; }
726
-
727
- clearRect(...args) { this.ctx.clearRect(...args); }
728
- fillRect(...args) { this.ctx.fillRect(...args); }
729
- strokeRect(...args) { this.ctx.strokeRect(...args); }
730
- beginPath() { this.ctx.beginPath(); }
731
- moveTo(...args) { this.ctx.moveTo(...args); }
732
- lineTo(...args) { this.ctx.lineTo(...args); }
733
- arc(...args) { this.ctx.arc(...args); }
734
- closePath() { this.ctx.closePath(); }
735
- fill() { this.ctx.fill(); }
736
- stroke() { this.ctx.stroke(); }
737
- drawImage(...args) { this.ctx.drawImage(...args); }
738
- save() { this.ctx.save(); }
739
- restore() { this.ctx.restore(); }
740
- translate(...args) { this.ctx.translate(...args); }
741
- rotate(...args) { this.ctx.rotate(...args); }
742
- scale(...args) { this.ctx.scale(...args); }
743
- createLinearGradient(...args) { return this.ctx.createLinearGradient(...args); }
744
-
745
- set strokeStyle(value) { this.ctx.strokeStyle = value; }
746
- get strokeStyle() { return this.ctx.strokeStyle; }
747
- set lineWidth(value) { this.ctx.lineWidth = value; }
748
- get lineWidth() { return this.ctx.lineWidth; }
749
- set globalAlpha(value) { this.ctx.globalAlpha = value; }
750
- get globalAlpha() { return this.ctx.globalAlpha; }
714
+ get fillStyle() { return this._currentFillStyle; }
715
+
716
+ set textAlign(v) { this._currentTextAlign = v; this.ctx.textAlign = v; }
717
+ get textAlign() { return this._currentTextAlign; }
718
+
719
+ set textBaseline(v) { this._currentTextBaseline = v; this.ctx.textBaseline = v; }
720
+ get textBaseline() { return this._currentTextBaseline; }
721
+
722
+ set strokeStyle(v) { this.ctx.strokeStyle = v; }
723
+ get strokeStyle() { return this.ctx.strokeStyle; }
724
+ set lineWidth(v) { this.ctx.lineWidth = v; }
725
+ get lineWidth() { return this.ctx.lineWidth; }
726
+ set globalAlpha(v) { this.ctx.globalAlpha = v; }
727
+ get globalAlpha() { return this.ctx.globalAlpha; }
728
+
729
+ // Toutes les opérations non-texte passent directement au ctx 2D
730
+ clearRect(...a) { this.ctx.clearRect(...a); }
731
+ fillRect(...a) { this.ctx.fillRect(...a); }
732
+ strokeRect(...a) { this.ctx.strokeRect(...a); }
733
+ beginPath() { this.ctx.beginPath(); }
734
+ moveTo(...a) { this.ctx.moveTo(...a); }
735
+ lineTo(...a) { this.ctx.lineTo(...a); }
736
+ arc(...a) { this.ctx.arc(...a); }
737
+ rect(...a) { this.ctx.rect(...a); }
738
+ closePath() { this.ctx.closePath(); }
739
+ fill(...a) { this.ctx.fill(...a); }
740
+ stroke() { this.ctx.stroke(); }
741
+ drawImage(...a) { this.ctx.drawImage(...a); }
742
+ save() { this.ctx.save(); }
743
+ restore() { this.ctx.restore(); }
744
+ translate(...a) { this.ctx.translate(...a); }
745
+ rotate(...a) { this.ctx.rotate(...a); }
746
+ scale(...a) { this.ctx.scale(...a); }
747
+ setTransform(...a) { this.ctx.setTransform(...a); }
748
+ clip(...a) { this.ctx.clip(...a); }
749
+ createLinearGradient(...a) { return this.ctx.createLinearGradient(...a); }
750
+ createRadialGradient(...a) { return this.ctx.createRadialGradient(...a); }
751
+ createPattern(...a) { return this.ctx.createPattern(...a); }
751
752
 
752
753
  resize(width, height) {
753
- this.canvas.width = width * this.dpr;
754
- this.canvas.height = height * this.dpr;
755
- this.canvas.style.width = `${width}px`;
754
+ this.canvas.width = width * this.dpr;
755
+ this.canvas.height = height * this.dpr;
756
+ this.canvas.style.width = `${width}px`;
756
757
  this.canvas.style.height = `${height}px`;
758
+ // Reset transform avant scale pour éviter accumulation
759
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
757
760
  this.ctx.scale(this.dpr, this.dpr);
758
-
759
- // ✅ NOUVEAU : Update viewport
760
761
  this.updateViewport(0, 0, width, height);
761
762
  }
762
763
 
763
764
  destroy() {
765
+ clearInterval(this._cleanupTimer);
766
+
764
767
  if (this.gl) {
765
768
  const gl = this.gl;
766
- this.textCache.forEach(entry => {
767
- if (entry.texture) gl.deleteTexture(entry.texture);
768
- });
769
- gl.deleteBuffer(this.positionBuffer);
770
- gl.deleteBuffer(this.texCoordBuffer);
771
- gl.deleteProgram(this.program);
769
+ gl.deleteTexture(this._atlasTex);
770
+ gl.deleteBuffer(this._quadBuf);
771
+ gl.deleteBuffer(this._instanceBuf);
772
+ gl.deleteProgram(this._program);
772
773
  }
773
-
774
- if (this._textCleanupInterval) {
775
- clearInterval(this._textCleanupInterval);
776
- }
777
-
778
- this.clearCaches();
774
+
775
+ this._atlasGlyphs.clear();
776
+ this._fontMetrics.clear();
777
+ if (this._colorCache) this._colorCache.clear();
779
778
  }
780
779
  }
781
780