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:
false—Sizeis in pixels and stays there however far away the points are. That is a starfield: stars do not get smaller when you fly toward them.true—Sizeis in world units and shrinks with distance, like everything else. That is spray, debris, snow.
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:
DepthTest— "am I allowed to draw if something is already in front of me?" Off means always draw.DepthWrite— "do I record my own depth for the things drawn after me?" Off means later things ignore me.
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:
Node.RenderOrder, lowest first. It is the manual override, and it beats everything below.- Opaque before blended. Solid geometry fills the depth buffer first, so blended things have something to test against.
- 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