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.
- package/ASSET_LICENSES.md +122 -0
- package/Avatar.js +860 -0
- package/LICENSE.md +21 -0
- package/README.md +407 -0
- package/anims/UAL1_Standard.glb +0 -0
- package/anims/UAL2_Standard.glb +0 -0
- package/anims/pirouette.bvh +867 -0
- package/attachments.js +388 -0
- package/avatarManager.js +110 -0
- package/blink.js +58 -0
- package/bvh.js +110 -0
- package/gltfAnim.js +271 -0
- package/index.js +61 -0
- package/licenses/AGPL-3.0.txt +661 -0
- package/models/body.glb +0 -0
- package/models/eyes.glb +0 -0
- package/models/feet.glb +0 -0
- package/models/hands.glb +0 -0
- package/models/head.glb +0 -0
- package/models/textures/android_face.png +0 -0
- package/models/textures/android_face_ao.jpg +0 -0
- package/models/textures/android_face_metallic.jpg +0 -0
- package/models/textures/android_face_normal.jpg +0 -0
- package/models/textures/android_face_roughness.jpg +0 -0
- package/models/textures/android_lower.png +0 -0
- package/models/textures/android_lower_ao.jpg +0 -0
- package/models/textures/android_lower_metallic.jpg +0 -0
- package/models/textures/android_lower_normal.jpg +0 -0
- package/models/textures/android_lower_roughness.jpg +0 -0
- package/models/textures/android_upper.png +0 -0
- package/models/textures/android_upper_ao.jpg +0 -0
- package/models/textures/android_upper_metallic.jpg +0 -0
- package/models/textures/android_upper_normal.jpg +0 -0
- package/models/textures/android_upper_roughness.jpg +0 -0
- package/models/textures/blue_eyes.png +0 -0
- package/models/textures/layers/cute_pants.png +0 -0
- package/models/textures/layers/cute_shirt.png +0 -0
- package/nipple.js +218 -0
- package/package.json +48 -0
- package/pbr.js +225 -0
- package/physics.js +313 -0
- package/skeleton.js +70 -0
- package/sliders.js +590 -0
- package/speech.js +130 -0
- package/visemes.js +66 -0
- package/voice.js +75 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
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
|
+
}
|