humanforge 0.4.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 CHANGED
@@ -33,6 +33,7 @@ npm run build:human -- mpfb2/src/mpfb/data mh_assets \
33
33
  --extra mh_hair01 --extra mh_dress01 --extra mh_suits01 # -> assets/human (~26 MB, all options)
34
34
  npm run build:garments # + modest wear and dresses (below)
35
35
  npm run build:previews # thumbnails + swatches for every option (needs Chrome)
36
+ npm run build:mobile -- --skin-2k # JPEG/PNG texture pack for Expo (no WebP in expo-gl)
36
37
  ```
37
38
 
38
39
  Leave out the `--extra` packs for the system set only (~14 MB). Every item loads on demand, so
@@ -63,24 +64,83 @@ npx expo install expo-gl expo-file-system three fflate
63
64
  ```tsx
64
65
  import { HumanView, useHumanPackage } from 'humanforge';
65
66
 
66
- const { pkg } = useHumanPackage('https://cdn.example.com/human');
67
- if (!pkg) return <Loading />;
67
+ const ASSETS = 'https://cdn.jsdelivr.net/gh/abdulrehman043/humanforge@v0.5.0/assets/human';
68
+
69
+ function Avatar({ config }) {
70
+ // ready = the first textured frame can be painted (manifest, body, and this look's
71
+ // hair/clothes/skin/eyes are on disk), not just "manifest loaded"
72
+ const { pkg, ready, progress } = useHumanPackage(ASSETS, { config, textureSize: '1k' });
73
+ if (!pkg || !ready) return <Loading progress={progress} />;
74
+ return (
75
+ <HumanView
76
+ pkg={pkg}
77
+ config={config}
78
+ framing="face" // or "body"
79
+ textureSize="1k" // see the table below
80
+ expressions={{ mouthSmile: 0.6 }}
81
+ onReady={() => SplashScreen.hideAsync()} // fires once, after the first textured frame
82
+ style={{ width: 240, height: 240, borderRadius: 120 }}
83
+ />
84
+ );
85
+ }
86
+ ```
68
87
 
69
- <HumanView
70
- pkg={pkg}
71
- framing="face" // or "body"
72
- textureSize="1k" // "2k" for the editor screen
73
- config={{ gender: 0.1, age: 0.2, african: 1, asian: 0, caucasian: 0,
74
- skin: 'young_african_female', hair: 'afro01', hairColor: '#1a120c',
75
- detail: { noseWidth: 0.3, eyeSize: 0.2 },
76
- clothes: ['female_casualsuit01', 'shoes01'] }}
77
- expressions={{ mouthSmile: 0.6 }}
78
- style={{ width: 240, height: 240, borderRadius: 120 }}
79
- />
88
+ A ref gives you `avatar` (bones, `setExpression`), `render()` and `snapshot()` (PNG file URI).
89
+
90
+ #### Textures on Expo: the mobile pack
91
+
92
+ expo-gl uploads images through stb_image, which **can't decode WebP**: a WebP texture leaves the
93
+ mesh black with only the lights showing. The asset package therefore ships a **mobile texture
94
+ pack** with the same textures as baseline JPEG (opaque) and paletted PNG (hair, lashes, lace),
95
+ in three tiers. `useHumanPackage`, `preloadHumanPackage` and `expoLoadOptions` select it
96
+ automatically (`textureFormat: 'mobile'`); the web keeps using WebP. Nothing is converted on the
97
+ device, and nothing large goes into your app or the npm package: files download from the CDN
98
+ on demand and stay in the app's cache directory (offline on later launches).
99
+
100
+ | `textureSize` | Max edge | Use for | First-look download* |
101
+ | --- | --- | --- | --- |
102
+ | `'512'` | 512 px | lists, chat bubbles, avatars under ~120 pt | ~0.8 MB |
103
+ | `'1k'` (default) | 1024 px | profile, bust/face framing; at phone sizes this matches web 2k | ~1.2 MB |
104
+ | `'2k'` | 2048 px (skins) | full-screen face close-ups, tablets | ~1.3 MB |
105
+
106
+ \* meshes + textures of a default look (hair, outfit, shoes, skin, eyes, brows, lashes); the
107
+ shared body and shape data add ~1.45 MB once. Brows, lashes and eye textures are small at every
108
+ tier; textures are never upscaled.
109
+
110
+ On the jsDelivr setup the pack lives on its own git tag (`mobile-v0.5.0`), because jsDelivr serves
111
+ GitHub versions only up to 50 MB; `manifest.mobileTextures.baseUrl` points there, so you only pass
112
+ the main URL. Self-hosting: run `npm run build:mobile` and upload `assets/human/mobile/` next to
113
+ `manifest.json` (or pass `mobileBaseUrl`).
114
+
115
+ #### Preload during the splash screen
116
+
117
+ ```tsx
118
+ import { preloadHumanPackage } from 'humanforge';
119
+
120
+ // resolves when the files are on disk, so the first <HumanView> paints textured at once
121
+ await preloadHumanPackage(ASSETS, { config: savedAvatar, textureSize: '1k', onProgress: (done, total) => {} });
80
122
  ```
81
123
 
82
- Files are cached in the app's cache directory, so later launches work offline. A ref gives you
83
- `avatar` (bones, `setExpression`), `render()` and `snapshot()` (PNG file URI).
124
+ It shares its cache with `useHumanPackage` (same URL = same package, no second download).
125
+
126
+ #### Performance knobs
127
+
128
+ | `HumanView` prop | Default | Notes |
129
+ | --- | --- | --- |
130
+ | `textureSize` | `'1k'` | tiers above |
131
+ | `pixelRatio` | device (max 3) | `2` on 3x phones renders ~55% fewer pixels and still looks sharp |
132
+ | `antialias` | `true` | MSAA (expo-gl `msaaSamples` on iOS) |
133
+ | `maxTextureAnisotropy` | `2` | `1` turns it off |
134
+ | `normalMaps` | `true` | `false` skips clothing normal maps (lighter, slightly flatter fabric) |
135
+
136
+ Slider, color and expression changes reuse the textures already on the GPU: only a new item
137
+ (hair, outfit, skin tone) or a new tier loads files. `onReady` fires once per avatar, after the
138
+ first frame with all textures; it does not fire again on later edits.
139
+
140
+ Recommended: editor screen `textureSize="1k"` (or `'2k'` on tablets), lists and thumbnails
141
+ `'512'` with `pixelRatio={2}`. For option pickers, use the static preview images and swatches
142
+ (`getHumanCatalog`, below) rather than live 3D views: they are small WebP files shown with
143
+ `<Image>`, which uses the platform decoder (WebP is fine there).
84
144
 
85
145
  ### 3. React / web
86
146
 
@@ -111,7 +171,7 @@ import { HumanEditor, useHumanPackage } from 'humanforge/web';
111
171
  // React Native (Expo)
112
172
  import { HumanEditor, useHumanPackage } from 'humanforge/native';
113
173
 
114
- const ASSETS = 'https://cdn.jsdelivr.net/gh/abdulrehman043/humanforge@v0.4.0/assets/human';
174
+ const ASSETS = 'https://cdn.jsdelivr.net/gh/abdulrehman043/humanforge@v0.5.0/assets/human';
115
175
  const { pkg } = useHumanPackage(ASSETS);
116
176
 
117
177
  <HumanEditor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanforge",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Open-source avatar SDK for React and React Native: realistic MakeHuman-based humans (CC0) plus a tiny procedural low-poly mode.",
5
5
  "license": "MIT",
6
6
  "author": "abdulrehman043",
@@ -52,7 +52,8 @@
52
52
  "typecheck": "tsc --noEmit",
53
53
  "test": "tsx scripts/smoke.ts && tsx scripts/human-smoke.mts && tsx scripts/fit-test.mts && tsx scripts/editor-test.mts",
54
54
  "build:facemap": "tsx tools/build-facemap.mts",
55
- "build:previews": "tsx tools/build-previews.mts"
55
+ "build:previews": "tsx tools/build-previews.mts",
56
+ "build:mobile": "tsx tools/build-mobile-textures.mts"
56
57
  },
57
58
  "peerDependencies": {
58
59
  "@gltf-transform/core": ">=4",
@@ -82,6 +82,17 @@ export interface HumanManifest {
82
82
  /** Iris color and round iris thumbnail per eye color (tools/build-previews.mts). */
83
83
  eyeOptions?: Record<string, { swatch: string; preview: string }>;
84
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
+ };
85
96
  expressions: Record<string, string>;
86
97
  }
87
98
  interface Morph { idx: Uint16Array; d: Int16Array; scale: number }
@@ -89,6 +100,13 @@ interface Morph { idx: Uint16Array; d: Int16Array; scale: number }
89
100
  /** Loads a texture from a URL. `size` is the known square size (React Native needs it up front). */
90
101
  export type HumanTextureLoader = (url: string, size: number) => Promise<Texture>;
91
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
+
92
110
  export const webTextureLoader: HumanTextureLoader = async (url) => new TextureLoader().loadAsync(url);
93
111
 
94
112
  export interface HumanPackage {
@@ -101,12 +119,21 @@ export interface HumanPackage {
101
119
  fetchBinary: (url: string) => Promise<ArrayBuffer>;
102
120
  proxyCache: Map<string, Promise<GlbPrimitive>>;
103
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>;
104
127
  }
105
128
 
106
129
  export interface LoadOptions {
107
130
  loadTexture?: HumanTextureLoader;
108
131
  /** Override fetching (e.g. for an offline cache). Must return the raw (still gzipped) bytes. */
109
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;
110
137
  }
111
138
 
112
139
  /** Undo build-human's wrapping delta coding + byte planes. */
@@ -157,12 +184,109 @@ export async function loadHumanPackage(baseUrl: string, opts: LoadOptions = {}):
157
184
  }
158
185
  morphs.set(m.name, { idx, d, scale: m.scale });
159
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
+ }
160
250
  return {
161
- baseUrl: root, manifest, base, morphs, body: readGlb(bodyBuf), loadTexture: opts.loadTexture ?? webTextureLoader,
162
- fetchBinary, proxyCache: new Map(), textureCache: new Map(),
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) })),
163
253
  };
164
254
  }
165
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
+
166
290
  // ---------------------------------------------------------------- shape evaluation
167
291
  const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
168
292
 
@@ -295,7 +419,12 @@ export class HumanAvatar {
295
419
  private exprDeltas: Float32Array[];
296
420
  private skinMat: MeshStandardMaterial;
297
421
  private version = 0;
298
- textureSize: '1k' | '2k' = '2k';
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;
299
428
 
300
429
  constructor(pkg: HumanPackage) {
301
430
  this.pkg = pkg;
@@ -358,8 +487,8 @@ export class HumanAvatar {
358
487
  return [slot, await this.loadProxy(id)] as const;
359
488
  });
360
489
  const sizes = this.pkg.manifest.textureSizes;
361
- const skin = this.texture(`skins/${c.skin}_${this.textureSize}.webp`, this.textureSize === '2k' ? sizes.skin2k : sizes.skin1k);
362
- const eyes = this.texture(`eyes/${c.eyes}.webp`, sizes.eyes);
490
+ const skin = this.texture(`skins/${c.skin}`, sizes.skin2k);
491
+ const eyes = this.texture(`eyes/${c.eyes}`, sizes.eyes);
363
492
  const loaded = await Promise.all(loads);
364
493
  const [skinTex, eyeTex] = await Promise.all([skin, eyes]);
365
494
  if (v !== this.version) return; // a newer setConfig superseded this one
@@ -371,14 +500,17 @@ export class HumanAvatar {
371
500
  this.group.add(entry[1].mesh);
372
501
  if (entry[1].soft) this.group.add(entry[1].soft);
373
502
  }
374
- this.skinMat.map = skinTex;
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
+ }
375
508
  this.skinMat.color.set(c.skinTint);
376
- this.skinMat.needsUpdate = true;
377
509
  for (const [slot, p] of this.proxies) {
378
510
  const m = p.mesh.material as MeshStandardMaterial;
379
511
  if (slot === 'hair') { m.color.set(c.hairColor); (p.soft?.material as MeshStandardMaterial | undefined)?.color.set(c.hairColor); }
380
512
  if (slot === 'eyebrows') m.color.set(c.eyebrowColor);
381
- if (slot === 'eyes' && m.map !== eyeTex) { m.map = eyeTex; m.needsUpdate = true; }
513
+ if (slot === 'eyes' && m.map !== eyeTex) { if (!m.map) m.needsUpdate = true; m.map = eyeTex; }
382
514
  if (p.meta.kind === 'clothes' && p.meta.tintable) m.color.set(c.garmentColors?.[clothingInfo(p.meta.id).slot] ?? p.meta.color ?? '#ffffff');
383
515
  }
384
516
  this.reshape();
@@ -386,6 +518,30 @@ export class HumanAvatar {
386
518
 
387
519
  getConfig(): HumanConfig { return this.config; }
388
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
+
389
545
  /** Expression by ARKit-style name (e.g. 'jawOpen', 'mouthSmile') or MPFB unit name, weight 0..1. */
390
546
  setExpression(name: string, weight: number) {
391
547
  const unit = this.exprNames.findIndex((u) => u === name || this.alias(u) === name);
@@ -396,20 +552,11 @@ export class HumanAvatar {
396
552
  }
397
553
  get expressionNames() { return this.exprNames.map((u) => this.alias(u)); }
398
554
 
399
- private texture(file: string, size: number): Promise<Texture> {
400
- const url = `${this.pkg.baseUrl}/${file}`;
401
- let t = this.pkg.textureCache.get(url);
402
- if (!t) {
403
- t = this.pkg.loadTexture(url, size).then((tex) => {
404
- tex.colorSpace = file.endsWith('_n.webp') ? '' : SRGBColorSpace;
405
- tex.flipY = false;
406
- tex.anisotropy = 4;
407
- tex.needsUpdate = true;
408
- return tex;
409
- });
410
- this.pkg.textureCache.set(url, t);
411
- }
412
- 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
+ });
413
560
  }
414
561
 
415
562
  private async loadProxy(fullId: string): Promise<ProxyInstance | null> {
@@ -422,8 +569,8 @@ export class HumanAvatar {
422
569
  }
423
570
  const [prim, map, normalMap] = await Promise.all([
424
571
  p,
425
- meta.texture ? this.texture(meta.texture, meta.textureSize) : null,
426
- 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,
427
574
  ]);
428
575
  const geo = geometryFrom(prim, '_JOINTS', '_WEIGHTS');
429
576
  const refs = asU16(prim.attributes._REFS), refw = toFloat(prim.attributes._REFW), offs = toFloat(prim.attributes._OFFS);
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export * from './core';
2
2
  export * from './human';
3
3
  export * from './native';
4
+ // the root entry targets React Native: its preload uses the Expo disk cache and mobile textures
5
+ export { preloadHumanPackage } from './native';
@@ -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 { HumanAvatar, HumanConfig, HumanPackage, loadHumanPackage } from '../human/runtime';
9
+ import {
10
+ HumanAvatar, HumanConfig, HumanPackage, LoadOptions, PreloadOptions, TextureTier, loadHumanPackage, warmHumanPackage,
11
+ } from '../human/runtime';
10
12
  import { expoLoadOptions } from './humanExpo';
11
13
 
12
- /** Load (and disk-cache) the human package once per base URL. */
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
- export function useHumanPackage(baseUrl: string) {
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
- p.then((v) => live && setPkg(v), (e) => live && setError(e));
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
- /** '1k' textures for lists/low-end devices, '2k' for the editor. */
40
- textureSize?: '1k' | '2k';
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
- { pkg, config, framing = 'face', expressions, background = '#e9ecf1', rotatable = true, textureSize = '2k', style, onReady }, ref,
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
- if (!gl.current || !renderer.current) return;
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 h = framing === 'face' ? 0.42 : m.height * 1.12;
78
- const dist = h / 2 / Math.tan((camera.fov * Math.PI) / 360) / Math.min(1, camera.aspect * 1.1);
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
- renderer.current.render(stage.scene, camera);
82
- gl.current.endFrameEXP();
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: true });
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
- <GLView style={{ flex: 1 }} onContextCreate={onContextCreate} />
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
  });
@@ -1,5 +1,9 @@
1
1
  // React Native (Expo) adapters for the realistic human: a disk-cached fetcher and an
2
- // expo-gl texture loader. Pass both to loadHumanPackage (useHumanPackage does it for you).
2
+ // expo-gl texture loader. Pass expoLoadOptions to loadHumanPackage / preloadHumanPackage
3
+ // (useHumanPackage does it for you).
4
+ //
5
+ // expo-gl decodes images with stb_image, which has no WebP support, so these options select the
6
+ // JPEG/PNG mobile texture pack (manifest.mobileTextures, built by `npm run build:mobile`).
3
7
  import * as FileSystem from 'expo-file-system/legacy';
4
8
  import { Texture } from 'three';
5
9
  import type { HumanTextureLoader, LoadOptions } from '../human/runtime';
@@ -7,20 +11,34 @@ import type { HumanTextureLoader, LoadOptions } from '../human/runtime';
7
11
  const DIR = `${FileSystem.cacheDirectory ?? ''}humanforge/`;
8
12
  let dirReady: Promise<void> | null = null;
9
13
 
10
- /** Download once into the cache directory; later sessions load offline. */
11
- async function cached(url: string): Promise<string> {
12
- dirReady ??= FileSystem.makeDirectoryAsync(DIR, { intermediates: true }).catch(() => {});
13
- await dirReady;
14
- const file = DIR + url.replace(/^https?:\/\//, '').replace(/[^a-zA-Z0-9._-]/g, '_');
15
- const info = await FileSystem.getInfoAsync(file);
16
- if (!info.exists) {
17
- const res = await FileSystem.downloadAsync(url, file);
18
- if (res.status !== 200) {
19
- await FileSystem.deleteAsync(file, { idempotent: true });
20
- throw new Error(`humanforge: ${res.status} for ${url}`);
21
- }
14
+ const inflight = new Map<string, Promise<string>>();
15
+
16
+ /**
17
+ * Download once into the cache directory; later sessions load offline. Concurrent requests for
18
+ * the same URL share one download, and files are written to a temp name and moved into place, so
19
+ * an interrupted download never leaves a broken file in the cache.
20
+ */
21
+ export function cached(url: string): Promise<string> {
22
+ let p = inflight.get(url);
23
+ if (!p) {
24
+ p = (async () => {
25
+ dirReady ??= FileSystem.makeDirectoryAsync(DIR, { intermediates: true }).catch(() => {});
26
+ await dirReady;
27
+ const file = DIR + url.replace(/^https?:\/\//, '').replace(/[^a-zA-Z0-9._-]/g, '_');
28
+ if ((await FileSystem.getInfoAsync(file)).exists) return file;
29
+ const tmp = `${file}.${Date.now()}.part`;
30
+ const res = await FileSystem.downloadAsync(url, tmp);
31
+ if (res.status !== 200) {
32
+ await FileSystem.deleteAsync(tmp, { idempotent: true });
33
+ throw new Error(`humanforge: ${res.status} for ${url}`);
34
+ }
35
+ await FileSystem.moveAsync({ from: tmp, to: file });
36
+ return file;
37
+ })();
38
+ inflight.set(url, p);
39
+ p.catch(() => {}).finally(() => inflight.delete(url));
22
40
  }
23
- return file;
41
+ return p;
24
42
  }
25
43
 
26
44
  const B64 = new Uint8Array(128);
@@ -43,16 +61,20 @@ export const expoFetch = async (url: string): Promise<ArrayBuffer> =>
43
61
 
44
62
  /**
45
63
  * expo-gl decodes images natively when texImage2D receives `{ localUri }`, so we hand three
46
- * a "data texture" whose data is the cached file (same trick expo-three uses).
64
+ * a "data texture" whose data is the cached file (same trick expo-three uses). The file must be
65
+ * JPEG or PNG (stb_image); WebP uploads silently fail and the mesh renders black.
47
66
  */
48
67
  export const expoTextureLoader: HumanTextureLoader = async (url, size) => {
68
+ if (/\.webp$/i.test(url)) console.warn(`humanforge: expo-gl can't decode WebP (${url}); build/host the mobile texture pack`);
49
69
  const localUri = await cached(url);
50
70
  const tex = new Texture();
51
71
  tex.image = { data: { localUri }, width: size, height: size };
52
72
  (tex as any).isDataTexture = true;
53
- tex.generateMipmaps = false;
73
+ // all pack textures are power-of-two squares, so mipmaps work and avoid shimmer at bust framing
74
+ tex.generateMipmaps = true;
54
75
  tex.needsUpdate = true;
55
76
  return tex;
56
77
  };
57
78
 
58
- export const expoLoadOptions: LoadOptions = { fetch: expoFetch, loadTexture: expoTextureLoader };
79
+ /** Options for loadHumanPackage / preloadHumanPackage on Expo: disk cache + JPEG/PNG textures. */
80
+ export const expoLoadOptions: LoadOptions = { fetch: expoFetch, loadTexture: expoTextureLoader, textureFormat: 'mobile' };
@@ -2,8 +2,8 @@ export { AvatarView } from './AvatarView';
2
2
  export type { AvatarViewHandle, AvatarViewProps } from './AvatarView';
3
3
  export { AvatarCreator } from './AvatarCreator';
4
4
  export type { AvatarCreatorProps } from './AvatarCreator';
5
- export { HumanView, useHumanPackage } from './HumanView';
6
- export type { HumanViewHandle, HumanViewProps } from './HumanView';
7
- export { expoFetch, expoLoadOptions, expoTextureLoader } from './humanExpo';
5
+ export { HumanView, preloadHumanPackage, useHumanPackage } from './HumanView';
6
+ export type { HumanViewHandle, HumanViewProps, UseHumanPackageOptions } from './HumanView';
7
+ export { cached as cachedFile, expoFetch, expoLoadOptions, expoTextureLoader } from './humanExpo';
8
8
  export { HumanEditor } from './HumanEditor';
9
9
  export type { HumanEditorProps } from './HumanEditor';
@@ -5,7 +5,7 @@ import {
5
5
  Vector3, WebGLRenderer,
6
6
  } from 'three';
7
7
  import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
8
- import { HumanAvatar, HumanConfig, HumanPackage } from '../human/runtime';
8
+ import { HumanAvatar, HumanConfig, HumanPackage, TextureTier } from '../human/runtime';
9
9
 
10
10
  export interface HumanCanvasProps {
11
11
  pkg: HumanPackage;
@@ -16,7 +16,8 @@ export interface HumanCanvasProps {
16
16
  background?: string;
17
17
  /** Drag to turn the avatar. Default true. */
18
18
  rotatable?: boolean;
19
- textureSize?: '1k' | '2k';
19
+ /** Texture tier: '512' | '1k' | '2k' (default '2k'). */
20
+ textureSize?: TextureTier;
20
21
  className?: string;
21
22
  style?: React.CSSProperties;
22
23
  onReady?: (avatar: HumanAvatar) => void;