diff --git a/src/rendering/water-material.js b/src/rendering/water-material.js index 27862a38..7de9577a 100644 --- a/src/rendering/water-material.js +++ b/src/rendering/water-material.js @@ -1,4 +1,4 @@ -import { Color, DoubleSide, Euler, MeshBasicMaterial, MeshPhysicalMaterial, Quaternion, Vector2, Vector3, Vector4 } from "three"; +import { DoubleSide, Euler, MeshBasicMaterial, Quaternion, ShaderMaterial, Vector2, Vector3, Vector4 } from "three"; const MAX_WATER_CONTACT_PATCHES = 6; const WATER_CONTACT_EPSILON = 1e-4; @@ -8,137 +8,136 @@ function createBoundsCorners(bounds) { new Vector3(bounds.min.x, bounds.min.y, bounds.min.z), new Vector3(bounds.min.x, bounds.min.y, bounds.max.z), new Vector3(bounds.min.x, bounds.max.y, bounds.min.z), - new Vector3(bounds.min.x, bounds.max.y, bounds.max.z), new Vector3(bounds.max.x, bounds.min.y, bounds.min.z), new Vector3(bounds.max.x, bounds.min.y, bounds.max.z), new Vector3(bounds.max.x, bounds.max.y, bounds.min.z), new Vector3(bounds.max.x, bounds.max.y, bounds.max.z) ]; } - -function createInverseVolumeRotation(rotationDegrees) { - return new Quaternion() - .setFromEuler(new Euler((rotationDegrees.x * Math.PI) / 180, (rotationDegrees.y * Math.PI) / 180, (rotationDegrees.z * Math.PI) / 180, "XYZ")) - .invert(); -} - -export function collectWaterContactPatches(volume, contactBounds) { - const inverseRotation = createInverseVolumeRotation(volume.rotationDegrees); - const halfX = Math.max(volume.size.x * 0.5, WATER_CONTACT_EPSILON); - const halfY = Math.max(volume.size.y * 0.5, WATER_CONTACT_EPSILON); - const halfZ = Math.max(volume.size.z * 0.5, WATER_CONTACT_EPSILON); - const surfaceY = halfY; - const surfaceBand = Math.max(0.18, Math.min(0.55, volume.size.y * 0.2)); - const localPoint = new Vector3(); - const patches = []; - for (const bounds of contactBounds) { - const corners = createBoundsCorners(bounds); - let minX = Number.POSITIVE_INFINITY; - let minY = Number.POSITIVE_INFINITY; - let minZ = Number.POSITIVE_INFINITY; - let maxX = Number.NEGATIVE_INFINITY; - let maxY = Number.NEGATIVE_INFINITY; - let maxZ = Number.NEGATIVE_INFINITY; - for (const corner of corners) { - localPoint.copy(corner); - localPoint.x -= volume.center.x; - localPoint.y -= volume.center.y; - localPoint.z -= volume.center.z; - localPoint.applyQuaternion(inverseRotation); - minX = Math.min(minX, localPoint.x); - minY = Math.min(minY, localPoint.y); - minZ = Math.min(minZ, localPoint.z); - maxX = Math.max(maxX, localPoint.x); - maxY = Math.max(maxY, localPoint.y); - maxZ = Math.max(maxZ, localPoint.z); + const clampedOpacity = Math.max(0.14, Math.min(1, options.opacity)); + const topFaceFlag = options.isTopFace ? 1 : 0; + const hex = options.colorHex.replace("#", ""); + const cr = parseInt(hex.substring(0, 2), 16) / 255; + const cg = parseInt(hex.substring(2, 4), 16) / 255; + const cb = parseInt(hex.substring(4, 6), 16) / 255; + const vertexShader = ` + uniform float time; + uniform float waveStrength; + uniform float waveAmplitude; + uniform float isTopFace; + varying vec2 vLocalSurfaceUv; + varying vec3 vWaveNormal; + varying vec3 vWorldPos; + varying vec3 vViewDir; + void main() { + vec3 transformedPosition = position; + vLocalSurfaceUv = position.xz; + vWaveNormal = vec3(0.0, 1.0, 0.0); + if (isTopFace > 0.5) { + vec2 dirA = normalize(vec2(0.92, 0.38)); + vec2 dirB = normalize(vec2(-0.34, 0.94)); + vec2 dirC = normalize(vec2(0.58, -0.81)); + float phaseA = dot(vLocalSurfaceUv, dirA) / 2.3 + time * 0.92; + float phaseB = dot(vLocalSurfaceUv, dirB) / 1.45 - time * 1.08; + float phaseC = dot(vLocalSurfaceUv, dirC) / 0.82 + time * 1.42; + float waveA = sin(phaseA) * 0.55; + float waveB = sin(phaseB) * 0.30; + float waveC = sin(phaseC) * 0.15; + transformedPosition.y += (waveA + waveB + waveC) * waveAmplitude; + vec2 slope = + dirA * (cos(phaseA) / 2.3) * 0.55 + + dirB * (cos(phaseB) / 1.45) * 0.30 + + dirC * (cos(phaseC) / 0.82) * 0.15; + vWaveNormal = normalize(vec3(-slope.x * (0.3 + waveStrength * 0.7), 1.0, -slope.y * (0.3 + waveStrength * 0.7))); + } + vec4 worldPos = modelMatrix * vec4(transformedPosition, 1.0); + vWorldPos = worldPos.xyz; + vViewDir = normalize(cameraPosition - worldPos.xyz); + gl_Position = projectionMatrix * viewMatrix * worldPos; } - if (maxX <= -halfX || minX >= halfX || maxZ <= -halfZ || minZ >= halfZ) { - continue; + `; + const fragmentShader = ` + precision highp float; + uniform vec3 waterColor; + uniform float surfaceOpacity; + uniform float waveStrength; + uniform float time; + uniform float isTopFace; + uniform vec2 halfSize; + uniform vec4 contactPatches[${MAX_WATER_CONTACT_PATCHES}]; + varying vec2 vLocalSurfaceUv; + varying vec3 vWaveNormal; + varying vec3 vWorldPos; + varying vec3 vViewDir; + float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); } - if (maxY < surfaceY - surfaceBand || minY > surfaceY + surfaceBand) { - continue; + float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix( + mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), + u.y + ); } - const overlapMinX = Math.max(minX, -halfX); - const overlapMaxX = Math.min(maxX, halfX); - const overlapMinZ = Math.max(minZ, -halfZ); - const overlapMaxZ = Math.min(maxZ, halfZ); - const overlapWidth = overlapMaxX - overlapMinX; - const overlapDepth = overlapMaxZ - overlapMinZ; - if (overlapWidth <= WATER_CONTACT_EPSILON || overlapDepth <= WATER_CONTACT_EPSILON) { - continue; + void main() { + vec3 normal = normalize(vWaveNormal); + vec3 viewDir = normalize(vViewDir); + float fresnel = pow(1.0 - clamp(dot(viewDir, normal), 0.0, 1.0), 2.8); + float refractPattern = + sin((vLocalSurfaceUv.x + normal.x * 0.6) * 2.2 + time * 0.8) * + sin((vLocalSurfaceUv.y + normal.z * 0.4) * 1.9 - time * 0.65); + float detail = noise(vLocalSurfaceUv * 1.8 + vec2(time * 0.12, -time * 0.09)); + float refraction = refractPattern * 0.08 + (detail - 0.5) * 0.12; + vec3 deepTint = waterColor * vec3(0.52, 0.66, 0.78); + vec3 shallowTint = mix(waterColor, vec3(0.72, 0.9, 1.0), 0.2 + fresnel * 0.24); + vec3 color = mix(deepTint, shallowTint, 0.58 + refraction); + float edgeDistance = min(halfSize.x - abs(vLocalSurfaceUv.x), halfSize.y - abs(vLocalSurfaceUv.y)); + float edgeBand = max(0.22, min(halfSize.x, halfSize.y) * 0.12); + float edgeFoam = isTopFace > 0.5 ? 1.0 - smoothstep(0.0, edgeBand, edgeDistance) : 0.0; + float contactFoam = 0.0; + if (isTopFace > 0.5) { + for (int patchIndex = 0; patchIndex < ${MAX_WATER_CONTACT_PATCHES}; patchIndex += 1) { + vec4 patch = contactPatches[patchIndex]; + if (patch.z <= 0.0) { + continue; + } + float normalizedDistance = length(vLocalSurfaceUv - patch.xy) / patch.z; + float ring = smoothstep(0.38, 0.72, normalizedDistance) * (1.0 - smoothstep(0.88, 1.2, normalizedDistance)); + contactFoam = max(contactFoam, ring * patch.w); + } + } + float sparkle = max(0.0, sin(vLocalSurfaceUv.x * 5.2 + time * 1.35) * sin(vLocalSurfaceUv.y * 4.4 - time * 1.08)); + float foam = clamp(max(edgeFoam * 0.42, contactFoam) * (0.45 + waveStrength * 0.75) + sparkle * 0.06, 0.0, 0.72); + vec3 specular = vec3(pow(max(0.0, dot(reflect(-viewDir, normal), normalize(vec3(0.25, 0.88, 0.35)))), 18.0)) * (0.18 + fresnel * 0.52); + color = mix(color, vec3(0.97, 0.99, 1.0), foam); + color += specular; + color += vec3(0.05, 0.08, 0.12) * fresnel; + float alpha = isTopFace > 0.5 + ? clamp(surfaceOpacity + fresnel * 0.16 + foam * 0.12, 0.32, 0.9) + : clamp(surfaceOpacity * 0.72 + refraction * 0.05, 0.16, 0.68); + gl_FragColor = vec4(color, alpha); } - const radius = Math.max(0.2, Math.min(Math.max(overlapWidth, overlapDepth) * 0.55, Math.min(halfX, halfZ) * 0.85)); - const verticalDistance = Math.min(Math.abs(surfaceY - minY), Math.abs(maxY - surfaceY)); - const intensity = 1 - Math.min(verticalDistance / surfaceBand, 1); - if (intensity <= WATER_CONTACT_EPSILON) { - continue; - } - patches.push({ - x: (overlapMinX + overlapMaxX) * 0.5, - z: (overlapMinZ + overlapMaxZ) * 0.5, - radius, - intensity: 0.45 + intensity * 0.55 + `; + const material = new ShaderMaterial({ + vertexShader, + fragmentShader, + uniforms: { + time: animationUniform, + waterColor: { value: [cr, cg, cb] }, + surfaceOpacity: { value: clampedOpacity }, + waveStrength: { value: waveStrength }, + waveAmplitude: { value: waveAmplitude }, + isTopFace: { value: topFaceFlag }, + halfSize: { value: halfSize }, + contactPatches: { value: contactPatches } + }, + transparent: true, + depthWrite: false, + side: DoubleSide }); - } - return patches - .sort((left, right) => right.radius * right.intensity - left.radius * left.intensity) - .slice(0, MAX_WATER_CONTACT_PATCHES); -} - -export function createWaterMaterial(options) { - if (options.wireframe) { - return { - material: new MeshBasicMaterial({ - color: options.colorHex, - wireframe: true, - transparent: true, - opacity: Math.min(1, options.opacity + 0.2), - depthWrite: false - }), - animationUniform: null - }; - } - if (!options.quality) { - return { - material: new MeshBasicMaterial({ - color: options.colorHex, - transparent: true, - opacity: options.opacity, - depthWrite: false - }), - animationUniform: null - }; - } - const animationUniform = { value: options.time }; - const halfSize = new Vector2(Math.max(options.halfSize.x, WATER_CONTACT_EPSILON), Math.max(options.halfSize.z, WATER_CONTACT_EPSILON)); - const waterColor = new Color(options.colorHex); - const contactPatches = Array.from({ length: MAX_WATER_CONTACT_PATCHES }, (_, index) => { - const patch = options.contactPatches?.[index]; - return new Vector4(patch?.x ?? 0, patch?.z ?? 0, patch?.radius ?? 0, patch?.intensity ?? 0); - }); - const waveStrength = Math.max(0, options.waveStrength); - const waveAmplitude = 0.016 + Math.min(0.12, waveStrength * 0.06); - const clampedOpacity = Math.max(0.12, Math.min(1, options.opacity)); - const transmission = options.isTopFace - ? Math.max(0.16, Math.min(0.72, 0.86 - clampedOpacity * 0.55)) - : Math.max(0.08, Math.min(0.42, 0.46 - clampedOpacity * 0.32)); - const attenuationDistance = options.isTopFace - ? 1.4 + clampedOpacity * 2.2 - : 0.8 + clampedOpacity * 1.1; - const emissiveIntensity = options.isTopFace - ? 0.16 + waveStrength * 0.14 + clampedOpacity * 0.08 - : 0.05 + clampedOpacity * 0.05; - const material = new MeshPhysicalMaterial({ - color: options.colorHex, - emissive: options.colorHex, - emissiveIntensity, - roughness: options.isTopFace ? 0.08 : 0.22, - metalness: 0.02, - transparent: true, - opacity: 1, - transmission, - thickness: options.isTopFace ? 1.8 : 0.85, - ior: 1.325, reflectivity: options.isTopFace ? 0.45 : 0.16, clearcoat: options.isTopFace ? 0.85 : 0.18, clearcoatRoughness: options.isTopFace ? 0.12 : 0.2, diff --git a/src/rendering/water-material.ts b/src/rendering/water-material.ts index f009407a..9b0d40bf 100644 --- a/src/rendering/water-material.ts +++ b/src/rendering/water-material.ts @@ -1,4 +1,4 @@ -import { Color, DoubleSide, Euler, MeshBasicMaterial, MeshPhysicalMaterial, Quaternion, Vector2, Vector3, Vector4 } from "three"; +import { DoubleSide, Euler, MeshBasicMaterial, Quaternion, ShaderMaterial, Vector2, Vector3, Vector4 } from "three"; import type { Vec3 } from "../core/vector"; @@ -15,7 +15,7 @@ export interface WaterContactPatch { } export interface WaterMaterialResult { - material: MeshBasicMaterial | MeshPhysicalMaterial; + material: MeshBasicMaterial | ShaderMaterial; animationUniform: { value: number } | null; } @@ -166,138 +166,161 @@ export function createWaterMaterial(options: WaterMaterialOptions): WaterMateria const animationUniform = { value: options.time }; const halfSize = new Vector2(Math.max(options.halfSize.x, WATER_CONTACT_EPSILON), Math.max(options.halfSize.z, WATER_CONTACT_EPSILON)); - const waterColor = new Color(options.colorHex); const contactPatches = Array.from({ length: MAX_WATER_CONTACT_PATCHES }, (_, index) => { const patch = options.contactPatches?.[index]; return new Vector4(patch?.x ?? 0, patch?.z ?? 0, patch?.radius ?? 0, patch?.intensity ?? 0); }); const waveStrength = Math.max(0, options.waveStrength); const waveAmplitude = 0.016 + Math.min(0.12, waveStrength * 0.06); - const clampedOpacity = Math.max(0.12, Math.min(1, options.opacity)); - const transmission = options.isTopFace - ? Math.max(0.16, Math.min(0.72, 0.86 - clampedOpacity * 0.55)) - : Math.max(0.08, Math.min(0.42, 0.46 - clampedOpacity * 0.32)); - const attenuationDistance = options.isTopFace - ? 1.4 + clampedOpacity * 2.2 - : 0.8 + clampedOpacity * 1.1; - const emissiveIntensity = options.isTopFace - ? 0.16 + waveStrength * 0.14 + clampedOpacity * 0.08 - : 0.05 + clampedOpacity * 0.05; - const material = new MeshPhysicalMaterial({ - color: options.colorHex, - emissive: options.colorHex, - emissiveIntensity, - roughness: options.isTopFace ? 0.08 : 0.22, - metalness: 0.02, + const clampedOpacity = Math.max(0.14, Math.min(1, options.opacity)); + const topFaceFlag = options.isTopFace ? 1 : 0; + const hex = options.colorHex.replace("#", ""); + const cr = parseInt(hex.substring(0, 2), 16) / 255; + const cg = parseInt(hex.substring(2, 4), 16) / 255; + const cb = parseInt(hex.substring(4, 6), 16) / 255; + + const vertexShader = /* glsl */ ` + uniform float time; + uniform float waveStrength; + uniform float waveAmplitude; + uniform float isTopFace; + + varying vec2 vLocalSurfaceUv; + varying vec3 vWaveNormal; + varying vec3 vWorldPos; + varying vec3 vViewDir; + + void main() { + vec3 transformedPosition = position; + vLocalSurfaceUv = position.xz; + vWaveNormal = vec3(0.0, 1.0, 0.0); + + if (isTopFace > 0.5) { + vec2 dirA = normalize(vec2(0.92, 0.38)); + vec2 dirB = normalize(vec2(-0.34, 0.94)); + vec2 dirC = normalize(vec2(0.58, -0.81)); + float phaseA = dot(vLocalSurfaceUv, dirA) / 2.3 + time * 0.92; + float phaseB = dot(vLocalSurfaceUv, dirB) / 1.45 - time * 1.08; + float phaseC = dot(vLocalSurfaceUv, dirC) / 0.82 + time * 1.42; + float waveA = sin(phaseA) * 0.55; + float waveB = sin(phaseB) * 0.30; + float waveC = sin(phaseC) * 0.15; + + transformedPosition.y += (waveA + waveB + waveC) * waveAmplitude; + + vec2 slope = + dirA * (cos(phaseA) / 2.3) * 0.55 + + dirB * (cos(phaseB) / 1.45) * 0.30 + + dirC * (cos(phaseC) / 0.82) * 0.15; + vWaveNormal = normalize(vec3(-slope.x * (0.3 + waveStrength * 0.7), 1.0, -slope.y * (0.3 + waveStrength * 0.7))); + } + + vec4 worldPos = modelMatrix * vec4(transformedPosition, 1.0); + vWorldPos = worldPos.xyz; + vViewDir = normalize(cameraPosition - worldPos.xyz); + gl_Position = projectionMatrix * viewMatrix * worldPos; + } + `; + + const fragmentShader = /* glsl */ ` + precision highp float; + + uniform vec3 waterColor; + uniform float surfaceOpacity; + uniform float waveStrength; + uniform float time; + uniform float isTopFace; + uniform vec2 halfSize; + uniform vec4 contactPatches[${MAX_WATER_CONTACT_PATCHES}]; + + varying vec2 vLocalSurfaceUv; + varying vec3 vWaveNormal; + varying vec3 vWorldPos; + varying vec3 vViewDir; + + float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); + } + + float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + + return mix( + mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), + u.y + ); + } + + void main() { + vec3 normal = normalize(vWaveNormal); + vec3 viewDir = normalize(vViewDir); + float fresnel = pow(1.0 - clamp(dot(viewDir, normal), 0.0, 1.0), 2.8); + + float refractPattern = + sin((vLocalSurfaceUv.x + normal.x * 0.6) * 2.2 + time * 0.8) * + sin((vLocalSurfaceUv.y + normal.z * 0.4) * 1.9 - time * 0.65); + float detail = noise(vLocalSurfaceUv * 1.8 + vec2(time * 0.12, -time * 0.09)); + float refraction = refractPattern * 0.08 + (detail - 0.5) * 0.12; + + vec3 deepTint = waterColor * vec3(0.52, 0.66, 0.78); + vec3 shallowTint = mix(waterColor, vec3(0.72, 0.9, 1.0), 0.2 + fresnel * 0.24); + vec3 color = mix(deepTint, shallowTint, 0.58 + refraction); + + float edgeDistance = min(halfSize.x - abs(vLocalSurfaceUv.x), halfSize.y - abs(vLocalSurfaceUv.y)); + float edgeBand = max(0.22, min(halfSize.x, halfSize.y) * 0.12); + float edgeFoam = isTopFace > 0.5 ? 1.0 - smoothstep(0.0, edgeBand, edgeDistance) : 0.0; + float contactFoam = 0.0; + + if (isTopFace > 0.5) { + for (int patchIndex = 0; patchIndex < ${MAX_WATER_CONTACT_PATCHES}; patchIndex += 1) { + vec4 patch = contactPatches[patchIndex]; + if (patch.z <= 0.0) { + continue; + } + + float normalizedDistance = length(vLocalSurfaceUv - patch.xy) / patch.z; + float ring = smoothstep(0.38, 0.72, normalizedDistance) * (1.0 - smoothstep(0.88, 1.2, normalizedDistance)); + contactFoam = max(contactFoam, ring * patch.w); + } + } + + float sparkle = max(0.0, sin(vLocalSurfaceUv.x * 5.2 + time * 1.35) * sin(vLocalSurfaceUv.y * 4.4 - time * 1.08)); + float foam = clamp(max(edgeFoam * 0.42, contactFoam) * (0.45 + waveStrength * 0.75) + sparkle * 0.06, 0.0, 0.72); + vec3 specular = vec3(pow(max(0.0, dot(reflect(-viewDir, normal), normalize(vec3(0.25, 0.88, 0.35)))), 18.0)) * (0.18 + fresnel * 0.52); + + color = mix(color, vec3(0.97, 0.99, 1.0), foam); + color += specular; + color += vec3(0.05, 0.08, 0.12) * fresnel; + + float alpha = isTopFace > 0.5 + ? clamp(surfaceOpacity + fresnel * 0.16 + foam * 0.12, 0.32, 0.9) + : clamp(surfaceOpacity * 0.72 + refraction * 0.05, 0.16, 0.68); + + gl_FragColor = vec4(color, alpha); + } + `; + + const material = new ShaderMaterial({ + vertexShader, + fragmentShader, + uniforms: { + time: animationUniform, + waterColor: { value: [cr, cg, cb] }, + surfaceOpacity: { value: clampedOpacity }, + waveStrength: { value: waveStrength }, + waveAmplitude: { value: waveAmplitude }, + isTopFace: { value: topFaceFlag }, + halfSize: { value: halfSize }, + contactPatches: { value: contactPatches } + }, transparent: true, - opacity: 1, - transmission, - thickness: options.isTopFace ? 1.8 : 0.85, - ior: 1.325, - reflectivity: options.isTopFace ? 0.45 : 0.16, - clearcoat: options.isTopFace ? 0.85 : 0.18, - clearcoatRoughness: options.isTopFace ? 0.12 : 0.2, - attenuationColor: waterColor, - attenuationDistance, - envMapIntensity: options.isTopFace ? 1.2 : 0.9, depthWrite: false, side: DoubleSide }); - material.customProgramCacheKey = () => `water-${options.isTopFace ? "top" : "side"}`; - material.onBeforeCompile = (shader) => { - shader.uniforms["waterTime"] = animationUniform; - shader.uniforms["waterWaveStrength"] = { value: waveStrength }; - shader.uniforms["waterWaveAmplitude"] = { value: waveAmplitude }; - shader.uniforms["waterIsTopFace"] = { value: options.isTopFace ? 1 : 0 }; - shader.uniforms["waterHalfSize"] = { value: halfSize }; - shader.uniforms["waterContactPatches"] = { value: contactPatches }; - - shader.vertexShader = shader.vertexShader - .replace( - "#include ", - `#include - uniform float waterTime; - uniform float waterWaveStrength; - uniform float waterWaveAmplitude; - uniform float waterIsTopFace; - varying vec2 vWaterLocalPos; - varying vec3 vWaterWaveNormal;` - ) - .replace( - "#include ", - `#include - vWaterLocalPos = transformed.xz; - vWaterWaveNormal = vec3(0.0, 1.0, 0.0); - if (waterIsTopFace > 0.5) { - vec2 dirA = normalize(vec2(0.92, 0.38)); - vec2 dirB = normalize(vec2(-0.34, 0.94)); - vec2 dirC = normalize(vec2(0.58, -0.81)); - float phaseA = dot(transformed.xz, dirA) / 2.3 + waterTime * 0.92; - float phaseB = dot(transformed.xz, dirB) / 1.45 - waterTime * 1.08; - float phaseC = dot(transformed.xz, dirC) / 0.82 + waterTime * 1.42; - float waveA = sin(phaseA) * 0.55; - float waveB = sin(phaseB) * 0.3; - float waveC = sin(phaseC) * 0.15; - transformed.y += (waveA + waveB + waveC) * waterWaveAmplitude; - vec2 slope = - dirA * (cos(phaseA) / 2.3) * 0.55 + - dirB * (cos(phaseB) / 1.45) * 0.3 + - dirC * (cos(phaseC) / 0.82) * 0.15; - vWaterWaveNormal = normalize(vec3(-slope.x * (0.3 + waterWaveStrength * 0.7), 1.0, -slope.y * (0.3 + waterWaveStrength * 0.7))); - }` - ); - - shader.fragmentShader = shader.fragmentShader - .replace( - "#include ", - `#include - uniform float waterTime; - uniform float waterWaveStrength; - uniform float waterIsTopFace; - uniform vec2 waterHalfSize; - uniform vec4 waterContactPatches[${MAX_WATER_CONTACT_PATCHES}]; - varying vec2 vWaterLocalPos; - varying vec3 vWaterWaveNormal;` - ) - .replace( - "#include ", - `#include - if (waterIsTopFace > 0.5) { - normal = normalize(mix(normal, vWaterWaveNormal, 0.72)); - }` - ) - .replace( - "#include ", - `#include - if (waterIsTopFace > 0.5) { - float edgeDistance = min(waterHalfSize.x - abs(vWaterLocalPos.x), waterHalfSize.y - abs(vWaterLocalPos.y)); - float edgeBand = max(0.22, min(waterHalfSize.x, waterHalfSize.y) * 0.12); - float edgeFoam = 1.0 - smoothstep(0.0, edgeBand, edgeDistance); - float contactFoam = 0.0; - for (int patchIndex = 0; patchIndex < ${MAX_WATER_CONTACT_PATCHES}; patchIndex += 1) { - vec4 patch = waterContactPatches[patchIndex]; - if (patch.z <= 0.0) { - continue; - } - - float normalizedDistance = length(vWaterLocalPos - patch.xy) / patch.z; - float ring = smoothstep(0.38, 0.72, normalizedDistance) * (1.0 - smoothstep(0.88, 1.2, normalizedDistance)); - contactFoam = max(contactFoam, ring * patch.w); - } - - vec3 viewDirection = normalize(vViewPosition); - float fresnel = pow(1.0 - clamp(abs(dot(viewDirection, normal)), 0.0, 1.0), 3.0); - float sparkle = sin(vWaterLocalPos.x * 5.5 + waterTime * 1.4) * sin(vWaterLocalPos.y * 4.6 - waterTime * 1.1); - float foam = clamp(max(edgeFoam * 0.42, contactFoam) * (0.45 + waterWaveStrength * 0.7) + max(0.0, sparkle) * 0.06, 0.0, 0.72); - diffuseColor.rgb = mix(diffuseColor.rgb, vec3(0.97, 0.99, 1.0), foam); - diffuseColor.rgb = mix(diffuseColor.rgb, diffuse.rgb * 1.12, 0.32 + (1.0 - transmissionFactor) * 0.22); - diffuseColor.rgb += vec3(0.08, 0.12, 0.18) * fresnel * 0.18; - diffuseColor.a = 1.0; - }` - ); - }; - return { material, animationUniform diff --git a/tests/domain/water-material.test.ts b/tests/domain/water-material.test.ts index 73137619..c340df4e 100644 --- a/tests/domain/water-material.test.ts +++ b/tests/domain/water-material.test.ts @@ -1,4 +1,4 @@ -import { MeshPhysicalMaterial } from "three"; +import { ShaderMaterial } from "three"; import { describe, expect, it } from "vitest"; import { collectWaterContactPatches, createWaterMaterial } from "../../src/rendering/water-material"; @@ -84,7 +84,7 @@ describe("water material helpers", () => { expect(patches).toHaveLength(0); }); - it("keeps quality water visibly tinted instead of fading to transparent alpha", () => { + it("builds a shared quality shader material for visible tinted water", () => { const result = createWaterMaterial({ colorHex: "#4da6d9", surfaceOpacity: 0.55, @@ -101,12 +101,12 @@ describe("water material helpers", () => { contactPatches: [] }); - expect(result.material).toBeInstanceOf(MeshPhysicalMaterial); + expect(result.material).toBeInstanceOf(ShaderMaterial); - const material = result.material as MeshPhysicalMaterial; - expect(material.opacity).toBe(1); - expect(material.transmission).toBeGreaterThan(0.16); - expect(material.transmission).toBeLessThan(0.72); - expect(material.emissiveIntensity).toBeGreaterThan(0.16); + const material = result.material as ShaderMaterial; + expect(material.transparent).toBe(true); + expect(material.uniforms["surfaceOpacity"]?.value).toBeGreaterThan(0.14); + expect(material.uniforms["waveStrength"]?.value).toBe(0.35); + expect(material.uniforms["isTopFace"]?.value).toBe(1); }); }); \ No newline at end of file