Unity GPU粒子模拟纹理紧凑化的并行实现
【Unity】GPUパーティクルのシミュレーションテクスチャのコンパクト化の並列化
在分层稀疏数据纹理的同时紧密填充。
概要
在使用 GPU 粒子动态生成粒子时,需要定期整理模拟纹理以丢弃已死亡的粒子。
此时有一种著名的便捷方法。
本文不详细解释该方法本身,简而言之:
- 创建计数纹理,并在有效粒子的位置写入 "1"。
- 为计数纹理生成 Mipmap。
- 基于 Z-Curve(Z-Order)的思想,可以得知任意第 n 个有效粒子的位置。
- 在原始模拟纹理的粒子所求出的位置进行排列。
大致就是这样。
这种方法只需要计数纹理中的1个通道,因此如果使用多个通道,就可以将多种类型的粒子分别整理(紧凑化)。这是一种常规的思路,但因为它很方便,所以这里说明一下步骤。
计数纹理的构成
所谓说明,其实只是简单地增加一个独立的维度,因此理论上没有什么需要特别解释的。只是在计数纹理中查找第 n 个粒子时,需要考虑在此之前未计数的粒子的偏移量而已。
另一方面,为了缩小计数纹理的尺寸,有一些巧妙的做法,因此下面说明计数纹理的构成。
在本文中,我们将把16种粒子分别进行紧凑化。
假设使用RGBA的4个通道分别进行计数,那么如果有4张计数纹理,就可以对16种粒子分别进行计数。为了降低渲染成本,我们将这4张计数纹理合并为1张计数纹理来处理。
此外,在向计数纹理写入值时,将16个粒子作为1个单位
分配给计数纹理的一个纹素。由于使用Mipmaps的计数方法即使写入的值不是"1"也能准确计数,因此如果存在16个粒子,写入"16"即可进行计数。
在源代码中的命名方面,我们将4x4=16个计数的单位称为“Unit”,按Unit为单位计数得到的、对应1种粒子的计数纹理称为“Block”,将Block分配到RGBA上得到的、对应4种粒子的计数纹理称为“Group”,并将粒子的种类称为“Spec”。
要查找第n个粒子时,
- 需分别求出所有种类粒子的累积和。
- 确定第 n 个粒子属于哪种类型的粒子(即指定对应的 Block)。
- 按照 d4rkpl4y3r 的方法,在对应的 Block 中查找包含第 (n - (累积和)) 个粒子的 Unit。
- 在该 Unit 中查找第 (n - (累积和')) 个粒子。
具体步骤如下。在步骤 4 中,我们参考原始的“Particle Data Texture”来查找有效的粒子。此处不再赘述,另一种方法是在创建计数纹理时预先准备一个用于标志位的纹理。
実装
这是为 Render Texture 实现的。(Custom Render Texture 也是同样的方式。)
CompactSparseTextureInParallel.shader
CompactSparseTextureInParallel.shader
// ref: https://github.com/d4rkc0d3r/CompactSparseTextureDemo/tree/main
Shader "Test/CompactSparseTextureInParallel"
{
Properties
{
_ActiveRatio ("Active Ratio", Range(0,1)) = 0.5
[PowerSlider(5)] _SpecBias ("Particle Spec Distribution Bias", Range(0.1,10)) = 1
[NoScaleOffset] _ParticleDataTexture ("Particle Data Texture", 2D) = "black" {}
[NoScaleOffset] _CountTexture ("Count Texture", 2D) = "black" {}
}
CGINCLUDE
/*** 汎用関数: Morton Index *************************************************************/
uint Interleave2d( uint2 a )
{
// ref: https://lemire.me/blog/2018/01/08/how-fast-can-you-bit-interleave-32-bit-integers/
a = ( a | ( a << 8 ) ) & 0x00FF00FF; // 0b00000000111111110000000011111111
a = ( a | ( a << 4 ) ) & 0x0F0F0F0F; // 0b00001111000011110000111100001111
a = ( a | ( a << 2 ) ) & 0x33333333; // 0b00110011001100110011001100110011
a = ( a | ( a << 1 ) ) & 0x55555555; // 0b01010101010101010101010101010101
return a.x | ( a.y << 1 );
}
uint2 Deinterleave2d( uint b )
{
// ref: https://stackoverflow.com/questions/3137266/how-to-de-interleave-bits-unmortonizing
uint2 a = uint2( b, b >> 1 );
a = ( a ) & 0x55555555; // 0b01010101010101010101010101010101
a = ( a | ( a >> 1 ) ) & 0x33333333; // 0b00110011001100110011001100110011
a = ( a | ( a >> 2 ) ) & 0x0F0F0F0F; // 0b00001111000011110000111100001111
a = ( a | ( a >> 4 ) ) & 0x00FF00FF; // 0b00000000111111110000000011111111
a = ( a | ( a >> 8 ) ) & 0x0000FFFF; // 0b00000000000000001111111111111111
return a;
}
uint2 MortonIndex2d( uint index1d )
{
return Deinterleave2d( index1d );
}
uint DemortonIndex2d( uint2 index2d )
{
return Interleave2d( index2d );
}
/*** 汎用関数: 乱数 *********************************************************************/
float TimeHash1( float seed, float time, float offset, float chainedHash = 0.0 )
{
/*
* IbukiHash by Andante (https://twitter.com/andanteyk)
* This work is marked with CC0 1.0. To view a copy of this license,
* visit https://creativecommons.org/publicdomain/zero/1.0/
*
* ref: https://andantesoft.hatenablog.com/entry/2024/12/19/193517
*
* Modified by Yuri Tsukimi (2026/10).
*/
uint4 mult = uint4( 0xae3cc725, 0x9fe72885, 0xae36bfb5, 0x82c1fcad );
uint4 u = asuint( float4( seed, time, offset, chainedHash ) );
u = u * mult;
u ^= u.wxyz ^ u >> 13;
uint r = dot( u, mult );
r ^= r >> 11;
r = ( r * r ) ^ r;
return float( r ) / 4294967295.0f; // 2^32 - 1
}
float2 TimeHash2( float seed, float time, float offset )
{
float2 result;
result.x = TimeHash1( seed, time, offset );
result.y = TimeHash1( seed, time, offset, result.x );
return result;
}
/*** 汎用関数: 色 ***********************************************************************/
float3 h2rgb( float h )
{
return saturate( abs( frac( h + float3( 0, 2, 1 ) / 3.0 ) * 6.0 - 3.0 ) - 1.0 );
}
/***************************************************************************************/
ENDCG
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
CGINCLUDE
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
};
v2f vert ( appdata v )
{
v2f o;
o.vertex = UnityObjectToClipPos( v.vertex );
o.uv = v.uv;
return o;
}
/*** 共通定義 ***********************************************************************/
// ParticleDataTexture の 4x4 の範囲のセルを CountTexture の1テクセルに要約する.
// ユニットは正方形であることを前提とする.
#define COUNT_UNIT_WIDTH 4
#define COUNT_UNIT_HEIGHT COUNT_UNIT_WIDTH
#define COUNT_UNIT_SIZE ( COUNT_UNIT_WIDTH * COUNT_UNIT_HEIGHT )
// 16種類のパーティクルをそれぞれコンパクト化する
#define PARTICLE_SPEC_COUNT 16
// 1テクセルの次元
#define VECTOR_DIMENSION 4
Texture2D<float4> _ParticleDataTexture;
float4 _ParticleDataTexture_TexelSize;
Texture2D<float4> _CountTexture;
float4 _CountTexture_TexelSize;
SamplerState sampler_point_clamp;
float _ActiveRatio;
float _SpecBias;
/*** パーティクルの定義 **************************************************************/
struct ParticleData
{
// パーティクルの種類.この値ごとにパーティクルを Compaction する.
uint spec;
// アクティブかどうかのフラグ
bool isActive;
// パーティクルの色
float3 color;
};
ParticleData GetParticleData( uint2 index2d )
{
float2 uv = ( index2d + 0.5 ) * _ParticleDataTexture_TexelSize.xy;
float4 data = _ParticleDataTexture.SampleLevel( sampler_point_clamp, uv, 0 );
ParticleData pd;
pd.spec = uint( data.w ) >> 1; // 2ビット目以降が spec
pd.isActive = ( uint( data.w ) & 1 ) != 0; // 1ビット目が isActive
pd.color = data.xyz;
return pd;
}
ParticleData GetParticleData( uint index )
{
return GetParticleData( MortonIndex2d( index ) );
}
float4 SetParticleData( ParticleData pd )
{
float4 data;
data.xyz = pd.color;
data.w = ( pd.spec << 1 ) | ( pd.isActive ? 1 : 0 );
return data;
}
/***********************************************************************************/
ENDCG
Pass
{
Name "Particle Data"
/*
* ランダムな値をセットする Pass
*/
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
float4 frag ( v2f IN ) : SV_Target
{
float2 uv = IN.uv;
// パーティクルのデータ
ParticleData particleData;
{
// 乱数で適当に初期化することにする.
float2 rnd = TimeHash2( uv.x, _Time.y, uv.y );
// このパーティクルの種類.ここに設定した種類ごとにコンパクト化を行う.
float r = pow( rnd.x, _SpecBias );
uint spec = floor( r * PARTICLE_SPEC_COUNT );
spec = clamp( spec, 0, PARTICLE_SPEC_COUNT - 1 );
particleData.spec = spec;
// このパーティクルがアクティブかどうか.適当な値を入れておく.
bool isActive = ( rnd.y < _ActiveRatio );
particleData.isActive = isActive;
// このパーティクルの色.
float hue = float( spec ) / float( PARTICLE_SPEC_COUNT );
float3 color = h2rgb( hue );
particleData.color = isActive ? color : float3( 0.0, 0.0, 0.0 );
}
return SetParticleData( particleData );
}
ENDCG
}
Pass
{
Name "Count"
/*
* アクティブなパーティクルをカウントする Pass
*/
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
float4 frag ( v2f IN ) : SV_Target
{
float2 uv = IN.uv;
float4 texelSize = _CountTexture_TexelSize;
// FlagTexture 全体に対するインデックス
uint2 index2d = uv * texelSize.zw;
uint index = DemortonIndex2d( index2d );
// CountTexture では ParticleDataTexture の 4x4 を1ユニットとして1つのテクセルに
// まとめるので,その分テクスチャサイズが小さくなる.
// まとめた後のテクスチャを「ブロック」と呼ぶことにする.
uint2 unitSize2d = uint2( COUNT_UNIT_WIDTH, COUNT_UNIT_HEIGHT );
uint2 blockSize2d = uint2( _ParticleDataTexture_TexelSize.zw ) / unitSize2d;
uint blockSize = blockSize2d.x * blockSize2d.y;
// 16種類のパーティクルを個別に Compaction したいので,ブロックも16個必要.
// ただし,CountTexture は float4 なので,CountTexture の同じ範囲に4つの
// ブロックを格納できる.4ブロックを1つにまとめた状態を「グループ」と呼ぶことにする.
// ブロックのインデックスは1グループに4つのブロックが格納されることを考慮する必要がある
uint groupIndex = index / blockSize;
uint blockBaseIndex = groupIndex * VECTOR_DIMENSION;
if ( blockBaseIndex >= PARTICLE_SPEC_COUNT )
{
// 範囲外
return float4( 0, 0, 0, 0 );
}
// ParticleDataTexture から4x4個のパーティクルを取得する.
ParticleData particles[ COUNT_UNIT_SIZE ];
{
// CountTexture における処理中のブロックでの座標を ParticleDataTexture の座標に変換する.
// ParticleDataTexture における該当ユニットの左上の座標を求める.
uint2 sampleBaseIndex2d = ( index2d % blockSize2d ) * unitSize2d;
// Z-Curve の順に16個取得する.ちなみに,ここでのサンプル順は何でもいい.
[ unroll( COUNT_UNIT_SIZE ) ]
for ( uint i = 0; i < COUNT_UNIT_SIZE; ++i )
{
uint2 sampleIndex2d = sampleBaseIndex2d + MortonIndex2d( i );
particles[ i ] = GetParticleData( sampleIndex2d );
}
}
// 種類ごとにパーティクルを数える
uint particleCount[ PARTICLE_SPEC_COUNT ];
{
[ unroll( PARTICLE_SPEC_COUNT ) ]
for ( uint i = 0; i < PARTICLE_SPEC_COUNT; ++i )
{
particleCount[ i ] = 0;
}
}
{
[ unroll( COUNT_UNIT_SIZE ) ]
for ( uint i = 0; i < COUNT_UNIT_SIZE; ++i )
{
if ( particles[ i ].isActive )
{
uint spec = particles[ i ].spec;
if ( spec < PARTICLE_SPEC_COUNT )
{
particleCount[ spec ]++;
}
}
}
}
// 現在処理しているグループに属する種類のパーティクルに絞る
uint4 targetParticleCount;
{
[ unroll( VECTOR_DIMENSION ) ]
for ( uint i = 0; i < VECTOR_DIMENSION; ++i )
{
targetParticleCount[ i ] = particleCount[ blockBaseIndex + i ];
}
}
return targetParticleCount;
}
ENDCG
}
Pass
{
Name "Compaction"
/*
* ParticleDataTexture を Compaction する Pass
*/
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
uint4 GetParticleCount( float2 uv, uint lod )
{
float4 tex = _CountTexture.SampleLevel( sampler_point_clamp, uv, lod );
uint4 counts = uint4( tex * float( 1 << ( lod + lod ) ) );
return counts;
}
bool GetActiveParticleIndex( uint targetIndex, out uint activeIndex )
{
activeIndex = 0;
// ブロックのサイズとグループの個数を求めておく
uint2 unitSize2d = uint2( COUNT_UNIT_WIDTH, COUNT_UNIT_HEIGHT );
uint2 blockSize2d = uint2( _ParticleDataTexture_TexelSize.zw ) / unitSize2d;
uint2 groupCount2d = _CountTexture_TexelSize.zw / blockSize2d;
// ブロック全体が1テクセルになる LoD
uint maxLod = uint( log2( min( blockSize2d.x, blockSize2d.y ) ) );
// ブロックごとのパーティクル数を取得
uint particleCounts[ PARTICLE_SPEC_COUNT ];
{
[ unroll( PARTICLE_SPEC_COUNT / VECTOR_DIMENSION ) ]
for ( uint i = 0; i < PARTICLE_SPEC_COUNT / VECTOR_DIMENSION; ++i )
{
float2 sampleUv = ( float2( MortonIndex2d( i ) ) + 0.5 ) / groupCount2d;
uint4 counts = GetParticleCount( sampleUv, maxLod );
[ unroll( VECTOR_DIMENSION ) ]
for ( uint j = 0; j < VECTOR_DIMENSION; ++j )
{
particleCounts[ i * VECTOR_DIMENSION + j ] = counts[ j ];
}
}
}
// 探索対象のブロックと,ブロック内でのインデックスを求める
uint blockIndex = 0xFFFFFFFF;
uint targetLocalIndex = targetIndex;
{
[ unroll( PARTICLE_SPEC_COUNT ) ]
for ( uint i = 0; i < PARTICLE_SPEC_COUNT; ++i )
{
if ( targetLocalIndex < particleCounts[ i ] )
{
blockIndex = i;
break;
}
targetLocalIndex -= particleCounts[ i ];
}
}
if ( 0xFFFFFFFF == blockIndex )
{
// アクティブなパーティクルが見つからなかった場合
return false;
}
// 探索対象のグループ
uint groupIndex = blockIndex / VECTOR_DIMENSION;
uint groupSubindex = blockIndex % VECTOR_DIMENSION;
// 探索対象のグループの中心座標
float2 uvScale = 1.0 / float2( groupCount2d );
float2 baseUv = float2( MortonIndex2d( groupIndex ) );
float2 deltaUv = float2( 0.5, 0.5 );
float2 uv = deltaUv;
// 目的のインデックスが属するユニットの,ブロック内での位置を求める
uint count = 0;
{
uint dim = groupSubindex;
[ loop ]
for ( float lod = maxLod - 1; lod >= 0; --lod )
{
// 一つ下の LoD へ進む
deltaUv *= 0.5;
// 周囲の3つのカウントを取得する.右上のカウントは取得する必要がない.
float2 sampleUvs[ 3 ] = {
( baseUv + uv + float2( -1, -1 ) * deltaUv ) * uvScale,
( baseUv + uv + float2( 1, -1 ) * deltaUv ) * uvScale,
( baseUv + uv + float2( -1, 1 ) * deltaUv ) * uvScale
};
uint3 counts = uint3(
GetParticleCount( sampleUvs[ 0 ], lod )[ dim ],
GetParticleCount( sampleUvs[ 1 ], lod )[ dim ],
GetParticleCount( sampleUvs[ 2 ], lod )[ dim ]
);
counts = count + uint3( counts.x,
counts.x + counts.y,
counts.x + counts.y + counts.z );
// カウントを累積して,目的のインデックスがどの領域に属するか判定する
float tmpCount = float( count ); /* 最適化対策 */
if ( targetLocalIndex < counts.x )
{
uv += float2( -1, -1 ) * deltaUv;
}
else if ( targetLocalIndex < counts.y )
{
tmpCount = counts.x;
uv += float2( 1, -1 ) * deltaUv;
}
else if ( targetLocalIndex < counts.z )
{
tmpCount = counts.y;
uv += float2( -1, 1 ) * deltaUv;
}
else
{
tmpCount = counts.z;
uv += float2( 1, 1 ) * deltaUv;
}
count = uint( tmpCount );
}
}
// 求めた座標を ParticleDataTexture のスケールに変換する.
uint2 unitBaseIndex2d = uint2( uv * blockSize2d ) * unitSize2d;
uint unitBaseIndex = DemortonIndex2d( unitBaseIndex2d );
// ユニット内で目的のインデックスを探す
uint remainCount = targetLocalIndex - count;
uint indexOffset = 0;
{
[ unroll( COUNT_UNIT_SIZE ) ]
for ( uint i = 0; i < COUNT_UNIT_SIZE; ++i )
{
uint2 sampleIndex2d = unitBaseIndex2d + MortonIndex2d( i );
ParticleData particleData = GetParticleData( sampleIndex2d );
if ( particleData.isActive && particleData.spec == blockIndex )
{
if ( 0 == remainCount )
{
indexOffset = i;
break;
}
remainCount--;
}
}
}
activeIndex = unitBaseIndex + indexOffset;
return true;
}
float4 frag( v2f IN ) : SV_Target
{
float4 output = float4( 0, 0, 0, 0 );
float2 uv = IN.uv;
float4 texelSize = _ParticleDataTexture_TexelSize;
uint2 index2d = uint2( uv * texelSize.zw );
uint index = DemortonIndex2d( index2d );
uint activeIndex;
bool isFound = GetActiveParticleIndex( index, activeIndex );
if ( isFound )
{
ParticleData particleData = GetParticleData( activeIndex );
output = SetParticleData( particleData );
}
return output;
}
ENDCG
}
}
}
在Blit()中执行。
CompactSparseTextureInParallelControl.cs
CompactSparseTextureInParallelControl.cs
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace Test.CompactSparseTextureInParallel
{
public class CompactSparseTextureInParallelControl : MonoBehaviour
{
public enum CompactionBlitPass
{
ParticleData,
Count,
Compact
}
[SerializeField] private Material material;
[SerializeField] private RenderTexture particleDataTexture;
[SerializeField] private RenderTexture countTexture;
[SerializeField] private RenderTexture compactTexture;
public void UpdateCompaction()
{
Graphics.Blit(null, particleDataTexture, material,
(int)CompactionBlitPass.ParticleData);
Graphics.Blit(null, countTexture, material,
(int)CompactionBlitPass.Count);
Graphics.Blit(null, compactTexture, material,
(int)CompactionBlitPass.Compact);
}
}
#if UNITY_EDITOR
[CustomEditor(typeof(CompactSparseTextureInParallelControl))]
public class CompactSparseTextureInParallelControlEditor : Editor
{
public override void OnInspectorGUI()
{
DrawDefaultInspector();
EditorGUILayout.Space();
var control = (CompactSparseTextureInParallelControl)target;
if (GUILayout.Button("Update Compaction"))
{
control.UpdateCompaction();
if (!Application.isPlaying)
{
SceneView.RepaintAll();
}
}
}
}
#endif
}
设置材质和纹理。根据本次设置,“Count Texture”的大小将是“Particle Data Texture”的四分之一。请启用“Auto Generate Mipmaps”。
結果
通过检查器中的“Update Compaction”来执行。
雑記
其实没必要搞这么复杂,如果是1,024x1,024=1,048,576个粒子左右的话,用2048x2048的纹理就能计数,在PC上完全没问题,但在移动端就比较吃力了……虽然看起来不太优雅,但只要能跑起来就行。
虽然没特别说明,但即使粒子的数据大小不同,也能用一个纹理统一处理,这正是这种方法——或者说Morton Index的优点所在。
另外,使用这种方法的话,基数排序之类的操作会变得比较容易。
这又是题外话了,我觉得本文的思路是将一维的压缩扩展到二维。如果要扩展到三维的话,该沿着哪个轴来扩展呢?
我挺好奇的。
查看点赞用户列表
0
注册新用户并更便捷地使用Qiita
- 获取符合您需求的文章
- 您可以高效地回顾有用信息
- 您可以使用深色主题
来源:Qiita · Unity 开发社区 · qiita.com



