Skip to content
Azazel-LabsPublic

About

Mesh Optimization

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

farthing: small change for far things. A little goes a long way: a flight helmet's LOD chain, each level a quarter of the last, at 94,722, 23,679, 5,916 and 1,480 triangles (baked). The last helmet is 1/64 of the first, and looks the same from where it's used.

CI crates.io docs.rs Rust 1.88+ License: MIT-0 Live demo

farthing makes far things small. It builds the LODs, proxies and impostors that stand in for what's in the distance, and it keeps them looking like the original from where they're seen. A farthing was the smallest coin. A little goes a long way.

It's a pure-Rust, engine-agnostic asset optimizer. It has no unsafe code, gives the same output on any machine and any thread count, and is MIT-0 licensed. Use it as a library, a CLI or a build step.

Closer to the original at the same triangle count

DamagedHelmet reduced from 15,452 to 3,349 triangles by meshoptimizer and by farthing, with pixel-error maps against the original. meshoptimizer: 18.4 dB PSNR, silhouette off 1.86%. farthing: 22.1 dB, silhouette off 0.33%.

farthing's reducer protects what you actually see: silhouettes, UV seams, shading normals and texture layout. This is the widest gap of the 24 image comparisons. Across all 24, farthing is ahead in 21, by +1.5 dB on average (every case).

Bar charts of farthing's error as a share of meshoptimizer's at matched triangle counts. Against attribute-aware meshoptimizer: worst-case distance 0.69x, mean distance 0.41x, UV error 0.27x, normal error 0.89x. Against permissive seams: 0.80x, 0.57x, 0.33x, 0.99x. Against positions-only: 1.17x (a loss), 0.62x, 0.29x, 0.40x. Also +1.5 dB in image space, 100% of triangle targets reached, and 5 to 9 times slower.

Measured over 8 assets at 50%, 20%, 5% and 1% of their triangles. The price is speed: farthing is 5–9× slower than meshoptimizer because it measures error much more carefully. That's meant for a build step, not for every frame. A fast mode gets within 2× of meshoptimizer's speed and still has lower distance and UV error (normals are slightly worse). Methodology and per-asset tables are in docs/BENCHMARKS.md.

Every level comes with the distance to use it

FlightHelmet LOD1, LOD2 and LOD3 (a quarter, a sixteenth and a sixty-fourth of the triangles) rendered at their reported switch distances on a 1080p screen next to the original, magnified, with difference maps. 1%, 5% and 16% of pixels differ by more than 10%.

farthing measures each level's worst-case deviation from the original and turns it into a switch distance: the point where that error is one pixel on a 1080p screen. You don't have to tune distances by eye. The farthest level here is a baked proxy, one new mesh with a new UV atlas and colour, normal and roughness/metal textures baked from the original. Most of its difference is the glass visor, which an opaque proxy can't reproduce.

UV atlases with fewer seams and less wasted texture

DamagedHelmet unwrapped into a 1024 by 1024 atlas by farthing and by xatlas tuned for its best result, with unused texture shown in blue and a bar of each atlas's pieces pushed together. farthing: 69.7% of the texture used, 277 charts, 13.6% seams, stretch 1.0034. xatlas: 45.7%, 848 charts, 25.7%, 1.0064.

Far levels need a fresh UV atlas to bake into. Against xatlas at its best settings, with both packed into the same 1024² atlas, farthing makes fewer charts with shorter seams on all 8 benchmark assets and uses more of the texture on 7 (67% on average, against 59%). Stretch is a near tie: farthing is lower on 5 assets, and every result is within 1% of isometric (data). The packer is available on its own as snugly.

Impostors for the far distance

An octahedral impostor atlas of DamagedHelmet (12 by 12 views), and the 15,452-triangle mesh next to its 2-triangle impostor at 160, 96 and 48 pixels on screen, magnified, with difference maps. 27.5, 28.5 and 25.1 dB.

