meshToDensePointCloud: sample the faces in rows, and let every edge cover itself - #6732
Open
Fedr wants to merge 10 commits into
Open
meshToDensePointCloud: sample the faces in rows, and let every edge cover itself#6732Fedr wants to merge 10 commits into
Fedr wants to merge 10 commits into
Conversation
…over itself Three things replace the grid of similar triangles, and none of them needs a division number rounded to a power of two: 1) an edge is divided so that every point of it is within the radius from a sample of its own, and only if some incident face relies on that, which decouples the faces from each other; 2) a face is sampled in rows parallel to its longest edge. The angles at the ends of that edge are acute, so every point of the face projects on it inside it, and the sections parallel to it shrink towards the opposite vertex - hence the row below a point is never narrower than the face is there, and the point is within a band of it and half a step along it. sqrt(2)*radius along a row against radius/sqrt(2) between the rows keeps both within the radius with the fewest samples; 3) a face lower over its longest edge than the samples of that edge reach needs no rows at all, which is the thin triangle of #6726 without a special case for it. On a building mesh of 169k faces, against master: 227846 points instead of 591789 at radius 0.2 and 125389 instead of 185353 at 0.5. The largest surface-to-cloud distance is 82.9% and 84.1% of the radius, where the grid left 93.2% and 93.8%, so the cover is not tighter than before either.
… alone A face flatter than the radius is covered by its longest edge alone once that edge is divided finely enough: ceil( len / 2*sqrt( radius^2 - h^2 ) ) parts. The face asks for that division when the samples it adds to the edge are fewer than the rows it saves, and asks nothing otherwise; the edge takes the maximum of what its faces ask, and no divisibility is involved anywhere. A face that got what it asked needs no rows, and its other two edges need no samples of their own. This is the thin-triangle rule of #6726 expressed in the new scheme. On the isolated slivers where that PR was measured this now matches it exactly - 9, 9 and 15 points - and on the building mesh it brings 227846 points down to 217531 at radius 0.2 and 125389 to 123935 at 0.5.
The three patterns have different strengths: a grid of similar triangles puts its nodes in a triangular lattice, which covers about 2.5 times more area per sample than the rectangular one of the rows, but it needs its nodes among the division points of its edges, which forces its number to a power of two and drags the neighbours' edges along. The rows need nothing of the kind, and a flat face needs only its longest edge divided finely enough. So the face now counts what each of the three would cost - the samples inside it plus half of those on its edges, since an edge is shared, and a grid needs no self-covering edges because it covers them itself - and takes the cheapest. The edge takes the smallest multiple of what the grids ask that is not less than what the rows and the flat faces ask, so both kinds coexist. On the isolated shapes where the grid used to win it now matches it exactly (9, 9, 15, 25, 25, 45 and 561 samples), and on the building mesh: 207214 points instead of 293831 at radius 0.2 and 121822 instead of 130525 at 0.5, against master's 591789 and 185353.
Three of the nine members were not needed. The distance between the samples within a row is radius * sqrt(2) on every face, so it was a constant stored 169 thousand times. The height of the first row, the distance between them and the height of the face were only ever used as the fraction ( first + i * band ) / height, so two fractions replace the three. And a face covered by its own vertices is the one that asks for nothing, which makes the flag a function of the other members. 24 bytes per face instead of 40, the same samples to the last point, and the call on a building mesh of 169k faces went from 3.84 to 3.54 ms at radius 0.2 and from 3.39 to 2.94 at 0.5.
When the rows are the cheapest pattern and the layout turns out to need none of them, the samples of the longest edge already cover the face - but with grid, rows and wants all zero the face looked covered by its own vertices, so the edge pass skipped it and that edge could stay undivided, with nothing covering the face at all. Whether any mesh actually reached that state is unknown; it now asks for the division that covers it, as a flat face does. No change on the shapes or on the building mesh: 207214 points at radius 0.2 and 121822 at 0.5.
FaceLayout is down to the choice itself: the longest edge, the division of a grid, the division the
face wants of that edge, and whether it is sampled in rows. The rows themselves - how many, where
the first one is and how far apart - are the output of layoutRows on the division that edge finally
got, so both the pass that counts the samples and the pass that writes them compute it from that.
The first attempt at this broke the cover on the torus, 0.0748 against a radius of 0.05, and the
reason was not the recomputing: the count read
faceSamples[f] = numRowSamples( rowsOf( f, v, baseLen ), baseLen, radius );
where rowsOf sets baseLen through a reference while baseLen is also an argument of the same call.
The order of evaluation is unspecified, MSVC takes the arguments right to left, so numRowSamples
got baseLen = 0, counted two samples per row instead of the real number, and the writing pass then
ran past the range reserved for the face into the next one.
16 bytes per face instead of 24, the same samples to the last point, and the call on the building
mesh went from 3.54 to 3.22 ms at radius 0.2 and from 2.94 to 2.60 at 0.5.
The patterns never apply together: a face is covered by its own vertices, or by a grid, or by the samples of its longest edge, or it is sampled in rows. So one number serves them all, tagged by which of the four it is - and the local index of the longest edge takes two bits, the tag another two, leaving 28 for the number of parts, far more than divsForStep can ever return. 4 bytes per face instead of 16, the same samples to the last point, and the call on the building mesh went from 3.22 to 3.03 ms at radius 0.2 and from 2.60 to 2.38 at 0.5 - against master's 3.65 and 2.41 for two to three times as many points.
A default FaceLayout is now a face covered by its own vertices, so an early return out of layoutFace cannot leave the fields as they came from the allocation. Buffer accepts an element type that is either trivially constructible or constructible from NoInit, and the initializers make the first false, so the second is provided. That costs about 12% of the call on the building mesh - 3.45 ms instead of 3.03 at radius 0.2 and 2.81 instead of 2.38 at 0.5 - because Buffer runs a constructor per element instead of only allocating. The samples are the same to the last point.
…t there Default initializers make the type not trivially constructible, and Buffer then constructs every element instead of only allocating the memory. The loop itself is cheap - 0.042 ms for 169 thousand elements when the allocator hands back warm pages - but in a real call it performs the first touch of the whole array in one thread, where otherwise the pages are first touched by the parallel passes and the faults spread across the cores. Measured on the building mesh, best of six runs: 2.92 ms against 3.21 at radius 0.2, and 2.28 against 2.98 at 0.5, so the earlier commit understated it as 12% while at the coarse radius it is 31%. Every field is assigned where a layout is made, including on the two paths that return early, and the comment on the struct now records all of this so the initializers are not added back by habit.
… that took the initializers out
Grantim
approved these changes
Aug 31, 2026
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.
Fewer points for the same guarantee, by dropping the grid of similar triangles altogether. Supersedes #6726 - the thin triangle it special-cases falls out of the new scheme with no rule of its own.
What the grid cost
The samples inside a face were the nodes of a grid of
n*ntriangles similar to it, andnhad to be a power of two: a face's nodes on an edge must be among that edge's division points, andmaxof two powers of two is a multiple of both. That coupling is expensive twice over - up to 4x in the interior from the rounding, and an edge dragged to its coarser neighbour's resolution.What replaces it
ceil( len / 2r )parts, exact. And only if some incident face relies on that: a face covered by its own three vertices does not, and a face covered by its longest edge alone relies on that edge only. Faces no longer constrain each other at all.sqrt(2)*radiusalong a row againstradius/sqrt(2)between rows keepssqrt( (step/2)^2 + band^2 ) <= radiuswith the fewest samples.sqrt( radius^2 - (step/2)^2 )up into the face; a face flatter than that is covered by that one edge. This is meshToDensePointCloud: cover a thin triangle by the samples of its longest edge #6726's thin-triangle rule, now a consequence rather than a case.Measured
A building STL of 169k faces, 106k vertices, bounding box diagonal 27.7. One binary with
MRMesh.dllswapped between the three variants and the runs interleaved; best of 4 runs, each the minimum of 3 calls:65% fewer points than master at radius 0.2, 34% fewer at 0.5, and 29% / 7% fewer than #6726. The rows, the exact division counts and the two extra passes over the faces cost time, but a face layout packed into one 32-bit word - and a third of the samples to write - pay for them: 18% faster than master at radius 0.2 and level with it at 0.5. The objective was the point count; the speed came out even.
Each face picks its pattern
The three patterns have different strengths. A grid puts its nodes in a triangular lattice, which covers about 2.5x more area per sample than the rectangular one the rows make - but it needs its nodes among the division points of its edges, which forces its number to a power of two and drags the neighbours' edges along with it. The rows need nothing of an edge but that it covers itself. And a flat face needs only its longest edge divided finely enough.
So a face counts what each of the three would cost - the samples inside it plus half of those on its edges, since an edge is shared, and a grid needs no self-covering edges because it covers them itself - and takes the cheapest. An edge then takes the smallest multiple of what the grids ask that is not less than what the rows and the flat faces ask, so the two kinds coexist on one edge.
Point counts for isolated shapes, radius 1 unless stated, where the grid alone used to be far ahead:
It never loses to either predecessor on a single face now: where the grid is the best pattern, it is the one chosen.
Tests
All the coverage tests pass unchanged, including the torus one that catches the failures of an unconforming grid. Only counts moved: the degenerate triangle needs 7 points instead of 15, exactly the case of rule 3. The worst surface-to-cloud distance on the building above is 93.2% of the radius at 0.2 and 93.6% at 0.5, the same as master's.