可视化智能相机系统来了!插件 Cinestation 今日上线,全免费!
引言:
一个「智能相机系统」能为游戏开发者节省大量的时间和精力。杭州每日给力的游戏开发主程陈炫烨将自己团队的项目中使用的相机系统做成了插件 Cinestation,并免费提供给开发者。
Cinestation 是一个支持 Cocos Creator 3.3.x 上的可视化智能相机系统,它具备智能追踪、优先级控制、轨道移动、噪声控制、时间轴动画等等功能,支持配置任意数量的镜头,完成复杂的相机混合和运动效果。至此,Cocos 在相机操作上的短板也被补齐。
PART 1
写在前面
首先说说我为什么要为 Cocos 开发这一套系统呢?
起因是我有幸参与了11月初的杭州「Cocos Star Meetings」,分享了我们团队一款原生 3D RPG 游戏的开发历程。事后 C 姐找到我,希望我能把内容整理成文分享给更多的开发者(为了给各位开发者谋福利你知道 C 姐有多努力吗!)。于是呢,我决定把项目中在用的相机系统拿出来与大家分享,但写着写着觉得,光写文貌似不能非常实际地帮助到大家,干脆直接做成插件好了!
相信开发者们对「相机系统」并不陌生。Cinestation v1.0.0 实现了绝大部分关键功能,足以覆盖大部分游戏开发需求。有些功能我自己都还没在实际项目中用上,也希望各位开发者可以多多探索,和我一起完善这个插件。
随着时间推移,Cinestation 会越来越强大,我也会持续记录分享 Cinestation 的开发历程,感兴趣的小伙伴可以持续关注。
PART 2
开发过程
本来以为,我都已经在项目中用了这么久的相机系统,改成插件那还不是手到擒来的事,于是和 C 姐约定「两周交货」。万万没想到,我太大意了,做下去之后发现自己连检测个节点选中都不会。不过呢,好在各位大佬的微信我都有,于是我问了@jare@热心网友蒋先生@插件小王子以及我同事@聪明的可达鸭……为啥找了这么多人?主要是大家的方法都不太一样,我都试了一下,最后终于找到了一个简单的方法,干货来了请做笔记:
//main.ts
function selectNode() {
Editor.Message.request("scene", "execute-scene-script", {
name: "cinestation",
method: "selectNode",
args: Editor.Selection.getSelected("node")
})
}
export const load = function () {
Editor.Message.addBroadcastListener("selection:select", selectNode);
};
export const unload = function () {
Editor.Message.removeBroadcastListener("selection:select", selectNode);
};
插件加载的时候,注册下 selection:select 广播,然后在回调中调用 Editor.Selection.getSelected("node") 就可以拾取到选中的节点了。
编辑器可视化
在解决了插件的基本问题后,我又陷入另一个麻烦:Cocos 并没有开放编辑器中的绘制函数,要如何让相机和轨道在编辑器中实现可视化呢?
这里有两个问题:
如何让编辑器运行你的绘图代码?
相机的视锥体和轨道绘制函数怎么写?
问题一很好解决。Cocos 有个叫 executeInEditMode 的装饰器,装饰下组件就可以运行了。
问题二,绘制视锥体能难倒我?原理其实很简单,先定义视锥体的8个裁剪坐标,再与投影矩阵做个逆变换,8个顶点咱们就算好了,最后生成一个 model,提交给场景就可以显示了。核心代码如下:
let corners = [
new Vec3(-1, -1, -1),
new Vec3(1, -1, -1),
new Vec3(-1, 1, -1),
new Vec3(1, 1, -1),
new Vec3(-1, -1, 1),
new Vec3(1, -1, 1),
new Vec3(-1, 1, 1),
new Vec3(1, 1, 1),
];
let lens = this.lens;
let size = view.getDesignResolutionSize();
let matProjInv = Mat4.perspective(new Mat4(), toRadian(lens.fov), size.width / size.height, lens.near, lens.far).invert();
for (let i = 0; i < 8; i++) {
corners[i].transformMat4(matProjInv);
}
let positions: number[] = [], colors: number[] = [];
for (let i = 0; i < 8; i++) {
for (let j = 0; j < 3; j++) {
Vec3.toArray(positions, corners[i], positions.length);
Vec3.toArray(positions, corners[i ^ (1 << j)], positions.length);
colors.push(1, 1, 1, 1);
colors.push(1, 1, 1, 1);
}
}
return utils.createMesh({ positions, colors }, mesh);
相机轨道的绘制要麻烦一点,因为绘制的是曲线所以得做一些采样,然后再连线,最后也是生成一个 model 提交给场景。核心代码如下:
let vertices: Vec3[] = [];
let step = 1 / 10;
let children = this.node.children;
for (let t = 0; t < children.length; t += step) {
vertices.push(this.evaluateLocalPosition(new Vec3(), t));
}
let positions: number[] = [], colors: number[] = [];
let point0s = [], point1s = [];
let greenColor = new Color(0, 1, 0, 1);
let grayColor = new Color(0.3, 0.3, 0.3, 1);
function linkPoints(p0: Vec3, p1: Vec3, col: Color) {
if (!(p0 && p1)) return;
Vec3.toArray(positions, p0, positions.length);
Vec3.toArray(positions, p1, positions.length);
colors.push(col.r, col.g, col.b, col.a);
colors.push(col.r, col.g, col.b, col.a);
}
for (let i = 0; i + 1 < vertices.length; i++) {
let p0 = vertices[i];
let p1 = vertices[i + 1];
let dir = new Vec3(p1).subtract(p0).normalize();
let offset = new Vec3(dir.z, dir.y, -dir.x).multiplyScalar(0.1);
if (i === 0) {
point0s.push(new Vec3(p0).subtract(offset));
point1s.push(new Vec3(p0).add(offset));
}
point0s.push(new Vec3(p1).subtract(offset));
point1s.push(new Vec3(p1).add(offset));
linkPoints(new Vec3(p0).subtract(offset), new Vec3(p0).add(offset), greenColor);
linkPoints(p0, p1, grayColor);
}
if (this._looped) {
linkPoints(vertices[vertices.length - 1], vertices[0], grayColor);
}
for (let i = 0; i + 1 < point0s.length; i++) {
linkPoints(point0s[i], point0s[i + 1], greenColor);
}
if (this._looped) {
linkPoints(point0s[point0s.length - 1], point0s[0], greenColor);
}
for (let i = 0; i + 1 < point1s.length; i++) {
linkPoints(point1s[i], point1s[i + 1], greenColor);
}
if (this._looped) {
linkPoints(point1s[point1s.length - 1], point1s[0], greenColor);
}
return utils.createMesh({ positions, colors }, mesh);
在 Cinestation 的 debug 模式下你会看到一个全屏的镜头区域配置的显示,这个是如何做到的呢?
你们是不是觉得我是画在 UI 上的?不不不,一方面纯 UI 实现不了我这个视图功能,另一方面我不希望使用插件的时候还需要动态创建 canvas 节点,这样会显得很累赘。
所以还是 model 大法好呀,我在 Cinestation Brain 的组件里创建了 quad model,并且在 effect 的顶点着色器中强制让 quad 全屏显示,然后在片段着色器中利用 sdf 实现了这么一个效果。核心代码如下:
vec4 frag () {
vec2 uv = gl_FragCoord.xy * cc_screenSize.zw;
float dx = abs(uv.x - 0.5) * 2.;
float dy = abs(uv.y - 0.5) * 2.;
float dpx = abs(uv.x - lookatPoint.x) * 2.;
float dpy = abs(uv.y - lookatPoint.y) * 2.;
float softZoneWidth = max(u_deadZoneWidth, u_softZoneWidth);
float softZoneHeight = max(u_deadZoneHeight, u_softZoneHeight);
float deadZone = sdf_intersect(step(u_deadZoneWidth, dx), step(u_deadZoneHeight, dy));
float softZone = sdf_intersect(step(softZoneWidth, dx), step(softZoneHeight, dy));
float deadZoneLine0 = sdf_intersect(1. - step(u_deadZoneWidth, dx), step(u_deadZoneWidth + 7. * cc_screenSize.z, dx));
float deadZoneLine1 = sdf_intersect(1. - step(u_deadZoneHeight, dy), step(u_deadZoneHeight + 7. * cc_screenSize.w, dy));
float softZoneLine0 = sdf_intersect(1. - step(softZoneWidth, dx), step(softZoneWidth + 7. * cc_screenSize.z, dx));
float softZoneLine1 = sdf_intersect(1. - step(softZoneHeight, dy), step(softZoneHeight + 7. * cc_screenSize.w, dy));
vec4 color = vec4(1,0,0,0.15);
color = mix(vec4(0,0.7,1,0.15), color, softZone);
color = mix(vec4(0), color, deadZone);
color = mix(vec4(0,0.7,1,0.3), color, sdf_union(deadZoneLine0, deadZoneLine1));
color = mix(vec4(1,0,0,0.3), color, sdf_union(softZoneLine0, softZoneLine1));
color = mix(vec4(1,1,0,1), color, sdf_intersect(step(10. * cc_screenSize.z, dpx), step(10. * cc_screenSize.z, dpy * cc_screenSize.y/cc_screenSize.x)));
return color;
区域配置的显示虽然好了,现在带来了一个新问题:插件没在 resources 下,材质没法动态加载,得靠用户拖到组件上。这多麻烦,必须给解决了!
查遍引擎源码,我注意到 Cocos 内置的 effect 其实都是写死在 ts 代码中的,所以官方内置的材质是可以在引擎中随时动态创建的,那么我把我的 effect 也写死在代码中不就好了吗?
但是有一个问题是,写死在代码中的 effect 需要提供多个版本的代码(gl1、gl2、gl3),我要写3个版本的 effect 那不是要狗。思索再三,我突然想到一个简单的办法:是不是可以在直接运行的时候将 effect 对象用 json 序列化一下,再写死到代码里?果不其然,一切顺利,我们可以动态生成材质了。
节点对齐(alignWithView)
Cocos 编辑器有个功能叫 AlignWithView,在 Node->AlignWithView 下。使用这个功能可以快速移动节点的位置和方向到你当前视野所处的位置。在创建虚拟相机的时候,我希望虚拟相机的位置能与当前视野对齐,而不用手动调用 AlignWithView。
由于我对编辑器事件消息的那套比较陌生,另外编辑器使用 AlignWithView 的时候是针对当前选中的节点的,我这是在新建节点上哪选中?所以我也就没打算通过操作编辑器的事件消息来处理。
其实想实现这个效果,并不需要编辑器,我们只要能拿到 EditorCamera,再将它的坐标位置和旋转方向赋值给我们的节点就可以了。EditorCamera 就是我们当前编辑器渲染场景使用的相机,对于开发者来说它是隐藏的。
所以问题就变成了如何找到这个 EditorCamera,我们想想看,编辑器渲染用的也是 Cocos 引擎,那么在引擎的管线中肯定是可以找到这个相机的,不然整个场景压根就没法渲染。所以,在扒了一遍管线代码之后呢,找到了这个相机,它就是 director.root.windows[0].cameras[0],前提是在编辑器下,运行时 EditorCamera 肯定是不存在的哈。然后一通赋值,AlignWithView 我搞定了。
于是我满心欢喜地去告诉@jare我找到 EidtorCamera 了。他是这么回复我的:
cce.Camera.camera 就可以直接拿到 EditorCamera!!!我裂开了。
组件可视化
绘图聊完了,再说说组件的可视化。这一点得狠狠的夸下 Cocos,做的真**的好,使用非常方便,一个 property 打遍天下无敌手。使用 Cinestation 的时候你会发现,编辑器的可视化会随着我们选择的选项不同而动态显示元素,这里是通过 property 的 visible 函数实现的。再往下就是整套系统的各种计算和缓动了,这个留到后面大家熟悉 Cinestation 之后再来和大家分享吧。
PART 3
插件下载
然后是大家关心的插件收费的问题,为了能让插件能切实的帮助到更多的人,这也是我做这件事的初衷,所以我决定本插件完全免费!建议大家现在就可以点击文末【阅读原文】去 Cocos Store 直接下载,以防我反悔
Cinestation 下载-Cocos Store
https://store.cocos.com/app/detail/3422
之后我也会努力更新 Cinestation,不断完善它的能力,各位开发者如果在使用过程中遇到了什么问题、或者有其他想要交流的,欢迎关注我的个人公众号给我评论或留言,我们一起把 Cinestation 越做越好吧↓↓↓
近年来 Cocos 在 3D 领域的发展非常迅猛,成果也是有目共睹的,Cocos 的坚持真的让人佩服。也希望更多人能参与 Cocos 社区的建设与 Cocos 一起成长,大家一起加油。
最后悄悄告诉大家,C姐向我表白了,有截图为证,你们呢?不点个赞关注转发一下?