Past a hundred pixels or so, a whole model becomes one card. farthing plans the views, your engine renders them with its real materials, and farthing assembles a relightable atlas sized for one texel per screen pixel. It ships with a reference shader that blends between views and marches depth for parallax.

What's in the box

What it does How
Reduction LODs by ratio, triangle count, error bound or screen size, with a switch distance for each level Attribute-aware quadric edge collapse (UVs, colours, normals, skin weights, morph targets), silhouette-weighted, with batched parallel rounds. UV seams are permissive or strict. Whole parts are removed when they stop being visible. Symmetry is detected and preserved. Includes a fast mode
Quad reduction Lowers quad meshes and keeps them all quads Removes edge rings, ranked by quadric error, in linear time
Proxies Rebuilds a messy kitbash or scene as one closed, manifold mesh Voxelization with sub-voxel signed boundaries, leak-aware gap sealing, surface nets, then reduction
UV atlases Fresh atlases for proxies and far LODs LSCM + ARAP charts with exact overlap tests, split and merge under stretch limits, packed by snugly
Baking Moves colour, normal, roughness/metal and any custom channel onto the new mesh farthing finds where each texel comes from on the source surface, and your engine shades it. Supersampled, dilated, MikkTSpace normals. Has a two-phase API for async GPUs
Cluster LOD Nanite-style cluster DAGs for per-cluster LOD on the GPU, crack-free for any camera Meshlets (128 triangles, 255 vertices) grouped by shared border, each group reduced to half with its border locked, levels down to one root. Errors and bounds grow monotonically, so the select test runs per cluster with no traversal. Parallel, deterministic
HLOD Turns a whole area (buildings, props, terrain pieces) into one distant proxy with one atlas, or a whole level into a tree of nested cells Camera-sampled hidden-surface removal, small parts kept whole, merge, then reduction or voxel remesh, unwrap, and a bake from the visible source only. Loose-octree cells, each parent built from its children, switch distances per node and a selection that covers every instance exactly once
Aggregation Merges many objects into one draw call Material classes, island repacking by texel density, texel copy
Impostors Cards for the far distance Octahedral, hemi-octahedral, flipbook or single view, with an optional second depth layer, billboard clouds and progressive surfel clouds, which also export as 3D Gaussian splats (.ply) shaped to the surface each one stands for. Reference WGSL included
Visibility & occluders Removes what can't be seen and builds occlusion geometry Camera-sampled ID renders (alpha-aware through your engine), plus conservative self-tuning occluder meshes and boxes
Bones Fewer joints and influences for far LODs Leaf-first removal by skinned error, influence limiting
I/O glTF/GLB (skins, morphs, embedded textures) and OBJ Optional io feature

farthing never decides what anything looks like. It handles geometry, UVs, correspondence and packing. Anything that needs real materials goes through a small RenderHost trait that your engine implements. That covers baking, impostor captures and alpha-aware visibility. So world-space, procedural and engine-specific materials all bake correctly. engine-poc/ is a minimal wgpu engine that proves the boundary with procedural materials farthing never sees.

Try it

In the browser. Open the live demo (source in demo-web/). It runs the whole pipeline in WebAssembly. Drop a glTF, watch the LOD chain build and bake, and fly out to see the levels switch. Nothing is uploaded.

From the command line. Grab a prebuilt binary from Releases, or build it:

cargo install farthing-cli

farthing lods model.glb -o lods/ --levels 4 --far-from 3   # LOD chain, far levels baked
farthing reduce model.glb -o lod.glb --ratio 0.25           # one level
farthing reduce model.glb -o lod.glb --ratio 0.25 --strict-seams --symmetry
farthing remesh scene.glb -o proxy.glb --resolution 128 --triangles 3000
farthing uv far.glb -o far_uv.glb
farthing occluder scene.glb -o occ.glb --triangles 300 --boxes 8
farthing bones character.glb -o char_lod.glb --ratio 0.5 --influences 2
farthing quad-reduce character.obj -o character_lod.obj --ratio 0.25
farthing clusters model.glb -o dag.json                     # Nanite-style cluster DAG
farthing hlod block.glb -o far.glb --triangles 2000 --hemisphere   # distant proxy of an area
farthing hlod level.glb -o hlod/ --cells 64 --hemisphere     # nested HLOD cells + hlod.json
farthing splats tree.glb -o tree.ply --count 20000         # foliage as Gaussian splats (--round: old fixed discs)

As a library:

[dependencies]
farthing = "0.15"                                            # with glTF/OBJ I/O
# farthing = { version = "0.15", default-features = false }  # geometry only
Integration level How Use when
Geometry only Call simplify, remesh, occluder, parameterize and others on Mesh/Scene values Offline tools and build steps with no material needs
RenderHost Implement the five-method trait (example: engine-poc/src/engine.rs) Baking, impostors and visibility with your engine's real materials
Two-phase bake prepare_bake → shade the samples on your GPU, any thread, any timing → finish_bake Engines that render asynchronously or batch GPU work
Files + CLI glTF/OBJ in, glTF/OBJ and JSON reports out Asset pipelines, CI, non-Rust tooling

Using the library

farthing is a set of functions over two data types. Each function takes plain data, returns plain data and keeps no global state, so you can call them in any order, from any thread. This section goes through them in roughly the order an asset pipeline uses them. Every snippet runs inside a function that returns farthing::Result<()>.

Meshes and scenes

A Mesh is an indexed triangle list. Its vertices are wedges: one vertex per unique set of attributes, so a UV seam is two vertices at the same position. GPUs use the same layout, so you can build a mesh straight from your vertex buffers:

use farthing::Mesh;

let mut mesh = Mesh::new(positions, indices);       // Vec<Vec3>, Vec<u32>
mesh.normals = Some(normals);                       // all optional: normals, tangents,
mesh.uvs = vec![uv0];                               // any number of UV sets, colours,
mesh.material_ids = vec![0; mesh.triangle_count()]; // skin weights, morph targets
mesh.validate()?;

A Scene holds meshes, instances with world transforms, skeletons and material slots. A slot is just a name and an id. farthing never knows what a material looks like. The io feature reads and writes glTF, GLB and OBJ:

let scene = farthing::io::load("model.glb")?;                   // .glb, .gltf or .obj
let (scene, materials) = farthing::io::load_gltf("model.glb")?; // plus the decoded glTF materials
let (scene, materials) = farthing::io::load_gltf_with(&bytes, |uri| files.get(uri).cloned())?; // no file system
let scene = farthing::Scene::from_mesh(mesh);                   // one mesh at the origin

Reduction

simplify reduces one mesh. The result includes the largest deviation it introduced, in world units:

use farthing::reduce::{simplify, SimplifyOptions};

let r = simplify(&mesh, &SimplifyOptions::ratio(0.25))?;
println!("{} triangles, error {}", r.mesh.triangle_count(), r.error);

There are three ways to set a target: a share of the triangles (target_ratio), a count (target_triangles) or an error bound (max_error). With more than one target, stop decides whether reduction ends at the first one met (StopWhen::Any) or the last (StopWhen::All). For an object that is never larger than a known size on screen, convert pixels to an error bound:

use farthing::reduce::{screen_size_to_max_error, SimplifyOptions};

// 300 px across on screen, allowed to be off by half a pixel.
let max_error = screen_size_to_max_error(mesh.bounds().diagonal(), 300.0, 0.5);
let opts = SimplifyOptions { max_error: Some(max_error), ..Default::default() };

The defaults are what scored best in the benchmarks. These are the options you're most likely to change:

use farthing::reduce::{Quality, SeamMode, SimplifyOptions, Symmetry};

