Repository navigation
Pre-size geometry buffers instead of growing arrays and converting - #349
Conversation
renderPathsAsLines grew a plain array one push at a time and handed it to setPositions, which then copied the whole thing into a Float32Array. Counting segments first and filling the typed array in place skips both the repeated growth and the conversion. On a 7023 path model that is 652818 floats: 3.1ms -> 0.5ms, and about 5 MB of transient boxed doubles never allocated. Output is byte for byte identical.
|
Visit the preview URL for this PR (updated for commit 533c51b): https://gcode-preview--pr349-perf-pack-line-verti-0bv00xd9.web.app (expires Mon, 21 Sep 2026 02:41:51 GMT) 🔥 via Firebase Hosting GitHub Action 🌎 Sign: 59bd114ae4847b32c2bba0b68620b9069a3e3531 |
Same change as the line packing, applied to the four buffers tube geometry builds. vertices, normals, uvs and indices were grown by push and then copied into typed arrays by setAttribute and setIndex. The sizes follow from the path topology, so they can be allocated up front and filled by cursor: vertices = (points + 1) * (radialSegments + 1) * 3 uvs = points * (radialSegments + 1) * 2 indices = (points - 1) * radialSegments * 6 On the 3.5 MB 3DBenchy that is 6.7M pushes across the four buffers: tube geometry generation drops from 103ms to 79ms. Index width is now chosen explicitly rather than inferred by three.js: Uint16 below 65536 vertices, Uint32 above, since indices only ever reference this geometry's own vertices. All four buffers compared element by element against the previous implementation across 400 geometries, all identical.
remcoder
left a comment
There was a problem hiding this comment.
Solid refactor. Verified locally (157/157 tests pass) and checked the sizing math against the fill loops for point counts 0–10 — all exact. Details:
- Buffer sizing is provably correct. accounts for the closing segment; and match the / loops exactly. I also confirmed the sizing is robust to the latent bug: both branches of produce exactly one extra ring, so is safe in both test and browser environments.
- Index width boundary is safe. selects Uint32; at exactly 65536 the max index is 65535 (still Uint16-addressable), so it's conservative by one — harmless, just one vertex earlier than strictly needed.
- ** is behavior-identical** to the inlined loop, including the Y offset and the Z offset. Segment-count formula matches the loop exactly.
- Good regression tests: exact-size assertions catch both oversizing and undersizing, the trailing-index check catches slack, and the Uint16/Uint32 boundary test is well chosen.
Non-blocking notes:
- has a pre-existing quirk: for a path with not a multiple of 3 (e.g. 4), it reads past the end (, → NaN). Not introduced here, but worth a guard while you're in there.
- The path means writes yield NaN silently — same as before, just noting typed arrays don't throw.
- Consider describing the 6-floats-per-segment layout — already in the JSDoc, good.
remcoder
left a comment
There was a problem hiding this comment.
Solid refactor. Verified locally (157/157 tests pass) and checked the sizing math against the fill loops for point counts 0-10 — all exact. Details:
- Buffer sizing is provably correct. vertexCount = (points.length + 1) * ringSize accounts for the closing segment; uvCount and indexCount match the generateUVs/generateIndices loops exactly. Also confirmed the sizing is robust to the
closedlatent bug: both branches ofgenerateSegment(closed === false ? ...)produce exactly one extra ring, so the +1 is safe in both test and browser environments. - Index width boundary is safe. vertexCount > 65535 selects Uint32; at exactly 65536 the max index is 65535 (still Uint16-addressable), so it's conservative by one — harmless, just one vertex earlier than strictly needed.
- packLineVertices is behavior-identical to the inlined loop, including the -0.1 Y offset and the offset = -lineHeight/2 Z offset. The segment-count formula Math.ceil((len-3)/3) matches the loop exactly.
- Good regression tests: exact-size assertions catch both oversizing and undersizing, the trailing-index check catches slack, and the Uint16/Uint32 boundary test is well chosen.
Non-blocking notes:
- packLineVertices has a pre-existing quirk: for a path with vertices.length not a multiple of 3 (e.g. 4), it reads past the end (vertices[4], vertices[5] -> NaN). Not introduced here, but worth a guard while you're in there.
- NaN path means Float32Array writes yield NaN silently — same as before, just noting typed arrays don't throw.
|
One thing I missed on the first pass: the copy this PR sets out to remove is still there.
Since this.setIndex(new BufferAttribute(indices, 1));
this.setAttribute('position', new BufferAttribute(vertices, 3));
this.setAttribute('normal', new BufferAttribute(normals, 3));
this.setAttribute('uv', new BufferAttribute(uvs, 2));Safe as a drop-in: these are already I'd rather this landed here than as a follow-up — without it the PR's goal is half-delivered and the four lines are inconsistent with each other. Not asking for a test: "didn't allocate a copy" isn't observable through the public surface without spying on the Everything else in my approval stands. |
|
Your suggestion was a good catch. I checked (actually I told Claude to check for me) That's what's litteraly in three.js: // three.js source
class Float32BufferAttribute extends BufferAttribute {
constructor(array, itemSize, normalized) {
super(new Float32Array(array), itemSize, normalized); // ← allocates + copies
}
}The last commit shaved off an additional 8% of the rendering time on a 200k-point spiral, median of 5 constructions, 3 runs. |
Geometry buffers across the codebase are built by pushing onto plain
number[], then handed to three.js — which copies each one into a typed array. Large models pay twice: the arrays grow and reallocate as they fill, then every element is copied.Every one of these sizes is known before the loop starts, so the buffer can be allocated once and filled by cursor. Same change in both places it happens.
1. Line rendering —
SceneManager.renderPathsAsLinesOne position buffer, built per render.
2. Tube rendering —
ExtrusionGeometryFour buffers —
vertices,normals,uvs,indices— built per path. The sizes follow from the path topology:End to end, building every tube geometry in the model: 103 ms → 79 ms.
Benchmark setup
3.5 MB 3DBenchy — 163,474 lines → 7023 paths → 104,037 points, producing 652,818 line floats and 537,745 tube vertices / 2,412,600 indices. Chrome, median of 7–10 runs after warm-up.
Correctness
Both changes produce byte-identical output, verified rather than assumed. The previous implementations were reimplemented as references and compared element-by-element:
Model renders unchanged in lines and tubes mode.
New tests cover the parts that could silently drift:
Float32ArrayUint16ArraytoUint32Arraypast 65,535 verticesnpm run checkpasses: 12 test files, typecheck, lint.Worth a reviewer's attention
Pre-sizing the index buffer means the index width is now chosen explicitly instead of inferred by
setIndex. Indices only ever reference the geometry's own vertices, soUint16Arrayis correct below 65,536 vertices andUint32Arrayabove — there's a test for the crossover, since no model in the repo reaches it (the Benchy's largest path is 1075 vertices).Related
#350 is a separate optimization in the same
ExtrusionGeometryloop — caching the ring of sines and cosines rather than recomputing it a million times. Different technique, so it is kept apart, but the two touch the same function and whichever merges second will need a small rebase. Together they take tube geometry from 103 ms to 39 ms.Follow-up: zero-copy attributes
Per review feedback,
Float32BufferAttributewas replaced with plainBufferAttributefor the position, normal and uv attributes. The buffers are already exact-sizeFloat32Arrays, andFloat32BufferAttribute's constructor doesnew Float32Array(array)— a full copy.BufferAttributeadopts the buffer as-is, eliminating 3 full-buffer copies per path (position + normal + uv).Node bench, 200k-point spiral,
new ExtrusionGeometry(points, 0.4, 0.2, 4), median of 5 constructions: ~65 ms → ~60 ms (~8% faster; the buffer fill dominates, the copies were the remaining overhead).Assisted by Claude Code - Opus 5