q5 2.14.4 → 2.15.0

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,594 +0,0 @@
1
- Q5.renderers.webgpu.text = ($, q) => {
2
- let textShader = Q5.device.createShaderModule({
3
- label: 'MSDF text shader',
4
- code: `
5
- struct Uniforms {
6
- halfWidth: f32,
7
- halfHeight: f32
8
- }
9
- struct VertexParams {
10
- @builtin(vertex_index) vertex : u32,
11
- @builtin(instance_index) instance : u32
12
- }
13
- struct FragmentParams {
14
- @builtin(position) position : vec4f,
15
- @location(0) texCoord : vec2f,
16
- @location(1) fillColor : vec4f
17
- }
18
- struct Char {
19
- texOffset: vec2f,
20
- texExtent: vec2f,
21
- size: vec2f,
22
- offset: vec2f,
23
- }
24
- struct Text {
25
- pos: vec2f,
26
- scale: f32,
27
- matrixIndex: f32,
28
- fillIndex: f32,
29
- strokeIndex: f32
30
- }
31
-
32
- @group(0) @binding(0) var<uniform> uniforms: Uniforms;
33
- @group(0) @binding(1) var<storage> transforms: array<mat4x4<f32>>;
34
- @group(0) @binding(2) var<storage> colors : array<vec4f>;
35
-
36
- @group(1) @binding(0) var fontTexture: texture_2d<f32>;
37
- @group(1) @binding(1) var fontSampler: sampler;
38
- @group(1) @binding(2) var<storage> fontChars: array<Char>;
39
-
40
- @group(2) @binding(0) var<storage> textChars: array<vec4f>;
41
- @group(2) @binding(1) var<storage> textMetadata: array<Text>;
42
-
43
- const quad = array(vec2f(0, -1), vec2f(1, -1), vec2f(0, 0), vec2f(1, 0));
44
-
45
- @vertex
46
- fn vertexMain(v : VertexParams) -> FragmentParams {
47
- let char = textChars[v.instance];
48
-
49
- let text = textMetadata[i32(char.w)];
50
-
51
- let fontChar = fontChars[i32(char.z)];
52
-
53
- let charPos = ((quad[v.vertex] * fontChar.size + char.xy + fontChar.offset) * text.scale) + text.pos;
54
-
55
- var vert = vec4f(charPos, 0.0, 1.0);
56
- vert = transforms[i32(text.matrixIndex)] * vert;
57
- vert.x /= uniforms.halfWidth;
58
- vert.y /= uniforms.halfHeight;
59
-
60
- var f : FragmentParams;
61
- f.position = vert;
62
- f.texCoord = (quad[v.vertex] * vec2f(1, -1)) * fontChar.texExtent + fontChar.texOffset;
63
- f.fillColor = colors[i32(text.fillIndex)];
64
- return f;
65
- }
66
-
67
- fn sampleMsdf(texCoord: vec2f) -> f32 {
68
- let c = textureSample(fontTexture, fontSampler, texCoord);
69
- return max(min(c.r, c.g), min(max(c.r, c.g), c.b));
70
- }
71
-
72
- @fragment
73
- fn fragmentMain(f : FragmentParams) -> @location(0) vec4f {
74
- // pxRange (AKA distanceRange) comes from the msdfgen tool,
75
- // uses the default which is 4.
76
- let pxRange = 4.0;
77
- let sz = vec2f(textureDimensions(fontTexture, 0));
78
- let dx = sz.x*length(vec2f(dpdxFine(f.texCoord.x), dpdyFine(f.texCoord.x)));
79
- let dy = sz.y*length(vec2f(dpdxFine(f.texCoord.y), dpdyFine(f.texCoord.y)));
80
- let toPixels = pxRange * inverseSqrt(dx * dx + dy * dy);
81
- let sigDist = sampleMsdf(f.texCoord) - 0.5;
82
- let pxDist = sigDist * toPixels;
83
- let edgeWidth = 0.5;
84
- let alpha = smoothstep(-edgeWidth, edgeWidth, pxDist);
85
- if (alpha < 0.001) {
86
- discard;
87
- }
88
- return vec4f(f.fillColor.rgb, f.fillColor.a * alpha);
89
- }
90
- `
91
- });
92
-
93
- let textBindGroupLayout = Q5.device.createBindGroupLayout({
94
- label: 'MSDF text group layout',
95
- entries: [
96
- {
97
- binding: 0,
98
- visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
99
- buffer: { type: 'read-only-storage' }
100
- },
101
- {
102
- binding: 1,
103
- visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
104
- buffer: { type: 'read-only-storage' }
105
- }
106
- ]
107
- });
108
-
109
- let fontSampler = Q5.device.createSampler({
110
- minFilter: 'linear',
111
- magFilter: 'linear',
112
- mipmapFilter: 'linear',
113
- maxAnisotropy: 16
114
- });
115
-
116
- let fontBindGroupLayout = Q5.device.createBindGroupLayout({
117
- label: 'MSDF font group layout',
118
- entries: [
119
- {
120
- binding: 0,
121
- visibility: GPUShaderStage.FRAGMENT,
122
- texture: {}
123
- },
124
- {
125
- binding: 1,
126
- visibility: GPUShaderStage.FRAGMENT,
127
- sampler: {}
128
- },
129
- {
130
- binding: 2,
131
- visibility: GPUShaderStage.VERTEX,
132
- buffer: { type: 'read-only-storage' }
133
- }
134
- ]
135
- });
136
-
137
- let fontPipelineLayout = Q5.device.createPipelineLayout({
138
- bindGroupLayouts: [...$.bindGroupLayouts, fontBindGroupLayout, textBindGroupLayout]
139
- });
140
-
141
- $._pipelineConfigs[2] = {
142
- label: 'msdf font pipeline',
143
- layout: fontPipelineLayout,
144
- vertex: { module: textShader, entryPoint: 'vertexMain' },
145
- fragment: {
146
- module: textShader,
147
- entryPoint: 'fragmentMain',
148
- targets: [{ format: 'bgra8unorm', blend: $.blendConfigs.normal }]
149
- },
150
- primitive: { topology: 'triangle-strip', stripIndexFormat: 'uint32' },
151
- multisample: { count: 4 }
152
- };
153
-
154
- $._pipelines[2] = Q5.device.createRenderPipeline($._pipelineConfigs[2]);
155
-
156
- class MsdfFont {
157
- constructor(bindGroup, lineHeight, chars, kernings) {
158
- this.bindGroup = bindGroup;
159
- this.lineHeight = lineHeight;
160
- this.chars = chars;
161
- this.kernings = kernings;
162
- let charArray = Object.values(chars);
163
- this.charCount = charArray.length;
164
- this.defaultChar = charArray[0];
165
- }
166
- getChar(charCode) {
167
- return this.chars[charCode] ?? this.defaultChar;
168
- }
169
- // Gets the distance in pixels a line should advance for a given
170
- // character code. If the upcoming character code is given any
171
- // kerning between the two characters will be taken into account.
172
- getXAdvance(charCode, nextCharCode = -1) {
173
- let char = this.getChar(charCode);
174
- if (nextCharCode >= 0) {
175
- let kerning = this.kernings.get(charCode);
176
- if (kerning) {
177
- return char.xadvance + (kerning.get(nextCharCode) ?? 0);
178
- }
179
- }
180
- return char.xadvance;
181
- }
182
- }
183
-
184
- $._fonts = [];
185
- let fonts = {};
186
-
187
- let createFont = async (fontJsonUrl, fontName, cb) => {
188
- q._preloadCount++;
189
-
190
- let res = await fetch(fontJsonUrl);
191
- if (res.status == 404) {
192
- q._preloadCount--;
193
- return '';
194
- }
195
- let atlas = await res.json();
196
-
197
- let slashIdx = fontJsonUrl.lastIndexOf('/');
198
- let baseUrl = slashIdx != -1 ? fontJsonUrl.substring(0, slashIdx + 1) : '';
199
- // load font image
200
- res = await fetch(baseUrl + atlas.pages[0]);
201
- let img = await createImageBitmap(await res.blob());
202
-
203
- // convert image to texture
204
- let imgSize = [img.width, img.height, 1];
205
- let texture = Q5.device.createTexture({
206
- label: `MSDF ${fontName}`,
207
- size: imgSize,
208
- format: 'rgba8unorm',
209
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
210
- });
211
- Q5.device.queue.copyExternalImageToTexture({ source: img }, { texture }, imgSize);
212
-
213
- // chars and kernings can be stored as csv strings, making the file
214
- // size smaller, but they need to be parsed into arrays of objects
215
- if (typeof atlas.chars == 'string') {
216
- atlas.chars = $.CSV.parse(atlas.chars, ' ');
217
- atlas.kernings = $.CSV.parse(atlas.kernings, ' ');
218
- }
219
-
220
- let charCount = atlas.chars.length;
221
- let charsBuffer = Q5.device.createBuffer({
222
- size: charCount * 32,
223
- usage: GPUBufferUsage.STORAGE,
224
- mappedAtCreation: true
225
- });
226
-
227
- let fontChars = new Float32Array(charsBuffer.getMappedRange());
228
- let u = 1 / atlas.common.scaleW;
229
- let v = 1 / atlas.common.scaleH;
230
- let chars = {};
231
- let o = 0; // offset
232
- for (let [i, char] of atlas.chars.entries()) {
233
- chars[char.id] = char;
234
- chars[char.id].charIndex = i;
235
- fontChars[o] = char.x * u; // texOffset.x
236
- fontChars[o + 1] = char.y * v; // texOffset.y
237
- fontChars[o + 2] = char.width * u; // texExtent.x
238
- fontChars[o + 3] = char.height * v; // texExtent.y
239
- fontChars[o + 4] = char.width; // size.x
240
- fontChars[o + 5] = char.height; // size.y
241
- fontChars[o + 6] = char.xoffset; // offset.x
242
- fontChars[o + 7] = -char.yoffset; // offset.y
243
- o += 8;
244
- }
245
- charsBuffer.unmap();
246
-
247
- let fontBindGroup = Q5.device.createBindGroup({
248
- label: 'msdf font bind group',
249
- layout: fontBindGroupLayout,
250
- entries: [
251
- { binding: 0, resource: texture.createView() },
252
- { binding: 1, resource: fontSampler },
253
- { binding: 2, resource: { buffer: charsBuffer } }
254
- ]
255
- });
256
-
257
- let kernings = new Map();
258
- if (atlas.kernings) {
259
- for (let kerning of atlas.kernings) {
260
- let charKerning = kernings.get(kerning.first);
261
- if (!charKerning) {
262
- charKerning = new Map();
263
- kernings.set(kerning.first, charKerning);
264
- }
265
- charKerning.set(kerning.second, kerning.amount);
266
- }
267
- }
268
-
269
- $._font = new MsdfFont(fontBindGroup, atlas.common.lineHeight, chars, kernings);
270
-
271
- $._font.index = $._fonts.length;
272
- $._fonts.push($._font);
273
- fonts[fontName] = $._font;
274
-
275
- q._preloadCount--;
276
-
277
- if (cb) cb(fontName);
278
- };
279
-
280
- $._g.colorMode($.RGB, 1);
281
-
282
- $.loadFont = (url, cb) => {
283
- let ext = url.slice(url.lastIndexOf('.') + 1);
284
- if (ext != 'json') return $._g.loadFont(url, cb);
285
- let fontName = url.slice(url.lastIndexOf('/') + 1, url.lastIndexOf('-'));
286
- createFont(url, fontName, cb);
287
- return fontName;
288
- };
289
-
290
- $._loadDefaultFont = (fontName) => {
291
- fonts[fontName] = null;
292
- if (navigator.onLine) {
293
- $.loadFont(`https://q5js.org/fonts/${fontName}-msdf.json`);
294
- } else {
295
- $.loadFont(`/node_modules/q5/builtinFonts/${fontName}-msdf.json`);
296
- }
297
- };
298
-
299
- $._textSize = 18;
300
- $._textAlign = 'left';
301
- $._textBaseline = 'alphabetic';
302
- let leadingSet = false,
303
- leading = 22.5,
304
- leadDiff = 4.5,
305
- leadPercent = 1.25;
306
-
307
- $.textFont = (fontName) => {
308
- let font = fonts[fontName];
309
- if (font) $._font = font;
310
- else if (font === undefined) $._loadDefaultFont(fontName);
311
- };
312
-
313
- $.textSize = (size) => {
314
- $._textSize = size;
315
- if (!leadingSet) {
316
- leading = size * leadPercent;
317
- leadDiff = leading - size;
318
- }
319
- };
320
-
321
- $.textLeading = (lineHeight) => {
322
- $._font.lineHeight = leading = lineHeight;
323
- leadDiff = leading - $._textSize;
324
- leadPercent = leading / $._textSize;
325
- leadingSet = true;
326
- };
327
-
328
- $.textAlign = (horiz, vert) => {
329
- $._textAlign = horiz;
330
- if (vert) $._textBaseline = vert;
331
- };
332
-
333
- let charStack = [],
334
- textStack = [];
335
-
336
- let measureText = (font, text, charCallback) => {
337
- let maxWidth = 0,
338
- offsetX = 0,
339
- offsetY = 0,
340
- line = 0,
341
- printedCharCount = 0,
342
- lineWidths = [],
343
- nextCharCode = text.charCodeAt(0);
344
-
345
- for (let i = 0; i < text.length; ++i) {
346
- let charCode = nextCharCode;
347
- nextCharCode = i < text.length - 1 ? text.charCodeAt(i + 1) : -1;
348
- switch (charCode) {
349
- case 10: // newline
350
- lineWidths.push(offsetX);
351
- line++;
352
- maxWidth = Math.max(maxWidth, offsetX);
353
- offsetX = 0;
354
- offsetY -= font.lineHeight * leadPercent;
355
- break;
356
- case 13: // CR
357
- break;
358
- case 32: // space
359
- // advance the offset without actually adding a character
360
- offsetX += font.getXAdvance(charCode);
361
- break;
362
- case 9: // tab
363
- offsetX += font.getXAdvance(charCode) * 2;
364
- break;
365
- default:
366
- if (charCallback) {
367
- charCallback(offsetX, offsetY, line, font.getChar(charCode));
368
- }
369
- offsetX += font.getXAdvance(charCode, nextCharCode);
370
- printedCharCount++;
371
- }
372
- }
373
- lineWidths.push(offsetX);
374
- maxWidth = Math.max(maxWidth, offsetX);
375
- return {
376
- width: maxWidth,
377
- height: lineWidths.length * font.lineHeight * leadPercent,
378
- lineWidths,
379
- printedCharCount
380
- };
381
- };
382
-
383
- $.text = (str, x, y, w, h) => {
384
- if (!$._font) {
385
- // if the default font hasn't been loaded yet, try to load it
386
- if ($._font !== null) $.textFont('sans-serif');
387
- return;
388
- }
389
-
390
- let type = typeof str;
391
- if (type != 'string') {
392
- if (type == 'object') str = str.toString();
393
- else str = str + '';
394
- }
395
-
396
- if (str.length > w) {
397
- let wrapped = [];
398
- let i = 0;
399
- while (i < str.length && wrapped.length < h) {
400
- let max = i + w;
401
- if (max >= str.length) {
402
- wrapped.push(str.slice(i));
403
- break;
404
- }
405
- let end = str.lastIndexOf(' ', max);
406
- if (end == -1 || end < i) end = max;
407
- wrapped.push(str.slice(i, end));
408
- i = end + 1;
409
- }
410
- str = wrapped.join('\n');
411
- }
412
-
413
- let spaces = 0, // whitespace char count, not literal spaces
414
- hasNewline;
415
- for (let i = 0; i < str.length; i++) {
416
- let c = str[i];
417
- switch (c) {
418
- case '\n':
419
- hasNewline = true;
420
- case '\r':
421
- case '\t':
422
- case ' ':
423
- spaces++;
424
- }
425
- }
426
-
427
- let charsData = [];
428
-
429
- let ta = $._textAlign,
430
- tb = $._textBaseline,
431
- textIndex = textStack.length,
432
- o = 0, // offset
433
- measurements;
434
-
435
- if (ta == 'left' && !hasNewline) {
436
- measurements = measureText($._font, str, (textX, textY, line, char) => {
437
- charsData[o] = textX;
438
- charsData[o + 1] = textY;
439
- charsData[o + 2] = char.charIndex;
440
- charsData[o + 3] = textIndex;
441
- o += 4;
442
- });
443
-
444
- if (tb == 'alphabetic') y -= $._textSize;
445
- else if (tb == 'center') y -= $._textSize * 0.5;
446
- else if (tb == 'bottom') y -= leading;
447
- } else {
448
- // measure the text to get the line widths before setting
449
- // the x position to properly align the text
450
- measurements = measureText($._font, str);
451
-
452
- let offsetY = 0;
453
- if (tb == 'alphabetic') y -= $._textSize;
454
- else if (tb == 'center') offsetY = measurements.height * 0.5;
455
- else if (tb == 'bottom') offsetY = measurements.height;
456
-
457
- measureText($._font, str, (textX, textY, line, char) => {
458
- let offsetX = 0;
459
- if (ta == 'center') {
460
- offsetX = measurements.width * -0.5 - (measurements.width - measurements.lineWidths[line]) * -0.5;
461
- } else if (ta == 'right') {
462
- offsetX = -measurements.lineWidths[line];
463
- }
464
- charsData[o] = textX + offsetX;
465
- charsData[o + 1] = textY + offsetY;
466
- charsData[o + 2] = char.charIndex;
467
- charsData[o + 3] = textIndex;
468
- o += 4;
469
- });
470
- }
471
- charStack.push(charsData);
472
-
473
- let txt = [];
474
-
475
- if ($._matrixDirty) $._saveMatrix();
476
-
477
- txt[0] = x;
478
- txt[1] = -y;
479
- txt[2] = $._textSize / 44;
480
- txt[3] = $._matrixIndex;
481
- txt[4] = $._fillSet ? $._fill : 0;
482
- txt[5] = $._stroke;
483
-
484
- textStack.push(txt);
485
- $.drawStack.push(2, measurements.printedCharCount, $._font.index);
486
- };
487
-
488
- $.textWidth = (str) => {
489
- if (!$._font) return 0;
490
- return measureText($._font, str).width;
491
- };
492
-
493
- $.createTextImage = (str, w, h) => {
494
- $._g.textSize($._textSize);
495
-
496
- if ($._doFill) {
497
- let fi = $._fill * 4;
498
- $._g.fill(colorStack.slice(fi, fi + 4));
499
- }
500
- if ($._doStroke) {
501
- let si = $._stroke * 4;
502
- $._g.stroke(colorStack.slice(si, si + 4));
503
- }
504
-
505
- let img = $._g.createTextImage(str, w, h);
506
-
507
- if (img.canvas.textureIndex == undefined) {
508
- $._createTexture(img);
509
- } else if (img.modified) {
510
- let cnv = img.canvas;
511
- let textureSize = [cnv.width, cnv.height, 1];
512
- let texture = $._textures[cnv.textureIndex];
513
-
514
- Q5.device.queue.copyExternalImageToTexture(
515
- { source: cnv },
516
- { texture, colorSpace: $.canvas.colorSpace },
517
- textureSize
518
- );
519
- img.modified = false;
520
- }
521
- return img;
522
- };
523
-
524
- $.textImage = (img, x, y) => {
525
- if (typeof img == 'string') img = $.createTextImage(img);
526
-
527
- let og = $._imageMode;
528
- $._imageMode = 'corner';
529
-
530
- let ta = $._textAlign;
531
- if (ta == 'center') x -= img.canvas.hw;
532
- else if (ta == 'right') x -= img.width;
533
-
534
- let bl = $._textBaseline;
535
- if (bl == 'alphabetic') y -= img._leading;
536
- else if (bl == 'center') y -= img._middle;
537
- else if (bl == 'bottom') y -= img._bottom;
538
- else if (bl == 'top') y -= img._top;
539
-
540
- $.image(img, x, y);
541
- $._imageMode = og;
542
- };
543
-
544
- $._hooks.preRender.push(() => {
545
- if (!charStack.length) return;
546
-
547
- // calculate total buffer size for text data
548
- let totalTextSize = 0;
549
- for (let charsData of charStack) {
550
- totalTextSize += charsData.length * 4;
551
- }
552
-
553
- // create a single buffer for all the char data
554
- let charBuffer = Q5.device.createBuffer({
555
- size: totalTextSize,
556
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
557
- mappedAtCreation: true
558
- });
559
-
560
- // copy all the text data into the buffer
561
- new Float32Array(charBuffer.getMappedRange()).set(charStack.flat());
562
- charBuffer.unmap();
563
-
564
- // calculate total buffer size for metadata
565
- let totalMetadataSize = textStack.length * 6 * 4;
566
-
567
- // create a single buffer for all metadata
568
- let textBuffer = Q5.device.createBuffer({
569
- label: 'textBuffer',
570
- size: totalMetadataSize,
571
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
572
- mappedAtCreation: true
573
- });
574
-
575
- // copy all metadata into the buffer
576
- new Float32Array(textBuffer.getMappedRange()).set(textStack.flat());
577
- textBuffer.unmap();
578
-
579
- // create a single bind group for the text buffer and metadata buffer
580
- $._textBindGroup = Q5.device.createBindGroup({
581
- label: 'msdf text bind group',
582
- layout: textBindGroupLayout,
583
- entries: [
584
- { binding: 0, resource: { buffer: charBuffer } },
585
- { binding: 1, resource: { buffer: textBuffer } }
586
- ]
587
- });
588
- });
589
-
590
- $._hooks.postRender.push(() => {
591
- charStack = [];
592
- textStack = [];
593
- });
594
- };