virtools引擎3d游戏程序设计


《Virtools 引擎 3D 游戏程序设计》 覃伯明 编著 致谢(排名分前后) 非常感谢郭佳伟先生无偿为本书撰写附录 E《Virtools 消息使用 须知》。非常感谢姚建辉先生无偿提供层级关系的中文显示 BB 源码。 非常感谢杨旭东先生、郭健先生对本书的斧正。 …… 内容提要 本书是直接从 VSL、Shader、SDK 的角度来讲解 Virtools 的开发游戏,开发虚拟漫游 的方方面面,所以本书面向的是已经具有一定编程经验的读者,最好已经学会了 virtools。 本书的内容是先从容易上手的 VSL 脚本说起,最先说的是 VSL 的基础语法。可以让 没有代码开放经验的读者,也能够看懂 VSL,然后慢慢讲解各个 VSL 类型的用法。 VSL 部分讲解完后,接着就是 Shader 的学习和应用。Shader 可谓是短小精悍,算法 却博大精深,所以要求读者对 HLSL 语言基础了解,这一部份讲解了目前游戏中经常用到 的画面效果,非常值得去学习。 SDK 是发挥和扩充 Virtools 功能的利器,SDK 涉及的面很广,所以没有像 VSL 部分 那样罗列所有类的用法,而是强调如何进行开发,并把常用的 SDK 开发方式都已经罗列 并讲解了,掌握这一部分,就相当完全驾奴了 Virtools 这个引擎。 随书附送有配套光盘。光盘按章节顺序提供了书中的所用的实例代码,这些代码都经 过作者精心调试过,在 WindowsXP、Virtools4.0 下,保证可用。 如果你是 virtools 开发者,如果你是 3D 游戏开发者,如果你是虚拟现实项目开发者, 请你相信,此书会对你很有用。 前言 Virtools 是什么 ——是―3D for All‖开发平台。直译为:为所有平台解决 3D 图形化功能。它是用最直观 的图形化程序接口方式,来实现真实的多样化的虚拟体验。 Virtools 能做什么 ——3D 游戏、虚拟场景漫游、数字营销系统、数字教学方案、地理信息导航、虚拟 驾驶等等。 本书能为读者带来什么 ——提高项目开发的手段,提示画面效果,缩短开发周期。 写本书的初衷 目前市面上 Virtools 的开发书籍很少,涉及底层的开发应用就更少,所以想把自己多 年来的开发经验总结出来,让晚辈们少走弯路,也希望与同行多多交流,相互学习。做为 一个开发人员,仅仅熟悉 virtools 现有的图形化界面方式,并以此开展项目工作是远远不 够的。当项目提出更多的需求或者需求变得复杂时,你就会发现图形化界面编程方式变得 难以应付了。如果想让自己处理起来游刃有余,必须要融会贯通 virtools 的 VSL 脚本程序 以及 SDK 开发包,三者共同发挥才能展现出 virtools 的强大魅力,才能出色的完成项目工 作。 本书的特点: 本书的特点是,涉及所有的实例代码,基本上都有详细的注解。只要读者有点程序设 计基础,都能看懂。对于比较难的代码,即使看不懂,也没关系,因为已经封装成模块, 直接套用后,就能运用到自己的项目中。 本书光盘内容: 光盘内容分 4 部分,第一部分是 VSL 程序范例;第二部分是 Shader 的程序范例;第 三部分 SDK 程序范例;第四部分是附录的程序范例,第六部分是 Virtools 素材资源。光盘 中的命名规则是以章节来命名的。比如: “例子 5.1.2_1.cmo” 其中―5‖表示第五章、―1‖表示第五章的第一节内容、―2‖表示该小节下的第二个知识点, ―_1‖表示该知识点的第一个例子程序。 本书的读者 可供虚拟现实项目或者游戏开发项目的程序员参考。 本书约定 本书中对 VSL 脚本有两种叫法,一个叫 VSL 脚本程序,一个叫 VSL 脚本 BB,它们 其实是同一个东西,只不过一个从程序的角度来叙述 VSL,因为它要输写代码,所以叫 VSL 脚本程序;一个从 BB 的角度来叙述 VSL,因为它要连线,填参数,所以叫 VSL 脚本 BB。 当说到脚本时候,请注意 virtools 脚本和 VSL 脚本程序,是不同概念的东西。VSL 脚 本程序只是一小段程序代码,其作用类似 BB,所以书中也叫它 VSL 脚本 BB; 而 Virtools 脚本则是一个集合体,包含着 BB、BG、VSL 脚本 BB、以及连接线等等,其更像是一个 脚本流程图,Virtools 脚本的意义更大,更广些。 无论是 VSL 脚本开发,还是 SDK 的开发,都有碰到“输入输出参数”和“输入输出 端口”这两个是完全不同的概念。“输入输出参数”概念就是一个 BB 上端和下端的参数, 是用来填写和获得数据的参数,就像是程序调用的输入参数和返回参数。“输入输出端口” 则是用来在 virtools 脚本中参与连线的端子。 目录 《Virtools 引擎 3D 游戏程序设计》 ............................................................................................. 1 致谢(排名分前后) ....................................................................................................................... 2 第一部分 VSL 脚本语言程序设计 ................................................................................................ 1 第一章 认识 VSL (Virtools Scripting Language ,脚本语言 ) ............................................ 1 1.1 VSL 简介 .......................................................................................................... 1 1.1.1 BB 与 VSL 的关系 ................................................................................... 1 1.1.2 VSL 擅长什么工作 .................................................................................. 1 1.1.3 VSL 脚本语言与 C 语言相似 .................................................................. 3 1.1.4 VSL 与其它脚本语言不同 ...................................................................... 3 1.2 VSL 脚本管理器 ............................................................................................ 3 1.2.1 VSL 脚本管理器的使用 .......................................................................... 3 1.2.2 创造第一个 VSL 脚本程序 ..................................................................... 5 1.2.3 一个带输入输出的 VSL 脚本程序 ......................................................... 6 1.2.4 VSL 脚本程序的重命名、定位和删除 .................................................. 9 1.2.5 VSL 快捷键使用 ...................................................................................... 9 1.3 VSL 语法描述 ................................................................................................ 10 1.3.1 VSL 的变量和常量 ................................................................................ 10 1.3.2 VSL 的运算符 ........................................................................................ 11 1.3.3 VSL 的表达式与语句 ............................................................................ 13 1.3.4 VSL 的赋值语句和声明语句和注释 .................................................... 14 1.3.5 VSL 的条件判断语句 ............................................................................ 14 1.3.6 VSL 的循环和控制语句 ........................................................................ 20 1.3.7 数组和多维数组 ..................................................................................... 24 第二章 VSL 的函数 ............................................................................................................... 27 2.1 函数的定义与调用 ......................................................................................... 27 2.1.1 什么是函数 ............................................................................................. 27 2.1.2 定义函数 ................................................................................................. 27 2.1.3 函数的调用 ............................................................................................. 28 2.1.4 函数的重载 ............................................................................................. 29 2.2 函数的参数及其返回值 ............................................................................... 30 2.2.1 形参和实参 ............................................................................................. 30 2.2.2 函数值的返回 ......................................................................................... 31 2.3 变量的作用域和存储类型 ........................................................................... 32 2.3.1 局部变量、全局变量和作用域 ............................................................. 32 2.3.2 VSL 全局共享变量和全局静态变量 .................................................... 34 2.3.3 结构体和枚举类型 ................................................................................. 36 2.4 字符串类的使用 ........................................................................................... 38 2.4.1 字符变量、字符串类型变量、字符数组和字符串类的区别 ............. 38 2.4.2 字符串类的定义与赋值 ......................................................................... 39 2.4.3 字符串的处理函数 ................................................................................. 39 2.4.4 字符串的应用 ......................................................................................... 40 第三章 VSL 中的 3D 数学与变换以及简单几何体 ............................................................ 43 3.1 向量 ................................................................................................................. 43 3.1.1 向量的概念 ................................................................................................... 43 3.1.2 向量长度和归一化 ....................................................................................... 43 3.1.3 向量的相加与相减 ....................................................................................... 44 3.1.4 向量和标量的乘法 ................................................................................. 45 3.1.5 向量的点积 ................................................................................................... 45 3.1.5 向量的叉积 ................................................................................................... 46 3.1.6 位置和位移向量 ........................................................................................... 46 3.2 VSL 中的 2D 向量 Vector2D ........................................................................ 47 3.2.1 Vector2D 的方法......................................................................................... 47 3.2.2 Vector2D 的应用 ................................................................................... 49 3.3 VSL 中的 3D 向量 Vector ............................................................................. 51 3.3.1 Vector 的方法 ......................................................................................... 51 3.3.2 Vector 的应用 ............................................................................................. 51 3.4 矩阵和 Matrix ................................................................................................. 53 3.4.1 矩阵的概念 ............................................................................................. 53 3.4.2 Matrix 的方法和应用 ............................................................................. 54 3.5 简单几何体 ..................................................................................................... 54 3.5.1 点、线、面 ............................................................................................. 54 3.5.2 正方体 ......................................................................................................... 55 3.5.3 圆锥体和圆球 ......................................................................................... 56 3.5.4 矩形 ......................................................................................................... 56 第四章 Virtools 中的 2D 实体 .............................................................................................. 62 4.1 2D 帧 Entity2D ............................................................................................. 62 4.1.1 Entity2D 与 Rect ..................................................................................... 62 4.1.2 Entity2D 的应用 ..................................................................................... 62 4.2 2D 精灵 Sprite .............................................................................................. 67 4.2.1 Sprite 与 Entity2D ................................................................................... 67 4.2.2 Sprite 的应用 .......................................................................................... 67 第五章 灯光、材质和纹理 ................................................................................................... 70 5.1 光 ..................................................................................................................... 70 5.1.1 光的类型 ................................................................................................. 70 5.1.3 光的衰减属性 ......................................................................................... 71 5.1.4 高光 ......................................................................................................... 72 5.1.5 色彩 ............................................................................................................... 73 5.2 材质的特性 ..................................................................................................... 74 5.2.1 物体表面材质属性 ................................................................................. 74 5.2.2 3D 物体填充模式 ................................................................................... 75 5.2.3 Flat 渲染和 Gouraud 渲染 ...................................................................... 75 5.2.4 Alpha 混合 .............................................................................................. 76 5.2.5 Alpha 测试(Alpha Test) ..................................................................... 78 5.2.6 纹理采样过滤 ......................................................................................... 79 5.2.7 纹理寻址(Texture Address) ..................................................................... 80 5.2.8 多重纹理混合 ......................................................................................... 81 5.2.9 纹理过滤和纹理寻址的显示例程 ......................................................... 82 5.3 纹理的特性 ..................................................................................................... 83 5.3.1 纹理的插槽(Slot)属性 ...................................................................... 83 5.3.2 天空盒纹理 ............................................................................................. 85 第六章 3D 物体 ..................................................................................................................... 87 6.1 3D Entity ............................................................................................................... 87 6.1.1 坐标系和 3D 物体 .................................................................................. 87 6.1.2 3D Entity 的位置、方向与缩放 .............................................................. 88 6.1.3 物体的父子关系 ................................................................................... 91 6.1.4 物体的网格(Mesh) ................................................................................ 93 6.1.5 3D 物体的屏幕坐标位置 ........................................................................... 94 6.2 Mesh ..................................................................................................................... 94 6.2.1 mesh 到应用 ........................................................................................... 94 6.2.2 mesh 与材质、纹理贴图 ....................................................................... 96 6.2.3 纹理坐标:(u, v)坐标 ......................................................................... 97 6.2.4 Mesh 的通道材质特性 ........................................................................... 97 6.3 3D 精灵 Sprite3D ................................................................................................. 98 6.3.1 3D 精灵和公告板 ................................................................................... 98 6.3.2 3D 精灵的应用 ....................................................................................... 99 第七章 角色动画 ................................................................................................................. 101 7.1 角色 ..................................................................................................................... 101 7.1.1 角色的概念 ........................................................................................... 101 7.1.2 播放角色动画 ....................................................................................... 101 7.1.3 角色的次级动画播放 ........................................................................... 104 7.1.4 角色的身体 ........................................................................................... 105 7.2 角色动画 ..................................................................................................... 105 7.2.1 如果确定角色动画是否有问题 ........................................................... 105 7.2.2 角色的动画帧 ....................................................................................... 106 7.2.3 角色动画的一些设置 Unlimited Controller ........................................ 109 7.2.4 用 VSL 设置角色动画 ......................................................................... 112 7.3 角色动画的应用 .................................................................................................... 113 7.3.1 用 VSL 来控制角色动画 ..................................................................... 113 第八章 摄像机 ..................................................................................................................... 119 8.1 相机的基本属性 ................................................................................................. 119 8.1.1 相机的投影 ........................................................................................... 119 8.1.2 相机的视锥 ........................................................................................... 121 8.1.3 目标相机 ............................................................................................... 122 8.2 相机的应用 ................................................................................................... 123 8.2.1 第一人称相机 ....................................................................................... 123 8.2.2 第三人称相机 ....................................................................................... 126 第九章 曲线与网格 ............................................................................................................. 130 9.1 3D 曲线 ............................................................................................................... 130 9.1.1 什么是 3D 曲线和 3D 曲线节点 ......................................................... 130 9.1.2 3D 曲线的应用 ..................................................................................... 132 9.1.3 3D 曲线节点的详细设置 ..................................................................... 134 9.2 2D 路径 ............................................................................................................... 136 9.2.1 2D 曲线和 2D 曲线的应用 .................................................................. 136 9.3 网格 ............................................................................................................... 137 9.3.1 网格和层 ..................................................................................................... 137 9.3.2 网格的 VSL 应用 ........................................................................................ 138 第十章 表、组和场景 ......................................................................................................... 142 10.1 表 ....................................................................................................................... 142 10.1.1 表的数据存储类型 ............................................................................... 142 10.1.2 使用 VSL 创建表和储存数据 ............................................................. 143 10.1.3 通过 VSL 提取表中数据和删除行列 ................................................. 145 10.1.4 通过 VSL 对表数据的操作 ................................................................. 147 10.2 组 ....................................................................................................................... 148 10.2.1 组的意义 ............................................................................................... 148 10.2.2 组的应用 ............................................................................................... 149 10.3 场景 ................................................................................................................... 150 10.3.1 场景是一种优化技术 ........................................................................... 150 10.3.2 场景技术的简单应用 ........................................................................... 151 10.4 数据容器 ........................................................................................................... 153 10.4.1 数据容器的使用 ................................................................................... 153 10.4.2 String、StringTokenizer 和 ArrayString .............................................. 156 第十一章 声音与视频 ......................................................................................................... 158 11.1 声音的基本运用 ............................................................................................... 158 11.1.1 声音的特性 ........................................................................................... 158 11.1.2 播放一个声音 ....................................................................................... 158 11.1.3 2D 声音和 3D 声音 .............................................................................. 160 11.2 声音的高级应用 ............................................................................................... 161 11.2.1 锥形的听觉范围 ................................................................................... 161 11.2.2 一个声音的多重播放 ........................................................................... 164 11.3 视频的播放 ....................................................................................................... 165 11.3.1 视频的一些属性 ................................................................................... 165 11.3.2 视频的播放 ........................................................................................... 166 第十二章 VSL 中的 bc ....................................................................................................... 169 12.1 什么是工厂 ....................................................................................................... 169 12.1.1 工厂干了什么 ....................................................................................... 169 12.1.2 工厂能生产几种产品 ........................................................................... 169 12.1.3 工厂也可以复制产品 ........................................................................... 170 12.1.4 工厂如何删除产品 ............................................................................... 172 12.2 属性控制 ........................................................................................................... 173 12.2.1 属性(Attribute)是什么..................................................................... 173 12.2.2 属性(Attribute)的操作..................................................................... 173 12.3 bc 的其他功能 .................................................................................................. 177 12.3.1 取得场景物件 ....................................................................................... 177 12.3.2 bc 的其他一些小功能 .......................................................................... 180 12.4 输入管理器 ....................................................................................................... 183 12.4.1 鼠标的使用 ........................................................................................... 183 12.4.2 键盘的使用 ........................................................................................... 185 12.5 渲染设备 ........................................................................................................... 187 12.5.1 什么是渲染设备 ................................................................................... 187 12.5.2 如何设置雾 ........................................................................................... 188 12.5.3 鼠标拾取物件 ....................................................................................... 190 12.5.4 2D 屏幕与 3D 位置 .............................................................................. 194 12.6 ac ....................................................................................................................... 197 12.6.1 什么是 ac .............................................................................................. 197 12.6.1 如何创建和使用 ac .............................................................................. 198 第二部分 高级渲染语言 ............................................................................................................. 201 第十三章 Virtools 中的 shader ........................................................................................... 201 13.1 认识 shader ......................................................................................................... 201 13.1.1 shader 的诞生 ....................................................................................... 201 13.1.2 Virtools 中的 shader .............................................................................. 201 13.1.3 Virtools 中 shader 的执行过程 ............................................................. 202 13.1.4 shader 编辑器 ....................................................................................... 203 13.2 shader 语言 .................................................................................................. 204 13.2.1 创建第一个 Shader 程序 ...................................................................... 204 13.2.2 顶点着色器和像素着色器 ................................................................... 206 第十四章 光照 ..................................................................................................................... 208 14.1 参数 ...................................................................................................................... 208 14.1.1 设置自发光参数的 Virtools 写法 ........................................................ 208 14.1.2 设置自发光参数的 HLSL 写法 ........................................................... 209 14.2 光照 ............................................................................................................... 211 14.2.1 光照的原理 ........................................................................................... 211 14.2.2 光照 Shader ........................................................................................... 213 14.2.3 光照的 HLSL 写法 ............................................................................... 214 14.3 镜面反射 ....................................................................................................... 218 14.3.1 一盏灯的镜面反射光照 ....................................................................... 218 14.3.2 二盏灯的镜面反射光照 ....................................................................... 220 第十五章 纹理贴图光照 ..................................................................................................... 223 15.1 简单的纹理渲染 .................................................................................................. 223 15.1.1 Virtools 纹理渲染 Shader ..................................................................... 223 15.1.2 HLSL 纹理渲染 .................................................................................... 223 15.1.3 带光照的纹理渲染 ............................................................................... 225 15.1.4 逐像素的纹理渲染 ............................................................................... 227 15.1.5 两层纹理渲染 ....................................................................................... 229 15.2 凹凸纹理映射 Bump mapping ..................................................................... 231 15.2.1 凹凸纹理映射的由来 ........................................................................... 231 15.2.2 凹凸纹理映射的原理 ........................................................................... 231 15.2.3 凹凸纹理映射的实现 ........................................................................... 232 15.2.4 改良的凹凸纹理映射 ........................................................................... 236 15.3 环境映射 ....................................................................................................... 241 15.3.1 环境映射的原理和立方贴图纹理 ....................................................... 241 15.3.2 Virtools 中如何生成立方体贴图 ......................................................... 242 15.3.3 反射环境映射 ....................................................................................... 243 15.3.4 折射环境映射 ....................................................................................... 246 15.3.5 菲涅尔效果和颜色色散 ....................................................................... 248 15.3.6 环境映射和凹凸纹理映射的结合 XXX ............................................. 253 15.4 2DFrame 的 shader .................................................................................... 256 15.4.1 2D 帧的多次渲染 ................................................................................. 256 15.4.2 2D 帧的旋转 ......................................................................................... 259 第十六章 shader 小效果 ............................................................................................. 264 16.1 shader 实现动画效果 .................................................................................. 264 16.1.1 脉动的茶壶——顶点动画 ................................................................... 264 16.1.2 舞动的花朵——纹理 UV 动画 ........................................................... 267 16.1.3 波光粼粼的浅水区——序列帧动画 ................................................... 270 16.2 简单的几个修饰效果 .......................................................................................... 273 16.2.1 广告墙变换效果 ................................................................................... 273 16.2.2 海底焦散 ............................................................................................... 276 16.2.3 画面模糊效果 ....................................................................................... 280 16.2.4 景深效果(Depth of Field) ................................................................ 284 16.2.5 地球大气效果 ....................................................................................... 287 16.3 简单的几个其他效果 .......................................................................................... 292 16.3.1 Gamma burn(灼烧) .......................................................................... 292 16.3.2 Glow(发光) ........................................................................................... 294 16.3.3 Bloom(绽放) .................................................................................... 297 16.3.4 Fur(毛发) ......................................................................................... 302 16.3.5 GrayScale(灰度) .............................................................................. 306 第十七章 shader 的应用 ............................................................................................. 308 17.1 地形渲染 ....................................................................................................... 308 17.1.1 Texture Splatting 原理简介 .................................................................. 308 17.1.2 Texture Splatting 对地形建模的一点要求 .......................................... 308 17.1.3 shader 代码剖析 ................................................................................... 308 17.2 运动模糊 ....................................................................................................... 314 17.2.1 运动模糊原理简介 ............................................................................... 314 17.2.2 设置需要配合的资源 ........................................................................... 314 17.2.3 运动模糊 shader 代码 .......................................................................... 316 17.3 带倒影的水面渲染 ....................................................................................... 319 17.3.1 渲染水的步骤 ....................................................................................... 319 17.3.2 水面的设置 ........................................................................................... 321 17.3.3 水的 shader ........................................................................................... 322 第十八章 shader 应用的举一反三 ............................................................................. 327 18.1 渲染车 ........................................................................................................... 327 18.1.1 渲染车灯 ............................................................................................... 327 18.1.2 渲染车轮 ............................................................................................... 331 18.1.3 渲染车窗 ............................................................................................... 335 18.2 渲染场景 ....................................................................................................... 338 18.2.1 带法线的地形渲染 ............................................................................... 338 18.2.2 树的渲染 ............................................................................................... 344 18.2.3 树叶与草的渲染 ................................................................................... 345 第三部分 Virtools SDK .............................................................................................................. 349 第十九章 认识 Virtools SDK .............................................................................................. 349 19.1 Virtools 的体系结构 ..................................................................................... 349 19.1.1 SDK 的介绍 .......................................................................................... 349 19.1.2 帧循环的介绍 ....................................................................................... 351 19.1.3 主要对象之间的关系 ........................................................................... 352 19.2 使用 SDK 创建第一个 BB .......................................................................... 353 19.2.1 行动前的准备 ....................................................................................... 353 19.2.2 通过向导创建一个 BB ......................................................................... 355 19.2.3 BB 源文件的解释................................................................................. 360 19.2.4 编译生成 BB ........................................................................................ 366 第二十章 用 SDK 开发 BB ................................................................................................ 369 20.1 BB 的参数 .................................................................................................... 369 20.1.1 BB 的参数有三种................................................................................. 369 20.1.2 BB 参数值的获取和赋值 ..................................................................... 370 20.1.3 改变 BB 参数的数量和类型 ......................................................... 373 20.2 BB 的输入输出端口..................................................................................... 378 20.2.1 BB 的输入输出端口控制 .............................................................. 378 20.3 自定义类型的参数 ....................................................................................... 382 20.3.1 自定义类型的参数一些说明 ....................................................... 382 20.3.2 创建简单的自定义参数 ............................................................... 384 20.3.3 结构体类型参数的处理函数 ............................................................... 386 20.4 使用 SDK 的 BB .......................................................................................... 392 20.4.1 修改 PushBotton 和 DragAndDrop .............................................. 392 20.4.2 转向功能 ....................................................................................... 393 20.4.3 有层级关系的中文显示 ............................................................... 395 第二十一章 用 SDK 扩展功能 ............................................................................... 405 21.1 多线程的应用 ............................................................................................... 405 21.1.1 理解进程和线程 ................................................................................... 405 21.1.2 VXThread、VxMutex、VxMutexLock ............................................... 406 21.1.3 多线程的应用 ....................................................................................... 407 21.2 管理器的开发使用 ....................................................................................... 417 21.2.1 管理器为何物 ....................................................................................... 417 21.2.2 Manager 代码解释 ................................................................................ 421 21.2.3 Virtools 与 ADO 的整合 ...................................................................... 422 21.2.4 创建 ADOACCESSManager ................................................................ 424 21.2.5 在 BB 中使用调用管理器方法 ............................................................ 431 21.2.6 把管理器功能整合到一个 dll 中 ......................................................... 434 21.3 为 VSL 绑定新类型 ..................................................................................... 436 21.3.1 绑定新类型的规则 ............................................................................... 436 21.3.2 绑定的细则 ........................................................................................... 437 21.3.3 绑定的简单范例 ................................................................................... 441 21.4 第三方声音引擎与 Virtools 的整合 ............................................................ 443 21.4.1 为什么要整合第三方声音引擎 ........................................................... 443 21.4.2 第三方声音引擎使用介绍 ................................................................... 443 21.4.3 不同喇叭播放不同声音 ....................................................................... 445 21.5 网络引擎与 Virtools 的整合 ........................................................................ 454 21.5.1 为什么要整合网络引擎 ....................................................................... 454 21.5.2 使用网络引擎创建一个服务器 ........................................................... 454 21.5.3 网络模块管理器 ................................................................................... 460 21.5.4 连接到服务器 BB................................................................................. 466 21.5.5 发送消息到服务器 BB ......................................................................... 468 21.5.6 处理服务器消息 BB ............................................................................. 469 第二十二章 用 SDK 开发新功能 ....................................................................................... 473 22.1 读写注册表 ................................................................................................... 473 22.1.1 注册表的简单介绍 ............................................................................... 473 22.1.2 读写注册表功能描述 ........................................................................... 473 22.1.3 访问注册表的 BB................................................................................. 474 22.2 文件目录的操作 ........................................................................................... 478 22.2.1 例举所有指定类型文件 ....................................................................... 478 22.2.2 记录日志文件 ....................................................................................... 481 22.2.3 设置打印 ............................................................................................... 482 22.2.4 复制移动文件 ....................................................................................... 487 22.3 Virtools 与串口通信 ..................................................................................... 489 22.3.1 串口通信类 ........................................................................................... 489 22.3.2 定义串口通信协议 ............................................................................... 490 22.3.3 串口通信管理器 ................................................................................... 493 22.3.4 开串口 BB ............................................................................................ 494 22.3.5 从串口中得到数据 ............................................................................... 495 22.3.6 向串口写数据 ....................................................................................... 496 22.4 Virtools 与 Flash 整合 ................................................................................. 498 22.4.1 为什么要和 Flash 整合 ........................................................................ 498 22.4.2 Flash 的封装 ......................................................................................... 498 22.4.3 封装一个播放 swf 文件的 BB ............................................................. 499 第二十三章 发布应用程序 ....................................................................................... 505 23.1 编译生成独立的执行程序 ........................................................................... 505 23.1.1 关于执行程序的介绍 ........................................................................... 505 23.1.2 编译生成 Virtools 自带的播放器。 .................................................... 506 23.1.3 CcustomPlayer 类分析。 ..................................................................... 507 23.1.4 CcustomPlayerApp 类分析。 ............................................................... 508 附录 A VSL 全局函数 ...................................................................................................... 510 附录 B Virtools 参数类型 GUID ...................................................................................... 514 附录 C 枚举 CK_BEHAVIOR_FLAGS 的标示 ....................................................... 516 附录 D CKBehavior::SetCallbackFunction 回调标准 ...................................................... 517 附录 E Virtools 消息使用须知............................................................................................. 518 消息的接收 ........................................................................................................... 518 消息的参数 ........................................................................................................... 520 附录 F 常用类的标识符类 CK_CLASSID .............................................................. 523 以下目录未完成 第二部分 Virtools 中 Shader 的开发 第三部分 Virtools SDK 的开发 第四部分 Virtools 的 web 开发 第五部分 Virtools 与第三方插件的整合 第六部分 Virtools 项目(一个游戏实例,一个 VR 项目) 第 XXX 章 VSL 编程常见问题 5.1 语法问题 5.2 方法问题 …… 实例篇 第 XXXX 章 VSL 实例编程 5.1 2DEntity 类型 5.2 3DEnity 类型 5.3 其他类型 …… - 1 - 第一部分 VSL 脚本语言程序设计 第一章 认识 VSL (Virtools Scripting Language ,脚本语言 ) VSL 是 Virtools 提供的一个非常有创意的脚本语言,它以输写代码的方式(区别于 Virtools 现有的图形化编程界面)进行编程开发,提高了程序的可读性和工作效率,它的 存在好比如虎添翼。掌握了这个这种开发方式,你一定会大大的减少―连线‖的数量和提高 程序的开发效率。Virtools 的现成的 BB 能办的事情,VSL 几乎都能办到,有些地方甚至 做得更好,所以请相信:―它很好,很强大!‖。本章将详细的介绍 VSL 的语法基础和使用 指南,读者可以了解以下内容:  VSL 的基本面貌。  VSL 语法规则。  VSL 脚本管理器如何管理 VSL 脚本。  VSL 编程中常用的快捷键。  VSL 的基本变量和语句。 1.1 VSL 简介 1.1.1 BB 与 VSL 的关系 通常说的 BB 英文全名是 Building Blocks,其意义就像我们小时候玩的一种玩具—— 积木。它有好多不同颜色、不同形状、不同大小的小木块,然后小朋友就用这些各式各样 的木块,盖出自己的城堡。Virtools 的每个 BB 就像这些小木块,将这些 BB 有机的连接起 来,就会做出意想不到的效果。想象力越丰富,越能做出漂亮的“城堡”,做出漂亮的效果。 这样理解 BB 的含义我认为最恰当不过了。 VSL 与 BB 之间是什么关系呢?VSL 写出来的东西其实就是一个 BB!VSL 写出来的 BB 和自带的 BB 在使用方法上完全一样,没用任何区别;区别的仅仅的是 BB 是通过什么 制造出来的。就像玩具积木,买来的时候已经有了很多各式各样的小木块,但是我仍未满 足,我自己找来木块和锯子,自己创造了一些形状特异的小木块,满足我要盖新奇城堡的 欲望。VSL 就是用来满足这个欲望的手段,VSL 就是木块和锯子,是创造出新 BB 的一种 方法。它扩展了程序员的能力。 1.1.2 VSL 擅长什么工作 在谈论这个问题前,先看下图: - 2 - 图 1.1.2_1 这个 Virtools 脚本(注意不是 VSL 脚本程序),它的功能是:将一个 3D 物体拷贝出 10 个,然后分别重新命名这 10 个新的 3D 物体。要完成这样的功能需要 4 个 BB 进行组 合,要连接 8 条线。现在我们用 VSL 脚本程序(注意不是 Virtools 脚本)写一个 BB 来实 现同样的功能,如下代码和图: void main() { // Insert your code here String temp; for(int i=0;i<10;i++) { Entity3D myCopy=Entity3D.Cast(bc.Copy(MyRef)); temp=MyName+i; myCopy.SetName(temp.Str()); } } 图 1.1.2_2 两种之间对比一下,发现没省什么功夫!只是画面简洁一些而已,好处并不明显!别 急如果这时有十个不同的 3D 物体,分别都要拷贝出若干个,我们也就理所当然的把这些 脚本代码复制十份,添上不同的参数就完成了,看似也很简单的嘛。如果此刻项目的需求 变了(实际的工作中,项目需求的变化是绝对正常的),我们不仅要拷贝出这些 3D 物体, 还要把他们的位置全部归 0,也就是放到世界坐标为:0,0,0 的位置上。这时候来看看 两种方法各自改进的工作量吧: 图 1.1.2_3 - 3 - 上图是 Virtools 脚本代码改动后的模样,因为先前这个脚本代码被复制十份,所以这 十个地方的都被替换更新,遗漏一处就会给项目带来潜在的错误。再来看看 VSL 脚本程序 是如何改动的(例子 1.1.2_1.cmo): void main() { // Insert your code here String temp; for(int i=0;i<10;i++) { Entity3D myCopy=Entity3D.Cast(bc.Copy(MyRef)); temp=MyName+i; myCopy.SetName(temp.Str()); Vector pos(0,0,0); myCopy.SetPosition(pos); } } VSL 脚本程序改动的仅仅是增加最后那两行代码,仅此而已!这下看到 VSL 的优点了吧, 除了这些 VSL 还可以完成一些 virtools 自带的 BB 无法完成的工作,比如稍复杂一点的数 学混合运算,VSL 是首选的。而且对于一些要发布成网页模式的项目,VSL 可以直接的支 持应用,不像用 SDK 开发的功能,要把相应的 dll 文件下载到客户机器上,VSL 的优点在 这里尤为突出。总之学会 VSL 脚本程序,一定会让你受益匪浅。 1.1.3 VSL 脚本语言与 C 语言相似 VSL 与 C/C++语言有几分相似,但绝对没有 C/C++那么复杂,脚本语言的宗旨是—— 简单;学习简单,使用简单,维护简单,不简单就不叫脚本语言,至少不是成功的脚本语 言。在 VSL 中有着和 C/C++语言相似的变量、常量、结构和类,但绝对没有让人紧张的 指针。所以不用担心学不会 VSL。如果你有一定的 C/C++或者其他的程序设计基础,那么 VSL 对你来说就是小菜一碟。如果你没有代码输写的经验,也不要气馁,我在关于 VSL 的教程写得通俗易懂,你会发现使用 VSL 写代码算不上一件难事。 1.1.4 VSL 与其它脚本语言不同 VSL 脚本和其它脚本语言(python lua)一样,是一种解释性的语言,都有相应的脚 本引擎来解释执行。正因为是边解释边运行,所以脚本语言的执行效率不是最优的。VSL 严格意义上讲,它不属于脚本语言。它过于强大了,完全可以创建和控制所有 Virtools 里 面的对象, 1.2 VSL 脚本管理器 1.2.1 VSL 脚本管理器的使用 VSL 脚本管理器是管理所有 VSL 脚本程序的地方,是负责 VSL 代码的编辑、编译、 调试和维护的地方。往后的学习中,我们会频繁的来到这里,默认的情况下这个管理器是 没有打开的,有两种方法可以打开它:第一种方法如图 1.2.1_1 所示:点击―VSL Script Manager‖项即可进入 VSL 脚本管理器环境中。 - 4 - 图 1.2.1_1 第二种选中任何一个 VSL 脚本程序写的 BB(即 VSL 脚本 BB),右键鼠标,选择第一 选项―Edit Vsl Script‖,然后进入 VSL 脚本管理器。如图 1.2.1_2 所示 图 1.2.1_2 进入了 VSL 脚本管理器,你会看到下图所示的界面: 图 1.2.1_3 在这个编辑环境中,常用的功能有: 编译所有的 VSL 脚本程序。 这里可以选择 Release/Debug 两种模式,在平时我们使用 Debug 模式, - 5 - 这样可以调试我们的 VSL 程序;当项目完成以后要发布了,选择 Release 模式,再编译 一次,这样会使 VSL 脚本程序运行的更快一些。 这个是在 Debug 模式才起作用,当我们想让 VSL 脚本程序执行到某一行时 候停下来,可以用这个功能按钮实现。 当 VSL 脚本程序编译通过后,就可以点击这个按钮来执行,查看运行结果。 这个按钮是强行结束 VSL 脚本程序的执行。 Run VSL 是 VSL 脚本程序的名字,即 VSL 脚本 BB 的名字;那个 No 表示此脚本还没编译或者是没用编译成功。 和 是 VSL 脚本 BB 在 Virtools 脚本中用于连线的端口。 和 是 VSL 脚本 BB 的输入和输出参数,就像普通的 BB 的那样。 最后的 void main()之处,就是你编辑代码的地方,是发挥想象力的地方。 1.2.2 创造第一个 VSL 脚本程序 创造一个 VSL 脚本有三个步骤:创建、编译和运行。创建 VSL 脚本很简单,有两种 方法:第一种方法先选择 Building Blocks、然后选中 VSL、最后用鼠标左键将―Run VSL‖ 这个 BB 拖到 Virtools 脚本中即可,如图 1.2.2_1 所示: 图 1.2.2_1 第二种方法是在一个 Virtools 脚本中,按住键盘 Ctrl 键不放,然后双击鼠标左键,会 出现下拉的组合框,在输入框输入 run 后,组合框中会出现―Run VSL‖,鼠标单击并选中它 即可,如图 1.2.2_2 所示: 图 1.2.2_2 一个 VSL 脚本程序已经创建完成了,也就是一个新的 BB 已经被你创造出来了,只是 现在还不可以用,要经过编译以后才可以用了,就像做瓷器一样,当用泥巴捏好形状后, 如果不烤一下,也用不了。如何编译呢?这要回到上一节说的 VSL 脚本管理器开发环境中, - 6 - 点击一下 按钮即可编译所有 VSL 脚本程序;或者按下 F7 键编译当前选中 脚本;或者右键 按钮,在弹出的菜单中选择:Compile。如图 1.2.2_3 所 示: 图 1.2.2_3 恭喜你的第一个 VSL 脚本程序编译成功了(不可能不成功,因为我们使用的是默认的 代码),这时 变成了 表示编译成为 Debug 版本的 VSL 脚 本程序。要运行它,只要点击一下 按钮就可以了,但这个时候你看不到任何 反应,因为我们没有添加任何一行代码,所以它什么也不会为我们做。现在我们添加一句 代码,让它运行起来有点反应: void main() { // Insert your code here bc.OutputToConsole("I am VSL!"); } 这句代码的意义是往控制台打印一句话―I am VSL!‖。运行该脚本后,你会发现在 Virtools 控制台出现了一句―I am VSL!‖的句子,这正是我们预期的结果。你也可以在 Virtools 脚本 中连线到 VSL 脚本 BB,按正统方式来运行。 1.2.3 一个带输入输出的 VSL 脚本程序 在前边我们成功创建了一个 VSL 脚本程序,但实际使用中的 VSL 脚本程序通常是有 多个输入参数和输出参数的,而且有多个输入输出端口,就像普通的 BB 那样,如下图: 图 1.2.3_1 下面让我们来一步一步的实现这个 VSL 脚本。首先我们要创建一个 virtools 脚本,然 后在这个 virtools 脚本上,创建一个 VSL 脚本程序,如图 1.2.2_2 所示。可以看到目前这 个脚本程序只有一个输入端口,和一个输出端口,没有任何输入输出参数。然后我们进入 - 7 - 到 VSL 脚本管理器中,找到其中 pIn 项和 pOut 项,这里是增加和删除输入输出参数的地 方,右键 pIn 项,在弹出的菜单中,选择 add pIn 项,就会为该脚本添加一个输入参数了, 同理操作 pOut 项也可以添加一个输出参数。如下图 1.2.3_2 所示: 图 1.2.3_2 当添加一个输入和输出参数时,VSL 脚本管理器会给这些参数自动命名,如果你想重 命名只要右键需重命名的参数,然后选择 Rename 项即可,如果是删除选择 Delete 项即 可,见图 1.2.3_3。参数的类型是默认为 int 型。如果要改变参数类型,只要双击 int,然后 选择想要的类型即可,如图 1.2.3_4。 图 1.2.3_3 图 1.2.3_4 现在我们将输入参数重命名为 myIn,类型为 bool 型;输出参数重命名为 myOut 类型 为 String 型。如果你不明白其中的什么是 int?什么是 bool?什么是 String?不用着急,后 面我们会专门详细学习这个的,这里就先不用管它,因为我们此刻的目的不是弄明白这些, 而是创建一个带输入输出的 VSL 脚本程序。现在我们的输入输出参数已经准备好了,然后 给 VSL 程序脚本添上如下代码(先不要理会代码的意思),然后在 virtools 脚本中,添加 一个 Text Display 的 BB 和这个 VSL 程序脚本配合起来运行,如下图 1.2.3_5 所示: void main() { // Insert your code here if(myIn) { myOut="myIn is 1"; } else { myOut="myIn is 0"; } } - 8 - 图 1.2.3_5 编译 VSL 脚本程序,并运行 virtools 程序,你会发现在 Text Display 在屏幕上打印出: myIn is 0;然后我们去改变 Run VSL 这个脚本 BB 的输入参数的值,默认是 false 既不打 叉,改为 true 既打上叉,再运行,这时候 Text Display 这个 BB 在屏幕上打印的字符就变 为:myIn is 1。到这里我们已经学会了添加带有输入输出参数的 VSL 脚本程序。 下面我们来给这个脚本程序,再添加上一输入端口和一个输出端口。修改输入输出端 口的方法和修改输入输出参数的方法一样,只是类型是无法进行选择,默认为 bool 型,这 个类型是无法也是没有必要改变的,我们如法炮制后,添加如下代码(例子 1.2.3_1.cmo): void main() { // Insert your code here if(In) { if(myIn) { myOut="myIn is 1"; } else { myOut="myIn is 0"; } Out==TRUE; SecondOut= FALSE; } if(SecondIn) { myOut="I am the other port!"; Out=FALSE; SecondOut=TRUE; } } 对于上面的代码我会在讲到 if 语句的时候,进行详细的解释,现在先不管它。代码添 加完成并编译成功后,我们转到 virtools 脚本,并添加其他的一些 BB 和这个 VSL 脚本 BB 配合使用,以方便查看效果。如下图: - 9 - 图 1.2.3_6 在上图所显示的其他 BB,如果你都不熟悉,建议你先放下书本,去温习一下 virtools 的基础知识和技能,然后再回来继续向下学习。此刻运行这个 virtools 程序,然后间歇性 的按下空格键,你就会发现屏幕上打印的字符变化得更加生动了。 1.2.4 VSL 脚本程序的重命名、定位和删除 给 VSL 脚本重命名很简单,你可以在 Virtools 脚本中选择要重命名的 VSL 脚本 BB, 然后按下 F2 键,然后输入你的新名字即可的;或者通过右键,然后在弹出的菜单中选择 ―Rename‖,如图 1.2.1_2 所示;或者在 VSL 管理器中选择要命名的 VSL 脚本程序,通过 右键,然后在弹出的菜单中选择―Rename‖ 如图 1.2.1_3 所示。 如果想从 VSL 管理器中直接跳转到使用了该 VSL 脚本 BB 的 Virtools 脚本中去,也通 过图 1.2.1_3 的方法,右键选中某个 VSL 脚本,然后选择―Focus in Schematic‖即可。如 果 Virtool 脚本中多处都使用了这个 VSL 脚本,那这个方法只能定位到其中的一个 Virtools 脚本 BB 中去。 删除一个 VSL 脚本 BB 不能在通过 VSL 管理器来删除,既然它是 BB,就自然是按常 规的方法,到 Virtools 脚本中,选中要删除的 BB 来进行删除。注意如果 Virtool 脚本中多 处都使用了这个 VSL 脚本 BB,只有把这些 BB 都删除了,该 BB 的 VSL 脚本才会从 VSL 管理器中自动地消失。 1.2.5 VSL 快捷键使用 关于快捷键,由于个人喜好的关系,我只使用其中的几个而已,比如 F7、F9、Ctrl + C、 Ctrl + V 等,这里我还是把它们全列出来,毕竟每个人的喜好不同。  Shift + F4 –在 Debug 编辑模式中显示上一句错误代码的位置  F5 –执行脚本程序。  F6 – 停止脚本程序的执行。  F7 – 编辑当前 VSL 脚本程序。  Shift + F7 –编辑所有 VSL 脚本程序。  F9 – 插入一个断点。  F9 + Shift – 移除当前 VSL 脚本程序中所有的断点  F9 + Shift + Control – 移除全部 VSL 脚本的所有断点。  F10 –在 Debug 编辑模式中单步调试 VSL 脚本程序。 - 10 -  F11 –在 Debug 编辑模式中单步调试,所调用的函数体内的语句。  Shift + F11 –在 Debug 编辑模式中单步调试,所调用的函数体外的语句。  Ctrl + Pge Up/Pg Down –上翻/下翻查看代码。  Ctrl + C –复制。  Ctrl + V – 粘贴。  Ctrl + F –查找。  Ctrl + H – 替换。  Ctrl + G – 跳转到特定的行。  Alt + Enter – 全屏或窗口编辑模式切换。  Ctrl + M – 自动完成设置开关。  Ctrl + B – 自动关闭开关。 1.3 VSL 语法描述 1.3.1 VSL 的变量和常量 什么是常量?常量就是不可改变的量,它是一个常数。就像一个人在生下来那一刻, 是男是女就已经定了,无法再改变了(当然你可能会想到医学上的某些科技,请不要钻牛 角尖)。常量是有分类的,就像人分男女一样,常量分为:整型常量、浮点型常量、布尔型 常量、字符型常量、字符串常量。以下是举例说明: 整型常量: 0、98、21、-19589、-8、6543,3120291…… 浮点型常量:3.4、3.1415956、-555.123、-999.00、3256.98…… 布尔型常量:ture 或者 false、0 或者 1、TURE 或者 FALSE; 字符型常量:'A'、'Z'、'#'、'*'、';'…… 字符串常量:‖Virtools4.0‖、‖VSL 脚本程序设计‖…… 单独的常量如果存在于程序代码中,没有被使用,是没有任何意义的,连程序编译阶 段都无法通过。常量用得最多的地方就是给变量赋值。常量与变量相对,常量值不可变, 而变量值是可以变的。 什么是变量?变量的值是可以改变的量,与常量相对;变量就像一个抽屉,是用来装 东西的一种空间,只不过它装的是数据的值,而不是爸爸买给你的玩具罢了。变量分为整 型变量、浮点型变量、布尔型变量、字符型变量、以及字符串变量等;这些不同种类的变 量是用来存储不同类型的数据,就好像你的抽屉那样,有的抽屉放书,有的抽屉放衣服, 有的抽屉放玩具。 整型变量是: 用来存储整数的―抽屉‖, 用符号 int 表示。 浮点类型变量是:用来存储带有小数的实数的―抽屉‖, 用符号 float 表示。 布尔型变量是: 仅仅能够存储―0‖或―1‖的―抽屉‖,用符号 bool 表示。 字符型变量: 用来存储计算机字符集中的字符的―抽屉‖,用符号 char 表示。 字符串型变量: 用来存储一连串计算机字符集的―抽屉‖,用符号 str 表示。 要声明一个变量,还要给变量起一个名字,俗称变量名或者标识符,如果没有变量名 程序经无法分辩和使用这些变量,就好像你有几十个、几百个抽屉,如果你不给抽屉打上 标签,你就无法区分它们。声明发方式如下: int myint; // 声明了一个名为―myint‖整形变量。 float myfloat; // 声明了一个名为―myfloat‖浮点型变量。 bool mybool; // 声明了一个名为―mybool‖布尔型变量。 char mychar; // 声明了一个名为―mychar‖字符型变量。 - 11 - str mystr; // 声明了一个名为―mystr‖字符串型变量。 其中,int、float、bool、char、str 就是各自类型的说明符号;myint、myfloat、mybool、 mychar、mystr 就是变量名。变量名的命名不能随意,有一定的规则:  变量名只能由字母、数字和下划线三类字符组成。  第一个字符必须是字母。  大写字母和小写字母被认为是两个不同的字符,如 A 和 a 是两个不同的标识符。  变量名不能太长,太长会异常,而且可读性也差。  变量名不能是 VSL 的保留关键字。 其中保留的关键字如下,这些是不能做为变量名的: Main int short float char while do bool if for then else true false TRUE FALSE NULL null struct class enum extern shared static switch case default break continue return do bool static 1.3.2 VSL 的运算符 运算符是程序语言表达对数据,对变量的一种操作。无论是加减乘除还是大于小于, 都需要用到运算符。运算符又分不同种类有:赋值运算符、算术运算符、逻辑运算符、关 系运算符、自增自减运算符。 一、赋值运算符 赋值运算符用‗=‘表示,这里不是等于的意思,等于用‗==‘表示,请注意区分开了。 赋 值的意思是把某个常量或变量赋值给另一个变量。例如: int num; //声明一个整型变量。 num=21; //将一个整型常量赋值给这个整型变量。 int count = num; //声明了另外一个整型变量,并将整型变量 num 赋值给它。 float pi=0.0; //声明一个浮点类型变量,并设置其初始值为 0.0。 pi=3.14; //赋给这个浮点类型变量一个新的值。 bool enable=false;//声明一个布尔型变量,并设置其初始值为 false。 char ch; //声明一个字符型变量,没有赋初值。 ch=‘V‘; //将一个字符‘V‘赋值给这个字符型变量。 二、算术运算符 运算符就是四则混合运算里面常用的那些:包括加(+)、减(-)、乘(*)、除(/)、求余(或 称模运算,%)。例如: float pi=3.1415; //声明一个浮点类型变量,初值设为 3.1415。 float result=1+2* pi -10/2+99%10;//声明浮点类型变量,将四则混合运算的结果赋值给它。 三、逻辑运算符 逻辑运算符是根据表达式的值来返回真值或是假值;也可以用‗1‘来表示真值,用‗0‘来 表示假值。这里出现了一个新的概念:表达式;在后边我会具体的解释它,此刻你就先把 每一行语句当作一个表达式来理解吧。逻辑运算符有三种: ―&& ‖—— 逻辑与。当表达式进行―&&‖运算时只要有一个为假,总的表达式就为假, 只有当所有都为真时,总的式子才为真。 ―||‖——逻辑或。当表达式进行―||‖运算时,只要有一个为真,总的值就为真,只有当所 有的都为假时,总的式子才为假。 - 12 - ―!‖——逻辑非。当表达式进行―!‖运算时,是把相应的变量数据反转为相应的真或假 值。若原先为假,则逻辑非以后为真,若原先为真,则逻辑非以后为假。 例如: bool yes=5&&3; //―&&‖两边是非零的常量 5 和 3,所以 yes 的值为真即 ture; bool enable=-1||7;//―||‖ 两边也是非零的常量-1 和 7 所以 enable 的值为真即 ture; bool no=!yes; //―!‖ yes的值是真 ture,反转后得假即 flase; 四、关系运算符 关系运算符是对两个表达式进行比较运算,返回一个真值或是假值。包括大于(>)、小 于(<)、等于(==)、 大于等于(>=)、小于等于(<=)和不等于(!=)六种。这里要提醒你一下, 请不要混淆等于―==‖和赋值―=‖的概念。 五、自加自减运算符 自加运算符―++‖和自减运算符―--‖是一种特殊的运算符,对变量的操作结果是增加 1 和减少 1。例如: int count=10; int num=10; count--; // count 结果现在为 9 --count; // count 结果现在为 8 num++; // num 结果现在为 11 ++num; // num 结果现在为 12 在这个例子中,运算符―++‖和―--‖在变量的前面还是在后面对本身的影响都是一样的, 都是进行变量本身的减 1 或者加 1 的运算,但是当把它们作为其他表达式的一部分时,两 者就有区别了。如果运算符放在变量前面,那么在运算之前,变量先完成本身的自加或自 减运算后,然后再与表达式进行运算;如果运算符放在变量后面,变量先完成与表达式进 行运算,然后再自身进行自加自减运算。例如: int result,answer; //声明两个整形变量 result 和 answer int count=10; //声明一个整形变量 count 并赋初值为 10。 int num=10; //声明一个整形变量 num 并赋初值为 10。 result=-- count; //result 的值为 9,count 的值为 9。 answer= num--; //answer 的值为 10,num 的值为 9。 你是否已经看明白了呢?让我再来解释一下吧。语句―--count‖的自减运算符―--‖在变量 ―count‖的左边,那么它是先完成本身的自减运算(既本身原来是 10,自减 1,结果为 9), 然后将这个结果在通过赋值运算符 ―=‖赋值给 result,所以 result 的值为 9。而语句―num--‖ 的自减运算符―--‖在变量―num‖的右边。那么他是先通过赋值运算符 ―=‖赋值给 result(既 num 本身是 10,赋值给 answer,所以 answer 的值也是 10),然后变量―num‖在完成本身 的自减运算(既本身原来是 10,自减 1,其结果也为 9)。 五、复合赋值运算符 前面已经提到了赋值运算符,现在又来个复合赋值运算符,两者有什么区别呢?其实 它实际上就是一种缩写形式,使得对变量的改变更为简洁。比如: Total=Total+3; Total+=3; 上面两个表达式的意思都是:变量 Total 的值加 3,然后在赋值给变量 Total。在 VSL 中这 - 13 - 样的复合赋值运算符一共有 4 个,既: 符号 功能 例子 意思 += 加法赋值 x+=y x=x+y -= 减法赋值 x-=y x=x-y *= 乘法赋值 x*=y x=x*y /= 除法赋值 x/=y x=x/y 1.3.3 VSL 的表达式与语句 表达式是由常量、变量、运算符组合(到后面讲函数时,函数也可以是组成表达式的元 素),计算以后返回一个结果值。表达式的结束标志是分号(;),VSL 语言表达式基本遵循一 般代数规则,其所有的语句和声明都是用分号结束,在分号出现之前,语句是不完整的。 带着这个概念回头看一下前边的代码,发现我们已经不知不觉已经运用了很多。 表达式本身不做什么事情,只是返回结果值。在程序不对返回的结果值做任何操作的 情况下,返回的结果值不起任何作用,表达式的作用有两点,一个是放在赋值语句的右边, 另一个是作为函数的参数(往后会介绍)。例如: int result; //声明一个变量。 4+6; //一个无意义的表达式―4+6;‖,因为它没有被使用。 result=4+6; //通过赋值语句给 result 赋值,赋的值是表达式―4+6;‖返回的结果既//10,这 样表达式就变得有存在的意义了。 表达式返回结果的值是有类型的。表达式隐含的返回数据类型取决于组成表达式的变 量和常量的类型。因此,表达式的返回值有可能是整型,或者是浮点型,或者是布尔类型, 或者是字符型。如果在一个表达式中,既有浮点型数据、又有整数型数据和字符型数据的 话,就会有类型转化的问题了,类型转化的原则是从低级向高级自动转化(VSL 脚本类型不 允许强制类型转换)。VSL 脚本语言的转换顺序基本是这样的: 字符型-->整型; 整型-->浮点型; 字符型和浮点型之间不存在精度转换。 就是如果字符型和整型在一起运算时,结果为整型,如果整型和浮点型在一起运算, 所得的结果就是浮点型,如果字符型和浮点型在一起运算时,结果为浮点型。例如(例子 1.3.3_1.cmo): void main() { // Insert your code here char ch='A'; //声明一个字符型变量ch并设初值为'A' int result=0; //声明一个整型遍历result并设初值为0 float answer=0.0; //声明一个浮点型遍历answer并设初值为0.0 result=ch; //字符型变量合法赋值给整型变量 answer=ch; //字符型变量非法赋值给浮点型变量 bc.OutputToConsole(result); //控制台打印结果为:65。结果正确 bc.OutputToConsole(answer); //控制台打印结果为:0.000000。结果错误 answer=result; //整型变量合法赋值给浮点型变量 bc.OutputToConsole(answer); //控制台打印结果为:65.000000。结果正确 - 14 - result=(ch/2+result)*2+answer/2; //字符型和浮点型都参与运算的一个非法表达式 bc.OutputToConsole(result); //控制台打印结果为:1107427522。结果正确 result=ch; //再一次将字符型变量合法赋值给整型变量 result=(ch/2+result)*2; //字符型和浮点型没有混合的一个合法表达式 bc.OutputToConsole(result); //控制台打印结果为:194。结果正确 //当编译这一条语句时,VSL管理器会提示一条表示精度发生了变化的警告: //Waring: conversion from ―float‖ to ―int‖。 //对于一下数值敏感的地方会导致不理想结果。而在这段程序则无关紧要。 result=answer/3; //浮点型变量合法赋值给整型变量 bc.OutputToConsole(result); //控制台打印结果为:21。结果正确 result=ch+32; //浮点型变量和整数运算后赋值给整型变量 ch=result; //整型变量合法赋值给字符型变量 String temp; //这后面的语句将在后面进行解释。 temp.Resize(1); temp[0]=ch; bc.OutputToConsole(temp); //控制台打印结果为:a。结果正确 } 1.3.4 VSL 的赋值语句和声明语句和注释 这三个概念在前面的脚本代码中已经不知不觉的大量用到了,没什么复杂的地方可言, 赋值语句很点像在求解代数方程;而声明语句则是声明变量的类型;注释就是每一行―//‖ 两个斜杠号后面的文字描述,如果存在多行的注释也可用―/*‖和―*/‖这两个组合符号把注释 的文字给框起来。例如: /*这个是个演示注释,赋值语句和声明语句 使用的例子*/ float pi; //声明语句声明了一个浮点类型变量 pi=2*3.1415; //赋值语句给浮点类型变量进行赋值。 通过逗号分隔可以在一行内连续声明几个变量,这样虽然可减少代码的行数,但是就 不好在代码后面标上注释了;另外在声明变量的时候,也可以直接给变量赋值,这叫做变 量的初始化,声明变量以后,马上赋初值是个好习惯。例如: float pi,pi02,mylenght,myheight,mywide; //一个声明了多个变量的声明语句 char ch='A',oh=‘a‘,yeah=‘}‘; //声明变量的同时,进行初始化 1.3.5 VSL 的条件判断语句 什么是条件判断语句,这种语句就像是我们考试的时候写的判断题,特别是那些什么 《思想品德》和《思想政治》这类课程的考试卷中判断题出现得最多,答题就是在后边括 号内打个勾或者打个叉。只不过在 VSL 脚本程序中,不是在括号内打个勾或者打个叉打而 已,而是在括号内通过一个表达式或者一个变量、一个值(其实严格的说括号内的值或者 变量都属于表达式)来表示是真还是假(既―打个勾‖还是要―打个叉‖)。 在程序中我们可以 用 true、TRUE 或非 0 的值来表示―真‖,用 false、FALSE 或 0 的值来表示假。 条件判断语句有好几种形式下面让我细细说来。 - 15 - 一、if 语句 if(表达式) 语句 1; 如果表达式的值为非 0,则执行语句 1,否则跳过语句继续执行下面的语句。如果语 句1有多于一条语句要执行时, 必须使用"{"和"}" 把这些语句包括在其中, 此时条件语句形 式为: if(表达式) { 语句体 1; 其他语句; } 例如: 功能:根据输入的一个整数判断其大小,如果大于等于 0,把这个值赋给 result,然后 像控制台输出 Greater or Equal zero;如果是小于 0,就值赋给 answer,然后输出 Less than zero。代码如下(例子 1.3.5_1.cmo): void main() { // Insert your code here int result,answer; if(num>=0) //if语句判断是真是假 { result=num; bc.OutputToConsole(result); bc.OutputToConsole("Greater or Equal zero"); } if(num<0) //if语句判断是真是假 { answer=num; bc.OutputToConsole(answer); bc.OutputToConsole("Less than zero"); } } 新建一个 VSL 脚本程序,然后添加上述代码并编译成功,将参数填个大于或者等于 0 的整数比如 87 并运行,会发现控制台上会打印出 87 和 Greater or Equal zero 的信息;如 果填入小于 0 的整数比如-87 并运行,会发现控制台上打印的是-87 和 Less than zero 的信息。这就是 if 语句的作用,是一个判断―是‖还是―不是‖的语句 。 二、if--else 语句 除了可以指定在条件为真(即非 0)时执行某些语句外,还可以在条件为假(即 0) 时执行另外一段代码。if--else 语句就可以达到这个目的。 if(表达式) 语句 1; else 语句 2; 同样,当语句 1 或语句 2 是多于一个语句时,需要用{}把语句括起来。 例如: 功能:和刚才上面拿一段程序一样。代码如下(例子 1.3.5_2.cmo): - 16 - void main() { // Insert your code here int result,answer; bool enable; //定义一个bool型变量。 enable=num>=0; //将表达式的值赋给这个变量 if(enable) //在if语句中直接判断bool变量,程序可读性更好。 { result=num; bc.OutputToConsole(result); bc.OutputToConsole("Greater or Equal zero"); outStr= "Greater or Equal zero"; } else //假如enable为0即false { answer=num; bc.OutputToConsole(answer); bc.OutputToConsole("Less than zero"); outStr="Less than zero"; } } 新建一个 VSL 脚本程序,然后添加上述代码并编译成功,运行你会发现运行的结果和 前一段程序是一模一样的,但是逻辑上的思考方式将不一样了,前一段程序的思维模式是: 也许是这个,也许是那个;而这段程序思维模式是:不是这个,将就是那个。不要觉得这 些思考很多余,实际的问题会比这些复杂得多。 三、if--else if--else 结构。 if(表达式 1) 语句 1; else if(表达式 2) 语句 2; else if(表达式 3) 语句 3; …… else 语句 n; 这种结构是从上到下逐个对条件进行判断,一旦发现条件满点足就执行与它有关的语 句, 并跳过其它剩余阶梯;若没有一个条件满足,则执行最后一个else 语句 n。最后这个else 常起着"缺省条件"的作用。同样,如果每一个条件中有多于一条语句要执行时,必须使用"{" 和"}"把这些语句包括在其中。 例如: 功能:根据输入的一个整数判断其大小,如果小于 0,向控制台输出 Less than Zero; 如果是等于 0,向控制台输出 Equal Zero ;如果是大于 0 而小于 10,向控制台输出 Greater than Zero and Less than ten;如果是其余情况(即大于等于 10),向控制台输出 Greater or Equal ten。代码如下(例子 1.3.5_3.cmo): - 17 - void main() { // Insert your code here if(num<0) { //小于0时候,执行这段。 bc.OutputToConsole("Less than zero"); outStr="Less than zero"; } else if(num==0) { //num不是小于0,而是等于0时,执行这段 bc.OutputToConsole("Equal zero"); outStr="Equal zero"; } else if(num>0&&num<10) { //num即不小于0,也不等于,而是在大于0小于10之间,执行这段 bc.OutputToConsole("Greater than Zero and Less than ten"); outStr="Greater than Zero and Less than ten"; } else { //num不属于以上所有情况,就执行这段 bc.OutputToConsole("Greater or Equal ten"); outStr="Greater or Equal ten"; } } 看了上面的代码注释,你是否发觉到是不是有点白痴,有点多余呢!其实我这之所以 写,是为了模拟计算机在执行分析代码时候的过程,是为了更深刻的描述这种判断语句的 特点。建立一个 VSL 脚本程序,然后添加上述代码并编译成功。然后分别将输入参数的值 设置为-8,0,8,80 各个情况运行一次,你会发现到代码执行的结果根据输入变量的值的变化 而变化。 四、if 判断语句的混合嵌套使用。 这种使用方法,是普遍存在的,其实在第 1.2.3 小节―一个带输入输出的 VSL 脚本程序‖ 的章节中已经使用到了,当时我没有解释代码的意思,特地的留到这来说,现在为了方便 叙述,再 copy 过来进行详细的叙述。代码如下(例子 1.2.3_1.cmo): void main() { // Insert your code here if(In) //从该VSL脚本BB的第一个输入端口触发执行脚本 { - 18 - if(myIn) //在if语句里面又嵌套了一个if—else结构 { myOut="myIn is 1"; //如果myIn为真,输出参数赋值为myIn is 1 } else { myOut="myIn is 0"; //如果myIn为假,输出参数赋值为myIn is 0 } Out=TRUE; //激活第一个输出端口 SecondOut= FALSE; //关闭第二个输出端口 } //因第一个输入端口触发执行脚本的部分到这结束 if(SecondIn) //从该VSL脚本BB的第二个输入端口触发执行脚本 { //无论myIn为真为假 myOut="I am the other port!"; //输出参数都是赋值为I am the other port! Out=FALSE; //关闭第一个输出端口 SecondOut=TRUE; //激活第二个输出端口 } //因第二个输入端口触发执行脚本的部分到这结束 } 学习了 if 判断语句以后,回头看这代码其实很简单。假如从该 VSL 脚本 BB 的第一个 输入端口进入,就执行 if(In){……}这个花括号内的代码。如果是第二输入端口进入就执行 if(SecondIn){……}的花括号内的代码,每个花括号里面的代码都处理各自的情况。你也许 已经注意到了 Out=TRUE;和 SecondOut= FALSE 这两句代码,和前面的代码很少出现它, 这是 VSL 脚本程序控制输出端口的方法,要哪个输出端口―开‖就让哪个端口为 TRUE,反 之亦然。上面的程序是当从第一个输入端口进,就从第一个输出端口出;当从第二个输入 端口进,就从第二个输出端口出。 五、switch--case 语句 在进行 VSL 脚本编程的时侯, 经常会碰到根据不同情况做出不同的处理程序,就好像是 大家一起去看电影,各自拿着自己的票进行对号入座那样。 这种情况可用嵌套 if -else-if 语句来实现, 但 if-else-if 语句使用不方便, 并且容易导致程序可读性降低,程序容易出错。 VSL 提供了一个 switch 判断语句。语句的格式为: switch(变量) { case 常量 1: 语句 1 或空; case 常量 2: 语句 2 或空; …… case 常量 n: 语句 n 或空; default: 语句 n+1 或空; } 执行 switch 判断语句时,将变量逐个与 case 后的常量进行比较,若与其中一个相等,则执 - 19 - 行该常量下的语句,若不与任何一个常量相等,则执行 default 后面的语句。其实 switch 判断 语句是可以用 if 语句代替的,switch 判断语句一般出现在比较整的情况下或者能转化成比 较整数的情况下使用。 注意:  switch 中变量可以是数值,也可以是字符,但必须是整数。  可以省略一些 case 和 default。  每个 case 或 default 后的语句可以是语句体,但不需要使用{和}括起来。 例如: 功能:输入一个学生的成绩,然后给这个成绩等级(这是学校常干的事),大于等于 90 分的为'A'等,80-89 的为'B'等,70-79 为'C'等,60-69 为'D'等,60 分以下为'E'等。并将 结果输出。代码如下(例子 1.3.5_4.cmo): void main() { // Insert your code here if(ifIn) //从输入端口一触发脚本,执行的是if判断语句进行分级 { if(Score>=90) bc.OutputToConsole("A"); else if(Score>=80&&Score<90)bc.OutputToConsole("B"); else if(Score>=70&&Score<80)bc.OutputToConsole("C"); else if(Score>=60&&Score<70)bc.OutputToConsole("D"); else bc.OutputToConsole("E"); } if(switchIn) //从输入端口二触发脚本,执行的是switch判断语句进行分级 { int num=Score/10; switch(num) { case 10: case 9: bc.OutputToConsole("A"); break; case 8: bc.OutputToConsole("B"); break; case 7: bc.OutputToConsole("C"); break; case 6: bc.OutputToConsole("D"); break; default: bc.OutputToConsole("E"); - 20 - break; } } } 新建一个VSL脚本程序,然后增加一个输入端口并命名为switchIn,将默认的输入端口 重命名为ifIn;再加入输入参数Score,类型为float型即浮点型变量,编译并运行。从不同的 输入端口触发,并改变输入参数的值,得到的结果是一致的,但用switch语句,程序可读性 更强,逻辑更清晰。请注意观察上面的代码,你会发现并不是每个case里面有都语句,有 时侯里面是空的。switch语句执行的顺序是从第一case判断,如果正确即判断结果为真就往 下执行,直到break;如果不正确,就执行下一个case。所以在这里,当成绩是100分时, 执行case 10:然后往下执行,bc.OutputToConsole("A");break;然后跳出switch判断语句。 再看这里用一句int num=Score/10,它将输入的浮点数比如93,经过除10,等到了这个分 数的十位数,再传给switch语句进行判断。Score/10的答案是9.3是一个浮点数,把它赋值 给整型变量num,后面的0.3丢失了。这就是精度丢失,在前面讲VSL的变量和常量的章节 中说到这个,这里在复习一下。 1.3.6 VSL 的循环和控制语句 很多情况下我们经常需要执行重复的任务,比如将一句话在屏幕上打印 100 次;将一 个数从 1 一直加到 1000 等等。使用循环语句可以方便的实现这样的任务。VSL 提供三种 基本的循环语句:for 语句、while 语句和 do-while 语句。 一、循环语句 (一)、for循环 它的一般形式为: for(<初始化>;<条件表达式>;<增量>) 语句体; for循环为执行重复的操作提供了循序渐进的步骤,他的组成部分完成下面的步骤:  设置初始值,其实是一个赋值语句,它用来给循环控制变量赋初值。  条件表达式进行测试,检查循环是否应当继续进行,还是结束循环。  语句体是循环执行的操作。  增量是被用于更新测试循环的值 一个for循环的这三个部分之间必须用―;‖分开。 例如: for(int i=1;i<=10;i++) 语句; 上例中先给i赋初值1,判断i是否小于等于10,若是则执行语句,之后i值增加1。再重 新判断,直到条件为假,即i>10时,结束循环。 注意: (1).for循环中语句可以是包含了多条语句的语句体,但要用{和}将参加循环的语句括起 来。例如(例子1.3.6_1.cmo)用for循环求1+2+……+1000的和: void main() { // Insert your code here int result=0; for(int i=1;i<=1000;i++) result+=i; //1+2+……+1000 - 21 - bc.OutputToConsole(result); } (2).for 循环中的初始化、条件表达式和增量都是选择项,即可以缺省,但―;‖不能缺省。 省略了初始化,表示不对循环控制变量赋初值。省略了条件表达式,而不做其它处理时便成 为死循环。省略了增量,则不对循环控制变量进行操作,这时可在语句体中加入修改循环控 制变量的语句。例如(例子1.3.6_2.cmo)― void main() { // Insert your code here int i=0; for(;i<10;i+=2) //省略了初始化赋值语句的for循环 bc.OutputToConsole(i); bc.OutputToConsole("=======for========="); i=1; for(;i<10;) //省略了初始化赋值语句和增量语句的for循环 { bc.OutputToConsole(i); i+=2; } } (3).for循环可以有多层嵌套。 例如(例子1.3.6_3.cmo): void main() { // Insert your code here int result=0; for(int i=0;i<6;i++) //第一层for循环 { bc.OutputToConsole("=================="); for(int j=0;j<6;j++) //第二层for循环 { result=i*10+j; bc.OutputToConsole(result); } } } (二)、while循环 它的一般形式为: while(条件表达式) 语句; while循环是没有初始化和没有增量更新部分的for循环,它只有测试条件和循环体。 while循环先执行括号内的条件表达式,如果条件为真时,便执行循环体内的语句,就像for 循环一样,执行完后,程序返回到条件表达式,对这个表达式进行重新计算,直到该条件为 假才结束循环。并继续执行循环程序外的后续语句。例如(例子1.3.6_4.cmo): void main() - 22 - { // Insert your code here int result=0; //初始化result的值 int i=0; //初始化i的值 while(i<=1000) //while循环的测试条件 { result+=i; //循环体 i++; } bc.OutputToConsole(result); } 上例中,while循环是检查i的值是否小于或等于1000,因其事先被初始化为0,所以条 件为真,进入循环使得result和i的值不断累加;当i的值大于1000,即 ―i<=1000‖的条件为假, 循环便告结束;跳出循环后,执行循环体外的语句bc.OutputToConsole(result);便向控制台 打印出计算结果。与for循环一样,while循环总是在循环的头部检验条件,这就意味着循环 可能什么也不执行就退出,比如一开始i的值设为1001,条件表达式―i<=1000‖一开始就为假, 就没有进入循环,经直接执行打印语句了。 其实for循环和while循环几乎是等效的,所以究竟要使用哪一个,是个人喜好的问题范 畴了。程序员使用for循环来进行循环计算的,因为它的格式允许将所有相关的信息——(< 初始化>;<条件表达式>;<增量>)——放在同一处,有利于程序的可读性;而在while循环一 般是在无法预见循环将会执行多少次的情况下才使用。在设计循环语句时,要注意以下几 点:  要清楚循环终止的条件。  在首次测试之前进行初始化条件。  在条件被再次执行之前,要先更新条件。 (三)、do--while循环 它的一般格式为: do { 语句块; } while(条件表达式); 例如(例子1.3.6_5.cmo) void main() { // Insert your code here int score=10; do { bc.OutputToConsole(score); score--; } while(score>=0); //出口条件循环 bc.OutputToConsole("Less than Zero"); } - 23 - 这个循环与while循环和for循环不同,它属于出口条件循环。这就意味着它先执行循环 体中的语句,然后再判断条件是否为真,如果为真则继续循环;如果为假,则终止循环。因 此,do-while循环至少要执行一次循环语句。而while循环和for循环它们属于入口条件循环, 是先进行条件判断以后再决定是否执行循环体的语句,所以它们有可能什么也不执行就跳出 的情况。通常入口条件循环比出口条件循环好,因为入口条件循环在循环开始前就对条件进 行检测,但有些情况下使用do-while循环更合理。例如,等待用户输入一个考试成绩分数时, 程序必须先获得输入的分数,然后对它进行测试,直到输入一个合理的分数为止。 二、循环控制 (一)、break语句 break语句通常用在循环语句和开关语句中。当break用于开关语句 switch中时,可使 程序跳出switch而执行switch以后的语句;break在 switch中的用法已在前面介绍过了,这 里就不再重复。当break语句用于do-while、for、while循环语句中时,可使程序终止循环而 执行循环后面的语句,通常break语句总是与if语句联在一起。即满足条件时便跳出循环。例 如(例子1.3.6_6.cmo): void main() { // Insert your code here int result=0; for(int i=0;i<1000;i++) //一个标准的for循环 { if(i==101)break; //如果i等于101,就跳出循环 result+=i; //通过break语句跳出循环体,这一句没被执行 } bc.OutputToConsole(result); } 可以看出,result最终的结果是1+2+……+100。因为在i等于101的时候,就跳出循环 了。另外在多层循环中,一个break语句只向外跳一层。例如(例子1.3.6_7.cmo): void main() { // Insert your code here int result=0; for(int i=0;i<5;i++) //第一层for循环 { bc.OutputToConsole("================"); for(int j=0;j<5;j++) //第二层for循环 { if(i==3)break; //不打印30段的数 result=i*10+j; bc.OutputToConsole(result); //把每次循环的结果打印到控制台 } } } 当执行到i等于3时,执行了break语句,跳出到外层的循环,i变为4,所以就打印不出 - 24 - 30段的数了。 (二)、continue语句 continue语句的作用是跳过循环体中剩余的语句而强行执行下一次循环,但不会跳过循 环的更新判断表达式。在for循环中,continue语句是程序直接跳到条件表达式处,然后在按 常规执行增量表达式,不过对于while循环来说continue将直接跳到条件表达式处,因此while 循环体语句中位于continue之后的语句将被跳过,在某些情况下这将会引发可能的错误。 continue语句只用在for、while、do-while等循环体中, 常与if条件语句一起使用,用来加速循 环。例如(例子1.3.6_8.cmo): void main() { // Insert your code here int result=0; for(int i=0;i<5;i++) //第一层for循环 { bc.OutputToConsole("=========="); for(int k=0;k<5;k++) //第二层for循环 { if(k==2)continue; //不打印个位数为2的数 result=i*10+k; bc.OutputToConsole(result); //把每次循环的结果打印到控制台 } } } 从程序中可以看出,continue语句只是当次循环后面的语句没有被执行,也就是从 continue处重新开始接着执行下次循环。 1.3.7 数组和多维数组 一、一维数组的声明 声明数组的语法为在数组名后加上用方括号括起来的维数说明。下面是一个整型数组 的例子: int array[10]; 这条语句定义了一个具有10个整型元素的名为array的数组。这些整数在内存中是连续 存储的。数组的大小等于每个元素的大小乘上数组元素的个数。方括号中的维数表达式可以 包含运算符,但其计算结果必须是一个长整型值。这个数组是一维的。 下面这些声明是合法的: int offset[5+3]; float count[5*2+3]; 下面是不合法的: int n=10; int offset[n]; /*在声明时,变量不能作为数组的维数*/ 二、用下标访问数组元素 int offset[10]; 表明该数组是一维数组,里面有10个数,它们分别为offset[0],offset[1],……offset[9]; 千万注意,数组的第一个元素下标从0开始。一些刚学编程的人员经常在这儿犯一些错误。 - 25 - offset[3]=25; 上面的例子是把25赋值给整型数组offset的第四个元素。在定义一个数组指定其元素数目 的时候,必须使用常数(如10),但不能使用变量(int offset[m]),否则会发生错误。数组 的很多用途都源自使用下标或索引来对各个元素进行编号和访问。在赋值的时候,可以使用 变量作为数组下标。例如(例子1.3.7_1.cmo) void main() { // Insert your code here int result[5]; for(int i=0;i<5;i++) { result[i]=i; //给整形数组的单元值进行赋值 bc.OutputToConsole(result[i]); } bc.OutputToConsole("========================"); for(int j=4;j>=0;j--) { bc.OutputToConsole(result[j]); //向控制台打印整形数组的每个单元值。 } } 题目的意思是定义了一个5个长度的整形数组,然后分别给数组成员赋值,最后反向把 数组打印到控制台。这样要注意一点,VSL管理器不会检查下标是否有效,如果将一个值赋 给不存在的元素result[8], VSL管理器在编译的时候不一定会指出错误,但在程序运行的时候, 这样的操作会引发问题,可以破坏数据和代码,也可能导致程序异常终止。所以必须使用有 效的下标值。例如(例子1.3.7_2.cmo)一个典型的错误是: void main() { // Insert your code here int result[5]; for(int i=0;i<=5;i++) { result[i]=i; //当i等于5的时候result[5]是一个存在的元素。 bc.OutputToConsole(result[i]); } } 这里再次强调一下数组的第一个元素下标从0开始,为什么会这样呢?这个没得说的就是 这样,所以请注意一个数组的最后一个元素的索引比数组的长度小1。 三、数组的初始化 前面说过,变量可以在定义的时候初始化,但数组则不可以进行初始化,例如。 int array[3]={1,2,3}; 这条语句在C语言执行是没问题的,但在VSL中,则会编译出错。虽然VSL是类C语言, 但不是完全和C语言类似,毕竟有自己的特色。要初始化VSL数组只能对数组的每个元素进 行初始化,例如: int array[3]; - 26 - array[0]=1; array[1]=2; array[2]=3; 既然有整形数组,自然也就有字符数组,浮点型数组,bool型数组,例如: float myfloat[10]; char mych[10]; bool myenable[10]; 同样地想初始化他们,也必须按上面的法则进行初始化。 四、多维数组 有时,数组的维数并不止一维,例如一个记录消费中心在第一季度里各个月的收入数 据就可以用二维数组来表示。定义二维数组的方法是在一维数组定义的后面再加上一个用方 括号括起来的维数说明。例如: float array[3][8]; 实际上,这个数组可以看成3个连续的一维数组,每个一维数组具有8个元素。该数组在内 存中的存储格式为最左边的维数相同的元素连续存储,也即按行存储的。首先存储第一行8 个元素,其次是第二行,最后是第三行。请运行下面的代码(例子1.3.7_3.cmo),你会可以 看出,二维数组元素是按行存储的。 void main() { // Insert your code here int array[3][4]; for(int i=0;i<3;i++) for(int j=0;j<4;j++) array[i][j]=j*10+i; //给二维数组的元素进行赋值 for(int i=0;i<3;i++) { bc.OutputToConsole("============"); for(int j=0;j<4;j++) bc.OutputToConsole(array[i][j]); //打印数组的每一个元素 } } - 27 - 第二章 VSL 的函数 函数不管是哪种语言,都会涉及到的这个概念,它是一个重要的不可缺少的东西。通 常一些功能强大,关系复杂的项目,会采样“化整为零”的方法把这些功能划分成一个个 小功能,并将这些小功能分别写成各自的函数来实现,这就是“至上而下”的开发方式。 这样做不但可以反复利用、减少编写代码量,还可以增加可读性,方便日后更好的维护。 在这一章的学习中你会掌握以下几点:  声明和调用函数。  函数参数的传递和值返回。  函数的递归调用。  变量的作用域和存储类型。  使用VSL的自带函数。 2.1 函数的定义与调用 2.1.1 什么是函数 每个VSL脚本程序的入口和出口都位于函数main()之中,这在你创建脚本的时候,VSL 管理器就自动帮你添加上了。main()函数可以调用其他函数,这些函数执行完毕后又返回到 main()函数中,main()函数不能被其他的VSL脚本程序函数所调用。函数调用发生时,立即 执行被调用的函数,而调用者则进入等待状态,直到被调用函数执行完毕。 除了自己定义函数外,Virtools本书已经定义了一些默认自带函数,你可以在帮助文档 中看到,你也应该去了解它们,它们是已经编译好的函数,只需正确的调用这些函数即可, 对于这些自带的库函数我们一般当作―黑箱‖处理,并不关心它内部的实现细节。例如,一个 自带的函数sqrt(),它返回平方根,假如要计算16的平方根,并将这个计算的结果赋值给变 量x,则可以在程序中这样写: x=sqrt(16); //16的平方根是4,并将这个结果4赋值给变量x。 表达式sqrt(16)将调用sqrt( )函数。表达式sqrt(16)被称为函数调用,被调用的函数叫做 被调函数,包含函数调用的函数叫调用函数。括号中的值(即16)是发给函数的信息,这 被称为传递给函数,这个值(即16)称为参数。同时,这个函数是有发送回来一个值,这 个值是可以赋值给变量的,这就返回值。简单一点说:参数是发给函数的值,返回值是从函 数中发送回来的值。 2.1.2 定义函数 按函数的归属来分可以分为:自带函数(或者说库函数)和自定义函数;按返回值来 分可以分为:有返回值函数和无返回值函数;按是否传递参数来分:有带参数的函数和不带 - 28 - 参数的函数等等。函数的定义包括函数头和语句体两部分。 函数头由三个部分组成: 函数返回值类型、函数名、参数表;一个完整的标准的函数 格式应该是这样的: 函数返回值类型 函数名(参数表) { 语句体; } 函数返回值类型可以是前面说到的某个数据类型、某个结构,关于结构的概念到以后 再介绍。函数名在程序中必须是唯一的,它也遵循标识符命名规则。记住不要使用系统保留 的关键字参数表可以没有也可以有多个。语句体包括局部变量的声明和可执行代码。在定义 一个函数的时候要注意,每个函数的定义都是独立的,平等的,不要在一个函数中定义另外 一个函数,这是不允许的。 在实际的应用中,你会发现函数的形式是多种多样的。有些函数需要多个信息,就要 定义多个参数,这些参数就要用逗号隔开,例如:setpos(int x,int y,int z);有些函数不需要任 何信息,也就没用任何参数传递,例如rand();等等。 2.1.3 函数的调用 要像调用一个函数,必须事先定义好该函数的返回值类型、参数类型和函数体,这和 使用变量的道理是一样的,要先有变量,好才能给变量赋值。例如(例子2.1.3_1.cmo): int myfun(int num) //这一个带返回值和参数的函数 { return 10*num; //将传入的整数乘以10以后,将结果做为返回值返回 } void main() { // Insert your code here int result=myfun(99); //这里调用了myfun函数,传递99这个参数,得到990的返回值。 bc.OutputToConsole(result); //把结果打印到控制台 } 函数myfun( )虽然短小简单,但它包含了全部的函数特性:有函数头和函数体;有接受 一个参数;有返回一个参数。可以说这个程序包含了一个样板函数以及一个标准的调用函数 的方式。在main( )函数中可以调用myfun( )函数,同理myfun( )函数也可以调用其他函数, 这也叫做嵌套调用,例如(例子2.1.3_2.cmo): int myfun02(int k) //这个函数将被函数myfun( )调用 { return k%3; } int myfun(int num) //这个函数将被函数main( )调用 { return 10*myfun02(num); } void main() { // Insert your code here - 29 - int result=myfun(98);//main( )函数调用myfun( ) bc.OutputToConsole(result); } 函数调用函数不是什么稀奇的东西,只不过用了个嵌套调用这个概念而已;如果是函 数自己调用自己那才有意思呢,其实这就是函数的递归调用,这种函数在特定的编程比如在 人工智能领域会用得很多,这里我只是简单地举个例子。一个函数如果自己调用自己,则被 调用的函数也将会调用自己,如果函数中没有一个终止调用的语句,那么会无限的调用下去, 直到程序死掉为止。这个终止的语句我们通常放在if语句当中。例如(例子2.1.3_3.cmo): int fun(int num) //这是一个递归函数 { if(num==1) //要一个结束递归调用的条件 return 1; //一个终止语句 else return num*fun(num-1); //递归函数调用自己本身 } void main() { // Insert your code here int k=5; int result=k*fun(k-1); //开始进行递归调用。 bc.OutputToConsole(result); } 递归调用的过程是一个有趣的过程,只要if(num==1)语句为false,每个fun( )调用都将 执行num*fun(num-1)这条语句,然后就再执行一个新的fun( )调用,而不会执行return 1这条 语句,直到某个调用的if(num==1)语句为true时,当前的调用将会执行语句return 1,不会再 执行语句num*fun(num-1),就这样结束了自己的运行,并将控制权返回给前一个调用函数, 依此类推。上面的程序fun( )函数进行了5次递归调用,语句num*fun(num-1)按照函数的调 用顺序被执行了5次,而return 1语句按函数调用相反的顺序被执行了5次。进入5层递归调 用后,程序将沿着进入的路径返回。 每个递归调用都将创建自己的一套变量,当程序达到第5次调用时,将有5个独立的num 变量,其中每个值并不一样,请注意观察。 2.1.4 函数的重载 函数的重载有时候也称为函数的多态。何为多态?术语多态指的是有多重形态,函数 多态即函数存在多种形式。术语函数重载指的是可以有多个同名的函数,因此可以理解为对 名称进行了重载。可以通过函数重载来设计一系列的函数,这系列的函数它们完成的是相同 的工作,但使用的是不同的参数。函数重载的关键是函数的参数列表,进行重载的函数它们 参数的数目和类型必须不同,而参数名是无关紧要的。例如(例子2.1.4_1.cmo): int add(int a, int b) // 两个整数相加 { return a + b; } float add(float a, float b) // 两个浮点数相加 { - 30 - return a + b; } int add(int a,int b,int c) //三个整数相加 { return a+b-c; } void main() { // Insert your code here float r1 = add(10.0,5.0); //与函数float add(float a, float b)相匹配 int r2 = add(1,2); //与函数int add(int a, int b)相匹配 int r3 = add(1,2,3); //与函数int add(int a,int b,int c)相匹配 bc.OutputToConsole(r1); //将输出到控制台 bc.OutputToConsole(r2); bc.OutputToConsole(r3); } 上面的程序中,当发生函数调用的时候,面对三个相同名字的函数,程序靠什么知道 掉用哪一个函数呢?靠的就是前边说的函数的参数列表。函数调用语句 add(10.0,5.0)中的 参数是两个浮点型,而函数定义中有带浮点参数的函数 float add(float a, float b),这个函 数最匹配,就调用它了;同样地,函数调用语句 add(1,2,3)中的参数是三个整型,最匹配 的函数定义是 int add(int a,int b,int c),即调用它了。 程序靠虽然函数重载看起来很有趣, 但使用不能太泛滥了,只有在函数基本上处理相同的作业,但必须要处理不同的参数时, 用函数重载就最合适了。 2.2 函数的参数及其返回值 2.2.1 形参和实参 函数的调用时把一些表达式作为参数传递给函数。函数定义中的参数是形式参数,简 称形参;函数的调用者提供给函数的参数叫实际参数,简称实参。在函数调用之前,实参的 值将被拷贝到这些形参中,这就是所谓的值拷贝传递;另外如果是对象(关于对象在后面会 介绍的)作为实参则会把这个值直接进行传递,还把值的拷贝传递,这就是值传递。请注意 一个是―值传递‖,一个是―值拷贝传递‖。这里我们先说按值拷贝传递的情况(值传递的情况 后面会介绍的),请看下面的例子(例子2.2.1_1.cmo): void fun(int num) //定义一个函数 { bc.OutputToConsole(++num); //形参num++先自加1,然后把结果输出到控制台 } void main() { // Insert your code here int k=5; fun(k); //调用函数fun( ),请注意实参的值 bc.OutputToConsole(k); //把实参的值输出到控制台 } - 31 - 在main函数中,先定义一个变量k并初始化为5,在 fun(k)这个函数中运算后输出。当程 序运行fun(k);这一步时,把k的值赋值给num,在运行程序过程中,把实参的值进行拷贝传 给形参,这就是函数参数的值拷贝传递,这时num的值经过++num变成了6,而k此时仍然 是5。 形参和实参可能不只一个,如果多于一个时,函数声明、调用、定义的形式都要一一 对应,不仅个数要对应,参数的数据类型和顺序都要也要对应。下面的这个例子中函数有两 个参数,一个是整型,一个是浮点型,那么在声明、调用、定义的时候,不仅个数一样,类 型和顺序也对应。如果不对应,有可能使得编译错误,即使没错误,也有可能让数据传递过 程中出现错误。例如(例子2.2.1_2.cmo): void fun(int num,float sce) { float result=sce/num; bc.OutputToConsole(result); } void main() { // Insert your code here int k=5; float n=10.5; fun(k,n); bc.OutputToConsole(k); } 2.2.2 函数值的返回 函数的返回值,在前面的例子中我们已经用得很多了,在VSL中允许的返回值类型有 以下几种: void //无返回类型,也就说没有返回值 char //返回字符类型 int //返回整形类型 float //返回浮点型类型 VSL enum //返回 VSL 枚举类型,在后面会介绍 VSL struct //返回 VSL 结构体类型 C/C++ struct/class //将来支持的类型,现在不支持 把这些符号放在函数名的前边,来表示该函数将会返回一个怎样类型的值,例如: int fun( ); //int 在 fun( )的前面用来表明这个函数将返回一个整数 float fun( ); //float 在 fun( )的前面用来表明这个函数将返回一个浮点数 char fun( ); //char 在fun( )的前面用来表明这个函数将返回一个字符 虽然有了一个表明函数要的类型的声明,但这是声明,要实际返回一个值还要在函数 体的语句中添加一个return语句。return语句 它的意思就是返回一个值。在VSL脚本语言中, return一定是在函数的最后一行。 ―例子2.1.4_1.cmo‖的程序中就有一个带返回值的函数, 它的函数体中有一个return语句;例子2.2.1_2.cmo的程序的函数就是一个没有返回值的函 数,它的函数体语句中没有return语句。调用函数的时候,由于函数有一个返回值,所以必须 要用变量接受这个返回值(不是绝对的),如果我们不用一个变量接受这个值,函数还照样返 回,只是返回的这个值没有被使用。 - 32 - 2.3 变量的作用域和存储类型 2.3.1 局部变量、全局变量和作用域 VSL程序的标识符作用域有三种:局部、全局、文件共享(Shared global variable)。 标识符的作用域决定了程序中的哪些语句可以使用它,换句话说,就是标识符在程序其他部 分的可见性。通常,标识符的作用域都是通过它在程序中的位置隐式说明的。 一、局部变量: 在一个函数内部定义的变量叫做局部变量,它只在本函数范围内有效,也就是说只有 在本函数内才能使用它们,在此函数以外是不能使用这些变量的。这就是所谓的―局部变量‖。 请看下面的例子(例子2.3.1_1.cmo): void fun(int num) { int m,n; m=n*num; m,n在此范围内有效 bc.OutputToConsole(m); } void main() { // Insert your code here int m,n; m=6; n=10; while(m>0) m,n在此范围内有效 { --m; int c=m*n; c在此范围内有效 bc.OutputToConsole(c); } } 主函数main中定义的变量m和n也只是在主函数中有效,而不会因为在主函数中定义的, 就可以在整个脚本程序中都有效;不同的函数中可以使用相同的变量,它们代表不同的的对 象,互不干扰,例如fun函数中定义的变量m、n和主函数main中定义的变量m、n,它们是 不同的,VSL是不会混淆它们的;函数的形参也是局部变量,不同的函数定义,使用相同名 的形参是可以的;在一个函数内部,在某个类型语句的语句体{ }中定义的变量,仅仅在该语 句体的{ }范围内有效,例如while(){ }这个―程序块‖里面定义的变量c。 二、全局作用域: 对于具有全局作用域的变量,它在当前的整个VSL脚本都有效,我们可以在程序的任 何位置访问它们。当一个变量是在所有函数的外部声明,也就是最好在程序的开头声明(为 了提高程序的可读性),那么这个变量就是全局变量。全局变量的作用是扩展了函数间的数 据联系的方式。由于同一个VSL脚本程序中的所有函数都能引用和改变全局变量的值,所以 如果其中的某一个函数改变了全局变量的值,就能影响到其他函数,相当于各个函数间有了 直接的传递通道。由于函数的调用只能带回一个返回值,所以有时可以利用全局变量加强这 种联系方式,从函数中得到一个以上的返回值。例如(例子2.3.1_2.cmo): - 33 - int result=0; //全局变量,在整个VSL脚本中都可以访问 int answer=0; int c=1; void fun() { c=result-answer; //c的值这里被改变了,这个值将会影响到其他函数的计算结果 bc.OutputToConsole(c); } void average() { c=(result+answer)/c; //c的值再一次被改变 bc.OutputToConsole(c); } void main() { // Insert your code here result=10; answer=5; fun(); average(); } 全局变量看起来可以大大省去参数传递和返回值的麻烦,但是如果大量的使用全局变 量,会给你的程序的维护带来更大的麻烦,全局变量带来程序的可读性差,通用性差,维护 成本高等要素,所以尽量少用全局变量。 三、全局变量和局部变量重名 在一个VSL脚本程序中,全局变量和局部变量同名,则在局部变量的作用范围内,全 局变量不起作用。例如(例子2.3.1_3.cmo): int m=6; int n=7; void main() { // Insert your code here int m=8; int n=9; 是局部变量m=8,n=9起作用 fun(); 而非全局变量m=6,n=7 bc.OutputToConsole(m); 全局变量m=6,n=7 bc.OutputToConsole(n); } void fun() { bc.OutputToConsole(m); bc.OutputToConsole(n); } 这是我估计使用重名的变量,请注意区分和弄明白其中的含义。 - 34 - 2.3.2 VSL 全局共享变量和全局静态变量 如果你有过C语言的编程经验,你会发现在声明局部变量时,在变量定义类型符号前 加上―static‖符号,那么这个变量即变成了静态存储变量,静态局部变量的作用域仍然是局限 于声明它的语句块中,但是在语句块执行期间,变量将始终保持它的值。而且,初始化值只 在语句块第一次执行时起作用。在随后的运行过程中,变量将保持语句块上一次执行时的值。 在VSL脚本程序中是没有局部静态存储变量这一个概念,取而代之的是全局共享变量和全局 静态变量。 全局共享变量用符号―shared‖标示,全局静态变量用符号―static‖表示,它们是VSL所特 有的一个特性,它们其实也就是一种全局变量,它有全局变量的所有特征,但又被赋予了新 的功能。例如: shared int a; // 声明一个全局共享变量 static float b; // 声明一个全局静态变量 void main() { shared int i;// 声明成局部的静态存储变量, static float j;// 编译虽没有错误,但执行结果不是预期的 } 声明这两种变量要在函数体外边声明,并且不能进行初始化赋值,如果你这样做了, 是编译通不过的,请不要试图在函数中声明这两种变量,虽然编译时不会提示出语法错误问 题,但是程序运行的时候是不会让你得到预期的结果的。例如: shared int a = 0; //错误,全局共享变量不能赋初值 static Vector v(1.0,2.0,3.0); // 错误,全局静态变量不能赋初值 void main() { static int b = 0; // 虽然编译不提示错误,但是声明语句本身存在问题 shared Vector w(-1,0,0); // 虽然编译不提示错误,但是声明语句本身存在问题 } 全局静态变量,可以在脚本执行完代码并离开脚本以后,然后再次触发进入脚本执行 代码,它的值仍然还在。例如(例子2.3.2_1.cmo): static int a; //声明一个全局静态变量 void main() { if (In) { // In is a boolean global variable a = 0; //第一次运行前要给此边赋初值 bc.OutputToConsole(a); } else { ++a; //再次进入脚本执行的语句 bc.OutputToConsole(a); } } 新建立一个VSL脚本程序,然后要添加一个输入端口,并输入以上代码并编译成功。 然后转到Virtools脚本中,添加两个―Key Evenvt‖BB,连接方式如下: - 35 - 图2.3.2_1 运行这个Virtools程序,先按下1键,让VSL脚本从端口一进入,给变量a初始化;然后 再不断按下2键。在Virtools控制台上会打印出不同的结果。假设去掉程序中的―static‖符号或 者不进行初始化,编译再运行看看是什么结果?全局共享变量要在第一次执行时进行初始化, 在随后的运行过程中,变量将保持语句块上一次执行时的值。 全局共享变量,它的作用域范围比全局静态变量更广,它可以在所有的相同或不同的 VSL脚本程序中都有效。但必须每个VSL脚本都加上―shared‖的定义。例如(例子 2.3.2_2.cmo): //这是第一个VSL脚本的代码 shared Vector pos; //声明了一个全局共享变量 void main() { // Insert your code here pos.Set(0,1,0.5);//给这个全局共享变量进行赋值 } //这是第二个VSL脚本的代码,它的声明和第一脚本代码是一样的 shared Vector pos; void main() //声明了一个全局共享变量 { // Insert your code here if (pos == pIn0) //对pos变量的操作 { bc.OutputToConsole("Equal"); } else { bc.OutputToConsole("No Equal"); } } 新建立一个VSL脚本程序,然后要添加一个输入参数,参数名使用默认,并输入以上 代码并编译成功。然后转到Virtools脚本中,连接成如下方式: - 36 - 图2.3.2_2 运行这个Virtools程序,在Virtools控制台上会打印出脚本执行的结果。这里要强调一点, 每个VSL脚本中的―shared Vector pos‖是不能缺少的,同时也不要漏掉―shared‖。你可以这 样试试,看是什么结果。 2.3.3 结构体和枚举类型 一、结构体 结构体和枚举类型放到这里来讲是因为它们一般都声明成全局变量来使用(起码我个 人的喜好是这样,否则我是不用的)。迄今为止,我们已经介绍了VSL基本类型的变量(如 整形、浮点型、字符型、布尔型等),拥有这些变量仍未能满足实际项目的需求,我们常常 觉得不够用,有时候需要将不同的类型的数据组合成一个有机的整体,以方便使用。比如, 一个产品的编号、名称、型号、重量、体积,出厂日期、产地等项,这些项都与一个产品编 号相联系。如果用独立的什么给个变量来表示这个概念,是难以反映这些项之间的关系,如 果把这些项组成一个组合项,这个组合项中包含了若干个不同的类型(也可以相同)的数据 项。这样就直观多了。例如: struct product { char[10] number; char[20] name; char[10] type; float weight; Vector cubage; char[20] date; char[50] address; }; 上面的记录就是一个结构体,它是用户自定义的一个类型,是一种更灵活的格式,正 因为它同一个结构可以存储多种类型的数据,这才使得一个产品的所有信息称为一个整体, 便于跟踪。定义一个结构体的一般形式为: struct structName(结构体名) { memberList(成员列表) }; 关键字struct表明,这些代码定义的是一个结构体的布局。标识符structName是这张数 据结构的名称,花括号内的是该结果的各个成员(或称分量),这些成员可以是前面说的基 本类型,也可以是另外的结构体,以及后面要说的类。结构体的使用例如(例子2.3.3_1.cmo): struct product //定义一个结构体 { str number; //声明一个字符串类型来存放产品编号 str name; //产品的名称 str type; //产品的型号 float weight; //产品的重量 - 37 - Vector cubage; //产品的体积 str date; //生产日期 str address; //产地 }; void main() { // Insert your code here product ark,ark02; //声明了两个自定义的结构体类型变量ark,和ark02 ark.number="20081203"; //给结构体成员变量的赋值 ark.name="myark"; ark.type="ship"; ark.weight=1000; ark.cubage.Set(1.0,1.0,1.0);//这个类型变量,后面会详细介绍 ark.date="2008-12-03"; ark.address="made in the world"; ark02=ark; //将一个结构体类型变量的值赋给另一个结构体变量 ark02.number="20090101"; if(ark02.weight==ark.weight) //对两个结构体变量的中的某个分量进行比较 bc.OutputToConsole("ark02.weight Equal ark.weight"); ark02.weight=2000; if(ark02.weight!=ark.weight) //对两个结构体变量的中的某个分量进行比较 bc.OutputToConsole("ark02.weight don't Equal ark.weight"); } 从上面的代码可以看出结构体的使用其实和普通的变量没什么大的区别,区别在于如 果访问结构体的成员变量,要访问结构体的分量必须用―.‖来进行访问。在结构体的分量中还 可以嵌套另外一个结构体,这种用法比较少,也不存在特别的意义,就不介绍了。 二、枚举 所谓―枚举‖是指将变量的值一一的列举出来,这个变量的值也就只能是被列出来的值的 范围内。如果一个变量仅仅有几种可能的的值,就适合用这种枚举类型,它常被用来定义相 关的符号常量,而不是新类型。VSL提供enum的句法来创建符号常量的的方式,使用enum 的句法与结构体相似,例如请看下面的语句: enum week { Mon, Tue, Wed, Thu, Fri, Sat, Sun}; 这条语句完成两个工作: 1、让 week 称为枚举类型。 2、将 Mon, Tue, Wed 等做为符号常量,称为枚举分量,它们对应整数值依次为 0 到 7。 在定义枚举类型的时候,枚举分量中每个成员(标识符)结束符是―,‖而不是―;‖, 最后一个 成员可省略―,‖号。枚举分量的值是默认设置,即从0开始,一路递增下去。可以使用赋值的 方式来显示的改变枚举分量的赋值规律。例如: enum week { Mon=1, Tue, Wed, Thu=5, Fri, Sat, Sun}; 这时Mon=1, Tue=2, Wed=3, Thu=5, Fri=6, Sat=7, Sun=8。可见枚举分量排在后面的值要 比前面的值大1,如果某个分量出现了手动赋值后,就按照这个值为起点,再递加下。注意 枚举分量的值必须是整数。 一个VSL中脚本中可以定义多个枚举变量,但是这些枚举变量中的分量不能有相同的 分量名。例如: - 38 - enum week { Mon, Tue, Wed, Thu, Fri, Sat, Sun};//定义一个枚举类型 enum weekend { Sat, Sun}; //错误,Sat,Sun 重名了。 下面我们来看一个枚举类型应用的例子(例子2.3.3_2.cmo): enum Envet //定义一个枚举类型 { yes = 1, // 1 进行赋值,强制从1开始递加 no, // 2 again, // 3 }; void main() { // Insert your code here switch(num) //判断执行的条件 { case yes : //枚举分量1 bc.OutputToConsole("Yes,I understand! "); break; case no : //枚举分量2 bc.OutputToConsole("Yorry,I don't understand"); break; case again: //枚举分量3 bc.OutputToConsole("Please,I shall do it again"); break; default: //其它情况 bc.OutputToConsole("warning"); } } 2.4 字符串类的使用 2.4.1 字符变量、字符串类型变量、字符数组和字符串类的区别 看到上面这个小标题,是不是有点混淆了,学完这一节就可以理顺这些内容了。在1.3.1 小节介绍的字符变量(char)和字符串类型变量(str),在1.3.7小节介绍的字符数组(ch[ ]), 以及在这一节要讲的字符串类(String),它们虽然都是处理计算机字符,但它们所面向的 问题、和处理的手段都不一样。 在解释它们之间关系之前,要澄清一点这是站在VSL的角度来做解释的,毕竟我们学 习的是VSL。所谓字符变量(char)只能存储一个字符,要么是‘a‘、要么是‘b‘、 要么是‘c‘ 等等,一次一个而已。字符串类型变量(str)它可以一次存储多个字符,例如str=‖abcd‖、 或者str=‖123.53‖等等,但是这种类型变量,又无法得到这一串字符其中的一个字符。字符 数组(ch[ ])既可以存储一串字符,又可以得到其中的某一字符,但是它所存放的字符的多 少是固定的,实际的应用中常常需要能动态的调配存储字符容量的大小。就这样字符串类 (String)登场了。 关于什么是类、什么是对象、什么是封装、什么是继承、什么是多态等等,我一点都 不想在这解释,因为我要保持VSL的简单易学易用性。只要记住能称之为类的东西,是可以 - 39 - 对很多相似的东西进行同样的操作。在往后的章节中你会碰到很多类,以及声明这些类的对 象。我都没去刻意的提及这些概念,我只是不想将VSL复杂化。读者了解这个就这么一回事 而已就行了。 所谓字符串类,就是包含了一系列对字符串类型变量的操作的集合。它的用处可不只 是动态的分配存储字符串容量的大小,还可以对两个字符串首尾相连、可以比较两个字符串 的大小、可以转换字符串的大小写等等。在VSL中这个字符串类基本上都满足了对字符串的 大部分操作。 2.4.2 字符串类的定义与赋值 我们来比较一下字符变量、字符串类型变量、字符数组和字符串对象它们之间的定义 与初始化方式: int ia=49; //定义一个整型数据 char ch01=ia,ch02='a'; //字符变量类型的定义与赋初值 ch01='\\'; //字符变量类型的常规赋值 str mystr01,mystr02="a"; //字符串类型变量的定义与赋初值 mystr01="ab\tcd"; //字符串类型变量的常规赋值 char arOne[3]; //字符数组的定义、无法赋初值 arOne[0]='a'; //对字符数组的单个元素进行赋值 arOne[1]='b'; String Str01,Str02="dcba"; //字符串对象的定义与赋初值 Str01=mystr01; //字符变量类型的赋值 从上面的代码第二行“ch01=ia”可以看出:字符型和整型是相互通用的,它们之间 可以相互进行赋值操作,甚至可以直接的赋值为 ch01=49。此时变量 ch01 的值是字符’1’, 而变量 ia 的值是整数 49,它们的意义是不一样的。换句简单的话来说,我们可以使用数字 来表示字符,反正亦然。例如整数 48~57 对应字符的 0~9;整数 65~90 对应字符的 A~Z; 整数 97~122 对应字符的 a~z 等等,详细可以查看 ASCII 字符对照表,如果读者之前没有 任何的程序设计学习经验,还是不要使用这种方法吧。另外在使用这种方法之前要多试验 几下,看 VSL 是否完全支持所有的 ASCII 字符对照。 代码的第三行“ch01='\\'”这个赋值语句很奇怪,其实它表示要使用一个特殊字符(或 者称为转义字符)赋值给变量 ch01。转义字符是以一个“\”开头的特殊形式的字符常量, 它的意思是将斜杠“\”后面的字符转变成另外的一个意义的字符。常用的有’\n’(换行 符)、’\t’(横向跳格符,跳跃一个 tab 按键的宽度)、’\\’(反斜杠字符“\”)、’\’’(单用户即撇 号字符)等。再使用其他转义字符时,同样要在 VSL 中多试验几下,查看 VSL 是否支持, 毕竟 VSL 不是 C 语言。 对于字符串类型变量(str)的赋值要使用双引号括起来。在赋值语句中也可以使用转 义字符。而字符数组的赋值和字符变量的赋值是一回事,只不过字符数组无法赋初值,对 字符数组的赋值,就是对其每一个元素赋值的过程,在赋值过程中,可以只对其中的几个 元素进行赋值,没有赋值上的元素默认值为空。其实字符数组和字符串类型变量在 VSL 中 很少用,通常都使用字符串对象(String)来代替。 字符串对象(String)的赋值也是双引号括起来,在赋值语句中也可以使用转义字符, 也可以把字符串类型变量(str)赋值给字符串对象(String),但不能把字符类型、字符 数组赋值它。 2.4.3 字符串的处理函数 字符串对象(String)提高了很多处理字符串应用的函数,掌握字符串的处理方法在 - 40 - 程序设计中是很有帮助的。 1.连接两个字符串 连接两个字符串非常简单,只要将两个字符串相加即可。例如 String01+=String02。 该语句表示,字符串 String02 接到字符串 String01 的后面,很方便吧;还有更方便的,例 如:String01= String02+3.14;该语句等同于 String01= String02+‖3.14‖,从这一点可以 看到字符串还可与浮点型数据进行连接,换句或是可以将整形、浮点型转换成字符串。当 然这个“3.14”是可以用浮点型变量来表示。但要注意 String01=3.14 是不行的,因为一 个是浮点数、一个是字符串对象,类型不匹配,不能赋值;而 String01+=3.14 是行得通的, 因为这是一个浮点型转换成字符串的表达式。 2.字符串拷贝与替换局部字符 字符串的拷贝只要使用一个等于号,就可以完成操作。例如 String01=String02。其实 它更像一个赋值操作。替换局部字符有两种情况:一种是替换字符串中的某一个字符;一 种是替换字符串中的某一段字符。使用的函数 String01.Replace(Scrstr,Dirstr)可以完成上 面两种情况的替换。其中 Scrstr 是要被替换的某一段字符,Dirstr 是要替换为这些字符。 如果 Scrstr 在 String01 并不存在,则不会发生替换。 3.字符串的比较 字符串比较有两种:一种是完全的比较,一种是包含的比较。所谓完全比较是指:对 两个字符串进行从左到右的逐个字符元素进行比较(比较的标准是按 ASCII 码值的大小), 一直比较到最后一个字符为止。如果两个字符串的所有字符元素都相同,则两字符串相等; 如果有不同的字符元素,就以第一个不相同的字符的比较结果为准。例如字符串 String01 与字符串 String02 进行比较,使用方法为: String01.Compare(String02),该函数返回一 个整数值: a) 如果 String01 等于 String02,整数值为 0。 b) 如果 String01 大于 String02,整数值为 1。 c) 如果 String01 小于 String02,整数值为-1。 所谓包含比较是指:检测字符串 String01 是否完全包含字符串 String02 的每个字符元 素。例如检测字符串 1 是否包含字符串 2:String01=“aaabbccc”; String02=“abbc”; 调用函数 String01. Contains (String02)。该函数返回一个布尔值,如果 String01 包含 String02,该值为 TRUE,否则返回 FALSE。读者请将 “abbc”改为“cbba”,再测试 一下,看看结果,加深所谓包含的意思。 4.字符串的大小 字符串对象(String)的优点之一是自动调整大小,要获得一个字符串对象的长度可 以调用函数 String01.Length();该函数返回字符串拥有的字符个数。同时也可以手动的调节 字符串的大小,只要调用函数 String01.Resize(int num)并输入想要调整的大小,如‘26’ 即可。 5. 字符串的截取 如果想获得字符串某段连续的字符、或是去除某段连续的字符,这就要使用截取功能 了。字符串的截取有两种:一种是保留指定的那些字符;一种是移除定的那些字符 2.4.4 字符串的应用 上面我们说了一串又一串,不来个实例,说明不了问题,印象也不会深刻。请打开例 子程序 2.4.2_1,它列举了绝大部分的字符串的用法,要好好学会它,其有 9 个输出参数 如下:VSL 脚本程序添加如下参数: 参数名 参数类型 参数功能 参数用途 - 41 - MyString01 String 输出参数 将结果输出到参数1 MyString02 String 输出参数 将结果输出到参数2 MyString03 String 输出参数 将结果输出到参数3 MyString04 String 输出参数 将结果输出到参数4 MyString05 String 输出参数 将结果输出到参数5 MyString06 String 输出参数 将结果输出到参数6 MyString07 String 输出参数 将结果输出到参数7 MyString08 String 输出参数 将结果输出到参数8 MyString09 String 输出参数 将结果输出到参数9 其代码如下: void main() { String wow; //定义一个字符串对象 wow="How do you do!\n"; //字符串对象的赋值 wow+="Fine thank you,Are you?\n"; //两字符串相连 wow.ToLower(); //字符串转换成小写 MyString01=wow; //将转化成小写的字符串赋给输出参数1 String temp="How old are you?\n"; //定义另一个字符串对象 int age=21; //定义一个整型变量并赋给初值 float result=3.1415926; //定义一个浮点型变量并赋给初值 wow=temp; //字符串对象的赋值操作 wow+=age; //字符串和整型变量相连 wow.ToUpper(); //字符串转换成大写字符 MyString02=wow; //将转化成大写的字符串赋给输出参数2 wow=""; //字符串赋值为空串 wow+=result; //字符串和浮点型变量相连 MyString03=wow; //将结果赋给输出参数3 temp=" Sot Nice! "; //字符串对象的赋值操作 MyString04=temp.Trim(); //去除字符串头、尾多余的空格 MyString05=temp.Strip(); //把多个空格连续的地方用一个空格替换 temp.Resize(26); //重组字符串大小 for(int i=0;i<26;i++) //遍历每一个字符串 { temp[i]=65+i; //对字符串中的26个元素进行赋值 } MyString06=temp; //把结果赋值给输出参数6 String Scrstr="BCD"; //定义一个字符串对象 String Dirstr="bcd"; //定义一个字符串对象 temp.Replace('Z','a'); //将字符串中的某一元素替换为另一个值 temp.Replace(Scrstr,Dirstr); //将某一串元素替换为另一串的值 MyString07=temp; //把结果赋值给输出参数7 int res=-1; res=MyString07.Compare(MyString06); //字符串比较 res=MyString07.Contains(Dirstr); //字符串包含比较 - 42 - res=MyString07.Find('M',0); //查找字符串中的某一个元素的位置 res=MyString07.Find(Dirstr,0); //查找字符串中某一串字符的位置 // res=MyString02.RFind('H',0); //一个有名无实的函数 bc.OutputToConsole(res); //将结果打印到控制台 MyString09=MyString08=MyString07; //VSL特别的赋值方式 MyString08.Crop(5,10); //截取字符串中的某一串 MyString09.Cut(5,10); //去除字符串中的某一串 } 上面的VSL脚本输出参数很多,这是为了在Virtools脚本编辑器中查看运行的结果,这 些代码都附上了注释,理解它不会存在难度。但是最后的两行代码也许会让你感到困惑,其 中MyString08.Crop(5,10)表示只保留原字符串的从第五个字符,到第十个字符的内容,其 它全部舍弃;而MyString09.Cut(5,10)表示只是舍弃原字符串的从第五个字符,到第十个字 符的内容;其余的字符不变。 - 43 - 第三章 VSL 中的 3D 数学与变换以及简单几何体 之前我们学习的内容完全没有涉及到代数方面的知识,都是一些基本的程序设计。但 现在完全不同了,3D图形学和数学知识,要说多深就有多深,那样的深度是搞科研的人弄 的,这里只做浅析的描述,对于数学知识背景缺乏的读者来说,也容易理解和应用。本章的 主要介绍以下几个方面的内容:  向量的学习  学习VSL脚本程序向量Vector类的用法  学习Vector2D类和Vector4类的用法  矩阵的学习  Matrix类的学习和使用 3.1 向量 3.1.1 向量的概念 向量是3D算法的基础,对于虚拟现实技术程序员来说这是非常重要的。在Virtools中向 量分为2D向量和3D向量两种,3D图形学中用一条有向线段来表示向量。向量是由起点和终 点定义的,所以就具有长度和顶点所指方向的两个属性。如图3.3.1_1所示: 图3.1.1_1 上图左边是演示2D向量,右边是演示3D向量。2D向量u是用由两个点P1(起点)和P2 (终点)定义,计算向量u= P2 - P1 =(x2-x1,y2-y1);3D向量u也是用由两个点P1(起点)和 P2(终点)定义,计算向量u= P2 - P1 =(x2-x1,y2-y1, z2-z1)。2D向量由两个数组成,而3D向 量则由三个数组成,所以在定义一个2D向量或3D向量的时候,所设置这些组成的数,其实 是向量的终点,而且起点总是为原点。这一点是要注意的:向量被定义后,总是相对于原点 的。这并不意味着不能平移向量并使用它们进行各种操作,而只是为了要你理解向量的实际 概念。向量用有向线段可以表示很多概念,如速度、加速度、光线的照射方向、既有大小又 有方向的物理模型等。向量除了2D、3D的向量外,还有n维的向量,但不是通常所说的正 常空间范畴了,这里就不说了。 3.1.2 向量长度和归一化 向量的大小是其有向线段的长度,也可以叫做向量的长度或向量的模。向量的长度是 - 44 - 从原点到向量表示的终点的距离,可以用各个分量的平方和的平方根的数学公式算出,通俗 的说就是勾股定理来计算向量才长度。例如: 我们用‖u‖表示向量u的长度,ux,uy,uz来表示分量。在实际应用中除了计算向量的长度 外,有时候也需要对向量进行缩放。比如我们可能执行很多计算,但只是关心结果向量的方 向,而不关心其长度,也就是说只需要向量的方向性。对向量进行归一化可以满足这种需求, 归一化即使对向量进行缩放,使其长度为1.0,同时方向保持不变。长度等于1的向量,也叫 作单位向量。我们û表示单位向量,其数学公式如下: 3.1.3 向量的相加与相减 要将多个向量相加,只要分别将各个分量相加即可,当然这多个向量必须是相同的维 数。图3.1.3_1显示了2D向量u和v的加法运算,结果为u+v。向量加法的几何意义为:平行 移动向量v,使得起点与向量u的终点重合,然后画出三角形的另一条边。数学公式如下: 这是2D向量的数学运算,3D向量的数学运算也类似,只是多了z轴的值。 图3.1.3_2显示了2D向量u和v的减法运算,向量的减法实际上是加上一个方向相反的向 量而已;和加法类似,向量减法是分别把两个向量的各个分量相减得到的,同样的向量必须 有相同的维数。向量之差u-v是从v到u的向量,而v-u是u(起点)到v(终点)的向量。数学 公式如下: 图3.1.3_1 图3.1.3_2 - 45 - 3.1.4 向量和标量的乘法 假如用一个向量来一个表示速度,向量的方向是速度的方向,向量的长度是速度的大 小(即速率),要提高和降低这个速度,可以使用缩放进行运算。缩放是通过将每个分量乘 以一个变量来完成的。向量乘法的公式为: 其中k为实数常量。要想改变向量的方向,也就是说反转速度的方向,可以将该向量乘 以一个-1即可。数学公式为: 下图为一个2D向量和标量的乘法的示意图: 图3.1.4_1 3.1.5 向量的点积 向量既然能和标量相乘,那能不能向量与向量相乘呢?这个是肯定的。但是直接将 向量的各个分量相乘并没什么用。所有通常数学上定义点积和叉积才是两个向量的乘法。点 积通常用―.‖符号表示,它将各个分量分别相乘后,得到一个标量,而不是将各个分量相乘, 并保留向量形式。运算表达式有如特征: 上面的等式不能很明显的体现几何上的意义,只能看出u和v的点积等于向量u的长度乘 以向量v的长度,再乘以它们的夹角的余弦值而已。但是它提供了一种计算两个向量夹角的 方法,利用余弦定律,我们能够发现它们的关系。经过公式变换后得到: 一些点积中有用的特性如下:  如果u · v = 0,那么u⊥v。 - 46 -  如果u · v > 0,那么两个向量的角度θ小于90度。  如果u · v < 0,那么两个向量的角度θ大于90度。  如果向量u和v相等,则u · v = |u|2 = |v|2 点积除了可以计算夹角以外,还可以用于其它很多方面,点积的重要用途之一就是计算向 量在给定方向上的投影。有一点要注意:向量和标量相乘是满足数学上的结合律、交换律、 和分配律的。但向量的乘法则不满足这些定律,使用时要请留意。 3.1.5 向量的叉积 前面我们说到向量时总是列举2D向量,其实对于3D向量方法仍完全适合的。这里我们 将讨论另一种向量的乘法——叉积。叉积只有当向量为3维或者更多维的分量时,叉积才有 意义。叉积u×v的公式如下: 其中 n 是一个单位法线向量,长度为 1.0。叉积不象点积,结果值是一个标量,叉积 的结果值是另一个向量。通过把两个向量 u 和 v 相乘得到另一的向量 n,向量 n 垂直于 u 和 v。也就是说向量 n 垂直于 u 并且垂直于 u。如下图: 图3.1.5_1 3.1.6 位置和位移向量 其实没有什么专门的位置向量和位移向量的特殊概念,只是把向量用来表示位置和位 移的意思,在 Virtools 编程中,这是用得很多的。如图 3.1.6_1 描述了一个可以用于表示 位置和位移的向量。向量 p1 移动向量 p2,p1p2 是 p1 到 p2 的位移向量,表明了 p1 到 p2 的运动方向和长度。如下图所示: - 47 - 图3.1.6_1 3.2 VSL 中的 2D 向量 Vector2D 3.2.1 Vector2D 的方法 Vector2D由两个分量所组成(即x、y),可以把它理解为一个结构体,所以可以用之 前我们介绍的方法来访问它的分量。Vector2D多数用来表示屏幕坐标,我们在屏幕上显示 一串文字,移动一个按钮,或者缩小放大一张图片,弹出一菜单,获取鼠标在的位置等等, 都会用Vector2D。我们常用Vector2D来表示2D元素(比如2D帧,2D精灵,和显示的字符) 在屏幕上的位置和大小。为了方便使用,Vector2D对基本―+、-、*、/、+=、-=,*=,/=‖ 运 算符进行了重载,同时还提供了一些常用函数,例如求向量的长度,归一化向量的函数方法 等。具体请查看Virtools的帮助文档。下面我们来看个例子以帮助学习Vector2D,你会发现 这是目前最长的一段程序,但是请相信它是多么的简单,它罗列出Vector2D常用函数的使 用方法(例子3.2.1_1.cmo): void main() { // Insert your code here bc.OutputToConsole("-----------FirstVec2D+SecondVec2D-----------"); OutVec2D=FirstVec2D+SecondVec2D;//两个向量相加,结果赋值给另外一个向量 bc.OutputToConsole(OutVec2D.x); bc.OutputToConsole(OutVec2D.y); bc.OutputToConsole("-----------FirstVec2D+=SecondVec2D-----------"); OutVec2D+=SecondVec2D; //两个向量相加,结果赋值给OutVec2D bc.OutputToConsole(OutVec2D.x); bc.OutputToConsole(OutVec2D.y); bc.OutputToConsole("-----------FirstVec2D-SecondVec2D-----------"); OutVec2D=FirstVec2D-SecondVec2D; //两个向量相减,结果赋值给另外一个向量 bc.OutputToConsole(OutVec2D.x); bc.OutputToConsole(OutVec2D.y); bc.OutputToConsole("-----------FirstVec2D-=SecondVec2D-----------"); OutVec2D-=SecondVec2D; //两个向量相减,结果赋值给OutVec2D bc.OutputToConsole(OutVec2D.x); - 48 - bc.OutputToConsole(OutVec2D.y); bc.OutputToConsole("-----------FirstVec2D*float-----------"); OutVec2D=FirstVec2D*3.14; //向量和一个标量相乘,结果赋值给另外一个向量 bc.OutputToConsole(OutVec2D.x); bc.OutputToConsole(OutVec2D.y); bc.OutputToConsole("-----------OutVec2D*=float-----------"); OutVec2D*=3.14; //向量和一个标量相乘,结果赋值给OutVec2D bc.OutputToConsole(OutVec2D.x); bc.OutputToConsole(OutVec2D.y); bc.OutputToConsole("-----------FirstVec2D*SecondVec2D-----------"); OutVec2D=FirstVec2D*SecondVec2D; //两个向量相乘,结果赋值给另外一个向量 bc.OutputToConsole(OutVec2D.x); bc.OutputToConsole(OutVec2D.y); bc.OutputToConsole("-----------OutVec2D*=SecondVec2D-----------"); OutVec2D*=SecondVec2D; //两个向量相乘,结果赋值给OutVec2D bc.OutputToConsole(OutVec2D.x); bc.OutputToConsole(OutVec2D.y); bc.OutputToConsole("-----------FirstVec2D/float-----------"); OutVec2D=FirstVec2D/3.14; //向量和一个标量相除,结果赋值给另外一个向量 bc.OutputToConsole(OutVec2D.x); bc.OutputToConsole(OutVec2D.y); bc.OutputToConsole("-----------OutVec2D/=float-----------"); OutVec2D/=3.14; //向量和一个标量相除,结果赋值给OutVec2D bc.OutputToConsole(OutVec2D.x); bc.OutputToConsole(OutVec2D.y); bc.OutputToConsole("-----------FirstVec2D/SecondVec2D-----------"); OutVec2D=FirstVec2D/SecondVec2D; //两个向量相除,结果赋值给另外一个向量 bc.OutputToConsole(OutVec2D.x); bc.OutputToConsole(OutVec2D.y); bc.OutputToConsole("-----------OutVec2D/=SecondVec2D-----------"); OutVec2D/=SecondVec2D; //两个向量相除,结果赋值给OutVec2D bc.OutputToConsole(OutVec2D.x); bc.OutputToConsole(OutVec2D.y); bc.OutputToConsole("-----------FirstVec2D.Cross()-----------"); OutVec2D=FirstVec2D.Cross(); //向量的叉乘,结果赋值给另外一个向量 bc.OutputToConsole(OutVec2D.x); bc.OutputToConsole(OutVec2D.y); bc.OutputToConsole("-----------FirstVec2D.Dot(SecondVec2D)-----------"); float arcos=FirstVec2D.Dot(SecondVec2D); //两个向量的点乘,到底一个弧度值 bc.OutputToConsole(arcos); bc.OutputToConsole("-----------FirstVec2D.Magnitude()-----------"); arcos=FirstVec2D.Magnitude(); //得到向量的长度,即大小 bc.OutputToConsole(arcos); bc.OutputToConsole("-----------FirstVec2D.SquareMagnitude()-----------"); - 49 - arcos=FirstVec2D.SquareMagnitude(); //得到长度的平方 bc.OutputToConsole(arcos); bc.OutputToConsole("-----------FirstVec2D.Normalize()-----------"); OutVec2D=FirstVec2D.Normalize(); //向量的归一化。 bc.OutputToConsole(OutVec2D.x); bc.OutputToConsole(OutVec2D.y); bc.OutputToConsole("-----------OutVec2D.Set(100,101)-----------"); OutVec2D.Set(100,101); //对向量的分量进行设置新的值 bc.OutputToConsole(OutVec2D.x); bc.OutputToConsole(OutVec2D.y); } 新建一个VSL脚本,增加两个输入参数FirstVec2D和SecondVec2D,类型都选择为 Vector2D类型;再增加一个输出参数,类型也选择Vector2D类型。编译成功并运行,在控 制台上打印出的运行结果,请仔细体会其中的意义。对照着前面一节的内容,来看Vector2D, 你会发现Vector2D的所提供的函数基本都覆盖上一节的知识。 3.2.2 Vector2D 的应用 一、功能:实现一个2D动画效果,对一个2D帧进行改变位置的同时,进行缩放。 新建立一个Virtools程序,添加一个2D帧,然后创建一个Virtools脚本,在脚本中添加 添加四个BB:―Bezier Progression‖、―Interpolator‖、―Get Component‖,―Edit 2D Entity‖然 后将它们连线成如下图所示: 图3.2.2_1 连线完成后,对相应的输入参数进行设置好后并运行这个程序,你会发现笑脸在移动的同时, 大小也在变化。这个是没有使用到VSL脚本程序的做法,接下来我们来看用VSL来实现它。 新建一个VSL脚本程序,添加两个Vector2D类型的输入变量,并将这两个变量分别命名 StartPos和EndPos,再添加一个float类型的输入变量,命名为step;然后再添加两个float 类型的输出变量,并将这两个变量分别命名Vx和Vy。其中StartPos表示2D帧初始时候的位 置和大小,EndPos表示2D帧结束时的位置和大小,step是当前的位置和大小状态标志,其 值在0至1之间。Vx和Vy是作为―Edit 2D Entity‖这个BB的输入参数。添加如下代码并编译成 功(例子3.2.2_1.cmo): void main() { - 50 - // Insert your code here Vector2D OutPos; Vector2D temppos=EndPos-StartPos;//得到2D帧移动的方向和大小 temppos*=step; //得到当前要变化的大小和位置 OutPos=StartPos+temppos;//得到当前的位置和大小 Vx=OutPos.x; //得到X轴的分量 Vy=OutPos.y; //得到Y轴的分量 } 然后与―Bezier Progression‖和―Edit 2D Entity‖连线成下图所示,运行。你会发现结果 是一样的,我们用一个VSL脚本BB替换了―Interpolator‖、―Get Component‖这过两个BB。其 实我们可以将这些BB全部替换掉,就用一个VSL脚本BB就可以了,但是由于我们的知识还 不全面,所有放到后面再说。 图3.2.2_2 二、功能:实现一个2D动画效果,4个小球以某一中心进行相向运动。 新键一个Virtools程序,然后新建一个Virtools脚本,在该脚本中新建一个VSL脚本程序, 并在给该脚本程序添加和上面程序一样的3个输入参数,输出参数我们添加是四个Vector2D 类型的参数,命名依次为:OutPos、OutPos01、OutPos02、OutPos03。这是个输出参数 正是我们要的相向而行的4个小球的位置向量。然后我们添加如下代码并编译成功(例子 3.2.2_2.cmo): void main() { // Insert your code here Vector2D temppos=EndPos-StartPos; //得到2D帧移动的方向和大小 temppos*=step; /得到当前位置变化量 OutPos=StartPos+temppos; //小球01得到当前的位置 OutPos02=OutPos.Cross(); //小球02得到与小球01相垂直的位置 OutPos03=OutPos02.Cross(); //小球03得到与小球02相垂直的位置 OutPos04=OutPos03.Cross();//小球04得到与小球03相垂直的位置 } 图3.2.2_3 添加―Bezier Progression‖、 ―Set 2D Position‖然后与刚才新建立的VSL脚本BB连线配合使 用如下图所示: - 51 - 图3.2.2_4 运行这个程序,你会看到旁边的四个小球以中间的小球,进行相向运动。同样其实我们也可 以将―Bezier Progression‖、 ―Set 2D Position‖替换掉,就用一个VSL脚本BB,但我们放到 学习了Entity2D和Curve2D后,再做这样的举例。在学习上面的例子的时候,不要只是认为 2D帧的位置发生了变化,要体会其中的位置变化的同时也表现了向量的方向性。 3.3 VSL 中的 3D 向量 Vector 3.3.1 Vector 的方法 Vector由三个分量所组成(即x、y、z),可以把它理解为一个结构体,所以可以用之 前我们介绍的方法来访问它的分量。Vector2D多数用来表示屏幕坐标,而Vector则是用来表 示三维空间的坐标。在三维空间中的每一个房子,每一条河流,每一片云彩等等都可以用 Vector来定位;我们可以用它来实现在三维空间中常用的操作,例如对物体进行移动、旋转、 缩放等等。和Vector2D一样,Vector也提供了一些常用函数以方便使用,除了对基本―+、-、 *、/、+=、-=,*=,/=‖ 运算符进行了重载,和求向量的长度,归一化向量的函数方法外, 并没有什么特殊实用的函数,所以就不列举Vector每个函数的使用方法了,具体请查看 Virtools的帮助文档。 3.3.2 Vector 的应用 功能:实现一个3D动画效果,对圆锥进行缩放,并使正方体和圆球绕该圆锥进行相对运行。 新建立一个Virtools程序,打开本书附送光盘上的VSL_SDK_Res资源包,并添加3D资 源模型―Sphere.nmo‖― Cube.nmo‖― Cone01.nmo‖。然后创建一个Virtools脚本,接着创建一 - 52 - 个VSL脚本程序,为该VSL脚本程序添加如下参数: 参数名 参数类型 参数功能 参数用途 step float 输入参数 当前状态变化尺度其值在0至1之间 Radius float 输入参数 正方形和圆球的运行半径 StartScale Vector 输入参数 圆锥体的缩放最小比例 EndScale Vector 输入参数 圆锥体的缩放最大比例 OutPos Vector 输出参数 正方形当前的运动位置 OutPos02 Vector 输出参数 圆球当前的运动位置 OutScale Vector 输出参数 圆锥体当前的缩放比例 往脚本中添加如下代码并编译成功(例子3.3.2_1.cmo): void main() { // Insert your code here OutPos.x = Radius*sin(step*2*pi); //计算正方体当前的运动位置 OutPos.z = Radius*cos(step*2*pi); OutPos.y=0; OutPos02.x=0; //计算圆球当前的运动位置 OutPos02.y = Radius*sin(step*2*pi); OutPos02.z = Radius*cos(step*2*pi); OutScale=StartScale+(EndScale-StartScale)*step; //得到缩放的尺度 } 最后在脚本中添加BB:―Bezier Progression‖、―Set Posiont‖、―Scale‖。然后将它们连线成 如下图所示: 图3.3.2_1 运行这个程序,你会看到圆锥体在运动中缩放,而正方体和圆球围绕这圆锥体进行运 动。这个例子是展现了Vector的应用,体现了位置向量和方向向量和缩放向量的概念。 在VSL的帮助文档中,你会看到还有一个Vector4类型,看着带着个数字4字,自然的 联想到它有4个分量(即x、y、z、w)。它在实际项目中用得比较少,这里就不提了,同样 的原因Quaternion这个类型也就不说了。 - 53 - 3.4 矩阵和 Matrix 3.4.1 矩阵的概念 对 3D 图形运算,常常会涉及大量的多组数值进行数学运算,矩阵的使用可以简化数 据的表示和变换处理。像在写 shader 程序的时候,这个概念更是运用得很多,但平时在 Virtools可视化的编程方式中应用得很少。在VSL和SDK 中,也应用的比较少,因为 Virtools 提供了其它一些更高效的更方便的方法来实现类似的操作。矩阵只是在涉及到摄像机的内 容时候会用到一些,这里只提点一些矩阵的知识。 矩阵是一个矩形阵列,有指定的行和列。通常说矩阵为 m×n,表示它有 m 行和 n 列。 m×n 也叫矩阵的维数,在 Virtools 中用得最多的时候 4×4 维,这也是 3D 图形常用的矩阵 维数。例如,下面的矩阵 A 为 4×4 维: A=             09710 4380 1354 6621 矩阵的运算法则,矩阵的逆矩阵和单位矩阵,以及矩阵的转置等等这些概念属于线性代数 的范畴了,这里不加说明了。三维空间中的一个矩阵,除了包含位置信息外,还包含了方 向和缩放等信息,所以用它了处理涉及位置方向及大小都会发生变化的运算是十分方便的。 下面的程序是将一个圆锥物体从当前的位置方向及大小,渐进变化成另一个圆锥体的大小 并位置方向重合。例如(例子 3.4.1_1.cmo): 图3.4.1_1 新建立一个 Virtools 程序,打开本书的 VSL_SDK_Res 资源包添加 ― Cone01.nmo‖, 然后 copy 出 Cone01 这个圆锥物体,然后任意改变两者的形状和方向。然后创建一个 Virtools 脚本,接着加入:―Bezier Progression‖、―Interpolator‖、―SetWorld Matriax‖、―Op‖ 这些 BB,并连线成如上图,填好参数。如果但不使用矩阵的概念,可以想象实现这样的 功能该多么复杂和麻烦。 - 54 - 3.4.2 Matrix 的方法和应用 Matrix 是 VSL 中矩阵的标识符,它由 4 组 Vector4 类型组成,形成了 4×4 维的矩阵。 查看 VSL 帮助文档,它有一些成员函数,但由于实际的使用项目中很少单独的使用到它们, 所以这里就不一一列举每个函数的用法,只是做个简单的例子,加深一些矩阵的存在意义。 我们用一个 VSL 脚本程序替换上面例子的―Interpolator‖BB,新键一个 VSL 脚本程序,添 加如下变量,例如: 参数名 参数类型 参数功能 参数用途 step float 输入参数 当前状态变化尺度其值在0至1之间 StarMatX Matrix 输入参数 圆锥体的初始矩阵 EndMatX Matrix 输入参数 圆锥体的变化结束时的矩阵 OutMatX Matrix 输出参数 圆锥体的当前变化矩阵 添加如下代码到 VSL 脚本中并编译成功。 void main() { // Insert your code here Vx3DInterpolateMatrix(step,OutMatX,StarMatX,EndMatX); } 运行之后,发现结果和之前的一道程序是一样的。这两个例子只是演示了矩阵的作用,以 及说明一个矩阵中包含有位置、方向和大小的运算在里面。 3.5 简单几何体 3.5.1 点、线、面 定义三维空间中的一个点,我们用 Vector 类型来表示,这样的一个点可以是表示位置, 也可以表示方向。对于三维空间中的射线我们用 Ray 的标识符来表示,有人会想到为什么 没用直线呢——直线没头没尾没什么用处,又不好表达。射线有个起点,有个方向,这样 一切理解和运算起来顺理成章了,有了射线,线段的意义也就自然包含其中了。 VSL 中射线用标识符是 Ray 表示,他是一个结构体类型,有两个分量:起点分量是 m_Origin 和方向分量是 m_Direction。创建一个 VSL 脚本程序,为该 VSL 脚本程序添加 如下参数和代码(例子 3.5.2_1.cmo): 参数名 参数类型 参数功能 参数用途 point Vector 输入参数 空间中的某个点 step float 输入参数 射线上的点位置变化尺度其值在0至1之间 dis float 输出参数 点到射线的距离 OutPos Vector 输出参数 射线在标尺0至1之间的位置 void main() { // Insert your code here Ray myray; //定义一条射线 Vector dir(0,1,0); Vector pos(0,0,0); Vector mypont(6,3,6); myray.m_Direction=dir; //设置射线的方向 myray.m_Origin=pos; //设置射线的原点 - 55 - dis=myray.Distance(point); //计算某点到射线的距离 myray.Interpolate(OutPos,step); //计算在step 为0至1之间的值时的位置 } 上面的程序都好理解,只是最后一条语句比较生硬,它是一种插值运算,我们在介绍 向量和矩阵的时候已经不知不觉用了这种插值运算了,可以使用这个函数获得射线从原点 到所定义的方向位置中间的一系列点的位置,setp 的值从 0 到 1,当 set 为 0 的时候得到 的位置点就是射线的原点 m_Origin,当 set 为 1 的时候得到点为 m_Direction。比如说, 我开枪打碎了一块玻璃,那么我可以定义一条射线,这条射线以枪为起点,玻璃的位置为 方向点,要得到子弹走过的所以路程。就可以用这个函数了。 平面是 3D 图形学的关键部分,对渲染还是碰撞检测都是非常重要的。从理论上说, 平面是无限延伸的,但是这样在 3D 图形学里,常常把平面封闭到一个多边形对象里面。 例如一个正方体有 12 个空间中的三角形构成,每个三角形就是一个平面,如下图所示: 图3.5.1_1 可以通过这种封闭的平面得到多边形的信息,也可以通过多边形的信息得到平面的信 息。平面的无限延伸属性把空间分成两个半空间,这对进行空间划分和碰撞检测非常重要。 例如在游戏设计中,判断玩家控制角色是否会穿过墙壁时,要对玩家和墙壁进行检测,当 检测到玩家穿过了墙壁,到了面的另外一头,那么玩家就发生了与墙壁的碰撞了。 VSL 中面用标识符是 Plane 表示,本书不打算多说平面的内容,往后在说的摄像机的 章节会再提到一些。理解什么是平面在 Virtools 里面就已经足够了,使用平面这个类型的 机会很少。 3.5.2 正方体 Virtools 中的正方体只是包含两个 Vector 类型分量的结构体,是用正方体对角线的两 个定点来表示的正方体概念。它是不会在 Virtools 窗口中进行渲染,只是一个抽象的存在, 它主要用来参与检测碰撞运算的。Virtools 中的正方体与其它三维建模软件里面使用的正 方体概念是不同的,其它的三维建模软件的正方体有定点,有线,有面的关系存在,而 Virtools 的正方体只是三维空间中的一个抽象空间。 在实际的 Virtools 编程中,很少直接去构造一个正方体,更多的是获得 3D 实体的绑 定正方体,甚至有时候这样做也是多余的,因为 Virtools 提供了很好的 BB 来实现碰撞检 测,程序员基本上没机会使用 3D 实体的绑定正方体。 VSL的正方体类型标识符是Bbox,其有两个Vector类型分量Max和Min。下面我们通过 一个简单的实例来学习它。创建一个VSL脚本程序,为该VSL脚本程序添加如下参数: 参数名 参数类型 参数功能 参数用途 size Vector 输出参数 当前Bbox的大小 halfSize Vector 输出参数 当前Bbox大小一半 - 56 - CenterPos Vector 输出参数 当前Bbox的中心位置 IsInIt bool 输出参数 判断两个Bbox是否包含 IsInVec bool 输出参数 判断某个点是否在Bbox内 OutBox Bbox 输出参数 输出一个新构造的Bbox 然后添加代码如下(例子 3.5.2_1.cmo): void main() { // Insert your code here Bbox mybox,otherbox; //定义两个Bbox类型变量 Vector minvec(0,0,0); Vector maxvec(10,10,10); Vector maxvec02(3,3,3); mybox.Min=minvec; //给Bbox的各个分量赋值,从而设定其大小和位置 mybox.Max=maxvec; otherbox.Min=minvec; otherbox.Max=maxvec02; size=mybox.GetSize(); //得到Bbox的大小 halfSize=mybox.GetHalfSize(); //当前Bbox大小一半 CenterPos=mybox.GetCenter(); //得到当前Bbox的中心位置 IsInIt=mybox.IsBoxInside(otherbox); //判断是否两个Bbox存在包含关系 IsInVec=mybox.VectorIn(maxvec02); //判断某个定点是否包含在Bbox中 OutBox.SetCenter(CenterPos,halfSize); //这个Bbox的其大小和位置 OutBox.SetCorners(minvec,maxvec); //这个Bbox的其大小和位置 OutBox.SetDimension(maxvec02,maxvec02); //这种Bbox的其大小和位置 } 以上就是 Bbox 常用函数的用法,理解起来非常简单。 3.5.3 圆锥体和圆球 圆锥体类型在 VSL 中用标识符是 Cone 表示,圆球类型用标识符 Sphere 表示,它们 和正方体一样只是一种抽象的存在于三维空间中,Virtools 不会渲染它们,当年创建了这 样的简单几何体,在 Virtools 的 Level Manager 中是找不到它们的。我也很少使用它们, 所以对它们没什么好解释的,只是想告诉你它们的存在。 3.5.4 矩形 矩形的定义:有一个角是直角的平行四边形叫做矩形,长方形和正方形都属于矩形。在 VSL 中用 Rect 表示,它有 4 个成员 float 型分量分别为 left、top、right、bottom。这四个 分量分别定义了矩形的大小和位置,如下图所示: - 57 - 图3.5.4_1 创建了一个矩形,在Virtools的Level Manager中也是找不到它的,同时也不会渲染它。 它的主要作用体现在2DEntity上,它是2D帧一个重要属性。在响应鼠标点击按钮和改变按钮 的位置上,矩形起着重要作用。下面我们来看Rect的函数方法,请打开本书附送光盘的―例 子3.5.4_1.cmo‖文件。 脚本―DefineRect‖功能是定义并设置矩形的大小。 其输入参数如下: 参数名 参数类型 参数功能 参数用途 Entity2DOne Entity2D 输入参数 输入一个2D帧 Entity2DTwo Entity2D 输入参数 输入一个2D帧 Entity2DThere Entity2D 输入参数 输入一个2D帧 该脚本程序代码如下: void main() { // Insert your code here Rect rectone,recttow,rectthere; //定义三个矩形分量 rectone.left=100; //设置矩形的分量来设定其大小和位置 rectone.top=100; rectone.right=200; rectone.bottom=200; Entity2DOne.SetRect(rectone); //设置2D帧的大小,其大小就是矩形的大小 Vector2D topleft(200,100); //定义一个2D顶点,表示矩形的左上角 Vector2D rightbottom(300,200); //定义一个2D顶点,表示矩形的右下角 recttow.SetTopLeft(topleft); //设置矩形的左上角点,确定矩形的位置 recttow.SetBottomRight(rightbottom); //设置矩形的右下角点,确定矩形的大小 Entity2DTwo.SetRect(recttow); /设置2D帧的大小,其大小就是矩形的大小 Vector2D centre(300,100); //设置一个2D顶点,表示矩形的中心点 Vector2D size(100,100); //设置一个2D顶点,表示矩形长宽大小 rectthere.SetCenter(centre); //设置矩形的中心点位置,确定矩形的位置 rectthere.SetSize(size); //设置矩形的长宽大小,确定矩形的大小 Entity2DThere.SetRect(rectthere); /设置2D帧的大小,其大小就是矩形的大小 - 58 - } 确定一个矩形的大小有好几种方法,可以直接设置它的每个分量来设定大小;可以通 过设置矩形的左上角和右下角的位置来设定大小;也可以通过设置矩形的中心点和矩形的长 高来设定大小等。Rect其实还有其它的函数方法来设置其大小,这里就不再一一列举了。上 面的程序使用到了Entity2D类型变量,这是下一章要学习的内容,这里为了直观的表现我们 对Rect的操作而产生的变化,才提前使用Entity2D类型。 脚本―MoveRect‖功能是:移动矩形。 其输入参数如下: 参数名 参数类型 参数功能 参数用途 Entity2DOne Entity2D 输入参数 输入一个2D帧 Entity2DTwo Entity2D 输入参数 输入一个2D帧 Entity2DThere Entity2D 输入参数 输入一个2D帧 centrepos Vector2D 输出参数 输出一个2D顶点,表示位置 halfsize Vector2D 输出参数 输出一个2D顶点,表示大小 该脚本程序代码如下: void main() { // Insert your code here Rect rectone,recttow,rectthere; //定义三个矩形分量 Entity2DOne.GetRect(rectone); //得到2D帧的矩形大小 Entity2DTwo.GetRect(recttow); Entity2DThere.GetRect(rectthere); centrepos=rectone.GetCenter(); //得到矩形的中心点位置 halfsize=rectone.GetHalfSize(); //得到矩形的长宽的一半大小 rectone.HMove(-5); //在水平方向移动-5个单位长度 recttow.VMove(-5); //在垂直方向移动-5个单位长度 rectthere.HTranslate(20); //在水平方向移动20个像素长度 rectthere.VTranslate(50); //在垂直方向移动20个像素长度 Vector2D movedis(10,-10); //设置一个顶点,表示移动的程度 rectthere.Translate(movedis); //以像素为单位移动一个矩形 Entity2DOne.SetRect(rectone); //设置2D帧的大小,其大小就是矩形的大小 Entity2DTwo.SetRect(recttow); Entity2DThere.SetRect(rectthere); } 移动一个矩形有两种方法:一个是以单位长度来移动即函数Move;一个是以像素为单 位来移动即函数Translate。如果你不太明白以单位长度来移动,那么请按下Shif+G打开界 面网格,界面网格中每一个小方格就是一个单位长度。 脚本―ScaleRect‖功能是:对矩形进行缩放。 其输入参数如下: 参数名 参数类型 参数功能 参数用途 Entity2DOne Entity2D 输入参数 输入一个2D帧 Entity2DTwo Entity2D 输入参数 输入一个2D帧 Entity2DThere Entity2D 输入参数 输入一个2D帧 该脚本程序代码如下: - 59 - void main() { // Insert your code here Rect rectone,recttow,rectthere; //定义三个矩形分量 Entity2DOne.GetRect(rectone); //得到2D帧的矩形大小 Entity2DTwo.GetRect(recttow); Entity2DThere.GetRect(rectthere); Vector2D scalevec(0.5,0.5); //定义一个2D顶点,表示缩放程度 Vector2D Inflatevec(40,40); //定义一个2D顶点,表示膨胀程度 rectone.Scale(scalevec); //长宽缩小到原来的一半大小 recttow.Inflate(Inflatevec); //膨胀矩形 Entity2DOne.SetRect(rectone); //设置2D帧的大小,其大小就是矩形的大小 Entity2DTwo.SetRect(recttow); } 改变矩形的大小有两种:一种是以矩形左上角的顶点不动的改变,称为缩放;一种是 以矩形的中心点不动的改变,称为膨胀。矩形的膨胀是以像素为单位的进行,如上面的程序 是矩形向四周膨胀40个像素;而缩放是以原来的矩形长宽的倍数进行的,如上面程序矩形 的长宽都缩小为原来的0.5倍,即整体缩小了1/4。 脚本―InsideRect‖功能是:判断一个顶点是否在矩形区域内。 其输入参数如下: 参数名 参数类型 参数功能 参数用途 MousePos Vector2D 输入参数 鼠标的当前位置 Entity2DOne Entity2D 输入参数 输入一个2D帧 该脚本程序代码如下: void main() { // Insert your code here Rect rectone; //定义一个2D顶点,表示鼠标的位置 Entity2DOne.GetRect(rectone); //得到2D帧的矩形大小 int inside=FALSE; inside=rectone.IsInside(MousePos); //判断鼠标是否在矩形区域内 if(inside) Out=TRUE; //在矩形区域内 else Out=FALSE; //不在矩形区域内 } 我们判断一个矩形或者一个Vector2D顶点位置,是否在矩形区域内用―IsInside‖函数, 它返回一个bool类型值,返回―真‖表示在矩形区域内,脚本的输出端口开启;返回―否‖在矩 形区域外,脚本的输出端口关闭。这个函数是个多态函数(前面已经提过什么是多态),它 的输入参数可以使Vector2D类型也可以是Rect类型。 脚本―Homogeneous‖功能是:将矩形的屏幕坐标转化成屏幕齐次坐标。 其输入参数如下: 参数名 参数类型 参数功能 参数用途 Entity2DOne Entity2D 输入参数 输入一个2D帧 - 60 - Entity2DTwo Entity2D 输入参数 输入一个2D帧 Entity2DThere Entity2D 输入参数 输入一个2D帧 ScreenRect Rect 输出参数 输出屏幕的矩形 该脚本程序代码如下: void main() { // Insert your code here Rect rectone(0,0,800,600); //定义三个矩形的大小 Rect recttow(0,0,400,300); Rect rectthere(400,300,800,600); rectone.TransformToHomogeneous(ScreenRect); //将矩形转换成齐次坐标 recttow.TransformToHomogeneous(ScreenRect); rectthere.TransformToHomogeneous(ScreenRect); Entity2DOne.SetHomogeneousCoordinates(true); //这种2D帧的齐次坐标属性 Entity2DTwo.SetHomogeneousCoordinates(true); Entity2DThere.SetHomogeneousCoordinates(true); Entity2DOne.SetHomogeneousRect(rectone); //设置带有齐次坐标属性2D帧的大小 Entity2DTwo.SetHomogeneousRect(recttow); Entity2DThere.SetHomogeneousRect(rectthere); } 先解释一些什么是屏幕坐标和齐次坐标。假设我们的渲染屏幕大小设置为800×600的 分辨率,那么屏幕坐标和齐次坐标的左上角的坐标都为(0,0);x轴方向都是水平向右, y轴方向都是垂直向下。而屏幕坐标和齐次坐标的右下角的坐标则不同,屏幕坐标是实际的 像素坐标大小即(800,600);齐次坐标则是(1,1),齐次坐标X轴为1对于屏幕坐标的 800,齐次坐标的y轴为1对应于屏幕坐标的600。齐次坐标的取值在0到1之间。如图3.5.4_2 所示: 图3.5.4_2 上面的程序中用屏幕坐标分别定义了三个矩形,经过齐次坐标转换后,这些矩形的齐 次坐标大小分别为:rectone(0,0,1,1)、recttow(0,0,0.5, 0.5)、rectthere(0.5, 0.5,1,600);同 样齐次坐标也可以转换为屏幕坐标。 - 61 - 为了方便学习,我把上面几个VSL脚本放到一个Virtools文件中,依次按下空格键就可 以逐一的观察每个脚本的运行结果。Virtools脚本上的BB连线情况如图3.5.4_3所示: 图3.5.4_3 - 62 - 第四章 Virtools 中的 2D 实体 这一章开始,内容会慢慢的生动起来,之前都是一些枯燥而缺乏表现的内容。现在我 们开始对Virtools渲染的物体进行学习,往下的每一步学习都会直观的看到表现的结果。这 一章我们主要讲Virtools的2D实体的内容。2D实体主要用来做游戏和其它虚拟仿真项目的界 面,一个界面的好坏关系着游戏的风格和项目的成败,所以2D实体必须要很好的掌握和应 用。本章的主要内容如下:  理解和应用Entity2D  理解和应用Sprite  Entity2D和Sprite的优缺点 4.1 2D 帧 Entity2D 4.1.1 Entity2D 与 Rect 在上一章的内容中的 Rect 部分已经提到了 Rect 是 Entity2D 的重要属性,这一点是理 所当然的。无论是开发游戏还是开发虚拟仿真项目,必须要有一个人机交互界面,这个界 面是用户了解和使用该软件的有效途径。Entity2D 是 2D 界面元素,它是专门用来做交互 界面的,当然也可以处理一些平面效果。既然界面,它自然是显示在屏幕上的一系列图形 化按钮,既然是按钮自然其在屏幕上的一块区域,既然是屏幕上的一块区域,自然也就有 起始位置和长宽大小,要表示这样的位置和大小,矩形首当其冲了。Entity2D 与 Rect 编 程应用请参看上一章 Rect 部分的内容。 4.1.2 Entity2D 的应用 Entity2D既然是平面的元素,自然会存在多个2D帧重叠的情况;自然也存在会遮挡3D 实体的情况。人机交互界面开发过程中,也要设置平面元素的主从关系,改变界面元素内容 的情况等等。如何解决重叠和遮挡的问题,以及主从关系等问题呢?请打开本书附带光盘的 例子4.1.2_1.cmo。 脚本―Set2DFZorder‖功能是:改变2D帧重叠顺序和改变对3D物体的遮挡顺序。 其输入参数如下: 参数名 参数类型 参数功能 参数用途 My2DF Entity2D 输入参数 输入一个2D帧 Your2DF Entity2D 输入参数 输入一个2D帧 His2DF Entity2D 输入参数 输入一个2D帧 该脚本程序代码如下: void main() { // Insert your code here My2DF.SetHomogeneousCoordinates(true); //将该2D帧设置齐次坐标属性 My2DF.SetZOrder(1); //设置2D帧渲染层级关系为1 Your2DF.SetZOrder(2); //设置2D帧渲染层级关系为2 His2DF.SetZOrder(3); //设置2D帧渲染层级关系为3 His2DF.SetBackground(true); //设置2D帧先于3D实体前渲染 } - 63 - 通过语句―My2DF.SetHomogeneousCoordinates(true)‖函数将2D帧设置齐次坐标属性 后,当改变渲染屏幕的大小时,比如从分辨率800×600,改成1024×768,2D帧的所占屏幕 的相当大小和相当位置不变;如果没有设置为齐次坐标属性的2D帧,当改变了屏幕分辨率 后,其大小相对变小了。读者可以自行测试一下。要取消齐次坐标属性也是调用该函数只是 将传递的参数true改为false即可。 改变2D帧的层级关系,可以通过―My2DF.SetZOrder(1)‖来改变,传入的数字越大,越 在上面层,层级编号大的层能遮挡层级编号小于自己的2D帧,也可传入负数。通常2D帧是 在渲染完3D实体以后才渲染,所以2D帧总是遮挡3D物体的,要想改变这种情况,可以通过 改变2D帧和3D实体的渲染顺来实现,用2D帧提供的函数―SetBackground(true)‖可以实现, 参数改为false则取消这种设置。运行结果如下图所示: 图4.1.2_1 图4.1.2_2 (运行前2D帧的层级遮挡关系) (运行后2D帧的层级遮挡关系) 运行这个VSL脚本前后,请在Virtools中查看每个2D帧的层级关系属性,这样会有利于 加深映象,巩固我们学过的内容。下图是His2DF(黄色)2D帧的属性参数页: 图4.1.2_3 运行之前,属性页中的―Position‖项的―Z Order:‖项,三个2D帧都是为0,既它们是同 - 64 - 层级关系,按照创建它们时的先后顺序,相互遮盖着。运行脚本―Set2DFZorder‖后,它们的 层级关系改变了。层级编号为2的―Your2DF‖遮盖了层级编号为1的―My2DF‖, 因为2D帧是 在渲染完3D实体以后才渲染,所以2D帧总是遮挡3D物体的,所以这个两个2D帧遮挡了住 后面的正方体。而His2DF这个2D帧,虽然层级编号为3,但它确在最下一层,被正方体和 其它2D帧所遮挡,这是因为它改变了渲染顺序,它在3D实体渲染之前就先进行了渲染,所 以被正方体遮盖着。它的属性页中的―Position‖项的―Background Sprite‖项,被VSL脚本进行 了设置,改变了渲染顺序。 脚本―SetParentClip‖功能是:改变2D帧父子关系,并设置父子间裁剪关系。 其输入参数如下: 参数名 参数类型 参数功能 参数用途 My2DF Entity2D 输入参数 输入一个2D帧 Your2DF Entity2D 输入参数 输入一个2D帧 His2DF Entity2D 输入参数 输入一个2D帧 该脚本程序代码如下: void main() { // Insert your code here Your2DF.SetParent(My2DF); //设置2D的帧的父物体 His2DF.SetParent(My2DF); Your2DF.SetClipToParent(true); //设置子2D帧可以被父2D帧裁剪 His2DF.SetClipToParent(true); Your2DF.EnableClipToCamera(true); //设置2D帧可以被摄像机进行裁剪 His2DF.EnableClipToCamera(false); //设置2D帧可以不被摄像机进行裁剪 } 运行了这个脚本后,得到的结果如下图: 图4.1.2_4 图4.1.2_5 (运行前子父2D帧的裁剪关系) (运行后子父2D帧的裁剪关系) 从这个脚本的执行结果中可以看到不在父物体范围内的子物体,已经被裁剪掉,不显 示了。在Virtools中查看子物体的2D帧的属性页,请查看―Parent‖项和―Clip to Parent‖项,它 们通关VSL脚本进行了设置。被父物体进行裁剪这种2D帧属性,对于制作弹出式菜单等非 常有用,对于2D帧被相见裁剪的效果,这里无法直观的看到效果,其意思是当2D帧移出了 相机的可视范围后,就不渲染它,这一点用得比较少,所以我也不打算演示它。 - 65 - 脚本―GetChild‖功能是:遍历父物体的每个子物体。 其输入参数如下: 参数名 参数类型 参数功能 参数用途 My2DF Entity2D 输入参数 输入一个2D帧 该脚本程序代码如下: void main() { // Insert your code here int count=My2DF.GetChildrenCount(); //得到父物体有几个子物体 Entity2D temp2df; for(int i=0;i=count) //如果插槽(Slot)编号到最后一个 curnt=0; //从插槽(Slot)编号0重新开始。 MyTex.SetCurrentSlot(curnt); //设置纹理当前的插槽(Slot)编号 } 除了可以一张一张图片的从磁盘载入生成带多个插槽(Slot)纹理,也可以直接装载一 个视频文件(*.avi格式视频文件)来生成一个带插槽(Slot)的纹理。有些视频画面很大可 以通过函数ResizeImages( )对图像的大小进行设置。 ―LoadAVI‖脚本的输入参数如下: 参数名 参数类型 参数功能 参数用途 MyTex Texture 输入参数 纹理贴图类型参数 该 VSL 脚本代码如下: - 85 - void main() { // Insert your code here MyTex.ReleaseAllSlots(); //清除所用的插槽(Slot) MyTex.LoadMovie("Horse.avi"); //从磁盘装载一个视频文件 MyTex.ResizeImages(128,128); //对纹理的大小进行重新设定。 } 既然可以从磁盘载入图片,反过来也可以将纹理的图片保存到磁盘上。另外还可以设 置纹理在内存中存在格式;以及可以设置鼠标点选透明度设置。 ―ImageSave‖脚本的输入参数如下: 参数名 参数类型 参数功能 参数用途 MyTex Texture 输入参数 纹理贴图类型参数 PFormat VX_PIXELFORMAT 输入参数 纹理在内存中存在格式 pickTh int 输入参数 鼠标点选透明度设置 该 VSL 脚本代码如下: void main() { // Insert your code here MyTex.SetDesiredVideoFormat(PFormat); //设置纹理在内存中存在的格式 MyTex.SaveImage("d:\\savePic.bmp"); //将纹理图片保存到磁盘上 MyTex.SaveImageAlpha("d:\\savePicAlpha.bmp"); //保存图片的Alpha通道到磁盘上 MyTex.SetPickThreshold(pickTh); //设置透明度以下不能点选 } 纹理是表示物体表面颜色的一块内存空间,当物体表面的细节要求越高,纹理的细节 也就越高,纹理的尺寸也就越大,但纹理所占的内存空间也就越大。合理地设置纹理在内存 中存在的格式,可以大大地降低内存的消耗。默认的纹理纹理内存格式是―_32_ABGR8888‖, 简单地说也就是纹理上的一个像素由32位内存空间表示。Direct3D支持纹理的压缩和实时解 压缩,即DXT纹理压缩。应用DXT纹理压缩不仅节省大量内存空间,而且能有效的降低纹理 传输带宽,提高图形系统的整体性能。DXT纹理压缩有五个级别,其中DXT1支持15位RGB 和1位Alpha图形格式;DXT2和DXT3支持12位RGB和4位Alpha;DXT4和DXT5则采用了线 性插值的方法生成Alpha。 在VSL中通过函数SetDesiredVideoFormat( )函数可以设置,但是有一点要提醒的是, 当设置成为压缩图片格式以后,再还原为压缩前的格式时,是无法完全复原的,因为压缩会 丢失部分信息,而且压缩会给物体表面带来一点失真现象,如果要求很细致的物体表面建议 不使用DXT纹理压缩格式。 SetPickThreshold( )函数能让鼠标点不中图片,只有当图片Alpha度高于参数pickTh的 数值才能用鼠标选中,这也是Virtools一个特色的地方。 5.3.2 天空盒纹理 无论是模拟室内的场景还是模拟室外的场景,很多时候需要利用天空盒来烘托整个场 景的气氛,让天空盒(Skybox)作为整个场景的环境和背景。在 Virtools 中实现天空盒很 简单方法如下: ―CubeMapTex‖脚本的输入参数如下: 参数名 参数类型 参数功能 参数用途 - 86 - MyOutText Texture 输入参数 纹理贴图类型参数 该 VSL 脚本代码如下: void main() { // Insert your code here MyOutText=bc.CreateTexture("CubeTex"); //创建一个纹理 MyOutText.LoadImage("View2_r.bmp",0); //加载天空的右面图片 MyOutText.LoadImage("View2_l.bmp",1); //加载天空的左面图片 MyOutText.LoadImage("View2_u.bmp",2); //加载天空的头顶图片 MyOutText.LoadImage("View2_d.bmp",3); //加载天空的底部图片 MyOutText.LoadImage("View2_f.bmp",4); //加载天空的前面图片 MyOutText.LoadImage("View2_b.bmp",5); //加载天空的后面图片 MyOutText.SetCubeMap(TRUE); } 天空盒6张图片,就像用一个正方体包含了世界。当生成天空盒纹理的所有6个插槽都 准备好后,调用函数SetCubeMap(TRUE)就可实现纹理的转变了。 - 87 - 第六章 3D 物体 3D物体是Virtools最基本的一个物体,你在场景中看到的每一个物体都3D物体。笨重 的石头,流淌的河水,飞行的鸟儿,舞动的精灵等等,这些都是3D物体,它让我们的游戏 变得生动起来。3D物体在Virtools中为了方便应用和表示进行了一些细分:3D实体(3D Entity)、 3D帧(3D Frame)、 3D精灵(3D Sprite)这些都是3D物体的分类,这一章我们就 来学习3D物体,你将会学习到以下内容:  坐标系概念  3D Entity的应用  Mesh的应用  3D Frame的引用  3D Sprite的概念和引用 6.1 3D Entity 6.1.1 坐标系和 3D 物体 计算机3D图形学最基本的目标就是将构建好的3D物体显示在2D屏幕坐标上。在虚拟世 界里有多个物体,而每个物体都有自己的位置、方向和大小。如何表述这些物体间谁大、谁 小、谁远、谁近的差异呢?这时就需要个参照,于是定出了世界坐标系做为一切参照的原点 (这是我个人理解的,至于科学家如何定义的先放下,这样理解就够了)。 我们在中学的时候学习的平面坐标系只是2元的,就x,y轴两个方向。现在通过添加一 个垂直于x、y轴平面的z轴到这个平面坐标系中,这个坐标系就变成3元的了,就可以表示三 维空间中的任意一点了,这个就是三维坐标系,也就做笛卡尔三维坐标系。它的名字来源其 发明者法国数学家笛卡尔(Rene Descartes),很巧我们使用的Virtools也来自法国。 根据z轴相对于x、y轴平面的方向不同,三维坐标系可分为左手坐标系和右手坐标系。 如何区分呢?用右手定则和左手定则来区分。我们就用左手则吧,举起你的左手(no、no、 no不用举过头顶,另外请不要在意周围的人对你的举动感到困扰),左手要靠近屏幕,大拇 指指向X轴正方向,食指指向Y轴正方向,然后弯曲其余3指,此时这3个手指的弯曲方向如 果是坐标系的Z轴正方向,那这个坐标系就是左手坐标系,反之就是右手坐标系。对照这下 图6.1.1_1看一下Virtools用的是左手坐标系,还是右手坐标系。 如图6.1.1_1 - 88 - 6.1.2 3D Entity 的位置、方向与缩放 3D物体具有三个方向的属性,相对于2D物体虽然多了一个方向的属性(即Z轴方向), 但正是因为这个多出的z轴方向,才使得虚拟世界变得充满梦幻色彩,同时事情也变得异常 复杂。即使仅仅是对物体的位置,方向,大小做简单处理也要比二维的情况复杂得多。 在 VSL 脚本中用符号―Entity3D‖来表示 3D 实体(3D Entity)类型。请打开例子 6.1.2_1.cmo,这个程序包含了多个 VSL 脚本代码。其中―VSLScale‖脚本的输入参数如下: 参数名 参数类型 参数功能 参数用途 MyEntity3D Entity3D 输入参数 表示一个3D实体 scale Vector 输入参数 一个缩放向量 child bool 输入参数 是否作用于子物体 absolute bool 输入参数 是否开启累加缩放 其代码如下: void main() { // Insert your code here if(absolute) // absolute=ture时相对缩小形式 MyEntity3D.SetScale(scale,child); //进行相对缩放 Else // absolute=false时累加缩小形式 MyEntity3D.AddScale(scale,child); //进行累加缩放 } 这里强调说明SetScale( )和AddScale( )的这两个函数区别,如果一个物体是(1,1,1) 大小,用SetScale( )函数进行连续2次缩放,其大小仍然是原来的2倍大小即(2,2,2);如果用 AddScale( ) 函数进行连续2次缩放,那么大小就是原来的4被大小了即(4,4,4)。读者编程的 时候要注意这点,否则会出现比较让人纳闷的错误。 设置3D物体的位置其实很简单,但是如果你没有理解好,就会把这个简单的事情做复 杂。我们要移动一个物体理所当然想到先使用得到当前物体位置,然后加上一个移动的分量, 最后把物体设置到这个新位置上。其实有更简单的。 ―Translate‖脚本的输入参数如下: 参数名 参数类型 参数功能 参数用途 MyEntity3D Entity3D 输入参数 表示一个3D实体 Speed Vector 输入参数 一个速度向量 RefEntity3D Entity3D 输入参数 表示一个3D实体 其代码如下: void main() { // Insert your code here MyEntity3D.Translate(Speed,RefEntity3D); //移动物体。 //Vector oldpos,newpos; //声明两个位置向量 //MyEntity3D.GetPosition(oldpos); //获得当前位置 //newpos=oldpos+Speed; //计算新位置 //MyEntity3D.SetPosition(newpos); //设置新位置 } 从上面的代码可以看出移动物体是一件多么简单的事情,仅需要调用一个函数即可。 参数Speed表示物体要移动的分量;参数RefEntity3D是物体的参照物,如果RefEntity3D为 - 89 - 空(也就是不填参数即NULL),那么物体的移动分量就是以世界坐标原点的位置和方向做 参照。如果你是想让物体按着自己的方向一路向前进,当物体方向发生改变时,你就会发现 这个函数不能自动改变方向。要解决这个问题就要把RefEntity3D参数设置为自身,让物体 按照自身坐标的位置和方向做参照运动。读者可以自己在实例程序中对这个参照物进行更改, 然后观看效果以便加深印象。 物体的旋转有两种方法,一种是使用标准的x、y、z轴定义的方式旋转,一种是以四元 数的标准来旋转。一般情况下是不会使用四元数执行标准的3D旋转和操作,但可以使用它 进行高级的相机操作,这会比用角度来操作要直观得多。 其中―VSLRotate‖脚本的输入参数如下: 参数名 参数类型 参数功能 参数用途 MyEntity3D Entity3D 输入参数 表示一个3D实体 Speed Vector 输入参数 一个方向向量 Angle float 输入参数 一个弧度值 quat Quaternion 输入参数 一个四元数表示方向 Dir Vector 输入参数 一个方向向量 Up Vector 输入参数 一个方向向量 Right Vector 输入参数 一个方向向量 其代码如下: void main() { // Insert your code here MyEntity3D.Rotate(RotateAxis,Angle); //物体绕RotateAxis轴旋转Angle个弧度 MyEntity3D.GetQuaternion(quat); //得到旋转后的四元数方向 MyEntity3D.GetOrientation(Dir,Up,Right); //得到旋转后的方向向量 } 函数Rotate( )的参数Angle是个弧度值,在Virtools脚本中可以添加一个局部参数(鼠标 右键,然后选择:Add Local Parameter),然后把这个参数的类型设置为―Angle‖类型,最 后通过连线到该VSL脚本的这个弧度值参数上即可,这样就可以按直观的360度的方法填写 旋转角度了,Virtools会自动地进行转换处理。 上面是旋转物体的脚本代码,现在来看看怎样设置一个物体的方向的脚本代码。其中 ―SetDir‖脚本的输入参数如下: 参数名 参数类型 参数功能 参数用途 MyEntity3D Entity3D 输入参数 表示一个3D实体 quat Quaternion 输入参数 一个四元数表示方向 Dir Vector 输入参数 一个方向向量 Up Vector 输入参数 一个方向向量 ways bool 输入参数 设置方向的方法选择 其代码如下: void main() { // Insert your code here if(ways) //当ways=1时 MyEntity3D.SetQuaternion(quat); //用四元数方式设置物体的方向。 else //当ways=0时 - 90 - MyEntity3D.SetOrientation(Dir,Up); //用标准向量方式设置物体的方向 } 这两种方法效果是一样的,从对比可以看出,用向量的的方法设置物体的方向需要2个方向 方向的向量(Dir,Up)。为什么需要向量呢?请看下图6.1.2_1 如图6.1.2_1 自身坐标系又叫做建模空间,这是物体以自己为原点参照的坐标系。自身坐标系简化 了建模的过程。在物体自己的坐标系中建模比在世界坐标系中直接建模更容易。例如,在自 身坐标系中建模不像在世界坐标系中要考虑本物体相对于其他物体的位置、大小、方向关系。 在物体的自身坐标系中,用三个相互垂直的向量来表示其物体的方向,当确定了其中两个方 向,另外一个也就确定了。所以当我们用向量的方法来设置物体的方向时,需要两个方向上 的向量,就是这个道理。 还记得前面我们学过的关于矩阵的知识吗,设置物体的位置和方向也可以使用的方法 来进行。其中―ConstructWorldMatrix‖脚本的输入参数如下: 参数名 参数类型 参数功能 参数用途 MyEntity3D Entity3D 输入参数 表示一个3D实体 RefEntity3D Entity3D 输入参数 表示一个3D实体 inveMatrix Matrix 输出参数 一个空间矩阵 其代码如下: void main() { // Insert your code here Vector pos,scale //定义两个向量; RefEntity3D.GetPosition(pos); //得到参照物的位置向量 scale.Set(0.5,0.5,0.5); //设置缩放比例 Quaternion quate; //定义一个四元数方向 RefEntity3D.GetQuaternion(quate); //得到参照物的四元数方向 MyEntity3D.ConstructWorldMatrix(pos,scale,quate); //进行矩阵变换 inveMatrix=MyEntity3D.GetInverseWorldMatrix(); //得到矩阵的逆矩阵 } 上面代码的倒数第二行,是根据传入的位置,缩放比例和方向先构造了一个矩阵,这 个矩阵包含了物体位置,物体缩放程度以及物体方向,最后物体用这个矩阵进行了变换。最 - 91 - 后一行代码是得到物体当前矩阵的逆矩阵,关于这方面的只是请读者自行参看《线性代数》 的相关内容。 得到逆矩阵以后,再让物体变换一次看一下效果。其中―SetWorldMatrix‖脚本的输入参 数如下: 参数名 参数类型 参数功能 参数用途 MyEntity3D Entity3D 输入参数 表示一个3D实体 mattrix Matrix 输入参数 一个空间矩阵 其代码如下: void main() { // Insert your code here MyEntity3D.SetWorldMatrix(mattrix); //物体用矩阵mattrix进行变换 } 在很多地方,Virtools提供了很好的BB来使用,所以矩阵的内容使用得很少。但是作 为知识的完整性,掌握它是有好处的。 6.1.3 物体的父子关系 这种父子关系能为我们处理3D场景中的物体带来极大的方便,比如说一个人走上了车, 车开动了,这时候我们把人设置为车的子物体;车移动,人自然也跟着移动,同时人可以在 车上进行自身的动作,比如挪动位置。当人下了车以后,再把人与车的父子关系取消;这时 候车开走了,人就不会再跟着车移动,而是按着自己的方向移动。如果没有这种父子关系, 要处理这种现象将会很麻烦。 设置子物体之间的类型必须是相同类型的,比如说3D实体的父物体必须是3D实体,2D 帧的子物体必须是2D子物体,否则编译程序的时候就会出错。一个物体只能有一个父物体, 就好像一个小孩只能有一个亲爸爸一样,不能同时设置多个父物体,但子物体的父物体是可 以成为另一个物体的子物体,就好像爸爸还有自己的爸爸一样的道理。这一点不用多说相比 读者也很容易明白。请打开例子6.1.3_1.cmo,其中―ParentChildren‖脚本的输入参数如下: 参数名 参数类型 参数功能 参数用途 NewCask Entity3D 输入参数 表示一个3D实体 OldCask Entity3D 输入参数 表示一个3D实体 BadCask Entity3D 输入参数 表示一个3D实体 其代码如下: void main() { // Insert your code here NewCask.AddChild(OldCask); //将OldCask设置成NewCask的子物体 BadCask.SetParent(NewCask); //将NewCask设置成BadCask的父物体 Vector pos01,pos02; OldCask.GetPosition(pos01); //得到OldCask的世界坐标位置 BadCask.GetPosition(pos02,NewCask); //得到BadCask相对于NewCask的坐标位置 BadCask.SetPosition(pos01); //设置OldCask的世界坐标位置 OldCask.SetPosition(pos02,NewCask); //设置OldCask相对于NewCask的坐标位置 NewCask.SetPosition(0,2,0,NULL,FALSE); //改变父物体的位置 int count=NewCask.GetChildrenCount(); //得到父物体的子物体数量 - 92 - Entity3D temp=NewCask.GetChild(0); //得到其中的一个子物体 //NewCask.RemoveChild(BadCask); //脱离某物体的父子关系 } 这个代码运行前后的效果如下图6.1.3_1与图6.1.3_2所示: 图6.1.3_1(运行前) 图6.1.3_2 (运行后) 图6.1.3_3(Mesh改变后) 从上面的运行结果可以看出,设置子物体的位置不会影响父物体的位置。如果改变的 是父物体的位置,子物体也会跟着改变,同样改变物体的方向和大小,子物体也会相应的改 变。在上面最后一行代码NewCask.SetPosition(0,2,0,NULL,FALSE)前三个参数分别表示x 轴、y轴、z轴的值,也可以用一个Vector参数来代替这三个参数,VSL已经对这个函数进行 了重载。第四个参数为―NULL‖,表示设置的是世界坐标位置;第五个参数为―FALSE‖表示这 个操作会影响到其子物体,如果改参数为―TRUE‖则表示操作只影响自身,而不会作用于其 子物体。 如果想取消一个物体的父子关系可以SetParent( )函数,把―NULL‖传递给改函数即可, 例如―BadCask.SetParent(NULL)‖,表示把BadCask的父亲物体设置为NULL,既设置为空, 空则表示没有,没有就是没了任何关系。另外也可用函数RemoveChild( )函数实现这个功能, 将要脱离父子关系的子物体传递给改函数即可。如果想知道父物体拥有子物体的数量可以用 函数GetChildrenCount( ),想得到其中的某一个子物体可以那个用函GetChild( )数。 VSL的每个类,每个类型都提供很多函数方法,书中没有办法进行一一的举例学习, 读者请自行查看帮助文档,书中的内容毕竟只是为了教学的目的,所以很多例子只是教学而 已。不过作者会将绝大部分常用的函数方法提供教学实例。 上面的代码只是移动位置,下面我们看一下旋转的时候,父子关系之间的影响。请看 脚本―ParentChildren‖,它输入参数如下: 参数名 参数类型 参数功能 参数用途 NewCask Entity3D 输入参数 表示一个3D实体 OldCask Entity3D 输入参数 表示一个3D实体 BadCask Entity3D 输入参数 表示一个3D实体 angle float 输入参数 表示选择角度 child bool 输入参数 是否涉及子物体 其代码如下: void main() { // Insert your code here Vector AxisY(0,1,0); //定义旋转轴,表示以Y轴旋转 NewCask.Rotate(AxisY,angle,NULL,child); //旋转父物体 OldCask.Rotate(AxisY,-2*angle); //正方向旋转子物体 BadCask.Rotate(AxisY,-2*angle); //反方向旋转子物体 } 上面的代码很简单,Rotate( )函数的第一参数表示:物体以某一向量为旋转轴进行旋 转;第二个参数输入的是一个弧度值;第三、第四个参数请参考上一个脚本代码中使用到的 函数SetPosition( )。 改变位置,设置父子关系和物体旋转是很简单的一件事情,当这些操作发生时,物体 - 93 - 本身的一些属性也发生了变化,但一些属性却不会方生变化,很多这些属性只有自己多编写 代码测试才能发现。请看脚本―GetBoxCentre‖,它的输入参数如下: 参数名 参数类型 参数功能 参数用途 MyEntity Entity3D 输入参数 表示一个3D实体 centrepos Vector 输出参数 物体的中心位置向量 mybox Bbox 输出参数 物体的包围盒大小 Hbox Bbox 输出参数 涉及子物体的包围盒大小 Radius float 输出参数 物体的半经 其代码如下: void main() { // Insert your code here MyEntity.GetBaryCenter(centrepos); //得到物体的重心位置 mybox=MyEntity.GetBoundingBox(false); //得到物体的绑定盒子大小 Hbox=MyEntity.GetHierarchicalBox(false); //得到父子物体的绑定盒子大小 Radius=MyEntity.GetRadius(); //得到物体的半经大小 } 物体的重心位置、半经和自身包围盒是不会因为物体的父子关系而变化,但父子物体 的包围盒会因为父子关系而变化。运行例子程序你会发现父物体随着旋转在包围盒的大小在 不断的变化。另外GetBoundingBox( )和GetHierarchicalBox( )带一个参数,如果改参数为 ―false‖时,表示包围盒的坐标是世界坐标位置;如果为―true‖时,表示包围盒的坐标位置为 局部坐标位置,就是以自身为参照原点的位置。 6.1.4 物体的网格(Mesh) 什么是物体的网格,简单的说网格就是一系列点线面的集合,是物体渲染的基本信息。 网格必须包含于3D实体中,否则Virtools不会单独渲染它。3D实体可以随时卸载网格和加入 新网格,并指定Virtools渲染哪一个网格。关于网格的具体内容在下一章再详细介绍。 还是请打开例子6.1.3_1.cmo,其中脚本―MeshSet‖就是这个功能的实现,它的输入参 数如下: 参数名 参数类型 参数功能 参数用途 MyEntity Entity3D 输入参数 表示一个3D实体 Mesh01 Mesh 输入参数 网格一 Mesh02 Mesh 输入参数 网格二 Mesh03 Mesh 输入参数 网格三 其代码如下: void main() { // Insert your code here MyEntity3D.RemoveMesh(Mesh01); //移出当前网格 MyEntity3D.AddMesh(Mesh02); //加入一个新网格 MyEntity3D.AddMesh(Mesh03); //加入一个新网格 MyEntity3D.SetCurrentMesh(Mesh03); //设置一个网格为当前网格 } 当移出、添加网格的时候,物体的包围盒以及物体的半径是会发生改变的,这一点再 - 94 - 处理碰撞时要注意。一个物体如果加入了多个网格,那么只有当前的网格才能被渲染处理, 其它的不渲染。另外被移出的网格,并没有从内存中删除,还可以把它重新加入进来。上面 代码执行后,物体的网格发生的变化参见图6.1.3_3。 6.1.5 3D 物体的屏幕坐标位置 有时候在处理场景时,也需要知道3D物体在屏幕上显示的大小区域,实现这个功能在 Virtools4.0版本里面已经是件很简单的一件事情了。请打开例子6.1.5_1.cmo,其中的VSL 脚本代码的输入输出参数如下: 参数名 参数类型 参数功能 参数用途 MyEntity3D Entity3D 输入参数 表示一个3D实体 MyEntity2D Entity2D 输入参数 表示一个2D实体 Isallinde bool 输出参数 用于接受函数返回的检测结果 inside bool 输出参数 用于接受函数返回的检测结果 其代码如下: void main() { // Insert your code here Rect myrect; //定义一个矩形变量 myEntity3D.GetRenderExtents(myrect); //得到3D实体在屏幕上的矩形大小 myEntity2D.SetRect(myrect); //改变2D实体在屏幕的大小 isallinde=myEntity3D.IsAllInsideFrustrum(); //检测3D实体是否全部在屏幕内。 RenderContext RCPlayer=bc.GetPlayerRenderContext(); //得到图形渲染设备 inside=myEntity3D.IsInViewFrustrum(RCPlayer); //检测3D实体是否还在屏幕内。 //检测3D物体包括其所有子物体,是否在屏幕内 //inside=myEntity3D.IsInViewFrustrumHierarchic(RCPlayer); } 运行该程序的时候,请用鼠标移动场景内的3D物体的,然后把它随意拖动,并拖动到 窗口外,再拖回来。你会发现屏幕窗口上的2D帧会随着3D物体近大远小的变化。在上面的 代码中有个RenderContext类型的变量,这个变量表示图形渲染设备,是渲染3D、2D物体 的内存上的一块区域。另外我们还可以判断3D物体是否在屏幕画面内,VSL都为我们提供 了相当实用的函数,请读者在运行该程序的时候仔细观察,该脚本输出变量的变化,以加深 印象。 6.2 Mesh 6.2.1 mesh 到应用 3D物体都是基于网格的,一般情况下网格都是由三角形、四边形组成的,在Virtools 和绝大部分游戏引擎中,说到网格都是指由三角形组成的。在前面一个小节中,我们说过仅 仅是创建一个Mesh,Virtools是不会渲染它的,但仍然会占用内存空间。 下面的程序,先是创建一个3D Entity实体,这时Virtools是渲染来这个3D实体,但是你 仍然看不到它,因为创建的3D实体只是一个空壳而已,没有网格就好像没有身体,又怎样 能被看见呢?接着我们创建一个网格,这个网格是一个由四个点、两个面组成的正方形,并 把这个网格设置为3D实体的当前网格。 请打开例子6.2.1_1.cmo,其中脚本―CreatMesh‖就是这个功能的实现,它的输出参数 - 95 - 如下: 参数名 参数类型 参数功能 参数用途 myEntity3D Entity3D 输出参数 创建的一个3D实体 myMesh Mesh 输出参数 创建的一个网格 其代码如下: void main() { // Insert your code here myEntity3D=bc.CreateEntity3D("myEntity3D"); //创建一个实体myEntity3D myMesh=bc.CreateMesh("myEntity3D_Mesh"); //创建一个网格myEntity3D_Mesh myEntity3D.AddMesh(myMesh); //把网格加到3D实体身上 myEntity3D.SetCurrentMesh(myMesh); //把该网格设置当前渲染到网格 myMesh.SetVertexCount(4); //创建网格拥有的顶点数为4 Vector Vertexpos(-1,1,0); //等于一个位置向量 myMesh.SetVertexPosition(0,Vertexpos); //设置网格第一个顶点位置 Vertexpos.Set(1,1,0); //改变位置向量的值 myMesh.SetVertexPosition(1,Vertexpos); //设置网格第二个顶点位置 Vertexpos.Set(1,-1,0); //改变位置向量的值 myMesh.SetVertexPosition(2,Vertexpos); //设置网格第三个顶点位置 Vertexpos.Set(-1,-1,0); //改变位置向量的值 myMesh.SetVertexPosition(3,Vertexpos); //设置网格第四个顶点位置 Vertexpos.Set(0,0,-1); //表示一个方向向量 myMesh.SetVertexNormal(0,Vertexpos); //设置网格第一个顶点的方向 myMesh.SetVertexNormal(1,Vertexpos); //设置网格第二个顶点的方向 myMesh.SetVertexNormal(2,Vertexpos); //设置网格第三个顶点的方向 myMesh.SetVertexNormal(3,Vertexpos); //设置网格第四个顶点的方向 myMesh.SetFaceCount(2); //设置网格到面的数量为2个面 myMesh.SetFaceVertexIndex(0,0,1,3); //设置网格第一个面 myMesh.SetFaceVertexIndex(1,3,1,2); //设置网格第二个面 Color mycolor(255,0,0); //定义一个颜色 int corint=mycolor.GetRGB(); //得到颜色的值到另一种形式 myMesh.SetVertexColor(0,corint); //设置网格第一个顶点的颜色 mycolor.Set(0,255,0); corint=mycolor.GetRGB(); myMesh.SetVertexColor(1,corint); //设置网格第二个顶点的颜色 mycolor.Set(0,0,255); corint=mycolor.GetRGB(); myMesh.SetVertexColor(2,corint); //设置网格第三个顶点的颜色 mycolor.Set(255,255,255); corint=mycolor.GetRGB(); myMesh.SetVertexColor(3,corint); //设置网格第四个顶点的颜色 - 96 - myMesh.SetLitMode(VX_PRELITMESH); //设置为顶点渲染模式 } 上面到程序的第一行、第二行分别创建来一个名为―myEntity3D‖的3D实体,和一个名为 ―myEntity3D_Mesh‖的网格。至于什么是―bc‖,放在后面再说。我们创建了实体和网格之后, 然后把网格设置为3D实体的当前网格。正如前面所说网格是由三角形组成的,而一个三角 形由3个点和3条边组成,自然要创建点和面,所谓面就是被三角形所围起来到区域,在3D 图形引擎中常把物体到一个个三角形称之为面。比如这个建筑有几个面,说的就是网格由几 个三角形组成。如下图6.2.1_1所示: 图6.2.1_1 上图显示了程序创建4个顶点,共2个面到情况,网格的顶点编号分别为0、1、2、3; 这里要提到的是函数SetFaceVertexIndex(0,0,1,3),它到第一个参数表示网格到面数,这里 是第0个面,其它的三个参数表示网格的顶点编号(0、1、3);表示由这个三个顶点围成 到三角形一,不同到顶点编号排练顺序会围成不同到三角形,如编号(3、1、2)围成的三 角形二。但是再创建面的时候,最好按着同一个方向来创建,如上图是按顺时针到方向生成 三角形面的,这一点为日后到管理修改带来方便。 我们创建的点面以后,还要给点面设置方向,这样计算机才能分区那些是背面,那些 是正面,哪些要面渲染,哪些面不要渲染;顶点到方向还涉及到光照对物体到影响,所以顶 点的方向是不可缺少到,设置顶点到方向向量用函数SetVertexNormal( )。 现在我们已经创建了3D实体的网格,此时运行起来是可以看见到物体了,但是却是一 个黑块,这是因为我们没有使用到材质到缘故。不过我们可以给网格的顶点上色,使用顶点 渲染的方式来让它看起来美观一些。要给顶点上色可以使用SetVertexColor ( )函数,上了色 以后还要设置网格到渲染模式,改变渲染模式使用SetLitMode(VX_PRELITMESH)函数。参 数VX_PRELITMESH为顶点渲染的方式,默认的时候为VX_LITMESH普通光照模式。当设 置为顶点渲染模式,灯光照射到该物体的时候就会无效了。 6.2.2 mesh 与材质、纹理贴图 要获得丰富到虚拟场景体验,少不了给3D物体使用材质和纹理贴图。一个3D Entity可 以拥有多个Mesh,一个Mesh也可以拥有多个材质,而一个材质却只能有一张或者没有纹理 贴图,一张纹理又有多个插槽。 现在继续接着上一小节讲开来,脚本―Set MeshMat‖功能是把传入给脚本的材质参数赋 给Mesh。渲染结果可以看到一副图。该脚本的输入参数如下: 参数名 参数类型 参数功能 参数用途 MyMesh Mesh 输入参数 一个网格参数 myMat Material 输入参数 一个材质参数 其代码如下: void main() - 97 - { // Insert your code here MyMesh.SetLitMode(VX_LITMESH); //设置为普通光照模式 MyMesh.ApplyGlobalMaterial(myMat); //将所有面都设置成使用该材质 MyMesh.SetVertexTextureCoordinates(0,0,0); //分配第一个顶点的纹理坐标 MyMesh.SetVertexTextureCoordinates(1,1,0); //分配第二个顶点的纹理坐标 MyMesh.SetVertexTextureCoordinates(2,1,1); //分配第三个顶点的纹理坐标 MyMesh.SetVertexTextureCoordinates(3,0,1); //分配第四个顶点的纹理坐标 } 上面这一小段代码的第二行的作用是将所有面都设置成使用该材质,言下之意也可以 不这样做,可以让某些面使用某一个材质,另一些面使用另一个材质。该VSL脚本的最后四 行代码是不是很费解?是的,因为这里涉及了一个新概念——纹理坐标。这个内容我并没有 放到纹理贴图章节中去说,讲一些还用不到的概念,我说得费劲,读者理解起来也费劲,何 必呢。还不如在用到的时候再学习! 6.2.3 纹理坐标:(u, v)坐标 纹理是如何应用三角形上面的呢?纹理贴图是按像素点使用的。由于纹理贴图是二维 的,可以使用二维的坐标系就可以表示每个像素点位置了。这个二维的坐标系,是跟屏幕的 坐标系是一样的,都是左上角表示坐标的原点,向右表示为u坐标轴的方向,从原点向下表 示为v坐标轴的方向。不管纹理的图片大小,所有纹理元素在坐标系里的坐标值都是从0到1 之间。也就是说u和v的值在0到1之间。比如设置顶点里的纹理坐标值(u,v)为(0.5,1.0),当不 同大小的图片作为纹理时,这个顶点使用的纹理元素是不一样的。假如图片大小为100×80 的纹理图片,那么它使用纹理元素就是(50,80)的像素。假如图片大小为800×600的纹 理图片,那么它使用纹理元素就是(400,600)的像素。上面说纹理坐标值一般是0到1.0 之间,也可以让纹理坐标值超过1.0的,这时看到的图像是重复的,读者可以自己改一下最 后四行到参数,看看效果。例如: MyMesh.SetVertexTextureCoordinates(0,0,0); //分配第一个顶点的纹理坐标 MyMesh.SetVertexTextureCoordinates(1,2,0); //分配第二个顶点的纹理坐标 MyMesh.SetVertexTextureCoordinates(2,2,2); //分配第三个顶点的纹理坐标 MyMesh.SetVertexTextureCoordinates(3,0,2); //分配第四个顶点的纹理坐标 函数SetVertexTextureCoordinates( )有四个参数,第一个参数表示顶点的索引编号, 第二、第三个参数分别表示要设置到纹理(u, v)坐标。其实还有第四个参数,只是该参数 默认值为-1,表示对mesh默认的材质及其纹理贴图进行设置。网格除了默认的材质还有另 一种材质——通道材质。 6.2.4 Mesh 的通道材质特性 给Mesh加上若干个通道,可以让你实现多重渲染叠加,每一个通道都会作用于网格的 点和面上。可以用这种方法来实现环境映射和地形上阴影的效果。实际上目前的很多3D游 戏的地形阴影都是采样阴影通道来实现的。 给网格加上一个通道使用的函数是AddChannel( ),它需要传入一个材质做为其参数, 同时还要设置相应的渲染混合方式。这些混合方式参数的意义,在我们前面的章节中提到过 了。这里要提醒的是:如果网格使用的是顶点渲染方式,那么添加的通道也会进行同样的顶 点渲染方式;如果使用的普通光照渲染方式,那么添加的通道也会进行普通光照方式渲染。 例子6.2.1_1.cmo中的脚本―ChannelMat‖只是简单的给我们之前创建的网格加上一层 通道,并且让网格丢弃原来的材质而使用另外一个新的材质,其输入参数如下: - 98 - 参数名 参数类型 参数功能 参数用途 MyMesh Mesh 输入参数 一个网格参数 replaceMat Material 输入参数 一个材质参数 ChannelMat Material 输入参数 一个材质参数 其代码如下: void main() { // Insert your code heremyMesh Material oldMat=myMesh.GetMaterial(0); //得到网格的材质 myMesh.ReplaceMaterial(oldMat,replaceMat); //替换材质 myMesh.AddChannel(ChannelMat); //添加一个网格通道 myMesh.SetChannelSourceBlend(0,VXBLEND_ZERO); //设置通道0的渲染方式 myMesh.SetChannelDestBlend(0,VXBLEND_SRCCOLOR); //设置通道0的渲染方式 } 上面的脚本代码中GetMaterial(0) 得到的是网格的第一材质也是该网格的唯一个材质, 如果网格有多个材质,可以通过传入的参数来获得其中某一个材质。要想获得该网格一共有 多少个材质使用函数GetMaterialCount( ),此函数只是得到网格的三角面所使用的材质数, 并不包括增加的通道所包含的材质数目。 对网格进行替换材质的函数是ReplaceMaterial( ),它有两个输入参数,第一个是要被 替换的材质,第二个是新的材质。由于网格可以添加多个通道,所以在设置通道材质的渲染 混合方式时要指定应用于哪一个通道。比如上面最后两行程序所执行的两个函数中,第一个 参数为通道编号,第二参为混合方式参数。 该脚本的运行结果如下图所示: 图6.2.4_1(通道图) 图6.2.4_2(原图) 图6.2.4_3(合成图) 6.3 3D 精灵 Sprite3D 6.3.1 3D 精灵和公告板 其实所谓精灵只是用法和实体上存在一点特别,实际上它也是一个3D实体,只不过是 一个比较特殊的3D实体,在VSL脚本中用Sprite3D符号表示精灵。精灵不同与Entity3D类型 的实体,Entity3D有自己的Mesh(网格);而Sprite3D没有网格,它更像是一个矩形的面 片存在于3维空间中,Sprite3D和Entity3D一样有自己的材质和贴图,它能够以公告板的方 式进行渲染,这也许正是它称为―精灵‖的原因,同时它也能够以3维坐标系的某个旋转轴作 为参考进行渲染。 - 99 - 公告板(Billboard)技术是一项非常有创意和实用的技术,它是以用一种简单的方式来完 成很多特别的效果。例如我们在Virtools中使用到的粒子系统BB,那些发射出来的粒子就是 公告板技术的应用;还有比如一些路灯的灯光,以及一些树木,如果使用公告板技术来实现, 将会非常简单,而且效果很好等等。在一定程度上来说,这些效果也可以由其他更真实的技 术来实现,但公告板技术最吸引人的地方在于实现这些效果时所使用的系统资源极低。 公告板技术其实很简单,它就是使用了两个三角形组成的矩形来显示一个图片,在显 示过程中这个矩形面板根据摄像机的观察角度和位置的变化而变化。公告板大体分为二类, 根据项目的实际需求而进行选择使用: (1)摄像机在虚拟空间中任意移动位置和改变方向,Sprite3D始终面对着摄像机。例 如,一个太阳,一个月亮。这一类在时间项目中使用得较少,还不如直接放一个3D模型的 太阳、月亮更好看些。 (2)在角色扮演游戏中,摄像机可以做的旋转只限于水平和竖直。程序员的目的是: 无论角色如何走动,某一个平面模型始终朝向摄像机。也就是平面模型的平面和视线垂直。 例如有一些2.5D的游戏就大量采样了这样的技术。 6.3.2 3D 精灵的应用 请读者打开程序例子6.3.2_1.cmo,这个程序是拷贝三个Sprite3D类型的树木,然后 copy出若干个,形成一片树林。读者试着改变该VSL脚本的第四个参数,然后运行并旋转摄 像机观看,就会发现不同类型的公告板的使用情况。该代码输入参数如下: 参数名 参数类型 参数功能 参数用途 MySprite01 Sprite3D 输入参数 一个3D精灵 MySprite02 Sprite3D 输入参数 一个3D精灵 MySprite03 Sprite3D 输入参数 一个3D精灵 Sprite3Dmode VXSPRITE3D_TYPE 输入参数 公告板技术选择 其代码如下: void main() { // Insert your code here MySprite01.SetMode(Sprite3DMode); //设置公告板技术的使用的模式 MySprite02.SetMode(Sprite3DMode); //设置公告板技术的使用的模式 MySprite03.SetMode(Sprite3DMode); //设置公告板技术的使用的模式 //以下代码是制作一片树林 Vector pos; //一个位置向量 Sprite3D tempsp; //定义一个Sprite3D类型变量,用于暂时存放拷贝出来的树 for(int i=0;i<80;i++) //拷贝出八十棵树木 { int posx=randi(-80,80); //在-80和80之间随机一个数,做X轴的分量 int posz=randi(-80,80); //在-80和80之间随机一个数,做Z轴的分量 pos.x=posx; //给位置向量的X轴分量赋值 pos.z=posz; //给位置向量的Z轴分量赋值 pos.y=-1; //做Y轴的分量为-1 int mycase=randi(0,3); //随机拷贝某一棵树 switch(mycase) { - 100 - case 0: //拷贝MySprite01并设置位置 tempsp=Sprite3D .Cast(bc.Copy(MySprite01)); tempsp.SetPosition(pos); break; case 1: //拷贝MySprite02并设置位置 tempsp=Sprite3D .Cast(bc.Copy(MySprite02)); tempsp.SetPosition(pos); break; case 2: //拷贝MySprite03并设置位置 tempsp=Sprite3D .Cast(bc.Copy(MySprite03)); tempsp.SetPosition(pos); break; } } } 上面的函数中使用到一个全局函数randi(int min,int max ),它的作用是在给定的整数范 围内随机抽取出一个整数并返回。但要注意的是随机的范围是:min≤随机数<max。所以我 们再随机拷贝哪一棵树的时候,参数填的是0到3(即randi(0,3);)。随机的拷贝了一个树后, 然后设置到随机得到的一个位置上,就形成了一片树林。 - 101 - 第七章 角色动画 在游戏里面,让游戏变得生动的就是角色,角色一般分为:玩家和NPC(非玩家角色)。 我们在游戏中,打怪练级,PK比武,买卖药品装备,领取任务等等,这都是有了角色才能 存在的事情。这些形状各异,行为古怪的角色,是怎么控制的呢?是我们这一章要学习的内 容:  角色Character的应用  动作Animation的应用  角色身体BodyPart的应用  以及三者之间的关系 7.1 角色 7.1.1 角色的概念 在游戏世界中,一个人类的角色(Character)是由身体的各个部分组成如头、手、腿、 衣服以及骨骼等,这些身体的各个部分就是(BodyPart),角色在游戏中行走,交谈,打怪这 些动画就是角色的动作(Animation)。 一个角色所有的 BodyPart,都必须与根节点存在层级关系,这个根节点是唯一的,是 在建模阶段确定的,同时该根节点也指明了角色的位置和方向。一个角色通常有个叫“地 板参照物”的 BodyPart,它是用来检测地板碰撞的,使得角色保持在地板上,而不会掉下 去。也有一些角色没这个参照物,但最好在三维建模的时候加上这个地板参照物并命名为 “FloorRef”,这一点对程序员进行后期开发很有用处。 角色的动作有些是可以打断,有些是不可以断的,比如玩家先是站立休息,然后突然 拔腿就跑,或是行进中突然跳起等等,这些都是从当前动作立即切换到另外一个动作的情 况,所以当前的动作必须可以随时打断,而且两个动作的切换过程要自然。但有些动作不 能被打断,比如一个死完动作,它就不能再别任何其它的动作打断了,死了也就自然不能 动了。 角色以及角色的动作都是由美工在建模时候弄好,然后通过 Virtools 自带的插件导出 来的。在 Virtools 中可以很容易的控制各种类型角色。 7.1.2 播放角色动画 通常我们在开发游戏的时候,创建角色时通常会把一个角色的所有动画全部整备到一 起,然后再建立一个该角色的 Virtools 脚本,并在脚本中加入一个“Unlimited Controller” BB,并打开这个 BB 的参数把消息和角色动画一一对应起来填好。然后当调用某一个动画 的时候,向该脚本发送对应的消息就行了。 这是很顺理成章的事情,但有时候这样做会很糟糕。比如一个角色等级还是初级时, 此时这个角色只有一些简单的技能,只能施展一些小动作,这时没有必要把其他那些还不 够等级施展的动画也加入到角色中,这样做浪费了系统资源。还有一种情况是动作播放时 间很长,动画帧数很多,比如一个民族舞蹈动画,一个动画可能有几分钟长,全部有 6 级 别,近百个动作,不可能全部加入到角色中。 解决的方法就是用到什么动作就装载什么动作,不再使用时候就卸载掉。请读者打开 随时附送光盘例子 7.1.2_1.cmo。这个程序实现的功能是:从磁盘中装载三个动作,然后把 这些动作加到角色中并通过“Unlimited Controller”BB 进行播放。这里使用到一个简单的 - 102 - VSL 脚本,其输入参数如下: 参数名 参数类型 参数功能 参数用途 MyChara Character 输入参数 输入一个角色类型 MyAnim Animation 输入参数 输入一个动画类型 其代码如下: void main() { // Insert your code here MyChara.AddAnimation(MyAnim); //把动画加载到角色中 //MyChara.RemoveAnimation(MyAnim); //把动画从角色中移除 } 其实可以不使用这个 VSL 脚本,而改用“Add Animation”这 BB 同样也能够实现该功 能。但无论是用 BB 还是用 VSL 加载动作,有两点要注意的:一个动画从磁盘载入进来后, 必须正确的加入到对应的角色中,不要张冠李戴;第二个是如果有多个相同的角色,就要 加载多个相同的动作,分发到每个角色身子上,不能一个动作,几个角色共用。另外动作 从角色身上移除并没有从内存中移除。要想在程序结束之前卸载它,还需要其它步骤。 这个程序的 Virtools 脚本如下图所示。 图 7.1.2_1 几乎所有的角色的动画播放都离不开“Unlimited Controller”BB,确实这个 BB 太强 大了,以至于一说到角色动作播放,我就首先想到它。但有时候使用 VSL 来控制动作播放, 比用消息驱动“Unlimited Controller”BB 播放动作更方便。请打开程序例子 7.1.3_1.cmo, 这是一个用 VSL 控制角色动画播放的简单例子,其中“ActiveAnimation”参数如下: 参数名 参数类型 参数功能 参数用途 MyChara Character 输入参数 输入一个角色类型 num int 输出参数 角色执行当前的第几个动作 其代码如下: void main() { // Insert your code here MyChara.SetAutomaticProcess(true); //设置自动更新动画帧 num=MyChara.GetAnimationCount(); //得到角色动画的数量 num=randi(0,num); //随机一个动画 Animation tempani=MyChara.GetAnimation(num); //得到其中的一个变化 - 103 - MyChara.SetActiveAnimation(tempani); //激活动画播放 } 上面的脚本自动播放一个角色动画 tempani,当动画播放到达最后一帧的时候,就会 停下来,如果没有别的操作,角色就会停止不动。函数 SetActiveAnimation( )的作用是把 当前的动作播放完,如果激活动画的时候,该动作已经是最后一帧了,你不会看到任何效 果。要激活播放角色动画之前,还要确保角色自动更新动画的功能已经开启,否则也看不 到任何效果变化,使用函数 SetAutomaticProcess( )来控制这个功能的开关,如果传入参 数的值是“false”表明关闭该功能,调用函数 SetActiveAnimation( )是无效的。 上面的代码对角色播放的当前动作进行了随机,先用函数 GetAnimationCount( )获得 角色拥有的动画总数,然后进行随机抽取,并通过函数 GetAnimation( )得到随机出的动作, 作为参数传给函数 SetActiveAnimation( )。在这个程序中,没有让角色播完当前的动画, 就打断了它,又随机出另外一个动作,并立即执行该动作。脚本‖ NextAdtiveAni‖实现了播 放动作的另一种方法,其输入参数如下: 参数名 参数类型 参数功能 参数用途 MyChara Character 输入参数 输入一个角色类型 num int 输入参数 角色执行当前的第几个动作 其代码如下 void main() { // Insert your code here int count=0; //定义一个整形变量,用于存放角色拥有的动作数量 do { count=MyChara.GetAnimationCount(); //得到角色拥有的动作数量 count=randi(0,count); //随机抽取其中的一个动作 }while(count!=num); //如果和前一个动作相同,继续随机 Animation tempani=MyChara.GetAnimation(count); //得到动作 //以某种方式切换到当前动作 MyChara.SetNextActiveAnimation(tempani,CK_TRANSITION_WARPBEST); } 上面的代码中只有最后一行代码是陌生的,函数 SetNextActiveAnimation( )的功能是: 角色以怎样的一种方式从当前动作转换到下一个动作。其中第二个参数是选择动作之间的 转换方式,如下表所示: CK_TRANSITION_FROMANIMATIO N 使用动画自身的转换模式进行转换,详见 (CKAnimation::SetTransitionMode) CK_TRANSITION_FROMNOW 不进行任何转换直接切换到新动作 CK_TRANSITION_FROMWARPFRO MCURRENT 在当前位置转换到下一个动作 CK_TRANSITION_LOOPIFEQUAL 如果当前动作和下一个动作一样就循环播放该动作 CK_TRANSITION_TOSTART 从头开始下一个动作 CK_TRANSITION_USEVELOCITY 使用当前动画和要切入的动画各自的播放速度,推 断出一个角色跟节点躯体的移动速度 - 104 - CK_TRANSITION_WARPBEST (CK_TRANSITION_FROMWARPFROMCURREN T|CK_TRANSITION_WARPTOBEST) CK_TRANSITION_WARPMASK CK_TRANSITION_WARPPOS (CK_TRANSITION_FROMWARPFROMCURREN T|CK_TRANSITION_WARPTOPOS) CK_TRANSITION_WARPSAMEPOS (CK_TRANSITION_FROMWARPFROMCURREN T|CK_TRANSITION_WARPTOSAMEPOS) CK_TRANSITION_WARPSTART (CK_TRANSITION_FROMWARPFROMCURREN T|CK_TRANSITION_WARPTOSTART) CK_TRANSITION_WARPTOBEST 转换到下一个动作的最佳位置处 CK_TRANSITION_WARPTOPOS 转换目标动作所指定的位置 CK_TRANSITION_WARPTOSAMEP OS 从源动作的位置处转换到目标动作相同的位置处 CK_TRANSITION_WARPTOSTART 转换到下一个动作的开始处 表 7.1.2_1 看到上面的表格,是否感觉到一个简单的动作切换问题变得复杂起来,其实不然。很 多时候,很多地方这样类似的参数多得是,我们只要知道能够满足自己的绝大部需求的参 数就行了。参数 CK_TRANSITION_WARPBEST 是一个最好的选择。 7.1.3 角色的次级动画播放 一个角色在播放当前动作的时候,有时候会需要身体的某一部分播放另外一定动作。 比如猪八戒再深情地打着鼾声,偶尔翘起鼻子闻闻周围空气的气味,看猴哥带吃的回来没; 或者一个角色交谈时,除了肢体动作外,还有一张一合的口型动作等等。这些翘鼻子以及 动嘴巴的动作,都是角色身体局部性的小动作,可以用在次级动画播放,当然也可以不用。 在播放主体动作和次级动作同时播放时,有一点要特别注意的地方就主体、次级动作 之间的冲突。比如说主体动作是向前进,次级动作就不能选择为向后退;主体动作是跳去, 次级动作就不能是趴下等等。不是什么动作都可以选择为次级动作,只有那些局部的,不 影响角色身体全局的动画,比如嘴巴的一张一合,跟走路,跳去没有关系,才可以设置为 次级动作。这些动作在三维建模的时候,要特别留意的。 下面来看个例子程序 此节未完,需要模型 CKSECONDARYANIMATION_DOWARP 当一个动作开始或者结束是创建转换 CKSECONDARYANIMATION_FROMANIMATION 使用由 CKAnimation::SetSecondaryAnimati onMode 指定的设置 CKSECONDARYANIMATION_LASTFRAME 动画停留在最后一帧 CKSECONDARYANIMATION_LOOP 动画将循环播放 CKSECONDARYANIMATION_LOOPNTIMES 动画将循环播放 n 次 (由 CKCharacter::PlaySecondaryAnimat ion 指定次数) - 105 - CKSECONDARYANIMATION_ONESHOT 动画将立即开始播放 上节未完成 7.1.4 角色的身体 角色的身体(BodyPart)在 VSL 的帮助文档中,你找不到具体的细节,只能发现有一 个 BodyPart 名字的存在。BodyPart 在三维建模工具里面(maya 或者 max)一般包括角色 的骨骼、和皮肤等,它属于 3D 物体,有 3D 物体的属性,至于在 VSL 文档中没有 BodyPart 的详细说明,我也不知道,不过这样不重要。 使用角色的身体(BodyPart)用的最多的是得到它的位置和方向,以及某些碰撞检测。 请读者打开程序例子 7.1.4_1.cmo。这个脚本的功能是得到角色的手掌的位置,然后把另 一根棍子,设置到这位置上并调整方向,使得角色看起来扶着那个棍子。其参数如下: 参数名 参数类型 参数功能 参数用途 MyBodyPart BodyPart 输入参数 角色的某一身体 Bar Entity3D 输入参数 一个3D物体 其代码如下: void main() { // Insert your code here Vector Pos; //声明一个位置向量 MyBodyPart.GetPosition(Pos); //得到某一部位的位置 Bar.SetPosition(Pos); //设置棍子的位置 Bar.Rotate(1,0,0,3.14/2); //调整棍子的方向 Bar.Translate(0,-0.1,3); //调整棍子的位置 } 上面的代码很简单,只是为了说明虽然VSL中没有BodyPart的说明,但是仍然是有用处 的。运行上面的程序,你看到棍子从其它地方跑到角色的手上了。 图7.1.4_1(物体根据BodyPart设定位置前后) 7.2 角色动画 7.2.1 如果确定角色动画是否有问题 我们都知道角色动画是在三维建模软件里面“K”出来的(俗称K动画),或者是通过 - 106 - 一些动作捕捉设备制造出来的。有些动作看起来很好,但放到游戏场景中应用的时候却很糟 糕。比如一个走路的动画,左脚右脚交替迈出一步就行了,没有必要“K”出两三步的动画, 浪费系统资源。再比如一个走路前进的动画,播放完后角色又跳回到第一帧的位置,总是走 完一步,又跳回起点位置,如何前进得了。这都是由于不规范动画制作引起的。 还有一些比较隐蔽的动画错误就是角色的BodyPart在动画播放过程中,位置或方向不对 造成的。比如说做为角色 “地板参照物”的BodyPart,在角色已经开始移动的时候,该BodyPart 滞后几个动画帧才移动,导致角色在走上斜坡时先是半截身体陷入地下走,然后突然又跳到 地面上,接着再陷入地下再跳到地面上的情况。又比如角色手持的兵器,大多情况下是正常 的,但在某些动画某些帧上的位置或方向放生了变化,导致绑定的兵器出现错位现象如下图 7.2.1_1所示。没有经验的程序员通常不能判断这种错误的所在;而美工不是程序更加不明 白。 具体的三维模型建模规范不是本书的内容,这里只是提及一些比较隐蔽的错误,使得 程序员可以排查。要知道游戏中的bug不一定都是程序的问题。 图7.2.1_1剑绑定的BodyPart出现问题 7.2.2 角色的动画帧 角色的一个动画长度没有固定的要求,游戏里面的角色动画一般简短,十多帧到几十 帧不等,少数有上百帧的。而一些舞蹈教学的动画就很长,几千帧都不足为奇。这个根据需 求而定。帧数越多,所消耗的系统资源也就越多,初始化播放以及动作切换也就越慢。 有时候我们想在角色动画播放到第几帧的时候,设置一个特效或播放一个声音;有时 后想让动画从某一个帧数开始播放,而不是从头开始;有时候想在主角死亡动作播放快结束 时候弹出一个“Game Over”等等,这些如何实现呢?请打开例子程序7.2.2_1。 - 107 - 图7.2.2_1 我们用三种方法来实现这个功能: 第一种方法:使用两个“Test”BB,第一个Test检测当前角色的动画是不是选择的动 画(比如挥铁锹动画),如果是则进入到下一个“Test”BB,第二个Test检测当前挥动铁锹 的动画的帧数是否达到指定的帧数了(比如这里是45帧),如果是播放一个声音。如上图 7.2.2_1所示。 第二种方法:使用“Animation Synchronizer”这个动作分析BB,双击该BB,然后展 开其对话框,选中挥铁锹动画,并添加一个消息“PlaySound”,然后把该消息的游标拖到 到45帧位置处,并在此行点上一点,如图7.2.2_2中圈内所示。其作用是当该动画播放到第 45帧的时候,向该Virtools脚本发送已经定义好的消息“PlaySound”。所以我在该脚本添加 一个“Waiter Message”消息接收BB去接受这个消息,然后触发播放声音,如图7.2.2_1所 示。 想比较这两种方法各有各的好处。用消息接收的方法简洁明了一些,但是当系统越来 越大,消息越来越多,消息管理变得凌乱起来,这时就会容易出现接受不到消息的情况,特 别是程序运行得比较“卡”的状态时,丢失消息的情况时有发生。使用“Test”BB的方法 虽然不会出现“丢失”的情况,但是当要判读多动作多个帧数时候,Test的使用数量会明显 上升,不容易开发并且维护修改起来更困难。 以上是用Virtools只带BB做法,下面来看如何用VSL来实现这个功能。 - 108 - 图7.2.2_2 第三种方法:使用VSL中的Animation类型可以满足这些需求。其Virtools脚本如下图所示: 图7.2.2_3 上面是使用VSL脚本来实现的例子,当然目前还要配合“Unlimited Controller”BB来使 用,但这个脚本已经体现了一种信息:脚本程序和资源分开。建议尽量不要在资源物体下建 立脚本程序,一旦资源需要替换,你的脚本又要重新修整一次,当这种替换发生频繁的时候, 你会发现把脚本程序建立在Level层上是一件多么舒坦的事情,如果是封装得好的脚本,几 乎不需要修改。以下是该VSL脚本的输入参数: 参数名 参数类型 参数功能 参数用途 myCharacte Character 输入参数 角色 myAnim Animation 输入参数 一个动画 myNum float 输入参数 检测的动画帧数 该脚本有两个输出端口:Out和bOut1。 - 109 - 其代码如下: void main() { // Insert your code here Out=TRUE; //输出端口一默认打开 bOut1=FALSE; //输出端口二默认关闭 float num; //声明一个整形变量,用来存放当前动画的当前播放帧数 Animation temp; //声明一个动画类型对象,用来存放当前角色播放的动画 temp=myCharacte.GetActiveAnimation(); //得到角色当前正播放的动画 if(temp==myAnim&&myAnim!=NULL) //输入动画与角色当前动画相等并不为空值 { //判断通过 num=temp.GetFrame(); //得到该动画播放到第几帧 if(num>myNum) //如果当前动画帧数大于用于检测的输入参数 { bOut1=TRUE; //开启输出端口一 Out=FALSE; //关闭输出端口二 } } } 动画的长度除了用帧数(注意VSL中用的是浮点型的数)来表示外,还可以用一个取 值为0到1的浮点数来表示,它具体的值是GetFrame( ) / GetLength( ),要得到当前动画的播放 百分比程度,使用函数GetStep( )。如果想设置动画到某个具体帧数可以使用函数SetFrame( ) 或者SetStep( )。这种情况一般在类似能设置动画快进,快退的舞蹈虚拟教学项目中用到。 7.2.3 角色动画的一些设置 Unlimited Controller 一个角色的动画是经常切换来切换去的,比如从走到跑,从跑到出招打怪,也就休息 吃药补血等等。但是有一些动画比如跳,死亡等动作是不能被打其它动作打断的,必须该 动作播放完成后,其它动作才能切换过来。比如角色一个原地腾跃跳起的动画,如果这时 要切换一个向前走的动画,是不行的,必须让角色落地,也就是跳起动画结束后才切入。 反过来原来是向前走的动画,要切换到跳起的动画,就要可以立即切换过去。这样才能使 得玩家感觉自然些。如何设置这些动作之间的关系呢? 要说角色动画的设置就不能不说到“Unlimited Controller”这 个 BB,它实在是太棒了, 使我们能如此方便地控制角色动画的方法,其 BB 外形如下: 图 7.3.3_1 - 110 - 从图 7.3.3_1 看出,输出参数有四个,从左向右依次分别表示当前角色播放动画、当前 动画所对应的触发消息、当前动画播放程度(0~1)、当前动画播放到第几帧。双击该 BB, 可以看到一个展开的该 BB 的对话框,如图 7.3.3_2 和图 7.3.3_3 所示(因为纸张大小问题 被切成两张图来显示)。下面逐一细细说来,虽然它不是 VSL 的内容,但是我仍然要很热 心的介绍给大家。 图 7.3.3_2 图 7.3.3_3 Order:这个编号数码越大优先级别也就越高,反之亦然。所以把类似站立、休闲移 动等动画设置为高的编号;类似跳、死亡的动画设置为低编号。如果你不这样设置也无所 谓。作者一直以来也没注意这点,写到本书本章节的时候,才发现的。 Message:当该 BB 接收到对应消息时就会播放对应的动画。这里要注意的是如果该 动画是可以被打断的,就要一直发此消息,才可以把动作播放完成,如果只发一次消息, 角色只会动一下而已。如果动作是不可打断的,发一次消息就可以了,动作就会完整播放 一次,如果动画播放期间仍然发送该消息,那么这个不可中断的动作又会多播放一次。 Animation:消息所对应的动画,发什么消息,播放什么动作。 Warp [None/Start/Best]:指定该动作和角色当前动作之间的切换方式。其中“None” 立即打断当前动画开始一个新动画;“Start”创建一个转换从当前动作切换到下一个动作 的开始处;“Best”转换到下一个动画的最佳帧位置,也就是说下一个动画不一定会从其第 一帧处开始播放。 Warp length:依据“Time Base”和“Fps”两项进行动画之间转换动作(过渡动 作)的长度。 比方说‘Warp length’=5 和 ‘Fps’=30.0,那么: 假如‘TimeBase’=Frame,则转换动作将持续 5 个动画帧并且“Fps”项的值不会 有任何变化。在这种情况下,如果使用动作分析 BB “Animation Synchronizer”时,把 消息定位在动画的最后 5 帧后发出,是发不出去的,因为后面 5 帧动画已经不是该动画了, 而是插入的与下一个动画进行切换的转换动画了。 假如‘TimeBase’=Time,则转换的动画将持续 5/30(Warp length / Fps)秒的时间, 并且转换动画播放帧率不是 Fps 所指定的值。 Stopable [Yes/No]:指定动作是否能够被其它动作所打断。其它动作必须等待该动 作播放完成后,才能发生切换。一个典型的例子是:跳跃的动作。“Yes”表示可以打断, 这是默认选项;“No”表示不可以打断。 Time Base [Time/Frame]:是动作进行转换所依据的一个基础量。选择是以帧数还 是以时间进行转换。详细参见“Warp length”。 Fps:动画以每秒多少帧率的速度进行播放。假如‘TimeBase’=Frame 时,这个值 - 111 - 不会产生作用,因为此时动画帧是按渲染设备的帧率来进行播放。 Turn [Yes/No]:指定动画在播放过程中是否可以进行旋转。这个旋转是由消息 “Joy_Left”和“Joy_Right‖控制的,“Yes”表示可以旋转,这是默认选项;“No”表示不 可以旋转。比如角色前进动画,是可以随时进行转弯的;而跳跃动画就不要转弯了,否则 本来可以跳过河的动画,结果变成了跳进河了;再比如一个死亡动画,自然也不能打断也 不能进行旋转。 Orient [Yes/No]:指定角色的方向是否依据动画的方向而变。一个典型的应用是:假 如角色有一个向左的动画,当向左运动完成时,角色的方向变成动画的方向一样;如果这 个参数的值为“No”,那么向左运动完成时,角色会出现一个回转的多余动作。这个种情 况要看旋转动画而定。一般游戏中很少有专门的向左、向右的动画,要转向直接旋转角色 方向就行了。例子 7.3.3_1 有个实例,读者可以改变参数,观察效果。 Description: 一个动画描述,就像程序的注解。没有参与实际的程序应用。 以上是主动画的详细阐述,下面来看次级动画的参数,虽然实际项目中很少用到,但是作 为学习,了解一下也没坏处。如下图所示: 图 7.3.3_4 Message: 等待的消息,用来控制开始或者停止播放角色的局部躯体动画。 Action [Play/Stop]: 指定该消息的用途是开始还是结束动画。 Animation: 当接收到动画消息后,要开始播放或者要停止播放的动画。 Time Base [Time/Frame]: 参见上面响应的内容。 Fps: 参见上面响应的内容。 Loop Cnt: 指定动画是否进行循环播放多少次,如果是循环播放的话,要中途停止只有通 过发送消息来停止动画的播放。 Start: 动画从第几帧开始播放 Description: 一个动画描述 ―Unlimited Controller‖这个 BB 除了设置动画的相关参数外,还有下面两个参数: 图 7.3.3_5 Keep Character On Floor: 假如该选项选中,那么角色会保持在地板上(前提是有块设 置了地板属性的地面),否则就能天马行空的走。 - 112 - Rotation Angle: 当角色接收到―Joy_Left‖和―Joy_Right‖消息,要进行旋转时,设置其旋 转速度。 7.2.4 用 VSL 设置角色动画 上一节我们知道了如何使用“Unlimited Controller”这个 BB 对动画进行设置,通过 VSL 也可以进行这样的设置,但相比之下却显得繁琐,而且如果“Unlimited Controller” 和 VSL 脚本同时对一个动画进行设置的时候,会存在冲突。所以通过 BB 设置动画属性是 明智之举,因为它实在是简单又方便。 下面我们仍然来学习一下 VSL 如何对动画进行设置的。首先看 Animation 类型的函数 SetFlag(int)。它是设置动画件转换方式的功能函数,其中常用的输入参数值如下表所示: CKANIMATION_ALIGNORIENTATION 角色方向将修正为动作所指示方向 (CKAnimation::SetCharacterOrientation) CKANIMATION_ALLOWTURN 动画播放时允许角色进行转向 CKANIMATION_CANBEBREAK 动作允许被打断,否则下一个动作要等到该动作 结束时才可以播放 (CKAnimation::SetCanBeInterrupt) CKANIMATION_SECONDARY_ALL 当开始或者停止一个动画的时候,创建一个转换 (次级动画)。 CKANIMATION_SECONDARY_DOWA RP 当开始或者停止一个动画的时候,创建一个转换 (次级动画)。 CKANIMATION_SECONDARY_LASTF RAME 动画将停留在最后一帧(次级动画) CKANIMATION_SECONDARY_LOOP 动画将不断循环播放下去(次级动画) CKANIMATION_SECONDARY_LOOP NTIMES 动画将不断循环播放 N 次(这个 N 次由函数 PlaySecondaryAnimation 指定) (次级动画) CKANIMATION_SECONDARY_ONES HOT 动画立即开始播放(次级动画) CKANIMATION_SECONDARYWARPE R 用来进行与下一个动画进行转换的动画 CKANIMATION_SUBANIMSSORTED 该标志作用于关键帧动画。角色中所有的子物体 动画已经根据其各自在动画中所拥有的权值进行 挑选。 CKANIMATION_TRANSITION_ALL CKANIMATION_TRANSITION_FROM NOW 没有任何转换,开始播放一个动画 CKANIMATION_TRANSITION_FROM WARPFROMCURRENT 在当前位置转换到下一个位置 CKANIMATION_TRANSITION_LOOPI FEQUAL 假如当前动作和下一个动作是同一个动作,就继 续循环播放该动作 CKANIMATION_TRANSITION_TOSTA 从头开始一个新动作 - 113 - RT CKANIMATION_TRANSITION_USEVE LOCITY 使用当前动画和要切入的动画各自的播放速度, 推断出一个角色跟节点躯体的移动速度 CKANIMATION_TRANSITION_WARP BEST (CK_TRANSITION_FROMWARPFROMCURR ENT|CK_TRANSITION_WARPTOBEST) CKANIMATION_TRANSITION_WARP POS (CK_TRANSITION_FROMWARPFROMCURR ENT|CK_TRANSITION_WARPTOOS) CKANIMATION_TRANSITION_WARP SAMEPOS (CK_TRANSITION_FROMWARPFROMCURR ENT|CK_TRANSITION_WARPTOSAMEPOS) CKANIMATION_TRANSITION_WARP START (CK_TRANSITION_FROMWARPFROMCURR ENT|CK_TRANSITION_WARPTOSTART) CKANIMATION_TRANSITION_WARP TOBEST 转换到下一个动作的最佳位置处 CKANIMATION_TRANSITION_WARP TOPOS 转换到目标动作所指定的位置 CKANIMATION_TRANSITION_WARP TOSAMEPOS 从源动作的位置处转换到目标动作相同的位置处 CKANIMATION_TRANSITION_WARP TOSTART 转换到下一个动画的开始出 表 7.2.4_1 看了上表所列参数明细,是不是觉得在 BB“Unlimited Controller”中是如此简单的事 情,怎么到这变成了那么复杂。你所看到的只是参数复杂而已,当编写起 VSL 脚本代码来 会更复杂,而且还不光是这个函数,还有其它的函数。以至于作者也不想编写出实例来说 明其各自用途,只是建议使用 BB“Unlimited Controller”。这里所列只是为了加深读者对 “Unlimited Controller”的印象。 7.3 角色动画的应用 7.3.1 用 VSL 来控制角色动画 前面夸了一通 “Unlimited Controller”这个 BB 的好处,这里却还是用 VSL 来控制角 色动画播放,是不是太多余了?告诉你,完全不多余。“Unlimited Controller”这个 BB 有个 缺点,就是该 BB 只能建立在角色的脚本下,建立到其它地方是非法的,不允许的。 作者曾经碰到过这样一种情况:项目中要介绍近百种动物,每种动物都有各自一个动 画,如果用“Unlimited Controller”这个 BB 的话,我就要为每个动物建立一个 Virtools 脚 本,然后加入这个 BB 到脚本中,并给该 BB 填好参数。想想工作的繁琐度,无法让人接 受。而使用 VSL 写个脚本做个通用的,放在 Leve 层处理,是很明智的举措。打开例子程 序 7.3.1_1,该程序实现控制一个角色基本功能。其中 Virtools 脚本程序如下图所示: - 114 - 图 7.3.1_1 该脚本的功能实现角色前进、后退、左转、右转、跳起(不可打断动作)、开门(动作 被设置成慢动作播放)。主要的部分使用 VSL 来完成。其中脚本“VSL Animation”的输入 输出参数如下: 参数名 参数类型 参数功能 参数用途 myCharacte Character 输入参数 角色 WalkAni Animation 输入参数 向前动画 WalkBAni Animation 输入参数 向后动画 JumpAni Animation 输入参数 跳跃动画 SlowAni Animation 输入参数 被慢放的动画 TrunLeftAni Animation 输入参数 左转动画 TrunRightAni Animation 输入参数 右转动画 CurAni Animation 输出参数 当前角色播放的动画 StopIdle bool 输出参数 是否进行休闲动画播放 由于动画有六个分别用六个不同按键触发,所以创建六个输入端口来区分这六个不同的按 键,输入端口分别为:WalkIn、WalkBIn、JumpIn、SlowIn、TurnRightIn。 其代码如下: void main() { // Insert your code here MyCharac.SetAutomaticProcess(true); //设置角色动画为自动更新 - 115 - CurAni=MyCharac.GetActiveAnimation(); //得到当前播放的动画 StopIdle=TRUE; //角色停止休闲动画标志 if(WalkIn) //向前进按键触发 { if(CurAni!=WalkAni||CurAni==NULL) //判断当前动画是否是向前进动画 { //如果不是,就设置为即将播放 MyCharac.SetNextActiveAnimation(WalkAni,CK_TRANSITION_WARPBEST); } float num=WalkAni.GetFrame(); //得到动画的当前帧数 float count=WalkAni.GetLength(); //得到动画的总长度帧数 if(num>count-1) //动画在结束时,设置继续播放该动画 MyCharac.SetNextActiveAnimation(WalkAni,CK_TRANSITION_WARPBEST); } if(WalkBIn) //向后退按键触发 { if(CurAni!=WalkBAni||CurAni==NULL) { MyCharac.SetNextActiveAnimation(WalkBAni,CK_TRANSITION_WARPBEST); } float num=WalkBAni.GetFrame(); float count=WalkBAni.GetLength(); if(num>count-1) MyCharac.SetNextActiveAnimation(WalkBAni,CK_TRANSITION_WARPBEST); } if(JumpIn) //跳跃按键触发 { if(CurAni!=JumpAni||CurAni==NULL) { JumpAni.SetCanBeInterrupt(FALSE); //设置该动作(跳跃)为不可打断 MyCharac.SetNextActiveAnimation(JumpAni,CK_TRANSITION_WARPBEST); } float num=JumpAni.GetFrame(); float count=JumpAni.GetLength(); if(num>count-1) { JumpAni.SetCanBeInterrupt(FALSE); //设置该动作(跳跃)为不可打断 MyCharac.SetNextActiveAnimation(JumpAni,CK_TRANSITION_WARPBEST); } } if(SlowIn) //慢放按键触发 { if(CurAni!=SlowAni||CurAni==NULL) { SlowAni.LinkToFrameRate(TRUE,10); //设置该动作以FPS=10的帧率播放 - 116 - MyCharac.SetNextActiveAnimation(SlowAni,CK_TRANSITION_WARPBEST); } float num=SlowAni.GetFrame(); float count=SlowAni.GetLength(); if(num>count-1) { SlowAni.LinkToFrameRate(TRUE,10); //设置该动作以FPS=10的帧率播放 MyCharac.SetNextActiveAnimation(SlowAni,CK_TRANSITION_WARPBEST); } } if(TurnLeftIn) //左转按键触发 { if(CurAni!=TrunLeftAni||CurAni==NULL) { TrunLeftAni.SetCharacterOrientation(true); //设置角色方向将随动动画方向 MyCharac.SetNextActiveAnimation(TrunLeftAni,CK_TRANSITION_WARPBEST); } float num=TrunLeftAni.GetFrame(); float count=TrunLeftAni.GetLength(); if(num>count-1) { TrunLeftAni.SetCharacterOrientation(true); //设置角色方向将随动动画方向 MyCharac.SetNextActiveAnimation(TrunLeftAni,CK_TRANSITION_WARPBEST); } } if(TurnRightIn) //右转按键触发 { if(CurAni!=TrunRightAni||CurAni==NULL) { TrunRightAni.SetCharacterOrientation(true); //设置角色方向将随动动画方向 MyCharac.SetNextActiveAnimation(TrunRightAni,CK_TRANSITION_WARPBEST); } float num=TrunRightAni.GetFrame(); float count=TrunRightAni.GetLength(); if(num>count-1) { TrunLeftAni.SetCharacterOrientation(true);//设置角色方向将随动动画方向 MyCharac.SetNextActiveAnimation(TrunRightAni,CK_TRANSITION_WARPBEST); } } } 上面的脚本代码虽然长,其实功能很单一,完全可以写成一个共用的函数、然后调用。 读者可以自己试验一下。上面脚本是实现了角色基本行为动能,但是如果没有按键按下, 角色就会像个木头一样不动,不自然。如果角色能在没有按键按下的情况下播放一个休闲 - 117 - 动画,当有按键按下时就停止播放休闲动画,转去响应按键动画就会显得自然多了。VSL 脚本“SetIdleAni”负责实现这个功能,其参数如下: 参数名 参数类型 参数功能 参数用途 myCharacte Character 输入参数 角色 IdleAni Animation 输入参数 向前动画 StopIdle bool 输入参数 是否进行休闲动画播放 Enalbe bool 输出参数 是否进行休闲动画播放 其代码如下: void main() { // Insert your code here Animation CurAni=MyChara.GetActiveAnimation(); //得到角色当前播放动画 if(StopIdle) //如果判断为True { //表示不能播放休闲动画 if(CurAni==NULL) //如果当前动作为空,允许播放休闲动画 Enalbe=FALSE; //修改变量值。 else { float num=CurAni.GetFrame(); //得到当前动画的帧数 float count=CurAni.GetLength(); //得到当前动画的长度帧数 if(num>count-1) //当前动画已经播放完,可播放休闲动画 Enalbe=FALSE; //修改变量值。 } return; //判断完直接返回,不执行下面的代码 } MyChara.SetAutomaticProcess(true); //设置角色动画为自动更新 if(CurAni!=IdleAni||CurAni==NULL) //假如当前动画不是休闲动画或者为空 { //激活休闲动画,进行播放 MyChara.SetNextActiveAnimation(IdleAni,CK_TRANSITION_WARPBEST); } float num=IdleAni.GetFrame(); //得到休闲动画的帧数, float count=IdleAni.GetLength(); //得到休闲动画的长度帧数 if(num>count-1) //如果休闲动画播放结束时,再激活播放 MyChara.SetNextActiveAnimation(IdleAni,CK_TRANSITION_WARPBEST); } 在这段 VSL 脚本中关键之处在于变量 StopIdle 和 Enalbe。其中输入变量 StopIdle 的 值从脚本“VSL Animation”的输出参数“StopIdle”获得,或者是从其自身的输出参数 Enalbe 获得。当有相应的按键按下,并触发了脚本“VSL Animation”,该脚本理解将输出变量 “StopIdle”置为 true,表示此刻正播放非休闲的动画。脚本“SetIdleAni”当检测到这个 值为 true 时,就进行检测非休闲动画是否播放完成,如果检测表示播放完成,就修改其输 出参数 Enalbe 为 false,表示可以播放休闲动画了。 在程序开始运行的时候,没有任何按键按下的情况下,角色没有播放任何动作,也就 是说当前动作为空(CurAni==NULL),此时脚本“SetIdleAni”的 Enalbe 就可以设置为 false。允许脚本执行播放休闲动画。 - 118 - 现在程序的功能运行后,角色看起来自然一些了,但是还不能转向,我们可以使用 “Rotate”这个 BB,为了控制转向的速度还要使用“Per Second”这个 BB。但在转向前要 注意,有些动作播放的时候是不能够转向的,所以要对角色的当前动画进行检测。脚本 “TestAnimation”负责这个功能。其输入参数如下: 参数名 参数类型 参数功能 参数用途 myCharac Character 输入参数 角色 TestAnim Animation 输入参数 不能转向的动画 其代码如下: void main() { // Insert your code here Animation temp=myCharac.GetActiveAnimation(); //得到角色当前播放动画 if(temp!=TestAnim&&temp!=NULL) //对当前动画进行判断 Out=TRUE; //如果不是则激活输出端口 else Out=FALSE; //如果是则关闭输出端口 } - 119 - 第八章 摄像机 在现实世界中,很多人使用数码相机,记录着生活的点点滴滴,形成一个个平面的图 像保存下来。在虚拟世界中,摄像机就如同数码相机,把虚拟空间的3维物体,通过一系列 转换形成一个2D的画面显示在计算机的屏幕上。在Virtools中相机和3D物体一样,在三维空 间中有自己的位置和方向。我们可以像控制3D物体那样去控制相机的位置和方向。默认的 情况下,Virtools是不会渲染相机的,可以通过选择菜单“Opetions”,打开“General Perferences”面板,选择“Show All Camera”。这样Virtools就会对渲染三维空间中存在的 相机了。在这一章将要学习以下内容:  摄像机是工作的原理  摄像机的属性控制  目标相机的应用  自由相机的应用  多个相机之间如何协调工作 8.1 相机的基本属性 8.1.1 相机的投影 投影主要任务就是将 3D 场景转化为 2D 图像表示。这种从 3 维转换成 2 维的过程就叫 做投影。投影的方法有很多种,但在 Virtools 中有两种我们感兴趣的投影感,那就是“透 视投影”和“平行投影”。透视投影可以使离相机越远的物体投影到屏幕上后就越小,距离 相机越近的物体投影到屏幕上后就越大,这很符号我们的眼睛看时间的规律;而“平行投 影”是把三维场景投影成像一张平面地图那样没有近大远小的区分。其各自特征如下图所 示: 图 8.1.1_1(透视投影) - 120 - 图 8.1.1_2(平行投影) 绝大多数项目需要的“透视投影”的效果,而“平行投影”一般是用在出一些类似平 面图设计图或者电子地图效果的项目上,使用到的机会很少。至于两种投影的算法就不多 说了,不了解这些也无妨。下面我们做个程序观察其两者区别,打开例子 8.1.1_1。其 Virtools 脚本程序如下图所示: 图 8.1.1_3 该程序先拷贝 150 个立方体,并随机的摆放再相应的区域内,用当相机使用不同的投 影模式时,做为观察用。改变相机的投影模式,使用了两种途径来实现,一种是使用传统 的 BB 方式,使用了“Set Projection”和“ Orthographic Zoom”这两个 BB。一种是使用 VSL 脚本编码方式。其中脚本的输入参数如下: 参数名 参数类型 参数功能 参数用途 MyCamera Camera 输入参数 相机 projTypeOne int 输入参数 正字法投影投影方式 projTypeTwo int 输入参数 透视投影方式 - 121 - OutProj int 输出参数 当前相机投影方式 另外该脚本还有两个输入端口“In”和“bIn1”用于响应不同的投影触发事件。脚本的代 码如下: void main() { // Insert your code here if(In) { MyCamera.SetProjectionType(projTypeOne); //设置成正字法投影 MyCamera.SetOrthographicZoom(0.0058); //设置放大系数 OutProj=MyCamera.GetProjectionType(); //得到当前投影方式 } if(bIn1) { MyCamera.SetProjectionType(projTypeTwo); //设置成透视投影 OutProj=MyCamera.GetProjectionType(); //得到当前投影方式 } } 上面的代码中函数 SetOrthographicZoom( )相当于 BB“Orthographic Zoom”的作用, 而函数 SetProjectionType( )相当于 BB“Set Projection”的作用。另在相机投影模式设置 为正字法的时候,函数 SetOrthographicZoom( )才起作用,该函数的输入参数的值越大, 投影后的物体也就越大,默认情况下,其值为 1,不是最合适的,所以需要自行调节。 8.1.2 相机的视锥 3D 场景可以是非常大的,而相机只能对场景的一部分进行投影,然后显示在屏幕上。 如何确定这一部分区域呢?用摄像机的视锥,其实相机的投影变换其实质就是定义视锥, 并将视锥内的几何图形投影到屏幕窗口上去。什么是摄像机的视锥,可以理解为摄像机的 视觉范围,这个视觉范围由一个前剪切面、一个后剪切面以及一个相机的视锥角度。这三 个参数围成了一个空间,这个空间就是摄像机的视锥,如图 8.1.2_1 所示。 图 8.1.2_1 视锥角度越大,可见范围也就越大,一般使用标准的 35mm 摄像机的视锥角度,这也 - 122 - 是拍电影的常规角度。得到了要投影区域,在转换到屏幕上时候,还要注意一个参数:投 影平面的宽高比(Aspect Ratio),该参数一般设为屏幕分辨率的宽和高,当渲染窗口的大 小发生改变时,要记得修改这个值,否则转换后就会引起拉伸变形。 请打开程序“例子 8.1.2_1”,它是对上面所提到的参数进行了控制,读者可以随意的 修改其输入参数的值,观看其效果,看看可视范围是否发生了变化。该脚本很简单其输入 参数如下: 参数名 参数类型 参数功能 参数用途 MyCamera Camera 输入参数 要控制的相机 FrontPlan float 输入参数 前剪切面参数 BackPlan float 输入参数 后剪切面参数 Fov float 输入参数 视锥角度 Width int 输入参数 窗口的宽度 Height int 输入参数 窗口的高度 RollAngle float 输入参数 一个旋转角度 其代码如下: void main() { // Insert your code here MyCamera.SetFrontPlane(FrontPlan); //设置相机的前剪切面 MyCamera.SetBackPlane(BackPlan); //设置相机的后剪切面 MyCamera.SetFov(Fov); //设置视锥角度 MyCamera.SetAspectRatio(Width,Height); //改变投影平面的宽高比 MyCamera.Roll(RollAngle); //使相机旋转一个角度 } 上面的VSL脚本代码的最后一行,是让摄像机以自己的Z轴为旋转轴,旋转一个角度。 如果相机是正字法投影或者是目标相机(Target Camera)是不起作用的。如果要想摄像机 围绕任意轴旋转,只能像旋转 3D 物体那样使用 Rotate( )函数了。 8.1.3 目标相机 所谓目标相机就是:给相机定一个目标物体,相机会跟着这个目标物体一起移动,不 需要再建立脚本来处理这种跟随行为。作为目标物体的必须是 3D Frame 这种类型的物体, 但常常我们要设置的目标物体是 3Dentity 或 Character 类型的,其实可以建立一个 3DFrame, 然后设置为目标物体的子物体。子物体随者目标物体动,而相机随着这个 3DFrame 子物体 动,就能达到目的了。 这里要注意一点,如果删除一个目标相机,这个 3DFrame 也会随之删除。目标相机原 来简单,功能单一。下面是实现功能的 VSL 脚本,输入参数如下: 参数名 参数类型 参数功能 参数用途 MyCamera Camera 输入参数 要设置成目标相机的相机 Target Entity3D 输入参数 相机目标物体 代码如下: void main() { MyCamera.SetTarget(Target);//把相机设置成目标相机 } - 123 - 如果想把目标相机设置成“自由”相机,只要再调用一次该函数,并把输入“NULL” 作为其参数即可。 8.2 相机的应用 8.2.1 第一人称相机 本节所列举的第一人称相机实例程序,只是适合缓慢地进行漫游的情况,例如房地产 项目的虚拟场景漫游等;不适合做第一人称射击游戏里的相机,在那种射击游戏里虽然在 屏幕上也只是显示主角的视野,但所面向的问题还有很多,所以相机控制相对较复杂。 请打开随书附带的例子 8.2.1_1,该程序用两种方法来实现第一人称相机,一种方法是 使用 Virtools 自带的 BB,一种是使用一个 VSL 脚本来实现。Virtools 脚本程序如图 8.2.1_1 所示。读者可以双击进入 “CamearActionBG”和“CamerakeyEnvet”这两个 BG 内部去看 看,然后再看看这个 VSL 脚本,可以大大的体会使用 VSL 的好处。 图 8.2.1_1 该 VSL 脚本的输入参数比较多,主要用来设置控制相机的按键,输入参数如下: 参数名 参数类型 参数功能 参数用途 MyCamera Camera 输入参数 要设置成目标相机的相机 W Key 输入参数 向前按键 S Key 输入参数 向后按键 A Key 输入参数 向左按键 D Key 输入参数 向右进按键 R Key 输入参数 向上按键 F Key 输入参数 向下按键 Left Key 输入参数 向左转按键 Right Key 输入参数 向右转按键 Up Key 输入参数 向上看按键 Down Key 输入参数 向下看按键 Q Key 输入参数 以相机方向前进按键 E Key 输入参数 以相机方向后退按键 FloorPos float 输入参数 相机高于地板高度 代码如下: - 124 - void main() { // Insert your code here GUID inputguid=GetInputManagerGuid(); //得到输入设备管理器的标识符 //根据标识符得到输入设备管理器 InputManager iM = InputManager.Cast(bc.GetManagerByGuid(inputguid)); Vector oldpos,newpos; //定义两个位置向量 Vector SpeedW(0,0,0.05); //定义一个前进的速度向量 Vector SpeedL(0.05,0,0); //定义一个左移的速度向量 Vector SpeedY(0,0.05,0); //定义一个向上移动的速度向量 Vector SpeedZ(0,0,0.05); //定义一个以相机方向移动的速度向量 float rotateAngle=0.02; //定义相机的旋转速度 if(iM.IsKeyDown(W)) //向前进 { MyCamera.GetPosition(oldpos); //得到相机当前位置 MyCamera.Translate(SpeedW,MyCamera); //移动相机 MyCamera.GetPosition(newpos); //得到相机移动后的位置 newpos.y=oldpos.y; //保持相机的高度不变 MyCamera.SetPosition(newpos); //设置相机的新位置 } if(iM.IsKeyDown(S)) //向后移动 { MyCamera.GetPosition(oldpos); MyCamera.Translate(-SpeedW,MyCamera); //反方向移动相机 MyCamera.GetPosition(newpos); newpos.y=oldpos.y; MyCamera.SetPosition(newpos); } if(iM.IsKeyDown(A)) //向左平移 { MyCamera.Translate(-SpeedL,MyCamera); //移动相机 } if(iM.IsKeyDown(D)) //向右平移 { MyCamera.Translate(SpeedL,MyCamera); //移动相机 } if(iM.IsKeyDown(R)) //提升相机高度 { MyCamera.GetPosition(oldpos); //得到相机当前位置 newpos=oldpos+SpeedY; //计算新位置高度 MyCamera.SetPosition(newpos); //设置相机的新位置 } if(iM.IsKeyDown(F)) //降低相机高度 { - 125 - MyCamera.GetPosition(oldpos); newpos=oldpos-SpeedY; //计算新位置高度 MyCamera.SetPosition(newpos); } if(iM.IsKeyDown(Left)) //相机向左转 { Vector AxisY(0,1,0); //定义旋转轴 MyCamera.Rotate(AxisY,rotateAngle); //相机旋转 } if(iM.IsKeyDown(Right)) //相机向右转 { Vector AxisY(0,1,0); MyCamera.Rotate(AxisY,-rotateAngle); } if(iM.IsKeyDown(Up)) //相机向上看 { Vector Axis; MyCamera.GetOrientation(NULL,NULL,Axis,NULL); //得到相机自身坐标系 MyCamera.Rotate(Axis,rotateAngle); } if(iM.IsKeyDown(Down)) //相机向下看 { Vector Axis; MyCamera.GetOrientation(NULL,NULL,Axis,NULL); //得到相机自身坐标系 MyCamera.Rotate(Axis,-rotateAngle); } if(iM.IsKeyDown(Q)) //相机以自身方向前进 { MyCamera.Translate(-SpeedZ,MyCamera); } if(iM.IsKeyDown(E)) //相机以自身方向后退 { MyCamera.Translate(SpeedZ,MyCamera); } MyCamera.GetPosition(newpos); //得到相机位置 if(newpos.yDis) //如果距离大于滞后启动的距离,触发跟随 { Out=false; //关闭输出端口一 MoveOut=true; //打开输出端口二 Role.GetPosition(rolepos); //得到角色的位置,也就是滞后物体的目标位置 LookMe.GetPosition(pos); //得到滞后物体的起始移动位置 } Else //如果距离不大于滞后启动的距离,不触发跟随 { Out=true; //打开输出端口一 MoveOut=false; //关闭输出端口二 } } “Role”这个参数要选择角色肩膀以上的 BodyPart,这样在相机拉近距离的时候可以 看到角色的脸,如果选择腰部作为参数,那么相机贴近角色时,只能看见腰,而看不见头 和脚了。“LookMe”这个参数可以是一个独立的 3D 物体,不属于任何物体的子物体,可 以创建一个 3DFrame 作为其输入参数。“Role”、 “LookMe”和相机这三者的关系是: 相机实时的观察“LookMe”,“ LookMe”跟随“Role”运动,总是试图与“Role”的 位置重合。所以我们就可以通过滞后“LookMe”的跟随运动,来实现我们的第三人称相 机跟随运动。“LookMe”向“Role”运动过程中,使用了线性插值的方法来实现,具体 请参看“相机目标定位”这个 BG 里其它 BB 的使用。 图 8.2.2_2 BG“相机目标定位”是实现“ LookMe”跟随“Role”运动。而 GB“Camera Follow” - 128 - 是实现相机跟随和观察“ LookMe”的运动。其 BG 内部脚本如图 8.2.2_2 所示。其中参数 “Distance”表示相机与角色的距离;“keep At Constant Distance”这个 BB 作用是保持相机 和角色两者之间的距离;“Look At”设置相机观察的物体,“KeepOnFloor”是相机保持在 地板之上。这三个 BB 不断循环检测下去,完成了相机跟随观察“ LookMe”的运动。其 中的“KeepOnFloor”这个 VSL 脚本 BB 的输入参数如下: 参数名 参数类型 参数功能 参数用途 FloorHeight float 输入参数 表示相机要高于地板的距离 其代码如下: void main() { Camera cam = Camera.Cast( bc.GetOwner() ); //得到当前Virtools脚本所对应的相机 Vector pos; //定义一个位置向量 cam.GetPosition( pos ); //得到相机的位置 float inY = FloorHeight-pos.y; //对相机高度进行比较 if( inY>0 ) //如果相机高度位置过低, { //就设置为最低高度。 pos.y=FloorHeight; cam.SetPosition( pos ); } } 例子程序中的地板一块平面,所以高度同一,如果地板是起伏的山路,就要根据实际 的地形参数实时进行更改,才不至于出现“穿帮”现象。 上面已经实现了第三人称相机的跟随运动,但是相机不能仅仅是在角色后面跟随那么 单调,还要可以看到角色的正面,侧面。通过鼠标改变相机的观察角度是一个必须要有的 功能。BG“RotateCamera”实现了这个功能,其脚本细节如下图: 图 8.2.2_3 通过 BB“Get Mouse Displacement”得到鼠标在屏幕上相对移动量,并把该移动量 传给 VSL 脚本 BB“Straff Camera”,实现鼠标旋转相机的响应事件。当鼠标按键松开时, 使用 BB“Binary Switch”中断循环,结束相机响应鼠标事件。其中 VSL 脚本“Straff Camera” 的输入参数如下: 参数名 参数类型 参数功能 参数用途 MouseDisp Vector2D 输入参数 鼠标移动量 CamDist float 输入参数 相机与角色的距离 其代码如下: void main() { Camera cam = Camera.Cast( bc.GetOwner() ); //得到当前Virtools脚本所对应的相机 //计算相机自身坐标系上要移动的量 - 129 - Vector trans( -MouseDisp.x, MouseDisp.y*0.8, 0 ); trans *= CamDist*0.005; //以角色为中心、的球面移动量 cam.Translate( trans, cam ); //以自身做参照,进行移动,得到围绕角色旋转效果 } 这里有一点要特别强调的是:这个 BG 的优先级别属性 Priority 被设置为 100。其余的 BB、BG 都是 0。级别越高,优先级越大。之所以这样做是因为 GB“Camera Follow”和 BG“RotateCamera”都同时对相机进行不同类型操作,这就存在争强的现象,如果优先 级别都一样,就会出现闪烁的情况。为了避免此情况,所以上升了 BG“RotateCamera” 的优先级别。 实现了相机旋转的功能后,还差最后一个相机的拉近、拉远功能就可以完成第三人称 相机了,这个功能也涉及鼠标的操作。VSL 脚本“Cam Dist”实现该功能,其输入输出参 数如下: 参数名 参数类型 参数功能 参数用途 Wheel_Dir float 输入参数 鼠标移动量 DistIn float 输入参数 相机与角色的距离 Progression Cure2D 输入参数 一个2D曲线用平滑插值移动 DistOut float 输出参数 修正后的相机与角色的距离 其代码如下: void main() { float step = Progression.GetY( (DistIn-3)*0.01 ) * 10; //得到2D曲线的位置 if( Wheel_Dir > 0 ) step *= -1; //鼠标中键有移动 float dist = DistIn + step; //调整相机与角色的距离 if( dist > 20 ) dist=20; //封顶最大距离 if( dist < 4 ) dist=4; //封底最小距离 DistOut = dist; //输出修正后的距离值 } 对于上面的代码使用了一我们没有学习的类型“Cure2D”,它是一个 2D 曲线,再插 值运动中,是经常用到的,再往后的章节会具体的学习他。 - 130 - 第九章 曲线与网格 在Virtools这个款3D虚拟现实软件中,把复杂,抽象空间曲线,用直观的图形化的方式 表达出来,使人能清晰明了地创建和控制空间路径曲线,而不是浪费脑力去发挥人的抽象空 间遐想力去描绘空间曲线。这是其他一些3D图形引擎所不具备的特性。在Virtools中路径曲 线分为两种,一种是2D路径曲线Curve2D;一种是3D空间路径曲线Curve。曲线最大的用处 在于能够做为3D物体的运动轨迹,网格Grid的作用和曲线的作用相对,它最大的用处是闲置 3D物体的运动区域,它用直观的平面的方法实现了三维空间中静态障碍物的设置。在这一 章我们想学习以下内容:  2D曲线Curve2D的原理和应用  2D曲线节点Curve2Dpoint  3D曲线Curve的原理和应用  3D路径节点CurvePoint  网格的原理和应用 9.1 3D 曲线 9.1.1 什么是 3D 曲线和 3D 曲线节点 一个 3D 曲线是由一系列的 3D 曲线节点组成的 spline。它是一种 3D 物体,能够在 3D 场景中进行渲染,默认的情况下 Virtools 是不渲染的,需要手动开启。可以通过选择菜单 “Opetions”,打开“General Perferences”面板,选择“Show All Curve”即可。3D 曲 线节点,或者称为 3D 曲线控制点,也是一种 3D 物体。它的作用是表明曲线是如何通过这 控制点的(以哪条切线进入节点,以哪条切线离开节点或者“TCB”参数方式来表明)。下 图中所示的正是三维空间中的一条 3D 曲线,粗线为 3D 曲线、粗线上的各个小方块就是 3D 曲线控制点。 图 9.1.1_1 - 131 - 默认情况下创建的 3D 曲线都是一条平滑的曲线,这种曲线能够满足大多数的项目需 求,所以对 3D 曲线节点的详细设置并没有太多的要求,设置可以不用理睬它,只要设置 好位置就行了。在 VSL 中 3D 曲线类型用符号“Curve”表示,3D 曲线控制点用符号 “CurvePoint”表示。下面我们创建一条空间中的曲线。请打开随书附带光盘例子 9.1.1_1, 其 VSL 脚本代码输出参数如下: 参数名 参数类型 参数功能 参数用途 MyCurve Curve 输出参数 输出创建的3D曲线 其代码如下: //创建3D曲线节点,并加入到曲线中去的函数 void AddMyPoint(str Name,Vector pos) { CurvePoint temp=bc.CreateCurvePoint(Name); //创建一个3D节点 temp.SetPosition(pos); //设置该节点的空间位置 MyCurve.AddControlPoint(temp); //把该节点加入到3D曲线中 } //主函数 void main() { // Insert your code here MyCurve=bc.CreateCurve("MyCurve",true,true); //创建一条3D曲线 Vector pointpos(-6.8951,0.02,-3.7447); //定义一个位置向量 AddMyPoint("MyCurvePoint01",pointpos); //调用创建节点函数 //创建第二个3D曲线节点 pointpos.Set(-14.4914,0.3698,-0.6481); AddMyPoint("MyCurvePoint02",pointpos); //创建第三个3D曲线节点 pointpos.Set(-5.6290,1.4154,-13.1798); AddMyPoint("MyCurvePoint03",pointpos); //创建第四个3D曲线节点 pointpos.Set(16.9406,2.2655,-7.5127); AddMyPoint("MyCurvePoint04",pointpos); //创建第五个3D曲线节点 pointpos.Set(11.0290,4.6365,7.4857); AddMyPoint("MyCurvePoint05",pointpos); //创建第六个3D曲线节点 pointpos.Set(6.5195,1.4547,18.4573); AddMyPoint("MyCurvePoint06",pointpos); //创建第七个3D曲线节点 pointpos.Set(-7.1194,0.7749,17.2438); AddMyPoint("MyCurvePoint07",pointpos); //创建第八个3D曲线节点 pointpos.Set(-5.6007,4.9012,6.5507); AddMyPoint("MyCurvePoint08",pointpos); //创建第九个3D曲线节点 - 132 - pointpos.Set(6.6380,0.0200,-15.4673); AddMyPoint("MyCurvePoint09",pointpos); } 创建3D曲线节点的方法,写成了一个独立的函数,用于在主函数中调用。如果只创建 一个3D曲线不加入3D曲线节点的话,该曲线只是一个只有位置的3D物体而已,没有任何意 义。所以我们创建了九个节点,并加入到3D曲线中;节点如果被创建以后,没有加入任何 3D曲线,它也只是一个只有位置的3D物体而已,没有任何意义。 9.1.2 3D 曲线的应用 上一小节虽然创建了曲线,但好像感觉就是在三维场景里面显示了一条曲线而已,貌 似没什么用处。其实3D曲线最大的用处是做某些3D物体的运动轨迹,比如在游乐场旋转木 马的运动轨迹,过山车的运动轨迹等等。试想一下如果没有3D曲线,凭着一套数学公式去 演绎这样的一条曲线„„! 下面我们来看看3D物体如何按曲线的轨迹是做运动。请打开例子9.1.2_1,该程序运用 了两种方法实现物体以3D曲线为运动轨迹进行移动,在处理这方面问题上,Virtools提供了 很好的BB去实现它,比如BB“Curve Follow”和“Position On Curve”的配合使用,其效果 是无可挑剔的,直观、方便、快捷。VSL在这方面就不如这些BB了,但仍有其优点,就是 可以同时处理多个物体的轨迹运动。其Virtools脚本程序细节如下图所示: 图9.1.2_1 使用BB实现的方法,这里就不多说了,毕竟本书的主角不是它们。有兴趣的读者可以 自行研究该例子程序。下面来看VSL脚本“showCurve”,其输入参数如下: 参数名 参数类型 参数功能 参数用途 MyCurve Curve 输入参数 输出创建的3D曲线 LineColor Color 输入参数 3D曲线的颜色 其代码如下: void main() { // Insert your code here MyCurve.Close(); //设置3D曲线为闭合的曲线 MyCurve.SetColor(LineColor); //设置曲线的颜色 MyCurve.CreateLineMesh(); //使Virtools把3D曲线渲染成线 } 默认的情况下,创建的3D曲线是不闭合的,调用函数Close()可以实现3D曲线的闭合, - 133 - 闭合的曲线自动把首尾的两个控制点连接起来。如果想设置为不闭合的曲线调用函数Open( ) 即可。我们可能不喜欢Virtools默认的渲染3D曲线的方式,因为它连曲线的控制节点也一起 渲染出来了,不好看。我们可以通过调用函数CreateLineMesh( )来让Virtools渲染出一条细细 的3D曲线,可以通过调用函数SetColor( )来设置这条细线的颜色。如果想渲染出自定义粗细 的3D曲线,就只能调用BB“Render Curve”了。该程序运行效果下图所示: 图9.1.2_2 本例子程序的另一个VSL脚本“Follow”,它的功能是处理多个3D物体以同一条曲线 为轨迹进行运动,如果是以BB“Curve Follow”和“Position On Curve”的这样的组合来实 现该功能化,就会使出现多个这两个BB的重复脚本。用VSL写一个脚本处理该问题会方便 得多。该脚本输入参数如下: 参数名 参数类型 参数功能 参数用途 MyCurve Curve 输入参数 输出创建的3D曲线 cask1 Entity3D 输入参数 在曲线轨迹上运动的3D物体 cask2 Entity3D 输入参数 在曲线轨迹上运动的3D物体 cask3 Entity3D 输入参数 在曲线轨迹上运动的3D物体 Step float 输入参数 获取3D曲线上某点的参数 其代码如下: void main() { // Insert your code here Vector pos; //定义一个位置向量 float tempsetp=0; //定义一个浮点型变量,用来存放增量 MyCurve.GetPos(Step,pos); //得到曲线上某一点空间位置 cask1.SetPosition(pos); //将3D物体设置到这个位置上 //对第二3D物体进行设置 tempsetp=Step+0.03; //取曲线上不同的位置 if(tempsetp>1) //如果大于1,从头开始 tempsetp=tempsetp-1; - 134 - MyCurve.GetPos(tempsetp,pos); //得到曲线上某一点空间位置 cask2.SetPosition(pos); //将第二3D物体设置到这个位置上 //对第三3D物体进行设置 tempsetp=Step+0.06; //取曲线上不同的位置 if(tempsetp>1) //如果大于1,从头开始 tempsetp=tempsetp-1; MyCurve.GetPos(tempsetp,pos); //得到曲线上某一点空间位置 cask3.SetPosition(pos); //将第三3D物体设置到这个位置上 } 9.1.3 3D 曲线节点的详细设置 前面说过曲线的节点是表示曲线是如何通过该节点的,曲线在通过该节点的时候的表 现形式。一般是通过曲线的在节点的入切线、出切线和“TCB”参数来表示。 所谓“TCB”参数就是曲线的张力(tension)、曲线的连续性(continuity)以及曲线的 (偏移)的缩写。 张力——控制曲线中的节点曲率大小。较高的张力可以生成线性曲线,较小的张力可 以生成非常宽的弧形曲线。 连续性——控制曲线在节点处的切线属性。较高的连续性值可以在关键点的两侧产生 弯曲,较低的连续性值会生成线性动画曲线。较低连续性和较高张力线性曲线类似。 偏移——控制曲线在节点处有关的动画曲线显示的位置。较高的偏移会使曲线位于节 点之外。较低的偏移会使曲线位于点之前。 这三种属性表现如图 9.1.3_1 所示。该图展现了三者取值不同的情况下,导致曲线形状 的不同。 图 9.1.3_1 - 135 - 还是请打开例子 9.1.2_1,脚本“SetTCB”实现了修改 3D 曲线节点属性的修改,其输入参 数如下: 参数名 参数类型 参数功能 参数用途 MyCurve Curve 输入参数 输出创建的3D曲线 Type bool 输入参数 是否设置曲线类型标志 TCB bool 输入参数 是否设置曲线TCB标志 其代码如下: void main() { // Insert your code here int count=MyCurve.GetControlPointCount(); //得到当前曲线节点数量 for(int i=0;i1) //如果已经到曲线结束就重头开始 OutStep=OutStep-1.0; Vector2D temppos=EndPos-StartPos; //计算2D帧移动的方向和大小 temppos*=my2DCurve.GetY(OutStep); //得到曲线此时刻的Y轴值 pos=StartPos+temppos; //计算2D帧的当前位置和大小 myEntity2D.SetSize(pos); //设置2D帧的当前大小 myEntity2D.SetPosition(pos); //2D帧的当前位置 } 关于 2D 曲线的控制节点,其概念于 3D 曲线控制节点特性一致,这里不再啰嗦了。 上面的 VSL 脚本代码请参看例子 9.2.1_1。 9.3 网格 9.3.1 网格和层 在 3D 场景中,网格(Grid)是一种简单、实用用的对象。它由一系列小单元组成, 这些小单元可以设置成不同的值。网格的最大用处标记 3D 场景中静态的障碍物,例如桌 子、石头、水池、围墙等等。网格不仅可以限制角色在行动区域中哪里能走,哪里不能走; 还可以为角色查找一条合适的路径,走向目的地。 网格的外形看起来是一个立方体,有长、宽、高;这个立方体又由一系列的小立方体 单元组成。这个矩形的作用是把要进行检测的场景给罩了起来,被罩住的物体会进行碰撞 检测,没被罩住的不参加该网格的碰撞检测。网格只是罩住障碍物,而要标记出障碍物还 得靠层(Layer)。一个网格可以包含多个层,每个层可以用来标记相同属性的障碍物,例 如围墙层、水池层、室内家具层等。 所谓层(Layer)仅仅是用来存储一系列的值而已。根据这些值的不同,对障碍物进行 判断。简单的说就是设置网格中小立方体的颜色值,而这些颜色所在的地方,就是障碍物 所在。一个网格的大小没有限制,但网格的小立方体单元数量是受限制的长、宽都不能超 过 128。也就是说最多 128×128 个单元。下图显示了所谓的网格与层: - 138 - 图 9.3.1_1 图 9.3.1_2 在一个 3D 场景中,常常出现这种情况:就是在同一个地方有两个网格同时作用。例 如一个网格要在这个区域标示出障碍物,阻止角色穿过;另一个网格也是在这个区域,它 要标示的是这个区域中的某些小区,要发生的特定事件。也许读者此刻有点犯迷糊了,具 体打个比方说:在一个悬崖上有个独木桥,这里使用两个网格,第一网格是标示悬崖区域 的障碍物,阻止角色掉入到悬崖;第二个网格是标示出是个独木桥区域,当角色走上桥的 时候,行走的动画就切换到小心翼翼地行走动画,而不是像在平地上大步流星的走。此刻 如果角色想走上独木桥,第一个网格判断到桥属于悬崖区域,就会阻止角色上独木桥。如 何解决两个网格的冲突问题呢?可以通过设置网格的优先级别来化解网格间的冲突问题。 其实 Virtools 中直接在图像化的开发界面上应用网格要方便得多,在 VSL 中虽然可以 使用网格,但是有很多限制,因为它毕竟是以代码的形式去实现功能。在处理这种需要手 工编辑的问题上,显得很困难的。 9.3.2 网格的 VSL 应用 在 VSL 中虽然可以定义和创建网格和层,但无法将网格(Grid)和层(Layer)作为 输入输出参数,如果想从其他地方获得 VSL 脚本创建的网格和层,只能通过网格和层的文 - 139 - 件名去获取了。从这一点可以看出 VSL 对这两个对象的操作是很有限。下面来看一个简单 的创建网格的程序。请打开例子 9.3.2_1,其中 VSL 脚本“CreatGrid”的功能是创建一个 网格并为网格加入两个层;“SetLayer”脚本的功能是设置层的值。 void main() { // Insert your code here Grid mygrid=bc.CreateGrid("MyGrid"); //创建一个网格 mygrid.SetDimensions(50,50,100,100); //设置网格的大小和单元格的数量 mygrid.SetHeightValidity(100); //设置网格的高度 mygrid.ConstructMeshTexture(0.5); //设置网格渲染的透明度 //mygrid.DestroyMeshTexture(); //曲线网格的渲染。 Layer mylayer=mygrid.AddLayer(1); //给网格加入一个层 if(mylayer==NULL) //如果加入层失败 { //向控制台打印出错信息 bc.OutputToConsole("Create The First Layer ERROR"); Out=FALSE; //关闭输出端口 return; //程序立即返回 } mylayer=mygrid.AddLayer(2); if(mylayer==NULL) //同上 { bc.OutputToConsole("Create The Second Layer ERROR"); Out=FALSE; return; } mygrid.SetPriority(10); //设置网格的优先级别 Vector pos(50,50,50); //定义一个位置向量 int cx,cy; //定义两个高度 float dis=mygrid.Get2dCoordsFrom3dPos(pos,cx,cy); //根据世界坐标得到对应单元格 bc.OutputToConsole(dis); //向控制台打印结果 bc.OutputToConsole(cx); bc.OutputToConsole(cy); mygrid.Get3dPosFrom2dCoords(pos,cx,cy); //根据单元格得到世界坐标 bc.OutputToConsole(pos.x); //向控制台打印结果 bc.OutputToConsole(pos.y); bc.OutputToConsole(pos.z); //得到网格的方向模式 CK_GRIDORIENTATION Girdmode=mygrid.GetOrientationMode(); bc.OutputToConsole(Girdmode); //向控制台打印结果 mygrid.SetOrientationMode(CKGRID_YZ); //设置网格的方向模式 } 在 VSL 中创建网格,还是由“工厂”对象 bc 来创建(前面的 4.2.2 小节解释过为什 - 140 - 么称它为工厂)。设置网格大小可以使用函数 SetDimensions(50,50,100,100),该函数的 第一、第二个参数是网格的宽和长的单元格数目,第三、第四个参数表示网格的大小。有 了网格的长宽,就缺少网格的高了,函数 SetHeightValidity( )的功能是设置网格的高度。 网格即使有了长宽高的大小,但 Virtools 默认是不渲染网格,如果要渲染就要调用函数 ConstructMeshTexture( )并传入一个浮点型参数,来设置网格的透明度;如果不想再渲染 网格可以调用 DestroyMeshTexture( )函数即可。因为网格也属于 3D 物体,也可通过调用 函数 Show(CKSHOW)和 Show(CKHIDE)来到达显示隐藏网格的目的。 创建完网格后,会有一个默认的层(“-default-”)也随之出现,但此时网格并没有任 何一个层,直到我们调用用函数 AddLayer(1 )加入该层,这里要注意的不能调用“0”做 为该函数的输入参数,否则会出错。如果想加入第二个层,这样要强调一下:这二个层必 须已经在其它的网格中存在(如图 9.3.1_2 所示,存在多个层),否则就会创建失败。可见 在 VSL 中使用网格多么麻烦! 有了网格,我就可以知道当前被网格罩住的场景中的 3D 物体的位置对应的单元格编 号,从而查看该单元格是否是层所标示的障碍物区域。调用 Get2dCoordsFrom3dPos( ) 函数除了得到对应的单元编号外,还可以得到该 3D 物体距离网格的高度值。如果想要根 据当前的单元格得到空间中的位置调用函数 Get3dPosFrom2dCoords( )即可。 在进行网格应用的时候,还有其检测的方向性,不过这一点很容易忽视,因为使用得 太少,没什么重要性和实用性可言,默认的情况下检测方向是无角度限制的,可以通过调 用函数 SetOrientationMode( )来更改其检测角度,其它检测角度如下表所示: CKGRID_FREE 无角度限制 CKGRID_XY Aligned along the X and Y world axis CKGRID_XZ Aligned along the X and Z world axis CKGRID_YZ Aligned along the Y and Z world axis 表 9.3.2_1 上面 VSL 脚本创建了网格和层,下面我们来设置层的属性 void main() { // Insert your code here //根据网格名和网格类型标识符得到网格 Object obgrid=bc.GetObjectByNameAndClass("MyGrid",CKCID_GRID); Grid mygrid=Grid.Cast(obgrid); //转换为网格 if(mygrid==NULL) //如果网格不存在 { bc.OutputToConsole("Get Grid ERROR"); //打印出错信息 Out=FALSE; //关闭输出端口 return; //程序立即返回 } mygrid.SetOrientationMode(CKGRID_FREE); //设置网格的方向模式 Layer lay01=mygrid.GetLayer(1); //得到网格第一层 Layer lay02=mygrid.GetLayer("MyNewLayer"); //根据层名得到层 if(lay01==NULL||lay02==NULL) //如果层不存在,则返回 - 141 - { bc.OutputToConsole("Get Layer ERROR"); Out=FALSE; return; } Color mycolor(1.0,0.0,0.0); //定义一个颜色值 int colorint=mycolor.GetRGB(); bc.OutputToConsole(colorint); //输出该颜色的整形值 for(int i=0;i<50;i++) 设置层中单元格的颜色值 { lay01.SetValue(i,0,colorint); lay01.SetValue(i,49,colorint); lay01.SetValue(0,i,colorint); lay01.SetValue(49,i,colorint); } mycolor.Set(0.0,1.0,0.0); //修改颜色值 colorint=mycolor.GetRGB(); bc.OutputToConsole(colorint); for(int i=20;i<30;i++) //设置另一个层上单元格的颜色值 for(int j=20;j<30;j++) { lay02.SetValue(i,j,colorint); } bc.OutputToConsole("Get lay01 Value"); //得到层上某一单元格的值 lay01.GetValue(25,25,colorint); //将结果打印到控制台上 bc.OutputToConsole(colorint); lay01.GetValue(49,49,colorint); bc.OutputToConsole(colorint); bc.OutputToConsole("Get lay02 Value"); //得到层上某一单元格的值 lay02.GetValue(25,25,colorint); //将结果打印到控制台上 bc.OutputToConsole(colorint); lay02.GetValue(49,49,colorint); bc.OutputToConsole(colorint); } 层的应用比较简单,就是设置颜色值,和得到颜色值。这样说成设置颜色值不要理解 为网格就是用来设置颜色的。设置和得到单元格的颜色值是为了标示场景中的事件区域。 是做场景事件用的。 - 142 - 第十章 表、组和场景 Virtools的表(Array)是一种非常直观、易用的数据存储结构。它的表现形式就像微软 的“Excel”表格处理软件,表由行和列组成,列表示数据的类型、行包含该数据的值。掌 握表的使用,会让你的程序的逻辑变得清晰,数据的存储变得直观。组(Group)也是一种 存储和管理物件的结构,理解和使用组很简单,虽然简单但用处很大。这些内容是很重要的, 它会对你程序的逻辑造成很大的影响,用得好,用得坏直接影响程序的逻辑结构和程序的维 护成本,要灵活应用它们还需要日后的经验积累,会做、做完和做好是一个不同级别的处事 态度。本章的最后还简单的述说了场景(Scene)的概念和应用,它是一种优化技术;虽然 不是所有项目都用得上场景(Scene),但是总是会有用到它的项目的在本章将学习以下的 一些内容:  表的数据存储类型  如何创建和操作表的数据  使用表要注意的一些事项  组的意义和运用  使用组的一些注意事项  场景(Scene)的概念和应用 10.1 表 10.1.1 表的数据存储类型 Virtools 的表所能够存储的数据类型总的说来有五种,这五种表的数据存储类型基本上 包含了所有在 Virtools 开发中所能碰到的数据或对象,也就是说基本上所有的东西都可以 存在表里面。表的五种数据存储类型如下所示,其中括号内的大写英文字符,是 VSL 中所 对应的数据类型表示:  整型 (CKARRAYTYPE_INT)  浮点型 (CKARRAYTYPE_FLOAT)  字符串 (CKARRAYTYPE_STRING)  对象 (CKARRAYTYPE_OBJECT) (仅仅是存储对象名)  参数 (CKARRAYTYPE_PARAMETER) (参数类型是一系列复杂对象的总 称,是可以再细分选择的) 要向表里存储数据,在尽可能的情况下请使用前四种数据类型,因为它们存取数据快、 并占用内存少。而最后一种的“参数”数据类型,它要比前面四种数据类型要多消耗十倍 的系统内存。可见如果一个表有几万行的数据要存储,就不要是使用这种“参数”类型的 方式进行存储。 “参数”只是一系列复杂对象的总称,还需要进一步的细分选择,如图 10.1.1_1 所示。 在下列列表框中就是被细分的复杂对象,这些对象很多但合适用的却不多,比如 3D 物体、 2D 帧、以及材质、纹理、声音等等,都可以用这种“参数”类型的方式存储在表中。 正如前面所提及的,这种存储方式会消耗更多的内存。所以大多数情况下使用“对象” 类型来代替“参数”类型,但是也有无法替代的情况。比如存储多行的字符串,如果按普 通的字符串类型存储于表中,第一个换行后面的字符串,将会被丢失;如果使用“参数” 数据类型,再进一步选择“String”的字符串类型,就没问题了。如果 10.1.1_1 所示。 - 143 - 图 10.1.1_1 10.1.2 使用 VSL 创建表和储存数据 创建任何东西,都由 “工厂”对象 bc 来创建,包括表。表是行列的结构,所以创建 完表以后,还要创建表的列,并设置列所对应的数据类型,有了列的数据类型,但还没有 能存储数据的空间,所以在添加数据前,要创建行。表有了行和列就可以往里添加数据了。 请打开例子 10.1.2_1,其中“CreatMyArray”是创建表、并设置列的数据类型。“AddMyRow” 是向创建的表添加数据。 “CreatMyArray”创建的是一个这样的表,它包含角色的姓名、性别、年龄、能力值、 角色的模型、角色所领养的宠物的模型、该宠物的能力属性、角色所领养的另一个宠物的 模型、该宠物的能力属性,一共九列。该 VSL 脚本演示了各种数据类型的创建。其输出参 数如下: 参数名 参数类型 参数功能 参数用途 MyArray Array 输出参数 创建的一个表 其代码如下: void main() { //通过工厂“bc”创建表,并命名为“MyArray”。 MyArray= bc.CreateArray("MyArray", true, true); //创建字符串类型列 MyArray.InsertColumn(-1, CKARRAYTYPE_STRING, "Name", GetGUID(GUID_STRING)); MyArray.InsertColumn(-1, CKARRAYTYPE_STRING, "Sex", GetGUID(GUID_STRING)); //创建整型列 MyArray.InsertColumn(-1, CKARRAYTYPE_INT, "Age", GetGUID(GUID_INT)); //创建浮点型列 - 144 - MyArray.InsertColumn(-1, CKARRAYTYPE_FLOAT, "Power", GetGUID(GUID_FLOAT)); //创建对象列 MyArray.InsertColumn(-1, CKARRAYTYPE_OBJECT, "CHARACTER", GetGUID(GUID_CHARACTER)); //创建参数类型的列 GUID guid_character = GetGUID(GUID_CHARACTER); GUID guid_string= GetGUID(GUID_STRING); String columnName; for (int i = 0; i < 2; ++i) { columnName = "Pet"; columnName += i; MyArray.InsertColumn(-1, CKARRAYTYPE_PARAMETER, columnName.Str(), guid_character ); columnName = "PetName"; columnName += i; MyArray.InsertColumn(-1, CKARRAYTYPE_PARAMETER, columnName.Str(), guid_string); } } 本例子创建的表是一个动态表,当程序复位的时候,会消失。如果要想在程序复位的 时候,创建的表留下来,需要设置 CreateArray( )函数的第二个参数为 false 即可,该函数 的第三个参数表示是否将创建添加到当前场景(Sence)中。何为场景(Sence)将在后 面的章节介绍。当创建好一个表,然后通过 InsertColumn( )函数来添加列,该函数有四个 参数:第一个参数表示将该列插入那一列之前,如果填写“-1”就表示添加在最后;第二 个参数表示该列的表数据类型;第三个参数表示列的名字;第四个参数表示在 VSL 中,每 一种数据类型的唯一标识符(GUID),通过 GetGUID ( )函数可以得到该唯一标识符,要 想了解其它类型数据的唯一标识符,请打开 Virtools 的帮助文档,查阅关于“Using GUID” 的内容即可。也许乍看起来唯一标识符(GUID)很陌生,其实它的用法很单一、很简单只 要用时查查帮助文档就可以了。 相对于创建表来说,向表中添加数据就容易得多。“AddMyRow”的输入参数如下: 参数名 参数类型 参数功能 参数用途 MyArray Array 输入参数 向该表填写数据 Name String 输入参数 输入角色名字 Sex String 输入参数 输入角色性别 Age int 输入参数 输入角色年纪 Power float 输入参数 输入角色能力 Role Character 输入参数 输入玩家角色类型 Pet01 Character 输入参数 输入宠物角色类型 PetName01 String 输入参数 输入宠物名字 Pet02 Character 输入参数 输入宠物角色类型 PetName02 String 输入参数 输入宠物名字 其代码如下: - 145 - void main() { //在表的末尾添加一行,用来存放数据 MyArray.AddRow(); int count=MyArray.GetRowCount()-1; //得到最后一行的索引号 //添加字符串类型数据 MyArray.SetElementStringValue(count,0,Name.Str()); MyArray.SetElementStringValue(count,1,Sex.Str()); //添加整形、浮点型数据 MyArray.SetElementValue(count,2,Age); MyArray.SetElementValue(count,3,Power); //添加对象类型数据 MyArray.SetElementObject(count,4,Role); MyArray.SetElementObject(count,5,Pet01); MyArray.SetElementStringValue(count,6,PetName01.Str()); MyArray.SetElementObject(count,7,Pet02); MyArray.SetElementStringValue(count,8,PetName02.Str()); } 可以看出虽然表数据类型有五种,但是添加数据到表的函数只有三个,也就是说用三 个函数实现了五种数据类型的存储。SetElementStringValue( )函数在存储字符串类型的时 候调用;SetElementValue( )函数在存储整形、浮点型数据时调用;SetElementObject( ) 函数在存储对象和参数对象数据类型时调用。这三个函数的输入参数都是一样的,第一、 二个参数表示要设置表中的哪一行,那一列的数据;第三个参数表示要存储到表中的值。 10.1.3 通过 VSL 提取表中数据和删除行列 存储数据和提取数据在 VSL 中都容易发生这样一个错误——类型不匹配。比如说:某 列是整形,而存取这个该列的值时,用了一个浮点型数来承接,就会出错。你会觉得奇怪, 为什么 VSL 不会自动进行精度转换,有时候某些想法是无意义的,只是提醒你这是 VSL, 而不是 C\C++。 请打开例子 10.1.2_1,继续用上一小节创建的表,请注意表中每种数据类型的获得方 法。“GetArrayElement”其输入参数如下: 参数名 参数类型 参数功能 参数用途 MyArray Array 输入参数 提取该表中的数据 其代码如下: void main() { int rownum=MyArray.GetRowCount(); //得到该表一共有几行数据 String colname; //字符串类,用于显示结果 String resvale; //字符串类,用于得到表中数据 int size=0; //字符串数据的长度 float k=0.0; Character temp; //表中“对象”类型数据 for(int i=0;i; } } 声明了全局全家变量以后就可以通过材质的属性页中,进行颜色值,如图14.1.1_1 下图所示,也通过BB“Set Shader Parameters”这个BB来改变。 - 209 - 图14.1.1_1 还有一种方式可以完成实时改变颜色值功能,这种方法是把该颜色值跟材质的“Emissive” 属性的颜色值关联管理起来,在定义的全局变量后面使用语义如下所示: float4 Col:EMISSIVE; //定义的全局变量、方便实时更改颜色值 technique tech { pass p { MaterialEmissive=; } } 重新编译该Shader的代码,再打开材质属性页如图14.1.1_2图,设置材质的“Emissive” 属 性的颜色值,就会发现3D物体的颜色也发生变化。在全局变量float4 Col后面使用了 “EMISSIVE”这个语义,表示这个颜色值会和材质的Emissive”属性的颜色值关联管理起 来。这两种方式各自有各自的优点,往后的学习中你发现各自的优点所在。 图14.1.1_2 有兴趣的读者请把“float4 Col={1.0,1.0,0.0,1.0};”这行代码前加上一个“static”变成 “static float4 Col={1.0,1.0,0.0,1.0};”再编译一下,再看看这个参数值是否还可以实时修改。 14.1.2 设置自发光参数的 HLSL 写法 所谓HLSL写法,就是不用Virtools自带的那些便捷的写法,上一小节“ float4 Col:EMISSIVE和MaterialEmissive=”就是Virtools的便捷方式,这种便捷方式便捷到 连入口函数都不需要写。为了达到学习的目的,用HLSL方式写法如下: - 210 - float4 Col={1.0,1.0,0.0,1.0}; float4 ColorShow_PS():COLOR { float4 finalColor = Col; return finalColor; } technique tech { pass p { PixelShader = compile ps_1_1 ColorShow_PS(); } } 上面代码的功能和前面的Shader代码的功能是一样的,运行结果也是一样的。只不过入口函 数有了些变化,就是没有使用到输出结构而是直接定义了入口函数的返回值类型,并在函数 后面使用“:COLOR”了这个语义,表面这个函数返回的是个颜色值。入口函数名也在后面 加上了一个“_PS”的后缀,表明这是一个像素着色的入口函数,增加代码的可读性。 上面的例子只是使用一个颜色,但如何把两个或多个颜色相互混合后把结果输出呢? 其实只要将这多个颜色按比例相加就行了,代码如下所示: float cut = 0.3; float4 Col01={1.0,1.0,0.0,1.0}; float4 Col02={1.0,0.0,0.0,1.0}; float4 ColorShow_PS():COLOR { float4 finalColor = Col01*cut+Col02*(1-cut); return finalColor; } technique tech { pass p { PixelShader = compile ps_1_1 ColorShow_PS(); } } 定义浮点类型变量float cut、但这个变量后面却带有“ = 0.3”这语句,其实这是Virtools提供的一种便捷方式,,它把变量cut的值钳制在某个范围 内,其中float UIMin=0表示最小值是0,当然也可以是负值;float UIMax=1表示最大值是1, 也可以是其他值,最后的“= 0.3”表示默认值。这种便捷方式使用效果如图14.1.2_1所示。 前面说过本小节不使用Virtools自带的便捷方式,但最终还是用到一些,没办法毕竟HLSL被 Virtools封装了一层。现在只能在算法、和思想上区分一些,完全的抛开Virtools是不行的, 也没有必要,毕竟我们是在Virtools下使用Shader。有些读者此刻会觉得——好烦,到底要 怎样!其实,我们中的很多人不可能一辈子就是用一个软件,今天你在这家公司使用Virtools, 下一家公司使用其他游戏引擎,再下一家公司又会使用其他的软件。只要是使用到Shader 编程,这些游戏引擎或软件就免不了像Virtools一样,对Shader语言进行支持、封装。它们 - 211 - 之间就存在共性了,把Virtools里面的Shader代码修改一下,就可以放到其他软件或游戏引 擎中,是一件很快乐的事情,取长补短嘛。但前提必须了解这个共性,这个共性就是HLSL, 所以在讲Virtools的Shader的同时,本书也会讲HLSL。 图14.1.2_1 Shader编程使用到的参数很多、参数类型也很多;Virtools的便捷开发方式的参数也很多, 这里只是小小几个,其他参数会在后面的章节中逐个介绍。 14.2 光照 14.2.1 光照的原理 上一小节实现了一个物体的简单渲染,但就显示出一块颜色而已,这种程度是无法让 人满足的,下面就添加个光照效果,添加光效之前首先要明白光照是怎么一个原理。一个3D 物体的表面颜色是自发光(emissive)、环境反射(ambient)、漫反射(diffuse)、镜面反射 (specular)等光照共同作用的结果。除了物体的表面颜色外还会与光的类型、光的颜色、 光的位置等形成最终的表面颜色。一个3D物体表面呈现的颜色数学公式如下: EmissiveIIII SpecularDiffuseAmbientTotal  自发光(也称环境光)是物体表面自身发出的光,这种光照作用不需要其他光源,也 能呈现所指定的颜色。从现实世界的角度看,除了光源外的物体是不会自己发光的,这只是 虚拟现实中的一种模拟罢了。这种模拟的自发光只对自身有影响,因为不是光源,所以不会 照亮附近的物体或投下影子。在虚拟现实中为了模拟现实的光照效果,自发光就相当于一个 亮度值,它是均等的,没有衰减,也不会引起任何光线传播的改变,它与物体上该点的法向 量无关。 环境反射光不需要光源的位置,也不是被光源直接照射到,而是四面八方的物体将照 射到它们身上的光经过反射而照射到这个物体上。环境反射的结果依赖于物体的反射光能力, 以及照射到该物体上的环境光的颜色。和自发光一样,环境反射也是呈现所指定的颜色,但 它还受到全局环境光照的影响。如果用GlobalAmbient表示全局环境光照、Ambient表示物件 的环境反射光的颜色系数,AmbientFinal表示最终的环境反射光颜色,那么数学公式如下: AmbientFinal=Ambient×GlobalAmbient; 漫反射光是指从光源发出的光照射到粗糙的物体表面后,经被物体表面向各个不同的 方向进行了反射,反射后的光没有固定的方向,即使平行光束经过漫反射后,也不再是平行 光束。经过漫反射后物体表面呈均匀的亮度,从各方向上看去无明显差异。由漫反射形成的 物体亮度,一般视光源强度,和反射面性质而定。漫反射如图14.2.1_1图所示,如果用Kd表示 材质的漫反射颜色;LightColor表示入射漫反射光的颜色;N表示规范化的物体表面法向量; - 212 - L是规范化的指向光源的向量;DiffuseCol表示漫反射的颜色系数,DiffuserFinal表示最终的 漫反射的颜色值;那么其数学计算公式如下: DiffuserFinal= DiffuseCol×LightColor×max(N L,0) 其中N L 是两个向量的点积,点积的值反映两个向量夹角的大小,点积越大,夹角越 小;点积越小,夹角越大。函数max(N L,0)表示取这两个值中的最大值,因为背光面的 点会得到负数的点积值。 图14.2.1_1 图14.2.1_2 镜面反射(有人也称作高光)是指从光源发出的光照射到光滑的物体表面后,被物体 平行的方式反射出去。镜面反射的结果除了受到光源和材质的镜面反射颜色性质的影响外, 还受到物体表面光泽度的影响,表面越光滑的物体,镜面反射的效果越显著,比如一辆新车。 镜面反射不同于漫反射、环境反射和自发光这些,它的反射作用依赖于观察者和入射光线的 角度,如果入射光线和观察者的位置重合,就观察不到镜面反射。图14.2.1_3就是镜面反射 的简图: 图14.2.1_3 镜面高光反射的计算需要引入一个新的向量:eye向量。所谓eye向量就是从相机位置指向观 察目标的向量。下面是用来计算镜面反射的数学公式:   shininessRLfacinglightColSpecColSpecular 0,max  其中:SpecularCol是材质镜面反射的颜色;lightColor是入射镜面反射的颜色光;L是指向光 源的规范化的向量;R是镜面反射向量,facing是1如果LR大于0的,否则为0;shininess是 光泽度因子,这个点积的幂,它确保镜面反射外表在角度变化时能够迅速的衰减。 - 213 - 14.2.2 光照 Shader 这个Shader实现的功能是缺少镜面反射的普通光照,并用Virtools提供的便捷Shader语 句方式来实现。首先将一个3D物体拖拽到Virtools编辑窗口中,这个3D物体的点面数要多一 些,才可以看到较好的效果,因为这个光照渲染技术依赖于3D物体的顶点数目。请打开例 子程序14.2.1_1.cmo float4 EmissiveCol:EMISSIVE; //定义一个颜色4D向量与材质的自发光属性关联 float4 DiffuseCol:Diffuse; //定义一个颜色4D向量与材质的漫反射属性关联 technique tech { pass p { MaterialEmissive=; //设置物体的自发光 MaterialDiffuse=; //设置物体的漫反射 LightEnable[0] = true; //启用灯光 LightAttenuation0[0] = 1; //设置灯光的衰减属性 LightDiffuse[0] = {1, 1, 1, 0}; //设置灯光的漫反射颜色 LightRange[0] = 5000; //设置灯光的最远作用距离 LightPosition[0] = {100, 500, -1000}; //设置灯光的位置 LightType[0] = POINT; //设置光的类型 } } MaterialEmissive在前面已经讲过了,同样的道理MaterialDiffuse对于材质的漫反射、 MaterialAmbient对于材质的环境光;MaterialAmbient在这里没有用到,即使用了也看不到 效果变化,这也许是因为Virtools认为这个光照的分量没那么重要。Shader代码中灯光默认 是关闭的,要手动开启如代码LightEnable[0] = true表示开启一盏灯光,该灯光编号为“0”, 意思可以同时开启多盏灯,各个灯光的变化依次加“1”,在中括号“[ ]”内的数字显示区 别。每盏灯光的属性可以各不相同,但是所能开启的灯光数量是有限的。多开几盏灯光效果 也许会很好,但是这些灯光渲染的时间和资源也就消耗越多,这是一个要权衡的问题,一般 大多就是用一盏灯,少数会使用到三盏灯。LightAttenuation0[0]是设置光的衰减属性,这个 内容在VSL部分已经讲过,请参见第五章相关内容。LightDiffuse[0]表示该灯光的漫反射颜 色,注意这个不是光的颜色,到后面我们还会学习光的镜面反射的颜色,要区分开来。 LightRange[0]和LightPosition[0]表示光的有限范围和光的位置,最后设置光的类型,、光 的三种类型标识符:POINT表示为点光源、,DIRECTIONAL为方向光(平行光)、SPOT 为聚光灯。当选择不同的光的类型,光的属性设置也就不同。 LightPosition[0]表示灯光的位置,在代码中赋给了它一个固定的值,如果灯光的位置 是变化的,上面代码就无法适应了。要改改,改成如下所示: float4 EmissiveCol:EMISSIVE; //定义一个颜色4D向量与材质的自发光属性关联 float4 DiffuseCol:Diffuse; //定义一个颜色4D向量与材质的漫反射属性关联 float3 lightPos ; //定义一个位置向量并与一个3D物体关联起来 technique tech { pass p - 214 - { MaterialEmissive=; //设置物体的自发光 MaterialDiffuse=; //设置物体的漫反射 LightEnable[0] = true; //启用灯光 LightAttenuation0[0] = 1; //设置灯光的衰减属性 LightDiffuse[0] = {1, 1, 1, 0}; //设置灯光的颜色 LightRange[0] = 5000; //设置灯光的最远作用距离 LightPosition[0] =; //设置灯光的位置 LightType[0] = POINT; //设置光的类型 } } 语句“float3 lightPos ”和前面章节出现的语句“float cut = 0.3”,有个共同之处就是声名的变量后跟着一个“< >”, 表示这个变量有个便捷的用法。此处“float3 lightPos 的用法如下 图所示: 图 14.2.2_1 上图圈内的“lightPos”正是 Shader 的中对应的位置变量,它与选中的 3D 物体关联起来, 当该 3D 物体位置发生改变时,LightPosition[0]获得的值也就随之改变,省去了手动地求 得物体的位置后,再进行赋值的过程。Virtools 这种便捷方式是个很好的功能。 14.2.3 光照的 HLSL 写法 上一小节实现了 Shader 的光照,是用 Virtools 的内置 Shader 语句完成的,完成是完成 了,但对学习 Shader 语言编程没有帮助,不能看出其技术细节,也无法对代码进行移植, 现在来看一下使用 HLSL 语言来完成这个光照,其代码如下所示: float4x4 Wvp : WORLDVIEWPROJECTION; //合矩阵 float4x4 World : WORLD; //世界矩阵 float3 lightPosition; //一个与3D物体关联起来的位置向量 float3 lightColor; //光的颜色 float3 EmissiveCol; //物体自发光颜色 float3 DiffuseCol; //物体漫反射颜色 struct OutPut //输出结构 { float4 position:POSITION; //位置分量 - 215 - float4 color:COLOR; //颜色分量 }; //入口函数 OutPut BbasicLight(float4 position:POSITION,float3 normal : NORMAL) { OutPut OUT; //定义一个输出结构类型变量,用于返回值 OUT.position=mul(position,Wvp); //给输出结构的位置分量赋值 float3 P=position.xyz; //得到物体顶点的3维向量坐标 float3 N=normalize(mul(normal ,World)); //得到物体顶点的法线 float3 L=normalize(lightPosition-P); //得到指向光源的方向向量 float diffuseLight = max(dot(N,L),0); //得到顶点法线与光源方向的夹角 float3 diffuse=DiffuseCol*lightColor*diffuseLight; //得到物体的漫反射颜色 OUT.color.xyz=EmissiveCol+diffuse; //得到最终的颜色值 OUT.color.w=1; //颜色的最后一个分量置“1”。 return OUT; //返回结果 } technique tech { pass p { VertexShader = compile vs_1_1 BbasicLight(); //使用顶点渲染 } } 绝大部分顶点渲染函数都少不了这么一个全局变量,它是世界变换矩阵与视觉变换矩 阵和投影变换矩阵的乘积,有人称它为世界矩阵,因为基本上所有的顶点基本上都要与这个 变量相乘,才能正确地显示在2D的屏幕上,这个全局变量就是: float4x4 Wvp : WORLDVIEWPROJECTION; //合矩阵 代码的第二行也是一个表示矩阵的全局变量,它就是世界变换矩阵,注意与刚才说的 世界矩阵区别开来。它的作用就是把某些局部向量或矩阵转换到世界空间中,比如物体上某 个顶点的法线,该法线的原点参照物体本身,如果要与世界空间中其他向量进行运算,因为 原点参照不同,计算的结果就会产生误差,所以有必要乘以这个矩阵进行转换。这个全局变 量就是: float4x4 World : WORLD; //世界矩阵 由于是光照函数,所以必定需要一个光源的位置,float3 lightPosition注意这里只是需 要一个位置而言,所以不一定非要一个灯光的位置,也可以将一个3D帧或3D实体传给该参 数。由于这个光照函数只用到自发光、和漫反射光,所以光照的数学模型就简化为如下所示: EmissiveII DiffuseTotal  接着定义了三个颜色向量,分别表示光的颜色、自发光颜色以及物体的漫反射颜色。 这三个颜色向量与前面章节的Shader代码所表示的颜色向量不一样,它们是三维的而不是四 维的,即类型是float3而不是float4,用float3表示这颜色指明是只使用颜色的r、g、b值,而 忽略a值。如下所示: float3 lightColor; //光的颜色 - 216 - float3 EmissiveCol; //物体自发光颜色 float3 DiffuseCol; //物体漫反射颜色 入口函数通过返回结构体变量来完成这一次渲染,可以看到代码中没有定义输入结构 体,而是把需要的分量之间作为参数传给了入口函数,如下所示: OutPut BbasicLight(float4 position:POSITION,float3 normal : NORMAL) 这种方式也是可以的,但对于参数较多的情况,建议还是定义一个输入结构体,这样 要修改起来也方便一些。函数体内一般是首先定义输出结构变量,然后给结构体变量的各个 分量逐个赋值。函数mul(position,Wvp)表示行向量position和矩阵Wvp的积,因为position 是物体的顶点向量、Wvp是世界合矩阵,所以这个积的结果是将物体正确显示在2D的屏幕 上,注意正确显示的显示结果不一定意味着3D物体可以在屏幕中观察到,也许物体落在屏 幕外边也是可能的。函数mul( )也可以执行矩阵与矩阵的积运算,也可以矩阵与列向量的积 运算,结果的意义视传入参数的意义而定。 因为自发光就相当于一个亮度值,它是均等的,没有衰减,所以就直接使用全局变量 EmissiveCol来表示就行了,不需要特别的运算。而漫反射则不同,回顾一下求漫反射的数 学公式: DiffuseFinal=DiffuseCol×LightColor×max(N L,0) 漫反射的强度随着顶点法线N和指向光源位置向量的L夹角的变小而变得更强。如果L 与N平行则反射最强烈,如果L垂直平行于表面,则漫反射最弱。我们用全局变量DiffuseCol 的颜色值来表示这个漫反射的颜色系数,用全局变量lightColor表示公式中光的颜色值, 现在剩下要求N和L的角度了。跟据数学定理可以知道两个向量的点积可以求得这个两 个向量的夹角,两个向量的点积用函数dot( )可以求得实现。但是顶点的法线是个局部坐标 值,需要转换到世界坐标中去,所以再次使用函数mul(normal ,World)来实现,只不过这次 使用时把顶点的法线向量normal作为第一个参数、把世界矩阵World作为第二个参数,转换 完后最好还要对结果向量进行规范化处理,函数normalize( )如果不对结果进行规范化,效 果可能会太暗或太强,。得到最终的顶点向量N后,接下来求L。L是从顶点位置指向光源的 向量,两个向量相减就行了,别忘了对结果进行规范化。并不是每个结果向量都需要规范化, 这里我们之所以规范化N和L,因为我们求的是夹角,函数dot( )两个参数值使用规范化的向 量,结果更真实些。 对于求夹角的点积,这个值有可能出现负值,负值不是我们要的结果,此时调用函数 max(),它可以求得两个数中最大的一个数,将点积的结果与“0”传这个函数,如果点积 为负值,就返回0,表面物体的背面不受漫反射影响。 是否注意到了代码中float3 P=position.xyz和OUT.color.xyz=EmissiveCol+diffuse语句。 变量position是float4类型,它有x、y、z、w四个分量值;而变量P是float3类型,只有x、y、 z三个分量值。“.xyz”表示取position的x、y、z分量,并从这个三个值创建一个新的float3 类型向量,然后把这个新值赋给float3类型的变量P。当然也可以“.xwz”组成一个新值赋 给变量P,结果是不一样的。同理OUT.color这个变量是float4类型,而EmissiveCol+diffuse 的结果是float3类型,所以它们和的结果赋给了OUT.color的x、y、z这三个分量,当然也可 以OUT.color.xwz,表示值赋给的是x、w、z。最后OUT.color.w=1,w在表示位置的4D向量 中,其作用只是与4×4矩阵相乘的一个凑合的数字,可以假设为1。 最后用调用顶点着色器,就完成整个渲染代码的编写。 VertexShader = compile vs_1_1 BbasicLight(); //使用顶点渲染 例子程序中使用了3种方法来实现物体的普通光照,效果如下图所示: - 217 - 图14.2.3_1 在这段Shader代码中颜色向量使用的是float3类型,例如lightColor、EmissiveCol、 DiffuseCol,当填写颜色值的时候,要逐个的把x、y、z填写好,很不直观,如图14.2.3_2所 示。如果把这个颜色向量改成float4类型,直观地通过选取颜色来填参数就好了,如图14.2.3_3 所示。有时候会从其他地方搞定一些Shader代码要把它移植到Virtools来,就会对这种float3 类型的颜色值,改成float4类型以方便调试。改动不要想得太简单,直接把float3改成float4 是不行的,还有改动涉及的相关代码,下面把上面的代码稍改一下: 图14.2.3_2 图14.2.3_3 - 218 - float4x4 Wvp : WORLDVIEWPROJECTION; //合矩阵 float4x4 World : WORLD; //世界矩阵 float3 lightPosition; //一个与3D物体关联起来的位置向量 float4 lightColor; //光的颜色 float4 EmissiveCol; //物体自发光颜色(4D向量) float4 DiffuseCol; //物体漫反射颜色(4D向量) struct OutPut //输出结构 { float4 position:POSITION; //位置分量 float4 color:COLOR; //颜色分量 }; //入口函数 OutPut basicLight(float4 position:POSITION,float3 normal : NORMAL) { OutPut OUT; OUT.position=mul(position,Wvp); float3 P=position.xyz; float3 N=normalize(mul(normal ,World)); float3 L=normalize(lightPosition-P); float diffuseLight = max(dot(N,L),0); float4 diffuse=DiffuseCol*lightColor*diffuseLight; OUT.color=EmissiveCol+diffuse; return OUT; } technique tech { pass p { VertexShader = compile vs_1_1 basicLight(); } } 改动的代码很简单、很少,算法是完全一样的,因为EmissiveCol+diffuse的和是一个 4D向量了,所以就不需要OUT.color.w=1的语句了,其余的地方只是将三处的float3改成了 float4。 14.3 镜面反射 14.3.1 一盏灯的镜面反射光照 上一小节实现了一个物体的简单光照渲染,但对于那些光滑的金属、塑料、玻璃和大 理石等,进行简单的光照渲染是无法达到效果的,因为这些物体的表面都是一些有着抛光或 者是耀眼的表现,使用镜面反射光照就能够达到这样的效果。打开例子程序14.3.1_1.cmo, 该程序用两种方式实现物体的镜面反射,一种是用常规渲染光线方式进行物体的镜面反射渲 染,另一种是编写Shader代码,“SpecularDiffuseShader”其代码如下: float4x4 Wvp : WORLDVIEWPROJECTION; //合矩阵 - 219 - float4x4 World : WORLD; //世界矩阵 float3 EyePos : EYEPOS; //观察者位置向量 float3 lightPosition; //一个与3D物体关联起来的位置向量 float4 lightColor; //光的颜色 float4 EmissiveCol; //物体自发光颜色 float4 DiffuseCol; //物体漫反射颜色 float4 SpecularCol; //物体镜面反射颜色 float specularPower = 0.5; //光泽因子 struct OutPut //输出结构 { float4 position:POSITION; //位置分量 float4 color:COLOR; //颜色分量 }; //入口函数 OutPut basicLight(float4 position:POSITION, float3 normal : NORMAL) { OutPut OUT=(OutPut) 0;; OUT.position=mul(position,Wvp); float3 P=position.xyz; float3 N=normalize(mul(normal ,World)); float3 L=normalize(lightPosition-P); float diffuseLight = max(dot(N,L),0); float4 diffuse=DiffuseCol*lightColor*diffuseLight; //以上是计算物体的常规光照部分,以下是计算物体镜面反射部分 float3 C = normalize(EyePos - P); //得到指向观察者的向量 float3 R = (reflect( C,N)); //计算镜面反射向量 float specularLight=pow(clamp(dot(L,-R),0,1) , specularPower); //计算镜面反射 float4 col = EmissiveCol+diffuse; //自发光和漫反射颜色结果 col += SpecularCol*lightColor*specularLight; //加上镜面反射颜色结果 OUT.color=col ; //最终的颜色结果 return OUT; } technique tech { pass p { VertexShader = compile vs_1_1 basicLight(); } } 回顾一下镜面反射的数学公式:   shininessRLfacinglightColorlSpecularCoSpecular 0,max  可以看出SpecularCol、lightcolor、facing、shininess这几项都是已知的数;L的求法漫反射小 - 220 - 节已经讲过,现在就差如何求出镜面反射向量R。使用Shader的内置函数reflect(C,N )能够方 便的求出这个向量R,该函数能够根据入射的方向向量C和表面法线向量N计算反射向量, 这个函数仅对三元向量有效。法线向量N我们已经会求了,C这个入射的方向向量其实在这 里指的是指向观察者的方向向量,只需要将观察者位置向量减去物体顶点位置向量即可,因 为涉及夹角的计算,别忘了也对这个向量C进行归一化处理。 对于观察者的位置,定义了全局变量,并使用了语义“: EYEPOS”,这样把摄像机的 位置和自动的关联了起来,方便了许多。但要注意其他的引擎也许并不支持这个语义。 float3 EyePos : EYEPOS; //观察者位置向量 计算出的向量R和我们实际想要的方向相反,所以负了一些再与向量L进行点积。点积 后的结果没有使用函数max( )而是使用了clamp(x,a,b),该函数的作用是把x的值限制在指定 的范围[a,b]内,这里是限制在[0,1]内。进行幂运算时候使用函数pow(x,y)即可。 镜面反射的颜色已经求出来了,但要记得把自发光、漫反射加上才是最终的效果,最 后用调用顶点着色器、编译就可以看到效果了,效果如图14.3.1_1所示。 图14.3.1_1 14.3.2 二盏灯的镜面反射光照 为了达到更好的效果,光打一盏灯是不够,那么我们就来打两盏灯。既然是两盏灯,就选 用不同位置、不同颜色的两盏灯,这样方便看到效果,这样就要新定义一个灯的位置向量 和灯的颜色向量。虽然打了两盏灯,但物体表面材质反射光的特性并没有改变,世界空间 也没发生改变,更不会多出一个观察者,所以不需要定义其他的变量。 //世界空间没有发生改变 float4x4 Wvp : WORLDVIEWPROJECTION; float4x4 World : WORLD; float3 EyePos : EYEPOS; //第一盏灯的位置和颜色 float3 lightPosition; float4 lightColor; //第二盏灯的位置和颜色 float3 lightPosition02; float4 lightColor02; //物体表面材质反射光的特性没有改变 float4 EmissiveCol; float4 DiffuseCol; float4 SpecularCol; float specularPower = 0.5; //输出结构 - 221 - struct OutPut { float4 position:POSITION; float4 color:COLOR; }; //入口函数 OutPut basicLight(float4 position:POSITION,float3 normal : NORMAL) { OutPut OUT=(OutPut) 0;; OUT.position=mul(position,Wvp); float3 P=position.xyz; float3 N=normalize(mul(normal ,World)); //计算指向两盏灯的向量 float3 L=normalize(lightPosition-P); float3 L02=normalize(lightPosition02-P); //计算指向两盏灯的各自漫反射夹角 float diffuseLight = max(dot(N,L),0); float diffuseLight02 = max(dot(N,L02),0); //两盏灯共同作用的漫反射结果 float4 diffuse=DiffuseCol*(lightColor*diffuseLight+lightColor02*diffuseLight02); float3 C = normalize(EyePos - P); float3 R = (reflect( C,N)); //计算指向两盏灯的各镜面反射夹角 float specularLight=pow(clamp(dot(L,-R),0,1) , specularPower); float specularLight02=pow(clamp(dot(L02,-R),0,1) , specularPower); //两盏灯共同作用的镜面反射结果 float4 specular=SpecularCol*(lightColor*specularLight+lightColor02*specularLight02); //两盏灯共同作用的最终的结果 float4 col = EmissiveCol+diffuse+specular; OUT.color=col ; return OUT; } technique tech { pass p { VertexShader = compile vs_1_1 basicLight(); } } 只要原理清晰,实现两盏灯的光照,修改代码起来很简单,按同样的道理可以进行多 盏灯的光照,以增强效果,但是随之而来的是系统的计算量会成倍的增加,导致运行效率低 下,这是一个要权衡的地方。该程序最终的运行效果如图14.3.2_1所示。 - 222 - 图14.3.2_1 - 223 - 第十五章 纹理贴图光照 上一章讲的只是光照,仅仅是光照还无法表示物件的细节,物件的细节还是要靠纹理 来表现,本章讲的就是带纹理的光照。带纹理的光照虽然只是简单的一句话,但却包含着 很丰富内容,本章将会学到以下内容:  简单的渲染一个带光照的物体  带凹凸感的物体渲染  把环境映射到物体上  在 2D 帧上渲染纹理 15.1 简单的纹理渲染 15.1.1 Virtools 纹理渲染 Shader 前面的Shader代码只是光照而已,3D模型仅是光照还不行,要表现3D模型的细节还需 要靠纹理来体现。Virtools使用Shader简单渲染一张纹理是很简单的事情,也正是简单,所以 无法实现的细节,达不到学习的目的,但还是要先说说,在以后的学习中好做个对比。我们 接着14.2.2小节的代码,添加两行就能完成一个带光照带纹理的渲染,代码如下所示: float4 EmissiveCol:EMISSIVE; //定义一个颜色4D向量与材质的自发光属性关联 float4 DiffuseCol:Diffuse; //定义一个颜色4D向量与材质的漫反射属性关联 float3 lightPos ; //定义一个位置向量并与一个3D物体关联起来 texture Tex: TEXTURE; //定义一个纹理并与材质本身的纹理关联起来 technique tech { pass p { MaterialEmissive=; //设置物体的自发光 MaterialDiffuse=; //设置物体的漫反射 LightEnable[0] = true; //启用灯光 LightAttenuation0[0] = 1; //设置灯光的衰减属性 LightDiffuse[0] = {1, 1, 1, 0}; //设置灯光的颜色 LightRange[0] = 5000; //设置灯光的最远作用距离 LightPosition[0] =; //设置灯光的位置 LightType[0] = POINT; //设置光的类型 Texture[0] = ; //设置渲染的纹理 } } 好快,就添加了两行就完成纹理的渲染。至于添加了哪两行,读者自己去看吧,例子请参照 “例子15.1.1_1.cmo”。下一节来说说HLSL是如何渲染纹理的。 15.1.2 HLSL 纹理渲染 纹理就是 3D 模型外表面的图案,纹理在被使用前存放在磁盘上那时叫做图片,图片 的格式有 bmp、jpg、tga、png、dds 等。一些美工人员会把纹理叫做贴图,还一些美工人 - 224 - 员他们说的纹理是包含材质、贴图和光照的结果。程序员有时候和他们交流纹理时会发生 误会,所以我干脆有时候说成纹理贴图,这样误会就消除了。 把一张纹理或多张纹理贴到一个物体上要遵守相应的规则,这个规则总的称为纹理映 射。在 Shader 中要完成一次纹理映射操作,首先需要定义一个纹理采样器,这个纹理采 样器包含了采样状态,采样状态指定了纹理将如何被采样,并控制采样过程中的纹理过滤方 式,其语法格式如下所示: texture Tex ; //纹理全局变量 sampler TexSampler = sampler_state //纹理采样器 { texture = ; //纹理采样器进行采样的纹理对象 Minfilter = LINEAR; //纹理在被缩小时进行线性过滤 Magfilter = LINEAR; //纹理在被放大时进行线性过滤 MipFilter = LINEAR; //mipmap进行线性采样 AddressU = Wrap; //U方向的纹理寻址模式采用重叠映射寻址模式 AddressV = Wrap; // V方向的纹理寻址模式采用重叠映射寻址模式 }; 什么是纹理的寻址模式、纹理过滤在第五章已经讲述过了,这里就不提了。纹理采样 器现在已经准备好了,剩下的工作就是如何使用这个采用器。tex2D(s,t)是个 2D 纹理映 射函数,它有两个参数,第一个就是刚才我们定义的纹理采样器。第二个参数是一个float2, 也就是纹理坐标 uv。它函数返回一个颜色值 float4,简单的说就是取纹理 s 中,纹理坐 标 t 处的颜色。下面来实现一个简单的纹理映射,代码如下所示: texture Tex ; //纹理全局变量 sampler TexSampler = sampler_state //纹理采样器 { texture = ; //纹理采样器进行采样的纹理对象 Minfilter = LINEAR; //纹理在被缩小时进行线性过滤 Magfilter = LINEAR; //纹理在被放大时进行线性过滤 MipFilter = LINEAR; //mipmap进行线性采样 }; float4 Texture_PS(float2 texCoord:TEXCOORD0):COLOR { float4 finalColor = tex2D(TexSampler, texCoord); return finalColor; } technique tech { pass p { PixelShader = compile ps_1_1 Texture_PS(); } } 看代码要看仔细,不知道读者是否发现这段代码使用的是像素着色器进行编译的,因 为2D采样函数,只能在像素着色器中才能得到支持。代码中的纹理采样器中缺少了 AddressU = Wrap和AddressV = Wrap这两行也是得到同样结果的,其实着色器中只要定义 - 225 - texture = 一句就行了,其他的系统会默认的采用我们所列举的采样方式,但是为了方 便可读性,还是加上较好。例子请参照“例子15.1.1_1.cmo”,其运行结果如图15.1.2_1(不 带光照的渲染)所示。 图15.1.2_1 15.1.3 带光照的纹理渲染 上一小节的Shader代码只是给物体渲染出了纹理,在前面一些的代码只是给物体进行 光照而已,两者要怎样整合到一块,这样的结果才会令人稍微满意些。是否记得我们进行光 照渲染的时候最终的颜色是把漫反射颜色加上自发光颜色的和,现在多了一个纹理采样的颜 色是不是也是同样相加呢?其实是相乘,虽然仅仅是相乘而已,但要整合两个效果代码的书 写就变了许多,其代码如下所示: float4x4 Wvp : WORLDVIEWPROJECTION; float4x4 World : WORLD; float3 lightPosition; //一个与3D物体关联起来的位置向量 float4 lightColor; //定义一个颜色4D向量表示灯光的颜色 float4 EmissiveCol; //定义一个颜色4D向量表示材质的自发光属性 float4 DiffuseCol; //定义一个颜色4D向量表示材质的漫发光属性 texture Tex ; //纹理全局变量 sampler TexSampler = sampler_state //纹理采样器 { texture = ; //纹理采样器进行采样的纹理对象 Minfilter = LINEAR; //纹理在被缩小时进行线性过滤 Magfilter = LINEAR; //纹理在被放大时进行线性过滤 MipFilter = LINEAR; //mipmap进行线性采样 }; struct VS_Input //输入结构 { float4 position:POSITION; //物体顶点位置向量 float3 normal : NORMAL; //物体顶点法线向量 float2 texCoord:TEXCOORD0; //物体顶点上的UV坐标 }; struct VS_OutPut //输出结构 { float4 position:POSITION; //物体顶点位置向量 - 226 - float2 texCoord:TEXCOORD0; //物体顶点上的UV坐标 float3 N:TEXCOORD1; //物体顶点法线变换到世界空间的结果 float3 L:TEXCOORD2; //指向光源的向量 }; VS_OutPut BasicTransform_VS(VS_Input In) //顶点渲染的入口函数 { VS_OutPut OUT; //定义输出结构对象 OUT.position=mul(In.position,Wvp); //将顶点转换到正确的位置上 float3 P=In.position.xyz; //得到3维的位置向量 OUT.N=normalize(mul(In.normal,World)); //把局部法线向量转换到世界空间 OUT.L=normalize(lightPosition-P); //得到指向光源的向量 OUT.texCoord=In.texCoord; //纹理贴图不加变换的直接输出 return OUT; //返回经过转换的结果 } float4 Texture_PS(VS_OutPut P):COLOR //像素渲染的入口函数 { float diffuseLight = max(dot(P.N,P.L),0); //漫反射光照系数 float4 diffuse=DiffuseCol*lightColor*diffuseLight; //求漫反射颜色值 //求最终的物体表面渲染结果 float4 finalColor =(EmissiveCol+diffuse)*tex2D(TexSampler, P.texCoord); return finalColor; //返回结果颜色向量 } technique tech { pass p { //使用2.0版本的顶点着色器和像素着色器 VertexShader = compile vs_2_0 BasicTransform_VS(); PixelShader = compile ps_2_0 Texture_PS(); } } 虽然只是把学习过的内容整合到一起,却花费了一番功夫、同时使用了顶点着色器和 像素着色器。读者请注意我们是先处理顶点转换,得到相应光照需要的向量,然后把结果作 为像素着色器参数,计算物体的表面颜色,这个顺序是不能反的。细心的读者会发现输出结 果有点蹊跷:“float4 position:POSITION”和“float2 texCoord:TEXCOORD0”分别表示 输出结果做为位置和纹理坐标信息送入GPU处理; 而“ float3 N:TEXCOORD1”和“ float3 L:TEXCOORD2”是要参与光照的向量,为什么却使用“TEXCOORD1”这个语义来定义 呢?其实这些每个输入、输出变量必要与一个语义关联起来(这种关联还涉及相应的专用的 寄存器),关联的目的是告诉CPU对这些数据按怎样的逻辑处理。这样的N、L只是一个中 间变量,是参与后面的像素着色器光照阶段计算,使用语义“TEXCOORD1”是让GPU把 这个结果存储起来(存储在相应的寄存器中)。 还有一个地方悄悄的发生了改变就是使用了2.0版本的顶点和像素渲染器“compile vs_2_0”、“ compile ps_2_0”,主要的原因是,1.1版本的渲染器的函数参数不支持以 结构体分量的方式传入如函数“dot(P.N,P.L)”。 例子请参照“例子15.1.1_1.cmo” - 227 - 15.1.4 逐像素的纹理渲染 我们已经学习了一个完整的 3D 物体光照 Shader 了,有些读者也许会迫不及待的将所 学用到实际当中,但却发现有些 3D 物体使用了光照 Shader 后能够表现出预期的效果,有 些则达不到效果。其中的原因请看图 15.1.4_1。 图 15.1.4_1 原来光照是对物体的每个顶点进行光照,每个三角形面只计算其三个顶点的光照、然 后为每个三角形内的区域根据三个顶点的光照值进行光滑的颜色差值,这样的做法,当这 个三角形面范围太大,结果就会很糟糕。如图 15.1.4_1 中的左边的茶壶和中间的多棱物体, 一个面多、一个面少都进行了镜面发射光照,结果却大相径庭。 要想使得面少的物体得到更好的光照效果,只有逐像素的进行光照计算,其效果如图 15.1.4_1 右边的多棱物体所示。下面就是实现逐像素光照的 Shader 代码。其实代码的内容 和使用顶点光照的内容很相似,算法是完全相同的,所以就懒得在代码中做注释了。 float4x4 world : World; float4x4 wvp : WorldViewProjection; float3 eyePos : EyePos; float3 LightPos ; float4 LightCol ; float4 dif : DIFFUSE; float4 emi : EMISSIVE; float4 spe : SPECULAR; float specularPower = 0.5; texture Tex ; sampler2D TexSampler = sampler_state { texture = ; Minfilter = LINEAR; Magfilter = LINEAR; MipFilter = LINEAR; }; struct OUTPUT_VS - 228 - { float4 Position : POSITION; float2 texCoord:TEXCOORD0; float3 WorldNormal : TEXCOORD1; float3 WorldPosition : TEXCOORD2; }; struct INPUT_PS { float2 texCoord:TEXCOORD0; float3 WorldNormal : TEXCOORD1; float3 WorldPosition : TEXCOORD2; }; OUTPUT_VS PerPixelTransformVS(float3 position : POSITION, float3 normal : NORMAL,float2 texCoord:TEXCOORD0) { OUTPUT_VS output; output.Position = mul(float4(position, 1.0), wvp ); output.WorldNormal = mul(normal, world ); output.WorldPosition =mul(float4(position, 1.0), world ); output.texCoord=texCoord; return output; } float4 PelPixelTexturePS(INPUT_PS input) : COLOR { float3 directionToLight = normalize(LightPos - input.WorldPosition); float diffuseIntensity = saturate( dot(directionToLight, input.WorldNormal)); float4 diffuse = LightCol * diffuseIntensity; float3 reflectionVector = normalize(reflect(-directionToLight, input.WorldNormal)); float3 directionToCamera = normalize(eyePos - input.WorldPosition); float4 SpecularCol=pow(saturate(dot(reflectionVector, directionToCamera)), specularPower); float4 color =(emi + diffuse*dif+spe*SpecularCol)*tex2D(TexSampler, input.texCoord) ; color.a = 1.0; return color; } technique tech { pass p { VertexShader = compile vs_2_0 PerPixelTransformVS(); PixelShader = compile ps_2_0 PelPixelTexturePS(); } - 229 - } 在这段 Shader 代码中、顶点渲染程序只是做了一些顶点左边变换、然后剩下的工作都 交给像素渲染程序来完成,这也表示像素渲染也能完成很多在顶点渲染阶段的工作。但是 逐像素渲染的计算量,要比顶点渲染的计算量要大得多,太多物体都使用逐像素渲染的话 会降低运行效率。 有些读者就会有这样的疑问,同一个算法放到顶点渲染器中就叫逐顶点光照、放到像 素渲染器中就叫逐像素渲染,好像没什么说服力。其实在像素渲染器中对 3D 物体的表面 法向量进行了差值、然后使用这个被差值后的表面法向量进行每个像素的光照计算,而不 是差值由顶点渲染所计算出来后的光照颜色。从代码中频繁使用向量归一化函数对个分量 进行归一化处理,就可以看出来一个纹理坐标集的线性插值使得每个像素的表面法向量不 再规范化,所以必须重新进行规范化处理。简单一点的说,虽然算法一样,但是到了顶点 渲染器后执行的方式与到了像素渲染器后执行的方式不同,再简单一点说是差值方式不同。 例子请参照“例子 15.1.4_1.cmo”。上述代码中还出现了一个新函数 saturate(x)其功能是如 果 x 的值在 0 到 1 直接,就返回 x,如果 x 小于 0 就返回 0 ,如果 x 大于 1 则返回 1。 15.1.5 两层纹理渲染 在VSL中讲过多层纹理混合的内容,在Shader中同样也可以实现多层纹理混合。简单 的3D物体纹理渲染,需要一个两个采样器,并使用一次tex2D( )函数,如果是两层纹理渲染 相比就要使用两个纹理采样器、使用调用二次tex2D( )函数。请打开“例子15.1.5_1.cmo”, 实现两层纹理混合的Shader代码如下所示: float4x4 wvp : WorldViewProjection; Texture Texture1; //纹理全局变量1 Texture Texture2; //纹理全局变量2 float timer:Time; //运行时系统时间 sampler TextureSampler1 = sampler_state //纹理采样器1 { texture = ; //纹理采样器进行采样的纹理对象1 MipFilter = LINEAR; MinFilter = LINEAR; MagFilter = LINEAR; }; sampler TextureSampler2 = sampler_state //纹理采样器2 { texture = ; //纹理采样器进行采样的纹理对象2 MipFilter = LINEAR; MinFilter = LINEAR; MagFilter = LINEAR; }; struct IN_VS //顶点渲染输入结构体 { float4 Position:POSITION; float2 TexCoord:TEXCOORD0; //只输入一套纹理UV坐标 }; struct OUT_VS //顶点渲染输出结构体 - 230 - { float4 Position:POSITION; float2 TexCoord:TEXCOORD0; //输出一套UV纹理坐标 float2 TexCoord02:TEXCOORD1; //输出另一套UV纹理坐标 }; //顶点渲染入口函数 OUT_VS TransformVS(IN_VS IN) { OUT_VS Out; Out.Position = mul(IN.Position,wvp); Out.TexCoord = IN.TexCoord; //输出的第一套UV纹理坐标与输入的相同 float offset = sin(timer)+1; //第二套UV纹理坐标增加了一个变量值, Out.TexCoord02.x = IN.TexCoord.x+offset; //以显示其中的变化 Out.TexCoord02.y = IN.TexCoord.y; //只是对U分量进行变化,V分量不变 return Out; } //像素顶点渲染入口函数 float4 TextureColorPS(OUT_VS P):COLOR { float4 diffuseColor1 = tex2D(TextureSampler1, P.TexCoord); float4 diffuseColor2 = tex2D(TextureSampler2,P.TexCoord02); float4 diffuseColor=lerp(diffuseColor1,diffuseColor2,0.6f); //两个纹理采样结果的混合 return diffuseColor; } technique tech { pass p { VertexShader = compile vs_2_0 TransformVS(); PixelShader = compile ps_2_0 TextureColorPS(); } } 代码中的两个纹理采样器的采样方式是一样的,只是采样的对象不同,所以就定义了 两个采样器。两个纹理所使用的是同一套UV纹理坐标,从输入结构体的纹理UV分量可以看 出来,为了观察其中的变化结果,输出结构对象中的第二套UV纹理坐标做了一个正弦函数 的变化。变化的规律依据时间轴来进行,所以定义了一个全局变量“float timer:Time”,其 中“:Time”这个语义表示:全局变量的值与程序运行时的系统时间进行管理,不需要额外 的对该变量进行赋值,注意变量的值只有在程序运行时才会发生改变。最后,在对两个纹理 对象进行采样后,得到的两个颜色值通过函数lerp(a,b,f)进行混合,当然也可按自己的意 愿用自己的算法进行混合。这里进行的线性插值的混合。函数lerp(a,b,f)差值是算法是: (1-f)*a+b*f。参数a和b是相匹配的向量或标量类型,更明白一点说是参数要有相同的维数, 参数f可以是一个标量或者与a、b类型相同的向量。最终的运行结果如下图所示: - 231 - 图15.1.5_1 15.2 凹凸纹理映射 Bump mapping 15.2.1 凹凸纹理映射的由来 前面章节使用的茶壶和多棱物体这两个3D物体的表面都是光滑的类型,表现它们的纹 理细节,只需要一张贴图就足够了,如果要想更细致些,就使用更大图(比如1024×1024)。 但是实际情况往往会碰到犹如一堵红砖砌的墙壁,一条卵石铺的路等这样3D物体,它们就 没有光滑的表面了,而是粗糙、凹凸不平的表面。这种粗糙、凹凸不平的3D物体如果使用 几何形状来表示,这对美工人员以及甚至对引擎本身都是一个巨大的考验;另外3D物体表 面的特性渲染到屏幕上也许比一个像素还要小,这就意味着在光栅化阶段,无法进行精确的 渲染该细节。这种得不偿失的事情,就放弃吧! 我们虽然放弃了使用几何形状来表示凹凸不平表面性质的方法,但没有放弃表现这种 表面性质的欲望。后来由James F. Blinn这个世界图形学先驱者在1978年发明了凸凹纹理映射 技术,使用法向扰动技术模拟带褶皱的曲面。再后来基于同样的思想改良该技术,使得能够 进 行 实 时 的 渲 染 比 如 Normal Mapping, Parallax Mapping,Parallax Occulision Mapping,Relief Mapping等等,均是凸凹纹理映射技术,只是它们考虑得越来越全面,产 生的效果越来越好。 15.2.2 凹凸纹理映射的原理 “所谓凹凸纹理映射就是把由一个纹理提供的物体表面法向量的扰动每个片段的光照 相结合,来模拟光照与凹凸表面的相互作用”(引用来源于《Cg 教程》),简单的说就是让 平板的面看起来有凹凸粗糙的感觉。现实世界中,凹凸不平的物体表面,其反射光线的法线 方向也就各不相同,光照射到这些点上时,物体表面各个位置光照产生效果就不一样,使得 最终呈现在眼前的是凹凸不平的表面。现在反过来假象一些:如果几何体表面是平的,但是 平面上各个点的法线方向却各不相同,当用光照模型进行光照计算后,呈现出来的效是不是 会看到高低不平的凹凸效果感觉呢?答案是肯定的,可见凹凸纹理映射关键的地方是物体法 线方向的表示。 - 232 - 凹凸纹理映射有很多种,“Normal mapping”是当前最通用的实时性能较好的一种,它 输入的纹理是一个法线贴图(normal map),在其不同的颜色通道中记录了扰动的表面法向 量。正是由于牵涉到对物体表面的每处法向量的计算,而3D物体只有在顶点处才有顶点的 法向量,一个三角形面包含的三个顶点的法向量,无法表示出三角形面上的法线变化多端的 情况,所以必须从法线贴图中取出相对应的法线向量进行逐像素的光照渲染、而不是顶点光 照渲染。 有些读者很奇怪,一张纹理贴图存储的是一个RGB或者RGBA,每个颜色红、绿、蓝 和alpha通道值,都是一个无符号的标量并且颜色值的取值范围被限制在0~1之内。而物体表 面的法向量是有方向的,是个矢量,规范化的向量其值在-1~1之内。两者如何整到一块的呢? 是用数学方法把[-1,1]范围内的有符号的法线向量进行缩放和偏移,把范围压缩至[0,1]之内, 就可以存储到纹理中。当到了渲染器内部,取出该颜色值,然后进行解压缩算法,还原回原 来的法线向量后再进行光照计算,这个解压缩的过程在性能上没有损失。 法向量的压缩算法如下所示: colorComponent=0.5*normalComponent+0.5; 解压缩算法如下所示: normalComponent=2*(colorComponent-0.5); 其中normalComponent表示物体的表面法向量、colorComponent表示法向量被压缩后存储在 纹理贴图上的颜色值。 如何存储法线到贴图中现在已经明白了,但是这个表面法线是如何制作的呢?大部分 的法向量都是从高度域(Height Field)中得到。先是得到一张表示物体表面凹凸不平的高度 图,然后再将该高度图经过相应的数学压缩和转换算法到最终我们需要的法线贴图。其实这 些过程不需要我们程序员自己来实现,主流的3D模型建模软件或图形处理软件可以方便的 导出法线贴图和高度图,所以不了解算法,对使用凹凸纹理映射没有影响,这里只是顺带提 一下而已。 15.2.3 凹凸纹理映射的实现 为了便于观察结果,我们只选用只有两个面的的3D模型来验证凹凸纹理映射的神奇。 打开“例子15.2.3_1.cmo”,其中在shader编辑器中名为“OriginalShader”的Shader是本列所 使用的代码,如下所示: float4x4 Wvp : WORLDVIEWPROJECTION; //合矩阵 float4x4 World : WORLD; //世界矩阵 float3 EyePos : EYEPOS; //观察者位置向量 float3 lightPosition; //一个与3D物体关联起来的位置向量 float4 lightColor; //光的颜色 float4 EmissiveCol; //物体自发光颜色 float4 DiffuseCol; //物体漫反射颜色 float4 SpecularCol; //物体镜面反射颜色 float specularPower = 0.5; //光泽因子 bool SpecularAble = true; //是否开启镜面反射 float3 normalMapScale = {-1,-1,1}; //一个修正方向的向量 texture Tex ; //3D物体的表面纹理贴图 sampler2D TexSampler = sampler_state //纹理的采样方式 - 233 - { texture = ; Minfilter = LINEAR; Magfilter = LINEAR; MipFilter = LINEAR; AddressU = Wrap; AddressV = Wrap; }; texture normalMap; //3D物体的表面的法线贴图 sampler bumpSampler = sampler_state //纹理的采样方式 { texture = ; MipFilter = LINEAR; Minfilter = LINEAR; Magfilter = LINEAR; }; //输入结构 struct VS_Input { float4 position:POSITION; //物体顶点位置向量 float3 normal : NORMAL; //物体顶点法线向量 float2 texCoord:TEXCOORD0; //UV坐标 float3 Tangent : TEXCOORD1; //切向量 float3 Binorm : TEXCOORD2; //副法线向量 }; //输出结构 struct VS_OutPut { float4 position:POSITION; //物体顶点位置向量 float2 texCoord:TEXCOORD0; //UV坐标 float3 N:TEXCOORD1; //物体顶点法线向量 float3 L:TEXCOORD2; //指向光源向量 float3 C:TEXCOORD3; //指向观察者向量 float3 T:TEXCOORD4; //切向量 float3 B:TEXCOORD5; //副法线向量 }; //顶点渲染阶段入口函数 VS_OutPut BasicTransform_VS(VS_Input In) { VS_OutPut OUT; float4 newPostion=In.position; OUT.position=mul(newPostion,Wvp); //得到顶点坐标与合矩阵的乘积 float4 wPos=mul(newPostion,World); //把局部的顶点坐标转换到世界坐标系中 float3 P=wPos.xyz; //得到世界坐标系中顶点的位置向量 - 234 - OUT.N=normalize(mul(In.normal,World));//得到世界坐标系中顶点的方向向量 OUT.L=normalize(lightPosition-P); //得到指向光源的向量 OUT.C=normalize(EyePos - P); //得到观察者向量 OUT.T=In.Tangent; //得到顶点的切线 OUT.B=In.Binorm; //得到顶点的副法线 OUT.texCoord=In.texCoord; //得到UV坐标 return OUT; } //像素渲染阶段入口函数 float4 Texture_PS(VS_OutPut P):COLOR { float3 bumpNorm = tex2D( bumpSampler, P.texCoord ); //对法线贴图进行采样 float3 normalComponent=2*(bumpNorm -0.5); //对采样的结果进行解压 bumpNorm = normalComponent*normalMapScale; //对解压的结果进行一个修正 bumpNorm =mul(bumpNorm , float3x3(P.T,P.B,P.N)); //把向量转换到切线空间 float4 finalCol =tex2D(TexSampler, P.texCoord); //对纹理贴图进行采样 float diffuseLight = max(dot(bumpNorm ,P.L),0); //根据法线、计算光照 float4 diffuse=DiffuseCol*lightColor*diffuseLight+EmissiveCol; if(SpecularAble ) //计算镜面发射光照 { float3 C =normalize (reflect( P.C,bumpNorm )); float specularLight=pow(clamp(dot(P.L,-C),0,1) , specularPower); finalCol += SpecularCol*lightColor*specularLight; } return finalCol; } technique tech { pass p { VertexShader = compile vs_2_0 BasicTransform_VS(); PixelShader = compile ps_2_0 Texture_PS(); } } 凹凸纹理映射离不开光照的计算、要体现表面细节也离不开对纹理的采样,所以读者 会发现该代码中、有相当一部分是前面学过的知识,现在我们要在原来的代码上做出更好的 效果。代码中多定义了一个纹理变量“texture normalMap”,这个就是要使用的法线贴图; 在输入、输出结构中定义了切向量“ float3 Tangent : TEXCOORD1”和副法线“float3 Binorm : TEXCOORD2”。前面介绍过的顶点法线向量这个好理解,现在又冒出切向量和副 法线理解上就有些复杂。 我们知道一个3D物体在建模阶段,是以自身中心位置作参照,这个参照称为局部坐标 系或叫局部空间,它所要表示的是这个3D物体的每个顶点的位置和方向关系;当把这个3D 物体和和其他3D物体放到场景世界中,是以(0,0,0)点作参照的点,这个参照称为世界 坐标系或叫世界空间(WORLD),它所要表示的是世界万物的位置和方向;当我们用眼睛 - 235 - (摄像机)去观察这个世界时,无法一眼把世界都囊括在视觉范围内,所能看到的区域是在 以眼睛为起点,向着视觉的方向的一片区域而已,以眼睛做参照称为观察坐标系或观察者空 间(EYEPOS)。在凹凸纹理映射中我们将要在学习一个坐标系——切线坐标系或叫切线空 间。存储在纹理贴图上的法线向量的值就是在切线空间内,它与切向量和副法线向量共同表 达这个切线空间。当我们根据高度图生成的三伟坐标系,就是所谓的切线空间坐标系,这个 坐标系就是这个法线贴图。 有一点要注意的,顶点上的法线,顶点的法线方向以物体的局部坐标做参照;而存储 于贴图中的法线是属于像素的,每个像素存储的是一个法线向量,向量的原点就在该像素、 而不是这个贴图的中心,也就是说每个像素独有一个切线空间,互不干扰,这个法线向量既 然与切向量和副法线向量共同表达这个切线空间,自然它们之间是相互垂直的。其中法线方 向垂直于纹理平面,切向量和副法线向量在纹理平面内。 为什么要弄出个切线空间,增加读者的理解难度,是不是在炫耀高深、有没有简便方 法?请相信每一个发明的初衷都是朴实无华的!同一个3D物体在世界空间中存放在不同的 位置、面朝不同的方向,当我们在对它进行光照时候,光照的结果也不同。从法向量贴图中 取出的向量只有一个方向,如果不管这个3D物体在世界空间中的位置、方向都使用这取出 的向量进行光照计算,结果可想而知,因为两者都不在一个坐标系内,计算出来的东西能正 确才怪。现在问题很明显,就是要统一坐标系,要么是切线坐标系的法线向量转到世界坐标 系中;要么就把涉及光照的在时间坐标系中的相关向量转到切线空间。本例子程序使用的是 后一种方法。 由于通过3D模型建模软件或图形处理软件生成的法线向量纹理贴图,坐标系的方向可 能不一样,作用代码中声明了一个向量float3 normalMapScale = {-1,-1,1},用于修正结果。 该代码运行效果如图15.2.3_1所示: 图15.2.3_1 - 236 - 图15.2.3_2(凹凸纹理映射使用到的表面物体贴图和法线贴图) 15.2.4 改良的凹凸纹理映射 上一小节我们讲述的凹凸纹理映射方法,是本书目前为止出现的最复杂的一个Shader 代码,但是它只是一个过时的方法,图形显示卡发展到今天,已经有更好的技术来表现物体 表面的凹凸细节。是不是很悲伤?花了那么大的功夫学习,居然是个过时的东西。在学习新 的实现方法之前,先来看看旧方法为何会被淘汰。 旧方法通常称作法线贴图(Normal Mapping),它有一个缺点就是它只能表示那些凹 凸程度不大的表面效果,对于凹凸程度很大的表面,法线贴图所呈现的效果就不行了,其原 因是因为它的实现没有考虑到视线的方向性。在现实生活中,一辆小汽车停在一辆大卡车的 后面,我们在观察大卡车正面的时候,无法看到被挡在后面的小汽车的,而反过来站在小汽 车正面进行观察时,同时可以看到小汽车和后面的大卡车。正确凹凸纹理映射也是一样的道 理,在这个观察者方向上,前面的低点和后面高点,此时可以同时看到,如图15.2.4_1所示, 但观察者方向转到对面时,此时前面的A点会挡住后面的B点。法线贴图的方法无法做到这 些功能的。 图15.2.4_1 视差贴图技术(Parallax Mapping或是一种在法线贴图技术上的改良型,这种技术会对 纹理的坐标做变换,一些凸出的纹理会遮蔽到其他的纹理,它能够获得更好的“高度”的视觉 效果。视差贴图技术要对纹理坐标做变换,必须要有一定的依据,它除了了使用一张法线纹 理贴图外,还要使用一张物体表面的高度图,如图15.2.4_3所示。 - 237 - 法线贴图 高度图 物体表面贴图 图15.2.4_3 上图与图15.2.3_2比较多了中间那张高度图,就凭这个种高度图来区别图15.2.4_1上的A、 B两点相互之间的遮蔽光线。简单一点说就是在渲染时,把B点的像素跳过去,之所以要跳 过B点,是因为它被A点遮住了。为了跳过B点,按照前辈们的经验应该试着挪动纹理坐标, 把被遮住的B点跳过去。也就是说根据高度图(中间灰色的图)提供的数据,把那个位置较 低那个纹理的后面的纹理向前拉,如图15.2.4_2所示。相当于在对贴图采样的时候刻意的把 那个像素跳过去。这样那个不该被看见B点就会消失而不见了。 图15.2.4_2 这个算法是不科学的,虽然计算的时候已经把视线的角度考虑进行。但仍然是一种来 自于经验的估算,但其效果还是令人欣慰的,如图15.2.4_4所示,砖块的凹凸感觉非常好了, 前面的砖块已经对后面的砖块进行了局部的遮蔽。不过视差贴图技术的实现原理,其实和法 线贴图是一样的,都是根据法线贴图进行的处理,所以我们可以把它当作是法线贴图的一种 改良版的技术罢了。因为没有模型的改变,当观察者视角和平面的夹角接近于平行的时候, 无论是法线贴图,还是视差贴图,凹凸的感觉都会荡然无存。 技术是日新月异的,还有更好的实现凹凸纹理映射的技术比如:视差遮蔽贴图技术 (Parallax Occlusion Mapping)和位移贴图技术(Displacement mapping),它们实现的效 果更加精致,同时对图形显示卡的要求也就越高,本人所使用的nv5200显卡已经老得时空 错位了,完全不支持这些新技术了,这些技术也复杂得多,有兴趣的读者可以自行研究,打 开“例子15.2.3_1.cmo”,其中在shader编辑器中名为“ImproveShader”的Shader是改良版的 凹凸纹理映射的Shader代码,如下所示: - 238 - 图15.2.4_4 float4x4 Wvp : WORLDVIEWPROJECTION; //合矩阵 float4x4 World : WORLD; //世界矩阵 float3 EyePos : EYEPOS; //观察者位置向量 float3 lightPosition; //一个与3D物体关联起来的位置向量 float4 lightColor; //光的颜色 float4 EmissiveCol; //物体自发光颜色 float4 DiffuseCol; //物体漫反射颜色 float4 SpecularCol; //物体镜面反射颜色 float specularPower = 0.5; //光泽因子 bool SpecularAble = true; //是否开启镜面反射 bool BurningAble = true; //是否开启增强光照 bool useParallax = true; //是否开启视差贴图技术 float burnFactor = 1.17; //增强光照系数 float parallaxAmplitude = 0.05; //视差高度系数 float parallaxOffset = 0.8; //视差偏移系数 float3 normalMapScale = {-1,-1,1}; //一个修正方向的向量 texture Tex ; //3D物体的表面纹理贴图 sampler2D TexSampler = sampler_state //纹理的采样方式 { texture = ; Minfilter = LINEAR; Magfilter = LINEAR; MipFilter = LINEAR; AddressU = Wrap; AddressV = Wrap; }; texture normalMap; //3D物体的表面的法线贴图 sampler bumpSampler = sampler_state //纹理的采样方式 { texture = ; - 239 - MipFilter = LINEAR; Minfilter = LINEAR; Magfilter = LINEAR; }; //输入结构 struct VS_Input { float4 position:POSITION; //物体顶点位置向量 float3 normal : NORMAL; //物体顶点法线向量 float2 texCoord:TEXCOORD0; //UV坐标 float3 Tangent : TEXCOORD1; //切向量 float3 Binorm : TEXCOORD2; //副法线向量 }; //输出结构 struct VS_OutPut { float4 position:POSITION; //物体顶点位置向量 float2 texCoord:TEXCOORD0; //UV坐标 float3 N:TEXCOORD1; //物体顶点法线向量 float3 L:TEXCOORD2; //指向光源向量 float3 C:TEXCOORD3; //指向观察者向量 float3 T:TEXCOORD4; //切向量 float3 B:TEXCOORD5; //副法线向量 }; //顶点渲染阶段入口函数 VS_OutPut BasicTransform_VS(VS_Input In) { VS_OutPut OUT; float4 newPostion=In.position; OUT.position=mul(newPostion,Wvp); //得到顶点坐标与合矩阵的乘积 float4 wPos=mul(newPostion,World); //把局部的顶点坐标转换到世界坐标系中 float3 P=wPos.xyz; //得到世界坐标系中顶点的位置向量 OUT.N=normalize(mul(In.normal,World));//得到世界坐标系中顶点的方向向量 OUT.L=normalize(lightPosition-P); //得到指向光源的向量 OUT.C=normalize(EyePos - P); //得到观察者向量 OUT.T=In.Tangent; //得到顶点的切线 OUT.B=In.Binorm; //得到顶点的副法线 OUT.texCoord=In.texCoord; //得到UV坐标 return OUT; } //像素渲染阶段入口函数 float4 Texture_PS(VS_OutPut P):COLOR { float2 uv = P.texCoord; //得到uv纹理坐标 - 240 - if( useParallax ) //是否开启视差贴图技术 { //把切线和副法线转换到观察者方向 float2 camDir = mul( float2x3(P.T,P.B), P.C ); float depth = tex2D( bumpSampler, uv ).a-parallaxOffset; //采样高度 uv -= depth*parallaxAmplitude*camDir; //对uv进行偏移 } float3 bumpNorm = tex2D( bumpSampler,uv); //用偏移后uv值对法线贴图进行采样 float3 normalComponent=2*(bumpNorm -0.5); //对采样的结果进行解压 bumpNorm = normalComponent*normalMapScale; //对解压的结果进行一个修正 bumpNorm =mul(bumpNorm , float3x3(P.T,P.B,P.N)); //把向量转换到切线空间 float4 finalCol =tex2D(TexSampler, uv ); //偏移后uv值对纹理贴图进行采样 float diffuseLight = max(dot(bumpNorm ,P.L),0); //根据法线、计算光照 float4 diffuse=DiffuseCol*lightColor*diffuseLight+EmissiveCol; if( BurningAble ) //增强光照系数 { diffuse*= burnFactor ; } else { diffuse= saturate( diffuse ); } finalCol*=diffuse; if(SpecularAble ) //计算镜面发射光照 { float3 C =normalize (reflect( P.C,bumpNorm )); float specularLight=pow(clamp(dot(P.L,-C),0,1) , specularPower); finalCol += SpecularCol*lightColor*specularLight; } return finalCol; } technique tech { pass p { VertexShader = compile vs_2_0 BasicTransform_VS(); PixelShader = compile ps_2_0 Texture_PS(); } } 上述的码中:“float depth = tex2D( bumpSampler, uv ).a-parallaxOffset;”是获取贴图 的alpha通道,这个是高度值,然后减去一个偏移量parallaxOffset,这个偏移量是 可调节 的,最后根据结果把uv纹理坐标值进行了修改。为了增强光照效果,在计算完漫反射值后 又会乘以一个可以调节的系数。存在可调节的系数,表明在世界应用这种技术时,要根据具 体的情况调节参数,而不是简单套用。剩下的代码就和法线贴图的算法一样了。 - 241 - 15.3 环境映射 15.3.1 环境映射的原理和立方贴图纹理 每年一些发达的城市都有车展,车展上性感十足的车模旁边往往停靠着一辆流光溢彩 的靓车。如果凑近些仔细观察、注意是观察车、而不是车模,你会发现:光滑的汽车表面映 出了周围的环境,也包括你的面容。在图形学中实现这种效果的技术叫——环境映射 (Environment Mapping)。实现环境映射的技术有多种形式:有基于反射的环境映射;有基 于折射的环境映射;有基于颜色色散效果的环境映射等等。无论怎样的环境映射技术都离不 对立方体环境贴图的采样。 在数学中我们知道一个立方体有六个面,每个面的面积相等,如果这六个面贴上6副画, 在图形学中就叫做立法贴图纹理。一张普通的纹理贴图是2D的,从左上角(0,0)开始到 右下角(1,1)的纹理UV坐标;而贴在立方体各个面的这组2D纹理,程序时如何对其进行 采样的呢?以该立方体的中心为起点,向四面八方做射线,与该立方体的交点就是采样到的 颜色。 如果这个立方体的6个面都是镜子,那它周围世界将会映射在这6块镜子中,那么立方 体的这组纹理就叫做环境贴图纹理,说得专业一点就是能够表达出周围的全景环境的立方体 贴图叫环境贴图。环境贴图分两种:一种是静态的环境贴图,就是预先把全景环境生成好6 张纹理贴图,在运行中这个环境贴图时不会变化的;另一种是动态的环境贴图,就是在实时 运行的情况下,将实时变换的全景环境更新到这6张纹理贴图上,这种做法更真实些,但所 消耗的资源也就多一些。图15.3.1_1所示就是一个环境贴图及其分解出的6张2D纹理。 图15.3.1_1 - 242 - 15.3.2 Virtools 中如何生成立方体贴图 前面的小节说过立方体贴图分两种一种是静态的、一种是动态的。静态的立方体贴图 可以通过三维建模软件等制作生成,但是美工为项目生成的是一组 6 张的 2D 贴图,还要 到 Virtools 中进行一下加工。首先在 Virtools 中新建一张纹理贴图、然后打开该纹理的属性 设置页,在黑乎乎的默认图片上单击右键并选择“Replace slot”、在弹出的窗口中在指定的 的 6 张贴图中选择表示前面的图片,如图 15.3.3_1 所示。然后在右键选择“Add new slot”、 在弹出的窗口中在指定的 6 张贴图中选择表示右边的图片。接着依次类推到“后边”、“左 边”、“上边”、“下边”去全部弄好。最后在 Mip Levels 这个属性选项中、选择“Cube”后, 就完成了一张静态的立方体贴图的生成,结果如图 15.3.3_2 所示。 图 15.3.2_1 图 15.3.2_2 动态的立方体生成就需要脚本配合才能完成,虽然是实时的渲染,但是方法却很简单。 首先在 Virtools 中新建一张纹理贴图、然后打开该纹理的属性设置页,直接在 Mip Levels 这个属性选项中、选择“Cube”。然后新建立一个 Virtools 脚本并使用“Render Scene in RT View”这个 BB,填写好相关参数,其中要注意这个 BB 的参数“(CubeMap) Render Face by Face”项要设置为 true。最后该 BB 循环运行,当 Virtools 运行时,实时渲染的全景环境 - 243 - 就会生成在立体图贴图中。详情参照例子程序 15.3.3 反射环境映射 一组平行光从远处射到物体的表面(入射光线)与物体的表面法线 N 形成一个夹角“i”, 当到达物体表面的光被以同样的角度反射出去(反射光线),就像镜子反射光线一般,这就 是镜面如图 15.3.3_1 所示。反射环境映射除了立方体纹理采样以外、关键的算法就是求 出这个反射向量,求反射向量的函数在前面的光照 shader 代码中已经出现,是 reflect( ) 函数。 使用环境映射有个假设:映射出周围环境的物体与四周的环境是相隔无限远的、不会 因为物体位置的变化而影响映射的结果。其实,如果一个表面是曲面,即使四周环境离物 体很近,视觉上已经足够精细;但如果表面是平面的、那效果完全不能让人接受;所以实 际的开发应用中都是用曲面来进行环境映射,并且凸的曲面比凹的曲面效果更好些。另外 两个进行了环境映射的物体之间不能相互进行映射,也不能映射自身。 图 15.3.3_1 图 15.3.3_2 打开例子程序15.3.3_1.cmo,我们来反射环境映射的shader实现,程序中使用的静态的 环境映射方式,立方体贴图是名为“CubeMap_Tex”的纹理,用来水壶模型来进行环境映射, 因为它的曲面性很好。同时也使用了名为“Sky Around(CubeMap)”的 BB实现天空盒的渲染, 这样有利用进行对比观察。在进行反射环境映射的同时也将混合渲染茶壶自身的贴图。 float4x4 Wvp : WORLDVIEWPROJECTION; //合矩阵 float4x4 World : WORLD; //世界矩阵 float3 EyePos : EYEPOS; //观察者位置向量 float3 lightPosition; //与光源位置关联起来的位置向量 float4 lightColor; //光的颜色 float4 EmissiveCol; //物体自发光颜色 float4 DiffuseCol; //物体漫反射颜色 float4 SpecularCol; //物体镜面反射颜色 bool EnableSpec=false; //是否开启镜面反射 bool EnableCubeMap=false; //是否开启环境映射 float shininess = 0.8; //两贴图纹理相互混合系数 float specularPower = 0.8; //镜面反射强调系数 texture Tex ; //3D物体的表面纹理贴图 sampler2D TexSampler = sampler_state //纹理的采样方式 { texture = ; - 244 - Minfilter = LINEAR; Magfilter = LINEAR; MipFilter = LINEAR; AddressU = Wrap; AddressV = Wrap; }; texture2D CubeMap; //立方体贴图 sampler CubeMapSampler= sampler_state //立方体贴图的采样方式 { Texture = ; MinFilter = LINEAR; MagFilter = LINEAR; MipFilter = LINEAR; }; //输入结构 struct VS_Input { float4 position:POSITION; //物体顶点位置向量 float3 normal : NORMAL; //物体顶点法线向量 float2 texCoord:TEXCOORD0; //UV纹理坐标 }; //输出结构 struct VS_OutPut { float4 position:POSITION; //物体顶点位置向量 float2 texCoord:TEXCOORD0; //UV纹理坐标 float3 N:TEXCOORD1; //物体顶点法线向量 float3 L:TEXCOORD2; //指向光源向量 float3 C:TEXCOORD3; //指向观察者向量 }; //顶点渲染阶段入口函数 VS_OutPut BasicTransform_VS(VS_Input In) { VS_OutPut OUT; OUT.position=mul(In.position,Wvp); //得到顶点坐标转换到裁剪坐标系 float4 wPos=mul(In.position,World); //把局部的顶点坐标转换到世界坐标系中 float3 P=wPos.xyz; //得到世界坐标系中顶点的位置向量 OUT.N=normalize(mul(In.normal,World)); //得到世界坐标系中顶点的方向向量 OUT.L=normalize(lightPosition-P); //得到指向光源的向量 OUT.C = normalize(EyePos - P); //得到观察者向量 OUT.texCoord=In.texCoord; //得到UV坐标 return OUT; } //像素渲染阶段入口函数 - 245 - float4 Texture_PS(VS_OutPut P):COLOR { float diffuseLight = max(dot(P.N,P.L),0); //根据法线、计算光照 float4 diffuse=DiffuseCol*lightColor*diffuseLight; float4 col = (EmissiveCol+diffuse)*tex2D(TexSampler, P.texCoord); if(EnableSpec) //是否开启镜面反射光照 { float3 C =normalize (reflect( P.C,P.N)); float specularLight=pow(clamp(dot(P.L,-C),0,1) , specularPower); col += SpecularCol*lightColor*specularLight; } if(EnableCubeMap) //是否开启环境映射 { //求光线的反射向量 float3 refTexCoord=normalize( -reflect(normalize(P.C) , normalize(P.N)) ); //对立方体贴图进行采样 float4 colrefl = texCUBE(CubeMapSampler, refTexCoord); //将物体的纹理与环境映射的结果进行混合 return lerp(col , colrefl, shininess); } else { return col; } } technique tech { pass p { VertexShader = compile vs_2_0 BasicTransform_VS(); PixelShader = compile ps_2_0 Texture_PS(); } } 上述的代码中出现的新函数就是“texCUBE(CubeMapSampler, refTexCoord)”,但 实现的效果和运用的原理是截然不同的,图形学的精髓在于利用怎样的数学方法去模拟现实 世界,shader语言只是一组手段而已。函数texCUBE( )与tex2D( )用法是相同的、但它们采 样的纹理不同、采样的原理也不同,所以可以明显看出函数texCUBE( )的第二个参数接受 的是一个3D向量、而函数tex2D( ) 的第二个参数接受的是一个2D向量。细心的读者会发现 在求取环境映射所使用的光线的反射向量前加了一个负号,如下所示: float3 refTexCoord=normalize( -reflect(normalize(P.C) , normalize(P.N)) ); 这是因为我们分析时入射光方向和反射光方向与shader语言的分析方式相反,所以加个负号 调整过来。 - 246 - 15.3.4 折射环境映射 在学习高中物理的时候,老师这样讲述折射现象:“鱼儿在清澈的水里面游动,可以 看得很清楚。然而,沿着你看见与的方向用鱼叉去叉它,却叉不到。有经验的渔民都 知道,只有瞄准鱼的下方才能把鱼叉到。这是折射造成的一种假象”。“或者是把一块 厚玻璃放在钢笔的前面,笔杆看起来好像‘错位’了,这是折射造成的一种假象”。光 从一种介质斜射入另一种介质时,传播方向一般会发生变化,这种现象叫光的折射。 如图15.3.4_1和图15.3.4_2所示。 图 15.3.4_1 图 15.3.4_2 折射的原理说起来很容易让人理解,使用shader来模拟这种自然现象也是一件很容易 的事情,其他代码如下所示: float4x4 Wvp : WORLDVIEWPROJECTION; //合矩阵 float4x4 World : WORLD; //世界矩阵 float3 EyePos : EYEPOS; //观察者位置向量 float3 lightPosition; //与光源位置关联起来的位置向量 float4 lightColor; //光的颜色 float4 EmissiveCol; //物体自发光颜色 float4 DiffuseCol; //物体漫反射颜色 float4 SpecularCol; //物体镜面反射颜色 bool EnableSpec=false; //是否开启镜面反射 bool EnableCubeMap=false; //是否开启环境映射 float shininess = 0.8; //两贴图纹理相互混合系数 float specularPower = 0.8; //镜面反射强调系数 float etaRatio = 0.8; //折射系数 texture Tex ; //3D物体的表面纹理贴图 sampler2D TexSampler = sampler_state //纹理的采样方式 { texture = ; Minfilter = LINEAR; - 247 - Magfilter = LINEAR; MipFilter = LINEAR; AddressU = Wrap; AddressV = Wrap; }; texture2D CubeMap; //立方体贴图 sampler CubeMapSampler= sampler_state //立方体贴图的采样方式 { Texture = ; MinFilter = LINEAR; MagFilter = LINEAR; MipFilter = LINEAR; }; //输入结构 struct VS_Input { float4 position:POSITION; //物体顶点位置向量 float3 normal : NORMAL; //物体顶点法线向量 float2 texCoord:TEXCOORD0; //UV纹理坐标 }; //输出结构 struct VS_OutPut { float4 position:POSITION; //物体顶点位置向量 float2 texCoord:TEXCOORD0; //UV纹理坐标 float3 N:TEXCOORD1; //物体顶点法线向量 float3 L:TEXCOORD2; //指向光源向量 float3 C:TEXCOORD3; //指向观察者向量 }; //顶点渲染阶段入口函数 VS_OutPut BasicTransform_VS(VS_Input In) { VS_OutPut OUT; OUT.position=mul(In.position,Wvp); //得到顶点坐标转换到裁剪坐标系 float4 wPos=mul(In.position,World); //把局部的顶点坐标转换到世界坐标系中 float3 P=wPos.xyz; //得到世界坐标系中顶点的位置向量 OUT.N=normalize(mul(In.normal,World)); //得到世界坐标系中顶点的方向向量 OUT.L=normalize(lightPosition-P); //得到指向光源的向量 OUT.C = normalize(EyePos - P); //得到观察者向量 OUT.texCoord=In.texCoord; //得到UV坐标 return OUT; } //像素渲染阶段入口函数 float4 Texture_PS(VS_OutPut P):COLOR - 248 - { float diffuseLight = max(dot(P.N,P.L),0); //根据法线、计算光照 float4 diffuse=DiffuseCol*lightColor*diffuseLight; float4 col = (EmissiveCol+diffuse)*tex2D(TexSampler, P.texCoord); if(EnableSpec) //是否开启镜面反射光照 { float3 C =normalize (reflect( P.C,P.N)); float specularLight=pow(clamp(dot(P.L,-C),0,1) , specularPower); col += SpecularCol*lightColor*specularLight; } if(EnableCubeMap) //是否开启环境映射 { //求光线的折射后向量 float3 refTexCoord=normalize( -refract(normalize(P.C) , normalize(P.N),etaRatio) ); //对立方体贴图进行采样 float4 colrefl = texCUBE(CubeMapSampler, refTexCoord); //将物体的纹理与环境映射的结果进行混合 return lerp(col , colrefl, shininess); } else { return col; } } technique tech { pass p { VertexShader = compile vs_2_0 BasicTransform_VS(); PixelShader = compile ps_2_0 Texture_PS(); } } 实现反射的环境映射与折射的环境映射的shader代码唯一不同的的地方就是: float3 refTexCoord=normalize( -refract(normalize(P.C) , normalize(P.N),etaRatio) ); 折射函数refract( )比反射函数reflect( )多一个浮点型参数“etaRatio ”,这个参数是表示介 质的折射率。在现实世界中水的折射率1.333,玻璃的折射率是1.5,砖石的折射率是2.417, 但这只是现实,实际的项目中是不理会这套的,调节此参数来满足合适的效果。 15.3.5 菲涅尔效果和颜色色散 前面的两个小节的内容一个是反射、一个是折射。如果同时进行反射、折射那就是菲 涅尔效果的模拟。现实中菲涅尔效果表现在光线到达两种不同介质的接触面时,一部分光在 接触的表面被反射出去、另一部分光则发生了折射穿过了该接触面。高中物理中我们学习到 光的颜色由光的波长(或频率)来决定。不同波长的光在通过两介质的接触表面时,被折射 的角度就会不一样(如图15.3.5_1所示),比如三菱镜可以把一道白光分出一道彩虹的就是 - 249 - 利用了这个原理,这种现象叫做颜色的色散。本小节将模拟红绿蓝三个分量的色散情况如图 15.3.5_2所示。 图15.3.5_1 图15.3.5_2 在下面的shader代码,我们把普通光照、反射环境映射、折射环境映射、颜色色散的 四个效果整合到一个shader代码中,使用是选择想要的渲染技术就行了,而不是选择不同 的shader代码,这样的方法会方便很多。 float4x4 Wvp : WORLDVIEWPROJECTION; //合矩阵 float4x4 World : WORLD; //世界矩阵 float3 EyePos : EYEPOS; //观察者位置向量 float3 lightPosition; //与光源位置关联起来的位置向量 float4 lightColor; //光的颜色 float4 EmissiveCol; //物体自发光颜色 float4 DiffuseCol; //物体漫反射颜色 float4 SpecularCol; //物体镜面反射颜色 bool EnableSpec=false; //是否开启镜面反射 float shininess = 0.8; //两贴图纹理相互混合系数 float specularPower = 0.8; //镜面反射强度系数 float etaRatio = 0.8; //折射系数 float etaRatioRed = 0.8; //红光折射系数 float etaRatioGreen = 0.8; //绿光折射系数 float etaRatioBlue = 0.8; //蓝光折射系数 texture Tex ; //3D物体的表面纹理贴图 sampler2D TexSampler = sampler_state //纹理的采样方式 { texture = ; Minfilter = LINEAR; Magfilter = LINEAR; MipFilter = LINEAR; - 250 - AddressU = Wrap; AddressV = Wrap; }; texture2D CubeMap; //立方体贴图 sampler CubeMapSampler= sampler_state //立方体贴图的采样方式 { Texture = ; MinFilter = LINEAR; MagFilter = LINEAR; MipFilter = LINEAR; }; //输入结构 struct VS_Input { float4 position:POSITION; //物体顶点位置向量 float3 normal : NORMAL; //物体顶点法线向量 float2 texCoord:TEXCOORD0; //UV纹理坐标 }; //输出结构 struct VS_OutPut { float4 position:POSITION; //物体顶点位置向量 float2 texCoord:TEXCOORD0; //UV纹理坐标 float3 N:TEXCOORD1; //物体顶点法线向量 float3 L:TEXCOORD2; //指向光源向量 float3 C:TEXCOORD3; //指向观察者向量 }; //普通光照函数 float4 LightingTex(VS_OutPut P) { float diffuseLight = max(dot(P.N,P.L),0); float4 diffuse=DiffuseCol*lightColor*diffuseLight; float4 col = (EmissiveCol+diffuse)*tex2D(TexSampler, P.texCoord); if(EnableSpec) //是否开启镜面反射光照 { float3 C =normalize (reflect( P.C,P.N)); float specularLight=pow(clamp(dot(P.L,-C),0,1) , specularPower); col += SpecularCol*lightColor*specularLight; } return col; //返回光照的结果 }; //顶点渲染阶段入口函数 VS_OutPut BasicTransform_VS(VS_Input In) { - 251 - VS_OutPut OUT; OUT.position=mul(In.position,Wvp); //得到顶点坐标转换到裁剪坐标系 float4 wPos=mul(In.position,World); //把局部的顶点坐标转换到世界坐标系中 float3 P=wPos.xyz; //得到世界坐标系中顶点的位置向量 OUT.N=normalize(mul(In.normal,World)); //得到世界坐标系中顶点的方向向量 OUT.L=normalize(lightPosition-P); //得到指向光源的向量 OUT.C = normalize(EyePos - P); //得到观察者向量 OUT.texCoord=In.texCoord; //得到UV坐标 return OUT; } //光照像素渲染函数 float4 Light_PS(VS_OutPut P):COLOR { float4 lightcolor=LightingTex(P); return lightcolor; } //折射的环境映射像素渲染函数 float4 Refract_PS(VS_OutPut P):COLOR { float4 lightcolor=LightingTex(P); float3 refTexCoord=normalize( -refract(normalize(P.C) , normalize(P.N),etaRatio) ); float4 colrefl = texCUBE(CubeMapSampler, refTexCoord); return lerp(lightcolor, colrefl, shininess); } //反射的环境映射像素渲染函数 float4 Reflect_PS(VS_OutPut P):COLOR { float4 lightcolor=LightingTex(P); float3 refTexCoord=normalize( -reflect(normalize(P.C) , normalize(P.N)) ); float4 colrefl = texCUBE(CubeMapSampler, refTexCoord); return lerp(lightcolor, colrefl, shininess); } //菲涅尔效果和颜色色散 float4 Fresnel_PS(VS_OutPut P):COLOR { //反射的环境映射采样 float3 refTexCoord=normalize( -reflect(normalize(P.C) , normalize(P.N)) ); float4 refcol = texCUBE(CubeMapSampler, refTexCoord); //色散的环境映射采样 //红色光的折射向量 float3 RedTexCoord=normalize( -refract(normalize(P.C) , normalize(P.N),etaRatioRed) ); //绿色光的折射向量 float3 GreenTexCoord=normalize( -refract(normalize(P.C) , - 252 - normalize(P.N),etaRatioGreen) ); //蓝色光的折射向量 float3 BlueTexCoord=normalize( -refract(normalize(P.C) , normalize(P.N),etaRatioBlue) ); float4 colref; //依次对各个颜色分量进行采样 colref.r= texCUBE(CubeMapSampler, RedTexCoord).r; colref.g= texCUBE(CubeMapSampler, GreenTexCoord).g; colref.b= texCUBE(CubeMapSampler, BlueTexCoord).b; colref.a=1; //alpha值设置为不透明 return lerp(refcol, colref, shininess); //将反射和折射色散后结果输出 } technique LightingTech //光照渲染技术 { pass LightColor { VertexShader = compile vs_2_0 BasicTransform_VS(); PixelShader = compile ps_2_0 Light_PS(); } } technique RefractTech //折射环境映射渲染技术 { pass RefractColor { VertexShader = compile vs_2_0 BasicTransform_VS(); PixelShader = compile ps_2_0 Refract_PS(); } } technique ReflectTech //反射环境映射渲染技术 { pass ReflectColor { VertexShader = compile vs_2_0 BasicTransform_VS(); PixelShader = compile ps_2_0 Reflect_PS(); } } technique FresnelTech //菲涅尔效果和颜色色散的环境映射渲染技术 { pass FresnelColor { VertexShader = compile vs_2_0 BasicTransform_VS(); PixelShader = compile ps_2_0 Fresnel_PS(); } } - 253 - 在上面的代码中颜色色散的效果,其实就是对红、绿、蓝各个分量的颜色进行求出其 折射向量后,然后依次对立方体贴图进行采样,得到的结果再合成一个float4类型的颜色值。 如下所示: float4 colref; //依次对各个颜色分量进行采样 colref.r= texCUBE(CubeMapSampler, RedTexCoord).r; colref.g= texCUBE(CubeMapSampler, GreenTexCoord).g; colref.b= texCUBE(CubeMapSampler, BlueTexCoord).b; colref.a=1; //alpha值设置为不透明 texCUBE(CubeMapSampler, RedTexCoord)返回的是一个float4类型的颜色值,函数后面 紧接这“.r”,表示只是取这个颜色值的红色分量;依此类推“.g”是取绿色分量;“.b”是 取蓝色分量。 模拟菲涅尔效果是同时存在反射、折射的环境映射,所以使用了函数lerp( )来对两种采 样的结果进行协调输出最终的颜色值。 15.3.6 环境映射和凹凸纹理映射的结合 XXX 凹凸+反射 float4x4 Wvp : WORLDVIEWPROJECTION; float4x4 World : WORLD; float3 EyePos : EYEPOS; float3 lightPosition; float4 lightColor; float4 EmissiveCol; float4 DiffuseCol; float4 SpecularCol; bool EnableSpec=false; bool EnableCubeMap=false; float shininess = 0.5; float specularPower = 0.5; bool BurningAble = true; bool useParallax = true; float burnFactor = 1.17; float parallaxAmplitude = 0.05; float parallaxOffset = 0.8; float3 normalMapScale = {-1,-1,1}; texture Tex ; sampler2D TexSampler = sampler_state - 254 - { texture = ; Minfilter = LINEAR; Magfilter = LINEAR; MipFilter = LINEAR; AddressU = Wrap; AddressV = Wrap; }; texture normalMap; sampler bumpSampler = sampler_state { texture = ; MipFilter = LINEAR; Minfilter = LINEAR; Magfilter = LINEAR; }; texture2D CubeMap; sampler CubeMapSampler= sampler_state { Texture = ; MinFilter = LINEAR; MagFilter = LINEAR; MipFilter = LINEAR; }; struct VS_Input { float4 position :POSITION; float3 normal : NORMAL; float2 texCoord :TEXCOORD0; float3 Tangent : TEXCOORD1; float3 Binorm : TEXCOORD2; }; struct VS_OutPut { float4 position:POSITION; float2 texCoord:TEXCOORD0; float3 N:TEXCOORD1; float3 L:TEXCOORD2; float3 C:TEXCOORD3; float3 T:TEXCOORD4; - 255 - float3 B:TEXCOORD5; }; VS_OutPut BasicTransform_VS(VS_Input In) { VS_OutPut OUT; OUT.position=mul(In.position,Wvp); float4 wPos=mul(In.position,World); float3 P=wPos.xyz; OUT.N=normalize(mul(In.normal,World)); OUT.L=normalize(lightPosition-P); OUT.C = normalize(EyePos - P); OUT.T=In.Tangent; OUT.B=In.Binorm; OUT.texCoord=In.texCoord; return OUT; } float4 Texture_PS(VS_OutPut P):COLOR { float2 uv = P.texCoord; if( useParallax ) { float2 camDir = mul( float2x3(P.T,P.B), P.C ); float depth = tex2D( bumpSampler, uv ).a-parallaxOffset; uv -= depth*parallaxAmplitude*camDir; } float3 bumpNorm = tex2D( bumpSampler,uv); float3 normalComponent=2*(bumpNorm -0.5); bumpNorm = normalComponent*normalMapScale; bumpNorm =mul(bumpNorm , float3x3(P.T,P.B,P.N)); float4 finalCol =tex2D(TexSampler, uv );// (texSize; //采样器采样的图片 Minfilter = LINEAR; //纹理在被缩小时进行线性过滤 Magfilter = LINEAR; //纹理在被放大时进行线性过滤 }; texture AlphaTex; //透明通道图片 sampler AlphaSampler = sampler_state { texture = ; //采样器采样的图片 Minfilter = LINEAR; //纹理在被缩小时进行线性过滤 Magfilter = LINEAR; //纹理在被放大时进行线性过滤 }; texture LightTex; //相框图片 sampler LightSampler = sampler_state { texture = ; //采样器采样的图片 Minfilter = LINEAR; //纹理在被放大时进行线性过滤 Magfilter = LINEAR; //纹理在被放大时进行线性过滤 }; struct OutPutVS //顶点采样输出结构 { float4 position:POSITION; //顶点的位置 float2 texCoord:TEXCOORD0; //纹理UV }; struct OutPutPS //像素采样输出结构 { float4 color:COLOR; //输出颜色值 }; //顶点渲染函数 OutPutVS TexVS(float2 InPosition:POSITION, float2 InTexCoord:TEXCOORD0) - 258 - { OutPutVS OUT; //定义一个顶点输出结构变量 OUT.position= float4(InPosition, 0, 1); //转换到2D屏幕坐标 OUT.texCoord= InTexCoord; //纹理UV坐标 return OUT; } //像素渲染函数 OutPutPS TexPS(OutPutVS opvs) { OutPutPS OUT; //定义一个像素输出结构变量 float4 diffCol=tex2D(TexSampler, opvs.texCoord); //对头像纹理采样 float4 alphacol=tex2D(AlphaSampler, opvs.texCoord); //对通道纹理采样 diffCol.a=alphacol.a; //设置Alpha值 OUT.color=diffCol; //得到最终返回颜色 return OUT; } //定义像素渲染函数 OutPutPS TexPS02(OutPutVS opvs) { OutPutPS OUT; //定义一个像素输出结构变量 float4 lightcol=tex2D(LightSampler , opvs.texCoord); //对相框纹理采样 OUT.color=lightcol; //得到最终返回颜色 return OUT; } technique tech //渲染技术 { pass p //第一个渲染通道 { AlphaBlendEnable = true; //开启alpha混合 SrcBlend = SRCALPHA; //混合方法 DestBlend =INVSRCALPHA; //混合方法 VertexShader = compile vs_2_0 TexVS(); PixelShader = compile ps_2_0 TexPS(); } pass p2 //第二个渲染通道 { PixelShader = compile ps_2_0 TexPS02(); } } 上面的代码其实没有亮点,就是对纹理进行采样而已,先是采样头像贴图,接着再采 样通道贴图,然后把通道贴图的alpha的值,赋给头像贴图的alpha值,然后混合输出就得到 上图(三)摸样。因为头像贴图的alpha值都是1,所有像素全显示;而通道贴图的alpha的 值白色区域是1的部分显示,黑色区域是0的部分透明掉。关于alpha混合的方式请参考第一 部分章节5.2.4。如何加上边框就是在第一个渲染通道的结果上,执行第二个渲染通道就行 - 259 - 了。有些读者想把第二个渲染通道的操作整合放到第一个渲染通道上,结果是失败的。什么 时候使用两个渲染通道(即Pass),请读者认真体会,这也算是本节的亮点吧。一种是前一 个渲染通道的结果上,在覆盖上一层渲染结果,好比刷油漆;一种是两种不同的油漆颜色如 何混合到一起,然后再刷。请读者细心体会,详细请参见实例 “例子15.4.1_1.cmo”。 15.4.2 2D 帧的旋转 在Virtools里面旋转一个3D物体非常简单,但是渲染一个2D帧就没有好方法了。常用的 替代方法是,做一组序列帧图片,然后循环播放,就可以看出来是在旋转,但是这种方法比 较消耗资源,而且序列帧数少的话,看起来还不连贯。其实在通过shader对UV进行旋转就 能实现2D帧的旋转,效果如图15.4.2_1所示:该图描绘的是枪的准心,其中图(一)是可以 变色的,图(二)是要旋转的部分,图(三)是准心,最终融合效果是图(四)。 图15.4.2_1 实现这个效果也会使用多个渲染通道才能完成,由于有些贴图不旋转,有些则旋转, 所以UV不能共用。详细代码如下所示: int PassIndex : PASSINDEX; //当前执行到第几个渲染通道 texture tex : TEXTURE; //材质使用的贴图 sampler TexSampler = sampler_state //材质使用的贴图的采样器 { texture = ; //要采样的纹理 Minfilter = LINEAR; //纹理在被缩小时进行线性过滤 Magfilter = LINEAR; //纹理在被放大时进行线性过滤 AddressU = BORDER; //U方向的纹理寻址模式采用重叠映射寻址模式 AddressV = BORDER; //V方向的纹理寻址模式采用重叠映射寻址模式 }; texture CirFrame01 ; sampler CirFrameTexSampler01 = sampler_state { texture = ; //要采样的纹理 Minfilter = LINEAR; //纹理在被缩小时进行线性过滤 Magfilter = LINEAR; //纹理在被放大时进行线性过滤 AddressU = BORDER; //U方向的纹理寻址模式采用重叠映射寻址模式 AddressV = BORDER; //V方向的纹理寻址模式采用重叠映射寻址模式 }; texture CirFrame02 ; sampler CirFrameTexSampler02 = sampler_state - 260 - { texture = ; //要采样的纹理 Minfilter = LINEAR; //纹理在被缩小时进行线性过滤 Magfilter = LINEAR; //纹理在被放大时进行线性过滤 AddressU = BORDER; //U方向的纹理寻址模式采用重叠映射寻址模式 AddressV = BORDER; //V方向的纹理寻址模式采用重叠映射寻址模式 }; float radian01; //旋转弧度参数 float radian02; //旋转弧度参数 float radian03; //旋转弧度参数 struct VS_Input //顶点输入参数结构 { float2 position:POSITION; //顶点的位置 float4 color:COLOR; //顶点的颜色 float2 texCoord:TEXCOORD0; //顶点的UV }; struct VS_Output //顶点输出参数结构 { float4 position:POSITION; //顶点的位置 float4 color:COLOR; //顶点的颜色 float2 texCoord:TEXCOORD0; //顶点的UV }; VS_Output mainVS(VS_Input In) //顶点渲染函数 { VS_Output Out; //定义一个顶点输出结构变量 Out.position= float4(In.position, 0, 1); //转换到2D屏幕坐标 Out.color=In.color; //设置顶点的颜色 Out.texCoord=In.texCoord; //设置顶点的UV return Out; //返回 }; VS_Output RotateVS(VS_Input In) //旋转的顶点渲染函数 { VS_Output Out; //定义一个顶点输出结构变量 Out.position= float4(In.position, 0, 1); //转换到2D屏幕坐标 Out.color=In.color; //设置顶点的颜色 float2 newCoord; //用于存放旋转后的uv值变量 float NewY = In.texCoord.y; //得到原始UV值 float NewX = In.texCoord.x; //得到原始UV值 float Curradian; //得到要渲染的角度的变量 if(PassIndex==1) Curradian=radian01; //第二个渲染通道的旋转角度值 if(PassIndex==2) Curradian=radian02; //第三个渲染通道的旋转角度值 if(PassIndex==3) - 261 - Curradian=radian03; //第四个渲染通道的旋转角度值 //旋转UV值的算法 newCoord.x = 0.5 + (In.texCoord.x - 0.5) * cos(Curradian) + (In.texCoord.y - 0.5) * sin(Curradian); newCoord.y = 0.5 - (In.texCoord.x - 0.5) * sin(Curradian) + (In.texCoord.y - 0.5) * cos(Curradian); Out.texCoord = newCoord; //最终的UV值 return Out; }; float4 mainPS(VS_Output P):COLOR //像素渲染函数 { float4 result ; //定义一个返回的颜色变量 if(PassIndex==0) //第一个渲染通道的纹理采样 result = tex2D(TexSampler,P.texCoord)*P.color; if(PassIndex==4) //第五个渲染通道的纹理采样 result = tex2D(CirFrameTexSampler02 ,P.texCoord); return result; } float4 RotatePS(VS_Output P):COLOR //旋转效果的像素渲染函数 { float4 result = tex2D(CirFrameTexSampler01 ,P.texCoord); return result; } technique tech { pass p0//第一个渲染通道 { AlphaBlendEnable = true; SrcBlend = SRCALPHA; DestBlend =INVSRCALPHA; VertexShader = compile vs_2_0 mainVS();//RotateVS PixelShader = compile ps_2_0 mainPS(); } pass p1//第二个渲染通道 { VertexShader = compile vs_2_0 RotateVS();//RotateVS PixelShader = compile ps_2_0 RotatePS(); } pass p2//第三个渲染通道 { VertexShader = compile vs_2_0 RotateVS();//RotateVS PixelShader = compile ps_2_0 RotatePS(); } pass p3//第四个渲染通道 - 262 - { VertexShader = compile vs_2_0 RotateVS();//RotateVS PixelShader = compile ps_2_0 RotatePS(); } pass p4//第五个渲染通道 { VertexShader = compile vs_2_0 mainVS();//RotateVS PixelShader = compile ps_2_0 mainPS(); } } 上面的shader代码使用了5个渲染通道,顶点渲染函数和像素渲染函数各有2个,其中函 数mainVS()是正常的顶点的变换,而RotateVS()是带旋转的顶点变换;像素渲染函数 mainPS()和RotatePS()没有大的区别,只是运用不同的采样器就进行采样吧了,其实可以 把这两个像素渲染函数写成一个。整个代码中除了旋转UV算法外没有什么特殊的地方,下 面来讲讲旋转原理推到旋转公式,假如圆周上(X,Y)点旋转一定角度后到达(X′, Y′)点,如图 15.4.2_1所示: 图15.4.2_1 旋转公司推到过程如下所示: 其中有: 풕품(휷) = 풀 푿 ① 풕품(휶 + 휷) = 풀′ 푿′ ② 푿ퟐ + 풀ퟐ = 푿′ퟐ + 풀′ퟐ ③ 有公式: 풕품(휶 + 휷) = 풕품(휶)+풕품(휷) ퟏ−풕품(휶)∗풕품(휷) ④ 把①代入④从而消除参数휷后得: 풕품(휶) + 풀 푿 = 풀′ 푿′ ∗ (ퟏ − 풕품(휶) ∗ 풀 푿) ⑤ - 263 - 由⑤可以得: 푿′ = 풀′ ∗ 푿−풀∗풕품(휶) 푿∗풕품(휶)+풚 ⑥ 把⑥代入③从而消除参数푿′,化简后得: 퐘′ = 퐗 ∗ 퐬퐢퐧훂 + 퐘 ∗ 퐜퐨퐬훂 ⑦ 把⑦代入⑥,得: 푿′ = 푿 ∗ 풄풐풔휶 − 풀 ∗ 풔풊풏휶 ⑧ 以上是从圆周上一点旋转一定角度后,求新位置的的公式。如果旋转的圆心不在原点,那就 要处理一个偏移量,假如圆心是点(refX,refY),旋转的角度是θ,那么最后的公式为: X′ = (X − refX) ∗ cos θ + (Y − refY) ∗ sinθ + refX ⑨ Y′ = −(X − refX) ∗ sin θ + (Y − refY) ∗ cosθ + refY ⑩ 本例中要旋转的是一张图片,图片的UV在0至1之间,中心点是(0.5,0.5),所以实际运用 的算法语句是: newCoord.x = 0.5 + (In.texCoord.x - 0.5) * cos(Curradian) + (In.texCoord.y - 0.5) * sin(Curradian); newCoord.y = 0.5 - (In.texCoord.x - 0.5) * sin(Curradian) + (In.texCoord.y - 0.5) * cos(Curradian); 详细请参考 - 264 - 第十六章 shader 小效果 在一个虚拟场景中所表现的世界不可能是静止不动的,常常会看到摇摆的花草、漫步 的游人和漂泊的云彩等等,动与静相结合才是一个丰富的世界。无论是动还是静,当把渲 染到屏幕的最终画面,再进行一次 shader 处理,还能得到其他意想不到的效果,本章描 述在 shader 如何实现动画,讲解屏幕后处理技术,将讲解一下内容:  使用 shader 来实现简单的动画效果  使用 shader 来进行屏幕后处理,来加强效果  游戏中常用的一些简单效果 16.1 shader 实现动画效果 16.1.1 脉动的茶壶——顶点动画 这个茶壶是随着时间有规律的舞动。在这句话中“随着时间”——表示需要一个表示 时间的变量;“有规律的”——表示需要一个以时间变化的函数来表示这个规律。表示时间 的变量在shader语言中有个现成的语义——“: Time”,它可以拿来使用;而以时间变化的函 数有简单的、有复杂的、也有你我都不知道的,我们就选用一个中学生都熟悉的正弦函数, 之所以选用正弦函数,是因为它的值被限制在一个区间内,其规律曲线图像如图16.1.1_1所 示。如果选择一个直线函数:y=tx;其结果就是物体随时间不断的变得无穷大,这样的效果 太糟糕了。由于正弦函数的值范围在-1至1之间,有时候我们不想要负数,干脆在结果上加1 使得结果的取值范围0至2之间,如图6.1.1_2所示。 图16.1.1_1 图16.1.1_2 打开“例子16.1.1_1.cmo”,在shader编辑器中找到该顶点程序的源代码,这段代码中不 仅包含了脉动的顶点动画代码,也包括了简单光照的像素着色代码,这样才看起来像个效果, 不然就像是一团黑乎乎的东西在蠕动。其代码如下: float4x4 Wvp : WORLDVIEWPROJECTION; //合矩阵 float4x4 World : WORLD; //世界矩阵 float3 EyePos : EYEPOS; //观察者位置向量 float3 lightPosition; //与光源位置关联起来的位置向量 float4 lightColor; //光的颜色 float4 EmissiveCol; //物体自发光颜色 - 265 - float4 DiffuseCol; //物体漫反射颜色 float4 SpecularCol; //物体镜面反射颜色 float specularPower = 20; //镜面反射强度系数 float scaleFactor = 0.05; //缩放程度系数 float frequency = 20; //脉动频率系数 float ticks : Time; //时间变量 texture Tex ; //3D物体的表面纹理贴图 sampler2D TexSampler = sampler_state //纹理的采样方式 { texture = ; Minfilter = LINEAR; Magfilter = LINEAR; MipFilter = LINEAR; AddressU = Wrap; AddressV = Wrap; }; struct VS_Input //输入结构体 { float4 position:POSITION; float3 normal : NORMAL; float2 texCoord:TEXCOORD0; }; struct VS_OutPut //输出结构体 { float4 position:POSITION; float2 texCoord:TEXCOORD0; float3 N:TEXCOORD1; float3 L:TEXCOORD2; float3 C:TEXCOORD3; }; VS_OutPut BasicTransform_VS(VS_Input In) //顶点换算 { VS_OutPut OUT; float displacement = sin(In.position.z*frequency*ticks )+1; //时间脉动的函数 //向顶点的法线方向计算一个偏移量值 float4 displacementDir=float4(In.normal.x,In.normal.y,In.normal.z,0)*scaleFactor; //向着法线的方向求得一个新的顶点位置向量 float4 newPostion=In.position+displacementDir*displacement; //以下代码出现过好多遍了,注释请参照前面章节的例子注释 OUT.position=mul(newPostion,Wvp); float4 wPos=mul(newPostion,World); float3 P=wPos.xyz; OUT.N=normalize(mul(In.normal,World)); OUT.L=normalize(lightPosition-P); - 266 - OUT.C = normalize(EyePos - P); OUT.texCoord=In.texCoord; return OUT; } float4 Texture_PS(VS_OutPut P):COLOR //光照计算 { float diffuseLight = max(dot(P.N,P.L),0); float4 diffuse=DiffuseCol*lightColor*diffuseLight; float3 C =normalize (reflect( P.C,P.N)); float specularLight=pow(clamp(dot(P.L,-C),0,1) , specularPower); float4 col = (EmissiveCol+diffuse)*tex2D(TexSampler, P.texCoord); col += SpecularCol*lightColor*specularLight; float4 finalColor =col; return finalColor; } technique tech { pass p { VertexShader = compile vs_2_0 BasicTransform_VS(); PixelShader = compile ps_2_0 Texture_PS(); } } 上面代码的效果图16.1.1_3(观察壶盖)所示: 图16.1.1_3 脉动的函数sin(ticks)中只是一个根据时间的推移按着标准的频率脉动,假如要控制它 脉动的频率可快可慢,就加入一个变量frequency来控制,此时脉动函数就成了sin(frequency * ticks)。此时物体的脉动就像一个整体的缩放,这样太没意思了,如果想让物体的每个顶点 发生的变化是不一样的脉动,就要把每个顶点的特性考虑到脉动函数中,每个顶点之间有什 么不同的特性呢?——位置不同!由于物体的顶点是float4类型,而参数需要的是float类型, 那就选取某一个轴向的值就可以了,这样最终的脉动函数就成了sin((In.position.z *frequency * ticks)。 如果觉得脉动的幅度不够大,那就加一个变量scaleFactor来控制物体的脉动幅度,由于是物 体顶点的脉动是向着法线方向上的,所以不要忘了与顶点法线向量相乘,最终的幅度值由 float4(In.normal.x,In.normal.y,In.normal.z,0)*scaleFactor表示。最后不要忘了把偏移量与原来 的顶点位置向量向加得到新的顶点位置向量。 - 267 - 16.1.2 舞动的花朵——纹理 UV 动画 在游戏场景中,我们会经常看到地上的花草或者水中的海藻的会轻微舞动,这节我们 就用shader来实现这个功能。由于虚拟世界中的花草等3D物体,通常只是用2个面来表示, 细节只能通过纹理来展示,而不像水壶那样有复杂的网格,所以就不能对这些物体使用顶点 动画来达到效果。 由于花草的顶点数目少,也就无需考虑使用前面提到的光照函数来实现光照效果了, 如果为了提升的效果可以考虑多纹理融合的方式来表示花草的光效,但这不是本节的重点。 既然要使用像素渲染来达到效果,我们最容易想到的是控制顶点上的UV,但是要如何控制 才像呢? 我们想想现实世界的花草被微风吹过时,花草的头部摆幅最大,根部摆幅最小。从这 一点可以看出要根据各个顶点的Y轴(高度)不同来确定摆幅的大小,而摆幅的具体大小要 用X轴来表示, 可见我们必须要定义一个中心点,然后每个顶点以这个中心点为参照,决 定摆幅的大小。由于得到顶点的参照高度,那么就少不了编写顶点着色的代码,不要期望只 是编写像素着色代码就能完成这个效果。 我们希望花草是周期性的左右摆动,自然就免不了找一个周期性的数学函数(sin、cos 是最常用的了)来表示,这个周期性必需通过时间才能观察得到,自然也就要定义一个时间 变量了。分析结束了,下面我们就用代码来虚拟这个简单的现实,具体代码如下所示: //使用纹理UV来达到动画效果 //根据像素的高度来决定波浪的效果 float4x4 WorldObj : WORLD; // 物体的矩阵 float4x4 Wvp : WORLDVIEWPROJECTION; //矩阵 float timer : Time; //时间 //manul paras float3 BaseLocation = {0,-0.5,0}; //舞动的中心点 float amplitude = 0.1; //舞动的幅度 float WaveSpeed = 1; //左右舞动的速度 struct VS_Input //输入顶点信息 { float4 Position : POSITION; //顶点的位置 float4 UV : TEXCOORD0; //顶点的UV float4 Normal : NORMAL; //顶点的法线 }; struct VS_OutPut //顶点着色后的输出信息作为像素着色的输入信息 { float4 HPosition : POSITION; //存放顶点转换后的坐标 float4 TexCoord : TEXCOORD0; //顶点的UV float4 WPosition :TEXCOORD1; //存放顶点的世界坐标 }; texture2D Tex : TEXTURE; //纹理信息 sampler2D TexSample = sampler_state //纹理采样方式 { Texture = < Tex >; MinFilter = LINEAR; MagFilter = LINEAR; - 268 - MipFilter = LINEAR; AddressU = BORDER; AddressV = BORDER; }; //顶点着色函数 VS_OutPut VertexShader_VS(VS_Input IN) { VS_OutPut OUT; float4 Po = float4(IN.Position.x,IN.Position.y,IN.Position.z,1.0); //物体的局部坐标 float4 hpos = mul(Po, Wvp); //顶点转换后的坐标 OUT.HPosition = hpos; OUT.WPosition = Po; //计算偏移量用到 OUT.TexCoord = IN.UV; //操作的计算UV return OUT; } //像素着色函数 float4 PixelShader_PS(VS_OutPut IN) : COLOR //像素着色函数 { //amplitudeHs是中心点的偏移量,决定摆幅打大小是amplitude float amplitudeH = (IN.WPosition.y- BaseLocation.y)*amplitude; float4 OutColor; //着色算法函数 OutColor = tex2D(TexSample, float2((IN.TexCoord.x+amplitudeH*amplitudeH*sin(timer*WaveSpeed)), IN.TexCoord.y)); return OutColor; } technique tech { pass p0 { AlphaTestEnable = TRUE; //开AlphaTest AlphaFunc = GREATER; AlphaRef = 3; AlphaBlendEnable = TRUE; //开AlphaBlend DestBlend = INVSRCALPHA; SrcBlend = SRCALPHA; CullMode = NONE; VertexShader = compile vs_1_1 VertexShader_VS(); PixelShader = compile ps_2_0 PixelShader_PS(); } } 上面的代码中参数BaseLocation就是我们定义的物体的中心点,这里之所以取值是0.5, 是因为花朵的大小是1,如果你的项目中花朵的大小是7,不要忘记修改这个值;我们还定 - 269 - 义了一个参数amplitude来表示花草的摆动幅度大小的基数,这样方便调节花草的摆动效果; 另外参数frequency和WaveSpeed都是周期性函数sin使用的参数,读者把这些参数设置得 很大,然后看看效果就会明白它的用意。上面的代码中有注解,我相信大部分读者都能看明 白,唯一疑惑的地方就是下面一条语句而已: float2((IN.TexCoord.x+amplitudeH*amplitudeH*sin(timer*WaveSpeed)), IN.TexCoord.y)); 这条语句是实现花草摆动的数学算法,这条语句不是作者原创的,只是做了一些简化 修改,以便到底教学的目的。其实本书中所列的shader部分的代码绝大部分都不是作者原创 的,作者也是继承了前辈们的成果,拿为己用(当然本应感谢这些前辈,但是无法确认这些 原创代码段的前辈是谁,所有就谢谢internet吧)。要想原创新的效果代,除了程序设计本身 外,还要有很好的数学专业技能(注意已经不是数学基础能达到的境界了),和抽象事件的 能力。但是数学不够专业并不能妨碍我们使用和修改前辈们创造的shader效果,这条语句我 们还可以得出来的,我们来细细分析: 我们要花草左右移动,那就是移动X轴(纹理的U轴坐标),也就是说X值是变得的,Y 轴(纹理的V轴坐标)自然不用处理,那么我们得到的数学公式是: float2((IN.TexCoord.x+??, IN.TexCoord.y)) 现在接着确定上式中“??”的变化规律,既然是说左右摆动,那就自然想到sin函数, 那么我们可以进一步得到数学公式是: float2((IN.TexCoord.x+*sin(??), IN.TexCoord.y)) 现在再接着确定上式中“??”中需要什么样的状态,我们知道花草的摆动是必须需 要时间来衡量,时间不流逝,时间停止,世界都是静止的,自然不能动。所以我们再进一步 得到数学公式: float2((IN.TexCoord.x+sin(timer), IN.TexCoord.y)) 到了这一步,我们编译代码运行看看。这时候看到的结果是整一朵花是左右来回移动, 完全不是我们要的效果。如图16.1.2_1所示: 图16.1.2_1 我们的目标是花朵的头部摆幅最大,根部摆幅最小,根据各个顶点的Y轴(高度)不同 来确定摆幅的大小,这个就会自然的想到它,除了它也就没什么其他可以想的了,因为在 shader程序中,能够容易得到的信息就是顶点、法线、和UV了。 要判断那个哪个顶点高, 哪个顶点低,就得需要一个参照点,然后每个顶点以这个中心点为参照,决定摆幅的大小。 现在就来求这个以高度为依据的值,比高矮这个数学公式就很简单了,如所示: float amplitudeH = (IN.WPosition.y- BaseLocation.y)*amplitude; 上式中IN.WPosition.y就是花朵模型顶点的高度,BaseLocation.y就是参照点,而 amplitude只是一个基数,用来调节偏移倍数的,因为有些模型很大或是很小,这个偏移量 就需要一个系数来控制大小。既然得到了系数我们就把它用上,公式如下: float2((IN.TexCoord.x+ amplitudeH*sin(timer), IN.TexCoord.y)) 编译一下,运行一下看一下效果,看一下是否接近我们想要的效果了,结果不是,还差一些, 效果如图16.1.2_1所示,完全就是一个摆钟的运动轨迹。 - 270 - 图16.1.2_2 问题出在哪里呢?其实问题还是在amplitudeH上,因为它的变化量是线性的,如图 16.1.2_3所示。如果是一条曲线变化的效果也许会好一些,比如amplitudeH*amplitudeH的曲 线,如图16.1.2_4所示。. 图16.1.2_3 图16.1.2_4 改改公式,并给sin函数乘以一个系数,以调节摇摆周期速度如下: float2((IN.TexCoord.x+amplitudeH*amplitudeH*sin(timer*WaveSpeed)), IN.TexCoord.y)); 这个公式与上面的代码段中出现的公式已经一样了,结果自然出来了,同时这也是我们推理 的最终结果,详细请参考“例子16.1.2_1.cmo”。 16.1.3 波光粼粼的浅水区——序列帧动画 看这一小节的标题,有人可能就会想,既然是序列帧动画,直接调用“Movie Player” 这个BB播放就行了,甚至可以使用“Texture Scroller”这个BB,一张图片就能模拟水面的 移动,为什么还需要使用shader呢。其实,我们为了提升一点画面效果,即使麻烦一些, 也是值得的。 我们打开例子程序“例子16.1.3_1.cmo”,先来对比两者的效果。这个例子中我们模拟 的环境是浴室里浴缸中的水,然后用两种方式来实现浴缸中水的效果表现。在Level管理器 中找到“WaterTextureScroller”这个3D物体,它是使用“Texture Scroller”这个BB来表现 效果的,如图16.1.3_1所示;找到“WaterShader” 这个3D物体,它是使用shader和纹理序 列帧动画来表现效果的,如图16.1.3_2所示。可见后者的效果好一些,既然我们认同了这个 效果好,那我们就来学习它是如何实现的吧。 要达到这个效果,我们先要准备一个波光粼粼的序列帧图片,查看例子程序中的 “WaterTex”这张纹理贴图,你会发现它是由30张slot组成的序列帧图片,如图16.1.3_3所 示,对比使用“Texture Scroller”的方法,资源的消耗很大。这个效果还使用了一张贴图, 用来平滑水和浴缸接触面的自然过渡以及水面的透明度,如图16.1.3_2圈内所示(由于我们 的美术功底不好,所以只有两处平滑过渡可以看出来)。 这张处理自然过渡的贴图只要做得 和浴缸的轮廓一样,就可以得到更好的效果,如图16.1.3_4所示。 - 271 - 图16.1.3_1 图16.1.3_2 图16.1.3_3 图16.1.3_4 分析完了这个shader使用的资源后,我们来看看这段shader是如何使用这些图片资源来 达到效果的。Shader代码如下所示: texture g_pTexture : TEXTURE; // 水面使用的贴图 texture g_pAlphaTexture; // 水面过渡使用的贴图 float4x4 Wvp : WORLDVIEWPROJECTION; // 合矩阵 float4 Diff : DIFFUSE; // 水面的反射的眼色 float TexScale; // 水面纹理的缩放 //纹理的采样器 sampler TextureSampler =sampler_state { Texture = ; MipFilter = LINEAR; MinFilter = LINEAR; MagFilter = LINEAR; }; - 272 - sampler TexAlphaSampler =sampler_state { Texture = ; MipFilter = LINEAR; MinFilter = LINEAR; MagFilter = LINEAR; }; // 输出结构 struct VS_OUTPUT { float4 vPosition : POSITION; //顶点的位置 float4 vDiff : COLOR0; //漫反射颜色 float2 vUV : TEXCOORD0; //波光粼粼的纹理UV坐标 float2 vUVA : TEXCOORD1; //alpha过渡的纹理UV坐标 }; // 顶点渲染 VS_OUTPUT RenderSceneVS( float3 vPosition : POSITION, float4 vDiffuse:COLOR0, float2 vUV : TEXCOORD0) { VS_OUTPUT output; output.vPosition = mul(float4(vPosition , 1), Wvp ); //将顶点转换到正确的位置上 output.vDiff=Diff; //设置物体漫反射颜色 output.vUV=vUV*TexScale; //设置水面的波光的密度 output.vUVA=vUV; //设置过渡效果图片采样UV坐标 return output; } // 像素渲染 float4 RenderScenePS( VS_OUTPUT input ) : COLOR { float4 OutColor; float4 color= tex2D(TextureSampler, input.vUV); //采样 float4 colorA= tex2D(TexAlphaSampler , input.vUVA); //采样 OutColor =(color+color.w)*input.vDiff; //表现波光粼粼的效果的算法 OutColor.w=colorA.w; //设置边缘过渡和水面透明度。 return OutColor;// } technique tech { pass p { Lighting = False; ZWriteEnable =False; - 273 - AlphaBlendEnable=TRUE; //开启Alpha SrcBlend =SrcAlpha; DestBlend =InvSrcAlpha; VertexShader = compile vs_1_1 RenderSceneVS(); PixelShader = compile ps_1_4 RenderScenePS(); } } 上面的代码没有难以理解的地方,算法的关键就是下面这一条语句: OutColor =(color+color.w)*input.vDiff; //表现波光粼粼的效果的算法 如果返回的色彩直接是材质使用的贴图的色彩(即OutColor =color);那么得到效果是 一些斑点,原因是参见图16.1.3_3的Alpha通道就能明白。所以在原来的色彩上再加color.w 这个系数(即OutColor =(color+color.w)),这时水面整个都变亮了;虽然水是变亮了,但 是无法改变它的颜色, 那么就给他乘上材质的漫反射值吧(即OutColor =(color+color.w)*input.vDiff);水完成了,最后我们要控制水的透明度和边缘处理,语句 OutColor.w=colorA.w实现了这个功能。 效果就这样最终达成了,但是水没有动,那就建立 一个脚本拖出“Movie Player”这个BB,把参数设置好,运行就可以看到浴缸里波光粼粼 的效果了,详细参加“例子16.1.3_1.cmo”。 16.2 简单的几个修饰效果 16.2.1 广告墙变换效果 我们平时上班下班穿梭于高楼大厦、在公交车站总会看到大幅的广告、广告画面时不 时会切换来切换去,有些大厦外墙或者是舞台的幕墙是通过很多个小屏幕拼接起来的。现在 我们就来实现这么一个效果,这个效果其实也是通过对透明通道的灵活运用来实现的,其效 果如图16.2.1_1所示: 切换前 切换中 切换后 图16.2.1_1 上图画面从左到右就是一个广告画面的过程:先是一张完整的画面;接着是新的画面 从左上角慢慢侵入,旧的画面从右下角慢慢的推出;最后新画面完全替代了旧的画面。为了 模拟大屏幕的拼接效果,我们用了一些小的黑框框来表示。要实现上图的效果光是两张人物 贴图还不行,还需要一些补助的贴图:一张是表示拼接效果的贴图,一张是两张图片过渡的 Alpha通道图,这个通道图是这个效果的关键。如下图16.2.1_2所示: - 274 - 拼接图 通道图 通道分析图 图16.2.1_2 看到这个效果需要的贴图内容,部分读者估计和我当时一样,以为对实现的方法已经 有所了解了,三两下就能搞定了,不需要看前辈们的代码。作者就尝试用自己的方法来实现, 结果三两下以后,发现自己的效果不是预期的,后来还是忍不住看来一下前辈们的代码,代 码如下所示: float4x4 g_mWorldViewProjection: WorldViewProjection; // 合矩阵 texture ScrTexture; // 旧画面 texture DesTexture; // 新画面 texture WindowTexture; // 拼接图 texture MaskTexture; // 通道图 float offsetX; // 偏移量 float offsetY; // 偏移量 sampler ScrSampler = sampler_state // 旧画面采样器 { Texture = < ScrTexture >; Minfilter = LINEAR; Magfilter = LINEAR; }; sampler DesSampler = sampler_state // 新画面采样器 { Texture = < DesTexture >; Minfilter = LINEAR; Magfilter = LINEAR; }; sampler MaskSampler= sampler_state // 拼接图采样器 { Texture = ; Minfilter = LINEAR; Magfilter = LINEAR; }; sampler WindowSampler = sampler_state // 通道图采样器 { - 275 - Texture = < WindowTexture >; Minfilter = LINEAR; Magfilter = LINEAR; }; struct VS_OUTPUT // 顶点渲染输出结构 { float4 position : POSITION; float2 texCoord : TEXCOORD0; }; // 顶点渲染函数 VS_OUTPUT RenderSceneVS( float4 inPositionOS : POSITION, float2 inTexCoord : TEXCOORD0) { VS_OUTPUT Out; Out.position = mul( inPositionOS, g_mWorldViewProjection ); Out.texCoord = inTexCoord; return Out; } // 像素渲染函数 float4 RenderScenePS(VS_OUTPUT i ) : COLOR0 { // 对各个贴图进行采样 float4 windowCol= tex2D(WindowSampler ,i.texCoord); float4 scrCol= tex2D(ScrSampler ,i.texCoord); float4 DesCol= tex2D(DesSampler ,i.texCoord); // 这个整个代码的关键,实现效果的算法 float2 Changtx; Changtx=i.texCoord*0.333+0.333; Changtx.x*=offsetX; Changtx.y+=offsetY; // 以特定的方式对通道图进行采样 float4 maskCol= tex2D(MaskSampler,Changtx); // 计算最终的色彩输出 float4 finalCol=(scrCol*maskCol+DesCol*(1-maskCol))*windowCol; return finalCol; } technique tech { pass p { VertexShader = compile vs_2_0 RenderSceneVS(); PixelShader = compile ps_2_0 RenderScenePS(); } } - 276 - 这段代码没有陌生的地方,陌生的地方只是对贴图采样的算法,罗列出这个算法的一 些关键值出来,通过观察发现:它只是对通道图的中间部分进行来采样,左边部分和右边 部分完全被忽视掉了,如图 16.2.1_2 的通道分析图所示。再仔细观察会发现,切换前采样 的区域是中间部分的上部,切换开始后,慢慢通过插值采样的区域从中间上部慢慢过渡到 中部,最后切换完成时到了中间的下部。在采样上部时显示的是旧画面,当采样到中部时 候,就是两幅图各占一部分,当采样到下部是就完全是新画面了。 从结果上说变量 Changtx.x 的取值必须在 0.333~0.666 之间、以及 Changtx.y 的取值 必须在 0.0~1.0 之间,结果才会正确。为了切换的多样性给 Changtx.x 乘上+1 或者-1 让它 从不同的方向进行切换。 在使用这个效果的时候要注意 3D 模型的 UV 情况,作者之前使用的是 Virtools 自带资 源的 Floor.nmo 结果完全不对,怎么修改都有瑕疵;后来使用了 Plane 1x1.nmo 这个资源, 就正确了。有兴趣的读者可以把这两个模型的顶点的 UV 打印出来,对比一下就可以发现 原因。另外这个 shader 效果在 2D 帧上运行良好。详细参考例子程序例子 16.2.1_1.com 16.2.2 海底焦散 这一小节我们模拟浅海取的海底地形,当阳光透过海面照射到海底时,由于流动的海 面,使得光线折射变得“晃动”现漫折射,投影表面出现光子分散,这种现象就叫做焦 散。不过这是人们对自然现象的定义;而虚拟现实中我们的目的就是:产生水波纹的 光影效果(参看图16.2.2_1)。除了海底有焦散效果外,站在安静的澡堂水草边,站在 有着月亮倒影的水塘边,你的脸色也会有水波纹的光影效果。这些现象我们在虚拟世 界中都也可以焦散效果来模拟。 开雾效 关雾效 图16.2.2_1 如果读者能理解上一节的shader代码,那么这一节的shader代码就会轻而易举的 的明白。因为这个效果也是对透明通道贴图进行采样,然后对UV进行一些控制,最 后经过特定融合色彩的方式得到最终的效果。为了达到更生动的效果,使用了两张表 示焦散效果的通道图(参看图16.2.2_2),而不是一张;当采样时,在两张通道图高亮 的地方重合处,效果会表现出强光,反之亦然;这样一明一暗的交替出现,比起单张 焦散通道图要给人的感觉更生动些。 - 277 - 焦散图一 焦散图二 图16.2.2_3 实现焦散效果代码如下所示: float4x4 matWorldViewProj:WORLDVIEWPROJECTION; // 世界×视图×投影矩阵 float4x4 matWorld:WORLD; // 世界矩阵 float4x4 matWorldInv:WORLDVIEWI; // 世界×视图矩阵 float4 ColEmi:Emissive; // 材质的自发光颜色 float4 ColDif:Diffuse; // 材质的漫反射颜色 float timer : TIME; // 时间变量 float4 vecLightPos; // 光的位置 float4 vecLightColor; // 光的颜色 bool fog; // 是否开启雾效 float4 fogcolor; // 雾的颜色 float fognear = 100; // 雾的最近有效距离 float fogfar = 150; // 雾的最远有效距离 float waveSpeed; // 焦散的移动速度 float waveAplitude; // 焦散的变向速度 float scale; // 焦散疏密缩放 texture BaseTex; // 海底基本贴图 texture ShadowTex; // 海底阴影贴图 texture CausticsTex01; // 焦散贴图 texture CausticsTex02; // 焦散贴图 sampler sTex1 = sampler_state // 纹理采样设置 { Texture = ; MipFilter = Linear; MinFilter = Linear; MagFilter = Linear; AddressU = Wrap; AddressV = Wrap; }; - 278 - sampler sTex2 = sampler_state // 纹理采样设置 { Texture = ; MipFilter = Linear; MinFilter = Linear; MagFilter = Linear; AddressU = Wrap; AddressV = Wrap; }; sampler sTex3 = sampler_state // 纹理采样设置 { Texture = ; MipFilter = Linear; MinFilter = Linear; MagFilter = Linear; AddressU = Wrap; AddressV = Wrap; }; sampler sTex4 = sampler_state // 纹理采样设置 { Texture = ; MipFilter = Linear; MinFilter = Linear; MagFilter = Linear; AddressU = Wrap; AddressV = Wrap; }; // 定义着色器的输出信息结构 struct TMULTI_VS_OUT { float4 Pos : POSITION; float4 tColor : COLOR0; float2 Tex1 : TEXCOORD0; float2 Tex2 : TEXCOORD1; float2 Tex3 : TEXCOORD2; float2 Tex4 : TEXCOORD3; }; // 顶点渲染函数 TMULTI_VS_OUT TMulti_VS(float4 inPos : POSITION, float3 inNormal : NORMAL, float2 inTexCoord0 : TEXCOORD0) { TMULTI_VS_OUT Out; // 定义输出对象 // 物体表面光照计算 - 279 - Out.Pos = mul(inPos, matWorldViewProj); // 转换位置 float3 N = normalize(mul(inNormal, matWorldInv)); // 计算法线 float3 P = mul(inPos, matWorld); // 计算顶点位置 float3 L=normalize(vecLightPos-P); // 计算光方向 float diffuseLight = max(dot(N,L),0); // 计算光和法线的夹角 Out.tColor=ColDif*vecLightColor*diffuseLight+ColEmi; // 基本光照值 // 计算采样4张贴图的UV值 Out.Tex1 = inTexCoord0.xy; Out.Tex2 = inTexCoord0.xy; Out.Tex3 = float2(inTexCoord0.x+waveAplitude*sin(waveSpeed*timer), inTexCoord0.y+waveAplitude*cos(waveSpeed*timer))*scale; Out.Tex4 = float2(inTexCoord0.x+waveAplitude*cos(waveSpeed*timer), inTexCoord0.y+waveAplitude*sin(waveSpeed*timer))*scale; return Out; } // 像素渲染函数 float4 PS( TMULTI_VS_OUT In ) : COLOR { float4 tex_1 = tex2D(sTex1,In.Tex1); float4 tex_2 = tex2D(sTex2,In.Tex2); float4 causticsA = tex2D(sTex3,In.Tex3); float4 causticsB = tex2D(sTex4,In.Tex4); // 最终的色彩合成 float4 BaseCaustics = lerp( tex_1, ( causticsA + causticsB ) * 1.8, ( tex_1 * 1.2 )); BaseCaustics = ( causticsA + causticsB ) * BaseCaustics * 0.5*In.tColor*tex_2; return BaseCaustics; } technique tech { pass p { FogEnable = ; FogColor = ; FogDensity = 0; FogVertexMode = NONE; FogTableMode = LINEAR; FogStart = ; FogEnd = ; VertexShader = compile vs_2_0 TMulti_VS(); PixelShader = compile ps_2_0 PS (); } } 上面的代码的前部定义了19变量,看起来很多,但是都是我们前面的shader代码已经 使用过的,看到注释自然会猜的各个变量在什么地方参与计算。接着是4个纹理采样器,这 - 280 - 段代码也是滥熟的了。在定义渲染输出结构时,第一个位置分量、和第二个色彩分量,都是 常客,大部分的shader代码的输出结构都包含它们,因为这个是必须的——没有位置分量 着色器就不能渲染到正确的位置上;没有色彩分量着色器就不知道渲染什么颜色。第三分量 到第六个分量都是存储纹理UV信息。 在顶点渲染函数中,计算物体表面光照算法,我们在前面的第十四章已经出现过了。 在计算对焦散通道图进行采样纹理时使用的UV,这个算法在第十六章也就使用过了,都是 老面孔没什么好解释的。 在像素渲染函数中,先是将海带的基本贴图和焦散通道图进行插值融合得到一个颜色 值,注意有两张焦散通道图,所有要先和再参与计算。最后再乘上之前计算的光照颜色值和 阴影值得到最终的色彩。上面的公式中还出现了一些常数,这些是调节各个阶段的色彩值使 用的。 16.2.3 画面模糊效果 在冬天里,我们对着镜子哈一口气,镜子中自己的那种脸模糊了。这个就是本小节要 学习的内容。既然是要模糊画面,所以我们的 shader 就不是用在一个材质上,因为材质依 据网格而生,而网格就只是一个 3D 实体的点线面,无法表示屏幕画面。所以必须是等到 所以渲染物体渲染到屏幕后,再对这个最后的屏幕画面运用 shader 进行处理,以达到模糊 效果。这个过程也许就是网上所说的:图像后处理! 实现这个模糊的数学算法很简单,其实就是:获取画面中某个像素的临近的像素颜色 值,按平均的比例求和,然后将这个颜色作为最终颜色返回。 临近的像素:我们就提取当前像素的左上、右上、左下和右下的 4 个像素够了。 按平均的比例求和:我们就把每个像素 0.25 倍值相加,4 个像素点加起来就是 1。 看完上面的解释,感觉这是一个简单的技术吧,虽然简单,但如果不参考下面的代码, 马上自己去研究写一个,不一定能得到实例的效果,还是先来看一下 shader 代码吧: texture tex : TEXTURE0; //要模糊的贴图,可以理解为屏幕画面 float2 texsize ; //??? float blurFactor; //UV采样系数调整 float blurAngle; //控制采样的临近像素位置 float blurBurnFactor; //平均比例系数 //几个静态预处理函数 //调整UV采样结果 static float2 texSizeFactor = texsize * blurFactor * 10; //要采样左上、右上、左下和右下的4个像素的UV坐标技术 static float2 UVOffset0 = float2( cos(blurAngle), -sin(blurAngle) )*texSizeFactor; static float2 UVOffset1 = float2(-sin(blurAngle), -cos(blurAngle) )*texSizeFactor; static float2 UVOffset2 = float2(-cos(blurAngle), sin(blurAngle) )*texSizeFactor; static float2 UVOffset3 = float2( sin(blurAngle), cos(blurAngle) )*texSizeFactor; //定点渲染输出结构 struct VSOUT { float4 pos : POSITION; //顶点位置 float2 tex0 : TEXCOORD0; //保存右上角UV坐标值 - 281 - float2 tex1 : TEXCOORD1; //保存左下角UV坐标 float2 tex2 : TEXCOORD2; //保存左上角UV坐标 float2 tex3 : TEXCOORD3; //保存右下角UV坐标 }; //顶点渲染函数 VSOUT BlurVS( float4 Pos : POSITION, float2 Tex0 : TEXCOORD0 ) { VSOUT vsout; //声明一个输出结果变量 vsout.pos = Pos; //位置 vsout.tex0 = Tex0+UVOffset0; //UV坐标偏移量计算 vsout.tex1 = Tex0+UVOffset1; vsout.tex2 = Tex0+UVOffset2; vsout.tex3 = Tex0+UVOffset3; return vsout; } //纹理采样器 sampler texSampler = sampler_state { texture = ; Minfilter = LINEAR; Magfilter = LINEAR; AddressU = CLAMP; AddressV = CLAMP; }; //像素渲染函数 float4 BlurPS( VSOUT PSIn ) : COLOR { //对附近的进行采样 float4 col0 = tex2D( texSampler , PSIn.tex0 )*blurBurnFactor; float4 col1 = tex2D( texSampler , PSIn.tex1 )*blurBurnFactor; float4 col2 = tex2D( texSampler , PSIn.tex2 )*blurBurnFactor; float4 col3 = tex2D( texSampler , PSIn.tex3 )*blurBurnFactor; float4 finalColor = col0+col1+col2+col3; return finalColor; } technique tech { pass p - 282 - { VertexShader = compile vs_1_1 BlurVS(); PixelShader = compile ps_1_1 BlurPS(); } } 代码很简单,其中的注释已经写得很明白。接着的问题是如何得到当前屏幕画面?使 用“Render Scene in RT View”这个BB,只需要给这个BB的第一个参数传入一张纹理,其他 保持默认,如图16.2.3_2所示。这个BB的作用就是将屏幕画面通过自定义的参数,渲染到一 张图片上。这张图片就是要传递给上面的shader代码进行处理的图片。经过shader处理完后 的图片,如何又放回到屏幕上呢?使用“Combine RT Views”这个BB,具体参数设置如图 如图16.2.3_3所示。这个两个BB在“图像后处理”技术应用中会经常用到,这个模糊效果的 Virtools脚本如图16.2.3_1所示。 有时候在进行“图像后处理”时,会有些效果没处理到,比如:贴花(车痕迹)。其原 因是已经后处理结束后,贴花才渲染到屏幕上。解决的办法就是,把贴花的BB放到正两个 BB的前面,问题就可以解决了。 现在最后的问题就只剩下,如何执行上面的shader代码,往常我们都是把shader材质上 执行,而“图像后处理”的shader缺不是放在材质是上,而是作为参数传递给BB“Combine RT Views”来执行,如图16.2.3_3所示(参看shader参数、Technique参数)。由于不同的shader 输入参数不一样,这个BB可以根据shader的不同,生成不同的参数,方法是右键这个BB, 再弹出菜单中选择“Build Exposed Params:”项。 图16.2.3_1 图16.2.3_2 - 283 - 图16.2.3_3 这样简单的模糊效果只能勉强接受,如图16.2.3_5所示;为了达到比较好的效果可以多 次进行模糊,即把上一次模糊的结构,再用同样的方式在模糊几次,模糊4次效果就好很多 了,如图 16.2.3_5所示。要模糊4次,就要多创建一张贴图,用 于保存每次模糊的结果图像。 具体的Virtools脚步如图16.2.3_4所示。 图16.2.3_4 图16.2.3_5 图16.2.3_6 整个模糊的效果技术讲解就完了,详细可以参看“例子16.2.3_1.cmo”。 - 284 - 16.2.4 景深效果(Depth of Field) 回想一下在中学学习物理的时候,讲到凸透镜的光学规律,其实人的眼睛也就是一个 凸透镜,当光线通过眼睛时聚焦在焦点上。当景物成像在这个焦点外时,物体会变得模糊, 但是在这个焦点的某一个特定的距离内(正负偏移),影像模糊的程度是肉眼无法 察觉的,这段距离称之为景深(距离)。关于景深的具体概念和算法,大家自行到 网上搜索学习一下,由于篇幅问题就不细究了。具体实现的效果如下图所示: 图 16.2.4_1 开景深效果 图 16.2.4_2 关景深效果 注意观察上面两幅图,开了景深效果后,只有人头处是清晰的,后面的火箭和前面的 坦克炮管是模糊的。 要先实现这种效果首先需要知道场景中任一点的深度,这可以使用深度缓冲实现。深 度缓冲(即 Z 缓冲)可以看成一张包含场景的灰度图,灰度的值表示物体距离相机的远近。 场景深度缓冲看起来应是图 16.2.4_3 所示: 图 16.2.4_3 将深度缓冲图与上面的开景深效果图对比分析发现:黑色部分是没有模糊的,颜色越 白处,模糊就越深。场景的模糊技术我们沿用上一小节的 shader 代码实现。之前为了模糊 4 次,多创建一张贴图,用于保存每次模糊的结果图像;现在我们需要在焦点偏移量之外 (景深距离外围)显示模糊过的场景,落在焦点上(即景深距离范围内)的绘制正常 的场景,两者之间的场景则需要对这两张纹理进行混合。所以要多创建一张纹理包含原始 的场景,然后复用一张模糊处理使用的纹理来保存深度缓冲信息(即图 16.2.4_3 的内容)。 下面是获得深度缓冲信息的 shader 代码: // 将深度缓冲信息写到纹理中 float4x4 wv : WORLDVIEW; float4x4 wvp : WORLDVIEWPROJECTION; float FocalDist; float FocalScale; - 285 - //静态变量 static float focalDist = FocalDist*40; static float focalScale = FocalScale*0.1f; //定点渲染输出结构体 struct VSOUT { float4 pos : POSITION; float4 col : COLOR; }; //定点渲染函数 VSOUT CartoonVS( float3 pos:POSITION ) { VSOUT vsout; vsout.pos = mul(float4(pos,1),wvp); //获得深度缓冲信息 vsout.col = abs(focalDist-mul(float4(pos,1),wv).z)*focalScale; return vsout; } technique tech { pass p { VertexShader = compile vs_1_1 CartoonVS(); } } 上述的代码很简单,所以关键在于这一行代码,即获得深度缓冲信息算法: vsout.col = abs(focalDist-mul(float4(pos,1),wv).z)*focalScale; 其中 focalDist 是焦点到摄像机的距离,mul(float4(pos,1),wv).z 是物体的定点到摄像 机的距离;这两个值的相减后取绝对值的目的是得到点到焦点的偏移量。最后再乘以 focalScale 的目的是决定淡入模糊的程度。如果有读者参考了网上的一些景深算法,会发 现有点区别。其实没有区别,只是 Virtools 里面把部分算法省略,而把省略算法的结果用 参数 focalDist 和 focalScale 来表示,大家只要调节这两个参数,效果也会随之变化,结果 是一样的,这样计算量还小一些。得到深度缓冲信息后,就是要进行多张纹理融合的算法 了,这个算法很简单只是注释而已,具体 shader 代码如下所示: // 景深效果算法 texture texBlur : TEXTURE0; //模糊贴图 texture texZInfo : TEXTURE1; // z缓冲贴图 texture texColor : TEXTURE2; //原始贴图 //定点渲染输出结构体 struct VS_OUTPUT { float4 Pos : POSITION; float2 UVBlur : TEXCOORD0; - 286 - float2 UVZInfo : TEXCOORD1; float2 UVColor : TEXCOORD2; }; //定点渲染函数 VS_OUTPUT DOFCombineVS( float4 Pos : POSITION, float2 UV0 : TEXCOORD0 ) { VS_OUTPUT Out; //基本的定点变换 Out.Pos = Pos; Out.UVBlur = UV0; Out.UVZInfo = UV0; Out.UVColor = UV0; return Out; } //纹理采样器 sampler texBlurSampler = sampler_state { texture = ; Minfilter = LINEAR; Magfilter = LINEAR; }; sampler texZInfoSampler = sampler_state { texture = ; Minfilter = LINEAR; Magfilter = LINEAR; }; sampler texColorSampler = sampler_state { texture = ; Minfilter = LINEAR; Magfilter = LINEAR; }; //像素渲染函数 float4 DOFCombinePS( VS_OUTPUT PSIn ) : COLOR { float4 Blur = tex2D( texBlurSampler, PSIn.UVBlur ); //采样模糊贴图 float4 ZInfo = tex2D( texZInfoSampler, PSIn.UVZInfo ); //采样z缓冲贴图 float4 Color = tex2D( texColorSampler, PSIn.UVColor ); //采样原始贴图 //根据缓冲信息决定模糊和原始画面的融合比例 return lerp( Color, Blur, ZInfo.r ); } technique tech - 287 - { pass p { VertexShader = compile vs_1_1 DOFCombineVS(); PixelShader = compile ps_1_1 DOFCombinePS(); } } 这个效果也属于屏幕后处理技术,所以脚本和上一小节的脚本有点相似,如下图所示 (具体参考例子程序 16.2.4_1.cmo): Virtools 脚本内容大部分与上一节的脚本一致,只是多创建了一张纹理和多使用了一 个“Render Scene in RT View”和一个“Combine RT Views”的 BB。我们获得场景深度信 息也是“Render Scene in RT View”这个 BB 来获得的,这个 BB 其他参数保持默认,只是 在参数 Overriding Material:要填入一张材质(例子中材质名为:DOF Z Info),并且这个材 质要添加应用获得深度缓冲算法的 shader 代码。这个参数的意义是:强迫场景中所以的渲 染物体,都使用这个材质渲染一次。 最后使用 “Combine RT Views”这个 BB,将 shader 参数和纹理参数填好就行了,他 的最终的目的是:将传入的三张纹理,根据指导的 shader 代码处理后,把结果渲染到屏幕 上。 有些人会发现一个变化,就是如果改变窗口大小以后(原来默认是 1024×768,),比如 说 1280×720,运行发现画面拉伸变形了。解决的问题是把创建的图片是 2 的 n 次方,并 大于屏幕窗口。然后涉及 RT View 的参数,都显示的填写 1280×720 的值,就行了。详细 参看 Virtools 脚本内容,就很容易明白。Virtools 自带的例子不是我研究出的这种方法,他 们是修改矩形的前两个参数值,并且使用 512 的贴图,有时候画面不清晰,而且不能理解 它的两个参数值如何确定的。建议读者还是使用我的这种方法来处理画面拉伸问题。 16.2.5 地球大气效果 在浩瀚的宇宙空间中近距离观察地球,地球是被大气所覆盖的,看起来如图 16.2.5_1 所示。这个效果分成两个部分,一个是渲染球体表面大气效果,球体表面大气的特征是越 往中间气候的效果越淡,越往球的边缘气候效果越强;另外一个是渲染大气面片效果,大 气面片表示球体外的气候扩散区域,这个面片是一直面向摄像机的,就像广告板技术一样, 气候的区域越往外越薄。 - 288 - 图 16.2.5_1 给球体附上一成颜色很简单,但是球体的中间是颜色很淡,周围较深,并且球体在自 转的时候,大气却不能旋转,相机改变观察角度的时候,效果也是一样的,否则看起来太 假了。用顶点法线和相机到顶点的向量夹角是可以用来来判断大气厚薄的区域的,详细带 代码如下所示: float4x4 matWVP : WORLDVIEWPROJECTION; //顶点转换合矩阵 float4x4 matWorld : WORLD; //世界矩阵 float4x4 matInvTransposeWorld : INV_TWORLD; //世界的逆矩阵 float3 wCam : CamPos; //摄像机位置 float4 AtmosphereColor; //大气颜色 texture ColorMap : TEXTURE; //星球贴图 sampler ColorMapSampler = sampler_state //星球贴图采样器 { Texture = ; MinFilter = Linear; MagFilter = Linear; MipFilter = Linear; }; texture Atmosphere; //大气贴图 sampler AtmosphereSampler = sampler_state //大气贴图采样器 { Texture = ; MinFilter = Linear; MagFilter = Linear; MipFilter = Linear; AddressU = CLAMP; }; struct VS_OUTPUT //顶点渲染输出结构 { float4 Pos : POSITION; //顶点空间位置 - 289 - float2 Tex : TEXCOORD0; //顶点UV float3 Normal : TEXCOORD1; //顶点法线 float3 View : TEXCOORD2; //顶点到相机的向量 }; //顶点渲染函数 VS_OUTPUT VS ( float4 Pos : Position, //输入顶点渲染位置 float4 Tex : TEXCOORD0, //输入顶点的UV float4 Normal : NORMAL) //输入顶点的法线 { VS_OUTPUT Out=(VS_OUTPUT)0; //定义输出结构对象 Out.Pos = mul (Pos, matWVP); //顶点转换 float4 wPos = mul (Pos, matWorld); //顶点转换到世界空间 Out.View = wCam-wPos; //相机到顶点的向量 //法线向量转换到逆反的世界空间,这是为了方便与相机到顶点向量计算。 Out.Normal = normalize (mul (Normal, matInvTransposeWorld)); Out.Tex = Tex; //得到UV return Out; //返回结果 } //带贴图的气候像素渲染函数 float4 PS_Texured (VS_OUTPUT In) : COLOR { float4 colTerrain = tex2D (ColorMapSampler, In.Tex); //采样星球表面贴图 float4 colAtmosphere = tex2D (AtmosphereSampler, float2 (1,0)); //采样气候贴图 float fFalloff = 1-dot (normalize (In.View), normalize (In.Normal)); //计算颜色融合值 return lerp (colTerrain, colAtmosphere, fFalloff); //按fFalloff值融合两个贴图颜色 } //带单一颜色值的气候像素渲染函数 float4 PS_OneColor (VS_OUTPUT In) : COLOR { float4 colTerrain = tex2D (ColorMapSampler, In.Tex); //采样星球表面贴图 float fFalloff = 1-dot (normalize (In.View), normalize (In.Normal)); //计算颜色融合值 return lerp (colTerrain, AtmosphereColor, fFalloff); //按fFalloff值融合两个贴图颜色 } technique Texured //带气候贴图渲染的球体 { pass p { VertexShader = compile vs_2_0 VS(); PixelShader = compile ps_2_0 PS_Texured(); } } technique OneColor //带单一气候颜色值渲染的球体 { pass p - 290 - { VertexShader = compile vs_2_0 VS(); PixelShader = compile ps_2_0 PS_OneColor (); } } 上面的 shader 代码有两个渲染技术,像素渲染函数也有两个,一个是采样贴图来表 示大气色彩,一个是使用单一颜色来表示大气色彩,其实可以把它们写在一个渲染函数中, 只要增加一个变量来区别调用即可,这个就留給有兴趣的读者来做的吧。整个代码没有什 么新意的地方,唯一可以讲解的就是两个像素融合的算法,计算摄像机到顶点的向量,这 个是在世界空间中的向量,而顶点的法线是在物体的局部空间中,所以要把顶点法线转换 到世界空间中,通过 mul 函数转换到逆反的世界空间中,只是为了与摄像机到顶点向量的 参照坐标系一致,以便于计算。通过向量的点乘求得交角,根据夹角的值来确定两个像素 值的融合。无论是使用大气贴图还是使用单一颜色表示大气,没有区别,只是在进行最终 融合两色彩值的时候函数 lerp 的第二个参数值不同而已。 球体的渲染已经完成,下面来看看大气层面片的表现,其实这个大气层面片不使用 shader 也行,只要美工做好一个贴图替换它进行,只是这样做不方便调节。大气面片 shader 代码如下所示: float4x4 matWVP : WORLDVIEWPROJECTION; //顶点转换合矩阵 float4x4 matWorld : WORLD; //世界矩阵 float4x4 matInvTransposeWorld : INV_TWORLD; //世界的逆矩阵 float PlanetRadius; //星球半径 float AtmosphereRadius; //大气层半径 float4 AtmosphereColor; texture ColorMap : TEXTURE; //大气贴图半径 sampler ColorMapSampler = sampler_state //大气贴图采样器 { Texture = ; MinFilter = Linear; MagFilter = Linear; MipFilter = Linear; AddressU = CLAMP; }; struct VS_OUTPUT //顶点渲染输出结构 { float4 Pos : POSITION; //顶点空间位置 float2 Tex : TEXCOORD0; //顶点UV }; //顶点渲染函数 VS_OUTPUT VS ( float4 Pos : Position, //输入顶点渲染位置 float4 Tex : TEXCOORD0, //输入顶点的UV float4 Normal : NORMAL) //输入顶点的法线 { VS_OUTPUT Out=(VS_OUTPUT)0; //定义输出结构对象 Out.Pos = mul (Pos, matWVP); //顶点转换 - 291 - Out.Tex = Tex; //得到UV return Out; //返回结果 } //带贴图的气候像素渲染函数 float4 PS_Textured (VS_OUTPUT In) : COLOR { float a = AtmosphereRadius; //得到星球半径 float p = PlanetRadius; //得到大气层半径 //缩放p和a两个值到纹理空间中(纹理空间只在0到1之间) p=p/a*0.5; //大气半径大于星球半径,求比例 a=0.5; //星球半径在纹理空间中设置成0.5 //计算纹理空间中的要采样的UV值 float fAltitude = 1-( (length(In.Tex-float2 (0.5,0.5)) - p) / (a-p)); //用计算得到的UV值对大气贴图进行采样 float4 colFinal = tex2D (ColorMapSampler, float2 (saturate (fAltitude), 0)); colFinal.a = fAltitude-0.1; //控制大气散射的Alpha值 return colFinal; } //带单一颜色值的气候像素渲染函数 float4 PS_OneColor (VS_OUTPUT In) : COLOR { float a = AtmosphereRadius; //得到星球半径 float p = PlanetRadius; //得到大气层半径 //缩放p和a两个值到纹理空间中(纹理空间只在0到1之间) p=p/a*0.5; //大气半径大于星球半径,求比例 a=0.5; //星球半径在纹理空间中设置成0.5 //计算纹理空间中的要采样的UV值 float fAltitude = 1-( (length(In.Tex-float2 (0.5,0.5)) - p) / (a-p)); float4 colFinal = AtmosphereColor; //直接使用输入的颜色值 colFinal.a = fAltitude-0.1; //控制大气散射的Alpha值 return colFinal; } technique Textured { pass p { VertexShader = compile vs_1_1 VS(); AlphaBlendEnable = true; //开启alpha混合 SrcBlend = SrcAlpha; //--关于Alpha详细参考本书5.2章节 DestBlend = InvSrcAlpha; //--关于Alpha详细参考本书5.2章节 PixelShader = compile ps_2_0 PS_Textured(); } } - 292 - technique OneColor { pass p { VertexShader = compile vs_1_1 VS(); AlphaBlendEnable = true; //开启alpha混合 SrcBlend = SrcAlpha; //--关于Alpha详细参考本书5.2章节 DestBlend = InvSrcAlpha; //--关于Alpha详细参考本书5.2章节 PixelShader = compile ps_2_0 PS_OneColor(); } } 上述代码中采样纹理时,使用的 UV 计算方式有点特别,其实它实现的功能是:以圆 面积的方式采用一张正方形的贴图。本书例子程序 16.2.5_1.cmo,仔细观察表示大气效果 的 3D 物体,可以发现大气面片其实只是一张控制 alpha 值的纹理贴图而已,由于纹理 UV (或者说是纹理空间)的取值范围从 0 到 1,中心点是(0.5,0.5)。当然传入星球半径和大 气层半径时,就要转换到 0 到 1 的范围内(即纹理空间内),通过先求得大气半径与星球 半径两者的比值,然后假设星球半径是 0.5,大气半径自然就是比值乘以 0.5 了,这样就 可以轻易的转换纹理空间内,详细参看代码中的参数 p 和 a。由于是要以圆面积的方式采 样,顶点的 UV 要减去(0.5,0.5),然后通过 length 函数求得顶点到中心的半径,大气层 扩散的半径只有 p,两者相减的结果再除以两半径的差值就是没有大气的要被透明掉的区 域,公式为:( (length(In.Tex-float2 (0.5,0.5)) - p) / (a-p)),最后由 1 减去这个值就是真正 的大气区域 alpha 值了。详细请参看本书例子程序 16.2.5_1.cmo。 16.3 简单的几个其他效果 16.3.1 Gamma burn(灼烧) 有时候,我们希望场景的亮度更亮一些(比如被太阳烤焦的大地),当然我们可以调节 显示器的亮度(这不是一个好方法),也可以让美工重新处理一些贴图。但是如果我们同一 个场景内某些地方需要亮一些,某些地方则不需要,并且场景中使用的贴图较多时,美工修 改起来会很不情愿。这一节我们就来实现用shader代码对整个画面的亮度控制,其效果如下 图所示: 图 16.3.1_1(正常) 图 16.3.1_2(burn) 这种效果在某些游戏场景中,比如从黑暗的山洞中快速冲出洞口时,在洞口处使用一 个渐变的 Gamma Burn 来表示刺眼的效果。这个 shader 的主要算法是:首先是获得当前窗 - 293 - 口渲染的画面内容,把他保存到一张纹理上,然后采样这张纹理将颜色值乘以一个系数得 到一个新的颜色值,最后把这个新的颜色值再贴回到窗口上,形成最终的效果,具体代码 如下所示: texture tex0 : TEXTURE0; //存放当前画面的内容的纹理 float BurnAmount; //控制画面亮度调节的浮点数 //顶点渲染输出结构体 struct VS_OUTPUT { float4 Pos : POSITION; //位置向量 float2 UV0 : TEXCOORD0; //UV信息 }; //顶点渲染函数 VS_OUTPUT BurnVS( float4 Pos : POSITION, float2 UV0 : TEXCOORD0 ) { VS_OUTPUT Out; //定义一个返回结构类型的变量 Out.Pos = Pos; //得到位置 Out.UV0 = UV0; //得到UV坐标 return Out; } //设置纹理采样器 sampler tex0Sampler = sampler_state { texture = ; Minfilter = LINEAR; Magfilter = LINEAR; MipFilter = LINEAR; }; //渲染渲染函数,Gamma burn渲染函数 float4 BurnPS( VS_OUTPUT PSIn ) : COLOR { float4 col0 = tex2D( tex0Sampler, PSIn.UV0 ); //对当前画面信息纹理进行采样 return col0 *BurnAmount; //乘以一个系数,表示burn的结果 } //渲染技术 technique tech { pass p { VertexShader = compile vs_2_0 BurnVS(); PixelShader = compile ps_2_0 BurnPS(); } } 这个效果同上一节的“景深效果”一样,也属于“屏幕后处理效果”,同理需要“Render - 294 - Scene in RT View”和 “Combine RT Views”这两个BB协调工作才能完成最终效果的实现, 一个是把画面渲染到一张纹理中;一个是把处理好的纹理渲染到屏幕上。具体Virtools脚本 如图图16.3.1_3所示,具体请参考例子程序16.3.1_1. cmo。 图16.3.1_3 16.3.2 Glow(发光) Glow 是模拟有发光特性的物体,但是这个发光不能照亮其他物体,他只是模拟自身发 光的表现。如图 16.3.2_2 所示: 图 16.3.2_1(关 Glow) 图 16.3.2_2(开 Glow) Glow 的 shader 代码实现原理是:如果某个像素为白色,那么它会向旁边的像素传染 它的白色,就感觉像自己在“发光”那样。所以第一步要获得画面中要发光的白色像素; 第二步把这些发光的白色像素向四周扩散出去,周围被感染的像素也就会带上白色,只是 比中心的白色淡一些,然后它也继续想四周传染下去,它周围的像素白色会更弱一些,如 此以往到最后就没有影响了;第三步就是将原始的画面和扩散后的白色发光区域融合在画 面上。把发光的白色像素向四周扩散出去就使用 16.2.3 小节的模糊 shader 就能实现了。 要想得到画面中发光的白色像素,我们用了一个取巧的办法,就是:先把怎个渲染画 面渲染到一张纹理上,这也是原始画面。然后采样这张纹理,将得到的每个像素减去一个 系数(0.8)就可以得到一个没钱其他色彩的灰度图,然后我们将这个灰度值扩大一定倍数 (5 倍)让这个灰度变得更亮一些,这样就是我们想要的发光的白色像素贴图。将这张贴 图经过第二步的模糊处理后,与原始色彩相加就得到最终效果。具体实现是 shader 代码如 下所示: //--- Additional Automatic Parameters texture texBright: TEXTURE0; //存放发光的白色像素的贴图 texture texColor : TEXTURE1; //原始的画面贴图 float GlowIntensity; //发光程度调节系数 //顶点渲染输出结构体 - 295 - struct VS_OUTPUT { float4 Pos : POSITION; //位置分量 float2 UVBright: TEXCOORD0; //采样发光贴图的UV float2 UVColor : TEXCOORD1; //采样原始的画面贴图的UV }; //顶点渲染函数 VS_OUTPUT StandardVS( float4 Pos : POSITION, float2 UV0 : TEXCOORD0 ) { VS_OUTPUT Out; //定义一个返回的变量结构 //基本的顶点投影变换 Out.Pos = Pos; Out.UVBright= UV0; //使用顶点的UV Out.UVColor = UV0; //UV值都一样,其实不需要多一个分量 return Out; } //设置纹理采样器 sampler texBrightSampler = sampler_state { texture = ; Minfilter = LINEAR; Magfilter = LINEAR; }; sampler texColorSampler = sampler_state { texture = ; Minfilter = LINEAR; Magfilter = LINEAR; }; //像素渲染函数——两张贴图融合 float4 GlowCombinePS( VS_OUTPUT PSIn ) : COLOR { float4 Bright = tex2D( texBrightSampler, PSIn.UVBright ); float4 Color = tex2D( texColorSampler, PSIn.UVColor ); //将两张最终效果的贴图只是进行直观的叠加,就是最终的效果。 return Color+Bright*2*GlowIntensity; } //像素渲染函数——得到发光的像素 float4 DrawBrightColorsPS( VS_OUTPUT PSIn ) : COLOR { float4 Color = tex2D( texColorSampler, PSIn.UVColor ); //将得到的颜色值减去一个系数就能得到这个颜色的灰度值 float tmp = Color-0.8; - 296 - //连续加5次而不是tmp*5,是因为ps_1_1不支持,到了ps_2_0 才支持 return tmp+tmp+tmp+tmp+tmp; } //得到发光的贴图的渲染技术 technique DrawBrightColor { pass p { VertexShader = compile vs_1_1 StandardVS(); PixelShader = compile ps_1_1 DrawBrightColorsPS(); } } //融合两张图片的渲染技术 technique CombineBluredBrightAndScreenRendering { pass p { VertexShader = compile vs_1_1 StandardVS(); PixelShader = compile ps_1_1 GlowCombinePS(); } } 可见这个 shader 的关键支持是知道颜色值减去一个系数可以得到一个灰度图,相反如 果一个颜色值加上一个系数就可以整体正确亮度。其他地方都是我们已经学过的。为了观 察效果,我在场景中渲染了一些白色的线框,参考 Virtools 脚本的“ShowObjFrame”这个 BG。如果把这个 BG 放到“Combine RT Views”系列 BB 的后面或者放到其他脚本离开这 个循环连线,会发现线框没有渲染出来。这是一个很值得注意的地方,因为:这几节讲的 效果是“屏幕后处理”技术,所有它们必须放到最后执行。所以“ShowObjFrame” 这个 BG 放在了“Combine RT Views”系列 BB 的前面。 另外,同前面章节一样,在使用了“屏幕后处理”技术的 shader 时,发生了画面拉伸 的情况,请注意纹理大小的调节、和参数的设置,详细参考例子程序 16.3.2_1. cmo。为了 便于观察中间过程的图片处理效果,我做了一些小设置,读者只要间隔的按几下空格键就 可以观察到原始贴图,和发光区域贴图、扩散后的发光区域贴图的效果。另外作者的渲染 视口是 1280×720。如图读者设置的不是这个大小可以修改成这个大小便于观察,或者修 改 BB 的参数即可。 图 16.3.2_3 - 297 - 16.3.3 Bloom(绽放) Bloom 也是一个屏幕后处理的 shader 处理,它在有光的场景中使用会给场景一种“锦 上添花”的效果,如图 16.3.4_1 和图 16.3.4_2 对比所示(注意看铁塔部分)。其实他的原理 简单一点说是:先把整个画面渲染到一张纹理上,然后根据这张纹理换算出,场景的亮度 图,把这个亮度图进行模糊处理后,再和原图按比例混合,最后渲染到屏幕上。 图 16.3.4_1(关 Bloom) 图 16.3.4_2(开 Bloom) 渲染到纹理的图片是场景的颜色值,如何从中获得颜色的亮度值呢?这个涉及图形学 的知识,但这不是本书的内容,这里只给出 RGB 与 YUV 的颜色空间的转换公式,如下所 示: Y = 0.299 * R + 0.587 * G + 0.114 * B U = -0.147 * R - 0.289 * G + 0.436 * B = 0.492 * (B - Y) V = 0.615 * R - 0.515 * G - 0.100 * B = 0.877 * (R - Y) R = Y + 1.140 * V G = Y - 0.394 * U - 0.581 * V B = Y + 2.032 * U 其中 Y 表示物体的明亮度,U 和 V 色度;至于 RGB 就不要多说了。如需了解更多,请参 考数字图像处理方面的书籍。得到了亮度图后还要对黑色像素进行过滤,具体 shader 代码 如下所示: // 参照Wolfgang Engel先生所写的《Programming pixel and vertex shaders》 一书 float fGlareThreshold=0.6; //光的强度 float fGlareIntensity = 2; //光的密度 texture RenderMap : TEXTURE0; //创建的原始纹理贴图 sampler RenderMapSampler = sampler_state //纹理采样器 { Texture = ; MagFilter = LINEAR; MinFilter = LINEAR; AddressU = CLAMP; AddressV = CLAMP; }; struct oVertex //顶点渲染输出结构 { float4 Pos : POSITION; float2 Tex : TEXCOORD0; - 298 - }; //简单的顶点渲染函数,主要是获得UV oVertex VS( float4 Pos : POSITION, float2 Tex : TEXCOORD0) { oVertex Out = (oVertex)0; Out.Pos = Pos; Out.Tex = Tex; return Out; } //像素渲染函数,根据像素值转换到亮度。 float4 PS(oVertex In) : COLOR0 { float4 RGBA = tex2D (RenderMapSampler, In.Tex); // float3 (0.299, 0.587, 0.114)是前面公式提到的获得Y的亮度因子,其他因子没有使用 float Luminance = dot (RGBA, float3 (0.299, 0.587, 0.114)); //将得到的亮度值作为后面渲染融合的alpha值。 RGBA.a = Luminance; //将得到的亮度值通过参数进行调节,已达到效果,其中saturate的作用是把颜色值// 制在0到1的范围内, RGBA = saturate (RGBA - fGlareThreshold) * fGlareIntensity; return RGBA; } technique Tech { pass p { VertexShader = compile vs_1_1 VS(); PixelShader = compile ps_2_0 PS(); } } 上面的代码得到画面的亮度区域后,接下来是将这个亮度图进行模糊处理,然后再和 原始画面进行混合。模糊处理的 shader 代码可以采样之前章节使用的模糊代码,也可以使 用《Programming pixel and vertex shaders》书中的模糊代码,如下所示。两者大同小异, 具体可以参考例子代码,这里就不再多说了。只是提醒一点就是使用不同的模糊代码,相 关的 BB 的参数要设置不同。 float2 pixelSize; float fIteration; texture RenderMap : TEXTURE0; sampler RenderMapSampler = sampler_state { Texture = ; MagFilter = LINEAR; - 299 - MinFilter = LINEAR; AddressU = CLAMP; AddressV = CLAMP; }; struct oVertex { float4 Pos : POSITION; float2 TopLeft : TEXCOORD0; float2 TopRight : TEXCOORD1; float2 BottomRight : TEXCOORD2; float2 BottomLeft : TEXCOORD3; }; oVertex VS( float4 Pos : POSITION, float2 Tex : TEXCOORD0) { oVertex Out = (oVertex)0; Out.Pos = Pos; float2 halfPixelSize = pixelSize / 2.0; float2 dUV = (pixelSize.xy * fIteration) + halfPixelSize.xy; // 采样左上像素的UV Out.TopLeft = float2 (Tex.x - dUV.x, Tex.y + dUV.y); // 采样右上像素的UV Out.TopRight = float2 (Tex.x + dUV.x, Tex.y + dUV.y); // 采样右下像素的UV Out.BottomRight = float2 (Tex.x + dUV.x, Tex.y - dUV.y); // 采样坐下像素的UV Out.BottomLeft = float2 (Tex.x - dUV.x, Tex.y - dUV.y); return Out; } float4 PS(oVertex In) : COLOR0 { float4 addedBuffer = 0.0; // 采样左上像素 addedBuffer += tex2D (RenderMapSampler, In.TopLeft); // 采样右上像素 addedBuffer += tex2D (RenderMapSampler, In.TopRight); // 采样右下像素 addedBuffer += tex2D (RenderMapSampler, In.BottomRight); // 采样坐下像素 addedBuffer += tex2D (RenderMapSampler, In.BottomLeft); // 结果取平均值 return addedBuffer*=0.25; - 300 - } technique Tech { pass p { VertexShader = compile vs_1_1 VS(); PixelShader = compile ps_1_1 PS(); } } 原始图像通过BB就能得到,绽放后的区域图通过上面的shader也已经得到,最后只剩 下这两种图像如何融合在一起了。两个像素的融合算法归根结底无非就是如何加减乘除,但 就是这个加减乘除可以拉开效果上的很大差距。请看下面的代码: //参照Wolfgang Engel先生所写的《Programming pixel and vertex shaders》 一书 //绽放效果调节 float ScreenRatio; //窗口大小的纵横比 texture RenderMap : TEXTURE0; //使用的原始纹理贴图 sampler RenderMapSampler = sampler_state //原始图像采样器 { Texture = ; MagFilter = LINEAR; MinFilter = LINEAR; AddressU = CLAMP; AddressV = CLAMP; }; texture BlurredImage : TEXTURE1; //使用的被模糊的亮度纹理贴图 sampler BlurredImageSampler = sampler_state //模糊的亮度纹理采样器 { Texture = ; MagFilter = LINEAR; MinFilter = LINEAR; AddressU = CLAMP; AddressV = CLAMP; }; //顶点渲染输出结构 struct oVertex { float4 Pos : POSITION; //顶点位置 float2 Tex1 : TEXCOORD0; //UV1 float2 Tex2 : TEXCOORD1; //UV2 }; //简单的顶点渲染函数,主要是获得UV oVertex VS( float4 Pos : POSITION, float2 Tex : TEXCOORD0) - 301 - { oVertex Out = (oVertex)0; Out.Pos = Pos; Out.Tex1 = Tex; //两套UV是一样的,其实只需要一套 Out.Tex2 = Tex; return Out; } //根据窗口大小的纵横比,求得实际采样纹理的UV。 float2 DoAspectRatioCorrection (float2 Tex) { return float2 (Tex.x, 0.5 + (Tex.y-0.5)/ScreenRatio); } //原图和亮度图融合的算术方式 float3 ShaderX3Blend2 (float3 colScreen, float3 colTexel) { return saturate (colTexel + colScreen - colTexel * colScreen); } //顶点渲染函数 float4 PS(oVertex In) : COLOR0 { float4 color; //采样原图和模糊了的亮度图 float4 RenderMap = tex2D (RenderMapSampler, DoAspectRatioCorrection (In.Tex1)); float4 BlurredImage = tex2D (BlurredImageSampler, DoAspectRatioCorrection (In.Tex2)); return float4 (ShaderX3Blend2 (RenderMap, BlurredImage), 1); //两像素采样 } technique Tech { pass p { VertexShader = compile vs_1_1 VS(); PixelShader = compile ps_2_0 PS(); } } 上面的代码有两点值得我们学习的地方。第一点就是参数 ScreenRatio,它表示窗口 大小的纵横比,因为我们的要把窗口的画面渲染到纹理,处理后再把纹理渲染到窗口。当 窗口的大小不一样的时候,我们得到的这种纹理内容会是拉伸的,最终到窗口的画面也是 拉伸的。在前面的例子程序中,笔者使用的是固定参数加上 2048×2048 的纹理来避免最 终画面的拉伸;而使用这节使用参数 ScreenRatio 的方法是优于前面的方法。这种方法是 根据纵横比的变化,动态的通过函数 DoAspectRatioCorrection 求得实际的不拉伸的 UV 坐标进行对纹理采样。第二点学习的地方是 ShaderX3Blend2 函数融合两个像素的算法, 老是大家也可以在这里改成其他像素融合算法,但是效果最终不能强过这算法。这种算法 需要一定实验才能得出。 - 302 - 具体读者可以参考例子程序 16.3.3_1. cmo,运行时候根据屏幕左上角的文字提示,通 过按键调节参数,以达到自己满意的效果。 16.3.4 Fur(毛发) 以前由于硬件的限制,这个效果很少在游戏中大量使用,因为挺耗资源的;而现在, 硬件已经飞速发展,在游戏中运用已经比较普遍了。毛发不是头发,不能表现女孩飘逸的 长发,短发也不行,但可以表现男人的胡须和老虎的皮毛。 毛发的实现原理简单的讲就是在要长毛发的地方,向着法线的方向紧密的画一串点, 就像糖葫芦那样,这一串的点就是一根毛发,所以仅一根毛发看不出效果,要成片的毛发 集在一起才能看出效果,如果图 16.3.4_1 所示: 图 16.3.4_1 毛发之间是有小小缝隙的,这些缝隙可以用一张噪声贴图来协助表现,这样的噪声图 都是用程序来实现,过程是创建一张图片,然后在图片上是随机生成点,白点就是长毛发 的地方,黑点就是不长毛发的地方,以下是生成一张噪声图的 VSL 代码。 void main() { int width = 256; //--贴图宽度 int height = 256; //--贴图高度 DWORD pixel[ 256*256 ]; //--贴图总像素数组 str texName = "Fur Noise";//--贴图的名字 //--根据名字得到贴图,以判断是否已经创建了这样的贴图 Texture tex = Texture.Cast( bc.GetObjectByNameAndClass( texName, CKCID_TEXTURE ) ); if( !tex ) { tex = bc.CreateTexture( texName ); //--创建贴图 tex.CreateImage( width, height ); //--设置贴图大小 } //--随机设置像素值 int randLimit = floori( 32768 * FurDensity ); //--根据毛发密度参数设置随机取值度 - 303 - for( int a=0 ; a; // 光的位置 float FurLength = 0.65; // 毛发的长度 float FurThickness = 3; // 毛发的密度 static float4 LightColor = {1,1,1,1}; // 光的颜色(白色) //--- 将光源的位置转换到世界坐标系中 static float3 localLightPos = mul(World,float4(lightPos,1)); //根据顶点的位置、顶点法线和光的位置计算漫反射光照 float4 DiffuseLighting(float3 lPos,float3 lNorm) { float3 lightDir = normalize( localLightPos - lPos ); //--- 光的方向 //--- 漫反射结果 return clamp((mat_diffuse * LightColor * dot(lNorm, lightDir)),0,1) + mat_diffuse*0.5f; } //顶点输出结构 struct vOUTPUT { float4 Position : POSITION; float4 Color : COLOR; float2 TexCoord : TEXCOORD0; float2 TexCoord1 : TEXCOORD1; - 304 - }; //顶点渲染函数,这个函数在20个pass中都调用, //参数PassIndex and PassCount 系统会自动赋值 vOUTPUT FurVertexShaderPass( float3 Position : POSITION, float3 Normal : NORMAL, float2 Tex0 : TEXCOORD0 ) { vOUTPUT Out = (vOUTPUT)0; //---计算当前pass的百分比:当前pass索引/pass总数,其中是0到1 float passRatio = (float)(PassIndex) / (float)PassCount; //--- 以平方曲线的方式画点,避免线性画点导致不美观 passRatio *= passRatio; //--- 计算每根胡须点的位置 float3 p = Position + (Normal * passRatio * FurLength); Out.Position = mul(float4(p,1),Wvp); //---顶点投影变换 Out.Color = DiffuseLighting(p,Normal); //---漫反射光照 Out.Color.a = (1.0f - passRatio); //---毛发不同部位不同的alpha值 Out.TexCoord = Tex0; //-- 材质使用贴图的UV坐标 Out.TexCoord1 = Tex0 * FurThickness; //--噪声贴图使用的UV坐标 return Out; } texture BaseTexture : TEXTURE; // 当前材质的使用的贴图 texture NoiseTexture; // 毛发的噪声贴图 sampler BaseSampler = sampler_state { //---材质使用的贴图的采样器 texture = ; MipFilter = LINEAR; Minfilter = LINEAR; Magfilter = LINEAR; }; sampler NoiseSampler = sampler_state {//--噪声贴图的采样器 texture = ; MipFilter = LINEAR; Minfilter = LINEAR; Magfilter = LINEAR; }; /********************************************** 这个像素着色函数在20个pass中被调用,它的作用是画点,它把原图的贴图的像素与噪声 贴图的像素通过一定比例叠加在一起。 ***********************************************/ float4 FurPixelShader(vOUTPUT In) : COLOR { float4 bCol = tex2D( BaseSampler, In.TexCoord ); //--采样毛发的表面贴图 float4 nCol = tex2D( NoiseSampler, In.TexCoord1 ); //--采样毛发的噪声贴图 float4 alpha = float4(1,1,1,nCol.a); //--获得毛发的缝隙值 - 305 - return (bCol+bCol.a*nCol.a*0.35) * alpha * In.Color; //--返回最终的颜色值 } /************************************ 这个渲染技术包含了20个相同的渲染pass **************************************/ technique Fur { pass p0 { AlphaTestEnable = TRUE; //--开启Alpha测试 AlphaFunc = GREATER; //--当前值大于Alpha测试的参考值时通过 AlphaRef = 3; //--Alpha测试的参考值是3 AlphaBlendEnable = TRUE; //--开启Alpha混合 DestBlend = INVSRCALPHA; //-- Alpha的混合方式 SrcBlend = SRCALPHA; //--关于Alpha详细参考本书5.2章节 VertexShader = compile vs_1_1 FurVertexShaderPass(); //--顶点渲染 PixelShader = compile ps_1_1 FurPixelShader(); //--像素渲染 } pass p1 { VertexShader = compile vs_1_1 FurVertexShaderPass(); PixelShader = compile ps_1_1 FurPixelShader(); } pass p2 { VertexShader = compile vs_1_1 FurVertexShaderPass(); PixelShader = compile ps_1_1 FurPixelShader(); } pass p3 { VertexShader = compile vs_1_1 FurVertexShaderPass(); PixelShader = compile ps_1_1 FurPixelShader(); } ……. pass p20 { VertexShader = compile vs_1_1 FurVertexShaderPass(); PixelShader = compile ps_1_1 FurPixelShader(); } } 画每一根毛发上的点的时候,位置不是线性分布的,先是通过 pass p0 渲染表面,然 后就是从 pass p1 到 pass p20 是渲染毛发, 毛发上的每个点的语句是:float3 p = Position + (Normal * passRatio * FurLength)。其中参数 FurLength 是用户可调节的,表 示毛发长度,而参数 passRatio 表示毛发从底端到前端上的点的位置次序,以百分比的方 式表示:float passRatio = (float)(PassIndex) / (float)PassCount。如果取消这两个参数 - 306 - passRatio 和 FurLength,可发现毛发全部画在一个法线长度的地方,Normal * passRatio 表示所有的点仅仅是分布在一个法线长度范围内,为了分布更自然所有以平方曲线的方式 来分布毛发上的 20 个点:passRatio *= passRatio; 毛发底部比较浓密,顶端就变得稀疏,所以需要通过一个方式来控制每个毛发上的点 的透明度,实现的语句是:Out.Color.a = (1.0f - passRatio);从这个语句可见毛发越是接近 顶端越透明。 毛发上每个点的最终颜色的语句是:return (bCol+bCol.a*nCol.a*0.35) * alpha * In.Color; 其中参数 bCol 是表面贴图,参数 nCol 是噪声贴图,bCol.a 和 nCol.a 是像素的 alpha 值,(bCol+bCol.a*nCol.a*0.35)是表面贴图和噪声贴图的融合方式,可见噪声贴图 只是使用它的透明分量值。参数 In.Color 是通过顶点着色函数计算得到的光照颜色,参数 alpha 是表示是否决定此处长毛发。详细请参照实例“例子 16.3.4_1.cmo” 16.3.5 GrayScale(灰度) 有些游戏,主角被 NPC 消灭后,整个画面会慢慢变灰,然后提示玩家是否继续,如 果玩家选择继续,整个画面从灰又慢慢变到正常。这一小节讲的就是如何实现这个效果。 要想变灰,首先要获得要变灰的画面,使用―Render Scene in RT View‖这个 BB 可以得到。 得到了画面如何把色彩变灰呢?将颜色值的三个颜色分量按指定权重进行取值即可,详细 请读者参见第 16.3.3 小节。将画面变灰后,最后一步就是把画面再渲染回屏幕上,这就要 使用―Combine RT Views‖这个 BB 了。具体 shader 代码如下所示: float4 Luminance = { 0.3,0.59,0.11,0.0}; //亮度调节参数 float Graystep =1.0; //灰度过渡调节参数 float ScreenRatio; //渲染窗口纵横比参数 struct oTLTex4 //顶点输出结构 { float4 pos : POSITION; //顶点位置 float2 tex0 : TEXCOORD0; //顶点UV }; //一个简单的纹理采样器设置 texture tex : TEXTURE0; sampler TexSampler = sampler_state { texture = ; //纹理采样器进行采样的纹理对象 Minfilter = LINEAR; //纹理在被缩小时进行线性过滤 Magfilter = LINEAR; //纹理在被放大时进行线性过滤 AddressU = CLAMP; //U方向的纹理寻址模式采用重叠映射寻址模式 AddressV = CLAMP; //V方向的纹理寻址模式采用重叠映射寻址模式 }; //根据窗口纵横比,计算实际UV坐标,以避免画面拉伸现象 float2 DoAspectRatioCorrection (float2 Tex) { return float2 (Tex.x, 0.5 + (Tex.y-0.5)/ScreenRatio); } //顶点渲染函数 oTLTex4 Transform( - 307 - float3 Pos : POSITION, float2 Tex0 : TEXCOORD0 ) { oTLTex4 output = (oTLTex4)0; //定义一个顶点输出结构变量 output.pos = float4(Pos, 1); //顶点变换 output.tex0 = Tex0; //UV赋值 return output; //结果返回 } //像素渲染函数 float4 GrayScalePixel(oTLTex4 p) : COLOR //返回值是一个颜色值 { //使用正确的UV对纹理进行采样 float4 col0 = tex2D(TexSampler , DoAspectRatioCorrection (p.tex0)); float4 col1 = dot(col0,Luminance); //灰度计算 return lerp(col0 , col1 , Graystep); //灰度与原图的过渡比例 } technique Grayscale { pass p { VertexShader = compile vs_1_1 Transform(); PixelShader = compile ps_2_0 GrayScalePixel(); } } 再实际的项目中,画面的灰度是正确了,但美术人员不会理睬算法和技术上的正确, 他们关注的只是效果,所有觉得需要画面需要再亮一些。既然如此就不必理会标准的灰度 权重 static float4 Luminance,而是做一个可调节的参数权重 float4 Luminance。 由于画面是过渡的,而不是突然变灰,突然变正常。所以需要有一个将两个颜色混合 的比例参数 Graystep 来进行控制。将两个颜色按比例混合的函数自然就是 lerp()了。 - 308 - 第十七章 shader 的应用 前面章节讲的都是小效果,这章来讲稍微复杂效果,可编程渲染管线的 shader 语言就 是用短小精悍的语言,来完成常规渲染管线办不到或者很难、很罗嗦才能办到的事情。本 节主要学习一下内容:  多层纹理地形渲染  运动模糊的实现  实时带倒影的水面 17.1 地形渲染 17.1.1 Texture Splatting 原理简介 Texture Splatting 网上也有说成 Terrain Splatting 因为这种技术主要用来渲染地形。说 来很惭愧,Texture Splatting 我不知道怎样翻译成中文才算恰当,有人说是“纹理泼溅”, 这个名字到有点生动,但是我不喜欢最后一个字的读音。如果硬要给他翻译个名字,不如 叫做“多重纹理渲染到表面”吧。 Texture Splatting 这个技术是一位名为 Charles Bloom 的前辈在 2000 年创造的,他在 http://www.cbloom.com/3d/techdocs/splatting.txt 里对这个技术进行了阐述。这篇文章很长, 要一句话概括的话就是:使用不同的 alpha 值将多张纹理融合到表面的技术。 这个技术有个限制就是:地形表面只能够使用 4 张贴图来表示。4 张贴图对于一个区 域的地形来说已经基本够了,比如说地形的最低层是沙子,倒数第二层是草地,倒数第三 层是一些碎石,倒数第四层是一个路面。如果有些游戏场景的地形比较复杂的话,比如有 树林,有草地,有山区,有沙漠,有雪地等等。就把这大块地形切成不同类型的小块地形, 然后使用不同的组的 4 张贴图来渲染地形。 为什么有 4 张贴图的限制呢?关键原因是不同的 alpha 值存储在什么地方的问题。 Texture Splatting 技术是把这些不同的 alpha 值存储在一直纹理中。而一个纹理中通常有 多个通道:红、绿、蓝、或者还有透明度值,可见限制的原因在这里。有人说我们可以使 用更多的贴图来存储不同 alpha 值,这样就没有限制了,理论上是可以,只是多采用几张 纹理,然后在 shader 代码中多个“pass”过程。但是要考虑性能的影响,以及这样的开销 对整个场景效果的贡献能有多大? 在 5.2.4 章节我们讲到 alpha 混合的原理,想必大家都能看明白;但这里来看看前辈们 如何把 alpha 混合发挥到极致,以下是地形纹理的融合算法: result _n = result _n-1 * ( 1 - alpha_m ) + texture_n * alpha_m。 其中 n 代表第 n 层紋理。result _n 代表第 n 层混合后的颜色值;m 表示贴图的 r、g、b、a 四个分量值。 17.1.2 Texture Splatting 对地形建模的一点要求 17.1.3 shader 代码剖析 以下是 1 张底图+4 层贴图的地形渲染 shader 代码,因为 alpha 贴图不但用了 r、g、 b 三个颜色分量,连 a 分量也使用了,已经充分应用了 alpha 贴图的所有通道,即 1 张底 图+4 层贴图,这样做地面能更丰富些。 不是所有的地形都需要使用 5 张贴图来表示地面纹理,所有提供一些必要参数来供美 - 309 - 术人员选择,布尔类型变量 Diffuse3 和 Diffuse4,表示是否使用第三层和第四层贴图;有 些地形需要阴影,所有也提供一个布尔类型变量 lightmap 来进行选择;有些地形不需要第 二套 UV,有些地形则需要,所有也提供一个布尔类型变量 UV02 来进行选择;还有的地 形在使用阴影图的时候是反的,所有又提供一个布尔类型变量 InvUV 来进行反转 UV,使 得阴影层显示正确。 // 使用到的矩阵 float4x4 wvp : WORLDVIEWPROJECTION; //world矩阵×View矩阵×projection矩阵 bool UV02; bool InvUV; bool lightmap; bool Diffuse3; bool Diffuse4; // 使用到的贴图 texture texBaseDiffuse; // 贴在地形最底层纹理 texture texDiffuse1; // 贴在地形第一层的纹理 texture texDiffuse2; // 贴在地形第二层的纹理 texture texDiffuse3; // 贴在地形第三层的纹理 texture texDiffuse4; // 贴在地形第四层的纹理 texture texMask; // 融合这些纹理的alpha通道图 texture texLightmap; // 地形的光照信息图 float3 TexLoop01; float3 TexLoop02; //纹理采样方式设置 sampler2D TextureBaseDiffuse = sampler_state //对地形最底层的纹理进行采样 { texture = ; MinFilter = LINEAR; MagFilter = LINEAR; }; sampler2D TextureDiffuse1 = sampler_state //对地形第一层的纹理进行采样 { texture = ; MinFilter = LINEAR; MagFilter = LINEAR; MipFilter = LINEAR; }; sampler2D TextureDiffuse2 = sampler_state //对地形第二层的纹理进行采样 { texture = ; MinFilter = LINEAR; MagFilter = LINEAR; MipFilter = LINEAR; }; sampler2D TextureDiffuse3 = sampler_state //对地形第三层的纹理进行采样 - 310 - { texture = ; MinFilter = LINEAR; MagFilter = LINEAR; MipFilter = LINEAR; }; sampler2D TextureDiffuse4 = sampler_state //对地形第四层的纹理进行采样 { texture = ; MinFilter = LINEAR; MagFilter = LINEAR; MipFilter = LINEAR; }; sampler2D TextureMask = sampler_state //对alpha通道纹理进行采样 { texture = ; MinFilter = LINEAR; MagFilter = LINEAR; MipFilter = LINEAR; }; sampler2D TextureLightmap = sampler_state //对地形的光照信息纹理进行采样 { texture = ; MinFilter = LINEAR; MagFilter = LINEAR; MipFilter = LINEAR; }; //使用的的一些参数 bool fog; // 是否使用雾效 float4 fogcolor; // 雾的颜色 float fognear = 100; // 雾的最近有效距离 float fogfar = 150; // 雾的最远有效距离 float4 colorEmissive; // 地形自发光颜色 float4 colorDiffuse; // 地形漫反射颜色 float burn = 3; // 色彩曝光参数值 //顶点渲染输入结构 struct VSIN { float3 pos : POSITION; float4 col : COLOR; float3 norm : NORMAL; float2 uv : TEXCOORD0; float2 uv2 : TEXCOORD1; }; - 311 - //顶点渲染输出结构 struct VSOUT { float4 pos : POSITION; float4 col : COLOR; float2 uv : TEXCOORD0; float2 uv2 : TEXCOORD1; }; float3 LightPos; //得到光的位置 float4 LightCol; //得到光的颜色 // 对光照的结果做点修饰的变量 float overflowingDiffuse = 0.3; //地形的漫反射颜色与光的颜色相乘,得到混合的色彩 static float4 combinedDif = colorDiffuse * LightCol; //顶点渲染函数 VSOUT Diffuse_VS( VSIN vsIn ) { VSOUT vsOut; //将物体换算到正确的位置上。 vsOut.pos = mul( float4(vsIn.pos,1), wvp ); float3 lightDir = normalize(LightPos-vsIn.pos); //计算光的方向 float lightDot = dot( vsIn.norm, lightDir ); //计算物体法线和广的方向的夹角 lightDot += -lightDot*overflowingDiffuse+overflowingDiffuse; //修改夹角的值 float4 col = saturate(combinedDif * lightDot); //得到地形漫反射值 vsOut.col = colorEmissive + col; //得到地形最终颜色值 vsOut.uv = vsIn.uv; //得到地形第一套UV坐标 if(UV02) //是否使用第二套UV坐标 { vsOut.uv2 = vsIn.uv2; //得到地形第一套UV坐标 } else { vsOut.uv2 = vsIn.uv; } return vsOut; } //像素渲染函数 float4 Diffuse_PS( VSOUT psIn ) : COLOR { float4 result; float4 diffuseColor; //采样最底层纹理的颜色值 diffuseColor= tex2D(TextureBaseDiffuse , psIn.uv*TexLoop01.x); - 312 - //采样第一层纹理的颜色值 float4 ColorLayer1 = tex2D(TextureDiffuse1 , psIn.uv*TexLoop01.y); //采样第二层纹理的颜色值 float4 ColorLayer2 = tex2D(TextureDiffuse2 , psIn.uv*TexLoop01.z); if(InvUV) /反转UV(从建模软件导出的3D物体,UV有反的情况) psIn.uv2.y = 1 - psIn.uv2.y; float4 AlphaLayer = tex2D(TextureMask , psIn.uv2); //融化最底层和第一、二层的色彩 result= (diffuseColor*(1-AlphaLayer.r)) + ColorLayer1*(AlphaLayer.r); result= result*(1-AlphaLayer.g) + ColorLayer2*(AlphaLayer.g); //是否使用第三层贴图 if(Diffuse3) { //采样第三层纹理的颜色值并融合 float4 ColorLayer3 = tex2D(TextureDiffuse3 , psIn.uv*TexLoop02.x); result= result*(1-AlphaLayer.b) + ColorLayer3*(AlphaLayer.b); } //是否使用第四层贴图 if(Diffuse4) { //采样第四层纹理的颜色值并融合 float4 ColorLayer4 = tex2D(TextureDiffuse4 , psIn.uv*TexLoop02.y); result= result*(1-AlphaLayer.a) + ColorLayer4*(AlphaLayer.a); } //计算最终的色彩值 float4 ex = psIn.col ; // 得到在顶点渲染函数中的计算出的物体的表面颜色 float4 finalcolor; // 表示地形的最终颜色 finalcolor = ex * result; //是否使用光照阴影图 if(lightmap) { float4 LightLayer = tex2D(TextureLightmap, psIn.uv2); finalcolor = (finalcolor * LightLayer) * burn; } else { finalcolor = finalcolor * burn; } // 返回最终的颜色值 return finalcolor; } - 313 - technique tech { pass p { FogEnable = ; FogColor = ; FogDensity = 0; // pixelshader fog FogVertexMode = NONE; FogTableMode = LINEAR; // Fog distance settings FogStart = ; FogEnd = ; VertexShader = compile vs_1_1 Diffuse_VS(); PixelShader = compile ps_2_0 Diffuse_PS(); } } 上述的代码看起来很长,占了好几页,其实真正的内容是像素渲染函数Diffuse_PS( ); 其他的代码都是我们之前学过或者的东西,纹理采样语法倒是简单、但是要理解为什么它进 行了采样,采样的结果用在哪里?才算真正理解代码的含义。在采样通道图,和阴影图前要 记得反转第二套UV值(这个要视建模软件导出的3D模型情况而定,不一定所有的3D模型都 需要反转)。当计算出最终结果后,我们可以选择是否需要显示阴影和把颜色调得更亮一些, 这些也都是简单的效果修饰了。 在顶点渲染函数 Diffuse_VS( )中,在计算顶点光照结果的时候,碰到了这样一条语句 ightDot += -lightDot*overflowingDiffuse+overflowingDiffuse;这条语句对光也顶点法线的 夹角进行了一些调整,这个调整有点像是调整明暗对比度(不知道这样解释是否正确)。 有些地形比较到,纹理需要重复地贴,float3类型变量TexLoop01、TexLoop02;分别使 用了它们的x、y、z分量来表示重复每一层纹理的数量。读者自行改变数值,就可以观察到 结果表现。详细参看例子17.1.1_1.cmo。 - 314 - 17.2 运动模糊 17.2.1 运动模糊原理简介 图 17.2.1_2 在学习之前,先看一下效果,如图16.2.3_1所示。这样的效果为什么叫“运动模糊”, 而不是简单的称之为“模糊”呢?直观理解是:摄像机必须运动才能有模糊效果,静止的画 面是不会模糊的。下面来分析一下产生这种现象原理: 我们做游戏开发和虚拟现实开发的时候,无论电脑每秒渲染场景的速度多快或者多慢 (FPS30或者是FPS60),无论如何瞬间截取屏幕画面,都是一副一副静止的画面;哪怕你的 场景的摄像机运行的如何的块,选择的如何的天昏地暗,截取的画面都是清醒的单张画面, 不能能得到模糊的画面。这样的结果如果读者无法理解,就是没有很好理解计算机画面渲染 的机制。正常来说,我们在每一帧渲染之前,会把后备缓冲清理干净,然后把新的画面填充 到到这个缓冲区上,最后Fill(翻转)到屏幕上,这就是为什么截取画面都不是模糊的。也 许有人就想如果不清除的后备缓冲„„,那样的结果什么都不是 O__O”„ 想必大家都用过手机拍过照吧(作者的手机是很cheap那种,蓝屏的,没拍照功能), 想想为什么拍照的时候,拿着摄像头的手不能动,被拍的人也不能动;如果动了就能得到我 们的运动模糊效果了(比如被拍的人不断的挥手,手部分就会是运动模糊效果),只不过这 个时候我们不需要这种效果。原因呢? 因为现实世界中的摄像头(无论是光学传感器还是传统的胶片),他们“成像”需要时 间,这个时间虽然是无论是几毫秒还是是几十毫米,它都会把这段时间的所有视觉信息的变 化过程全部拍摄下来。 17.2.2 设置需要配合的资源 现在已经明白了运动模糊是怎么一回事,回过神来我们要在电脑上实现这种效果。运 动模糊技术的目的就在于增强快速移动场景的真实感,这一技术并不是在两帧画面之间插入 - 315 - 更多的画面变化信息,而是将当前帧画面同前一帧画面混合在一起所获得的一种效果。要在 Virtools中实现这个效果,不仅要编写shader代码还要一些Virtools脚本和设置一些贴图来进行 配合,才能看到效果。 先要创建2张纹理,每张纹理的大小都是2048×2048,这个大小属于2的n次方。我想目 前没有那个显示器的分辨率能大于这个数了,如果纹理大小低于屏幕的分辨率,那么画面有 可能就会变的模糊(注意此模糊非彼模糊)。这二张纹理中一张用来存放当前帧画面;一张 存放经过shader代码进行混合后的画面。 这段shader代码的应用和之前的不一样,之前的shader代码都是附在一张材质上,通过 材质来表现3D物体的效果;这节的shader代码没有附在任何一张材质上,而是通过一个BB 来执行“Combine RT View”。 第一步使用名为“Render Scene in RT View”的BB,将当前画面渲染到之前我们准 备的一张纹理上。这个BB的具体使用请查看帮助文档或者参考其他书籍,但有一点必须要 提醒读者,就是:就是这个BB的第一个参数的填写方法,由于我们要对全屏幕进行模糊处 理,我们准备的纹理是2048×2048,所以必须准确填写屏幕的矩形大小,不要使用默认的数 值大小,不然得到的画面内容会变形。我的3D渲染窗口的是1024×768,参数填写如图 17.2.2_1所示: 图17.2.2_1 第二步使用名为“Combine RT View”的BB,讲当前画面纹理和之前的画面纹理通过 执行shader代码中的“MotionBlur”技术,讲两个纹理混合。这个BB的具体使用同样请查 看帮助文档或者参考其他书籍;此刻同样的再提醒一次屏幕矩形大小的填写。 第三步还是使用名为“Combine RT View”的BB,把混合后的纹理渲染到屏幕上,通 过执行shader代码中的“Direct”技术来实现。这三个步骤每一帧都有要去执行,不然看不 到效果,具体Virtools脚本连接如图17.2.2_2所示: 图17.2.2_2 如果场景是空白的,相机不移动同样看不到效果,本节参看例子程序中种了很多树, 并设置摄像机随曲线移动,以便观察效果,Virtools脚本如图17.2.2_3所示 图17.2.2_3 - 316 - 17.2.3 运动模糊 shader 代码 下面的代码中是在Virtools附带的一个例子程序“Castle_MotionBlur.cmo”的shader上做 了修改,也对Virtools脚本做了修改,读者可以在Virtools安装目录中找到它原来的代码。这 段shader代码也很简单,关键的函数是MixPixel( );而这个函数做的事情也不多就是把两个 贴图的内容按特定的算法进行了融合。这个特定的算法我们后面解释,先来明白需求。我们 在赛车游戏中不需要全屏幕的模糊,希望屏幕中间的玩家控制的车不模糊,而周围的景象模 糊。如下如图17.2.3_2所示。 图17.2.3_1 其中shader代码如下所示: float RefValue; //模糊程度参数调节 struct oTLTex4 //着色器输出结构 { float4 pos : POSITION; float2 tex0 : TEXCOORD0; float2 tex1 : TEXCOORD1; }; texture oldTex : TEXTURE0; //之前的画面贴图和其采样器设置 sampler oldTexSampler = sampler_state { texture = ; Minfilter = LINEAR; Magfilter = LINEAR; }; - 317 - texture newTex : TEXTURE1; //当前的画面贴图和其采样器设置 sampler newTexSampler = sampler_state { texture = ; Minfilter = LINEAR; Magfilter = LINEAR; }; //顶点着色函数 oTLTex4 PassThrough( float3 Pos : POSITION, float2 Tex0 : TEXCOORD0, float2 Tex1 : TEXCOORD1) { oTLTex4 output = (oTLTex4)0; output.pos = float4(Pos, 1); float2 newTexCoord = Tex0; output.tex0 = newTexCoord; output.tex1 = newTexCoord; return output; } //模糊像素融合着色函数 float4 MixPixel(oTLTex4 p) : COLOR { //得到UV到事件中心点的距离 float flength = ( p.tex0.y - 0.25 ) * ( p.tex0.y - 0.1875 ) + ( p.tex0.x - 0.25 ) * ( p.tex0.x - 0.1875 ); flength=sqrt(flength ); float4 oldCol = tex2D(oldTexSampler , p.tex0); float4 newCol = tex2D(newTexSampler , p.tex1); float dis=(flength-0.045); //中间部分不模糊 dis=(1-dis)*RefValue ; //计算两贴图融合因子 return lerp(oldCol ,newCol ,dis*dis); //混合两贴图 /* if(flength<0.045)//0.3~0; { return newCol ; } else { return lerp(oldCol ,newCol ,flength); } */ } - 318 - //像素着色函数 float4 NormalPixel(oTLTex4 p) : COLOR { return tex2D(oldTexSampler , p.tex0); } //模糊渲染技术 technique MotionBlur { pass p { VertexShader = compile vs_1_1 PassThrough(); PixelShader = compile ps_2_0 MixPixel(); } } //正常渲染技术 technique Direct { pass p { VertexShader = compile vs_1_1 PassThrough(); PixelShader = compile ps_2_0 NormalPixel(); } } 在解释融合算法之前先看一副图,如图17.2.3_1所示。这幅图是我前面创建的2张纹理 的大小(即2048×2048)和我们渲染窗口的大小即(1024×768)。可见渲染到全屏幕时纹理 UV的使用情况,我们的需求是画面中间不进行模糊处理,所以要找到实际的中心点,然后 求得到中点的距离。数学公式如下所示: float flength = ( p.tex0.y - 0.25 ) * ( p.tex0.y - 0.1875 ) + ( p.tex0.x - 0.25 ) * ( p.tex0.x - 0.1875 ); 得到了中点,也得到屏幕中心的距离,剩下的就是如何有规律的从屏幕四周到中间衰 减模糊程度,到中间时模糊程度最小,不能直接到中间就完全没有模糊(参见图17.2.3_1)。 0.045是我们设置的不模糊的中间区域,如果想扩大不模糊的中心范围可以修改这个值,这 个常量值的设置注意范围,否则会得到不正确的效果。最后通过一个可调节的参数RefValue 来控制模糊的程度,最后通过平方曲线来衰减模糊程度,算法如下所示: float dis=(flength-0.045);//2 dis=(1-dis)*RefValue ; return lerp(oldCol ,newCol ,dis*dis); 我得要承认这个衰减算法我设计的不好,但是效果上能够满足需求,详细请参考“例 子17.2.1_1.cmo”。 - 319 - 图17.2.3_1 17.3 带倒影的水面渲染 17.3.1 渲染水的步骤 水的渲染有很多种算法,无论是哪一种算法的渲染原理,都需要很大的篇幅以及必须 具备较好的图形学知识才能够讲解透彻,但这些超出本书的范围,本书注重的是实用,本 书的很多实例即使读者不能明白其中的原理,也能使用封装好的模块和效果。本例讲到的 水渲染,其实也仅仅只是简单地介绍水的实现过程而已。水面要有折射、要有反射、要有 波动、还要有菲涅尔效果才算逼真。要实现这种效果,需要以下四张纹理:折射纹理 (Refraction),反射纹理(Reflection), 水波凹凸纹理(Bumpmap),菲涅尔效果纹理 (Fresnel)。 为了实现水的倒影效果,需要 2 个摄像机:一个是水面上的相机、一个是水面下的相 机;3D 物体要分成三个组:一个是水上的物体组、一个是水下物体组、一个是水面组。 它们是这样协调工作的:首先是把水上的物体以及水面隐藏,只显示水下的物体,然后用 水上相机渲染画面到折射纹理上;接着把水下的物体和水面隐藏,只显示水上的物体,再 - 320 - 接着用水下相机渲染画面到反射纹理上;最后把水上、水下的物体都隐藏,只渲染水面。 相机位置如图 17.3.1_1 所示。 如图 17.3.1_1 所示 这个过程描述起来步骤虽然挺多,其实只要把资源打好组后,再开发一个 VSL 就能实 现了,其中 VSL 的输入参数如下所示: 参数名 参数类型 参数功能 参数用途 Water Entity3D 输入参数 水面物体 UnderWater Group 输入参数 水下物体 AboveWater Group 输入参数 水上物体 NormalCam Camera 输入参数 水上相机,常规相机 InvertedCam Camera 输入参数 水下相机,反射相机 Phase int 输入参数 渲染哪一组物体的标识 waterY float 输入参数 水面高度 VSL 具体代码如下所示: void ShowAllInGroup(Group grp,CK_OBJECT_SHOWOPTION show) { // 隐藏或者显示整个组的物体 Entity3D ent; for(int i = 0;i; // 眼睛的位置 float4 c15 ; float4 c16 ; // 水波的密度和水波的偏移量 float4 c19 ; // c19 // 倒影的偏移量 float4 c20 ; // c20 float4 c21 ; // c21 float4 viLi ; // c94 float4 vLig ; // c95 float4 vFog ; // c18 texture t0; // 水浪凹凸贴图 texture t1; // 反射贴图 texture t2; // 折射贴图 texture t3; // 菲涅尔效果贴图 sampler t1_sam =sampler_state // 反射采样器 { Texture = ; MipFilter = Linear; MinFilter = Linear; MagFilter = Linear; }; struct VS_INPUT // 顶点渲染输入结构 { float3 Position : Position; // 顶点位置向量 }; - 324 - struct VS_OUTPUT // 顶点渲染输出结构 { float4 Position : POSITION; // 顶点位置向量 float4 Color0 : COLOR0; float4 Color1 : COLOR1; float2 tc0 : TEXCOORD0; // 采样水浪凹凸贴图的UV float4 tc1 : TEXCOORD1; // 采样反射贴图的UV float4 tc2 : TEXCOORD2; // 采样折射贴图的UV float2 tc3 : TEXCOORD3; // 采样菲涅尔效果贴图的UV float Fog : FOG; }; VS_OUTPUT WaterVS(VS_INPUT vsin) { VS_OUTPUT Out = (VS_OUTPUT) 0; //定义一个返回值结构 float4 r7 = mul(float4(vsin.Position,1),mWOR); //将顶点转换到最终屏幕空间中 float4 r0 = mul(float4(vsin.Position,1),mWVP); //将顶点转换到世界空间中 Out.Position = r0; float2 r8 = r7.xz*c16.x; //设置水波的密度和水波的偏移度 Out.tc0 = r8+c16.y; //得到采样水浪凹凸贴图的UV float4 r1 = r0.wwww*c15.xzxx + r0; float4 r2 = r1*c15.xyxx; r2.w = 1/r2.w; r1 = r2*r2.w; Out.tc2 = r1*c19; //得到采样折射贴图的UV r2 = c21; r1 = r1*c20+r2; Out.tc1 = r1*c19; //得到采样反射贴图的UV Out.tc1.w = 1.0; Out.tc2.w = 1.0; float4 r3 = vEye-r7; r3.w = dot(r3,r3); r3.w = 1/(float)sqrt(r3.w); r3.xyz = r3*r3.w; float4 r4 = r3+vLig; r4.w = dot(r4.xyz,r4.xyz); r4.w = 1.0/(float)sqrt(r4.w); r4.y = r4.y*r4.w; r4.xw = vLig.w; r3 = r3*vEye.w; Out.tc3 = r3.xz + c16.y; //得到采样菲涅尔效果贴图的UV Out.Color0 = viLi; r4 = lit(r4.x,r4.y,r4.w); Out.Color1 = r4.zzzz; Out.Fog = r0.w*0.05*vFog.x + vFog.y; - 325 - return Out; } PixelShader PS = asm { ps.1.1 tex t0 texbem t1, t0 texbem t2, t0 texbem t3, t0 dp3_sat r1, v0_bx2, t3_bx2 mul r1, r1, r1 mul r1, r1, r1 mul_sat r0.rgb, v1, t1.a +mul_sat r1.a, r1, r1 add_sat r0.rgb, r1_bias.a, r0_bias mad r0.rgb, t3.a,t1, r0 mad r0.rgb, t2, 1-t3.a, r0 +mov r0.a,t2.a }; technique techHLSL { pass p { FogEnable =0; FogColor = 0x00DBF5F9; FogTableMode = 3; FogStart = 200; FogEnd = 2000; VertexShader = compile vs_1_1 WaterVS(); PixelShader = ; FillMode = SOLID; // Reflection BumpEnvMat00[1] = 0.015; BumpEnvMat01[1] = 0; BumpEnvMat10[1] = 0; BumpEnvMat11[1] = 0.015; // Refraction BumpEnvMat00[2] = 0.015; BumpEnvMat01[2] = 0; BumpEnvMat10[2] = 0; BumpEnvMat11[2] = 0.015; // Fresnel BumpEnvMat00[3] = 0.25; - 326 - BumpEnvMat01[3] = 0; BumpEnvMat10[3] = 0; BumpEnvMat11[3] = 0.25; Texture[0] = ; Texture[1] = ; Texture[2] = ; Texture[3] = ; MinFilter[0] = LINEAR; MagFilter[0] = LINEAR; MipFilter[0] = LINEAR; MinFilter[1] = LINEAR; MagFilter[1] = LINEAR; MipFilter[1] = LINEAR; MinFilter[2] = LINEAR; MagFilter[2] = LINEAR; MipFilter[2] = LINEAR; MinFilter[3] = LINEAR; MagFilter[3] = LINEAR; MipFilter[3] = LINEAR; AddressU[1] = clamp; AddressV[1] = clamp; AddressW[1] = clamp; AddressU[2] = clamp; AddressV[2] = clamp; AddressW[2] = clamp; AddressU[3] = clamp; AddressV[3] = clamp; AddressW[3] = clamp; } } 一些场景使用这个shader的时候,达不到预期的效果,问题主要是3个:一是参数调集 不正确;二是倒影与物体颜色相近,倒影在水面上不突出,本例中的天空是亮色,而山的倒 影是暗色,所以对比明显,效果才突出;三是一些物体插入水中太多,倒影有部分不正确, 比如图17.3.3_1画面中间最小的小岛,它“吃水”太多,倒影有错误。如果场景中有粒子系 统,粒子倒影会不正确,所以要先关掉粒子系统,渲染完倒影后,在打开粒子。 - 327 - 第十八章 shader 应用的举一反三 有很多初级的开发人员,经常会说这个 shader 效果不错,但是总是发现项目的模型和 这个情况不一样,无法使用;也有一些初级的开发人员都不会知道一些 shader 能运用到自 己的项目中增加效果。本章就只是把前面的 shader 代码,修改修改适用不同的情况,本章 主要讲一下内容:  渲染一辆车  渲染一个场景 18.1 渲染车 18.1.1 渲染车灯 通常渲染汽车车灯的时候,常规做法是弄一张贴图来表示车灯而已,如图18.1.1_1所示, 但是效果不好;如果车灯能有些光束散射出来,效果就好很多,如图18.1.1_2所示。要达到 这个效果可以做个车灯灯光的3D模型,放到车灯处,也可以使用Shader语言开发一个汽车灯 光效果。 图18.1.1_1 图18.1.1_2 请读者回想一下第16.3.4小节学习的毛发shader,其实可以使用同样的原理来实现车灯 的渲染。如果只是简单的把毛发shader搬过来用,发现效果完全不行,这个毛发shader是不 是不适用?答案是:修改一下就能用。修改后的代码如下所示: //--- 系统自动设置的参数 float4x4 World : WORLD; // 世界矩阵 float4x4 Wvp : WORLDVIEWPROJECTION; // World x View x Projection 合矩阵 int PassCount : PASSCOUNT; // shader中pass的数量 int PassIndex : PASSINDEX; // 当前pass的索引 float4 mat_diffuse : DIFFUSE; // 物体的材质的漫反射光 //--- 要程序填写的参数 float3 lightPos ; // 光的位置 - 328 - float FurLength = 0.65; // 光线的长度 float FurThickness = 3; // 光线的密度 static float4 LightColor = {1,1,1,1}; // 光的颜色(白色) //--- 将光源的位置转换到世界坐标系中 static float3 localLightPos = mul(World,float4(lightPos,1)); //根据顶点的位置、顶点法线和光的位置计算漫反射光照 float4 DiffuseLighting(float3 lPos,float3 lNorm) { float3 lightDir = normalize( localLightPos - lPos ); //--- 光的方向 //--- 漫反射结果 return clamp((mat_diffuse * LightColor * dot(lNorm, lightDir)),0,1) + mat_diffuse*0.5f; } //顶点输出结构 struct vOUTPUT { float4 Position : POSITION; float4 Color : COLOR; float2 TexCoord : TEXCOORD0; float2 TexCoord1 : TEXCOORD1; }; //顶点渲染函数,这个函数在20个pass中都调用, //参数PassIndex and PassCount 系统会自动赋值 vOUTPUT FurVertexShaderPass( float3 Position : POSITION, float3 Normal : NORMAL, float2 Tex0 : TEXCOORD0 ) { vOUTPUT Out = (vOUTPUT)0; //---计算当前pass的百分比:当前pass索引/pass总数,其中是0到1 float passRatio = (float)(PassIndex) / (float)PassCount; //--- 以平方曲线的方式画点,避免线性画点导致不美观 passRatio *= passRatio; //--- 计算每条光线的位置 float3 p = Position + (Normal * passRatio * FurLength); Out.Position = mul(float4(p,1),Wvp); //---顶点投影变换 Out.Color = DiffuseLighting(p,Normal); //---漫反射光照 //------------这里是与Furshader不同的地方------------------------------ Out.Color.a =((1-passRatio)/(0.5*PassCount))*FurThickness; //光线不同的alpha值 Out.TexCoord = Tex0; //-- 材质使用贴图的UV坐标 Out.TexCoord1 = Tex0 * FurThickness; //--噪声贴图使用的UV坐标 return Out; } texture BaseTexture : TEXTURE; // 当前材质的使用的贴图 texture NoiseTexture; // 光线的噪声贴图 sampler BaseSampler = sampler_state { //---材质使用的贴图的采样器 - 329 - texture = ; MipFilter = LINEAR; Minfilter = LINEAR; Magfilter = LINEAR; }; sampler NoiseSampler = sampler_state {//--噪声贴图的采样器 texture = ; MipFilter = LINEAR; Minfilter = LINEAR; Magfilter = LINEAR; }; /********************************************** 这个像素着色函数在20个pass中被调用,它的作用是画点,它把原图的贴图的像素与噪声 贴图的像素通过一定比例叠加在一起。 ***********************************************/ float4 FurPixelShader(vOUTPUT In) : COLOR { float4 bCol = tex2D( BaseSampler, In.TexCoord )*2; //--采样光线的表面贴图 float4 nCol = tex2D( NoiseSampler, In.TexCoord1 ); //--采样光线的噪声贴图 float4 alpha = float4(1,1,1,nCol.a); //--获得光线的缝隙值 /------------这里是与Furshader不同的地方------------------------------ bCol.a=In.Color.a*nCol.a; //--返回最终的颜色值 return bCol; } /************************************ 这个渲染技术包含了20个相同的渲染pass **************************************/ technique Fur { pass p0 { VertexShader = compile vs_1_1 FurVertexShaderPass(); //--顶点渲染 PixelShader = compile ps_1_1 FurPixelShader(); //--像素渲染 } pass p1 { /------------这里是与Furshader不同的地方------------------------------ CullMode =NONE; //--渲染裁剪方式 AlphaBlendEnable = TRUE; //--开启Alpha混合 DestBlend = ONE; //-- Alpha的混合方式 SrcBlend = SRCALPHA; //--关于Alpha详细参考本书5.2章节 VertexShader = compile vs_1_1 FurVertexShaderPass(); PixelShader = compile ps_1_1 FurPixelShader(); } pass p2 { - 330 - VertexShader = compile vs_1_1 FurVertexShaderPass(); PixelShader = compile ps_1_1 FurPixelShader(); } pass p3 { VertexShader = compile vs_1_1 FurVertexShaderPass(); PixelShader = compile ps_1_1 FurPixelShader(); } ……. pass p20 { VertexShader = compile vs_1_1 FurVertexShaderPass(); PixelShader = compile ps_1_1 FurPixelShader(); } } 上面的代码与原来毛发shader代码比较,只是修改了三处地方。如果不加修改的运用, 效果如图18.1.1_3所示,可以看出主要是毛发部分的渲染出现了问题,因为毛发是往外扩张 的,而扩张的部分出现了透明异常。读者可以把pass p1到pass p20的代码注释掉就可以验 证这个问题。其实如果车身不使用shader代码,只是简单的附一张贴图渲染的话,这个问 题就不存在,但是车身不加上shader代码进行渲染,就出不来效果也是不行的。 图18.1.1_3 这个问题是alpha混合时先后问题造成的,只要在Virtools脚本中把车身车灯两者先后 都隐藏,在依次显示就能解决问题,处理脚本如图18.1.1_4所示,修改后的效果如图18.1.1_5 所示。 图18.1.1_4 - 331 - 图18.1.1_5 车灯效果仍然不理想,车光不像,太密了,透明度不对。对定点渲染函数和像素渲染 函数中涉及透明算法的几条语句进行修改,修改后的效果如图18.1.1_6所示: 图18.1.1_6 效果已经很接近了,就是灯光太过于透明,剩下的目的已经很明确就是:加强灯光亮 度。由于光线与背景相融合的透明方式,无论怎么设置都无法达到足够的亮度,要想其他办 法才行。解决的办法还是在alpha混合方式上下手,注释掉在pass p0处的alpha设置,并在 pass p1处添加新的alpha混合方式,设置新的alpha混合方式的语句是如下所示: AlphaBlendEnable = TRUE; //--开启Alpha混合 DestBlend = ONE; //-- Alpha的混合方式 SrcBlend = SRCALPHA; //--关于Alpha详细参考本书5.2章节 修改后完成后再调节一下毛发的密度和长度,效果已经不错了,如图18.1.1_2所示。其实这 个从毛发修改过了的效果,还能表现一些闪闪发亮的东西,比如科幻游戏中表现一个扔过来 的光子炸弹或者海盗的宝藏。 18.1.2 渲染车轮 汽车的轮子的渲染,如果只是简单使用一张贴图来表示,表现效果不够好;大多都使 用法线贴图来渲染车轮,第15.2.4小节的shader代码可以直接拿过来使用,效果还能接受, 不足的地方是车胎处有些地方镜面反射太强,如果是一个旧车轮或者是肮车轮,在生锈和污 秽的地方,出现镜面反射效果就显得不自然。解决的办法就是使用一张光泽贴图来控制高光 的反射程度。图18.1.2_1是三种渲染方式的差异比较: 仅贴图渲染 法线渲染 带光泽贴图的法线渲染 图18.1.2_1 这一节讲的是如何把15.2.4小节的shader代码修改成带光泽贴图的法线渲染,所谓光泽 贴图就是用来控制镜面反射的贴图,它是一张只有黑白的贴图,使用这张贴控制特定顶点的 反射程度,比如说一块铁板,有些部位已经锈迹斑斑,那么光泽贴图对应部分就比较暗,有 些部分还很光滑,那么光泽贴图对应部分就比较亮。如果不使用光泽贴图仅仅采用常规方式 - 332 - 渲染,就会导致生锈的地方也发亮,这显然不对。 为了方便调节shader的相关参数,把一些基本参数与材质的属性参数关联起来,会方便 许多,比如与光照有关的漫反射、自发光、镜面反射参数等等。需要在原来的代码基础上多 传递一张贴图,多设置一个采样器,这张贴图控制着物体表面的镜面反射程度。算法就没什 么好解释的就是在计算镜面反射的结果上,再乘上采样光照贴图所得的颜色值,由于光泽贴 图中只包含黑白两色,即每个RGB分量都相同,所以只需要使用一个分量乘以 specularLight (镜面反射强调参数)就行了。具体代码如下所示: float4x4 Wvp : WORLDVIEWPROJECTION; //合矩阵 float4x4 World : WORLD; //世界矩阵 float3 EyePos : EYEPOS; //观察者位置向量 float3 lightPosition: NEARESTLIGHT ;//光的位置 float4 lightColor: NEARESTLIGHT ;//光的颜色 float4 EmissiveCol: EMISSIVE; /物体自发光颜色 float4 DiffuseCol: DIFFUSE; //物体漫反射颜色 float4 SpecularCol: SPECULAR; //物体镜面反射颜色 float specularPower : POWER; //光泽因子 bool SpecularAble = true; //是否开启镜面反射 bool BurningAble = true; //是否开启增强光照 bool useParallax = true; //是否开启视差贴图技术 float burnFactor = 1.17; //增强光照系数 float parallaxAmplitude = 0.05; //视差高度系数 float parallaxOffset = 0.8; //视差偏移系数 float3 normalMapScale = {-1,-1,1}; //一个修正方向的向量 texture Tex ; //3D物体的表面纹理贴图 sampler2D TexSampler = sampler_state //纹理的采样方式 { texture = ; Minfilter = LINEAR; Magfilter = LINEAR; MipFilter = LINEAR; AddressU = Wrap; AddressV = Wrap; }; texture normalMap; //3D物体的表面的法线贴图 sampler bumpSampler = sampler_state //纹理的采样方式 { texture = ; MipFilter = LINEAR; Minfilter = LINEAR; Magfilter = LINEAR; }; texture2D GlossMapTex ; //这里是修改的部分,增加了光泽贴图 sampler SGlossMap = sampler_state //纹理的采样方式 { - 333 - Texture = ; MinFilter = LINEAR; MagFilter = LINEAR; MipFilter = LINEAR; }; //输入结构 struct VS_Input { float4 position:POSITION; //物体顶点位置向量 float3 normal : NORMAL; //物体顶点法线向量 float2 texCoord:TEXCOORD0; //UV坐标 float3 Tangent : TEXCOORD1; //切向量 float3 Binorm : TEXCOORD2; //副法线向量 }; //输出结构 struct VS_OutPut { float4 position:POSITION; //物体顶点位置向量 float2 texCoord:TEXCOORD0; //UV坐标 float3 N:TEXCOORD1; //物体顶点法线向量 float3 L:TEXCOORD2; //指向光源向量 float3 C:TEXCOORD3; //指向观察者向量 float3 T:TEXCOORD4; //切向量 float3 B:TEXCOORD5; //副法线向量 }; //顶点渲染阶段入口函数 VS_OutPut BasicTransform_VS(VS_Input In) { VS_OutPut OUT; float4 newPostion=In.position; OUT.position=mul(newPostion,Wvp); //得到顶点坐标与合矩阵的乘积 float4 wPos=mul(newPostion,World); //把局部的顶点坐标转换到世界坐标系中 float3 P=wPos.xyz; //得到世界坐标系中顶点的位置向量 OUT.N=normalize(mul(In.normal,World));//得到世界坐标系中顶点的方向向量 OUT.L=normalize(lightPosition-P); //得到指向光源的向量 OUT.C=normalize(EyePos - P); //得到观察者向量 OUT.T=In.Tangent; //得到顶点的切线 OUT.B=In.Binorm; //得到顶点的副法线 OUT.texCoord=In.texCoord; //得到UV坐标 return OUT; } //像素渲染阶段入口函数 float4 Texture_PS(VS_OutPut P):COLOR { - 334 - float2 uv = P.texCoord; //得到uv纹理坐标 if( useParallax ) //是否开启视差贴图技术 { //把切线和副法线转换到观察者方向 float2 camDir = mul( float2x3(P.T,P.B), P.C ); float depth = tex2D( bumpSampler, uv ).a-parallaxOffset; //采样高度 uv -= depth*parallaxAmplitude*camDir; //对uv进行偏移 } float3 bumpNorm = tex2D( bumpSampler,uv); //用偏移后uv值对法线贴图进行采样 float3 normalComponent=2*(bumpNorm -0.5); //对采样的结果进行解压 bumpNorm = normalComponent*normalMapScale; //对解压的结果进行一个修正 bumpNorm =mul(bumpNorm , float3x3(P.T,P.B,P.N)); //把向量转换到切线空间 float4 finalCol =tex2D(TexSampler, uv ); //偏移后uv值对纹理贴图进行采样 float diffuseLight = max(dot(bumpNorm ,P.L),0); //根据法线、计算光照 float4 diffuse=DiffuseCol*lightColor*diffuseLight+EmissiveCol; if( BurningAble ) //增强光照系数 { diffuse*= burnFactor ; } else { diffuse= saturate( diffuse ); } finalCol*=diffuse; if(SpecularAble ) //计算镜面发射光照 { float4 GlossCol=tex2D(SGlossMap , P.texCoord); //这里是增加的语句 float3 C =normalize (reflect( P.C,bumpNorm )); //这里是修改的部分 float specularLight=pow(clamp(dot(P.L,-C),0,1) , specularPower)* GlossCol.x; finalCol += SpecularCol*lightColor*specularLight; } return finalCol; } technique tech { pass p { VertexShader = compile vs_2_0 BasicTransform_VS(); PixelShader = compile ps_2_0 Texture_PS(); } } 再渲染一些物件的时候,有时不会很在乎光源的位置,所以干脆就会把光源的位置固 定到物件的局部坐标系中,同样的大多数情况下光的颜色都是白色,也可以把这个参数固定 下来,这样做的目的是少暴露几个参数,调试起来方便吧了,固定光源位置和光颜色的语句 - 335 - 如下所示: float3 lightPosition: NEARESTLIGHT ;//光的位置 float4 lightColor: NEARESTLIGHT ;//光的颜色 很多人都认为法线贴图、光泽贴图、视差图都是美工提供的,确实是这样,但是互联 网上有很多出色的软件可以根据一张贴图就能导出法线贴图、光泽贴图等,而且还可以调节 导出贴图的相关参数,相当好用,Crazybump就是一个这样的好软件,该软件生成法线贴 图时,有凸,有凹两种法线贴图,选择凹的法线贴图,才正确,因为Virtools的法线贴图与 其他引擎的相比较,正好反了过来 18.1.3 渲染车窗 常规渲染车窗的做法是弄一张玻璃,然后半透明处理,但是半透明会把整张贴图全部 半透明掉,连车窗上贴着的广告都透明掉,而且玻璃无法反射环境的图像。其实把第15.3.3 章节的反射环境映射修改一下就能达到目的,效果如图18.1.3_1所示: 仅贴图渲染 带透明通道的环境映射渲染 图18.1.3_1 要想实现车窗玻璃上不透明的广告贴纸,需要车窗玻璃的贴图带有透明通道,如图 18.1.3_2所示,白色区域表示不透明的广告贴纸区域,其他部分表示透明的程度值。 图18.1.3_2 下面是在第15.3.3章节的反射环境映射的shader代码基础上添加了几行语句而已,就 能实现预期的效果: float4x4 Wvp : WORLDVIEWPROJECTION; //合矩阵 float4x4 World : WORLD; //世界矩阵 float3 EyePos : EYEPOS; //观察者位置向量 float3 lightPosition; //与光源位置关联起来的位置向量 float4 lightColor; //光的颜色 float4 EmissiveCol; //物体自发光颜色 float4 DiffuseCol; //物体漫反射颜色 float4 SpecularCol; //物体镜面反射颜色 bool EnableSpec=false; //是否开启镜面反射 bool EnableCubeMap=false; //是否开启环境映射 - 336 - float shininess = 0.8; //两贴图纹理相互混合系数 float specularPower = 0.8; //镜面反射强调系数 texture Tex ; //3D物体的表面纹理贴图 sampler2D TexSampler = sampler_state //纹理的采样方式 { texture = ; Minfilter = LINEAR; Magfilter = LINEAR; MipFilter = LINEAR; AddressU = Wrap; AddressV = Wrap; }; texture2D CubeMap; //立方体贴图 sampler CubeMapSampler= sampler_state //立方体贴图的采样方式 { Texture = ; MinFilter = LINEAR; MagFilter = LINEAR; MipFilter = LINEAR; }; //输入结构 struct VS_Input { float4 position:POSITION; //物体顶点位置向量 float3 normal : NORMAL; //物体顶点法线向量 float2 texCoord:TEXCOORD0; //UV纹理坐标 }; //输出结构 struct VS_OutPut { float4 position:POSITION; //物体顶点位置向量 float2 texCoord:TEXCOORD0; //UV纹理坐标 float3 N:TEXCOORD1; //物体顶点法线向量 float3 L:TEXCOORD2; //指向光源向量 float3 C:TEXCOORD3; //指向观察者向量 }; //顶点渲染阶段入口函数 VS_OutPut BasicTransform_VS(VS_Input In) { VS_OutPut OUT; OUT.position=mul(In.position,Wvp); //得到顶点坐标转换到裁剪坐标系 float4 wPos=mul(In.position,World); //把局部的顶点坐标转换到世界坐标系中 float3 P=wPos.xyz; //得到世界坐标系中顶点的位置向量 OUT.N=normalize(mul(In.normal,World)); //得到世界坐标系中顶点的方向向量 - 337 - OUT.L=normalize(lightPosition-P); //得到指向光源的向量 OUT.C = normalize(EyePos - P); //得到观察者向量 OUT.texCoord=In.texCoord; //得到UV坐标 return OUT; } //像素渲染阶段入口函数 float4 Texture_PS(VS_OutPut P):COLOR { float diffuseLight = max(dot(P.N,P.L),0); float4 diffuse=DiffuseCol*lightColor*diffuseLight; float4 basecol= tex2D(TexSampler, P.texCoord); //这里是修改的部分 float4 col = basecol*(EmissiveCol+diffuse); //这里是修改的部分 col.a=basecol.a; if(EnableSpec) //是否开启镜面反射光照 { float3 C =normalize (reflect( P.C,P.N)); float specularLight=pow(clamp(dot(P.L,-C),0,1) , specularPower); col += SpecularCol*lightColor*specularLight; } if(EnableCubeMap) //是否开启环境映射 { //求光线的反射向量 float3 refTexCoord=normalize( -reflect(normalize(P.C) , normalize(P.N)) ); //对立方体贴图进行采样 float4 colrefl = texCUBE(CubeMapSampler, refTexCoord); //将物体的纹理与环境映射的结果进行混合 return lerp(col , colrefl, shininess); } else { return col; } } technique tech { pass p { //这里是修改的部分,要开启Alpha混合才能,透明才起作用 AlphaBlendEnable = TRUE; DestBlend = INVSRCALPHA; SrcBlend = SRCALPHA; VertexShader = compile vs_2_0 BasicTransform_VS(); PixelShader = compile ps_2_0 Texture_PS(); } - 338 - } 这节的其实都没有学习新的shader代码,只是在之前学习的基础上,融会贯通、举一 反三罢了;他们的原理都是一样,只是对渲染语句做些修改,结果就不一样了。 18.2 渲染场景 18.2.1 带法线的地形渲染 前面的第17.1章节,提到渲染一个地形可以使用5层纹理融合,但是地表看起来没有凹 凸感,这一节就是在原来地形的基础上,融合第第15.2章节的内容,渲染一个带法线的地形, 这样看上去效果会好很多,起码更真实一些,如图18.2.1_1和图18.2.1_2所示。 图18.2.1_1 不带法线的地形渲染 图18.2.1_2 带法线的地形渲染 地形渲染原理没有改变,原来只是直接把贴图刷到地形上去,现在要先把贴图与贴图 的法线进行处理,然后再将处理后的颜色刷到地形上去。由于不同的贴图其法线也不同,所 以就要在shader代码上增加几个采样法线纹理的参数,具体代码如下所示: float4x4 wvp : WORLDVIEWPROJECTION; //world矩阵×View矩阵×projection矩阵 float4x4 World : WORLD; // world矩阵 float3 EyePos : EYEPOS ;// 眼睛的位置 bool UV02; // 是否使用第二套UV bool lightmap; // 是否使用引用图层 texture texBaseDiffuse; // 贴在地形最底层纹理 sampler2D TextureBaseDiffuse = sampler_state // 地形最底层的纹理进行采样器 { texture = ; - 339 - MinFilter = LINEAR; MagFilter = LINEAR; }; texture texBaseDiffuseNor; //地形最底层法线 sampler2D TextureBaseDiffuseNor = sampler_state //地形最底层法线采样器 { texture = ; MinFilter = LINEAR; MagFilter = LINEAR; }; texture texDiffuse1; // 贴在地形第一层的纹理 sampler2D TextureDiffuse1 = sampler_state // 地形第一层的纹理进行采样器 { texture = ; MinFilter = LINEAR; MagFilter = LINEAR; MipFilter = LINEAR; }; texture texDiffuse1Nor; // 地形第一层法线 sampler2D TextureDiffuse1Nor = sampler_state // 地形第一层法线采样器 { texture = ; MinFilter = LINEAR; MagFilter = LINEAR; MipFilter = LINEAR; }; texture texDiffuse2; // 地形第二层的纹理 sampler2D TextureDiffuse2 = sampler_state // 地形第二层的纹理进行采样器 { texture = ; MinFilter = LINEAR; MagFilter = LINEAR; MipFilter = LINEAR; }; texture texDiffuse2Nor; // 地形第二层的法线 sampler2D TextureDiffuse2Nor = sampler_state // 地形第二层法线采样器 { texture = ; MinFilter = LINEAR; MagFilter = LINEAR; MipFilter = LINEAR; }; texture texDiffuse3; // 地形第三层的纹理 sampler2D TextureDiffuse3 = sampler_state // 地形第三层的纹理进行采样器 - 340 - { texture = ; MinFilter = LINEAR; MagFilter = LINEAR; MipFilter = LINEAR; }; texture texDiffuse3Nor; // 地形第三层的法线 sampler2D TextureDiffuse3Nor = sampler_state // 地形第三层法线采样器 { texture = ; MinFilter = LINEAR; MagFilter = LINEAR; MipFilter = LINEAR; }; texture texMask; // 融合这些纹理的alpha通道图 sampler2D TextureMask = sampler_state // 对alpha通道纹理进行采样 { texture = ; MinFilter = LINEAR; MagFilter = LINEAR; MipFilter = LINEAR; }; texture texLightmap; // 地形的阴影信息图 sampler2D TextureLightmap = sampler_state // 对地形的阴影信息纹理进行采样 { texture = ; MinFilter = LINEAR; MagFilter = LINEAR; MipFilter = LINEAR; }; float3 TexLoop01; // 地形贴图重复数 float3 TexLoop02; // 地形贴图重复数 bool fog; // 是否使用雾效 float4 fogcolor; // 雾的颜色 float fognear = 100; // 雾的最近有效距离 float fogfar = 150; // 雾的最远有效距离 float4 colorEmissive: Emissive; // 地形自发光颜色 float4 colorDiffuse : Diffuse; // 地形漫反射颜色 float burn = 3; // 色彩曝光参数值 float3 normalMapScale = {-1,-1,1}; // 一个修正法线方向的向量 struct VSIN //顶点渲染输入结构 { float3 pos : POSITION; float4 col : COLOR; - 341 - float3 norm : NORMAL; float2 uv : TEXCOORD0; float2 uv2 : TEXCOORD1; float3 Tangent : TEXCOORD2; float3 Binorm : TEXCOORD3; }; struct VSOUT//顶点渲染输出结构 { float4 pos : POSITION; float2 uv : TEXCOORD0; float2 uv2 : TEXCOORD1; float3 N:TEXCOORD2; float3 L:TEXCOORD3; float3 C:TEXCOORD4; float3 T:TEXCOORD5; float3 B:TEXCOORD6; }; float3 LightPos; //得到光的位置 float4 LightCol; //得到光的颜色 // 对光照的结果做点修饰的变量 float overflowingDiffuse = 0.3; //地形的漫反射颜色与光的颜色相乘,得到混合的色彩 static float4 combinedDif = colorDiffuse * LightCol; VSOUT Diffuse_VS( VSIN vsIn ) //顶点渲染函数 { VSOUT vsOut; vsOut.pos = mul( float4(vsIn.pos,1), wvp ); //将物体换算到屏幕空间正确的位置上。 vsOut.uv = vsIn.uv; //得到地形第一套UV坐标 if(UV02) //是否使用第二套UV坐标 { vsOut.uv2 = vsIn.uv2; //得到地形第二套UV坐标 } else { vsOut.uv2 = vsIn.uv; //得到地形第二套UV坐标 } float4 wPos=mul( float4(vsIn.pos,1),World); //将物体换算到世界空间中 float3 P=wPos.xyz; //物体在世界空间中的位置 vsOut.N=normalize(mul(vsIn.norm,World)); //将物体法线换算到世界空间中 vsOut.L=normalize(LightPos-P); //得到光源到顶点位置的向量 vsOut.C=normalize(EyePos - P); //得到摄像机到顶点位置的向量 vsOut.T=vsIn.Tangent; //得到顶点的切线 vsOut.B=vsIn.Binorm; //得到顶点的副法线 return vsOut; - 342 - } float4 Diffuse_PS( VSOUT psIn ) : COLOR { float4 result; psIn.uv2.y = 1 - psIn.uv2.y; //顶点反转,有些模型不需要反转。 float4 AlphaLayer = tex2D(TextureMask , psIn.uv2); //采样融合通道信息贴图 float3x3 TBN= float3x3(psIn.T,psIn.B,psIn.N); //定义切线空间 //最低层纹理及其法线采样 result= tex2D(TextureBaseDiffuse , psIn.uv*TexLoop01.x); float3 bumpNorm= tex2D( TextureBaseDiffuseNor ,psIn.uv*TexLoop01.x); float3 normalComponent=2*(bumpNorm -0.5); //对采样法线的结果进行解压 bumpNorm = normalComponent*normalMapScale; //对解压的结果进行一个修正 bumpNorm =mul(bumpNorm ,TBN); //把向量转换到切线空间 float diffuseLight = max(dot(bumpNorm ,psIn.L),0); //根据法线、计算光照 diffuseLight += -diffuseLight *overflowingDiffuse+overflowingDiffuse; float4 diffuse3=combinedDif *diffuseLight+colorEmissive; //得到光照色彩 result= (result*(1-AlphaLayer.r))*diffuse3; //得到最底层代法线表面色彩 //同理第一层纹理及其法线采样 float4 ColorLayer = tex2D(TextureDiffuse1 , psIn.uv*TexLoop01.y); bumpNorm= tex2D( TextureDiffuse1Nor ,psIn.uv*TexLoop01.y); normalComponent=2*(bumpNorm -0.5); bumpNorm = normalComponent*normalMapScale; bumpNorm =mul(bumpNorm ,TBN); diffuseLight = max(dot(bumpNorm ,psIn.L),0); diffuseLight += -diffuseLight *overflowingDiffuse+overflowingDiffuse; diffuse3=combinedDif *diffuseLight+colorEmissive; result= result+ ColorLayer *(AlphaLayer.r)*diffuse3; //颜色融合 //同理第二层纹理及其法线采样 ColorLayer = tex2D(TextureDiffuse2 , psIn.uv*TexLoop01.z); bumpNorm= tex2D( TextureDiffuse2Nor ,psIn.uv*TexLoop01.z); normalComponent=2*(bumpNorm -0.5); bumpNorm = normalComponent*normalMapScale; bumpNorm =mul(bumpNorm , TBN); diffuseLight = max(dot(bumpNorm ,psIn.L),0)*diffuseLight; diffuseLight += -diffuseLight *overflowingDiffuse+overflowingDiffuse; diffuse3=combinedDif *diffuseLight+colorEmissive; result= result*(1-AlphaLayer.g) + ColorLayer *(AlphaLayer.g)*diffuse3; //颜色融合 ////同理第三层纹理及其法线采样 ColorLayer = tex2D(TextureDiffuse3 , psIn.uv*TexLoop02.x); bumpNorm = tex2D( TextureDiffuse3Nor ,psIn.uv*TexLoop02.x); normalComponent=2*(bumpNorm -0.5); bumpNorm = normalComponent*normalMapScale; bumpNorm =mul(bumpNorm ,TBN); - 343 - diffuseLight = max(dot(bumpNorm ,psIn.L),0); diffuseLight += -diffuseLight *overflowingDiffuse+overflowingDiffuse; diffuse3=combinedDif *diffuseLight+colorEmissive; result= result*(1-AlphaLayer.b) + ColorLayer *(AlphaLayer.b)*diffuse3; //颜色融合 if(lightmap) //是否使用光照阴影图 { diffuse3 = tex2D(TextureLightmap, psIn.uv2); result= (result* diffuse3) * burn; //颜色融合 } else { result= result* burn; //颜色融合 } return result; // 返回最终的颜色值 } technique tech { pass p { FogEnable = ; FogColor = ; FogDensity = 0; // pixelshader fog FogVertexMode = NONE; FogTableMode = LINEAR; // Fog distance settings FogStart = ; FogEnd = ; VertexShader = compile vs_1_1 Diffuse_VS(); PixelShader = compile ps_3_0 Diffuse_PS(); } } 本节也没有新东西出现,也只是把前面章节的shader代码融会贯通到一起而已。虽然 效果有了凹凸感,但是程序运行效率下降了许多,毕竟多采样了一倍的贴图数,为了提高效 率,远处的地形其实不需要使用法线 ,等摄像机走近了再使用法线。听起来好像又变复杂 了,但是原理还是没有变,只是多了一下逻辑要处理,这个逻辑就是:要判断摄像机到顶点 的距离,并根据这个距离把没有使用法线的远景演示与使用法线的近景颜色,自然过渡并融 合起来。其实这还不是最复杂的,在一些不需要着重考虑运行效率的虚拟现实项目中,远景 近景都要使用不同的法线融合,这样的情况就又更加复杂了一些。有兴趣的读者可以参考示 例:例子18.2.1_1.cmo,其中在shader编辑器中TextureSplating是原来的地形渲染shader 代码,TextureSplatingNor是本例列出的带法线的地形渲染shader代码,TextureSplatingFar 是不考虑运行效率的远景近景都使用法线的地形渲染shader代码。 由于像素着色器ps2.0运算符和贴图采样数有限制,所以本例代码使用了像素着色器 - 344 - ps3.0。即使是PS3.0也最大支持16纹理的采样,而远景近景都是使用法线的地形渲染代码 已经超出了16纹理的采样,所以有2层纹理不能使用远景法线渲染。虽然现在像素着色器版 本已经到了5.0,但目前Virtools只能到PS3.0。 18.2.2 树的渲染 渲染一颗树与渲染一个茶壶,一块石头,一个油桶等这类的物体不同,不同之处在于 树叶的复杂性,大多数游戏中的树叶,都是运用几个面片,纵横叠加来表示。由于使用的是 面片,一个面片的法线只有一个,所以当选择摄像机观察这些叶子时,会发现很多叶子看不 见,只能在材质属性中,选择“Both Sided(双面渲染)”才能看见全部的叶子。另外一个 不同之处在于,叶子是带透明通道的,必须开启Alpha测试才能,并调节alpha测试参数值, 否则就会看到叶子有黑边,如图18.2.2_1和图18.2.2_2所示。 按前面学习的思路,要很好的渲染一个物体,除了原有的纹理贴图外,还需要法线贴 图和光泽贴图,第18.1.2小节渲染汽车轮胎的shader貌似非常合适,赶紧拿过来用上。发现 使用后的效果与不使用shader仅使用常规光照的效果要好很多,但可惜,有黑边!这就是 没有Alpha测试没有开启的结果。如何开启Alpha测试,方法比较简单,需要修改的代码如 下所示: „„ //此处省略 float3 lightPosition; //修改了灯光参数 bool EnableAlpha; //是否开启Alpha float AlphaTestNum = 180; //Alpha测试参照值 „„ //此处省略 technique tech { pass p { AlphaBlendEnable=; //是否开启Alpha混合 SrcBlend =SrcAlpha; //设置Alpha混合方式 DestBlend =InvSrcAlpha; AlphaTestEnable = ; //是否开启Alpha测试 AlphaRef = ; //设置Alpha测试参照值 AlphaFunc = GREATER; CullMode = NONE; //不进行背面裁剪 VertexShader = compile vs_2_0 BasicTransform_VS(); PixelShader = compile ps_2_0 Texture_PS(); } } - 345 - 图18.2.2_1 开启Alpha测试 图18.2.2_2 关闭Alpha测试 本小节也没有学习新的渲染技术,只是拿旧的修修改改,就能运用到其他类型物体的渲染上 了,详细参看实例“18.2.2_1.cmo”。 18.2.3 树叶与草的渲染 上一小节的树的渲染,和这一节树叶的渲染是不同的,一个是模型细节比较丰富的树 的渲染,一个是面片树叶的渲染。很多时候,3D游戏场景中大面积的草地,茂密的树叶, 如果使用模型来刻画细节,难道很大,效果不一定好,相反使用许多纵横交错的面片来表示, 效果和实时运行效率都好很多。这种用面片制成的草地和树叶有一个缺陷就是光照信息不能 很好的表达,因为面片的顶点太少,无法很好的计算光照。解决这个问题的办法是采样一种 光影贴图,把光影附到草地上和叶子上。采样光影的的方法也有一个缺陷就是,光照不是动 态的,而是静态的,不会根据摄像机和光影的位置变化而变化。即使如此,总比没有光影单 调的叶子好多了。如图18.3.1_1所示: - 346 - 图18.2.3_1 既然是使用的2个面来表示一片树叶,就可以使用第16.1.2章节的shader修改一下,就 让草和树叶飘动起来,效果就会丰富些。具体代码如下所示: float4x4 worldViewProj : WORLDVIEWPROJECTION; float4x4 World2 : WORLD; float time : TIME; texture colorTexture :Texture ; //模型贴图及其采样器 sampler2D colorTextureSampler= sampler_state { Texture = ; MinFilter = Linear; MagFilter = Linear; MipFilter = Linear; }; texture LightTexture ; //光影贴图及其采样器 sampler2D LightTextureSampler= sampler_state { Texture = ; MinFilter = Linear; MagFilter = Linear; MipFilter = Linear; }; float4 emissive : EMISSIVE; //自发光程度设置 float TimeScale = 2; //叶子摆动周期 float frequency = 2; //叶子摆动频率 float amplitude = 10; //叶子摆动幅度 float4 lightPos : NEARESTLIGHT ; //光的位置 float4 lightColor : NEARESTLIGHT ; //光的颜色 - 347 - bool EnableLightTex; //是否使用光影图 bool EnableUV02; //是否使用第二套UV bool EnableAlpha; //是否使用Alpha混合 bool EnableAlphaTest; //是否使用Alpha测试 float AlphaTestNum = 180; // Alpha测试参考值 float burn = 2.0; // 颜色加强 struct a2v { //顶点渲染输入结构体 float4 position : POSITION; //顶点位置 float2 texCoord : TEXCOORD0; //第一套UV float2 texCoord02 : TEXCOORD1; //第二套UV }; struct v2f { //顶点渲染输出结构体 float4 position : POSITION; //顶点位置 float2 texCoord : TEXCOORD0; //第一套UV float2 texCoord02 : TEXCOORD1; //第二套UV float4 color : COLOR; //顶点颜色 }; v2f v(a2v In) //顶点渲染函数 { v2f Out; Out.position = In.position; //树叶摆动算法 if(In.texCoord.y < 0.9) { Out.position.x += sin(Out.position.x * frequency + (time/TimeScale)) * amplitude* 1-In.texCoord.y; } // 顶点坐标转换到屏幕坐标 Out.position = mul(Out.position,worldViewProj); Out.texCoord = In.texCoord; //得到第一套UV if(EnableUV02) //是否使用第二套UV Out.texCoord02 = In.texCoord02; else Out.texCoord02 = In.texCoord; float4 normal = float4(0.0f, 0.0f, 1.0f, 0.0f); //叶子的法线用于指向-Z轴 float4 worldSpacePos = mul(In.position, World2); //顶点转换到世界空间 //构造一条光位置到顶点位置的向量,用于计算光照 float4 normLightVec = normalize(lightPos - worldSpacePos); //计算顶点的光照颜色 Out.color = max(dot(normal,normLightVec),0) * lightColor + emissive ; Out.color.a = 1.0f; return Out; } - 348 - float4 f(v2f In ) : COLOR //像素渲染函数 { float4 colorMap = tex2D(colorTextureSampler, In.texCoord); //得到叶子的纹理信息 if(EnableLightTex) { float4 LightMap = tex2D(LightTextureSampler, In.texCoord02); //得到叶子阴影信息 colorMap *=LightMap ; } colorMap *=burn; //调亮颜色 return In.color*colorMap ; } technique Complete { pass envPass { VertexShader = compile vs_1_1 v(); AlphaBlendEnable=; //是否开启Alpha混合 SrcBlend =SrcAlpha; //设置Alpha混合方式 DestBlend =InvSrcAlpha; AlphaTestEnable = ; //是否开启Alpha测试 AlphaRef = ; //设置Alpha测试参照值 AlphaFunc = GREATER; CullMode = NONE; //不进行背面裁剪 PixelShader = compile ps_2_0 f(); } } 这节的草与树叶飘动的shader算法比起16.1.2小节花朵舞动的算法要精细得多,但是 16.1.2的shader可以表现海底“S”形扭动的海带,而本节的shader却不行,所以各有各的 妙用。本例中草地和小树是没有使用光影的,只有大树才使用光影,shader代码中把相关 变量暴露成参数,以便在不同的模型中设置不同的效果参数。详细参看例子18.2.3_1.cmo。 - 349 - 第三部分 Virtools SDK 第十九章 认识 Virtools SDK Virtools SDK 是我们控制 Virtools 工作的最底层的东西了,能掌握好它,就如同自己 拥有一把锋利的剑,但仍然不要指望它无所不能。像其他收费的游戏引擎一样,Virtools SDK 只是提供到函数级别以上的源代码、函数级别以下的代码是没有的。使用 SDK 开发 一个必须的条件是学习过 C/C++程序。本章将带你浏览 Virtools SDK 内容、先做个整体印 象的了解。本章的主要内容是:  Virtools SDK 结构的简单介绍  开发 BB 工具的介绍  创建一个简单的 BB  BB 的特征介绍 19.1 Virtools 的体系结构 19.1.1 SDK 的介绍 SDK是“Software Development Kit”的首字母缩写,翻译过来就是软件开发工具。这 组开发工具(静态链接库、动态链接库、头文件、源文件)使得程序员能够方便地访问Virtools 的功能函数、程序员直接使用这些功能函数将会有更大的发挥空间,不但可以扩展Virtools 的功能、还可以使用Virtools引擎开发一些自定义的应用程序。 既然是工具,就有使用工具的使用方法,总不能拿着锤子去砍柴、而拿着菜刀去炒菜; SDK提供程序员开发便利的同时,也制定了它的开发规则。本书的SDK部分内容不会像VSL 部分内容那样,讲述每个类型及其每个函数,因为SDK太庞大了。作者的目标仅仅是希望 能把读者带到融会贯通的地步而已,剩下的就只有读者自己去摸索了。 Virtools引擎是以一个模块化的架构组织起来的,所有Virtools的可执行程序,包括exe 播放器、自定义的播放器、可视化的编辑开发(devr.exe)和其他开发插件,都是共同遵循 这这个模块化的组织架构。其大概的架构如图19.1.1_1所示: 图19.1.1_1 - 350 - Virtools引擎中关键部分由两个DLL组成:CK2和VxMath。VxMath提供的是一些底层 的函数;DK2则是这个引擎的行为准则、组织和管理引擎的其他模块。Virtools的所有模块 都是通过插件的方式来实现的,它有很多这样的插件,这些不同的插件承担着不同的功能模 块的实现。 在使用SDK进行开发时会接触到Virtools的API(Application Programming Interface, 简称API),翻译过来就叫做“应用编程接口”。SDK有5个类型的API,它们分别是:VxMath API、CK2 API、Standard External Managers API、The RenderEngine API、The Rasterizer API。 VxMath API是一个功能强大的工具库。它提供了很多类和方法能够在三维空间处理、 三维互动、以及多线程编程等对我们有很大帮助。Virtools本身就基于这个库,这个库是经 过优化的、一些函数甚至是以汇编的方式执行,所以非常高效。VxMath API包含5个方面的 内容: 1. 数学函数——包括向量,矩阵,四元数以及碰撞计算等。 2. 容器——列表,数组,二叉树,哈希表,字符串等。 3. 系统工具——硬件检测,线程,文件路径管理,DLL的访问等。 4. 图形函数——颜色,图像描述等。 5. 其它宏定义和公用函数集 当您使用VxMath库时,不要忘记在VC工程项目中将库VxMath.lib链接到你的工程文件 中,才可以编译通过。 CK2 API是最为庞大的部分、最核心部分,它定义了所有的图形和行为规则,它包括 100多个类。比如我们常见的角色(CKCharacter)、网格(CKMesh)、材质(CKMaterial) 纹理(CKTexture)等等。当您使用CK2库时,也不要忘记VC工程项目中将库CK2.lib链接 到你的工程文件中,才可以编译通过。 其他功能管理器接口(Standard External Managers API),这部分管理器有些是属 于CK2 API、有些则不是,但访问它们并不困难。每个管理器都管理自己那一块事情,用户 也可以自定义自己的管理器,默认存在的管理器有13个如下所示: 1. 属性管理器(CKAttributeManager):管理Virtools中所有的属性。 2. 行为管理器(CKBehaviorManager):管理行为模块和脚本。 3. 碰撞管理器(CKCollisionManager): 管理碰撞的计算和处理。 4. 地板管理器(CKFloorManager): 管理地板计算的和处理。 5. 网格管理器(CKGridManager): Virtools中网格(grid)及其相关bb的计算和处理-。 6. 输入管理器(CKInputManager):管理外部输入设备的信息及处理。 7. 消息管理器(CKMessageManager):管理Virtools中消息机制的处理。 8. MIDI声音文件管理器(CKMidiManager):管理MIDI声音文件的执行操作。 9. 参数管理器(CKParameterManager):管理Virtools中脚本中参数。 10. 路径管理器(CKPathManager):管理执行程序及其资源的文件路径。 11. 渲染管理器(CKRenderManager):处理图像渲染对象功能。 12. 声音管理器(CKSoundManager):声音的处理。 13. 时间管理器(CKTimeManager):处理Virtools中涉及时间的功能。 The RenderEngine API是负责渲染部分的工作,它包含了所有的3D渲染功能。它的 一个主要作用是控制物体的渲染过程,比如说控制2D帧的渲染顺序。由于渲染引擎 (RenderEngine)本身是一个插件,所以可以写一个用户自定义的渲染插件替代原来的插 件。 The Rasterizer API是负责把最终的场景呈现在显示终端,也就是光栅化的过程,所 - 351 - 谓光栅化就是将图元转变为二维图像的过程。这组API主要是被渲染引擎所调用,同样的它 本身也是一个插件,用户也可自定义自己的光栅化插件来进行工作。 Virtools的开发是基于一个非常开放的架构,它的插件架构图如下所示: 图19.1.1_2 19.1.2 帧循环的介绍 当Virtools执行程序开始执行后就在其循环进程中不断地循环下去,直到该执行程序被 暂停或退出为止。在这不断的循环叫“帧循环”,其中的每一圈叫做“一帧”。从这一点可以 看出这样的运行是很消耗系统资源的,但这是必须的,这种实时运行的程序可以快速地响应 任何变化带来的执行结果的不同。 衡量一个实时运行的程序的好坏,除了常规的指标外,还有一个重要的指标,它就是 FPS值(Frame Per Second的缩写)。FPS中文意思是每秒帧数,即帧速,也就是说程序在 一秒中内,这个“帧循环”中能跑几圈。一般的FPS在30以上,人的眼睛就可以观察到流 畅的实时渲染画面了,如果这个帧速太低,人的眼睛就会察觉到画面的停顿、跳跃;当FPS 到了60帧以上时是非常的理想了。 Virtools中的每一帧从开始到结束都是有规则地、重复地执行的,图19.1.2_1是一帧内 的执行步骤图,当然如果你使用Virtools SDK开发自定义的程序这个帧的步骤图就不是默认 的这个了,而是你自己定义的顺序。 图19.1.2_1 这个步骤分四个部分:按顺序依次为行为模块执行前、行为模块执行、行为模块执行 后、渲染。 行为模块执行前与行为模块执行后这两个阶段,将行为模块执行夹在中间,这样可以 方便的对一些参数和操作在执行前做一些类似初始化的操作;当行为模块执行完毕,又可以 对结果做一些修正。这两个部分都是管理器模块执行操作的时间段。 - 352 - 在行为模块执行阶段中、Virtools中的各个行为按照优先级别一个一个的往下执行完成。 首先是优先级别较高的对象、其次是该对象的行为脚本、然后是行为脚本中的那些“BB” 和“BG”。如果两个对象的优先级别相同,那么谁先谁后是未知的。一个行为脚本在执行过 程中可能会激活其他行为脚本,但会出现延时,被激活的行为脚本真正开始执行时,可能会 是过去数帧之后,当然这种延时也可能是0。 行为模块是CK2 API的中心、主要的部分,而CK2又是Virtools的核心,所以可见学习 和掌握行为模块的重要性。它负责执行并管理类似行为对象,参数,属性,输入,输出等对 象的操作。这个模块包括既负责组织上述对象的内存组织形式,也负责对这些对象进行读和 写,并且还处理这些对象之间的内在联系。这个行为模块是一个概念的称呼,它并不是有一 个专门的类或者函数来实现,而是一个相互关联的函数集、类集之间的协作。 在渲染阶段中,3D对象(人物,场景几何等)和2D对象(游标,精灵,界面元素等) 被当前图像渲染设备描绘出来。当前图像渲染设备保存着一个要渲染的对象的队列。可以在 行为执行模块修改这个队列,来控制这个队列渲染那些对象。 这个渲染阶段内部还可以细分,图19.1.2_2所示是其内部渲染对象的顺序。它首先对 后备缓冲、深度缓冲的清除,其次是渲染背景2D对象、然后才是3D对象,接着是前置2D对 象,最后将后备缓冲填充主缓中,屏幕才显示出来。 图19.1.2_2 19.1.3 主要对象之间的关系 每个Virtools应用程序只包含一个设备上下文(Context)。设备上下文(Context)可以 访问所有对象和算法,包括文件管理,内存管理,帧循环,管理器,插件以及渲染引擎等。 可以说设备上下文(Context)就是CK2的实现。 每个Virtools应用程序只包含一个Level层,与其说是层,不如说它是个容器,是个最大 的容器,Virtools中其他的所有对象都包含在其中。在Virtools中有很多种组织管理对象的方 式。最大的容器是Level层,场景、组、表、参数、脚本等包含被包含在Level层中;场景、 组、表又可以包含其他物件(比如3D实体、2D帧、材质、相机等);这些其他物件也可以 不在任何场景、组、表而是直接存在于Level中。而场景、组、表等都可以存在多个不同的 个体,在Virtools软件中可以在Level层中创建多个场景、组、表,但无法创建另外一个Level 层。 至于什么是场景(Scene)、组(Groups)、表(Arrays)在VSL章节部分已经详细介 绍了,而区域(Places)、入口(Portals)由于其VSL部分并不实用,所以就没有在本身中 介绍。但在Virtools图形化的编辑窗口中,可以简单使用,这里也就不多说了。Virtools中的 对象之间的关系只要理解本小节所述即可。其他部分的关系如图19.1.3_1所示。 渲染设备(Render Context)也可把它看做是渲染引擎部分,一个Level层中可以有多 个渲染设备(这组需求的项目,笔者很少用过),也不在此妄言什么,详细请参考相关SDK 文档。 - 353 - 图19.1.3_1 19.2 使用 SDK 创建第一个 BB 19.2.1 行动前的准备 首先要安装C++语言的编译工具VC++,Virtools3.0需要安装VC6.0;Virtools3.5、 Virtools4.0需要安装VC2003;Virtools4.1和Virtools5.0则需要安装VC2005。如果安装的编 译工具不与Virtools的版本号对应,会出现不少麻烦。 VC++安装完成以后,在开发BB之前,首先设置好要包含的Virtools SDK的头文件和库 文件路径。步骤如下: 打开VC++2003 ——> 选择菜单“工具”按钮——> 在弹出的下拉菜单中选中“选 项”按钮 ——> 在弹出“选项”对话框中,选中“Projects”下的“VC++目录”项,如图 19.2.1_1所示 ——> 在“显示以下内容的目录”的下拉框中,选择“包含文件”,然后添 加Virtools头文件路径,如图19.2.1_2所示 ——>在“显示以下内容的目录”的下拉框中, - 354 - 选择“库文件”,然后添加Virtools库文件路径,如图19.2.1_3所示。不同的VC版本可能画 面有些出入,但是大同小异。 如果有条件最好再安装VC助手软件VisualAssistX。它是一个非常好的VC插件,自动 识别各种关键字,函数,成员变量,自动给出输入提示,自动更正大小写错误,自动标示错 误等等。关于VisualAssistX的使用就不多说了。 在Virtools安装目录里面找到两个文件夹“vcprojects”和“VCWizards”。其默认位置 是在:C:\Program Files\Virtools\Virtools Dev 3.5\Sdk\Utils\Wizard这个位置,如果你的 Virtools在其他盘符,那么路径上就有所不同。将上面两个文件夹复制到VC2003的目录中去, 其默认目录是:C:\Program Files\Microsoft Visual Studio .NET 2003\Vc7,同样的如果你的 VC++在其他盘符,那么路径上同样有所不同。复制的时候,windows是系统会提示是否要 覆盖原有的两个目录,选择“是”。这两个目录主要是开发Virtools的BB的VC向导,不用向 导开发BB也可以,但就很麻烦。 图19.2.1_1 - 355 - 图19.2.1_2 图19.2.1_3 19.2.2 通过向导创建一个 BB 之所以使用向导来创建BB,是因为该向导会帮助我们初始化一些相关操作,方便开发, 同时也使得Virtools引擎能够识别我们用VC创建的dll文件;还可以避免与其他BB的冲突。这 一小节我们将创建一个简单的BB,虽然这个BB没有一些具体的使用意义,只是演示开发一 个BB的过程,为后面的内容做个基础铺垫。 - 356 - 图19.2.2_1 打开VC++2003新建一个Visual C++项目,在弹出的对话框中,你会发现有两个Virtools 向导:“Virtools Interface Plugin”和“Virtools Dev BB and Managers”。前者是开发Virtools可 视化的编辑界面中的插件,更简单的说这个向导开发的不是BB,而是开发扩展Virtools这个 软件的新面板插件;后者就是我们说的开发新BB,同时它还可以开发自己的管理器(如图 19.2.2_1所示)。这些在往后的章节中会具体介绍。 图19.2.2_2 把项目命名为“HelloWorld”后,然后设置好项目存放的路径,点击确定。此时又接着 弹出一个对话框,要求选择是开发BB(Building Block)还是开发管理器(Manager),选择 “Building Block”项。然后点击下一步,如图19.2.2_2所示。 - 357 - 图19.2.2_3 接着弹出的对话框是:描述我们开发BB的外部特征定义向导。其中“Building Block Name”表示BB的名字,我们将这个BB命名为:wow_Helloworld。“ Building Block GUID” 表示该BB的全局唯一标识符,它是一组十六进制数字,点击该按钮可以重新生成另一组数 字的标识符。“Apple to Class”表示我们生成的这个BB,只能被哪些对象所应用;比如说“2D Text”这个BB,就只能被2D帧(Entity2D)这个类型使用,而不能被角色(Character)这个 类型使用。剩下的几个选项就是设置BB的输入、输出参数和输入、输出端口的相关数量和 类型。在这里我们设置该BB有2个输入端口、1个输出端口;增加两个字符串类型的输入参 数,增加一个字符串类型的输出参数,如图19.2.2_3所示。然后点击下一步。 这时弹出的对话框提供了一组多选框,这些多选框是改变BB的外部特征功能的设定, 这些多选项不一定每个都有有效的,比如“Custom edition dialog”和“Custom setting edition dialog” 即使你勾选了它们,但没起作用。这组多选框,大多数情况下不需要选,直接选择 下一步吧,如图19.2.2_4所示。 - 358 - 图19.2.2_4 其中: “Targetable”——BB可以增加一个设置作用目标的参数。如图19.2.2_5中蓝圈所示。 图19.2.2_5 “Send Messages”和“Receive Messages”——BB可以发送、和接受消息,在Virtools 脚本中有BB有特殊的标记。如图19.2.2_6中红圈所示。 图19.2.2_6 “Input can be created by behavior”和“Output can be created by behavior”——表示 BB的输入、输出端口是否可以在脚本中改变其数目。如图19.2.2_7中红圈所示 图19.2.2_7 “Input parameters can be changed by behavior”和“Output parameters can be changed by behavior”——表示BB的输入、输出参数是否可以改变。这种使用方式在Virtools中使用 - 359 - 得很多,大家都明白,我就不粘图表示了。 对于剩下的几个选项,我不打算解释了,大家就看着字面上的意思去理解好了,有兴 趣的读者可以去尝试一下这些选项,看看对实际应用是否有帮助。上面列举的多选项,只是 表示该BB可以发生这些行为,但要实现这些行为还是要自己写代码实现的,这些选项只是 告诉Virtools我们自定义的BB有哪些功能,并在BB的外形中显示一些相关标记(请注意图 19.2.2_6和图19.2.2_7中所示BB的左下角发生的小变化)。这些小标记,记不住也没关系, 记住BB的作用和用法就行了。 再接下来弹出的对话框中又提供了一组多选框,这些多选框我们不去管它。这个对话 框中对我们有用的就只有那两个输入文本框,如图19.2.2_8所示。其中: “Behavior category”——是设置该BB存放在Virtools的哪个类别中,方便用户从这个类别 中拖拽到Virtools脚本中。 “Description”——是对该BB的一些注释,建议写些让人一目了然并且很久以后能让人想 起该BB的作用的注释。 图19.2.2_8 最后弹出的对话框是对该插件的一些注释,以及关于开发该插件的人员的注释,除此 之外没有特别的用意。我们之间点击“Finish”,完成这些设置,如图图19.2.2_9所示。 - 360 - 图19.2.2_9 19.2.3 BB 源文件的解释 在向导完成了所有设置之后,进入到VC的编辑界面,会发现向导为我们生成了三个文 件。通过观察可以发现“HelloWorld.cpp”和“HelloWorld.def”这两个文件的文件名是在图 19.2.2_1中进行设置的;而“wow_HelloWorld.cpp”的文件名是在图19.2.2_3中进行设置的, “wow_HelloWorld”也将是该BB在Virtools脚本中显示的名字。所以在使用向导的时候这两 处的命名要区别开来。如图19.2.3_1所示: 图19.2.3_1 文件“HelloWorld.def”中的内容不多,也不需要对其进行添加或修改其代码。它的存 在是让Virtools从中知道如何获得这个插件的一些相关属性,对于这个文件我们不需要在意。 文件“HelloWorld.cpp”是对这个插件的详细描述。说到有必要理顺一下“DLL”、“插件” 和“BB”的关系。 DLL是Dynamic Link Library的缩写,翻译为动态链接库。在Windows系统中,它允许 程序共享执行特殊任务所必需的代码、数据或函数以及其他资源。当执行某一个程序时,相 应的DLL文件就会被调用。一个应用程序可有多个DLL文件,一个DLL文件也可能被几个应 用程序所共用。 插件(Extension)也称为扩展,是一种遵循一定规范的应用程序接口编写出来的程序, 主要是用来扩展软件功能。很多软件都有插件,包括Virtools,如图19.1.1_2所示,这些插 件都是由Virtools公司自己开发,而此刻我们用向导生成的插件则是软件用户个人开发的。 - 361 - 这些插件最终是以DLL文件的形式存在。当然也可以以其他的文件格式存在,比如:静态链 接库等。 BB则是Virtools特有的,这个对于Virtools程序员来讲是再熟悉不过了。插件的功能通 过BB来表示,或者说程序员通过各种不同的BB来发挥出各个插件的功用。一个插件可用包 含一个或多个BB、甚至可以不包含任何BB,比如管理器。正确的说我们用向导开发生成出 来的DLL文件是一个扩展了Virtools功能的插件,这个插件中有一个BB可以发挥该插件的功 能。其实大家都喜欢直观的说是开发一个BB,这些都无所谓了,因为并没有造成任何歧义。 文件“HelloWorld.cpp”中代码也不多,如果开发的DLL文件中只包含一个BB,那么这 个这个文件也不需要做任何添加或修改。这个文件的主要目的是对我们开发插件的详细描述, 比如怎样初始化自定义插件、如何安全退出自定义插件、以及插件中各个BB的声明、还有 一些插件相关信息的声明。其代码如下: #include "CKAll.h" //Virtools的头文件,开发Virtools插件不可缺少的 CKERROR InitInstance(CKContext* context); //初始化自定义插件 CKERROR ExitInstance(CKContext* context) ; //安全退出自定义插件 #define PLUGIN_COUNT 1 //插件数目的宏定义 CKPluginInfo g_PluginInfo[PLUGIN_COUNT]; //插件信息结构数组 int CKGetPluginInfoCount() //返回插件的数量 { return PLUGIN_COUNT; } //插件的信息描述:包括插件的开发者、插件类型、版本、全局唯一标识符、以及相关注释、 //和简要。 CKPluginInfo* CKGetPluginInfo(int Index) { int Plugin = 0; g_PluginInfo[Plugin].m_Author = "Virtools"; g_PluginInfo[Plugin].m_Description = "Enter your description here"; g_PluginInfo[Plugin].m_Extension = ""; g_PluginInfo[Plugin].m_Type = CKPLUGIN_BEHAVIOR_DLL; g_PluginInfo[Plugin].m_Version = 0x00010000; g_PluginInfo[Plugin].m_InitInstanceFct = NULL; g_PluginInfo[Plugin].m_GUID = CKGUID(0xd213eaaf, 0xb3e2bbac); g_PluginInfo[Plugin].m_Summary = "Enter your summary here"; return &g_PluginInfo[Index]; } // 如果开发的插件不是管理器类型,也不增加自定义的参数类型,那么初始化插件函数、 退、//出插件函数,没有必要在此添加任何代码 CKERROR InitInstance(CKContext* context) { return CK_OK; } CKERROR ExitInstance(CKContext* context) { return CK_OK; - 362 - } // 对该插件中所包含BB的注册。如果有多个BB,必须都在此处一一注册。 void RegisterBehaviorDeclarations(XObjectDeclarationArray *reg) { RegisterBehavior(reg, FillBehaviorwow_HelloWorldDecl); } 上述代码中有两个类型CKPluginInfo和XobjectDeclarationArray,它们一个用来表示插 件的信息、一个用来表示注册BB的队列。还有一个函数RegisterBehavior( ),它的第二个参 数是一个函数的指针,这个指针就是该BB的注册函数的地址。其实细心的读者会发现,文 件“HelloWorld.cpp”和“HelloWorld.def”在有几个函数名是一样的; “HelloWorld.cpp” 和“wow_HelloWorld.cpp”也有出现相同函数名的地方。它们的存在目的是让Virtools去自 动的识别我们生成的DLL文件中所包含的插件信息。 文件“wow_HelloWorld.cpp”是我们重点操作的文件,是创建BB细节的地方。这个文 件中除了输入BB本身功能相关代码外,还有对输入、输出、局部参数的修改;输入、输出 端口的修改;以及BB的回调函数处理等等。这些地方如果某处没有修改正确,都会给后续 的开发带来麻烦。下面让我们来一行一行的看它的代码: #include "CKAll.h" //包含Virtools的头文件 //4个前向声明函数 CKObjectDeclaration *FillBehaviorwow_HelloWorldDecl(); CKERROR Createwow_HelloWorldProto(CKBehaviorPrototype **); int wow_HelloWorld(const CKBehaviorContext& BehContext); int wow_HelloWorldCallBack(const CKBehaviorContext& BehContext); 上面的几行代码,只是包含的头文件,和一些前向声明函数,有时候由于开发的需要 会在这里添加自己要包含的其他头文件,添加自定义的前向声明函数。本小节则不会有这样 的需要。 CKObjectDeclaration *FillBehaviorwow_HelloWorldDecl() //BB的注册函数 { //创建一个BB的声明函数 CKObjectDeclaration *od = CreateCKObjectDeclaration("wow_HelloWorld"); od->SetType(CKDLL_BEHAVIORPROTOTYPE); //必须默认的类型 od->SetVersion(0x00000001); //设置该BB的版本 od->SetCreationFunction(Createwow_HelloWorldProto); //创建BB外部特征的函数 od->SetDescription("这个BB只是做个试验"); //创建一个BB的声明函数 od->SetCategory("行动纲领"); //设置BB的存放类别 od->SetGuid(CKGUID(0xd213eaaf, 0xb3e2bbac)); //设置一个全局唯一标识符 od->SetAuthorGuid(CKGUID(0x56495254,0x4f4f4c53)); //设置开发者的标识符 od->SetAuthorName("梦幻的魔法之门"); //创建一个BB的声明函数 od->SetCompatibleClassId(CKCID_BEOBJECT);//设置该BB专应用于哪一类型 return od; } FillBehaviorwow_HelloWorldDecl( )是向Virtools注册一个新BB的函数,同时在文件 “HelloWorld.cpp”中,这个函数以内存地址的身份做为一个输入参数传给了另一个函数 RegisterBehavior( )。所以使用向导生成的函数名,是不能够随意更改的。当然硬更改也可 以,就是要把所以使用了该函数名的地方,一处不漏的都修改过来,但这样做又为我们带来 - 363 - 什么好处呢?使用向导的意义又何在呢?所以没必要还是别修改这些函数名。 上面函数体中第一行代码的作用是设置这个BB在Virtools脚本中显示的名字, CKObjectDeclaration *od = CreateCKObjectDeclaration("wow_HelloWorld"); 字符串“wow_HelloWorld”正是我们给BB起的名字,这个名字与图19.2.2_3中“Building Block Name”一栏的所填写的内容是对应的。当然我们可以再此重新给BB命名,比如改成 我们惯用的中文名,虽然中文名让人一目了然,但是我们开发时常常使用“Ctrl+双击鼠标 左键”快捷方式的方式来添加BB,如果BB的名字是中文,添加起来就麻烦一些,慢一些了。 由向导生成一些内容,在这个函数体中可以得到修改,比如对该BB的描述通过函数 SetDescription( )进行重新设定、BB存放的类别通过函数SetCategory ( )进行重新设定、BB 开发者信息通过函数SetAuthorName ( )进行重新设定、以及使用BB的专门类型可以通过函 数SetCompatibleClassId ( )进行重新设定。剩下的几个函数修改的意义不大,就保持默认 吧,对该BB的注册信息重新设置完成以后,并编译生成DLL,然后将此DLL文件放到Virtools 环境中去,会看到如图19.2.3_2所示情况。 图19.2.3_2 上面代码中有行比较特别语句如下所示: od->SetCreationFunction(Createwow_HelloWorldProto); 这个语句是向Virtools注册BB外部特征的语句,它的参数是一个函数的指针,作为参数的函 数Createwow_HelloWorldProto( )的具体代码实现如下所示: CKERROR Createwow_HelloWorldProto(CKBehaviorPrototype** pproto) { CKBehaviorPrototype *proto = CreateCKBehaviorPrototype("wow_HelloWorld"); if (!proto) { return CKERR_OUTOFMEMORY; } //--- 声明输入端口 proto->DeclareInput("入口一"); proto->DeclareInput("入口二"); //--- 声明输出端口 proto->DeclareOutput("出口"); //--- 输入参数声明 proto->DeclareInParameter("words", CKPGUID_STRING); proto->DeclareInParameter("text", CKPGUID_STRING,‖梦幻的魔法之门‖); //--- 输出参数声明 proto->DeclareOutParameter("outstr", CKPGUID_STRING); //--- BB回调函数行为描述 proto->SetBehaviorCallbackFct(wow_HelloWorldCallBack, CKCB_BEHAVIORATTACH|CKCB_BEHAVIORDETACH|CKCB_BEHAVIORDELETE|C KCB_BEHAVIOREDITED|CKCB_BEHAVIORSETTINGSEDITED|CKCB_BEHAVIORLO AD|CKCB_BEHAVIORPRESAVE|CKCB_BEHAVIORPOSTSAVE|CKCB_BEHAVIORRE - 364 - SUME|CKCB_BEHAVIORPAUSE|CKCB_BEHAVIORRESET|CKCB_BEHAVIORRESET| CKCB_BEHAVIORDEACTIVATESCRIPT|CKCB_BEHAVIORACTIVATESCRIPT|CKCB_ BEHAVIORREADSTATE, NULL); proto->SetFunction(wow_HelloWorld); *pproto = proto; return CK_OK; } 上述代码中语句: CKBehaviorPrototype *proto = CreateCKBehaviorPrototype("wow_HelloWorld"); 是创建一个描述BB特征的行为对象,它需要传入一个字符串类型变量,这个字符串就是这 对象的名字,这个名字没有在Virtools中的其他地方所使用到,只是为了调试方便才需要命 名的。输入、输出端口和参数都可以用中文名,但中文名不要太长,另外输入参数可以初始 化值,几乎什么类型的值都可以初始化,其方法就是:将值用引号括起来即可。如下面初始 化字符串的值的语句: proto->DeclareInParameter("text", CKPGUID_STRING,‖梦幻的魔法之门‖); 除了输入、输出参数外,还有一个局部参数类型,只是本小节没有使用到而已,在后 面的章节中会出现这个类型的参数。另外这些输入、输出端口和参数的使用是有顺序区分的, 当我们用索引对这些类型进行访问时,排在最前面的编号是0,排在其次的是1,依次类推。 这个排序时分类的,输入端口是输入端口的顺序编号,输出参数是输出参数的顺序编号,互 不混淆的。 proto->SetBehaviorCallbackFct(wow_HelloWorldCallBack, „„ ) 上面这条语句的作用是设置这个BB的回调处理函数,参数一就是回调函数的地址,回 调函数是一个很有用的一个地方。而语句 proto->SetFunction(wow_HelloWorld); 则是设置BB实现自己功能的函数入口,它的参数也是一个函数的地址,这个作为参数传入 的函数如下所示: int wow_HelloWorld(const CKBehaviorContext& BehContext) { return CKBR_OK; } 看起来这个函数最简单,什么都没有,这是当然的,这个函数是留给开发者添加自己 代码的地方,想让这个BB实现怎样的功能就添加怎样的代码,这个函数也可以说是“BB行 为函数”或者说是“BB的实现函数”。我们做个简单的功能就让这个BB实现两个字符串的 相加,然后把结果作为参数输出,其代码修改后如下所示: int wow_HelloWorld(const CKBehaviorContext& BehContext) { CKBehavior *Beh=BehContext.Behavior; //得到描述Virtools行为的对象 CKContext *Ctx=BehContext.Context; //得到描述Virtools行为物件的对象 //得到BB的第一个、第二个参数的字符串 CKSTRING words = (CKSTRING)Beh->GetInputParameterReadDataPtr(0); CKSTRING text = (CKSTRING)Beh->GetInputParameterReadDataPtr(1); XString outstr=words; outstr+=text; //两字符串相加,结果输出到输出参数中 Beh->SetOutputParameterValue(0,outstr.Str(),outstr.Length()+1); - 365 - return CKBR_OK; //执行结束后,返回一个结果 } 该函数的参数“const CKBehaviorContext& BehContext”是一个结构体,这个结构体 的作用是做为参数传递给上面的“BB行为函数”和下面的“BB回调函数”。通过这个结构 体可以获得一些频繁使用的一些全局对象或全局变量,例如“BehContext.Behavior”它专 门用来获得及修改BB的输入参数、局部参数、输出参数的值,还能控制BB的输入输出端口 状态等功能。例如“BehContext.Context”它是Virtools的核心,它负责创建、删除Virtools 中的各种物件等功能。这几个对象在后边学习的SDK开发中,会形影不离的跟随本书,所 以这里只是简单介绍罢了。 CKSTRING words = (CKSTRING)Beh->GetInputParameterReadDataPtr(0); CKSTRING text = (CKSTRING)Beh->GetInputParameterReadDataPtr(1); 上面两行代码是是获得BB输入参数的字符串指针,因为是指针,所以得到指针后,要 类型转换一下才可以用。注意,说到字符串顺带提醒三点: 一、在Virtools的SDK中不要采用判断字符串指针是否为NULL来断言输入参数是否有输入 字符串,因为没有内容的字符串不一定就是NULL。 二、在使用对象XString时,不要用XString:: Length()来判断字符串是否有内容,即使是个 空串XString:: Length()也大于0。 三、要想判断是一个字符串否为空串,最安全的办法就是和另一个是空串的字符串进行比较, 请调用函数XString::Compare()吧。 得到输入参数的方法简单,设置输出参数值的代码也简单如下所示: Beh->SetOutputParameterValue(0,outstr.Str(),outstr.Length()+1); 由于CKSTRING是字符串指针、所以上面函数最后一个参数为什么要“+1”,可想而知了。 这个“BB行为函数”功能单一,就是两个字符串的相加,然后输出,其余的代码没什么必 要再多讲,要强调的只有对象“BehContext.Behavior”和“BehContext.Context”而已。 下面来看最后一个函数: int wow_HelloWorldCallBack(const CKBehaviorContext& BehContext) { switch (BehContext.CallbackMessage) { case CKM_BEHAVIORATTACH: //当在Virtools可视化编辑环境中,BB被拖放的脚本进行连线时,调用。 break; case CKM_BEHAVIORDETACH: //当BB的连线断开时调用 break; case CKM_BEHAVIORDELETE: //当BB被删除时调用 break; case CKM_BEHAVIOREDITED: //当BB被编辑时,比如增加输入、输出接口、修改参数类型等时调用 break; case CKM_BEHAVIORSETTINGSEDITED: //当BB改变“Setting”时调用 break; - 366 - case CKM_BEHAVIORLOAD: //当BB被载入内存时调用 break; case CKM_BEHAVIORPRESAVE: //当BB被保存之前调用 break; case CKM_BEHAVIORPOSTSAVE: //当BB被保存之后调用 break; case CKM_BEHAVIORRESUME: //当BB在程序开始运行,或者是暂停后又开始运行时调用 break; case CKM_BEHAVIORPAUSE: //当BB在程序被暂停时调用 break; case CKM_BEHAVIORRESET: //当BB在程序被复位时调用 break; case CKM_BEHAVIORNEWSCENE: //当BB在程序中场景进行切换时调用 break; case CKM_BEHAVIORDEACTIVATESCRIPT: //当BB所在脚本被禁止活动时调用 break; case CKM_BEHAVIORACTIVATESCRIPT: //当BB所在脚本被激活时调用 break; case CKM_BEHAVIORREADSTATE: //当BB的状态发生改变时,例如初始化条件,局部参数变化时调用 break; } return CKBR_OK; } “BB回调函数”在BB存活期间应付各种各样的事件发生的一个处理机制,这个存活期 间不仅仅是程序运行期间,也包括程序复位后的期间。这个回调函数的使用情况视开发的功 能而定,有些功能需要,有些则不需要,比如本例就不需要,所以本例也就不在此函数添加 任何代码,至于每个其每个分支的详细说明请参考Virtools SDK文档。 19.2.4 编译生成 BB BB的源代码解释完毕,剩下最后一步就是生成BB的了,如果前面的相关设置都已经完 成,生成BB这一步就最简单了,只要点击“重新生成解决方案”或“重新生成HelloWorld” 即可,如图19.2.4_1所示。 - 367 - 图19.2.4_1 生成完成后把生产的“HelloWorld.dll”文件拷贝到Virtools目录下的“BuildingBlocks”中去, 然后打开Virtools,就可以在BB分类窗口中发现“行动纲领”栏目下有一个“wow_试验”的 BB,如果图19.2.3_2所示。将这个BB拖到Virtools脚本中,给该BB的输入参数的两个字符 串随意赋值,运行结果如图19.2.4_2所示: 图19.2.4_2 BB我们是创建成功了,但有必要了解一下向导为我们创建的项目工程属性的特征。首先打 开项目工程属性对话框,如图19.2.4_3所示 图19.2.4_3 如果需要改变向导生成DLL文件的目录,可以在“输出文件”项中修改,如图19.2.4_3 所示。查看“附加依赖项”中,可以看到向导为我们添加了两个库“CK2.lib”和“ VxMath.lib”, 这两个库是开发BB必须。 - 368 - 现在我们是假设开发的BB是没有问题的,所以就直接放到Virtools中使用,而实际情况 下BB的功能太复杂时,出错经常发生,在调试某些Bug或功能的时候想单步调试或者下断点 来调试这个BB的代码,是常有的情况。只要在“命令”指定Virtools的应用程序,如图19.2.4_4 所示;然后再将输出文件路径指定到Virtools目录下的“BuildingBlocks”目录中,即可。然 后就可以单步运行调试了。当调试完成后不要忘记了生产“Release”版本的DLL。 图19.2.4_4 - 369 - 第二十章 用 SDK 开发 BB 要开发 BB,就必须要了解并能够控制 BB 的参数和端口,实际的项目需求多种多样, 现有的参数类型也有不适用的时候,这就需要能够自定义参数类型,本章主要讲的是 BB 的开发应用,主要内容包括:  BB 的参数类型及其作用  BB 的端口控制  如何自定义参数类型  用 SDK 开发几个 BB 20.1 BB 的参数 20.1.1 BB 的参数有三种 BB的参数有三种:输入参数、输出参数、局部参数。参数是BB之间相互传递数据的介 质,就像函数的输入、输出参数那样。BB的输入、输出参数见得太多了,就不解释了,下 面我们来看看何为局部参数,它的作用是什么,以及如何创建和使用它。 在C\C++的规范中,有这样的一种表达式:static int wow;一看就知道是静态变量, 当再一次进入函数时,这个静态变量的值还在,并没有随着函数的作用域的结束而消失。 BB的局部参数的作用与静态存储变量一样。下面我们来看看如何创建它。 接着第十九章的例子,打开该项目工程文件,找到函数Createwow_HelloWorldProto( ), 这里是我们添加了输入、输出参数的地方;也将是我们添加局部参数的地方,添加的代码如 下所示: //----BB的局部参数声明,字符串类型 proto->DeclareLocalParameter("localstr",CKPGUID_STRING,"NULL"); //---- BB的局部参数声明,布尔类型 proto->DeclareSetting("Enable",CKPGUID_BOOL,"FALSE"); 编译生成后放到Virtools中观察发现: BB“wow_试验”的外观上没有变化,局部参数并没 出现。右键该BB、在弹出的菜单中选择“Edit Settings”,会弹出一个对话框,发现我们声 明的第二个局部参数“Enable”就在其中,而声明的第一个参数“localstr”仍然没发现。 如图所示: 图20.1.1_1 其实第二个局部参数是第一个局部参数的一种特例,这种局部参数是可以让用户在 Virtools的可视化编辑窗口中进行修改的,其他用法没什么不同,声明这种特殊参数的函数 是DeclareSetting( )、声明常规局部参数的函数是DeclareLocalParameter( )。通常情况下类 似“静态变量”用法都是使用第一种局部参数,只有在确实需要由用户更改、BB本身不修 改其值、而仅是读取其值的条件下才使用第二种局部参数。 无论是声明BB的输入、输出参数还是声明局部参数,所调用的函数都需要传入三参数: - 370 - 第一个是参数的名字,用引号括起来;第二参数是参数的类型ID,详细参加附录B;第三个 是参数的初始值也是用引号括起来,这个参数不是必须的,不使用该参数,函数就会使用默 认的值。并小节例子请参看“三种参数类型_第二十章”目录。 20.1.2 BB 参数值的获取和赋值 到目前为止,开发的BB中只有字符串类型的参数,这只是其中一种常用参数而已,还 有些其他常用的参数类型,比如:对象、枚举、和类似整形、浮点等。获取和设置这些不同 类型的参数的值的方法是不一样的。我们继续上一小节的例子程序,首先给BB添加几个输 入参数,找到函数Createwow_HelloWorldProto在其函数体中添加如下代码: proto->DeclareInParameter("位置",CKPGUID_VECTOR,"0.0,1.0,2.0"); proto->DeclareInParameter("对象",CKPGUID_OBJECT3D,"NULL"); proto->DeclareInParameter("填充方式",CKPGUID_FILLMODE,"VXFILL_SOLID"); 请注意观察上面参数的类型和赋初值的方式,其中第一个参数是3维向量,其初始化的 各个分量值用逗号隔开;第二个参数值是3D对象,其初始化值一般为NULL,如果想指定某 个3D物体作为其初值,就输入这个3D物体的名字,如果输入名字命名3D物体不存在,则其 值会自动为NULL;第三个参数是一个枚举类型的值,其值可以是在范围内的整型数,也可 以是枚举类型的具体分量名。 参数添加完成后,接下来是如果获得这些参数值,找到函数wow_HelloWorld,添加如 下代码: VxVector pos; //定义一个3维向量 Beh->GetInputParameterValue(2,&pos); //得到第三个参数值 //得到第四个参数值 CK3dObject *Ob3D=(CK3dObject *)Beh->GetInputParameterObject(3); if(Ob3D) //如果参数正确 { Ob3D->SetName(outstr.Str()); //设置3D物体的新名字 Ob3D->SetPosition(&pos); //设置3D物体的新位置 CKMesh* Mesh=Ob3D->GetCurrentMesh(); //得到3D物体的网格 if(Mesh) //如果3D物体有网格 { CKMaterial *Mat=Mesh->GetMaterial(0); //得到网格的材质 if(Mat) //网格是否有材质 { VXFILL_MODE fillmode; //定义一个渲染的枚举类型 //得到第5个参数值 Beh->GetInputParameterValue(4,&fillmode); Mat->SetFillMode(fillmode); //更改物体的渲染模式 } else { Ctx->OutputToConsole("wow_试验的3D物体没有材质"); } } else - 371 - { Ctx->OutputToConsole("wow_试验的3D物体没有Mesh"); } } else { Ctx->OutputToConsole("wow_试验的\"位置\"参数错误"); } 这段代码的功能是根据输入的字符串给3D物体进行重命名,然后重新设置位置,最后 改变其渲染模式。这段BB的代码浓缩了3种获取参数值的方法,不同的参数类型,用不同的 函数,要对症下药。例如基本的参数类型使用函数GetInputParameterValue( );对于字符串 类型的参数使用函数GetInputParameterReadDataPtr( );对于对象类型的参数使用函数 GetInputParameterObject( )。函数GetInputParameterObject( )返回的值是一个基类的值, 要进行强制类型转换才能得到相应的类型。既然我们学会了如何得到输出参数的值,自然可 以想象得出得到局部参数的值和得到输出参数的值的函数方法了,就不在此罗列了,代码详 细请参考“参数值的获取和设置_第二十章”目录。 以上方法操作BB的参数是没有问题的,建议使用这些方法就好了,其实设置和获取参 数的值还有其他方法,只不过这些方法比较晦涩难懂,不提倡使用,但作为学习,还是来看 个例子吧。用VC重新建一个BB的项目文件,添加其输入、输出、局部参数如下所示: //输入参数 proto->DeclareInParameter("对象",CKPGUID_OBJECT3D,"NULL"); proto->DeclareInParameter("名字", CKPGUID_STRING); proto->DeclareInParameter("位置",CKPGUID_VECTOR,"0.0,1.0,2.0"); proto->DeclareInParameter("填充方式",CKPGUID_FILLMODE,"VXFILL_SOLID"); //输出参数 proto->DeclareOutParameter("对象1",CKPGUID_OBJECT3D,"NULL"); proto->DeclareOutParameter("名字1", CKPGUID_STRING); proto->DeclareOutParameter("位置1",CKPGUID_VECTOR,"0.0,1.0,2.0"); proto->DeclareOutParameter("填充方式1",CKPGUID_FILLMODE,"VXFILL_SOLID"); //局部参数 proto->DeclareSetting("ways",CKPGUID_INT,"0"); 可以发现BB的输入、输出参数的类型和数量是完全一样的,不错这个BB的功能就是将 输入参数不做任何处理地赋值给输出参数。这样做岂不是毫无实际意义,不错这个BB在功 能上完全没有用处,但它可以直接明了的暴露BB在设置和获取参数值的另一种方法。这种 方法的代码部分如下所示。至于这段添加在什么地方,想必读者已经明白,不明白的请重新 学习第十九章。 CKBehavior* beh = BehContext.Behavior; //获得行为控制对象 beh->ActivateInput(0,FALSE); //禁止端口再被触发 int way=0; //得到局部参数的值 beh->GetLocalParameterValue(0,&way); int count_in = beh->GetInputParameterCount(); //得到输出参数的数量 CKParameterIn *pin; //声明一个输入参数类型 CKParameterOut *pout; //声明一个输出参数类型 if(0==way) //方式一 - 372 - { for(int i=0;iGetInputParameter(i); //得到输入参数 pout = beh->GetOutputParameter(i); //得到输出参数 pout->CopyValue(pin->GetRealSource()); //直接赋值 } } if(1==way) //方式二 { CKObject* ob3d; //声明一个3D物体 pin = beh->GetInputParameter(0); //得到第一个输入参数 pout = beh->GetOutputParameter(0); //得到第一个输出参数 pin->GetValue(&ob3d); //得到第一个输入参数的值 pout->SetValue(&ob3d); //设置第一个输出参数的值 //往下的代码以此类推 CKSTRING Value; pin = beh->GetInputParameter(1); pout = beh->GetOutputParameter(1); pin->GetValue(&Value); pout->SetValue(&Value); VxVector Vec; pin = beh->GetInputParameter(2); pout = beh->GetOutputParameter(2); pin->GetValue(&Vec); pout->SetValue(&Vec); /* 这种设置值的方式,对以下类型无法适应,所以注释掉 VXFILL_MODE fillmode;//int fillmode; pin = beh->GetInputParameter(3); pout = beh->GetOutputParameter(3); pin->GetValue(&fillmode); pout->SetValue(&fillmode); */ } beh->ActivateOutput(0,TRUE); //激活输出端口 return CKBR_OK; 在这段代码上,碰到了两个类型是我们第一次见:输入参数(CKParameterIn)、输 出参数(CKParameterOut)。它们都是经CKParameter派生出来的类型,所以有很多相同 的函数方法,其实真正的值存储在CKParameter。在方式一中得到输入参数值的语句是 pin->GetRealSource(),该函数返回的值就是一个CKParameter类型,得到了这个返回值后, 作为参数传给函数CopyValue( )。语句pout->CopyValue( )的作用是将CKParameter的值 拷贝一份出来赋值给pout。在方式二中得到参数值的函数是GetValue( ),设置参数值的函 - 373 - 数是SetValue( ),它们只需传入对于类型的变量地址即可,可见方式二关键在于正确传入变 量类型。另外方式二对有些变量类型的参数,使用上有限制(代码中被注释的部分),比如 VXFILL_MODE等类似类型的参数。详细代码请参看 20.1.3 改变 BB 参数的数量和类型 在使用Virtools开发的时候,常会使用一些BB是可以改变其参数的数量及其参数的类型, 例如:“Test”、“Per Second”、“Identity”、“ Switch On Parameter”等等。这种可改变参 数类型及其数量的特性可大大的减少使用BB的数量和种类。如何开发这种可变参数的BB, 其实在早在第十九章已经介绍过了,现在来复习一下,在使用向导创建BB的时候,到设置 行为特性标志步骤时,勾选“Input parameters can be changed by behavior”、“User can change input parameters”,如图20.1.3_1所示。 图20.1.3_1 在完成向导生成BB代码的步骤后,你会发现这次向导创建的代码,比我们在第十九章 向导创建的代码多了一条语句: proto->SetBehaviorFlags((CK_BEHAVIOR_FLAGS)( CKBEHAVIOR_INTERNALLYCRE ATEDINPUTS|CKBEHAVIOR_VARIABLEPARAMETERINPUTS)); 这条语句就是设置BB行为特性的标准,该BB有什么样的特性由其参数决定,要想同时拥有 多个行为特性,用“|”号将多个特性相连即可。详细特性标准符可参考附录C所示。上面的 这条语句就是设置该BB的参数类型可变,参数数量也可变。 以上说的改变参数类型及其数量的方法,需要由开发人员一个参数、一个参数地添加 BB的参数、或是一个参数、一个参数地修改参数的类型,下面我们来看一下如何一次处理 多个BB参数的改变。在Virtools中有这样一个设置物体属性的BB,名为“Set Attribute”,它 一次可以给一个物体添加一个属性,如果你的物体有十个属性要添加,你就得调用十次这个 的BB,麻烦!如果有一个BB一次调用就能把物体的所有属性都设置好,岂不方便。那么我 - 374 - 们就来开发一个这样功能的BB吧,其模样如图20.1.3_2所示: 图20.1.3_2 在用向导生成BB代码的步骤中,到了图20.1.3_1所示步骤是,除勾选“Targetable” 外其他都不勾选,这样生成的BB,用户就无法改变其输入参数的类型和数量了,因为这是 留给BB内部改变的。默认情况下,该BB默认一次设置2个属性,所以BB的输入参数如下所 示: proto->DeclareInParameter("Attribute_0", CKPGUID_ATTRIBUTE); proto->DeclareInParameter("Attribute Value_0",CKPGUID_INT); proto->DeclareInParameter("Attribute_1", CKPGUID_ATTRIBUTE); proto->DeclareInParameter("Attribute Value_1",CKPGUID_INT); 我们要设置一个局部变量,来让用户能选择自己想要一次性设置一个物体的多少个属性,定 义局部变量代码如下所示: proto->DeclareSetting("Atrribute num",CKPGUID_INT,"2"); 至于什么是属性,属性的如何使用这些基础东西不是本节的重点,本节只讲解代码,重点是 改变BB的参数,以下是设置属性的代码,这段代码是参照“Set Attribute”的源码修改而来, CKBehavior* beh = BehContext.Behavior; CKContext * ctx = BehContext.Context; beh->ActivateInput(0,FALSE); // 关闭输入端口,防止一帧内多次触发 beh->ActivateOutput(0); // 打开输出端口,无论成功失败都触发 CKBeObject *beo = (CKBeObject*)beh->GetTarget(); // 得到使用该BB的物件 if(!beo) return CKBR_OK; // 失败则返回 int count; // 得到要设置多少个属性 beh->GetLocalParameterValue(0,&count); for (int i=0;iGetInputParameterValue(i*2,&Attribute); // 得到属性类型 beo->SetAttribute(Attribute); // 设置物体有这个属性 //通过定义一个输出参数,来存放这个物体属性的内存地址, CKParameterOut* pout = beo->GetAttributeParameter(Attribute); if(pout) { //属性赋值。 CKParameter* real; CKParameterIn* tpin = (CKParameterIn*)beh->GetInputParameter(i*2+1); if (tpin && (real=tpin->GetRealSource())) //得到参数的内存值 pout->CopyValue(real); //将内存值复制一份 - 375 - } } 上面属性值的获取和赋值的函数方法是固定的,要给物体设置一个属性,需要2个参数, 一个表示属性的类型,一个是表示属性的值。如果有多个属性要设置,就需要一个for循环 来处理。 当用户选择好BB参数属性类型后,我们要自动改变其要设置的属性值的类型,就如图 BB“Set Attribute”那样的自动化。这个功能不是在Virtools运行时才进行的,而是要在Virtools 脚本中编辑BB参数的时候完成的,用户编辑BB的参数在代码中实现的地方是在回调函数的 “CKM_BEHAVIOREDITED”中完成的,其代码如下: case CKM_BEHAVIOREDITED: { int c_pin = beh->GetInputParameterCount(); //得到输入参数的数量 //得到属性管理器 CKAttributeManager* Manager = BehContext.AttributeManager; for(int i=0;iGetInputParameterValue(i,&Attribute); //得到输入参数的值 //得到属性的类型 int ParamType = Manager->GetAttributeParameterType(Attribute); //得到下一个输入参数 CKParameterIn* pin = beh->GetInputParameter(i+1); if (pin) //设置参数的类型 { if (pin->GetType()!=ParamType) { pin->SetType( ParamType ); } }//--------if(pin)---------End------------ }//-------for---------End------------ }//-------------- case CKM_BEHAVIOREDITED----------End------------ break; 上面的代码中涉及属性的语句在以后的章节中介绍,此刻我们关注的是输入参数,改 变参数的语句只有一句“pin->SetType( ParamType )”,其参数ParamType是个整型,其 意义表示参数类型的GUID。这里的功能是设置下一个参数的类型是:上一个作为属性类型 的参数所需要的值。 我们开发的BB默认的可设置的属性数量是2个,也就是输入参数有4个。如果用户通过 局部参数设置值为n个,就要自动创建n*2个输入参数,同时也要设置这n*2个输入参数的类 型。实现这个功能的代码应该放在回调函数的“CKM_BEHAVIORSETTINGSEDITED”中 完成。其代码如下所示: case CKM_BEHAVIORSETTINGSEDITED: { char name[20]; //定义个字符串数组,以便设置输入参数的名字 int count=0; - 376 - beh->GetLocalParameterValue(0,&count); //得到局部参数的值 if (count<2) //如果默认是2,就不需要再新输入参数 { count=2; //如果设置的值小于2,提示错误信息 beh->SetLocalParameterValue(0,&count); ctx->OutputToConsole("wow_SetMoreAttribute 局部参数值必须大于2"); break; } int c_pin = beh->GetInputParameterCount(); //得到目前输入参数的数量 count*=2; //求出需求输入参数的数量 if (c_pinCreateInputParameter(name, CKPGUID_ATTRIBUTE); sprintf( name, "Attribute Value_%d", i/2); //设置输入参数的名字 //创建一个输入参数,其类型为默认整型 beh->CreateInputParameter(name, CKPGUID_INT); } } else if (c_pin>count) //实际数量大于需求数量 { for(int i=c_pin-1;i>=count;i--) //删除多余的输入参数 { beh->RemoveInputParameter(i); } } } break; 上面的代码的作用就是根据需求的输入参数数量,少就增,多就减。使用的函数也很 简单CreateInputParameter( )是创建一个输入参数,只需要传入要创建的参数的名字和类型。 移除一个输入参数则调用函数RemoveInputParameter( )即可。增加的参数类型都统一为属 性参数(CKPGUID_ATTRIBUTE)和整型(CKPGUID_INT)。上面两段代码一个是修改输 入参数的类型,一个是控制输入参数的数量,相辅相成,才能实现一次性输入多个属性的功 能。 这一小节我们实现了在回调函数处添加代码的学习,图20.1.3_2所显示的就是回调函 数中“case CKM_BEHAVIORSETTINGSEDITED”项的内容,这一项的作用就是当用户改 变了BB的Setting参数后,就会调用这一项的代码,执行操作,这个操作时用户自定义的。 而“case CKM_BEHAVIOREDITED” 项的内容,如图20.1.3_2、图20.1.3_2,图20.1.3_3 所示。回调函数的分支很多,详细参考附录D - 377 - 图20.1.3_3 当经过图201.3_2步骤设置了一次性输入3个属性之后,双击该BB,可以看到默认的属性类 型和属性值类型(图201.3_2)。当选择好属性类型后,此时无法立即设置属性的值(图 201.3_4中圈中所示)。但确定一次,然后再一次双击该BB打开其输入参数时,就可以设置 对于的属性值了(图20.1.3_5)。对于那些设定的属性值,仍然保持原有的类型。 图20.1.3_4 - 378 - 图20.1.3_5 这个BB的功能虽然只是对输入参数进行增加减少而已,以此类推对输出参数、局部参 数的操作也不外乎如此而已。这里就不一一介绍,也无法一一介绍,因为SDK的内容太多, 本书只是做到点到即止,详细参加SDK中Parameters相关章节。本节代码参考“设置多个 属性_第二十章20.1.3” 20.2 BB 的输入输出端口 20.2.1 BB 的输入输出端口控制 BB的输入输出端口,大家都非常熟悉了,当BB的某个输入端口被触发时,该BB的输 出端口可以对应的开启一个,也可以开启多个端口,或是一个端口都不开启,这些全赖于该 BB的判断而定。同样的BB的输入端口也可以在同一帧内同时触发,至于有何用处就看BB 内部逻辑了。BB的输入端口也可以像BB的参数那样,可以增加和减少,只不过用到的机会 相对少一些。 这一小节我们通过开发一个BB来学习如果动态的创建BB的端口。Virtools中有个BB叫 “Proximity”,其功能是判断两个3D物体间的距离知否在指定的半径范围内,其输出的结果 就只有范围内、和范围外两个结果。在游戏中NPC常常依据离玩家不同的距离做出不同的策 略行为,所以“Proximity”这个BB就用不上了。下面我们就来开发一个可以检测多个范围 的BB。首先用向导建立一个BB,然后参照图20.1.3_1选择特定的选项,至于勾选那几项如 果读者印象不深,请回头重新看本书第三部分内容,然后定义参数和端口如下所示: //--- 定义输入端口 proto->DeclareInput("开始检测"); proto->DeclareInput("截断检测"); //--- 定义输出端口 proto->DeclareOutput("开始时输出"); - 379 - proto->DeclareOutput("截断时输出"); proto->DeclareOutput("最大时输出"); proto->DeclareOutput("距离0输出"); proto->DeclareOutput("距离1输出"); proto->DeclareOutput("距离2输出"); //--- 定义输入参数 proto->DeclareInParameter("测试实体", CKPGUID_3DENTITY); proto->DeclareInParameter("参照物实体", CKPGUID_3DENTITY); proto->DeclareInParameter("检测时间间隔", CKPGUID_TIME,"0m 1s 0ms"); proto->DeclareInParameter("距离0", CKPGUID_FLOAT,"10"); proto->DeclareInParameter("距离1", CKPGUID_FLOAT,"20"); proto->DeclareInParameter("距离2", CKPGUID_FLOAT,"30"); //--- 定义输出参数 proto->DeclareOutParameter("上次输出口", CKPGUID_INT,"-1"); proto->DeclareOutParameter("当前间隔时间", CKPGUID_FLOAT,"0.0"); 可以看出输入参数、和输出端口的数量很多,因为该BB默认检测两3D物体在以小到大 3个不同的距离范围,另外添加了一个时间变量,它的作用是每隔多久检测一次,没有必要 每一帧都做一次距离的检测。同时可以看出我们又使用了中文作为参数的名字,这样增强可 读性。BB的第一个输出参数表示最近一次被打开的输出端口;第二个输出参数表示当前时 间间隔增量,当它大于出入参数的的检测时间间隔时,BB检测一次完成后,该参数的值会 归零。 至于如何实现范围的检测不是本小节的重点,并且逻辑也简单,所以我们关键是学习 输入、输出端口的操作。其代码如下所示: CKBehavior* beh = BehContext.Behavior; CKContext* ctx = BehContext.Context; //--- 先将所有的输出端口全部关闭 int count_param_out = beh->GetInputParameterCount(); for(int i=0;iActivateOutput(i,FALSE); } float limit=0.0f; //检测时间间隔 int outnum=0; //表示哪一个输出端口被激活 if(beh->IsInputActive(1)) //中断BB的运行 { beh->ActivateInput(1,FALSE); beh->ActivateOutput(1,TRUE); return CKBR_OK; } else { if(beh->IsInputActive(0)) //输入端口一触发,激活该BB { beh->ActivateInput(0,FALSE); //关闭输入端口一, - 380 - CK3dEntity *Role=(CK3dEntity*)beh->GetInputParameterObject(0); CK3dEntity *RoleRe=(CK3dEntity*)beh->GetInputParameterObject(1); if(Role==NULL||RoleRe==NULL) { ctx->OutputToConsole("wow_MultiProximity输入参数有误"); return CKBR_PARAMETERERROR; } beh->ActivateOutput(0,TRUE); //激活输出端口一 beh->SetOutputParameterValue(0,&outnum); beh-> SetLocalParameterValue (1,&limit); //初始化时间量 return CKBR_ACTIVATENEXTFRAME; } } beh->GetInputParameterValue(2,&limit); //得到隔多少时间检测一次 float currenttime=0.0; // beh-> GetLocalParameterValue (1,¤ttime); //得到已经过去了多少时间 currenttime+=BehContext.DeltaTime; //加上上一帧滑过的时间 beh-> SetLocalParameterValue (1,¤ttime); //更新时间累积量 if(currenttime>limit) //超过间隔就检测一次 { CK3dEntity *Role=(CK3dEntity*)beh->GetInputParameterObject(0); CK3dEntity *RoleRe=(CK3dEntity*)beh->GetInputParameterObject(1); currenttime=0.0; beh-> SetLocalParameterValue (1,¤ttime); //重置时间累积的量 VxVector pos; Role->GetPosition(&pos,RoleRe); //得到相对位置 float dis=pos.Magnitude(); //求得两物体之间距离 float distance=0.0; float midddis=0.0; for(int i=3;iGetInputParameterValue(i,&distance); beh->GetOutputParameterValue(0,&outnum); if(i==3) { if(dis<=distance&&outnum!=i)// { beh->SetOutputParameterValue(0,&i); beh->ActivateOutput(i,TRUE); break; } continue; } - 381 - beh->GetInputParameterValue(i-1,&midddis); beh->GetInputParameterValue(i,&distance); if(i==(count_param_out-1)) { if(dis>distance&&outnum!=2) { outnum=2; beh->SetOutputParameterValue(0,&outnum); beh->ActivateOutput(2,TRUE); break; } break; } if(dis<=distance&&dis>midddis&&outnum!=i)// { beh->SetOutputParameterValue(0,&i); beh->ActivateOutput(i,TRUE); break; } } } return CKBR_ACTIVATENEXTFRAME; 从最后的返回值“return CKBR_ACTIVATENEXTFRAME”中可以看出,该BB是触发 后是自动循环运行的。所以需要一个输入端口能够中断这个循环,否则这个BB被触发就不 可控制了,当然也可以通过对输入参数的判断来中断循环。判断BB的哪一个端口被触发用 函数beh->IsInputActive(n),其参数n是端口的编号,当一个输入端口被激活后,最好立即 执行关闭该输入端口的操作,调用函数beh->ActivateInput(n,FALSE)可完成,参数n表示第 n个输入端口,参数FALSE表示是关闭还是打开。之所以这样做事为了防止Virtools在一帧 之内多次触发该输入端口引起的错误,这种错误不是Virtools引起的,而是用户在连线的时 候逻辑不清造成的。要激活哪一个输出端口调用函数beh->ActivateOutput(in,TRUE)即可。 有一点要额外提醒的就是:端口的关闭和开启不要放到多线程中完成,这样做不会得到你想 要的结果,至于为什么,读者稍微分析一下就可以知道。 这个BB是每新创建一个输出端口,就会多检测一个范围值,就需要多一输入参数,同 样在BB的回调函数中我们添加创建输入参数的代码,其代码如下所示: case CKM_BEHAVIOREDITED: { int c_out = beh->GetOutputCount(); int c_pin = beh->GetInputParameterCount(); char pin_str[20]; while( c_pin < c_out ) { sprintf( pin_str, "距离%d", c_pin-3); beh->CreateInputParameter(pin_str, CKPGUID_FLOAT); ++c_pin; - 382 - } while( c_pin > c_out ) { --c_pin; CKDestroyObject(beh->RemoveInputParameter(c_pin)); } } break; 这个BB中创建输出端口并不需要添加任何代码,当然也可以一次性添加指定数量的输 出端口、方法参照20.1.3小节。这个BB的代码和使用例子请参加目录 图20.2.1_1 20.3 自定义类型的参数 20.3.1 自定义类型的参数一些说明 自定义类型和参数是两个不同的概念,请注意本节标题的说法。其实参数(输入参数、 输出参数、局部参数)是一个CKObject对象,通过它可以访问Virtools的类型数据,比如3D 实体(CK3dEntity)、相机(CKCamera)、组(CKGroup)等。这些是Virtools所提供的默 认类型,它们都已经在Virtools的参数管理器中经过了注册。同样的用户要创建一个新类型 - 383 - 时也需要注册,在创建新类型的过程中,往往要和参数联系到一起,所以常常说成:“创建 新的类型参数”。 在Virtools中能创建的数据类型有:标示类型(FLAGS)、枚举类型(Enum)、结构体 类型(Structure)和自定义类型。类型创建出来了,还要在Virtools脚本中应用,比如会有对新 的类型进行赋值、取值、比较、相加、相减、添加到属性中等操作,这些操作有些是要额外 的添加处理函数来实现的,有些就不需要额外的实现函数。 在Virtools环境中,每个参数的类型对应于一个CKParameterTypeDesc结构的描述。 这种结构的主要信息如下所示: struct CKParameterTypeDesc { CKParameterType Index; //在内部使用的参数数组(索引) CKGUID Guid; //该类型的全局唯一标识符 CKGUID DerivedFrom; //父类的全局唯一标识符 XString TypeName; //这个类型的名字 int Valid; //保留字段 int DefaultSize; //类型参数的默认大小(以字节为单位) //每一次这种类型的参数被创建时调用该函数创建 CK_PARAMETERCREATEDEFAULTFUNCTION CreateDefaultFunction; //每一次这种类型的参数被删除时调用该函数删除 CK_PARAMETERDELETEFUNCTION DeleteFunction; //当文件在保存和装载时调用该函数进行相应处理,但不是每个类型都必须这样处理 CK_PARAMETERSAVELOADFUNCTION SaveLoadFunction; //参数检查 CK_PARAMETERCHECKFUNCTION CheckFunction; //将参数拷贝出一个新的参数时,调用该函数处理 CK_PARAMETERCOPYFUNCTION CopyFunction; //通过字符串设置参数的默认值 CK_PARAMETERSTRINGFUNCTION StringFunction; //当编辑该参数要使用自定义设置窗口时,调用该函数 CK_PARAMETERUICREATORFUNCTION UICreatorFunction; //注册这个动态库的索引值,自定义的参数就在这个动态库中被声明 CKPluginEntry* CreatorDll; //应用程序保留的字段,用于保存特定的数据 CKDWORD dwParam; //指定该参数类型的标示,详细参照 SDK 文档的 CK_PARAMETERTYPE_FLAGS CKDWORD dwFlags; //在根据参数类型转换到指定对象 ID 时用到 CKDWORD Cid; //在参数管理器中使用 XBitArray DerivationMask; //参数管理器 ID CKGUID Saver_Manager; }; 以上解释的内容,如果暂时理解不了也没关系,创建一般类型参数时,不涉及上面结 构体的编程,只有在创建复杂(完全的自定义类型)类型时,才涉及该结构体的编程,平时 - 384 - 很少用到。 每一个用户定义的参数类型都要有个全局唯一标识符与之对应,如何得到一个全局唯 一标识符呢?这个简单Virtools已经为你准备好了一个小工具。打开你所安装到磁盘的 Virtools目录,找到“SDK”这个文件夹,点击进入,然后你会发现目录“Utils”,在“Utils” 目录里面有个可执行文件“CKGuidGen.exe”,这就是那个工具,运行这个程序,就会弹出 一组数据,这段数据就是生成的全局唯一标识码,复制处理粘贴到代码中就OK了,使用很 方便。 20.3.2 创建简单的自定义参数 打开VC++2003,使用BB生成向导创建一个Virtools的BB项目工程文件并将其命名为 “NewParameter”,我们先创建几个简单的类型(标示类型、枚举类型、结构体)来试试 看看。第一步我们要为新创建的参数类型定义全局唯一标识符,打开“NewParameter.cpp” 文件,在文件顶部定义这些全局唯一标识符,如下所示: #define GUID_WOW_STRUCT CKGUID(0x70bc782d,0x4b475d9b) //结构体 #define GUID_WOW_ROLEFLAGS CKGUID(0x6c077d9c,0x464b6a35) //标示类型 #define GUID_WOW_LIFEENUM CKGUID(0x8ac555a,0x1e9f2de4) //枚举类型 #define GUID_WOW_GAMETYPE CKGUID(0x765e4f8d,0x6dd4e30) //枚举类型 参数类型的全局唯一标识符定义完后,接下来就可以定义新参数类型了,打开源文件 “NewParameter.cpp”, 找到函数CKERROR InitInstance(CKContext* context),在该函数 体中添加如下代码: CKParameterManager* pm = context->GetParameterManager(); //得到参数管理器 //注册枚举类型新参数 pm->RegisterNewEnum(GUID_WOW_LIFEENUM,"生与死","生=0,死=1"); //注册结构体类型新参数 pm->RegisterNewStructure( GUID_WOW_STRUCT, "主角属性","主角,种族,是死是活,血量,魔法值,攻击防御", CKPGUID_CHARACTER,CKPGUID_STRING,GUID_WOW_LIFEENUM, CKPGUID_2DVECTOR,CKPGUID_2DVECTOR,CKPGUID_2DVECTOR); //注册标示类型新参数 pm->RegisterNewFlags(GUID_WOW_ROLEFLAGS, "五行属性","金=1,木=2,水=4,火=8,土=16,风=32,雷=64"); //注册枚举类型新参数 pm->RegisterNewEnum(GUID_WOW_GAMETYPE,"游戏状态","游戏初始化=1, 游戏开始=2,云与山之彼端=3,梦幻的魔法之门=4,失落的神庙=5,游戏结束=6"); 先定义一个参数管理器对象pm,然后通过调用函数context->GetParameterManager() 得到参数管理器。得到参数管理器后,如果是创建枚举类型参数那么就是调用函数 pm->RegisterNewEnum( ),它需要传递三个参数:第一个参数是该类型的全局唯一标示符, 第二个参数是该参数的名字,第三个参数是该参数的默认值,如果有多个默认值就用“,” 号隔开。 如果是创建标示类型参数则要调用函数是pm-> RegisterNewFlags ( ),它与创建枚举类 型参数一样,也需要传递三个参数,各个参数的意义相同,唯一不同的是设置初始化值的时 候,后一个分量的值是前一个分量值的2倍: "金=1,木=2,水=4,火=8,土=16,风=32,雷=64"。 这是为什么呢?我也不知道,也许这是人家的游戏规则吧。O(∩_∩)O 如果创建新的结构体类型参数则要调用函数是pm->RegisterNewStructure( )。由于结构 - 385 - 体的特性,其分量类型是不同的,所以该函数的参数与结构体分量的个数有关系,总的来说, 它有四类参数,第一个参数是该结构体类型的全局唯一标示符;第二个参数是该结构体类型 的参数名字;第三个参数是该结构体各个分量的名字,各分量名字用逗号隔开;第四个参数 是结构体各个分量的类型标识符,也是用逗号隔开。 编译生成dll,然后放到Virtools相应目录中,打开Virtools在Level层新建一个脚本,在 脚本中添加参数,找到我们定义的参数观察结果如图20.3.2_1、图20.3.2_2和图20.3.2_3所 示。 图20.3.2_1 图20.3.2_2 图20.3.2_3 从图20.3.2_3中可以看出,我们自定义的参数类型,与Virtools的BB是可以交互的,可 - 386 - 以使用Test这个BB对两个自定义参数的值进行比较,也可以使用Identity这个BB进行赋值, 同样也可在Array和Attribute中添加我们自定义的参数。 现在看起来自定义参数似乎没那么复杂——太简单了!如果我们要对自定义的结构体 类型参数的某一个分量进行赋值、取值;或者比较结构体中某一分量的大小;或者结构体中 又包含其他结构体类型分量。这时候Virtools脚本就不知道怎么处理了,这时就要开发这些 功能的处理函数了,更细致的工作就要开始了,总的来说不简单的事情来了。 20.3.3 结构体类型参数的处理函数 我们自定义的结构体,在Virtools中可以找到这个参数了,但是无法对该结构体的各个 分量进行操作,不能操作的参数要来有何用?在Virtools中设置参数分类的值常用―OP‖这个 BB,怎样使得―OP‖这个BB也能处理我们的自定义结构体呢? 在Virtools引擎中参数的相关操作是由参数管理器(CKParameterManager)管理的, “OP”这个BB的各种行为也是由参数管理器控制的,所以我们只要往参数管理器中注册新的 “OP”行为就可以了。在Virtools窗口化的编辑器中注册新“OP”行为,对SDK开发来讲, 其实就是注册一个新的处理函数罢了。注册新函数与注册新参数类型一样,每个新的函数都 需要一个全局标识符与其他东西区别开来。以下是得到和设置玩家属性(自定义结构体的参 数名字)的生命值(结构体的分量值)和魔法值(结构体的分量值)处理函数、即其他分类 的全局唯一标识符: #define GUID_WOW_GETLIFE CKGUID((0x393c7d71,0x35f00c02)) //得到生命值 #define GUID_WOW_ADDLIFE CKGUID((0x63c47355,0x1c194a0f)) //设置生命值 #define GUID_WOW_GETMAGIC CKGUID((0x2a91542c,0x38b94ff6)) //得到魔法值 #define GUID_WOW_SETMAGIC CKGUID((0x63ee3b8c,0x37945fa6)) //设置魔法值 #define GUID_WOW_GETRACE CKGUID((0x55d3117e,0x2b796d09)) //得到种族 #define GUID_WOW_SETRACE CKGUID((0x2fb611df,0x76bd0089)) //设置种族 #define GUID_WOW_GETDEATH CKGUID((0x56a36307,0x17e922d3)) //得到生死 #define GUID_WOW_SETDEATH CKGUID((0x378f7fa9,0x2eb035da)) //设置生死 上面的宏定义同样放在―NewParameter.cpp‖文件顶部,接下来要给我们的处理函数注 册到参数管理器中。代码如下所示: CKParameterManager* pm = context->GetParameterManager( ); //得到参数管理器 //注册加减血函数的标识符 pm->RegisterOperationType(GUID_WOW_GETLIFE,"得到生命值"); pm->RegisterOperationType(GUID_WOW_ADDLIFE,"设置生命值"); //注册血处理函数 pm->RegisterOperationFunction(GUID_WOW_GETLIFE, CKPGUID_FLOAT, GUID_WOW_STRUCT, CKPGUID_NONE, ParamOpGetCurrentLife); pm->RegisterOperationFunction(GUID_WOW_ADDLIFE,GUID_WOW_STRUCT,GUID_ WOW_STRUCT, CKPGUID_FLOAT, ParamOpSetCurrentLife); //注册魔法值函数标识符 pm->RegisterOperationType(GUID_WOW_GETMAGIC,"得到魔法值"); pm->RegisterOperationType(GUID_WOW_SETMAGIC,"设置魔法值"); //注册魔法处理函数 pm->RegisterOperationFunction(GUID_WOW_GETMAGIC, CKPGUID_2DVECTOR, GUID_WOW_STRUCT, CKPGUID_NONE, ParamOpGetMagic); pm->RegisterOperationFunction(GUID_WOW_SETMAGIC, GUID_WOW_STRUCT, - 387 - GUID_WOW_STRUCT, CKPGUID_2DVECTOR, ParamOpSetMagic); //注册种族函数标识符 pm->RegisterOperationType(GUID_WOW_GETRACE,"得到种族"); pm->RegisterOperationType(GUID_WOW_SETRACE,"设置种族"); //注册种族处理函数 pm->RegisterOperationFunction(GUID_WOW_GETRACE, CKPGUID_STRING, GUID_WOW_STRUCT, CKPGUID_NONE, ParamOpGetRace); pm->RegisterOperationFunction(GUID_WOW_SETRACE, GUID_WOW_STRUCT, GUID_WOW_STRUCT, CKPGUID_STRING, ParamOpSetRace); //注册生死函数标识符 pm->RegisterOperationType(GUID_WOW_GETDEATH,"得到生死"); pm->RegisterOperationType(GUID_WOW_SETDEATH,"设置生死"); //注册生死处理函数 pm->RegisterOperationFunction(GUID_WOW_GETDEATH, GUID_WOW_LIFEENUM, GUID_WOW_STRUCT, CKPGUID_NONE, ParamOpGetDeath); pm->RegisterOperationFunction(GUID_WOW_SETDEATH, GUID_WOW_STRUCT, GUID_WOW_STRUCT, GUID_WOW_LIFEENUM, ParamOpSetDeath); 上面小段代码添加在CKERROR InitInstance(CKContext* context)的函数体中,至于 为什么要放在这个函数体中,请参考前面章节。注册一个处理函数其实就四步:第一步得到 参数管理器,通过语句context->GetParameterManager可以得到;第二步是注册函数的标 识符,其实就是给函数起个名字而已,调用函数pm->RegisterOperationType( )可以完成, 他有两个参数,前者是处理函数的全局唯一标识符,后者是该处理函数的名字;第三步就是 注册处理函数本身了,参数管理器提供的函数pm-> RegisterOperationFunction ( )可以完成, 它有5个参数,它们定义如下: GUID_WOW_GETMAGIC 处理函数的全局唯一标识符 CKPGUID_2DVECTOR ―OP‖的输出参数类型类型 GUID_WOW_STRUCT ―OP‖左边的输入参数类型 CKPGUID_NONE ―OP‖右边的输入参数类型 ParamOpGetMagic 处理函数地址 当处理其他类型变量的时候,要相应修改类型标示,以上三步,都不需要花费太大脑 力。第四步才是重点的地方,重点在于编写处理函数的方法了,这是个费时、费神的功夫, 先看第一个函数SetOutParamVal( )。这个函数的作用是将输入参数(结构体类型参数)的 分量逐个赋值到输出参数(结构体类型参数)中去,其代码如下: //设置“OP”的输出结构体参数值 void SetOutParamVal(CKContext* context, CK_ID* IDres,CK_ID* IDArray) { CKParameter* pout1; //声明一个参数对象 CKParameter* pout2; //声明一个参数对象 //分别得到输入参数、输出参数的结构体分量0 pout1 = (CKParameter*) context->GetObject(IDArray[0]); //输入参数值分量0 pout2 = (CKParameter*) context->GetObject(IDres[0]); //输出参数值分量0 pout2->CopyValue(pout1); //输入参数分量0的值赋给输出参数的相应分量 //分别得到输入参数、输出参数的结构体分量一 - 388 - pout1 = (CKParameter*) context->GetObject(IDArray[1]); pout2 = (CKParameter*) context->GetObject(IDres[1]); pout2->CopyValue(pout1); //输入参数分量二的值赋给输出参数的相应分量 //分别得到输入参数、输出参数的结构体分量二 pout1 = (CKParameter*) context->GetObject(IDArray[2]); pout2 = (CKParameter*) context->GetObject(IDres[2]); pout2->CopyValue(pout1); //输入参数分量一的值赋给输出参数的相应分量 //分别得到输入参数、输出参数的结构体分量三 pout1 = (CKParameter*) context->GetObject(IDArray[3]); pout2 = (CKParameter*) context->GetObject(IDres[3]); pout2->CopyValue(pout1); //输入参数分量三的值赋给输出参数的相应分量 //分别得到输入参数、输出参数的结构体分量四 pout1 = (CKParameter*) context->GetObject(IDArray[4]); pout2 = (CKParameter*) context->GetObject(IDres[4]); pout2->CopyValue(pout1); //输入参数分量四的值赋给输出参数的相应分量 //分别得到输入参数、输出参数的结构体分量五 pout1 = (CKParameter*) context->GetObject(IDArray[5]); pout2 = (CKParameter*) context->GetObject(IDres[5]); pout2->CopyValue(pout1); //输入参数分量五的值赋给输出参数的相应分量 } 上面一个函数的语句中,重复了5次同样的代码,为什么要重复5次,请读者想一想。 除此之外就是一个CK_ID类型了,它其实就是存放全局唯一标识符的变量。在Virtools中每 一个对象,每一个参数甚至参数中的每一个分量都有一个全局唯一标识符。通过这个全局标 识符,可以访问我们想用的对象或参数。CKContext中维护者所有的CK_ID,所以通过它的 函数方法GetObject(CK_ID objID )可以访问任何分配了全局唯一标识符的实例,调用该函数 返 回 的是 一个CKObject对象,这是所有类的父类,通过强制类型转换为参数类型 (CKParameter*)后,仅仅是通过函数CopyValue( )复制了相同的值。 下面的函数ParamOpGetCurrentLife( )的作用是得到结构体分类中的生命值,既然是 生命值为什么要使用Vx2Dvector类型呢?表示生命值需要一个float类型的变量就够了,但 是在游戏中,一个角色的血量总有个上限值,它也需要一个float类型变量来表示。所以干脆 使用一个Vx2Dvector类型,其中x分量表示当前血量,y分量表示血量上限。 //得到当前血量值。 void ParamOpGetCurrentLife(CKContext* context, CKParameterOut *res, CKParameterIn *p1, CKParameterIn *p2) { CK_ID* IDArray = (CK_ID *) p1->GetReadDataPtr(); //得到输入参数值 if (!IDArray) return; //得到输入参数的第四个分量的值,也就是血值 CKParameter* pout = (CKParameter*) context->GetObject(IDArray[3]); Vx2DVector life=0.0; pout->GetValue(&life); res->SetValue(&life.x); //将当前血值赋值到“OP”的输出参数中 } - 389 - 因为结构体分量是Vx2Dvector类型,而“OP”的输出参数是浮点类型,所以处理上与 函数SetOutParamVal( )稍有不同,请读者注意观察上面的代码。已经我们学会了得到结构 体各个分量的值了的方法,剩下来就是给结构体的某个分量设置新的值。实现这个功能的函 数是ParamOpSetCurrentLife( ),其代码如下所示: //更新血量值 void ParamOpSetCurrentLife(CKContext* context, CKParameterOut *res, CKParameterIn *p1, CKParameterIn *p2) { CKParameter* pout; CKParameter* pout2; CK_ID* IDArray = (CK_ID *) p1->GetReadDataPtr(); //得到输入参数的值 if (!IDArray) return; CK_ID* IDres = (CK_ID*) res->GetWriteDataPtr(); //得到输出参数的值 if (!IDres) return; //将输入参数的值原封不动的赋值给输出参数 SetOutParamVal(context, IDres,IDArray); //以下才是修改相应的值 float curlife=0.0; p2->GetValue(&curlife); //得到“OP”的输入参数二 pout = (CKParameter*) context->GetObject(IDArray[3]); //得到输入参数的分量四 Vx2DVector life=0.0; pout->GetValue(&life); //得到血值 life.x+=curlife ; //角色得到加血了 pout2 = (CKParameter*) context->GetObject(IDres[3]); //得到输出参数的分量四 pout2->SetValue(&life); //设置新的血量值 } 整个过程都是对指针的操作,所以在编程时要小心,否则到了Virtools运行的时候,程 序会崩掉。处理血值的函数已经OK了,同理处理魔法值、攻击力、防御力值也是同样的方 法,因为他们是同类型的分量。以下是处理魔法值的函数,至于处理攻击力、防御力的函数 没有编写,请有兴趣的读者自己完善它。 //得到当前魔法值 void ParamOpGetMagic(CKContext* context, CKParameterOut *res, CKParameterIn *p1, CKParameterIn *p2) { CK_ID* IDArray = (CK_ID *) p1->GetReadDataPtr(); if (!IDArray) return; CKParameter* pout = (CKParameter*) context->GetObject(IDArray[4]); res->CopyValue(pout); } //更新魔法值 void ParamOpSetMagic(CKContext* context, CKParameterOut *res, CKParameterIn *p1, - 390 - CKParameterIn *p2) { CKParameter* pout2; CK_ID* IDArray = (CK_ID *) p1->GetReadDataPtr(); if (!IDArray) return; CK_ID* IDres = (CK_ID*) res->GetWriteDataPtr(); if (!IDres) return; SetOutParamVal(context, IDres,IDArray); Vx2DVector curmagic; p2->GetValue(&curmagic); pout2 = (CKParameter*) context->GetObject(IDres[4]); pout2->SetValue(&curmagic); } //得到玩家的种族 void ParamOpGetRace(CKContext* context, CKParameterOut *res, CKParameterIn *p1, CKParameterIn *p2) { CK_ID* IDArray = (CK_ID *) p1->GetReadDataPtr(); if (!IDArray) return; CKParameter* pout = (CKParameter*) context->GetObject(IDArray[1]); res->CopyValue(pout); } 如果更新的是字符串的值,处理上有些不同,也是容易出错的地方,在编写代码是要 非常注意。函数ParamOpSetRace( )是修改结构体中字符串类型的值。其代码如下所示: void ParamOpSetRace(CKContext* context, CKParameterOut *res, CKParameterIn *p1, CKParameterIn *p2) { CKParameter* pout2; CK_ID* IDArray = (CK_ID *) p1->GetReadDataPtr(); if (!IDArray) return; CK_ID* IDres = (CK_ID*) res->GetWriteDataPtr(); if (!IDres) return; SetOutParamVal(context, IDres,IDArray); CKParameter* temp=p2->GetRealSource(); //得到“OP”的第二个输入参数 int paramsize = temp->GetStringValue(NULL); //得到字符串的长度 char* buf = new char[paramsize]; //开辟同样长度的字符串 temp->GetStringValue(buf, FALSE); //得到输入参数的值 pout2 = (CKParameter*) context->GetObject(IDres[1]); //得到输出参数的分量 pout2->SetValue(buf,strlen(buf)+1); //更新字符串类型分量的值 - 391 - delete buf; //删除开辟的内存 } 这里使用到了开辟和删除内存的操作,同时也可以看到处理字符串类型的分量时要麻 烦一些。以下是处理结构体分量中我们自定义的类型参数,方法与前面的一样,这里就不多 说了。 //得到当前生存状态 void ParamOpGetDeath(CKContext* context, CKParameterOut *res, CKParameterIn *p1, CKParameterIn *p2) { CK_ID* IDArray = (CK_ID *) p1->GetReadDataPtr(); if (!IDArray) return; CKParameter* pout = (CKParameter*) context->GetObject(IDArray[2]); res->CopyValue(pout); } //修改生存状态 void ParamOpSetDeath(CKContext* context, CKParameterOut *res, CKParameterIn *p1, CKParameterIn *p2) { CKParameter* pout2; CK_ID* IDArray = (CK_ID *) p1->GetReadDataPtr(); if (!IDArray) return; CK_ID* IDres = (CK_ID*) res->GetWriteDataPtr(); if (!IDres) return; SetOutParamVal(context, IDres,IDArray); int death; p2->GetValue(&death); pout2 = (CKParameter*) context->GetObject(IDres[2]); pout2->SetValue(&death); } 从本小节的代码编写上看,要掌握开发新参数类型的方法,关键要处理好:CK_ID 、 CKParameterIn、CKParameterOut、CKParameter之间的关系,理解了它们之间的关系, 以上代码就容易理解了。图20.3.3_1所示是我们扩展的“OP”新功能,“OP”还是原来的 BB,但是它能显示并计算我们自定义的参数类型,详细参考“结构体类型参数.cmo”。 - 392 - 图20.3.3_1 20.4 使用 SDK 的 BB 20.4.1 修改 PushBotton 和 DragAndDrop 在开发游戏界面时候,免不了使用按钮和拖到图标。实现这两个功能的是PushBotton 和DragAndDrop这两个BB,但是他们必须要跟在2D帧的Virtools脚本上,在其他物件建立的 Virtools脚本,它不能存在。这样做有个不好的地方就是:假设有30个2D帧按钮,就必须要 建立30个Virtools脚本,这样导致项目中的Virtools脚本变得很多,不好管理。其实正确的方 法是:把按钮放到组(Group)里面,为这个组建立一个Virtools脚本,在这个脚本中处理 相应按钮逻辑;当游戏界面进行却换时,激活另外一个2D帧组的脚本,处理其他按钮逻辑。 新建Virtools,然后创建一个2D帧,为这个2D帧,创建一个Virtools脚本,在这个脚本 中,添加PushBotton这个BB,然后鼠标选中它,按下F1建,就会弹出这个BB的帮助文档, 帮助文档的第三行:Categorized in Interface/Controls,计算这个BB的源码的分类情况, 然后打开D:\Program files\Virtools\Virtools 4.0\Sdk\Behaviors.sln(如果Virtools按照在不同 路径时会不一样),按着这分类就可以找到改BB的源码,这些源码都是可以拿来参照学习的。 用VC生成BB向导,新建一个“wow_PushBotton”项目工程,然后参照源码,把相关 的头文件和CPP文件copy过来,修改一下wow_PushBotton这个BB的全局唯一标识符 (GUID),不然就会与自带的PushBotton这个BB发生冲突了。先编译生成dll后,再到Virtools 中去验证一下:是否功能一模一样,仅仅是名字不同而已。 上一步OK的话,接下来就修改代码,使它在任何一个Virtools脚本都能使用到它。在 Virtools中有一些BB,选中它,然后右键,选择“Add Target Parameter”就能给BB加一个 目标参数,然后该BB就可以copy到其他脚本去了。同理,只要PushBotton这个BB也有添加 目标参数这个功能,那么也就能在其他Virtools脚本中使用,如图19.2.2_5所示。 想一想“Set 2D Position”和“Set Position”这两个BB就有“Add Target Parameter” 这个功能,用前面提到的方法,找到这两BB的源码分类,然后对比这两BB的源码,发现有 “Add Target Parameter”功能的,在BB行为注册函数SetBehaviorFlags的参数中有 CKBEHAVIOR_TARGETABLE而PushBotton这个BB的行为注册函数SetBehaviorFlags的 参数中只有BEHAVIORPROTOTYPE_NORMAL。这个是原因所在,添加上参数 CKBEHAVIOR_TARGETABLE并与BEHAVIORPROTOTYPE_NORMAL参数用“|”分隔开。 编译生成dll,在到Virtools试验一下,发现右键该BB,就会有 “Add Target Parameter”的 - 393 - 选项了。设置了“Target Parameter”以后,PushBotton这个BB就到处都能使用了。其实 关键不是这个参数设置的原因,参数设置只是给这个BB有“Add Target Parameter”功能 而已,使不使用这个“Target Parameter”参数,还是要在BB实现函数中实现的。参看BB 实现函数 int wow_PushButton(const CKBehaviorContext& behcontext) { CKBehavior* beh = behcontext.Behavior; CK2dEntity* frame = (CK2dEntity*)beh->GetTarget();// 此语句表示使用了Target …… } 由于本例只是添加了一条语句而已,并没有添加其他功能代码,所以代码就不全贴了。 按照同样的原理也可以把DragAndDrop这个BB改成在任何脚本都能使用。详细请参看“设 置多个属性_第二十章20.1.3”。 20.4.2 转向功能 在Virtools原有的BB中,让一个物体转向另外一个物体通常使用“look at”和“Rotate” 这两个BB。如果一辆坦克要旋转炮台瞄准目标,目标与炮台不在同一高度时,使用“look at” 这个BB时,得到的效果如图20.4.2_1所示;而预想的效果应该只是炮台转向目标,而不是 翘起来瞄准目标如图20.4.2_2所示。如果使用“Rotate”这个BB,就要计算炮台和目标的 夹角,然后再决定是顺时针转还是逆时针转向目标,要实现这个功能,用现有BB来完成的 话,有点困难,所以开发一个转向目标的BB。 图20.4.2_ 1使用look at 图20.4.2_2 大多数情况下,当炮台转向目标后,可能还会发生相对移动,比如边移动边开炮,需 要一定时间实时瞄准目标;旋转的时候坦克有可能被击毁,所以旋转动作要能够被中断;根 据这些需求,该BB的输入输出参数和输入输出端口如下所示: //--- BB的端口定义 proto->DeclareInput("开始"); proto->DeclareInput("截断"); proto->DeclareOutput("结束时输出"); //--- BB的输入参数定义 proto->DeclareInParameter("旋转3D实体", CKPGUID_3DENTITY); //旋转的物体 - 394 - proto->DeclareInParameter("参照3D实体", CKPGUID_3DENTITY); //目标的物体 proto->DeclareInParameter("旋转的速度", CKPGUID_FLOAT); //旋转速度 proto->DeclareInParameter("结束的时间", CKPGUID_TIME,"0m 3s 0ms");//时间总长 proto->DeclareInParameter("中断旋转", CKPGUID_BOOL,"FALSE");//中断旋转 //---- BB的局部参数定义 proto->DeclareLocalParameter(NULL, CKPGUID_TIME, "0m 0s 0ms");//退出时间戳 proto->DeclareLocalParameter(NULL,CKPGUID_TIME, "0m 0s 0ms");//旋转时间戳 BB的实现函数代码如下所示: int wow_EyesOnYou(const CKBehaviorContext& BehContext) { CKBehavior* beh = BehContext.Behavior; CKContext *ctx = BehContext.Context; float timer_count=0.0; //时间戳 float timer_limit; BOOL loopenable=FALSE; //退出标志 beh->GetInputParameterValue(4,&loopenable); //TRUE表示退出 if(loopenable) //中断退出 { beh->ActivateInput(1,FALSE); beh->ActivateInput(0,FALSE); timer_count=0.0; beh->SetLocalParameterValue(0,&timer_count); //复位计时 return CKBR_OK; } if(beh->IsInputActive(1)) //中断退出 { beh->ActivateInput(1,FALSE); beh->ActivateInput(0,FALSE); timer_count=0.0; beh->SetLocalParameterValue(0,&timer_count); //复位计时 return CKBR_OK; } //得到旋转物体和目标物体 CK3dEntity *Rotata3DEnity=(CK3dEntity *)beh->GetInputParameterObject(0); CK3dEntity *Referentail3DEnity=(CK3dEntity *)beh->GetInputParameterObject(1); if(Rotata3DEnity==NULL||Referentail3DEnity==NULL) { ctx->OutputToConsole("wow_EyesOnYou输入的参数为空"); return CKBR_BEHAVIORERROR; } beh->GetInputParameterValue(3,&timer_limit); //得到旋转结束时间 beh->GetLocalParameterValue(0,&timer_count); //结束输出计时器 CKTimeManager *timeM=ctx->GetTimeManager(); //得到时间管理器 timer_count+=timeM->GetLastDeltaTime(); //更新时间戳 - 395 - beh->SetLocalParameterValue(0,&timer_count); //保存累积的时间 if(timer_count>=timer_limit) //达到输出时间 { timer_count=0.0; beh->SetLocalParameterValue(0,&timer_count); //复位计算器 beh->ActivateOutput(0,TRUE); //激活端口0 return CKBR_OK; } float timespeed=0.0; //物体旋转时间累积变量 beh->GetLocalParameterValue(1,×peed); //得到物体旋转时间计时器 timespeed+=timeM->GetLastDeltaTime(); //更新时间戳 beh->SetLocalParameterValue(1,×peed); //保存累积的时间 if(timespeed>=30) //30毫秒更新一次旋转 { timespeed=0.0; //计时器重新计时 beh->SetLocalParameterValue(1,×peed); //保存当前计时的时间 float speed; beh->GetInputParameterValue(2,&speed); //得到物体旋转的速度 VxVector eyesonyou(0.0f,0.0f,0.0f); //定一个旋转用的向量 Referentail3DEnity->GetPosition(&eyesonyou,Rotata3DEnity); //得到相对位置 eyesonyou.y=0.0f; //去掉Y轴的高度 eyesonyou = eyesonyou*-1; //相对位置取反向 VxVector zaxis(0,0,1); //定义旋转轴是Z轴 eyesonyou=Interpolate(speed,zaxis,eyesonyou); //设置旋转差值 Rotata3DEnity->LookAt(&eyesonyou,Rotata3DEnity); //开始旋转语句 } return CKBR_ACTIVATENEXTFRAME; } 从上面的代码可以看出,进行了2个计时,一个用来计算何时结束瞄准,一个是用来计 算何时旋转。在进行旋转计时的时候,限制了每30毫秒,旋转一次,这是避免游戏如果运 行帧率较快时,导致旋转速度变快。旋转还是用到了LookAt函数,和“Look At”这个BB调 用的函数方法是一样的,只是这个BB在“Look At”目标时,是一步步以插值的方式靠近目 标。在进行插值的时候,忽略了Y轴的信息,这有这样才不会发生图20.4.2_1的错误结果。 这个BB有个限制,就是物体必须是“-Z”轴对齐的,否则就会转错方向。详细代码和实例 请参看“EyesOnYouPushBotton_第二十章20.4.2” 20.4.3 有层级关系的中文显示 在写本章节之前,先特别感谢姚建辉先生,他无偿提供了实现层级关系的中文显示BB 的源码。 Virtools对中文的支持不是很好,最大的缺陷是不能切换到中文输入法实时输入中文, 不过还好,起码支持中文显示,也支持中文路径。显示中文使用的是TextDisplay这个BB。 但是这个BB有一缺陷,就是它没有层级关系,文字会有叠加在一起的情况,有时候2D帧的 层级大于1时,还会遮住文字。在做一些游戏中的玩家背包打开界面或者玩家交易界面时, 又必须使得文字有层级关系,相互间可以被遮挡。如图20.4.3_1所示。 - 396 - 有层级关系的文本显示 没有层级关系的文本显示 图 20.4.3_1 本章将开发一个带层级关系的中文显示 BB,就像 2D Text 这个 BB 一样。打开 Text Display BB 会发现,它是在一个 SpriteText 对象上显示中文,而 SpriteText 这对象本身是 分层级的,问题就这样迎刃而解了。再把 SpriteText 绑定到一个 2Dframe 上,并设置成这 个 2D 帧的子物体,用起来就和 2D Text 基本一样。 由于涉及到字体,所以要包含涉及字体的头文件,和定义字体使用到的宏,但这些信 息不在 D:\Program files\Virtools\Virtools 4.0\Sdk\Includes 这个路径,而是包含在 D:\Program files\Virtools\Virtools 4.0\Sdk\Samples\Behaviors\Interface\Sources 里面,所 以要在 VC2003 设置好相应的头文件路径。这些涉及字体的头文件和宏定义如下所示: #include "CKFontManager.h" #define FONT_NONE 0x0 // #define FONT_ITALIC 0x1 //字体倾斜 #define FONT_UNDERLINE 0x2 //字体下划线 #define FONT_STRIKEOUT 0x4 #define FONT_BOLD 0x8 #define PARA_FONTNAME 0 //何种字体 输入参数索引 #define PARA_FONTWEIGHT 1 //字体粗细 输入参数索引 #define PARA_ITALIC 2 //字体倾斜 输入参数索引 #define PARA_UNDERLINE 3 //字体下划线 输入参数索引 #define PARA_ALIGNMENT 4 //文本对齐方式 输入参数索引 #define PARA_FONTSIZE 5 //字体大小 输入参数索引 #define PARA_OFFSET 6 //文本偏移量 输入参数索引 #define PARA_FONTCOLOR 7 //字体颜色 输入参数索引 #define PARA_TEXT 8 //字体文本 输入参数索引 TestDisplay 这个 BB,要设置字体类型,下划线等要到 Setting 选项中进行,这个方 式用起来不方便。我们把这些参数设置成输入参数来表示,BB 的输入输出参数和输入输 出端口如下所示: proto->DeclareInput("On"); //开启输入端口 proto->DeclareInput("Off"); //关闭输入端口 proto->DeclareOutput("Exit On"); //开启时输出端口 proto->DeclareOutput("Exit Off"); //关闭时输出端口 //-----输入参数声明,依次是字体选择、字体粗细、字体倾斜、字体下划线、对齐方式、字 //-----体大小、偏移量、文本颜色、文本内容 proto->DeclareInParameter("Font Name", CKPGUID_FONTNAME, "宋体"); proto->DeclareInParameter("Font Weight", CKPGUID_MYFONTWEIGHT, "NORMAL"); proto->DeclareInParameter("Italic", CKPGUID_BOOL, "FALSE"); - 397 - proto->DeclareInParameter("Underline", CKPGUID_BOOL, "FALSE"); proto->DeclareInParameter("Alignment", CKPGUID_SPRITETEXTALIGNMENT, "Left"); proto->DeclareInParameter("Font Size", CKPGUID_INT, "12"); proto->DeclareInParameter("Offset", CKPGUID_2DVECTOR); proto->DeclareInParameter("Color", CKPGUID_COLOR); proto->DeclareInParameter("Text", CKPGUID_STRING); //---- 局部参数声明,文本精灵对象 proto->DeclareLocalParameter(NULL, CKPGUID_SPRITETEXT );// Spritetext //---- BB行为标准,可以叫Targe proto->SetBehaviorFlags((CK_BEHAVIOR_FLAGS)(CKBEHAVIOR_TARGETABLE)); 由于该BB是要设置为2D帧的子物体,所以要能够加一个Target参数,要设置BB有这个 功能,只需要调用函数SetBehaviorFlags时,传入CKBEHAVIOR_TARGETABLE参数即可, 如上面代码的最后一行。 在Virtools中现实文字的渲染,无论中文还是英文,都是通过图片来进行的。但是英文 可以把26个字母和其他符合整合到一张图片中,不管文字怎么变化,都是在那个些字母中 组合而成。中文则不行,中文没有办法这么做,所以当中文字发生改变时,就要重新把新的 文字画到纹理中,这样就会影响运行效率,所以尽量不要时时刻刻的变换文字。函数 ParseInputParameter()是创建要用于显示中文的图片,然后把中文字画在这张图片上;再 把文本精灵设置为指定2D帧的子物体,这样就具备带层级的2D中文显示的条件了。该函数 的具体代码如下所示: ////////////////////////////////////////////////////////////////////////// // 字体信息最好一旦确定下来就不要随便修改, // 如果有需要,就把这个函数放到Show2DChineseTextCallBack () 就可以了 ////////////////////////////////////////////////////////////////////////// void ParseInputParameter(CKBehavior *beh, CKContext *cnt) { CKBehavior *pBehavior = beh; //得到BB的行为描述对象 CKContext *pContext = cnt; //得到Virtools设备对象 //得到文本精灵对象 CKSpriteText *tt = (CKSpriteText *)pBehavior->GetLocalParameterObject(0); CK2dEntity *pTarget = (CK2dEntity *)pBehavior->GetTarget(); if(pTarget != NULL) //设置文本精灵对象的父物体和可被父物体裁剪 { tt->SetParent(pTarget); tt->SetClipToParent(TRUE); } if(tt == NULL) { tt->Create(256, 32, 32); //写文字的图片没有创建,就创建一个 } CKDWORD col; //文本颜色 VxColor color; //文本颜色 pBehavior->GetInputParameterValue(PARA_FONTCOLOR, &color); col = RGBAFTOCOLOR(color.r, color.g, color.b, color.a); - 398 - int myszie; //得到字体大小 pBehavior->GetInputParameterValue(PARA_FONTSIZE, &myszie); char *fontName; //得到字体名 int fontIndex = -1; //得到使用的字体类型的索引 pBehavior->GetInputParameterValue(PARA_FONTNAME, &fontIndex); CKParameterManager* pm = pContext->GetParameterManager();//得到字体管理器 //枚举所有字体类型 CKEnumStruct* data = pm->GetEnumDescByType( pm->ParameterGuidToType(CKPGUID_FONTNAME)); fontName = data->Desc[fontIndex]; //根据索引得到字体的名字 int weight = 400; //得到字体的粗细参数 pBehavior->GetInputParameterValue(PARA_FONTWEIGHT, &weight); int style = FONT_NONE; //用来得到字体显示风格 CKBOOL italic = FALSE, underline = FALSE; //是否使用斜体,和下划线 pBehavior->GetInputParameterValue(PARA_ITALIC, &italic); pBehavior->GetInputParameterValue(PARA_UNDERLINE, &underline); if(italic) //按位“或”来决定字体显示风格 { style |= FONT_ITALIC; //是否要求以斜体显示 } if(underline) { style |= FONT_UNDERLINE; //是否要求加下划线 } int alin; //得到字体的对齐方式 pBehavior->GetInputParameterValue(PARA_ALIGNMENT, &alin); tt->SetTextColor(col); //设置文本颜色 tt->SetAlign((CKSPRITETEXT_ALIGNMENT)alin); //设置文本对齐方式 //设置文本精灵的字体。 tt->SetFont(fontName, myszie, weight, (style & FONT_ITALIC), (style & FONT_UNDERLINE) ); } 文本精灵类是CKSpriteText,这类提供了涉及字体的函数方法,比如创建字体函数 SetFont()、设置字体颜色函数SetText()、设置文本对齐方式函数SetAlign()等等。这些在 Virtools的SDK帮助文档都有说明。 另外有一点要说明的是:文本精灵被设置为指定2D帧 的子物体,当在Virtools中对这个2D帧进行复制的时候,要特别注意:复制选项设置。如果 没有取消子物体的话,就会把这个文本精灵也复制一份,如图20.4.3_2所示。所以,在使用 这个BB的时候,这一点要特别注意的。 - 399 - 图20.4.3_1 显示中文的必要条件已经准备好,现在就来看看如何控制显示和隐藏。不要以为显示隐 藏就是show和hide两个BB那么简单的事情,因为文本精灵在Virtools中的资源管理面板中是 找不到的。如果处理不好,在Virtools复位的时候,它还会把中文显示出来。由于文字需要 每帧都渲染到屏幕上,所以该BB在显示文字时候,必须每帧都激活,详细代码如下所示: int Show2DChineseText(const CKBehaviorContext& BehContext) { CKBehavior *pBehavior = BehContext.Behavior; CKContext *pContext = BehContext.Context; //得到该BB付上的2D帧 CK2dEntity *pTarget = (CK2dEntity *)pBehavior->GetTarget(); //得到文本精灵对象 CKSpriteText *tt = (CKSpriteText *)pBehavior->GetLocalParameterObject(0); char *strText = tt->GetText(); //得到画在文本精灵上的字符串 if( pBehavior->IsInputActive(0) ) //输入端口一激活,显示文本内容 { strText = NULL; //初始化文本字符串 pBehavior->ActivateInput(0, FALSE); //关闭输入端口一 pBehavior->ActivateOutput(0); //激活输出端口一 tt->Show(CKSHOW); //显示文本精灵对象 } else { if( pBehavior->IsInputActive(1) ) //输入端口二激活,关闭文本显示 { pBehavior->ActivateInput(1, FALSE); //关闭输入端口一 pBehavior->ActivateOutput(1); //激活输出端口二 tt->Show(CKHIDE); //隐藏文本精灵对象 return CKBR_OK; } } if( !pTarget->IsVisible() ) //检查2D帧是否显示 { tt->Show(CKHIDE); //如果是就隐藏文本精灵对象 - 400 - return CKBR_OK; } else { tt->Show(CKSHOW); //否则,就显示文本精灵对象 } if(pTarget->IsVisible() || tt->IsVisible()) { //调用渲染文字函数 Show2DChineseTexCNRenderCallback(BehContext.CurrentRenderContext, pTarget, (void *)pBehavior->GetID()); } return CKBR_ACTIVATENEXTFRAME; } 上面是BB的实现函数,它没做什么事情,只是控制BB的运行和关闭,以及根据2D帧的 显示隐藏情况来设置文本的显示和隐藏。其实真正以层级关系渲染文字的函数是 Show2DChineseTexCNRenderCallback(),其代码如下所示: CKBOOL Show2DChineseTexCNRenderCallback(CKRenderContext *dev,CKRenderObject* obj,void *Argument) { //得到BB的行为描述对象 CKBehavior *pBehavior = (CKBehavior *)CKGetObject(dev->GetCKContext(), (CK_ID)Argument); //得到Virtools设备对象 CKContext *pContext = pBehavior->GetCKContext(); //得到精灵文本对象 CKSpriteText *tt = (CKSpriteText *)pBehavior->GetLocalParameterObject(0); char *strText = tt->GetText(); //得到画在文本精灵上的字符串 int i_z = tt->GetZOrder(); //得到渲染层级 CK2dEntity *pTarget = (CK2dEntity *)pBehavior->GetTarget(); //得到2DF if(pTarget == NULL) { pContext->OutputToConsoleEx("请先设置2D帧!"); return CKBR_OK; } tt->SetParent(pTarget); //设置文本精灵的父物体是2D帧 tt->SetClipToParent(); //文本精灵可以被父物体裁剪 if( !pTarget->IsVisible() ) //2D帧隐藏,文本精灵也隐藏 { tt->Show(CKHIDE); return CKBR_OK; } else - 401 - { tt->Show(CKSHOW); } CKParameterIn *pIn; //输入参数指针 CKParameter *pOut; //参数指针 XString buffer; //输入的字符串 pIn = pBehavior->GetInputParameter(PARA_TEXT); //得到输入文本参数指针 pOut = pIn->GetRealSource(); //得到参数的内存数据 int paramsize = pOut->GetStringValue(NULL); //得到字符串大小 if (paramsize) { XAP paramstring(new char[paramsize]); //定义一个字符串容器 pOut->GetStringValue(paramstring,FALSE); //得到参数中得到的文本 buffer << (char*)paramstring; //字符串赋值 buffer << " "; //增加一个结束符 } Vx2DVector off; //得到位置偏移量 pBehavior->GetInputParameterValue(PARA_OFFSET, &off); ////////////////////////////////////////////////////////////////////////// // 现有的Sprite矩形范围是否跟参考物一致? // 理论矩形 Width = Rect_Target.right - off.x. Height = Rect_Target.bottom - off.y // 如果不一致。就创建 Vx2DVector pt_sprt, pt_ref, vec_temp; //计算2D帧,文本精灵的矩形 VxRect rt_ref; pTarget->GetRect(rt_ref); //得到2D帧的矩形大小 pt_ref.x = rt_ref.GetWidth() - off.x; //矩形大小减去偏移量 pt_ref.y = rt_ref.GetHeight() - off.y; tt->GetSize(pt_sprt); //得到2D帧的矩形大小 vec_temp = pt_ref - pt_sprt; //比较矩形大小 if(vec_temp.SquareMagnitude() >= 1.0f) { tt->Create(pt_ref.x, pt_ref.y, 32); //矩形发生变化就重新创建个 strText = NULL; //文本内容清空 } int z_order; //设置文本精灵层级 z_order = pTarget->GetZOrder(); if(z_order != i_z) { tt->SetZOrder(z_order); //设置文本精灵层级月2D帧的一致 } tt->SetPosition(off, FALSE, FALSE, pTarget); //设置文本精灵的位置 VxRect destrect; //测试2D帧是否在屏幕外部, pTarget->GetRect(destrect); //如果在外部就对文字进行裁剪 - 402 - if (pTarget->IsRatioOffset()) { VxRect screen; dev->GetViewRect(screen); destrect.Translate(screen.GetTopLeft()); off.x += destrect.left; off.y += destrect.top; tt->SetPosition(off, FALSE, FALSE, NULL); } //当文字内容发生改变,就重新设置一次文本精灵对象 if( !strText || strcmp(strText, buffer.CStr())) { ParseInputParameter(pBehavior, pContext); tt->SetText(buffer.Str()); } return CKBR_OK; } 上面的代码在处理2D文字的层级显示时,其实是根据2D帧的层级来设置文本精灵的层 级,由于文本精灵类是从2D帧类派生而来,所以很多函数方法都相同,设置和得到层级关 系的函数都是:SetZOrder()和GetZOrder()。设置为2D帧的子物体后,当文本太长,是可 以被父物体裁剪的,裁剪的方法是对父物体矩形区域的计算,然后根据实际裁剪后文本精灵 的矩形大小,在重新创建文本精灵的贴图。上面的代码中还可以学到取字符串参数的值的另 外一种方法。 由于创建的文本精灵是没有显示在Virtools资源管理器中,但是每出现一个这样的BB就 会创建一个文本精灵,所以必须要在不适用的时候删除掉它。具体的做法是在BB的回调函 数中实现。具体代码如下所示: //BB的回调函数 int Show2DChineseTextCallBack(const CKBehaviorContext& BehContext) { CKBehavior *beh = BehContext.Behavior; CKContext *ctx = BehContext.Context; switch( BehContext.CallbackMessage ) { case CKM_BEHAVIORCREATE: //BB从无到有加入到脚本时,发生回调 case CKM_BEHAVIORLOAD: //BB从nmo加载到脚本时,发生回调 { // 创建文本精灵 CKSpriteText* tt = (CKSpriteText*)ctx->CreateObject( CKCID_SPRITETEXT, "2DTextCN Sprite",beh->IsDynamic()? CK_OBJECTCREATION_DYNAMIC: CK_OBJECTCREATION_NONAMECHECK); // 告诉引擎文本精灵发生修改的方式 tt->ModifyObjectFlags(CK_OBJECT_NOTTOBELISTEDANDSAVED,0); tt->EnableClipToCamera(FALSE); //不被相机裁剪 - 403 - // 重新设置文本精灵的大小。 Vx2DVector spritesize(320.0f,32.0f); tt->ReleaseAllSlots(); tt->Create((int)spritesize.x,(int)spritesize.y,32); // 把文本精灵保存到局部参数中 beh->SetLocalParameterObject(0, tt); } break; case CKM_BEHAVIOREDITED: //当BB被设置参数,发生回调 { // BB被编辑后,重新生成精灵文本 ParseInputParameter(BehContext.Behavior, BehContext.Context); } break; case CKM_BEHAVIORDELETE: //当BB被删除,发生回调 { // BB被删除后,删除文本精灵 CKSpriteText *tex = (CKSpriteText*) beh->GetLocalParameterObject(0); ctx->DestroyObject(tex); } break; case CKM_BEHAVIORACTIVATESCRIPT: //当BB所在脚本被激活时,发生回调 case CKM_BEHAVIORRESUME: //当Virtools继续运行时,发生回调 { // BB的脚本重新激活,或者程序重新运行时,发生回调 CKScene *scene = ctx->GetCurrentScene(); if( beh->IsParentScriptActiveInScene(scene)){ if(beh->IsActive()) { CKSpriteText *tex = (CKSpriteText*) beh->GetLocalParameterObject(0); tex->Show(CKSHOW); } } } break; case CKM_BEHAVIORDEACTIVATESCRIPT: //禁用BB所在脚本时,发生回调 case CKM_BEHAVIORPAUSE: //Virtools暂停时,发生回调 case CKM_BEHAVIORRESET: //Virtools复位时,发生回调 { // 复位,或者脚本被停止运行,或者暂停是,发生回调,隐藏文本 CKSpriteText *tex = (CKSpriteText*) beh->GetLocalParameterObject(0); tex->Show(CKHIDE); } break; case CKM_BEHAVIORNEWSCENE: //Virtools切换到新场景时,发生回调 { // 被场景发生切换时,发生回调。精灵文本对象根据父物体的关系,改加入 //场景就加入场景,改移除场景就移除场景 - 404 - CKSpriteText *tex = (CKSpriteText*) beh->GetLocalParameterObject(0); CKBeObject *owner = beh->GetOwner(); CKScene *scene = ctx->GetCurrentScene(); if( beh->IsParentScriptActiveInScene(scene)) { scene->AddObjectToScene( tex ); if(beh->IsActive()) { tex->Show(CKSHOW); } } else { if(owner->IsInScene(scene)) { scene->AddObjectToScene(tex); tex->Show(CKHIDE); } else { scene->RemoveObjectFromScene( tex ); tex->Show(CKHIDE); } } } break; } return CKBR_OK; } 回调函数的工作只是在正确的时机完成显示、隐藏、创建、删除的工作,具体的方法 已经在上面代码的注释说明白了。本章实例请参看“Show2DTextCN__第二十章20.4.3”。 - 405 - 第二十一章 用 SDK 扩展功能 使用 SDK 开发新的 BB,大多数情况是为了方便逻辑控制而封装的一些方法而已。当 程序逻辑过于复杂的时候,还需要使用 SDK 开发管理器模块,甚至要与第三方库进行整合 才能实现特殊的项目需求。本章的主要内容是:  如何使用在 Virtools 中使用多线程  使用多线程注意的事项  管理器的作用与运用  如何与第三方库整合的方法 21.1 多线程的应用 21.1.1 理解进程和线程 ―所谓进程可以理解为一个正在运行的程序的实例,它由两个部分组成:一个是操作系 统用来管理进程的内核对象;一个是地址空间‖(摘自《Windows核心编程》第四章(机械 工业出版社))。这些所谓内核对象、地址空间你可以无视它,它不会妨碍我们SDK的理解 和运用。但要明白:当系统执行我们的应用程序时,必然会创建一个进程,而这个进程中已 经包含了一个主线程,我们的代码都在这个主线程中执行(如果没有另外开辟其他子进程或 线程的话)。每个进程都包含一个实例句柄,这个句柄是唯一表示这个进程的。 “句柄是 WINDOWS 用来标识被应用程序所建立或使用的对象的唯一整数, WINDOWS 使用各种各样的句柄标识诸如应用程序实例,窗口,控制,位图,GDI 对象等 等。WINDOWS 句柄有点象 C 语言中的文件句柄。”(摘自《WINDOWS 编程短平快》(南 京大学出版社))。 在 Virtools 提供的 SDK 中,分别搜索“WIN_HANDLE”和“HWND ”关键字,可 以找到获取图像渲染程设备句柄的函数 CKContext::GetMainWindow( );要获得应用程序 主窗口的句柄函数是 CKContext::GetMainWindow( );要对句柄进程更多的操作请在 Virtools 提供的 SDK 文档找搜寻“Windows functions”会发现更多函数。 当进程的内核对象创建后,系统会给该内核对象生成一个独一无二的标识符(ID),也 就是生成这个进程的―身份证号‖,有了这个―身份证号‖就能使你识别和跟踪系统中的进程或 线程。就好像政府通过身份证号来管理其公民一样。 一个线程同样也由线程的内核对象和线程的堆栈两个部分组成;每个线程也都有自己 的线程句柄和线程ID。其实进程只是线程的容器,它不执行任何东西,线程是在进程中被创 建,当进程被撤销时,这个进程的所有线程也会被撤销。一个进程中的多个线程可以共享该 进程地址空间,这些个线程可以执行相同的代码,对相同的数据进行操作,所以该进程中的 各个线程之间的通信畅通无阻。 所谓线程是一组计算机指令的集合,它可以在程序里独立执行,可以同时处理多个程 序的任务。在单核的处理器中,线程只是用于分配该处理器的处理时间的一种手段;而在 多核的处理器中,每个线程都可分配给一个不同的处理器,真正以―并行运算‖状态执行任 务。多线程能提高计算机资源的利用率,缩短程序完成任务的时间,提高执行效率。 线程的好处看起来很是让人振奋,但是它是一把双刃剑,有利有弊。弊端就是资源冲 突在所难免。比如在 Virtools 的表类型(array),假如一个线程在给这个表增加一行信息, 另一个线程要删除一行信息,还有一个线程在遍历这个表的每一行信息。这样的执行后果, - 406 - 不堪设想。为了解决这种冲突,就要对这类资源进行―锁定‖,处理完了再―解锁‖;这时其他 线程才可以对这个资源进行处理。由于―锁‖的存在,有些线程就会出现等待,降低了任务 完成的效率,有时候,某个线程―锁‖住某个资源,然后该线程出现了问题,没―解锁‖,也就 是―死锁‖;那么其他要访问该资源的线程就会一直等待下去。这个后果也是很可怕的。解 决―死锁‖的方法有很多种方式避免,但软件开发和维护的难度就大大增加了。 Virtools的SDK支持c\c++、win32、MFC,所以多线程可以使用他们提供的系列函数来 创建多线程,比如CreateThread函数、_beginthreaderx函数等。不过还是建议使用Virtools 封装好的多线程类来实现,因为Virtools封装过的东西毕竟经过精心设计和测试过,比起个 人行为来说更可靠和安全。 21.1.2 VXThread、VxMutex、VxMutexLock VxThread 就是我们要说的 Virtools 封装的多线程类,在使用这个类的时候可以通过继 承 VxThread 类和重载 VxThread::Run()的方法来实现我们的功能;也可用直接声明一 个 VxThread 对象然后关联到一个“ThreadFunction ”的方法来达到我们的目的。作者比 较倾向于第二种方法实现。 VxThread 类提供了 19 个成员函数,以下是各个成员函数及其注解表: 函数 作用 VxThread( ) 默认构造函数 Close( ) 关闭系统线程 CreateThread( ) 创建一个系统线程 GetCurrentVxThread( ) 返回一个系统当前执行的线程对象指针 GetCurrentVxThreadId( ) 返回一个系统当前执行的线程的 ID 号 GetHandle( ) 得到线程的句柄 GetID( ) 得到线程的 ID 号 GetName( ) 得到线程的名字 GetPriority( ) 线程的优先级 IsCreated( ) 线程是否被创建 IsJoinable( ) 线程是否可调度 IsMainThread( ) 该线程是否是主线程 IsStarted( ) 线程此刻是否获得 CPU 时间(是否正在执行) SetName( ) 设置线程的名字 SetPriority( ) 设置限制的优先级别 Terminate( ) 提前退出线程,一个危险的函数,不用使用它 Wait( ) 等待线程执行结束 GetExitCode( ) 得到线程执行状态信息 ::~VxThread( ) 默认析构函数 VxMutexl 类的作用是两个线程间对同一资源的同步和互斥协调。当有两个以上的线程 要访问同一资源时,就要使用到这个类。VxMutexl 类主要成员函数及其注解表: 函数 作用 VxMutex( ) 默认构造函数 EnterMutex( ) 线程要访问互斥资源 LeaveMutex( ) 线程要访问互斥资源完成 ::~VxMutex( ) 默认析构函数 所谓互斥:是指某一资源同时只允许一个访问者(线程或进程)对其进行访问,具有 - 407 - 唯一性和排它性。比如说:一个马桶只能允许一次座一个人,当马桶上做有人了,另外一 个人绝对不能允许再座上去,可见马桶是“互斥”的。 所谓同步:是指在互斥的基础上,通过某种机制来达到访问者对资源的有序访问。比 如说当马桶上有人,就在举个牌;马桶空闲了,就放下牌子;其他人就看着这个牌子决定 去座不座这个马桶。 VxMutexl 类的函数 EnterMutex( )就是举起牌子的功能,而 LeaveMutex( )函数就是放 下牌子的功能。VxMutexlLock 类是对 VxMutexl 对象的一个加锁、解锁功能,很少用到它, 也就不打算讲解它。 在上一小节中,虽然建议使用Virtools的封装好的多线程类,这一点是不错。但是有时 候要写一些跨平台、跨游戏引擎的通用模块,就最好不用Virtools里面的类了,甚至连Windows 下的函数都不建议用,只有全用C/C++的函数,才能够很好的进行移植到其他操作系统中, 当然如果要移植的操作系统不支持C/C++,那就无奈了。 21.1.3 多线程的应用 这一小节是一个使用多线程的实例代码,实现的功能是在线程中装载资源,使用的是 Virtools的多线程类VxThread来实现,详细代码请参看例子MultiThread。前面说过实现多线 程可用通过重载VxThread::Run()和关联到一个“ThreadFunction ”函数两种方法来, 本节采用的是关联函数的方法来实现。以下是代码和注释: #ifdef WIN32 //包含特定的头文件 #include "Windows.h" #endif 包含头文件时使用了#ifdef和#endif可以看成,如果程序运行的不是windows平台,就 会发生错误。读者有兴趣可以参照Virtools自带的两个BB―Sound Load‖和―Texture Load‖的 代码,会发现VxThread类支持XBox平台,不同平台你要包含不同的头文件。 //多线程加载的结构体 typedef struct LoadObjectThreadInfo { CKBOOL Loaded; //加载完成标识 CKBOOL Stop; //结束进程标识 CKBehavior* beh; CKContext* ctx; CKModelReader *mreader; //加载模型的类 CKBOOL Error; LoadObjectThreadInfo() //结构体构造函数 { Loaded=FALSE; Stop=FALSE; beh=NULL; ctx=NULL; mreader=NULL; Error=FALSE; } ~LoadObjectThreadInfo() //结构体析构函数 { - 408 - Loaded=FALSE; Stop=FALSE; beh=NULL; ctx=NULL; mreader=NULL; Error=FALSE; } }LoadObjectThreadInfo; 由于一个线程中使用到的参数很多,所以将这些参数封装到一个结构体中,传递给线 程函数是一种常用的方法。结构体中的每个分量的作用,要在以下的代码中仔细领会它们并 且掌握其运用方式。 //宏定义,定义一组释放加载模型的类的操作 #define ExitLoad(error) { if(LoadInfo->mreader) LoadInfo->mreader->Release(); LoadInfo->Error=TRUE; } //宏函数的前向声明 unsigned int ObjectMutithreadingLoad(void *arg); 函数ObjectMutithreadingLoad( )就是我们要关联到线程中去的函数,不要傻乎乎的认 为关联到线程中的函数名一定就是―ThreadFunction ‖,有部分读者常常读书不认真。我们要 在线程中执行的所有操作都在该函数中完成。当该函数返回退出后,线程的任务就算完成了。 //--- 输入端口声明 proto->DeclareInput("装载开启"); proto->DeclareInput("枪毙装载"); //--- 输出端口声明 proto->DeclareOutput("装载输出"); proto->DeclareOutput("枪毙输出"); proto->DeclareOutput("错误输出"); proto->DeclareOutput("装载完成"); //--- 输入参数声明 proto->DeclareInParameter("文件路径", CKPGUID_STRING); proto->DeclareInParameter("装载列表", CKPGUID_DATAARRAY); //---- 局部参数声明 proto->DeclareLocalParameter("Loading Object Thread", CKPGUID_POINTER); proto->DeclareLocalParameter("Loading Info", CKPGUID_POINTER); proto->DeclareLocalParameter("MutithreadingEnable",CKPGUID_BOOL); 上面3个局部参数中,有两个是―CKPGUID_POINTER‖类型,即指针。有时候只是为 了简单的在Virtools可编辑脚本中使用一自定义的类、或结构体等类型时,你也可以用这种 类型作为输入、输出参数。但是无法在Virtools脚本中查看它的值。 int wow_MultiThread(const CKBehaviorContext& BehContext) { CKBehavior* beh = BehContext.Behavior; CKContext* ctx=BehContext.Context; CKRenderContext* playRC=ctx->GetPlayerRenderContext();//得到当前渲染设备 BOOL mutithreadenable=FALSE; //定义相应的指针 - 409 - LoadObjectThreadInfo *LoadInfo; //多线程加载结构体的指针 VxThread *VXT; //多线程加载对象的指针 beh->GetLocalParameterValue(0,&VXT); //得到存放加载对象的指针局部参数 beh->GetLocalParameterValue(1,&LoadInfo);//得到存放加载结构体的指针局部参数 //先关闭所有输出端口,然后再依判定分别打开。 beh->ActivateOutput(0,FALSE); beh->ActivateOutput(1,FALSE); beh->ActivateOutput(2,FALSE); beh->ActivateOutput(3,FALSE); //初始化线程 if(beh->IsInputActive(0)) { beh->ActivateInput(0,FALSE); // ------------------------------------------------------------- // 线程使用 // ------------------------------------------------------------- if (!VXT) // 开辟一个线程 { VXT = new VxThread(); //创建多线程对象 if(VXT==NULL) //判定合法性 { ctx->OutputToConsole("wow_MultiThread的new VxThread()错误"); beh->ActivateOutput(2,TRUE); return CKBR_OK; } VXT->SetName("MyMutithreadingLoad"); //给线程命名 VXT->SetPriority(VXTP_NORMAL); //设置线程级别 } else//判定合法性 { ctx->OutputToConsole("wow_MultiThread装载线程已经存在"); beh->ActivateOutput(2,TRUE); return CKBR_OK; } if (!LoadInfo) //开辟一个装载线程结构 { LoadInfo=new LoadObjectThreadInfo;//创建线程加载对象结构 if(LoadInfo==NULL)//判定合法性 { ctx->OutputToConsole("wow_MultiThread的new LoadObjectThreadInfo错误"); beh->ActivateOutput(2,TRUE); return CKBR_OK; } - 410 - } else//判定合法性 { ctx->OutputToConsole("wow_MultiThread装载线程结构LoadInfo已经存在"); beh->ActivateOutput(2,TRUE); return CKBR_OK; } //初始化线程结构中的各个分量 LoadInfo->Loaded=FALSE; LoadInfo->Stop=FALSE; LoadInfo->Error=FALSE; LoadInfo->mreader=NULL; LoadInfo->beh=BehContext.Behavior; LoadInfo->ctx=BehContext.Context; //保存创建的对象 beh->SetLocalParameterValue(0,&VXT,sizeof(VxThread *)); beh->SetLocalParameterValue(1,&LoadInfo,sizeof(LoadObjectThreadInfo *)); beh->SetLocalParameterValue(2,&mutithreadenable); //警告场景线程进入 playRC->WarnEnterThread(); if (!VXT->IsCreated()) { //启动多线程 VXT->CreateThread(ObjectMutithreadingLoad,LoadInfo); beh->ActivateOutput(0,TRUE); } return CKBR_ACTIVATENEXTFRAME; } //结束多线程的执行 if(beh->IsInputActive(1)) { beh->ActivateInput(1,FALSE); playRC->WarnExitThread(); //警告场景线程要结束 if (VXT!=NULL) //加载对象不为空,要释放掉 { if (LoadInfo) //加载结构体不空,要释放掉 { LoadInfo->Stop=TRUE; ExitLoad(LoadInfo->Stop); //执行定义的操作 } VXT->Wait(); //以阻塞的方式等待线程结束 - 411 - VXT->Close(); //关闭线程 delete VXT; //删除对象 VXT=NULL; beh->SetLocalParameterValue(0,&VXT); } if (LoadInfo!=NULL) //加载结构体不为空,要释放删除该对象 { delete LoadInfo; LoadInfo=NULL; beh->SetLocalParameterValue(1,&LoadInfo); } beh->ActivateOutput(1,TRUE); return CKBR_OK; } //线程开始执行后,每一帧都检测是否这个线程执行完了。 beh->GetLocalParameterValue(2,&mutithreadenable); if(mutithreadenable) //如果执行结束,就结束掉线程 { playRC->WarnExitThread(); if (VXT!=NULL) { if (LoadInfo) { LoadInfo->Stop=TRUE; ExitLoad(LoadInfo->Stop); } VXT->Wait(); VXT->Close(); delete VXT; VXT=NULL; beh->SetLocalParameterValue(0,&VXT); } if (LoadInfo!=NULL) { delete LoadInfo; LoadInfo=NULL; beh->SetLocalParameterValue(1,&LoadInfo); } beh->ActivateOutput(3,TRUE); return CKBR_OK; } return CKBR_ACTIVATENEXTFRAME; //没结束就继续执行。 } 上面的代码稍微有点长,但是内容不多,现在做一些必要的分析。当我们进行多线程 - 412 - 装载资源的时候,场景有可能此时正在进行渲染,如果装载进来的资源,比如一个3D物体, 正好在当前相机的可视范围,那么Virtools会渲染它,而此时,这个物体只是装在了一半, 还有一半没装载进来,此时渲染这个物体就会有可能问题(这个没有得到认证,只是理论的 分析而言)。所以当我们多线程装载资源时,要告诉当前渲染设备,要注意这种情况,以下 是告诉当前渲染设备是否有线程操作的代码。 CKRenderContext* playRC=ctx->GetPlayerRenderContext();//得到当前渲染设备 playRC->WarnEnterThread(); //警告场景线程进入 playRC->WarnExitThread(); //警告场景有线程退出 由于线程的操作通常不能在一个函数体的作用域内完成,所以我们声明一个线程对象 的指针。然后“new”它。声明了一个线程对象,并不意味着该线程会立马运行(获得CPU 时间),要只有当调用了函数CreateThread( )并关联了线程的函数及参数后,才会开始运行。 以下是线程启动的代码: VxThread *VXT; //多线程加载对象的指针 VXT = new VxThread(); //创建多线程对象 VXT->SetName("MyMutithreadingLoad"); //给线程命名 VXT->SetPriority(VXTP_NORMAL); //设置线程级别 VXT->CreateThread(ObjectMutithreadingLoad,LoadInfo);//启动多线程 线程也是可以设置不同的优先级别的,但是不建议设置它,特别是不熟悉的程序员。 这里我们默认的基本是普通。线程的优先级别如下表所示: VXTP_ABOVENORMAL 高于普通级别 VXTP_BELOWNORMAL 低于普通级别 VXTP_HIGHLEVEL 高优先级别 VXTP_IDLE 空闲级别 VXTP_LOWLEVEL 低优先级别 VXTP_NORMAL 普通级别 VXTP_TIMECRITICAL 时间片轮回 知道了如何创建一个线程,相应的也要关心如何销毁一个线程。销毁一个线程就简单 得多,如果要提前销毁一个线程(也就是说线程没有从线程函数中正常返回结束),就是一 个烦恼的事情。正常的消耗过程是,先等待线程在线程函数中正常返回结束,然后关闭(释 放)线程创建的资源,最后销毁我们创建的线程指针,并赋值为NULL。销毁现场的代码如 下所示: VXT->Wait(); //以阻塞的方式等待线程结束 VXT->Close(); //关闭线程 delete VXT; //删除对象 VXT=NULL; //定义的指针赋值为空 有时候当线程被创建后,没有结束前,我们在Virtools中复位程序时,是Virtools是不会 为你结束你开辟的线程,简单的说:Virtools不会帮你擦屁股。所以我们要在复位程序的时 候结束我们的线程,注意这个时候线程可能正在执行中。在什么地方结束线程呢?如果读者 记得前面的内容的话,也想早就想到了。不错,是在该BB的回调函数中的―switch ‖语句中 的―case CKM_BEHAVIORRESET‖项里。代码段如下所示: int wow_MultiThreadCallBack(const CKBehaviorContext& BehContext) { CKBehavior* beh = BehContext.Behavior; CKContext* ctx = BehContext.Context; - 413 - VxThread *VXT; LoadObjectThreadInfo *LoadInfo; switch (BehContext.CallbackMessage) { //当用户复位Virtools时,要删除开辟的线程资源。 case CKM_BEHAVIORRESET: if(!beh->GetLocalParameter(0) || !beh->GetLocalParameter(1)) break; beh->GetLocalParameterValue(0,&VXT); beh->GetLocalParameterValue(1,&LoadInfo); if (VXT!=NULL) { if (LoadInfo) { LoadInfo->Stop=TRUE; ExitLoad(LoadInfo->Stop); } VXT->Wait(); VXT->Close(); delete VXT; VXT=NULL; beh->SetLocalParameterValue(0,&VXT); } if (LoadInfo!=NULL) { delete LoadInfo; LoadInfo=NULL; beh->SetLocalParameterValue(1,&LoadInfo); } break; } return CKBR_OK; } 以上只是开辟线程和关闭线程的方法,这些方式是通用的,真正存在差异的地方是关联 的线程函数中的操作。以下函数实现的是多线程加载磁盘资源,它的思路是这样的:要加载 的资源的文件名全部存放到一个表(Array)中,线程每次从表中读一个文件名进行加载, 加载完成后就置表中的一个标志位,表示该资源已经装载完成。 unsigned int ObjectMutithreadingLoad(void *arg) { //得到传递给线程的参数 LoadObjectThreadInfo *LoadInfo=(LoadObjectThreadInfo *)arg; //得到要加载的资源目录 CKSTRING filePath= (CKSTRING )LoadInfo->beh->GetInputParameterReadDataPtr(0); - 414 - //得到要从指定的资源目录中装载的资源列表 CKDataArray *BuildingArray= (CKDataArray *)LoadInfo->beh->GetInputParameterObject(1); XString BuildingResText=filePath; while(!LoadInfo->Stop)//线程是否从外部被申请中断----while-start { int count=BuildingArray->GetRowCount();//得到资源列表的数量 for(int i=0;iGetElementValue(i,1,&enable); if(enable==0)//没有被加载---------star { //得到要加载资源的完整的路径 XString tempstr; int size=BuildingArray->GetElementStringValue(i,0,NULL); tempstr.Resize(size-1); BuildingArray->GetElementStringValue(i,0,tempstr.Str()); tempstr=BuildingResText+tempstr; tempstr+=".nmo"; //分析这个完整的资源路径 CKPathSplitter sp(tempstr.Str()); CKFileExtension ext(sp.GetExtension()); // 得到加载模型的类 LoadInfo->mreader = CKGetPluginManager()->GetModelReader(ext); if (!LoadInfo->mreader) { LoadInfo->Error=TRUE; //失败就退出线程 return 1; } else { //成功就开始加载资源 //加载进来的资源都存放到一个CKObjectArray对象中 CKObjectArray* liste=CreateCKObjectArray(); if(liste==NULL) { //失败退出 LoadInfo->ctx->OutputToConsole ("wow_MultiThread的CreateCKObjectArray()错误"); LoadInfo->beh->ActivateOutput(2,TRUE); //??? return CKBR_OK; } //创建CKObjectArray对象成功就开始加载资源 - 415 - if(LoadInfo->mreader->Load(LoadInfo->ctx,tempstr.Str(), liste,CK_LOAD_AS_DYNAMIC_OBJECT) != CK_OK) { //装载失败退出 LoadInfo->Error=TRUE; LoadInfo->Stop=TRUE; } else { //装载成功就把资源全部加入到Level层中 LoadInfo->ctx->GetCurrentLevel()->BeginAddSequence(TRUE); CKObject *tmp=0; for(liste->Reset() ;!liste->EndOfList(); liste->Next()) { tmp = liste->GetData(LoadInfo->ctx); if(tmp) { LoadInfo->ctx->GetCurrentLevel()->AddObject(tmp); } } LoadInfo->ctx->GetCurrentLevel()->BeginAddSequence(FALSE); enable=1; BuildingArray->SetElementValue(i,1,&enable); //每加载完一个资源,歇息一下,把CPU留给其他线程。 Sleep(100); //线程是否从外部被申请中断 if(LoadInfo->Stop) { return CKBR_OK;//是就立马跳出来 } } } }//没有被加载---------end }//遍历表中元素开是否应该加载-------end LoadInfo->Stop = TRUE;//装载完成。 LoadInfo->beh->SetLocalParameterValue(2,&LoadInfo->Stop); break; }//线程是否被中断---while--end return 0x00005678; } 上面的代码的逻辑不复杂,并附有注解,希望大家能看懂,对于使用SDK开发的程序 员,也是必须看懂的。结构体LoadObjectThreadInfo在这个函数里被充分的使用,其他地方 只是初始化或释放它而已。传递给该函数的参数只是(void *)类型的指针,所以需要强制 - 416 - 类型转换。该结构体中分量“Stop”非常重要,他是退出线程的标志,当装载完成后,或 者从BB的其他端口被该分量赋值为“TRUE”时,线程函数才会从While循环体中跳出,否 则会一直循环下去。 加载模型使用的是CKModelReader类,这个类有两个有用的函数:Load( )和Save( ), 其中Load函数是一个多态的函数,分别应对从磁盘、内存和文件IO装载资源。本书只讲述 从磁盘加载资源的情况。以下是Load函数的原型: CKModelReader::Load(CKContext* context, CKSTRING FileName, CKObjectArray *liste,CKDWORD LoadFlags,CKCharacter *carac=NULL); 第一个参数是Virtools设备;第二个参数是磁盘文件名;第三个参数是对象容器(列表); 第四参数是装载标志,该标志属于CK_LOAD_FLAGS枚举类型,枚举给分量如下所示: 分量 备注 CK_LOAD_ANIMATION 装载动画 CK_LOAD_GEOMETRY 装载几何体画 CK_LOAD_DEFAULT CK_LOAD_GEOMETRY|CK_LOAD_ANIMATION CK_LOAD_ASCHARACTER 加载所有的对象,并创建一个包含它们的所有字符 CK_LOAD_DODIALOG 检查命名的唯一性,重名就弹出对话框提示用户。 CK_LOAD_AS_DYNAMIC_OBJECT 加载的物体可能是临时的,会在运行时删除 CK_LOAD_AUTOMATICMODE 重名的物体的处理要么被重命名要么被替换,依据 CKContext::SetAutomaticLoadMode 的设置来判定 CK_LOAD_CHECKDUPLICATES 检查命名的唯一性 CK_LOAD_CHECKDEPENDENCIES 检查每一个所需的插件 CK_LOAD_ONLYBEHAVIORS 无相关文档可查 CK_LOAD_REPLACEALL_WITHSCRIPT 替换所有重复的名字 当然装载资源也可以不使用CKModelReader这个类,也可用使用CKContext类型的 Load函数。确实这也是可以的,只是CKModelReader是装载的基类,读者也可用CKContext 类来加载资源,看看有什么更好的地方。 要想获得CKModelReader对象,就要通过插件管理器(CKPluginManager类)的 CKModelReader( )函数获得,该函数需要传递一个参数,这个参数表示要加载资源的文件 扩展名。要从完整的文件路径中获得文件扩展名,可以通过类CKPathSplitter来实现, CKPathSplitter类用于分解组成它的四个组成部分(磁盘驱动器,目录,文件名和扩展名) 的路径,顺带一提CKPathMaker类的作用正和CKPathSplitter类相反,它是创建一个由这四 个组成部分的路径字符串。 装载进来的资源在对象容器(CKObjectArray )里,我们必须要把他们加入到Level 层中,如果当前是在某个场景(CKScene)的渲染,还要把它加入到当前场景中才能显示 在画面中。由于加载进来的物体、贴图、动画等很多,不要一下子全显示到相机前,要有计 划的一点一点显示出来,否则会程序很卡。为什么呢?因为加载进来的东西是没有经过渲染 优化的,再加上进出显存是一个慢操作,碰到大量要挤进显卡渲染的物件,程序不卡才怪。 打个比方说我们玩一些游戏,大家集在一起,然后一起放出各式各样的魔法特效,游戏画面 就会卡,这是同样的道理。 如果线程是一个长时间的操作,要Sleep一下,让其他线程做一下操作,比如其他线程 想告诉该线程,你要提前结束。如果没有Sleep,很有可能该线程很忙,没有时间理会外面 发生的事情,可能会失控、会“瞎忙”。 上面的代码是把表中所列的资源全部加载完后,退出。有些时候,会经常发生加载,比 如飞行预览地球,所以该线程没必要退出,要加载的时候就往该表里面填写新的资源列表。 - 417 - 这时候就要注意了,这个线程在读表,如果另外一个线程在对表进行读写删操作,就容易出 错,这里就出现了前面提到的互斥和同步问题了。解决的办法也在前面提过了使用VxMutexl 类,如何它的示意方法如下所示: VxMutexl::EnterMutex( ); 这里对表进行操作„„„„ VxMutexl::LeaveMutex( ); 任何一个要访问这个表的线程在操作表的前后要加上这两行代码,有一处不加,就容易 发生问题。本书的例子中没有使用的该类,因为没有其他线程争用资源。 另外顺带又提一下在线程中操作输入、输出端口不一定会成功比如下面代码就不会每次 都成功: LoadInfo->beh->ActivateOutput(2,TRUE); //线程中控制端口,不会每次都成功 线程的加载到这里就讲完了,如果你加载的资源里面带有Virtools脚本,那么这个BB会 运行失败,如何解决这个问题,留给读者自己去尝试吧。除了使用Virtools自带的多线程类 外,当然也可用使用Windows32API、或者C/C++的函数来实现,只是这不是本书的目的, 这里就不举例了。 21.2 管理器的开发使用 21.2.1 管理器为何物 在Virtools中管理器是BB以外,另一个重要的东西。这个东西在作者所涉及的项目开发 中,使用到的情况不多,虽然很多功能可以通过写BB的方式扩展,但不一定是最优的方法。 熟悉和使用管理器的开发是很有必要的。 管理器一个最大的特点是你可以在Virtools所有脚本执行前,或者在所有脚本执行以后 的这两个点进行介入,开发你想要实现的功能(参见:图19.1.2_1)。 开发管理器和BB一样,最终生成的是DLL文件,用户自定义的管理器都是从类 CKBaseManager派生而来,而CKBaseManager中有很多虚函数,你必须重载这些虚函数。 这也许就是两者在代码编写上重要区别的地方。 我们来看看如何创建一个自定义的管理器。在开始之前,读者必须已经执行过19.2.1小 节所描述的操作,然后在执行了图19.2.2_1的操作后,在弹出的对话框中选择“Manager” 项,而不在是“Building Block”,如图21.2.1_1所示。 图21.2.1_1 - 418 - 选择下一步后,会弹另一个对话框,内容非常的庞大,“Manger Name”是管理器的名 字,“Manager GUID”是管理器的全局唯一标识符。其他的选项分4类为多选,“Processing Events”是事件处理过程,其实就是脚本执行事件;“Object Events”为对象处理事件; “Rendering Events”是渲染事件;“Misc”是其他事件。如图21.2.1_2所示,主要事件的 的解释请看表21.2.1_1。 图21.2.1_2 项名 解释 SaveData 当在Virtools编辑模式中装载和保存数据时的事件函数 LoadData - 419 - PreClearAll 在Virtools引擎调用函数CKClearAll操作前后的事件函数 PostClearAll PreProcess 在Virtools引擎调用函数CKProcess操作前后的事件函数, 简单的说就是暂停的时候 PostProcess SequenceAddedToScene 当物体加入或者移除场景前后的事件函数 SequenceRemovedFromScene PreLaunchScene 在“激活”一个场景前后的事件函数 PostLaunchScene OnCKInit Virtools引擎初始化的事件函数 OnCKEnd Virtools引擎解释的事件函数 OnCKReset Virtools引擎复位前后的事件函数 OnCKPostReset OnCKPause Virtools引擎暂停或者运行前后的事件函数 OnCKPlay SequenceToBeDeleted 一个物体被删除前后的事件函数 SequenceDeleted PreLoad 装载一个cmo或者nmo前后的事件函数 PostLoad PreSave 保存一个cmo或者nmo前后的事件函数 PostSave OnPreCopy Copy物体前后的事件函数 OnPostCopy OnPreRender 渲染物体前后的事件函数 OnPostRender OnPostSpriteRender 渲染精灵以后的事件函数 表21.2.1_1 读者有兴趣可以全部勾选上,看看到底发生了什么变化。我们继续点击下一步,此时 弹出最后一个对话框,如图21.2.1_3所示。这个对话框和图19.2.2_9的对话框没有区别,只 是记录一下插件的相关信息。 - 420 - 图21.2.1_3 创建完成以后,我们在输入代码之前,可以立即进行编译生成dll,即使这个管理器什 么没做。将生成的DLL,建议放到Virtools目录下的“Managers” 文件夹中,当然也可以 放在“BuildingBlocks”文件夹中也可以,两者是没有区别的。然后我们打开Virtools,选择 菜单栏中的“Options”,然后再选择“Installed Plugins”项,然后会弹出下面的对话框, 选择“Managers”项,就可以查找到Virtools已经识别了我们开发的管理器插件。同样的选 择“BuildingBlocks”可以查看到我们到目前为止开发的BB,如图21.2.1_4所示。 当Virtools启动的时候会加载我们开发的Manager和BB,但有时候我们不希望启动 Virtools的时候加载某些Manager和BB,只要在“Active”列,对应的选项上进行鼠标单击, Virtools就会在这一项打上一个“×”,如图21.2.1_4所示(圈内所示)。 图21.2.1_4 - 421 - 21.2.2 Manager 代码解释 对比一下19.2小节的内容,发现向导创建管理器的代码比创建BB的代码,多了一个头 文件“wow_Manager.h”,如图21.2.2_1所示。打开这个文件,会发现这个头文件的内容会 根据图21.2.1_2所选着的项不同而添加不同的代码,主要是我们要重载的虚函数。 图21.2.2_1 在这个头文件中发现Virtools也会为该管理器建立一个全局唯一标识符CKGUID,这个 标识符必须是唯一的,不能和BB或者某个类型变量的GUID相同,否则会出现意想不到的错 误,如果我们都是用向导生成代码,基本上不会出现相同的GUID。 #define wow_ManagerGUID CKGUID(0xc9e99be5, 0xf474c024) 在这个头文件中,向导为我们定义好了一个类,这个类就是我们要开发的管理器类的原 型,我们会在这里添加我们的代码来扩充这个管理器的功能。代码如下所示: class wow_Manager : public CKBaseManager { public : wow_Manager(CKContext* Context); //--- 构造函数 ~wow_Manager(); //--- 析构函数 virtual CKERROR OnCKReset(); //--- 当Virtools复位的时候会调用该函数 virtual CKERROR OnCKPlay(); //--- 当Virtools运行的时候会调用该函数 virtual CKERROR OnCKPause(); //--- 当Virtools暂停的时候会调用该函数 virtual CKDWORD GetValidFunctionsMask() { //--- 向Virtools 注册该管理器类要处理的回调函数 Return CKMANAGER_FUNC_OnCKReset| CKMANAGER_FUNC_OnCKPlay|CKMANAGER_FUNC_OnCKPause; } } 从上面的代码可以看出管理器类wow_Manager是从类CKBaseManager继承而来,所 以管理器wow_Manager可以是用CKBaseManager中定义的函数方法,这些函数方法,建 议读者去学习学习。要开发管理器,需要读者对C++有一定的基础。定义新的管理器插件后, 接下来的一步是向Virtools告知自己的合法性,这就需要注册,就好像我们平时上网,必须 注册后,才能有发言权。注册这一步的代码Virtools向导已经帮我们写好了,就是在该类的 构造函数中实现,代码在―wow_Manager.cpp‖中,如下所示: wow_Manager::wow_Manager(CKContext *Context) : CKBaseManager(Context, wow_ManagerGUID, - 422 - "wow_Manager") { Context->RegisterNewManager(this); //--- 向Virtools注册我们自定义的管理器 } 管理器的代码就除了上面两个文件外,其余的另外两个文件的代码与BB的代码没什么 区别,详细请参照19.2小节的内容,这里就不在多说了。 21.2.3 Virtools 与 ADO 的整合 ADO (ActiveX Data Objects,ActiveX 数据对象)是Microsoft 提供的应用程序接口(API) 用以实现访问关系或非关系数据库中的数据。谈起 ADO 有人会联想到 ADO.net,其实两 者是有区别的。ADO 使用 OLE DB 接口并基于微软的 COM 技术,而 ADO.NET 拥有自己 的 ADO.NET 接口并且基于微软的.NET 体系架构。.NET 体系不同于 COM 体系,ADO.NET 接口也就完全不同于 ADO 和 OLE DB 接口,这也就是说 ADO.NET 和 ADO 是不同的两种 数据访问方式。 这一节讲的是开发一个 Virtools 的管理器,这个管理器是使用 Virtools SDK 与 ADO 做 一个插件,这个插件的作用是访问数据库。例子程序“ADOACCESS 数据库管理器”是这 个小节的用例,他的功能是这样的: 首先,当 Virtools 运行的时候,他会读取一个 Level 层的一个属性―Database‖的值(当 然这个值你可以去改变它),这个值存放数据库的完整路径和文件名。 其次,当得到数据库路径后,会立即打开它,并分析数据库中的表名和列名,然后在 Virtools 创建相应的表,包括表名和列名都一样。 最后,把数据库中所有的内容全部读出来,放到 Virtools 表中。这样要注意的数据库 的类型和 Virtools 表中的类型是不一样的,为了使问题简单化,以便用于教学。我们要做 一个约定:我们只识别 int、float、bool、string 四中类型。 数据库我们选用随手可得的桌面型数据库 ACCESS(这个在 Microsoft Office 安装程 序中可以得到)。当使用不同的数据库软件时,注意打开这些数据库的命令不一样,这些在 附带的例子程序中有注释。 ADO 的内容本书不会多说,以下是一个把 ADO 封装到一个类中,类名叫 CTinyADO, 以方便在 Virtools 中使用,虽然功能不是很强大,但读者可以自己扩展。 //#import ―…‖ 的作用与熟悉的#include类似,编译的时候系统会为程序生成 msado15.tlh,ado15.tli两个C++头文件来定义ADO库。 #import "c:\program files\common files\system\ado\msado15.dll" no_namespace rename("EOF","adoEOF") #include //Include support for VC++ Extensions #include #include //存放表中的列名、和标示列的类型 struct COLUMN { char colname[32]; int coltype; }; class CTinyADO - 423 - { public: CTinyADO(void); ~CTinyADO(void); //初始化数据库 BOOL InitDB(char * yourDB); //打开数据库 BOOL OpenData(char * yourArray); //得到所有的表名 BOOL GetALLArrayName(std::vector *Name); //得到所指定表的所有列名 BOOL GetALLColumnName(std::vector *ColName,char *yourArray); //得到数据库中指定表的某一列的全部内容 //得到该表该列的所有字符串内容 BOOL DBUpdateArray(char *yourArray,std::vector *ColTex,char *ColmnName); //得到该表该列的所有整形内容 BOOL DBUpdateArray(char *yourArray,std::vector *ColTex,char *ColmnName); //得到该表该列的所有浮点型内容 BOOL DBUpdateArray(char *yourArray,std::vector *ColTex,char *ColmnName); //得到该表该列的所有bool内容 BOOL DBUpdateArray(char *yourArray,std::vector *ColTex,char *ColmnName); //数据更新 //设置该表该列的所有字符串内容 BOOL ArrayUpdateDB(char *yourArray,std::vector *ColTex,char *ColmnName); //设置该表该列的所有整形内容 BOOL ArrayUpdateDB(char *yourArray,std::vector *ColTex,char *ColmnName); //设置该表该列的所有浮点型内容 BOOL ArrayUpdateDB(char *yourArray,std::vector *ColTex,char *ColmnName); //设置该表该列的所有bool内容 BOOL ArrayUpdateDB(char *yourArray,std::vector *ColTex,char *ColmnName); //关闭数据 void CloseData(void); //释放Connection对象 void ReleaseConnection(void); //释放Connection对象 void ReleaseRecordset(void); - 424 - //删除所有记录 BOOL DeleteAllRecord(char *yourArray); private: // 用Connection对象连接数据库 _ConnectionPtr m_pConnection; // 一个指向Recordset对象的指针 _RecordsetPtr m_pRecordset; //用于执行SQL语句的对象的指针 _CommandPtr m_pCommand; // 是否成功打开数据库中的表 bool m_bSuccess; }; 我在这个写这个类时,尽量做到与Virtools无关,这样方便移植到其他项目工作中,这 个类的工作比较单一,就是打开、关闭、然后更新内容而已,代码中都有注释。类中使用 STL的vector容器,请不要把它和3D图像学中的vector相混淆。如果真的混淆了,请学习C++ 标准库STL。另外这个类中出现了一过些特别的类型和对象:_ConnectionPtr、RecordsetPtr、 CommandPtr 这些事ADO中的类型,与Virtools无关,如果读者不熟悉,也要学习一下ADO 的程序开发。 上面的文件中我们定义了一个结构体―struct COLUMN‖,这个结构的作用是在创建 Virtools表的时候使用。它有两个分量一个存储表的列名,是字符串类型;一个存储表的类 型,0表示该列存储的是整型数据、1表示该列存储的是浮点型数据、2表示该列存储的是布 尔型数据、3表示该列存储的是字符串类型数据。 这里要提醒大家,我们学习的关键不是ADO与Virtools的插件,而是学习Virtools与其 他第三方API的集成方法,说得夸张些是学习不同系统的集成。在作者的实际工作中除了 ADO集成、还有FMOD集成、还有RakNet集成、还有Flash集成等等。因为VirtoolsSDK的 易扩展性,所有可以举一反三。 21.2.4 创建 ADOACCESSManager 按照21.2.1的步骤,我们新建一个工程文件,并命名为ADOACCESSManager,并给这 个管理器命名为“wow_ADOACCESS”。创建完成后将21.2.2小节提及的代码添加到工程 文件中(也就是TinyADO.h、TinyADO.cpp这两个文件),然后打开―wow_ADOACCESS.h‖ 文件,在顶部输入包含头文件#include“ TinyADO.h”,即可编译生成dll。向导生成的代码 中是不做任何事情的,我们现在要添加自己的代码进去。 我们的第一个目标是打开数据库,所有首先要加入处理数据库的方法,这个方法封装在 类―CTinyADO‖中,我们把这个类包含到VC项目中,这一步我们已经做好了。此时要在管理 器类wow_ADOACCESS中定义一个CTinyADO类型的成员变量,这个成员变量命名为 tinyDB,这样管理器就可以得到处理数据库的能力了。代码如下所示: #include "CKBaseManager.h" #include ".\tinyado.h" #define wow_ADOACCESSGUID CKGUID(0x887c126e, 0x6b906a94) class wow_ADOACCESS : public CKBaseManager { public : wow_ADOACCESS(CKContext* Context); - 425 - ~wow_ADOACCESS(); //--- Called before the composition is reset. virtual CKERROR OnCKReset(); //--- Called when the process loop is started. virtual CKERROR OnCKPlay(); //--- Called when the process loop is paused. virtual CKERROR OnCKPause(); //--- Returns list of functions implemented by the manager. virtual CKDWORD GetValidFunctionsMask() { return CKMANAGER_FUNC_OnCKReset| CKMANAGER_FUNC_OnCKPlay| CKMANAGER_FUNC_OnCKPause; } //---根据输入的表名、列名创建Virtools中的表。 CKBOOL CreateArray(char *arrayName,std::vector *ColmunName,char * yourArray=NULL,CKBOOL EnableFill=FALSE); //---声明一个处理数据库的对象 CTinyADO tinyDB; protected : BOOL Enable; }; 因为有了CTinyADO对象,管理器已经具有打开数据库的能力了,但是还没有在Virtools 中创建与数据库中一样的表和列名的能力,所以我们必须实现它。函数CreateArray( )就是 实现了这个功能,他有3个参数:第一个参数是要创建的表的名字;第二个参数是一个我们 自定义结构体的容器,这个容器存储了表中的列名和列的类型;第三个参数是要打开数据库 中的表名;第四个参数是创建完表以后是否要把数据库中的数据更新到我们创建的Virtools 的表中。现在是决定在什么时候打开数据库,我们假定在Virtools运行的时候,打开我们的 数据库。如果是这样就要重载virtual CKERROR OnCKPlay()函数了。这个函数在向导生成 的代码中已经准备好了,我们填入的代码如下所示: CKERROR wow_ADOACCESS::OnCKPlay() { if(!Enable) //Enable的作用是避免在Virtools暂停后继续运行时做个处理 { Enable=TRUE; CKAttributeManager* AttributeManager=NULL; //定义一个属性管理器指针 CKLevel* CurLevel=NULL; //定义一个Level指针 AttributeManager=m_Context->GetAttributeManager();//得到属性管理器 CurLevel=m_Context->GetCurrentLevel(); //得到当前Level层 //得到Level层属性(Database),要打开的数据库完整路径在这里 int DBFile=AttributeManager->GetAttributeTypeByName("Database"); //属性值用CKParameterOut承接 CKParameterOut* pout = CurLevel->GetAttributeParameter(DBFile); - 426 - if (!pout) { m_Context->OutputToConsole("Database Attribute Error!"); } else { //得到Database属性的值 char* StringValue = (char *) pout->GetReadDataPtr(); if(tinyDB.InitDB(StringValue)) //打开数据库 { m_Context->OutputToConsole("数据库打开成功"); } else { m_Context->OutputToConsole(StringValue); m_Context->OutputToConsole("数据库打开错误!"); m_Context->OutputToConsole("是否缺失: Provider=Microsoft.Jet.OLEDB.4.0;Data Source="); } } std::vector arrayName; //定义一个容器,用来存放所有的表名 tinyDB.GetALLArrayName(&arrayName); //得到数据库中所有的表的名字 char ch[64]; //定义一个字符串,用于表示列名 std::vector::iterator iter; //定义迭代器,用来处理表名 XString OpenArrayName="SELECT * FROM "; //打开表的SQL语句前半部分 XString tempName; //用来表示打开表的完整SQL语句 for(iter=arrayName.begin();iter!=arrayName.end();++iter) { //不要放在循环体外面,每次得到列名时,并没有清除之前的内容 std::vector ColmunName; //定义一个容器,用来存放表的列名 tempName=OpenArrayName; tempName+=*iter; tinyDB.GetALLColumnName(&ColmunName,tempName.Str());//得到表列名 CreateArray(*iter,&ColmunName,tempName.Str(),TRUE); //创建表 std::vector::iterator iterCOL; //定义迭代器,用来处理列名 for(iterCOL=ColmunName.begin();iterCOL!=ColmunName.end();++iterCOL) { strcpy(ch,iterCOL->colname); m_Context->OutputToConsole(ch); //把列名打印到Virtools控制台 } } //删除,我们创建的指针。 char *god=0; for(iter=arrayName.begin();iter!=arrayName.end();) - 427 - { god=*iter; ++iter; delete[] god; god=NULL; } return CKERR_NOTIMPLEMENTED; } 函数OnCKPlay()运行的时候会调用一次,如果在Virtools运行过程中被暂停了运行,接 着继续运行时又会再一次调用该函数。而我们打开数据库并创建Virtools表都是在这个函数 里实现的,如果暂停几下Virtools,就会重复运行多重。这样的结果不是我们要的,所以我 们定义了一个布尔型变量Enable,用于避免重复的运行,同时也要记得在Virtools复位后, 把这个变量设置回初值,前面提过Virtools复位会调用函数OnCKReset(),设置回初始值也 就这个函数中执行,代码如下所示: CKERROR wow_ADOACCESS::OnCKReset() { Enable=FALSE; return CKERR_NOTIMPLEMENTED; } 因为打开数据库的语句存放在Level层的―Database‖属性中,所以首先要获得属性管理器, 然后找到Level层,最后才能得到属性的值。属性管理器是可以通过下面一条语句得到: CKAttributeManager* AttributeManager=m_Context->GetAttributeManager(); m_Context在第19章所说的Virtools设备上下文。得到属性管理器后就可以根据属性名 得到属性,但要注意返回的属性是一个整型数值,这个时候得到是属性,还没得到存储在属 性中的值,以下是得到属性的语句: int DBFile=AttributeManager->GetAttributeTypeByName("Database"); 属性可以被大部分Virtools对象所使用,比如表、组、3D物体、2D帧,不同的物体可 以拥有相同的属性。要想获得某个物体的属性,首先要得到这个物体,因为属性的类型很多, 还好Virtools定义了一个通用方式来获得不同对象的各式各样类型的属性值,就使用参数 (Parameter)这个对象来获得属性的值,然后再根据参数(Parameter)的相应函数方法 来转换到具体的类型值,这个方法很通用,代码如下所示: CKParameterOut* pout = CurLevel->GetAttributeParameter(DBFile); char* StringValue = (char *) pout->GetReadDataPtr(); 上面是获得字符串的值,如果是其他基本类型的值比如整型、浮点型就要调用另外的 函数GetValue( )、如果是获得Virtools其他对象的值就使用函数GetValueObject( )或者 GetObject( ),用法比较简单,详细请参考Virtools的SDK中的Parameter相关内容。为了操 作数据库方便, tinyDB内部会创建了一些对象,这些被tinyDB创建的对象是不会被Virtools 释放的,因为Virtools不知道这些资源的存在,也就无法管理;所有我们要在Virtools退出程 序前释放掉这些资源,释放资源的地方放到管理器的析构函数最合适了。如下代码所示: wow_ADOACCESS::~wow_ADOACCESS() { tinyDB.ReleaseConnection(); } 上面的函数 OnCKPlay( )实现获得数据库中的表名和列名,但只是获得,我们的目的 - 428 - 是在 Virtools 中创建一样的表和列,包数据库中的数据―映射‖一份到 Virtools 表中,这就需 要创建 Virtools 表(array)和列。为了实现这个功能,我们专门开发了一个成员函数,代 码如下所示: //在Virtools中创建Array,这个Array与DB中的表对应。 CKBOOL wow_ADOACCESS::CreateArray(char *arrayName,std::vector *ColmunName,char * yourArray,CKBOOL EnableFill) { std::vector::iterator iterCOL; //定义一个迭代器,用于遍历列名 char ch[1024]; //临时存放列名 int coltype=0; //列名的类型 CKLevel* CurLevel=NULL; CurLevel=m_Context->GetCurrentLevel(); //创建Virtools中的Array CKDataArray *obj=(CKDataArray *)m_Context->CreateObject( CKCID_DATAARRAY,arrayName,CK_OBJECTCREATION_DYNAMIC ); CurLevel->AddObject(obj); if (obj)//添加Array中的列,设置列的类型 { for(iterCOL=ColmunName->begin();iterCOL!=ColmunName->end();++iterCOL) { strcpy(ch,iterCOL->colname); switch (iterCOL->coltype) { case 0: //0表示整型 obj->InsertColumn(-1,CKARRAYTYPE_INT,ch); break; case 1: //1表示浮点型 obj->InsertColumn(-1,CKARRAYTYPE_FLOAT,ch); break; case 2: //2表示布尔类型 obj->InsertColumn(-1,CKARRAYTYPE_PARAMETER,ch,CKPGUID_BOOL); break; case 3: //0表示字符串类型 obj->InsertColumn(-1,CKARRAYTYPE_STRING,ch); break; } } } else { return FALSE; } //判断是否要把DB中的数据填到Virtools表中 if (EnableFill&&yourArray) - 429 - { char *god=0; int colmun=-1; int NUM=0; //是否需要增加新行的计数器 int count=0; //当前表的总行数 std::vector ColINT; //整型的容器 std::vector::iterator iterINT; //迭代器 std::vector ColFLOAT; //浮点型的容器 std::vector::iterator iterFLOAT; //迭代器 std::vector ColBOOL; //布尔型的容器 std::vector::iterator iterBOOL; //迭代器 std::vector ColTex; //字符串的容器 std::vector::iterator iterText; //迭代器 int an=0; float fan=0; BOOL ban=false; //遍历数据库中所有列,根据列的类型,使用不同的容器提前数据库中当列的所有值 for(iterCOL=ColmunName->begin();iterCOL!=ColmunName->end();++iterCOL) { colmun++; //遍历列时的计数器 switch (iterCOL->coltype) //判断列的类型 { case 0: //整型 ColINT.clear(); //使用前清干净容器 //得到该类的所有整型内容 tinyDB.DBUpdateArray(yourArray,&ColINT,iterCOL->colname); NUM=0; //是否需要增加新行的计数器 for(iterINT=ColINT.begin();iterINT!=ColINT.end();++iterINT) { NUM++; count=obj->GetRowCount();//当前表的总行数 if (countAddRow(); } an=*iterINT; //得到值并填入表中 obj->SetElementValue(NUM-1,colmun,&an); } break; case 1: //浮点型 ColFLOAT.clear(); //使用前清干净容器 //得到该类的所有浮点型内容 tinyDB.DBUpdateArray(yourArray,&ColFLOAT,iterCOL->colname); - 430 - NUM=0; //是否需要增加新行的计数器 ` for(iterFLOAT=ColFLOAT.begin();iterFLOAT!=ColFLOAT.end();++iterFLOAT) { NUM++; count=obj->GetRowCount();//当前表的总行数 if (countAddRow(); //创建新行 } fan=*iterFLOAT; //得到值并填入表中 obj->SetElementValue(NUM-1,colmun,&fan); } break; case 2: //布尔类型 ColBOOL.clear(); //使用前清干净容器 //得到该类的所有浮点型内容 tinyDB.DBUpdateArray(yourArray,&ColBOOL,iterCOL->colname); NUM=0; //是否需要增加新行的计数器 for(iterBOOL=ColBOOL.begin();iterBOOL!=ColBOOL.end();++iterBOOL) { NUM++; count=obj->GetRowCount(); //当前表的总行数 if (countAddRow(); //创建新行 } ban=*iterBOOL; //得到值并填入表中 obj->SetElementValue(NUM-1,colmun,&ban); } break; case 3: //字符串类型 //使用前清干净容器 for(iterText=ColTex.begin();iterText!=ColTex.end();) { god=*iterText; ++iterText; delete[] god; god=NULL; } ColTex.clear(); //使用前清干净容器 tinyDB.DBUpdateArray(yourArray,&ColTex,iterCOL->colname);// NUM=0; //是否需要增加新行的计数器 for(iterText=ColTex.begin();iterText!=ColTex.end();++iterText) { - 431 - NUM++; count=obj->GetRowCount(); //当前表的总行数 if (countAddRow(); //创建新行 } //得到值并填入表中 obj->SetElementStringValue(NUM-1,colmun,*iterText); } //使用完再清干净容器 for(iterText=ColTex.begin();iterText!=ColTex.end();) { god=*iterText; ++iterText; delete[] god; god=NULL; } ColTex.clear(); break; } } } return TRUE; } 代码好长呀,其实这段代码做的事情就只有两件:一是创建表(Array),二是往表里 面填相应的数据。该函数的第一个参数是表的名字;第二个参数是该表的所有列结构说明, 前面也提供这个结构体保护了列的名字和列的类型标示;第三个参数是打开数据库表的 SQL 语句;第四个参数是表示是否把数据库中的所有数据填入相应的 Virtools 表中。 表和列创建好以后,我们是一列一列的内容往里填,而不是一行一行的往里填,当第 一次填内容时,就要给 Virtools 表创建行;当填写不同列的内容时,数量是不一样的,所 有行有可能不够,所以每次填写时要比较行数。由于使用了 std::vector 来存储要填写的数 据,当涉及到字符串指针时,要记得释放这个字符串所占用的内存,std::vector 的 clear( ) 函数是不负责回收指针所占用的内存的。 另外有一点特别要提醒的是在数据库的表中,必须要有一行数据,否则以上代码就会 得不得正确的结果,至于为什么,也许是 ACCESS 的访问方法有一些限制;还有一点要 特别提醒的是ACCESS如果有些列设置成“主键”,而 Virtools的表中对应的列,没有对“主 键”做处理的话,那么在会写 ACCESS 的表时,就会发生错误。 21.2.5 在 BB 中使用调用管理器方法 上面在管理器中使用ADO访问数据库的方法已经完成了,但是在实际的开发工作中, 我们需要部分功能在管理器中实现,另外的功能在BB中实现。比如在需要的时候增加数据、 清除数据、以及查询数据、数据排序、数据筛选等等操作,这些操作如果只能在Virtools启 动、暂停、复位时候实现的话,就变得不灵活。所有我们就要在前面创建的管理器的基础上, 再扩展对数据库操作的功能。 - 432 - 我们现在来扩展一个删除所有数据库中指定表的所有内容,我们用向导创建一个BB, VC工程命名为―ADOACCESSClear‖,BB命名为―wow_ADOACCESSClear‖,要想扩展数据 库的功能,必须要包含相应的文件到该工程中,wow_ADOACCESS.h、tinyado.h、 tinyado.cpp这三个文件时可缺少的,加入到工程文件后,添加下列代码: int wow_ADOACCESSClear(const CKBehaviorContext& BehContext) { CKContext *ctx=BehContext.Context; CKBehavior *beh=BehContext.Behavior; char *MyArray = (char *)(beh->GetInputParameterReadDataPtr(0)); beh->ActivateInput(0,FALSE); beh->ActivateOutput(0,FALSE); beh->ActivateOutput(1,FALSE); // 得到管理器 wow_ADOACCESS* adom = (wow_ADOACCESS*) ctx->GetManagerByGuid( wow_ADOACCESSGUID); if (adom) { adom->tinyDB.DeleteAllRecord(MyArray); // 清除数据库的指定表 beh->ActivateOutput(0,TRUE); } else { beh->ActivateOutput(1,TRUE); } return CKBR_OK; } 这个BB的功能是删除指定数据库的指定表,输入参数是表名,tinyDB这个类中已经实 现了具体的操作,这个BB只是负责调用该函数执行删除动作而已。可以看见我们这里的所 谓扩展删除功能,只是调用管理器里面已经开发好的功能而已,这种方法是正确的,管理器 准备了所有的功能,调用则通过BB来实现。详细请参加例子程序―ADOACCESS清除‖。 上面是一个清除数据库指定表的扩展,下边再写一个更新数据库表的扩展,以便加深 印 象 。 我 们 用 向 导 创 建 一 个 BB , VC 工 程 命 名 为 ―ADOACCESSWrite‖, BB 命 名 为 ―wow_ADOACCESSWrite‖,同样的把wow_ADOACCESS.h、tinyado.h、tinyado.cpp这三 个文件加入到工程文件后,添加下列代码: int wow_ADOACCESSWrite(const CKBehaviorContext& BehContext) { CKContext *ctx=BehContext.Context; CKBehavior *beh=BehContext.Behavior; beh->ActivateInput(0,FALSE); //关闭所有输出端口。 beh->ActivateOutput(0,FALSE); beh->ActivateOutput(1,FALSE); XString outinfo;//BB运行状态输出 CKDataArray* MyArray=NULL; MyArray=(CKDataArray *)beh->GetInputParameterObject(0); //要回写的表 - 433 - if (!MyArray) //输入参数错误 { outinfo="输入参数错误"; beh->SetOutputParameterValue(0,outinfo.Str(),outinfo.Length()+1); beh->ActivateOutput(1,TRUE); return CKBR_OK; } std::vector contentText; //字符串容器 std::vector contentINT; //整型容器 std::vector contentFLOAT; //浮点型容器 std::vector contentBOOL; //布尔型容器 int count=MyArray->GetRowCount(); //得到表的函数 int size=0; float f=0.0; BOOL en=FALSE; XString tempstr; //获得表的所有内容 for (int i=0;iGetElementStringValue(i,0,NULL); tempstr.Resize(size); MyArray->GetElementStringValue(i,0,tempstr.Str()); ctx->OutputToConsole(tempstr.Str()); size=tempstr.Length(); char *ch=new char[size]; strcpy(ch,tempstr.Str()); contentText.push_back(ch); //把字符串内容加到容器 MyArray->GetElementValue(i,1,&size); contentINT.push_back(size); //把整型内容加到容器 MyArray->GetElementValue(i,2,&en); contentBOOL.push_back(en); //把布尔型内容加到容器 MyArray->GetElementValue(i,3,&f); contentFLOAT.push_back(f); //把浮点内容加到容器 } //得到管理器,打开指定表,并整列更新数据到数据库中 XString OpenArrayName="SELECT * FROM "; OpenArrayName+=MyArray->GetName(); wow_ADOACCESS* adom = (wow_ADOACCESS*) ctx->GetManagerByGuid( wow_ADOACCESSGUID); adom->tinyDB.ArrayUpdateDB(OpenArrayName.Str(),&contentText,"字符串类型"); adom->tinyDB.ArrayUpdateDB(OpenArrayName.Str(),&contentINT,"整型"); adom->tinyDB.ArrayUpdateDB(OpenArrayName.Str(),&contentFLOAT,"浮点型"); adom->tinyDB.ArrayUpdateDB(OpenArrayName.Str(),&contentBOOL,"布尔类型"); //释放资源 - 434 - std::vector::iterator iterText; for(iterText=contentText.begin();iterText!=contentText.end();) { ctx->OutputToConsole(*iterText); char *god=*iterText; ++iterText; delete[] god; god=NULL; } beh->ActivateOutput(0,TRUE); return CKBR_OK; } 回写数据的功能要比清除数据的代码要多,其实调用管理器相应的函数就只有几个语 句,其他的语句都是为了回写做准备的,有人想为什么不把这个功能也写到管理器中,这样 再开发BB的时候,就像清除BB一样简单,调用一个函数就搞定了。有一点要明白的管理器 是不知道具体的表和列的情况,所以它是无法做到通用的,管理器只是定义一下抽象的操作, 具体情况就要BB具体去分析了,当然随着项目的开展,需求的明确,管理器会慢慢抽象出 更多的操作以供扩展,但这是需要一个过程,这也是项目开展的一种过程。 有细心的读者会发现:每扩展一个管理器的功能,就要把wow_ADOACCESS.h、 tinyado.h、tinyado.cpp这三个文件加入到工程文件中,当扩展的越多,越多的工程都有这 三个文件,如果这三个文件有改动,就必须把所有工程都更新一遍这些文件,而且还要所有 工程编译一遍才能得到正确的结果,这样太麻烦了。确实太麻烦了,那么我们就把这些代码 整合到一个工程文件中,这样修改起来就方便很多。 21.2.6 把管理器功能整合到一个 dll 中 上面的几个小节中,我们为了实现对ACCESS的访问,建立了三个工程文件,生成了三个 不同的dll,然后这三个dll放在Virtools不同的目录中(“BuildingBlocks”、“Managers”) 以便区分。这个看起来挺麻烦的,试想把一个管理器和由这个管理器衍生处理的BB,全部封 装到一个工程文件中,只生成一个动态库,这样维护和使用起来真是方便很多。其实要做到 这一步,只需要做几个步骤就可以实现了。 我们用向导生成的工程文件,编译生成后默认是一个dll,这个dll就是一个插件,现在 我们要做的是编译生成后仍然是一个dll,但包含了管理器插件和清除、回写插件。所以第 一步我们要告诉Virtools引擎,我们这个dll包含了两个插件。这个工作很简单,只要修改 宏定义PLUGIN_COUNT的值即可。打开实例工程文件“ADOACCESS数据库管理器_第二十一章 21.5”,打开“ADOACCESSManager.cpp”文件,修改以下代码: #define PLUGIN_COUNT 1 //修改前默认1,表示这个dll里只包含1个插件 #define PLUGIN_COUNT 2 //修改后为2,表示这个dll里包含2个插件 Virtools现在已经知道了插件的数量,接下来要告诉它每个插件是什么类型,用向导 默认生成的管理器插件信息的代码如下: CKPluginInfo* CKGetPluginInfo(int Index) { int Plugin = 0; g_PluginInfo[Plugin].m_Author = "Virtools"; g_PluginInfo[Plugin].m_Description = "Enter your description here"; - 435 - g_PluginInfo[Plugin].m_Extension = ""; g_PluginInfo[Plugin].m_Type = CKPLUGIN_MANAGER_DLL; g_PluginInfo[Plugin].m_Version = 0x00010000; g_PluginInfo[Plugin].m_InitInstanceFct = InitInstance; g_PluginInfo[Plugin].m_ExitInstanceFct = ExitInstance; g_PluginInfo[Plugin].m_GUID = wow_ADOACCESSGUID; g_PluginInfo[Plugin].m_Summary = "Enter your summary here"; } 上面这个函数只是告诉Virtools引擎,这个工程文件只有一个插件的信息,函数 CKGetPluginInfo的解释在第19.2.3的章节中已经介绍过,我们要往里面添加第二个插件的 信息,其代码如下: CKPluginInfo* CKGetPluginInfo(int Index) { switch (Index) { case 0: g_PluginInfo[Index].m_Author = "梦幻的魔法之门"; g_PluginInfo[Index].m_Description = "使用ADO访问ACCESS数据库"; g_PluginInfo[Index].m_Extension = ""; g_PluginInfo[Index].m_Type = CKPLUGIN_MANAGER_DLL; g_PluginInfo[Index].m_Version = 0x00010000; g_PluginInfo[Index].m_InitInstanceFct = InitInstance; g_PluginInfo[Index].m_ExitInstanceFct = ExitInstance; g_PluginInfo[Index].m_GUID = wow_ADOACCESSGUID; g_PluginInfo[Index].m_Summary = "Enter your summary here"; break; case 1: g_PluginInfo[Index].m_Author = "梦幻的魔法之门"; g_PluginInfo[Index].m_Description = "调用ACCESS管理器的BBs"; g_PluginInfo[Index].m_Extension = ""; g_PluginInfo[Index].m_Type = CKPLUGIN_BEHAVIOR_DLL; g_PluginInfo[Index].m_Version = 0x00010000; g_PluginInfo[Index].m_InitInstanceFct = NULL; g_PluginInfo[Index].m_ExitInstanceFct = NULL; g_PluginInfo[Index].m_GUID = CKGUID(0xd213eaaf, 0xb3e2bbac); g_PluginInfo[Index].m_Summary = "Enter your summary here"; break; } return &g_PluginInfo[Index]; } 通过对前后代码的对比,可以看出代码使用了switch语句来做区分插件的数量,而插 件 的 类 型 是 则 要 通 过 g_PluginInfo[Index].m_Type 的 值 来 区 分 : ―CKPLUGIN_MANAGER_DLL‖指明是管理器插件、―CKPLUGIN_BEHAVIOR_DLL‖ 指明 - 436 - 是 一 个 普 通 的 BB 插 件 。 由于两个插件的全局唯一标示符是不能相同的,所有 g_PluginInfo[Index].m_GUID的赋值要注意;由于普通插件的BB大多数不需要在启动时初 始化和在Virtools关闭时做一些特殊处理,所有g_PluginInfo[Index].m_InitInstanceFct和 g_PluginInfo[Index].m_ExitInstanceFct为NULL。 到这里Virtools 引擎知道了插件数量和类型的信息,接下来就把 ―ADOACCESSWrite.cpp‖和―ADOACCESSClear.cpp‖叫入到这个工程文件中,这两个文件 是我们前面的例子代码中已经编写好的。到了这一步不要以为就完成了,要注意普通的BB 只有在向Virtools注册自己的行为信息后,引擎才能知道你的具体实现方法,所有要在 ―ADOACCESSManager.cpp‖文件末尾添加如下的代码: void RegisterBehaviorDeclarations(XObjectDeclarationArray *reg) { RegisterBehavior(reg, FillBehaviorwow_ADOACCESSWriteDecl); RegisterBehavior(reg, FillBehaviorwow_ADOACCESSClearDecl); } 上面的函数是新增加的,默认的管理器向导是不会生成这个函数。这些函数都在第19.2 的章节中介绍过。就剩最后一步了,就是打开―ADOACCESSManager.def‖文件,在末尾添 加一行: RegisterBehaviorDeclarations 之所以添加这一行,是因为RegisterBehaviorDeclarations这个函数是我们手动添加进 去的,而Virtools在分析dll的时候要通过def文件才能知道哪些函数是BB行为的注册信息。 现在已经全部完成修改了,马上编译生成新的dll吧,再测试前一定要记得把原来的 ―ADOACCESSClear.dll‖和―ADOACCESSWrite.dll‖这两个BB删除掉。因为我们新的dll已经 包含这两个dll中的bb了,否则就会出错。 管理器的开发内容就说到这里,有些读者感觉到对数据库功能的扩展还有很多没有涉 及,其他的一些内容就要靠读者自己去扩展了,毕竟我们的内容时讲如何使用管理器,而不 是讲ADO访问ACCESS。 21.3 为 VSL 绑定新类型 21.3.1 绑定新类型的规则 我们运用SDK开发新的BB,然后在Virtools脚本中运用;同样的我们能运用SDK绑定新 的自定义参数或者―OP‖的新操作,然后也能在Virtools中运用;这一章讲解如何把自己在SDK 编写的C/C++代码,到了VSL中也能使用。但是有一点要提醒VSL不是C/C++,只是类似! 毕竟不是!所以不要期望所以的C++代码都能在VSL运行。 把C/C++下定义新类型的绑定到VSL中都是通过宏定义来实现,所有宏定义的绑定都 是通过名为VSLManager的这个管理器来实现,所以必须先确保VSLManager已经成功的装 载,然后再进行绑定操作。最好的方法是在创建自定义管理器的时候进行代码或者数据的绑 定。这个管理器就如同前面章节所说的也是从CKBaseManager继承而来,重载函数 OnCKInit( ),在该函数体中填写新类型绑定的宏定义。 绑定的代码必须从宏定义STARTVSLBIND开始,然后以宏定义STOPVSLBIND结束 (简单的打个比方就是小括号的作用)。STARTVSLBIND需要传入一个参数CKContext, 这个参数是全局唯一的,我们就称之为Virtools引擎设备吧。样式如下所示: #include "VSLManagerSDK.h" CKERROR MyManager::OnCKInit() - 437 - { STARTVSLBIND(m_Context) //这里绑定新的宏 STOPVSLBIND return CK_OK; } 其中宏STARTVSLBIND具体定义如下(可以做个了解): #define STARTVSLBIND(context) using namespace VSL; VSLManager *VSLM = (VSLManager *)context->GetManagerByGuid(VSLMANAGER_GUID); if (VSLM) { VSLM->RegisterSpace();} 21.3.2 绑定的细则 宏定义虽然大大方便了我们的开发,但是宏太多了,就要注意理清他们的作用和意义。 为了便于说明,这里定义一个简单的类,它没有实际的意义,只是为了方便我们把问题说明 白而已,类定义如下所示: class MyClass { public : //成员变量 int i; float f; AnotherClass ac; //这表示另外一个类,类也可以作为成员变量 //构造函数和析构函数 MyClass(); MyClass(int a, float f); ~MyClass(); //成员函数 void Nothing(); float SetIAndF(int i, float f); int GetI() const; MyClass operator*(const MyClass &mc); private : //私有的成员不能被绑定到 VSL 中 }; 1. 绑定新类型 绑定新类型是一个基本的功能,也是一个实现起来最繁琐的功能;因为你必须针对不 同的类型来使用不同的机制进行绑定。各种机制如下所示: DECLAREOBJECTTYPE(type) 参数type:结构或者类;使用这个宏来绑定结构或者类。 例如: 在你的SDK中重载管理器基类“CKBaseManager”的初始化函数OnCKInit( )时,即 - 438 - MyManager::OnCKInit(),在函数体中定义: DECLAREOBJECTTYPE(MyClass) 而在VSL脚本中声明2个该类的对象,操作如下所示: MyClass a; // 执行默认的构造函数 MyClass b; // 执行默认的构造函数 a = b; //通过内存copy来实现赋值(memcpy ),因为类中没有重载(=)这个操作符 DECLAREOBJECTTYPEALIAS(type,alias) 参数type:结构或者类 参数alias :在VSL中要操作的结构/类的起个名字(const char *);使用这个宏来在VSL中 以不同的名字绑定不同的结构/类 例如: 在你的SDK中重载管理器基类―CKBaseManager‖的初始化函数OnCKInit( )时,即 MyManager::OnCKInit(),在函数体中定义: DECLAREOBJECTTYPEALIAS (MyClass, "myclass") 而在VSL脚本中定义2个类,操作如下所示: myclass a; // 执行默认的构造函数 myclass b; // 执行默认的构造函数 a = b; //通过内存copy来实现赋值(memcpy ),因为类中没有重载(=)这个操作符 DECLAREPOINTERTYPE(type) 参数type:结构/类名;使用这个宏来绑定结构/类的指针(类的构造函数不能在VSL中被访 问),所以在VSL中就不能实例化这个结构/类 例如: 在你的SDK中重载管理器基类―CKBaseManager‖的初始化函数OnCKInit( )时,即 MyManager::OnCKInit(),在函数体中定义: DECLAREPOINTERTYPE (MyClass); 而在VSL脚本中声明2个该类的对象,操作如下所示: MyClass a; // 没有默认的构造函数 MyClass b; // 没有默认的构造函数。 a = b; //作用只是一个DWORD复制(可以通过这个指针来访问这个对象) DECLAREPOINTERTYPEALIAS(type,alias) 参数type:结构或者类 参数alias :在VSL中要操作的结构/类的起个名字(const char *);使用这个宏来在VSL中 以不同的名字绑定不同的结构/类;使用这个宏来绑定指向结构/类的指针时(构造函数不能 在VSL中被调用),所以这种宏定义的结果是VSL不能实例化这个结构/类。 例如: 在你的SDK中重载管理器基类―CKBaseManager‖的初始化函数OnCKInit( )时,即 MyManager::OnCKInit(),在函数体中定义: DECLAREPOINTERTYPEALIAS(MyClass, "myclass") 而在VSL脚本中声明2个该类的对象,操作如下所示: myclass a; // 没有默认的构造函数。 - 439 - myclass b; // 没有默认的构造函数时。 a = b; // 作用只是一个DWORD复制(可以通过这个指针来访问这个对象) 2. 绑定函数 DECLAREFUN_%T_%P(returnType,functionName, [paramDeclList] ) T: 函数调用约定,C表示CDECL S表示STDCALL。 P:参数的数量(0到6) 参数returnType :函数返回值类型 参数functionName : 函数的名(它不是一个字符串) 参数paramDeclList :函数参数列表 例如: C函数声明:void div(int iNumer, int iDenom, int& oQuot, int& oRemain); 在你的SDK中重载管理器基类―CKBaseManager‖的初始化函数OnCKInit( )时,即 MyManager::OnCKInit(),在函数体中定义: DECLAREFUN_C_4(void, div, int iNumer, int iDenom, int& oQuot, int& oRemain); 而在VSL脚本调用操作如下所示: int quot; int remain; div(5, 2, quot, remain); 你可以绑定这个函数的别名,这时就必须使用另外一个宏DECLAREFUNALIAS 替代 DECLAREFUN。 DECLAREFUNALIAS_%T_%P(alias,returnType,functionName, [paramDeclList] ) T: 函数调用约定,C表示CDECL S表示STDCALL。 P:参数的数量(0到6) 参数alias :函数的别名(const char *) 参数returnType 函数返回值类型 参数functionName : 函数的名(它不是一个字符串) 参数paramDeclList :函数参数列表 在 SDK 中 重 载 管 理 器 基 类 ―CKBaseManager‖的初始化函数OnCKInit( ) 时,即 MyManager::OnCKInit(),在函数体中定义: DECLAREFUNALIAS_C_4("cDiv", void, div, int iNumer, int iDenom, int& oQuot, int& oRemain) 3. 绑定友元函数 DECLAREFRIEND_%P(returnType,functionName, [paramDeclList] ) P:参数的数量(0到6)。 . 参数returnType 函数返回值类型 参数functionName : 函数的名(它不是一个字符串) 参数paramDeclList :函数参数列表 4. 绑定友元操作符 DECLAREFRIENDOP_%P(returnType,operatorName, [paramDeclList] ) P:参数的数量(0到6)。 . - 440 - 参数returnType 函数返回值类型 参数functionName : 函数的名(它不是一个字符串) 参数paramDeclList :函数参数列表 注意操作符的名字必须是―operator‖+ 例如: C函数声明:friend const VxVector operator+ (const VxVector& v); 在 SDK 中重载管理器基类―CKBaseManager‖的初始化函数 OnCKInit( )时,即 MyManager::OnCKInit(),在函数体中定义: DECLAREFRIENDOP_1(const VxVector,operator+,const VxVector& v); 5. 绑定构造函数 要想绑定一个构造函数,你必须定义一个访问这个构造函数的函数(就像是用一个变 量地址做为参数) 在绑定结构/类的构造函数之前,这个结构/类必须已经用宏DECLAREOBJECTTYPE 和 DECLAREOBJECTTYPEALIAS锁定义。 这个构造函数的绑定函数的名字必须与字符串―__new‖关联起来("new" 之前还有两个 下划线字符)。并且这个类/结构的C++名字已经被声明过(不能是别名) 例如,构造函数如下所示: void __newMyClass(BYTE *iAdd) { new (iAdd) MyClass(); } And: void __newMyClass(BYTE *iAdd, int a, float f) { new (iAdd) MyClass(int a, float f); } 然后你必须使用宏―DECLARECTOR_‖来绑定这个函数已经这个构造函数的参数。宏的 第一个参数必须是函数的名称。假如构造函数使用了参数,那么你也必须对它们进行声明(按 照参数的顺序来声明)。 在 SDK 中 重 载 管 理 器 基 类 ―CKBaseManager‖的初始化函数OnCKInit( ) 时,即 MyManager::OnCKInit(),在函数体中定义: DECLARECTOR_0(__newMyClass) DECLARECTOR_2(__newMyClass, int i, int f) 6. 绑定析构函数 前面的内容是在绑定一个类/结构的构造函数前,这个类/结构必须依据使用宏 DECLAREOBJECTTYPE 或者DECLAREOBJECTTYPEALIAS声明过。 绑定析构函数的操作于此很相似,首先要定义一个函数。这个函数通过宏 DECLAREDESTRUCTORFUNCTION 来调用析构函数。宏使用的参数是这个类或者结构 的名字。 这个析构函数的绑定函数的名字必须与字符串―__dest‖关联起来("dest" 之前还有两 个下划线字符)。并且这个类/结构的C++名字已经被声明过(不能是别名) 例如: - 441 - 析构函数宏定义:DECLAREDESTRUCTORFUNCTION(MyClass) 在你的SDK中重载管理器基类―CKBaseManager‖的初始化函数OnCKInit( )时,即 MyManager::OnCKInit(),在函数体中定义: DECLAREDESTRUCTOR(__destMyClass) 7. 绑定成员函数 DECLAREMETHOD[C]_%P(class,returnType,functionName, [paramDeclList] ) C:如果成员函数被const所修饰就加C到宏中 P:参数个数0到8 参数class :成员函数属哪个类的 参数returnType : 函数返回类型 参数functionName : 函数名 (注意不是字符串是函数名不是函数的名字). 参数paramDeclList : 参数列表的声明. 例如: 在你的SDK中重载管理器基类―CKBaseManager‖的初始化函数OnCKInit( )时,即 MyManager::OnCKInit(),在函数体中定义: DECLAREMETHOD_0(MyClass,void,Nothing) DECLAREMETHOD_2(MyClass,float,SetIAndF, int i, int f) DECLAREMETHODC_0(MyClass,int,GetI, int i, int f) 注意到GetI函数我们使用的宏是DECLAREMETHODC ,因为GetI 是被const所修饰。 8. 绑定成员操作符 DECLAREOP[C]_%P(class,returnType,operatorName, [paramDeclList] ) C:如果成员函数被const所修饰就加C到宏中 P:参数个数0到8 参数class :成员函数属哪个类的 参数returnType : 函数返回类型 参数functionName : 函数名 (注意不是字符串是函数名不是函数的名字). 参数paramDeclList : 参数列表的声明. 注意操作符的名字必须是―operator‖+ NOTE: operator name must be : "operator" plus the operator. In our MyManager::OnCKInit() DECLAREOP_1(MyClass,MyClass,operator*,const MyClass &mc) 其实为VSL绑定新类型的方法很少用,上面这7种情况作者也几乎没有使用过,除了介 绍的绑定方法外,还有:绑定成员操作符、绑定静态成员函数、绑定成员变量、绑定枚举 类型、绑定常量等。详细可以参考SDK帮助文档“Binding New C/C++ Functionality to use in VSL”的相关内容。 21.3.3 绑定的简单范例 讲了那么多,不弄个例子来试试,是不能掌握的。按照上一节的讲的方法,使用向导 创建一个管理器项目,命名为:“CustomVSL”,到了图 21.2.1_2 所示步骤时,将管理器插 件名字命名为:“wow_CustomVSLManager”,勾选 OnCKInit 项。然后按照步骤一步一步 完成后,打开 wow_CustomVSLManager.CPP,在文件头部定义一个类,类的内容如下所 - 442 - 示: class NPC { public: NPC() { name="NULL"; blood=100.0; attack=18.0; defense=5; alive=FALSE; } BOOL GetNPCalive(); float GetNPCattack(); private: XString name; float blood; float attack; float defense; BOOL alive; }; BOOL NPC::GetNPCalive() { return alive; } float NPC::GetNPCattack() { return attack; } 这个类有 4 个私有变量,一个构造函数和 2 个成员函数,作用很简单没有实际的用途, 仅仅是用于测试。有一点要提醒读者:如果涉及的布尔类型的时候,尽量使用 BOOL 而不 是 bool,否则会得到意外的错误结果。而编译时却没有错误。定义好了类,但 VSL 还不能 识别它,按照上一小节描述,我们要在 OnCKInit 函数中声明这个类的相关操作,具体代 码如下所示: CKERROR wow_CustomVSLManager::OnCKInit() { STARTVSLBIND(m_Context) //必须从宏定义STARTVSLBIND开始 DECLAREOBJECTTYPE(NPC); //绑定新类型 DECLARECTOR_0(__newNPC); //绑定类的构造函数 DECLAREMETHOD_0(NPC,BOOL,GetNPCalive); //绑定类的成员函数 DECLAREMETHOD_0(NPC,float,GetNPCattack); //绑定类的成员函数 STOPVSLBIND //最后以宏定义STOPVSLBIND结束 return CKERR_NOTIMPLEMENTED; } - 443 - 其中类的构造函数也要进行声明,不然 VSL 在构建一个类的实例(即:创建这个类的 一个对象)的时候,找不到构造函数,导致初始化实例时,结果不是预期的。代码如下所 示: void __newNPC(BYTE *iAdd) { new (iAdd) NPC(); } 整个过程就这么简单,编译生成 dll 后,将 dll 放到指定目录,在 Virtools 中,打开 VSL 编辑器,输入如下代码做个验证: void main() { NPC a; bool alive; alive=a.GetNPCalive(); if(alive) bc.OutputToConsole("alive"); else bc.OutputToConsole("Death"); float atk=a.GetNPCattack(); bc.OutputToConsole(atk); } 运行,输出结果为:Death、18.000000。在VSL中绑定自定义的新类型成功。详细参看 随书附带光盘“CustomVSL为VSL绑定新类型_第二十一章21.3”目录 21.4 第三方声音引擎与 Virtools 的整合 21.4.1 为什么要整合第三方声音引擎 Virtools自身就能处理声音的功能模块,但每样工具都不是万能的,都有它的缺陷,比 如5.1声道、7.1声道就不能很好支持,有些项目甚至要求支持6个喇叭播放不同的声音, virtools的原有的声音模块就不给力了。 FMOD声音引擎简单通用且跨平台,只要不是用于商业用途就可以免费使用FMOD, 现在一些知名的网络游戏都在使用该声音引擎,如《魔兽世界》、《孤岛危机》等。FMOD 支持的声音文件类型要比Virtools多很多,FMOD不公开源码,但提供SDK,这个SDK简单 易用,并且附带了很多实例,这点和Virtools一样做得很好,作为学习参照实例很重要。FMOD 的特点有:虚拟频道(Virtual Channels)、数字CD回放(Digital CD Playback)、同时支持多 个声卡、支持多路输出(Multi-speaker output)、增强的网络特性。FMOD的缺点就是:如果 项目不是免费的就要交版费。要想下载和了解更多关于FMOD的信息请读者查看其官方网站: http://www.fmod.org/ 21.4.2 第三方声音引擎使用介绍 FMOD虽然简单易用,但要了解和掌握它的每一个功能是很需要篇幅去讲解的,本节 只是简单的讲解如何播放一个声音。要想播放一个声音,首先要有一个声音设备,就好像去 卡拉ok唱歌,要拿着麦克风。FMOD的声音播放设备类型是:FMOD::System。创建的语句 是: - 444 - FMOD::System *system; //定义一个声音设备指针 FMOD::System_Create(&system); //创建一个声音设备 system->init(32, FMOD_INIT_NORMAL, 0); //初始化声音设备 初始化函数init有3个参数,第一个参数是表示虚拟通道数,填写能同时播放多少声音 的数目(也就是通道数),游戏中同时播放相同或不同的声音是常有的事情,比如说机关枪 的枪声、弹壳掉地的声音,打中铁板的声音等;第二个参数是表示这个设备应用配置,比如 说是不是以流的方式播放声音,还是装载到内存后播放,是3D立体环境声、还是常规播放, 是应用于PC平台、还是PS平台等;第三个参数通常设置为0,如果不是开发一些专门的音 频软件一般很少设置它。声音设备已经准备OK,剩下的自然就是装载一个声音并播放它。实 现的语句如下所示: FMOD::Sound *sound1 = 0; //定义一个声音的指针 FMOD::Channel *channel = 0; //定义一个通道的指针 //创建一个声音 system->createSound("../media/drumloop.wav", FMOD_HARDWARE, 0, &sound1); sound1->setMode(FMOD_LOOP_OFF); //设置声音是否循环播放 //播放一个声音 system->playSound(FMOD_CHANNEL_FREE, sound1, false, &channel); 创建一个播放的声音函数是createSound,它有4个参数第一个参数是装载声音的路径; 第二个参数是播放的方式,比如说是软件播放、还是硬件播放;第三个参数也是播放方式的 配置,让用户有更多的方式来播放这一个声音,通常设置0;第四个参数是这个声音的指针。 播放声音playSound函数也有4个参数,她的第一个参数是声音播放的通道,通常设置 为FMOD_CHANNEL_FREE,意思是让FMOD自己去设置播放的通道,第二个参数是装载 了声音的指针,第三个参数是声音播放时是否可以被暂停,第4个参数是声音的播放通道。 当应用程序退出时,还需要释放之前创建的资源,实现的语句如下所示: sound1->release(); //释放装载的声音文件 system->close(); //声音设备关闭 system->release(); //是否创建的声音设备资源 可以看出没有释放声音通道Channel这个资源,因为FMOD的通道都是虚拟通道,受到 声音设备System的管理,另外代码中也没有“Create”通道的地方,所有不需要另外的地 方释放这个指针,System会负责它,但前提是正确调用System的释放语句。这一节作者不 打算做实例,有兴趣的读者可以自己尝试。 - 445 - 21.4.3 不同喇叭播放不同声音 图21.4.3_1 FMOD播放一个声音很简单,但Virtools也可以简单的做到这点,所以使用本节讲解 Virtools做不到的一点:支持7.1声道,8个喇叭同时播放互不干扰的8种不同的声音。所谓7.1 声道就是:最新的高保真音频规范,支持四个环绕音箱,两个主音箱,一个中置音箱和一个 低音音箱的音频输出,能给家庭影院带来难以想象的震撼效果。为什么是8个喇叭:四个环绕 音箱 + 两个主音箱 + 一个中置音箱 + 一个低音音箱 = 8个喇叭(所谓音箱就是用个盒子 把喇叭包起来,木盒是最好的)。 当前市面上的电脑主板大多数都支持5.1、7.1声道,如果正确安装了声卡驱动,在控制 面板选择Realtek音频管理器(不同的主板、不同的声卡,不同的声卡驱动可能不一样),然 后选择5.1或者7.1声道,才能开启特殊的声音设置,默认的是立体声,如图21.4.3_1所示(不 同的电脑,设置画面不一样)。另外一个必要条件是需要一套支持5.1声道、7.1声道的功放, 如果没有使用3、4副耳塞也行。 理解了声道和喇叭的关系之后,就来开发一个通用的BB,不管有几个喇叭(目前最多 也就8个)都能在不同的喇叭播放不同的声音,不管是立体声、还是5.1声道、或是7.1声道, 对BB使用来说没有区别,所以这个BB要有8个参数来控制这些通道是否播放声音。一个声 音播放要经过装载声音、卸载声音、播放声音和停止播放4个过程,所以BB起码要有4个输 入端口来触发。无论何时播放都需要获得声音设备,所以就要保存声音播放设备和声音指针 到BB的具备参数中,以便运行时程序找回这些设备指针。用向导新建一个BB工程项目名为 “MultiSpeakerOutput”,其中输入输出端口、参数声明代码如下所示: //输入端口声明 proto->DeclareInput("Load"); //装载端口 - 446 - proto->DeclareInput("Play"); //播放端口 proto->DeclareInput("Stop"); //停止播放端口 proto->DeclareInput("UnLoad"); //卸载端口 //输出端口声明 proto->DeclareOutput("End Load"); //装载完成输出端口 proto->DeclareOutput("Start Playing"); //播放成功输出端口 proto->DeclareOutput("End Playing"); //停止播放成功输出端口 proto->DeclareOutput("End UnLoad"); //卸载成功输出端口 proto->Declar eOutput("Error"); //出错时,端口输出 //输入参数声明 proto->DeclareInParameter("SoundPath", CKPGUID_STRING); //声音路径 proto->DeclareInParameter("LoopEnable", CKPGUID_BOOL); //是否循环 proto->DeclareInParameter("frontleft", CKPGUID_FLOAT,"0.0"); //左前置喇叭 proto->DeclareInParameter("frontright", CKPGUID_FLOAT,"0.0"); //由前置喇叭 proto->DeclareInParameter("center", CKPGUID_FLOAT,"0.0"); //中置喇叭 proto->DeclareInParameter("lfe", CKPGUID_FLOAT,"0.0"); //低音喇叭 proto->DeclareInParameter("backleft", CKPGUID_FLOAT,"0.0"); //左后置喇叭 proto->DeclareInParameter("backright", CKPGUID_FLOAT,"0.0"); //右后置喇叭 proto->DeclareInParameter("sideleft", CKPGUID_FLOAT,"0.0"); //左侧翼喇叭 proto->DeclareInParameter("sideright", CKPGUID_FLOAT,"0.0"); //右侧翼喇叭 proto->DeclareInParameter("Volume", CKPGUID_FLOAT,"1.0"); //音量 //输出参数声明 proto->DeclareOutParameter("SpeakerMode", CKPGUID_STRING); //喇叭模式 proto->DeclareOutParameter("OutInfo", CKPGUID_STRING); //BB状态输出 //局部参数声明 proto->DeclareLocalParameter("FMODSystem", CKPGUID_POINTER,NULL); //设备 proto->DeclareLocalParameter("FOMDSound",CKPGUID_POINTER,NULL); //声音 proto->DeclareLocalParameter("FOMDChannel",CKPGUID_POINTER,NULL);//通道 如果是 5.1 声道,那么侧翼喇叭的两个输入参数就无效;如果是立体声,除了侧翼喇 叭外、中置喇叭和后置喇叭都无效;这只是按最多的使用数量来设置输入参数的多少。具 体实现代码如下所示: int wow_MultiSpeakerOutput(const CKBehaviorContext& BehContext) { CKBehavior *beh=BehContext.Behavior; CKContext *ctx=BehContext.Context; //关闭所有输出端口 beh->ActivateOutput(0,FALSE); beh->ActivateOutput(1,FALSE); beh->ActivateOutput(2,FALSE); beh->ActivateOutput(3,FALSE); beh->ActivateOutput(4,FALSE); //FMOD播放变量 FMOD::System *system=0; //FMOD系统设备 FMOD::Sound *sound1=0; //FMOD声音 - 447 - FMOD::Channel *channel = 0;//声音播放的通道 FMOD_SPEAKERMODE speakermode;//播放声音的模式 FMOD_RESULT result; //FMOD返回值 unsigned int version; //FMOD的版本号 XString outinfo; //BB的输出参数变量内容字符串 //创建FMOD声音播放设备,装载声音资源。 if (beh->IsInputActive(0)) { beh->ActivateInput(0,FALSE);//BB被触发后,关闭该端口,以防一帧内被连续触发 //这个BB之前是否已经创建了FMOD声音设备 beh->GetLocalParameterValue(0,&system); if(system!=NULL)//如果是,就退出。 { outinfo="wow_MultiSpeakerOutput已经被创建,释放前,不能再创建。"; beh->SetOutputParameterValue(0,outinfo.Str(),outinfo.Length()+1); beh->ActivateOutput(4,TRUE); ctx->OutputToConsole(outinfo.Str()); return CKBR_OK; } //如果没有,就创建一个FMOD声音设备。 result = FMOD::System_Create(&system);//创建一个FMOD声音设备 if(result!=FMOD_OK) { outinfo="wow_MultiSpeakerOutput的FMOD声音系统创建时错误!"; beh->SetOutputParameterValue(0,outinfo.Str(),outinfo.Length()+1); beh->ActivateOutput(4,TRUE); ctx->OutputToConsole(outinfo.Str()); return CKBR_OK; } system->getVersion(&version);//得到FMOD版本号 if (version < FMOD_VERSION) { outinfo="你使用的FMOD版本过旧!"; beh->SetOutputParameterValue(0,outinfo.Str(),outinfo.Length()+1); beh->ActivateOutput(4,TRUE); ctx->OutputToConsole(outinfo.Str()); return CKBR_OK; } //设置声音输出格式的软件混合器 result = system->setSoftwareFormat(44100, FMOD_SOUND_FORMAT_PCM16, 0, 0, FMOD_DSP_RESAMPLER_LINEAR); //返回声音设备能力 result = system->getDriverCaps(0, 0, 0, 0, &speakermode); result = system->setSpeakerMode(speakermode);//设置扬声器播放模式 - 448 - result = system->init(32, FMOD_INIT_NORMAL, 0);//初始化FMOD声音系统 if(result!=FMOD_OK) { outinfo="wow_MultiSpeakerOutput的FMOD声音系统初始化时错误"; beh->SetOutputParameterValue(0,outinfo.Str(),outinfo.Length()+1); beh->ActivateOutput(1,TRUE); return CKBR_OK; } //扬声器播放模式 switch (speakermode) { case FMOD_SPEAKERMODE_MONO : { outinfo="系统扬声器模式模式:单声道。"; break; } case FMOD_SPEAKERMODE_STEREO : { outinfo="系统扬声器模式模式: 立体声。"; break; } case FMOD_SPEAKERMODE_QUAD : { outinfo="系统扬声器模式模式: 四通道。"; break; } case FMOD_SPEAKERMODE_SURROUND : { outinfo="系统扬声器模式模式: 环绕立体声。"; break; } case FMOD_SPEAKERMODE_5POINT1 : { outinfo="系统扬声器模式模式: 5.1环绕。"; break; } case FMOD_SPEAKERMODE_7POINT1 : { outinfo="系统扬声器模式模式: 7.1环绕。"; break; } } beh->SetOutputParameterValue(0,outinfo.Str(),outinfo.Length()+1); beh->SetLocalParameterValue(0,&system,sizeof(FMOD::System *)); - 449 - //装载一个声音文件 CKSTRING mysong=(CKSTRING)beh->GetInputParameterReadDataPtr(0); result = system->createSound(mysong, FMOD_SOFTWARE | FMOD_2D, 0, &sound1); if(result==FMOD_OK) { outinfo="wow_MultiSpeakerOutput转载声音成功"; beh->SetOutputParameterValue(1,outinfo.Str(),outinfo.Length()+1); beh->SetLocalParameterValue(1,&sound1,sizeof(FMOD::Sound *)); beh->ActivateOutput(0,TRUE); return CKBR_OK; } else { outinfo="wow_MultiSpeakerOutput转载声音失败"; beh->SetOutputParameterValue(1,outinfo.Str(),outinfo.Length()+1); beh->ActivateOutput(4,TRUE); return CKBR_PARAMETERERROR; } } //播放声音 if(beh->IsInputActive(1)) { beh->ActivateInput(1,FALSE); beh->GetLocalParameterValue(0,&system); beh->GetLocalParameterValue(1,&sound1); if(sound1==NULL||system==NULL) { outinfo="wow_MultiSpeakerOutput声音播放失败a"; beh->SetOutputParameterValue(1,outinfo.Str(),outinfo.Length()+1); beh->ActivateOutput(4,TRUE); return CKBR_PARAMETERERROR; } else { BOOL loopenalbe=FALSE; beh->GetInputParameterValue(1,&loopenalbe); if(loopenalbe)//设置声音时候是否选好播放 { sound1->setMode(FMOD_LOOP_NORMAL); } else { sound1->setMode(FMOD_LOOP_OFF); - 450 - } //播放声音按给定的通道 result = system->playSound(FMOD_CHANNEL_FREE, sound1, true, &channel); if(result==FMOD_OK) { outinfo="wow_MultiSpeakerOutput播放声音成功"; beh->SetOutputParameterValue(1,outinfo.Str(),outinfo.Length()+1); beh->SetLocalParameterValue(2,&channel,sizeof(FMOD::Channel *)); float rontleft,frontright,center,lfe,backleftl float backright,sideleft,sideright,Volume; beh->GetInputParameterValue(2,&frontleft); beh->GetInputParameterValue(3,&frontright); beh->GetInputParameterValue(4,¢er); beh->GetInputParameterValue(5,&lfe); beh->GetInputParameterValue(6,&backleft); beh->GetInputParameterValue(7,&backright); beh->GetInputParameterValue(8,&sideleft); beh->GetInputParameterValue(9,&sideright); beh->GetInputParameterValue(10,&Volume); //设置声音播放的通道 result = channel->setSpeakerMix(frontleft,frontright,center, lfe, backleft, backright, sideleft, sideright); result = channel->setPaused(false);//不可暂停 channel->setVolume(Volume);//设置声音音量 system->update(); beh->ActivateOutput(1,TRUE); return CKBR_OK; } else { outinfo="wow_MultiSpeakerOutput播放声音失败"; beh->SetOutputParameterValue(1,outinfo.Str(),outinfo.Length()+1); beh->ActivateOutput(4,TRUE); return CKBR_OK; } } } //停止声音播放 if (beh->IsInputActive(2)) { beh->ActivateInput(2,FALSE); beh->GetLocalParameterValue(1,&sound1); beh->GetLocalParameterValue(2,&channel); if(sound1==NULL||channel==NULL) - 451 - { outinfo="wow_MultiSpeakerOutput声音停止播放失败"; beh->SetOutputParameterValue(1,outinfo.Str(),outinfo.Length()+1); beh->ActivateOutput(4,TRUE); return CKBR_PARAMETERERROR; } else { result=channel->stop();//停止播放声音 beh->ActivateOutput(2,TRUE); if(result==FMOD_OK) { outinfo="wow_MultiSpeakerOutput停止播放成功"; beh->SetOutputParameterValue(1,outinfo.Str(),outinfo.Length()+1); } return CKBR_OK; } } //卸载声音 if(beh->IsInputActive(3)) { beh->ActivateInput(3,FALSE); beh->GetLocalParameterValue(0,&system); beh->GetLocalParameterValue(1,&sound1); if(sound1==NULL||system==NULL) { outinfo="wow_MultiSpeakerOutput声音卸载失败"; beh->SetOutputParameterValue(1,outinfo.Str(),outinfo.Length()+1); beh->ActivateOutput(4,TRUE); return CKBR_PARAMETERERROR; } else { if(sound1!=NULL) { result = sound1->release(); //卸载声音 sound1=NULL; ctx->OutputToConsole("sound1=NULL"); beh->SetLocalParameterValue(1,&sound1); } if(system!=NULL)//释放FMOD声音设备 { result = system->close(); result = system->release(); - 452 - system=NULL; ctx->OutputToConsole("system=NULL"); beh->SetLocalParameterValue(0,&system); } beh->ActivateOutput(3,TRUE); } } return CKBR_OK; } 如果理解了上一小节的几个函数的内容,这一节的代码虽然长,但是也就那几个函数 是核心而已,其他的函数都是为了这几个函数做铺垫的,以方便安全的使用,唯一特别的是 下面这条语句: channel->setSpeakerMix(frontleft,frontright,center, lfe, backleft, backright, sideleft, sideright); 这条语句就是设置不同的喇叭播放不同的声音,也可以个别喇叭播放同一个声音,另外几个 喇叭播放另一个声音,参数正好8个,参数值为1表示这个喇叭播放,反之为0这个喇叭就不 播放。如果这样就认为这个BB已经完成了,那就错了。要知道Virtools有个特点就是复位的 时候,不是退出程序,很多资源不会释放,所以复位后在运行,这个BB就会出现问题,前面 的资源没卸载,又要开始装载,导致内存的泄漏。安全的方法就是在该BB的回调函数复位项 添加释放资源的代码,如下所示: int wow_MultiSpeakerOutputCallBack(const CKBehaviorContext& BehContext) { FMOD::System *system=0; FMOD::Sound *sound1=0; FMOD_RESULT result; CKBehavior *beh=BehContext.Behavior; CKContext *ctx=BehContext.Context; switch (BehContext.CallbackMessage) { case CKM_BEHAVIORRESET: beh->GetLocalParameterValue(0,&system); beh->GetLocalParameterValue(1,&sound1); if(sound1!=NULL) { result = sound1->release(); sound1=NULL; ctx->OutputToConsole("sound1=NULL"); beh->SetLocalParameterValue(1,&sound1); } if(system!=NULL) { result = system->close(); result = system->release(); system=NULL; - 453 - ctx->OutputToConsole("system=NULL"); beh->SetLocalParameterValue(0,&system); } break; } return CKBR_OK; } 这个BB编码到此就算开发完了?没有!Virtools还有一个特性就是:脚本是可以在运行 时被删除的,删除了BB,但BB创建FMOD相关资源是不会被删除掉的,所以还要在BB回调 函数中添加处理,至于怎么处理,就留给读者动动脑筋吧。如果想成功编译生成dll,还差 两步。第一步需要设置这个工程项目的“附件依赖项”,如图21.4.3_2所示;第二步需要设置 FMOD的头文件路径和库文件路径,如图21.4.3_3和图21.4.3_4所示。读者可以参照附书实例 “MultisPeakerOutput多个喇叭播放多个声音_第二十一章21.4”,读者有兴趣可以再细致研究 FMOD,可以在声音方面使得Virtools具有出色的功能。 图21.4.3_2 图21.4.3_3 - 454 - 图21.4.3_4 21.5 网络引擎与 Virtools 的整合 21.5.1 为什么要整合网络引擎 Virtools自身就带有网络引擎功能,但是这个功能是需要另外付费才能买到,对于那些 欠发达地区的公司,多数不愿再承受这样的一个开销;另外一些项目的复杂性要求必须拥有 自己的网络解决方案才行,所以在Virtools上扩展自己的网络模块就变得很有必要了。如果 只是在局域网中联机对战,那么Virtools附带的BB就能很好的很漂亮的完成工作,但是不要 使用这些网络通信BB,除非已经购买到了网络通信的许可;否则发布成exe应用程序是运行 不了的,会提示许可证的问题。但是对于这个问题有一个取巧的办法,就是找一个市面上用 Virtools开发的游戏,并且该游戏支持局域网对战,并且该游戏已经获得许可证,然后把该 游戏的网络通信BB对应的dll,复制到自己的项目中就行了。但这不是本节的重点,本节的 重点是讲如何在Virtools中使用第三方网络引擎。 Raknet是一个基于UDP网络传输协议的C++网络库,允许程序员在他们自己的程序中 实现高效的网络传输服务,最新版本Raknet已经支持C#,该网络引擎是针对游戏程序设计 的,当然也可以适应于其他应用程序。Raknet支持当前市面上主流的操作平台,其中包括: PC、PlayStation 3、 XBOX 360、 PlayStation Vita、 Linux、 Mac、 the iPhone、and the gPhone。Radnet的高性能让人赞叹,在同一台计算机上,可以实现在两个程序之间每秒传 输25,000条信息;RakNet在安全的传输方面自动使用SHA1, AES128, SYN,用RSA避免 传输受到攻击;RakNet还支持语音传送,也可以执行远程控制;另外RakNet在网络上的论 坛非常活跃,教程非常丰富,并且有大量实例方便学习。该网络引擎的优点还有很多,详细 大家可以在互联网上搜索。 RakNet支持两种版权,如果你是用作个人项目,RakNet将是免费的。如果用在商业用 途,需要购买商业授权。通过其官方网站:http://www.jenkinssoftware.com/可以获得该网 络引擎的对应源代码。 21.5.2 使用网络引擎创建一个服务器 要使用网络引擎必须对网络通信有一定的基础认识,否则理解这一小节就会比较困难, 网络通信不是本书的重点,所以不会述说这方面的知识,只是针对网络引擎的应用来解说。 本例使用的网络引擎版本是RakNet-3.6201,不同的版本在一些开发设置上稍有不同。通常 项目要求服务器不会是运行在Virtools上,甚至服务器程序都不会在windows系统上运行, 开发的专业程度比客户端的高,所以这一小节只是演示如何开发一个简单的聊天系统。 这个简单的聊天系统分两个应用程序,一个是服务器、一个是客户端。服务器是以控 制台的方式运行,运行时只启动一个服务器程序;客户端是在Virtools上运行,可以启动多 - 455 - 个客户端应用程序。一个简单的聊天系统服务器也必须有创建、绑定、监听、运行链接、收 发过程和关闭结束这些过程。这些繁琐的编码过程都被RakNet封装在几个类中,这些类被 包含在几个重要的头文件中。其中RakNetworkFactory.h是管理程序中使用的类,包括类内 存分配和类内存的释放。RakPeerInterface.h用于建立服务器、客户端所需用的信息,包括 服务器的建立,连接和数据的发送和接收等。MessageIdentifiers.h包含网络引擎在运行过 程中的信息定义。RakNetStatistics.h包含网络引擎在运行时保存的统计信息以便查询。以 下是简单的聊天系统的服务器端代码,注意这些代码与Virtools无关: #include "MessageIdentifiers.h" //网络引擎在运行过程中的信息定义 #include "RakNetworkFactory.h" //管理程序中使用的类 #include "RakPeerInterface.h" //建立服务器、客户端所需用的信息 #include "RakNetStatistics.h" //用于存储网络运行下的统计信息 #include #include #include #include #ifdef _WIN32 #include "Kbhit.h" #endif #include #include #include "WindowsIncludes.h" // Sleep #include #if defined(_CONSOLE_2) #include "Console2SampleIncludes.h" #endif // 函数前向声明 unsigned char GetPacketIdentifier(Packet *p); // 处理消息的函数 unsigned __stdcall ServerHandleThread( LPVOID arguments ); // 运行线程函数 #ifdef _CONSOLE_2 _CONSOLE_2_SetSystemProcessParams #endif RakPeerInterface *server; // 服务器对象 char message[2048]; // 接收的消息 RakNetStatistics *rss; // 统计信息 Packet* p; // 接收到的包 unsigned char packetIdentifier; // 处理GetPacketIdentifier函数的返回值 char portstring[30]; // 存储用户输入的服务器端口号 bool b; // 服务器启动函数返回值 //记录链接进来的客户端,以便传给ping函数 SystemAddress clientID=UNASSIGNED_SYSTEM_ADDRESS; int main(void) { //创建服务器端 server=RakNetworkFactory::GetRakPeerInterface();//初始化一个网络通信实例,为它 - 456 - 分配内存 puts(" 服务器端口号 33333"); strcpy(portstring, "33333"); //Startup函数功能是:启动网络线程,打开监听端口。 //第一个参数表明你的服务器允许同时连接多少个客户 //第二个参数表明做保留之用,(因连接而需侦听的端口)。 //第三个参数SocketDescriptor为本地套接字描述函数。 //第四个参数通常传入1 SocketDescriptor socketDescriptor(atoi(portstring),0); b = server->Startup(32, 30, &socketDescriptor, 1 ); if (b) puts("服务器成功建立,等待客户端连接."); else { puts("服务器建立失败"); exit(1); } server->SetIncomingPassword("VirtoolsCustomNet", //设置访问密码 (int)strlen("VirtoolsCustomNet")); server->SetOccasionalPing(true); // 设置ping自动进行 //用于保存客户端socket队列 DataStructures::List > sockets; server->GetSockets(sockets); //服务器获得这个队列 printf("RakNet使用的端口:\n"); //控制台提示信息 for (unsigned int i=0; i < sockets.Size(); i++) { printf("%i. %i\n", i+1, sockets[i]->boundAddress.port); } printf("本机 IP: %s\n", server->GetLocalIP(0)); //显示本地IP信息 printf("本机 GUID is %s\n", //显示应用程序标示信息 server->GetGuidFromSystemAddress(UNASSIGNED_SYSTEM_ADDRESS).ToString()); puts("输入'quit' 退出. 输入'stat'显示状态. 输入'ping' 执行ping.\n输入'ban' to 禁止发 言\n输入'kick 'to 踢掉第一个链接\n输入信息回车进行聊天"); Sleep(1500); unsigned threadId; //线程ID char count[20]="cc"; //传个线程的参数 _beginthreadex( NULL, 0, ServerHandleThread, count, 0, &threadId ); //开线程 while (1) //循环以便程序不会退出 { Sleep(30); } server->Shutdown(300); //网络模块关闭 RakNetworkFactory::DestroyRakPeerInterface(server); //消毁网络模块 return 0; - 457 - } // 分析接收到消息 unsigned char GetPacketIdentifier(Packet *p) { if (p==0) // 如果是个空包,返回255 return 255; if ((unsigned char)p->data[0] == ID_TIMESTAMP) { RakAssert(p->length > sizeof(unsigned char) + sizeof(unsigned long)); return (unsigned char) p->data[sizeof(unsigned char) + sizeof(unsigned long)]; } else return (unsigned char) p->data[0]; } //线程处理函数 unsigned __stdcall ServerHandleThread( LPVOID arguments ) { while (1)//线程中不断循环 { if (kbhit()) { gets(message); // 判断输入的字符是命令,还是聊天内容 if (strcmp(message, "quit")==0) //退出 { puts("quit."); break; } if (strcmp(message, "stat")==0) //显示状态 { rss=server->GetStatistics(server->GetSystemAddressFromIndex(0)); StatisticsToString(rss, message, 2); printf("%s", message); printf("Ping %i\n", server->GetAveragePing(server->GetSystemAddressFromIndex(0))); continue; } if (strcmp(message, "ping")==0) //ping客户端 { server->Ping(clientID); continue; } if (strcmp(message, "kick")==0) //踢掉第一个 { server->CloseConnection(clientID, true, 0); - 458 - continue; } if (strcmp(message, "ban")==0) //禁止发言 { printf("Enter IP to ban. You can use * as a wildcard\n"); gets(message); server->AddToBanList(message); printf("IP %s added to ban list.\n", message); continue; } //服务器广播的消息 char message2[2048]; message2[0]=0; strcpy(message2, "Server: "); strcat(message2, message); server->Send(message2, (const int) strlen(message2)+1, //广播消息 HIGH_PRIORITY, RELIABLE_ORDERED, 0, UNASSIGNED_SYSTEM_ADDRESS, true); } p = server->Receive(); // 接受到消息 if (p==0) continue; // 没有消息包 packetIdentifier = GetPacketIdentifier(p); // 处理一个消息包 switch (packetIdentifier) //检测消息包类型 { case ID_DISCONNECTION_NOTIFICATION: // 链接正常断开 printf("ID_DISCONNECTION_NOTIFICATION from %s\n", p->systemAddress.ToString(true)); break; case ID_NEW_INCOMING_CONNECTION: //有人连入 printf("ID_NEW_INCOMING_CONNECTION from %s with GUID %s\n", p->systemAddress.ToString(true), p->guid.ToString()); clientID=p->systemAddress; // Record the player ID of the client break; case ID_MODIFIED_PACKET: // Cheater! printf("ID_MODIFIED_PACKET\n"); break; case ID_CONNECTION_LOST: // 链接断开 printf("ID_CONNECTION_LOST from %s\n", p->systemAddress.ToString(true));; break; default: //聊天信息回复和打印到控制台 printf("%s\n", p->data); sprintf(message, "%s", p->data); - 459 - server->Send(message, (const int) strlen(message)+1, HIGH_PRIORITY, RELIABLE_ORDERED, 0, p->systemAddress, true); break; } // 处理完一个包后调用这一个函数表示让它生效。 server->DeallocatePacket(p); Sleep(10); } return 1; } RakPeerInterface是一个重要的类,它是RakNet网络通信的"接口"类,它把很多复杂繁 琐的功能简单地封装了起来,比如它封装了服务器和客户端的创建过程,用户只要按步骤轻 松调用这个类的几个函数就能搞定。无论是创建客户端,还是服务器端都是首先要定义一个 这个类的指针,至于创建的是服务器端还是客户端程序要看Startup函数的调用方法。函数 Startup()有4个参数,第一个参数是允许接入的客户端数量,如果传入1,就表示这个是客户 端程序;第二和第四个参数没有特殊的需求的话,保持默认就好了;第三个参数是一个描述 本地套接字的SocketDescriptor结构。本例创建的是服务器程序,当成功调用了这个函数后, 就完成了建立和启动网络线程,绑定和监听端口的所有工作了。 服务器建立起来后,一般情况下要设置访问的密码,否则不能和这个服务器链接,函数 SetIncomingPassword( )完成了这个功能,只要传入两个参数,第一个参数是密码,第二个 参数是这个密码的长度。服务器接收客户端发来的消息之后,要转发给其他客户端,本例专 门开了一个线程来处理消息的收发,开辟线程的方法是C++的知识,这里就不讲解线程的知 识了。 发送消息是由send()函数,它有7个参数:第一个参数,是发送的消息指针,字符串类 型指针;第二个参数,是发送数据的大小再加1,之所以加1,是因为消息都是按照数据流 发送的,如果不在数据之间留下空格,网络引擎就无法分辨出数据,所以要在每个数据之后 加上一个空格,就像给字符串加上一个结束符。第三个参数,用于设置发送消息的重要性, 重要性有三个等级HIGH_PRIORITY(高)、MEDIUM_PRIORITY(中)、LOW_PRIORITY (低),当多个消息要发送时,就会根据重要性等级来决定谁先发,谁后方;第四个参数是 消息的可靠性,可靠性也有5个等级,在网络引擎发送数据时,以可靠高地发送消息,那么 消息就会按照正确的循序到达,反之选择不可靠,那么消息可能就是无续的到达了;第五个 参数,保持默认值;第六个参数,接收者的ID,直接用UNASSIGNED_SYSTEM_ADDRESS 宏就行了,好像也没别的选择;第七个参数是表示该消息是否广播。 Packet是网络传输中用于存储数据的一个数据结构,这个结构有四个分量:第一个分 量是PlayerID,playerId表示这个数据来自哪个客户端,即是客户端的标识符;第二个参数 Unsigned long length表示这个结构中数据的长度;第三个参数Unsigned long bitsize表示这 个结构中数据的比特大小;第四个参数Char *data是数据内容。服务器接收到的消息,都是 以这个包的结构存在,通过调用函数Receive()就可以从接收的缓冲区中得到一个包,前提 是接收缓冲区有客户端发来的消息。得到一个消息包后要调用函数GetPacketIdentifier()处 理一下,再使用。使用完后要调用函数DeallocatePacket()并传入已经处理的包,表示让这 个包生效。 最后在结束程序的时候要记得关闭网络模块,释放创建的资源,执行的语句都是固定的, 详细请参考上面的实例。要想成功编译这个服务器端程序还需要进行2部设置:第一步,右 击项目名。在这里是raknettest. “属性”->"配置属性"->"链接器"->"输入"-> “附加依赖项” - 460 - 中加入 RakNetLibStaticDebug.lib Ws2_32.lib ;第二步、在菜单栏的“工具”-->"选项"-->" 项目和解决方案"-->"vc++目录"里在第2个下拉列表里选 “库文件” 在下面的空白里加入lib 的目录。在在下拉列表里选“包含文件”,同样在下面的空白里加入include的目录。关于使 用第三方网络引擎建立服务器的内容就到这里,要想了解更深入就需要参考RakNet的帮助 文档。服务器建立好了,详细请参看随书实例的“第三部分SDK”的网络引擎“RakNet-3.6201” 目录下的“\Tutorial\TutorialServerThread”。 下一步就要建立客户端了,客户端就要学习到 Virtools和RakNet的整合。 21.5.3 网络模块管理器 上一小节已经建立好了简单聊天系统的服务器端,这节是建立聊天系统的客户端。客户 端的思路是这样的:使用RakNet封装一个网络通信模块,通过开发一个管理器,把网络通 信模块的所有功能实现,然后在开放几个BB,来调用这个管理器的功能。这样做的目的是 BB之间可以实现数据共享。管理器的功能包括创建socket、连接到服务器,收发信息以及 程序退出时释放创建资源。处理接收到的消息需要开辟一个线程来专门处理,如果不专门开 启个线程处理的话,消息发来时会有很大概率收不到。开启线程需要定义一个结构体,把要 在线程中处理的对象包含进去,其中要包含网络模块指针、数据包指针、接收到的消息队列、 已经处理线程间同步互次的变量,具体结构体如下所示: //-处理消息结构体-------------------------- typedef struct HandlMessageThreadInfo { CKBOOL Stop; //是否要线程停止运行 CKContext *Context; //Virtools设备 CKBOOL Error; //错误标示 RakPeerInterface *client; //网络模块 unsigned char packetIdentifier; //介绍的消息包类型识别返回字符 Packet* p; //消息包 int RakNetType; char m_strMsg[DEFAULT_BUFFER]; //消息存放字符串 XClassArray ReveStr; //接收到的消息队列 VxMutex mutex; //处理线程间同步互次变量 HandlMessageThreadInfo() //初始化 { Stop = FALSE; Context = NULL; Error = FALSE; client = NULL; p = NULL; } }HandlMessageThreadInfo; //结构体别名 为什么使用接收消息队列?因为在过去一帧中,接收到的消息可能不止一条,自然就要 把接收到的消息用队列来存储,处理的时候在从这个队列中一条一条地取。由于把消息在存 在队列中是在接收消息线程中进行的,而从队列中取出一条消息是在主线程中进行的,两个 线程可能在同一时间要对这个队列进行增和删的操作,如果没有一个同步互次机制,就很危 险,所有需要一个VxMutex类型变量来完成这个协调的使命。发消息就轻松很多,这个动作 - 461 - 只在主线程中进行,但一帧之内有时要发多天消息,所有存储发送消息的字符串数组要定义 得足够长,这里用宏DEFAULT_BUFFER来表示,默认是1024个字符长度,通常最好不要 超过4096的长度,否则就有可能不能一次发完。发送的消息长度越短越好。 因为既要开发管理器,又要开发BB,所以最好把两者一起放在一个VC工程项目中开发, 使用Virtools的SDK开发的VC向导建立一个新的工程项目名为“RakNetClientManager”, 在向导弹出图21.2.1_1画面时,把“Manager”和“Building Block”两项同时勾选,然后 下一步,然后会先弹出一个创建BB的选项,给这个BB起名为“wow_RakNetActivateClient”, 再下一步时会弹出管理器创建选项,给这个管理器命名为“wow_RakNetClientManager”, 剩下的就按部就班吧。向导创建完以后,头文件“wow_RakNetClientManager.h”是管理 器类的定义,打开这个文件在类声明的地方添加和修改相应的代码,如下所示: class wow_RakNetClientManager : public CKBaseManager { public : wow_RakNetClientManager(CKContext* Context); //构造函数 ~wow_RakNetClientManager(); //析构函数 HandlMessageThreadInfo *m_handleMessage; //线程处理函数,收消息处理函 VxThread *VXT; //线程结构 XClassArray m_StrVec; //接受消息容器 XString m_StrSend; //发消息字符串 BOOL RakNetClientConnection(char *ServerIP,char *ServerPort); //创建客户端 BOOL RakNetCloseConnection(void); //关闭客户端网络模块 BOOL RakNetRunClient(char *ServerIP,char *ServerPort); //运行客户端 BOOL RakNetClientSendToServer(char *text); //客户端发消息到服务器 virtual CKERROR PreProcess(); //每一帧之前调用 virtual CKERROR PostProcess(); //每一帧之后调用 virtual CKERROR OnCKReset(); //复位时调用 virtual CKERROR OnCKPlay(); //运行开始时调用 virtual CKDWORD GetValidFunctionsMask() //注册回调函数 { return CKMANAGER_FUNC_PreProcess| //每一帧之前调用 CKMANAGER_FUNC_PostProcess| //每一帧之后调用 CKMANAGER_FUNC_OnCKReset| //复位时调用 CKMANAGER_FUNC_OnCKPlay; //运行开始时调用 } protected : BOOL m_enable; //网络模块是否OK }; 从这个类所声明的成员函数中,可以看到它的功能不多。在类的构造函数中只是向 Virtools注册自定义的管理器,否则Virtools就无法识别,就像目前我国的户籍制度一样,不 注册个户口,就是黑户。注册自定义管理器外,还初始化线程结构对象和线程指针,为后面 的操作做好准备。构造函数只在Virtools启动时调用一次,构造函数的代码如下所示: wow_RakNetClientManager::wow_RakNetClientManager(CKContext *Context) : CKBaseManager(Context, wow_RakNetClientManagerGUID, "wow_RakNetClientManager") - 462 - { Context->RegisterNewManager(this); //创建自定义的管理器向Virtools设备注册 m_handleMessage=NULL; //消息结构赋初值 VXT=NULL; //线程赋初值 m_enable=FALSE; //启用标志赋初值 } 析构函数就是释放在运行过程中开辟的一些资源,这是一个常理没什么好说的,由于在 运行中管理器会创建前面自定义的结构,还会开辟线程接收消息,所有在析构函数里面判断 一次,如果这些资源没有被释放时,就立即释放。这是回收资源最后的阵地,析构函数的代 码如下所示: wow_RakNetClientManager::~wow_RakNetClientManager() { if (m_handleMessage) //管理器析构时释放资源 { m_handleMessage->Stop=TRUE; //线程结束标志,告诉线程结束自己行为退出 m_handleMessage->ReveStr.Clear();//把消息队列清空 Sleep(200); //等待200毫秒,让线程有时间结束自己行为 if (VXT) //消耗线程 { VXT->Wait(); //等待线程退出 VXT->Close(); //线程关闭 delete VXT; //删除线程 VXT=NULL; //删除后赋给个空值 } if (m_handleMessage->client) //释放网络资源 { m_handleMessage->client->Shutdown(300); //等待300毫秒告知服务器 RakNetworkFactory::DestroyRakPeerInterface(m_handleMessage->client); } //销毁网络模块 delete m_handleMessage; //删除自定义的线程结构体 m_handleMessage=NULL; //删除后赋给个空值 } m_enable=FALSE; //网络是否可以使用标志 m_StrVec.Clear(); } 前面都是赋初值和释放的步骤,创建的步骤是在点击Virtools播放键的时候开始,所以 需要重载虚函数:OnCKPlay()。在这个函数中第一步创建线程结构体,第二步创建线程, 第三步创建网络模块,详细代码如下所示: //--- Called when the process loop is started. CKERROR wow_RakNetClientManager::OnCKPlay() { if (!m_handleMessage&&!VXT)//如果没有创建,就创建相关资源 { m_handleMessage=new HandlMessageThreadInfo; //创建线程结构体并赋初值 - 463 - m_handleMessage->client=NULL; m_handleMessage->Context=m_Context; m_handleMessage->p=NULL; m_handleMessage->Error=FALSE; m_handleMessage->Stop=FALSE; m_handleMessage->RakNetType=-100; m_StrVec.Clear(); //清空消息队列 VXT = new VxThread(); //创建线程资源 VXT->SetName("RakNetClientHandle"); //设置线程的名字 VXT->SetPriority(VXTP_NORMAL); //设置线程的优先级别 //创建RakNet网络引擎通信客户端模块 m_handleMessage->client=RakNetworkFactory::GetRakPeerInterface(); m_handleMessage->client->AllowConnectionResponseIPMigration(false); unsigned short clientPort=rand()%10000;//产生一个客户端ID号 SocketDescriptor socketDescriptor(clientPort,0); //定义一个客户端描述 m_handleMessage->client->Startup(1,30,&socketDescriptor, 1); //启动 m_handleMessage->client->SetOccasionalPing(true); } return CKERR_NOTIMPLEMENTED; } 注意这里只是创建,并没有连接到服务器,线程也只是创建也没有启动,因为这些动作 在运行中是可以多次被执行的,比如当游戏进入单人模式时,就没有必要在和服务器连接了, 这样可以节省服务器端的资源开销。真正执行连接到服务器,启动接收消息线程的函数是 RakNetClientConnection(),需要传入两个参数,一个是服务器的IP地址,一个是服务器的 端口号。具体代码如下所示: BOOL wow_RakNetClientManager::RakNetClientConnection(char *ServerIP,char *ServerPort) { m_enable=FALSE; //网络是否可以使用标志,还不可用 //使用访问密码链接到服务器 bool b = m_handleMessage->client->Connect(ServerIP, atoi(ServerPort), "VirtoolsCustomNet", (int) strlen("VirtoolsCustomNet")); if (b) //返回TRUE,表示连接成功 { char ch[128]; sprintf(ch,"%s",m_handleMessage->client->GetLocalIP(0)); m_Context->OutputToConsole(ch); //打印本地IP地址到Virtools控制台 sprintf(ch,"%s", m_handleMessage->client->GetGuidFromSystemAddress (UNASSIGNED_SYSTEM_ADDRESS).ToString()); m_Context->OutputToConsole(ch); m_StrSend=""; //清空发消息队列 m_enable=TRUE; //网络是否可以使用标志,可用 if (!VXT->IsCreated())//启动消息接收线程 { //将线程结构体传给线程处理函数 - 464 - VXT->CreateThread(Run,m_handleMessage); } } else { //如果返回False,表示连接失败,那就销毁客户端网络对象 m_Context->OutputToConsole("尝试连接失败!"); RakNetworkFactory::DestroyRakPeerInterface(m_handleMessage->client); } return m_enable; } 既然在运行中才连接,自然也会在运行中断开,然后再连接,再断开,所有断开和连接 也要封装成独立的函数方法,代码如下所示: BOOL wow_RakNetClientManager::RakNetCloseConnection(void) { m_handleMessage->client->CloseConnection(m_handleMessage->client->GetSyste mAddressFromIndex(0),true,0); return TRUE; } 消息接收线程函数和服务器的消息线程函数几乎一样,没有什么区别,其实还简单些, 相同的部分这里就不贴出来了。唯一不同的地方就是要保存接收到的消息,以便在别的地方 处理,之所以要在别的地方处理这些消息,是因为收到消息的时候,程序的主线程可能正在 渲染阶段,脚本已经执行过一轮,要等下一帧才能响应,如图19.1.2_1所示。而服务器端接 收到客户端发来的消息后,直接群发出去,然后立即再等待客户端发来的消息,它不需要干 其他事情。 unsigned int Run(void *arg) { //类型转换,得到线程结构 HandlMessageThreadInfo *handleMessage=(HandlMessageThreadInfo *)arg; char strrec[DEFAULT_BUFFER]; //临时存放接收到的消息 while(!handleMessage->Stop) { Sleep(10); //线程中没一个循环休息一下,让其他进程有得到跟多的 CPU 资源 handleMessage->p = handleMessage->client->Receive();//检测是否有消息 while(handleMessage->p) { handleMessage->packetIdentifier = // 处理一个消息包 GetPacketIdentifier(handleMessage->p); switch (handleMessage->packetIdentifier) //检测消息包类型 { //„„„„这里省略了很多行,详细看例子 default: //这个是客户端,除了显示消息外,没有其他的事情可做 strcpy(strrec,(char *)handleMessage->p->data); handleMessage->mutex.EnterMutex(); //进入资源同步互次状态 handleMessage->ReveStr.PushBack(strrec); //消息进入队列 - 465 - handleMessage->mutex.LeaveMutex();//离开资源同步互次状态 break; } //让这个消息包生效 handleMessage->client->DeallocatePacket(handleMessage->p); // 循环检测,再检测一次看是否还有消息包要接收 handleMessage->p = handleMessage->client->Receive(); } } return 1; } 本例收发消息的原则是在每一帧开始前,把上一帧所接收的消息全部copy出来,然后 清空线程结构中的消息队列,否则只加不减就会出问题。正因为这样在清空的时候要注意消 息队列这个资源的同步互次,因为收消息的时候,队列有可能正在加入新的消息;而这里要 清空这消息队列。当两个线程一个要加,一个要清空,同时发生不互次的话,程序就崩溃了。 重载每帧开始前函数PreProcess()的代码如下所示: CKERROR wow_RakNetClientManager::PreProcess() { if (m_enable)//如果网络模块已经开启 { //每帧开始前,把解释消息队列清除。 XString *temp,ha; m_handleMessage->mutex.EnterMutex();//进入资源同步互次状态 m_StrVec=m_handleMessage->ReveStr; //消息赋值给另外一个队列 m_handleMessage->ReveStr.Clear(); //清空结构中的队列 m_handleMessage->mutex.LeaveMutex();//离开资源同步互次状态 } m_StrSend=""; //每帧开始前,把发送消息清空 return CKERR_NOTIMPLEMENTED; } 每一帧开始前取出所有接受到的消息,清空发送消息字符串,在每一帧结束之后就要清 空已取到的消息队列,以便下一帧再取。所有在每一帧必须处理完所有的消息,不然下一帧 就是新的消息了。而发送消息是在每一帧结束后,一次性发送出去。重载每帧结束后的函数 PreProcess()的代码如下所示: CKERROR wow_RakNetClientManager::PostProcess() { if (m_enable)//如果网络模块已经开启 { m_StrVec.Clear(); //清空取得的消息队列 int size=m_StrSend.Length(); //判断这一帧要发送消息的长度 if (size>5)//发送消息长度大于5才表示不是空字符串,因为Xstring空串长度就有4 { //网络消息发送, m_handleMessage->client->Send(m_StrSend.Str(), m_StrSend.Length()+1, HIGH_PRIORITY, RELIABLE_ORDERED, 0, UNASSIGNED_SYSTEM_ADDRESS, true); - 466 - } } return CKERR_NOTIMPLEMENTED; } 在每一帧中发送的消息可能不止一条,那就把他们连接起来,用个特殊的符号隔开,注 意不要用结束符,连接发送消息的函数是: BOOL wow_RakNetClientManager::RakNetClientSendToServer(char *text) { //字符串连接很简单,就直接相加就好 m_StrSend+=text; return TRUE; } 很多时候Virtools会进行复位,复位的时候就要释放消息队列的资源,释放资源和在析 构函数中做的事情是一样的,这里就不多说了,也不贴出代码,详细请参考示例。这里所有 主要代码就讲解完了,同样的想要成功编译这个工程还需要进行2部设置,这两步设置和上 一节的一样,请参看上一小节。 21.5.4 连接到服务器 BB 客户端网络通信管理器已经建立好,但是并没有和服务器进行链接,既然有链接也就有 断开链接的时候,所以要开发的这个BB就要有2个输入端口、2个输出端口。由于链接有可 能出现失败情况,所以需要第三个输出端口,在发生错误是输出。链接到服务器自然也需要 服务器的IP和端口号,就需要2个输入参数。链接互联网上的服务器,是不会立即收到服务 器的回复的,所以需要第三个参数表示在多少时间过去以后,没有等待到服务器回复就表示 链接超时,链接失败。既然需要判断是否超时,就要需要一个局部变量来累计等待的时间。 为了便于反应BB的运行状态,最好有一个输出参数用于输出BB的运行状态。具体的输入、 输出端口;输入、输出参数如下所示: proto->DeclareInput("创建"); //开始链接服务器输入端口 proto->DeclareInput("结束"); //与服务器断开输入端口 proto->DeclareOutput("创建成功"); //链接服务器成功输出端口 proto->DeclareOutput("结束成功"); //与服务器断开输出端口 proto->DeclareOutput("错误"); //异常输出端口 //---输入参数声明,分别是服务器IP地址,端口号已经等待超时的时间值 proto->DeclareInParameter("ServerIP", CKPGUID_STRING,"127.0.0.1"); proto->DeclareInParameter("ServerPort", CKPGUID_STRING,"33333"); proto->DeclareInParameter("Wait Time", CKPGUID_TIME,"0m 21s 0ms"); //--- 输出参数,用于表示BB的状态 proto->DeclareOutParameter("OutTex", CKPGUID_STRING); //---- 局部参数,用于记录时间累计 proto->DeclareLocalParameter("CurTime",CKPGUID_FLOAT); 由于管理器已经封装与服务器交互的方法,所有BB的工作就变得简单了,只是调用函 数而已。在上一节创建的工程项目中除了保护管理器外还包含了一个BB的创建,该BB名为 “wow_RakNetActivateClient”,打开“wow_RakNetActivateClient.cpp”文件,在相应的 地方添加如下代码: int wow_RakNetActivateClient(const CKBehaviorContext& BehContext) { - 467 - CKContext *ctx=BehContext.Context; //Virtools物件描述 CKBehavior *beh=BehContext.Behavior; //Virtools行为描述 XString outinfo; //用于输出的字符串 float timetowait = 0; //用于获得第三个输入参数的值 float curtime = 0; //当前累计的时间 beh->ActivateOutput(0,FALSE); //先关闭所有输出端口 beh->ActivateOutput(1,FALSE); beh->ActivateOutput(2,FALSE); //根据管理器GUID得到网络通信管理器 wow_RakNetClientManager* rcm = (wow_RakNetClientManager*) ctx->GetManagerByGuid(wow_RakNetClientManagerGUID); if (rcm==NULL) //如果失败就提示输出 { //输出到参数和激活第三个端口 outinfo="wow_RakNetActivateClient得不到通信管理器"; beh->SetOutputParameterValue(0,outinfo.Str(),outinfo.Length()+1); beh->ActivateOutput(2,TRUE); return CKBR_OK; } if (beh->IsInputActive(0)) //输入端口一被激活,表示要链接到服务器 { beh->ActivateInput(0,FALSE); //关闭端口一,以免一帧内再被激活 //从输入端口中得到服务器IP地址和端口号 CKSTRING serverIP=(CKSTRING)beh->GetInputParameterReadDataPtr(0); CKSTRING serverPort=(CKSTRING)beh->GetInputParameterReadDataPtr(1); rcm->RakNetClientConnection(serverIP,serverPort); //链接到服务器的函数 outinfo="wow_RakNetActivateClient尝试连接到服务器";//状态输出到参数 beh->SetOutputParameterValue(0,outinfo.Str(),outinfo.Length()+1); beh->SetLocalParameterValue(0,&curtime); //开始计时前先置0, return CKBR_ACTIVATENEXTFRAME; //下一帧自动激活 } if (beh->IsInputActive(1)) //输入端口二被激活,表示要与服务器断开链接 { beh->ActivateInput(1,FALSE); //关闭端口二 outinfo="wow_RakNetActivateClient结束与服务器的链接"; //状态输出到参数 beh->SetOutputParameterValue(0,outinfo.Str(),outinfo.Length()+1); rcm->RakNetCloseConnection();//与服务器断开链接函数 beh->ActivateInput(1,TRUE); //激活相应输出端口 return CKBR_OK; } //下面代码每帧都会被执行,直到有结果才结束 beh->GetInputParameterValue(2, &timetowait); //得到最长等待服务器回复时间 beh->GetLocalParameterValue(0,&curtime); //当前已经等了多久时间 float elapsed = curtime + BehContext.DeltaTime; //继续累积这个时间 beh->SetLocalParameterValue(0,&elapsed); //累积的时间记录到局部参数中 - 468 - if (elapsedm_handleMessage->RakNetType== ID_CONNECTION_REQUEST_ACCEPTED) { outinfo="wow_RakNetActivateClient连接到服务器ok"; //状态输出到参数 beh->SetOutputParameterValue(0,outinfo.Str(),outinfo.Length()+1); beh->ActivateOutput(0,TRUE); //成功才激活端口一 return CKBR_OK; } } else { //超时了,表示链接失败,状态输出到参数,激活端口三 outinfo="wow_RakNetActivateClient连接到服务器超过了预定时间,失败"; beh->SetOutputParameterValue(0,outinfo.Str(),outinfo.Length()+1); beh->ActivateOutput(2,TRUE); return CKBR_OK; } return CKBR_ACTIVATENEXTFRAME; //继续等待的话,下一帧再继续运行 } 上面的代码虽然有那么一些,其实关键就是两个语句,一个是连接到服务器的语句、一 个判断服务器回复成功标准,代码都有注释,请读者自行找出这两条语句。其他的语句只是 为了这两条语句和反应BB运行状态的语句,以至于不想再用篇幅对上面代码做解释了。 21.5.5 发送消息到服务器 BB 在创建客户端网络通信管理器的时候提到在每一帧中发送的消息可能不止一条时,就把 这些消息连接起来,用个特殊的符号隔开,由管理器在每个循环脚本执行完后,一次性发出 去。所以这个发送消息到服务器的BB,只是一个字符串拼接功能的BB罢了。由于向导生成 的工程只包含一个管理器和一个BB,所以要用向导再建一个工程,工程命名为 “RakNetSendMsgServer”, BB命名为“wow_RakNetSendMsgServer”,然后给该BB设 置相应的端口和参数,如下所示: proto->DeclareInput("预发"); //发送消息触发端口 proto->DeclareOutput("预发完"); //消息发送完成端口 proto->DeclareOutput("错误"); //错误发送时输出 proto->DeclareInParameter("Text", CKPGUID_STRING); //要发送的字符串消息 proto->DeclareInParameter("分隔符", CKPGUID_STRING); //消息的分割符 //--- 输出参数,用于表示BB的状态 proto->DeclareOutParameter("outinfo", CKPGUID_STRING); 字符串相加很简单,之所以要另外开发这个BB,是因为字符串必须和管理器的成员变 量m_StrSend相加才行。m_StrSend是管理器每帧要发送的消息的总和,详细代码如下所示: int wow_RakNetSendMsgServer(const CKBehaviorContext& BehContext) { CKContext *ctx=BehContext.Context; //Virtools物件描述 CKBehavior *beh=BehContext.Behavior; //Virtools行为描述 - 469 - XString outinfo; //用于输出的字符串 beh->ActivateOutput(0,FALSE); //先关闭所有输出端口 beh->ActivateOutput(1,FALSE); //根据管理器GUID得到网络通信管理器 wow_RakNetClientManager* rcm = (wow_RakNetClientManager*) ctx->GetManagerByGuid(wow_RakNetClientManagerGUID); if (rcm==NULL) //如果失败就提示输出 { //输出到参数和激活第二个端口 outinfo="wow_RakNetSendMsgServer得不到通信管理器"; beh->SetOutputParameterValue(0,outinfo.Str(),outinfo.Length()+1); beh->ActivateOutput(1,TRUE); return CKBR_OK; } //得到要发送的字符串和分隔符 CKSTRING text=(CKSTRING)beh->GetInputParameterReadDataPtr(0); CKSTRING delchar=(CKSTRING)beh->GetInputParameterReadDataPtr(1); rcm->m_StrSend+=text; //字符串相加 rcm->m_StrSend+=delchar; //加上分隔符 rcm->m_StrSend.Trim(); //去掉多余的空格 //将发送的消息输出到端口以便观察 beh->SetOutputParameterValue(0,rcm->m_StrSend.Str(),rcm->m_StrSend.Length()+1); beh->ActivateOutput(0,TRUE); //激活端口一 return CKBR_OK; } 这个BB就这些代码,但是不要在这里编译它,将“wow_RakNetSendMsgServer.cpp” 拷贝出来,放到网络通信管理器项目的目录下,打开将这个cpp文件添加到管理器项目中。 这时编译会出错,因为加入的这个cpp文件没有包含管理器的头文件,将“#include "wow_RakNetClientManager.h"”语句添加到该cpp文件头部就可以了,这时编译OK,将生 产的dll丢到Virtools的管理器目录,打开Virtools却找不到该BB,因为还少一步,很多人忘记 这一步:Virtools注册自定义的BB。注册的地方是固定和语句都是固定的,代码如下所示: void RegisterBehaviorDeclarations(XObjectDeclarationArray *reg) { RegisterBehavior(reg, FillBehaviorwow_RakNetActivateClientDecl); } 21.5.6 处理服务器消息 BB 消息接收工作是在管理器的线程接收函数里已经完成了,这里只是将接收到的消息进行 处理而已。由于程序这一帧到下一帧期间,收到的消息可能不止一条,所以管理器使用了队 列类型(XClassArray)来存放所有收到的字符串消息。这个队列类型有两个,一个在消息 处理结构体中,参与消息接收线程的工作;一个在管理器中,参与BB处理消息的工作。两 个队列唯一交汇的地方是在每一帧开始前,在函数PreProcess()中,唯一的动作就是结构体 中的消息队列赋值给管理器中的消息队列。可能不好理解,打个比方说有两个桶,一个人拿 着桶去水龙头接收,一个人拿桶救火;洒水的桶定时与接水的桶碰头取水洒,接水的桶给完 水后继续去接水。 主线程和接收线程就好比这两种人,他们各自拿着各自的桶工作,如果 - 470 - 两个线程只有一个桶„„。 使用VC的BB向导创建一个新的项目,项目名为“RakNetRevMsgFromServer”, BB命 名为“wow_RakNetRevMsgFromServer”。 既然要处理的消息不止一条,那么消息的输出 参数就要是可以变化的,如果要输出参数是可变化的,要在使用VC创建BB的向导时,在弹 出图19.2.2_4的对话框是,勾选相应的选项,否则无法实现可变数目的输出参数功能(详细 请参见第19章)。通常默认应该至少有一个消息输出参数。该BB的参数输入、输出端口及其 输出参数如下所示: proto->DeclareInput("接收"); //输入端口 proto->DeclareOutput("接收成功"); //输出端口 proto->DeclareOutput("错误"); //错误时输出端口 //--- 输出参数 proto->DeclareOutParameter("Num", CKPGUID_INT); //共几条消息 proto->DeclareOutParameter("RevMsg", CKPGUID_STRING); //第一条消息 当用户编辑这BB,增加或减少输出端口的数目时,自动设置新增加参数的类型为字符 串,这个动作需要在BB的回调函数中处理,详细代码如下所示: int wow_RakNetRevMsgFromServerCallBack(const CKBehaviorContext& BehContext) { CKContext *ctx=BehContext.Context; //Virtools物件描述 CKBehavior *beh=BehContext.Behavior; //Virtools行为描述 int count; //获得用户创建的输出参数数目 CKParameterOut * out; //输出参数类型变量 CKGUID guid; //参数类型的GUID switch (BehContext.CallbackMessage) { case CKM_BEHAVIOREDITED: //当BB被用户编辑是调用 count=beh->GetOutputParameterCount(); //得到当前输出端口数目 if (count<=2) //其实不可能小于2 { count=2; } else { out=beh->GetOutputParameter(1); //得到默认就有的输出参数1 guid=out->GetGUID(); //得到参数类型的GUID for (int i=2;iGetOutputParameter(i); //得到新增的输出参数 out->SetGUID(guid); //设置输出参数类型 } } break; return CKBR_OK; } 因为默认的输出参数是一定存在的,所以通过这个参数获得字符串类型参数的GUID, - 471 - 然后用着GUID来设置新参数的类型。这样做的目的是防止用户设置错参数类型。输出参数 的具体应该是多少,这个要根据实际的项目中来调节,第一个参数是整形,它表示这一帧处 理时,有几条消息。把每条消息设置到BB的输出参数代码如下所示: int wow_RakNetRevMsgFromServer(const CKBehaviorContext& BehContext) { CKContext *ctx=BehContext.Context; //Virtools物件描述 CKBehavior *beh=BehContext.Behavior; //Virtools行为描述 XString outinfo,*temp; //用于输出的字符串 beh->ActivateOutput(0,FALSE); //先关闭所有输出端口 beh->ActivateOutput(1,FALSE); //根据管理器GUID得到网络通信管理器 wow_RakNetClientManager* rcm = (wow_RakNetClientManager*) ctx->GetManagerByGuid(wow_RakNetClientManagerGUID); if (rcm==NULL) //如果失败就提示输出 { //输出到参数和激活第二个端口 outinfo="wow_RakNetRevMsgFromServer得不到通信管理器"; beh->SetOutputParameterValue(0,outinfo.Str(),outinfo.Length()+1); beh->ActivateOutput(1,TRUE); return CKBR_OK; } int count=beh->GetOutputParameterCount(); //得到当前输出端口数目 for (int i=1;iSetOutputParameterValue(i,outinfo.Str(),outinfo.Length()+1); } int size=rcm->m_StrVec.Size(); //查看队列中有多少个消息 int num=0; for( temp = rcm->m_StrVec.Begin(); temp != rcm->m_StrVec.End(); ++temp) { //将消息取出,并设置到输出参数 num++; outinfo=*temp; //消息字符串赋值 if (num<= count-2) //消息数目比输出参数的数目多,要做判断 { beh->SetOutputParameterValue(num,outinfo.Str(),outinfo.Length()+1); } } beh->SetOutputParameterValue(0,& size); //输出队列中有多少个消息 if (num>0) //num大于1表示至少有一条消息,就让端口输出。 { beh->ActivateOutput(0,TRUE); } return CKBR_OK; } - 472 - 队列类型(XClassArray)的用法类似STL的vector类似,Virtools中的队列类型不仅仅 只有XClassArray,还其他的队列容器类型,详细请自行查看SDK文档;也可以直接使用STL 中的对象替换掉XClassArray,他们的用法大同小异。上面的代码很简单,只是有一点要注 意就是:创建的输出参数数量,不一定都是大于每帧接收的消息的数量,所以当这种情况发 生时,就会丢掉一些消息。 同样的与上一小节一样,不要在这个项目中编译,而是把相应的cpp文件copy到管理器 项目中,调节注册函数和包含管理器头文件,就能把这几个BB包含到一个dll中,便于日后 的代码的维护。 - 473 - 第二十二章 用 SDK 开发新功能 有一些小功能,比如打印屏幕图片,仅仅靠 SDK 提供的类、函数是无法实现的,而 与第三方库整合,工程又很大了。其实,Window 系统提供有 Win32 API,它有很多函数 可以调用,在 Virtools 的 SDK 中调用 Win32 API,就像调用自己的函数一样简单。本章讲 解的内容如下所示:  SDK 调用 Win32 API 的方法  开发几个 BB 丰富 Virtools 的功能  Virtools 与 Flash 的整合 22.1 读写注册表 22.1.1 注册表的简单介绍 注册表是微软系统的一个重要数据库,用于存储系统和应用程序的设置信息。注册表 由主键、键、子键和键值四项构成。打个简单的比喻:打开我的电脑,发现里面有 3 个“ 主 键”,分别为“C 盘”、“D 盘”“E 盘”,并且没有办法也没有必要对这三个“主键”进行删 除、或者增加新的主键;打开“C 盘”,发现里面有很多文件夹,各个文件夹下面还有更多 层的文件夹,这些都是“键”、或者“子键”;当我们层层打开这些文件夹后,终于发现了 一个文件名为“Hi.txt”的文件,里面的内容是:“辛苦了!”,这就是“键值”。其中“键值” 类型有四种: 键值类型 数据类型 说明 REG_SZ 字符串 文本字符串 REG_MULTI_SZ 多字符串 含有多个文本值的字符串 REG_BINARY 二进制数 二进制值,以十六进制显示。 REG_DWORD 双字 一个 32 位的二进制值,显示为 8 位的十六进制值。 在开始菜单,选择运行,再弹出的对话框中输入“regedit”、回车后就会弹出注册表, 会发现有好几个主键,本书范例只是在“HKEY_CURRENT_USER”这个主键中操作,因 所在这里的操作改变只是针对当前用户而改变,并不影响其他用户行为。至于各个主键和 键的意义不是本书重点,这里不提。有兴趣的读者可以查看其它书籍或者收索互联网。 在实际的项目中,使用到注册表的时候一般是保存玩家存档;玩家对游戏的设置信息; 保存一个游戏安装路径,以便下载更新等。 22.1.2 读写注册表功能描述 每个项目读写注册表的数据不同,最好能写一个通用的BB,Virtools中的Array类型可以 存储很多类型的数据,包括字符串,而且Array的数据数目没有限制。注册表的键值类型只 有四种,其中一种也是字符串,两者不需要额外的转换就能读取。 所以通过Array作为注 册表和Virtools数据交换的桥梁是一个好办法。建一个Array,该表有2列,第一列表示“建”, 第二列表示“键值”,表的行数就是要添加到注册表的数目。 程序在第一次运行的时候,注册表并没有这些数据,读取就会失败,所以需要在指定的 注册表位置添加键,并给该键赋值。这些操作只需要轻松调用5个win32API函数就能搞定。 这些函数是: - 474 - 1.通过RegCreateKeyEx函数可以在注册表中创建键,如果需要创建的键已经存在了,则打 开键: LONG RegCreateKeyEx( HKEY hKey, // 主键 LPCTSTR lpSubKey, // 子键 DWORD Reserved, // 保留字段,没有使用 LPTSTR lpClass, //指向包含键类型的字符串。如果该键已经存在,则忽略该参数。 DWORD dwOptions, // 为新创建的键设置一定的属性 REGSAM samDesired, // 设置对键访问的权限 LPSECURITY_ATTRIBUTESlpSecurityAttributes, // 句柄不可以被继承 PHKEY phkResult, // 一个指向新创建或打开的键的句柄的指针 LPDWORD lpdwDisposition // 指明键是被创建还是被打开的 ); 2.通过RegOpenKeyEx函数可以打开一个指定的键: LONG RegOpenKeyEx( HKEY hKey, // 主键 LPCTSTR lpSubKey, // 子键 DWORD ulOptions, // 保留字段,没有使用 REGSAM samDesired, // 安全访问标记 PHKEY phkResult // 指向将要打开键的句柄 ); 3.通过RegQueryValueEx函数可以从一个已经打开的键中读取数据: LONG RegQueryValueEx( HKEY hKey, // 主键 LPTSTR lpValueName, // 非空的包含查询值的名称的字符串指针 LPDWORD lpReserved, // 保留,必须为NULL LPDWORD lpType, // 指向数据类型的指针 LPBYTE lpData, // 保存返回值的变量的指针。如果不需要返回值,该参数可以为NULL。 LPDWORD lpcbData // 保存返回值长度的变量的指针 ); 4.RegSetValueEx函数可以设置注册表中键的值 LONG RegSetValueEx( HKEY hKey, // 主键 LPCTSTR lpValueName,// 指向包含值名的字符串指针 DWORD Reserved, //保留,通常必须设置为0 DWORD dwType, //设置的值的类型 CONST BYTE lpData, //指向包含数据的缓冲区的指针 DWORD cbData //以字节为单位,指定数据的长度 ); 5.通过RegCloseKey函数关闭注册表。 LONG RegCloseKey(hKEY); // hKEY为主键 22.1.3 访问注册表的 BB 理解了上面5个注册表函数,开发就变得很容易了:首先打开注册表的相应位置,判断 - 475 - 是读还是写,如果是读就遍历Array的每一行,获得表中第一列(第一列存的是注册表中的 键名)的字符串值,然后根据这个键名获取键值,把改值存在第二列;反之亦然,具体的代 码如下所示: int wow_RegistryReadWrite(const CKBehaviorContext& BehContext) { CKBehavior *beh=BehContext.Behavior; CKContext *ctx=BehContext.Context; beh->ActivateOutput(0,FALSE); beh->ActivateOutput(1,FALSE); beh->ActivateOutput(2,FALSE); //得到要写入到注册表中的信息 CKDataArray* RegArray=NULL; //该表的数据与注册表中的键对应 RegArray=(CKDataArray *)beh->GetInputParameterObject(0); XString outinfo;//BB运行状态输出 XString strvalue; //注册表使用的一些变量 HKEY hKEY;//键值 long ret; //得到注册表函数返回值的参数 int size; if (!RegArray) //该表不存在 { outinfo="wow_RegistryReadWrite输入参数错误"; beh->SetOutputParameterValue(0,outinfo.Str(),outinfo.Length()+1); beh->ActivateOutput(2,TRUE); ctx->OutputToConsole(outinfo.Str()); return CKBR_OK; } int count=RegArray->GetRowCount(); //得到表的函数,也就是得到注册表的键数 //得到访问注册表的路径,注意使用的是“\\”而不是“\” CKSTRING registrypaths=(CKSTRING)beh->GetInputParameterReadDataPtr(1); if (beh->IsInputActive(0))//读注册表输入端口出发 { beh->ActivateInput(0,FALSE); //打开注册表 ret=::RegOpenKeyEx(HKEY_CURRENT_USER,registrypaths, 0, KEY_READ,&hKEY);//path if(ret!=ERROR_SUCCESS) //如果无法打开hKEY,则终止程序的执行 { ctx->OutputToConsole("wow_RegistryReadWrite错误: 查询无法打开有关的hKEY!"); beh->ActivateOutput(2,TRUE); ctx->OutputToConsole(outinfo.Str()); return 0; } //打开注册表成功,就把注册表的键值写到相应的表中 - 476 - for (int i=0;iGetElementStringValue(i,0,NULL); outinfo.Resize(size-1); RegArray->GetElementStringValue(i,0,outinfo.Str()); if(outinfo.Length()<1) continue; DWORD dwType=REG_SZ; //类型是字符串 char tmp[256]="\0"; //字符串就要用结束符,在注册表的数据不要太长 DWORD dwSize=256; //获取键值 ret=::RegQueryValueEx(hKEY,outinfo.Str(), NULL,&dwType,(BYTE *)tmp,&dwSize); if(ret!=ERROR_SUCCESS) { ctx->OutputToConsole("wow_RegistryReadWrite错误:无法查询有关注册表信息!"); ::RegCloseKey(hKEY); beh->ActivateOutput(2,TRUE); ctx->OutputToConsole(outinfo.Str()); return 0; } RegArray->SetElementStringValue(i,1,tmp); //获取键值成功,存入第二列 } ::RegCloseKey(hKEY); //关闭注册表 beh->ActivateOutput(0,TRUE); } if (beh->IsInputActive(1)) //写注册表输入端口出发 { beh->ActivateInput(1,FALSE); DWORD dw; //打开注册表的相应位置 ret=::RegCreateKeyEx(HKEY_CURRENT_USER,registrypaths,0L,NULL,REG_OPTI ON_NON_VOLATILE,KEY_ALL_ACCESS,NULL,&hKEY,&dw);//lpSubKey if(ret!=ERROR_SUCCESS) //打开失败 { ctx->OutputToConsole("wow_RegistryReadWrite错误: 创建时无法打开有关的hKEY!"); ::RegCloseKey(hKEY); beh->ActivateOutput(2,TRUE); ctx->OutputToConsole(outinfo.Str()); return CKBR_OK; } ::RegCloseKey(hKEY); - 477 - //打开注册表 ret=::RegOpenKeyEx(HKEY_CURRENT_USER,registrypaths, 0, KEY_ALL_ACCESS,&hKEY);//path if(ret!=ERROR_SUCCESS) //如果无法打开hKEY,则终止程序的执行 { ctx->OutputToConsole("wow_RegistryReadWrite错误: 查询时无法打开有关的hKEY!"); ::RegCloseKey(hKEY); beh->ActivateOutput(2,TRUE); ctx->OutputToConsole(outinfo.Str()); return CKBR_OK; } for (int i=0;iGetElementStringValue(i,0,NULL); outinfo.Resize(size-1); RegArray->GetElementStringValue(i,0,outinfo.Str()); if(outinfo.Length()<1) continue; //从表中的第二列获得在注册表中的键 值 size=RegArray->GetElementStringValue(i,1,NULL); strvalue.Resize(size-1); RegArray->GetElementStringValue(i,1,strvalue.Str()); //设置键值 ret=::RegSetValueEx(hKEY,outinfo.Str(),NULL,REG_SZ, (const BYTE *)strvalue.Str(),strlen(strvalue.Str())+1);//0L if(ret!=ERROR_SUCCESS) { ctx->OutputToConsole("wow_RegistryReadWrite错误: 新建值时无法打开有关的hKEY!"); beh->ActivateOutput(2,TRUE); return CKBR_OK; } } ::RegCloseKey(hKEY); //关闭注册表 beh->ActivateOutput(1,TRUE); } outinfo="成功"; beh->SetOutputParameterValue(0,outinfo.Str(),outinfo.Length()+1); return CKBR_OK; } 因为调用了Win32的函数,所以在编译的前,记得包含“window.h”头文件。操作注册 表的函数方法有很多,本书就点到此为止,详细请参看随书目录“RegistryReadWrite注册表 读写_第二十二章22.1”。 - 478 - 22.2 文件目录的操作 22.2.1 例举所有指定类型文件 在日常的电脑操作中,随处可以碰到列举所有指定类型的文件,比如打开记事本,然 后选择菜单栏的开始,就会弹出一个框,选择不同路径的时候,就会显示这个路径下所有的 *.txt文件。在一些虚拟显示项目中,有时候需要知道指定路径下有多少个指定的类型文件, 比如图片、配置文件、以及下载的更新包等等。这一小节要开发这样一个BB,它有三个功 能,第一个功能是:给定一个目录,和一个指定的文件类型,然后检索出所有在该目录下, 以及该目录下所有子目录存在的这个类型的文件的完整路径,包括文件名,最后保存到一张 表中,以供其他代码所使用。第二个功能是:创建一个目录。第三个功能是:删除一个目录, 包括该目录下的所有文件。 既然这三个功能互不相关,自然就要有3个输入端口和3个输出端口;既然路径和类型 只是指定而不是确定,所以要有2个输入参数一个是路径,一个是文件类型,以便更改检索 的路径和文件类型;既然是把完整路径存放到表总,就要