@driftengine/core 4.1.3 → 4.1.4

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.
@@ -70,6 +70,94 @@ export function textHeightPx(cellSize: number): number {
70
70
  return GLYPH_HEIGHT * cellSize;
71
71
  }
72
72
 
73
+ /**
74
+ * The cell size to draw at, so every cell of a bitmap glyph covers whole device pixels.
75
+ *
76
+ * **A 5x7 face draws every stroke exactly one cell wide, so a fractional cell is not a slightly
77
+ * softer glyph — it is a glyph whose strokes are different widths.** The rasteriser gives one
78
+ * column of cells four pixels and the next three, and a face carrying its legibility entirely in
79
+ * uniform strokes comes apart. Reported three times as text "eaten away" in a checkerboard while
80
+ * larger text in the same frame stayed solid, which is the size dependence this explains: a whole
81
+ * cell is uniform and a fractional one is not, and nothing about it is a dropped pixel.
82
+ *
83
+ * Two doors reach a fractional cell, which is why it was reported as unrelated faults. A caller
84
+ * asks for one outright, sizing a second line at a fraction of its headline; or asks for a whole
85
+ * one that the device ratio makes fractional, a 3-pixel cell at a 1.25 ratio being 3.75 on the
86
+ * grid. `viewportWidth` is whatever units the caller lays out in — CSS pixels for an overlay
87
+ * sized from `cssWidth`, device pixels for one sized from the canvas — and `bufferWidth` is the
88
+ * drawing buffer, so the ratio between them is exactly what turns one into the other.
89
+ *
90
+ * **What it gives up is the asked size.** The cell drawn is the largest whole number of device
91
+ * pixels that is not larger than the one asked for, so a string is drawn up to one device pixel
92
+ * per cell narrower than `textWidthPx` measures it. Down rather than to nearest, because a caller
93
+ * that fitted a line to a box measured it first: rounding up would draw a line wider than the box
94
+ * it was fitted to, and rounding down can only leave a gap.
95
+ *
96
+ * **`textWidthPx` and `RendererApi.textWidth` are deliberately left unsnapped**, and that is the
97
+ * cost rather than an oversight. Neither is handed a viewport, so neither can know whether the
98
+ * caller lays out in CSS pixels or device ones — and this ratio is exactly that question. A width
99
+ * is therefore an upper bound on what is drawn: a centred line sits up to half the shortfall off
100
+ * centre, at most half a device pixel per cell. Handing the measurement a viewport would fix it
101
+ * and is a change to the public surface rather than to a patch.
102
+ *
103
+ * **What would make it wrong** is a face whose strokes are not cell-aligned — an SDF atlas, where
104
+ * the glyph is a distance field and a fractional size is exactly what it is built to serve.
105
+ * `sdfTextLayout.ts` is that path and does not come through here.
106
+ */
107
+ export function deviceSnappedCellSize(
108
+ cellSize: number,
109
+ viewportWidth: number,
110
+ bufferWidth: number,
111
+ ): number {
112
+ const ratio = bufferWidth / viewportWidth;
113
+ if (!Number.isFinite(ratio) || ratio <= 0) return cellSize;
114
+ const device = cellSize * ratio;
115
+ if (!Number.isFinite(device)) return cellSize;
116
+ /*
117
+ * The tolerance is against float error rather than a fudge: a ratio of 1 turns an asked 4 into
118
+ * 3.9999999999999996 often enough, and flooring that would shrink every whole cell on every
119
+ * ordinary display by a pixel — the change doing most harm where there was nothing to fix.
120
+ */
121
+ const whole = Math.floor(device + 1e-6);
122
+ /* Below one device pixel there is no whole size to snap to, and flooring would draw nothing. */
123
+ if (whole < 1) return cellSize;
124
+ return whole / ratio;
125
+ }
126
+
127
+ /**
128
+ * The same grid's **phase**, which is the other half of the same defect.
129
+ *
130
+ * **A whole pitch on a fractional origin still draws strokes of two widths.** Snapping the cell to
131
+ * four device pixels puts the boundaries four apart, and starting them at 10.4 puts every boundary
132
+ * at `.4` — so each stroke covers three whole pixels and two halves, and the rasteriser resolves
133
+ * that the same ragged way it resolved a fractional cell. The pitch decides how far apart the
134
+ * boundaries are; this decides where the first one is, and a face of uniform strokes needs both.
135
+ *
136
+ * It was found the way the first half was: the cell snap shipped, and the reporter said the text was
137
+ * still wrong.
138
+ *
139
+ * **To nearest rather than down**, which is the opposite of the cell above and for the opposite
140
+ * reason: this is a position, not a size. Moving a label up to half a device pixel is the smallest
141
+ * change that puts its grid on the display's, while flooring would shift every label the same way
142
+ * and bias a centred one off centre.
143
+ *
144
+ * **What would make it wrong** is a caller animating an origin sub-pixel on purpose — a label that
145
+ * slides smoothly now steps by a device pixel. For a face whose strokes are one pixel wide that is
146
+ * the trade this whole function exists to make, and `sdfTextLayout.ts`, which does not come through
147
+ * here, is the path for anything that wants the other answer.
148
+ */
149
+ export function deviceSnappedOrigin(
150
+ value: number,
151
+ viewportWidth: number,
152
+ bufferWidth: number,
153
+ ): number {
154
+ const ratio = bufferWidth / viewportWidth;
155
+ if (!Number.isFinite(ratio) || ratio <= 0) return value;
156
+ const device = value * ratio;
157
+ if (!Number.isFinite(device)) return value;
158
+ return Math.round(device) / ratio;
159
+ }
160
+
73
161
  export class TextLayout {
74
162
  /** Reused every upload; text changes far too often to allocate per change. */
75
163
  readonly cells = new Float32Array(MAX_CELLS * 2);
@@ -1,5 +1,11 @@
1
1
  import type { TextStyle } from './textLayout.ts';
2
- import { MAX_CELLS, TEXT_CUBE, TextLayout } from './textLayout.ts';
2
+ import {
3
+ MAX_CELLS,
4
+ TEXT_CUBE,
5
+ TextLayout,
6
+ deviceSnappedCellSize,
7
+ deviceSnappedOrigin,
8
+ } from './textLayout.ts';
3
9
  import { compileProgram, uniformLocations } from './shader.ts';
4
10
  import { TEXT_FRAG, TEXT_VERT } from './shaders/text.ts';
5
11
 
@@ -115,8 +121,21 @@ export class TextRenderer {
115
121
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
116
122
 
117
123
  setVec2(gl, this.uniforms['uViewport'], viewportWidth, viewportHeight);
118
- setVec2(gl, this.uniforms['uOrigin'], originX, originY);
119
- setFloat(gl, this.uniforms['uCellSize'], style.cellSize);
124
+ /* Snapped with the cell below: a whole pitch on a fractional origin still splits a stroke.
125
+ See `deviceSnappedOrigin`. */
126
+ setVec2(
127
+ gl,
128
+ this.uniforms['uOrigin'],
129
+ deviceSnappedOrigin(originX, viewportWidth, gl.drawingBufferWidth),
130
+ deviceSnappedOrigin(originY, viewportHeight, gl.drawingBufferHeight),
131
+ );
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
+ );
120
139
  // Far enough that a whole line barely converges, near enough that a rotating character
121
140
  // reads as turning rather than shearing.
122
141
  setFloat(gl, this.uniforms['uDepth'], Math.max(viewportWidth, 600) * 1.4);