fix(world): stabilize lafabrik spawn and vegetation

This commit is contained in:
Tom Boullay
2026-06-01 01:32:21 +02:00
parent 061e0dc677
commit aa2d411b0c
12 changed files with 51 additions and 110 deletions
+1 -3
View File
@@ -3,7 +3,6 @@ import * as THREE from "three";
import { useGLTF } from "@react-three/drei"; import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber"; import { useThree } from "@react-three/fiber";
import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig"; import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
import { flattenLaFabrikTerrainFootprint } from "@/data/world/laFabrikConfig";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene"; import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
@@ -66,10 +65,9 @@ export function TerrainModel({
const terrainModel = useMemo(() => { const terrainModel = useMemo(() => {
optimizeGLTFSceneTextures(scene, maxAnisotropy); optimizeGLTFSceneTextures(scene, maxAnisotropy);
const model = scene.clone(true); const model = scene.clone(true);
flattenLaFabrikTerrainFootprint(model, position, rotation, scale);
applyTerrainMaterialSettings(model, receiveShadow); applyTerrainMaterialSettings(model, receiveShadow);
return model; return model;
}, [maxAnisotropy, position, receiveShadow, rotation, scale, scene]); }, [maxAnisotropy, scene, receiveShadow]);
useEffect(() => { useEffect(() => {
onLoaded?.(); onLoaded?.();
+5 -1
View File
@@ -15,5 +15,9 @@ export const PLAYER_XZ_DAMPING_FACTOR = 8;
export const PLAYER_FALL_RESPAWN_Y = -20; export const PLAYER_FALL_RESPAWN_Y = -20;
export const PLAYER_FALL_RESPAWN_DELAY = 3; export const PLAYER_FALL_RESPAWN_DELAY = 3;
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = LA_FABRIK_PLAYER_SPAWN; export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [
LA_FABRIK_PLAYER_SPAWN[0] + 5,
LA_FABRIK_PLAYER_SPAWN[1],
LA_FABRIK_PLAYER_SPAWN[2] + 5,
];
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0]; export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
@@ -20,7 +20,6 @@ export interface CharacterConfig {
scale: Vector3Tuple; scale: Vector3Tuple;
animations: readonly string[]; animations: readonly string[];
defaultAnimation: string; defaultAnimation: string;
snapToTerrain?: boolean;
} }
export const CHARACTER_CONFIGS = { export const CHARACTER_CONFIGS = {
+2 -56
View File
@@ -1,4 +1,3 @@
import * as THREE from "three";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
export const LA_FABRIK_CENTER: Vector3Tuple = [59.4973, 6.2746, 64.6354]; export const LA_FABRIK_CENTER: Vector3Tuple = [59.4973, 6.2746, 64.6354];
@@ -7,15 +6,10 @@ export const LA_FABRIK_HALF_EXTENTS = {
x: 8.5, x: 8.5,
z: 7.5, z: 7.5,
} as const; } as const;
export const LA_FABRIK_FLOOR_Y = 6.3; export const LA_FABRIK_PLAYER_SPAWN: Vector3Tuple = [59.5, 7.8, 64.64];
export const LA_FABRIK_PLAYER_SPAWN: Vector3Tuple = [59.5, 8.05, 64.64]; export const LA_FABRIK_INITIAL_LOOK_AT: Vector3Tuple = [58, 7.8, 62.5];
export const LA_FABRIK_INTERIOR_LIGHT_POSITION: Vector3Tuple = [59.5, 9, 64.64]; export const LA_FABRIK_INTERIOR_LIGHT_POSITION: Vector3Tuple = [59.5, 9, 64.64];
const _terrainMatrix = new THREE.Matrix4();
const _meshWorldMatrix = new THREE.Matrix4();
const _inverseMeshWorldMatrix = new THREE.Matrix4();
const _worldPosition = new THREE.Vector3();
export function isInsideLaFabrikFootprint( export function isInsideLaFabrikFootprint(
x: number, x: number,
z: number, z: number,
@@ -33,51 +27,3 @@ export function isInsideLaFabrikFootprint(
Math.abs(localZ) <= LA_FABRIK_HALF_EXTENTS.z + padding Math.abs(localZ) <= LA_FABRIK_HALF_EXTENTS.z + padding
); );
} }
export function flattenLaFabrikTerrainFootprint(
object: THREE.Object3D,
position: Vector3Tuple,
rotation: Vector3Tuple,
scale: Vector3Tuple,
): void {
_terrainMatrix.compose(
new THREE.Vector3(...position),
new THREE.Quaternion().setFromEuler(new THREE.Euler(...rotation)),
new THREE.Vector3(...scale),
);
object.updateMatrixWorld(true);
object.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
const geometry = child.geometry;
const positions = geometry.getAttribute("position");
if (!positions) return;
_meshWorldMatrix.multiplyMatrices(_terrainMatrix, child.matrixWorld);
_inverseMeshWorldMatrix.copy(_meshWorldMatrix).invert();
for (let index = 0; index < positions.count; index++) {
_worldPosition
.fromBufferAttribute(positions, index)
.applyMatrix4(_meshWorldMatrix);
if (!isInsideLaFabrikFootprint(_worldPosition.x, _worldPosition.z, 0.8)) {
continue;
}
_worldPosition.y = Math.min(_worldPosition.y, LA_FABRIK_FLOOR_Y - 0.35);
_worldPosition.applyMatrix4(_inverseMeshWorldMatrix);
positions.setXYZ(
index,
_worldPosition.x,
_worldPosition.y,
_worldPosition.z,
);
}
positions.needsUpdate = true;
geometry.computeVertexNormals();
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
});
}
-8
View File
@@ -2,10 +2,6 @@ import { useMemo } from "react";
import { useGLTF } from "@react-three/drei"; import { useGLTF } from "@react-three/drei";
import * as THREE from "three"; import * as THREE from "three";
import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig"; import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
import {
isInsideLaFabrikFootprint,
LA_FABRIK_FLOOR_Y,
} from "@/data/world/laFabrikConfig";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
import { getMapNodesByName } from "@/utils/map/loadMapSceneData"; import { getMapNodesByName } from "@/utils/map/loadMapSceneData";
@@ -70,10 +66,6 @@ function createTerrainHeightSampler(
return { return {
getHeight: (x, z) => { getHeight: (x, z) => {
if (isInsideLaFabrikFootprint(x, z, 0.6)) {
return LA_FABRIK_FLOOR_Y;
}
localOrigin.set(x, RAYCAST_Y, z).applyMatrix4(inverseTerrainMatrix); localOrigin.set(x, RAYCAST_Y, z).applyMatrix4(inverseTerrainMatrix);
raycaster.set(localOrigin, localDirection); raycaster.set(localOrigin, localDirection);
hits.length = 0; hits.length = 0;
+4 -29
View File
@@ -18,7 +18,6 @@ import {
useTerrainHeightSampler, useTerrainHeightSampler,
} from "@/hooks/three/useTerrainHeight"; } from "@/hooks/three/useTerrainHeight";
import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision"; import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision";
import { flattenLaFabrikTerrainFootprint } from "@/data/world/laFabrikConfig";
import type { MapNode } from "@/types/map/mapScene"; import type { MapNode } from "@/types/map/mapScene";
import type { OctreeReadyHandler } from "@/types/three/three"; import type { OctreeReadyHandler } from "@/types/three/three";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading"; import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
@@ -214,7 +213,7 @@ function CollisionModelInstance({
modelUrl: string; modelUrl: string;
onLoaded: () => void; onLoaded: () => void;
terrainHeight: TerrainHeightSampler; terrainHeight: TerrainHeightSampler;
}): React.JSX.Element | null { }): React.JSX.Element {
const { position, rotation, scale } = node; const { position, rotation, scale } = node;
const normalizedScale = normalizeMapScale(scale); const normalizedScale = normalizeMapScale(scale);
const { scene } = useLoggedGLTF(modelUrl, { const { scene } = useLoggedGLTF(modelUrl, {
@@ -224,46 +223,22 @@ function CollisionModelInstance({
scale: normalizedScale, scale: normalizedScale,
}); });
const sceneInstance = useClonedObject(scene); const sceneInstance = useClonedObject(scene);
const collisionSceneInstance = useMemo(() => {
if (node.name === "terrain") {
flattenLaFabrikTerrainFootprint(
sceneInstance,
position,
rotation,
normalizedScale,
);
}
return sceneInstance;
}, [node.name, normalizedScale, position, rotation, sceneInstance]);
const collisionPosition = useMemo(() => { const collisionPosition = useMemo(() => {
if (node.name === "terrain") return position; if (node.name === "terrain") return position;
const [x, y, z] = position; const [x, y, z] = position;
const height = terrainHeight.getHeight(x, z); const height = terrainHeight.getHeight(x, z);
const bottomOffset = getObjectBottomOffset( const bottomOffset = getObjectBottomOffset(sceneInstance, normalizedScale);
collisionSceneInstance,
normalizedScale,
);
return [x, height !== null ? height + bottomOffset : y, z] as const; return [x, height !== null ? height + bottomOffset : y, z] as const;
}, [ }, [node.name, normalizedScale, position, sceneInstance, terrainHeight]);
node.name,
normalizedScale,
position,
collisionSceneInstance,
terrainHeight,
]);
useEffect(() => { useEffect(() => {
onLoaded(); onLoaded();
}, [onLoaded]); }, [onLoaded]);
if (node.name === "lafabrik") {
return null;
}
return ( return (
<primitive <primitive
object={collisionSceneInstance} object={sceneInstance}
position={collisionPosition} position={collisionPosition}
rotation={rotation} rotation={rotation}
scale={normalizedScale} scale={normalizedScale}
+6 -1
View File
@@ -4,6 +4,7 @@ import {
PLAYER_SPAWN_POSITION_GAME, PLAYER_SPAWN_POSITION_GAME,
PLAYER_SPAWN_POSITION_PHYSICS, PLAYER_SPAWN_POSITION_PHYSICS,
} from "@/data/player/playerConfig"; } from "@/data/player/playerConfig";
import { LA_FABRIK_INITIAL_LOOK_AT } from "@/data/world/laFabrikConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode"; import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug"; import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug"; import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
@@ -99,7 +100,11 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
<GameMusic /> <GameMusic />
{mainState === "outro" ? <GameCinematics /> : null} {mainState === "outro" ? <GameCinematics /> : null}
{mainState !== "intro" ? <GameDialogues /> : null} {mainState !== "intro" ? <GameDialogues /> : null}
<Player octree={octree} spawnPosition={playerSpawnPosition} /> <Player
initialLookAt={LA_FABRIK_INITIAL_LOOK_AT}
octree={octree}
spawnPosition={playerSpawnPosition}
/>
</> </>
) : null} ) : null}
</> </>
+2 -5
View File
@@ -3,18 +3,15 @@ import { AnimatedModel } from "@/components/three/models/AnimatedModel";
import { import {
CHARACTER_CONFIGS, CHARACTER_CONFIGS,
CHARACTER_IDS, CHARACTER_IDS,
type CharacterConfig,
type CharacterId, type CharacterId,
} from "@/data/world/characters/characterConfig"; } from "@/data/world/characters/characterConfig";
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight"; import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore"; import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore";
function CharacterModel({ id }: { id: CharacterId }): React.JSX.Element { function CharacterModel({ id }: { id: CharacterId }): React.JSX.Element {
const config: CharacterConfig = CHARACTER_CONFIGS[id]; const config = CHARACTER_CONFIGS[id];
const state = useCharacterDebugStore((store) => store.characters[id]); const state = useCharacterDebugStore((store) => store.characters[id]);
const snappedPosition = useTerrainSnappedPosition(state.position); const position = useTerrainSnappedPosition(state.position);
const position =
config.snapToTerrain === false ? state.position : snappedPosition;
return ( return (
<AnimatedModel <AnimatedModel
@@ -17,8 +17,7 @@ export function GeneratedMapNodeInstance({
node, node,
onLoaded, onLoaded,
}: GeneratedMapNodeInstanceProps): React.JSX.Element | null { }: GeneratedMapNodeInstanceProps): React.JSX.Element | null {
const snappedPosition = useTerrainSnappedPosition(node.position); const position = useTerrainSnappedPosition(node.position);
const position = node.name === "lafabrik" ? node.position : snappedPosition;
const scale = normalizeMapScale(node.scale); const scale = normalizeMapScale(node.scale);
if (node.name === "ecole") { if (node.name === "ecole") {
+9 -2
View File
@@ -7,10 +7,12 @@ import { PlayerController } from "@/world/player/PlayerController";
interface PlayerProps { interface PlayerProps {
octree: Octree | null; octree: Octree | null;
initialLookAt?: Vector3Tuple | undefined;
spawnPosition: Vector3Tuple; spawnPosition: Vector3Tuple;
} }
export function Player({ export function Player({
initialLookAt,
spawnPosition, spawnPosition,
octree, octree,
}: PlayerProps): React.JSX.Element { }: PlayerProps): React.JSX.Element {
@@ -18,12 +20,17 @@ export function Player({
useLayoutEffect(() => { useLayoutEffect(() => {
camera.position.set(...spawnPosition); camera.position.set(...spawnPosition);
}, [camera, spawnPosition]); if (initialLookAt) camera.lookAt(...initialLookAt);
}, [camera, initialLookAt, spawnPosition]);
return ( return (
<> <>
<PlayerCamera /> <PlayerCamera />
<PlayerController octree={octree} spawnPosition={spawnPosition} /> <PlayerController
initialLookAt={initialLookAt}
octree={octree}
spawnPosition={spawnPosition}
/>
</> </>
); );
} }
+7 -1
View File
@@ -75,6 +75,7 @@ const PLAYER_FLOOR_NORMAL_MIN = 0.15;
const PLAYER_GROUND_SNAP_DISTANCE = 0.22; const PLAYER_GROUND_SNAP_DISTANCE = 0.22;
interface PlayerControllerProps { interface PlayerControllerProps {
initialLookAt?: Vector3Tuple | undefined;
octree: Octree | null; octree: Octree | null;
spawnPosition: Vector3Tuple; spawnPosition: Vector3Tuple;
} }
@@ -89,6 +90,7 @@ const _collisionCorrection = new THREE.Vector3();
function resetPlayerCapsule( function resetPlayerCapsule(
capsule: Capsule, capsule: Capsule,
spawnPosition: Vector3Tuple, spawnPosition: Vector3Tuple,
initialLookAt: Vector3Tuple | undefined,
camera: THREE.Camera, camera: THREE.Camera,
velocity: THREE.Vector3, velocity: THREE.Vector3,
): void { ): void {
@@ -100,6 +102,7 @@ function resetPlayerCapsule(
capsule.end.set(...spawnPosition); capsule.end.set(...spawnPosition);
velocity.set(0, 0, 0); velocity.set(0, 0, 0);
camera.position.copy(capsule.end); camera.position.copy(capsule.end);
if (initialLookAt) camera.lookAt(...initialLookAt);
} }
function createSpawnCapsule(spawnPosition: Vector3Tuple): Capsule { function createSpawnCapsule(spawnPosition: Vector3Tuple): Capsule {
@@ -145,6 +148,7 @@ function getCapsuleFootY(capsule: Capsule): number {
} }
export function PlayerController({ export function PlayerController({
initialLookAt,
octree, octree,
spawnPosition, spawnPosition,
}: PlayerControllerProps): null { }: PlayerControllerProps): null {
@@ -234,6 +238,7 @@ export function PlayerController({
resetPlayerCapsule( resetPlayerCapsule(
capsule.current, capsule.current,
spawnPosition, spawnPosition,
initialLookAt,
camera, camera,
velocity.current, velocity.current,
); );
@@ -241,7 +246,7 @@ export function PlayerController({
onFloor.current = false; onFloor.current = false;
wantsJump.current = false; wantsJump.current = false;
initializedRef.current = true; initializedRef.current = true;
}, [camera, spawnPosition]); }, [camera, initialLookAt, spawnPosition]);
useEffect(() => { useEffect(() => {
movementLockedRef.current = movementLocked; movementLockedRef.current = movementLocked;
@@ -339,6 +344,7 @@ export function PlayerController({
resetPlayerCapsule( resetPlayerCapsule(
capsule.current, capsule.current,
spawnPosition, spawnPosition,
initialLookAt,
camera, camera,
velocity.current, velocity.current,
); );
+14 -1
View File
@@ -16,6 +16,7 @@ import {
VEGETATION_TYPES, VEGETATION_TYPES,
type VegetationType, type VegetationType,
} from "@/data/world/vegetationConfig"; } from "@/data/world/vegetationConfig";
import { isInsideLaFabrikFootprint } from "@/data/world/laFabrikConfig";
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances"; import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
interface VegetationSystemProps { interface VegetationSystemProps {
@@ -60,6 +61,15 @@ function createVegetationChunks(
}); });
} }
function removeLaFabrikVegetation(
instances: VegetationInstance[],
): VegetationInstance[] {
return instances.filter((instance) => {
const [x, , z] = instance.position;
return !isInsideLaFabrikFootprint(x, z, 1.2);
});
}
export function VegetationSystem({ export function VegetationSystem({
onlyMapName = null, onlyMapName = null,
streaming = true, streaming = true,
@@ -90,7 +100,10 @@ export function VegetationSystem({
const entry = data.get(config.mapName); const entry = data.get(config.mapName);
if (!entry || entry.instances.length === 0) return []; if (!entry || entry.instances.length === 0) return [];
return createVegetationChunks(type, entry.instances); const instances = removeLaFabrikVegetation(entry.instances);
if (instances.length === 0) return [];
return createVegetationChunks(type, instances);
}); });
}, [data, groups, models, onlyMapName]); }, [data, groups, models, onlyMapName]);