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 +76 -16
- package/package.json +3 -2
- package/src/human/runtime.ts +171 -24
- package/src/index.ts +2 -0
- 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/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
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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.
|
|
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.
|
|
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",
|
package/src/human/runtime.ts
CHANGED
|
@@ -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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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}
|
|
362
|
-
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);
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
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
|
});
|
package/src/native/humanExpo.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
79
|
+
/** Options for loadHumanPackage / preloadHumanPackage on Expo: disk cache + JPEG/PNG textures. */
|
|
80
|
+
export const expoLoadOptions: LoadOptions = { fetch: expoFetch, loadTexture: expoTextureLoader, textureFormat: 'mobile' };
|
package/src/native/index.ts
CHANGED
|
@@ -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';
|
package/src/web/HumanCanvas.tsx
CHANGED
|
@@ -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
|
-
|
|
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;
|