@bloopjs/toodle 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) 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/WebGLTextShader.d.ts +20 -0
  8. package/dist/backends/webgl2/WebGLTextShader.d.ts.map +1 -0
  9. package/dist/backends/webgl2/mod.d.ts +1 -0
  10. package/dist/backends/webgl2/mod.d.ts.map +1 -1
  11. package/dist/{text → backends/webgpu}/FontPipeline.d.ts +1 -1
  12. package/dist/backends/webgpu/FontPipeline.d.ts.map +1 -0
  13. package/dist/{text/TextShader.d.ts → backends/webgpu/WebGPUTextShader.d.ts} +7 -7
  14. package/dist/backends/webgpu/WebGPUTextShader.d.ts.map +1 -0
  15. package/dist/backends/webgpu/mod.d.ts +2 -0
  16. package/dist/backends/webgpu/mod.d.ts.map +1 -1
  17. package/dist/backends/webgpu/wgsl/text.wgsl.d.ts.map +1 -0
  18. package/dist/mod.d.ts +1 -1
  19. package/dist/mod.d.ts.map +1 -1
  20. package/dist/mod.js +1795 -1777
  21. package/dist/mod.js.map +15 -14
  22. package/dist/{text → scene}/TextNode.d.ts +5 -5
  23. package/dist/scene/TextNode.d.ts.map +1 -0
  24. package/dist/scene/mod.d.ts +1 -0
  25. package/dist/scene/mod.d.ts.map +1 -1
  26. package/dist/text/mod.d.ts +1 -3
  27. package/dist/text/mod.d.ts.map +1 -1
  28. package/dist/textures/AssetManager.d.ts +9 -6
  29. package/dist/textures/AssetManager.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/Toodle.ts +1 -1
  32. package/src/backends/ITextShader.ts +15 -0
  33. package/src/backends/mod.ts +1 -0
  34. package/src/backends/webgl2/WebGLTextShader.ts +35 -0
  35. package/src/backends/webgl2/mod.ts +1 -0
  36. package/src/{text → backends/webgpu}/FontPipeline.ts +2 -2
  37. package/src/{text/TextShader.ts → backends/webgpu/WebGPUTextShader.ts} +14 -10
  38. package/src/backends/webgpu/mod.ts +2 -0
  39. package/src/mod.ts +1 -1
  40. package/src/{text → scene}/TextNode.ts +6 -6
  41. package/src/scene/mod.ts +1 -0
  42. package/src/text/mod.ts +1 -4
  43. package/src/textures/AssetManager.ts +38 -31
  44. package/dist/text/FontPipeline.d.ts.map +0 -1
  45. package/dist/text/TextNode.d.ts.map +0 -1
  46. package/dist/text/TextShader.d.ts.map +0 -1
  47. package/dist/text/text.wgsl.d.ts.map +0 -1
  48. /package/dist/{text → backends/webgpu/wgsl}/text.wgsl.d.ts +0 -0
  49. /package/src/{text → backends/webgpu/wgsl}/text.wgsl.ts +0 -0
package/dist/mod.js CHANGED
@@ -17219,1917 +17219,1932 @@ 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);
17222
+ // src/backends/webgl2/WebGLTextShader.ts
17223
+ class WebGLTextShader {
17224
+ label = "text";
17225
+ font;
17226
+ maxCharCount;
17227
+ constructor(font, maxCharCount) {
17228
+ this.font = font;
17229
+ this.maxCharCount = maxCharCount;
17230
+ }
17231
+ startFrame(_uniform) {}
17232
+ processBatch(_nodes) {
17233
+ throw new Error("Text rendering is not supported in WebGL mode. Use WebGPU backend for text rendering.");
17234
+ }
17235
+ endFrame() {}
17247
17236
  }
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
- };
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);
17255
17344
  }
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
- };
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));
17262
17350
  }
17263
17351
 
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);
17276
- }
17277
- for (const kid of node.kids) {
17278
- this.enqueue(kid);
17279
- }
17280
- }
17281
- flush() {
17282
- this.nodes = [];
17283
- this.layers = [];
17284
- this.pipelines = [];
17285
- }
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);
17292
- }
17293
- return layer;
17294
- }
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);
17301
- }
17302
- return pipeline;
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;
17303
17376
  }
17304
- }
17305
17377
 
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);
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);
17312
17386
  }
17387
+ `;
17313
17388
 
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;
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;
17323
17400
  }
17324
- set zoom(value) {
17325
- this.#zoom = value;
17326
- this.setDirty();
17327
- }
17328
- get rotation() {
17329
- return rad2deg(this.#rotation);
17330
- }
17331
- set rotation(value) {
17332
- this.#rotation = deg2rad(value);
17333
- this.setDirty();
17334
- }
17335
- get rotationRadians() {
17336
- return this.#rotation;
17337
- }
17338
- set rotationRadians(value) {
17339
- this.#rotation = value;
17340
- this.setDirty();
17341
- }
17342
- get x() {
17343
- return this.#position.x;
17344
- }
17345
- get y() {
17346
- return this.#position.y;
17347
- }
17348
- set x(value) {
17349
- this.#position.x = value;
17350
- this.setDirty();
17351
- }
17352
- set y(value) {
17353
- this.#position.y = value;
17354
- this.setDirty();
17355
- }
17356
- get matrix() {
17357
- if (this.#isDirty) {
17358
- this.#isDirty = false;
17359
- this.#matrix = createViewMatrix(this, this.#matrix);
17360
- }
17361
- return this.#matrix;
17362
- }
17363
- setDirty() {
17364
- this.#isDirty = true;
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);
17365
17454
  }
17366
17455
  }
17367
-
17368
- // src/scene/SceneNode.ts
17369
- class SceneNode {
17370
- static nextId = 1;
17371
- id;
17372
- label;
17373
- #isActive = true;
17374
- #layer = null;
17375
- #parent = null;
17376
- #key = null;
17377
- #kids = [];
17378
- #transform;
17379
- #matrix = mat3.identity();
17380
- #renderComponent = null;
17381
- #size = null;
17382
- #positionProxy;
17383
- #scaleProxy;
17384
- #cache = null;
17385
- constructor(opts) {
17386
- this.id = opts?.id ?? SceneNode.nextId++;
17387
- if (opts?.rotation && opts?.rotationRadians) {
17388
- throw new Error(`Cannot set both rotation and rotationRadians for node ${opts?.label ?? this.id}`);
17389
- }
17390
- this.#transform = {
17391
- position: opts?.position ?? { x: 0, y: 0 },
17392
- scale: { x: 1, y: 1 },
17393
- size: opts?.size ?? { width: 1, height: 1 },
17394
- rotation: opts?.rotationRadians ?? 0
17395
- };
17396
- if (opts?.scale)
17397
- this.scale = opts.scale;
17398
- if (opts?.rotation)
17399
- this.rotation = opts.rotation;
17400
- this.#matrix = mat3.identity();
17401
- this.#renderComponent = opts?.render ?? null;
17402
- this.#layer = opts?.layer ?? null;
17403
- this.#isActive = opts?.isActive ?? true;
17404
- this.label = opts?.label ?? undefined;
17405
- this.#size = opts?.size ?? null;
17406
- this.#key = opts?.key ?? null;
17407
- for (const kid of opts?.kids ?? []) {
17408
- this.add(kid);
17409
- }
17410
- const self = this;
17411
- this.#positionProxy = {
17412
- get x() {
17413
- return self.#transform.position.x;
17414
- },
17415
- set x(value) {
17416
- self.#transform.position.x = value;
17417
- self.setDirty();
17418
- },
17419
- get y() {
17420
- return self.#transform.position.y;
17421
- },
17422
- set y(value) {
17423
- self.#transform.position.y = value;
17424
- self.setDirty();
17425
- }
17426
- };
17427
- this.#scaleProxy = {
17428
- get x() {
17429
- return self.#transform.scale.x;
17430
- },
17431
- set x(value) {
17432
- self.#transform.scale.x = value;
17433
- self.setDirty();
17434
- },
17435
- get y() {
17436
- return self.#transform.scale.y;
17437
- },
17438
- set y(value) {
17439
- self.#transform.scale.y = value;
17440
- self.setDirty();
17441
- }
17442
- };
17443
- }
17444
- add(kid, index) {
17445
- kid.#parent = this;
17446
- if (index === undefined) {
17447
- this.#kids.push(kid);
17448
- } else {
17449
- this.#kids.splice(index, 0, kid);
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"
17450
17496
  }
17451
- kid.setDirty();
17452
- return kid;
17453
- }
17454
- get kids() {
17455
- return this.#kids;
17456
- }
17457
- get children() {
17458
- return this.#kids;
17459
- }
17460
- get transform() {
17461
- return this.#transform;
17462
- }
17463
- get key() {
17464
- return this.#key ?? "";
17465
- }
17466
- get parent() {
17467
- return this.#parent;
17468
- }
17469
- set position(value) {
17470
- this.#transform.position = value;
17471
- this.setDirty();
17472
- }
17473
- get position() {
17474
- return this.#positionProxy;
17475
- }
17476
- set x(value) {
17477
- this.#transform.position.x = value;
17478
- this.setDirty();
17479
- }
17480
- get x() {
17481
- return this.#transform.position.x;
17482
- }
17483
- set y(value) {
17484
- this.#transform.position.y = value;
17485
- this.setDirty();
17486
- }
17487
- get y() {
17488
- return this.#transform.position.y;
17489
- }
17490
- set rotation(value) {
17491
- this.#transform.rotation = deg2rad(value);
17492
- this.setDirty();
17493
- }
17494
- get rotation() {
17495
- return rad2deg(this.#transform.rotation);
17496
- }
17497
- get rotationRadians() {
17498
- return this.#transform.rotation;
17499
- }
17500
- set rotationRadians(value) {
17501
- this.#transform.rotation = value;
17502
- this.setDirty();
17503
- }
17504
- get scale() {
17505
- return this.#scaleProxy;
17506
- }
17507
- set scale(value) {
17508
- if (typeof value === "number") {
17509
- this.#transform.scale = { x: value, y: value };
17510
- } else {
17511
- this.#transform.scale = value;
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: {}
17512
17528
  }
17513
- this.setDirty();
17514
- }
17515
- set size(value) {
17516
- this.#size = value;
17517
- this.setDirty();
17518
- }
17519
- get size() {
17520
- return this.#size;
17521
- }
17522
- get aspectRatio() {
17523
- if (!this.#size) {
17524
- console.warn("Attempted to get aspect ratio of a node with no ideal size");
17525
- return 1;
17529
+ ]
17530
+ };
17531
+ var engineUniformBindGroupLayout = {
17532
+ label: "Uniform bind group",
17533
+ entries: [
17534
+ {
17535
+ binding: 0,
17536
+ visibility: GPUShaderStage.VERTEX,
17537
+ buffer: {}
17526
17538
  }
17527
- return this.#size.width / this.#size.height;
17528
- }
17529
- get isActive() {
17530
- if (!this.#cache?.isActive) {
17531
- this.#cache ??= {};
17532
- let parent = this;
17533
- let isActive = this.#isActive;
17534
- while (isActive && parent.#parent) {
17535
- parent = parent.#parent;
17536
- isActive = isActive && parent.#isActive;
17537
- }
17538
- this.#cache.isActive = isActive;
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" }
17539
17560
  }
17540
- return this.#cache.isActive;
17541
- }
17542
- set isActive(value) {
17543
- this.#isActive = value;
17544
- this.setDirty();
17561
+ ]
17562
+ };
17563
+ // src/utils/error.ts
17564
+ var warnings = new Map;
17565
+ function warnOnce(key, msg) {
17566
+ if (warnings.has(key)) {
17567
+ return;
17545
17568
  }
17546
- get layer() {
17547
- if (this.#layer != null) {
17548
- return this.#layer;
17549
- }
17550
- if (!this.#cache?.layer) {
17551
- this.#cache ??= {};
17552
- let parent = this;
17553
- while (parent.#parent) {
17554
- parent = parent.#parent;
17555
- if (parent.hasExplicitLayer) {
17556
- this.#cache.layer = parent.#layer;
17557
- return this.#cache.layer;
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);
17558
17602
  }
17603
+ charKerning.set(kearning.second, kearning.amount);
17559
17604
  }
17560
- this.#cache.layer = 0;
17561
17605
  }
17562
- return this.#cache.layer;
17563
- }
17564
- get hasExplicitLayer() {
17565
- return this.#layer != null;
17566
- }
17567
- set layer(value) {
17568
- this.#layer = value;
17569
- this.setDirty();
17570
- }
17571
- get renderComponent() {
17572
- return this.#renderComponent;
17573
- }
17574
- get matrix() {
17575
- if (!this.#cache?.matrix) {
17576
- this.#cache ??= {};
17577
- if (this.#parent) {
17578
- mat3.clone(this.#parent.matrix, this.#matrix);
17579
- } else {
17580
- mat3.identity(this.#matrix);
17581
- }
17582
- this.#cache.matrix = createModelMatrix(this.transform, this.#matrix);
17606
+ this.#chars = new Map;
17607
+ const charCount = Object.values(json.chars).length;
17608
+ this.charBuffer = new Float32Array(charCount * 8);
17609
+ let offset = 0;
17610
+ const u3 = 1 / json.common.scaleW;
17611
+ const v3 = 1 / json.common.scaleH;
17612
+ for (const [i3, char] of json.chars.entries()) {
17613
+ this.#chars.set(char.id, char);
17614
+ this.#chars.get(char.id).charIndex = i3;
17615
+ this.charBuffer[offset] = char.x * u3;
17616
+ this.charBuffer[offset + 1] = char.y * v3;
17617
+ this.charBuffer[offset + 2] = char.width * u3;
17618
+ this.charBuffer[offset + 3] = char.height * v3;
17619
+ this.charBuffer[offset + 4] = char.width;
17620
+ this.charBuffer[offset + 5] = char.height;
17621
+ this.charBuffer[offset + 6] = char.xoffset;
17622
+ this.charBuffer[offset + 7] = -char.yoffset;
17623
+ offset += 8;
17583
17624
  }
17584
- return this.#cache.matrix;
17585
17625
  }
17586
- get bounds() {
17587
- if (!this.#cache?.bounds) {
17588
- this.#cache ??= {};
17589
- const height = this.size?.height ?? 0;
17590
- const width = this.size?.width ?? 0;
17591
- const corners = [
17592
- vec2.transformMat3([-width / 2, height / 2], this.matrix),
17593
- vec2.transformMat3([width / 2, height / 2], this.matrix),
17594
- vec2.transformMat3([-width / 2, -height / 2], this.matrix),
17595
- vec2.transformMat3([width / 2, -height / 2], this.matrix)
17596
- ];
17597
- const center = vec2.transformMat3([0, 0], this.matrix);
17598
- const xValues = corners.map((c3) => c3[0]);
17599
- const yValues = corners.map((c3) => c3[1]);
17600
- this.#cache.bounds = {
17601
- x: center[0],
17602
- y: center[1],
17603
- left: Math.min(xValues[0], xValues[1], xValues[2], xValues[3]),
17604
- right: Math.max(xValues[0], xValues[1], xValues[2], xValues[3]),
17605
- top: Math.max(yValues[0], yValues[1], yValues[2], yValues[3]),
17606
- bottom: Math.min(yValues[0], yValues[1], yValues[2], yValues[3])
17607
- };
17626
+ getChar(charCode) {
17627
+ const char = this.#chars.get(charCode);
17628
+ if (!char) {
17629
+ const fallbackCharacter = this.#chars.get(this.#fallbackCharCode ?? this.#chars.keys().toArray()[0]);
17630
+ warnOnce(`unknown_char_${this.name}`, `Couldn't find character ${charCode} in characters for font ${this.name} -- defaulting to first available character "${fallbackCharacter.char}"`);
17631
+ return fallbackCharacter;
17608
17632
  }
