API Reference · Geometry

Mesh

Namespace: Ava3D

public sealed class Mesh

Triangle geometry, independent of where it sits in the world.

A mesh is shared: put the same one on twenty MeshNodes and it is uploaded to the GPU once. That is why the arrays are init-only — the renderers cache GPU buffers against mesh identity, and an array that could be swapped out from under them is an array whose length could change, which is a buffer size and an index count and a draw call all silently wrong at once.

The contents of those arrays are another matter. Writing new values into them and calling Mesh.InvalidateGeometry is how geometry animates: a flag, a wake, a heightfield under a cursor, a spline the user is dragging. The length cannot change that way, which is exactly why it is safe. To change the length, build a new mesh and assign it to MeshNode.Mesh.

Properties

MemberDescription
BoundingBox Bounds { get; }

Local-space extent, computed once on first use.

Vector4[] Colors { get; set; }

Per-vertex colour, multiplied into the material's base colour and its base-colour map.

Optional, and white where a mesh does not carry it. This is glTF's COLOR_0 and it means what glTF says it means — a tint, not a replacement — so a white vertex colour is the identity and a mesh gaining one changes nothing until the values do.

What it is for is the case that would otherwise be several meshes. A hull banded into light, base and dark for cel shading is three materials and three draw calls when the colour lives on the material, and one of each when it lives here; so is a terrain tinted by height, or a plume fading from mouth to tip. Uploaded as four bytes a vertex rather than four floats, which is why it costs eight per cent of the vertex buffer and not a third — see GlMeshCache.FloatsPerVertex.

Held as Vector4 here because that is what a caller computing one wants, and packed on the way to the card. The two budgets are different and only the second one is tight.

required uint[] Indices { get; set; }

Triangle list. Length is always a multiple of three.

Vector3[][] MorphNormalTargets { get; set; }

How each target bends the surface, as a per-vertex normal displacement parallel to Mesh.MorphTargets — same length, same order, and an entry may be null for a target that only moves vertices.

A moved vertex is lit by the normal it had before it moved unless something says otherwise, and on a lengthened snout or a widened jaw that reads as a shape lit for a different shape. Exporters know this: every one of the reference character's targets carries a NORMAL beside its POSITION, because Blender writes both by default.

The authored answer rather than a derived one, and the difference is not only fidelity. Deriving normals from the morphed triangles means a pass over the triangles — fifty times what blending this array costs, measured — and it averages across every edge, so the hard edges the artist authored come back soft. See MeshNode.MorphNormals, which is now the fallback for a file that supplied none rather than the way normals are usually got.

Vector3[][] MorphTargets { get; set; }

Shapes this mesh can be moved towards, each one a per-vertex displacement from the positions above. One array per target, each as long as Mesh.Positions; shorter is legal and the vertices past its end do not move.

A face that never changes is a mask, and this is the difference a viewer notices without being able to name it. One blink is worth more surface detail than any map: it is the cheapest signal that a thing is alive, and it is a hundred vertices moving a millimetre for a tenth of a second.

Displacements rather than absolute positions, which is what glTF's morph targets are and is the representation that composes: several targets at once add up, so a blink and a jaw drop can run together without either knowing about the other. Absolute positions do not compose at all — two of them at half weight is the average of two faces, which is a third face that neither target described.

The weights live on the node rather than here — see MeshNode.MorphWeights — because the mesh is the shape and the weights are what is being done with it. Two patrons sharing one head mesh blink independently, and would not if the weight were a property of the geometry.

string Name { get; set; }

Optional name, carried through from the source file for debugging.

Vector3[] Normals { get; set; }

Per-vertex normals. Optional — Mesh.WithGeneratedNormals derives them from the faces when a source does not supply them.

required Vector3[] Positions { get; set; }

Vertex positions in the mesh's own space. Required.

JointBinding[] Skinning { get; set; }

Which joints move each vertex, and how much. Null for geometry that is not skinned, which is almost all of it.

One entry per vertex, four influences each, matching glTF's JOINTS_0/WEIGHTS_0 pair. Four is the format's own limit for a single set and covers every rig anyone exports by default. A file carrying JOINTS_1 puts its next four in Mesh.Skinning2, which is read too.

