乐府互娱技术分享!Cocos Creator 用 2D 相机实现完美 3D 翻转效果
来自乐府互娱的技术分享。通过两种方案的尝试,最终找到了 2D 相机实现 3D 翻转的完美方法!
目标效果
如何实现上图的效果?大家是不是首先想到:用个 3D 相机,设置 3D 节点去旋转 rotationY,然后再移动这个节点?
我的第一反应也是如此。但是这样一来就得新增一个 3D 相机,并且需要增加一个分组,使这张图只被 3D 相机渲染不被 2D 相机渲染,同时还要额外管理这个 3D 相机。
那么,能不能用 2D 相机来实现这个效果呢?答案是当然是:能!
尝试一:
模拟 3D 旋转运动
既然要模拟 3D 旋转运动,我们首先要知道它到底是怎么运动的,它的运动在 3D 相机与 2D 相机下又有什么区别?
3D 与 2D 相机的区别也就是投影矩阵的区别,即透视投影与正交投影的区别,也就是有没有近大远小的效果。我们可以将正交投影理解为相机无限远的透视投影,远到可以忽视屏幕与物体间的距离。反之,想要模拟透视投影就需要假设一个与屏幕有一定距离的相机。
图形绕 Y 轴形变的过程
首先我们假设在图形的本地空间坐标系内:原点在图片的中心点,假设某顶点的坐标为(x , y , z),Z 轴的正方向朝屏幕内侧。
图形围绕 X 轴旋转了角度 A,此时该顶点坐标变为了(xcosA,y,xsinA)。
需要求绿点(x2, y2, 0)的坐标。
其中,n 为相机到屏幕的距离,z 为改变后顶点的 Z 坐标。
上图中,黑点为旋转前的点,红点是旋转了角度 A 后得到的点,绿点是旋转后的点与相机连线与屏幕的交点。
根据相似三角形公式,我们很容易推导出:
其中(x,y)为变换前的坐标,A 为变换角度,n 为假设的投影点到屏幕的距离。
我们已经知道了旋转角度与变换前后坐标的公式,那么该如何修改图片的顶点坐标呢?
没错,我们很容易想到自定义渲染。
自定义渲染
自定义渲染可以实现各种各样的效果,目前常用的方法有两种方法:
创建自定义 Assembler,在顶点数据输入渲染管线前修改它的值 。
创建自定义材质,给材质增加参数。这个参数会作为 uniform 变量传入 shader。
本次我们采用的是第一种方法:创建自定义 Assembler。
Assembler 是指处理渲染组件顶点数据的一系列方法。不同渲染组件可能有不同的顶点数据、顶点数量、填充规则,也会使用不同的 Assembler。我们目前使用的 Cocos Creator 的 2D 渲染中,Assember2D 类是一个重要的基础类。
最常用的 cc.Sprite 的各种模式(simple,sliced,tiled等)在内部都对应了不同的 Assembler 派生类。同样是一个四边形的节点,不同的 Assembler 可以将其转化成不同数量的顶点实现不同的渲染效果。
接着对底层 Assembler 进行分析。我们图片使用的是 simple 模式,所以我们查看 simple 脚本,发现 simple 脚本就三个函数:
engine/cocos2d/core/renderer/webgl/assemblers/sprite/2d/simple.js
updateRenderData:调用更新渲染数据函数(UV、顶点), 标脏
updateUVs:更新 UV 数据
updateVerts:更新顶点数据
而 updateVerts 函数中只是计算了一些图片本地数据(计算出纹理中,上下左右四个边距离锚点的距离),然后调用了父类 Assembler2D 的 updateWorldVerts 函数将把节点的本地坐标转换为世界坐标保存到 verts(顶点数据)中。
engine/cocos2d/core/renderer/assembler-2d.js
所以,只要重写 Assembler2D 的 updateWorldVerts 以使用计算出的顶点数据就可以改变顶点数据了。
最终效果与问题分析
获取到图片节点 cc.Sprite 的 assembler;将重新计算后的坐标节点(内部)替换原来的顶点坐标;重写 assembler-2d 中 updateWorldVerts 方法以使用计算后的坐标;最后将节点的渲染标记标脏,以重新刷新节点的渲染。
//伪代码
//获取到图片节点cc.Sprite的assembler
let assembler = this.node.getComponent(cc.Sprite)._assembler
//重写assembler中updateWorldVerts方法以使用计算后的坐标
assembler.updateWorldVerts = function(comp) {
let points = self.points //使用自己改变角度后计算的内部顶点坐标
let verts = this._renderData.vDatas[0] //顶点数据
let matrix = comp.node._worldMatrix;
let matm = matrix.m //4*4的变换矩阵
let a = matm[0], b = matm[1], c = matm[4], d = matm[5],
tx = matm[12], ty = matm[13];
//矩阵乘法:
//x' = ax + cy + tx
//y' = bx + dy + ty
// left bottom
verts[0] = a*points[0] + c*points[1] + tx
verts[1] = b*points[0] + d*points[1] + ty
// right bottom
verts[5] = a*points[2] + c*points[3] + tx
verts[6] = b*points[2] + d*points[3] + ty
// left top
verts[10] = a*points[4] + c*points[5] + tx
verts[11] = b*points[4] + d*points[5] + ty
// right top
verts[15] = a*points[6] + c*points[7] + tx
verts[16] = b*points[6] + d*points[7] + ty
}
//将节点的渲染标记标脏,以重新刷新节点的渲染
this.node.getComponent(cc.Sprite)._vertsDirty = true
this.node._renderFlag |= 1 << cc.RenderFlow.FLAG_LOCAL_TRANSFORM
最终效果如下:
看上去是没问题的,但是慢放时图片似乎有一些变形。我们用九宫格图做对照,果然,移动过程中是变形的,图片沿着对角线发生扭曲了:
为什么会扭曲呢?这是因为我们将 2D 的点的位置修改成 3D 顶点的位置数据,让图形扭曲成我们要的样式,但是顶点的 UV 坐标仍是 2D 的,是没有深度的。这里的问题在于我们进行了错误的 UV 坐标插值。3D 的 UV 插值不一定是线性的,除非顶点的 Z 坐标都是一样的才行。
因此这个方法并不可取。我们重新回过头来思考实现方案。为什么 2D 相机与 3D 相机的显示不同呢?在之前的尝试中我们了解到是因为投影不同,那么是不是修改相机投影就可以了呢?
尝试二:
修改相机投影
想要修改相机投影,就要知道投影是怎么影响节点的渲染的,所以我们对图片默认纹理进行了分析。
默认纹理分析
Resources/static/default-assets/resources/effects/builtin-2d-sprite.effect
(仅分析到顶点着色器)
CCProgram vs %{
precision highp float;//定义浮点型的精度为高精度
//引入cocos内置的shader变量--engine/cocos2d/renderer/build/chunks
#include
#include
in vec3 a_position;//顶点坐标
in vec4 a_color;//顶点颜色
out vec4 v_color;//顶点shader片段会输出的颜色值
//是否使用贴图(使用了贴图会输入与输出纹理坐标)
#if USE_TEXTURE
in vec2 a_uv0;
out vec2 v_uv0;
#endif
void main () {
vec4 pos = vec4(a_position, 1);
//是否没有使用图像模版作为遮罩
#if CC_USE_MODEL
pos = cc_matViewProj * cc_matWorld * pos;
#else
pos = cc_matViewProj * pos;
#endif
#if USE_TEXTURE
v_uv0 = a_uv0;
#endif
v_color = a_color;
gl_Position = pos;
}
}%
查看源码顶点着色器代码片段,我们发现顶点坐标与 cc_matViewProj 与 cc_matWorld 有着莫大的联系,那么图片 Shader 中 cc_matViewProj 和 cc_matWorld 具体是什么意思呢?
通过查看官方的[内置 shader 变量]文档,我们发现,cc_matWorld 是将顶点的坐标转换为世界坐标的变换矩阵,而 cc_matViewProj 则是模型坐标到透视的变换矩阵。那么想要修改相机投影,最后肯定是修改 cc_matViewProj 的值了。
知道了要修改的值,但是这个值是怎么来的呢?
这里不得不称赞一下,这就是开源的引擎的好处了——查源码!
cc_matViewProj =>
view._matViewProj =>
camera.extractView(view, width, height) =>
Mat4.copy(out._matViewProj, _matViewProj)=>
this._calcMatrices(width, height)
我们不断从一个参数到另一个参数、从一个函数到另一个函数,终于发现相机根据是否是透视投影有不同的矩阵计算,最终得到了我们要的 _matViewProj 也就是视图投影矩阵。现在就来了解一下相机的透视矩阵是如何计算的。
相机脚本分析
engine/cocos2d/renderer/scene/camera.js
_calcMatrices (width, height) {
//视图矩阵(由于该节点是相机节点,所以相机节点的世界矩阵的逆矩阵就是视口矩阵)
this._node.getWorldRT(_matViewInv);
Mat4.invert(_matView, _matViewInv);
//透视矩阵
let aspect = width / height;
if (this._projection === enums.PROJ_PERSPECTIVE) { //透视投影
Mat4.perspective(_matProj,
this._fov, //纵向视角大小(弧度值)
aspect, //(长宽比)
this._near, //近平面距离
this._far //远平面距离
);
} else { //正交投影
let x = this._orthoHeight * aspect;
let y = this._orthoHeight;
Mat4.ortho(_matProj,
-x, x, -y, y, this._near, this._far
);
}
// _matViewProj = 视图投影矩阵(视图矩阵*投影矩阵)
Mat4.mul(_matViewProj, _matProj, _matView);
// _matInvViewProj = 视图投影矩阵的逆矩阵
Mat4.invert(_matInvViewProj, _matViewProj);
}
所以只要将目标图片的 cc_matViewProj 改为透视投影的 vp 值(透视矩阵*视图矩阵并将其转化为数组)就可以用 2D 相机实现 3D 投影的效果了。
知道了投影矩阵怎么修改、以及修改的矩阵应该写入哪里,那么现在我们应该怎么将自定义的参数传入 shader 中呢?答案就是:自定义材质!
自定义材质
在资源管理器右键新建一个 Effect,并新建一个 Material 引用对应的 effect(Material 和 effect 的关系解释可查看官方[材质资源]文档)。
我们只需要在新建的 Effect 文件的 properties 中声明一个类型为16位数组的变量(比如 mat_vp),然后获取图片的材质并将我们计算好的视图投影矩阵转为数组值传入自定义的材质中 material.setProperty("mat_vp", arr) 就可以了。
操作步骤与最终效果
//伪代码
//在effect脚本中新建"mat_vp"参数用以存储计算的透视矩阵
properties:
mat_vp: {value:[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0]}
texture: { value: white }
//伪代码
//在顶点着色器中使用我们自定义的参数
CCProgram vs %{
void main () {
#if CC_USE_MODEL
pos = mat_vp * cc_matWorld * pos;
#else
pos = mat_vp * pos;
#endif
gl_Position = pos;
}
}%
export class PerspectiveCamera {
public static setVPMatToNode(node:cc.Node, cameraNode: cc.Node) {
//计算设备的宽度/高度
let aspect = cc.view._viewportRect.width / cc.view._viewportRect.height
//得到视图矩阵matView
let matView:any = cc.mat4()
let matViewInv:any = cc.mat4()
//获取摄像机的视图矩阵
cameraNode.getWorldRT(matViewInv)
//矩阵求逆
cc.Mat4.invert(matView, matViewInv)
//得到透视矩阵
let matP:any = cc.mat4()
let fovy = Math.PI / 3
//计算透视投影矩阵
cc.Mat4.perspective(matP, fovy, aspect, 0.5, 1500)
//VP = 透视矩阵*视图矩阵
let matVP = cc.mat4()
cc.Mat4.mul(matVP, matP, matView);
let arr = []
//矩阵转数组
cc.Mat4.toArray(arr, matVP)
}
}
//获取图片纹理
let material = node.getComponent(cc.Sprite).getMaterial(0)
//将计算好的值传给自定义的"mat_vp"参数(arr为透视矩阵*视图矩阵并转位数组)
material.setProperty("mat_vp", arr)
//最后改变节点的rotationY值就可以了
this.node.rotationY = 想要旋转的角度****
最终效果如下:
完美!
扩展链接:
[内置 shader 变量]-Cocos Creator
https://docs.cocos.com/creator3d/manual/zh/material-system/builtin-shader-variables.html
[材质资源]-Cocos Creator
https://docs.cocos.com/creator/manual/zh/asset/material.html?h=effect)
本文首发于「乐府札记」公众号,乐府团队会在此分享他们的前端、后端及平台相关技术经验和创新成果,欢迎关注、交流!
往期精彩