ลองดูเกมที่มีไฟ ควัน คาถาเวทมนตร์ หรือประกายไฟที่กระเด็นออกมาตอนดาบฟันโดน ทั้งหมดนั้นไม่ใช่ภาพเคลื่อนไหวชิ้นใหญ่ชิ้นเดียว แต่เป็นภาพเล็ก ๆ ง่าย ๆ หลายร้อยหลายพันชิ้นที่เรียกว่า particle แต่ละชิ้นทำตามกฎง่าย ๆ ไม่กี่ข้อ บทนี้จะพาไปดูให้ละเอียดว่ากลไกเหล่านั้นทำงานยังไง ตั้งแต่ loop ที่สร้างและขยับมัน คุณสมบัติที่แต่ละ particle พกติดตัว ไปจนถึง shader ที่ทำให้มันเรืองแสง ละลายหาย และค่อย ๆ จางไป
particle คือชิ้นส่วนเล็ก ๆ ของเอฟเฟกต์ภาพหนึ่งชิ้น ปกติจะเป็นสี่เหลี่ยมแบนที่ประกอบจากสามเหลี่ยมสองรูป (เรียกว่า quad) แปะภาพเล็ก ๆ ไว้บนนั้น (เรียกว่า sprite) และวาดให้หันหน้าเข้าหาบางอย่างอยู่เสมอ ปกติคือกล้อง (รายละเอียดอยู่ในหัวข้อที่ 4) particle system คือโค้ดที่สร้าง particle จำนวนมาก ขยับมันไปตามเวลา แล้ววาดมันออกมา ควัน ไฟ ฝน ประกายไฟ ออร่าเวทมนตร์ ทั้งหมดนี้คือ quad เล็ก ๆ หลายพันชิ้น ไม่ใช่ภาพเคลื่อนไหววาดมือชิ้นเดียว
ไม่ว่า engine เบื้องหลังจะซับซ้อนแค่ไหน particle system ทุกตัวรัน loop สามขั้นตอนเดิมซ้ำทุกเฟรม:
เพื่อให้เห็นภาพชัดขึ้น นี่คือ loop เดียวกันเป๊ะ ๆ ที่เขียนเป็นโปรแกรม C# เล็ก ๆ แบบไม่พึ่ง Unity เลย เป็นเวอร์ชันตัดทอนของสิ่งที่ particle system จริงทำอยู่ข้างในนั่นแหละ
using System;
using System.Collections.Generic;
class Particle
{
public float x, y; // position
public float vx, vy; // velocity
public float life; // seconds left before it dies
}
class MiniParticleSystem
{
List<Particle> particles = new List<Particle>();
float spawnTimer = 0f;
const float spawnInterval = 0.5f; // one new particle every 0.5s
const float lifeTime = 1.5f; // each particle lives 1.5s
public void Update(float dt)
{
// 1. EMIT: spawn new particles on a timer
spawnTimer += dt;
if (spawnTimer >= spawnInterval)
{
spawnTimer = 0f;
particles.Add(new Particle { x = 0, y = 0, vx = 1f, vy = 2f, life = lifeTime });
}
// 2. SIMULATE: move every particle, age it, remove the dead ones
for (int i = particles.Count - 1; i >= 0; i--)
{
Particle p = particles[i];
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
if (p.life <= 0f)
particles.RemoveAt(i); // dead, remove it
}
// 3. RENDER: a real engine draws a quad here, we just print
Console.WriteLine($"particles alive = {particles.Count}");
}
}
class Program
{
static void Main()
{
var system = new MiniParticleSystem();
for (int frame = 0; frame < 6; frame++)
system.Update(0.5f); // pretend each frame takes 0.5 seconds
}
}
Output:
particles alive = 1
particles alive = 2
particles alive = 2
particles alive = 2
particles alive = 2
particles alive = 2
particle ใหม่เกิดทุกเฟรม (เพราะ spawnInterval เท่ากับเวลาต่อเฟรมพอดี) และแต่ละตัวมีอายุ 1.5 วินาที ซึ่งเท่ากับสามรอบของ simulate particle หนึ่งตัวจะถูกนับว่า "ยังไม่ตาย" ในเฟรมที่มันเกิดกับเฟรมถัดไป แล้วก็ถูกลบทิ้งในรอบที่สาม พอดีก่อนที่มันจะถูกนับซ้ำอีกครั้ง ดังนั้นหลังผ่านไปสองสามเฟรมแรก ระบบจะเข้าสู่จังหวะคงที่ คือมี particle ที่ยังไม่ตายอยู่ราว ๆ lifetime หาร spawnInterval ตลอดเวลา จำตัวเลขนี้ไว้ เพราะมันจะกลับมาอีกทีในแบบฝึกหัดและในหัวข้อเรื่อง performance ตอนท้าย
particle ทุกตัวพกข้อมูลชุดเล็ก ๆ ติดตัวมาด้วย ตัวหลัก ๆ ที่จะใช้บ่อยมากคือ:
กลเม็ดที่ทำให้ size กับ color "เปลี่ยนไปตามอายุ" มักจะเหมือนกันเสมอ คือเอาว่า particle เดินทางมาถึงไหนแล้วในช่วงอายุของมัน แสดงเป็นเศษส่วนตั้งแต่ 0 (เพิ่งเกิด) ถึง 1 (ใกล้ตาย) แล้วใช้เศษส่วนนั้น blend (interpolate) ระหว่างค่าเริ่มต้นกับค่าสุดท้าย เศษส่วนนี้มักเรียกว่า t และการ blend แบบนี้คือ lerp (linear interpolation ชื่อเท่ ๆ ของ "เลื่อนค่าแบบ smooth ระหว่างตัวเลขสองตัว")
struct Particle
{
public float life; // seconds left
public float maxLife; // total lifetime, for normalizing
}
static float Lerp(float a, float b, float t) => a + (b - a) * t;
static void PrintTrace()
{
Particle p = new Particle { life = 1.0f, maxLife = 1.0f };
float startSize = 0.2f, endSize = 1.0f;
for (int step = 0; step <= 4; step++)
{
float t = 1f - (p.life / p.maxLife); // 0 = just born, 1 = about to die
float size = Lerp(startSize, endSize, t);
float alpha = 1f - t; // fade out linearly
Console.WriteLine($"life={p.life:F2} t={t:F2} size={size:F2} alpha={alpha:F2}");
p.life -= 0.25f;
}
}
Output:
life=1.00 t=0.00 size=0.20 alpha=1.00
life=0.75 t=0.25 size=0.40 alpha=0.75
life=0.50 t=0.50 size=0.60 alpha=0.50
life=0.25 t=0.75 size=0.80 alpha=0.25
life=0.00 t=1.00 size=1.00 alpha=0.00
สังเกต t ที่ไต่จาก 0 ไป 1 ตามที่ life นับถอยหลัง size โตจาก 0.2 ไป 1.0 คู่กันไป ส่วน alpha จางจากทึบเต็มที่ (1.0) ไปจนมองไม่เห็นเลย (0.0) นี่คือสิ่งที่ module "Size over Lifetime" กับ "Color over Lifetime" ของ Unity ทำอยู่ข้างในเป๊ะ ๆ คือมันเก็บ curve หรือ gradient ไว้ แล้ว sample ด้วยค่า t ตัวเดียวกันนี้ทุกเฟรม
mini particle system ในหัวข้อที่ 1 รันอยู่บน CPU ล้วน ๆ คือ for loop ธรรมดาที่ไล่ทำทีละ particle ทีละตัว เขียนและ debug ง่าย แต่มันไม่ scale — แค่ particle สองสามพันตัวก็หนักสำหรับ CPU แล้ว เพราะ CPU ถูกออกแบบมาให้ทำงานซับซ้อนแบบมี branch จำนวนไม่มากได้ดี ไม่ใช่ทำงานเหมือนกันเป๊ะ ๆ เป็นล้าน ๆ ครั้ง
GPU (graphics processing unit) ถูกสร้างมาตรงข้ามกัน แทนที่จะมี core แรง ๆ ไม่กี่ตัว มันมี core เล็ก ๆ เรียบง่ายเป็นพัน ๆ ตัวที่ออกแบบมาให้ทำ operation เดียวกันเป๊ะ ๆ กับข้อมูลหลายชิ้นพร้อมกัน ซึ่งตรงกับสิ่งที่ particle simulation ต้องการพอดี คือ logic "ขยับ, เพิ่มอายุ, อาจจะตาย" แบบเดียวกัน ที่ทำกับ particle เป็นล้าน ๆ ตัวแยกจากกันอิสระ compute shader (shader ที่รันการคำนวณทั่วไปบน GPU แทนที่จะวาดสามเหลี่ยม) สามารถอัปเดต particle ทุกตัวแบบขนานได้โดยใช้ structured buffer (บล็อกหน่วยความจำบน GPU ที่เก็บ array ของ struct ที่กำหนดเอง อ่านและเขียนได้จาก shader)
นี่คือ compute shader kernel ที่ทำขั้น "simulate" แบบเดียวกับ loop C# ของเรา แต่เขียนให้รันครั้งเดียวต่อ particle หนึ่งตัว กระจายไปบน GPU thread ที่ทำงานขนานกันเป็นพัน ๆ ตัว:
// A GPU compute shader kernel: this ONE function body
// runs once per particle, but thousands of copies run at the same time.
struct Particle
{
float3 position;
float3 velocity;
float life;
};
RWStructuredBuffer<Particle> particles;
float deltaTime;
[numthreads(64, 1, 1)]
void UpdateParticles(uint3 id : SV_DispatchThreadID)
{
Particle p = particles[id.x];
p.position += p.velocity * deltaTime;
p.life -= deltaTime;
particles[id.x] = p;
// note: no adding/removing from an array here -- GPU particle
// systems usually just mark life <= 0 as "dead" and skip drawing
// it, instead of resizing an array the way our C# List did.
}
อ่านตรงนี้เป็น worked trace ไม่ใช่ console output จริง ถ้า dispatch kernel นี้ด้วย particle 1,000,000 ตัว และ thread group ขนาด 64, GPU จะปล่อยประมาณ 1,000,000 / 64 ≈ 15,625 thread group แล้ว position กับ life ของทุก particle ก็จะถูกอัปเดตใน GPU pass เดียวกัน ไม่ใช่ทำทีละตัวแบบเวอร์ชัน CPU แต่ทำพร้อมกันแทบทั้งหมดจริง ๆ นี่คือเหตุผลทั้งหมดที่ทำให้ particle system แบบ GPU รองรับ particle เป็นล้าน ๆ ตัวได้ ในขณะที่แบบ CPU เริ่มมีปัญหาตั้งแต่หลักหมื่นต้น ๆ แล้ว
Billboarding คือการหมุน quad แบนทุกเฟรมให้หันหน้าเข้าหากล้องเสมอ ไม่ว่า transform ของ particle เองจะหมุนไปทางไหน และไม่ว่ากล้องจะขยับไปตรงไหนก็ตาม ถ้าไม่ทำแบบนี้ sprite แบน ๆ จะดูถูกต้องแค่จากมุมเดียว แล้วกลายเป็นเส้นบาง ๆ มองไม่เห็นเลยเมื่อมองจากด้านข้าง ซึ่งนั่นคือสิ่งที่สี่เหลี่ยมแบนธรรมดาจะเป็นเมื่อมองจากด้านข้างพอดี
หลักคณิตศาสตร์ก็คือ แทนที่จะใช้ rotation ของ particle เอง shader จะเอามุมแต่ละมุมของ quad ใน local space (เช่น มุมที่ local x = -0.5, y = 0.5) แล้วดันมันออกจากจุดศูนย์กลางของ particle ไปตาม camera's right vector กับ camera's up vector ซึ่งเป็นสองทิศทางที่ดึงมาจาก view matrix ของกล้องตรง ๆ วิธีนี้รับประกันว่าระนาบของ quad จะตั้งฉากกับทิศทางมองของกล้องเสมอ
Shader "Custom/BillboardParticle"
{
Properties
{
_MainTex ("Sprite", 2D) = "white" {}
}
SubShader
{
Tags { "Queue"="Transparent" "RenderType"="Transparent" }
Blend SrcAlpha OneMinusSrcAlpha
ZWrite Off
Cull Off
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION; // local quad corner, e.g. (-0.5,-0.5,0)..(0.5,0.5,0)
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
};
sampler2D _MainTex;
v2f vert (appdata v)
{
v2f o;
// particle center in world space (the particle's own position)
float3 center = mul(unity_ObjectToWorld, float4(0, 0, 0, 1)).xyz;
// camera's right and up axes, straight from the view matrix
float3 camRight = UNITY_MATRIX_V[0].xyz;
float3 camUp = UNITY_MATRIX_V[1].xyz;
// push the quad's local corner out along those axes instead
// of the particle's own rotation -- this is billboarding
float3 worldPos = center
+ camRight * v.vertex.x
+ camUp * v.vertex.y;
o.pos = mul(UNITY_MATRIX_VP, float4(worldPos, 1.0));
o.uv = v.uv;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
return tex2D(_MainTex, i.uv);
}
ENDCG
}
}
}
ตรงนี้ไม่มี console output ให้ดู worked trace ของมันคือภาพที่เห็น ลองเอา shader นี้ไปใส่กับ quad หมุน transform ของ quad รอบแกนไหนก็ได้ใน editor แล้ว sprite ของมันจะไม่เปลี่ยนทิศทางบนหน้าจอเลย ลองเดินกล้องไปรอบ ๆ มันใน Scene view quad จะหมุนตามเงียบ ๆ เพื่อหันหน้าเข้าหาเราตลอด rotation ของ GameObject เองไม่มีผลอะไรอีกต่อไป
Cull Off ใน SubShader geometry ของ billboard อาจหันหน้า "กลับด้าน" เทียบกับกล้องได้ ขึ้นอยู่กับตอนสร้างโมเดล และค่า default Cull Back จะไม่วาด quad นั้นเลยแบบเงียบ ๆ โดยไม่มี error ใด ๆ ถ้า particle มองไม่เห็นจากบางมุม นี่คือจุดแรกที่ควรเช็กcomponent ParticleSystem ที่มากับ Unity มีชื่อเล่นว่า Shuriken (เป็นชื่อรหัสภายในดั้งเดิม และเป็นชื่อที่นักพัฒนา Unity ส่วนใหญ่ยังใช้เรียกมันอยู่) มัน simulate particle บน CPU และพฤติกรรมทั้งหมดของมันถูกจัดเป็น module ต่าง ๆ เช่น Emission, Shape, Color over Lifetime, Size over Lifetime และอื่น ๆ แต่ละอันแก้ไขได้จาก foldout ใน Inspector และแต่ละอันก็เข้าถึงได้จาก C# เป็น struct บน component ด้วยเหมือนกัน
using UnityEngine;
public class HitSparkEmitter : MonoBehaviour
{
public ParticleSystem sparks;
public void PlayHitSpark(Vector3 worldPos, Color tint)
{
// Read-and-modify a module's settings from script
var main = sparks.main;
main.startColor = tint;
// Move the emitter to the hit point, then fire a burst of
// 12 particles right now, overriding a few properties per-particle
sparks.transform.position = worldPos;
var emitParams = new ParticleSystem.EmitParams();
emitParams.startSize = Random.Range(0.1f, 0.3f);
emitParams.velocity = Random.insideUnitSphere * 2f;
sparks.Emit(emitParams, 12);
}
}
Worked trace: เรียก PlayHitSpark(hitPoint, Color.yellow) ตอนดาบฟันโดน emitter จะกระโดดไปที่ hitPoint สี default ของ particle ถูกตั้งเป็นสีเหลือง และ particle 12 ตัวก็ระเบิดออกมาทันที แต่ละตัวมีขนาดสุ่มเล็ก ๆ ของตัวเองและความเร็วพุ่งออกแบบสุ่ม (เพราะ EmitParams ให้เรา override property แต่ละอย่างต่อ burst ได้ ทับค่า default ของ module) particle ทั้ง 12 ตัวนั้นก็จะเพิ่มอายุตาม loop แบบเดียวกับหัวข้อที่ 1 เป๊ะ ๆ แล้วค่อย ๆ จางไปตาม curve ที่ตั้งไว้ใน module "Color over Lifetime" ใน Inspector
main.startColor จะเปลี่ยนแค่สี default ของ particle ที่ emit ต่อจากนี้เท่านั้น ส่วน EmitParams ไว้ override แค่ burst เฉพาะจุดโดยไม่แตะค่า default ของ module เลย ใช้ตัวนี้เวลาที่เรียก particle system ตัวเดียวกันแต่ต้องการค่าเริ่มต้นต่างกัน (เช่น hit spark สีแดงกับสีน้ำเงิน)particle shader ทุกตัวต้องระวังจุดหนึ่งที่พลาดง่ายมาก คือ Shuriken เขียนสีปัจจุบันของแต่ละ particle (รวมถึงค่าที่ module "Color over Lifetime" คำนวณออกมาด้วย) ลงใน channel COLOR ต่อ vertex ของ mesh ถ้า shader ของเราไม่เคยอ่าน channel นั้นเลย การจางและการเปลี่ยนสีที่ตั้งไว้ใน Inspector จะไม่มีผลอะไรเลยให้เห็น ไม่ว่าจะตั้งค่าไว้ละเอียดแค่ไหนก็ตาม
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float4 color : COLOR; // Shuriken writes each particle's current
// color (from Color over Lifetime) here
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float4 color : COLOR;
};
v2f vert (appdata v)
{
v2f o;
// ... billboard position code from section 4 goes here ...
o.uv = v.uv;
o.color = v.color; // pass the particle's color through to the pixel shader
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 tex = tex2D(_MainTex, i.uv);
return tex * i.color; // texture tinted and faded by the particle system
}
return tex; แทนที่จะเป็น return tex * i.color; ใน pixel shader particle system จะดูเหมือนไม่ทำอะไรเลย ไม่จาง ไม่เปลี่ยนสี เพราะ shader แค่ทิ้งข้อมูลที่ Shuriken คำนวณมาให้ทิ้งไปเฉย ๆ ถ้าเอฟเฟกต์ particle "ไม่ยอมจาง" ทั้งที่ curve ใน Inspector ดูถูกต้องแล้ว นี่คือบรรทัดแรกที่ควรเช็กที่ผ่านมา shader ทุกตัวใช้ alpha blending ปกติ คือ dst = src * a + dst * (1 - a) หมายความว่า pixel ใหม่จะถูกผสมกับสิ่งที่วาดไว้อยู่แล้ว โดยถ่วงน้ำหนักด้วย alpha ซึ่งเหมาะกับควัน ผ้า หรืออะไรก็ตามที่ควรดูเหมือนบังสิ่งที่อยู่ข้างหลังบางส่วน
ไฟ ออร่าเวทมนตร์ และพลังงานเรืองแสงมักต้องการอะไรที่ต่างออกไป นั่นคือ additive blending ซึ่งสีของ pixel ใหม่จะถูกบวกเข้ากับสิ่งที่มีอยู่แล้วตรง ๆ sprite เรืองแสงสองอันที่ซ้อนกันจะไม่ "บัง" กัน แต่จะบวกกันแล้วสว่างขึ้น ซึ่งตรงกับพฤติกรรมของแสงจริงเวลาของเรืองแสงสองอันซ้อนทับกันพอดี
ใน shader language ของ Unity นี่คือแค่บรรทัดเดียว คือ Blend One One แทนที่จะเป็น Blend SrcAlpha OneMinusSrcAlpha มันบอก GPU ว่า "เอาสี source คูณหนึ่ง บวกเข้ากับสี destination คูณหนึ่ง" ไม่มีการผสม มีแต่การบวก
Shader "Custom/AdditiveGlow"
{
Properties
{
_MainTex ("Sprite", 2D) = "white" {}
_Intensity ("Glow Intensity", Range(1, 4)) = 1.5
}
SubShader
{
Tags { "Queue"="Transparent" "RenderType"="Transparent" }
Blend One One // ADDITIVE: add this pixel's color onto whatever
// is already in the frame buffer
ZWrite Off
Cull Off
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float4 color : COLOR; };
struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float4 color : COLOR; };
sampler2D _MainTex;
float _Intensity;
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex); // (billboarding from section 4 applies too)
o.uv = v.uv;
o.color = v.color;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 tex = tex2D(_MainTex, i.uv);
// IMPORTANT: with Blend One One, the GPU does NOT use alpha
// to fade the color -- it just adds whatever RGB we return.
// So the fade (alpha) has to be baked into RGB ourselves.
fixed3 glow = tex.rgb * i.color.rgb * i.color.a * _Intensity;
return fixed4(glow, 1.0);
}
ENDCG
}
}
}
Worked trace: ตอน _Intensity เท่ากับ 1.5, texture pixel สีขาวสว่างเต็มที่ (1,1,1) ที่ถูกแต่งสีด้วยสี particle เหลืองทึบเต็มที่ (1, 0.9, 0.2, 1) จะได้ glow = (1,1,1) * (1,0.9,0.2) * 1 * 1.5 = (1.5, 1.35, 0.3) ค่าที่เกิน 1.0 นี่แหละคือประเด็นสำคัญ มันคือสี HDR (high dynamic range) แบบ "สว่างกว่าขาว" ซึ่งเป็นสิ่งที่ทำให้ glow ทะลุผ่าน bloom post-processing ออกมาได้ แทนที่จะดูเป็นก้อนขาวแบน ๆ
Blend One One แต่ยังหวังพึ่ง alpha channel ที่ return ออกมาเพื่อให้ particle จางหายไปตามอายุ additive blending ไม่เคยอ่าน alpha มาผสมสีเลย blend mode นี้เมิน alpha channel ไปเฉย ๆ ถ้าเราไม่คูณค่าการจางเข้าไปใน RGB เอง (แบบที่คูณ i.color.a เข้าไปด้านบน) particle จะหายวับไปทันทีแทนที่จะค่อย ๆ จางลงเอฟเฟกต์ dissolve ทำให้ sprite ดูเหมือนกำลังถูกไฟไหม้หายไปหรือค่อย ๆ ก่อตัวขึ้นมา โดยเทียบค่าจาก noise texture (texture ที่เต็มไปด้วยค่าสีเทาแบบสุ่มเทียม) กับ threshold ที่เลื่อนจาก 0 ไป 1 ไปตามเวลา ตรงไหนที่ค่า noise ต่ำกว่า threshold pixel นั้นจะถูกทิ้งไปทั้งหมดด้วย clip() (คำสั่งใน shader ที่แปลว่า "ทิ้ง pixel นี้ไป ไม่ต้องวาด")
Shader "Custom/Dissolve"
{
Properties
{
_MainTex ("Sprite", 2D) = "white" {}
_NoiseTex ("Dissolve Noise", 2D) = "white" {}
_DissolveAmount ("Dissolve Amount", Range(0, 1)) = 0
_EdgeWidth ("Edge Width", Range(0.01, 0.3)) = 0.08
_EdgeColor ("Edge Color", Color) = (1, 0.5, 0.1, 1)
}
SubShader
{
Tags { "Queue"="Transparent" "RenderType"="Transparent" }
Blend SrcAlpha OneMinusSrcAlpha
ZWrite Off
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; };
struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; };
sampler2D _MainTex;
sampler2D _NoiseTex;
float _DissolveAmount;
float _EdgeWidth;
fixed4 _EdgeColor;
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 tex = tex2D(_MainTex, i.uv);
float noise = tex2D(_NoiseTex, i.uv).r;
// clip() discards this pixel entirely if the value passed
// in is negative -- so pixels where noise is below the
// threshold vanish
clip(noise - _DissolveAmount);
// pixels just above the threshold get an edge color, like
// a glowing line eating its way through the sprite
float edge = 1 - smoothstep(0, _EdgeWidth, noise - _DissolveAmount);
fixed3 finalColor = lerp(tex.rgb, _EdgeColor.rgb, edge);
return fixed4(finalColor, tex.a);
}
ENDCG
}
}
}
public class Dissolver : MonoBehaviour
{
public Material dissolveMat;
public float duration = 2f;
float t = 0f;
void Update()
{
t += Time.deltaTime / duration;
dissolveMat.SetFloat("_DissolveAmount", Mathf.Clamp01(t));
}
}
Worked trace: ตอน _DissolveAmount = 0, noise - 0 ไม่มีทางติดลบเลย (noise มีค่าตั้งแต่ 0 ขึ้นไปเสมอ) ดังนั้นไม่มี pixel ไหนถูก clip เลย sprite จะโชว์เต็ม ๆ ตอน _DissolveAmount = 0.5 pixel ทุกตัวที่ค่า noise ต่ำกว่า 0.5 จะถูกทิ้งไป ใช้ค่า sample ในไดอะแกรมด้านบนก็คือ pixel ที่ทำเครื่องหมาย [X] พอดี ตอน _DissolveAmount = 1 แม้แต่ pixel ที่มีค่า noise สูงสุด 1.0 ก็ยังได้ noise - 1 = 0 ซึ่ง clip() ก็ทิ้งเหมือนกัน (มันทิ้งตอนค่าเป็นศูนย์หรือติดลบ) sprite เลยหายไปหมดแล้ว
sprite นิ่ง ๆ ตัวเดียวจะได้แค่ก้อนแบน ๆ ไม่สมจริงพอจะเป็นไฟหรือการระเบิด flipbook (เรียกอีกอย่างว่า sprite sheet) อัด animation frame ที่วาดไว้ล่วงหน้าหลายเฟรมลงใน texture เดียว จัดเรียงเป็น grid แล้ว shader ก็เลือกแค่สี่เหลี่ยม UV เล็ก ๆ ของเฟรมปัจจุบันมาวาดในแต่ละครั้ง เหมือนการ์ตูนวาดมือจิ๋ว ๆ ที่เล่นครั้งเดียวต่อ particle หนึ่งตัว
// _Tiles = 4 (a 4x4 sheet, 16 frames total)
// _FramesPerSecond = how fast the flipbook plays
float _Tiles;
float _FramesPerSecond;
float2 FlipbookUV(float2 uv, float age)
{
float totalFrames = _Tiles * _Tiles;
float frame = floor(age * _FramesPerSecond);
frame = fmod(frame, totalFrames); // loop back to frame 0 after the last one
float col = fmod(frame, _Tiles);
float row = floor(frame / _Tiles);
float2 tileSize = 1.0 / _Tiles;
float2 tileOffset = float2(col, (_Tiles - 1) - row) * tileSize; // flip row: UV (0,0) is bottom-left
return tileOffset + uv * tileSize;
}
Worked trace: ตอน _Tiles = 4 กับ _FramesPerSecond = 10 ที่ age = 0.65 วินาที: frame = floor(0.65 * 10) = floor(6.5) = 6 totalFrames = 16 และ 6 % 16 = 6 ก็เลยยังอยู่ที่ frame 6 col = 6 % 4 = 2, row = floor(6 / 4) = 1 tileSize = 0.25 ดังนั้น tileOffset = (2 * 0.25, (4 - 1 - 1) * 0.25) = (0.5, 0.5) นั่นคือ sample สี่เหลี่ยมจาก UV (0.5, 0.5) ถึง (0.75, 0.75) ซึ่งตรงกับช่อง row 1, column 2 ใน grid ด้านบนพอดี
quad ของ particle คือสี่เหลี่ยมแบนบางจนแทบไม่มีความหนา เมื่อมันทะลุผ่าน geometry ทึบ เช่น ควันลอยผ่านพื้น quad จะหยุดถูกวาดตรงจุดที่พื้นผิว geometry อยู่พอดี เหลือไว้เป็นเส้นตรงแข็ง ๆ ดูไม่สวยเลย soft particles แก้ปัญหานี้ด้วยการค่อย ๆ ลด alpha ของ particle ลงเมื่อ depth ของมันเข้าใกล้ depth ของสิ่งที่วาดอยู่ข้างหลังมันแล้ว โดยใช้ depth texture ของกล้อง (texture ที่เก็บไว้ว่าในแต่ละ screen pixel พื้นผิวที่วาดไปแล้วที่ใกล้ที่สุดอยู่ห่างแค่ไหน)
sampler2D _CameraDepthTexture; // depth of everything already drawn this frame
float _FadeDistance; // world units over which the fade happens
fixed4 frag (v2f i) : SV_Target
{
// depth already in the frame buffer at this screen pixel (0..1, non-linear)
float sceneDepthRaw = tex2D(_CameraDepthTexture, i.screenUV).r;
float sceneEyeDepth = LinearEyeDepth(sceneDepthRaw);
// this particle pixel's own depth, passed through from the vertex shader
float particleEyeDepth = i.viewDepth;
float diff = sceneEyeDepth - particleEyeDepth;
float fade = saturate(diff / _FadeDistance);
fixed4 tex = tex2D(_MainTex, i.uv);
return fixed4(tex.rgb, tex.a * fade * i.color.a);
}
Worked trace: สมมติ _FadeDistance = 0.5 pixel ควันที่อยู่ห่างจากพื้น 0.02 หน่วยโลกจะได้ diff = 0.02 ดังนั้น fade = saturate(0.02 / 0.5) = 0.04 แทบมองไม่เห็นเลยตรงจุดที่ชนกันพอดี ซึ่งเป็นสิ่งที่ลบเส้นขอบแข็งนั้นออกไป ส่วน pixel ของ particle เดียวกันที่ห่างจากพื้นผิวใด ๆ 0.5 หน่วยขึ้นไปจะได้ fade = saturate(1.0) = 1.0 มองเห็นเต็มที่ เพราะไม่ได้อยู่ใกล้จุดชนกับอะไรเลย
_CameraDepthTexture ต้องเปิด depth texture mode ของกล้องจริง ๆ ก่อน (ในการตั้งค่า Camera ของ Unity หรือถูก render pipeline ร้องขอ) ถ้า shader ของ soft particle compile ผ่านแต่ไม่มีการจางให้เห็นเลย ส่วนใหญ่แล้วนี่คือสาเหตุUnity มี particle system มาให้สองแบบ และการเลือกใช้ตัวไหนก็คือ tradeoff ระหว่าง CPU กับ GPU จากหัวข้อที่ 3 นั่นแหละ แค่ห่อด้วยเครื่องมือสร้างที่ต่างกันสองแบบ
Shuriken คือตัวเลือก default ที่มีมาให้เสมอ เรียบง่าย ใช้ได้กับทุกแพลตฟอร์มที่ Unity รองรับ และเพียงพอมากสำหรับเอฟเฟกต์ที่ขับเคลื่อนด้วยเกมเพลย์ ที่ต้องการแค่ particle หลักสิบหรือหลักร้อยตัวต่อครั้ง เช่น hit spark, ประกายตอนเก็บไอเทม, ฝุ่นตอนเดิน VFX Graph เป็น package แยกต่างหากที่เราสร้างเอฟเฟกต์เป็น node graph แทนที่จะซ้อน module ใน Inspector และ simulation เองก็รันบน GPU เป็น compute shader ซึ่งเป็นสิ่งที่ทำให้มันดัน particle count ไปถึงหลักล้านได้ แต่ข้อแลกเปลี่ยนคือมันต้องมี Scriptable Render Pipeline (URP หรือ HDRP) และ GPU ที่ค่อนข้างใหม่พอสมควร เลยไม่ใช่ตัวที่เอาไปแทน Shuriken ได้ทุกที่
Overdraw คือการที่ GPU รัน fragment (pixel) shader ให้กับ screen pixel เดียวกันมากกว่าหนึ่งครั้งในเฟรมเดียว สำหรับ object ทึบแสง GPU มีกลเม็ดที่จะข้าม pixel ที่ถูกบังอยู่หลังอะไรที่ใกล้กว่าได้ แต่ particle โปร่งแสงทำลายกลเม็ดนั้นไปหมดเลย particle ทุกตัวที่คลุม pixel นั้นต้องถูก shade และ blend ตามลำดับ เพราะแต่ละตัวมีส่วนกับสีสุดท้ายที่ blend ออกมา พื้นที่หน้าจอที่ถูกคลุมด้วยกลุ่มควัน 20 ก้อนที่ซ้อนกันจะรัน pixel shader ประมาณ 20 ครั้งต่อทุก pixel ในพื้นที่นั้น
ทุกอย่างในบทนี้จริง ๆ แล้วคือไอเดียเดียวที่เอาไปใช้ในหลายจุด คือ particle เป็นข้อมูลเบา ๆ (position, velocity, lifetime, size, color) ที่ถูกขยับด้วยคณิตศาสตร์ง่าย ๆ แล้ว shader ก็แปลงข้อมูลนั้นให้กลายเป็นแสงบนหน้าจอ ไม่ว่า shader ตัวนั้นจะ clip pixel เพื่อทำ dissolve, บวกสีเพื่อทำ glow, หรือ sample สี่เหลี่ยมที่เลื่อนไปมาของ sprite sheet เพื่อทำ flipbook มันก็อ่านค่า per-particle ชุดเดียวกันไม่กี่ตัวที่เราเข้าใจไปแล้วตั้งแต่หัวข้อที่ 1 กับ 2 เสมอ
dst = src*a + dst*(1-a) โหมดปกติสำหรับความโปร่งใสทั่วไปBlend One One) ใช้กับ glow, ไฟ, และเวทมนตร์vy += gravity * dt โดย gravity = -9.8) แล้วค่อยขยับ particle (x += vx * dt, y += vy * dt) โดยใช้ dt = 0.1 ลองเขียน (ด้วยมือหรือด้วยโค้ด) ค่า y ที่พิมพ์ออกมาหลังจาก 4 เฟรมแรก
class Particle
{
public float x, y;
public float vx, vy;
}
class Program
{
static void Main()
{
Particle p = new Particle { x = 0, y = 0, vx = 0, vy = 0 };
float dt = 0.1f;
float gravity = -9.8f;
for (int frame = 1; frame <= 4; frame++)
{
p.vy += gravity * dt; // gravity changes velocity first
p.x += p.vx * dt;
p.y += p.vy * dt; // then velocity changes position
Console.WriteLine($"frame {frame}: y = {p.y:F3}");
}
}
}
frame 1: y = -0.098
frame 2: y = -0.294
frame 3: y = -0.588
frame 4: y = -0.980
แต่ละเฟรม gravity จะลด vy ลง 0.98 ก่อน (คือ 9.8 * 0.1) แล้วค่อยเอา vy ใหม่ไป apply กับ y การอัปเดต velocity ก่อน position แบบนี้เรียกว่า semi-implicit (หรือ symplectic) Euler integration ซึ่งเป็นไอเดียพื้นฐานเดียวกับที่ physics engine ของ Unity เองใช้กับการเคลื่อนที่ของ Rigidbody และมันเป็นวิธีมาตรฐานที่เสถียรสำหรับใส่ gravity ง่าย ๆ ให้ particle
_GlowWidth ให้กว้างกว่า _EdgeWidth) กับ property สีตัวที่สอง (_GlowColor) ลองเขียน logic สีของ fragment shader ใหม่
fixed4 frag (v2f i) : SV_Target
{
fixed4 tex = tex2D(_MainTex, i.uv);
float noise = tex2D(_NoiseTex, i.uv).r;
float d = noise - _DissolveAmount;
clip(d);
// tight bright line right at the edge
float edge = 1 - smoothstep(0, _EdgeWidth, d);
// wider, dimmer glow just outside the edge
float glow = 1 - smoothstep(0, _GlowWidth, d);
fixed3 withGlow = lerp(tex.rgb, _GlowColor.rgb, glow * 0.5);
fixed3 finalColor = lerp(withGlow, _EdgeColor.rgb, edge);
return fixed4(finalColor, tex.a);
}
เพราะ _GlowWidth ตั้งไว้กว้างกว่า _EdgeWidth ทำให้ smoothstep(0, _GlowWidth, d) กว่าจะไปถึง 1 (แปลว่า glow ลดลงเป็น 0) ต้องไกลจากขอบจริงมากกว่า smoothstep(0, _EdgeWidth, d) มาก นั่นทำให้สี glow ที่จางกว่ามองเห็นได้ในแถบกว้าง ในขณะที่สีขอบสว่างจาก lerp ตัวที่สองจะโผล่มาเฉพาะใกล้ ๆ d = 0 มาก ๆ ซ้อนทับอยู่ด้านบน ลำดับสำคัญมาก คือ glow ถูก blend เข้าไปก่อน แล้วค่อย blend ขอบที่คมกว่าทับลงบนผลลัพธ์นั้นอีกที
Part A: ประมาณ spawnRate * lifetime = 30 * 0.4 = 12 particle ที่ยังไม่ตายพร้อมกัน particle แต่ละตัวอยู่ได้ 0.4 วินาที ในขณะที่ตัวใหม่ก็เกิดขึ้นทุก ๆ 1/30 วินาที ดังนั้นในช่วงเวลาไหนก็ตามจะมี particle ประมาณ 12 ตัวที่ "อยู่ระหว่างทาง" พร้อมกัน เป็นไอเดียเดียวกับ lifetime หาร spawnInterval จากหัวข้อที่ 1 แค่เขียนกลับด้านเป็น lifetime คูณ spawnRate
Part B: Design B ทำให้เกิด overdraw มากกว่าเยอะ overdraw ขึ้นอยู่กับว่ามี quad โปร่งแสงกี่อันซ้อนทับกันบน screen pixel เดียวกัน ไม่ใช่พื้นที่หน้าจอรวมที่มันคลุม quad จิ๋ว 400 อันที่อัดอยู่ในพื้นที่เดียวกับ quad ขนาดกลาง 40 อัน แปลว่ามีเลเยอร์ซ้อนทับกันมากกว่าเยอะในแต่ละ pixel ทำให้ GPU ต้องรัน fragment shader มากกว่าเดิมหลายเท่าต่อ pixel ทั้งที่ภาพสุดท้ายคลุมพื้นที่หน้าจอเท่ากัน วิธีแก้จากหัวข้อที่ 12 ใช้ได้ตรงนี้เลย คือเลือก particle จำนวนน้อยแต่ตัวใหญ่กว่า แทนตัวเล็ก ๆ จำนวนมากที่ซ้อนกัน เมื่อผลลัพธ์ภาพออกมาคล้ายกัน