找回密码
 立即注册
查看: 377|回复: 0

Unity实现Impostor踩坑日记(一)

[复制链接]
发表于 2022-3-6 21:38 | 显示全部楼层 |阅读模式
概述



新手一枚,最近在学习渲染相关的知识,看了一些别人的分享,了解了一种叫做 Impostor 的技术。这种技术本质上基于光场的思想,将物体在各个方向上的材质属性记录下来;然后在渲染过程中,用一块 Billboard 面片替代原来的物体,利用对应方向的材质属性,将光照结果渲染到面片上。只要相机够远就看不太出来原物体和 Impostor 的区别,而对应节省了大量的顶点的存储与计算,当然代价是会多花几张贴图。个人对 Impostor 的初步感受是,应该会比较适合一些远处物体的渲染。比如说FF14中俾斯麦歼灭战的浮岛场景,由于玩家只能在固定的一个副本区进行攻略,对远处物体的视角比较有限,所以绘制远处的空岛时就可以用 Impostor 来替换原来的高模而不容易穿帮。堡垒之夜就用了这样的技术,效果如下图所示。



堡垒之夜的talk,介绍了 Impostor 技术

Impostor 已经有挺多的实现了,Unity 有一个 Amplify Impostor 插件,UE有一个开源的 Impostor 实现,肯定比我弄得好,不多介绍。
整个 Impostor 分为两个步骤 —— 1. 烘培,也就是在各个方向上给预制件拍照;2. 渲染,也就是根据当前的视线方向,选取正确的照片进行显示。
Post Page
总结其中比较关键的思想是:烘焙过程记录渲染的中间变量(材质属性、法线、到某一平面的深度等信息),而不是记录渲染结果。然后在渲染时,对应地读取相应的中间变量,在片元着色器中完成渲染。接下来就解决一下烘焙的问题。
烘焙

烘焙 Impostor 的核心之一是确定相机的参数。也就是说,相机放哪?朝哪里看?视锥体要套多大?怎么存到贴图里?接下来我们就来处理这些问题。
确定 V 矩阵

我们的烘焙相机都从哪些方向去拍?这和我们的需求有关,如果场景限制使得玩家只会在赤道面的各个方向看某个物体,那我们就把烘焙相机们排布在赤道上即可。这里考虑更一般的情况:假设我们要做一个“吃鸡”类游戏,要求从各个视角去烘焙远景物体。那么比较容易想到的是,将相机按照球面的经纬度进行等度数排布即可,而上面给的链接提出了一种八面体的排布方案,可以更好地利用两极附近的数据(可以参考前面那张兔子的图,最上面一行和最下面一行其实是同样图形的旋转)。不过我比较笨暂时还没看懂,就先实现一个球面的方案。(如果屏幕前的你懂这个,又愿意教我的话,我自然是不胜荣幸!)


我们将纹理划为很多个小正方格子,每一个格子称为一个 Frame。通过这个名字你可能也看出来了,Impostor 其实本质上就是以视角驱动的序列帧动画。记纹理按行分为了 块,按列分为了 块。如果按照同一行为相同纬度那么位于第 行第 列的 Frame,实际上它对应的相机旋转矩阵可以对应地转换到经纬度。这样就可以得到一个输入 Frame 下标,输出相机旋转矩阵的函数:
Matrix4x4 GetCameraRotation(Vector2Int frameIdx) {
  float latitudeFraction = -(180.0f / (vFrames - 1));
  float longitudeFraction = 360.0f / hFrames;
  Quaternion v = Quaternion.Euler(latitudeFraction * frameIdx.y + 90, 0, 0);
  Quaternion h = Quaternion.Euler(0, longitudeFraction * frameIdx.x, 0);
  return Matrix4x4.Rotate(v * h);
}
在最终生成的 Impostor 纹理上,各 Frame 排列顺序是从北极按行向南极排列,如下面的兔子。


当然,我们也许还希望让相机移动到目标物体的包围盒中心,于是,矩阵可以额外进行平移。此外,如果想要保证烘焙在模型空间下进行,可以再右乘 M 矩阵的逆。那么就要求 center 是在模型坐标下的。worldToLocalMatrix可以取自根节点Transform,就不细说了。
GetCameraRotation(frameIdx) * Matrix4x4.Translate(-center) * worldToLocalMatrix
上式即为 V 矩阵的表达式。
确定 P 矩阵

