diff --git a/src/main.ts b/src/main.ts index 56af372..ef9a875 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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("#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(); diff --git a/src/scene/Water.ts b/src/scene/Water.ts new file mode 100644 index 0000000..9daddd6 --- /dev/null +++ b/src/scene/Water.ts @@ -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 }; +}