Fix custom model loading and hook order
Detect the model format from file contents instead of the URL extension, since TTS model URLs are often extension-less. Move the diffuse texture hook into a child component so the hook count stays consistent across renders.
This commit is contained in:
@@ -1,16 +1,18 @@
|
|||||||
import { Suspense, useLayoutEffect } from 'react';
|
import { Suspense, useLayoutEffect } from 'react';
|
||||||
import { useLoader } from '@react-three/fiber';
|
import { useLoader } from '@react-three/fiber';
|
||||||
import { useFBX, useGLTF, useTexture } from '@react-three/drei';
|
import { useTexture } from '@react-three/drei';
|
||||||
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
|
|
||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
import type * as THREE from 'three';
|
import type * as THREE from 'three';
|
||||||
|
import type { Object3D } from 'three';
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { assetUrl } from './assetUrl';
|
import { assetUrl } from './assetUrl';
|
||||||
|
import { FlexibleModelLoader } from './flexibleModelLoader';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ,
|
* A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ,
|
||||||
* and FBX by sniffing the URL extension; falls back to GLTF for unknown
|
* and FBX, detecting the format from the file contents rather than the URL
|
||||||
* extensions. `DiffuseURL` is applied to the model's materials when present.
|
* (TTS model URLs are often extension-less). `DiffuseURL` is applied to the
|
||||||
|
* model's materials when present.
|
||||||
*/
|
*/
|
||||||
export default function CustomModelViewer({ object }: { object: TTSObject }) {
|
export default function CustomModelViewer({ object }: { object: TTSObject }) {
|
||||||
const meshUrl = object.CustomMesh?.MeshURL;
|
const meshUrl = object.CustomMesh?.MeshURL;
|
||||||
@@ -41,23 +43,23 @@ function Model({
|
|||||||
meshUrl: string;
|
meshUrl: string;
|
||||||
diffuseUrl?: string;
|
diffuseUrl?: string;
|
||||||
}) {
|
}) {
|
||||||
const ext = meshUrl.split('?')[0]!.split('.').pop()!.toLowerCase();
|
const root = useLoader(FlexibleModelLoader, assetUrl(meshUrl));
|
||||||
const url = assetUrl(meshUrl);
|
|
||||||
|
|
||||||
const diffuse = diffuseUrl ? useTexture(assetUrl(diffuseUrl)) : null;
|
return (
|
||||||
|
<>
|
||||||
|
<primitive object={root} scale={0.5} />
|
||||||
|
{diffuseUrl && <DiffuseTexture root={root} url={diffuseUrl} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let root: THREE.Object3D;
|
// Rendered inside the Canvas so `useTexture` can access the R3F store. Only
|
||||||
if (ext === 'obj') {
|
// mounted when a diffuse URL exists, so the hook count stays consistent.
|
||||||
root = useLoader(OBJLoader, url);
|
function DiffuseTexture({ root, url }: { root: Object3D; url: string }) {
|
||||||
} else if (ext === 'fbx') {
|
const texture = useTexture(assetUrl(url));
|
||||||
root = useFBX(url);
|
|
||||||
} else {
|
|
||||||
root = useGLTF(url).scene;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply the diffuse texture to every mesh material on the loaded model.
|
// Apply the diffuse texture to every mesh material on the loaded model.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!diffuse) return;
|
|
||||||
root.traverse((child) => {
|
root.traverse((child) => {
|
||||||
const mesh = child as THREE.Mesh;
|
const mesh = child as THREE.Mesh;
|
||||||
if (mesh.isMesh) {
|
if (mesh.isMesh) {
|
||||||
@@ -65,12 +67,12 @@ function Model({
|
|||||||
? mesh.material[0]
|
? mesh.material[0]
|
||||||
: mesh.material;
|
: mesh.material;
|
||||||
if (material && 'map' in material) {
|
if (material && 'map' in material) {
|
||||||
material.map = diffuse;
|
material.map = texture;
|
||||||
material.needsUpdate = true;
|
material.needsUpdate = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [root, diffuse]);
|
}, [root, texture]);
|
||||||
|
|
||||||
return <primitive object={root} scale={0.5} />;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { FileLoader, Loader, type LoadingManager, type Object3D } from 'three';
|
||||||
|
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';
|
||||||
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||||
|
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
|
||||||
|
|
||||||
|
const GLB_MAGIC = [0x67, 0x6c, 0x54, 0x46]; // "glTF"
|
||||||
|
const FBX_MAGIC = 'Kaydara FBX Binary';
|
||||||
|
const OBJ_LINE = /^(v|vn|vt|f|o|g|s|usemtl|mtllib)\b/m;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a model regardless of format by sniffing the file contents rather than
|
||||||
|
* relying on the URL extension. TTS `CustomMesh.MeshURL` values often point at
|
||||||
|
* extension-less Steam URLs, so the format must be detected from the bytes.
|
||||||
|
* Resolves to a `THREE.Object3D` (a `Group` for OBJ/FBX, the scene for GLTF).
|
||||||
|
*/
|
||||||
|
export class FlexibleModelLoader extends Loader<Object3D> {
|
||||||
|
constructor(manager?: LoadingManager) {
|
||||||
|
super(manager);
|
||||||
|
}
|
||||||
|
|
||||||
|
override load(
|
||||||
|
url: string,
|
||||||
|
onLoad: (object: Object3D) => void,
|
||||||
|
onProgress?: (event: ProgressEvent) => void,
|
||||||
|
onError?: (event: unknown) => void,
|
||||||
|
) {
|
||||||
|
const file = new FileLoader(this.manager);
|
||||||
|
file.setPath(this.path);
|
||||||
|
file.setResponseType('arraybuffer');
|
||||||
|
file.setRequestHeader(this.requestHeader);
|
||||||
|
file.setWithCredentials(this.withCredentials);
|
||||||
|
file.load(
|
||||||
|
url,
|
||||||
|
(data) => this.parse(data as ArrayBuffer, onLoad, onError),
|
||||||
|
onProgress,
|
||||||
|
onError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
parse(
|
||||||
|
data: ArrayBuffer,
|
||||||
|
onLoad: (object: Object3D) => void,
|
||||||
|
onError?: (event: unknown) => void,
|
||||||
|
) {
|
||||||
|
const bytes = new Uint8Array(data);
|
||||||
|
const head = new TextDecoder().decode(bytes.slice(0, 1024));
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isGlb(bytes)) {
|
||||||
|
new GLTFLoader().parse(data, '', (gltf) => onLoad(gltf.scene), onError);
|
||||||
|
} else if (head.startsWith(FBX_MAGIC)) {
|
||||||
|
onLoad(new FBXLoader().parse(data, ''));
|
||||||
|
} else if (head.trimStart().startsWith('{')) {
|
||||||
|
new GLTFLoader().parse(data, '', (gltf) => onLoad(gltf.scene), onError);
|
||||||
|
} else if (OBJ_LINE.test(head)) {
|
||||||
|
onLoad(new OBJLoader().parse(new TextDecoder().decode(data)));
|
||||||
|
} else {
|
||||||
|
// Best-effort fallback for anything unrecognized.
|
||||||
|
new GLTFLoader().parse(data, '', (gltf) => onLoad(gltf.scene), onError);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
onError?.(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGlb(bytes: Uint8Array): boolean {
|
||||||
|
return (
|
||||||
|
bytes.length >= 4 &&
|
||||||
|
bytes[0] === GLB_MAGIC[0] &&
|
||||||
|
bytes[1] === GLB_MAGIC[1] &&
|
||||||
|
bytes[2] === GLB_MAGIC[2] &&
|
||||||
|
bytes[3] === GLB_MAGIC[3]
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user