diff --git a/src/rendering/water-material.ts b/src/rendering/water-material.ts index 62e4a326..3df3482b 100644 --- a/src/rendering/water-material.ts +++ b/src/rendering/water-material.ts @@ -28,7 +28,10 @@ export interface WaterContactTriangleMesh { export type WaterContactSource = WaterContactBounds | WaterContactOrientedBox | WaterContactTriangleMesh; +export type WaterContactPatchShape = "box" | "segment"; + export interface WaterContactPatch { + shape: WaterContactPatchShape; x: number; z: number; halfWidth: number; @@ -42,6 +45,7 @@ export interface WaterMaterialResult { animationUniform: { value: number } | null; contactPatchesUniform: { value: Vector4[] } | null; contactPatchAxesUniform: { value: Vector2[] } | null; + contactPatchShapesUniform: { value: number[] } | null; } interface WaterMaterialOptions { @@ -66,7 +70,7 @@ interface OrientedWaterVolume { size: Vec3; } -interface TriangleMeshPatchSample { +interface TriangleMeshSegmentSample { patch: WaterContactPatch; normal: Vector3; } @@ -389,6 +393,7 @@ function createPatchFromProjectedPoints(projectedPoints: Vector2[], preferredAxi const patchCenterSecondary = (minSecondary + maxSecondary) * 0.5; return { + shape: "box", x: primaryAxis.x * patchCenterPrimary + secondaryAxis.x * patchCenterSecondary, z: primaryAxis.y * patchCenterPrimary + secondaryAxis.y * patchCenterSecondary, halfWidth, @@ -410,6 +415,28 @@ function computeTriangleNormal(pointA: Vector3, pointB: Vector3, pointC: Vector3 return normal.normalize(); } +function createSegmentPatchFromEndpoints(startPoint: Vector2, endPoint: Vector2, radius: number): WaterContactPatch | null { + const axis = endPoint.clone().sub(startPoint); + const length = axis.length(); + + if (length <= WATER_CONTACT_EPSILON) { + return null; + } + + axis.divideScalar(length); + const center = startPoint.clone().add(endPoint).multiplyScalar(0.5); + + return { + shape: "segment", + x: center.x, + z: center.y, + halfWidth: length * 0.5, + halfDepth: Math.max(radius, WATER_CONTACT_EPSILON), + axisX: axis.x, + axisZ: axis.y + }; +} + function addUniqueProjectedPoint(points: Vector2[], point: Vector2) { const alreadyExists = points.some( (candidate) => Math.abs(candidate.x - point.x) <= WATER_CONTACT_EPSILON && Math.abs(candidate.y - point.y) <= WATER_CONTACT_EPSILON @@ -488,7 +515,7 @@ function createWaterlineSegmentFromPolygon(polygon: Vector3[], surfaceY: number) return [startPoint.clone(), endPoint.clone()] as const; } -function createPatchCornerPoints(patch: WaterContactPatch) { +function createSegmentEndpoints(patch: WaterContactPatch) { const axis = new Vector2(patch.axisX, patch.axisZ); if (axis.lengthSq() <= WATER_CONTACT_EPSILON) { axis.set(1, 0); @@ -496,17 +523,13 @@ function createPatchCornerPoints(patch: WaterContactPatch) { axis.normalize(); } - const perpendicularAxis = new Vector2(-axis.y, axis.x); const center = new Vector2(patch.x, patch.z); - return [ - center.clone().add(axis.clone().multiplyScalar(patch.halfWidth)).add(perpendicularAxis.clone().multiplyScalar(patch.halfDepth)), - center.clone().add(axis.clone().multiplyScalar(patch.halfWidth)).add(perpendicularAxis.clone().multiplyScalar(-patch.halfDepth)), - center.clone().add(axis.clone().multiplyScalar(-patch.halfWidth)).add(perpendicularAxis.clone().multiplyScalar(patch.halfDepth)), - center.clone().add(axis.clone().multiplyScalar(-patch.halfWidth)).add(perpendicularAxis.clone().multiplyScalar(-patch.halfDepth)) - ]; + const offset = axis.clone().multiplyScalar(patch.halfWidth); + + return [center.clone().sub(offset), center.clone().add(offset)] as const; } -function measurePatchExtentsInBasis(points: Vector2[], axis: Vector2) { +function measureSegmentExtentsInBasis(points: Vector2[], radius: number, axis: Vector2) { const perpendicularAxis = new Vector2(-axis.y, axis.x); let minPrimary = Number.POSITIVE_INFINITY; let maxPrimary = Number.NEGATIVE_INFINITY; @@ -525,8 +548,44 @@ function measurePatchExtentsInBasis(points: Vector2[], axis: Vector2) { return { minPrimary, maxPrimary, - minSecondary, - maxSecondary + minSecondary: minSecondary - radius, + maxSecondary: maxSecondary + radius + }; +} + +function createSegmentPatchFromCluster(cluster: { + axis: Vector2; + endpoints: Vector2[]; + maxRadius: number; + extents: ReturnType; +}): WaterContactPatch | null { + const axis = cluster.axis.clone(); + + if (axis.lengthSq() <= WATER_CONTACT_EPSILON) { + axis.set(1, 0); + } else { + axis.normalize(); + } + + const perpendicularAxis = new Vector2(-axis.y, axis.x); + const halfWidth = (cluster.extents.maxPrimary - cluster.extents.minPrimary) * 0.5; + const halfDepth = Math.max(cluster.maxRadius, (cluster.extents.maxSecondary - cluster.extents.minSecondary) * 0.5); + + if (halfWidth <= WATER_CONTACT_EPSILON || halfDepth <= WATER_CONTACT_EPSILON) { + return null; + } + + const centerPrimary = (cluster.extents.minPrimary + cluster.extents.maxPrimary) * 0.5; + const centerSecondary = (cluster.extents.minSecondary + cluster.extents.maxSecondary) * 0.5; + + return { + shape: "segment", + x: axis.x * centerPrimary + perpendicularAxis.x * centerSecondary, + z: axis.y * centerPrimary + perpendicularAxis.y * centerSecondary, + halfWidth, + halfDepth, + axisX: axis.x, + axisZ: axis.y }; } @@ -552,17 +611,17 @@ function getTriangleMeshMergeSettings(mergeProfile: WaterContactTriangleMesh["me }; } -function mergeTriangleMeshContactPatches(rawPatches: TriangleMeshPatchSample[], minimumThickness: number, mergeProfile: WaterContactTriangleMesh["mergeProfile"]) { +function mergeTriangleMeshContactPatches(rawPatches: TriangleMeshSegmentSample[], minimumThickness: number, mergeProfile: WaterContactTriangleMesh["mergeProfile"]) { const mergeSettings = getTriangleMeshMergeSettings(mergeProfile, minimumThickness); const clusters: Array<{ axis: Vector2; normal: Vector3; - points: Vector2[]; - extents: ReturnType; + endpoints: Vector2[]; + maxRadius: number; + extents: ReturnType; }> = []; for (const rawPatch of rawPatches) { - const patchPoints = createPatchCornerPoints(rawPatch.patch); const patchAxis = new Vector2(rawPatch.patch.axisX, rawPatch.patch.axisZ); if (patchAxis.lengthSq() <= WATER_CONTACT_EPSILON) { patchAxis.set(1, 0); @@ -570,6 +629,8 @@ function mergeTriangleMeshContactPatches(rawPatches: TriangleMeshPatchSample[], patchAxis.normalize(); } + const patchEndpoints = createSegmentEndpoints(rawPatch.patch); + let merged = false; for (const cluster of clusters) { @@ -583,7 +644,7 @@ function mergeTriangleMeshContactPatches(rawPatches: TriangleMeshPatchSample[], continue; } - const patchExtents = measurePatchExtentsInBasis(patchPoints, cluster.axis); + const patchExtents = measureSegmentExtentsInBasis(patchEndpoints, rawPatch.patch.halfDepth, cluster.axis); const primaryGap = Math.max(0, Math.max(cluster.extents.minPrimary - patchExtents.maxPrimary, patchExtents.minPrimary - cluster.extents.maxPrimary)); const secondaryGap = Math.max(0, Math.max(cluster.extents.minSecondary - patchExtents.maxSecondary, patchExtents.minSecondary - cluster.extents.maxSecondary)); const clusterPrimarySpan = cluster.extents.maxPrimary - cluster.extents.minPrimary; @@ -601,8 +662,9 @@ function mergeTriangleMeshContactPatches(rawPatches: TriangleMeshPatchSample[], continue; } - cluster.points.push(...patchPoints.map((point) => point.clone())); - cluster.extents = measurePatchExtentsInBasis(cluster.points, cluster.axis); + cluster.endpoints.push(...patchEndpoints.map((point) => point.clone())); + cluster.maxRadius = Math.max(cluster.maxRadius, rawPatch.patch.halfDepth); + cluster.extents = measureSegmentExtentsInBasis(cluster.endpoints, cluster.maxRadius, cluster.axis); merged = true; break; } @@ -611,14 +673,15 @@ function mergeTriangleMeshContactPatches(rawPatches: TriangleMeshPatchSample[], clusters.push({ axis: patchAxis, normal: rawPatch.normal.clone(), - points: patchPoints.map((point) => point.clone()), - extents: measurePatchExtentsInBasis(patchPoints, patchAxis) + endpoints: patchEndpoints.map((point) => point.clone()), + maxRadius: rawPatch.patch.halfDepth, + extents: measureSegmentExtentsInBasis(patchEndpoints, rawPatch.patch.halfDepth, patchAxis) }); } } return clusters - .map((cluster) => createPatchFromProjectedPoints(cluster.points, cluster.axis, minimumThickness)) + .map((cluster) => createSegmentPatchFromCluster(cluster)) .filter((patch): patch is WaterContactPatch => patch !== null); } @@ -645,7 +708,7 @@ function appendTriangleMeshContactPatches( ); const bandMinimumThickness = Math.max(0.08, Math.min(0.22, surfaceBand * 0.45)); const triangleVertices = [new Vector3(), new Vector3(), new Vector3()]; - const rawPatches: TriangleMeshPatchSample[] = []; + const rawPatches: TriangleMeshSegmentSample[] = []; for (let indexOffset = 0; indexOffset <= source.indices.length - 3; indexOffset += 3) { const polygon: Vector3[] = []; @@ -683,11 +746,7 @@ function appendTriangleMeshContactPatches( continue; } - const patch = createPatchFromProjectedPoints( - [waterlineSegment[0], waterlineSegment[1]], - preferredAxis, - bandMinimumThickness - ); + const patch = createSegmentPatchFromEndpoints(waterlineSegment[0], waterlineSegment[1], bandMinimumThickness); if (patch !== null) { rawPatches.push({ @@ -811,6 +870,13 @@ export function createWaterContactPatchAxisUniformValue(contactPatches?: WaterCo }); } +export function createWaterContactPatchShapeUniformValue(contactPatches?: WaterContactPatch[]): number[] { + return Array.from({ length: MAX_WATER_CONTACT_PATCHES }, (_, index) => { + const patch = contactPatches?.[index]; + return patch?.shape === "segment" ? 1 : 0; + }); +} + export function createWaterMaterial(options: WaterMaterialOptions): WaterMaterialResult { if (options.wireframe) { return { @@ -823,7 +889,8 @@ export function createWaterMaterial(options: WaterMaterialOptions): WaterMateria }), animationUniform: null, contactPatchesUniform: null, - contactPatchAxesUniform: null + contactPatchAxesUniform: null, + contactPatchShapesUniform: null }; } @@ -837,7 +904,8 @@ export function createWaterMaterial(options: WaterMaterialOptions): WaterMateria }), animationUniform: null, contactPatchesUniform: null, - contactPatchAxesUniform: null + contactPatchAxesUniform: null, + contactPatchShapesUniform: null }; } @@ -845,6 +913,7 @@ export function createWaterMaterial(options: WaterMaterialOptions): WaterMateria const halfSize = new Vector2(Math.max(options.halfSize.x, WATER_CONTACT_EPSILON), Math.max(options.halfSize.z, WATER_CONTACT_EPSILON)); const contactPatchesUniform = { value: createWaterContactPatchUniformValue(options.contactPatches) }; const contactPatchAxesUniform = { value: createWaterContactPatchAxisUniformValue(options.contactPatches) }; + const contactPatchShapesUniform = { value: createWaterContactPatchShapeUniformValue(options.contactPatches) }; 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)); @@ -864,6 +933,7 @@ export function createWaterMaterial(options: WaterMaterialOptions): WaterMateria varying vec3 vWaveNormal; varying vec3 vWorldPos; varying vec3 vViewDir; + #include void main() { vec3 transformedPosition = position; @@ -891,9 +961,11 @@ export function createWaterMaterial(options: WaterMaterialOptions): WaterMateria } vec4 worldPos = modelMatrix * vec4(transformedPosition, 1.0); + vec4 mvPosition = viewMatrix * worldPos; vWorldPos = worldPos.xyz; vViewDir = normalize(cameraPosition - worldPos.xyz); - gl_Position = projectionMatrix * viewMatrix * worldPos; + gl_Position = projectionMatrix * mvPosition; + #include } `; @@ -908,11 +980,13 @@ export function createWaterMaterial(options: WaterMaterialOptions): WaterMateria uniform vec2 halfSize; uniform vec4 contactPatches[${MAX_WATER_CONTACT_PATCHES}]; uniform vec2 contactPatchAxes[${MAX_WATER_CONTACT_PATCHES}]; + uniform float contactPatchShapes[${MAX_WATER_CONTACT_PATCHES}]; varying vec2 vLocalSurfaceUv; varying vec3 vWaveNormal; varying vec3 vWorldPos; varying vec3 vViewDir; + #include float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); @@ -943,6 +1017,22 @@ export function createWaterMaterial(options: WaterMaterialOptions): WaterMateria return value; } + float signedDistanceToRegion(vec2 point, vec2 center, vec2 axis, vec2 halfExtents) { + vec2 patchPerpendicular = vec2(-axis.y, axis.x); + vec2 patchLocalUv = vec2(dot(point - center, axis), dot(point - center, patchPerpendicular)); + vec2 regionDelta = abs(patchLocalUv) - halfExtents; + vec2 outsideDelta = max(regionDelta, 0.0); + float outsideDistance = length(outsideDelta); + float insideDistance = min(max(regionDelta.x, regionDelta.y), 0.0); + return outsideDistance + insideDistance; + } + + float distanceToSegmentBand(vec2 point, vec2 center, vec2 axis, float halfLength) { + float along = clamp(dot(point - center, axis), -halfLength, halfLength); + vec2 closestPoint = center + axis * along; + return distance(point, closestPoint); + } + void main() { vec3 normal = normalize(vWaveNormal); vec3 viewDir = normalize(vViewDir); @@ -972,19 +1062,34 @@ export function createWaterMaterial(options: WaterMaterialOptions): WaterMateria } vec2 patchAxis = contactPatchAxes[patchIndex]; - vec2 patchPerpendicular = vec2(-patchAxis.y, patchAxis.x); - vec2 patchLocalUv = vec2(dot(vLocalSurfaceUv - patchData.xy, patchAxis), dot(vLocalSurfaceUv - patchData.xy, patchPerpendicular)); - vec2 regionDelta = abs(patchLocalUv) - patchData.zw; - vec2 outsideDelta = max(regionDelta, 0.0); - float outsideDistance = length(outsideDelta); - float insideDistance = min(max(regionDelta.x, regionDelta.y), 0.0); - float signedDistance = outsideDistance + insideDistance; - float boundaryScale = max(min(patchData.z, patchData.w), 0.18); - float normalizedDistance = abs(signedDistance) / boundaryScale; - float contactBody = 1.0 - smoothstep(0.0, 0.65, max(signedDistance, 0.0) / boundaryScale); - float ripple = (sin(normalizedDistance * 13.0 - time * 3.2) * 0.5 + 0.5) * exp(-normalizedDistance * 2.6); + if (dot(patchAxis, patchAxis) <= 0.0) { + patchAxis = vec2(1.0, 0.0); + } else { + patchAxis = normalize(patchAxis); + } + + float alongDistance = dot(vLocalSurfaceUv - patchData.xy, patchAxis); + float contactBody = 0.0; + float ripple = 0.0; + float normalizedDistance = 1.0; + float tangentNoise = noise(vec2(alongDistance * 0.45 + float(patchIndex) * 7.13, time * 0.12)); + + if (contactPatchShapes[patchIndex] > 0.5) { + float segmentRadius = max(patchData.w * mix(0.82, 1.18, tangentNoise), 0.05); + float segmentDistance = distanceToSegmentBand(vLocalSurfaceUv, patchData.xy, patchAxis, patchData.z); + normalizedDistance = segmentDistance / segmentRadius; + contactBody = 1.0 - smoothstep(0.0, 1.0, normalizedDistance); + ripple = (sin(normalizedDistance * 11.0 - time * 3.2 + alongDistance * 0.48) * 0.5 + 0.5) * exp(-normalizedDistance * 1.9); + } else { + float boundaryScale = max(min(patchData.z, patchData.w), 0.18) * mix(0.86, 1.14, tangentNoise); + float signedDistance = signedDistanceToRegion(vLocalSurfaceUv, patchData.xy, patchAxis, patchData.zw); + normalizedDistance = abs(signedDistance) / max(boundaryScale, 0.05); + contactBody = 1.0 - smoothstep(0.0, 1.0, normalizedDistance); + ripple = (sin(normalizedDistance * 13.0 - time * 3.2 + alongDistance * 0.35) * 0.5 + 0.5) * exp(-normalizedDistance * 2.6); + } + float wakeNoise = noise(vLocalSurfaceUv * 3.4 + vec2(time * 0.34, -time * 0.28)); - float foamField = max(contactBody * 0.42, ripple * (0.72 + wakeNoise * 0.28)); + float foamField = max(contactBody * 0.48, ripple * (0.68 + wakeNoise * 0.32)); contactFoam = max(contactFoam, foamField); contactRipple = max(contactRipple, ripple); contactSheen = max(contactSheen, contactBody); @@ -1007,6 +1112,7 @@ export function createWaterMaterial(options: WaterMaterialOptions): WaterMateria : clamp(surfaceOpacity * 0.72 + refraction * 0.08 + caustics * 0.04, 0.16, 0.7); gl_FragColor = vec4(color, alpha); + #include } `; @@ -1022,10 +1128,12 @@ export function createWaterMaterial(options: WaterMaterialOptions): WaterMateria isTopFace: { value: topFaceFlag }, halfSize: { value: halfSize }, contactPatches: contactPatchesUniform, - contactPatchAxes: contactPatchAxesUniform + contactPatchAxes: contactPatchAxesUniform, + contactPatchShapes: contactPatchShapesUniform }, transparent: true, depthWrite: false, + fog: true, side: DoubleSide }); @@ -1033,6 +1141,7 @@ export function createWaterMaterial(options: WaterMaterialOptions): WaterMateria material, animationUniform, contactPatchesUniform, - contactPatchAxesUniform + contactPatchAxesUniform, + contactPatchShapesUniform }; } \ No newline at end of file