From ab44a2b9c582a07f6b07705d444b381d42637b21 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Mon, 6 Apr 2026 17:53:49 +0200 Subject: [PATCH] Refactor water material shader and add contact patch collection --- src/rendering/water-material.js | 276 +++++++++++++++++++------------- src/rendering/water-material.ts | 8 +- 2 files changed, 170 insertions(+), 114 deletions(-) diff --git a/src/rendering/water-material.js b/src/rendering/water-material.js index 7de9577a..ca97e264 100644 --- a/src/rendering/water-material.js +++ b/src/rendering/water-material.js @@ -8,31 +8,150 @@ 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) ]; } - 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 = ` + +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); + } + + if (maxX <= -halfX || minX >= halfX || maxZ <= -halfZ || minZ >= halfZ) { + continue; + } + + if (maxY < surfaceY - surfaceBand || minY > surfaceY + surfaceBand) { + continue; + } + + 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; + } + + 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 + }); + } + + 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 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.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)); @@ -43,21 +162,26 @@ function createBoundsCorners(bounds) { 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 = ` + + const fragmentShader = /* glsl */ ` precision highp float; + uniform vec3 waterColor; uniform float surfaceOpacity; uniform float waveStrength; @@ -65,163 +189,95 @@ function createBoundsCorners(bounds) { 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) { + vec4 patchData = contactPatches[patchIndex]; + if (patchData.z <= 0.0) { continue; } - float normalizedDistance = length(vLocalSurfaceUv - patch.xy) / patch.z; + + float normalizedDistance = length(vLocalSurfaceUv - patchData.xy) / patchData.z; float ring = smoothstep(0.38, 0.72, normalizedDistance) * (1.0 - smoothstep(0.88, 1.2, normalizedDistance)); - contactFoam = max(contactFoam, ring * patch.w); + contactFoam = max(contactFoam, ring * patchData.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, - depthWrite: false, - side: DoubleSide - }); - 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, + + 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 }); - 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/src/rendering/water-material.ts b/src/rendering/water-material.ts index 9b0d40bf..7f5f6c20 100644 --- a/src/rendering/water-material.ts +++ b/src/rendering/water-material.ts @@ -276,14 +276,14 @@ export function createWaterMaterial(options: WaterMaterialOptions): WaterMateria if (isTopFace > 0.5) { for (int patchIndex = 0; patchIndex < ${MAX_WATER_CONTACT_PATCHES}; patchIndex += 1) { - vec4 patch = contactPatches[patchIndex]; - if (patch.z <= 0.0) { + vec4 patchData = contactPatches[patchIndex]; + if (patchData.z <= 0.0) { continue; } - float normalizedDistance = length(vLocalSurfaceUv - patch.xy) / patch.z; + float normalizedDistance = length(vLocalSurfaceUv - patchData.xy) / patchData.z; float ring = smoothstep(0.38, 0.72, normalizedDistance) * (1.0 - smoothstep(0.88, 1.2, normalizedDistance)); - contactFoam = max(contactFoam, ring * patch.w); + contactFoam = max(contactFoam, ring * patchData.w); } }