Skip to content

remesher: Initial experimental voxel remesher#1069

Open
zeux wants to merge 40 commits into
masterfrom
remesh
Open

remesher: Initial experimental voxel remesher#1069
zeux wants to merge 40 commits into
masterfrom
remesh

Conversation

@zeux

@zeux zeux commented Jul 25, 2026

Copy link
Copy Markdown
Owner

This change implements the initial version of the voxel remesher. Given a triangle mesh and the target resolution, the algorithm voxelizes the mesh, post-processes the grid to close internal areas and then reconstructs a mesh from the resulting grid, trying to match the appearance of the original mesh.

The remeshing algorithm is an unconventional hybrid: it does not use traditional triangle-voxel intersection, instead splatting barycentric samples into the grid; it does not use distance fields or Hermite data, instead accumulating quadrics representing original geometry; it uses a hybrid polygonizer with MC topology and dual-like vertices (one vertex per cell). All of this is the best compromise found so far during experimentation / prototyping but is subject to change. The output has occasional redundant connecting triangles due to how MC handles non-contiguous occupancy, which I'd like to improve if I come up with good ideas :)

Voxelization is fundamentally a tradeoff: it will merge thin features and close small holes, which is good, but it can also merge disconnected features which can look good or bad depending on where it happens. There might be ways to improve this in the future by recording supersampled occupancy and doing something intelligent with the result; for now, resolution is the main quality lever. A high resolution remesh has a high number of triangles, but it can be simplified to a pretty much arbitrary number of triangles with the existing simplifier, using regularization as necessary to achieve nice triangle shapes.

The algorithm preserves thin surfaces (sheets) and by default, meshes them as dual-sided triangles, producing an infinitely thin result. RemeshThicken option can expand the resulting thin surface a little bit; this currently has some false positives due to how it's implemented and would probably be reworked in the future. Wires require thickening to be preserved (as they are infinitely thin in two dimensions by default).

Resulting vertices can use average placement (fairly regular result) or solved placement (minimized quadrics with regularization, for better adherence to the underlying surface at the cost of less regular output and occasional intersections). This might need to be improved in the future, as there are some issues with the resulting surface smoothness, or expanded with additional options.

The output size is not known a-priori and the worst case bound (~5*res^3 triangles) is not practically useful. It is possible to run the algorithm in two passes (remesh(NULL) to count, allocate, remesh(buf) to fill), which is ~1.4x more expensive vs fill. It's also possible to estimate the output size using K*(res-2)^2 formula (K is content dependent but could be set to 10..20 to balance memory vs success rate), and optimistically remesh using this estimate as a limit; if the output doesn't fit, remesh will return the real count and the buffer can be reallocated and remeshing - retried (~2x more expensive vs fill).

Alongside the core algorithm, demo/remesh.html contains the remeshing harness that assembles a more complex algorithm: meshopt remesh -> meshopt simplify -> generate normals -> paint. Painting can either generate vertex colors, or unwrap UVs (watlas) and generate texture; in either case, we find the point on the original mesh using ray cast / closest point query and evaluate the material. For now the baking is diffuse-only and slow on high texture resolutions as it's using single threaded JS raycasts (three-mesh-bvh).

All of this is not quite ready and is in high degree of flux: "experimental" in this case really means "everything might change before the actual experimental release", but it's been baking for a while and seems to be in a good enough state to merge to master at least.

An example output (second image is remeshed & retextured output; third image uses some unpublished changes to bake a normal & metalroughness maps, this is not part of this PR for now but shows the limits of a full rebake)

stitched

This contribution is sponsored by Valve.

zeux added 30 commits July 14, 2026 20:28
This is a heavily reduced copy of simplify.html; it is a somewhat
similar structure but it only supports one model and has a much reduced
simplfication pipeline.

We keep the debug visualization code for now although it doesn't work
correctly yet, because I'd expect that it will be useful for post-simplify
topology visualization.
Making progress towards the full remeshing pipeline; we now merge all
input meshes (pre-skinned at their current pose) into a single
position-only mesh; that is then simplified and displayed. The normals
on the mesh are auto-generated using three.js logic.

Also remove the debug overlay logic for now; we will need it back but
it's too messy to maintain alongside the rest of the code.
We do not have the actual remesher implemented yet so we approximate it
with the sloppy simplifier; after that, the regular simplification
remains as an optional step. The remesher uses resolution instead of
error target, and the post-simplification just targets an error bound
instead of ratio now.

Running two simplification steps looks a little awkward but the initial
sloppy simplify will be replaced with a remesher in the future.
We now build a BVH around the merged mesh, and also track triangle ->
source geometry correspondence. This allows us to query an arbitrary
point against the BVH and translate this into source mesh/triangle.

We use this capability to generate vertex colors for the resampled mesh.
Each vertex and each triangle center is evaluated against a very basic
material model (color/map/ao/metalness); result is averaged per vertex
and stored in each vertex for display.

To perform resampling we need access to source images from JS. This...
is more difficult than makes sense, and the most straightforward route
seems to involve making a canvas, drawing the texture into the canvas
and getting the pixel data. This is not very fast but we do it once per
image.
In addition to the vertex painting we now provide texture painting. We
use watlas to UV unwrap the remeshed geometry, and then fill it based on
source material.

For transfer, each triangle is rasterized into UV space and each pixel
is converted back into world space, followed by the same BVH query. This
is fairly slow at higher texture resolutions; there might be ways to
reuse this information but also maybe the lookup can be faster in the
future.

The texture has unfilled areas that result in bilinear artifacts; we'll
fix this separately.
We now ask watlas to leave a bit of space between charts and fill the
resulting space. This could be done as a post-process, but we seem to
get higher quality results on seams if instead each triangle projection
covers the gutter instead and resamples the original data there.

We accumulate the samples in each pixel and average them at the end;
also rework color average to work off the same structure for clarity.
Remeshing pipeline is too slow to re-trigger it; so we always add
another group to the new mesh and dynamically toggle material visibility
instead.

This only works on the remeshed mesh, not on the original geometry, but
that's a reasonable limitation for now.
- Use ray cast along the normal and fall back to closest point; this
  improves quality if the mesh has multiple layers close to each other.
- Fix behavior for non-indexed source geometry and support multiple
  material groups.
- Change some defaults to have more reasonable performance.
We'll likely have more functions and an enum with options in the future
but we'll start with just a function.

The output will use a flat triangle array, with an uncertain number of
per-corner floats (might need to communicate extra data to establish
corner ids).
For now we use resolution-2 as the target because we'll need the grid
padding to be left in tact to simplify meshing, as the outer shell
should be treated as empty; this might change in the future.
In this pass, we only tag the voxels that intersect any triangles. We
will need more information in boundary voxels to be able to mesh the
shape accurately, but that requires a lot more storage which should not
scale as N^3.

For the implementation, for now instead of doing precise box-triangle
intersection tests, we estimate a barycentric sampling grid and splat
the points into voxels instead. This should guarantee hole-free
voxelization as long as the point density is high enough that the
distance between the two points is never more than a voxel's edge along
any axis.

For thin triangles, we currently over-sample which might be unreasonably
slow; this and some other inefficiencies could be fixed later.
We use a somewhat unconventional implementation of Marching Cubes, where
instead of interpolating the point on the edge, we take the value from
the occupied point as is. This will need to change a bit as we add
actual voxel solve, but this is necessary because the voxels we use for
occupancy tracking are also going to be the unit of positional
accumulation, so we don't really have data in non-occupied voxels.

This doesn't yet produce a coherent mesh due to some occupancy
connectivity issues, but we're getting closer.

Because of this construction we produce many degenerate triangles. We
could use positions to filter them out, but positions may not be
available if the destination buffer is NULL, so we use vertex codes
instead.
Instead of using voxel grid coordinates for the output vertices, we now
accumulate triangle samples into each occupied voxel and use the
resulting position during meshing.

To avoid storing N^3 voxels, we compact the data and replace the
original binary occupancy with 1-255 row offsets; since resolution is
limited, each row can have at most 255 occupied voxels.

Because we do not track the triangle indices per voxel, to accumulate
the data we need to run voxelization again, using the same code and the
same source triangles. It's important that this results in the same
samples, otherwise the addressing logic may break and we'll end up
overwriting data from other rows.
Previously, we were disabling degenerate triangle filtering when
destination buffer was not provided; this resulted in the initial call
reporting a higher number of triangles vs the actual number that was
returned after.

This is perhaps not critical and we might need to go back to the
approximate capacity in the future if this helps with performance, but
for now this results in more confusing behavior so we now use triangle
codes to reject degenerate triangles regardless of whether positions are
computed.
While it's canonical and correct to classify occupied voxels as 1 during
the MC lookup, this is resulting in some holes in diagonal sheet
surfaces; this appears to be due to ambiguous cases in the MC meshing
being resolved in a particular way.

Interestingly, flipping the categorization (treating occupied as 0 and
empty as 1) fixes these gaps. Not sure if this is a durable fix and if
we'll need further changes to the tables, but for now flip this - and
also flip the triangle winding to maintain consistent orientation.
Instead of using original Bourke tables as is, we reindex them so that
vertex codes map to the more intuitive XYZ bit order (or rather ZYX).

Instead of storing edge ids and remapping them back to vertex ids, we
embed this directly into the table, using nibbles to signify the edge.
This is valuable because it allows us to orient each edge such that its
first vertex always points at the occupied voxel.

Also apply another triangle winding flip; our previous corner data was
actually incorrectly indexed and flipped Y & Z, these tables should more
clearly reflect original data.
With resolution voxels and first & last column being reserved for empty
padding, the coordinates inside range from 1..resolution-2, and because
we use 0-based coordinates in the voxelization loops, the range is
0..resolution-3. We were off by 1 which could result in writing voxel
data to the boundary that should remain open.

Also update resolution limits accordingly; resolution up to 256 is
actually safe because that guarantees up to 254 active voxels in each
row (with two values remaining for storing voxel states; we only use one
but that's about to change).
A single-voxel thin sheet does polygonize from both sides; due to how we
compute the positions, they are identical on both sides so we get a
clean double-sided infinitely thin layer.

This is fine by itself; but code running downstream of this may or may
not expect this. As an option, we now allow expanding this into a thin
closed plate instead. This requires reading the opposite voxel to the
two voxels that the edge we are meshing connects; because we now orient
edges in a consistent direction, this voxel is always in the same
logical direction (O -> A -> B), and is guaranteed to be in bounds.

The voxels on the side of the plate do not get offset along the
orthogonal direction (e.g. if plate has a constant Z, then computing
edge from the boundary that also has a constant Z does not get
recognized as a thin sheet because the opposite voxel is occupied. To
ensure we only get *one* layer, not two, for thin sheets, we restrict
this to positive directions for now: the negative-facing side is not
offset, but the positive-facing side is.
In the future it might make sense to emit cube geometry if Debug is
specified, but for now it's sufficient to simply output original grid
position that does not depend on the accumulation code or future solve.
- Use creased normals with a customizable cutoff instead of naive average
- Show transferred texture in a 2D canvas
- Add a 'solve' option for post-remesh simplification
- Compact the mesh after simplification to see real vertex counts
- Disable polygon offset during regular rendering
Because our voxelization does not record the side, a mesh is voxelized
as a shell and meshing the resulting grid produces two layers of
geometry: exterior and interior. This may be desireable for some cases
but is usually inefficient and redundant.

We now solidify the voxel grid by default: any voxel that does not have
a path (through any of the 6 neighbors) that reaches the grid boundary
is classified as 'inside'. This uses the voxel state 0xff, which is free
because we only need states 1-254 to represent row offsets as there can
be at most 254 occupied voxels per row.

Voxels tagged as 'inside' do not have a full voxel representation: we
can not compute the position for them. For now this is fine because the
solidification ensures an empty and inside voxel can never be neighbors;
if we need this in the future it would be possible to synthesize a fake
position, but that's only necessary if we close gaps with dilation.
Instead of an explicit branch we now use a ternary to compute the new
cell state, and compute changed mask by accumulating changed bits. This
results in cleaner codegen on Clang and fixes vectorization problems in
GCC which makes solidification noticeably faster.
For now instead of adding a separate module, expose remesh() function
with flags via MeshoptSimplifier module. We'll see if this holds, but
the functionality is thematically similar and most uses of remeshing
probably need simplification too; by itself, remesher doesn't increase
the simplifier size *that* much.

This change does *not* rebuild the Wasm blob as the implementation is in
flux.
We still maintain the old option to use the sloppy simplifier behind a
separate setting, but by default we use the new remesher, controlled via
a separate set of flags.
We now accumulate a plane quadric per voxel and solve it to get a more
precise estimate of surface position compared to average. The quadrics
are area weighted; for simplicity, we use area weighted positions
instead too when solve is disabled.

To stabilize the solve, we use the same LDL solve as the simplifier uses
together with regularization. Regularization is applied during the
solve, using the centroid (weighted average) as the point; note that
this is equivalent to adding a point quadric to every voxel sample we
accumulate, just easier/faster.

For now, regularization weight is a constant and has been tuned to
balance instability with quality. Further tuning might be required here
in the future.
- Fix sloppy remesher setting; it no longer routed to simplifySloppy
- Add solve remesher setting
- Change regularization to use light regularization for better balance
  with the same threshold
- Change default simplification threshold to 10^-2.5
Instead of reading 8 voxels per iteration, we only read 4 and use the
bits of the cube mask from the previous iteration. Also we simply read
the correctly offset bits now instead of using loops and going through
offset masks. This makes remeshing ~10% faster on Clang and ~20% faster
on GCC; Clang's codegen of the original code was better so the
difference is less stark there.
Instead of solving each vertex as we emit it, we now compute the final
position (minus offset) per voxel in a separate pass. This is marginally
more efficient when solve is disabled (~5%) and noticeably more
efficient when solve is enabled (~10-15%); the function has very
predictable code flow and also each voxel is solved once as opposed to
multiple times (once per edge it participates in, including degenerate
edges).

In some cases we may be solving additional voxels that are inside a
fully closed volume, but this should still be worthwhile and in the
future we could filter those out by restructuring solidification/row
allocation.
Quadric accumulation costs ~7% of runtime and we only need it when solve
is enabled; disable it to save a bit of time and skip redundant work.

Note that we keep the existing memory structure: we still allocate and
clear memory for the quadrics even if they aren't used. When solve *is*
enabled, separate allocation is slower as memory access is less local,
and this doesn't buy us that much time when solve is disabled. Most
likely in the future solve will be enabled by default, so this is a much
better balance.
A separate function makes it easier to profile and tune it independently
of the rest of the code.

The initial implementation had a branchy structure. While voxel
occupancy often has predictable content, GCC doesn't compile that super
well; we can instead use the fact that voxelize only emits binary voxel
state, and accumulate voxel data directly to produce count. This ends up
being lowered to branchless code with cmov for data replacement and is
faster on GCC and has similar performance on Clang.
zeux added 10 commits July 21, 2026 07:37
We need this to fully test the new solve implementation.

Also reformat simplifier source/types with Prettier; this was missed in
the previous changes as auto-formatting got disabled.
Prettier auto-formatting got disabled and a lot of this code is not
formatted in line with expectations. For now just reformat the entire
file until we figure out a new strategy.
Any triangle that results in weight=0 samples is not useful: the samples
contribute nothing to the affected voxels. What's more problematic is
the fact that if a voxel *only* receives weight=0 samples, its weight
stays at 0 which means we can't compute a valid position.

To fix this, skip triangles like this early so that they don't tag
voxels as occupied.
Previously we were solving each voxel multiple times as the solve would
be done per edge in emitVertex. Typically, most voxels we end up
rasterize are on the narrow band and as such they produce more than one
edge, so we repeat the work. Additionally this is done in code that has
unpredictable branches which reduces IPC artificially.

Instead, solve each voxel once in a separate pass, which might produce a
little bit of redundant work for inner voxels but this is more than paid
for with the lack of redundancy and improved efficiency.

Also, in addition to regularization, this adds a fail-safe where we
never replace voxel centroid with a position that's more than a voxel
diagonal away from it. This is similar to the neighborhood clamping we
do in the simplifier; it doesn't appear to be strictly necessary with
the current (high) regularization weight, but with a lower
regularization weight some quadric configurations solve into an
unreasonable minimizer and this fixes that.
We were placing the vertices on the grid corners and the grid was
additionally offset by 1. This resulted in positions that did not
correspond to visual geometry very well. We now place these in the voxel
centers instead on a correctly placed grid.

Also remove RemeshDebug from the public API and use a high bit, similar
to simplification; this option will likely be removed in the future.
Solve is now using bit 2 and debug is now internal.
- Add correct handling for texture-less metallic
- Add handling for emissive color/texture
- Fix UV transformation logic; multiple textures would result in
  multiple transformations of the same underlying UV vector
- Fix a rare exception when material group wasn't found
- Fix debug texture UV staying around on revert/reload
- Fix VRAM leak as we were not disposing the live geometry objects
- Adjust defaults for some parameters

The output colors are still clamped at 1.0, so materials that heavily
rely on emiss emissive will not preserve appearance perfectly but it's
better than not using emissive at all.
This exercises most of the newly added code, and does a simple two-pass
remesh similar to what we do in JS: count first, then generate. In the
future we could switch to an optimistic estimate but this requires more
investigation.
meshopt_remesh is now correctly tagged as MESHOPTIMIZER_EXPERIMENTAL;
also move it to a more appropriate place in the file.

Finally also add a C++ templated wrapper for different index types; this
also allows us to default options to 0.
When thickening is disabled, our mesher operates in "pure" dual mode:
each voxel gets a unique position and code, and triangles with two
vertices with identical codes/positions are filtered out.

When looking at a cube configuration, we know exactly which edges the
triangles connect, and which corner of each edge is occupied or not.
Because of this, we actually can pre-filter degenerate triangles ahead
of time.

For now, we use this information to make counting faster when thickening
is disabled; we could also use this to accelerate emission (and skip
degenerate triangles), but it doesn't appear to be profitable in the
current form. When thickening is enabled, whether each edge uses the
voxel position is dynamic so we can't do this processing statically.
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.

1 participant