@bloopjs/toodle 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mod.js CHANGED
@@ -17219,386 +17219,120 @@ class WebGPUBackend {
17219
17219
  return this.#renderPass;
17220
17220
  }
17221
17221
  }
17222
- // src/backends/webgl2/WebGLTextShader.ts
17223
- class WebGLTextShader {
17224
- label = "text";
17222
+ // src/backends/webgl2/WebGLFontPipeline.ts
17223
+ class WebGLFontPipeline {
17225
17224
  font;
17225
+ fontTexture;
17226
+ charDataTexture;
17227
+ textBufferTexture;
17226
17228
  maxCharCount;
17227
- constructor(font, maxCharCount) {
17229
+ lineHeight;
17230
+ #gl;
17231
+ constructor(gl, font, fontTexture, charDataTexture, textBufferTexture, maxCharCount) {
17232
+ this.#gl = gl;
17228
17233
  this.font = font;
17234
+ this.fontTexture = fontTexture;
17235
+ this.charDataTexture = charDataTexture;
17236
+ this.textBufferTexture = textBufferTexture;
17229
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);
17230
17288
  }
17231
- startFrame(_uniform) {}
17232
- processBatch(_nodes) {
17233
- throw new Error("Text rendering is not supported in WebGL mode. Use WebGPU backend for text rendering.");
17289
+ destroy() {
17290
+ const gl = this.#gl;
17291
+ gl.deleteTexture(this.fontTexture);
17292
+ gl.deleteTexture(this.charDataTexture);
17293
+ gl.deleteTexture(this.textBufferTexture);
17234
17294
  }
17235
- endFrame() {}
17236
- }
17237
-
17238
- // src/backends/webgpu/wgsl/text.wgsl.ts
17239
- var text_wgsl_default = `
17240
- // Adapted from: https://webgpu.github.io/webgpu-samples/?sample=textRenderingMsdf
17241
-
17242
- // Quad vertex positions for a character
17243
- const pos = array(
17244
- vec2f(0, -1),
17245
- vec2f(1, -1),
17246
- vec2f(0, 0),
17247
- vec2f(1, 0),
17248
- );
17249
-
17250
- // Debug colors for visualization
17251
- const debugColors = array(
17252
- vec4f(1, 0, 0, 1),
17253
- vec4f(0, 1, 0, 1),
17254
- vec4f(0, 0, 1, 1),
17255
- vec4f(1, 1, 1, 1),
17256
- );
17257
-
17258
- // Vertex input from GPU
17259
- struct VertexInput {
17260
- @builtin(vertex_index) vertex: u32,
17261
- @builtin(instance_index) instance: u32,
17262
- };
17263
-
17264
- // Output from vertex shader to fragment shader
17265
- struct VertexOutput {
17266
- @builtin(position) position: vec4f,
17267
- @location(0) texcoord: vec2f,
17268
- @location(1) debugColor: vec4f,
17269
- @location(2) @interpolate(flat) instanceIndex: u32,
17270
- };
17271
-
17272
- // Metadata for a single character glyph
17273
- struct Char {
17274
- texOffset: vec2f, // Offset to top-left in MSDF texture (pixels)
17275
- texExtent: vec2f, // Size in texture (pixels)
17276
- size: vec2f, // Glyph size in ems
17277
- offset: vec2f, // Position offset in ems
17278
- };
17279
-
17280
- // Metadata for a text block
17281
- struct TextBlockDescriptor {
17282
- transform: mat3x3f, // Text transform matrix (model matrix)
17283
- color: vec4f, // Text color
17284
- fontSize: f32, // Font size
17285
- blockWidth: f32, // Total width of text block
17286
- blockHeight: f32, // Total height of text block
17287
- bufferPosition: f32 // Index and length in textBuffer
17288
- };
17289
-
17290
- // Font bindings
17291
- @group(0) @binding(0) var fontTexture: texture_2d<f32>;
17292
- @group(0) @binding(1) var fontSampler: sampler;
17293
- @group(0) @binding(2) var<storage> chars: array<Char>;
17294
- @group(0) @binding(3) var<uniform> fontData: vec4f; // Contains line height (x)
17295
-
17296
- // Text bindings
17297
- @group(1) @binding(0) var<storage> texts: array<TextBlockDescriptor>;
17298
- @group(1) @binding(1) var<storage> textBuffer: array<vec4f>; // Each vec4: xy = glyph pos, z = char index
17299
-
17300
- // Global uniforms
17301
- @group(2) @binding(0) var<uniform> viewProjectionMatrix: mat3x3f;
17302
-
17303
- // Vertex shader
17304
- @vertex
17305
- fn vertexMain(input: VertexInput) -> VertexOutput {
17306
- // Because the instance index is used for character indexing, we are
17307
- // overloading the vertex index to store the instance of the text metadata.
17308
- //
17309
- // I.e...
17310
- // Vertex 0-4 = Instance 0, Vertex 0-4
17311
- // Vertex 4-8 = Instance 1, Vertex 0-4
17312
- // Vertex 8-12 = Instance 2, Vertex 0-4
17313
- let vertexIndex = input.vertex % 4;
17314
- let textIndex = input.vertex / 4;
17315
-
17316
- let text = texts[textIndex];
17317
- let textElement = textBuffer[u32(text.bufferPosition) + input.instance];
17318
- let char = chars[u32(textElement.z)];
17319
-
17320
- let lineHeight = fontData.x;
17321
- let textWidth = text.blockWidth;
17322
- let textHeight = text.blockHeight;
17323
-
17324
- // Center text vertically; origin is mid-height
17325
- let offset = vec2f(0, -textHeight / 2);
17326
-
17327
- // Glyph position in ems (quad pos * size + per-char offset)
17328
- let emPos = pos[vertexIndex] * char.size + char.offset + textElement.xy - offset;
17329
- let charPos = emPos * (text.fontSize / lineHeight);
17330
-
17331
- var output: VertexOutput;
17332
- let transformedPosition = viewProjectionMatrix * text.transform * vec3f(charPos, 1);
17333
-
17334
- output.position = vec4f(transformedPosition, 1);
17335
- output.texcoord = pos[vertexIndex] * vec2f(1, -1);
17336
- output.texcoord *= char.texExtent;
17337
- output.texcoord += char.texOffset;
17338
- output.debugColor = debugColors[vertexIndex];
17339
- output.instanceIndex = textIndex;
17340
- return output;
17341
-
17342
- // To debug - hardcode quad in bottom right quarter of the screen:
17343
- // output.position = vec4f(pos[input.vertex], 0, 1);
17344
- }
17345
-
17346
- // Signed distance function sampling for MSDF font rendering
17347
- fn sampleMsdf(texcoord: vec2f) -> f32 {
17348
- let c = textureSample(fontTexture, fontSampler, texcoord);
17349
- return max(min(c.r, c.g), min(max(c.r, c.g), c.b));
17350
17295
  }
17351
17296
 
17352
- // Fragment shader
17353
- // Anti-aliasing technique by Paul Houx
17354
- // more details here:
17355
- // https://github.com/Chlumsky/msdfgen/issues/22#issuecomment-234958005
17356
- @fragment
17357
- fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
17358
- let text = texts[input.instanceIndex];
17359
-
17360
- // pxRange (AKA distanceRange) comes from the msdfgen tool.
17361
- let pxRange = 4.0;
17362
- let texSize = vec2f(textureDimensions(fontTexture, 0));
17363
-
17364
- let dx = texSize.x * length(vec2f(dpdxFine(input.texcoord.x), dpdyFine(input.texcoord.x)));
17365
- let dy = texSize.y * length(vec2f(dpdxFine(input.texcoord.y), dpdyFine(input.texcoord.y)));
17366
-
17367
- let toPixels = pxRange * inverseSqrt(dx * dx + dy * dy);
17368
- let sigDist = sampleMsdf(input.texcoord) - 0.5;
17369
- let pxDist = sigDist * toPixels;
17370
-
17371
- let edgeWidth = 0.5;
17372
- let alpha = smoothstep(-edgeWidth, edgeWidth, pxDist);
17373
-
17374
- if (alpha < 0.001) {
17375
- discard;
17297
+ // src/utils/error.ts
17298
+ var warnings = new Map;
17299
+ function warnOnce(key, msg) {
17300
+ if (warnings.has(key)) {
17301
+ return;
17376
17302
  }
17377
-
17378
- let msdfColor = vec4f(text.color.rgb, text.color.a * alpha);
17379
- return msdfColor;
17380
-
17381
- // Debug options:
17382
- // return text.color;
17383
- // return input.debugColor;
17384
- // return vec4f(1, 0, 1, 1); // hardcoded magenta
17385
- // return textureSample(fontTexture, fontSampler, input.texcoord);
17303
+ warnings.set(key, true);
17304
+ console.warn(msg ?? key);
17386
17305
  }
17387
- `;
17388
17306
 
17389
- // src/backends/webgpu/FontPipeline.ts
17390
- class FontPipeline {
17391
- pipeline;
17392
- font;
17393
- fontBindGroup;
17394
- maxCharCount;
17395
- constructor(pipeline, font, fontBindGroup, maxCharCount) {
17396
- this.pipeline = pipeline;
17397
- this.font = font;
17398
- this.fontBindGroup = fontBindGroup;
17399
- this.maxCharCount = maxCharCount;
17400
- }
17401
- static async create(device, font, colorFormat, maxCharCount) {
17402
- const pipeline = await pipelinePromise(device, colorFormat, font.name);
17403
- const texture = device.createTexture({
17404
- label: `MSDF font ${font.name}`,
17405
- size: [font.imageBitmap.width, font.imageBitmap.height, 1],
17406
- format: "rgba8unorm",
17407
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
17408
- });
17409
- device.queue.copyExternalImageToTexture({ source: font.imageBitmap }, { texture }, [font.imageBitmap.width, font.imageBitmap.height]);
17410
- const charsGpuBuffer = device.createBuffer({
17411
- label: `MSDF font ${font.name} character layout buffer`,
17412
- size: font.charCount * Float32Array.BYTES_PER_ELEMENT * 8,
17413
- usage: GPUBufferUsage.STORAGE,
17414
- mappedAtCreation: true
17415
- });
17416
- const charsArray = new Float32Array(charsGpuBuffer.getMappedRange());
17417
- charsArray.set(font.charBuffer, 0);
17418
- charsGpuBuffer.unmap();
17419
- const fontDataBuffer = device.createBuffer({
17420
- label: `MSDF font ${font.name} metadata buffer`,
17421
- size: Float32Array.BYTES_PER_ELEMENT * 4,
17422
- usage: GPUBufferUsage.UNIFORM,
17423
- mappedAtCreation: true
17424
- });
17425
- const fontDataArray = new Float32Array(fontDataBuffer.getMappedRange());
17426
- fontDataArray[0] = font.lineHeight;
17427
- fontDataBuffer.unmap();
17428
- const fontBindGroup = device.createBindGroup({
17429
- layout: pipeline.getBindGroupLayout(0),
17430
- entries: [
17431
- {
17432
- binding: 0,
17433
- resource: texture.createView()
17434
- },
17435
- {
17436
- binding: 1,
17437
- resource: device.createSampler(sampler)
17438
- },
17439
- {
17440
- binding: 2,
17441
- resource: {
17442
- buffer: charsGpuBuffer
17443
- }
17444
- },
17445
- {
17446
- binding: 3,
17447
- resource: {
17448
- buffer: fontDataBuffer
17449
- }
17450
- }
17451
- ]
17452
- });
17453
- return new FontPipeline(pipeline, font, fontBindGroup, maxCharCount);
17454
- }
17455
- }
17456
- function pipelinePromise(device, colorFormat, label) {
17457
- const shader = device.createShaderModule({
17458
- label: `${label} shader`,
17459
- code: text_wgsl_default
17460
- });
17461
- return device.createRenderPipelineAsync({
17462
- label: `${label} pipeline`,
17463
- layout: device.createPipelineLayout({
17464
- bindGroupLayouts: [
17465
- device.createBindGroupLayout(fontBindGroupLayout),
17466
- device.createBindGroupLayout(textUniformBindGroupLayout),
17467
- device.createBindGroupLayout(engineUniformBindGroupLayout)
17468
- ]
17469
- }),
17470
- vertex: {
17471
- module: shader,
17472
- entryPoint: "vertexMain"
17473
- },
17474
- fragment: {
17475
- module: shader,
17476
- entryPoint: "fragmentMain",
17477
- targets: [
17478
- {
17479
- format: colorFormat,
17480
- blend: {
17481
- color: {
17482
- srcFactor: "src-alpha",
17483
- dstFactor: "one-minus-src-alpha"
17484
- },
17485
- alpha: {
17486
- srcFactor: "one",
17487
- dstFactor: "one"
17488
- }
17489
- }
17490
- }
17491
- ]
17492
- },
17493
- primitive: {
17494
- topology: "triangle-strip",
17495
- stripIndexFormat: "uint32"
17496
- }
17497
- });
17498
- }
17499
- if (typeof GPUShaderStage === "undefined") {
17500
- globalThis.GPUShaderStage = {
17501
- VERTEX: 1,
17502
- FRAGMENT: 2,
17503
- COMPUTE: 4
17504
- };
17505
- }
17506
- var fontBindGroupLayout = {
17507
- label: "MSDF font group layout",
17508
- entries: [
17509
- {
17510
- binding: 0,
17511
- visibility: GPUShaderStage.FRAGMENT,
17512
- texture: {}
17513
- },
17514
- {
17515
- binding: 1,
17516
- visibility: GPUShaderStage.FRAGMENT,
17517
- sampler: {}
17518
- },
17519
- {
17520
- binding: 2,
17521
- visibility: GPUShaderStage.VERTEX,
17522
- buffer: { type: "read-only-storage" }
17523
- },
17524
- {
17525
- binding: 3,
17526
- visibility: GPUShaderStage.VERTEX,
17527
- buffer: {}
17528
- }
17529
- ]
17530
- };
17531
- var engineUniformBindGroupLayout = {
17532
- label: "Uniform bind group",
17533
- entries: [
17534
- {
17535
- binding: 0,
17536
- visibility: GPUShaderStage.VERTEX,
17537
- buffer: {}
17538
- }
17539
- ]
17540
- };
17541
- var sampler = {
17542
- label: "MSDF text sampler",
17543
- minFilter: "linear",
17544
- magFilter: "linear",
17545
- mipmapFilter: "linear",
17546
- maxAnisotropy: 16
17547
- };
17548
- var textUniformBindGroupLayout = {
17549
- label: "MSDF text block uniform",
17550
- entries: [
17551
- {
17552
- binding: 0,
17553
- visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
17554
- buffer: { type: "read-only-storage" }
17555
- },
17556
- {
17557
- binding: 1,
17558
- visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
17559
- buffer: { type: "read-only-storage" }
17560
- }
17561
- ]
17562
- };
17563
- // src/utils/error.ts
17564
- var warnings = new Map;
17565
- function warnOnce(key, msg) {
17566
- if (warnings.has(key)) {
17567
- return;
17568
- }
17569
- warnings.set(key, true);
17570
- console.warn(msg ?? key);
17571
- }
17572
-
17573
- // src/text/MsdfFont.ts
17574
- class MsdfFont {
17575
- id;
17576
- json;
17577
- imageBitmap;
17578
- name;
17579
- charset;
17580
- charCount;
17581
- lineHeight;
17582
- charBuffer;
17583
- #kernings;
17584
- #chars;
17585
- #fallbackCharCode;
17586
- constructor(id, json, imageBitmap) {
17587
- this.id = id;
17588
- this.json = json;
17589
- this.imageBitmap = imageBitmap;
17590
- const charArray = Object.values(json.chars);
17591
- this.charCount = charArray.length;
17592
- this.lineHeight = json.common.lineHeight;
17593
- this.charset = json.info.charset;
17594
- this.name = json.info.face;
17595
- this.#kernings = new Map;
17596
- if (json.kernings) {
17597
- for (const kearning of json.kernings) {
17598
- let charKerning = this.#kernings.get(kearning.first);
17599
- if (!charKerning) {
17600
- charKerning = new Map;
17601
- this.#kernings.set(kearning.first, charKerning);
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);
17602
17336
  }
17603
17337
  charKerning.set(kearning.second, kearning.amount);
17604
17338
  }
@@ -18218,82 +17952,700 @@ class SceneNode {
18218
17952
  };
18219
17953
  }
18220
17954
  }
18221
- function reviver(key, value) {
18222
- if (key === "kids") {
18223
- return value.map((kid) => new SceneNode(kid));
18224
- }
18225
- if (Array.isArray(value) && value.every((v3) => typeof v3 === "number")) {
18226
- if (value.length === 2) {
18227
- return value;
18228
- }
18229
- if (value.length === 3) {
18230
- return value;
18231
- }
18232
- if (value.length === 4) {
18233
- return value;
17955
+ function reviver(key, value) {
17956
+ if (key === "kids") {
17957
+ return value.map((kid) => new SceneNode(kid));
17958
+ }
17959
+ if (Array.isArray(value) && value.every((v3) => typeof v3 === "number")) {
17960
+ if (value.length === 2) {
17961
+ return value;
17962
+ }
17963
+ if (value.length === 3) {
17964
+ return value;
17965
+ }
17966
+ if (value.length === 4) {
17967
+ return value;
17968
+ }
17969
+ }
17970
+ return value;
17971
+ }
17972
+
17973
+ // src/scene/TextNode.ts
17974
+ var DEFAULT_FONT_SIZE = 14;
17975
+
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.`);
17984
+ }
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 };
17988
+ }
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;
18001
+ }
18002
+ get text() {
18003
+ return this.#text;
18004
+ }
18005
+ get formatting() {
18006
+ return this.#formatting;
18007
+ }
18008
+ get font() {
18009
+ return this.#font;
18010
+ }
18011
+ set text(text) {
18012
+ if (!text) {
18013
+ throw new Error("text cannot be empty");
18014
+ }
18015
+ this.#text = text;
18016
+ this.setDirty();
18017
+ }
18018
+ get tint() {
18019
+ return this.#formatting.color || { r: 1, g: 1, b: 1, a: 1 };
18020
+ }
18021
+ set tint(tint) {
18022
+ this.#formatting.color = tint;
18023
+ this.setDirty();
18024
+ }
18025
+ set formatting(formatting) {
18026
+ this.#formatting = formatting;
18027
+ this.setDirty();
18028
+ }
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;
18148
+ }
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);
18209
+ }
18210
+ startFrame(uniform) {
18211
+ this.#cachedUniform = uniform;
18212
+ }
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;
18301
+ }
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}`);
18313
+ }
18314
+ return shader;
18315
+ }
18316
+ destroy() {
18317
+ const gl = this.#backend.gl;
18318
+ gl.deleteProgram(this.#program);
18319
+ gl.deleteVertexArray(this.#vao);
18320
+ this.#pipeline.destroy();
18321
+ }
18322
+ }
18323
+
18324
+ // src/backends/webgpu/wgsl/text.wgsl.ts
18325
+ var text_wgsl_default = `
18326
+ // Adapted from: https://webgpu.github.io/webgpu-samples/?sample=textRenderingMsdf
18327
+
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
+ );
18335
+
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
+ );
18343
+
18344
+ // Vertex input from GPU
18345
+ struct VertexInput {
18346
+ @builtin(vertex_index) vertex: u32,
18347
+ @builtin(instance_index) instance: u32,
18348
+ };
18349
+
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
+ };
18357
+
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
+ };
18365
+
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
+ };
18375
+
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
18385
+
18386
+ // Global uniforms
18387
+ @group(2) @binding(0) var<uniform> viewProjectionMatrix: mat3x3f;
18388
+
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;
18401
+
18402
+ let text = texts[textIndex];
18403
+ let textElement = textBuffer[u32(text.bufferPosition) + input.instance];
18404
+ let char = chars[u32(textElement.z)];
18405
+
18406
+ let lineHeight = fontData.x;
18407
+ let textWidth = text.blockWidth;
18408
+ let textHeight = text.blockHeight;
18409
+
18410
+ // Center text vertically; origin is mid-height
18411
+ let offset = vec2f(0, -textHeight / 2);
18412
+
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);
18416
+
18417
+ var output: VertexOutput;
18418
+ let transformedPosition = viewProjectionMatrix * text.transform * vec3f(charPos, 1);
18419
+
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;
18427
+
18428
+ // To debug - hardcode quad in bottom right quarter of the screen:
18429
+ // output.position = vec4f(pos[input.vertex], 0, 1);
18430
+ }
18431
+
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
+ }
18437
+
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];
18445
+
18446
+ // pxRange (AKA distanceRange) comes from the msdfgen tool.
18447
+ let pxRange = 4.0;
18448
+ let texSize = vec2f(textureDimensions(fontTexture, 0));
18449
+
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)));
18452
+
18453
+ let toPixels = pxRange * inverseSqrt(dx * dx + dy * dy);
18454
+ let sigDist = sampleMsdf(input.texcoord) - 0.5;
18455
+ let pxDist = sigDist * toPixels;
18456
+
18457
+ let edgeWidth = 0.5;
18458
+ let alpha = smoothstep(-edgeWidth, edgeWidth, pxDist);
18459
+
18460
+ if (alpha < 0.001) {
18461
+ discard;
18462
+ }
18463
+
18464
+ let msdfColor = vec4f(text.color.rgb, text.color.a * alpha);
18465
+ return msdfColor;
18466
+
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"
18234
18582
  }
18235
- }
18236
- return value;
18583
+ });
18237
18584
  }
18238
-
18239
- // src/scene/TextNode.ts
18240
- var DEFAULT_FONT_SIZE = 14;
18241
-
18242
- class TextNode extends SceneNode {
18243
- #text;
18244
- #formatting;
18245
- #font;
18246
- constructor(shader, text, opts = {}) {
18247
- const { width, height } = measureText(shader.font, text, opts.wordWrap);
18248
- if (text.length > shader.maxCharCount) {
18249
- throw new Error(`Text: ${text} exceeds ${shader.maxCharCount} characters. Try using fewer characters or increase the limit in Toodle.attach.`);
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: {}
18250
18614
  }
18251
- const em2px = shader.font.lineHeight / (opts.fontSize ?? DEFAULT_FONT_SIZE);
18252
- if (!opts.shrinkToFit && !opts.size) {
18253
- opts.size = { width: width / em2px, height: height / em2px };
18615
+ ]
18616
+ };
18617
+ var engineUniformBindGroupLayout = {
18618
+ label: "Uniform bind group",
18619
+ entries: [
18620
+ {
18621
+ binding: 0,
18622
+ visibility: GPUShaderStage.VERTEX,
18623
+ buffer: {}
18254
18624
  }
18255
- super({
18256
- ...opts,
18257
- render: {
18258
- shader,
18259
- writeInstance: (_node, _array, _offset) => {
18260
- throw new Error("not implemented - needs access to text uniform buffer, dimensions and a model matrix");
18261
- }
18262
- }
18263
- });
18264
- this.#font = shader.font;
18265
- this.#text = text;
18266
- this.#formatting = opts;
18267
- }
18268
- get text() {
18269
- return this.#text;
18270
- }
18271
- get formatting() {
18272
- return this.#formatting;
18273
- }
18274
- get font() {
18275
- return this.#font;
18276
- }
18277
- set text(text) {
18278
- if (!text) {
18279
- throw new Error("text cannot be empty");
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" }
18280
18646
  }
18281
- this.#text = text;
18282
- this.setDirty();
18283
- }
18284
- get tint() {
18285
- return this.#formatting.color || { r: 1, g: 1, b: 1, a: 1 };
18286
- }
18287
- set tint(tint) {
18288
- this.#formatting.color = tint;
18289
- this.setDirty();
18290
- }
18291
- set formatting(formatting) {
18292
- this.#formatting = formatting;
18293
- this.setDirty();
18294
- }
18295
- }
18296
-
18647
+ ]
18648
+ };
18297
18649
  // src/backends/webgpu/WebGPUTextShader.ts
18298
18650
  var deets = new _t2(text_wgsl_default);
18299
18651
  var struct = deets.structs.find((s3) => s3.name === "TextBlockDescriptor");
@@ -19574,6 +19926,105 @@ async function textureToBitmap(device, texture, width, height) {
19574
19926
  readBuffer.unmap();
19575
19927
  return bitmap;
19576
19928
  }
19929
+ async function packBitmapsToAtlasCPU(images, textureSize) {
19930
+ const cpuTextureAtlases = [];
19931
+ const packed = [];
19932
+ const spaces = [
19933
+ { x: 0, y: 0, width: textureSize, height: textureSize }
19934
+ ];
19935
+ let atlasRegionMap = new Map;
19936
+ for (const [id, { bitmap }] of images) {
19937
+ let bestSpace = -1;
19938
+ let bestScore = Number.POSITIVE_INFINITY;
19939
+ for (let i3 = 0;i3 < spaces.length; i3++) {
19940
+ const space2 = spaces[i3];
19941
+ if (bitmap.width <= space2.width && bitmap.height <= space2.height) {
19942
+ const score = Math.abs(space2.width * space2.height - bitmap.width * bitmap.height);
19943
+ if (score < bestScore) {
19944
+ bestScore = score;
19945
+ bestSpace = i3;
19946
+ }
19947
+ }
19948
+ }
19949
+ if (bestSpace === -1) {
19950
+ const tex2 = createAtlasBitmapFromPacked(packed, textureSize);
19951
+ cpuTextureAtlases.push({
19952
+ texture: tex2,
19953
+ textureRegions: atlasRegionMap,
19954
+ width: tex2.width,
19955
+ height: tex2.height
19956
+ });
19957
+ atlasRegionMap = new Map;
19958
+ packed.length = 0;
19959
+ spaces.length = 0;
19960
+ spaces.push({
19961
+ x: 0,
19962
+ y: 0,
19963
+ width: textureSize,
19964
+ height: textureSize
19965
+ });
19966
+ bestSpace = 0;
19967
+ }
19968
+ const space = spaces[bestSpace];
19969
+ packed.push({
19970
+ texture: bitmap,
19971
+ x: space.x,
19972
+ y: space.y,
19973
+ width: bitmap.width,
19974
+ height: bitmap.height
19975
+ });
19976
+ spaces.splice(bestSpace, 1);
19977
+ if (space.width - bitmap.width > 0) {
19978
+ spaces.push({
19979
+ x: space.x + bitmap.width,
19980
+ y: space.y,
19981
+ width: space.width - bitmap.width,
19982
+ height: bitmap.height
19983
+ });
19984
+ }
19985
+ if (space.height - bitmap.height > 0) {
19986
+ spaces.push({
19987
+ x: space.x,
19988
+ y: space.y + bitmap.height,
19989
+ width: space.width,
19990
+ height: space.height - bitmap.height
19991
+ });
19992
+ }
19993
+ const uvScale = {
19994
+ width: bitmap.width / textureSize,
19995
+ height: bitmap.height / textureSize
19996
+ };
19997
+ atlasRegionMap.set(id, {
19998
+ uvOffset: {
19999
+ x: space.x / textureSize,
20000
+ y: space.y / textureSize
20001
+ },
20002
+ uvScale,
20003
+ uvScaleCropped: uvScale,
20004
+ cropOffset: { x: 0, y: 0 },
20005
+ originalSize: { width: bitmap.width, height: bitmap.height }
20006
+ });
20007
+ }
20008
+ const tex = createAtlasBitmapFromPacked(packed, textureSize);
20009
+ cpuTextureAtlases.push({
20010
+ texture: tex,
20011
+ textureRegions: atlasRegionMap,
20012
+ width: tex.width,
20013
+ height: tex.height
20014
+ });
20015
+ return cpuTextureAtlases;
20016
+ }
20017
+ function createAtlasBitmapFromPacked(packed, atlasSize) {
20018
+ const canvas = new OffscreenCanvas(atlasSize, atlasSize);
20019
+ const ctx = canvas.getContext("2d");
20020
+ if (!ctx) {
20021
+ throw new Error("Failed to get 2d context from OffscreenCanvas");
20022
+ }
20023
+ for (const texture of packed) {
20024
+ ctx.drawImage(texture.texture, texture.x, texture.y);
20025
+ }
20026
+ return canvas.transferToImageBitmap();
20027
+ }
19577
20028
 
19578
20029
  // src/textures/AssetManager.ts
19579
20030
  class AssetManager {
@@ -19723,7 +20174,9 @@ class AssetManager {
19723
20174
  const textShader = new WebGPUTextShader(webgpuBackend, fontPipeline, font, presentationFormat, limits.instanceCount);
19724
20175
  this.#fonts.set(id, textShader);
19725
20176
  } else {
19726
- const textShader = new WebGLTextShader(font, limits.maxTextLength);
20177
+ const webglBackend = this.#backend;
20178
+ const fontPipeline = WebGLFontPipeline.create(webglBackend.gl, font, limits.maxTextLength);
20179
+ const textShader = new WebGLTextShader(webglBackend, fontPipeline);
19727
20180
  this.#fonts.set(id, textShader);
19728
20181
  }
19729
20182
  return id;
@@ -19761,24 +20214,31 @@ class AssetManager {
19761
20214
  return texture;
19762
20215
  }
19763
20216
  async#registerBundleFromTextures(bundleId, opts) {
19764
- if (this.#backend.type !== "webgpu") {
19765
- throw new Error("Dynamic texture bundle registration is only supported with WebGPU backend. Use prebaked atlases instead.");
19766
- }
19767
- const device = this.#backend.device;
19768
- const images = new Map;
19769
- let _networkLoadTime = 0;
19770
- await Promise.all(Object.entries(opts.textures).map(async ([id, url]) => {
19771
- const now = performance.now();
19772
- const bitmap = await getBitmapFromUrl(url);
19773
- _networkLoadTime += performance.now() - now;
19774
- let textureWrapper = this.#wrapBitmapToTexture(bitmap, id);
19775
- if (opts.cropTransparentPixels && this.#cropComputeShader) {
19776
- textureWrapper = await this.#cropComputeShader.processTexture(textureWrapper);
20217
+ if (this.#backend.type === "webgpu") {
20218
+ const device = this.#backend.device;
20219
+ const images = new Map;
20220
+ await Promise.all(Object.entries(opts.textures).map(async ([id, url]) => {
20221
+ const bitmap = await getBitmapFromUrl(url);
20222
+ let textureWrapper = this.#wrapBitmapToTexture(bitmap, id);
20223
+ if (opts.cropTransparentPixels && this.#cropComputeShader) {
20224
+ textureWrapper = await this.#cropComputeShader.processTexture(textureWrapper);
20225
+ }
20226
+ images.set(id, textureWrapper);
20227
+ }));
20228
+ const atlases = await packBitmapsToAtlas(images, this.#backend.limits.textureSize, device);
20229
+ this.bundles.registerDynamicBundle(bundleId, atlases);
20230
+ } else {
20231
+ if (opts.cropTransparentPixels) {
20232
+ console.warn("cropTransparentPixels is not supported on WebGL2 backend and will be ignored.");
19777
20233
  }
19778
- images.set(id, textureWrapper);
19779
- }));
19780
- const atlases = await packBitmapsToAtlas(images, this.#backend.limits.textureSize, device);
19781
- this.bundles.registerDynamicBundle(bundleId, atlases);
20234
+ const images = new Map;
20235
+ await Promise.all(Object.entries(opts.textures).map(async ([id, url]) => {
20236
+ const bitmap = await getBitmapFromUrl(url);
20237
+ images.set(id, { bitmap, id });
20238
+ }));
20239
+ const atlases = await packBitmapsToAtlasCPU(images, this.#backend.limits.textureSize);
20240
+ this.bundles.registerDynamicBundle(bundleId, atlases);
20241
+ }
19782
20242
  }
19783
20243
  async#registerBundleFromAtlases(bundleId, opts) {
19784
20244
  await this.bundles.registerAtlasBundle(bundleId, opts);
@@ -20444,5 +20904,5 @@ export {
20444
20904
  AssetManager
20445
20905
  };
20446
20906
 
20447
- //# debugId=7918C9E3F46FD35864756E2164756E21
20907
+ //# debugId=6E0EAD9B5EBDC37664756E2164756E21
20448
20908
  //# sourceMappingURL=mod.js.map