<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web3D粒子特效系统</title>
<script src="https://cdn.jsdelivr.net/npm/vue@3.3.4/dist/vue.global.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.156.1/build/three.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0a0a1e;
color: white;
overflow: hidden;
}
#app {
width: 100vw;
height: 100vh;
display: flex;
}
#canvas-container {
flex: 1;
position: relative;
}
.editor-panel {
width: 360px;
background: #1a1a2e;
border-left: 2px solid #2a2a4e;
display: flex;
flex-direction: column;
overflow: hidden;
}
.editor-header {
padding: 25px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border-bottom: 2px solid #8b5cf6;
}
.editor-header h1 {
font-size: 20px;
margin-bottom: 8px;
}
.editor-header p {
font-size: 13px;
opacity: 0.9;
}
.presets {
padding: 20px;
border-bottom: 2px solid #2a2a4e;
}
.presets h3 {
font-size: 14px;
margin-bottom: 12px;
color: #8b5cf6;
}
.preset-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.preset-btn {
padding: 12px;
background: rgba(99,102,241,0.1);
border: 2px solid rgba(99,102,241,0.3);
border-radius: 8px;
color: white;
font-size: 13px;
cursor: pointer;
transition: all 0.3s;
text-align: center;
}
.preset-btn:hover {
background: rgba(99,102,241,0.2);
border-color: #6366f1;
transform: translateY(-2px);
}
.preset-btn.active {
background: rgba(99,102,241,0.3);
border-color: #6366f1;
}
.controls {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.control-group {
margin-bottom: 25px;
}
.control-group h4 {
font-size: 14px;
margin-bottom: 15px;
color: #8b5cf6;
padding-bottom: 8px;
border-bottom: 1px solid #2a2a4e;
}
.control-item {
margin-bottom: 18px;
}
.control-label {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 13px;
color: rgba(255,255,255,0.8);
}
.control-value {
color: #8b5cf6;
font-weight: 600;
}
.slider {
width: 100%;
height: 6px;
border-radius: 3px;
background: #2a2a4e;
outline: none;
-webkit-appearance: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
cursor: pointer;
transition: all 0.3s;
}
.slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
box-shadow: 0 0 10px rgba(99,102,241,0.5);
}
.color-picker-wrapper {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.color-option {
aspect-ratio: 1;
border-radius: 6px;
cursor: pointer;
border: 3px solid transparent;
transition: all 0.3s;
}
.color-option:hover {
transform: scale(1.1);
}
.color-option.active {
border-color: white;
box-shadow: 0 0 10px rgba(255,255,255,0.5);
}
.action-buttons {
padding: 20px;
border-top: 2px solid #2a2a4e;
display: flex;
gap: 10px;
}
.btn {
flex: 1;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(99,102,241,0.4);
}
.btn-secondary {
background: #2a2a4e;
color: white;
}
.btn-secondary:hover {
background: #3a3a5e;
}
.stats {
position: absolute;
top: 20px;
left: 20px;
background: rgba(0,0,0,0.8);
padding: 15px;
border-radius: 8px;
font-size: 12px;
font-family: 'Courier New', monospace;
}
.stats div {
margin-bottom: 5px;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #1a1a2e;
}
::-webkit-scrollbar-thumb {
background: #6366f1;
border-radius: 3px;
}
</style>
</head>
<body>
<div id="app">
<div id="canvas-container">
<div class="stats">
<div>粒子数量: {{ particleCount }}</div>
<div>FPS: {{ fps }}</div>
<div>活跃粒子: {{ activeParticles }}</div>
</div>
</div>
<div class="editor-panel">
<div class="editor-header">
<h1>粒子特效编辑器</h1>
<p>实时调整参数预览效果</p>
</div>
<div class="presets">
<h3>预设特效</h3>
<div class="preset-grid">
<button
v-for="preset in presets"
:key="preset.name"
class="preset-btn"
:class="{ active: currentPreset === preset.name }"
@click="loadPreset(preset)"
>
{{ preset.name }}
</button>
</div>
</div>
<div class="controls">
<div class="control-group">
<h4>发射器设置</h4>
<div class="control-item">
<div class="control-label">
<span>粒子数量</span>
<span class="control-value">{{ config.particleCount }}</span>
</div>
<input
type="range"
class="slider"
v-model="config.particleCount"
min="100"
max="10000"
step="100"
@input="updateParticles"
>
</div>
<div class="control-item">
<div class="control-label">
<span>发射速率</span>
<span class="control-value">{{ config.emissionRate }}</span>
</div>
<input
type="range"
class="slider"
v-model="config.emissionRate"
min="10"
max="500"
step="10"
>
</div>
<div class="control-item">
<div class="control-label">
<span>生命周期</span>
<span class="control-value">{{ config.lifetime }}s</span>
</div>
<input
type="range"
class="slider"
v-model="config.lifetime"
min="0.5"
max="10"
step="0.5"
>
</div>
</div>
<div class="control-group">
<h4>粒子属性</h4>
<div class="control-item">
<div class="control-label">
<span>粒子大小</span>
<span class="control-value">{{ config.particleSize }}</span>
</div>
<input
type="range"
class="slider"
v-model="config.particleSize"
min="0.1"
max="5"
step="0.1"
>
</div>
<div class="control-item">
<div class="control-label">
<span>初始速度</span>
<span class="control-value">{{ config.velocity }}</span>
</div>
<input
type="range"
class="slider"
v-model="config.velocity"
min="0.1"
max="10"
step="0.1"
>
</div>
<div class="control-item">
<div class="control-label">
<span>重力强度</span>
<span class="control-value">{{ config.gravity }}</span>
</div>
<input
type="range"
class="slider"
v-model="config.gravity"
min="-5"
max="5"
step="0.1"
>
</div>
</div>
<div class="control-group">
<h4>外观设置</h4>
<div class="control-item">
<div class="control-label">
<span>粒子颜色</span>
</div>
<div class="color-picker-wrapper">
<div
v-for="color in colors"
:key="color"
class="color-option"
:class="{ active: config.color === color }"
:style="{ background: color }"
@click="config.color = color"
></div>
</div>
</div>
<div class="control-item">
<div class="control-label">
<span>透明度</span>
<span class="control-value">{{ config.opacity }}</span>
</div>
<input
type="range"
class="slider"
v-model="config.opacity"
min="0"
max="1"
step="0.1"
>
</div>
</div>
<div class="control-group">
<h4>高级选项</h4>
<div class="control-item">
<div class="control-label">
<span>扩散角度</span>
<span class="control-value">{{ config.spread }}°</span>
</div>
<input
type="range"
class="slider"
v-model="config.spread"
min="0"
max="360"
step="15"
>
</div>
<div class="control-item">
<div class="control-label">
<span>旋转速度</span>
<span class="control-value">{{ config.rotation }}</span>
</div>
<input
type="range"
class="slider"
v-model="config.rotation"
min="0"
max="10"
step="0.5"
>
</div>
</div>
</div>
<div class="action-buttons">
<button class="btn btn-primary" @click="toggleEmit">
{{ isEmitting ? '停止发射' : '开始发射' }}
</button>
<button class="btn btn-secondary" @click="clearParticles">
清空粒子
</button>
</div>
</div>
</div>
<script>
const { createApp } = Vue;
const { Scene, PerspectiveCamera, WebGLRenderer, Points, BufferGeometry,
PointsMaterial, BufferAttribute, Color, AdditiveBlending } = THREE;
createApp({
data() {
return {
scene: null,
camera: null,
renderer: null,
particleSystem: null,
animationId: null,
isEmitting: true,
currentPreset: '烟花',
config: {
particleCount: 3000,
emissionRate: 50,
lifetime: 3,
particleSize: 2,
velocity: 3,
gravity: -1,
color: '#ff6b9d',
opacity: 0.8,
spread: 360,
rotation: 2
},
colors: [
'#ff6b9d', '#c44569', '#f8b500', '#38ada9',
'#4a69bd', '#6c5ce7', '#a29bfe', '#fd79a8',
'#fdcb6e', '#00b894'
],
presets: [
{
name: '烟花',
config: {
particleCount: 3000,
emissionRate: 50,
lifetime: 3,
particleSize: 2,
velocity: 3,
gravity: -1,
color: '#ff6b9d',
opacity: 0.8,
spread: 360,
rotation: 2
}
},
{
name: '喷泉',
config: {
particleCount: 2000,
emissionRate: 100,
lifetime: 2,
particleSize: 1.5,
velocity: 5,
gravity: -2,
color: '#38ada9',
opacity: 0.9,
spread: 45,
rotation: 0
}
},
{
name: '飘雪',
config: {
particleCount: 1000,
emissionRate: 30,
lifetime: 5,
particleSize: 1,
velocity: 0.5,
gravity: -0.3,
color: '#ffffff',
opacity: 0.7,
spread: 180,
rotation: 1
}
},
{
name: '爆炸',
config: {
particleCount: 5000,
emissionRate: 200,
lifetime: 1.5,
particleSize: 2.5,
velocity: 8,
gravity: 0,
color: '#f8b500',
opacity: 1,
spread: 360,
rotation: 5
}
},
{
name: '星尘',
config: {
particleCount: 4000,
emissionRate: 80,
lifetime: 4,
particleSize: 1.2,
velocity: 2,
gravity: 0.2,
color: '#a29bfe',
opacity: 0.6,
spread: 180,
rotation: 3
}
},
{
name: '火焰',
config: {
particleCount: 1500,
emissionRate: 150,
lifetime: 1,
particleSize: 3,
velocity: 4,
gravity: 1,
color: '#fd79a8',
opacity: 0.8,
spread: 30,
rotation: 0
}
}
],
particles: [],
particleCount: 0,
activeParticles: 0,
fps: 0,
lastTime: performance.now(),
frames: 0
};
},
mounted() {
this.initScene();
this.createParticleSystem();
this.animate();
window.addEventListener('resize', this.onWindowResize);
},
beforeUnmount() {
window.removeEventListener('resize', this.onWindowResize);
if (this.animationId) {
cancelAnimationFrame(this.animationId);
}
this.dispose();
},
methods: {
initScene() {
const container = document.getElementById('canvas-container');
const width = container.clientWidth;
const height = container.clientHeight;
this.scene = new Scene();
this.camera = new PerspectiveCamera(75, width / height, 0.1, 1000);
this.camera.position.z = 50;
this.renderer = new WebGLRenderer({ antialias: true });
this.renderer.setSize(width, height);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(this.renderer.domElement);
},
createParticleSystem() {
const geometry = new BufferGeometry();
const positions = new Float32Array(this.config.particleCount * 3);
const colors = new Float32Array(this.config.particleCount * 3);
const sizes = new Float32Array(this.config.particleCount);
const alphas = new Float32Array(this.config.particleCount);
geometry.setAttribute('position', new BufferAttribute(positions, 3));
geometry.setAttribute('color', new BufferAttribute(colors, 3));
geometry.setAttribute('size', new BufferAttribute(sizes, 1));
geometry.setAttribute('alpha', new BufferAttribute(alphas, 1));
const material = new PointsMaterial({
size: this.config.particleSize,
vertexColors: true,
transparent: true,
opacity: this.config.opacity,
blending: AdditiveBlending,
depthWrite: false
});
if (this.particleSystem) {
this.scene.remove(this.particleSystem);
this.particleSystem.geometry.dispose();
this.particleSystem.material.dispose();
}
this.particleSystem = new Points(geometry, material);
this.scene.add(this.particleSystem);
// 初始化粒子数据
this.particles = [];
for (let i = 0; i < this.config.particleCount; i++) {
this.particles.push({
active: false,
life: 0,
maxLife: 0,
position: { x: 0, y: 0, z: 0 },
velocity: { x: 0, y: 0, z: 0 }
});
}
this.particleCount = this.config.particleCount;
},
emitParticle() {
for (let i = 0; i < this.particles.length; i++) {
if (!this.particles[i].active) {
const particle = this.particles[i];
particle.active = true;
particle.life = 0;
particle.maxLife = parseFloat(this.config.lifetime);
// 初始位置
particle.position.x = 0;
particle.position.y = -20;
particle.position.z = 0;
// 初始速度
const spread = parseFloat(this.config.spread) * Math.PI / 180;
const angle = (Math.random() - 0.5) * spread;
const elevation = (Math.random() - 0.5) * spread;
const velocity = parseFloat(this.config.velocity);
particle.velocity.x = Math.sin(angle) * velocity * Math.cos(elevation);
particle.velocity.y = Math.cos(angle) * velocity;
particle.velocity.z = Math.sin(elevation) * velocity;
break;
}
}
},
updateParticles() {
const positions = this.particleSystem.geometry.attributes.position.array;
const colors = this.particleSystem.geometry.attributes.color.array;
const sizes = this.particleSystem.geometry.attributes.size.array;
const alphas = this.particleSystem.geometry.attributes.alpha.array;
const color = new Color(this.config.color);
const gravity = parseFloat(this.config.gravity);
const rotation = parseFloat(this.config.rotation);
let active = 0;
for (let i = 0; i < this.particles.length; i++) {
const particle = this.particles[i];
if (particle.active) {
active++;
particle.life += 0.016;
// 应用重力
particle.velocity.y += gravity * 0.016;
// 更新位置
particle.position.x += particle.velocity.x * 0.016;
particle.position.y += particle.velocity.y * 0.016;
particle.position.z += particle.velocity.z * 0.016;
// 旋转效果
if (rotation > 0) {
const angle = particle.life * rotation;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const x = particle.position.x * cos - particle.position.z * sin;
const z = particle.position.x * sin + particle.position.z * cos;
particle.position.x = x;
particle.position.z = z;
}
// 生命周期
const lifeRatio = particle.life / particle.maxLife;
// 更新属性
positions[i * 3] = particle.position.x;
positions[i * 3 + 1] = particle.position.y;
positions[i * 3 + 2] = particle.position.z;
colors[i * 3] = color.r;
colors[i * 3 + 1] = color.g;
colors[i * 3 + 2] = color.b;
sizes[i] = parseFloat(this.config.particleSize) * (1 - lifeRatio * 0.5);
alphas[i] = parseFloat(this.config.opacity) * (1 - lifeRatio);
// 检查是否死亡
if (particle.life >= particle.maxLife) {
particle.active = false;
}
} else {
positions[i * 3] = 0;
positions[i * 3 + 1] = 0;
positions[i * 3 + 2] = 0;
sizes[i] = 0;
alphas[i] = 0;
}
}
this.activeParticles = active;
this.particleSystem.geometry.attributes.position.needsUpdate = true;
this.particleSystem.geometry.attributes.color.needsUpdate = true;
this.particleSystem.geometry.attributes.size.needsUpdate = true;
this.particleSystem.geometry.attributes.alpha.needsUpdate = true;
},
loadPreset(preset) {
this.currentPreset = preset.name;
Object.assign(this.config, preset.config);
this.createParticleSystem();
},
toggleEmit() {
this.isEmitting = !this.isEmitting;
},
clearParticles() {
this.particles.forEach(p => p.active = false);
},
updateStats() {
this.frames++;
const currentTime = performance.now();
if (currentTime >= this.lastTime + 1000) {
this.fps = Math.round((this.frames * 1000) / (currentTime - this.lastTime));
this.frames = 0;
this.lastTime = currentTime;
}
},
animate() {
this.animationId = requestAnimationFrame(this.animate);
// 发射新粒子
if (this.isEmitting) {
const emitCount = Math.ceil(parseFloat(this.config.emissionRate) / 60);
for (let i = 0; i < emitCount; i++) {
this.emitParticle();
}
}
// 更新粒子
this.updateParticles();
// 旋转场景
if (this.particleSystem) {
this.particleSystem.rotation.y += 0.002;
}
this.updateStats();
if (this.renderer && this.scene && this.camera) {
this.renderer.render(this.scene, this.camera);
}
},
onWindowResize() {
const container = document.getElementById('canvas-container');
if (!container) return;
const width = container.clientWidth;
const height = container.clientHeight;
if (this.camera) {
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
}
if (this.renderer) {
this.renderer.setSize(width, height);
}
},
dispose() {
if (this.particleSystem) {
this.particleSystem.geometry.dispose();
this.particleSystem.material.dispose();
}
if (this.renderer) {
this.renderer.dispose();
}
}
}
}).mount('#app');
</script>
</body>
</html>
返回笔记首页
Web3D粒子特效系统
主题配置