烘焙时应该使用正交投影。因此确定P矩阵,我们比较关心的是视景体的尺寸。我们想烘焙的物体有大有小,大到一个岛屿,小到一棵树都有可能。如果让我们自己去确定相机参数那也太蛋疼了。所以我们需要求出各个方向上指定物体的包围盒,然后取最大的那个包围盒作为我们的相机视景体。这样一来就不怕模型被裁掉,二来物体对视景体有着较好的填充,最终渲染到纹理时对纹理可以有较高的利用率。
如果我们已知物体在模型空间下的包围盒,如何计算不同视角方向上的包围盒大小?其实这里只需要看角点即可,推导不难,读者可以自行探索。下面给出了一种利用旋转矩阵进行计算的代码。
public static Bounds TransformByMat(this Bounds bound, Matrix4x4 mat)
{
    // transform center
    var center = mat.MultiplyPoint3x4(bound.center);
    var extents = bound.extents;

    // transform edges
    var axisX = mat.MultiplyVector(new Vector3(bound.extents.x, 0, 0));
    var axisY = mat.MultiplyVector(new Vector3(0, bound.extents.y, 0));
    var axisZ = mat.MultiplyVector(new Vector3(0, 0, bound.extents.z));

    // get minimum AABB for transformed box
    extents.x = Mathf.Abs(axisX.x) + Mathf.Abs(axisY.x) + Mathf.Abs(axisZ.x);
    extents.y = Mathf.Abs(axisX.y) + Mathf.Abs(axisY.y) + Mathf.Abs(axisZ.y);
    extents.z = Mathf.Abs(axisX.z) + Mathf.Abs(axisY.z) + Mathf.Abs(axisZ.z);

    return new Bounds { center = center, extents = extents };
}当然,有的GameObject下面可能挂了很多个节点,那么就把他们的包围盒都提取出来,再用Unity Bounds中一个叫做 Encapsulate 的方法逐个包围即可。取出了这么一个最小包围盒后,我们在 V 矩阵的求解时将相机移到包围盒中心。因此投影变换矩阵很好计算。
projMat = Matrix4x4.Ortho(
              -size.x / 2, size.x / 2,
              -size.y / 2, size.y / 2,
              size.z / 2, -size.z / 2);当然了,我们也许希望视锥体在视口的一面是正方形的,所以需要取 x y 二者中的较大值参与计算。
捕获材质属性

捕获材质属性实际上需要针对特定的烘焙用着色器,也就是说并不存在一个固定的 Shader 就能把各式各样材质的属性都拿出来。以 URP 的 Lit 为例,其实我们参考其中 Tags{"LightMode" = "UniversalGBuffer"} 这个Pass就可以实现一个烘焙用着色器。步骤是直接复用 Lit 的 Properties与顶点着色器,在片段着色器中计算、存储需要用到的属性即可。例如:
#pragma vertex LitGBufferPassVertex
#pragma fragment frag

#include "Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl"
#include "Packages/com.unity.render-pipelines.universal/Shaders/LitGBufferPass.hlsl"

void frag( Varyings input,
    out half4 outGBuffer0 : SV_Target0,
    out half4 outGBuffer1 : SV_Target1,
    out half4 outGBuffer2 : SV_Target2,
    out half4 outGBuffer3 : SV_Target3,
    out half4 outGBuffer4 : SV_Target4,
    out half4 outGBuffer5 : SV_Target5,
    out half4 outGBuffer6 : SV_Target6,
    out half4 outGBuffer7 : SV_Target7,
    out float outDepth : SV_Depth)
{
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);

    SurfaceData surfaceData;
    InitializeStandardLitSurfaceData(input.uv, surfaceData);

    InputData inputData;
    InitializeInputData(input, surfaceData.normalTS, inputData);

    half smoothness = surfaceData.smoothness;
   
    float3 remappedNormalWS = inputData.normalWS * 0.5 + 0.5;             // values between [ 0,  1]
    // half3 packedNormalWS = PackFloat2To888(remappedOctNormalWS);

    outGBuffer0 = half4(surfaceData.albedo.rgb, 1);
    outGBuffer1 = half4(remappedNormalWS.xyz, smoothness);
    outGBuffer2 = 0;
    outGBuffer3 = 0;
    outGBuffer4 = 0;
    outGBuffer5 = 0;
    outGBuffer6 = 0;
    outGBuffer7 = 0;
    outDepth = input.positionCS.z;
}
可以看出,这里存储了反射率、法线、深度等数据。之后,只要在C#端配置和保存这两个Render Target即可。这样我们就得到了前边那个兔子的两张 Impostor 纹理。所以说其实在 Impostor 中借鉴了 Deferred shading 的思想。



Albedo

绘制