17609
- return this.#cache.bounds;
17610
- }
17611
- setBounds(bounds) {
17612
- if (bounds.left !== undefined)
17613
- this.left = bounds.left;
17614
- if (bounds.right !== undefined)
17615
- this.right = bounds.right;
17616
- if (bounds.top !== undefined)
17617
- this.top = bounds.top;
17618
- if (bounds.bottom !== undefined)
17619
- this.bottom = bounds.bottom;
17620
- if (bounds.x !== undefined)
17621
- this.centerX = bounds.x;
17622
- if (bounds.y !== undefined)
17623
- this.centerY = bounds.y;
17624
- return this;
17625
- }
17626
- set left(value) {
17627
- this.#adjustWorldPosition([value - this.bounds.left, 0]);
17628
- }
17629
- set bottom(value) {
17630
- this.#adjustWorldPosition([0, value - this.bounds.bottom]);
17631
- }
17632
- set top(value) {
17633
- this.#adjustWorldPosition([0, value - this.bounds.top]);
17634
- }
17635
- set right(value) {
17636
- this.#adjustWorldPosition([value - this.bounds.right, 0]);
17637
- }
17638
- set centerX(value) {
17639
- this.#adjustWorldPosition([value - this.bounds.x, 0]);
17633
+ return char;
17640
17634
  }
