humanforge 0.3.0 → 0.5.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/README.md +117 -18
- package/package.json +5 -2
- package/src/human/catalog.ts +47 -0
- package/src/human/editor.ts +129 -20
- package/src/human/runtime.ts +177 -25
- package/src/index.ts +2 -0
- package/src/native/HumanEditor.tsx +29 -10
- package/src/native/HumanView.tsx +129 -25
- package/src/native/humanExpo.ts +39 -17
- package/src/native/index.ts +3 -3
- package/src/web/HumanCanvas.tsx +3 -2
- package/src/web/HumanEditor.tsx +33 -12
package/src/human/runtime.ts
CHANGED
|
@@ -63,6 +63,8 @@ export interface ProxyMeta {
|
|
|
63
63
|
roughness?: number;
|
|
64
64
|
/** Texture coordinates are in tiles (repeat wrapping). */
|
|
65
65
|
tileable?: boolean;
|
|
66
|
+
/** Transparent WebP thumbnail, relative to the package root (tools/build-previews.mts). */
|
|
67
|
+
preview?: string;
|
|
66
68
|
}
|
|
67
69
|
export interface HumanManifest {
|
|
68
70
|
vertexCount: number;
|
|
@@ -74,9 +76,23 @@ export interface HumanManifest {
|
|
|
74
76
|
};
|
|
75
77
|
bones: { name: string; parent: number; head: number[]; tail: number[] }[];
|
|
76
78
|
proxies: ProxyMeta[];
|
|
77
|
-
|
|
79
|
+
/** `swatch`: the skin tone as rendered (hex), from tools/build-previews.mts. */
|
|
80
|
+
skins: { id: string; tags: string[]; swatch?: string }[];
|
|
78
81
|
eyes: string[];
|
|
82
|
+
/** Iris color and round iris thumbnail per eye color (tools/build-previews.mts). */
|
|
83
|
+
eyeOptions?: Record<string, { swatch: string; preview: string }>;
|
|
79
84
|
textureSizes: { skin1k: number; skin2k: number; eyes: number };
|
|
85
|
+
/**
|
|
86
|
+
* JPEG/PNG copies of every WebP texture for decoders without WebP (expo-gl's stb_image),
|
|
87
|
+
* from tools/build-mobile-textures.mts. Files: `<baseUrl or dir>/<key>_<tier>.<ext>`.
|
|
88
|
+
*/
|
|
89
|
+
mobileTextures?: {
|
|
90
|
+
dir: string;
|
|
91
|
+
/** Absolute URL of the pack when it is hosted apart from the manifest (e.g. a separate CDN tag). */
|
|
92
|
+
baseUrl?: string;
|
|
93
|
+
tiers: Record<TextureTier, number>;
|
|
94
|
+
files: Record<string, { ext: 'jpg' | 'png'; max: number }>;
|
|
95
|
+
};
|
|
80
96
|
expressions: Record<string, string>;
|
|
81
97
|
}
|
|
82
98
|
interface Morph { idx: Uint16Array; d: Int16Array; scale: number }
|
|
@@ -84,6 +100,13 @@ interface Morph { idx: Uint16Array; d: Int16Array; scale: number }
|
|
|
84
100
|
/** Loads a texture from a URL. `size` is the known square size (React Native needs it up front). */
|
|
85
101
|
export type HumanTextureLoader = (url: string, size: number) => Promise<Texture>;
|
|
86
102
|
|
|
103
|
+
/** Texture quality tier: max texture edge 512 / 1024 / 2048 px (smaller sources are never upscaled). */
|
|
104
|
+
export type TextureTier = '512' | '1k' | '2k';
|
|
105
|
+
/** 'webp': the web files. 'mobile': the JPEG/PNG pack (needed by expo-gl, which can't decode WebP). */
|
|
106
|
+
export type TextureFormat = 'webp' | 'mobile';
|
|
107
|
+
const TIER_PX: Record<TextureTier, number> = { '512': 512, '1k': 1024, '2k': 2048 };
|
|
108
|
+
const tierFor = (px: number): TextureTier => (px >= 2048 ? '2k' : px >= 1024 ? '1k' : '512');
|
|
109
|
+
|
|
87
110
|
export const webTextureLoader: HumanTextureLoader = async (url) => new TextureLoader().loadAsync(url);
|
|
88
111
|
|
|
89
112
|
export interface HumanPackage {
|
|
@@ -96,12 +119,21 @@ export interface HumanPackage {
|
|
|
96
119
|
fetchBinary: (url: string) => Promise<ArrayBuffer>;
|
|
97
120
|
proxyCache: Map<string, Promise<GlbPrimitive>>;
|
|
98
121
|
textureCache: Map<string, Promise<Texture>>;
|
|
122
|
+
textureFormat: TextureFormat;
|
|
123
|
+
/** URL and pixel size of a texture key ('skins/<id>', 'eyes/<id>', 'textures/<name>') at a tier. */
|
|
124
|
+
resolveTexture(key: string, tier: TextureTier, nativeSize: number): { url: string; size: number };
|
|
125
|
+
/** Load (once per URL) a texture by key and tier. */
|
|
126
|
+
getTexture(key: string, tier: TextureTier, nativeSize: number): Promise<Texture>;
|
|
99
127
|
}
|
|
100
128
|
|
|
101
129
|
export interface LoadOptions {
|
|
102
130
|
loadTexture?: HumanTextureLoader;
|
|
103
131
|
/** Override fetching (e.g. for an offline cache). Must return the raw (still gzipped) bytes. */
|
|
104
132
|
fetch?: (url: string) => Promise<ArrayBuffer>;
|
|
133
|
+
/** Texture files to use. Default 'webp'; the Expo loaders use 'mobile' (falls back to WebP if the pack is missing). */
|
|
134
|
+
textureFormat?: TextureFormat;
|
|
135
|
+
/** Where the mobile pack lives. Default: manifest.mobileTextures.baseUrl, else `<baseUrl>/mobile`. */
|
|
136
|
+
mobileBaseUrl?: string;
|
|
105
137
|
}
|
|
106
138
|
|
|
107
139
|
/** Undo build-human's wrapping delta coding + byte planes. */
|
|
@@ -152,12 +184,109 @@ export async function loadHumanPackage(baseUrl: string, opts: LoadOptions = {}):
|
|
|
152
184
|
}
|
|
153
185
|
morphs.set(m.name, { idx, d, scale: m.scale });
|
|
154
186
|
}
|
|
187
|
+
const loadTexture = opts.loadTexture ?? webTextureLoader;
|
|
188
|
+
const mobile = manifest.mobileTextures;
|
|
189
|
+
const textureFormat: TextureFormat = opts.textureFormat === 'mobile' && mobile ? 'mobile' : 'webp';
|
|
190
|
+
if (opts.textureFormat === 'mobile' && !mobile) console.warn('humanforge: this asset package has no mobile texture pack (npm run build:mobile); using WebP');
|
|
191
|
+
const mobileBase = (opts.mobileBaseUrl ?? mobile?.baseUrl ?? `${root}/${mobile?.dir ?? 'mobile'}`).replace(/\/$/, '');
|
|
192
|
+
const textureCache = new Map<string, Promise<Texture>>();
|
|
193
|
+
const resolveTexture = (key: string, tier: TextureTier, nativeSize: number) => {
|
|
194
|
+
const entry = textureFormat === 'mobile' ? mobile!.files[key] : undefined;
|
|
195
|
+
if (entry) {
|
|
196
|
+
const px = Math.min(TIER_PX[tier], entry.max, nativeSize);
|
|
197
|
+
return { url: `${mobileBase}/${key}_${tierFor(px)}.${entry.ext}`, size: px };
|
|
198
|
+
}
|
|
199
|
+
// WebP: skins come in 1k and 2k; everything else at its native size
|
|
200
|
+
if (key.startsWith('skins/')) return tier === '2k' ? { url: `${root}/${key}_2k.webp`, size: manifest.textureSizes.skin2k } : { url: `${root}/${key}_1k.webp`, size: manifest.textureSizes.skin1k };
|
|
201
|
+
return { url: `${root}/${key}.webp`, size: nativeSize };
|
|
202
|
+
};
|
|
203
|
+
const getTexture = (key: string, tier: TextureTier, nativeSize: number) => {
|
|
204
|
+
const { url, size } = resolveTexture(key, tier, nativeSize);
|
|
205
|
+
let t = textureCache.get(url);
|
|
206
|
+
if (!t) {
|
|
207
|
+
t = loadTexture(url, size).then((tex) => {
|
|
208
|
+
tex.colorSpace = key.endsWith('_n') ? '' : SRGBColorSpace;
|
|
209
|
+
tex.flipY = false;
|
|
210
|
+
tex.needsUpdate = true;
|
|
211
|
+
return tex;
|
|
212
|
+
});
|
|
213
|
+
textureCache.set(url, t);
|
|
214
|
+
t.catch(() => textureCache.delete(url));
|
|
215
|
+
}
|
|
216
|
+
return t;
|
|
217
|
+
};
|
|
218
|
+
return {
|
|
219
|
+
baseUrl: root, manifest, base, morphs, body: readGlb(bodyBuf), loadTexture,
|
|
220
|
+
fetchBinary, proxyCache: new Map(), textureCache, textureFormat, resolveTexture, getTexture,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Texture key of a proxy texture path ('textures/hair-bob02.webp' -> 'textures/hair-bob02'). */
|
|
225
|
+
const keyOf = (file: string) => file.replace(/\.(webp|png|jpe?g)$/, '');
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Every file a config needs for its first textured frame (proxy meshes + textures), so apps can
|
|
229
|
+
* download them ahead of time. Used by preloadHumanPackage.
|
|
230
|
+
*/
|
|
231
|
+
export function humanAssetsFor(pkg: HumanPackage, config: Partial<HumanConfig>, opts: { textureSize?: TextureTier; normalMaps?: boolean } = {}) {
|
|
232
|
+
const c = { ...DEFAULT_HUMAN, ...config } as HumanConfig;
|
|
233
|
+
const tier = opts.textureSize ?? '1k';
|
|
234
|
+
const ids = ['eyes-high-poly', 'teeth-teeth_base', 'tongue-tongue01'];
|
|
235
|
+
if (c.eyebrows) ids.push(`eyebrows-${c.eyebrows}`);
|
|
236
|
+
if (c.eyelashes) ids.push(`eyelashes-${c.eyelashes}`);
|
|
237
|
+
const metaOf = (id: string) => pkg.manifest.proxies.find((p) => `${p.kind}-${p.id}` === id);
|
|
238
|
+
const hidden = c.clothes.some((id) => metaOf(`clothes-${id}`)?.hidesHair);
|
|
239
|
+
if (c.hair && !hidden) ids.push(`hair-${c.hair}`);
|
|
240
|
+
for (const id of c.clothes) ids.push(`clothes-${id}`);
|
|
241
|
+
const proxies = ids.map(metaOf).filter((p): p is ProxyMeta => !!p);
|
|
242
|
+
const textures: { key: string; nativeSize: number }[] = [
|
|
243
|
+
{ key: `skins/${c.skin}`, nativeSize: pkg.manifest.textureSizes.skin2k },
|
|
244
|
+
{ key: `eyes/${c.eyes}`, nativeSize: pkg.manifest.textureSizes.eyes },
|
|
245
|
+
];
|
|
246
|
+
for (const p of proxies) {
|
|
247
|
+
if (p.texture) textures.push({ key: keyOf(p.texture), nativeSize: p.textureSize });
|
|
248
|
+
if (p.normalTexture && opts.normalMaps !== false) textures.push({ key: keyOf(p.normalTexture), nativeSize: 1024 });
|
|
249
|
+
}
|
|
155
250
|
return {
|
|
156
|
-
|
|
157
|
-
|
|
251
|
+
meshes: proxies.map((p) => ({ id: `${p.kind}-${p.id}`, url: `${pkg.baseUrl}/${p.file}` })),
|
|
252
|
+
textures: textures.map((t) => ({ ...t, ...pkg.resolveTexture(t.key, tier, t.nativeSize) })),
|
|
158
253
|
};
|
|
159
254
|
}
|
|
160
255
|
|
|
256
|
+
export interface PreloadOptions extends LoadOptions {
|
|
257
|
+
/** The look to warm up (default DEFAULT_HUMAN): its hair, clothes, skin and eyes are downloaded too. */
|
|
258
|
+
config?: Partial<HumanConfig>;
|
|
259
|
+
textureSize?: TextureTier;
|
|
260
|
+
normalMaps?: boolean;
|
|
261
|
+
onProgress?: (done: number, total: number) => void;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Load the package and download everything the first textured frame of `config` needs. Resolves
|
|
266
|
+
* when those files are fetched (and, with the Expo loaders, on disk), not just the manifest.
|
|
267
|
+
*/
|
|
268
|
+
export async function preloadHumanPackage(baseUrl: string, opts: PreloadOptions = {}): Promise<HumanPackage> {
|
|
269
|
+
const pkg = await loadHumanPackage(baseUrl, opts);
|
|
270
|
+
await warmHumanPackage(pkg, opts);
|
|
271
|
+
return pkg;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Download the meshes and textures for a config into the package caches (see preloadHumanPackage). */
|
|
275
|
+
export async function warmHumanPackage(pkg: HumanPackage, opts: Omit<PreloadOptions, keyof LoadOptions> = {}) {
|
|
276
|
+
const { meshes, textures } = humanAssetsFor(pkg, opts.config ?? {}, opts);
|
|
277
|
+
const total = meshes.length + textures.length;
|
|
278
|
+
let done = 0;
|
|
279
|
+
const tick = () => opts.onProgress?.(++done, total);
|
|
280
|
+
await Promise.all([
|
|
281
|
+
...meshes.map(async (m) => {
|
|
282
|
+
let p = pkg.proxyCache.get(m.id);
|
|
283
|
+
if (!p) { p = pkg.fetchBinary(m.url).then(readGlb); pkg.proxyCache.set(m.id, p); p.catch(() => pkg.proxyCache.delete(m.id)); }
|
|
284
|
+
await p; tick();
|
|
285
|
+
}),
|
|
286
|
+
...textures.map(async (t) => { await pkg.getTexture(t.key, opts.textureSize ?? '1k', t.nativeSize); tick(); }),
|
|
287
|
+
]);
|
|
288
|
+
}
|
|
289
|
+
|
|
161
290
|
// ---------------------------------------------------------------- shape evaluation
|
|
162
291
|
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
|
163
292
|
|
|
@@ -290,7 +419,12 @@ export class HumanAvatar {
|
|
|
290
419
|
private exprDeltas: Float32Array[];
|
|
291
420
|
private skinMat: MeshStandardMaterial;
|
|
292
421
|
private version = 0;
|
|
293
|
-
|
|
422
|
+
/** Texture tier; set before the first setConfig (changing it later reloads textures). */
|
|
423
|
+
textureSize: TextureTier = '2k';
|
|
424
|
+
/** Anisotropic filtering for textures (1 = off). Lower is cheaper on phones. */
|
|
425
|
+
anisotropy = 4;
|
|
426
|
+
/** Load clothing normal maps. Turn off for lighter mobile materials. */
|
|
427
|
+
normalMaps = true;
|
|
294
428
|
|
|
295
429
|
constructor(pkg: HumanPackage) {
|
|
296
430
|
this.pkg = pkg;
|
|
@@ -353,8 +487,8 @@ export class HumanAvatar {
|
|
|
353
487
|
return [slot, await this.loadProxy(id)] as const;
|
|
354
488
|
});
|
|
355
489
|
const sizes = this.pkg.manifest.textureSizes;
|
|
356
|
-
const skin = this.texture(`skins/${c.skin}
|
|
357
|
-
const eyes = this.texture(`eyes/${c.eyes}
|
|
490
|
+
const skin = this.texture(`skins/${c.skin}`, sizes.skin2k);
|
|
491
|
+
const eyes = this.texture(`eyes/${c.eyes}`, sizes.eyes);
|
|
358
492
|
const loaded = await Promise.all(loads);
|
|
359
493
|
const [skinTex, eyeTex] = await Promise.all([skin, eyes]);
|
|
360
494
|
if (v !== this.version) return; // a newer setConfig superseded this one
|
|
@@ -366,14 +500,17 @@ export class HumanAvatar {
|
|
|
366
500
|
this.group.add(entry[1].mesh);
|
|
367
501
|
if (entry[1].soft) this.group.add(entry[1].soft);
|
|
368
502
|
}
|
|
369
|
-
|
|
503
|
+
// only touch the material when the texture really changed (slider edits keep the same one)
|
|
504
|
+
if (this.skinMat.map !== skinTex) {
|
|
505
|
+
if (!this.skinMat.map) this.skinMat.needsUpdate = true; // null -> texture changes the shader
|
|
506
|
+
this.skinMat.map = skinTex;
|
|
507
|
+
}
|
|
370
508
|
this.skinMat.color.set(c.skinTint);
|
|
371
|
-
this.skinMat.needsUpdate = true;
|
|
372
509
|
for (const [slot, p] of this.proxies) {
|
|
373
510
|
const m = p.mesh.material as MeshStandardMaterial;
|
|
374
511
|
if (slot === 'hair') { m.color.set(c.hairColor); (p.soft?.material as MeshStandardMaterial | undefined)?.color.set(c.hairColor); }
|
|
375
512
|
if (slot === 'eyebrows') m.color.set(c.eyebrowColor);
|
|
376
|
-
if (slot === 'eyes' && m.map !== eyeTex) { m.map =
|
|
513
|
+
if (slot === 'eyes' && m.map !== eyeTex) { if (!m.map) m.needsUpdate = true; m.map = eyeTex; }
|
|
377
514
|
if (p.meta.kind === 'clothes' && p.meta.tintable) m.color.set(c.garmentColors?.[clothingInfo(p.meta.id).slot] ?? p.meta.color ?? '#ffffff');
|
|
378
515
|
}
|
|
379
516
|
this.reshape();
|
|
@@ -381,6 +518,30 @@ export class HumanAvatar {
|
|
|
381
518
|
|
|
382
519
|
getConfig(): HumanConfig { return this.config; }
|
|
383
520
|
|
|
521
|
+
/**
|
|
522
|
+
* Re-apply textures after changing textureSize, anisotropy or normalMaps (meshes stay as they
|
|
523
|
+
* are; already-loaded files come from the cache).
|
|
524
|
+
*/
|
|
525
|
+
async reloadTextures() {
|
|
526
|
+
const c = this.config, sizes = this.pkg.manifest.textureSizes;
|
|
527
|
+
const v = this.version;
|
|
528
|
+
const [skin, eyes] = await Promise.all([this.texture(`skins/${c.skin}`, sizes.skin2k), this.texture(`eyes/${c.eyes}`, sizes.eyes)]);
|
|
529
|
+
const maps = await Promise.all([...this.proxies.values()].map(async (p) => [p,
|
|
530
|
+
p.meta.texture ? await this.texture(keyOf(p.meta.texture), p.meta.textureSize) : null,
|
|
531
|
+
p.meta.normalTexture && this.normalMaps ? await this.texture(keyOf(p.meta.normalTexture), 1024) : null] as const));
|
|
532
|
+
if (v !== this.version) return; // a setConfig happened meanwhile and used the new settings
|
|
533
|
+
this.skinMat.map = skin;
|
|
534
|
+
for (const [p, map, normal] of maps) {
|
|
535
|
+
for (const m of [p.mesh.material, p.soft?.material].filter(Boolean) as MeshStandardMaterial[]) {
|
|
536
|
+
const shaderChange = !!m.normalMap !== !!normal;
|
|
537
|
+
if (p.meta.tileable) for (const t of [map, normal]) if (t && t.wrapS !== RepeatWrapping) { t.wrapS = t.wrapT = RepeatWrapping; t.needsUpdate = true; }
|
|
538
|
+
if (p.meta.kind === 'eyes') m.map = eyes; else if (map) m.map = map;
|
|
539
|
+
m.normalMap = normal;
|
|
540
|
+
if (shaderChange) m.needsUpdate = true;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
384
545
|
/** Expression by ARKit-style name (e.g. 'jawOpen', 'mouthSmile') or MPFB unit name, weight 0..1. */
|
|
385
546
|
setExpression(name: string, weight: number) {
|
|
386
547
|
const unit = this.exprNames.findIndex((u) => u === name || this.alias(u) === name);
|
|
@@ -391,20 +552,11 @@ export class HumanAvatar {
|
|
|
391
552
|
}
|
|
392
553
|
get expressionNames() { return this.exprNames.map((u) => this.alias(u)); }
|
|
393
554
|
|
|
394
|
-
private texture(
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
tex.colorSpace = file.endsWith('_n.webp') ? '' : SRGBColorSpace;
|
|
400
|
-
tex.flipY = false;
|
|
401
|
-
tex.anisotropy = 4;
|
|
402
|
-
tex.needsUpdate = true;
|
|
403
|
-
return tex;
|
|
404
|
-
});
|
|
405
|
-
this.pkg.textureCache.set(url, t);
|
|
406
|
-
}
|
|
407
|
-
return t;
|
|
555
|
+
private texture(key: string, nativeSize: number): Promise<Texture> {
|
|
556
|
+
return this.pkg.getTexture(key, this.textureSize, nativeSize).then((tex) => {
|
|
557
|
+
if (tex.anisotropy !== this.anisotropy) { tex.anisotropy = this.anisotropy; tex.needsUpdate = true; }
|
|
558
|
+
return tex;
|
|
559
|
+
});
|
|
408
560
|
}
|
|
409
561
|
|
|
410
562
|
private async loadProxy(fullId: string): Promise<ProxyInstance | null> {
|
|
@@ -417,8 +569,8 @@ export class HumanAvatar {
|
|
|
417
569
|
}
|
|
418
570
|
const [prim, map, normalMap] = await Promise.all([
|
|
419
571
|
p,
|
|
420
|
-
meta.texture ? this.texture(meta.texture, meta.textureSize) : null,
|
|
421
|
-
meta.normalTexture ? this.texture(meta.normalTexture, 1024) : null,
|
|
572
|
+
meta.texture ? this.texture(keyOf(meta.texture), meta.textureSize) : null,
|
|
573
|
+
meta.normalTexture && this.normalMaps ? this.texture(keyOf(meta.normalTexture), 1024) : null,
|
|
422
574
|
]);
|
|
423
575
|
const geo = geometryFrom(prim, '_JOINTS', '_WEIGHTS');
|
|
424
576
|
const refs = asU16(prim.attributes._REFS), refw = toFloat(prim.attributes._REFW), offs = toFloat(prim.attributes._OFFS);
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// <HumanEditor pkg={pkg} options={{ gender: 'female', exclude: ['ears'] }} onSave={(config, png) => save(config)} />
|
|
5
5
|
import React, { useMemo, useRef, useState } from 'react';
|
|
6
|
-
import { LayoutChangeEvent, PanResponder, Pressable, ScrollView, StyleSheet, Text, View, ViewStyle, StyleProp } from 'react-native';
|
|
6
|
+
import { Image, LayoutChangeEvent, PanResponder, Pressable, ScrollView, StyleSheet, Text, View, ViewStyle, StyleProp } from 'react-native';
|
|
7
7
|
import type { HumanConfig, HumanPackage } from '../human/runtime';
|
|
8
8
|
import { EditorControl, HumanEditorModel as EditorModel, HumanEditorOptions, createHumanEditor } from '../human/editor';
|
|
9
9
|
import { HumanView, HumanViewHandle } from './HumanView';
|
|
@@ -32,7 +32,7 @@ export function HumanEditor({
|
|
|
32
32
|
pkg, options, editor: given, value, defaultValue, onChange, onSave, onCancel, saveLabel = 'Save', randomize = true,
|
|
33
33
|
accentColor = '#1d1d1f', background = '#e9ecf1', style,
|
|
34
34
|
}: HumanEditorProps) {
|
|
35
|
-
const editor = useMemo(() => given ?? createHumanEditor(pkg
|
|
35
|
+
const editor = useMemo(() => given ?? createHumanEditor(pkg, options), [given, pkg, JSON.stringify(options)]);
|
|
36
36
|
const [inner, setInner] = useState<HumanConfig>(() => editor.normalize(defaultValue ?? null));
|
|
37
37
|
const config = value ? editor.normalize(value) : inner;
|
|
38
38
|
const [tab, setTab] = useState(0);
|
|
@@ -110,15 +110,31 @@ function Control({ ctl, config, onChange, accent }: { ctl: EditorControl; config
|
|
|
110
110
|
);
|
|
111
111
|
}
|
|
112
112
|
const v = ctl.value(config);
|
|
113
|
+
if (ctl.display === 'swatch') {
|
|
114
|
+
return (
|
|
115
|
+
<View style={s.block}>
|
|
116
|
+
<Text style={s.blockLabel}>{ctl.label}</Text>
|
|
117
|
+
<View style={s.wrap}>
|
|
118
|
+
{ctl.options.map((o) => (
|
|
119
|
+
<Pressable key={o.id ?? 'none'} accessibilityLabel={o.label} onPress={() => onChange(ctl.apply(config, o.id))}
|
|
120
|
+
style={[s.swatch, { backgroundColor: o.swatch ?? '#eee' }, o.id === v && { borderColor: accent }]}>
|
|
121
|
+
{o.preview ? <Image source={{ uri: o.preview }} style={s.swatchImg} /> : null}
|
|
122
|
+
</Pressable>
|
|
123
|
+
))}
|
|
124
|
+
</View>
|
|
125
|
+
</View>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
113
128
|
return (
|
|
114
129
|
<View style={s.block}>
|
|
115
130
|
<Text style={s.blockLabel}>{ctl.label}</Text>
|
|
116
131
|
<View style={s.wrap}>
|
|
117
132
|
{ctl.options.map((o) => (
|
|
118
|
-
<Pressable key={o.id ?? 'none'} onPress={() => onChange(ctl.apply(config, o.id))}
|
|
119
|
-
style={[s.
|
|
120
|
-
{o.
|
|
121
|
-
|
|
133
|
+
<Pressable key={o.id ?? 'none'} accessibilityLabel={o.label} onPress={() => onChange(ctl.apply(config, o.id))}
|
|
134
|
+
style={[s.tile, o.id === v && { borderColor: accent, borderWidth: 2, backgroundColor: '#fff' }]}>
|
|
135
|
+
{o.id === null ? <View style={s.none}><View style={s.noneBar} /></View>
|
|
136
|
+
: o.preview ? <Image source={{ uri: o.preview }} style={s.tileImg} resizeMode="contain" />
|
|
137
|
+
: <Text style={s.choiceText}>{o.label}</Text>}
|
|
122
138
|
</Pressable>
|
|
123
139
|
))}
|
|
124
140
|
</View>
|
|
@@ -173,10 +189,13 @@ const s = StyleSheet.create({
|
|
|
173
189
|
block: { marginVertical: 10 },
|
|
174
190
|
blockLabel: { fontSize: 13, fontWeight: '600', color: '#333', marginBottom: 8 },
|
|
175
191
|
wrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
192
|
+
tile: { width: 76, height: 76, borderRadius: 12, borderWidth: 1, borderColor: '#e1e3e7', backgroundColor: '#f4f5f7', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', padding: 3 },
|
|
193
|
+
tileImg: { width: '100%', height: '100%' },
|
|
194
|
+
none: { width: 26, height: 26, borderRadius: 13, borderWidth: 2, borderColor: '#9aa0a8', alignItems: 'center', justifyContent: 'center' },
|
|
195
|
+
noneBar: { width: 30, height: 2, backgroundColor: '#9aa0a8', transform: [{ rotate: '-45deg' }] },
|
|
196
|
+
choiceText: { fontSize: 11, color: '#555', textAlign: 'center' },
|
|
197
|
+
swatch: { width: 36, height: 36, borderRadius: 18, borderWidth: 3, borderColor: '#fff', overflow: 'hidden', shadowColor: '#000', shadowOpacity: 0.15, shadowRadius: 2, shadowOffset: { width: 0, height: 1 }, elevation: 1 },
|
|
198
|
+
swatchImg: { width: '100%', height: '100%' },
|
|
180
199
|
slider: { flex: 1, height: 32, justifyContent: 'center' },
|
|
181
200
|
track: { height: 4, borderRadius: 2, backgroundColor: '#e3e5e8', overflow: 'hidden' },
|
|
182
201
|
fill: { height: 4 },
|
package/src/native/HumanView.tsx
CHANGED
|
@@ -1,31 +1,71 @@
|
|
|
1
1
|
// <HumanView>: realistic MakeHuman-based avatar for React Native (expo-gl + three).
|
|
2
2
|
// Renders on demand only; a static avatar costs no GPU after the first frame.
|
|
3
3
|
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
|
4
|
-
import { PanResponder, StyleProp, View, ViewStyle } from 'react-native';
|
|
4
|
+
import { LayoutChangeEvent, PanResponder, PixelRatio, StyleProp, View, ViewStyle } from 'react-native';
|
|
5
5
|
import { ExpoWebGLRenderingContext, GLView } from 'expo-gl';
|
|
6
6
|
import {
|
|
7
7
|
ACESFilmicToneMapping, Color, DirectionalLight, HemisphereLight, PerspectiveCamera, Scene, SRGBColorSpace, Vector3, WebGLRenderer,
|
|
8
8
|
} from 'three';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
HumanAvatar, HumanConfig, HumanPackage, LoadOptions, PreloadOptions, TextureTier, loadHumanPackage, warmHumanPackage,
|
|
11
|
+
} from '../human/runtime';
|
|
10
12
|
import { expoLoadOptions } from './humanExpo';
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
export interface UseHumanPackageOptions extends Omit<PreloadOptions, keyof LoadOptions | 'onProgress'> {
|
|
15
|
+
/** Host of the mobile texture pack, if not next to the manifest or in manifest.mobileTextures.baseUrl. */
|
|
16
|
+
mobileBaseUrl?: string;
|
|
17
|
+
/** Also download this look's meshes and textures before `ready` (default: true, with DEFAULT_HUMAN). */
|
|
18
|
+
preload?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Preload on Expo: loads the package with the disk cache and JPEG/PNG textures, then downloads
|
|
23
|
+
* everything `config` needs for its first textured frame. Resolves when those files are on disk.
|
|
24
|
+
*
|
|
25
|
+
* await preloadHumanPackage(ASSETS, { config: savedAvatar, textureSize: '1k' }); // e.g. during splash
|
|
26
|
+
*/
|
|
27
|
+
export function preloadHumanPackage(baseUrl: string, opts: Omit<PreloadOptions, keyof LoadOptions> & { mobileBaseUrl?: string } = {}) {
|
|
28
|
+
return getPackage(baseUrl, opts.mobileBaseUrl).then(async (pkg) => { await warmHumanPackage(pkg, opts); return pkg; });
|
|
29
|
+
}
|
|
30
|
+
|
|
13
31
|
const packages = new Map<string, Promise<HumanPackage>>();
|
|
14
|
-
|
|
32
|
+
function getPackage(baseUrl: string, mobileBaseUrl?: string) {
|
|
33
|
+
let p = packages.get(baseUrl);
|
|
34
|
+
if (!p) {
|
|
35
|
+
p = loadHumanPackage(baseUrl, { ...expoLoadOptions, mobileBaseUrl });
|
|
36
|
+
packages.set(baseUrl, p);
|
|
37
|
+
p.catch(() => packages.delete(baseUrl));
|
|
38
|
+
}
|
|
39
|
+
return p;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Load (and disk-cache) the human package once per base URL. `ready` turns true when the first
|
|
44
|
+
* textured frame can be painted: manifest, base mesh, and (unless `preload: false`) the meshes and
|
|
45
|
+
* textures of `config`, all on disk. `progress` is 0..1 while preloading.
|
|
46
|
+
*/
|
|
47
|
+
export function useHumanPackage(baseUrl: string, opts: UseHumanPackageOptions = {}) {
|
|
15
48
|
const [pkg, setPkg] = useState<HumanPackage | null>(null);
|
|
49
|
+
const [ready, setReady] = useState(false);
|
|
50
|
+
const [progress, setProgress] = useState(0);
|
|
16
51
|
const [error, setError] = useState<Error | null>(null);
|
|
52
|
+
const key = JSON.stringify([opts.config ?? null, opts.textureSize ?? '1k', opts.normalMaps ?? true, opts.preload ?? true]);
|
|
17
53
|
useEffect(() => {
|
|
18
|
-
let p = packages.get(baseUrl);
|
|
19
|
-
if (!p) {
|
|
20
|
-
p = loadHumanPackage(baseUrl, expoLoadOptions);
|
|
21
|
-
packages.set(baseUrl, p);
|
|
22
|
-
p.catch(() => packages.delete(baseUrl));
|
|
23
|
-
}
|
|
24
54
|
let live = true;
|
|
25
|
-
|
|
55
|
+
setReady(false);
|
|
56
|
+
getPackage(baseUrl, opts.mobileBaseUrl)
|
|
57
|
+
.then(async (p) => {
|
|
58
|
+
if (!live) return;
|
|
59
|
+
setPkg(p);
|
|
60
|
+
if (opts.preload !== false) {
|
|
61
|
+
await warmHumanPackage(p, { ...opts, onProgress: (d, t) => live && setProgress(d / t) });
|
|
62
|
+
}
|
|
63
|
+
if (live) { setProgress(1); setReady(true); }
|
|
64
|
+
})
|
|
65
|
+
.catch((e) => live && setError(e));
|
|
26
66
|
return () => { live = false; };
|
|
27
|
-
}, [baseUrl]);
|
|
28
|
-
return { pkg, error };
|
|
67
|
+
}, [baseUrl, key]);
|
|
68
|
+
return { pkg, ready, progress, error };
|
|
29
69
|
}
|
|
30
70
|
|
|
31
71
|
export interface HumanViewProps {
|
|
@@ -36,9 +76,21 @@ export interface HumanViewProps {
|
|
|
36
76
|
expressions?: Record<string, number>;
|
|
37
77
|
background?: string;
|
|
38
78
|
rotatable?: boolean;
|
|
39
|
-
/**
|
|
40
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Texture tier. Default '1k': at face/bust framing on a phone it looks like web '2k'.
|
|
81
|
+
* '512' for lists and avatars under ~120 pt; '2k' only for full-screen close-ups.
|
|
82
|
+
*/
|
|
83
|
+
textureSize?: TextureTier;
|
|
84
|
+
/** Render resolution in pixels per point. Default: the device's (max 3). 2 is a good speed/quality balance. */
|
|
85
|
+
pixelRatio?: number;
|
|
86
|
+
/** MSAA. Default true (expo-gl multisampling on iOS, three's antialias elsewhere). */
|
|
87
|
+
antialias?: boolean;
|
|
88
|
+
/** Anisotropic texture filtering. Default 2; 1 turns it off. */
|
|
89
|
+
maxTextureAnisotropy?: number;
|
|
90
|
+
/** Clothing normal maps. Default true; false for lighter materials on low-end phones. */
|
|
91
|
+
normalMaps?: boolean;
|
|
41
92
|
style?: StyleProp<ViewStyle>;
|
|
93
|
+
/** Called once, after the first frame with all textures has been drawn. */
|
|
42
94
|
onReady?: (avatar: HumanAvatar) => void;
|
|
43
95
|
}
|
|
44
96
|
|
|
@@ -49,12 +101,21 @@ export interface HumanViewHandle {
|
|
|
49
101
|
}
|
|
50
102
|
|
|
51
103
|
export const HumanView = forwardRef<HumanViewHandle, HumanViewProps>(function HumanView(
|
|
52
|
-
{
|
|
104
|
+
{
|
|
105
|
+
pkg, config, framing = 'face', expressions, background = '#e9ecf1', rotatable = true, textureSize = '1k',
|
|
106
|
+
pixelRatio, antialias = true, maxTextureAnisotropy = 2, normalMaps = true, style, onReady,
|
|
107
|
+
}, ref,
|
|
53
108
|
) {
|
|
54
109
|
const gl = useRef<ExpoWebGLRenderingContext | null>(null);
|
|
55
110
|
const renderer = useRef<WebGLRenderer | null>(null);
|
|
56
111
|
const yaw = useRef(0);
|
|
57
112
|
const pending = useRef(false);
|
|
113
|
+
const textured = useRef(false); // config applied with all textures loaded
|
|
114
|
+
const readyFired = useRef(false);
|
|
115
|
+
const readyCb = useRef(onReady);
|
|
116
|
+
readyCb.current = onReady;
|
|
117
|
+
const [layout, setLayout] = useState<{ w: number; h: number } | null>(null);
|
|
118
|
+
|
|
58
119
|
const stage = useMemo(() => {
|
|
59
120
|
const scene = new Scene();
|
|
60
121
|
scene.background = new Color(background);
|
|
@@ -65,21 +126,39 @@ export const HumanView = forwardRef<HumanViewHandle, HumanViewProps>(function Hu
|
|
|
65
126
|
scene.add(key, rim, new HemisphereLight('#ffffff', '#b9a99a', 1.1));
|
|
66
127
|
const avatar = new HumanAvatar(pkg);
|
|
67
128
|
avatar.textureSize = textureSize;
|
|
129
|
+
avatar.anisotropy = maxTextureAnisotropy;
|
|
130
|
+
avatar.normalMaps = normalMaps;
|
|
68
131
|
scene.add(avatar.group);
|
|
132
|
+
textured.current = false;
|
|
133
|
+
readyFired.current = false;
|
|
69
134
|
return { scene, camera: new PerspectiveCamera(24, 1, 0.05, 50), avatar };
|
|
70
135
|
}, [pkg]);
|
|
71
136
|
|
|
137
|
+
useEffect(() => { (stage.scene.background as Color).set(background); requestRender(); }, [background]);
|
|
138
|
+
|
|
72
139
|
const draw = () => {
|
|
73
|
-
|
|
140
|
+
const ctx = gl.current, r = renderer.current;
|
|
141
|
+
if (!ctx || !r) return;
|
|
74
142
|
const { camera, avatar } = stage;
|
|
143
|
+
const w = ctx.drawingBufferWidth, h = ctx.drawingBufferHeight;
|
|
144
|
+
if (r.domElement.width !== w || r.domElement.height !== h) {
|
|
145
|
+
r.domElement.width = w; r.domElement.height = h;
|
|
146
|
+
r.setSize(w, h, false);
|
|
147
|
+
camera.aspect = w / h;
|
|
148
|
+
camera.updateProjectionMatrix();
|
|
149
|
+
}
|
|
75
150
|
const m = avatar.metrics();
|
|
76
151
|
const target = framing === 'face' ? new Vector3(0, m.headY - 0.08, 0) : new Vector3(0, m.height * 0.52, 0);
|
|
77
|
-
const
|
|
78
|
-
const dist =
|
|
152
|
+
const vh = framing === 'face' ? 0.42 : m.height * 1.12;
|
|
153
|
+
const dist = vh / 2 / Math.tan((camera.fov * Math.PI) / 360) / Math.min(1, camera.aspect * 1.1);
|
|
79
154
|
camera.position.set(Math.sin(yaw.current) * dist, target.y + dist * 0.03, Math.cos(yaw.current) * dist);
|
|
80
155
|
camera.lookAt(target);
|
|
81
|
-
|
|
82
|
-
|
|
156
|
+
r.render(stage.scene, camera);
|
|
157
|
+
ctx.endFrameEXP();
|
|
158
|
+
if (textured.current && !readyFired.current) {
|
|
159
|
+
readyFired.current = true;
|
|
160
|
+
readyCb.current?.(avatar);
|
|
161
|
+
}
|
|
83
162
|
};
|
|
84
163
|
const requestRender = () => {
|
|
85
164
|
if (pending.current) return;
|
|
@@ -90,7 +169,7 @@ export const HumanView = forwardRef<HumanViewHandle, HumanViewProps>(function Hu
|
|
|
90
169
|
const onContextCreate = (ctx: ExpoWebGLRenderingContext) => {
|
|
91
170
|
const w = ctx.drawingBufferWidth, h = ctx.drawingBufferHeight;
|
|
92
171
|
const canvas = { width: w, height: h, style: {}, clientWidth: w, clientHeight: h, addEventListener: () => {}, removeEventListener: () => {}, getContext: () => ctx };
|
|
93
|
-
const r = new WebGLRenderer({ canvas: canvas as any, context: ctx as any, antialias
|
|
172
|
+
const r = new WebGLRenderer({ canvas: canvas as any, context: ctx as any, antialias });
|
|
94
173
|
r.setPixelRatio(1);
|
|
95
174
|
r.setSize(w, h, false);
|
|
96
175
|
r.outputColorSpace = SRGBColorSpace;
|
|
@@ -102,17 +181,28 @@ export const HumanView = forwardRef<HumanViewHandle, HumanViewProps>(function Hu
|
|
|
102
181
|
requestRender();
|
|
103
182
|
};
|
|
104
183
|
|
|
184
|
+
// Shape/color changes reuse the cached textures; only a new item or tier loads files.
|
|
105
185
|
const key = JSON.stringify(config);
|
|
106
186
|
useEffect(() => {
|
|
107
187
|
let live = true;
|
|
108
188
|
stage.avatar.setConfig(config).then(() => {
|
|
109
189
|
if (!live) return;
|
|
190
|
+
textured.current = true;
|
|
110
191
|
requestRender();
|
|
111
|
-
onReady?.(stage.avatar);
|
|
112
192
|
});
|
|
113
193
|
return () => { live = false; };
|
|
114
194
|
}, [key, stage]);
|
|
115
195
|
|
|
196
|
+
// quality knobs that need the textures reapplied
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
const a = stage.avatar;
|
|
199
|
+
if (a.textureSize === textureSize && a.anisotropy === maxTextureAnisotropy && a.normalMaps === normalMaps) return;
|
|
200
|
+
a.textureSize = textureSize;
|
|
201
|
+
a.anisotropy = maxTextureAnisotropy;
|
|
202
|
+
a.normalMaps = normalMaps;
|
|
203
|
+
a.reloadTextures().then(requestRender);
|
|
204
|
+
}, [textureSize, maxTextureAnisotropy, normalMaps, stage]);
|
|
205
|
+
|
|
116
206
|
useEffect(() => {
|
|
117
207
|
for (const [k, v] of Object.entries(expressions ?? {})) stage.avatar.setExpression(k, v);
|
|
118
208
|
requestRender();
|
|
@@ -141,9 +231,23 @@ export const HumanView = forwardRef<HumanViewHandle, HumanViewProps>(function Hu
|
|
|
141
231
|
},
|
|
142
232
|
}));
|
|
143
233
|
|
|
234
|
+
// expo-gl sizes its buffer to (view size x device scale). For a lower pixelRatio, render a
|
|
235
|
+
// smaller GLView and scale it up to fill the box.
|
|
236
|
+
const device = Math.min(3, PixelRatio.get());
|
|
237
|
+
const k = pixelRatio && pixelRatio < device ? pixelRatio / device : 1;
|
|
238
|
+
const onLayout = (e: LayoutChangeEvent) => setLayout({ w: e.nativeEvent.layout.width, h: e.nativeEvent.layout.height });
|
|
239
|
+
const glStyle: StyleProp<ViewStyle> = k === 1 || !layout
|
|
240
|
+
? { flex: 1 }
|
|
241
|
+
: {
|
|
242
|
+
position: 'absolute', width: layout.w * k, height: layout.h * k,
|
|
243
|
+
left: (layout.w - layout.w * k) / 2, top: (layout.h - layout.h * k) / 2, transform: [{ scale: 1 / k }],
|
|
244
|
+
};
|
|
245
|
+
|
|
144
246
|
return (
|
|
145
|
-
<View style={[{ overflow: 'hidden' }, style]} {...(rotatable ? pan.panHandlers : null)}>
|
|
146
|
-
|
|
247
|
+
<View style={[{ overflow: 'hidden' }, style]} onLayout={onLayout} {...(rotatable ? pan.panHandlers : null)}>
|
|
248
|
+
{k === 1 || layout ? (
|
|
249
|
+
<GLView style={glStyle} msaaSamples={antialias ? 4 : 0} onContextCreate={onContextCreate} />
|
|
250
|
+
) : null}
|
|
147
251
|
</View>
|
|
148
252
|
);
|
|
149
253
|
});
|