metaverse-avatar 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/ASSET_LICENSES.md +122 -0
  2. package/Avatar.js +860 -0
  3. package/LICENSE.md +21 -0
  4. package/README.md +407 -0
  5. package/anims/UAL1_Standard.glb +0 -0
  6. package/anims/UAL2_Standard.glb +0 -0
  7. package/anims/pirouette.bvh +867 -0
  8. package/attachments.js +388 -0
  9. package/avatarManager.js +110 -0
  10. package/blink.js +58 -0
  11. package/bvh.js +110 -0
  12. package/gltfAnim.js +271 -0
  13. package/index.js +61 -0
  14. package/licenses/AGPL-3.0.txt +661 -0
  15. package/models/body.glb +0 -0
  16. package/models/eyes.glb +0 -0
  17. package/models/feet.glb +0 -0
  18. package/models/hands.glb +0 -0
  19. package/models/head.glb +0 -0
  20. package/models/textures/android_face.png +0 -0
  21. package/models/textures/android_face_ao.jpg +0 -0
  22. package/models/textures/android_face_metallic.jpg +0 -0
  23. package/models/textures/android_face_normal.jpg +0 -0
  24. package/models/textures/android_face_roughness.jpg +0 -0
  25. package/models/textures/android_lower.png +0 -0
  26. package/models/textures/android_lower_ao.jpg +0 -0
  27. package/models/textures/android_lower_metallic.jpg +0 -0
  28. package/models/textures/android_lower_normal.jpg +0 -0
  29. package/models/textures/android_lower_roughness.jpg +0 -0
  30. package/models/textures/android_upper.png +0 -0
  31. package/models/textures/android_upper_ao.jpg +0 -0
  32. package/models/textures/android_upper_metallic.jpg +0 -0
  33. package/models/textures/android_upper_normal.jpg +0 -0
  34. package/models/textures/android_upper_roughness.jpg +0 -0
  35. package/models/textures/blue_eyes.png +0 -0
  36. package/models/textures/layers/cute_pants.png +0 -0
  37. package/models/textures/layers/cute_shirt.png +0 -0
  38. package/nipple.js +218 -0
  39. package/package.json +48 -0
  40. package/pbr.js +225 -0
  41. package/physics.js +313 -0
  42. package/skeleton.js +70 -0
  43. package/sliders.js +590 -0
  44. package/speech.js +130 -0
  45. package/visemes.js +66 -0
  46. package/voice.js +75 -0
Binary file
package/nipple.js ADDED
@@ -0,0 +1,218 @@
1
+ import * as THREE from 'three';
2
+
3
+ // Nipple protrusion morph — a localized vertex displacement on the body mesh.
4
+ //
5
+ // There is no nipple bone in the Ruth2 rig (the breast is skinned to the
6
+ // LEFT_PEC/RIGHT_PEC collision volumes), so the bone-driven shape sliders can't
7
+ // isolate the nipple — scaling a pec moves the whole breast. This drives the
8
+ // nipple directly in the rest geometry instead: it finds each nipple apex, then
9
+ // pushes a soft falloff region around it along the surface normal. The
10
+ // displacement lives in the mesh's REST positions, so skinning — body
11
+ // animation, breast_size, pec jiggle — all ride on top of it unchanged.
12
+ //
13
+ // Apex finding is self-calibrating (no baked coordinates): among the vertices
14
+ // skinned to each pec, the nipple tip is the most-forward one (the body faces
15
+ // -Y in mesh-local space), and the push direction is that tip's surface normal.
16
+
17
+ const PEC_BONES = { LEFT_PEC: 'L', RIGHT_PEC: 'R' };
18
+ const WEIGHT_MIN = 0.3; // min pec skin-weight for a vertex to count as breast
19
+ const NEIGHBORHOOD = 0.025; // m — radius the local-bump test smooths over
20
+ const CAP_RADIUS = 0.018; // m — extent of the nipple "cap" used to centre the morph
21
+ const TIP_RADIUS = 0.004; // m — TIGHT radius the push direction is averaged over
22
+ const FALLOFF_RADIUS = 0.024; // m — region displaced around the nipple centre
23
+ const MAX_DISPLACE = 0.012; // m — tip displacement at |amount| = 1
24
+
25
+ const smoothstep = (t) => (t <= 0 ? 0 : t >= 1 ? 1 : t * t * (3 - 2 * t));
26
+
27
+ // Precompute the morph for one body SkinnedMesh. Returns null if the mesh has no
28
+ // pec-weighted geometry (e.g. a different rig). Snapshots the rest positions and
29
+ // normals of the affected vertices so the morph is idempotent and amount 0
30
+ // restores the mesh exactly.
31
+ export function buildNippleMorph(skinnedMesh) {
32
+ const geo = skinnedMesh.geometry;
33
+ const pos = geo.getAttribute('position');
34
+ const si = geo.getAttribute('skinIndex');
35
+ const sw = geo.getAttribute('skinWeight');
36
+ const nrm = geo.getAttribute('normal');
37
+ if (!pos || !si || !sw || !nrm) return null;
38
+
39
+ const bones = skinnedMesh.skeleton.bones;
40
+ const pecIndex = {}; // bone array index -> 'L' | 'R'
41
+ bones.forEach((b, i) => { if (PEC_BONES[b.name]) pecIndex[i] = PEC_BONES[b.name]; });
42
+ if (!Object.values(pecIndex).includes('L') || !Object.values(pecIndex).includes('R')) return null;
43
+
44
+ // Combined pec weight per vertex, split by dominant side.
45
+ const sideVerts = { L: [], R: [] };
46
+ for (let v = 0; v < pos.count; v++) {
47
+ let wL = 0, wR = 0;
48
+ for (let k = 0; k < 4; k++) {
49
+ const side = pecIndex[si.getComponent(v, k)];
50
+ if (side === 'L') wL += sw.getComponent(v, k);
51
+ else if (side === 'R') wR += sw.getComponent(v, k);
52
+ }
53
+ if (wL > WEIGHT_MIN && wL >= wR) sideVerts.L.push(v);
54
+ else if (wR > WEIGHT_MIN) sideVerts.R.push(v);
55
+ }
56
+
57
+ const affected = []; // { i, ox,oy,oz, ux,uy,uz } unit displacement at amount=1
58
+ const tmp = new THREE.Vector3();
59
+ for (const side of ['L', 'R']) {
60
+ const list = sideVerts[side];
61
+ if (!list.length) continue;
62
+
63
+ // Local protrusion per vertex: how far it sticks out past its own
64
+ // neighborhood along its normal. The nipple is the local bump (not the
65
+ // breast's overall front-most point); `seed` is its single strongest point.
66
+ const prot = new Map();
67
+ let seed = -1, maxProt = -Infinity;
68
+ for (const v of list) {
69
+ const x = pos.getX(v), y = pos.getY(v), z = pos.getZ(v);
70
+ let cx = 0, cy = 0, cz = 0, c = 0;
71
+ for (const u of list) {
72
+ const dx = pos.getX(u) - x, dy = pos.getY(u) - y, dz = pos.getZ(u) - z;
73
+ if (dx * dx + dy * dy + dz * dz < NEIGHBORHOOD * NEIGHBORHOOD) {
74
+ cx += pos.getX(u); cy += pos.getY(u); cz += pos.getZ(u); c++;
75
+ }
76
+ }
77
+ if (c < 6) continue;
78
+ const p = (x - cx / c) * nrm.getX(v) + (y - cy / c) * nrm.getY(v) + (z - cz / c) * nrm.getZ(v);
79
+ prot.set(v, p);
80
+ if (p > maxProt) { maxProt = p; seed = v; }
81
+ }
82
+ if (seed < 0) continue;
83
+ const sx = pos.getX(seed), sy = pos.getY(seed), sz = pos.getZ(seed);
84
+
85
+ // Centre the morph on the protrusion-weighted centroid of the bump cap, not
86
+ // on `seed`. The single steepest vertex sits off-centre (low on the nipple),
87
+ // which made the morph push from the lower edge.
88
+ let cx = 0, cy = 0, cz = 0, cw = 0;
89
+ for (const [v, p] of prot) {
90
+ if (p < 0.5 * maxProt) continue;
91
+ const dx = pos.getX(v) - sx, dy = pos.getY(v) - sy, dz = pos.getZ(v) - sz;
92
+ if (dx * dx + dy * dy + dz * dz > CAP_RADIUS * CAP_RADIUS) continue;
93
+ cx += pos.getX(v) * p; cy += pos.getY(v) * p; cz += pos.getZ(v) * p; cw += p;
94
+ }
95
+ const ax = cw ? cx / cw : sx, ay = cw ? cy / cw : sy, az = cw ? cz / cw : sz;
96
+
97
+ // Push direction = surface normal at the bump's most-forward point (`seed`),
98
+ // averaged over a TIGHT radius. A wide average picks up the surrounding
99
+ // breast curvature and tilts the push up/sideways instead of straight out.
100
+ const dir = new THREE.Vector3();
101
+ for (const v of list) {
102
+ const dx = pos.getX(v) - sx, dy = pos.getY(v) - sy, dz = pos.getZ(v) - sz;
103
+ if (dx * dx + dy * dy + dz * dz < TIP_RADIUS * TIP_RADIUS) {
104
+ dir.add(tmp.set(nrm.getX(v), nrm.getY(v), nrm.getZ(v)));
105
+ }
106
+ }
107
+ if (dir.lengthSq() < 1e-6) dir.set(nrm.getX(seed), nrm.getY(seed), nrm.getZ(seed));
108
+ dir.normalize();
109
+
110
+ // Soft falloff region around the nipple centre.
111
+ for (const v of list) {
112
+ const dx = pos.getX(v) - ax, dy = pos.getY(v) - ay, dz = pos.getZ(v) - az;
113
+ const d = Math.sqrt(dx * dx + dy * dy + dz * dz);
114
+ if (d >= FALLOFF_RADIUS) continue;
115
+ const w = smoothstep(1 - d / FALLOFF_RADIUS) * MAX_DISPLACE;
116
+ affected.push({
117
+ i: v,
118
+ ox: pos.getX(v), oy: pos.getY(v), oz: pos.getZ(v),
119
+ ux: dir.x * w, uy: dir.y * w, uz: dir.z * w,
120
+ });
121
+ }
122
+ }
123
+ if (!affected.length || !nrm) return null;
124
+
125
+ // Localized smooth-normal recompute support. A global computeVertexNormals()
126
+ // would flatten the whole surface (per-face normals → faceting on a non-indexed
127
+ // mesh). Instead we re-light ONLY the displaced region: weld vertices by
128
+ // position so duplicate verts at a UV/normal seam still average to a smooth
129
+ // normal, and gather just the triangles touching the region so the rest of the
130
+ // body keeps its imported normals untouched. Triangles come from the index
131
+ // buffer when the geometry is indexed (the .glb parts), else consecutive triples.
132
+ const weldId = buildWeldMap(pos); // vertex -> canonical (first) vertex at its position
133
+ const affectedKeys = new Set(affected.map((a) => weldId[a.i]));
134
+ const index = geo.getIndex();
135
+ const triCount = (index ? index.count : pos.count) / 3;
136
+ const corner = index ? (k) => index.getX(k) : (k) => k;
137
+ const localTris = []; // flat [a,b,c, a,b,c, ...] vertex indices
138
+ for (let t = 0; t < triCount; t++) {
139
+ const a = corner(3 * t), b = corner(3 * t + 1), c = corner(3 * t + 2);
140
+ if (affectedKeys.has(weldId[a]) || affectedKeys.has(weldId[b]) || affectedKeys.has(weldId[c])) {
141
+ localTris.push(a, b, c);
142
+ }
143
+ }
144
+
145
+ return {
146
+ geometry: geo,
147
+ affected,
148
+ baseNormals: nrm.array.slice(), // restore on amount 0
149
+ weldId,
150
+ localTris: Int32Array.from(localTris),
151
+ _amount: 0,
152
+ };
153
+ }
154
+
155
+ // Canonical-vertex map for welding: weldId[v] is the index of the first vertex
156
+ // sharing v's position, so verts split at a UV/normal seam still share a normal.
157
+ function buildWeldMap(pos) {
158
+ const weldId = new Int32Array(pos.count);
159
+ const seen = new Map();
160
+ const Q = 1e5; // quantize to 1e-5 m
161
+ for (let v = 0; v < pos.count; v++) {
162
+ const key = `${Math.round(pos.getX(v) * Q)},${Math.round(pos.getY(v) * Q)},${Math.round(pos.getZ(v) * Q)}`;
163
+ let id = seen.get(key);
164
+ if (id === undefined) { id = v; seen.set(key, v); }
165
+ weldId[v] = id;
166
+ }
167
+ return weldId;
168
+ }
169
+
170
+ // Displace the nipple region by `amount` (the slider value, typically [-1.5, 1.5];
171
+ // + extends the nipple forward, - pulls it in/flattens). Idempotent.
172
+ export function applyNippleMorph(morph, amount) {
173
+ if (!morph) return;
174
+ amount = amount || 0;
175
+ if (amount === morph._amount) return;
176
+ morph._amount = amount;
177
+
178
+ const pos = morph.geometry.getAttribute('position');
179
+ const nrm = morph.geometry.getAttribute('normal');
180
+ for (const a of morph.affected) {
181
+ pos.setXYZ(a.i, a.ox + a.ux * amount, a.oy + a.uy * amount, a.oz + a.uz * amount);
182
+ }
183
+ pos.needsUpdate = true;
184
+
185
+ if (amount === 0) {
186
+ nrm.array.set(morph.baseNormals); // exact restore at rest
187
+ } else {
188
+ recomputeRegionNormals(morph, pos, nrm);
189
+ }
190
+ nrm.needsUpdate = true;
191
+ morph.geometry.computeBoundingSphere();
192
+ }
193
+
194
+ // Recompute area-weighted smooth normals for the morph region only, welding by
195
+ // position, then write them back to the affected verts (and their duplicates).
196
+ function recomputeRegionNormals(morph, pos, nrm) {
197
+ const { weldId, localTris } = morph;
198
+ const acc = new Map(); // weldId -> [nx, ny, nz]
199
+ const add = (id, x, y, z) => {
200
+ const s = acc.get(id);
201
+ if (s) { s[0] += x; s[1] += y; s[2] += z; } else { acc.set(id, [x, y, z]); }
202
+ };
203
+ for (let i = 0; i < localTris.length; i += 3) {
204
+ const a = localTris[i], b = localTris[i + 1], c = localTris[i + 2];
205
+ const ax = pos.getX(a), ay = pos.getY(a), az = pos.getZ(a);
206
+ const e1x = pos.getX(b) - ax, e1y = pos.getY(b) - ay, e1z = pos.getZ(b) - az;
207
+ const e2x = pos.getX(c) - ax, e2y = pos.getY(c) - ay, e2z = pos.getZ(c) - az;
208
+ // cross(e1, e2) — area-weighted face normal (not normalized).
209
+ const fx = e1y * e2z - e1z * e2y, fy = e1z * e2x - e1x * e2z, fz = e1x * e2y - e1y * e2x;
210
+ add(weldId[a], fx, fy, fz); add(weldId[b], fx, fy, fz); add(weldId[c], fx, fy, fz);
211
+ }
212
+ for (const a of morph.affected) {
213
+ const s = acc.get(weldId[a.i]);
214
+ if (!s) continue;
215
+ const len = Math.hypot(s[0], s[1], s[2]) || 1;
216
+ nrm.setXYZ(a.i, s[0] / len, s[1] / len, s[2] / len);
217
+ }
218
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "metaverse-avatar",
3
+ "version": "0.1.0",
4
+ "description": "A self-contained, independently-controllable three.js avatar (RuthAndRoth Ruth2) with shape sliders, BVH/glTF animation, PBR materials, physics, lip-sync, blinking, and attachments — one class, new Avatar().",
5
+ "type": "module",
6
+ "author": "Richard Anaya <richard.anaya@gmail.com>",
7
+ "homepage": "https://github.com/richardanaya/metaverse-avatar#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/richardanaya/metaverse-avatar.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/richardanaya/metaverse-avatar/issues"
14
+ },
15
+ "main": "index.js",
16
+ "module": "index.js",
17
+ "exports": {
18
+ ".": "./index.js",
19
+ "./Avatar": "./Avatar.js"
20
+ },
21
+ "files": [
22
+ "*.js",
23
+ "anims",
24
+ "models",
25
+ "licenses",
26
+ "README.md",
27
+ "LICENSE.md",
28
+ "ASSET_LICENSES.md"
29
+ ],
30
+ "sideEffects": false,
31
+ "scripts": {
32
+ "dev": "python3 -m http.server 8413"
33
+ },
34
+ "keywords": [
35
+ "three",
36
+ "threejs",
37
+ "avatar",
38
+ "3d",
39
+ "animation",
40
+ "bvh",
41
+ "lip-sync",
42
+ "ruth"
43
+ ],
44
+ "license": "MIT",
45
+ "peerDependencies": {
46
+ "three": ">=0.184.0"
47
+ }
48
+ }
package/pbr.js ADDED
@@ -0,0 +1,225 @@
1
+ import * as THREE from 'three';
2
+
3
+ // PBR material stack for one avatar texture region.
4
+ //
5
+ // A region's final material is composited from a stack of "map sets":
6
+ //
7
+ // [ skin , clothingLayer0 , clothingLayer1 , ... ]
8
+ //
9
+ // where each map set carries up to five channel images:
10
+ //
11
+ // albedo (base color) · normal · roughness · metallic · ambient occlusion
12
+ //
13
+ // Each channel is composited onto its own 1024² canvas backing a
14
+ // CanvasTexture, and those textures are wired into a single
15
+ // MeshStandardMaterial (map / normalMap / roughnessMap / metalnessMap /
16
+ // aoMap). The skin set covers the whole region; every clothing layer is
17
+ // masked by its own albedo alpha so a garment only paints where it exists.
18
+ //
19
+ // Roughness and metalness also have a global scalar (the material's
20
+ // `roughness` / `metalness`): when a region has no map for that channel the
21
+ // scalar is the constant value, and when a map is present the map drives it.
22
+ // That is the "set a value instead of a texture" path.
23
+ //
24
+ // Channels are allocated lazily — a region with only an albedo skin costs
25
+ // one canvas, not five.
26
+
27
+ export const PBR_CHANNELS = [
28
+ { key: 'albedo', label: 'Base Color', short: 'Alb', slot: 'map', srgb: true },
29
+ { key: 'normal', label: 'Normal', short: 'Nrm', slot: 'normalMap', srgb: false },
30
+ { key: 'roughness', label: 'Roughness', short: 'Rgh', slot: 'roughnessMap', srgb: false, scalar: 'roughness' },
31
+ { key: 'metallic', label: 'Metallic', short: 'Met', slot: 'metalnessMap', srgb: false, scalar: 'metalness' },
32
+ { key: 'ao', label: 'Ambient Occ.', short: 'AO', slot: 'aoMap', srgb: false },
33
+ ];
34
+ const CH = Object.fromEntries(PBR_CHANNELS.map((c) => [c.key, c]));
35
+
36
+ // Per-channel default fill when no image covers a pixel. Albedo falls back
37
+ // to the region base color (set per stack); normals to flat; roughness/AO to
38
+ // white (so the scalar dominates); metallic to black (non-metal).
39
+ const DEFAULT_FILL = {
40
+ normal: '#8080ff',
41
+ roughness: '#ffffff',
42
+ metallic: '#000000',
43
+ ao: '#ffffff',
44
+ };
45
+
46
+ export function emptyMapSet() {
47
+ return { albedo: null, normal: null, roughness: null, metallic: null, ao: null };
48
+ }
49
+
50
+ const hex = (n) => '#' + n.toString(16).padStart(6, '0');
51
+
52
+ export class PBRMaterialStack {
53
+ constructor({ size = 1024, layered = false, baseColor = 0xffffff, roughness = 0.6, metalness = 0.0 } = {}) {
54
+ this.size = size;
55
+ this.layered = layered;
56
+ this.baseColor = baseColor;
57
+ this.globalRoughness = roughness;
58
+ this.globalMetalness = metalness;
59
+
60
+ this.skin = emptyMapSet();
61
+ this.layers = []; // { maps, visible, name }
62
+ this._channels = {}; // key -> { canvas, ctx, texture }
63
+ this._scratch = null;
64
+
65
+ this.material = new THREE.MeshStandardMaterial({
66
+ color: 0xffffff,
67
+ roughness,
68
+ metalness,
69
+ side: THREE.DoubleSide,
70
+ });
71
+
72
+ this.recompositeAll();
73
+ }
74
+
75
+ // ---- map set editing ----
76
+
77
+ setSkinMap(channel, img) {
78
+ this.skin[channel] = img ?? null;
79
+ this.recompositeChannel(channel);
80
+ if (channel === 'albedo') this.recompositeChannel('albedo');
81
+ }
82
+
83
+ addLayer(name) {
84
+ const i = this.layers.length;
85
+ this.layers.push({ maps: emptyMapSet(), visible: true, name: name ?? `Layer ${i + 1}` });
86
+ return i;
87
+ }
88
+
89
+ removeLayer(i) {
90
+ if (i < 0 || i >= this.layers.length) return;
91
+ this.layers.splice(i, 1);
92
+ this.recompositeAll();
93
+ }
94
+
95
+ moveLayer(i, dir) {
96
+ const j = i + dir;
97
+ if (i < 0 || i >= this.layers.length || j < 0 || j >= this.layers.length) return;
98
+ [this.layers[i], this.layers[j]] = [this.layers[j], this.layers[i]];
99
+ this.recompositeAll();
100
+ }
101
+
102
+ setLayerMap(i, channel, img) {
103
+ const layer = this.layers[i];
104
+ if (!layer) return;
105
+ layer.maps[channel] = img ?? null;
106
+ // The albedo alpha is every channel's coverage mask, so changing it
107
+ // re-masks the whole layer.
108
+ if (channel === 'albedo') this.recompositeAll();
109
+ else this.recompositeChannel(channel);
110
+ }
111
+
112
+ setLayerVisible(i, visible) {
113
+ const layer = this.layers[i];
114
+ if (!layer) return;
115
+ layer.visible = visible;
116
+ this.recompositeAll();
117
+ }
118
+
119
+ setGlobalRoughness(v) {
120
+ this.globalRoughness = v;
121
+ if (!this.material.roughnessMap) this.material.roughness = v;
122
+ }
123
+
124
+ setGlobalMetalness(v) {
125
+ this.globalMetalness = v;
126
+ if (!this.material.metalnessMap) this.material.metalness = v;
127
+ }
128
+
129
+ // ---- compositing ----
130
+
131
+ recompositeAll() {
132
+ for (const c of PBR_CHANNELS) this.recompositeChannel(c.key);
133
+ }
134
+
135
+ recompositeChannel(key) {
136
+ const ch = CH[key];
137
+ const sets = [this.skin, ...this.layers.filter((l) => l.visible).map((l) => l.maps)];
138
+ const anyImage = sets.some((s) => s[key]);
139
+
140
+ // Albedo always exists (region base color); other channels drop their map
141
+ // and fall back to the scalar when nothing provides them.
142
+ if (key !== 'albedo' && !anyImage) {
143
+ this._setChannelTexture(ch, null);
144
+ return;
145
+ }
146
+
147
+ const { ctx, texture } = this._ensureChannel(key);
148
+ ctx.globalCompositeOperation = 'source-over';
149
+ if (key === 'albedo') {
150
+ ctx.clearRect(0, 0, this.size, this.size);
151
+ ctx.fillStyle = hex(this.baseColor);
152
+ ctx.fillRect(0, 0, this.size, this.size);
153
+ } else {
154
+ ctx.fillStyle = DEFAULT_FILL[key];
155
+ ctx.fillRect(0, 0, this.size, this.size);
156
+ }
157
+
158
+ // Skin covers the entire region.
159
+ if (this.skin[key]) this._cover(ctx, this.skin[key]);
160
+
161
+ // Each clothing layer is masked to its own albedo footprint.
162
+ for (const layer of this.layers) {
163
+ if (!layer.visible) continue;
164
+ const img = layer.maps[key];
165
+ if (!img) continue;
166
+ const mask = layer.maps.albedo;
167
+ if (mask && mask !== img) this._maskedDraw(ctx, img, mask);
168
+ else this._cover(ctx, img);
169
+ }
170
+
171
+ texture.needsUpdate = true;
172
+ this._setChannelTexture(ch, texture);
173
+ }
174
+
175
+ // ---- internals ----
176
+
177
+ _ensureChannel(key) {
178
+ if (this._channels[key]) return this._channels[key];
179
+ const ch = CH[key];
180
+ const canvas = document.createElement('canvas');
181
+ canvas.width = canvas.height = this.size;
182
+ const ctx = canvas.getContext('2d');
183
+ const texture = new THREE.CanvasTexture(canvas);
184
+ // glTF UV convention: texture origin is top-left, so don't flip vertically
185
+ // (the avatar loads glTF meshes, whose UVs already use that convention).
186
+ texture.flipY = false;
187
+ texture.colorSpace = ch.srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace;
188
+ texture.anisotropy = 4;
189
+ this._channels[key] = { canvas, ctx, texture };
190
+ return this._channels[key];
191
+ }
192
+
193
+ _setChannelTexture(ch, texture) {
194
+ const had = this.material[ch.slot];
195
+ this.material[ch.slot] = texture || null;
196
+ if (ch.scalar) {
197
+ // Map present → map is authoritative (scalar 1); absent → scalar value.
198
+ const fallback = ch.scalar === 'roughness' ? this.globalRoughness : this.globalMetalness;
199
+ this.material[ch.scalar] = texture ? 1.0 : fallback;
200
+ }
201
+ if (!!had !== !!texture) this.material.needsUpdate = true; // toggling a map recompiles
202
+ }
203
+
204
+ _cover(ctx, img) {
205
+ const w = img.naturalWidth || img.width;
206
+ const h = img.naturalHeight || img.height;
207
+ ctx.drawImage(img, 0, 0, w, h, 0, 0, this.size, this.size);
208
+ }
209
+
210
+ _maskedDraw(ctx, img, mask) {
211
+ if (!this._scratch) {
212
+ const canvas = document.createElement('canvas');
213
+ canvas.width = canvas.height = this.size;
214
+ this._scratch = { canvas, ctx: canvas.getContext('2d') };
215
+ }
216
+ const s = this._scratch.ctx;
217
+ s.globalCompositeOperation = 'source-over';
218
+ s.clearRect(0, 0, this.size, this.size);
219
+ this._cover(s, img);
220
+ s.globalCompositeOperation = 'destination-in'; // keep img only where mask is opaque
221
+ this._cover(s, mask);
222
+ s.globalCompositeOperation = 'source-over';
223
+ ctx.drawImage(this._scratch.canvas, 0, 0);
224
+ }
225
+ }