let opts = SimplifyOptions {
    target_ratio: Some(0.1),
    seams: SeamMode::Strict,   // UV islands keep their exact outlines (may stop short of the target)
    symmetry: Symmetry::Auto,  // find a mirror plane and keep both halves identical
    lock_border: true,         // open edges stay put, so tiles and modular pieces still line up
    error_normalization: 1.0,  // classic QEM: protects small features, costs overall fidelity
    quality: Quality::Fast,    // several times faster; skips symmetry and part removal
    ..Default::default()
};

A few more options:

  • vertex_importance scales the cost of collapsing each vertex.
  • locked_vertices pins vertices in place.
  • transparent_materials lists glass and visors, so parts behind them still count as visible.

The result also maps vertices both ways:

  • vertex_remap gives, for each input vertex, the output vertex it collapsed into. Use it to geomorph between levels.
  • source_vertex gives, for each output vertex, the input vertex it copies. Use it to carry your own vertex data across.

simplify_scene reduces every mesh of a scene in parallel.

LOD chains and switch distances

lod_chain reduces each level from the one before and adds up the errors, so every level reports how far it is from the original:

use farthing::reduce::{lod_chain, SimplifyOptions};

let levels = lod_chain(&mesh, &[0.25, 0.0625, 0.015625], &SimplifyOptions::default())?;
let (screen_height, fov_y) = (1080.0, 60f32.to_radians());
for (i, level) in levels.iter().enumerate() {
    // The distance at which this level's error covers one pixel: switch to it from there out.
    let switch = level.error * screen_height / (2.0 * (fov_y / 2.0).tan());
    println!("LOD{i}: {} triangles, from {switch:.1} m", level.triangles);
}

Errors are in the mesh's own units. For an instance drawn at a scale, multiply by the scale.

New UVs for far levels

Far away, an original's UV layout wastes texture: every material keeps its own islands, and the seams between them keep the reducer from simplifying further. Drop the attributes, reduce the bare geometry, and give it one fresh atlas:

use farthing::reduce::{repair, simplify, SimplifyOptions};
use farthing::uv::{parameterize, ParamOptions};

let (flat, _) = scene.flatten();               // every instance in world space
let mut far = repair::geometry_only(&flat);    // positions only: no seams to hold it back
far.material_ids.iter_mut().for_each(|m| *m = 0);
let opts = SimplifyOptions { target_triangles: Some(500), ..Default::default() };
let mut far = simplify(&far, &opts)?.mesh;
far.compute_normals(60.0);                     // hard edges above 60°

let uv = parameterize(&far, &ParamOptions { atlas_size: 512, padding: 2, ..Default::default() })?;
println!("{} charts, {:.0}% of the atlas used", uv.charts, uv.utilization * 100.0);

parameterize returns the mesh split along chart boundaries, with the new UVs in set 0. Every chart gets the same texel density. Set texels_per_unit to choose that density yourself. ParamOptions::planar() is a faster unwrap for boxy shapes.

Baking through your engine

The new mesh needs textures made from the original's materials. farthing works out where each texel comes from on the source surface, and your engine decides what that point looks like, through the RenderHost trait:

use farthing::glam::Vec4;
use farthing::host::*;
use farthing::image::Image;
use farthing::MaterialId;

struct MyEngine { /* your materials */ }

impl RenderHost for MyEngine {
    fn material_info(&self, _material: MaterialId) -> MaterialInfo {
        MaterialInfo::default() // opaque, one-sided
    }
    fn bake_surface(&mut self, req: &SurfaceBakeRequest) -> farthing::Result<ChannelImages> {
        // One optional sample per texel: the source surface's world position, normal,
        // tangent, both UV sets and vertex colour at that texel, and its material.
        let mut color = Image::new(req.width, req.height, Vec4::ZERO);
        for (i, s) in req.samples.iter().enumerate() {
            if let Some(s) = s {
                color.pixels[i] = self.shade(s.material, s.uv[0], s.position);
            }
        }
        let mut out = ChannelImages::new();
        let image = ChannelImage { image: color, encoding: ChannelEncoding::Color };
        out.insert(channels::BASE_COLOR.into(), image);
        Ok(out)
    }
    fn render_views(&mut self, _req: &ViewRenderRequest) -> farthing::Result<Vec<ViewImages>> {
        todo!("only impostors need this")
    }
    fn create_material(&mut self, _desc: &BakedMaterialDesc) -> farthing::Result<MaterialId> {
        todo!("only needed when you ask farthing to make the material")
    }
}

