Skip to content

meshToDensePointCloud: sample the faces in rows, and let every edge cover itself - #6732

Open
Fedr wants to merge 10 commits into
masterfrom
row-sampling
Open

meshToDensePointCloud: sample the faces in rows, and let every edge cover itself#6732
Fedr wants to merge 10 commits into
masterfrom
row-sampling

Conversation

@Fedr

@Fedr Fedr commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

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*n triangles similar to it, and n had to be a power of two: a face's nodes on an edge must be among that edge's division points, and max of 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

  1. Every edge covers itself. It is divided so that every point of it is within the radius from one of its own samples, 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.
  2. A face is sampled in rows parallel to its longest edge. Two properties of the longest edge make it work: the angles at its ends are acute, so every point of the face projects onto it inside the segment; and the sections parallel to it shrink monotonically towards the opposite vertex, so the row below a point is never narrower than the face is at that point. A point is therefore within one band of the row below it and within half a step along that row, and sqrt(2)*radius along a row against radius/sqrt(2) between rows keeps sqrt( (step/2)^2 + band^2 ) <= radius with the fewest samples.
  3. A face lower than its longest edge's samples reach needs no rows. Those samples are within half a step of any point of the edge, so they reach 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.dll swapped between the three variants and the runs interleaved; best of 4 runs, each the minimum of 3 calls:

radius master #6726 this PR
0.2 591 789 points, 3.54 ms 293 831, 3.02 ms 207 214, 2.91 ms
0.5 185 353 points, 2.25 ms 130 525, 2.29 ms 121 822, 2.24 ms

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:

shape master #6726 this PR
two slivers on an edge of 10, h = 0.05 45 9 9
the same, h = 0.5 45 9 9
the same, h = 0.9 25 15 15
the same, h = 1.5 25 25 25
the same, h = 3 25 25 25
equilateral, side 10 45 45 45
equilateral, side 10, radius 0.3 561 561 561

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.

Fedr added 10 commits August 31, 2026 14:29
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants