A cross-platform mesh normal denoising tool based on the Static/Dynamic Filtering algorithm (Zhang et al., arXiv:1712.03574).
Smooths noisy 3D meshes while preserving geometric detail — useful for cleaning up photogrammetry output, 3D scans, and other noisy mesh data.
- Multi-format input: OBJ, PLY, OFF, STL, glTF (.gltf/.glb), USD (.usd/.usda/.usdc/.usdz)
- Multi-format output: Any format supported by OpenMesh (OBJ, PLY, OFF, STL, etc.)
- Flexible CLI: Run with built-in defaults or a custom options file
- Pipeline timing printed to stdout; optional JSON/CSV metrics export
- Deterministic mode for reproducible results
- Optional CHOLMOD solver backend (auto-detected, falls back to Eigen LDLT)
- OpenMP parallelization (optional)
- Input validation — rejects meshes with NaN/Inf coordinates
# Denoise with built-in defaults
MeshDenoiser input.obj output.obj
# With a custom options file
MeshDenoiser options.txt input.obj output.obj
# Denoise a glTF or USD file
MeshDenoiser scan.glb denoised.obj
MeshDenoiser scene.usdz clean.ply
# Generate a default options template
MeshDenoiser --write-default-options my_options.txt
# Show all options
MeshDenoiser --help| Flag | Description |
|---|---|
--export-precision N |
Vertex coordinate precision for output (default: 16) |
--metrics-json PATH |
Write JSON timing and solver metrics |
--metrics-csv PATH |
Append CSV timing and solver metrics |
--deterministic |
Force single-threaded execution for reproducible output |
--write-default-options PATH |
Write the default options template to a file and exit |
This repo is also a Swift Package (MeshDenoiserKit) targeting iOS 17+ / macOS 14+.
It exposes the denoiser as a buffers-in/buffers-out API — file I/O stays in your app
(ModelIO/SceneKit read USDZ natively). Vertex count and order are preserved, so UVs
and materials on the original asset remain valid; recompute normals after denoising.
import MeshDenoiserKit
var params = MeshDenoiseParameters() // tuned defaults
params.outerIterations = 2 // more smoothing
params.backend = .reference // default while native backend matures
let denoised = try await MeshDenoiser.denoise(
positions: positions, // [SIMD3<Float>]
indices: indices, // [UInt32], 3 per triangle
parameters: params
) { progress in
print("Progress: \(progress)")
}Cancellation: cancel the surrounding Task; the call throws CancellationError
(checked between solver iterations). Memory use of the reference backend is dominated
by the filter's neighbor pairs, which grow with face count × eta²: about 1.5 GB for a
327K-face mesh at the default eta (0.7 GB at eta = 1). Lower eta to reduce it; the
choice of linear solver makes little difference to memory.
On macOS, app code that works with USD/USDZ assets can use the ModelIO/SceneKit adapter directly:
import MeshDenoiserKit
var params = MeshDenoiseParameters()
params.backend = .automatic
let options = MeshAssetDenoiseOptions(
parameters: params,
preprocessing: .none
)
let preflight = try MeshAssetDenoiser.preflight(
inputURL: inputUSDZ,
options: options
)
let preview = try await MeshAssetDenoiser.preview(
inputURL: inputUSDZ,
options: options
) { progress in
print("Preview progress: \(progress)")
}
let denoisedPositions = preview.meshes[0].denoisedPositions
let summary = try await MeshAssetDenoiser.process(
inputURL: inputUSDZ,
outputURL: outputUSDZ,
options: options
) { progress in
print("Progress: \(progress)")
}
// If the app already has an MDLAsset loaded, avoid a second read:
let inMemorySummary = try await MeshAssetDenoiser.process(
asset: modelIOAsset,
outputURL: outputUSDZ,
options: options
)MeshAssetDenoiser.preview(...) denoises each MDLMesh and returns original
positions, denoised positions, and shared triangle indices without writing or
mutating the source asset. Use it for interactive app previews where exporting
a full USDZ would make the UI wait on asset packaging.
MeshAssetDenoiser.process(...) writes a new denoised asset, preserving vertex
count/order, submeshes, materials, UVs, and transforms. USDZ output patches the
USD geometry and repacks the asset because ModelIO can read USDZ but does not
export it directly. The URL-based process(inputURL:outputURL:...) API requires
distinct input and output URLs so a failed export cannot overwrite the source
asset. Asset export writes to a temporary sibling file first and replaces the
final output only after export succeeds. Progress callbacks are monotonic and
cover 0...1.
preflight(...) returns the same mesh counts and preprocessing diagnostics
without denoising or exporting, which is useful for showing validation failures
before starting the processing step.
Topology-changing repair is not applied inside this asset adapter; use
.conservativeValidation(...) only when you want to reject assets that would
need face/topology changes before denoising. Validation allows unused-vertex
compaction diagnostics because the adapter keeps the original vertex buffer
shape and denoises referenced and unreferenced vertices in place.
Swift backend selection:
| Backend | Status |
|---|---|
.reference |
Default C++/OpenMesh/Eigen backend; exact golden parity target |
.nativeCPU |
Native Swift pipeline with CPU fixed-point filter and built-in conjugate-gradient vertex update |
.nativeGPU |
Native Swift pipeline with Metal fixed-point filter; preprocessing and the built-in conjugate-gradient vertex update stay on CPU |
.automatic |
Uses native CPU by default; explicit .nativeGPU is available for benchmarked GPU runs |
The native backends are under active parity/performance validation. Keep .reference
as the default for production until benchmark gates and larger fixture coverage pass.
Benchmark Swift backends with:
swift run -c release MeshDenoiserBench --faces 81920 --backend allUse a local USD/USDZ or OBJ file instead of a generated sphere with:
swift run -c release MeshDenoiserBench --input path/to/model.usdz --backend allExercise the same URL-based asset processing API used by macOS app code with:
swift run -c release MeshDenoiserProcess --input path/to/model.usdz --output path/to/denoised.usdz --backend automatic
swift run -c release MeshDenoiserProcess --input path/to/model.usdz --output path/to/denoised.usdz --backend nativeCPU --repair conservativeMeshDenoiserProcess prints progress=... lines to stderr and a compact
meshes,vertices,faces CSV summary to stdout after export succeeds.
Run the same benchmark across a folder or manifest of app-exported assets with:
swift run -c release MeshDenoiserBench --input-dir path/to/usdz-fixtures --backend all
swift run -c release MeshDenoiserBench --input-list path/to/fixtures.txt --backend all
swift run -c release MeshDenoiserBench --input-dir path/to/usdz-fixtures --backend all --keep-going
swift run -c release MeshDenoiserBench --input-dir path/to/usdz-fixtures --backend all --keep-going --fail-on-error
swift run -c release MeshDenoiserBench --input-dir path/to/usdz-fixtures --backend all --repair conservative --keep-going
swift run -c release MeshDenoiserBench --input-dir path/to/usdz-fixtures --backend all --keep-going --fail-on-error --max-error-threshold 0.002 --mean-error-threshold 0.0002 --max-total-seconds-threshold 10 --max-memory-mb-threshold 8192
swift run -c release MeshDenoiserBench --input-dir path/to/usdz-fixtures --backend nativeGPU --repair conservative --no-reference-check --keep-going --fail-on-error --max-total-seconds-threshold 10 --max-memory-mb-threshold 8192The benchmark importer uses ModelIO on macOS for .usd, .usda, .usdc, and
.usdz, flattens mesh transforms, and triangulates supported submeshes before
calling the buffers-in API. Directory and manifest runs add an input column to
the CSV output so production USDZ fixture results can be compared asset by asset.
Use --keep-going during broad fixture sweeps to keep benchmarking after an
asset fails validation; those rows add status,error columns and include simple
mesh diagnostics such as boundary, non-manifold, and degenerate-face counts.
Add --fail-on-error in CI to complete the sweep and then exit nonzero if any
asset/backend row failed. Add --max-error-threshold and
--mean-error-threshold to make successful native rows fail the sweep when
reference parity exceeds the release gate. Add --max-total-seconds-threshold
and --max-memory-mb-threshold to enforce runtime and peak-RSS budgets on the
same batch pass. Use --no-reference-check for production runtime and memory
gates on large USDZ assets when you do not want the benchmark-only C++ reference
comparison to affect peak RSS; error thresholds are only valid when reference
checking is enabled.
Use --repair conservative to opt into an explicit mesh preflight before
denoising. The conservative pass welds duplicate or near-duplicate vertices,
removes degenerate and duplicate triangles, drops extra faces around
non-manifold edges, orients shared edges consistently, and compacts unused
vertices. It does not fill holes or silently run by default. Repair runs add
per-row CSV diagnostics describing before/after vertex, face, boundary-edge,
non-manifold-edge, and degenerate-face counts, plus removed face and vertex
counts.
CSV output columns are:
backend,vertices,faces,total_secs,max_error_vs_reference,mean_error_vs_reference,peak_memory_mb
With --no-reference-check, native denoise rows omit the reference-error
columns:
backend,vertices,faces,total_secs,peak_memory_mb
Benchmark the native vertex-update stage directly with:
swift run -c release MeshDenoiserBench --mode filter --backend nativeGPU --faces 81920
swift run -c release MeshDenoiserBench --mode vertex-update --faces 81920The filter and vertex-update modes use deterministic generated meshes by
default, accept --input, and print:
mode,backend,vertices,faces,total_secs,peak_memory_mb,checksum
Vendored dependencies are pinned by scripts/vendor_dependencies.sh. SwiftPM
uses an immutable OpenMesh Core snapshot; the CMake CLI intentionally remains on
the stable OpenMesh 11.0 release.
The defaults are tuned for detail-preserving cleanup. Generate a commented template with
MeshDenoiser --write-default-options options.txt, or see MeshDenoiserDefaults.txt.
Options files hold one Name value pair per line (spaces or tabs); options a file
omits keep the defaults below, and unknown names or unparsable values are errors.
| Parameter | Default | Description |
|---|---|---|
Lambda |
0.15 | Regularization weight. Higher = more smoothing per iteration |
Eta |
2.2 | Spatial Gaussian sigma, scaled by average face-centroid distance |
Mu |
0.2 | Guidance normal difference weight |
Nu |
0.25 | Signal normal difference weight |
MeshUpdateClosenessWeight |
0.001 | Vertex position fidelity during mesh update |
MeshUpdateIterations |
20 | Max iterations for mesh vertex update per outer iteration |
MeshUpdateDisplacementEps |
0.1 | Early-stop threshold for mesh update RMS displacement (<=0 disables) |
OuterIterations |
1 | Number of full filtering passes. More = more smoothing |
DeterministicMode |
0 | Force single-threaded execution (0/1) |
LinearSolverType |
1 | Reference backend only: 0=CG, 1=Eigen LDLT, 2=CHOLMOD (falls back to 1 if unavailable). Native backends use their built-in CG vertex update. |
| Library | Version | Type | Notes |
|---|---|---|---|
| Eigen | 5.0.1 | Vendored, header-only | Shared by CMake and SwiftPM; override CLI headers with -DEIGEN3_INCLUDE_DIR=... |
| OpenMesh (CMake CLI) | 11.0 | Fetched automatically (SHA-256 pinned), linked statically | Stable release used for mesh data and traditional format I/O |
| OpenMesh Core (SwiftPM) | cb4e9528 |
Vendored | Immutable upstream snapshot used by the reference backend |
| tinygltf | 3.0.1 | Fetched automatically | C11 glTF 2.0 parser; indexed triangles with dense float32 positions |
| tinyusdz | 0.9.1 | Fetched automatically | USD format support |
| OpenMP | — | Optional | Multi-threaded performance (auto-detected) |
| SuiteSparse CHOLMOD | — | Optional | Faster sparse solver (auto-detected, falls back to Eigen LDLT) |
CMake tests require Python 3. The CTest suite checks the numerical golden fixture and generated GLB/glTF fixtures on macOS, Linux, and Windows.
sudo apt-get update && sudo apt-get install -y build-essential cmake python3
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
ctest --test-dir build --output-on-failure
./build/MeshDenoiser --helpbrew update && brew install cmake
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
ctest --test-dir build --output-on-failure
./build/MeshDenoiser --helpFor OpenMP on macOS, brew install libomp and add to the configure command:
-DOpenMP_CXX_FLAGS="-Xpreprocessor -fopenmp -I$(brew --prefix libomp)/include" -DOpenMP_CXX_LIB_NAMES=omp -DOpenMP_omp_LIBRARY=$(brew --prefix libomp)/lib/libomp.dylib.
Release builds target the generic CPU of the platform so binaries run anywhere. For a
local build tuned to your own CPU, add -DMESHDENOISER_NATIVE_ARCH=ON (do not distribute it).
cmake -S . -B build
cmake --build build --config Release
ctest --test-dir build -C Release --output-on-failure- For glTF and USD files with multiple meshes or transforms, all geometry is combined and transforms are applied automatically before filtering.
- Vertices that share an exact position (UV/normal seams, flat-shaded triangle soups) are merged for filtering, so seams are not treated as open boundaries. The output keeps the input's vertices and faces.
- glTF imports support indexed triangles with dense float32 positions and 8-, 16-, or 32-bit unsigned indices. Sparse accessors and nonindexed primitives are rejected explicitly.
- Output format is determined by file extension (e.g.,
.obj,.ply,.stl).
GitHub Actions builds and uploads artifacts for Ubuntu, macOS, and Windows. The binaries link OpenMesh statically and are built without CPU-specific tuning, so each archive contains a single self-contained executable. Download from the Releases page.
Downloaded binaries may be quarantined. To fix:
xattr -cr meshdenoiser-macos/- MeshSDFilter algorithm is BSD-3-Clause (see
LICENSES/MeshSDFilter-BSD-3-Clause.txt). - OpenMesh license:
LICENSES/OpenMesh-LICENSE.txt. - tinygltf license:
LICENSES/tinygltf-LICENSE.txt. - This project is licensed under the Mozilla Public License v2.0.