@bloopjs/toodle 0.1.2 → 0.1.4

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.
Files changed (55) hide show
  1. package/dist/Toodle.d.ts +1 -1
  2. package/dist/Toodle.d.ts.map +1 -1
  3. package/dist/backends/ITextShader.d.ts +15 -0
  4. package/dist/backends/ITextShader.d.ts.map +1 -0
  5. package/dist/backends/mod.d.ts +1 -0
  6. package/dist/backends/mod.d.ts.map +1 -1
  7. package/dist/backends/webgl2/WebGLFontPipeline.d.ts +26 -0
  8. package/dist/backends/webgl2/WebGLFontPipeline.d.ts.map +1 -0
  9. package/dist/backends/webgl2/WebGLTextShader.d.ts +24 -0
  10. package/dist/backends/webgl2/WebGLTextShader.d.ts.map +1 -0
  11. package/dist/backends/webgl2/glsl/text.glsl.d.ts +12 -0
  12. package/dist/backends/webgl2/glsl/text.glsl.d.ts.map +1 -0
  13. package/dist/backends/webgl2/mod.d.ts +2 -0
  14. package/dist/backends/webgl2/mod.d.ts.map +1 -1
  15. package/dist/{text → backends/webgpu}/FontPipeline.d.ts +1 -1
  16. package/dist/backends/webgpu/FontPipeline.d.ts.map +1 -0
  17. package/dist/{text/TextShader.d.ts → backends/webgpu/WebGPUTextShader.d.ts} +7 -7
  18. package/dist/backends/webgpu/WebGPUTextShader.d.ts.map +1 -0
  19. package/dist/backends/webgpu/mod.d.ts +2 -0
  20. package/dist/backends/webgpu/mod.d.ts.map +1 -1
  21. package/dist/backends/webgpu/wgsl/text.wgsl.d.ts.map +1 -0
  22. package/dist/mod.d.ts +1 -1
  23. package/dist/mod.d.ts.map +1 -1
  24. package/dist/mod.js +1832 -1460
  25. package/dist/mod.js.map +17 -14
  26. package/dist/{text → scene}/TextNode.d.ts +5 -5
  27. package/dist/scene/TextNode.d.ts.map +1 -0
  28. package/dist/scene/mod.d.ts +1 -0
  29. package/dist/scene/mod.d.ts.map +1 -1
  30. package/dist/text/mod.d.ts +1 -3
  31. package/dist/text/mod.d.ts.map +1 -1
  32. package/dist/textures/AssetManager.d.ts +9 -6
  33. package/dist/textures/AssetManager.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/src/Toodle.ts +1 -1
  36. package/src/backends/ITextShader.ts +15 -0
  37. package/src/backends/mod.ts +1 -0
  38. package/src/backends/webgl2/WebGLFontPipeline.ts +173 -0
  39. package/src/backends/webgl2/WebGLTextShader.ts +275 -0
  40. package/src/backends/webgl2/glsl/text.glsl.ts +132 -0
  41. package/src/backends/webgl2/mod.ts +2 -0
  42. package/src/{text → backends/webgpu}/FontPipeline.ts +2 -2
  43. package/src/{text/TextShader.ts → backends/webgpu/WebGPUTextShader.ts} +14 -10
  44. package/src/backends/webgpu/mod.ts +2 -0
  45. package/src/mod.ts +1 -1
  46. package/src/{text → scene}/TextNode.ts +6 -6
  47. package/src/scene/mod.ts +1 -0
  48. package/src/text/mod.ts +1 -4
  49. package/src/textures/AssetManager.ts +46 -31
  50. package/dist/text/FontPipeline.d.ts.map +0 -1
  51. package/dist/text/TextNode.d.ts.map +0 -1
  52. package/dist/text/TextShader.d.ts.map +0 -1
  53. package/dist/text/text.wgsl.d.ts.map +0 -1
  54. /package/dist/{text → backends/webgpu/wgsl}/text.wgsl.d.ts +0 -0
  55. /package/src/{text → backends/webgpu/wgsl}/text.wgsl.ts +0 -0
package/dist/mod.js CHANGED
@@ -17219,150 +17219,414 @@ class WebGPUBackend {
17219
17219
  return this.#renderPass;
17220
17220
  }
17221
17221
  }
17222
- // src/math/matrix.ts
17223
- function createProjectionMatrix(resolution, dst) {
17224
- const { width, height } = resolution;
17225
- return mat3.scaling([2 / width, 2 / height], dst);
17226
- }
17227
- function createViewMatrix(camera, target) {
17228
- const matrix = mat3.identity(target);
17229
- mat3.scale(matrix, [camera.zoom, camera.zoom], matrix);
17230
- mat3.rotate(matrix, camera.rotationRadians, matrix);
17231
- mat3.translate(matrix, [-camera.x, -camera.y], matrix);
17232
- return matrix;
17233
- }
17234
- function createModelMatrix(transform, base) {
17235
- mat3.translate(base, [transform.position.x, transform.position.y], base);
17236
- mat3.rotate(base, transform.rotation, base);
17237
- mat3.scale(base, [transform.scale.x, transform.scale.y], base);
17238
- return base;
17239
- }
17240
- function convertScreenToWorld(screenCoordinates, camera, projectionMatrix, resolution) {
17241
- const inverseViewProjectionMatrix = mat3.mul(mat3.inverse(camera.matrix), mat3.inverse(projectionMatrix));
17242
- const normalizedDeviceCoordinates = {
17243
- x: 2 * screenCoordinates.x / resolution.width - 1,
17244
- y: 1 - 2 * screenCoordinates.y / resolution.height
17245
- };
17246
- return transformPoint(normalizedDeviceCoordinates, inverseViewProjectionMatrix);
17247
- }
17248
- function convertWorldToScreen(worldCoordinates, camera, projectionMatrix, resolution) {
17249
- const viewProjectionMatrix = mat3.mul(projectionMatrix, camera.matrix);
17250
- const ndcPoint = transformPoint(worldCoordinates, viewProjectionMatrix);
17251
- return {
17252
- x: (ndcPoint.x + 1) * resolution.width / 2,
17253
- y: (1 - ndcPoint.y) * resolution.height / 2
17254
- };
17222
+ // src/backends/webgl2/WebGLFontPipeline.ts
17223
+ class WebGLFontPipeline {
17224
+ font;
17225
+ fontTexture;
17226
+ charDataTexture;
17227
+ textBufferTexture;
17228
+ maxCharCount;
17229
+ lineHeight;
17230
+ #gl;
17231
+ constructor(gl, font, fontTexture, charDataTexture, textBufferTexture, maxCharCount) {
17232
+ this.#gl = gl;
17233
+ this.font = font;
17234
+ this.fontTexture = fontTexture;
17235
+ this.charDataTexture = charDataTexture;
17236
+ this.textBufferTexture = textBufferTexture;
17237
+ this.maxCharCount = maxCharCount;
17238
+ this.lineHeight = font.lineHeight;
17239
+ }
17240
+ static create(gl, font, maxCharCount) {
17241
+ const fontTexture = gl.createTexture();
17242
+ assert(fontTexture, "Failed to create font texture");
17243
+ gl.bindTexture(gl.TEXTURE_2D, fontTexture);
17244
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
17245
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
17246
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
17247
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
17248
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, font.imageBitmap);
17249
+ const charDataTexture = gl.createTexture();
17250
+ assert(charDataTexture, "Failed to create char data texture");
17251
+ gl.bindTexture(gl.TEXTURE_2D, charDataTexture);
17252
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
17253
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
17254
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
17255
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
17256
+ const charCount = font.charCount;
17257
+ const charTextureWidth = charCount * 2;
17258
+ const charTextureData = new Float32Array(charTextureWidth * 4);
17259
+ for (let i3 = 0;i3 < charCount; i3++) {
17260
+ const srcOffset = i3 * 8;
17261
+ const dstOffset0 = i3 * 2 * 4;
17262
+ const dstOffset1 = (i3 * 2 + 1) * 4;
17263
+ charTextureData[dstOffset0] = font.charBuffer[srcOffset];
17264
+ charTextureData[dstOffset0 + 1] = font.charBuffer[srcOffset + 1];
17265
+ charTextureData[dstOffset0 + 2] = font.charBuffer[srcOffset + 2];
17266
+ charTextureData[dstOffset0 + 3] = font.charBuffer[srcOffset + 3];
17267
+ charTextureData[dstOffset1] = font.charBuffer[srcOffset + 4];
17268
+ charTextureData[dstOffset1 + 1] = font.charBuffer[srcOffset + 5];
17269
+ charTextureData[dstOffset1 + 2] = font.charBuffer[srcOffset + 6];
17270
+ charTextureData[dstOffset1 + 3] = font.charBuffer[srcOffset + 7];
17271
+ }
17272
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, charTextureWidth, 1, 0, gl.RGBA, gl.FLOAT, charTextureData);
17273
+ const textBufferTexture = gl.createTexture();
17274
+ assert(textBufferTexture, "Failed to create text buffer texture");
17275
+ gl.bindTexture(gl.TEXTURE_2D, textBufferTexture);
17276
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
17277
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
17278
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
17279
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
17280
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, maxCharCount, 1, 0, gl.RGBA, gl.FLOAT, null);
17281
+ gl.bindTexture(gl.TEXTURE_2D, null);
17282
+ return new WebGLFontPipeline(gl, font, fontTexture, charDataTexture, textBufferTexture, maxCharCount);
17283
+ }
17284
+ updateTextBuffer(data, glyphCount) {
17285
+ const gl = this.#gl;
17286
+ gl.bindTexture(gl.TEXTURE_2D, this.textBufferTexture);
17287
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, glyphCount, 1, gl.RGBA, gl.FLOAT, data);
17288
+ }
17289
+ destroy() {
17290
+ const gl = this.#gl;
17291
+ gl.deleteTexture(this.fontTexture);
17292
+ gl.deleteTexture(this.charDataTexture);
17293
+ gl.deleteTexture(this.textBufferTexture);
17294
+ }
17255
17295
  }
17256
- function transformPoint(point, matrix) {
17257
- const result = vec2.transformMat3([point.x, point.y], matrix);
17258
- return {
17259
- x: result[0],
17260
- y: result[1]
17261
- };
17296
+
17297
+ // src/utils/error.ts
17298
+ var warnings = new Map;
17299
+ function warnOnce(key, msg) {
17300
+ if (warnings.has(key)) {
17301
+ return;
17302
+ }
17303
+ warnings.set(key, true);
17304
+ console.warn(msg ?? key);
17262
17305
  }
17263
17306
 
17264
- // src/scene/Batcher.ts
17265
- class Batcher {
17266
- nodes = [];
17267
- layers = [];
17268
- pipelines = [];
17269
- enqueue(node) {
17270
- if (node.renderComponent && node.isActive) {
17271
- this.nodes.push(node);
17272
- const z3 = node.layer;
17273
- const layer = this.#findOrCreateLayer(z3);
17274
- const pipeline = this.#findOrCreatePipeline(layer, node.renderComponent.shader);
17275
- pipeline.nodes.push(node);
17307
+ // src/text/MsdfFont.ts
17308
+ class MsdfFont {
17309
+ id;
17310
+ json;
17311
+ imageBitmap;
17312
+ name;
17313
+ charset;
17314
+ charCount;
17315
+ lineHeight;
17316
+ charBuffer;
17317
+ #kernings;
17318
+ #chars;
17319
+ #fallbackCharCode;
17320
+ constructor(id, json, imageBitmap) {
17321
+ this.id = id;
17322
+ this.json = json;
17323
+ this.imageBitmap = imageBitmap;
17324
+ const charArray = Object.values(json.chars);
17325
+ this.charCount = charArray.length;
17326
+ this.lineHeight = json.common.lineHeight;
17327
+ this.charset = json.info.charset;
17328
+ this.name = json.info.face;
17329
+ this.#kernings = new Map;
17330
+ if (json.kernings) {
17331
+ for (const kearning of json.kernings) {
17332
+ let charKerning = this.#kernings.get(kearning.first);
17333
+ if (!charKerning) {
17334
+ charKerning = new Map;
17335
+ this.#kernings.set(kearning.first, charKerning);
17336
+ }
17337
+ charKerning.set(kearning.second, kearning.amount);
17338
+ }
17276
17339
  }
17277
- for (const kid of node.kids) {
17278
- this.enqueue(kid);
17340
+ this.#chars = new Map;
17341
+ const charCount = Object.values(json.chars).length;
17342
+ this.charBuffer = new Float32Array(charCount * 8);
17343
+ let offset = 0;
17344
+ const u3 = 1 / json.common.scaleW;
17345
+ const v3 = 1 / json.common.scaleH;
17346
+ for (const [i3, char] of json.chars.entries()) {
17347
+ this.#chars.set(char.id, char);
17348
+ this.#chars.get(char.id).charIndex = i3;
17349
+ this.charBuffer[offset] = char.x * u3;
17350
+ this.charBuffer[offset + 1] = char.y * v3;
17351
+ this.charBuffer[offset + 2] = char.width * u3;
17352
+ this.charBuffer[offset + 3] = char.height * v3;
17353
+ this.charBuffer[offset + 4] = char.width;
17354
+ this.charBuffer[offset + 5] = char.height;
17355
+ this.charBuffer[offset + 6] = char.xoffset;
17356
+ this.charBuffer[offset + 7] = -char.yoffset;
17357
+ offset += 8;
17279
17358
  }
17280
17359
  }
17281
- flush() {
17282
- this.nodes = [];
17283
- this.layers = [];
17284
- this.pipelines = [];
17360
+ getChar(charCode) {
17361
+ const char = this.#chars.get(charCode);
17362
+ if (!char) {
17363
+ const fallbackCharacter = this.#chars.get(this.#fallbackCharCode ?? this.#chars.keys().toArray()[0]);
17364
+ warnOnce(`unknown_char_${this.name}`, `Couldn't find character ${charCode} in characters for font ${this.name} -- defaulting to first available character "${fallbackCharacter.char}"`);
17365
+ return fallbackCharacter;
17366
+ }
17367
+ return char;
17285
17368
  }
17286
- #findOrCreateLayer(z3) {
17287
- let layer = this.layers.find((l3) => l3.z === z3);
17288
- if (!layer) {
17289
- layer = { z: z3, pipelines: [] };
17290
- this.layers.push(layer);
17291
- this.layers.sort((a3, b3) => a3.z - b3.z);
17369
+ getXAdvance(charCode, nextCharCode = -1) {
17370
+ const char = this.getChar(charCode);
17371
+ if (nextCharCode >= 0) {
17372
+ const kerning = this.#kernings.get(charCode);
17373
+ if (kerning) {
17374
+ return char.xadvance + (kerning.get(nextCharCode) ?? 0);
17375
+ }
17292
17376
  }
17293
- return layer;
17377
+ return char.xadvance;
17294
17378
  }
17295
- #findOrCreatePipeline(layer, shader) {
17296
- let pipeline = layer.pipelines.find((p3) => p3.shader === shader);
17297
- if (!pipeline) {
17298
- pipeline = { shader, nodes: [] };
17299
- layer.pipelines.push(pipeline);
17300
- this.pipelines.push(pipeline);
17379
+ static async create(id, fontJsonUrl) {
17380
+ const response = await fetch(fontJsonUrl);
17381
+ const json = await response.json();
17382
+ const i3 = fontJsonUrl.href.lastIndexOf("/");
17383
+ const baseUrl = i3 !== -1 ? fontJsonUrl.href.substring(0, i3 + 1) : undefined;
17384
+ if (json.pages.length < 1) {
17385
+ throw new Error(`Can't create an msdf font without a reference to the page url in the json`);
17386
+ }
17387
+ if (json.pages.length > 1) {
17388
+ throw new Error(`Can't create an msdf font with more than one page`);
17389
+ }
17390
+ const textureUrl = baseUrl + json.pages[0];
17391
+ const textureResponse = await fetch(textureUrl);
17392
+ const bitmap = await createImageBitmap(await textureResponse.blob());
17393
+ return new MsdfFont(id, json, bitmap);
17394
+ }
17395
+ set fallbackCharacter(character) {
17396
+ const charCode = character.charCodeAt(0);
17397
+ if (this.#chars.has(charCode)) {
17398
+ this.#fallbackCharCode = charCode;
17399
+ } else {
17400
+ const fallbackCode = this.#chars.keys().toArray()[0];
17401
+ console.warn(`${character} character does not exist in font ${this.name} defaulting to "${this.#chars.get(fallbackCode)?.char}".`);
17402
+ this.#fallbackCharCode = fallbackCode;
17301
17403
  }
17302
- return pipeline;
17303
17404
  }
17304
17405
  }
17305
17406
 
17306
- // src/math/angle.ts
17307
- function deg2rad(degrees) {
17308
- return degrees * (Math.PI / 180);
17309
- }
17310
- function rad2deg(radians) {
17311
- return radians * (180 / Math.PI);
17312
- }
17313
-
17314
- // src/scene/Camera.ts
17315
- class Camera {
17316
- #position = { x: 0, y: 0 };
17317
- #zoom = 1;
17318
- #rotation = 0;
17319
- #isDirty = true;
17320
- #matrix = mat3.create();
17321
- get zoom() {
17322
- return this.#zoom;
17323
- }
17324
- set zoom(value) {
17325
- this.#zoom = value;
17326
- this.setDirty();
17327
- }
17328
- get rotation() {
17329
- return rad2deg(this.#rotation);
17407
+ // src/text/shaping.ts
17408
+ var TAB_SPACES = 4;
17409
+ function shapeText(font, text, blockSize, fontSize, formatting, textArray, initialFloatOffset = 0, debug = false) {
17410
+ let offset = initialFloatOffset;
17411
+ const measurements = measureText(font, text, formatting.wordWrap);
17412
+ const alignment = formatting.align || "left";
17413
+ const em2px = fontSize / font.lineHeight;
17414
+ const hackHasExplicitBlock = blockSize.width !== measurements.width;
17415
+ let debugData = null;
17416
+ if (debug) {
17417
+ debugData = [];
17330
17418
  }
17331
- set rotation(value) {
17332
- this.#rotation = deg2rad(value);
17333
- this.setDirty();
17419
+ for (const word of measurements.words) {
17420
+ for (const glyph of word.glyphs) {
17421
+ let lineOffset = 0;
17422
+ if (alignment === "center") {
17423
+ lineOffset = measurements.width * -0.5 - (measurements.width - measurements.lineWidths[glyph.line]) * -0.5;
17424
+ } else if (alignment === "right") {
17425
+ const blockSizeEm = blockSize.width / em2px;
17426
+ const delta = measurements.width - measurements.lineWidths[glyph.line];
17427
+ lineOffset = (hackHasExplicitBlock ? blockSizeEm / 2 : measurements.width / 2) - measurements.width + delta;
17428
+ } else if (alignment === "left") {
17429
+ const blockSizeEm = blockSize.width / em2px;
17430
+ lineOffset = hackHasExplicitBlock ? -blockSizeEm / 2 : -measurements.width / 2;
17431
+ }
17432
+ if (debug && debugData) {
17433
+ debugData.push({
17434
+ line: glyph.line,
17435
+ word: word.glyphs.map((g3) => g3.char.char).join(""),
17436
+ glyph: glyph.char.char,
17437
+ startX: word.startX,
17438
+ glyphX: glyph.offset[0],
17439
+ advance: glyph.char.xadvance,
17440
+ lineOffset,
17441
+ startY: word.startY,
17442
+ glyphY: glyph.offset[1]
17443
+ });
17444
+ }
17445
+ textArray[offset] = word.startX + glyph.offset[0] + lineOffset;
17446
+ textArray[offset + 1] = word.startY + glyph.offset[1];
17447
+ textArray[offset + 2] = glyph.char.charIndex;
17448
+ offset += 4;
17449
+ }
17334
17450
  }
17335
- get rotationRadians() {
17336
- return this.#rotation;
17451
+ if (debug && debugData) {
17452
+ console.table(debugData);
17337
17453
  }
17338
- set rotationRadians(value) {
17339
- this.#rotation = value;
17340
- this.setDirty();
17454
+ }
17455
+ function measureText(font, text, wordWrap) {
17456
+ let maxWidth = 0;
17457
+ const lineWidths = [];
17458
+ let textOffsetX = 0;
17459
+ let textOffsetY = 0;
17460
+ let line = 0;
17461
+ let printedCharCount = 0;
17462
+ let nextCharCode = text.charCodeAt(0);
17463
+ let word = { glyphs: [], width: 0, startX: 0, startY: 0 };
17464
+ const words = [];
17465
+ for (let i3 = 0;i3 < text.length; i3++) {
17466
+ const isLastLetter = i3 === text.length - 1;
17467
+ const charCode = nextCharCode;
17468
+ nextCharCode = i3 < text.length - 1 ? text.charCodeAt(i3 + 1) : -1;
17469
+ switch (charCode) {
17470
+ case 9 /* HorizontalTab */:
17471
+ insertSpaces(TAB_SPACES);
17472
+ break;
17473
+ case 10 /* Newline */:
17474
+ flushLine();
17475
+ flushWord();
17476
+ break;
17477
+ case 13 /* CarriageReturn */:
17478
+ break;
17479
+ case 32 /* Space */:
17480
+ insertSpaces(1);
17481
+ break;
17482
+ default: {
17483
+ const advance = font.getXAdvance(charCode, nextCharCode);
17484
+ if (wordWrap && wordWrap.breakOn === "character" && textOffsetX + advance > wordWrap.emWidth) {
17485
+ if (word.startX === 0) {
17486
+ flushWord();
17487
+ } else {
17488
+ lineWidths.push(textOffsetX - word.width);
17489
+ line++;
17490
+ maxWidth = Math.max(maxWidth, textOffsetX);
17491
+ textOffsetX = word.width;
17492
+ textOffsetY -= font.lineHeight;
17493
+ word.startX = 0;
17494
+ word.startY = textOffsetY;
17495
+ word.glyphs.forEach((g3) => {
17496
+ g3.line = line;
17497
+ });
17498
+ }
17499
+ }
17500
+ word.glyphs.push({
17501
+ char: font.getChar(charCode),
17502
+ offset: [word.width, 0],
17503
+ line
17504
+ });
17505
+ if (isLastLetter) {
17506
+ flushWord();
17507
+ }
17508
+ word.width += advance;
17509
+ textOffsetX += advance;
17510
+ }
17511
+ }
17341
17512
  }
17342
- get x() {
17343
- return this.#position.x;
17513
+ lineWidths.push(textOffsetX);
17514
+ maxWidth = Math.max(maxWidth, textOffsetX);
17515
+ const lineCount = lineWidths.length;
17516
+ return {
17517
+ width: maxWidth,
17518
+ height: lineCount * font.lineHeight,
17519
+ lineWidths,
17520
+ lineCount,
17521
+ printedCharCount,
17522
+ words
17523
+ };
17524
+ function flushWord() {
17525
+ printedCharCount += word.glyphs.length;
17526
+ words.push(word);
17527
+ word = {
17528
+ glyphs: [],
17529
+ width: 0,
17530
+ startX: textOffsetX,
17531
+ startY: textOffsetY
17532
+ };
17344
17533
  }
17345
- get y() {
17346
- return this.#position.y;
17534
+ function flushLine() {
17535
+ lineWidths.push(textOffsetX);
17536
+ line++;
17537
+ maxWidth = Math.max(maxWidth, textOffsetX);
17538
+ textOffsetX = 0;
17539
+ textOffsetY -= font.lineHeight;
17347
17540
  }
17348
- set x(value) {
17349
- this.#position.x = value;
17350
- this.setDirty();
17541
+ function insertSpaces(spaces) {
17542
+ if (spaces < 1)
17543
+ spaces = 1;
17544
+ textOffsetX += font.getXAdvance(32 /* Space */) * spaces;
17545
+ if (wordWrap?.breakOn === "word" && textOffsetX >= wordWrap.emWidth) {
17546
+ flushLine();
17547
+ }
17548
+ flushWord();
17351
17549
  }
17352
- set y(value) {
17353
- this.#position.y = value;
17354
- this.setDirty();
17550
+ }
17551
+ function findLargestFontSize(font, text, size, formatting) {
17552
+ if (!formatting.fontSize) {
17553
+ throw new Error("fontSize is required for shrinkToFit");
17355
17554
  }
17356
- get matrix() {
17357
- if (this.#isDirty) {
17358
- this.#isDirty = false;
17359
- this.#matrix = createViewMatrix(this, this.#matrix);
17360
- }
17361
- return this.#matrix;
17555
+ if (!formatting.shrinkToFit) {
17556
+ throw new Error("shrinkToFit is required for findLargestFontSize");
17362
17557
  }
17363
- setDirty() {
17364
- this.#isDirty = true;
17558
+ const minSize = formatting.shrinkToFit.minFontSize;
17559
+ const maxSize = formatting.shrinkToFit.maxFontSize ?? formatting.fontSize;
17560
+ const maxLines = formatting.shrinkToFit.maxLines ?? Number.POSITIVE_INFINITY;
17561
+ const threshold = 0.5;
17562
+ let low = minSize;
17563
+ let high = maxSize;
17564
+ while (high - low > threshold) {
17565
+ const testSize = (low + high) / 2;
17566
+ const testMeasure = measureText(font, text, formatting.wordWrap);
17567
+ const padding = formatting.shrinkToFit.padding ?? 0;
17568
+ const scaledWidth = testMeasure.width * (testSize / font.lineHeight);
17569
+ const scaledHeight = testMeasure.height * (testSize / font.lineHeight);
17570
+ const fitsWidth = scaledWidth <= size.width - size.width * padding;
17571
+ const fitsHeight = scaledHeight <= size.height - size.height * padding;
17572
+ const fitsLines = testMeasure.lineCount <= maxLines;
17573
+ if (fitsWidth && fitsHeight && fitsLines) {
17574
+ low = testSize;
17575
+ } else {
17576
+ high = testSize;
17577
+ }
17365
17578
  }
17579
+ return low;
17580
+ }
17581
+
17582
+ // src/math/angle.ts
17583
+ function deg2rad(degrees) {
17584
+ return degrees * (Math.PI / 180);
17585
+ }
17586
+ function rad2deg(radians) {
17587
+ return radians * (180 / Math.PI);
17588
+ }
17589
+
17590
+ // src/math/matrix.ts
17591
+ function createProjectionMatrix(resolution, dst) {
17592
+ const { width, height } = resolution;
17593
+ return mat3.scaling([2 / width, 2 / height], dst);
17594
+ }
17595
+ function createViewMatrix(camera, target) {
17596
+ const matrix = mat3.identity(target);
17597
+ mat3.scale(matrix, [camera.zoom, camera.zoom], matrix);
17598
+ mat3.rotate(matrix, camera.rotationRadians, matrix);
17599
+ mat3.translate(matrix, [-camera.x, -camera.y], matrix);
17600
+ return matrix;
17601
+ }
17602
+ function createModelMatrix(transform, base) {
17603
+ mat3.translate(base, [transform.position.x, transform.position.y], base);
17604
+ mat3.rotate(base, transform.rotation, base);
17605
+ mat3.scale(base, [transform.scale.x, transform.scale.y], base);
17606
+ return base;
17607
+ }
17608
+ function convertScreenToWorld(screenCoordinates, camera, projectionMatrix, resolution) {
17609
+ const inverseViewProjectionMatrix = mat3.mul(mat3.inverse(camera.matrix), mat3.inverse(projectionMatrix));
17610
+ const normalizedDeviceCoordinates = {
17611
+ x: 2 * screenCoordinates.x / resolution.width - 1,
17612
+ y: 1 - 2 * screenCoordinates.y / resolution.height
17613
+ };
17614
+ return transformPoint(normalizedDeviceCoordinates, inverseViewProjectionMatrix);
17615
+ }
17616
+ function convertWorldToScreen(worldCoordinates, camera, projectionMatrix, resolution) {
17617
+ const viewProjectionMatrix = mat3.mul(projectionMatrix, camera.matrix);
17618
+ const ndcPoint = transformPoint(worldCoordinates, viewProjectionMatrix);
17619
+ return {
17620
+ x: (ndcPoint.x + 1) * resolution.width / 2,
17621
+ y: (1 - ndcPoint.y) * resolution.height / 2
17622
+ };
17623
+ }
17624
+ function transformPoint(point, matrix) {
17625
+ const result = vec2.transformMat3([point.x, point.y], matrix);
17626
+ return {
17627
+ x: result[0],
17628
+ y: result[1]
17629
+ };
17366
17630
  }
17367
17631
 
17368
17632
  // src/scene/SceneNode.ts
@@ -17706,1430 +17970,1533 @@ function reviver(key, value) {
17706
17970
  return value;
17707
17971
  }
17708
17972
 
17709
- // src/scene/QuadNode.ts
17710
- var PRIMITIVE_TEXTURE = "__primitive__";
17711
- var RESERVED_PRIMITIVE_INDEX_START = 1000;
17712
- var CIRCLE_INDEX = 1001;
17713
- var DEFAULT_REGION = {
17714
- x: 0,
17715
- y: 0,
17716
- width: 0,
17717
- height: 0
17718
- };
17973
+ // src/scene/TextNode.ts
17974
+ var DEFAULT_FONT_SIZE = 14;
17719
17975
 
17720
- class QuadNode extends SceneNode {
17721
- assetManager;
17722
- #color;
17723
- #atlasCoords;
17724
- #region;
17725
- #matrixPool;
17726
- #flip;
17727
- #cropOffset;
17728
- #cropRatio;
17729
- #atlasSize;
17730
- #textureId;
17731
- #writeInstance;
17732
- constructor(options, matrixPool) {
17733
- assert(options.shader, "QuadNode requires a shader to be explicitly provided");
17734
- assert(options.size, "QuadNode requires a size to be explicitly provided");
17735
- assert(options.atlasCoords, "QuadNode requires atlas coords to be explicitly provided");
17736
- options.render ??= {
17737
- shader: options.shader,
17738
- writeInstance: writeQuadInstance
17739
- };
17740
- super(options);
17741
- assert(options.assetManager, "QuadNode requires an asset manager");
17742
- this.assetManager = options.assetManager;
17743
- if (options.atlasCoords && options.atlasCoords.atlasIndex >= RESERVED_PRIMITIVE_INDEX_START) {
17744
- this.#textureId = PRIMITIVE_TEXTURE;
17745
- this.#region = DEFAULT_REGION;
17746
- this.#atlasSize = DEFAULT_REGION;
17747
- } else {
17748
- assert(options.textureId, "QuadNode requires texture id to be explicitly provided");
17749
- this.#textureId = options.textureId;
17750
- assert(options.region, "QuadNode requires a region to be explicitly provided");
17751
- this.#region = options.region;
17752
- assert(options.atlasSize, "QuadNode requires atlas size to be explicitly provided");
17753
- this.#atlasSize = options.atlasSize;
17976
+ class TextNode extends SceneNode {
17977
+ #text;
17978
+ #formatting;
17979
+ #font;
17980
+ constructor(shader, text, opts = {}) {
17981
+ const { width, height } = measureText(shader.font, text, opts.wordWrap);
17982
+ if (text.length > shader.maxCharCount) {
17983
+ throw new Error(`Text: ${text} exceeds ${shader.maxCharCount} characters. Try using fewer characters or increase the limit in Toodle.attach.`);
17754
17984
  }
17755
- this.#atlasCoords = options.atlasCoords;
17756
- this.#color = options.color ?? { r: 1, g: 1, b: 1, a: 1 };
17757
- this.#matrixPool = matrixPool;
17758
- this.#flip = { x: options.flipX ? -1 : 1, y: options.flipY ? -1 : 1 };
17759
- this.#cropOffset = options.cropOffset ?? { x: 0, y: 0 };
17760
- this.#cropRatio = !this.#atlasCoords.uvScaleCropped ? { width: 1, height: 1 } : {
17761
- width: this.#atlasCoords.uvScaleCropped.width / this.#atlasCoords.uvScale.width,
17762
- height: this.#atlasCoords.uvScaleCropped.height / this.#atlasCoords.uvScale.height
17763
- };
17764
- this.#writeInstance = options.writeInstance;
17765
- }
17766
- get color() {
17767
- return this.#color;
17768
- }
17769
- set color(value) {
17770
- this.#color = value;
17771
- }
17772
- get size() {
17773
- const size = super.size;
17774
- if (!size) {
17775
- throw new Error("QuadNode requires a size");
17985
+ const em2px = shader.font.lineHeight / (opts.fontSize ?? DEFAULT_FONT_SIZE);
17986
+ if (!opts.shrinkToFit && !opts.size) {
17987
+ opts.size = { width: width / em2px, height: height / em2px };
17776
17988
  }
17777
- return size;
17778
- }
17779
- set size(val) {
17780
- super.size = val;
17781
- }
17782
- get matrixWithSize() {
17783
- const matrix = mat3.clone(this.matrix, this.#matrixPool.get());
17784
- mat3.scale(matrix, [this.size.width * this.#flip.x, this.size.height * this.#flip.y], matrix);
17785
- return matrix;
17786
- }
17787
- get atlasCoords() {
17788
- return this.#atlasCoords;
17989
+ super({
17990
+ ...opts,
17991
+ render: {
17992
+ shader,
17993
+ writeInstance: (_node, _array, _offset) => {
17994
+ throw new Error("not implemented - needs access to text uniform buffer, dimensions and a model matrix");
17995
+ }
17996
+ }
17997
+ });
17998
+ this.#font = shader.font;
17999
+ this.#text = text;
18000
+ this.#formatting = opts;
17789
18001
  }
17790
- get region() {
17791
- return this.#region;
18002
+ get text() {
18003
+ return this.#text;
17792
18004
  }
17793
- get writeInstance() {
17794
- return this.#writeInstance;
18005
+ get formatting() {
18006
+ return this.#formatting;
17795
18007
  }
17796
- get flipX() {
17797
- return this.#flip.x === -1;
18008
+ get font() {
18009
+ return this.#font;
17798
18010
  }
17799
- set flipX(value) {
17800
- this.#flip.x = value ? -1 : 1;
18011
+ set text(text) {
18012
+ if (!text) {
18013
+ throw new Error("text cannot be empty");
18014
+ }
18015
+ this.#text = text;
17801
18016
  this.setDirty();
17802
18017
  }
17803
- get flipY() {
17804
- return this.#flip.y === -1;
18018
+ get tint() {
18019
+ return this.#formatting.color || { r: 1, g: 1, b: 1, a: 1 };
17805
18020
  }
17806
- set flipY(value) {
17807
- this.#flip.y = value ? -1 : 1;
18021
+ set tint(tint) {
18022
+ this.#formatting.color = tint;
17808
18023
  this.setDirty();
17809
18024
  }
17810
- get cropOffset() {
17811
- return this.#cropOffset;
18025
+ set formatting(formatting) {
18026
+ this.#formatting = formatting;
18027
+ this.setDirty();
17812
18028
  }
17813
- set cropOffset(value) {
17814
- this.#cropOffset = value;
18029
+ }
18030
+
18031
+ // src/backends/webgl2/glsl/text.glsl.ts
18032
+ var vertexShader2 = `#version 300 es
18033
+ precision highp float;
18034
+
18035
+ // Engine uniforms
18036
+ uniform mat3 u_viewProjection;
18037
+
18038
+ // Per-text-block uniforms
18039
+ uniform mat3 u_textTransform;
18040
+ uniform vec4 u_textColor;
18041
+ uniform float u_fontSize;
18042
+ uniform float u_blockWidth;
18043
+ uniform float u_blockHeight;
18044
+ uniform float u_lineHeight;
18045
+
18046
+ // Character data texture (RGBA32F, 2 texels per character)
18047
+ // Texel 0: texOffset.xy, texExtent.xy
18048
+ // Texel 1: size.xy, offset.xy
18049
+ uniform sampler2D u_charData;
18050
+
18051
+ // Text buffer texture (RGBA32F, 1 texel per glyph)
18052
+ // Each texel: xy = glyph position, z = char index
18053
+ uniform sampler2D u_textBuffer;
18054
+
18055
+ // Outputs to fragment shader
18056
+ out vec2 v_texcoord;
18057
+
18058
+ // Quad vertex positions for a character (matches WGSL)
18059
+ const vec2 pos[4] = vec2[4](
18060
+ vec2(0.0, -1.0),
18061
+ vec2(1.0, -1.0),
18062
+ vec2(0.0, 0.0),
18063
+ vec2(1.0, 0.0)
18064
+ );
18065
+
18066
+ void main() {
18067
+ // gl_VertexID gives us 0-3 for the quad vertices
18068
+ // gl_InstanceID gives us which glyph we're rendering
18069
+ int vertexIndex = gl_VertexID;
18070
+ int glyphIndex = gl_InstanceID;
18071
+
18072
+ // Fetch glyph data from text buffer texture
18073
+ vec4 glyphData = texelFetch(u_textBuffer, ivec2(glyphIndex, 0), 0);
18074
+ vec2 glyphPos = glyphData.xy;
18075
+ int charIndex = int(glyphData.z);
18076
+
18077
+ // Fetch character metrics (2 texels per char)
18078
+ // Texel 0: texOffset.x, texOffset.y, texExtent.x, texExtent.y
18079
+ // Texel 1: size.x, size.y, offset.x, offset.y
18080
+ vec4 charData0 = texelFetch(u_charData, ivec2(charIndex * 2, 0), 0);
18081
+ vec4 charData1 = texelFetch(u_charData, ivec2(charIndex * 2 + 1, 0), 0);
18082
+
18083
+ vec2 texOffset = charData0.xy;
18084
+ vec2 texExtent = charData0.zw;
18085
+ vec2 charSize = charData1.xy;
18086
+ vec2 charOffset = charData1.zw;
18087
+
18088
+ // Center text vertically; origin is mid-height
18089
+ vec2 offset = vec2(0.0, -u_blockHeight / 2.0);
18090
+
18091
+ // Glyph position in ems (quad pos * size + per-char offset)
18092
+ vec2 emPos = pos[vertexIndex] * charSize + charOffset + glyphPos - offset;
18093
+ vec2 charPos = emPos * (u_fontSize / u_lineHeight);
18094
+
18095
+ // Transform position through model and view-projection matrices
18096
+ vec3 worldPos = u_textTransform * vec3(charPos, 1.0);
18097
+ vec3 clipPos = u_viewProjection * worldPos;
18098
+
18099
+ gl_Position = vec4(clipPos.xy, 0.0, 1.0);
18100
+
18101
+ // Calculate texture coordinates
18102
+ v_texcoord = pos[vertexIndex] * vec2(1.0, -1.0);
18103
+ v_texcoord *= texExtent;
18104
+ v_texcoord += texOffset;
18105
+ }
18106
+ `;
18107
+ var fragmentShader2 = `#version 300 es
18108
+ precision highp float;
18109
+
18110
+ // Font texture (MSDF atlas)
18111
+ uniform sampler2D u_fontTexture;
18112
+
18113
+ // Text color
18114
+ uniform vec4 u_textColor;
18115
+
18116
+ // Input from vertex shader
18117
+ in vec2 v_texcoord;
18118
+
18119
+ // Output color
18120
+ out vec4 fragColor;
18121
+
18122
+ // Signed distance function sampling for MSDF font rendering
18123
+ // Median of three: max(min(r,g), min(max(r,g), b))
18124
+ float sampleMsdf(vec2 texcoord) {
18125
+ vec4 c = texture(u_fontTexture, texcoord);
18126
+ return max(min(c.r, c.g), min(max(c.r, c.g), c.b));
18127
+ }
18128
+
18129
+ void main() {
18130
+ // pxRange (AKA distanceRange) comes from the msdfgen tool
18131
+ float pxRange = 4.0;
18132
+ vec2 texSize = vec2(textureSize(u_fontTexture, 0));
18133
+
18134
+ // Anti-aliasing technique by Paul Houx
18135
+ // https://github.com/Chlumsky/msdfgen/issues/22#issuecomment-234958005
18136
+ float dx = texSize.x * length(vec2(dFdx(v_texcoord.x), dFdy(v_texcoord.x)));
18137
+ float dy = texSize.y * length(vec2(dFdx(v_texcoord.y), dFdy(v_texcoord.y)));
18138
+
18139
+ float toPixels = pxRange * inversesqrt(dx * dx + dy * dy);
18140
+ float sigDist = sampleMsdf(v_texcoord) - 0.5;
18141
+ float pxDist = sigDist * toPixels;
18142
+
18143
+ float edgeWidth = 0.5;
18144
+ float alpha = smoothstep(-edgeWidth, edgeWidth, pxDist);
18145
+
18146
+ if (alpha < 0.001) {
18147
+ discard;
17815
18148
  }
17816
- get textureId() {
17817
- return this.#textureId;
18149
+
18150
+ fragColor = vec4(u_textColor.rgb, u_textColor.a * alpha);
18151
+ }
18152
+ `;
18153
+
18154
+ // src/backends/webgl2/WebGLTextShader.ts
18155
+ class WebGLTextShader {
18156
+ label = "text";
18157
+ font;
18158
+ maxCharCount;
18159
+ #backend;
18160
+ #pipeline;
18161
+ #program;
18162
+ #vao;
18163
+ #cpuTextBuffer;
18164
+ #cachedUniform = null;
18165
+ #uViewProjection = null;
18166
+ #uTextTransform = null;
18167
+ #uTextColor = null;
18168
+ #uFontSize = null;
18169
+ #uBlockWidth = null;
18170
+ #uBlockHeight = null;
18171
+ #uLineHeight = null;
18172
+ #uCharData = null;
18173
+ #uTextBuffer = null;
18174
+ #uFontTexture = null;
18175
+ constructor(backend, pipeline) {
18176
+ this.#backend = backend;
18177
+ this.#pipeline = pipeline;
18178
+ this.font = pipeline.font;
18179
+ this.maxCharCount = pipeline.maxCharCount;
18180
+ const gl = backend.gl;
18181
+ const vs = this.#compileShader(gl, gl.VERTEX_SHADER, vertexShader2);
18182
+ const fs = this.#compileShader(gl, gl.FRAGMENT_SHADER, fragmentShader2);
18183
+ const program = gl.createProgram();
18184
+ assert(program, "Failed to create WebGL program");
18185
+ gl.attachShader(program, vs);
18186
+ gl.attachShader(program, fs);
18187
+ gl.linkProgram(program);
18188
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
18189
+ const info = gl.getProgramInfoLog(program);
18190
+ throw new Error(`Failed to link text shader program: ${info}`);
18191
+ }
18192
+ this.#program = program;
18193
+ this.#uViewProjection = gl.getUniformLocation(program, "u_viewProjection");
18194
+ this.#uTextTransform = gl.getUniformLocation(program, "u_textTransform");
18195
+ this.#uTextColor = gl.getUniformLocation(program, "u_textColor");
18196
+ this.#uFontSize = gl.getUniformLocation(program, "u_fontSize");
18197
+ this.#uBlockWidth = gl.getUniformLocation(program, "u_blockWidth");
18198
+ this.#uBlockHeight = gl.getUniformLocation(program, "u_blockHeight");
18199
+ this.#uLineHeight = gl.getUniformLocation(program, "u_lineHeight");
18200
+ this.#uCharData = gl.getUniformLocation(program, "u_charData");
18201
+ this.#uTextBuffer = gl.getUniformLocation(program, "u_textBuffer");
18202
+ this.#uFontTexture = gl.getUniformLocation(program, "u_fontTexture");
18203
+ const vao = gl.createVertexArray();
18204
+ assert(vao, "Failed to create WebGL VAO");
18205
+ this.#vao = vao;
18206
+ this.#cpuTextBuffer = new Float32Array(this.maxCharCount * 4);
18207
+ gl.deleteShader(vs);
18208
+ gl.deleteShader(fs);
17818
18209
  }
17819
- get isPrimitive() {
17820
- return this.#textureId === PRIMITIVE_TEXTURE;
18210
+ startFrame(uniform) {
18211
+ this.#cachedUniform = uniform;
17821
18212
  }
17822
- get isCircle() {
17823
- return this.#atlasCoords.atlasIndex === CIRCLE_INDEX;
18213
+ processBatch(nodes) {
18214
+ if (nodes.length === 0)
18215
+ return 0;
18216
+ const gl = this.#backend.gl;
18217
+ const uniform = this.#cachedUniform;
18218
+ if (!uniform) {
18219
+ throw new Error("Tried to process batch but engine uniform is not set");
18220
+ }
18221
+ gl.useProgram(this.#program);
18222
+ gl.bindVertexArray(this.#vao);
18223
+ if (this.#uViewProjection) {
18224
+ const m3 = uniform.viewProjectionMatrix;
18225
+ const mat3x3 = new Float32Array([
18226
+ m3[0],
18227
+ m3[1],
18228
+ m3[2],
18229
+ m3[4],
18230
+ m3[5],
18231
+ m3[6],
18232
+ m3[8],
18233
+ m3[9],
18234
+ m3[10]
18235
+ ]);
18236
+ gl.uniformMatrix3fv(this.#uViewProjection, false, mat3x3);
18237
+ }
18238
+ if (this.#uLineHeight) {
18239
+ gl.uniform1f(this.#uLineHeight, this.#pipeline.lineHeight);
18240
+ }
18241
+ gl.activeTexture(gl.TEXTURE0);
18242
+ gl.bindTexture(gl.TEXTURE_2D, this.#pipeline.fontTexture);
18243
+ if (this.#uFontTexture) {
18244
+ gl.uniform1i(this.#uFontTexture, 0);
18245
+ }
18246
+ gl.activeTexture(gl.TEXTURE1);
18247
+ gl.bindTexture(gl.TEXTURE_2D, this.#pipeline.charDataTexture);
18248
+ if (this.#uCharData) {
18249
+ gl.uniform1i(this.#uCharData, 1);
18250
+ }
18251
+ gl.activeTexture(gl.TEXTURE2);
18252
+ gl.bindTexture(gl.TEXTURE_2D, this.#pipeline.textBufferTexture);
18253
+ if (this.#uTextBuffer) {
18254
+ gl.uniform1i(this.#uTextBuffer, 2);
18255
+ }
18256
+ for (const node of nodes) {
18257
+ if (!(node instanceof TextNode)) {
18258
+ console.error(node);
18259
+ throw new Error(`Tried to use WebGLTextShader on something that isn't a TextNode: ${node}`);
18260
+ }
18261
+ const text = node.text;
18262
+ const formatting = node.formatting;
18263
+ const measurements = measureText(this.font, text, formatting.wordWrap);
18264
+ const size = node.size ?? measurements;
18265
+ const fontSize = formatting.shrinkToFit ? findLargestFontSize(this.font, text, size, formatting) : formatting.fontSize;
18266
+ const actualFontSize = fontSize || DEFAULT_FONT_SIZE;
18267
+ shapeText(this.font, text, size, actualFontSize, formatting, this.#cpuTextBuffer, 0);
18268
+ this.#pipeline.updateTextBuffer(this.#cpuTextBuffer, measurements.printedCharCount);
18269
+ if (this.#uTextTransform) {
18270
+ const m3 = node.matrix;
18271
+ const mat3x3 = new Float32Array([
18272
+ m3[0],
18273
+ m3[1],
18274
+ m3[2],
18275
+ m3[4],
18276
+ m3[5],
18277
+ m3[6],
18278
+ m3[8],
18279
+ m3[9],
18280
+ m3[10]
18281
+ ]);
18282
+ gl.uniformMatrix3fv(this.#uTextTransform, false, mat3x3);
18283
+ }
18284
+ if (this.#uTextColor) {
18285
+ const tint = node.tint;
18286
+ gl.uniform4f(this.#uTextColor, tint.r, tint.g, tint.b, tint.a);
18287
+ }
18288
+ if (this.#uFontSize) {
18289
+ gl.uniform1f(this.#uFontSize, actualFontSize);
18290
+ }
18291
+ if (this.#uBlockWidth) {
18292
+ gl.uniform1f(this.#uBlockWidth, formatting.align === "center" ? 0 : measurements.width);
18293
+ }
18294
+ if (this.#uBlockHeight) {
18295
+ gl.uniform1f(this.#uBlockHeight, measurements.height);
18296
+ }
18297
+ gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, measurements.printedCharCount);
18298
+ }
18299
+ gl.bindVertexArray(null);
18300
+ return nodes.length;
17824
18301
  }
17825
- extra = {
17826
- setAtlasCoords: (value) => {
17827
- this.#atlasCoords = value;
17828
- },
17829
- cropRatio: () => {
17830
- return this.#cropRatio;
17831
- },
17832
- atlasSize: () => {
17833
- return this.#atlasSize;
18302
+ endFrame() {}
18303
+ #compileShader(gl, type, source) {
18304
+ const shader = gl.createShader(type);
18305
+ assert(shader, "Failed to create WebGL shader");
18306
+ gl.shaderSource(shader, source);
18307
+ gl.compileShader(shader);
18308
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
18309
+ const info = gl.getShaderInfoLog(shader);
18310
+ const typeStr = type === gl.VERTEX_SHADER ? "vertex" : "fragment";
18311
+ gl.deleteShader(shader);
18312
+ throw new Error(`Failed to compile ${typeStr} shader: ${info}`);
17834
18313
  }
17835
- };
17836
- }
17837
- function writeQuadInstance(node, array, offset) {
17838
- if (!(node instanceof QuadNode)) {
17839
- throw new Error("QuadNode.writeInstance can only be called on QuadNodes");
18314
+ return shader;
17840
18315
  }
17841
- array.set(node.matrixWithSize, offset);
17842
- array.set([node.color.r, node.color.g, node.color.b, node.color.a], offset + 12);
17843
- const region = node.region;
17844
- if (node.textureId === PRIMITIVE_TEXTURE) {
17845
- array.set([
17846
- node.atlasCoords.uvOffset.x,
17847
- node.atlasCoords.uvOffset.y,
17848
- node.atlasCoords.uvScale.width,
17849
- node.atlasCoords.uvScale.height
17850
- ], offset + 16);
17851
- } else {
17852
- const atlasSize = node.extra.atlasSize();
17853
- array.set([
17854
- node.atlasCoords.uvOffset.x + region.x / atlasSize.width,
17855
- node.atlasCoords.uvOffset.y + region.y / atlasSize.height,
17856
- region.width / atlasSize.width,
17857
- region.height / atlasSize.height
17858
- ], offset + 16);
18316
+ destroy() {
18317
+ const gl = this.#backend.gl;
18318
+ gl.deleteProgram(this.#program);
18319
+ gl.deleteVertexArray(this.#vao);
18320
+ this.#pipeline.destroy();
17859
18321
  }
17860
- array.set([
17861
- node.cropOffset.x / 2 / (node.atlasCoords.originalSize.width || 1),
17862
- node.cropOffset.y / 2 / (node.atlasCoords.originalSize.height || 1),
17863
- node.extra.cropRatio().width,
17864
- node.extra.cropRatio().height
17865
- ], offset + 20);
17866
- new DataView(array.buffer).setUint32(array.byteOffset + (offset + 24) * Float32Array.BYTES_PER_ELEMENT, node.atlasCoords.atlasIndex, true);
17867
- node.writeInstance?.(array, offset + 28);
17868
- return 1;
17869
18322
  }
17870
18323
 
17871
- // src/scene/JumboQuadNode.ts
17872
- var MAT3_SIZE = 12;
17873
- var VEC4F_SIZE = 4;
18324
+ // src/backends/webgpu/wgsl/text.wgsl.ts
18325
+ var text_wgsl_default = `
18326
+ // Adapted from: https://webgpu.github.io/webgpu-samples/?sample=textRenderingMsdf
17874
18327
 
17875
- class JumboQuadNode extends QuadNode {
17876
- #tiles;
17877
- #matrixPool;
17878
- constructor(options, matrixPool) {
17879
- assert(options.shader, "JumboQuadNode requires a shader to be explicitly provided");
17880
- assert(options.tiles && options.tiles.length > 0, "JumboQuadNode requires at least one tile to be provided");
17881
- options.render ??= {
17882
- shader: options.shader,
17883
- writeInstance: writeJumboQuadInstance
17884
- };
17885
- super({
17886
- ...options,
17887
- atlasCoords: options.tiles[0].atlasCoords
17888
- }, matrixPool);
17889
- this.#matrixPool = matrixPool;
17890
- this.#tiles = [];
17891
- for (const tile of options.tiles) {
17892
- assert(tile.atlasCoords, "JumboQuadNode requires atlas coords to be provided");
17893
- assert(tile.size, "JumboQuadNode requires a size to be provided");
17894
- this.#tiles.push({
17895
- textureId: tile.textureId,
17896
- offset: tile.offset,
17897
- size: tile.size,
17898
- atlasCoords: tile.atlasCoords
17899
- });
17900
- }
17901
- }
17902
- get atlasCoords() {
17903
- throw new Error("JumboQuadNode does not have a single atlas coords");
17904
- }
17905
- get tiles() {
17906
- return this.#tiles;
17907
- }
17908
- getTileMatrix(tile) {
17909
- const matrix = mat3.clone(this.matrix, this.#matrixPool.get());
17910
- const originalSize = {
17911
- width: Math.max(...this.#tiles.map((t3) => t3.offset.x + t3.size.width)),
17912
- height: Math.max(...this.#tiles.map((t3) => t3.offset.y + t3.size.height))
17913
- };
17914
- const proportionalSize = {
17915
- width: this.size.width / originalSize.width,
17916
- height: this.size.height / originalSize.height
17917
- };
17918
- const centerOffset = {
17919
- x: tile.offset.x + tile.size.width / 2 - originalSize.width / 2,
17920
- y: -(tile.offset.y + tile.size.height / 2 - originalSize.height / 2)
17921
- };
17922
- mat3.translate(matrix, [
17923
- centerOffset.x * proportionalSize.width,
17924
- centerOffset.y * proportionalSize.height
17925
- ], matrix);
17926
- mat3.scale(matrix, [
17927
- tile.size.width * proportionalSize.width * (this.flipX ? -1 : 1),
17928
- tile.size.height * proportionalSize.height * (this.flipY ? -1 : 1)
17929
- ], matrix);
17930
- return matrix;
17931
- }
17932
- }
17933
- function writeJumboQuadInstance(node, array, offset) {
17934
- if (!(node instanceof JumboQuadNode)) {
17935
- throw new Error("JumboQuadNode.writeJumboQuadInstance can only be called on JumboQuadNodes");
17936
- }
17937
- let tileOffset = 0;
17938
- for (const tile of node.tiles) {
17939
- const coord = tile.atlasCoords;
17940
- const matrix = node.getTileMatrix(tile);
17941
- array.set(matrix, offset + tileOffset);
17942
- tileOffset += MAT3_SIZE;
17943
- array.set([node.color.r, node.color.g, node.color.b, node.color.a], offset + tileOffset);
17944
- tileOffset += VEC4F_SIZE;
17945
- array.set([
17946
- coord.uvOffset.x,
17947
- coord.uvOffset.y,
17948
- coord.uvScale.width,
17949
- coord.uvScale.height
17950
- ], offset + tileOffset);
17951
- tileOffset += VEC4F_SIZE;
17952
- const cropRatio = !coord.uvScaleCropped ? { width: 1, height: 1 } : {
17953
- width: coord.uvScaleCropped.width / coord.uvScale.width,
17954
- height: coord.uvScaleCropped.height / coord.uvScale.height
17955
- };
17956
- array.set([
17957
- tile.atlasCoords.cropOffset.x / 2 / (tile.atlasCoords.originalSize.width || 1),
17958
- tile.atlasCoords.cropOffset.y / 2 / (tile.atlasCoords.originalSize.height || 1),
17959
- cropRatio.width,
17960
- cropRatio.height
17961
- ], offset + tileOffset);
17962
- tileOffset += VEC4F_SIZE;
17963
- new DataView(array.buffer).setUint32(array.byteOffset + (offset + tileOffset) * Float32Array.BYTES_PER_ELEMENT, coord.atlasIndex, true);
17964
- tileOffset += VEC4F_SIZE;
17965
- }
17966
- node.writeInstance?.(array, offset + tileOffset);
17967
- return node.tiles.length;
17968
- }
18328
+ // Quad vertex positions for a character
18329
+ const pos = array(
18330
+ vec2f(0, -1),
18331
+ vec2f(1, -1),
18332
+ vec2f(0, 0),
18333
+ vec2f(1, 0),
18334
+ );
17969
18335
 
17970
- // src/utils/error.ts
17971
- var warnings = new Map;
17972
- function warnOnce(key, msg) {
17973
- if (warnings.has(key)) {
17974
- return;
17975
- }
17976
- warnings.set(key, true);
17977
- console.warn(msg ?? key);
17978
- }
18336
+ // Debug colors for visualization
18337
+ const debugColors = array(
18338
+ vec4f(1, 0, 0, 1),
18339
+ vec4f(0, 1, 0, 1),
18340
+ vec4f(0, 0, 1, 1),
18341
+ vec4f(1, 1, 1, 1),
18342
+ );
17979
18343
 
17980
- // src/text/MsdfFont.ts
17981
- class MsdfFont {
17982
- id;
17983
- json;
17984
- imageBitmap;
17985
- name;
17986
- charset;
17987
- charCount;
17988
- lineHeight;
17989
- charBuffer;
17990
- #kernings;
17991
- #chars;
17992
- #fallbackCharCode;
17993
- constructor(id, json, imageBitmap) {
17994
- this.id = id;
17995
- this.json = json;
17996
- this.imageBitmap = imageBitmap;
17997
- const charArray = Object.values(json.chars);
17998
- this.charCount = charArray.length;
17999
- this.lineHeight = json.common.lineHeight;
18000
- this.charset = json.info.charset;
18001
- this.name = json.info.face;
18002
- this.#kernings = new Map;
18003
- if (json.kernings) {
18004
- for (const kearning of json.kernings) {
18005
- let charKerning = this.#kernings.get(kearning.first);
18006
- if (!charKerning) {
18007
- charKerning = new Map;
18008
- this.#kernings.set(kearning.first, charKerning);
18009
- }
18010
- charKerning.set(kearning.second, kearning.amount);
18011
- }
18012
- }
18013
- this.#chars = new Map;
18014
- const charCount = Object.values(json.chars).length;
18015
- this.charBuffer = new Float32Array(charCount * 8);
18016
- let offset = 0;
18017
- const u3 = 1 / json.common.scaleW;
18018
- const v3 = 1 / json.common.scaleH;
18019
- for (const [i3, char] of json.chars.entries()) {
18020
- this.#chars.set(char.id, char);
18021
- this.#chars.get(char.id).charIndex = i3;
18022
- this.charBuffer[offset] = char.x * u3;
18023
- this.charBuffer[offset + 1] = char.y * v3;
18024
- this.charBuffer[offset + 2] = char.width * u3;
18025
- this.charBuffer[offset + 3] = char.height * v3;
18026
- this.charBuffer[offset + 4] = char.width;
18027
- this.charBuffer[offset + 5] = char.height;
18028
- this.charBuffer[offset + 6] = char.xoffset;
18029
- this.charBuffer[offset + 7] = -char.yoffset;
18030
- offset += 8;
18031
- }
18032
- }
18033
- getChar(charCode) {
18034
- const char = this.#chars.get(charCode);
18035
- if (!char) {
18036
- const fallbackCharacter = this.#chars.get(this.#fallbackCharCode ?? this.#chars.keys().toArray()[0]);
18037
- warnOnce(`unknown_char_${this.name}`, `Couldn't find character ${charCode} in characters for font ${this.name} -- defaulting to first available character "${fallbackCharacter.char}"`);
18038
- return fallbackCharacter;
18039
- }
18040
- return char;
18041
- }
18042
- getXAdvance(charCode, nextCharCode = -1) {
18043
- const char = this.getChar(charCode);
18044
- if (nextCharCode >= 0) {
18045
- const kerning = this.#kernings.get(charCode);
18046
- if (kerning) {
18047
- return char.xadvance + (kerning.get(nextCharCode) ?? 0);
18048
- }
18049
- }
18050
- return char.xadvance;
18051
- }
18052
- static async create(id, fontJsonUrl) {
18053
- const response = await fetch(fontJsonUrl);
18054
- const json = await response.json();
18055
- const i3 = fontJsonUrl.href.lastIndexOf("/");
18056
- const baseUrl = i3 !== -1 ? fontJsonUrl.href.substring(0, i3 + 1) : undefined;
18057
- if (json.pages.length < 1) {
18058
- throw new Error(`Can't create an msdf font without a reference to the page url in the json`);
18059
- }
18060
- if (json.pages.length > 1) {
18061
- throw new Error(`Can't create an msdf font with more than one page`);
18062
- }
18063
- const textureUrl = baseUrl + json.pages[0];
18064
- const textureResponse = await fetch(textureUrl);
18065
- const bitmap = await createImageBitmap(await textureResponse.blob());
18066
- return new MsdfFont(id, json, bitmap);
18067
- }
18068
- set fallbackCharacter(character) {
18069
- const charCode = character.charCodeAt(0);
18070
- if (this.#chars.has(charCode)) {
18071
- this.#fallbackCharCode = charCode;
18072
- } else {
18073
- const fallbackCode = this.#chars.keys().toArray()[0];
18074
- console.warn(`${character} character does not exist in font ${this.name} defaulting to "${this.#chars.get(fallbackCode)?.char}".`);
18075
- this.#fallbackCharCode = fallbackCode;
18076
- }
18077
- }
18078
- }
18344
+ // Vertex input from GPU
18345
+ struct VertexInput {
18346
+ @builtin(vertex_index) vertex: u32,
18347
+ @builtin(instance_index) instance: u32,
18348
+ };
18079
18349
 
18080
- // src/text/shaping.ts
18081
- var TAB_SPACES = 4;
18082
- function shapeText(font, text, blockSize, fontSize, formatting, textArray, initialFloatOffset = 0, debug = false) {
18083
- let offset = initialFloatOffset;
18084
- const measurements = measureText(font, text, formatting.wordWrap);
18085
- const alignment = formatting.align || "left";
18086
- const em2px = fontSize / font.lineHeight;
18087
- const hackHasExplicitBlock = blockSize.width !== measurements.width;
18088
- let debugData = null;
18089
- if (debug) {
18090
- debugData = [];
18091
- }
18092
- for (const word of measurements.words) {
18093
- for (const glyph of word.glyphs) {
18094
- let lineOffset = 0;
18095
- if (alignment === "center") {
18096
- lineOffset = measurements.width * -0.5 - (measurements.width - measurements.lineWidths[glyph.line]) * -0.5;
18097
- } else if (alignment === "right") {
18098
- const blockSizeEm = blockSize.width / em2px;
18099
- const delta = measurements.width - measurements.lineWidths[glyph.line];
18100
- lineOffset = (hackHasExplicitBlock ? blockSizeEm / 2 : measurements.width / 2) - measurements.width + delta;
18101
- } else if (alignment === "left") {
18102
- const blockSizeEm = blockSize.width / em2px;
18103
- lineOffset = hackHasExplicitBlock ? -blockSizeEm / 2 : -measurements.width / 2;
18104
- }
18105
- if (debug && debugData) {
18106
- debugData.push({
18107
- line: glyph.line,
18108
- word: word.glyphs.map((g3) => g3.char.char).join(""),
18109
- glyph: glyph.char.char,
18110
- startX: word.startX,
18111
- glyphX: glyph.offset[0],
18112
- advance: glyph.char.xadvance,
18113
- lineOffset,
18114
- startY: word.startY,
18115
- glyphY: glyph.offset[1]
18116
- });
18117
- }
18118
- textArray[offset] = word.startX + glyph.offset[0] + lineOffset;
18119
- textArray[offset + 1] = word.startY + glyph.offset[1];
18120
- textArray[offset + 2] = glyph.char.charIndex;
18121
- offset += 4;
18122
- }
18123
- }
18124
- if (debug && debugData) {
18125
- console.table(debugData);
18126
- }
18127
- }
18128
- function measureText(font, text, wordWrap) {
18129
- let maxWidth = 0;
18130
- const lineWidths = [];
18131
- let textOffsetX = 0;
18132
- let textOffsetY = 0;
18133
- let line = 0;
18134
- let printedCharCount = 0;
18135
- let nextCharCode = text.charCodeAt(0);
18136
- let word = { glyphs: [], width: 0, startX: 0, startY: 0 };
18137
- const words = [];
18138
- for (let i3 = 0;i3 < text.length; i3++) {
18139
- const isLastLetter = i3 === text.length - 1;
18140
- const charCode = nextCharCode;
18141
- nextCharCode = i3 < text.length - 1 ? text.charCodeAt(i3 + 1) : -1;
18142
- switch (charCode) {
18143
- case 9 /* HorizontalTab */:
18144
- insertSpaces(TAB_SPACES);
18145
- break;
18146
- case 10 /* Newline */:
18147
- flushLine();
18148
- flushWord();
18149
- break;
18150
- case 13 /* CarriageReturn */:
18151
- break;
18152
- case 32 /* Space */:
18153
- insertSpaces(1);
18154
- break;
18155
- default: {
18156
- const advance = font.getXAdvance(charCode, nextCharCode);
18157
- if (wordWrap && wordWrap.breakOn === "character" && textOffsetX + advance > wordWrap.emWidth) {
18158
- if (word.startX === 0) {
18159
- flushWord();
18160
- } else {
18161
- lineWidths.push(textOffsetX - word.width);
18162
- line++;
18163
- maxWidth = Math.max(maxWidth, textOffsetX);
18164
- textOffsetX = word.width;
18165
- textOffsetY -= font.lineHeight;
18166
- word.startX = 0;
18167
- word.startY = textOffsetY;
18168
- word.glyphs.forEach((g3) => {
18169
- g3.line = line;
18170
- });
18171
- }
18172
- }
18173
- word.glyphs.push({
18174
- char: font.getChar(charCode),
18175
- offset: [word.width, 0],
18176
- line
18177
- });
18178
- if (isLastLetter) {
18179
- flushWord();
18180
- }
18181
- word.width += advance;
18182
- textOffsetX += advance;
18183
- }
18184
- }
18185
- }
18186
- lineWidths.push(textOffsetX);
18187
- maxWidth = Math.max(maxWidth, textOffsetX);
18188
- const lineCount = lineWidths.length;
18189
- return {
18190
- width: maxWidth,
18191
- height: lineCount * font.lineHeight,
18192
- lineWidths,
18193
- lineCount,
18194
- printedCharCount,
18195
- words
18196
- };
18197
- function flushWord() {
18198
- printedCharCount += word.glyphs.length;
18199
- words.push(word);
18200
- word = {
18201
- glyphs: [],
18202
- width: 0,
18203
- startX: textOffsetX,
18204
- startY: textOffsetY
18205
- };
18206
- }
18207
- function flushLine() {
18208
- lineWidths.push(textOffsetX);
18209
- line++;
18210
- maxWidth = Math.max(maxWidth, textOffsetX);
18211
- textOffsetX = 0;
18212
- textOffsetY -= font.lineHeight;
18213
- }
18214
- function insertSpaces(spaces) {
18215
- if (spaces < 1)
18216
- spaces = 1;
18217
- textOffsetX += font.getXAdvance(32 /* Space */) * spaces;
18218
- if (wordWrap?.breakOn === "word" && textOffsetX >= wordWrap.emWidth) {
18219
- flushLine();
18220
- }
18221
- flushWord();
18222
- }
18223
- }
18224
- function findLargestFontSize(font, text, size, formatting) {
18225
- if (!formatting.fontSize) {
18226
- throw new Error("fontSize is required for shrinkToFit");
18227
- }
18228
- if (!formatting.shrinkToFit) {
18229
- throw new Error("shrinkToFit is required for findLargestFontSize");
18230
- }
18231
- const minSize = formatting.shrinkToFit.minFontSize;
18232
- const maxSize = formatting.shrinkToFit.maxFontSize ?? formatting.fontSize;
18233
- const maxLines = formatting.shrinkToFit.maxLines ?? Number.POSITIVE_INFINITY;
18234
- const threshold = 0.5;
18235
- let low = minSize;
18236
- let high = maxSize;
18237
- while (high - low > threshold) {
18238
- const testSize = (low + high) / 2;
18239
- const testMeasure = measureText(font, text, formatting.wordWrap);
18240
- const padding = formatting.shrinkToFit.padding ?? 0;
18241
- const scaledWidth = testMeasure.width * (testSize / font.lineHeight);
18242
- const scaledHeight = testMeasure.height * (testSize / font.lineHeight);
18243
- const fitsWidth = scaledWidth <= size.width - size.width * padding;
18244
- const fitsHeight = scaledHeight <= size.height - size.height * padding;
18245
- const fitsLines = testMeasure.lineCount <= maxLines;
18246
- if (fitsWidth && fitsHeight && fitsLines) {
18247
- low = testSize;
18248
- } else {
18249
- high = testSize;
18250
- }
18251
- }
18252
- return low;
18253
- }
18350
+ // Output from vertex shader to fragment shader
18351
+ struct VertexOutput {
18352
+ @builtin(position) position: vec4f,
18353
+ @location(0) texcoord: vec2f,
18354
+ @location(1) debugColor: vec4f,
18355
+ @location(2) @interpolate(flat) instanceIndex: u32,
18356
+ };
18254
18357
 
18255
- // src/text/TextNode.ts
18256
- var DEFAULT_FONT_SIZE = 14;
18358
+ // Metadata for a single character glyph
18359
+ struct Char {
18360
+ texOffset: vec2f, // Offset to top-left in MSDF texture (pixels)
18361
+ texExtent: vec2f, // Size in texture (pixels)
18362
+ size: vec2f, // Glyph size in ems
18363
+ offset: vec2f, // Position offset in ems
18364
+ };
18257
18365
 
18258
- class TextNode extends SceneNode {
18259
- #text;
18260
- #formatting;
18261
- #font;
18262
- constructor(shader, text, opts = {}) {
18263
- const { width, height } = measureText(shader.font, text, opts.wordWrap);
18264
- if (text.length > shader.maxCharCount) {
18265
- throw new Error(`Text: ${text} exceeds ${shader.maxCharCount} characters. Try using fewer characters or increase the limit in Toodle.attach.`);
18266
- }
18267
- const em2px = shader.font.lineHeight / (opts.fontSize ?? DEFAULT_FONT_SIZE);
18268
- if (!opts.shrinkToFit && !opts.size) {
18269
- opts.size = { width: width / em2px, height: height / em2px };
18270
- }
18271
- super({
18272
- ...opts,
18273
- render: {
18274
- shader,
18275
- writeInstance: (_node, _array, _offset) => {
18276
- throw new Error("not implemented - needs access to text uniform buffer, dimensions and a model matrix");
18277
- }
18278
- }
18279
- });
18280
- this.#font = shader.font;
18281
- this.#text = text;
18282
- this.#formatting = opts;
18283
- }
18284
- get text() {
18285
- return this.#text;
18286
- }
18287
- get formatting() {
18288
- return this.#formatting;
18289
- }
18290
- get font() {
18291
- return this.#font;
18292
- }
18293
- set text(text) {
18294
- if (!text) {
18295
- throw new Error("text cannot be empty");
18296
- }
18297
- this.#text = text;
18298
- this.setDirty();
18299
- }
18300
- get tint() {
18301
- return this.#formatting.color || { r: 1, g: 1, b: 1, a: 1 };
18302
- }
18303
- set tint(tint) {
18304
- this.#formatting.color = tint;
18305
- this.setDirty();
18306
- }
18307
- set formatting(formatting) {
18308
- this.#formatting = formatting;
18309
- this.setDirty();
18310
- }
18311
- }
18366
+ // Metadata for a text block
18367
+ struct TextBlockDescriptor {
18368
+ transform: mat3x3f, // Text transform matrix (model matrix)
18369
+ color: vec4f, // Text color
18370
+ fontSize: f32, // Font size
18371
+ blockWidth: f32, // Total width of text block
18372
+ blockHeight: f32, // Total height of text block
18373
+ bufferPosition: f32 // Index and length in textBuffer
18374
+ };
18312
18375
 
18313
- // src/backends/webgpu/wgsl/pixel-scraping.wgsl.ts
18314
- var pixel_scraping_wgsl_default = `
18315
- // ==============================
18316
- // === BOUNDING BOX PASS =======
18317
- // ==============================
18376
+ // Font bindings
18377
+ @group(0) @binding(0) var fontTexture: texture_2d<f32>;
18378
+ @group(0) @binding(1) var fontSampler: sampler;
18379
+ @group(0) @binding(2) var<storage> chars: array<Char>;
18380
+ @group(0) @binding(3) var<uniform> fontData: vec4f; // Contains line height (x)
18381
+
18382
+ // Text bindings
18383
+ @group(1) @binding(0) var<storage> texts: array<TextBlockDescriptor>;
18384
+ @group(1) @binding(1) var<storage> textBuffer: array<vec4f>; // Each vec4: xy = glyph pos, z = char index
18318
18385
 
18319
- // Input texture from which to compute the non-transparent bounding box
18320
- @group(0) @binding(0)
18321
- var input_texture: texture_2d<f32>;
18386
+ // Global uniforms
18387
+ @group(2) @binding(0) var<uniform> viewProjectionMatrix: mat3x3f;
18322
18388
 
18323
- // Atomic bounding box storage structure
18324
- struct bounding_box_atomic {
18325
- min_x: atomic<u32>,
18326
- min_y: atomic<u32>,
18327
- max_x: atomic<u32>,
18328
- max_y: atomic<u32>,
18329
- };
18389
+ // Vertex shader
18390
+ @vertex
18391
+ fn vertexMain(input: VertexInput) -> VertexOutput {
18392
+ // Because the instance index is used for character indexing, we are
18393
+ // overloading the vertex index to store the instance of the text metadata.
18394
+ //
18395
+ // I.e...
18396
+ // Vertex 0-4 = Instance 0, Vertex 0-4
18397
+ // Vertex 4-8 = Instance 1, Vertex 0-4
18398
+ // Vertex 8-12 = Instance 2, Vertex 0-4
18399
+ let vertexIndex = input.vertex % 4;
18400
+ let textIndex = input.vertex / 4;
18330
18401
 
18331
- // Storage buffer to hold atomic bounding box updates
18332
- @group(0) @binding(1)
18333
- var<storage, read_write> bounds: bounding_box_atomic;
18402
+ let text = texts[textIndex];
18403
+ let textElement = textBuffer[u32(text.bufferPosition) + input.instance];
18404
+ let char = chars[u32(textElement.z)];
18334
18405
 
18335
- // Compute shader to find the bounding box of non-transparent pixels
18336
- @compute @workgroup_size(8, 8)
18337
- fn find_bounds(@builtin(global_invocation_id) gid: vec3<u32>) {
18338
- let size = textureDimensions(input_texture).xy;
18339
- if (gid.x >= size.x || gid.y >= size.y) {
18340
- return;
18341
- }
18406
+ let lineHeight = fontData.x;
18407
+ let textWidth = text.blockWidth;
18408
+ let textHeight = text.blockHeight;
18342
18409
 
18343
- let pixel = textureLoad(input_texture, vec2<i32>(gid.xy), 0);
18344
- if (pixel.a > 0.0) {
18345
- atomicMin(&bounds.min_x, gid.x);
18346
- atomicMin(&bounds.min_y, gid.y);
18347
- atomicMax(&bounds.max_x, gid.x);
18348
- atomicMax(&bounds.max_y, gid.y);
18349
- }
18350
- }
18410
+ // Center text vertically; origin is mid-height
18411
+ let offset = vec2f(0, -textHeight / 2);
18351
18412
 
18352
- // ==============================
18353
- // === CROP + OUTPUT PASS ======
18354
- // ==============================
18413
+ // Glyph position in ems (quad pos * size + per-char offset)
18414
+ let emPos = pos[vertexIndex] * char.size + char.offset + textElement.xy - offset;
18415
+ let charPos = emPos * (text.fontSize / lineHeight);
18355
18416
 
18356
- // Input texture from which cropped data is read
18357
- @group(0) @binding(0)
18358
- var input_texture_crop: texture_2d<f32>;
18417
+ var output: VertexOutput;
18418
+ let transformedPosition = viewProjectionMatrix * text.transform * vec3f(charPos, 1);
18359
18419
 
18360
- // Output texture where cropped image is written
18361
- @group(0) @binding(1)
18362
- var output_texture: texture_storage_2d<rgba8unorm, write>;
18420
+ output.position = vec4f(transformedPosition, 1);
18421
+ output.texcoord = pos[vertexIndex] * vec2f(1, -1);
18422
+ output.texcoord *= char.texExtent;
18423
+ output.texcoord += char.texOffset;
18424
+ output.debugColor = debugColors[vertexIndex];
18425
+ output.instanceIndex = textIndex;
18426
+ return output;
18363
18427
 
18364
- // Bounding box passed in as a uniform (not atomic anymore)
18365
- struct bounding_box_uniform {
18366
- min_x: u32,
18367
- min_y: u32,
18368
- max_x: u32,
18369
- max_y: u32,
18370
- };
18428
+ // To debug - hardcode quad in bottom right quarter of the screen:
18429
+ // output.position = vec4f(pos[input.vertex], 0, 1);
18430
+ }
18371
18431
 
18372
- @group(0) @binding(2)
18373
- var<uniform> bounds_uniform: bounding_box_uniform;
18432
+ // Signed distance function sampling for MSDF font rendering
18433
+ fn sampleMsdf(texcoord: vec2f) -> f32 {
18434
+ let c = textureSample(fontTexture, fontSampler, texcoord);
18435
+ return max(min(c.r, c.g), min(max(c.r, c.g), c.b));
18436
+ }
18374
18437
 
18375
- // Struct to store both original and cropped texture dimensions
18376
- struct image_dimensions {
18377
- original_width: u32,
18378
- original_height: u32,
18379
- cropped_width: u32,
18380
- cropped_height: u32,
18381
- };
18438
+ // Fragment shader
18439
+ // Anti-aliasing technique by Paul Houx
18440
+ // more details here:
18441
+ // https://github.com/Chlumsky/msdfgen/issues/22#issuecomment-234958005
18442
+ @fragment
18443
+ fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
18444
+ let text = texts[input.instanceIndex];
18382
18445
 
18383
- // Storage buffer to output the result dimensions
18384
- @group(0) @binding(3)
18385
- var<storage, read_write> dimensions_out: image_dimensions;
18446
+ // pxRange (AKA distanceRange) comes from the msdfgen tool.
18447
+ let pxRange = 4.0;
18448
+ let texSize = vec2f(textureDimensions(fontTexture, 0));
18386
18449
 
18387
- // Compute shader to crop the input texture to the bounding box and output it
18388
- @compute @workgroup_size(8, 8)
18389
- fn crop_and_output(@builtin(global_invocation_id) gid: vec3<u32>) {
18390
- let size = textureDimensions(input_texture_crop).xy;
18450
+ let dx = texSize.x * length(vec2f(dpdxFine(input.texcoord.x), dpdyFine(input.texcoord.x)));
18451
+ let dy = texSize.y * length(vec2f(dpdxFine(input.texcoord.y), dpdyFine(input.texcoord.y)));
18391
18452
 
18392
- let crop_width = bounds_uniform.max_x - bounds_uniform.min_x + 1u;
18393
- let crop_height = bounds_uniform.max_y - bounds_uniform.min_y + 1u;
18453
+ let toPixels = pxRange * inverseSqrt(dx * dx + dy * dy);
18454
+ let sigDist = sampleMsdf(input.texcoord) - 0.5;
18455
+ let pxDist = sigDist * toPixels;
18394
18456
 
18395
- if (gid.x >= crop_width || gid.y >= crop_height) {
18396
- return;
18397
- }
18457
+ let edgeWidth = 0.5;
18458
+ let alpha = smoothstep(-edgeWidth, edgeWidth, pxDist);
18398
18459
 
18399
- let src_coord = vec2<i32>(
18400
- i32(bounds_uniform.min_x + gid.x),
18401
- i32(bounds_uniform.min_y + gid.y)
18402
- );
18460
+ if (alpha < 0.001) {
18461
+ discard;
18462
+ }
18403
18463
 
18404
- let dst_coord = vec2<i32>(i32(gid.x), i32(gid.y));
18405
- let pixel = textureLoad(input_texture_crop, src_coord, 0);
18406
- textureStore(output_texture, dst_coord, pixel);
18464
+ let msdfColor = vec4f(text.color.rgb, text.color.a * alpha);
18465
+ return msdfColor;
18407
18466
 
18408
- // Output dimensions from workgroup (0,0) only
18409
- if (gid.x == 0u && gid.y == 0u) {
18410
- dimensions_out.original_width = size.x;
18411
- dimensions_out.original_height = size.y;
18412
- dimensions_out.cropped_width = crop_width;
18413
- dimensions_out.cropped_height = crop_height;
18467
+ // Debug options:
18468
+ // return text.color;
18469
+ // return input.debugColor;
18470
+ // return vec4f(1, 0, 1, 1); // hardcoded magenta
18471
+ // return textureSample(fontTexture, fontSampler, input.texcoord);
18472
+ }
18473
+ `;
18474
+
18475
+ // src/backends/webgpu/FontPipeline.ts
18476
+ class FontPipeline {
18477
+ pipeline;
18478
+ font;
18479
+ fontBindGroup;
18480
+ maxCharCount;
18481
+ constructor(pipeline, font, fontBindGroup, maxCharCount) {
18482
+ this.pipeline = pipeline;
18483
+ this.font = font;
18484
+ this.fontBindGroup = fontBindGroup;
18485
+ this.maxCharCount = maxCharCount;
18486
+ }
18487
+ static async create(device, font, colorFormat, maxCharCount) {
18488
+ const pipeline = await pipelinePromise(device, colorFormat, font.name);
18489
+ const texture = device.createTexture({
18490
+ label: `MSDF font ${font.name}`,
18491
+ size: [font.imageBitmap.width, font.imageBitmap.height, 1],
18492
+ format: "rgba8unorm",
18493
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
18494
+ });
18495
+ device.queue.copyExternalImageToTexture({ source: font.imageBitmap }, { texture }, [font.imageBitmap.width, font.imageBitmap.height]);
18496
+ const charsGpuBuffer = device.createBuffer({
18497
+ label: `MSDF font ${font.name} character layout buffer`,
18498
+ size: font.charCount * Float32Array.BYTES_PER_ELEMENT * 8,
18499
+ usage: GPUBufferUsage.STORAGE,
18500
+ mappedAtCreation: true
18501
+ });
18502
+ const charsArray = new Float32Array(charsGpuBuffer.getMappedRange());
18503
+ charsArray.set(font.charBuffer, 0);
18504
+ charsGpuBuffer.unmap();
18505
+ const fontDataBuffer = device.createBuffer({
18506
+ label: `MSDF font ${font.name} metadata buffer`,
18507
+ size: Float32Array.BYTES_PER_ELEMENT * 4,
18508
+ usage: GPUBufferUsage.UNIFORM,
18509
+ mappedAtCreation: true
18510
+ });
18511
+ const fontDataArray = new Float32Array(fontDataBuffer.getMappedRange());
18512
+ fontDataArray[0] = font.lineHeight;
18513
+ fontDataBuffer.unmap();
18514
+ const fontBindGroup = device.createBindGroup({
18515
+ layout: pipeline.getBindGroupLayout(0),
18516
+ entries: [
18517
+ {
18518
+ binding: 0,
18519
+ resource: texture.createView()
18520
+ },
18521
+ {
18522
+ binding: 1,
18523
+ resource: device.createSampler(sampler)
18524
+ },
18525
+ {
18526
+ binding: 2,
18527
+ resource: {
18528
+ buffer: charsGpuBuffer
18529
+ }
18530
+ },
18531
+ {
18532
+ binding: 3,
18533
+ resource: {
18534
+ buffer: fontDataBuffer
18535
+ }
18536
+ }
18537
+ ]
18538
+ });
18539
+ return new FontPipeline(pipeline, font, fontBindGroup, maxCharCount);
18540
+ }
18541
+ }
18542
+ function pipelinePromise(device, colorFormat, label) {
18543
+ const shader = device.createShaderModule({
18544
+ label: `${label} shader`,
18545
+ code: text_wgsl_default
18546
+ });
18547
+ return device.createRenderPipelineAsync({
18548
+ label: `${label} pipeline`,
18549
+ layout: device.createPipelineLayout({
18550
+ bindGroupLayouts: [
18551
+ device.createBindGroupLayout(fontBindGroupLayout),
18552
+ device.createBindGroupLayout(textUniformBindGroupLayout),
18553
+ device.createBindGroupLayout(engineUniformBindGroupLayout)
18554
+ ]
18555
+ }),
18556
+ vertex: {
18557
+ module: shader,
18558
+ entryPoint: "vertexMain"
18559
+ },
18560
+ fragment: {
18561
+ module: shader,
18562
+ entryPoint: "fragmentMain",
18563
+ targets: [
18564
+ {
18565
+ format: colorFormat,
18566
+ blend: {
18567
+ color: {
18568
+ srcFactor: "src-alpha",
18569
+ dstFactor: "one-minus-src-alpha"
18570
+ },
18571
+ alpha: {
18572
+ srcFactor: "one",
18573
+ dstFactor: "one"
18574
+ }
18575
+ }
18576
+ }
18577
+ ]
18578
+ },
18579
+ primitive: {
18580
+ topology: "triangle-strip",
18581
+ stripIndexFormat: "uint32"
18414
18582
  }
18583
+ });
18415
18584
  }
18416
-
18417
- // ==============================
18418
- // === MISSING TEXTURE FILL ====
18419
- // ==============================
18420
-
18421
- // Output texture to draw a fallback checkerboard
18422
- @group(0) @binding(0)
18423
- var checker_texture: texture_storage_2d<rgba8unorm, write>;
18424
-
18425
- // Compute shader to fill a texture with a purple & green checkerboard
18426
- @compute @workgroup_size(8, 8)
18427
- fn missing_texture(@builtin(global_invocation_id) id: vec3<u32>) {
18428
- let size = textureDimensions(checker_texture);
18429
- if (id.x >= size.x || id.y >= size.y) {
18430
- return;
18585
+ if (typeof GPUShaderStage === "undefined") {
18586
+ globalThis.GPUShaderStage = {
18587
+ VERTEX: 1,
18588
+ FRAGMENT: 2,
18589
+ COMPUTE: 4
18590
+ };
18591
+ }
18592
+ var fontBindGroupLayout = {
18593
+ label: "MSDF font group layout",
18594
+ entries: [
18595
+ {
18596
+ binding: 0,
18597
+ visibility: GPUShaderStage.FRAGMENT,
18598
+ texture: {}
18599
+ },
18600
+ {
18601
+ binding: 1,
18602
+ visibility: GPUShaderStage.FRAGMENT,
18603
+ sampler: {}
18604
+ },
18605
+ {
18606
+ binding: 2,
18607
+ visibility: GPUShaderStage.VERTEX,
18608
+ buffer: { type: "read-only-storage" }
18609
+ },
18610
+ {
18611
+ binding: 3,
18612
+ visibility: GPUShaderStage.VERTEX,
18613
+ buffer: {}
18431
18614
  }
18432
-
18433
- let checker_size = 25u;
18434
- let on_color = ((id.x / checker_size + id.y / checker_size) % 2u) == 0u;
18435
-
18436
- let color = select(
18437
- vec4<f32>(0.5, 0.0, 0.5, 1.0), // Purple
18438
- vec4<f32>(0.0, 1.0, 0.0, 1.0), // Green
18439
- on_color
18440
- );
18441
-
18442
- textureStore(checker_texture, vec2<i32>(id.xy), color);
18615
+ ]
18616
+ };
18617
+ var engineUniformBindGroupLayout = {
18618
+ label: "Uniform bind group",
18619
+ entries: [
18620
+ {
18621
+ binding: 0,
18622
+ visibility: GPUShaderStage.VERTEX,
18623
+ buffer: {}
18624
+ }
18625
+ ]
18626
+ };
18627
+ var sampler = {
18628
+ label: "MSDF text sampler",
18629
+ minFilter: "linear",
18630
+ magFilter: "linear",
18631
+ mipmapFilter: "linear",
18632
+ maxAnisotropy: 16
18633
+ };
18634
+ var textUniformBindGroupLayout = {
18635
+ label: "MSDF text block uniform",
18636
+ entries: [
18637
+ {
18638
+ binding: 0,
18639
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
18640
+ buffer: { type: "read-only-storage" }
18641
+ },
18642
+ {
18643
+ binding: 1,
18644
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
18645
+ buffer: { type: "read-only-storage" }
18646
+ }
18647
+ ]
18648
+ };
18649
+ // src/backends/webgpu/WebGPUTextShader.ts
18650
+ var deets = new _t2(text_wgsl_default);
18651
+ var struct = deets.structs.find((s3) => s3.name === "TextBlockDescriptor");
18652
+ if (!struct) {
18653
+ throw new Error("FormattedText struct not found");
18443
18654
  }
18444
- `;
18445
-
18446
- // src/backends/webgpu/TextureComputeShader.ts
18447
- var BOUNDING_BOX_SIZE = 4 * Uint32Array.BYTES_PER_ELEMENT;
18448
- var WORKGROUP_SIZE = 8;
18449
- var MAX_BOUND = 4294967295;
18450
- var MIN_BOUND = 0;
18655
+ var textDescriptorInstanceSize = struct.size;
18451
18656
 
18452
- class TextureComputeShader {
18453
- #device;
18454
- #boundingBuffer;
18455
- #cropPipeline;
18456
- #boundPipeline;
18457
- #missingTexturePipeline;
18458
- constructor(device, cropPipeline, boundPipeline, missingTexturePipeline) {
18459
- this.#device = device;
18460
- this.#boundPipeline = boundPipeline;
18461
- this.#cropPipeline = cropPipeline;
18462
- this.#missingTexturePipeline = missingTexturePipeline;
18463
- this.#boundingBuffer = this.#device.createBuffer({
18464
- size: BOUNDING_BOX_SIZE,
18465
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST
18466
- });
18467
- }
18468
- static create(device) {
18469
- const pipelines = createPipelines(device, "TextureComputeShader");
18470
- return new TextureComputeShader(device, pipelines.cropPipeline, pipelines.boundPipeline, pipelines.missingTexturePipeline);
18471
- }
18472
- async processTexture(textureWrapper) {
18473
- const boundsBindGroup = this.#boundsBindGroup(textureWrapper.texture);
18474
- const commandEncoder = this.#device.createCommandEncoder();
18475
- const passEncoder = commandEncoder.beginComputePass();
18476
- const dispatchX = Math.ceil(textureWrapper.texture.width / WORKGROUP_SIZE);
18477
- const dispatchY = Math.ceil(textureWrapper.texture.height / WORKGROUP_SIZE);
18478
- const boundsInit = new Uint32Array([
18479
- MAX_BOUND,
18480
- MAX_BOUND,
18481
- MIN_BOUND,
18482
- MIN_BOUND
18483
- ]);
18484
- this.#device.queue.writeBuffer(this.#boundingBuffer, 0, boundsInit.buffer, 0, BOUNDING_BOX_SIZE);
18485
- passEncoder.setPipeline(this.#boundPipeline);
18486
- passEncoder.setBindGroup(0, boundsBindGroup);
18487
- passEncoder.dispatchWorkgroups(dispatchX, dispatchY);
18488
- passEncoder.end();
18489
- this.#device.queue.submit([commandEncoder.finish()]);
18490
- const { texelX, texelY, texelWidth, texelHeight, computeBuffer } = await this.#getBoundingBox();
18491
- if (texelX === MAX_BOUND || texelY === MAX_BOUND) {
18492
- return await this.#createMissingTexture(textureWrapper.texture);
18493
- }
18494
- const croppedTexture = await this.#cropTexture(texelWidth, texelHeight, computeBuffer, textureWrapper.texture);
18495
- const leftCrop = texelX;
18496
- const rightCrop = textureWrapper.originalSize.width - texelX - texelWidth;
18497
- const topCrop = texelY;
18498
- const bottomCrop = textureWrapper.originalSize.height - texelY - texelHeight;
18499
- textureWrapper = {
18500
- texture: croppedTexture,
18501
- cropOffset: { x: leftCrop - rightCrop, y: bottomCrop - topCrop },
18502
- originalSize: textureWrapper.originalSize
18503
- };
18504
- return textureWrapper;
18505
- }
18506
- async#getBoundingBox() {
18507
- const readBuffer = this.#device.createBuffer({
18508
- label: "AABB Compute Buffer",
18509
- size: BOUNDING_BOX_SIZE,
18510
- usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
18657
+ class WebGPUTextShader {
18658
+ label = "text";
18659
+ #backend;
18660
+ #pipeline;
18661
+ #bindGroups = [];
18662
+ #font;
18663
+ #maxCharCount;
18664
+ #engineUniformsBuffer;
18665
+ #descriptorBuffer;
18666
+ #textBlockBuffer;
18667
+ #cpuDescriptorBuffer;
18668
+ #cpuTextBlockBuffer;
18669
+ #instanceIndex = 0;
18670
+ #textBlockOffset = 0;
18671
+ constructor(backend, pipeline, font, _colorFormat, instanceCount) {
18672
+ this.#backend = backend;
18673
+ const device = backend.device;
18674
+ this.#font = font;
18675
+ this.#pipeline = pipeline.pipeline;
18676
+ this.#maxCharCount = pipeline.maxCharCount;
18677
+ this.#descriptorBuffer = device.createBuffer({
18678
+ label: "msdf text descriptor buffer",
18679
+ size: textDescriptorInstanceSize * instanceCount,
18680
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
18511
18681
  });
18512
- const copyEncoder = this.#device.createCommandEncoder();
18513
- copyEncoder.copyBufferToBuffer(this.#boundingBuffer, 0, readBuffer, 0, BOUNDING_BOX_SIZE);
18514
- this.#device.queue.submit([copyEncoder.finish()]);
18515
- await readBuffer.mapAsync(GPUMapMode.READ);
18516
- const computeBuffer = new Uint32Array(readBuffer.getMappedRange().slice(0));
18517
- readBuffer.unmap();
18518
- const [minX, minY, maxX, maxY] = computeBuffer;
18519
- return {
18520
- texelX: minX,
18521
- texelY: minY,
18522
- texelWidth: maxX - minX + 1,
18523
- texelHeight: maxY - minY + 1,
18524
- computeBuffer
18525
- };
18526
- }
18527
- async#cropTexture(croppedWidth, croppedHeight, computeBuffer, inputTexture) {
18528
- const boundsUniform = this.#device.createBuffer({
18529
- label: "Cropping Bounds Uniform Buffer",
18530
- size: BOUNDING_BOX_SIZE,
18682
+ this.#cpuDescriptorBuffer = new Float32Array(instanceCount * textDescriptorInstanceSize / Float32Array.BYTES_PER_ELEMENT);
18683
+ this.#cpuTextBlockBuffer = new Float32Array(instanceCount * this.maxCharCount * 4);
18684
+ this.#engineUniformsBuffer = device.createBuffer({
18685
+ label: "msdf view projection matrix",
18686
+ size: Float32Array.BYTES_PER_ELEMENT * 12,
18531
18687
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
18532
- });
18533
- this.#device.queue.writeBuffer(boundsUniform, 0, computeBuffer);
18534
- const outputTexture = this.#device.createTexture({
18535
- label: "Cropped Texture",
18536
- size: [croppedWidth, croppedHeight],
18537
- format: "rgba8unorm",
18538
- usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC
18539
- });
18540
- const dimensionsOutBuffer = this.#device.createBuffer({
18541
- label: "Cropping Dimensions Output Buffer",
18542
- size: BOUNDING_BOX_SIZE,
18543
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
18544
- });
18545
- const bindGroup = this.#croppingBindGroup(inputTexture, outputTexture, boundsUniform, dimensionsOutBuffer);
18546
- const encoder = this.#device.createCommandEncoder();
18547
- const pass = encoder.beginComputePass();
18548
- pass.setPipeline(this.#cropPipeline);
18549
- pass.setBindGroup(0, bindGroup);
18550
- pass.dispatchWorkgroups(Math.ceil(croppedWidth / WORKGROUP_SIZE), Math.ceil(croppedHeight / WORKGROUP_SIZE));
18551
- pass.end();
18552
- this.#device.queue.submit([encoder.finish()]);
18553
- return outputTexture;
18554
- }
18555
- async#createMissingTexture(inputTexture) {
18556
- const placeholder = this.#device.createTexture({
18557
- label: "Missing Placeholder Texture",
18558
- size: [inputTexture.width, inputTexture.height],
18559
- format: "rgba8unorm",
18560
- usage: GPUTextureUsage.COPY_SRC | GPUTextureUsage.COPY_DST | GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING
18561
- });
18562
- const encoder = this.#device.createCommandEncoder();
18563
- const pass = encoder.beginComputePass();
18564
- pass.setPipeline(this.#missingTexturePipeline);
18565
- pass.setBindGroup(0, this.#missingTextureBindGroup(placeholder));
18566
- pass.dispatchWorkgroups(placeholder.width / 8, placeholder.height / 8);
18567
- pass.end();
18568
- this.#device.queue.submit([encoder.finish()]);
18569
- return {
18570
- texture: placeholder,
18571
- cropOffset: { x: 0, y: 0 },
18572
- originalSize: { width: inputTexture.width, height: inputTexture.height }
18573
- };
18574
- }
18575
- #boundsBindGroup(inputTexture) {
18576
- return this.#device.createBindGroup({
18577
- layout: this.#boundPipeline.getBindGroupLayout(0),
18688
+ });
18689
+ this.#textBlockBuffer = device.createBuffer({
18690
+ label: "msdf text buffer",
18691
+ size: instanceCount * this.maxCharCount * 4 * Float32Array.BYTES_PER_ELEMENT,
18692
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
18693
+ });
18694
+ this.#bindGroups.push(pipeline.fontBindGroup);
18695
+ this.#bindGroups.push(device.createBindGroup({
18696
+ label: "msdf text bind group",
18697
+ layout: pipeline.pipeline.getBindGroupLayout(1),
18578
18698
  entries: [
18579
- { binding: 0, resource: inputTexture.createView() },
18580
- { binding: 1, resource: { buffer: this.#boundingBuffer } }
18699
+ {
18700
+ binding: 0,
18701
+ resource: { buffer: this.#descriptorBuffer }
18702
+ },
18703
+ {
18704
+ binding: 1,
18705
+ resource: { buffer: this.#textBlockBuffer }
18706
+ }
18581
18707
  ]
18582
- });
18583
- }
18584
- #croppingBindGroup(inputTexture, outputTexture, boundsUniform, dimensionsOutBuffer) {
18585
- return this.#device.createBindGroup({
18586
- layout: this.#cropPipeline.getBindGroupLayout(0),
18708
+ }));
18709
+ const engineUniformsBindGroup = device.createBindGroup({
18710
+ label: "msdf text uniforms bind group",
18711
+ layout: pipeline.pipeline.getBindGroupLayout(2),
18587
18712
  entries: [
18588
- { binding: 0, resource: inputTexture.createView() },
18589
- { binding: 1, resource: outputTexture.createView() },
18590
- { binding: 2, resource: { buffer: boundsUniform } },
18591
- { binding: 3, resource: { buffer: dimensionsOutBuffer } }
18713
+ {
18714
+ binding: 0,
18715
+ resource: { buffer: this.#engineUniformsBuffer }
18716
+ }
18592
18717
  ]
18593
18718
  });
18719
+ this.#bindGroups.push(engineUniformsBindGroup);
18594
18720
  }
18595
- #missingTextureBindGroup(outputTexture) {
18596
- return this.#device.createBindGroup({
18597
- layout: this.#missingTexturePipeline.getBindGroupLayout(0),
18598
- entries: [{ binding: 0, resource: outputTexture.createView() }]
18599
- });
18721
+ startFrame(uniform) {
18722
+ const device = this.#backend.device;
18723
+ device.queue.writeBuffer(this.#engineUniformsBuffer, 0, uniform.viewProjectionMatrix);
18724
+ this.#instanceIndex = 0;
18725
+ this.#textBlockOffset = 0;
18600
18726
  }
18601
- }
18602
- function createPipelines(device, label) {
18603
- const shader = device.createShaderModule({
18604
- label: `${label} Shader`,
18605
- code: pixel_scraping_wgsl_default
18606
- });
18607
- const findBoundsBindGroupLayout = device.createBindGroupLayout({
18608
- label: "Bounds Detection Layout",
18609
- entries: [
18610
- {
18611
- binding: 0,
18612
- visibility: GPUShaderStage.COMPUTE,
18613
- texture: { sampleType: "float", viewDimension: "2d" }
18614
- },
18615
- {
18616
- binding: 1,
18617
- visibility: GPUShaderStage.COMPUTE,
18618
- buffer: { type: "storage" }
18619
- }
18620
- ]
18621
- });
18622
- const cropBindGroupLayout = device.createBindGroupLayout({
18623
- label: "Cropping Layout",
18624
- entries: [
18625
- { binding: 0, visibility: GPUShaderStage.COMPUTE, texture: {} },
18626
- {
18627
- binding: 1,
18628
- visibility: GPUShaderStage.COMPUTE,
18629
- storageTexture: {
18630
- access: "write-only",
18631
- format: "rgba8unorm",
18632
- viewDimension: "2d"
18633
- }
18634
- },
18635
- {
18636
- binding: 2,
18637
- visibility: GPUShaderStage.COMPUTE,
18638
- buffer: { type: "uniform" }
18639
- },
18640
- {
18641
- binding: 3,
18642
- visibility: GPUShaderStage.COMPUTE,
18643
- buffer: { type: "storage" }
18644
- }
18645
- ]
18646
- });
18647
- const missingTextureBindGroupLayout = device.createBindGroupLayout({
18648
- label: "Missing Texture Layout",
18649
- entries: [
18650
- {
18651
- binding: 0,
18652
- visibility: GPUShaderStage.COMPUTE,
18653
- storageTexture: {
18654
- access: "write-only",
18655
- format: "rgba8unorm",
18656
- viewDimension: "2d"
18657
- }
18727
+ processBatch(nodes) {
18728
+ if (nodes.length === 0)
18729
+ return 0;
18730
+ const renderPass = this.#backend.renderPass;
18731
+ renderPass.setPipeline(this.#pipeline);
18732
+ for (let i3 = 0;i3 < this.#bindGroups.length; i3++) {
18733
+ renderPass.setBindGroup(i3, this.#bindGroups[i3]);
18734
+ }
18735
+ for (const node of nodes) {
18736
+ if (!(node instanceof TextNode)) {
18737
+ console.error(node);
18738
+ throw new Error(`Tried to use WebGPUTextShader on something that isn't a TextNode: ${node}`);
18658
18739
  }
18659
- ]
18660
- });
18661
- return {
18662
- boundPipeline: device.createComputePipeline({
18663
- label: `${label} - Find Bounds Pipeline`,
18664
- layout: device.createPipelineLayout({
18665
- bindGroupLayouts: [findBoundsBindGroupLayout]
18666
- }),
18667
- compute: { module: shader, entryPoint: "find_bounds" }
18668
- }),
18669
- cropPipeline: device.createComputePipeline({
18670
- label: `${label} - Crop Pipeline`,
18671
- layout: device.createPipelineLayout({
18672
- bindGroupLayouts: [cropBindGroupLayout]
18673
- }),
18674
- compute: { module: shader, entryPoint: "crop_and_output" }
18675
- }),
18676
- missingTexturePipeline: device.createComputePipeline({
18677
- label: `${label} - Missing Texture Pipeline`,
18678
- layout: device.createPipelineLayout({
18679
- bindGroupLayouts: [missingTextureBindGroupLayout]
18680
- }),
18681
- compute: { module: shader, entryPoint: "missing_texture" }
18682
- })
18683
- };
18684
- }
18685
-
18686
- // src/text/text.wgsl.ts
18687
- var text_wgsl_default = `
18688
- // Adapted from: https://webgpu.github.io/webgpu-samples/?sample=textRenderingMsdf
18689
-
18690
- // Quad vertex positions for a character
18691
- const pos = array(
18692
- vec2f(0, -1),
18693
- vec2f(1, -1),
18694
- vec2f(0, 0),
18695
- vec2f(1, 0),
18696
- );
18697
-
18698
- // Debug colors for visualization
18699
- const debugColors = array(
18700
- vec4f(1, 0, 0, 1),
18701
- vec4f(0, 1, 0, 1),
18702
- vec4f(0, 0, 1, 1),
18703
- vec4f(1, 1, 1, 1),
18704
- );
18705
-
18706
- // Vertex input from GPU
18707
- struct VertexInput {
18708
- @builtin(vertex_index) vertex: u32,
18709
- @builtin(instance_index) instance: u32,
18710
- };
18711
-
18712
- // Output from vertex shader to fragment shader
18713
- struct VertexOutput {
18714
- @builtin(position) position: vec4f,
18715
- @location(0) texcoord: vec2f,
18716
- @location(1) debugColor: vec4f,
18717
- @location(2) @interpolate(flat) instanceIndex: u32,
18718
- };
18719
-
18720
- // Metadata for a single character glyph
18721
- struct Char {
18722
- texOffset: vec2f, // Offset to top-left in MSDF texture (pixels)
18723
- texExtent: vec2f, // Size in texture (pixels)
18724
- size: vec2f, // Glyph size in ems
18725
- offset: vec2f, // Position offset in ems
18726
- };
18727
-
18728
- // Metadata for a text block
18729
- struct TextBlockDescriptor {
18730
- transform: mat3x3f, // Text transform matrix (model matrix)
18731
- color: vec4f, // Text color
18732
- fontSize: f32, // Font size
18733
- blockWidth: f32, // Total width of text block
18734
- blockHeight: f32, // Total height of text block
18735
- bufferPosition: f32 // Index and length in textBuffer
18736
- };
18737
-
18738
- // Font bindings
18739
- @group(0) @binding(0) var fontTexture: texture_2d<f32>;
18740
- @group(0) @binding(1) var fontSampler: sampler;
18741
- @group(0) @binding(2) var<storage> chars: array<Char>;
18742
- @group(0) @binding(3) var<uniform> fontData: vec4f; // Contains line height (x)
18743
-
18744
- // Text bindings
18745
- @group(1) @binding(0) var<storage> texts: array<TextBlockDescriptor>;
18746
- @group(1) @binding(1) var<storage> textBuffer: array<vec4f>; // Each vec4: xy = glyph pos, z = char index
18747
-
18748
- // Global uniforms
18749
- @group(2) @binding(0) var<uniform> viewProjectionMatrix: mat3x3f;
18750
-
18751
- // Vertex shader
18752
- @vertex
18753
- fn vertexMain(input: VertexInput) -> VertexOutput {
18754
- // Because the instance index is used for character indexing, we are
18755
- // overloading the vertex index to store the instance of the text metadata.
18756
- //
18757
- // I.e...
18758
- // Vertex 0-4 = Instance 0, Vertex 0-4
18759
- // Vertex 4-8 = Instance 1, Vertex 0-4
18760
- // Vertex 8-12 = Instance 2, Vertex 0-4
18761
- let vertexIndex = input.vertex % 4;
18762
- let textIndex = input.vertex / 4;
18763
-
18764
- let text = texts[textIndex];
18765
- let textElement = textBuffer[u32(text.bufferPosition) + input.instance];
18766
- let char = chars[u32(textElement.z)];
18767
-
18768
- let lineHeight = fontData.x;
18769
- let textWidth = text.blockWidth;
18770
- let textHeight = text.blockHeight;
18771
-
18772
- // Center text vertically; origin is mid-height
18773
- let offset = vec2f(0, -textHeight / 2);
18774
-
18775
- // Glyph position in ems (quad pos * size + per-char offset)
18776
- let emPos = pos[vertexIndex] * char.size + char.offset + textElement.xy - offset;
18777
- let charPos = emPos * (text.fontSize / lineHeight);
18778
-
18779
- var output: VertexOutput;
18780
- let transformedPosition = viewProjectionMatrix * text.transform * vec3f(charPos, 1);
18781
-
18782
- output.position = vec4f(transformedPosition, 1);
18783
- output.texcoord = pos[vertexIndex] * vec2f(1, -1);
18784
- output.texcoord *= char.texExtent;
18785
- output.texcoord += char.texOffset;
18786
- output.debugColor = debugColors[vertexIndex];
18787
- output.instanceIndex = textIndex;
18788
- return output;
18789
-
18790
- // To debug - hardcode quad in bottom right quarter of the screen:
18791
- // output.position = vec4f(pos[input.vertex], 0, 1);
18740
+ const text = node.text;
18741
+ const formatting = node.formatting;
18742
+ const measurements = measureText(this.#font, text, formatting.wordWrap);
18743
+ const textBlockSize = 4 * text.length;
18744
+ const textDescriptorOffset = this.#instanceIndex * textDescriptorInstanceSize / Float32Array.BYTES_PER_ELEMENT;
18745
+ this.#cpuDescriptorBuffer.set(node.matrix, textDescriptorOffset);
18746
+ this.#cpuDescriptorBuffer.set([node.tint.r, node.tint.g, node.tint.b, node.tint.a], textDescriptorOffset + 12);
18747
+ const size = node.size ?? measurements;
18748
+ const fontSize = formatting.shrinkToFit ? findLargestFontSize(this.#font, text, size, formatting) : formatting.fontSize;
18749
+ const actualFontSize = fontSize || DEFAULT_FONT_SIZE;
18750
+ this.#cpuDescriptorBuffer[textDescriptorOffset + 16] = actualFontSize;
18751
+ this.#cpuDescriptorBuffer[textDescriptorOffset + 17] = formatting.align === "center" ? 0 : measurements.width;
18752
+ this.#cpuDescriptorBuffer[textDescriptorOffset + 18] = measurements.height;
18753
+ this.#cpuDescriptorBuffer[textDescriptorOffset + 19] = this.#textBlockOffset / 4;
18754
+ shapeText(this.#font, text, size, actualFontSize, formatting, this.#cpuTextBlockBuffer, this.#textBlockOffset);
18755
+ this.#backend.device.queue.writeBuffer(this.#descriptorBuffer, textDescriptorOffset * Float32Array.BYTES_PER_ELEMENT, this.#cpuDescriptorBuffer, textDescriptorOffset, textDescriptorInstanceSize / Float32Array.BYTES_PER_ELEMENT);
18756
+ this.#backend.device.queue.writeBuffer(this.#textBlockBuffer, this.#textBlockOffset * Float32Array.BYTES_PER_ELEMENT, this.#cpuTextBlockBuffer, this.#textBlockOffset, textBlockSize);
18757
+ this.#textBlockOffset += textBlockSize;
18758
+ renderPass.draw(4, measurements.printedCharCount, 4 * this.#instanceIndex, 0);
18759
+ this.#instanceIndex++;
18760
+ }
18761
+ return nodes.length;
18762
+ }
18763
+ endFrame() {}
18764
+ get font() {
18765
+ return this.#font;
18766
+ }
18767
+ get maxCharCount() {
18768
+ return this.#maxCharCount;
18769
+ }
18792
18770
  }
18793
18771
 
18794
- // Signed distance function sampling for MSDF font rendering
18795
- fn sampleMsdf(texcoord: vec2f) -> f32 {
18796
- let c = textureSample(fontTexture, fontSampler, texcoord);
18797
- return max(min(c.r, c.g), min(max(c.r, c.g), c.b));
18772
+ // src/scene/Batcher.ts
18773
+ class Batcher {
18774
+ nodes = [];
18775
+ layers = [];
18776
+ pipelines = [];
18777
+ enqueue(node) {
18778
+ if (node.renderComponent && node.isActive) {
18779
+ this.nodes.push(node);
18780
+ const z3 = node.layer;
18781
+ const layer = this.#findOrCreateLayer(z3);
18782
+ const pipeline = this.#findOrCreatePipeline(layer, node.renderComponent.shader);
18783
+ pipeline.nodes.push(node);
18784
+ }
18785
+ for (const kid of node.kids) {
18786
+ this.enqueue(kid);
18787
+ }
18788
+ }
18789
+ flush() {
18790
+ this.nodes = [];
18791
+ this.layers = [];
18792
+ this.pipelines = [];
18793
+ }
18794
+ #findOrCreateLayer(z3) {
18795
+ let layer = this.layers.find((l3) => l3.z === z3);
18796
+ if (!layer) {
18797
+ layer = { z: z3, pipelines: [] };
18798
+ this.layers.push(layer);
18799
+ this.layers.sort((a3, b3) => a3.z - b3.z);
18800
+ }
18801
+ return layer;
18802
+ }
18803
+ #findOrCreatePipeline(layer, shader) {
18804
+ let pipeline = layer.pipelines.find((p3) => p3.shader === shader);
18805
+ if (!pipeline) {
18806
+ pipeline = { shader, nodes: [] };
18807
+ layer.pipelines.push(pipeline);
18808
+ this.pipelines.push(pipeline);
18809
+ }
18810
+ return pipeline;
18811
+ }
18798
18812
  }
18799
18813
 
18800
- // Fragment shader
18801
- // Anti-aliasing technique by Paul Houx
18802
- // more details here:
18803
- // https://github.com/Chlumsky/msdfgen/issues/22#issuecomment-234958005
18804
- @fragment
18805
- fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
18806
- let text = texts[input.instanceIndex];
18807
-
18808
- // pxRange (AKA distanceRange) comes from the msdfgen tool.
18809
- let pxRange = 4.0;
18810
- let texSize = vec2f(textureDimensions(fontTexture, 0));
18811
-
18812
- let dx = texSize.x * length(vec2f(dpdxFine(input.texcoord.x), dpdyFine(input.texcoord.x)));
18813
- let dy = texSize.y * length(vec2f(dpdxFine(input.texcoord.y), dpdyFine(input.texcoord.y)));
18814
-
18815
- let toPixels = pxRange * inverseSqrt(dx * dx + dy * dy);
18816
- let sigDist = sampleMsdf(input.texcoord) - 0.5;
18817
- let pxDist = sigDist * toPixels;
18818
-
18819
- let edgeWidth = 0.5;
18820
- let alpha = smoothstep(-edgeWidth, edgeWidth, pxDist);
18821
-
18822
- if (alpha < 0.001) {
18823
- discard;
18814
+ // src/scene/Camera.ts
18815
+ class Camera {
18816
+ #position = { x: 0, y: 0 };
18817
+ #zoom = 1;
18818
+ #rotation = 0;
18819
+ #isDirty = true;
18820
+ #matrix = mat3.create();
18821
+ get zoom() {
18822
+ return this.#zoom;
18823
+ }
18824
+ set zoom(value) {
18825
+ this.#zoom = value;
18826
+ this.setDirty();
18827
+ }
18828
+ get rotation() {
18829
+ return rad2deg(this.#rotation);
18830
+ }
18831
+ set rotation(value) {
18832
+ this.#rotation = deg2rad(value);
18833
+ this.setDirty();
18834
+ }
18835
+ get rotationRadians() {
18836
+ return this.#rotation;
18837
+ }
18838
+ set rotationRadians(value) {
18839
+ this.#rotation = value;
18840
+ this.setDirty();
18841
+ }
18842
+ get x() {
18843
+ return this.#position.x;
18844
+ }
18845
+ get y() {
18846
+ return this.#position.y;
18847
+ }
18848
+ set x(value) {
18849
+ this.#position.x = value;
18850
+ this.setDirty();
18851
+ }
18852
+ set y(value) {
18853
+ this.#position.y = value;
18854
+ this.setDirty();
18855
+ }
18856
+ get matrix() {
18857
+ if (this.#isDirty) {
18858
+ this.#isDirty = false;
18859
+ this.#matrix = createViewMatrix(this, this.#matrix);
18860
+ }
18861
+ return this.#matrix;
18862
+ }
18863
+ setDirty() {
18864
+ this.#isDirty = true;
18824
18865
  }
18825
-
18826
- let msdfColor = vec4f(text.color.rgb, text.color.a * alpha);
18827
- return msdfColor;
18828
-
18829
- // Debug options:
18830
- // return text.color;
18831
- // return input.debugColor;
18832
- // return vec4f(1, 0, 1, 1); // hardcoded magenta
18833
- // return textureSample(fontTexture, fontSampler, input.texcoord);
18834
18866
  }
18835
- `;
18836
18867
 
18837
- // src/text/FontPipeline.ts
18838
- class FontPipeline {
18839
- pipeline;
18840
- font;
18841
- fontBindGroup;
18842
- maxCharCount;
18843
- constructor(pipeline, font, fontBindGroup, maxCharCount) {
18844
- this.pipeline = pipeline;
18845
- this.font = font;
18846
- this.fontBindGroup = fontBindGroup;
18847
- this.maxCharCount = maxCharCount;
18868
+ // src/scene/QuadNode.ts
18869
+ var PRIMITIVE_TEXTURE = "__primitive__";
18870
+ var RESERVED_PRIMITIVE_INDEX_START = 1000;
18871
+ var CIRCLE_INDEX = 1001;
18872
+ var DEFAULT_REGION = {
18873
+ x: 0,
18874
+ y: 0,
18875
+ width: 0,
18876
+ height: 0
18877
+ };
18878
+
18879
+ class QuadNode extends SceneNode {
18880
+ assetManager;
18881
+ #color;
18882
+ #atlasCoords;
18883
+ #region;
18884
+ #matrixPool;
18885
+ #flip;
18886
+ #cropOffset;
18887
+ #cropRatio;
18888
+ #atlasSize;
18889
+ #textureId;
18890
+ #writeInstance;
18891
+ constructor(options, matrixPool) {
18892
+ assert(options.shader, "QuadNode requires a shader to be explicitly provided");
18893
+ assert(options.size, "QuadNode requires a size to be explicitly provided");
18894
+ assert(options.atlasCoords, "QuadNode requires atlas coords to be explicitly provided");
18895
+ options.render ??= {
18896
+ shader: options.shader,
18897
+ writeInstance: writeQuadInstance
18898
+ };
18899
+ super(options);
18900
+ assert(options.assetManager, "QuadNode requires an asset manager");
18901
+ this.assetManager = options.assetManager;
18902
+ if (options.atlasCoords && options.atlasCoords.atlasIndex >= RESERVED_PRIMITIVE_INDEX_START) {
18903
+ this.#textureId = PRIMITIVE_TEXTURE;
18904
+ this.#region = DEFAULT_REGION;
18905
+ this.#atlasSize = DEFAULT_REGION;
18906
+ } else {
18907
+ assert(options.textureId, "QuadNode requires texture id to be explicitly provided");
18908
+ this.#textureId = options.textureId;
18909
+ assert(options.region, "QuadNode requires a region to be explicitly provided");
18910
+ this.#region = options.region;
18911
+ assert(options.atlasSize, "QuadNode requires atlas size to be explicitly provided");
18912
+ this.#atlasSize = options.atlasSize;
18913
+ }
18914
+ this.#atlasCoords = options.atlasCoords;
18915
+ this.#color = options.color ?? { r: 1, g: 1, b: 1, a: 1 };
18916
+ this.#matrixPool = matrixPool;
18917
+ this.#flip = { x: options.flipX ? -1 : 1, y: options.flipY ? -1 : 1 };
18918
+ this.#cropOffset = options.cropOffset ?? { x: 0, y: 0 };
18919
+ this.#cropRatio = !this.#atlasCoords.uvScaleCropped ? { width: 1, height: 1 } : {
18920
+ width: this.#atlasCoords.uvScaleCropped.width / this.#atlasCoords.uvScale.width,
18921
+ height: this.#atlasCoords.uvScaleCropped.height / this.#atlasCoords.uvScale.height
18922
+ };
18923
+ this.#writeInstance = options.writeInstance;
18924
+ }
18925
+ get color() {
18926
+ return this.#color;
18927
+ }
18928
+ set color(value) {
18929
+ this.#color = value;
18930
+ }
18931
+ get size() {
18932
+ const size = super.size;
18933
+ if (!size) {
18934
+ throw new Error("QuadNode requires a size");
18935
+ }
18936
+ return size;
18937
+ }
18938
+ set size(val) {
18939
+ super.size = val;
18940
+ }
18941
+ get matrixWithSize() {
18942
+ const matrix = mat3.clone(this.matrix, this.#matrixPool.get());
18943
+ mat3.scale(matrix, [this.size.width * this.#flip.x, this.size.height * this.#flip.y], matrix);
18944
+ return matrix;
18945
+ }
18946
+ get atlasCoords() {
18947
+ return this.#atlasCoords;
18948
+ }
18949
+ get region() {
18950
+ return this.#region;
18951
+ }
18952
+ get writeInstance() {
18953
+ return this.#writeInstance;
18954
+ }
18955
+ get flipX() {
18956
+ return this.#flip.x === -1;
18957
+ }
18958
+ set flipX(value) {
18959
+ this.#flip.x = value ? -1 : 1;
18960
+ this.setDirty();
18961
+ }
18962
+ get flipY() {
18963
+ return this.#flip.y === -1;
18848
18964
  }
18849
- static async create(device, font, colorFormat, maxCharCount) {
18850
- const pipeline = await pipelinePromise(device, colorFormat, font.name);
18851
- const texture = device.createTexture({
18852
- label: `MSDF font ${font.name}`,
18853
- size: [font.imageBitmap.width, font.imageBitmap.height, 1],
18854
- format: "rgba8unorm",
18855
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
18856
- });
18857
- device.queue.copyExternalImageToTexture({ source: font.imageBitmap }, { texture }, [font.imageBitmap.width, font.imageBitmap.height]);
18858
- const charsGpuBuffer = device.createBuffer({
18859
- label: `MSDF font ${font.name} character layout buffer`,
18860
- size: font.charCount * Float32Array.BYTES_PER_ELEMENT * 8,
18861
- usage: GPUBufferUsage.STORAGE,
18862
- mappedAtCreation: true
18863
- });
18864
- const charsArray = new Float32Array(charsGpuBuffer.getMappedRange());
18865
- charsArray.set(font.charBuffer, 0);
18866
- charsGpuBuffer.unmap();
18867
- const fontDataBuffer = device.createBuffer({
18868
- label: `MSDF font ${font.name} metadata buffer`,
18869
- size: Float32Array.BYTES_PER_ELEMENT * 4,
18870
- usage: GPUBufferUsage.UNIFORM,
18871
- mappedAtCreation: true
18872
- });
18873
- const fontDataArray = new Float32Array(fontDataBuffer.getMappedRange());
18874
- fontDataArray[0] = font.lineHeight;
18875
- fontDataBuffer.unmap();
18876
- const fontBindGroup = device.createBindGroup({
18877
- layout: pipeline.getBindGroupLayout(0),
18878
- entries: [
18879
- {
18880
- binding: 0,
18881
- resource: texture.createView()
18882
- },
18883
- {
18884
- binding: 1,
18885
- resource: device.createSampler(sampler)
18886
- },
18887
- {
18888
- binding: 2,
18889
- resource: {
18890
- buffer: charsGpuBuffer
18891
- }
18892
- },
18893
- {
18894
- binding: 3,
18895
- resource: {
18896
- buffer: fontDataBuffer
18897
- }
18898
- }
18899
- ]
18900
- });
18901
- return new FontPipeline(pipeline, font, fontBindGroup, maxCharCount);
18965
+ set flipY(value) {
18966
+ this.#flip.y = value ? -1 : 1;
18967
+ this.setDirty();
18902
18968
  }
18903
- }
18904
- function pipelinePromise(device, colorFormat, label) {
18905
- const shader = device.createShaderModule({
18906
- label: `${label} shader`,
18907
- code: text_wgsl_default
18908
- });
18909
- return device.createRenderPipelineAsync({
18910
- label: `${label} pipeline`,
18911
- layout: device.createPipelineLayout({
18912
- bindGroupLayouts: [
18913
- device.createBindGroupLayout(fontBindGroupLayout),
18914
- device.createBindGroupLayout(textUniformBindGroupLayout),
18915
- device.createBindGroupLayout(engineUniformBindGroupLayout)
18916
- ]
18917
- }),
18918
- vertex: {
18919
- module: shader,
18920
- entryPoint: "vertexMain"
18969
+ get cropOffset() {
18970
+ return this.#cropOffset;
18971
+ }
18972
+ set cropOffset(value) {
18973
+ this.#cropOffset = value;
18974
+ }
18975
+ get textureId() {
18976
+ return this.#textureId;
18977
+ }
18978
+ get isPrimitive() {
18979
+ return this.#textureId === PRIMITIVE_TEXTURE;
18980
+ }
18981
+ get isCircle() {
18982
+ return this.#atlasCoords.atlasIndex === CIRCLE_INDEX;
18983
+ }
18984
+ extra = {
18985
+ setAtlasCoords: (value) => {
18986
+ this.#atlasCoords = value;
18921
18987
  },
18922
- fragment: {
18923
- module: shader,
18924
- entryPoint: "fragmentMain",
18925
- targets: [
18926
- {
18927
- format: colorFormat,
18928
- blend: {
18929
- color: {
18930
- srcFactor: "src-alpha",
18931
- dstFactor: "one-minus-src-alpha"
18932
- },
18933
- alpha: {
18934
- srcFactor: "one",
18935
- dstFactor: "one"
18936
- }
18937
- }
18938
- }
18939
- ]
18988
+ cropRatio: () => {
18989
+ return this.#cropRatio;
18940
18990
  },
18941
- primitive: {
18942
- topology: "triangle-strip",
18943
- stripIndexFormat: "uint32"
18991
+ atlasSize: () => {
18992
+ return this.#atlasSize;
18944
18993
  }
18945
- });
18946
- }
18947
- if (typeof GPUShaderStage === "undefined") {
18948
- globalThis.GPUShaderStage = {
18949
- VERTEX: 1,
18950
- FRAGMENT: 2,
18951
- COMPUTE: 4
18952
18994
  };
18953
18995
  }
18954
- var fontBindGroupLayout = {
18955
- label: "MSDF font group layout",
18956
- entries: [
18957
- {
18958
- binding: 0,
18959
- visibility: GPUShaderStage.FRAGMENT,
18960
- texture: {}
18961
- },
18962
- {
18963
- binding: 1,
18964
- visibility: GPUShaderStage.FRAGMENT,
18965
- sampler: {}
18966
- },
18967
- {
18968
- binding: 2,
18969
- visibility: GPUShaderStage.VERTEX,
18970
- buffer: { type: "read-only-storage" }
18971
- },
18972
- {
18973
- binding: 3,
18974
- visibility: GPUShaderStage.VERTEX,
18975
- buffer: {}
18996
+ function writeQuadInstance(node, array, offset) {
18997
+ if (!(node instanceof QuadNode)) {
18998
+ throw new Error("QuadNode.writeInstance can only be called on QuadNodes");
18999
+ }
19000
+ array.set(node.matrixWithSize, offset);
19001
+ array.set([node.color.r, node.color.g, node.color.b, node.color.a], offset + 12);
19002
+ const region = node.region;
19003
+ if (node.textureId === PRIMITIVE_TEXTURE) {
19004
+ array.set([
19005
+ node.atlasCoords.uvOffset.x,
19006
+ node.atlasCoords.uvOffset.y,
19007
+ node.atlasCoords.uvScale.width,
19008
+ node.atlasCoords.uvScale.height
19009
+ ], offset + 16);
19010
+ } else {
19011
+ const atlasSize = node.extra.atlasSize();
19012
+ array.set([
19013
+ node.atlasCoords.uvOffset.x + region.x / atlasSize.width,
19014
+ node.atlasCoords.uvOffset.y + region.y / atlasSize.height,
19015
+ region.width / atlasSize.width,
19016
+ region.height / atlasSize.height
19017
+ ], offset + 16);
19018
+ }
19019
+ array.set([
19020
+ node.cropOffset.x / 2 / (node.atlasCoords.originalSize.width || 1),
19021
+ node.cropOffset.y / 2 / (node.atlasCoords.originalSize.height || 1),
19022
+ node.extra.cropRatio().width,
19023
+ node.extra.cropRatio().height
19024
+ ], offset + 20);
19025
+ new DataView(array.buffer).setUint32(array.byteOffset + (offset + 24) * Float32Array.BYTES_PER_ELEMENT, node.atlasCoords.atlasIndex, true);
19026
+ node.writeInstance?.(array, offset + 28);
19027
+ return 1;
19028
+ }
19029
+
19030
+ // src/scene/JumboQuadNode.ts
19031
+ var MAT3_SIZE = 12;
19032
+ var VEC4F_SIZE = 4;
19033
+
19034
+ class JumboQuadNode extends QuadNode {
19035
+ #tiles;
19036
+ #matrixPool;
19037
+ constructor(options, matrixPool) {
19038
+ assert(options.shader, "JumboQuadNode requires a shader to be explicitly provided");
19039
+ assert(options.tiles && options.tiles.length > 0, "JumboQuadNode requires at least one tile to be provided");
19040
+ options.render ??= {
19041
+ shader: options.shader,
19042
+ writeInstance: writeJumboQuadInstance
19043
+ };
19044
+ super({
19045
+ ...options,
19046
+ atlasCoords: options.tiles[0].atlasCoords
19047
+ }, matrixPool);
19048
+ this.#matrixPool = matrixPool;
19049
+ this.#tiles = [];
19050
+ for (const tile of options.tiles) {
19051
+ assert(tile.atlasCoords, "JumboQuadNode requires atlas coords to be provided");
19052
+ assert(tile.size, "JumboQuadNode requires a size to be provided");
19053
+ this.#tiles.push({
19054
+ textureId: tile.textureId,
19055
+ offset: tile.offset,
19056
+ size: tile.size,
19057
+ atlasCoords: tile.atlasCoords
19058
+ });
18976
19059
  }
18977
- ]
19060
+ }
19061
+ get atlasCoords() {
19062
+ throw new Error("JumboQuadNode does not have a single atlas coords");
19063
+ }
19064
+ get tiles() {
19065
+ return this.#tiles;
19066
+ }
19067
+ getTileMatrix(tile) {
19068
+ const matrix = mat3.clone(this.matrix, this.#matrixPool.get());
19069
+ const originalSize = {
19070
+ width: Math.max(...this.#tiles.map((t3) => t3.offset.x + t3.size.width)),
19071
+ height: Math.max(...this.#tiles.map((t3) => t3.offset.y + t3.size.height))
19072
+ };
19073
+ const proportionalSize = {
19074
+ width: this.size.width / originalSize.width,
19075
+ height: this.size.height / originalSize.height
19076
+ };
19077
+ const centerOffset = {
19078
+ x: tile.offset.x + tile.size.width / 2 - originalSize.width / 2,
19079
+ y: -(tile.offset.y + tile.size.height / 2 - originalSize.height / 2)
19080
+ };
19081
+ mat3.translate(matrix, [
19082
+ centerOffset.x * proportionalSize.width,
19083
+ centerOffset.y * proportionalSize.height
19084
+ ], matrix);
19085
+ mat3.scale(matrix, [
19086
+ tile.size.width * proportionalSize.width * (this.flipX ? -1 : 1),
19087
+ tile.size.height * proportionalSize.height * (this.flipY ? -1 : 1)
19088
+ ], matrix);
19089
+ return matrix;
19090
+ }
19091
+ }
19092
+ function writeJumboQuadInstance(node, array, offset) {
19093
+ if (!(node instanceof JumboQuadNode)) {
19094
+ throw new Error("JumboQuadNode.writeJumboQuadInstance can only be called on JumboQuadNodes");
19095
+ }
19096
+ let tileOffset = 0;
19097
+ for (const tile of node.tiles) {
19098
+ const coord = tile.atlasCoords;
19099
+ const matrix = node.getTileMatrix(tile);
19100
+ array.set(matrix, offset + tileOffset);
19101
+ tileOffset += MAT3_SIZE;
19102
+ array.set([node.color.r, node.color.g, node.color.b, node.color.a], offset + tileOffset);
19103
+ tileOffset += VEC4F_SIZE;
19104
+ array.set([
19105
+ coord.uvOffset.x,
19106
+ coord.uvOffset.y,
19107
+ coord.uvScale.width,
19108
+ coord.uvScale.height
19109
+ ], offset + tileOffset);
19110
+ tileOffset += VEC4F_SIZE;
19111
+ const cropRatio = !coord.uvScaleCropped ? { width: 1, height: 1 } : {
19112
+ width: coord.uvScaleCropped.width / coord.uvScale.width,
19113
+ height: coord.uvScaleCropped.height / coord.uvScale.height
19114
+ };
19115
+ array.set([
19116
+ tile.atlasCoords.cropOffset.x / 2 / (tile.atlasCoords.originalSize.width || 1),
19117
+ tile.atlasCoords.cropOffset.y / 2 / (tile.atlasCoords.originalSize.height || 1),
19118
+ cropRatio.width,
19119
+ cropRatio.height
19120
+ ], offset + tileOffset);
19121
+ tileOffset += VEC4F_SIZE;
19122
+ new DataView(array.buffer).setUint32(array.byteOffset + (offset + tileOffset) * Float32Array.BYTES_PER_ELEMENT, coord.atlasIndex, true);
19123
+ tileOffset += VEC4F_SIZE;
19124
+ }
19125
+ node.writeInstance?.(array, offset + tileOffset);
19126
+ return node.tiles.length;
19127
+ }
19128
+
19129
+ // src/backends/webgpu/wgsl/pixel-scraping.wgsl.ts
19130
+ var pixel_scraping_wgsl_default = `
19131
+ // ==============================
19132
+ // === BOUNDING BOX PASS =======
19133
+ // ==============================
19134
+
19135
+ // Input texture from which to compute the non-transparent bounding box
19136
+ @group(0) @binding(0)
19137
+ var input_texture: texture_2d<f32>;
19138
+
19139
+ // Atomic bounding box storage structure
19140
+ struct bounding_box_atomic {
19141
+ min_x: atomic<u32>,
19142
+ min_y: atomic<u32>,
19143
+ max_x: atomic<u32>,
19144
+ max_y: atomic<u32>,
18978
19145
  };
18979
- var engineUniformBindGroupLayout = {
18980
- label: "Uniform bind group",
18981
- entries: [
18982
- {
18983
- binding: 0,
18984
- visibility: GPUShaderStage.VERTEX,
18985
- buffer: {}
19146
+
19147
+ // Storage buffer to hold atomic bounding box updates
19148
+ @group(0) @binding(1)
19149
+ var<storage, read_write> bounds: bounding_box_atomic;
19150
+
19151
+ // Compute shader to find the bounding box of non-transparent pixels
19152
+ @compute @workgroup_size(8, 8)
19153
+ fn find_bounds(@builtin(global_invocation_id) gid: vec3<u32>) {
19154
+ let size = textureDimensions(input_texture).xy;
19155
+ if (gid.x >= size.x || gid.y >= size.y) {
19156
+ return;
18986
19157
  }
18987
- ]
19158
+
19159
+ let pixel = textureLoad(input_texture, vec2<i32>(gid.xy), 0);
19160
+ if (pixel.a > 0.0) {
19161
+ atomicMin(&bounds.min_x, gid.x);
19162
+ atomicMin(&bounds.min_y, gid.y);
19163
+ atomicMax(&bounds.max_x, gid.x);
19164
+ atomicMax(&bounds.max_y, gid.y);
19165
+ }
19166
+ }
19167
+
19168
+ // ==============================
19169
+ // === CROP + OUTPUT PASS ======
19170
+ // ==============================
19171
+
19172
+ // Input texture from which cropped data is read
19173
+ @group(0) @binding(0)
19174
+ var input_texture_crop: texture_2d<f32>;
19175
+
19176
+ // Output texture where cropped image is written
19177
+ @group(0) @binding(1)
19178
+ var output_texture: texture_storage_2d<rgba8unorm, write>;
19179
+
19180
+ // Bounding box passed in as a uniform (not atomic anymore)
19181
+ struct bounding_box_uniform {
19182
+ min_x: u32,
19183
+ min_y: u32,
19184
+ max_x: u32,
19185
+ max_y: u32,
18988
19186
  };
18989
- var sampler = {
18990
- label: "MSDF text sampler",
18991
- minFilter: "linear",
18992
- magFilter: "linear",
18993
- mipmapFilter: "linear",
18994
- maxAnisotropy: 16
19187
+
19188
+ @group(0) @binding(2)
19189
+ var<uniform> bounds_uniform: bounding_box_uniform;
19190
+
19191
+ // Struct to store both original and cropped texture dimensions
19192
+ struct image_dimensions {
19193
+ original_width: u32,
19194
+ original_height: u32,
19195
+ cropped_width: u32,
19196
+ cropped_height: u32,
18995
19197
  };
18996
- var textUniformBindGroupLayout = {
18997
- label: "MSDF text block uniform",
18998
- entries: [
18999
- {
19000
- binding: 0,
19001
- visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
19002
- buffer: { type: "read-only-storage" }
19003
- },
19004
- {
19005
- binding: 1,
19006
- visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
19007
- buffer: { type: "read-only-storage" }
19198
+
19199
+ // Storage buffer to output the result dimensions
19200
+ @group(0) @binding(3)
19201
+ var<storage, read_write> dimensions_out: image_dimensions;
19202
+
19203
+ // Compute shader to crop the input texture to the bounding box and output it
19204
+ @compute @workgroup_size(8, 8)
19205
+ fn crop_and_output(@builtin(global_invocation_id) gid: vec3<u32>) {
19206
+ let size = textureDimensions(input_texture_crop).xy;
19207
+
19208
+ let crop_width = bounds_uniform.max_x - bounds_uniform.min_x + 1u;
19209
+ let crop_height = bounds_uniform.max_y - bounds_uniform.min_y + 1u;
19210
+
19211
+ if (gid.x >= crop_width || gid.y >= crop_height) {
19212
+ return;
19008
19213
  }
19009
- ]
19010
- };
19011
19214
 
19012
- // src/text/TextShader.ts
19013
- var deets = new _t2(text_wgsl_default);
19014
- var struct = deets.structs.find((s3) => s3.name === "TextBlockDescriptor");
19015
- if (!struct) {
19016
- throw new Error("FormattedText struct not found");
19215
+ let src_coord = vec2<i32>(
19216
+ i32(bounds_uniform.min_x + gid.x),
19217
+ i32(bounds_uniform.min_y + gid.y)
19218
+ );
19219
+
19220
+ let dst_coord = vec2<i32>(i32(gid.x), i32(gid.y));
19221
+ let pixel = textureLoad(input_texture_crop, src_coord, 0);
19222
+ textureStore(output_texture, dst_coord, pixel);
19223
+
19224
+ // Output dimensions from workgroup (0,0) only
19225
+ if (gid.x == 0u && gid.y == 0u) {
19226
+ dimensions_out.original_width = size.x;
19227
+ dimensions_out.original_height = size.y;
19228
+ dimensions_out.cropped_width = crop_width;
19229
+ dimensions_out.cropped_height = crop_height;
19230
+ }
19017
19231
  }
19018
- var textDescriptorInstanceSize = struct.size;
19019
19232
 
19020
- class TextShader {
19021
- label = "text";
19022
- #backend;
19023
- #pipeline;
19024
- #bindGroups = [];
19025
- #font;
19026
- #maxCharCount;
19027
- #engineUniformsBuffer;
19028
- #descriptorBuffer;
19029
- #textBlockBuffer;
19030
- #cpuDescriptorBuffer;
19031
- #cpuTextBlockBuffer;
19032
- #instanceIndex = 0;
19033
- #textBlockOffset = 0;
19034
- constructor(backend, pipeline, font, _colorFormat, instanceCount) {
19035
- this.#backend = backend;
19036
- const device = backend.device;
19037
- this.#font = font;
19038
- this.#pipeline = pipeline.pipeline;
19039
- this.#maxCharCount = pipeline.maxCharCount;
19040
- this.#descriptorBuffer = device.createBuffer({
19041
- label: "msdf text descriptor buffer",
19042
- size: textDescriptorInstanceSize * instanceCount,
19043
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
19233
+ // ==============================
19234
+ // === MISSING TEXTURE FILL ====
19235
+ // ==============================
19236
+
19237
+ // Output texture to draw a fallback checkerboard
19238
+ @group(0) @binding(0)
19239
+ var checker_texture: texture_storage_2d<rgba8unorm, write>;
19240
+
19241
+ // Compute shader to fill a texture with a purple & green checkerboard
19242
+ @compute @workgroup_size(8, 8)
19243
+ fn missing_texture(@builtin(global_invocation_id) id: vec3<u32>) {
19244
+ let size = textureDimensions(checker_texture);
19245
+ if (id.x >= size.x || id.y >= size.y) {
19246
+ return;
19247
+ }
19248
+
19249
+ let checker_size = 25u;
19250
+ let on_color = ((id.x / checker_size + id.y / checker_size) % 2u) == 0u;
19251
+
19252
+ let color = select(
19253
+ vec4<f32>(0.5, 0.0, 0.5, 1.0), // Purple
19254
+ vec4<f32>(0.0, 1.0, 0.0, 1.0), // Green
19255
+ on_color
19256
+ );
19257
+
19258
+ textureStore(checker_texture, vec2<i32>(id.xy), color);
19259
+ }
19260
+ `;
19261
+
19262
+ // src/backends/webgpu/TextureComputeShader.ts
19263
+ var BOUNDING_BOX_SIZE = 4 * Uint32Array.BYTES_PER_ELEMENT;
19264
+ var WORKGROUP_SIZE = 8;
19265
+ var MAX_BOUND = 4294967295;
19266
+ var MIN_BOUND = 0;
19267
+
19268
+ class TextureComputeShader {
19269
+ #device;
19270
+ #boundingBuffer;
19271
+ #cropPipeline;
19272
+ #boundPipeline;
19273
+ #missingTexturePipeline;
19274
+ constructor(device, cropPipeline, boundPipeline, missingTexturePipeline) {
19275
+ this.#device = device;
19276
+ this.#boundPipeline = boundPipeline;
19277
+ this.#cropPipeline = cropPipeline;
19278
+ this.#missingTexturePipeline = missingTexturePipeline;
19279
+ this.#boundingBuffer = this.#device.createBuffer({
19280
+ size: BOUNDING_BOX_SIZE,
19281
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST
19282
+ });
19283
+ }
19284
+ static create(device) {
19285
+ const pipelines = createPipelines(device, "TextureComputeShader");
19286
+ return new TextureComputeShader(device, pipelines.cropPipeline, pipelines.boundPipeline, pipelines.missingTexturePipeline);
19287
+ }
19288
+ async processTexture(textureWrapper) {
19289
+ const boundsBindGroup = this.#boundsBindGroup(textureWrapper.texture);
19290
+ const commandEncoder = this.#device.createCommandEncoder();
19291
+ const passEncoder = commandEncoder.beginComputePass();
19292
+ const dispatchX = Math.ceil(textureWrapper.texture.width / WORKGROUP_SIZE);
19293
+ const dispatchY = Math.ceil(textureWrapper.texture.height / WORKGROUP_SIZE);
19294
+ const boundsInit = new Uint32Array([
19295
+ MAX_BOUND,
19296
+ MAX_BOUND,
19297
+ MIN_BOUND,
19298
+ MIN_BOUND
19299
+ ]);
19300
+ this.#device.queue.writeBuffer(this.#boundingBuffer, 0, boundsInit.buffer, 0, BOUNDING_BOX_SIZE);
19301
+ passEncoder.setPipeline(this.#boundPipeline);
19302
+ passEncoder.setBindGroup(0, boundsBindGroup);
19303
+ passEncoder.dispatchWorkgroups(dispatchX, dispatchY);
19304
+ passEncoder.end();
19305
+ this.#device.queue.submit([commandEncoder.finish()]);
19306
+ const { texelX, texelY, texelWidth, texelHeight, computeBuffer } = await this.#getBoundingBox();
19307
+ if (texelX === MAX_BOUND || texelY === MAX_BOUND) {
19308
+ return await this.#createMissingTexture(textureWrapper.texture);
19309
+ }
19310
+ const croppedTexture = await this.#cropTexture(texelWidth, texelHeight, computeBuffer, textureWrapper.texture);
19311
+ const leftCrop = texelX;
19312
+ const rightCrop = textureWrapper.originalSize.width - texelX - texelWidth;
19313
+ const topCrop = texelY;
19314
+ const bottomCrop = textureWrapper.originalSize.height - texelY - texelHeight;
19315
+ textureWrapper = {
19316
+ texture: croppedTexture,
19317
+ cropOffset: { x: leftCrop - rightCrop, y: bottomCrop - topCrop },
19318
+ originalSize: textureWrapper.originalSize
19319
+ };
19320
+ return textureWrapper;
19321
+ }
19322
+ async#getBoundingBox() {
19323
+ const readBuffer = this.#device.createBuffer({
19324
+ label: "AABB Compute Buffer",
19325
+ size: BOUNDING_BOX_SIZE,
19326
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
19044
19327
  });
19045
- this.#cpuDescriptorBuffer = new Float32Array(instanceCount * textDescriptorInstanceSize / Float32Array.BYTES_PER_ELEMENT);
19046
- this.#cpuTextBlockBuffer = new Float32Array(instanceCount * this.maxCharCount * 4);
19047
- this.#engineUniformsBuffer = device.createBuffer({
19048
- label: "msdf view projection matrix",
19049
- size: Float32Array.BYTES_PER_ELEMENT * 12,
19328
+ const copyEncoder = this.#device.createCommandEncoder();
19329
+ copyEncoder.copyBufferToBuffer(this.#boundingBuffer, 0, readBuffer, 0, BOUNDING_BOX_SIZE);
19330
+ this.#device.queue.submit([copyEncoder.finish()]);
19331
+ await readBuffer.mapAsync(GPUMapMode.READ);
19332
+ const computeBuffer = new Uint32Array(readBuffer.getMappedRange().slice(0));
19333
+ readBuffer.unmap();
19334
+ const [minX, minY, maxX, maxY] = computeBuffer;
19335
+ return {
19336
+ texelX: minX,
19337
+ texelY: minY,
19338
+ texelWidth: maxX - minX + 1,
19339
+ texelHeight: maxY - minY + 1,
19340
+ computeBuffer
19341
+ };
19342
+ }
19343
+ async#cropTexture(croppedWidth, croppedHeight, computeBuffer, inputTexture) {
19344
+ const boundsUniform = this.#device.createBuffer({
19345
+ label: "Cropping Bounds Uniform Buffer",
19346
+ size: BOUNDING_BOX_SIZE,
19050
19347
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
19051
19348
  });
19052
- this.#textBlockBuffer = device.createBuffer({
19053
- label: "msdf text buffer",
19054
- size: instanceCount * this.maxCharCount * 4 * Float32Array.BYTES_PER_ELEMENT,
19055
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
19349
+ this.#device.queue.writeBuffer(boundsUniform, 0, computeBuffer);
19350
+ const outputTexture = this.#device.createTexture({
19351
+ label: "Cropped Texture",
19352
+ size: [croppedWidth, croppedHeight],
19353
+ format: "rgba8unorm",
19354
+ usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC
19056
19355
  });
19057
- this.#bindGroups.push(pipeline.fontBindGroup);
19058
- this.#bindGroups.push(device.createBindGroup({
19059
- label: "msdf text bind group",
19060
- layout: pipeline.pipeline.getBindGroupLayout(1),
19356
+ const dimensionsOutBuffer = this.#device.createBuffer({
19357
+ label: "Cropping Dimensions Output Buffer",
19358
+ size: BOUNDING_BOX_SIZE,
19359
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
19360
+ });
19361
+ const bindGroup = this.#croppingBindGroup(inputTexture, outputTexture, boundsUniform, dimensionsOutBuffer);
19362
+ const encoder = this.#device.createCommandEncoder();
19363
+ const pass = encoder.beginComputePass();
19364
+ pass.setPipeline(this.#cropPipeline);
19365
+ pass.setBindGroup(0, bindGroup);
19366
+ pass.dispatchWorkgroups(Math.ceil(croppedWidth / WORKGROUP_SIZE), Math.ceil(croppedHeight / WORKGROUP_SIZE));
19367
+ pass.end();
19368
+ this.#device.queue.submit([encoder.finish()]);
19369
+ return outputTexture;
19370
+ }
19371
+ async#createMissingTexture(inputTexture) {
19372
+ const placeholder = this.#device.createTexture({
19373
+ label: "Missing Placeholder Texture",
19374
+ size: [inputTexture.width, inputTexture.height],
19375
+ format: "rgba8unorm",
19376
+ usage: GPUTextureUsage.COPY_SRC | GPUTextureUsage.COPY_DST | GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING
19377
+ });
19378
+ const encoder = this.#device.createCommandEncoder();
19379
+ const pass = encoder.beginComputePass();
19380
+ pass.setPipeline(this.#missingTexturePipeline);
19381
+ pass.setBindGroup(0, this.#missingTextureBindGroup(placeholder));
19382
+ pass.dispatchWorkgroups(placeholder.width / 8, placeholder.height / 8);
19383
+ pass.end();
19384
+ this.#device.queue.submit([encoder.finish()]);
19385
+ return {
19386
+ texture: placeholder,
19387
+ cropOffset: { x: 0, y: 0 },
19388
+ originalSize: { width: inputTexture.width, height: inputTexture.height }
19389
+ };
19390
+ }
19391
+ #boundsBindGroup(inputTexture) {
19392
+ return this.#device.createBindGroup({
19393
+ layout: this.#boundPipeline.getBindGroupLayout(0),
19061
19394
  entries: [
19062
- {
19063
- binding: 0,
19064
- resource: { buffer: this.#descriptorBuffer }
19065
- },
19066
- {
19067
- binding: 1,
19068
- resource: { buffer: this.#textBlockBuffer }
19069
- }
19395
+ { binding: 0, resource: inputTexture.createView() },
19396
+ { binding: 1, resource: { buffer: this.#boundingBuffer } }
19070
19397
  ]
19071
- }));
19072
- const engineUniformsBindGroup = device.createBindGroup({
19073
- label: "msdf text uniforms bind group",
19074
- layout: pipeline.pipeline.getBindGroupLayout(2),
19398
+ });
19399
+ }
19400
+ #croppingBindGroup(inputTexture, outputTexture, boundsUniform, dimensionsOutBuffer) {
19401
+ return this.#device.createBindGroup({
19402
+ layout: this.#cropPipeline.getBindGroupLayout(0),
19075
19403
  entries: [
19076
- {
19077
- binding: 0,
19078
- resource: { buffer: this.#engineUniformsBuffer }
19079
- }
19404
+ { binding: 0, resource: inputTexture.createView() },
19405
+ { binding: 1, resource: outputTexture.createView() },
19406
+ { binding: 2, resource: { buffer: boundsUniform } },
19407
+ { binding: 3, resource: { buffer: dimensionsOutBuffer } }
19080
19408
  ]
19081
19409
  });
19082
- this.#bindGroups.push(engineUniformsBindGroup);
19083
19410
  }
19084
- startFrame(uniform) {
19085
- const device = this.#backend.device;
19086
- device.queue.writeBuffer(this.#engineUniformsBuffer, 0, uniform.viewProjectionMatrix);
19087
- this.#instanceIndex = 0;
19088
- this.#textBlockOffset = 0;
19411
+ #missingTextureBindGroup(outputTexture) {
19412
+ return this.#device.createBindGroup({
19413
+ layout: this.#missingTexturePipeline.getBindGroupLayout(0),
19414
+ entries: [{ binding: 0, resource: outputTexture.createView() }]
19415
+ });
19089
19416
  }
19090
- processBatch(nodes) {
19091
- if (nodes.length === 0)
19092
- return 0;
19093
- const renderPass = this.#backend.renderPass;
19094
- renderPass.setPipeline(this.#pipeline);
19095
- for (let i3 = 0;i3 < this.#bindGroups.length; i3++) {
19096
- renderPass.setBindGroup(i3, this.#bindGroups[i3]);
19097
- }
19098
- for (const node of nodes) {
19099
- if (!(node instanceof TextNode)) {
19100
- console.error(node);
19101
- throw new Error(`Tried to use TextShader on something that isn't a TextNode: ${node}`);
19417
+ }
19418
+ function createPipelines(device, label) {
19419
+ const shader = device.createShaderModule({
19420
+ label: `${label} Shader`,
19421
+ code: pixel_scraping_wgsl_default
19422
+ });
19423
+ const findBoundsBindGroupLayout = device.createBindGroupLayout({
19424
+ label: "Bounds Detection Layout",
19425
+ entries: [
19426
+ {
19427
+ binding: 0,
19428
+ visibility: GPUShaderStage.COMPUTE,
19429
+ texture: { sampleType: "float", viewDimension: "2d" }
19430
+ },
19431
+ {
19432
+ binding: 1,
19433
+ visibility: GPUShaderStage.COMPUTE,
19434
+ buffer: { type: "storage" }
19102
19435
  }
19103
- const text = node.text;
19104
- const formatting = node.formatting;
19105
- const measurements = measureText(this.#font, text, formatting.wordWrap);
19106
- const textBlockSize = 4 * text.length;
19107
- const textDescriptorOffset = this.#instanceIndex * textDescriptorInstanceSize / Float32Array.BYTES_PER_ELEMENT;
19108
- this.#cpuDescriptorBuffer.set(node.matrix, textDescriptorOffset);
19109
- this.#cpuDescriptorBuffer.set([node.tint.r, node.tint.g, node.tint.b, node.tint.a], textDescriptorOffset + 12);
19110
- const size = node.size ?? measurements;
19111
- const fontSize = formatting.shrinkToFit ? findLargestFontSize(this.#font, text, size, formatting) : formatting.fontSize;
19112
- const actualFontSize = fontSize || DEFAULT_FONT_SIZE;
19113
- this.#cpuDescriptorBuffer[textDescriptorOffset + 16] = actualFontSize;
19114
- this.#cpuDescriptorBuffer[textDescriptorOffset + 17] = formatting.align === "center" ? 0 : measurements.width;
19115
- this.#cpuDescriptorBuffer[textDescriptorOffset + 18] = measurements.height;
19116
- this.#cpuDescriptorBuffer[textDescriptorOffset + 19] = this.#textBlockOffset / 4;
19117
- shapeText(this.#font, text, size, actualFontSize, formatting, this.#cpuTextBlockBuffer, this.#textBlockOffset);
19118
- this.#backend.device.queue.writeBuffer(this.#descriptorBuffer, textDescriptorOffset * Float32Array.BYTES_PER_ELEMENT, this.#cpuDescriptorBuffer, textDescriptorOffset, textDescriptorInstanceSize / Float32Array.BYTES_PER_ELEMENT);
19119
- this.#backend.device.queue.writeBuffer(this.#textBlockBuffer, this.#textBlockOffset * Float32Array.BYTES_PER_ELEMENT, this.#cpuTextBlockBuffer, this.#textBlockOffset, textBlockSize);
19120
- this.#textBlockOffset += textBlockSize;
19121
- renderPass.draw(4, measurements.printedCharCount, 4 * this.#instanceIndex, 0);
19122
- this.#instanceIndex++;
19123
- }
19124
- return nodes.length;
19125
- }
19126
- endFrame() {}
19127
- get font() {
19128
- return this.#font;
19129
- }
19130
- get maxCharCount() {
19131
- return this.#maxCharCount;
19132
- }
19436
+ ]
19437
+ });
19438
+ const cropBindGroupLayout = device.createBindGroupLayout({
19439
+ label: "Cropping Layout",
19440
+ entries: [
19441
+ { binding: 0, visibility: GPUShaderStage.COMPUTE, texture: {} },
19442
+ {
19443
+ binding: 1,
19444
+ visibility: GPUShaderStage.COMPUTE,
19445
+ storageTexture: {
19446
+ access: "write-only",
19447
+ format: "rgba8unorm",
19448
+ viewDimension: "2d"
19449
+ }
19450
+ },
19451
+ {
19452
+ binding: 2,
19453
+ visibility: GPUShaderStage.COMPUTE,
19454
+ buffer: { type: "uniform" }
19455
+ },
19456
+ {
19457
+ binding: 3,
19458
+ visibility: GPUShaderStage.COMPUTE,
19459
+ buffer: { type: "storage" }
19460
+ }
19461
+ ]
19462
+ });
19463
+ const missingTextureBindGroupLayout = device.createBindGroupLayout({
19464
+ label: "Missing Texture Layout",
19465
+ entries: [
19466
+ {
19467
+ binding: 0,
19468
+ visibility: GPUShaderStage.COMPUTE,
19469
+ storageTexture: {
19470
+ access: "write-only",
19471
+ format: "rgba8unorm",
19472
+ viewDimension: "2d"
19473
+ }
19474
+ }
19475
+ ]
19476
+ });
19477
+ return {
19478
+ boundPipeline: device.createComputePipeline({
19479
+ label: `${label} - Find Bounds Pipeline`,
19480
+ layout: device.createPipelineLayout({
19481
+ bindGroupLayouts: [findBoundsBindGroupLayout]
19482
+ }),
19483
+ compute: { module: shader, entryPoint: "find_bounds" }
19484
+ }),
19485
+ cropPipeline: device.createComputePipeline({
19486
+ label: `${label} - Crop Pipeline`,
19487
+ layout: device.createPipelineLayout({
19488
+ bindGroupLayouts: [cropBindGroupLayout]
19489
+ }),
19490
+ compute: { module: shader, entryPoint: "crop_and_output" }
19491
+ }),
19492
+ missingTexturePipeline: device.createComputePipeline({
19493
+ label: `${label} - Missing Texture Pipeline`,
19494
+ layout: device.createPipelineLayout({
19495
+ bindGroupLayouts: [missingTextureBindGroupLayout]
19496
+ }),
19497
+ compute: { module: shader, entryPoint: "missing_texture" }
19498
+ })
19499
+ };
19133
19500
  }
19134
19501
 
19135
19502
  // src/textures/Bundles.ts
@@ -19697,18 +20064,22 @@ class AssetManager {
19697
20064
  this.bundles.unloadBundle(bundleId);
19698
20065
  }
19699
20066
  async loadFont(id, url, fallbackCharacter = "_") {
19700
- if (this.#backend.type !== "webgpu") {
19701
- throw new Error("loadFont is only supported with WebGPU backend. Text rendering is not available in WebGL mode.");
19702
- }
19703
- const webgpuBackend = this.#backend;
19704
- const device = webgpuBackend.device;
19705
- const presentationFormat = webgpuBackend.presentationFormat;
19706
20067
  const limits = this.#backend.limits;
19707
20068
  const font = await MsdfFont.create(id, url);
19708
20069
  font.fallbackCharacter = fallbackCharacter;
19709
- const fontPipeline = await FontPipeline.create(device, font, presentationFormat, limits.maxTextLength);
19710
- const textShader = new TextShader(this.#backend, fontPipeline, font, presentationFormat, limits.instanceCount);
19711
- this.#fonts.set(id, textShader);
20070
+ if (this.#backend.type === "webgpu") {
20071
+ const webgpuBackend = this.#backend;
20072
+ const device = webgpuBackend.device;
20073
+ const presentationFormat = webgpuBackend.presentationFormat;
20074
+ const fontPipeline = await FontPipeline.create(device, font, presentationFormat, limits.maxTextLength);
20075
+ const textShader = new WebGPUTextShader(webgpuBackend, fontPipeline, font, presentationFormat, limits.instanceCount);
20076
+ this.#fonts.set(id, textShader);
20077
+ } else {
20078
+ const webglBackend = this.#backend;
20079
+ const fontPipeline = WebGLFontPipeline.create(webglBackend.gl, font, limits.maxTextLength);
20080
+ const textShader = new WebGLTextShader(webglBackend, fontPipeline);
20081
+ this.#fonts.set(id, textShader);
20082
+ }
19712
20083
  return id;
19713
20084
  }
19714
20085
  getFont(id) {
@@ -20394,8 +20765,10 @@ __export(exports_mod4, {
20394
20765
  // src/scene/mod.ts
20395
20766
  var exports_mod5 = {};
20396
20767
  __export(exports_mod5, {
20768
+ TextNode: () => TextNode,
20397
20769
  SceneNode: () => SceneNode,
20398
20770
  QuadNode: () => QuadNode,
20771
+ DEFAULT_FONT_SIZE: () => DEFAULT_FONT_SIZE,
20399
20772
  Camera: () => Camera
20400
20773
  });
20401
20774
  // src/screen/mod.ts
@@ -20403,8 +20776,7 @@ var exports_mod6 = {};
20403
20776
  // src/text/mod.ts
20404
20777
  var exports_mod7 = {};
20405
20778
  __export(exports_mod7, {
20406
- TextShader: () => TextShader,
20407
- TextNode: () => TextNode
20779
+ TextShader: () => WebGPUTextShader
20408
20780
  });
20409
20781
  // src/textures/mod.ts
20410
20782
  var exports_mod8 = {};
@@ -20426,5 +20798,5 @@ export {
20426
20798
  AssetManager
20427
20799
  };
20428
20800
 
20429
- //# debugId=1537C686BF0E9F8164756E2164756E21
20801
+ //# debugId=4A88BA8B6CA05C3D64756E2164756E21
20430
20802
  //# sourceMappingURL=mod.js.map