WINDAGE ENGINE v1.0.0
Contents

Docs / Rendering

Level of detail

MeshDetail makes simpler versions of a mesh when it loads, and gives you the rule that picks one for how far away a copy stands. You need it when a scene has thousands of copies of a heavy mesh, most of whose triangles are detail too small to see from where they stand.

Tiers

A mesh gets up to ten tiers (MeshDetail.Options.maximumTiers), each aiming for about half the triangles of the one before (reduction). Tier 0 is the mesh as loaded. They are called tiers rather than levels because in this engine a level is a Level file.

Tiers stop early when a tier would keep more than stall (85%) of the triangles before it, and a submesh of smallestTriangleCount (12) triangles or fewer is left alone. Those are the defaults of MeshDetail.Options; pass your own as options: when your meshes ask for different ones.

For a mesh that came through MeshLoader, one call does everything:

// Your game, after MeshLoader.load(…). Safe off the main thread, and worth running there.
let tiers = MeshDetail.tiers(for: loaded, vertexDescriptor: layout, positionAttribute: 0, normalAttribute: 1)

vertexDescriptor is the one the mesh was loaded with. Attribute positionAttribute is where each vertex's position is read, and normalAttribute where the normal each tier rewrites goes; both are three floats in the same buffer, whose layout gives the stride. The mesh's own buffers are read on the CPU. A mesh with a submesh that is not triangles gets no tiers. Simplifying a 19,000-triangle mesh takes a few milliseconds.

Each MeshDetail.Tier has:

PropertyWhat it is
vertices[UInt8] in the full mesh's vertex layout, three vertices per triangle
submeshes[[UInt32]], per submesh, indices into vertices
errorthe worst of its submeshes' errors, in the mesh's units

Tier i is reduction i of every submesh, so a copy changes all its parts at once. A submesh with fewer reductions repeats its coarsest.

The steps, for a mesh that did not come from MeshLoader

The call above is two public steps. reductions(indices:positions:vertexCount:stride:) simplifies one submesh into [MeshDetail.Reduction], each with indices into the full mesh's vertices and an error; positions points at the first vertex's three position floats. tiers(_:full:vertices:stride:positionOffset:normalOffset:) turns per-submesh reductions into tiers; full is each submesh's own indices, and vertices points at the first vertex itself. Both take options:. Every reduction is made from the full mesh, so errors do not add up from tier to tier.

Choosing a tier: the half-pixel rule

Each tier's error is how far its surface strays from the original, in the mesh's units. Seen from far enough away, that covers less than half a pixel and the tier cannot be told from the full mesh. So there is no table of distances to tune per asset.

// Your game, per copy, per frame. `fovy` is the vertical field of view your game passes to
// simd_float4x4(perspectiveFovY:aspectRatio:nearZ:); `viewportHeight` is in pixels,
// from viewportSizeChanged(width:height:). `box` is the copy's BoundingBox in the world.
let radius = simd_length(box.maximum - box.minimum) / 2
let distance = simd_distance(camera.position, box.center) - radius
let tier = MeshDetail.coarsestTier(errors: tiers.map(\.error),
                                   distance: distance,
                                   scale: copyScale,                 // the copy's uniform scale
                                   pixelsPerRadian: Float(viewportHeight) / fovy)

Half a pixel, not one. At one pixel, a wood of trees six pixels tall visibly lost their tiers of branches: a pixel of error all round an outline that small adds up to a different shape.

The tests check that a farther copy never gets a finer tier than a nearer one.

Recomputed normals

A simplified mesh keeps some vertices and drops the rest, and a kept vertex keeps its normal. On a faceted prop that normal belonged to one face of many, perhaps one facing the ground, so a far copy drew nearly black. Every tier therefore gets its own vertices, with normals made again from its own faces: each corner's normal is the area-weighted average of the faces meeting at its position that bend less than 60° from its own face (MeshDetail.Options.creaseCosine), so hard edges stay hard.

The cost is three vertices per triangle of each tier, which halves with every tier: about 1 MB for every tier of a 19,000-triangle tree together.

Permissive simplification

The simplifier may collapse across hard edges. Low-poly packs split every vertex at every hard edge, so a careful simplifier finds nothing it may touch. The price is softened shading on a far copy, below half a pixel.

Drawing a tier is the game's job

MeshDetail is the CPU half only, like InstanceBatches. Putting a tier's vertices in a buffer and drawing them is yours, because only your game knows its vertex layout and how it batches.

Copies at different tiers are different batches. Key InstanceBatches by mesh and tier:

// Your game.
struct DrawKey: Hashable, Sendable {
    var mesh: Int
    var tier: Int
}
let batches = InstanceBatches<DrawKey>(copies)   // copies: [(DrawKey, simd_float4x4)]

See instancing and culling.

What it does not do

The license you ship

The simplifier is meshoptimizer's (MIT), vendored unmodified as the MeshOptimizer target: the engine's first code not written in Swift. An app that ships MeshDetail ships meshoptimizer's license, Sources/MeshOptimizer/LICENSE.md, alongside its other notices.

When it was added, 1,000 trees of 19,000 triangles each went from 25 ms a frame to 5.6 ms on an M2.