feat: water shader with Fresnel, caustics & light shafts (#3)

This commit is contained in:
2026-10-06 19:10:31 +00:00
parent 4492b7ace8
commit b7489af394
2 changed files with 221 additions and 0 deletions
+5
View File
@@ -1,5 +1,6 @@
import * as THREE from "three";
import { createTank } from "./scene/Tank";
import { createWater } from "./scene/Water";
import { setupLighting } from "./scene/Lighting";
const canvas = document.querySelector<HTMLCanvasElement>("#aquarium");
@@ -28,6 +29,7 @@ camera.lookAt(0, 1.2, 0);
// Scene content
setupLighting(scene, renderer);
createTank(scene);
const water = createWater(scene);
// Resize handling
function resize(): void {
@@ -42,8 +44,11 @@ resize();
window.addEventListener("resize", resize);
// Render loop
const startTime = performance.now();
function animate(): void {
requestAnimationFrame(animate);
const time = (performance.now() - startTime) / 1000;
water.update(time);
renderer.render(scene, camera);
}
animate();
+216
View File
@@ -0,0 +1,216 @@
import * as THREE from "three";
// Tank dimensions
const TANK_WIDTH = 4;
const TANK_HEIGHT = 2.5;
const TANK_DEPTH = 3;
const INSET = 0.05;
// Water volume dimensions (inset from glass)
const WATER_WIDTH = TANK_WIDTH - INSET * 2;
const WATER_DEPTH = TANK_DEPTH - INSET * 2;
const WATER_Y_MIN = 0.1; // gravel level
const WATER_Y_MAX = 2.2; // slightly below top
const WATER_HEIGHT = WATER_Y_MAX - WATER_Y_MIN;
const WATER_Y_CENTER = (WATER_Y_MIN + WATER_Y_MAX) / 2;
// ---- Shaders ----
const waterVertex = /* glsl */ `
varying vec3 vNormal;
varying vec3 vWorldPos;
varying vec3 vViewDir;
void main() {
vec4 worldPos = modelMatrix * vec4(position, 1.0);
vWorldPos = worldPos.xyz;
vNormal = normalize(normalMatrix * normal);
vViewDir = normalize(cameraPosition - worldPos.xyz);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const waterFragment = /* glsl */ `
uniform float uTime;
uniform vec3 uColor;
varying vec3 vNormal;
varying vec3 vWorldPos;
varying vec3 vViewDir;
void main() {
// Fresnel: edges more opaque, center more transparent
float fresnel = pow(1.0 - abs(dot(normalize(vNormal), normalize(vViewDir))), 3.0);
// Subtle animated surface distortion
float wave = sin(vWorldPos.x * 3.0 + uTime * 0.8) *
sin(vWorldPos.z * 2.5 + uTime * 0.6) * 0.02;
// Base color with slight variation
vec3 color = uColor + wave * 0.1;
// Alpha: mostly transparent in center, more visible at edges
float alpha = 0.15 + fresnel * 0.5;
gl_FragColor = vec4(color, alpha);
}
`;
const causticVertex = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const causticFragment = /* glsl */ `
uniform float uTime;
varying vec2 vUv;
float noise(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float caustic(vec2 uv, float t) {
vec2 p = uv * 6.0;
float v = 0.0;
v += sin(p.x * 3.0 + t * 1.2) * sin(p.y * 3.0 + t * 0.8);
v += sin(p.x * 5.0 - t * 0.9 + 1.3) * sin(p.y * 4.0 + t * 1.1);
v += sin((p.x + p.y) * 4.0 + t * 0.7) * 0.5;
v = pow(abs(v), 2.0);
return v;
}
void main() {
vec2 uv = vUv;
float t = uTime * 0.5;
float c = caustic(uv, t) + caustic(uv * 0.7 + 0.3, t * 0.8);
c = clamp(c, 0.0, 1.0);
// Bright yellow-white caustic color
vec3 causticColor = vec3(0.9, 0.85, 0.7) * c * 0.6;
// Fade at edges
float edgeFade = smoothstep(0.0, 0.1, uv.x) * smoothstep(1.0, 0.9, uv.x) *
smoothstep(0.0, 0.1, uv.y) * smoothstep(1.0, 0.9, uv.y);
gl_FragColor = vec4(causticColor * edgeFade, c * 0.4 * edgeFade);
}
`;
const shaftVertex = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const shaftFragment = /* glsl */ `
uniform float uTime;
varying vec2 vUv;
void main() {
// Vertical gradient: bright at top (uv.y=1), transparent at bottom (uv.y=0)
float intensity = pow(vUv.y, 2.0) * 0.3;
// Subtle horizontal fade (narrow beam)
float hFade = smoothstep(0.0, 0.3, vUv.x) * smoothstep(1.0, 0.7, vUv.x);
// Gentle pulsing
float pulse = 0.8 + 0.2 * sin(uTime * 0.5);
vec3 color = vec3(0.9, 0.85, 0.7) * intensity * hFade * pulse;
gl_FragColor = vec4(color, intensity * hFade * pulse * 0.5);
}
`;
// ---- Creation ----
export function createWater(scene: THREE.Scene): { update: (time: number) => void } {
// Collect all uTime uniforms so update() can set them in one place
const timeUniforms: { value: number }[] = [];
// 1. Water Volume
const waterMaterial = new THREE.ShaderMaterial({
vertexShader: waterVertex,
fragmentShader: waterFragment,
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
uniforms: {
uTime: { value: 0 },
uColor: { value: new THREE.Color(0.08, 0.35, 0.45) },
},
});
timeUniforms.push(waterMaterial.uniforms.uTime);
const waterGeometry = new THREE.BoxGeometry(WATER_WIDTH, WATER_HEIGHT, WATER_DEPTH);
const waterMesh = new THREE.Mesh(waterGeometry, waterMaterial);
waterMesh.position.set(0, WATER_Y_CENTER, 0);
waterMesh.renderOrder = 2;
scene.add(waterMesh);
// 2. Caustics on gravel floor
const causticMaterial = new THREE.ShaderMaterial({
vertexShader: causticVertex,
fragmentShader: causticFragment,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
uniforms: {
uTime: { value: 0 },
},
});
timeUniforms.push(causticMaterial.uniforms.uTime);
const causticGeometry = new THREE.PlaneGeometry(WATER_WIDTH, WATER_DEPTH);
const causticMesh = new THREE.Mesh(causticGeometry, causticMaterial);
causticMesh.position.set(0, 0.06, 0);
causticMesh.rotation.x = -Math.PI / 2;
causticMesh.renderOrder = 1;
scene.add(causticMesh);
// 3. Light shafts
const shaftMaterial = new THREE.ShaderMaterial({
vertexShader: shaftVertex,
fragmentShader: shaftFragment,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
side: THREE.DoubleSide,
uniforms: {
uTime: { value: 0 },
},
});
timeUniforms.push(shaftMaterial.uniforms.uTime);
const shaftGeometry = new THREE.PlaneGeometry(0.3, 2.0);
const shaftPositions: [number, number, number, number][] = [
// [x, y, z, rotationY]
[-0.8, 1.1, -0.3, 0.3],
[0.5, 1.2, 0.5, -0.5],
[1.2, 1.0, -0.8, 0.8],
];
for (const [x, y, z, rotY] of shaftPositions) {
const shaft = new THREE.Mesh(shaftGeometry, shaftMaterial);
shaft.position.set(x, y, z);
shaft.rotation.y = rotY;
shaft.renderOrder = 2;
scene.add(shaft);
}
// Update function — called every frame with elapsed time in seconds
function update(time: number): void {
for (const u of timeUniforms) {
u.value = time;
}
}
return { update };
}