diff --git a/src/rendering/water-material.js b/src/rendering/water-material.js new file mode 100644 index 00000000..f4917235 --- /dev/null +++ b/src/rendering/water-material.js @@ -0,0 +1,219 @@ +import { Color, DoubleSide, Euler, MeshBasicMaterial, MeshPhysicalMaterial, Quaternion, Vector2, Vector3, Vector4 } from "three"; + +const MAX_WATER_CONTACT_PATCHES = 6; +const WATER_CONTACT_EPSILON = 1e-4; + +function createBoundsCorners(bounds) { + return [ + 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); + } + 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 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 material = new MeshPhysicalMaterial({ + color: options.colorHex, + emissive: options.colorHex, + emissiveIntensity: options.isTopFace ? 0.08 + waveStrength * 0.12 : 0.03, + roughness: options.isTopFace ? 0.08 : 0.22, + metalness: 0.02, + transparent: true, + opacity: options.opacity, + transmission: options.isTopFace ? 0.86 : 0.42, + 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: options.isTopFace ? 3.5 : 1.7, + 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 += vec3(0.08, 0.12, 0.18) * fresnel * 0.18; + diffuseColor.a = clamp(diffuseColor.a + foam * 0.16, 0.0, 1.0); + }`); + }; + return { + material, + animationUniform + }; +} \ No newline at end of file diff --git a/src/rendering/water-material.ts b/src/rendering/water-material.ts new file mode 100644 index 00000000..a07e8d56 --- /dev/null +++ b/src/rendering/water-material.ts @@ -0,0 +1,294 @@ +import { Color, DoubleSide, Euler, MeshBasicMaterial, MeshPhysicalMaterial, Quaternion, Vector2, Vector3, Vector4 } from "three"; + +import type { Vec3 } from "../core/vector"; + +export interface WaterContactBounds { + min: Vec3; + max: Vec3; +} + +export interface WaterContactPatch { + x: number; + z: number; + radius: number; + intensity: number; +} + +export interface WaterMaterialResult { + material: MeshBasicMaterial | MeshPhysicalMaterial; + animationUniform: { value: number } | null; +} + +interface WaterMaterialOptions { + colorHex: string; + surfaceOpacity: number; + waveStrength: number; + opacity: number; + quality: boolean; + wireframe: boolean; + isTopFace: boolean; + time: number; + halfSize: { + x: number; + z: number; + }; + contactPatches?: WaterContactPatch[]; +} + +interface OrientedWaterVolume { + center: Vec3; + rotationDegrees: Vec3; + size: Vec3; +} + +const MAX_WATER_CONTACT_PATCHES = 6; +const WATER_CONTACT_EPSILON = 1e-4; + +function createBoundsCorners(bounds: WaterContactBounds) { + return [ + 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: Vec3) { + 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: OrientedWaterVolume, contactBounds: WaterContactBounds[]): WaterContactPatch[] { + 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: WaterContactPatch[] = []; + + 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: WaterMaterialOptions): WaterMaterialResult { + 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 material = new MeshPhysicalMaterial({ + color: options.colorHex, + emissive: options.colorHex, + emissiveIntensity: options.isTopFace ? 0.08 + waveStrength * 0.12 : 0.03, + roughness: options.isTopFace ? 0.08 : 0.22, + metalness: 0.02, + transparent: true, + opacity: options.opacity, + transmission: options.isTopFace ? 0.86 : 0.42, + 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: options.isTopFace ? 3.5 : 1.7, + 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 += vec3(0.08, 0.12, 0.18) * fresnel * 0.18; + diffuseColor.a = clamp(diffuseColor.a + foam * 0.16, 0.0, 1.0); + }` + ); + }; + + return { + material, + animationUniform + }; +} \ No newline at end of file