+18
-1
@@ -2,6 +2,8 @@ import * as THREE from "three";
|
|||||||
import { createTank } from "./scene/Tank";
|
import { createTank } from "./scene/Tank";
|
||||||
import { createWater } from "./scene/Water";
|
import { createWater } from "./scene/Water";
|
||||||
import { setupLighting } from "./scene/Lighting";
|
import { setupLighting } from "./scene/Lighting";
|
||||||
|
import { createFishSchool } from "./scene/Fish";
|
||||||
|
import { createParallaxCamera } from "./scene/Camera";
|
||||||
|
|
||||||
const canvas = document.querySelector<HTMLCanvasElement>("#aquarium");
|
const canvas = document.querySelector<HTMLCanvasElement>("#aquarium");
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
@@ -30,6 +32,15 @@ camera.lookAt(0, 1.2, 0);
|
|||||||
setupLighting(scene, renderer);
|
setupLighting(scene, renderer);
|
||||||
createTank(scene);
|
createTank(scene);
|
||||||
const water = createWater(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
|
// Resize handling
|
||||||
function resize(): void {
|
function resize(): void {
|
||||||
@@ -45,10 +56,16 @@ window.addEventListener("resize", resize);
|
|||||||
|
|
||||||
// Render loop
|
// Render loop
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
|
let lastFrameTime = performance.now();
|
||||||
function animate(): void {
|
function animate(): void {
|
||||||
requestAnimationFrame(animate);
|
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);
|
water.update(time);
|
||||||
|
fishSchool.update(dt, time);
|
||||||
|
parallax.update(mouseX, mouseY, time, dt);
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
}
|
}
|
||||||
animate();
|
animate();
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user