大规模建筑内景的渲染:Interior-Mapping

简介

Interior Mapping主要用于在物体表面利用透视错局模拟出格子结构的内部内容。虽然从外部观察起来,物体内部的构造是“真实的”,但一切渲染都没有依靠更多的模型或真实顶点。这在渲染大量模式化的内部结构中会有很大作用。
例如渲染高楼林立的都市时,如果要渲染每个单独的房间就会有大量的性能开销,完全的使用单一的贴图又会有些单调,这个时候就适合用Interior Mapping来伪造出建筑的外观而不让性能下降太多。
它的几个优点:
1.房间的数量不会影响framerate或内存开销
2.并不需要太多的额外资源就可以表现大的场景
3.实现上并不要求高级的shader model特性

基本思路的实现

思路可以参考Joost van Dongen的论文Interior Mapping - A new technique for rendering realistic buildings
按照Joost论文中的思路实现即可,本文不再详述。

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
// Reciprocal of each floor's height d
float3 invert_d = _Tiling / _BoundarySize;

float3 cameraPos_obj = mul(unity_WorldToObject, float4(_WorldSpaceCameraPos, 1.0)).xyz;
float3 cameraToPixelOffset_obj = i.pos_obj - cameraPos_obj;

float3 cameraStepDir_obj = step(float3(0, 0, 0), cameraToPixelOffset_obj);
float3 floorId = floor(i.pos_obj * 0.999 * invert_d) + cameraStepDir_obj;
float3 floorPos_obj = floorId / invert_d;
float3 cameraToFloorIntersectionOffset_obj = floorPos_obj - cameraPos_obj;
// This is the ratio between the actual distance before reaching destination and the distance of pixel and camera in object space
float3 ratio = cameraToFloorIntersectionOffset_obj / cameraToPixelOffset_obj;

// Here the intersection coordinate has been normalized through dividing by d
float2 intersectionXY_obj = (cameraPos_obj + ratio.z * cameraToPixelOffset_obj).xy * invert_d;
float2 intersectionXZ_obj = (cameraPos_obj + ratio.y * cameraToPixelOffset_obj).xz * invert_d;
float2 intersectionZY_obj = (cameraPos_obj + ratio.x * cameraToPixelOffset_obj).zy * invert_d;

float4 ceilingCol = tex2D(_CeilingTexture, intersectionXZ_obj);
float4 floorCol = tex2D(_FloorTexture, intersectionXZ_obj);
float4 horizonCol = lerp(floorCol, ceilingCol, step(0, cameraToPixelOffset_obj.y));

float4 wallXYCol = tex2D(_WallXYTexture, intersectionXY_obj);
float4 wallZYCol = tex2D(_WallZYTexture, intersectionZY_obj);

// Check which face is closer to camera and pick it as the final texuture for specific pixel
float xLessThanZ = step(ratio.x, ratio.z);
float4 verticalCol = lerp(wallXYCol, wallZYCol, xLessThanZ);
float ratioMin_x_z = lerp(ratio.z, ratio.x, xLessThanZ);

float x_zLessThanY = step(ratioMin_x_z, ratio.y);
float4 innerCol = lerp(horizonCol, verticalCol, x_zLessThanY);

这段按照论文思路实现的代码有几点缺陷:
1.墙面还没有厚度,虽然可以手动加一个外墙的mask但是,会从窗外看到一个不对齐的地面,同时也无法只通过一个厚度参数自动调整。
2.还没有加上sprite层(家具层)丰富室内内容
3.在拐角处会看到本不应该看到的墙面

本文的一些定义:
Block: 每个单独的房间称为一个Block
内腔:每个Block中不被墙体填充的部分
d: 代表一个Block某个方向上的长度
_BoundarySize: 指一个Cube Mesh的某条棱的长度
_Tiling: 指在某个坐标轴方向上,Mesh被切分的次数

可配置厚度墙面的实现

如果没有显示出墙的厚度,会有看上去错位的地板

我们要做的是将原本的墙面朝着某个方向推出一定距离,模拟出墙的厚度。首先计算出摄像机相关变量,方便后面确定偏移量

1
2
3
float3 cameraPos_obj = mul(unity_WorldToObject, float4(_WorldSpaceCameraPos, 1.0)).xyz;
float3 cameraToPixelOffset_obj = i.pos_obj - cameraPos_obj;
float3 cameraStepDir_obj = step(float3(0, 0, 0), cameraToPixelOffset_obj); // return 1 if it's positive, otherwise 0

原始的墙面界面位置为

1
floor(i.positionCopy * 0.999 / _d)  + stepDir

为了模拟墙面向内intrude,需要减去一个墙面的偏移量(stepDir - float3(0.5, 0.5, 0.5)) * _WallThickness之后再计算相似三角形边的比

1
float3 ratio = ((floor(i.positionCopy * 0.999 / _d)  + stepDir - (stepDir - float3(0.5, 0.5, 0.5)) * _WallThickness) * _d  - cameraPos_obj) / direction;

不过目前为止只是加上了一个墙面的偏移量,结果如下图一样


造成视觉上的怪异的原因是对于墙面部分,我们俯视它时依然会“穿过”水平墙面看到内部的垂直墙面,而这些uv之外的部分实际不应该被看到。

判断像素是否在墙内

找到问题之后,我们就需要找到每个block中,那些像素对应的是墙面上的位置。
首先利用floor可以找到每个block的中心位置。

1
2
3
float3 floorId = floor(i.vertexPos  * 0.999 / _d);
float3 centerPos_obj = (floorId + 0.5) * _d;
float3 offsetFromCenter_obj = abs(i.vertexPos - centerPos_obj) / _d;

这里的centerPos_obj在object space中,offsetFromCenter_obj表示的是归一化后的偏移,即范围为[0,1],代表偏移量占每个block的比例.

至于如何判定一个点是否处在墙面里,一种方法是:
检测在某个平面上最远的坐标分量是否超出了内cube的半径,以xy平面为例

1
2
float max_xy = max(offsetFromCenter_obj.x, offsetFromCenter_obj.y);
float mask = step((1 - _WallThickness) * 0.5, max_xy );


同样的手法可以用到其他2个平面上,合起来就是
1
2
3
4
5
6
float max_xy = max(offsetFromCenter_obj.x, offsetFromCenter_obj.y);
float mask = step((1 - _WallThickness) * 0.5, max_xy );
float max_xz = max(offsetFromCenter_obj.x, offsetFromCenter_obj.z);
mask *= step((1 - _WallThickness) * 0.5, max_xz );
float max_yz = max(offsetFromCenter_obj.y, offsetFromCenter_obj.z);
mask *= step((1 - _WallThickness) * 0.5, max_yz );



这里mask相当于是一个AND布尔运算操作,只有在各个平面的投影中都属于“内部”中的像素才可以被称为真正的内腔,也就是会绘制墙面上的像素,这些像素的mask值为0.
这个过程可以想象成在三个正交的方向上对一个几何体做投影挖去不属于墙面的部分,最终剩下的白色部分就是我们所需要的墙面
1
2
float4 outerCol = tex2D(_outerWallTexture, i.uv);
float4 finalCol = lerp(innerCol, outerCol, mask);


最终的效果如

使用AND还是OR

我们在进行投影布尔运算的时候,实际上会将内部结构中的墙体一并减去。但由于我们实际上只需要最外层的mask值,所以对于立方体来说,这样的操作不会有什么问题。但是对于球体等不规则物体来说,由于我们会看到内部的结构,所以被错误减去的墙体将会出现视觉问题。


如果将其mask输出,就能更容易的发现问题

本来是内部的墙面出现在了球面上,因为被不正确的减去了,所以本来应该有墙的地方就缺失了一块.
可能从正交视图的某个平面方向上看更容易理解

修正这个错误的思路就是从之前的AND运算变成OR运算,只要在任意方向上超出内腔的范围,就认定为进入墙体。这从直观理解上也更容易理解。
值得注意的是,如果用OR运算,我们只需要判断两个方向即可。这是因为第三个方向的信息变得冗余了。

1
2
3
4
5
6
7
float max_xy = max(offsetFromCenter_obj.x, offsetFromCenter_obj.y);
float mask = step(max_xy, (1 - _WallThickness) * 0.5);
float max_xz = max(offsetFromCenter_obj.x, offsetFromCenter_obj.z);
mask *= step(max_xz, (1 - _WallThickness) * 0.5);
// float max_yz = max(offsetFromCenter_obj.y, offsetFromCenter_obj.z);
// mask *= step(max_yz, (1 - _WallThickness) * 0.5);
mask = 1 - mask;

不过这也会带来一个潜在的问题,

当tiling达到某个值的时候,墙面会恰好在立方体的边缘,这个时候,整个表面就被墙遮住了。我曾经想在shader中判断并自动做偏移(只有tiling为偶数的时候才会出现)不过后来还是决定暴露这个参数,让使用者自己决定合适的tiling。有趣的是,虽然这个问题造成了不少困扰,但是之前AND的操作方法是不会遇到这个问题的;)
解决方法:判断墙的边界是否超出mesh的边界,详见正确显示边角的遮挡

制作Sprite Layer

朴素的截面偏移层

原论文中提到了制作在空房间内放置道具或者人物的思路,其实实现起来也很简单,和墙面一样是对观察深度进行一个偏移后计算交点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Intrude XY wall along Z axis by 0.4 block length
float3 spriteOffset_block = float3(0, 0, 0.4);
float3 cameraStepDir_obj = step(float3(0, 0, 0), cameraToPixelOffset_obj);
float3 floorId = floor(i.pos_obj * 0.999 * invert_d) + cameraStepDir_obj;
float3 spriteLayerRatio = ((floorId - spriteOffset_block) / invert_d - cameraPos_obj) / cameraToPixelOffset_obj;
float2 S_intersectionXY = (cameraPos_obj + spriteLayerRatio.z * cameraToPixelOffset_obj).xy * invert_d;
float2 S_intersectionZY = (cameraPos_obj + spriteLayerRatio.x * cameraToPixelOffset_obj).zy * invert_d;
float4 S_XYCol = tex2D(_SpriteTex, S_intersectionXY);
float4 S_ZYCol = tex2D(_SpriteTex, S_intersectionZY);
float S_xLessThanZ = step(spriteLayerRatio.x, spriteLayerRatio.z);
float4 S_verticalCol = lerp(S_XYCol, S_ZYCol, S_xLessThanZ);
float S_ratioMin_x_z = lerp(spriteLayerRatio.z, spriteLayerRatio.x, S_x_less_z);

float ratioMin_xyz = lerp(ratio.y, ratioMin_x_z, xzLessThanY);
// To see if it's closer than the origin wall
float S_isLess = step(S_ratioMin_x_z, ratioMin_xyz);
// If sprite layer is closer and the alpha is greater than 0, replace old pixel of wall by sprite layer
innerCol = lerp(innerCol, S_verticalCol, S_isLess * S_verticalCol.a);

不过这种方法也会有明显的问题,就是只能从一个方向上观察室内的人物或物体,从侧面和背面都能很容易的注意到是面片这一事实。

支持多方向适配

我们可以手工指定其他面,从而正确的显示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...
// If the tile's unilateral count(from the center to this tile) reach half of total tiling, means it's the side tile
float isSideOrFront = abs(ceil(i.pos_obj * 0.999 * invert_d).x) / (_Tiling.x/2) > 0.999 ? 1:0;
// Pick the color from side texture
float4 S_verticalCol = lerp(S_XYCol, S_ZYCol, isSideOrFront);
// Here we use the ratio of the face we decided to use in above step to overwrite the actual minimal one "S_x_less_z"
float S_ratioMin_x_z = lerp(spriteRatio.z, spriteRatio.x, isSideOrFront);
float2 halfTileCount = lerp(S_intersectionXY_ori, S_intersectionZY_ori, isSideOrFront) / _Tiling.x * 2;
// Because our sprite will keep repeating, even in the place we should not see it. Luckily, we can use the time it repeats to see if it's out of the valid display region.
bool validSpriteRegion = 1
* step(abs(halfTileCount.y), 1)
* step(abs(halfTileCount.x), 1);

float ratioMin_xyz = lerp(ratio.y, S_ratioMin_x_z, xzLessThanY);
float S_isLess = step(S_ratioMin_x_z, ratioMin_xyz) * validSpriteRegion;
...

类Billboard Sprite Layer

上面的方法除了在适应不同视角上处理的很生硬,代码中也有很多硬编码的重复代码。一个比较优雅的改进方法是使用一种技术可以自动的适应不同的观察角度。所以我在这里的方法是利用billboard思想制作一个可以自动跟随摄像机的sprite

传统Billboard

传统的billboard思路是变换顶点坐标:将Object Space下的坐标直接赋值给View Space,即可以获得稳定不变的view位置。几乎所有的操作也都是在vertex shader中完成的

1
2
3
float4 billboardPos = mul(UNITY_MATRIX_P,
mul(UNITY_MATRIX_MV, float4(0.0, 0.0, 0.0, 1.0)) // 依旧要记录物体中心的位置,否则物体始终在屏幕中央
+ float4(v.vertex.x, v.vertex.y, 0.0, 0.0));

但是显然这种方法无法运用到我们这个例子上,因为其他部分的显示要依附于原始的顶点位置,另外如果我们有多个block,那么对于每个block,我们无法再分出更多的顶点来完成变换(不会考虑Gemotry Shader)

物体空间内的Billboard

我们的思路是在保持顶点位置不变的情况下,算出每个像素对应的立方体表面的一个点的投影坐标

基础知识准备

在一般的渲染管线中。最先传入的顶点数据都是模型空间下的(Object space/Model space),经过Model Matrix,View Matrix和Projection Matrix以及Viewport Transform之后成为屏幕上的坐标。在不同的文章中,Projection Matrix的定义会有差别,也是比较容易引起误导的地方。我们这里将Projection Matrix定义为将物体从View Space(Eye Space)转换到NDC Space的矩阵。
这样一个完整的Projection Matrix包括两个部分,
第一部分是“线性”的缩放,将原本的View Frustum到近裁面为$[-Z_{near}, Z_{near}]$,但深度只有1的“相似” View Frustum。在这个Clip Space中,任意一点的属性都可以由顶点属性基于x,y,z中的任意一个进行插值而得到。
$$
\left[
\begin{array}{cccc}
x_{clip}\\
y_{clip}\\
z_{clip}\\
1
\end{array}
\right ]
=
\left[
\begin{array}{cccc}
\frac{ {2n} }{ {r-l} } & 0 & \frac{ {r+l} }{ {r-l} } & 0 \\
0 & \frac{ {2n} }{ {t-b} } & \frac{ {t+b} }{ {t-b} } & 0 \\
0 & 0 & \frac{ {-(f+n)} }{ {f-n} } & \frac{ {-2fn} }{ {f-n} } \\
0 & 0 & -1 & 0 \\
\end{array}
\right ]
\left[
\begin{array}{cccc}
x_{view}\\
y_{view}\\
z_{view}\\
1
\end{array}
\right ]
$$
这一步发生在vert shader中

第二部分中,我们要将Clip Space通过Perspective Divide转换到NDC(Normalized Device Coordinates) Space。NDC空间是按近大远小压缩的。
NDC还有另外一个特点:该空间中如果要计算任意一点的属性值,直接根据Barycentric Interpolation来插值的话会因为深度的非均匀压缩而产生扭曲

解决方法是应用Perspective Correction进行矫正,若已知顶点P1, P2的属性,则线段上任意一点P的值为
$$p=z[\frac{p_1}{z_1}(1-t) + \frac{p_2}{z_2}t]$$
直观的理解是先将属性按照深度压缩,然后就可以线性的插值了。详细的推导过程见scratchapixel

Perspective Divide的矩阵表示
$$
\left[
\begin{array}{cccc}
x_{ndc}\\
y_{ndc}\\
z_{ndc}
\end{array}
\right ]
=
\left[
\begin{array}{cccc}
\frac{1}{-z_{view}}\\
\frac{1}{-z_{view}}\\
\frac{1}{-z_{view}}
\end{array}
\right ]^{\mathsf{T}}
\left[
\begin{array}{cccc}
x_{clip}\\
y_{clip}\\
z_{clip}
\end{array}
\right ]
$$
这一步在frag shader之前GPU自动进行

如果将clip space中的属性应用到屏幕上,会因为顶点的位置是压缩过的而产生变形。

这样有了两个部分后,一个完整的Projection Matrix就可以表示为
$$
[M_{Proj}] =
\left[
\begin{array}{cccc}
\frac{1}{-z_{view}}\\
\frac{1}{-z_{view}}\\
\frac{1}{-z_{view}}\\
N/A
\end{array}
\right ]^{\mathsf{T}}
\left[
\begin{array}{cccc}
\frac{ {2n} }{ {r-l} } & 0 & \frac{ {r+l} }{ {r-l} } & 0 \\
0 & \frac{ {2n} }{ {t-b} } & \frac{ {t+b} }{ {t-b} } & 0 \\
0 & 0 & \frac{ {-(f+n)} }{ {f-n} } & \frac{ {-2fn} }{ {f-n} } \\
0 & 0 & -1 & 0
\end{array}
\right ]
$$

如果直接使用frag shader的输入中的position(通过SV_POSITION这个语义绑定(semantics binding) )的xy分量来映射billboard的uv,我们可以得到一个屏幕空间下的映射,但是使用的其实就是类似于gl_FragCoord的屏幕坐标值,并不能适应我们后面的调整需要。另外补充一下,SV_POSITION虽然在vert shader还是Clip Space,但是在进入frag shader之前会进行Perspective Divide(在某些硬件下这部分的计算会交给frag shader,但是仍处于可编程部分之前,所以可以通俗的认为都发生在frag shader之前)从而SV_POSITION就进入了NDC也即就行了透视处理。
虽然Unity中DirectX和OpenGL或Vulkan对projection matrix的实现各不相同,但是他们的w分量都是$Z_{view}$(虽然Projection Matrix中$w = -Z_{view}$,但在前一步的View Matrix时已经取反了一次),所以除以这个w分量就能将坐标压缩到NDC完成透视变换。

屏幕空间下的值只能使用NDC来插值,Clip Space下坐标和View Space成“线性”关系,和World Space成affine关系。

虽然frag shader中的输入SV_POSITION的z值已经是NDC下的,但是因为我们后面会要计算相对参考点的位置,而参考点又都没有进行透视处理,所以为了方便起见,我们统一在Clip Space进行处理,最后一步的时候再转换到NDC空间。
而要获得像素点在Clip Space下的坐标,我们有两种方法,
一种是对已经进入到ndc的坐标逆向乘以w分量从而得到Clip Space坐标。不过因为SV_POSITION本身并不属于NDC,所以我们不能通过这种方法获取除z以外的其他Clip Space中的坐标

1
2
float depth = i.pos.z * i.pos.w;
return half4(depth, depth, depth, 1); // Ouput depth visualization

还有一种方法是先在vert shader中将Clip Space坐标保存成一个顶点属性,然后交由硬件插值后传入frag shader。这里的重要区别是,GPU不会对没有标记SV_POSITION的顶点属性进行Perspective Divide和ViewPort Transform,进行的是含透视矫正的插值。由于是含透视矫正的,所以对于插值的属性,它按在Clip Space的深度进行插值,也就是说它的坐标值准确的对应于Clip Space下该frag的坐标。

1
2
float depth = i.pos_clip.z;
return half4(depth, depth, depth, 1); // Ouput depth visualization

有意思的是,如果我们将Clip Space坐标除以了w之后就可以得到NDC空间下的屏幕坐标,可以用来当uv采样texture看看是不是如我们期望的一样。
核心代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct v2f {
float4 pos: SV_POSITION;
float2 uv: TEXCOORD0;
float4 pos_clip:TEXCOORD1;
};
v2f vert(appdata_all v) {
v2f o;
...
o.pos = UnityObjectToClipPos(v.vertex);
o.pos_clip = UnityObjectToClipPos(v.vertex);
return o;
}
half4 frag(v2f i) : COLOR {
return tex2D(_MainTex, frac(i.pos_clip.xy / i.pos_clip.w));
}

但是我们也会注意到一个问题,这是一个“真的”billboard,uv的中心也始终在屏幕(也就是摄像机)的中央,如果将摄像机进行平移,就会失去中心点。另外,随着摄像机的拉近和拉远也没有缩放效果。

让Billboard跟随物体

让我们先解决第一个问题,即让billboard跟着mesh走。方法是先计算出物体模型空间下的中心在Clip Space中的位置。由于已经有了当前frag的Clip Space坐标,我们就能算出当前frag距离物体中心的偏移量(注意此刻我们仍是在Clip Space中,后面还需转换到NDC中)。如果当前frag的Clip Space坐标和物体的中心一样,那么偏移量就为0,uv也对应的为0。在有更多偏移量的地方设置相应的uv,这样就实现了uv以物体中心而不是屏幕的中心为起点向周围发散。这会是一个很大的帮助,因为我们后面会要让每个billboard sprite紧紧附在对应的block上。

不过如果uv的起点是物体的中心,那贴图的左下角(UV为(0,0))也会是会和中心对齐,这看上去就会很奇怪。我们要做的是指定GetNdcUv计算出的uv变为(0.5,0.5),即在计算出的uv加上(0.5,0.5)的偏移(等价于将texture向(-0.5,-0.5)的方向移动)

1
2
3
4
5
6
7
8
9
10
11
float3 center_block = float3(0.5, 0.5, 0.5);
float3 offsetFromFloor_block = frac(1 + sign(i.pos_obj) * center_block);
// Origin we want the billboard to be in object space
float3 offset_obj = sign(i.pos_obj)
* d * (offsetFromFloor_block + floor(abs(i.pos_obj * 0.999) / d));
float4 uvOrigin_clip = UnityObjectToClipPos(float4(offset_obj.xyz, 1.0));
float2 uv_ndc = pos_clip.xy / pos_clip.w;
float2 uv_origin_ndc = uvOrigin_clip.xy / uvOrigin_clip.w;
// Caculate the uv offset from center in obj space
uv_ndc.xy -= uv_origin_ndc;
half4 screenTexture = tex2D(_MainTex, uv_ndc + center_block); // The compensate offset here is actually incorrect since we only consider one direction.

可以看到A1格已经到了左下角,没有完全对齐的原因是因为缩放比例还不正确。

我们也可以调节uvTiling让uv充满整个面(这个也可以自动算出,但是有时候美术的确可能需要手动调节sprite的大小),如果想让sprite的anchor中心不再是物体的中心,也可以自己加offset细调,这会在后面提到。

让Billboard带透视

至于没有随着距离远近缩放的问题,可以通过

1
uv_ndc *= uvOrigin_clip.w;

来解决,这是因为uv_ndc是在ndc下的offset,只有乘以了z才能变成Clip Space下的距离,实现uv“近小远大”,texture对应的近大远小。另外注意这里对于一个block,统一使用了中心点的w分量来做透视,这样可以保证整个面上所有的像素的深度都是一致的。虽然也可以通过与frag的w混合来实现一定程度的透视效果,但是这里就不展开了。

Tiled Sprite Layer

下一步就是实现 tilied sprite layer。
我们可以首先计算出每个block的边长

1
float d = _BoundarySize/ _Tiling;

同时将原始的object position也作为顶点属性传递到frag shader之后,可以计算出这属于第几个block,然后再加上半格的偏移就可以得到每个block中心的位置,要记得带上sign(i.originPos),否则方向会是错的。

1
2
3
float d = _Length / _PosTiling;
// Origin we want the billboard to be in object space
float3 offset_obj = sign(i.pos_obj) * d * (floor(abs(i.pos_obj*0.999) / d) + float3(0.5, 0.5, 0.5));

再将新的offset传入后,就能看到tilied的sprite了


目前物体的旋转中心还是在物体的中央(每个block的(0.5,0.5,0.5)处)。我们要调整的是offset,因为它是我们认为的参考点(anchor),所以还要做一步偏移操作。

1
2
3
4
5
6
7
// Move the anchor to feet
float3 center_block = float3(0.5, 0.5, 0.5) + float3(0, -0.3, 0);
float3 offsetFromFloor_block = frac(1 + sign(i.pos_obj) * center_block);
float3 offset_obj = sign(i.pos_obj)
* d * (offsetFromFloor_block + floor(abs(i.pos_obj * 0.999) / d));
float3 intrudeDir_obj = normalize(offset_obj - cameraPos_obj);
float2 uv_ndc = GetNdcUv(clipPos, _UvTiling, offset_obj.xyz - _Offset.w * intrudeDir_obj));

为了验证我们的计算没有错误,我们可以比较虚拟billboard是否和真实的billboard有一样的显示。我在场景里放了一块传统的billboard,放置在和虚拟billboard同样的anchor点,然后旋转物体进行观察。结果可以看到两者完全重合在一起,证明了算法的准确性。


正确显示边角的遮挡

最后一步是,如果墙面正好在房间的外部一些,即我们的mesh之外一点。虽然表面上采样的点属于内腔,从正面看是不会看到遮挡的墙,但是从另一个角度看,则会看到本该被剔除的墙面。一般发生在墙面的转角处。这一点甚至在PS4 Spider-Man中都没有处理

完整视频参考:

不过要想解决这个问题并不复杂。我们只用计算出最外层的坐标阈值,超出部分一律不绘制即可。

1
2
3
4
5
6
7
// Test if wall's z is out of box boundary
float zIsInsideBoundary = step(abs(cameraPos_obj + ratio.z * cameraToPixelOffset_obj).z / (_BoundarySize * 0.5), 0.999);
// Test if wall's x is out of box boundary
float xIsInsideBoundary = step(abs(cameraPos_obj + ratio.x * cameraToPixelOffset_obj).x / (_BoundarySize * 0.5), 0.999);
...
float4 wallXYCol = tex2D(_WallXYTexture, intersectionXY_obj) * zIsInsideBoundary;
float4 wallZYCol = tex2D(_WallZYTexture, intersectionZY_obj) * xIsInsideBoundary;

后面的改进

1.通过事先bake好光照,使室内的表现更加真实。计算真实的光照也是有可能的,只不过可能需要更多的操作,需要权衡一下是否真的需要。
2.处理好不同方向的偏移补偿,目前的uv offset补偿其实是有问题的,没有考虑顶部和底部的情况。(不过如果大楼是封顶的倒也不太要紧)
3.本文使用的是分开的墙面texture方法,也有些实现使用的是Cubemap。

引用

[1] Model View Projection
[2] OpenGL Projection Matrix
[3] Interior Mapping - A new technique for rendering realistic buildings
[4] The Visibility Problem, the Depth Buffer Algorithm and Depth Interpolation
[5] Test Character Sprite
[6] Unity Products:Fake Interiors/Manual
[7] Interior Mapping: rendering real rooms without geometry
[8] Unity Forum: Interior Mapping
[9] Interior Mapping - A new technique for rendering realistic buildings