humanforge 0.0.0-stage → 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.
@@ -0,0 +1,67 @@
1
+ // Minimal GLB geometry reader (no DOM, no TextDecoder, no GLTFLoader) so the human
2
+ // runtime works the same in browsers and React Native. Reads the first mesh primitive:
3
+ // attributes (incl. custom _MH/_REFS/... and KHR_mesh_quantization types) and indices.
4
+
5
+ export interface GlbAttribute {
6
+ array: Float32Array | Uint8Array | Uint16Array | Uint32Array | Int8Array | Int16Array;
7
+ itemSize: number;
8
+ normalized: boolean;
9
+ }
10
+ export interface GlbPrimitive {
11
+ attributes: Record<string, GlbAttribute>;
12
+ index: Uint16Array | Uint32Array;
13
+ }
14
+
15
+ const TYPES: Record<number, any> = {
16
+ 5120: Int8Array, 5121: Uint8Array, 5122: Int16Array, 5123: Uint16Array, 5125: Uint32Array, 5126: Float32Array,
17
+ };
18
+ const SIZES: Record<string, number> = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT4: 16 };
19
+
20
+ function ascii(bytes: Uint8Array) {
21
+ let s = '';
22
+ for (let i = 0; i < bytes.length; i += 8192) s += String.fromCharCode(...bytes.subarray(i, i + 8192));
23
+ return s;
24
+ }
25
+
26
+ export function readGlb(buffer: ArrayBuffer): GlbPrimitive {
27
+ const dv = new DataView(buffer);
28
+ if (dv.getUint32(0, true) !== 0x46546c67) throw new Error('not a GLB');
29
+ const jsonLen = dv.getUint32(12, true);
30
+ const json = JSON.parse(decodeURIComponent(escape(ascii(new Uint8Array(buffer, 20, jsonLen)))));
31
+ const binStart = 20 + jsonLen + 8;
32
+ const read = (i: number): GlbAttribute => {
33
+ const acc = json.accessors[i];
34
+ const view = json.bufferViews[acc.bufferView];
35
+ const T = TYPES[acc.componentType];
36
+ const size = SIZES[acc.type];
37
+ const out = new T(acc.count * size);
38
+ const start = binStart + (view.byteOffset ?? 0) + (acc.byteOffset ?? 0);
39
+ const stride = view.byteStride ?? size * T.BYTES_PER_ELEMENT;
40
+ if (stride === size * T.BYTES_PER_ELEMENT && start % T.BYTES_PER_ELEMENT === 0) {
41
+ out.set(new T(buffer, start, acc.count * size));
42
+ } else {
43
+ // padded / interleaved: copy element by element
44
+ const getter = ({ 5120: 'getInt8', 5121: 'getUint8', 5122: 'getInt16', 5123: 'getUint16', 5125: 'getUint32', 5126: 'getFloat32' } as any)[acc.componentType];
45
+ for (let e = 0; e < acc.count; e++) for (let k = 0; k < size; k++) {
46
+ out[e * size + k] = (dv as any)[getter](start + e * stride + k * T.BYTES_PER_ELEMENT, true);
47
+ }
48
+ }
49
+ return { array: out, itemSize: size, normalized: !!acc.normalized };
50
+ };
51
+ const prim = json.meshes[0].primitives[0];
52
+ const attributes: Record<string, GlbAttribute> = {};
53
+ for (const [name, idx] of Object.entries(prim.attributes)) attributes[name] = read(idx as number);
54
+ const index = read(prim.indices).array as Uint16Array | Uint32Array;
55
+ return { attributes, index };
56
+ }
57
+
58
+ /** Convert a (possibly normalized integer) attribute to float values. */
59
+ export function toFloat(a: GlbAttribute): Float32Array {
60
+ if (a.array instanceof Float32Array) return a.array;
61
+ const out = new Float32Array(a.array.length);
62
+ const max = a.normalized
63
+ ? a.array instanceof Uint8Array ? 255 : a.array instanceof Uint16Array ? 65535 : a.array instanceof Int8Array ? 127 : a.array instanceof Int16Array ? 32767 : 1
64
+ : 1;
65
+ for (let i = 0; i < out.length; i++) out[i] = a.normalized && max !== 1 ? Math.max(-1, a.array[i] / max) : a.array[i];
66
+ return out;
67
+ }
@@ -0,0 +1,3 @@
1
+ export * from './runtime';
2
+ export * from './sliders';
3
+ export * from './fit';
@@ -0,0 +1,508 @@
1
+ // Realistic human runtime: loads the CC0 MakeHuman-derived package built by
2
+ // tools/build-human.ts and reshapes it on the CPU (shape sliders change rarely),
3
+ // keeping only facial expressions as GPU morph targets.
4
+ //
5
+ // Platform-neutral: no DOM, no GLTFLoader. Textures come from a pluggable loader
6
+ // (TextureLoader on the web by default; see src/native/humanTextures.ts for React Native).
7
+ import {
8
+ Bone, BufferAttribute, BufferGeometry, DoubleSide, FrontSide, Group, Material, Matrix4, MeshStandardMaterial,
9
+ Quaternion, SRGBColorSpace, Skeleton, SkinnedMesh, Texture, TextureLoader, Vector3,
10
+ } from 'three';
11
+ import { gunzipSync } from 'fflate';
12
+ import { DETAIL_SLIDERS } from './sliders';
13
+ import { GlbPrimitive, readGlb, toFloat } from './glb';
14
+
15
+ // ---------------------------------------------------------------- config
16
+ export interface HumanConfig {
17
+ /** 0 = female, 1 = male */
18
+ gender: number;
19
+ /** 0..1 maps to 18..90 years */
20
+ age: number;
21
+ muscle: number;
22
+ weight: number;
23
+ height: number;
24
+ proportions: number;
25
+ cupSize: number;
26
+ firmness: number;
27
+ african: number;
28
+ asian: number;
29
+ caucasian: number;
30
+ /** Detail sliders, -1..1, keyed by DETAIL_SLIDERS id. */
31
+ detail: Record<string, number>;
32
+ skin: string;
33
+ /** Multiplied over the skin texture for fine tone control. */
34
+ skinTint: string;
35
+ eyes: string;
36
+ eyebrows: string | null;
37
+ eyebrowColor: string;
38
+ eyelashes: string | null;
39
+ hair: string | null;
40
+ hairColor: string;
41
+ clothes: string[];
42
+ }
43
+
44
+ export const DEFAULT_HUMAN: HumanConfig = {
45
+ gender: 0.5, age: 0.15, muscle: 0.5, weight: 0.5, height: 0.5, proportions: 0.5, cupSize: 0.5, firmness: 0.5,
46
+ african: 1 / 3, asian: 1 / 3, caucasian: 1 / 3, detail: {},
47
+ skin: 'young_caucasian_female', skinTint: '#ffffff', eyes: 'brownlight', eyebrows: 'eyebrow001', eyebrowColor: '#3b2a1e',
48
+ eyelashes: 'eyelashes01', hair: 'bob02', hairColor: '#3b2a1e', clothes: ['female_casualsuit01', 'shoes01'],
49
+ };
50
+
51
+ // ---------------------------------------------------------------- package
52
+ export interface ProxyMeta {
53
+ id: string; kind: string; file: string; triangles: number; transparent: boolean; tintable: boolean; zDepth: number;
54
+ deleteVerts?: [number, number][]; scale: [number, number, number][];
55
+ texture?: string; normalTexture?: string; textureSize: number; doubleSided: boolean;
56
+ }
57
+ export interface HumanManifest {
58
+ vertexCount: number;
59
+ groundVerts: number[];
60
+ body: { file: string; vertexCount: number; triangles: number };
61
+ shapes: {
62
+ file: string; baseOffset: number; baseMin: number[]; baseScale: number[];
63
+ morphs: { name: string; offset: number; count: number; scale: number; bits: 8 | 16 }[];
64
+ };
65
+ bones: { name: string; parent: number; head: number[]; tail: number[] }[];
66
+ proxies: ProxyMeta[];
67
+ skins: { id: string; tags: string[] }[];
68
+ eyes: string[];
69
+ textureSizes: { skin1k: number; skin2k: number; eyes: number };
70
+ expressions: Record<string, string>;
71
+ }
72
+ interface Morph { idx: Uint16Array; d: Int16Array; scale: number }
73
+
74
+ /** Loads a texture from a URL. `size` is the known square size (React Native needs it up front). */
75
+ export type HumanTextureLoader = (url: string, size: number) => Promise<Texture>;
76
+
77
+ export const webTextureLoader: HumanTextureLoader = async (url) => new TextureLoader().loadAsync(url);
78
+
79
+ export interface HumanPackage {
80
+ baseUrl: string;
81
+ manifest: HumanManifest;
82
+ base: Float32Array;
83
+ morphs: Map<string, Morph>;
84
+ body: GlbPrimitive;
85
+ loadTexture: HumanTextureLoader;
86
+ fetchBinary: (url: string) => Promise<ArrayBuffer>;
87
+ proxyCache: Map<string, Promise<GlbPrimitive>>;
88
+ textureCache: Map<string, Promise<Texture>>;
89
+ }
90
+
91
+ export interface LoadOptions {
92
+ loadTexture?: HumanTextureLoader;
93
+ /** Override fetching (e.g. for an offline cache). Must return the raw (still gzipped) bytes. */
94
+ fetch?: (url: string) => Promise<ArrayBuffer>;
95
+ }
96
+
97
+ /** Undo build-human's wrapping delta coding + byte planes. */
98
+ function unplane16(src: Uint8Array, at: number, n: number, signed: boolean) {
99
+ const out = signed ? new Int16Array(n) : new Uint16Array(n);
100
+ let acc = 0;
101
+ for (let i = 0; i < n; i++) { acc = (acc + (src[at + i] | (src[at + n + i] << 8))) & 0xffff; out[i] = acc; }
102
+ return out;
103
+ }
104
+ function unplane8(src: Uint8Array, at: number, n: number) {
105
+ const out = new Int16Array(n);
106
+ let acc = 0;
107
+ for (let i = 0; i < n; i++) { acc = (acc + src[at + i]) & 0xff; out[i] = acc > 127 ? acc - 256 : acc; }
108
+ return out;
109
+ }
110
+
111
+ /** Load the human package (manifest + shapes + body). Proxies/textures load lazily per config. */
112
+ export async function loadHumanPackage(baseUrl: string, opts: LoadOptions = {}): Promise<HumanPackage> {
113
+ const root = baseUrl.replace(/\/$/, '');
114
+ const rawFetch = opts.fetch ?? (async (u: string) => (await fetch(u)).arrayBuffer());
115
+ // `.gz` files are gunzipped in JS, so no server Content-Encoding setup is needed (and it works in RN).
116
+ const fetchBinary = async (u: string) => {
117
+ const buf = new Uint8Array(await rawFetch(u));
118
+ const raw = u.endsWith('.gz') && buf[0] === 0x1f && buf[1] === 0x8b ? gunzipSync(buf) : buf;
119
+ return raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength) as ArrayBuffer;
120
+ };
121
+ const manifestBytes = new Uint8Array(await rawFetch(`${root}/manifest.json`));
122
+ let text = '';
123
+ for (let i = 0; i < manifestBytes.length; i += 8192) text += String.fromCharCode(...manifestBytes.subarray(i, i + 8192));
124
+ const manifest: HumanManifest = JSON.parse(text);
125
+ const [bin, bodyBuf] = await Promise.all([fetchBinary(`${root}/${manifest.shapes.file}`), fetchBinary(`${root}/${manifest.body.file}`)]);
126
+ const bytes = new Uint8Array(bin);
127
+ const N = manifest.vertexCount, sh = manifest.shapes;
128
+ const base = new Float32Array(N * 3);
129
+ for (let k = 0; k < 3; k++) {
130
+ const q = unplane16(bytes, sh.baseOffset + k * N * 2, N, false);
131
+ for (let i = 0; i < N; i++) base[i * 3 + k] = sh.baseMin[k] + q[i] * sh.baseScale[k];
132
+ }
133
+ const morphs = new Map<string, Morph>();
134
+ for (const m of sh.morphs) {
135
+ const idx = unplane16(bytes, m.offset, m.count, false) as Uint16Array;
136
+ const d = new Int16Array(m.count * 3);
137
+ let at = m.offset + m.count * 2;
138
+ for (let k = 0; k < 3; k++) {
139
+ const v = m.bits === 8 ? unplane8(bytes, at, m.count) : unplane16(bytes, at, m.count, true);
140
+ at += m.count * (m.bits === 8 ? 1 : 2);
141
+ for (let i = 0; i < m.count; i++) d[i * 3 + k] = v[i];
142
+ }
143
+ morphs.set(m.name, { idx, d, scale: m.scale });
144
+ }
145
+ return {
146
+ baseUrl: root, manifest, base, morphs, body: readGlb(bodyBuf), loadTexture: opts.loadTexture ?? webTextureLoader,
147
+ fetchBinary, proxyCache: new Map(), textureCache: new Map(),
148
+ };
149
+ }
150
+
151
+ // ---------------------------------------------------------------- shape evaluation
152
+ const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
153
+
154
+ /** Weights of the baked macro basis for a config (see VARIANTS in tools/build-human.ts). */
155
+ export function macroWeights(c: HumanConfig): Record<string, number> {
156
+ const w: Record<string, number> = {};
157
+ const g = clamp01(c.gender);
158
+ w['macro/sexF'] = Math.max(0, 1 - 2 * g);
159
+ w['macro/sexM'] = Math.max(0, 2 * g - 1);
160
+ const split = (v: number, up: string, down: string) => {
161
+ const x = clamp01(v);
162
+ const [k, amt] = x >= 0.5 ? [up, 2 * x - 1] : [down, 1 - 2 * x];
163
+ w[`macro/${k}F`] = (w[`macro/${k}F`] ?? 0) + amt * (1 - g);
164
+ w[`macro/${k}M`] = (w[`macro/${k}M`] ?? 0) + amt * g;
165
+ };
166
+ split(c.muscle, 'muscleUp', 'muscleDown');
167
+ split(c.weight, 'weightUp', 'weightDown');
168
+ // MakeHuman's height range spans dwarf..giant (1.2-2.45 m); use its realistic middle band.
169
+ split(0.5 + (clamp01(c.height) - 0.5) * 0.36, 'heightUp', 'heightDown');
170
+ split(c.proportions, 'proportionsUp', 'proportionsDown');
171
+ split(c.cupSize, 'cupUp', 'cupDown');
172
+ split(c.firmness, 'firmUp', 'firmDown');
173
+ // age slider 0..1 -> 18..90 years -> MakeHuman age value
174
+ const years = 18 + 72 * clamp01(c.age);
175
+ if (years < 25) {
176
+ const t = (25 - years) / 7;
177
+ w['macro/ageYoungF'] = t * (1 - g); w['macro/ageYoungM'] = t * g;
178
+ } else {
179
+ const t = (years - 25) / 65;
180
+ w['macro/ageOldF'] = t * (1 - g); w['macro/ageOldM'] = t * g;
181
+ }
182
+ const rs = c.african + c.asian + c.caucasian || 1;
183
+ for (const r of ['african', 'asian', 'caucasian'] as const) {
184
+ const x = c[r] / rs;
185
+ w[`macro/${r}F`] = x * (1 - g); w[`macro/${r}M`] = x * g;
186
+ }
187
+ return w;
188
+ }
189
+
190
+ /** Full shape (all MakeHuman vertices incl. helpers), metres, before grounding. */
191
+ export function evaluateShape(pkg: HumanPackage, c: HumanConfig, out: Float32Array = new Float32Array(pkg.base.length)) {
192
+ out.set(pkg.base);
193
+ const add = (name: string, weight: number) => {
194
+ const m = pkg.morphs.get(name);
195
+ if (!m || Math.abs(weight) < 1e-4) return;
196
+ const s = m.scale * weight;
197
+ for (let i = 0; i < m.idx.length; i++) {
198
+ const o = m.idx[i] * 3;
199
+ out[o] += m.d[i * 3] * s; out[o + 1] += m.d[i * 3 + 1] * s; out[o + 2] += m.d[i * 3 + 2] * s;
200
+ }
201
+ };
202
+ for (const [k, v] of Object.entries(macroWeights(c))) add(k, v);
203
+ for (const s of DETAIL_SLIDERS) {
204
+ const v = c.detail[s.id] ?? 0;
205
+ if (v > 0) s.pos.forEach((t) => add(t, v));
206
+ else if (v < 0) s.neg.forEach((t) => add(t, -v));
207
+ }
208
+ return out;
209
+ }
210
+
211
+ function morphDeltas(pkg: HumanPackage, name: string): Float32Array | null {
212
+ const m = pkg.morphs.get(name);
213
+ if (!m) return null;
214
+ const d = new Float32Array(pkg.base.length);
215
+ for (let i = 0; i < m.idx.length; i++) {
216
+ const o = m.idx[i] * 3;
217
+ d[o] = m.d[i * 3] * m.scale; d[o + 1] = m.d[i * 3 + 1] * m.scale; d[o + 2] = m.d[i * 3 + 2] * m.scale;
218
+ }
219
+ return d;
220
+ }
221
+
222
+ // ---------------------------------------------------------------- avatar
223
+ const tmpA = new Vector3(), tmpB = new Vector3(), tmpC = new Vector3();
224
+ const asU16 = (a: GlbPrimitive['attributes'][string]) => (a.array instanceof Uint16Array ? a.array : Uint16Array.from(a.array));
225
+
226
+ /** Geometry with float position/normal storage (recomputed on the CPU) plus uv/skin from the GLB. */
227
+ function geometryFrom(prim: GlbPrimitive, joints: string, weights: string) {
228
+ const g = new BufferGeometry();
229
+ const n = prim.attributes.POSITION.array.length / 3;
230
+ g.setAttribute('position', new BufferAttribute(new Float32Array(n * 3), 3));
231
+ g.setAttribute('normal', new BufferAttribute(new Float32Array(n * 3), 3));
232
+ g.setAttribute('uv', new BufferAttribute(toFloat(prim.attributes.TEXCOORD_0), 2));
233
+ g.setAttribute('skinIndex', new BufferAttribute(asU16(prim.attributes[joints]), 4));
234
+ g.setAttribute('skinWeight', new BufferAttribute(toFloat(prim.attributes[weights]), 4));
235
+ g.setIndex(new BufferAttribute(prim.index, 1));
236
+ return g;
237
+ }
238
+
239
+ interface ProxyInstance { meta: ProxyMeta; mesh: SkinnedMesh; refs: Uint16Array; refw: Float32Array; offs: Float32Array }
240
+
241
+ export class HumanAvatar {
242
+ readonly group = new Group();
243
+ private pkg: HumanPackage;
244
+ private body: SkinnedMesh;
245
+ private skeleton: Skeleton;
246
+ private mh: Uint16Array;
247
+ private fullIndex: Uint16Array | Uint32Array;
248
+ private positions: Float32Array;
249
+ private proxies = new Map<string, ProxyInstance>();
250
+ private config: HumanConfig = { ...DEFAULT_HUMAN };
251
+ private exprNames: string[];
252
+ private exprDeltas: Float32Array[];
253
+ private skinMat: MeshStandardMaterial;
254
+ private version = 0;
255
+ textureSize: '1k' | '2k' = '2k';
256
+
257
+ constructor(pkg: HumanPackage) {
258
+ this.pkg = pkg;
259
+ const geo = geometryFrom(pkg.body, 'JOINTS_0', 'WEIGHTS_0');
260
+ this.mh = asU16(pkg.body.attributes._MH);
261
+ this.fullIndex = pkg.body.index.slice();
262
+ const bones = pkg.manifest.bones.map((b) => Object.assign(new Bone(), { name: b.name }));
263
+ pkg.manifest.bones.forEach((b, i) => { if (b.parent >= 0) bones[b.parent].add(bones[i]); });
264
+ this.skeleton = new Skeleton(bones);
265
+ this.skinMat = new MeshStandardMaterial({ roughness: 0.55, metalness: 0 });
266
+ this.body = new SkinnedMesh(geo, this.skinMat);
267
+ this.body.name = 'body';
268
+ this.body.frustumCulled = false;
269
+ this.body.add(bones[0]);
270
+ this.group.add(this.body);
271
+ this.positions = new Float32Array(pkg.base.length);
272
+ // expressions: GPU morph targets (body + face proxies)
273
+ this.exprNames = Object.keys(pkg.manifest.expressions).filter((u) => pkg.morphs.has(`expr/${u}`));
274
+ this.exprDeltas = this.exprNames.map((u) => morphDeltas(pkg, `expr/${u}`)!);
275
+ this.attachMorphs(geo, (i, k) => this.mh[i] * 3 + k);
276
+ this.body.updateMorphTargets();
277
+ this.body.morphTargetDictionary = Object.fromEntries(this.exprNames.map((u, i) => [this.alias(u), i]));
278
+ }
279
+
280
+ get bones() { return this.skeleton.bones; }
281
+ private alias(unit: string) { return this.pkg.manifest.expressions[unit] ?? unit; }
282
+
283
+ /** Expression morph attributes from full-shape deltas, either by direct lookup or via proxy refs. */
284
+ private attachMorphs(geo: BufferGeometry, lookup: (i: number, k: number) => number, refs?: { r: Uint16Array; w: Float32Array }) {
285
+ const n = geo.getAttribute('position').count;
286
+ geo.morphAttributes.position = this.exprDeltas.map((d) => {
287
+ const arr = new Float32Array(n * 3);
288
+ for (let i = 0; i < n; i++) for (let k = 0; k < 3; k++) {
289
+ arr[i * 3 + k] = refs
290
+ ? refs.w[i * 3] * d[refs.r[i * 3] * 3 + k] + refs.w[i * 3 + 1] * d[refs.r[i * 3 + 1] * 3 + k] + refs.w[i * 3 + 2] * d[refs.r[i * 3 + 2] * 3 + k]
291
+ : d[lookup(i, k)];
292
+ }
293
+ return new BufferAttribute(arr, 3);
294
+ });
295
+ geo.morphTargetsRelative = true;
296
+ }
297
+
298
+ /** Apply a full or partial config. Loads any new hair/clothes/textures, then reshapes. */
299
+ async setConfig(patch: Partial<HumanConfig>) {
300
+ const prev = this.config;
301
+ const c = (this.config = { ...prev, ...patch, detail: { ...prev.detail, ...(patch.detail ?? {}) } });
302
+ const v = ++this.version;
303
+ const wanted = new Map<string, string>(); // slot -> proxy id
304
+ wanted.set('eyes', 'eyes-high-poly');
305
+ if (c.eyebrows) wanted.set('eyebrows', `eyebrows-${c.eyebrows}`);
306
+ if (c.eyelashes) wanted.set('eyelashes', `eyelashes-${c.eyelashes}`);
307
+ wanted.set('teeth', 'teeth-teeth_base');
308
+ wanted.set('tongue', 'tongue-tongue01');
309
+ if (c.hair) wanted.set('hair', `hair-${c.hair}`);
310
+ c.clothes.forEach((id) => wanted.set(`clothes:${id}`, `clothes-${id}`));
311
+ const loads = [...wanted.entries()].map(async ([slot, id]) => {
312
+ const cur = this.proxies.get(slot);
313
+ if (cur && `${cur.meta.kind}-${cur.meta.id}` === id) return null;
314
+ return [slot, await this.loadProxy(id)] as const;
315
+ });
316
+ const sizes = this.pkg.manifest.textureSizes;
317
+ const skin = this.texture(`skins/${c.skin}_${this.textureSize}.webp`, this.textureSize === '2k' ? sizes.skin2k : sizes.skin1k);
318
+ const eyes = this.texture(`eyes/${c.eyes}.webp`, sizes.eyes);
319
+ const loaded = await Promise.all(loads);
320
+ const [skinTex, eyeTex] = await Promise.all([skin, eyes]);
321
+ if (v !== this.version) return; // a newer setConfig superseded this one
322
+ for (const slot of [...this.proxies.keys()]) if (!wanted.has(slot)) this.removeProxy(slot);
323
+ for (const entry of loaded) {
324
+ if (!entry || !entry[1]) continue;
325
+ this.removeProxy(entry[0]);
326
+ this.proxies.set(entry[0], entry[1]);
327
+ this.group.add(entry[1].mesh);
328
+ }
329
+ this.skinMat.map = skinTex;
330
+ this.skinMat.color.set(c.skinTint);
331
+ this.skinMat.needsUpdate = true;
332
+ for (const [slot, p] of this.proxies) {
333
+ const m = p.mesh.material as MeshStandardMaterial;
334
+ if (slot === 'hair') m.color.set(c.hairColor);
335
+ if (slot === 'eyebrows') m.color.set(c.eyebrowColor);
336
+ if (slot === 'eyes' && m.map !== eyeTex) { m.map = eyeTex; m.needsUpdate = true; }
337
+ }
338
+ this.reshape();
339
+ }
340
+
341
+ getConfig(): HumanConfig { return this.config; }
342
+
343
+ /** Expression by ARKit-style name (e.g. 'jawOpen', 'mouthSmile') or MPFB unit name, weight 0..1. */
344
+ setExpression(name: string, weight: number) {
345
+ const unit = this.exprNames.findIndex((u) => u === name || this.alias(u) === name);
346
+ if (unit < 0) return;
347
+ for (const m of [this.body, ...[...this.proxies.values()].map((p) => p.mesh)]) {
348
+ if (m.morphTargetInfluences && unit < m.morphTargetInfluences.length) m.morphTargetInfluences[unit] = weight;
349
+ }
350
+ }
351
+ get expressionNames() { return this.exprNames.map((u) => this.alias(u)); }
352
+
353
+ private texture(file: string, size: number): Promise<Texture> {
354
+ const url = `${this.pkg.baseUrl}/${file}`;
355
+ let t = this.pkg.textureCache.get(url);
356
+ if (!t) {
357
+ t = this.pkg.loadTexture(url, size).then((tex) => {
358
+ tex.colorSpace = file.endsWith('_n.webp') ? '' : SRGBColorSpace;
359
+ tex.flipY = false;
360
+ tex.anisotropy = 4;
361
+ tex.needsUpdate = true;
362
+ return tex;
363
+ });
364
+ this.pkg.textureCache.set(url, t);
365
+ }
366
+ return t;
367
+ }
368
+
369
+ private async loadProxy(fullId: string): Promise<ProxyInstance | null> {
370
+ const meta = this.pkg.manifest.proxies.find((p) => `${p.kind}-${p.id}` === fullId);
371
+ if (!meta) return null;
372
+ let p = this.pkg.proxyCache.get(fullId);
373
+ if (!p) {
374
+ p = this.pkg.fetchBinary(`${this.pkg.baseUrl}/${meta.file}`).then(readGlb);
375
+ this.pkg.proxyCache.set(fullId, p);
376
+ }
377
+ const [prim, map, normalMap] = await Promise.all([
378
+ p,
379
+ meta.texture ? this.texture(meta.texture, meta.textureSize) : null,
380
+ meta.normalTexture ? this.texture(meta.normalTexture, 1024) : null,
381
+ ]);
382
+ const geo = geometryFrom(prim, '_JOINTS', '_WEIGHTS');
383
+ const refs = asU16(prim.attributes._REFS), refw = toFloat(prim.attributes._REFW), offs = toFloat(prim.attributes._OFFS);
384
+ const mat = new MeshStandardMaterial({
385
+ map, normalMap, metalness: 0,
386
+ roughness: meta.kind === 'eyes' ? 0.15 : meta.kind === 'hair' ? 0.55 : 0.8,
387
+ side: meta.doubleSided ? DoubleSide : FrontSide,
388
+ });
389
+ if (meta.kind === 'hair') {
390
+ mat.alphaTest = 0.3;
391
+ mat.alphaToCoverage = true;
392
+ } else if (meta.transparent) {
393
+ mat.transparent = true;
394
+ mat.depthWrite = false;
395
+ }
396
+ const mesh = new SkinnedMesh(geo, mat);
397
+ mesh.name = fullId;
398
+ mesh.frustumCulled = false;
399
+ mesh.renderOrder = meta.transparent ? 2 + meta.zDepth / 100 : 0;
400
+ if (['eyebrows', 'eyelashes', 'teeth', 'tongue'].includes(meta.kind)) {
401
+ this.attachMorphs(geo, () => 0, { r: refs, w: refw });
402
+ mesh.updateMorphTargets();
403
+ mesh.morphTargetDictionary = this.body.morphTargetDictionary;
404
+ mesh.morphTargetInfluences = [...(this.body.morphTargetInfluences ?? [])];
405
+ }
406
+ return { meta, mesh, refs, refw, offs };
407
+ }
408
+
409
+ private removeProxy(slot: string) {
410
+ const p = this.proxies.get(slot);
411
+ if (!p) return;
412
+ this.group.remove(p.mesh);
413
+ p.mesh.geometry.dispose();
414
+ (p.mesh.material as Material).dispose();
415
+ this.proxies.delete(slot);
416
+ }
417
+
418
+ /** CPU reshape: body, proxies, skeleton, faces hidden under clothes. */
419
+ private reshape() {
420
+ const P = evaluateShape(this.pkg, this.config, this.positions);
421
+ const man = this.pkg.manifest;
422
+ let gy = 0;
423
+ for (const i of man.groundVerts) gy += P[i * 3 + 1];
424
+ gy /= man.groundVerts.length;
425
+ for (let i = 1; i < P.length; i += 3) P[i] -= gy;
426
+
427
+ // body positions + seam-aware normals (accumulated per MakeHuman vertex)
428
+ const geo = this.body.geometry;
429
+ const pos = geo.getAttribute('position') as BufferAttribute;
430
+ const mh = this.mh;
431
+ for (let i = 0; i < mh.length; i++) pos.setXYZ(i, P[mh[i] * 3], P[mh[i] * 3 + 1], P[mh[i] * 3 + 2]);
432
+ pos.needsUpdate = true;
433
+ const hidden = new Uint8Array(man.vertexCount);
434
+ for (const p of this.proxies.values()) p.meta.deleteVerts?.forEach(([a, b]) => hidden.fill(1, a, b + 1));
435
+ const full = this.fullIndex;
436
+ const kept: number[] = [];
437
+ const nAcc = new Float32Array(P.length);
438
+ for (let i = 0; i < full.length; i += 3) {
439
+ const a = mh[full[i]], b = mh[full[i + 1]], c = mh[full[i + 2]];
440
+ if (!(hidden[a] || hidden[b] || hidden[c])) kept.push(full[i], full[i + 1], full[i + 2]);
441
+ tmpA.fromArray(P, a * 3); tmpB.fromArray(P, b * 3).sub(tmpA); tmpC.fromArray(P, c * 3).sub(tmpA);
442
+ tmpB.cross(tmpC);
443
+ for (const v of [a, b, c]) { nAcc[v * 3] += tmpB.x; nAcc[v * 3 + 1] += tmpB.y; nAcc[v * 3 + 2] += tmpB.z; }
444
+ }
445
+ geo.setIndex(kept);
446
+ const nor = geo.getAttribute('normal') as BufferAttribute;
447
+ for (let i = 0; i < mh.length; i++) {
448
+ tmpA.fromArray(nAcc, mh[i] * 3).normalize();
449
+ nor.setXYZ(i, tmpA.x, tmpA.y, tmpA.z);
450
+ }
451
+ nor.needsUpdate = true;
452
+ geo.computeBoundingSphere();
453
+
454
+ // proxies: barycentric refs + offsets scaled by the body's local proportions
455
+ for (const p of this.proxies.values()) {
456
+ const s = p.meta.scale.map(([a, b, d], k) => Math.abs(P[a * 3 + k] - P[b * 3 + k]) / d);
457
+ const g = p.mesh.geometry;
458
+ const pp = g.getAttribute('position') as BufferAttribute;
459
+ const r = p.refs, w = p.refw, o = p.offs;
460
+ for (let i = 0; i < pp.count; i++) {
461
+ const a = r[i * 3] * 3, b = r[i * 3 + 1] * 3, c = r[i * 3 + 2] * 3;
462
+ const wa = w[i * 3], wb = w[i * 3 + 1], wc = w[i * 3 + 2];
463
+ pp.setXYZ(i,
464
+ wa * P[a] + wb * P[b] + wc * P[c] + o[i * 3] * s[0],
465
+ wa * P[a + 1] + wb * P[b + 1] + wc * P[c + 1] + o[i * 3 + 1] * s[1],
466
+ wa * P[a + 2] + wb * P[b + 2] + wc * P[c + 2] + o[i * 3 + 2] * s[2]);
467
+ }
468
+ pp.needsUpdate = true;
469
+ g.computeVertexNormals();
470
+ g.computeBoundingSphere();
471
+ }
472
+
473
+ // skeleton: heads/tails follow the reshaped joint helpers; +Y points down the bone
474
+ const mean = (idx: number[], out: Vector3) => {
475
+ out.set(0, 0, 0);
476
+ for (const i of idx) { out.x += P[i * 3]; out.y += P[i * 3 + 1]; out.z += P[i * 3 + 2]; }
477
+ return out.divideScalar(idx.length);
478
+ };
479
+ const world = man.bones.map((b) => {
480
+ const h = mean(b.head, new Vector3()), t = mean(b.tail, new Vector3());
481
+ const q = new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), t.sub(h).normalize());
482
+ return new Matrix4().compose(h, q, new Vector3(1, 1, 1));
483
+ });
484
+ const bones = this.skeleton.bones;
485
+ man.bones.forEach((b, i) => {
486
+ const local = b.parent < 0 ? world[i].clone() : world[b.parent].clone().invert().multiply(world[i]);
487
+ local.decompose(bones[i].position, bones[i].quaternion, bones[i].scale);
488
+ });
489
+ this.group.updateMatrixWorld(true);
490
+ this.body.bind(this.skeleton); // recomputes bone inverses for the new rest pose
491
+ for (const p of this.proxies.values()) p.mesh.bind(this.skeleton, this.body.bindMatrix);
492
+ }
493
+
494
+ /** Head height and total height (world space at rest), for camera framing. */
495
+ metrics() {
496
+ const head = this.skeleton.bones.find((b) => /Head$/.test(b.name));
497
+ const hp = head ? head.getWorldPosition(new Vector3()) : new Vector3(0, 1.5, 0);
498
+ this.body.geometry.computeBoundingBox();
499
+ const box = this.body.geometry.boundingBox!;
500
+ return { headY: hp.y + 0.1, height: box.max.y };
501
+ }
502
+
503
+ dispose() {
504
+ for (const k of [...this.proxies.keys()]) this.removeProxy(k);
505
+ this.body.geometry.dispose();
506
+ this.skinMat.dispose();
507
+ }
508
+ }
@@ -0,0 +1,76 @@
1
+ // Web helper: selfie image -> HumanConfig, using MediaPipe Face Landmarker (Apache-2.0) in the
2
+ // browser and the platform-neutral solver in ./fit. Web only (MediaPipe tasks-vision uses WASM);
3
+ // on React Native, run a landmark model yourself and call fitSelfie() from ./fit directly.
4
+ //
5
+ // npm i @mediapipe/tasks-vision
6
+ // const fitter = await createSelfieFitter();
7
+ // const { config } = await fitter.fit(pkg, imgElement);
8
+ // await avatar.setConfig(config);
9
+ import type { HumanPackage } from './runtime';
10
+ import { FitSelfieOptions, Landmark, SelfieFit, fitSelfie } from './fit';
11
+
12
+ export interface SelfieFitterOptions {
13
+ /** Folder with MediaPipe's vision_wasm_* files. Default: jsDelivr for the installed version. */
14
+ wasmPath?: string;
15
+ /** face_landmarker.task model. Default: Google's hosted float16 model. */
16
+ modelPath?: string;
17
+ delegate?: 'GPU' | 'CPU';
18
+ /** Longest side of the images analyzed (the whole photo, then a crop around the face). Default 1024. */
19
+ maxSize?: number;
20
+ }
21
+
22
+ export interface SelfieFitterResult extends SelfieFit {
23
+ landmarks: Landmark[];
24
+ /** The face crop that was analyzed (landmarks are normalized to it), handy for previews. */
25
+ canvas: HTMLCanvasElement;
26
+ }
27
+
28
+ export const DEFAULT_FACE_MODEL = 'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task';
29
+ export const DEFAULT_WASM_PATH = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@1.1.1/wasm';
30
+
31
+ export async function createSelfieFitter(opts: SelfieFitterOptions = {}) {
32
+ const { FilesetResolver, FaceLandmarker } = await import('@mediapipe/tasks-vision');
33
+ const fileset = await FilesetResolver.forVisionTasks(opts.wasmPath ?? DEFAULT_WASM_PATH);
34
+ const create = (delegate: 'GPU' | 'CPU') => FaceLandmarker.createFromOptions(fileset, {
35
+ baseOptions: { modelAssetPath: opts.modelPath ?? DEFAULT_FACE_MODEL, delegate },
36
+ runningMode: 'IMAGE', numFaces: 1, minFaceDetectionConfidence: 0.5,
37
+ });
38
+ const landmarker = await create(opts.delegate ?? 'GPU').catch(() => create('CPU'));
39
+ const maxSize = opts.maxSize ?? 1024;
40
+ const draw = (img: CanvasImageSource, sx: number, sy: number, sw: number, sh: number, w: number, h: number) => {
41
+ const c = document.createElement('canvas');
42
+ c.width = w; c.height = h;
43
+ c.getContext('2d', { willReadFrequently: true })!.drawImage(img, sx, sy, sw, sh, 0, 0, w, h);
44
+ return c;
45
+ };
46
+
47
+ return {
48
+ /** Detect the face in `image` and fit a config. Throws if no face is found. */
49
+ async fit(pkg: HumanPackage, image: CanvasImageSource & { width: number; height: number } | Blob, fitOpts: FitSelfieOptions = {}): Promise<SelfieFitterResult> {
50
+ const src = image instanceof Blob ? await createImageBitmap(image, { imageOrientation: 'from-image' }) : image;
51
+ const w0 = (src as any).naturalWidth || src.width, h0 = (src as any).naturalHeight || src.height;
52
+ // 1. find the face on a downscaled copy
53
+ const k = Math.min(1, maxSize / Math.max(w0, h0));
54
+ let canvas = draw(src, 0, 0, w0, h0, Math.round(w0 * k), Math.round(h0 * k));
55
+ let lms = landmarker.detect(canvas).faceLandmarks[0];
56
+ if (!lms) throw new Error('No face found in the image');
57
+ // 2. re-detect on a face crop at full resolution: sharper landmarks, and the iris/sclera
58
+ // get enough pixels for color sampling even in waist-up portraits
59
+ const xs = lms.map((p) => p.x * w0), ys = lms.map((p) => p.y * h0);
60
+ const fx = Math.min(...xs), fy = Math.min(...ys), fw = Math.max(...xs) - fx, fh = Math.max(...ys) - fy;
61
+ const side = Math.max(fw, fh) * 1.8;
62
+ const cx = Math.max(0, fx + fw / 2 - side / 2), cy = Math.max(0, fy + fh / 2 - side / 2);
63
+ const cw = Math.min(w0 - cx, side), chh = Math.min(h0 - cy, side);
64
+ if (side * 1.2 < Math.max(w0, h0)) {
65
+ const kc = Math.min(1, maxSize / Math.max(cw, chh));
66
+ const crop = draw(src, cx, cy, cw, chh, Math.round(cw * kc), Math.round(chh * kc));
67
+ const again = landmarker.detect(crop).faceLandmarks[0];
68
+ if (again) { canvas = crop; lms = again; }
69
+ }
70
+ const pixels = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height);
71
+ const fit = fitSelfie(pkg, lms, { width: canvas.width, height: canvas.height }, pixels, fitOpts);
72
+ return { ...fit, landmarks: lms, canvas };
73
+ },
74
+ close() { landmarker.close(); },
75
+ };
76
+ }