merge develop into feat/galerie - resolve model and code conflicts

This commit is contained in:
Tom Boullay
2026-05-29 02:25:46 +02:00
940 changed files with 92419 additions and 37567 deletions
+24
View File
@@ -0,0 +1,24 @@
import { useRef } from "react";
import { useFrame } from "@react-three/fiber";
import * as THREE from "three";
import { createNetShader } from "@/shaders/NetShader";
export function NetTest(): React.JSX.Element {
const materialRef = useRef<THREE.ShaderMaterial>(null);
useFrame((_, delta) => {
const timeUniform = materialRef.current?.uniforms.uTime;
if (timeUniform) timeUniform.value += delta;
});
return (
<mesh position={[0, 2, -3]} rotation={[0, 0, 0]}>
<planeGeometry args={[2, 2, 1, 1]} />
<primitive
object={createNetShader()}
ref={materialRef}
attach="material"
/>
</mesh>
);
}
@@ -5,7 +5,7 @@ import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import { REPAIR_CASE_ANIMATION_DURATION } from "@/data/gameplay/repairCaseConfig";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
interface RepairCompletionStepProps {
config: RepairMissionConfig;
+8 -10
View File
@@ -7,21 +7,18 @@ import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspec
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep";
import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemblyStep";
import {
RepairScanSequence,
type RepairScannedBrokenPart,
} from "@/components/three/gameplay/RepairScanSequence";
import { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence";
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
import {
REPAIR_MISSIONS,
type RepairMissionConfig,
} from "@/data/gameplay/repairMissions";
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
import type {
MissionStep,
RepairMissionConfig,
RepairMissionId,
RepairScannedBrokenPart,
} from "@/types/gameplay/repairMission";
import { useGameStore } from "@/managers/stores/useGameStore";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
@@ -70,6 +67,7 @@ export function RepairGame({
readonly RepairScannedBrokenPart[]
>([]);
const parsedScale = toVector3Scale(scale);
const snappedPosition = useTerrainSnappedPosition(position);
const readyForFragmentation = step === "inspected";
useRepairFragmentationInput({
@@ -109,7 +107,7 @@ export function RepairGame({
if (step === "locked") return null;
return (
<group position={position} rotation={rotation} scale={parsedScale}>
<group position={snappedPosition} rotation={rotation} scale={parsedScale}>
<Suspense fallback={null}>
<RepairMissionAssetPreloader config={config} />
</Suspense>
@@ -117,7 +115,7 @@ export function RepairGame({
{step === "waiting" ? (
<RepairInspectionObject
config={config}
worldPosition={position}
worldPosition={snappedPosition}
onInspect={() => setMissionStep(mission, "inspected")}
/>
) : null}
@@ -2,7 +2,7 @@ import { InteractableObject } from "@/components/three/interaction/InteractableO
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three";
interface RepairInspectionObjectProps {
@@ -10,7 +10,7 @@ import {
REPAIR_CASE_MODEL_PATH,
} from "@/data/gameplay/repairCaseConfig";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three";
interface RepairMissionCaseProps {
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { RepairCompletionParticles } from "@/components/three/gameplay/RepairCompletionParticles";
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
import { REPAIR_REASSEMBLY_SECONDS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
interface RepairReassemblyStepProps {
config: RepairMissionConfig;
@@ -3,7 +3,6 @@ import * as THREE from "three";
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import type { RepairScannedBrokenPart } from "@/components/three/gameplay/RepairScanSequence";
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import {
@@ -15,7 +14,9 @@ import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type {
RepairMissionConfig,
RepairMissionPartConfig,
} from "@/data/gameplay/repairMissions";
RepairScannedBrokenPart,
} from "@/types/gameplay/repairMission";
import { logger } from "@/utils/core/Logger";
import type { Vector3Tuple } from "@/types/three/three";
const INSTALL_TARGET_POSITION: Vector3Tuple = [0, 0.8, 0];
@@ -34,6 +35,7 @@ const REPAIR_INSTALL_RADIUS = 1.1;
const VALID_PART_COLOR = "#22c55e";
const INVALID_PART_COLOR = "#ef4444";
const STORED_BROKEN_PART_COLOR = "#38bdf8";
let hasWarnedMissingPlaceholders = false;
interface RepairRepairingStepProps {
brokenParts: readonly RepairScannedBrokenPart[];
@@ -400,6 +402,14 @@ function getPlaceholderTargets(
return placeholders;
}
if (!hasWarnedMissingPlaceholders) {
hasWarnedMissingPlaceholders = true;
logger.warn(
"RepairGame",
"Repair case placeholders missing, using fallback slots",
);
}
return FALLBACK_PLACEHOLDER_OFFSETS.map(
(offset, index): RepairCasePlaceholder => ({
name: `placeholder_${index + 1}`,
@@ -416,12 +426,12 @@ function getBrokenPartTargetPositions(
part: RepairScannedBrokenPart,
placeholderTargets: readonly RepairCasePlaceholder[],
): readonly Vector3Tuple[] {
if (!part.placeholderName) {
if (!part.caseSlotName) {
return placeholderTargets.map((placeholder) => placeholder.position);
}
const matchingPlaceholder = placeholderTargets.find(
(placeholder) => placeholder.name === part.placeholderName,
(placeholder) => placeholder.name === part.caseSlotName,
);
return matchingPlaceholder
@@ -475,6 +485,6 @@ function getBrokenPartsToDeposit(
id: part.id,
label: part.label,
modelPath: part.modelPath ?? config.modelPath,
...(part.placeholderName ? { placeholderName: part.placeholderName } : {}),
...(part.caseSlotName ? { caseSlotName: part.caseSlotName } : {}),
}));
}
@@ -8,7 +8,9 @@ import { REPAIR_SCAN_PART_SECONDS } from "@/data/gameplay/repairGameConfig";
import type {
RepairMissionConfig,
RepairMissionPartConfig,
} from "@/data/gameplay/repairMissions";
RepairScannedBrokenPart,
} from "@/types/gameplay/repairMission";
import { logger } from "@/utils/core/Logger";
import type { ExplodedPart } from "@/utils/three/ExplodedModel";
interface RepairScanSequenceProps {
@@ -16,13 +18,13 @@ interface RepairScanSequenceProps {
onComplete: (brokenParts: readonly RepairScannedBrokenPart[]) => void;
}
export interface RepairScannedBrokenPart {
id: string;
label: string;
modelPath: string;
placeholderName?: string;
interface RepairBrokenPartMatch {
config: RepairMissionPartConfig;
partIndex: number;
}
const warnedMissingScanParts = new Set<string>();
export function RepairScanSequence({
config,
onComplete,
@@ -31,9 +33,9 @@ export function RepairScanSequence({
const [activePartIndex, setActivePartIndex] = useState(0);
const activePart = parts[activePartIndex];
const scanPartSeconds = config.scanPartSeconds ?? REPAIR_SCAN_PART_SECONDS;
const brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts);
const visibleBrokenPartIndexes = brokenPartIndexes.filter(
(partIndex) => partIndex <= activePartIndex,
const brokenPartMatches = getBrokenPartMatches(parts, config);
const visibleBrokenPartMatches = brokenPartMatches.filter(
(match) => match.partIndex <= activePartIndex,
);
useEffect(() => {
@@ -65,8 +67,8 @@ export function RepairScanSequence({
onPartsReady={setParts}
/>
<RepairScanVisual target={activePart?.object} />
{visibleBrokenPartIndexes.map((partIndex) => {
const part = parts[partIndex];
{visibleBrokenPartMatches.map((match) => {
const part = parts[match.partIndex];
if (!part) return null;
return (
@@ -87,29 +89,25 @@ function getScannedBrokenParts(
parts: readonly ExplodedPart[],
config: RepairMissionConfig,
): readonly RepairScannedBrokenPart[] {
const brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts);
return brokenPartIndexes.map((_, index) => {
const configuredPart = config.brokenParts[index] ?? config.brokenParts[0];
return getBrokenPartMatches(parts, config).map((match) => {
return {
id: configuredPart?.id ?? `${config.id}-broken-part-${index}`,
label: configuredPart?.label ?? `${config.label} broken part`,
modelPath: configuredPart?.modelPath ?? config.modelPath,
...(configuredPart?.placeholderName
? { placeholderName: configuredPart.placeholderName }
id: match.config.id,
label: match.config.label,
modelPath: match.config.modelPath ?? config.modelPath,
...(match.config.caseSlotName
? { caseSlotName: match.config.caseSlotName }
: {}),
};
});
}
function getBrokenPartIndexes(
function getBrokenPartMatches(
parts: readonly ExplodedPart[],
brokenParts: readonly RepairMissionPartConfig[],
): number[] {
if (parts.length === 0 || brokenParts.length === 0) return [];
config: RepairMissionConfig,
): RepairBrokenPartMatch[] {
if (parts.length === 0 || config.brokenParts.length === 0) return [];
const matchedIndexes = brokenParts.flatMap((brokenPart) => {
const matches = config.brokenParts.flatMap((brokenPart) => {
const { nodeName } = brokenPart;
if (!nodeName) return [];
@@ -117,12 +115,30 @@ function getBrokenPartIndexes(
objectContainsNodeName(part.object, nodeName),
);
return index >= 0 ? [index] : [];
return index >= 0 ? [{ config: brokenPart, partIndex: index }] : [];
});
if (matchedIndexes.length > 0) return [...new Set(matchedIndexes)];
if (matches.length !== config.brokenParts.length) {
const matchedIds = new Set(matches.map((match) => match.config.id));
const missingIds = config.brokenParts
.filter((brokenPart) => !matchedIds.has(brokenPart.id))
.map((brokenPart) => brokenPart.id);
return parts.slice(0, brokenParts.length).map((_, index) => index);
const warningKey = `${config.id}:${missingIds.join(",")}`;
if (!warnedMissingScanParts.has(warningKey)) {
warnedMissingScanParts.add(warningKey);
logger.warn("RepairScan", "Broken parts missing from exploded model", {
missionId: config.id,
missingIds,
});
}
}
return matches.filter(
(match, index, allMatches) =>
allMatches.findIndex((item) => item.partIndex === match.partIndex) ===
index,
);
}
function objectContainsNodeName(
@@ -3,7 +3,7 @@ import { Component, useEffect, useMemo, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import { clone } from "three/addons/utils/SkeletonUtils.js";
import { SkeletonUtils } from "three-stdlib";
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
import {
useHandTrackingGloveStatus,
@@ -255,7 +255,7 @@ function HandTrackingGloveModel({
throw new Error(`Missing glove root node ${config.rootNodeName}`);
}
const clonedRootNode = clone(rootNode);
const clonedRootNode = SkeletonUtils.clone(rootNode);
clonedRootNode.visible = false;
return clonedRootNode;
@@ -41,8 +41,27 @@ type InteractableObjectProps =
const _cameraPos = new THREE.Vector3();
const _cameraDir = new THREE.Vector3();
const _objectPos = new THREE.Vector3();
const _objectBounds = new THREE.Box3();
const _raycaster = new THREE.Raycaster();
function getInteractableWorldPosition(
group: THREE.Group,
debugSphere: THREE.Mesh | null,
): THREE.Vector3 {
_objectBounds.makeEmpty();
for (const child of group.children) {
if (child === debugSphere) continue;
_objectBounds.expandByObject(child);
}
if (!_objectBounds.isEmpty()) {
return _objectBounds.getCenter(_objectPos);
}
return group.getWorldPosition(_objectPos);
}
function createInteractableHandle(
props: InteractableObjectProps,
): InteractableHandle {
@@ -158,7 +177,7 @@ export function InteractableObject(
const t = bodyRef.current.translation();
_objectPos.set(t.x, t.y, t.z);
} else if (group) {
group.getWorldPosition(_objectPos);
getInteractableWorldPosition(group, debugSphereRef.current);
} else {
_objectPos.set(...position);
}
+43 -33
View File
@@ -4,11 +4,12 @@ import type { AnimationAction } from "three";
import {
AnimatedModelContext,
type AnimatedModelContextValue,
} from "@/components/three/models/useAnimatedModel";
} from "@/hooks/animation/useAnimatedModel";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { ModelTransformProps } from "@/types/three/three";
import { logger } from "@/utils/core/Logger";
export interface AnimatedModelConfig extends ModelTransformProps {
interface AnimatedModelConfig extends ModelTransformProps {
modelPath: string;
animations?: string[];
defaultAnimation?: string;
@@ -67,32 +68,6 @@ export function AnimatedModel({
}
}, [mixer, onAnimationEnd]);
const play = useCallback(
(name: string, fade = fadeDuration) => {
const action = actions[name];
if (action) {
Object.values(actions).forEach((a) => {
if (a && a !== action) a.fadeOut(fade);
});
action.reset().fadeIn(fade).play();
setCurrentAnim(name);
}
},
[actions, fadeDuration],
);
const stop = useCallback(
(fade = fadeDuration) => {
Object.values(actions).forEach((a) => a?.fadeOut(fade));
const defaultAction = actions[defaultAnimation];
if (defaultAction) {
defaultAction.reset().fadeIn(fade).play();
setCurrentAnim(defaultAnimation);
}
},
[actions, defaultAnimation, fadeDuration],
);
const fadeTo = useCallback(
(name: string, fade = fadeDuration) => {
const action = actions[name];
@@ -106,6 +81,19 @@ export function AnimatedModel({
},
[actions, fadeDuration],
);
const play = fadeTo;
const stop = useCallback(
(fade = fadeDuration) => {
Object.values(actions).forEach((a) => a?.fadeOut(fade));
const defaultAction = actions[defaultAnimation];
if (defaultAction) {
defaultAction.reset().fadeIn(fade).play();
setCurrentAnim(defaultAnimation);
}
},
[actions, defaultAnimation, fadeDuration],
);
const setSpeed = useCallback(
(newSpeed: number) => {
@@ -121,17 +109,39 @@ export function AnimatedModel({
return;
}
let defaultAction = actions[defaultAnimation as string];
let defaultAction = actions[defaultAnimation];
if (!defaultAction && names.length > 0) {
defaultAction = actions[names[0] as string];
const fallbackAnimation = names[0];
if (!defaultAction && fallbackAnimation) {
logger.warn(
"AnimatedModel",
"Default animation missing, using fallback",
{
modelPath,
defaultAnimation,
fallbackAnimation,
availableAnimations: names,
},
);
defaultAction = actions[fallbackAnimation];
}
if (defaultAction) {
defaultAction.play();
Object.values(actions).forEach((action) => {
if (action && action !== defaultAction) action.fadeOut(fadeDuration);
});
defaultAction.reset().fadeIn(fadeDuration).play();
onLoaded?.();
}
}, [actions, defaultAnimation, names, autoPlay, onLoaded]);
}, [
actions,
defaultAnimation,
fadeDuration,
modelPath,
names,
autoPlay,
onLoaded,
]);
const contextValue: AnimatedModelContextValue = {
play,
+23 -10
View File
@@ -1,8 +1,23 @@
import { useMemo } from "react";
import { useEffect } from "react";
import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
export interface SimpleModelConfig extends ModelTransformProps {
function applyShadowSettings(
object: THREE.Object3D,
castShadow: boolean,
receiveShadow: boolean,
): void {
object.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
child.castShadow = castShadow;
child.receiveShadow = receiveShadow;
});
}
interface SimpleModelConfig extends ModelTransformProps {
modelPath: string;
castShadow?: boolean;
receiveShadow?: boolean;
@@ -27,20 +42,18 @@ export function SimpleModel({
rotation,
scale,
});
const model = useMemo(() => scene.clone(true), [scene]);
const model = useClonedObject(scene, { cloneResources: true });
useEffect(() => {
applyShadowSettings(model, castShadow, receiveShadow);
}, [castShadow, model, receiveShadow]);
const parsedScale =
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
return (
<group position={position} rotation={rotation} scale={parsedScale}>
{children ?? (
<primitive
object={model}
castShadow={castShadow}
receiveShadow={receiveShadow}
/>
)}
{children ?? <primitive object={model} />}
</group>
);
}
@@ -1,14 +0,0 @@
import { createContext } from "react";
export interface AnimatedModelContextValue {
play: (name: string, fade?: number) => void;
stop: (fade?: number) => void;
fadeTo: (name: string, fade?: number) => void;
currentAnimation: string;
isReady: boolean;
setSpeed: (speed: number) => void;
names: string[];
}
export const AnimatedModelContext =
createContext<AnimatedModelContextValue | null>(null);
+53
View File
@@ -0,0 +1,53 @@
import { useMemo } from "react";
import * as THREE from "three";
import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import { CLOUD_CONFIG } from "@/data/world/cloudConfig";
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
interface CloudModelProps {
castShadow?: boolean;
receiveShadow?: boolean;
}
function applyCloudSettings(
scene: THREE.Object3D,
castShadow: boolean,
receiveShadow: boolean,
): void {
scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = castShadow;
child.receiveShadow = receiveShadow;
const materials = Array.isArray(child.material)
? child.material
: [child.material];
for (const material of materials) {
material.fog = false;
}
}
});
}
export function CloudModel({
castShadow = false,
receiveShadow = false,
}: CloudModelProps): React.JSX.Element {
const { scene } = useGLTF(CLOUD_CONFIG.modelPath);
const maxAnisotropy = useThree((state) =>
state.gl.capabilities.getMaxAnisotropy(),
);
const cloud = useMemo(() => {
optimizeGLTFSceneTextures(scene, maxAnisotropy);
const model = scene.clone(true);
applyCloudSettings(model, castShadow, receiveShadow);
return model;
}, [castShadow, maxAnisotropy, receiveShadow, scene]);
return <primitive object={cloud} />;
}
useGLTF.preload(CLOUD_CONFIG.modelPath);
+15
View File
@@ -0,0 +1,15 @@
import { useGLTF } from "@react-three/drei";
import {
MergedStaticMapModel,
type MergedStaticMapModelProps,
} from "@/components/three/world/MergedStaticMapModel";
const ECOLE_MODEL_PATH = "/models/ecole/model.gltf";
type EcoleModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
export function EcoleModel(props: EcoleModelProps): React.JSX.Element {
return <MergedStaticMapModel modelPath={ECOLE_MODEL_PATH} {...props} />;
}
useGLTF.preload(ECOLE_MODEL_PATH);
@@ -0,0 +1,19 @@
import { useGLTF } from "@react-three/drei";
import {
MergedStaticMapModel,
type MergedStaticMapModelProps,
} from "@/components/three/world/MergedStaticMapModel";
const FERME_VERTICALE_MODEL_PATH = "/models/fermeverticale/model.gltf";
type FermeVerticaleModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
export function FermeVerticaleModel(
props: FermeVerticaleModelProps,
): React.JSX.Element {
return (
<MergedStaticMapModel modelPath={FERME_VERTICALE_MODEL_PATH} {...props} />
);
}
useGLTF.preload(FERME_VERTICALE_MODEL_PATH);
@@ -0,0 +1,17 @@
import { useGLTF } from "@react-three/drei";
import {
MergedStaticMapModel,
type MergedStaticMapModelProps,
} from "@/components/three/world/MergedStaticMapModel";
const GENERATEUR_MODEL_PATH = "/models/generateur/model.gltf";
type GenerateurModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
export function GenerateurModel(
props: GenerateurModelProps,
): React.JSX.Element {
return <MergedStaticMapModel modelPath={GENERATEUR_MODEL_PATH} {...props} />;
}
useGLTF.preload(GENERATEUR_MODEL_PATH);
@@ -0,0 +1,17 @@
import { useGLTF } from "@react-three/drei";
import {
MergedStaticMapModel,
type MergedStaticMapModelProps,
} from "@/components/three/world/MergedStaticMapModel";
const LA_FABRIK_MODEL_PATH = "/models/lafabrik/model.gltf";
type LaFabrikMapModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
export function LaFabrikMapModel(
props: LaFabrikMapModelProps,
): React.JSX.Element {
return <MergedStaticMapModel modelPath={LA_FABRIK_MODEL_PATH} {...props} />;
}
useGLTF.preload(LA_FABRIK_MODEL_PATH);
@@ -0,0 +1,175 @@
import { useEffect, useRef } from "react";
import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import * as THREE from "three";
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
import type { Vector3Tuple } from "@/types/three/three";
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
export interface MergedStaticMapModelProps {
modelPath: string;
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Tuple;
castShadow?: boolean;
receiveShadow?: boolean;
onLoaded?: () => void;
}
interface MergedMeshData {
geometry: THREE.BufferGeometry;
material: THREE.Material | THREE.Material[];
}
interface GeometryGroup {
geometries: THREE.BufferGeometry[];
material: THREE.Material | THREE.Material[];
}
function cloneMaterial(
material: THREE.Material | THREE.Material[],
): THREE.Material | THREE.Material[] {
return Array.isArray(material)
? material.map((item) => item.clone())
: material.clone();
}
function disposeMaterial(material: THREE.Material | THREE.Material[]): void {
if (Array.isArray(material)) {
for (const item of material) {
item.dispose();
}
return;
}
material.dispose();
}
function createGeometrySignature(geometry: THREE.BufferGeometry): string {
const attributes = Object.entries(geometry.attributes)
.map(([name, attribute]) => {
return `${name}:${attribute.itemSize}:${attribute.normalized}`;
})
.sort()
.join("|");
return `${geometry.index ? "indexed" : "non-indexed"}:${attributes}`;
}
function createMaterialKey(
material: THREE.Material | THREE.Material[],
): string {
if (Array.isArray(material)) {
return material.map((item) => item.uuid).join("|");
}
return material.uuid;
}
function createMergedMeshes(scene: THREE.Group): MergedMeshData[] {
const groups = new Map<string, GeometryGroup>();
scene.updateMatrixWorld(true);
scene.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
const geometry = child.geometry.clone();
geometry.applyMatrix4(child.matrixWorld);
const material = child.material;
const key = `${createMaterialKey(material)}:${createGeometrySignature(geometry)}`;
const group = groups.get(key);
if (group) {
group.geometries.push(geometry);
return;
}
groups.set(key, {
geometries: [geometry],
material: cloneMaterial(material),
});
});
return [...groups.values()]
.map((group) => {
if (group.geometries.length === 1) {
const [geometry] = group.geometries;
if (!geometry) return null;
return {
geometry,
material: group.material,
};
}
const geometry = mergeGeometries(group.geometries, false);
for (const sourceGeometry of group.geometries) {
sourceGeometry.dispose();
}
if (!geometry) {
disposeMaterial(group.material);
return null;
}
return {
geometry,
material: group.material,
};
})
.filter((meshData): meshData is MergedMeshData => meshData !== null);
}
export function MergedStaticMapModel({
modelPath,
position,
rotation,
scale,
castShadow = true,
receiveShadow = true,
onLoaded,
}: MergedStaticMapModelProps): React.JSX.Element {
const { scene } = useGLTF(modelPath);
const maxAnisotropy = useThree((state) =>
state.gl.capabilities.getMaxAnisotropy(),
);
const groupRef = useRef<THREE.Group>(null);
useEffect(() => {
const group = groupRef.current;
if (!group) return;
optimizeGLTFSceneTextures(scene, maxAnisotropy);
const mergedMeshes = createMergedMeshes(scene);
const meshes = mergedMeshes.map((meshData) => {
const mesh = new THREE.Mesh(meshData.geometry, meshData.material);
mesh.castShadow = castShadow;
mesh.receiveShadow = receiveShadow;
return mesh;
});
for (const mesh of meshes) {
group.add(mesh);
}
onLoaded?.();
return () => {
for (const mesh of meshes) {
group.remove(mesh);
mesh.geometry.dispose();
disposeMaterial(mesh.material);
}
};
}, [castShadow, maxAnisotropy, modelPath, onLoaded, receiveShadow, scene]);
return (
<group
ref={groupRef}
position={position}
rotation={rotation}
scale={scale}
/>
);
}
+47 -12
View File
@@ -3,12 +3,15 @@ import { useGLTF } from "@react-three/drei";
import { Component, useEffect, useMemo, useRef, type ReactNode } from "react";
import * as THREE from "three";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { logger } from "@/utils/core/Logger";
interface SkyModelProps {
modelPath: string;
fallbackModelScale?: number | undefined;
fallbackModelPath?: string | undefined;
fallbackScale?: number | undefined;
fallbackColor?: string | undefined;
materialSide?: THREE.Side | undefined;
modelPath: string;
scale?: number | undefined;
unlit?: boolean | undefined;
}
@@ -23,6 +26,8 @@ interface SkyModelContentProps {
interface SkyModelErrorBoundaryProps {
children: ReactNode;
fallback: ReactNode;
label: string;
modelPath: string;
}
interface SkyModelErrorBoundaryState {
@@ -31,7 +36,7 @@ interface SkyModelErrorBoundaryState {
const SKY_MODEL_SCALE = 1;
const SKY_MODEL_RENDER_ORDER = -1000;
const LEGACY_SKY_MODEL_PATH = "/models/sky/model.glb";
const SKYBOX_MODEL_PATH = "/models/skybox/model.gltf";
class SkyModelErrorBoundary extends Component<
SkyModelErrorBoundaryProps,
@@ -46,6 +51,17 @@ class SkyModelErrorBoundary extends Component<
return { hasError: true };
}
componentDidCatch(error: Error): void {
logger.warn(
"SkyModel",
`${this.props.label} model failed; using fallback`,
{
error,
modelPath: this.props.modelPath,
},
);
}
render(): ReactNode {
if (this.state.hasError) {
return this.props.fallback;
@@ -56,6 +72,8 @@ class SkyModelErrorBoundary extends Component<
}
export function SkyModel({
fallbackColor,
fallbackModelScale = SKY_MODEL_SCALE,
fallbackModelPath,
fallbackScale = SKY_MODEL_SCALE,
materialSide = THREE.BackSide,
@@ -63,17 +81,35 @@ export function SkyModel({
scale = SKY_MODEL_SCALE,
unlit = false,
}: SkyModelProps): React.JSX.Element {
const fallback = fallbackModelPath ? (
<SkyModelContent
materialSide={materialSide}
modelPath={fallbackModelPath}
scale={fallbackScale}
unlit={unlit}
/>
const colorFallback = fallbackColor ? (
<color attach="background" args={[fallbackColor]} />
) : null;
const fallback = fallbackModelPath ? (
<SkyModelErrorBoundary
key={fallbackModelPath}
fallback={colorFallback}
label="Fallback sky"
modelPath={fallbackModelPath}
>
<SkyModelContent
materialSide={materialSide}
modelPath={fallbackModelPath}
scale={fallbackScale ?? fallbackModelScale}
unlit={unlit}
/>
</SkyModelErrorBoundary>
) : (
colorFallback
);
return (
<SkyModelErrorBoundary key={modelPath} fallback={fallback}>
<SkyModelErrorBoundary
key={modelPath}
fallback={fallback}
label="Primary sky"
modelPath={modelPath}
>
<SkyModelContent
materialSide={materialSide}
modelPath={modelPath}
@@ -190,5 +226,4 @@ function disposeSkyModelMaterials(model: THREE.Object3D): void {
});
}
useGLTF.preload("/models/skybox/skybox.gltf");
useGLTF.preload(LEGACY_SKY_MODEL_PATH);
useGLTF.preload(SKYBOX_MODEL_PATH);
@@ -0,0 +1,89 @@
import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
import type { Vector3Tuple } from "@/types/three/three";
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
const TERRAIN_DEFAULT_POSITION: Vector3Tuple = [0, 0, 0];
interface TerrainModelProps {
position?: Vector3Tuple;
rotation?: Vector3Tuple;
scale?: Vector3Tuple;
receiveShadow?: boolean;
visible?: boolean;
groupRef?: React.RefObject<THREE.Group>;
onLoaded?: () => void;
}
function applyTerrainMaterialSettings(
scene: THREE.Object3D,
receiveShadow: boolean,
): void {
scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.receiveShadow = receiveShadow;
const materials = Array.isArray(child.material)
? child.material
: [child.material];
for (const material of materials) {
const materialWithAlphaMap = material as THREE.Material & {
alphaMap?: THREE.Texture | null;
};
material.depthTest = true;
material.depthWrite = true;
if (material.opacity >= 1 && !materialWithAlphaMap.alphaMap) {
material.transparent = false;
}
}
}
});
}
export function TerrainModel({
position = TERRAIN_DEFAULT_POSITION,
rotation = [0, 0, 0],
scale = [1, 1, 1],
receiveShadow = true,
visible = true,
groupRef,
onLoaded,
}: TerrainModelProps): React.JSX.Element {
const internalRef = useRef<THREE.Group>(null);
const ref = groupRef ?? internalRef;
const { scene } = useGLTF(TERRAIN_MODEL_PATH);
const maxAnisotropy = useThree((state) =>
state.gl.capabilities.getMaxAnisotropy(),
);
const terrainModel = useMemo(() => {
optimizeGLTFSceneTextures(scene, maxAnisotropy);
const model = scene.clone(true);
applyTerrainMaterialSettings(model, receiveShadow);
return model;
}, [maxAnisotropy, scene, receiveShadow]);
useEffect(() => {
onLoaded?.();
}, [onLoaded]);
return (
<group
ref={ref}
position={position}
rotation={rotation}
scale={scale}
visible={visible}
>
<primitive object={terrainModel} />
</group>
);
}
useGLTF.preload(TERRAIN_MODEL_PATH);