@driftengine/core 4.1.5 → 4.2.1

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,31 @@
1
1
  [
2
+ {
3
+ "version": "4.2.1",
4
+ "date": "2026-09-21",
5
+ "entries": [
6
+ {
7
+ "kind": "fixed",
8
+ "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.",
9
+ "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."
10
+ }
11
+ ]
12
+ },
13
+ {
14
+ "version": "4.2.0",
15
+ "date": "2026-09-21",
16
+ "entries": [
17
+ {
18
+ "kind": "fixed",
19
+ "short": "Text is drawn at the cell size it was asked for, so a centred line is centred again. 4.1.4 snapped the cell down without telling the caller that measured it.",
20
+ "text": "**Reported as text that \"is not centered anymore and maybe tinier\", in every consumer at once**, correct in a full-size landscape browser and wrong in mobile portrait.\n\n4.1.4 made the draw path snap the cell down to whole device pixels, so a 5x7 face could not draw strokes of two different widths. That defect is real and the snap is the right cure for it, but imposing it was wrong. `textWidthPx` is the only measurement a consumer has, it takes no viewport and therefore cannot know the device ratio, so every consumer went on laying out against the cell it asked for while the engine drew a smaller one.\n\n**Everything done with a measured width broke together**: a centred line, a right-aligned column, a line fitted to a box. Measured on a 23-character string, a cell of 4.68 at ratio 1 draws 46.6 px narrower than measured; a whole cell of 3 on a 1.25 display draws 41.1 px narrower and 20% smaller than asked. The release note that shipped it called this \"at most half a device pixel per cell off centre\", which was wrong by two orders of magnitude.\n\n**It looked fine on a desktop because the snap is a no-op there.** A whole cell at a whole device ratio is returned untouched, which is exactly the case a full-size landscape browser produces. A consumer that divides an available width by a cell count, which is what a responsive layout does, gets a fraction and pays the whole gap. That is why it was reported as a mobile-portrait fault rather than a text fault.\n\nBoth backends now draw the cell they are handed. The origin snap stays: it rounds to the nearest device pixel, shifts a line by at most half of one and breaks no layout.\n\n**What this gives up** is that a fractional cell draws strokes of two widths again for a caller that does not opt in, which is the defect 4.1.4 was written to fix. It is available rather than imposed now, and the entry below is how."
21
+ },
22
+ {
23
+ "kind": "added",
24
+ "short": "deviceSnappedCellSize is exported, so a caller can ask for whole-pixel glyph strokes and measure the size it will actually get.",
25
+ "text": "The cell size to draw at so that every cell of a bitmap glyph covers whole device pixels, which the engine used to apply on the caller's behalf and no longer does.\n\n**It was not exported at all before**, which is why no consumer could have compensated for the snap being imposed: the decision was reachable only from inside the renderer. Snap once, at the point the cell is computed, then use that one number to measure, to lay out and to draw. The measurement and the picture then agree by construction, which is the property the imposed version could not have.\n\n```js\nconst cell = deviceSnappedCellSize(available / cells, viewportWidth, canvas.width);\nconst width = textWidthPx(label, cell);\nrenderer.drawText(text, viewportWidth, viewportHeight, (boxWidth - width) / 2, y, { ...style, cellSize: cell }, now);\n```\n\nThe cell drawn is the largest whole number of device pixels not larger than the one asked for. Down rather than to nearest, because a caller that fitted a line to a box measured it first and rounding up would draw a line wider than the box; rounding down can only leave a gap. A cell already whole is returned untouched, so nothing moves where there was nothing to fix."
26
+ }
27
+ ]
28
+ },
2
29
  {
3
30
  "version": "4.1.5",
4
31
  "date": "2026-09-21",
package/dist/index.d.ts CHANGED
@@ -331,7 +331,7 @@ export { BoltBatch } from './render/boltBatch.ts';
331
331
  export { createLineSegments, setPolyline } from './render/linePoints.ts';
332
332
  export type { LineSegments } from './render/linePoints.ts';
333
333
  export { FlockRenderer } from './render/flockRenderer.ts';
334
- export { DEFAULT_TEXT_STYLE, textHeightPx, textWidthPx } from './render/textLayout.ts';
334
+ export { DEFAULT_TEXT_STYLE, deviceSnappedCellSize, textHeightPx, textWidthPx, } from './render/textLayout.ts';
335
335
  export type { TextStyle } from './render/textLayout.ts';
336
336
  export { GLYPH_HEIGHT, GLYPH_SPACING, GLYPH_WIDTH, countCells, forEachCell, forEachRun, glyphRows, hasGlyph, measureText, } from './geometry/pixelFont.ts';
337
337
  export { parseSdfFont } from './render/sdfFont.ts';
package/dist/index.js CHANGED
@@ -433,7 +433,13 @@ export { FlockRenderer } from './render/flockRenderer.js';
433
433
  * the same shape scatter, plumes, particles, flocks and bolts already have. The measuring
434
434
  * helpers were statics on the class and are free functions now, because neither needs a device.
435
435
  */
436
- export { DEFAULT_TEXT_STYLE, textHeightPx, textWidthPx } from './render/textLayout.js';
436
+ export { DEFAULT_TEXT_STYLE,
437
+ /*
438
+ * Exported so a caller can ask for whole-pixel strokes, which the engine used to impose. See
439
+ * the note at the cell upload in `render/textRenderer.ts`: snap once, then measure, lay out
440
+ * and draw with that one number.
441
+ */
442
+ deviceSnappedCellSize, textHeightPx, textWidthPx, } from './render/textLayout.js';
437
443
  export { GLYPH_HEIGHT, GLYPH_SPACING, GLYPH_WIDTH, countCells, forEachCell, forEachRun, glyphRows, hasGlyph, measureText, } from './geometry/pixelFont.js';
438
444
  /*
439
445
  * SDF text, reached the same way pixel text is: the verbs are on the renderer and the
@@ -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 { deviceSnappedCellSize, deviceSnappedOrigin } from '../../textLayout.js';
41
+ import { 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';
@@ -2492,9 +2492,9 @@ export class WebGPURenderer {
2492
2492
  deviceSnappedOrigin(originX, viewportWidth, this.surface.canvas.width),
2493
2493
  deviceSnappedOrigin(originY, viewportHeight, this.surface.canvas.height),
2494
2494
  ]);
2495
- /* Whole device pixels per cell, or a 5x7 face draws strokes of two different widths. The
2496
- decision is shared with WebGL2; only this binding is per-backend. See `textLayout.ts`. */
2497
- v.writeFloat(vertexSlot, at('uCellSize'), deviceSnappedCellSize(style.cellSize, viewportWidth, this.surface.canvas.width));
2495
+ /* The cell the caller asked for, drawn faithfully. See `textRenderer.ts` for why the snap
2496
+ this used to impose is the caller's to ask for. */
2497
+ v.writeFloat(vertexSlot, at('uCellSize'), style.cellSize);
2498
2498
  /* Far enough that a whole line barely converges, near enough that a turn reads as a turn. */
2499
2499
  v.writeFloat(vertexSlot, at('uDepth'), Math.max(viewportWidth, 600) * 1.4);
2500
2500
  v.writeFloat(vertexSlot, at('uReveal'), style.reveal);
@@ -1,4 +1,4 @@
1
- import { MAX_CELLS, TEXT_CUBE, TextLayout, deviceSnappedCellSize, deviceSnappedOrigin, } from './textLayout.js';
1
+ import { MAX_CELLS, TEXT_CUBE, TextLayout, deviceSnappedOrigin } from './textLayout.js';
2
2
  import { compileProgram, uniformLocations } from './shader.js';
3
3
  import { TEXT_FRAG, TEXT_VERT } from './shaders/text.js';
4
4
  /**
@@ -103,9 +103,28 @@ export class TextRenderer {
103
103
  /* Snapped with the cell below: a whole pitch on a fractional origin still splits a stroke.
104
104
  See `deviceSnappedOrigin`. */
105
105
  setVec2(gl, this.uniforms['uOrigin'], deviceSnappedOrigin(originX, viewportWidth, gl.drawingBufferWidth), deviceSnappedOrigin(originY, viewportHeight, gl.drawingBufferHeight));
106
- /* Whole device pixels per cell, or a 5x7 face draws strokes of two different widths. The
107
- decision is shared with WebGPU; only this binding is per-backend. See `textLayout.ts`. */
108
- setFloat(gl, this.uniforms['uCellSize'], deviceSnappedCellSize(style.cellSize, viewportWidth, gl.drawingBufferWidth));
106
+ /*
107
+ * **The cell the caller asked for, drawn faithfully.**
108
+ *
109
+ * 4.1.4 snapped this down to whole device pixels here, so a 5x7 face could not draw strokes
110
+ * of two different widths. That defect is real and the snap is the right cure for it, but
111
+ * imposing it was wrong: `textWidthPx` is the only measurement a consumer has, it takes no
112
+ * viewport and so cannot know the device ratio, and every consumer went on laying out against
113
+ * the cell it asked for while this drew a smaller one. A centred line, a right-aligned column
114
+ * and a line fitted to a box all broke at once, silently, and only where the snap was not a
115
+ * no-op: a whole cell at a whole ratio is untouched, which is why it looked right in a
116
+ * full-size landscape browser and wrong in mobile portrait, where a consumer divides an
117
+ * available width by a cell count and gets a fraction.
118
+ *
119
+ * So the snap is the caller's to ask for. `deviceSnappedCellSize` is exported; a caller that
120
+ * wants whole-pixel strokes snaps once and uses that one number to measure, to lay out and to
121
+ * draw, where the measurement and the picture agree by construction.
122
+ *
123
+ * **What this gives up** is that a fractional cell draws strokes of two widths again for a
124
+ * caller that does not opt in. **What would make it wrong** is a measurement that could snap
125
+ * on the caller's behalf, which would need the viewport `textWidthPx` is not handed.
126
+ */
127
+ setFloat(gl, this.uniforms['uCellSize'], style.cellSize);
109
128
  // Far enough that a whole line barely converges, near enough that a rotating character
110
129
  // reads as turning rather than shearing.
111
130
  setFloat(gl, this.uniforms['uDepth'], Math.max(viewportWidth, 600) * 1.4);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/core",
3
- "version": "4.1.5",
3
+ "version": "4.2.1",
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.1.5",
33
- "@driftengine/physics": "4.1.5",
32
+ "@driftengine/drft": "4.2.1",
33
+ "@driftengine/physics": "4.2.1",
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.1.5",
67
- "@driftengine/network": "4.1.5",
68
- "@driftengine/texture": "4.1.5"
66
+ "@driftengine/nav": "4.2.1",
67
+ "@driftengine/network": "4.2.1",
68
+ "@driftengine/texture": "4.2.1"
69
69
  }
70
70
  }