17641
- set centerY(value) {
17642
- this.#adjustWorldPosition([0, value - this.bounds.y]);
17635
+ getXAdvance(charCode, nextCharCode = -1) {
17636
+ const char = this.getChar(charCode);
17637
+ if (nextCharCode >= 0) {
17638
+ const kerning = this.#kernings.get(charCode);
17639
+ if (kerning) {
17640
+ return char.xadvance + (kerning.get(nextCharCode) ?? 0);
17641
+ }
17642
+ }
17643
+ return char.xadvance;
17643
17644
  }
17644
- delete() {
17645
- this.#parent?.remove(this);
17646
- for (const child of this.#kids) {
17647
- child.delete();
17645
+ static async create(id, fontJsonUrl) {
17646
+ const response = await fetch(fontJsonUrl);
17647
+ const json = await response.json();
17648
+ const i3 = fontJsonUrl.href.lastIndexOf("/");
17649
+ const baseUrl = i3 !== -1 ? fontJsonUrl.href.substring(0, i3 + 1) : undefined;
17650
+ if (json.pages.length < 1) {
17651
+ throw new Error(`Can't create an msdf font without a reference to the page url in the json`);
17648
17652
  }
17649
- this.#kids = [];
17650
- this.#isActive = false;
17651
- this.#layer = null;
17652
- this.#renderComponent = null;
17653
+ if (json.pages.length > 1) {
17654
+ throw new Error(`Can't create an msdf font with more than one page`);
17655
+ }
17656
+ const textureUrl = baseUrl + json.pages[0];
17657
+ const textureResponse = await fetch(textureUrl);
17658
+ const bitmap = await createImageBitmap(await textureResponse.blob());
17659
+ return new MsdfFont(id, json, bitmap);
17653
17660
  }
17654
- remove(kid) {
17655
- const childIndex = this.#kids.findIndex((child) => child.id === kid.id);
17656
- if (childIndex <= -1) {
17657
- throw new Error(`${kid.label ?? kid.id} is not a child of ${this.label ?? this.id}`);
17661
+ set fallbackCharacter(character) {
17662
+ const charCode = character.charCodeAt(0);
17663
+ if (this.#chars.has(charCode)) {
17664
+ this.#fallbackCharCode = charCode;
17665
+ } else {
17666
+ const fallbackCode = this.#chars.keys().toArray()[0];
17667
+ console.warn(`${character} character does not exist in font ${this.name} defaulting to "${this.#chars.get(fallbackCode)?.char}".`);
17668
+ this.#fallbackCharCode = fallbackCode;
17658
17669
  }
17659
- this.#kids.splice(childIndex, 1);
17660
- kid.#parent = null;
17661
- kid.setDirty();
17662
17670
  }
17663
- #adjustWorldPosition(delta) {
17664
- const inverseMatrix = mat3.inverse(this.#parent?.matrix ?? mat3.identity());
17665
- inverseMatrix[8] = inverseMatrix[9] = 0;
17666
- const localDelta = vec2.transformMat3(delta, inverseMatrix);
17667
- this.#transform.position.x += localDelta[0];
17668
- this.#transform.position.y += localDelta[1];
17669
- this.setDirty();
17671
+ }
17672
+
17673
+ // src/text/shaping.ts
17674
+ var TAB_SPACES = 4;
17675
+ function shapeText(font, text, blockSize, fontSize, formatting, textArray, initialFloatOffset = 0, debug = false) {
17676
+ let offset = initialFloatOffset;
17677
+ const measurements = measureText(font, text, formatting.wordWrap);
17678
+ const alignment = formatting.align || "left";
17679
+ const em2px = fontSize / font.lineHeight;
17680
+ const hackHasExplicitBlock = blockSize.width !== measurements.width;
17681
+ let debugData = null;
17682
+ if (debug) {
17683
+ debugData = [];
17670
17684
  }
17671
- setDirty() {
17672
- this.#cache = null;
17673
- this.#kids.forEach((kid) => kid.setDirty());
17685
+ for (const word of measurements.words) {
17686
+ for (const glyph of word.glyphs) {
17687
+ let lineOffset = 0;
17688
+ if (alignment === "center") {
17689
+ lineOffset = measurements.width * -0.5 - (measurements.width - measurements.lineWidths[glyph.line]) * -0.5;
17690
+ } else if (alignment === "right") {
17691
+ const blockSizeEm = blockSize.width / em2px;
17692
+ const delta = measurements.width - measurements.lineWidths[glyph.line];
17693
+ lineOffset = (hackHasExplicitBlock ? blockSizeEm / 2 : measurements.width / 2) - measurements.width + delta;
17694
+ } else if (alignment === "left") {
17695
+ const blockSizeEm = blockSize.width / em2px;
17696
+ lineOffset = hackHasExplicitBlock ? -blockSizeEm / 2 : -measurements.width / 2;
17697
+ }
17698
+ if (debug && debugData) {
17699
+ debugData.push({
17700
+ line: glyph.line,
17701
+ word: word.glyphs.map((g3) => g3.char.char).join(""),
17702
+ glyph: glyph.char.char,
17703
+ startX: word.startX,
17704
+ glyphX: glyph.offset[0],
17705
+ advance: glyph.char.xadvance,
17706
+ lineOffset,
17707
+ startY: word.startY,
17708
+ glyphY: glyph.offset[1]
17709
+ });
17710
+ }
17711
+ textArray[offset] = word.startX + glyph.offset[0] + lineOffset;
17712
+ textArray[offset + 1] = word.startY + glyph.offset[1];
17713
+ textArray[offset + 2] = glyph.char.charIndex;
17714
+ offset += 4;
17715
+ }
17674
17716
  }
17675
- static parse(json) {
17676
- const obj = JSON.parse(json, reviver);
17677
- return new SceneNode(obj);
17717
+ if (debug && debugData) {
17718
+ console.table(debugData);
17678
17719
  }
17679
- toJSON() {
17680
- return {
17681
- id: this.id,
17682
- label: this.label,
17683
- transform: this.#transform,
17684
- layer: this.#layer,
17685
- isActive: this.#isActive,
17686
- kids: this.#kids,
17687
- render: this.#renderComponent
17720
+ }
17721
+ function measureText(font, text, wordWrap) {
17722
+ let maxWidth = 0;
17723
+ const lineWidths = [];
17724
+ let textOffsetX = 0;
17725
+ let textOffsetY = 0;
17726
+ let line = 0;
17727
+ let printedCharCount = 0;
17728
+ let nextCharCode = text.charCodeAt(0);
17729
+ let word = { glyphs: [], width: 0, startX: 0, startY: 0 };
17730
+ const words = [];
17731
+ for (let i3 = 0;i3 < text.length; i3++) {
17732
+ const isLastLetter = i3 === text.length - 1;
17733
+ const charCode = nextCharCode;
17734
+ nextCharCode = i3 < text.length - 1 ? text.charCodeAt(i3 + 1) : -1;
17735
+ switch (charCode) {
17736
+ case 9 /* HorizontalTab */:
17737
+ insertSpaces(TAB_SPACES);
17738
+ break;
17739
+ case 10 /* Newline */:
17740
+ flushLine();
17741
+ flushWord();
17742
+ break;
17743
+ case 13 /* CarriageReturn */:
17744
+ break;
17745
+ case 32 /* Space */:
17746
+ insertSpaces(1);
17747
+ break;
17748
+ default: {
17749
+ const advance = font.getXAdvance(charCode, nextCharCode);
17750
+ if (wordWrap && wordWrap.breakOn === "character" && textOffsetX + advance > wordWrap.emWidth) {
17751
+ if (word.startX === 0) {
17752
+ flushWord();
17753
+ } else {
17754
+ lineWidths.push(textOffsetX - word.width);
17755
+ line++;
17756
+ maxWidth = Math.max(maxWidth, textOffsetX);
17757
+ textOffsetX = word.width;
17758
+ textOffsetY -= font.lineHeight;
17759
+ word.startX = 0;
17760
+ word.startY = textOffsetY;
17761
+ word.glyphs.forEach((g3) => {
17762
+ g3.line = line;
17763
+ });
17764
+ }
17765
+ }
17766
+ word.glyphs.push({
17767
+ char: font.getChar(charCode),
17768
+ offset: [word.width, 0],
17769
+ line
17770
+ });
17771
+ if (isLastLetter) {
17772
+ flushWord();
17773
+ }
17774
+ word.width += advance;
17775
+ textOffsetX += advance;
17776
+ }
17777
+ }
17778
+ }
17779
+ lineWidths.push(textOffsetX);
17780
+ maxWidth = Math.max(maxWidth, textOffsetX);
17781
+ const lineCount = lineWidths.length;
17782
+ return {
17783
+ width: maxWidth,
17784
+ height: lineCount * font.lineHeight,
17785
+ lineWidths,
17786
+ lineCount,
17787
+ printedCharCount,
17788
+ words
17789
+ };
17790
+ function flushWord() {
17791
+ printedCharCount += word.glyphs.length;
17792
+ words.push(word);
17793
+ word = {
17794
+ glyphs: [],
17795
+ width: 0,
17796
+ startX: textOffsetX,
17797
+ startY: textOffsetY
17688
17798
  };
17689
17799
  }
17690
- }
17691
- function reviver(key, value) {
17692
- if (key === "kids") {
17693
- return value.map((kid) => new SceneNode(kid));
17800
+ function flushLine() {
17801
+ lineWidths.push(textOffsetX);
17802
+ line++;
17803
+ maxWidth = Math.max(maxWidth, textOffsetX);
17804
+ textOffsetX = 0;
17805
+ textOffsetY -= font.lineHeight;
17694
17806
  }
17695
- if (Array.isArray(value) && value.every((v3) => typeof v3 === "number")) {
17696
- if (value.length === 2) {
17697
- return value;
17698
- }
17699
- if (value.length === 3) {
17700
- return value;
17807
+ function insertSpaces(spaces) {
17808
+ if (spaces < 1)
17809
+ spaces = 1;
17810
+ textOffsetX += font.getXAdvance(32 /* Space */) * spaces;
17811
+ if (wordWrap?.breakOn === "word" && textOffsetX >= wordWrap.emWidth) {
17812
+ flushLine();
17701
17813
  }
17702
- if (value.length === 4) {
17703
- return value;
17814
+ flushWord();
17815
+ }
17816
+ }
17817
+ function findLargestFontSize(font, text, size, formatting) {
17818
+ if (!formatting.fontSize) {
17819
+ throw new Error("fontSize is required for shrinkToFit");
17820
+ }
17821
+ if (!formatting.shrinkToFit) {
17822
+ throw new Error("shrinkToFit is required for findLargestFontSize");
17823
+ }
17824
+ const minSize = formatting.shrinkToFit.minFontSize;
17825
+ const maxSize = formatting.shrinkToFit.maxFontSize ?? formatting.fontSize;
17826
+ const maxLines = formatting.shrinkToFit.maxLines ?? Number.POSITIVE_INFINITY;
17827
+ const threshold = 0.5;
17828
+ let low = minSize;
17829
+ let high = maxSize;
17830
+ while (high - low > threshold) {
17831
+ const testSize = (low + high) / 2;
17832
+ const testMeasure = measureText(font, text, formatting.wordWrap);
17833
+ const padding = formatting.shrinkToFit.padding ?? 0;
17834
+ const scaledWidth = testMeasure.width * (testSize / font.lineHeight);
17835
+ const scaledHeight = testMeasure.height * (testSize / font.lineHeight);
17836
+ const fitsWidth = scaledWidth <= size.width - size.width * padding;
17837
+ const fitsHeight = scaledHeight <= size.height - size.height * padding;
17838
+ const fitsLines = testMeasure.lineCount <= maxLines;
17839
+ if (fitsWidth && fitsHeight && fitsLines) {
17840
+ low = testSize;
17841
+ } else {
17842
+ high = testSize;
17704
17843
  }
17705
17844
  }
17706
- return value;
17845
+ return low;
17707
17846
  }
17708
17847
 
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
- };
17848
+ // src/math/angle.ts
17849
+ function deg2rad(degrees) {
17850
+ return degrees * (Math.PI / 180);
17851
+ }
17852
+ function rad2deg(radians) {
17853
+ return radians * (180 / Math.PI);
17854
+ }
17719
17855
 
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;
17856
+ // src/math/matrix.ts
17857
+ function createProjectionMatrix(resolution, dst) {
17858
+ const { width, height } = resolution;
17859
+ return mat3.scaling([2 / width, 2 / height], dst);
17860
+ }
17861
+ function createViewMatrix(camera, target) {
17862
+ const matrix = mat3.identity(target);
17863
+ mat3.scale(matrix, [camera.zoom, camera.zoom], matrix);
17864
+ mat3.rotate(matrix, camera.rotationRadians, matrix);
17865
+ mat3.translate(matrix, [-camera.x, -camera.y], matrix);
17866
+ return matrix;
17867
+ }
17868
+ function createModelMatrix(transform, base) {
17869
+ mat3.translate(base, [transform.position.x, transform.position.y], base);
17870
+ mat3.rotate(base, transform.rotation, base);
17871
+ mat3.scale(base, [transform.scale.x, transform.scale.y], base);
17872
+ return base;
17873
+ }
17874
+ function convertScreenToWorld(screenCoordinates, camera, projectionMatrix, resolution) {
17875
+ const inverseViewProjectionMatrix = mat3.mul(mat3.inverse(camera.matrix), mat3.inverse(projectionMatrix));
17876
+ const normalizedDeviceCoordinates = {
17877
+ x: 2 * screenCoordinates.x / resolution.width - 1,
17878
+ y: 1 - 2 * screenCoordinates.y / resolution.height
17879
+ };
17880
+ return transformPoint(normalizedDeviceCoordinates, inverseViewProjectionMatrix);
17881
+ }
17882
+ function convertWorldToScreen(worldCoordinates, camera, projectionMatrix, resolution) {
17883
+ const viewProjectionMatrix = mat3.mul(projectionMatrix, camera.matrix);
17884
+ const ndcPoint = transformPoint(worldCoordinates, viewProjectionMatrix);
17885
+ return {
17886
+ x: (ndcPoint.x + 1) * resolution.width / 2,
17887
+ y: (1 - ndcPoint.y) * resolution.height / 2
17888
+ };
17889
+ }
17890
+ function transformPoint(point, matrix) {
17891
+ const result = vec2.transformMat3([point.x, point.y], matrix);
17892
+ return {
17893
+ x: result[0],
17894
+ y: result[1]
17895
+ };
17896
+ }
17897
+
17898
+ // src/scene/SceneNode.ts
17899
+ class SceneNode {
17900
+ static nextId = 1;
17901
+ id;
17902
+ label;
17903
+ #isActive = true;
17904
+ #layer = null;
17905
+ #parent = null;
17906
+ #key = null;
17907
+ #kids = [];
17908
+ #transform;
17909
+ #matrix = mat3.identity();
17910
+ #renderComponent = null;
17911
+ #size = null;
17912
+ #positionProxy;
17913
+ #scaleProxy;
17914
+ #cache = null;
17915
+ constructor(opts) {
17916
+ this.id = opts?.id ?? SceneNode.nextId++;
17917
+ if (opts?.rotation && opts?.rotationRadians) {
17918
+ throw new Error(`Cannot set both rotation and rotationRadians for node ${opts?.label ?? this.id}`);
17754
17919
  }
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
17920
+ this.#transform = {
17921
+ position: opts?.position ?? { x: 0, y: 0 },
17922
+ scale: { x: 1, y: 1 },
17923
+ size: opts?.size ?? { width: 1, height: 1 },
17924
+ rotation: opts?.rotationRadians ?? 0
17763
17925
  };
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");
17926
+ if (opts?.scale)
17927
+ this.scale = opts.scale;
17928
+ if (opts?.rotation)
17929
+ this.rotation = opts.rotation;
17930
+ this.#matrix = mat3.identity();
17931
+ this.#renderComponent = opts?.render ?? null;
17932
+ this.#layer = opts?.layer ?? null;
17933
+ this.#isActive = opts?.isActive ?? true;
17934
+ this.label = opts?.label ?? undefined;
17935
+ this.#size = opts?.size ?? null;
17936
+ this.#key = opts?.key ?? null;
17937
+ for (const kid of opts?.kids ?? []) {
17938
+ this.add(kid);
17776
17939
  }
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;
17789
- }
17790
- get region() {
17791
- return this.#region;
17792
- }
17793
- get writeInstance() {
17794
- return this.#writeInstance;
17795
- }
17796
- get flipX() {
17797
- return this.#flip.x === -1;
17798
- }
17799
- set flipX(value) {
17800
- this.#flip.x = value ? -1 : 1;
17801
- this.setDirty();
17802
- }
17803
- get flipY() {
17804
- return this.#flip.y === -1;
17805
- }
17806
- set flipY(value) {
17807
- this.#flip.y = value ? -1 : 1;
17808
- this.setDirty();
17809
- }
17810
- get cropOffset() {
17811
- return this.#cropOffset;
17812
- }
17813
- set cropOffset(value) {
17814
- this.#cropOffset = value;
17815
- }
17816
- get textureId() {
17817
- return this.#textureId;
17818
- }
17819
- get isPrimitive() {
17820
- return this.#textureId === PRIMITIVE_TEXTURE;
17821
- }
17822
- get isCircle() {
17823
- return this.#atlasCoords.atlasIndex === CIRCLE_INDEX;
17940
+ const self = this;
17941
+ this.#positionProxy = {
17942
+ get x() {
17943
+ return self.#transform.position.x;
17944
+ },
17945
+ set x(value) {
17946
+ self.#transform.position.x = value;
17947
+ self.setDirty();
17948
+ },
17949
+ get y() {
17950
+ return self.#transform.position.y;
17951
+ },
17952
+ set y(value) {
17953
+ self.#transform.position.y = value;
17954
+ self.setDirty();
17955
+ }
17956
+ };
17957
+ this.#scaleProxy = {
17958
+ get x() {
17959
+ return self.#transform.scale.x;
17960
+ },
17961
+ set x(value) {
17962
+ self.#transform.scale.x = value;
17963
+ self.setDirty();
17964
+ },
17965
+ get y() {
17966
+ return self.#transform.scale.y;
17967
+ },
17968
+ set y(value) {
17969
+ self.#transform.scale.y = value;
17970
+ self.setDirty();
17971
+ }
17972
+ };
17824
17973
  }
17825
- extra = {
17826
- setAtlasCoords: (value) => {
17827
- this.#atlasCoords = value;
17828
- },
17829
- cropRatio: () => {
17830
- return this.#cropRatio;
17831
- },
17832
- atlasSize: () => {
17833
- return this.#atlasSize;
17974
+ add(kid, index) {
17975
+ kid.#parent = this;
17976
+ if (index === undefined) {
17977
+ this.#kids.push(kid);
17978
+ } else {
17979
+ this.#kids.splice(index, 0, kid);
17834
17980
  }
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");
17981
+ kid.setDirty();
17982
+ return kid;
17840
17983
  }
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);
17984
+ get kids() {
17985
+ return this.#kids;
17859
17986
  }
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
- }
17870
-
17871
- // src/scene/JumboQuadNode.ts
17872
- var MAT3_SIZE = 12;
17873
- var VEC4F_SIZE = 4;
17874
-
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
- }
17987
+ get children() {
17988
+ return this.#kids;
17901
17989
  }
17902
- get atlasCoords() {
17903
- throw new Error("JumboQuadNode does not have a single atlas coords");
17990
+ get transform() {
17991
+ return this.#transform;
17904
17992
  }
17905
- get tiles() {
17906
- return this.#tiles;
17993
+ get key() {
17994
+ return this.#key ?? "";
17907
17995
  }
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;
17996
+ get parent() {
17997
+ return this.#parent;
17931
17998
  }
17932
- }
17933
- function writeJumboQuadInstance(node, array, offset) {
17934
- if (!(node instanceof JumboQuadNode)) {
17935
- throw new Error("JumboQuadNode.writeJumboQuadInstance can only be called on JumboQuadNodes");
17999
+ set position(value) {
18000
+ this.#transform.position = value;
18001
+ this.setDirty();
17936
18002
  }
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;
18003
+ get position() {
18004
+ return this.#positionProxy;
17965
18005
  }
17966
- node.writeInstance?.(array, offset + tileOffset);
17967
- return node.tiles.length;
17968
- }
17969
-
17970
- // src/utils/error.ts
17971
- var warnings = new Map;
17972
- function warnOnce(key, msg) {
17973
- if (warnings.has(key)) {
17974
- return;
18006
+ set x(value) {
18007
+ this.#transform.position.x = value;
18008
+ this.setDirty();
17975
18009
  }
17976
- warnings.set(key, true);
17977
- console.warn(msg ?? key);
17978
- }
17979
-
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
- }
18010
+ get x() {
18011
+ return this.#transform.position.x;
18032
18012
  }
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;
18013
+ set y(value) {
18014
+ this.#transform.position.y = value;
18015
+ this.setDirty();
18041
18016
  }
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;
18017
+ get y() {
18018
+ return this.#transform.position.y;
18051
18019
  }
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);
18020
+ set rotation(value) {
18021
+ this.#transform.rotation = deg2rad(value);
18022
+ this.setDirty();
18067
18023
  }
18068
- set fallbackCharacter(character) {
18069
- const charCode = character.charCodeAt(0);
18070
- if (this.#chars.has(charCode)) {
18071
- this.#fallbackCharCode = charCode;
18024
+ get rotation() {
18025
+ return rad2deg(this.#transform.rotation);
18026
+ }
18027
+ get rotationRadians() {
18028
+ return this.#transform.rotation;
18029
+ }
18030
+ set rotationRadians(value) {
18031
+ this.#transform.rotation = value;
18032
+ this.setDirty();
18033
+ }
18034
+ get scale() {
18035
+ return this.#scaleProxy;
18036
+ }
18037
+ set scale(value) {
18038
+ if (typeof value === "number") {
18039
+ this.#transform.scale = { x: value, y: value };
18072
18040
  } 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;
18041
+ this.#transform.scale = value;
18076
18042
  }
18043
+ this.setDirty();
18077
18044
  }
18078
- }
18079
-
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 = [];
18045
+ set size(value) {
18046
+ this.#size = value;
18047
+ this.setDirty();
18091
18048
  }
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
- });
18049
+ get size() {
18050
+ return this.#size;
18051
+ }
18052
+ get aspectRatio() {
18053
+ if (!this.#size) {
18054
+ console.warn("Attempted to get aspect ratio of a node with no ideal size");
18055
+ return 1;
18056
+ }
18057
+ return this.#size.width / this.#size.height;
18058
+ }
18059
+ get isActive() {
18060
+ if (!this.#cache?.isActive) {
18061
+ this.#cache ??= {};
18062
+ let parent = this;
18063
+ let isActive = this.#isActive;
18064
+ while (isActive && parent.#parent) {
18065
+ parent = parent.#parent;
18066
+ isActive = isActive && parent.#isActive;
18117
18067
  }
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;
18068
+ this.#cache.isActive = isActive;
18122
18069
  }
18070
+ return this.#cache.isActive;
18123
18071
  }
18124
- if (debug && debugData) {
18125
- console.table(debugData);
18072
+ set isActive(value) {
18073
+ this.#isActive = value;
18074
+ this.setDirty();
18126
18075
  }
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();
18076
+ get layer() {
18077
+ if (this.#layer != null) {
18078
+ return this.#layer;
18079
+ }
18080
+ if (!this.#cache?.layer) {
18081
+ this.#cache ??= {};
18082
+ let parent = this;
18083
+ while (parent.#parent) {
18084
+ parent = parent.#parent;
18085
+ if (parent.hasExplicitLayer) {
18086
+ this.#cache.layer = parent.#layer;
18087
+ return this.#cache.layer;
18180
18088
  }
18181
- word.width += advance;
18182
- textOffsetX += advance;
18183
18089
  }
18090
+ this.#cache.layer = 0;
18184
18091
  }
18092
+ return this.#cache.layer;
18185
18093
  }
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
- };
18094
+ get hasExplicitLayer() {
18095
+ return this.#layer != null;
18206
18096
  }
18207
- function flushLine() {
18208
- lineWidths.push(textOffsetX);
18209
- line++;
18210
- maxWidth = Math.max(maxWidth, textOffsetX);
18211
- textOffsetX = 0;
18212
- textOffsetY -= font.lineHeight;
18097
+ set layer(value) {
18098
+ this.#layer = value;
18099
+ this.setDirty();
18213
18100
  }
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();
18101
+ get renderComponent() {
18102
+ return this.#renderComponent;
18103
+ }
18104
+ get matrix() {
18105
+ if (!this.#cache?.matrix) {
18106
+ this.#cache ??= {};
18107
+ if (this.#parent) {
18108
+ mat3.clone(this.#parent.matrix, this.#matrix);
18109
+ } else {
18110
+ mat3.identity(this.#matrix);
18111
+ }
18112
+ this.#cache.matrix = createModelMatrix(this.transform, this.#matrix);
18113
+ }
18114
+ return this.#cache.matrix;
18115
+ }
18116
+ get bounds() {
18117
+ if (!this.#cache?.bounds) {
18118
+ this.#cache ??= {};
18119
+ const height = this.size?.height ?? 0;
18120
+ const width = this.size?.width ?? 0;
18121
+ const corners = [
18122
+ vec2.transformMat3([-width / 2, height / 2], this.matrix),
18123
+ vec2.transformMat3([width / 2, height / 2], this.matrix),
18124
+ vec2.transformMat3([-width / 2, -height / 2], this.matrix),
18125
+ vec2.transformMat3([width / 2, -height / 2], this.matrix)
18126
+ ];
18127
+ const center = vec2.transformMat3([0, 0], this.matrix);
18128
+ const xValues = corners.map((c3) => c3[0]);
18129
+ const yValues = corners.map((c3) => c3[1]);
18130
+ this.#cache.bounds = {
18131
+ x: center[0],
18132
+ y: center[1],
18133
+ left: Math.min(xValues[0], xValues[1], xValues[2], xValues[3]),
18134
+ right: Math.max(xValues[0], xValues[1], xValues[2], xValues[3]),
18135
+ top: Math.max(yValues[0], yValues[1], yValues[2], yValues[3]),
18136
+ bottom: Math.min(yValues[0], yValues[1], yValues[2], yValues[3])
18137
+ };
18220
18138
  }
18221
- flushWord();
18139
+ return this.#cache.bounds;
18222
18140
  }
18223
- }
18224
- function findLargestFontSize(font, text, size, formatting) {
18225
- if (!formatting.fontSize) {
18226
- throw new Error("fontSize is required for shrinkToFit");
18141
+ setBounds(bounds) {
18142
+ if (bounds.left !== undefined)
18143
+ this.left = bounds.left;
18144
+ if (bounds.right !== undefined)
18145
+ this.right = bounds.right;
18146
+ if (bounds.top !== undefined)
18147
+ this.top = bounds.top;
18148
+ if (bounds.bottom !== undefined)
18149
+ this.bottom = bounds.bottom;
18150
+ if (bounds.x !== undefined)
18151
+ this.centerX = bounds.x;
18152
+ if (bounds.y !== undefined)
18153
+ this.centerY = bounds.y;
18154
+ return this;
18227
18155
  }
18228
- if (!formatting.shrinkToFit) {
18229
- throw new Error("shrinkToFit is required for findLargestFontSize");
18156
+ set left(value) {
18157
+ this.#adjustWorldPosition([value - this.bounds.left, 0]);
18230
18158
  }
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
- }
18159
+ set bottom(value) {
18160
+ this.#adjustWorldPosition([0, value - this.bounds.bottom]);
18251
18161
  }
18252
- return low;
18253
- }
18254
-
18255
- // src/text/TextNode.ts
18256
- var DEFAULT_FONT_SIZE = 14;
18257
-
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;
18162
+ set top(value) {
18163
+ this.#adjustWorldPosition([0, value - this.bounds.top]);
18283
18164
  }
18284
- get text() {
18285
- return this.#text;
18165
+ set right(value) {
18166
+ this.#adjustWorldPosition([value - this.bounds.right, 0]);
18286
18167
  }
18287
- get formatting() {
18288
- return this.#formatting;
18168
+ set centerX(value) {
18169
+ this.#adjustWorldPosition([value - this.bounds.x, 0]);
18289
18170
  }
18290
- get font() {
18291
- return this.#font;
18171
+ set centerY(value) {
18172
+ this.#adjustWorldPosition([0, value - this.bounds.y]);
18292
18173
  }
18293
- set text(text) {
18294
- if (!text) {
18295
- throw new Error("text cannot be empty");
18174
+ delete() {
18175
+ this.#parent?.remove(this);
18176
+ for (const child of this.#kids) {
18177
+ child.delete();
18296
18178
  }
18297
- this.#text = text;
18298
- this.setDirty();
18179
+ this.#kids = [];
18180
+ this.#isActive = false;
18181
+ this.#layer = null;
18182
+ this.#renderComponent = null;
18299
18183
  }
18300
- get tint() {
18301
- return this.#formatting.color || { r: 1, g: 1, b: 1, a: 1 };
18184
+ remove(kid) {
18185
+ const childIndex = this.#kids.findIndex((child) => child.id === kid.id);
18186
+ if (childIndex <= -1) {
18187
+ throw new Error(`${kid.label ?? kid.id} is not a child of ${this.label ?? this.id}`);
18188
+ }
18189
+ this.#kids.splice(childIndex, 1);
18190
+ kid.#parent = null;
18191
+ kid.setDirty();
18302
18192
  }
18303
- set tint(tint) {
18304
- this.#formatting.color = tint;
18193
+ #adjustWorldPosition(delta) {
18194
+ const inverseMatrix = mat3.inverse(this.#parent?.matrix ?? mat3.identity());
18195
+ inverseMatrix[8] = inverseMatrix[9] = 0;
18196
+ const localDelta = vec2.transformMat3(delta, inverseMatrix);
18197
+ this.#transform.position.x += localDelta[0];
18198
+ this.#transform.position.y += localDelta[1];
18305
18199
  this.setDirty();
18306
18200
  }
18307
- set formatting(formatting) {
18308
- this.#formatting = formatting;
18309
- this.setDirty();
18201
+ setDirty() {
18202
+ this.#cache = null;
18203
+ this.#kids.forEach((kid) => kid.setDirty());
18204
+ }
18205
+ static parse(json) {
18206
+ const obj = JSON.parse(json, reviver);
18207
+ return new SceneNode(obj);
18208
+ }
18209
+ toJSON() {
18210
+ return {
18211
+ id: this.id,
18212
+ label: this.label,
18213
+ transform: this.#transform,
18214
+ layer: this.#layer,
18215
+ isActive: this.#isActive,
18216
+ kids: this.#kids,
18217
+ render: this.#renderComponent
18218
+ };
18310
18219
  }
18311
18220
  }
18312
-
18313
- // src/backends/webgpu/wgsl/pixel-scraping.wgsl.ts
18314
- var pixel_scraping_wgsl_default = `
18315
- // ==============================
18316
- // === BOUNDING BOX PASS =======
18317
- // ==============================
18318
-
18319
- // Input texture from which to compute the non-transparent bounding box
18320
- @group(0) @binding(0)
18321
- var input_texture: texture_2d<f32>;
18322
-
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
- };
18330
-
18331
- // Storage buffer to hold atomic bounding box updates
18332
- @group(0) @binding(1)
18333
- var<storage, read_write> bounds: bounding_box_atomic;
18334
-
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
- }
18342
-
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);
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;
18349
18228
  }
18350
- }
18351
-
18352
- // ==============================
18353
- // === CROP + OUTPUT PASS ======
18354
- // ==============================
18355
-
18356
- // Input texture from which cropped data is read
18357
- @group(0) @binding(0)
18358
- var input_texture_crop: texture_2d<f32>;
18359
-
18360
- // Output texture where cropped image is written
18361
- @group(0) @binding(1)
18362
- var output_texture: texture_storage_2d<rgba8unorm, write>;
18363
-
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
- };
18371
-
18372
- @group(0) @binding(2)
18373
- var<uniform> bounds_uniform: bounding_box_uniform;
18374
-
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
- };
18382
-
18383
- // Storage buffer to output the result dimensions
18384
- @group(0) @binding(3)
18385
- var<storage, read_write> dimensions_out: image_dimensions;
18386
-
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;
18391
-
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;
18394
-
18395
- if (gid.x >= crop_width || gid.y >= crop_height) {
18396
- return;
18229
+ if (value.length === 3) {
18230
+ return value;
18397
18231
  }
18398
-
18399
- let src_coord = vec2<i32>(
18400
- i32(bounds_uniform.min_x + gid.x),
18401
- i32(bounds_uniform.min_y + gid.y)
18402
- );
18403
-
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);
18407
-
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;
18232
+ if (value.length === 4) {
18233
+ return value;
18414
18234
  }
18235
+ }
18236
+ return value;
18415
18237
  }
18416
18238
 
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>;
18239
+ // src/scene/TextNode.ts
18240
+ var DEFAULT_FONT_SIZE = 14;
18424
18241
 
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;
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.`);
18431
18250
  }
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);
18443
- }
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;
18451
-
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
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 };
18254
+ }
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
+ }
18466
18263
  });
18264
+ this.#font = shader.font;
18265
+ this.#text = text;
18266
+ this.#formatting = opts;
18467
18267
  }
18468
- static create(device) {
18469
- const pipelines = createPipelines(device, "TextureComputeShader");
18470
- return new TextureComputeShader(device, pipelines.cropPipeline, pipelines.boundPipeline, pipelines.missingTexturePipeline);
18268
+ get text() {
18269
+ return this.#text;
18471
18270
  }
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);
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");
18493
18280
  }
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;
18281
+ this.#text = text;
18282
+ this.setDirty();
18505
18283
  }
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
18511
- });
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
- };
18284
+ get tint() {
18285
+ return this.#formatting.color || { r: 1, g: 1, b: 1, a: 1 };
18526
18286
  }
18527
- async#cropTexture(croppedWidth, croppedHeight, computeBuffer, inputTexture) {
18528
- const boundsUniform = this.#device.createBuffer({
18529
- label: "Cropping Bounds Uniform Buffer",
18530
- size: BOUNDING_BOX_SIZE,
18531
- 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;
18287
+ set tint(tint) {
18288
+ this.#formatting.color = tint;
18289
+ this.setDirty();
18554
18290
  }
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
- };
18291
+ set formatting(formatting) {
18292
+ this.#formatting = formatting;
18293
+ this.setDirty();
18574
18294
  }
18575
- #boundsBindGroup(inputTexture) {
18576
- return this.#device.createBindGroup({
18577
- layout: this.#boundPipeline.getBindGroupLayout(0),
18295
+ }
18296
+
18297
+ // src/backends/webgpu/WebGPUTextShader.ts
18298
+ var deets = new _t2(text_wgsl_default);
18299
+ var struct = deets.structs.find((s3) => s3.name === "TextBlockDescriptor");
18300
+ if (!struct) {
18301
+ throw new Error("FormattedText struct not found");
18302
+ }
18303
+ var textDescriptorInstanceSize = struct.size;
18304
+
18305
+ class WebGPUTextShader {
18306
+ label = "text";
18307
+ #backend;
18308
+ #pipeline;
18309
+ #bindGroups = [];
18310
+ #font;
18311
+ #maxCharCount;
18312
+ #engineUniformsBuffer;
18313
+ #descriptorBuffer;
18314
+ #textBlockBuffer;
18315
+ #cpuDescriptorBuffer;
18316
+ #cpuTextBlockBuffer;
18317
+ #instanceIndex = 0;
18318
+ #textBlockOffset = 0;
18319
+ constructor(backend, pipeline, font, _colorFormat, instanceCount) {
18320
+ this.#backend = backend;
18321
+ const device = backend.device;
18322
+ this.#font = font;
18323
+ this.#pipeline = pipeline.pipeline;
18324
+ this.#maxCharCount = pipeline.maxCharCount;
18325
+ this.#descriptorBuffer = device.createBuffer({
18326
+ label: "msdf text descriptor buffer",
18327
+ size: textDescriptorInstanceSize * instanceCount,
18328
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
18329
+ });
18330
+ this.#cpuDescriptorBuffer = new Float32Array(instanceCount * textDescriptorInstanceSize / Float32Array.BYTES_PER_ELEMENT);
18331
+ this.#cpuTextBlockBuffer = new Float32Array(instanceCount * this.maxCharCount * 4);
18332
+ this.#engineUniformsBuffer = device.createBuffer({
18333
+ label: "msdf view projection matrix",
18334
+ size: Float32Array.BYTES_PER_ELEMENT * 12,
18335
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
18336
+ });
18337
+ this.#textBlockBuffer = device.createBuffer({
18338
+ label: "msdf text buffer",
18339
+ size: instanceCount * this.maxCharCount * 4 * Float32Array.BYTES_PER_ELEMENT,
18340
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
18341
+ });
18342
+ this.#bindGroups.push(pipeline.fontBindGroup);
18343
+ this.#bindGroups.push(device.createBindGroup({
18344
+ label: "msdf text bind group",
18345
+ layout: pipeline.pipeline.getBindGroupLayout(1),
18578
18346
  entries: [
18579
- { binding: 0, resource: inputTexture.createView() },
18580
- { binding: 1, resource: { buffer: this.#boundingBuffer } }
18347
+ {
18348
+ binding: 0,
18349
+ resource: { buffer: this.#descriptorBuffer }
18350
+ },
18351
+ {
18352
+ binding: 1,
18353
+ resource: { buffer: this.#textBlockBuffer }
18354
+ }
18581
18355
  ]
18582
- });
18583
- }
18584
- #croppingBindGroup(inputTexture, outputTexture, boundsUniform, dimensionsOutBuffer) {
18585
- return this.#device.createBindGroup({
18586
- layout: this.#cropPipeline.getBindGroupLayout(0),
18356
+ }));
18357
+ const engineUniformsBindGroup = device.createBindGroup({
18358
+ label: "msdf text uniforms bind group",
18359
+ layout: pipeline.pipeline.getBindGroupLayout(2),
18587
18360
  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 } }
