From b289a92b8773f04f266afd16cdc45b468a19cf1e Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Thu, 15 Oct 2026 23:02:52 +0200 Subject: [PATCH 01/19] [Performance] Instanced model and shadow shaders 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. --- Sources/CShaderTypes/ShaderTypes.h | 19 ++++++ .../UntoldEngine/Shaders/modelShader.metal | 58 +++++++++++++++++++ .../UntoldEngine/Shaders/shadowShader.metal | 22 +++++++ 3 files changed, 99 insertions(+) diff --git a/Sources/CShaderTypes/ShaderTypes.h b/Sources/CShaderTypes/ShaderTypes.h index be6842331..9a2699940 100644 --- a/Sources/CShaderTypes/ShaderTypes.h +++ b/Sources/CShaderTypes/ShaderTypes.h @@ -138,6 +138,8 @@ typedef enum{ modelPassJointTransformIndex, modelPassHasArmature, modelPassOccluderShrinkIndex, + // The ModelInstanceUniform records of an instanced draw (vertexModelInstancedShader). + modelPassInstanceIndex, }ModelPassBufferIndices; typedef enum{ modelPassFragmentUniformIndex, @@ -159,6 +161,21 @@ typedef enum{ modelPassFragmentSpotShadowUniformIndex, }ModelPassFragmentBufferIndices; +// What one instance of an instanced model draw has of its own: its transforms. Draws that +// share their meshes, materials and ghost opacity are encoded once, with a record per +// instance; the draw's base instance is the index of its first record. Only the vertex +// stage reads the records; the fragment stage is fragmentModelShader, bound as for a +// single draw. +typedef struct +{ + matrix_float4x4 modelViewMatrix; + matrix_float4x4 modelMatrix; + // The model matrix the instance was drawn with the previous frame, for the velocity + // (see Uniforms.prevModelMatrix). + matrix_float4x4 prevModelMatrix; + matrix_float3x3 normalMatrix; +} ModelInstanceUniform; + typedef enum{ deformationPassInPositionIndex, deformationPassInNormalIndex, @@ -655,6 +672,8 @@ typedef enum{ shadowPassLightPositionUniform, shadowPassJointTransformIndex, shadowPassHasArmature, + // The model matrix of each instance of an instanced shadow draw (vertexShadowInstancedShader). + shadowPassInstanceIndex, }ShadowBufferIndices; typedef struct{ diff --git a/Sources/UntoldEngine/Shaders/modelShader.metal b/Sources/UntoldEngine/Shaders/modelShader.metal index 1c68932e2..f14510777 100644 --- a/Sources/UntoldEngine/Shaders/modelShader.metal +++ b/Sources/UntoldEngine/Shaders/modelShader.metal @@ -192,6 +192,64 @@ vertex VertexOutModel vertexModelShader( } +// Instanced model draws: entities that draw the same meshes with the same materials are +// encoded as one draw. Each instance reads what is its own (ModelInstanceUniform) from a +// buffer the pass writes once; `uniforms` carries only what the pass shares (projection, +// view, camera, and the view-projections of this frame and the previous one for the +// velocity). Meshes drawn this way have no skeleton, so the stage takes only the four +// streams every mesh has. The stage hands the fragment stage the same VertexOutModel as +// vertexModelShader, computed by the same arithmetic, and the pipeline's fragment function +// is fragmentModelShader itself: an entity shades the same whichever way it is drawn, to +// the last bits of what two compilations of the same arithmetic compute. +typedef struct { + float4 position [[attribute(modelPassVerticesIndex)]]; + float4 normals [[attribute(modelPassNormalIndex)]]; + float2 uv [[attribute(modelPassUVIndex)]]; + float4 tangent [[attribute(modelPassTangentIndex)]]; +} VertexInModelInstanced; + +vertex VertexOutModel vertexModelInstancedShader( + VertexInModelInstanced in [[stage_in]], + constant Uniforms &uniforms [[buffer(modelPassUniformIndex)]], + const device ModelInstanceUniform *instances [[buffer(modelPassInstanceIndex)]], + uint instanceId [[instance_id]] +) { + const device ModelInstanceUniform &instance = instances[instanceId]; + + VertexOutModel out; + + float4 position = in.position; + float4 normals = in.normals; + + float4 worldPosition = instance.modelMatrix * position; + out.worldPosition = worldPosition.xyz; + out.position = uniforms.projectionMatrix * instance.modelViewMatrix * position; + out.uvCoords = in.uv; + // See vertexModelShader: where the vertex is this frame and was the previous one, for + // the velocity. The pass's uniforms carry the two view-projections, the record where + // the instance was drawn the previous frame. + float4 currentClip = uniforms.unjitteredViewProjectionMatrix * worldPosition; + float4 prevClip = uniforms.prevViewProjectionMatrix * instance.prevModelMatrix * position; + out.currentClipXYW = currentClip.xyw; + out.prevClipXYW = prevClip.xyw; + + // See vertexModelShader: tangents take the model matrix's linear part, normals the + // inverse-transpose. + float3x3 modelLinear = float3x3( + instance.modelMatrix.columns[0].xyz, + instance.modelMatrix.columns[1].xyz, + instance.modelMatrix.columns[2].xyz + ); + simd_float3 T = normalize(modelLinear * in.tangent.xyz); + simd_float3 N = normalize(instance.normalMatrix * normals.xyz); + + out.tangent = float4(T, in.tangent.w); + out.tbNormal = N; + + return out; +} + + // Depth-only occluder shell of a mesh (MeshOccluderComponent): the same vertex streams and // skinning as vertexModelShader, but every vertex is pushed `shrinkMeters` along its world-space // normal, away from the camera, so the shell sits just behind the surface the camera sees. diff --git a/Sources/UntoldEngine/Shaders/shadowShader.metal b/Sources/UntoldEngine/Shaders/shadowShader.metal index ad7ce4ac0..9d8e5314a 100644 --- a/Sources/UntoldEngine/Shaders/shadowShader.metal +++ b/Sources/UntoldEngine/Shaders/shadowShader.metal @@ -51,5 +51,27 @@ if (hasArmature) { vertexOut.position=lightOrthoView*(uniforms.modelMatrix*position); + return vertexOut; +} + +// Instanced shadow draws: casters that draw the same meshes are encoded as one draw, and +// each instance's model matrix comes from a buffer the pass writes once. Meshes drawn this +// way have no skeleton, so the stage takes the position stream alone. The arithmetic is +// that of vertexShadowShader. +struct VertexShadowInstancedInput { + + float3 position [[attribute(shadowPassModelPositionIndex)]]; +}; + +vertex VertexShadowOutput vertexShadowInstancedShader(VertexShadowInstancedInput in [[stage_in]], + constant simd_float4x4 &lightOrthoView [[buffer(shadowPassLightMatrixUniform)]], + const device simd_float4x4 *instanceModelMatrices [[buffer(shadowPassInstanceIndex)]], + uint instanceId [[instance_id]]){ + + VertexShadowOutput vertexOut; + + float4 position = float4(in.position, 1.0); + vertexOut.position=lightOrthoView*(instanceModelMatrices[instanceId]*position); + return vertexOut; } From daaabe934dc11bade7a4c1fbc7ebcb3836230d0f Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Thu, 15 Oct 2026 23:02:52 +0200 Subject: [PATCH 02/19] [Chores] Rebuild the shader libraries 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). --- .../UntoldEngineKernels/.metalsource.sha256 | 2 +- .../UntoldEngineKernels-ios.metallib | Bin 892145 -> 902694 bytes .../UntoldEngineKernels-iossim.metallib | Bin 895777 -> 906358 bytes .../UntoldEngineKernels-tvos.metallib | Bin 892497 -> 903046 bytes .../UntoldEngineKernels-tvossim.metallib | Bin 896001 -> 906598 bytes .../UntoldEngineKernels-xros.metallib | Bin 893857 -> 904390 bytes .../UntoldEngineKernels-xrossim.metallib | Bin 897233 -> 907814 bytes .../UntoldEngineKernels.metallib | Bin 890737 -> 901286 bytes 8 files changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/UntoldEngine/UntoldEngineKernels/.metalsource.sha256 b/Sources/UntoldEngine/UntoldEngineKernels/.metalsource.sha256 index ecbb08296..6b637479c 100644 --- a/Sources/UntoldEngine/UntoldEngineKernels/.metalsource.sha256 +++ b/Sources/UntoldEngine/UntoldEngineKernels/.metalsource.sha256 @@ -1 +1 @@ -a9b77a22d8a409ac18447f39f1b1557430df52388bd6df244125014ec699e9cc +97a20e253fb0ade0f28ae6147073e02f1702b6ebb04db1280a0175a2bd743850 diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib index 6948599b3eb222f43c22cd38a2b0e9d3a6df916b..8dc321ada664a2910a58e18fb02d2c822b8ac656 100644 GIT binary patch delta 6431 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 JR#x)qe*;*KQ#Jqq delta 2654 zcmY+_4{Q@v90%}QVO`sG(!FQqHrTiW#x@392XzeCpkw^mLZ@9?wsql85&1t^F(L?? z6iM7-7HskhZpNuo69!6RahMJNK!qr1h?ay17|;@9fJmfEG>(Xw*Y~~+@siiuexKj_ zy?5{K-5u?jKYF^t*Wh(6O;HR=nxedtsVK^5e}?|6C@tbwQ7m=xkL@^*Tjc-SJo219 zQJxPr%X474?0@K_@A+&xQ)HvI92d>anMFHOEOalYkw$WC1~r#PCuh^R%|g3!o2WY1 zW>89GsKnO$!Ku_tYPYA+;es?$Cp+kZ?!ju|FHhdUTn0H-cZxiR8+Gbn9SDaIR@aFh zJ+o~zIHiG|0QZ4I+XYW5w9%P7AG1!7#RkC2tAfY%`Rpz+W>UZ*=M%_xur}l#OO|>k znM*(Cdssg>oGkT`&PB9is+U~?jer8fba`qAdx&rZ;n;amFtL1Hs*BCWB3}f%qk`2^ z8)azCYz;UH4&M-bSli5cK-WO(T|xQNHnT54F;M6)L5X`ZGOdZ-1K$BF22!UV;!{%P zGQ|-NJZ7X@wHjTW-pXCDF<3m;NE-}Kh^Ju33>WW!>yMi_{Dnr+xJJLv@URmw6D%^1 z+yyW38wl&qhd5NHESZ?)zXeUa$|&R6@Mx1>vQne2nd^8POocf&8tJ_njdBXt^L|)S za=3PR87B%``6ZY=*{?&|!lG9G5LO8bcPB4#4OjI&lsKF)`&&t~k+;~z-S9eiXi$1$ zV_Ye2x_@XSMSYm>=fgn+Bs>m}eq*A>@?z?I(#uc7#$fKBq-9ii`ByLnZzVOJsH@Qt-M)pHU~#iq z-&Dm^Fu$2yhS^}zJW?ym=*#(y>^dkL6tn9T*$SPK)Z<&`5!=f-{ zlU1yC*79BaEbJOAy5CAC(=_^gc{9HNi@{>A$$p8~%u_2o{0I0QxVu+6_B7pG(ZH`F zCypH3(f{T2p7vzOeScu3ga3l$F(j*zM6yP=S9lXEf?9O>ZW>ne}GTTe{w?!{>|{&z}7cYT*XO diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-iossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-iossim.metallib index 84cc1104f5cbd008f1147376b3ff523452d4ba7c..86e5a6da88b3f80b23a73c2236f661638d63f4a5 100644 GIT binary patch delta 6408 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 VG%08@(7Zq+K=bZKZ^`N5{{em2Llyu4 delta 2650 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 delta 2645 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 Mo*g@H$?E(62LfeZWB>pF delta 2645 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 delta 2633 zcmY+_du$X%90%~d0@u6i9V|0idr;uEyn3{hD-Xm|((TA~55 zz#%au+UUtI*pfgf@CRU;cnu*fqIkY28luG*DF*O}AwVE<#6)Vu)ZLlyO59{_+u!H+ zo0*;6*}Zgl?(x1k;i{0o%%Y?yHbr?iMNyRFCtdolqST3BMR8QfKQ`e!x$&_j1M^}5v*HIp8X&7dLGLCs@osA!Cu zlFXH%9F;6%gVd*o8j3||&)BuhZ_G_U4p`(l8q}$UwICctSZNRyZMiC)%B*5t;0|z8 zqu^0fRk}DX%$&nyXPw|gtKcDh3f1F-tPy1vdJ5 zRu!*CP69biSu&@pU>!dLy9Y~NFpbgg1wq~oPr^Ol*{C|-OS@->_<7hMtn;Qc*PIal z0;b?)6#2vGSEEC^eG9X}8eDdLFL~R;-S+3nUpkU1<`%H4NK%nhqLHU?27NZSnq3EF znDqrZMfh!mJqX8^*hwq$(v89ZzYoiX_1D_PGS$r6!XLwYFlCEfT;kBY5dRBS2vheN zb^0q|eu!J}jVgha9F~2`rC-7A@CtaWN4oL2bSwz-(eQv-?{$cAr!NfgVpyG7d%_{+ zYt)`w7-mc14e&T4Pth*E9N~=!N3zmHnJ3Rd-xsaoweTjmGCPg5i5jhWaV=j1+X+jS zr-|u2eKEv0z#_1wdTDLNA-)ZE!0fZx7*nI?NrX=&YeiBglH$G8S-OwEkML=P1O2k^Wo4UqJ1hq4f1>wW=%vSHHT)Q?*BsO7 z6d#T^%Y*C`EDrNzQlh+te~j>DgneaBu{0fvLc9mo4{O-$6ic4IxS4+r8!+qMa#DAy zMi&;>@;+DsR`P+YGu|~XEeY};;P>FMqteZdeRD|_zlNNoId<3oa@yVXt~5F|XC&=i zTEKrr@*t9EtiZCbOd=$RELNrdwUcjwNelEq?-2O)GR63vjlz za#YIMPnKCMXaDx6IrpcBThqf9y`|`FnBIo>q=!f3I(klQ9}(Z(deCOoTdLk{db8`z Qp|>=>IeQM;Tm@hM3(Q*p>;M1& diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib index 41ed46c1b083c45adc4195824f2c542c34bdeb0b..741184474fde4766c634bf020c2af02240d81cae 100644 GIT binary patch delta 6415 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 X8V;HmGzn-Pph-dV>@we#>4yFXi#164 delta 2619 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 delta 2651 zcmY+_ZA?>F7zgl66=+M5>p6-bh_|3Z1)(S^zM!S}0=@%nRR5+xkbAfqbA1HWSN-wf*&^BX0&{nY;$3^_x$h7NKS8ge$R8x zx##rU`&sAW(JvPHYP~LJg5Mw|OH$_~Ns`)oQq@mMYSca@$y_bI?8J54EdGDXBd!?} z#kI3RTw^Om{n7Kjy%}_=K&IAA7tPO{OS==y^fa@M!kMzcpGBit88j}NseO7qRZN!+ zZABtfDECaekT{w4*punR>||21%IKQvu@#yh$=b|Z1~JyL1aY-{RO(EyMGdtKzgd+&IT-7>^uUMDjVntZj>)_~Zjg?}VQmqYa12_tHJka=r zbsOsj4TB=TX_S+@jeQFm(W_o)6yJt$ZasSnexg@SASLf8&rZbFB5aqF=uwrG?&LLb z7iJ;8C@xMF_TG* z*{hG~Q28A;8Q~zpE%jPeq~r~j3F_A?_GwgG#%Pn>!>r&CINGW4c%hkow)>b390p4t zXq+>{Oo3HKI#}vurAUb&#U9jB#!7dyC7|n|wksM1=C`s+P!tq+sL`AS`*;m#7!)!X zwPOPPyad7RB}~LqUyzE>^72QB(-Ie zQeM)t>9Q$}ZZ3ARyYLKnphR^)e}u4taIDfq)(RWlD|ho>VFfV7W75{De#zT>6lRCH zcbK&M9ADz)f5OUPEgkVX^|`Rr%ME4+L=%&+(B5!SuZ%lE*J!5ll|V_Ip7!^`)>0So|C66Q|Qd1NyMwl_{T^JBPo(-(Ox5msyyrrECOqpN+VSr{2ap95q2!IXlvBF z%FBmfQJ8y+MO*vy)$RN;Y#62-ve5ZtD}A%NfnS4-=zSg+b@3<9Yim6G2lx|s=(KSC znBQMh%kLm3hMcxf|DUsMZ7qL@oH6A1FUE7M^mwh8KZZ$oS;ua@n#2BT4}T6f!lQp# z;>VVfY8v)rN-}ju&XA`FnDoYLc-wRt`y7BbYEAxFF|bvwM|gl#G!OwVxMU! hcW+|saF^4Vq_$+W8P#S|n^|osYO@SEjj3D0{{o}-0wn+d From 452c8c4072d3a1974b8ecb3c94feb39c8e0f2f11 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Thu, 15 Oct 2026 23:02:52 +0200 Subject: [PATCH 03/19] [Performance] Draw entities that share their meshes and materials as 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. --- Sources/UntoldEngine/ECS/Components.swift | 28 +- .../Profiling/RenderStatsCollector.swift | 36 + .../Renderer/Pipelines/RenderPipeLines.swift | 56 +- .../Pipelines/RenderPipelineType.swift | 7 + .../Renderer/RenderInitializer.swift | 47 ++ .../Renderer/RenderInstancing.swift | 691 ++++++++++++++++++ .../RenderPasses+InstancedDraws.swift | 299 ++++++++ .../UntoldEngine/Renderer/RenderPasses.swift | 177 ++++- .../Utils/EngineSettingsAPI.swift | 11 + .../InstancedDrawListTests.swift | 280 +++++++ .../InstancedDrawRenderTests.swift | 649 ++++++++++++++++ .../RenderInstanceKeyTests.swift | 302 ++++++++ docs/API/UsingEngineAPI.md | 11 + docs/Architecture/renderingSystem.md | 28 +- 14 files changed, 2589 insertions(+), 33 deletions(-) create mode 100644 Sources/UntoldEngine/Renderer/RenderInstancing.swift create mode 100644 Sources/UntoldEngine/Renderer/RenderPasses+InstancedDraws.swift create mode 100644 Tests/UntoldEngineRenderTests/InstancedDrawListTests.swift create mode 100644 Tests/UntoldEngineRenderTests/InstancedDrawRenderTests.swift create mode 100644 Tests/UntoldEngineRenderTests/RenderInstanceKeyTests.swift diff --git a/Sources/UntoldEngine/ECS/Components.swift b/Sources/UntoldEngine/ECS/Components.swift index 62d7a7466..6d855c0ec 100644 --- a/Sources/UntoldEngine/ECS/Components.swift +++ b/Sources/UntoldEngine/ECS/Components.swift @@ -50,7 +50,33 @@ public class WorldTransformComponent: Component { } public class RenderComponent: Component { - public var mesh: [Mesh] + public var mesh: [Mesh] { + didSet { + // Whatever changed (a material, a texture, the level of detail), the + // renderer has to look again at what the component draws. + instanceKey = .unresolved + if instancedSubmeshDraws != nil { + instancedSubmeshDraws = nil + } + if instancedShadowDraws != nil { + instancedShadowDraws = nil + } + } + } + + /// What `mesh` draws, as a number the renderer groups instanced draws by: see + /// `RenderInstanceKeyTable`. Any change to `mesh` clears it. + var instanceKey: RenderInstanceKey = .unresolved + + /// The draws of `mesh` in the opaque pass with everything they bind, prepared the + /// first time the component stands for its group in an instanced draw. Any change + /// to `mesh` clears them. + var instancedSubmeshDraws: [InstancedSubmeshDraw]? + + /// The draws of `mesh` in a shadow pass, prepared the first time the component + /// stands for its group there: every submesh but those of glass, which casts no + /// shadow. Any change to `mesh` clears them. + var instancedShadowDraws: [InstancedShadowDraw]? var assetURL: URL = .init(fileURLWithPath: "") var assetName: String = "" public var isVisible: Bool = true // Visibility flag for bulk loading diff --git a/Sources/UntoldEngine/Profiling/RenderStatsCollector.swift b/Sources/UntoldEngine/Profiling/RenderStatsCollector.swift index 7d28d2ade..a46d3d6eb 100644 --- a/Sources/UntoldEngine/Profiling/RenderStatsCollector.swift +++ b/Sources/UntoldEngine/Profiling/RenderStatsCollector.swift @@ -170,6 +170,42 @@ public extension MTLRenderCommandEncoder { #endif } + /// An instanced draw: `instanceCount` instances, the first of which reads the instance + /// record at `baseInstance`. The statistics count it as one draw call and + /// `instanceCount` times its triangles. + @inline(__always) + func drawIndexedPrimitivesTracked( + type: MTLPrimitiveType, + indexCount: Int, + indexType: MTLIndexType, + indexBuffer: MTLBuffer, + indexBufferOffset: Int, + instanceCount: Int, + baseInstance: Int, + category: RenderDrawCategory = .other + ) { + drawIndexedPrimitives( + type: type, + indexCount: indexCount, + indexType: indexType, + indexBuffer: indexBuffer, + indexBufferOffset: indexBufferOffset, + instanceCount: instanceCount, + baseVertex: 0, + baseInstance: baseInstance + ) + #if ENGINE_STATS_ENABLED + RenderStatsCollector.shared.recordIndexedDraw( + type: type, + indexCount: indexCount, + instanceCount: instanceCount, + category: category + ) + #else + _ = category + #endif + } + @inline(__always) func drawPrimitivesTracked( type: MTLPrimitiveType, diff --git a/Sources/UntoldEngine/Renderer/Pipelines/RenderPipeLines.swift b/Sources/UntoldEngine/Renderer/Pipelines/RenderPipeLines.swift index 5d0834162..7ac748130 100644 --- a/Sources/UntoldEngine/Renderer/Pipelines/RenderPipeLines.swift +++ b/Sources/UntoldEngine/Renderer/Pipelines/RenderPipeLines.swift @@ -361,6 +361,24 @@ public func InitShadowPipeline() -> RenderPipeline? { ) } +/// The shadow pipeline of instanced draws (see `InstancedDrawList`): the depth state of +/// `InitShadowPipeline`, a vertex stage that reads each instance's model matrix from a +/// buffer and takes the position stream alone. Nil on a device that cannot draw that +/// way (`InstancedDrawing.isSupported(by:)`): the pass then draws caster by caster. +public func InitShadowInstancedPipeline() -> RenderPipeline? { + guard let device = renderInfo.device, InstancedDrawing.isSupported(by: device) else { return nil } + return CreatePipeline( + vertexShader: "vertexShadowInstancedShader", + fragmentShader: nil, + vertexDescriptor: createShadowInstancedVertexDescriptor(), + colorFormats: [.invalid], + depthFormat: renderInfo.depthPixelFormat, + depthCompareFunction: MTLCompareFunction.less, + reverseZCompatible: false, + name: "Shadow Instanced Pipeline" + ) +} + // MARK: Model pipeline private var wf: WorkingColorFormats { @@ -384,7 +402,30 @@ public func InitModelOpaquePipeline() -> RenderPipeline? { makeModelPipeline(mayDiscard: false, name: "Model Pipeline (opaque)") } -private func makeModelPipeline(mayDiscard: Bool, name: String) -> RenderPipeline? { +/// The model pipeline of instanced draws (see `InstancedDrawList`): the attachments, the +/// depth state, the function constants and the fragment function of `InitModelPipeline` +/// itself, so a group shades as a single draw does on every render path (deferred, hybrid, +/// forward); a vertex stage that reads each instance's transform from a +/// buffer, and the four vertex streams every mesh has. Nil on a device that cannot draw +/// that way (`InstancedDrawing.isSupported(by:)`): the pass then draws entity by entity. +public func InitModelInstancedPipeline() -> RenderPipeline? { + guard let device = renderInfo.device, InstancedDrawing.isSupported(by: device) else { return nil } + return makeModelPipeline(instanced: true, mayDiscard: true, name: "Model Instanced Pipeline") +} + +/// The instanced model pipeline compiled without the discards, as `InitModelOpaquePipeline`: +/// the pass picks it for the groups whose material never needs them. +public func InitModelInstancedOpaquePipeline() -> RenderPipeline? { + guard let device = renderInfo.device, InstancedDrawing.isSupported(by: device) else { return nil } + return makeModelPipeline(instanced: true, mayDiscard: false, name: "Model Instanced Pipeline (opaque)") +} + +private func makeModelPipeline(instanced: Bool = false, mayDiscard: Bool, name: String) -> RenderPipeline? { + let vertexShader = instanced ? "vertexModelInstancedShader" : "vertexModelShader" + // One fragment function for both: a second compilation of the same stage would differ + // from the first in the last bits it computes, and a group is to shade as a single + // draw does. + let fragmentShader = "fragmentModelShader" let indirectInGBuffer = renderInfo.perFragmentIndirectLighting let litInGBuffer = renderInfo.perFragmentLightingWanted renderInfo.perFragmentIndirectLightingBuilt = indirectInGBuffer @@ -393,8 +434,8 @@ private func makeModelPipeline(mayDiscard: Bool, name: String) -> RenderPipeline handleError(.metalLibraryNotFound) return nil } - guard let vertexFunction = library.makeFunction(name: "vertexModelShader") else { - handleError(.shaderCreationFailed, "vertexModelShader") + guard let vertexFunction = library.makeFunction(name: vertexShader) else { + handleError(.shaderCreationFailed, vertexShader) return nil } let constants = MTLFunctionConstantValues() @@ -406,9 +447,9 @@ private func makeModelPipeline(mayDiscard: Bool, name: String) -> RenderPipeline constants.setConstantValue(&litValue, type: .bool, index: Int(modelFunctionConstantLitInGBuffer.rawValue)) let fragmentFunction: MTLFunction do { - fragmentFunction = try library.makeFunction(name: "fragmentModelShader", constantValues: constants) + fragmentFunction = try library.makeFunction(name: fragmentShader, constantValues: constants) } catch { - handleError(.shaderCreationFailed, "fragmentModelShader (\(name)): \(error.localizedDescription)") + handleError(.shaderCreationFailed, "\(fragmentShader) (\(name)): \(error.localizedDescription)") return nil } @@ -416,7 +457,7 @@ private func makeModelPipeline(mayDiscard: Bool, name: String) -> RenderPipeline desc.label = name desc.vertexFunction = vertexFunction desc.fragmentFunction = fragmentFunction - desc.vertexDescriptor = createModelVertexDescriptor() + desc.vertexDescriptor = instanced ? createModelInstancedVertexDescriptor() : createModelVertexDescriptor() desc.depthAttachmentPixelFormat = renderInfo.depthPixelFormat desc.rasterSampleCount = max(1, renderInfo.opaqueSampleCount) @@ -1262,8 +1303,11 @@ public func DefaultPipeLines() -> [(RenderPipelineType, RenderPipelineInitBlock) (.grid, InitGridPipeline), (.sky, InitSkyPipeline), (.shadow, InitShadowPipeline), + (.shadowInstanced, InitShadowInstancedPipeline), (.model, InitModelPipeline), (.modelOpaque, InitModelOpaquePipeline), + (.modelInstanced, InitModelInstancedPipeline), + (.modelInstancedOpaque, InitModelInstancedOpaquePipeline), (.light, InitLightPipeline), (.lightTile, InitLightTilePipeline), (.geometry, InitGeometryPipeline), diff --git a/Sources/UntoldEngine/Renderer/Pipelines/RenderPipelineType.swift b/Sources/UntoldEngine/Renderer/Pipelines/RenderPipelineType.swift index 2047274d3..d0ef4c575 100644 --- a/Sources/UntoldEngine/Renderer/Pipelines/RenderPipelineType.swift +++ b/Sources/UntoldEngine/Renderer/Pipelines/RenderPipelineType.swift @@ -24,10 +24,17 @@ public extension RenderPipelineType { static let grid: RenderPipelineType = "grid" static let sky: RenderPipelineType = "sky" static let shadow: RenderPipelineType = "shadow" + /// The shadow pipeline for instanced draws: casters that share their meshes, one draw. + static let shadowInstanced: RenderPipelineType = "shadowInstanced" static let model: RenderPipelineType = "model" /// The model pipeline compiled without fragment discards, for the opaque draws that never /// need them: the GPU resolves visibility before shading them. static let modelOpaque: RenderPipelineType = "modelOpaque" + /// The model pipeline for instanced draws: entities that share their meshes and + /// materials, one draw. + static let modelInstanced: RenderPipelineType = "modelInstanced" + /// The instanced model pipeline compiled without fragment discards, as `modelOpaque`. + static let modelInstancedOpaque: RenderPipelineType = "modelInstancedOpaque" static let light: RenderPipelineType = "light" /// The TBDR light pass as a tile stage: lights each pixel once per colour it holds. static let lightTile: RenderPipelineType = "lightTile" diff --git a/Sources/UntoldEngine/Renderer/RenderInitializer.swift b/Sources/UntoldEngine/Renderer/RenderInitializer.swift index f8744a75d..9094f609b 100644 --- a/Sources/UntoldEngine/Renderer/RenderInitializer.swift +++ b/Sources/UntoldEngine/Renderer/RenderInitializer.swift @@ -148,6 +148,17 @@ func updateOpaquePipelinesForSampleCount() { if let modelOpaquePipeline = InitModelOpaquePipeline() { PipelineManager.shared.update(rendererPipeLine: modelOpaquePipeline, forType: .modelOpaque) } + // Only where the renderer was set up with them: the pass draws instanced when they are there. + if PipelineManager.shared.renderPipelinesByType[.modelInstanced] != nil, + let modelInstancedPipeline = InitModelInstancedPipeline() + { + PipelineManager.shared.update(rendererPipeLine: modelInstancedPipeline, forType: .modelInstanced) + } + if PipelineManager.shared.renderPipelinesByType[.modelInstancedOpaque] != nil, + let modelInstancedOpaquePipeline = InitModelInstancedOpaquePipeline() + { + PipelineManager.shared.update(rendererPipeLine: modelInstancedOpaquePipeline, forType: .modelInstancedOpaque) + } if let lightPipeline = InitLightPipeline() { PipelineManager.shared.update(rendererPipeLine: lightPipeline, forType: .light) } @@ -1381,6 +1392,42 @@ func initIBLResources() { } } +/// The vertex streams of an instanced shadow draw: the position stream alone, laid out as +/// `createShadowVertexDescriptor` lays it out. +func createShadowInstancedVertexDescriptor() -> MTLVertexDescriptor { + let vertexDescriptor = MTLVertexDescriptor() + let position = Int(shadowPassModelPositionIndex.rawValue) + vertexDescriptor.attributes[position].format = .float4 + vertexDescriptor.attributes[position].bufferIndex = position + vertexDescriptor.attributes[position].offset = 0 + vertexDescriptor.layouts[position].stride = MemoryLayout.stride + vertexDescriptor.layouts[position].stepFunction = .perVertex + vertexDescriptor.layouts[position].stepRate = 1 + return vertexDescriptor +} + +/// The vertex streams of an instanced model draw: position, normal, texture coordinates +/// and tangent, laid out as `createModelVertexDescriptor` lays them out. A mesh drawn +/// instanced has no skeleton, so the joint streams are not part of the draw. +func createModelInstancedVertexDescriptor() -> MTLVertexDescriptor { + let vertexDescriptor = MTLVertexDescriptor() + let streams: [(index: Int, format: MTLVertexFormat, stride: Int)] = [ + (Int(modelPassVerticesIndex.rawValue), .float4, MemoryLayout.stride), + (Int(modelPassNormalIndex.rawValue), .float4, MemoryLayout.stride), + (Int(modelPassUVIndex.rawValue), .float2, MemoryLayout.stride), + (Int(modelPassTangentIndex.rawValue), .float4, MemoryLayout.stride), + ] + for stream in streams { + vertexDescriptor.attributes[stream.index].format = stream.format + vertexDescriptor.attributes[stream.index].bufferIndex = stream.index + vertexDescriptor.attributes[stream.index].offset = 0 + vertexDescriptor.layouts[stream.index].stride = stream.stride + vertexDescriptor.layouts[stream.index].stepFunction = .perVertex + vertexDescriptor.layouts[stream.index].stepRate = 1 + } + return vertexDescriptor +} + func createShadowVertexDescriptor() -> MTLVertexDescriptor { // set the vertex descriptor let vertexDescriptor = MTLVertexDescriptor() diff --git a/Sources/UntoldEngine/Renderer/RenderInstancing.swift b/Sources/UntoldEngine/Renderer/RenderInstancing.swift new file mode 100644 index 000000000..d68dd0e81 --- /dev/null +++ b/Sources/UntoldEngine/Renderer/RenderInstancing.swift @@ -0,0 +1,691 @@ +// +// RenderInstancing.swift +// UntoldEngine +// +// Instanced draws: entities that draw the same meshes with the same materials are +// encoded as one draw, with a record per instance. +// +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import CShaderTypes +import Foundation +import Metal +import MetalKit +import simd + +/// Instanced drawing as a whole. +/// +/// A scene that places one model many times (the chairs of a hall, the trees of a site) +/// asks for the same draw over and over with a different transform. The opaque pass and +/// the shadow cascades gather the entities that draw the same thing and encode one draw +/// for all of them: the meshes and the material are bound once, and each instance reads +/// its transform from a buffer. +enum InstancedDrawing { + /// Whether the passes draw instanced. Off, every entity is encoded on its own, as + /// before. Set with `setRendering(.instancedDraws(_:))`. + nonisolated(unsafe) static var isEnabled = true + + /// Whether `device` can draw instanced the way the passes do: each draw starts at a + /// record of its own in a buffer the pass binds once (its base instance), which + /// every Mac and every Apple GPU from the A9 on can do. On an older one the instanced + /// pipelines are not made, and the passes draw entity by entity. + static func isSupported(by device: MTLDevice) -> Bool { + device.supportsFamily(.apple3) || device.supportsFamily(.mac2) + } +} + +/// The current value set via `setRendering(.instancedDraws(_:))`. +public func getInstancedDrawsEnabled() -> Bool { + InstancedDrawing.isEnabled +} + +/// What a render component draws, as a number: see `RenderInstanceKeyTable`. +struct RenderInstanceKey: Equatable { + /// The generation of the table the key was looked up in. A key of another generation + /// says nothing. + var generation: UInt32 + /// The number of what the component draws, or `notInstanced`. + var id: Int32 + + /// The id of a component that is drawn on its own. + static let notInstanced: Int32 = -1 + /// The key of a component nobody has looked at since its meshes last changed. + static let unresolved = RenderInstanceKey(generation: 0, id: notInstanced) +} + +/// One draw of an instanced group in the opaque pass: a submesh, with everything the +/// encoder is handed for it. +/// +/// It is made from the submesh's material once, when a component first stands for its +/// group, and kept by the component until its meshes change. Two things read it: the +/// encoder, which binds exactly these values, and `MeshListDescription`, which decides +/// from the same values whether two materials are the same. A value an instanced draw +/// is to bind is added here and to `MeshListDescription.addMaterial(of:)`. +struct InstancedSubmeshDraw { + let meshIndex: Int32 + + let primitiveType: MTLPrimitiveType + let indexCount: Int + let indexType: MTLIndexType + let indexBuffer: MTLBuffer + let indexBufferOffset: Int + + let baseColorTexture: MTLTexture? + let roughnessTexture: MTLTexture? + let metallicTexture: MTLTexture? + let normalTexture: MTLTexture? + let heightTexture: MTLTexture? + let emissiveTexture: MTLTexture? + let baseColorSampler: MTLSamplerState? + /// The sampler of the roughness and metallic textures. + let materialSampler: MTLSamplerState? + let normalSampler: MTLSamplerState? + let heightSampler: MTLSamplerState? + + let stScale: Float + let hasNormal: Bool + let normalIsPackedXY: Bool + /// The material's parameter block. Its `passthroughAlpha` and `lodDither` are not + /// used: an instance takes its opacity from its record and has no dither. + let parameters: MaterialParametersUniform + + /// The draw of `submesh`, whose material is `material`: one the opaque pass draws. + init(meshIndex: Int, submesh: borrowing SubMesh, material: borrowing Material) { + self.meshIndex = Int32(meshIndex) + primitiveType = submesh.primitiveType + indexCount = submesh.indexCount + indexType = submesh.indexType + indexBuffer = submesh.indexBuffer + indexBufferOffset = submesh.indexBufferOffset + + baseColorTexture = material.baseColor.texture + roughnessTexture = material.roughness.texture + metallicTexture = material.metallic.texture + normalTexture = material.normal.texture + heightTexture = material.height.texture + emissiveTexture = material.emissive.texture + baseColorSampler = material.baseColor.sampler + materialSampler = material.roughness.sampler + normalSampler = material.normal.sampler + heightSampler = material.height.sampler + + stScale = material.stScale + hasNormal = material.normal.texture != nil + normalIsPackedXY = material.normalIsPackedXY + parameters = RenderPasses.opaqueMaterialParameters(of: material) + } + + /// The draws of `meshes` in the opaque pass, in the order they are encoded: every + /// submesh that has a material and is not blended. + static func draws(of meshes: [Mesh]) -> [InstancedSubmeshDraw] { + var draws: [InstancedSubmeshDraw] = [] + for meshIndex in meshes.indices { + for submeshIndex in meshes[meshIndex].submeshes.indices { + // A material that is blended or lets light through belongs to the + // transparency pass. + guard let material = meshes[meshIndex].submeshes[submeshIndex].material, + !material.hasTransparency + else { continue } + draws.append(InstancedSubmeshDraw( + meshIndex: meshIndex, + submesh: meshes[meshIndex].submeshes[submeshIndex], + material: material + )) + } + } + return draws + } +} + +/// One draw of an instanced group in a shadow pass: the index data of a submesh that +/// casts. Glass casts no shadow, so its submeshes are not among them. +struct InstancedShadowDraw { + let meshIndex: Int32 + let primitiveType: MTLPrimitiveType + let indexCount: Int + let indexType: MTLIndexType + let indexBuffer: MTLBuffer + let indexBufferOffset: Int + + /// The shadow draws of `meshes`, in the order they are encoded. + static func draws(of meshes: [Mesh]) -> [InstancedShadowDraw] { + var draws: [InstancedShadowDraw] = [] + for meshIndex in meshes.indices { + for submeshIndex in meshes[meshIndex].submeshes.indices { + if let material = meshes[meshIndex].submeshes[submeshIndex].material, material.transmitsLight { + continue + } + draws.append(InstancedShadowDraw( + meshIndex: Int32(meshIndex), + primitiveType: meshes[meshIndex].submeshes[submeshIndex].primitiveType, + indexCount: meshes[meshIndex].submeshes[submeshIndex].indexCount, + indexType: meshes[meshIndex].submeshes[submeshIndex].indexType, + indexBuffer: meshes[meshIndex].submeshes[submeshIndex].indexBuffer, + indexBufferOffset: meshes[meshIndex].submeshes[submeshIndex].indexBufferOffset + )) + } + } + return draws + } +} + +/// What a mesh list sends to the GPU in an opaque draw, as a list of words that two mesh +/// lists have in common exactly when their entities can share a draw. +/// +/// The words name the Metal meshes, their local transforms and, for every submesh, its +/// index data and what the pass binds for its material (`InstancedSubmeshDraw`): the +/// textures and samplers by identity, the values bit for bit. What differs between two +/// entities that share a draw (the world transform, the opacity of a ghost channel) is +/// not part of it. +struct MeshListDescription { + private(set) var words: [UInt64] = [] + + /// Describes `meshes`. Returns false, leaving `words` unusable, for a list that is + /// always drawn on its own: one that is empty or draws nothing in the opaque pass. + /// + /// Whether the vertices are the entity's own (a skeleton, a deformation) is not the + /// mesh list's to say: every mesh carries a skin, used or not. The passes ask the + /// entity, and draw one that has either on its own. + mutating func describe(_ meshes: [Mesh]) -> Bool { + words.removeAll(keepingCapacity: true) + guard !meshes.isEmpty else { return false } + + var drawsOpaque = false + for meshIndex in meshes.indices { + add(identityOf: meshes[meshIndex].metalKitMesh) + add(meshes[meshIndex].localSpace) + words.append(UInt64(meshes[meshIndex].submeshes.count)) + + for submeshIndex in meshes[meshIndex].submeshes.indices { + // The index data, which a shadow draw reads for every submesh. + add(identityOf: meshes[meshIndex].submeshes[submeshIndex].metalKitSubmesh) + guard let material = meshes[meshIndex].submeshes[submeshIndex].material else { + words.append(Self.noMaterial) + continue + } + // The transparency pass draws a blended submesh and one that lets light + // through, entity by entity. The second casts no shadow, which two + // components that share a shadow draw have to agree on. + if material.hasTransparency { + words.append(material.transmitsLight ? Self.transmittingMaterial : Self.blendedMaterial) + continue + } + words.append(Self.opaqueMaterial) + addMaterial(of: InstancedSubmeshDraw( + meshIndex: meshIndex, + submesh: meshes[meshIndex].submeshes[submeshIndex], + material: material + )) + drawsOpaque = true + } + } + return drawsOpaque + } + + var hash: Int { + // FNV-1a over the words. + var hash: UInt64 = 0xCBF2_9CE4_8422_2325 + for word in words { + hash = (hash ^ word) &* 0x0000_0100_0000_01B3 + } + return Int(truncatingIfNeeded: hash) + } + + private static let noMaterial: UInt64 = 0 + private static let blendedMaterial: UInt64 = 1 + private static let opaqueMaterial: UInt64 = 2 + private static let transmittingMaterial: UInt64 = 3 + + /// Everything of the material that `draw` binds. A value left out here would let two + /// different materials share a draw: `RenderInstanceKeyTests` checks the parameter + /// block against the list below, field by field. + private mutating func addMaterial(of draw: borrowing InstancedSubmeshDraw) { + add(identityOf: draw.baseColorTexture) + add(identityOf: draw.roughnessTexture) + add(identityOf: draw.metallicTexture) + add(identityOf: draw.normalTexture) + add(identityOf: draw.heightTexture) + add(identityOf: draw.emissiveTexture) + add(identityOf: draw.baseColorSampler) + add(identityOf: draw.materialSampler) + add(identityOf: draw.normalSampler) + add(identityOf: draw.heightSampler) + add(draw.stScale, (draw.hasNormal ? 1 : 0) + (draw.normalIsPackedXY ? 2 : 0)) + + add(draw.parameters.baseColor) + add(draw.parameters.hasTexture) + add(draw.parameters.textureChannels) + add(draw.parameters.edgeTint) + add(simd_float4(draw.parameters.emmissive, 0)) + add(draw.parameters.roughness, draw.parameters.specular) + add(draw.parameters.subsurface, draw.parameters.metallic) + add(draw.parameters.specularTint, draw.parameters.anisotropic) + add(draw.parameters.sheen, draw.parameters.sheenTint) + add(draw.parameters.clearCoat, draw.parameters.clearCoatGloss) + add(draw.parameters.ior, draw.parameters.alphaCutoff) + add(draw.parameters.heightScale, draw.parameters.heightMidlevel) + add(draw.parameters.heightRemapMin, draw.parameters.heightRemapMax) + add(draw.parameters.normalScale, draw.parameters.interactWithLight ? 1 : 0) + add(draw.parameters.transmission, 0) + words.append( + UInt64(UInt32(bitPattern: draw.parameters.alphaMode)) + | (UInt64(UInt32(bitPattern: draw.parameters.hasEmissiveTexture)) << 32) + ) + // Not here: passthroughAlpha and lodDither, which an instanced draw does not take + // from the material's parameter block. + } + + @inline(__always) + private mutating func add(identityOf object: AnyObject?) { + words.append(object.map { UInt64(UInt(bitPattern: ObjectIdentifier($0))) } ?? 0) + } + + @inline(__always) + private mutating func add(_ first: Float, _ second: Float) { + words.append(UInt64(first.bitPattern) | (UInt64(second.bitPattern) << 32)) + } + + @inline(__always) + private mutating func add(_ value: simd_float4) { + add(value.x, value.y) + add(value.z, value.w) + } + + @inline(__always) + private mutating func add(_ value: simd_int4) { + words.append(UInt64(UInt32(bitPattern: value.x)) | (UInt64(UInt32(bitPattern: value.y)) << 32)) + words.append(UInt64(UInt32(bitPattern: value.z)) | (UInt64(UInt32(bitPattern: value.w)) << 32)) + } + + @inline(__always) + private mutating func add(_ matrix: simd_float4x4) { + add(matrix.columns.0) + add(matrix.columns.1) + add(matrix.columns.2) + add(matrix.columns.3) + } +} + +/// Gives every distinct thing a render component can draw a number. +/// +/// Two components get the same number exactly when their mesh lists have the same +/// `MeshListDescription`. Their entities can then share a draw: only what an instance has +/// of its own differs. The table compares the descriptions themselves, not hashes of +/// them, so two different materials never share a number. +/// +/// A description names its meshes and textures by identity and does not keep them alive. +/// That is sound because a component holds on to everything its key was made from for as +/// long as it has the key: whatever changes `RenderComponent.mesh` clears it. +/// +/// Descriptions are not removed one by one. A long session leaves old ones behind +/// (a material edited, a texture streamed in); once the table has outgrown +/// `maximumDescriptions`, `beginPass` empties it and starts a new generation, and the keys +/// of the old one are looked up again as their entities are drawn. +final class RenderInstanceKeyTable: @unchecked Sendable { + static let shared = RenderInstanceKeyTable() + + /// Past this many descriptions the next pass starts with an empty table. + static let maximumDescriptions = 1 << 16 + + private let lock = NSLock() + private var generation: UInt32 = 1 + private var descriptions: [[UInt64]] = [] + private var idsByHash: [Int: [Int32]] = [:] + private var scratch = MeshListDescription() + + /// The generation a key must have to be used in the pass that is starting. The table + /// is emptied here when it has outgrown its limit, and nowhere else: the ids a pass + /// has collected stay good until the pass ends. + func beginPass() -> UInt32 { + lock.lock() + defer { lock.unlock() } + if descriptions.count > Self.maximumDescriptions { + removeAllLocked() + } + return generation + } + + /// The id of what `component` draws, or `RenderInstanceKey.notInstanced`. Looks it up + /// and leaves it in the component's `instanceKey` when the key is not of this + /// generation. + func resolve(_ component: RenderComponent) -> Int32 { + lock.lock() + defer { lock.unlock() } + if component.instanceKey.generation == generation { + return component.instanceKey.id + } + let id = idLocked(for: component.mesh) + component.instanceKey = RenderInstanceKey(generation: generation, id: id) + return id + } + + /// The id of what `meshes` draws, without a component to cache it in. + func id(for meshes: [Mesh]) -> Int32 { + lock.lock() + defer { lock.unlock() } + return idLocked(for: meshes) + } + + /// The number of descriptions the table holds. + var count: Int { + lock.lock() + defer { lock.unlock() } + return descriptions.count + } + + /// The generation keys are being given in. + var currentGeneration: UInt32 { + lock.lock() + defer { lock.unlock() } + return generation + } + + /// Empties the table and starts a new generation: every key given so far is stale. + func removeAll() { + lock.lock() + defer { lock.unlock() } + removeAllLocked() + } + + private func removeAllLocked() { + descriptions.removeAll(keepingCapacity: true) + idsByHash.removeAll(keepingCapacity: true) + // Zero is the generation of an unresolved key. + generation = generation == .max ? 1 : generation + 1 + } + + private func idLocked(for meshes: [Mesh]) -> Int32 { + guard scratch.describe(meshes) else { return RenderInstanceKey.notInstanced } + + let hash = scratch.hash + if let candidates = idsByHash[hash] { + for candidate in candidates where descriptions[Int(candidate)] == scratch.words { + return candidate + } + } + let id = Int32(descriptions.count) + descriptions.append(scratch.words) + idsByHash[hash, default: []].append(id) + return id + } +} + +/// The entities one pass draws instanced, grouped by what they draw. +/// +/// The pass adds each entity with the id of its key and its world transform, lays the +/// groups out, writes a record per instance and encodes the draws of each group. A group +/// of one is a draw like any other. The list is kept between passes, so once its arrays +/// have grown a frame allocates nothing. +/// +/// A group needs a run of records per distinct local transform among its meshes: the +/// model matrix of an instance is its world transform times the mesh's local one. Meshes +/// that follow each other with the same local transform (nearly always all of them) +/// share a run. +struct InstancedDrawList { + struct Group { + let keyID: Int32 + /// The opacity of the group's entities on a ghost channel, 1 for any other. It is + /// the draw's (the material parameters carry it), so entities that differ in it + /// are in groups of their own. + let passthroughAlpha: Float + var instanceCount: Int32 = 0 + /// Index of the first record of the group's first run. + var firstRecord: Int32 = 0 + /// Where the group's runs start in `runLocalSpaces`. + var firstRun: Int32 = 0 + var runCount: Int32 = 0 + /// Where the run of each of the group's meshes starts in `meshRuns`. + var firstMesh: Int32 = 0 + /// Instances whose records are written. + var written: Int32 = 0 + } + + private(set) var groups: [Group] = [] + /// One component of each group: every other one draws the same meshes and materials. + private(set) var groupComponents: [RenderComponent] = [] + /// The local transform of each run of each group. + private(set) var runLocalSpaces: [simd_float4x4] = [] + /// For each mesh of each group, which of the group's runs it reads. + private(set) var meshRuns: [Int32] = [] + /// Records the pass has to write, known after `layOutRecords`. + private(set) var recordCount = 0 + + /// By key id, the index plus one of the group of the entities drawn opaque (opacity + /// 1, nearly every entity), or zero. + private var groupOfKey: [Int32] = [] + /// The same for the entities on a ghost channel, by key id and opacity. + private var groupOfGhostKey: [GhostGroupKey: Int32] = [:] + private var instanceGroups: [Int32] = [] + private var instanceSpaces: [simd_float4x4] = [] + /// Where each instance was the previous frame (its space, when it did not move). + private var instancePreviousSpaces: [simd_float4x4] = [] + + private struct GhostGroupKey: Hashable { + let keyID: Int32 + let passthroughAlpha: Float + } + + var isEmpty: Bool { + instanceGroups.isEmpty + } + + var instanceCount: Int { + instanceGroups.count + } + + /// Adds an entity. `keyID` is the id of `component`'s key and is not negative. + /// `previousSpace` is the world transform the entity was drawn with the previous frame, + /// for the velocity the opaque pass writes; nil says it is where it was (a shadow pass + /// passes none). `passthroughAlpha` is the entity's opacity on a ghost channel: an + /// entity joins the group of its key with that opacity. + @inline(__always) + mutating func add( + keyID: Int32, + component: RenderComponent, + space: simd_float4x4, + previousSpace: simd_float4x4? = nil, + passthroughAlpha: Float = 1 + ) { + var group: Int32 + if passthroughAlpha == 1 { + let key = Int(keyID) + if key >= groupOfKey.count { + groupOfKey.append(contentsOf: repeatElement(0, count: max(key + 1 - groupOfKey.count, 1024))) + } + group = groupOfKey[key] + if group == 0 { + groups.append(Group(keyID: keyID, passthroughAlpha: 1)) + groupComponents.append(component) + group = Int32(groups.count) + groupOfKey[key] = group + } + } else { + let ghostKey = GhostGroupKey(keyID: keyID, passthroughAlpha: passthroughAlpha) + if let known = groupOfGhostKey[ghostKey] { + group = known + } else { + groups.append(Group(keyID: keyID, passthroughAlpha: passthroughAlpha)) + groupComponents.append(component) + group = Int32(groups.count) + groupOfGhostKey[ghostKey] = group + } + } + groups[Int(group) - 1].instanceCount += 1 + instanceGroups.append(group - 1) + instanceSpaces.append(space) + instancePreviousSpaces.append(previousSpace ?? space) + } + + /// Empties the list for the next pass, keeping its storage. + mutating func removeAll() { + for group in groups where group.passthroughAlpha == 1 { + groupOfKey[Int(group.keyID)] = 0 + } + groupOfGhostKey.removeAll(keepingCapacity: true) + groups.removeAll(keepingCapacity: true) + groupComponents.removeAll(keepingCapacity: true) + runLocalSpaces.removeAll(keepingCapacity: true) + meshRuns.removeAll(keepingCapacity: true) + instanceGroups.removeAll(keepingCapacity: true) + instanceSpaces.removeAll(keepingCapacity: true) + instancePreviousSpaces.removeAll(keepingCapacity: true) + recordCount = 0 + } + + /// Decides where each group's records go. Call once every entity is added. + mutating func layOutRecords() { + runLocalSpaces.removeAll(keepingCapacity: true) + meshRuns.removeAll(keepingCapacity: true) + + var nextRecord: Int32 = 0 + for groupIndex in groups.indices { + let meshes = groupComponents[groupIndex].mesh + groups[groupIndex].firstRun = Int32(runLocalSpaces.count) + groups[groupIndex].firstMesh = Int32(meshRuns.count) + + var runCount: Int32 = 0 + for meshIndex in meshes.indices { + if runCount == 0 || runLocalSpaces[runLocalSpaces.count - 1] != meshes[meshIndex].localSpace { + runLocalSpaces.append(meshes[meshIndex].localSpace) + runCount += 1 + } + meshRuns.append(runCount - 1) + } + + groups[groupIndex].runCount = runCount + groups[groupIndex].firstRecord = nextRecord + groups[groupIndex].written = 0 + nextRecord += runCount * groups[groupIndex].instanceCount + } + recordCount = Int(nextRecord) + } + + /// The record the first instance of `group` reads when it draws its mesh `meshIndex`: + /// the base instance of that draw. + @inline(__always) + func baseInstance(of group: Group, meshIndex: Int) -> Int { + Int(group.firstRecord + meshRuns[Int(group.firstMesh) + meshIndex] * group.instanceCount) + } + + /// Writes the records of the opaque pass: `records` has room for `recordCount`. The + /// matrices are the ones the pass computes for an entity it draws on its own, the + /// previous frame's model matrix included. + mutating func writeModelRecords(to records: UnsafeMutablePointer, viewMatrix: simd_float4x4) { + for instance in instanceGroups.indices { + let groupIndex = Int(instanceGroups[instance]) + let slot = groups[groupIndex].written + groups[groupIndex].written = slot + 1 + + let space = instanceSpaces[instance] + let previousSpace = instancePreviousSpaces[instance] + let firstRun = Int(groups[groupIndex].firstRun) + var record = Int(groups[groupIndex].firstRecord + slot) + for run in 0 ..< Int(groups[groupIndex].runCount) { + let localSpace = runLocalSpaces[firstRun + run] + let modelMatrix = simd_mul(space, localSpace) + records[record] = ModelInstanceUniform( + modelViewMatrix: simd_mul(viewMatrix, modelMatrix), + modelMatrix: modelMatrix, + prevModelMatrix: simd_mul(previousSpace, localSpace), + normalMatrix: matrix3x3_upper_left(modelMatrix).inverse.transpose + ) + record += Int(groups[groupIndex].instanceCount) + } + } + } + + /// Writes the records of a shadow pass, a model matrix per instance: `records` has + /// room for `recordCount`. + mutating func writeShadowRecords(to records: UnsafeMutablePointer) { + for instance in instanceGroups.indices { + let groupIndex = Int(instanceGroups[instance]) + let slot = groups[groupIndex].written + groups[groupIndex].written = slot + 1 + + let space = instanceSpaces[instance] + let firstRun = Int(groups[groupIndex].firstRun) + var record = Int(groups[groupIndex].firstRecord + slot) + for run in 0 ..< Int(groups[groupIndex].runCount) { + records[record] = simd_mul(space, runLocalSpaces[firstRun + run]) + record += Int(groups[groupIndex].instanceCount) + } + } + } +} + +/// The buffers that hold the instance records of the frames being drawn. +/// +/// A pass borrows a buffer, fills it, and its command buffer's completion handler hands +/// it back: a buffer is never written while the GPU may still read it, however many +/// frames are in flight. The handler gets a token, not the buffer, so nothing that is +/// not `Sendable` crosses into it. +final class InstanceRecordBufferPool: @unchecked Sendable { + static let shared = InstanceRecordBufferPool() + + /// The smallest buffer the pool makes. + static let minimumLength = 64 * 1024 + /// Free buffers kept for the next frames; a scene that shrinks gives the rest back. + static let maximumFreeBuffers = 8 + + private let lock = NSLock() + private var free: [MTLBuffer] = [] + private var lent: [Int: MTLBuffer] = [:] + private var nextToken = 0 + + /// A buffer of at least `length` bytes and the token that returns it, or nil when + /// the device cannot make one. + func borrow(length: Int, device: MTLDevice) -> (buffer: MTLBuffer, token: Int)? { + lock.lock() + defer { lock.unlock() } + + // The smallest free buffer that is large enough. + var best: Int? + for index in free.indices where free[index].length >= length && free[index].device === device { + if let current = best, free[current].length <= free[index].length { continue } + best = index + } + + let buffer: MTLBuffer + if let best { + buffer = free.remove(at: best) + } else { + var capacity = Self.minimumLength + while capacity < length { + capacity *= 2 + } + guard let made = device.makeBuffer(length: capacity, options: .storageModeShared) else { return nil } + made.label = "Instance Records" + buffer = made + } + + let token = nextToken + nextToken &+= 1 + lent[token] = buffer + return (buffer, token) + } + + /// Hands back the buffer borrowed with `token`. + func giveBack(_ token: Int) { + lock.lock() + defer { lock.unlock() } + guard let buffer = lent.removeValue(forKey: token) else { return } + free.append(buffer) + if free.count > Self.maximumFreeBuffers, + let smallest = free.indices.min(by: { free[$0].length < free[$1].length }) + { + free.remove(at: smallest) + } + } + + /// Buffers free and lent, for tests. + var bufferCounts: (free: Int, lent: Int) { + lock.lock() + defer { lock.unlock() } + return (free.count, lent.count) + } +} diff --git a/Sources/UntoldEngine/Renderer/RenderPasses+InstancedDraws.swift b/Sources/UntoldEngine/Renderer/RenderPasses+InstancedDraws.swift new file mode 100644 index 000000000..62deb509a --- /dev/null +++ b/Sources/UntoldEngine/Renderer/RenderPasses+InstancedDraws.swift @@ -0,0 +1,299 @@ +// +// RenderPasses+InstancedDraws.swift +// UntoldEngine +// +// How the opaque pass and the shadow cascades encode the entities they draw instanced. +// +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import CShaderTypes +import Foundation +import Metal +import MetalKit +import simd + +extension RenderPasses { + /// What a pass drew instanced: its groups (one per distinct mesh list), the entities + /// in them, and the draws they took (one per submesh of a group). + struct InstancedDrawCounts: Equatable { + var groups = 0 + var instances = 0 + var draws = 0 + } + + /// The pipeline a pass draws instanced with, next to the `pipeline` it draws single + /// entities with. Nil when the pass has to draw every entity on its own: instanced + /// drawing is off, or the renderer was set up without the instanced pipeline. + static func instancedPipeline(_ type: RenderPipelineType, beside pipeline: RenderPipeline) -> RenderPipeline? { + guard InstancedDrawing.isEnabled, + let instanced = PipelineManager.shared.renderPipelinesByType[type], + instanced.success, + instanced.pipelineState != nil, + instanced.rasterSampleCount == pipeline.rasterSampleCount + else { return nil } + return instanced + } + + /// The id of what `component` draws in the table generation of this pass, or + /// `RenderInstanceKey.notInstanced`. The component remembers it until its meshes change. + @inline(__always) + static func instanceKeyID(of component: RenderComponent, generation: UInt32) -> Int32 { + let key = component.instanceKey + if key.generation == generation { + return key.id + } + return RenderInstanceKeyTable.shared.resolve(component) + } + + /// Borrows a buffer for `byteCount` bytes of instance records and has the command + /// buffer give it back when the GPU is done with the frame. + private static func borrowInstanceRecords(byteCount: Int, for commandBuffer: MTLCommandBuffer) -> MTLBuffer? { + guard let device = renderInfo.device, + let borrowed = InstanceRecordBufferPool.shared.borrow(length: byteCount, device: device) + else { + handleError(.bufferAllocationFailed, "Instance Records") + return nil + } + let token = borrowed.token + commandBuffer.addCompletedHandler { _ in + InstanceRecordBufferPool.shared.giveBack(token) + } + return borrowed.buffer + } + + // MARK: - Opaque pass + + /// Encodes the entities the opaque pass gathered in `draws`: one draw per submesh of + /// each group, through `pipeline`, or through `plainPipeline` (the one compiled without + /// the discards) for a submesh whose material never needs them, as the pass does for an + /// entity it draws on its own. The encoder is left on one of the two. + @discardableResult + static func encodeInstancedOpaqueDraws( + _ draws: inout InstancedDrawList, + pipeline: RenderPipeline, + plainPipeline: RenderPipeline? = nil, + renderEncoder: MTLRenderCommandEncoder, + commandBuffer: MTLCommandBuffer, + viewMatrix: simd_float4x4, + projectionMatrix: simd_float4x4, + cameraPosition: simd_float3, + unjitteredViewProjection: simd_float4x4, + prevViewProjection: simd_float4x4, + pomQuality: POMQualityUniform + ) -> InstancedDrawCounts { + guard !draws.isEmpty, let pipelineState = pipeline.pipelineState else { return InstancedDrawCounts() } + + draws.layOutRecords() + let stride = MemoryLayout.stride + guard let records = borrowInstanceRecords(byteCount: draws.recordCount * stride, for: commandBuffer) else { + return InstancedDrawCounts() + } + draws.writeModelRecords( + to: records.contents().bindMemory(to: ModelInstanceUniform.self, capacity: draws.recordCount), + viewMatrix: viewMatrix + ) + + renderEncoder.setRenderPipelineState(pipelineState) + renderEncoder.setDepthStencilState(pipeline.depthState) + // Which of the two the encoder holds. + var plainBound = false + + // What the pass shares. The instanced stages take a surface's transform from its + // record, so the model matrices here are never read; the two view-projections serve + // the velocity each instance writes from where its record says it was. + var passUniforms = Uniforms() + passUniforms.projectionMatrix = projectionMatrix + passUniforms.viewMatrix = viewMatrix + passUniforms.modelViewMatrix = viewMatrix + passUniforms.normalMatrix = matrix_identity_float3x3 + passUniforms.modelMatrix = matrix_identity_float4x4 + passUniforms.prevModelMatrix = matrix_identity_float4x4 + passUniforms.cameraPosition = cameraPosition + passUniforms.unjitteredViewProjectionMatrix = unjitteredViewProjection + passUniforms.prevViewProjectionMatrix = prevViewProjection + renderEncoder.setVertexBytes(&passUniforms, length: MemoryLayout.stride, index: Int(modelPassUniformIndex.rawValue)) + renderEncoder.setFragmentBytes(&passUniforms, length: MemoryLayout.stride, index: Int(modelPassFragmentUniformIndex.rawValue)) + renderEncoder.setVertexBuffer(records, offset: 0, index: Int(modelPassInstanceIndex.rawValue)) + var pomQuality = pomQuality + renderEncoder.setFragmentBytes(&pomQuality, length: MemoryLayout.stride, index: Int(modelPassFragmentPOMQualityIndex.rawValue)) + + var drawCount = 0 + for groupIndex in draws.groups.indices { + let group = draws.groups[groupIndex] + let component = draws.groupComponents[groupIndex] + let meshes = component.mesh + + // What the group's draws bind is prepared once per component, not per frame. + let submeshDraws: [InstancedSubmeshDraw] + if let prepared = component.instancedSubmeshDraws { + submeshDraws = prepared + } else { + submeshDraws = InstancedSubmeshDraw.draws(of: meshes) + component.instancedSubmeshDraws = submeshDraws + } + + var boundMesh: Int32 = -1 + var baseInstance = 0 + for drawIndex in submeshDraws.indices { + let meshIndex = submeshDraws[drawIndex].meshIndex + if meshIndex != boundMesh { + bindInstancedModelStreams(of: meshes, meshIndex: Int(meshIndex), renderEncoder: renderEncoder) + baseInstance = draws.baseInstance(of: group, meshIndex: Int(meshIndex)) + boundMesh = meshIndex + } + bindMaterial(of: submeshDraws, drawIndex, passthroughAlpha: group.passthroughAlpha, renderEncoder: renderEncoder) + + // The group's material decides, as it does for a single draw; an instance + // has no dither. + let plain = plainPipeline != nil && modelDrawNeverDiscards(submeshDraws[drawIndex].parameters) + if plain != plainBound { + renderEncoder.setRenderPipelineState((plain ? plainPipeline! : pipeline).pipelineState!) + plainBound = plain + } + if plain { renderInfo.opaqueDrawsPlain += 1 } else { renderInfo.opaqueDrawsDiscarding += 1 } + + renderEncoder.drawIndexedPrimitivesTracked( + type: submeshDraws[drawIndex].primitiveType, + indexCount: submeshDraws[drawIndex].indexCount, + indexType: submeshDraws[drawIndex].indexType, + indexBuffer: submeshDraws[drawIndex].indexBuffer, + indexBufferOffset: submeshDraws[drawIndex].indexBufferOffset, + instanceCount: Int(group.instanceCount), + baseInstance: baseInstance, + category: .opaque + ) + } + drawCount += submeshDraws.count + } + + return InstancedDrawCounts(groups: draws.groups.count, instances: draws.instanceCount, draws: drawCount) + } + + /// The four vertex streams an instanced model draw reads. A mesh drawn instanced has + /// no skeleton and no deformed copy of its vertices. + @inline(__always) + private static func bindInstancedModelStreams(of meshes: [Mesh], meshIndex: Int, renderEncoder: MTLRenderCommandEncoder) { + let positions = Int(modelPassVerticesIndex.rawValue) + let normals = Int(modelPassNormalIndex.rawValue) + let textureCoordinates = Int(modelPassUVIndex.rawValue) + let tangents = Int(modelPassTangentIndex.rawValue) + renderEncoder.setVertexBuffer(meshes[meshIndex].vertexBuffers[positions], offset: 0, index: positions) + renderEncoder.setVertexBuffer(meshes[meshIndex].vertexBuffers[normals], offset: 0, index: normals) + renderEncoder.setVertexBuffer(meshes[meshIndex].vertexBuffers[textureCoordinates], offset: 0, index: textureCoordinates) + renderEncoder.setVertexBuffer(meshes[meshIndex].vertexBuffers[tangents], offset: 0, index: tangents) + } + + /// Hands the encoder the material of `draws[index]`: what the pass binds for an + /// entity it draws on its own, with the group's ghost opacity in place of the + /// entity's (an instance has no dither). The values are read where they are, without + /// a copy of the draw. + @inline(__always) + private static func bindMaterial( + of draws: [InstancedSubmeshDraw], + _ index: Int, + passthroughAlpha: Float, + renderEncoder: MTLRenderCommandEncoder + ) { + var stScale = draws[index].stScale + renderEncoder.setFragmentBytes(&stScale, length: MemoryLayout.stride, index: Int(modelPassFragmentSTScaleIndex.rawValue)) + + renderEncoder.setFragmentTexture(draws[index].baseColorTexture, index: Int(modelPassBaseTextureIndex.rawValue)) + renderEncoder.setFragmentSamplerState(draws[index].baseColorSampler, index: Int(modelPassBaseSamplerIndex.rawValue)) + renderEncoder.setFragmentTexture(draws[index].roughnessTexture, index: Int(modelPassRoughnessTextureIndex.rawValue)) + renderEncoder.setFragmentSamplerState(draws[index].materialSampler, index: Int(modelPassMaterialSamplerIndex.rawValue)) + renderEncoder.setFragmentTexture(draws[index].metallicTexture, index: Int(modelPassMetallicTextureIndex.rawValue)) + + var hasNormal = draws[index].hasNormal + renderEncoder.setFragmentBytes(&hasNormal, length: MemoryLayout.stride, index: Int(modelPassFragmentHasNormalTextureIndex.rawValue)) + var normalIsPackedXY = draws[index].normalIsPackedXY + renderEncoder.setFragmentBytes(&normalIsPackedXY, length: MemoryLayout.stride, index: Int(modelPassFragmentNormalIsPackedXYIndex.rawValue)) + + var materialParameters = draws[index].parameters + materialParameters.passthroughAlpha = passthroughAlpha + renderEncoder.setFragmentBytes(&materialParameters, length: MemoryLayout.stride, index: Int(modelPassFragmentMaterialParameterIndex.rawValue)) + + renderEncoder.setFragmentTexture(draws[index].normalTexture, index: Int(modelPassNormalTextureIndex.rawValue)) + renderEncoder.setFragmentSamplerState(draws[index].normalSampler, index: Int(modelPassNormalSamplerIndex.rawValue)) + renderEncoder.setFragmentTexture(draws[index].heightTexture, index: Int(modelPassHeightTextureIndex.rawValue)) + renderEncoder.setFragmentSamplerState(draws[index].heightSampler, index: Int(modelPassHeightSamplerIndex.rawValue)) + renderEncoder.setFragmentTexture(draws[index].emissiveTexture, index: Int(modelPassEmissiveTextureIndex.rawValue)) + } + + // MARK: - Shadow cascades + + /// Encodes the casters a shadow cascade gathered in `draws`: one draw per submesh of + /// each group. The light's matrix, the depth bias and the viewport are the encoder's + /// already; it is left on the instanced pipeline. + @discardableResult + static func encodeInstancedShadowDraws( + _ draws: inout InstancedDrawList, + pipeline: RenderPipeline, + renderEncoder: MTLRenderCommandEncoder, + commandBuffer: MTLCommandBuffer + ) -> InstancedDrawCounts { + guard !draws.isEmpty, let pipelineState = pipeline.pipelineState else { return InstancedDrawCounts() } + + draws.layOutRecords() + let stride = MemoryLayout.stride + guard let records = borrowInstanceRecords(byteCount: draws.recordCount * stride, for: commandBuffer) else { + return InstancedDrawCounts() + } + draws.writeShadowRecords(to: records.contents().bindMemory(to: simd_float4x4.self, capacity: draws.recordCount)) + + renderEncoder.setRenderPipelineState(pipelineState) + if let depthState = pipeline.depthState { + renderEncoder.setDepthStencilState(depthState) + } + renderEncoder.setVertexBuffer(records, offset: 0, index: Int(shadowPassInstanceIndex.rawValue)) + + var drawCount = 0 + for groupIndex in draws.groups.indices { + let group = draws.groups[groupIndex] + let component = draws.groupComponents[groupIndex] + let meshes = component.mesh + + // The submeshes that cast are listed once per component, not per frame. + let shadowDraws: [InstancedShadowDraw] + if let prepared = component.instancedShadowDraws { + shadowDraws = prepared + } else { + shadowDraws = InstancedShadowDraw.draws(of: meshes) + component.instancedShadowDraws = shadowDraws + } + + var boundMesh: Int32 = -1 + var baseInstance = 0 + for drawIndex in shadowDraws.indices { + let meshIndex = shadowDraws[drawIndex].meshIndex + if meshIndex != boundMesh { + renderEncoder.setVertexBuffer( + meshes[Int(meshIndex)].vertexBuffers[Int(modelPassVerticesIndex.rawValue)], + offset: 0, + index: Int(shadowPassModelPositionIndex.rawValue) + ) + baseInstance = draws.baseInstance(of: group, meshIndex: Int(meshIndex)) + boundMesh = meshIndex + } + + renderEncoder.drawIndexedPrimitivesTracked( + type: shadowDraws[drawIndex].primitiveType, + indexCount: shadowDraws[drawIndex].indexCount, + indexType: shadowDraws[drawIndex].indexType, + indexBuffer: shadowDraws[drawIndex].indexBuffer, + indexBufferOffset: shadowDraws[drawIndex].indexBufferOffset, + instanceCount: Int(group.instanceCount), + baseInstance: baseInstance, + category: .shadow + ) + } + drawCount += shadowDraws.count + } + + return InstancedDrawCounts(groups: draws.groups.count, instances: draws.instanceCount, draws: drawCount) + } +} diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index 3401a3d78..196f3fa2c 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -25,6 +25,11 @@ public enum RenderPasses { /// Set to 0 to disable distance culling. Default: 40 world units. public nonisolated(unsafe) static var maxShadowCastingDistance: Float = 40.0 + /// What the most recent opaque pass drew instanced, and what the cascades of the most + /// recent shadow pass did together. Written by the passes (render thread). + nonisolated(unsafe) static var lastOpaqueInstancedDrawCounts = InstancedDrawCounts() + nonisolated(unsafe) static var lastShadowInstancedDrawCounts = InstancedDrawCounts() + private static let runtimeState = RuntimeState() private static let lodDebugPalette: [simd_float3] = [ simd_float3(1.0, 0.0, 0.0), // LOD0 = red @@ -85,6 +90,30 @@ public enum RenderPasses { /// not on every cascade call. var shadowEntityCandidates: [EntityID] = [] var shadowCacheDirty: Bool = true + /// The instanced draws of the opaque pass and of the shadow cascades, kept + /// between frames for their storage. A pass takes its list out and puts it back + /// empty: see `takeInstancedDrawList`. + var opaqueInstancedDraws = InstancedDrawList() + var shadowInstancedDraws = InstancedDrawList() + } + + /// Takes the instanced draw list a pass fills, leaving an empty one in its place so + /// that the pass is the only owner of the storage while it works on it. + private static func takeInstancedDrawList(_ list: ReferenceWritableKeyPath) -> InstancedDrawList { + runtimeState.lock.lock() + defer { runtimeState.lock.unlock() } + let taken = runtimeState[keyPath: list] + runtimeState[keyPath: list] = InstancedDrawList() + return taken + } + + /// Puts a pass's instanced draw list back, emptied: its storage serves the next + /// frame, and it lets go of the components it grouped. + private static func putBackInstancedDrawList(_ draws: inout InstancedDrawList, _ list: ReferenceWritableKeyPath) { + draws.removeAll() + runtimeState.lock.lock() + defer { runtimeState.lock.unlock() } + runtimeState[keyPath: list] = draws } @inline(__always) @@ -353,7 +382,7 @@ public enum RenderPasses { /// material, neither masked nor blended, with no dither fade running — and so goes through /// the model pipeline compiled without discards. @inline(__always) - private static func modelDrawNeverDiscards(_ parameters: MaterialParametersUniform) -> Bool { + static func modelDrawNeverDiscards(_ parameters: MaterialParametersUniform) -> Bool { parameters.alphaMode == 0 && parameters.lodDither.y <= 0.5 } @@ -977,16 +1006,49 @@ public enum RenderPasses { return computed } + /// The parameter block of `material` for a draw of the opaque pass: what the material + /// says, before anything the entity adds to it (the opacity of a ghost channel, a + /// dither, a debug colour). An instanced draw sends exactly this, so a value added to + /// `MaterialParametersUniform` and filled here reaches both ways of drawing; add it to + /// `MeshListDescription` too, which decides whether two materials are the same. + /// (`RenderInstanceKeyTests` fails until it is.) + @inline(__always) + static func opaqueMaterialParameters(of material: borrowing Material) -> MaterialParametersUniform { + var materialParameters = MaterialParametersUniform() + materialParameters.specular = material.specular + materialParameters.specularTint = material.specularTint + materialParameters.subsurface = material.subsurface + materialParameters.anisotropic = material.anisotropic + materialParameters.sheen = material.sheen + materialParameters.sheenTint = material.sheenTint + materialParameters.clearCoat = material.clearCoat + materialParameters.clearCoatGloss = material.clearCoatGloss + materialParameters.baseColor = material.baseColorValue + materialParameters.roughness = material.roughnessValue + materialParameters.metallic = material.metallicValue + materialParameters.ior = material.ior + materialParameters.edgeTint = material.edgeTint + materialParameters.alphaCutoff = material.alphaCutoff + materialParameters.alphaMode = Int32(material.alphaMode.rawValue) + materialParameters.interactWithLight = material.interactWithLight + materialParameters.emmissive = material.emissiveValue + applyMaterialTextureState(material: material, materialParameters: &materialParameters) + return materialParameters + } + @inline(__always) private static func applyMaterialTextureState( - material: Material, + material: borrowing Material, materialParameters: inout MaterialParametersUniform ) { + // Read apart: `&&` would capture the borrowed material in its closure. + let hasHeightMap = material.hasHeightMap + let heightEnabled = material.heightEnabled materialParameters.hasTexture = simd_int4( Int32(material.hasBaseMap ? 1 : 0), Int32(material.hasRoughMap ? 1 : 0), Int32(material.hasMetalMap ? 1 : 0), - Int32((material.hasHeightMap && material.heightEnabled) ? 1 : 0) + Int32((hasHeightMap && heightEnabled) ? 1 : 0) ) materialParameters.textureChannels = simd_int4( Int32(material.roughnessChannel.rawValue), @@ -1414,6 +1476,16 @@ public enum RenderPasses { let cameraPosition = shadingCameraPosition(cameraComponent) let projectionMatrix = renderInfo.perspectiveSpace + // The casters whose vertices are not their own (no skeleton, no deformation) are + // gathered per cascade and encoded together, one draw for all those that share + // their meshes. + let instancedPipeline = instancedPipeline(.shadowInstanced, beside: shadowPipeline) + let instanceKeyGeneration = instancedPipeline == nil ? 0 : RenderInstanceKeyTable.shared.beginPass() + var instancedDraws = takeInstancedDrawList(\.shadowInstancedDraws) + defer { putBackInstancedDrawList(&instancedDraws, \.shadowInstancedDraws) } + var instancedCounts = InstancedDrawCounts() + defer { lastShadowInstancedDrawCounts = instancedCounts } + // Render each cascade into its own depth array slice. for cascadeIdx in 0 ..< shadowSystem.cascadeCount { guard cascadeIdx < renderInfo.csmRenderPassDescriptors.count else { break } @@ -1453,6 +1525,14 @@ public enum RenderPasses { let deformation = sceneSnapshot.deformation(of: caster.entity) let hasSkeleton = caster.entity.traits.contains(.skeleton) + if instancedPipeline != nil, deformation == nil, !hasSkeleton { + let keyID = instanceKeyID(of: caster.components.render, generation: instanceKeyGeneration) + if keyID >= 0 { + instancedDraws.add(keyID: keyID, component: caster.components.render, space: transformComponent.space) + continue + } + } + for mesh in caster.components.render.mesh { var modelUniforms = Uniforms() var modelMatrix = simd_mul(transformComponent.space, mesh.localSpace) @@ -1487,6 +1567,19 @@ public enum RenderPasses { } } + if let instancedPipeline { + let counts = encodeInstancedShadowDraws( + &instancedDraws, + pipeline: instancedPipeline, + renderEncoder: renderEncoder, + commandBuffer: commandBuffer + ) + instancedCounts.groups += counts.groups + instancedCounts.instances += counts.instances + instancedCounts.draws += counts.draws + instancedDraws.removeAll() + } + renderEncoder.updateFence(renderInfo.fence, after: .fragment) renderEncoder.popDebugGroup() renderEncoder.endEncoding() @@ -2532,6 +2625,21 @@ public enum RenderPasses { renderEncoder.setFragmentTexture(textureResources.spotShadowMap, index: Int(modelPassSpotShadowTextureIndex.rawValue)) } + // The entities that draw nothing of their own are gathered and encoded together + // in sub-pass 1c, one draw for all those that share their meshes and materials. + // A debug colouring gives every entity its own colour, so it draws them one by one. + let instancedPipeline = (pass.colorsByLOD || pass.colorsByStreamingTier) + ? nil + : instancedPipeline(.modelInstanced, beside: modelPipeline) + // Its counterpart without the discards, for the groups whose material never needs + // them, when the single draws have theirs. + let plainInstancedPipeline: RenderPipeline? = (instancedPipeline == nil || plainModelPipeline == nil) + ? nil + : Self.instancedPipeline(.modelInstancedOpaque, beside: modelPipeline) + let instanceKeyGeneration = instancedPipeline == nil ? 0 : RenderInstanceKeyTable.shared.beginPass() + var instancedDraws = takeInstancedDrawList(\.opaqueInstancedDraws) + defer { putBackInstancedDrawList(&instancedDraws, \.opaqueInstancedDraws) } + // ── Sub-pass 1: Unbatched geometry ────────────────────────────────────── renderEncoder.setRenderPipelineState(modelPipeline.pipelineState!) renderEncoder.setDepthStencilState(modelPipeline.depthState) @@ -2565,6 +2673,30 @@ public enum RenderPasses { // The entity's meshes, or its outgoing and incoming levels while a LOD fade runs. let fadeDraws = lodFadeDraws(lod: fadingLOD, renderComponent: renderComponent) + + // Nothing of this draw is the entity's own but its transform: it joins the + // entities that draw the same meshes (sub-pass 1c). + if instancedPipeline != nil, + fadeDraws == nil, + deformation == nil, + !hasSkeleton, + tileRepresentationFade == nil, + meshFade == nil + { + let keyID = instanceKeyID(of: renderComponent, generation: instanceKeyGeneration) + if keyID >= 0 { + instancedDraws.add( + keyID: keyID, + component: renderComponent, + space: worldTransformComponent.space, + // Where the entity was the previous frame, for the velocity. + previousSpace: ObjectMotion.shared.previousWorldTransform(of: entityId, current: worldTransformComponent.space), + passthroughAlpha: passthroughAlpha + ) + continue + } + } + for drawIndex in 0 ..< (fadeDraws?.count ?? 1) { let lodDraw = fadeDraws?[drawIndex] ?? LODDitherDraw(meshes: renderComponent.mesh, threshold: 1.0, mode: 0.0) for mesh in lodDraw.meshes { @@ -2611,26 +2743,8 @@ public enum RenderPasses { var normalIsPackedXY = material.normalIsPackedXY renderEncoder.setFragmentBytes(&normalIsPackedXY, length: MemoryLayout.stride, index: Int(modelPassFragmentNormalIsPackedXYIndex.rawValue)) - var materialParameters = MaterialParametersUniform() - materialParameters.specular = material.specular - materialParameters.specularTint = material.specularTint - materialParameters.subsurface = material.subsurface - materialParameters.anisotropic = material.anisotropic - materialParameters.sheen = material.sheen - materialParameters.sheenTint = material.sheenTint - materialParameters.clearCoat = material.clearCoat - materialParameters.clearCoatGloss = material.clearCoatGloss - materialParameters.baseColor = material.baseColorValue - materialParameters.roughness = material.roughnessValue - materialParameters.metallic = material.metallicValue - materialParameters.ior = material.ior - materialParameters.edgeTint = material.edgeTint - materialParameters.alphaCutoff = material.alphaCutoff + var materialParameters = opaqueMaterialParameters(of: material) materialParameters.passthroughAlpha = passthroughAlpha - materialParameters.alphaMode = Int32(material.alphaMode.rawValue) - materialParameters.interactWithLight = material.interactWithLight - materialParameters.emmissive = material.emissiveValue - applyMaterialTextureState(material: material, materialParameters: &materialParameters) if pass.colorsByLOD { applyLODDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) } @@ -2777,6 +2891,25 @@ public enum RenderPasses { } } + // ── Sub-pass 1c: Instanced geometry ───────────────────────────────────── + if let instancedPipeline { + lastOpaqueInstancedDrawCounts = encodeInstancedOpaqueDraws( + &instancedDraws, + pipeline: instancedPipeline, + plainPipeline: plainInstancedPipeline, + renderEncoder: renderEncoder, + commandBuffer: commandBuffer, + viewMatrix: viewMatrix, + projectionMatrix: pass.projectionMatrix, + cameraPosition: pass.cameraPosition, + unjitteredViewProjection: pass.unjitteredViewProjection, + prevViewProjection: pass.prevViewProjection, + pomQuality: pass.pomQuality + ) + } else { + lastOpaqueInstancedDrawCounts = InstancedDrawCounts() + } + // ── Sub-pass 2: Lighting quad ──────────────────────────────────────────── // When the geometry lit itself whole per fragment, the lit attachment holds the frame // already and the quad stands down: the pass ends here, on device and in the simulator. diff --git a/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift b/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift index 1401ddee9..431614f25 100644 --- a/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift +++ b/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift @@ -161,6 +161,13 @@ public enum RenderingProperty: Sendable { /// composing the two. Forward by default on visionOS, deferred on macOS and iOS /// (`RenderInfo.defaultRenderPath`). `getRenderPath()` reads the path the two make. case renderPath(RenderPath) + + /// Whether entities that draw the same meshes with the same materials are encoded as + /// one instanced draw in the opaque pass and the shadow cascades. A scene that places + /// a model many times then costs a draw per model, not per placement. Enabled by + /// default; disabled, every entity is encoded on its own. What is drawn is the same + /// either way. See `getInstancedDrawsEnabled()` to read the current value. + case instancedDraws(RenderingToggle) } /// Where the opaque geometry is shaded. @@ -305,6 +312,10 @@ public func setRendering(_ property: RenderingProperty) { renderInfo.perFragmentIndirectLighting = path != .deferred renderInfo.perFragmentLighting = path == .forward renderInfo.renderPathChosen = true + case .instancedDraws(.enabled): + InstancedDrawing.isEnabled = true + case .instancedDraws(.disabled): + InstancedDrawing.isEnabled = false } } diff --git a/Tests/UntoldEngineRenderTests/InstancedDrawListTests.swift b/Tests/UntoldEngineRenderTests/InstancedDrawListTests.swift new file mode 100644 index 000000000..164434f6c --- /dev/null +++ b/Tests/UntoldEngineRenderTests/InstancedDrawListTests.swift @@ -0,0 +1,280 @@ +// +// InstancedDrawListTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import CShaderTypes +import Metal +import simd +@testable import UntoldEngine +import XCTest + +/// A pass gathers the entities it draws instanced in an `InstancedDrawList`, which decides +/// where each instance's record goes, and borrows the buffer for the records from +/// `InstanceRecordBufferPool`. These tests pin the layout of the records, their values, +/// and the comings and goings of the buffers. +@MainActor +final class InstancedDrawListTests: BaseRenderSetup { + override func tearDown() async throws { + destroyAllEntities() + try await super.tearDown() + } + + override func initializeAssets() {} + + // MARK: - Helpers + + private func component(_ meshes: [Mesh]) -> RenderComponent { + let component = RenderComponent() + component.mesh = meshes + return component + } + + private func translation(_ x: Float, _ y: Float, _ z: Float) -> simd_float4x4 { + matrix4x4Translation(x, y, z) + } + + /// A transform with a turn and an uneven scale: its normal matrix is not its own + /// upper left. + private func space(_ seed: Float) -> simd_float4x4 { + let turn = simd_float4x4(simd_quatf(angle: 0.3 + seed, axis: simd_normalize(simd_float3(0.2, 1, 0.4)))) + var scale = matrix_identity_float4x4 + scale.columns.0.x = 1 + seed + scale.columns.1.y = 0.5 + scale.columns.2.z = 2 + return translation(seed * 3, 1, -seed) * turn * scale + } + + private func modelRecords(of list: inout InstancedDrawList, viewMatrix: simd_float4x4) -> [ModelInstanceUniform] { + var records = [ModelInstanceUniform](repeating: ModelInstanceUniform(), count: list.recordCount) + records.withUnsafeMutableBufferPointer { list.writeModelRecords(to: $0.baseAddress!, viewMatrix: viewMatrix) } + return records + } + + private func shadowRecords(of list: inout InstancedDrawList) -> [simd_float4x4] { + var records = [simd_float4x4](repeating: simd_float4x4(), count: list.recordCount) + records.withUnsafeMutableBufferPointer { list.writeShadowRecords(to: $0.baseAddress!) } + return records + } + + // MARK: - Groups + + func testEntitiesWithTheSameKeyAreOneGroupInTheOrderTheyCame() { + let cube = component(BasicPrimitives.createCube(extent: 1)) + let sphere = component(BasicPrimitives.createSphere(extent: 1)) + var list = InstancedDrawList() + XCTAssertTrue(list.isEmpty) + + // Keys 7, 2, 7, 7, 2: the group of 7 comes first, as its first entity did. + list.add(keyID: 7, component: cube, space: translation(0, 0, 0)) + list.add(keyID: 2, component: sphere, space: translation(1, 0, 0)) + list.add(keyID: 7, component: cube, space: translation(2, 0, 0)) + list.add(keyID: 7, component: cube, space: translation(3, 0, 0)) + list.add(keyID: 2, component: sphere, space: translation(4, 0, 0)) + list.layOutRecords() + + XCTAssertEqual(list.instanceCount, 5) + XCTAssertEqual(list.groups.map(\.keyID), [7, 2]) + XCTAssertEqual(list.groups.map(\.instanceCount), [3, 2]) + XCTAssertTrue(list.groupComponents[0] === cube) + XCTAssertTrue(list.groupComponents[1] === sphere) + XCTAssertEqual(list.recordCount, 5) + XCTAssertEqual(list.baseInstance(of: list.groups[0], meshIndex: 0), 0) + XCTAssertEqual(list.baseInstance(of: list.groups[1], meshIndex: 0), 3) + + // The records of a group follow each other, in the order its entities came. + let records = shadowRecords(of: &list) + XCTAssertEqual(records.map(\.columns.3.x), [0, 2, 3, 1, 4]) + } + + func testAnEmptiedListStartsOver() { + let cube = component(BasicPrimitives.createCube(extent: 1)) + var list = InstancedDrawList() + list.add(keyID: 3000, component: cube, space: translation(0, 0, 0)) + list.add(keyID: 5, component: cube, space: translation(1, 0, 0)) + list.layOutRecords() + + list.removeAll() + + XCTAssertTrue(list.isEmpty) + XCTAssertTrue(list.groups.isEmpty) + XCTAssertTrue(list.groupComponents.isEmpty, "the list lets go of the components it grouped") + XCTAssertEqual(list.recordCount, 0) + + // The keys of the pass before mean nothing now. + list.add(keyID: 5, component: cube, space: translation(2, 0, 0)) + list.layOutRecords() + XCTAssertEqual(list.groups.map(\.keyID), [5]) + XCTAssertEqual(list.groups[0].instanceCount, 1) + XCTAssertEqual(list.recordCount, 1) + } + + // MARK: - Records + + func testAModelRecordHoldsWhatThePassComputesForAnEntityDrawnAlone() { + let cube = component(BasicPrimitives.createCube(extent: 1)) + let view = translation(0, -2, -10) + var list = InstancedDrawList() + let spaces = [space(0.1), space(0.7), space(1.3)] + for space in spaces { + list.add(keyID: 0, component: cube, space: space) + } + list.layOutRecords() + + let records = modelRecords(of: &list, viewMatrix: view) + + XCTAssertEqual(records.count, 3) + for (index, space) in spaces.enumerated() { + let modelMatrix = simd_mul(space, cube.mesh[0].localSpace) + XCTAssertEqual(records[index].modelMatrix, modelMatrix) + XCTAssertEqual(records[index].modelViewMatrix, simd_mul(view, modelMatrix)) + XCTAssertEqual(records[index].normalMatrix, matrix3x3_upper_left(modelMatrix).inverse.transpose) + // An entity that is where it was: its previous model matrix is this frame's. + XCTAssertEqual(records[index].prevModelMatrix, modelMatrix) + } + } + + func testEntitiesOfAGhostOpacityAreAGroupOfTheirOwn() { + // The opacity is the draw's, so a key has a group per opacity among its entities. + let cube = component(BasicPrimitives.createCube(extent: 1)) + var list = InstancedDrawList() + list.add(keyID: 4, component: cube, space: translation(0, 0, 0)) + list.add(keyID: 4, component: cube, space: translation(1, 0, 0), passthroughAlpha: 0.35) + list.add(keyID: 4, component: cube, space: translation(2, 0, 0)) + list.add(keyID: 4, component: cube, space: translation(3, 0, 0), passthroughAlpha: 0.35) + list.add(keyID: 4, component: cube, space: translation(4, 0, 0), passthroughAlpha: 0.6) + + XCTAssertEqual(list.groups.map(\.keyID), [4, 4, 4]) + XCTAssertEqual(list.groups.map(\.passthroughAlpha), [1, 0.35, 0.6]) + XCTAssertEqual(list.groups.map(\.instanceCount), [2, 2, 1]) + XCTAssertEqual(list.instanceCount, 5) + + // Emptied, the ghost groups are forgotten with the others. + list.removeAll() + list.add(keyID: 4, component: cube, space: translation(0, 0, 0), passthroughAlpha: 0.35) + XCTAssertEqual(list.groups.map(\.passthroughAlpha), [0.35]) + XCTAssertEqual(list.groups.map(\.instanceCount), [1]) + } + + func testAModelRecordHoldsWhereTheEntityWasThePreviousFrame() { + let cube = component(BasicPrimitives.createCube(extent: 1)) + var list = InstancedDrawList() + list.add(keyID: 0, component: cube, space: translation(3, 0, 0), previousSpace: translation(1, 0, 0)) + list.add(keyID: 0, component: cube, space: translation(5, 0, 0)) + list.layOutRecords() + + let records = modelRecords(of: &list, viewMatrix: matrix_identity_float4x4) + + XCTAssertEqual(records.count, 2) + let localSpace = cube.mesh[0].localSpace + XCTAssertEqual(records[0].modelMatrix, simd_mul(translation(3, 0, 0), localSpace)) + XCTAssertEqual(records[0].prevModelMatrix, simd_mul(translation(1, 0, 0), localSpace), "the previous transform times the mesh's local one, as for a single draw") + XCTAssertEqual(records[1].prevModelMatrix, records[1].modelMatrix, "an entity that gave no previous transform is where it was") + } + + func testMeshesWithTheirOwnLocalTransformGetARunOfRecordsEach() { + // Four meshes: the first two share a local transform, the third has its own, the + // fourth has the first's again but does not follow it. + let cube = BasicPrimitives.createCube(extent: 1) + var meshes = cube + cube + cube + cube + let left = translation(-1, 0, 0) + let right = translation(1, 0, 0) + meshes[0].localSpace = left + meshes[1].localSpace = left + meshes[2].localSpace = right + meshes[3].localSpace = left + let group = component(meshes) + let single = component(cube) + + var list = InstancedDrawList() + list.add(keyID: 0, component: group, space: translation(0, 10, 0)) + list.add(keyID: 1, component: single, space: translation(0, 20, 0)) + list.add(keyID: 0, component: group, space: translation(0, 30, 0)) + list.layOutRecords() + + // Three runs of two records for the first group, then the second group's record. + XCTAssertEqual(list.groups[0].runCount, 3) + XCTAssertEqual(list.groups[1].runCount, 1) + XCTAssertEqual(list.recordCount, 7) + XCTAssertEqual((0 ..< 4).map { list.baseInstance(of: list.groups[0], meshIndex: $0) }, [0, 0, 2, 4]) + XCTAssertEqual(list.baseInstance(of: list.groups[1], meshIndex: 0), 6) + + let records = shadowRecords(of: &list) + let positions = records.map { simd_make_float3($0.columns.3) } + XCTAssertEqual(positions, [ + simd_float3(-1, 10, 0), simd_float3(-1, 30, 0), // meshes 0 and 1 + simd_float3(1, 10, 0), simd_float3(1, 30, 0), // mesh 2 + simd_float3(-1, 10, 0), simd_float3(-1, 30, 0), // mesh 3 + simd_float3(0, 20, 0), + ]) + } + + func testTheRecordIsLaidOutAsTheShaderReadsIt() { + // modelViewMatrix, modelMatrix, normalMatrix (three padded columns), parameters. + XCTAssertEqual(MemoryLayout.stride, 240) + XCTAssertEqual(MemoryLayout.offset(of: \.modelViewMatrix), 0) + XCTAssertEqual(MemoryLayout.offset(of: \.modelMatrix), 64) + XCTAssertEqual(MemoryLayout.offset(of: \.prevModelMatrix), 128) + XCTAssertEqual(MemoryLayout.offset(of: \.normalMatrix), 192) + } + + // MARK: - Buffers + + func testABufferGivenBackIsLentAgain() throws { + let pool = InstanceRecordBufferPool() + let device = try XCTUnwrap(renderInfo.device) + + let first = try XCTUnwrap(pool.borrow(length: 1000, device: device)) + XCTAssertGreaterThanOrEqual(first.buffer.length, InstanceRecordBufferPool.minimumLength) + let second = try XCTUnwrap(pool.borrow(length: 1000, device: device)) + XCTAssertFalse(first.buffer === second.buffer, "a buffer in use is not lent twice") + XCTAssertNotEqual(first.token, second.token) + XCTAssertEqual(pool.bufferCounts.lent, 2) + + pool.giveBack(first.token) + XCTAssertEqual(pool.bufferCounts.free, 1) + XCTAssertEqual(pool.bufferCounts.lent, 1) + let third = try XCTUnwrap(pool.borrow(length: 2000, device: device)) + XCTAssertTrue(third.buffer === first.buffer) + + // A token that is not out does nothing. + pool.giveBack(first.token) + XCTAssertEqual(pool.bufferCounts.lent, 2) + } + + func testTheSmallestBufferThatFitsIsLent() throws { + let pool = InstanceRecordBufferPool() + let device = try XCTUnwrap(renderInfo.device) + let small = try XCTUnwrap(pool.borrow(length: 10, device: device)) + let large = try XCTUnwrap(pool.borrow(length: InstanceRecordBufferPool.minimumLength * 8, device: device)) + XCTAssertGreaterThanOrEqual(large.buffer.length, InstanceRecordBufferPool.minimumLength * 8) + pool.giveBack(large.token) + pool.giveBack(small.token) + + XCTAssertTrue(try XCTUnwrap(pool.borrow(length: 100, device: device)).buffer === small.buffer) + XCTAssertTrue(try XCTUnwrap(pool.borrow(length: InstanceRecordBufferPool.minimumLength + 1, device: device)).buffer === large.buffer) + // Nothing free is large enough: a new one. + let larger = try XCTUnwrap(pool.borrow(length: InstanceRecordBufferPool.minimumLength * 20, device: device)) + XCTAssertGreaterThanOrEqual(larger.buffer.length, InstanceRecordBufferPool.minimumLength * 20) + } + + func testThePoolKeepsOnlySoManyFreeBuffers() throws { + let pool = InstanceRecordBufferPool() + let device = try XCTUnwrap(renderInfo.device) + let borrowed = try (0 ..< InstanceRecordBufferPool.maximumFreeBuffers + 3).map { index in + try XCTUnwrap(pool.borrow(length: InstanceRecordBufferPool.minimumLength << (index % 3), device: device)) + } + + for loan in borrowed { + pool.giveBack(loan.token) + } + + XCTAssertEqual(pool.bufferCounts.lent, 0) + XCTAssertEqual(pool.bufferCounts.free, InstanceRecordBufferPool.maximumFreeBuffers) + } +} diff --git a/Tests/UntoldEngineRenderTests/InstancedDrawRenderTests.swift b/Tests/UntoldEngineRenderTests/InstancedDrawRenderTests.swift new file mode 100644 index 000000000..7c2700b2b --- /dev/null +++ b/Tests/UntoldEngineRenderTests/InstancedDrawRenderTests.swift @@ -0,0 +1,649 @@ +// +// InstancedDrawRenderTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import CShaderTypes +import Metal +import simd +@testable import UntoldEngine +import XCTest + +/// Entities that draw the same meshes with the same materials are encoded as one +/// instanced draw. These tests pin what makes a group, and that a frame looks the same, +/// pixel for pixel, whether its entities are drawn instanced or one by one. +@MainActor +final class InstancedDrawRenderTests: BaseRenderSetup { + private var savedImmersionStyle: UntoldImmersionMode = .none + private var savedRenderPath: RenderPath = .deferred + + override func setUp() async throws { + try await super.setUp() + savedImmersionStyle = renderInfo.immersionStyle + savedRenderPath = getRenderPath() + setRendering(.instancedDraws(.enabled)) + // Every entity of these scenes is meant to be drawn, however small on screen. + setRendering(.smallObjectCulling(pixels: 0)) + } + + override func tearDown() async throws { + setRendering(.instancedDraws(.enabled)) + setRendering(.smallObjectCulling(pixels: 1)) + setSceneChannel(.ghostGeometry, .renderMode(.normal)) + renderInfo.immersionStyle = savedImmersionStyle + setRendering(.renderPath(savedRenderPath)) + SpatialDebugVisualization.shared.colorRenderablesByLOD = false + destroyAllEntities() + try await super.tearDown() + } + + override func initializeAssets() { + let camera = createEntity() + createGameCamera(entityId: camera) + CameraSystem.shared.activeCamera = camera + cameraLookAt(entityId: camera, eye: simd_float3(0, 6, 16), target: simd_float3(0, 0, 0), up: simd_float3(0, 1, 0)) + } + + // MARK: - Helpers + + private func material( + baseColor: simd_float4 = simd_float4(0.8, 0.8, 0.8, 1), + emissive: simd_float3 = .zero, + metallic: Float = 0, + roughness: Float = 0.6 + ) -> Material { + Material( + runtimeMaterial: RuntimeMaterialSource( + baseColorFactor: baseColor, + emissiveFactor: emissive, + metallicFactor: metallic, + roughnessFactor: roughness + ), + device: renderInfo.device + ) + } + + /// A copy of `meshes` whose submeshes all have `material`. + private func meshes(_ meshes: [Mesh], with material: Material?) -> [Mesh] { + var copy = meshes + for meshIndex in copy.indices { + for submeshIndex in copy[meshIndex].submeshes.indices { + copy[meshIndex].submeshes[submeshIndex].material = material + } + } + return copy + } + + @discardableResult + private func place( + _ meshes: [Mesh], + at position: simd_float3, + scale: simd_float3 = simd_float3(repeating: 1), + turnedBy degrees: Float = 0 + ) -> EntityID { + let entity = createEntity() + setEntityMeshDirect(entityId: entity, meshes: meshes, assetName: "Shape") + scaleTo(entityId: entity, scale: scale) + if degrees != 0 { + rotateTo(entityId: entity, angle: degrees, axis: simd_float3(0.3, 1, 0.2)) + } + translateTo(entityId: entity, position: position) + return entity + } + + /// A 8x8 texture of two colours in squares of two texels. + private func checkerTexture(_ first: SIMD4, _ second: SIMD4) throws -> MTLTexture { + let size = 8 + let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba8Unorm_srgb, width: size, height: size, mipmapped: false) + descriptor.usage = .shaderRead + let texture = try XCTUnwrap(renderInfo.device.makeTexture(descriptor: descriptor)) + var texels = [UInt8]() + for y in 0 ..< size { + for x in 0 ..< size { + let colour = ((x / 2) + (y / 2)).isMultiple(of: 2) ? first : second + texels.append(contentsOf: [colour.x, colour.y, colour.z, colour.w]) + } + } + texture.replace(region: MTLRegionMake2D(0, 0, size, size), mipmapLevel: 0, withBytes: texels, bytesPerRow: size * 4) + return texture + } + + /// Draws enough frames for the culling result of the scene to come back and be used. + private func drawFrames() { + for _ in 0 ..< 6 { + renderer.draw(in: renderer.metalView) + renderInfo.lastCommandBuffer?.waitUntilCompleted() + } + } + + /// The lit frame, four half floats per pixel. + private func litPixels() throws -> [Float16] { + let texture = try XCTUnwrap(textureResources.deferredColorMap) + XCTAssertEqual(texture.pixelFormat, .rgba16Float) + var pixels = [Float16](repeating: 0, count: texture.width * texture.height * 4) + pixels.withUnsafeMutableBytes { bytes in + texture.getBytes( + bytes.baseAddress!, + bytesPerRow: texture.width * 8, + from: MTLRegionMake2D(0, 0, texture.width, texture.height), + mipmapLevel: 0 + ) + } + return pixels + } + + private struct Frame { + let pixels: [Float16] + let opaque: RenderPasses.InstancedDrawCounts + let shadow: RenderPasses.InstancedDrawCounts + } + + private func frame(instanced: Bool) throws -> Frame { + setRendering(.instancedDraws(instanced ? .enabled : .disabled)) + drawFrames() + return try Frame( + pixels: litPixels(), + opaque: RenderPasses.lastOpaqueInstancedDrawCounts, + shadow: RenderPasses.lastShadowInstancedDrawCounts + ) + } + + /// A channel difference beyond this is more than the last bits of a half float. + private static let lastBits: Float = 0.004 + + /// Pixels that differ in any channel at all, those that differ beyond the last bits, + /// and how many have something drawn on them. + private func compare(_ first: [Float16], _ second: [Float16]) -> (different: Int, beyondLastBits: Int, covered: Int) { + XCTAssertEqual(first.count, second.count) + var different = 0 + var beyondLastBits = 0 + var covered = 0 + let background = Array(first[0 ..< 4]) + for offset in stride(from: 0, to: min(first.count, second.count), by: 4) { + var differs = false + var beyond = false + for channel in 0 ..< 4 where first[offset + channel] != second[offset + channel] { + differs = true + if abs(Float(first[offset + channel]) - Float(second[offset + channel])) > Self.lastBits { + beyond = true + } + } + if differs { different += 1 } + if beyond { beyondLastBits += 1 } + if first[offset] != background[0] || first[offset + 1] != background[1] || first[offset + 2] != background[2] { + covered += 1 + } + } + return (different, beyondLastBits, covered) + } + + /// Draws the scene one by one and instanced, and checks that both frames are the same + /// picture and that the picture is not empty. Returns the instanced frame. + /// + /// The same picture, to the last bits of a few pixels: the instanced vertex stage is + /// compiled apart from the single-entity one, and the two differ in the last bits of + /// what they hand the fragment stage (the G-buffer attachments at their own precision + /// are identical), which the derivatives the fragment stage takes (the texture + /// footprint, the specular anti-aliasing) turn into a visible difference at a quad or + /// two along the silhouettes. A wrong transform, material, opacity or missing instance + /// changes hundreds of pixels by much more. + @discardableResult + private func assertSamePictureBothWays( + coveringAtLeast coveredPixels: Int = 2000, + file: StaticString = #filePath, + line: UInt = #line + ) throws -> Frame { + let single = try frame(instanced: false) + let instanced = try frame(instanced: true) + let result = compare(single.pixels, instanced.pixels) + XCTAssertGreaterThan(result.covered, coveredPixels, "the scene is on screen", file: file, line: line) + XCTAssertLessThanOrEqual( + result.different, max(50, result.covered / 1000), + "pixels that differ at all between the two ways of drawing, of \(result.covered) covered", file: file, line: line + ) + XCTAssertLessThanOrEqual( + result.beyondLastBits, max(4, result.covered / 10000), + "pixels that differ beyond the last bits between the two ways of drawing, of \(result.covered) covered", file: file, line: line + ) + XCTAssertEqual(single.opaque, RenderPasses.InstancedDrawCounts(), "nothing is instanced when the setting is off", file: file, line: line) + return instanced + } + + // MARK: - What makes a group + + func testEntitiesThatShareMeshesAndMaterialsAreOneDraw() throws { + let cube = meshes(BasicPrimitives.createCube(extent: 1), with: material(baseColor: simd_float4(0.9, 0.2, 0.2, 1))) + let sphere = meshes(BasicPrimitives.createSphere(extent: 1), with: material(baseColor: simd_float4(0.2, 0.3, 0.9, 1))) + for index in 0 ..< 12 { + place(cube, at: simd_float3(Float(index % 6) * 2.4 - 6, 0, Float(index / 6) * -3)) + } + for index in 0 ..< 5 { + place(sphere, at: simd_float3(Float(index) * 2.4 - 4.8, 2.5, -1)) + } + + let instanced = try frame(instanced: true) + + XCTAssertEqual(instanced.opaque, RenderPasses.InstancedDrawCounts(groups: 2, instances: 17, draws: 2)) + } + + func testAnEntityWithItsOwnMaterialIsAGroupOfItsOwn() throws { + let shape = BasicPrimitives.createCube(extent: 1) + // Black and glowing: what is seen is the material's own colour, whatever the light. + let red = material(baseColor: simd_float4(0, 0, 0, 1), emissive: simd_float3(1, 0, 0)) + var entities: [EntityID] = [] + for index in 0 ..< 5 { + entities.append(place(meshes(shape, with: red), at: simd_float3(Float(index) * 3 - 6, 0, 0))) + } + XCTAssertEqual(try frame(instanced: true).opaque, RenderPasses.InstancedDrawCounts(groups: 1, instances: 5, draws: 1)) + + // The middle one turns green: the change reaches the renderer by itself. + XCTAssertTrue(updateMaterial(entityId: entities[2]) { $0.emissiveValue = simd_float3(0, 1, 0) }) + let edited = try frame(instanced: true) + XCTAssertEqual(edited.opaque, RenderPasses.InstancedDrawCounts(groups: 2, instances: 5, draws: 2)) + + let texture = try XCTUnwrap(textureResources.deferredColorMap) + let camera = try XCTUnwrap(CameraSystem.shared.activeCamera) + let view = try XCTUnwrap(scene.get(component: CameraComponent.self, for: camera)).viewSpace + func colour(atEntity index: Int) -> simd_float3 { + let clip = renderInfo.perspectiveSpace * view * simd_float4(Float(index) * 3 - 6, 0, 0, 1) + let x = Int((clip.x / clip.w * 0.5 + 0.5) * Float(texture.width)) + let y = Int((0.5 - clip.y / clip.w * 0.5) * Float(texture.height)) + let offset = (y * texture.width + x) * 4 + return simd_float3(Float(edited.pixels[offset]), Float(edited.pixels[offset + 1]), Float(edited.pixels[offset + 2])) + } + for index in 0 ..< 5 { + let seen = colour(atEntity: index) + if index == 2 { + XCTAssertGreaterThan(seen.y, 0.5, "the edited entity shows its own material, got \(seen)") + XCTAssertLessThan(seen.x, 0.2, "the edited entity does not show the group's material, got \(seen)") + } else { + XCTAssertGreaterThan(seen.x, 0.5, "entity \(index) shows the group's material, got \(seen)") + XCTAssertLessThan(seen.y, 0.2, "entity \(index) does not show the edited material, got \(seen)") + } + } + + // Given the group's material back, it is one of the group again. + XCTAssertTrue(updateMaterial(entityId: entities[2]) { $0.emissiveValue = simd_float3(1, 0, 0) }) + XCTAssertEqual(try frame(instanced: true).opaque, RenderPasses.InstancedDrawCounts(groups: 1, instances: 5, draws: 1)) + } + + func testSubmeshesTheOpaquePassDoesNotDrawAreNotDrawnInstancedEither() throws { + let shape = BasicPrimitives.createCube(extent: 1) + var glass = material(baseColor: simd_float4(0.2, 0.5, 0.9, 0.4)) + glass.alphaMode = .blend + for index in 0 ..< 4 { + place(meshes(shape, with: glass), at: simd_float3(Float(index) * 3 - 4.5, 0, 0)) + place(meshes(shape, with: nil), at: simd_float3(Float(index) * 3 - 4.5, 3, 0)) + } + place(meshes(shape, with: material()), at: simd_float3(0, -3, 0)) + + let instanced = try assertSamePictureBothWays() + + XCTAssertEqual(instanced.opaque, RenderPasses.InstancedDrawCounts(groups: 1, instances: 1, draws: 1), "only the solid cube") + } + + // MARK: - The same picture + + /// Six materials (plain, metallic, emissive, textured, cut out, tiled) on two shapes, + /// four placements each: 12 groups of 2. + private func placeManyMaterials() throws { + let cube = BasicPrimitives.createCube(extent: 1) + let sphere = BasicPrimitives.createSphere(extent: 1) + + var textured = material() + textured.baseColor.texture = try checkerTexture(SIMD4(230, 60, 40, 255), SIMD4(250, 240, 200, 255)) + var cutOut = material(baseColor: simd_float4(1, 1, 1, 1)) + cutOut.baseColor.texture = try checkerTexture(SIMD4(40, 200, 90, 255), SIMD4(40, 200, 90, 0)) + cutOut.alphaMode = .mask + cutOut.alphaCutoff = 0.5 + var tiled = textured + tiled.stScale = 3 + + let materials: [Material] = [ + material(baseColor: simd_float4(0.85, 0.15, 0.1, 1), roughness: 0.3), + material(baseColor: simd_float4(0.9, 0.9, 0.95, 1), metallic: 1, roughness: 0.15), + material(baseColor: simd_float4(0.05, 0.05, 0.05, 1), emissive: simd_float3(0.2, 0.9, 1.4)), + textured, + cutOut, + tiled, + ] + var index = 0 + for (materialIndex, material) in materials.enumerated() { + for copy in 0 ..< 4 { + let shape = (materialIndex + copy).isMultiple(of: 2) ? cube : sphere + place( + meshes(shape, with: material), + at: simd_float3(Float(index % 8) * 2.6 - 9.1, Float(index / 8) * 2.6 - 2.6, Float(copy) * -1.5), + scale: simd_float3(1 + Float(copy) * 0.25, 1, 0.6 + Float(materialIndex) * 0.15), + turnedBy: Float(index) * 17 + ) + index += 1 + } + } + } + + func testAFrameOfManyMaterialsLooksTheSameDrawnBothWays() throws { + try placeManyMaterials() + + let instanced = try assertSamePictureBothWays() + + // Six materials on two shapes each. + XCTAssertEqual(instanced.opaque, RenderPasses.InstancedDrawCounts(groups: 12, instances: 24, draws: 12)) + } + + func testMeshesWithTheirOwnLocalTransformsLookTheSameDrawnBothWays() throws { + // One entity draws a cube to its left and a sphere to its right: each mesh has a + // local transform, the same as its world one so that the registration keeps it. + var pair = meshes(BasicPrimitives.createCube(extent: 1), with: material(baseColor: simd_float4(0.9, 0.6, 0.1, 1))) + + meshes(BasicPrimitives.createSphere(extent: 1), with: material(baseColor: simd_float4(0.2, 0.7, 0.4, 1))) + pair[0].localSpace = matrix4x4Translation(-0.9, 0, 0) + pair[0].worldSpace = pair[0].localSpace + pair[1].localSpace = matrix4x4Translation(0.9, 0.4, 0) + pair[1].worldSpace = pair[1].localSpace + for index in 0 ..< 6 { + place(pair, at: simd_float3(Float(index % 3) * 5 - 5, Float(index / 3) * 3 - 1, 0), turnedBy: Float(index) * 31) + } + + let instanced = try assertSamePictureBothWays() + + XCTAssertEqual(instanced.opaque, RenderPasses.InstancedDrawCounts(groups: 1, instances: 6, draws: 2)) + } + + func testAnEntityThatFadesAmongInstancesLooksTheSameDrawnBothWays() throws { + let shape = meshes(BasicPrimitives.createCube(extent: 1.5), with: material(baseColor: simd_float4(0.3, 0.6, 0.9, 1))) + var entities: [EntityID] = [] + for index in 0 ..< 6 { + entities.append(place(shape, at: simd_float3(Float(index) * 3 - 7.5, 0, 0), turnedBy: 20)) + } + // Half way through a fade, an entity is drawn with a dither of its own. + registerComponent(entityId: entities[3], componentType: MeshFadeComponent.self) + let fade = try XCTUnwrap(scene.get(component: MeshFadeComponent.self, for: entities[3])) + fade.progress = 0.5 + + let instanced = try assertSamePictureBothWays() + + XCTAssertEqual(instanced.opaque, RenderPasses.InstancedDrawCounts(groups: 1, instances: 5, draws: 1)) + } + + func testAGhostChannelKeepsItsOpacityDrawnBothWays() throws { + // In mixed immersion an entity on a ghost channel is drawn see-through: the + // opacity belongs to the entity, not to the material it shares, and the ghosts + // are a group of their own. + renderInfo.immersionStyle = .mixed + let shape = meshes(BasicPrimitives.createCube(extent: 1.5), with: material(emissive: simd_float3(1, 0.5, 0.1))) + var entities: [EntityID] = [] + for index in 0 ..< 6 { + entities.append(place(shape, at: simd_float3(Float(index) * 3 - 7.5, 0, 0))) + } + setEntitySceneChannels(entityId: entities[1], channels: .ghostGeometry) + setEntitySceneChannels(entityId: entities[4], channels: .ghostGeometry) + setSceneChannel(.ghostGeometry, .renderMode(.passthroughGhost(opacity: 0.35))) + + let instanced = try assertSamePictureBothWays() + + XCTAssertEqual(instanced.opaque, RenderPasses.InstancedDrawCounts(groups: 2, instances: 6, draws: 2), "the four opaque cubes, and the two ghosts") + } + + // MARK: - The render paths + + /// The hybrid path: the geometry evaluates the indirect light and the sun's shadow per + /// fragment, through the instanced pipelines built with the same function constants. + func testAFrameOfManyMaterialsLooksTheSameDrawnBothWaysOnTheHybridPath() throws { + setRendering(.renderPath(.hybrid)) + try placeManyMaterials() + + let instanced = try assertSamePictureBothWays() + + XCTAssertTrue(renderInfo.perFragmentIndirectLightingBuilt, "the pipelines are the hybrid path's") + XCTAssertFalse(renderInfo.perFragmentLightingBuilt) + XCTAssertEqual(instanced.opaque, RenderPasses.InstancedDrawCounts(groups: 12, instances: 24, draws: 12)) + } + + /// The forward path, the headset's: the geometry lights itself whole per fragment and + /// writes the lit colour and its velocity; the light quad stands down. + func testAFrameOfManyMaterialsLooksTheSameDrawnBothWaysOnTheForwardPath() throws { + setRendering(.renderPath(.forward)) + try placeManyMaterials() + + let instanced = try assertSamePictureBothWays() + + XCTAssertTrue(renderInfo.perFragmentLightingBuilt, "the pipelines are the forward path's") + XCTAssertEqual(instanced.opaque, RenderPasses.InstancedDrawCounts(groups: 12, instances: 24, draws: 12)) + } + + /// The velocity texture of the last frame, two half floats per pixel (the surface's + /// motion in NDC), copied out of the private texture the forward pass writes. + private func velocityPixels() throws -> [Float16] { + let velocity = try XCTUnwrap(textureResources.velocityMap) + XCTAssertEqual(velocity.pixelFormat, .rg16Float) + let descriptor = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: .rg16Float, width: velocity.width, height: velocity.height, mipmapped: false + ) + descriptor.storageMode = .shared + descriptor.usage = .shaderRead + let copy = try XCTUnwrap(renderInfo.device.makeTexture(descriptor: descriptor)) + let commandBuffer = try XCTUnwrap(renderInfo.commandQueue.makeCommandBuffer()) + let blit = try XCTUnwrap(commandBuffer.makeBlitCommandEncoder()) + blit.copy(from: velocity, to: copy) + blit.endEncoding() + commandBuffer.commit() + commandBuffer.waitUntilCompleted() + var pixels = [Float16](repeating: 0, count: copy.width * copy.height * 2) + pixels.withUnsafeMutableBytes { bytes in + copy.getBytes( + bytes.baseAddress!, + bytesPerRow: copy.width * 4, + from: MTLRegionMake2D(0, 0, copy.width, copy.height), + mipmapLevel: 0 + ) + } + return pixels + } + + /// The velocity the frame writes after `mover` goes from `from` to `to`, drawn `instanced`. + private func velocityOfAMove(_ mover: EntityID, from: simd_float3, to: simd_float3, instanced: Bool) throws -> [Float16] { + setRendering(.instancedDraws(instanced ? .enabled : .disabled)) + translateTo(entityId: mover, position: from) + drawFrames() + translateTo(entityId: mover, position: to) + renderer.draw(in: renderer.metalView) + renderInfo.lastCommandBuffer?.waitUntilCompleted() + return try velocityPixels() + } + + /// An instance's record carries where it was the previous frame, so an entity that + /// moves on its own has its motion written for the temporal resolve as it has when it + /// is drawn alone. + func testAnEntityThatMovesAmongInstancesWritesTheSameVelocityDrawnBothWays() throws { + setRendering(.renderPath(.forward)) + let shape = meshes(BasicPrimitives.createCube(extent: 1.5), with: material(baseColor: simd_float4(0.2, 0.6, 0.9, 1))) + var entities: [EntityID] = [] + for index in 0 ..< 6 { + entities.append(place(shape, at: simd_float3(Float(index) * 3 - 7.5, 0, 0))) + } + let mover = entities[2] + let from = simd_float3(-1.5, 0, 0) + let to = simd_float3(-1.5, 1.2, 0.8) + + let single = try velocityOfAMove(mover, from: from, to: to, instanced: false) + let instanced = try velocityOfAMove(mover, from: from, to: to, instanced: true) + + XCTAssertTrue(renderInfo.perFragmentLightingBuilt, "the velocity is written on the forward path") + XCTAssertEqual(RenderPasses.lastOpaqueInstancedDrawCounts, RenderPasses.InstancedDrawCounts(groups: 1, instances: 6, draws: 1)) + XCTAssertEqual(single.count, instanced.count) + var moving = 0 + var different = 0 + for offset in stride(from: 0, to: min(single.count, instanced.count), by: 2) { + if single[offset] != 0 || single[offset + 1] != 0 { moving += 1 } + // To the last bits, as the pictures (see assertSamePictureBothWays). + if abs(Float(single[offset]) - Float(instanced[offset])) > Self.lastBits + || abs(Float(single[offset + 1]) - Float(instanced[offset + 1])) > Self.lastBits + { + different += 1 + } + } + XCTAssertGreaterThan(moving, 500, "the moved cube's pixels carry its motion; the camera stood still") + XCTAssertLessThanOrEqual(different, max(4, moving / 10000), "pixels whose velocity differs between the two ways of drawing, of \(moving) moving") + } + + func testShadowsLookTheSameDrawnBothWays() throws { + let sun = createEntity() + createDirLight(entityId: sun) + setSunElevationAzimuth(entityId: sun, elevation: 50, azimuth: 30) + + let ground = meshes(BasicPrimitives.createCube(extent: 1), with: material(baseColor: simd_float4(0.7, 0.7, 0.7, 1))) + place(ground, at: simd_float3(0, -1.2, 0), scale: simd_float3(40, 0.2, 40)) + let post = meshes(BasicPrimitives.createCube(extent: 1), with: material(baseColor: simd_float4(0.8, 0.3, 0.2, 1))) + for index in 0 ..< 9 { + place(post, at: simd_float3(Float(index % 3) * 4 - 4, 0.4, Float(index / 3) * -4 + 2), scale: simd_float3(0.8, 3, 0.8), turnedBy: Float(index) * 11) + } + RenderPasses.invalidateShadowEntityCache() + + let instanced = try assertSamePictureBothWays() + + // The ground and the posts share the cube's mesh and differ in material: two + // groups in the opaque pass, and in every cascade they are cast into. + XCTAssertEqual(instanced.opaque, RenderPasses.InstancedDrawCounts(groups: 2, instances: 10, draws: 2)) + XCTAssertGreaterThan(instanced.shadow.instances, 0, "the posts cast into a cascade") + XCTAssertEqual(instanced.shadow.draws, instanced.shadow.groups, "a cube is one draw per group") + XCTAssertLessThan(instanced.shadow.draws, instanced.shadow.instances, "casters share their draws") + } + + func testLoadedModelsWithASkinnedOneAmongThemLookTheSameDrawnBothWays() async throws { + func load(_ file: String, at position: simd_float3) async -> EntityID { + let entity = createEntity() + let loaded = expectation(description: file) + setEntityMeshAsync(entityId: entity, filename: file, withExtension: "untold") { _ in loaded.fulfill() } + await fulfillment(of: [loaded], timeout: 60) + translateTo(entityId: entity, position: position) + return entity + } + _ = await load("stadium", at: .zero) + for index in 0 ..< 3 { + _ = await load("ball", at: simd_float3(Float(index) * 1.5 - 1.5, 0.3, 2)) + } + // The player is posed by its skeleton: its vertices are its own, frame by frame. + let player = await load("redplayer", at: simd_float3(0, 0, 0)) + setEntityAnimations(entityId: player, filename: "running", withExtension: "untold", name: "running") + changeAnimation(entityId: player, name: "running") + setAnimationPlaybackSpeed(entityId: player, speed: 0) + let camera = try XCTUnwrap(CameraSystem.shared.activeCamera) + cameraLookAt(entityId: camera, eye: simd_float3(0, 3, 7), target: .zero, up: simd_float3(0, 1, 0)) + + let instanced = try assertSamePictureBothWays() + + let skeletonId = getComponentId(for: SkeletonComponent.self) + let renderId = getComponentId(for: RenderComponent.self) + let skinned = queryEntitiesWithComponentIds([skeletonId, renderId], in: scene) + XCTAssertFalse(skinned.isEmpty, "the player is drawn through its skeleton") + XCTAssertGreaterThan(instanced.opaque.instances, 0, "the stadium and the balls are drawn instanced") + for entity in skinned { + let render = try XCTUnwrap(scene.get(component: RenderComponent.self, for: entity)) + XCTAssertNil(render.instancedSubmeshDraws, "a skinned entity never stands for a group") + } + } + + func testEntitiesFollowTheirLevelOfDetailFromGroupToGroup() throws { + // Six entities with two levels, a cube up close and a sphere from 20 units on. + let shared = material(baseColor: simd_float4(0.9, 0.7, 0.2, 1)) + let near = meshes(BasicPrimitives.createCube(extent: 1), with: shared) + let far = meshes(BasicPrimitives.createSphere(extent: 1), with: shared) + var entities: [EntityID] = [] + for index in 0 ..< 6 { + // Three within reach of the first level, three beyond it. + let entity = place(near, at: simd_float3(Float(index % 3) * 3 - 3, 0, index < 3 ? 4 : -14)) + registerComponent(entityId: entity, componentType: LODComponent.self) + let lod = try XCTUnwrap(scene.get(component: LODComponent.self, for: entity)) + lod.lodLevels = [ + LODLevel(mesh: near, maxDistance: 20), + LODLevel(mesh: far, maxDistance: .greatestFiniteMagnitude), + ] + entities.append(entity) + } + LODSystem.shared.reset() + LODSystem.shared.update(deltaTime: 0.1) + + let instanced = try assertSamePictureBothWays() + XCTAssertEqual(instanced.opaque, RenderPasses.InstancedDrawCounts(groups: 2, instances: 6, draws: 2), "three cubes, three spheres") + + // The camera backs away: every entity is past its first level now. + let camera = try XCTUnwrap(CameraSystem.shared.activeCamera) + cameraLookAt(entityId: camera, eye: simd_float3(0, 12, 40), target: .zero, up: simd_float3(0, 1, 0)) + LODSystem.shared.update(deltaTime: 0.1) + + let fromAfar = try assertSamePictureBothWays(coveringAtLeast: 500) + XCTAssertEqual(fromAfar.opaque, RenderPasses.InstancedDrawCounts(groups: 1, instances: 6, draws: 1), "six spheres") + } + + func testGlassAmongInstancesLooksTheSameDrawnBothWays() throws { + let sun = createEntity() + createDirLight(entityId: sun) + setSunElevationAzimuth(entityId: sun, elevation: 50, azimuth: 30) + + let ground = meshes(BasicPrimitives.createCube(extent: 1), with: material(baseColor: simd_float4(0.7, 0.7, 0.7, 1))) + place(ground, at: simd_float3(0, -1.2, 0), scale: simd_float3(40, 0.2, 40)) + + // A post with a glass ball on top, six times: the post is drawn instanced and + // casts; the ball goes to the transparency pass and casts no shadow. + var glass = material(baseColor: simd_float4(0.6, 0.9, 1.0, 1), roughness: 0.0) + glass.transmission = 1 + XCTAssertTrue(glass.transmitsLight) + var lamp = meshes(BasicPrimitives.createCube(extent: 1), with: material(baseColor: simd_float4(0.8, 0.3, 0.2, 1))) + + meshes(BasicPrimitives.createSphere(extent: 1), with: glass) + lamp[0].localSpace = matrix4x4Translation(0, 0, 0) + lamp[0].worldSpace = lamp[0].localSpace + lamp[1].localSpace = matrix4x4Translation(0, 1.4, 0) + lamp[1].worldSpace = lamp[1].localSpace + for index in 0 ..< 6 { + place(lamp, at: simd_float3(Float(index % 3) * 4 - 4, 0, Float(index / 3) * -4 + 2)) + } + RenderPasses.invalidateShadowEntityCache() + + let instanced = try assertSamePictureBothWays() + + // The ground, and the posts of the lamps: the glass is in neither pass's groups. + XCTAssertEqual(instanced.opaque, RenderPasses.InstancedDrawCounts(groups: 2, instances: 7, draws: 2)) + XCTAssertGreaterThan(instanced.shadow.instances, 0) + XCTAssertEqual(instanced.shadow.draws, instanced.shadow.groups, "a lamp casts with its post alone") + } + + // MARK: - When nothing is instanced + + func testADebugColouringDrawsEveryEntityOnItsOwn() throws { + let shape = meshes(BasicPrimitives.createCube(extent: 1), with: material()) + for index in 0 ..< 4 { + place(shape, at: simd_float3(Float(index) * 3 - 4.5, 0, 0)) + } + XCTAssertEqual(try frame(instanced: true).opaque.instances, 4) + + SpatialDebugVisualization.shared.colorRenderablesByLOD = true + XCTAssertEqual(try frame(instanced: true).opaque, RenderPasses.InstancedDrawCounts()) + } + + // MARK: - Records + + func testTheRecordBuffersOfAFrameComeBack() throws { + let shape = meshes(BasicPrimitives.createCube(extent: 1), with: material()) + for index in 0 ..< 4 { + place(shape, at: simd_float3(Float(index) * 3 - 4.5, 0, 0)) + } + + _ = try frame(instanced: true) + + // drawFrames waits for each frame's command buffer; the completion handlers + // may still be running. + let deadline = Date().addingTimeInterval(5) + while InstanceRecordBufferPool.shared.bufferCounts.lent > 0, Date() < deadline { + usleep(1000) + } + let counts = InstanceRecordBufferPool.shared.bufferCounts + XCTAssertEqual(counts.lent, 0, "every buffer a pass borrowed is back once its frame completed") + XCTAssertGreaterThan(counts.free, 0) + XCTAssertLessThanOrEqual(counts.free, InstanceRecordBufferPool.maximumFreeBuffers) + } +} diff --git a/Tests/UntoldEngineRenderTests/RenderInstanceKeyTests.swift b/Tests/UntoldEngineRenderTests/RenderInstanceKeyTests.swift new file mode 100644 index 000000000..1dca8b2c8 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/RenderInstanceKeyTests.swift @@ -0,0 +1,302 @@ +// +// RenderInstanceKeyTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import CShaderTypes +import Metal +import simd +@testable import UntoldEngine +import XCTest + +/// Entities share an instanced draw when their render components have the same key. A +/// key must be shared by components that send the same things to the GPU, and by no +/// others: these tests pin both directions, and what clears a key. +@MainActor +final class RenderInstanceKeyTests: BaseRenderSetup { + private var table: RenderInstanceKeyTable! + + override func setUp() async throws { + try await super.setUp() + table = RenderInstanceKeyTable() + } + + override func tearDown() async throws { + destroyAllEntities() + try await super.tearDown() + } + + override func initializeAssets() {} + + // MARK: - Helpers + + private func material() -> Material { + Material( + runtimeMaterial: RuntimeMaterialSource( + baseColorFactor: simd_float4(0.8, 0.7, 0.6, 1), + emissiveFactor: .zero, + metallicFactor: 0.2, + roughnessFactor: 0.6 + ), + device: renderInfo.device + ) + } + + /// A copy of `meshes` whose submeshes all have `material`. + private func meshes(_ meshes: [Mesh], with material: Material?) -> [Mesh] { + var copy = meshes + for meshIndex in copy.indices { + for submeshIndex in copy[meshIndex].submeshes.indices { + copy[meshIndex].submeshes[submeshIndex].material = material + } + } + return copy + } + + private func texture() throws -> MTLTexture { + let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba8Unorm, width: 2, height: 2, mipmapped: false) + return try XCTUnwrap(renderInfo.device.makeTexture(descriptor: descriptor)) + } + + private func sampler() throws -> MTLSamplerState { + let descriptor = MTLSamplerDescriptor() + descriptor.minFilter = .nearest + // The device gives equal descriptors one sampler: make this one like no other. + descriptor.lodMaxClamp = Float(samplerCount) + 3.5 + samplerCount += 1 + return try XCTUnwrap(renderInfo.device.makeSamplerState(descriptor: descriptor)) + } + + private var samplerCount = 0 + + private func component(_ meshes: [Mesh]) -> RenderComponent { + let component = RenderComponent() + component.mesh = meshes + return component + } + + // MARK: - The same key + + func testComponentsThatDrawTheSameThingsShareAKey() { + let cube = meshes(BasicPrimitives.createCube(extent: 1), with: material()) + let first = component(cube) + let second = component(cube) + // The same values, set on a copy by hand. + var rebuilt = cube + rebuilt[0].submeshes[0].material = cube[0].submeshes[0].material + let third = component(rebuilt) + + let id = table.resolve(first) + + XCTAssertGreaterThanOrEqual(id, 0) + XCTAssertEqual(table.resolve(second), id) + XCTAssertEqual(table.resolve(third), id) + XCTAssertEqual(table.count, 1) + } + + func testWhatADrawDoesNotBindLeavesTheKeyAlone() throws { + let base = material() + let cube = BasicPrimitives.createCube(extent: 1) + let id = table.id(for: meshes(cube, with: base)) + + // Where a texture came from, how far its streaming got, the samplers of the + // textures that are read through another one's: none of it reaches the encoder. + let changes: [(String, (inout Material) -> Void)] = try [ + ("baseColorURL", { $0.baseColorURL = URL(fileURLWithPath: "/tmp/other.png") }), + ("baseColorTextureName", { $0.baseColorTextureName = "other" }), + ("baseColorStreamingLevel", { $0.baseColorStreamingLevel = .capped }), + ("baseColorSourceDimensions", { $0.baseColorSourceDimensions = simd_int2(64, 64) }), + ("metallic sampler", { [sampler = sampler()] in $0.metallic.sampler = sampler }), + ("emissive sampler", { [sampler = sampler()] in $0.emissive.sampler = sampler }), + ("wrap mode", { $0.baseColor.wrapMode = .clampToEdge }), + // A height setting without a height texture switches nothing on. + ("heightEnabled", { $0.heightEnabled.toggle() }), + ] + for (name, change) in changes { + var changed = base + change(&changed) + XCTAssertEqual(table.id(for: meshes(cube, with: changed)), id, name) + } + } + + // MARK: - Another key + + func testEveryValueADrawBindsGivesAnotherKey() throws { + var base = material() + base.height.texture = try texture() + let cube = BasicPrimitives.createCube(extent: 1) + let id = table.id(for: meshes(cube, with: base)) + XCTAssertGreaterThanOrEqual(id, 0) + + let changes: [(String, (inout Material) -> Void)] = try [ + ("base colour texture", { [texture = texture()] in $0.baseColor.texture = texture }), + ("roughness texture", { [texture = texture()] in $0.roughness.texture = texture }), + ("metallic texture", { [texture = texture()] in $0.metallic.texture = texture }), + ("normal texture", { [texture = texture()] in $0.normal.texture = texture }), + ("height texture", { [texture = texture()] in $0.height.texture = texture }), + ("emissive texture", { [texture = texture()] in $0.emissive.texture = texture }), + ("base colour sampler", { [sampler = sampler()] in $0.baseColor.sampler = sampler }), + ("roughness sampler", { [sampler = sampler()] in $0.roughness.sampler = sampler }), + ("normal sampler", { [sampler = sampler()] in $0.normal.sampler = sampler }), + ("height sampler", { [sampler = sampler()] in $0.height.sampler = sampler }), + ("stScale", { $0.stScale = 2 }), + ("normalIsPackedXY", { $0.normalIsPackedXY.toggle() }), + ("baseColorValue", { $0.baseColorValue.z = 0.1 }), + ("base colour alpha", { $0.baseColorValue.w = 0.5 }), + ("edgeTint", { $0.edgeTint.y = 0.4 }), + ("emissiveValue", { $0.emissiveValue.z = 2 }), + ("roughnessValue", { $0.roughnessValue = 0.1 }), + ("metallicValue", { $0.metallicValue = 0.9 }), + ("roughnessChannel", { $0.roughnessChannel = .g }), + ("metallicChannel", { $0.metallicChannel = .b }), + ("normalScale", { $0.normalScale = 0.5 }), + ("specular", { $0.specular = 0.3 }), + ("specularTint", { $0.specularTint = 0.3 }), + ("subsurface", { $0.subsurface = 0.3 }), + ("anisotropic", { $0.anisotropic = 0.3 }), + ("sheen", { $0.sheen = 0.3 }), + ("sheenTint", { $0.sheenTint = 0.3 }), + ("clearCoat", { $0.clearCoat = 0.3 }), + ("clearCoatGloss", { $0.clearCoatGloss = 0.3 }), + ("ior", { $0.ior = 1.33 }), + ("interactWithLight", { $0.interactWithLight.toggle() }), + ("alphaMode", { $0.alphaMode = .mask }), + ("alphaCutoff", { $0.alphaCutoff = 0.25 }), + ("heightScale", { $0.heightScale = 0.11 }), + ("heightMidlevel", { $0.heightMidlevel = 0.4 }), + ("heightRemapMin", { $0.heightRemapMin = 0.2 }), + ("heightRemapMax", { $0.heightRemapMax = 0.8 }), + ("heightEnabled", { $0.heightEnabled.toggle() }), + ] + + var ids: [Int32: String] = [id: "the unchanged material"] + for (name, change) in changes { + var changed = base + change(&changed) + let changedID = table.id(for: meshes(cube, with: changed)) + XCTAssertGreaterThanOrEqual(changedID, 0, name) + XCTAssertNil(ids[changedID], "\(name) draws like \(ids[changedID] ?? "")") + ids[changedID] = name + } + } + + func testAnotherMeshOrLocalTransformGivesAnotherKey() { + let shared = material() + let cube = meshes(BasicPrimitives.createCube(extent: 1), with: shared) + let id = table.id(for: cube) + + // The same shape built again is another set of vertex buffers. + XCTAssertNotEqual(table.id(for: meshes(BasicPrimitives.createCube(extent: 1), with: shared)), id) + + var moved = cube + moved[0].localSpace.columns.3.x = 2 + XCTAssertNotEqual(table.id(for: moved), id) + + // A mesh more, or the same meshes in another order. + let sphere = meshes(BasicPrimitives.createSphere(extent: 1), with: shared) + let both = table.id(for: cube + sphere) + XCTAssertNotEqual(both, id) + XCTAssertNotEqual(table.id(for: sphere + cube), both) + } + + // MARK: - No key + + func testAMeshListTheOpaquePassDoesNotDrawHasNoKey() { + let cube = BasicPrimitives.createCube(extent: 1) + var glass = material() + glass.alphaMode = .blend + + XCTAssertEqual(table.id(for: []), RenderInstanceKey.notInstanced) + XCTAssertEqual(table.id(for: meshes(cube, with: nil)), RenderInstanceKey.notInstanced) + XCTAssertEqual(table.id(for: meshes(cube, with: glass)), RenderInstanceKey.notInstanced) + XCTAssertEqual(table.count, 0) + + // One solid submesh among them is enough. + let mixed = meshes(cube, with: glass) + meshes(BasicPrimitives.createSphere(extent: 1), with: material()) + XCTAssertGreaterThanOrEqual(table.id(for: mixed), 0) + } + + // MARK: - What clears a key + + func testAChangeToTheMeshesClearsTheKeyAndThePreparedDraws() { + let renderComponent = component(meshes(BasicPrimitives.createCube(extent: 1), with: material())) + let id = table.resolve(renderComponent) + renderComponent.instancedSubmeshDraws = InstancedSubmeshDraw.draws(of: renderComponent.mesh) + XCTAssertEqual(renderComponent.instanceKey, RenderInstanceKey(generation: table.currentGeneration, id: id)) + + // One value of one material, changed where it is. + renderComponent.mesh[0].submeshes[0].material?.roughnessValue = 0.05 + + XCTAssertEqual(renderComponent.instanceKey, .unresolved) + XCTAssertNil(renderComponent.instancedSubmeshDraws) + let changedID = table.resolve(renderComponent) + XCTAssertNotEqual(changedID, id) + + // And changed back: the first key again. + renderComponent.mesh[0].submeshes[0].material?.roughnessValue = 0.6 + XCTAssertEqual(renderComponent.instanceKey, .unresolved) + XCTAssertEqual(table.resolve(renderComponent), id) + } + + func testAResolvedKeyIsNotLookedUpAgain() { + let renderComponent = component(meshes(BasicPrimitives.createCube(extent: 1), with: material())) + let generation = table.beginPass() + let id = table.resolve(renderComponent) + + // The component answers for itself while its key is of the pass's generation. + XCTAssertEqual(renderComponent.instanceKey.generation, generation) + XCTAssertEqual(renderComponent.instanceKey.id, id) + + table.removeAll() + XCTAssertNotEqual(table.currentGeneration, generation, "a key of the old generation is stale") + XCTAssertEqual(table.count, 0) + XCTAssertEqual(table.resolve(renderComponent), 0, "the table numbers from zero again") + XCTAssertEqual(renderComponent.instanceKey.generation, table.currentGeneration) + } + + func testTheTableStartsOverOnceItHasOutgrownItsLimit() { + var moving = meshes(BasicPrimitives.createCube(extent: 1), with: material()) + let generation = table.beginPass() + + // A material edited over and over leaves a description behind each time. + for step in 0 ... RenderInstanceKeyTable.maximumDescriptions { + moving[0].localSpace.columns.3.x = Float(step) + XCTAssertEqual(table.id(for: moving), Int32(step)) + } + XCTAssertEqual(table.count, RenderInstanceKeyTable.maximumDescriptions + 1) + XCTAssertEqual(table.currentGeneration, generation, "never in the middle of a pass") + + XCTAssertNotEqual(table.beginPass(), generation) + XCTAssertEqual(table.count, 0) + } + + // MARK: - The parameter block + + /// `MeshListDescription` lists the fields of the material's parameter block one by + /// one. A field added to the block and not to the list would let two materials that + /// differ in it share a draw: this fails until the list (and this test) know it. + func testTheDescriptionKnowsEveryFieldOfTheParameterBlock() { + let described = [ + "baseColor", "hasTexture", "textureChannels", "edgeTint", "emmissive", "roughness", "specular", + "subsurface", "metallic", "specularTint", "anisotropic", "sheen", "sheenTint", "clearCoat", + "clearCoatGloss", "ior", "alphaCutoff", "alphaMode", "interactWithLight", "heightScale", + "heightMidlevel", "heightRemapMin", "heightRemapMax", "normalScale", "hasEmissiveTexture", + "transmission", + ] + // The instance's own: its record carries the opacity, and it has no dither. + let ofTheInstance = ["passthroughAlpha", "lodDither"] + + let fields = Mirror(reflecting: MaterialParametersUniform()).children.compactMap(\.label) + + XCTAssertFalse(fields.isEmpty, "the block's fields can be listed") + XCTAssertEqual( + Set(fields), Set(described + ofTheInstance), + "MaterialParametersUniform changed: add the new field to MeshListDescription.addMaterial(of:) and to this list" + ) + } +} diff --git a/docs/API/UsingEngineAPI.md b/docs/API/UsingEngineAPI.md index a57fe68bb..147733a20 100644 --- a/docs/API/UsingEngineAPI.md +++ b/docs/API/UsingEngineAPI.md @@ -97,6 +97,17 @@ setRendering(.foveatedRendering(.disabled)) getFoveatedRenderingEnabled() ``` +Entities that draw the same meshes with the same materials are encoded as one instanced draw, in +the opaque pass and in the shadow cascades: a scene that places a model many times costs a draw +per model, not per placement. Nothing has to be set up for it. An entity whose material is +edited, or that is fading, skinned or deformed, is drawn on its own. The picture is the same +either way; turn it off to compare, or to rule it out while tracking down a problem: + +```swift +setRendering(.instancedDraws(.disabled)) +getInstancedDrawsEnabled() +``` + ## PostFX Use `setPostFX` for individual post-processing and SSAO settings: diff --git a/docs/Architecture/renderingSystem.md b/docs/Architecture/renderingSystem.md index 1f76ac905..bf148ce62 100644 --- a/docs/Architecture/renderingSystem.md +++ b/docs/Architecture/renderingSystem.md @@ -181,6 +181,8 @@ The cascade count is 2 by default. Raise to 3 in `Globals.swift` for outdoor sce **Receiver bias:** The established cascade-0 bias is converted from normalized depth into a light-space world distance. Each cascade then converts that shared distance through its own fitted depth span before comparison. This prevents blended cascades from producing spatially offset shadow edges solely because their orthographic depth ranges differ. +**Instanced casters:** Within a cascade, the casters that draw the same meshes share one draw per submesh (see [Instanced Draws](#instanced-draws)): each reads its model matrix from a buffer the cascade writes once. A caster with a skeleton or a deformation has vertices of its own and is drawn alone, as before. + The shadow map produced here is consumed by the TBDR light sub-pass inside `model`. **Point shadows:** `pointShadow` renders a cube depth map for the first point light with `castsShadow(true)`. It renders six 90-degree faces from the point light's world position and samples the cube map only for the matching point-light index. Because this costs six depth renders, the current milestone intentionally supports one active shadowed point light. @@ -203,10 +205,10 @@ This is the core of the tile-based deferred rendering (TBDR) pipeline. Opaque ge Attachments 0-4 are memoryless G-Buffer targets. They stay in tile memory and are not stored as full-screen textures during normal lit rendering. Attachment 5 is the lit scene-color target. -Inside `combinedModelLightExecution`, the unbatched model phase iterates `visibleEntityIds`. For each entity that is not batched: -- Binds vertex/index buffers -- Uploads the model matrix, normal matrix, and camera uniforms into the current in-flight frame slot -- Issues a draw call per mesh submesh +Inside `combinedModelLightExecution`, the unbatched model phase iterates `visibleEntityIds`. An entity that is not batched goes one of two ways: + +- **Drawn with the entities that draw the same thing.** Most entities have nothing of their own in a draw but their transform. They are gathered, and after the walk each group of them that shares its meshes and materials is encoded as one instanced draw per submesh (see [Instanced Draws](#instanced-draws)). +- **Drawn on its own.** An entity in the middle of a LOD, tile or mesh fade, one with a skeleton or a deformation, and every entity while a debug colouring is on, binds its vertex and index buffers, uploads its model matrix, normal matrix and the camera uniforms, and issues a draw call per submesh. Before encoding an entity's draws, the renderer checks scene-channel visibility. Individual entities take the render mode of their channels through the pass's scene snapshot (see [How a Pass Reads the Scene](#how-a-pass-reads-the-scene)); batch groups use their stored channel mask. Hidden channels are skipped entirely rather than rendered transparently. @@ -402,6 +404,24 @@ When a pass needs another component, add it to the snapshot instead of calling ` --- +## Instanced Draws + +A scene that places one model many times (the chairs of a hall, the trees of a site, the copies of a pack model) asks for the same draw over and over with a different transform. The opaque phase of `model` and the cascades of `shadow` gather the entities that draw the same thing and encode one draw for all of them: the vertex streams and the material are bound once, and each instance reads what is its own from a buffer. + +**What makes a group.** `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 with the same local transforms and, for every submesh, the same index data, textures, samplers and material values. `RenderInstanceKeyTable` gives the numbers. It compares the full description of a mesh list (`MeshListDescription`), not a hash of it, so two different materials never end up in one group. Nothing has to be declared: copies of a pack model, a thousand cubes made from one mesh, or two entities given equal materials by hand group by themselves. + +**What keeps a key honest.** Any change to `renderComponent.mesh` (a material edited, a texture streamed in, a LOD switch) clears the key in the property's `didSet`, and the key is looked up again the next time the entity is drawn. An entity whose material is edited leaves its group, and joins it again if the material is set back. + +**What is not instanced.** Whether an entity has something of its own in a draw is asked of the entity, each frame: a LOD, tile or mesh fade in progress, a skeleton, a deformation. Those are drawn on their own by the code that always drew them. So is every entity while a debug colouring is on, every transparent submesh (the transparency pass sorts them), and every entity when the setting is off: `setRendering(.instancedDraws(.disabled))`. + +**The draw.** `InstancedDrawList` holds the groups of a pass. Once the walk is over it lays out one record per instance (`ModelInstanceUniform`: model-view, model and normal matrices and the model matrix of the previous frame for the velocity; a model matrix alone for a shadow), writes them into a buffer borrowed from `InstanceRecordBufferPool` and encodes each group with `vertexModelInstancedShader` or `vertexShadowInstancedShader`. The instanced model pipelines take the model pipeline's own fragment function and function constants, so a group shades as a single draw does on every render path (deferred, hybrid, forward); a group whose material never discards goes through the instanced pipeline compiled without the discards, as a single draw does. The opacity of a ghost channel is the draw's, in the material parameters, so entities that differ in it are in groups of their own. The buffer is bound once per pass; each draw names its first record as its base instance. A group of one is a draw like any other. The command buffer's completion handler hands the buffer back to the pool, so a buffer is never written while the GPU may still read it. + +**The same picture.** The instanced vertex stages do the arithmetic of the single-entity ones on the same values and hand the fragment stage the same `VertexOutModel`, and the fragment function of both is `fragmentModelShader` itself. 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 (the texture footprint, the specular anti-aliasing) turn that into a visible difference at a quad or two along the silhouettes. `InstancedDrawRenderTests` holds the engine to that, on the deferred, hybrid and forward paths, the velocity of a moving instance included. The one thing that changes is the order of the draws (the entities drawn on their own, the batches, then the groups in the order their first entity came), which can change which of two exactly coplanar surfaces shows. + +**Adding a material value.** What an instanced draw binds for a submesh is prepared once per component (`InstancedSubmeshDraw`) from `RenderPasses.opaqueMaterialParameters(of:)`, the function the single-entity draw fills its parameter block with. A value added to `MaterialParametersUniform` and filled there reaches both ways of drawing. Add it to `MeshListDescription.addMaterial(of:)` as well, so that two materials that differ in it get different keys: `RenderInstanceKeyTests` fails until it is there. + +--- + ## Step 5: Graph Execution With the graph assembled, the engine sorts and executes it: From 7bce966f59ba7200633bd2759c6fb2d6220f0bc8 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Sun, 4 Oct 2026 13:17:12 +0200 Subject: [PATCH 04/19] [Performance] Walk the scene once for the directional shadows The shadow pass went over the entities of the scene once to fit the cascades and once more per cascade to choose its casters, working out the same world boxes each time. With tens of thousands of entities that was a fifth of the CPU time of a frame, shadows drawn or not. The pass now walks the scene once a frame. The walk gives the bounds the cascades are fitted to, which ShadowSystem.updateCascades takes from its caller when it has them, and the casters with their boxes, which each cascade then tests by distance and frustum without touching the scene again. A cascade draws the casters it drew before, in the same order. The fit makes the light-space box of a caster (eight transforms) only when the sphere around it (one transform) could overlap a cascade seen from the light. The sphere holds the box, so the cascades come out the same; DirectionalShadowWalkTests compares them with those the shadow system fits on its own. Whether an entity casts is read from its render component as the walk passes it. A caster made or switched since the last frame no longer waits for invalidateShadowEntityCache; the spot and point passes still use the candidate cache. --- .../UntoldEngine/Renderer/RenderPasses.swift | 225 +++++++++++------- .../UntoldEngine/Systems/ShadowSystem.swift | 100 ++++++-- .../DirectionalShadowWalkTests.swift | 164 +++++++++++++ 3 files changed, 384 insertions(+), 105 deletions(-) create mode 100644 Tests/UntoldEngineRenderTests/DirectionalShadowWalkTests.swift diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index 196f3fa2c..a6139e61a 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -16,7 +16,7 @@ import MetalKit public enum RenderPasses { /// Count of unbatched shadow-caster entities from the most recent frame. - /// Updated by shadowCasters(for:in:) (main thread); read by the streaming heartbeat (main thread). + /// Updated by the shadow pass for each cascade (main thread); read by the streaming heartbeat (main thread). public nonisolated(unsafe) static var lastShadowCasterCount: Int = 0 public typealias RenderPassExecution = @Sendable (MTLCommandBuffer) -> Void @@ -673,8 +673,10 @@ public enum RenderPasses { /// Rebuilds the shadow entity candidate list from the current ECS state. /// /// Only filters by stable properties (component presence). Per-frame culls — isVisible, - /// scene-channel visibility, distance, cascade frustum — are applied per-cascade in - /// shadowCasters(for:in:) so this scan runs at most once per dirty event, not every frame. + /// scene-channel visibility, distance, light frustum — are applied by the spot and point + /// shadow passes that read the list, so this scan runs at most once per dirty event, + /// not every frame. The directional pass does not use the list: it walks the scene once + /// a frame for the fit of its cascades, and finds its casters on the way. private static func rebuildShadowEntityCache() { let sceneSnapshot = RenderSceneSnapshot() @@ -729,101 +731,147 @@ public enum RenderPasses { return runtimeState.shadowEntityCandidates } - private static func shadowCasters(for cascadeIdx: Int, in sceneSnapshot: RenderSceneSnapshot) -> [ShadowCaster] { - ensureShadowCacheConfigured() - guard let frustum = shadowFrustum(for: cascadeIdx) else { return [] } - - // Conservative, direction-agnostic pre-reject: a caster farther than the engine's - // own shadow-distance horizon (maxShadowCastingDistance) from anything this cascade's - // camera-frustum slice could see cannot matter to this cascade, regardless of light - // direction — unlike a camera-depth cutoff, this never excludes the far/shallow-angle - // casters that motivated removing the old per-cascade distance cull, since it is - // measured from the cascade's own world-space bounding sphere, not the camera. - let cascadeCenter = shadowSystem.cascadeWorldCenters[cascadeIdx] - let cascadeReach = shadowSystem.cascadeWorldRadii[cascadeIdx] + RenderPasses.maxShadowCastingDistance - - // Rebuild candidate list if dirty. At most one rebuild per dirty event, shared - // across all cascade invocations in the same frame. - runtimeState.lock.lock() - let dirty = runtimeState.shadowCacheDirty - runtimeState.lock.unlock() - if dirty { - rebuildShadowEntityCache() - } + /// A caster of the directional light, with the bounds the cascades test it by. + private struct DirectionalShadowCaster { + let caster: ShadowCaster + /// The box around the caster in the scene's own space: what a cascade's frustum, + /// which has the scene root folded in, is tested against. + let worldMin: simd_float3 + let worldMax: simd_float3 + /// The same box with the scene root's transform applied: the space the cascades' + /// centres and radii are in. + let visualMin: simd_float3 + let visualMax: simd_float3 + } - runtimeState.lock.lock() - let candidates = runtimeState.shadowEntityCandidates - runtimeState.lock.unlock() + /// What a frame's directional shadows are made from, found in one walk of the scene. + /// + /// The cascades are fitted to the bounds of everything that is drawn (`fitBounds`), + /// and each cascade then draws the casters that reach it (`casters`). Both used to walk + /// the scene on their own, the casters once per cascade: with tens of thousands of + /// entities those walks were a fifth of the frame even when no shadow was drawn. + private struct DirectionalShadowWalk { + var fitBounds: [ShadowCasterBounds] = [] + var casters: [DirectionalShadowCaster] = [] + } - var result: [ShadowCaster] = [] - result.reserveCapacity(candidates.count / 4) + /// Walks the scene once for the directional light: see `DirectionalShadowWalk`. + /// + /// Whether an entity casts is read from its render component as the walk passes it, + /// so a caster made or switched on since the last frame is there without the + /// candidate cache of the spot and point passes having to be invalidated. + private static func gatherDirectionalShadowCasters(in sceneSnapshot: RenderSceneSnapshot) -> DirectionalShadowWalk { + var walk = DirectionalShadowWalk() + walk.fitBounds.reserveCapacity(sceneSnapshot.entityCapacity) + walk.casters.reserveCapacity(sceneSnapshot.entityCapacity / 4) // An object too small to be drawn casts a shadow about as small. let smallObjectCulling = SmallObjectCulling.forCurrentFrame() let batchingEnabled = BatchingSystem.shared.isEnabled() let sceneRootMatrix = SceneRootTransform.shared.matrix + let sceneRootIsIdentity = sceneRootMatrix == matrix_identity_float4x4 var renderModes = SceneChannelRenderModeMemo() - for entityId in candidates { - guard let entity = sceneSnapshot.entity(entityId) else { continue } + sceneSnapshot.forEachEntity(with: .drawable) { entity in + if !entity.traits.isDisjoint(with: [.sceneCamera, .camera, .light, .gizmo]) { return } let renderMode = renderModes.mode(of: sceneSnapshot.sceneChannels(of: entity)) - if renderMode == .hidden || renderMode == .wireframe { continue } - if batchingEnabled { - // Batch-eligible entities (StaticBatchComponent present) are always drawn - // via shadowCasterBatchGroups — whether or not the batch rebuild has landed - // yet. Skipping them here prevents O(n_loaded_tiles) shadow draw calls that - // scale with the scene and eventually overflow the GPU command buffer budget. - // During the brief batch-rebuild window their shadow is absent; this is - // preferable to the alternative of the app freezing at ~300+ loaded tiles. - // The exception is a mesh carrying an occluder shell or fade: it stays out of - // its batch for as long as the component is there, so it casts on its own. - if entity.traits.contains(.staticBatch), - entity.traits.isDisjoint(with: meshOccluderOrFade) - { continue } - if BatchingSystem.shared.isBatched(entityId: entityId) { continue } - } + if renderMode == .hidden || renderMode == .wireframe { return } + + // Batch-eligible entities (StaticBatchComponent present) are always drawn via + // shadowCasterBatchGroups, whether or not the batch rebuild has landed yet. + // Skipping them here prevents O(n_loaded_tiles) shadow draw calls that scale + // with the scene and eventually overflow the GPU command buffer budget. During + // the brief batch-rebuild window their shadow is absent; this is preferable to + // the alternative of the app freezing at ~300+ loaded tiles. The exception is a + // mesh carrying an occluder shell or fade: it stays out of its batch for as long + // as the component is there, so it casts on its own. The cascades are fitted to + // the batch groups' own bounds, not to those of their entities. + let batchEligible = batchingEnabled && entity.traits.contains(.staticBatch) + let batched = batchingEnabled && BatchingSystem.shared.isBatched(entityId: entity.entityId) + let fits = !batchEligible && !batched + let mayCast = !batched && !(batchEligible && entity.traits.isDisjoint(with: meshOccluderOrFade)) + guard fits || mayCast else { return } guard let components = sceneSnapshot.drawComponents(of: entity), components.render.isVisible - else { continue } - let worldTransformComponent = components.world - let localTransformComponent = components.local + else { return } + let casts = mayCast && components.render.castsShadow + guard fits || casts else { return } + let boundingBox = components.local.boundingBox let (worldMin, worldMax) = worldAABB_MinMax( - localMin: localTransformComponent.boundingBox.min, - localMax: localTransformComponent.boundingBox.max, - worldMatrix: worldTransformComponent.space - ) - if let smallObjectCulling, smallObjectCulling.culls(worldMin: worldMin, worldMax: worldMax) { continue } - // Directional-light caster relevance cannot be determined from camera - // distance or the cascade receiver split — a caster outside a cascade's - // camera-depth interval can still project a shadow into that interval. - // The world-space distance reject above stays correct for any light - // direction; the fitted light-space cascade frustum below is the - // correctness-preserving cull for what actually lands in the map. - // - // cascadeCenter/cascadeReach are in visual-world space (they come from the - // camera-frustum-derived cascade bounds), while worldMin/worldMax above are - // scene-local (needed as-is for the frustum test below, since `frustum` is built - // from a matrix that folds the scene-root transform back in). Fold the root - // transform into a second, visual-world copy of the bounds just for this check. - let (visualWorldMin, visualWorldMax) = worldAABB_MinMax( - localMin: localTransformComponent.boundingBox.min, - localMax: localTransformComponent.boundingBox.max, - worldMatrix: simd_mul(sceneRootMatrix, worldTransformComponent.space) + localMin: boundingBox.min, + localMax: boundingBox.max, + worldMatrix: components.world.space ) - if shadowEntityBeyondMaxDistance( - worldMin: visualWorldMin, worldMax: visualWorldMax, - cameraPosition: cascadeCenter, - maxDistance: cascadeReach - ) { continue } - if isAABBInFrustum(frustum, min: worldMin, max: worldMax) { - result.append(ShadowCaster(entity: entity, components: components)) + // The cascades' centres, radii and corners are in visual-world space (they come + // from the camera's frustum), while worldMin/worldMax are scene-local (needed + // as they are for the frustum test, since a cascade's frustum is built from a + // matrix that folds the scene-root transform back in). With a scene root that + // is not the identity, the box is made a second time in visual-world space. + var visualMin = worldMin + var visualMax = worldMax + if !sceneRootIsIdentity { + (visualMin, visualMax) = worldAABB_MinMax( + localMin: boundingBox.min, + localMax: boundingBox.max, + worldMatrix: simd_mul(sceneRootMatrix, components.world.space) + ) + } + + if fits { + walk.fitBounds.append(ShadowCasterBounds(min: visualMin, max: visualMax)) + } + if casts { + if let smallObjectCulling, smallObjectCulling.culls(worldMin: worldMin, worldMax: worldMax) { return } + walk.casters.append(DirectionalShadowCaster( + caster: ShadowCaster(entity: entity, components: components), + worldMin: worldMin, + worldMax: worldMax, + visualMin: visualMin, + visualMax: visualMax + )) } } + return walk + } - lastShadowCasterCount = result.count - return result + /// The test a cascade puts each caster of the frame to. + private struct CascadeCasterTest { + let frustum: Frustum + let center: simd_float3 + let reach: Float + + init?(cascadeIdx: Int) { + guard let frustum = shadowFrustum(for: cascadeIdx) else { return nil } + self.frustum = frustum + // Conservative, direction-agnostic pre-reject: a caster farther than the + // engine's own shadow-distance horizon (maxShadowCastingDistance) from + // anything this cascade's camera-frustum slice could see cannot matter to this + // cascade, regardless of light direction. Unlike a camera-depth cutoff, this + // never excludes the far/shallow-angle casters that motivated removing the old + // per-cascade distance cull, since it is measured from the cascade's own + // world-space bounding sphere, not the camera. + center = shadowSystem.cascadeWorldCenters[cascadeIdx] + reach = shadowSystem.cascadeWorldRadii[cascadeIdx] + RenderPasses.maxShadowCastingDistance + } + + /// Whether `caster` can cast into the cascade. + /// + /// Directional-light caster relevance cannot be determined from camera distance or + /// the cascade receiver split: a caster outside a cascade's camera-depth interval + /// can still project a shadow into that interval. The world-space distance reject + /// stays correct for any light direction; the fitted light-space cascade frustum + /// is the correctness-preserving cull for what actually lands in the map. + @inline(__always) + func accepts(_ caster: borrowing DirectionalShadowCaster) -> Bool { + if shadowEntityBeyondMaxDistance( + worldMin: caster.visualMin, worldMax: caster.visualMax, + cameraPosition: center, + maxDistance: reach + ) { return false } + return isAABBInFrustum(frustum, min: caster.worldMin, max: caster.worldMax) + } } private static func shadowCasterBatchGroups(for cascadeIdx: Int) -> [BatchGroup] { @@ -1459,7 +1507,14 @@ public enum RenderPasses { return } - shadowSystem.updateCascades() + // One walk of the scene serves the fit of the cascades and the casters of each. + // The cascades ask for it only when a directional light casts shadows. + let sceneSnapshot = RenderSceneSnapshot() + var shadowWalk = DirectionalShadowWalk() + shadowSystem.updateCascades(entityCasterBounds: { + shadowWalk = gatherDirectionalShadowCasters(in: sceneSnapshot) + return shadowWalk.fitBounds + }) guard shadowSystem.isActive else { return } guard !renderInfo.csmRenderPassDescriptors.isEmpty else { return } @@ -1472,7 +1527,6 @@ public enum RenderPasses { let viewMatrix = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) // What every draw of the pass shares, read once. - let sceneSnapshot = RenderSceneSnapshot() let cameraPosition = shadingCameraPosition(cameraComponent) let projectionMatrix = renderInfo.perspectiveSpace @@ -1520,7 +1574,12 @@ public enum RenderPasses { index: Int(shadowPassLightMatrixUniform.rawValue) ) - for caster in shadowCasters(for: cascadeIdx, in: sceneSnapshot) { + var casterCount = 0 + let cascadeTest = CascadeCasterTest(cascadeIdx: cascadeIdx) + for casterIndex in shadowWalk.casters.indices { + guard let cascadeTest, cascadeTest.accepts(shadowWalk.casters[casterIndex]) else { continue } + casterCount += 1 + let caster = shadowWalk.casters[casterIndex].caster let transformComponent = caster.components.world let deformation = sceneSnapshot.deformation(of: caster.entity) let hasSkeleton = caster.entity.traits.contains(.skeleton) @@ -1567,6 +1626,8 @@ public enum RenderPasses { } } + lastShadowCasterCount = casterCount + if let instancedPipeline { let counts = encodeInstancedShadowDraws( &instancedDraws, diff --git a/Sources/UntoldEngine/Systems/ShadowSystem.swift b/Sources/UntoldEngine/Systems/ShadowSystem.swift index 10fbc85a9..db9705f09 100644 --- a/Sources/UntoldEngine/Systems/ShadowSystem.swift +++ b/Sources/UntoldEngine/Systems/ShadowSystem.swift @@ -256,12 +256,14 @@ private func stableSpotShadowUp(for direction: simd_float3) -> simd_float3 { return simd_float3(1.0, 0.0, 0.0) } -struct ShadowSystem { - private struct ShadowCasterBounds { - var min: simd_float3 - var max: simd_float3 - } +/// The box around something the cascades are fitted to, in the space the cascade corners +/// are in (the scene root's transform folded in). +struct ShadowCasterBounds { + var min: simd_float3 + var max: simd_float3 +} +struct ShadowSystem { private struct LightSpaceBounds { var min: simd_float3 var max: simd_float3 @@ -412,6 +414,16 @@ struct ShadowSystem { /// Main per-frame update — computes one tight ortho matrix per cascade. mutating func updateCascades() { + updateCascades(entityCasterBounds: nil) + } + + /// Fits the cascades to the camera and to what can cast into them. + /// + /// - Parameter entityCasterBounds: the bounds of the entities the cascades are fitted + /// to, from a caller that walks the scene anyway (the shadow pass, which finds its + /// casters in the same walk). It is asked only when a directional light casts + /// shadows. Nil, the system walks the scene itself. + mutating func updateCascades(entityCasterBounds: (() -> [ShadowCasterBounds])?) { // In XR stereo mode the shadow map is shared between both eyes. // Only compute (and overwrite) on eye 0; eye 1 reuses the matrices. if renderInfo.isXRStereoMode && renderInfo.currentEye > 0 { @@ -488,9 +500,13 @@ struct ShadowSystem { ? simd_float3(0, 1, 0) : simd_float3(0, 0, 1) let lightView = matrix_look_at_right_hand(-lightForward * 150.0, simd_float3(0, 0, 0), lightUp) - let casterLightSpaceBounds = collectShadowCasterBounds().map { - lightSpaceBounds(for: $0, lightView: lightView) - } + // The light-space box of a caster costs eight transforms, and it only matters for + // a caster that overlaps a cascade seen from the light. The sphere around a caster + // costs one, and says which casters cannot: most of them, in a scene much larger + // than its cascades. The boxes of the others are made when a cascade first asks. + let casterBounds = collectShadowCasterBounds(entityBounds: entityCasterBounds?()) + let casterSpheres = casterBounds.map { lightSpaceSphere(for: $0, lightView: lightView) } + var casterLightSpaceBounds = [LightSpaceBounds?](repeating: nil, count: casterBounds.count) for i in 0 ..< cascadeCount { let cascadeFar = splits[i] @@ -557,7 +573,25 @@ struct ShadowSystem { } let casterXYMargin = max(texelSize * 4.0, diameter * 0.03) - for boundsLS in casterLightSpaceBounds { + for casterIndex in casterBounds.indices { + // The box lies inside the sphere: where the sphere does not reach the + // cascade, the box does not either. + let sphere = casterSpheres[casterIndex] + if sphere.center.x + sphere.radius < minX - casterXYMargin + || sphere.center.x - sphere.radius > maxX + casterXYMargin + || sphere.center.y + sphere.radius < minY - casterXYMargin + || sphere.center.y - sphere.radius > maxY + casterXYMargin + { + continue + } + + let boundsLS: LightSpaceBounds + if let made = casterLightSpaceBounds[casterIndex] { + boundsLS = made + } else { + boundsLS = lightSpaceBounds(for: casterBounds[casterIndex], lightView: lightView) + casterLightSpaceBounds[casterIndex] = boundsLS + } guard lightSpaceXYIntersects( boundsLS, minX: minX - casterXYMargin, @@ -636,12 +670,31 @@ struct ShadowSystem { /// directly, since their effect on cascade output (e.g. cascadeDepthSpans) can otherwise be /// masked by other, already-correct mechanisms like camera-frustum culling. func collectShadowCasterBoundsForTesting() -> [(min: simd_float3, max: simd_float3)] { - collectShadowCasterBounds().map { (min: $0.min, max: $0.max) } + collectShadowCasterBounds(entityBounds: nil).map { (min: $0.min, max: $0.max) } } - private func collectShadowCasterBounds() -> [ShadowCasterBounds] { - let sceneSnapshot = RenderSceneSnapshot() + /// The bounds of everything the cascades are fitted to: the entities (`entityBounds` + /// when the caller has them, else from a walk of the scene) and the batch groups. + private func collectShadowCasterBounds(entityBounds: [ShadowCasterBounds]?) -> [ShadowCasterBounds] { let batchingEnabled = BatchingSystem.shared.isEnabled() + var bounds = entityBounds ?? collectEntityShadowCasterBounds(batchingEnabled: batchingEnabled) + + if batchingEnabled { + for group in BatchingSystem.shared.batchGroups where shouldRenderSceneChannelsOpaque(group.sceneChannels) { + let (worldMin, worldMax) = worldAABB_MinMax( + localMin: group.boundingBox.min, + localMax: group.boundingBox.max, + worldMatrix: SceneRootTransform.shared.matrix + ) + bounds.append(ShadowCasterBounds(min: worldMin, max: worldMax)) + } + } + + return bounds + } + + private func collectEntityShadowCasterBounds(batchingEnabled: Bool) -> [ShadowCasterBounds] { + let sceneSnapshot = RenderSceneSnapshot() let sceneRootMatrix = SceneRootTransform.shared.matrix var renderModes = SceneChannelRenderModeMemo() @@ -673,20 +726,21 @@ struct ShadowSystem { bounds.append(ShadowCasterBounds(min: worldMin, max: worldMax)) } - if batchingEnabled { - for group in BatchingSystem.shared.batchGroups where shouldRenderSceneChannelsOpaque(group.sceneChannels) { - let (worldMin, worldMax) = worldAABB_MinMax( - localMin: group.boundingBox.min, - localMax: group.boundingBox.max, - worldMatrix: SceneRootTransform.shared.matrix - ) - bounds.append(ShadowCasterBounds(min: worldMin, max: worldMax)) - } - } - return bounds } + /// The sphere around a caster's box, in light space: where its centre lands and a + /// radius that holds every corner. The radius is taken a little large, so that the + /// rounding of the transform cannot leave a corner outside. + private func lightSpaceSphere( + for caster: ShadowCasterBounds, + lightView: simd_float4x4 + ) -> (center: simd_float2, radius: Float) { + let center = lightView * simd_float4((caster.min + caster.max) * 0.5, 1.0) + let radius = simd_length(caster.max - caster.min) * 0.5 + return (simd_float2(center.x, center.y), radius * 1.001 + 0.001) + } + private func lightSpaceBounds( for caster: ShadowCasterBounds, lightView: simd_float4x4 diff --git a/Tests/UntoldEngineRenderTests/DirectionalShadowWalkTests.swift b/Tests/UntoldEngineRenderTests/DirectionalShadowWalkTests.swift new file mode 100644 index 000000000..b860a5e95 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/DirectionalShadowWalkTests.swift @@ -0,0 +1,164 @@ +// +// DirectionalShadowWalkTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import simd +@testable import UntoldEngine +import XCTest + +/// The shadow pass walks the scene once a frame for the directional light: the cascades +/// are fitted to what the walk finds, and each cascade draws the casters that reach it. +/// These tests pin that the walk fits the cascades as the shadow system does on its own, +/// and who is a caster. +@MainActor +final class DirectionalShadowWalkTests: BaseRenderSetup { + private var savedPixels: Float = 1 + + override func setUp() async throws { + try await super.setUp() + savedPixels = SmallObjectCulling.minimumPixels + setRendering(.smallObjectCulling(pixels: 1)) + } + + override func tearDown() async throws { + SmallObjectCulling.minimumPixels = savedPixels + setSceneChannel(.ghostGeometry, .renderMode(.normal)) + destroyAllEntities() + try await super.tearDown() + } + + override func initializeAssets() { + let camera = createEntity() + createGameCamera(entityId: camera) + CameraSystem.shared.activeCamera = camera + cameraLookAt(entityId: camera, eye: simd_float3(0, 6, 16), target: .zero, up: simd_float3(0, 1, 0)) + + let sun = createEntity() + createDirLight(entityId: sun) + setSunElevationAzimuth(entityId: sun, elevation: 50, azimuth: 30) + } + + // MARK: - Helpers + + @discardableResult + private func makeCube(at position: simd_float3, scale: simd_float3 = simd_float3(repeating: 1)) -> EntityID { + let entity = createEntity() + setEntityMeshDirect(entityId: entity, meshes: BasicPrimitives.createCube(extent: 1.0), assetName: "Cube") + scaleTo(entityId: entity, scale: scale) + translateTo(entityId: entity, position: position) + return entity + } + + private func makeGround() -> EntityID { + makeCube(at: simd_float3(0, -1.2, 0), scale: simd_float3(40, 0.2, 40)) + } + + /// Draws enough frames for the culling result of the scene to come back and be used. + private func drawFrames() { + for _ in 0 ..< 6 { + renderer.draw(in: renderer.metalView) + renderInfo.lastCommandBuffer?.waitUntilCompleted() + } + } + + /// The casters drawn into the cascades of the last frame, counted once per cascade + /// they were drawn into. + private var castersDrawn: Int { + RenderPasses.lastShadowInstancedDrawCounts.instances + } + + // MARK: - The fit + + func testThePassFitsTheCascadesAsTheShadowSystemDoesOnItsOwn() { + _ = makeGround() + for index in 0 ..< 6 { + makeCube(at: simd_float3(Float(index % 3) * 4 - 4, 0.4, Float(index / 3) * -4 + 2), scale: simd_float3(0.8, 3, 0.8)) + } + // What shapes the fit without casting, or casts without being drawn large: + // a roof that casts no shadow, a speck under a pixel, a hidden entity. + let roof = makeCube(at: simd_float3(0, 12, 0), scale: simd_float3(10, 0.2, 10)) + scene.get(component: RenderComponent.self, for: roof)?.castsShadow = false + makeCube(at: simd_float3(2, 1, 4), scale: simd_float3(repeating: 0.005)) + let hidden = makeCube(at: simd_float3(-6, 20, 0), scale: simd_float3(4, 4, 4)) + setEntitySceneChannels(entityId: hidden, channels: .ghostGeometry) + setSceneChannel(.ghostGeometry, .renderMode(.hidden)) + + drawFrames() + XCTAssertTrue(shadowSystem.isActive) + XCTAssertGreaterThan(castersDrawn, 0, "the posts cast") + let matricesFromThePass = shadowSystem.cascadeLightSpaceMatrices + let depthSpansFromThePass = shadowSystem.cascadeDepthSpans + + // The shadow system, walking the scene by itself. + shadowSystem.updateCascades() + + XCTAssertEqual(shadowSystem.cascadeLightSpaceMatrices, matricesFromThePass) + XCTAssertEqual(shadowSystem.cascadeDepthSpans, depthSpansFromThePass) + } + + func testWhatDoesNotCastStillShapesTheCascades() throws { + _ = makeGround() + makeCube(at: simd_float3(0, 0.4, 0), scale: simd_float3(0.8, 3, 0.8)) + drawFrames() + let depthSpans = shadowSystem.cascadeDepthSpans + let casters = castersDrawn + XCTAssertGreaterThan(casters, 0) + + // A roof far above that casts no shadow: the cascades reach up to it, as they did + // before the pass found its casters in the same walk, and nothing more is drawn. + // It is wide enough to be over the first cascade from wherever the light looks. + let roof = makeCube(at: simd_float3(0, 60, 0), scale: simd_float3(300, 0.2, 300)) + try XCTUnwrap(scene.get(component: RenderComponent.self, for: roof)).castsShadow = false + drawFrames() + + XCTAssertGreaterThan(shadowSystem.cascadeDepthSpans[0], depthSpans[0] + 20) + XCTAssertEqual(castersDrawn, casters) + } + + // MARK: - The casters + + func testACasterMadeAfterAFrameCastsInTheNextOne() throws { + _ = makeGround() + drawFrames() + let casters = castersDrawn + + // No invalidation of the candidate cache: the walk reads the scene as it is. + let post = makeCube(at: simd_float3(0, 0.4, 0), scale: simd_float3(0.8, 3, 0.8)) + drawFrames() + XCTAssertGreaterThan(castersDrawn, casters, "the new cube casts") + let castersWithThePost = castersDrawn + + // And it stops as soon as it is told not to cast. + try XCTUnwrap(scene.get(component: RenderComponent.self, for: post)).castsShadow = false + drawFrames() + XCTAssertEqual(castersDrawn, casters) + + try XCTUnwrap(scene.get(component: RenderComponent.self, for: post)).castsShadow = true + drawFrames() + XCTAssertEqual(castersDrawn, castersWithThePost) + } + + func testACasterOutOfReachOfTheCascadesIsNotDrawn() { + _ = makeGround() + makeCube(at: simd_float3(0, 0.4, 0), scale: simd_float3(0.8, 3, 0.8)) + drawFrames() + let casters = castersDrawn + + // Far past the shadow distance from anything the cascades cover. + makeCube(at: simd_float3(0, 0.4, -400), scale: simd_float3(4, 4, 4)) + // Hidden: not a caster either. + let hidden = makeCube(at: simd_float3(3, 0.4, 0), scale: simd_float3(0.8, 3, 0.8)) + setEntitySceneChannels(entityId: hidden, channels: .ghostGeometry) + setSceneChannel(.ghostGeometry, .renderMode(.hidden)) + drawFrames() + + XCTAssertGreaterThan(casters, 0) + XCTAssertEqual(castersDrawn, casters) + } +} From 6daceb96d487eb1dd8effde14fbb2cf8fa87a746 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Sun, 4 Oct 2026 13:56:12 +0200 Subject: [PATCH 05/19] [Performance] Remember whether a render component has blended submeshes The transparency pass asked every visible entity whether any of its submeshes is blended by going through copies of its meshes and their materials, every frame, to find in nearly every case that none is. A render component now keeps the answer until its meshes change (the didSet that clears its instance key clears this too), and the pass asks it before anything else about the entity. --- Sources/UntoldEngine/ECS/Components.swift | 23 ++++ .../UntoldEngine/Renderer/RenderPasses.swift | 22 ++-- .../BlendedSubmeshFlagTests.swift | 104 ++++++++++++++++++ 3 files changed, 136 insertions(+), 13 deletions(-) create mode 100644 Tests/UntoldEngineRenderTests/BlendedSubmeshFlagTests.swift diff --git a/Sources/UntoldEngine/ECS/Components.swift b/Sources/UntoldEngine/ECS/Components.swift index 6d855c0ec..2610aa160 100644 --- a/Sources/UntoldEngine/ECS/Components.swift +++ b/Sources/UntoldEngine/ECS/Components.swift @@ -61,9 +61,32 @@ public class RenderComponent: Component { if instancedShadowDraws != nil { instancedShadowDraws = nil } + blendedSubmeshes = nil } } + /// Whether any submesh of `mesh` is blended or lets light through: what the + /// transparency pass draws of the entity. Most entities have none, and the pass asks + /// every visible one each frame, so the answer is kept until the meshes change. + var hasBlendedSubmeshes: Bool { + if let known = blendedSubmeshes { + return known + } + var found = false + search: for meshIndex in mesh.indices { + for submeshIndex in mesh[meshIndex].submeshes.indices + where mesh[meshIndex].submeshes[submeshIndex].material?.hasTransparency == true + { + found = true + break search + } + } + blendedSubmeshes = found + return found + } + + private var blendedSubmeshes: Bool? + /// What `mesh` draws, as a number the renderer groups instanced draws by: see /// `RenderInstanceKeyTable`. Any change to `mesh` clears it. var instanceKey: RenderInstanceKey = .unresolved diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index a6139e61a..f063e71c5 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -4069,6 +4069,15 @@ public enum RenderPasses { for entityId in visibleEntityIds { guard let entity = sceneSnapshot.entity(entityId) else { continue } + // Nearly every entity has nothing blended to draw: that is asked first, and + // the render component remembers the answer. + guard let renderComponent = sceneSnapshot.render(of: entity) else { + continue + } + if !renderComponent.hasBlendedSubmeshes { + continue + } + let renderMode = renderModes.mode(of: sceneSnapshot.sceneChannels(of: entity)) if renderMode == .hidden || renderMode == .wireframe { continue } @@ -4080,23 +4089,10 @@ public enum RenderPasses { // Blend submeshes go with the colour (there is no dither path here). if meshSkipsColor(entity, in: sceneSnapshot) { continue } - guard let renderComponent = sceneSnapshot.render(of: entity) else { - continue - } guard let worldTransformComponent = sceneSnapshot.worldTransform(of: entity) else { continue } - let hasTransparentSubmesh = renderComponent.mesh.contains { mesh in - mesh.submeshes.contains { submesh in - submesh.material?.hasTransparency ?? false - } - } - - if !hasTransparentSubmesh { - continue - } - let worldPosition = simd_float3( worldTransformComponent.space.columns.3.x, worldTransformComponent.space.columns.3.y, diff --git a/Tests/UntoldEngineRenderTests/BlendedSubmeshFlagTests.swift b/Tests/UntoldEngineRenderTests/BlendedSubmeshFlagTests.swift new file mode 100644 index 000000000..e109689cb --- /dev/null +++ b/Tests/UntoldEngineRenderTests/BlendedSubmeshFlagTests.swift @@ -0,0 +1,104 @@ +// +// BlendedSubmeshFlagTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import simd +@testable import UntoldEngine +import XCTest + +/// The transparency pass asks every visible entity whether it has anything blended to +/// draw. A render component remembers the answer until its meshes change: these tests +/// pin that the answer follows the materials, and that the pass draws by it. +@MainActor +final class BlendedSubmeshFlagTests: BaseRenderSetup { + override func tearDown() async throws { + destroyAllEntities() + try await super.tearDown() + } + + override func initializeAssets() { + let camera = createEntity() + createGameCamera(entityId: camera) + CameraSystem.shared.activeCamera = camera + cameraLookAt(entityId: camera, eye: simd_float3(0, 2, 8), target: .zero, up: simd_float3(0, 1, 0)) + } + + private func material(_ alphaMode: MaterialAlphaMode) -> Material { + var material = Material( + runtimeMaterial: RuntimeMaterialSource( + baseColorFactor: simd_float4(0.3, 0.6, 0.9, 0.5), + emissiveFactor: .zero, + metallicFactor: 0, + roughnessFactor: 0.5 + ), + device: renderInfo.device + ) + material.alphaMode = alphaMode + return material + } + + /// A copy of `meshes` whose submeshes all have `material`. + private func meshes(_ meshes: [Mesh], with material: Material?) -> [Mesh] { + var copy = meshes + for meshIndex in copy.indices { + for submeshIndex in copy[meshIndex].submeshes.indices { + copy[meshIndex].submeshes[submeshIndex].material = material + } + } + return copy + } + + private func transparentDraws() -> Int { + for _ in 0 ..< 6 { + renderer.draw(in: renderer.metalView) + renderInfo.lastCommandBuffer?.waitUntilCompleted() + } + return RenderStatsCollector.shared.snapshot().drawCallsTransparent + } + + func testTheAnswerFollowsTheMaterials() { + let cube = BasicPrimitives.createCube(extent: 1) + let component = RenderComponent() + XCTAssertFalse(component.hasBlendedSubmeshes, "no meshes") + + component.mesh = meshes(cube, with: material(.opaque)) + XCTAssertFalse(component.hasBlendedSubmeshes) + + component.mesh[0].submeshes[0].material?.alphaMode = .blend + XCTAssertTrue(component.hasBlendedSubmeshes, "one material changed where it is") + + component.mesh[0].submeshes[0].material?.alphaMode = .mask + XCTAssertFalse(component.hasBlendedSubmeshes, "a cut-out is drawn by the opaque pass") + + // One blended submesh among several meshes is enough. + component.mesh = meshes(cube, with: material(.opaque)) + meshes(cube, with: nil) + meshes(cube, with: material(.blend)) + XCTAssertTrue(component.hasBlendedSubmeshes) + + component.mesh.removeLast() + XCTAssertFalse(component.hasBlendedSubmeshes) + } + + func testTheTransparencyPassDrawsWhatBecomesBlended() { + let solid = createEntity() + setEntityMeshDirect(entityId: solid, meshes: meshes(BasicPrimitives.createCube(extent: 1), with: material(.opaque)), assetName: "Solid") + translateTo(entityId: solid, position: simd_float3(-1.5, 0, 0)) + let glass = createEntity() + setEntityMeshDirect(entityId: glass, meshes: meshes(BasicPrimitives.createCube(extent: 1), with: material(.blend)), assetName: "Glass") + translateTo(entityId: glass, position: simd_float3(1.5, 0, 0)) + + XCTAssertEqual(transparentDraws(), 1, "the glass cube") + + // The solid cube turns to glass between two frames, then back. + XCTAssertTrue(updateMaterial(entityId: solid) { $0.alphaMode = .blend }) + XCTAssertEqual(transparentDraws(), 2) + + XCTAssertTrue(updateMaterial(entityId: solid) { $0.alphaMode = .opaque }) + XCTAssertEqual(transparentDraws(), 1) + } +} From 01a90fb6f80ed172ca4d8a4e04c539fdf962566b Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Sun, 4 Oct 2026 13:56:12 +0200 Subject: [PATCH 06/19] [Performance] Select LOD levels through a snapshot of the scene LODSystem.update asked the scene for the LOD component and the two transform components of every entity with levels, each a lock, a copy of the scene and a component-id lookup. It now reads them through one RenderSceneSnapshot, as the render passes do. The fade transitions are advanced the same way. entityDistanceToCamera and entityDistanceAndRadius gain a form that takes the components; the forms that take an entity id call it. --- Sources/UntoldEngine/Systems/LODSystem.swift | 91 +++++++++++++++----- 1 file changed, 68 insertions(+), 23 deletions(-) diff --git a/Sources/UntoldEngine/Systems/LODSystem.swift b/Sources/UntoldEngine/Systems/LODSystem.swift index 681344fe4..3ce4d18cc 100644 --- a/Sources/UntoldEngine/Systems/LODSystem.swift +++ b/Sources/UntoldEngine/Systems/LODSystem.swift @@ -63,25 +63,33 @@ public class LODSystem: @unchecked Sendable { ? lodScreenSizeReach(eyeProjections: [renderInfo.xrEye0Projection, renderInfo.xrEye1Projection]) : lodScreenSizeReach(projection: renderInfo.perspectiveSpace) - // Query entities with LOD components - let lodId = getComponentId(for: LODComponent.self) - let transformId = getComponentId(for: WorldTransformComponent.self) - let entities = queryEntitiesWithComponentIds([lodId, transformId], in: scene) - - for entityId in entities { - updateEntityLOD(entityId: entityId, cameraPosition: cameraPosition, screenSizeReach: screenSizeReach, config: config) + // The entities with levels are read through one snapshot of the scene: asking + // the scene for each component of each entity costs a lock and a copy every time, + // and a scene can hold tens of thousands of them. + let sceneSnapshot = RenderSceneSnapshot() + sceneSnapshot.forEachEntity(with: [.lod, .worldTransform]) { entity in + guard let lodComponent = sceneSnapshot.lod(of: entity) else { return } + updateEntityLOD( + entityId: entity.entityId, + lodComponent: lodComponent, + worldTransform: sceneSnapshot.worldTransform(of: entity), + localTransform: sceneSnapshot.localTransform(of: entity), + cameraPosition: cameraPosition, + screenSizeReach: screenSizeReach, + config: config + ) } } private func advanceActiveTransitions(deltaTime: Float) { - let lodId = getComponentId(for: LODComponent.self) - let entities = queryEntitiesWithComponentIds([lodId], in: scene) let transitionDuration = max(LODConfig.shared.fadeTransitionTime, 0.001) - for entityId in entities { - guard let lodComponent = scene.get(component: LODComponent.self, for: entityId), + let sceneSnapshot = RenderSceneSnapshot() + sceneSnapshot.forEachEntity(with: .lod) { entity in + let entityId = entity.entityId + guard let lodComponent = sceneSnapshot.lod(of: entity), lodComponent.previousLOD != nil - else { continue } + else { return } withWorldMutationGate { lodComponent.transitionProgress += deltaTime / transitionDuration @@ -112,9 +120,15 @@ public class LODSystem: @unchecked Sendable { } } - private func updateEntityLOD(entityId: EntityID, cameraPosition: simd_float3, screenSizeReach: Float?, config: LODConfig) { - guard let lodComponent = scene.get(component: LODComponent.self, for: entityId) else { return } - + private func updateEntityLOD( + entityId: EntityID, + lodComponent: LODComponent, + worldTransform: WorldTransformComponent?, + localTransform: LocalTransformComponent?, + cameraPosition: simd_float3, + screenSizeReach: Float?, + config: LODConfig + ) { // Skip if no LOD levels loaded yet (async loading may still be in progress) guard !lodComponent.lodLevels.isEmpty else { return } @@ -129,7 +143,8 @@ public class LODSystem: @unchecked Sendable { if lodComponent.selectsByScreenSize, let screenSizeReach { // Select by the size of the entity on screen, as it is now let (distance, radius) = entityDistanceAndRadius( - entityId: entityId, + worldTransform: worldTransform, + localTransform: localTransform, cameraPosition: cameraPosition, localRadius: lodComponent.screenSizeRadius ) @@ -145,7 +160,11 @@ public class LODSystem: @unchecked Sendable { ) } else { // Calculate distance - let distance = entityDistanceToCamera(entityId: entityId, cameraPosition: cameraPosition) + let distance = entityDistanceToCamera( + worldTransform: worldTransform, + localTransform: localTransform, + cameraPosition: cameraPosition + ) // Select desired LOD level based on distance desiredLOD = selectLODLevel( @@ -326,9 +345,21 @@ func findFallbackLODLevel(_ levels: [some LODResidencyLevel], from desiredIndex: /// Shared by `LODSystem` and `GaussianLODSystem` — distance from the camera to an entity's /// local-space bounding-box center, in world space. func entityDistanceToCamera(entityId: EntityID, cameraPosition: simd_float3) -> Float { - guard let worldTransform = scene.get(component: WorldTransformComponent.self, for: entityId), - let localTransform = scene.get(component: LocalTransformComponent.self, for: entityId) - else { return 0.0 } + entityDistanceToCamera( + worldTransform: scene.get(component: WorldTransformComponent.self, for: entityId), + localTransform: scene.get(component: LocalTransformComponent.self, for: entityId), + cameraPosition: cameraPosition + ) +} + +/// `entityDistanceToCamera(entityId:cameraPosition:)` for a caller that has the entity's +/// transform components at hand. Zero when either is missing. +func entityDistanceToCamera( + worldTransform: WorldTransformComponent?, + localTransform: LocalTransformComponent?, + cameraPosition: simd_float3 +) -> Float { + guard let worldTransform, let localTransform else { return 0.0 } let boundingBox = localTransform.boundingBox let localCenter = (boundingBox.min + boundingBox.max) * 0.5 @@ -341,9 +372,23 @@ func entityDistanceToCamera(entityId: EntityID, cameraPosition: simd_float3) -> /// entity's world transform, or the sphere around its bounding box when `localRadius` /// is 0. func entityDistanceAndRadius(entityId: EntityID, cameraPosition: simd_float3, localRadius: Float) -> (distance: Float, radius: Float) { - guard let worldTransform = scene.get(component: WorldTransformComponent.self, for: entityId), - let localTransform = scene.get(component: LocalTransformComponent.self, for: entityId) - else { return (0.0, 0.0) } + entityDistanceAndRadius( + worldTransform: scene.get(component: WorldTransformComponent.self, for: entityId), + localTransform: scene.get(component: LocalTransformComponent.self, for: entityId), + cameraPosition: cameraPosition, + localRadius: localRadius + ) +} + +/// `entityDistanceAndRadius(entityId:cameraPosition:localRadius:)` for a caller that has +/// the entity's transform components at hand. Zeros when either is missing. +func entityDistanceAndRadius( + worldTransform: WorldTransformComponent?, + localTransform: LocalTransformComponent?, + cameraPosition: simd_float3, + localRadius: Float +) -> (distance: Float, radius: Float) { + guard let worldTransform, let localTransform else { return (0.0, 0.0) } let boundingBox = localTransform.boundingBox let localCenter = (boundingBox.min + boundingBox.max) * 0.5 From 803f7c784cd9409e1e69acae3c3618c1ac1c31b6 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Sun, 4 Oct 2026 13:56:12 +0200 Subject: [PATCH 07/19] [Performance] Narrow the streaming update to the entities that stream Every tick, GeometryStreamingSystem.update asks the octree for the entities within 500 units and goes through them six times, asking the scene each time whether the entity exists and has a StreamingComponent or a TileComponent. In a scene loaded whole none has either, and all of it was spent finding that out. The entities the query returns are now narrowed once, with one read of the scene, to those that exist and have one of the two components. The passes see the entities they acted on before. The checks that ask whether the query returned an entity still answer for the whole query; the set they use is only made when a pass has an entity to ask about. --- .../Systems/GeometryStreamingSystem.swift | 42 +++++++++++++++---- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift b/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift index 7313b05b1..7fdecf0ab 100644 --- a/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift +++ b/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift @@ -762,7 +762,12 @@ public class GeometryStreamingSystem: @unchecked Sendable { ) } - let nearbyEntities = OctreeSystem.shared.queryNear(point: effectiveCameraPosition, radius: maxQueryRadius) + // Everything below concerns entities that stream (a StreamingComponent) or are + // tiles (a TileComponent). The query returns every entity within its radius, and + // in a scene loaded whole none of them is either: narrowing the list once keeps + // the passes from asking the scene about each of them, six times over. + let queriedEntities = OctreeSystem.shared.queryNear(point: effectiveCameraPosition, radius: maxQueryRadius) + let nearbyEntities = entitiesThatStream(among: queriedEntities) // Build a padded frustum once per tick for the load-gate test. // nil when no camera is available; the gate is simply skipped that tick. @@ -1371,7 +1376,15 @@ public class GeometryStreamingSystem: @unchecked Sendable { // Also check loaded entities that might now be out of range // (they may not be in the octree query if they're far away) - let nearbySet = Set(nearbyEntities) // O(1) lookup + // Whether the query returned an entity, streaming or not. The set is only made if + // one of the passes below has an entity to ask about. + var queriedSet: Set? + func wasQueried(_ entityId: EntityID) -> Bool { + if queriedSet == nil { + queriedSet = Set(queriedEntities) + } + return queriedSet?.contains(entityId) ?? false + } // ── Tile unload pass ─────────────────────────────────────────────────── // Grace period: both .parsed and .parsing tiles stay resident for @@ -1417,7 +1430,7 @@ public class GeometryStreamingSystem: @unchecked Sendable { var staleTileIds: [EntityID] = [] let tileSnapshot = loadedTileEntitiesSnapshot() for entityId in tileSnapshot { - if nearbySet.contains(entityId) { continue } // already handled in pass 1 + if wasQueried(entityId) { continue } // already handled in pass 1 guard scene.exists(entityId) else { staleTileIds.append(entityId) continue @@ -1448,7 +1461,7 @@ public class GeometryStreamingSystem: @unchecked Sendable { // approaching camera does not cancel a parse it is about to need. let loadingTileSnapshot = loadingTileEntitiesSnapshot() for entityId in loadingTileSnapshot { - if nearbySet.contains(entityId) { continue } // already handled in pass 1 + if wasQueried(entityId) { continue } // already handled in pass 1 guard scene.exists(entityId) else { unmarkLoadingTileEntity(entityId) continue @@ -1480,7 +1493,7 @@ public class GeometryStreamingSystem: @unchecked Sendable { // appear in nearbyEntities so the HLOD pass above cannot reach them. let hlodSnapshot = loadedHLODEntitiesSnapshot() for entityId in hlodSnapshot { - if nearbySet.contains(entityId) { continue } // already handled above + if wasQueried(entityId) { continue } // already handled above guard scene.exists(entityId) else { unmarkLoadedHLODEntity(entityId) continue @@ -1493,7 +1506,7 @@ public class GeometryStreamingSystem: @unchecked Sendable { // the octree query radius are cleaned up here. let lodSnapshot = loadedLODEntitiesSnapshot() for entityId in lodSnapshot { - if nearbySet.contains(entityId) { continue } + if wasQueried(entityId) { continue } guard scene.exists(entityId) else { unmarkLoadedLODEntity(entityId) continue @@ -1506,7 +1519,7 @@ public class GeometryStreamingSystem: @unchecked Sendable { let trackedLoadedSnapshot = loadedStreamingEntitiesSnapshot() for entityId in trackedLoadedSnapshot { // Skip if already processed via octree query - if nearbySet.contains(entityId) { continue } + if wasQueried(entityId) { continue } // Check if entity still exists first (handles destroyed/recreated entities) guard scene.exists(entityId) else { @@ -1731,7 +1744,7 @@ public class GeometryStreamingSystem: @unchecked Sendable { diagnostics.updateFrame = currentFrame diagnostics.updateTriggered = true diagnostics.updateWorkMs = updateWorkMs - diagnostics.nearbyEntitiesQueried = nearbyEntities.count + diagnostics.nearbyEntitiesQueried = queriedEntities.count diagnostics.unloadCandidates = unloadCandidates.count diagnostics.processedUnloads = processedUnloads diagnostics.loadCandidates = loadCandidates.count @@ -2511,6 +2524,19 @@ public class GeometryStreamingSystem: @unchecked Sendable { } } + /// The entities of `entities` that still exist and stream or are tiles: the ones the + /// passes of `update` look for when they ask the scene for a StreamingComponent or a + /// TileComponent. One read of the scene answers for all of them. + private func entitiesThatStream(among entities: [EntityID]) -> [EntityID] { + let currentScene = scene + let streamingId = getComponentId(for: StreamingComponent.self) + let tileId = getComponentId(for: TileComponent.self) + return entities.filter { entityId in + guard let mask = currentScene.mask(for: entityId) else { return false } + return mask.test(streamingId) || mask.test(tileId) + } + } + private func auditTileRepresentationDiagnostics( nearbyEntities: [EntityID], cameraPosition: simd_float3, From aa0fa0f4f9ecefc24763c23b69b651629342e2b8 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Sun, 4 Oct 2026 13:56:12 +0200 Subject: [PATCH 08/19] [Performance] Gather the culling bounds without an array per entity The culling gather made the world box of every entity twice (once for the small-object test, once for the cull) and built an array per entity to hold its one to three bounding boxes before appending them to the list for the GPU. The box is now made once, and appendSegmentedEntityAABBs adds an entity's bounding boxes to the list in place. makeSegmentedEntityAABBs is kept and calls it. --- .../UntoldEngine/Systems/CullingSystem.swift | 97 +++++++++++-------- 1 file changed, 54 insertions(+), 43 deletions(-) diff --git a/Sources/UntoldEngine/Systems/CullingSystem.swift b/Sources/UntoldEngine/Systems/CullingSystem.swift index 39b116ffa..e8d59cba0 100644 --- a/Sources/UntoldEngine/Systems/CullingSystem.swift +++ b/Sources/UntoldEngine/Systems/CullingSystem.swift @@ -142,7 +142,20 @@ func makeSegmentedEntityAABBs(localMin: simd_float3, index: UInt32, version: UInt32) -> [EntityAABB] { let (center, halfExtent) = worldAABB_CenterExtent(localMin: localMin, localMax: localMax, worldMatrix: M) + var result: [EntityAABB] = [] + appendSegmentedEntityAABBs(center: center, halfExtent: halfExtent, index: index, version: version, to: &result) + return result +} +/// `makeSegmentedEntityAABBs` for a caller that has the world box of the entity at hand +/// and a list to add to: the culling gather, which does this for every entity of the +/// scene each frame and would otherwise make an array per entity. +@inline(__always) +func appendSegmentedEntityAABBs(center: simd_float3, + halfExtent: simd_float3, + index: UInt32, version: UInt32, + to container: inout [EntityAABB]) +{ // Find the dominant (longest) half-extent axis. let dominantAxis: Int if halfExtent.x >= halfExtent.y && halfExtent.x >= halfExtent.z { @@ -160,16 +173,15 @@ func makeSegmentedEntityAABBs(localMin: simd_float3, guard (dominantHalf / minShort) > kSegmentAspectThreshold else { // Not elongated – emit a single AABB. - return [EntityAABB( + container.append(EntityAABB( center: simd_float4(center.x, center.y, center.z, 0), halfExtent: simd_float4(halfExtent.x, halfExtent.y, halfExtent.z, 0), index: index, version: version, pad0: 0, pad1: 0 - )] + )) + return } // Split into kSegmentCount equal segments along the dominant axis. - var result: [EntityAABB] = [] - result.reserveCapacity(kSegmentCount) let segHalfLen = dominantHalf / Float(kSegmentCount) let startOffset = -dominantHalf + segHalfLen // offset of first segment centre from entity centre @@ -179,13 +191,12 @@ func makeSegmentedEntityAABBs(localMin: simd_float3, var segHalf = halfExtent segHalf[dominantAxis] = segHalfLen - result.append(EntityAABB( + container.append(EntityAABB( center: simd_float4(segCenter.x, segCenter.y, segCenter.z, 0), halfExtent: simd_float4(segHalf.x, segHalf.y, segHalf.z, 0), index: index, version: version, pad0: 0, pad1: 0 )) } - return result } public func performFrustumCulling(commandBuffer: MTLCommandBuffer) { @@ -680,16 +691,17 @@ public func executeFrustumCulling(_ commandBuffer: MTLCommandBuffer) { return } + // The entity's world box, made once for the size test and for the cull. + let boundingBox = localTransformComponent.boundingBox + let (center, halfExtent) = worldAABB_CenterExtent( + localMin: boundingBox.min, + localMax: boundingBox.max, + worldMatrix: worldTransformComponent.space + ) + // Objects that would be a pixel or so tall are not worth a draw. - if let smallObjectCulling { - let (center, halfExtent) = worldAABB_CenterExtent( - localMin: localTransformComponent.boundingBox.min, - localMax: localTransformComponent.boundingBox.max, - worldMatrix: worldTransformComponent.space - ) - if smallObjectCulling.culls(center: center, radius: simd_length(halfExtent)) { - return - } + if let smallObjectCulling, smallObjectCulling.culls(center: center, radius: simd_length(halfExtent)) { + return } objects.insert(sceneSnapshot.object(of: entity)) @@ -700,25 +712,24 @@ public func executeFrustumCulling(_ commandBuffer: MTLCommandBuffer) { // AABB has a conservative nearDepth (closest corner of the full entity) that // is far less likely to fall behind a stale occluder in the HZB. if isXRStereoMode { - let singleAABB = makeObjectAABB( - localMin: localTransformComponent.boundingBox.min, - localMax: localTransformComponent.boundingBox.max, - worldMatrix: worldTransformComponent.space, + entityAABBContainer.append(EntityAABB( + center: simd_float4(center.x, center.y, center.z, 0.0), + halfExtent: simd_float4(halfExtent.x, halfExtent.y, halfExtent.z, 0.0), index: getEntityIndex(entityId), - version: getEntityVersion(entityId) - ) - entityAABBContainer.append(singleAABB) + version: getEntityVersion(entityId), + pad0: 0, + pad1: 0 + )) } else { // get object AABB — elongated meshes are split into segments so a solid // occluder covering only part of the entity cannot falsely cull the whole entity. - let segments = makeSegmentedEntityAABBs( - localMin: localTransformComponent.boundingBox.min, - localMax: localTransformComponent.boundingBox.max, - worldMatrix: worldTransformComponent.space, + appendSegmentedEntityAABBs( + center: center, + halfExtent: halfExtent, index: getEntityIndex(entityId), - version: getEntityVersion(entityId) + version: getEntityVersion(entityId), + to: &entityAABBContainer ) - entityAABBContainer.append(contentsOf: segments) } } @@ -1020,29 +1031,29 @@ func executeReduceScanFrustumCulling(_ commandBuffer: MTLCommandBuffer) { return } + // The entity's world box, made once for the size test and for the cull. + let boundingBox = localTransformComponent.boundingBox + let (center, halfExtent) = worldAABB_CenterExtent( + localMin: boundingBox.min, + localMax: boundingBox.max, + worldMatrix: worldTransformComponent.space + ) + // Objects that would be a pixel or so tall are not worth a draw. - if let smallObjectCulling { - let (center, halfExtent) = worldAABB_CenterExtent( - localMin: localTransformComponent.boundingBox.min, - localMax: localTransformComponent.boundingBox.max, - worldMatrix: worldTransformComponent.space - ) - if smallObjectCulling.culls(center: center, radius: simd_length(halfExtent)) { - return - } + if let smallObjectCulling, smallObjectCulling.culls(center: center, radius: simd_length(halfExtent)) { + return } objects.insert(sceneSnapshot.object(of: entity)) // get object AABB — elongated meshes are split into segments. - let segments = makeSegmentedEntityAABBs( - localMin: localTransformComponent.boundingBox.min, - localMax: localTransformComponent.boundingBox.max, - worldMatrix: worldTransformComponent.space, + appendSegmentedEntityAABBs( + center: center, + halfExtent: halfExtent, index: getEntityIndex(entityId), - version: getEntityVersion(entityId) + version: getEntityVersion(entityId), + to: &entityAABBContainer ) - entityAABBContainer.append(contentsOf: segments) } let count = entityAABBContainer.count From 7f3a74af2dbafb5b0e65488c31e99271c7e9c48a Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Sun, 4 Oct 2026 13:56:12 +0200 Subject: [PATCH 09/19] [Docs] The walks over the entities outside the render passes How the shadow pass walks the scene once a frame, and that the LOD selection, the streaming update and the transparency pass follow the rule the render passes follow: read the scene once, and keep on the render component what the meshes alone decide. --- docs/Architecture/renderingSystem.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/Architecture/renderingSystem.md b/docs/Architecture/renderingSystem.md index bf148ce62..de0b5b2e2 100644 --- a/docs/Architecture/renderingSystem.md +++ b/docs/Architecture/renderingSystem.md @@ -175,6 +175,8 @@ The cascade count is 2 by default. Raise to 3 in `Globals.swift` for outdoor sce **Cascade transitions:** The final 10% of each non-final cascade overlaps the next cascade. `ShadowSystem.cascadeBlendStart` computes this boundary once per frame on the CPU and is the single source of truth for it: the same value both widens the next cascade's frustum-fitting near plane and is uploaded to the shader (`cascadeBlendStarts`) for the cross-fade, so the two are never independently re-derived and cannot drift out of sync. The lighting shader samples both slices in the overlap and blends smoothly between them, avoiding a hard visibility change when camera motion moves stationary geometry across a split. +**One walk per frame:** `shadowExecution` walks the scene once for the directional light (`gatherDirectionalShadowCasters`). The walk gives the bounds of everything that is drawn, which `ShadowSystem.updateCascades(entityCasterBounds:)` fits the cascades to, and the list of casters with their bounds, which each cascade then tests without touching the scene again. Whether an entity casts is read from its render component as the walk passes it, so a caster made or switched since the last frame needs no cache to be invalidated. The fit only makes the light-space box of a caster (eight transforms) when the sphere around it (one transform) could overlap a cascade seen from the light: in a scene much larger than its cascades nearly every caster is skipped by the sphere. + **Directional caster culling:** Each cascade culls shadow casters in two stages. First, a direction-agnostic world-space distance reject: a caster farther than `maxShadowCastingDistance` from the cascade's own camera-frustum-slice bounding sphere (`ShadowSystem.cascadeWorldCenters`/`cascadeWorldRadii`) cannot matter to that cascade regardless of light direction, so it is dropped before the more expensive test. Second, the fitted light-space frustum is the correctness-preserving cull for what actually lands in the shadow map. Camera distance and cascade receiver splits alone are not valid caster-relevance tests for a directional light — an object outside the receiver's camera-depth interval can still project a shadow into it — which is why the distance reject is measured from the cascade's world-space bounds, not the camera. Before both, a caster that small-object culling leaves out of the main view (its bounding sphere is under the pixel threshold as seen from the camera) is left out of the shadow maps too, for directional, spot and point lights: its shadow would be about as small. **Shadow softness:** CSM sampling uses a centered 16-tap Poisson PCF kernel. Softness is authored in cascade-0 texels, converted to a world-space radius, then converted into each sampled cascade's texels. This keeps the physical filter footprint consistent across cascade transitions. The default runtime softness is `nearRadiusTexels = 2.0`, `farRadiusTexels = 5.0`, with an `xrRadiusScale = 1.35` applied only in stereo XR. Use `setShadowSoftness(_:)` to tune this globally per scene. @@ -402,6 +404,8 @@ for entityId in visibleEntityIds { When a pass needs another component, add it to the snapshot instead of calling `scene.get` inside the loop. +The same holds outside the passes for anything that visits every entity each frame. `LODSystem.update` selects the level of every entity with levels through a snapshot. `GeometryStreamingSystem.update` narrows the entities its spatial query returns to those that stream or are tiles, with one read of the scene, before any of its passes looks at them: a scene loaded whole has none. And what can be known from the meshes alone is remembered by the render component until they change: whether an entity has anything blended for the transparency pass (`RenderComponent.hasBlendedSubmeshes`), like its instance key. + --- ## Instanced Draws From 0067e705afad574733b5ca05acd61c2da2daf614 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Sun, 4 Oct 2026 17:10:56 +0200 Subject: [PATCH 10/19] [Bugfix] Draw the lone mesh of an entity that is in a batch The batches of a cell are built per material: meshes that share a material are merged, and a mesh that is alone with its material is left to its entity. But the render passes draw an entity either through its batches or by itself. An entity with two materials, one shared with a neighbour and one of its own, was skipped as batched while only its shared mesh was in a batch: nothing drew the other mesh. A lone mesh now gets a batch of its own when its entity is in a batch for another of its meshes. A mesh whose entity shares nothing is left to the entity, as before. --- .../UntoldEngine/Systems/BatchingSystem.swift | 28 ++- .../StaticBatchMembershipTests.swift | 236 ++++++++++++++++++ docs/Architecture/batchingSystem.md | 2 +- 3 files changed, 263 insertions(+), 3 deletions(-) create mode 100644 Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift diff --git a/Sources/UntoldEngine/Systems/BatchingSystem.swift b/Sources/UntoldEngine/Systems/BatchingSystem.swift index e21451bef..a5dd0da3e 100644 --- a/Sources/UntoldEngine/Systems/BatchingSystem.swift +++ b/Sources/UntoldEngine/Systems/BatchingSystem.swift @@ -1353,9 +1353,10 @@ public class BatchingSystem: @unchecked Sendable { var totalVertices = 0 var totalIndices = 0 var totalBytes = 0 + let batchedEntities = entitiesWithSharedGroups(in: input.materialGroups) for (batchKey, meshGroup) in input.materialGroups { - if meshGroup.count < 2 { continue } + if !isWorthABatch(meshGroup, batchedEntities: batchedEntities) { continue } let convertedGroup = meshGroup.map { item in (entityId: item.entityId, mesh: item.mesh, meshIndex: item.meshIndex, transform: item.transform) } @@ -1717,16 +1718,39 @@ public class BatchingSystem: @unchecked Sendable { } } + /// The entities that a build of `materialGroups` puts in a batch: those with a mesh + /// that shares its material with another. + private func entitiesWithSharedGroups(in materialGroups: [BatchBuildKey: [BatchMeshItem]]) -> Set { + var entities: Set = [] + for meshGroup in materialGroups.values where meshGroup.count >= 2 { + for item in meshGroup { + entities.insert(item.entityId) + } + } + return entities + } + + /// A mesh that is alone with its material gains nothing from a batch, and its entity + /// goes on drawing it. Unless the entity is in a batch for another of its meshes: the + /// passes draw an entity either through its batches or by itself, so the lone mesh + /// gets a batch of its own, or nothing would draw it. + private func isWorthABatch(_ meshGroup: [BatchMeshItem], batchedEntities: Set) -> Bool { + if meshGroup.count >= 2 { return true } + guard let only = meshGroup.first else { return false } + return batchedEntities.contains(only.entityId) + } + @discardableResult private func appendBatchGroups( from materialGroups: [BatchBuildKey: [BatchMeshItem]] ) -> (createdGroups: Int, batchedMeshes: Int) { var createdGroups = 0 var batchedMeshes = 0 + let batchedEntities = entitiesWithSharedGroups(in: materialGroups) for (batchKey, meshGroup) in materialGroups { // Only batch if we have multiple meshes with same material+LOD - if meshGroup.count < 2 { + if !isWorthABatch(meshGroup, batchedEntities: batchedEntities) { continue } diff --git a/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift b/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift new file mode 100644 index 000000000..6a599a293 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift @@ -0,0 +1,236 @@ +// +// StaticBatchMembershipTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Metal +import simd +@testable import UntoldEngine +import XCTest + +/// An entity marked static is drawn by its batch when it is in one, and by itself when +/// it is not. These tests pin who draws it as it enters and leaves a batch: when no batch +/// takes it, when it moves, loses its mark or is destroyed, and when it is placed to stay. +/// The measure is the frame itself: with batching on it is the frame drawn without. +@MainActor +final class StaticBatchMembershipTests: BaseRenderSetup { + private var sun: EntityID = .invalid + + override func setUp() async throws { + try await super.setUp() + resetBatching() + // Every entity of these scenes is meant to be drawn, however small on screen. + setRendering(.smallObjectCulling(pixels: 0)) + } + + override func tearDown() async throws { + resetBatching() + setRendering(.smallObjectCulling(pixels: 1)) + destroyAllEntities() + try await super.tearDown() + } + + override func initializeAssets() { + let camera = createEntity() + createGameCamera(entityId: camera) + CameraSystem.shared.activeCamera = camera + // The scene stands around x = 8, clear of the border between two batch cells at 0. + cameraLookAt(entityId: camera, eye: simd_float3(8, 6, 16), target: simd_float3(8, 0, 0), up: simd_float3(0, 1, 0)) + + sun = createEntity() + createDirLight(entityId: sun) + setSunElevationAzimuth(entityId: sun, elevation: 50, azimuth: 30) + } + + // MARK: - Helpers + + /// Batching off and empty, with every cell built in the tick that finds it dirty. + private func resetBatching() { + clearSceneBatches() + enableBatching(false) + setBatching(.cellSize(32)) + setBatching(.maxDirtyCellsPerTick(8)) + setBatching(.rebuildBudgets(vertices: 1_000_000_000, indices: 1_000_000_000, bytes: 1_000_000_000)) + setBatching(.runtimeCellLimits(vertices: 1_000_000_000, indices: 1_000_000_000, bytes: 1_000_000_000)) + setBatching(.quiescenceFramesBeforeBuild(0)) + setBatching(.recentVisibilityWindowFrames(120)) + setBatching(.visibilityGatedBuild(false)) + setBatching(.backgroundArtifactBuild(false)) + setBatching(.maxBuildDispatchesPerTick(8)) + setBatching(.maxArtifactAppliesPerTick(8)) + } + + private func material(_ baseColor: simd_float4, clearCoat: Float = 0) -> Material { + var material = Material( + runtimeMaterial: RuntimeMaterialSource(baseColorFactor: baseColor, metallicFactor: 0, roughnessFactor: 0.6), + device: renderInfo.device + ) + material.clearCoat = clearCoat + return material + } + + private let red = simd_float4(0.9, 0.2, 0.2, 1) + private let blue = simd_float4(0.2, 0.3, 0.9, 1) + private let grey = simd_float4(0.7, 0.7, 0.7, 1) + + /// A cube whose mesh sits `offset` away from its entity. + private func cube(_ material: Material, offset: simd_float3 = .zero) -> [Mesh] { + var meshes = BasicPrimitives.createCube(extent: 1) + for meshIndex in meshes.indices { + // Both, or the engine takes the mesh for one that carries its node's transform + // and puts it back at the entity's origin. + meshes[meshIndex].localSpace.columns.3 = simd_float4(offset, 1) + meshes[meshIndex].worldSpace = meshes[meshIndex].localSpace + for submeshIndex in meshes[meshIndex].submeshes.indices { + meshes[meshIndex].submeshes[submeshIndex].material = material + } + } + return meshes + } + + @discardableResult + private func place( + _ meshes: [Mesh], + at position: simd_float3, + scale: simd_float3 = simd_float3(repeating: 1), + isStatic: Bool = true, + under parent: EntityID? = nil + ) -> EntityID { + let entity = createEntity() + setEntityMeshDirect(entityId: entity, meshes: meshes, assetName: "Shape") + if let parent { + setParent(childId: entity, parentId: parent) + } + scaleTo(entityId: entity, scale: scale) + translateTo(entityId: entity, position: position) + if isStatic { + setEntityStaticBatch(entityId: entity) + } + return entity + } + + /// A floor that is no part of any batch, for shadows to fall on. + private func placeGround() { + place(cube(material(grey)), at: simd_float3(8, -1.2, 0), scale: simd_float3(40, 0.2, 40), isStatic: false) + } + + /// Draws enough frames for the culling result of the scene to come back and be used, + /// and for a cell that changed to be rebuilt. + private func drawFrames(_ count: Int = 6) { + for _ in 0 ..< count { + renderer.draw(in: renderer.metalView) + renderInfo.lastCommandBuffer?.waitUntilCompleted() + } + } + + /// The lit frame, four half floats per pixel. + private func litPixels() throws -> [Float16] { + let texture = try XCTUnwrap(textureResources.deferredColorMap) + XCTAssertEqual(texture.pixelFormat, .rgba16Float) + var pixels = [Float16](repeating: 0, count: texture.width * texture.height * 4) + pixels.withUnsafeMutableBytes { bytes in + texture.getBytes( + bytes.baseAddress!, + bytesPerRow: texture.width * 8, + from: MTLRegionMake2D(0, 0, texture.width, texture.height), + mipmapLevel: 0 + ) + } + return pixels + } + + /// The frame of the scene as it stands, drawn without any batch. + private func frameWithoutBatches() throws -> [Float16] { + enableBatching(false) + drawFrames() + return try litPixels() + } + + /// Pixels that differ visibly between two frames, and how many of the first have + /// something drawn on them. A batch holds its vertices already placed in the world, + /// so an edge can land a hair away from where the entity's own draw puts it: a pixel + /// counts when a channel differs by more than `tolerance`. + private func compare(_ first: [Float16], _ second: [Float16], tolerance: Float = 0.02) -> (different: Int, covered: Int) { + XCTAssertEqual(first.count, second.count) + var different = 0 + var covered = 0 + let background = Array(first[0 ..< 4]) + for offset in stride(from: 0, to: min(first.count, second.count), by: 4) { + for channel in 0 ..< 3 where abs(Float(first[offset + channel]) - Float(second[offset + channel])) > tolerance { + different += 1 + break + } + if first[offset] != background[0] || first[offset + 1] != background[1] || first[offset + 2] != background[2] { + covered += 1 + } + } + return (different, covered) + } + + /// Checks that the frame drawn now, with batches, is the frame `unbatched`. + /// + /// The edges of a shape can differ by a pixel between a batch and the entity's own + /// draw; a shape or a shadow that is missing, or drawn twice, is thousands. + private func assertSameFrame( + as unbatched: [Float16], + file: StaticString = #filePath, + line: UInt = #line + ) throws { + let batched = try litPixels() + let result = compare(unbatched, batched) + XCTAssertLessThan(result.different, 150, "pixels that differ from the frame drawn without batches", file: file, line: line) + XCTAssertGreaterThan(result.covered, 2000, "the scene is on screen", file: file, line: line) + } + + /// Checks that the frame just drawn, with the batches as they are now, is the frame + /// the scene gives without them. Batching is off when this returns. + private func assertTheFrameIsTheOneWithoutBatches(file: StaticString = #filePath, line: UInt = #line) throws { + let batched = try litPixels() + let unbatched = try frameWithoutBatches() + let result = compare(unbatched, batched) + XCTAssertLessThan(result.different, 150, "pixels that differ from the frame drawn without batches", file: file, line: line) + XCTAssertGreaterThan(result.covered, 2000, "the scene is on screen", file: file, line: line) + } + + private func isBatched(_ entity: EntityID) -> Bool { + BatchingSystem.shared.isBatched(entityId: entity) + } + + /// The box around every vertex the batches hold. + private var batchedBounds: (min: simd_float3, max: simd_float3)? { + let groups = BatchingSystem.shared.batchGroups + guard var bounds = groups.first?.boundingBox else { return nil } + for group in groups.dropFirst() { + bounds.min = simd_min(bounds.min, group.boundingBox.min) + bounds.max = simd_max(bounds.max, group.boundingBox.max) + } + return bounds + } + + private var batchedEntities: Set { + Set(BatchingSystem.shared.batchGroups.flatMap(\.entityIds)) + } + + // MARK: - An entity that no batch takes + + func testAnEntityWithOneMaterialInABatchAndOneOutIsDrawnWhole() throws { + placeGround() + // The first entity is a red cube and a blue one; only its red cube has company. + let both = place(cube(material(red)) + cube(material(blue), offset: simd_float3(0, 0, 3)), at: simd_float3(5, 0, 0)) + let other = place(cube(material(red)), at: simd_float3(11, 0, 0)) + let unbatched = try frameWithoutBatches() + + enableBatching(true) + generateBatches() + drawFrames() + + XCTAssertTrue(isBatched(both)) + XCTAssertTrue(isBatched(other)) + try assertSameFrame(as: unbatched) + } +} diff --git a/docs/Architecture/batchingSystem.md b/docs/Architecture/batchingSystem.md index f25eb3cda..b8b242846 100644 --- a/docs/Architecture/batchingSystem.md +++ b/docs/Architecture/batchingSystem.md @@ -79,7 +79,7 @@ This is the core build loop: For each `CellBuildInput`, on the background thread: -- Iterate material groups. **Skip any group with < 2 meshes** (no point batching a single mesh). +- Iterate material groups. **Skip any group with < 2 meshes** (no point batching a single mesh): its entity goes on drawing it. The exception is a lone mesh of an entity that is in a batch for another of its meshes. The render passes draw an entity either through its batches or by itself, so that mesh gets a batch of its own. - For each group that qualifies, call `createBatchGroup`: - Loop through all meshes in the group. - For each mesh, extract positions, normals, UVs, tangents from the Metal buffers. From 1b53ad791ad8c4d446306e9adc29a3c0081d9dbb Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Sun, 4 Oct 2026 17:11:34 +0200 Subject: [PATCH 11/19] [Bugfix] Tell apart in a batch the materials that differ in clear coat, sheen or tints A batch draws all of its meshes with the material of one of them, so the key that decides which meshes are merged has to tell apart everything the opaque pass sends per material. It left out the edge tint, the specular tint, subsurface, anisotropy, sheen, its tint, clear coat and its gloss: two materials alike in the rest were merged, and one of them was drawn with the values of the other. --- Sources/UntoldEngine/Systems/BatchingSystem.swift | 15 +++++++++++++++ .../StaticBatchMembershipTests.swift | 11 +++++++++++ 2 files changed, 26 insertions(+) diff --git a/Sources/UntoldEngine/Systems/BatchingSystem.swift b/Sources/UntoldEngine/Systems/BatchingSystem.swift index a5dd0da3e..9b851e389 100644 --- a/Sources/UntoldEngine/Systems/BatchingSystem.swift +++ b/Sources/UntoldEngine/Systems/BatchingSystem.swift @@ -2834,6 +2834,21 @@ public class BatchingSystem: @unchecked Sendable { components.append("\(material.metallicChannel.rawValue)") components.append(String(format: "%.2f", material.specular)) components.append(String(format: "%.2f", material.ior)) + // The rest of what the opaque pass sends per material: a batch draws all of its + // meshes with the material of one, so two that differ in any of it are two. + components.append(String(format: "%.2f,%.2f,%.2f,%.2f", + material.edgeTint.x, + material.edgeTint.y, + material.edgeTint.z, + material.edgeTint.w)) + components.append(String(format: "%.2f,%.2f,%.2f,%.2f,%.2f,%.2f,%.2f", + material.specularTint, + material.subsurface, + material.anisotropic, + material.sheen, + material.sheenTint, + material.clearCoat, + material.clearCoatGloss)) components.append(String(format: "%.2f", material.stScale)) components.append(String(format: "%.2f", material.normalScale)) // What the material gives off: two lamps alike in all but their glow are two materials. diff --git a/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift b/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift index 6a599a293..9036aef68 100644 --- a/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift +++ b/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift @@ -233,4 +233,15 @@ final class StaticBatchMembershipTests: BaseRenderSetup { XCTAssertTrue(isBatched(other)) try assertSameFrame(as: unbatched) } + + func testMaterialsThatDifferInTheirClearCoatAreNotMerged() { + let plain = place(cube(material(red)), at: simd_float3(5, 0, 0)) + let coated = place(cube(material(red, clearCoat: 1)), at: simd_float3(11, 0, 0)) + + enableBatching(true) + generateBatches() + + XCTAssertFalse(isBatched(plain), "a batch draws all of its entities with the material of one") + XCTAssertFalse(isBatched(coated)) + } } From feee1ad3458e736aaf1b9bf84e6c43f838bdfc8d Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Sun, 4 Oct 2026 17:12:13 +0200 Subject: [PATCH 12/19] [Bugfix] Apply a batch built in the background when nothing else changes The batches of a cell are built on a background queue and applied by a later tick. The tick returns early when it has no pending entity, no dirty cell and nothing to retire, and it did so before looking at the builds that had finished. The last cell of a scene that then stood still was never applied: its entities stayed on a draw each, with their batch waiting in the queue, until something else in the scene changed. The tick now goes on while a finished build waits. --- .../UntoldEngine/Systems/BatchingSystem.swift | 5 ++++- .../StaticBatchMembershipTests.swift | 21 +++++++++++++++++++ docs/Architecture/batchingSystem.md | 2 +- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/Sources/UntoldEngine/Systems/BatchingSystem.swift b/Sources/UntoldEngine/Systems/BatchingSystem.swift index 9b851e389..7e39ecfd5 100644 --- a/Sources/UntoldEngine/Systems/BatchingSystem.swift +++ b/Sources/UntoldEngine/Systems/BatchingSystem.swift @@ -938,10 +938,13 @@ public class BatchingSystem: @unchecked Sendable { } } + // A cell built in the background is applied by a later tick, which may be one + // with nothing else to do: the last cell of a scene that then stands still. guard !pendingEntityRemovals.isEmpty || !pendingEntityAdditions.isEmpty || !dirtyCells.isEmpty || - !retiringBatchArtifacts.isEmpty + !retiringBatchArtifacts.isEmpty || + diagnostics.readyArtifactsQueued > 0 else { diagnostics.dirtyCellsAfterPrune = dirtyCells.count lastTickDiagnostics = diagnostics diff --git a/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift b/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift index 9036aef68..537c87aad 100644 --- a/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift +++ b/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift @@ -244,4 +244,25 @@ final class StaticBatchMembershipTests: BaseRenderSetup { XCTAssertFalse(isBatched(plain), "a batch draws all of its entities with the material of one") XCTAssertFalse(isBatched(coated)) } + + // MARK: - An entity of a streamed cell + + func testACellBuiltInTheBackgroundIsAppliedWhenNothingElseChanges() { + let first = place(cube(material(red)), at: simd_float3(5, 0, 0)) + let second = place(cube(material(red)), at: simd_float3(11, 0, 0)) + enableBatching(true) + setBatching(.backgroundArtifactBuild(true)) + + // The one cell of the scene is built on the batching system's own queue. Its + // batch is ready some frames later, when no cell is dirty any more. + BatchingSystem.shared.notifyTileEntitiesResident([first, second]) + var frames = 0 + while !isBatched(first), frames < 300 { + drawFrames(1) + frames += 1 + } + + XCTAssertTrue(isBatched(first), "after \(frames) frames") + XCTAssertTrue(isBatched(second)) + } } diff --git a/docs/Architecture/batchingSystem.md b/docs/Architecture/batchingSystem.md index b8b242846..7662a2496 100644 --- a/docs/Architecture/batchingSystem.md +++ b/docs/Architecture/batchingSystem.md @@ -55,7 +55,7 @@ Cells flagged by tile promotion skip the quiescence check and advance directly t This is the core build loop: -1. **Apply completed background artifacts first** — results from previous frames' async builds are swapped in (up to `maxArtifactAppliesPerTick = 4` per frame). +1. **Apply completed background artifacts first** — results from previous frames' async builds are swapped in (up to `maxArtifactAppliesPerTick = 4` per frame). A tick with nothing else to do still runs this step while an artifact is waiting: the last cell of a scene that then stands still is applied like any other. 2. **Gather `batchPending` cells** and build rebuild candidates. For each: - Estimate the work: count total vertices + indices + bytes across all entities in the cell. From c31fd0526a01aeef0f49b3aa6dc0b3ef02f3bcd4 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Sun, 4 Oct 2026 17:13:10 +0200 Subject: [PATCH 13/19] [Bugfix] A static entity that no batch draws casts its shadow With batching enabled, the shadow passes skipped every entity marked static: its batch was expected to cast for it. An entity that no batch took had no shadow at all, from the sun, from spot lights or from point lights. That is an entity alone with its material in its cell, every entity of a cell over the runtime limits, and an entity marked static that the batching system was never told of. The passes now ask the batching system (castsShadowByItself). An entity in a batch is drawn by the batch. An entity of a streamed cell that waits for its batches, or of a tile that is fading in or out, is still skipped: that is what keeps a tile that has just come in from adding a shadow draw per entity. Any other marked entity casts for itself, and the cascades are fitted to it. The spot and point passes keep a list of candidates that leaves out the marked entities a batch draws. The batching system invalidates the list when batches are built or removed. --- .../UntoldEngine/Renderer/RenderPasses.swift | 64 +++++++------ .../UntoldEngine/Systems/BatchingSystem.swift | 57 +++++++++++ .../UntoldEngine/Systems/ShadowSystem.swift | 8 +- .../StaticBatchMembershipTests.swift | 94 +++++++++++++++++++ docs/API/UsingStaticBatchingSystem.md | 4 + docs/Architecture/batchingSystem.md | 11 +++ 6 files changed, 205 insertions(+), 33 deletions(-) diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index f063e71c5..ae1a6dd1d 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -689,13 +689,15 @@ public enum RenderPasses { guard let renderComponent = sceneSnapshot.render(of: entity), renderComponent.castsShadow else { return } - // Batch-eligible entities always cast shadows via shadowCasterBatchGroups. + // A marked entity that is in a batch casts via the batch groups, and one that + // waits for its batch casts nothing (see gatherDirectionalShadowCasters). // Excluding them here prevents O(n_loaded_tiles) individual shadow draw calls. - // A mesh carrying an occluder shell or fade is out of its batch and casts on its - // own (the system that adds or removes those components invalidates this cache). + // The others cast on their own: one that no batch took, and a mesh carrying an + // occluder shell or fade, which is out of its batch. The batching system, and + // the system that adds or removes those components, invalidate this cache. if batchingEnabled, entity.traits.contains(.staticBatch), - entity.traits.isDisjoint(with: meshOccluderOrFade) + !BatchingSystem.shared.castsShadowByItself(entity) { return } candidates.append(entity.entityId) } @@ -777,26 +779,26 @@ public enum RenderPasses { let renderMode = renderModes.mode(of: sceneSnapshot.sceneChannels(of: entity)) if renderMode == .hidden || renderMode == .wireframe { return } - // Batch-eligible entities (StaticBatchComponent present) are always drawn via - // shadowCasterBatchGroups, whether or not the batch rebuild has landed yet. - // Skipping them here prevents O(n_loaded_tiles) shadow draw calls that scale - // with the scene and eventually overflow the GPU command buffer budget. During - // the brief batch-rebuild window their shadow is absent; this is preferable to - // the alternative of the app freezing at ~300+ loaded tiles. The exception is a - // mesh carrying an occluder shell or fade: it stays out of its batch for as long - // as the component is there, so it casts on its own. The cascades are fitted to - // the batch groups' own bounds, not to those of their entities. - let batchEligible = batchingEnabled && entity.traits.contains(.staticBatch) - let batched = batchingEnabled && BatchingSystem.shared.isBatched(entityId: entity.entityId) - let fits = !batchEligible && !batched - let mayCast = !batched && !(batchEligible && entity.traits.isDisjoint(with: meshOccluderOrFade)) - guard fits || mayCast else { return } + // An entity in a batch is drawn via shadowCasterBatchGroups, and the cascades + // are fitted to the batch groups' own bounds, not to those of their entities. + // An entity of a streamed cell that waits for its batches is drawn by nobody: + // skipping it prevents O(n_loaded_tiles) shadow draw calls that scale with the + // scene and eventually overflow the GPU command buffer budget. During the brief + // batch-rebuild window its shadow is absent; this is preferable to the + // alternative of the app freezing at ~300+ loaded tiles. Any other marked + // entity has no batch to cast for it, so it casts on its own: one that its + // cell was built without (alone with its material) and a mesh carrying an + // occluder shell or fade, which stays out of its batch for as long as the + // component is there. BatchingSystem.castsShadowByItself holds the rule. + if batchingEnabled, + entity.traits.contains(.staticBatch), + !BatchingSystem.shared.castsShadowByItself(entity) + { return } guard let components = sceneSnapshot.drawComponents(of: entity), components.render.isVisible else { return } - let casts = mayCast && components.render.castsShadow - guard fits || casts else { return } + let casts = components.render.castsShadow let boundingBox = components.local.boundingBox let (worldMin, worldMax) = worldAABB_MinMax( @@ -819,9 +821,7 @@ public enum RenderPasses { ) } - if fits { - walk.fitBounds.append(ShadowCasterBounds(min: visualMin, max: visualMax)) - } + walk.fitBounds.append(ShadowCasterBounds(min: visualMin, max: visualMax)) if casts { if let smallObjectCulling, smallObjectCulling.culls(worldMin: worldMin, worldMax: worldMax) { return } walk.casters.append(DirectionalShadowCaster( @@ -914,10 +914,11 @@ public enum RenderPasses { guard let entity = sceneSnapshot.entity(entityId) else { continue } let renderMode = renderModes.mode(of: sceneSnapshot.sceneChannels(of: entity)) if renderMode == .hidden || renderMode == .wireframe { continue } - if batchingEnabled { - if entity.traits.contains(.staticBatch) { continue } - if BatchingSystem.shared.isBatched(entityId: entityId) { continue } - } + // A candidate that a batch has taken since the list was made, or is about to. + if batchingEnabled, + entity.traits.contains(.staticBatch), + !BatchingSystem.shared.castsShadowByItself(entity) + { continue } guard let components = sceneSnapshot.drawComponents(of: entity), components.render.isVisible @@ -974,10 +975,11 @@ public enum RenderPasses { guard let entity = sceneSnapshot.entity(entityId) else { continue } let renderMode = renderModes.mode(of: sceneSnapshot.sceneChannels(of: entity)) if renderMode == .hidden || renderMode == .wireframe { continue } - if batchingEnabled { - if entity.traits.contains(.staticBatch) { continue } - if BatchingSystem.shared.isBatched(entityId: entityId) { continue } - } + // A candidate that a batch has taken since the list was made, or is about to. + if batchingEnabled, + entity.traits.contains(.staticBatch), + !BatchingSystem.shared.castsShadowByItself(entity) + { continue } guard let components = sceneSnapshot.drawComponents(of: entity), components.render.isVisible diff --git a/Sources/UntoldEngine/Systems/BatchingSystem.swift b/Sources/UntoldEngine/Systems/BatchingSystem.swift index 7e39ecfd5..e62f4160e 100644 --- a/Sources/UntoldEngine/Systems/BatchingSystem.swift +++ b/Sources/UntoldEngine/Systems/BatchingSystem.swift @@ -563,6 +563,9 @@ public class BatchingSystem: @unchecked Sendable { /// Values < 1 are clamped to 1 at the drain site — 0 or negative would either /// silently stall the queue or crash on Array.removeFirst with a negative count. public var maxTileResidentDrainPerTick: Int = 16 + /// The entities of `pendingTileResidentQueue` that the tick has not drained yet, for + /// `awaitsBatch(entityId:)` to ask in constant time. + private var tileResidentsAwaitingDrain: Set = [] private var isSubscribed: Bool = false private var batchCellSize: Float = 32.0 /// Maps each cell to the UUIDs of its current batch groups. @@ -653,6 +656,44 @@ public class BatchingSystem: @unchecked Sendable { // the 1-frame quiescence window and reset the timer, so the cell rebuilds // once after the last slice — regardless of how many slices the tile produced. pendingTileResidentQueue.append(contentsOf: entityIds) + tileResidentsAwaitingDrain.formUnion(entityIds) + } + + /// Whether the shadow passes draw a marked entity by itself. + /// + /// An entity in a batch is drawn with it. One in a streamed cell that waits for its + /// batches is drawn by nobody for that short while (see `awaitsBatch(entityId:)`), + /// and neither is one of a tile that is fading in or out, which the batching system + /// is told of when the fade ends. Any other casts for itself, and so does an entity + /// carrying a component that keeps it out of its batch (an occluder shell, a fade). + func castsShadowByItself(_ entity: RenderSceneSnapshot.Entity) -> Bool { + if entityToBatch[entity.entityId] != nil { return false } + if !entity.traits.isDisjoint(with: [.meshOccluder, .meshFade]) { return true } + if entity.traits.contains(.tileRepresentationFade) { return false } + return !awaitsBatch(entityId: entity.entityId) + } + + /// The spot and point shadow passes keep a list of the entities that may cast, which + /// leaves out the marked ones that a batch draws or is about to. They are told when a + /// marked entity that was left out may have to cast for itself. + private func shadowCastersChanged() { + RenderPasses.invalidateShadowEntityCache() + } + + /// Whether a marked entity that is in no batch is about to be taken by one. + /// + /// The shadow pass draws no entity of a streamed cell while the cell waits for its + /// batches, so that a tile that has just come in does not add a draw per entity. + /// Every other marked entity that no batch draws casts for itself: one whose cell was + /// built without it (alone with its material, or in a cell over the runtime limits) + /// and one that the system does not track at all. + public func awaitsBatch(entityId: EntityID) -> Bool { + if let cellId = entityToCellMembership[entityId] { + if dirtyCells.contains(cellId) { return true } + let state = cellLifecycle[cellId]?.state + return state == .streaming || state == .batchPending + } + return pendingEntityAdditions.contains(entityId) || tileResidentsAwaitingDrain.contains(entityId) } /// Removes the given entities from all pending batching queues immediately. @@ -665,6 +706,7 @@ public class BatchingSystem: @unchecked Sendable { /// destroying tile geometry to also clean committed state. public func cancelPendingEntities(_ entityIds: Set) { guard !entityIds.isEmpty else { return } + shadowCastersChanged() pendingEntityAdditions.subtract(entityIds) pendingEntityRemovals.subtract(entityIds) newlyResidentEntities.subtract(entityIds) @@ -678,6 +720,7 @@ public class BatchingSystem: @unchecked Sendable { pendingTileResidentQueue.removeAll(keepingCapacity: true) } pendingTileResidentQueueHead = 0 + tileResidentsAwaitingDrain.subtract(entityIds) } /// Full batching cleanup for tile entities that are about to be destroyed. @@ -705,6 +748,7 @@ public class BatchingSystem: @unchecked Sendable { pendingTileResidentQueue.removeAll(keepingCapacity: true) } pendingTileResidentQueueHead = 0 + tileResidentsAwaitingDrain.subtract(entityIds) // Queue removal from committed state. removeEntityFromBatchingTracking is called // by the batch tick; it only manipulates BatchingSystem-internal dictionaries and // does not access ECS data, so it is safe to call after destroyEntity. @@ -726,6 +770,7 @@ public class BatchingSystem: @unchecked Sendable { pendingTileResidentQueue.removeAll(keepingCapacity: true) } pendingTileResidentQueueHead = 0 + tileResidentsAwaitingDrain.subtract(entityIds) var affectedCells: Set = [] for entityId in entityIds { @@ -915,6 +960,7 @@ public class BatchingSystem: @unchecked Sendable { for i in pendingTileResidentQueueHead ..< drainEnd { let entityId = pendingTileResidentQueue[i] + tileResidentsAwaitingDrain.remove(entityId) guard scene.exists(entityId) else { continue } guard scene.get(component: StaticBatchComponent.self, for: entityId) != nil else { continue } pendingEntityAdditions.insert(entityId) @@ -1036,6 +1082,7 @@ public class BatchingSystem: @unchecked Sendable { entityToBatch.removeValue(forKey: entityId) guard let oldCell = entityToCellMembership.removeValue(forKey: entityId) else { return } + shadowCastersChanged() if var members = cellToEntities[oldCell] { members.remove(entityId) if members.isEmpty { @@ -1178,6 +1225,7 @@ public class BatchingSystem: @unchecked Sendable { if exceedsRuntimeComplexityGuard(estimate) { metrics.skippedByComplexityGuard += 1 runtimeBatchIneligibleCells.insert(cellId) + shadowCastersChanged() recordRuntimeCellComplexitySkip(cellId: cellId) dirtyCells.remove(cellId) setCellState( @@ -1418,6 +1466,8 @@ public class BatchingSystem: @unchecked Sendable { } let removedExisting = removeBatchesForCell(cellId, queueForRetirement: true) + // Built: what the cell's batches did not take casts for itself from here on. + shadowCastersChanged() if artifact.builtGroups.isEmpty { setCellState( cellId, @@ -1843,6 +1893,7 @@ public class BatchingSystem: @unchecked Sendable { cellToGroupIds.removeValue(forKey: cellId) batchedCells.remove(cellId) + shadowCastersChanged() if queueForRetirement { enqueueRetiringBatchGroups(cellId: cellId, groups: removedGroups) @@ -2074,6 +2125,8 @@ public class BatchingSystem: @unchecked Sendable { setCellState(batchGroup.cellId, .renderableBatched) } + shadowCastersChanged() + Logger.log(message: "✅ Created \(batchGroups.count) batch groups") let totalBatchedMeshes = batchGroups.reduce(0) { $0 + $1.entityIds.count } Logger.log(message: "📊 Batching Stats: \(totalBatchedMeshes) meshes → \(batchGroups.count) draw calls") @@ -2264,6 +2317,7 @@ public class BatchingSystem: @unchecked Sendable { tileParsedEntityIds.removeAll() pendingTileResidentQueue.removeAll() pendingTileResidentQueueHead = 0 + tileResidentsAwaitingDrain.removeAll() cellLastVisibleFrame.removeAll() cellBuildGeneration.removeAll() runtimeBatchIneligibleCells.removeAll() @@ -2272,6 +2326,7 @@ public class BatchingSystem: @unchecked Sendable { lastTickDiagnostics = .init() batchIndexNeedsRebuild = false tickCounter = 0 + shadowCastersChanged() } /// Check if an entity is part of a batch @@ -2324,12 +2379,14 @@ public class BatchingSystem: @unchecked Sendable { tileParsedEntityIds.removeAll() pendingTileResidentQueue.removeAll() pendingTileResidentQueueHead = 0 + tileResidentsAwaitingDrain.removeAll() runtimeBatchIneligibleCells.removeAll() clearPendingBuildArtifacts() for cellId in cellToEntities.keys { setCellState(cellId, .renderableUnbatched) } } + shadowCastersChanged() } public func isEnabled() -> Bool { diff --git a/Sources/UntoldEngine/Systems/ShadowSystem.swift b/Sources/UntoldEngine/Systems/ShadowSystem.swift index db9705f09..58ddd2e86 100644 --- a/Sources/UntoldEngine/Systems/ShadowSystem.swift +++ b/Sources/UntoldEngine/Systems/ShadowSystem.swift @@ -705,8 +705,12 @@ struct ShadowSystem { if !entity.traits.isDisjoint(with: [.sceneCamera, .camera, .light, .gizmo]) { return } let renderMode = renderModes.mode(of: sceneSnapshot.sceneChannels(of: entity)) if renderMode == .hidden || renderMode == .wireframe { return } - if batchingEnabled, entity.traits.contains(.staticBatch) { return } - if batchingEnabled, BatchingSystem.shared.isBatched(entityId: entity.entityId) { return } + // The cascades are fitted to what casts: a marked entity that is in a batch, or + // waits for one, is not drawn by itself (see gatherDirectionalShadowCasters). + if batchingEnabled, + entity.traits.contains(.staticBatch), + !BatchingSystem.shared.castsShadowByItself(entity) + { return } guard let components = sceneSnapshot.drawComponents(of: entity), components.render.isVisible diff --git a/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift b/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift index 537c87aad..37658ed18 100644 --- a/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift +++ b/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift @@ -218,6 +218,78 @@ final class StaticBatchMembershipTests: BaseRenderSetup { // MARK: - An entity that no batch takes + func testAMarkedEntityThatNoBatchTakesCastsItsShadow() throws { + placeGround() + // Two posts with a material each: neither has anyone to share a batch with. + let redPost = place(cube(material(red)), at: simd_float3(5, 0.4, 0), scale: simd_float3(0.8, 3, 0.8)) + let bluePost = place(cube(material(blue)), at: simd_float3(11, 0.4, 0), scale: simd_float3(0.8, 3, 0.8)) + let unbatched = try frameWithoutBatches() + let castersWithoutBatches = RenderPasses.lastShadowInstancedDrawCounts.instances + XCTAssertGreaterThan(castersWithoutBatches, 0) + + enableBatching(true) + generateBatches() + drawFrames() + + XCTAssertFalse(isBatched(redPost)) + XCTAssertFalse(isBatched(bluePost)) + XCTAssertFalse(BatchingSystem.shared.awaitsBatch(entityId: redPost)) + XCTAssertEqual(RenderPasses.lastShadowInstancedDrawCounts.instances, castersWithoutBatches, "the posts cast as they do without batching") + try assertSameFrame(as: unbatched) + } + + func testAMarkedEntityThatNoBatchTakesCastsItsShadowFromAPointLight() throws { + setLight(entityId: sun, .directional(.castsShadow(false))) + let lamp = createEntity() + createPointLight(entityId: lamp) + translateTo(entityId: lamp, position: simd_float3(8, 4, 3)) + setLight(entityId: lamp, .power(4000)) + setLight(entityId: lamp, .point(.range(40))) + setLight(entityId: lamp, .point(.castsShadow(true))) + + try assertThePostsCastTheSameWithBatchingOn() + } + + func testAMarkedEntityThatNoBatchTakesCastsItsShadowFromASpotLight() throws { + setLight(entityId: sun, .directional(.castsShadow(false))) + let lamp = createEntity() + createSpotLight(entityId: lamp) + translateTo(entityId: lamp, position: simd_float3(8, 9, 3)) + setLight(entityId: lamp, .power(8000)) + setLight(entityId: lamp, .spot(.coneAngle(80))) + setLight(entityId: lamp, .spot(.range(40))) + setLight(entityId: lamp, .spot(.castsShadow(true))) + + try assertThePostsCastTheSameWithBatchingOn() + } + + /// Two posts with a material each, which no batch takes, on a floor: checks that the + /// light of the scene draws their shadows, and draws them the same with batching on. + private func assertThePostsCastTheSameWithBatchingOn(file: StaticString = #filePath, line: UInt = #line) throws { + placeGround() + let posts = [ + place(cube(material(red)), at: simd_float3(5, 0.4, 0), scale: simd_float3(0.8, 3, 0.8)), + place(cube(material(blue)), at: simd_float3(11, 0.4, 0), scale: simd_float3(0.8, 3, 0.8)), + ] + // The posts have shadows to lose: the frame differs when they are told not to cast. + for post in posts { + try XCTUnwrap(scene.get(component: RenderComponent.self, for: post)).castsShadow = false + } + let withoutShadows = try frameWithoutBatches() + for post in posts { + try XCTUnwrap(scene.get(component: RenderComponent.self, for: post)).castsShadow = true + } + let unbatched = try frameWithoutBatches() + XCTAssertGreaterThan(compare(withoutShadows, unbatched).different, 1000, "the light draws the shadows of the posts", file: file, line: line) + + enableBatching(true) + generateBatches() + drawFrames() + + XCTAssertFalse(posts.contains(where: isBatched), file: file, line: line) + try assertSameFrame(as: unbatched, file: file, line: line) + } + func testAnEntityWithOneMaterialInABatchAndOneOutIsDrawnWhole() throws { placeGround() // The first entity is a red cube and a blue one; only its red cube has company. @@ -247,6 +319,28 @@ final class StaticBatchMembershipTests: BaseRenderSetup { // MARK: - An entity of a streamed cell + func testAnEntityOfAStreamedCellCastsNothingWhileItWaitsForItsBatch() { + placeGround() + let posts = [ + place(cube(material(red)), at: simd_float3(5, 0.4, 0), scale: simd_float3(0.8, 3, 0.8)), + place(cube(material(red)), at: simd_float3(11, 0.4, 0), scale: simd_float3(0.8, 3, 0.8)), + ] + enableBatching(true) + drawFrames() + let castersOfTheScene = RenderPasses.lastShadowInstancedDrawCounts.instances + XCTAssertGreaterThan(castersOfTheScene, 0, "marked entities that the batching system was not told of cast") + + // A tile comes in: its cell is not built before it has been still for a while. + setBatching(.quiescenceFramesBeforeBuild(1000)) + BatchingSystem.shared.notifyTileEntitiesResident(Set(posts)) + XCTAssertTrue(BatchingSystem.shared.awaitsBatch(entityId: posts[0]), "queued") + drawFrames() + + XCTAssertFalse(isBatched(posts[0])) + XCTAssertTrue(BatchingSystem.shared.awaitsBatch(entityId: posts[0]), "in a cell that waits") + XCTAssertLessThan(RenderPasses.lastShadowInstancedDrawCounts.instances, castersOfTheScene, "the posts do not cast for now") + } + func testACellBuiltInTheBackgroundIsAppliedWhenNothingElseChanges() { let first = place(cube(material(red)), at: simd_float3(5, 0, 0)) let second = place(cube(material(red)), at: simd_float3(11, 0, 0)) diff --git a/docs/API/UsingStaticBatchingSystem.md b/docs/API/UsingStaticBatchingSystem.md index b1654c0b3..e72067ee3 100644 --- a/docs/API/UsingStaticBatchingSystem.md +++ b/docs/API/UsingStaticBatchingSystem.md @@ -39,6 +39,10 @@ setEntityMeshAsync(entityId: building, filename: "office_building", withExtensio } ``` +## Shadows + +An entity in a batch casts through its batch. A marked entity that no batch took (it is alone with its material in its cell) casts its own shadow, from the sun and from spot and point lights. The one exception is a streamed tile that has just come in: its entities cast nothing for the few frames until their batch is built. + ## Runtime Batching in Tiled Scenes For tiled scenes, the flow is different: diff --git a/docs/Architecture/batchingSystem.md b/docs/Architecture/batchingSystem.md index 7662a2496..c4ede25d0 100644 --- a/docs/Architecture/batchingSystem.md +++ b/docs/Architecture/batchingSystem.md @@ -119,6 +119,17 @@ For batch groups that survive the AABB test and scene-channel visibility filter, Per-entity batching membership (`entityToBatch`) is still maintained and used by non-batched rendering paths and by tests. +### Shadows + +Batch groups cast in the directional, spot and point shadow passes. A marked entity that is not in a batch is handled by `BatchingSystem.castsShadowByItself(_:)`: + +| The entity | Who draws its shadow | +|---|---| +| Is in a batch | The batch group | +| Is in a streamed cell that waits for its batches, or belongs to a tile that is fading in or out | Nobody, for that short while. A tile that has just come in would otherwise add a shadow draw per entity | +| Was left out when its cell was built (alone with its material, or the cell is over the per-cell limits) | Itself | +| Carries an occluder shell or a fade, or is not known to the batching system | Itself | + --- ## Step 6: Retirement (Safe GPU Buffer Release) From f8c60121844e5d93e4860720ead85c534894831e Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Sun, 4 Oct 2026 17:13:55 +0200 Subject: [PATCH 14/19] [Feature] Batch in the background the static entities placed to stay Marked entities reach the batching system in two ways: through generateBatches(), which rebuilds every batch of the scene inside the call, or as streamed tiles, whose cells are built once they have been seen and only under the per-cell size limits. notifyStaticEntitiesPlaced(_:) is for marked entities that are loaded and in the scene to stay. Their cells are built by the tick, on the background queue and within the per-tick budgets, but without waiting to be seen and whatever their size, as generateBatches() builds them. Until its batch is built an entity is drawn by itself, shadow included. --- .../UntoldEngine/Systems/BatchingSystem.swift | 54 +++++++++++++++++-- .../StaticBatchMembershipTests.swift | 38 +++++++++++++ docs/API/UsingStaticBatchingSystem.md | 20 +++++++ docs/Architecture/batchingSystem.md | 13 ++++- 4 files changed, 120 insertions(+), 5 deletions(-) diff --git a/Sources/UntoldEngine/Systems/BatchingSystem.swift b/Sources/UntoldEngine/Systems/BatchingSystem.swift index e62f4160e..bbcd90125 100644 --- a/Sources/UntoldEngine/Systems/BatchingSystem.swift +++ b/Sources/UntoldEngine/Systems/BatchingSystem.swift @@ -566,6 +566,15 @@ public class BatchingSystem: @unchecked Sendable { /// The entities of `pendingTileResidentQueue` that the tick has not drained yet, for /// `awaitsBatch(entityId:)` to ask in constant time. private var tileResidentsAwaitingDrain: Set = [] + /// Cells that hold entities placed in the scene to stay (the parts of a pack, an entity + /// that moved and came to rest), as opposed to tiles that the streaming system brings + /// in and takes away. Their batches are built as soon as the budgets of a tick allow: + /// whether or not the cell has been seen and whatever its size, as `generateBatches()` + /// builds them. + private var ungatedCells: Set = [] + /// Entities handed to `notifyStaticEntitiesPlaced(_:)` that the next tick registers. + /// The cell each one lands in joins `ungatedCells`. + private var placedEntities: Set = [] private var isSubscribed: Bool = false private var batchCellSize: Float = 32.0 /// Maps each cell to the UUIDs of its current batch groups. @@ -659,6 +668,24 @@ public class BatchingSystem: @unchecked Sendable { tileResidentsAwaitingDrain.formUnion(entityIds) } + /// Hands marked entities that are loaded and in place to the batching system: their + /// cells are built in the background over the next ticks, without waiting for a cell + /// to be seen and without the size limits that guard the cells of streamed tiles. + /// + /// For entities that are in the scene to stay, like the parts of a pack. Until its + /// batch is built an entity is drawn by itself, in the shadow pass too. While batching + /// is disabled this does nothing, and `generateBatches()` picks the marks up later. + public func notifyStaticEntitiesPlaced(_ entityIds: some Sequence) { + guard batchingEnabled else { return } + for entityId in entityIds { + guard scene.exists(entityId), + scene.get(component: StaticBatchComponent.self, for: entityId) != nil + else { continue } + pendingEntityAdditions.insert(entityId) + placedEntities.insert(entityId) + } + } + /// Whether the shadow passes draw a marked entity by itself. /// /// An entity in a batch is drawn with it. One in a streamed cell that waits for its @@ -687,8 +714,12 @@ public class BatchingSystem: @unchecked Sendable { /// Every other marked entity that no batch draws casts for itself: one whose cell was /// built without it (alone with its material, or in a cell over the runtime limits) /// and one that the system does not track at all. + /// So does one handed over with `notifyStaticEntitiesPlaced(_:)`: its cell is built + /// whether or not it is seen, so it is not left without a shadow for long or for good. public func awaitsBatch(entityId: EntityID) -> Bool { + if placedEntities.contains(entityId) { return false } if let cellId = entityToCellMembership[entityId] { + if ungatedCells.contains(cellId) { return false } if dirtyCells.contains(cellId) { return true } let state = cellLifecycle[cellId]?.state return state == .streaming || state == .batchPending @@ -721,6 +752,7 @@ public class BatchingSystem: @unchecked Sendable { } pendingTileResidentQueueHead = 0 tileResidentsAwaitingDrain.subtract(entityIds) + placedEntities.subtract(entityIds) } /// Full batching cleanup for tile entities that are about to be destroyed. @@ -749,6 +781,7 @@ public class BatchingSystem: @unchecked Sendable { } pendingTileResidentQueueHead = 0 tileResidentsAwaitingDrain.subtract(entityIds) + placedEntities.subtract(entityIds) // Queue removal from committed state. removeEntityFromBatchingTracking is called // by the batch tick; it only manipulates BatchingSystem-internal dictionaries and // does not access ECS data, so it is safe to call after destroyEntity. @@ -771,6 +804,7 @@ public class BatchingSystem: @unchecked Sendable { } pendingTileResidentQueueHead = 0 tileResidentsAwaitingDrain.subtract(entityIds) + placedEntities.subtract(entityIds) var affectedCells: Set = [] for entityId in entityIds { @@ -1102,6 +1136,7 @@ public class BatchingSystem: @unchecked Sendable { private func registerEntityForBatching(entityId: EntityID, deferBatchBuild: Bool) { guard let candidate = resolveBatchCandidate(entityId: entityId) else { guard scene.exists(entityId) else { + placedEntities.remove(entityId) removeEntityFromBatchingTracking(entityId: entityId) return } @@ -1111,6 +1146,7 @@ public class BatchingSystem: @unchecked Sendable { let hasWorld = scene.get(component: WorldTransformComponent.self, for: entityId) != nil let hasLocal = scene.get(component: LocalTransformComponent.self, for: entityId) != nil Logger.log(message: "[Batching] resolveBatchCandidate FAILED entity=\(entityId) staticBatch=\(hasStatic) render=\(hasRender) meshEmpty=\(meshEmpty) world=\(hasWorld) local=\(hasLocal)") + placedEntities.remove(entityId) removeEntityFromBatchingTracking(entityId: entityId) return } @@ -1136,6 +1172,9 @@ public class BatchingSystem: @unchecked Sendable { cellToEntities[newCell, default: []].insert(entityId) dirtyCells.insert(newCell) markCellDirtyForFallback(cellId: newCell, deferBatchBuild: deferBatchBuild) + if placedEntities.remove(entityId) != nil, ungatedCells.insert(newCell).inserted { + shadowCastersChanged() + } } private func updateCellVisibilityHistory() -> Set { @@ -1159,8 +1198,10 @@ public class BatchingSystem: @unchecked Sendable { guard var record = cellLifecycle[cellId] else { continue } guard record.state == .renderableUnbatched || record.state == .streaming else { continue } guard cellToEntities[cellId]?.isEmpty == false else { continue } - guard !runtimeBatchIneligibleCells.contains(cellId) else { continue } - if visibilityGatedBatchBuildEnabled { + // A cell of entities placed to stay is built unseen and whatever its size. + let ungated = ungatedCells.contains(cellId) + guard ungated || !runtimeBatchIneligibleCells.contains(cellId) else { continue } + if visibilityGatedBatchBuildEnabled, !ungated { let lastVisibleFrame = cellLastVisibleFrame[cellId] ?? Int.min let isVisibleNow = visibleCells.contains(cellId) let isRecentlyVisible = isVisibleNow || (lastVisibleFrame != Int.min && (tickCounter - lastVisibleFrame) <= recentWindow) @@ -1222,7 +1263,8 @@ public class BatchingSystem: @unchecked Sendable { let estimate = estimateCellWork(for: cellId) updateRuntimeCellEstimate(cellId: cellId, estimate: estimate) - if exceedsRuntimeComplexityGuard(estimate) { + let ungated = ungatedCells.contains(cellId) + if !ungated, exceedsRuntimeComplexityGuard(estimate) { metrics.skippedByComplexityGuard += 1 runtimeBatchIneligibleCells.insert(cellId) shadowCastersChanged() @@ -1239,7 +1281,7 @@ public class BatchingSystem: @unchecked Sendable { let lastVisibleFrame = cellLastVisibleFrame[cellId] ?? Int.min let isVisibleNow = visibleCells.contains(cellId) let isRecentlyVisible = isVisibleNow || (lastVisibleFrame != Int.min && (tickCounter - lastVisibleFrame) <= recentWindow) - if visibilityGatedBatchBuildEnabled, !isRecentlyVisible { + if visibilityGatedBatchBuildEnabled, !ungated, !isRecentlyVisible { metrics.deferredByVisibility += 1 continue } @@ -1968,6 +2010,7 @@ public class BatchingSystem: @unchecked Sendable { } guard cellToEntities[cellId]?.isEmpty ?? true else { return } + ungatedCells.remove(cellId) if cellLifecycle[cellId]?.state == .unloaded { cellLifecycle.removeValue(forKey: cellId) cellLastVisibleFrame.removeValue(forKey: cellId) @@ -2318,6 +2361,8 @@ public class BatchingSystem: @unchecked Sendable { pendingTileResidentQueue.removeAll() pendingTileResidentQueueHead = 0 tileResidentsAwaitingDrain.removeAll() + ungatedCells.removeAll() + placedEntities.removeAll() cellLastVisibleFrame.removeAll() cellBuildGeneration.removeAll() runtimeBatchIneligibleCells.removeAll() @@ -2380,6 +2425,7 @@ public class BatchingSystem: @unchecked Sendable { pendingTileResidentQueue.removeAll() pendingTileResidentQueueHead = 0 tileResidentsAwaitingDrain.removeAll() + placedEntities.removeAll() runtimeBatchIneligibleCells.removeAll() clearPendingBuildArtifacts() for cellId in cellToEntities.keys { diff --git a/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift b/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift index 37658ed18..0a6406c82 100644 --- a/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift +++ b/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift @@ -359,4 +359,42 @@ final class StaticBatchMembershipTests: BaseRenderSetup { XCTAssertTrue(isBatched(first), "after \(frames) frames") XCTAssertTrue(isBatched(second)) } + + // MARK: - Entities placed to stay + + func testPlacedEntitiesAreBatchedUnseenAndWhateverTheirSize() { + // Behind the camera, in a cell of their own, and over the size a streamed cell may have. + let first = place(cube(material(red)), at: simd_float3(5, 0, 200)) + let second = place(cube(material(red)), at: simd_float3(11, 0, 200)) + enableBatching(true) + setBatching(.visibilityGatedBuild(true)) + setBatching(.runtimeCellLimits(vertices: 8, indices: 8, bytes: 64)) + + // Told of as a tile is, they wait to be seen. + BatchingSystem.shared.notifyTileEntitiesResident([first, second]) + drawFrames() + XCTAssertFalse(isBatched(first)) + + BatchingSystem.shared.notifyStaticEntitiesPlaced([first, second]) + XCTAssertFalse(BatchingSystem.shared.awaitsBatch(entityId: first), "it casts for itself until its batch is built") + drawFrames() + + XCTAssertTrue(isBatched(first)) + XCTAssertTrue(isBatched(second)) + } + + func testPlacingEntitiesDoesNothingWhileBatchingIsOff() { + let first = place(cube(material(red)), at: simd_float3(5, 0, 0)) + let second = place(cube(material(red)), at: simd_float3(11, 0, 0)) + + BatchingSystem.shared.notifyStaticEntitiesPlaced([first, second]) + drawFrames() + XCTAssertFalse(isBatched(first)) + + // The marks are there for a later build. + enableBatching(true) + generateBatches() + XCTAssertTrue(isBatched(first)) + XCTAssertTrue(isBatched(second)) + } } diff --git a/docs/API/UsingStaticBatchingSystem.md b/docs/API/UsingStaticBatchingSystem.md index e72067ee3..23eb5aec2 100644 --- a/docs/API/UsingStaticBatchingSystem.md +++ b/docs/API/UsingStaticBatchingSystem.md @@ -39,6 +39,18 @@ setEntityMeshAsync(entityId: building, filename: "office_building", withExtensio } ``` +To add static content to a scene that is already running, tell the batching system about the new entities instead of rebuilding everything. Their cells are built in the background over the next frames, and each entity is drawn by itself until its batch is ready: + +```swift +setEntityMeshAsync(entityId: crate, filename: "crate", withExtension: "untold") { success in + guard success else { return } + setEntityStaticBatchComponent(entityId: crate) + BatchingSystem.shared.notifyStaticEntitiesPlaced([crate]) +} +``` + +Pass the entities that draw a mesh: the entity itself for a model of one node, as here, and its descendants that have a `RenderComponent` for a model of several. Entities without the mark are skipped, and the call does nothing while batching is disabled. + ## Shadows An entity in a batch casts through its batch. A marked entity that no batch took (it is alone with its material in its cell) casts its own shadow, from the sun and from spot and point lights. The one exception is a streamed tile that has just come in: its entities cast nothing for the few frames until their batch is built. @@ -122,6 +134,14 @@ setBatching(.visibilityGatedBuild(true)) setBatching(.backgroundArtifactBuild(true)) ``` +### `BatchingSystem.shared.notifyStaticEntitiesPlaced(_:)` + +Hands marked entities that are loaded and in place to the batching system. Their cells are built in the background, without waiting to be seen and without the per-cell size limits of streamed tiles. + +```swift +BatchingSystem.shared.notifyStaticEntitiesPlaced(entities) +``` + ### `generateBatches()` Builds batch artifacts for the currently marked static entities. This is mainly for always-resident/manual workflows. diff --git a/docs/Architecture/batchingSystem.md b/docs/Architecture/batchingSystem.md index c4ede25d0..cf563338d 100644 --- a/docs/Architecture/batchingSystem.md +++ b/docs/Architecture/batchingSystem.md @@ -26,6 +26,16 @@ When your 100 entities load, each one that has a `StaticBatchComponent` gets reg - If eligible → it gets assigned to a cell and added to `cellToEntities[cellId]`. - The cell is marked **dirty** and its state becomes `renderableUnbatched`. +An entity reaches registration in one of three ways: + +| Who tells the batching system | Call | How its cell is built | +|---|---|---| +| The tile streaming system, when a tile comes in | `notifyTileEntitiesResident(_:)` and residency events | Once the cell has been seen, and only if it is under the per-cell limits (2c and 2d below) | +| Whoever placed entities that are in the scene to stay: the pack loader, or your own code | `notifyStaticEntitiesPlaced(_:)` | As soon as the per-tick budgets allow: unseen cells too, and whatever their size | +| Nobody: you call `generateBatches()` | | All at once, in the call | + +A cell that received placed entities stays **ungated** for as long as it has entities: later rebuilds of it (an entity moved out, a material changed) do not wait for it to be seen either. + --- ## Step 2: Per-Frame Tick — The Pipeline @@ -59,7 +69,7 @@ This is the core build loop: 2. **Gather `batchPending` cells** and build rebuild candidates. For each: - Estimate the work: count total vertices + indices + bytes across all entities in the cell. - - If a cell **exceeds the per-cell complexity guard** (>160K verts, >300K indices, >8MB), it's flagged `runtimeIneligibleCells` and stays unbatched. + - If a cell **exceeds the per-cell complexity guard** (>160K verts, >300K indices, >8MB), it's flagged `runtimeIneligibleCells` and stays unbatched. Ungated cells skip this guard and the visibility check. - Otherwise it becomes a `CellRebuildCandidate`. 3. **Sort candidates** by priority: @@ -128,6 +138,7 @@ Batch groups cast in the directional, spot and point shadow passes. A marked ent | Is in a batch | The batch group | | Is in a streamed cell that waits for its batches, or belongs to a tile that is fading in or out | Nobody, for that short while. A tile that has just come in would otherwise add a shadow draw per entity | | Was left out when its cell was built (alone with its material, or the cell is over the per-cell limits) | Itself | +| Is in an ungated cell whose batch is not built yet | Itself | | Carries an occluder shell or a fade, or is not known to the batching system | Itself | --- From 2a87c87b5a8c43dbfe62317a80ee2e79a469d95f Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Sun, 4 Oct 2026 17:14:54 +0200 Subject: [PATCH 15/19] [Bugfix] A static entity that moves, loses its mark or is destroyed leaves its batch A batch holds the vertices of its entities where they stood when it was built, and nothing told the batching system when that stopped being true. An entity moved with the transform functions was still drawn at its old place by its batch. One that lost its StaticBatchComponent, or was destroyed, stayed in the batch until something else rebuilt its cell. The editor works around the first case by unmarking the entity and rebuilding every batch of the scene. The transform functions now note the entity that moves and every descendant that moves with it. Before the frame is drawn those entities are out of their batches, and their cells fall back to a draw per entity. A moved entity keeps its mark: after 30 frames at rest it is batched again where it stands. Unmarking and destroying take the entity out at once, and its cell is rebuilt without it. The transform functions are called from any thread, so they only append the entity to a list behind a lock, and only while there is an entity that a move could take out of a batch. --- .../UntoldEngine/Renderer/UntoldEngine.swift | 3 + .../UntoldEngine/Systems/BatchingSystem.swift | 119 ++++++++++++++++++ .../Systems/RegistrationSystem.swift | 2 + .../Systems/TransformSystem.swift | 3 + .../StaticBatchMembershipTests.swift | 115 +++++++++++++++++ docs/API/UsingStaticBatchingSystem.md | 11 ++ docs/API/UsingTransformSystem.md | 1 + docs/Architecture/batchingSystem.md | 14 +++ 8 files changed, 268 insertions(+) diff --git a/Sources/UntoldEngine/Renderer/UntoldEngine.swift b/Sources/UntoldEngine/Renderer/UntoldEngine.swift index 38d13928b..6f76b8e9c 100644 --- a/Sources/UntoldEngine/Renderer/UntoldEngine.swift +++ b/Sources/UntoldEngine/Renderer/UntoldEngine.swift @@ -728,6 +728,9 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { } #endif + // An entity that the update moved is out of its batch before the frame is drawn. + BatchingSystem.shared.releaseMovedEntities() + // render hook (platform-specific) render?() diff --git a/Sources/UntoldEngine/Systems/BatchingSystem.swift b/Sources/UntoldEngine/Systems/BatchingSystem.swift index bbcd90125..5f7dd1e9b 100644 --- a/Sources/UntoldEngine/Systems/BatchingSystem.swift +++ b/Sources/UntoldEngine/Systems/BatchingSystem.swift @@ -575,6 +575,19 @@ public class BatchingSystem: @unchecked Sendable { /// Entities handed to `notifyStaticEntitiesPlaced(_:)` that the next tick registers. /// The cell each one lands in joins `ungatedCells`. private var placedEntities: Set = [] + /// Marked entities that left their batch because they moved, with the tick of their + /// last move. One that has been at rest for `restFramesBeforeRebatch` is placed again. + private var movedEntities: [EntityID: Int] = [:] + /// Ticks without a move after which a marked entity that moved is batched where it + /// stands. Long enough for the pauses of a drag not to rebuild its cells each time. + var restFramesBeforeRebatch: Int = 30 + /// The entities whose transform changed since `releaseMovedEntities()` last ran. The + /// transform functions are called from any thread, so this is the one piece of the + /// system behind a lock; it is only filled while there is something to move out of. + private let movedInboxLock = NSLock() + private var movedInbox: [EntityID] = [] + private var movedInboxIsOpen = false + private var drainedMoves: [EntityID] = [] private var isSubscribed: Bool = false private var batchCellSize: Float = 32.0 /// Maps each cell to the UUIDs of its current batch groups. @@ -681,11 +694,89 @@ public class BatchingSystem: @unchecked Sendable { guard scene.exists(entityId), scene.get(component: StaticBatchComponent.self, for: entityId) != nil else { continue } + movedEntities.removeValue(forKey: entityId) pendingEntityAdditions.insert(entityId) placedEntities.insert(entityId) } } + /// The place of an entity in the world changed. + /// + /// A batch holds the vertices of its entities where they stood when it was built, so + /// an entity that moves leaves its batch at once: its cell goes back to a draw per + /// entity and is rebuilt without it. The entity keeps its mark, and is batched again + /// where it comes to rest. + /// + /// Called by the transform functions, from whatever thread they run on: the entity is + /// only noted here. `releaseMovedEntities()` acts on it, in the tick and once more + /// before the frame is drawn. + func notifyEntityMoved(entityId: EntityID) { + movedInboxLock.lock() + if movedInboxIsOpen { + movedInbox.append(entityId) + } + movedInboxLock.unlock() + } + + /// Takes the entities that moved since the last call out of their batches. + func releaseMovedEntities() { + movedInboxLock.lock() + guard !movedInbox.isEmpty else { + movedInboxLock.unlock() + return + } + swap(&movedInbox, &drainedMoves) + movedInboxLock.unlock() + + if batchingEnabled { + for entityId in drainedMoves { + if let cellId = entityToCellMembership[entityId] { + removeEntityFromBatchingTracking(entityId: entityId) + if cellToEntities[cellId]?.isEmpty == false { + ungatedCells.insert(cellId) + } + movedEntities[entityId] = tickCounter + } else if movedEntities[entityId] != nil { + movedEntities[entityId] = tickCounter + } + } + } + drainedMoves.removeAll(keepingCapacity: true) + } + + /// Moves are only noted while there is an entity they could take out of a batch. + private func refreshMovedInbox() { + let isOpen = batchingEnabled && !(entityToCellMembership.isEmpty && movedEntities.isEmpty) + movedInboxLock.lock() + movedInboxIsOpen = isOpen + if !isOpen { + movedInbox.removeAll(keepingCapacity: true) + } + movedInboxLock.unlock() + } + + /// An entity lost its mark, or is being destroyed: it leaves its batch at once, and + /// its cell is rebuilt without it. + func notifyEntityUnmarked(entityId: EntityID) { + movedEntities.removeValue(forKey: entityId) + placedEntities.remove(entityId) + pendingEntityAdditions.remove(entityId) + newlyResidentEntities.remove(entityId) + + shadowCastersChanged() + guard let cellId = entityToCellMembership[entityId] else { return } + removeEntityFromBatchingTracking(entityId: entityId) + guard batchingEnabled else { + // Nothing is built while batching is off; enabling it again starts from + // `generateBatches()` or from the next change. + dirtyCells.remove(cellId) + return + } + if cellToEntities[cellId]?.isEmpty == false { + ungatedCells.insert(cellId) + } + } + /// Whether the shadow passes draw a marked entity by itself. /// /// An entity in a batch is drawn with it. One in a streamed cell that waits for its @@ -753,6 +844,9 @@ public class BatchingSystem: @unchecked Sendable { pendingTileResidentQueueHead = 0 tileResidentsAwaitingDrain.subtract(entityIds) placedEntities.subtract(entityIds) + for entityId in entityIds { + movedEntities.removeValue(forKey: entityId) + } } /// Full batching cleanup for tile entities that are about to be destroyed. @@ -782,6 +876,9 @@ public class BatchingSystem: @unchecked Sendable { pendingTileResidentQueueHead = 0 tileResidentsAwaitingDrain.subtract(entityIds) placedEntities.subtract(entityIds) + for entityId in entityIds { + movedEntities.removeValue(forKey: entityId) + } // Queue removal from committed state. removeEntityFromBatchingTracking is called // by the batch tick; it only manipulates BatchingSystem-internal dictionaries and // does not access ECS data, so it is safe to call after destroyEntity. @@ -805,6 +902,9 @@ public class BatchingSystem: @unchecked Sendable { pendingTileResidentQueueHead = 0 tileResidentsAwaitingDrain.subtract(entityIds) placedEntities.subtract(entityIds) + for entityId in entityIds { + movedEntities.removeValue(forKey: entityId) + } var affectedCells: Set = [] for entityId in entityIds { @@ -981,6 +1081,10 @@ public class BatchingSystem: @unchecked Sendable { return } + // What moved since the last frame leaves its batch before anything is built. + releaseMovedEntities() + defer { refreshMovedInbox() } + // Drain deferred tile-resident entities at a controlled rate. // notifyTileEntitiesResident enqueues entity IDs here instead of processing // them immediately inside withWorldMutationGate. Each tick we take at most @@ -1018,6 +1122,16 @@ public class BatchingSystem: @unchecked Sendable { } } + // A marked entity that moved and has come to rest is batched where it stands. + if !movedEntities.isEmpty { + let restFrames = max(1, restFramesBeforeRebatch) + let atRest = movedEntities.filter { tickCounter - $0.value > restFrames }.map(\.key) + for entityId in atRest { + movedEntities.removeValue(forKey: entityId) + } + notifyStaticEntitiesPlaced(atRest) + } + // A cell built in the background is applied by a later tick, which may be one // with nothing else to do: the last cell of a scene that then stands still. guard !pendingEntityRemovals.isEmpty || @@ -2168,6 +2282,7 @@ public class BatchingSystem: @unchecked Sendable { setCellState(batchGroup.cellId, .renderableBatched) } + refreshMovedInbox() shadowCastersChanged() Logger.log(message: "✅ Created \(batchGroups.count) batch groups") @@ -2363,6 +2478,7 @@ public class BatchingSystem: @unchecked Sendable { tileResidentsAwaitingDrain.removeAll() ungatedCells.removeAll() placedEntities.removeAll() + movedEntities.removeAll() cellLastVisibleFrame.removeAll() cellBuildGeneration.removeAll() runtimeBatchIneligibleCells.removeAll() @@ -2371,6 +2487,7 @@ public class BatchingSystem: @unchecked Sendable { lastTickDiagnostics = .init() batchIndexNeedsRebuild = false tickCounter = 0 + refreshMovedInbox() shadowCastersChanged() } @@ -2426,12 +2543,14 @@ public class BatchingSystem: @unchecked Sendable { pendingTileResidentQueueHead = 0 tileResidentsAwaitingDrain.removeAll() placedEntities.removeAll() + movedEntities.removeAll() runtimeBatchIneligibleCells.removeAll() clearPendingBuildArtifacts() for cellId in cellToEntities.keys { setCellState(cellId, .renderableUnbatched) } } + refreshMovedInbox() shadowCastersChanged() } diff --git a/Sources/UntoldEngine/Systems/RegistrationSystem.swift b/Sources/UntoldEngine/Systems/RegistrationSystem.swift index ad2fd9565..33c57237e 100644 --- a/Sources/UntoldEngine/Systems/RegistrationSystem.swift +++ b/Sources/UntoldEngine/Systems/RegistrationSystem.swift @@ -6103,6 +6103,7 @@ private func removeEntityStaticBatchComponentRecursive(entityId: EntityID) { // Remove from this entity if it has the component if let _ = scene.get(component: StaticBatchComponent.self, for: entityId) { scene.remove(component: StaticBatchComponent.self, from: entityId) + BatchingSystem.shared.notifyEntityUnmarked(entityId: entityId) Logger.log(message: "✅ StaticBatchComponent removed from entity \(entityId)") } @@ -6117,6 +6118,7 @@ private func removeEntityStaticBatchComponentRecursive(entityId: EntityID) { func removeEntityStaticBatch(entityId: EntityID) { if scene.get(component: StaticBatchComponent.self, for: entityId) != nil { scene.remove(component: StaticBatchComponent.self, from: entityId) + BatchingSystem.shared.notifyEntityUnmarked(entityId: entityId) } } diff --git a/Sources/UntoldEngine/Systems/TransformSystem.swift b/Sources/UntoldEngine/Systems/TransformSystem.swift index 6cc983bd5..77d62534f 100644 --- a/Sources/UntoldEngine/Systems/TransformSystem.swift +++ b/Sources/UntoldEngine/Systems/TransformSystem.swift @@ -26,8 +26,10 @@ func syncWorldTransformAndMarkOctreeDirty(entityId: EntityID) { // Keep world transforms and octree bounds current for the whole hierarchy. // Imported multi-mesh assets usually have renderable children under a non-render root. + // A batch holds its entities where they stood: what moves leaves it. guard scene.get(component: ScenegraphComponent.self, for: entityId) != nil else { OctreeSystem.shared.markDirty(entityId) + BatchingSystem.shared.notifyEntityMoved(entityId: entityId) return } @@ -45,6 +47,7 @@ func syncWorldTransformAndMarkOctreeDirty(entityId: EntityID) { } OctreeSystem.shared.markDirty(current) + BatchingSystem.shared.notifyEntityMoved(entityId: current) queue.append(contentsOf: getEntityChildren(parentId: current)) } } diff --git a/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift b/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift index 0a6406c82..4ebaf579f 100644 --- a/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift +++ b/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift @@ -24,12 +24,16 @@ final class StaticBatchMembershipTests: BaseRenderSetup { override func setUp() async throws { try await super.setUp() resetBatching() + // A moved entity is batched again after one frame at rest, so that a test does + // not have to draw the half second of frames the engine waits by default. + BatchingSystem.shared.restFramesBeforeRebatch = 1 // Every entity of these scenes is meant to be drawn, however small on screen. setRendering(.smallObjectCulling(pixels: 0)) } override func tearDown() async throws { resetBatching() + BatchingSystem.shared.restFramesBeforeRebatch = 30 setRendering(.smallObjectCulling(pixels: 1)) destroyAllEntities() try await super.tearDown() @@ -360,6 +364,117 @@ final class StaticBatchMembershipTests: BaseRenderSetup { XCTAssertTrue(isBatched(second)) } + // MARK: - An entity that moves + + func testAnEntityThatMovesLeavesItsBatchAndIsBatchedAgainWhereItStops() throws { + placeGround() + let first = place(cube(material(red)), at: simd_float3(5, 0, 0)) + let second = place(cube(material(red)), at: simd_float3(11, 0, 0)) + enableBatching(true) + generateBatches() + drawFrames() + XCTAssertTrue(isBatched(first)) + XCTAssertTrue(isBatched(second)) + XCTAssertEqual(try XCTUnwrap(batchedBounds).min.x, 4.5, accuracy: 0.01) + + translateTo(entityId: first, position: simd_float3(2, 0, 2)) + renderer.draw(in: renderer.metalView) + renderInfo.lastCommandBuffer?.waitUntilCompleted() + + XCTAssertFalse(isBatched(first), "the frame after the move is drawn without the batch") + XCTAssertFalse(isBatched(second), "its neighbour is alone now") + XCTAssertNotNil(scene.get(component: StaticBatchComponent.self, for: first), "it keeps its mark") + + drawFrames() + XCTAssertTrue(isBatched(first), "at rest, it is batched where it stands") + XCTAssertTrue(isBatched(second)) + XCTAssertEqual(try XCTUnwrap(batchedBounds).min.x, 1.5, accuracy: 0.01) + + try assertTheFrameIsTheOneWithoutBatches() + } + + func testAnEntityThatKeepsMovingStaysOutOfItsBatch() { + placeGround() + let first = place(cube(material(red)), at: simd_float3(5, 0, 0)) + let second = place(cube(material(red)), at: simd_float3(11, 0, 0)) + enableBatching(true) + generateBatches() + drawFrames() + XCTAssertTrue(isBatched(first)) + + for step in 1 ... 8 { + translateTo(entityId: first, position: simd_float3(5 - Float(step) * 0.2, 0, 0)) + renderer.draw(in: renderer.metalView) + renderInfo.lastCommandBuffer?.waitUntilCompleted() + XCTAssertFalse(isBatched(first), "step \(step)") + XCTAssertFalse(isBatched(second), "step \(step)") + } + } + + func testEntitiesMovedWithTheirParentAreBatchedAgainWhereTheyStop() throws { + placeGround() + let parent = createEntity() + registerTransformComponent(entityId: parent) + registerSceneGraphComponent(entityId: parent) + let first = place(cube(material(red)), at: simd_float3(5, 0, 0), under: parent) + let second = place(cube(material(red)), at: simd_float3(11, 0, 0), under: parent) + enableBatching(true) + generateBatches() + drawFrames() + XCTAssertTrue(isBatched(first)) + XCTAssertEqual(try XCTUnwrap(batchedBounds).max.y, 0.5, accuracy: 0.01) + + translateTo(entityId: parent, position: simd_float3(0, 2, 0)) + renderer.draw(in: renderer.metalView) + renderInfo.lastCommandBuffer?.waitUntilCompleted() + XCTAssertFalse(isBatched(first)) + XCTAssertFalse(isBatched(second)) + + drawFrames() + XCTAssertTrue(isBatched(first)) + XCTAssertTrue(isBatched(second)) + XCTAssertEqual(try XCTUnwrap(batchedBounds).max.y, 2.5, accuracy: 0.01) + try assertTheFrameIsTheOneWithoutBatches() + } + + // MARK: - An entity that stops being static + + func testAnEntityThatLosesItsMarkLeavesItsBatch() throws { + placeGround() + let first = place(cube(material(red)), at: simd_float3(5, 0, 0)) + let second = place(cube(material(red)), at: simd_float3(8, 0, 0)) + let third = place(cube(material(red)), at: simd_float3(11, 0, 0)) + enableBatching(true) + generateBatches() + drawFrames() + XCTAssertEqual(batchedEntities, [first, second, third]) + + removeEntityStaticBatchComponent(entityId: first) + XCTAssertFalse(isBatched(first), "at once: the next frame draws it by itself") + drawFrames() + + XCTAssertEqual(batchedEntities, [second, third]) + try assertTheFrameIsTheOneWithoutBatches() + } + + func testADestroyedEntityIsNotDrawnByItsBatch() throws { + placeGround() + let first = place(cube(material(red)), at: simd_float3(5, 0, 0)) + let second = place(cube(material(red)), at: simd_float3(8, 0, 0)) + let third = place(cube(material(red)), at: simd_float3(11, 0, 0)) + enableBatching(true) + generateBatches() + drawFrames() + XCTAssertEqual(batchedEntities, [first, second, third]) + + destroyEntity(entityId: first) + drawFrames() + + XCTAssertEqual(batchedEntities, [second, third]) + XCTAssertEqual(try XCTUnwrap(batchedBounds).min.x, 7.5, accuracy: 0.01, "its vertices are gone with it") + try assertTheFrameIsTheOneWithoutBatches() + } + // MARK: - Entities placed to stay func testPlacedEntitiesAreBatchedUnseenAndWhateverTheirSize() { diff --git a/docs/API/UsingStaticBatchingSystem.md b/docs/API/UsingStaticBatchingSystem.md index 23eb5aec2..9fb7a768e 100644 --- a/docs/API/UsingStaticBatchingSystem.md +++ b/docs/API/UsingStaticBatchingSystem.md @@ -51,6 +51,17 @@ setEntityMeshAsync(entityId: crate, filename: "crate", withExtension: "untold") Pass the entities that draw a mesh: the entity itself for a model of one node, as here, and its descendants that have a `RenderComponent` for a model of several. Entities without the mark are skipped, and the call does nothing while batching is disabled. +## Static Entities That Move + +A batch holds its entities where they stood when it was built. You do not have to unmark an entity before moving it: + +- An entity moved with the transform functions leaves its batch before the next frame is drawn, together with every descendant that moves with it. Its neighbours go back to one draw each until their cell is rebuilt. +- It keeps its mark. Thirty frames after it stops, it is batched again where it stands. +- An entity that never stops moving stays out of batching. It costs nothing more than an unmarked entity, but it gains nothing either, so do not mark what moves all the time. +- An entity that is destroyed, or that loses its mark, leaves its batch the same way. + +To move a whole scene without touching its batches, move the scene root instead (see [Transform System](UsingTransformSystem.md)). + ## Shadows An entity in a batch casts through its batch. A marked entity that no batch took (it is alone with its material in its cell) casts its own shadow, from the sun and from spot and point lights. The one exception is a streamed tile that has just come in: its entities cast nothing for the few frames until their batch is built. diff --git a/docs/API/UsingTransformSystem.md b/docs/API/UsingTransformSystem.md index fbe3ec7a0..ae664d1d7 100644 --- a/docs/API/UsingTransformSystem.md +++ b/docs/API/UsingTransformSystem.md @@ -178,6 +178,7 @@ This resets position/rotation/scale and refreshes root matrices immediately. - Use Scene-Level Translation for Batch-Safe Movement: - Use `translateSceneTo` / `translateSceneBy` instead of moving every entity individually. - This avoids breaking static batches and is ideal for spatial drag gestures on the whole scene. + - An entity marked static that you move by itself leaves its batch until it has stopped, and is batched again where it stands (see [Static Batching](UsingStaticBatchingSystem.md)). - Use Scene-Level Yaw Rotation for Batch-Safe Alignment: - Use `rotateSceneToYaw` / `rotateSceneByYaw` to rotate large scenes around world up. - This is ideal for scene alignment and calibration flows where static batching must stay active. diff --git a/docs/Architecture/batchingSystem.md b/docs/Architecture/batchingSystem.md index cf563338d..291e5b11c 100644 --- a/docs/Architecture/batchingSystem.md +++ b/docs/Architecture/batchingSystem.md @@ -143,6 +143,20 @@ Batch groups cast in the directional, spot and point shadow passes. A marked ent --- +## An Entity That Stops Being Static + +A batch holds the vertices of its entities where they stood when it was built. Three things take an entity out of its batch at once, so that no frame draws it at its old place or draws it twice: + +- **It moves.** The transform functions (`translateTo`, `rotateTo`, `scaleTo` and the rest) note the entity and every descendant that moves with it. Before the next frame is drawn they are out of their batches, and their cells fall back to a draw per entity. The entity keeps its `StaticBatchComponent`: once it has been at rest for 30 frames it is registered again where it stands, in an ungated cell. An entity that moves every frame simply stays out. +- **It loses its mark** (`removeEntityStaticBatchComponent`). +- **It is destroyed.** + +In all three cases the cell is rebuilt without the entity through the normal tick, in the background. + +Moving the **scene root** (`translateSceneTo`, `rotateSceneToYaw`, ...) is not a move in this sense: batches are drawn through the scene root's transform and stay as they are. + +--- + ## Step 6: Retirement (Safe GPU Buffer Release) Old GPU buffers aren't freed immediately. They go into `retiringBatchArtifacts` with a `retireAfterFrame = currentFrame + 3`. After 3 frames, the system drops the Swift reference, allowing ARC to release the MTLBuffers — guaranteeing the GPU has finished with them. From 8f9fcf5a14844f2cf7199773a0e8a85034ffd663 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Sun, 4 Oct 2026 17:15:47 +0200 Subject: [PATCH 16/19] [Performance] Ask whether an entity is marked before asking whether it is batched Each pass asked the batching system about every visible entity, a dictionary lookup per entity and pass: 7 % of the CPU time of a frame with 17,000 visible entities and batches on. Only an entity with a StaticBatchComponent can be in a batch, now that one that loses the component leaves its batch at once, and the component is a bit of the snapshot the pass already holds. --- Sources/UntoldEngine/Renderer/RenderPasses.swift | 8 +++++--- docs/Architecture/batchingSystem.md | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index ae1a6dd1d..dc8c3dc88 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -2106,6 +2106,7 @@ public enum RenderPasses { // Skip batched entities if batching is enabled let fadingLOD = pass.fadingLOD(of: entity) if pass.batchingEnabled, + traits.contains(.staticBatch), BatchingSystem.shared.isBatched(entityId: entityId), fadingLOD?.previousLOD == nil, !traits.contains(.tileRepresentationFade), @@ -2715,6 +2716,7 @@ public enum RenderPasses { if renderMode == .hidden || renderMode == .wireframe { continue } let fadingLOD = pass.fadingLOD(of: entity) if pass.batchingEnabled, + traits.contains(.staticBatch), BatchingSystem.shared.isBatched(entityId: entityId), fadingLOD?.previousLOD == nil, !traits.contains(.tileRepresentationFade), @@ -4083,7 +4085,7 @@ public enum RenderPasses { let renderMode = renderModes.mode(of: sceneSnapshot.sceneChannels(of: entity)) if renderMode == .hidden || renderMode == .wireframe { continue } - if batchingEnabled, BatchingSystem.shared.isBatched(entityId: entityId) { + if batchingEnabled, entity.traits.contains(.staticBatch), BatchingSystem.shared.isBatched(entityId: entityId) { continue } @@ -4595,7 +4597,7 @@ public enum RenderPasses { // A hidden channel wins over a wireframe one, so the mode alone tells both. guard renderModes.mode(of: sceneSnapshot.sceneChannels(of: entity)) == .wireframe else { return } let entityId = entity.entityId - if batchingEnabled, BatchingSystem.shared.isBatched(entityId: entityId) { return } + if batchingEnabled, entity.traits.contains(.staticBatch), BatchingSystem.shared.isBatched(entityId: entityId) { return } if !entity.traits.isDisjoint(with: [.sceneCamera, .camera, .gizmo, .light]) { return } guard let components = sceneSnapshot.drawComponents(of: entity), components.render.isVisible @@ -4785,7 +4787,7 @@ public enum RenderPasses { guard let entity = sceneSnapshot.entity(entityId), renderModes.mode(of: sceneSnapshot.sceneChannels(of: entity)) == .wireframe else { return false } - if batchingEnabled, BatchingSystem.shared.isBatched(entityId: entityId) { return false } + if batchingEnabled, entity.traits.contains(.staticBatch), BatchingSystem.shared.isBatched(entityId: entityId) { return false } if !entity.traits.isDisjoint(with: [.sceneCamera, .camera, .gizmo, .light]) { return false } guard let components = sceneSnapshot.drawComponents(of: entity) else { return false } diff --git a/docs/Architecture/batchingSystem.md b/docs/Architecture/batchingSystem.md index 291e5b11c..cdf4071e2 100644 --- a/docs/Architecture/batchingSystem.md +++ b/docs/Architecture/batchingSystem.md @@ -127,7 +127,7 @@ The renderer uses **cluster-level frustum culling** to determine which batch gro For batch groups that survive the AABB test and scene-channel visibility filter, each `BatchGroup` is one draw call with its merged buffer. 100 entities sharing one material and channel mask = **1 draw call**, submitted only when the group's spatial bounds are within the frustum and its channels are visible. -Per-entity batching membership (`entityToBatch`) is still maintained and used by non-batched rendering paths and by tests. +Per-entity batching membership (`entityToBatch`) is still maintained and used by non-batched rendering paths and by tests. Only an entity with a `StaticBatchComponent` can be in a batch, so the passes look at the component first (a bit they already have) and ask the batching system only about marked entities. ### Shadows From c303a8b4de068358fe659c0d65e394fee4b6a835 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Sun, 4 Oct 2026 17:17:27 +0200 Subject: [PATCH 17/19] [Performance] Batch the parts that a pack places once A model that a pack places several times is drawn with instancing. The models it places a single time have nothing to share a draw with, and a building exported from a BIM tool is mostly that. The pack loader now marks those parts static: the parts of a model that the pack places once and that has no LOD chain, without the ones no batch takes (a transparent material, a skeleton, an animation). When the pack is in, it hands them to the batching system, which merges them by material in the background. With batching disabled the marks do nothing, and a later generateBatches() uses them. setBatching(.packSinglePlacements(false)) turns the marking off. On a site of 21,576 parts the loader marks 4,373, and 4,227 of them end up in 213 batches of 37 MB, built over the 22 frames after the load. Six views at 1080p, three alternating rounds against the build before: a frame goes from 19.6 to 18.2 ms from above and from 25.5-31.3 to 20.8-26.8 ms among the trees, its CPU time from 12.3-13.3 to 9.8-11.7 ms, the draws from 7,340 to 4,236. Three of the six views are the same pixel for pixel. In another a pair of coplanar parts is drawn in the other order (682 pixels of 2 million), and close to a tree the faint self-shadows of the leaves are gone (15,000 pixels, most by 1 or 2 of 255): the first cascade is fitted to the boxes of the batches and covers 30 m in depth where it covered 24. --- .../UntoldEngine/Systems/BatchingSystem.swift | 3 + .../Systems/RegistrationSystem.swift | 61 +++- .../Utils/EngineSettingsAPI.swift | 7 + .../StaticBatchMembershipTests.swift | 1 + .../UntoldPackStaticPartsTests.swift | 286 ++++++++++++++++++ docs/API/UsingEngineAPI.md | 1 + docs/API/UsingStaticBatchingSystem.md | 30 ++ 7 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 Tests/UntoldEngineRenderTests/UntoldPackStaticPartsTests.swift diff --git a/Sources/UntoldEngine/Systems/BatchingSystem.swift b/Sources/UntoldEngine/Systems/BatchingSystem.swift index 5f7dd1e9b..4531d734b 100644 --- a/Sources/UntoldEngine/Systems/BatchingSystem.swift +++ b/Sources/UntoldEngine/Systems/BatchingSystem.swift @@ -535,6 +535,9 @@ public class BatchingSystem: @unchecked Sendable { private var entityToCellMembership: [EntityID: BatchCellID] = [:] // Track candidate cell membership private var cellToEntities: [BatchCellID: Set] = [:] // Candidate entities by cell private var batchingEnabled: Bool = false + /// Whether loading a pack marks as static the parts of the models it places once + /// (`setBatching(.packSinglePlacements(_:))`). + public var marksPackSinglePlacements: Bool = true // Dirty tracking for incremental updates private var dirtyCells: Set = [] diff --git a/Sources/UntoldEngine/Systems/RegistrationSystem.swift b/Sources/UntoldEngine/Systems/RegistrationSystem.swift index 33c57237e..bf4981034 100644 --- a/Sources/UntoldEngine/Systems/RegistrationSystem.swift +++ b/Sources/UntoldEngine/Systems/RegistrationSystem.swift @@ -2885,6 +2885,13 @@ private final class PackLoadDispatcher: @unchecked Sendable { private var nextIndex = 0 private var remaining: Int private var overallSuccess = true + /// How many times the pack places each model file. A file placed several times is + /// drawn with instancing; the parts of one placed a single time are marked static, so + /// that the batching system merges them by material (`markStaticParts(of:)`). + private let placementCounts: [String: Int] + private let marksSinglePlacements: Bool + /// The parts marked static so far, handed to the batching system when the pack is in. + private var staticParts: [EntityID] = [] /// One parse and one GPU build per distinct .untold for the duration of the pack /// load; dropped with the dispatcher, after which the entities' copies keep the /// shared buffers alive. @@ -2905,6 +2912,8 @@ private final class PackLoadDispatcher: @unchecked Sendable { self.rootEntityId = rootEntityId self.completionBox = completionBox remaining = models.count + placementCounts = models.reduce(into: [:]) { counts, model in counts[model.path, default: 0] += 1 } + marksSinglePlacements = BatchingSystem.shared.marksPackSinglePlacements } func start() { @@ -2934,6 +2943,9 @@ private final class PackLoadDispatcher: @unchecked Sendable { let lodLevels = (lodChains[model.path] ?? []).map { PackLODChain.Level(url: packDir.appendingPathComponent($0.path), screenSize: $0.screenSize) } + // A model with a LOD chain changes its meshes as the camera moves, which a batch + // cannot follow without being rebuilt each time. + let isStatic = marksSinglePlacements && placementCounts[model.path] == 1 && lodLevels.isEmpty withWorldMutationGate { var lodChain: PackLODChain? @@ -2956,6 +2968,7 @@ private final class PackLoadDispatcher: @unchecked Sendable { sharedBuilds: builds, lodChain: lodChain ) { success in + var staticParts: [EntityID] = [] withWorldMutationGate { translateTo(entityId: childId, position: position) scaleTo(entityId: childId, scale: scale) @@ -2968,21 +2981,32 @@ private final class PackLoadDispatcher: @unchecked Sendable { // Failed child loads also keep this placement so fallback cubes mark the // broken pack model's intended location. setEntityName(entityId: childId, name: displayName) + if success, isStatic { + staticParts = markStaticParts(of: childId) + } } - self.recordCompletionAndAdvance(success) + self.recordCompletionAndAdvance(success, staticParts: staticParts) } } } - private func recordCompletionAndAdvance(_ success: Bool) { + private func recordCompletionAndAdvance(_ success: Bool, staticParts newStaticParts: [EntityID]) { lock.lock() overallSuccess = overallSuccess && success + staticParts.append(contentsOf: newStaticParts) remaining -= 1 let isDone = remaining <= 0 let result = overallSuccess + let placedParts = isDone ? staticParts : [] lock.unlock() if isDone { + // With batching disabled the marks wait for `generateBatches()`. + if !placedParts.isEmpty { + withWorldMutationGate { + BatchingSystem.shared.notifyStaticEntitiesPlaced(placedParts) + } + } completionBox?.call(result) } else { startNext() @@ -2990,6 +3014,39 @@ private final class PackLoadDispatcher: @unchecked Sendable { } } +/// Marks the parts of a pack placement as static and returns the ones it marked. +/// +/// Left unmarked is what no batch takes (`BatchingSystem.resolveBatchCandidate`): a part +/// with a transparent material, one with a skeleton or an animation, and one that keeps +/// its identity (`shouldPreserveSceneEntityIdentity`). Must run inside the world mutation +/// gate. +private func markStaticParts(of placementId: EntityID) -> [EntityID] { + var marked: [EntityID] = [] + var queue: [EntityID] = [placementId] + var index = 0 + while index < queue.count { + let entityId = queue[index] + index += 1 + queue.append(contentsOf: getEntityChildren(parentId: entityId)) + + guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId), + !renderComponent.mesh.isEmpty, + !renderComponent.mesh.contains(where: { mesh in + mesh.submeshes.contains { $0.material?.hasTransparency ?? false } + }), + scene.get(component: SkeletonComponent.self, for: entityId) == nil, + scene.get(component: AnimationComponent.self, for: entityId) == nil, + !shouldPreserveSceneEntityIdentity(entityId: entityId) + else { continue } + + if !hasComponent(entityId: entityId, componentType: StaticBatchComponent.self) { + registerComponent(entityId: entityId, componentType: StaticBatchComponent.self) + } + marked.append(entityId) + } + return marked +} + /// Loads every model referenced by a `.untoldpack` manifest as a child entity under /// `rootEntityId`, placed using each model's manifest transform. Called from /// `setEntityMeshAsync` when it resolves a `.untoldpack` path, so callers never have diff --git a/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift b/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift index 431614f25..14be05ca4 100644 --- a/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift +++ b/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift @@ -682,6 +682,11 @@ public enum BatchingProperty: Sendable { case runtimeCellLimits(vertices: Int, indices: Int, bytes: Int) case quiescenceFramesBeforeBuild(Int) case recentVisibilityWindowFrames(Int) + /// Whether loading a `.untoldpack` marks as static the parts of the models it places + /// a single time and that have no LOD chain, so that batching merges them by material + /// (a model placed several times is drawn with instancing instead). On by default; + /// the marks only take effect while batching is enabled. + case packSinglePlacements(Bool) } public func setBatching(_ property: BatchingProperty) { @@ -720,6 +725,8 @@ public func setBatching(_ property: BatchingProperty) { batching.setQuiescenceFramesBeforeBatchBuild(value) case let .recentVisibilityWindowFrames(value): batching.setRecentVisibilityWindowFrames(value) + case let .packSinglePlacements(value): + batching.marksPackSinglePlacements = value } } diff --git a/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift b/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift index 4ebaf579f..d5376d474 100644 --- a/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift +++ b/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift @@ -67,6 +67,7 @@ final class StaticBatchMembershipTests: BaseRenderSetup { setBatching(.backgroundArtifactBuild(false)) setBatching(.maxBuildDispatchesPerTick(8)) setBatching(.maxArtifactAppliesPerTick(8)) + setBatching(.packSinglePlacements(true)) } private func material(_ baseColor: simd_float4, clearCoat: Float = 0) -> Material { diff --git a/Tests/UntoldEngineRenderTests/UntoldPackStaticPartsTests.swift b/Tests/UntoldEngineRenderTests/UntoldPackStaticPartsTests.swift new file mode 100644 index 000000000..bd27ac297 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/UntoldPackStaticPartsTests.swift @@ -0,0 +1,286 @@ +// +// UntoldPackStaticPartsTests.swift +// UntoldEngine +// +// Loading a `.untoldpack` marks as static the parts of the models it places a single +// time, and hands them to the batching system: which parts get the mark, and what +// becomes of them with batching on and off. +// +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +import simd +@testable import UntoldEngine +@testable import UntoldEngineMeshCook +import XCTest + +final class UntoldPackStaticPartsTests: BaseRenderSetup { + private var tempRoot: URL! + + override func setUp() async throws { + try await super.setUp() + LoadingSystem.shared.resourceURLFn = getResourceURL + LODSystem.shared.reset() + resetBatching() + BatchingSystem.shared.restFramesBeforeRebatch = 1 + tempRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("UntoldPackStaticPartsTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempRoot, withIntermediateDirectories: true) + } + + override func tearDown() async throws { + LoadingSystem.shared.resourceURLFn = getResourceURL + LODSystem.shared.reset() + resetBatching() + BatchingSystem.shared.restFramesBeforeRebatch = 30 + destroyAllEntities() + if let tempRoot { + try? FileManager.default.removeItem(at: tempRoot) + } + try await super.tearDown() + } + + override func initializeAssets() { + let camera = createEntity() + createGameCamera(entityId: camera) + CameraSystem.shared.activeCamera = camera + cameraLookAt(entityId: camera, eye: simd_float3(8, 6, 16), target: simd_float3(8, 0, 0), up: simd_float3(0, 1, 0)) + } + + // MARK: - Helpers + + /// Batching off and empty, with every cell built in the tick that finds it dirty. + private func resetBatching() { + clearSceneBatches() + enableBatching(false) + setBatching(.cellSize(32)) + setBatching(.maxDirtyCellsPerTick(8)) + setBatching(.rebuildBudgets(vertices: 1_000_000_000, indices: 1_000_000_000, bytes: 1_000_000_000)) + setBatching(.runtimeCellLimits(vertices: 1_000_000_000, indices: 1_000_000_000, bytes: 1_000_000_000)) + setBatching(.quiescenceFramesBeforeBuild(0)) + setBatching(.recentVisibilityWindowFrames(120)) + setBatching(.visibilityGatedBuild(false)) + setBatching(.backgroundArtifactBuild(false)) + setBatching(.maxBuildDispatchesPerTick(8)) + setBatching(.maxArtifactAppliesPerTick(8)) + setBatching(.packSinglePlacements(true)) + } + + private struct Placement { + var name: String + /// A bundled test model: "ball" (one mesh) or "stadium" (seven). + var asset: String + var path: String + var position = SIMD3(0, 0, 0) + } + + /// Copies the bundled models to their paths under tempRoot and writes a manifest that + /// places them. The scene of these tests stands around x = 8, clear of the border + /// between two batch cells at 0. + private func writePack(_ placements: [Placement], cookChains: Bool = false) throws -> URL { + var models: [[String: Any]] = [] + for placement in placements { + let source = try XCTUnwrap( + LoadingSystem.shared.resourceURL(forResource: placement.asset, withExtension: "untold"), + "bundled \(placement.asset).untold not found" + ) + let destination = tempRoot.appendingPathComponent(placement.path) + if !FileManager.default.fileExists(atPath: destination.path) { + try FileManager.default.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.copyItem(at: source, to: destination) + } + models.append([ + "displayName": placement.name, + "path": placement.path, + "transform": [ + [1, 0, 0, Double(placement.position.x)], + [0, 1, 0, Double(placement.position.y)], + [0, 0, 1, Double(placement.position.z)], + [0, 0, 0, 1], + ], + ]) + } + let manifest: [String: Any] = ["formatVersion": 1, "sourceAsset": "Site.blend", "models": models] + let packURL = tempRoot.appendingPathComponent("Site").appendingPathExtension("untoldpack") + try JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted]).write(to: packURL) + if cookChains { + let report = try UntoldMeshLODCooker.cookChains(forPackAt: packURL, options: UntoldMeshLODOptions(minimumTriangles: 0)) + XCTAssertTrue(report.failures.isEmpty) + } + return packURL + } + + /// Loads a pack and returns its root and its placements by display name. + private func loadPack(_ packURL: URL) async throws -> (root: EntityID, children: [String: EntityID]) { + let rootId = createEntity() + let expectation = expectation(description: "pack load completes") + setEntityMeshAsync(entityId: rootId, filename: packURL.deletingPathExtension().path, withExtension: "untoldpack") { success in + XCTAssertTrue(success) + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 20.0) + let scenegraph = try XCTUnwrap(scene.get(component: ScenegraphComponent.self, for: rootId)) + return (rootId, Dictionary(uniqueKeysWithValues: scenegraph.children.map { (getEntityName(entityId: $0), $0) })) + } + + /// The entities of a placement that draw a mesh: the placement itself for a model of + /// one node, its descendants for one of several. + private func parts(of placement: EntityID) -> [EntityID] { + var found: [EntityID] = [] + var queue = [placement] + while let entity = queue.popLast() { + if scene.get(component: RenderComponent.self, for: entity) != nil { + found.append(entity) + } + queue.append(contentsOf: getEntityChildren(parentId: entity)) + } + return found + } + + private func isMarked(_ entity: EntityID) -> Bool { + scene.get(component: StaticBatchComponent.self, for: entity) != nil + } + + private func isBatched(_ entity: EntityID) -> Bool { + BatchingSystem.shared.isBatched(entityId: entity) + } + + private func drawFrames(_ count: Int = 6) { + for _ in 0 ..< count { + renderer.draw(in: renderer.metalView) + renderInfo.lastCommandBuffer?.waitUntilCompleted() + } + } + + /// Two balls from two files, each placed once and close enough to share a batch cell. + /// The files stand in one folder, as the models of a cooked pack do: their materials + /// name the same textures, so they are one material to the batching system. + private func twoSingleBalls(z: Float = 0) -> [Placement] { + [ + Placement(name: "First", asset: "ball", path: "Models/First.untold", position: SIMD3(6, 0, z)), + Placement(name: "Second", asset: "ball", path: "Models/Second.untold", position: SIMD3(10, 0, z)), + ] + } + + // MARK: - Which parts are marked + + func testThePartsOfAModelPlacedOnceAreMarkedStatic() async throws { + let packURL = try writePack([ + Placement(name: "Single", asset: "ball", path: "Single/Ball.untold", position: SIMD3(4, 0, 0)), + Placement(name: "Twin A", asset: "ball", path: "Twin/Ball.untold", position: SIMD3(8, 0, 0)), + Placement(name: "Twin B", asset: "ball", path: "Twin/Ball.untold", position: SIMD3(12, 0, 0)), + Placement(name: "Stadium", asset: "stadium", path: "Stadium/Stadium.untold", position: SIMD3(8, 0, -20)), + ]) + + let (_, children) = try await loadPack(packURL) + + let single = try parts(of: XCTUnwrap(children["Single"])) + XCTAssertEqual(single.count, 1) + XCTAssertTrue(single.allSatisfy(isMarked)) + + // A model placed twice is drawn with instancing: one copy of its meshes. + for name in ["Twin A", "Twin B"] { + let twin = try parts(of: XCTUnwrap(children[name])) + XCTAssertEqual(twin.count, 1) + XCTAssertFalse(twin.contains(where: isMarked), name) + } + + // Every node of a model of several. + let stadium = try parts(of: XCTUnwrap(children["Stadium"])) + XCTAssertEqual(stadium.count, 7) + XCTAssertTrue(stadium.allSatisfy(isMarked)) + } + + func testAModelWithALODChainIsNotMarked() async throws { + let packURL = try writePack( + [Placement(name: "Single", asset: "ball", path: "Single/Ball.untold", position: SIMD3(4, 0, 0))], + cookChains: true + ) + + let (_, children) = try await loadPack(packURL) + + let single = try parts(of: XCTUnwrap(children["Single"])) + XCTAssertNotNil(scene.get(component: LODComponent.self, for: single[0]), "the chain was loaded") + XCTAssertFalse(single.contains(where: isMarked), "its meshes change with the distance, which a batch cannot follow") + } + + func testTheSettingTurnsTheMarkingOff() async throws { + setBatching(.packSinglePlacements(false)) + let packURL = try writePack(twoSingleBalls()) + + let (root, _) = try await loadPack(packURL) + + XCTAssertEqual(parts(of: root).count, 2) + XCTAssertFalse(parts(of: root).contains(where: isMarked)) + } + + // MARK: - What becomes of the marked parts + + func testThePartsAreBatchedOnceThePackIsIn() async throws { + enableBatching(true) + let packURL = try writePack(twoSingleBalls()) + + let (root, _) = try await loadPack(packURL) + drawFrames() + + let balls = parts(of: root) + XCTAssertEqual(balls.count, 2) + XCTAssertTrue(balls.allSatisfy(isBatched), "two files with the same material are one batch") + XCTAssertEqual(BatchingSystem.shared.batchGroups.count, 1) + } + + func testThePartsAreBatchedUnseenAndWhateverTheirSize() async throws { + enableBatching(true) + setBatching(.visibilityGatedBuild(true)) + setBatching(.runtimeCellLimits(vertices: 8, indices: 8, bytes: 64)) + // Behind the camera. + let packURL = try writePack(twoSingleBalls(z: 200)) + + let (root, _) = try await loadPack(packURL) + drawFrames() + + XCTAssertTrue(parts(of: root).allSatisfy(isBatched)) + } + + func testWithBatchingOffTheMarksWaitForABuild() async throws { + let packURL = try writePack(twoSingleBalls()) + + let (root, _) = try await loadPack(packURL) + drawFrames() + + let balls = parts(of: root) + XCTAssertTrue(balls.allSatisfy(isMarked)) + XCTAssertFalse(balls.contains(where: isBatched)) + XCTAssertTrue(BatchingSystem.shared.batchGroups.isEmpty) + + enableBatching(true) + generateBatches() + XCTAssertTrue(balls.allSatisfy(isBatched)) + } + + func testAPackMovedAfterItLoadedIsBatchedWhereItStands() async throws { + enableBatching(true) + let packURL = try writePack(twoSingleBalls()) + let (root, _) = try await loadPack(packURL) + drawFrames() + let balls = parts(of: root) + XCTAssertTrue(balls.allSatisfy(isBatched)) + let heightBefore = try XCTUnwrap(BatchingSystem.shared.batchGroups.first).boundingBox.max.y + + // What an editor does when it drops a pack at a point: move its root once it is in. + translateTo(entityId: root, position: simd_float3(0, 3, 0)) + drawFrames(1) + XCTAssertFalse(balls.contains(where: isBatched), "the batch held the balls where they stood") + XCTAssertTrue(balls.allSatisfy(isMarked)) + + drawFrames() + XCTAssertTrue(balls.allSatisfy(isBatched)) + let heightAfter = try XCTUnwrap(BatchingSystem.shared.batchGroups.first).boundingBox.max.y + XCTAssertEqual(heightAfter, heightBefore + 3, accuracy: 0.01) + } +} diff --git a/docs/API/UsingEngineAPI.md b/docs/API/UsingEngineAPI.md index 147733a20..07ec7207c 100644 --- a/docs/API/UsingEngineAPI.md +++ b/docs/API/UsingEngineAPI.md @@ -185,6 +185,7 @@ setBatching(.maxDirtyCellsPerTick(8)) setBatching(.visibilityGatedBuild(true)) setBatching(.backgroundArtifactBuild(true)) setBatching(.runtimeTuning(.visionOSBalanced)) +setBatching(.packSinglePlacements(true)) // a pack's parts placed once are marked static (default) ``` Entity tagging and rebuild commands remain explicit: diff --git a/docs/API/UsingStaticBatchingSystem.md b/docs/API/UsingStaticBatchingSystem.md index 9fb7a768e..9d45dc095 100644 --- a/docs/API/UsingStaticBatchingSystem.md +++ b/docs/API/UsingStaticBatchingSystem.md @@ -6,6 +6,7 @@ UntoldEngine supports two batching modes in practice: |---|---| | Manual batch generation | Always-resident static content | | Runtime cell-based batching | Tiled streaming scenes | +| Automatic, for a `.untoldpack` | The parts of a pack that are placed once | ## Manual Batching for Always-Resident Content @@ -51,6 +52,34 @@ setEntityMeshAsync(entityId: crate, filename: "crate", withExtension: "untold") Pass the entities that draw a mesh: the entity itself for a model of one node, as here, and its descendants that have a `RenderComponent` for a model of several. Entities without the mark are skipped, and the call does nothing while batching is disabled. +## Batching the Parts of a Pack + +A `.untoldpack` places many models. A model the pack places several times is drawn with instancing, which needs no setup. The models it places a **single time** have nothing to share a draw with, and there can be thousands of them: every wall, slab and fixture of a building. The pack loader marks their parts static for you, and when the pack is in it hands them to the batching system, which merges them by material in the background. + +All it takes is batching being enabled: + +```swift +setBatching(.enabled(true)) + +setEntityMeshAsync(entityId: site, filename: "Site", withExtension: "untoldpack") { success in + // The parts placed once are being merged; nothing to call. +} +``` + +What the loader marks: + +- the parts of a model that the pack places once, +- unless the model has a LOD chain (its meshes change with the distance, which a batch cannot follow), +- and unless a part has a blended material, a skeleton or an animation. + +With batching disabled the marks are there and do nothing. Enable batching later and call `generateBatches()` to use them. + +To load packs without any marking: + +```swift +setBatching(.packSinglePlacements(false)) +``` + ## Static Entities That Move A batch holds its entities where they stood when it was built. You do not have to unmark an entity before moving it: @@ -143,6 +172,7 @@ setBatching(.cellSize(32.0)) setBatching(.maxDirtyCellsPerTick(8)) setBatching(.visibilityGatedBuild(true)) setBatching(.backgroundArtifactBuild(true)) +setBatching(.packSinglePlacements(true)) ``` ### `BatchingSystem.shared.notifyStaticEntitiesPlaced(_:)` From 997006f4ed2321028ec485620e905e8ffe2992b0 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Sun, 4 Oct 2026 17:52:03 +0200 Subject: [PATCH 18/19] [Test] The batching tests cast their shadows from a sun of their own The scenes of StaticBatchMembershipTests were lit by the renderer's default light, whose shadow of a post is 140 pixels of the frame in their view: about as many as the edge pixels that a batch and an entity's own draw may differ by. The class now makes its sun the active light, from a side where that shadow is 3,500 pixels, so that a shadow missing or drawn twice cannot pass for edge differences. --- .../UntoldEngineRenderTests/StaticBatchMembershipTests.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift b/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift index d5376d474..9bbfb2cc5 100644 --- a/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift +++ b/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift @@ -46,9 +46,12 @@ final class StaticBatchMembershipTests: BaseRenderSetup { // The scene stands around x = 8, clear of the border between two batch cells at 0. cameraLookAt(entityId: camera, eye: simd_float3(8, 6, 16), target: simd_float3(8, 0, 0), up: simd_float3(0, 1, 0)) + // The light that casts: from this side the shadow of a post is 3,500 pixels of the + // frame, far more than the edge pixels a batch and an entity's own draw differ by. sun = createEntity() createDirLight(entityId: sun) - setSunElevationAzimuth(entityId: sun, elevation: 50, azimuth: 30) + setDirectionalLight(.active(sun)) + setSunElevationAzimuth(entityId: sun, elevation: 50, azimuth: 135) } // MARK: - Helpers From 16a2c9314f7fa18545b88b1a44b4fd7291d3caf0 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Sun, 4 Oct 2026 17:52:03 +0200 Subject: [PATCH 19/19] [Bugfix] A batch no longer draws a hidden entity, nor casts for one that casts no shadow A batch drew all of its entities and cast a shadow for all of them, whatever their render components said. An entity that was hidden went on being drawn by its batch, and one told not to cast a shadow went on casting through it. An editor that hides an entity by clearing isVisible had no way to hide a batched one. A hidden entity is no longer taken into a batch. castsShadow is now part of what groups entities: the ones that cast no shadow get batches of their own, which the shadow passes skip, so they keep the saving in draws. The render component tells the batching system when either flag changes. Before the frame is drawn, an entity that was hidden is out of its batch, one that is shown again is handed back, and one whose castsShadow changed moves to a batch of entities that cast as it does. They keep their mark throughout. --- Sources/UntoldEngine/ECS/Components.swift | 17 ++- .../UntoldEngine/Renderer/RenderPasses.swift | 6 +- .../UntoldEngine/Systems/BatchingSystem.swift | 128 ++++++++++++++++-- .../StaticBatchMembershipTests.swift | 83 ++++++++++++ docs/API/UsingStaticBatchingSystem.md | 2 + docs/Architecture/batchingSystem.md | 10 +- 6 files changed, 228 insertions(+), 18 deletions(-) diff --git a/Sources/UntoldEngine/ECS/Components.swift b/Sources/UntoldEngine/ECS/Components.swift index 2610aa160..f5499169a 100644 --- a/Sources/UntoldEngine/ECS/Components.swift +++ b/Sources/UntoldEngine/ECS/Components.swift @@ -102,8 +102,21 @@ public class RenderComponent: Component { var instancedShadowDraws: [InstancedShadowDraw]? var assetURL: URL = .init(fileURLWithPath: "") var assetName: String = "" - public var isVisible: Bool = true // Visibility flag for bulk loading - public var castsShadow: Bool = true + /// Whether the entity is drawn. A batch draws all of its entities, and casts a shadow + /// for all of them or for none, so the batching system is told when either flag + /// changes: a hidden entity leaves its batch, and one whose `castsShadow` changed + /// moves to a batch of entities that cast as it does. + public var isVisible: Bool = true { // Visibility flag for bulk loading + didSet { + if isVisible != oldValue { BatchingSystem.shared.noteRenderFlagsChanged() } + } + } + + public var castsShadow: Bool = true { + didSet { + if castsShadow != oldValue { BatchingSystem.shared.noteRenderFlagsChanged() } + } + } public required init() { mesh = [] diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index dc8c3dc88..ba656e4e9 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -875,7 +875,7 @@ public enum RenderPasses { } private static func shadowCasterBatchGroups(for cascadeIdx: Int) -> [BatchGroup] { - let groups = BatchingSystem.shared.batchGroups.filter { shouldRenderSceneChannelsOpaque($0.sceneChannels) } + let groups = BatchingSystem.shared.batchGroups.filter { $0.castsShadow && shouldRenderSceneChannelsOpaque($0.sceneChannels) } guard !groups.isEmpty, let frustum = shadowFrustum(for: cascadeIdx) else { return [] } return groups.filter { @@ -1005,7 +1005,7 @@ public enum RenderPasses { } private static func pointShadowCasterBatchGroups() -> [BatchGroup] { - let groups = BatchingSystem.shared.batchGroups.filter { shouldRenderSceneChannelsOpaque($0.sceneChannels) } + let groups = BatchingSystem.shared.batchGroups.filter { $0.castsShadow && shouldRenderSceneChannelsOpaque($0.sceneChannels) } guard !groups.isEmpty, pointShadowState.isActive else { return [] } @@ -1023,7 +1023,7 @@ public enum RenderPasses { } private static func spotShadowCasterBatchGroups() -> [BatchGroup] { - let groups = BatchingSystem.shared.batchGroups.filter { shouldRenderSceneChannelsOpaque($0.sceneChannels) } + let groups = BatchingSystem.shared.batchGroups.filter { $0.castsShadow && shouldRenderSceneChannelsOpaque($0.sceneChannels) } guard !groups.isEmpty, spotShadowState.isActive, let frustum = spotShadowState.frustum diff --git a/Sources/UntoldEngine/Systems/BatchingSystem.swift b/Sources/UntoldEngine/Systems/BatchingSystem.swift index 4531d734b..4136e97df 100644 --- a/Sources/UntoldEngine/Systems/BatchingSystem.swift +++ b/Sources/UntoldEngine/Systems/BatchingSystem.swift @@ -41,6 +41,8 @@ private struct BatchBuildKey: Hashable { let materialHash: String let lodIndex: Int let sceneChannelsRawValue: UInt64 + /// A batch casts a shadow for all of its entities or for none of them. + let castsShadow: Bool var materialLODHash: String { "\(materialHash)_LOD\(lodIndex)" @@ -509,6 +511,10 @@ public struct BatchGroup { /// so that non-LOD entities are not incorrectly colored with an LOD0 tint. var isLODBatch: Bool = false + /// Whether the shadow passes draw the group: its entities all cast a shadow, or none + /// of them does (`RenderComponent.castsShadow` is part of what groups them). + var castsShadow: Bool = true + /// Backward-compatible alias; prefer batchKey for clarity. var materialHash: String { batchKey @@ -591,6 +597,15 @@ public class BatchingSystem: @unchecked Sendable { private var movedInbox: [EntityID] = [] private var movedInboxIsOpen = false private var drainedMoves: [EntityID] = [] + /// Set, behind the same lock, when a render component's `isVisible` or `castsShadow` + /// changed since `releaseMovedEntities()` last ran. + private var renderFlagsChanged = false + /// Marked entities that are out of batching only because they are hidden: a batch + /// draws all of its entities. They are batched when they are shown again. + private var heldBackEntities: Set = [] + /// The tracked entities that cast no shadow, as they were registered: the batch an + /// entity is in depends on it, so a change sends the entity through registration again. + private var nonCastingEntities: Set = [] private var isSubscribed: Bool = false private var batchCellSize: Float = 32.0 /// Maps each cell to the UUIDs of its current batch groups. @@ -721,16 +736,30 @@ public class BatchingSystem: @unchecked Sendable { movedInboxLock.unlock() } - /// Takes the entities that moved since the last call out of their batches. + /// The `isVisible` or `castsShadow` of a render component changed. Called by the + /// component from whatever thread sets it; `releaseMovedEntities()` acts on it. + func noteRenderFlagsChanged() { + movedInboxLock.lock() + renderFlagsChanged = true + movedInboxLock.unlock() + } + + /// Takes out of their batches the entities that moved since the last call and the + /// ones that were hidden, and regroups the ones whose `castsShadow` changed. func releaseMovedEntities() { movedInboxLock.lock() - guard !movedInbox.isEmpty else { + let flagsChanged = renderFlagsChanged + renderFlagsChanged = false + guard flagsChanged || !movedInbox.isEmpty else { movedInboxLock.unlock() return } swap(&movedInbox, &drainedMoves) movedInboxLock.unlock() + if batchingEnabled, flagsChanged { + reconcileRenderFlags() + } if batchingEnabled { for entityId in drainedMoves { if let cellId = entityToCellMembership[entityId] { @@ -747,6 +776,58 @@ public class BatchingSystem: @unchecked Sendable { drainedMoves.removeAll(keepingCapacity: true) } + /// Remembers a marked entity that no batch takes only because it is hidden. + private func holdBackIfHidden(_ entityId: EntityID) { + guard scene.exists(entityId), + scene.get(component: StaticBatchComponent.self, for: entityId) != nil, + let renderComponent = scene.get(component: RenderComponent.self, for: entityId), + !renderComponent.isVisible + else { return } + heldBackEntities.insert(entityId) + } + + /// After a render flag changed somewhere: a tracked entity that was hidden leaves, one + /// whose `castsShadow` changed goes through registration again (it belongs to another + /// batch now), and a held back one that is shown again is placed. + private func reconcileRenderFlags() { + var hidden: [EntityID] = [] + var regrouped: [EntityID] = [] + for entityId in entityToCellMembership.keys { + guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { continue } + if !renderComponent.isVisible { + hidden.append(entityId) + } else if renderComponent.castsShadow == nonCastingEntities.contains(entityId) { + regrouped.append(entityId) + } + } + for entityId in hidden + regrouped { + guard let cellId = entityToCellMembership[entityId] else { continue } + removeEntityFromBatchingTracking(entityId: entityId) + if cellToEntities[cellId]?.isEmpty == false { + ungatedCells.insert(cellId) + } + } + heldBackEntities.formUnion(hidden) + + var shownAgain: [EntityID] = [] + var gone: [EntityID] = [] + for entityId in heldBackEntities { + guard scene.exists(entityId), + scene.get(component: StaticBatchComponent.self, for: entityId) != nil, + let renderComponent = scene.get(component: RenderComponent.self, for: entityId) + else { + gone.append(entityId) + continue + } + if renderComponent.isVisible { + shownAgain.append(entityId) + } + } + heldBackEntities.subtract(gone) + heldBackEntities.subtract(shownAgain) + notifyStaticEntitiesPlaced(regrouped + shownAgain) + } + /// Moves are only noted while there is an entity they could take out of a batch. private func refreshMovedInbox() { let isOpen = batchingEnabled && !(entityToCellMembership.isEmpty && movedEntities.isEmpty) @@ -762,6 +843,7 @@ public class BatchingSystem: @unchecked Sendable { /// its cell is rebuilt without it. func notifyEntityUnmarked(entityId: EntityID) { movedEntities.removeValue(forKey: entityId) + heldBackEntities.remove(entityId) placedEntities.remove(entityId) pendingEntityAdditions.remove(entityId) newlyResidentEntities.remove(entityId) @@ -1233,6 +1315,7 @@ public class BatchingSystem: @unchecked Sendable { entityToBatch.removeValue(forKey: entityId) guard let oldCell = entityToCellMembership.removeValue(forKey: entityId) else { return } + nonCastingEntities.remove(entityId) shadowCastersChanged() if var members = cellToEntities[oldCell] { members.remove(entityId) @@ -1264,6 +1347,7 @@ public class BatchingSystem: @unchecked Sendable { let hasLocal = scene.get(component: LocalTransformComponent.self, for: entityId) != nil Logger.log(message: "[Batching] resolveBatchCandidate FAILED entity=\(entityId) staticBatch=\(hasStatic) render=\(hasRender) meshEmpty=\(meshEmpty) world=\(hasWorld) local=\(hasLocal)") placedEntities.remove(entityId) + holdBackIfHidden(entityId) removeEntityFromBatchingTracking(entityId: entityId) return } @@ -1287,6 +1371,11 @@ public class BatchingSystem: @unchecked Sendable { entityToCellMembership[entityId] = newCell cellToEntities[newCell, default: []].insert(entityId) + if candidate.renderComponent.castsShadow { + nonCastingEntities.remove(entityId) + } else { + nonCastingEntities.insert(entityId) + } dirtyCells.insert(newCell) markCellDirtyForFallback(cellId: newCell, deferBatchBuild: deferBatchBuild) if placedEntities.remove(entityId) != nil, ungatedCells.insert(newCell).inserted { @@ -1526,6 +1615,8 @@ public class BatchingSystem: @unchecked Sendable { for entityId in members { guard let candidate = resolveBatchCandidate(entityId: entityId) else { entityToCellMembership.removeValue(forKey: entityId) + nonCastingEntities.remove(entityId) + holdBackIfHidden(entityId) continue } @@ -1576,7 +1667,8 @@ public class BatchingSystem: @unchecked Sendable { batchKey: batchKey.materialLODHash, material: batchMaterial, cellId: batchKey.cellId, - lodIndex: batchKey.lodIndex + lodIndex: batchKey.lodIndex, + castsShadow: batchKey.castsShadow ) else { continue } builtGroups.append(group) @@ -1757,7 +1849,8 @@ public class BatchingSystem: @unchecked Sendable { cellId: candidate.cellId, materialHash: matHash, lodIndex: candidate.lodIndex, - sceneChannelsRawValue: candidate.sceneChannels.rawValue + sceneChannelsRawValue: candidate.sceneChannels.rawValue, + castsShadow: candidate.renderComponent.castsShadow ) groupCounts[key, default: 0] += 1 groupVertices[key, default: 0] += vertexCount @@ -1848,6 +1941,10 @@ public class BatchingSystem: @unchecked Sendable { // Skip entities with empty meshes (not yet loaded by streaming) if renderComponent.mesh.isEmpty { return nil } + // A batch draws all of its entities: one that is hidden keeps its own draw, which + // is skipped (see heldBackEntities). + if !renderComponent.isVisible { return nil } + if scene.get(component: LODComponent.self, for: entityId)?.previousLOD != nil { return nil } @@ -1915,7 +2012,8 @@ public class BatchingSystem: @unchecked Sendable { cellId: candidate.cellId, materialHash: matHash, lodIndex: candidate.lodIndex, - sceneChannelsRawValue: candidate.sceneChannels.rawValue + sceneChannelsRawValue: candidate.sceneChannels.rawValue, + castsShadow: candidate.renderComponent.castsShadow ) let finalTransform = simd_mul(candidate.worldTransform.space, mesh.localSpace) @@ -1977,7 +2075,8 @@ public class BatchingSystem: @unchecked Sendable { batchKey: batchKey.materialLODHash, material: batchMaterial, cellId: batchKey.cellId, - lodIndex: batchKey.lodIndex + lodIndex: batchKey.lodIndex, + castsShadow: batchKey.castsShadow ) { batchGroups.append(batchGroup) let newIndex = batchGroups.count - 1 @@ -2262,7 +2361,13 @@ public class BatchingSystem: @unchecked Sendable { // Build fresh cell membership and mesh groups from current scene state. for entityId in entities { - guard let candidate = resolveBatchCandidate(entityId: entityId) else { continue } + guard let candidate = resolveBatchCandidate(entityId: entityId) else { + holdBackIfHidden(entityId) + continue + } + if !candidate.renderComponent.castsShadow { + nonCastingEntities.insert(entityId) + } entityToCellMembership[entityId] = candidate.cellId cellToEntities[candidate.cellId, default: []].insert(entityId) addCandidateMeshes(candidate, to: &materialGroups) @@ -2316,7 +2421,8 @@ public class BatchingSystem: @unchecked Sendable { batchKey: String, material: Material, cellId: BatchCellID, - lodIndex: Int + lodIndex: Int, + castsShadow: Bool ) -> BatchGroup? { var allPositions: [simd_float4] = [] // Changed to float4 to match vertex descriptor var allNormals: [simd_float4] = [] // Changed to float4 to match vertex descriptor @@ -2455,7 +2561,8 @@ public class BatchingSystem: @unchecked Sendable { meshIndices: meshIndices, boundingBox: (min: minBounds, max: maxBounds), sceneChannels: sceneChannels, - isLODBatch: isLODBatch + isLODBatch: isLODBatch, + castsShadow: castsShadow ) } @@ -2482,6 +2589,8 @@ public class BatchingSystem: @unchecked Sendable { ungatedCells.removeAll() placedEntities.removeAll() movedEntities.removeAll() + heldBackEntities.removeAll() + nonCastingEntities.removeAll() cellLastVisibleFrame.removeAll() cellBuildGeneration.removeAll() runtimeBatchIneligibleCells.removeAll() @@ -2547,6 +2656,7 @@ public class BatchingSystem: @unchecked Sendable { tileResidentsAwaitingDrain.removeAll() placedEntities.removeAll() movedEntities.removeAll() + heldBackEntities.removeAll() runtimeBatchIneligibleCells.removeAll() clearPendingBuildArtifacts() for cellId in cellToEntities.keys { diff --git a/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift b/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift index 9bbfb2cc5..3b04b1a41 100644 --- a/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift +++ b/Tests/UntoldEngineRenderTests/StaticBatchMembershipTests.swift @@ -479,6 +479,89 @@ final class StaticBatchMembershipTests: BaseRenderSetup { try assertTheFrameIsTheOneWithoutBatches() } + func testAHiddenEntityIsNotDrawnByItsBatch() throws { + placeGround() + let first = place(cube(material(red)), at: simd_float3(5, 0, 0)) + let second = place(cube(material(red)), at: simd_float3(8, 0, 0)) + let third = place(cube(material(red)), at: simd_float3(11, 0, 0)) + enableBatching(true) + generateBatches() + drawFrames() + XCTAssertEqual(batchedEntities, [first, second, third]) + + // What an editor does to hide an entity. + try XCTUnwrap(scene.get(component: RenderComponent.self, for: first)).isVisible = false + drawFrames() + + XCTAssertEqual(batchedEntities, [second, third]) + XCTAssertEqual(try XCTUnwrap(batchedBounds).min.x, 7.5, accuracy: 0.01, "its vertices are out of the batch") + let hidden = try litPixels() + + try XCTUnwrap(scene.get(component: RenderComponent.self, for: first)).isVisible = true + drawFrames() + XCTAssertEqual(batchedEntities, [first, second, third], "shown again, it is batched again") + try XCTAssertGreaterThan(compare(hidden, litPixels()).different, 1000, "and seen again") + + // The frame without the cube is the one drawn without batches. + try XCTUnwrap(scene.get(component: RenderComponent.self, for: first)).isVisible = false + drawFrames() + try assertTheFrameIsTheOneWithoutBatches() + } + + func testEntitiesThatCastNoShadowAreBatchedApartFromTheOnesThatDo() throws { + placeGround() + let casting = [ + place(cube(material(red)), at: simd_float3(4, 0.4, 0), scale: simd_float3(0.8, 3, 0.8)), + place(cube(material(red)), at: simd_float3(7, 0.4, 0), scale: simd_float3(0.8, 3, 0.8)), + ] + let notCasting = [ + place(cube(material(red)), at: simd_float3(10, 0.4, 0), scale: simd_float3(0.8, 3, 0.8)), + place(cube(material(red)), at: simd_float3(13, 0.4, 0), scale: simd_float3(0.8, 3, 0.8)), + ] + for post in notCasting { + try XCTUnwrap(scene.get(component: RenderComponent.self, for: post)).castsShadow = false + } + let unbatched = try frameWithoutBatches() + + enableBatching(true) + generateBatches() + drawFrames() + + // One material, two batches: a batch casts for all of its entities or for none. + XCTAssertEqual(batchedEntities, Set(casting + notCasting)) + XCTAssertEqual(BatchingSystem.shared.batchGroups.count, 2) + XCTAssertEqual(BatchingSystem.shared.batchGroups.filter(\.castsShadow).count, 1) + try assertSameFrame(as: unbatched) + } + + func testAnEntityToldNotToCastLeavesTheBatchThatCasts() throws { + placeGround() + let first = place(cube(material(red)), at: simd_float3(5, 0.4, 0), scale: simd_float3(0.8, 3, 0.8)) + let second = place(cube(material(red)), at: simd_float3(8, 0.4, 0), scale: simd_float3(0.8, 3, 0.8)) + let third = place(cube(material(red)), at: simd_float3(11, 0.4, 0), scale: simd_float3(0.8, 3, 0.8)) + enableBatching(true) + generateBatches() + drawFrames() + XCTAssertEqual(batchedEntities, [first, second, third]) + let allCasting = try litPixels() + + try XCTUnwrap(scene.get(component: RenderComponent.self, for: first)).castsShadow = false + drawFrames() + + XCTAssertEqual(batchedEntities, [second, third], "alone among the ones that cast none, it draws itself") + try XCTAssertGreaterThan(compare(allCasting, litPixels()).different, 1000, "its shadow is gone") + + // Told to cast again, it is back with the others. + try XCTUnwrap(scene.get(component: RenderComponent.self, for: first)).castsShadow = true + drawFrames() + XCTAssertEqual(batchedEntities, [first, second, third]) + try XCTAssertLessThan(compare(allCasting, litPixels()).different, 150) + + try XCTUnwrap(scene.get(component: RenderComponent.self, for: first)).castsShadow = false + drawFrames() + try assertTheFrameIsTheOneWithoutBatches() + } + // MARK: - Entities placed to stay func testPlacedEntitiesAreBatchedUnseenAndWhateverTheirSize() { diff --git a/docs/API/UsingStaticBatchingSystem.md b/docs/API/UsingStaticBatchingSystem.md index 9d45dc095..7a3481e21 100644 --- a/docs/API/UsingStaticBatchingSystem.md +++ b/docs/API/UsingStaticBatchingSystem.md @@ -87,6 +87,8 @@ A batch holds its entities where they stood when it was built. You do not have t - An entity moved with the transform functions leaves its batch before the next frame is drawn, together with every descendant that moves with it. Its neighbours go back to one draw each until their cell is rebuilt. - It keeps its mark. Thirty frames after it stops, it is batched again where it stands. - An entity that never stops moving stays out of batching. It costs nothing more than an unmarked entity, but it gains nothing either, so do not mark what moves all the time. +- An entity that is hidden (`isVisible = false`) leaves its batch too, and is batched again when it is shown: a batch draws all of its entities. +- Entities that cast no shadow (`castsShadow = false`) are batched apart from the ones that do, and the shadow passes skip their batches. Changing the flag moves the entity from one batch to the other. - An entity that is destroyed, or that loses its mark, leaves its batch the same way. To move a whole scene without touching its batches, move the scene root instead (see [Transform System](UsingTransformSystem.md)). diff --git a/docs/Architecture/batchingSystem.md b/docs/Architecture/batchingSystem.md index cdf4071e2..7bf009843 100644 --- a/docs/Architecture/batchingSystem.md +++ b/docs/Architecture/batchingSystem.md @@ -22,7 +22,7 @@ cellId(x, y, z) = floor(worldCenter / cellSize) When your 100 entities load, each one that has a `StaticBatchComponent` gets registered: -- **Eligibility check** (`resolveBatchCandidate`): the entity must have a `RenderComponent`, `WorldTransformComponent`, no skeleton/animation, no transparency, no gizmo/light component, no `.preserveIdentity` scene channel, and its mesh must already be resident in memory. The LOD index is derived from `LODComponent.currentLOD` (entity-level LOD), then `TileLODTagComponent.levelIndex` (per-tile LOD/HLOD children), defaulting to 0. `isLODBatch` on the resulting `BatchGroup` is true if any member entity has either component. +- **Eligibility check** (`resolveBatchCandidate`): the entity must have a `RenderComponent` that is visible (a batch draws all of its entities), `WorldTransformComponent`, no skeleton/animation, no transparency, no gizmo/light component, no `.preserveIdentity` scene channel, and its mesh must already be resident in memory. The LOD index is derived from `LODComponent.currentLOD` (entity-level LOD), then `TileLODTagComponent.levelIndex` (per-tile LOD/HLOD children), defaulting to 0. `isLODBatch` on the resulting `BatchGroup` is true if any member entity has either component. - If eligible → it gets assigned to a cell and added to `cellToEntities[cellId]`. - The cell is marked **dirty** and its state becomes `renderableUnbatched`. @@ -79,7 +79,7 @@ This is the core build loop: 4. **Apply per-tick budgets**: up to 8 cells, 120K verts, 220K indices, 6MB total per tick. Once budgets are exhausted, remaining cells defer to next frame. -5. **Snapshot build inputs** under a world mutation gate: for each selected cell, group its entities' meshes by `BatchBuildKey = (cellId, materialHash, lodIndex, sceneChannelsRawValue)`. This produces `CellBuildInput`. +5. **Snapshot build inputs** under a world mutation gate: for each selected cell, group its entities' meshes by `BatchBuildKey = (cellId, materialHash, lodIndex, sceneChannelsRawValue, castsShadow)`. This produces `CellBuildInput`. `castsShadow` is the entity's `RenderComponent.castsShadow`: a batch casts a shadow for all of its entities or for none, so the ones that cast none get batches of their own, which the shadow passes skip. 6. **Dispatch background builds** on `artifactBuildQueue` (a `.utility` DispatchQueue). The heavy work — actually merging vertex data — happens off the main thread. @@ -145,13 +145,15 @@ Batch groups cast in the directional, spot and point shadow passes. A marked ent ## An Entity That Stops Being Static -A batch holds the vertices of its entities where they stood when it was built. Three things take an entity out of its batch at once, so that no frame draws it at its old place or draws it twice: +A batch holds the vertices of its entities where they stood when it was built, and draws all of them. Five things take an entity out of its batch at once, so that no frame draws it at its old place, draws it twice or draws it when it should not be seen: - **It moves.** The transform functions (`translateTo`, `rotateTo`, `scaleTo` and the rest) note the entity and every descendant that moves with it. Before the next frame is drawn they are out of their batches, and their cells fall back to a draw per entity. The entity keeps its `StaticBatchComponent`: once it has been at rest for 30 frames it is registered again where it stands, in an ungated cell. An entity that moves every frame simply stays out. +- **It is hidden** (`RenderComponent.isVisible`). The component tells the batching system when the flag changes. The entity keeps its mark and is batched again when it is shown. +- **Its `castsShadow` changes.** It moves to a batch of entities that cast as it does. - **It loses its mark** (`removeEntityStaticBatchComponent`). - **It is destroyed.** -In all three cases the cell is rebuilt without the entity through the normal tick, in the background. +In all five cases the cell is rebuilt without the entity through the normal tick, in the background. Moving the **scene root** (`translateSceneTo`, `rotateSceneToYaw`, ...) is not a move in this sense: batches are drawn through the scene root's transform and stay as they are.