返回笔记首页

Web3D粒子特效系统

主题配置
html
<!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>