前言
前段时间做了一件很有意思的事,使用一张普通纹理图片做成了一个天空盒(SkyBox),其实是半个,因为最终形成的天空盒是上下对称的,看起来有天空之境的感觉。
天空盒篇
常见的天空盒一般采用立方体包围盒做 geometry,再使用 CubeMap 将 6 张纹理图贴到立方体包围盒上,但是如果只用一张纹理图片,要映射到立方体包围盒上,无论怎么展 UV,都会造成部分棱和顶点 UV 聚集或突变,从而在这样的地方造成贴图扭曲或错位的不协调现象。
既然立方体包围盒不可行,就只有采用另一种 geometry —— 球 了,但是直接用球作为 geometry 会造成两种问题:1、球的极点处会有扭曲现象,因为球极点处的 UV 是聚集的,同一个位置,V 都为 1 或 0,而 U 则为 [0, 1] 都有,即在该位置处一个 V 对应多个 U,自然会造成扭曲;2、由于球是一个圆周,所以 U 为 0 的位置和 U 为 1 的位置是同一个,对于一张普通纹理图,这就会造成错位,能看到非常明显的接缝线。所以需要重新展开球面的 UV,而常见的展 UV 方法主要有以下几种:
- 对于平面,uv 坐标自然通过线性归一化解决,取左下角坐标和右上角坐标,得到平面的宽度和长度,再用当前坐标减去左下角坐标,最后除以平面宽度和长度得到 uv 坐标。
- 对于球面,自然是取水平方位角 \(θ, \theta \in [-\pi, \pi]\) 和 垂直仰角 \(φ, φ\in [0, \pi]\) 作为 uv 坐标。对于 threejs 而言,方位角在 XZ 平面上,可计算 \(\theta = Math.atan2(z, x)\),仰角 \(φ = Math.acos(-y)\),其中 \(x,y,z\) 为当前坐标归一化后的值,则 \(u = (θ +\pi)/(2*\pi)\),\(v=φ/\pi\)。
- 对于一般的曲面,可能会依据当前顶点的法线计算 uv 坐标。对法线向量 x,y,z 三个分量的值进行排序,取最小的两个作为 uv,或者将最小的两个除以最大的一个作为 uv(CubeMap 中通常用这种进行 6 个面的纹理映射,最大的那个决定映射 6 个面中的哪一面),或者取固定的两个分量作为 uv ,或者得到最小的两个分量索引取对应顶点坐标的分量值作为 uv 等等,若 uv 取值为 [-1,1],则还需要做 \(uv = uv *0.5+0.5\) 。根据法线计算 uv 一般需要根据不同的使用情况选择不同的 uv 计算策略。
Shaun 很快的解决了第二个问题,想要将一张普通纹理图片贴在整个球面上而不留下接缝线是不现实的,而铺在半个球面上,另一半球面做对称,这个是可行的,而且看起来的效果也不错,计算 uv 就很简单了,原始的 v 不需要改变,只需要将 u 从 [0, 0.5] 映射为 [0, 1],从 [0.5, 1] 映射为 [1, 0],即 \[ f(u)=\begin{cases} 2*u, 0 \le u \le 0.5 \\ 2*(1-u), 0.5 \le u \le 1 \end{cases} \] ,接缝线问题使用对称性巧妙的解决了,但第一个问题,极点扭曲现象,还是没法解决,接下来才是真正的难点 ╯︿╰。
为了解决极点扭曲问题,首先需要知道极点扭曲的根本原因是同一个位置对应了多个 uv,所以要么将这些 uv 给散开,要么抛弃一部分 uv,散开 uv 的方式 Shaun 没想出来,抛弃一部分 uv 倒是有一种简单的方式,不过抛弃 uv 也意味着会损失一部分纹理,就这个天空盒而言,抛弃一部分 uv 是能接受的,不然平面到球面必然有形变。具体抛弃方法为,将球面铺平,变为一个大圆面,即忽略球面顶点坐标的 z 值,求出球的 AABB,将 AABB 铺平,即为大圆的外接正方形,用计算平面 uv 的方式重新球上各顶点的 uv 坐标,这种方式的确能解决极点扭曲的问题,但又带来了一个更为严重的问题。
这个问题就是,球面上半部分显示很正常,但是越靠近底部大圆的部分,纹理拉伸的越厉害,造成天空盒四周都出现很严重的纹理拉伸现象。出现纹理拉伸现象的原因也很好理解,那就是越靠近底部大圆的顶点,uv 坐标之间的间隔越小,即同样的 uv 间隔,顶点之间的距离变大了,在进行纹理插值时,自然会导致纹理拉伸现象。知道问题出现的原因了,那怎么解决了?这又是一个新问题 😔。
导致纹理拉伸现象的原因是线性映射,那能不能对计算好的 uv 进行非线性映射,从而抵消 OpenGL 线性纹理插值的影响,非线性映射最重要的是找到合适的非线性函数,常见非线性函数一般有幂函数(伽马变换就是一种幂函数变换,幂 < 1 拉伸小值,幂 > 1 拉伸大值),对数函数,指数等,对于 Shaun 这里的情况,常规的非线性函数肯定是不行的,只能自己想一个函数。
注意到,这是在球面上,要取一个非线性映射函数,自然需要从圆的弧长入手,先计算圆的弧长。计算弧长的本质是勾股定理 \(ds=\sqrt{(dx)^2 + (dy)^2} = \sqrt{1+(dy/dx)^2} * dx\),对于圆 \(x^2 + y^2 = r^2, y \ge 0\),即 \(y=\sqrt{r^2-x^2}\),有 \(dy/dx = -x/ \sqrt{r^2-x^2}\),则对于圆的弧长 \(L=\int \sqrt{1+x^2/(r^2-x^2)}dx = r \int 1/ \sqrt{1-(x/r)^2}d(x/r) = r*arcsin(x/r)|\),即对于圆在第一象限的弧长可以为 \(L=r*arcsin(x/r), 0\le x \le r\) 。
由于 v 是均匀的,所以只需要对 u 进行拉伸即可,离圆心越近的点,则越接近 0.5, 离圆心越远的点则越偏离 0.5,非线性拉伸公式为 $ u = α * (u-0.5) + 0.5$,其中 \(α = L / (\pi*r/2), L中的x为点到圆心的距离\),使用这种拉伸后,天空盒四周的纹理拉伸现象确实不见了,但是又引入了新的问题,天空盒顶部出现了局部拉伸现象,出现微弱的纹理模糊,虽然区域不大,依靠一些手段可以让用户看不到这块区域,但是不能自欺欺人,这一块存在总让 Shaun 觉得很不舒服,于是,就有了下面的终极解决方案。
Shaun 最终想出解决方案是:既然单纯的拉伸不能完美解决问题,那还是只能从问题根源入手,完全重新计算 uv 坐标,这次计算 uv 坐标,还是需要借助上文的 \(α\)。具体计算 uv 坐标的方式如下:
- 先计算球面上顶点相对大圆圆心的角度,即 \(angle = Math.atan2(y - center.y, x - center.x)\),其中 center 即为大圆圆心(0, 0)。
- 根据顶点到圆心的距离得到 \(\alpha\),计算 \(α_x = α * cos(angle), α_y=α * sin(angle)\) 。
- 计算 uv:\(u = α_x * 0.5 + 0.5, v=α_y*0.5+0.5\) 。
使用这个方式计算 uv,天空盒的全部问题都解决了,天空盒没有任何拉伸扭曲等令人看起来不协调的地方,至于对称也说的过去,Shaun 个人感觉挺漂亮的 ( ̄▽ ̄)"。自此天空盒的事就算是告一段落了。
后记
做这个天空盒,确实花费了 Shaun 了不少力气,在做的那两天,满脑子都是为什么会拉伸扭曲,以及如何解决拉伸扭曲,最终想出了这套方案,简单优雅,最后的效果也是完美达到了 Shaun 的预期。不过一般人应该也用不到 Shaun 这套方案,这只是 Shaun 自己想做做而已,搞不出来没关系,搞出来当然是好的。