orillusion入门系列六 | 系统交互01
-
经过几次的学习已经能够构建出一个空间(场景),并在空间中创建物体(几何体),物体可以有不同的外观(材质),与现实的效果足够逼真(光照),终于把最重要的相关性最强的几部分3D功能用起来了。不过面对这块空间想做点什么,又感觉缺少了点什么,是的,只能观看不能操作,如果我要通过键盘、鼠标对场景进行实时的干预该如何做呢,经过了解输入系统可以满足我们的要求。
输入系统是个比较杂乱的部分,不同平台都有对应的封装,我们可以回忆一下Win32编程将键盘和鼠标的输入集成到了事件系统,用户操作按键或操作鼠标会触发对应的消息码,指示消息,附带参数包含具体的按键信息或鼠标信息,按键信息一般包含按键码或鼠标键位。再回忆一下DOM的事件系统,使用addEventListener将click或mouse类的事件挂载,然后在回调函数中获得结果……
回忆结束我们可以总结出来几个输入系统的特点:1、挂载感兴趣的事件;2、回调函数得到触发时处理业务逻辑。需要注意的是,键盘需要有按键表进行区分按键,对应的是鼠标需要区分不同按键,以及屏幕坐标,辅助键等一些附属信息。键盘输入
使用键盘输入,首先需要熟悉两种键盘事件:
- KEY_DOWN:键盘按下事件,使用输入系统挂载该事件,将会得到按下键盘事件通知;
- KEY_UP:键盘弹起事件,使用输入系统挂载该事件,将会得到弹起键盘事件通知;
下面来一起梳理一下使用流程:
- 初始化:必要的引擎初始化;
// 创建一个场景 let scene = new Scene3D(); // // 创建一个相机 let cameraObj = new Object3D(); let camera = this.cameraObj.addComponent(Camera3D); // // 设置相机类型 camera.perspective(60, window.innerWidth / window.innerHeight, 1, 5000.0);
- 输入挂载:使用键盘挂载系统指定事件和回调;
Engine3D.inputSystem.addEventListener(KeyEvent.KEY_UP, keyUp, this); Engine3D.inputSystem.addEventListener(KeyEvent.KEY_DOWN, keyDown, this);
- 处理回调:在回调中获取参数。
private keyDown(e: KeyEvent) { console.log('keyDown:', e.keyCode, e); } private keyUp(e: KeyEvent) { console.log('keyUp:', e.keyCode, e); }
以上是最简单的能够获得键盘输入的步骤。
完整示例
上一节的示例似乎没有实际的使用价值,键盘一定是对应着具体的业务目标,这里我们写一个完全的例子,在场景中创建一个立方体,通过键盘来控制视角的拉远与接近。
import { Engine3D, Scene3D, Object3D, Camera3D, HoverCameraController, ForwardRenderJob, KeyEvent, KeyCode, MeshRenderer, BoxGeometry, LitMaterial, Vector3 } from "@orillusion/core"; export default class Keyboard { cameraObj: Object3D; camera: Camera3D; scene: Scene3D; controller: HoverCameraController; async run() { await this.init(); await this.setup(); await this.start(); } /*** * 配置并初始化引擎 */ private async init() { // 初始化引擎 await Engine3D.init(); // 创建一个场景 this.scene = new Scene3D(); // // 创建一个相机 this.cameraObj = new Object3D(); this.camera = this.cameraObj.addComponent(Camera3D); // // 设置相机类型 this.camera.perspective(60, window.innerWidth / window.innerHeight, 1, 5000.0); // // 设置相机控制器 this.controller = this.cameraObj.addComponent(HoverCameraController); this.controller.setCamera(20, -20, 25); // 添加相机至场景 this.scene.addChild(this.cameraObj); let boxObj = new Object3D(); let mr: MeshRenderer = boxObj.addComponent(MeshRenderer); mr.geometry = new BoxGeometry(3, 3, 3); // 设置材质 mr.material = new LitMaterial(); // 设置位置 boxObj.localPosition = new Vector3(0, 0, 0); // 添加对象至场景 this.scene.addChild(boxObj); } /** * 引擎功能代码 */ private async setup() { Engine3D.inputSystem.addEventListener(KeyEvent.KEY_UP, this.keyUp, this); Engine3D.inputSystem.addEventListener(KeyEvent.KEY_DOWN, this.keyDown, this); } /** * 启动渲染 */ private async start() { let view = new View3D(); // 指定渲染的场景 view.scene = this.scene; // 指定使用的相机 view.camera = this.camera; // 开始渲染 Engine3D.startRenderView(view); } private keyDown(e: KeyEvent) { console.log('keyDown:', e.keyCode, e); if (e.keyCode == KeyCode.Key_W) { this.controller.distance -= 1; } else if (e.keyCode == KeyCode.Key_S) { this.controller.distance += 1; } } private keyUp(e: KeyEvent) { console.log('keyUp:', e.keyCode, e); } }
这是一个最基础的示例,只有场景和相机组件,运行这个示例后操作键盘,在控制台能够看到结果。在一个实际应用中,在响应事件可以实现对应的业务需求。
运行这个示例能够看到只有当键盘按下时才会有效果,那如何实现持续的按键功能呢,可以通过脚本来实现。创建一个脚本,键盘按下时开启变量,键盘弹起时关闭变量,在更新函数中根据变量来调整视距。class KeyboardScript extends ComponentBase { private front: boolean = false; private back: boolean = false; protected start() { Engine3D.inputSystem.addEventListener(KeyEvent.KEY_UP, this.keyUp, this); Engine3D.inputSystem.addEventListener(KeyEvent.KEY_DOWN, this.keyDown, this); } private keyDown(e: KeyEvent) { // console.log('keyDown:', e.keyCode); if (e.keyCode == KeyCode.Key_W) { this.front = true; } else if (e.keyCode == KeyCode.Key_S) { this.back = true; } } private keyUp(e: KeyEvent) { // console.log('keyUp:', e.keyCode); if (e.keyCode == KeyCode.Key_W) { this.front = false; } else if (e.keyCode == KeyCode.Key_S) { this.back = false; } } onUpdate() { if (!this.enable) return; let transform = this.object3D.transform; if (this.front) transform.z -= 1; if (this.back) transform.z += 1; } }
将该脚本添加至对象即可
boxObj.addComponent(KeyboardScript);
再运行这个实例,在键盘持续入下时能够看到持续平滑的效果。这是这个实现是通过调整物体的坐标来实现的。系统实现
在回调函数中获得的参数类型是KeyEvent,KeyEvent是CEvent的子类,除了CEvent类的参数外,对于键盘事件的使用主要在于对该类型的解析,keyCode是引擎的宏定义,具体按键定义可以参考官方文档。
出于好奇orilluson的输入系统如何实现的,找来源码进行了一个大体的了解,可以看到输入系统的核心类是InputSystem,该类继承于CEventDispatcher类,CEventDispatcher类是可调度事件的所有类的基类,包含了事件的注册,注销,分发和清理等功能实现。内部保存了监听对象列表,当有消息需要处理时通过遍历监听器列表触发回调函数。InputSystem继承了CEventDispatcher类的事件处理能力外着重实现了键盘鼠标的事件处理。
具体执行步骤如下:- Engine3D.init:初始化引擎后,实例化了InputSystem类,并将canvas实例传入InputSystem类;
- InputSystem.initCanvas:InputSystem监听了画布的键盘与鼠标事件;
- addEventListener:引擎或对象通过addEventListener函数来挂载用户监听;
- dispatchEvent:当有挂载的监听事件响应时,回调函数会得执行。
在输入系统的支持下,可以很轻松的使用键盘和鼠标与触控。
输入系统的回调事件在类CEvent中,先熟悉一下这个类的常用定义:- type:事件类型对应的一个字符串常量;
- param:注册事件时传递的参数,在注册事件时写入的参数在这里可以读出;
- ctrlKey:事件发生时 Ctrl 是否被按下,通过查询该键的值来判断Ctrl键的状态;
- altKey:事件发生时 Alt 是否被按下,通过查询该键的值来判断Alt键的状态;
- shiftKey:事件发生时 Shift 是否被按下,通过查询该键的值来判断Shift键的状态;
鼠标与触控
电脑端的鼠标操作与移动端的触控操作有许多共同的地方,在具体用法时如果能够合并为一,是可以节省一半的事件挂载操作的,不过需要留意触控与鼠标的事件对应关系。
有了前面键盘操作的基础,鼠标与触控使用类型,我们先看支持的事件类型:- POINTER_CLICK:触摸点击事件,对应鼠标的单击事件;
- POINTER_MOVE:触摸滑动事件,对应鼠标的移动事件
- POINTER_DOWN:触摸开始事件,
- POINTER_UP:触摸结束事件
- POINTER_OUT:触摸滑出事件
既然已经合并了,后面鼠标与触控用触控来说明吧。
基础示例
先实现一个最基础的触控功能,与键盘类似,先注册事件,然后响应事件。
import { Engine3D, Scene3D, Object3D, Camera3D, HoverCameraController, ForwardRenderJob, PointerEvent3D } from "@orillusion/core"; export default class Mouse { cameraObj: Object3D; camera: Camera3D; scene: Scene3D; async run() { await this.init(); await this.setup(); await this.start(); } /*** * 配置并初始化引擎 */ private async init() { // 初始化引擎 await Engine3D.init(); // 创建一个场景 this.scene = new Scene3D(); // 创建一个相机 this.cameraObj = new Object3D(); this.camera = this.cameraObj.addComponent(Camera3D); // 设置相机类型 this.camera.perspective(60, window.innerWidth / window.innerHeight, 1, 5000.0); // 设置相机控制器 let controller = this.cameraObj.addComponent(HoverCameraController); controller.setCamera(20, -20, 25); // 添加相机至场景 this.scene.addChild(this.cameraObj); } /** * 引擎功能代码 */ private async setup() { Engine3D.inputSystem.addEventListener(PointerEvent3D.PICK_UP, this.onUp, this); Engine3D.inputSystem.addEventListener(PointerEvent3D.PICK_DOWN, this.onDown, this); Engine3D.inputSystem.addEventListener(PointerEvent3D.PICK_CLICK, this.onPick, this); Engine3D.inputSystem.addEventListener(PointerEvent3D.PICK_OVER, this.onOver, this); Engine3D.inputSystem.addEventListener(PointerEvent3D.PICK_OUT, this.onOut, this); Engine3D.inputSystem.addEventListener(PointerEvent3D.PICK_MOVE, this.onMove, this); } /** * 启动渲染 */ private async start() { let view = new View3D(); // 指定渲染的场景 view.scene = this.scene; // 指定使用的相机 view.camera = this.camera; // 开始渲染 Engine3D.startRenderView(view); } private onUp(e: PointerEvent3D) { console.log('onUp:',e); } private onDown(e: PointerEvent3D) { console.log('onDown:',e); } private onPick(e: PointerEvent3D) { console.log('onPick:',e); } private onOver(e: PointerEvent3D) { console.log('onOver:',e); } private onOut(e: PointerEvent3D) { console.log('onOut:',e); } private onMove(e: PointerEvent3D) { console.log('onMove:',e); } }
PointerEvent3D
触控的参数是以PointerEvent3D类型作为回调函数的参数传递到应用,PointerEvent3D是CEvent的子类,除了CEvent类的参数外,需要熟悉一下这个类型的关键字段。
- mouseX:当前鼠标所在位置的X坐标;
- mouseY:当前鼠标所在位置的Y坐标;
- movementX:当前事件和上一个鼠标事件之间鼠标在水平方向上的移动值;
- movementY:当前事件和上一个鼠标事件之间鼠标在垂直方向上的移动值;
坐标系列的数值请注意,可以使用前面相机组件提供的转换函数进行转换,不必自己写算法进行转换。
由对象挂载
前面的挂载直接由引擎的输入系统挂载,这样在整个场景中都会响应,如果只需要在一个物体中响应鼠标的事件,我们可以将事件挂在物体上,为什么可以这么做呢,找出来代码可以看到,物体的容器是Object3D类,而Object3D类是Entiry的子类,Entity的父类是CEventDispatcher类,正是因为Object3D通过CEventDispatcher,继承了事件的能力。
这样就有了以下的代码:// 创建一个对象 this.boxObj = new Object3D(); this.boxObj.localPosition = new Vector3(0,0,0); // 创建渲染组件 let mr: MeshRenderer = this.boxObj.addComponent(MeshRenderer); // 设置形状 mr.geometry = new BoxGeometry(5, 5, 5); // 设置材质 mr.material = new LitMaterial(); // 添加到场景 this.scene.addChild(this.boxObj); boxObj.addEventListener(PointerEvent3D.PICK_CLICK, this.onClick, this); onClick(e: PointerEvent3D) { console.log('onPick:',e); }
运行后可以在控制台看到输出
关于坐标
一直以来的学习路径是以实用为主,但是现在必须要接触一点点不能称之为理论的理论了,那就是坐标系统。
世界坐标
首先要解决一个困惑的地方,过去在3D空间中的所有坐标都可以称为世界坐标,世界坐标是三维的,有三个维度(x,y,z),一般在引擎中创建可以由系统使用,开发用户程序需要遵守引擎对于世界的规划,相当于场景作为一个空间,世界坐标是对这个空间制定的规则。这里歪个楼,骇客帝国之所以叫矩阵,是不是因为在3D引擎中对空间世界的处理也是以矩阵为基础的。再拉回来,世界坐标一般以(0,0,0)为中心,我们创建的物体默认的位置也是在这里的,这里是世界的中心,一般分为右手或左手坐标系,好了关于世界坐标系这里已经够用了。
屏幕坐标
说回到屏幕坐标是我们过去所熟悉的,首先屏幕坐标是一个二维坐标,以像素为单位,屏幕的左下角为起点,向屏幕的左侧和上方依次是x和y坐标的正向。在网页开发中我们通过DOM事件系统获得的当前坐标一般都是指的屏幕坐标。在网页开发中并不是绝对的没有z轴,CSS中的z-index属性是否可以理解成一种z轴坐标呢。
相互转换
屏幕坐标是我们最终渲染到屏的最终展现形式,世界坐标是在三维空间内的标识,两者经常需要相互转换,例如今天需要讨论的输入系统的使用。假设在屏幕上点击了一个位置,需要转换到世界坐标,相似的在世界坐标内的位置或距离也需要转换为屏幕坐标。
坐标转换有标准的算法,这里我们不必如此费力,完全可以借助引擎的工具,经过一翻查找,在相机组件的实现类Camera3D,有坐标转换的工具,可以一起熟悉一下- object3DToScreenRay:世界坐标转换屏幕坐标;
// 定义一个世界坐标 let pos3D = Vector3(); // 转换为屏幕坐标 let posScreen = Engine.camera.object3DToScreenRay(pos3D);
- ScreenRayToObject3D:屏幕坐标转换为世界坐标;
// 定义一个屏幕 let posScreen = Vector3(); // 转换为屏幕坐标 let pos3D= Engine.camera.ScreenRayToObject3D(posScreen );
小结
经过前面几次的学习,已经能够完事的构建出一个空间了,但是这块空间仍然缺乏灵动的能力,不能随时响应我们的操控,输入系统是一个随时干预系统的大杀器,可以让我们获得掌控感,是否控制欲获得了满足。
今天只是一些最基础的用法,发挥想象力可以使这个空间好玩起来了。作为3D新手,后续会不断的记录学习过程,期待与你一起学习一起飞!