├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── demo ├── BVHExporter.js ├── BVHeLoader.js ├── app.js ├── boneMapping.js ├── gui.js ├── index.html ├── skeletonHelper.js └── tpose-map.png ├── docs ├── Algorithm.md └── imgs │ ├── BadCurrentPose.png │ ├── BadCurrentPoseRetarget.png │ ├── BadEmbedPose.png │ ├── BadEmbedRetarget.png │ ├── GoodPose.png │ ├── GoodRetarget.png │ ├── Poses.png │ └── Spaces.png └── retargeting.js /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | typings/* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # retargeting-threejs 2 | 3 | Animation and pose retargeting solver for 3D humanoid characters using Threejs. 4 | 5 | See [this](docs/Algorithm.md) documentation for the algorithm explanation and some animation related topics. 6 | 7 | [[Try demo](https://resources.gti.upf.edu/demos/retargeting-threejs/demo/)] 8 | 9 | ## Set up 10 | To use the code, include the retargeting.js file in your project and include the following lines in your index.html. The retargeting.js expects an importmap for "three": 11 | ``` html 12 | 13 | 20 | ``` 21 | ## API 22 | 23 | ``` javascript 24 | // example offline retarget 25 | 26 | let source = loadSourceSkeleton(); // user function: returns THREE.Skeleton 27 | let sourceAnim = loadSourceAnimation(); // user function: returns THREE.AnimationClip 28 | 29 | let target = loadSkinnedModel(); // user function: returns some skinned model from a glb (for example) 30 | 31 | let options = { 32 | srcPoseMode: AnimationRetargeting.BindPoseModes.DEFAULT, // will use the actual skeleton's bind pose 33 | trgPoseMode: AnimationRetargeting.BindPoseModes.CURRENT, // will use the current local transforms of the bones as bind pose 34 | trgEmbedWorldTransforms: true // the rotations of the parent(s) of the skeleton will be included in the retargeting. They are needed to make the source and target skeletons match, for this example. 35 | } 36 | let retargeting = new AnimationRetargeting( source, target, options ); 37 | retargeting.retargetAnimation( sourceAnim ); 38 | 39 | ``` 40 | 41 | 42 | ### Constructor 43 | 44 | Retargets animations and/or current poses from one skeleton to another. 45 | Both skeletons must have the same bind pose (same orientation for each mapped bone) in order to properly work. 46 | Use optional parameters to adjust the bind pose. 47 | 48 | ``` javascript 49 | AnimationRetargeting( srcSkeleton, trgSkeleton, options ) 50 | ``` 51 | - `srcSkeleton` THREE.Skeleton
Skeleton of the source avatar. If not an skeleton, an object traverse will be perfomed to find one (from a skinnedMesh for example). 52 | - `trgSkeleton` THREE.Skeleton
Same as srcSkeleton but for the target avatar 53 | - `options` Object
optional attribute to modify the retargeting behaviour 54 | 55 | - `srcPoseMode` and `trgPoseMode`: AnimationRetargeting.BindPoseModes
Pose of the srcSkeleton that will be used as the bind pose for the retargeting. Default: skeleton's actual bind pose. 56 | 57 | - `srcEmbedWorldTransforms` and `trgEmbedWorldTransforms`: Bool
Retargeting only takes into account the transforms from the actual bone objects (local transforms). If set to true, external (parent) transforms are computed and embedded into the root joint (only once, on construction). 58 | Afterwards, parent transforms/matrices can be safely modified and will not affect in retargeting. 59 | Useful when it is easier to modify the container of the skeleton rather than the actual skeleton in order to align source and target poses. 60 | - `boneNameMap`: Object.
String-to-string mapping between src (keys) and trg (values) through bone names. Only supports one-to-one mapping. If no mapping is specified, an automatic one is performed based on the name of the bones. 61 | 62 | 63 | 64 | ### Static Properties 65 | 66 | #### BindPoseModes 67 | Enumeration that determines which pose will be used as the retargeting bind pose. 68 | 69 | - `DEFAULT` or `0`: Uses skeleton's actual bind pose 70 | - `CURRENT` or `1`: Uses skeleton's current pose 71 | 72 | ### Methods 73 | 74 | #### retargetPose 75 | Retargets the current pose from the source skeleton to the target skeleton. Only the mapped bones are computed. 76 | 77 | ``` javascript 78 | .retargetPose() : undefined 79 | ``` 80 | 81 | #### retargetAnimation 82 | Retargets a THREEJS AnimationClip from source to target skeleton. Returns another AnimationClip. 83 | Only mapped bones are computed 84 | 85 | ``` javascript 86 | .retargetAnimation( anim ) : THREE.AnimationClip 87 | ``` 88 | - `anim`: THREE.AnimationClip
animation to retarget 89 | 90 | > [!CAUTION] 91 | > *Work in progress* 92 | > #### applyTPose 93 | > Force the skeleton to have a T-pose shape, facing the +Z axis. Only works for humanoid skeletons. 94 | >``` javascript 95 | >.applyTpose( skeleton, map ) : THREE.AnimationClip 96 | >``` 97 | 98 | ## Usual issues 99 | 100 | A failed retargeting might be due to many reasons. 101 | 102 | The lack of movement might be caused by an improper bone mapping or bad track IDs. 103 | 104 | Weird target rotations might also be due to improper bone mapping. However, most commonly, it will be caused by incorrect set up of the auxiliary pose. The API exposes some attributes to alleviate this 105 | 106 | 107 | #### :heavy_check_mark: Case A: Successful Retargeting 108 |
109 | Good skeleton bind pose 110 | Good skeleton bind pose 111 |
112 | 113 | Case A shows a successful retargeting from the avatar on the left (red shirt) to the avatar on the right(white shirt). Note the white avatar only moves one finger as the source avatar only has one finger. It could have been manually mapped instead of relying on the automap. 114 | 115 | #### :warning: Case B: Current pose modification 116 | 117 |
118 | Bad skeleton bind Pose that requires modifying a joint 119 | Bad skeleton bind Pose that requires modifying a joint 120 |
121 | 122 | Case B shows an example where both skeleton's auxiliary pose are different. In this particular case, only the root joint (hips) differs. Since the world rotations of the source avatar do not mean the same for the target avatar, the resulting animation look weird. For this case, it would suffice to rotate 90º the root joint and instantiating the AnimationRetargeting class with the `trgPoseMode` set to `CURRENT`, so it checks for the current modified target bone setup. 123 | 124 | #### :warning: Case C: World transform embeding 125 | 126 |
127 | Bad skeleton's bind pose that requires modifying the container's transform 128 | Bad skeleton's bind pose that requires modifying the container's transform 129 |
130 | 131 | 132 | Case C shows a similar example as in case B. However, for this case it might be easier to just modify the container of the skeleton (or some upper container). Rotating the skeleton's parent object 90º results in the same pose as Case A. Then it would only suffice to instantiate AnimationRetargeting with `trgEmbedWorldTransforms` set to ```true```, so the algorithm takes the container's rotation into account. 133 | -------------------------------------------------------------------------------- /demo/BVHExporter.js: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | 3 | const BVHExporter = { 4 | 5 | getTabs: function(level) { 6 | 7 | let tabs = ""; 8 | for (let i = 0; i < level; ++i) { 9 | tabs += "\t"; 10 | } 11 | return tabs; 12 | }, 13 | 14 | exportBone: function(bone, level) { 15 | 16 | let isEndSite = bone.children.length == 0; 17 | 18 | let tabs = this.getTabs(level); 19 | let bvh = tabs; 20 | if(bone.type != 'Bone') 21 | return ""; 22 | let exportPos = false; 23 | if (!bone.parent || bone.parent.type != 'Bone') { 24 | bvh += "ROOT " + bone.name + "\n"; 25 | exportPos = true; 26 | } else if (isEndSite) { 27 | bvh += "End Site" + "\n"; 28 | } 29 | else { 30 | bvh += "JOINT " + bone.name + "\n"; 31 | } 32 | 33 | let position = this.skeleton.getBoneByName( bone.name ).getWorldPosition(new THREE.Vector3()); 34 | let parentPos = this.skeleton.getBoneByName( bone.name ).parent ? this.skeleton.getBoneByName( bone.name ).parent.getWorldPosition(new THREE.Vector3()) : new THREE.Vector3(); 35 | 36 | position.sub(parentPos); 37 | 38 | bvh += tabs + "{\n"; 39 | bvh += tabs + "\tOFFSET " + position.x.toFixed(6) + 40 | " " + position.y.toFixed(6) + 41 | " " + position.z.toFixed(6) + "\n"; 42 | 43 | if (!isEndSite) { 44 | if (exportPos) { 45 | bvh += tabs + "\tCHANNELS 6 Xposition Yposition Zposition Xrotation Yrotation Zrotation\n"; 46 | } else { 47 | bvh += tabs + "\tCHANNELS 3 Xrotation Yrotation Zrotation\n"; 48 | } 49 | } 50 | 51 | for (let i = 0; i < bone.children.length; ++i) { 52 | bvh += this.exportBone(bone.children[i], level + 1); 53 | } 54 | 55 | bvh += tabs + "}\n"; 56 | 57 | return bvh; 58 | }, 59 | 60 | quatToEulerString: function(q) { 61 | let euler = new THREE.Euler(); 62 | euler.setFromQuaternion(q); 63 | return THREE.MathUtils.radToDeg(euler.x).toFixed(6) + " " + THREE.MathUtils.radToDeg(euler.y).toFixed(6) + " " + THREE.MathUtils.radToDeg(euler.z).toFixed(6) + " "; 64 | }, 65 | 66 | posToString: function(p) { 67 | return p.x.toFixed(6) + " " + p.y.toFixed(6) + " " + p.z.toFixed(6) + " "; 68 | }, 69 | 70 | export: function(action, skeleton, clip) { 71 | 72 | let bvh = ""; 73 | const framerate = 1 / 30; 74 | const numFrames = 1 + Math.floor(clip.duration / framerate); 75 | 76 | this.skeleton = skeleton; 77 | skeleton.pose(); // needs to be in bind pose (tpose) 78 | 79 | bvh += "HIERARCHY\n"; 80 | 81 | if (skeleton.bones[0] == undefined) { 82 | console.error("Can not export skeleton with no bones"); 83 | return; 84 | } 85 | 86 | bvh += this.exportBone(skeleton.bones[0], 0); 87 | 88 | bvh += "MOTION\n"; 89 | bvh += "Frames: " + numFrames + "\n"; 90 | bvh += "Frame Time: " + framerate + "\n"; 91 | 92 | const interpolants = action._interpolants; 93 | 94 | const getBoneFrameData = (time, bone) => { 95 | 96 | let data = ""; 97 | 98 | // End site 99 | if(!bone.children.length) 100 | return data; 101 | 102 | // const tracks = clip.tracks.filter( t => t.name.replaceAll(".bones").split(".")[0].includes(bone.name) ); 103 | const tracks = clip.tracks.filter( t => { 104 | let name = t.name.replaceAll(".bones"); 105 | let idx = name.lastIndexOf("."); 106 | if ( idx >= 0 ){ 107 | name = name.slice( 0, idx ); 108 | } 109 | return name === bone.name; 110 | } ); 111 | 112 | const pos = new THREE.Vector3(0,0,0); 113 | const quat = new THREE.Quaternion(0,0,0,1); 114 | 115 | // No animation info 116 | for(let i = 0; i < tracks.length; ++i) { 117 | 118 | const t = tracks[i]; 119 | const trackIndex = clip.tracks.indexOf( t ); 120 | const interpolant = interpolants[ trackIndex ]; 121 | const values = interpolant.evaluate(time); 122 | 123 | const type = t.name.replaceAll(".bones").split(".")[1]; 124 | switch(type) { 125 | case 'position': 126 | // threejs animation clips store a position which will be attached to the bone each frame. 127 | // However, BVH position track stores the translation from the bone's offset defined in HERIARCHY 128 | if (values.length) { 129 | pos.fromArray(values.slice(0, 3)); 130 | pos.sub(bone.position); 131 | } 132 | break; 133 | case 'quaternion': // retarget animation quaternion to the bvh bind posed skeleton 134 | if (values.length) { 135 | quat.fromArray(values.slice(0, 4)); 136 | let invWorldRot = this.skeleton.getBoneByName( bone.name ).getWorldQuaternion(new THREE.Quaternion()).invert(); 137 | let wordlParentBindRot = this.skeleton.getBoneByName( bone.name ).parent.getWorldQuaternion(new THREE.Quaternion()); 138 | quat.premultiply(wordlParentBindRot).multiply(invWorldRot); 139 | }else{ 140 | quat.set(0,0,0,1); 141 | } 142 | break; 143 | } 144 | } 145 | 146 | // TODO: check for channels in bone heriarchy to acurately determine which attributes and in which order should appear 147 | // add position track if root 148 | if ( !bone.parent || !bone.parent.isBone ){ 149 | data += this.posToString(pos); 150 | } 151 | data += this.quatToEulerString(quat); 152 | 153 | // process and append children's data (following HIERARCHY) 154 | for (const b of bone.children) 155 | data += getBoneFrameData(time, b); 156 | 157 | return data; 158 | } 159 | 160 | for( let frameIdx = 0; frameIdx < numFrames; ++frameIdx ) { 161 | bvh += getBoneFrameData(frameIdx * framerate, skeleton.bones[0]); 162 | bvh += "\n"; 163 | } 164 | 165 | this.skeleton = null; 166 | 167 | return bvh; 168 | }, 169 | 170 | exportCustom: function(action, skeleton, clip) { 171 | 172 | let bvh = ""; 173 | 174 | this.skeleton = skeleton; 175 | 176 | bvh += "HIERARCHY\n"; 177 | 178 | if (skeleton.bones[0] == undefined) { 179 | console.error("Can not export skeleton with no bones"); 180 | return; 181 | } 182 | 183 | bvh += this.exportBone(skeleton.bones[0], 0); 184 | 185 | bvh += "MOTION\n"; 186 | 187 | const interpolants = action._interpolants; 188 | 189 | const getBoneFrameData = (bone) => { 190 | 191 | let data = ""; 192 | 193 | // End site 194 | if(!bone.children.length) 195 | return data; 196 | 197 | const tracks = clip.tracks.filter( t => t.name.replaceAll(".bones").split(".")[0].includes(bone.name) ); 198 | 199 | if(tracks.length) { 200 | data += "\n" + bone.name; 201 | } 202 | 203 | for(let i = 0; i < tracks.length; ++i) { 204 | 205 | const t = tracks[i]; 206 | const type = t.name.replaceAll(".bones").split(".")[1]; 207 | data += "\n" + type + " @"; 208 | 209 | for( let j = 0; j < t.times.length; ++j ) { 210 | 211 | data += t.times[j] + " "; 212 | 213 | switch(type) { 214 | case 'position': 215 | const pos = new THREE.Vector3(); 216 | pos.fromArray(t.values.slice(j * 3, j * 3 + 3)); 217 | data += this.posToString(pos); 218 | break; 219 | case 'quaternion': 220 | const q = new THREE.Quaternion(); 221 | q.fromArray(t.values.slice(j * 4, j * 4 + 4)); 222 | data += this.quatToEulerString(q); 223 | } 224 | } 225 | 226 | } 227 | 228 | for (const b of bone.children) 229 | data += getBoneFrameData(b); 230 | 231 | return data; 232 | } 233 | 234 | bvh += getBoneFrameData(skeleton.bones[0]); 235 | 236 | this.skeleton = null; 237 | 238 | return bvh; 239 | }, 240 | 241 | exportMorphTargets: function(action, morphTargetDictionary, clip) { 242 | 243 | if ( !action || !morphTargetDictionary || !clip || !clip.tracks.length ){ 244 | return ""; 245 | } 246 | 247 | let bvh = ""; 248 | const framerate = 1 / 30; 249 | const numFrames = 1 + Math.floor(clip.duration / framerate); 250 | 251 | bvh += "BLENDSHAPES\n"; 252 | bvh += '{\n'; 253 | if (morphTargetDictionary == undefined) { 254 | console.error("Can not export animation with morph targets"); 255 | return; 256 | } 257 | let morphTargets = Object.keys(morphTargetDictionary); 258 | morphTargets.map((v) => {bvh += "\t" + v + "\n"}); 259 | bvh += "}\n"; 260 | bvh += "MOTION\n"; 261 | bvh += "Frames: " + numFrames + "\n"; 262 | bvh += "Frame Time: " + framerate + "\n"; 263 | 264 | const interpolants = action._interpolants; 265 | if(!interpolants.length) { 266 | return bvh; 267 | } 268 | const getMorphTargetFrameData = (time, morphTarget) => { 269 | 270 | let data = ""; 271 | for(let idx = 0; idx < morphTarget.length; idx++) 272 | { 273 | const tracks = clip.tracks.filter( t => t.name.includes('[' + morphTarget[idx] + ']') ); 274 | // No animation info 275 | if(!tracks.length){ 276 | data += "0.000 "; // TO DO consider removing the blendshape instead of filling with 0 277 | // console.warn("No tracks for " + morphTarget[idx]) 278 | } 279 | else { 280 | 281 | const t = tracks[0]; 282 | const trackIndex = clip.tracks.indexOf( t ); 283 | const interpolant = interpolants[ trackIndex ]; 284 | const values = interpolant.evaluate(time); 285 | data += values[0].toFixed(3) + " "; 286 | 287 | } 288 | } 289 | 290 | return data; 291 | } 292 | 293 | for( let frameIdx = 0; frameIdx < numFrames; ++frameIdx ) { 294 | bvh += getMorphTargetFrameData(frameIdx * framerate, morphTargets); 295 | bvh += "\n"; 296 | } 297 | 298 | return bvh; 299 | }, 300 | }; 301 | 302 | export { BVHExporter } -------------------------------------------------------------------------------- /demo/BVHeLoader.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { BVHLoader } from 'https://cdn.skypack.dev/three@0.136/examples/jsm/loaders/BVHLoader.js'; 3 | 4 | // Overwrite/add methods 5 | 6 | /* 7 | reads a string array (lines) from a BVHE file 8 | and outputs a skeleton structure including motion data 9 | 10 | returns thee root node: 11 | { name: '', channels: [], children: [] } 12 | */ 13 | BVHLoader.prototype.parseExtended = function(text) { 14 | 15 | function readBvh( lines ) { 16 | 17 | // read model structure 18 | let boneRoot = null; 19 | const bonesList = []; // collects flat array of all bones 20 | 21 | let bs = null; 22 | let firstLine = nextLine( lines ); 23 | 24 | if ( firstLine == 'HIERARCHY' ) { 25 | 26 | boneRoot = readNode( lines, nextLine( lines ), bonesList ); 27 | firstLine = nextLine( lines ); 28 | 29 | // read motion data 30 | if ( firstLine !== 'MOTION' ) { 31 | 32 | console.error( 'THREE.BVHLoader: MOTION expected.' ); 33 | 34 | } 35 | 36 | // number of frames 37 | let tokens = nextLine( lines ).split( /[\s]+/ ); 38 | const numFrames = parseInt( tokens[ 1 ] ); 39 | 40 | if ( isNaN( numFrames ) ) { 41 | 42 | console.error( 'THREE.BVHLoader: Failed to read number of frames.' ); 43 | } 44 | 45 | // frame time 46 | tokens = nextLine( lines ).split( /[\s]+/ ); 47 | const frameTime = parseFloat( tokens[ 2 ] ); 48 | 49 | if ( isNaN( frameTime ) ) { 50 | 51 | console.error( 'THREE.BVHLoader: Failed to read frame time.' ); 52 | 53 | } 54 | 55 | // read frame data line by line /**CHANGE IT TO SUPPORT BLENDSHAPES ANIMATION */ 56 | for ( let i = 0; i < numFrames; i ++ ) { 57 | 58 | tokens = nextLine( lines ).split( /[\s]+/ ); 59 | if(boneRoot) { 60 | readFrameBoneData( tokens, i * frameTime, boneRoot ); 61 | } 62 | } 63 | 64 | } 65 | 66 | if(lines.length > 1) { 67 | 68 | firstLine = nextLine( lines ) 69 | if ( firstLine == 'BLENDSHAPES' ) { 70 | //console.error( 'THREE.BVHLoader: HIERARCHY expected.' ); 71 | const bsList = []; // collects flat array of all blendshapes 72 | bs = readBlendshape( lines, nextLine( lines ), bsList ); 73 | firstLine = nextLine( lines ); 74 | 75 | // read motion data 76 | if ( firstLine !== 'MOTION' ) { 77 | 78 | console.error( 'THREE.BVHLoader: MOTION expected.' ); 79 | } 80 | 81 | // number of frames 82 | let tokens = nextLine( lines ).split( /[\s]+/ ); 83 | const numFrames = parseInt( tokens[ 1 ] ); 84 | 85 | if ( isNaN( numFrames ) ) { 86 | 87 | console.error( 'THREE.BVHLoader: Failed to read number of frames.' ); 88 | 89 | } 90 | 91 | // frame time 92 | tokens = nextLine( lines ).split( /[\s]+/ ); 93 | const frameTime = parseFloat( tokens[ 2 ] ); 94 | 95 | if ( isNaN( frameTime ) ) { 96 | 97 | console.error( 'THREE.BVHLoader: Failed to read frame time.' ); 98 | 99 | } 100 | 101 | // read frame data line by line /**CHANGE IT TO SUPPORT BLENDSHAPES ANIMATION */ 102 | 103 | for ( let i = 0; i < numFrames; i ++ ) { 104 | 105 | tokens = nextLine( lines ).split( /[\s]+/ ); 106 | if(bs) { 107 | readFrameBSData( tokens, i * frameTime, bs ); 108 | } 109 | 110 | } 111 | } 112 | 113 | } 114 | 115 | return {bones: bonesList, blendshapes: bs}; 116 | } 117 | 118 | /* 119 | Recursively reads data from a single frame into the bone hierarchy. 120 | The passed bone hierarchy has to be structured in the same order as the BVH file. 121 | keyframe data is stored in bone.frames. 122 | 123 | - data: splitted string array (frame values), values are shift()ed so 124 | this should be empty after parsing the whole hierarchy. 125 | - frameTime: playback time for this keyframe. 126 | - bone: the bone to read frame data from. 127 | */ 128 | function readFrameBoneData( data, frameTime, bone ) { 129 | 130 | // end sites have no motion data 131 | 132 | if ( bone.type === 'ENDSITE' ) return; 133 | 134 | // add keyframe 135 | 136 | const keyframe = { 137 | time: frameTime, 138 | position: new THREE.Vector3(), 139 | rotation: new THREE.Quaternion() 140 | }; 141 | 142 | bone.frames.push( keyframe ); 143 | 144 | const quat = new THREE.Quaternion(); 145 | 146 | const vx = new THREE.Vector3( 1, 0, 0 ); 147 | const vy = new THREE.Vector3( 0, 1, 0 ); 148 | const vz = new THREE.Vector3( 0, 0, 1 ); 149 | 150 | // parse values for each channel in node 151 | 152 | for ( let i = 0; i < bone.channels.length; i ++ ) { 153 | 154 | switch ( bone.channels[ i ] ) { 155 | 156 | case 'Xposition': 157 | keyframe.position.x = parseFloat( data.shift().trim() ); 158 | break; 159 | case 'Yposition': 160 | keyframe.position.y = parseFloat( data.shift().trim() ); 161 | break; 162 | case 'Zposition': 163 | keyframe.position.z = parseFloat( data.shift().trim() ); 164 | break; 165 | case 'Xrotation': 166 | quat.setFromAxisAngle( vx, parseFloat( data.shift().trim() ) * Math.PI / 180 ); 167 | keyframe.rotation.multiply( quat ); 168 | break; 169 | case 'Yrotation': 170 | quat.setFromAxisAngle( vy, parseFloat( data.shift().trim() ) * Math.PI / 180 ); 171 | keyframe.rotation.multiply( quat ); 172 | break; 173 | case 'Zrotation': 174 | quat.setFromAxisAngle( vz, parseFloat( data.shift().trim() ) * Math.PI / 180 ); 175 | keyframe.rotation.multiply( quat ); 176 | break; 177 | default: 178 | console.warn( 'THREE.BVHLoader: Invalid channel type.' ); 179 | 180 | } 181 | 182 | } 183 | 184 | // parse child nodes 185 | 186 | for ( let i = 0; i < bone.children.length; i ++ ) { 187 | 188 | readFrameBoneData( data, frameTime, bone.children[ i ] ); 189 | 190 | } 191 | 192 | } 193 | 194 | /* 195 | Recursively reads data from a single frame into the bone hierarchy. 196 | The passed bone hierarchy has to be structured in the same order as the BVH file. 197 | keyframe data is stored in bone.frames. 198 | 199 | - data: splitted string array (frame values), values are shift()ed so 200 | this should be empty after parsing the whole hierarchy. 201 | - frameTime: playback time for this keyframe. 202 | - bs: blendshapes array to read frame data from. 203 | */ 204 | function readFrameBSData( data, frameTime, bs ) { 205 | 206 | for( let i = 0; i < bs.length; i++ ) { 207 | // add keyframe 208 | if(!data.length) { 209 | return; 210 | } 211 | const keyframe = { 212 | time: frameTime, 213 | weight: 0 214 | }; 215 | 216 | bs[i].frames.push( keyframe ); 217 | // parse values in node 218 | keyframe.weight = parseFloat( data.shift().trim() ); 219 | } 220 | } 221 | 222 | /* 223 | Recursively parses the HIERACHY section of the BVH file 224 | 225 | - lines: all lines of the file. lines are consumed as we go along. 226 | - firstline: line containing the node type and name e.g. 'JOINT hip' 227 | - list: collects a flat list of nodes 228 | 229 | returns: a BVH node including children 230 | */ 231 | function readNode( lines, firstline, list ) { 232 | 233 | const node = { name: '', type: '', frames: [] }; 234 | list.push( node ); 235 | 236 | // parse node type and name 237 | 238 | let tokens = firstline.split( /[\s]+/ ); 239 | 240 | if ( tokens[ 0 ].toUpperCase() === 'END' && tokens[ 1 ].toUpperCase() === 'SITE' ) { 241 | 242 | node.type = 'ENDSITE'; 243 | node.name = 'ENDSITE'; // bvh end sites have no name 244 | 245 | } else { 246 | 247 | node.name = tokens[ 1 ]; 248 | node.type = tokens[ 0 ].toUpperCase(); 249 | 250 | } 251 | 252 | if ( nextLine( lines ) !== '{' ) { 253 | 254 | console.error( 'THREE.BVHLoader: Expected opening { after type & name' ); 255 | 256 | } 257 | 258 | // parse OFFSET 259 | 260 | tokens = nextLine( lines ).split( /[\s]+/ ); 261 | 262 | if ( tokens[ 0 ] !== 'OFFSET' ) { 263 | 264 | console.error( 'THREE.BVHLoader: Expected OFFSET but got: ' + tokens[ 0 ] ); 265 | 266 | } 267 | 268 | if ( tokens.length !== 4 ) { 269 | 270 | console.error( 'THREE.BVHLoader: Invalid number of values for OFFSET.' ); 271 | 272 | } 273 | 274 | const offset = new THREE.Vector3( 275 | parseFloat( tokens[ 1 ] ), 276 | parseFloat( tokens[ 2 ] ), 277 | parseFloat( tokens[ 3 ] ) 278 | ); 279 | 280 | if ( isNaN( offset.x ) || isNaN( offset.y ) || isNaN( offset.z ) ) { 281 | 282 | console.error( 'THREE.BVHLoader: Invalid values of OFFSET.' ); 283 | 284 | } 285 | 286 | node.offset = offset; 287 | 288 | // parse CHANNELS definitions 289 | 290 | if ( node.type !== 'ENDSITE' ) { 291 | 292 | tokens = nextLine( lines ).split( /[\s]+/ ); 293 | 294 | if ( tokens[ 0 ] !== 'CHANNELS' ) { 295 | 296 | console.error( 'THREE.BVHLoader: Expected CHANNELS definition.' ); 297 | 298 | } 299 | 300 | const numChannels = parseInt( tokens[ 1 ] ); 301 | node.channels = tokens.splice( 2, numChannels ); 302 | node.children = []; 303 | 304 | } 305 | 306 | // read children 307 | 308 | while ( true ) { 309 | 310 | const line = nextLine( lines ); 311 | 312 | if ( line === '}' ) { 313 | 314 | return node; 315 | 316 | } else { 317 | 318 | node.children.push( readNode( lines, line, list ) ); 319 | 320 | } 321 | 322 | } 323 | 324 | } 325 | 326 | /* 327 | Recursively parses the BLENDSHAPES section of the BVH file 328 | 329 | - lines: all lines of the file. lines are consumed as we go along. 330 | - firstline: line containing the blendshape name e.g. 'Blink_Left' and the skinning meshes names that have this morph target 331 | - list: collects a flat list of blendshapes 332 | 333 | returns: a BVH node including children 334 | */ 335 | function readBlendshape( lines, line, list ) { 336 | 337 | while ( true ) { 338 | let line = nextLine( lines ); 339 | 340 | if ( line === '{' ) continue; 341 | if ( line === '}' ) return list; 342 | 343 | let node = { name: '', meshes: [], frames: [] }; 344 | list.push( node ); 345 | 346 | // parse node type and name 347 | 348 | let tokens = line.split( /[\s]+/ ); 349 | 350 | node.name = tokens[ 0 ]; 351 | 352 | for(let i = 1; i < tokens.length; i++){ 353 | 354 | node.meshes.push(tokens[ i ]); 355 | 356 | } 357 | 358 | 359 | } 360 | 361 | } 362 | 363 | /* 364 | recursively converts the internal bvh node structure to a Bone hierarchy 365 | 366 | source: the bvh root node 367 | list: pass an empty array, collects a flat list of all converted THREE.Bones 368 | 369 | returns the root Bone 370 | */ 371 | function toTHREEBone( source, list ) { 372 | 373 | const bone = new THREE.Bone(); 374 | list.push( bone ); 375 | 376 | bone.position.add( source.offset ); 377 | bone.name = source.name; 378 | 379 | if ( source.type !== 'ENDSITE' ) { 380 | 381 | for ( let i = 0; i < source.children.length; i ++ ) { 382 | 383 | bone.add( toTHREEBone( source.children[ i ], list ) ); 384 | 385 | } 386 | 387 | } 388 | 389 | return bone; 390 | 391 | } 392 | 393 | /* 394 | builds a AnimationClip from the keyframe data saved in each bone. 395 | 396 | bone: bvh root node 397 | 398 | returns: a AnimationClip containing position and quaternion tracks 399 | */ 400 | function toTHREEAnimation( bones, blendshapes ) { 401 | 402 | const boneTracks = []; 403 | 404 | // create a position and quaternion animation track for each node 405 | 406 | for ( let i = 0; i < bones.length; i ++ ) { 407 | 408 | const bone = bones[ i ]; 409 | 410 | if ( bone.type === 'ENDSITE' ) 411 | continue; 412 | 413 | // track data 414 | 415 | const times = []; 416 | const positions = []; 417 | const rotations = []; 418 | 419 | for ( let j = 0; j < bone.frames.length; j ++ ) { 420 | 421 | const frame = bone.frames[ j ]; 422 | 423 | times.push( frame.time ); 424 | 425 | // the animation system animates the position property, 426 | // so we have to add the joint offset to all values 427 | 428 | positions.push( frame.position.x + bone.offset.x ); 429 | positions.push( frame.position.y + bone.offset.y ); 430 | positions.push( frame.position.z + bone.offset.z ); 431 | 432 | rotations.push( frame.rotation.x ); 433 | rotations.push( frame.rotation.y ); 434 | rotations.push( frame.rotation.z ); 435 | rotations.push( frame.rotation.w ); 436 | 437 | } 438 | 439 | if ( scope.animateBonePositions ) { 440 | 441 | boneTracks.push( new THREE.VectorKeyframeTrack( bone.name + '.position', times, positions ) ); 442 | 443 | } 444 | 445 | if ( scope.animateBoneRotations ) { 446 | 447 | boneTracks.push( new THREE.QuaternionKeyframeTrack( bone.name + '.quaternion', times, rotations ) ); 448 | 449 | } 450 | 451 | } 452 | 453 | const bsTracks = []; 454 | if(blendshapes) { 455 | for ( let i = 0; i < blendshapes.length; i ++ ) { 456 | 457 | const bs = blendshapes[ i ]; 458 | // track data 459 | 460 | const times = []; 461 | const weights = []; 462 | 463 | for ( let j = 0; j < bs.frames.length; j ++ ) { 464 | const frame = bs.frames[ j ]; 465 | 466 | times.push( frame.time ); 467 | 468 | // the animation system animates the morphInfluences property, 469 | // so we have to add the blendhsape weight to all values 470 | 471 | weights.push( frame.weight ); 472 | } 473 | 474 | if( bs.meshes.length ) { 475 | 476 | for( let b = 0; b < bs.meshes.length; b++) { 477 | 478 | bsTracks.push( new THREE.NumberKeyframeTrack( bs.meshes[b] + '.morphTargetInfluences[' + bs.name + ']', times, weights ) ); 479 | } 480 | } 481 | else { 482 | 483 | bsTracks.push( new THREE.NumberKeyframeTrack( 'Body' + '.morphTargetInfluences[' + bs.name + ']', times, weights ) ); 484 | } 485 | 486 | } 487 | } 488 | return { skeletonClip: new THREE.AnimationClip( 'skeletonAnimation', - 1, boneTracks ), blendshapesClip: new THREE.AnimationClip( 'bsAnimation', - 1, bsTracks )}; 489 | 490 | } 491 | 492 | /* 493 | returns the next non-empty line in lines 494 | */ 495 | function nextLine( lines ) { 496 | 497 | let line; 498 | // skip empty lines 499 | while ( ( line = lines.shift().trim() ).length === 0 ) { } 500 | 501 | return line; 502 | 503 | } 504 | 505 | const scope = this; 506 | 507 | const lines = text.split( /[\r\n]+/g ); 508 | 509 | const {bones, blendshapes} = readBvh( lines ); 510 | 511 | const threeBones = []; 512 | if(bones.length) 513 | toTHREEBone( bones[ 0 ], threeBones ); 514 | 515 | const {skeletonClip, blendshapesClip } = toTHREEAnimation( bones, blendshapes ); 516 | 517 | return { 518 | skeletonAnim: { 519 | skeleton: skeletonClip.tracks.length ? new THREE.Skeleton( threeBones ) : null, 520 | clip: skeletonClip 521 | }, 522 | blendshapesAnim: { 523 | clip: blendshapesClip 524 | } 525 | }; 526 | } 527 | 528 | export { BVHLoader } -------------------------------------------------------------------------------- /demo/app.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; 3 | import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; 4 | import { FBXLoader } from 'three/addons/loaders/FBXLoader.js' 5 | import { BVHLoader } from './BVHeLoader.js'; 6 | import { BVHExporter } from './BVHExporter.js'; 7 | import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js' 8 | import { Gui } from './gui.js' 9 | import { AnimationRetargeting, applyTPose } from '../retargeting.js' 10 | import BoneMappingScene from './boneMapping.js'; 11 | 12 | class App { 13 | constructor() { 14 | 15 | this.elapsedTime = 0; // clock is ok but might need more time control to dinamicaly change signing speed 16 | this.clock = new THREE.Clock(); 17 | this.loaderBVH = new BVHLoader(); 18 | this.loaderGLB = new GLTFLoader(); 19 | this.loaderFBX = new FBXLoader(); 20 | this.GLTFExporter = new GLTFExporter(); 21 | this.currentCharacter = ""; 22 | this.loadedCharacters = {}; // store avatar loadedCharacters 23 | 24 | this.currentAnimation = ""; 25 | this.loadedAnimations = {}; 26 | this.bindedAnimations = {}; 27 | 28 | this.mixer = null; 29 | this.playing = false; 30 | 31 | this.speed = 1; 32 | this.showSkeletons = true; 33 | this.gui = null; 34 | this.retargeting = null; 35 | 36 | this.srcPoseMode = AnimationRetargeting.BindPoseModes.DEFAULT; 37 | this.trgPoseMode = AnimationRetargeting.BindPoseModes.DEFAULT; 38 | this.srcEmbeddedTransforms = true; 39 | this.trgEmbeddedTransforms = true; 40 | this.boneMap = null; 41 | this.autoBoneMap = true; 42 | this.boneMapScene = new BoneMappingScene(Object.keys(AnimationRetargeting.boneMap)); 43 | } 44 | 45 | init() { 46 | this.scene = new THREE.Scene(); 47 | let sceneColor = 0xa0a0a0;//0x303030; 48 | this.scene.background = new THREE.Color( sceneColor ); 49 | this.scene.fog = new THREE.Fog( sceneColor, 10, 50 ); 50 | 51 | // renderer 52 | this.renderer = new THREE.WebGLRenderer( { antialias: true } ); 53 | this.renderer.setPixelRatio( window.devicePixelRatio ); 54 | this.renderer.setSize( window.innerWidth, window.innerHeight ); 55 | 56 | this.renderer.toneMapping = THREE.LinearToneMapping; 57 | this.renderer.toneMappingExposure = 1; 58 | // this.renderer.shadowMap.enabled = false; 59 | document.body.appendChild( this.renderer.domElement ); 60 | 61 | //include lights 62 | const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); 63 | this.scene.add(ambientLight); 64 | 65 | const hemiLight = new THREE.HemisphereLight( 0xffffff, 0xffffff, 2 ); 66 | hemiLight.position.set( 0, 50, 0 ); 67 | this.scene.add( hemiLight ); 68 | 69 | const dirLight = new THREE.DirectionalLight( 0xffffff, 3 ); 70 | dirLight.position.set( - 1, 1.75, 1 ); 71 | dirLight.position.multiplyScalar( 30 ); 72 | this.scene.add( dirLight ); 73 | 74 | dirLight.castShadow = true; 75 | 76 | dirLight.shadow.mapSize.width = 2048; 77 | dirLight.shadow.mapSize.height = 2048; 78 | 79 | const d = 50; 80 | 81 | dirLight.shadow.camera.left = - d; 82 | dirLight.shadow.camera.right = d; 83 | dirLight.shadow.camera.top = d; 84 | dirLight.shadow.camera.bottom = - d; 85 | 86 | dirLight.shadow.camera.far = 3500; 87 | dirLight.shadow.bias = - 0.0001; 88 | 89 | // add entities 90 | let ground = new THREE.Mesh( new THREE.PlaneGeometry( 300, 300 ), new THREE.MeshStandardMaterial( { color: 0xcbcbcb, depthWrite: true, roughness: 1, metalness: 0 } ) ); 91 | ground.rotation.x = -Math.PI / 2; 92 | ground.receiveShadow = true; 93 | this.scene.add( ground ); 94 | 95 | const grid = new THREE.GridHelper(300, 300, 0x101010, 0x555555 ); 96 | grid.name = "Grid"; 97 | this.scene.add(grid); 98 | 99 | this.camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.01, 1000); 100 | this.camera.position.set(0,1.2,2); 101 | this.controls = new OrbitControls( this.camera, this.renderer.domElement ); 102 | this.controls.target.set(0, 1, 0); 103 | this.controls.enableDamping = true; // this requires controls.update() during application update 104 | this.controls.dampingFactor = 0.1; 105 | this.controls.enabled = true; 106 | this.controls.update(); 107 | 108 | this.renderer.render( this.scene,this.camera ); 109 | 110 | const queryString = window.location.search; 111 | const urlParams = new URLSearchParams(queryString); 112 | let showControls = true; 113 | if(urlParams.has('controls')) { 114 | showControls = !(urlParams.get('controls') === "false"); 115 | } 116 | let modelToLoad = ['https://resources.gti.upf.edu/3Dcharacters/Woman/Woman.glb', (new THREE.Quaternion()).setFromAxisAngle( new THREE.Vector3(1,0,0), 0 ) ]; 117 | this.loadAvatar(modelToLoad[0], modelToLoad[1], "Woman", "glb", ()=>{ 118 | this.changeSourceAvatar( "Woman" ); 119 | }); 120 | 121 | modelToLoad = ['https://resources.gti.upf.edu/3Dcharacters/ReadyEva/ReadyEva.glb', (new THREE.Quaternion()).setFromAxisAngle( new THREE.Vector3(1,0,0), 0 ) ]; 122 | this.loadAvatar(modelToLoad[0], modelToLoad[1], "ReadyEva", "glb", ()=>{ 123 | this.gui = new Gui( this ); 124 | this.changeAvatar( "ReadyEva" ); 125 | this.animate(); 126 | document.getElementById("loading").style.display = "none"; 127 | this.isAppReady = true; 128 | 129 | }); 130 | 131 | window.addEventListener( 'resize', this.onWindowResize.bind(this) ); 132 | } 133 | 134 | animate() { 135 | 136 | requestAnimationFrame( this.animate.bind(this) ); 137 | 138 | let delta = this.clock.getDelta() 139 | delta *= this.speed; 140 | this.elapsedTime += delta; 141 | 142 | this.update(delta); 143 | this.controls.update(); 144 | this.boneMapScene.update(); 145 | const zoom = this.controls.getDistance(); 146 | if( zoom > 80) { 147 | this.scene.getObjectByName("Grid").visible = false; 148 | 149 | this.scene.fog.near = zoom; 150 | this.scene.fog.far = zoom + 100; 151 | } 152 | else { 153 | this.scene.getObjectByName("Grid").visible = true; 154 | this.scene.fog.near = 20; 155 | this.scene.fog.far = zoom + 50; 156 | } 157 | this.renderer.render( this.scene, this.camera ); 158 | } 159 | 160 | update( deltaTime ) { 161 | this.elapsedTime += deltaTime; 162 | if (this.playing) { 163 | if(this.mixer) { 164 | this.mixer.update( deltaTime ); 165 | } 166 | if(this.sourceMixer) { 167 | this.sourceMixer.update( deltaTime ); 168 | } 169 | } 170 | } 171 | 172 | changeAvatar( avatarName ) { 173 | let current = this.loadedCharacters[this.currentCharacter]; 174 | if ( current) { 175 | this.scene.remove( current.model ); // delete from scene current model 176 | this.scene.remove( current.skeletonHelper ); // delete skeleton helper from scene 177 | } 178 | 179 | this.currentCharacter = avatarName; 180 | const character = this.loadedCharacters[this.currentCharacter]; 181 | this.scene.add( character.model ); // add model to scene 182 | if(character.skeletonHelper) { 183 | character.skeletonHelper.visible = this.showSkeletons; 184 | this.scene.add( character.skeletonHelper ); // add skeleton helper to scene 185 | } 186 | character.model.position.x = 1; 187 | this.onChangeAvatar(avatarName); 188 | this.retargeting = null; 189 | 190 | if ( this.gui ){ this.gui.refresh(); } 191 | } 192 | 193 | changeSourceAvatar( avatarName ) { 194 | let current = this.loadedCharacters[this.currentSourceCharacter]; 195 | if ( current) { 196 | this.scene.remove( current.model ); // delete from scene current model 197 | this.scene.remove( current.skeletonHelper ); // delete skeleton helper from scene 198 | } 199 | 200 | this.currentSourceCharacter = avatarName; 201 | const character = this.loadedCharacters[this.currentSourceCharacter]; 202 | this.scene.add( character.model ); // add model to scene 203 | if(character.skeletonHelper) { 204 | character.skeletonHelper.visible = this.showSkeletons; 205 | this.scene.add( character.skeletonHelper ); // add skeleton helper to scene 206 | } 207 | this.sourceMixer = this.loadedCharacters[avatarName].mixer; 208 | let animations = character.animations; 209 | if(animations && animations.length) { 210 | 211 | for(let i in animations) { 212 | const animation = animations[i]; 213 | this.loadedAnimations[animation.name] = { 214 | name: animation.name, 215 | animation: animation, 216 | skeleton: character.skeleton, 217 | type: "bvhe" 218 | }; 219 | } 220 | } 221 | this.currentAnimation = ""; 222 | this.bindedAnimations = {}; 223 | 224 | this.retargeting = null; 225 | 226 | if ( this.gui ){ this.gui.refresh(); } 227 | } 228 | 229 | loadAvatar( modelFilePath, modelRotation, avatarName, extension, callback = null ) { 230 | 231 | if(extension == "fbx") { 232 | this.loaderFBX.load( modelFilePath, (fbx) => { 233 | console.log(fbx) 234 | let model = fbx; 235 | model.quaternion.premultiply( modelRotation ); 236 | model.castShadow = true; 237 | let skeleton = null; 238 | let bones = []; 239 | if(avatarName == "Witch") { 240 | model.traverse( (object) => { 241 | if ( object.isMesh || object.isSkinnedMesh ) { 242 | 243 | if(!object.name.includes("Hat")) 244 | object.material.side = THREE.FrontSide; 245 | object.frustumCulled = false; 246 | object.castShadow = true; 247 | object.receiveShadow = true; 248 | if (object.name == "Eyelashes") // eva 249 | object.castShadow = false; 250 | if(object.material.map) 251 | object.material.map.anisotropy = 16; 252 | if(object.name == "Hair") { 253 | object.material.map = null; 254 | object.material.color.set(0x6D1881); 255 | } 256 | if(object.name.includes("Bottom")) { 257 | object.material.map = null; 258 | object.material.color.set(0x000000); 259 | } 260 | if(object.name.includes("Top")) { 261 | object.material.map = null; 262 | object.material.color.set(0x000000); 263 | } 264 | if(object.name.includes("Shoes")) { 265 | object.material.map = null; 266 | object.material.color.set(0x19A7A3); 267 | } 268 | } else if (object.isBone) { 269 | object.scale.set(1.0, 1.0, 1.0); 270 | bones.push(object); 271 | } 272 | if (object.skeleton){ 273 | skeleton = object.skeleton; 274 | } 275 | } ); 276 | }else{ 277 | model.traverse( (object) => { 278 | if ( object.isMesh || object.isSkinnedMesh ) { 279 | object.material.side = THREE.FrontSide; 280 | object.frustumCulled = false; 281 | object.castShadow = true; 282 | object.receiveShadow = true; 283 | if (object.name == "Eyelashes") // eva 284 | object.castShadow = false; 285 | if(object.material.map) 286 | object.material.map.anisotropy = 16; 287 | } else if(object.isBone) { 288 | bones.push(object); 289 | } 290 | if (object.skeleton){ 291 | skeleton = object.skeleton; 292 | } 293 | } ); 294 | 295 | } 296 | 297 | if ( avatarName == "Kevin" ){ 298 | let hair = model.getObjectByName( "Classic_short" ); 299 | if( hair && hair.children.length > 1 ){ hair.children[1].renderOrder = 1; } 300 | } 301 | 302 | model.name = avatarName; 303 | 304 | let animations = fbx.animations; 305 | 306 | if(!skeleton && bones.length) { 307 | skeleton = new THREE.Skeleton(bones); 308 | for(let i = 0; i < animations.length; i++) { 309 | this.loadBVHAnimation(avatarName, {skeletonAnim :{skeleton, clip: animations[i]}}, i == (animations.length - 1) ? callback : null) 310 | } 311 | return; 312 | } 313 | let skeletonHelper = new THREE.SkeletonHelper(skeleton.bones[0]); 314 | this.loadedCharacters[avatarName] ={ 315 | model, skeleton, animations, skeletonHelper 316 | } 317 | 318 | this.onLoadAvatar(model, avatarName); 319 | if (callback) { 320 | callback(animations); 321 | } 322 | 323 | }); 324 | } 325 | else { 326 | this.loaderGLB.load( modelFilePath, (glb) => { 327 | let model = glb.scene; 328 | model.quaternion.premultiply( modelRotation ); 329 | model.castShadow = true; 330 | let skeleton = null; 331 | let bones = []; 332 | if(avatarName == "Witch") { 333 | model.traverse( (object) => { 334 | if ( object.isMesh || object.isSkinnedMesh ) { 335 | 336 | if(!object.name.includes("Hat")) 337 | object.material.side = THREE.FrontSide; 338 | object.frustumCulled = false; 339 | object.castShadow = true; 340 | object.receiveShadow = true; 341 | if (object.name == "Eyelashes") // eva 342 | object.castShadow = false; 343 | if(object.material.map) 344 | object.material.map.anisotropy = 16; 345 | if(object.name == "Hair") { 346 | object.material.map = null; 347 | object.material.color.set(0x6D1881); 348 | } 349 | if(object.name.includes("Bottom")) { 350 | object.material.map = null; 351 | object.material.color.set(0x000000); 352 | } 353 | if(object.name.includes("Top")) { 354 | object.material.map = null; 355 | object.material.color.set(0x000000); 356 | } 357 | if(object.name.includes("Shoes")) { 358 | object.material.map = null; 359 | object.material.color.set(0x19A7A3); 360 | } 361 | } else if (object.isBone) { 362 | object.scale.set(1.0, 1.0, 1.0); 363 | bones.push(object); 364 | } 365 | if (object.skeleton){ 366 | skeleton = object.skeleton; 367 | } 368 | } ); 369 | }else{ 370 | model.traverse( (object) => { 371 | if ( object.isMesh || object.isSkinnedMesh ) { 372 | object.material.side = THREE.FrontSide; 373 | object.frustumCulled = false; 374 | object.castShadow = true; 375 | object.receiveShadow = true; 376 | if (object.name == "Eyelashes") // eva 377 | object.castShadow = false; 378 | if(object.material.map) 379 | object.material.map.anisotropy = 16; 380 | } else if(object.isBone) { 381 | bones.push(object); 382 | } 383 | if (object.skeleton){ 384 | skeleton = object.skeleton; 385 | } 386 | } ); 387 | 388 | } 389 | 390 | if ( avatarName == "Kevin" ){ 391 | let hair = model.getObjectByName( "Classic_short" ); 392 | if( hair && hair.children.length > 1 ){ hair.children[1].renderOrder = 1; } 393 | } 394 | 395 | model.name = avatarName; 396 | 397 | let animations = glb.animations; 398 | 399 | if(!skeleton && bones.length) { 400 | skeleton = new THREE.Skeleton(bones); 401 | for(let i = 0; i < animations.length; i++) { 402 | this.loadBVHAnimation(avatarName, {skeletonAnim :{skeleton, clip: animations[i]}}, i == (animations.length - 1) ? callback : null) 403 | } 404 | return; 405 | } 406 | let skeletonHelper = new THREE.SkeletonHelper(skeleton.bones[0]); 407 | this.loadedCharacters[avatarName] ={ 408 | model, skeleton, animations, skeletonHelper 409 | } 410 | // let skeleton2 = new THREE.SkeletonHelper(skeleton.bones[0]); 411 | // this.scene.add(skeleton2) 412 | this.onLoadAvatar(model, avatarName); 413 | if (callback) { 414 | callback(animations); 415 | } 416 | 417 | }); 418 | } 419 | } 420 | 421 | loadAnimation( modelFilePath, avatarName, callback = null ) { 422 | 423 | const data = this.loaderBVH.parseExtended(modelFilePath); 424 | this.loadBVHAnimation( avatarName, data, callback ); 425 | } 426 | 427 | changePlayState(state = !this.playing) { 428 | this.playing = state; 429 | } 430 | 431 | stopAnimation() { 432 | this.playing = false; 433 | if(this.mixer) { 434 | this.mixer.update(0); 435 | this.mixer.setTime(0); 436 | } 437 | if(this.sourceMixer) { 438 | this.sourceMixer.update(0); 439 | this.sourceMixer.setTime(0); 440 | } 441 | } 442 | changeSkeletonsVisibility(visibility) { 443 | this.showSkeletons = visibility; 444 | 445 | if(this.currentSourceCharacter && this.loadedCharacters[this.currentSourceCharacter].skeletonHelper) { 446 | this.loadedCharacters[this.currentSourceCharacter].skeletonHelper.visible = visibility; 447 | } 448 | if(this.currentCharacter) { 449 | this.loadedCharacters[this.currentCharacter].skeletonHelper.visible = visibility; 450 | } 451 | this.scene.getObjectByName("Grid").visible = visibility; 452 | } 453 | 454 | onLoadAvatar(newAvatar, name){ 455 | // Create mixer for animation 456 | const mixer = new THREE.AnimationMixer(newAvatar); 457 | this.loadedCharacters[name].mixer = mixer; 458 | } 459 | 460 | onChangeAvatar(avatarName) { 461 | if (!this.loadedCharacters[avatarName]) { 462 | return false; 463 | } 464 | this.currentCharacter = avatarName; 465 | this.changePlayState(this.playing); 466 | this.mixer = this.loadedCharacters[avatarName].mixer; 467 | this.boneMap = null; 468 | return true; 469 | } 470 | 471 | 472 | onChangeAnimation(animationName) { 473 | if(!this.loadedAnimations[animationName]) { 474 | console.warn(animationName + 'not found') 475 | } 476 | if(this.currentAnimation) { 477 | this.sourceMixer.uncacheClip(this.loadedAnimations[this.currentAnimation].animation); 478 | } 479 | this.sourceMixer.clipAction(this.loadedAnimations[animationName].animation).setEffectiveWeight(1.0).play(); 480 | this.sourceMixer.setTime(0); 481 | this.currentAnimation = animationName; 482 | if(this.retargeting) { 483 | 484 | this.bindAnimationToCharacter(this.currentAnimation, this.currentCharacter); 485 | this.mixer.setTime(0.01); 486 | this.mixer.setTime(0); 487 | 488 | } 489 | // this.bindAnimationToCharacter(this.currentAnimation, this.currentCharacter); 490 | } 491 | 492 | onWindowResize() { 493 | 494 | this.camera.aspect = window.innerWidth / window.innerHeight; 495 | this.camera.updateProjectionMatrix(); 496 | 497 | this.renderer.setSize( window.innerWidth, window.innerHeight ); 498 | } 499 | 500 | // load animation from bvhe file 501 | loadBVHAnimation(name, animationData, callback) { 502 | 503 | let skeleton = null; 504 | let bodyAnimation = null; 505 | let faceAnimation = null; 506 | if ( animationData && animationData.skeletonAnim ){ 507 | skeleton = animationData.skeletonAnim.skeleton; 508 | skeleton.bones.forEach( b => { b.name = b.name.replace( /[`~!@#$%^&*()|+\-=?;:'"<>\{\}\\\/]/gi, "") } ); 509 | // loader does not correctly compute the skeleton boneInverses and matrixWorld 510 | skeleton.bones[0].updateWorldMatrix( false, true ); // assume 0 is root 511 | skeleton = new THREE.Skeleton( skeleton.bones ); // will automatically compute boneInverses 512 | 513 | animationData.skeletonAnim.clip.tracks.forEach( b => { b.name = b.name.replace( /[`~!@#$%^&*()|+\-=?;:'"<>\{\}\\\/]/gi, "") } ); 514 | animationData.skeletonAnim.clip.name = name; 515 | bodyAnimation = animationData.skeletonAnim.clip; 516 | } 517 | 518 | if ( animationData && animationData.blendshapesAnim ){ 519 | animationData.blendshapesAnim.clip.name = "faceAnimation"; 520 | faceAnimation = animationData.blendshapesAnim.clip; 521 | } 522 | 523 | this.loadedAnimations[name] = { 524 | name: name, 525 | animation: bodyAnimation ?? new THREE.AnimationClip( "bodyAnimation", -1, [] ), 526 | faceAnimation, 527 | skeleton, 528 | type: "bvhe" 529 | }; 530 | 531 | let boneContainer = new THREE.Group(); 532 | boneContainer.add( skeleton.bones[0] ); 533 | boneContainer.position.x = -1; 534 | boneContainer.name = "Armature"; 535 | this.scene.add( boneContainer ); 536 | let skeletonHelper = new THREE.SkeletonHelper(boneContainer); 537 | skeletonHelper.name = name; 538 | skeletonHelper.skeleton = skeleton; 539 | skeletonHelper.changeColor( 0xFF0000, 0xFFFF00 ); 540 | 541 | this.loadedCharacters[name] ={ 542 | model: skeletonHelper, skeleton, animations: [this.loadedAnimations[name].animation] 543 | } 544 | this.onLoadAvatar(skeletonHelper, name); 545 | if (callback) { 546 | callback(this.loadedCharacters[name].animations); 547 | } 548 | } 549 | 550 | /** 551 | * KeyframeEditor: fetches a loaded animation and applies it to the character. The first time an animation is binded, it is processed and saved. Afterwards, this functino just changes between existing animations 552 | * @param {String} animationName 553 | * @param {String} characterName 554 | */ 555 | bindAnimationToCharacter(animationName, characterName) { 556 | 557 | let animationData = this.loadedAnimations[animationName]; 558 | if(!animationData) { 559 | console.warn(animationName + " not found"); 560 | return false; 561 | } 562 | this.currentAnimation = animationName; 563 | 564 | let currentCharacter = this.loadedCharacters[characterName]; 565 | if(!currentCharacter) { 566 | console.warn(characterName + ' not loaded') 567 | } 568 | // Remove current animation clip 569 | let mixer = currentCharacter.mixer; 570 | mixer.stopAllAction(); 571 | 572 | while(mixer._actions.length){ 573 | mixer.uncacheClip(mixer._actions[0]._clip); // removes action 574 | } 575 | //currentCharacter.skeleton.pose(); // for some reason, mixer.stopAllAction makes bone.position and bone.quaternions undefined. Ensure they have some values 576 | 577 | // if not yet binded, create it. Otherwise just change to the existing animation 578 | if ( !this.bindedAnimations[animationName] || !this.bindedAnimations[animationName][currentCharacter.name] ) { 579 | let bodyAnimation = animationData.animation; 580 | if(bodyAnimation) { 581 | 582 | let tracks = []; 583 | // Remove position changes (only keep i == 0, hips) 584 | for (let i = 0; i < bodyAnimation.tracks.length; i++) { 585 | 586 | if(!bodyAnimation.tracks[i].name.includes("Hips") && bodyAnimation.tracks[i].name.includes('position')) { 587 | continue; 588 | } 589 | tracks.push(bodyAnimation.tracks[i]); 590 | tracks[tracks.length - 1].name = tracks[tracks.length - 1].name.replace( /[\[\]`~!@#$%^&*()|+\-=?;:'"<>\{\}\\\/]/gi, "").replace(".bones", ""); 591 | } 592 | 593 | bodyAnimation.tracks = tracks; 594 | if( this.retargeting ) 595 | { 596 | bodyAnimation = this.retargeting.retargetAnimation(bodyAnimation); 597 | } 598 | 599 | this.validateAnimationClip(bodyAnimation); 600 | 601 | bodyAnimation.name = animationName; // mixer 602 | } 603 | 604 | if(!this.bindedAnimations[animationName]) { 605 | this.bindedAnimations[animationName] = {}; 606 | } 607 | this.bindedAnimations[animationName][this.currentCharacter] = bodyAnimation; 608 | 609 | } 610 | 611 | let bindedAnim = this.bindedAnimations[animationName][this.currentCharacter]; 612 | // mixer.clipAction(bindedAnim.mixerFaceAnimation).setEffectiveWeight(1.0).play(); // already handles nulls and undefines 613 | mixer.clipAction(bindedAnim).setEffectiveWeight(1.0).play(); 614 | mixer.update(0); 615 | this.duration = bindedAnim.duration; 616 | this.mixer = mixer; 617 | 618 | return true; 619 | } 620 | 621 | /** Validate body animation clip created using ML */ 622 | validateAnimationClip(clip) { 623 | 624 | let newTracks = []; 625 | let tracks = clip.tracks; 626 | let bones = this.loadedCharacters[this.currentCharacter].skeleton.bones; 627 | let bonesNames = []; 628 | tracks.map((v) => { bonesNames.push(v.name.split(".")[0])}); 629 | 630 | for(let i = 0; i < bones.length; i++) 631 | { 632 | 633 | let name = bones[i].name; 634 | if(bonesNames.indexOf( name ) > -1) 635 | continue; 636 | let times = [0]; 637 | let values = [bones[i].quaternion.x, bones[i].quaternion.y, bones[i].quaternion.z, bones[i].quaternion.w]; 638 | 639 | let track = new THREE.QuaternionKeyframeTrack(name + '.quaternion', times, values); 640 | newTracks.push(track); 641 | 642 | } 643 | clip.tracks = clip.tracks.concat(newTracks); 644 | } 645 | 646 | applyOriginalBindPose(characterName) { 647 | 648 | let skeleton = this.loadedCharacters[characterName].skeleton; 649 | skeleton.pose(); 650 | } 651 | 652 | forceTpose(characterName) { 653 | const character = this.loadedCharacters[characterName]; 654 | if(this.currentSourceCharacter == characterName) { 655 | //if(this.srcKeyBones) { 656 | this.boneMapScene.srcTPoseMap = this.srcKeyBones; 657 | //} 658 | 659 | const result = applyTPose(character.skeleton, this.boneMapScene.srcTPoseMap ); 660 | character.skeleton = result.skeleton; 661 | this.boneMapScene.srcTPoseMap = result.map; 662 | } 663 | else if(this.currentCharacter == characterName) { 664 | //if(this.trgKeyBones) { 665 | this.boneMapScene.trgTPoseMap = this.trgKeyBones; 666 | //} 667 | const result = applyTPose(character.skeleton, this.boneMapScene.trgTPoseMap); 668 | character.skeleton = result.skeleton; 669 | this.boneMapScene.trgTPoseMap = result.map; 670 | } 671 | } 672 | 673 | applyRetargeting(srcEmbedWorldTransforms = true, trgEmbedWorldTransforms = true, boneNameMap = this.boneMap) { 674 | const source = this.loadedCharacters[this.currentSourceCharacter]; 675 | const target = this.loadedCharacters[this.currentCharacter]; 676 | 677 | let srcSkeleton = source.skeleton; 678 | let trgSkeleton = target.skeleton; 679 | let srcPoseMode = this.srcPoseMode; 680 | let trgPoseMode = this.trgPoseMode; 681 | 682 | this.retargeting = new AnimationRetargeting(srcSkeleton, trgSkeleton, { srcPoseMode, trgPoseMode, srcEmbedWorldTransforms: this.srcEmbeddedTransforms, trgEmbedWorldTransforms: this.trgEmbeddedTransforms, boneNameMap: (this.autoBoneMap ? null : boneNameMap) } ); 683 | this.boneMap = this.retargeting.boneMap.nameMap; 684 | 685 | if(this.currentAnimation) { 686 | this.bindAnimationToCharacter(this.currentAnimation, this.currentCharacter); 687 | this.sourceMixer.setTime(0.01); 688 | this.sourceMixer.setTime(0.0); 689 | this.mixer.setTime(0); 690 | } 691 | else { 692 | // this.retargeting.retargetPose(); 693 | } 694 | } 695 | 696 | exportRetargetAnimation(filename, animation, format) { 697 | 698 | const innerDownload = function(filename, stringData, type = "text/plain") { 699 | let file = new Blob([stringData], {type: type}); 700 | if (window.navigator.msSaveOrOpenBlob) // IE10+ 701 | window.navigator.msSaveOrOpenBlob(file, filename); 702 | else { // Others 703 | let a = document.createElement("a"); 704 | let url = URL.createObjectURL(file); 705 | a.href = url; 706 | a.download = filename; 707 | a.click(); 708 | setTimeout(function() { 709 | window.URL.revokeObjectURL(url); 710 | }, 0); 711 | } 712 | } 713 | let action = this.mixer.clipAction(animation) 714 | if(format == 'bvh') { 715 | const stringData = BVHExporter.export(action, this.loadedCharacters[this.currentCharacter].skeleton, animation); 716 | innerDownload(filename + ".bvh", stringData); 717 | } 718 | else { 719 | let options = {animations: [this.bindedAnimations[this.currentAnimation][this.currentCharacter]], binary: true } 720 | this.GLTFExporter.parse(this.loadedCharacters[this.currentCharacter].model.children[0], 721 | ( gltf ) => innerDownload(filename + '.glb', gltf, 'application/octet-stream' ), // called when the gltf has been generated 722 | ( error ) => { console.log( 'An error happened:', error ); }, // called when there is an error in the generation 723 | options 724 | ); 725 | } 726 | } 727 | 728 | resize(width, height) { 729 | const aspect = width / height; 730 | this.camera.aspect = aspect; 731 | this.camera.updateProjectionMatrix(); 732 | this.renderer.setSize(width, height); 733 | } 734 | } 735 | 736 | export {App} 737 | 738 | const app = new App(); 739 | app.init(); 740 | window.app = app; 741 | 742 | // ADDON THREE.SkeletonHelper 743 | 744 | THREE.SkeletonHelper.prototype.changeColor = function ( a, b ) { 745 | 746 | //Change skeleton helper lines colors 747 | let colorArray = this.geometry.attributes.color.array; 748 | for(let i = 0; i < colorArray.length; i+=6) { 749 | colorArray[i+3] = 58/256; 750 | colorArray[i+4] = 161/256; 751 | colorArray[i+5] = 156/256; 752 | } 753 | this.geometry.attributes.color.array = colorArray; 754 | this.material.linewidth = 5; 755 | } 756 | 757 | 758 | -------------------------------------------------------------------------------- /demo/boneMapping.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import { SkeletonHelper } from './skeletonHelper.js'; 3 | import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; 4 | 5 | class BoneMappingScene { 6 | 7 | static VIEW = 0; 8 | static MAP = 1; 9 | 10 | static BASE_COLOR = new THREE.Color().setHex(0xffffff); 11 | static VIEW_COLOR = new THREE.Color().setHex(0x3E57E4); 12 | static EDIT_COLOR = new THREE.Color().setHex(0x7ba80a); 13 | static UNMAPED_COLOR = new THREE.Color().setHex(0xffff00); 14 | 15 | constructor(tposeBones) { 16 | this.scene = new THREE.Scene(); 17 | 18 | //include lights 19 | const ambientLight = new THREE.AmbientLight(0xffffff, 1); 20 | this.scene.add(ambientLight); 21 | 22 | const light = new THREE.PointLight(0xffffff, 2, 0, 0); 23 | light.position.set(0,0.5,0.5); 24 | this.scene.add(light) 25 | 26 | this.active = false; 27 | 28 | this.selectedSrcBone = -1; 29 | this.selectedTrgBone = -1; 30 | this.boneMap = null; 31 | this.srcTPoseMap = { 32 | 33 | "ShouldersUnion": null, 34 | "BelowStomach": null, 35 | "RArm": null, 36 | "RWrist": null, 37 | "LArm": null, 38 | "LWrist": null, 39 | "LUpLeg": null, 40 | "LFoot": null, 41 | "RUpLeg": null, 42 | "RFoot": null, 43 | }; 44 | 45 | this.trgTPoseMap = { 46 | 47 | "ShouldersUnion": null, 48 | "BelowStomach": null, 49 | "RArm": null, 50 | "RWrist": null, 51 | "LArm": null, 52 | "LWrist": null, 53 | "LUpLeg": null, 54 | "LFoot": null, 55 | "RUpLeg": null, 56 | "RFoot": null, 57 | } 58 | this.tPoseBones = [ 59 | 60 | "ShouldersUnion", 61 | "BelowStomach", 62 | "RArm", 63 | "RWrist", 64 | "LArm", 65 | "LWrist", 66 | "LUpLeg", 67 | "LFoot", 68 | "RUpLeg", 69 | "RFoot" 70 | ]; 71 | 72 | } 73 | 74 | init(root, srcSkeleton, trgSkeleton, boneMap, onSelect = null) { 75 | 76 | this.boneMap = boneMap; 77 | const clonedSrc = this.cloneSkeleton(srcSkeleton); 78 | const clonedTrg = this.cloneSkeleton(trgSkeleton); 79 | 80 | clonedSrc.bones[0].position.x = -0.15; 81 | clonedSrc.bones[0].updateMatrixWorld(true); 82 | this.source = new SkeletonHelper(clonedSrc.bones[0], new THREE.Color().setHex( 0x96a0cc )); 83 | this.source.name = "source"; 84 | clonedTrg.bones[0].position.x = 0.15; 85 | clonedTrg.bones[0].updateMatrixWorld(true); 86 | this.target = new SkeletonHelper(clonedTrg.bones[0]); 87 | this.target.name = "target"; 88 | this.scene.add(this.source); 89 | this.scene.add(this.target); 90 | 91 | for(let i = 0; i < this.source.bones.length; i++) { 92 | const srcBoneName = this.source.bones[i].name; 93 | if(!this.boneMap[srcBoneName]) { 94 | const id = findIndexOfBoneByName(this.source, srcBoneName); 95 | if(id < 0) { 96 | return; 97 | } 98 | this.source.instancedMesh.setColorAt( id, BoneMappingScene.UNMAPED_COLOR); 99 | this.source.instancedMesh.instanceColor.needsUpdate = true; 100 | } 101 | } 102 | 103 | const mappedBonesNames = Object.values(this.boneMap); 104 | for(let i = 0; i < this.target.bones.length; i++) { 105 | const trgBoneName = this.target.bones[i].name; 106 | if(mappedBonesNames.indexOf(trgBoneName) < 0) { 107 | this.target.instancedMesh.setColorAt( i, BoneMappingScene.UNMAPED_COLOR); 108 | this.target.instancedMesh.instanceColor.needsUpdate = true; 109 | } 110 | } 111 | // renderer 112 | this.renderer = new THREE.WebGLRenderer( { antialias: true, alpha: true } ); 113 | this.renderer.setPixelRatio( window.devicePixelRatio ); 114 | this.renderer.setSize( root.clientWidth, root.clientHeight ); 115 | 116 | this.renderer.toneMapping = THREE.LinearToneMapping; 117 | this.renderer.toneMappingExposure = 1; 118 | 119 | this.camera = new THREE.PerspectiveCamera(40, root.clientWidth/root.clientHeight, 0.01, 100); 120 | this.camera.position.set(0,0.1,0.8); 121 | this.controls = new OrbitControls( this.camera, this.renderer.domElement ); 122 | this.controls.target.set(-0.050, -0.01, 0); 123 | this.controls.enableDamping = true; // this requires controls.update() during application update 124 | this.controls.dampingFactor = 0.1; 125 | this.controls.enabled = true; 126 | this.controls.update(); 127 | 128 | this.renderer.render( this.scene,this.camera ); 129 | this.root = this.renderer.domElement; 130 | this.div = document.createElement('div'); 131 | this.div.style.position = 'absolute'; 132 | this.div.style.bottom = "40px"; 133 | this.div.style.left = "25%"; 134 | this.div.innerText = ''; 135 | root.append(this.div); 136 | root.appendChild(this.renderer.domElement); 137 | this.mouseX = 0; 138 | this.mouseY = 0; 139 | this.root.addEventListener( 'mousedown', this.onMouseDown.bind(this) ); 140 | this.root.addEventListener( 'mouseup', this.onMouseUp.bind(this) ); 141 | 142 | this.active = true; 143 | this.state = BoneMappingScene.VIEW; 144 | this.onSelect = onSelect; 145 | } 146 | 147 | cloneSkeleton(skeleton) { 148 | const cloned = skeleton.clone(); 149 | let bones = []; 150 | let parents = []; 151 | let totalLenght = 0; 152 | for(let i = 0; i < skeleton.bones.length; i++) { 153 | bones.push(skeleton.bones[i].clone(false)); 154 | 155 | let parentIdx = -1; 156 | if(i != 0) { 157 | bones[i].parent = null; 158 | if(skeleton.bones[i].parent) { 159 | parentIdx = skeleton.bones.indexOf(skeleton.bones[i].parent); 160 | } 161 | 162 | } 163 | parents.push(parentIdx); 164 | } 165 | //skeleton.bones[0].parent.add(bones[0]); 166 | for(let i = 0; i < skeleton.bones.length; i++) { 167 | if(parents[i] > -1) { 168 | bones[parents[i]].add(bones[i]); 169 | } 170 | } 171 | cloned.bones = bones; 172 | cloned.pose(); 173 | for(let i = 1; i < cloned.bones.length; i++) { 174 | const dist = cloned.bones[i].getWorldPosition(new THREE.Vector3()).distanceTo(cloned.bones[i].parent.getWorldPosition(new THREE.Vector3())) 175 | totalLenght += dist; 176 | } 177 | 178 | let scale = 1 / totalLenght; 179 | const globalScale = new THREE.Vector3(0.01, 0.01, 0.01); 180 | skeleton.bones[0].parent.getWorldScale(globalScale); 181 | globalScale.multiplyScalar(scale); 182 | cloned.bones[0].scale.copy(globalScale); 183 | cloned.bones[0].position.set(0,0,0); 184 | cloned.bones[0].updateMatrixWorld(true); 185 | return cloned; 186 | } 187 | 188 | update() { 189 | if(this.active) { 190 | this.controls.update(); 191 | this.renderer.render( this.scene,this.camera ); 192 | } 193 | } 194 | 195 | onMouseDown(event) { 196 | 197 | this.mouseX = event.pageX; 198 | this.mouseY = event.pageY; 199 | } 200 | 201 | onMouseUp(event) { 202 | event.preventDefault(); 203 | event.stopImmediatePropagation(); 204 | 205 | const diffX = Math.abs(event.pageX - this.mouseX); 206 | const diffY = Math.abs(event.pageY - this.mouseY); 207 | const delta = 6; 208 | 209 | if(diffX < delta && diffY < delta) { 210 | if(this.selectedSrcBone > -1) { 211 | let color = null; 212 | const srcBoneName = this.source.bones[this.selectedSrcBone].name; 213 | if(!this.boneMap[srcBoneName]) { 214 | color = BoneMappingScene.UNMAPED_COLOR; 215 | } 216 | 217 | this.clearSelection(this.source.instancedMesh, this.selectedSrcBone, color); 218 | } 219 | if(this.selectedTrgBone > -1) { 220 | let color = null; 221 | const trgBoneName = this.target.bones[this.selectedTrgBone].name; 222 | const mappedBonesNames = Object.values(this.boneMap); 223 | if(mappedBonesNames.indexOf(trgBoneName) < 0) { 224 | color = BoneMappingScene.UNMAPED_COLOR; 225 | } 226 | 227 | this.clearSelection(this.target.instancedMesh, this.selectedTrgBone, color); 228 | } 229 | switch(event.button) { 230 | case 0: // LEFT 231 | this.state = BoneMappingScene.VIEW; 232 | this.div.innerText = 'Mode: VIEW'; 233 | break; 234 | case 2: // RIGHT 235 | this.state = BoneMappingScene.EDIT; 236 | this.div.innerText = 'Mode: EDIT'; 237 | break; 238 | } 239 | this.onMouseClick(event); 240 | } 241 | } 242 | 243 | onMouseClick(event) { 244 | 245 | // Convert mouse position to normalized device coordinates (-1 to +1) 246 | let mouse = new THREE.Vector2(); 247 | let {x ,y , width, height} = this.renderer.domElement.getBoundingClientRect(); 248 | mouse.x = ( (event.clientX - x) / width ) * 2 - 1; 249 | mouse.y = - ( (event.clientY - y)/ height ) * 2 + 1; 250 | 251 | let source = this.source.instancedMesh; 252 | let target = this.target.instancedMesh; 253 | 254 | // Set raycaster from the camera to the mouse direction 255 | // Raycaster 256 | let raycaster = new THREE.Raycaster(); 257 | raycaster.setFromCamera(mouse, this.camera); 258 | 259 | // Check for intersections 260 | const intersects = raycaster.intersectObjects([source, target]); 261 | 262 | // If there is an intersection, log it or perform some action 263 | if (intersects.length > 0) { 264 | const bones = intersects[0].object.parent.bones; 265 | const bone = bones[intersects[0].instanceId]; 266 | 267 | let selectColor = new THREE.Color(); 268 | 269 | if(this.state == BoneMappingScene.VIEW) { 270 | selectColor = BoneMappingScene.VIEW_COLOR; 271 | } 272 | else if(this.state == BoneMappingScene.EDIT) { 273 | selectColor = BoneMappingScene.EDIT_COLOR;; 274 | } 275 | 276 | // Source selected 277 | if(intersects[0].object == source) { 278 | 279 | const lastSelected = this.selectedSrcBone; 280 | // Select source bone 281 | this.selectedSrcBone = intersects[0].instanceId; 282 | if(this.state == BoneMappingScene.VIEW ) { 283 | // Select target bone only in view mode 284 | this.selectedTrgBone = findIndexOfBoneByName(target.parent, this.boneMap[bone.name]); 285 | } 286 | else { 287 | // Update bone mapping in edit mode and return to view mode 288 | const srcName = Object.keys(this.boneMap).find(key => this.boneMap[key] === target.parent.bones[this.selectedTrgBone].name); 289 | if(srcName) { 290 | this.boneMap[srcName] = null; 291 | } 292 | if(lastSelected > -1) { 293 | this.clearSelection(this.source.instancedMesh, lastSelected, BoneMappingScene.UNMAPED_COLOR); 294 | } 295 | this.boneMap[bone.name] = target.parent.bones[this.selectedTrgBone].name; 296 | this.state = BoneMappingScene.VIEW; 297 | } 298 | 299 | target.setColorAt( this.selectedTrgBone, selectColor); 300 | target.instanceColor.needsUpdate = true; 301 | 302 | if(this.onSelect) { 303 | this.onSelect(bone, this.selectedSrcBone); 304 | } 305 | 306 | } // Target selected 307 | else if(intersects[0].object == target) { 308 | 309 | const lastSelected = this.selectedTrgBone; 310 | // Select target bone 311 | this.selectedTrgBone = intersects[0].instanceId; 312 | 313 | if(this.state == BoneMappingScene.VIEW ) { 314 | // Select target bone only in view mode 315 | const srcName = Object.keys(this.boneMap).find(key => this.boneMap[key] === bone.name); 316 | this.selectedSrcBone = findIndexOfBoneByName(source.parent, srcName); 317 | } 318 | else { 319 | const srcName = Object.keys(this.boneMap).find(key => this.boneMap[key] === bone.name); 320 | if(srcName) { 321 | this.boneMap[srcName] = null; 322 | } 323 | if(lastSelected > -1) { 324 | this.clearSelection(this.target.instancedMesh, lastSelected, BoneMappingScene.UNMAPED_COLOR); 325 | } 326 | // Update bone mapping in edit mode and return to view mode 327 | this.boneMap[source.parent.bones[this.selectedSrcBone].name] = bone.name; 328 | this.state = BoneMappingScene.VIEW; 329 | } 330 | source.setColorAt( this.selectedSrcBone, selectColor); 331 | source.instanceColor.needsUpdate = true; 332 | if(this.onSelect) { 333 | this.onSelect(source.parent.bones[this.selectedSrcBone], this.selectedTrgBone); 334 | } 335 | } 336 | 337 | intersects[0].object.setColorAt( intersects[0].instanceId, selectColor); 338 | intersects[0].object.instanceColor.needsUpdate = true; 339 | } 340 | } 341 | 342 | clearSelection(mesh, boneIdx, color) { 343 | mesh.setColorAt( boneIdx, color || mesh.parent.color || BoneMappingScene.BASE_COLOR ); 344 | } 345 | 346 | onUpdateFromGUI(sourceBoneName) { 347 | let target = this.target.instancedMesh; 348 | let baseTrgColor = this.target.parent.color || BoneMappingScene.BASE_COLOR; 349 | 350 | if(this.selectedTrgBone) { 351 | target.setColorAt( this.selectedTrgBone, baseTrgColor); 352 | } 353 | // Select target bone 354 | this.selectedTrgBone = findIndexOfBoneByName(target.parent, this.boneMap[sourceBoneName]); 355 | 356 | target.setColorAt( this.selectedTrgBone, BoneMappingScene.VIEW_COLOR ); 357 | target.instanceColor.needsUpdate = true; 358 | 359 | } 360 | dispose() { 361 | this.active = false; 362 | if(this.source) { 363 | this.scene.remove(this.source); 364 | } 365 | if(this.target) { 366 | this.scene.remove(this.target); 367 | } 368 | if(this.renderer) { 369 | this.renderer.dispose(); 370 | } 371 | } 372 | } 373 | 374 | function findIndexOfBoneByName( skeleton, name ){ 375 | if ( !name ){ return -1; } 376 | let b = skeleton.bones; 377 | for( let i = 0; i < b.length; ++i ){ 378 | if ( b[i].name == name ){ return i; } 379 | } 380 | return -1; 381 | } 382 | export default BoneMappingScene; -------------------------------------------------------------------------------- /demo/gui.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | import { LX } from 'lexgui'; 4 | import 'lexgui/components/codeeditor.js'; 5 | 6 | class Gui { 7 | constructor( app ){ 8 | this.app = app; 9 | 10 | // available model models paths - [model, rotation] 11 | this.avatarOptions = { 12 | "Eva": ['https://resources.gti.upf.edu/3Dcharacters/Eva/Eva.glb', 0, 'https://resources.gti.upf.edu/3Dcharacters/Eva/Eva.png', false], 13 | "ReadyEva": ['https://resources.gti.upf.edu/3Dcharacters/ReadyEva/ReadyEva.glb', 0, 'https://resources.gti.upf.edu/3Dcharacters/ReadyEva/ReadyEva.png', false], 14 | "Witch": ['https://resources.gti.upf.edu/3Dcharacters/Eva_Witch/Eva_Witch.glb', 0, 'https://resources.gti.upf.edu/3Dcharacters/Eva_Witch/Eva_Witch.png', false], 15 | "Kevin": ['https://resources.gti.upf.edu/3Dcharacters/Kevin/Kevin.glb', 0, 'https://resources.gti.upf.edu/3Dcharacters/Kevin/Kevin.png', false], 16 | "Ada": ['https://resources.gti.upf.edu/3Dcharacters/Ada/Ada.glb', 0, 'https://resources.gti.upf.edu/3Dcharacters/Ada/Ada.png', false], 17 | "Woman": ['https://resources.gti.upf.edu/3Dcharacters/Woman/Woman.glb', 0, "", true], 18 | "Dancer": ['https://resources.gti.upf.edu/3Dcharacters/Dancer/Dancer.glb', 0, "", true] 19 | } 20 | 21 | // take canvas from dom, detach from dom, attach to lexgui 22 | this.app.renderer.domElement.remove(); // removes from dom 23 | let main_area = LX.init(); 24 | let [canvas_area, panel_area ] = main_area.split({type: "horizontal", sizes:["80%", "20%"], minimizable: true}); 25 | 26 | canvas_area.attach( this.app.renderer.domElement ); 27 | canvas_area.onresize = (bounding) => this.app.resize(bounding.width, bounding.height); 28 | 29 | 30 | /* Add show/hide right panel button*/ 31 | canvas_area.addOverlayButtons([ 32 | { 33 | selectable: true, 34 | selected: true, 35 | icon: "fa-solid fa-gear", 36 | name: "Properties", 37 | callback: (v, e) => { 38 | if(main_area.split_extended) { 39 | main_area.reduce(); 40 | } 41 | else { 42 | main_area.extend(); 43 | } 44 | } 45 | } 46 | ], {float: 'tvr'}); 47 | 48 | this.panel = null; 49 | 50 | this.srcItemSelected = ""; 51 | this.trgItemSelected = ""; 52 | 53 | panel_area.addMenubar( m => { 54 | m.setButtonIcon("Github", "fa-brands fa-github", () => {window.open("https://github.com/upf-gti/retargeting-threejs")}, {float: "right"}); 55 | }); 56 | 57 | this.createSidePanel(panel_area); 58 | main_area.extend(); 59 | main_area.reduce(); 60 | } 61 | 62 | refresh(removePanel){ 63 | this.panel.refresh(); 64 | if(removePanel && this.dialogTransform ) { 65 | this.dialogTransform.close(); 66 | } 67 | } 68 | 69 | createTransformPanel(type, title) { 70 | let avatarName = ""; 71 | let itemSelected = ""; 72 | if(type == "source") { 73 | avatarName = this.app.currentSourceCharacter; 74 | itemSelected = this.srcItemSelected; 75 | } 76 | else { 77 | avatarName = this.app.currentCharacter; 78 | itemSelected = this.trgItemSelected; 79 | } 80 | let character = this.app.loadedCharacters[avatarName]; 81 | 82 | if(this.dialogTransform) { 83 | if(this.dialogTransform.title.innerText == avatarName) { 84 | this.panelTransform.refresh(character, itemSelected); 85 | return; 86 | } 87 | else { 88 | this.dialogTransform.close(); 89 | } 90 | } 91 | this.skeletonPanel = new LX.Panel("Skeleton"); 92 | this.createSkeletonPanel(this.skeletonPanel, character.skeleton, type); 93 | 94 | this.dialogTransform = new LX.PocketDialog( avatarName, p => { 95 | this.panelTransform = p; 96 | this.panelTransform.refresh = (character, itemSelected) => { 97 | p.clear(); 98 | this.panelTransform.attach( this.skeletonPanel) 99 | if(itemSelected) { 100 | let root = character.model.name == itemSelected ? character.model : character.model.getObjectByName(itemSelected); 101 | if(!root) { 102 | root = character.skeleton.bones[0].parent.getObjectByName(itemSelected) 103 | } 104 | p.addVector3("Position", [root.position.x, root.position.y, root.position.z], (value, event) => { 105 | root.position.set(value[0], value[1], value[2]); 106 | }, {step:0.01}); 107 | p.addVector3("Rotation", [root.rotation.x, root.rotation.y, root.rotation.z], (value, event) => { 108 | root.rotation.set(value[0], value[1], value[2]); 109 | }, {step:0.01}); 110 | p.addNumber("Scale", root.scale.x, (value, event) => { 111 | root.scale.set(value, value, value); 112 | }, {step:0.01}); 113 | } 114 | } 115 | this.panelTransform.refresh(character, itemSelected); 116 | }, {closable: true, float: "l", onclose: (root) => { 117 | 118 | root.remove(); 119 | this.panelTransform = null; 120 | this.dialogTransform = null; 121 | } 122 | }) 123 | 124 | } 125 | 126 | createSidePanel(panel_area) { 127 | this.panel = new LX.Panel( "Controls", { draggable: false }); 128 | panel_area.attach(this.panel); 129 | 130 | let avatars = []; 131 | let avatarsWithAnimations = []; 132 | for(let avatar in this.avatarOptions) { 133 | if(this.avatarOptions[avatar][3]) { 134 | avatarsWithAnimations.push({ value: avatar, src: this.avatarOptions[avatar][2] ?? ""}) 135 | } 136 | avatars.push({ value: avatar, src: this.avatarOptions[avatar][2] ?? ""}); 137 | } 138 | 139 | this.panel.refresh = (force = false) =>{ 140 | let p = this.panel; 141 | this.panel.clear(); 142 | this.createSourcePanel(this.panel, avatarsWithAnimations, force); 143 | this.createTargetPanel(this.panel, avatars, force); 144 | 145 | p.branch("Retargeting") 146 | p.addCheckbox("Show skeletons", this.app.showSkeletons, (v) => { 147 | this.app.changeSkeletonsVisibility(v); 148 | }, {nameWidth: "auto"}) 149 | p.addCheckbox("Source embedded transforms", this.app.srcEmbeddedTransforms ?? true, (v) => { 150 | this.app.srcEmbeddedTransforms = v; 151 | },{nameWidth: "auto"}) 152 | 153 | p.addCheckbox("Target embedded transforms", this.app.trgEmbeddedTransforms ?? true, (v) => { 154 | this.app.trgEmbeddedTransforms = v; 155 | }, {nameWidth: "auto"}) 156 | p.sameLine(); 157 | if(this.app.currentSourceCharacter) { 158 | p.sameLine(); 159 | p.addComboButtons("Bone mapping", [ 160 | { 161 | value: "Auto", 162 | callback: (v, e) => { 163 | this.app.boneMap = null; 164 | this.app.autoBoneMap = true; 165 | this.refresh(); 166 | } 167 | }, 168 | { 169 | value: "From File", 170 | callback: (v, e) => { 171 | this.fileInput.domEl.classList.remove('hidden'); 172 | if(!this.app.boneMap) { 173 | this.fileInput.domEl.getElementsByTagName('input')[0].click(); 174 | } 175 | this.app.autoBoneMap = false; 176 | } 177 | } 178 | ], {selected: this.app.autoBoneMap ? "Auto" : "From File"}); 179 | 180 | this.fileInput = p.addFile("File", (v, e) => { 181 | let files = p.widgets["File"].domEl.children[1].files; 182 | if(!files.length) { 183 | return; 184 | } 185 | const path = files[0].name.split("."); 186 | const extension = path[path.length - 1]; 187 | const reader = new FileReader(); 188 | if (extension == "json" || extension == "txt") { 189 | reader.readAsText(files[0]); 190 | 191 | reader.onload = (e) => { 192 | try { 193 | const json = JSON.parse(e.target.result); 194 | this.app.boneMap = json.boneMapNames; 195 | this.app.srcKeyBones = json.srcKeyBones, 196 | this.app.trgKeyBones = json.trgKeyBones; 197 | } 198 | catch{ 199 | alert("It can't be parsed as a JSON!"); 200 | } 201 | } 202 | } 203 | else { LX.popup("Only accepts JSON and TXT formats!"); } 204 | 205 | }, {read: false, local:false}); 206 | 207 | this.fileInput.domEl.classList.add('hidden'); 208 | 209 | if(this.app.boneMap) { 210 | p.addButton(null, "Edit bones mapping", () => { 211 | this.showBoneMapping(); 212 | }, {width: "40px", icon: "fa-solid fa-bone"}); 213 | } 214 | p.endLine(); 215 | 216 | const poseModes = ["DEFAULT", "CURRENT"]; 217 | p.addDropdown("Source reference pose", poseModes, poseModes[this.app.srcPoseMode], (v) => { 218 | this.app.srcPoseMode = poseModes.indexOf(v); 219 | }, {nameWidth: "200px"}); 220 | 221 | p.addDropdown("Character reference pose", poseModes, poseModes[this.app.trgPoseMode], (v) => { 222 | this.app.trgPoseMode = poseModes.indexOf(v); 223 | }, {nameWidth: "200px"}); 224 | 225 | p.sameLine(); 226 | p.addButton(null, "Apply retargeting", () => { 227 | this.app.applyRetargeting(this.app.srcEmbeddedTransforms, this.app.trgEmbeddedTransforms, this.app.boneMap); 228 | this.refresh(); 229 | }, { width: "200px"}) 230 | } 231 | 232 | if(this.app.retargeting) { 233 | p.addButton(null, "Export animation", () => { 234 | if(this.app.mixer && this.app.mixer._actions.length) { 235 | this.showExportDialog((name, animation, format) => this.app.exportRetargetAnimation(name, animation, format)) 236 | } 237 | else { 238 | LX.popup("No retarget animation.", "Warning!", { timeout: 5000}) 239 | return; 240 | } 241 | }) 242 | } 243 | p.endLine(); 244 | p.merge(); 245 | } 246 | 247 | this.panel.refresh(false); 248 | } 249 | 250 | showExportDialog(callback) { 251 | let options = { modal : true}; 252 | 253 | let value = ""; 254 | 255 | const dialog = this.prompt = new LX.Dialog("Export retarget animation", p => { 256 | 257 | let animation = this.app.mixer._actions[0]._clip; 258 | let name = animation.name; 259 | let format = 'bvh'; 260 | p.addText(null, name, (v) => { 261 | name = v; 262 | }, {placeholder: "...", minWidth:"100px"} ); 263 | p.endLine(); 264 | p.addDropdown("Format", ["bvh", "glb"], format, (v) => { 265 | format = v; 266 | }) 267 | p.sameLine(2); 268 | p.addButton("", options.accept || "OK", (v, e) => { 269 | e.stopPropagation(); 270 | if(options.required && value === '') { 271 | 272 | text += text.includes("You must fill the input text.") ? "": "\nYou must fill the input text."; 273 | dialog.close() ; 274 | } 275 | else { 276 | if(callback) { 277 | callback(name, animation, format); 278 | } 279 | dialog.close() ; 280 | } 281 | 282 | }, { buttonClass: "accept" }); 283 | p.addButton("", "Cancel", () => {if(options.on_cancel) options.on_cancel(); dialog.close();} ); 284 | 285 | }, options); 286 | 287 | // Focus text prompt 288 | if(options.input !== false && dialog.root.querySelector('input')) 289 | dialog.root.querySelector('input').focus(); 290 | } 291 | 292 | createSourcePanel(panel, avatarsWithAnimations, force) { 293 | // SOURCE AVATAR/ANIMATION 294 | panel.branch("Source", {icon: "fa-solid fa-child-reaching"}); 295 | 296 | panel.sameLine(); 297 | panel.addDropdown("Source", avatarsWithAnimations, this.app.currentSourceCharacter, (value, event) => { 298 | if(this.dialogTransform) { 299 | this.dialogTransform.close(); 300 | } 301 | // upload model 302 | if (value == "Upload Animation or Avatar") { 303 | this.uploadAvatar((value, extension) => { 304 | 305 | if ( !this.app.loadedCharacters[value] ) { 306 | document.getElementById("loading").style.display = "block"; 307 | 308 | let modelFilePath = this.avatarOptions[value][0]; 309 | let modelRotation = (new THREE.Quaternion()).setFromAxisAngle( new THREE.Vector3(1,0,0), this.avatarOptions[value][1] ); 310 | 311 | if( extension == "glb" || extension == "gltf" ) { 312 | this.app.loadAvatar(modelFilePath, modelRotation, value, extension, (animations) => { 313 | this.app.changeSourceAvatar(value); 314 | if(!animations.length) { 315 | LX.popup("Avatar loaded without animations.", "Warning!", {position: [ "10px", "50px"], timeout: 5000}) 316 | } 317 | avatarsWithAnimations.push({ value: value, src: ""}); 318 | document.getElementById("loading").style.display = "none"; 319 | this.refresh(); 320 | } ); 321 | } 322 | else if( extension == "bvh" || extension == "bvhe") { 323 | this.app.loadAnimation(modelFilePath, value, (animations) => { 324 | this.app.changeSourceAvatar(value); 325 | if(!animations.length) { 326 | LX.popup("Avatar loaded without animations.", "Warning!", {position: [ "10px", "50px"], timeout: 5000}) 327 | } 328 | avatarsWithAnimations.push({ value: value, src: ""}); 329 | document.getElementById("loading").style.display = "none"; 330 | this.refresh(); 331 | }) 332 | } 333 | return; 334 | } 335 | 336 | // use controller if it has been already loaded in the past 337 | this.app.changeSourceAvatar(value); 338 | this.srcItemSelected = ""; 339 | this.refresh(true); 340 | // TO DO: load animations if it has someone 341 | 342 | }, true); 343 | } 344 | else { 345 | // load desired model 346 | if ( !this.app.loadedCharacters[value] ) { 347 | document.getElementById("loading").style.display = "block"; 348 | let modelFilePath = this.avatarOptions[value][0]; 349 | let modelRotation = (new THREE.Quaternion()).setFromAxisAngle( new THREE.Vector3(1,0,0), this.avatarOptions[value][1] ); 350 | const path = modelFilePath.split("."); 351 | const extension = path[path.length - 1]; 352 | this.app.loadAvatar(modelFilePath, modelRotation, value, extension,(animations)=>{ 353 | this.app.changeSourceAvatar(value); 354 | if(!animations.length) { 355 | LX.popup("Avatar loaded without animations.", "Warning!", {position: [ "10px", "50px"], timeout: 5000}) 356 | } 357 | document.getElementById("loading").style.display = "none"; 358 | this.refresh(); 359 | } ); 360 | return; 361 | } 362 | // use controller if it has been already loaded in the past 363 | this.app.changeSourceAvatar(value); 364 | this.srcItemSelected = ""; 365 | 366 | this.refresh(true); 367 | } 368 | }); 369 | 370 | panel.addButton( null, "Upload Animation or Avatar", (v) => { 371 | this.uploadAvatar((value, extension) => { 372 | 373 | if ( !this.app.loadedCharacters[value] ) { 374 | document.getElementById("loading").style.display = "block"; 375 | let modelFilePath = this.avatarOptions[value][0]; 376 | let modelRotation = (new THREE.Quaternion()).setFromAxisAngle( new THREE.Vector3(1,0,0), this.avatarOptions[value][1] ); 377 | if( extension == "glb" || extension == "gltf" || extension == "fbx" ) { 378 | this.app.loadAvatar(modelFilePath, modelRotation, value, extension, (animations) => { 379 | this.app.changeSourceAvatar(value); 380 | avatarsWithAnimations.push({ value: value, src: ""}); 381 | if(!animations.length) { 382 | LX.popup("Avatar loaded without animations.", "Warning!", {position: [ "10px", "50px"], timeout: 5000}) 383 | } 384 | document.getElementById("loading").style.display = "none"; 385 | this.refresh(true); 386 | } ); 387 | } 388 | else if( extension == "bvh" || extension == "bvhe") { 389 | this.app.loadAnimation(modelFilePath, value, (animations) => { 390 | this.app.changeSourceAvatar(value); 391 | if(!animations.length) { 392 | LX.popup("Avatar loaded without animations.", "Warning!", {position: [ "10px", "50px"], timeout: 5000}) 393 | } 394 | avatarsWithAnimations.push({ value: value, src: ""}); 395 | document.getElementById("loading").style.display = "none"; 396 | this.refresh(true); 397 | }) 398 | } 399 | return; 400 | } 401 | 402 | // use controller if it has been already loaded in the past 403 | this.app.changeSourceAvatar(value); 404 | this.srcItemSelected = ""; 405 | 406 | this.refresh(); 407 | 408 | }, true); 409 | } ,{ width: "40px", icon: "fa-solid fa-cloud-arrow-up" } ); 410 | 411 | panel.endLine(); 412 | if(this.app.currentSourceCharacter) { 413 | 414 | panel.addButton(null, "Apply original bind pose", () => { 415 | 416 | this.app.applyOriginalBindPose(this.app.currentSourceCharacter); 417 | this.refresh(); 418 | }); 419 | panel.addButton(null, "Convert current pose to T-pose", () => { 420 | 421 | this.app.forceTpose(this.app.currentSourceCharacter); 422 | this.refresh(); 423 | }); 424 | panel.addButton(null, "Open skeleton panel", () => { 425 | 426 | this.createTransformPanel("source", ""); 427 | }); 428 | } 429 | this.createAnimationPanel(panel); 430 | 431 | panel.merge(); 432 | } 433 | 434 | createTargetPanel(panel, avatars, force) { 435 | // TARGET AVATAR 436 | panel.branch("Target", {icon: "fa-solid fa-people-arrows"}); 437 | panel.sameLine(); 438 | panel.addDropdown("Target avatar", avatars, this.app.currentCharacter, (value, event) => { 439 | if(this.dialogTransform) { 440 | this.dialogTransform.close(); 441 | } 442 | // upload model 443 | if (value == "Upload Avatar") { 444 | this.uploadAvatar((value, extension) => { 445 | 446 | if ( !this.app.loadedCharacters[value] ) { 447 | document.getElementById("loading").style.display = "block"; 448 | 449 | let modelFilePath = this.avatarOptions[value][0]; 450 | let modelRotation = (new THREE.Quaternion()).setFromAxisAngle( new THREE.Vector3(1,0,0), this.avatarOptions[value][1] ); 451 | this.app.loadAvatar(modelFilePath, modelRotation, value, extension, ()=>{ 452 | avatars.push({ value: value, src: ""}); 453 | this.app.changeAvatar(value); 454 | document.getElementById("loading").style.display = "none"; 455 | this.refresh(true); 456 | } ); 457 | return; 458 | } 459 | 460 | // use controller if it has been already loaded in the past 461 | this.app.changeAvatar(value); 462 | this.trgItemSelected = ""; 463 | 464 | this.refresh(true); 465 | 466 | }); 467 | } 468 | else { 469 | // load desired model 470 | if ( !this.app.loadedCharacters[value] ) { 471 | document.getElementById("loading").style.display = "block"; 472 | let modelFilePath = this.avatarOptions[value][0]; 473 | let modelRotation = (new THREE.Quaternion()).setFromAxisAngle( new THREE.Vector3(1,0,0), this.avatarOptions[value][1] ); 474 | const path = modelFilePath.split("."); 475 | const extension = path[path.length - 1]; 476 | 477 | this.app.loadAvatar(modelFilePath, modelRotation, value, extension, ()=>{ 478 | avatars.push({ value: value, src: ""}); 479 | this.app.changeAvatar(value); 480 | // TO DO: load animations if it has someone 481 | document.getElementById("loading").style.display = "none"; 482 | this.refresh(true); 483 | } ); 484 | return; 485 | } 486 | 487 | // use controller if it has been already loaded in the past 488 | this.app.changeAvatar(value); 489 | this.trgItemSelected = ""; 490 | 491 | this.refresh(); 492 | } 493 | }); 494 | 495 | panel.addButton( null, "Upload Avatar", (v) => { 496 | this.uploadAvatar((value, extension) => { 497 | 498 | if ( !this.app.loadedCharacters[value] ) { 499 | document.getElementById("loading").style.display = "block"; 500 | let modelFilePath = this.avatarOptions[value][0]; 501 | let modelRotation = (new THREE.Quaternion()).setFromAxisAngle( new THREE.Vector3(1,0,0), this.avatarOptions[value][1] ); 502 | this.app.loadAvatar(modelFilePath, modelRotation, value, extension, ()=>{ 503 | avatars.push({ value: value, src: ""}); 504 | this.app.changeAvatar(value); 505 | document.getElementById("loading").style.display = "none"; 506 | // TO DO: load animations if it has someone 507 | this.refresh(true); 508 | } ); 509 | return; 510 | } 511 | 512 | // use controller if it has been already loaded in the past 513 | this.app.changeAvatar(value); 514 | this.refresh(true); 515 | }); 516 | } ,{ width: "40px", icon: "fa-solid fa-cloud-arrow-up" } ); 517 | 518 | panel.endLine(); 519 | 520 | if(this.app.currentCharacter) { 521 | panel.addButton(null, "Apply original bind pose", () => { 522 | this.app.applyOriginalBindPose(this.app.currentCharacter); 523 | 524 | this.refresh(); 525 | }); 526 | panel.addButton(null, "Convert current pose to T-pose", () => { 527 | 528 | this.app.forceTpose(this.app.currentCharacter); 529 | this.refresh(); 530 | }); 531 | panel.addButton(null, "Open skeleton panel", () => { 532 | 533 | this.createTransformPanel("target", ""); 534 | }); 535 | } 536 | panel.merge(); 537 | } 538 | 539 | createAnimationPanel(panel) { 540 | panel.addTitle("Animation", {icon: "fa-solid fa-hands-asl-interpreting"}); 541 | panel.sameLine(); 542 | let animations = []; 543 | for(let anim in this.app.loadedCharacters[this.app.currentSourceCharacter].animations) { 544 | 545 | animations.push(this.app.loadedCharacters[this.app.currentSourceCharacter].animations[anim].name); 546 | } 547 | panel.addDropdown("Animation", animations, this.app.currentAnimation, (v) => { 548 | this.app.onChangeAnimation(v); 549 | }); 550 | 551 | panel.addButton("", "": "fa-play'>") + "", (v,e) => { 552 | this.app.changePlayState(); 553 | panel.refresh(); 554 | }, { width: "40px"}); 555 | panel.addButton("", "" + "", (v,e) => { 556 | this.app.stopAnimation(); 557 | panel.refresh(); 558 | }, { width: "40px"}); 559 | panel.endLine(); 560 | } 561 | 562 | createSkeletonPanel(panel, skeleton, type, force) { 563 | const rootBone = skeleton.bones[0].parent ?? skeleton.bones[0]; 564 | let parent = rootBone.parent; 565 | if(parent && parent.type == "Scene") { 566 | parent = null; 567 | } 568 | let itemSelected = ""; 569 | let sceneTree = {}; 570 | 571 | if(type == 'source') { 572 | // itemSelected = this.srcItemSelected = this.srcItemSelected ? this.srcItemSelected : parent.name; 573 | itemSelected = this.srcItemSelected = (parent && parent.name ? parent.name : rootBone.name); 574 | } 575 | else { 576 | // itemSelected = this.trgItemSelected = this.trgItemSelected ? this.trgItemSelected : parent.name; 577 | itemSelected = this.trgItemSelected = (parent && parent.name ? parent.name : rootBone.name); 578 | } 579 | if(force || (type == "source" && !this.srcTree || type == "target" && !this.trgTree)) { 580 | sceneTree = { 581 | id: parent ? parent.name : rootBone.name, 582 | selected: (parent ? parent.name : rootBone.name) == itemSelected, 583 | skipVisibility: true 584 | }; 585 | let children = []; 586 | if(parent) { 587 | children.push( { 588 | id: rootBone.name, 589 | children: [], 590 | closed: true, 591 | selected: rootBone.name == itemSelected, 592 | skipVisibility: true 593 | }) 594 | } 595 | const addChildren = (bone, array) => { 596 | 597 | for( let b of bone.children ) { 598 | 599 | if ( ! b.isBone ){ continue; } 600 | let child = { 601 | id: b.name, 602 | children: [], 603 | icon: "fa-solid fa-bone", 604 | closed: true, 605 | selected: b.name == itemSelected, 606 | skipVisibility: true 607 | } 608 | 609 | array.push( child ); 610 | 611 | addChildren(b, child.children); 612 | } 613 | }; 614 | 615 | addChildren(rootBone, parent ? children[0].children : children); 616 | 617 | sceneTree['children'] = children; 618 | 619 | } 620 | 621 | if(type == "source") { 622 | sceneTree = this.srcTree ? this.srcTree.data : sceneTree; 623 | } 624 | else { 625 | sceneTree = this.trgTree ? this.trgTree.data : sceneTree; 626 | } 627 | let tree = panel.addTree("Skeleton", sceneTree, { 628 | // filter: false, 629 | id: type, 630 | rename: false, 631 | onevent: (event) => { 632 | console.log(event.string()); 633 | 634 | switch(event.type) { 635 | case LX.TreeEvent.NODE_SELECTED: 636 | if(event.multiple) 637 | console.log("Selected: ", event.node); 638 | else { 639 | itemSelected = event.node.id; 640 | if(tree.options.id == 'source') { 641 | this.srcItemSelected = itemSelected; 642 | } 643 | else { 644 | this.trgItemSelected = itemSelected; 645 | } 646 | //tree.selected = tree.name == itemSelected; 647 | this.createTransformPanel(tree.options.id, itemSelected); 648 | 649 | } 650 | break; 651 | case LX.TreeEvent.NODE_DELETED: 652 | if(event.multiple) 653 | console.log("Deleted: ", event.node); 654 | else 655 | console.log(event.node.id + " deleted"); 656 | break; 657 | case LX.TreeEvent.NODE_DBLCLICKED: 658 | console.log(event.node.id + " dbl clicked"); 659 | break; 660 | case LX.TreeEvent.NODE_CONTEXTMENU: 661 | const m = event.panel; 662 | m.add( "Components/Transform"); 663 | m.add( "Components/MeshRenderer"); 664 | break; 665 | case LX.TreeEvent.NODE_DRAGGED: 666 | console.log(event.node.id + " is now child of " + event.value.id); 667 | break; 668 | case LX.TreeEvent.NODE_RENAMED: 669 | console.log(event.node.id + " is now called " + event.value); 670 | break; 671 | case LX.TreeEvent.NODE_VISIBILITY: 672 | console.log(event.node.id + " visibility: " + event.value); 673 | break; 674 | } 675 | } 676 | }); 677 | return tree; 678 | } 679 | 680 | uploadAvatar(callback = null, isSource = false) { 681 | let name, model, extension; 682 | let rotation = 0; 683 | 684 | let title = "Avatar"; 685 | let text = "Load a .gltf or a .glb file." 686 | if(isSource) { 687 | title = "Animation/Avatar "; 688 | text = "Load a .bvh, .bvhe, .gltf or .glb file." 689 | } 690 | 691 | this.avatarDialog = new LX.Dialog("Upload " + title , panel => { 692 | 693 | panel.addText(null, text, null, {disabled: true}); 694 | let nameWidget = panel.addText("Name Your " + title, name, (v, e) => { 695 | if (this.avatarOptions[v]) LX.popup("This name is taken. Please, change it.", null, { position: ["45%", "20%"]}); 696 | name = v; 697 | }); 698 | 699 | let avatarFile = panel.addFile(title + " File", (v, e) => { 700 | let files = panel.widgets[title + " File"].domEl.children[1].files; 701 | if(!files.length) { 702 | return; 703 | } 704 | const path = files[0].name.split("."); 705 | const filename = path[0]; 706 | extension = path[1].toLocaleLowerCase(); 707 | const reader = new FileReader(); 708 | if (extension == "glb" || extension == "gltf" || extension == "fbx" || isSource && (extension == "bvh" || extension == "bvhe")) { 709 | model = v; 710 | if(!name) { 711 | name = filename; 712 | nameWidget.set(name) 713 | } 714 | if(extension == "glb" || extension == "gltf" || extension == "fbx") { 715 | reader.readAsDataURL(files[0]); 716 | } 717 | else { 718 | reader.readAsText(files[0]); 719 | } 720 | reader.onload = (e) => { 721 | model = e.target.result; 722 | } 723 | } 724 | else { LX.popup("Only accepts GLB and GLTF formats or BVH and BVHE (only for animations)!"); } 725 | 726 | }, {read: false}); 727 | 728 | panel.addNumber("Apply Rotation", 0, (v) => { 729 | rotation = v * Math.PI / 180; 730 | }, { min: -180, max: 180, step: 1 } ); 731 | 732 | panel.addButton(null, "Upload", () => { 733 | if (name && model) { 734 | if (this.avatarOptions[name]) { LX.popup("This name is taken. Please, change it.", null, { position: ["45%", "20%"]}); return; } 735 | this.avatarOptions[name] = [model, rotation, "icon"]; 736 | 737 | panel.clear(); 738 | this.avatarDialog.root.remove(); 739 | if (callback) callback(name, extension); 740 | } 741 | else { 742 | LX.popup("Complete all fields!", null, { position: ["45%", "20%"]}); 743 | } 744 | }); 745 | panel.root.addEventListener("drop", (v, e) => { 746 | 747 | let files = v.dataTransfer.files; 748 | if(!files.length) { 749 | return; 750 | } 751 | for(let i = 0; i < files.length; i++) { 752 | 753 | const path = files[i].name.split("."); 754 | const filename = path[0]; 755 | const extension = path[1].toLocaleLowerCase(); 756 | if (extension == "glb" || extension == "gltf" || isSource && (extension == "bvh" || extension == "bvhe")) { 757 | // Create a data transfer object 758 | const dataTransfer = new DataTransfer(); 759 | // Add file to the file list of the object 760 | dataTransfer.items.add(files[i]); 761 | // Save the file list to a new variable 762 | const fileList = dataTransfer.files; 763 | avatarFile.domEl.children[1].files = fileList; 764 | avatarFile.domEl.children[1].dispatchEvent(new Event('change'), { bubbles: true }); 765 | model = v; 766 | if(!name) { 767 | name = filename; 768 | nameWidget.set(name) 769 | } 770 | } 771 | } 772 | }) 773 | 774 | }, { size: ["40%"], closable: true, onclose: (root) => { root.remove(); if(this.gui) this.gui.setValue("Avatar File", this.app.currentCharacter)} }); 775 | 776 | return name; 777 | } 778 | 779 | showBoneMapping() { 780 | if(this.dialog) { 781 | this.dialog.close(); 782 | } 783 | const areaMap = new LX.Area({width: "100%"}); 784 | const [area3D, areaPanel] = areaMap.split({type:'horizontal', sizes: ["50%", "50%"]}); 785 | 786 | const bonePanel = areaPanel.addPanel({id:"bone-panel"}); 787 | 788 | 789 | const bones = this.app.loadedCharacters[this.app.currentCharacter].skeleton.bones; 790 | let bonesName = []; 791 | for(let i = 0; i < bones.length; i++) { 792 | bonesName.push(bones[i].name); 793 | } 794 | const area = new LX.Area({width: "100%", height: "calc(100% - 30px)"}); 795 | const area2D = new LX.Area(); 796 | 797 | this.dialog = new LX.Dialog("Bone Mapping", panel => { 798 | 799 | panel.root.appendChild(area.root); 800 | 801 | // 3D mapping 802 | this.createBonePanel(bonePanel); 803 | //2D mapping 804 | const p = area2D.addPanel(); 805 | this.create2DPanel(p, bonesName); 806 | 807 | //panel.root.prepend(area.root); 808 | const tabs = area.addTabs(); 809 | tabs.add("3D mapping", areaMap, {selected: true}); 810 | // areaMap.root.style.display = "flex"; 811 | tabs.add("2D mapping", area2D, {onSelect: (e, name) => { 812 | this.create2DPanel(p, bonesName); 813 | }}); 814 | 815 | }, { size: ["80%", "70%"], closable: true, onclose: () => { 816 | if(this.app.currentAnimation) { 817 | this.app.bindAnimationToCharacter(this.app.currentAnimation, this.app.currentCharacter); 818 | } 819 | this.dialog.panel.clear(); 820 | this.dialog.root.remove(); 821 | this.app.boneMapScene.dispose(); 822 | } }); 823 | 824 | //3D mapping 825 | this.app.boneMapScene.init(area3D.root, this.app.loadedCharacters[this.app.currentSourceCharacter].skeleton, this.app.loadedCharacters[this.app.currentCharacter].skeleton, this.app.boneMap, (bone) => { this.createBonePanel(bonePanel, bone, bonesName)}); 826 | } 827 | 828 | create2DPanel(panel, bonesName) { 829 | panel.clear(); 830 | const htmlStr = "Select the corresponding bone name of your avatar to match the provided list of bone names. An automatic selection is done, adjust if needed."; 831 | panel.addTextArea(null, htmlStr, null, {disabled: true}); 832 | 833 | let i = 0; 834 | for (const part in this.app.boneMap) { 835 | if ((i % 2) == 0) panel.sameLine(2); 836 | i++; 837 | const widget = panel.addDropdown(part, bonesName, this.app.boneMap[part], (value, event) => { 838 | this.app.boneMap[part] = value; 839 | }, {filter: true}); 840 | if(!this.app.boneMap[part]) { 841 | widget.domEl.classList.add("warning"); 842 | } 843 | widget.domEl.children[0].classList.add("source-color"); 844 | widget.domEl.children[1].classList.add("target-color"); 845 | } 846 | } 847 | 848 | createBonePanel(panel, bone, bonesName) { 849 | panel.clear(); 850 | panel.branch("Retargeting bone map"); 851 | const s = "An automatic mapping is done, adjust if needed. Click on a bone to highlight its corresponding bone on the other skeleton. To edit it, select a bone on one skeleton with the left mouse button, then right-click on the other skeleton to assign a new corresponding bone. This can also be done using the dropdown menu. The source skeleton is displayed in blue, while the target skeleton is shown in white. Bones without a mapping are highlighted in yellow."; 852 | panel.addTextArea(null, s, null, {disabled: true, height: "70px"}); 853 | panel.addText("Source", "Target", null, {disabled: true}); 854 | if(bone) { 855 | const widget = panel.addDropdown(bone.name, bonesName, this.app.boneMap[bone.name], (value, event) => { 856 | this.app.boneMap[bone.name] = value; 857 | this.app.boneMapScene.onUpdateFromGUI(bone.name); 858 | }, {filter: true}); 859 | if(!this.app.boneMap[bone.name]) { 860 | widget.domEl.classList.add("warning"); 861 | } 862 | widget.domEl.children[0].classList.add("source-color"); 863 | widget.domEl.children[1].classList.add("target-color"); 864 | 865 | // panel.branch("T-pose skeleton map"); 866 | panel.addTitle("T-pose skeleton map (optional)"); 867 | const text = "To automatically apply a T-pose, it is necessary to identify some of the bones. If you want to apply it, assign the selected bone if it matches any of the options."; 868 | panel.addTextArea(null, text, null, {disabled: true, fitHeight: true}); 869 | const tboneName = Object.keys(this.app.boneMapScene.srcTPoseMap).find(key => this.app.boneMapScene.srcTPoseMap[key] === bone.name); 870 | panel.addDropdown("Assign to", this.app.boneMapScene.tPoseBones, tboneName, (value, event) => { 871 | const tboneName = Object.keys(this.app.boneMapScene.srcTPoseMap).find(key => this.app.boneMapScene.srcTPoseMap[key] === bone.name); 872 | this.app.boneMapScene.srcTPoseMap[tboneName] = null; 873 | this.app.boneMapScene.srcTPoseMap[value] = bone.name; 874 | this.app.boneMapScene.trgTPoseMap[value] = this.app.boneMap[bone.name]; 875 | 876 | }, {filter: true}); 877 | const img = document.createElement('img'); 878 | img.src = "tpose-map.png"; 879 | img.style.width = "45%"; 880 | img.style.left = "25%"; 881 | img.style.position = "relative"; 882 | panel.current_branch.content.appendChild(img); 883 | } 884 | panel.merge(); 885 | } 886 | } 887 | 888 | 889 | 890 | export {Gui} -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Retargeting 4 | 5 | 6 | 7 | 8 | 9 | 59 | 69 | 70 | 71 | 72 | 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /demo/skeletonHelper.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | 4 | const _vector = /*@__PURE__*/ new THREE.Vector3(); 5 | const _boneMatrix = /*@__PURE__*/ new THREE.Matrix4(); 6 | const _matrixWorldInv = /*@__PURE__*/ new THREE.Matrix4(); 7 | 8 | 9 | class SkeletonHelper extends THREE.Group { 10 | 11 | constructor( object, color = new THREE.Color().setHex(0xffffff) ) { 12 | 13 | super() 14 | const bones = getBoneList( object ); 15 | 16 | const geometry = new THREE.ConeGeometry( 0.02, 1, 3 ); 17 | const material = new THREE.MeshPhongMaterial( {color: new THREE.Color().setHex(0xffffff), toneMapped: false} ); //, depthTest: false, depthWrite: false, toneMapped: false, transparent: true 18 | this.instancedMesh = new THREE.InstancedMesh(geometry, material, bones.length); 19 | this.add(this.instancedMesh) 20 | this.isSkeletonHelper = true; 21 | 22 | this.type = 'SkeletonHelper'; 23 | 24 | this.root = object; 25 | this.bones = bones; 26 | this.color = color; 27 | 28 | this.matrixAutoUpdate = false; 29 | 30 | for(let i = 0; i < bones.length; i++) { 31 | this.instancedMesh.setColorAt(i, color); 32 | } 33 | } 34 | 35 | updateMatrixWorld( force ) { 36 | 37 | const bones = this.bones; 38 | 39 | _matrixWorldInv.copy( this.root.matrixWorld ).invert(); 40 | 41 | for ( let i = 0; i < bones.length; i ++ ) { 42 | 43 | const bone = bones[ i ]; 44 | _boneMatrix.copy(bone.matrixWorld); 45 | if ( bone.children.length && bone.children[0].isBone ) { 46 | 47 | let position = _vector.clone(); 48 | position.setFromMatrixPosition( _boneMatrix ); 49 | 50 | let childPos = _vector.clone(); 51 | childPos.setFromMatrixPosition( bone.children[0].matrixWorld ); 52 | 53 | let q = new THREE.Quaternion(); 54 | _vector.subVectors(childPos, position); 55 | let dir = _vector.clone(); 56 | q.setFromUnitVectors(new THREE.Vector3(0,1,0),_vector.normalize()); 57 | 58 | let len = Math.abs(position.distanceTo(childPos)); 59 | let scale = _vector.clone(); 60 | //_boneMatrix.decompose(position, q, scale); 61 | scale.x = 6*len; 62 | scale.y = len; 63 | scale.z = 6*len; 64 | 65 | position.addScaledVector(dir, 0.5) 66 | _boneMatrix.compose( position, q, scale); 67 | } 68 | else { 69 | let position = _vector.clone(); 70 | let scale = _vector.clone(); 71 | let q = new THREE.Quaternion(); 72 | 73 | _boneMatrix.decompose(position, q, scale); 74 | 75 | scale.x = 0.2; 76 | scale.y = 0.03; 77 | scale.z = 0.2; 78 | _boneMatrix.compose( position, q, scale); 79 | } 80 | this.instancedMesh.setMatrixAt(i, _boneMatrix); 81 | } 82 | this.instancedMesh.instanceMatrix.needsUpdate = true; 83 | this.instancedMesh.computeBoundingSphere(); 84 | 85 | } 86 | 87 | dispose() { 88 | 89 | // this.geometry.dispose(); 90 | // this.material.dispose(); 91 | 92 | } 93 | 94 | } 95 | 96 | 97 | function getBoneList( object ) { 98 | 99 | const boneList = []; 100 | 101 | if ( object.isBone === true ) { 102 | 103 | boneList.push( object ); 104 | 105 | } 106 | 107 | for ( let i = 0; i < object.children.length; i ++ ) { 108 | 109 | boneList.push.apply( boneList, getBoneList( object.children[ i ] ) ); 110 | 111 | } 112 | 113 | return boneList; 114 | 115 | } 116 | 117 | 118 | export { SkeletonHelper }; -------------------------------------------------------------------------------- /demo/tpose-map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upf-gti/retargeting-threejs/5f844f089ed5eca79a775af5a66354e9c85a83a5/demo/tpose-map.png -------------------------------------------------------------------------------- /docs/Algorithm.md: -------------------------------------------------------------------------------- 1 | 2 | # ANIMATION RETARGETING 3 | Animation retargeting is the process of transferring an animation from one character (the source) to another character (the target) that may have different proportions, joint structures, or skeletal configurations. This technique is widely used in computer graphics, particularly in video games and film production, to reuse animations across different characters, saving time and resources. 4 | 5 | ## Key concepts 6 | 7 | ### Spaces 8 | For animation, and specifically, for retargeting is very important the space used when computing bone's transformations so the results can be very different: 9 | 10 | - World space or Global Space: Object transformation based upon its place in the world view, that is, relative to the (0,0,0) of the world. 11 | - Local space: Object transformation relative to its parent. 12 | ![World space to local space](imgs/Spaces.png) 13 | 14 | ### Source and Target Characters 15 | - Source Character: Original character that has the animation data 16 | - Target Character: Character that will receive the animation 17 | 18 | ### Skeleton 19 | The skeleton of an avatar is defined by a heriarchy of joints (defined by positions) or bones (defined by an initial position, direction and length), depending of the software. Each joint has a scale, a rotation and a position, the latter being an offset with respect to its parent. The position with respect to its parent and children, determine in which axis a limb needs to be rotated to achieve a particular pose. This implies that depending on how the skeleton is modeled, the local transforms of a skeleton might differ from others, even if they are successfully applied to the same mesh. The retargeting algorithm attacks this issue by working in world space with an auxiliary pose that looks the same for both skeletons. 20 | 21 | #### Transformations 22 | When working with skeletons, it is important to consider the following questions about bone transformations: 23 | - The scale has to be homogeneous and with positive values. Otherwise, it can give unwanted problems. 24 | - It is better to calculate rotations using quaternions. This avoids gimbal lock. 25 | #### Skeleton Mapping 26 | 27 | To perform a retargeting, a correspondence between the joints of the source skeleton and the target skeleton has to be established. Not only for the names, also for the missing joints if it’s the case. This often requires manual setup or the use of automated algorithms. 28 | 29 | ### Poses 30 | A pose is a particular configuration of the transformation (position, rotation and scale) of the bones/joints of a skeleton. The most common poses are the following: 31 | - Bind Pose: Default initial pose for the skeleton before it is animated. Used as a reference for attaching/binding the mesh to the bones (skinning). Usually, the pose shapes a T (T-pose) or an A (A-pose) 32 | - Rest Pose: Default or neutral pose of the skeleton when no transformations or animations are applied. It is likely that this pose will be the same as the bind pose. 33 | - Base Pose: Pose of the skeleton without rotations applied to the bones. 34 | 35 | ![Poses comparison](imgs/Poses.png) 36 | 37 | ## Understanding the retargeting algorithm 38 | The algorithm used derives from 39 | [@sketchpunk](https://github.com/sketchpunk/FunWithWebGL2/tree/master/lesson_132_animation_retargeting). Given two skeletons, an animation can be approximately retargeted using an auxiliary pose shared by both skeletons. As long as the bone heriarchy and the auxiliary pose of both skeletons are similar, the retargeting can be successfully performed. However, some issues can appear as bone proportions might differ. This might result in missed bone contacts. This algorithm is particularly useful to retarget vague animations such as running or walking. 40 | 41 | ### 1. Joint mapping 42 | The algorithm starts mapping each joint of the source skeleton to a corresponding joint in the target skeleton by name. In this approach, the same skeleton structure is assumed, so the mapping is one-to-on. But some joints may need to be interpolated if the skeleton has a different structure. 43 | 44 | ### 2. Skeleton preparation 45 | 46 | Once the mapping is done, the next step is posing the source and target avatars into the same pose, each with their respective local transforms. This auxiliary pose can be the bind pose as long as the configuration is exactly the same for both skeletons. This ensures each bone to be retargeted has the same direction in world space for each of the avatars. 47 | 48 | ![Good skeleton bind pose](imgs/GoodPose.png) 49 | 50 | ### 3. Retarget joint transformations 51 | When an animation is applied to the source avatar, each local rotation can be transfered from one avatar to the other by computing the offset with respect to the auxiliary pose. Since both skeleton share the same auxiliary pose, the offset in world space should be the same. Then it is only a matter of changing between local and world spaces. 52 | 53 | The rotation (quaternion) computations look as follows (where `bind` means the `auxiliary pose`): 54 | 55 | `trgLocal` = `invBindTrgWorldParent` * `bindSrcWorldParent` * `srcLocal` * `invBindSrcWorld` * `bindTrgWorld` 56 | 57 | These arbitrary multiplications can be explained as follows: 58 | - Each bone's new rotation will be transformed isolated from the rest of the other bone's new rotations. Instead, the auxiliary pose will be used for the rest of the bones. 59 | - srcWorldRot = `bindSrcWorldParent` * `srcLocal`: compute world rotation of the avatar with this bone's new rotation and the auxiliary pose for the rest of the bones. 60 | - offsetWorldRot = srcWorldRot * `invBindSrcWorld`: by multiplying on the right by the inverse of the auxiliary pose __(including the current bone)__, the auxiliary pose is removed and results in the __world offset rotation__. 61 | > [!NOTE] 62 | > Multiplying by the left instead, would result in the local offset rotation. 63 | - trgWorldRot = offsetWorldRot * `bindTrgWorld`: add the offset to the target skeleton's auxiliary pose. Since both source and target poses are the same, the same movement should be expected. 64 | - `trgLocalRot` = `invBindTrgWorldParent` * trgWorldRot: by multplying on the left by the inverse of the auxiliary pose __(excluding the current bone)__, the parent's auxiliary pose is removed and results in the __local retargeted bone rotation__. The current bone needs to be excluded from the inverse because the complete local retargeted rotation is desired, not just the offset with respect to the auxiliary pose. 65 | 66 | ![Good skeleton retargeted pose](imgs/GoodRetarget.png) 67 | 68 | The implemented algorithm uses a more sophisticated approach which applies some extra world rotation as, sometimes, it is easier to modify the container of the skeleton, rather than the actual skeleton in order to build the auxiliary pose. 69 | 70 | trgLocal = invBindTrgWorldParent * `invTrgEmbedded` * `srcEmbedded` * bindSrcWorldParent * srcLocal * invBindSrcWorld * `invSrcEmbedded` * `trgEmbedded` * bindTrgWorld 71 | -------------------------------------------------------------------------------- /docs/imgs/BadCurrentPose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upf-gti/retargeting-threejs/5f844f089ed5eca79a775af5a66354e9c85a83a5/docs/imgs/BadCurrentPose.png -------------------------------------------------------------------------------- /docs/imgs/BadCurrentPoseRetarget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upf-gti/retargeting-threejs/5f844f089ed5eca79a775af5a66354e9c85a83a5/docs/imgs/BadCurrentPoseRetarget.png -------------------------------------------------------------------------------- /docs/imgs/BadEmbedPose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upf-gti/retargeting-threejs/5f844f089ed5eca79a775af5a66354e9c85a83a5/docs/imgs/BadEmbedPose.png -------------------------------------------------------------------------------- /docs/imgs/BadEmbedRetarget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upf-gti/retargeting-threejs/5f844f089ed5eca79a775af5a66354e9c85a83a5/docs/imgs/BadEmbedRetarget.png -------------------------------------------------------------------------------- /docs/imgs/GoodPose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upf-gti/retargeting-threejs/5f844f089ed5eca79a775af5a66354e9c85a83a5/docs/imgs/GoodPose.png -------------------------------------------------------------------------------- /docs/imgs/GoodRetarget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upf-gti/retargeting-threejs/5f844f089ed5eca79a775af5a66354e9c85a83a5/docs/imgs/GoodRetarget.png -------------------------------------------------------------------------------- /docs/imgs/Poses.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upf-gti/retargeting-threejs/5f844f089ed5eca79a775af5a66354e9c85a83a5/docs/imgs/Poses.png -------------------------------------------------------------------------------- /docs/imgs/Spaces.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upf-gti/retargeting-threejs/5f844f089ed5eca79a775af5a66354e9c85a83a5/docs/imgs/Spaces.png -------------------------------------------------------------------------------- /retargeting.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | //import { normalize } from 'three/src/math/MathUtils.js'; 3 | 4 | 5 | // asymetric and/or negative scaling of objects is not properly supported 6 | class AnimationRetargeting { 7 | 8 | /** 9 | * @DEFAULT Uses skeleton's actual bind pose 10 | * @CURRENT Uses skeleton's current pose 11 | * @TPOSE Forces the skeleton's current pose to T-pose and uses skeleton's current pose 12 | */ 13 | static BindPoseModes = { DEFAULT : 0, CURRENT: 1} 14 | static boneMap = { 15 | "LEye": "lefteye", 16 | "REye": "righteye", 17 | "Head": "head", 18 | "Neck": "neck", 19 | "ShouldersUnion": "spine2", // chest 20 | "Stomach": "spine1", 21 | "BelowStomach": "spine", 22 | "Hips": "hips", 23 | "RShoulder": "rightshoulder", 24 | "RArm": "rightarm", 25 | "RElbow": "rightforearm", 26 | "RHandThumb": "righthandthumb1", 27 | "RHandThumb2": "righthandthumb2", 28 | "RHandThumb3": "righthandthumb3", 29 | "RHandThumb4": "righthandthumb4", 30 | "RHandIndex": "righthandindex1", 31 | "RHandIndex2": "righthandindex2", 32 | "RHandIndex3": "righthandindex3", 33 | "RHandIndex4": "righthandindex4", 34 | "RHandMiddle": "righthandmiddle1", 35 | "RHandMiddle2": "righthandmiddle2", 36 | "RHandMiddle3": "righthandmiddle3", 37 | "RHandMiddle4": "righthandmiddle4", 38 | "RHandRing": "righthandring1", 39 | "RHandRing2": "righthandring2", 40 | "RHandRing3": "righthandring3", 41 | "RHandRing4": "righthandring4", 42 | "RHandPinky": "righthandpinky1", 43 | "RHandPinky2": "righthandpinky2", 44 | "RHandPinky3": "righthandpinky3", 45 | "RHandPinky4": "righthandpinky4", 46 | "RWrist": "righthand", 47 | "LShoulder": "leftshoulder", 48 | "LArm": "leftarm", 49 | "LElbow": "leftforearm", 50 | "LHandThumb": "lefthandthumb1", 51 | "LHandThumb2": "lefthandthumb2", 52 | "LHandThumb3": "lefthandthumb3", 53 | "LHandThumb4": "lefthandthumb4", 54 | "LHandIndex": "lefthandindex1", 55 | "LHandIndex2": "lefthandindex2", 56 | "LHandIndex3": "lefthandindex3", 57 | "LHandIndex4": "lefthandindex4", 58 | "LHandMiddle": "lefthandmiddle1", 59 | "LHandMiddle2": "lefthandmiddle2", 60 | "LHandMiddle3": "lefthandmiddle3", 61 | "LHandMiddle4": "lefthandmiddle4", 62 | "LHandRing": "lefthandring1", 63 | "LHandRing2": "lefthandring2", 64 | "LHandRing3": "lefthandring3", 65 | "LHandRing4": "lefthandring4", 66 | "LHandPinky": "lefthandpinky1", 67 | "LHandPinky2": "lefthandpinky2", 68 | "LHandPinky3": "lefthandpinky3", 69 | "LHandPinky4": "lefthandpinky4", 70 | "LWrist": "lefthand", 71 | "LUpLeg": "leftupleg", 72 | "LLeg": "leftleg", 73 | "LFoot": "leftfoot", 74 | "RUpLeg": "rightupleg", 75 | "RLeg": "rightleg", 76 | "RFoot": "rightfoot", 77 | }; 78 | /** 79 | * Retargets animations and/or current poses from one skeleton to another. 80 | * Both skeletons must have the same bind pose (same orientation for each mapped bone) in order to properly work. 81 | * Use optional parameters to adjust the bind pose. 82 | * @param srcSkeleton Skeleton of source avatar. Its bind pose must be the same as trgSkeleton. The original skeleton is cloned and can be safely modified 83 | * @param trgSkeleton Same as srcSkeleton but for the target avatar 84 | * @param options.srcPoseMode BindPoseModes enum values. Pose of the srcSkeleton that will be used as the bind pose for the retargeting. By default, skeleton's actual bind pose is used. 85 | * @param options.trgPoseMode BindPoseModes enum values. Same as srcPoseMode but for the target avatar. 86 | 87 | * @param options.srcEmbedWorldTransforms Bool. Retargeting only takes into account transforms from the actual bone objects. 88 | * If set to true, external (parent) transforms are computed and embedded into the root joint. 89 | * Afterwards, parent transforms/matrices can be safely modified and will not affect in retargeting. 90 | * Useful when it is easier to modify the container of the skeleton rather than the actual skeleton in order to align source and target poses 91 | * @param options.trgEmbedWorldTransforms Same as srcEmbedWorldTransforms but for the target avatar 92 | * @param options.boneNameMap String-to-string mapping between src and trg through bone names. Only supports one-to-one mapping 93 | */ 94 | constructor( srcSkeleton, trgSkeleton, options = {} ){ 95 | 96 | this.srcSkeleton = srcSkeleton; // original ref 97 | if ( !srcSkeleton.boneInverses ){ // find its skeleton 98 | srcSkeleton.traverse( (o) => { if( o.isSkinnedMesh ){ this.srcSkeleton = o.skeleton; } } ); 99 | } 100 | this.trgSkeleton = trgSkeleton; // original ref 101 | if ( !trgSkeleton.boneInverses ){ // find its skeleton 102 | trgSkeleton.traverse( (o) => { if( o.isSkinnedMesh ){ this.trgSkeleton = o.skeleton; } } ); 103 | } 104 | 105 | this.boneMap = this.computeBoneMap( this.srcSkeleton, this.trgSkeleton, options.boneNameMap ); // { idxMap: [], nameMape:{} } 106 | this.srcBindPose = this.cloneRawSkeleton( this.srcSkeleton, options.srcPoseMode, options.srcEmbedWorldTransforms ); // returns pure skeleton, without any object model applied 107 | this.trgBindPose = this.cloneRawSkeleton( this.trgSkeleton, options.trgPoseMode, options.trgEmbedWorldTransforms ); // returns pure skeleton, without any object model applied 108 | 109 | this.precomputedQuats = this.precomputeRetargetingQuats(); 110 | this.proportionRatio = this.computeProportionRatio(); // returns an aproximate ratio of lengths between source skeleton and target skeleton 111 | } 112 | 113 | /** 114 | * creates a Transform object with identity values 115 | * @returns Transform 116 | */ 117 | _newTransform(){ return { p: new THREE.Vector3(0,0,0), q: new THREE.Quaternion(0,0,0,1), s: new THREE.Vector3(1,1,1) }; } 118 | 119 | /** 120 | * Deep clone of the skeleton. New bones are generated. Skeleton's parent objects will not be linked to the cloned one 121 | * Returned skeleton has new attributes: 122 | * - Always: .parentIndices, .transformsWorld, .transformsWorldInverses 123 | * - embedWorld == true: .transformsWorldEmbedded 124 | * @param {THREE.Skeleton} skeleton 125 | * @returns {THREE.Skeleton} 126 | */ 127 | cloneRawSkeleton( skeleton, poseMode, embedWorld = false ){ 128 | let bones = skeleton.bones; 129 | 130 | let resultBones = new Array( bones.length ); 131 | let parentIndices = new Int16Array( bones.length ); 132 | 133 | // bones[0].clone( true ); // recursive 134 | for( let i = 0; i < bones.length; ++i ){ 135 | resultBones[i] = bones[i].clone(false); 136 | resultBones[i].parent = null; 137 | } 138 | 139 | for( let i = 0; i < bones.length; ++i ){ 140 | let parentIdx = findIndexOfBone( skeleton, bones[i].parent ) 141 | if ( parentIdx > -1 ){ resultBones[ parentIdx ].add( resultBones[ i ] ); } 142 | 143 | parentIndices[i] = parentIdx; 144 | } 145 | 146 | resultBones[0].updateWorldMatrix( false, true ); // assume 0 is root. Update all global matrices (root does not have any parent) 147 | 148 | // generate skeleton 149 | let resultSkeleton; 150 | switch(poseMode) { 151 | case AnimationRetargeting.BindPoseModes.CURRENT: 152 | resultSkeleton = new THREE.Skeleton( resultBones ); // will automatically compute the inverses from the matrixWorld of each bone 153 | 154 | break; 155 | default: 156 | let boneInverses = new Array( skeleton.boneInverses.length ); 157 | for( let i = 0; i < boneInverses.length; ++i ) { 158 | boneInverses[i] = skeleton.boneInverses[i].clone(); 159 | } 160 | resultSkeleton = new THREE.Skeleton( resultBones, boneInverses ); 161 | resultSkeleton.pose(); 162 | break; 163 | } 164 | 165 | resultSkeleton.parentIndices = parentIndices; // add this attribute to the THREE.Skeleton class 166 | 167 | // precompute transforms (forward and inverse) from world matrices 168 | let transforms = new Array( skeleton.bones.length ); 169 | let transformsInverses = new Array( skeleton.bones.length ); 170 | for( let i = 0; i < transforms.length; ++i ){ 171 | let t = this._newTransform(); 172 | resultSkeleton.bones[i].matrixWorld.decompose( t.p, t.q, t.s ); 173 | transforms[i] = t; 174 | 175 | t = this._newTransform(); 176 | resultSkeleton.boneInverses[i].decompose( t.p, t.q, t.s ); 177 | transformsInverses[i] = t; 178 | } 179 | resultSkeleton.transformsWorld = transforms; 180 | resultSkeleton.transformsWorldInverses = transformsInverses; 181 | 182 | // embedded transform 183 | if ( embedWorld && bones[0].parent ){ 184 | let embedded = { forward: this._newTransform(), inverse: this._newTransform() }; 185 | let t = embedded.forward; 186 | bones[0].parent.updateWorldMatrix( true, false ); 187 | bones[0].parent.matrixWorld.decompose( t.p, t.q, t.s ); 188 | t = embedded.inverse; 189 | skeleton.bones[0].parent.matrixWorld.clone().invert().decompose( t.p, t.q, t.s ); 190 | resultSkeleton.transformsWorldEmbedded = embedded; 191 | } 192 | return resultSkeleton; 193 | } 194 | 195 | 196 | /** 197 | * Maps bones from one skeleton to another given boneMap. 198 | * Given a null bonemap, an automap is performed 199 | * @param {THREE.Skeleton} srcSkeleton 200 | * @param {THREE.Skeleton} trgSkeleton 201 | * @param {object} boneMap { string: string } 202 | * @returns {object} { idxMap: [], nameMape: {} } 203 | */ 204 | computeBoneMap( srcSkeleton, trgSkeleton, boneMap = null ){ 205 | let srcBones = srcSkeleton.bones; 206 | let trgBones = trgSkeleton.bones; 207 | let result = { 208 | idxMap: new Int16Array( srcBones.length ), 209 | nameMap: {} 210 | } 211 | result.idxMap.fill( -1 ); // default to no map; 212 | if ( boneMap ) { 213 | for ( let srcName in boneMap ){ 214 | let idx = findIndexOfBoneByName( srcSkeleton, srcName ); 215 | if ( idx < 0 ){ continue; } 216 | let trgIdx = findIndexOfBoneByName( trgSkeleton, boneMap[ srcName ] ); // will return either a valid index or -1 217 | result.idxMap[ idx ] = trgIdx; 218 | result.nameMap[ srcName ] = boneMap[ srcName ]; 219 | } 220 | } 221 | else { 222 | // automap 223 | const auxBoneMap = Object.keys(AnimationRetargeting.boneMap); 224 | this.srcBoneMap = computeAutoBoneMap( srcSkeleton ); 225 | this.trgBoneMap = computeAutoBoneMap( trgSkeleton ); 226 | if(this.srcBoneMap.idxMap.length && this.trgBoneMap.idxMap.length) { 227 | for(let i = 0; i < auxBoneMap.length; i++) { 228 | const name = auxBoneMap[i]; 229 | if(this.srcBoneMap.idxMap[i] < 0) { 230 | continue; 231 | } 232 | result.idxMap[this.srcBoneMap.idxMap[i]] = this.trgBoneMap.idxMap[i]; 233 | result.nameMap[ this.srcBoneMap.nameMap[name]] = this.trgBoneMap.nameMap[name]; 234 | } 235 | } 236 | } 237 | 238 | return result 239 | } 240 | 241 | /** 242 | * Computes an aproximate ratio of lengths between source skeleton and target skeleton 243 | */ 244 | computeProportionRatio(){ 245 | let srcLength = 0; 246 | // Compute source sum of bone lengths 247 | for(let i = 1; i < this.srcBindPose.bones.length; i++) { 248 | let dist = this.srcBindPose.bones[i].getWorldPosition(new THREE.Vector3()).distanceTo(this.srcBindPose.bones[i].parent.getWorldPosition(new THREE.Vector3())) 249 | srcLength += dist; 250 | } 251 | 252 | let trgLength = 0; 253 | // Compute target sum of bone lengths 254 | for(let i = 1; i < this.trgBindPose.bones.length; i++) { 255 | let dist = this.trgBindPose.bones[i].getWorldPosition(new THREE.Vector3()).distanceTo(this.trgBindPose.bones[i].parent.getWorldPosition(new THREE.Vector3())) 256 | trgLength += dist; 257 | } 258 | return trgLength / srcLength 259 | } 260 | 261 | precomputeRetargetingQuats(){ 262 | //BASIC ALGORITHM --> trglocal = invBindTrgWorldParent * bindSrcWorldParent * srcLocal * invBindSrcWorld * bindTrgWorld 263 | // trglocal = invBindTrgWorldParent * invTrgEmbedded * srcEmbedded * bindSrcWorldParent * srcLocal * invBindSrcWorld * invSrcEmbedded * trgEmbedded * bindTrgWorld 264 | 265 | let left = new Array( this.srcBindPose.bones.length ); // invBindTrgWorldParent * invTrgEmbedded * srcEmbedded * bindSrcWorldParent 266 | let right = new Array( this.srcBindPose.bones.length ); // invBindSrcWorld * invSrcEmbedded * trgEmbedded * bindTrgWorld 267 | 268 | for( let srcIndex = 0; srcIndex < left.length; ++srcIndex ){ 269 | let trgIndex = this.boneMap.idxMap[ srcIndex ]; 270 | if( trgIndex < 0 ){ // not mapped, cannot precompute 271 | left[ srcIndex ] = null; 272 | right[ srcIndex ] = null; 273 | continue; 274 | } 275 | 276 | let resultQuat = new THREE.Quaternion(0,0,0,1); 277 | resultQuat.copy( this.trgBindPose.transformsWorld[ trgIndex ].q ); // bindTrgWorld 278 | if ( this.trgBindPose.transformsWorldEmbedded ) { resultQuat.premultiply( this.trgBindPose.transformsWorldEmbedded.forward.q ); } // trgEmbedded 279 | if ( this.srcBindPose.transformsWorldEmbedded ) { resultQuat.premultiply( this.srcBindPose.transformsWorldEmbedded.inverse.q ); } // invSrcEmbedded 280 | resultQuat.premultiply( this.srcBindPose.transformsWorldInverses[ srcIndex ].q ); // invBindSrcWorld 281 | right[ srcIndex ] = resultQuat; 282 | 283 | resultQuat = new THREE.Quaternion(0,0,0,1); 284 | // bindSrcWorldParent 285 | if ( this.srcBindPose.bones[ srcIndex ].parent ){ 286 | let parentIdx = this.srcBindPose.parentIndices[ srcIndex ]; 287 | resultQuat.premultiply( this.srcBindPose.transformsWorld[ parentIdx ].q ); 288 | } 289 | 290 | if ( this.srcBindPose.transformsWorldEmbedded ) { resultQuat.premultiply( this.srcBindPose.transformsWorldEmbedded.forward.q ); } // srcEmbedded 291 | if ( this.trgBindPose.transformsWorldEmbedded ) { resultQuat.premultiply( this.trgBindPose.transformsWorldEmbedded.inverse.q ); } // invTrgEmbedded 292 | 293 | // invBindTrgWorldParent 294 | if ( this.trgBindPose.bones[ trgIndex ].parent ){ 295 | let parentIdx = this.trgBindPose.parentIndices[ trgIndex ]; 296 | resultQuat.premultiply( this.trgBindPose.transformsWorldInverses[ parentIdx ].q ); 297 | } 298 | left[ srcIndex ] = resultQuat 299 | } 300 | 301 | return { left: left, right: right }; 302 | } 303 | 304 | /** 305 | * retargets the bone specified 306 | * @param {int} srcIndex MUST be a valid MAPPED bone. Otherwise it crashes 307 | * @param {THREE.Quaternion} srcLocalQuat 308 | * @param {THREE.Quaternion} resultQuat if null, a new THREE.Quaternion is created 309 | * @returns resultQuat 310 | */ 311 | _retargetQuaternion( srcIndex, srcLocalQuat, resultQuat = null ){ 312 | if ( !resultQuat ){ resultQuat = new THREE.Quaternion(0,0,0,1); } 313 | //BASIC ALGORITHM --> trglocal = invBindTrgWorldParent * bindSrcWorldParent * srcLocal * invBindSrcWorld * bindTrgWorld 314 | // trglocal = invBindTrgWorldParent * invTrgEmbedded * srcEmbedded * bindSrcWorldParent * srcLocal * invBindSrcWorld * invSrcEmbedded * trgEmbedded * bindTrgWorld 315 | 316 | // In this order because resultQuat and srcLocalQuat might be the same Quaternion instance 317 | resultQuat.copy( srcLocalQuat ); // srcLocal 318 | resultQuat.premultiply( this.precomputedQuats.left[ srcIndex ] ); // invBindTrgWorldParent * invTrgEmbedded * srcEmbedded * bindSrcWorldParent 319 | resultQuat.multiply( this.precomputedQuats.right[ srcIndex ] ); // invBindSrcWorld * invSrcEmbedded * trgEmbedded * bindTrgWorld 320 | return resultQuat; 321 | } 322 | 323 | /** 324 | * Retargets the current whole (mapped) skeleton pose. 325 | * Currently, only quaternions are retargeted 326 | */ 327 | retargetPose(){ 328 | 329 | let m = this.boneMap.idxMap; 330 | for ( let i = 0; i < m.length; ++i ){ 331 | if ( m[i] < 0 ){ continue; } 332 | this._retargetQuaternion( i, this.srcSkeleton.bones[ i ].quaternion, this.trgSkeleton.bones[ m[i] ].quaternion ); 333 | } 334 | } 335 | 336 | /** 337 | * 338 | * assumes srcTrack IS a position track (VectorKeyframeTrack) with the proper values array and name (boneName.scale) 339 | * @param {THREE.VectorKeyframeTrack} srcTrack 340 | * @returns {THREE.VectorKeyframeTrack} 341 | */ 342 | retargetPositionTrack( srcTrack ){ 343 | let boneName = srcTrack.name.slice(0, srcTrack.name.length - 9 ); // remove the ".position" 344 | let boneIndex = findIndexOfBoneByName( this.srcSkeleton, boneName ); 345 | if ( boneIndex < 0 || this.boneMap.idxMap[ boneIndex ] < 0 ){ 346 | return null; 347 | } 348 | // Retargets the root bone posiiton 349 | let srcValues = srcTrack.values; 350 | let trgValues = new Float32Array( srcValues.length ); 351 | if( boneIndex == 0 ) { // asume the first bone is the root 352 | 353 | let trgBindPos = this.trgBindPose.bones[boneIndex].getWorldPosition(new THREE.Vector3()); 354 | let srcBindPos = this.srcBindPose.bones[boneIndex].getWorldPosition(new THREE.Vector3()); 355 | 356 | let pos = new THREE.Vector3(); 357 | 358 | for( let i = 0; i < srcValues.length; i+=3 ){ 359 | 360 | pos.set( srcValues[i], srcValues[i+1], srcValues[i+2]); 361 | let diffPosition = new THREE.Vector3(); 362 | diffPosition.subVectors(pos, srcBindPos); 363 | 364 | // Scale the animation difference position with the scale diff between source and target and add it to the the Target Bind Position of the bone 365 | diffPosition.multiplyScalar(this.proportionRatio); 366 | if(this.srcBindPose.transformsWorldEmbedded) { 367 | diffPosition.applyQuaternion(this.srcBindPose.transformsWorldEmbedded.forward.q); 368 | } 369 | if(this.trgBindPose.transformsWorldEmbedded) { 370 | diffPosition.applyQuaternion(this.trgBindPose.transformsWorldEmbedded.inverse.q); 371 | } 372 | diffPosition.add(trgBindPos); 373 | 374 | trgValues[i] = diffPosition.x ; 375 | trgValues[i+1] = diffPosition.y ; 376 | trgValues[i+2] = diffPosition.z ; 377 | } 378 | } 379 | // TODO missing interpolation mode. Assuming always linear. Also check if arrays are copied or referenced 380 | return new THREE.VectorKeyframeTrack( this.boneMap.nameMap[ boneName ] + ".position", srcTrack.times, trgValues ); 381 | } 382 | 383 | /** 384 | * assumes srcTrack IS a quaternion track with the proper values array and name (boneName.quaternion) 385 | * @param {THREE.QuaternionKeyframeTrack} srcTrack 386 | * @returns {THREE.QuaternionKeyframeTrack} 387 | */ 388 | retargetQuaternionTrack( srcTrack ){ 389 | let boneName = srcTrack.name.slice(0, srcTrack.name.length - 11 ); // remove the ".quaternion" 390 | let boneIndex = findIndexOfBoneByName( this.srcSkeleton, boneName ); 391 | if ( boneIndex < 0 || this.boneMap.idxMap[ boneIndex ] < 0 ){ 392 | return null; 393 | } 394 | 395 | let quat = new THREE.Quaternion( 0,0,0,1 ); 396 | let srcValues = srcTrack.values; 397 | let trgValues = new Float32Array( srcValues.length ); 398 | for( let i = 0; i < srcValues.length; i+=4 ){ 399 | quat.set( srcValues[i], srcValues[i+1], srcValues[i+2], srcValues[i+3] ); 400 | this._retargetQuaternion( boneIndex, quat, quat ); 401 | trgValues[i] = quat.x; 402 | trgValues[i+1] = quat.y; 403 | trgValues[i+2] = quat.z; 404 | trgValues[i+3] = quat.w; 405 | } 406 | 407 | // TODO missing interpolation mode. Assuming always linear 408 | return new THREE.QuaternionKeyframeTrack( this.boneMap.nameMap[ boneName ] + ".quaternion", srcTrack.times, trgValues ); 409 | } 410 | 411 | /** 412 | * NOT IMPLEMENTEED 413 | * assumes srcTrack IS a scale track (VectorKeyframeTrack) with the proper values array and name (boneName.scale) 414 | * @param {THREE.VectorKeyframeTrack} srcTrack 415 | * @returns {THREE.VectorKeyframeTrack} 416 | */ 417 | retargetScaleTrack( srcTrack ){ 418 | let boneName = srcTrack.name.slice(0, srcTrack.name.length - 6 ); // remove the ".scale" 419 | let boneIndex = findIndexOfBoneByName( this.srcSkeleton, boneName ); 420 | if ( boneIndex < 0 || this.boneMap.idxMap[ boneIndex ] < 0 ){ 421 | return null; 422 | } 423 | // TODO 424 | 425 | // TODO missing interpolation mode. Assuming always linear. Also check if arrays are copied or referenced 426 | return new THREE.VectorKeyframeTrack( this.boneMap.nameMap[ boneName ] + ".scale", srcTrack.times, srcTrack.values ); 427 | } 428 | 429 | /** 430 | * Given a clip, all tracks with a mapped bone are retargeted. 431 | * Currently only quaternions are retargeted 432 | * @param {THREE.AnimationClip} anim 433 | * @returns {THREE.AnimationClip} 434 | */ 435 | retargetAnimation( anim ){ 436 | let trgTracks = []; 437 | let srcTracks = anim.tracks; 438 | for( let i = 0; i < srcTracks.length; ++i ){ 439 | let t = srcTracks[i]; 440 | let newTrack = null; 441 | if ( t.name.endsWith( ".position" ) && t.name.includes(this.srcSkeleton.bones[0].name) ){ newTrack = this.retargetPositionTrack( t ); } // ignore for now 442 | else if ( t.name.endsWith( ".quaternion" ) ){ newTrack = this.retargetQuaternionTrack( t ); } 443 | else if ( t.name.endsWith( ".scale" ) ){ newTrack = this.retargetScaleTrack( t ); } // ignore for now 444 | 445 | if ( newTrack ){ trgTracks.push( newTrack ); } 446 | } 447 | 448 | // negative duration: automatically computes proper duration of animation based on tracks 449 | return new THREE.AnimationClip( anim.name, -1, trgTracks, anim.blendMode ); 450 | } 451 | } 452 | 453 | // ---- HELPERS ---- 454 | // should be moved into a "utils" file 455 | 456 | // O(n) 457 | function findIndexOfBone( skeleton, bone ){ 458 | if ( !bone ){ return -1;} 459 | let b = skeleton.bones; 460 | for( let i = 0; i < b.length; ++i ){ 461 | if ( b[i] == bone ){ return i; } 462 | } 463 | return -1; 464 | } 465 | 466 | // O(nm) 467 | function findIndexOfBoneByName( skeleton, name ){ 468 | if ( !name ){ return -1; } 469 | let b = skeleton.bones; 470 | for( let i = 0; i < b.length; ++i ){ 471 | if ( b[i].name == name ){ return i; } 472 | } 473 | return -1; 474 | } 475 | 476 | // sets bind quaternions only. Warning: Not the best function to call every frame. 477 | function forceBindPoseQuats( skeleton, skipRoot = false ){ 478 | let bones = skeleton.bones; 479 | let inverses = skeleton.boneInverses; 480 | if ( inverses.length < 1 ){ return; } 481 | let boneMat = inverses[0].clone(); 482 | let _ignoreVec3 = new THREE.Vector3(); 483 | for( let i = 0; i < bones.length; ++i ){ 484 | boneMat.copy( inverses[i] ); // World to Local 485 | boneMat.invert(); // Local to World 486 | 487 | // get only the local matrix of the bone (root should not need any change) 488 | let parentIdx = findIndexOfBone( skeleton, bones[i].parent ); 489 | if ( parentIdx > -1 ){ boneMat.premultiply( inverses[ parentIdx ] ); } 490 | else{ 491 | if ( skipRoot ){ continue; } 492 | } 493 | 494 | boneMat.decompose( _ignoreVec3, bones[i].quaternion, _ignoreVec3 ); 495 | // bones[i].quaternion.setFromRotationMatrix( boneMat ); 496 | bones[i].quaternion.normalize(); 497 | } 498 | } 499 | 500 | /** 501 | * Apply a T-pose shape to the passed skeleton. 502 | * @param {THREE.Skeleton} skeleton 503 | * @param {Object} map 504 | */ 505 | function applyTPose(skeleton, map) { 506 | 507 | if(!map) { 508 | map = computeAutoBoneMap(skeleton); 509 | map = map.nameMap; 510 | } 511 | else { 512 | if(Object.values(map).every(value => value === null)) { 513 | map = computeAutoBoneMap(skeleton); 514 | map = map.nameMap; 515 | } 516 | } 517 | 518 | let resultSkeleton = skeleton; 519 | // Check if spine is extended 520 | let spineBase = resultSkeleton.getBoneByName(map.BelowStomach); // spine 521 | let spineChild = spineBase.children[0]; 522 | let spineParent = spineBase; 523 | let parent = spineParent.parent; 524 | while(parent && parent.isBone) { 525 | let pos = spineParent.getWorldPosition(new THREE.Vector3()); 526 | let parentPos = parent.getWorldPosition(new THREE.Vector3()); 527 | // Compute direction (parent-to-child) 528 | let dir = new THREE.Vector3(); 529 | dir.subVectors(pos, parentPos).normalize(); 530 | alignBoneToAxis(spineParent, dir); 531 | spineChild = spineChild.parent; 532 | spineParent = spineParent.parent; 533 | parent = spineParent.parent; 534 | } 535 | 536 | //------------------------------------ LOOK AT Z-AXIS ------------------------------------// 537 | // Check if the resultSkeleton is oriented in the +Z using the plane formed by left up and the hips 538 | let leftBaseLeg = resultSkeleton.getBoneByName(map.LUpLeg); // left up leg 539 | if(!leftBaseLeg) { 540 | return skeleton; 541 | } 542 | let hips = leftBaseLeg.parent; // hips 543 | if(!hips) { 544 | return skeleton; 545 | } 546 | let leftBaseLegPos = leftBaseLeg.getWorldPosition(new THREE.Vector3()); 547 | let hipsPos = hips.getWorldPosition(new THREE.Vector3()); // new THREE.Vector3().setFromMatrixPosition(hips.matrixWorld); // BEST PERFORMANCE 548 | 549 | // Compute up leg direciton 550 | let lefLegDir = new THREE.Vector3(); 551 | lefLegDir.subVectors(leftBaseLegPos, hipsPos).normalize(); 552 | 553 | spineBase = resultSkeleton.getBoneByName(map.BelowStomach); // spine 554 | const spineBasePos = spineBase.getWorldPosition(new THREE.Vector3()); 555 | 556 | // Compute spine direction 557 | let spineDir = new THREE.Vector3(); 558 | let spineDirO = new THREE.Vector3(); 559 | spineDirO.subVectors(spineBasePos, hipsPos); 560 | spineDir.subVectors(spineBasePos, hipsPos).normalize(); 561 | 562 | // Compute perpendicular axis between left up and hips-spine 563 | let axis = new THREE.Vector3(); 564 | axis.crossVectors(lefLegDir, spineDir).normalize(); 565 | 566 | let zAxis = new THREE.Vector3(0, 0, 1); 567 | // Compute angle (rad) between perpendicular axis and z-axis 568 | let angle = (zAxis).angleTo(axis); 569 | 570 | if(Math.abs(angle) > 0.001) { 571 | let rot = new THREE.Quaternion();//.setFromAxisAngle(yAxis, -angle); 572 | 573 | // Get spine bone global rotation 574 | let hipsRot = hips.getWorldQuaternion(new THREE.Quaternion()); 575 | // Apply computed rotation to the spine bone global rotation 576 | rot = rot.setFromUnitVectors(axis, zAxis) 577 | spineDirO.applyQuaternion(rot); 578 | hipsRot = hipsRot.premultiply(rot); 579 | 580 | if (hips.parent) { 581 | let parent = hips.parent; 582 | let hipsParentRot = parent.getWorldQuaternion(new THREE.Quaternion()); 583 | // Convert new spine bone global rotation to local rotation and set to the bone 584 | hips.quaternion.copy(hipsRot.premultiply(hipsParentRot.invert())); 585 | // let hipsParentPos = parent.getWorldPosition(new THREE.Vector3()); 586 | 587 | // hips.position.copy(spineDirO.sub(hipsParentPos)); 588 | 589 | } 590 | else { 591 | hips.quaternion.copy(hipsRot); 592 | hips.position.copy(spineDirO); 593 | } 594 | // Update bone matrix and children matrices 595 | hips.updateMatrix(); 596 | hips.updateMatrixWorld(true, true); 597 | } 598 | // Check if spine follows +Y axis 599 | spineBase = resultSkeleton.getBoneByName(map.BelowStomach); // spine 600 | let yAxis = new THREE.Vector3(0, 1, 0); 601 | alignBoneToAxis(hips, yAxis, spineBase); 602 | 603 | //------------------------------------ LEGS ALIGNED TO Y-AXIS ------------------------------------// 604 | // Check if left leg is extended 605 | let leftLegEnd = resultSkeleton.getBoneByName(map.LFoot); // foot 606 | if(!leftLegEnd) { 607 | return skeleton; 608 | } 609 | let leftLegBase = leftLegEnd.parent; // knee 610 | parent = leftLegBase.parent; // up-leg 611 | 612 | let leftLegBasePos = leftLegBase.getWorldPosition(new THREE.Vector3()); 613 | let parentPos = parent.getWorldPosition(new THREE.Vector3()); 614 | 615 | // Compute up leg direction (up-leg-to-knee) 616 | let leftLegBaseDir = new THREE.Vector3(); 617 | leftLegBaseDir.subVectors(leftLegBasePos, parentPos).normalize(); 618 | alignBoneToAxis(leftLegBase, leftLegBaseDir); 619 | 620 | // Check if left leg follow the -Y axis 621 | yAxis = new THREE.Vector3(0, -1, 0); 622 | leftLegEnd = resultSkeleton.getBoneByName(map.LFoot); 623 | leftLegBase = leftLegEnd.parent; 624 | 625 | alignBoneToAxis(parent, yAxis); 626 | 627 | // Compute perpendicular axis between left leg and left foot 628 | leftLegBasePos = leftLegEnd.getWorldPosition(new THREE.Vector3()); 629 | let child = leftLegEnd.children[0].children.length ? leftLegEnd.children[0].children[0] : leftLegEnd.children[0]; 630 | let childPos = child.getWorldPosition(new THREE.Vector3()); 631 | 632 | // Compute leg direction (foot-to-footend) 633 | leftLegBaseDir.subVectors(childPos, leftLegBasePos).normalize(); 634 | 635 | axis.crossVectors(leftLegBaseDir, yAxis).normalize(); 636 | var xAxis = new THREE.Vector3(1, 0, 0); 637 | 638 | // Compute angle (rad) between perpendicular axis and x-axis 639 | angle = (xAxis).angleTo(axis); 640 | 641 | if(Math.abs(angle) > 0.001) { 642 | let rot = new THREE.Quaternion();//.setFromAxisAngle(yAxis, -angle); 643 | 644 | // Get foot bone global rotation 645 | let footRot = leftLegEnd.getWorldQuaternion(new THREE.Quaternion()); 646 | // Apply computed rotation to the foot bone global rotation 647 | rot = rot.setFromUnitVectors(axis, xAxis) 648 | leftLegBaseDir.applyQuaternion(rot); 649 | footRot.premultiply(rot); 650 | 651 | if (leftLegEnd.parent) { 652 | let parent = leftLegEnd.parent; 653 | let footParentRot = parent.getWorldQuaternion(new THREE.Quaternion()); 654 | // Convert new spine bone global rotation to local rotation and set to the bone 655 | leftLegEnd.quaternion.copy(footRot.premultiply(footParentRot.invert())); 656 | } 657 | else { 658 | leftLegEnd.quaternion.copy(footRot); 659 | } 660 | // Update bone matrix and children matrices 661 | leftLegEnd.updateMatrix(); 662 | leftLegEnd.updateMatrixWorld(true, true); 663 | } 664 | 665 | // Check if right leg is extended 666 | let rightLegEnd = resultSkeleton.getBoneByName(map.RFoot); // foot 667 | if(!rightLegEnd) { 668 | return skeleton; 669 | } 670 | let rightLegBase = rightLegEnd.parent; // knee 671 | parent = rightLegBase.parent; // up-leg 672 | 673 | let rightLegBasePos = rightLegBase.getWorldPosition(new THREE.Vector3()); 674 | parentPos = parent.getWorldPosition(new THREE.Vector3()); 675 | 676 | // Compute up leg direction (up-leg-to-knee) 677 | let rightLegBaseDir = new THREE.Vector3(); 678 | rightLegBaseDir.subVectors(rightLegBasePos, parentPos).normalize(); 679 | alignBoneToAxis(rightLegBase, rightLegBaseDir); 680 | 681 | // Check if right leg follow the -Y axis 682 | rightLegEnd = resultSkeleton.getBoneByName(map.RFoot); 683 | rightLegBase = rightLegEnd.parent; 684 | 685 | alignBoneToAxis(parent, yAxis); 686 | 687 | // child = rightLegEnd; 688 | // parent = rightLegBase; 689 | // while(child && child.isBone && child.children.length) { 690 | // let pos = parent.getWorldPosition(new THREE.Vector3()); 691 | // let parentPos = parent.parent.getWorldPosition(new THREE.Vector3()); 692 | // // Compute direction (parent-to-child) 693 | // let dir = new THREE.Vector3(); 694 | // dir.subVectors(pos, parentPos).normalize(); 695 | // this.alignBoneToAxis(child, dir); 696 | // parent = child; 697 | // child = child.children[0]; 698 | // } 699 | 700 | // Compute perpendicular axis between right leg and right foot 701 | rightLegBasePos = rightLegEnd.getWorldPosition(new THREE.Vector3()); 702 | child = rightLegEnd.children[0].children.length ? rightLegEnd.children[0].children[0] : rightLegEnd.children[0]; 703 | childPos = child.getWorldPosition(new THREE.Vector3()); 704 | 705 | // Compute leg direction (foot-to-footend) 706 | rightLegBaseDir.subVectors(childPos, rightLegBasePos).normalize(); 707 | 708 | axis.crossVectors(rightLegBaseDir, yAxis).normalize(); 709 | xAxis = new THREE.Vector3(1, 0, 0); 710 | 711 | // Compute angle (rad) between perpendicular axis and x-axis 712 | angle = (xAxis).angleTo(axis); 713 | 714 | if(Math.abs(angle) > 0.001) { 715 | let rot = new THREE.Quaternion();//.setFromAxisAngle(yAxis, -angle); 716 | 717 | // Get foot bone global rotation 718 | let footRot = rightLegEnd.getWorldQuaternion(new THREE.Quaternion()); 719 | // Apply computed rotation to the foot bone global rotation 720 | rot = rot.setFromUnitVectors(axis, xAxis) 721 | rightLegBaseDir.applyQuaternion(rot); 722 | footRot.premultiply(rot); 723 | 724 | if (rightLegEnd.parent) { 725 | let parent = rightLegEnd.parent; 726 | let footParentRot = parent.getWorldQuaternion(new THREE.Quaternion()); 727 | // Convert new spine bone global rotation to local rotation and set to the bone 728 | rightLegEnd.quaternion.copy(footRot.premultiply(footParentRot.invert())); 729 | } 730 | else { 731 | rightLegEnd.quaternion.copy(footRot); 732 | } 733 | // Update bone matrix and children matrices 734 | rightLegEnd.updateMatrix(); 735 | rightLegEnd.updateMatrixWorld(true, true); 736 | } 737 | //------------------------------------ ARMS COMPLETLY EXTENDED AND ALIGNED TO X-AXIS ------------------------------------// 738 | //LEFT 739 | 740 | // Check if left arm follow the +X axis 741 | let lArm = resultSkeleton.getBoneByName(map.LArm).parent; 742 | var xAxis = new THREE.Vector3(1, 0, 0); 743 | alignBoneToAxis(lArm, xAxis); 744 | // Check if left arm is extended 745 | let leftEnd = resultSkeleton.getBoneByName(map.LWrist); // hand 746 | let leftBase = leftEnd.parent; 747 | parent = leftBase.parent; 748 | let spine = resultSkeleton.getBoneByName(map.ShouldersUnion); 749 | 750 | while(parent != spine) { 751 | let pos = leftBase.getWorldPosition(new THREE.Vector3()); 752 | let parentPos = parent.getWorldPosition(new THREE.Vector3()); 753 | // Compute direction (parent-to-child) 754 | let dir = new THREE.Vector3(); 755 | dir.subVectors(pos, parentPos).normalize(); 756 | alignBoneToAxis(leftBase, dir); 757 | leftEnd = leftEnd.parent; 758 | leftBase = leftBase.parent; 759 | parent = leftBase.parent; 760 | } 761 | 762 | leftEnd = resultSkeleton.getBoneByName(map.LWrist); 763 | const innerLoop = (parent) => { 764 | child = parent.children[0]; 765 | while(parent.children.length) { 766 | let pos = child.getWorldPosition(new THREE.Vector3()); 767 | let parentPos = parent.getWorldPosition(new THREE.Vector3()); 768 | 769 | alignBoneToAxis(parent, xAxis); 770 | parent = child; 771 | child = parent.children[0]; 772 | } 773 | } 774 | for(let i = 0; i < leftEnd.children.length; i++) { 775 | innerLoop(leftEnd.children[i]); 776 | } 777 | 778 | //RIGHT 779 | // Check if right arm follow the -X axis 780 | let rArm = resultSkeleton.getBoneByName(map.RArm).parent; 781 | var xAxis = new THREE.Vector3(-1, 0, 0); 782 | alignBoneToAxis(rArm, xAxis); 783 | // Check if right arm is extended 784 | let rightEnd = resultSkeleton.getBoneByName(map.RWrist); // hand 785 | let rightBase = rightEnd.parent; 786 | parent = rightBase.parent; 787 | spine = resultSkeleton.getBoneByName(map.ShouldersUnion); 788 | while(parent != spine) { 789 | let pos = rightBase.getWorldPosition(new THREE.Vector3()); 790 | let parentPos = parent.getWorldPosition(new THREE.Vector3()); 791 | // Compute direction (parent-to-child) 792 | let dir = new THREE.Vector3(); 793 | dir.subVectors(pos, parentPos).normalize(); 794 | alignBoneToAxis(rightBase, dir); 795 | rightEnd = rightEnd.parent; 796 | rightBase = rightBase.parent; 797 | parent = rightBase.parent; 798 | } 799 | 800 | rightEnd = resultSkeleton.getBoneByName(map.RWrist); 801 | 802 | for(let i = 0; i < rightEnd.children.length; i++) { 803 | innerLoop(rightEnd.children[i]); 804 | } 805 | //normalize bone quaternions 806 | resultSkeleton.bones.forEach(bone =>{ 807 | bone.quaternion.normalize(); 808 | }) 809 | // resultSkeleton.calculateInverses(); 810 | resultSkeleton.update(); 811 | return {skeleton: resultSkeleton, map}; 812 | } 813 | 814 | /** 815 | * Rotate the given bone in order to be aligned with the specified axis 816 | * @param {THREE.Bone} bone 817 | * @param {THREE.Vector3} axis 818 | */ 819 | function alignBoneToAxis(bone, axis, child) { 820 | bone.updateMatrixWorld(true, true); 821 | // Get global positions 822 | const bonePos = bone.getWorldPosition(new THREE.Vector3()); 823 | const childPos = child ? child.getWorldPosition(new THREE.Vector3()) : bone.children[0].getWorldPosition(new THREE.Vector3()); 824 | 825 | // Compute the unitary direction of the bone from its position and its child position 826 | let dir = new THREE.Vector3(); 827 | dir.subVectors(childPos, bonePos).normalize(); 828 | 829 | // Compute angle (rad) between the bone direction and the axis 830 | let angle = (dir).angleTo(axis); 831 | if(Math.abs(angle) > 0.001) { 832 | // Compute the perpendicular unitary axis between the directions 833 | let perpVector = new THREE.Vector3(); 834 | perpVector.crossVectors(axis, dir).normalize(); 835 | let rot = new THREE.Quaternion().setFromAxisAngle(perpVector, -angle); 836 | // Get bone global rotation 837 | let boneRot = bone.getWorldQuaternion(new THREE.Quaternion()); 838 | // Apply computed rotation to the bone global rotation 839 | boneRot = boneRot.premultiply(rot); 840 | 841 | if (bone.parent) { 842 | let parent = bone.parent; 843 | let boneParentRot = parent.getWorldQuaternion(new THREE.Quaternion()); 844 | // Convert new bone global rotation to local rotation and set to the it 845 | bone.quaternion.copy(boneRot.premultiply(boneParentRot.invert())); 846 | // Update bone matrix and children matrices 847 | } 848 | else { 849 | bone.quaternion.copy(boneRot); 850 | } 851 | bone.updateMatrix(); 852 | bone.updateMatrixWorld(false, true); 853 | } 854 | } 855 | 856 | 857 | /** 858 | * Maps automatically bones from the skeleton to an auxiliar map. 859 | * Given a null bonemap, an automap is performed 860 | * @param {THREE.Skeleton} srcSkeleton 861 | * @returns {object} { idxMap: [], nameMape: {} } 862 | */ 863 | function computeAutoBoneMap( skeleton ){ 864 | const auxBoneMap = Object.keys(AnimationRetargeting.boneMap); 865 | let bones = skeleton.bones; 866 | let result = { 867 | idxMap: new Int16Array( auxBoneMap.length ), 868 | nameMap: {} 869 | }; 870 | 871 | result.idxMap.fill( -1 ); // default to no map; 872 | // automap 873 | for(let i = 0; i < auxBoneMap.length; i++) { 874 | const auxName = auxBoneMap[i]; 875 | for( let j = 0; j < bones.length; ++j ){ 876 | let name = bones[j].name; 877 | if ( typeof( name ) !== "string" ){ continue; } 878 | name = name.toLowerCase().replace( "mixamorig", "" ).replace( /[`~!@#$%^&*()_|+\-=?;:'"<>\{\}\\\/]/gi, "" ); 879 | if ( name.length < 1 ){ continue; } 880 | if(name.toLowerCase().includes(auxName.toLocaleLowerCase()) || name.toLowerCase().includes(AnimationRetargeting.boneMap[auxName].toLocaleLowerCase())) { 881 | result.nameMap[auxName] = bones[j].name; 882 | result.idxMap[i] = j; 883 | break; 884 | } 885 | } 886 | } 887 | return result; 888 | } 889 | export { AnimationRetargeting, findIndexOfBone, findIndexOfBoneByName, forceBindPoseQuats, applyTPose, computeAutoBoneMap }; 890 | --------------------------------------------------------------------------------