在Unity引擎中,引入了可编程渲染管线(SRP)后,剔除(Cull)操作的执行时机被移到了脚本层。这意味着开发者可以更灵活地控制哪些物体需要被渲染,哪些物体可以被剔除,从而提高渲染效率。以下是Unity引擎在SRP下的Cull过程的详细分析,特别是在2019.4版本中。
Cull的基本流程
-
相机设置:
- 当你在场景中添加一个相机时,相机会有一系列的设置,包括视锥体(Frustum)和剔除层(Culling Mask)。这些设置决定了相机能看到哪些物体。
-
场景物体的添加:
- 当你通过
Instantiate
方法在场景中添加可渲染物体时,这些物体会被附加到场景树中,并且每个物体上都会有一个Renderer
组件。此时,这些物体并不会立即被渲染。
- 当你通过
-
剔除过程:
- 在SRP中,剔除的过程通常是在渲染循环的开始阶段进行的。Unity会调用用户自定义的渲染管线中的
Cull
方法。 - 在这个
Cull
方法中,Unity会根据相机的视锥体来判断哪些物体是可见的。具体步骤如下:- 视锥体剔除:首先,Unity会检查每个物体的包围盒(Bounding Box)是否与相机的视锥体相交。如果不相交,则该物体会被剔除。
- 层剔除:接着,Unity会根据相机的剔除层设置,进一步筛选出哪些物体是需要渲染的。
- 在SRP中,剔除的过程通常是在渲染循环的开始阶段进行的。Unity会调用用户自定义的渲染管线中的
-
剔除后的渲染:
- 剔除完成后,Unity会将可见的物体传递给渲染管线进行后续的渲染处理。这包括设置材质、绘制网格等操作。
优化剔除的策略
在SRP中,开发者可以通过以下方式优化剔除过程:
- 自定义剔除逻辑:开发者可以在自定义的SRP中实现更复杂的剔除逻辑,例如基于距离、视角等条件进行剔除。
- 使用层和标签:合理使用Unity的层和标签功能,可以帮助快速筛选出需要渲染的物体,减少不必要的计算。
- 剔除体积:可以使用剔除体积(Culling Volume)来限制某些区域内的物体渲染,进一步提高性能。
结论
在Unity 2019.4版本中,SRP的引入使得Cull的执行时机和方式变得更加灵活和高效。通过将剔除逻辑移到脚本层,开发者可以根据具体需求自定义剔除策略,从而优化渲染性能。在实际开发中,合理利用相机设置、层和标签,以及自定义剔除逻辑,可以显著提高游戏的渲染效率。
1、RendererScene
在Unity引擎的渲染系统中,RendererScene
的设计是为了高效管理与渲染相关的节点和数据。以下是对RendererScene
的详细分析,特别是它如何与场景节点(SceneNode
)和渲染节点(RendererNode
)进行交互,以及如何处理可见性和边界框(AABB)信息。
RendererScene的结构与功能
-
Renderer Nodes的构建:
- 在构造场景节点的同时,
RendererScene
会为每个有效的渲染组件创建对应的渲染节点(RendererNode
)。这意味着每当一个场景节点被激活并且其渲染组件处于启用状态时,它就会被添加到RendererScene
中。 - 具体来说,
Renderer AwakeFromLoad
、SetEnabled
和Deactivate
等方法会触发对RendererScene
的更新,分别调用AddRenderer
和RemoveRenderer
来管理渲染节点的增删。
- 在构造场景节点的同时,
-
多线程安全性:
- 在渲染过程中,可能会出现多线程对渲染节点的访问和修改。为了避免并发问题,
RendererScene
使用了Pending List来延迟增删操作。这种设计允许在渲染过程中安全地管理节点的状态,确保渲染数据的一致性。
- 在渲染过程中,可能会出现多线程对渲染节点的访问和修改。为了避免并发问题,
-
数据组织与加工:
- 在初步组织了渲染节点后,
RendererScene
需要进一步整理和加工中间数据,以便为后续的渲染过程做好准备。这包括计算每个渲染节点的边界框(AABB)和可见性信息。
- 在初步组织了渲染节点后,
RendererScene中的关键数据
RendererScene
主要维护以下三类数据:
-
SceneNode:
- 这是与渲染节点直接关联的场景节点。每个渲染节点都对应一个场景节点,提供了渲染所需的变换和其他信息。
-
AABB BoundingBox:
- AABB(Axis-Aligned Bounding Box)是一个用于包围渲染对象的边界框。它在渲染过程中用于快速判断物体是否在相机的视锥体内,从而决定是否需要渲染该物体。虽然在初步组织时并未计算AABB,但在后续处理中会根据场景节点的变换和几何信息来计算。
-
VisibilityBits:
- 可见性信息(
VisibilityBits
)用于标记每个渲染节点的可见性状态。这通常是一个位图,表示该节点在当前渲染帧中是否可见。可见性信息的计算通常依赖于相机的视锥体剔除和其他条件。
- 可见性信息(
数据结构示例
在RendererScene
中,这些数据通常以动态数组的形式存储,以便于在运行时进行增删改查操作。以下是一个简化的示例:
// These arrays are always kept in sync
dynamic_array<SceneNode> m_RendererNodes; // 存储渲染节点对应的场景节点
dynamic_array<AABB> m_BoundingBoxes; // 存储每个渲染节点的AABB
dynamic_array<UInt8> m_VisibilityBits; // 存储每个渲染节点的可见性信息
总结
RendererScene
在Unity的渲染系统中扮演着重要的角色,它负责管理与渲染相关的节点和数据。通过使用Pending List
来处理多线程增删操作,RendererScene
能够在高效和安全的环境中组织渲染数据。随着渲染过程的推进,RendererScene
会继续计算和更新AABB和可见性信息,为最终的渲染做好准备。这种设计不仅提高了渲染效率,也为开发者提供了更大的灵活性。
2、可见性更新
在Unity引擎中,RendererScene
负责管理渲染节点的可见性信息。可见性更新是渲染循环中的一个重要环节,确保只有在当前帧需要渲染的物体被通知并处理,从而提高性能。以下是对可见性更新过程的详细分析。
可见性状态
每个渲染节点的可见性信息(VisibilityBits
)有四个状态(mask):
- 不可见(0)
- 当帧应该可见(
kVisibleCurrentFrame
) - 上一帧可见(
kVisiblePreviousFrame
) - 已经可见(
kBecameVisibleCalled
)
这些状态帮助引擎跟踪每个渲染节点在不同帧中的可见性变化。
可见性更新流程
-
Early Update阶段:
- 在Unity的更新循环中,
RendererNotifyInvisible
是一个属于EarlyUpdate
类别的步骤,发生在渲染的早期阶段。此时,RendererScene
会遍历每个渲染节点,检查其可见性状态。
- 在Unity的更新循环中,
-
遍历渲染节点:
- 对于每个渲染节点,检查其
VisibilityBits
:- 如果状态为“上一帧可见”(
kVisiblePreviousFrame
),则认为该节点在当前帧不可见,并调用RendererBecameInvisible
通知相关组件。 - 更新可见性状态:
- 如果当前状态为“当帧应该可见”(
kVisibleCurrentFrame
),则更新为“上一帧可见”。 - 否则,更新为“不可见”。
- 如果当前状态为“当帧应该可见”(
- 如果状态为“上一帧可见”(
int nodeCount = m_RendererNodes.size(); for (int i = 0; i < nodeCount; ++i) { SceneNode& node = m_RendererNodes[i]; UInt8& vbits = m_VisibilityBits[i]; if (vbits == kVisiblePreviousFrame) { static_cast<Renderer*>(node.renderer)->RendererBecameInvisible(); } // Roll visibility over to next frame vbits = (vbits & kVisibleCurrentFrame) ? kVisiblePreviousFrame : 0; }
- 对于每个渲染节点,检查其
-
Cull阶段:
- 在可见性更新后,进行剔除(Cull)操作。Cull的结果会影响哪些物体在当前帧是可见的。Cull完成后,
RendererScene
会通过SceneAfterCullingOutputReady
回调进行可见性更新。
- 在可见性更新后,进行剔除(Cull)操作。Cull的结果会影响哪些物体在当前帧是可见的。Cull完成后,
-
更新可见性位:
- 对于静态和动态物体,更新其可见性位:
- 对于静态物体,遍历可见列表,将其状态更新为“当帧应该可见”。
- 对于动态物体,类似地更新其状态。
// Update visibility bits for static objects for (int i = 0; i < visible[kStaticRenderers].size; ++i) { int index = visible[kStaticRenderers].indices[i]; m_VisibilityBits[index] |= kVisibleCurrentFrame; } // Update visibility bits for dynamic objects size_t offset = GetStaticObjectCount(); for (int i = 0; i < visible[kDynamicRenderer].size; ++i) { int index = visible[kDynamicRenderer].indices[i] + offset; m_VisibilityBits[index] |= kVisibleCurrentFrame; }
- 对于静态和动态物体,更新其可见性位:
-
通知可见性变化:
- 对于当前帧可见的渲染节点,调用
RendererBecameVisible
通知相关组件,并设置kBecameVisibleCalled
标志。
for (unsigned i = 0, n = m_RendererNodes.size(); i < n; ++i) { SceneNode& node = m_RendererNodes[i]; UInt8& vbits = m_VisibilityBits[i]; if (vbits == kVisibleCurrentFrame) { static_cast<Renderer*>(node.renderer)->RendererBecameVisible(); vbits |= kBecameVisibleCalled; } }
- 对于当前帧可见的渲染节点,调用
总结
可见性更新是Unity渲染循环中的一个关键步骤,确保渲染系统能够高效地管理哪些物体在当前帧需要被渲染。通过维护VisibilityBits
,引擎能够在每一帧中跟踪物体的可见性状态。
3、AABB Bounds更新
在Unity引擎中,AABB(Axis-Aligned Bounding Box)更新是渲染和剔除(Cull)过程中的一个重要环节。AABB用于快速判断物体是否在视锥体内,从而决定是否需要进行详细的渲染计算。以下是关于AABB更新的详细分析。
AABB更新的时机
AABB的更新必须在剔除(Cull)之前进行,但不能过早。原因如下:
- Transform更新:在一帧中,物体的Transform(位置、旋转、缩放)可能会发生变化,因此AABB的更新需要在Transform更新之后进行。
- Renderer特定更新:某些Renderer(如ParticleSystemRenderer和SkinnedMeshRenderer)需要根据每个粒子或当前动作来更新AABB,因此需要在这些Renderer完成更新后再进行AABB的更新。
因此,AABB的更新通常发生在PostLateUpdate
阶段的UpdateAllRenderers
中。
AABB和WorldMatrix的更新
在RendererUpdateManager::UpdateAll
中,AABB和WorldMatrix会被更新。更新WorldMatrix的原因包括:
- MVP矩阵构建:Renderer在渲染时需要构造模型-视图-投影(MVP)矩阵,而WorldMatrix是构建MVP矩阵的基础。因此,Renderer需要在每一帧中更新WorldMatrix。
- Transform Chain:虽然其他Scene Node的Transform可以按需更新,但Renderer需要在每帧中确保其WorldMatrix是最新的,以便进行正确的渲染。
AABB的更新策略
不同类型的Renderer可能有不同的AABB更新策略。引擎设计上将更新的实现路由给每种Renderer,以便于管理和优化。
以MeshRenderer为例
在InitializeClass
时,MeshRenderer会注册其更新方法:
GetRendererUpdateManager().RegisterDispatchUpdate(kRendererMesh, TransformChangeSystemMask(0), DispatchUpdate, PrepareDispatchUpdate, PrepareSingleRendererUpdate, FinalizeUpdate);
这里的TransformChangeDispatch
是一个监控Transform变化的框架,能够处理平移、旋转、缩放和Local Bounds变化。所有Renderer监控的Mask都包含:
TransformChangeDispatch::kInterestedInGlobalTRS
TransformChangeDispatch::kInterestedInRendererUpdate
这使得在一帧内发生的Transform变化能够被有效地传递给注册的方法。
AABB更新的具体过程
在RendererUpdateManager::UpdateAll
中,系统会遍历每种Renderer并依次进行Prepare、Update和Finalize操作。以MeshRenderer为例,更新过程如下:
-
读取Transform:
TransformAccessReadOnly transformAccess = changedTransforms[i];
-
计算WorldMatrix:
affineX worldMatrix = CalculateGlobalMatrix(transformAccess);
-
加载Local AABB:
aabb localAABB = aabbLoad(&renderer.GetWritableTransformInfo().localAABB);
-
计算World AABB:
aabb worldAABB = aabbTransform(worldMatrix, localAABB);
-
存储WorldMatrix和AABB:
StoreWorldMatrix(renderer, worldMatrix, CalculateHierarchyTransformType(transformAccess), jobData->motionVectorFrameIndex); StoreAABB(renderer, jobData, worldAABB);
总结
AABB的更新是Unity渲染流程中的一个关键环节,确保在剔除之前,所有Renderer的包围盒信息都是最新的。通过在PostLateUpdate
阶段更新AABB和WorldMatrix,Unity能够高效地管理渲染对象的可见性,从而提高渲染性能。不同类型的Renderer通过注册各自的更新方法,实现了灵活的更新策略,确保了系统的高效性和可扩展性。
4、CullScriptable
在Unity的渲染管线中,剔除(Cull)是一个至关重要的步骤,它决定了哪些对象需要被渲染,哪些可以被忽略,从而提高渲染效率。Cull的过程与相机密切相关,因为每个相机都有自己的视锥体和剔除参数。以下是关于CullScriptable的详细分析。
Cull与相机的关系
在Unity中,场景可以包含多个相机,而每个相机的渲染过程都是独立的。RendererScene的更新是每帧一次,而Cull和渲染则是针对每个相机进行的。在通用渲染管线(URP)中,RenderSingleCamera
是每个相机的入口点。
-
CullingParameters:每个相机都有一组CullingParameters,这些参数包括LayerMask、裁剪平面等信息。这些参数在Native层处理,但可以通过URP封装的相机参数进行覆盖,以适应特定的渲染需求。
-
Cull的开始:一旦CullingParameters准备好,就可以调用Cull方法进行剔除:
var cullResults = context.Cull(ref cullingParameters);
Cull的实现
在Native层,Cull的实现可以分为几种情况:
1. BatchRendererGroup
BatchRendererGroup允许开发者在脚本层进行批量绘制。它的优势在于:
- GPU实例化:通过批量绘制,可以利用GPU实例化技术,减少绘制调用的数量,从而提高性能。
- 自定义Cull:开发者可以通过
OnPerformCulling
方法自定义Cull逻辑,而不是逐个物体进行Cull。这种方式可以将多个Renderer合并为一个Batch进行处理,减少更新和Cull的开销。
2. CullingGroup
CullingGroup是Unity提供的一个脚本层API,允许开发者维护一组包围球(Bounding Sphere)并管理它们与物体(或Renderer)的对应关系。其工作流程如下:
- 维护包围球:开发者可以创建和管理多个包围球,并将它们与场景中的物体关联。
- 可见性检查:在进行Renderer的Cull之前,Unity会先对CullingGroup进行Cull,检查哪些包围球是可见的。
- 增量更新:在Cull完成后,RendererScene会进行
UpdateAll
更新,但只更新变化的部分,而不是全量更新,这样可以减少Cull的负载。
3. 特殊系统(如Terrain)
对于一些特殊类型的系统(如Terrain),Unity使用QuadTree等数据结构进行高效的剔除。这种方法能够快速判断哪些区域是可见的,从而优化渲染性能。
4. RendererScene的Cull
最后,RendererScene会进行实际的Cull操作。此时,所有的剔除逻辑和可见性判断都已经完成,RendererScene会根据Cull的结果更新需要渲染的对象。
总结
CullScriptable在Unity的渲染管线中扮演着重要角色,它通过与相机的紧密结合,确保了渲染过程的高效性。通过BatchRendererGroup和CullingGroup等机制,Unity能够在CPU和GPU之间实现高效的批量处理和剔除,减少不必要的计算和绘制调用,从而提升整体性能。对于特殊类型的对象,Unity还提供了更为高效的剔除策略,如QuadTree等,进一步优化了渲染流程。
5、CullScene
在Unity的渲染管线中,CullScene的过程是一个复杂而高效的步骤,旨在确保只有可见的渲染器被传递给渲染模块。以下是CullScene的详细分析,涵盖了从Renderer的分类到最终可见Renderer Nodes列表的生成过程。
Renderer的分类
在Cull开始之前,RendererScene会通过UpdateAll
方法更新所有的Renderer,确保获取到最新的Renderer状态。接下来,调用PrepareCullingParametersRendererArrays
将Renderer进行分类,分类的枚举如下:
enum
{
kStaticRenderers = 0,
kDynamicRenderer,
kSceneIntermediate,
kCameraIntermediate,
kTreeRenderer,
kCustomCullRenderers,
kStandardVisibleListCount,
kSpriteGroup = kStandardVisibleListCount,
kBatchRendererGroupsStart
};
这些分类有助于在Cull过程中快速筛选出不需要渲染的对象。
Cull的过程
Cull的过程使用了Job System来提高性能,具体步骤如下:
-
LOD Mask和Layer Mask筛选:
- 首先,Cull会检查每个Renderer的LOD Mask和Layer Mask。这一步骤可以快速剔除掉与当前相机视图无关的Renderer,减少后续计算的负担。
-
视锥体裁剪:
- 接下来,Cull会使用优化过的投影体(如平头椎体或长方体)与Renderer的AABB(轴对齐包围盒)进行碰撞检测。通过计算,判断AABB是否在视锥体的内部,从而进一步筛选出可见的Renderer。
-
基于相机Layer的Distance Cull:
- 最后,Cull会根据相机的Layer设置进行距离剔除。不同的Layer可以设置不同的裁剪距离,默认情况下,Renderer会被剔除掉超过远裁剪面的对象。
数据处理与合并
由于Renderer的数量可能非常庞大,Cull过程中的每个Job会将输入分成多个Blocks,每个Block包含一部分Renderer Nodes。经过前面的筛选后,可能会出现不紧密排列的情况,因此需要一个额外的Job来重新组合这些数据。
- 可见Node的Index:在这个过程中,蓝色表示可见的Node的Index,而灰色表示不可见的Node的Index(如图3所示)。
CullingOutput与CullResults
经过视锥体裁剪后,Cull的结果会存储在CullingOutput
的visible
中。这个输出按照Renderer的分类存储,每个List中包含对应的Node Index。
-
Job Fence同步:所有的Jobs会通过
sceneCullingOutputIsReady
JobFence进行同步,确保所有的Cull操作完成后再进行下一步处理。 -
数据加工到CullResults:在所有的Cull操作完成后,存储的Index需要进一步加工到
CullResults
的rendererCullCallbacks
中,最终形成Renderer Nodes的列表。
最终结果
经过以上的处理,最终得到的可见Renderer Nodes列表是可以交付给渲染模块进行渲染的。然而,在实际渲染之前,可能还需要进行一些额外的处理,例如:
- 排序:根据渲染顺序对可见的Renderer进行排序,以优化渲染性能。
- 状态更新:确保所有Renderer的状态(如材质、变换等)都是最新的,以便在渲染时能够正确显示。
总结
CullScene的过程通过高效的分类、筛选和数据处理,确保了只有可见的Renderer被传递给渲染模块。这一过程不仅提高了渲染效率,还减少了不必要的计算和资源消耗,为Unity的实时渲染提供了强有力的支持。
6、交付渲染前的操作
在Unity的渲染管线中,交付渲染前的操作是一个至关重要的步骤,确保所有可见的渲染对象(包括Renderer Nodes、Lights和Reflection Probes)都被正确处理和准备。以下是这一过程的详细分析:
1. 处理可见的对象
在Cull完成后,首先需要处理的不仅是可见的Renderer Nodes,还包括与渲染相关的Lights和Reflection Probes。这些元素同样会影响最终的渲染结果,因此需要在Cull后进行相应的更新和准备。
2. 通知可见的Renderers
调用afterCullingOutputReady
方法,通知所有可见的Renderers。这一时刻,RendererScene会进行UpdateVisibility
,更新每个Renderer的可见性状态。同时,所有与Renderer相关的MonoBehaviour(如脚本层的Renderer Component)会接收到OnWillRenderObject
消息,允许它们在渲染前进行必要的准备。
3. DispatchGeometryJobs
在Cull过程中,DispatchGeometryJobs
是一个关键环节。尽管它看似与Cull没有直接关系,但它实际上负责调用rendererCullingOutputReady
回调,传递Cull筛选出来的Renderer Nodes。
- 复杂Renderer的处理:大多数Renderer并不需要对Cull结果进行额外操作,但某些复杂的Renderer(如ParticleSystemRenderer)需要进行额外的处理。这些Renderer的渲染过程并不是简单地绘制一个Mesh,而是需要根据Cull结果生成实际的绘制对象。例如,ParticleSystemRenderer需要计算每个粒子并生成对应的面片,同时将数据动态批处理到VBO(Vertex Buffer Object)、IBO(Index Buffer Object)等渲染缓冲区中。
4. 组织渲染数据
在Cull完成后,得到的是Renderer实例的列表,但直接将这些实例用于渲染显然不够精细。因此,引入了SharedRendererScene
的概念,它与RendererScene
不同,专门用于存储实际的Render Node。
-
提取Render Node:通过
ExtractSceneRenderNodeQueue
方法,从Renderer实例中提取Render Node。每个Renderer会通过RegisterPrepareRenderNodesCallback
注册Prepare Render Node的方法。 -
MeshRenderer的准备:以MeshRenderer为例,如果Mesh没有经过Prepare,会调用
PrepareMesh
,主要构造渲染所需的VBO、IBO等渲染缓冲区。同时,最重要的rendererSpecificData
会被赋值,对于MeshRenderer来说,这就是MeshRenderingData
。
5. 注册渲染回调
最后,Renderer会注册一系列的回调函数,包括executeBatchedCallback
、executeCallback
和cleanupCallback
等。这些回调函数负责对每个Render Node(或这一批Render Node)进行渲染。
- 回调的作用:这些回调函数的注册和执行是渲染过程中的重要环节,确保每个Renderer在渲染时能够正确处理其对应的Render Node。虽然这些细节在分析渲染时会更为清晰,但在此阶段,了解它们的目的和大致功能是足够的。
总结
在交付渲染前的操作中,Unity通过一系列的步骤确保所有可见的Renderer、Lights和Reflection Probes都被正确处理。通过Cull、通知、几何调度、渲染数据组织和回调注册等环节,最终形成一个高效且精细的渲染准备过程。这些步骤不仅提高了渲染效率,还确保了渲染结果的准确性和质量。