Refactor water material to use ShaderMaterial for better rendering effects

This commit is contained in:
2026-04-06 17:46:59 +02:00
parent 6da77e8495
commit ff23723797
3 changed files with 274 additions and 252 deletions

View File

@@ -1,4 +1,4 @@
import { Color, DoubleSide, Euler, MeshBasicMaterial, MeshPhysicalMaterial, Quaternion, Vector2, Vector3, Vector4 } from "three";
import { DoubleSide, Euler, MeshBasicMaterial, Quaternion, ShaderMaterial, Vector2, Vector3, Vector4 } from "three";
const MAX_WATER_CONTACT_PATCHES = 6;
const WATER_CONTACT_EPSILON = 1e-4;
@@ -8,137 +8,136 @@ 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)
];
}
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);
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 = `
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;
}
if (maxX <= -halfX || minX >= halfX || maxZ <= -halfZ || minZ >= halfZ) {
continue;
`;
const fragmentShader = `
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);
}
if (maxY < surfaceY - surfaceBand || minY > surfaceY + surfaceBand) {
continue;
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
);
}
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;
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) {
continue;
}
float normalizedDistance = length(vLocalSurfaceUv - 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);
}
}
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 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
`;
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 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 clampedOpacity = Math.max(0.12, Math.min(1, options.opacity));
const transmission = options.isTopFace
? Math.max(0.16, Math.min(0.72, 0.86 - clampedOpacity * 0.55))
: Math.max(0.08, Math.min(0.42, 0.46 - clampedOpacity * 0.32));
const attenuationDistance = options.isTopFace
? 1.4 + clampedOpacity * 2.2
: 0.8 + clampedOpacity * 1.1;
const emissiveIntensity = options.isTopFace
? 0.16 + waveStrength * 0.14 + clampedOpacity * 0.08
: 0.05 + clampedOpacity * 0.05;
const material = new MeshPhysicalMaterial({
color: options.colorHex,
emissive: options.colorHex,
emissiveIntensity,
roughness: options.isTopFace ? 0.08 : 0.22,
metalness: 0.02,
transparent: true,
opacity: 1,
transmission,
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,

View File

@@ -1,4 +1,4 @@
import { Color, DoubleSide, Euler, MeshBasicMaterial, MeshPhysicalMaterial, Quaternion, Vector2, Vector3, Vector4 } from "three";
import { DoubleSide, Euler, MeshBasicMaterial, Quaternion, ShaderMaterial, Vector2, Vector3, Vector4 } from "three";
import type { Vec3 } from "../core/vector";
@@ -15,7 +15,7 @@ export interface WaterContactPatch {
}
export interface WaterMaterialResult {
material: MeshBasicMaterial | MeshPhysicalMaterial;
material: MeshBasicMaterial | ShaderMaterial;
animationUniform: { value: number } | null;
}
@@ -166,138 +166,161 @@ export function createWaterMaterial(options: WaterMaterialOptions): WaterMateria
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 clampedOpacity = Math.max(0.12, Math.min(1, options.opacity));
const transmission = options.isTopFace
? Math.max(0.16, Math.min(0.72, 0.86 - clampedOpacity * 0.55))
: Math.max(0.08, Math.min(0.42, 0.46 - clampedOpacity * 0.32));
const attenuationDistance = options.isTopFace
? 1.4 + clampedOpacity * 2.2
: 0.8 + clampedOpacity * 1.1;
const emissiveIntensity = options.isTopFace
? 0.16 + waveStrength * 0.14 + clampedOpacity * 0.08
: 0.05 + clampedOpacity * 0.05;
const material = new MeshPhysicalMaterial({
color: options.colorHex,
emissive: options.colorHex,
emissiveIntensity,
roughness: options.isTopFace ? 0.08 : 0.22,
metalness: 0.02,
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 patch = contactPatches[patchIndex];
if (patch.z <= 0.0) {
continue;
}
float normalizedDistance = length(vLocalSurfaceUv - 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);
}
}
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,
opacity: 1,
transmission,
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,
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 <common>",
`#include <common>
uniform float waterTime;
uniform float waterWaveStrength;
uniform float waterWaveAmplitude;
uniform float waterIsTopFace;
varying vec2 vWaterLocalPos;
varying vec3 vWaterWaveNormal;`
)
.replace(
"#include <begin_vertex>",
`#include <begin_vertex>
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 <common>",
`#include <common>
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 <normal_fragment_begin>",
`#include <normal_fragment_begin>
if (waterIsTopFace > 0.5) {
normal = normalize(mix(normal, vWaterWaveNormal, 0.72));
}`
)
.replace(
"#include <color_fragment>",
`#include <color_fragment>
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

View File

@@ -1,4 +1,4 @@
import { MeshPhysicalMaterial } from "three";
import { ShaderMaterial } from "three";
import { describe, expect, it } from "vitest";
import { collectWaterContactPatches, createWaterMaterial } from "../../src/rendering/water-material";
@@ -84,7 +84,7 @@ describe("water material helpers", () => {
expect(patches).toHaveLength(0);
});
it("keeps quality water visibly tinted instead of fading to transparent alpha", () => {
it("builds a shared quality shader material for visible tinted water", () => {
const result = createWaterMaterial({
colorHex: "#4da6d9",
surfaceOpacity: 0.55,
@@ -101,12 +101,12 @@ describe("water material helpers", () => {
contactPatches: []
});
expect(result.material).toBeInstanceOf(MeshPhysicalMaterial);
expect(result.material).toBeInstanceOf(ShaderMaterial);
const material = result.material as MeshPhysicalMaterial;
expect(material.opacity).toBe(1);
expect(material.transmission).toBeGreaterThan(0.16);
expect(material.transmission).toBeLessThan(0.72);
expect(material.emissiveIntensity).toBeGreaterThan(0.16);
const material = result.material as ShaderMaterial;
expect(material.transparent).toBe(true);
expect(material.uniforms["surfaceOpacity"]?.value).toBeGreaterThan(0.14);
expect(material.uniforms["waveStrength"]?.value).toBe(0.35);
expect(material.uniforms["isTopFace"]?.value).toBe(1);
});
});