文章

HLSL 随机数生成方法

以 CasualPRT 项目 SurfelSampleCS.compute 中的实际用法为切入点,总结四种常用 GPU 随机数生成方法:sin-dot 哈希、Hammersley 低差异序列、PCG Hash、Dave Hoskins 系列 hash。

整理日期:2026-10-03


0. 为什么 GPU 随机数不一样

CPU 上通常用带状态的 RNG(如 Mersenne Twister、Xoshiro),但 GPU 上成千上万个线程并行执行,维护全局 RNG 状态代价高。因此 GPU 随机数的主流做法是:

  • 无状态哈希(stateless hash):把坐标/索引直接哈希成 [0,1) 的浮点数,线程之间零耦合;
  • 少量状态的流式 RNG:每个线程持有一个 uint 状态,逐步推进(PCG 是典型);
  • 低差异序列(quasi-random):根本不追求"随机",而是确定性、均匀分层的采样点分布。

另一个 GPU 特有的坑:浮点精度。所有基于 float 的哈希都依赖"大数乘法后取 frac 丢掉高位"来制造混沌,一旦编译器把变量优化成 half(移动端很常见),精度直接崩掉,输出会出现明显 banding。整数哈希(PCG)则完全没有这个问题,且跨驱动/跨平台位级一致。


1. sin-dot 哈希 —— rand(float2 uv)

来源:Stack Overflow: Random noise functions for glsl

float rand(float2 uv)
{
    return frac(sin(dot(uv, float2(12.9898, 78.233))) * 43758.5453);
}

逐层拆解

步骤 作用
dot(uv, float2(12.9898, 78.233)) 把 2D 输入投影到固定方向,压缩成一个标量。两个系数是"搅拌"用的魔数,无特殊含义
sin(...) 映射到 [-1, 1]。sin 是高频振荡函数,输入微小变化 → 输出剧烈变化,起到打乱作用
* 43758.5453 放大到很大的数值范围。GPU 浮点精度有限,大数乘法让低位精度大量丢失,进一步打散 bit
frac(...) 只留小数部分,得到 [0, 1)

本质:利用浮点精度损失的混沌效应做廉价哈希。相同输入永远得到相同输出(确定性),不同输入的输出看起来均匀分布。

项目中的原始用法(SurfelSampleCS.compute)

float2 xy = float2(id.x, id.y) / float2(32, 16);  // 线程 ID 归一化
xy += float2(1, 1) * _randSeed;                    // 逐帧偏移,换一组随机数

float u = rand(xy * 1.0);   // 乘不同系数得到"独立"的两路随机
float v = rand(xy * 2.0);
float3 dir = UniformSphereSample(u, v);  // u, v → 球面均匀方向

优缺点

  • ✅ 一行搞定,零整数运算,写起来最快
  • ✅ 教科书级流行,Shadertoy/教程里随处可见
  • ❌ 质量差:大网格上有可见的相关性/条纹图案
  • ❌ 精度敏感:half/min16float 下 frac 之前精度全丢,出现 banding;不同驱动的 sin 实现结果还可能不一致
  • ❌ sin 本身是较贵的超越函数

适用:原型、demo、对质量无要求的场合。


2. Hammersley 低差异序列 —— UE4 MonteCarlo.usf

来源:Unreal Engine 4, Engine/Shaders/MonteCarlo.usf(现已迁移为 Private/MonteCarlo.ush)

关键认知:UE 的 MonteCarlo.usf 里根本没有 sin-hash 这类函数。UE 采样分布函数(UniformSampleSphere 等)只接受现成的随机数 E,而 E 由 Hammersley 序列生成。

// ref: Unreal Engine 4, Common.ush
// 32 位比特反转:Hammersley 序列的核心操作
// E2 的本质是 Van der Corput 序列(radical inverse),让相邻 Index 的采样点天然错开
uint ReverseBits32(uint bits)
{
    bits = (bits << 16) | (bits >> 16);
    bits = ((bits & 0x55555555) << 1) | ((bits & 0xAAAAAAAA) >> 1);
    bits = ((bits & 0x33333333) << 2) | ((bits & 0xCCCCCCCC) >> 2);
    bits = ((bits & 0x0F0F0F0F) << 4) | ((bits & 0xF0F0F0F0) >> 4);
    bits = ((bits & 0x00FF00FF) << 8) | ((bits & 0xFF00FF00) >> 8);
    return bits;
}

// ref: Unreal Engine 4, MonteCarlo.usf
float2 Hammersley(uint Index, uint NumSamples, uint2 Random)
{
    float E1 = frac((float)Index / NumSamples + float(Random.x & 0xffff) / (1 << 16));
    float E2 = float(ReverseBits32(Index) ^ Random.y) * 2.3283064365386963e-10;
    return float2(E1, E2);
}

