叠纸游戏Unite大会分析:《恋与深空》的影视级渲染管线打造
【GameLook专稿,禁止转载!】
GameLook报道/叠纸去年年初上线的《恋与深空》可以说是近两年最成功的二游之一,自2024年1月18日上线以来,全球玩家数量突破7000万,荣获科隆游戏展2025最佳移动游戏。营收方面,不仅创下过8亿全球月流水峰值,还成功在今年5月份再次成为移动端收入最高的二游。
在10月24日举行的Unite 2025大会上,来自叠纸游戏《恋与深空》制作组的引擎技术负责人 阮天龙和制作组TA负责人 秦平分享了该游戏的影视级管线,以及影视级效果的打造过程。
据Gamelook了解,叠纸团队基于Unity 2019对引擎源码进行深度修改,开发了一套自定义的SRP管线,并且未来将上线Vulkan版本,持续提升游戏性能。
以下是Gamelook整理润色的完整内容:
阮天龙:

《恋与深空》渲染底层框架的分享内容主要涵盖【场景渲染优化、光照方案与管线设计、阴影优化】三部分。在开发过程中,我们基于Unity 2019对引擎源码进行深度修改,开发了一套自定义的SRP管线。目前Android线上版本为GLES 3.1,未来也将上线Vulkan版本,持续提升性能,满足玩家对高品质游戏的需求。

1. 场景渲染优化
在场景渲染优化中,我们开发了一套名【RendererGroupRenderer】的场景渲染系统,将每个渲染批次称之为一个RenderGroup。通过这套系统我们实现了以下功能:
自定义静态场景描述:我们去除GameObject,避免了更新大量GameObject时带来的性能损耗。

优化CPU→GPU Upload 频率:主要包括InstanceData和ConstantBuffer的Upload。关于InstanceData的Upload优化,我们在项目初期针对室内小规模场景,采用的是静态生成InstanceDataBuffer,配合BVH分割裁剪的形式。随着项目推进,场景精度要求不断提高,后期便转向在GPU端完成裁剪与instance填充。ConstantBuffer的Upload优化将在后面【单DrawCall性能优化】部分进行详细说明。
CPU侧Burst+Job System并行粗裁剪:对静态物件我们通过Burst+Job System实现了一套高度并发的裁剪系统,同时只在CPU侧进行粗略的裁剪,将细粒度的裁剪任务交由GPU完成。
InstanceData数据形式
业界常用Constant Buffer形式的InstanceData存在一些缺点,比如64KB 尺寸限制、常量缓存小、动态索引时容易发生缓存击穿导致性能下降等。另一种常用形式是用SSBO来传递InstanceData,但这种方法读取性能通常不如缓存未击穿情况下的ConstantBuffer,并且部分安卓设备在GLES下不支持在vertex shader中读取SSBO,这也限制了它的兼容性。同时这两个方案都有共同的问题:依赖动态索引,对低端手机性能不友好。

针对以上问题,我们提出了一种“新瓶装旧酒”的方案——Vertex Stream based Instance Data。
使用PerInstance Step的Vertex Stream作为Instance Buffer;
走Vertex Fetch缓存,无需动态索引,Cache命中率高,无兼容性问题;
通过ComputeShader向Instance VertexBuffer输出,实现GPU Driven。
使用PerInstance Step的Vertex Stream作为Instance Buffer——这是一种在GPU Instancing诞生之初就被支持的Instance方法,既可以避免动态索引带来的性能问题,也避免了SSBO的兼容性问题。我们还可以通过ComputeShader向Instance VertexBuffer输出来实现GLES下兼容性较高的GPU Driven。
最后,由于Unity引擎在底层没有支持PerInstance Step的Vertex Stream,我们对引擎也做了相应的定制,最终暴露给上层的是CommandBuffer中添加的一个DrawMeshInstancedTraditional接口,它需要将另一个mesh作为instance data传进来。我们也加了相应的接口来配置instance mesh中各个数据段对应的顶点semantic。
GPU Driven
我们会依据Group数量与Instance数量,提前分配IndirectParameter Buffer与Instance Data Buffer(这里Instance Data Buffer只是提前分配了空间,实际的数据为GPU Cull时填入)。
同时,我们会预计算每个Group的Instance Offset,并将其存储到Parameter的InstanceStart项,全程只绑定一份Instance Buffer。

