引擎设计跟踪(九.8) Gizmo helper实现与多国语言
最近把gizmo helper的绘制做好了.
1.为了复用代码,写了utility来创建sphere, cube, cylinder, plane, ring(line), circle(solid) 这些基本物体, 顺便把天空球的创建代码改用utility函数,以后DS的灯光球和椎体等等也可以复用了.
2.本来很简单的以为使用正交投影(orthographic projection)就可以画出大小不变的gizmo.但是却忽略了一个问题: 因为helper的世界变换矩阵跟选中的物体的一样,所以理论上gizmo的轴朝向是跟物体的轴(透视投影后的)朝向一致,位置也和物体(透视)投影后的坐标一致. 但是用正交投影以后,发现旋转和位置都不匹配, 仅仅是大小不变. 比如下图,同一个点, 投影模式不一样,那么最终的位置也不一样:
所以正确的做法应该是用透视投影,并保持投影后的大小不变.思路就是根据投影后的大小反推原始参数,比如这里的求助贴: http://gamedev.stackexchange.com/questions/24968/constant-size-geometries 作者自问自答,使用的是投影大小反推缩放值. 我用的方法类似,也比较简单容易理解:使helper和摄像机保持固定距离.
其实这个固定距离跟投影后的固定大小一样, 是一个magic值,只不过投影后的大小这个值更直观. 而且固定距离也可以先用投影后的固定大小算出来, 但是需要相机的投影参数. 我这里没有相机投影参数,所以做的很简单:
1 ////////////////////////////////////////////////////////////////////////// 2 void AxisGizmo::updateRelativeCamera(const Vector3& cameraPos,const Quaternion& cameraRotation) 3 { 4 //update gizmo transform 5 6 //note: keep a constant distance form camera so that gizmo size looks unchanged 7 static const scalar DISTANCE = 40; 8 const Vector3& pos = mTarget->getPosition(); 9 Vector3 movedPos = cameraPos + (pos - cameraPos).getNormalizedVector()*DISTANCE; 10 11 const Vector3& scale = Vector3::UNIT_ALL; 12 const Quaternion& rotate = mTarget->getRotation(); 13 mState->setWorldTransform(movedPos, scale, rotate, cameraRotation); 14 }
3.上一步完成以后, Roation helper也有一个问题, 比如max或者maya的helper,最外层的那两个圆环一直朝向摄像机. 这个做法也有很多种,比如3D billboard,或者直接在投影后的位置画2D ring.
我用的是billboard的方法.首先, ring创建在view space (对象在object space的顶点坐标直接设置为在view space的坐标) 同时world transofrm为view.inverse(), 这样根据world*view=identity, 相当于顶点的原始坐标和直接在view space一致. 同时注意到view.inverse() (matrix 3x3) 正好就是camera的rotation matrix,所以billboard the world matrix计算如下:
world = gen_transform(pos, scale, matCameraRotation) 或者 world = gen_transform(pos, scale, quatCameraRotation)
1 virtual void setWorldTransform(const Vector3& pos, const Vector3& scale, const Quaternion& rotation, const Quaternion& camearRotation) 2 { 3 AxisGizmoState::setWorldTransform(pos, scale, rotation, camearRotation); 4 //TODO: update view space ring transform 5 6 ////note: the rings are created to orient the Vector3::UNIT_Z by default (vertices are in view space already) 7 ////now just apply the rotation = inverse(viewMatrix33) = camera rotation, to make world*view == identity 8 Matrix44::generateTransform(mViewSpaceRingTransform, pos, scale, camearRotation); 9 }
其实做了这么多,就是为了在常规管线(world*view*projection)里去掉旋转,得到(identity + worldpos+viewTransPos)*projection即projected pos.于是在shader框架里又加了一组自定义的semantics(shader自动变量)为BillboardView/BillboardViewProjection/BillboardWorldView/BillboardWorldViewProjection, 直接跳过world*view的旋转,只使用位移, 这样可以方便在shader里面直接使用billbard.
感觉这样的billboard更高效,不需要反算world rotation,再乘上去:inverse(view)*view*projection, 而直接是identity.setpos(worldPos+viewTransformTranslate)*projection 去掉了2步无用的操作.而且用起来更方便了.因为这几个变量就像WORLD_MATRIX, WORLD_VIEW_PROJECTION一样,被封装到shader框架的自动变量里,shader框架会去自动计算和更新(如果用到的话),不用再像上面那样,起码要有一个billboard基类来复用CPU端代码,或者根据情况,逐个情况手动在CPU端计算.
唯一需要注意的是billboard物体的local space的几何顶点必须相当于直接在view space创建, 面向相机,一般为Z+或者Z-,比如我的右手系,view空间的视向量为Z-,那么billboard物体的面片在local space的朝向应该为Z+.因为目前没有billbard相关的系统所以没有测试用例,而这个里的gizmo helper用统一的shader也懒得再分出来一个shader专门测试shader变量,所以目前还是手动CPU代码计算变换.
4.注意max/maya中的rotation helper, 3个轴对应的圆圈, 只显示了前面部分,后面不显示.这个有点像back face culling, 但是绘制的是line而不是triangle.
想到了3个方法去做这个效果. 第一是用球体+alpha贴图, 第二是用很窄很细的cynlinder来代替圆圈线, 第三种是shader里面处理. 前两种都是画的面片,所以开启back face culling就可以了.试了第二种,效果还可以,就是有时候线过粗,有点变形.
第三种方法是在shader里面clip掉背对相机的点.如何判断一个点是朝向相机还是背对相机? 注意一个点的view space的normal, 正面的点,他们的view space normal都朝向观察点(view space的原点).如下图的viewspace 顶视图:
所以只要clip掉normal.z > 0 或者<0 的像素(根据左右手系)就可以了.注意到sphere/circle通常在local space的原点都是(0,0,0) 那么球面/圈上的点的local space法向量就是normalize(localpos).有了以上分析,就可以写shader了:
1 void BladeVSMain( 2 float4 pos : POSITION, 3 uniform float4x4 wvp_matrix, 4 uniform float4x4 wv_matrix, 5 out float4 outPos : POSITION, 6 out float3 outViewNormal : TEXCOORD //view space normal 7 ) 8 { 9 outPos = mul(pos,wvp_matrix); 10 float3 normal = pos.xyz; 11 outViewNormal = normalize( mul(normal, (float3x3)wv_matrix) ); 12 } 13 14 float4 BladeFSMain( 15 in float4 pos : POSITION, 16 in float3 viewNormal : TEXCOORD 17 ) :COLOR0 18 { 19 clip( viewNormal.z ); 20 //object_diffuse_color is a built-in semantic for per-instance diffuse 21 return object_diffuse_color; 22 }
到这里遇到另外一个问题,是shader变量被优化的问题.本来自动变量WORLD_VIEW_WMATRIX是float4x4, 我在绑定shader参数的时候会做大小和类型检查,但是发现类型不匹配的assertion failure, 一看原来shader里面虽然定义了float4x4,但只用到了world_view的3x3部分(旋转变换), D3DCompile把这个变量的类型也优化成float3x4, 导致跟定义的不匹配, 于是将精确匹配检查改为兼容性检查,只要可兼容,就可以绑定, 这样问题也解决了.
5.helper 的hit test - 根据屏幕坐标得到空间射线(这个很早做的,不多说了),然后根据射线检测出被选中的helper的对应的轴.
问题是箭头尾部的线, 怎么做相交查询. 因为一条线很难被选取,所以要扩大一定范围.本来想直接在投影后的空间做2D相交检测,这样的话,扩大的范围也好计算,但是觉得太麻烦, 最后用的是3D空间的box, 这样box的切面大小就是扩大的范围了. 注意,由于这个box只用于相交检测,不用于渲染,所以不需要创建显卡资源.
rotation helper的轴是三个线圈, 也不好做相交检测, 幸好有一个球在, 根据球面交点的局部坐标,(就知道在哪个轴了)比如交点的localpos.x == 0 说明红色圈(绕x轴的圈)被选中.而这样扩大范围也不难,加上误差比较就可以了.
最后说一下多国语言,这个很早就在考虑,但是没时间做, 但是想了想这个属于很基本的特性, 越到后面越难加, 工作量越大, 索性现在做了.
多国语言原理简单说就是程序中用ID而不用硬编码字符串, 运行时加载语言文件表, 查表得到翻译的字符串.这里面有几个问题需要考虑, 第一,程序员在写代码时,必须有原始语言文件,不断添加资源, 这样很麻烦. 第二,策划在写剧情脚本时, 也要很繁琐的添加文字,然后得到ID, 一样麻烦. 第三,语言文件越来越大可能有无效的信息,需要删除无用的数据. 整个处理流程考虑清楚了,就可以动手了.
所以最初的设想是这样:对于程序员来说, 程序用宏开关, 本地编译时, 使用硬编码的字符串. 编写一个lang-build-tool 来处理程序中出现的字符串,生成内置语言表. 最终发布时, 根据语言查表, 这个语言表不是ID-String map, 而是String List, 就是内置语言(硬编码语言)的列表. 这个表是用来做批量翻译用的,已经与程序源代码编写无关.
根据内置表人工翻译出来的的语言表, 根据在最终发布时一起加载, 组成内置表到目标语言的String-String map.程序在实时根据内置字符串查表就可以得到目标语言.这么做程序员就不用理繁琐的文字资源添加和修改了. 能够这样做前提是程序中自身用到的字符串不多, 而且大都十分短小(每个单元是1-3个单词). 这样效率没有什么大问题.
对于策划来说, 可能字符串的量很大, 而且字符串很长, String-String map的效率可能会低,而且程序已经不需要原始String. 所以做法稍微不同, 任何配置工具都会在内部自动调用语言表的接口, 添加文字到原始语言表, 这个跟程序源代码产生的表类似, 只用于最后批量翻译, 而且与程序生成的内置表不同, 不需要再加载. 翻译后的表是线性String List, (不需要与原始表构成String-String map), 直接用index索引, 而index是多少程序员不需要关心, 因为这个index也存在策划配置文件里面自动生成.
比如程序中的宏如下:
1 #define MULTILANG 0 2 3 #ifndef MULTILANG 4 # define XLang(_str) TEXT(_str) 5 #else 6 # define XLang(_str) ILangTableManager::getSingleton().getBuiltInLangString( TEXT(_str) ) 7 #endif
这样程序员只要知道,对于不需要被翻译的字符串,使用TEXT("Something"), 而对于需要被翻译的字符串,使用XLang("Translation"), 内部调试的时候宏开关关闭, 程序员只需要专注于代码就可以了.
同时lang-build-tool 会处理(read-only-parsing) 所有的源代码, 找到XLang("")内部的字符串并添加到内置语言表里, 这个内置语言表用批量作翻译, 和运行时加载.
最终发布时, 将改宏开启, 语言模块会加载 源代码生成的内置表, 和翻译后的目标语言表, 组成map, 供运行时查询, 策划的语言表也会被加载, 被策划配置数据直接使用.
同时, 需要提供一个翻译工具.因为语言表可能是二进制的.即便不是二进制,是纯文本, 也要有对比功能.翻译工具加载原始语言和目标语言的表,对比不同, 提供UI界面给翻译人员做批量翻译.暂时把这个工具叫做lang-diff-edit-tool(这个工具还没有写...) 最终翻译时, 翻译人员需要翻译两张表, 一个是程序源代码被lang-build-tool处理后生成的表, 另一个是各种策划工具生成的一张语言表. 这样批量翻译也没有问题了.
以上只是简单流程, 没有考虑语音和文字图片的因素.
声音资源话,可能要分成普通声音资源(不需要翻译)和语音资源, 文字图片也需要单独分出来, 以便以后批量翻译处理. 最大的问题在于语音和字幕,字幕是需要结合情景来翻译的,而不能简单机械的处理.而且最头痛的是模型动画(面部)口部的动作也要做单独"翻译"处理. 笔者玩过<星际争霸2>的英文版和中文版, 可以看出中文版的角色口型跟英文版的不同, 是配合中文发音的. 不知道这个是美术为每句话专门调的动画呢(-_-!), 还是有工具自动处理的.
理想的情况是有对话语言编辑工具, 将各种基本发音(元音/辅音) 自动作出模型表情动画( 记得OGRE有一个面部表情的例子, 就是根据发音动态做出口型的), 同时需要有语音引擎,将文本转成数字(音标)信息, 用这个信息来处理面部表情.而且,由于一个声音可长可短, 可能仍然不能机械翻译-做动作, 要结合配音演员的声音数据, 分析每个音节的长短, 来做自动动画的时间控制和匹配... 最后,可以加上表情选项, 每句话都可以有不同的表情,这样一个场景对话工具和语言翻译工具才算完美.
上面只是大话一下, 回归现实, 目前我只做了简单的文字翻译... 由于现在没有翻译界面的工具,而我用的是二进制语言表, 所以目前是宏来定义了一堆字符串, 这些信息放在一个或者几个header被lang-build-tool处理, 为了测试语言模块, 写了中文对应的header(这些中文header编译代码时用不到),生成了中文的语言表.现在已经可以动态切换语言了. 这么做对于程序员来说其实也可以, 唯一的缺点就是需要往header里面加字符串和rebuild, 优点翻译是更显性化, 更可控, 也许以后就这么做了.
另外,语言表的字符串,最好用utf8, 这样不用考虑wchar_t的大小(UTF16/UTF32) 或者endian的问题了, 即便是二进制文件, 也用utf8保存单个字符串, 加载时转换为多字节或者wstring.
还有,对于类工厂,如果使用字符串做类创建,那么这个字符串最好不要被翻译, 因为这个类型字符串可能被保存在资源文件里面, 如果切换语言, 一个类被注册成了其他语言字符串, 使用原来资源中的字符串将找不到注册信息. 当然也可以在加载资源的时候,手动将类型信息也翻译了, 不过那么做没太大必要.还有,有部分字符串是在UI模块里面自动翻译的(手动调用接口翻译),特别是属性表的文字. 主要原因是翻译起来不方便, 或者是需要写入资源的属性信息,加载资源时还需要原字符串.
最后贴一张图纪念一下最近的工作, 以后更新仍然会很慢,因为工作很忙, 业余只有周末有点时间.后面会把translate/rotate/scale工具做完,因为现在只是显示了helper,还没有做功能.然后开始搞动画,拖了好久了...
图里面有几个bug待会儿修一下..