Repository navigation
[Performance] Draw entities that share their meshes and materials as one instanced draw - #1390
Merged
untoldengine merged 3 commits intoOct 30, 2026
Merged
Conversation
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.
Owner
|
This is great work. thank you |
untoldengine
approved these changes
Oct 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.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.instanceKeyis 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.RenderInstanceKeyTablegives the numbers and compares the descriptions in full, not their hashes.renderComponent.mesh(a material edit, a texture streamed in, a LOD switch) clears the key in the property'sdidSet. An entity whose material is edited leaves its group and joins it again if the material is set back.What is not instanced
setRendering(.instancedDraws(.disabled))turns it off;getInstancedDrawsEnabled()reads it.The draw (
RenderPasses+InstancedDraws.swift,RenderPipeLines.swift,modelShader.metal,shadowShader.metal)vertexModelInstancedShaderandvertexShadowInstancedShader, and the pipelines.modelInstanced,.modelInstancedOpaqueand.shadowInstanced. The instanced model pipelines usefragmentModelShaderitself, 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 sameVertexOutModel, 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,UniformsandVertexOutModelkeep their contracts: render extensions that draw with them are not affected.ModelInstanceUniform: the model-view, model, previous model and normal matrices; a model matrix for a shadow) goes into a buffer borrowed fromInstanceRecordBufferPooland 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.InstancedSubmeshDraw) fromRenderPasses.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.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 ofMaterialParametersUniformagainst 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 UntoldEngineTestson the rebased branch (2026-10-28): 1,727 run, 3 skipped. The only failure isExternalRenderExtensionPackageTests, which fails locally whenever the checkout folder is not namedUntoldEngine; it passes from a checkout with that name.compare_psnr.pyitself (Homebrew python with OpenCV and scikit-image).Limits