此外,我们还需要生成逐物件信息Buffe(包含GroupID、LOD Distance Range、Bounds、Transform等信息),用于在GPU裁剪时获取每个物件的属性。
CPU剪裁:在GPU裁剪之前,我们会先执行一次CPU粗裁剪,以判断Group整体是否可见。从一个根包围盒开始,比较物件包围盒体积总和与合并后包围盒的体积比值,低于阈值就递归分裂包围盒(主要目的为避免两个物件距离过远,拉出一个超大总包围盒的情况发生)。同时结合PVS进一步判断Group的可见性,因为我们没有类似DX12的IndirectExecute,我们的GPU裁剪只能减少instance数,并不能消除Group整体的drawcall,因此需要,通过CPU裁剪尽可能准确地剔除掉完全不可见的Group。

GPU剪裁:GPU裁剪则通过一次dispatch对所有Group进行逐物件3段裁剪,包含视锥裁剪、LOD裁剪、Hiz遮挡剔除,通过裁剪将Parameter的Instance Count加1,并输出InstanceData。
阴影剔除:我们参考了龙之教条分享的方法,将画面深度重投影到阴影空间作为Shadow Reveiver Mask,若Shadow Caster投出的Volume与Mask不相交,就可剔除避免多余阴影渲染。
此外关于我们“为什么没有实现Cluster/Meshlet”部分,首先它在移动端存在较大基础开销,其次在GLES下实现Cluster也存在兼容性问题。综合考虑下,我们认为优先优化单DrawCall的性能更能为我们带来免费且直接的性能提升。
单DrawCall性能优化
在过往的观察中,我们发现许多对于渲染的CPU耗时优化往往过于关注DrawCall数量,而忽视了每个DrawCall本身的耗时。我们认为降低DrawCall数量只是一种优化方法,最终的CPU耗时才是唯一的衡量指标。

现代移动设备与图形标准其实早就可以胜任大量drawcall,这部分在HypeHype引擎团队在Siggraph 2023中也有过分享——他们在iphone 6s上测试了一万个不同Mesh与材质的DrawCall,耗时仅有11.27ms。其他同等的安卓设备也都基本能维持在60帧以上。而在2014年Metal刚刚诞生时,也提出过比GLES多画10倍DrawCall的口号。
11年后的今天,我们仍为DrawCall过多而苦恼的原因,主要来自多方面的开销,包括PSO切换过多、Buffer提交与拷贝、引擎渲染逻辑以及过多RHI接口调用,都会增加CPU负担。因此我们认为性能优化不能只盯着DrawCall数量,而要综合考量这些因素。
PSO切换优化:主要取决于每个项目对shader变体数量和shader复杂度的权衡。RenderGroup渲染队列会根据shader,material,mesh的优先级排序,同时我们对阴影进行特殊处理:无AlphaTest的材质统一用相同shader渲染Shadow Depth,减少阴影渲染时的PSO切换频率。

Buffer提交优化:在GLES下,Map/Unmap buffer会带来显著开销,现代RHI支持的persistent map虽能显著减少upload耗时,但仍无法避免数据从主线程到渲染线程,再到buffer内存的多次拷贝以及memcmp。因此我们采用了以下三种针对性的策略,显著减少了Buffer Upload:
PerRendererBuffer将逐Renderer的参数(如物体所受的环境光SH),存放在由Renderer对象维护的Uniform Buffer中,渲染时直接绑定;
PerShaderBuffer针对不需要逐材质变化的uniform buffer,只在shader切换时提交一次,相比PerRendererBuffer来说,PerShaderBuffer更加灵活,可以支持不同的shader变体;
针对PerMaterialBuffer,我们借用了SRP Batcher代码预生成逐材质buffer并直接绑定。
渲染逻辑优化:商业游戏引擎为保证灵活性与稳定性,渲染时会进行复杂的逻辑判断。比如在Unity引擎内部,每次调用Draw时会先调用一个ApplyMaterial函数,它会在渲染之前更新所有的渲染状态与参数,当DrawCall数量较多时存在可观的耗时。因此我们进行了以下优化:

对ApplyMaterial接口进行了单独拆分,仅在材质或参数需要切换时才由上层主动调用;
只需改变PerMaterialBuffer时,改用简化后的专用接口。
优化后,我们的CPU在在相同DrawCall下耗时减少1/3。
RHI调用优化:RHI调用优化主要的目标是减少除了Draw Primitive以外的其他图形API调用,具体优化包括:
合并相同stride的Vertex&Index Buffer,避免逐Draw Call bind VB/IB,耗时减少15%;
Resource未发生变化时,跳过DescriptorSet设置,耗时进一步减少30%;SetDescriptors本身耗时较高时候,而且切换Descriptor还会增加下一次draw的耗时,这个在Arm的Best Practice Guide里有过介绍。
我们在低端安卓设备上测试了5000个DrawCall的耗时。使用引擎原生的渲染时,渲染线程的耗时是34.79ms。当我们对Buffer提交与渲染逻辑进行优化后,耗时降低到22.97ms。在进一步优化RHI调用次数后,耗时进一步大幅降至了11.8ms。最终我们在DrawCall数量不变的前提下,让CPU耗时减少到了原来的1/3以下。

