<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D 鱼群模拟</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #1a1a2e; font-family: 'Segoe UI', sans-serif; }
canvas { display: block; }
#info {
position: fixed; top: 15px; left: 15px;
color: #e0f7ff; font-size: 13px;
background: rgba(0,20,40,0.7); padding: 12px 18px;
border-radius: 10px; border: 1px solid rgba(0,150,255,0.3);
backdrop-filter: blur(5px); pointer-events: none; z-index: 10;
}
#info h3 { margin-bottom: 6px; color: #4fc3f7; font-size: 15px; }
#info p { line-height: 1.5; opacity: 0.85; }
</style>
</head>
<body>
<div id="info">
<h3>🐟 3D 鱼群模拟</h3>
<p>10条鱼遵循 Boids Plus 规则<br>水草随水流动态摆动</p>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(() => {
// ==================== 场景初始化 ====================
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
scene.fog = new THREE.Fog(0x1a1a2e, 15, 30);
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 3, 8);
camera.lookAt(0, 0.5, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
document.body.appendChild(renderer.domElement);
// ==================== 灯光 ====================
const ambientLight = new THREE.AmbientLight(0x404060, 0.6);
scene.add(ambientLight);
const mainLight = new THREE.DirectionalLight(0xffeedd, 1.2);
mainLight.position.set(5, 10, 5);
mainLight.castShadow = true;
mainLight.shadow.mapSize.set(2048, 2048);
mainLight.shadow.camera.near = 0.5;
mainLight.shadow.camera.far = 30;
mainLight.shadow.camera.left = -8;
mainLight.shadow.camera.right = 8;
mainLight.shadow.camera.top = 8;
mainLight.shadow.camera.bottom = -8;
scene.add(mainLight);
const fillLight = new THREE.DirectionalLight(0x4488ff, 0.4);
fillLight.position.set(-5, 3, -3);
scene.add(fillLight);
const waterLight = new THREE.PointLight(0x00aaff, 1.5, 10);
waterLight.position.set(0, 2, 0);
scene.add(waterLight);
const pointLight2 = new THREE.PointLight(0xff8844, 0.5, 12);
pointLight2.position.set(-4, 4, 3);
scene.add(pointLight2);
// ==================== 地面 ====================
const floorGeo = new THREE.PlaneGeometry(30, 30);
const floorMat = new THREE.MeshStandardMaterial({ color: 0x2a2a3e, roughness: 0.9 });
const floor = new THREE.Mesh(floorGeo, floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.5;
floor.receiveShadow = true;
scene.add(floor);
// ==================== 桌子 ====================
const tableGroup = new THREE.Group();
const tableMat = new THREE.MeshStandardMaterial({ color: 0x8B5E3C, roughness: 0.6, metalness: 0.1 });
// 桌面
const tableTop = new THREE.Mesh(new THREE.BoxGeometry(5, 0.15, 3.5), tableMat);
tableTop.position.y = 0.075;
tableTop.castShadow = true;
tableTop.receiveShadow = true;
tableGroup.add(tableTop);
// 桌腿
const legPositions = [[-2.2, -0.4, -1.5], [2.2, -0.4, -1.5], [-2.2, -0.4, 1.5], [2.2, -0.4, 1.5]];
legPositions.forEach(p => {
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.9, 0.12), tableMat);
leg.position.set(p[0], p[1], p[2]);
leg.castShadow = true;
tableGroup.add(leg);
});
// 横梁
const beamMat = new THREE.MeshStandardMaterial({ color: 0x7A5230, roughness: 0.7 });
[[-2.2, 0, 0], [2.2, 0, 0]].forEach(p => {
const beam = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.08, 3), beamMat);
beam.position.set(p[0], p[1], p[2]);
tableGroup.add(beam);
});
scene.add(tableGroup);
// ==================== 鱼缸 ====================
const TANK_W = 3.2, TANK_H = 2.2, TANK_D = 1.8;
const TANK_Y = 0.15;
// 玻璃材质
const glassMat = new THREE.MeshPhysicalMaterial({
color: 0x88ccff, transparent: true, opacity: 0.15,
roughness: 0.05, metalness: 0.1, side: THREE.DoubleSide,
transmission: 0.9
});
const tankGroup = new THREE.Group();
tankGroup.position.y = TANK_Y;
// 玻璃壁
const wallMat = glassMat.clone();
const walls = [
{ s: [TANK_W, TANK_H, 0.05], p: [0, TANK_H/2, TANK_D/2] },
{ s: [TANK_W, TANK_H, 0.05], p: [0, TANK_H/2, -TANK_D/2] },
{ s: [0.05, TANK_H, TANK_D], p: [TANK_W/2, TANK_H/2, 0] },
{ s: [0.05, TANK_H, TANK_D], p: [-TANK_W/2, TANK_H/2, 0] },
];
walls.forEach(w => {
const wall = new THREE.Mesh(new THREE.BoxGeometry(...w.s), wallMat);
wall.position.set(...w.p);
tankGroup.add(wall);
});
// 底部
const bottomMat = new THREE.MeshStandardMaterial({ color: 0x5c3a1e, roughness: 0.9 });
const bottom = new THREE.Mesh(new THREE.BoxGeometry(TANK_W, 0.05, TANK_D), bottomMat);
bottom.position.y = 0.025;
bottom.receiveShadow = true;
tankGroup.add(bottom);
// 沙子层
const sandMat = new THREE.MeshStandardMaterial({ color: 0xd4b896, roughness: 0.95 });
const sand = new THREE.Mesh(new THREE.BoxGeometry(TANK_W - 0.1, 0.08, TANK_D - 0.1), sandMat);
sand.position.y = 0.09;
tankGroup.add(sand);
// 水面
const waterGeo = new THREE.PlaneGeometry(TANK_W - 0.15, TANK_D - 0.15, 32, 32);
const waterMat = new THREE.MeshPhysicalMaterial({
color: 0x006994, transparent: true, opacity: 0.4,
roughness: 0.2, metalness: 0.1, side: THREE.DoubleSide
});
const water = new THREE.Mesh(waterGeo, waterMat);
water.rotation.x = -Math.PI / 2;
water.position.y = TANK_H - 0.15;
tankGroup.add(water);
// 缸沿
const rimMat = new THREE.MeshStandardMaterial({ color: 0x334455, roughness: 0.3, metalness: 0.5 });
const rimGeo = new THREE.BoxGeometry(TANK_W + 0.1, 0.06, TANK_D + 0.1);
const rim = new THREE.Mesh(rimGeo, rimMat);
rim.position.y = TANK_H;
rim.castShadow = true;
tankGroup.add(rim);
scene.add(tankGroup);
// ==================== 装饰石头 ====================
const stoneMat = new THREE.MeshStandardMaterial({ color: 0x666677, roughness: 0.8 });
[[-1.2, 0.12, -0.5, 0.25], [1.0, 0.15, 0.6, 0.3], [0.3, 0.1, -0.7, 0.18]].forEach(([x,y,z,r]) => {
const stone = new THREE.Mesh(new THREE.SphereGeometry(r, 8, 6), stoneMat);
stone.position.set(x, TANK_Y + y, z);
stone.scale.y = 0.5;
stone.castShadow = true;
scene.add(stone);
});
// ==================== 鱼模型创建 ====================
function createFishMesh(color) {
const group = new THREE.Group();
// 身体 - 流线型
const bodyGeo = new THREE.CylinderGeometry(0.04, 0.07, 0.35, 8, 4);
bodyGeo.rotateZ(Math.PI / 2);
// 压扁一点
bodyGeo.scale(1, 0.65, 1.3);
const bodyMat = new THREE.MeshStandardMaterial({
color: color, roughness: 0.3, metalness: 0.4,
emissive: color, emissiveIntensity: 0.15
});
const body = new THREE.Mesh(bodyGeo, bodyMat);
group.add(body);
// 头部
const headGeo = new THREE.SphereGeometry(0.055, 8, 6);
headGeo.scale(1.4, 0.7, 1.2);
const head = new THREE.Mesh(headGeo, bodyMat);
head.position.x = 0.18;
group.add(head);
// 尾巴
const tailGeo = new THREE.ConeGeometry(0.1, 0.18, 4);
tailGeo.rotateZ(-Math.PI / 2);
const tailMat = new THREE.MeshStandardMaterial({
color: color, roughness: 0.4, metalness: 0.2,
emissive: color, emissiveIntensity: 0.1, transparent: true, opacity: 0.85
});
const tail = new THREE.Mesh(tailGeo, tailMat);
tail.position.x = -0.22;
tail.name = 'tail';
group.add(tail);
// 背鳍
const finGeo = new THREE.ConeGeometry(0.06, 0.12, 3);
const finMat = new THREE.MeshStandardMaterial({
color: color, transparent: true, opacity: 0.7, roughness: 0.5
});
const backFin = new THREE.Mesh(finGeo, finMat);
backFin.position.set(0, 0.07, 0);
backFin.name = 'backFin';
group.add(backFin);
// 眼睛
const eyeGeo = new THREE.SphereGeometry(0.018, 8, 6);
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
const eyePupil = new THREE.MeshBasicMaterial({ color: 0x111111 });
[[0.22, 0.02, 0.045], [0.22, 0.02, -0.045]].forEach(p => {
const eye = new THREE.Mesh(eyeGeo, eyeMat);
eye.position.set(...p);
group.add(eye);
const pupil = new THREE.Mesh(new THREE.SphereGeometry(0.01, 6, 4), eyePupil);
pupil.position.set(p[0] + 0.01, p[1], p[2]);
group.add(pupil);
});
group.castShadow = true;
return group;
}
// ==================== 水草 ====================
// 在外面创建共享材质(只创建一次)
const seaweedLeafMat = new THREE.MeshStandardMaterial({
color: 0x2d8a4e, roughness: 0.7, side: THREE.DoubleSide
});
// 优化后的 createSeaweed 函数
function createSeaweed(x, z, height, segments) {
const group = new THREE.Group();
group.position.set(x, 0, z);
const segmentHeight = height / segments;
const segmentGeo = new THREE.CylinderGeometry(0.02, 0.025, segmentHeight, 5, 1);
segmentGeo.translate(0, segmentHeight / 2, 0);
const leaves = [];
const basePositions = [];
let currentY = 0;
// 创建茎干
for (let i = 0; i < segments; i++) {
const leaf = new THREE.Mesh(segmentGeo.clone(), seaweedLeafMat);
leaf.position.set(0, TANK_Y + currentY + 0.1, 0);
leaf.castShadow = true;
group.add(leaf);
leaves.push(leaf);
basePositions.push(leaf.position.clone());
currentY += segmentHeight;
}
// 添加叶片(减少数量)
for (let i = 2; i < leaves.length; i += 2) { // 每隔2段加一个叶片
const leafBlade = new THREE.Mesh(
new THREE.PlaneGeometry(0.06, segmentHeight * 1.5),
seaweedLeafMat
);
leafBlade.position.set(0, TANK_Y + (i * segmentHeight) + 0.1, 0);
leafBlade.rotation.y = Math.random() * Math.PI;
leafBlade.name = 'leafBlade';
group.add(leafBlade);
leaves.push(leafBlade);
basePositions.push(leafBlade.position.clone());
}
// 预分配向量对象,避免每帧创建
group.userData = {
leaves,
basePositions,
segments,
velocities: leaves.map(() => new THREE.Vector3()), // 预分配速度向量
tempForce: new THREE.Vector3() // 临时力向量
};
return group;
}
// 对应的优化后的 updateSeaweeds 函数
function updateSeaweeds() {
const time = Date.now() * 0.001;
seaweeds.forEach(sw => {
const { leaves, basePositions, velocities } = sw.userData;
leaves.forEach((leaf, i) => {
const velocity = velocities[i];
const basePos = basePositions[i];
// 计算鱼产生的水流力(简化)
let forceStrength = 0;
fishSchool.forEach(fish => {
const dist = fish.mesh.position.distanceTo(leaf.position);
if (dist < 1.0) {
forceStrength += (1.0 - dist) * fish.velocity.length() * 0.5;
}
});
// 弹簧回复力
const springForce = basePos.clone().sub(leaf.position).multiplyScalar(0.05);
// 自然摆动(简化)
const swayX = Math.sin(time * 0.6 + i * 0.4) * 0.0005 * (i / leaves.length);
const swayZ = Math.cos(time * 0.5 + i * 0.3) * 0.0003 * (i / leaves.length);
// 应用力
velocity.add(springForce);
velocity.x += swayX + (forceStrength * 0.01);
velocity.z += swayZ;
velocity.multiplyScalar(0.90); // 阻尼
leaf.position.add(velocity);
// 限制偏移
const offset = leaf.position.clone().sub(basePos);
const maxOffset = 0.1 * (i / leaves.length);
if (offset.length() > maxOffset) {
offset.normalize().multiplyScalar(maxOffset);
leaf.position.copy(basePos).add(offset);
}
});
});
}
// ==================== 初始化水草 ====================
const seaweeds = [];
const plantConfigs = [
[-1.2, -0.6, 0.8, 12], [-0.8, 0.5, 0.6, 10], [1.0, -0.4, 0.9, 14],
[1.3, 0.6, 0.5, 8], [-0.3, -0.7, 0.7, 11], [0.5, 0.7, 0.65, 9],
[-1.4, 0.2, 0.55, 9], [0.0, -0.5, 0.75, 12]
];
plantConfigs.forEach(([x, z, h, s]) => {
const sw = createSeaweed(x, z, h, s);
scene.add(sw);
seaweeds.push(sw);
});
// ==================== Boids 鱼群 ====================
const FISH_COLORS = [
0xff6b35, 0xffaa22, 0x44aaff, 0xff4488, 0x88ff44,
0xaa44ff, 0x44ffaa, 0xff8844, 0x4488ff, 0xff44aa
];
class BoidFish {
constructor(index) {
this.mesh = createFishMesh(FISH_COLORS[index % FISH_COLORS.length]);
this.mesh.position.set(
(Math.random() - 0.5) * (TANK_W * 0.5),
TANK_Y + 0.3 + Math.random() * (TANK_H * 0.6),
(Math.random() - 0.5) * (TANK_D * 0.5)
);
this.velocity = new THREE.Vector3(
(Math.random() - 0.5) * 0.02,
(Math.random() - 0.5) * 0.01,
(Math.random() - 0.5) * 0.02
);
this.acceleration = new THREE.Vector3();
this.index = index;
this.tailPhase = Math.random() * Math.PI * 2;
this.maxSpeed = 0.02 + Math.random() * 0.005; // 降低速度
this.tailSpeed = 3 + Math.random() * 2;
scene.add(this.mesh);
}
applyForce(force) {
this.acceleration.add(force);
}
// Boids Plus 规则
flock(boids) {
const perceptionRadius = 0.8;
const separationRadius = 0.5;
let separation = new THREE.Vector3();
let alignment = new THREE.Vector3();
let cohesion = new THREE.Vector3();
let sepCount = 0, alignCount = 0, cohCount = 0;
boids.forEach(boid => {
if (boid === this) return;
const dist = this.mesh.position.distanceTo(boid.mesh.position);
if (dist < perceptionRadius) {
// 分离
if (dist < separationRadius) {
const diff = new THREE.Vector3().subVectors(this.mesh.position, boid.mesh.position);
diff.normalize().divideScalar(dist);
separation.add(diff);
sepCount++;
}
// 对齐
alignment.add(boid.velocity);
alignCount++;
// 凝聚
cohesion.add(boid.mesh.position);
cohCount++;
}
});
if (sepCount > 0) separation.divideScalar(sepCount);
if (alignCount > 0) {
alignment.divideScalar(alignCount).normalize().multiplyScalar(this.maxSpeed);
alignment.sub(this.velocity);
}
if (cohCount > 0) {
cohesion.divideScalar(cohCount);
cohesion.sub(this.mesh.position).normalize().multiplyScalar(this.maxSpeed);
cohesion.sub(this.velocity);
}
// separation.multiplyScalar(2.5);
// alignment.multiplyScalar(1.0);
// cohesion.multiplyScalar(0.8);
separation.multiplyScalar(20); // 分离力加倍
alignment.multiplyScalar(0.01); // 对齐力减半
cohesion.multiplyScalar(0.1); // 凝聚力大幅降低
this.applyForce(separation);
this.applyForce(alignment);
this.applyForce(cohesion);
}
// 边界避让
avoidBounds() {
const margin = 0.3;
const turnForce = 0.003;
const halfW = TANK_W / 2 - margin;
const halfD = TANK_D / 2 - margin;
const minY = TANK_Y + 0.2;
const maxY = TANK_Y + TANK_H - 0.3;
const pos = this.mesh.position;
const force = new THREE.Vector3();
if (pos.x > halfW) force.x -= turnForce * (pos.x - halfW) * 5;
if (pos.x < -halfW) force.x += turnForce * (-halfW - pos.x) * 5;
if (pos.y > maxY) force.y -= turnForce * (pos.y - maxY) * 5;
if (pos.y < minY) force.y += turnForce * (minY - pos.y) * 5;
if (pos.z > halfD) force.z -= turnForce * (pos.z - halfD) * 5;
if (pos.z < -halfD) force.z += turnForce * (-halfD - pos.z) * 5;
this.applyForce(force);
}
// 水草避让
avoidPlants() {
const force = new THREE.Vector3();
seaweeds.forEach(sw => {
sw.userData.leaves.forEach(leaf => {
const dist = this.mesh.position.distanceTo(leaf.position);
if (dist < 0.2) {
const dir = new THREE.Vector3().subVectors(this.mesh.position, leaf.position);
dir.normalize().divideScalar(dist);
force.add(dir.multiplyScalar(0.005));
}
});
});
this.applyForce(force);
}
update() {
this.velocity.add(this.acceleration);
this.velocity.clampLength(0.005, this.maxSpeed);
this.mesh.position.add(this.velocity);
// 朝向速度方向
if (this.velocity.length() > 0.001) {
const lookTarget = this.mesh.position.clone().add(this.velocity);
this.mesh.lookAt(lookTarget);
}
// 尾巴摆动
const tail = this.mesh.getObjectByName('tail');
const backFin = this.mesh.getObjectByName('backFin');
if (tail) {
this.tailPhase += this.tailSpeed * 0.05;
tail.rotation.y = Math.sin(this.tailPhase) * 0.4;
}
if (backFin) {
backFin.rotation.z = Math.sin(this.tailPhase * 0.8) * 0.15;
}
this.acceleration.set(0, 0, 0);
}
}
// 初始化鱼群
const fishSchool = [];
for (let i = 0; i < 10; i++) {
fishSchool.push(new BoidFish(i));
}
// ==================== 气泡 ====================
const bubbles = [];
const bubbleGeo = new THREE.SphereGeometry(0.02, 6, 4);
const bubbleMat = new THREE.MeshPhysicalMaterial({
color: 0xffffff, transparent: true, opacity: 0.5, roughness: 0, metalness: 0.1
});
for (let i = 0; i < 15; i++) {
const bubble = new THREE.Mesh(bubbleGeo, bubbleMat.clone());
bubble.position.set(
(Math.random() - 0.5) * TANK_W * 0.8,
TANK_Y + Math.random() * TANK_H,
(Math.random() - 0.5) * TANK_D * 0.8
);
bubble.userData.speed = 0.005 + Math.random() * 0.01;
bubble.userData.phase = Math.random() * Math.PI * 2;
scene.add(bubble);
bubbles.push(bubble);
}
// ==================== 水面波浪 ====================
function updateWater(time) {
const positions = waterGeo.attributes.position;
for (let i = 0; i < positions.count; i++) {
const x = positions.getX(i);
const y = positions.getY(i);
const waveZ = Math.sin(x * 3 + time * 2) * 0.015 +
Math.cos(y * 2.5 + time * 1.5) * 0.01 +
Math.sin((x + y) * 2 + time * 3) * 0.008;
positions.setZ(i, waveZ);
}
positions.needsUpdate = true;
}
// ==================== 鼠标交互 ====================
let mouseX = 0, mouseY = 0;
let targetRotX = 0, targetRotY = 0;
document.addEventListener('mousemove', (e) => {
mouseX = (e.clientX / innerWidth - 0.5) * 2;
mouseY = (e.clientY / innerHeight - 0.5) * 2;
});
document.addEventListener('touchmove', (e) => {
e.preventDefault();
mouseX = (e.touches[0].clientX / innerWidth - 0.5) * 2;
mouseY = (e.touches[0].clientY / innerHeight - 0.5) * 2;
}, { passive: false });
// ==================== 动画循环 ====================
const clock = new THREE.Clock();
let frameCount = 0;
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
frameCount++;
// 平滑相机跟随鼠标
targetRotY += (mouseX * 0.5 - targetRotY) * 0.03;
targetRotX += (mouseY * 0.3 - targetRotX) * 0.03;
camera.position.x = Math.sin(targetRotY) * 8;
camera.position.z = Math.cos(targetRotY) * 8;
camera.position.y = 3 + targetRotX * 2;
camera.lookAt(0, TANK_H * 0.4 + TANK_Y, 0);
// Boids 更新
fishSchool.forEach(fish => {
fish.flock(fishSchool);
fish.avoidBounds();
fish.avoidPlants();
fish.update();
});
// 水草更新
updateSeaweeds();
// 气泡更新
bubbles.forEach(b => {
b.userData.phase += 0.02;
b.position.y += b.userData.speed;
b.position.x += Math.sin(b.userData.phase) * 0.003;
b.position.z += Math.cos(b.userData.phase * 0.7) * 0.002;
if (b.position.y > TANK_Y + TANK_H - 0.2) {
b.position.y = TANK_Y + 0.1;
b.position.x = (Math.random() - 0.5) * TANK_W * 0.6;
b.position.z = (Math.random() - 0.5) * TANK_D * 0.6;
}
b.material.opacity = 0.3 + Math.sin(b.userData.phase) * 0.2;
});
// 水面
updateWater(time);
// 灯光微动
waterLight.intensity = 1.5 + Math.sin(time * 2) * 0.3;
renderer.render(scene, camera);
}
animate();
// ==================== 响应式 ====================
window.addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
})();
</script>
</body>
</html>
index.html
index.html