Your engine shades points instead of handing over textures. That's why world-space, triplanar and procedural materials bake correctly. GeometryOnlyHost is a host with no materials. It is enough for reduction, proxies, occluders, bones and visibility. With a host in hand, bake:

use farthing::bake::{bake_with_host, compute_tangents, BakeOptions};
use farthing::glam::Mat4;
use farthing::host::channels;

let mut far = uv.mesh;
compute_tangents(&mut far, 0)?;                // MikkTSpace, for the baked normal map
let opts = BakeOptions {
    width: 512,
    height: 512,
    channels: vec![channels::BASE_COLOR.into(), channels::NORMAL.into(), channels::ORM.into()],
    ..Default::default()
};
let baked = bake_with_host(&mut engine, &far, Mat4::IDENTITY, &scene, &opts)?;
let color = &baked.textures[channels::BASE_COLOR].image;

A few things the bake handles for you:

  • Normals. Return them as ChannelEncoding::NormalWorld and farthing converts them to the new mesh's tangent space.
  • Quality. Each texel is supersampled (2×2 by default), and the edges are dilated into the gutters.
  • Re-baking. baked.correspondence keeps the traced map from texels to the source surface, so you can bake more channels later without tracing again.
  • Textures you already have. If your materials are already flat images in the source UVs, bake_flattened copies texels and needs no host at all.

If your engine shades on a GPU queue, on another thread or across an FFI boundary, split the bake in two:

use farthing::bake::{finish_bake, prepare_bake};

let prepared = prepare_bake(&far, Mat4::IDENTITY, &scene, &opts)?;
let shaded = gpu.shade(&prepared.request).await; // ChannelImages; any time, any thread
let baked = finish_bake(prepared, shaded)?;

Proxies and occluders

remesh turns a whole scene into one closed, manifold mesh. It works on kitbashes, piles of intersecting parts and buildings with interiors you'll never see, and gives a clean stand-in for the far distance. Unwrap and bake it as above.

use farthing::voxel::{remesh, RemeshOptions};

let proxy = remesh(&scene, &RemeshOptions {
    resolution: 128,              // voxels along the longest side
    close_gaps: 2,                // seal leaks up to two voxels wide
    target_triangles: Some(3000),
    ..Default::default()
})?;

occluder builds a low-poly mesh that stays inside the original, for occlusion culling. box_occluders returns a few boxes instead:

use farthing::voxel::{box_occluders, occluder, OccluderOptions};

let occ = occluder(&scene, &OccluderOptions { target_triangles: 300, ..Default::default() })?;
let boxes = box_occluders(&scene, 64, 8)?;    // up to 8 boxes from a 64-voxel grid

Impostors

Once a model is a few dozen pixels on screen, a camera-facing card showing a picture of it costs less than any mesh. Give farthing the size on screen where the impostor takes over. It plans the cameras, has your engine render them, and packs the atlas:

use farthing::impostor::{build_impostor, frame_size_for_screen, ImpostorKind};

let frame = frame_size_for_screen(96.0);       // 96 px across when the impostor takes over
let kind = ImpostorKind::Octahedral { frames_per_side: 12, hemisphere: false };
let imp = build_impostor(&mut engine, &scene, &kind, frame, false)?;
// imp.atlas: colour, normal and height atlases. imp.quad: the card.
// imp.params: serialize it next to the atlas for the shader.

