canvasframework 0.6.1 → 0.6.3
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.
- package/core/CanvasFramework.js +94 -121
- package/core/WebGLCanvasAdapter.js +640 -641
- package/package.json +1 -1
- package/utils/AnimationEngine.js +2 -2
|
@@ -1,781 +1,780 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
|
25
|
+
this.dpr = options.dpr || window.devicePixelRatio || 1;
|
|
10
26
|
|
|
11
|
-
//
|
|
27
|
+
// ── Canvas 2D pour tout sauf le texte ──────────────────────────────
|
|
12
28
|
this.ctx = this.canvas.getContext('2d', {
|
|
13
|
-
alpha:
|
|
14
|
-
desynchronized:
|
|
15
|
-
willReadFrequently:
|
|
29
|
+
alpha: options.alpha !== false,
|
|
30
|
+
desynchronized: true,
|
|
31
|
+
willReadFrequently: false
|
|
16
32
|
});
|
|
17
33
|
|
|
18
|
-
//
|
|
19
|
-
this.
|
|
20
|
-
this.
|
|
21
|
-
this.
|
|
22
|
-
this.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
//
|
|
54
|
-
|
|
55
|
-
this.
|
|
56
|
-
|
|
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
|
-
//
|
|
60
|
-
this.
|
|
61
|
-
points: [],
|
|
62
|
-
rects: [],
|
|
63
|
-
maxPoolSize: 100
|
|
64
|
-
};
|
|
83
|
+
// ── Métriques polices (cache) ──────────────────────────────────────
|
|
84
|
+
this._fontMetrics = new Map();
|
|
65
85
|
|
|
66
|
-
//
|
|
67
|
-
this.
|
|
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
|
-
//
|
|
85
|
-
this.
|
|
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
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
:
|
|
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
|
-
|
|
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.
|
|
131
|
+
this._isWebGL2 = !!gl;
|
|
132
|
+
this._setupShaders();
|
|
133
|
+
this._setupBuffers();
|
|
134
|
+
this._setupAtlasTexture();
|
|
154
135
|
}
|
|
155
136
|
|
|
156
|
-
|
|
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
|
-
|
|
165
|
-
|
|
155
|
+
|
|
156
|
+
out vec2 v_uv;
|
|
157
|
+
out vec4 v_color;
|
|
158
|
+
|
|
166
159
|
void main() {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
189
|
+
// ── Fragment shader ────────────────────────────────────────────────
|
|
190
|
+
const fs = isGL2 ? `#version 300 es
|
|
174
191
|
precision mediump float;
|
|
175
|
-
uniform sampler2D
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
const fragmentShader = this._createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
|
|
212
|
+
this._program = this._linkProgram(vs, fs);
|
|
185
213
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
gl
|
|
250
|
+
_setupBuffers() {
|
|
251
|
+
const gl = this.gl;
|
|
204
252
|
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
210
|
-
const
|
|
211
|
-
gl.
|
|
212
|
-
gl.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
239
|
-
return
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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.
|
|
299
|
-
|
|
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
|
-
|
|
302
|
-
|
|
366
|
+
this._atlasGlyphs.set(key, glyph);
|
|
367
|
+
this._atlasDirty = true;
|
|
368
|
+
this.stats.glyphsCached++;
|
|
303
369
|
|
|
304
|
-
return
|
|
370
|
+
return glyph;
|
|
305
371
|
}
|
|
306
372
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
393
|
+
|
|
394
|
+
this._atlasDirty = true;
|
|
320
395
|
}
|
|
321
396
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
420
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
421
|
+
// BATCH — accumulation des instances
|
|
422
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
359
423
|
|
|
360
|
-
// ────────────────────────────────────────────────
|
|
361
|
-
// ✅ OPTIMISATION : Batch Rendering avec auto-flush
|
|
362
|
-
// ────────────────────────────────────────────────
|
|
363
424
|
beginTextBatch() {
|
|
364
|
-
this.
|
|
365
|
-
this.
|
|
425
|
+
this._batchMode = true;
|
|
426
|
+
this._instanceCount = 0;
|
|
366
427
|
}
|
|
367
428
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
//
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
553
|
+
// 4. Résolution
|
|
554
|
+
gl.uniform2f(loc.resolution, cw / this.dpr, ch / this.dpr);
|
|
484
555
|
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
533
|
-
if (
|
|
534
|
-
else
|
|
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
|
-
|
|
598
|
+
// 9. Copier le résultat WebGL sur le canvas 2D principal
|
|
599
|
+
this.ctx.drawImage(this._glCanvas, 0, 0);
|
|
537
600
|
|
|
538
|
-
this.
|
|
539
|
-
|
|
601
|
+
this.stats.drawCalls++;
|
|
602
|
+
this.stats.instancesDrawn += this._instanceCount;
|
|
540
603
|
}
|
|
541
604
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
571
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
676
|
+
const result = [r, g, b, a];
|
|
677
|
+
this._colorCache.set(color, result);
|
|
678
|
+
return result;
|
|
632
679
|
}
|
|
633
680
|
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
648
|
-
if (this.
|
|
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
|
-
|
|
672
|
-
|
|
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
|
-
//
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
|
|
703
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
set
|
|
723
|
-
get
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
|
754
|
-
this.canvas.height
|
|
755
|
-
this.canvas.style.width
|
|
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.
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
gl.
|
|
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
|
-
|
|
775
|
-
|
|
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
|
|