The joints are indices into Skin.Joints, and the skin lives on the node rather than here for the same reason the morph weights do: the mesh is the shape, and which skeleton is currently wearing it is not a property of the shape. See MeshNode.Skin.

JointBinding[] Skinning2 { get; set; }

Four more influences per vertex, for the rigs that need eight. Null for almost everything, which is the point of it being separate: a mesh with one set pays nothing for the existence of a second.

glTF's JOINTS_1/WEIGHTS_1, and the name is taken from there rather than invented, because the only reason this exists is that a file said so. The format allows any number of sets; two is where every exporter stops and where the returns have long since gone.

Parallel to Mesh.Skinning and the same length. The eight weights across both arrays sum to one together — they are two halves of one partition, not two independent sets — which is why the loader normalises across all eight and not four at a time.

int TangentTextureCoordinate { get; set; }

UV set used to derive Tangents: 0 or 1. A normal map using another set is disabled with diagnostics.

Vector4[] Tangents { get; set; }

Per-vertex tangents in the glTF packing: xyz is the tangent direction, w is ±1 giving the handedness of the bitangent, which the shader reconstructs as cross(normal, tangent.xyz) * w.

Only needed by Material.NormalTexture. Optional, and usually absent — Mesh.WithGeneratedTangents derives them from the UVs, which most exported models require because glTF exporters omit the TANGENT attribute unless asked.

Vector2[] TexCoords { get; set; }

Per-vertex texture coordinates. Optional; without them a material's texture is ignored.

Vector2[] TexCoords1 { get; set; }

Second per-vertex coordinate set, glTF TEXCOORD_1. Selected independently by each TextureMapping.

int TriangleCount { get; }

Number of triangles, which is the index count divided by three.

int VertexCount { get; }

Number of vertices.

Methods

MemberDescription
Vector3[] GetEdges(float creaseAngleDegrees = 30.0f)

The mesh's edges as endpoint pairs, ready for LineNode.Positions — a wireframe, a set of panel lines, or the outline of an open surface, depending on creaseAngleDegrees.

An edge is kept when the two faces meeting along it fold by at least that angle, or when only one face has it at all. Those two rules cover the whole range:

0 keeps every edge: the full wireframe, triangulation and all.The default keeps the folds and drops what is buried inside a smooth surface, which is the panel-line look — a box gives its twelve edges, a sphere gives nothing.180 keeps only the boundary: the rim of a plane, the mouth of a tube, the hole in a surface that is not closed.

This is the general answer to drawing lines on a model, which is why there is no Primitives.BoxEdges beside Primitives.Box and no wireframe flag on Material: Primitives.Box(1f, 1f, 1f).GetEdges() is the twelve edges of a cube, and the same call on a loaded ship is its panel lines. Nothing here needs the geometry to have been authored for it.

Two details make it work on real files rather than only on hand-built meshes. Vertices are welded by position first, because a flat-shaded export shares none — every triangle carries its own three, so two faces meeting along an edge agree on neither of its ends. And an edge with one face is a boundary rather than a fold, and is always wanted: it is exactly the line a person would draw.

Not cached, unlike Mesh.Bounds — it is a method rather than a property to say so. Call it once when the mesh is built, not once a frame.

creaseAngleDegrees

How far two faces must fold before the edge between them counts, in degrees. Clamped to 0..180.

void InvalidateGeometry()

Says that the contents of the arrays have changed, so the renderers upload them again and Mesh.Bounds is measured again.

Write into Mesh.Positions — and Mesh.Normals, if the shape moved enough to matter — then call this. It is the whole of animated geometry:

for (var i = 0; i < wave.Positions.Length; i++)
    wave.Positions[i].Y = MathF.Sin(wave.Positions[i].X * 0.5f + time) * 0.4f;

wave.InvalidateGeometry();
scene.Invalidate();

