Documentation · 3D Guide
8 · Making it fast
Performance advice is worthless without a number beside it, so this chapter has numbers. What a frame actually costs, which of your decisions move that cost, and how to measure your own scene rather than trusting any of it.
Draw calls, not triangles
The instinct is that triangles are the expensive thing. They are not, usually. The expensive thing is the number of times the CPU has to stop and tell the GPU to start something: a draw call. Each one changes state, binds buffers and textures, and waits.
Here is the demo's stress scene, which is deliberately lopsided to make the point:
128,002 triangles · 126 draw calls · 120 fps
A hundred and twenty-eight thousand triangles is nothing to a modern GPU. A hundred and twenty-six draw
calls is also nothing. Twelve thousand draw calls of ten triangles each would be far slower than this while
drawing a fraction as much. One draw call per MeshNode is the rule to
budget against.
It is a budget, not a law, and three things break it in your favour. A node can carry many copies of its mesh and still be one draw (instances). A tree of static nodes can be folded into a handful (merging). And anything the camera cannot see is never submitted at all (culling), which happens whether you ask for it or not.
Share meshes; do not copy them
GPU buffers are cached against the identity of a Mesh, not its contents. The stress scene above is one sphere on 125 nodes: one vertex buffer, 125 draw calls.
var sphere = Primitives.Sphere(0.5f); // built once
foreach (var p in positions)
scene.Children.Add(new MeshNode(sphere, paint) { Position = p }); // uploaded once
Build a fresh Primitives.Sphere(0.5f) inside that loop and you get 125 identical buffers, 125
uploads, and a cache full of things that will never be reused. It is the single easiest large mistake to
make here, and it does not announce itself — the picture is identical.
One node, many copies
Sharing a mesh saves the upload. It does not save the draw call — a hundred nodes holding one mesh is still a hundred times the CPU stops and tells the GPU to start. When the copies differ only in where they are and what colour they are, say that instead of building a node each:
var crate = Primitives.Box(0.34f, 0.34f, 0.34f, chamfer: 0.02f);
var shelf = new MeshNode(crate, material)
{
Instances = [.. places.Select(p => MeshInstance.At(p.Position, p.Rotation))]
};
A MeshInstance is a transform and a tint, and nothing else. They go to the card as a second vertex buffer that advances once per copy rather than once per vertex, so the whole node draws in one call. The transform is relative to the node, which is what keeps the shelf a thing that can be moved.
The demo's instancing scene is the same four hundred crates twice — as separate nodes on the left, as instances on the right, same mesh and same material on both sides:
800 crates · 402 draw calls · 9,602 triangles · 0.98 ms/render
Four hundred of those draws are the left half. The right half is one. The two halves look identical, which is the point: nothing about the picture says which is which.
The tint is what stops four hundred of a thing reading as one thing drawn four hundred times. It is linear RGBA multiplied into the material's base colour, so the material's own colour, its base-colour map and the mesh's vertex colours all still mean what they meant — and the alpha multiplies too, so a copy can fade out without the material knowing.
An instance is not a node. It has no name, no children, no visibility of its own, and nothing can pick it or light it individually. That is the trade, and it is the right one for a crate, a bolt or a blade of grass and the wrong one for a door somebody has to open. If you need any of those things, it wants to be a node.
Instanced drawing is a renderer feature, so it is reported as one rather than assumed. Metal and OpenGL make
the instanced call and the scene above is 402 draws. Vulkan draws each copy with its own call and reports
801. The CPU renderer has no draw call to save either way — it shades and rasterises the copies in a loop,
which costs what drawing them that many times has always cost. The picture is identical on all
four, and RenderInfo.Features says which you got and why.
Merge what never moves apart
A node is the cheap way to place a mesh, but every node is a draw call. When a hundred parts will never move independently again — a building, a bolted assembly, scenery — bake them into one:
var whole = Mesh.Merge(parts.Select(p => p.Mesh!.Transformed(p.WorldTransform)));
scene.Children.Add(new MeshNode(whole, sharedMaterial)); // one draw call, once
Transformed bakes a matrix into the geometry — normals through the inverse
transpose, winding reversed under a mirror — and Merge joins the results. The trade is
flexibility for draw calls, and it is only worth making for things that are genuinely static: a merged
mesh cannot be moved in pieces, and it is one upload of everything if any of it changes.
Merging only helps if the parts share a material. Different materials cannot be one draw call, so merging across them just makes a big mesh that still needs several passes over it.
Doing that by hand means finding the parts, checking they agree on every material property, and baking each one's world transform yourself. Batching.Fold does the finding:
var saved = Batching.Fold(room); // returns how many draw calls it removed
It groups a node's children by everything a material can differ in, bakes each group's placement into its
vertices, and replaces the group with one node per material. Tiling is baked too — UvScale and
UvOffset are the two properties deliberately not in the grouping key, because
fourteen floor plates that differ only in where their panelling starts are fourteen draws that did not
need to be. The one exception is a material that turns its texture: UvRotation happens after
the scale and before the offset, so the offset stays in the material and in the key, and such a material
folds only with materials that turn and slide the same way.
What it will not touch is as much of the design as what it will. In one list, what a fold takes and what it leaves alone:
- It takes an unnamed, visible
MeshNodewith no children. Its own placement, the placement of an unnamed group it stands in, and its material's tiling are baked into the vertices; its tag and its shadow and order flags travel with it. A surface alone in its material stays the very node it was. - It leaves alone anything named; anything with children; anything
hidden; anything carrying instances; anything skinned or
morphed, because a merged mesh has no bones and no targets; anything with
LevelsOfDetailor aMaxDrawDistance, because those are decided per node; anything with a texture transform of its own — a per-map mapping, a map reading the second UV set, or the node's ownUvScale,UvOffsetorUvRotation; and anything you passed aspinned:
Batching.Fold(room, pinned: [door, lamp]);
Naming is the rule worth remembering. A named node is something somebody refers to — looks up, animates, opens — so folding leaves it alone, and the way to keep a part addressable is simply to give it a name. The fold reaches one level down into unnamed groups of plain geometry, so a room made of furniture groups still collapses to a few draws, but it stops at anything with a group inside it: a lid still turns on its hinge.
Fold once, when the scenery is finished — never in the frame loop. The result is a mesh,
and a mesh rebuilt every frame is an upload every frame. A group that would pass
Batching.MostVertices (60,000) or MostTriangles (120,000) is split rather than
merged past the ceiling, because a sixteen-bit index buffer stops at 65,536 and the CPU renderer drops
whatever runs past its budget without saying so.
What the camera cannot see
Everything above is work you do. This one is done for you and it is on by default: before the draw list reaches the renderer, every mesh and sprite whose bounding box lies entirely outside the camera's frustum is dropped. Here is the demo's film at four moments, each one measured twice from the same build with the feature off and then on:
culling off culling on dropped
60 s 303 draws, 81,676 tri 39 draws, 7,004 tri 87%
150 s 298 draws, 112,742 tri 23 draws, 12,376 tri 92%
240 s 1,046 draws, 106,766 tri 967 draws, 72,652 tri 8%
300 s 1,017 draws, 81,942 tri 342 draws, 26,744 tri 66%
Read the 240-second row against the others. Culling pays where the camera stands inside the scene — a room in a building, a cockpit, a street — and pays almost nothing when the camera is outside looking at the whole thing, because then there is nothing outside the view to skip. It is not a setting you tune; it is a saving that appears exactly when your shot needs it.
Where it pays, it pays in time as well as in submission. The 60-second shot again, with the clock on it:
culling off · 2592×1302 · 303 draws · 81,676 triangles · 0.35 ms/render
culling on · 2592×1302 · 39 draws · 7,162 triangles · 0.20 ms/render
RenderInfo reports what it dropped, which is the only honest way to know whether it is doing
anything for you:
geometry : 39 draws, 7,004 triangles · culled 264 of 303 outside the view (87%)
The two knobs are on the scene. FrustumCulling switches it off, which is worth doing once when
measuring so that the two numbers come from the same build rather than from your memory of last week.
CullingMargin widens the test in world units, for geometry drawn somewhere its bounds do not
describe — a vertex shader that displaces, a sprite grown after its bounds were computed. Nothing in this
library needs it, which is why it is zero.
Lines and points are never culled, and that is not an oversight. They measure their width in device pixels, so a segment's bounding box does not describe the ribbon actually painted for it — a line whose box is just off screen can still put pixels on screen. Meshes and sprites have bounds that mean what they say, so those are the two kinds that get dropped.
Instanced copies are culled one at a time. A node with a hundred trees is one box around the whole forest, and that box is always in view — but each copy is also tested on its own, and only the copies in front of the camera are drawn. You do not do anything for this. The picture is the same pixel for pixel; what changes is how much of the forest reaches the card.
Far away
Culling drops what is outside the view. It keeps everything inside it, however small: a fern forty metres away is a few pixels on screen and three thousand triangles on the card. Two settings on a MeshNode fix that.
var ferns = new MeshNode(fern, frond) { Instances = placed, MaxDrawDistance = 30f };
ferns.LevelsOfDetail.Add(new LevelOfDetail(8f, lightFern)); // 240 triangles from 8 m
MaxDrawDistancestops drawing the node past that many metres. Right for grass and ground cover, and for anything pastScene.FogEnd, where a surface is exactly the fog colour.LevelsOfDetailswaps in lighter meshes, each from its own distance. A level can also carry its own material.
Both are measured per copy, from the camera to the centre of that copy. Near ferns are the detailed mesh and far ferns the light one, in the same frame, from the same node.
The demo's forest at its busiest second, on an M3 Max at 1080p, with and without them:
without 1,311,944 triangles · 12.8–15.6 ms a frame
with 279,024 triangles · 9.4–11.8 ms a frame (4K: 21.7–22.5 → 15.7–18.3 ms)
Shadows are cast by the node's own mesh, from every copy, at every distance. A light's
shadow map does not know where the camera is, and a shadow that changed shape as you walked past a
threshold would be far more visible than the triangles it saved. How far shadows reach is set by
Light.ShadowCasterBounds, not by draw distance.
Pick distances by looking, not by formula: put the camera at the distance, and if you cannot tell the two meshes apart, that is the distance. Levels are for static geometry — a skinned or morphed node draws its levels without the pose.
Textures
A Texture holds encoded bytes until a renderer actually needs pixels, then decodes and uploads a couple per frame. A scene with forty maps appears on the first frame and sharpens over the next few, rather than allocating a hundred megabytes of RGBA before anything is drawn — which on a 32-bit WebAssembly heap is the difference between working and not.
For a map you generated in code, skip the round trip entirely. Encoding a PNG so that the renderer can decode it again costs over a second of stall for four 1024×512 maps in a browser:
material.BaseColorTexture = Texture.FromPixels(rgba, 1024, 512, name: "panels");
And if that map changes while it is on screen, rewrite the same array and call
Texture.Refresh rather than building another one. Uploads are streamed a few per frame, so a
texture the renderer has not seen before draws as flat white until its turn comes — replacing one ten
times a second is white one frame in six, which reads as a flicker and is very hard to attribute to
anything. Refreshing keeps the picture already uploaded on screen until the new pixels replace it, and
allocates nothing.
One texture is not like the others. The image handed to
EnvironmentLight.FromTexture has to be blurred by roughness
before it can light anything, and that is done on the CPU the first time the scene is built. It costs
7 to 11 milliseconds, once, and — this is the part worth knowing — that figure barely
moves between a 256×128 source and a 2048×1024 one, because the work happens after the image has been
reduced to a fixed working size. So there is no performance argument for feeding it a small sky. Swapping
the texture at runtime pays the cost again; rotating it with Rotation does not, because that
is a number the shader reads and not a rebuild.
A map can also arrive already compressed, and stay that way.
Ktx2Loader reads a .ktx2 file — the container glTF's own
compressed-texture extension uses, and what toktx writes from a PNG — with its format, its
size and its whole mip chain in the header:
material.BaseColorTexture = Ktx2Loader.Load(File.ReadAllBytes("hull.ktx2"));
The saving is memory rather than time, and it is large: a 2048×2048 RGBA map is 16 MB decoded and 2.7 MB as BC1, and it stays compressed all the way into the card rather than being expanded on the way. It also stays compressed in the card, which is the part that matters on a device where texture memory is the budget. TextureFormat names the ten it understands: BC1, BC3, BC4 and BC5 for desktop, BC7 for better desktop, ETC2 for GL ES and WebGL 2, ASTC for mobile.
A card that cannot take the format still draws the picture. Where there is no compressed
upload path, BC1, BC3, BC4 and BC5 are decoded to RGBA8 and uploaded that way — the same image, at the
size compression existed to avoid. BC7, ETC2 and ASTC have no decoder here and are reported as not drawn
rather than drawn wrong. So ship one of those four, or an uncompressed map, if one file has to work
everywhere; check RenderInfo.Features for what the renderer you got will actually take.
The per-frame snapshot
Once a frame, the control walks your scene graph and builds a flat, immutable draw list to hand the render thread. That is what makes mutation lock-free, and it is not free — so it is measured:
SceneRebuildsPerSecond | How often the walk actually happened. It only runs when something changed. |
SceneRebuildBytes | What one walk allocated, averaged over the window. For the demo's film: 496 bytes. |
Nearly all of a walk used to be one allocation: a fresh array of draw items, every frame, sized to the scene. That array now comes from a small pool of exact-length arrays and goes back when the renderer is finished with it, so a scene whose shape is not changing allocates almost nothing per frame. The same two scenes, before and after:
| the film | 27,192 bytes a walk → 496; 3.3 MB/s → 58 KB/s |
| the motherboard | 611 draws, 392 bytes a walk, 46 KB/s at 120 rebuilds a second |
That 496 is the same figure at 39 draws and at 1,046, which is the point of a pool: what a walk allocates stopped being a function of how big the scene is.
The walk itself is an explicit stack and a reusable scratch list — for a 60-node scene, 6.8 µs. If your
rebuild figure is high and your scene is not changing, something is calling Invalidate that
need not be; that is the number to chase, because a rebuild you did not need is the one cost on this page
that buys nothing at all.
Measure your own scene
RenderInfo reports what happened this frame, live:
var info = View.Info;
Console.WriteLine($"{info.Renderer} · {info.FramesPerSecond:0.0} fps · " +
$"{info.DrawCalls} draws · {info.Triangles} triangles");
The demo prints exactly that and exits, which is how every number on this site was taken and how you can re-take them on your own hardware:
AVA3D_SCENE="Stress test" AVA3D_PROBE=8 dotnet run --project samples/Ava3D.Demo.Desktop
Watch for a frame rate pinned to exactly 60 or 120. That is the display, not the GPU:
the renderer is waiting for vsync and has spare capacity you cannot see. Compare frame
milliseconds instead — FrameMilliseconds — which keeps moving after the frame rate
stops.
How many pixels it draws
The control renders at the size it is shown at, in real device pixels, taken from the transform
it is about to be drawn under — not from its layout size. The same view laid out at 800×448 is 800×448 of
fill work in a window at 100%, 1600×896 on a display at two device pixels to the point, and 3200×1792 if
a Viewbox above it is doubling the frame as well. Fill cost goes with the area, so the last
of those is sixteen times the first.
RenderInfo.Size is what it actually was, and it is worth
reading once when you first put a view somewhere. It is the one number that says whether the picture on
the screen is being drawn or being stretched.
Scaling the view is a resolution decision as much as a layout one. A fixed-size view
inside a Viewbox — the ordinary way to build a design that fills any window — asks for
every pixel the box stretches it to. That is what keeps it sharp, and it is also the whole of the extra
cost. If a scene is heavy enough that you would rather have the frame rate, give the view a smaller
Width and Height instead and let the box do the rest: the picture keeps its
shape, and you have chosen the resolution rather than inherited it.
Two limits sit on top of that, and neither is a quality setting. A frame is held to 32 megapixels, which is more than a 6K display filled edge to edge. It is then held to whatever the driver says it will actually allocate — 16384 pixels a side on a desktop, as little as 4096 in a browser on a modest card. That second one matters more than it sounds: a render target past the limit is not refused, it is simply incomplete, and what comes back is a black frame rather than an error.
Edges, and what smoothing them costs
A renderer decides each pixel from one sample. Where a triangle's edge crosses a pixel, that pixel is either inside or outside — there is no half — so a silhouette is a staircase. Standing still, that reads as sharpness. Moving, each step jumps to the next pixel at its own moment and the whole edge crawls. It is the fault people describe as flickering, and no amount of texture resolution touches it, because it is not the texture.
There are two levers, and they are not the same lever.
view.SampleCount = 4; // coverage sampled four times per pixel; shaded once
view.RenderScale = 2; // the whole frame rendered twice as large, then averaged down
SampleCount is multisampling. The shading still happens once per pixel; only coverage is sampled more finely, so it fixes the silhouette and costs a fraction of what the other one does. It is also the one that is not everywhere: Metal takes it, OpenGL takes it on every context but GL ES 2 — so a browser gets it — and Vulkan and the CPU renderer do not.
RenderScale is supersampling: render more pixels than you are going to show and let the composite average them. It costs its square in fill, and it works on every renderer including the one with no GPU. It is also the only one of the two that helps with anything inside a triangle — a tiled surface breaking up in the distance, a specular highlight sparkling on fine relief — because those are a shading problem and multisampling does not shade more.
Ask what was granted, not what you asked for.
RenderInfo.SampleCount and RenderScale
report what the frame was actually drawn with. A request the device will not allocate is not an error
and does not fail — the frame comes back at one sample, looking exactly like a frame that was never
asked. The renderer's feature list says whether this backend can multisample at all.
Both are capped by the two limits above, so asking for four times the pixels on a view that already fills a 6K display gets you what fits rather than a black frame.
Drawing fewer frames
Everything above makes a frame cheaper. This asks a different question: was the frame needed? A model viewer is nearly always orbiting something, so drawing at display rate is both simplest and right. An application is nearly always not — a game on its inventory screen, a CAD tool with a dialog over the viewport, a dashboard whose scene changed once when the data arrived. Every one of those is a GPU running flat out to produce the same image, which on a laptop is fan noise and battery and in a browser tab is a frame budget somebody else needed.
Two levers, and they are independent — one decides whether to draw, the other how often:
View.RenderTrigger = RenderTrigger.OnDemand; // only after something changed
View.MaxFrameRate = 30; // and never more often than this
"Changed" means a message reached the renderer, and everything you would expect already
sends one: assigning Scene, mutating anything in a scene that is attached, orbiting, panning,
zooming, InvalidateCamera, InvalidateScene, and resizing the control. Interaction
stays smooth because a drag moves the camera and a moved camera is a message. An animation driven from a
clock needs nothing special either: a scene ticked once a frame is a scene that changed once a frame. The
saving is on the screens where nothing is being ticked at all.
What it cannot notice is a change nothing announced — writing into a Mesh's arrays without
InvalidateGeometry, or into a Material without
Refresh. Under the default those omissions never show, because the next
frame was going to be drawn anyway; here they show as a picture that will not update.
InvalidateScene is the blunt instrument for anything that slips through.
MaxFrameRate is a ceiling rather than a clock, and it is suspended for the length of a drag so that a cap chosen for a still screen does not make orbiting feel like a fault. It can only ever remove frames, so a cap above what the display offers does nothing. And because frames arrive only when the compositor offers one, the achievable rates are the display's divided by a whole number: a cap of 24 on a 60 Hz panel delivers 20, because the alternative is 30 and 30 is more than was asked for.
It caps rendering, not repainting. A compositor also draws a visual when the window around
it changes — a panel over the viewport, a host's own overlay — and nothing here can decline that. What the
control declines is to render for it: a repaint that arrives before the next frame is due is answered with
the frame already rendered, a blit, and only a due one draws the scene. The same goes for a repaint over an
OnDemand view where nothing changed. So something else in the window repainting on its own is
no longer a floor under the cap. Measured on the demo, whose diagnostics panel refreshes four times a second:
a cap of 30 used to render 31.8 frames a second and a cap of 12 rendered 13.7; now they render 29.9 and
12.0, and the panel's repaints show up as FramesReused instead — about two a second, the ones
that landed between due frames.
How much is left running
RenderInfo reports the pacing as well as the cost:
MaxFrameRate says what is actually in force (zero while the user is orbiting),
FramesRendered and FramesReused split the repaints into the ones that drew the
scene and the ones that showed the last frame again, RepaintWaitMilliseconds and
LongestRepaintWaitMilliseconds say how long the compositor took to answer a request for a
frame, LateFrames counts the answers that took over a tenth of a second, and
PacingSummary is the one line of it a diagnostics panel wants.
Know what that last group can and cannot see. It measures a render thread that has been parked — which is a real failure, and on macOS a common one. It cannot see a window that is presenting a stale picture while everything behind it runs: through eleven seconds of exactly that, every counter in the process reads healthy, including these, and the only instrument that disagrees is a screen recording. That is worth knowing before you trust a green dashboard.
One interaction worth expecting. A TextureProgram keeps a surface
moving with nothing else in the scene changing, and it asks for a redraw when it evaluates — so it works
under OnDemand, but at the rate the compositor answers rather than at its own. The demo's
screens scene, three programs at ten a second: 118 frames a second continuous, 13 to 15 on demand,
and the picture animates in both.
Reading a trace
Averages say a frame costs 4 ms; they cannot say which frame cost 40. For that, give the view a
FrameTrace — or run with AVA3D_TRACE=frames.json in the
environment, which gives every view one and writes it when the view leaves the tree and when the process
exits:
View.Trace = new FrameTrace(); // the last 2,000 frames, a struct each
using (View.Trace.Mark("deck build")) // a span of your own, from any thread
BuildDeck();
using var file = File.Create("frames.json");
View.Trace.Export(file, TraceFormat.ChromeJson); // or TraceFormat.Csv, one row a frame
Open the JSON at ui.perfetto.dev. Three tracks. ui carries each snapshot build
and any marker made on that thread. render carries each frame: the wait for the compositor,
then the frame itself — or a shorter reuse where the last picture was shown again. gpu
carries what the card spent, split into shadow, contact, scene and bloom where the backend can bracket them.
Click a frame for its arguments: which scene and snapshot it drew, why the snapshot was rebuilt
("Structure, Transform"; "Animation" when a clip did it; "Program" when a texture program redrew),
uploads and evictions during it, and the bytes resident afterwards. Frames is the same data as
records, for a test or a dashboard.
What the GPU track can and cannot say. Its lengths are measured on the card's own clock; its
position is not — a GPU span is placed after the frame that submitted it, because the two clocks are not the
same clock. Vulkan and desktop OpenGL bracket every pass. Metal reports the frame's total from its command
buffer and leaves the passes empty. The browser has no timer queries unless the page was granted
EXT_disjoint_timer_query_webgl2, which it usually is not, and then the track is simply absent.
Bloom is Skia's pass and is bracketed on OpenGL only, where it shares the context. PresentMs is
always null: nothing in this process learns when a frame reached the glass, for the reason two paragraphs up.
tools/bench.sh --trace <dir> writes one trace per measured run beside the table it prints.
Keeping resources resident
A texture is decoded the first frame something draws it and copied to the card a few milliseconds a frame; a mesh is uploaded the first frame it is drawn. Until then the surface wears a stand-in — flat white for a colour map, flat blue for a normal map — and that is the cost a scene switch used to show: a second of white panels while forty maps came up, every time, because what the previous scene had uploaded was thrown away the moment it stopped being drawn.
Now what is uploaded stays. Every view on the same graphics device draws from one set of resident resources, so a texture a second view names is drawn without a second upload, and a scene switched away from and back within the grace period comes back complete. Three numbers govern the set, and one policy carries them:
View.ResourcePolicy = new ResourcePolicy
{
BudgetBytes = 512L << 20, // zero, the default, takes the device's own recommendation
GraceSeconds = 5, // unused for this long, and a resource goes
UploadMillisecondsPerFrame = 2 // what the render thread spends copying, per frame
};
Past the budget, the least recently drawn resource goes first — never one any view is currently drawing,
whether or not that view is rendering, so an idle OnDemand view keeps its scene on the card.
Under the budget nothing goes until the grace period has passed since it was last drawn. The budget
defaults to half of what the device reports as its working set on Metal, half the largest device-local
heap (or its VK_EXT_memory_budget figure) on Vulkan, 512 MB on OpenGL, which has no way to
ask, and 256 MB of decoded pixels for the CPU renderer. The policy is the device's rather than the view's:
where two views on one card ask for different things, the most permissive value of each field is in
force. Resources.DefaultPolicy is what a view with none set applies.
Decoding, resizing and building mip chains happen on the thread pool, a bounded few at a time, so the
frame never waits on an image; what the render thread does is the copy into device memory, inside
UploadMillisecondsPerFrame, one image at least however large it is. A view under
OnDemand keeps its loop ticking while anything is on its way and draws only when a decoded
image is waiting, which is what makes SceneReady arrive on an idle view rather than on the
next change.
To have a scene on the card before it is shown, prepare it:
var report = await View.PrepareAsync(nextScene, progress); // decodes and uploads, without showing it
View.Scene = nextScene; // the first frame is complete; SceneReady fires on it
The task completes when everything the scene draws with is resident and says what it cost in ScenePreparation; the progress reports bytes and counts as they arrive. A view not yet in the tree waits for its attachment first. What a preparation makes resident stays in use until the scene is assigned to that view, so nothing prepared is evicted in the gap; cancelling releases what was decoded and nothing else is drawing.
Two more levers, for the switch itself. View.SwapPolicy = SwapPolicy.WhenReady keeps the
previous scene's frame on screen, unchanged, until the new scene is resident, then switches on one frame
— no stand-ins, at the price of the old picture staying up for the length of the uploads; a view with
no frame to keep draws the new scene at once, as the default does. And
View.Retain(scene) pins a scene's meshes and textures against eviction, whatever the policy
says, until the result is disposed — for the hub a game keeps returning to, or a scene that has to
appear complete the instant it is shown, prepared once and retained after.
RenderInfo reports the device's ResidentBytes against
ResourceBudgetBytes, how many resources the scene being drawn is still waiting for in
PendingUploads, and the running Uploads and Evictions; the trace
carries the uploads and evictions of each frame and the bytes resident after it. A second visit to a
scene that shows uploads is a scene whose textures are new objects each time — the caches are keyed by
identity, so a scene rebuilt from the same Texture and Mesh objects costs
nothing, and one that generates fresh ones costs the whole model again.
When there is no GPU
On a host that offers no GPU context the CPU fallback takes over, and it is a different machine rather than a slower one: it lights per vertex instead of per pixel, carries only the base-colour map, and fills every pixel in managed code — against its own depth buffer, in horizontal bands across every core. The demo's Contact scene, the same moment and the same window, the two renderers side by side:
Metal · 2592×1302 · 68 draws · 15,798 triangles · 0.22 ms/render
Skia · 2592×1302 · 68 draws · 6,872 triangles · 11.45 ms/render
Compare the milliseconds and not the frame rate: both of those runs report over eighty frames a second, which says only that both kept up with the display. The render time says one of them did it with fifty times the headroom.
It also counts differently — 6,872 triangles actually rasterised against the 15,798 the GPU backends
describe as submitted — so compare it against itself over time and never against a GPU row. Everything it
cannot do is in RenderInfo.Features, sixteen crosses out of twenty-eight, each with the
sentence for why, and one of them is a triangle budget: past it, the rest of the frame is dropped rather
than drawn late. How the renderer is chosen is the rest of that
story.
The short version
- Reuse
Meshinstances. One shared mesh is one upload, however many nodes hold it. - Count nodes, not triangles. One
MeshNodeis one draw call. - Give a node
Instanceswhen the copies differ only by transform and tint — four hundred of them, one draw. - Merge static assemblies that share a material, and nothing else.
Batching.Foldfinds them; name or pin whatever must stay separate. - Leave culling on, and read
RenderInfoto see what it is dropping. - Stop drawing what is too far to matter —
MaxDrawDistancefor ground cover and anything past the fog,LevelsOfDetailfor things that are small at a distance. - Write into arrays and call
InvalidateGeometryrather than building a newMeshwhen geometry changes. - Hide idle things with
IsVisible = false— the snapshot skips the whole subtree. - Generate maps with
Texture.FromPixels, never through a PNG. - Write into pixels and call
Texture.Refreshrather than building a newTexturewhen a map changes. - Smooth edges with
SampleCountbefore reaching forRenderScale— the first costs a fraction of the second, and edges are most of what crawls. - Stop drawing when nothing is happening —
RenderTrigger.OnDemandfor a screen that mostly sits still, andMaxFrameRatewhen it moves more slowly than the display. - Measure before and after. Every claim on this page came from the probe, and yours can too.
Run it
One mesh, 125 nodes, 126 draw calls — and the probe line that produced the numbers above.
AVA3D_SCENE="Stress test" AVA3D_PROBE=8 dotnet run --project samples/Ava3D.Demo.Desktop
The same four hundred crates as nodes and as instances, side by side:
AVA3D_SCENE=Instancing AVA3D_PROBE=8 dotnet run --project samples/Ava3D.Demo.Desktop
And the culling pair — one build, one shot of the film, the feature off and then on:
AVA3D_STORY=1 AVA3D_STORY_AT=300 AVA3D_CULL=0 AVA3D_PROBE=8 dotnet run --project samples/Ava3D.Demo.Desktop
AVA3D_STORY=1 AVA3D_STORY_AT=300 AVA3D_PROBE=8 dotnet run --project samples/Ava3D.Demo.Desktop
The forest at its busiest second, with levels of detail and draw distances doing their work:
AVA3D_SCENE=Fog AVA3D_SCENE_AT=30 AVA3D_COMPLETE_TIMING=1 AVA3D_PROBE=12 dotnet run --project samples/Ava3D.Demo.Desktop
The frame-rate ceiling, and the same scene left to draw only when something changes:
AVA3D_SCENE="Programmable screens" AVA3D_MAXFPS=12 AVA3D_PROBE=8 dotnet run --project samples/Ava3D.Demo.Desktop
AVA3D_SCENE="Programmable screens" AVA3D_ONDEMAND=1 AVA3D_PROBE=8 dotnet run --project samples/Ava3D.Demo.Desktop
The two anti-aliasing levers, on a scene made of thin edges — watch the clock face:
AVA3D_SCENE="Clock tower" AVA3D_SAMPLES=4 dotnet run --project samples/Ava3D.Demo.Desktop
AVA3D_SCENE="Clock tower" AVA3D_RENDER_SCALE=2 dotnet run --project samples/Ava3D.Demo.Desktop