前情提要

在工地开发项目的过程中,阿茶遇到了这样一个需求,有若干3D模型会在一个固定的全屏背景前移动。

在实现这个功能的时候,阿茶想的比较复杂,为了实现简便,阿茶首先考虑到的是曾经听过的解决办法,将背景放在Canvas下,并且单独设置一个相机用来显示该背景。

当阿茶和师父说了自己的实现方式后,师父表示,这样做感觉是将简单的问题复杂化了,可以直接将图片当做3D物体放置在场景中,然后将图片根据屏幕尺寸与图片和相机的相对位置进行缩放以实现背景的适配。

听到这个方式之后,阿茶突然发现,迄今为止,阿茶所针对相机和物体进行的操作并没有研究深入,因此决定稍微研究一下。另外这样子的需求肯定不止这一个地方会用得到,因此顺便在这里稍加记录,将来再次遇到的时候就可以直接拿来复用(copy)了。

为了完成这篇文章,阿茶有幸请到了原神中大名鼎鼎的应急食品最好的向导——派蒙,在这里饰演一个3D物体。

1. 作为UI实现

作为UI进行实现这种方式比较简单,以为使用的是UGUI中默认的适配方式进行适配,不需要通过代码进行操作。

其步骤如下所示

  1. 在任意Game Object的Inspector面板的Layer部分新建一个名为Background的层。
  2. 新建一个Camera命名为「BackgroundCamera」。
    1. 设置Layer为步骤1种建立的Background
    2. 设置Clear FlagsSolid Color
    3. 设置Culling Mask为步骤1种建立的Background层。
    4. 因为是背景相机,因此这里将相机的模式(Projection)设置为正交相机(Orthographic)。
    5. 因为相机会按照深度进行渲染,背景相机对应的背景应该是在所有物体的后面,因此,应该将该相机的Depth值设置为最小,这里阿茶设置为了-100(截图中的倒数第三行,没有画红框)。
    6. [可选项]因为相机和canvas的image会在场景中显示,如果你觉得对你的开发有影响,可以将相机的位置设置一个非常角落的坐标,比如阿茶的相机设置在了(-1000, 0, 0)这个位置,这下相机和image就不会影响Scene面板中的编辑了。

  1. 建立一个Canvas并命名为「BackgroundCanvas」。
    1. 在「BackgroundCanvas」的Inspector面板中将Canvas下的Render Mode设置为Screen Space - Camera模式。
    2. 将步骤2的「BackgroundCamera」拖入到Render Camera中。

  1. 在步骤3建立的Canvas下新建一个UI - Image,并将其命名为「BackgroundImage」,并在其Inspector面板中将Rect Transform的适配设置为Stretch

  1. 回到主相机
    1. 在主相机的Inspector面板中将Clear Flags设置为Depth only
    2. Culling Mask设置为除去步骤1种建立的Background层之外的所有层。

大功告成了!我们的派蒙能够成功的在背景图前面自由的飞翔了

2. 作为3D物体实现

相对于使用Canvas进行实现,这个相对来说要稍微复杂一点,因为使用Canvas是借助UGUI的系统,让unity调整适配,而使用这种方式,则需要程序员(这里也就是阿茶)来关心如何使用代码进行适配。

视锥

在描述该实现方法之前,这里需要稍微研究一下视锥这个概念,以及其引申出来的一些计算法。

所谓的视锥,可以理解为一个平方的等边三角形。

如上图所示,眼睛的位置及为观察的位置,在unity中也就是相机的位置。根据三角函数的知识,FoV,d,hFoV, d, h三者中,任意知道两者既可以求得第三者。

于是我们就可以得到这个公式h=2×d×tan(FoV)h=2\times d \times\tan(FoV)

Unity中的对应关系

在Unity中,上图中的三个数值的对应关系如下

FoVFoV:Field of View,也就是主相机设置中的Field of View属性的值。

dd:为主相机和作为背景图片的GameObject之间的距离。

hh:为作为背景图片的GameObject的高度。

具体实现

建立背景图片

如图,在场景中拖入一张用作背景的图片。

将其命名为「BackgroundImage」,并将其Transform进行Reset。

设置主相机

设置主相机的属性

  1. 主相机所渲染的层默认为Everything
  2. 相机模式(Projection)设置为透视模式(Perspective)。
  3. 注意,这里的Positionz轴为-10,而步骤1中图片的z轴为0,这一点在后续计算d时会用得到。

代码控制

  1. 让程序在开始时获得荧幕的解析度。

其目的是判定图片将会以哪一边作为基准进行等比例缩放。

由于背景图片需要全屏进行展示,并且大多数情况,背景图片必须进行等比缩放,因此这里的缩放需要以较长边为基准进行缩放。即如果是横屏,则应以屏幕的宽作为适配,如果是竖屏,则应以屏幕高作为适配(这里的宽和高对应屏幕在不同状态下的横边和竖边)。

补充:但是对于一张分辨率固定的图片,不能单一考虑以长边进行适配,如对于一张1080*2340的图片,放在1080*1920的分辨率的屏幕下,如果以长边对图片进行缩放,那么便会出现按照长边将图片进行缩小,这时便会出现短边无法完整显示图片。在这种情况下,就应当以短边对图片进行适配。

因此可以使用UnityEngine.Screen.widthUnityEngine.Screen.height进行获取。

同时,也可以使用相机的属性aspect进行获取。在Document中,aspect的解释是The aspect ratio (width divided by height).。因此,如果aspect的值大于1,则可以认为是横屏,反之则可以认为是竖屏。

  1. 已知FoVFoVdd,根据公式计算hh

h=2.0×distance×tan(camera.FoV×1/2)h = 2.0 \times distance \times \tan(camera.FoV \times 1/2)

转换为Unity程式码,其对应的内容如下

1
2
this.m_frustumHeight = 2.0f * this.m_distance * Mathf.Tan(m_mainCamera.fieldOfView * 0.5f * Mathf.Deg2Rad);
this.frustumWidth = this.m_frustumHeight * this.m_Camera.m_Lens.Aspect;

上式计算出的结果是unit单位,因此计算图片缩放时,图片也应使用unit单位进行计算。

  1. 计算后,应将这个结果用于图片的缩放。

!注:在阿茶的项目中,该脚本挂在了「BackgroundImage」上!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
if (this.m_mainCamera.aspect < 1.0f)  // width / height  ||||  width < height
{
if (this.m_frustumHeight / this.m_BGSR.bounds.size.y * this.m_BGSR.bounds.size.x >= this.frustumWidth)
{
this.scale *= this.m_frustumHeight / this.m_BGSR.bounds.size.y;
}
else
{
this.scale *= this.m_frustumHeight * this.m_mainCamera.aspect / this.m_BGSR.bounds.size.x;
}
}
else // width >= height
{
if (this.m_frustumHeight * this.m_mainCamera.aspect / this.m_BGSR.bounds.size.x * this.m_BGSR.bounds.size.y >= this.m_frustumHeight)
{
this.scale *= this.m_frustumHeight * this.m_mainCamera.aspect / this.m_BGSR.bounds.size.x;
}
else
{
this.scale *= this.m_frustumHeight / this.m_BGSR.bounds.size.y;
}
}

this.transform.localScale = this.scale;

其对应的完成脚本代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class UsualCamera : MonoBehaviour
{
private Camera m_mainCamera; // Get main camera.
private Transform m_mainCameraTF; // Get camera's transform.
private Transform m_BGImageTF; // Get background iamge's transform.
private SpriteRenderer m_BGSR; // Get background image's sprite renderer.

private float m_distance; // Distance between camera and background image.

private Vector2 scale; // Background image scale to be zoomed.
private float m_frustumHeight; // Camera frustum's height(Unit);
private float m_frustumWidth; // Camera frustum's height(Unit);

// define const strings
private const string backgroundImageName = "BackgroundImage";



void Start()
{
this.m_mainCamera = Camera.main;
this.m_mainCameraTF = this.m_mainCamera.transform;
this.m_BGImageTF = this.transform;
this.m_BGSR = this.GetComponent<SpriteRenderer>();
this.m_distance = (this.m_BGImageTF.position - this.m_mainCameraTF.position).magnitude;
this.scale = Vector2.one;

print($"Device's screen resolution is {Screen.width}x{Screen.height}, and Screen's {((this.m_mainCamera.aspect >= 1.0f) ? "Width" : "Height")} is shorter.");
print($"Background iamge's resolution is {this.m_BGSR.bounds.size * 100}");
print($"Distance is: {this.m_distance}, FOV is {this.m_mainCamera.fieldOfView}, aspect is {this.m_mainCamera.aspect}");

this.m_frustumHeight = 2.0f * this.m_distance * Mathf.Tan(m_mainCamera.fieldOfView * 0.5f * Mathf.Deg2Rad);
this.frustumWidth = this.m_frustumHeight * this.m_Camera.m_Lens.Aspect;

if (this.m_mainCamera.aspect < 1.0f) // width / height |||| width < height
{
if (this.m_frustumHeight / this.m_BGSR.bounds.size.y * this.m_BGSR.bounds.size.x >= this.frustumWidth)
{
this.scale *= this.m_frustumHeight / this.m_BGSR.bounds.size.y;
}
else
{
this.scale *= this.m_frustumHeight * this.m_mainCamera.aspect / this.m_BGSR.bounds.size.x;
}
}
else // width >= height
{
if (this.m_frustumHeight * this.m_mainCamera.aspect / this.m_BGSR.bounds.size.x * this.m_BGSR.bounds.size.y >= this.m_frustumHeight)
{
this.scale *= this.m_frustumHeight * this.m_mainCamera.aspect / this.m_BGSR.bounds.size.x;
}
else
{
this.scale *= this.m_frustumHeight / this.m_BGSR.bounds.size.y;
}
}

print($"Background image should be zoomed to scale: {this.scale}");

this.transform.localScale = this.scale;

}
}

参考

  1. [Gabber|橋の上と山々【背景素材】] : https://www.pixiv.net/artworks/101045191

  2. [yxlalm|Unity 3D模型展示之场景更换背景] : https://blog.csdn.net/yxl219/article/details/109849017

  3. [グレイ|レン&白レン(メルブラ)モーション] : https://bowlroll.net/file/201168

  4. [神帝宇|(派蒙模型)] : https://space.bilibili.com/7767105

  5. [SnoopyNa2Co3|Unity 3d镜头适配,分辨率aspect和fov的关系] : https://blog.csdn.net/SnoopyNa2Co3/article/details/83893093

  6. [Unity Documentation|距摄像机一定距离的视锥体的大小] : https://docs.unity3d.com/cn/2021.2/Manual/FrustumSizeAtDistance.html