package/src/index.ts CHANGED
@@ -933,7 +933,17 @@ export { FlockRenderer } from './render/flockRenderer.ts';
933
933
  * the same shape scatter, plumes, particles, flocks and bolts already have. The measuring
934
934
  * helpers were statics on the class and are free functions now, because neither needs a device.
935
935
  */
936
- export { DEFAULT_TEXT_STYLE, textHeightPx, textWidthPx } from './render/textLayout.ts';
936
+ export {
937
+ DEFAULT_TEXT_STYLE,
938
+ /*
939
+ * Exported so a caller can ask for whole-pixel strokes, which the engine used to impose. See
940
+ * the note at the cell upload in `render/textRenderer.ts`: snap once, then measure, lay out
941
+ * and draw with that one number.
942
+ */
943
+ deviceSnappedCellSize,
944
+ textHeightPx,
945
+ textWidthPx,
946
+ } from './render/textLayout.ts';
937
947
  export type { TextStyle } from './render/textLayout.ts';
938
948
  export {
939
949
  GLYPH_HEIGHT,
@@ -150,7 +150,7 @@ import {
150
150
  type GpuTextCube,
151
151
  } from './textPass.ts';
152
152
  import type { TextStyle } from '../../textLayout.ts';
153
- import { deviceSnappedCellSize, deviceSnappedOrigin } from '../../textLayout.ts';
153
+ import { deviceSnappedOrigin } from '../../textLayout.ts';
154
154
  import { GpuSurfaceTexture } from './surfaceTexturePass.ts';
155
155
  import {
156
156
  createSdfTextBindGroup,
@@ -3185,13 +3185,9 @@ export class WebGPURenderer implements RendererApi {
3185
3185
  deviceSnappedOrigin(originX, viewportWidth, this.surface.canvas.width),
3186
3186
  deviceSnappedOrigin(originY, viewportHeight, this.surface.canvas.height),
3187
3187
  ]);
3188
- /* Whole device pixels per cell, or a 5x7 face draws strokes of two different widths. The
3189
- decision is shared with WebGL2; only this binding is per-backend. See `textLayout.ts`. */
3190
- v.writeFloat(
3191
- vertexSlot,
3192
- at('uCellSize'),
3193
- deviceSnappedCellSize(style.cellSize, viewportWidth, this.surface.canvas.width),
3194
- );
3188
+ /* The cell the caller asked for, drawn faithfully. See `textRenderer.ts` for why the snap
3189
+ this used to impose is the caller's to ask for. */
3190
+ v.writeFloat(vertexSlot, at('uCellSize'), style.cellSize);
3195
3191
  /* Far enough that a whole line barely converges, near enough that a turn reads as a turn. */
3196
3192
  v.writeFloat(vertexSlot, at('uDepth'), Math.max(viewportWidth, 600) * 1.4);
3197
3193
  v.writeFloat(vertexSlot, at('uReveal'), style.reveal);
@@ -1,11 +1,5 @@
1
1
  import type { TextStyle } from './textLayout.ts';
2
- import {
3
- MAX_CELLS,
4
- TEXT_CUBE,
5
- TextLayout,
6
- deviceSnappedCellSize,
7
- deviceSnappedOrigin,
8
- } from './textLayout.ts';
2
+ import { MAX_CELLS, TEXT_CUBE, TextLayout, deviceSnappedOrigin } from './textLayout.ts';
9
3
  import { compileProgram, uniformLocations } from './shader.ts';
10
4
  import { TEXT_FRAG, TEXT_VERT } from './shaders/text.ts';
11
5
 
@@ -129,13 +123,28 @@ export class TextRenderer {
129
123
  deviceSnappedOrigin(originX, viewportWidth, gl.drawingBufferWidth),
130
124
  deviceSnappedOrigin(originY, viewportHeight, gl.drawingBufferHeight),
131
125
  );
132
- /* Whole device pixels per cell, or a 5x7 face draws strokes of two different widths. The
133
- decision is shared with WebGPU; only this binding is per-backend. See `textLayout.ts`. */
134
- setFloat(
135
- gl,
136
- this.uniforms['uCellSize'],
137
- deviceSnappedCellSize(style.cellSize, viewportWidth, gl.drawingBufferWidth),
138
- );
126
+ /*
127
+ * **The cell the caller asked for, drawn faithfully.**
128
+ *
129
+ * 4.1.4 snapped this down to whole device pixels here, so a 5x7 face could not draw strokes
130
+ * of two different widths. That defect is real and the snap is the right cure for it, but
131
+ * imposing it was wrong: `textWidthPx` is the only measurement a consumer has, it takes no
132
+ * viewport and so cannot know the device ratio, and every consumer went on laying out against
133
+ * the cell it asked for while this drew a smaller one. A centred line, a right-aligned column
134
+ * and a line fitted to a box all broke at once, silently, and only where the snap was not a
135
+ * no-op: a whole cell at a whole ratio is untouched, which is why it looked right in a
136
+ * full-size landscape browser and wrong in mobile portrait, where a consumer divides an
137
+ * available width by a cell count and gets a fraction.
138
+ *
139
+ * So the snap is the caller's to ask for. `deviceSnappedCellSize` is exported; a caller that
140
+ * wants whole-pixel strokes snaps once and uses that one number to measure, to lay out and to
141
+ * draw, where the measurement and the picture agree by construction.
142
+ *
143
+ * **What this gives up** is that a fractional cell draws strokes of two widths again for a
144
+ * caller that does not opt in. **What would make it wrong** is a measurement that could snap
145
+ * on the caller's behalf, which would need the viewport `textWidthPx` is not handed.
146
+ */
147
+ setFloat(gl, this.uniforms['uCellSize'], style.cellSize);
139
148
  // Far enough that a whole line barely converges, near enough that a rotating character
140
149
  // reads as turning rather than shearing.
141
150
  setFloat(gl, this.uniforms['uDepth'], Math.max(viewportWidth, 600) * 1.4);