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.
@@ -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
- skins: { id: string; tags: string[] }[];
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
- baseUrl: root, manifest, base, morphs, body: readGlb(bodyBuf), loadTexture: opts.loadTexture ?? webTextureLoader,
157
- 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) })),
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
- 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;
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}_${this.textureSize}.webp`, this.textureSize === '2k' ? sizes.skin2k : sizes.skin1k);
357
- 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);
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
- 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
+ }
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 = eyeTex; m.needsUpdate = true; }
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(file: string, size: number): Promise<Texture> {
395
- const url = `${this.pkg.baseUrl}/${file}`;
396
- let t = this.pkg.textureCache.get(url);
397
- if (!t) {
398
- t = this.pkg.loadTexture(url, size).then((tex) => {
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
@@ -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';
@@ -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.manifest, options), [given, pkg, JSON.stringify(options)]);
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.choice, o.id === v && { borderColor: accent, borderWidth: 2 }]}>
120
- {o.swatch ? <View style={[s.dot, { backgroundColor: o.swatch }]} /> : null}
121
- <Text style={[s.choiceText, o.id === v && { fontWeight: '700' }]}>{o.label}</Text>
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
- choice: { flexDirection: 'row', alignItems: 'center', gap: 6, borderWidth: 1, borderColor: '#e1e3e7', borderRadius: 10, paddingHorizontal: 12, paddingVertical: 9, minWidth: 64, justifyContent: 'center' },
177
- choiceText: { fontSize: 12, color: '#1d1d1f' },
178
- dot: { width: 12, height: 12, borderRadius: 6 },
179
- swatch: { width: 32, height: 32, borderRadius: 16, borderWidth: 3, borderColor: '#fff', shadowColor: '#000', shadowOpacity: 0.15, shadowRadius: 2, shadowOffset: { width: 0, height: 1 }, elevation: 1 },
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 },
@@ -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
  });