@es3d/es3d 1.2.0 → 1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@es3d/es3d",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "ES3D (Extensible Scalable 3D): a declarative, SVG-inspired 3D scene format with a WebGPU/WebGL2 reference renderer, editor and converter.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -7,26 +7,93 @@
7
7
  // vertex. That keeps the WebGL2 and WebGPU paths the same.
8
8
  //
9
9
  // The pool is CPU-side; backends upload the ranges appended since their last
10
- // sync (or everything after a reallocation).
10
+ // sync. The CPU copy is kept so a lost GPU context can be rebuilt.
11
+ //
12
+ // Growing and resetting are different events:
13
+ // - Growing keeps every byte and adds room. The CPU side adds a chunk (old
14
+ // chunks are never copied), and backends grow their buffers with a GPU-side
15
+ // copy of what they already hold, then upload only what is new. Vertex and
16
+ // index storage grow independently.
17
+ // - Resetting (reset(), a repack) throws the content away. It bumps
18
+ // `generation`, and backends reallocate and upload from zero.
11
19
 
12
20
  export const VERTEX_FLOATS = 8; // position xyz, normal xyz, uv
13
21
  export const VERTEX_BYTES = VERTEX_FLOATS * 4;
14
22
 
23
+ /**
24
+ * A growable typed array stored as chunks. Element `i` of the whole array is
25
+ * in the chunk whose `start <= i < start + length`. Growing appends a chunk
26
+ * and never moves existing data.
27
+ */
28
+ class ChunkedArray {
29
+ constructor(Type, stride, capacity) {
30
+ this.Type = Type;
31
+ this.stride = stride; // array entries per element (8 floats per vertex, 1 per index)
32
+ this.chunks = [{ start: 0, length: capacity, data: new Type(capacity * stride) }];
33
+ this.capacity = capacity; // elements
34
+ }
35
+
36
+ /** Make room for `count` elements in total. Returns true when it grew. */
37
+ reserve(count) {
38
+ if (count <= this.capacity) return false;
39
+ let cap = Math.max(1, this.capacity); // a zero capacity would never double
40
+ while (cap < count) cap *= 2;
41
+ const length = cap - this.capacity;
42
+ this.chunks.push({ start: this.capacity, length, data: new this.Type(length * this.stride) });
43
+ this.capacity = cap;
44
+ return true;
45
+ }
46
+
47
+ /**
48
+ * Visit the parts of elements [start, end) chunk by chunk:
49
+ * fn(firstElement, view), where view is the chunk's typed array restricted
50
+ * to that part.
51
+ */
52
+ forEach(start, end, fn) {
53
+ const s = this.stride;
54
+ for (const c of this.chunks) {
55
+ const a = Math.max(start, c.start);
56
+ const b = Math.min(end, c.start + c.length);
57
+ if (a < b) fn(a, c.data.subarray((a - c.start) * s, (b - c.start) * s));
58
+ }
59
+ }
60
+ }
61
+
15
62
  export class GeometryPool {
16
63
  constructor(vertexCapacity = 1 << 16, indexCapacity = 1 << 17) {
17
- this.vertices = new Float32Array(vertexCapacity * VERTEX_FLOATS);
18
- this.indices = new Uint32Array(indexCapacity);
64
+ this.vertexStore = new ChunkedArray(Float32Array, VERTEX_FLOATS, vertexCapacity);
65
+ this.indexStore = new ChunkedArray(Uint32Array, 1, indexCapacity);
19
66
  this.vertexCount = 0;
20
67
  this.indexCount = 0;
21
68
  this.ranges = new Map(); // key object -> {firstIndex, indexCount}
22
69
  this.edgeRanges = new Map();
23
70
  this.normalRanges = new Map();
24
71
  // dirty tracking for incremental uploads
25
- this.generation = 0; // bumps on reallocation
72
+ this.generation = 0; // bumps when the content is thrown away (reset, repack); NOT on growth
26
73
  this.uploadedVertices = 0;
27
74
  this.uploadedIndices = 0;
28
75
  }
29
76
 
77
+ /** Vertex capacity in vertices. Backends size their vertex buffer to at least this. */
78
+ get vertexCapacity() {
79
+ return this.vertexStore.capacity;
80
+ }
81
+
82
+ /** Index capacity in indices. */
83
+ get indexCapacity() {
84
+ return this.indexStore.capacity;
85
+ }
86
+
87
+ /** Capacity in bytes of the vertex storage. */
88
+ get vertexCapacityBytes() {
89
+ return this.vertexStore.capacity * VERTEX_BYTES;
90
+ }
91
+
92
+ /** Capacity in bytes of the index storage. */
93
+ get indexCapacityBytes() {
94
+ return this.indexStore.capacity * 4;
95
+ }
96
+
30
97
  reset() {
31
98
  this.vertexCount = 0;
32
99
  this.indexCount = 0;
@@ -42,60 +109,55 @@ export class GeometryPool {
42
109
  return this.vertexCount * VERTEX_BYTES + this.indexCount * 4;
43
110
  }
44
111
 
112
+ /** Make room for more vertices and indices. Growing keeps what is stored and uploaded. */
45
113
  ensure(extraV, extraI) {
46
- let grew = false;
47
- if ((this.vertexCount + extraV) * VERTEX_FLOATS > this.vertices.length) {
48
- let cap = this.vertices.length / VERTEX_FLOATS;
49
- while (cap < this.vertexCount + extraV) cap *= 2;
50
- const v = new Float32Array(cap * VERTEX_FLOATS);
51
- v.set(this.vertices.subarray(0, this.vertexCount * VERTEX_FLOATS));
52
- this.vertices = v;
53
- grew = true;
54
- }
55
- if (this.indexCount + extraI > this.indices.length) {
56
- let cap = this.indices.length;
57
- while (cap < this.indexCount + extraI) cap *= 2;
58
- const i = new Uint32Array(cap);
59
- i.set(this.indices.subarray(0, this.indexCount));
60
- this.indices = i;
61
- grew = true;
62
- }
63
- if (grew) {
64
- this.generation++;
65
- this.uploadedVertices = 0;
66
- this.uploadedIndices = 0;
67
- }
114
+ this.vertexStore.reserve(this.vertexCount + extraV);
115
+ this.indexStore.reserve(this.indexCount + extraI);
68
116
  }
69
117
 
70
118
  appendVertices(mesh) {
71
119
  const n = mesh.vertexCount;
72
120
  const base = this.vertexCount;
73
- const v = this.vertices;
74
121
  const p = mesh.positions, nr = mesh.normals, t = mesh.uvs;
75
- let o = base * VERTEX_FLOATS;
76
- for (let i = 0; i < n; i++) {
77
- v[o] = p[i * 3];
78
- v[o + 1] = p[i * 3 + 1];
79
- v[o + 2] = p[i * 3 + 2];
80
- v[o + 3] = nr[i * 3];
81
- v[o + 4] = nr[i * 3 + 1];
82
- v[o + 5] = nr[i * 3 + 2];
83
- v[o + 6] = t[i * 2];
84
- v[o + 7] = t[i * 2 + 1];
85
- o += VERTEX_FLOATS;
86
- }
122
+ this.vertexStore.forEach(base, base + n, (first, v) => {
123
+ const count = v.length / VERTEX_FLOATS;
124
+ let o = 0;
125
+ for (let i = first - base, end = i + count; i < end; i++) {
126
+ v[o] = p[i * 3];
127
+ v[o + 1] = p[i * 3 + 1];
128
+ v[o + 2] = p[i * 3 + 2];
129
+ v[o + 3] = nr[i * 3];
130
+ v[o + 4] = nr[i * 3 + 1];
131
+ v[o + 5] = nr[i * 3 + 2];
132
+ v[o + 6] = t[i * 2];
133
+ v[o + 7] = t[i * 2 + 1];
134
+ o += VERTEX_FLOATS;
135
+ }
136
+ });
87
137
  this.vertexCount += n;
88
138
  return base;
89
139
  }
90
140
 
91
141
  appendIndices(src, base) {
92
142
  const first = this.indexCount;
93
- const dst = this.indices;
94
- for (let i = 0; i < src.length; i++) dst[first + i] = src[i] + base;
143
+ this.indexStore.forEach(first, first + src.length, (at, dst) => {
144
+ const off = at - first;
145
+ for (let i = 0; i < dst.length; i++) dst[i] = src[off + i] + base;
146
+ });
95
147
  this.indexCount += src.length;
96
148
  return first;
97
149
  }
98
150
 
151
+ /** Visit stored vertices [start, end) chunk by chunk: fn(firstVertex, Float32Array). */
152
+ forEachVertices(start, end, fn) {
153
+ this.vertexStore.forEach(start, end, fn);
154
+ }
155
+
156
+ /** Visit stored indices [start, end) chunk by chunk: fn(firstIndex, Uint32Array). */
157
+ forEachIndices(start, end, fn) {
158
+ this.indexStore.forEach(start, end, fn);
159
+ }
160
+
99
161
  /** Range of a mesh's primitives (triangles or lines). */
100
162
  meshRange(mesh) {
101
163
  let r = this.ranges.get(mesh);
@@ -171,12 +171,8 @@ export class WebGL2Backend {
171
171
  this.vao = gl.createVertexArray();
172
172
  gl.bindVertexArray(this.vao);
173
173
  gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
174
- gl.enableVertexAttribArray(0);
175
- gl.vertexAttribPointer(0, 3, gl.FLOAT, false, VERTEX_BYTES, 0);
176
- gl.enableVertexAttribArray(1);
177
- gl.vertexAttribPointer(1, 3, gl.FLOAT, false, VERTEX_BYTES, 12);
178
- gl.enableVertexAttribArray(2);
179
- gl.vertexAttribPointer(2, 2, gl.FLOAT, false, VERTEX_BYTES, 24);
174
+ for (let k = 0; k < 3; k++) gl.enableVertexAttribArray(k);
175
+ this.bindVertexAttributes();
180
176
  gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuffer);
181
177
  for (let k = 0; k < 4; k++) {
182
178
  gl.enableVertexAttribArray(3 + k);
@@ -215,14 +211,28 @@ export class WebGL2Backend {
215
211
  this.gl.deleteTexture(t);
216
212
  }
217
213
 
218
- /** Upload geometry appended to the pool since the last sync. */
214
+ /** Point the VAO's vertex attributes at the vertex buffer bound to ARRAY_BUFFER. */
215
+ bindVertexAttributes() {
216
+ const gl = this.gl;
217
+ gl.vertexAttribPointer(0, 3, gl.FLOAT, false, VERTEX_BYTES, 0);
218
+ gl.vertexAttribPointer(1, 3, gl.FLOAT, false, VERTEX_BYTES, 12);
219
+ gl.vertexAttribPointer(2, 2, gl.FLOAT, false, VERTEX_BYTES, 24);
220
+ }
221
+
222
+ /**
223
+ * Upload geometry appended to the pool since the last sync.
224
+ *
225
+ * A new pool generation (reset, repack, rebuilt context) reallocates both
226
+ * buffers and uploads everything. A pool that only grew keeps its content:
227
+ * the buffer that ran out of room is replaced by a larger one, the bytes
228
+ * already uploaded are copied on the GPU, and only new data is sent.
229
+ */
219
230
  syncPool(pool) {
220
231
  const gl = this.gl;
221
- const vBytes = pool.vertices.byteLength;
222
- const iBytes = pool.indices.byteLength;
223
- const realloc = pool.generation !== this.poolGeneration || vBytes !== this.vertexCapacity || iBytes !== this.indexCapacity;
232
+ const vBytes = pool.vertexCapacityBytes;
233
+ const iBytes = pool.indexCapacityBytes;
224
234
  let uploaded = 0;
225
- if (realloc) {
235
+ if (pool.generation !== this.poolGeneration) {
226
236
  gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
227
237
  gl.bufferData(gl.ARRAY_BUFFER, vBytes, gl.STATIC_DRAW);
228
238
  gl.bindVertexArray(this.vao);
@@ -234,25 +244,67 @@ export class WebGL2Backend {
234
244
  this.poolGeneration = pool.generation;
235
245
  pool.uploadedVertices = 0;
236
246
  pool.uploadedIndices = 0;
247
+ } else {
248
+ if (vBytes > this.vertexCapacity) {
249
+ const old = this.vertexBuffer;
250
+ const next = gl.createBuffer();
251
+ gl.bindBuffer(gl.ARRAY_BUFFER, next);
252
+ gl.bufferData(gl.ARRAY_BUFFER, vBytes, gl.STATIC_DRAW);
253
+ gl.bindVertexArray(this.vao);
254
+ this.bindVertexAttributes();
255
+ gl.bindVertexArray(null);
256
+ this.copyBuffer(old, next, pool.uploadedVertices * VERTEX_BYTES);
257
+ gl.deleteBuffer(old);
258
+ this.vertexBuffer = next;
259
+ this.vertexCapacity = vBytes;
260
+ }
261
+ if (iBytes > this.indexCapacity) {
262
+ // An index buffer must first be bound as ELEMENT_ARRAY_BUFFER (WebGL2
263
+ // types buffers on first bind); bind it through the VAO, which keeps it.
264
+ const old = this.indexBuffer;
265
+ const next = gl.createBuffer();
266
+ gl.bindVertexArray(this.vao);
267
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, next);
268
+ gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, iBytes, gl.STATIC_DRAW);
269
+ gl.bindVertexArray(null);
270
+ this.copyBuffer(old, next, pool.uploadedIndices * 4);
271
+ gl.deleteBuffer(old);
272
+ this.indexBuffer = next;
273
+ this.indexCapacity = iBytes;
274
+ }
237
275
  }
238
276
  const p = pool.pending();
239
277
  if (p.vertexEnd > p.vertexStart) {
240
278
  gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
241
- const f = 8;
242
- gl.bufferSubData(gl.ARRAY_BUFFER, p.vertexStart * VERTEX_BYTES, pool.vertices.subarray(p.vertexStart * f, p.vertexEnd * f));
243
- uploaded += (p.vertexEnd - p.vertexStart) * VERTEX_BYTES;
279
+ pool.forEachVertices(p.vertexStart, p.vertexEnd, (first, data) => {
280
+ gl.bufferSubData(gl.ARRAY_BUFFER, first * VERTEX_BYTES, data);
281
+ uploaded += data.byteLength;
282
+ });
244
283
  }
245
284
  if (p.indexEnd > p.indexStart) {
246
285
  gl.bindVertexArray(this.vao);
247
286
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer);
248
- gl.bufferSubData(gl.ELEMENT_ARRAY_BUFFER, p.indexStart * 4, pool.indices.subarray(p.indexStart, p.indexEnd));
287
+ pool.forEachIndices(p.indexStart, p.indexEnd, (first, data) => {
288
+ gl.bufferSubData(gl.ELEMENT_ARRAY_BUFFER, first * 4, data);
289
+ uploaded += data.byteLength;
290
+ });
249
291
  gl.bindVertexArray(null);
250
- uploaded += (p.indexEnd - p.indexStart) * 4;
251
292
  }
252
293
  pool.markUploaded();
253
294
  return uploaded;
254
295
  }
255
296
 
297
+ /** Copy the first `bytes` of one buffer into another, on the GPU. */
298
+ copyBuffer(from, to, bytes) {
299
+ if (bytes <= 0) return;
300
+ const gl = this.gl;
301
+ gl.bindBuffer(gl.COPY_READ_BUFFER, from);
302
+ gl.bindBuffer(gl.COPY_WRITE_BUFFER, to);
303
+ gl.copyBufferSubData(gl.COPY_READ_BUFFER, gl.COPY_WRITE_BUFFER, 0, 0, bytes);
304
+ gl.bindBuffer(gl.COPY_READ_BUFFER, null);
305
+ gl.bindBuffer(gl.COPY_WRITE_BUFFER, null);
306
+ }
307
+
256
308
  uploadInstances(data, count) {
257
309
  const gl = this.gl;
258
310
  const bytes = count * 64;
@@ -254,30 +254,61 @@ export class WebGPUBackend {
254
254
  });
255
255
  }
256
256
 
257
+ /**
258
+ * Upload geometry appended to the pool since the last sync.
259
+ *
260
+ * A new pool generation (reset, repack) reallocates both buffers and uploads
261
+ * everything. A pool that only grew keeps its content: the buffer that ran
262
+ * out of room is replaced by a larger one, the bytes already uploaded are
263
+ * copied on the GPU, and only new data is sent.
264
+ */
257
265
  syncPool(pool) {
258
266
  const d = this.device;
259
- const vBytes = pool.vertices.byteLength;
260
- const iBytes = pool.indices.byteLength;
267
+ const vBytes = pool.vertexCapacityBytes;
268
+ const iBytes = pool.indexCapacityBytes;
269
+ const vUsage = GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC;
270
+ const iUsage = GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC;
261
271
  let uploaded = 0;
262
- if (pool.generation !== this.poolGeneration || !this.vertexBuffer || this.vertexBuffer.size < vBytes || this.indexBuffer.size < iBytes) {
272
+ if (pool.generation !== this.poolGeneration || !this.vertexBuffer) {
263
273
  if (this.vertexBuffer) this.vertexBuffer.destroy();
264
274
  if (this.indexBuffer) this.indexBuffer.destroy();
265
- this.vertexBuffer = d.createBuffer({ size: pow2(vBytes, 1 << 16), usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
266
- this.indexBuffer = d.createBuffer({ size: pow2(iBytes, 1 << 16), usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST });
275
+ this.vertexBuffer = d.createBuffer({ size: pow2(vBytes, 1 << 16), usage: vUsage });
276
+ this.indexBuffer = d.createBuffer({ size: pow2(iBytes, 1 << 16), usage: iUsage });
267
277
  this.poolGeneration = pool.generation;
268
278
  pool.uploadedVertices = 0;
269
279
  pool.uploadedIndices = 0;
280
+ } else {
281
+ let encoder = null;
282
+ const grow = (old, bytes, usage, keep) => {
283
+ const next = d.createBuffer({ size: pow2(bytes, 1 << 16), usage });
284
+ if (keep > 0) {
285
+ encoder ||= d.createCommandEncoder();
286
+ encoder.copyBufferToBuffer(old, 0, next, 0, keep);
287
+ }
288
+ return next;
289
+ };
290
+ const oldV = this.vertexBuffer.size < vBytes ? this.vertexBuffer : null;
291
+ const oldI = this.indexBuffer.size < iBytes ? this.indexBuffer : null;
292
+ if (oldV) this.vertexBuffer = grow(oldV, vBytes, vUsage, pool.uploadedVertices * VERTEX_BYTES);
293
+ if (oldI) this.indexBuffer = grow(oldI, iBytes, iUsage, pool.uploadedIndices * 4);
294
+ // the copy is queued before the writes below, so the new data lands after it;
295
+ // destroying a buffer waits for submitted work that reads it
296
+ if (encoder) d.queue.submit([encoder.finish()]);
297
+ if (oldV) oldV.destroy();
298
+ if (oldI) oldI.destroy();
270
299
  }
271
300
  const p = pool.pending();
272
301
  if (p.vertexEnd > p.vertexStart) {
273
- const src = pool.vertices.subarray(p.vertexStart * 8, p.vertexEnd * 8);
274
- d.queue.writeBuffer(this.vertexBuffer, p.vertexStart * VERTEX_BYTES, src.buffer, src.byteOffset, src.byteLength);
275
- uploaded += src.byteLength;
302
+ pool.forEachVertices(p.vertexStart, p.vertexEnd, (first, src) => {
303
+ d.queue.writeBuffer(this.vertexBuffer, first * VERTEX_BYTES, src.buffer, src.byteOffset, src.byteLength);
304
+ uploaded += src.byteLength;
305
+ });
276
306
  }
277
307
  if (p.indexEnd > p.indexStart) {
278
- const src = pool.indices.subarray(p.indexStart, p.indexEnd);
279
- d.queue.writeBuffer(this.indexBuffer, p.indexStart * 4, src.buffer, src.byteOffset, src.byteLength);
280
- uploaded += src.byteLength;
308
+ pool.forEachIndices(p.indexStart, p.indexEnd, (first, src) => {
309
+ d.queue.writeBuffer(this.indexBuffer, first * 4, src.buffer, src.byteOffset, src.byteLength);
310
+ uploaded += src.byteLength;
311
+ });
281
312
  }
282
313
  pool.markUploaded();
283
314
  return uploaded;