feat(environment): add wind-driven cloud system
This commit is contained in:
@@ -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);
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export const CLOUD_CONFIG = {
|
||||
enabled: true,
|
||||
modelPath: "/models/cloud/model.glb",
|
||||
center: [0, 40, 0] as Vector3Tuple,
|
||||
areaSize: [240, 180] as const,
|
||||
minDriftSpeed: 0.05,
|
||||
wrapPadding: 30,
|
||||
};
|
||||
|
||||
export const CLOUD_DEFAULTS = {
|
||||
count: 10,
|
||||
minHeight: 25,
|
||||
maxHeight: 55,
|
||||
minScale: 5,
|
||||
maxScale: 13,
|
||||
minRotation: 0,
|
||||
maxRotation: Math.PI * 2,
|
||||
minSpeedMultiplier: 0.4,
|
||||
maxSpeedMultiplier: 1,
|
||||
castShadow: false,
|
||||
receiveShadow: false,
|
||||
};
|
||||
|
||||
export const CLOUD_BOUNDS = {
|
||||
count: { min: 0, max: 30, step: 1 },
|
||||
height: { min: 10, max: 100, step: 1 },
|
||||
scale: { min: 1, max: 30, step: 0.5 },
|
||||
rotation: { min: -Math.PI * 2, max: Math.PI * 2, step: 0.1 },
|
||||
speedMultiplier: { min: 0, max: 3, step: 0.1 },
|
||||
};
|
||||
|
||||
export type CloudState = typeof CLOUD_DEFAULTS;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
|
||||
import type { CloudState } from "@/data/world/cloudConfig";
|
||||
|
||||
export function useCloudSettings(): CloudState {
|
||||
return useWorldSettingsStore((state) => state.clouds);
|
||||
}
|
||||
|
||||
export function useSetCloudSettings(): (clouds: Partial<CloudState>) => void {
|
||||
return useWorldSettingsStore((state) => state.setClouds);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { CLOUD_DEFAULTS, type CloudState } from "@/data/world/cloudConfig";
|
||||
import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig";
|
||||
import {
|
||||
GRAPHICS_DEFAULTS,
|
||||
@@ -6,11 +7,13 @@ import {
|
||||
} from "@/data/world/graphicsConfig";
|
||||
|
||||
interface WorldSettingsState {
|
||||
clouds: CloudState;
|
||||
wind: WindState;
|
||||
graphics: GraphicsState;
|
||||
}
|
||||
|
||||
interface WorldSettingsActions {
|
||||
setClouds: (clouds: Partial<CloudState>) => void;
|
||||
setWind: (wind: Partial<WindState>) => void;
|
||||
setWindSpeed: (speed: number) => void;
|
||||
setWindDirection: (direction: number) => void;
|
||||
@@ -27,6 +30,7 @@ interface WorldSettingsActions {
|
||||
type WorldSettingsStore = WorldSettingsState & WorldSettingsActions;
|
||||
|
||||
const DEFAULT_STATE: WorldSettingsState = {
|
||||
clouds: { ...CLOUD_DEFAULTS },
|
||||
wind: { ...WIND_DEFAULTS },
|
||||
graphics: { ...GRAPHICS_DEFAULTS },
|
||||
};
|
||||
@@ -34,6 +38,11 @@ const DEFAULT_STATE: WorldSettingsState = {
|
||||
export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({
|
||||
...DEFAULT_STATE,
|
||||
|
||||
setClouds: (cloudsUpdate) =>
|
||||
set((state) => ({
|
||||
clouds: { ...state.clouds, ...cloudsUpdate },
|
||||
})),
|
||||
|
||||
setWind: (windUpdate) =>
|
||||
set((state) => ({
|
||||
wind: { ...state.wind, ...windUpdate },
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
useMapPerformanceStore,
|
||||
} from "@/managers/stores/useMapPerformanceStore";
|
||||
import { GameMapCollision } from "@/world/GameMapCollision";
|
||||
import { CloudSystem } from "@/world/clouds/CloudSystem";
|
||||
import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance";
|
||||
import { isGeneratedMapModelName } from "@/world/map-generated/generatedMapModelConfig";
|
||||
import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem";
|
||||
@@ -261,6 +262,7 @@ export function GameMap({
|
||||
<MapInstancingSystem />
|
||||
<WorldPlane />
|
||||
<WaterSystem />
|
||||
<CloudSystem />
|
||||
<VegetationSystem />
|
||||
{isMapModelVisible("terrain", { groups, models }) ? (
|
||||
<TerrainModel />
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Suspense, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { CLOUD_CONFIG } from "@/data/world/cloudConfig";
|
||||
import { getWindVector } from "@/data/world/windConfig";
|
||||
import { useDynamicClouds } from "@/hooks/world/useGraphicsSettings";
|
||||
import { useCloudSettings } from "@/hooks/world/useCloudSettings";
|
||||
import { useWind } from "@/hooks/world/useWind";
|
||||
import { CloudModel } from "@/components/three/world/CloudModel";
|
||||
import type { CloudState } from "@/data/world/cloudConfig";
|
||||
|
||||
interface CloudInstance {
|
||||
height: number;
|
||||
rotationY: number;
|
||||
scale: number;
|
||||
speedMultiplier: number;
|
||||
x: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
function lerp(min: number, max: number, ratio: number): number {
|
||||
return min + (max - min) * ratio;
|
||||
}
|
||||
|
||||
function createCloudInstances(cloudSettings: CloudState): CloudInstance[] {
|
||||
const instances: CloudInstance[] = [];
|
||||
const [areaWidth, areaDepth] = CLOUD_CONFIG.areaSize;
|
||||
const count = Math.max(0, Math.round(cloudSettings.count));
|
||||
const columns = Math.ceil(Math.sqrt(count));
|
||||
const rows = columns > 0 ? Math.ceil(count / columns) : 0;
|
||||
|
||||
for (let index = 0; index < count; index++) {
|
||||
const column = index % columns;
|
||||
const row = Math.floor(index / columns);
|
||||
const columnRatio = columns <= 1 ? 0.5 : column / (columns - 1);
|
||||
const rowRatio = rows <= 1 ? 0.5 : row / (rows - 1);
|
||||
const variation = ((index * 37) % 100) / 100;
|
||||
|
||||
instances.push({
|
||||
height: lerp(cloudSettings.minHeight, cloudSettings.maxHeight, variation),
|
||||
rotationY: lerp(
|
||||
cloudSettings.minRotation,
|
||||
cloudSettings.maxRotation,
|
||||
variation,
|
||||
),
|
||||
scale: lerp(cloudSettings.minScale, cloudSettings.maxScale, variation),
|
||||
speedMultiplier: lerp(
|
||||
cloudSettings.minSpeedMultiplier,
|
||||
cloudSettings.maxSpeedMultiplier,
|
||||
((index * 53) % 100) / 100,
|
||||
),
|
||||
x: CLOUD_CONFIG.center[0] + (columnRatio - 0.5) * areaWidth,
|
||||
z: CLOUD_CONFIG.center[2] + (rowRatio - 0.5) * areaDepth,
|
||||
});
|
||||
}
|
||||
|
||||
return instances;
|
||||
}
|
||||
|
||||
function wrapAxis(
|
||||
value: number,
|
||||
center: number,
|
||||
size: number,
|
||||
padding: number,
|
||||
): number {
|
||||
const min = center - size / 2 - padding;
|
||||
const max = center + size / 2 + padding;
|
||||
const range = max - min;
|
||||
|
||||
if (value < min) return value + range;
|
||||
if (value > max) return value - range;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function CloudSystem(): React.JSX.Element | null {
|
||||
const cloudSettings = useCloudSettings();
|
||||
const dynamicClouds = useDynamicClouds();
|
||||
const wind = useWind();
|
||||
const refs = useRef<Array<THREE.Group | null>>([]);
|
||||
const clouds = useMemo(
|
||||
() => createCloudInstances(cloudSettings),
|
||||
[cloudSettings],
|
||||
);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const windVector = getWindVector(wind);
|
||||
const windLength = Math.hypot(windVector.x, windVector.z);
|
||||
const safeWindLength = Math.max(windLength, CLOUD_CONFIG.minDriftSpeed);
|
||||
const directionX =
|
||||
windLength > 0 ? windVector.x / windLength : Math.cos(wind.direction);
|
||||
const directionZ =
|
||||
windLength > 0 ? windVector.z / windLength : Math.sin(wind.direction);
|
||||
|
||||
refs.current.forEach((cloud, index) => {
|
||||
if (!cloud) return;
|
||||
|
||||
const instance = clouds[index];
|
||||
if (!instance) return;
|
||||
|
||||
const distance = safeWindLength * instance.speedMultiplier * delta;
|
||||
cloud.position.x = wrapAxis(
|
||||
cloud.position.x + directionX * distance,
|
||||
CLOUD_CONFIG.center[0],
|
||||
CLOUD_CONFIG.areaSize[0],
|
||||
CLOUD_CONFIG.wrapPadding,
|
||||
);
|
||||
cloud.position.z = wrapAxis(
|
||||
cloud.position.z + directionZ * distance,
|
||||
CLOUD_CONFIG.center[2],
|
||||
CLOUD_CONFIG.areaSize[1],
|
||||
CLOUD_CONFIG.wrapPadding,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
if (!CLOUD_CONFIG.enabled || !dynamicClouds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<group name="cloud-system">
|
||||
{clouds.map((cloud, index) => (
|
||||
<group
|
||||
key={index}
|
||||
ref={(node) => {
|
||||
refs.current[index] = node;
|
||||
}}
|
||||
position={[cloud.x, cloud.height, cloud.z]}
|
||||
rotation={[0, cloud.rotationY, 0]}
|
||||
scale={cloud.scale}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<CloudModel
|
||||
castShadow={cloudSettings.castShadow}
|
||||
receiveShadow={cloudSettings.receiveShadow}
|
||||
/>
|
||||
</Suspense>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user