Documentation · 3D Guide

6 · Past triangles

Some things in a scene are not surfaces. A star is a point of light with no size at all; a glow has no geometry; a panel line is a line. Drawing those as triangles is possible and always looks like what it is. This chapter is the three node types that are not meshes, and the rules that decide what ends up on top.

Sprites: things that always face you

A SpriteNode is a flat image that turns to face the camera every frame — a billboard. Turn the camera around it and it turns with you, so it never shows an edge.

scene.Children.Add(new SpriteNode
{
    Texture = glow,
    Position = beacon.WorldPosition,
    Size = new Vector2(150f, 150f),   // world units — it shrinks with distance
    Color = new Vector3(1f, 0.4f, 0.3f),
    Blend = BlendMode.Additive,
    DepthTest = false                    // draw over whatever is in front of it
});

Two properties carry the weight. Size is in world units rather than pixels, so a sprite behaves like an object in the scene and recedes with distance. And DepthTest = false lets it draw over geometry that is nearer — which is how a ship stays visible as a point of light long after its hull is smaller than a pixel. Emissive geometry cannot do that: it is still a surface, and a surface a pixel wide is a pixel.

Lines

A LineNode is unlit segments: the Positions array is read in pairs, each pair one segment from A to B. It is the type behind wireframes, panel lines, blueprint edges, trajectory arcs and shockwave rings.

scene.Children.Add(new LineNode
{
    Positions = hull.GetEdges(),   // creases and boundaries, not the whole wireframe
    Color = new Vector3(0.6f, 0.8f, 1f),
    Opacity = 0.22f,
    Width = 1.5f,
    DepthWrite = false,
    RenderOrder = 1                // after the hull, so the lines sit on it
});

Mesh.GetEdges is the usual source. Its one parameter is a crease angle: 0 gives you every edge, the default keeps folds and boundaries and drops what is buried inside a smooth surface, and 180 leaves only the outline of an open surface.

Width is in device pixels and works on every renderer — anything wider than one pixel is expanded into triangles and sized after the perspective divide, because every mainstream OpenGL driver clamps glLineWidth to 1 and Metal has no line-width control at all.

Points

A PointsNode is a cloud: one square per position, no geometry between them. Starfields, debris, sparks, dust, a scatter plot.

The property that decides what kind of cloud you have is SizeAttenuation:

scene.Children.Add(new PointsNode
{
    Positions = stars,          // 1,660 points on a very large sphere
    Size = 2f,
    SizeAttenuation = false,   // two pixels each, forever
    Color = new Vector3(1f, 1f, 0.95f)
});

Blending: what happens when things overlap

When a pixel is drawn over one that is already there, blending decides how the two combine. There are three modes and they are not interchangeable:

BlendMode.Opaque The new pixel replaces the old one. The default, the fastest, and right for anything solid.
BlendMode.Alpha A weighted mix, by the alpha value: glass, smoke, a fading overlay. The result depends on the order the two were drawn in, which is why order matters below.
BlendMode.Additive The new pixel is added to what is there, so it only ever brightens. Fire, tracers, glows, plasma. Overlapping additive sprites stack toward white, which is what makes a cluster of them read as one bright thing.

Two further switches decide how a surface interacts with the depth buffer:

Blended things almost always want DepthWrite = false. A glow that writes depth punches an invisible hole: the next glow behind it fails the depth test and vanishes, and a cluster of sprites turns into whichever one happened to be drawn first. Turning the write off is what lets additive things add.

What is drawn on top

Every drawable in the scene — meshes, sprites, lines, points — goes into one list and is sorted by the same three rules, in this order:

  1. Node.RenderOrder, lowest first. It is the manual override, and it beats everything below.
  2. Opaque before blended. Solid geometry fills the depth buffer first, so blended things have something to test against.
  3. Back to front, by the node's origin — per object, not per triangle.

That last point is the limitation worth knowing: two transparent surfaces that pass through each other cannot both be right, because the sort has one answer per object. It is enough for shells, glows, overlays and cockpit glass, and not enough for a bag of marbles made of coloured glass.

The recipe for "lines drawn over the hull they belong to" falls straight out of the three rules: DepthWrite = false so the lines do not block each other, and RenderOrder = 1 so they come after the hull whatever the distance sort thinks.

When two surfaces are in the same place

Sorting decides the order things are drawn in. It does not decide which one is in front, because the depth buffer still gets a vote — and that is a separate problem with a separate answer.

Draw something exactly coplanar with something else — panel lines lying on a hull, a grid on a floor, a decal on a wall — and the two surfaces compute depths that are equal in theory and, after the perspective divide has been rounded to whatever precision the depth buffer has, not quite equal in practice. Which one wins varies from pixel to pixel and from frame to frame. It reads as crawling dashes that shimmer as the camera turns, and it is called z-fighting.

Nudging the geometry toward the camera by a small amount does not work: the amount that is enough up close is invisible far away, because depth precision is not linear. So the fix is applied where the depths are computed, in the units the depth buffer actually has:

var paint = new Material
{
    BaseColor = new Vector3(0.7f, 0.1f, 0.1f),
    DepthBias = 1f,        // a constant nudge, in depth-buffer units
    DepthBiasSlope = 1f    // plus more where the surface is steep to the camera
};

Two terms, because one is not enough. The constant handles a surface facing you square-on. The slope term scales with how fast depth changes across the pixel, which is what saves a surface seen at a grazing angle — where a single pixel can span a wide range of depth and a constant nudge is swamped. Start both at 1 and raise them together until the shimmer stops; too much makes the biased surface float visibly in front.

Two limits worth knowing before you rely on it. Depth bias applies to filled triangles only — not to LineNode, PointsNode or SpriteNode, because OpenGL ES and WebGL guarantee the fill case and nothing else. For lines the DepthWrite and RenderOrder recipe above is still the answer. And it does nothing in a browser, where the entry point that sets it resolves and then terminates the WebAssembly runtime, so the renderer never calls it: if that is where your scene runs, keep coplanar surfaces a small distance apart in the geometry instead. The CPU fallback does honour it, in its own depth buffer's units, which are the same two terms the GPU takes.

A surface from a function

Some shapes are easier to write than to model. A blend of spheres and capsules, a rounded box with a hole carved out, anything that merges: those are one line each as a signed distance — a function that says how far a point is from the surface, negative inside — and a hundred lines of triangles. SurfaceNets turns such a function into a Mesh:

ScalarField field = (in Vector3 p) =>
    MathF.Min(p.Length() - 1f,                            // a sphere…
              (p - new Vector3(1.2f, 0f, 0f)).Length() - 0.6f); // …joined to a smaller one

var mesh = SurfaceNets.Extract(
    field,
    new BoundingBox(new Vector3(-1.5f), new Vector3(2f, 1.5f, 1.5f)),
    cellsAlongLongestAxis: 64);                           // finer is smoother and slower

It samples the function once at every corner of a grid, puts one vertex in every cell the surface passes through, and joins the cells around every crossing into triangles — surface nets, which gives even triangles and rounded creases. The normals are the function's gradient, so the shading is smooth however coarse the grid; a closed function gives a closed mesh, wound so the normals face out; and the same call gives the same bytes every time. SurfaceNetsOptions adds texture-coordinate and colour callbacks, a triangle budget that throws rather than build something too large to draw, and a cancellation token. When the result is finer than needed, Mesh.Simplified takes it.

Run it

Four scenes, one idea each, with the feature on and off in the same frame.

AVA3D_SCENE=Sprites  dotnet run --project samples/Ava3D.Demo.Desktop
AVA3D_SCENE=Lines    dotnet run --project samples/Ava3D.Demo.Desktop
AVA3D_SCENE=Points   dotnet run --project samples/Ava3D.Demo.Desktop
AVA3D_SCENE=Blending dotnet run --project samples/Ava3D.Demo.Desktop

← 5 · Surfaces and light 7 · Making it move →