From 25754966d863ab47713cefc3ba9961f53ae42a73 Mon Sep 17 00:00:00 2001 From: Andrew Prifer Date: Thu, 12 Jan 2023 20:12:27 +0100 Subject: [PATCH 01/16] Initial commit --- packages/dualsense-bindings/.gitignore | 1 + packages/dualsense-bindings/LICENSE | 203 +++++ .../devEnv/api-extractor.json | 36 + .../devEnv/api-extractor.tsconfig.json | 7 + packages/dualsense-bindings/devEnv/build.ts | 55 ++ .../dualsense-bindings/devEnv/tsconfig.json | 3 + packages/dualsense-bindings/package.json | 51 ++ .../dualsense-bindings/src/bindingCreators.ts | 142 ++++ packages/dualsense-bindings/src/ds-hid.ts | 718 ++++++++++++++++++ packages/dualsense-bindings/src/index.ts | 110 +++ packages/dualsense-bindings/tsconfig.json | 14 + .../src/shared/dualsense-bindings/App.tsx | 200 +++++ .../src/shared/dualsense-bindings/index.tsx | 10 + packages/playground/tsconfig.json | 3 +- tsconfig.base.json | 3 + yarn.lock | 69 +- 16 files changed, 1621 insertions(+), 4 deletions(-) create mode 100644 packages/dualsense-bindings/.gitignore create mode 100644 packages/dualsense-bindings/LICENSE create mode 100644 packages/dualsense-bindings/devEnv/api-extractor.json create mode 100644 packages/dualsense-bindings/devEnv/api-extractor.tsconfig.json create mode 100644 packages/dualsense-bindings/devEnv/build.ts create mode 100644 packages/dualsense-bindings/devEnv/tsconfig.json create mode 100644 packages/dualsense-bindings/package.json create mode 100644 packages/dualsense-bindings/src/bindingCreators.ts create mode 100644 packages/dualsense-bindings/src/ds-hid.ts create mode 100644 packages/dualsense-bindings/src/index.ts create mode 100644 packages/dualsense-bindings/tsconfig.json create mode 100644 packages/playground/src/shared/dualsense-bindings/App.tsx create mode 100644 packages/playground/src/shared/dualsense-bindings/index.tsx diff --git a/packages/dualsense-bindings/.gitignore b/packages/dualsense-bindings/.gitignore new file mode 100644 index 0000000000..3e22129247 --- /dev/null +++ b/packages/dualsense-bindings/.gitignore @@ -0,0 +1 @@ +/dist \ No newline at end of file diff --git a/packages/dualsense-bindings/LICENSE b/packages/dualsense-bindings/LICENSE new file mode 100644 index 0000000000..6b0b1270ff --- /dev/null +++ b/packages/dualsense-bindings/LICENSE @@ -0,0 +1,203 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/packages/dualsense-bindings/devEnv/api-extractor.json b/packages/dualsense-bindings/devEnv/api-extractor.json new file mode 100644 index 0000000000..f8015076a1 --- /dev/null +++ b/packages/dualsense-bindings/devEnv/api-extractor.json @@ -0,0 +1,36 @@ +/** + * Config file for API Extractor. For more info, please visit: https://api-extractor.com + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + + /** + * Optionally specifies another JSON config file that this file extends from. This provides a way for + * standard settings to be shared across multiple projects. + * + * If the path starts with "./" or "../", the path is resolved relative to the folder of the file that contains + * the "extends" field. Otherwise, the first path segment is interpreted as an NPM package name, and will be + * resolved using NodeJS require(). + * + * SUPPORTED TOKENS: none + * DEFAULT VALUE: "" + */ + "extends": "../../../devEnv/api-extractor-base.json", + // "extends": "my-package/include/api-extractor-base.json" + + /** + * Determines the "" token that can be used with other config file settings. The project folder + * typically contains the tsconfig.json and package.json config files, but the path is user-defined. + * + * The path is resolved relative to the folder of the config file that contains the setting. + * + * The default value for "projectFolder" is the token "", which means the folder is determined by traversing + * parent folders, starting from the folder containing api-extractor.json, and stopping at the first folder + * that contains a tsconfig.json file. If a tsconfig.json file cannot be found in this way, then an error + * will be reported. + * + * SUPPORTED TOKENS: + * DEFAULT VALUE: "" + */ + "projectFolder": ".." +} diff --git a/packages/dualsense-bindings/devEnv/api-extractor.tsconfig.json b/packages/dualsense-bindings/devEnv/api-extractor.tsconfig.json new file mode 100644 index 0000000000..bcc091f626 --- /dev/null +++ b/packages/dualsense-bindings/devEnv/api-extractor.tsconfig.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../tsconfig.json", + "compilerOptions": { + "paths": {} + } +} diff --git a/packages/dualsense-bindings/devEnv/build.ts b/packages/dualsense-bindings/devEnv/build.ts new file mode 100644 index 0000000000..599d72096d --- /dev/null +++ b/packages/dualsense-bindings/devEnv/build.ts @@ -0,0 +1,55 @@ +import * as path from 'path' +import {build} from 'esbuild' +import type {Plugin} from 'esbuild' + +const externalPlugin = (patterns: RegExp[]): Plugin => { + return { + name: `external`, + + setup(build) { + build.onResolve({filter: /.*/}, (args) => { + const external = patterns.some((p) => { + return p.test(args.path) + }) + + if (external) { + return {path: args.path, external} + } + }) + }, + } +} + +const definedGlobals = { + global: 'window', +} + +function createBundles(watch: boolean) { + const pathToPackage = path.join(__dirname, '../') + const esbuildConfig: Parameters[0] = { + entryPoints: [path.join(pathToPackage, 'src/index.ts')], + bundle: true, + sourcemap: true, + define: definedGlobals, + watch, + platform: 'neutral', + mainFields: ['browser', 'module', 'main'], + target: ['firefox57', 'chrome58'], + conditions: ['browser', 'node'], + plugins: [externalPlugin([/^[\@a-zA-Z]+/])], + } + + build({ + ...esbuildConfig, + outfile: path.join(pathToPackage, 'dist/index.js'), + format: 'cjs', + }) + + // build({ + // ...esbuildConfig, + // outfile: path.join(pathToPackage, 'dist/index.mjs'), + // format: 'esm', + // }) +} + +createBundles(false) diff --git a/packages/dualsense-bindings/devEnv/tsconfig.json b/packages/dualsense-bindings/devEnv/tsconfig.json new file mode 100644 index 0000000000..077404aaa4 --- /dev/null +++ b/packages/dualsense-bindings/devEnv/tsconfig.json @@ -0,0 +1,3 @@ +{ + +} \ No newline at end of file diff --git a/packages/dualsense-bindings/package.json b/packages/dualsense-bindings/package.json new file mode 100644 index 0000000000..9805fd802c --- /dev/null +++ b/packages/dualsense-bindings/package.json @@ -0,0 +1,51 @@ +{ + "name": "@theatre/dualsense-bindings", + "version": "0.6.0-dev.3", + "license": "Apache-2.0", + "author": { + "name": "Andrew Prifer", + "email": "andrew.prifer@gmail.com", + "url": "https://github.com/AndrewPrifer" + }, + "repository": { + "type": "git", + "url": "https://github.com/theatre-js/theatre", + "directory": "packages/dualsense-bindings" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist/**/*" + ], + "scripts": { + "prepack": "node ../../devEnv/ensurePublishing.js", + "typecheck": "yarn run build", + "build": "run-s build:ts build:js build:api-json", + "build:ts": "tsc --build ./tsconfig.json", + "build:js": "node -r esbuild-register ./devEnv/build.ts", + "build:api-json": "api-extractor run --local --config devEnv/api-extractor.json", + "prepublish": "node ../../devEnv/ensurePublishing.js", + "clean": "rm -rf ./dist && rm -f tsconfig.tsbuildinfo" + }, + "devDependencies": { + "@microsoft/api-extractor": "^7.18.11", + "@types/jest": "^26.0.23", + "@types/node": "^15.6.2", + "@types/w3c-web-hid": "^1.0.2", + "esbuild": "^0.12.15", + "esbuild-register": "^2.5.0", + "npm-run-all": "^4.1.5", + "typescript": "^4.4.2" + }, + "dependencies": { + "ahrs": "^1.3.0", + "buffer": "^6.0.3", + "crc": "^4.1.0", + "lodash-es": "4.17.21", + "threejs-math": "^0.147.0" + }, + "peerDependencies": { + "@theatre/core": "workspace:*", + "@theatre/studio": "workspace:*" + } +} diff --git a/packages/dualsense-bindings/src/bindingCreators.ts b/packages/dualsense-bindings/src/bindingCreators.ts new file mode 100644 index 0000000000..2ecd52a074 --- /dev/null +++ b/packages/dualsense-bindings/src/bindingCreators.ts @@ -0,0 +1,142 @@ +import type {IScrub} from '@theatre/studio' +// Using the slightly larger threejs-math library instead of @math-gl +// because math-gl by default uses ZYX euler order, and some methods, notable fromQuaternion, +// don't allow you to specify a different order, which we need, because threejs defaults to XYZ. +import {Euler, Quaternion, Vector3} from 'threejs-math' +import type {API} from '.' +import {get} from 'lodash-es' + +export function createRotationBinding({propName = 'rotation'} = {}) { + let scrub: IScrub | null = null + let existingRotation: Quaternion | null = null + + return { + cross: (pressed: boolean, {orientation, object, studio}: API) => { + const propValue = get(object.value, propName) + + if (!propValue) { + console.warn(`Bindings: object has no ${propName} prop.`) + return + } + if (pressed) { + // "applies" the current orientation, so that it is treated as the "zero" orientation + orientation.apply() + scrub = studio.scrub() + existingRotation = new Quaternion().setFromEuler( + new Euler(propValue.x, propValue.y, propValue.z), + ) + } else { + if (scrub) { + scrub.commit() + scrub = null + } + existingRotation = null + } + }, + orientation(x: number, y: number, z: number, w: number, {object}: API) { + if (!scrub || !existingRotation) return + + const propPointer = get(object.props, propName) + + scrub.capture((api) => { + const rotation = new Euler().setFromQuaternion( + new Quaternion(x, y, z, w).premultiply(existingRotation!), + ) + + api.set(propPointer, { + x: rotation.x, + y: rotation.y, + z: rotation.z, + }) + }) + }, + } +} + +export type MovementPlane = 'xz' | 'xy' | 'yz' + +export function createPositionBinding({ + propName = 'position', + onStart: onActive = () => {}, + onEnd = () => {}, +}: { + propName?: string + onStart?: ( + movementPlane: MovementPlane, + originalPosition: [number, number, number], + ) => void + onEnd?: () => void +} = {}) { + let existingPosition: Vector3 | null = null + let movementPlane: MovementPlane | null = null + let scrub: IScrub | null = null + + const createButtonHandler = + (plane: MovementPlane) => + (pressed: boolean, {object, orientation, studio}: API) => { + const propValue = get(object.value, propName) + + if (!propValue) { + console.warn(`Bindings: object has no ${propName} prop.`) + return + } + + if (pressed) { + onActive(plane, [propValue.x, propValue.y, propValue.z]) + + movementPlane = plane + // "applies" the current orientation, so that it is treated as the "zero" orientation + orientation.apply() + scrub = studio.scrub() + existingPosition = new Vector3(propValue.x, propValue.y, propValue.z) + } else { + onEnd() + + if (scrub) { + scrub.commit() + scrub = null + } + existingPosition = null + movementPlane = null + } + } + + return { + square: createButtonHandler('xz'), + triangle: createButtonHandler('xy'), + circle: createButtonHandler('yz'), + orientation(x: number, y: number, z: number, w: number, {object}: API) { + if (!scrub || !existingPosition) return + + scrub.capture((api) => { + if (!object.value.rotation) return + + const propPointer = get(object.props, propName) + + const euler = new Euler().setFromQuaternion(new Quaternion(x, y, z, w)) + + if (movementPlane === 'xz') { + api.set(propPointer, { + x: existingPosition!.x - euler.y * 10, + y: existingPosition!.y, + z: existingPosition!.z - euler.x * 10, + }) + } + if (movementPlane === 'xy') { + api.set(propPointer, { + x: existingPosition!.x - euler.y * 10, + y: existingPosition!.y + euler.x * 10, + z: existingPosition!.z, + }) + } + if (movementPlane === 'yz') { + api.set(propPointer, { + x: existingPosition!.x, + y: existingPosition!.y + euler.x * 10, + z: existingPosition!.z + euler.y * 10, + }) + } + }) + }, + } +} diff --git a/packages/dualsense-bindings/src/ds-hid.ts b/packages/dualsense-bindings/src/ds-hid.ts new file mode 100644 index 0000000000..affe59684a --- /dev/null +++ b/packages/dualsense-bindings/src/ds-hid.ts @@ -0,0 +1,718 @@ +import AHRS from 'ahrs' +import crc32 from 'crc/crc32' +import {Buffer} from 'buffer/' + +const clamp = (num: number, min: number, max: number) => + Math.min(Math.max(num, min), max) + +if (typeof navigator.hid === 'undefined') { + console.error('DualSense.js requires WebHID support!') +} + +export enum DSControllerStatus { + CONNECTED = 'CONNECTED', + DISCONNECTED = 'DISCONNECTED', +} + +export enum DSControllerConnectionType { + USB = 'USB', + BLUETOOTH = 'BLUETOOTH', +} + +interface DSControllerStateChangeEvent { + state: DSControllerState +} + +type DSControllerEvent = DSControllerStateChangeEvent + +type DSControllerStateChangeListener = ( + event: DSControllerStateChangeEvent, +) => void +type DSControllerEventListener = DSControllerStateChangeListener +type DSControllerEventType = 'stateChange' + +export interface DSControllerLEDColor { + r: number + g: number + b: number +} + +export interface DSController { + get status(): DSControllerStatus + get connectionType(): DSControllerConnectionType + get productName(): string + set ledColor(color: DSControllerLEDColor) + set leftMotor(strength: number) + set rightMotor(strength: number) + disconnect(): Promise + addEventListener( + type: DSControllerEventType, + listener: DSControllerEventListener, + ): void + removeEventListener( + type: DSControllerEventType, + listener: DSControllerEventListener, + ): void + attitude: { + applyGyro: () => void + apply: () => void + reset: () => void + } +} + +export interface DSControllerTouchState { + active: boolean + id: number + x: number + y: number +} + +export interface DSControllerGyroState { + x: number + y: number + z: number +} + +export interface DSControllerAccelState { + x: number + y: number + z: number +} + +export interface DSControllerBatteryState { + percent: number + full: boolean + charging: boolean +} + +export interface DSControllerAttitude { + heading: number + pitch: number + roll: number +} + +export class DSControllerState { + leftStickX: number = 0 + leftStickY: number = 0 + rightStickX: number = 0 + rightStickY: number = 0 + leftTrigger: number = 0 + rightTrigger: number = 0 + + leftButton: boolean = false + rightButton: boolean = false + + square: boolean = false + cross: boolean = false + circle: boolean = false + triangle: boolean = false + + dpadUp: boolean = false + dpadDown: boolean = false + dpadLeft: boolean = false + dpadRight: boolean = false + + leftStickButton: boolean = false + rightStickButton: boolean = false + + optionsButton: boolean = false + createButton: boolean = false + psButton: boolean = false + touchButton: boolean = false + + touch0: DSControllerTouchState = {active: false, id: 0, x: 0, y: 0} + touch1: DSControllerTouchState = {active: false, id: 0, x: 0, y: 0} + + gyro: DSControllerGyroState = {x: 0, y: 0, z: 0} + + accel: DSControllerAccelState = {x: 0, y: 0, z: 0} + + battery: DSControllerBatteryState = { + percent: 0, + full: false, + charging: false, + } + + attitude: [number, number, number, number] = [0, 0, 0, 1] + + raw01: any = {} + raw31: any = {} +} + +export class DSControllerNotFoundError extends Error { + constructor() { + super( + 'No DualSense controller available (not connected or not selected by the user)', + ) + this.name = new.target.name + Object.setPrototypeOf(this, new.target.prototype) + } +} + +export class DSControllerNotConnected extends Error { + constructor() { + super('DualSense is not connected') + this.name = new.target.name + Object.setPrototypeOf(this, new.target.prototype) + } +} + +export class DSControllerIncompatible extends Error { + constructor() { + super('DualSense is not compatible') + this.name = new.target.name + Object.setPrototypeOf(this, new.target.prototype) + } +} + +const DS_VENDOR_ID = 0x054c +const DS_PRODUCT_ID = 0x0ce6 + +export async function connectController(): Promise { + let devices = await navigator.hid.requestDevice({ + filters: [ + { + vendorId: DS_VENDOR_ID, + productId: DS_PRODUCT_ID, + }, + ], + }) + + if (devices.length === 0) { + throw new DSControllerNotFoundError() + } else { + let device = devices[0] + await device.open() + return new DSControllerImpl(device) + } +} + +class DSControllerInternalState { + changed: boolean = true + + leftMotor: number = 0 + rightMotor: number = 0 + ledColor: DSControllerLEDColor = {r: 0, g: 0, b: 0} + attitudeBias = {x: 0, y: 0, z: 0, w: 1} +} + +function shiftedSnormToFloat(x: number) { + return (x - 127) / 128 +} + +function unormToFloat(x: number) { + return x / 255 +} + +function floatToUnorm(x: number) { + return Math.round(clamp(x, 0.0, 1.0) * 255) +} + +const ahrsConfig = { + // Is this always correct? Should we use delta time instead? + sampleInterval: 20, + algorithm: 'Madgwick', + beta: 0.1, + doInitialisation: true, +} as const + +class DSControllerImpl implements DSController { + private _status: DSControllerStatus = DSControllerStatus.CONNECTED + private _connectionType: DSControllerConnectionType + private _device: HIDDevice + private _inputReportLength: number = 0 + private _internalState: DSControllerInternalState = + new DSControllerInternalState() + private _state: DSControllerState = new DSControllerState() + private _stateChangeListeners: Set = + new Set() + private _ahrs: AHRS + private _intervalId: ReturnType + + constructor(device: HIDDevice) { + this._device = device + this._device.addEventListener('inputreport', (evt: HIDInputReportEvent) => { + this.processInputReport(evt) + }) + this._ahrs = new AHRS(ahrsConfig) + + let numFeatureReports = this._device.collections[0]?.featureReports?.length + if (numFeatureReports === 20) { + this._connectionType = DSControllerConnectionType.USB + } else if (numFeatureReports === 12) { + this._connectionType = DSControllerConnectionType.BLUETOOTH + } else { + throw new DSControllerIncompatible() + } + + this._intervalId = setInterval(async () => { + await this.backgroundLoop() + }, 10) + } + + addEventListener( + type: DSControllerEventType, + listener: DSControllerEventListener, + ) { + switch (type) { + case 'stateChange': + this._stateChangeListeners.add(listener) + break + } + } + + removeEventListener( + type: DSControllerEventType, + listener: DSControllerEventListener, + ) { + switch (type) { + case 'stateChange': + this._stateChangeListeners.delete(listener) + break + } + } + + attitude = { + applyGyro: () => { + this._ahrs = new AHRS(ahrsConfig) + }, + apply: () => { + const quaterion = this._ahrs.getQuaternion() + this._internalState.attitudeBias = { + x: quaterion.x * -1, + y: quaterion.y * -1, + z: quaterion.z * -1, + w: quaterion.w, + } + }, + reset: () => { + this._internalState.attitudeBias = {x: 0, y: 0, z: 0, w: 1} + }, + } + + private dispatchEvent(type: DSControllerEventType, evt: DSControllerEvent) { + switch (type) { + case 'stateChange': + this._stateChangeListeners.forEach((l) => { + l(evt) + }) + break + } + } + + private processInputButtons( + buttons0: number, + buttons1: number, + buttons2: number, + ) { + this._state.square = !!(buttons0 & (1 << 4)) + this._state.cross = !!(buttons0 & (1 << 5)) + this._state.circle = !!(buttons0 & (1 << 6)) + this._state.triangle = !!(buttons0 & (1 << 7)) + + this._state.leftButton = !!(buttons1 & (1 << 0)) + this._state.rightButton = !!(buttons1 & (1 << 1)) + + let dpad = buttons0 & 0x0f + this._state.dpadUp = dpad == 0 || dpad == 1 || dpad == 7 + this._state.dpadDown = dpad == 3 || dpad == 4 || dpad == 5 + this._state.dpadLeft = dpad == 5 || dpad == 6 || dpad == 7 + this._state.dpadRight = dpad == 1 || dpad == 2 || dpad == 3 + + this._state.leftStickButton = !!(buttons1 & (1 << 6)) + this._state.rightStickButton = !!(buttons1 & (1 << 7)) + + this._state.optionsButton = !!(buttons1 & (1 << 5)) + this._state.createButton = !!(buttons1 & (1 << 4)) + this._state.psButton = !!(buttons2 & (1 << 0)) + this._state.touchButton = !!(buttons2 & (1 << 1)) + } + + private processInputTouch( + target: DSControllerTouchState, + touch0: number, + touch1: number, + touch2: number, + touch3: number, + ) { + target.active = !(touch0 & 0x80) + target.id = touch0 & 0x7f + target.x = ((touch2 & 0x0f) << 8) | touch1 + target.y = (touch3 << 4) | ((touch2 & 0xf0) >> 4) + } + + private processInputBattery(battery0: number, battery1: number) { + this._state.battery.percent = ((battery0 & 0x0f) * 100) / 800 + this._state.battery.full = !!(battery0 & 0x20) + this._state.battery.charging = !!(battery1 & 0x08) + } + + private processInputGyro( + target: DSControllerGyroState, + gyroX0: number, + gyroX1: number, + gyroY0: number, + gyroY1: number, + gyroZ0: number, + gyroZ1: number, + ) { + let gyrox = (gyroX1 << 8) | gyroX0 + if (gyrox > 0x7fff) gyrox -= 0x10000 + let gyroy = (gyroY1 << 8) | gyroY0 + if (gyroy > 0x7fff) gyroy -= 0x10000 + let gyroz = (gyroZ1 << 8) | gyroZ0 + if (gyroz > 0x7fff) gyroz -= 0x10000 + + // 1024 LSB/deg/s + gyrox = Math.abs(gyrox / 1024) > 0.01 ? gyrox / 1024 : 0 + gyroy = Math.abs(gyroy / 1024) > 0.01 ? gyroy / 1024 : 0 + gyroz = Math.abs(gyroz / 1024) > 0.01 ? gyroz / 1024 : 0 + + target.x = gyrox + target.y = gyroy + target.z = gyroz + } + + private processInputAccel( + target: DSControllerAccelState, + accelX0: number, + accelX1: number, + accelY0: number, + accelY1: number, + accelZ0: number, + accelZ1: number, + ) { + let accelx = (accelX1 << 8) | accelX0 + if (accelx > 0x7fff) accelx -= 0x10000 + let accely = (accelY1 << 8) | accelY0 + if (accely > 0x7fff) accely -= 0x10000 + let accelz = (accelZ1 << 8) | accelZ0 + if (accelz > 0x7fff) accelz -= 0x10000 + + // 8192 LSB/g + target.x = accelx / 8192 + target.y = accely / 8192 + target.z = accelz / 8192 + } + + private processInputReportUSB01(evt: HIDInputReportEvent) { + let data = evt.data + + this._state.leftStickX = shiftedSnormToFloat(data.getUint8(0)) + this._state.leftStickY = shiftedSnormToFloat(data.getUint8(1)) + this._state.rightStickX = shiftedSnormToFloat(data.getUint8(2)) + this._state.rightStickY = shiftedSnormToFloat(data.getUint8(3)) + + this._state.leftTrigger = unormToFloat(data.getUint8(4)) + this._state.rightTrigger = unormToFloat(data.getUint8(5)) + + this.processInputButtons( + data.getUint8(7), + data.getUint8(8), + data.getUint8(9), + ) + + let touch00 = data.getUint8(32) + let touch01 = data.getUint8(33) + let touch02 = data.getUint8(34) + let touch03 = data.getUint8(35) + + let touch10 = data.getUint8(36) + let touch11 = data.getUint8(37) + let touch12 = data.getUint8(38) + let touch13 = data.getUint8(39) + + this.processInputTouch( + this._state.touch0, + touch00, + touch01, + touch02, + touch03, + ) + this.processInputTouch( + this._state.touch1, + touch10, + touch11, + touch12, + touch13, + ) + + let battery0 = data.getUint8(52) + let battery1 = data.getUint8(53) + + this.processInputBattery(battery0, battery1) + + for (let i = 0; i <= evt.data.byteLength - 1; i++) { + this._state.raw01[i.toString()] = evt.data.getUint8(i) + } + } + + private processInputReportBluetooth01(evt: HIDInputReportEvent) { + let data = evt.data + + this._state.leftStickX = shiftedSnormToFloat(data.getUint8(0)) + this._state.leftStickY = shiftedSnormToFloat(data.getUint8(1)) + this._state.rightStickX = shiftedSnormToFloat(data.getUint8(2)) + this._state.rightStickY = shiftedSnormToFloat(data.getUint8(3)) + + this._state.leftTrigger = unormToFloat(data.getUint8(7)) + this._state.rightTrigger = unormToFloat(data.getUint8(8)) + + this.processInputButtons( + data.getUint8(4), + data.getUint8(5), + data.getUint8(6), + ) + + for (let i = 0; i <= evt.data.byteLength - 1; i++) { + this._state.raw01[i.toString()] = evt.data.getUint8(i) + } + } + + private processInputReportBluetooth31(evt: HIDInputReportEvent) { + let data = evt.data + + this._state.leftStickX = shiftedSnormToFloat(data.getUint8(1)) + this._state.leftStickY = shiftedSnormToFloat(data.getUint8(2)) + this._state.rightStickX = shiftedSnormToFloat(data.getUint8(3)) + this._state.rightStickY = shiftedSnormToFloat(data.getUint8(4)) + + this._state.leftTrigger = unormToFloat(data.getUint8(5)) + this._state.rightTrigger = unormToFloat(data.getUint8(6)) + + this.processInputButtons( + data.getUint8(8), + data.getUint8(9), + data.getUint8(10), + ) + + let touch00 = data.getUint8(33) + let touch01 = data.getUint8(34) + let touch02 = data.getUint8(35) + let touch03 = data.getUint8(36) + + let touch10 = data.getUint8(37) + let touch11 = data.getUint8(38) + let touch12 = data.getUint8(39) + let touch13 = data.getUint8(40) + + this.processInputTouch( + this._state.touch0, + touch00, + touch01, + touch02, + touch03, + ) + this.processInputTouch( + this._state.touch1, + touch10, + touch11, + touch12, + touch13, + ) + + let battery0 = data.getUint8(53) + let battery1 = data.getUint8(54) + + this.processInputBattery(battery0, battery1) + + let gyroX0 = data.getUint8(16) + let gyroX1 = data.getUint8(17) + let gyroY0 = data.getUint8(18) + let gyroY1 = data.getUint8(19) + let gyroZ0 = data.getUint8(20) + let gyroZ1 = data.getUint8(21) + + this.processInputGyro( + this._state.gyro, + gyroX0, + gyroX1, + gyroY0, + gyroY1, + gyroZ0, + gyroZ1, + ) + + let accelX0 = data.getUint8(22) + let accelX1 = data.getUint8(23) + let accelY0 = data.getUint8(24) + let accelY1 = data.getUint8(25) + let accelZ0 = data.getUint8(26) + let accelZ1 = data.getUint8(27) + + this.processInputAccel( + this._state.accel, + accelX0, + accelX1, + accelY0, + accelY1, + accelZ0, + accelZ1, + ) + + // mapping the controller's Y-up coordinate system to the AHRS' Z-up coordinate system + this._ahrs.update( + this._state.gyro.z, + this._state.gyro.x, + this._state.gyro.y, + this._state.accel.z, + this._state.accel.x, + this._state.accel.y, + 0, + 0, + 0, + ) + + type Quaternion = ReturnType + + function mulQuat(a: Quaternion, b: Quaternion) { + var qax = a.x, + qay = a.y, + qaz = a.z, + qaw = a.w, + qbx = b.x, + qby = b.y, + qbz = b.z, + qbw = b.w + + const res = { + x: qax * qbw + qay * qbz - qaz * qby + qaw * qbx, + y: -qax * qbz + qay * qbw + qaz * qbx + qaw * qby, + z: qax * qby - qay * qbx + qaz * qbw + qaw * qbz, + w: -qax * qbx - qay * qby - qaz * qbz + qaw * qbw, + } + + return res + } + + // AHRS has gimbal lock glitches when using euler angles so we are not doing that + const quaternion = mulQuat( + this._ahrs.getQuaternion(), + this._internalState.attitudeBias, + ) + + // mapping from AHRS' Z-up coordinate system to the controller's Y-up coordinate system + this._state.attitude = [ + quaternion.y, + quaternion.z, + quaternion.x, + quaternion.w, + ] + + for (let i = 0; i <= evt.data.byteLength - 1; i++) { + this._state.raw31[i.toString()] = evt.data.getUint8(i) + } + } + + private processInputReport(evt: HIDInputReportEvent) { + if (this._connectionType === DSControllerConnectionType.BLUETOOTH) { + if (evt.reportId == 0x01) { + console.log('01') + this.processInputReportBluetooth01(evt) + } else if (evt.reportId == 0x31) { + this.processInputReportBluetooth31(evt) + } else { + return + } + } else if (this._connectionType === DSControllerConnectionType.USB) { + if (evt.reportId == 0x01) { + this.processInputReportUSB01(evt) + } else { + return + } + } else { + return + } + + this.dispatchEvent('stateChange', { + state: this._state, + }) + } + + get status(): DSControllerStatus { + return this._status + } + + get connectionType(): DSControllerConnectionType { + return this._connectionType + } + + get productName(): string { + return this._device.productName + } + + set ledColor(color: DSControllerLEDColor) { + this._internalState.ledColor = { + r: floatToUnorm(color.r), + g: floatToUnorm(color.g), + b: floatToUnorm(color.b), + } + } + + set leftMotor(strength: number) { + this._internalState.leftMotor = floatToUnorm(strength) + } + + set rightMotor(strength: number) { + this._internalState.rightMotor = floatToUnorm(strength) + } + + async disconnect() { + if (this._status !== DSControllerStatus.CONNECTED) { + throw new DSControllerNotConnected() + } else { + clearInterval(this._intervalId) + await this._device.close() + this._status = DSControllerStatus.DISCONNECTED + } + } + + private prepareReport(): Uint8Array { + let data: Uint8Array + + if (this._connectionType == DSControllerConnectionType.BLUETOOTH) { + data = new Uint8Array(78) // only bytes starting with 1 will be sent, byte 0 is for CRC (report ID) + data[0] = 0x31 // report ID + data[1] = 0x02 + } else { + data = new Uint8Array(47) + } + + let off = 0 // common report data offset (0 for USB report 0x02, 3 for BT report 0x31) + if (this._connectionType == DSControllerConnectionType.BLUETOOTH) { + off = 2 + } + + data[off + 0] = 0xff + data[off + 1] = 0xf7 //0x1 | 0x2 | 0x4 | 0x10 | 0x40; + data[off + 2] = this._internalState.leftMotor + data[off + 3] = this._internalState.rightMotor + data[off + 39] = 0x01 + data[off + 43] = 0x02 + data[off + 44] = this._internalState.ledColor.r + data[off + 45] = this._internalState.ledColor.g + data[off + 46] = this._internalState.ledColor.b + + if (this._connectionType == DSControllerConnectionType.BLUETOOTH) { + let crcDv = new DataView(data.buffer, 74, 4) + let crc = crc32(Buffer.from(data.slice(0, 74)), 0xeada2d49) + crcDv.setUint32(0, crc, true) + + return data.slice(1, 78) + } else { + return data + } + } + + private async sendReport() { + if (this._connectionType == DSControllerConnectionType.BLUETOOTH) { + await this._device.sendReport(0x31, this.prepareReport()) + } else { + await this._device.sendReport(0x02, this.prepareReport()) + } + } + + private async backgroundLoop() { + await this.sendReport() + } +} diff --git a/packages/dualsense-bindings/src/index.ts b/packages/dualsense-bindings/src/index.ts new file mode 100644 index 0000000000..538b61c6de --- /dev/null +++ b/packages/dualsense-bindings/src/index.ts @@ -0,0 +1,110 @@ +import type {ISheetObject} from '@theatre/core' +import type {IStudio} from '@theatre/studio' +import * as DS from './ds-hid' + +export type API = { + orientation: { + applyGyro: () => void + apply: () => void + reset: () => void + } + object: ISheetObject + studio: IStudio +} + +type DSBindings = { + orientation?: (x: number, y: number, z: number, w: number, api: API) => void + + cross?: (pressed: boolean, api: API) => void + square?: (pressed: boolean, api: API) => void + circle?: (pressed: boolean, api: API) => void + triangle?: (pressed: boolean, api: API) => void +} + +type Selector = (address: ISheetObject['address']) => boolean + +export default class DualSenseBindings { + private _controller: DS.DSController | null = null + private _studio + private _bindings: Set<{bindings: DSBindings; selector: Selector}> = new Set() + + private _buttons = { + cross: false, + square: false, + circle: false, + triangle: false, + } + + constructor(studio: IStudio) { + this._studio = studio + } + + async connect() { + this._controller = await DS.connectController() + + this._controller.addEventListener('stateChange', (evt) => { + const item = this._studio.selection.find( + (s): s is ISheetObject => s.type === 'Theatre_SheetObject_PublicAPI', + ) + + if (!item) return + + const api: API = { + orientation: this._controller!.attitude, + object: item, + studio: this._studio, + } + + this._bindings.forEach((bindings) => { + if (!bindings.selector(item.address)) return + + bindings.bindings.orientation?.( + ...(Object.values(evt.state.attitude) as [ + number, + number, + number, + number, + ]), + api, + ) + + if (evt.state.cross !== this._buttons.cross) { + bindings.bindings.cross?.(evt.state.cross, api) + } + if (evt.state.square !== this._buttons.square) { + bindings.bindings.square?.(evt.state.square, api) + } + if (evt.state.circle !== this._buttons.circle) { + bindings.bindings.circle?.(evt.state.circle, api) + } + if (evt.state.triangle !== this._buttons.triangle) { + bindings.bindings.triangle?.(evt.state.triangle, api) + } + }) + + this._buttons.cross = evt.state.cross + this._buttons.square = evt.state.square + this._buttons.circle = evt.state.circle + this._buttons.triangle = evt.state.triangle + }) + + return () => { + this._controller?.disconnect() + this._controller = null + } + } + + addBinding(bindings: DSBindings, selector: Selector = () => true) { + const item = { + bindings, + selector, + } + this._bindings.add(item) + + return () => { + this._bindings.delete(item) + } + } +} + +export * from './bindingCreators' diff --git a/packages/dualsense-bindings/tsconfig.json b/packages/dualsense-bindings/tsconfig.json new file mode 100644 index 0000000000..a03e340e28 --- /dev/null +++ b/packages/dualsense-bindings/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "lib": ["ESNext", "DOM"], + "rootDir": "src", + "types": ["jest", "node"], + "emitDeclarationOnly": true, + "target": "es6", + "composite": true + }, + "include": ["./src/**/*"], + "references": [{"path": "../../theatre"}] +} diff --git a/packages/playground/src/shared/dualsense-bindings/App.tsx b/packages/playground/src/shared/dualsense-bindings/App.tsx new file mode 100644 index 0000000000..addbf63694 --- /dev/null +++ b/packages/playground/src/shared/dualsense-bindings/App.tsx @@ -0,0 +1,200 @@ +import {editable as e, SheetProvider} from '@theatre/r3f' +import {Plane} from '@react-three/drei' +import {getProject, notify} from '@theatre/core' +import React, {Suspense, useEffect} from 'react' +import {Canvas, useFrame} from '@react-three/fiber' +import {useGLTF, PerspectiveCamera} from '@react-three/drei' +import type { + MovementPlane} from '@theatre/dualsense-bindings'; +import DualSenseBindings, { + createPositionBinding, + createRotationBinding +} from '@theatre/dualsense-bindings' +import studio from '@theatre/studio' +import type {GridHelper} from 'three' + +notify.info( + 'Click anywhere to connect a controller', + 'This playground demonstrates the dualsense-bindings package. Click anywhere to connect a DualSense controller.', +) + +const EditableCamera = e(PerspectiveCamera, 'perspectiveCamera') + +let originalPosition: [number, number, number] | null = null +let movementPlane: MovementPlane | null = null + +// All the bindings code you need (API names work in progress) ⬇️ + +// Mayyybe we should depend on studio in the bindings package, all the bindings will be together anyway (I think), so it's easy to exclude from the production bundle. +// This kind of injection would only make sense if we assume that bindings would be added all over the place. +const bindings = new DualSenseBindings(studio) +bindings.addBinding( + createRotationBinding(), + (address) => address.projectId === 'Space', +) +bindings.addBinding( + createPositionBinding({ + // this API literally only exists so that I can do the fancy grid helper thing :D + onStart: (plane, newOriginalPosition) => { + movementPlane = plane + originalPosition = newOriginalPosition + }, + onEnd: () => { + originalPosition = null + movementPlane = null + }, + }), +) + +// Done + +const planeSize = 20 + +const PositioningHelper = () => { + const gridHelperRef = React.useRef(null) + + useFrame(() => { + if (gridHelperRef.current) { + if (movementPlane) { + gridHelperRef.current.visible = true + gridHelperRef.current.position.set(...originalPosition!) + if (movementPlane === 'xz') { + gridHelperRef.current.rotation.x = 0 + gridHelperRef.current.rotation.y = 0 + gridHelperRef.current.rotation.z = 0 + } + if (movementPlane === 'xy') { + gridHelperRef.current.rotation.x = Math.PI / 2 + gridHelperRef.current.rotation.y = 0 + gridHelperRef.current.rotation.z = 0 + } + if (movementPlane === 'yz') { + gridHelperRef.current.rotation.x = 0 + gridHelperRef.current.rotation.y = 0 + gridHelperRef.current.rotation.z = Math.PI / 2 + } + } else { + gridHelperRef.current.visible = false + } + } + }) + return +} + +const Model = () => { + const {scene} = useGLTF( + 'https://vazxmixjsiawhamofees.supabase.co/storage/v1/object/public/models/zombie-car/model.gltf', + ) + + useEffect(() => { + scene.traverse((mesh: any) => { + mesh.castShadow = true + }) + }, [scene]) + + return ( + + + + + + ) +} + +function App() { + return ( +
{ + bindings.connect() + }} + style={{ + height: '100vh', + }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ) +} + +export default App diff --git a/packages/playground/src/shared/dualsense-bindings/index.tsx b/packages/playground/src/shared/dualsense-bindings/index.tsx new file mode 100644 index 0000000000..5507263712 --- /dev/null +++ b/packages/playground/src/shared/dualsense-bindings/index.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import App from './App' +import studio from '@theatre/studio' +import extension from '@theatre/r3f/dist/extension' + +studio.extend(extension) +studio.initialize() + +ReactDOM.render(, document.getElementById('root')) diff --git a/packages/playground/tsconfig.json b/packages/playground/tsconfig.json index 39d0b70190..00d906312a 100644 --- a/packages/playground/tsconfig.json +++ b/packages/playground/tsconfig.json @@ -12,7 +12,8 @@ "references": [ {"path": "../../theatre"}, {"path": "../dataverse"}, - {"path": "../r3f"} + {"path": "../r3f"}, + {"path": "../dualsense-bindings"} ], "include": ["./src/**/*", "./src/**/*.json", "./devEnv/**/*"] } diff --git a/tsconfig.base.json b/tsconfig.base.json index ab99a313ed..cd4ed770a0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -28,6 +28,9 @@ "@theatre/r3f/dist/extension": ["./packages/r3f/src/extension/index.ts"], "@theatre/dataverse-experiments": [ "./packages/dataverse-experiments/src/index.ts" + ], + "@theatre/dualsense-bindings": [ + "./packages/dualsense-bindings/src/index.ts" ] }, "forceConsistentCasingInFileNames": true diff --git a/yarn.lock b/yarn.lock index 63086eb312..ee2f742aeb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8178,6 +8178,29 @@ __metadata: languageName: unknown linkType: soft +"@theatre/dualsense-bindings@workspace:packages/dualsense-bindings": + version: 0.0.0-use.local + resolution: "@theatre/dualsense-bindings@workspace:packages/dualsense-bindings" + dependencies: + "@microsoft/api-extractor": ^7.18.11 + "@types/jest": ^26.0.23 + "@types/node": ^15.6.2 + "@types/w3c-web-hid": ^1.0.2 + ahrs: ^1.3.0 + buffer: ^6.0.3 + crc: ^4.1.0 + esbuild: ^0.12.15 + esbuild-register: ^2.5.0 + lodash-es: 4.17.21 + npm-run-all: ^4.1.5 + threejs-math: ^0.147.0 + typescript: ^4.4.2 + peerDependencies: + "@theatre/core": "workspace:*" + "@theatre/studio": "workspace:*" + languageName: unknown + linkType: soft + "@theatre/r3f@workspace:*, @theatre/r3f@workspace:packages/r3f": version: 0.0.0-use.local resolution: "@theatre/r3f@workspace:packages/r3f" @@ -9032,6 +9055,13 @@ __metadata: languageName: node linkType: hard +"@types/w3c-web-hid@npm:^1.0.2": + version: 1.0.3 + resolution: "@types/w3c-web-hid@npm:1.0.3" + checksum: 90ee1eeb2acf5d5ddf0b7acefd4f8aaa7d0175d991c3606a9ad62bdfa7a8de93665f5f6218dc4ecb34ea1d2f3e357813b315f46c1ea6b8aa1693e217e436c9b2 + languageName: node + linkType: hard + "@types/webpack-sources@npm:*": version: 2.1.1 resolution: "@types/webpack-sources@npm:2.1.1" @@ -10201,6 +10231,13 @@ __metadata: languageName: node linkType: hard +"ahrs@npm:^1.3.0": + version: 1.3.1 + resolution: "ahrs@npm:1.3.1" + checksum: 235d1515ca6005a711bed2ff724b353bf4e9758c9ae099d6dc92646126ebbea173a826b974fb33e15893f81ee0a6d485e925488218f99d1a841412a62133ebed + languageName: node + linkType: hard + "ajv-errors@npm:^1.0.0": version: 1.0.1 resolution: "ajv-errors@npm:1.0.1" @@ -11603,7 +11640,7 @@ __metadata: languageName: node linkType: hard -"base64-js@npm:^1.0.2": +"base64-js@npm:^1.0.2, base64-js@npm:^1.3.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" checksum: 669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005 @@ -12160,6 +12197,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^6.0.3": + version: 6.0.3 + resolution: "buffer@npm:6.0.3" + dependencies: + base64-js: ^1.3.1 + ieee754: ^1.2.1 + checksum: 5ad23293d9a731e4318e420025800b42bf0d264004c0286c8cc010af7a270c7a0f6522e84f54b9ad65cbd6db20b8badbfd8d2ebf4f80fa03dab093b89e68c3f9 + languageName: node + linkType: hard + "builtin-modules@npm:^3.1.0": version: 3.2.0 resolution: "builtin-modules@npm:3.2.0" @@ -13376,6 +13423,15 @@ __metadata: languageName: node linkType: hard +"crc@npm:^4.1.0": + version: 4.2.0 + resolution: "crc@npm:4.2.0" + peerDependencies: + buffer: ">=6.0.3" + checksum: 29eabe6bcbee60b67348ba5e03742300c99617ad66d93d67e3bc62b665f0f2b1584157a35ade5b1037cef65b8a1fd8bedd6e0628c8449555fc9d16a866044318 + languageName: node + linkType: hard + "create-ecdh@npm:^4.0.0": version: 4.0.4 resolution: "create-ecdh@npm:4.0.4" @@ -19058,7 +19114,7 @@ fsevents@^1.2.7: languageName: node linkType: hard -"ieee754@npm:^1.1.4": +"ieee754@npm:^1.1.4, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" checksum: 5144c0c9815e54ada181d80a0b810221a253562422e7c6c3a60b1901154184f49326ec239d618c416c1c5945a2e197107aee8d986a3dd836b53dffefd99b5e7e @@ -22451,7 +22507,7 @@ fsevents@^1.2.7: languageName: node linkType: hard -"lodash-es@npm:^4.17.21, lodash-es@npm:^4.2.1": +"lodash-es@npm:4.17.21, lodash-es@npm:^4.17.21, lodash-es@npm:^4.2.1": version: 4.17.21 resolution: "lodash-es@npm:4.17.21" checksum: 05cbffad6e2adbb331a4e16fbd826e7faee403a1a04873b82b42c0f22090f280839f85b95393f487c1303c8a3d2a010048bf06151a6cbe03eee4d388fb0a12d2 @@ -31407,6 +31463,13 @@ fsevents@^1.2.7: languageName: node linkType: hard +"threejs-math@npm:^0.147.0": + version: 0.147.0 + resolution: "threejs-math@npm:0.147.0" + checksum: 314c12491f919cbb5974a382ac2a0fbfe0a04de7b144bc64d8edad5c06efbe7337e6b536b59cae9ae7b03063f56b24852d606f8b85dfa6408ebeed18f0c05c1b + languageName: node + linkType: hard + "throat@npm:^5.0.0": version: 5.0.0 resolution: "throat@npm:5.0.0" From 1ea8f0d3e242c1f0c34636f57448e9d1b829c93e Mon Sep 17 00:00:00 2001 From: Andrew Prifer Date: Thu, 12 Jan 2023 20:21:16 +0100 Subject: [PATCH 02/16] Fix spotlight positions --- .../src/shared/dualsense-bindings/App.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/playground/src/shared/dualsense-bindings/App.tsx b/packages/playground/src/shared/dualsense-bindings/App.tsx index addbf63694..d72329d9ff 100644 --- a/packages/playground/src/shared/dualsense-bindings/App.tsx +++ b/packages/playground/src/shared/dualsense-bindings/App.tsx @@ -4,11 +4,10 @@ import {getProject, notify} from '@theatre/core' import React, {Suspense, useEffect} from 'react' import {Canvas, useFrame} from '@react-three/fiber' import {useGLTF, PerspectiveCamera} from '@react-three/drei' -import type { - MovementPlane} from '@theatre/dualsense-bindings'; +import type {MovementPlane} from '@theatre/dualsense-bindings' import DualSenseBindings, { createPositionBinding, - createRotationBinding + createRotationBinding, } from '@theatre/dualsense-bindings' import studio from '@theatre/studio' import type {GridHelper} from 'three' @@ -133,32 +132,32 @@ function App() { - - - Date: Thu, 12 Jan 2023 20:35:20 +0100 Subject: [PATCH 03/16] Add DualSense.js copyright notice --- packages/dualsense-bindings/src/ds-hid.ts | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/dualsense-bindings/src/ds-hid.ts b/packages/dualsense-bindings/src/ds-hid.ts index affe59684a..40483cb093 100644 --- a/packages/dualsense-bindings/src/ds-hid.ts +++ b/packages/dualsense-bindings/src/ds-hid.ts @@ -1,3 +1,31 @@ +/* +Based on https://github.com/equalent/DualSense.js by Andrey Tsurkan, with the +addition of gyro and accelerometer support, and a virtual "attitude" sensor that +fuses data from the gyro and accelerometer to give the controller's orientation. + +MIT License + +Copyright (c) 2022 Andrey Tsurkan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + */ + import AHRS from 'ahrs' import crc32 from 'crc/crc32' import {Buffer} from 'buffer/' From 2f42c17f42e5d97337e69a1b40e70cbc708abf71 Mon Sep 17 00:00:00 2001 From: Andrew Prifer Date: Thu, 12 Jan 2023 20:40:02 +0100 Subject: [PATCH 04/16] It was probably the euler order after all --- packages/dualsense-bindings/src/ds-hid.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/dualsense-bindings/src/ds-hid.ts b/packages/dualsense-bindings/src/ds-hid.ts index 40483cb093..9fb228d4a7 100644 --- a/packages/dualsense-bindings/src/ds-hid.ts +++ b/packages/dualsense-bindings/src/ds-hid.ts @@ -613,7 +613,6 @@ class DSControllerImpl implements DSController { return res } - // AHRS has gimbal lock glitches when using euler angles so we are not doing that const quaternion = mulQuat( this._ahrs.getQuaternion(), this._internalState.attitudeBias, From b793859d6eaf0deb993a9f3dcc3ccd1a290f2b9a Mon Sep 17 00:00:00 2001 From: Andrew Prifer Date: Fri, 13 Jan 2023 01:14:02 +0100 Subject: [PATCH 05/16] Fix quaternion multiplication order when applying the bias --- packages/dualsense-bindings/src/ds-hid.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dualsense-bindings/src/ds-hid.ts b/packages/dualsense-bindings/src/ds-hid.ts index 9fb228d4a7..04a34bdce4 100644 --- a/packages/dualsense-bindings/src/ds-hid.ts +++ b/packages/dualsense-bindings/src/ds-hid.ts @@ -614,8 +614,8 @@ class DSControllerImpl implements DSController { } const quaternion = mulQuat( - this._ahrs.getQuaternion(), this._internalState.attitudeBias, + this._ahrs.getQuaternion(), ) // mapping from AHRS' Z-up coordinate system to the controller's Y-up coordinate system From d31a36f24fecb011ac0d0e03dc56f33b687d0b2c Mon Sep 17 00:00:00 2001 From: Andrew Prifer Date: Fri, 13 Jan 2023 02:44:24 +0100 Subject: [PATCH 06/16] More intuitive position controls --- packages/dualsense-bindings/src/bindingCreators.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/dualsense-bindings/src/bindingCreators.ts b/packages/dualsense-bindings/src/bindingCreators.ts index 2ecd52a074..849fac1c9b 100644 --- a/packages/dualsense-bindings/src/bindingCreators.ts +++ b/packages/dualsense-bindings/src/bindingCreators.ts @@ -115,11 +115,12 @@ export function createPositionBinding({ const euler = new Euler().setFromQuaternion(new Quaternion(x, y, z, w)) + // this is glitchy, better calculate the position straight from the quaternion if (movementPlane === 'xz') { api.set(propPointer, { x: existingPosition!.x - euler.y * 10, y: existingPosition!.y, - z: existingPosition!.z - euler.x * 10, + z: existingPosition!.z + euler.x * 10, }) } if (movementPlane === 'xy') { From 23e87f07171a91f45acb0a6893949043fcdbc9f0 Mon Sep 17 00:00:00 2001 From: Andrew Prifer Date: Fri, 13 Jan 2023 04:09:05 +0100 Subject: [PATCH 07/16] The ULTIMATE position controls --- .../dualsense-bindings/src/bindingCreators.ts | 64 +++++++++++++------ 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/packages/dualsense-bindings/src/bindingCreators.ts b/packages/dualsense-bindings/src/bindingCreators.ts index 849fac1c9b..466fd887f0 100644 --- a/packages/dualsense-bindings/src/bindingCreators.ts +++ b/packages/dualsense-bindings/src/bindingCreators.ts @@ -8,7 +8,7 @@ import {get} from 'lodash-es' export function createRotationBinding({propName = 'rotation'} = {}) { let scrub: IScrub | null = null - let existingRotation: Quaternion | null = null + let initialRotation: Quaternion | null = null return { cross: (pressed: boolean, {orientation, object, studio}: API) => { @@ -22,7 +22,7 @@ export function createRotationBinding({propName = 'rotation'} = {}) { // "applies" the current orientation, so that it is treated as the "zero" orientation orientation.apply() scrub = studio.scrub() - existingRotation = new Quaternion().setFromEuler( + initialRotation = new Quaternion().setFromEuler( new Euler(propValue.x, propValue.y, propValue.z), ) } else { @@ -30,17 +30,17 @@ export function createRotationBinding({propName = 'rotation'} = {}) { scrub.commit() scrub = null } - existingRotation = null + initialRotation = null } }, orientation(x: number, y: number, z: number, w: number, {object}: API) { - if (!scrub || !existingRotation) return + if (!scrub || !initialRotation) return const propPointer = get(object.props, propName) scrub.capture((api) => { const rotation = new Euler().setFromQuaternion( - new Quaternion(x, y, z, w).premultiply(existingRotation!), + new Quaternion(x, y, z, w).premultiply(initialRotation!), ) api.set(propPointer, { @@ -63,11 +63,11 @@ export function createPositionBinding({ propName?: string onStart?: ( movementPlane: MovementPlane, - originalPosition: [number, number, number], + initialPosition: [number, number, number], ) => void onEnd?: () => void } = {}) { - let existingPosition: Vector3 | null = null + let initialPosition: Vector3 | null = null let movementPlane: MovementPlane | null = null let scrub: IScrub | null = null @@ -88,7 +88,7 @@ export function createPositionBinding({ // "applies" the current orientation, so that it is treated as the "zero" orientation orientation.apply() scrub = studio.scrub() - existingPosition = new Vector3(propValue.x, propValue.y, propValue.z) + initialPosition = new Vector3(propValue.x, propValue.y, propValue.z) } else { onEnd() @@ -96,7 +96,7 @@ export function createPositionBinding({ scrub.commit() scrub = null } - existingPosition = null + initialPosition = null movementPlane = null } } @@ -106,35 +106,57 @@ export function createPositionBinding({ triangle: createButtonHandler('xy'), circle: createButtonHandler('yz'), orientation(x: number, y: number, z: number, w: number, {object}: API) { - if (!scrub || !existingPosition) return + if (!scrub || !initialPosition) return scrub.capture((api) => { if (!object.value.rotation) return const propPointer = get(object.props, propName) - const euler = new Euler().setFromQuaternion(new Quaternion(x, y, z, w)) + // calculate the controller's forward vector + const vector = new Vector3(0, 0, -1).applyQuaternion( + new Quaternion(x, y, z, w), + ) - // this is glitchy, better calculate the position straight from the quaternion if (movementPlane === 'xz') { api.set(propPointer, { - x: existingPosition!.x - euler.y * 10, - y: existingPosition!.y, - z: existingPosition!.z + euler.x * 10, + // Take the tangent of the angle between the forward vector and the orthogonal plane of the initial forward vector + // by dividing the x and y components by the z component. + // Limit the z component to at least 0.3 so that the position doesn't fly off the handle + // when we divide by z (and to avoid NaN). + // Below z = 0.3 the movement becomes too noisy anyway. + // We then multiply by 6 to make the movement a bit more sensitive. This value is chosen as a balance between sensitivity and noise. + // We then add the resulting components to the initial position to get the final position. + x: + initialPosition!.x + + (vector.x / Math.max(Math.abs(vector.z), 0.3)) * 8, + y: initialPosition!.y, + z: + initialPosition!.z + + (vector.y / Math.max(Math.abs(vector.z), 0.3)) * 8, }) } + // Do the same for the other planes too if (movementPlane === 'xy') { api.set(propPointer, { - x: existingPosition!.x - euler.y * 10, - y: existingPosition!.y + euler.x * 10, - z: existingPosition!.z, + x: + initialPosition!.x + + (vector.x / Math.max(Math.abs(vector.z), 0.3)) * 6, + y: + initialPosition!.y + + (vector.y / Math.max(Math.abs(vector.z), 0.3)) * 6, + z: initialPosition!.z, }) } if (movementPlane === 'yz') { api.set(propPointer, { - x: existingPosition!.x, - y: existingPosition!.y + euler.x * 10, - z: existingPosition!.z + euler.y * 10, + x: initialPosition!.x, + y: + initialPosition!.y + + (vector.y / Math.max(Math.abs(vector.z), 0.3)) * 6, + z: + initialPosition!.z - + (vector.x / Math.max(Math.abs(vector.z), 0.3)) * 6, }) } }) From 7ad79377ec43ce2f8f3c4014ae97575843529635 Mon Sep 17 00:00:00 2001 From: Andrew Prifer Date: Sat, 14 Jan 2023 19:44:57 +0100 Subject: [PATCH 08/16] Add docs to orientation2position code --- .../dualsense-bindings/src/bindingCreators.ts | 86 +++++++++++++------ 1 file changed, 59 insertions(+), 27 deletions(-) diff --git a/packages/dualsense-bindings/src/bindingCreators.ts b/packages/dualsense-bindings/src/bindingCreators.ts index 466fd887f0..552b791e8f 100644 --- a/packages/dualsense-bindings/src/bindingCreators.ts +++ b/packages/dualsense-bindings/src/bindingCreators.ts @@ -113,50 +113,82 @@ export function createPositionBinding({ const propPointer = get(object.props, propName) + /* + Calculating the position involves mapping the orientation to a point on a plane. This can be done using different + trigonometric methods. + + Things to consider: + - We need to balance the speed of movement with the precision of the movement. + - We need to balance the speed of movement with the sensor noise. Larger coefficients will make the + movement noisier. + + Here are some methods and their caveats: + - Take the cotangent of the orientation with respect to a plane + This is the method preferred here, since it allows for precise, slower movements at small orientation changes, + while allowing for fast movements at the extremes. This method also fits best the the laser pointer metaphor (see below). + - Take the cosine of the orientation with respect to a plane + The movement slows down towards the extremes,which is less useful than the tangent method described above. + - Map the orientation to a triangle wave with respect to a plane + This results in linear movement, which fits the trackball metaphor (see below). + + const xTranslationLinear = ((2 * a) / Math.PI) * Math.asin(vector.x) + const yTranslationLinear = a - ((2 * a) / Math.PI) * Math.acos(vector.y) + + There are also different metaphors we can consider in the implementation: + - Laser pointer + The controller is a laser pointer, projecting a point onto a flat surface. In this metaphor, the roll + of the controller is ignored, since it doesn't change the direction of the laser pointer. Orientation maps + tangentially to movement. At the extremes, movement becomes faster per orientation change. + - Trackball + The controller is a trackball. Changes in roll results in movement along the x axis, + changes in pitch results in changes in the y axis. The yaw of the controller is ignored, since a + trackpall can't be yawed. Orientation maps lineraly to movement. + + const upVector = new Vector3(0, 1, 0).applyQuaternion(new Quaternion(x, y, z, w)) + const xTranslationTrackball = (upVector.x / Math.max(Math.abs(upVector.y), 0.3)) * 6 + + It could be tempting to combine the two metaphors, but this can result in a confusing experience, + since the roll or the yaw of the controller can be changed inadvertnetly, which makes it difficult to + determine the user's intent. + + This implementation uses the laser pointer metaphor with the tangent method. + */ + // calculate the controller's forward vector - const vector = new Vector3(0, 0, -1).applyQuaternion( + const forwardVector = new Vector3(0, 0, -1).applyQuaternion( new Quaternion(x, y, z, w), ) + // Take the tangent of the angle between the forward vector and the orthogonal plane of the initial forward vector + // by dividing the x and y components by the z component. + // Limit the z component to at least 0.3 so that the position doesn't fly off the handle + // when we divide by z (and to avoid NaN). + // Below z = 0.3 the movement becomes too noisy anyway. + // We then multiply by 6 to make the movement a bit more sensitive. This value is chosen as a balance between sensitivity and noise. + const xTranslation = + (forwardVector.x / Math.max(Math.abs(forwardVector.z), 0.3)) * 6 + const yTranslation = + (forwardVector.y / Math.max(Math.abs(forwardVector.z), 0.3)) * 6 + if (movementPlane === 'xz') { api.set(propPointer, { - // Take the tangent of the angle between the forward vector and the orthogonal plane of the initial forward vector - // by dividing the x and y components by the z component. - // Limit the z component to at least 0.3 so that the position doesn't fly off the handle - // when we divide by z (and to avoid NaN). - // Below z = 0.3 the movement becomes too noisy anyway. - // We then multiply by 6 to make the movement a bit more sensitive. This value is chosen as a balance between sensitivity and noise. - // We then add the resulting components to the initial position to get the final position. - x: - initialPosition!.x + - (vector.x / Math.max(Math.abs(vector.z), 0.3)) * 8, + x: initialPosition!.x + xTranslation, y: initialPosition!.y, - z: - initialPosition!.z + - (vector.y / Math.max(Math.abs(vector.z), 0.3)) * 8, + z: initialPosition!.z + yTranslation, }) } - // Do the same for the other planes too if (movementPlane === 'xy') { api.set(propPointer, { - x: - initialPosition!.x + - (vector.x / Math.max(Math.abs(vector.z), 0.3)) * 6, - y: - initialPosition!.y + - (vector.y / Math.max(Math.abs(vector.z), 0.3)) * 6, + x: initialPosition!.x + xTranslation, + y: initialPosition!.y + yTranslation, z: initialPosition!.z, }) } if (movementPlane === 'yz') { api.set(propPointer, { x: initialPosition!.x, - y: - initialPosition!.y + - (vector.y / Math.max(Math.abs(vector.z), 0.3)) * 6, - z: - initialPosition!.z - - (vector.x / Math.max(Math.abs(vector.z), 0.3)) * 6, + y: initialPosition!.y + yTranslation, + z: initialPosition!.z - xTranslation, }) } }) From eb92bf05ae8e5fcda527a9793dcdfa1fa4648ded Mon Sep 17 00:00:00 2001 From: Andrew Prifer Date: Sat, 14 Jan 2023 19:45:40 +0100 Subject: [PATCH 09/16] Burp --- packages/dualsense-bindings/src/bindingCreators.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dualsense-bindings/src/bindingCreators.ts b/packages/dualsense-bindings/src/bindingCreators.ts index 552b791e8f..c3a2e8bc39 100644 --- a/packages/dualsense-bindings/src/bindingCreators.ts +++ b/packages/dualsense-bindings/src/bindingCreators.ts @@ -127,7 +127,7 @@ export function createPositionBinding({ This is the method preferred here, since it allows for precise, slower movements at small orientation changes, while allowing for fast movements at the extremes. This method also fits best the the laser pointer metaphor (see below). - Take the cosine of the orientation with respect to a plane - The movement slows down towards the extremes,which is less useful than the tangent method described above. + The movement slows down towards the extremes, which is less useful than the tangent method described above. - Map the orientation to a triangle wave with respect to a plane This results in linear movement, which fits the trackball metaphor (see below). From ad30220abf35f9aa05db323c2d5ceee3a8ccdb02 Mon Sep 17 00:00:00 2001 From: Andrew Prifer Date: Sat, 14 Jan 2023 19:49:00 +0100 Subject: [PATCH 10/16] Burp 2 --- packages/dualsense-bindings/src/bindingCreators.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dualsense-bindings/src/bindingCreators.ts b/packages/dualsense-bindings/src/bindingCreators.ts index c3a2e8bc39..98628173ec 100644 --- a/packages/dualsense-bindings/src/bindingCreators.ts +++ b/packages/dualsense-bindings/src/bindingCreators.ts @@ -148,7 +148,7 @@ export function createPositionBinding({ const xTranslationTrackball = (upVector.x / Math.max(Math.abs(upVector.y), 0.3)) * 6 It could be tempting to combine the two metaphors, but this can result in a confusing experience, - since the roll or the yaw of the controller can be changed inadvertnetly, which makes it difficult to + since the roll or the yaw of the controller can easily be changed inadvertently, which makes it difficult to determine the user's intent. This implementation uses the laser pointer metaphor with the tangent method. From d3744e53056f97c2fa14510a5a58012dd86f7d57 Mon Sep 17 00:00:00 2001 From: Andrew Prifer Date: Sat, 14 Jan 2023 19:56:03 +0100 Subject: [PATCH 11/16] Burp 3 --- packages/dualsense-bindings/src/bindingCreators.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/dualsense-bindings/src/bindingCreators.ts b/packages/dualsense-bindings/src/bindingCreators.ts index 98628173ec..70f531cacf 100644 --- a/packages/dualsense-bindings/src/bindingCreators.ts +++ b/packages/dualsense-bindings/src/bindingCreators.ts @@ -125,7 +125,8 @@ export function createPositionBinding({ Here are some methods and their caveats: - Take the cotangent of the orientation with respect to a plane This is the method preferred here, since it allows for precise, slower movements at small orientation changes, - while allowing for fast movements at the extremes. This method also fits best the the laser pointer metaphor (see below). + while allowing for fast movements at the extremes. This method fits best the the laser pointer metaphor (see below). + A downside of this method is that since the mapping is not linear, the movement is harder to predict towards the extremes. - Take the cosine of the orientation with respect to a plane The movement slows down towards the extremes, which is less useful than the tangent method described above. - Map the orientation to a triangle wave with respect to a plane From aa794d7976208b1f08b7796f801478a8e63cfba4 Mon Sep 17 00:00:00 2001 From: Andrew Prifer Date: Sat, 14 Jan 2023 19:56:28 +0100 Subject: [PATCH 12/16] Burp 4 --- packages/dualsense-bindings/src/bindingCreators.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/dualsense-bindings/src/bindingCreators.ts b/packages/dualsense-bindings/src/bindingCreators.ts index 70f531cacf..dff7c77c82 100644 --- a/packages/dualsense-bindings/src/bindingCreators.ts +++ b/packages/dualsense-bindings/src/bindingCreators.ts @@ -124,13 +124,14 @@ export function createPositionBinding({ Here are some methods and their caveats: - Take the cotangent of the orientation with respect to a plane - This is the method preferred here, since it allows for precise, slower movements at small orientation changes, + This method allows for precise, slower movements at small orientation changes, while allowing for fast movements at the extremes. This method fits best the the laser pointer metaphor (see below). A downside of this method is that since the mapping is not linear, the movement is harder to predict towards the extremes. - Take the cosine of the orientation with respect to a plane The movement slows down towards the extremes, which is less useful than the tangent method described above. - Map the orientation to a triangle wave with respect to a plane - This results in linear movement, which fits the trackball metaphor (see below). + This method results in linear movement, which fits the trackball metaphor (see below). While the movement is more + predictable, the precision that can be achieved is lower than with the tangent method. const xTranslationLinear = ((2 * a) / Math.PI) * Math.asin(vector.x) const yTranslationLinear = a - ((2 * a) / Math.PI) * Math.acos(vector.y) From 78b0f8528ebbd27146e47e0f548e0b6362a81ce0 Mon Sep 17 00:00:00 2001 From: Andrew Prifer Date: Sat, 14 Jan 2023 19:59:54 +0100 Subject: [PATCH 13/16] Burp 5 --- packages/dualsense-bindings/src/bindingCreators.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/dualsense-bindings/src/bindingCreators.ts b/packages/dualsense-bindings/src/bindingCreators.ts index dff7c77c82..6348ef9b0b 100644 --- a/packages/dualsense-bindings/src/bindingCreators.ts +++ b/packages/dualsense-bindings/src/bindingCreators.ts @@ -133,8 +133,8 @@ export function createPositionBinding({ This method results in linear movement, which fits the trackball metaphor (see below). While the movement is more predictable, the precision that can be achieved is lower than with the tangent method. - const xTranslationLinear = ((2 * a) / Math.PI) * Math.asin(vector.x) - const yTranslationLinear = a - ((2 * a) / Math.PI) * Math.acos(vector.y) + const xTranslationLinear = ((2 * a) / Math.PI) * Math.asin(forwardVector.x) + const yTranslationLinear = a - ((2 * a) / Math.PI) * Math.acos(forwardVector.y) There are also different metaphors we can consider in the implementation: - Laser pointer From 4614f69440ac6c5990008ce97af6a7bfb608169d Mon Sep 17 00:00:00 2001 From: Andrew Prifer Date: Mon, 16 Jan 2023 16:52:33 +0100 Subject: [PATCH 14/16] More docs changes --- packages/dualsense-bindings/src/bindingCreators.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/dualsense-bindings/src/bindingCreators.ts b/packages/dualsense-bindings/src/bindingCreators.ts index 6348ef9b0b..ebc7b68037 100644 --- a/packages/dualsense-bindings/src/bindingCreators.ts +++ b/packages/dualsense-bindings/src/bindingCreators.ts @@ -129,12 +129,13 @@ export function createPositionBinding({ A downside of this method is that since the mapping is not linear, the movement is harder to predict towards the extremes. - Take the cosine of the orientation with respect to a plane The movement slows down towards the extremes, which is less useful than the tangent method described above. - - Map the orientation to a triangle wave with respect to a plane + - Map two angles of the orientation linearly to the x and y axes of the plane This method results in linear movement, which fits the trackball metaphor (see below). While the movement is more - predictable, the precision that can be achieved is lower than with the tangent method. + predictable, the precision that can be achieved with the same range of movement is lower than with the tangent method. - const xTranslationLinear = ((2 * a) / Math.PI) * Math.asin(forwardVector.x) - const yTranslationLinear = a - ((2 * a) / Math.PI) * Math.acos(forwardVector.y) + const upVector = new Vector3(0, 1, 0).applyQuaternion(new Quaternion(x, y, z, w)) + const xTranslationLinear = ((2 * a) / Math.PI) * Math.asin(upVector.x) + const yTranslationLinear = ((2 * a) / Math.PI) * Math.asin(upVector.y) There are also different metaphors we can consider in the implementation: - Laser pointer @@ -146,9 +147,6 @@ export function createPositionBinding({ changes in pitch results in changes in the y axis. The yaw of the controller is ignored, since a trackpall can't be yawed. Orientation maps lineraly to movement. - const upVector = new Vector3(0, 1, 0).applyQuaternion(new Quaternion(x, y, z, w)) - const xTranslationTrackball = (upVector.x / Math.max(Math.abs(upVector.y), 0.3)) * 6 - It could be tempting to combine the two metaphors, but this can result in a confusing experience, since the roll or the yaw of the controller can easily be changed inadvertently, which makes it difficult to determine the user's intent. From b74248534cfc58104fcef8d877628ffd792a8e51 Mon Sep 17 00:00:00 2001 From: Andrew Prifer Date: Thu, 19 Jan 2023 13:44:38 +0100 Subject: [PATCH 15/16] Fix ahrs deltas & multiple buttons pressed Also clarify trackball comment --- .../dualsense-bindings/src/bindingCreators.ts | 30 +++++++++++-------- packages/dualsense-bindings/src/ds-hid.ts | 15 +++++++++- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/packages/dualsense-bindings/src/bindingCreators.ts b/packages/dualsense-bindings/src/bindingCreators.ts index ebc7b68037..a8492c005b 100644 --- a/packages/dualsense-bindings/src/bindingCreators.ts +++ b/packages/dualsense-bindings/src/bindingCreators.ts @@ -74,6 +74,8 @@ export function createPositionBinding({ const createButtonHandler = (plane: MovementPlane) => (pressed: boolean, {object, orientation, studio}: API) => { + if (movementPlane && movementPlane !== plane) return // only one button can be pressed at a time + const propValue = get(object.value, propName) if (!propValue) { @@ -90,6 +92,9 @@ export function createPositionBinding({ scrub = studio.scrub() initialPosition = new Vector3(propValue.x, propValue.y, propValue.z) } else { + // if this button press was ignored, the release should be ignored too + if (movementPlane !== plane) return // only one button can be pressed at a time + onEnd() if (scrub) { @@ -130,12 +135,12 @@ export function createPositionBinding({ - Take the cosine of the orientation with respect to a plane The movement slows down towards the extremes, which is less useful than the tangent method described above. - Map two angles of the orientation linearly to the x and y axes of the plane - This method results in linear movement, which fits the trackball metaphor (see below). While the movement is more - predictable, the precision that can be achieved with the same range of movement is lower than with the tangent method. + This method results in linear movement. While the movement is more predictable, the precision that can be achieved + with the same range of movement is lower than with the tangent method. - const upVector = new Vector3(0, 1, 0).applyQuaternion(new Quaternion(x, y, z, w)) - const xTranslationLinear = ((2 * a) / Math.PI) * Math.asin(upVector.x) - const yTranslationLinear = ((2 * a) / Math.PI) * Math.asin(upVector.y) + const vector = new Vector3(0, 0, -1).applyQuaternion(new Quaternion(x, y, z, w)) + const xTranslationLinear = ((2 * a) / Math.PI) * Math.asin(vector.x) + const yTranslationLinear = ((2 * a) / Math.PI) * Math.asin(vector.y) There are also different metaphors we can consider in the implementation: - Laser pointer @@ -143,15 +148,16 @@ export function createPositionBinding({ of the controller is ignored, since it doesn't change the direction of the laser pointer. Orientation maps tangentially to movement. At the extremes, movement becomes faster per orientation change. - Trackball - The controller is a trackball. Changes in roll results in movement along the x axis, - changes in pitch results in changes in the y axis. The yaw of the controller is ignored, since a - trackpall can't be yawed. Orientation maps lineraly to movement. + The controller is a trackball. Rotation around the global z axis results in movement along the x axis, Rotation around the + global x axis results in changes in the y axis. Rotation around the global y axis is ignored. Orientation maps lineraly to movement. + Implementing this is different, since with the trackball, the path of rotations matters, we can't map the orientation + directly to movement, we have to use the deltas. - It could be tempting to combine the two metaphors, but this can result in a confusing experience, - since the roll or the yaw of the controller can easily be changed inadvertently, which makes it difficult to - determine the user's intent. + It could be tempting to combine the two metaphors, but this can result in a confusing experience, + since the roll or the yaw of the controller can easily be changed inadvertently, which makes it difficult to + determine the user's intent. - This implementation uses the laser pointer metaphor with the tangent method. + This implementation uses the laser pointer metaphor with the tangent method. */ // calculate the controller's forward vector diff --git a/packages/dualsense-bindings/src/ds-hid.ts b/packages/dualsense-bindings/src/ds-hid.ts index 04a34bdce4..1568dc3c1f 100644 --- a/packages/dualsense-bindings/src/ds-hid.ts +++ b/packages/dualsense-bindings/src/ds-hid.ts @@ -163,6 +163,9 @@ export class DSControllerState { attitude: [number, number, number, number] = [0, 0, 0, 1] + prevTimestamp: number = -1 + timestamp: number = -1 + raw01: any = {} raw31: any = {} } @@ -237,7 +240,8 @@ function floatToUnorm(x: number) { } const ahrsConfig = { - // Is this always correct? Should we use delta time instead? + // Ahrs won't use this because we provide timestamps instead at every update. Using this would break + // because the DS can reduce the refrehs rate based on things like battery level. sampleInterval: 20, algorithm: 'Madgwick', beta: 0.1, @@ -500,6 +504,12 @@ class DSControllerImpl implements DSController { private processInputReportBluetooth31(evt: HIDInputReportEvent) { let data = evt.data + // Technically, since input reports can come in at a different rate than the sensor refresh rate, + // setting the timestamp like this is not totally correct, but it's close enough I think. + // We could get the actual sensor timestamp later. + this._state.prevTimestamp = this._state.timestamp + this._state.timestamp = Date.now() + this._state.leftStickX = shiftedSnormToFloat(data.getUint8(1)) this._state.leftStickY = shiftedSnormToFloat(data.getUint8(2)) this._state.rightStickX = shiftedSnormToFloat(data.getUint8(3)) @@ -589,6 +599,9 @@ class DSControllerImpl implements DSController { 0, 0, 0, + this._state.prevTimestamp !== -1 + ? (this._state.timestamp - this._state.prevTimestamp) / 1000 + : 20, ) type Quaternion = ReturnType From 52d74f16e5a8a146c44d59520fc260a98709fe74 Mon Sep 17 00:00:00 2001 From: Andrew Prifer Date: Thu, 19 Jan 2023 13:54:16 +0100 Subject: [PATCH 16/16] Comment --- packages/dualsense-bindings/src/ds-hid.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/dualsense-bindings/src/ds-hid.ts b/packages/dualsense-bindings/src/ds-hid.ts index 1568dc3c1f..54a689789e 100644 --- a/packages/dualsense-bindings/src/ds-hid.ts +++ b/packages/dualsense-bindings/src/ds-hid.ts @@ -504,9 +504,6 @@ class DSControllerImpl implements DSController { private processInputReportBluetooth31(evt: HIDInputReportEvent) { let data = evt.data - // Technically, since input reports can come in at a different rate than the sensor refresh rate, - // setting the timestamp like this is not totally correct, but it's close enough I think. - // We could get the actual sensor timestamp later. this._state.prevTimestamp = this._state.timestamp this._state.timestamp = Date.now() @@ -599,6 +596,9 @@ class DSControllerImpl implements DSController { 0, 0, 0, + // Technically, since input reports can come in at a different rate than the sensor refresh rate, + // setting the delta from the report timestamp is not totally correct, but it's close enough I think. + // We could get the actual sensor timestamp later. this._state.prevTimestamp !== -1 ? (this._state.timestamp - this._state.prevTimestamp) / 1000 : 20,