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 中:
在上面的画作中,它中心有一个强烈的消失点,拥有一个宽广的视角,但边缘却没有透视变形的情况。图中的所有物体看上去都保持着它原有的形状。这和我们之前的那张图相比,位于透视点中心的物体看上去要更大和更近。后人通过研究帕尼尼大佬的作品,逆向去设计这种投影的方法,即帕尼尼投影。
帕尼尼投影是一种圆柱形极平面投影(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 dCrop 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 参考文献
- Interior of Saint Peter’s, Rome, c. 1754
- The General Panini Projection
- Pannini: A New Projection for Rendering Wide Angle Perspective Images
水平有限,如有错误,请多包涵 (〃‘▽’〃)