Other kinds of impostor:

  • hemisphere: true, for objects never seen from below.
  • ImpostorKind::Flipbook, a ring of views around the object.
  • ImpostorKind::SingleView, one view from a fixed direction.
  • build_layered_impostor, which captures a second depth layer for concave props.
  • billboard_cloud and progressive_surfel_cloud, for shapes one card can't hold.
  • impostor::splats, which turns a surfel cloud into 3D Gaussians and writes the standard 3D Gaussian Splatting .ply, for foliage in any splat renderer. With footprints from impostor::footprint, each splat takes the shape of the patch of surface its surfel stands for: stretched along leaf strips, as opaque as the patch is covered (so coarse levels of sparse foliage stay see-through), and thicker and rougher where the normals spread. Colours come from your engine, evaluated at each surfel's source point or averaged over its patch (average_surfel_channel).

The reference shader is shaders/octahedral_impostor.wgsl.

Merging objects

aggregate merges a scene into one mesh with one atlas per material class, so a room full of props becomes one draw call. Then copy each texture into the atlas:

use farthing::aggregate::{aggregate, copy_texels, AggregateOptions};

let infos: Vec<_> = scene.materials.iter().map(|m| engine.material_info(m.id)).collect();
let merged = aggregate(&scene, &infos, &AggregateOptions { atlas_size: 2048, ..Default::default() })?;
let color = copy_texels(&merged, &flattened, channels::BASE_COLOR)?; // flattened: FlattenedTextures

Cluster LOD

build_cluster_dag builds the cluster hierarchy that Nanite-style renderers select from per cluster: meshlets at the bottom, coarser clusters above, one root at the top. Every cluster indexes the source mesh's vertex buffer, so all levels share it.

use farthing::cluster::{build_cluster_dag, ClusterOptions, LodView};

let dag = build_cluster_dag(&mesh, &ClusterOptions::default())?; // or ClusterOptions::fast()
// Per cluster: indices, bounds, normal cone, lod_error/lod_bounds, parent_error/parent_bounds.
// On the GPU: draw a cluster when its own error projects under the threshold and its parent's
// doesn't. Errors and bounds only grow towards the roots, so any camera gets a crack-free cut.
let visible = dag.select(&LodView { eye, pixels_per_radian, threshold: 1.0 });
let (vertices, local_indices) = dag.clusters[0].meshlet(); // u8 meshlet layout

Compared with a DAG built the way meshoptimizer's clusterlod.h builds one, at equal measured on-screen error: on the 135k-triangle dragon it needs 1.7–4× fewer triangles (up to 16× in fast mode), and it always reaches a single root. On small assets the results are mixed, and meshoptimizer wins on the Lantern. See BENCHMARKS.

Distant LOD for a whole area (HLOD)

build_hlod turns everything in a scene into one stand-in for the far distance. Before merging, it renders the scene from a ring of cameras and removes what none of them see: the insides of crates, the floors under walls, faces pressed together. Pebbles and bolts too small to matter at that distance are kept or dropped whole, never as shards. The rest is merged, reduced (or voxel-remeshed into one shell), unwrapped, and baked from the visible source, so hidden geometry never bleeds into the atlas.

use farthing::hlod::{build_hlod, HlodGeometry, HlodOptions};

let hlod = build_hlod(&mut engine, &scene, &HlodOptions {
    target_triangles: Some(2000),
    geometry: HlodGeometry::Reduce,       // or HlodGeometry::Remesh(RemeshOptions { .. })
    ..Default::default()                  // 64 cameras, 1024² atlas, colour + normal + ORM
})?;
// hlod.mesh: world-space proxy. hlod.bake: its textures. hlod.hidden_triangles: what was culled.

Set bake: None to get the unwrapped mesh and bake in your engine, and visibility: None to keep everything. Use CameraSet::Hemisphere for things standing on the ground, so their undersides go too.

