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:
2026-08-08 13:06:01 +08:00
parent 179efae99f
commit 3b86a641aa
2 changed files with 96 additions and 19 deletions
@@ -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]
);
}