Skip to content

[Performance] Draw entities that share their meshes and materials as one instanced draw - #1390

Merged
untoldengine merged 3 commits into
untoldengine:developfrom
miolabs:feature/instanced_draws
Oct 30, 2026
Merged

untoldengine merged 3 commits into
untoldengine:developfrom
miolabs:feature/instanced_draws

Conversation

@miogds

@miogds miogds commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Stage 2, part 2 of docs/proposals/LargeSceneRendering.md. The opaque pass and the shadow cascades encoded every entity on its own, however many of them drew the same thing. They now gather the entities that have nothing of their own in a draw but their transform, and encode each group that draws the same meshes with the same materials as one instanced draw per submesh.

Before After
10,000 cubes of one mesh, draws per frame 11,896 10
10,000 cubes, CPU time per frame 10.8 ms 4.9 ms
BIM site (21,576 render entities), overview: draws 11,926 5,223
BIM site, overview: frame (CPU) 32.9 ms (19.9) 23.7 ms (13.7)
BIM site, walking between buildings: draws (shadow draws) 16,452 (11,058) 7,416 (4,612)
BIM site, walking between buildings: frame (CPU) 28.4 ms (17.4) 22.2 ms (12.8)
BIM site, among the trees: frame (CPU) 42.2 ms (24.1) 31.4 ms (17.5)

Release build, headless at 1920×1080 on an M4 Max, the two builds run in turn, three rounds. The triangles drawn are the same in every view.

The picture is the same, to the last bits of a few pixels. The instanced shaders do the arithmetic of the single-entity ones on the same values, and the instanced pipelines use the model pass's own fragment function. The tests draw thirteen scenes both ways and compare the frames: the depth, position, normal and emissive attachments are identical, and what differs is about twenty pixels in a million at silhouettes, mostly by one float16 step, because two vertex functions compiled apart differ in their last bits and the fragment's derivatives amplify that at edge quads. The reference images of the render suite that match exactly on develop still match exactly. What else changes is the order of the draws (single entities, batches, then the groups), so where two surfaces lie exactly on each other the other one may show: in the BIM site that is 0 pixels in three of six views and 16 to 493 of 2,073,600 in the three among the trees.

Changes

Finding the groups (RenderInstancing.swift)

  • RenderComponent.instanceKey is a small number that two components share exactly when their mesh lists send the same things to the GPU: the same Metal meshes and local transforms and, per submesh, the same index data, textures, samplers and material values. RenderInstanceKeyTable gives the numbers and compares the descriptions in full, not their hashes.
  • Nothing is declared: copies of a pack model, cubes made from one mesh, or two entities given equal materials by hand group by themselves.
  • Any change to renderComponent.mesh (a material edit, a texture streamed in, a LOD switch) clears the key in the property's didSet. An entity whose material is edited leaves its group and joins it again if the material is set back.
  • The table starts over once it holds more than 65,536 descriptions, between passes only.

What is not instanced

  • An entity with something of its own in a draw: a LOD, tile or mesh fade in progress, a skeleton, a deformation. Those take the code that drew them before, unchanged.
  • Every entity while a debug colouring (by LOD, by streaming tier) is on.
  • Transparent submeshes (the transparency pass sorts them), spot and point light shadows, the wireframe and outline passes.
  • setRendering(.instancedDraws(.disabled)) turns it off; getInstancedDrawsEnabled() reads it.

The draw (RenderPasses+InstancedDraws.swift, RenderPipeLines.swift, modelShader.metal, shadowShader.metal)

  • New vertex entry points vertexModelInstancedShader and vertexShadowInstancedShader, and the pipelines .modelInstanced, .modelInstancedOpaque and .shadowInstanced. The instanced model pipelines use fragmentModelShader itself, with the same function constants as the single-entity ones (makeModelPipeline(instanced:mayDiscard:name:) builds the four model pipelines together, and rebuilds them with the sample count or the render path); the instanced vertex stage outputs the same VertexOutModel, world position and current and previous clip positions included, so the forward path's velocity and lighting are the same either way.
  • vertexModelShader, fragmentModelShader, vertexShadowShader, Uniforms and VertexOutModel keep their contracts: render extensions that draw with them are not affected.
  • A record per instance (ModelInstanceUniform: the model-view, model, previous model and normal matrices; a model matrix for a shadow) goes into a buffer borrowed from InstanceRecordBufferPool and handed back when the frame's command buffer completes. The buffer is bound once per pass and each draw names its first record as its base instance.
  • What a group's draws bind is prepared once per component (InstancedSubmeshDraw) from RenderPasses.opaqueMaterialParameters(of:), which the single-entity path now fills its parameter block with too, and kept until the meshes change. The opacity of a ghost channel is the draw's, in the material parameters as for a single draw: entities that differ in it are groups of their own.
  • A device that cannot start an instanced draw at a base instance (before the A9) gets no instanced pipelines and draws as before. So does a renderer set up with a pipeline list that lacks them.
  • The seven prebuilt Metal libraries are rebuilt with buildkernels.sh.

Verification

  • InstancedDrawRenderTests (16): the frame drawn both ways is the same, to the last bits of a few pixels, with six materials (textures, cut-outs, tiling, emission, metal), meshes with local transforms, a fade among instances, a ghost channel, shadows, loaded models with a skinned one, a pane of glass among the instances, and LOD switches; what makes a group, and that an edited entity leaves and rejoins it.
  • RenderInstanceKeyTests (9): 38 values that must give another key, 8 that must not, what clears a key, the table's limit, and a check of the fields of MaterialParametersUniform against the list the description is made from, which fails when a field is added to one and not the other.
  • InstancedDrawListTests (10): groups, groups of their own for the opacities of ghost channels, record layout and values, the runs of meshes with their own local transform, the buffer pool.
  • swift test --filter UntoldEngineTests on the rebased branch (2026-10-28): 1,727 run, 3 skipped. The only failure is ExternalRenderExtensionPackageTests, which fails locally whenever the checkout folder is not named UntoldEngine; it passes from a checkout with that name.
  • The render suite on the rebased branch (2026-10-28): 1,368 run in two halves, 69 skipped as on develop, 0 failures; the reference-image comparisons ran with compare_psnr.py itself (Homebrew python with OpenCV and scikit-image).
  • Zero warnings in the strict-concurrency build; no new SwiftFormat findings. The engine target compiles for the iOS and visionOS simulators.

Limits

  • The work per entity stays: after this change the walks over the entities (shadow caster selection, LOD selection, the transparency walk) are the largest part of the frame of the BIM site again.
  • A mesh that is drawn once is still one draw: 4,400 of the 5,200 draws left in the overview. Those are for batching by material.
  • Not tried on a headset. The instanced shaders are in all seven libraries, and the stereo path draws each eye through the same pass.

Javier Segura added 3 commits October 28, 2026 07:39
A scene that places one model many times asks for the same draw over and
over with a different transform. These are the vertex stages for drawing
such entities together: one draw with an instance count, and what belongs
to each instance read from a buffer.

vertexModelInstancedShader takes the transform of a surface (model-view,
model and normal matrices, and the model matrix of the previous frame for
the velocity) from a ModelInstanceUniform record, indexed by the instance
id; vertexShadowInstancedShader takes a model matrix per instance. They
read the four vertex streams every mesh has (the position alone for a
shadow): a mesh drawn this way has no skeleton.

The instanced model stage hands the fragment stage the VertexOutModel of
vertexModelShader, computed by the same arithmetic on the same values, and
the fragment function of an instanced pipeline is fragmentModelShader
itself, built with the same function constants: a surface shades the
same whichever way it is drawn, on the deferred, hybrid and forward paths
alike, to the last bits of what the two vertex stages hand it (two
compilations of the same arithmetic differ in those). The opacity of a
ghost channel is the draw's, in the material parameters, as for a single
draw.

vertexModelShader, fragmentModelShader, vertexShadowShader, Uniforms and
VertexOutModel are as they were: a render extension that draws with them
is not affected.
The seven prebuilt Metal libraries, rebuilt with buildkernels.sh for the
instanced model and shadow shaders, and the source hash the stale check
compares against. The deployment targets are the package's (macOS 14,
iOS 17, tvOS 17, visionOS 2).
…one instanced draw

The opaque pass and the shadow cascades encoded every entity on its own:
its vertex streams, its material and a draw call per submesh, however many
entities drew the same thing. A scene that places a model a thousand
times paid for a thousand draws.

Both now gather the entities that have nothing of their own in a draw but
their transform, and encode each group of them that draws the same meshes
with the same materials as one instanced draw per submesh.

A group is found without anything being declared. A render component
keeps a small number, its instance key, that two components share exactly
when their mesh lists send the same things to the GPU: the same Metal
meshes and local transforms and, per submesh, the same index data,
textures, samplers and material values. RenderInstanceKeyTable gives the
numbers by comparing the descriptions in full, not their hashes, so two
different materials never share a draw. Any change to
RenderComponent.mesh (a material edited, a texture streamed in, a LOD
switch) clears the key, and it is looked up again when the entity is next
drawn: an entity whose material is edited leaves its group, and joins it
again if the material is set back. Entities on a ghost channel join the
group of their key with their opacity: the opacity is the draw's.

An entity with something of its own in a draw is drawn alone by the code
that drew it before: a LOD, tile or mesh fade in progress, a skeleton, a
deformation, and every entity while a debug colouring is on. Transparent
submeshes stay with the transparency pass, which sorts them.

What a group's draws bind is prepared once per component
(InstancedSubmeshDraw) from the function the single-entity path fills its
parameter block with, and kept until the meshes change. The records of
the instances (model-view, model and normal matrices, and the model matrix
of the previous frame; a model matrix for a shadow) go into a buffer
borrowed from a pool and handed back when the frame's command buffer
completes. The buffer is bound once per pass and each draw names its
first record as its base instance. A device that cannot do that (before
the A9) gets no instanced pipelines and draws as before.

The instanced model pipelines are the model pipelines with the instanced
vertex stage: the same fragment function and function constants, so a
group shades as a single draw does on every render path, the lighting
inputs the forward path binds once per pass serving both; the same pair
with and without the discards, so a group whose material never discards
goes through the pipeline that lets the GPU resolve visibility first; and
the velocity each pixel writes on the forward path comes from where the
instance was the previous frame, which its record carries.

A frame is the same picture drawn either way, to the last bits of a few
pixels: a vertex stage compiled apart from another differs from it in the
last bits of what it computes (the G-buffer attachments at their own
precision are identical), and the derivatives the fragment stage takes
turn that into a visible difference at a quad or two along the
silhouettes. The tests compare the two ways in fifteen scenes, on the
deferred, hybrid and forward paths, the velocity of a moving instance
included. The order of the draws changes (single entities, batches, then
the groups), so where two surfaces lie exactly on each other the other
one may show.

setRendering(.instancedDraws(.disabled)) turns it off.

10,000 cubes of one mesh, 1,888 of them casting into a shadow cascade
(release build, M4 Max, before the forward path): 11,896 draws become 10,
and the CPU time of a frame goes from 10.8 ms to 4.9 ms.
@miogds
miogds requested a review from untoldengine as a code owner October 28, 2026 11:43
@untoldengine

Copy link
Copy Markdown
Owner

This is great work. thank you

@untoldengine
untoldengine merged commit 500035a into untoldengine:develop Oct 30, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants