diff --git a/src/data/world/waterConfig.ts b/src/data/world/waterConfig.ts
new file mode 100644
index 0000000..8cacc50
--- /dev/null
+++ b/src/data/world/waterConfig.ts
@@ -0,0 +1,35 @@
+import { TERRAIN_WATER_HEIGHT } from "@/data/world/terrainConfig";
+import type { Vector3Tuple } from "@/types/three/three";
+
+export interface WaterSurfaceConfig {
+ position: Vector3Tuple;
+ size: [number, number];
+}
+
+export const WATER_SHADER_CONFIG = {
+ enabled: true,
+ height: TERRAIN_WATER_HEIGHT,
+ scale: 0.3,
+ 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_SURFACES: WaterSurfaceConfig[] = [
+ {
+ position: [62, TERRAIN_WATER_HEIGHT, -82],
+ size: [75, 42],
+ },
+];
diff --git a/src/world/GameMap.tsx b/src/world/GameMap.tsx
index aa579b7..d62872e 100644
--- a/src/world/GameMap.tsx
+++ b/src/world/GameMap.tsx
@@ -25,6 +25,7 @@ import { isGeneratedMapModelName } from "@/world/map-generated/generatedMapModel
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 type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
import { logger } from "@/utils/core/Logger";
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
@@ -257,6 +258,7 @@ export function GameMap({
))}
+
{isMapModelVisible("terrain", { groups, models }) ? (
diff --git a/src/world/water/WaterSurface.tsx b/src/world/water/WaterSurface.tsx
new file mode 100644
index 0000000..93c039c
--- /dev/null
+++ b/src/world/water/WaterSurface.tsx
@@ -0,0 +1,63 @@
+import { useMemo, useRef } from "react";
+import * as THREE from "three";
+import { useFrame } from "@react-three/fiber";
+import { WATER_SHADER_CONFIG } from "@/data/world/waterConfig";
+import type { WaterSurfaceConfig } from "@/data/world/waterConfig";
+import {
+ WATER_FRAGMENT_SHADER,
+ WATER_VERTEX_SHADER,
+} from "@/world/water/waterShaders";
+
+export function WaterSurface({
+ position,
+ size,
+}: WaterSurfaceConfig): React.JSX.Element {
+ const materialRef = useRef(null);
+ 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 },
+ 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 },
+ }),
+ [],
+ );
+
+ useFrame(({ clock }) => {
+ const uniform = materialRef.current?.uniforms.uTime;
+ if (uniform) {
+ uniform.value = clock.getElapsedTime();
+ }
+ });
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/world/water/WaterSystem.tsx b/src/world/water/WaterSystem.tsx
new file mode 100644
index 0000000..07195a3
--- /dev/null
+++ b/src/world/water/WaterSystem.tsx
@@ -0,0 +1,16 @@
+import { WATER_SHADER_CONFIG, WATER_SURFACES } from "@/data/world/waterConfig";
+import { WaterSurface } from "@/world/water/WaterSurface";
+
+export function WaterSystem(): React.JSX.Element | null {
+ if (!WATER_SHADER_CONFIG.enabled) {
+ return null;
+ }
+
+ return (
+ <>
+ {WATER_SURFACES.map((surface, index) => (
+
+ ))}
+ >
+ );
+}
diff --git a/src/world/water/waterShaders.ts b/src/world/water/waterShaders.ts
new file mode 100644
index 0000000..2035c9d
--- /dev/null
+++ b/src/world/water/waterShaders.ts
@@ -0,0 +1,132 @@
+export const WATER_VERTEX_SHADER = /* glsl */ `
+ varying vec2 vWorldPos;
+
+ void main() {
+ vec4 worldPosition = modelMatrix * vec4(position, 1.0);
+ 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 vec3 uDeepColor;
+ uniform vec3 uMidColor;
+ uniform float uMidPos;
+ uniform vec3 uHighlight;
+ uniform float uOpacity;
+ uniform float uDeepOpacity;
+
+ varying vec2 vWorldPos;
+
+ 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;
+
+ gl_FragColor = vec4(color, alpha);
+ }
+`;