feat: water shader with Fresnel, caustics & light shafts (#3)
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { createTank } from "./scene/Tank";
|
import { createTank } from "./scene/Tank";
|
||||||
|
import { createWater } from "./scene/Water";
|
||||||
import { setupLighting } from "./scene/Lighting";
|
import { setupLighting } from "./scene/Lighting";
|
||||||
|
|
||||||
const canvas = document.querySelector<HTMLCanvasElement>("#aquarium");
|
const canvas = document.querySelector<HTMLCanvasElement>("#aquarium");
|
||||||
@@ -28,6 +29,7 @@ camera.lookAt(0, 1.2, 0);
|
|||||||
// Scene content
|
// Scene content
|
||||||
setupLighting(scene, renderer);
|
setupLighting(scene, renderer);
|
||||||
createTank(scene);
|
createTank(scene);
|
||||||
|
const water = createWater(scene);
|
||||||
|
|
||||||
// Resize handling
|
// Resize handling
|
||||||
function resize(): void {
|
function resize(): void {
|
||||||
@@ -42,8 +44,11 @@ resize();
|
|||||||
window.addEventListener("resize", resize);
|
window.addEventListener("resize", resize);
|
||||||
|
|
||||||
// Render loop
|
// Render loop
|
||||||
|
const startTime = performance.now();
|
||||||
function animate(): void {
|
function animate(): void {
|
||||||
requestAnimationFrame(animate);
|
requestAnimationFrame(animate);
|
||||||
|
const time = (performance.now() - startTime) / 1000;
|
||||||
|
water.update(time);
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
}
|
}
|
||||||
animate();
|
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