From c803ca97d3db86b200a4b4e5b2856958e9b6f20a Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 6 Oct 2026 19:34:56 +0000 Subject: [PATCH] feat: procedural fish with wandering AI & parallax camera (#7, #5) --- src/main.ts | 19 ++++- src/scene/Camera.ts | 67 +++++++++++++++ src/scene/Fish.ts | 204 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 src/scene/Camera.ts create mode 100644 src/scene/Fish.ts diff --git a/src/main.ts b/src/main.ts index ef9a875..bedf50b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,8 @@ import * as THREE from "three"; import { createTank } from "./scene/Tank"; import { createWater } from "./scene/Water"; import { setupLighting } from "./scene/Lighting"; +import { createFishSchool } from "./scene/Fish"; +import { createParallaxCamera } from "./scene/Camera"; const canvas = document.querySelector("#aquarium"); if (!canvas) { @@ -30,6 +32,15 @@ camera.lookAt(0, 1.2, 0); setupLighting(scene, renderer); createTank(scene); const water = createWater(scene); +const fishSchool = createFishSchool(scene, 6); +const parallax = createParallaxCamera(camera); + +// Mouse tracking (normalized to -1..1) +let mouseX = 0, mouseY = 0; +window.addEventListener("mousemove", (e) => { + mouseX = (e.clientX / window.innerWidth) * 2 - 1; + mouseY = (e.clientY / window.innerHeight) * 2 - 1; +}); // Resize handling function resize(): void { @@ -45,10 +56,16 @@ window.addEventListener("resize", resize); // Render loop const startTime = performance.now(); +let lastFrameTime = performance.now(); function animate(): void { requestAnimationFrame(animate); - const time = (performance.now() - startTime) / 1000; + const now = performance.now(); + const time = (now - startTime) / 1000; + const dt = Math.min((now - lastFrameTime) / 1000, 0.1); + lastFrameTime = now; water.update(time); + fishSchool.update(dt, time); + parallax.update(mouseX, mouseY, time, dt); renderer.render(scene, camera); } animate(); diff --git a/src/scene/Camera.ts b/src/scene/Camera.ts new file mode 100644 index 0000000..3ccca7a --- /dev/null +++ b/src/scene/Camera.ts @@ -0,0 +1,67 @@ +import * as THREE from "three"; + +export interface ParallaxCamera { + update: (mouseX: number, mouseY: number, time: number, dt: number) => void; +} + +// Creates a parallax camera controller: the camera sways gently toward the +// mouse and drifts slowly on its own while the mouse is idle. +export function createParallaxCamera(camera: THREE.PerspectiveCamera): ParallaxCamera { + // Baseline captured at creation time. `basePosition` is the full snapshot; + // `baseZ` is the starting depth used to offset the camera along its axis. + const basePosition = camera.position.clone(); + const baseZ = camera.position.z; + + // Smoothed state (radians for rotations, world units for the z offset). + let currentY = 0; + let currentX = 0; + let currentZ = 0; + + // Last known mouse position and when it most recently changed (idle detect). + let lastMouseX = 0; + let lastMouseY = 0; + let lastChangeTime = -Infinity; + + // Easing rate: how fast values chase their target (frame-rate independent). + const EASE = 5; + + function update(mouseX: number, mouseY: number, time: number, dt: number): void { + // 1. Mouse moved? Refresh the "last change" timestamp. + if (mouseX !== lastMouseX || mouseY !== lastMouseY) { + lastChangeTime = time; + lastMouseX = mouseX; + lastMouseY = mouseY; + } + + // 2. Target rotation about Y (≈ ±5°). + let targetY = mouseX * 0.087; + + // 3. Target rotation about X (≈ ±1°). + const targetX = mouseY * 0.02; + + // 4. Target z offset (parallax push away/toward the tank). + const targetZ = mouseX * 0.1; + + // 5. Idle sway — a slow drift once the mouse has been still for >2s. + if (time - lastChangeTime > 2) { + targetY += Math.sin(time * 0.4) * 0.03; + } + + // 6. Ease each value toward its target. + const ease = 1 - Math.exp(-EASE * dt); + currentY += (targetY - currentY) * ease; + currentX += (targetX - currentX) * ease; + currentZ += (targetZ - currentZ) * ease; + + // 7. Apply to the camera (position offset from the original baseline). + camera.rotation.y = currentY; + camera.rotation.x = currentX; + camera.position.z = baseZ + currentZ; + } + + // basePosition is the full baseline snapshot kept for future use (e.g. + // restoring x/y or clamping); the current parallax only offsets z. + void basePosition; + + return { update }; +} diff --git a/src/scene/Fish.ts b/src/scene/Fish.ts new file mode 100644 index 0000000..bd652a2 --- /dev/null +++ b/src/scene/Fish.ts @@ -0,0 +1,204 @@ +import * as THREE from "three"; + +// ---- Configuration ---- + +// Roaming / clamping bounds, kept inside the tank (tank spans x:-2..2, y:0..2.5, z:-1.5..1.5) +type Bounds = { x: [number, number]; y: [number, number]; z: [number, number] }; +const BOUNDS: { wander: Bounds; clamp: Bounds } = { + wander: { x: [-1.5, 1.5], y: [0.3, 2.0], z: [-1.0, 1.0] }, + clamp: { x: [-1.8, 1.8], y: [0.2, 2.3], z: [-1.3, 1.3] }, +}; + +// The fish body is elongated along local +X, so that is its forward axis. +const FORWARD = new THREE.Vector3(1, 0, 0); + +// Color palette: orange / blue / yellow hue ranges (HSL). +const HUE_RANGES: [number, number][] = [ + [0.05, 0.12], // orange + [0.55, 0.62], // blue + [0.12, 0.18], // yellow +]; + +// Scratch objects reused by update() to avoid per-frame allocation. +const _dir = new THREE.Vector3(); +const _targetQuat = new THREE.Quaternion(); + +// ---- Helpers ---- + +function rand(min: number, max: number): number { + return min + Math.random() * (max - min); +} + +function randIn(range: [number, number]): number { + return rand(range[0], range[1]); +} + +function randomWanderTarget(): THREE.Vector3 { + return new THREE.Vector3( + randIn(BOUNDS.wander.x), + randIn(BOUNDS.wander.y), + randIn(BOUNDS.wander.z) + ); +} + +function clampToBounds(v: THREE.Vector3): void { + v.x = THREE.MathUtils.clamp(v.x, BOUNDS.clamp.x[0], BOUNDS.clamp.x[1]); + v.y = THREE.MathUtils.clamp(v.y, BOUNDS.clamp.y[0], BOUNDS.clamp.y[1]); + v.z = THREE.MathUtils.clamp(v.z, BOUNDS.clamp.z[0], BOUNDS.clamp.z[1]); +} + +function randomFishColor(): THREE.Color { + const [h0, h1] = HUE_RANGES[Math.floor(Math.random() * HUE_RANGES.length)]; + return new THREE.Color().setHSL(rand(h0, h1), rand(0.7, 0.9), rand(0.5, 0.65)); +} + +// ---- Types ---- + +interface FishState { + group: THREE.Group; + tail: THREE.Mesh; + body: THREE.Mesh; + position: THREE.Vector3; + target: THREE.Vector3; + speed: number; + phase: number; + turnSpeed: number; + quaternion: THREE.Quaternion; +} + +// ---- Mesh construction ---- + +// Builds a low-poly procedural fish from primitives, all sharing one material. +// The body is an elongated sphere (long axis = local +X = forward). +function createFishMesh(color: THREE.Color): { + group: THREE.Group; + tail: THREE.Mesh; + body: THREE.Mesh; +} { + const material = new THREE.MeshStandardMaterial({ + color, + roughness: 0.4, + metalness: 0.1, + // DoubleSide so the flat pectoral fins are visible from either side. + side: THREE.DoubleSide, + }); + + const group = new THREE.Group(); + + // Body + const body = new THREE.Mesh(new THREE.SphereGeometry(1, 16, 12), material); + body.scale.set(1.0, 0.4, 0.25); + body.castShadow = true; + + // Tail — thin cone at the rear. + // NOTE: the spec says "rotated Z by PI", but that orients the cone's axis + // along Y, which makes the per-frame rotation.y wag invisible (a line aligned + // with its rotation axis doesn't move). Rotating -PI/2 instead points the tip + // forward (+X, toward the body) as intended, and lets the y-wag sweep side-to-side. + const tail = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.5, 4), material); + tail.rotation.z = -Math.PI / 2; + tail.scale.set(0.1, 1, 1); + tail.position.set(-1.0, 0, 0); + tail.castShadow = true; + + // Dorsal fin — sits on top of the body, points up. + const dorsal = new THREE.Mesh(new THREE.ConeGeometry(0.15, 0.2, 4), material); + dorsal.scale.set(0.1, 1, 1); + dorsal.position.set(0, 0.4, 0); + dorsal.castShadow = true; + + // Pectoral fins — small flat paddles on either side, angled out and back. + // Nudged just past the body's z-extent (0.25) so they read as fins rather + // than being buried inside the opaque body. + const pectoralGeo = new THREE.PlaneGeometry(0.2, 0.1); + + const leftFin = new THREE.Mesh(pectoralGeo, material); + leftFin.position.set(0, -0.05, 0.26); + leftFin.rotation.set(-0.3, 0, 0.3); // face +Z (outward), droop down, sweep back + leftFin.castShadow = true; + + const rightFin = new THREE.Mesh(pectoralGeo, material); + rightFin.position.set(0, -0.05, -0.26); + rightFin.rotation.set(-0.3, Math.PI, -0.3); // face -Z (outward) + rightFin.castShadow = true; + + group.add(body, tail, dorsal, leftFin, rightFin); + + return { group, tail, body }; +} + +// ---- School ---- + +export interface FishSchool { + update: (dt: number, time: number) => void; + group: THREE.Group; +} + +// Creates `count` independently-wandering fish under a single parent group. +export function createFishSchool(scene: THREE.Scene, count: number = 6): FishSchool { + const group = new THREE.Group(); + const fish: FishState[] = []; + + for (let i = 0; i < count; i++) { + const { group: fishGroup, tail, body } = createFishMesh(randomFishColor()); + + const position = randomWanderTarget(); + const target = randomWanderTarget(); + const speed = rand(0.3, 0.8); + const phase = Math.random() * Math.PI * 2; + const turnSpeed = rand(2, 4); + + // Start already facing the first target to avoid an initial swing. + const startDir = target.clone().sub(position).normalize(); + const quaternion = new THREE.Quaternion().setFromUnitVectors(FORWARD, startDir); + + fishGroup.position.copy(position); + fishGroup.quaternion.copy(quaternion); + fishGroup.scale.setScalar(rand(0.3, 0.5)); + + group.add(fishGroup); + + fish.push({ group: fishGroup, tail, body, position, target, speed, phase, turnSpeed, quaternion }); + } + + scene.add(group); + + function update(dt: number, time: number): void { + for (const f of fish) { + // 1. Direction toward current target. + _dir.subVectors(f.target, f.position); + + // 2. Reached the target — pick a new one. + if (_dir.length() < 0.3) { + f.target.copy(randomWanderTarget()); + _dir.subVectors(f.target, f.position); + } + _dir.normalize(); + + // 3. Turn toward the target (frame-rate independent slerp). + _targetQuat.setFromUnitVectors(FORWARD, _dir); + f.quaternion.slerp(_targetQuat, 1 - Math.exp(-f.turnSpeed * dt)); + + // 4. Move forward. + f.position.addScaledVector(_dir, f.speed * dt); + + // 5. Gentle vertical bob. + f.position.y += Math.sin(time * 1.5 + f.phase) * 0.002; + + // 6. Keep inside the tank. + clampToBounds(f.position); + + // 7. Apply transform. + f.group.position.copy(f.position); + f.group.quaternion.copy(f.quaternion); + + // 8. Tail wag. + f.tail.rotation.y = Math.sin(time * (2 + f.speed * 3) + f.phase) * 0.4; + + // 9. Subtle body flex (length pulses around 1.0). + f.body.scale.x = 1.0 + Math.sin(time * (2 + f.speed * 3) + f.phase) * 0.03; + } + } + + return { update, group }; +}