Files
webeditor3d/src/rendering/water-material.ts

1337 lines
46 KiB
TypeScript

import {
DoubleSide,
Euler,
Matrix4,
MeshBasicMaterial,
Quaternion,
ShaderMaterial,
Texture,
UniformsLib,
UniformsUtils,
Vector2,
Vector3,
Vector4
} from "three";
import type { Vec3 } from "../core/vector";
import { MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT } from "../document/brushes";
export interface WaterContactBounds {
min: Vec3;
max: Vec3;
}
export interface WaterContactOrientedBox {
kind: "orientedBox";
center: Vec3;
rotationDegrees: Vec3;
size: Vec3;
}
export interface WaterContactTriangleMesh {
kind: "triangleMesh";
vertices: Float32Array;
indices: Uint32Array;
mergeProfile?: "default" | "aggressive";
transform?: {
position: Vec3;
rotationDegrees: Vec3;
scale: Vec3;
};
}
export type WaterContactSource = WaterContactBounds | WaterContactOrientedBox | WaterContactTriangleMesh;
export type WaterContactPatchShape = "box" | "segment";
export interface WaterContactPatch {
shape: WaterContactPatchShape;
x: number;
z: number;
halfWidth: number;
halfDepth: number;
axisX: number;
axisZ: number;
}
export interface WaterMaterialResult {
material: MeshBasicMaterial | ShaderMaterial;
animationUniform: { value: number } | null;
contactPatchesUniform: { value: Vector4[] } | null;
contactPatchAxesUniform: { value: Vector2[] } | null;
contactPatchShapesUniform: { value: number[] } | null;
reflectionTextureUniform: { value: Texture | null } | null;
reflectionMatrixUniform: { value: Matrix4 } | null;
reflectionEnabledUniform: { value: number } | null;
}
interface WaterMaterialReflectionOptions {
texture: Texture | null;
enabled: boolean;
strength?: number;
}
interface WaterMaterialOptions {
colorHex: string;
surfaceOpacity: number;
waveStrength: number;
surfaceDisplacementEnabled?: boolean;
opacity: number;
quality: boolean;
wireframe: boolean;
isTopFace: boolean;
time: number;
halfSize: {
x: number;
z: number;
};
contactPatches?: WaterContactPatch[];
reflection?: WaterMaterialReflectionOptions;
}
interface OrientedWaterVolume {
center: Vec3;
rotationDegrees: Vec3;
size: Vec3;
}
interface TriangleMeshSegmentSample {
patch: WaterContactPatch;
normal: Vector3;
}
const MAX_WATER_CONTACT_PATCHES = MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT;
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 createOrientedBoxCorners(box: WaterContactOrientedBox) {
const halfSize = {
x: box.size.x * 0.5,
y: box.size.y * 0.5,
z: box.size.z * 0.5
};
const rotation = new Quaternion().setFromEuler(
new Euler((box.rotationDegrees.x * Math.PI) / 180, (box.rotationDegrees.y * Math.PI) / 180, (box.rotationDegrees.z * Math.PI) / 180, "XYZ")
);
return [
new Vector3(-halfSize.x, -halfSize.y, -halfSize.z),
new Vector3(-halfSize.x, -halfSize.y, halfSize.z),
new Vector3(-halfSize.x, halfSize.y, -halfSize.z),
new Vector3(-halfSize.x, halfSize.y, halfSize.z),
new Vector3(halfSize.x, -halfSize.y, -halfSize.z),
new Vector3(halfSize.x, -halfSize.y, halfSize.z),
new Vector3(halfSize.x, halfSize.y, -halfSize.z),
new Vector3(halfSize.x, halfSize.y, halfSize.z)
].map((corner) => corner.applyQuaternion(rotation).add(new Vector3(box.center.x, box.center.y, box.center.z)));
}
function createRotationQuaternion(rotationDegrees: Vec3) {
return new Quaternion().setFromEuler(
new Euler((rotationDegrees.x * Math.PI) / 180, (rotationDegrees.y * Math.PI) / 180, (rotationDegrees.z * Math.PI) / 180, "XYZ")
);
}
function createInverseVolumeRotation(rotationDegrees: Vec3) {
return createRotationQuaternion(rotationDegrees).invert();
}
function cross2d(origin: Vector2, pointA: Vector2, pointB: Vector2) {
return (pointA.x - origin.x) * (pointB.y - origin.y) - (pointA.y - origin.y) * (pointB.x - origin.x);
}
function buildConvexHull(points: Vector2[]) {
const sortedPoints = [...points]
.map((point) => point.clone())
.sort((left, right) => (left.x === right.x ? left.y - right.y : left.x - right.x));
const uniquePoints: Vector2[] = [];
for (const point of sortedPoints) {
const lastPoint = uniquePoints.at(-1);
if (lastPoint === undefined || Math.abs(point.x - lastPoint.x) > WATER_CONTACT_EPSILON || Math.abs(point.y - lastPoint.y) > WATER_CONTACT_EPSILON) {
uniquePoints.push(point);
}
}
if (uniquePoints.length <= 2) {
return uniquePoints;
}
const lowerHull: Vector2[] = [];
for (const point of uniquePoints) {
while (lowerHull.length >= 2 && cross2d(lowerHull[lowerHull.length - 2], lowerHull[lowerHull.length - 1], point) <= WATER_CONTACT_EPSILON) {
lowerHull.pop();
}
lowerHull.push(point);
}
const upperHull: Vector2[] = [];
for (let index = uniquePoints.length - 1; index >= 0; index -= 1) {
const point = uniquePoints[index];
if (point === undefined) {
continue;
}
while (upperHull.length >= 2 && cross2d(upperHull[upperHull.length - 2], upperHull[upperHull.length - 1], point) <= WATER_CONTACT_EPSILON) {
upperHull.pop();
}
upperHull.push(point);
}
lowerHull.pop();
upperHull.pop();
return [...lowerHull, ...upperHull];
}
function clipPolygonAgainstVerticalBoundary(polygon: Vector2[], limit: number, keepGreater: boolean) {
if (polygon.length === 0) {
return [];
}
const clipped: Vector2[] = [];
let previousPoint = polygon[polygon.length - 1] ?? null;
if (previousPoint === null) {
return [];
}
let previousInside = keepGreater ? previousPoint.x >= limit - WATER_CONTACT_EPSILON : previousPoint.x <= limit + WATER_CONTACT_EPSILON;
for (const point of polygon) {
const inside = keepGreater ? point.x >= limit - WATER_CONTACT_EPSILON : point.x <= limit + WATER_CONTACT_EPSILON;
if (inside !== previousInside) {
const deltaX = point.x - previousPoint.x;
if (Math.abs(deltaX) > WATER_CONTACT_EPSILON) {
const interpolation = (limit - previousPoint.x) / deltaX;
clipped.push(new Vector2(limit, previousPoint.y + (point.y - previousPoint.y) * interpolation));
}
}
if (inside) {
clipped.push(point.clone());
}
previousPoint = point;
previousInside = inside;
}
return clipped;
}
function clipPolygonAgainstHorizontalBoundary(polygon: Vector2[], limit: number, keepGreater: boolean) {
if (polygon.length === 0) {
return [];
}
const clipped: Vector2[] = [];
let previousPoint = polygon[polygon.length - 1] ?? null;
if (previousPoint === null) {
return [];
}
let previousInside = keepGreater ? previousPoint.y >= limit - WATER_CONTACT_EPSILON : previousPoint.y <= limit + WATER_CONTACT_EPSILON;
for (const point of polygon) {
const inside = keepGreater ? point.y >= limit - WATER_CONTACT_EPSILON : point.y <= limit + WATER_CONTACT_EPSILON;
if (inside !== previousInside) {
const deltaY = point.y - previousPoint.y;
if (Math.abs(deltaY) > WATER_CONTACT_EPSILON) {
const interpolation = (limit - previousPoint.y) / deltaY;
clipped.push(new Vector2(previousPoint.x + (point.x - previousPoint.x) * interpolation, limit));
}
}
if (inside) {
clipped.push(point.clone());
}
previousPoint = point;
previousInside = inside;
}
return clipped;
}
function clipPolygonToRectangle(polygon: Vector2[], minX: number, maxX: number, minZ: number, maxZ: number) {
let clippedPolygon = polygon;
clippedPolygon = clipPolygonAgainstVerticalBoundary(clippedPolygon, minX, true);
clippedPolygon = clipPolygonAgainstVerticalBoundary(clippedPolygon, maxX, false);
clippedPolygon = clipPolygonAgainstHorizontalBoundary(clippedPolygon, minZ, true);
clippedPolygon = clipPolygonAgainstHorizontalBoundary(clippedPolygon, maxZ, false);
return clippedPolygon;
}
function clipPolygonAgainstPlane3d(
polygon: Vector3[],
signedDistance: (point: Vector3) => number
) {
if (polygon.length === 0) {
return [];
}
const clipped: Vector3[] = [];
let previousPoint = polygon[polygon.length - 1] ?? null;
if (previousPoint === null) {
return [];
}
let previousDistance = signedDistance(previousPoint);
let previousInside = previousDistance >= -WATER_CONTACT_EPSILON;
for (const point of polygon) {
const distance = signedDistance(point);
const inside = distance >= -WATER_CONTACT_EPSILON;
if (inside !== previousInside) {
const interpolation = previousDistance / (previousDistance - distance);
clipped.push(previousPoint.clone().lerp(point, interpolation));
}
if (inside) {
clipped.push(point.clone());
}
previousPoint = point;
previousDistance = distance;
previousInside = inside;
}
return clipped;
}
function clipPolygonToContactVolume(polygon: Vector3[], halfX: number, minY: number, maxY: number, halfZ: number) {
let clippedPolygon = polygon;
clippedPolygon = clipPolygonAgainstPlane3d(clippedPolygon, (point) => point.x + halfX);
clippedPolygon = clipPolygonAgainstPlane3d(clippedPolygon, (point) => halfX - point.x);
clippedPolygon = clipPolygonAgainstPlane3d(clippedPolygon, (point) => point.y - minY);
clippedPolygon = clipPolygonAgainstPlane3d(clippedPolygon, (point) => maxY - point.y);
clippedPolygon = clipPolygonAgainstPlane3d(clippedPolygon, (point) => point.z + halfZ);
clippedPolygon = clipPolygonAgainstPlane3d(clippedPolygon, (point) => halfZ - point.z);
return clippedPolygon;
}
function calculatePolygonArea(polygon: Vector2[]) {
if (polygon.length < 3) {
return 0;
}
let doubledArea = 0;
for (let index = 0; index < polygon.length; index += 1) {
const point = polygon[index];
const nextPoint = polygon[(index + 1) % polygon.length];
if (point === undefined || nextPoint === undefined) {
continue;
}
doubledArea += point.x * nextPoint.y - nextPoint.x * point.y;
}
return Math.abs(doubledArea) * 0.5;
}
function createPatchFromProjectedPoints(projectedPoints: Vector2[], preferredAxis: Vector2 | null, minimumThickness: number): WaterContactPatch | null {
const hull = buildConvexHull(projectedPoints);
if (hull.length === 0) {
return null;
}
const primaryAxis = preferredAxis !== null && preferredAxis.lengthSq() > WATER_CONTACT_EPSILON ? preferredAxis.clone().normalize() : new Vector2(1, 0);
if (preferredAxis === null || preferredAxis.lengthSq() <= WATER_CONTACT_EPSILON) {
let longestSegmentLength = 0;
for (let index = 0; index < hull.length; index += 1) {
const startPoint = hull[index];
const endPoint = hull[(index + 1) % hull.length];
if (startPoint === undefined || endPoint === undefined) {
continue;
}
const segment = endPoint.clone().sub(startPoint);
const segmentLength = segment.lengthSq();
if (segmentLength > longestSegmentLength) {
longestSegmentLength = segmentLength;
primaryAxis.copy(segment.normalize());
}
}
}
if (primaryAxis.lengthSq() <= WATER_CONTACT_EPSILON) {
return null;
}
const secondaryAxis = new Vector2(-primaryAxis.y, primaryAxis.x);
let minPrimary = Number.POSITIVE_INFINITY;
let maxPrimary = Number.NEGATIVE_INFINITY;
let minSecondary = Number.POSITIVE_INFINITY;
let maxSecondary = Number.NEGATIVE_INFINITY;
for (const point of hull) {
const primaryDistance = point.dot(primaryAxis);
const secondaryDistance = point.dot(secondaryAxis);
minPrimary = Math.min(minPrimary, primaryDistance);
maxPrimary = Math.max(maxPrimary, primaryDistance);
minSecondary = Math.min(minSecondary, secondaryDistance);
maxSecondary = Math.max(maxSecondary, secondaryDistance);
}
const halfWidth = (maxPrimary - minPrimary) * 0.5;
let halfDepth = (maxSecondary - minSecondary) * 0.5;
if (halfWidth <= WATER_CONTACT_EPSILON) {
return null;
}
if (halfDepth <= WATER_CONTACT_EPSILON || calculatePolygonArea(hull) <= WATER_CONTACT_EPSILON) {
halfDepth = Math.max(halfDepth, minimumThickness);
}
if (halfDepth <= WATER_CONTACT_EPSILON) {
return null;
}
const patchCenterPrimary = (minPrimary + maxPrimary) * 0.5;
const patchCenterSecondary = (minSecondary + maxSecondary) * 0.5;
return {
shape: "box",
x: primaryAxis.x * patchCenterPrimary + secondaryAxis.x * patchCenterSecondary,
z: primaryAxis.y * patchCenterPrimary + secondaryAxis.y * patchCenterSecondary,
halfWidth,
halfDepth,
axisX: primaryAxis.x,
axisZ: primaryAxis.y
};
}
function computeTriangleNormal(pointA: Vector3, pointB: Vector3, pointC: Vector3) {
const edgeAB = pointB.clone().sub(pointA);
const edgeAC = pointC.clone().sub(pointA);
const normal = edgeAB.cross(edgeAC);
if (normal.lengthSq() <= WATER_CONTACT_EPSILON) {
return null;
}
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
);
if (!alreadyExists) {
points.push(point);
}
}
function createWaterlineSegmentFromPolygon(polygon: Vector3[], surfaceY: number) {
if (polygon.length < 2) {
return null;
}
const intersectionPoints: Vector2[] = [];
let previousPoint = polygon[polygon.length - 1] ?? null;
if (previousPoint === null) {
return null;
}
for (const point of polygon) {
const previousDelta = previousPoint.y - surfaceY;
const delta = point.y - surfaceY;
const previousOnPlane = Math.abs(previousDelta) <= WATER_CONTACT_EPSILON;
const onPlane = Math.abs(delta) <= WATER_CONTACT_EPSILON;
if (previousOnPlane && onPlane) {
addUniqueProjectedPoint(intersectionPoints, new Vector2(previousPoint.x, previousPoint.z));
addUniqueProjectedPoint(intersectionPoints, new Vector2(point.x, point.z));
} else if (previousOnPlane) {
addUniqueProjectedPoint(intersectionPoints, new Vector2(previousPoint.x, previousPoint.z));
} else if (onPlane) {
addUniqueProjectedPoint(intersectionPoints, new Vector2(point.x, point.z));
} else if ((previousDelta < 0 && delta > 0) || (previousDelta > 0 && delta < 0)) {
const interpolation = (surfaceY - previousPoint.y) / (point.y - previousPoint.y);
addUniqueProjectedPoint(
intersectionPoints,
new Vector2(previousPoint.x + (point.x - previousPoint.x) * interpolation, previousPoint.z + (point.z - previousPoint.z) * interpolation)
);
}
previousPoint = point;
}
if (intersectionPoints.length < 2) {
return null;
}
let startPoint = intersectionPoints[0] ?? null;
let endPoint = intersectionPoints[1] ?? null;
let longestDistanceSquared = -1;
for (let startIndex = 0; startIndex < intersectionPoints.length; startIndex += 1) {
for (let endIndex = startIndex + 1; endIndex < intersectionPoints.length; endIndex += 1) {
const candidateStart = intersectionPoints[startIndex];
const candidateEnd = intersectionPoints[endIndex];
if (candidateStart === undefined || candidateEnd === undefined) {
continue;
}
const distanceSquared = candidateStart.distanceToSquared(candidateEnd);
if (distanceSquared > longestDistanceSquared) {
longestDistanceSquared = distanceSquared;
startPoint = candidateStart;
endPoint = candidateEnd;
}
}
}
if (startPoint === null || endPoint === null || longestDistanceSquared <= WATER_CONTACT_EPSILON) {
return null;
}
return [startPoint.clone(), endPoint.clone()] as const;
}
function createSegmentEndpoints(patch: WaterContactPatch) {
const axis = new Vector2(patch.axisX, patch.axisZ);
if (axis.lengthSq() <= WATER_CONTACT_EPSILON) {
axis.set(1, 0);
} else {
axis.normalize();
}
const center = new Vector2(patch.x, patch.z);
const offset = axis.clone().multiplyScalar(patch.halfWidth);
return [center.clone().sub(offset), center.clone().add(offset)] as const;
}
function measureSegmentExtentsInBasis(points: readonly Vector2[], radius: number, axis: Vector2) {
const perpendicularAxis = new Vector2(-axis.y, axis.x);
let minPrimary = Number.POSITIVE_INFINITY;
let maxPrimary = Number.NEGATIVE_INFINITY;
let minSecondary = Number.POSITIVE_INFINITY;
let maxSecondary = Number.NEGATIVE_INFINITY;
for (const point of points) {
const primaryDistance = point.dot(axis);
const secondaryDistance = point.dot(perpendicularAxis);
minPrimary = Math.min(minPrimary, primaryDistance);
maxPrimary = Math.max(maxPrimary, primaryDistance);
minSecondary = Math.min(minSecondary, secondaryDistance);
maxSecondary = Math.max(maxSecondary, secondaryDistance);
}
return {
minPrimary,
maxPrimary,
minSecondary: minSecondary - radius,
maxSecondary: maxSecondary + radius
};
}
function createSegmentPatchFromCluster(cluster: {
axis: Vector2;
endpoints: Vector2[];
maxRadius: number;
extents: ReturnType<typeof measureSegmentExtentsInBasis>;
}): 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
};
}
function createSignedDistanceToPatchRegion(point: Vector2, center: Vector2, axis: Vector2, halfExtents: Vector2) {
const patchPerpendicular = new Vector2(-axis.y, axis.x);
const patchLocalUv = new Vector2(point.clone().sub(center).dot(axis), point.clone().sub(center).dot(patchPerpendicular));
const regionDelta = new Vector2(Math.abs(patchLocalUv.x) - halfExtents.x, Math.abs(patchLocalUv.y) - halfExtents.y);
const outsideDistance = new Vector2(Math.max(regionDelta.x, 0), Math.max(regionDelta.y, 0)).length();
const insideDistance = Math.min(Math.max(regionDelta.x, regionDelta.y), 0);
return outsideDistance + insideDistance;
}
function createBoxPatchFromSegmentLoop(segments: WaterContactPatch[], minimumThickness: number): WaterContactPatch | null {
if (segments.length < 3) {
return null;
}
const canonicalAxes: Vector2[] = [];
for (const segment of segments) {
if (segment.shape !== "segment") {
return null;
}
const axis = new Vector2(segment.axisX, segment.axisZ);
if (axis.lengthSq() <= WATER_CONTACT_EPSILON) {
continue;
}
axis.normalize();
if (axis.x < 0 || (Math.abs(axis.x) <= WATER_CONTACT_EPSILON && axis.y < 0)) {
axis.multiplyScalar(-1);
}
const existingAxis = canonicalAxes.find((candidate) => Math.abs(candidate.dot(axis)) >= 0.94);
if (existingAxis === undefined) {
canonicalAxes.push(axis);
}
}
if (canonicalAxes.length < 2) {
return null;
}
let foundOrthogonalAxes = false;
for (let leftIndex = 0; leftIndex < canonicalAxes.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < canonicalAxes.length; rightIndex += 1) {
const leftAxis = canonicalAxes[leftIndex];
const rightAxis = canonicalAxes[rightIndex];
if (leftAxis === undefined || rightAxis === undefined) {
continue;
}
if (Math.abs(leftAxis.dot(rightAxis)) <= 0.35) {
foundOrthogonalAxes = true;
break;
}
}
if (foundOrthogonalAxes) {
break;
}
}
if (!foundOrthogonalAxes) {
return null;
}
const projectedPoints: Vector2[] = [];
for (const segment of segments) {
const [startPoint, endPoint] = createSegmentEndpoints(segment);
projectedPoints.push(startPoint, endPoint);
}
const candidatePatch = createPatchFromProjectedPoints(projectedPoints, null, minimumThickness);
if (candidatePatch === null) {
return null;
}
const candidateAxis = new Vector2(candidatePatch.axisX, candidatePatch.axisZ);
if (candidateAxis.lengthSq() <= WATER_CONTACT_EPSILON) {
return null;
}
candidateAxis.normalize();
const candidateCenter = new Vector2(candidatePatch.x, candidatePatch.z);
const candidateHalfExtents = new Vector2(candidatePatch.halfWidth, candidatePatch.halfDepth);
const acceptanceDistance = Math.max(minimumThickness * 2.2, 0.14);
for (const point of projectedPoints) {
const signedDistance = createSignedDistanceToPatchRegion(point, candidateCenter, candidateAxis, candidateHalfExtents);
if (Math.abs(signedDistance) > acceptanceDistance) {
return null;
}
}
return candidatePatch;
}
function getTriangleMeshMergeSettings(mergeProfile: WaterContactTriangleMesh["mergeProfile"], minimumThickness: number) {
if (mergeProfile === "aggressive") {
return {
axisAlignment: 0.88,
normalAlignment: 0.9,
minimumPrimaryGap: Math.max(0.26, minimumThickness * 2.8),
minimumSecondaryGap: Math.max(0.18, minimumThickness * 2.2),
primaryGapScale: 0.34,
secondaryGapScale: 0.55
};
}
return {
axisAlignment: 0.95,
normalAlignment: 0.97,
minimumPrimaryGap: Math.max(0.04, minimumThickness),
minimumSecondaryGap: Math.max(0.05, minimumThickness * 1.1),
primaryGapScale: 0.06,
secondaryGapScale: 0.12
};
}
function mergeTriangleMeshContactPatches(rawPatches: TriangleMeshSegmentSample[], minimumThickness: number, mergeProfile: WaterContactTriangleMesh["mergeProfile"]) {
const mergeSettings = getTriangleMeshMergeSettings(mergeProfile, minimumThickness);
const clusters: Array<{
axis: Vector2;
normal: Vector3;
endpoints: Vector2[];
maxRadius: number;
extents: ReturnType<typeof measureSegmentExtentsInBasis>;
}> = [];
for (const rawPatch of rawPatches) {
const patchAxis = new Vector2(rawPatch.patch.axisX, rawPatch.patch.axisZ);
if (patchAxis.lengthSq() <= WATER_CONTACT_EPSILON) {
patchAxis.set(1, 0);
} else {
patchAxis.normalize();
}
const patchEndpoints = createSegmentEndpoints(rawPatch.patch);
let merged = false;
for (const cluster of clusters) {
const alignment = Math.abs(cluster.axis.dot(patchAxis));
if (alignment < mergeSettings.axisAlignment) {
continue;
}
const normalAlignment = Math.abs(cluster.normal.dot(rawPatch.normal));
if (normalAlignment < mergeSettings.normalAlignment) {
continue;
}
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;
const clusterSecondarySpan = cluster.extents.maxSecondary - cluster.extents.minSecondary;
const allowedPrimaryGap = Math.max(
mergeSettings.minimumPrimaryGap,
Math.max(rawPatch.patch.halfWidth, clusterPrimarySpan) * mergeSettings.primaryGapScale
);
const allowedSecondaryGap = Math.max(
mergeSettings.minimumSecondaryGap,
Math.max(rawPatch.patch.halfDepth, clusterSecondarySpan) * mergeSettings.secondaryGapScale
);
if (primaryGap > allowedPrimaryGap || secondaryGap > allowedSecondaryGap) {
continue;
}
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;
}
if (!merged) {
clusters.push({
axis: patchAxis,
normal: rawPatch.normal.clone(),
endpoints: patchEndpoints.map((point) => point.clone()),
maxRadius: rawPatch.patch.halfDepth,
extents: measureSegmentExtentsInBasis(patchEndpoints, rawPatch.patch.halfDepth, patchAxis)
});
}
}
return clusters
.map((cluster) => createSegmentPatchFromCluster(cluster))
.filter((patch): patch is WaterContactPatch => patch !== null);
}
function appendTriangleMeshContactPatches(
patches: WaterContactPatch[],
source: WaterContactTriangleMesh,
volume: OrientedWaterVolume,
inverseRotation: Quaternion,
halfX: number,
surfaceY: number,
surfaceBand: number,
halfZ: number
) {
const position = new Vector3(
source.transform?.position.x ?? 0,
source.transform?.position.y ?? 0,
source.transform?.position.z ?? 0
);
const rotation = source.transform !== undefined ? createRotationQuaternion(source.transform.rotationDegrees) : null;
const scale = new Vector3(
source.transform?.scale.x ?? 1,
source.transform?.scale.y ?? 1,
source.transform?.scale.z ?? 1
);
const bandMinimumThickness = Math.max(0.08, Math.min(0.22, surfaceBand * 0.45));
const triangleVertices = [new Vector3(), new Vector3(), new Vector3()];
const rawPatches: TriangleMeshSegmentSample[] = [];
for (let indexOffset = 0; indexOffset <= source.indices.length - 3; indexOffset += 3) {
const polygon: Vector3[] = [];
for (let cornerIndex = 0; cornerIndex < 3; cornerIndex += 1) {
const vertexIndex = source.indices[indexOffset + cornerIndex] ?? 0;
const vertex = triangleVertices[cornerIndex] ?? new Vector3();
vertex.set(source.vertices[vertexIndex * 3] ?? 0, source.vertices[vertexIndex * 3 + 1] ?? 0, source.vertices[vertexIndex * 3 + 2] ?? 0);
vertex.multiply(scale);
if (rotation !== null) {
vertex.applyQuaternion(rotation);
}
vertex.add(position);
vertex.x -= volume.center.x;
vertex.y -= volume.center.y;
vertex.z -= volume.center.z;
vertex.applyQuaternion(inverseRotation);
polygon.push(vertex.clone());
}
const triangleNormal = computeTriangleNormal(polygon[0] ?? new Vector3(), polygon[1] ?? new Vector3(), polygon[2] ?? new Vector3());
if (triangleNormal === null) {
continue;
}
const clippedPolygon = clipPolygonToContactVolume(polygon, halfX, surfaceY - surfaceBand, surfaceY + surfaceBand, halfZ);
const waterlineSegment = createWaterlineSegmentFromPolygon(clippedPolygon, surfaceY);
if (waterlineSegment === null) {
continue;
}
const preferredAxis = waterlineSegment[1].clone().sub(waterlineSegment[0]);
if (preferredAxis.lengthSq() <= WATER_CONTACT_EPSILON) {
continue;
}
const patch = createSegmentPatchFromEndpoints(waterlineSegment[0], waterlineSegment[1], bandMinimumThickness);
if (patch !== null) {
rawPatches.push({
patch,
normal: triangleNormal
});
}
}
const mergedPatches = mergeTriangleMeshContactPatches(rawPatches, bandMinimumThickness, source.mergeProfile);
const loopPatch = createBoxPatchFromSegmentLoop(mergedPatches, bandMinimumThickness);
if (loopPatch !== null) {
patches.push(loopPatch);
return;
}
patches.push(...mergedPatches);
}
export function collectWaterContactPatches(volume: OrientedWaterVolume, contactBounds: WaterContactSource[], patchLimit = MAX_WATER_CONTACT_PATCHES): 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 source of contactBounds) {
if ("kind" in source && source.kind === "triangleMesh") {
appendTriangleMeshContactPatches(patches, source, volume, inverseRotation, halfX, surfaceY, surfaceBand, halfZ);
continue;
}
const corners = "kind" in source ? createOrientedBoxCorners(source) : createBoundsCorners(source);
const localCorners: Vector3[] = [];
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);
localCorners.push(localPoint.clone());
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 clippedFootprint = clipPolygonToRectangle(
buildConvexHull(localCorners.map((corner) => new Vector2(corner.x, corner.z))),
-halfX,
halfX,
-halfZ,
halfZ
);
if (clippedFootprint.length < 2) {
continue;
}
const verticalDistance = Math.min(Math.abs(surfaceY - minY), Math.abs(maxY - surfaceY));
if (1 - Math.min(verticalDistance / surfaceBand, 1) <= WATER_CONTACT_EPSILON) {
continue;
}
let preferredAxis: Vector2 | null = null;
if ("kind" in source) {
const sourceRotation = createRotationQuaternion(source.rotationDegrees);
const projectedSourceX = new Vector2(
new Vector3(1, 0, 0).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).x,
new Vector3(1, 0, 0).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).z
);
const projectedSourceZ = new Vector2(
new Vector3(0, 0, 1).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).x,
new Vector3(0, 0, 1).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).z
);
const nextPrimaryAxis = projectedSourceX.lengthSq() >= projectedSourceZ.lengthSq() ? projectedSourceX : projectedSourceZ;
if (nextPrimaryAxis.lengthSq() > WATER_CONTACT_EPSILON) {
preferredAxis = nextPrimaryAxis.normalize();
}
}
const patch = createPatchFromProjectedPoints(clippedFootprint, preferredAxis, Math.max(0.08, Math.min(0.18, surfaceBand * 0.4)));
if (patch !== null) {
patches.push(patch);
}
}
const clampedPatchLimit = Math.max(1, Math.min(patchLimit, MAX_WATER_CONTACT_PATCHES));
return patches
.sort((left, right) => right.halfWidth * right.halfDepth - left.halfWidth * left.halfDepth)
.slice(0, clampedPatchLimit);
}
export function createWaterContactPatchUniformValue(contactPatches?: WaterContactPatch[]): Vector4[] {
return Array.from({ length: MAX_WATER_CONTACT_PATCHES }, (_, index) => {
const patch = contactPatches?.[index];
return new Vector4(patch?.x ?? 0, patch?.z ?? 0, patch?.halfWidth ?? 0, patch?.halfDepth ?? 0);
});
}
export function createWaterContactPatchAxisUniformValue(contactPatches?: WaterContactPatch[]): Vector2[] {
return Array.from({ length: MAX_WATER_CONTACT_PATCHES }, (_, index) => {
const patch = contactPatches?.[index];
return new Vector2(patch?.axisX ?? 1, patch?.axisZ ?? 0);
});
}
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 {
material: new MeshBasicMaterial({
color: options.colorHex,
wireframe: true,
transparent: true,
opacity: Math.min(1, options.opacity + 0.2),
depthWrite: false
}),
animationUniform: null,
contactPatchesUniform: null,
contactPatchAxesUniform: null,
contactPatchShapesUniform: null,
reflectionTextureUniform: null,
reflectionMatrixUniform: null,
reflectionEnabledUniform: null
};
}
if (!options.quality) {
return {
material: new MeshBasicMaterial({
color: options.colorHex,
transparent: true,
opacity: options.opacity,
depthWrite: false
}),
animationUniform: null,
contactPatchesUniform: null,
contactPatchAxesUniform: null,
contactPatchShapesUniform: null,
reflectionTextureUniform: null,
reflectionMatrixUniform: null,
reflectionEnabledUniform: 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 contactPatchesUniform = { value: createWaterContactPatchUniformValue(options.contactPatches) };
const contactPatchAxesUniform = { value: createWaterContactPatchAxisUniformValue(options.contactPatches) };
const contactPatchShapesUniform = { value: createWaterContactPatchShapeUniformValue(options.contactPatches) };
const reflectionTextureUniform = { value: options.reflection?.texture ?? null };
const reflectionMatrixUniform = { value: new Matrix4() };
const reflectionEnabledUniform = {
value:
options.reflection?.enabled === true && options.reflection?.texture !== null
? Math.max(0, Math.min(1, options.reflection?.strength ?? 0.36))
: 0
};
const surfaceDisplacementEnabledUniform = {
value: options.surfaceDisplacementEnabled === true ? 1 : 0
};
const waveStrength = Math.max(0, options.waveStrength);
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 isTopFace;
uniform float surfaceDisplacementEnabled;
uniform mat4 reflectionMatrix;
varying vec2 vLocalSurfaceUv;
varying vec3 vWaveNormal;
varying vec3 vViewDir;
varying vec4 vReflectionCoord;
#include <fog_pars_vertex>
void main() {
vec3 transformedPosition = position;
vLocalSurfaceUv = position.xz;
vWaveNormal = vec3(0.0, 1.0, 0.0);
vReflectionCoord = vec4(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;
float combinedWave = waveA + waveB + waveC;
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)));
if (surfaceDisplacementEnabled > 0.5) {
transformedPosition.y += combinedWave * (0.035 + waveStrength * 0.09);
}
}
vec4 worldPos = modelMatrix * vec4(transformedPosition, 1.0);
vec4 mvPosition = viewMatrix * worldPos;
vViewDir = normalize(cameraPosition - worldPos.xyz);
vReflectionCoord = reflectionMatrix * worldPos;
gl_Position = projectionMatrix * mvPosition;
#include <fog_vertex>
}
`;
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}];
uniform vec2 contactPatchAxes[${MAX_WATER_CONTACT_PATCHES}];
uniform float contactPatchShapes[${MAX_WATER_CONTACT_PATCHES}];
uniform sampler2D reflectionTexture;
uniform float reflectionEnabled;
varying vec2 vLocalSurfaceUv;
varying vec3 vWaveNormal;
varying vec3 vViewDir;
varying vec4 vReflectionCoord;
#include <fog_pars_fragment>
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
);
}
float fbm(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
for (int octave = 0; octave < 4; octave += 1) {
value += noise(p) * amplitude;
p = p * 2.02 + vec2(17.1, 11.7);
amplitude *= 0.5;
}
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);
float fresnel = pow(1.0 - clamp(dot(viewDir, normal), 0.0, 1.0), 2.8);
float largeWave = fbm(vLocalSurfaceUv * 0.42 + vec2(time * 0.06, -time * 0.04));
float mediumWave = fbm(vLocalSurfaceUv * 0.95 + normal.xz * 0.55 + vec2(-time * 0.11, time * 0.09));
float microWave = noise(vLocalSurfaceUv * 3.6 + normal.xz * 1.6 + vec2(time * 0.24, -time * 0.19));
float caustics = fbm(vLocalSurfaceUv * 1.8 + normal.xz * 1.2 + vec2(time * 0.16, -time * 0.14));
caustics *= fbm(vLocalSurfaceUv * 2.7 - normal.xz * 1.4 + vec2(-time * 0.21, time * 0.18));
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);
float contactFoam = 0.0;
float contactRipple = 0.0;
float contactSheen = 0.0;
float reflectionMask = 0.0;
vec3 reflectionColor = vec3(0.0);
vec2 foamDrift = vec2(
sin(time * 0.52 + vLocalSurfaceUv.y * 1.15),
cos(time * 0.46 + vLocalSurfaceUv.x * 1.08)
) * (0.06 + waveStrength * 0.12);
vec2 foamUv = vLocalSurfaceUv + foamDrift + normal.xz * (0.08 + waveStrength * 0.14);
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;
if (isTopFace > 0.5) {
for (int patchIndex = 0; patchIndex < ${MAX_WATER_CONTACT_PATCHES}; patchIndex += 1) {
vec4 patchData = contactPatches[patchIndex];
if (patchData.z <= 0.0 || patchData.w <= 0.0) {
continue;
}
vec2 patchAxis = contactPatchAxes[patchIndex];
if (dot(patchAxis, patchAxis) <= 0.0) {
patchAxis = vec2(1.0, 0.0);
} else {
patchAxis = normalize(patchAxis);
}
float alongDistance = dot(foamUv - 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(foamUv, 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(foamUv, 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(foamUv * 3.4 + vec2(time * 0.34, -time * 0.28));
float foamFlow = fbm(foamUv * 1.95 + vec2(time * 0.22, -time * 0.18));
float foamField = max(contactBody * (0.42 + foamFlow * 0.18), ripple * (0.68 + wakeNoise * 0.32));
contactFoam = max(contactFoam, foamField);
contactRipple = max(contactRipple, ripple);
contactSheen = max(contactSheen, contactBody);
}
}
float refraction = (largeWave - 0.5) * 0.18 + (mediumWave - 0.5) * 0.14 + (microWave - 0.5) * 0.08 + contactRipple * 0.06;
float glints = smoothstep(0.78, 0.97, fbm(vLocalSurfaceUv * 4.8 + normal.xz * 2.2 + vec2(time * 0.38, -time * 0.31))) * (0.14 + fresnel * 0.28);
vec3 color = mix(deepTint, shallowTint, clamp(0.46 + refraction + fresnel * 0.24 + caustics * 0.08, 0.05, 0.98));
if (isTopFace > 0.5 && reflectionEnabled > 0.0 && vReflectionCoord.w > 0.0) {
vec2 reflectionUv = vReflectionCoord.xy / vReflectionCoord.w;
reflectionUv += normal.xz * (0.01 + waveStrength * 0.012) + vec2((microWave - 0.5) * 0.018, (mediumWave - 0.5) * 0.015);
if (reflectionUv.x >= 0.0 && reflectionUv.x <= 1.0 && reflectionUv.y >= 0.0 && reflectionUv.y <= 1.0) {
vec4 reflectionSample = texture2D(reflectionTexture, clamp(reflectionUv, vec2(0.001), vec2(0.999)));
if (reflectionSample.a > 0.001) {
reflectionColor = mix(reflectionSample.rgb, shallowTint, 0.32);
reflectionMask = reflectionEnabled * reflectionSample.a * clamp(0.08 + fresnel * 0.72 + glints * 0.18, 0.0, 0.62);
}
}
}
float foam = clamp(max(edgeFoam * 0.48, contactFoam) * (0.52 + waveStrength * 0.8) + caustics * 0.08 + glints * 0.06, 0.0, 0.84);
vec3 specular = vec3(pow(max(0.0, dot(reflect(-viewDir, normal), normalize(vec3(0.25, 0.88, 0.35)))), 18.0)) * (0.14 + fresnel * 0.56 + caustics * 0.14 + contactSheen * 0.12);
color = mix(color, mix(reflectionColor, color, 0.42), reflectionMask);
color = mix(color, vec3(0.97, 0.99, 1.0), foam);
color += specular;
color += vec3(0.05, 0.08, 0.12) * fresnel;
color += vec3(0.02, 0.05, 0.08) * caustics;
float alpha = isTopFace > 0.5
? clamp(surfaceOpacity + fresnel * 0.18 + foam * 0.16 + contactRipple * 0.08, 0.32, 0.92)
: clamp(surfaceOpacity * 0.72 + refraction * 0.08 + caustics * 0.04, 0.16, 0.7);
gl_FragColor = vec4(color, alpha);
#include <fog_fragment>
}
`;
const uniforms = UniformsUtils.clone(UniformsLib.fog);
Object.assign(uniforms, {
time: animationUniform,
waterColor: { value: [cr, cg, cb] },
surfaceOpacity: { value: clampedOpacity },
waveStrength: { value: waveStrength },
isTopFace: { value: topFaceFlag },
surfaceDisplacementEnabled: surfaceDisplacementEnabledUniform,
halfSize: { value: halfSize },
contactPatches: contactPatchesUniform,
contactPatchAxes: contactPatchAxesUniform,
contactPatchShapes: contactPatchShapesUniform,
reflectionTexture: reflectionTextureUniform,
reflectionMatrix: reflectionMatrixUniform,
reflectionEnabled: reflectionEnabledUniform
});
const material = new ShaderMaterial({
vertexShader,
fragmentShader,
uniforms,
transparent: true,
depthWrite: false,
fog: true,
side: DoubleSide
});
return {
material,
animationUniform,
contactPatchesUniform,
contactPatchAxesUniform,
contactPatchShapesUniform,
reflectionTextureUniform,
reflectionMatrixUniform,
reflectionEnabledUniform
};
}