# 大象牙膏化学实验 3D 演示(Three.js)
请使用 Three.js 实现一个逼真的“大象牙膏”化学实验 3D 演示。所有代码(HTML、CSS、JavaScript)必须封装在一个独立的 HTML 文件中。
## 场景设置
- **平面**:创建一个尺寸为 `1000x1000` 的灰色、光滑水平平面,能够接收阴影。
## 三角烧瓶
### 形状
- 在平面中心放置一个透明的玻璃三角烧瓶。
- 烧瓶轮廓要求清晰:圆柱形颈部、逐渐变宽的圆锥形瓶身、扁平底部。
- **禁止**使用简单的圆锥体代替。
### 材质
- 高度透明的玻璃材质,具备以下属性:
- 高透射率、低粗糙度、正确折射率
- 参数参考:`color: 0xffffff`、`transparent: true`、`opacity: 0.9`、`roughness: 0.95`、`metalness: 0.35`、`clearcoat: 1.0`、`clearcoatRoughness: 0.03`、`transmission: 0.95`、`ior: 1.5`、`side: THREE.DoubleSide`
- 应能看到背景和透过液体的光线折射效果。
### 液体
- 烧瓶内预装约 **1/3 高度** 的荧光粉色液体。
- 液体表面平整,与烧瓶内壁贴合。
- 液体建模应参考烧瓶的锥形部分,不得溢出烧瓶。
### 朝向
- 三角烧瓶整体模型方向向上。
## “大象牙膏”喷发效果模拟
### 触发方式
- 页面底部(footer 区域)提供一个按钮,点击后开始喷发。
### 泡沫形态与质感
- 泡沫由大量微小、半透明、能够相互融合的粒子组成,模拟真实泡沫的稠密、蓬松质感。
- 避免看起来像孤立的小球。
- 颜色为荧光粉色,带有一定亮度变化以增加层次感。
- 可使用简单的噪点纹理或程序化方式为泡沫粒子增加表面细节,使其更像多孔泡沫。
### 喷射动态与流体模拟
- **初期喷发**:泡沫从烧瓶口猛烈向上喷出,形成持续上升的圆柱(紧密堆积的泡沫柱)。高度至少为三角烧瓶的 3 倍,泡沫柱接近最大高度时才开始分散。初始喷射速度和压力较大。
- **压力衰减**:喷射强度(速度和产生泡沫的速率)随时间逐渐减弱,导致泡沫柱高度和喷射距离减小,模拟化学反应物耗尽过程。
- **粒子运动**:
- 模拟基本流体动力学影响,而非简单抛物线。
- 粒子间应有轻微排斥力以模拟泡沫膨胀感。
- 喷射主流周围存在随机的速度扰动。
- 整体受重力影响。
### 泡沫与环境交互及变形
- **重力与堆积**:喷出的泡沫粒子受重力下落。接触平面或烧瓶外壁时,模拟受压变形效果(Y 轴压扁,X/Z 轴略微扩展)。
- **堆积形态**:落在平面和烧瓶上的泡沫逐渐堆积,形成一定厚度的覆盖层,不消失。堆积的泡沫同样具有变形和融合效果。
- **泡沫固定**:泡沫落到物体表面后,略微滑行一段距离便停止移动。
### 液体减少
- 随着喷发,烧瓶内液体液面逐渐下降,演示液体减少效果。
- 注意:不是液体整体缩小,而是液体顶部表面逐渐下降。
## 光照与渲染
### 光源
- 一个主光源,产生清晰阴影。
- 一个环境光,提亮场景暗部,避免纯黑区域。
### 阴影
- 启用渲染器的阴影贴图。
- 平面接收阴影,烧瓶和喷出的泡沫投射阴影。
### 反射与折射
- 烧瓶材质展示环境的微弱反射以及光线透过液体和玻璃的折射。
## 摄像机与控制
- 摄像机从 **斜 45 度角** 俯视场景中心(三角烧瓶所在位置)。
- 确保能清晰观察整个喷发过程和最终堆积效果。
液体喷射模拟与碰撞变形
用户需要一个使用 Three.js 实现的"大象牙膏"化学实验 3D 演示。这是一个复杂的交互式 3D 场景,需要在一个 HTML 文件中完成。
让我分析一下需求:
- 场景基础:
- 1000x1000 的灰色平面
- 阴影接收
- 三角烧瓶:
- 不能用简单圆锥,需要圆柱颈部 + 圆锥瓶身 + 平底
- 透明玻璃材质(具体参数已给出)
- 内部有 1/3 高度的荧光粉色液体
- 方向向上
- 喷发效果:
- 底部按钮触发
- 泡沫粒子系统:
- 微小、半透明、融合感
- 荧光粉色,有亮度变化
- 初期猛烈向上喷发(高度为烧瓶 3 倍)
- 压力衰减(速度和产生速率随时间降低)
- 粒子间轻微排斥力
- 随机速度扰动
- 重力影响
- 环境交互:
- 接触平面或烧瓶时变形(Y 轴压扁,X/Z 扩展)
- 堆积效果(不消失,形成覆盖层)
- 略微滑行后停止
- 液体减少(液面下降)
- 光照与渲染:
- 主光源(阴影)
- 环境光
- 启用阴影贴图
- 反射折射效果
- 摄像机:
- 斜 45 度角俯视
技术实现考虑:
- 使用 Three.js 的模块化导入
- 烧瓶几何体:可以用 LatheGeometry(车削几何体)来创建轮廓旋转体,这样可以精确控制形状(圆柱颈部 + 圆锥过渡 + 平底)
- 液体:同样用 LatheGeometry,但高度较低
- 泡沫粒子:
- 使用 InstancedMesh 高性能渲染大量粒子
- 每个粒子是一个球体,但用半透明材质
- 需要物理模拟:速度、重力、碰撞检测
- 变形效果可以通过缩放矩阵实现
- 堆积可以通过检测粒子与平面/烧瓶的碰撞,然后改变状态为"静态"
- 液体减少:通过缩放液体的 Y 轴或调整几何体高度
实现步骤:
- 搭建 HTML 结构和基础 Three.js 场景
- 创建平面
- 创建烧瓶(LatheGeometry)
- 创建液体(LatheGeometry,内部)
- 设置光照
- 设置相机(45 度俯视)
- 创建泡沫粒子系统类
- 位置、速度、状态(飞行中/已着陆)
- 更新逻辑:运动、碰撞检测、变形
- 喷发逻辑:
- 按钮触发
- 随时间衰减的喷射力
- 液体高度减少
- 渲染循环
关键难点:
- 烧瓶形状:需要仔细设计 LatheGeometry 的点集
- 泡沫融合感:通过大量半透明粒子叠加实现
- 碰撞检测:烧瓶是复杂形状,可以做简化检测(圆柱体近似或距离检测)
- 性能:粒子数量可能很大,需要用 InstancedMesh
让我开始编写代码:
烧瓶轮廓点设计:
- 从底部中心开始 (0,0)
- 底部半径 40,向上到高度 20(平底部分)
- 然后向外倾斜到半径 80,高度 100(圆锥部分)
- 然后向上圆柱形颈部,半径 15,高度 140(颈部)
- 最后顶部开口
液体:
- 在烧瓶内部,高度约为烧瓶的 1/3(约高度 47 左右)
- 半径根据烧瓶形状对应
泡沫粒子:
- 使用 InstancedMesh 渲染小球体
- 数量:2000-3000 个
- 材质:半透明,自发光(emissive)做荧光效果
- 物理:
- 发射位置:烧瓶口
- 初始速度:向上 + 随机扩散
- 喷射力随时间衰减
- 重力加速度
- 碰撞:
- 与平面:Y=0,碰撞后变形并停止
- 与烧瓶:距离检测,如果靠近烧瓶外壁,则沿法线滑下或停止
- 变形:着陆后 scale.y 减小,scale.x/z 增加
液体减少:
- 根据已发射的粒子数量或时间,线性减少液体 mesh 的 Y 轴缩放或顶点位置
代码结构:
- 使用 import map 导入 three.js 模块
- 类或函数组织
- 窗口大小调整处理
让我写具体的实现代码。
HTML
Preview
Copy
<!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>
body {
margin: 0;
overflow: hidden;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #1a1a1a;
}
#canvas-container {
width: 100vw;
height: 100vh;
position: relative;
}
#controls {
position: absolute;
bottom: 40px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
}
button {
padding: 15px 40px;
font-size: 18px;
background: linear-gradient(135deg, #ff006e 0%, #ff4d9e 100%);
color: white;
border: none;
border-radius: 50px;
cursor: pointer;
box-shadow: 0 4px 20px rgba(255, 0, 110, 0.4);
transition: all 0.3s ease;
font-weight: bold;
letter-spacing: 1px;
}
button:hover {
transform: translateX(-50%) translateY(-2px);
box-shadow: 0 6px 30px rgba(255, 0, 110, 0.6);
}
button:active {
transform: translateX(-50%) translateY(0);
}
button:disabled {
background: #666;
cursor: not-allowed;
box-shadow: none;
}
#info {
position: absolute;
top: 20px;
left: 20px;
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
pointer-events: none;
}
</style>
</head>
<body>
<div id="canvas-container"></div>
<div id="info">
<div>大象牙膏实验 (Elephant Toothpaste)</div>
<div>Three.js 物理模拟</div>
</div>
<div id="controls">
<button id="eruptBtn" onclick="startEruption()">开始喷发</button>
</div>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/[email protected]/build/three.module.js",
"three/addons/": "https://unpkg.com/[email protected]/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// 场景设置
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x222222);
scene.fog = new THREE.Fog(0x222222, 500, 2500);
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 5000);
camera.position.set(400, 400, 400);
camera.lookAt(0, 50, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
document.getElementById('canvas-container').appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.target.set(0, 50, 0);
controls.maxPolarAngle = Math.PI / 2 - 0.1;
// 光照
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
dirLight.position.set(200, 500, 200);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 2048;
dirLight.shadow.mapSize.height = 2048;
dirLight.shadow.camera.near = 0.5;
dirLight.shadow.camera.far = 2000;
dirLight.shadow.camera.left = -500;
dirLight.shadow.camera.right = 500;
dirLight.shadow.camera.top = 500;
dirLight.shadow.camera.bottom = -500;
dirLight.shadow.bias = -0.001;
scene.add(dirLight);
const spotLight = new THREE.SpotLight(0xff69b4, 2);
spotLight.position.set(-200, 300, -200);
spotLight.angle = Math.PI / 4;
spotLight.penumbra = 0.5;
scene.add(spotLight);
// 平面
const planeGeometry = new THREE.PlaneGeometry(1000, 1000);
const planeMaterial = new THREE.MeshStandardMaterial({
color: 0x444444,
roughness: 0.2,
metalness: 0.1
});
const plane = new THREE.Mesh(planeGeometry, planeMaterial);
plane.rotation.x = -Math.PI / 2;
plane.receiveShadow = true;
scene.add(plane);
// 创建烧瓶轮廓(车削几何体)
function createFlaskProfile() {
const points = [];
// 底部中心
points.push(new THREE.Vector2(0, 0));
// 底部平坦部分
points.push(new THREE.Vector2(40, 0));
points.push(new THREE.Vector2(40, 10));
// 圆锥部分(逐渐变宽)
for (let i = 0; i <= 10; i++) {
const t = i / 10;
const x = 40 + (60 - 40) * t;
const y = 10 + 90 * t;
points.push(new THREE.Vector2(x, y));
}
// 肩部过渡
points.push(new THREE.Vector2(62, 105));
points.push(new THREE.Vector2(55, 110));
// 圆柱颈部
points.push(new THREE.Vector2(20, 115));
points.push(new THREE.Vector2(20, 180));
// 瓶口外沿
points.push(new THREE.Vector2(25, 185));
points.push(new THREE.Vector2(25, 190));
// 瓶口内沿(厚度)
points.push(new THREE.Vector2(18, 190));
points.push(new THREE.Vector2(18, 115));
// 内部圆锥
for (let i = 10; i >= 0; i--) {
const t = i / 10;
const x = 37 + (55 - 37) * t;
const y = 10 + 90 * t;
points.push(new THREE.Vector2(x, y));
}
// 内部底部
points.push(new THREE.Vector2(37, 10));
points.push(new THREE.Vector2(0, 10));
return points;
}
// 烧瓶玻璃材质
const glassMaterial = new THREE.MeshPhysicalMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.3,
roughness: 0.05,
metalness: 0.35,
clearcoat: 1.0,
clearcoatRoughness: 0.03,
transmission: 0.95,
ior: 1.5,
side: THREE.DoubleSide,
thickness: 2.0,
envMapIntensity: 1.0
});
const flaskGeometry = new THREE.LatheGeometry(createFlaskProfile(), 64);
const flask = new THREE.Mesh(flaskGeometry, glassMaterial);
flask.position.y = 0;
flask.castShadow = true;
flask.receiveShadow = true;
scene.add(flask);
// 液体
function createLiquidProfile(level) {
const points = [];
const maxHeight = 60; // 液体高度约为烧瓶的1/3
if (level <= 0) return points;
const actualHeight = Math.min(level, maxHeight);
// 从中心开始
points.push(new THREE.Vector2(0, 10));
// 底部
points.push(new THREE.Vector2(37, 10));
// 根据高度计算侧面
for (let i = 0; i <= 10; i++) {
const t = i / 10;
const y = 10 + actualHeight * t;
let x;
if (y < 100) {
x = 37 + (55 - 37) * ((y - 10) / 90);
} else {
x = 55 + (18 - 55) * ((y - 100) / 15);
}
points.push(new THREE.Vector2(x - 2, y)); // 稍微缩进一点,与玻璃有间隙
}
// 顶部表面
points.push(new THREE.Vector2(0, 10 + actualHeight));
return points;
}
let liquidLevel = 60; // 初始液位
let liquidGeometry = new THREE.LatheGeometry(createLiquidProfile(liquidLevel), 64);
const liquidMaterial = new THREE.MeshPhysicalMaterial({
color: 0xff1493,
emissive: 0xff006e,
emissiveIntensity: 0.3,
transparent: true,
opacity: 0.8,
roughness: 0.1,
metalness: 0.1,
transmission: 0.4,
ior: 1.33,
side: THREE.DoubleSide
});
const liquid = new THREE.Mesh(liquidGeometry, liquidMaterial);
scene.add(liquid);
// 泡沫粒子系统
class FoamParticleSystem {
constructor(maxParticles = 3000) {
this.maxParticles = maxParticles;
this.particles = [];
this.activeCount = 0;
// 几何体 - 使用二十面体增加细节
const geometry = new THREE.IcosahedronGeometry(1, 1);
// 材质 - 荧光粉色,半透明
this.material = new THREE.MeshPhysicalMaterial({
color: 0xff69b4,
emissive: 0xff1493,
emissiveIntensity: 0.5,
transparent: true,
opacity: 0.6,
roughness: 0.8,
metalness: 0.1,
clearcoat: 0.3,
transmission: 0.2
});
// 使用 InstancedMesh 提高性能
this.mesh = new THREE.InstancedMesh(geometry, this.material, maxParticles);
this.mesh.castShadow = true;
this.mesh.receiveShadow = true;
this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
scene.add(this.mesh);
// 初始化粒子数据
for (let i = 0; i < maxParticles; i++) {
this.particles.push({
position: new THREE.Vector3(),
velocity: new THREE.Vector3(),
scale: new THREE.Vector3(1, 1, 1),
active: false,
landed: false,
life: 0,
maxLife: 0,
deformation: 0
});
}
this.dummy = new THREE.Object3D();
}
spawnParticle(intensity) {
if (this.activeCount >= this.maxParticles) return;
const particle = this.particles[this.activeCount];
particle.active = true;
particle.landed = false;
particle.life = 0;
particle.maxLife = 300 + Math.random() * 200;
particle.deformation = 0;
// 从瓶口发射
const angle = Math.random() * Math.PI * 2;
const radius = Math.random() * 8;
particle.position.set(
Math.cos(angle) * radius,
185,
Math.sin(angle) * radius
);
// 初始速度 - 猛烈向上喷发
const speed = (15 + Math.random() * 10) * intensity;
const spread = 0.3 * intensity;
particle.velocity.set(
(Math.random() - 0.5) * spread * speed,
speed,
(Math.random() - 0.5) * spread * speed
);
// 随机大小
const s = 3 + Math.random() * 4;
particle.scale.set(s, s, s);
this.activeCount++;
}
update(gravity, flaskRadius, flaskHeight) {
let activeIndex = 0;
for (let i = 0; i < this.activeCount; i++) {
const p = this.particles[i];
if (!p.active) continue;
if (!p.landed) {
// 物理更新
p.velocity.y -= gravity;
// 空气阻力
p.velocity.multiplyScalar(0.98);
// 粒子间轻微排斥(简化为随机扰动)
p.velocity.x += (Math.random() - 0.5) * 0.1;
p.velocity.z += (Math.random() - 0.5) * 0.1;
// 更新位置
p.position.add(p.velocity);
// 碰撞检测 - 地面
if (p.position.y <= p.scale.y) {
p.position.y = p.scale.y;
this.landParticle(p);
}
// 碰撞检测 - 烧瓶外壁(简化圆柱体检测)
const distFromCenter = Math.sqrt(p.position.x ** 2 + p.position.z ** 2);
if (p.position.y < flaskHeight && distFromCenter < flaskRadius + p.scale.x) {
// 沿着法线方向滑下或停止
const angle = Math.atan2(p.position.z, p.position.x);
p.position.x = Math.cos(angle) * (flaskRadius + p.scale.x);
p.position.z = Math.sin(angle) * (flaskRadius + p.scale.x);
if (p.velocity.y < 0) {
this.landParticle(p);
}
}
// 生命值检查
p.life++;
if (p.life > p.maxLife) {
p.active = false;
continue;
}
} else {
// 已着陆粒子的轻微滑动
if (p.deformation < 1) {
p.deformation += 0.05;
// 变形:Y轴压扁,X/Z扩展
const flattenFactor = 0.3 + p.deformation * 0.4;
const spreadFactor = 1 + p.deformation * 0.3;
p.scale.y = p.scale.x * flattenFactor;
p.scale.x = p.scale.x * spreadFactor;
p.scale.z = p.scale.z * spreadFactor;
}
}
// 更新实例矩阵
this.dummy.position.copy(p.position);
this.dummy.scale.copy(p.scale);
this.dummy.rotation.set(
Math.random() * Math.PI,
Math.random() * Math.PI,
Math.random() * Math.PI
);
this.dummy.updateMatrix();
this.mesh.setMatrixAt(activeIndex, this.dummy.matrix);
// 更新颜色变化(亮度微调)
const brightness = 0.5 + Math.sin(p.life * 0.05) * 0.2;
this.material.emissiveIntensity = brightness;
activeIndex++;
}
this.mesh.count = activeIndex;
this.mesh.instanceMatrix.needsUpdate = true;
// 清理不活跃粒子(从尾部移除)
while (this.activeCount > 0 && !this.particles[this.activeCount - 1].active) {
this.activeCount--;
}
}
landParticle(p) {
p.landed = true;
p.velocity.set(0, 0, 0);
// 初始变形
p.scale.y = p.scale.x * 0.5;
const spread = 1.4;
p.scale.x *= spread;
p.scale.z *= spread;
}
reset() {
for (let i = 0; i < this.maxParticles; i++) {
this.particles[i].active = false;
}
this.activeCount = 0;
this.mesh.count = 0;
}
}
const foamSystem = new FoamParticleSystem(4000);
// 喷发控制
let isErupting = false;
let eruptionTime = 0;
const eruptionDuration = 300; // 帧数
let spawnRate = 10;
let intensity = 1.0;
window.startEruption = function() {
if (isErupting) return;
isErupting = true;
eruptionTime = 0;
spawnRate = 15;
intensity = 1.0;
document.getElementById('eruptBtn').disabled = true;
document.getElementById('eruptBtn').textContent = '喷发中...';
// 重置液体
liquidLevel = 60;
updateLiquid();
};
function updateLiquid() {
// 更新液体几何体
liquid.geometry.dispose();
liquid.geometry = new THREE.LatheGeometry(createLiquidProfile(liquidLevel), 64);
}
// 动画循环
const clock = new THREE.Clock();
const gravity = 0.15;
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
if (isErupting) {
eruptionTime++;
// 压力衰减
const progress = eruptionTime / eruptionDuration;
intensity = Math.max(0.1, 1.0 - progress * 0.9);
spawnRate = Math.max(1, 15 * intensity);
// 生成粒子
const spawnCount = Math.floor(spawnRate);
for (let i = 0; i < spawnCount; i++) {
if (Math.random() < intensity) {
foamSystem.spawnParticle(intensity);
}
}
// 液体减少
if (liquidLevel > 5 && eruptionTime % 5 === 0) {
liquidLevel -= 0.3;
updateLiquid();
}
// 结束喷发
if (eruptionTime >= eruptionDuration && foamSystem.activeCount === 0) {
isErupting = false;
document.getElementById('eruptBtn').disabled = false;
document.getElementById('eruptBtn').textContent = '再次喷发';
}
}
// 更新泡沫
foamSystem.update(gravity, 65, 180);
controls.update();
renderer.render(scene, camera);
}
// 窗口调整
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
animate();
</script>
</body>
</html>