285 lines
11 KiB
JavaScript
285 lines
11 KiB
JavaScript
import { DoubleSide, Euler, MeshBasicMaterial, Quaternion, ShaderMaterial, 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 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));
|
|
vec2 dirC = normalize(vec2(0.58, -0.81));
|
|
float phaseA = dot(vLocalSurfaceUv, dirA) / 2.3 + time * 0.92;
|
|
float phaseB = dot(vLocalSurfaceUv, dirB) / 1.45 - time * 1.08;
|
|
float phaseC = dot(vLocalSurfaceUv, dirC) / 0.82 + time * 1.42;
|
|
float waveA = sin(phaseA) * 0.55;
|
|
float waveB = sin(phaseB) * 0.30;
|
|
float waveC = sin(phaseC) * 0.15;
|
|
|
|
transformedPosition.y += (waveA + waveB + waveC) * waveAmplitude;
|
|
|
|
vec2 slope =
|
|
dirA * (cos(phaseA) / 2.3) * 0.55 +
|
|
dirB * (cos(phaseB) / 1.45) * 0.30 +
|
|
dirC * (cos(phaseC) / 0.82) * 0.15;
|
|
vWaveNormal = normalize(vec3(-slope.x * (0.3 + waveStrength * 0.7), 1.0, -slope.y * (0.3 + waveStrength * 0.7)));
|
|
}
|
|
|
|
vec4 worldPos = modelMatrix * vec4(transformedPosition, 1.0);
|
|
vWorldPos = worldPos.xyz;
|
|
vViewDir = normalize(cameraPosition - worldPos.xyz);
|
|
gl_Position = projectionMatrix * viewMatrix * worldPos;
|
|
}
|
|
`;
|
|
|
|
const fragmentShader = /* glsl */ `
|
|
precision highp float;
|
|
|
|
uniform vec3 waterColor;
|
|
uniform float surfaceOpacity;
|
|
uniform float waveStrength;
|
|
uniform float time;
|
|
uniform float isTopFace;
|
|
uniform vec2 halfSize;
|
|
uniform vec4 contactPatches[${MAX_WATER_CONTACT_PATCHES}];
|
|
|
|
varying vec2 vLocalSurfaceUv;
|
|
varying vec3 vWaveNormal;
|
|
varying vec3 vWorldPos;
|
|
varying vec3 vViewDir;
|
|
|
|
float hash(vec2 p) {
|
|
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
|
|
}
|
|
|
|
float noise(vec2 p) {
|
|
vec2 i = floor(p);
|
|
vec2 f = fract(p);
|
|
vec2 u = f * f * (3.0 - 2.0 * f);
|
|
|
|
return mix(
|
|
mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), u.x),
|
|
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
|
|
u.y
|
|
);
|
|
}
|
|
|
|
void main() {
|
|
vec3 normal = normalize(vWaveNormal);
|
|
vec3 viewDir = normalize(vViewDir);
|
|
float fresnel = pow(1.0 - clamp(dot(viewDir, normal), 0.0, 1.0), 2.8);
|
|
|
|
float refractPattern =
|
|
sin((vLocalSurfaceUv.x + normal.x * 0.6) * 2.2 + time * 0.8) *
|
|
sin((vLocalSurfaceUv.y + normal.z * 0.4) * 1.9 - time * 0.65);
|
|
float detail = noise(vLocalSurfaceUv * 1.8 + vec2(time * 0.12, -time * 0.09));
|
|
float refraction = refractPattern * 0.08 + (detail - 0.5) * 0.12;
|
|
|
|
vec3 deepTint = waterColor * vec3(0.52, 0.66, 0.78);
|
|
vec3 shallowTint = mix(waterColor, vec3(0.72, 0.9, 1.0), 0.2 + fresnel * 0.24);
|
|
vec3 color = mix(deepTint, shallowTint, 0.58 + refraction);
|
|
|
|
float edgeDistance = min(halfSize.x - abs(vLocalSurfaceUv.x), halfSize.y - abs(vLocalSurfaceUv.y));
|
|
float edgeBand = max(0.22, min(halfSize.x, halfSize.y) * 0.12);
|
|
float edgeFoam = isTopFace > 0.5 ? 1.0 - smoothstep(0.0, edgeBand, edgeDistance) : 0.0;
|
|
float contactFoam = 0.0;
|
|
|
|
if (isTopFace > 0.5) {
|
|
for (int patchIndex = 0; patchIndex < ${MAX_WATER_CONTACT_PATCHES}; patchIndex += 1) {
|
|
vec4 patchData = contactPatches[patchIndex];
|
|
if (patchData.z <= 0.0) {
|
|
continue;
|
|
}
|
|
|
|
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 * 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
|
|
});
|
|
|
|
return {
|
|
material,
|
|
animationUniform
|
|
};
|
|
} |