Nested cells. build_hlod_tree partitions a whole level and builds a tree of proxies, as world-partition HLOD layers do. Every instance goes to one cell of a loose octree (or a quadtree over XZ) by the centre of its bounds. Instances too large for a cell go up to the first level whose cells fit them. Each leaf cell gets a proxy of its instances, and each parent gets a proxy of its children's proxies (or of the source, with ParentSource::Source). Cells double in size per level, and so does the error budget. Every node gets the same triangle budget, at most half its input, so each level is coarser than the one below. Hidden-surface removal for a node sees everything it replaces at once.

use farthing::cluster::LodView;
use farthing::hlod::{build_hlod_tree, HlodOptions, HlodTreeOptions};

let tree = build_hlod_tree(&mut engine, &scene, &HlodTreeOptions {
    cell_size: 64.0,                      // leaf cells; level l is 64 × 2^l
    proxy: HlodOptions { target_triangles: Some(2000), ..Default::default() },
    ..Default::default()
})?;
// Per node: bounds, the instances it replaces, its proxy (mesh + bake + engine material),
// and an error in world units: draw it beyond node.switch_distance(pixels_per_radian, 1.0).
let cut = tree.select(&LodView { eye, pixels_per_radian, threshold: 1.0 });
// cut.nodes: proxies to draw. cut.instances: originals to draw. Each instance exactly once.

A node's error uses the same convention as cluster LOD, and counts one atlas texel as error too, so a proxy never shows while its texels are bigger than the pixel threshold. Built from children, errors add up level by level. build_hlod_tree_with hands each node to a callback as soon as the builder no longer needs it, so an exporter can write proxies out and free them as it goes. The CLI's --cells writes one glTF per node and an hlod.json manifest with each node's bounds, instances, error and switch distance.

Visibility

Remove what no camera can see, such as the underside of a building or the inside of a closed box:

use farthing::visibility::{compute_visibility, cull_invisible, CameraSet, VisibilityOptions};

let vis = compute_visibility(&mut engine, &scene, &VisibilityOptions {
    cameras: CameraSet::Hemisphere { count: 64, distance_factor: 2.5 },
    resolution: 256,
})?;
let (culled, removed) = cull_invisible(&scene, &vis, 1); // keep one ring around what was seen

The default render_ids is a CPU ray caster that treats alpha-tested materials as opaque. Override it in your host to let cutouts through. CameraSet::Points places cameras along a play path instead.

Skinned meshes

A character's far levels need fewer bones and fewer influences per vertex:

use farthing::bones::*;

let opts = BoneReductionOptions { ratio: Some(0.5), ..Default::default() };
let plan = plan_bone_reduction(&skeleton, &[&mesh], None, &opts)?;
apply_bone_reduction(&mut mesh, &plan);
let (skeleton, remap) = compact_skeleton(&skeleton, &plan);
remap_joint_indices(&mut mesh, &remap);
limit_influences(&mut mesh, 2);

Pass animation poses as the third argument and each bone's removal error is measured in motion, not just from the bind pose.

Writing results

use farthing::io::{save_glb, ExportTextures};
use farthing::reduce::optimize::optimize_mesh;

optimize_mesh(&mut far);                       // vertex cache and vertex fetch order
let out = farthing::Scene::from_mesh(far);
let mut tex = ExportTextures::default();
tex.base_color.insert(0, baked.textures[channels::BASE_COLOR].image.clone());
tex.normal.insert(0, baked.textures[channels::NORMAL].image.clone());
save_glb(&out, "far.glb", &tex)?;              // glb_bytes(&out, &tex) for memory

The option structs implement serde, so pipeline settings can live in your asset database. Work runs on rayon's thread pool, and the output is the same on any thread count.

