@vectojs/three 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # @vectojs/three
2
+
3
+ > Put a live VectoJS 2D interface into Three.js/WebXR and route 3D pointer input back to the canvas.
4
+
5
+ [![npm](https://img.shields.io/npm/v/@vectojs/three?color=22d3ee)](https://www.npmjs.com/package/@vectojs/three)
6
+ [![CI](https://github.com/vectojs/vectojs/actions/workflows/ci.yml/badge.svg)](https://github.com/vectojs/vectojs/actions/workflows/ci.yml)
7
+ [![MIT](https://img.shields.io/badge/license-MIT-6366f1.svg)](https://github.com/vectojs/vectojs/blob/main/LICENSE)
8
+
9
+ `@vectojs/three` renders a VectoJS `Scene` into a canvas-backed Three.js texture. The adapter maps
10
+ raycast UV coordinates into the Scene's logical coordinate space, forwards pointer/hover/wheel
11
+ events, and keeps texture uploads synchronized with VectoJS rendering.
12
+
13
+ [Live 3D demo](https://vectojs.xuepoo.xyz/demos/dimension/) ·
14
+ [Reference](https://vectojs.xuepoo.xyz/reference/three/) ·
15
+ [Main repository](https://github.com/vectojs/vectojs)
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ bun add @vectojs/core @vectojs/ui @vectojs/three three
21
+ ```
22
+
23
+ `@vectojs/core` and `three` are peer dependencies. `@vectojs/ui` is used by the example and is
24
+ optional when you supply your own core entities.
25
+
26
+ ## Basic usage
27
+
28
+ ```ts
29
+ import { Button, Stack, Text } from '@vectojs/ui';
30
+ import { ThreeAdapter } from '@vectojs/three';
31
+ import * as THREE from 'three';
32
+
33
+ const scene3d = new THREE.Scene();
34
+ const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
35
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
36
+
37
+ const adapter = new ThreeAdapter({
38
+ width: 800,
39
+ height: 500,
40
+ });
41
+
42
+ const panel = new Stack({ direction: 'vertical', gap: 16 });
43
+ panel.setPosition(36, 36);
44
+ panel.add(new Text('VectoJS in 3D', { font: '700 28px Inter' }));
45
+ panel.add(new Button('Select', { onClick: () => console.log('selected') }));
46
+ adapter.vectoScene.add(panel);
47
+ adapter.vectoScene.start();
48
+
49
+ // Use the adapter's texture/material with a mesh in your Three.js scene.
50
+ scene3d.add(adapter.mesh);
51
+ ```
52
+
53
+ See the [reference](https://vectojs.xuepoo.xyz/reference/three/) for the exact constructor and
54
+ mesh/material customization supported by the installed version.
55
+
56
+ ## Coordinate and event model
57
+
58
+ - VectoJS layout uses the logical `width`/`height` passed to the adapter.
59
+ - The backing canvas may be larger on HiDPI displays; raycast UVs are still mapped to logical space.
60
+ - Pointer intersections are translated into VectoJS events and routed through its normal hit-test,
61
+ capture, target, and bubble phases.
62
+ - Hover state is tracked per pointer, including pointer leave boundaries.
63
+ - WebXR controllers can use the same raycast-to-2D route when supplied by the host application.
64
+
65
+ The adapter does not own the application's Three.js render loop, camera, controls, or raycaster.
66
+
67
+ ## Texture synchronization
68
+
69
+ The adapter wraps the VectoJS Scene render path so `texture.needsUpdate` is set after a dirty frame.
70
+ On-demand VectoJS scenes therefore upload only when their visual state changes; the Three.js host
71
+ still decides when to render its own frame.
72
+
73
+ ## Lifecycle
74
+
75
+ ```ts
76
+ adapter.dispose();
77
+ ```
78
+
79
+ `dispose()` restores the Scene render hook, releases adapter-owned Three.js resources, destroys the
80
+ inner VectoJS Scene, and detaches event state. Call it when removing the panel or unmounting the host
81
+ component.
82
+
83
+ ## Constraints
84
+
85
+ - The default output is a flat textured plane; it is not DOM rendered in 3D.
86
+ - Canvas clipping and logical hit-testing remain 2D even when the mesh is rotated in world space.
87
+ - The host must provide correct raycast intersections and account for occlusion.
88
+ - Texture resolution affects sharpness and upload cost. Choose logical size and DPR for the target
89
+ viewing distance rather than always maximizing both.
90
+ - Three.js releases outside the declared peer range are not guaranteed.
91
+
92
+ ## License
93
+
94
+ [MIT](https://github.com/vectojs/vectojs/blob/main/LICENSE) © 2026 Xuepoo
@@ -51,13 +51,45 @@ export declare class ThreeRenderer implements IRenderer {
51
51
  closePath(): void;
52
52
  arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, counterclockwise?: boolean): void;
53
53
  roundRect(x: number, y: number, width: number, height: number, radii: number | number[]): void;
54
+ /**
55
+ * Textures cached per drawImage source (identity-keyed). A frame that
56
+ * redraws the same image no longer re-uploads it. Mutable sources (a canvas
57
+ * you repaint) must call {@link invalidateImage} after changing pixels.
58
+ * Entries are skipped by the per-frame disposer and released on
59
+ * invalidation or {@link dispose}.
60
+ */
61
+ private imageTextureCache;
62
+ /** Drop the cached texture for a source whose pixels changed. */
63
+ invalidateImage(source: CanvasImageSource): void;
54
64
  drawImage(source: CanvasImageSource, dx: number, dy: number, dw: number, dh: number): void;
55
65
  fill(colorOrGradient: string | any): void;
56
66
  private fillSolidShape;
57
67
  stroke(colorOrGradient: string | any, lineWidth?: number): void;
68
+ /**
69
+ * Rasterized fillText textures keyed by `font|color|text`, LRU-evicted.
70
+ * HUD-style UIs redraw the same labels every frame; without the cache each
71
+ * call allocated a canvas, rasterized it, and uploaded a fresh GPU texture.
72
+ * Entries are skipped by {@link disposeActiveObjects} (flagged via
73
+ * `userData.vectoCached`) and released on eviction or {@link dispose}.
74
+ */
75
+ private textTextureCache;
76
+ private textTextureCacheLimit;
58
77
  fillText(text: string, x: number, y: number, font: string, color: string | any): void;
59
78
  fillCircle(cx: number, cy: number, radius: number, color: string, alpha?: number): void;
79
+ private frameDirty;
80
+ private presentScheduled;
81
+ /**
82
+ * The Scene calls flush() around every non-batched node (it commits the
83
+ * Canvas2D circle batch there). Rendering the whole Three scene on each of
84
+ * those calls made a frame cost O(N²) in entity count, so flush() only marks
85
+ * the frame dirty; the actual GL render happens once, in {@link present}.
86
+ *
87
+ * A microtask fallback keeps older `@vectojs/core` Scenes (that never call
88
+ * `present()`) painting: the many same-tick flushes coalesce into one render.
89
+ */
60
90
  flush(): void;
91
+ /** Render the accumulated frame once. Called by Scene at the end of each render pass. */
92
+ present(): void;
61
93
  createLinearGradient(x0: number, y0: number, x1: number, y1: number, colorStops: {
62
94
  stop: number;
63
95
  color: string;
package/dist/index.js CHANGED
@@ -161,7 +161,7 @@ var ThreeRenderer = class {
161
161
  const materials = Array.isArray(object.material) ? object.material : [object.material];
162
162
  for (const material of materials) {
163
163
  const map = material.map;
164
- if (map && !disposedTextures.has(map)) {
164
+ if (map && !map.userData.vectoCached && !disposedTextures.has(map)) {
165
165
  disposedTextures.add(map);
166
166
  map.dispose();
167
167
  }
@@ -218,7 +218,7 @@ var ThreeRenderer = class {
218
218
  const minY = Math.min(...corners.map((point) => point.y));
219
219
  const maxX = Math.max(...corners.map((point) => point.x));
220
220
  const maxY = Math.max(...corners.map((point) => point.y));
221
- const dpr = window.devicePixelRatio || 1;
221
+ const dpr = this.renderer.getPixelRatio() || 1;
222
222
  const canvasHeight = this.renderer.domElement.height / dpr;
223
223
  let scissorX = minX * dpr;
224
224
  let scissorY = (canvasHeight - maxY) * dpr;
@@ -310,15 +310,35 @@ var ThreeRenderer = class {
310
310
  this.currentPath.lineTo(x, y + r_tl);
311
311
  this.currentPath.arc(x + r_tl, y + r_tl, r_tl, Math.PI, -Math.PI / 2, false);
312
312
  }
313
+ /**
314
+ * Textures cached per drawImage source (identity-keyed). A frame that
315
+ * redraws the same image no longer re-uploads it. Mutable sources (a canvas
316
+ * you repaint) must call {@link invalidateImage} after changing pixels.
317
+ * Entries are skipped by the per-frame disposer and released on
318
+ * invalidation or {@link dispose}.
319
+ */
320
+ imageTextureCache = /* @__PURE__ */ new Map();
321
+ /** Drop the cached texture for a source whose pixels changed. */
322
+ invalidateImage(source) {
323
+ const cached = this.imageTextureCache.get(source);
324
+ if (cached) {
325
+ cached.dispose();
326
+ this.imageTextureCache.delete(source);
327
+ }
328
+ }
313
329
  drawImage(source, dx, dy, dw, dh) {
314
- let texture;
315
- if (source instanceof HTMLImageElement || source instanceof HTMLCanvasElement || typeof ImageBitmap !== "undefined" && source instanceof ImageBitmap) {
316
- texture = new THREE.CanvasTexture(source);
317
- } else {
318
- texture = new THREE.Texture(source);
319
- texture.needsUpdate = true;
330
+ let texture = this.imageTextureCache.get(source);
331
+ if (!texture) {
332
+ if (source instanceof HTMLImageElement || source instanceof HTMLCanvasElement || typeof ImageBitmap !== "undefined" && source instanceof ImageBitmap) {
333
+ texture = new THREE.CanvasTexture(source);
334
+ } else {
335
+ texture = new THREE.Texture(source);
336
+ texture.needsUpdate = true;
337
+ }
338
+ texture.minFilter = THREE.LinearFilter;
339
+ texture.userData.vectoCached = true;
340
+ this.imageTextureCache.set(source, texture);
320
341
  }
321
- texture.minFilter = THREE.LinearFilter;
322
342
  const material = new THREE.MeshBasicMaterial({
323
343
  map: texture,
324
344
  transparent: true,
@@ -481,33 +501,35 @@ var ThreeRenderer = class {
481
501
  color = grad.colorStops[0].color;
482
502
  }
483
503
  }
484
- const points = this.currentPath.getPoints();
485
- if (points.length === 0) return;
486
- const geometry = new THREE.BufferGeometry().setFromPoints(points);
487
504
  const parsed = parseThreeColor(color);
488
505
  const opacity = this.globalAlpha * parsed.alpha;
489
- const material = new THREE.LineBasicMaterial({
490
- color: parsed.color,
491
- transparent: opacity < 1,
492
- opacity,
493
- linewidth: lineWidth
494
- });
495
- const line = new THREE.Line(geometry, material);
496
- line.applyMatrix4(this.matrix);
497
- this.scene.add(line);
498
- this.activeObjects.push(line);
506
+ for (const shape of this.currentPath.toShapes()) {
507
+ const points = shape.getPoints();
508
+ if (points.length < 2) continue;
509
+ const geometry = new THREE.BufferGeometry().setFromPoints(points);
510
+ const material = new THREE.LineBasicMaterial({
511
+ color: parsed.color,
512
+ transparent: opacity < 1,
513
+ opacity,
514
+ linewidth: lineWidth
515
+ });
516
+ const line = new THREE.Line(geometry, material);
517
+ line.applyMatrix4(this.matrix);
518
+ this.scene.add(line);
519
+ this.activeObjects.push(line);
520
+ }
499
521
  }
522
+ /**
523
+ * Rasterized fillText textures keyed by `font|color|text`, LRU-evicted.
524
+ * HUD-style UIs redraw the same labels every frame; without the cache each
525
+ * call allocated a canvas, rasterized it, and uploaded a fresh GPU texture.
526
+ * Entries are skipped by {@link disposeActiveObjects} (flagged via
527
+ * `userData.vectoCached`) and released on eviction or {@link dispose}.
528
+ */
529
+ textTextureCache = /* @__PURE__ */ new Map();
530
+ textTextureCacheLimit = 256;
500
531
  fillText(text, x, y, font, color) {
501
532
  if (typeof document === "undefined") return;
502
- const canvas = document.createElement("canvas");
503
- const ctx = canvas.getContext("2d");
504
- ctx.font = font;
505
- const width = Math.max(1, Math.ceil(ctx.measureText(text).width));
506
- const fontSize = parseInt(font) || 16;
507
- const height = Math.max(1, Math.ceil(fontSize * 1.5));
508
- canvas.width = width;
509
- canvas.height = height;
510
- ctx.font = font;
511
533
  let fillCol = "#ffffff";
512
534
  if (typeof color === "string") {
513
535
  fillCol = color;
@@ -517,11 +539,36 @@ var ThreeRenderer = class {
517
539
  fillCol = grad.colorStops[0].color;
518
540
  }
519
541
  }
520
- ctx.fillStyle = fillCol;
521
- ctx.textBaseline = "alphabetic";
522
- ctx.fillText(text, 0, fontSize);
523
- const texture = new THREE.CanvasTexture(canvas);
524
- texture.minFilter = THREE.LinearFilter;
542
+ const cacheKey = `${font}|${fillCol}|${text}`;
543
+ let entry = this.textTextureCache.get(cacheKey);
544
+ if (entry) {
545
+ this.textTextureCache.delete(cacheKey);
546
+ this.textTextureCache.set(cacheKey, entry);
547
+ } else {
548
+ const canvas = document.createElement("canvas");
549
+ const ctx = canvas.getContext("2d");
550
+ ctx.font = font;
551
+ const width2 = Math.max(1, Math.ceil(ctx.measureText(text).width));
552
+ const fontSize2 = parseInt(font) || 16;
553
+ const height2 = Math.max(1, Math.ceil(fontSize2 * 1.5));
554
+ canvas.width = width2;
555
+ canvas.height = height2;
556
+ ctx.font = font;
557
+ ctx.fillStyle = fillCol;
558
+ ctx.textBaseline = "alphabetic";
559
+ ctx.fillText(text, 0, fontSize2);
560
+ const texture2 = new THREE.CanvasTexture(canvas);
561
+ texture2.minFilter = THREE.LinearFilter;
562
+ texture2.userData.vectoCached = true;
563
+ entry = { texture: texture2, width: width2, height: height2, fontSize: fontSize2 };
564
+ this.textTextureCache.set(cacheKey, entry);
565
+ while (this.textTextureCache.size > Math.max(1, this.textTextureCacheLimit)) {
566
+ const oldestKey = this.textTextureCache.keys().next().value;
567
+ this.textTextureCache.get(oldestKey).texture.dispose();
568
+ this.textTextureCache.delete(oldestKey);
569
+ }
570
+ }
571
+ const { texture, width, height, fontSize } = entry;
525
572
  const material = new THREE.MeshBasicMaterial({
526
573
  map: texture,
527
574
  transparent: true,
@@ -551,7 +598,30 @@ var ThreeRenderer = class {
551
598
  this.scene.add(mesh);
552
599
  this.activeObjects.push(mesh);
553
600
  }
601
+ frameDirty = false;
602
+ presentScheduled = false;
603
+ /**
604
+ * The Scene calls flush() around every non-batched node (it commits the
605
+ * Canvas2D circle batch there). Rendering the whole Three scene on each of
606
+ * those calls made a frame cost O(N²) in entity count, so flush() only marks
607
+ * the frame dirty; the actual GL render happens once, in {@link present}.
608
+ *
609
+ * A microtask fallback keeps older `@vectojs/core` Scenes (that never call
610
+ * `present()`) painting: the many same-tick flushes coalesce into one render.
611
+ */
554
612
  flush() {
613
+ this.frameDirty = true;
614
+ if (this.presentScheduled) return;
615
+ this.presentScheduled = true;
616
+ queueMicrotask(() => {
617
+ this.presentScheduled = false;
618
+ if (this.frameDirty && !this.disposed) this.present();
619
+ });
620
+ }
621
+ /** Render the accumulated frame once. Called by Scene at the end of each render pass. */
622
+ present() {
623
+ if (this.disposed) return;
624
+ this.frameDirty = false;
555
625
  this.renderer.render(this.scene, this.camera);
556
626
  }
557
627
  createLinearGradient(x0, y0, x1, y1, colorStops) {
@@ -562,6 +632,14 @@ var ThreeRenderer = class {
562
632
  if (this.disposed) return;
563
633
  this.disposed = true;
564
634
  this.disposeActiveObjects();
635
+ for (const entry of this.textTextureCache.values()) {
636
+ entry.texture.dispose();
637
+ }
638
+ this.textTextureCache.clear();
639
+ for (const texture of this.imageTextureCache.values()) {
640
+ texture.dispose();
641
+ }
642
+ this.imageTextureCache.clear();
565
643
  this.currentPath = null;
566
644
  this.stack.length = 0;
567
645
  this.alphaStack.length = 0;
package/dist/index.mjs CHANGED
@@ -123,7 +123,7 @@ var ThreeRenderer = class {
123
123
  const materials = Array.isArray(object.material) ? object.material : [object.material];
124
124
  for (const material of materials) {
125
125
  const map = material.map;
126
- if (map && !disposedTextures.has(map)) {
126
+ if (map && !map.userData.vectoCached && !disposedTextures.has(map)) {
127
127
  disposedTextures.add(map);
128
128
  map.dispose();
129
129
  }
@@ -180,7 +180,7 @@ var ThreeRenderer = class {
180
180
  const minY = Math.min(...corners.map((point) => point.y));
181
181
  const maxX = Math.max(...corners.map((point) => point.x));
182
182
  const maxY = Math.max(...corners.map((point) => point.y));
183
- const dpr = window.devicePixelRatio || 1;
183
+ const dpr = this.renderer.getPixelRatio() || 1;
184
184
  const canvasHeight = this.renderer.domElement.height / dpr;
185
185
  let scissorX = minX * dpr;
186
186
  let scissorY = (canvasHeight - maxY) * dpr;
@@ -272,15 +272,35 @@ var ThreeRenderer = class {
272
272
  this.currentPath.lineTo(x, y + r_tl);
273
273
  this.currentPath.arc(x + r_tl, y + r_tl, r_tl, Math.PI, -Math.PI / 2, false);
274
274
  }
275
+ /**
276
+ * Textures cached per drawImage source (identity-keyed). A frame that
277
+ * redraws the same image no longer re-uploads it. Mutable sources (a canvas
278
+ * you repaint) must call {@link invalidateImage} after changing pixels.
279
+ * Entries are skipped by the per-frame disposer and released on
280
+ * invalidation or {@link dispose}.
281
+ */
282
+ imageTextureCache = /* @__PURE__ */ new Map();
283
+ /** Drop the cached texture for a source whose pixels changed. */
284
+ invalidateImage(source) {
285
+ const cached = this.imageTextureCache.get(source);
286
+ if (cached) {
287
+ cached.dispose();
288
+ this.imageTextureCache.delete(source);
289
+ }
290
+ }
275
291
  drawImage(source, dx, dy, dw, dh) {
276
- let texture;
277
- if (source instanceof HTMLImageElement || source instanceof HTMLCanvasElement || typeof ImageBitmap !== "undefined" && source instanceof ImageBitmap) {
278
- texture = new THREE.CanvasTexture(source);
279
- } else {
280
- texture = new THREE.Texture(source);
281
- texture.needsUpdate = true;
292
+ let texture = this.imageTextureCache.get(source);
293
+ if (!texture) {
294
+ if (source instanceof HTMLImageElement || source instanceof HTMLCanvasElement || typeof ImageBitmap !== "undefined" && source instanceof ImageBitmap) {
295
+ texture = new THREE.CanvasTexture(source);
296
+ } else {
297
+ texture = new THREE.Texture(source);
298
+ texture.needsUpdate = true;
299
+ }
300
+ texture.minFilter = THREE.LinearFilter;
301
+ texture.userData.vectoCached = true;
302
+ this.imageTextureCache.set(source, texture);
282
303
  }
283
- texture.minFilter = THREE.LinearFilter;
284
304
  const material = new THREE.MeshBasicMaterial({
285
305
  map: texture,
286
306
  transparent: true,
@@ -443,33 +463,35 @@ var ThreeRenderer = class {
443
463
  color = grad.colorStops[0].color;
444
464
  }
445
465
  }
446
- const points = this.currentPath.getPoints();
447
- if (points.length === 0) return;
448
- const geometry = new THREE.BufferGeometry().setFromPoints(points);
449
466
  const parsed = parseThreeColor(color);
450
467
  const opacity = this.globalAlpha * parsed.alpha;
451
- const material = new THREE.LineBasicMaterial({
452
- color: parsed.color,
453
- transparent: opacity < 1,
454
- opacity,
455
- linewidth: lineWidth
456
- });
457
- const line = new THREE.Line(geometry, material);
458
- line.applyMatrix4(this.matrix);
459
- this.scene.add(line);
460
- this.activeObjects.push(line);
468
+ for (const shape of this.currentPath.toShapes()) {
469
+ const points = shape.getPoints();
470
+ if (points.length < 2) continue;
471
+ const geometry = new THREE.BufferGeometry().setFromPoints(points);
472
+ const material = new THREE.LineBasicMaterial({
473
+ color: parsed.color,
474
+ transparent: opacity < 1,
475
+ opacity,
476
+ linewidth: lineWidth
477
+ });
478
+ const line = new THREE.Line(geometry, material);
479
+ line.applyMatrix4(this.matrix);
480
+ this.scene.add(line);
481
+ this.activeObjects.push(line);
482
+ }
461
483
  }
484
+ /**
485
+ * Rasterized fillText textures keyed by `font|color|text`, LRU-evicted.
486
+ * HUD-style UIs redraw the same labels every frame; without the cache each
487
+ * call allocated a canvas, rasterized it, and uploaded a fresh GPU texture.
488
+ * Entries are skipped by {@link disposeActiveObjects} (flagged via
489
+ * `userData.vectoCached`) and released on eviction or {@link dispose}.
490
+ */
491
+ textTextureCache = /* @__PURE__ */ new Map();
492
+ textTextureCacheLimit = 256;
462
493
  fillText(text, x, y, font, color) {
463
494
  if (typeof document === "undefined") return;
464
- const canvas = document.createElement("canvas");
465
- const ctx = canvas.getContext("2d");
466
- ctx.font = font;
467
- const width = Math.max(1, Math.ceil(ctx.measureText(text).width));
468
- const fontSize = parseInt(font) || 16;
469
- const height = Math.max(1, Math.ceil(fontSize * 1.5));
470
- canvas.width = width;
471
- canvas.height = height;
472
- ctx.font = font;
473
495
  let fillCol = "#ffffff";
474
496
  if (typeof color === "string") {
475
497
  fillCol = color;
@@ -479,11 +501,36 @@ var ThreeRenderer = class {
479
501
  fillCol = grad.colorStops[0].color;
480
502
  }
481
503
  }
482
- ctx.fillStyle = fillCol;
483
- ctx.textBaseline = "alphabetic";
484
- ctx.fillText(text, 0, fontSize);
485
- const texture = new THREE.CanvasTexture(canvas);
486
- texture.minFilter = THREE.LinearFilter;
504
+ const cacheKey = `${font}|${fillCol}|${text}`;
505
+ let entry = this.textTextureCache.get(cacheKey);
506
+ if (entry) {
507
+ this.textTextureCache.delete(cacheKey);
508
+ this.textTextureCache.set(cacheKey, entry);
509
+ } else {
510
+ const canvas = document.createElement("canvas");
511
+ const ctx = canvas.getContext("2d");
512
+ ctx.font = font;
513
+ const width2 = Math.max(1, Math.ceil(ctx.measureText(text).width));
514
+ const fontSize2 = parseInt(font) || 16;
515
+ const height2 = Math.max(1, Math.ceil(fontSize2 * 1.5));
516
+ canvas.width = width2;
517
+ canvas.height = height2;
518
+ ctx.font = font;
519
+ ctx.fillStyle = fillCol;
520
+ ctx.textBaseline = "alphabetic";
521
+ ctx.fillText(text, 0, fontSize2);
522
+ const texture2 = new THREE.CanvasTexture(canvas);
523
+ texture2.minFilter = THREE.LinearFilter;
524
+ texture2.userData.vectoCached = true;
525
+ entry = { texture: texture2, width: width2, height: height2, fontSize: fontSize2 };
526
+ this.textTextureCache.set(cacheKey, entry);
527
+ while (this.textTextureCache.size > Math.max(1, this.textTextureCacheLimit)) {
528
+ const oldestKey = this.textTextureCache.keys().next().value;
529
+ this.textTextureCache.get(oldestKey).texture.dispose();
530
+ this.textTextureCache.delete(oldestKey);
531
+ }
532
+ }
533
+ const { texture, width, height, fontSize } = entry;
487
534
  const material = new THREE.MeshBasicMaterial({
488
535
  map: texture,
489
536
  transparent: true,
@@ -513,7 +560,30 @@ var ThreeRenderer = class {
513
560
  this.scene.add(mesh);
514
561
  this.activeObjects.push(mesh);
515
562
  }
563
+ frameDirty = false;
564
+ presentScheduled = false;
565
+ /**
566
+ * The Scene calls flush() around every non-batched node (it commits the
567
+ * Canvas2D circle batch there). Rendering the whole Three scene on each of
568
+ * those calls made a frame cost O(N²) in entity count, so flush() only marks
569
+ * the frame dirty; the actual GL render happens once, in {@link present}.
570
+ *
571
+ * A microtask fallback keeps older `@vectojs/core` Scenes (that never call
572
+ * `present()`) painting: the many same-tick flushes coalesce into one render.
573
+ */
516
574
  flush() {
575
+ this.frameDirty = true;
576
+ if (this.presentScheduled) return;
577
+ this.presentScheduled = true;
578
+ queueMicrotask(() => {
579
+ this.presentScheduled = false;
580
+ if (this.frameDirty && !this.disposed) this.present();
581
+ });
582
+ }
583
+ /** Render the accumulated frame once. Called by Scene at the end of each render pass. */
584
+ present() {
585
+ if (this.disposed) return;
586
+ this.frameDirty = false;
517
587
  this.renderer.render(this.scene, this.camera);
518
588
  }
519
589
  createLinearGradient(x0, y0, x1, y1, colorStops) {
@@ -524,6 +594,14 @@ var ThreeRenderer = class {
524
594
  if (this.disposed) return;
525
595
  this.disposed = true;
526
596
  this.disposeActiveObjects();
597
+ for (const entry of this.textTextureCache.values()) {
598
+ entry.texture.dispose();
599
+ }
600
+ this.textTextureCache.clear();
601
+ for (const texture of this.imageTextureCache.values()) {
602
+ texture.dispose();
603
+ }
604
+ this.imageTextureCache.clear();
527
605
  this.currentPath = null;
528
606
  this.stack.length = 0;
529
607
  this.alphaStack.length = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectojs/three",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },