俗话说条条大路通罗马,效果的实现也不一定有唯一的答案,今天就来分享下我想到的几种《笼中窥梦》游戏中 portal 效果的实现方法,并附带我在 Godot 引擎中的实现。
《笼中窥梦》(Moncage)是一款由两人工作室 Optillusion 开发、高度纯粹的视错觉解谜游戏。它在 Steam 平台 上获得了“特别好评”,并因其独特的创意被视为《画中世界》(Gorogoa)后的又一艺术精品。 右图为笔者模仿的 portal 效果。
在标准的透视投影中,摄像机就像人的眼睛,正对着屏幕中心,其视锥体(由六个平面包围的可见区域)是对称的。
非对称就是让摄像机不再位于视锥体的中心轴线上(右一),在这种投影中,依然是位于近远平面间的物体被投影在近平面上,只是近平面不在摄影机朝向的中心处。为什么这种方式可以实现效果呢?其实使用这种方式“恰好”从光路上模拟了这个效果在现实世界中的样子。
想象你站在一栋房子前,房子有一扇窗户,那么你透过窗子能看到哪些东西呢?
没错!相信你也发现了,你能看到的部分就相当于以窗户为近平面,房子的对侧为远平面,摄像机垂直于窗户所在的墙壁,把房子内的物体投影在窗户上。有很多街头视错觉艺术也是类似的原理,只要你看到的角度和投影的角度重合,看起来就像是真存在一个物体。
而在我们主相机的运动中,实时计算“窗户”投影的的摄像机的位置,将绘制出的窗内画面贴在对应的 quad 或者 Box 上,就可以得到逼真的 portal 效果。
非对称视锥体投影依然是将一个平截头体转换到标准设备坐标系(NDC),只不过是任意形状的平截头体(Frustum)。设定视锥体在近裁剪面(z=−n)处的六个边界参数:
l,r: 近平面在 x 轴的左、右边界;
b,t: 近平面在 y 轴的下、上边界;
n,f: 相机到近、远平面的距离。
关于为什么齐次坐标映射要除以 -z,涉及到使用四维的线性变换来模拟透视除法的问题;为什么只能由常数和 z 组成分子,也涉及到 z 分量运算时不应该由 x、y 影响 z 计算的问题;与本文无关,有机会再讲 orz
根据变换后的向量,一顿运算,最后可以得到投影矩阵P为:
该矩阵与普通透视矩阵唯一的区别在于第三列的前两个分量。当 r=−l 且 t=−b 时(对称情况),第三列前两项变为 0,矩阵退化为普通的透视矩阵。(累死我了……终于敲完了这一大堆……机核编辑器不支持公式,只能贴图了)
在 Godot 中,其实没这么麻烦,已经有这种相机了,可以通过设置 projection 为 PROJECTION_FRUSTUM 并控制 frustum_offset 来实现。
# 计算相机到 Quad 平面的距离 (Z 轴)
var d = abs(local_eye.z)
if d < 0.01: d = 0.01 # 防止除以 0
# 设置相机的近平面为到平面的距离
near = d
# 设置远平面为深度 10 (相对于近平面)
far = d + 10.0
# 计算非对称平截头体的边界
# 因为 near = d,所以 scale = 1
var scale = 1.0
var left = (-width / 2.0 - local_eye.x) * scale
var right = (width / 2.0 - local_eye.x) * scale
var bottom = (-height / 2.0 - local_eye.y) * scale
var top = (height / 2.0 - local_eye.y) * scale
# 应用到相机属性
projection = PROJECTION_FRUSTUM
frustum_offset = Vector2((left + right) / 2.0, (top + bottom) / 2.0)
size = top - bottom
# 将相机的旋转设置为与 Quad 一致,这样相机的视平面就与 Quad 平行
global_transform.basis = quad_tf.basis
shader就特别简单了,直接贴就行,另外别忘了这个相机的世界位置和普通相机要保持一致,这样投影才能和主相机这双“眼睛”对齐。
不过这种做法也有缺陷,贴在 quad 表面的分辨率是由单独的 Viewport 控制的,如下图所示,当 SubViewport 分辨率不高时,Quad 表面的贴图会比较模糊。不过这也不全是坏处,远处的物体可以采用分辨率较低的 RenderTexture, 不必和方法二一样多出一个屏幕分辨率的 Texture,降低 GPU 和内存压力。
而且这张图可以很方便地被传递给一些 2D 渲染管线,例如和网页内容融合,实现类似 Apple Vision Pro 中把 3D 物体内嵌的效果,同时可以支持网页内容和 3D canvas 的合成(CSS filter 等效果)。
这种方法的核心思想非常直观:想象我们在“里世界”放置了一个投影仪(也就是摄像机),它把拍摄到的画面投射到了“表世界”的窗口上。
我们在渲染窗口的每一个像素时,都去反问一下里世界的摄像机:“在你的视角里,这个位置对应的是哪个点?”然后直接把那个点的颜色拿过来涂上。
这就不需要窗口是什么规则形状,也不需要摄像机有什么特殊的变形,只要我们能把表世界的坐标转换到里世界摄像机的屏幕坐标系下,一切就搞定了。这被称为“投影纹理映射”。
关键在于如何正确地采样这张纹理。我们不能使用几何体自带的 UV,而是需要根据当前像素在“里世界”摄像机视角下的屏幕坐标(Screen Space / NDC)来计算 UV。
uniform mat4 target_view_proj; // 里世界相机的 VP 矩阵
void vertex() {
// 计算 Clip Space 坐标
v_clip_pos = target_view_proj * world_pos;
}
void fragment() {
// 透视除法
vec3 ndc = v_clip_pos.xyz / v_clip_pos.w;
// 检查是否在视野内
if (v_clip_pos.w < 0 || abs(ndc.x) > 1 || abs(ndc.y) > 1) {
return float4(0, 0, 0, 1); // 裁剪掉视野外部分
}
// 采样
vec2 uv = ndc.xy * 0.5 + 0.5;
color = texture(input_texture, uv);
}
# 获取相机的 View Matrix (World -> Camera)
var cam_transform := target_camera.global_transform.inverse()
var cam_view_proj := Projection(cam_transform)
# 获取相机的 Projection Matrix
var cam_projection := target_camera.get_camera_projection()
# 组合 View-Projection Matrix
var view_proj := cam_projection * cam_view_proj
# 传递给 Shader
# Projection 类型可以直接作为 mat4 传递给 shader parameter
mat.set_shader_parameter("target_view_proj", view_proj)
在 Shader 中执行投影计算(和上面的伪代码基本一样,就是多一些 Godot 坐标的细节)。
这种方法非常直观好懂,而且可以适用于各种形状的物体,不一定是 Quad,这点就比方法一灵活得多,同时可以保证输出的清晰度 一定和主窗口分辨率一致。只是性能上确实差一些,因为要独立渲染一遍整个场景。
这种方法当然也可以实现 3D 物体内嵌的效果,只是把里世界的屏幕纹理做透视矫正再贴回到网页中是比较费力的事情,2D 渲染引擎不一定可以支持这种矫正,如果支持(比如skia)或者自己实现也能做到,就是相比第一种方法没那么直观了。
// skia 中的透视矫正
void draw(SkCanvas* canvas, sk_sp<SkImage> image) {
// quad 四角在纹理中的坐标,需要预先使用 MVP 矩阵计算投影后的 NDC,再乘纹理宽高得到
SkPoint srcPoints[] = {{383, 824}, {1184, 1006}, {1179, 1588}, {402, 1857}};
// 还原的矩形
float targetW = 400.0f;
float targetH = 300.0f;
SkPoint dstPoints[] = {{0, 0},{targetW, 0},{targetW, targetH},{0, targetH}};
// 计算变换矩阵这个矩阵会把 srcPoints 围成的区域“拉伸且矫正”到 dstPoints
SkMatrix matrix;
if (!matrix.setPolyToPoly(srcPoints, dstPoints, 4)) {
return;
}
// 应用矫正矩阵
canvas->concat(matrix);
// 绘制并矫正纹理
canvas->drawImage(image, 0, 0, nullptr, nullptr);
}
方法三:模版缓冲 (Stencil Buffer)
这种方法也是《笼中窥梦》制作组选择的实现方式。如果说前两种方式是在平面上画出另一个世界,那么模版缓冲就像是在空间中挖了一个洞。
它的原理利用了 GPU 的模版测试(Stencil Test)功能。想象你有一张带孔的遮罩纸(模版),你先把这张纸盖在屏幕上,把窗口的位置挖空(写入模版值)。然后,当你绘制里世界的物体时,GPU 会只允许画在挖空的地方。
这样,里世界的物体就真的“嵌”在了表世界的窗口里,而且因为是直接渲染几何体而不是纹理,清晰度极高,还没有 RenderTexture 的分辨率限制。这种方式的优点是性能极高(没有 Render Texture 的开销),且天然支持递归(镜中镜)。
// Pass 1: 绘制窗口蒙版(挖洞)
Stencil {
Ref 1 // 设置模版值为 1
Comp Always // 总是通过测试
Pass Replace // 写入模版值
}
Draw(WindowMesh);
// Pass 2: 绘制里世界(填洞)
Stencil {
Ref 1
Comp Equal // 只有模版值为 1 的地方才绘制
}
Clear(DepthBuffer); // 清空深度,防止被表世界遮挡
Draw(InnerWorldObjects);
这种暂时就没办法在 Godot 中实现了,我用的是 Godot 4.4,Godot Shader 和材质系统没有暴露完整的 Stencil Op,在 Unity 中倒是很好实现,也有大把教程。
这种方法是直接把内容绘制到同一个目标上,就没办法做 2D 相关的处理了,不过对于传统的传送门类型游戏,这是一种非常好用且非常常见的实现方法(也是教程最多的hhh)。
当然笼中窥梦这个游戏中还有很多有趣效果的细节,比如如何判断两个场景物体的吻合、怎么在资产制作的过程中保证近大远小的物体可以顺利接在一起、对接后生成的物体如何顺利自然地呈现出动画。
本文只是对 portal 这类效果的一个小实践,让游戏真正好玩的永远是游戏设计师们在某个小巧思后脚踏实地衍生出的繁华世界,也再次推荐《笼中窥梦》这部优秀的国产独立游戏,感受视错觉与解密的独特魅力~
《笼中窥梦》制作人 monokura 对游戏设计思路的介绍 https://www.zhihu.com/question/499287700/answer/2230626908
评论区
共 6 条评论热门最新