├── .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 |
110 |
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 |
119 |
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 |
128 |
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 |
73 |
74 |
Loading, please wait...
75 |
76 |
77 |
78 |
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 | 
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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------