Both calls, because a mesh is not in the scene graph — it is data that any number of nodes in any number of scenes may be pointing at, and it has no way to reach them. This is the same contract Material and the lights have already: change something the graph cannot observe, and tell the graph. In an animated scene the second call is usually already happening for other reasons, since anything that moves a node has made it.

What this costs is one buffer upload per backend per call, which is the price of the thing and not avoidable. Nothing is allocated on the managed side — the interleaving scratch is reused — and on OpenGL the GPU buffer keeps its name and its bindings too. Metal releases and retakes its buffer instead, because a command buffer retains what it references until the GPU is done and writing into one a frame in flight is still reading is a race with no way to lose loudly. Assigning a fresh array to MeshNode.Mesh every frame creates and destroys buffers on every backend and re-binds every attribute with them, which is the trap this exists to close.

Worth knowing before you leave it out: the CPU fallback reads the arrays afresh every frame and has no buffer that can go stale, so forgetting this call looks like it works there and only breaks on OpenGL and Metal. That is the worst order to find out in.

One caveat worth knowing rather than guarding against: the renderer reads the arrays on its own thread, so writing into them while a frame is in flight can put part of a frame one step ahead of the rest. For animation driven from a timer tick — where the data is a continuous function of time and the tear is a fraction of one frame's motion — this is not visible and not worth a copy. Where it would be, keep two arrays and hand the finished one to a new mesh, so the renderer never reads the one being written.

static Mesh Merge(Mesh[] meshes)

One mesh holding all of meshes. See the other overload.

static Mesh Merge(IEnumerable<Mesh> meshes)

One mesh holding all of meshes, with their indices renumbered to suit.

Geometry costs a draw call each, and a draw call is the expensive unit — a hundred rivets on a hull that never move relative to one another are a hundred times more expensive as a hundred nodes than as one. Merging is how that gets paid down, and it is why the parts have to be in a common space first:

// A whole loaded model as a single draw. The node carrying it sits at the origin, because every
// transform in the file is now inside the vertices.
var flattened = new MeshNode(Mesh.Merge(
    root.Descendants
        .OfType<MeshNode>()
        .Where(node => node.Mesh is not null)
        .Select(node => node.Mesh!.Transformed(node.WorldTransform))));

That is also the trade being made, and it is worth saying out loud: what merging buys in draw calls it spends on flexibility. The parts can no longer move independently, be hidden independently, or be picked apart — they are one object now. Merge what is nailed down; leave what moves alone.

Where the parts disagree about which attributes they carry, the result carries the union of them, and each part is brought up to it on the way in by Mesh.WithGeneratedNormals and Mesh.WithGeneratedTangents. Dropping to the intersection instead would be simpler and much worse: one unwrapped greeble merged into a textured hull would silently take the texture off the hull, and the cause of that is very hard to see. Texture coordinates are the one attribute that cannot be derived — unwrapping is not a thing a library guesses — so a part with none contributes zeroes, and samples one texel. That is visible, local, and points at itself.

Vertices are not welded across the seams. Two parts that meet keep both copies, because welding needs a tolerance and a rule for what happens to attributes that disagree, and both are the caller's to choose. Mesh.GetEdges welds internally for exactly that reason and does not need this to have happened.

Merging nothing gives an empty mesh; merging one gives that same instance back, unwrapped.

Morph targets do not survive this either, and for a second reason on top of the renumbering: the inputs have different target counts and no shared idea of what target 0 means, so there is no answer to what the merged mesh's first target would be. A head that blinks should stay its own mesh; merging is for the static geometry around it.

Mesh Simplified(int targetTriangles, float preserveCreaseAngle = 25.0f)

This mesh with fewer triangles, spent where the shape needs them.

targetTriangles

About how many triangles to stop at, and approximate in both directions. One collapse on a closed surface removes two triangles, so a mesh at nine finishes at seven rather than at an eight nobody can reach; and a collapse that would turn a face inside out is refused rather than taken, so a mesh with nothing left that is safe to collapse stops above the number asked for.

preserveCreaseAngle

How sharp a fold has to be, in degrees, before the edge along it is treated as a feature worth keeping rather than a place to collapse.

