three-slug 1.0.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/GOAL.md +32 -0
- package/README.md +78 -0
- package/demo/fonts/DejaVu Fonts License.txt +97 -0
- package/demo/fonts/DejaVuSansMono-Bold.sluggish +0 -0
- package/demo/fonts/DejaVuSansMono-Bold.ttf +0 -0
- package/demo/fonts/DejaVuSansMono-BoldOblique.ttf +0 -0
- package/demo/fonts/DejaVuSansMono-Oblique.ttf +0 -0
- package/demo/fonts/DejaVuSansMono.ttf +0 -0
- package/demo/fonts/Roboto-Italic-VariableFont_wdth,wght.ttf +0 -0
- package/demo/fonts/Roboto-VariableFont_wdth,wght.ttf +0 -0
- package/demo/fonts/SpaceMono-Bold.ttf +0 -0
- package/demo/fonts/SpaceMono-BoldItalic.ttf +0 -0
- package/demo/fonts/SpaceMono-Italic.ttf +0 -0
- package/demo/fonts/SpaceMono-Regular.sluggish +0 -0
- package/demo/fonts/SpaceMono-Regular.ttf +0 -0
- package/demo/fonts/fonts.json +12 -0
- package/demo/index.html +103 -0
- package/demo/main.js +357 -0
- package/package.json +29 -0
- package/screenshot.png +0 -0
- package/src/SlugGenerator.js +399 -0
- package/src/SlugGeometry.js +211 -0
- package/src/SlugLoader.js +123 -0
- package/src/SlugMaterial.js +254 -0
- package/src/index.js +4 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// SlugGenerator.js
|
|
2
|
+
// Port of the Sluggish C++ Generator using opentype.js
|
|
3
|
+
import opentype from 'opentype.js';
|
|
4
|
+
import * as THREE from 'three';
|
|
5
|
+
|
|
6
|
+
const TEXTURE_WIDTH = 4096;
|
|
7
|
+
const SLUGGISH_HEADER_DATA = "SLUGGISH";
|
|
8
|
+
const SLUGGISH_HEADER_LEN = 8;
|
|
9
|
+
|
|
10
|
+
export class SlugGenerator {
|
|
11
|
+
constructor(parameters = {}) {
|
|
12
|
+
this.bandCount = 16;
|
|
13
|
+
this.fullRange = parameters.fullRange !== undefined ? parameters.fullRange : false;
|
|
14
|
+
this.whitelist = parameters.whitelist || null; // Array of codepoints to include
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async generateFromUrl(url) {
|
|
18
|
+
const font = await opentype.load(url);
|
|
19
|
+
return this.generate(font);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async generateFromFile(file) {
|
|
23
|
+
const buffer = await file.arrayBuffer();
|
|
24
|
+
return this.generateFromBuffer(buffer);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
generateFromBuffer(buffer) {
|
|
28
|
+
const font = opentype.parse(buffer);
|
|
29
|
+
return this.generate(font);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
generate(font) {
|
|
33
|
+
let ignoredCodePoints = 0;
|
|
34
|
+
let curvesTexData = []; // Flat array of [x, y]
|
|
35
|
+
let bandsTexBandOffsets = []; // 2 per band [curveCount, bandOffset]
|
|
36
|
+
let bandsTexCurveOffsets = []; // 2 per curve [curveX, curveY]
|
|
37
|
+
let codePointsData = [];
|
|
38
|
+
|
|
39
|
+
for (let i = 0; i < font.glyphs.length; i++) {
|
|
40
|
+
const glyph = font.glyphs.get(i);
|
|
41
|
+
let cp = glyph.unicode;
|
|
42
|
+
if (cp === undefined) {
|
|
43
|
+
if (i === 0) cp = -1; // .notdef fallback glyph
|
|
44
|
+
else continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Optional range filtering to control .sluggish file bloat
|
|
48
|
+
if (!this.fullRange && i !== 0) {
|
|
49
|
+
if (this.whitelist) {
|
|
50
|
+
if (!this.whitelist.includes(cp)) continue;
|
|
51
|
+
} else if (cp < 32 || cp > 126) {
|
|
52
|
+
continue; // Default to basic printable ASCII only
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const path = glyph.path; // ONLY use raw font-space unstretched paths to match getBoundingBox() perfectly!
|
|
56
|
+
|
|
57
|
+
// Determine visible bounding box in font units
|
|
58
|
+
const bbox = glyph.getBoundingBox();
|
|
59
|
+
if (bbox.x1 === bbox.x2 || bbox.y1 === bbox.y2) {
|
|
60
|
+
// Empty glyph (e.g. space) or missing - keep it to preserve advanceWidth!
|
|
61
|
+
codePointsData.push({
|
|
62
|
+
codePoint: cp,
|
|
63
|
+
width: 0,
|
|
64
|
+
height: 0,
|
|
65
|
+
advanceWidth: Math.floor(glyph.advanceWidth || 0),
|
|
66
|
+
bearingX: 0,
|
|
67
|
+
bearingY: 0,
|
|
68
|
+
bandCount: 0,
|
|
69
|
+
bandDimX: 0,
|
|
70
|
+
bandDimY: 0,
|
|
71
|
+
bandsTexCoordX: 0,
|
|
72
|
+
bandsTexCoordY: 0
|
|
73
|
+
});
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const gx1 = bbox.x1;
|
|
78
|
+
const gy1 = bbox.y1;
|
|
79
|
+
const gx2 = bbox.x2;
|
|
80
|
+
const gy2 = bbox.y2;
|
|
81
|
+
|
|
82
|
+
// 1. Build Temporary Curve List
|
|
83
|
+
let curves = [];
|
|
84
|
+
let currentX = 0, currentY = 0;
|
|
85
|
+
let firstCurve = false;
|
|
86
|
+
let startOfShapeX = 0, startOfShapeY = 0;
|
|
87
|
+
|
|
88
|
+
for (let i = 0; i < path.commands.length; i++) {
|
|
89
|
+
const cmd = path.commands[i];
|
|
90
|
+
if (cmd.type === 'M') {
|
|
91
|
+
firstCurve = true;
|
|
92
|
+
currentX = cmd.x - gx1;
|
|
93
|
+
currentY = cmd.y - gy1;
|
|
94
|
+
startOfShapeX = currentX;
|
|
95
|
+
startOfShapeY = currentY;
|
|
96
|
+
} else if (cmd.type === 'L') {
|
|
97
|
+
let nextX = cmd.x - gx1;
|
|
98
|
+
let nextY = cmd.y - gy1;
|
|
99
|
+
let c = {
|
|
100
|
+
first: firstCurve,
|
|
101
|
+
x1: currentX, y1: currentY,
|
|
102
|
+
x3: nextX, y3: nextY,
|
|
103
|
+
x2: (currentX + nextX) / 2.0,
|
|
104
|
+
y2: (currentY + nextY) / 2.0
|
|
105
|
+
};
|
|
106
|
+
curves.push(c);
|
|
107
|
+
firstCurve = false;
|
|
108
|
+
currentX = nextX;
|
|
109
|
+
currentY = nextY;
|
|
110
|
+
} else if (cmd.type === 'Q') {
|
|
111
|
+
let nextX = cmd.x - gx1;
|
|
112
|
+
let nextY = cmd.y - gy1;
|
|
113
|
+
let c = {
|
|
114
|
+
first: firstCurve,
|
|
115
|
+
x1: currentX, y1: currentY,
|
|
116
|
+
x2: cmd.x1 - gx1, y2: cmd.y1 - gy1,
|
|
117
|
+
x3: nextX, y3: nextY
|
|
118
|
+
};
|
|
119
|
+
curves.push(c);
|
|
120
|
+
firstCurve = false;
|
|
121
|
+
currentX = nextX;
|
|
122
|
+
currentY = nextY;
|
|
123
|
+
} else if (cmd.type === 'C') {
|
|
124
|
+
console.warn(`U+${cp.toString(16)} has bicubic curves. Slug requires quadratic.`);
|
|
125
|
+
ignoredCodePoints++;
|
|
126
|
+
curves = null; // Mark to skip
|
|
127
|
+
break;
|
|
128
|
+
} else if (cmd.type === 'Z') {
|
|
129
|
+
if (currentX !== startOfShapeX || currentY !== startOfShapeY) {
|
|
130
|
+
let c = {
|
|
131
|
+
first: firstCurve,
|
|
132
|
+
x1: currentX, y1: currentY,
|
|
133
|
+
x3: startOfShapeX, y3: startOfShapeY,
|
|
134
|
+
x2: (currentX + startOfShapeX) / 2.0,
|
|
135
|
+
y2: (currentY + startOfShapeY) / 2.0
|
|
136
|
+
};
|
|
137
|
+
curves.push(c);
|
|
138
|
+
firstCurve = false;
|
|
139
|
+
}
|
|
140
|
+
currentX = startOfShapeX;
|
|
141
|
+
currentY = startOfShapeY;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!curves || curves.length === 0) continue;
|
|
146
|
+
|
|
147
|
+
// Fix up curves where the control point is one of the endpoints
|
|
148
|
+
for (let c of curves) {
|
|
149
|
+
if ((c.x2 === c.x1 && c.y2 === c.y1) || (c.x2 === c.x3 && c.y2 === c.y3)) {
|
|
150
|
+
c.x2 = (c.x1 + c.x3) / 2.0;
|
|
151
|
+
c.y2 = (c.y1 + c.y3) / 2.0;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const bandsTexelIndex = Math.floor(bandsTexBandOffsets.length / 2);
|
|
156
|
+
|
|
157
|
+
// 2. Write Curves Texture
|
|
158
|
+
for (let c of curves) {
|
|
159
|
+
// make sure we start a curve at a texel's boundary (4 values = 2 points = 1 texel RGBA)
|
|
160
|
+
// wait, C++ code: g_curvesTexture.size() % 4
|
|
161
|
+
// each push_back pushed 1 float. so 4 floats = 1 texel.
|
|
162
|
+
// a curve is 3 points -> 6 floats.
|
|
163
|
+
if (c.first && curvesTexData.length % 4 !== 0) {
|
|
164
|
+
const toAdd = 4 - (curvesTexData.length % 4);
|
|
165
|
+
for (let i = 0; i < toAdd; i++) curvesTexData.push(-1.0);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// make sure a curve doesn't cross a row boundary
|
|
169
|
+
let texelCount = Math.floor(curvesTexData.length / 4);
|
|
170
|
+
let col = texelCount % TEXTURE_WIDTH;
|
|
171
|
+
const newRow = col === TEXTURE_WIDTH - 1;
|
|
172
|
+
if (newRow) {
|
|
173
|
+
const toAdd = 8 - (curvesTexData.length % 4); // C++ used 8
|
|
174
|
+
for (let i = 0; i < toAdd; i++) curvesTexData.push(-1.0);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (c.first || newRow) {
|
|
178
|
+
c.texelIndex = Math.floor(curvesTexData.length / 4);
|
|
179
|
+
curvesTexData.push(c.x1, c.y1);
|
|
180
|
+
} else {
|
|
181
|
+
c.texelIndex = Math.floor((Math.floor(curvesTexData.length / 2) - 1) / 2);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
curvesTexData.push(c.x2, c.y2);
|
|
185
|
+
curvesTexData.push(c.x3, c.y3);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const sizeX = 1 + (gx2 - gx1);
|
|
189
|
+
const sizeY = 1 + (gy2 - gy1);
|
|
190
|
+
let bCount = this.bandCount; // Re-enable spatial banding optimizations!
|
|
191
|
+
if (sizeX < bCount || sizeY < bCount) {
|
|
192
|
+
bCount = Math.floor(Math.min(sizeX, sizeY) / 2);
|
|
193
|
+
if(bCount < 1) bCount = 1;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const bandDimY = Math.ceil(sizeY / bCount);
|
|
197
|
+
let bandMinY = 0;
|
|
198
|
+
let bandMaxY = bandDimY;
|
|
199
|
+
|
|
200
|
+
// Sort curves by highest Y (max of y1,y2,y3) ascending/descending?
|
|
201
|
+
// C++: Max(a.x1, a.x2, a.x3) > Max(b.x1, b.x2, b.x3) for H-bands, wait!
|
|
202
|
+
// C++ for H-bands sorted by max X descending.
|
|
203
|
+
curves.sort((a, b) => Math.max(b.x1, b.x2, b.x3) - Math.max(a.x1, a.x2, a.x3));
|
|
204
|
+
|
|
205
|
+
for (let b = 0; b < bCount; b++) {
|
|
206
|
+
let bandTexelOffset = Math.floor(bandsTexCurveOffsets.length / 2);
|
|
207
|
+
let curveCount = 0;
|
|
208
|
+
|
|
209
|
+
for (let c of curves) {
|
|
210
|
+
if (c.y1 === c.y2 && c.y2 === c.y3) continue; // perfectly horizontal
|
|
211
|
+
let curveMinY = Math.min(c.y1, c.y2, c.y3);
|
|
212
|
+
let curveMaxY = Math.max(c.y1, c.y2, c.y3);
|
|
213
|
+
if (curveMinY > bandMaxY || curveMaxY < bandMinY) continue; // doesn't cross band
|
|
214
|
+
|
|
215
|
+
let texelIndex = c.texelIndex;
|
|
216
|
+
let curveOffsetX = texelIndex % TEXTURE_WIDTH;
|
|
217
|
+
let curveOffsetY = Math.floor(texelIndex / TEXTURE_WIDTH);
|
|
218
|
+
bandsTexCurveOffsets.push(curveOffsetX, curveOffsetY);
|
|
219
|
+
curveCount++;
|
|
220
|
+
}
|
|
221
|
+
bandsTexBandOffsets.push(curveCount, bandTexelOffset);
|
|
222
|
+
bandMinY += bandDimY;
|
|
223
|
+
bandMaxY += bandDimY;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// For vertical bands, sort by max Y descending
|
|
227
|
+
const bandDimX = Math.ceil(sizeX / bCount);
|
|
228
|
+
let bandMinX = 0;
|
|
229
|
+
let bandMaxX = bandDimX;
|
|
230
|
+
|
|
231
|
+
curves.sort((a, b) => Math.max(b.y1, b.y2, b.y3) - Math.max(a.y1, a.y2, a.y3));
|
|
232
|
+
|
|
233
|
+
for (let b = 0; b < bCount; b++) {
|
|
234
|
+
let bandTexelOffset = Math.floor(bandsTexCurveOffsets.length / 2);
|
|
235
|
+
let curveCount = 0;
|
|
236
|
+
|
|
237
|
+
for (let c of curves) {
|
|
238
|
+
if (c.x1 === c.x2 && c.x2 === c.x3) continue; // perfectly vertical
|
|
239
|
+
let curveMinX = Math.min(c.x1, c.x2, c.x3);
|
|
240
|
+
let curveMaxX = Math.max(c.x1, c.x2, c.x3);
|
|
241
|
+
if (curveMinX > bandMaxX || curveMaxX < bandMinX) continue; // doesn't cross band
|
|
242
|
+
|
|
243
|
+
let texelIndex = c.texelIndex;
|
|
244
|
+
let curveOffsetX = texelIndex % TEXTURE_WIDTH;
|
|
245
|
+
let curveOffsetY = Math.floor(texelIndex / TEXTURE_WIDTH);
|
|
246
|
+
bandsTexCurveOffsets.push(curveOffsetX, curveOffsetY);
|
|
247
|
+
curveCount++;
|
|
248
|
+
}
|
|
249
|
+
bandsTexBandOffsets.push(curveCount, bandTexelOffset);
|
|
250
|
+
bandMinX += bandDimX;
|
|
251
|
+
bandMaxX += bandDimX;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
codePointsData.push({
|
|
255
|
+
codePoint: cp,
|
|
256
|
+
width: Math.floor(gx2 - gx1),
|
|
257
|
+
height: Math.floor(gy2 - gy1),
|
|
258
|
+
advanceWidth: Math.floor(glyph.advanceWidth || 0),
|
|
259
|
+
bearingX: Math.floor(gx1),
|
|
260
|
+
bearingY: Math.floor(gy1),
|
|
261
|
+
bandCount: bCount,
|
|
262
|
+
bandDimX: bandDimX,
|
|
263
|
+
bandDimY: bandDimY,
|
|
264
|
+
bandsTexCoordX: bandsTexelIndex % TEXTURE_WIDTH,
|
|
265
|
+
bandsTexCoordY: Math.floor(bandsTexelIndex / TEXTURE_WIDTH)
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Post-processing
|
|
270
|
+
const bandHeaderTexels = Math.floor(bandsTexBandOffsets.length / 2);
|
|
271
|
+
for (let i = 1; i < bandsTexBandOffsets.length; i += 2) {
|
|
272
|
+
bandsTexBandOffsets[i] += bandHeaderTexels;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return this.buildOutput(codePointsData, curvesTexData, bandsTexBandOffsets, bandsTexCurveOffsets, font);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
buildOutput(codePoints, curvesList, bandOffsets, curveOffsets, font) {
|
|
279
|
+
// Build DataTextures and codePoint Map
|
|
280
|
+
const map = new Map();
|
|
281
|
+
codePoints.forEach(cp => map.set(cp.codePoint, cp));
|
|
282
|
+
|
|
283
|
+
const curvesTexels = Math.ceil(curvesList.length / 4);
|
|
284
|
+
const curvesTexHeight = Math.ceil(curvesTexels / TEXTURE_WIDTH);
|
|
285
|
+
|
|
286
|
+
let curvesFloatArray = new Float32Array(TEXTURE_WIDTH * curvesTexHeight * 4);
|
|
287
|
+
curvesFloatArray.fill(-1.0);
|
|
288
|
+
curvesFloatArray.set(curvesList);
|
|
289
|
+
|
|
290
|
+
const curvesTex = new THREE.DataTexture(curvesFloatArray, TEXTURE_WIDTH, curvesTexHeight, THREE.RGBAFormat, THREE.FloatType);
|
|
291
|
+
curvesTex.internalFormat = 'RGBA32F';
|
|
292
|
+
curvesTex.minFilter = THREE.NearestFilter;
|
|
293
|
+
curvesTex.magFilter = THREE.NearestFilter;
|
|
294
|
+
curvesTex.needsUpdate = true;
|
|
295
|
+
|
|
296
|
+
const bandsTexels = Math.floor(bandOffsets.length / 2) + Math.floor(curveOffsets.length / 2);
|
|
297
|
+
const bandsTexHeight = Math.ceil(bandsTexels / TEXTURE_WIDTH);
|
|
298
|
+
|
|
299
|
+
let bandsUintArray = new Uint32Array(TEXTURE_WIDTH * bandsTexHeight * 2);
|
|
300
|
+
bandsUintArray.set(bandOffsets, 0);
|
|
301
|
+
bandsUintArray.set(curveOffsets, bandOffsets.length);
|
|
302
|
+
|
|
303
|
+
const bandsTex = new THREE.DataTexture(bandsUintArray, TEXTURE_WIDTH, bandsTexHeight, THREE.RGIntegerFormat, THREE.UnsignedIntType);
|
|
304
|
+
bandsTex.internalFormat = 'RG32UI';
|
|
305
|
+
bandsTex.minFilter = THREE.NearestFilter;
|
|
306
|
+
bandsTex.magFilter = THREE.NearestFilter;
|
|
307
|
+
bandsTex.needsUpdate = true;
|
|
308
|
+
|
|
309
|
+
// Also return the raw arrays for export
|
|
310
|
+
return {
|
|
311
|
+
codePoints: map,
|
|
312
|
+
curvesTex: curvesTex,
|
|
313
|
+
bandsTex: bandsTex,
|
|
314
|
+
ascender: font.ascender || 0,
|
|
315
|
+
descender: font.descender || 0,
|
|
316
|
+
lineGap: font.lineGap || 0,
|
|
317
|
+
unitsPerEm: font.unitsPerEm || 0,
|
|
318
|
+
_raw: { codePoints, curvesList, bandOffsets, curveOffsets, metrics: {
|
|
319
|
+
ascender: font.ascender || 0,
|
|
320
|
+
descender: font.descender || 0,
|
|
321
|
+
lineGap: font.lineGap || 0,
|
|
322
|
+
unitsPerEm: font.unitsPerEm || 0
|
|
323
|
+
} }
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
exportSluggish(generatedData) {
|
|
328
|
+
// Binary pack into ArrayBuffer
|
|
329
|
+
const { codePoints, curvesList, bandOffsets, curveOffsets } = generatedData._raw;
|
|
330
|
+
|
|
331
|
+
const curvesTexels = Math.ceil(curvesList.length / 4);
|
|
332
|
+
const curvesTexHeight = Math.ceil(curvesTexels / TEXTURE_WIDTH);
|
|
333
|
+
const curvesFloatArray = new Float32Array(TEXTURE_WIDTH * curvesTexHeight * 4);
|
|
334
|
+
curvesFloatArray.fill(0); // Optional, filling with 0 or -1. Let's use 0 to match normal behavior
|
|
335
|
+
curvesFloatArray.set(curvesList);
|
|
336
|
+
|
|
337
|
+
const bandsTexels = Math.floor(bandOffsets.length / 2) + Math.floor(curveOffsets.length / 2);
|
|
338
|
+
const bandsTexHeight = Math.ceil(bandsTexels / TEXTURE_WIDTH);
|
|
339
|
+
const bandsUintArray = new Uint32Array(TEXTURE_WIDTH * bandsTexHeight * 2);
|
|
340
|
+
bandsUintArray.set(bandOffsets, 0);
|
|
341
|
+
bandsUintArray.set(curveOffsets, bandOffsets.length);
|
|
342
|
+
|
|
343
|
+
// Calculate total size
|
|
344
|
+
// Header: 8 bytes
|
|
345
|
+
// CP Count: 2 bytes
|
|
346
|
+
// CPs: 28 bytes * N
|
|
347
|
+
// Curves: Width(2) + Height(2) + Bytes(4) = 8 bytes + CurvesBytes
|
|
348
|
+
// Bands: Width(2) + Height(2) + Bytes(4) = 8 bytes + BandsBytes
|
|
349
|
+
// Calculate total size: 40 bytes per code point
|
|
350
|
+
const curvesBytes = curvesFloatArray.byteLength;
|
|
351
|
+
const bandsBytes = bandsUintArray.byteLength;
|
|
352
|
+
const metrics = generatedData._raw.metrics || { ascender: 0, descender: 0, lineGap: 0, unitsPerEm: 0 };
|
|
353
|
+
|
|
354
|
+
const totalBytes = 8 + 2 + (codePoints.length * 40) + 8 + curvesBytes + 8 + bandsBytes + 16;
|
|
355
|
+
const buffer = new ArrayBuffer(totalBytes);
|
|
356
|
+
const view = new DataView(buffer);
|
|
357
|
+
let offset = 0;
|
|
358
|
+
|
|
359
|
+
for (let i = 0; i < 8; i++) {
|
|
360
|
+
view.setUint8(offset++, SLUGGISH_HEADER_DATA.charCodeAt(i));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
view.setUint16(offset, codePoints.length, true); offset += 2;
|
|
364
|
+
|
|
365
|
+
for (let cp of codePoints) {
|
|
366
|
+
view.setUint32(offset, cp.codePoint, true); offset += 4;
|
|
367
|
+
view.setUint32(offset, cp.width, true); offset += 4;
|
|
368
|
+
view.setUint32(offset, cp.height, true); offset += 4;
|
|
369
|
+
view.setUint32(offset, cp.advanceWidth, true); offset += 4;
|
|
370
|
+
view.setInt32(offset, cp.bearingX, true); offset += 4; // Signed
|
|
371
|
+
view.setInt32(offset, cp.bearingY, true); offset += 4; // Signed
|
|
372
|
+
view.setUint32(offset, cp.bandCount, true); offset += 4;
|
|
373
|
+
view.setUint32(offset, cp.bandDimX, true); offset += 4;
|
|
374
|
+
view.setUint32(offset, cp.bandDimY, true); offset += 4;
|
|
375
|
+
view.setUint16(offset, cp.bandsTexCoordX, true); offset += 2;
|
|
376
|
+
view.setUint16(offset, cp.bandsTexCoordY, true); offset += 2;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
view.setUint16(offset, TEXTURE_WIDTH, true); offset += 2;
|
|
380
|
+
view.setUint16(offset, curvesTexHeight, true); offset += 2;
|
|
381
|
+
view.setUint32(offset, curvesBytes, true); offset += 4;
|
|
382
|
+
new Uint8Array(buffer).set(new Uint8Array(curvesFloatArray.buffer), offset);
|
|
383
|
+
offset += curvesBytes;
|
|
384
|
+
|
|
385
|
+
view.setUint16(offset, TEXTURE_WIDTH, true); offset += 2;
|
|
386
|
+
view.setUint16(offset, bandsTexHeight, true); offset += 2;
|
|
387
|
+
view.setUint32(offset, bandsBytes, true); offset += 4;
|
|
388
|
+
new Uint8Array(buffer).set(new Uint8Array(bandsUintArray.buffer), offset);
|
|
389
|
+
offset += bandsBytes;
|
|
390
|
+
|
|
391
|
+
// Footer Metadata Table (Backward compatible fallback)
|
|
392
|
+
view.setInt32(offset, metrics.ascender || 0, true); offset += 4;
|
|
393
|
+
view.setInt32(offset, metrics.descender || 0, true); offset += 4;
|
|
394
|
+
view.setInt32(offset, metrics.lineGap || 0, true); offset += 4;
|
|
395
|
+
view.setInt32(offset, metrics.unitsPerEm || 0, true); offset += 4;
|
|
396
|
+
|
|
397
|
+
return buffer;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
|
|
3
|
+
export class SlugGeometry extends THREE.InstancedBufferGeometry {
|
|
4
|
+
constructor(maxGlyphs = 1024) {
|
|
5
|
+
super();
|
|
6
|
+
|
|
7
|
+
// 1. Base Quad Geometry (0 to 1, or -1 to 1 based on shader assumptions)
|
|
8
|
+
// Shader expects position * scaleBias.xy + scaleBias.zw.
|
|
9
|
+
// In the C++ code, they use vertices from -1 to 1:
|
|
10
|
+
// -1, -1 | -1, 1 | 1, 1 | 1, -1
|
|
11
|
+
const vertices = new Float32Array([
|
|
12
|
+
-1.0, -1.0,
|
|
13
|
+
-1.0, 1.0,
|
|
14
|
+
1.0, 1.0,
|
|
15
|
+
1.0, -1.0
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const uvs = new Float32Array([
|
|
19
|
+
0.0, 0.0,
|
|
20
|
+
0.0, 1.0,
|
|
21
|
+
1.0, 1.0,
|
|
22
|
+
1.0, 0.0
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const normals = new Float32Array([
|
|
26
|
+
0.0, 0.0, 1.0,
|
|
27
|
+
0.0, 0.0, 1.0,
|
|
28
|
+
0.0, 0.0, 1.0,
|
|
29
|
+
0.0, 0.0, 1.0
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const indices = new Uint16Array([
|
|
33
|
+
0, 2, 1,
|
|
34
|
+
0, 3, 2
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
this.setIndex(new THREE.BufferAttribute(indices, 1));
|
|
38
|
+
this.setAttribute('position', new THREE.BufferAttribute(vertices, 2));
|
|
39
|
+
this.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
|
40
|
+
this.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
|
41
|
+
|
|
42
|
+
// 2. Instanced Attributes
|
|
43
|
+
this.maxGlyphs = maxGlyphs;
|
|
44
|
+
this.glyphCount = 0;
|
|
45
|
+
|
|
46
|
+
this.aScaleBias = new Float32Array(maxGlyphs * 4);
|
|
47
|
+
this.aGlyphBandScale = new Float32Array(maxGlyphs * 4);
|
|
48
|
+
this.aBandMaxTexCoords = new Float32Array(maxGlyphs * 4);
|
|
49
|
+
|
|
50
|
+
const attrScaleBias = new THREE.InstancedBufferAttribute(this.aScaleBias, 4);
|
|
51
|
+
attrScaleBias.setUsage(THREE.DynamicDrawUsage);
|
|
52
|
+
this.setAttribute('aScaleBias', attrScaleBias);
|
|
53
|
+
|
|
54
|
+
const attrGlyphBandScale = new THREE.InstancedBufferAttribute(this.aGlyphBandScale, 4);
|
|
55
|
+
attrGlyphBandScale.setUsage(THREE.DynamicDrawUsage);
|
|
56
|
+
this.setAttribute('aGlyphBandScale', attrGlyphBandScale);
|
|
57
|
+
|
|
58
|
+
// WebGL2 requires InterleavedBuffer or Int32/Uint32 for int attributes in shader (uvec4)
|
|
59
|
+
const attrBandMaxTexCoords = new THREE.InstancedBufferAttribute(this.aBandMaxTexCoords, 4);
|
|
60
|
+
attrBandMaxTexCoords.setUsage(THREE.DynamicDrawUsage);
|
|
61
|
+
this.setAttribute('aBandMaxTexCoords', attrBandMaxTexCoords);
|
|
62
|
+
|
|
63
|
+
this.instanceCount = 0;
|
|
64
|
+
|
|
65
|
+
this.boundingBox = new THREE.Box3();
|
|
66
|
+
this.boundingSphere = new THREE.Sphere();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
clear() {
|
|
70
|
+
this.glyphCount = 0;
|
|
71
|
+
this.instanceCount = 0;
|
|
72
|
+
this.boundingBox.makeEmpty();
|
|
73
|
+
this.boundingSphere.radius = 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
computeBoundingSphere() {
|
|
77
|
+
if (!this.boundingBox || this.boundingBox.isEmpty()) {
|
|
78
|
+
this.boundingSphere.set(new THREE.Vector3(), 0);
|
|
79
|
+
} else {
|
|
80
|
+
this.boundingBox.getBoundingSphere(this.boundingSphere);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
addGlyph(codePointData, x, y, width, height, displayWidth, displayHeight) {
|
|
85
|
+
|
|
86
|
+
// Based on C++ GL_RenderGlyph
|
|
87
|
+
if (this.glyphCount >= this.maxGlyphs) {
|
|
88
|
+
console.warn("Max glyphs reached");
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const i = this.glyphCount;
|
|
93
|
+
|
|
94
|
+
// Quads go from -1 to 1. Multiply by half width/height to get correct size.
|
|
95
|
+
// Bias translates the center position.
|
|
96
|
+
const sx = width / 2.0;
|
|
97
|
+
const sy = height / 2.0;
|
|
98
|
+
const cx = x + sx;
|
|
99
|
+
const cy = y + sy;
|
|
100
|
+
|
|
101
|
+
this.aScaleBias[i * 4 + 0] = sx;
|
|
102
|
+
this.aScaleBias[i * 4 + 1] = sy;
|
|
103
|
+
this.aScaleBias[i * 4 + 2] = cx;
|
|
104
|
+
this.aScaleBias[i * 4 + 3] = cy;
|
|
105
|
+
|
|
106
|
+
// Dynamically compute the exact bounding box of the packed structure for frustum culling and native Shadow Map projections
|
|
107
|
+
this.boundingBox.expandByPoint(new THREE.Vector3(cx - sx, cy - sy, 0));
|
|
108
|
+
this.boundingBox.expandByPoint(new THREE.Vector3(cx + sx, cy + sy, 0));
|
|
109
|
+
|
|
110
|
+
// Glyph and Band Scale
|
|
111
|
+
|
|
112
|
+
this.aGlyphBandScale[i * 4 + 0] = codePointData.width;
|
|
113
|
+
this.aGlyphBandScale[i * 4 + 1] = codePointData.height;
|
|
114
|
+
this.aGlyphBandScale[i * 4 + 2] = codePointData.width / codePointData.bandDimX;
|
|
115
|
+
this.aGlyphBandScale[i * 4 + 3] = codePointData.height / codePointData.bandDimY;
|
|
116
|
+
|
|
117
|
+
// Band Max Tex Coords (Uint32)
|
|
118
|
+
this.aBandMaxTexCoords[i * 4 + 0] = codePointData.bandCount - 1;
|
|
119
|
+
this.aBandMaxTexCoords[i * 4 + 1] = codePointData.bandCount - 1;
|
|
120
|
+
this.aBandMaxTexCoords[i * 4 + 2] = codePointData.bandsTexCoordX;
|
|
121
|
+
this.aBandMaxTexCoords[i * 4 + 3] = codePointData.bandsTexCoordY;
|
|
122
|
+
|
|
123
|
+
this.glyphCount++;
|
|
124
|
+
this.instanceCount = this.glyphCount;
|
|
125
|
+
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
updateBuffers() {
|
|
130
|
+
this.attributes.aScaleBias.needsUpdate = true;
|
|
131
|
+
this.attributes.aGlyphBandScale.needsUpdate = true;
|
|
132
|
+
this.attributes.aBandMaxTexCoords.needsUpdate = true;
|
|
133
|
+
|
|
134
|
+
// Automatically sync the bounding sphere to the aggressively tracked bounding box for native physics implementations
|
|
135
|
+
this.computeBoundingSphere();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
clear() {
|
|
139
|
+
this.glyphCount = 0;
|
|
140
|
+
this.instanceCount = 0;
|
|
141
|
+
if (this.boundingBox) this.boundingBox.makeEmpty();
|
|
142
|
+
if (this.boundingSphere) this.boundingSphere.radius = 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
addText(text, slugData, options = {}) {
|
|
146
|
+
const ascender = slugData.ascender || 0;
|
|
147
|
+
const descender = slugData.descender || 0;
|
|
148
|
+
const lineGap = slugData.lineGap || 0;
|
|
149
|
+
|
|
150
|
+
const defaultLineHeight = slugData.unitsPerEm > 0
|
|
151
|
+
? (ascender - descender + lineGap)
|
|
152
|
+
: 2000;
|
|
153
|
+
|
|
154
|
+
const {
|
|
155
|
+
fontScale = 0.5,
|
|
156
|
+
lineHeight = defaultLineHeight * fontScale,
|
|
157
|
+
startX = 0,
|
|
158
|
+
startY = 0,
|
|
159
|
+
justify = 'left' // 'left', 'center', 'right'
|
|
160
|
+
} = options;
|
|
161
|
+
|
|
162
|
+
const lines = text.split('\n');
|
|
163
|
+
let currentY = startY;
|
|
164
|
+
|
|
165
|
+
for (const line of lines) {
|
|
166
|
+
let lineWidth = 0;
|
|
167
|
+
|
|
168
|
+
// First pass: Measure exact physical width of the line for justification offsets
|
|
169
|
+
let j = 0;
|
|
170
|
+
while (j < line.length) {
|
|
171
|
+
const charCode = line.codePointAt(j);
|
|
172
|
+
j += charCode > 0xFFFF ? 2 : 1;
|
|
173
|
+
let data = slugData.codePoints.get(charCode) || slugData.codePoints.get(-1);
|
|
174
|
+
if (data) {
|
|
175
|
+
lineWidth += data.advanceWidth * fontScale;
|
|
176
|
+
} else if (line[j-1] === ' ') {
|
|
177
|
+
lineWidth += 600 * fontScale; // Fallback space width
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let currentX = startX;
|
|
182
|
+
if (justify === 'center') currentX -= lineWidth / 2.0;
|
|
183
|
+
else if (justify === 'right') currentX -= lineWidth;
|
|
184
|
+
|
|
185
|
+
// Second pass: Inject the geometric layout frames into the instanced buffers
|
|
186
|
+
let k = 0;
|
|
187
|
+
while (k < line.length) {
|
|
188
|
+
const charCode = line.codePointAt(k);
|
|
189
|
+
k += charCode > 0xFFFF ? 2 : 1;
|
|
190
|
+
let data = slugData.codePoints.get(charCode) || slugData.codePoints.get(-1);
|
|
191
|
+
|
|
192
|
+
if (data) {
|
|
193
|
+
if (data.width > 0 && data.height > 0) {
|
|
194
|
+
const quadW = data.width * fontScale;
|
|
195
|
+
const quadH = data.height * fontScale;
|
|
196
|
+
const px = currentX + data.bearingX * fontScale;
|
|
197
|
+
const py = currentY + data.bearingY * fontScale;
|
|
198
|
+
|
|
199
|
+
this.addGlyph(data, px, py, quadW, quadH, 0, 0); // Display size dropped in PBR pass
|
|
200
|
+
}
|
|
201
|
+
currentX += data.advanceWidth * fontScale;
|
|
202
|
+
} else if (line[k-1] === ' ') {
|
|
203
|
+
currentX += 600 * fontScale;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
currentY -= lineHeight;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
this.updateBuffers();
|
|
210
|
+
}
|
|
211
|
+
}
|