18361
+ {
18362
+ binding: 0,
18363
+ resource: { buffer: this.#engineUniformsBuffer }
18364
+ }
18592
18365
  ]
18593
18366
  });
18367
+ this.#bindGroups.push(engineUniformsBindGroup);
18594
18368
  }
18595
- #missingTextureBindGroup(outputTexture) {
18596
- return this.#device.createBindGroup({
18597
- layout: this.#missingTexturePipeline.getBindGroupLayout(0),
18598
- entries: [{ binding: 0, resource: outputTexture.createView() }]
18599
- });
18369
+ startFrame(uniform) {
18370
+ const device = this.#backend.device;
18371
+ device.queue.writeBuffer(this.#engineUniformsBuffer, 0, uniform.viewProjectionMatrix);
18372
+ this.#instanceIndex = 0;
18373
+ this.#textBlockOffset = 0;
18600
18374
  }
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
- }
18375
+ processBatch(nodes) {
18376
+ if (nodes.length === 0)
18377
+ return 0;
18378
+ const renderPass = this.#backend.renderPass;
18379
+ renderPass.setPipeline(this.#pipeline);
18380
+ for (let i3 = 0;i3 < this.#bindGroups.length; i3++) {
18381
+ renderPass.setBindGroup(i3, this.#bindGroups[i3]);
18382
+ }
18383
+ for (const node of nodes) {
18384
+ if (!(node instanceof TextNode)) {
18385
+ console.error(node);
18386
+ throw new Error(`Tried to use WebGPUTextShader on something that isn't a TextNode: ${node}`);
18658
18387
  }
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);
18388
+ const text = node.text;
18389
+ const formatting = node.formatting;
18390
+ const measurements = measureText(this.#font, text, formatting.wordWrap);
18391
+ const textBlockSize = 4 * text.length;
18392
+ const textDescriptorOffset = this.#instanceIndex * textDescriptorInstanceSize / Float32Array.BYTES_PER_ELEMENT;
18393
+ this.#cpuDescriptorBuffer.set(node.matrix, textDescriptorOffset);
18394
+ this.#cpuDescriptorBuffer.set([node.tint.r, node.tint.g, node.tint.b, node.tint.a], textDescriptorOffset + 12);
18395
+ const size = node.size ?? measurements;
18396
+ const fontSize = formatting.shrinkToFit ? findLargestFontSize(this.#font, text, size, formatting) : formatting.fontSize;
18397
+ const actualFontSize = fontSize || DEFAULT_FONT_SIZE;
18398
+ this.#cpuDescriptorBuffer[textDescriptorOffset + 16] = actualFontSize;
18399
+ this.#cpuDescriptorBuffer[textDescriptorOffset + 17] = formatting.align === "center" ? 0 : measurements.width;
18400
+ this.#cpuDescriptorBuffer[textDescriptorOffset + 18] = measurements.height;
18401
+ this.#cpuDescriptorBuffer[textDescriptorOffset + 19] = this.#textBlockOffset / 4;
18402
+ shapeText(this.#font, text, size, actualFontSize, formatting, this.#cpuTextBlockBuffer, this.#textBlockOffset);
18403
+ this.#backend.device.queue.writeBuffer(this.#descriptorBuffer, textDescriptorOffset * Float32Array.BYTES_PER_ELEMENT, this.#cpuDescriptorBuffer, textDescriptorOffset, textDescriptorInstanceSize / Float32Array.BYTES_PER_ELEMENT);
18404
+ this.#backend.device.queue.writeBuffer(this.#textBlockBuffer, this.#textBlockOffset * Float32Array.BYTES_PER_ELEMENT, this.#cpuTextBlockBuffer, this.#textBlockOffset, textBlockSize);
18405
+ this.#textBlockOffset += textBlockSize;
18406
+ renderPass.draw(4, measurements.printedCharCount, 4 * this.#instanceIndex, 0);
18407
+ this.#instanceIndex++;
18408
+ }
18409
+ return nodes.length;
18410
+ }
18411
+ endFrame() {}
18412
+ get font() {
18413
+ return this.#font;
18414
+ }
18415
+ get maxCharCount() {
18416
+ return this.#maxCharCount;
18417
+ }
18792
18418
  }
18793
18419
 
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));
18420
+ // src/scene/Batcher.ts
18421
+ class Batcher {
18422
+ nodes = [];
18423
+ layers = [];
18424
+ pipelines = [];
18425
+ enqueue(node) {
18426
+ if (node.renderComponent && node.isActive) {
18427
+ this.nodes.push(node);
18428
+ const z3 = node.layer;
18429
+ const layer = this.#findOrCreateLayer(z3);
18430
+ const pipeline = this.#findOrCreatePipeline(layer, node.renderComponent.shader);
18431
+ pipeline.nodes.push(node);
18432
+ }
18433
+ for (const kid of node.kids) {
18434
+ this.enqueue(kid);
18435
+ }
18436
+ }
18437
+ flush() {
18438
+ this.nodes = [];
18439
+ this.layers = [];
18440
+ this.pipelines = [];
18441
+ }
18442
+ #findOrCreateLayer(z3) {
18443
+ let layer = this.layers.find((l3) => l3.z === z3);
18444
+ if (!layer) {
18445
+ layer = { z: z3, pipelines: [] };
18446
+ this.layers.push(layer);
18447
+ this.layers.sort((a3, b3) => a3.z - b3.z);
18448
+ }
18449
+ return layer;
18450
+ }
18451
+ #findOrCreatePipeline(layer, shader) {
18452
+ let pipeline = layer.pipelines.find((p3) => p3.shader === shader);
18453
+ if (!pipeline) {
18454
+ pipeline = { shader, nodes: [] };
18455
+ layer.pipelines.push(pipeline);
18456
+ this.pipelines.push(pipeline);
18457
+ }
18458
+ return pipeline;
18459
+ }
18798
18460
  }
