-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathBoneWeightStudioPlugin.cs
More file actions
1289 lines (1139 loc) · 64.8 KB
/
Copy pathBoneWeightStudioPlugin.cs
File metadata and controls
1289 lines (1139 loc) · 64.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using Unity.Collections;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace VNyanWeightStudio
{
/// <summary>
/// VNyan SDK plugin: "Weight Studio". A feature-rich, runtime bone-weight editor.
///
/// - Pick a skinned mesh and an active bone.
/// - Paint weights directly on the model with an add / subtract / replace / smooth /
/// blur brush (radius, strength, falloff).
/// - Boundary-aware Laplacian smoothing over the whole mesh or a brushed selection.
/// - Maintains an UNLIMITED number of bone influences per vertex.
/// - Export to VRM 0.x, VRM 1.0 (4-influence cap), or .vbx (unlimited influences,
/// re-loadable in VNyan via the companion Reader plugin).
///
/// The window prefab is built by WeightStudioBuild.cs (Editor) and shipped as a
/// .vnobj AssetBundle; this script wires it up by control name at runtime.
/// </summary>
public class BoneWeightStudioPlugin : MonoBehaviour, VNyanInterface.IButtonClickedHandler
{
public GameObject windowPrefab;
GameObject window;
Text statusText;
Text brushLabel;
Font uiFont;
BrushConfig brush = BrushConfig.Default;
SmoothConfig smooth = SmoothConfig.Default;
bool paintMode;
readonly List<SkinnedMeshRenderer> meshes = new List<SkinnedMeshRenderer>();
SkinnedMeshRenderer activeRenderer;
WeightModel model;
// heatmap overlay (Blender-style weight-paint colours)
readonly WeightHeatmap heatmap = new WeightHeatmap();
float heatOpacity = 0.75f;
// weight transfer between meshes
TransferConfig xfer = TransferConfig.Default;
SkinnedMeshRenderer transferSource;
Text sourceLabel;
// auto-fit physics colliders
ColliderGen.GenConfig genCfg = ColliderGen.GenConfig.Default();
List<ColliderGen.GenCollider> genColliders;
bool includeColliders = true; // embed generated colliders in VBX/VRM exports
Text colliderLabel;
// tappable tooltips
Text tipText;
RectTransform meshContent;
RectTransform boneContent;
// brush picking
GameObject pickGO;
MeshCollider pickCollider;
Mesh pickBake;
float lastColliderBake = -999f;
Camera cam;
// selection mask for "smooth selection"
bool[] selectionMask;
bool buildingSelection;
float lastApply;
bool stroking; // true while a single LMB paint stroke is in progress (for one undo per stroke)
public void Awake()
{
VNyanInterface.VNyanInterface.VNyanUI.registerPluginButton("Weight Studio", this);
window = (GameObject)VNyanInterface.VNyanInterface.VNyanUI.instantiateUIPrefab(windowPrefab);
uiFont = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
LoadSettings();
if (window != null)
{
window.GetComponent<RectTransform>().anchoredPosition = Vector2.zero;
window.SetActive(false);
WireUI();
SetStatus("Load an avatar, pick a mesh + bone, enable Paint.");
}
}
public void pluginButtonClicked()
{
if (window == null) return;
window.SetActive(!window.activeSelf);
if (window.activeSelf)
{
window.transform.SetAsLastSibling();
RebuildMeshList();
}
}
// ------------------------------------------------------------ UI wiring
void WireUI()
{
BindSliderFloat("Slider_radius", "Value_radius", brush.radius, 0.005f, 0.4f, v => brush.radius = v, "0.000");
BindSliderFloat("Slider_strength", "Value_strength", brush.strength, 0f, 1f, v => brush.strength = v, "0.00");
BindSliderFloat("Slider_falloff", "Value_falloff", brush.falloff, 0f, 1f, v => brush.falloff = v, "0.00");
BindSliderInt("Slider_maxbones", "Value_maxbones", smooth.maxBonesPerVertex, 4, 16, v => smooth.maxBonesPerVertex = v);
BindSliderInt("Slider_smoothiter", "Value_smoothiter", smooth.iterations, 1, 30, v => smooth.iterations = v);
BindSliderFloat("Slider_smoothlambda", "Value_smoothlambda", smooth.lambda, 0f, 1f, v => smooth.lambda = v, "0.00");
Toggle tp = Find<Toggle>("Toggle_paint");
if (tp != null) { tp.SetIsOnWithoutNotify(paintMode); tp.onValueChanged.AddListener(v => { paintMode = v; UpdateBrushLabel(); }); }
BindButton("Button_Add", () => SetMode(BrushMode.Add));
BindButton("Button_Sub", () => SetMode(BrushMode.Subtract));
BindButton("Button_Replace", () => SetMode(BrushMode.Replace));
BindButton("Button_Smooth", () => SetMode(BrushMode.Smooth));
BindButton("Button_Blur", () => SetMode(BrushMode.Blur));
BindButton("Button_RescanMesh", RebuildMeshList);
BindButton("Button_RescanBone", RebuildBoneList);
BindButton("Button_SmoothMesh", () => DoSmooth(false));
BindButton("Button_SmoothSel", () => DoSmooth(true));
BindButton("Button_SmoothBone", () => DoSmoothBone(false));
BindButton("Button_SmoothBoneSel", () => DoSmoothBone(true));
BindButton("Button_LimitInf", DoLimit);
BindButton("Button_Apply", () => { if (model != null) { model.ApplyToMesh(); SetStatus("Applied to mesh."); } });
BindButton("Button_Revert", DoRevert);
BindButton("Button_ClearSel", () => { selectionMask = null; SetStatus("Selection cleared."); });
BindButton("Button_Undo", DoUndo);
BindButton("Button_Redo", DoRedo);
BindButton("Button_ExportVrm0", () => Export(ExportKind.Vrm0));
BindButton("Button_ExportVrm1", () => Export(ExportKind.Vrm1));
BindButton("Button_ExportVbx", () => Export(ExportKind.Vbx));
BindButton("Button_ExportVbxFile", ExportVbxFromFile); // standalone, from a .vsfavatar on disk
BindButton("Button_Close", () => { if (window != null) window.SetActive(false); });
meshContent = Find<RectTransform>("MeshContent");
boneContent = Find<RectTransform>("BoneContent");
statusText = Find<Text>("Label_Status");
brushLabel = Find<Text>("Label_Brush");
tipText = Find<Text>("Label_Tip");
sourceLabel = Find<Text>("Label_Source");
// ---- heatmap overlay ----
Toggle th = Find<Toggle>("Toggle_heatmap");
if (th != null) { th.SetIsOnWithoutNotify(false); th.onValueChanged.AddListener(SetHeatmap); }
BindSliderFloat("Slider_heatopacity", "Value_heatopacity", heatOpacity, 0f, 1f,
v => { heatOpacity = v; heatmap.SetOpacity(v); }, "0.00");
// ---- weight transfer ----
BindButton("Button_SetSource", SetTransferSource);
BindButton("Button_MapNearest", () => { xfer.mapping = TransferMapping.NearestVertex; SetStatus("Transfer mapping: Nearest vertex."); });
BindButton("Button_MapSmooth", () => { xfer.mapping = TransferMapping.NearestSmooth; SetStatus("Transfer mapping: Smooth (averaged)."); });
BindButton("Button_MapSurface", () => { xfer.mapping = TransferMapping.NearestSurface; SetStatus("Transfer mapping: Nearest surface."); });
BindSliderInt("Slider_xfersamples", "Value_xfersamples", xfer.samples, 1, 16, v => xfer.samples = v);
BindSliderFloat("Slider_xfermaxdist", "Value_xfermaxdist", xfer.maxDistance, 0f, 1f, v => xfer.maxDistance = v, "0.000");
BindSliderFloat("Slider_xferblend", "Value_xferblend", xfer.blend, 0f, 1f, v => xfer.blend = v, "0.00");
BindToggle("Toggle_xfermatch", xfer.matchByName, v => xfer.matchByName = v);
BindToggle("Toggle_xfersel", xfer.selectedOnly, v => xfer.selectedOnly = v);
BindToggle("Toggle_xfernorm", xfer.normalize, v => xfer.normalize = v);
BindButton("Button_Transfer", DoTransfer);
// ---- auto-fit colliders ----
colliderLabel = Find<Text>("Label_Colliders");
BindSliderFloat("Slider_colradius", "Value_colradius", genCfg.radiusScale, 0.3f, 2.5f, v => genCfg.radiusScale = v, "0.00");
BindSliderFloat("Slider_colminw", "Value_colminw", genCfg.minWeight, 0.1f, 0.95f, v => genCfg.minWeight = v, "0.00");
BindSliderInt("Slider_colminv", "Value_colminv", genCfg.minVerts, 4, 64, v => genCfg.minVerts = v);
BindToggle("Toggle_colcaps", genCfg.capsules, v => genCfg.capsules = v);
BindToggle("Toggle_colfingers", genCfg.skipFingers, v => genCfg.skipFingers = v);
BindToggle("Toggle_colhuman", genCfg.humanoidOnly, v => genCfg.humanoidOnly = v);
BindToggle("Toggle_colembed", includeColliders, v => includeColliders = v);
BindButton("Button_GenColliders", GenerateColliders);
BindButton("Button_ExportColliders", ExportColliderSidecars);
BindButton("Button_ExportUnityPkg", ExportUnityPackage);
// ---- tappable tooltips ----
BindAllHelp();
UpdateBrushLabel();
UpdateSourceLabel();
UpdateColliderLabel();
RebuildMeshList();
}
void SetMode(BrushMode m) { brush.mode = m; if (!paintMode) paintMode = true; UpdateBrushLabel(); }
void UpdateBrushLabel()
{
if (brushLabel == null) return;
string bone = (model != null && brush.activeBone >= 0 && brush.activeBone < model.boneNames.Length)
? model.boneNames[brush.activeBone] : "(no bone)";
brushLabel.text = (paintMode ? "PAINT " : "idle ") + brush.mode + " -> " + bone;
}
// ------------------------------------------------------------ heatmap overlay
void SetHeatmap(bool on)
{
heatmap.SetActiveBone(brush.activeBone);
heatmap.SetOpacity(heatOpacity);
heatmap.SetEnabled(on);
SetStatus(on
? "Heatmap ON — blue=0, green=0.5, red=1 for the active bone. Fade with opacity."
: "Heatmap off.");
}
// ------------------------------------------------------------ weight transfer
void SetTransferSource()
{
if (activeRenderer == null) { SetStatus("Select a mesh first, then Set Source."); return; }
transferSource = activeRenderer;
UpdateSourceLabel();
SetStatus("Transfer source = '" + transferSource.name + "'. Now select the TARGET mesh and click Transfer.");
}
void UpdateSourceLabel()
{
if (sourceLabel == null) return;
sourceLabel.text = "Source: " + (transferSource != null ? transferSource.name : "(none — pick a mesh, Set Source)");
}
void DoTransfer()
{
if (model == null) { SetStatus("No target mesh loaded."); return; }
if (transferSource == null) { SetStatus("No source set — select a mesh and click Set Source."); return; }
if (transferSource == activeRenderer) { SetStatus("Source and target are the same mesh — pick a different target."); return; }
model.PushUndo();
var r = WeightTransfer.Transfer(model, transferSource, xfer, selectionMask);
model.ApplyToMesh();
if (heatmap.Enabled) heatmap.Refresh();
RebuildBoneList();
SetStatus(r.message);
}
// ------------------------------------------------------------ auto-fit colliders
void UpdateColliderLabel()
{
if (colliderLabel == null) return;
int n = genColliders != null ? genColliders.Count : 0;
colliderLabel.text = n > 0
? "Colliders: " + n + " fitted" + (includeColliders ? " (embedded in exports)" : " (export disabled)")
: "Colliders: none — click Generate";
}
void GenerateColliders()
{
if (model != null && model.dirty) model.ApplyToMesh();
Transform root;
var renderers = GatherAvatarRenderers(out root);
if (renderers.Count == 0) { SetStatus("No avatar meshes — load a model and Rescan first."); return; }
Animator anim = root != null ? root.GetComponentInChildren<Animator>() : null;
var res = ColliderGen.Generate(renderers, anim, genCfg);
genColliders = res.colliders;
UpdateColliderLabel();
SetStatus(genColliders.Count > 0
? "Fitted " + res.message + ". Export VBX/VRM embeds them; or use Export Colliders / Unity Package."
: "No colliders fitted — lower 'Min verts' or 'Min weight', or check the mesh is readable.");
}
// base file name + ensured output dir, shared by every export
string ExportBaseName(out string dir)
{
dir = Path.Combine(Application.persistentDataPath, "WeightStudio");
try { Directory.CreateDirectory(dir); } catch { }
Transform root;
GatherAvatarRenderers(out root);
string stamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
return (root != null ? Sanitize(root.name) : "avatar") + "_" + stamp;
}
void ExportColliderSidecars()
{
if (genColliders == null || genColliders.Count == 0) { SetStatus("Generate colliders first."); return; }
try
{
string dir; string baseName = ExportBaseName(out dir);
var files = CollidersExport.WriteAll(Path.Combine(dir, baseName), genColliders);
Debug.Log("[WeightStudio] Collider sidecars: " + string.Join(", ", files.ToArray()));
SetStatus("Wrote " + files.Count + " collider configs (PhysBones/Jiggle/VRChat/DynamicBone) -> " + dir);
}
catch (Exception e)
{
Debug.LogError("[WeightStudio] Collider export failed: " + e);
SetStatus("Collider export failed (see log): " + e.Message);
}
}
void ExportUnityPackage()
{
if (model != null && model.dirty) model.ApplyToMesh();
// gather the CHOSEN avatar's meshes (same scoping as Export) so we embed
// exactly one copy of the model — not other avatars / a Pose Studio copy.
Transform root;
var renderers = GatherAvatarRenderers(out root);
string tempVbx = null;
try
{
string dir; string baseName = ExportBaseName(out dir);
string chosen = NativeFileDialog.SaveFile("Export Unity Package", dir, baseName + ".unitypackage",
"Unity package (*.unitypackage)", "*.unitypackage", "unitypackage");
if (string.IsNullOrEmpty(chosen)) { SetStatus("Export cancelled."); return; }
try { Directory.CreateDirectory(Path.GetDirectoryName(chosen)); } catch { }
string path = chosen;
// Build the model VBX into a temp file and embed it in the package, alongside
// a ScriptedImporter that re-imports it with UNLIMITED skin weights (>4/vertex).
string vbxAssetName = null;
if (renderers != null && renderers.Count > 0)
{
Animator anim = root != null ? root.GetComponentInChildren<Animator>() : null;
var cols = (includeColliders && genColliders != null && genColliders.Count > 0) ? genColliders : null;
vbxAssetName = (root != null ? Sanitize(root.name) : "avatar") + ".vbx";
tempVbx = Path.Combine(Path.GetTempPath(), "WeightStudio_" + Guid.NewGuid().ToString("N") + ".vbx");
VbxExporter.Export(tempVbx, root, renderers, anim, cols);
}
UnityPackageExport.Export(path, genColliders, tempVbx, vbxAssetName);
Debug.Log("[WeightStudio] Unity package: " + path);
SetStatus(vbxAssetName != null
? "Exported Unity package (model + .vbx importer, unlimited skin weights) -> " + path
: "Exported Unity addon package (scripts only — no avatar selected) -> " + path);
}
catch (Exception e)
{
Debug.LogError("[WeightStudio] Unity package export failed: " + e);
SetStatus("Unity package export failed (see log): " + e.Message);
}
finally
{
if (tempVbx != null) { try { File.Delete(tempVbx); } catch { } }
}
}
// ------------------------------------------------------------ tooltips
void BindAllHelp()
{
var tips = Tips();
foreach (var kv in tips)
{
string text = kv.Value;
Button b = Find<Button>("Help_" + kv.Key);
if (b != null) b.onClick.AddListener(() => SetTip(text));
}
SetTip("Tap any ? for a plain-language explanation of that option.");
}
void SetTip(string msg) { if (tipText != null) tipText.text = msg; }
static Dictionary<string, string> Tips()
{
return new Dictionary<string, string>
{
["radius"] = "Brush size in metres. Bigger = paints more of the model under one stroke.",
["strength"] = "How hard each stroke pushes the weight. Higher makes bigger changes per pass.",
["falloff"] = "Brush edge softness. 0 = hard edge (full strength to the rim); 1 = soft, fading out.",
["maxbones"] = "Most bones a single point may attach to. Lower = simpler/cheaper; used by Limit and Smooth.",
["smoothiter"] = "How many smoothing passes to run. More = smoother, but slower and can wash out detail.",
["smoothlambda"] = "How far each smoothing pass blends toward neighbours. 0 = none, 1 = fully averaged.",
["paint"] = "Turn on, then hold the left mouse button and drag on the model to paint. Hold Shift to mark a selection instead.",
["brushmodes"] = "Add raises the active bone's pull; Sub lowers it; Replace pushes toward a value; Smooth evens it with neighbours; Blur softens all bones nearby.",
["heatmap"] = "Colours the model by the active bone's weight (blue=0, green=0.5, red=1), like Blender's weight-paint view. Updates live as you paint.",
["heatopacity"] = "How solid the colour overlay is. Lower it to see the model's real textures underneath.",
["meshes"] = "Skinned meshes on your avatar. Click a name to edit it; click 'shown/hidden' on the right to hide a mesh that blocks your view while painting. Press Rescan after loading a new model.",
["undo"] = "Step back through your edits (brush strokes, Smooth, Limit, Transfer). Also Ctrl+Z. Keeps the last 24 steps per mesh.",
["redo"] = "Re-apply an edit you just undid. Also Ctrl+Shift+Z.",
["bones"] = "Bones of the selected mesh. Click one to make it the active bone your brush (and the heatmap) target.",
["smoothmesh"] = "Smooths choppy weight edges across the whole mesh so bends look natural.",
["smoothsel"] = "Same smoothing, but only on the area you Shift-painted as a selection.",
["smoothbone"] = "Smooths ONLY the active bone's weights across the whole mesh, leaving other bones' weights' relative balance intact.",
["smoothbonesel"] = "Smooths only the active bone's weights, and only inside the area you Shift-painted as a selection.",
["limitinf"] = "Caps every point to the 'Max bones / vert' count, keeping the strongest. Good before VRM export.",
["apply"] = "Writes your current edits onto the live mesh so you see them on the model.",
["revert"] = "Discards unsaved edits and returns to the last applied state.",
["clearsel"] = "Clears the Shift-painted selection.",
["export"] = "Save the avatar. VRM 0.x / 1.0 cap at 4 bones per point (standard); VBX keeps unlimited bones and reloads with the Reader plugin.",
["xfersource"] = "COPY-FROM mesh. Select it in the list, click Set Source. Then select the COPY-INTO mesh and click Transfer.",
["xfermap"] = "How weights are sampled: Nearest = closest point's weights; Smooth = average of several nearby points; Surface = blend from the closest triangle (most accurate).",
["xfersamples"] = "Smooth mapping only: how many nearby source points to average. More = smoother, less detail.",
["xfermaxdist"] = "Ignore source points farther than this (metres). 0 = no limit. Stops weights leaking from the wrong body part.",
["xferblend"] = "How much copied weight to apply. 1 = fully replace the target; 0.5 = mix half-and-half with what's there.",
["xfermatch"] = "Match bones by NAME between the two models (recommended). Off = match by bone order — only safe for identical skeletons.",
["xfersel"] = "Only change the Shift-painted selection instead of the whole mesh.",
["xfernorm"] = "Make each point's weights total 1 after transfer (recommended; prevents distortion).",
["xfergo"] = "Copy weights from the Source mesh onto the selected (target) mesh using the settings above.",
["colgen"] = "Auto-fit a collider to each bone, hugging the parts of the mesh that bone controls. Capsules for long bones, spheres for round ones.",
["colradius"] = "Scales every fitted collider's thickness. 1 = snug to the mesh; higher = puffier; lower = tighter inside.",
["colminw"] = "A vertex counts toward a bone only if that bone is at least this much of its weight. Higher = tighter, cleaner fits.",
["colminv"] = "Skip bones controlling fewer vertices than this. Raises to drop tiny noisy colliders; lower to cover small parts.",
["colcaps"] = "Allow capsule (pill) shapes for long bones like arms and legs. Off = spheres only.",
["colfingers"] = "Skip the small finger and toe bones — they rarely need colliders and just add clutter.",
["colhuman"] = "Only fit colliders on standard humanoid bones (needs a humanoid rig). Off = every skinning bone.",
["colembed"] = "Bake the fitted colliders into the VBX / VRM files you export, so spring-bones collide with the body.",
["colsidecar"] = "Save the colliders as JSON for PhysBones, Jiggle, VRChat PhysBones and DynamicBone — drop them into those systems.",
["colunitypkg"] = "Export a .unitypackage with Unity 2019.4 / 2022.3 scripts (collider + spring-bone + importer) plus a ready-made collider config.",
};
}
// ------------------------------------------------------------ paint loop
void Update()
{
if (window == null || !window.activeSelf) return;
if (!paintMode || model == null || !model.Loaded) return;
if (!Input.GetMouseButton(0)) return;
if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject()) return;
EnsureCamera();
if (cam == null) return;
// (re)bake the pick surface periodically
if (Time.unscaledTime - lastColliderBake > 0.2f) RebuildPickSurface();
Ray ray = cam.ScreenPointToRay(Input.mousePosition);
if (pickCollider == null) return;
RaycastHit hit;
if (!pickCollider.Raycast(ray, out hit, 1000f)) return;
model.RefreshWorldPositions();
if (buildingSelection)
PaintSelection(hit.point);
else
{
if (!stroking) { model.PushUndo(); stroking = true; } // one undo step per stroke
WeightTools.ApplyBrush(model, hit.point, brush);
if (Time.unscaledTime - lastApply > 0.05f)
{
model.ApplyToMesh();
if (heatmap.Enabled) heatmap.Refresh();
lastApply = Time.unscaledTime;
}
}
}
void EnsureCamera()
{
if (cam != null) return;
cam = Camera.main;
if (cam == null)
{
var all = Camera.allCameras;
if (all.Length > 0) cam = all[0];
}
}
void RebuildPickSurface()
{
if (activeRenderer == null) return;
if (pickGO == null)
{
pickGO = new GameObject("WeightStudio_PickSurface");
pickCollider = pickGO.AddComponent<MeshCollider>();
}
if (pickBake == null) pickBake = new Mesh();
activeRenderer.BakeMesh(pickBake, true);
pickGO.transform.SetParent(activeRenderer.transform, false);
pickGO.transform.localPosition = Vector3.zero;
pickGO.transform.localRotation = Quaternion.identity;
pickGO.transform.localScale = Vector3.one;
pickCollider.sharedMesh = null;
pickCollider.sharedMesh = pickBake;
lastColliderBake = Time.unscaledTime;
}
// ------------------------------------------------------------ selection painting
void PaintSelection(Vector3 worldHit)
{
if (model == null || model.weldWorldPos == null) return;
if (selectionMask == null || selectionMask.Length != model.weldCount)
selectionMask = new bool[model.weldCount];
float r2 = brush.radius * brush.radius;
int added = 0;
for (int w = 0; w < model.weldCount; w++)
{
if (selectionMask[w]) continue;
if ((model.weldWorldPos[w] - worldHit).sqrMagnitude <= r2) { selectionMask[w] = true; added++; }
}
if (added > 0) SetStatus("Selecting... " + CountSelection() + " verts.");
}
int CountSelection()
{
if (selectionMask == null) return 0;
int n = 0; for (int i = 0; i < selectionMask.Length; i++) if (selectionMask[i]) n++; return n;
}
// ------------------------------------------------------------ mesh / bone lists
void RebuildMeshList()
{
meshes.Clear();
var renderers = FindObjectsOfType<SkinnedMeshRenderer>();
Array.Sort(renderers, (a, b) => string.Compare(a ? a.name : "", b ? b.name : "", StringComparison.OrdinalIgnoreCase));
foreach (var smr in renderers)
{
if (smr == null || smr.sharedMesh == null) continue;
if (smr.bones == null || smr.bones.Length < 2) continue;
meshes.Add(smr);
}
if (meshContent == null) return;
for (int i = meshContent.childCount - 1; i >= 0; i--) Destroy(meshContent.GetChild(i).gameObject);
const float rowH = 22f;
float y = 0f;
foreach (var smr in meshes)
{
SkinnedMeshRenderer captured = smr;
GameObject row = MakeRow(meshContent, "Mesh_" + smr.name, smr.name, y, rowH,
() => SelectMesh(captured),
() => activeRenderer == captured);
AddVisibilityButton(row, captured);
y += rowH;
}
if (meshes.Count == 0) MakeInfo(meshContent, "(no skinned meshes — load an avatar, then Rescan)", y, rowH);
meshContent.sizeDelta = new Vector2(0f, Mathf.Max(y, 1f));
if (activeRenderer == null && meshes.Count > 0) SelectMesh(meshes[0]);
}
void SelectMesh(SkinnedMeshRenderer smr)
{
if (smr == null) return;
if (model != null && model.dirty) model.ApplyToMesh();
activeRenderer = smr;
model = WeightModel.Load(smr);
selectionMask = null;
if (model == null) { SetStatus("Could not load '" + smr.name + "' (mesh not readable?)."); return; }
brush.activeBone = Mathf.Clamp(brush.activeBone, 0, Mathf.Max(0, model.boneNames.Length - 1));
heatmap.Attach(activeRenderer, model); // re-bind overlay to the new working mesh
heatmap.SetActiveBone(brush.activeBone);
RebuildPickSurface();
RebuildBoneList();
RebuildMeshList();
UpdateBrushLabel();
SetStatus("Editing '" + smr.name + "': " + model.vertexCount + " verts, up to " + model.MaxBonesPerVertex() + " bones/vertex.");
}
void RebuildBoneList()
{
if (boneContent == null) return;
for (int i = boneContent.childCount - 1; i >= 0; i--) Destroy(boneContent.GetChild(i).gameObject);
if (model == null) { MakeInfo(boneContent, "(select a mesh first)", 0f, 22f); boneContent.sizeDelta = new Vector2(0, 22f); return; }
const float rowH = 20f;
float y = 0f;
for (int i = 0; i < model.boneNames.Length; i++)
{
int bi = i;
MakeRow(boneContent, "Bone_" + i, model.boneNames[i], y, rowH,
() => { brush.activeBone = bi; heatmap.SetActiveBone(bi); UpdateBrushLabel(); RebuildBoneList(); },
() => brush.activeBone == bi);
y += rowH;
}
boneContent.sizeDelta = new Vector2(0f, Mathf.Max(y, 1f));
}
// ------------------------------------------------------------ tools
void DoSmooth(bool selectionOnly)
{
if (model == null) { SetStatus("No mesh loaded."); return; }
bool[] mask = selectionOnly ? selectionMask : null;
if (selectionOnly && (mask == null || CountSelection() == 0)) { SetStatus("No selection — paint one with 'Select' first."); return; }
model.PushUndo();
var r = WeightTools.SmoothMesh(model, smooth, mask);
model.ApplyToMesh();
if (heatmap.Enabled) heatmap.Refresh();
SetStatus("Smoothed " + r.affectedWelds + " seed verts, up to " + r.maxBonesObserved + " bones/vertex.");
}
void DoSmoothBone(bool selectionOnly)
{
if (model == null) { SetStatus("No mesh loaded."); return; }
if (brush.activeBone < 0) { SetStatus("No active bone selected."); return; }
bool[] mask = selectionOnly ? selectionMask : null;
if (selectionOnly && (mask == null || CountSelection() == 0)) { SetStatus("No selection — paint one with 'Select' first."); return; }
model.PushUndo();
var r = WeightTools.SmoothBone(model, brush.activeBone, smooth, mask);
model.ApplyToMesh();
if (heatmap.Enabled) heatmap.Refresh();
string bn = (model.boneNames != null && brush.activeBone < model.boneNames.Length)
? model.boneNames[brush.activeBone] : ("bone " + brush.activeBone);
SetStatus("Smoothed '" + bn + "' over " + r.affectedWelds + " verts.");
}
void DoLimit()
{
if (model == null) { SetStatus("No mesh loaded."); return; }
model.PushUndo();
WeightTools.LimitInfluences(model, smooth.maxBonesPerVertex);
model.ApplyToMesh();
if (heatmap.Enabled) heatmap.Refresh();
SetStatus("Limited to " + smooth.maxBonesPerVertex + " influences/vertex.");
}
void DoRevert()
{
if (activeRenderer == null) return;
model = WeightModel.Load(activeRenderer);
selectionMask = null;
heatmap.Attach(activeRenderer, model);
heatmap.SetActiveBone(brush.activeBone);
RebuildBoneList();
SetStatus("Reverted to last applied mesh state.");
}
void DoUndo()
{
if (model == null) { SetStatus("No mesh loaded."); return; }
if (!model.Undo()) { SetStatus("Nothing to undo."); return; }
model.ApplyToMesh();
if (heatmap.Enabled) heatmap.Refresh();
RebuildBoneList();
SetStatus("Undo (" + model.UndoDepth + " more available, " + model.RedoDepth + " to redo).");
}
void DoRedo()
{
if (model == null) { SetStatus("No mesh loaded."); return; }
if (!model.Redo()) { SetStatus("Nothing to redo."); return; }
model.ApplyToMesh();
if (heatmap.Enabled) heatmap.Refresh();
RebuildBoneList();
SetStatus("Redo (" + model.RedoDepth + " more available).");
}
// ------------------------------------------------------------ export
enum ExportKind { Vrm0, Vrm1, Vbx }
void Export(ExportKind kind)
{
if (model != null && model.dirty) model.ApplyToMesh();
// gather all meshes of the CHOSEN avatar (not every SMR in the scene — other
// avatars / a Pose Studio copy / our heatmap overlay would otherwise duplicate).
Transform root;
var renderers = GatherAvatarRenderers(out root);
if (renderers.Count == 0) { SetStatus("No avatar meshes to export — select a mesh first."); return; }
Animator anim = root != null ? root.GetComponentInChildren<Animator>() : null;
string dir = Path.Combine(Application.persistentDataPath, "WeightStudio");
try { Directory.CreateDirectory(dir); } catch { }
string stamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
string baseName = (root != null ? Sanitize(root.name) : "avatar") + "_" + stamp;
var cols = (includeColliders && genColliders != null && genColliders.Count > 0) ? genColliders : null;
// let the user pick the file name + location (defaults to the WeightStudio folder)
string defName, defExt, filterLabel, filterPattern, dlgTitle;
switch (kind)
{
case ExportKind.Vbx:
defName = baseName + ".vbx"; defExt = "vbx";
filterLabel = "Weight Studio VBX (*.vbx)"; filterPattern = "*.vbx"; dlgTitle = "Export VBX"; break;
case ExportKind.Vrm0:
defName = baseName + ".vrm"; defExt = "vrm";
filterLabel = "VRM 0.x (*.vrm)"; filterPattern = "*.vrm"; dlgTitle = "Export VRM 0.x"; break;
default:
defName = baseName + "_vrm1.vrm"; defExt = "vrm";
filterLabel = "VRM 1.0 (*.vrm)"; filterPattern = "*.vrm"; dlgTitle = "Export VRM 1.0"; break;
}
string chosen = NativeFileDialog.SaveFile(dlgTitle, dir, defName, filterLabel, filterPattern, defExt);
if (string.IsNullOrEmpty(chosen)) { SetStatus("Export cancelled."); return; }
try { Directory.CreateDirectory(Path.GetDirectoryName(chosen)); } catch { }
try
{
string path = chosen;
switch (kind)
{
case ExportKind.Vbx:
VbxExporter.Export(path, root, renderers, anim, cols);
break;
case ExportKind.Vrm0:
GltfVrmExporter.Export(path, GltfVrmExporter.VrmVersion.Vrm0, root, renderers, anim, MetaFor(root), cols);
break;
default:
GltfVrmExporter.Export(path, GltfVrmExporter.VrmVersion.Vrm1, root, renderers, anim, MetaFor(root), cols);
break;
}
string colNote = cols != null ? " (+" + cols.Count + " colliders)" : "";
Debug.Log("[WeightStudio] Exported: " + path + colNote);
if (kind == ExportKind.Vbx)
SetStatus("Exported -> " + path + colNote +
" | This .vbx is an OVERLAY: load it in the Reader WITH this avatar open so it rides the tracked rig and borrows the real shaders. " +
"For a portable standalone with baked-in shaders, use \"Export VBX from .vsfavatar file\".");
else
SetStatus("Exported -> " + path + colNote);
}
catch (Exception e)
{
Debug.LogError("[WeightStudio] Export failed: " + e);
SetStatus("Export failed (see log): " + e.Message);
}
}
// ------------------------------------------------ standalone export from a .vsfavatar
// Reads an avatar straight out of a .vsfavatar file (a Unity AssetBundle) and
// writes a fully self-contained .vbx — no need for the avatar to be loaded in
// VNyan, and nothing is left in the live scene afterwards. Triggered by the
// "Button_ExportVbxFile" UI button if present, or Ctrl+Shift+E while the window
// is open.
void ExportVbxFromFile()
{
string startDir = Path.Combine(Application.persistentDataPath, "WeightStudio");
try { Directory.CreateDirectory(startDir); } catch { }
string vsf = NativeFileDialog.OpenFile(
"Select a .vsfavatar to convert to a standalone VBX",
startDir, "VSeeFace / VNyan Avatar (*.vsfavatar)", "*.vsfavatar");
if (string.IsNullOrEmpty(vsf)) { SetStatus("Standalone export cancelled."); return; }
SetStatus("Reading .vsfavatar (this can take a moment for large avatars)...");
VsfAvatarReader.Loaded loaded = null;
try
{
loaded = VsfAvatarReader.Load(vsf);
if (loaded == null || loaded.instance == null)
{
SetStatus("Couldn't find an avatar inside that .vsfavatar (see log).");
return;
}
Transform root = loaded.instance.transform;
var renderers = new List<SkinnedMeshRenderer>();
foreach (var smr in loaded.instance.GetComponentsInChildren<SkinnedMeshRenderer>(true))
if (smr != null && smr.sharedMesh != null) renderers.Add(smr);
if (renderers.Count == 0)
{
SetStatus("That avatar has no skinned meshes to export.");
return;
}
Animator anim = loaded.instance.GetComponentInChildren<Animator>(true);
string baseName = Sanitize(string.IsNullOrEmpty(loaded.sourceName)
? Path.GetFileNameWithoutExtension(vsf) : loaded.sourceName);
string outDir = Path.GetDirectoryName(vsf);
string chosen = NativeFileDialog.SaveFile(
"Save standalone VBX", outDir, baseName + ".vbx",
"Weight Studio VBX (*.vbx)", "*.vbx", "vbx");
if (string.IsNullOrEmpty(chosen)) { SetStatus("Standalone export cancelled."); return; }
try { Directory.CreateDirectory(Path.GetDirectoryName(chosen)); } catch { }
// Build reads every vertex into managed memory and bakes textures to PNG,
// so all data is captured before Cleanup unloads the bundle. Colliders are
// not passed here (they were fitted for the live avatar, not this file).
// Embed the source .vsfavatar bytes so the Reader can recover the REAL
// compiled materials (locked Poiyomi, etc.) on a standalone load.
VbxExporter.Export(chosen, root, renderers, anim, null, vsf);
int unreadable = 0;
foreach (var smr in renderers)
if (smr.sharedMesh != null && !smr.sharedMesh.isReadable) unreadable++;
string note = unreadable > 0
? " (WARNING: " + unreadable + " mesh(es) had Read/Write disabled and were skipped)"
: "";
Debug.Log("[WeightStudio] Standalone VBX exported from " + vsf + " -> " + chosen + note);
SetStatus("Standalone VBX -> " + chosen + note);
}
catch (Exception e)
{
Debug.LogError("[WeightStudio] Standalone export failed: " + e);
SetStatus("Standalone export failed (see log): " + e.Message);
}
finally
{
VsfAvatarReader.Cleanup(loaded);
}
}
// ---------------------------------------------------------- TEMPORARY recovery tool
// Reads a (possibly bug-duplicated) .vbx and re-applies its weights onto the
// CURRENTLY-LOADED original avatar, matching meshes by name and bones by name.
// Duplicate copies of a mesh are de-duplicated: for each renderer we pick the VBX
// copy whose influences best resolve onto the loaded skeleton (the stray second
// avatar's copy has an all -1 boneMap, so it scores ~0 and is ignored).
// Triggered by Ctrl+Shift+R while the window is open. Remove after recovery.
void RecoverWeightsFromVbx()
{
string dir = Path.Combine(Application.persistentDataPath, "WeightStudio");
try { Directory.CreateDirectory(dir); } catch { }
string path = NativeFileDialog.OpenFile(
"Recover weights from VBX (ignores duplicate meshes)",
dir, "Weight Studio VBX (*.vbx)", "*.vbx");
if (string.IsNullOrEmpty(path)) { SetStatus("Weight recovery cancelled."); return; }
VbxDoc doc;
try { using (var fs = File.OpenRead(path)) doc = VbxIO.Read(fs); }
catch (Exception e)
{
Debug.LogError("[WeightStudio] VBX read failed: " + e);
SetStatus("Couldn't read VBX (see log): " + e.Message);
return;
}
Transform root;
var targets = GatherAvatarRenderers(out root);
if (targets.Count == 0) { SetStatus("Load the original avatar first, then press Ctrl+Shift+R."); return; }
int done = 0, skipped = 0, dupIgnored = 0;
var sb = new StringBuilder();
foreach (var smr in targets)
{
Mesh mesh = smr.sharedMesh;
if (mesh == null) { skipped++; continue; }
int vc = mesh.vertexCount;
// candidate VBX meshes: same renderer name AND same vertex count (same topology)
var candidates = new List<VbxMesh>();
foreach (var vm in doc.meshes)
if (vm.name == smr.name && vm.positions != null && vm.positions.Length == vc)
candidates.Add(vm);
if (candidates.Count == 0)
{
skipped++;
sb.Append(" - ").Append(smr.name).Append(": no matching mesh in VBX\n");
continue;
}
if (candidates.Count > 1) dupIgnored += candidates.Count - 1;
// name -> bone slot on the LOADED renderer
Transform[] bones = smr.bones;
var nameToSlot = new Dictionary<string, int>(bones != null ? bones.Length : 0);
if (bones != null)
for (int i = 0; i < bones.Length; i++)
if (bones[i] != null && !nameToSlot.ContainsKey(bones[i].name))
nameToSlot[bones[i].name] = i;
// pick the copy whose influences resolve best onto the loaded skeleton
VbxMesh chosen = null;
int[] chosenMap = null;
double bestScore = -1.0;
float chosenAvg = 0f;
foreach (var cand in candidates)
{
int[] l2t = BuildLocalToTarget(cand, doc, nameToSlot);
long mapped = 0, total = 0; int off = 0;
int n2 = cand.bonesPerVertex != null ? cand.bonesPerVertex.Length : 0;
for (int v = 0; v < n2; v++)
{
int n = cand.bonesPerVertex[v];
for (int k = 0; k < n; k++)
{
int idx = off + k;
if (idx >= cand.influenceWeight.Length) break;
if (cand.influenceWeight[idx] <= 0f) continue;
total++;
int ls = cand.influenceBone[idx];
if (ls >= 0 && ls < l2t.Length && l2t[ls] >= 0) mapped++;
}
off += n;
}
double score = total > 0 ? (double)mapped / total : 0.0;
if (score > bestScore)
{
bestScore = score; chosen = cand; chosenMap = l2t; chosenAvg = AvgInfluences(cand);
}
}
if (chosen == null || bestScore <= 0.0)
{
skipped++;
sb.Append(" - ").Append(smr.name).Append(": bones don't match this skeleton (skipped)\n");
continue;
}
// decode + write the chosen copy's weights, remapped to this renderer's bone slots
var outBpv = new NativeArray<byte>(vc, Allocator.Temp);
var flatOut = new List<BoneWeight1>(vc * 4);
var tmp = new List<KeyValuePair<int, float>>();
int offset = 0;
long droppedInf = 0;
int bpvLen = chosen.bonesPerVertex != null ? chosen.bonesPerVertex.Length : 0;
for (int v = 0; v < vc; v++)
{
int n = v < bpvLen ? chosen.bonesPerVertex[v] : 0;
var acc = new Dictionary<int, float>();
for (int k = 0; k < n; k++)
{
int idx = offset + k;
if (idx >= chosen.influenceWeight.Length) break;
float wgt = chosen.influenceWeight[idx];
int ls = chosen.influenceBone[idx];
int ts = (ls >= 0 && ls < chosenMap.Length) ? chosenMap[ls] : -1;
if (ts < 0 || wgt <= 0f) { if (wgt > 0f) droppedInf++; continue; }
acc[ts] = acc.TryGetValue(ts, out float ex) ? ex + wgt : wgt;
}
offset += n;
tmp.Clear();
foreach (var kv in acc) tmp.Add(kv);
tmp.Sort((x, y) => y.Value.CompareTo(x.Value)); // descending, required by SetBoneWeights
if (tmp.Count == 0)
{
outBpv[v] = 1;
flatOut.Add(new BoneWeight1 { boneIndex = 0, weight = 1f });
continue;
}
float sum = 0f; for (int k = 0; k < tmp.Count; k++) sum += tmp[k].Value;
float inv = sum > 0f ? 1f / sum : 0f;
outBpv[v] = (byte)Mathf.Min(tmp.Count, 255);
for (int k = 0; k < tmp.Count; k++)
flatOut.Add(new BoneWeight1 { boneIndex = tmp[k].Key, weight = tmp[k].Value * inv });
}
var outW = new NativeArray<BoneWeight1>(flatOut.Count, Allocator.Temp);
for (int i = 0; i < flatOut.Count; i++) outW[i] = flatOut[i];
mesh.SetBoneWeights(outBpv, outW);
outBpv.Dispose(); outW.Dispose();
if (QualitySettings.skinWeights != SkinWeights.Unlimited)
QualitySettings.skinWeights = SkinWeights.Unlimited;
smr.quality = SkinQuality.Auto;
done++;
sb.Append(" - ").Append(smr.name).Append(": recovered (")
.Append(candidates.Count).Append(" copy(ies), match ")
.Append((bestScore * 100.0).ToString("0")).Append("%, avg ")
.Append(chosenAvg.ToString("0.0")).Append(" inf/vert");
if (droppedInf > 0) sb.Append(", ").Append(droppedInf).Append(" unmapped influences dropped");
sb.Append(")\n");
}
// reload the active renderer's editable model so painting/undo pick up the new weights
if (activeRenderer != null)
{
model = WeightModel.Load(activeRenderer);
heatmap.Attach(activeRenderer, model);
if (heatmap.Enabled) heatmap.Refresh();
RebuildBoneList();
}
Debug.Log("[WeightStudio] Weight recovery from " + path + ":\n" + sb +
"Recovered: " + done + ", skipped: " + skipped + ", duplicate copies ignored: " + dupIgnored);
SetStatus("Recovered weights onto " + done + " mesh(es)"
+ (skipped > 0 ? ", " + skipped + " had no match" : "")
+ (dupIgnored > 0 ? " (ignored " + dupIgnored + " duplicate copies)" : "")
+ ". See log; now re-export for a clean file.");
}
// renderer-local VBX slot -> loaded renderer bone slot, matched by bone NAME
static int[] BuildLocalToTarget(VbxMesh vm, VbxDoc doc, Dictionary<string, int> nameToSlot)
{
int len = vm.boneMap != null ? vm.boneMap.Length : 0;
var map = new int[len];
for (int s = 0; s < len; s++)
{
map[s] = -1;
int gi = vm.boneMap[s];
if (gi >= 0 && gi < doc.bones.Count)
{
string bn = doc.bones[gi].name;
if (bn != null && nameToSlot.TryGetValue(bn, out int ts)) map[s] = ts;
}
}
return map;
}
static float AvgInfluences(VbxMesh m)
{
if (m.bonesPerVertex == null || m.bonesPerVertex.Length == 0) return 0f;
long sum = 0;
for (int i = 0; i < m.bonesPerVertex.Length; i++) sum += m.bonesPerVertex[i];
return (float)sum / m.bonesPerVertex.Length;
}
static GltfVrmExporter.VrmMeta MetaFor(Transform root)
{
var m = GltfVrmExporter.VrmMeta.Default();
if (root != null) m.title = root.name;
return m;
}