先取出模型空间下的视线方向,这样便于计算Frame的位置
float3 objectSpaceCameraPos = mul(GetWorldToObjectMatrix(), float4(_WorldSpaceCameraPos.xyz, 1)).xyz;
float3 objectViewDirection = normalize(objectSpaceCameraPos);从视线方向反解出应该采样哪个 Frame,这里就是向量和三角函数那一套了。不过注意这里 需要通过一致变量传进来。
// calculating constants
float2 frameSize = 1 / float2(_vFrames, _hFrames);
float2 billboardUV = vertex.xy + 0.5; // Unity 内置 Quad 的顶点坐标,+ 0.5等下直接当Frame中采样用的uv偏移值来用

// get frame bias by view direction
// 其实就是求角度,然后取整就是具体的frame了
// 模型坐标系下会比较好计算一点
float hFrameBias = floor(frac(INV_TWO_PI * atan2(-objectViewDirection.z, -objectViewDirection.x) + frameSize.x/2) * _vFrames) / _vFrames;
float vFrameBias = floor(frac(INV_PI * acos(-objectViewDirection.y)) * _hFrames) / _hFrames;
float2 frameBias = float2(hFrameBias, vFrameBias); // 需要采样的那个frame的左上角

frameUV = frameBias + billboardUV * frameSize; // 采样位置然后准备一个四边形面片做 Billboarding。关键代码如下,求一组正交基然后乘回去就行。
// orthogonal vectors
float3 up = float3(0, 1, 0);
float3 horizontal = normalize(cross(objectViewDirection, up));
float3 vertical = cross(horizontal, objectViewDirection);
vertex = mul(vertex, float3x3(horizontal, vertical, objectViewDirection));
normal = objectViewDirection; // 向法线赋值会影响背面剔除的结果!上面的代码都在在顶点着色器里做,包括 Billboarding 和 FrameUV 的计算。FrameUV等下可以通过插值赋给片元着色器。片元着色器直接采样相应的纹理就行。这里先简单采样一下反射率,然后直接输出试一试。


黑色的背景是非预期的,所以考虑将这个面片做成AlphaTest材质,剃掉边缘没用的片元。
half4 albedo = tex2D(_Albedo, i.frameUV);
clip(albedo.a - 0.5);

现在看来,这一套流程是可以实现的。因此先对目前的结果进行调优。
问题1.  兔子变大了

这里应该是我没有将烘焙用的相机参数对面片进行拉伸,所以兔子看起来会大一些。将相机的左右平面间距作为一致变量输入,然后直接对 Quad 的顶点进行拉伸。
// commandBuffer.SetFloat("_VertResizeFactor", Mathf.Max(size.x, size.y)),size对应烘焙用P矩阵
vertex = mul(vertex, float3x3(horizontal, vertical, objectViewDirection)) * _VertResizeFactor;问题解决。
问题2. 兔子边缘脏脏的



感觉和滤波还有Mipmap有关系,查了查资料:
和双线性滤波有关。所以需要实现一个Dilation。



Albedo, 左边是Dilation前,右边是Dilation后

最终结果:


问题解决。
问题3. Overdraw

刚刚可以看到,黑色的区域全部是 Overdraw,应该需要自行生成一个面片,引入较少的新顶点给兔子描个边。目前的想法是把所有的 Albedo 的alpha通道叠在一起做成一张叠图,然后在这个叠图里手动圈出来一个多边形替代四边形面片。以后再做。
问题4. 移动相机的时候感觉面片破绽比较明显

Frame 较少的时候很明显会有这个东西是个四边形面片 Billboarding 的感觉。研究了一下,感觉需要调整一下 Billboarding 的方式,如果在视角变化较小,采样都发生在同一个Frame的时候,Billboard最好不动,之后实现一下。
问题5. 阴影问题

既然可以获取各个方向上看过去的形状,那么投影就是可以实现的。之后实现一下。
问题6. 丑

下次努努力,把 URP 的 Lit 搬进来看看。法线贴图啥的都没用上呢。先用一把试试,只实现漫反射。


half3 normal = tex2D(_NormalSmooth, i.frameUV).xyz * 2 - 1;
half3 worldNormal = TransformObjectToWorldNormal(normal);

Light mainLight = GetMainLight();
half3 WorldSpaceViewDirection = SafeNormalize( -_WorldSpaceCameraPos.xyz);
half3 halfDir = SafeNormalize(WorldSpaceViewDirection + mainLight.direction);
half3 diffuse = mainLight.color * albedo * saturate(dot(halfDir, worldNormal));算是那么回事吧。要实现 Lit 还得要更多的材质属性才行。
问题7: Impostor 和原模型的坐标原点不一致,替换或者设为 LOD 的时候会有不该出现的平移。还没解决。



后续可能会做的工作:

  • ★相邻帧混合,使得随视角的变化更平滑。
2. ★引入深度重建世界坐标,得到真实光照。
下期再见。个人学习日记,转载请注明。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-5-6 23:46 , Processed in 0.148470 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表