原理

  • E1:均匀格点 Index/NumSamples + 种子抖动(Random.x 低 16 位归一化到 [0,1));
  • E2:位反转即 Van der Corput 序列(radical inverse base-2)——Index=1,2,3,4… 映射到 0.5, 0.25, 0.75, 0.125…,相邻索引的点天然错开;异或种子再做 scrambling,逐帧换一套点分布;
  • 2.3283064365386963e-10 就是 1/2^32。

与白噪声的本质区别:白噪声每个点是独立随机的,而低差异序列是确定性、分层均匀的——512 个点在 [0,1)² 上的覆盖比 512 个白噪声点均匀得多,蒙特卡洛积分收敛更快。

UE 的采样管线

// UE: E = Hammersley(i, N, Random) → dir = UniformSampleSphere(E).xyz
uint surfelIndex = id.x * 16 + id.y;   // 序列索引兼缓冲区下标

float2 E = Hammersley(surfelIndex, 512, uint2(asuint(_randSeed * 157.0), asuint(_randSeed * 911.0)));
float3 dir = UniformSphereSample(E.x, E.y);

NumSamples = 512 = 32×16 线程总数,每线程取序列中一个点,球面覆盖天然分层均匀。

注意:NumSamples 要等于真实总采样数。Index > NumSamples 时 E1 的格点会回绕,破坏分层性。

优缺点

  • ✅ 收敛快,同等采样数下方差显著低于白噪声
  • ✅ 确定性 + 种子可扰动,烘焙类任务(PRT、SH、IBL 预积分)首选
  • ✅ 只需位运算和一次 frac,很便宜
  • ❌ 不是白噪声——做 TAA/Denoiser 输入时时间域上会"周期感",需要换序列或抖动
  • ❌ E1 的抖动上限只有 16 bit 精度(UE 的实现取舍)

适用:离线/预计算积分、重要性采样、任何"固定 N 个样本摊平球面/半球/圆盘"的场景。


3. PCG Hash —— 整数哈希的性价比之王

来源:Melissa O'Neill, PCG: A Family of Simple Fast Space-Efficient Statistically Good Algorithms for Random Number Generation;GPU 社区通用版本出自 Nathan Reed (reedbeta) 的博客 与 Jarzynski & Olano 论文 Hash Functions for GPU Rendering (JCGT 2020)。

Blender EEVEE、AMD Capsaicin、Unity 衍生项目里都是同一个实现:

// ref: reedbeta.com "Hash Functions for GPU Rendering" / pcg-random.org
uint pcg_hash(uint input)
{
    uint state = input * 747796405u + 2891336453u;
    uint word  = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
    return (word >> 22u) ^ word;
}

变体一:每线程带状态的流式 RNG

PCG 本来是带状态的生成器,每线程持有一个 uint 即可:

uint pcg_random(inout uint rngState)
{
    rngState = rngState * 747796405u + 2891336453u;
    uint word = ((rngState >> ((rngState >> 28u) + 4u)) ^ rngState) * 277803737u;
    return (word >> 22u) ^ word;
}

// 用法:uint rng = surfelIndex * 2654435769u + asuint(_randSeed);
//      float r0 = pcg_random(rng) * (1.0 / 4294967296.0);
//      float r1 = pcg_random(rng) * (1.0 / 4294967296.0);  // 连续取不重复

变体二:多通道无状态哈希 pcg3d

需要 2~4 个"独立"随机数时,别再像 sin-hash 那样乘系数凑——直接用向量化版本(Jarzynski & Olano):

// ref: Jarzynski & Olano, "Hash Functions for GPU Rendering" — pcg3d
uint3 pcg3d(uint3 v)
{
    v = v * 1664525u + 1013904223u;
    v.x += v.y * v.z;
    v.y += v.z * v.x;
    v.z += v.x * v.y;
    v ^= v >> 16u;
    v.x += v.y * v.z;
    v.y += v.z * v.x;
    v.z += v.x * v.y;
    return v;
}

// 用法:uint3 r = pcg3d(uint3(surfelIndex, asuint(_randSeed), 0));
//      float3 rand01 = (float3)r * (1.0 / 4294967296.0);

uint → float 的正确姿势

float rand01 = float(pcg_hash(seed)) * (1.0 / 4294967296.0);  // 2^-32,[0,1)

优缺点

  • ✅ 统计质量顶级:通过 TestU01 BigCrush;Jarzynski 论文全维度评测中质量/速度综合最优
  • ✅ 纯整数运算:无精度坑、half 优化不影响它、跨驱动位级一致(回放/比对调试时极其重要)
  • ✅ 极快:两条乘加 + 移位异或,延迟低于一次 sin
  • ✅ 状态仅 4 字节,流式取任意多随机数
  • ❌ 代码不如 sin-hash 直观,需要理解"线性同乘 + 输出置换"的结构