Honest limits

  • Speed. The default reducer is 5–9× slower than meshoptimizer on small assets and 2–3× on large ones. About 88% of the work runs in parallel. Fast mode closes most of the gap.
  • Cluster DAG build speed. Faster than a serial meshoptimizer clusterlod.h-style build from about 4 threads up, but 1.4× (fast) to 3× (default) slower per core. On Windows, link mimalloc as the global allocator: the system heap limits parallel scaling.
  • Worst-case distance against positions-only reducers. Ranking by area-weighted error trades a little worst-case distance for much better mean, UV and normal fidelity. Set error_normalization = 1.0 for classic QEM when only the outline matters.
  • Very aggressive targets (1% and below). farthing leads on every quality average, but a few cases are still worse, such as a watch's strap lugs at 1%.
  • Proxies can trail marching cubes on deep, narrow slots (the dragon: 1.39% vs 0.87% worst case, though marching cubes leaves 6% of it open). farthing's proxies are always closed and manifold.
  • Opaque proxies can't reproduce transmission or refraction, as the visor above shows.

The details, and what was tried and reverted, are in docs/BENCHMARKS.md. Technique sources are in PROVENANCE.md, and ideas borrowed from other sciences are in docs/research/.

Patent shame list

Patents on techniques in farthing's space that it deliberately stays clear of, with how it does so. Status and dates are from Google Patents as of November 2026; Google itself labels status an assumption, not a legal conclusion. This is a working list, not legal advice, and farthing has not had a freedom-to-operate review. If you know of one that belongs here, open an issue.

Active: designed around

Patent Owner What it claims, in short Status How farthing stays clear
US 12,112,510 B2, Convolutional approach to fast and compact packing of 3D mesh into 2D maps Tencent America LLC Placing each UV chart where a correlation between the atlas occupancy map and the chart's occupancy matrix shows no overlap Active, granted 2024-10-08, est. expiry 2043-05-11 The packer drops charts onto a per-column skyline outline; it never correlates masks against an occupancy map. FFT/correlation placement, proposed twice in the v8 research, is off the table.
US 12,394,102 B2, Mesh parameterization with temporally correlated UV atlases Tencent America LLC Generating a frame's UV atlas by aligning its charts with reference charts from previous frames, via packing Granted 2025-08-19 (filed 2023-03-16) farthing parameterizes each mesh on its own; it has no per-frame atlases for animated sequences. Anything like that would need a different design.
US 9,978,176 B2, Simplifying small mesh components with redundant backs Electronic Arts Inc. Casting a ray along a surface's normal and removing that surface from a component when the ray hits other geometry within a threshold distance Active, granted 2018-05-22, est. expiry 2036-03-31 Part removal (v8) deletes whole connected parts or nothing, using closest-point distances. It casts no normal rays and never strips individual surfaces from a part.

Expired: free for everyone (listed for credit, and so nobody avoids them by mistake)

Patent Owner What it covered Status
US 6,362,820 B1, Quadric metric for simplifying meshes with appearance attributes (Hoppe) Microsoft The attribute quadric with wedges that farthing's reducer builds on Expired (lifetime)
US 8,860,723 B2, Bounded simplification of geometrical computer data Donya Labs AB (Simplygon), now Microsoft Collapse-based reduction that projects removed geometry onto the reduced mesh and stops at an error bound Lapsed 2022-11-21 for unpaid fees
US 8,698,809 B2, Creation and rendering of hierarchical digital multimedia data Donya Labs AB, now Microsoft Repeatedly combining objects and simplifying each combination into a stand-in (hierarchical LOD) Expired (fees)

Layout

crates/farthing/       the library: data model at the root, then io/ reduce/ uv/ bake/
                       aggregate/ voxel/ visibility/ impostor/ bones/, reference WGSL in shaders/
crates/farthing-cli/   the `farthing` binary
demo-web/              WebAssembly browser demo (separate workspace)
engine-poc/            wgpu engine implementing RenderHost (separate workspace)
bench-compare/         head-to-head vs meshoptimizer, xatlas, baby_shark (separate workspace)
docs/                  BENCHMARKS.md, raw benchmark data, figures

License: MIT-0 (MIT No Attribution).

About

Mesh Optimization

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages