feat add model loading diagnostics

This commit is contained in:
Tom Boullay
2026-05-02 00:14:47 +02:00
parent 4d7d2efdcc
commit 1d64582383
13 changed files with 218 additions and 29 deletions
+8 -2
View File
@@ -1,9 +1,10 @@
import { useRef, useEffect, useState } from "react";
import { Grid, TransformControls, useGLTF } from "@react-three/drei";
import { Grid, TransformControls } from "@react-three/drei";
import type { ThreeEvent } from "@react-three/fiber";
import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor";
interface EditorMapProps {
@@ -258,7 +259,12 @@ function EditorModelNode({
const originalMaterialsRef = useRef(
new Map<THREE.Mesh, THREE.Material | THREE.Material[]>(),
);
const { scene } = useGLTF(modelUrl);
const { scene } = useLoggedGLTF(modelUrl, {
scope: "EditorMap.EditorModelNode",
position: node.position,
rotation: node.rotation,
scale: node.scale,
});
const sceneInstance = useClonedObject(scene);
const pointerHandlers = createEditorNodePointerHandlers(
index,
@@ -1,5 +1,4 @@
import { useEffect, useRef } from "react";
import { useGLTF } from "@react-three/drei";
import { useFrame, useThree } from "@react-three/fiber";
import gsap from "gsap";
import * as THREE from "three";
@@ -16,6 +15,7 @@ import {
REPAIR_CASE_ROTATION_RESET_SPEED,
} from "@/data/gameplay/repairCaseConfig";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { ModelTransformProps } from "@/types/three/three";
import { toVector3Scale } from "@/utils/three/scale";
@@ -42,7 +42,12 @@ export function RepairCaseModel({
scale = 1,
}: RepairCaseModelProps): React.JSX.Element {
const camera = useThree((state) => state.camera);
const { scene } = useGLTF(modelPath);
const { scene } = useLoggedGLTF(modelPath, {
scope: "RepairCaseModel",
position,
rotation,
scale,
});
const model = useClonedObject(scene);
const groupRef = useRef<THREE.Group>(null);
const lidRef = useRef<THREE.Object3D | null>(null);
@@ -8,6 +8,7 @@ import {
} from "@/data/gameplay/repairCaseConfig";
import { AudioManager } from "@/managers/AudioManager";
import type { Vector3Tuple } from "@/types/three/three";
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
interface RepairCaseErrorBoundaryProps {
children: ReactNode;
@@ -31,7 +32,15 @@ class RepairCaseErrorBoundary extends Component<
}
componentDidCatch(error: Error): void {
console.warn("Failed to load repair case model", error);
logModelLoadError(
{
modelPath: REPAIR_CASE_MODEL_PATH,
scope: "RepairCaseObject",
position: [0, -0.45, 0],
scale: 1.5,
},
error,
);
}
render(): ReactNode {
@@ -1,8 +1,8 @@
import { useState } from "react";
import { useGLTF } from "@react-three/drei";
import { RigidBody } from "@react-three/rapier";
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import {
TRIGGER_DEFAULT_COLLIDERS,
TRIGGER_DEFAULT_LABEL,
@@ -38,7 +38,10 @@ function SpawnedModelInstance({
path: string;
position: Vector3Tuple;
}): React.JSX.Element {
const { scene } = useGLTF(path);
const { scene } = useLoggedGLTF(path, {
scope: "TriggerObject.SpawnedModel",
position,
});
const model = useClonedObject(scene);
return <primitive object={model} position={position} />;
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useGLTF, useAnimations } from "@react-three/drei";
import { useAnimations } from "@react-three/drei";
import type { AnimationAction } from "three";
import * as THREE from "three";
import {
AnimatedModelContext,
type AnimatedModelContextValue,
} from "@/components/three/models/useAnimatedModel";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { Vector3Tuple } from "@/types/three/three";
export interface AnimatedModelConfig {
@@ -40,7 +41,12 @@ export function AnimatedModel({
children,
}: AnimatedModelProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
const { scene, animations } = useGLTF(modelPath);
const { scene, animations } = useLoggedGLTF(modelPath, {
scope: "AnimatedModel",
position,
rotation,
scale,
});
const model = useMemo(() => scene.clone(true), [scene]);
const { actions, names, mixer } = useAnimations(animations, groupRef);
@@ -1,14 +1,16 @@
import type { ReactNode } from "react";
import { Component, useEffect, useMemo } from "react";
import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { ExplodedModel } from "@/utils/three/ExplodedModel";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
import { toVector3Scale } from "@/utils/three/scale";
interface ModelErrorBoundaryProps {
children: ReactNode;
modelPath: string;
position?: Vector3Tuple | undefined;
}
@@ -30,7 +32,14 @@ class ModelErrorBoundary extends Component<
}
componentDidCatch(error: Error): void {
console.warn("Failed to load explodable model", error);
logModelLoadError(
{
modelPath: this.props.modelPath,
scope: "ExplodableModel",
position: this.props.position,
},
error,
);
}
render(): ReactNode {
@@ -52,7 +61,11 @@ export function ExplodableModel(
props: ExplodableModelInnerProps,
): React.JSX.Element {
return (
<ModelErrorBoundary key={props.modelPath} position={props.position}>
<ModelErrorBoundary
key={props.modelPath}
modelPath={props.modelPath}
position={props.position}
>
<ExplodableModelInner {...props} />
</ModelErrorBoundary>
);
@@ -66,7 +79,12 @@ function ExplodableModelInner({
scale = 1,
splitDistance = 1.2,
}: ExplodableModelInnerProps): React.JSX.Element {
const { scene } = useGLTF(modelPath);
const { scene } = useLoggedGLTF(modelPath, {
scope: "ExplodableModel",
position,
rotation,
scale,
});
const model = useClonedObject(scene);
const explodedModel = useMemo(
() => new ExplodedModel(model, { distance: splitDistance }),
+7 -2
View File
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { useGLTF } from "@react-three/drei";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { Vector3Tuple } from "@/types/three/three";
export interface SimpleModelConfig {
@@ -24,7 +24,12 @@ export function SimpleModel({
receiveShadow = true,
children,
}: SimpleModelProps): React.JSX.Element {
const { scene } = useGLTF(modelPath);
const { scene } = useLoggedGLTF(modelPath, {
scope: "SimpleModel",
position,
rotation,
scale,
});
const model = useMemo(() => scene.clone(true), [scene]);
const parsedScale =
+5 -1
View File
@@ -3,6 +3,7 @@ import { useGLTF } from "@react-three/drei";
import { useRef } from "react";
import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
interface SkyModelProps {
modelPath: string;
@@ -13,7 +14,10 @@ const SKY_MODEL_SCALE = 1;
export function SkyModel({ modelPath }: SkyModelProps): React.JSX.Element {
const camera = useThree((state) => state.camera);
const groupRef = useRef<THREE.Group>(null);
const { scene } = useGLTF(modelPath);
const { scene } = useLoggedGLTF(modelPath, {
scope: "SkyModel",
scale: SKY_MODEL_SCALE,
});
const model = useClonedObject(scene);
useFrame(() => {
+5 -2
View File
@@ -1,7 +1,8 @@
import { useRef, useEffect, useState, useCallback, useMemo } from "react";
import { useGLTF, useAnimations } from "@react-three/drei";
import { useAnimations } from "@react-three/drei";
import type { AnimationAction, AnimationMixer } from "three";
import * as THREE from "three";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
export interface CharacterAnimationConfig {
modelPath: string;
@@ -34,7 +35,9 @@ export function useCharacterAnimation(
} = config;
const groupRef = useRef<THREE.Group | null>(null);
const { scene, animations } = useGLTF(modelPath);
const { scene, animations } = useLoggedGLTF(modelPath, {
scope: "useCharacterAnimation",
});
const model = useMemo(() => scene.clone(true), [scene]);
const { actions, names, mixer } = useAnimations(animations, groupRef);
const [currentAnimation, setCurrentAnimation] = useState(initialAnimation);
+24
View File
@@ -0,0 +1,24 @@
import { useEffect, useRef } from "react";
import { useGLTF } from "@react-three/drei";
import {
logModelLoadSuccess,
type ModelLoadLogContext,
} from "@/utils/three/modelLoadLogger";
export function useLoggedGLTF(
modelPath: string,
context: Omit<ModelLoadLogContext, "modelPath">,
) {
const gltf = useGLTF(modelPath);
const hasLoggedRef = useRef(false);
const { position, rotation, scale, scope } = context;
useEffect(() => {
if (hasLoggedRef.current) return;
hasLoggedRef.current = true;
logModelLoadSuccess({ modelPath, position, rotation, scale, scope }, gltf);
}, [gltf, modelPath, position, rotation, scale, scope]);
return gltf;
}
+68
View File
@@ -0,0 +1,68 @@
import { logger } from "@/utils/core/logger";
import type { Vector3Tuple } from "@/types/three/three";
export interface ModelLoadLogContext {
modelPath: string;
scope: string;
position?: Vector3Tuple | undefined;
rotation?: Vector3Tuple | undefined;
scale?: Vector3Tuple | number | undefined;
}
interface LoadedModelInfo {
scene: {
name: string;
};
animations: Array<{
name: string;
}>;
}
function getModelLoadHint(error: Error): string | undefined {
const message = error.message.toLowerCase();
if (
message.includes("unexpected token 'v'") ||
message.includes("version https://git-lfs") ||
message.includes("git-lfs")
) {
return "This file looks like a Git LFS pointer instead of a real GLTF asset. Run `git lfs pull` or replace the asset.";
}
if (message.includes("couldn't load texture")) {
return "A texture referenced by the GLTF could not be loaded. Check file names, casing, and paths next to the model.";
}
return undefined;
}
export function logModelLoadSuccess(
context: ModelLoadLogContext,
gltf: LoadedModelInfo,
): void {
logger.debug("ModelLoader", "Model loaded", {
modelPath: context.modelPath,
scope: context.scope,
position: context.position,
rotation: context.rotation,
scale: context.scale,
sceneName: gltf.scene.name || null,
animations: gltf.animations.map((animation) => animation.name),
animationCount: gltf.animations.length,
});
}
export function logModelLoadError(
context: ModelLoadLogContext,
error: Error,
): void {
logger.error("ModelLoader", "Model failed to load", {
modelPath: context.modelPath,
scope: context.scope,
position: context.position,
rotation: context.rotation,
scale: context.scale,
reason: error.message,
hint: getModelLoadHint(error),
});
}
+37 -9
View File
@@ -1,10 +1,12 @@
import type { ReactNode } from "react";
import { Component, useEffect, useRef, useState } from "react";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode";
import { logger } from "@/utils/core/logger";
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
import type { MapNode } from "@/types/editor/editor";
import type { OctreeReadyHandler } from "@/types/three/three";
@@ -15,6 +17,8 @@ interface LoadedMapNode {
interface ErrorBoundaryProps {
children: ReactNode;
modelUrl: string;
node: MapNode;
}
interface ErrorBoundaryState {
@@ -35,7 +39,16 @@ class ModelErrorBoundary extends Component<
}
componentDidCatch(error: Error): void {
console.warn("Failed to load model", error);
logModelLoadError(
{
modelPath: this.props.modelUrl,
scope: "GameMap.ModelInstance",
position: this.props.node.position,
rotation: this.props.node.rotation,
scale: this.props.node.scale,
},
error,
);
}
render(): ReactNode {
@@ -62,7 +75,7 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
try {
const sceneData = await loadMapSceneData();
if (!sceneData) {
console.warn("map.json not found");
logger.warn("GameMap", "map.json not found");
return;
}
@@ -74,14 +87,20 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
sceneData.mapNodes.length - loadedMapNodes.length;
if (missingModelCount > 0) {
console.warn(
`${missingModelCount} map nodes were skipped because their model files are missing.`,
logger.warn(
"GameMap",
"Map nodes skipped because model files are missing",
{
missingModelCount,
},
);
}
setMapNodes(loadedMapNodes);
} catch (error) {
console.error("Error loading map:", error);
logger.error("GameMap", "Error loading map", {
error: error instanceof Error ? error : new Error(String(error)),
});
}
};
@@ -91,7 +110,11 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
return (
<group ref={groupRef}>
{mapNodes.map((mapNode, index) => (
<ModelErrorBoundary key={index}>
<ModelErrorBoundary
key={index}
modelUrl={mapNode.modelUrl}
node={mapNode.node}
>
<ModelInstance node={mapNode.node} modelUrl={mapNode.modelUrl} />
</ModelErrorBoundary>
))}
@@ -106,9 +129,14 @@ function ModelInstance({
node: MapNode;
modelUrl: string;
}): React.JSX.Element {
const { scene } = useGLTF(modelUrl);
const sceneInstance = useClonedObject(scene);
const { position, rotation, scale } = node;
const { scene } = useLoggedGLTF(modelUrl, {
scope: "GameMap.ModelInstance",
position,
rotation,
scale,
});
const sceneInstance = useClonedObject(scene);
return (
<primitive
+12 -2
View File
@@ -25,6 +25,7 @@ import {
} from "@/data/debug/testSceneConfig";
import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode";
import type { OctreeReadyHandler } from "@/types/three/three";
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
interface TestMapProps {
onOctreeReady: OctreeReadyHandler;
@@ -32,6 +33,7 @@ interface TestMapProps {
interface ModelPreviewErrorBoundaryProps {
children: ReactNode;
modelPath: string;
}
interface ModelPreviewErrorBoundaryState {
@@ -52,7 +54,15 @@ class ModelPreviewErrorBoundary extends Component<
}
componentDidCatch(error: Error): void {
console.warn("Failed to load debug animated model preview", error);
logModelLoadError(
{
modelPath: this.props.modelPath,
scope: "TestMap.ModelPreview",
position: [0, 0, -5],
scale: 1,
},
error,
);
}
render(): ReactNode {
@@ -124,7 +134,7 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
<RepairGameZone />
</Physics>
<ModelPreviewErrorBoundary>
<ModelPreviewErrorBoundary modelPath="/models/elec/model.gltf">
<AnimatedModel
modelPath="/models/elec/model.gltf"
defaultAnimation="Idle"