@navaramap/three 0.0.3 → 0.0.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 +7 -7
- package/dist/assets/fontWorker-OwvKiQ8o.js +2 -0
- package/dist/assets/fontWorker-OwvKiQ8o.js.map +1 -0
- package/dist/assets/{index-ywHSGImj.js → index-TKhRxurv.js} +2 -2
- package/dist/assets/index-TKhRxurv.js.map +1 -0
- package/dist/assets/{navara_wasm_font_worker_bg-Du9nb2Pn.wasm → navara_wasm_font_worker_bg-Kk06ArRB.wasm} +0 -0
- package/dist/assets/{navara_wasm_worker_bg-Xadu67fw.wasm → navara_wasm_worker_bg-BZjLKf4i.wasm} +0 -0
- package/dist/index.d.ts +351 -284
- package/dist/index.js +5394 -5470
- package/dist/index.js.map +1 -1
- package/dist/index.umd.cjs +287 -235
- package/dist/index.umd.cjs.map +1 -1
- package/package.json +12 -12
- package/src/ShadowMapViewers.ts +1 -1
- package/src/atmosphere.test.ts +227 -28
- package/src/atmosphere.ts +219 -55
- package/src/constants/assets.ts +63 -13
- package/src/declutter/DeclutterManager.ts +1 -1
- package/src/declutter/types.ts +1 -1
- package/src/evaluations/FeatureEvaluator.test.ts +152 -0
- package/src/evaluations/FeatureEvaluator.ts +73 -2
- package/src/event/features/billboard.ts +1 -0
- package/src/event/features/model.ts +1 -1
- package/src/event/features/point.ts +1 -0
- package/src/event/index.ts +1 -1
- package/src/index.iife.ts +3 -3
- package/src/index.ts +10 -12
- package/src/layers/effect/README.md +1 -1
- package/src/material/enhancer/instancedSprite/instancedSpriteBaseEnhancer/mutates.test.ts +2 -2
- package/src/material/enhancer/instancedSprite/instancedSpriteBaseEnhancer/mutates.ts +1 -1
- package/src/material/enhancer/polygon/polygonBaseEnhancer/mutates.ts +1 -1
- package/src/material/enhancer/polyline/polylineBaseEnhancer/mutates.ts +1 -1
- package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/index.ts +10 -7
- package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/mutates.test.ts +74 -81
- package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/mutates.ts +19 -86
- package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/shader.test.ts +77 -0
- package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/shader.ts +9 -1
- package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/state.test.ts +15 -19
- package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/state.ts +0 -13
- package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/types.ts +46 -48
- package/src/mesh/constants.ts +22 -0
- package/src/mesh/index.ts +1 -1
- package/src/mesh/model.ts +6 -0
- package/src/mesh/polygon.ts +6 -0
- package/src/mesh/polyline.ts +6 -0
- package/src/mesh/rtcRteHelper.ts +1 -1
- package/src/mesh/sdfText/batchedSdfText.ts +1223 -0
- package/src/mesh/sdfText/glyphBuffers.ts +222 -0
- package/src/mesh/sdfText/glyphSlots.test.ts +151 -0
- package/src/mesh/sdfText/glyphSlots.ts +98 -0
- package/src/mesh/sdfText/index.ts +10 -0
- package/src/mesh/sdfText/labelData.test.ts +178 -0
- package/src/mesh/sdfText/labelData.ts +187 -0
- package/src/mesh/{sdfText.test.ts → sdfText/layout.test.ts} +1 -1
- package/src/mesh/sdfText/layout.ts +306 -0
- package/src/mesh/sprite/instancedSprite.ts +33 -1
- package/src/utils/screen.ts +1 -1
- package/dist/assets/fontWorker-wz61edjR.js +0 -2
- package/dist/assets/fontWorker-wz61edjR.js.map +0 -1
- package/dist/assets/index-ywHSGImj.js.map +0 -1
- package/src/mesh/batchedSdfText.ts +0 -708
- package/src/mesh/sdfText.ts +0 -1189
- /package/dist/{draco_decoder.wasm → assets/draco_decoder-C32yEggz.wasm} +0 -0
- /package/dist/{draco_decoder2.wasm → assets/draco_decoder-Z1_iN-Ht.wasm} +0 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BufferAttribute,
|
|
3
|
+
InstancedBufferAttribute,
|
|
4
|
+
InstancedBufferGeometry,
|
|
5
|
+
} from "three";
|
|
6
|
+
|
|
7
|
+
import type { GlyphQuad } from "./layout";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The glyph-instance geometry of a batched text mesh: a shared unit quad plus
|
|
11
|
+
* one instance per glyph, drawn in a single call for every label in the batch.
|
|
12
|
+
*
|
|
13
|
+
* Instances are grouped into contiguous per-label runs handed out by
|
|
14
|
+
* `GlyphSlotAllocator`, so writing a label pushes only that run to the GPU via
|
|
15
|
+
* `addUpdateRange`. Growth doubles the buffers, which is the only path that
|
|
16
|
+
* re-uploads everything.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Per-instance `glyphKind` values. Must match the `GLYPH_KIND_*` defines in
|
|
20
|
+
* `shaders/glsl/sdfText.vert.glsl`. */
|
|
21
|
+
export const GlyphKind = {
|
|
22
|
+
SDF: 0,
|
|
23
|
+
COLOR: 1,
|
|
24
|
+
BACKGROUND: 2,
|
|
25
|
+
/** A slot inside a label's run that its text doesn't use, or a hole left by
|
|
26
|
+
* a freed run. Culled in the vertex shader. */
|
|
27
|
+
EMPTY: 3,
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
/** Instance slots a freshly-created batch reserves. */
|
|
31
|
+
const INITIAL_CAPACITY = 64;
|
|
32
|
+
|
|
33
|
+
/** Attribute name and component count, in the order they are (re)created. */
|
|
34
|
+
const INSTANCE_ATTRIBUTES = [
|
|
35
|
+
["glyphOffset", 2],
|
|
36
|
+
["glyphSize", 2],
|
|
37
|
+
["glyphUvRect", 4],
|
|
38
|
+
["glyphKind", 1],
|
|
39
|
+
["labelIndex", 1],
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
/** The backing arrays, one per instance attribute. */
|
|
43
|
+
type InstanceArrays = {
|
|
44
|
+
glyphOffset: Float32Array;
|
|
45
|
+
glyphSize: Float32Array;
|
|
46
|
+
glyphUvRect: Float32Array;
|
|
47
|
+
glyphKind: Float32Array;
|
|
48
|
+
labelIndex: Float32Array;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export class GlyphBuffers {
|
|
52
|
+
readonly geometry: InstancedBufferGeometry;
|
|
53
|
+
private _capacity: number;
|
|
54
|
+
private _arrays: InstanceArrays;
|
|
55
|
+
/** The live instance attributes, paired with their component count.
|
|
56
|
+
* Held directly so partial uploads don't go through `getAttribute`, whose
|
|
57
|
+
* union return type doesn't expose `addUpdateRange`. */
|
|
58
|
+
private _attributes: [InstancedBufferAttribute, number][] = [];
|
|
59
|
+
|
|
60
|
+
constructor(initialCapacity = INITIAL_CAPACITY) {
|
|
61
|
+
this._capacity = Math.max(1, initialCapacity);
|
|
62
|
+
this.geometry = new InstancedBufferGeometry();
|
|
63
|
+
|
|
64
|
+
// Unit quad: 2 triangles, 6 vertices.
|
|
65
|
+
// prettier-ignore
|
|
66
|
+
const positions = new Float32Array([
|
|
67
|
+
-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0,
|
|
68
|
+
-0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0,
|
|
69
|
+
]);
|
|
70
|
+
// prettier-ignore
|
|
71
|
+
const uvs = new Float32Array([
|
|
72
|
+
0, 0, 1, 0, 1, 1,
|
|
73
|
+
0, 0, 1, 1, 0, 1,
|
|
74
|
+
]);
|
|
75
|
+
this.geometry.setAttribute("position", new BufferAttribute(positions, 3));
|
|
76
|
+
this.geometry.setAttribute("uv", new BufferAttribute(uvs, 2));
|
|
77
|
+
|
|
78
|
+
this._arrays = this._allocate(this._capacity);
|
|
79
|
+
this.geometry.instanceCount = 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get capacity(): number {
|
|
83
|
+
return this._capacity;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Instances the draw call covers — the allocator's high-water mark. */
|
|
87
|
+
setInstanceCount(count: number): void {
|
|
88
|
+
this.geometry.instanceCount = count;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Grow to hold at least `slotCount` instances, preserving existing data.
|
|
93
|
+
* No-op when it already fits.
|
|
94
|
+
*/
|
|
95
|
+
ensureCapacity(slotCount: number): void {
|
|
96
|
+
if (slotCount <= this._capacity) return;
|
|
97
|
+
let capacity = this._capacity;
|
|
98
|
+
while (capacity < slotCount) capacity *= 2;
|
|
99
|
+
|
|
100
|
+
const previousCapacity = this._capacity;
|
|
101
|
+
this._capacity = capacity;
|
|
102
|
+
this._arrays = this._allocate(capacity, this._arrays, previousCapacity);
|
|
103
|
+
|
|
104
|
+
// three caches the instance ceiling on the first VAO bind and only clears
|
|
105
|
+
// it when the geometry is disposed, then clamps every draw with
|
|
106
|
+
// `min(geometry.instanceCount, geometry._maxInstanceCount)`. Replacing the
|
|
107
|
+
// instance attributes with larger ones does NOT refresh it, so without this
|
|
108
|
+
// the batch stays pinned to whatever capacity it had when it was first
|
|
109
|
+
// rendered — labels added later silently never draw. Dropping the cached
|
|
110
|
+
// value makes three recompute it from the new attributes on the next bind.
|
|
111
|
+
delete (
|
|
112
|
+
this.geometry as InstancedBufferGeometry &
|
|
113
|
+
Partial<Record<"_maxInstanceCount", number>>
|
|
114
|
+
)._maxInstanceCount;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Write a label's glyph quads into its run and blank the rest of it, then
|
|
119
|
+
* queue a partial upload of exactly that range.
|
|
120
|
+
*
|
|
121
|
+
* `runCapacity` is the slots the label owns; anything past the quads becomes
|
|
122
|
+
* {@link GlyphKind.EMPTY}, so an over-allocated run — or a label whose text
|
|
123
|
+
* just got shorter — draws nothing in the leftover slots.
|
|
124
|
+
*/
|
|
125
|
+
writeRun(
|
|
126
|
+
start: number,
|
|
127
|
+
runCapacity: number,
|
|
128
|
+
labelIndex: number,
|
|
129
|
+
quads: GlyphQuad[],
|
|
130
|
+
withBackground: boolean,
|
|
131
|
+
): void {
|
|
132
|
+
const {
|
|
133
|
+
glyphOffset,
|
|
134
|
+
glyphSize,
|
|
135
|
+
glyphUvRect,
|
|
136
|
+
glyphKind,
|
|
137
|
+
labelIndex: label,
|
|
138
|
+
} = this._arrays;
|
|
139
|
+
|
|
140
|
+
// The background occupies the run's first slot so it is drawn before the
|
|
141
|
+
// label's glyphs — the fragment shader's outline-seam fix depends on that
|
|
142
|
+
// ordering (see sdfText.frag.glsl).
|
|
143
|
+
let slot = start;
|
|
144
|
+
if (withBackground) {
|
|
145
|
+
glyphKind[slot] = GlyphKind.BACKGROUND;
|
|
146
|
+
label[slot] = labelIndex;
|
|
147
|
+
// The background derives its quad from the label's box, not from these.
|
|
148
|
+
glyphOffset[slot * 2] = 0;
|
|
149
|
+
glyphOffset[slot * 2 + 1] = 0;
|
|
150
|
+
glyphSize[slot * 2] = 0;
|
|
151
|
+
glyphSize[slot * 2 + 1] = 0;
|
|
152
|
+
slot++;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const q of quads) {
|
|
156
|
+
glyphKind[slot] = q.isColor ? GlyphKind.COLOR : GlyphKind.SDF;
|
|
157
|
+
label[slot] = labelIndex;
|
|
158
|
+
glyphOffset[slot * 2] = q.offsetEmX;
|
|
159
|
+
glyphOffset[slot * 2 + 1] = q.offsetEmY;
|
|
160
|
+
glyphSize[slot * 2] = q.sizeEmX;
|
|
161
|
+
glyphSize[slot * 2 + 1] = q.sizeEmY;
|
|
162
|
+
glyphUvRect[slot * 4] = q.uvL;
|
|
163
|
+
glyphUvRect[slot * 4 + 1] = q.uvT;
|
|
164
|
+
glyphUvRect[slot * 4 + 2] = q.uvR;
|
|
165
|
+
glyphUvRect[slot * 4 + 3] = q.uvB;
|
|
166
|
+
slot++;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (; slot < start + runCapacity; slot++) {
|
|
170
|
+
glyphKind[slot] = GlyphKind.EMPTY;
|
|
171
|
+
label[slot] = labelIndex;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
this._queueUpload(start, runCapacity);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Blank a run so its slots draw nothing until reallocated. */
|
|
178
|
+
clearRun(start: number, runCapacity: number): void {
|
|
179
|
+
this._arrays.glyphKind.fill(GlyphKind.EMPTY, start, start + runCapacity);
|
|
180
|
+
this._queueUpload(start, runCapacity);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
dispose(): void {
|
|
184
|
+
this.geometry.dispose();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Queue a `bufferSubData` covering `[start, start + count)` instances on
|
|
188
|
+
* every instance attribute. */
|
|
189
|
+
private _queueUpload(start: number, count: number): void {
|
|
190
|
+
for (const [attr, itemSize] of this._attributes) {
|
|
191
|
+
// addUpdateRange takes array-element units, not instance units.
|
|
192
|
+
attr.addUpdateRange(start * itemSize, count * itemSize);
|
|
193
|
+
attr.needsUpdate = true;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* (Re)create the instance attributes at `capacity`, copying `previous` in.
|
|
199
|
+
* Slots past the copied region start as EMPTY — a zero-filled array would
|
|
200
|
+
* otherwise read as `GlyphKind.SDF` and draw garbage quads.
|
|
201
|
+
*/
|
|
202
|
+
private _allocate(
|
|
203
|
+
capacity: number,
|
|
204
|
+
previous?: InstanceArrays,
|
|
205
|
+
previousCapacity = 0,
|
|
206
|
+
): InstanceArrays {
|
|
207
|
+
const arrays = {} as InstanceArrays;
|
|
208
|
+
this._attributes = [];
|
|
209
|
+
for (const [name, itemSize] of INSTANCE_ATTRIBUTES) {
|
|
210
|
+
const array = new Float32Array(capacity * itemSize);
|
|
211
|
+
if (previous) array.set(previous[name]);
|
|
212
|
+
if (name === "glyphKind") {
|
|
213
|
+
array.fill(GlyphKind.EMPTY, previousCapacity, capacity);
|
|
214
|
+
}
|
|
215
|
+
arrays[name] = array;
|
|
216
|
+
const attr = new InstancedBufferAttribute(array, itemSize);
|
|
217
|
+
this.geometry.setAttribute(name, attr);
|
|
218
|
+
this._attributes.push([attr, itemSize]);
|
|
219
|
+
}
|
|
220
|
+
return arrays;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { GlyphSlotAllocator, capacityFor } from "./glyphSlots";
|
|
4
|
+
|
|
5
|
+
describe("capacityFor", () => {
|
|
6
|
+
it("floors at the minimum run size", () => {
|
|
7
|
+
expect(capacityFor(0)).toBe(4);
|
|
8
|
+
expect(capacityFor(1)).toBe(4);
|
|
9
|
+
expect(capacityFor(4)).toBe(4);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("rounds up to the next power of two", () => {
|
|
13
|
+
expect(capacityFor(5)).toBe(8);
|
|
14
|
+
expect(capacityFor(8)).toBe(8);
|
|
15
|
+
expect(capacityFor(9)).toBe(16);
|
|
16
|
+
expect(capacityFor(1000)).toBe(1024);
|
|
17
|
+
expect(capacityFor(1024)).toBe(1024);
|
|
18
|
+
expect(capacityFor(1025)).toBe(2048);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("GlyphSlotAllocator", () => {
|
|
23
|
+
it("hands out non-overlapping runs and tracks the high-water mark", () => {
|
|
24
|
+
const a = new GlyphSlotAllocator();
|
|
25
|
+
expect(a.highWater).toBe(0);
|
|
26
|
+
|
|
27
|
+
const first = a.alloc(3); // → capacity 4
|
|
28
|
+
const second = a.alloc(10); // → capacity 16
|
|
29
|
+
|
|
30
|
+
expect(first).toEqual({ start: 0, capacity: 4 });
|
|
31
|
+
expect(second).toEqual({ start: 4, capacity: 16 });
|
|
32
|
+
expect(a.highWater).toBe(20);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// The core of the design: a label whose text changes length must keep its
|
|
36
|
+
// slots so the update is an in-place write, not a buffer rebuild.
|
|
37
|
+
describe("realloc within a size class", () => {
|
|
38
|
+
it("keeps the same run when the glyph count stays in class", () => {
|
|
39
|
+
const a = new GlyphSlotAllocator();
|
|
40
|
+
const run = a.alloc(5); // capacity 8
|
|
41
|
+
const after = a.realloc(run, 7);
|
|
42
|
+
|
|
43
|
+
expect(after).toBe(run);
|
|
44
|
+
expect(a.highWater).toBe(8);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("keeps the same run when the text gets shorter", () => {
|
|
48
|
+
const a = new GlyphSlotAllocator();
|
|
49
|
+
const run = a.alloc(8); // capacity 8
|
|
50
|
+
const after = a.realloc(run, 6);
|
|
51
|
+
|
|
52
|
+
expect(after).toBe(run);
|
|
53
|
+
expect(a.highWater).toBe(8);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("does not shrink below the minimum class", () => {
|
|
57
|
+
const a = new GlyphSlotAllocator();
|
|
58
|
+
const run = a.alloc(4);
|
|
59
|
+
// "ab" → "a" stays in the floor class, so nothing moves.
|
|
60
|
+
expect(a.realloc(run, 1)).toBe(run);
|
|
61
|
+
expect(a.highWater).toBe(4);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("realloc across size classes", () => {
|
|
66
|
+
it("moves the run and recycles the old slots", () => {
|
|
67
|
+
const a = new GlyphSlotAllocator();
|
|
68
|
+
const run = a.alloc(4); // capacity 4 at 0
|
|
69
|
+
const grown = a.realloc(run, 9); // needs capacity 16
|
|
70
|
+
|
|
71
|
+
expect(grown.capacity).toBe(16);
|
|
72
|
+
expect(grown.start).not.toBe(run.start);
|
|
73
|
+
expect(a.highWater).toBe(20);
|
|
74
|
+
|
|
75
|
+
// The vacated capacity-4 run is reusable.
|
|
76
|
+
expect(a.alloc(4)).toEqual({ start: 0, capacity: 4 });
|
|
77
|
+
expect(a.highWater).toBe(20);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("reuses a freed run of the matching class before growing", () => {
|
|
81
|
+
const a = new GlyphSlotAllocator();
|
|
82
|
+
const first = a.alloc(8);
|
|
83
|
+
a.alloc(8);
|
|
84
|
+
expect(a.highWater).toBe(16);
|
|
85
|
+
|
|
86
|
+
a.free(first);
|
|
87
|
+
expect(a.alloc(6)).toEqual({ start: first.start, capacity: 8 });
|
|
88
|
+
expect(a.highWater).toBe(16);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("does not serve a request from a different class's free list", () => {
|
|
92
|
+
const a = new GlyphSlotAllocator();
|
|
93
|
+
a.free(a.alloc(4));
|
|
94
|
+
expect(a.highWater).toBe(4);
|
|
95
|
+
|
|
96
|
+
// A capacity-16 request can't use the freed capacity-4 run.
|
|
97
|
+
expect(a.alloc(16)).toEqual({ start: 4, capacity: 16 });
|
|
98
|
+
expect(a.highWater).toBe(20);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("allocates a fresh run when reallocating from null", () => {
|
|
103
|
+
const a = new GlyphSlotAllocator();
|
|
104
|
+
expect(a.realloc(null, 2)).toEqual({ start: 0, capacity: 4 });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("reset clears the free lists and the high-water mark", () => {
|
|
108
|
+
const a = new GlyphSlotAllocator();
|
|
109
|
+
a.free(a.alloc(8));
|
|
110
|
+
a.reset();
|
|
111
|
+
|
|
112
|
+
expect(a.highWater).toBe(0);
|
|
113
|
+
expect(a.alloc(8)).toEqual({ start: 0, capacity: 8 });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("never overlaps runs across a churn of allocs, frees and reallocs", () => {
|
|
117
|
+
const a = new GlyphSlotAllocator();
|
|
118
|
+
const live = new Map<number, { start: number; capacity: number }>();
|
|
119
|
+
|
|
120
|
+
// Deterministic pseudo-random churn — a cheap stand-in for the real
|
|
121
|
+
// workload where labels appear, change text and disappear.
|
|
122
|
+
let seed = 42;
|
|
123
|
+
const next = (n: number) => {
|
|
124
|
+
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
|
125
|
+
return seed % n;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
for (let step = 0; step < 500; step++) {
|
|
129
|
+
const id = next(40);
|
|
130
|
+
const existing = live.get(id);
|
|
131
|
+
const op = next(3);
|
|
132
|
+
|
|
133
|
+
if (op === 0 && existing) {
|
|
134
|
+
a.free(existing);
|
|
135
|
+
live.delete(id);
|
|
136
|
+
} else {
|
|
137
|
+
live.set(id, a.realloc(existing ?? null, 1 + next(30)));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Every live run must stay inside the high-water mark and disjoint.
|
|
141
|
+
const occupied = new Set<number>();
|
|
142
|
+
for (const run of live.values()) {
|
|
143
|
+
expect(run.start + run.capacity).toBeLessThanOrEqual(a.highWater);
|
|
144
|
+
for (let s = run.start; s < run.start + run.capacity; s++) {
|
|
145
|
+
expect(occupied.has(s)).toBe(false);
|
|
146
|
+
occupied.add(s);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suballocator for the glyph-instance array of a batched text mesh.
|
|
3
|
+
*
|
|
4
|
+
* Every label owns one contiguous run of instance slots, so a text change
|
|
5
|
+
* rewrites only that run (one `addUpdateRange` on each instance attribute)
|
|
6
|
+
* instead of the whole buffer. Runs are rounded up to power-of-two size
|
|
7
|
+
* classes, which is what makes the common case free: re-shaping a label to a
|
|
8
|
+
* similar length ({@link realloc}) keeps the same `start`, so the caller
|
|
9
|
+
* overwrites in place and marks the leftover tail as empty slots.
|
|
10
|
+
*
|
|
11
|
+
* Freed runs go back to a per-class free list and are handed out again before
|
|
12
|
+
* the high-water mark advances. Worst-case internal fragmentation is 2x, which
|
|
13
|
+
* is the price of never having to relocate a run that merely changed length.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Smallest run handed out: one background quad plus a few glyphs. Below this
|
|
17
|
+
* the size classes churn without saving meaningful memory. */
|
|
18
|
+
const MIN_RUN_CAPACITY = 4;
|
|
19
|
+
|
|
20
|
+
/** A label's contiguous span of glyph-instance slots. */
|
|
21
|
+
export type GlyphRun = {
|
|
22
|
+
/** First instance index owned by the label. */
|
|
23
|
+
readonly start: number;
|
|
24
|
+
/** Slots reserved — always >= the glyph count the run was sized for. */
|
|
25
|
+
readonly capacity: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Capacity class for `count` slots: the next power of two, floored at
|
|
29
|
+
* {@link MIN_RUN_CAPACITY}. Exported for tests. */
|
|
30
|
+
export function capacityFor(count: number): number {
|
|
31
|
+
if (count <= MIN_RUN_CAPACITY) return MIN_RUN_CAPACITY;
|
|
32
|
+
// 2^ceil(log2(count)), computed without floating-point log to stay exact for
|
|
33
|
+
// the large counts a pathological label could reach.
|
|
34
|
+
let capacity = MIN_RUN_CAPACITY;
|
|
35
|
+
while (capacity < count) capacity *= 2;
|
|
36
|
+
return capacity;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class GlyphSlotAllocator {
|
|
40
|
+
/** Free run starts, keyed by capacity. Each list holds runs of exactly that
|
|
41
|
+
* capacity, so a request is served without searching. */
|
|
42
|
+
private _free = new Map<number, number[]>();
|
|
43
|
+
private _highWater = 0;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One past the highest slot ever allocated — the value to assign to
|
|
47
|
+
* `geometry.instanceCount`. Slots below it that no label owns are holes and
|
|
48
|
+
* must be filled with empty-kind instances by the caller.
|
|
49
|
+
*/
|
|
50
|
+
get highWater(): number {
|
|
51
|
+
return this._highWater;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reserve a run large enough for `count` glyph instances. */
|
|
55
|
+
alloc(count: number): GlyphRun {
|
|
56
|
+
const capacity = capacityFor(count);
|
|
57
|
+
|
|
58
|
+
const pool = this._free.get(capacity);
|
|
59
|
+
const reused = pool?.pop();
|
|
60
|
+
if (reused !== undefined) {
|
|
61
|
+
return { start: reused, capacity };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const start = this._highWater;
|
|
65
|
+
this._highWater += capacity;
|
|
66
|
+
return { start, capacity };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Return a run to its size class's free list. */
|
|
70
|
+
free(run: GlyphRun): void {
|
|
71
|
+
let pool = this._free.get(run.capacity);
|
|
72
|
+
if (!pool) {
|
|
73
|
+
pool = [];
|
|
74
|
+
this._free.set(run.capacity, pool);
|
|
75
|
+
}
|
|
76
|
+
pool.push(run.start);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resize a label's run to hold `count` glyphs.
|
|
81
|
+
*
|
|
82
|
+
* Returns the **same run** whenever `count` still fits its capacity class —
|
|
83
|
+
* the case that makes in-place text updates cheap. Otherwise the old run is
|
|
84
|
+
* freed and a new one allocated, and the caller must rewrite the run's
|
|
85
|
+
* attribute data at the new `start`.
|
|
86
|
+
*/
|
|
87
|
+
realloc(run: GlyphRun | null, count: number): GlyphRun {
|
|
88
|
+
if (run && capacityFor(count) === run.capacity) return run;
|
|
89
|
+
if (run) this.free(run);
|
|
90
|
+
return this.alloc(count);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Drop all bookkeeping. The caller is responsible for the backing buffers. */
|
|
94
|
+
reset(): void {
|
|
95
|
+
this._free.clear();
|
|
96
|
+
this._highWater = 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { LABEL_ROWS, LabelDataTexture, LabelRow } from "./labelData";
|
|
4
|
+
|
|
5
|
+
/** The texel index the shader's `readLabel(slot, row)` resolves to. */
|
|
6
|
+
const texelIndex = (slot: number, row: number) => slot * LABEL_ROWS + row;
|
|
7
|
+
|
|
8
|
+
const dataOf = (store: LabelDataTexture) =>
|
|
9
|
+
store.texture.image.data as unknown as Float32Array;
|
|
10
|
+
|
|
11
|
+
describe("LabelDataTexture", () => {
|
|
12
|
+
it("creates a float RGBA texture sized to hold the initial capacity", () => {
|
|
13
|
+
const store = new LabelDataTexture(16);
|
|
14
|
+
const { width, height } = store.texture.image;
|
|
15
|
+
|
|
16
|
+
expect(width * height).toBeGreaterThanOrEqual(16 * LABEL_ROWS);
|
|
17
|
+
expect(store.size.x).toBe(width);
|
|
18
|
+
expect(store.size.y).toBe(height);
|
|
19
|
+
expect(dataOf(store).length).toBe(width * height * 4);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("writes a row at the address the shader reads", () => {
|
|
23
|
+
const store = new LabelDataTexture(8);
|
|
24
|
+
store.setRow(3, LabelRow.COLOR_OPACITY, 0.25, 0.5, 0.75, 0.5);
|
|
25
|
+
|
|
26
|
+
const base = texelIndex(3, LabelRow.COLOR_OPACITY) * 4;
|
|
27
|
+
expect(Array.from(dataOf(store).slice(base, base + 4))).toEqual([
|
|
28
|
+
0.25, 0.5, 0.75, 0.5,
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("keeps rows of the same label independent", () => {
|
|
33
|
+
const store = new LabelDataTexture(4);
|
|
34
|
+
store.setRow(2, LabelRow.BOX, 1, 2, 3, 4);
|
|
35
|
+
store.setRow(2, LabelRow.STATE, 5, 6, 7, 8);
|
|
36
|
+
|
|
37
|
+
const box = texelIndex(2, LabelRow.BOX) * 4;
|
|
38
|
+
const state = texelIndex(2, LabelRow.STATE) * 4;
|
|
39
|
+
expect(Array.from(dataOf(store).slice(box, box + 4))).toEqual([1, 2, 3, 4]);
|
|
40
|
+
expect(Array.from(dataOf(store).slice(state, state + 4))).toEqual([
|
|
41
|
+
5, 6, 7, 8,
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("setComponent touches only its channel", () => {
|
|
46
|
+
const store = new LabelDataTexture(4);
|
|
47
|
+
store.setRow(1, LabelRow.STATE, 1, 2, 3, 4);
|
|
48
|
+
store.setComponent(1, LabelRow.STATE, 0, 0.5);
|
|
49
|
+
|
|
50
|
+
const base = texelIndex(1, LabelRow.STATE) * 4;
|
|
51
|
+
expect(Array.from(dataOf(store).slice(base, base + 4))).toEqual([
|
|
52
|
+
0.5, 2, 3, 4,
|
|
53
|
+
]);
|
|
54
|
+
expect(store.getComponent(1, LabelRow.STATE, 0)).toBe(0.5);
|
|
55
|
+
expect(store.getComponent(1, LabelRow.STATE, 2)).toBe(3);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("clearSlot zeroes exactly one label's rows", () => {
|
|
59
|
+
const store = new LabelDataTexture(4);
|
|
60
|
+
store.setRow(0, LabelRow.BOX, 9, 9, 9, 9);
|
|
61
|
+
store.setRow(1, LabelRow.BOX, 7, 7, 7, 7);
|
|
62
|
+
store.setRow(2, LabelRow.BOX, 5, 5, 5, 5);
|
|
63
|
+
|
|
64
|
+
store.clearSlot(1);
|
|
65
|
+
|
|
66
|
+
expect(store.getComponent(0, LabelRow.BOX, 0)).toBe(9);
|
|
67
|
+
expect(store.getComponent(1, LabelRow.BOX, 0)).toBe(0);
|
|
68
|
+
expect(store.getComponent(2, LabelRow.BOX, 0)).toBe(5);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("ensureCapacity", () => {
|
|
72
|
+
it("is a no-op while the request fits", () => {
|
|
73
|
+
const store = new LabelDataTexture(16);
|
|
74
|
+
const before = store.texture;
|
|
75
|
+
|
|
76
|
+
expect(store.ensureCapacity(16)).toBe(false);
|
|
77
|
+
expect(store.texture).toBe(before);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// The allocation is padded out to whole texture rows, and that padding is
|
|
81
|
+
// usable space — 16 labels round up to 2 rows, which address 25. Growing
|
|
82
|
+
// at 17 would mean an allocate + copy + texture recreate (and a full GPU
|
|
83
|
+
// re-upload) while free slots were still sitting in the buffer.
|
|
84
|
+
it("uses the row padding before growing", () => {
|
|
85
|
+
const store = new LabelDataTexture(16);
|
|
86
|
+
const before = store.texture;
|
|
87
|
+
|
|
88
|
+
expect(store.capacity).toBeGreaterThan(16);
|
|
89
|
+
expect(store.ensureCapacity(17)).toBe(false);
|
|
90
|
+
expect(store.ensureCapacity(store.capacity)).toBe(false);
|
|
91
|
+
expect(store.texture).toBe(before);
|
|
92
|
+
|
|
93
|
+
// ...and the padded slots are genuinely addressable.
|
|
94
|
+
const last = store.capacity - 1;
|
|
95
|
+
store.setRow(last, LabelRow.BOX, 1, 2, 3, 4);
|
|
96
|
+
expect(store.getComponent(last, LabelRow.BOX, 3)).toBe(4);
|
|
97
|
+
expect(texelIndex(last, LABEL_ROWS - 1) * 4 + 3).toBeLessThan(
|
|
98
|
+
dataOf(store).length,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// One past the padded capacity is what actually triggers the grow.
|
|
102
|
+
expect(store.ensureCapacity(store.capacity + 1)).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("reports a capacity the texture can really back", () => {
|
|
106
|
+
for (const requested of [1, 2, 16, 32, 40, 64, 128]) {
|
|
107
|
+
const store = new LabelDataTexture(requested);
|
|
108
|
+
const { width, height } = store.texture.image;
|
|
109
|
+
expect(store.capacity).toBeGreaterThanOrEqual(requested);
|
|
110
|
+
// Every row of every label must land inside the texture.
|
|
111
|
+
expect(store.capacity * LABEL_ROWS).toBeLessThanOrEqual(width * height);
|
|
112
|
+
expect(dataOf(store).length).toBe(width * height * 4);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("grows, replaces the texture, and preserves existing data", () => {
|
|
117
|
+
const store = new LabelDataTexture(2);
|
|
118
|
+
store.setRow(0, LabelRow.BOX, 1, 2, 3, 4);
|
|
119
|
+
store.setRow(1, LabelRow.STATE, 5, 6, 7, 8);
|
|
120
|
+
const before = store.texture;
|
|
121
|
+
|
|
122
|
+
expect(store.ensureCapacity(40)).toBe(true);
|
|
123
|
+
expect(store.texture).not.toBe(before);
|
|
124
|
+
expect(store.capacity).toBeGreaterThanOrEqual(40);
|
|
125
|
+
|
|
126
|
+
// Addresses are stable across a grow: the width is fixed, so only the
|
|
127
|
+
// height changes and previously written texels keep their index.
|
|
128
|
+
expect(store.getComponent(0, LabelRow.BOX, 1)).toBe(2);
|
|
129
|
+
expect(store.getComponent(1, LabelRow.STATE, 3)).toBe(8);
|
|
130
|
+
|
|
131
|
+
// ...and the new tail is addressable.
|
|
132
|
+
store.setRow(39, LabelRow.BOX, 11, 12, 13, 14);
|
|
133
|
+
expect(store.getComponent(39, LabelRow.BOX, 0)).toBe(11);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("reports the grown dimensions through size", () => {
|
|
137
|
+
const store = new LabelDataTexture(2);
|
|
138
|
+
const heightBefore = store.size.y;
|
|
139
|
+
|
|
140
|
+
store.ensureCapacity(500);
|
|
141
|
+
|
|
142
|
+
expect(store.size.y).toBeGreaterThan(heightBefore);
|
|
143
|
+
expect(store.size.x).toBe(store.texture.image.width);
|
|
144
|
+
expect(store.size.y).toBe(store.texture.image.height);
|
|
145
|
+
expect(dataOf(store).length).toBe(store.size.x * store.size.y * 4);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("keeps the highest addressable slot inside the buffer", () => {
|
|
149
|
+
const store = new LabelDataTexture(3);
|
|
150
|
+
store.ensureCapacity(100);
|
|
151
|
+
|
|
152
|
+
const last = texelIndex(store.capacity - 1, LABEL_ROWS - 1) * 4 + 3;
|
|
153
|
+
expect(last).toBeLessThan(dataOf(store).length);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// `needsUpdate` is a write-only setter that bumps `version`, so the upload
|
|
158
|
+
// request is only observable through the version counter.
|
|
159
|
+
it("flags the texture for upload on every write", () => {
|
|
160
|
+
const store = new LabelDataTexture(4);
|
|
161
|
+
|
|
162
|
+
let version = store.texture.version;
|
|
163
|
+
store.setRow(0, LabelRow.BOX, 1, 1, 1, 1);
|
|
164
|
+
expect(store.texture.version).toBeGreaterThan(version);
|
|
165
|
+
|
|
166
|
+
version = store.texture.version;
|
|
167
|
+
store.setComponent(0, LabelRow.STATE, 0, 1);
|
|
168
|
+
expect(store.texture.version).toBeGreaterThan(version);
|
|
169
|
+
|
|
170
|
+
version = store.texture.version;
|
|
171
|
+
store.clearSlot(0);
|
|
172
|
+
expect(store.texture.version).toBeGreaterThan(version);
|
|
173
|
+
|
|
174
|
+
version = store.texture.version;
|
|
175
|
+
store.markDirty();
|
|
176
|
+
expect(store.texture.version).toBeGreaterThan(version);
|
|
177
|
+
});
|
|
178
|
+
});
|