25 is a reasonable default and 60 is a reasonable ceiling. What matters more than the exact value is not setting it below the angle between neighbouring faces on a surface that is meant to be smooth: a 64-sided cylinder folds by 5.6 degrees at every barrel edge, so a threshold of 5 declares the entire barrel a crease, constrains every vertex on it, and produces a worse result than no creases at all. Set it above the curvature you want treated as smooth and below the folds you want treated as edges; on a shape with both, any value in the gap gives the same answer.

returns

A simplified mesh, or this one unchanged when it already has few enough triangles.

Mesh Transformed(Matrix4x4 transform)

This mesh with transform baked into its vertices.

A Node is the usual way to place geometry, and it is the cheaper one: it costs a matrix rather than a copy of every vertex, and the renderer already has it. This is for the cases a node cannot reach — reorienting a primitive so it can be used at all (Primitives.Plane(4f, 4f).Transformed(Matrix4x4.CreateRotationX(MathF.PI / 2f)) is a wall, and there is no other way to get one), and putting parts into a common space so that Mesh.Merge can join them.

Not WithTransform, unlike its neighbours: With promises a copy with a property changed, and there is no transform property here to change. The matrix goes into the positions and is gone.

Three things happen that a loop over Mesh.Positions would get wrong.

Normals take the inverse transpose rather than the matrix. Squash a sphere along Y and its surface tilts one way while its normals tilt the other; using the matrix on both leaves the shading lit as though nothing had moved. Against the closed form for an ellipsoid's normal, a unit sphere scaled by (3, 0.4, 1) comes out 6.0e-4 radians off this way and 1.31 radians — 75 degrees — the other.

Tangents, despite sitting next to the normals, do take the matrix: they run along the surface, which is what positions do. The invariant that settles it is that a tangent stays perpendicular to its own normal, and it does — after a 13:1 stretch the worst dot product between them is 3e-8 this way and 0.88 with the inverse transpose, which is 62 degrees out of true and would tilt every normal-mapped surface in the mesh.

And a matrix with a negative determinant — a mirror, which is how a left wing is usually made from a right one — turns every triangle inside out. So the winding is reversed to match, and the handedness in each tangent's w is flipped, without which a normal map lights the mirrored copy from the wrong side.

An identity matrix returns this same instance rather than a copy, which matters more than it looks: a renderer caches GPU buffers against mesh identity, so a copy is an upload.

Mesh WithColors(Vector4[] colors)

This mesh carrying colors, or none at all when given null. See Mesh.WithNormals; the same rules apply.

The usual way a mesh gets them, because they are rarely what a generator produces and often what a caller decides afterwards — banding a hull by shade, tinting a terrain by height, fading a plume along its length. All of those are one array over geometry that already exists.

Mesh WithFlatNormals()

This mesh with every vertex unshared and one normal per triangle: the faceted look, where each face is a flat plane of one shade and the edges between them are hard.

This is what a low-polygon solid is for. An eight-sided cylinder shaded smoothly reads as a badly lit tube — the shading says "round" while the silhouette says "octagon", and the eye believes the silhouette and concludes something is wrong. Faceted, the same geometry reads as an octagonal drum, deliberate and solid. Primitives generates shared vertices throughout, so no choice of arguments gets there and this is the way.

Why it lives on the mesh and not on . Flat shading as a material flag needs the fragment shader to derive a normal from screen-space derivatives, which is a third answer on the CPU fallback — it has no fragment shader and lights per vertex, so the one backend where a flag would be hardest to honour is the one most likely to be running when it matters. As a vertex operation it is arithmetic on a buffer, so all three backends draw it identically by construction and there is nothing to report in a feature list.

What it costs. Vertices, and a real number of them: a shape that shared them heavily comes out with three per triangle, so a 32-segment sphere goes from 561 vertices to 3,072. That is the price of a hard edge, it is what a flat-shading exporter does too, and it is why this is a build-time call rather than something to reach for on a dense mesh.

Texture coordinates and tangents follow their own corners, so a mapped surface keeps its mapping. Any triangle indexing past the end of the vertex array is dropped, which is the only thing that can be done with one.

