feat: water shader with Fresnel, caustics & light shafts (#3)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user