touchButton3D.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. // Assumptions: absolute position of button mesh is inside the mesh
  2. import { DeepImmutableObject } from "babylonjs/types";
  3. import { Vector3, Quaternion } from "babylonjs/Maths/math.vector";
  4. import { Mesh } from "babylonjs/Meshes/mesh";
  5. import { AbstractMesh } from "babylonjs/Meshes/abstractMesh";
  6. import { TransformNode } from "babylonjs/Meshes/transformNode";
  7. import { Scene } from "babylonjs/scene";
  8. import { Ray } from "babylonjs/Culling/ray";
  9. import { Button3D } from "./button3D";
  10. /**
  11. * Enum for Button States
  12. */
  13. enum ButtonState {
  14. /** None */
  15. None = 0,
  16. /** Pointer Entered */
  17. Hover = 1,
  18. /** Pointer Down */
  19. Press = 2
  20. }
  21. /**
  22. * Class used to create a touchable button in 3D
  23. */
  24. export class TouchButton3D extends Button3D {
  25. private _collisionMesh: Mesh;
  26. private _collidableFrontDirection: Vector3;
  27. private _lastTouchPoint: Vector3;
  28. private _tempButtonForwardRay: Ray;
  29. private _lastKnownCollidableScale: Vector3;
  30. private _collidableInitialized = false;
  31. private _frontOffset = 0;
  32. private _backOffset = 0;
  33. private _hoverOffset = 0;
  34. private _pushThroughBackOffset = 0;
  35. private _activeInteractions = new Map<number, ButtonState>();
  36. private _previousHeight = new Map<number, number>();
  37. /**
  38. * Creates a new touchable button
  39. * @param name defines the control name
  40. * @param collisionMesh mesh to track collisions with
  41. */
  42. constructor(name?: string, collisionMesh?: Mesh) {
  43. super(name);
  44. this._tempButtonForwardRay = new Ray(Vector3.Zero(), Vector3.Zero());
  45. if (collisionMesh) {
  46. this.collisionMesh = collisionMesh;
  47. }
  48. }
  49. /**
  50. * Sets the front-facing direction of the button
  51. * @param frontDir the forward direction of the button
  52. */
  53. public set collidableFrontDirection(frontWorldDir: Vector3) {
  54. this._collidableFrontDirection = frontWorldDir.normalize();
  55. // Zero out the scale to force it to be proplerly updated in _updateDistanceOffsets
  56. this._lastKnownCollidableScale = Vector3.Zero();
  57. this._updateDistanceOffsets();
  58. }
  59. private _getWorldMatrixData(mesh: Mesh) {
  60. let translation = Vector3.Zero();
  61. let rotation = Quaternion.Identity();
  62. let scale = Vector3.Zero();
  63. mesh.getWorldMatrix().decompose(scale, rotation, translation);
  64. return {translation: translation, rotation: rotation, scale: scale};
  65. }
  66. /**
  67. * Sets the mesh used for testing input collision
  68. * @param collisionMesh the new collision mesh for the button
  69. */
  70. public set collisionMesh(collisionMesh: Mesh) {
  71. if (this._collisionMesh) {
  72. this._collisionMesh.dispose();
  73. }
  74. // parent the mesh to sync transforms
  75. if (!collisionMesh.parent && this.mesh) {
  76. collisionMesh.setParent(this.mesh);
  77. }
  78. this._collisionMesh = collisionMesh;
  79. this._collisionMesh.metadata = this;
  80. this.collidableFrontDirection = collisionMesh.forward;
  81. this._collidableInitialized = true;
  82. }
  83. /*
  84. * Given a point, and two points on a line, this returns the distance between
  85. * the point and the closest point on the line. The closest point on the line
  86. * does not have to be between the two given points.
  87. *
  88. * Based off the 3D point-line distance equation
  89. *
  90. * Assumes lineDirection is normalized
  91. */
  92. private _getShortestDistancePointToLine(point: Vector3, linePoint: Vector3, lineDirection: Vector3) {
  93. const pointToLine = linePoint.subtract(point);
  94. const cross = lineDirection.cross(pointToLine);
  95. return cross.length();
  96. }
  97. /*
  98. * Checks to see if collidable is in a position to interact with the button
  99. * - check if collidable has a plane height off the button that is within range
  100. * - check that collidable + normal ray intersect the bounding sphere
  101. */
  102. private _isPrimedForInteraction(collidable: Vector3): boolean {
  103. // Check if the collidable has an appropriate planar height
  104. const heightFromCenter = this._getHeightFromButtonCenter(collidable);
  105. if (heightFromCenter > this._hoverOffset || heightFromCenter < this._pushThroughBackOffset) {
  106. return false;
  107. }
  108. // Check if the collidable or its hover ray lands within the bounding sphere of the button
  109. const distanceFromCenter = this._getShortestDistancePointToLine(this._collisionMesh.getAbsolutePosition(),
  110. collidable,
  111. this._collidableFrontDirection);
  112. return distanceFromCenter <= this._collisionMesh.getBoundingInfo().boundingSphere.radiusWorld;
  113. }
  114. /*
  115. * Returns a Vector3 of the collidable's projected position on the button
  116. * Returns the collidable's position if it is inside the button
  117. */
  118. private _getPointOnButton(collidable: Vector3): Vector3 {
  119. const heightFromCenter = this._getHeightFromButtonCenter(collidable);
  120. if (heightFromCenter <= this._frontOffset && heightFromCenter >= this._backOffset) {
  121. // The collidable is in the button, return its position
  122. return collidable;
  123. }
  124. else if (heightFromCenter > this._frontOffset) {
  125. // The collidable is in front of the button, project it to the surface
  126. const collidableDistanceToFront = (this._frontOffset - heightFromCenter);
  127. return collidable.add(this._collidableFrontDirection.scale(collidableDistanceToFront));
  128. }
  129. else {
  130. // The collidable is behind the button, project it to its back
  131. const collidableDistanceToBack = (this._backOffset - heightFromCenter);
  132. return collidable.add(this._collidableFrontDirection.scale(collidableDistanceToBack));
  133. }
  134. }
  135. /*
  136. * Updates the distance values.
  137. * Should be called when the front direction changes, or the mesh size changes
  138. *
  139. * Sets the following values:
  140. * _frontOffset
  141. * _backOffset
  142. * _hoverOffset
  143. * _pushThroughBackOffset
  144. *
  145. * Requires population of:
  146. * _collisionMesh
  147. * _collidableFrontDirection
  148. */
  149. private _updateDistanceOffsets() {
  150. let worldMatrixData = this._getWorldMatrixData(this._collisionMesh);
  151. if (!worldMatrixData.scale.equalsWithEpsilon(this._lastKnownCollidableScale)) {
  152. const collisionMeshPos = this._collisionMesh.getAbsolutePosition();
  153. this._tempButtonForwardRay.origin = collisionMeshPos;
  154. this._tempButtonForwardRay.direction = this._collidableFrontDirection;
  155. const frontPickingInfo = this._tempButtonForwardRay.intersectsMesh(this._collisionMesh as DeepImmutableObject<AbstractMesh>);
  156. this._tempButtonForwardRay.direction = this._tempButtonForwardRay.direction.negate();
  157. const backPickingInfo = this._tempButtonForwardRay.intersectsMesh(this._collisionMesh as DeepImmutableObject<AbstractMesh>);
  158. this._frontOffset = 0;
  159. this._backOffset = 0;
  160. if (frontPickingInfo.hit && backPickingInfo.hit) {
  161. this._frontOffset = this._getDistanceOffPlane(frontPickingInfo.pickedPoint!,
  162. this._collidableFrontDirection,
  163. collisionMeshPos);
  164. this._backOffset = this._getDistanceOffPlane(backPickingInfo.pickedPoint!,
  165. this._collidableFrontDirection,
  166. collisionMeshPos);
  167. }
  168. // For now, set the hover height equal to the thickness of the button
  169. const buttonThickness = this._frontOffset - this._backOffset;
  170. this._hoverOffset = this._frontOffset + (buttonThickness * 1.25);
  171. this._pushThroughBackOffset = this._backOffset - (buttonThickness * 1.5);
  172. this._lastKnownCollidableScale = this._getWorldMatrixData(this._collisionMesh).scale;
  173. }
  174. }
  175. // Returns the distance in front of the center of the button
  176. // Returned value is negative when collidable is past the center
  177. private _getHeightFromButtonCenter(collidablePos: Vector3) {
  178. return this._getDistanceOffPlane(collidablePos, this._collidableFrontDirection, this._collisionMesh.getAbsolutePosition());
  179. }
  180. // Returns the distance from pointOnPlane to point along planeNormal
  181. private _getDistanceOffPlane(point: Vector3, planeNormal: Vector3, pointOnPlane: Vector3) {
  182. const d = Vector3.Dot(pointOnPlane, planeNormal);
  183. const abc = Vector3.Dot(point, planeNormal);
  184. return abc - d;
  185. }
  186. // Updates the stored state of the button, and fire pointer events
  187. private _updateButtonState(id: number, newState: ButtonState, pointOnButton: Vector3) {
  188. const dummyPointerId = 0;
  189. const buttonIndex = 0; // Left click
  190. const buttonStateForId = this._activeInteractions.get(id) || ButtonState.None;
  191. // Take into account all inputs interacting with the button to avoid state flickering
  192. let previousPushDepth = 0;
  193. this._activeInteractions.forEach(function(value, key) {
  194. previousPushDepth = Math.max(previousPushDepth, value);
  195. });
  196. if (buttonStateForId != newState) {
  197. if (newState == ButtonState.None) {
  198. this._activeInteractions.delete(id);
  199. }
  200. else {
  201. this._activeInteractions.set(id, newState);
  202. }
  203. }
  204. let newPushDepth = 0;
  205. this._activeInteractions.forEach(function(value, key) {
  206. newPushDepth = Math.max(newPushDepth, value);
  207. });
  208. if (newPushDepth == ButtonState.Press) {
  209. if (previousPushDepth == ButtonState.Hover) {
  210. this._onPointerDown(this, pointOnButton, dummyPointerId, buttonIndex);
  211. }
  212. else if (previousPushDepth == ButtonState.Press) {
  213. this._onPointerMove(this, pointOnButton);
  214. }
  215. }
  216. else if (newPushDepth == ButtonState.Hover) {
  217. if (previousPushDepth == ButtonState.None) {
  218. this._onPointerEnter(this);
  219. }
  220. else if (previousPushDepth == ButtonState.Press) {
  221. this._onPointerUp(this, pointOnButton, dummyPointerId, buttonIndex, false);
  222. }
  223. else {
  224. this._onPointerMove(this, pointOnButton);
  225. }
  226. }
  227. else if (newPushDepth == ButtonState.None) {
  228. if (previousPushDepth == ButtonState.Hover) {
  229. this._onPointerOut(this);
  230. }
  231. else if (previousPushDepth == ButtonState.Press) {
  232. this._onPointerUp(this, pointOnButton, dummyPointerId, buttonIndex, false);
  233. this._onPointerOut(this);
  234. }
  235. }
  236. }
  237. // Decides whether to change button state based on the planar depth of the input source
  238. /** @hidden */
  239. public _collisionCheckForStateChange(mesh: AbstractMesh) {
  240. if (this._collidableInitialized) {
  241. this._updateDistanceOffsets();
  242. const collidablePosition = mesh.getAbsolutePosition();
  243. const inRange = this._isPrimedForInteraction(collidablePosition);
  244. const uniqueId = mesh.uniqueId;
  245. let activeInteraction = this._activeInteractions.get(uniqueId);
  246. if (inRange) {
  247. const pointOnButton = this._getPointOnButton(collidablePosition);
  248. const heightFromCenter = this._getHeightFromButtonCenter(collidablePosition);
  249. const flickerDelta = 0.003;
  250. this._lastTouchPoint = pointOnButton;
  251. const isGreater = function (compareHeight: number) {
  252. return heightFromCenter >= (compareHeight + flickerDelta);
  253. };
  254. const isLower = function (compareHeight: number) {
  255. return heightFromCenter <= (compareHeight - flickerDelta);
  256. };
  257. // Update button state and fire events
  258. switch (activeInteraction || ButtonState.None) {
  259. case ButtonState.None:
  260. if (isGreater(this._frontOffset) &&
  261. isLower(this._hoverOffset)) {
  262. this._updateButtonState(uniqueId, ButtonState.Hover, pointOnButton);
  263. }
  264. break;
  265. case ButtonState.Hover:
  266. if (isGreater(this._hoverOffset)) {
  267. this._updateButtonState(uniqueId, ButtonState.None, pointOnButton);
  268. }
  269. else if (isLower(this._frontOffset)) {
  270. this._updateButtonState(uniqueId, ButtonState.Press, pointOnButton);
  271. }
  272. break;
  273. case ButtonState.Press:
  274. if (isGreater(this._frontOffset)) {
  275. this._updateButtonState(uniqueId, ButtonState.Hover, pointOnButton);
  276. }
  277. else if (isLower(this._pushThroughBackOffset)) {
  278. this._updateButtonState(uniqueId, ButtonState.None, pointOnButton);
  279. }
  280. break;
  281. }
  282. this._previousHeight.set(uniqueId, heightFromCenter);
  283. }
  284. else if ((activeInteraction != undefined) && (activeInteraction != ButtonState.None)) {
  285. this._updateButtonState(uniqueId, ButtonState.None, this._lastTouchPoint);
  286. this._previousHeight.delete(uniqueId);
  287. }
  288. }
  289. }
  290. protected _getTypeName(): string {
  291. return "TouchButton3D";
  292. }
  293. // Mesh association
  294. protected _createNode(scene: Scene): TransformNode {
  295. return super._createNode(scene);
  296. }
  297. /**
  298. * Releases all associated resources
  299. */
  300. public dispose() {
  301. super.dispose();
  302. if (this._collisionMesh) {
  303. this._collisionMesh.dispose();
  304. }
  305. }
  306. }