URP Panini Projection 魔法

URP Panini Projection 魔法

然而,并没有文字记录这些非凡的视角是如何构建的!


阅读注意

  • 本文的URP版本为10.8.1

当你希望你制作的游戏能给玩家提供一个宽广的视角,于是将场景相机的FOV调高到了120,得到了以下画面:
在这里插入图片描述

但似乎效果有点不合人意,边缘拉伸太厉害了,左侧的Unity酱严重变形,右侧的红色球体被拉长为了椭圆形状。我们需要一些方法来解决这个问题,这就是本文要说的帕尼尼投影。

1 帕尼尼投影是什么

帕尼尼投影(Panini Projection) 的名字来源于一位意大利画家Giovanni Paolo Panini(1691-1765),在他一些著名的画作中,例如Interior of Saint Peter’s, Rome, c. 1754 中:
Interior of Saint Peter's, Rome*, c. 1754
在上面的画作中,它中心有一个强烈的消失点,拥有一个宽广的视角,但边缘却没有透视变形的情况。图中的所有物体看上去都保持着它原有的形状。这和我们之前的那张图相比,位于透视点中心的物体看上去要更大和更近。后人通过研究帕尼尼大佬的作品,逆向去设计这种投影的方法,即帕尼尼投影。

帕尼尼投影是一种圆柱形极平面投影(cylindrical stereographic projection),在 Pannini: A New Projection for Rendering Wide Angle Perspective Images 一文中给出了投影细节。
在这里插入图片描述

这个图中下半部分是一个俯视图,圆形的位置代表一个圆柱,中间代表场景俯视图,上面代表投影出来的图像。对于正常的直线投影,它的投影中心在圆柱中心,场景点和圆柱中心连线,直接投影到平面上。而帕尼尼投影会在此的基础上,将投影中心向后移,并连接直线投影在圆柱上的点,再投影在平面上。

简化一下模型:
在这里插入图片描述

也就是说,原本投影中心在 O O O点,投影到屏幕上是点 X X X。现在将投影中心后移距离 d d d到点 P P P,原本点 X X X的位置,现在应该投影到点 E E E。这种方式保证了垂直线是垂直且直的,类似于直线投影,并且随着投影点的后移能够逐步放大图像中心的部分,压缩图像边缘。

2 Unity 实现细节

我们现在来看看Unity是怎么实现的。
在这里插入图片描述

Unity 一共有两个参数:

  • Distance:投影中心后移的距离 d d d
  • Crop To Fit: 裁剪适配

2.1 计算压缩比例

我们先来看看在渲染前需要做那些准备:

// Back-ported & adapted from the work of the Stockholm demo team - thanks Lasse!
void DoPaniniProjection(Camera camera, CommandBuffer cmd, int source, int destination)
{
    float distance = m_PaniniProjection.distance.value;
    var viewExtents = CalcViewExtents(camera);
    // 计算压缩之后的 比例
    var cropExtents = CalcCropExtents(camera, distance);

    float scaleX = cropExtents.x / viewExtents.x;
    float scaleY = cropExtents.y / viewExtents.y;
    float scaleF = Mathf.Min(scaleX, scaleY);

    float paniniD = distance;
    // ratio
    float paniniS = Mathf.Lerp(1f, Mathf.Clamp01(scaleF), m_PaniniProjection.cropToFit.value);

    var material = m_Materials.paniniProjection;
    material.SetVector(ShaderConstants._Params, new Vector4(viewExtents.x, viewExtents.y, paniniD, paniniS));
    material.EnableKeyword(
        1f - Mathf.Abs(paniniD) > float.Epsilon
        ? ShaderKeywordStrings.PaniniGeneric : ShaderKeywordStrings.PaniniUnitDistance
    );

    Blit(cmd, source, BlitDstDiscardContent(cmd, destination), material);
}

首先,这里通过函数CalcViewExtents计算了屏幕的尺寸信息保存在了ViewExtents

Vector2 CalcViewExtents(Camera camera)
{ 
    float fovY = camera.fieldOfView * Mathf.Deg2Rad;
    float aspect = m_Descriptor.width / (float)m_Descriptor.height;

    float viewExtY = Mathf.Tan(0.5f * fovY);
    float viewExtX = aspect * viewExtY;

    return new Vector2(viewExtX, viewExtY);
}

在这里插入图片描述

然后,通过CalcCropExtents函数计算经过帕尼尼投影后,图片的压缩尺寸:

Vector2 CalcCropExtents(Camera camera, float d)
{ 
    // have X
    // want to find E
    // PS = viewDist
    float viewDist = 1f + d;
    // SX = projPos.x 
    var projPos = CalcViewExtents(camera);
    // (hypotenuse)斜边 OX = projHyp
    var projHyp = Mathf.Sqrt(projPos.x * projPos.x + 1f);
    // OA = cylDisMinsD
    float cylDistMinusD = 1f / projHyp;
    // AP = cylDist
    float cylDist = cylDistMinusD + d;
    // AQ = cylPos
    var cylPos = projPos * cylDistMinusD; 
    return cylPos * (viewDist / cylDist);
}

计算压缩尺寸相当于,知道直线投影的位置,求帕尼尼投影的位置。即我们已知 P S PS PS S X SX SX,求 S E SE SE
在这里插入图片描述

把压缩后的大小和原本大小相比,算出压缩比例,然后和cropToFit插值控制一下。

float paniniS = Mathf.Lerp(1f, Mathf.Clamp01(scaleF), m_PaniniProjection.cropToFit.value);

最后,以 d d d是否为1为条件,划分为两种计算方式,在着色器中有所体现。

material.EnableKeyword(
    1f - Mathf.Abs(paniniD) > float.Epsilon
    ? ShaderKeywordStrings.PaniniGeneric : ShaderKeywordStrings.PaniniUnitDistance
);

2.2 投影映射

顶点着色器很常规,没啥可说的。我们来看一下,片元着色器。

half4 Frag(Varyings input) : SV_Target
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
 
	// _Params.xy 为 viewExtents
    // _Params.w 为 paniniS(缩放比例)
    // _params.z 为 d 距离
    #if _GENERIC
    float2 proj_pos = Panini_Generic((2.0 * input.uv - 1.0) * _Params.xy * _Params.w, _Params.z);
    #else // _UNIT_DISTANCE
    float2 proj_pos = Panini_UnitDistance((2.0 * input.uv - 1.0) * _Params.xy * _Params.w);
    #endif

    float2 proj_ndc = proj_pos / _Params.xy;
    float2 coords = proj_ndc * 0.5 + 0.5;

    return SAMPLE_TEXTURE2D_X(_SourceTex, sampler_LinearClamp, coords);
}

这里通过2.0 * input.uv - 1.0将原点移动到屏幕中心,通过投影函数计算投影之前原本的位置,然后再映射回UV坐标,最后采样原图就行了。(注意:这里我们是已知投影后的点,要逆向找回对应原本的UV坐标。)

我们先来看看Panini_Generic函数

float2 Panini_Generic(float2 view_pos, float d)
{ 
    // Have E
    // Want to find X
    //
    // First compute line-circle intersection to find Q
    // Then project Q to find X

    // PS = view_dist
    float view_dist = 1.0 + d;
    // SE = view_pos.x
    // PE^2 = view_hyp_sq
    float view_hyp_sq = view_pos.x * view_pos.x + view_dist * view_dist; 
    float isect_D = view_pos.x * d;
    // the quadratic discriminant
    float isect_discrim = view_hyp_sq - isect_D * isect_D;

    // OA = cyl_dist_minus_d
    float cyl_dist_minus_d = (-isect_D * view_pos.x + view_dist * sqrt(isect_discrim)) / view_hyp_sq;
    // AP = cyl_dist
    float cyl_dist = cyl_dist_minus_d + d;
    // AQ = cyl_pos.x
    float2 cyl_pos = view_pos * (cyl_dist / view_dist); 
    return cyl_pos / (cyl_dist - d);
}

这里我们是已知点 E E E,反过来求点 X X X
在这里插入图片描述

再来看看Panini_UnitDistance

float2 Panini_UnitDistance(float2 view_pos)
{ 
    // Have E
    // Want to find X
    //
    // First apply tangent-secant theorem to find Q
    //   PE*QE = SE*SE
    //   QE = PE-PQ
    //   PQ = PE-(SE*SE)/PE
    //   Q = E*(PQ/PE)
    // Then project Q to find X

    const float d = 1.0;
    const float view_dist = 2.0;
    const float view_dist_sq = 4.0;
    // PE = view_hyp
    float view_hyp = sqrt(view_pos.x * view_pos.x + view_dist_sq);
    // PQ = cyl_hyp
    float cyl_hyp = view_hyp - (view_pos.x * view_pos.x) / view_hyp;
    // PQ / PE
    float cyl_hyp_frac = cyl_hyp / view_hyp;
    // AP = cyl_dist
    float cyl_dist = view_dist * cyl_hyp_frac; 
    float2 cyl_pos = view_pos * cyl_hyp_frac; 
    return cyl_pos / (cyl_dist - d);
}

这是当 d = 1 d=1 d=1的情况下的计算方式,这样我们有很多已知的值。
在这里插入图片描述

最后来看看实际效果:
在这里插入图片描述

3 参考文献


水平有限,如有错误,请多包涵 (〃‘▽’〃)

  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值