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 { useLoader } from '@react-three/fiber';
|
||||
import { useFBX, useGLTF, useTexture } from '@react-three/drei';
|
||||
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
|
||||
import { useTexture } from '@react-three/drei';
|
||||
import type { TTSObject } from '@tts/shared';
|
||||
import type * as THREE from 'three';
|
||||
import type { Object3D } from 'three';
|
||||
import Scene from './Scene';
|
||||
import { assetUrl } from './assetUrl';
|
||||
import { FlexibleModelLoader } from './flexibleModelLoader';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* extensions. `DiffuseURL` is applied to the model's materials when present.
|
||||
* and FBX, detecting the format from the file contents rather than the URL
|
||||
* (TTS model URLs are often extension-less). `DiffuseURL` is applied to the
|
||||
* model's materials when present.
|
||||
*/
|
||||
export default function CustomModelViewer({ object }: { object: TTSObject }) {
|
||||
const meshUrl = object.CustomMesh?.MeshURL;
|
||||
@@ -41,23 +43,23 @@ function Model({
|
||||
meshUrl: string;
|
||||
diffuseUrl?: string;
|
||||
}) {
|
||||
const ext = meshUrl.split('?')[0]!.split('.').pop()!.toLowerCase();
|
||||
const url = assetUrl(meshUrl);
|
||||
const root = useLoader(FlexibleModelLoader, assetUrl(meshUrl));
|
||||
|
||||
const diffuse = diffuseUrl ? useTexture(assetUrl(diffuseUrl)) : null;
|
||||
|
||||
let root: THREE.Object3D;
|
||||
if (ext === 'obj') {
|
||||
root = useLoader(OBJLoader, url);
|
||||
} else if (ext === 'fbx') {
|
||||
root = useFBX(url);
|
||||
} else {
|
||||
root = useGLTF(url).scene;
|
||||
return (
|
||||
<>
|
||||
<primitive object={root} scale={0.5} />
|
||||
{diffuseUrl && <DiffuseTexture root={root} url={diffuseUrl} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Rendered inside the Canvas so `useTexture` can access the R3F store. Only
|
||||
// mounted when a diffuse URL exists, so the hook count stays consistent.
|
||||
function DiffuseTexture({ root, url }: { root: Object3D; url: string }) {
|
||||
const texture = useTexture(assetUrl(url));
|
||||
|
||||
// Apply the diffuse texture to every mesh material on the loaded model.
|
||||
useLayoutEffect(() => {
|
||||
if (!diffuse) return;
|
||||
root.traverse((child) => {
|
||||
const mesh = child as THREE.Mesh;
|
||||
if (mesh.isMesh) {
|
||||
@@ -65,12 +67,12 @@ function Model({
|
||||
? mesh.material[0]
|
||||
: mesh.material;
|
||||
if (material && 'map' in material) {
|
||||
material.map = diffuse;
|
||||
material.map = texture;
|
||||
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