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 { create } from "zustand";
|
||||||
|
import { CLOUD_DEFAULTS, type CloudState } from "@/data/world/cloudConfig";
|
||||||
import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig";
|
import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig";
|
||||||
import {
|
import {
|
||||||
GRAPHICS_DEFAULTS,
|
GRAPHICS_DEFAULTS,
|
||||||
@@ -6,11 +7,13 @@ import {
|
|||||||
} from "@/data/world/graphicsConfig";
|
} from "@/data/world/graphicsConfig";
|
||||||
|
|
||||||
interface WorldSettingsState {
|
interface WorldSettingsState {
|
||||||
|
clouds: CloudState;
|
||||||
wind: WindState;
|
wind: WindState;
|
||||||
graphics: GraphicsState;
|
graphics: GraphicsState;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorldSettingsActions {
|
interface WorldSettingsActions {
|
||||||
|
setClouds: (clouds: Partial<CloudState>) => void;
|
||||||
setWind: (wind: Partial<WindState>) => void;
|
setWind: (wind: Partial<WindState>) => void;
|
||||||
setWindSpeed: (speed: number) => void;
|
setWindSpeed: (speed: number) => void;
|
||||||
setWindDirection: (direction: number) => void;
|
setWindDirection: (direction: number) => void;
|
||||||
@@ -27,6 +30,7 @@ interface WorldSettingsActions {
|
|||||||
type WorldSettingsStore = WorldSettingsState & WorldSettingsActions;
|
type WorldSettingsStore = WorldSettingsState & WorldSettingsActions;
|
||||||
|
|
||||||
const DEFAULT_STATE: WorldSettingsState = {
|
const DEFAULT_STATE: WorldSettingsState = {
|
||||||
|
clouds: { ...CLOUD_DEFAULTS },
|
||||||
wind: { ...WIND_DEFAULTS },
|
wind: { ...WIND_DEFAULTS },
|
||||||
graphics: { ...GRAPHICS_DEFAULTS },
|
graphics: { ...GRAPHICS_DEFAULTS },
|
||||||
};
|
};
|
||||||
@@ -34,6 +38,11 @@ const DEFAULT_STATE: WorldSettingsState = {
|
|||||||
export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({
|
export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({
|
||||||
...DEFAULT_STATE,
|
...DEFAULT_STATE,
|
||||||
|
|
||||||
|
setClouds: (cloudsUpdate) =>
|
||||||
|
set((state) => ({
|
||||||
|
clouds: { ...state.clouds, ...cloudsUpdate },
|
||||||
|
})),
|
||||||
|
|
||||||
setWind: (windUpdate) =>
|
setWind: (windUpdate) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
wind: { ...state.wind, ...windUpdate },
|
wind: { ...state.wind, ...windUpdate },
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
useMapPerformanceStore,
|
useMapPerformanceStore,
|
||||||
} from "@/managers/stores/useMapPerformanceStore";
|
} from "@/managers/stores/useMapPerformanceStore";
|
||||||
import { GameMapCollision } from "@/world/GameMapCollision";
|
import { GameMapCollision } from "@/world/GameMapCollision";
|
||||||
|
import { CloudSystem } from "@/world/clouds/CloudSystem";
|
||||||
import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance";
|
import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance";
|
||||||
import { isGeneratedMapModelName } from "@/world/map-generated/generatedMapModelConfig";
|
import { isGeneratedMapModelName } from "@/world/map-generated/generatedMapModelConfig";
|
||||||
import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem";
|
import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem";
|
||||||
@@ -261,6 +262,7 @@ export function GameMap({
|
|||||||
<MapInstancingSystem />
|
<MapInstancingSystem />
|
||||||
<WorldPlane />
|
<WorldPlane />
|
||||||
<WaterSystem />
|
<WaterSystem />
|
||||||
|
<CloudSystem />
|
||||||
<VegetationSystem />
|
<VegetationSystem />
|
||||||
{isMapModelVisible("terrain", { groups, models }) ? (
|
{isMapModelVisible("terrain", { groups, models }) ? (
|
||||||
<TerrainModel />
|
<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