针对 Java 移动设备的 3D 图形,第 2 部分: M3G 的保留模式
针对 Java 移动设备的 3D 图形,第 2 部分: M3G 的保留模式
2011年01月31日
共同学习,共同进步!
使用 JSR 184 轻松地管理场景图中的 3D 对象 Claus H??fele (Claus.Hoefele@gmail.com), 作家, 自由作家
简介: Mobile 3D Graphics API 保留模式允许操作 3D 世界的场景图表示。本文是此系列两部分中的第 2 部分,讨论保留模式这种轻松地管理 3D 对象的方式。 在这个 系列 的第 1 部分中,解释了如何使用 Mobile 3D Graphics API(M3G,在 JSR 184 中定义)的快速模式 创建 3D 场景,这种模式允许将 3D 对象快速地渲染到屏幕上。可以把快速模式看成对 3D 功能的低级访问。 对于更复杂的任务,在内存中建立 3D 世界的表示是有帮助的,这样就可以以结构化的方式管理数据。对于 M3G,这种方式称为保留模式。在保留模式中,要定义和显示整个 3D 对象世界,包括关于对象外观的信息。可以把保留模式看成一种更抽象但是更舒服的显示 3D 对象的方式。 如果在建模工具中创建 3D 场景并将数据导入应用程序,那么保留模式尤其方便。在本文中,将解释具体做法。
快速模式与保留模式 为了展示快速模式和保留模式之间的差异,我将 本系列的第 1 部分 中的一个快速模式示例转换为保留模式。还记得第 1 部分中的 白色立方体 吗?在一个继承自 Canvas 的类中,我创建了一个立方体,它有 8 个顶点,其中心在坐标系的原点上。还定义了一个三角形带,让 M3G 知道如何连接顶点来建立立方体的几何结构。一个采用透视投影的摄像机获取了立方体的快照,这个快照最后在 paint() 中进行渲染。在这个方法中,调用了 Graphics3D.render() 并以顶点数据和三角形带作为参数。 针对保留模式的修改出现在 init() 和 paint() 中。清单 1 显示了这些方法的实现。 清单 1. 保留模式中的简单立方体
/** * Initializes the sample. */ protected void init() { // Get the singleton for 3D rendering and create a World. _graphics3d = Graphics3D.getInstance(); _world = new World(); // Create vertex data. VertexBuffer cubeVertexData = new VertexBuffer(); VertexArray vertexPositions = new VertexArray(VERTEX_POSITIONS.length/3, 3, 1); vertexPositions.set(0, VERTEX_POSITIONS.length/3, VERTEX_POSITIONS); cubeVertexData.setPositions(vertexPositions, 1.0f, null); // Create the triangles that define the cube; the indices point to // vertices in VERTEX_POSITIONS. TriangleStripArray cubeTriangles = new TriangleStripArray( TRIANGLE_INDICES, new int[] {TRIANGLE_INDICES.length}); // Create a Mesh that represents the cube. Mesh cubeMesh = new Mesh(cubeVertexData, cubeTriangles, new Appearance()); _world.addChild(cubeMesh); // Create a camera with perspective projection. Camera camera = new Camera(); float aspect = (float) getWidth() / (float) getHeight(); camera.setPerspective(30.0f, aspect, 1.0f, 1000.0f); camera.setTranslation(0.0f, 0.0f, 10.0f); _world.addChild(camera); _world.setActiveCamera(camera); } /** * Renders the sample on the screen. * * @param graphics the graphics object to draw on. */ protected void paint(Graphics graphics) { _graphics3d.bindTarget(graphics); _graphics3d.render(_world); _graphics3d.releaseTarget(); }
首先,获得 3D 图形上下文并创建一个新的 World 对象,它代表 3D 场景。VertexBuffer 和 TriangleStrip 的初始化与快速模式一样,但不是使用两个对象进行渲染,而是将它们赋给一个 Mesh 实例。这个世界(World 对象)有两个孩子:刚创建的网格和一个用透视投影进行初始化的摄像机。_world.setActiveCamera() 让 M3G 使用刚添加的摄像机进行渲染。paint() 方法变得非常简单,它只是将世界渲染到图形上下文上。完整的类清单在 VerticesRetainedSample.java 中。 当以 World 对象为参数使用 Graphics3D.render() 时,M3G 采用保留模式进行操作。在 清单 1 中可以看到,保留模式与快速模式一样容易处理,但是每种模式使用的 API 调用略有不同。表 1 列出了它们之间的差异。 表 1. 快速模式和保留模式中使用的 API 调用
中设置当前摄像机。
将一个或多个摄像机添加作为世界的孩子,并激活其中之一。
对象中设置光源。
将光源添加作为世界的孩子。
设置背景。总是在渲染之前调用
对象中设置背景。如果没有设置,则背景自动清空为黑色。
渲染整个世界,包括它的孩子。
可以通过混合使用保留模式和快速模式各自的渲染命令来组合这两种模式。在使用 Graphics3D.render(World) 之后,被渲染的世界中的活动摄像机和光源自动替换 Graphics3D 的当前摄像机和光源。这样,后续的快速模式渲染可以轻松地使用同样的环境。也可以使用 Graphics3D.render(Node, Transform) 以快速模式渲染世界,因为 World 继承自 Node。在这种情况下,世界的光源、摄像机和背景被忽略,但是所有其他子节点都被渲染。 M3G 的规范根据用来进行绘制的 render() 调用类型来定义保留模式。当使用 render(World) 时,API 采用保留模式进行操作。因为也可以将 World 对象及其孩子作为 Node 按照快速模式进行渲染,所以这两种模式之间并没有很大的差异。但是,通常所说的保留模式是指处理称为场景图的数据结构中的一组 3D 对象的能力,这种场景图独立于渲染代码。 场景图 在定义复杂的世界时,场景图的优点就表现出来了。在 M3G 中,World 类在一个树型结构中组织 3D 对象,这个结构的节点是派生自 Node 的类。在前面的例子中,已经看到了 Node 的两个子类:Camera 和 Mesh。常用的其他对象包括 Light(对 3D 场景进行照明)和 Group(作为其他节点的容器)。成组的节点可以被视为单个对象。World 本身是一个具有更多行为的 Group,它作为场景图的顶级容器。 图 1 显示一个世界的树型结构,其中包括一个光源、一个摄像机和几个成组的网格。还在 Group 和 Mesh 节点中包含了几个特定的设置,稍后会解释这些设置。 图 1. 世界场景图树示例
在 M3G 的场景图中,一个节点必须属于正好一个组。场景还必须是非闭合的。除此限制之外,可以以适合数据结构的方式对场景图随意地建模。在我的示例中,决定采用两个组。每组四个网格。一个组包含蓝色网格,另一个组包含红色网格。还有一个组作为蓝组和红组的容器。 分配给组节点的用户 ID 以后可以帮助我识别组。任何节点都可以有一个用户 ID。对于这个示例,能够找到组节点就够了。我将光源和摄像机节点添加到根对象上,但是位置是无所谓的。可以添加许多摄像机,从而定义不同的视点并用 World.setActiveCamera() 在它们之间进行切换。还可以同时具有多个光源。(M3G 实现支持的光源最大数量可以通过 Graphics3D.getProperties() 获得。) 清单 2 显示 图 1 描绘的世界的初始化代码。 清单 2. 场景图树,第 1 部分:初始化
/** * Initializes the sample. */ protected void init() { // Get the singleton for 3D rendering and a World. _graphics3d = Graphics3D.getInstance(); _world = new World(); // Create a camera with perspective projection. Camera camera = new Camera(); float aspect = (float) getWidth() / (float) getHeight(); camera.setPerspective(30.0f, aspect, 1.0f, 1000.0f); camera.setTranslation(0.0f, 0.0f, 10.0f); _world.addChild(camera); _world.setActiveCamera(camera); // Create lights. Light light = new Light(); light.setMode(Light.OMNI); light.setTranslation(0.0f, 0.0f, 3.0f); _world.addChild(light); // Create two sets of vertex data: one for blue meshes and one for red // meshes. VertexBuffer blueCubeVertexData = new VertexBuffer(); blueCubeVertexData.setDefaultColor(0x000000FF); // blue VertexBuffer redCubeVertexData = new VertexBuffer(); redCubeVertexData.setDefaultColor(0x00FF0000); // red VertexArray vertexPositions = new VertexArray(VERTEX_POSITIONS.length/3, 3, 1); vertexPositions.set(0, VERTEX_POSITIONS.length/3, VERTEX_POSITIONS); blueCubeVertexData.setPositions(vertexPositions, 1.0f, null); redCubeVertexData.setPositions(vertexPositions, 1.0f, null); VertexArray vertexNormals = new VertexArray(VERTEX_NORMALS.length/3, 3, 1); vertexNormals.set(0, VERTEX_NORMALS.length/3, VERTEX_NORMALS); blueCubeVertexData.setNormals(vertexNormals); redCubeVertexData.setNormals(vertexNormals); // Create the triangles that define the cube; the indices point to // vertices in VERTEX_POSITIONS. TriangleStripArray cubeTriangles = new TriangleStripArray( TRIANGLE_INDICES, TRIANGLE_LENGTHS); // Create material. Material material = new Material(); material.setVertexColorTrackingEnable(true); Appearance appearance = new Appearance(); appearance.setMaterial(material); // Create groups to organize the cubes. Group allMeshes = new Group(); allMeshes.setUserID(USER_ID_ALL_MESHES); _world.addChild(allMeshes); Group blueMeshes = new Group(); blueMeshes.setUserID(USER_ID_BLUE_MESHES); allMeshes.addChild(blueMeshes); Group redMeshes = new Group(); redMeshes.setUserID(USER_ID_RED_MESHES); allMeshes.addChild(redMeshes); // Create eight cubes in a circle. for (int i=0; i
M3G 的二进制格式
手工创建 3D 对象是很乏味的,所以到目前为止我只使用了简单的立方体。在本节中,将讲解如何使用 3D 建模工具创建 3D 世界,然后将它导入 M3G 应用程序。 在应用程序之间交换数据要求有一种共用的文件格式。您不需要自己发明这种文件格式,可以使用 M3G 内置的二进制格式,应用程序可以使用 Loader.load() 读取这种格式。许多工具已经能够写 M3G 文件,也可以利用第三方工具厂商提供的导出器(exporter)来增加这种功能。在现有的许多 3D 工具中,我选择 Blender,这是一个功能强大的开放源码建模包。 Blender 是免费的,而且网上有许多出色的文档。重要的是,可以使用 Python 脚本扩展 Blender。我找到了两个写 M3G 文件格式的脚本。我采用 Gerhard V??lkl 编写的脚本,因为它的特性最多而且可在 GPL 许可协议下使用。参考资料 提供了这些工具的链接。
我要创建的场景是一个 3D 文字,它围绕坐标系的原点旋转。可以从 下载 一节中下载完成的 Blender 项目,但是下面总结了创建模型的步骤,您可以自己试试: 新建一个文件(Ctrl-X)并删除 Blender 默认创建的立方体。
将光标放在原点,并将光标吸附到前视图和侧视图中的网格(Shift-S-3)。光标现在正好在原点上。
在前视图上,添加文字(Add > Text),这会创建一个可编辑的字体对象。输入任何文字;我使用 "Hello, world!"。
切换到对象模式(Tab)并显示 Editing 上下文(F9)。单击 Curve and Surface 面板中的 Center New 按钮。文字现在围绕原点水平居中。
在同一个面板上,在 Ext1 域中输入 0.100,这会突出文字的深度,使它具有 3D 外观。
在顶视图中,将一个正交的 Bezier 圆添加到文字上(Add > Curve > Bezier Circle)。以后要使用这个圆使文字沿着圆周弯曲。
这个圆被默认选择;将它放大一点儿(按 S 进行放大)。
Blender 默认创建一个光源和一个摄像机。不必修改它们。
现在,已经有了 图 3 所示的模型。 图 3. 添加文字后的 Blender 项目(摄像机视图)
因为导出器脚本只能处理网格,所以必须对字体对象进行转换。然后,让产生的网格沿着圆周弯曲。 在对象模式中(用 Tab 进行切换),右击选择文字并按 Alt-C 对对象类型进行转换。先从字体转换为曲线,再从曲线转换为网格。
仍然选择文字,进入 Editing 上下文(F9),并在 Links and Materials 面板中用 New 按钮创建一个具有默认值的新材质。网格必须具有材质;否则,导出器脚本将报告错误。
选择文字,按 shift 选择圆,并用 Ctrl-P 创建父子关系。这时会打开一个选项列表,选择 curve deform。
只选择文字,跳到 Object 上下文(F7)。在 Anim settings 面板中选择 Track X。这选择 x 轴作为圆和文字之间的中心。文字现在沿着一个圆形弯曲。
文字现在按照这个圆变形了。根据您的喜好对文字进行转换(输入 G 进行平移,R 进行旋转)。
在文字上使用 Ctrl-Shift-A 将变形应用于网格并删除圆,因为已经不需要它了。如果删除圆之后,文字的变形看起来有点儿奇怪,那么按 Tab 两次在对象和编辑模式之间切换。
可以在 World 面板中修改背景色(用 F5 切换到 Shading 上下文并选择 World 按钮)。
在 Editing 上下文(F9)的 Links and Materials 面板中,将文字的对象名改为 Font#01。# 符号后面的数字是网格的用户 ID。
图 4 给出了结果。如果想预览这个模型,那么可以按 F12,Blender 会渲染它。 图 4. 文字沿着圆形弯曲之后的 Blender 项目(摄像机视图)
最后一步是将这个 3D 模型导出到 M3G 文件中。 将导出器脚本复制到 Blender 的 .blender\scripts 文件夹中,就完成了脚本的安装。
这个脚本使用了 Blender 的默认 Python 安装没有使用的一些导入语句。可以通过安装单独的 Python 解释器来解决这个问题。但是,我只是注释掉了这些导入语句,脚本仍然工作正常。
在重新启动 Blender 之后,装载这个 3D 模型并通过选择 File > Export > M3G in J2ME 运行脚本。
选择 Export into *.m3g binary file。这个脚本还可以生成与 3D 模型等效的 Java?? 语言源代码。如果要进行手工修改,这是很方便的。对于我们的意图,选择二进制文件就行了。
选择要存储的 M3G 文件的名称和目录并单击按钮 Save M3G J2ME。
好了,已经导出了模型,让我们将它用于应用程序中吧。我将使用与前一个例子相似的结构。清单 4 显示了 init() 和 run() 中的修改。 清单 4. 读取 M3G 二进制文件
/** * Initializes the sample. */ protected void init() { // Get the singleton for 3D rendering. _graphics3d = Graphics3D.getInstance(); try { // Load World from M3G binary file. Object3D[] objects = Loader.load(M3G_FILE_NAME); _world = (World) objects[0]; // Change the camera's properties to match the current device. Camera camera = _world.getActiveCamera(); float aspect = (float) getWidth() / (float) getHeight(); camera.setPerspective(60.0f, aspect, 1.0f, 1000.0f); } catch (Exception e) { e.printStackTrace(); } } /** * Drives the animation. */ public void run() { while(_isRunning) { // Find the Mesh and rotate it. Mesh text = (Mesh) _world.find(USER_ID_TEXT); text.postRotate(-2.0f, 0.0f, 0.0f, 1.0f); repaint(); try { Thread.sleep(50); } catch (Exception e){} } }
应用程序变得简单多了。在 init() 中,读取从 Blender 导出的 M3G 文件并将它转换为 World 对象。可以这么做是因为我知道导出器脚本生成的结构。惟一的手工修改是摄像机设置。高宽比取决于电话的屏幕尺寸,必须在运行时计算出来。在 run() 方法中,要通过用户 ID 找到文字的网格;因此,在 Blender 中相应地修改了字体对象的名称。旋转被应用于网格对象,因为 World 本身不能被转换。 在这里必须注意坐标轴。伸出右手,x 轴(拇指)向右,那么 Blender 的 y 轴(食指)指向外,z 轴(中指)向上。M3G 的 x 轴是一样的,但是 y 轴向上,z 轴向内(参见本系列第 1 部分中对 M3G 的坐标系 的描述)。这两种坐标系都是右手坐标系,但是围绕 x 轴转了 90 度。因为摄像机和文字网格是使用 Blender 坐标系导出的,所以文字显示正常。但是,如果要应用自己的转换,那么必须修改坐标轴。这就是在 清单 4 中围绕 z 轴进行旋转的原因。
图 5 显示完成的应用程序的一个快照;BinaryWorldSample.java 包含完整的源代码。 图 5. 导入到 M3G 应用程序中的 Blender 模型
从 3D 工具中导出文件的好处是,可以将开发人员和艺术家的工作分开。如果您在 Blender 中创建更复杂的模型,很快就会意识到这需要更多的软件开发技巧。对于控制台和 PC 上的游戏,主要的人力和金钱花在艺术创作上。对于手机,模型不但要好看,而且必须要小。
从 Blender 导出的文件有 48 KB ;空间主要用于顶点、法线和三角形带的定义。如果分析这个文件,就会看到这个模型有 3,392 个顶点和在 1,014 个三角形带中定义的 1,364 个三角形。再加上启用了照明,这对于一般的手机是很重的负担。如果关闭了照明,那么就不需要法线,这可以减少文件大小和处理开销。M3G 的文件格式支持压缩,但是不用压缩功能更好,因为当 M3G 文件被添加到应用程序的 Java Archive(JAR)文件中时总会被压缩。在示例应用程序中,M3G 文件在存档文件中压缩到了 16 KB。Blender 中还有一些工具可以减少网格的顶点数量,同时对形状的影响不大(使用 Editing 上下文的 Mesh 面板中的 Decimator 工具)。
要在商业项目中使用 Blender,必须花时间改进导出器脚本(这个脚本还不支持 M3G 的所有特性),还要谨慎地选择对 3D 场景进行建模的方式。花钱购买非免费软件包可能是值得的。另一方面,Blender 脚本可以在 GPL 许可协议下使用,所以任何人都可以改进它。脚本的作者最近添加了对纹理的支持。有人花时间为大家提供免费工具,这真是不错。 对齐和拾取
在将模型导入应用程序中之后,可以应用 M3G 提供的所有特性。我将展示两种最常用的特性 -- 对齐节点和拾取 -- 并与保留模式结合使用。 在开发高尔夫游戏时,希望在球飞行的过程中让摄像机总是将球显示在屏幕的正中。利用 Node.setAlignment(),一个节点(比如摄像机)可以自动朝向一个参照物(比如高尔夫球)。在下一个例子中,使用这种特性对文字进行转换,使它直接面对摄像机。 游戏中的另一个常见问题是选择一个对象,这称为拾取。如果设计一个射手并打出一发子弹,那么必须检查他是否打中了目标。Group.pick() 根据提供给它的原点和方向计算出一条射线。如果这条射线与一个网格相交,那么这个方法返回 true 并报告 "打中" 的网格对象。我将在屏幕中间绘制一个十字线,当文字处于十字线下时用这个方法报告。清单 5 演示如何使用对齐和拾取。 清单 5. 对齐和拾取
/** * Initializes the sample. */ protected void init() { // Get the singleton for 3D rendering. _graphics3d = Graphics3D.getInstance(); try { // Load World from M3G binary file. Object3D[] objects = Loader.load(M3G_FILE_NAME); _world = (World) objects[0]; // Change the camera's properties to match the current device. Camera camera = _world.getActiveCamera(); float aspect = (float) getWidth() / (float) getHeight(); camera.setPerspective(60.0f, aspect, 1.0f, 1000.0f); // Align the text with the camera. Mesh text = (Mesh) _world.find(USER_ID_TEXT); text.setAlignment(camera, Node.Y_AXIS, null, Node.NONE); text.align(null); // Load crosshair images. try { _crossHairImageOn = Image.createImage(CROSS_HAIR_ON); _crossHairImageOff = Image.createImage(CROSS_HAIR_OFF); } catch (Exception e) {} } catch (Exception e) { e.printStackTrace(); } } /** * Renders the sample on the screen. * * @param graphics the graphics object to draw on. */ protected void paint(Graphics graphics) { _graphics3d.bindTarget(graphics); _graphics3d.render(_world); _graphics3d.releaseTarget(); // Draw crosshair. if ((_crossHairImageOff != null) && (_crossHairImageOn != null)) { graphics.drawImage(_crossHairOn ? _crossHairImageOn : _crossHairImageOff, getWidth()/2, getHeight()/2, Graphics.VCENTER | Graphics.HCENTER); } drawMenu(graphics); } /** * Handles key presses. * * @param keyCode key code. */ protected void keyPressed(int keyCode) { Mesh text = (Mesh) _world.find(USER_ID_TEXT); switch (getGameAction(keyCode)) { case LEFT: text.postRotate(2.0f, 0.0f, 0.0f, 1.0f); break; case RIGHT: text.postRotate(-2.0f, 0.0f, 0.0f, 1.0f); break; case FIRE: init(); break; // no default } // Check whether ray cast by crosshair intersects with the text. _crossHairOn = isHit(); repaint(); } /** * Checks whether a ray that originates in the middle of the viewport and has * the same direction as the active camera intersects with the text. * * @return true if there's an intersection. */ private boolean isHit() { boolean isHit = false; RayIntersection rayIntersection = new RayIntersection(); Mesh mesh = (Mesh) _world.find(USER_ID_TEXT); if (_world.pick(-1, 0.5f, 0.5f, _world.getActiveCamera(), rayIntersection)) { if (rayIntersection.getIntersected() == mesh) { isHit = true; } } return isHit; }
在装载 M3G 文件之后,在 init() 中调用 setAlignment() 来设置文字网格的对齐信息。前两个参数定义 z 轴对齐;后两个参数定义 y 轴对齐。我让文字的 z 轴朝向摄像机的 y 轴,文字的 y 轴不变。坐标轴的选择要考虑到 Blender 和 M3G 坐标系之间的差异。文字网格上的实际转换由 align() 调用来执行。如果希望持续跟踪一个对象,应该在参照物每次移动时调用 align()。init() 还创建两个图像,它们将用于在 paint() 中绘制十字线。 根据布尔值 _crossHairOn,十字线要么是黑色的(_crossHairImageOff)要么是白色的(_crossHairImageOn)。可以很容易地混合调用 Graphics 和 Graphics3D。要确保 Graphics3D 调用在 bindTarget() 和 releaseTarget() 之间执行,Graphics 调用在外面执行。十字线的状态在 keyPressed() 中决定。 当按下对应的键时,keyPressed() 将文字向左或向右旋转。同时,isHit() 检查十字线是否瞄准了文字。在这个方法中使用 Group.pick(),它有两个变体,一个取任意射线原点和目的地作为参数,另一个在视口 坐标系中定义射线。 视口是 M3G 将图形投影到的平面。它的原点在左上角,坐标从 0 到 1。因为想要基于当前摄像机拾取网格,所以后一种变体更方便。坐标 (0.5, 0.5) 表示屏幕的正中,因为在默认情况下视口与屏幕一样大。如果报告相交,那么修改 _crossHairOn 的值并显示 图 6 所示的十字线图像。AlignmentPickingSample.java 包含这个示例的完整源代码。 图 6. 对齐摄像机的文字:a)十字线与文字不相交,b)十字线与文字相交
结束语
在这个系列的两篇文章中,我介绍了为手机开发 3D 应用程序的基础知识。快速模式直接在屏幕上渲染 3D 对象。另一方面,保留模式允许建立场景图,然后操作和渲染场景图。对于快速模式,理解如何手工创建 3D 场景是很重要的。另一种方式是使用 M3G 的二进制文件格式从 3D 工具中导入模型。用这种方式可以创建复杂的 3D 世界并有助于将开发人员和艺术家的工作分开。
我没有提到动画序列、雾化、分层、3D 子图、渐变网格或表示骨骼动画多边形的网格。这些留给您自己探索。