Morph targets do not survive this. Splitting invents vertices, so a displacement array indexed by the old numbering describes a mesh that no longer exists — and a target silently applied through the wrong numbering moves the wrong corners, which looks like a shattered face rather than like nothing. Flatten first, then author the targets against what came out.

Mesh WithGeneratedNormals()

This mesh with area-weighted smooth normals, or itself when it already has them.

Area weighting rather than plain averaging: a vertex shared by one large face and several slivers should follow the large face, which is what unweighted averaging gets wrong on triangulated CAD.

The three members that produce normals divide up like this, and the difference between them is which one you want rather than a matter of taste. This one fills a gap — it is what a loader calls on a file that supplied none, and it returns this untouched when the file did supply them, because a source's own normals are better than any guess. Mesh.WithSmoothNormals overwrites, which is what you want when the ones present are wrong or absent by construction. Mesh.WithFlatNormals overwrites too and splits the vertices, which is the faceted look.

Mesh WithGeneratedTangents()

This mesh with tangents derived from its UV layout, or itself when it already has them or lacks what the derivation needs.

Normal mapping needs a per-vertex frame that agrees with how the texture was painted, and the UVs are the only record of that. The tangent is the world direction in which u increases; the standard solve is per triangle, accumulated per vertex so shared vertices get a smooth frame, then made orthogonal to the normal.

Two details matter in practice. Degenerate UVs — a triangle whose three vertices share a texture coordinate, common in models that mix mapped and flat-coloured faces — give a zero-area solve, so those triangles are skipped rather than allowed to poison their vertices with NaN. And the sign in w is not decoration: mirrored UV islands, which is how nearly every character and vehicle is unwrapped, have a flipped bitangent, and without it the normal map lights the mirrored half from the wrong side.

Mesh WithGeneratedTangents(int textureCoordinate)

Derives tangents from UV0 or UV1, preserving both coordinate arrays. Existing tangents are reused only when they belong to the requested set. Returns this mesh unchanged when it has no normals or no coordinates in that set.

textureCoordinate

0 for Mesh.TexCoords, 1 for Mesh.TexCoords1.

Mesh WithNormals(Vector3[] normals)

This mesh carrying normals, or none at all when given null.

The counterpart to the three that compute them, for the case where they are already known — a format this library does not read, a solver, a shape whose normals are a closed form and therefore exact where an averaged one would not be. Passing null is how a mesh loses them, which is worth having as much as gaining them: it is what makes Mesh.WithGeneratedNormals recompute, since that one returns this whenever normals are present.

Mesh WithSmoothNormals()

This mesh with area-weighted smooth normals, computed afresh whether or not it already has them.

The counterpart to Mesh.WithFlatNormals, and the way back from it: vertices are welded by position before the faces are accumulated, so a mesh that has already been split — by this library, by an STL file, by an exporter set to flat shading — smooths as though it had never been. Without that weld this would be a slower way of computing face normals, since after a split every vertex belongs to exactly one triangle and there is nothing to average.

The weld is by rounded position and is used for nothing but deciding which corners share a normal; the vertices themselves stay where and as many as they were, so texture coordinates, tangents and the index buffer all survive. That matters on a UV seam, where two vertices sit at the same point with different coordinates: they must keep both coordinates and should share one normal, which is exactly what this does and what welding the vertices outright would get wrong.

Mesh WithTangents(Vector4[] tangents)

This mesh carrying tangents, in the glTF packing Mesh.Tangents describes, or none at all when given null. See Mesh.WithNormals; the same rules apply.

Clearing them is the useful direction: they are needed only by Material.NormalTexture, they are four floats a vertex, and a mesh that has moved to Material.BumpTexture — which needs no tangent frame at all — is carrying a buffer nothing reads.

Mesh WithTexCoords(Vector2[] texCoords)

This mesh carrying texCoords, or none at all when given null. See Mesh.WithNormals; the same rules apply.

Texture coordinates are the one attribute nothing here can derive — unwrapping a surface is a decision rather than a calculation — so this is the only way a mesh gets them after it is built.

See also