其他优化尝试Benchmark场景测试结果
我们还尝试了一些新的RHI特性,包括:

Multi-Draw Indirect(MDI):在支持的设备上能够带来明显优化,一定程度上改善GPU遮挡剔除可能会提交空DrawCall的问题(CPU端提交减少);
Bindless:然而,Bindless的表现却不尽如人意,即便在最新的安卓设备上也出现了神秘的负优化。结合MDI与Bindless,我们可以实现几乎用一个DrawCall渲染所有物件,但是CPU耗时却比不合批时还更高。这也是一个过度关注DrawCall数量的反面案例。当然,我们期待以后的移动芯片对bindless能有更好的支持。现阶段的话,我们尝试基于Unity Texture Streaming扩展出了一套无Feedback SVT系统作为替代方案,这个方案也还在验证阶段。
从Benchmark场景测试结果来看,RenderGroupRenderer对比原始无instancing渲染,DrawCall减少了1/3,渲染线程耗时大幅减少3/4,主线程耗时也减少了2/3(虽然C#耗时增加,但引擎原生裁剪与GameObject更新耗时减少,整体仍然带来了大幅的优化)。
2. 光照方案
光照方案

前向渲染管线:
我们在项目中选择使用前向渲染管线,包含以下多方面考虑:首先,前向管线在应对美术复杂且多变的需求方面有其优势,我们不需要担心一些材质属性的添加是否会导致GBuffer膨胀。
其次,传统的延迟管线对于移动平台而言带宽不太友好。OnePassDeferred则在灵活性方面存在一些局限,比如无法在RenderPass中间改变RT的尺寸,也不能fetch当前位置以外的像素内容。
在GLES下,FrameBufferFetch的兼容性也存在问题,不同芯片支持的fetch RT数量不同,有的只支持1张RT,需要改成通过PLS实现,但是我们测试PLS的性能并不理想。
另外,引擎自带的逐物件4盏光源对于较大的物件来说不太够用,因此我们尝试了Forward+。但是Forward+在早期设备上耗时太高,若限制逐tile最大光源数,镜头变化时,tile内光源数量不可控,超上限会带来表现bug。
为解决这些问题,我们采用了水平世界空间Tile划分——默认2米一格,分布于相机前方,逐Tile最多4盏光源,128*128 Index Map。这种划分方式使Tile光源重叠状态稳定,便于在制作时及时发现超限问题。
Vulkan版本管线改进
我们在未来的Vulkan版本的管线中增加了基于Subpass的Light Pre-Pass。
在Pre-Z Pass中,我们会输出一张简易的GBuffer RT并且store下来。由于我们的local light光照使用了无fresnel的简化PBR模型,所以我们不需要在GBuffer中输出specular或者Albedo,只将normal,roughness和一些特殊的材质id或属性信息pack到一张RGBA8的Gbuffer上,然后就可以跑一遍类似Deferred Shading的光源Volume渲染流程,将几何光照结果保存到Tile Memory上。

之后在Shading Pass中,我们会把物件再画一遍并fetch这些光照信息,再结合渲染时获得的albedo等材质属性,得到最终的光照结果。
我们将TAA所需的MotionVector Encode为RGBA8,R + G == 0代表无有效速度,这样某些不输出速度的材质可在BA通道存其他信息。
比如我们针对一些简易且大量的植被,会在MotionVector的BA通道上保存他们的UV信息,这样在Shading Pass时,我们只需要后处理获取gbuffer中的几何信息与MotionVector中的UV信息,即可还原出植被的材质表现。
Vulkan版本的管线流程大致如下:首先由PreZ Pass输出Depth,GBuffer与MotionVector,然后计算阴影的遮挡剔除,接着执行阴影的深度渲染,再然后是一些AO和屏幕空间SSS之类的计算然后我们就进入NativeRenderPass,在SubPass中计算ShadowMask,Light Pre-Pass,以及执行正常的Shading Pass。最后退出RenderPass,再执行其他后处理Pass。

Vulkan版本管线改进也存在一定局限,比如Light Pre-Pass只能替换默认Lighting Model,对于需要更多Gbuffer通道的Lighting Model,还是需要采用Forward+。
不过我们提供了一个逐光源可选参数,可以针对某个光源强行使用Standard Lit Model,对所有材质统一处理,这样可以在牺牲Lighting Model准确性的条件下实现让同Tile内的像素受4盏以上灯的影响。
GI
Diffuse GI部分,我们采用了较为传统的Lightmap+Light Probe的方式,Lightmap只保存间接光信息,Light Probe除了正常的逐物件单个采样点的模式以外,我们还提供了一种多采样点模式,能为每个物体设置多个采样点,依据线段、三角形或四面体的重心坐标进行插值。

在以下两张对比图中,左图为单采样点的效果,box的底部为统一的环境光照;右图则为使用两个采样点的结果,可以发现左右两边受到了不同的间接光照。
Specular GI方面,我们主要是基于使用了AABB校正的Reflection Probe。另外对于一些特定的地板或水面,我们还会使用平面反射代理。大致可以看成一种专门用来画反射的HLOD。
此外我们还参考了战神的做法,对Reflection Probe的CubeMap做了归一化。具体来说就是根据CubeMap的像素生成一份环境光照的SH系数,将CubeMap中的像素颜色与该方向的环境光照相除,得到归一化的CubeMap。在实际渲染时,再用每个像素在反射方向上所受的实际环境光照与CubeMap像素相乘,还原出反射颜色。
这种做法的好处是,即使大量物件采样同一个Reflection Probe,不同区域的反射也能产生不同的明暗差别。
3. 阴影优化
功能设计
我们阴影系统的基本设计为:

三级CSM+角色特写阴影/多角色POSM:3级Cascade的CSM+1级角色专属的特写阴影,在某些多角色场景时会使用POSM(Per-Object Shadow Map);
可支持两盏锥灯投影;
ScreenSpaceShadowMask:将以上阴影的结果都将输出到了一张RGBA8的ScreenSpaceShadowMask上;
R:Directional Shadow, G: Local Shadow 1, B: Local Shadow 2, A: AO:R通道保存主光阴影,G和B保存了锥灯阴影,A通道保存了AO信息。
距离剔除
我们首先做了一个简单的距离剔除,根据阴影距离修改ScreenSpaceShadow后处理三角形顶点的深度值,之后再用ZTest Greater渲染,剔除阴影距离外的Shadow计算。
因为在计算阴影时要采样depth,我们需要两份depth分别用于Test与Sample,我们会在NativeRenderPass中拷贝一份Memoryless的Depth Buffer用于Test,尽量避免额外的读写带宽。
半影区域检测
我们增加了半影区域检测功能,先在1/4分辨率下计算一次PCF,随后在全分辨率Shadow Pass里采样1/4 mask,仅对shadow值处于中间区域的像素执行全分辨率PCF,在保证效果的同时降低计算量。

为了避免这样做之后存在某些细节像素检测不准确的问题,我们会分别依据1/4 Buffer中Position的偏导与全分辨率Gather的4个深度值计算两组法线。若法线夹角大于阈值,则判定低分辨率像素不可靠,强行执行全分辨率PCF。
以下为场景的Debug视图,红色区域被我们判定为半影区间,只有这些像素才会执行全分辨率的PCF。
逐像素bias
我们利用Receiver Plane Depth Bias算法实现了逐像素的Shadow Bias。它的原理也比较简单,首先对屏幕空间shadow coordinates偏导应用二维链式法则,求出阴影空间偏导。

利用偏导与PCF采样偏移我们可以求出bias值。对于中心点来说,我们增加了1个像素偏移的bias结果作为起始bias。
下图为固定bias与逐像素bias的对比结果:

左图使用固定bias值,可以看到box的底部有一段漏光区域,并且与光照方向接近垂直的表面存在部分自阴影走样;使用逐像素bias之后(右图),我们只会在偏导较大的区域增加bias,可以在保持细节投影的同时解决自阴影的走样问题。
不过,当屏幕深度不连续时,逐像素bias可能算出错误结果,导致一些漏光现象。为了解决这一问题,需要美术手动指定bias的最大最小范围。
Scrolling Cached Shadow Map
针对DrawCall较多的场景,我们还尝试了Scrolling Cached Shadow Map,具体包括:

缓存CSM深度,对于前后两帧都被阴影视锥完全包含的对象,将上一帧的CSM滚动到当前帧投影位置直接得到阴影深度,避免直接渲染对象;
只对最后一级cascade应用Scrolling,当cascade范围比较小时,大量物体与会与视锥相交,优化效果就会受限;
间隔多帧更新缓存,减缓带宽压力。
在未来,我们还准备支持Local ShadowMap Atlas以及缓存机制。我们将会支持两盏以上的局部灯投影,并且根据光源的屏占比动态调整ShadowDepth精度了,对于远距离的局部光源,也会引入静态缓存支持。
秦平:

1. 角色光照方案

在角色光照方案中,相信大家多多少少都会遇到以下几类问题:

基于以上需求,我们进行了具体角色光照方案设计。
光照是由【直接光】和【间接光】组成的,一般情况下我们只会有一个平行光——我们习惯称之为主光。主光正常照亮场景,但在照亮角色的时候我们保留它的方向,用一个类似后处理盒子的方式覆写主光的颜色和亮度。具体实现方式为:

给Shader多传一份角色主光颜色,角色的Shader在获取主光时获取到的颜色为角色主光颜色;
给角色提供了一盏额外的不投影的平行光用来做轮廓光;
同时预留了两个额外光给角色,额外光可以是任意的点光和射灯组合,可以正常照亮范围内的角色和场景物件( 因为一个2米的格子最多四盏额外光,所以将2个灯光划分给角色)。
间接光我们使用Unity的LightProbe系统来创建探针,自己实现了保存间接光到探针里的部分,把场景的探针和角色的探针分开两套,分别存储和使用;
环境光高光我们使用同一个反射探针,但对于一些特殊的材质,我们提供了材质上输入CubeMap覆盖环境的反射探针的选项。

我们把这些影响角色的光照信息存到一个Scriptableobject里,由灯光师调整好之后保存为一个模板;下方右图为角色灯光方案保存的信息,包含了上面提到的两盏平行光,两个额外光,还有探针保存下来的sh,以及一些后处理盒子上可以额外调整的信息和是否使用自定义的反射探针。
最后用一个manager使用类似栈的方式去管理,这里选用栈的管理方式跟具体使用强相关——通常情况下除了加载新的灯光方案之外,最常用的一个功能就是还原上一个灯光方案效果,因此我们采用了栈的管理方式。

到这里,这个方案已经具备了角色/场景分开、可实时切换、支持定制保持模板这些功能。最后我们把切换灯光方案定义成剧情编辑器上的一个事件行为,支持了可衔接光照动画。
可衔接光效果如下所示:

下图为项目专用剧情编辑工具,基本上所有的灯光和阴影相关的参数及部分后处理、物理效果都可以在这个剧情编辑器控制。

2. 特写阴影
光和影一直都是密不可分的。如前文所提到,我们的阴影方案为三级CSM加特写阴影,实现原理就使用角色身上的一根可指定的骨骼做球心,构成一个指定半径的球,用这个球来构建和生成这张阴影图,在屏幕空间阴影的时候会进行精度比较,使用这张阴影图和级联阴影中精度较高的一张作为这个像素的Shadow Map。

可以看到,角色原本整个都在主光阴影里,打开特写阴影的时候变成了可以被主光正常照亮,就是因为特写阴影修改了近裁切平面;也就是说我们的特写阴影是一张单独可调参数的阴影图,具体参数包括远近裁切平面,最远距离,还有使用哪一盏光和往往最让人头疼的bias。
3. 皮肤细节
皮肤上我们聚焦一些细节表现,具体以脸红效果和流汗效果为例。
脸红效果
通常来讲,脸红的过程是一个逐渐变化并且不同区域变红程度不一样的过程,比如大部分人在脸红的时候会先从耳朵开始红,然后是脸颊,偶尔会有整张脸变红的表现。

为了模拟这个过程,我们采取了以下方式,使画面更加生动和真实:

手绘遮罩:基于遮罩纹理控制脸红区域、颜色梯度与强度;
多通道独立:可分别调节面部、耳朵、鼻子等不同区域的红晕效果;
预存变化过程:脸红的过渡过程分通道记录在对应曲线上,实现自然的情绪表达。
流汗效果
我们游戏里提供了运动陪伴功能,男主会进行一些运动训练的陪伴,因此也就需要提供相应的流汗效果。具体实现主要通过以下三个方面:

材质与粒子结合:材质着色器模拟皮肤表面光泽与湿润度,汗珠效果提供附着在皮肤上的材质实现和vfx实现可供选择;
遮罩控制流汗区域:使用遮罩图确定材质流汗区域,增强流汗效果的真实性和艺术性;
数据自动化传递:主控参数变化自动驱动材质与粒子参数。
下图为一些具体的计算方式与最终效果示意。

如若转载,请注明出处:http://www.gamelook.com.cn/2025/11/580940/