18799
18461
 
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;
18462
+ // src/scene/Camera.ts
18463
+ class Camera {
18464
+ #position = { x: 0, y: 0 };
18465
+ #zoom = 1;
18466
+ #rotation = 0;
18467
+ #isDirty = true;
18468
+ #matrix = mat3.create();
18469
+ get zoom() {
18470
+ return this.#zoom;
18471
+ }
18472
+ set zoom(value) {
18473
+ this.#zoom = value;
18474
+ this.setDirty();
18475
+ }
18476
+ get rotation() {
18477
+ return rad2deg(this.#rotation);
18478
+ }
18479
+ set rotation(value) {
18480
+ this.#rotation = deg2rad(value);
18481
+ this.setDirty();
18482
+ }
18483
+ get rotationRadians() {
18484
+ return this.#rotation;
18485
+ }
18486
+ set rotationRadians(value) {
18487
+ this.#rotation = value;
18488
+ this.setDirty();
18489
+ }
18490
+ get x() {
18491
+ return this.#position.x;
18492
+ }
18493
+ get y() {
18494
+ return this.#position.y;
18495
+ }
18496
+ set x(value) {
18497
+ this.#position.x = value;
18498
+ this.setDirty();
18499
+ }
18500
+ set y(value) {
18501
+ this.#position.y = value;
18502
+ this.setDirty();
18503
+ }
18504
+ get matrix() {
18505
+ if (this.#isDirty) {
18506
+ this.#isDirty = false;
18507
+ this.#matrix = createViewMatrix(this, this.#matrix);
18508
+ }
18509
+ return this.#matrix;
18510
+ }
18511
+ setDirty() {
18512
+ this.#isDirty = true;
18824
18513
  }
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
18514
  }
18835
- `;
18836
18515
 
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;
18516
+ // src/scene/QuadNode.ts
18517
+ var PRIMITIVE_TEXTURE = "__primitive__";
18518
+ var RESERVED_PRIMITIVE_INDEX_START = 1000;
18519
+ var CIRCLE_INDEX = 1001;
18520
+ var DEFAULT_REGION = {
18521
+ x: 0,
18522
+ y: 0,
18523
+ width: 0,
18524
+ height: 0
18525
+ };
18526
+
18527
+ class QuadNode extends SceneNode {
18528
+ assetManager;
18529
+ #color;
18530
+ #atlasCoords;
18531
+ #region;
18532
+ #matrixPool;
18533
+ #flip;
18534
+ #cropOffset;
18535
+ #cropRatio;
18536
+ #atlasSize;
18537
+ #textureId;
18538
+ #writeInstance;
18539
+ constructor(options, matrixPool) {
18540
+ assert(options.shader, "QuadNode requires a shader to be explicitly provided");
18541
+ assert(options.size, "QuadNode requires a size to be explicitly provided");
18542
+ assert(options.atlasCoords, "QuadNode requires atlas coords to be explicitly provided");
18543
+ options.render ??= {
18544
+ shader: options.shader,
18545
+ writeInstance: writeQuadInstance
18546
+ };
18547
+ super(options);
18548
+ assert(options.assetManager, "QuadNode requires an asset manager");
18549
+ this.assetManager = options.assetManager;
18550
+ if (options.atlasCoords && options.atlasCoords.atlasIndex >= RESERVED_PRIMITIVE_INDEX_START) {
18551
+ this.#textureId = PRIMITIVE_TEXTURE;
18552
+ this.#region = DEFAULT_REGION;
18553
+ this.#atlasSize = DEFAULT_REGION;
18554
+ } else {
18555
+ assert(options.textureId, "QuadNode requires texture id to be explicitly provided");
18556
+ this.#textureId = options.textureId;
18557
+ assert(options.region, "QuadNode requires a region to be explicitly provided");
18558
+ this.#region = options.region;
18559
+ assert(options.atlasSize, "QuadNode requires atlas size to be explicitly provided");
18560
+ this.#atlasSize = options.atlasSize;
18561
+ }
18562
+ this.#atlasCoords = options.atlasCoords;
18563
+ this.#color = options.color ?? { r: 1, g: 1, b: 1, a: 1 };
18564
+ this.#matrixPool = matrixPool;
18565
+ this.#flip = { x: options.flipX ? -1 : 1, y: options.flipY ? -1 : 1 };
18566
+ this.#cropOffset = options.cropOffset ?? { x: 0, y: 0 };
18567
+ this.#cropRatio = !this.#atlasCoords.uvScaleCropped ? { width: 1, height: 1 } : {
18568
+ width: this.#atlasCoords.uvScaleCropped.width / this.#atlasCoords.uvScale.width,
18569
+ height: this.#atlasCoords.uvScaleCropped.height / this.#atlasCoords.uvScale.height
18570
+ };
18571
+ this.#writeInstance = options.writeInstance;
18572
+ }
18573
+ get color() {
18574
+ return this.#color;
18575
+ }
18576
+ set color(value) {
18577
+ this.#color = value;
18578
+ }
18579
+ get size() {
18580
+ const size = super.size;
18581
+ if (!size) {
18582
+ throw new Error("QuadNode requires a size");
18583
+ }
18584
+ return size;
18585
+ }
18586
+ set size(val) {
18587
+ super.size = val;
18588
+ }
18589
+ get matrixWithSize() {
18590
+ const matrix = mat3.clone(this.matrix, this.#matrixPool.get());
18591
+ mat3.scale(matrix, [this.size.width * this.#flip.x, this.size.height * this.#flip.y], matrix);
18592
+ return matrix;
18593
+ }
18594
+ get atlasCoords() {
18595
+ return this.#atlasCoords;
18596
+ }
18597
+ get region() {
18598
+ return this.#region;
18599
+ }
18600
+ get writeInstance() {
18601
+ return this.#writeInstance;
18602
+ }
18603
+ get flipX() {
18604
+ return this.#flip.x === -1;
18605
+ }
18606
+ set flipX(value) {
18607
+ this.#flip.x = value ? -1 : 1;
18608
+ this.setDirty();
18609
+ }
18610
+ get flipY() {
18611
+ return this.#flip.y === -1;
18848
18612
  }
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);
18613
+ set flipY(value) {
18614
+ this.#flip.y = value ? -1 : 1;
18615
+ this.setDirty();
18902
18616
  }
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"
18617
+ get cropOffset() {
18618
+ return this.#cropOffset;
18619
+ }
18620
+ set cropOffset(value) {
18621
+ this.#cropOffset = value;
18622
+ }
18623
+ get textureId() {
18624
+ return this.#textureId;
18625
+ }
18626
+ get isPrimitive() {
18627
+ return this.#textureId === PRIMITIVE_TEXTURE;
18628
+ }
18629
+ get isCircle() {
18630
+ return this.#atlasCoords.atlasIndex === CIRCLE_INDEX;
18631
+ }
18632
+ extra = {
18633
+ setAtlasCoords: (value) => {
18634
+ this.#atlasCoords = value;
18921
18635
  },
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
- ]
18636
+ cropRatio: () => {
18637
+ return this.#cropRatio;
18940
18638
  },
18941
- primitive: {
18942
- topology: "triangle-strip",
18943
- stripIndexFormat: "uint32"
18639
+ atlasSize: () => {
18640
+ return this.#atlasSize;
18944
18641
  }
18945
- });
18946
- }
18947
- if (typeof GPUShaderStage === "undefined") {
18948
- globalThis.GPUShaderStage = {
18949
- VERTEX: 1,
18950
- FRAGMENT: 2,
18951
- COMPUTE: 4
18952
18642
  };
18953
18643
  }
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: {}
18644
+ function writeQuadInstance(node, array, offset) {
18645
+ if (!(node instanceof QuadNode)) {
18646
+ throw new Error("QuadNode.writeInstance can only be called on QuadNodes");
18647
+ }
18648
+ array.set(node.matrixWithSize, offset);
18649
+ array.set([node.color.r, node.color.g, node.color.b, node.color.a], offset + 12);
18650
+ const region = node.region;
18651
+ if (node.textureId === PRIMITIVE_TEXTURE) {
18652
+ array.set([
18653
+ node.atlasCoords.uvOffset.x,
18654
+ node.atlasCoords.uvOffset.y,
18655
+ node.atlasCoords.uvScale.width,
18656
+ node.atlasCoords.uvScale.height
18657
+ ], offset + 16);
18658
+ } else {
18659
+ const atlasSize = node.extra.atlasSize();
18660
+ array.set([
18661
+ node.atlasCoords.uvOffset.x + region.x / atlasSize.width,
18662
+ node.atlasCoords.uvOffset.y + region.y / atlasSize.height,
18663
+ region.width / atlasSize.width,
18664
+ region.height / atlasSize.height
18665
+ ], offset + 16);
18666
+ }
18667
+ array.set([
18668
+ node.cropOffset.x / 2 / (node.atlasCoords.originalSize.width || 1),
18669
+ node.cropOffset.y / 2 / (node.atlasCoords.originalSize.height || 1),
18670
+ node.extra.cropRatio().width,
18671
+ node.extra.cropRatio().height
18672
+ ], offset + 20);
18673
+ new DataView(array.buffer).setUint32(array.byteOffset + (offset + 24) * Float32Array.BYTES_PER_ELEMENT, node.atlasCoords.atlasIndex, true);
18674
+ node.writeInstance?.(array, offset + 28);
18675
+ return 1;
18676
+ }
18677
+
18678
+ // src/scene/JumboQuadNode.ts
18679
+ var MAT3_SIZE = 12;
18680
+ var VEC4F_SIZE = 4;
18681
+
18682
+ class JumboQuadNode extends QuadNode {
18683
+ #tiles;
18684
+ #matrixPool;
18685
+ constructor(options, matrixPool) {
18686
+ assert(options.shader, "JumboQuadNode requires a shader to be explicitly provided");
18687
+ assert(options.tiles && options.tiles.length > 0, "JumboQuadNode requires at least one tile to be provided");
18688
+ options.render ??= {
18689
+ shader: options.shader,
18690
+ writeInstance: writeJumboQuadInstance
18691
+ };
18692
+ super({
18693
+ ...options,
18694
+ atlasCoords: options.tiles[0].atlasCoords
18695
+ }, matrixPool);
18696
+ this.#matrixPool = matrixPool;
18697
+ this.#tiles = [];
18698
+ for (const tile of options.tiles) {
18699
+ assert(tile.atlasCoords, "JumboQuadNode requires atlas coords to be provided");
18700
+ assert(tile.size, "JumboQuadNode requires a size to be provided");
18701
+ this.#tiles.push({
18702
+ textureId: tile.textureId,
18703
+ offset: tile.offset,
18704
+ size: tile.size,
18705
+ atlasCoords: tile.atlasCoords
18706
+ });
18976
18707
  }
18977
- ]
18708
+ }
18709
+ get atlasCoords() {
18710
+ throw new Error("JumboQuadNode does not have a single atlas coords");
18711
+ }
18712
+ get tiles() {
18713
+ return this.#tiles;
18714
+ }
18715
+ getTileMatrix(tile) {
18716
+ const matrix = mat3.clone(this.matrix, this.#matrixPool.get());
18717
+ const originalSize = {
18718
+ width: Math.max(...this.#tiles.map((t3) => t3.offset.x + t3.size.width)),
18719
+ height: Math.max(...this.#tiles.map((t3) => t3.offset.y + t3.size.height))
18720
+ };
18721
+ const proportionalSize = {
18722
+ width: this.size.width / originalSize.width,
18723
+ height: this.size.height / originalSize.height
18724
+ };
18725
+ const centerOffset = {
18726
+ x: tile.offset.x + tile.size.width / 2 - originalSize.width / 2,
18727
+ y: -(tile.offset.y + tile.size.height / 2 - originalSize.height / 2)
18728
+ };
18729
+ mat3.translate(matrix, [
18730
+ centerOffset.x * proportionalSize.width,
18731
+ centerOffset.y * proportionalSize.height
18732
+ ], matrix);
18733
+ mat3.scale(matrix, [
18734
+ tile.size.width * proportionalSize.width * (this.flipX ? -1 : 1),
18735
+ tile.size.height * proportionalSize.height * (this.flipY ? -1 : 1)
18736
+ ], matrix);
18737
+ return matrix;
18738
+ }
18739
+ }
18740
+ function writeJumboQuadInstance(node, array, offset) {
18741
+ if (!(node instanceof JumboQuadNode)) {
18742
+ throw new Error("JumboQuadNode.writeJumboQuadInstance can only be called on JumboQuadNodes");
18743
+ }
18744
+ let tileOffset = 0;
18745
+ for (const tile of node.tiles) {
18746
+ const coord = tile.atlasCoords;
18747
+ const matrix = node.getTileMatrix(tile);
18748
+ array.set(matrix, offset + tileOffset);
18749
+ tileOffset += MAT3_SIZE;
18750
+ array.set([node.color.r, node.color.g, node.color.b, node.color.a], offset + tileOffset);
18751
+ tileOffset += VEC4F_SIZE;
18752
+ array.set([
18753
+ coord.uvOffset.x,
18754
+ coord.uvOffset.y,
18755
+ coord.uvScale.width,
18756
+ coord.uvScale.height
18757
+ ], offset + tileOffset);
18758
+ tileOffset += VEC4F_SIZE;
18759
+ const cropRatio = !coord.uvScaleCropped ? { width: 1, height: 1 } : {
18760
+ width: coord.uvScaleCropped.width / coord.uvScale.width,
18761
+ height: coord.uvScaleCropped.height / coord.uvScale.height
18762
+ };
18763
+ array.set([
18764
+ tile.atlasCoords.cropOffset.x / 2 / (tile.atlasCoords.originalSize.width || 1),
18765
+ tile.atlasCoords.cropOffset.y / 2 / (tile.atlasCoords.originalSize.height || 1),
18766
+ cropRatio.width,
18767
+ cropRatio.height
18768
+ ], offset + tileOffset);
18769
+ tileOffset += VEC4F_SIZE;
18770
+ new DataView(array.buffer).setUint32(array.byteOffset + (offset + tileOffset) * Float32Array.BYTES_PER_ELEMENT, coord.atlasIndex, true);
18771
+ tileOffset += VEC4F_SIZE;
18772
+ }
18773
+ node.writeInstance?.(array, offset + tileOffset);
18774
+ return node.tiles.length;
18775
+ }
18776
+
18777
+ // src/backends/webgpu/wgsl/pixel-scraping.wgsl.ts
18778
+ var pixel_scraping_wgsl_default = `
18779
+ // ==============================
18780
+ // === BOUNDING BOX PASS =======
18781
+ // ==============================
18782
+
18783
+ // Input texture from which to compute the non-transparent bounding box
18784
+ @group(0) @binding(0)
18785
+ var input_texture: texture_2d<f32>;
18786
+
18787
+ // Atomic bounding box storage structure
18788
+ struct bounding_box_atomic {
18789
+ min_x: atomic<u32>,
18790
+ min_y: atomic<u32>,
18791
+ max_x: atomic<u32>,
18792
+ max_y: atomic<u32>,
18978
18793
  };
18979
- var engineUniformBindGroupLayout = {
18980
- label: "Uniform bind group",
18981
- entries: [
18982
- {
18983
- binding: 0,
18984
- visibility: GPUShaderStage.VERTEX,
18985
- buffer: {}
18794
+
18795
+ // Storage buffer to hold atomic bounding box updates
18796
+ @group(0) @binding(1)
18797
+ var<storage, read_write> bounds: bounding_box_atomic;
18798
+
18799
+ // Compute shader to find the bounding box of non-transparent pixels
18800
+ @compute @workgroup_size(8, 8)
18801
+ fn find_bounds(@builtin(global_invocation_id) gid: vec3<u32>) {
18802
+ let size = textureDimensions(input_texture).xy;
18803
+ if (gid.x >= size.x || gid.y >= size.y) {
18804
+ return;
18986
18805
  }
18987
- ]
18806
+
18807
+ let pixel = textureLoad(input_texture, vec2<i32>(gid.xy), 0);
18808
+ if (pixel.a > 0.0) {
18809
+ atomicMin(&bounds.min_x, gid.x);
18810
+ atomicMin(&bounds.min_y, gid.y);
18811
+ atomicMax(&bounds.max_x, gid.x);
18812
+ atomicMax(&bounds.max_y, gid.y);
18813
+ }
18814
+ }
18815
+
18816
+ // ==============================
18817
+ // === CROP + OUTPUT PASS ======
18818
+ // ==============================
18819
+
18820
+ // Input texture from which cropped data is read
18821
+ @group(0) @binding(0)
18822
+ var input_texture_crop: texture_2d<f32>;
18823
+
18824
+ // Output texture where cropped image is written
18825
+ @group(0) @binding(1)
18826
+ var output_texture: texture_storage_2d<rgba8unorm, write>;
18827
+
18828
+ // Bounding box passed in as a uniform (not atomic anymore)
18829
+ struct bounding_box_uniform {
18830
+ min_x: u32,
18831
+ min_y: u32,
18832
+ max_x: u32,
18833
+ max_y: u32,
18988
18834
  };
18989
- var sampler = {
18990
- label: "MSDF text sampler",
18991
- minFilter: "linear",
18992
- magFilter: "linear",
18993
- mipmapFilter: "linear",
18994
- maxAnisotropy: 16
18835
+
18836
+ @group(0) @binding(2)
18837
+ var<uniform> bounds_uniform: bounding_box_uniform;
18838
+
18839
+ // Struct to store both original and cropped texture dimensions
18840
+ struct image_dimensions {
18841
+ original_width: u32,
18842
+ original_height: u32,
18843
+ cropped_width: u32,
18844
+ cropped_height: u32,
18995
18845
  };
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" }
18846
+
18847
+ // Storage buffer to output the result dimensions
18848
+ @group(0) @binding(3)
18849
+ var<storage, read_write> dimensions_out: image_dimensions;
18850
+
18851
+ // Compute shader to crop the input texture to the bounding box and output it
18852
+ @compute @workgroup_size(8, 8)
18853
+ fn crop_and_output(@builtin(global_invocation_id) gid: vec3<u32>) {
18854
+ let size = textureDimensions(input_texture_crop).xy;
18855
+
18856
+ let crop_width = bounds_uniform.max_x - bounds_uniform.min_x + 1u;
18857
+ let crop_height = bounds_uniform.max_y - bounds_uniform.min_y + 1u;
18858
+
18859
+ if (gid.x >= crop_width || gid.y >= crop_height) {
18860
+ return;
19008
18861
  }
19009
- ]
19010
- };
19011
18862
 
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");
18863
+ let src_coord = vec2<i32>(
18864
+ i32(bounds_uniform.min_x + gid.x),
18865
+ i32(bounds_uniform.min_y + gid.y)
18866
+ );
18867
+
18868
+ let dst_coord = vec2<i32>(i32(gid.x), i32(gid.y));
18869
+ let pixel = textureLoad(input_texture_crop, src_coord, 0);
18870
+ textureStore(output_texture, dst_coord, pixel);
18871
+
18872
+ // Output dimensions from workgroup (0,0) only
18873
+ if (gid.x == 0u && gid.y == 0u) {
18874
+ dimensions_out.original_width = size.x;
18875
+ dimensions_out.original_height = size.y;
18876
+ dimensions_out.cropped_width = crop_width;
18877
+ dimensions_out.cropped_height = crop_height;
18878
+ }
19017
18879
  }
19018
- var textDescriptorInstanceSize = struct.size;
19019
18880
 
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
18881
+ // ==============================
18882
+ // === MISSING TEXTURE FILL ====
18883
+ // ==============================
18884
+
18885
+ // Output texture to draw a fallback checkerboard
18886
+ @group(0) @binding(0)
18887
+ var checker_texture: texture_storage_2d<rgba8unorm, write>;
18888
+
18889
+ // Compute shader to fill a texture with a purple & green checkerboard
18890
+ @compute @workgroup_size(8, 8)
18891
+ fn missing_texture(@builtin(global_invocation_id) id: vec3<u32>) {
18892
+ let size = textureDimensions(checker_texture);
18893
+ if (id.x >= size.x || id.y >= size.y) {
18894
+ return;
18895
+ }
18896
+
18897
+ let checker_size = 25u;
18898
+ let on_color = ((id.x / checker_size + id.y / checker_size) % 2u) == 0u;
18899
+
18900
+ let color = select(
18901
+ vec4<f32>(0.5, 0.0, 0.5, 1.0), // Purple
18902
+ vec4<f32>(0.0, 1.0, 0.0, 1.0), // Green
18903
+ on_color
18904
+ );
18905
+
18906
+ textureStore(checker_texture, vec2<i32>(id.xy), color);
18907
+ }
18908
+ `;
18909
+
18910
+ // src/backends/webgpu/TextureComputeShader.ts
18911
+ var BOUNDING_BOX_SIZE = 4 * Uint32Array.BYTES_PER_ELEMENT;
18912
+ var WORKGROUP_SIZE = 8;
18913
+ var MAX_BOUND = 4294967295;
18914
+ var MIN_BOUND = 0;
18915
+
18916
+ class TextureComputeShader {
18917
+ #device;
18918
+ #boundingBuffer;
18919
+ #cropPipeline;
18920
+ #boundPipeline;
18921
+ #missingTexturePipeline;
18922
+ constructor(device, cropPipeline, boundPipeline, missingTexturePipeline) {
18923
+ this.#device = device;
18924
+ this.#boundPipeline = boundPipeline;
18925
+ this.#cropPipeline = cropPipeline;
18926
+ this.#missingTexturePipeline = missingTexturePipeline;
18927
+ this.#boundingBuffer = this.#device.createBuffer({
18928
+ size: BOUNDING_BOX_SIZE,
18929
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST
18930
+ });
18931
+ }
18932
+ static create(device) {
18933
+ const pipelines = createPipelines(device, "TextureComputeShader");
18934
+ return new TextureComputeShader(device, pipelines.cropPipeline, pipelines.boundPipeline, pipelines.missingTexturePipeline);
18935
+ }
18936
+ async processTexture(textureWrapper) {
18937
+ const boundsBindGroup = this.#boundsBindGroup(textureWrapper.texture);
18938
+ const commandEncoder = this.#device.createCommandEncoder();
18939
+ const passEncoder = commandEncoder.beginComputePass();
18940
+ const dispatchX = Math.ceil(textureWrapper.texture.width / WORKGROUP_SIZE);
18941
+ const dispatchY = Math.ceil(textureWrapper.texture.height / WORKGROUP_SIZE);
18942
+ const boundsInit = new Uint32Array([
18943
+ MAX_BOUND,
18944
+ MAX_BOUND,
18945
+ MIN_BOUND,
18946
+ MIN_BOUND
18947
+ ]);
18948
+ this.#device.queue.writeBuffer(this.#boundingBuffer, 0, boundsInit.buffer, 0, BOUNDING_BOX_SIZE);
18949
+ passEncoder.setPipeline(this.#boundPipeline);
18950
+ passEncoder.setBindGroup(0, boundsBindGroup);
18951
+ passEncoder.dispatchWorkgroups(dispatchX, dispatchY);
18952
+ passEncoder.end();
18953
+ this.#device.queue.submit([commandEncoder.finish()]);
18954
+ const { texelX, texelY, texelWidth, texelHeight, computeBuffer } = await this.#getBoundingBox();
18955
+ if (texelX === MAX_BOUND || texelY === MAX_BOUND) {
18956
+ return await this.#createMissingTexture(textureWrapper.texture);
18957
+ }
18958
+ const croppedTexture = await this.#cropTexture(texelWidth, texelHeight, computeBuffer, textureWrapper.texture);
18959
+ const leftCrop = texelX;
18960
+ const rightCrop = textureWrapper.originalSize.width - texelX - texelWidth;
18961
+ const topCrop = texelY;
18962
+ const bottomCrop = textureWrapper.originalSize.height - texelY - texelHeight;
18963
+ textureWrapper = {
18964
+ texture: croppedTexture,
18965
+ cropOffset: { x: leftCrop - rightCrop, y: bottomCrop - topCrop },
18966
+ originalSize: textureWrapper.originalSize
18967
+ };
18968
+ return textureWrapper;
18969
+ }
18970
+ async#getBoundingBox() {
18971
+ const readBuffer = this.#device.createBuffer({
18972
+ label: "AABB Compute Buffer",
18973
+ size: BOUNDING_BOX_SIZE,
18974
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
19044
18975
  });
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,
18976
+ const copyEncoder = this.#device.createCommandEncoder();
18977
+ copyEncoder.copyBufferToBuffer(this.#boundingBuffer, 0, readBuffer, 0, BOUNDING_BOX_SIZE);
18978
+ this.#device.queue.submit([copyEncoder.finish()]);
18979
+ await readBuffer.mapAsync(GPUMapMode.READ);
18980
+ const computeBuffer = new Uint32Array(readBuffer.getMappedRange().slice(0));
18981
+ readBuffer.unmap();
18982
+ const [minX, minY, maxX, maxY] = computeBuffer;
18983
+ return {
18984
+ texelX: minX,
18985
+ texelY: minY,
18986
+ texelWidth: maxX - minX + 1,
18987
+ texelHeight: maxY - minY + 1,
18988
+ computeBuffer
18989
+ };
18990
+ }
18991
+ async#cropTexture(croppedWidth, croppedHeight, computeBuffer, inputTexture) {
18992
+ const boundsUniform = this.#device.createBuffer({
18993
+ label: "Cropping Bounds Uniform Buffer",
18994
+ size: BOUNDING_BOX_SIZE,
19050
18995
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
19051
18996
  });
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
18997
+ this.#device.queue.writeBuffer(boundsUniform, 0, computeBuffer);
18998
+ const outputTexture = this.#device.createTexture({
18999
+ label: "Cropped Texture",
19000
+ size: [croppedWidth, croppedHeight],
19001
+ format: "rgba8unorm",
19002
+ usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC
19056
19003
  });
19057
- this.#bindGroups.push(pipeline.fontBindGroup);
19058
- this.#bindGroups.push(device.createBindGroup({
19059
- label: "msdf text bind group",
19060
- layout: pipeline.pipeline.getBindGroupLayout(1),
19004
+ const dimensionsOutBuffer = this.#device.createBuffer({
19005
+ label: "Cropping Dimensions Output Buffer",
19006
+ size: BOUNDING_BOX_SIZE,
19007
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
19008
+ });
19009
+ const bindGroup = this.#croppingBindGroup(inputTexture, outputTexture, boundsUniform, dimensionsOutBuffer);
19010
+ const encoder = this.#device.createCommandEncoder();
19011
+ const pass = encoder.beginComputePass();
19012
+ pass.setPipeline(this.#cropPipeline);
19013
+ pass.setBindGroup(0, bindGroup);
19014
+ pass.dispatchWorkgroups(Math.ceil(croppedWidth / WORKGROUP_SIZE), Math.ceil(croppedHeight / WORKGROUP_SIZE));
19015
+ pass.end();
19016
+ this.#device.queue.submit([encoder.finish()]);
19017
+ return outputTexture;
19018
+ }
19019
+ async#createMissingTexture(inputTexture) {
19020
+ const placeholder = this.#device.createTexture({
19021
+ label: "Missing Placeholder Texture",
19022
+ size: [inputTexture.width, inputTexture.height],
19023
+ format: "rgba8unorm",
19024
+ usage: GPUTextureUsage.COPY_SRC | GPUTextureUsage.COPY_DST | GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING
19025
+ });
19026
+ const encoder = this.#device.createCommandEncoder();
19027
+ const pass = encoder.beginComputePass();
19028
+ pass.setPipeline(this.#missingTexturePipeline);
19029
+ pass.setBindGroup(0, this.#missingTextureBindGroup(placeholder));
19030
+ pass.dispatchWorkgroups(placeholder.width / 8, placeholder.height / 8);
19031
+ pass.end();
19032
+ this.#device.queue.submit([encoder.finish()]);
19033
+ return {
19034
+ texture: placeholder,
19035
+ cropOffset: { x: 0, y: 0 },
19036
+ originalSize: { width: inputTexture.width, height: inputTexture.height }
19037
+ };
19038
+ }
19039
+ #boundsBindGroup(inputTexture) {
19040
+ return this.#device.createBindGroup({
19041
+ layout: this.#boundPipeline.getBindGroupLayout(0),
19061
19042
  entries: [
19062
- {
19063
- binding: 0,
19064
- resource: { buffer: this.#descriptorBuffer }
19065
- },
19066
- {
19067
- binding: 1,
19068
- resource: { buffer: this.#textBlockBuffer }
19069
- }
19043
+ { binding: 0, resource: inputTexture.createView() },
19044
+ { binding: 1, resource: { buffer: this.#boundingBuffer } }
19070
19045
  ]
19071
- }));
19072
- const engineUniformsBindGroup = device.createBindGroup({
19073
- label: "msdf text uniforms bind group",
19074
- layout: pipeline.pipeline.getBindGroupLayout(2),
19046
+ });
19047
+ }
19048
+ #croppingBindGroup(inputTexture, outputTexture, boundsUniform, dimensionsOutBuffer) {
19049
+ return this.#device.createBindGroup({
19050
+ layout: this.#cropPipeline.getBindGroupLayout(0),
19075
19051
  entries: [
19076
- {
19077
- binding: 0,
19078
- resource: { buffer: this.#engineUniformsBuffer }
19079
- }
19052
+ { binding: 0, resource: inputTexture.createView() },
19053
+ { binding: 1, resource: outputTexture.createView() },
19054
+ { binding: 2, resource: { buffer: boundsUniform } },
19055
+ { binding: 3, resource: { buffer: dimensionsOutBuffer } }
19080
19056
  ]
19081
19057
  });
19082
- this.#bindGroups.push(engineUniformsBindGroup);
19083
19058
  }
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;
19059
+ #missingTextureBindGroup(outputTexture) {
19060
+ return this.#device.createBindGroup({
19061
+ layout: this.#missingTexturePipeline.getBindGroupLayout(0),
19062
+ entries: [{ binding: 0, resource: outputTexture.createView() }]
19063
+ });
19089
19064
  }
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}`);
19065
+ }
19066
+ function createPipelines(device, label) {
19067
+ const shader = device.createShaderModule({
19068
+ label: `${label} Shader`,
19069
+ code: pixel_scraping_wgsl_default
19070
+ });
19071
+ const findBoundsBindGroupLayout = device.createBindGroupLayout({
19072
+ label: "Bounds Detection Layout",
19073
+ entries: [
19074
+ {
19075
+ binding: 0,
19076
+ visibility: GPUShaderStage.COMPUTE,
19077
+ texture: { sampleType: "float", viewDimension: "2d" }
19078
+ },
19079
+ {
19080
+ binding: 1,
19081
+ visibility: GPUShaderStage.COMPUTE,
19082
+ buffer: { type: "storage" }
19102
19083
  }
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
- }
19084
+ ]
19085
+ });
19086
+ const cropBindGroupLayout = device.createBindGroupLayout({
19087
+ label: "Cropping Layout",
19088
+ entries: [
19089
+ { binding: 0, visibility: GPUShaderStage.COMPUTE, texture: {} },
19090
+ {
19091
+ binding: 1,
19092
+ visibility: GPUShaderStage.COMPUTE,
19093
+ storageTexture: {
19094
+ access: "write-only",
19095
+ format: "rgba8unorm",
19096
+ viewDimension: "2d"
19097
+ }
19098
+ },
19099
+ {
19100
+ binding: 2,
19101
+ visibility: GPUShaderStage.COMPUTE,
19102
+ buffer: { type: "uniform" }
19103
+ },
19104
+ {
19105
+ binding: 3,
19106
+ visibility: GPUShaderStage.COMPUTE,
19107
+ buffer: { type: "storage" }
19108
+ }
19109
+ ]
19110
+ });
19111
+ const missingTextureBindGroupLayout = device.createBindGroupLayout({
19112
+ label: "Missing Texture Layout",
19113
+ entries: [
19114
+ {
19115
+ binding: 0,
19116
+ visibility: GPUShaderStage.COMPUTE,
19117
+ storageTexture: {
19118
+ access: "write-only",
19119
+ format: "rgba8unorm",
19120
+ viewDimension: "2d"
19121
+ }
19122
+ }
19123
+ ]
19124
+ });
19125
+ return {
19126
+ boundPipeline: device.createComputePipeline({
19127
+ label: `${label} - Find Bounds Pipeline`,
19128
+ layout: device.createPipelineLayout({
19129
+ bindGroupLayouts: [findBoundsBindGroupLayout]
19130
+ }),
19131
+ compute: { module: shader, entryPoint: "find_bounds" }
19132
+ }),
19133
+ cropPipeline: device.createComputePipeline({
19134
+ label: `${label} - Crop Pipeline`,
19135
+ layout: device.createPipelineLayout({
19136
+ bindGroupLayouts: [cropBindGroupLayout]
19137
+ }),
19138
+ compute: { module: shader, entryPoint: "crop_and_output" }
19139
+ }),
19140
+ missingTexturePipeline: device.createComputePipeline({
19141
+ label: `${label} - Missing Texture Pipeline`,
19142
+ layout: device.createPipelineLayout({
19143
+ bindGroupLayouts: [missingTextureBindGroupLayout]
19144
+ }),
19145
+ compute: { module: shader, entryPoint: "missing_texture" }
19146
+ })
19147
+ };
19133
19148
  }
19134
19149
 
19135
19150
  // src/textures/Bundles.ts
@@ -19697,18 +19712,20 @@ class AssetManager {
19697
19712
  this.bundles.unloadBundle(bundleId);
19698
19713
  }
19699
19714
  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
19715
  const limits = this.#backend.limits;
19707
19716
  const font = await MsdfFont.create(id, url);
19708
19717
  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);
19718
+ if (this.#backend.type === "webgpu") {
19719
+ const webgpuBackend = this.#backend;
19720
+ const device = webgpuBackend.device;
19721
+ const presentationFormat = webgpuBackend.presentationFormat;
19722
+ const fontPipeline = await FontPipeline.create(device, font, presentationFormat, limits.maxTextLength);
19723
+ const textShader = new WebGPUTextShader(webgpuBackend, fontPipeline, font, presentationFormat, limits.instanceCount);
19724
+ this.#fonts.set(id, textShader);
19725
+ } else {
19726
+ const textShader = new WebGLTextShader(font, limits.maxTextLength);
19727
+ this.#fonts.set(id, textShader);
19728
+ }
19712
19729
  return id;
19713
19730
  }
19714
19731
  getFont(id) {
@@ -20394,8 +20411,10 @@ __export(exports_mod4, {
20394
20411
  // src/scene/mod.ts
20395
20412
  var exports_mod5 = {};
20396
20413
  __export(exports_mod5, {
20414
+ TextNode: () => TextNode,
20397
20415
  SceneNode: () => SceneNode,
20398
20416
  QuadNode: () => QuadNode,
20417
+ DEFAULT_FONT_SIZE: () => DEFAULT_FONT_SIZE,
20399
20418
  Camera: () => Camera
20400
20419
  });
20401
20420
  // src/screen/mod.ts
@@ -20403,8 +20422,7 @@ var exports_mod6 = {};
20403
20422
  // src/text/mod.ts
20404
20423
  var exports_mod7 = {};
20405
20424
  __export(exports_mod7, {
20406
- TextShader: () => TextShader,
20407
- TextNode: () => TextNode
20425
+ TextShader: () => WebGPUTextShader
20408
20426
  });
20409
20427
  // src/textures/mod.ts
20410
20428
  var exports_mod8 = {};
@@ -20426,5 +20444,5 @@ export {
20426
20444
  AssetManager
20427
20445
  };
20428
20446
 
20429
- //# debugId=1537C686BF0E9F8164756E2164756E21
20447
+ //# debugId=7918C9E3F46FD35864756E2164756E21
20430
20448
  //# sourceMappingURL=mod.js.map