@driftengine/core 4.2.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.json CHANGED
@@ -1,4 +1,26 @@
1
1
  [
2
+ {
3
+ "version": "4.3.0",
4
+ "date": "2026-09-21",
5
+ "entries": [
6
+ {
7
+ "kind": "added",
8
+ "short": "RendererApi.snapTextCellSize, so a caller can draw a bitmap glyph on whole device pixels and measure the size it will actually get.",
9
+ "text": "**The snap 4.1.4 imposed and 4.2.0 made opt-in, now reachable from where text is actually laid out.**\n\n`deviceSnappedCellSize` has been exported since 4.2.0, but it takes the ratio as two widths and the second of them is the renderer's own drawing buffer. `RendererApi` carries `cssWidth` and `cssHeight` and nothing about device pixels, and a game's text layout is a pure function over CSS pixels — so a consumer that wanted whole-pixel strokes had to thread a canvas down through its whole interface layer to reach a number the renderer was already holding. Every consumer on this engine sizes text that way, so the export alone was not adoptable.\n\n```js\nconst cell = renderer.snapTextCellSize(available / cells, viewportWidth);\nconst width = textWidthPx(label, cell);\nconst x = (boxWidth - width) / 2;\nrenderer.drawText(text, viewportWidth, viewportHeight, x, y, { ...style, cellSize: cell }, now);\n```\n\n`viewportWidth` is the caller's own and it already passes it to `drawText`. Snap once on the line the cell is computed, and the width, the centring, the right edge and the row pitch all follow from the one number — the measurement and the picture cannot disagree, which is the property the imposed version could never have.\n\nThe decision stays in `textLayout.ts` with only the binding per backend: the cell drawn is the largest whole number of device pixels not larger than the one asked for, downward so a line fitted to a box can only leave a gap rather than overflow it, and a cell already whole comes back untouched. **What it gives up** is that a caller who does not ask still gets strokes of two widths on a fractional cell, which is the trade 4.2.0 chose deliberately.\n\nPinned on the shared surface by a test rather than left to the `Omit` derivation, for the reason `api.test.ts` records about the resolution governor: a public member is otherwise on the surface by accident of not being in the omit list, which is a poor way to make a promise."
10
+ }
11
+ ]
12
+ },
13
+ {
14
+ "version": "4.2.1",
15
+ "date": "2026-09-21",
16
+ "entries": [
17
+ {
18
+ "kind": "fixed",
19
+ "short": "A physics worker pool survives a slow start. Its first adoption was racing the time its own workers take to load, and losing on small or cold machines.",
20
+ "text": "**Reported from continuous integration, intermittently, as \"the workers did not take the shared buffer, so island solving stayed on this thread.\"** The world stays correct when that happens, because the pool falls back to a serial solve, so what it costs is the capability rather than the picture. That is why it survived: nothing looked wrong, physics was just quietly single-threaded.\n\n`createIslandPool` spawns its workers and returns with no readiness handshake, so the *first* `reload` of a pool is also paying for the worker entry to load: a module graph, resolved and type-stripped, once per worker. It was measured against the deadline meant for a running worker that has stopped answering, and those are different questions.\n\n**Measured** on an idle twenty-four core machine with a warm module cache: the first adoption of each pool takes **45 to 77 ms** and every later one **0.3 to 0.8 ms**. So the 250 ms was already spending up to a third of itself on startup under the best conditions available, and a cold two-core machine goes over it.\n\nThe first adoption now has its own five second budget, because a boot that slow is still a working pool while the alternative is serial physics for the life of the world. The deadline that means \"a running worker stopped answering\" is untouched. The wait also parks on the acknowledgement rather than spinning for it, which a budget that long makes load-bearing rather than tidy: burning a core for five seconds would slow the very startup being waited for, and the worker has always answered with `Atomics.notify` on that slot.\n\n**Two earlier attempts blamed core contention and are recorded in the test file beside this one**, because neither reproduced anything: a hot spin does not starve a worker on a preemptive scheduler, which is what made a startup cost look like a scheduling one twice running. This one is proven by reproduction: forcing the start deadline below the measured boot reproduces both failing cases and the identical message, and restoring it turns them green.\n\nA consumer on a phone or a modest laptop is the one this was costing, which is where a worker pool is worth the most."
21
+ }
22
+ ]
23
+ },
2
24
  {
3
25
  "version": "4.2.0",
4
26
  "date": "2026-09-21",
@@ -1839,6 +1839,25 @@ export declare class WebGL2Renderer implements RendererApi {
1839
1839
  * whatever box the caller lays out in, and the game's own card is not always the canvas.
1840
1840
  */
1841
1841
  drawText(text: TextRenderer, viewportWidth: number, viewportHeight: number, originX: number, originY: number, style: TextStyle, timeSec: number): void;
1842
+ /**
1843
+ * The cell size to draw a bitmap glyph at so every cell covers whole device pixels.
1844
+ *
1845
+ * **The caller snaps, and then measures and draws with the one number.** 4.1.4 applied this
1846
+ * inside `drawText` instead, where `textWidthPx` could not see it — it takes no viewport and so
1847
+ * cannot know the ratio — and every consumer went on laying out against the cell it asked for
1848
+ * while the engine drew a smaller one. A centred line, a right-aligned column and a line fitted
1849
+ * to a box broke together. Snapped here, the arithmetic and the picture cannot disagree.
1850
+ *
1851
+ * **It is on the renderer because only the renderer knows the second number.** `viewportWidth`
1852
+ * is the caller's, and it already passes it to `drawText`; the drawing buffer is not on the
1853
+ * shared surface and a game's text layout is a pure function over CSS pixels, so reaching it
1854
+ * meant threading a canvas through an interface layer to a value the renderer was holding.
1855
+ *
1856
+ * The decision itself stays in `textLayout.ts` and only this binding is per backend, which is
1857
+ * the 2026-08-13 rule: a cell already whole comes back untouched, and the snap is downward so a
1858
+ * line fitted to a box can only leave a gap rather than overflow it.
1859
+ */
1860
+ snapTextCellSize(cellSize: number, viewportWidth: number): number;
1842
1861
  /** Width of the string this handle currently holds, in pixels at a given cell size. */
1843
1862
  textWidth(text: TextRenderer, cellSize: number): number;
1844
1863
  disposeText(text: TextRenderer): void;
@@ -75,6 +75,7 @@ import { TextRenderer } from '../../textRenderer.js';
75
75
  import { SdfTextRenderer } from '../../sdfTextRenderer.js';
76
76
  import { MOTION_BLUR_MAX_UV, RUSH_REACH_UV } from '../../vertexDefaults.js';
77
77
  import { OUTPUT_TRANSFORM_CODE } from '../../vertexDefaults.js';
78
+ import { deviceSnappedCellSize } from '../../textLayout.js';
78
79
  import { WindStreakRenderer } from '../../windStreakRenderer.js';
79
80
  import { InstancedBatch } from './instanced.js';
80
81
  /**
@@ -2777,6 +2778,27 @@ export class WebGL2Renderer {
2777
2778
  this.textBudget.ask();
2778
2779
  }
2779
2780
  }
2781
+ /**
2782
+ * The cell size to draw a bitmap glyph at so every cell covers whole device pixels.
2783
+ *
2784
+ * **The caller snaps, and then measures and draws with the one number.** 4.1.4 applied this
2785
+ * inside `drawText` instead, where `textWidthPx` could not see it — it takes no viewport and so
2786
+ * cannot know the ratio — and every consumer went on laying out against the cell it asked for
2787
+ * while the engine drew a smaller one. A centred line, a right-aligned column and a line fitted
2788
+ * to a box broke together. Snapped here, the arithmetic and the picture cannot disagree.
2789
+ *
2790
+ * **It is on the renderer because only the renderer knows the second number.** `viewportWidth`
2791
+ * is the caller's, and it already passes it to `drawText`; the drawing buffer is not on the
2792
+ * shared surface and a game's text layout is a pure function over CSS pixels, so reaching it
2793
+ * meant threading a canvas through an interface layer to a value the renderer was holding.
2794
+ *
2795
+ * The decision itself stays in `textLayout.ts` and only this binding is per backend, which is
2796
+ * the 2026-08-13 rule: a cell already whole comes back untouched, and the snap is downward so a
2797
+ * line fitted to a box can only leave a gap rather than overflow it.
2798
+ */
2799
+ snapTextCellSize(cellSize, viewportWidth) {
2800
+ return deviceSnappedCellSize(cellSize, viewportWidth, this.gl.drawingBufferWidth);
2801
+ }
2780
2802
  /** Width of the string this handle currently holds, in pixels at a given cell size. */
2781
2803
  textWidth(text, cellSize) {
2782
2804
  return text.widthPx(cellSize);
@@ -1126,6 +1126,8 @@ export declare class WebGPURenderer implements RendererApi {
1126
1126
  createText(): GpuText;
1127
1127
  setText(text: GpuText, content: string): void;
1128
1128
  setPlate(text: GpuText, widthCells: number, heightCells: number, bottomCell: number): void;
1129
+ /** See `Renderer.snapTextCellSize`; only the drawing buffer differs. */
1130
+ snapTextCellSize(cellSize: number, viewportWidth: number): number;
1129
1131
  textWidth(text: GpuText, cellSize: number): number;
1130
1132
  disposeText(text: GpuText): void;
1131
1133
  /** A text label from an SDF font. See `sdfTextPass.ts`; the layout it holds is shared with WebGL2. */
@@ -38,7 +38,7 @@ import { POINT_SHADOW_SAMPLER, PointShadowSystem, } from '../../pointShadowSyste
38
38
  import { GpuPointShadowArray, GpuPointShadowMap } from './pointShadowPass.js';
39
39
  import { createPanelBindGroup, createPanelBindGroupLayout, createPanelCorners, panelPipeline, PANEL_FRAG_FIELDS, PANEL_FRAG_SIZE, PANEL_VERTEX_COUNT, PANEL_VERT_FIELDS, PANEL_VERT_SIZE, } from './panelPass.js';
40
40
  import { createGpuTextCube, createTextBindGroup, createTextBindGroupLayout, GpuText, textPipeline, TEXT_FRAG_FIELDS, TEXT_FRAG_SIZE, TEXT_VERT_FIELDS, TEXT_VERT_SIZE, } from './textPass.js';
41
- import { deviceSnappedOrigin } from '../../textLayout.js';
41
+ import { deviceSnappedCellSize, deviceSnappedOrigin } from '../../textLayout.js';
42
42
  import { GpuSurfaceTexture } from './surfaceTexturePass.js';
43
43
  import { createSdfTextBindGroup, createSdfTextBindGroupLayout, GpuSdfText, sdfTextPipeline, SDF_TEXT_FRAG_FIELDS, SDF_TEXT_FRAG_SIZE, SDF_TEXT_VERT_FIELDS, SDF_TEXT_VERT_SIZE, } from './sdfTextPass.js';
44
44
  import { createFilmBindGroup, createFilmBindGroupLayout, filmPipeline, FILM_FRAG_FIELDS, FILM_FRAG_SIZE, FILM_VERT_FIELDS, FILM_VERT_SIZE, } from './filmPass.js';
@@ -2342,6 +2342,10 @@ export class WebGPURenderer {
2342
2342
  setPlate(text, widthCells, heightCells, bottomCell) {
2343
2343
  text.setPlate(widthCells, heightCells, bottomCell);
2344
2344
  }
2345
+ /** See `Renderer.snapTextCellSize`; only the drawing buffer differs. */
2346
+ snapTextCellSize(cellSize, viewportWidth) {
2347
+ return deviceSnappedCellSize(cellSize, viewportWidth, this.surface.canvas.width);
2348
+ }
2345
2349
  textWidth(text, cellSize) {
2346
2350
  return text.layout.widthPx(cellSize);
2347
2351
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/core",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "description": "DriftEngine reusable browser 3D runtime",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -29,8 +29,8 @@
29
29
  ],
30
30
  "sideEffects": false,
31
31
  "dependencies": {
32
- "@driftengine/drft": "4.2.0",
33
- "@driftengine/physics": "4.2.0",
32
+ "@driftengine/drft": "4.3.0",
33
+ "@driftengine/physics": "4.3.0",
34
34
  "gl-matrix": "^3.4.3"
35
35
  },
36
36
  "author": "Drift Technologies",
@@ -63,8 +63,8 @@
63
63
  "prepack": "node ../../scripts/build.mjs @driftengine/core"
64
64
  },
65
65
  "devDependencies": {
66
- "@driftengine/nav": "4.2.0",
67
- "@driftengine/network": "4.2.0",
68
- "@driftengine/texture": "4.2.0"
66
+ "@driftengine/nav": "4.3.0",
67
+ "@driftengine/network": "4.3.0",
68
+ "@driftengine/texture": "4.3.0"
69
69
  }
70
70
  }
@@ -218,6 +218,7 @@ import type { SdfTextStyle } from '../../sdfTextLayout.ts';
218
218
  import { MOTION_BLUR_MAX_UV, RUSH_REACH_UV } from '../../vertexDefaults.ts';
219
219
  import { OUTPUT_TRANSFORM_CODE } from '../../vertexDefaults.ts';
220
220
  import type { RenderBackend } from '../api.ts';
221
+ import { deviceSnappedCellSize } from '../../textLayout.ts';
221
222
  import type { TextStyle } from '../../textLayout.ts';
222
223
  import { WindStreakRenderer } from '../../windStreakRenderer.ts';
223
224
  import type { WindStreakOptions } from '../../windStreakRenderer.ts';
@@ -3657,6 +3658,28 @@ export class WebGL2Renderer implements RendererApi {
3657
3658
  }
3658
3659
  }
3659
3660
 
3661
+ /**
3662
+ * The cell size to draw a bitmap glyph at so every cell covers whole device pixels.
3663
+ *
3664
+ * **The caller snaps, and then measures and draws with the one number.** 4.1.4 applied this
3665
+ * inside `drawText` instead, where `textWidthPx` could not see it — it takes no viewport and so
3666
+ * cannot know the ratio — and every consumer went on laying out against the cell it asked for
3667
+ * while the engine drew a smaller one. A centred line, a right-aligned column and a line fitted
3668
+ * to a box broke together. Snapped here, the arithmetic and the picture cannot disagree.
3669
+ *
3670
+ * **It is on the renderer because only the renderer knows the second number.** `viewportWidth`
3671
+ * is the caller's, and it already passes it to `drawText`; the drawing buffer is not on the
3672
+ * shared surface and a game's text layout is a pure function over CSS pixels, so reaching it
3673
+ * meant threading a canvas through an interface layer to a value the renderer was holding.
3674
+ *
3675
+ * The decision itself stays in `textLayout.ts` and only this binding is per backend, which is
3676
+ * the 2026-08-13 rule: a cell already whole comes back untouched, and the snap is downward so a
3677
+ * line fitted to a box can only leave a gap rather than overflow it.
3678
+ */
3679
+ snapTextCellSize(cellSize: number, viewportWidth: number): number {
3680
+ return deviceSnappedCellSize(cellSize, viewportWidth, this.gl.drawingBufferWidth);
3681
+ }
3682
+
3660
3683
  /** Width of the string this handle currently holds, in pixels at a given cell size. */
3661
3684
  textWidth(text: TextRenderer, cellSize: number): number {
3662
3685
  return text.widthPx(cellSize);
@@ -150,7 +150,7 @@ import {
150
150
  type GpuTextCube,
151
151
  } from './textPass.ts';
152
152
  import type { TextStyle } from '../../textLayout.ts';
153
- import { deviceSnappedOrigin } from '../../textLayout.ts';
153
+ import { deviceSnappedCellSize, deviceSnappedOrigin } from '../../textLayout.ts';
154
154
  import { GpuSurfaceTexture } from './surfaceTexturePass.ts';
155
155
  import {
156
156
  createSdfTextBindGroup,
@@ -3000,6 +3000,11 @@ export class WebGPURenderer implements RendererApi {
3000
3000
  text.setPlate(widthCells, heightCells, bottomCell);
3001
3001
  }
3002
3002
 
3003
+ /** See `Renderer.snapTextCellSize`; only the drawing buffer differs. */
3004
+ snapTextCellSize(cellSize: number, viewportWidth: number): number {
3005
+ return deviceSnappedCellSize(cellSize, viewportWidth, this.surface.canvas.width);
3006
+ }
3007
+
3003
3008
  textWidth(text: GpuText, cellSize: number): number {
3004
3009
  return text.layout.widthPx(cellSize);
3005
3010
  }