feat: derive the subdivision cap from an adjustable memory budget (proposal) - #115
Open
Dgmtnz wants to merge 3 commits into
Open
feat: derive the subdivision cap from an adjustable memory budget (proposal)#115Dgmtnz wants to merge 3 commits into
Dgmtnz wants to merge 3 commits into
Conversation
…d its check meshRepair packs an edge as `a * 4294967296 + b` (a * 2^32 + b) into a JS number. float64 holds that exactly only while it stays inside 2^53, i.e. up to a = 2^21 = 2,097,152 vertices. Above that distinct edges collide onto one key. diag-edgekey-collision.mjs (added) builds a closed torus whose manifoldness follows from its grid topology rather than from any measurement, so a counter that disagrees is wrong by construction: 1.54M vertices -> 0 non-manifold edges (below the threshold) 2.52M vertices -> 210,422 non-manifold edges (mesh is perfect) 3.74M vertices -> 819,608 non-manifold edges (mesh is perfect) In countEdgeDefects the collision is a false alarm on a good file: a 400mm sphere exported at 0.35mm / 5M triangles reported 185,146 non-manifold edges that staged measurement showed were not in the mesh at any pipeline stage. In resolveTJunctions it is a correctness bug. A genuine boundary edge (count 1) colliding with another reads as count 2, so its T-junction is silently left unrepaired, and decoding the key back with `b = k % 4294967296` yields vertex ids that were never on that edge, feeding wrong candidates to the split search. Both now key on exact Int32 pairs via a new IntPairMap in meshIndex.js, over a dense edge table. This also removes a second ceiling in countEdgeDefects, which counted in a JS Map and throws past V8's ~16.7M entry cap (~11M triangles). Below 2.1M vertices the old keys were exact, so this is a no-op there: pipeline fingerprints are unchanged across sphere, cube-with-fillets, cone, holed-plate and sliver-strip inputs at 10 model/texture/resolution combinations.
Profiling the export pipeline with live typed-array accounting (process.memoryUsage().arrayBuffers + .heapUsed sampled every 5ms; RSS overstates by ~30% because V8 does not return freed pages promptly) put the peak at 660 bytes per subdivided triangle, all of it in decimation — not the "~145 B/tri" the subdivision safety-cap comment assumes. Peak per subdivided triangle, sphere at 3.29M triangles: stage before after subdivide 178 147 displace 254 216 decimate 660 327 All of it is allocation sizing and lifetime; none of it changes geometry. - SoAHeap was sized at 3F entries, then rounded up to a power of two. Seeding pushes one entry per UNIQUE edge (1.5F by Euler), so a 4.9M-entry heap was allocated as 16.7M slots x 48B = 805MB. Capacity is only a bound in push() — nothing masks on it — so the pow2 rounding was pure waste too. - buildIndexed allocated `positions` at the corner count and returned a subarray VIEW, keeping a 6x-oversized buffer reachable for the entire run (237MB to store 39MB). Grows on demand, returns a copy. - slotFace[s] is always (s/3)|0 and faceSlot[s] only ever held s or -1; the first is gone and the second is a Uint8Array flag (-24 B/tri). - seedSeen and vertMap sizing hints were 2-6x over, each tipping its table over a power-of-two doubling; seedSeen is also freed after seeding rather than held through the collapse loop. - decimate(..., releaseInput) lets the caller hand over the input geometry, so its 72 B/tri is not held across the collapse loop. dispose() cannot do this: it frees GPU resources, not the JS typed arrays. - subdivision's splitEdges/midCache move to IntPairMap (12 B/slot vs 28). Verified with bench-pipeline.mjs: 10/10 fingerprints identical to before, across sphere, cube-with-fillets, cone, holed-plate and sliver-strip inputs.
The subdivision safety cap was a hardcoded 16M/32M pair, justified in a comment
by "~145 bytes per subdivided triangle". Measured, the pipeline peaked at 660
B/tri (327 after the preceding commit), so those caps stood for 10.6 GB and
21 GB — no tab reached them, it hit the allocator first and the export died
with an out-of-memory error instead of the "coarser than requested" warning the
cap exists to produce.
navigator.deviceMemory is clamped to 8 by its specification, so a 64 GB
workstation and an 8 GB laptop got the same ceiling and there was no way to say
"I have the RAM". The cap is now budget / measured-bytes-per-triangle, with the
budget a user setting (Advanced -> Quality Ceiling), persisted, resolved on the
page because the worker has no localStorage and passed as
settings.subdivisionCap.
PIPELINE_BYTES_PER_TRIANGLE is 384, measured two ways:
327 B/tri Node, live typed-array accounting (arrayBuffers + heapUsed)
384 B/tri Chromium, RSS rise of the browser processes over an idle baseline
during a real export (15.0M subdivided triangles, 665 -> 6165 MB)
The constant takes the browser figure. This guard exists to stop an export
before the allocator does, and the allocator charges for footprint, not for the
subset V8 attributes to live buffers.
A second ceiling is independent of RAM and binds above ~16 GB of budget: V8
caps a single typed array at 2^31-1 bytes (measured: largest allocatable
Float32Array is 2046 MB, page and worker alike). The pipeline's biggest single
allocations are decimation's quadrics (40 B/tri) and toNonIndexed's position
and normal buffers (36 B/tri each), so subdivision cannot exceed ~44.7M
triangles however much memory is free. The UI says when that, and not the
setting, is binding.
Allocation failures now degrade instead of throwing: subdivide() rolls back to
the last complete level and reports safetyCapHit, which the caller already
surfaces as "coarser than requested". Losing a finished multi-minute
subdivision to one failed allocation is not an acceptable failure mode. This
path is defensive and currently unexercised by any test — with the structural
cap in place it should be unreachable in Chromium; it exists because engine
limits differ.
Smart resolution budgets against the same cap the export enforces instead of a
fixed 16M chosen for cross-machine reproducibility, and computeRecommendedMaxTri
loses its flat 2M ceiling, which recommended the same output count for a 50mm
part and a 1200mm one (see diag-quality-ceiling.mjs). The 2M floor is kept so it
can never recommend less than before.
Budgets below 8 GB now resolve to a lower cap than the old nominal 16M/32M.
That is deliberate: the old numbers were not reachable. Pipeline fingerprints
unchanged, 10/10 identical to upstream across sphere, cube-with-fillets, cone,
holed-plate and sliver-strip inputs.
Dgmtnz
marked this pull request as ready for review
August 8, 2026 23:24
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context first — this one is a proposal, not a finished call
It changes default behaviour and adds a user-facing control, so it's a product decision. Happy to drop, reshape or split it. The two PRs it sits on stand on their own.
Depends on #114.
The problem
SAFETY_CAPis a hardcoded 16M/32M pair, justified by "~145 bytes per subdivided triangle". Measured, the pipeline peaks at 660 B/tri (327 after the memory PR). So those caps stood for 10.6 GB and 21 GB. No tab ever reached them — it hit the allocator first, and the export died with an out-of-memory alert instead of the "coarser than requested" warning the cap exists to produce.navigator.deviceMemoryis clamped to 8 by its own specification, so a 64 GB workstation and an 8 GB laptop got the same ceiling, and "I have the RAM, let me use it" was unexpressible.Separately,
computeRecommendedMaxTriclamps at a flat 2 M.diag-quality-ceiling.mjs(added) sweeps one model across physical sizes with a fine texture:Every size gets exactly 2.00 M output triangles — the same count for a 50 mm part and a 1200 mm one, i.e. a ~24× coarser surface on the large one, silently.
The change
cap = budgetBytes / PIPELINE_BYTES_PER_TRIANGLE, with the budget a user setting (Advanced → Quality Ceiling), persisted, resolved on the page (the worker has nolocalStorage) and passed assettings.subdivisionCap.A second ceiling is independent of RAM and I only found it by hitting it: V8 caps a single typed array at 2^31−1 bytes (measured — largest allocatable
Float32Arrayis 2046 MB, page and worker alike). At a 32 GB budget the cap was nominally 101 M triangles; subdivision reached 65 M, thentoNonIndexedasked for a 2.34 GBFloat32Arrayand threw, on a machine with 30 GB free. So the cap is also bounded by2^31 / 48 B/tri≈ 44.7 M triangles, and the UI says when that, not the setting, is binding.Allocation failures now degrade instead of throwing:
subdivide()rolls back to the last complete level and reportssafetyCapHit. Losing a finished multi-minute subdivision to one failed allocation isn't an acceptable failure mode.Smart budgets against the same cap the export enforces, and the 2 M output clamp becomes proportional to the budget (floored at 2 M so it can never recommend less than today).
Effect
The rows below 8 GB are coarser than today's nominal setting — deliberately. Today's 12 M Smart budget needs ~7.9 GB of real memory at the current 660 B/tri, so on a machine that only has a few GB to spare it mostly ended in an out-of-memory alert. These numbers are what actually completes.
Caveats
PIPELINE_BYTES_PER_TRIANGLE = 384is measured two ways: 327 B/tri under Node with live typed-array accounting, and 384 B/tri in Chromium as the RSS rise of the browser processes over an idle baseline during a real export (15.0 M subdivided triangles, 665 MB → 6165 MB). The constant takes the browser figure, since the allocator charges for footprint, not for the subset V8 calls live. Both were measured on generated models on one Linux machine — worth re-checking against your reference models.