适用:实时渲染里的通用随机(TAA 抖动、光线追踪、Path Tracer、SSAO 噪声),想要"就一个靠谱默认值"时选它。


4. hash21 / hash12 —— Dave Hoskins 系列

来源:Dave Hoskins, Hash without Sine(2013 博文 + Shadertoy 系列)。Hoskins 的动机很直接:把 sin 从哈希里去掉,规避移动端 sin 精度问题并提速。

经典版 hash21(Shadertoy 社区流传最广的形式)

// ref: Dave Hoskins
float hash21(float2 p)
{
    p = frac(p * float2(123.34, 345.45));
    p += dot(p, p + 34.345);
    return frac(p.x * p.y);
}

思路:先 frac 截断输入、再让 p 与自身做 dot 产生"自反馈"乘积 p.x * p.y,最后一次 frac 输出。没有 sin,全靠乘法链的精度丢失制造混沌。

新版 "Hash without Sine"(0.1031 常量系列,质量更好)

// 1 out, 2 in
float hash12(float2 p)
{
    float3 p3 = frac(float3(p.xyx) * .1031);
    p3 += dot(p3, p3.yzx + 33.33);
    return frac((p3.x + p3.y) * p3.z);
}

// 2 out, 2 in(直接返回 float2,不用乘系数凑独立随机数)
float2 hash22(float2 p)
{
    float3 p3 = frac(float3(p.xyx) * float3(.1031, .1030, .0973));
    p3 += dot(p3, p3.yzx + 33.33);
    return frac((p3.xx + p3.yz) * p3.zy);
}

同系列还有 hash11(1→1)、hash13(3→1)、hash33(3→3),常量家族固定为 0.1031 / 0.1030 / 0.0973 / 33.33。

用法对照(替换项目里的 sin-hash)

float u = hash12(xy + _randSeed);
float v = hash12(xy + _randSeed + 17.17);
// 或一次出两路:
float2 uv = hash22(xy + _randSeed);

优缺点

  • ✅ 无 sin:移动端安全,比 sin-hash 快
  • ✅ hash22 一类直接返回多维输出,通道间独立性比"乘 2.0 再 hash 一遍"好得多
  • ✅ 图案与相关性明显少于 sin-hash
  • ❌ 仍是 float 运算链:输入坐标很大时 frac 精度衰减(世界坐标建议先减去 tile 原点/取模),且跨驱动不保证位级一致
  • ❌ 质量仍不及整数哈希 PCG

适用:Shadertoy 风格的程序化噪声、Value Noise 的晶格哈希、低维度小坐标域的日常着色。


5. 横向对比

sin-hash Hammersley (UE) PCG Hash hash21/12 (Hoskins)
类型 白噪声哈希(浮点) 低差异序列(拟随机) 整数哈希 / 流式 RNG 白噪声哈希(浮点)
分布质量 差(有条纹、相关性) 分层均匀,收敛最快 优秀(BigCrush 级) 中等偏上
多维输出 乘系数凑,通道相关 天然 2D pcg3d/4d 向量化,独立性好 hash22 直接多维
精度风险 高(half 崩、sin 实现差异) 低(核心是整数位运算) 无(纯整数,跨驱动一致) 中(大坐标 frac 衰减)
速度 慢(sin) 极快(移位掩码 + 1 次 frac) 极快(乘加 + 移位) 快(无 sin)
确定性/可扰动 种子偏移 种子 scrambling 种子/状态任意 种子偏移
典型用途 原型、demo 烘焙、重要性采样、MC 积分 实时渲染通用默认 噪声函数、着色小技巧

6. 选型建议

  • 烘焙/预计算(SH、PRT、IBL 预积分) → Hammersley。固定样本数摊匀球面/半球,收敛快是硬指标。本项目的 Surfel 采样就属于这类。
  • 实时渲染的通用随机(TAA 抖动、路径追踪、AO 噪声) → PCG。质量与速度的最佳平衡,纯整数无跨平台坑;要多个随机数用 pcg3d,要连续流式取样用 inout uint 状态版。
  • 噪声函数(Value/Simplex 晶格哈希)、小坐标域 → Hoskins hash12/hash22。快、够用、无 sin。
  • 原型验证/一次性效果 → sin-hash,能跑就行,别带进生产。
  • 绝对不要:把 sin-hash 写进会被编译成 half 的移动端管线;用"乘 2.0 再 hash"的方式伪造独立多维随机数(用 hash22/pcg3d 代替)。

7. 参考

许可协议:  CC BY 4.0