16 Commits

Author SHA1 Message Date
Tom Boullay ab3943eef3 tune(environment): add cloud controls and visibility fixes
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-05-27 00:34:01 +02:00
Tom Boullay 4ebb5b8c25 feat(environment): add wind-driven cloud system 2026-05-27 00:33:53 +02:00
Tom Boullay a6787a7ecb chore: tune lighting and world plane material 2026-05-27 00:09:26 +02:00
Tom Boullay d816e4b07e feat(lighting): expose light colors in debug controls 2026-05-26 23:53:04 +02:00
Tom Boullay 665d9f9702 fix(map): add world plane collision and respawn 2026-05-26 23:52:12 +02:00
Tom Boullay 0696ca2ae3 perf: stream water shader by distance 2026-05-26 23:19:41 +02:00
Tom Boullay d6d3d5b685 fix: stabilize water depth and rounded mask 2026-05-26 22:56:50 +02:00
Tom Boullay 1c27d55e5a feat(map): add terrain boundary collision 2026-05-26 22:06:13 +02:00
Tom Boullay fd558db034 tune(environment): make water surface adjustable 2026-05-26 22:05:00 +02:00
Tom Boullay fbe8c0c854 feat(environment): add terrain water shader 2026-05-25 19:09:13 +02:00
Tom Boullay 88b6db6166 fix(map): disable generated path system 2026-05-25 18:42:38 +02:00
Tom Boullay 2b08665508 fix(map): align path preview with terrain projection 2026-05-25 17:54:57 +02:00
Tom Boullay 4f8355e934 debug(map): add path surface sampling preview 2026-05-25 17:40:46 +02:00
Tom Boullay 417afdc1d5 fix(terrain): map surface colors with configurable projection 2026-05-25 17:40:01 +02:00
Tom Boullay 235a38f67b fix(map): disable generated paths while correcting mapping 2026-05-25 17:39:00 +02:00
Tom Boullay f54e71fc03 feat(map): generate path tiles from terrain colors 2026-05-25 17:08:07 +02:00
33 changed files with 1266 additions and 33 deletions
+3 -1
View File
@@ -6,12 +6,14 @@ import {
DEBUG_GRID_SIZE,
DEBUG_GRID_Y,
} from "@/data/debug/debugConfig";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { Debug } from "@/utils/debug/Debug";
export function DebugHelpers(): React.JSX.Element | null {
const debug = Debug.getInstance();
const sceneMode = useSceneMode();
if (!debug.active) {
if (!debug.active || sceneMode === "game") {
return 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);
@@ -25,6 +25,23 @@ function applyTerrainMaterialSettings(
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;
}
}
}
});
}
+2
View File
@@ -10,6 +10,8 @@ export const PLAYER_GRAVITY = 30;
export const PLAYER_MAX_DELTA = 0.05;
export const PLAYER_ACCELERATION_MULTIPLIER = 9;
export const PLAYER_XZ_DAMPING_FACTOR = 8;
export const PLAYER_FALL_RESPAWN_Y = -20;
export const PLAYER_FALL_RESPAWN_DELAY = 3;
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [0, 50, 0];
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
+34
View File
@@ -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;
+9 -7
View File
@@ -1,12 +1,14 @@
export const AMBIENT_LIGHT_COLOR = "#dbeafe";
export const SUN_LIGHT_COLOR = "#fff7ed";
export const AMBIENT_LIGHT_COLOR = "#dfe7d8";
export const SUN_LIGHT_COLOR = "#ffe2bf";
export const LIGHTING_DEFAULTS = {
ambientIntensity: 1.8,
sunIntensity: 2.8,
sunX: 60,
sunY: 80,
sunZ: 30,
ambientColor: AMBIENT_LIGHT_COLOR,
ambientIntensity: 0.9,
sunColor: SUN_LIGHT_COLOR,
sunIntensity: 2.2,
sunX: 70,
sunY: 45,
sunZ: 35,
};
export const AMBIENT_INTENSITY_MIN = 0;
+7 -1
View File
@@ -2,7 +2,13 @@ import type { TerrainSurfaceColorConfig } from "@/types/world/terrainSurface";
export const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf";
export const TERRAIN_SURFACE_COLOR_TOLERANCE = 15;
export const TERRAIN_WATER_HEIGHT = 0;
export const TERRAIN_SURFACE_PROJECTION = {
flipX: false,
flipZ: true,
offsetX: 0,
offsetZ: 0,
};
export const TERRAIN_WATER_HEIGHT = 0.8;
export const TERRAIN_TILE_SIZE = 1;
export const GRASS_BASE_COLOR = "#1a3a1a";
+49
View File
@@ -0,0 +1,49 @@
import { TERRAIN_WATER_HEIGHT } from "@/data/world/terrainConfig";
import type { Vector3Tuple } from "@/types/three/three";
export interface WaterSurfaceConfig {
position: Vector3Tuple;
rotation: Vector3Tuple;
size: [number, number];
renderOrder: number;
}
export const WATER_SHADER_CONFIG = {
enabled: true,
height: TERRAIN_WATER_HEIGHT,
depthOffset: -0.04,
borderRadius: 0.18,
borderSoftness: 0.035,
scale: 0.4,
smoothness: 0.55,
edgeThreshold: 0.067,
edgeSoftness: 0.01,
flowX: 0,
flowZ: 0.05,
cellSpeed: 0.3,
noiseScale: 1.52,
noiseFlowSpeed: 0.2,
distortAmount: 0.3,
deepColor: "#1a3a5c",
midColor: "#59c0e8",
midPos: 0.084,
highlightColor: "#ffffff",
opacity: 0.88,
deepOpacity: 0.45,
};
export const WATER_STREAMING_CONFIG = {
enabled: true,
loadDistance: 40,
unloadDistance: 48,
updateInterval: 350,
};
export const WATER_SURFACES: WaterSurfaceConfig[] = [
{
position: [40, TERRAIN_WATER_HEIGHT, -102],
rotation: [0, 0, 0],
size: [75, 45],
renderOrder: 0,
},
];
+13 -4
View File
@@ -1,8 +1,8 @@
export const WIND_DEFAULTS = {
speed: 0.3,
direction: Math.PI * 0.25,
strength: 1.0,
noiseScale: 0.9,
speed: 1.5,
direction: 0.5584,
strength: 1.5,
noiseScale: 0.5,
};
export const WIND_BOUNDS = {
@@ -13,3 +13,12 @@ export const WIND_BOUNDS = {
};
export type WindState = typeof WIND_DEFAULTS;
export function getWindVector(wind: WindState): { x: number; z: number } {
const intensity = wind.speed * wind.strength;
return {
x: Math.cos(wind.direction) * intensity,
z: Math.sin(wind.direction) * intensity,
};
}
+13
View File
@@ -0,0 +1,13 @@
import { TERRAIN_COLORS } from "@/data/world/terrainConfig";
import type { Vector3Tuple } from "@/types/three/three";
export const WORLD_BOUNDS_CONFIG = {
enabled: true,
center: [0, 0, 0] as Vector3Tuple,
planeColor: TERRAIN_COLORS.grass1.hex,
planeY: -0.04,
planeCollisionThickness: 1,
size: [270, 260] as const,
wallHeight: 28,
wallThickness: 4,
};
+162
View File
@@ -0,0 +1,162 @@
import { CLOUD_BOUNDS } from "@/data/world/cloudConfig";
import { WIND_BOUNDS } from "@/data/world/windConfig";
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
export function useEnvironmentDebug(): void {
useDebugFolder("Dynamic Wind", (folder) => {
const { setWind, wind } = useWorldSettingsStore.getState();
const controls = { ...wind };
folder
.add(controls, "speed", WIND_BOUNDS.speed.min, WIND_BOUNDS.speed.max)
.step(WIND_BOUNDS.speed.step)
.name("Wind speed")
.onChange((speed: number) => setWind({ speed }));
folder
.add(
controls,
"direction",
WIND_BOUNDS.direction.min,
WIND_BOUNDS.direction.max,
)
.step(WIND_BOUNDS.direction.step)
.name("Wind direction")
.onChange((direction: number) => setWind({ direction }));
folder
.add(
controls,
"strength",
WIND_BOUNDS.strength.min,
WIND_BOUNDS.strength.max,
)
.step(WIND_BOUNDS.strength.step)
.name("Wind strength")
.onChange((strength: number) => setWind({ strength }));
folder
.add(
controls,
"noiseScale",
WIND_BOUNDS.noiseScale.min,
WIND_BOUNDS.noiseScale.max,
)
.step(WIND_BOUNDS.noiseScale.step)
.name("Wind noise scale")
.onChange((noiseScale: number) => setWind({ noiseScale }));
});
useDebugFolder("Environment", (folder) => {
const { clouds, graphics, setClouds, setDynamicClouds } =
useWorldSettingsStore.getState();
const controls = {
...clouds,
dynamicClouds: graphics.dynamicClouds,
};
folder
.add(controls, "dynamicClouds")
.name("Clouds")
.onChange((dynamicClouds: boolean) => setDynamicClouds(dynamicClouds));
folder
.add(controls, "count", CLOUD_BOUNDS.count.min, CLOUD_BOUNDS.count.max)
.step(CLOUD_BOUNDS.count.step)
.name("Cloud count")
.onChange((count: number) => setClouds({ count }));
folder
.add(controls, "minScale", CLOUD_BOUNDS.scale.min, CLOUD_BOUNDS.scale.max)
.step(CLOUD_BOUNDS.scale.step)
.name("Cloud min scale")
.onChange((minScale: number) => setClouds({ minScale }));
folder
.add(controls, "maxScale", CLOUD_BOUNDS.scale.min, CLOUD_BOUNDS.scale.max)
.step(CLOUD_BOUNDS.scale.step)
.name("Cloud max scale")
.onChange((maxScale: number) => setClouds({ maxScale }));
folder
.add(
controls,
"minRotation",
CLOUD_BOUNDS.rotation.min,
CLOUD_BOUNDS.rotation.max,
)
.step(CLOUD_BOUNDS.rotation.step)
.name("Cloud min rotation")
.onChange((minRotation: number) => setClouds({ minRotation }));
folder
.add(
controls,
"maxRotation",
CLOUD_BOUNDS.rotation.min,
CLOUD_BOUNDS.rotation.max,
)
.step(CLOUD_BOUNDS.rotation.step)
.name("Cloud max rotation")
.onChange((maxRotation: number) => setClouds({ maxRotation }));
folder
.add(
controls,
"minHeight",
CLOUD_BOUNDS.height.min,
CLOUD_BOUNDS.height.max,
)
.step(CLOUD_BOUNDS.height.step)
.name("Cloud min height")
.onChange((minHeight: number) => setClouds({ minHeight }));
folder
.add(
controls,
"maxHeight",
CLOUD_BOUNDS.height.min,
CLOUD_BOUNDS.height.max,
)
.step(CLOUD_BOUNDS.height.step)
.name("Cloud max height")
.onChange((maxHeight: number) => setClouds({ maxHeight }));
folder
.add(
controls,
"minSpeedMultiplier",
CLOUD_BOUNDS.speedMultiplier.min,
CLOUD_BOUNDS.speedMultiplier.max,
)
.step(CLOUD_BOUNDS.speedMultiplier.step)
.name("Cloud min speed")
.onChange((minSpeedMultiplier: number) =>
setClouds({ minSpeedMultiplier }),
);
folder
.add(
controls,
"maxSpeedMultiplier",
CLOUD_BOUNDS.speedMultiplier.min,
CLOUD_BOUNDS.speedMultiplier.max,
)
.step(CLOUD_BOUNDS.speedMultiplier.step)
.name("Cloud max speed")
.onChange((maxSpeedMultiplier: number) =>
setClouds({ maxSpeedMultiplier }),
);
folder
.add(controls, "castShadow")
.name("Cloud cast shadow")
.onChange((castShadow: boolean) => setClouds({ castShadow }));
folder
.add(controls, "receiveShadow")
.name("Cloud receive shadow")
.onChange((receiveShadow: boolean) => setClouds({ receiveShadow }));
});
}
+10
View File
@@ -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
View File
@@ -58,6 +58,7 @@ export function useTerrainSurfaceData(): TerrainSurfaceData | null {
return {
bounds: createTerrainSurfaceBounds(scene),
imageData,
raycastTarget: scene,
};
}, [scene]);
}
@@ -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 },
+10
View File
@@ -1,3 +1,5 @@
import type * as THREE from "three";
export type TerrainSurfaceKind =
| "grass"
| "path"
@@ -20,6 +22,13 @@ export interface TerrainSurfaceBounds {
maxZ: number;
}
export interface TerrainSurfaceProjectionConfig {
flipX: boolean;
flipZ: boolean;
offsetX: number;
offsetZ: number;
}
export interface TerrainSurfaceColorConfig {
hex: string;
rgb: TerrainSurfaceRgb;
@@ -38,4 +47,5 @@ export interface TerrainSurfaceSample {
export interface TerrainSurfaceData {
bounds: TerrainSurfaceBounds;
imageData: ImageData;
raycastTarget: THREE.Object3D;
}
+2
View File
@@ -18,6 +18,8 @@ interface DebugEvents {
const DEBUG_FOLDER_ORDER = [
"Lighting",
"Dynamic Wind",
"Environment",
"Game",
"Interaction",
"Hand Tracking",
+24 -5
View File
@@ -1,7 +1,8 @@
import type * as THREE from "three";
import * as THREE from "three";
import { TERRAIN_COLORS } from "@/data/world/terrainConfig";
import type {
TerrainSurfaceBounds,
TerrainSurfaceProjectionConfig,
TerrainSurfaceRgb,
TerrainSurfaceSample,
TerrainSurfaceUv,
@@ -14,11 +15,14 @@ type TerrainSurfaceImageSource =
| ImageBitmap;
const imageDataCache = new WeakMap<TerrainSurfaceImageSource, ImageData>();
function clamp01(value: number): number {
return Math.min(Math.max(value, 0), 1);
}
function wrap01(value: number): number {
return ((value % 1) + 1) % 1;
}
function isTerrainSurfaceImageSource(
value: unknown,
): value is TerrainSurfaceImageSource {
@@ -83,13 +87,27 @@ export function terrainSurfaceUvFromXZ(
x: number,
z: number,
bounds: TerrainSurfaceBounds,
projection?: TerrainSurfaceProjectionConfig,
): TerrainSurfaceUv {
const width = bounds.maxX - bounds.minX;
const depth = bounds.maxZ - bounds.minZ;
let u = width === 0 ? 0 : x / width + 0.5;
let v = depth === 0 ? 0 : z / depth + 0.5;
if (projection?.flipX) {
u = 1 - u;
}
if (projection?.flipZ) {
v = 1 - v;
}
u = wrap01(u + (projection?.offsetX ?? 0));
v = wrap01(v + (projection?.offsetZ ?? 0));
return {
u: width === 0 ? 0 : (x - bounds.minX) / width,
v: depth === 0 ? 0 : (z - bounds.minZ) / depth,
u,
v,
};
}
@@ -98,9 +116,10 @@ export function sampleTerrainSurfaceAtXZ(
x: number,
z: number,
bounds: TerrainSurfaceBounds,
projection?: TerrainSurfaceProjectionConfig,
): TerrainSurfaceSample {
return sampleTerrainSurfaceAtUv(
imageData,
terrainSurfaceUvFromXZ(x, z, bounds),
terrainSurfaceUvFromXZ(x, z, bounds, projection),
);
}
+6
View File
@@ -20,11 +20,14 @@ 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";
import { isInstancedMapNodeName } from "@/world/map-instancing/mapInstancingConfig";
import { VegetationSystem } from "@/world/vegetation/VegetationSystem";
import { WaterSystem } from "@/world/water/WaterSystem";
import { WorldPlane } from "@/world/WorldPlane";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
import { logger } from "@/utils/core/Logger";
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
@@ -257,6 +260,9 @@ export function GameMap({
))}
</group>
<MapInstancingSystem />
<WorldPlane />
<WaterSystem />
<CloudSystem />
<VegetationSystem />
{isMapModelVisible("terrain", { groups, models }) ? (
<TerrainModel />
+2
View File
@@ -11,6 +11,7 @@ import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode";
import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision";
import type { MapNode } from "@/types/editor/editor";
import type { OctreeReadyHandler } from "@/types/three/three";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
@@ -173,6 +174,7 @@ export function GameMapCollision({
return (
<group ref={groupRef} visible={false}>
{mapReady ? <WorldBoundsCollision /> : null}
{mapReady
? collisionNodes.map((mapNode, index) => (
<CollisionErrorBoundary
+10 -6
View File
@@ -5,12 +5,10 @@ import {
AMBIENT_INTENSITY_MAX,
AMBIENT_INTENSITY_MIN,
AMBIENT_INTENSITY_STEP,
AMBIENT_LIGHT_COLOR,
LIGHTING_DEFAULTS,
SUN_INTENSITY_MAX,
SUN_INTENSITY_MIN,
SUN_INTENSITY_STEP,
SUN_LIGHT_COLOR,
SUN_X_MAX,
SUN_X_MIN,
SUN_X_STEP,
@@ -24,12 +22,14 @@ import {
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
const SHADOW_MAP_SIZE = 2048;
const SHADOW_CAMERA_SIZE = 100;
const SHADOW_CAMERA_SIZE = 170;
const SHADOW_CAMERA_NEAR = 0.5;
const SHADOW_CAMERA_FAR = 200;
const SHADOW_CAMERA_FAR = 300;
type LightingState = {
ambientColor: string;
ambientIntensity: number;
sunColor: string;
sunIntensity: number;
sunX: number;
sunY: number;
@@ -57,6 +57,7 @@ export function Lighting(): React.JSX.Element {
}, []);
useDebugFolder("Lighting", (folder) => {
folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color");
folder
.add(
LIGHTING_STATE,
@@ -75,6 +76,7 @@ export function Lighting(): React.JSX.Element {
SUN_INTENSITY_STEP,
)
.name("Sun Intensity");
folder.addColor(LIGHTING_STATE, "sunColor").name("Sun Color");
folder
.add(LIGHTING_STATE, "sunX", SUN_X_MIN, SUN_X_MAX, SUN_X_STEP)
.name("Sun X");
@@ -88,6 +90,7 @@ export function Lighting(): React.JSX.Element {
useFrame(() => {
if (ambient.current) {
ambient.current.color.set(LIGHTING_STATE.ambientColor);
ambient.current.intensity = LIGHTING_STATE.ambientIntensity;
}
@@ -97,6 +100,7 @@ export function Lighting(): React.JSX.Element {
LIGHTING_STATE.sunY,
LIGHTING_STATE.sunZ,
);
sun.current.color.set(LIGHTING_STATE.sunColor);
sun.current.intensity = LIGHTING_STATE.sunIntensity;
}
});
@@ -106,7 +110,7 @@ export function Lighting(): React.JSX.Element {
<ambientLight
ref={ambient}
intensity={LIGHTING_STATE.ambientIntensity}
color={AMBIENT_LIGHT_COLOR}
color={LIGHTING_STATE.ambientColor}
/>
<directionalLight
ref={sun}
@@ -116,7 +120,7 @@ export function Lighting(): React.JSX.Element {
LIGHTING_STATE.sunZ,
]}
intensity={LIGHTING_STATE.sunIntensity}
color={SUN_LIGHT_COLOR}
color={LIGHTING_STATE.sunColor}
castShadow
/>
</>
+2
View File
@@ -5,6 +5,7 @@ import {
PLAYER_SPAWN_POSITION_PHYSICS,
} from "@/data/player/playerConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
@@ -36,6 +37,7 @@ interface WorldProps {
}
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
useEnvironmentDebug();
useMapPerformanceDebug();
const cameraMode = useCameraMode();
+21
View File
@@ -0,0 +1,21 @@
import { WORLD_BOUNDS_CONFIG } from "@/data/world/worldBoundsConfig";
export function WorldPlane(): React.JSX.Element | null {
if (!WORLD_BOUNDS_CONFIG.enabled) {
return null;
}
const { center, planeColor, planeY, size } = WORLD_BOUNDS_CONFIG;
return (
<mesh
name="world-plane"
position={[center[0], planeY, center[2]]}
receiveShadow
rotation={[-Math.PI / 2, 0, 0]}
>
<planeGeometry args={size} />
<meshStandardMaterial color={planeColor} roughness={1} />
</mesh>
);
}
+142
View File
@@ -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>
);
}
@@ -0,0 +1,11 @@
import { WorldPlaneCollision } from "@/world/collision/WorldPlaneCollision";
import { WorldWallsCollision } from "@/world/collision/WorldWallsCollision";
export function WorldBoundsCollision(): React.JSX.Element {
return (
<group name="world-bounds-collision">
<WorldPlaneCollision />
<WorldWallsCollision />
</group>
);
}
@@ -0,0 +1,20 @@
import { WORLD_BOUNDS_CONFIG } from "@/data/world/worldBoundsConfig";
export function WorldPlaneCollision(): React.JSX.Element | null {
if (!WORLD_BOUNDS_CONFIG.enabled) {
return null;
}
const { center, planeCollisionThickness, planeY, size } = WORLD_BOUNDS_CONFIG;
const [width, depth] = size;
return (
<mesh
name="world-plane-collision"
position={[center[0], planeY - planeCollisionThickness / 2, center[2]]}
>
<boxGeometry args={[width, planeCollisionThickness, depth]} />
<meshBasicMaterial />
</mesh>
);
}
@@ -0,0 +1,38 @@
import { WORLD_BOUNDS_CONFIG } from "@/data/world/worldBoundsConfig";
export function WorldWallsCollision(): React.JSX.Element | null {
if (!WORLD_BOUNDS_CONFIG.enabled) {
return null;
}
const { center, size, wallHeight, wallThickness } = WORLD_BOUNDS_CONFIG;
const [width, depth] = size;
const wallY = center[1] + wallHeight / 2;
const halfWidth = width / 2;
const halfDepth = depth / 2;
return (
<group name="world-walls-collision">
<mesh position={[center[0], wallY, center[2] - halfDepth]}>
<boxGeometry
args={[width + wallThickness * 2, wallHeight, wallThickness]}
/>
<meshBasicMaterial />
</mesh>
<mesh position={[center[0], wallY, center[2] + halfDepth]}>
<boxGeometry
args={[width + wallThickness * 2, wallHeight, wallThickness]}
/>
<meshBasicMaterial />
</mesh>
<mesh position={[center[0] - halfWidth, wallY, center[2]]}>
<boxGeometry args={[wallThickness, wallHeight, depth]} />
<meshBasicMaterial />
</mesh>
<mesh position={[center[0] + halfWidth, wallY, center[2]]}>
<boxGeometry args={[wallThickness, wallHeight, depth]} />
<meshBasicMaterial />
</mesh>
</group>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useRef } from "react";
import * as THREE from "three";
import { InstancedMapAsset } from "@/world/map-instancing/InstancedMapAsset";
import {
PATH_DEBUG_PREVIEW_ENABLED,
PATH_TILE_RENDER_ENABLED,
PATH_TILE_MODEL_PATH,
} from "@/world/paths/pathConfig";
import { usePathTileData } from "@/world/paths/usePathTileData";
import type { MapAssetInstance } from "@/world/map-instancing/useMapInstancingData";
export function PathSystem(): React.JSX.Element | null {
if (!PATH_DEBUG_PREVIEW_ENABLED && !PATH_TILE_RENDER_ENABLED) {
return null;
}
return <PathTiles />;
}
function PathTiles(): React.JSX.Element | null {
const pathTiles = usePathTileData();
if (pathTiles.length === 0) {
return null;
}
if (PATH_DEBUG_PREVIEW_ENABLED) {
return <PathDebugPreview instances={pathTiles} />;
}
if (!PATH_TILE_RENDER_ENABLED) {
return null;
}
return (
<InstancedMapAsset
castShadow={false}
instances={pathTiles}
modelPath={PATH_TILE_MODEL_PATH}
receiveShadow
/>
);
}
function PathDebugPreview({
instances,
}: {
instances: MapAssetInstance[];
}): React.JSX.Element {
const instancedMeshRef = useRef<THREE.InstancedMesh>(null);
useEffect(() => {
const instancedMesh = instancedMeshRef.current;
if (!instancedMesh) return;
const matrix = new THREE.Matrix4();
const position = new THREE.Vector3();
const quaternion = new THREE.Quaternion();
const scale = new THREE.Vector3(1, 1, 1);
for (let i = 0; i < instances.length; i++) {
const instance = instances[i];
if (!instance) continue;
position.set(
instance.position[0],
instance.position[1] + 0.08,
instance.position[2],
);
matrix.compose(position, quaternion, scale);
instancedMesh.setMatrixAt(i, matrix);
}
instancedMesh.instanceMatrix.needsUpdate = true;
instancedMesh.computeBoundingSphere();
}, [instances]);
return (
<instancedMesh
ref={instancedMeshRef}
args={[undefined, undefined, instances.length]}
>
<boxGeometry args={[0.35, 0.08, 0.35]} />
<meshBasicMaterial color="#ff00ff" />
</instancedMesh>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { TERRAIN_COLORS, TERRAIN_TILE_SIZE } from "@/data/world/terrainConfig";
export const PATH_SURFACE_KEY = "chemin";
export const PATH_DEBUG_PREVIEW_ENABLED = false;
export const PATH_TILE_RENDER_ENABLED = false;
export const PATH_TILE_MODEL_PATH = TERRAIN_COLORS.chemin.modelPath;
export const PATH_TILE_SIZE =
TERRAIN_COLORS.chemin.tileSize ?? TERRAIN_TILE_SIZE;
export const PATH_TILE_SAMPLE_STEP = 2;
export const PATH_TILE_MAX_COUNT = 1500;
export const PATH_TILE_ROTATION = [0, 0, 0] as const;
export const PATH_TILE_SCALE = [1, 1, 1] as const;
+72
View File
@@ -0,0 +1,72 @@
import { useMemo } from "react";
import { TERRAIN_SURFACE_PROJECTION } from "@/data/world/terrainConfig";
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
import { useTerrainSurfaceData } from "@/hooks/world/useTerrainSurfaceData";
import type { Vector3Tuple } from "@/types/three/three";
import { sampleTerrainSurfaceAtXZ } from "@/utils/world/terrainSurfaceSampler";
import type { MapAssetInstance } from "@/world/map-instancing/useMapInstancingData";
import {
PATH_TILE_MAX_COUNT,
PATH_SURFACE_KEY,
PATH_TILE_ROTATION,
PATH_TILE_SAMPLE_STEP,
PATH_TILE_SCALE,
} from "@/world/paths/pathConfig";
function createSampleCenters(min: number, max: number, step: number): number[] {
const start = Math.ceil(min / step) * step + step * 0.5;
const centers: number[] = [];
for (let value = start; value <= max; value += step) {
centers.push(value);
}
return centers;
}
export function usePathTileData(): MapAssetInstance[] {
const terrainSurfaceData = useTerrainSurfaceData();
const terrainHeight = useTerrainHeightSampler();
return useMemo(() => {
if (!terrainSurfaceData) return [];
const instances: MapAssetInstance[] = [];
const xCenters = createSampleCenters(
terrainSurfaceData.bounds.minX,
terrainSurfaceData.bounds.maxX,
PATH_TILE_SAMPLE_STEP,
);
const zCenters = createSampleCenters(
terrainSurfaceData.bounds.minZ,
terrainSurfaceData.bounds.maxZ,
PATH_TILE_SAMPLE_STEP,
);
for (const x of xCenters) {
for (const z of zCenters) {
if (instances.length >= PATH_TILE_MAX_COUNT) return instances;
const sample = sampleTerrainSurfaceAtXZ(
terrainSurfaceData.imageData,
x,
z,
terrainSurfaceData.bounds,
TERRAIN_SURFACE_PROJECTION,
);
if (sample.key !== PATH_SURFACE_KEY) continue;
const height = terrainHeight.getHeight(x, z) ?? 0;
instances.push({
position: [x, height, z],
rotation: [...PATH_TILE_ROTATION] as Vector3Tuple,
scale: [...PATH_TILE_SCALE] as Vector3Tuple,
});
}
}
return instances;
}, [terrainHeight, terrainSurfaceData]);
}
+46 -9
View File
@@ -17,6 +17,8 @@ import {
PLAYER_AIR_CONTROL_FACTOR,
PLAYER_CAPSULE_RADIUS,
PLAYER_EYE_HEIGHT,
PLAYER_FALL_RESPAWN_DELAY,
PLAYER_FALL_RESPAWN_Y,
PLAYER_GRAVITY,
PLAYER_JUMP_SPEED,
PLAYER_MAX_DELTA,
@@ -57,6 +59,22 @@ const _up = new THREE.Vector3(0, 1, 0);
const _translateVec = new THREE.Vector3();
const _collisionCorrection = new THREE.Vector3();
function resetPlayerCapsule(
capsule: Capsule,
spawnPosition: Vector3Tuple,
camera: THREE.Camera,
velocity: THREE.Vector3,
): void {
capsule.start.set(
spawnPosition[0],
spawnPosition[1] - PLAYER_EYE_HEIGHT + PLAYER_CAPSULE_RADIUS,
spawnPosition[2],
);
capsule.end.set(...spawnPosition);
velocity.set(0, 0, 0);
camera.position.copy(capsule.end);
}
function createSpawnCapsule(spawnPosition: Vector3Tuple): Capsule {
return new Capsule(
new THREE.Vector3(
@@ -104,6 +122,7 @@ export function PlayerController({
const movementLockedRef = useRef(movementLocked);
const keys = useRef<Keys>({ ...DEFAULT_KEYS });
const velocity = useRef(new THREE.Vector3());
const fallDuration = useRef(0);
const onFloor = useRef(false);
const wantsJump = useRef(false);
const initializedRef = useRef(false);
@@ -112,16 +131,15 @@ export function PlayerController({
const capsule = useRef(createSpawnCapsule(spawnPosition));
useLayoutEffect(() => {
capsule.current.start.set(
spawnPosition[0],
spawnPosition[1] - PLAYER_EYE_HEIGHT + PLAYER_CAPSULE_RADIUS,
spawnPosition[2],
resetPlayerCapsule(
capsule.current,
spawnPosition,
camera,
velocity.current,
);
capsule.current.end.set(...spawnPosition);
velocity.current.set(0, 0, 0);
fallDuration.current = 0;
onFloor.current = false;
wantsJump.current = false;
camera.position.copy(capsule.current.end);
initializedRef.current = true;
}, [camera, spawnPosition]);
@@ -211,6 +229,27 @@ export function PlayerController({
useFrame((_, delta) => {
if (!initializedRef.current) return;
const dt = Math.min(delta, PLAYER_MAX_DELTA);
if (capsule.current.end.y < PLAYER_FALL_RESPAWN_Y) {
fallDuration.current += dt;
if (fallDuration.current >= PLAYER_FALL_RESPAWN_DELAY) {
resetPlayerCapsule(
capsule.current,
spawnPosition,
camera,
velocity.current,
);
fallDuration.current = 0;
onFloor.current = false;
wantsJump.current = false;
return;
}
} else {
fallDuration.current = 0;
}
if (isPlayerInputLocked() || !canMove) {
keys.current = { ...DEFAULT_KEYS };
velocity.current.set(0, 0, 0);
@@ -218,8 +257,6 @@ export function PlayerController({
return;
}
const dt = Math.min(delta, PLAYER_MAX_DELTA);
camera.getWorldDirection(_forward);
_forward.setY(0);
if (_forward.lengthSq() > 0) {
+112
View File
@@ -0,0 +1,112 @@
import { useMemo, useRef } from "react";
import * as THREE from "three";
import { useFrame, useThree } from "@react-three/fiber";
import { FOG_CONFIG } from "@/data/world/fogConfig";
import { getWindVector } from "@/data/world/windConfig";
import { WATER_SHADER_CONFIG } from "@/data/world/waterConfig";
import type { WaterSurfaceConfig } from "@/data/world/waterConfig";
import { useWind } from "@/hooks/world/useWind";
import {
WATER_FRAGMENT_SHADER,
WATER_VERTEX_SHADER,
} from "@/world/water/waterShaders";
export function WaterSurface({
position,
renderOrder,
rotation,
size,
}: WaterSurfaceConfig): React.JSX.Element {
const scene = useThree((state) => state.scene);
const materialRef = useRef<THREE.ShaderMaterial>(null);
const wind = useWind();
const uniforms = useMemo(
() => ({
uTime: { value: 0 },
uScale: { value: WATER_SHADER_CONFIG.scale },
uSmoothness: { value: WATER_SHADER_CONFIG.smoothness },
uEdgeThreshold: { value: WATER_SHADER_CONFIG.edgeThreshold },
uEdgeSoftness: { value: WATER_SHADER_CONFIG.edgeSoftness },
uFlowX: { value: WATER_SHADER_CONFIG.flowX },
uFlowZ: { value: WATER_SHADER_CONFIG.flowZ },
uCellSpeed: { value: WATER_SHADER_CONFIG.cellSpeed },
uNoiseScale: { value: WATER_SHADER_CONFIG.noiseScale },
uNoiseFlowSpeed: { value: WATER_SHADER_CONFIG.noiseFlowSpeed },
uDistortAmount: { value: WATER_SHADER_CONFIG.distortAmount },
uBorderRadius: { value: WATER_SHADER_CONFIG.borderRadius },
uBorderSoftness: { value: WATER_SHADER_CONFIG.borderSoftness },
uDeepColor: { value: new THREE.Color(WATER_SHADER_CONFIG.deepColor) },
uMidColor: { value: new THREE.Color(WATER_SHADER_CONFIG.midColor) },
uMidPos: { value: WATER_SHADER_CONFIG.midPos },
uHighlight: {
value: new THREE.Color(WATER_SHADER_CONFIG.highlightColor),
},
uOpacity: { value: WATER_SHADER_CONFIG.opacity },
uDeepOpacity: { value: WATER_SHADER_CONFIG.deepOpacity },
uFogEnabled: { value: 0 },
uFogNear: { value: FOG_CONFIG.near },
uFogFar: { value: FOG_CONFIG.far },
uFogColor: { value: new THREE.Color(FOG_CONFIG.color) },
}),
[],
);
useFrame(({ clock }) => {
const material = materialRef.current;
if (!material) return;
const windVector = getWindVector(wind);
const {
uFlowX,
uFlowZ,
uFogColor,
uFogEnabled,
uFogFar,
uFogNear,
uNoiseScale,
uTime,
} = material.uniforms;
if (uTime) uTime.value = clock.getElapsedTime();
if (uFlowX) uFlowX.value = WATER_SHADER_CONFIG.flowX + windVector.x;
if (uFlowZ) uFlowZ.value = WATER_SHADER_CONFIG.flowZ + windVector.z;
if (uNoiseScale) {
uNoiseScale.value = WATER_SHADER_CONFIG.noiseScale * wind.noiseScale;
}
if (scene.fog instanceof THREE.Fog) {
if (uFogEnabled) uFogEnabled.value = 1;
if (uFogNear) uFogNear.value = scene.fog.near;
if (uFogFar) uFogFar.value = scene.fog.far;
if (uFogColor) uFogColor.value.copy(scene.fog.color);
} else if (uFogEnabled) {
uFogEnabled.value = 0;
}
});
return (
<mesh
position={[
position[0],
position[1] + WATER_SHADER_CONFIG.depthOffset,
position[2],
]}
rotation={[-Math.PI / 2 + rotation[0], rotation[1], rotation[2]]}
renderOrder={renderOrder}
>
<planeGeometry args={size} />
<shaderMaterial
ref={materialRef}
attach="material"
depthTest
depthWrite={false}
fragmentShader={WATER_FRAGMENT_SHADER}
side={THREE.FrontSide}
transparent
uniforms={uniforms}
vertexShader={WATER_VERTEX_SHADER}
/>
</mesh>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { useCallback, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import {
WATER_SHADER_CONFIG,
WATER_STREAMING_CONFIG,
WATER_SURFACES,
} from "@/data/world/waterConfig";
import type { WaterSurfaceConfig } from "@/data/world/waterConfig";
import { WaterSurface } from "@/world/water/WaterSurface";
function getDistanceToWaterSurface(
surface: WaterSurfaceConfig,
x: number,
z: number,
): number {
const halfWidth = surface.size[0] / 2;
const halfDepth = surface.size[1] / 2;
const distanceX = Math.max(Math.abs(x - surface.position[0]) - halfWidth, 0);
const distanceZ = Math.max(Math.abs(z - surface.position[2]) - halfDepth, 0);
return Math.hypot(distanceX, distanceZ);
}
export function WaterSystem(): React.JSX.Element | null {
const camera = useThree((state) => state.camera);
const lastUpdateRef = useRef(-WATER_STREAMING_CONFIG.updateInterval);
const [activeSurfaceIndexes, setActiveSurfaceIndexes] = useState<Set<number>>(
() => new Set(),
);
const updateActiveSurfaces = useCallback(() => {
const nextIndexes = new Set<number>();
const cameraX = camera.position.x;
const cameraZ = camera.position.z;
WATER_SURFACES.forEach((surface, index) => {
const distance = getDistanceToWaterSurface(surface, cameraX, cameraZ);
const wasActive = activeSurfaceIndexes.has(index);
const radius = wasActive
? WATER_STREAMING_CONFIG.unloadDistance
: WATER_STREAMING_CONFIG.loadDistance;
if (distance <= radius) {
nextIndexes.add(index);
}
});
if (
nextIndexes.size === activeSurfaceIndexes.size &&
[...nextIndexes].every((index) => activeSurfaceIndexes.has(index))
) {
return;
}
setActiveSurfaceIndexes(nextIndexes);
}, [activeSurfaceIndexes, camera]);
useFrame(({ clock }) => {
if (!WATER_STREAMING_CONFIG.enabled) return;
const now = clock.elapsedTime * 1000;
if (now - lastUpdateRef.current < WATER_STREAMING_CONFIG.updateInterval) {
return;
}
lastUpdateRef.current = now;
updateActiveSurfaces();
});
if (!WATER_SHADER_CONFIG.enabled) {
return null;
}
const visibleSurfaces = WATER_SURFACES.map((surface, index) => ({
index,
surface,
})).filter(({ index, surface }) => {
if (!WATER_STREAMING_CONFIG.enabled) {
return true;
}
if (activeSurfaceIndexes.size > 0) {
return activeSurfaceIndexes.has(index);
}
return (
getDistanceToWaterSurface(
surface,
camera.position.x,
camera.position.z,
) <= WATER_STREAMING_CONFIG.loadDistance
);
});
return (
<group name="water-system">
{visibleSurfaces.map(({ index, surface }) => (
<WaterSurface key={index} {...surface} />
))}
</group>
);
}
+165
View File
@@ -0,0 +1,165 @@
export const WATER_VERTEX_SHADER = /* glsl */ `
varying vec2 vUv;
varying vec3 vWorldPosition;
varying vec2 vWorldPos;
void main() {
vUv = uv;
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPosition.xyz;
vWorldPos = worldPosition.xz;
gl_Position = projectionMatrix * viewMatrix * worldPosition;
}
`;
export const WATER_FRAGMENT_SHADER = /* glsl */ `
uniform float uTime;
uniform float uScale;
uniform float uSmoothness;
uniform float uEdgeThreshold;
uniform float uEdgeSoftness;
uniform float uFlowX;
uniform float uFlowZ;
uniform float uCellSpeed;
uniform float uNoiseScale;
uniform float uNoiseFlowSpeed;
uniform float uDistortAmount;
uniform float uBorderRadius;
uniform float uBorderSoftness;
uniform vec3 uDeepColor;
uniform vec3 uMidColor;
uniform float uMidPos;
uniform vec3 uHighlight;
uniform float uOpacity;
uniform float uDeepOpacity;
uniform float uFogEnabled;
uniform float uFogNear;
uniform float uFogFar;
uniform vec3 uFogColor;
varying vec2 vUv;
varying vec3 vWorldPosition;
varying vec2 vWorldPos;
float roundedBoxMask(vec2 uv, float radius, float softness) {
vec2 centeredUv = uv * 2.0 - 1.0;
vec2 boxSize = vec2(1.0 - radius);
vec2 distanceToEdge = abs(centeredUv) - boxSize;
float outsideDistance = length(max(distanceToEdge, 0.0)) - radius;
return 1.0 - smoothstep(-softness, softness, outsideDistance);
}
vec2 hash2(vec2 p) {
p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
return fract(sin(p) * 43758.5453);
}
float smin(float a, float b, float k) {
float h = max(k - abs(a - b), 0.0) / k;
return min(a, b) - h * h * h * k / 6.0;
}
vec2 cellPoint(vec2 seed) {
return 0.5 + 0.5 * sin(uTime * uCellSpeed + 6.2831 * seed);
}
float voronoiF1(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float nearest = 8.0;
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
vec2 neighbor = vec2(float(x), float(y));
vec2 point = cellPoint(hash2(i + neighbor));
nearest = min(nearest, length(neighbor + point - f));
}
}
return nearest;
}
float voronoiSmoothF1(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float result = 8.0;
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
vec2 neighbor = vec2(float(x), float(y));
vec2 point = cellPoint(hash2(i + neighbor));
result = smin(result, length(neighbor + point - f), uSmoothness);
}
}
return result;
}
float noiseHash(vec2 p) {
p = fract(p * vec2(127.1, 311.7));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float valueNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(
mix(noiseHash(i), noiseHash(i + vec2(1.0, 0.0)), f.x),
mix(noiseHash(i + vec2(0.0, 1.0)), noiseHash(i + vec2(1.0, 1.0)), f.x),
f.y
);
}
float fbm(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
for (int i = 0; i < 2; i++) {
value += amplitude * valueNoise(p);
p *= 2.0;
amplitude *= 0.5;
}
return value;
}
void main() {
vec2 noiseUv = vWorldPos * uNoiseScale + vec2(uTime * uNoiseFlowSpeed, 0.0);
float noiseFactor = fbm(noiseUv);
vec2 distortion = vec2(noiseFactor - 0.5) * uDistortAmount;
vec2 uv = vWorldPos * uScale + vec2(uFlowX, uFlowZ) * uTime + distortion;
float f1 = voronoiF1(uv);
float smoothF1 = voronoiSmoothF1(uv);
float edge = f1 - smoothF1;
float ramp = smoothstep(uEdgeThreshold - uEdgeSoftness, uEdgeThreshold + uEdgeSoftness, edge);
float safeMidPosition = max(uMidPos, 0.0001);
float firstSegment = clamp(ramp / safeMidPosition, 0.0, 1.0);
float secondSegment = clamp((ramp - safeMidPosition) / max(1.0 - safeMidPosition, 0.0001), 0.0, 1.0);
float inSecondSegment = step(safeMidPosition, ramp);
vec3 color = mix(
mix(uDeepColor, uMidColor, firstSegment),
mix(uMidColor, uHighlight, secondSegment),
inSecondSegment
);
float alpha = mix(uDeepOpacity, 1.0, ramp) * uOpacity;
alpha *= roundedBoxMask(vUv, uBorderRadius, uBorderSoftness);
if (uFogEnabled > 0.5) {
float fogDistance = distance(cameraPosition, vWorldPosition);
float fogFactor = smoothstep(uFogNear, uFogFar, fogDistance);
color = mix(color, uFogColor, fogFactor);
alpha *= 1.0 - fogFactor;
}
if (alpha < 0.01) {
discard;
}
gl_FragColor = vec4(color, alpha);
}
`;