1. 项目概述
在游戏开发中,粒子系统是实现各种视觉效果的重要工具。传统的粒子系统通常使用预设的颜色或简单的渐变效果,但如果我们想让粒子呈现出更丰富、更动态的色彩变化,就需要更高级的技术方案。本文将介绍一种基于Shader的粒子特效实现方法,它能够根据输入图片的颜色动态生成粒子效果,实现类似"色彩爆炸"的视觉效果。
这个方案的核心思路是:
- 使用自定义Shader控制每个粒子的颜色
- 通过脚本从图片中采样颜色数据
- 将采样到的颜色传递给粒子系统
这种技术可以应用于各种场景,比如角色技能特效、UI交互反馈、场景过渡效果等。相比传统粒子系统,它能提供更丰富的视觉表现力,让特效与游戏内容产生更紧密的关联。
2. 技术实现原理
2.1 Shader基础结构
我们首先需要创建一个自定义Shader,它负责控制粒子的渲染方式。这个Shader的基本结构如下:
glsl复制CCEffect %{
techniques:
- name: glow
passes:
- vert: vs:vert
frag: fs:frag
blendState:
targets:
- blend: true
blendSrc: src_alpha
blendDst: one_minus_src_alpha
depthStencilState:
depthTest: false
depthWrite: false
rasterizerState:
cullMode: none
properties:
lightColor: { value: [1.0, 0.0, 0.0, 1.0], editor: { type: color } }
lightIntensity: { value: 3.0, editor: { slide: true, range: [1, 10] } }
glowsize: { value: 0.0, editor: { slide: true, range: [0, 10] } }
pulseSpeed: { value: 2.0, editor: { slide: true, range: [0, 17] } }
isBlinking: { value: 0, editor: { type: boolean } }
}%
这个Shader定义了一个technique,包含一个pass,设置了混合模式、深度测试等渲染状态,并声明了一些可调节的参数。
2.2 顶点着色器
顶点着色器的主要任务是处理顶点位置和传递数据到片元着色器:
glsl复制CCProgram vs %{
precision highp float;
#include <cc-global>
#if USE_LOCAL
#include <builtin/uniforms/cc-local>
#endif
in vec3 a_position;
in vec2 a_texCoord;
in vec4 a_color; // 来自脚本的每粒子颜色
out vec2 uv;
out vec4 color; // 传递给片元的颜色
vec4 vert() {
gl_Position = cc_matViewProj * vec4(a_position, 1.0);
uv = a_texCoord;
color = a_color; // 将顶点颜色传递给片元
return gl_Position; // 必须返回 gl_Position
}
}%
关键点在于接收并传递粒子颜色数据。这里我们直接从顶点属性a_color获取颜色值,然后通过varying变量color传递给片元着色器。
2.3 片元着色器
片元着色器相对简单,主要作用是输出最终颜色:
glsl复制CCProgram fs %{
precision highp float;
#include <sprite-texture>
#include <cc-global>
in vec2 uv;
in vec4 color; // 接收顶点传来的颜色
uniform sampler2D mainTexture;
uniform UBO {
vec4 lightColor;
float lightIntensity;
float pulseSpeed;
float time;
float isBlinking;
float glowsize;
};
vec4 frag() {
vec4 texColor = texture(cc_spriteTexture, uv);
// 最终颜色 = 顶点颜色 * 纹理颜色(或直接用顶点颜色)
// 这里保留 alpha 混合
return color;
}
}%
在这个实现中,我们直接使用从顶点着色器传递来的颜色值作为最终输出,忽略了纹理采样结果。这样做的目的是让粒子完全由脚本控制的颜色来决定外观。
3. 粒子颜色控制脚本
3.1 脚本结构与初始化
颜色控制脚本(l.ts)负责从图片中采样颜色并应用到粒子上:
typescript复制import { _decorator, Color, Component, ImageAsset, Node, ParticleSystem2D, Sprite, SpriteFrame, Texture2D, UITransform } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('l')
export class l extends Component {
@property(ParticleSystem2D)
particle: ParticleSystem2D;
@property(Node)
colorSourceNode: Node = null;
@property(ImageAsset)
private colorSourceImage: ImageAsset = null;
private pixelData: Uint8Array = null;
private texWidth = 0;
private texHeight = 0;
private uvRect: { x: number, y: number, w: number, h: number } | null = null;
private ready = false;
private fallbackColors: Color[] = [ /* ... */ ];
width:number=0;
height:number=0;
start() {
console.log('=== l start ===');
(this.particle as any).custom = true;
}
// ...
}
脚本中定义了必要的属性和变量,包括粒子系统引用、颜色源节点/图片、像素数据缓存等。
3.2 图片像素数据读取
核心功能之一是读取图片的像素数据:
typescript复制readTexturePixels() {
let imageAsset: ImageAsset = null;
this.uvRect = null;
if (this.colorSourceNode) {
const sprite = this.colorSourceNode.getComponent(Sprite);
const tran = this.colorSourceNode.getComponent(UITransform);
this.width = tran.width;
this.height = tran.height;
if (sprite && sprite.spriteFrame) {
const spriteFrame = sprite.spriteFrame;
const texture = spriteFrame.texture;
imageAsset = this.extractImageAssetFromTexture(texture as Texture2D);
if (imageAsset) {
const rect = spriteFrame.rect;
if (rect && rect.width > 0 && rect.height > 0) {
this.uvRect = { x: rect.x, y: rect.y, w: rect.width, h: rect.height };
}
}
}
}
if (!imageAsset && this.colorSourceImage) {
imageAsset = this.colorSourceImage;
}
if (!imageAsset) {
console.warn('未设置有效的颜色源,将使用后备颜色');
this.ready = false;
return;
}
const img = imageAsset.data as HTMLImageElement;
if (!img) {
console.warn('ImageAsset 数据无效');
this.ready = false;
return;
}
if (img.complete && img.naturalWidth > 0) {
this.processImage(img);
} else {
img.onload = () => this.processImage(img);
}
}
这个方法首先尝试从colorSourceNode获取图片数据,如果失败则尝试使用colorSourceImage。获取到图片后,会调用processImage处理图片数据。
3.3 图片处理与颜色采样
processImage方法将图片绘制到canvas上并提取像素数据:
typescript复制private processImage(img: HTMLImageElement) {
this.texWidth = img.width;
this.texHeight = img.height;
const canvas = document.createElement('canvas');
canvas.width = this.texWidth;
canvas.height = this.texHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, this.texWidth, this.texHeight);
this.pixelData = new Uint8Array(imageData.data);
this.ready = true;
console.log(`图片加载完成,尺寸:${this.texWidth}x${this.texHeight}`);
}
在lateUpdate中,我们会遍历所有活动的粒子,为每个粒子随机采样一个颜色:
typescript复制lateUpdate() {
const ps = this.particle as any;
const simulator = ps._simulator;
if (!simulator) return;
const particles = simulator.particles as any[];
if (!particles || particles.length === 0) return;
// 设置粒子发射区域大小
if (this.texWidth > 0 && this.texHeight > 0) {
if (this.uvRect) {
this.particle.posVar.x = this.width / 2;
this.particle.posVar.y = this.height / 2;
} else {
this.particle.posVar.x = this.texWidth / 2;
this.particle.posVar.y = this.texHeight / 2;
}
}
// 定义绝对安全的默认颜色(纯白色)
const DEFAULT_COLOR = { r: 255, g: 255, b: 255, a: 255 };
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
if (!p || p.timeToLive <= 0) continue;
let r: number, g: number, b: number, a: number;
if (this.ready && this.pixelData) {
// 从图片采样颜色
let px: number, py: number;
if (this.uvRect) {
const u = Math.random();
const v = Math.random();
px = Math.floor(this.uvRect.x + u * this.uvRect.w);
py = Math.floor(this.uvRect.y + v * this.uvRect.h);
} else {
px = Math.floor(Math.random() * this.texWidth);
py = Math.floor(Math.random() * this.texHeight);
}
const idx = (py * this.texWidth + px) * 4;
r = this.pixelData[idx];
g = this.pixelData[idx + 1];
b = this.pixelData[idx + 2];
a = this.pixelData[idx + 3];
} else {
// 使用后备颜色或默认颜色
const fallback = this.fallbackColors?.[i % this.fallbackColors.length];
if (fallback) {
r = fallback.r;
g = fallback.g;
b = fallback.b;
a = fallback.a;
} else {
r = DEFAULT_COLOR.r;
g = DEFAULT_COLOR.g;
b = DEFAULT_COLOR.b;
a = DEFAULT_COLOR.a;
}
}
if (p.color) {
p.color.set(r, g, b, a);
}
}
if (simulator.renderData) {
simulator.renderData.uvDirty = true;
if (simulator.renderData.dataHash !== undefined) {
simulator.renderData.dataHash = 0;
}
}
}
4. 粒子触发脚本
4.1 脚本结构与事件处理
触发脚本(zha.ts)负责在用户交互时生成粒子效果:
typescript复制import { _decorator, Component, EventTouch, ImageAsset, instantiate, Node, ParticleSystem2D, Prefab, Sprite, SpriteFrame, UITransform, Vec3 } from 'cc';
import { l } from './l';
const { ccclass, property } = _decorator;
@ccclass('zha')
export class zha extends Component {
@property(Prefab)
particlePrefab: Prefab = null!;
@property
particleOffset: Vec3 = new Vec3(0, 0, 0);
@property
autoDestroy: boolean = true;
@property
destroyDelay: number = 2.0;
@property
respawnDelay: number = 1.5;
@property
disableTouchDuringEffect: boolean = true;
private isAnimating: boolean = false;
private originalScale: Vec3 = new Vec3(1, 1, 1);
@property(ImageAsset)
particleColorSource: ImageAsset = null!;
start() {
this.originalScale = this.node.scale.clone();
this.node.on(Node.EventType.TOUCH_START, this.onTouchStart, this);
this.node.on(Node.EventType.TOUCH_END, this.onTouchEnd, this);
this.node.on(Node.EventType.TOUCH_CANCEL, this.onTouchCancel, this);
}
onDestroy() {
this.node.off(Node.EventType.TOUCH_START, this.onTouchStart, this);
this.node.off(Node.EventType.TOUCH_END, this.onTouchEnd, this);
this.node.off(Node.EventType.TOUCH_CANCEL, this.onTouchCancel, this);
}
// ...
}
4.2 触摸事件处理
处理触摸事件,实现按下效果和触发粒子爆炸:
typescript复制onTouchStart(event: EventTouch) {
if (this.isAnimating && this.disableTouchDuringEffect) return;
this.node.scale = new Vec3(0.95, 0.95, 1);
}
onTouchEnd(event: EventTouch) {
if (this.isAnimating && this.disableTouchDuringEffect) return;
this.node.scale = this.originalScale.clone();
const touchPos = event.getUILocation();
const uiTransform = this.getComponent(UITransform);
if (!uiTransform) return;
const localPos = uiTransform.convertToNodeSpaceAR(new Vec3(touchPos.x, touchPos.y, 0));
this.startExplosionEffect(localPos);
}
onTouchCancel(event: EventTouch) {
this.node.scale = this.originalScale.clone();
}
4.3 粒子效果生成
核心的粒子生成逻辑:
typescript复制startExplosionEffect(position: Vec3) {
if (this.isAnimating) return;
this.isAnimating = true;
this.hideSprite();
this.spawnParticle(position);
this.scheduleOnce(() => {
this.showSprite();
this.isAnimating = false;
}, this.respawnDelay);
}
spawnParticle(position: Vec3) {
if (!this.particlePrefab) {
console.warn('请设置粒子预制体');
return;
}
const particleNode = instantiate(this.particlePrefab);
if (this.node.parent) {
this.node.parent.addChild(particleNode);
} else {
particleNode.parent = this.node;
}
const worldPos = this.node.getWorldPosition();
particleNode.setWorldPosition(worldPos);
const particleSystem = particleNode.getComponent(ParticleSystem2D);
const lScript = particleNode.getComponent(l) as any;
if (lScript) {
lScript.colorSourceNode = this.node;
if (lScript.readTexturePixels) {
lScript.readTexturePixels();
}
}
if (particleSystem) {
particleSystem.resetSystem();
if (this.autoDestroy) {
this.scheduleOnce(() => {
if (particleNode && particleNode.isValid) {
particleNode.destroy();
}
}, this.destroyDelay);
}
}
return particleNode;
}
5. 实际应用与优化建议
5.1 性能优化技巧
-
图片预处理:对于静态图片,可以预先提取像素数据并缓存,避免每帧都进行图片处理。
-
粒子数量控制:根据目标平台性能调整粒子数量,移动设备建议控制在100-200个粒子以内。
-
颜色采样优化:可以预先将图片缩小到合适尺寸再进行采样,减少像素处理量。
-
Shader优化:简化Shader计算,避免在片元着色器中进行复杂运算。
5.2 常见问题排查
-
粒子不显示颜色:
- 检查Shader是否正确编译
- 确认脚本是否正确设置了粒子颜色
- 验证图片数据是否成功加载
-
性能问题:
- 使用性能分析工具定位瓶颈
- 减少粒子数量或简化Shader
- 考虑使用对象池管理粒子实例
-
颜色采样不准确:
- 检查UV坐标计算是否正确
- 验证图片尺寸和粒子发射区域设置
- 确保图片成功加载且像素数据有效
5.3 扩展应用场景
-
角色技能特效:根据角色贴图生成独特的技能特效,增强视觉一致性。
-
UI交互反馈:为按钮点击等交互提供动态色彩反馈。
-
场景过渡效果:使用场景截图作为颜色源,实现独特的转场效果。
-
动态壁纸:结合实时渲染,创建响应式的背景效果。
在实际项目中,可以根据需求调整参数和效果,比如添加颜色偏移、亮度调节等后期处理,进一步增强视觉效果。