· 5 分钟阅读 · 1870 字
粒子数量正确仍会成团:发射器必须保留帧内出生时刻
每秒发射 10 个粒子,遇到一帧 250 毫秒的停顿,应补出两个粒子。如果都在帧末以年龄零创建,数量看似正确,粒子却会挤在发射点。固定发射时刻应是 100 和 200 毫秒;绘制这帧时,它们已经分别活了 150 和 50 毫秒。对静止发射器、每秒 20 米的匀速粒子,这意味着沿运动方向分别前进 3 米与 1 米,而非一起停在零点。

少发与成团来自不同的取整位置
逐帧执行 floor(rate × dt) 会丢掉不足一个粒子的余量。每秒 10 个、每秒约 30 帧时,每帧时间约为 33.3 毫秒,每帧都得到零,累计一秒仍发不出粒子。保存小数余量能够修复总数,但若拿到整数数量后统一在当前帧末创建,出生时刻仍被压缩到了同一个点。
本例把速率限定为固定的正整数,时间使用整数微秒。第 k 个事件的理论出生时刻为 k × 1,000,000 / rate 微秒,k 从 1 开始;零时刻没有初始粒子。一帧处理开左闭右区间 (old, end],截止时刻恰好发生的事件归当前帧,下一帧不会重复领取。
累计事件数 N(t) = floor(t × rate / 1,000,000)。当前帧领取 N(old)+1 到 N(end),因而计数不依赖这一秒被拆成多少帧。这里没有将输入时间转成浮点,也没有通过不断相加的浮点余量判断临界点。速率不能在两次调用之间直接修改;否则 N(old) 会被新速率重新解释,事件历史与编号都会改变。
时钟只在整批事件准备完成后推进
Emission.cs 中的 Emitter.Step 先求领取区间,再为每个事件计算出生时间和帧末年龄。Birth.Id 是精确整数序号,TimeUs 与 AgeUs 是双精度微秒值。以下是 .NET 9 控制台探针使用的完整核心实现,不依赖引擎对象。
namespace EmissionLab;
public readonly record struct Birth(long Id, double TimeUs, double AgeUs);
public sealed class Emitter
{
private const long SecondUs = 1_000_000;
private readonly int rate;
public long ClockUs { get; private set; }
public Emitter(int rate)
{
if (rate <= 0) throw new ArgumentOutOfRangeException(nameof(rate));
this.rate = rate;
}
private long CountAt(long time)
{
Int128 count = (Int128)time * rate / SecondUs;
if (count > long.MaxValue) throw new OverflowException();
return (long)count;
}
public Birth[] Step(long endUs)
{
if (endUs < ClockUs) throw new ArgumentOutOfRangeException(nameof(endUs));
long first = CountAt(ClockUs), last = CountAt(endUs);
if (last - first > 4096) throw new InvalidOperationException("birth budget");
var births = new Birth[(int)(last - first)];
for (int i = 0; i < births.Length; i++)
{
long id = first + i + 1;
double time = (double)id * SecondUs / rate;
// 先做整数差,避免长时间运行后的浮点相减丢失年龄精度。
double age = (double)((Int128)endUs * rate - (Int128)id * SecondUs) / rate;
births[i] = new Birth(id, time, age);
}
ClockUs = endUs;
return births;
}
}
整数乘法提升为 Int128,避免 long 时间与速率相乘先发生溢出。输出序号仍受 long 上限约束。年龄先用整数计算 end × rate − id × 1,000,000,再除以 rate;长时间运行时,这比用两个很大的浮点绝对时间相减更稳妥。TimeUs 的双精度表示仍不能保证在任意大的绝对时间上区分相邻微秒,消费者需要精确排序时应使用 Id。
单帧上限 4,096 个出生事件是本例明确选择的拒绝策略,不是 GPU 容量测量结果。超额请求抛出异常,旧时钟不变;调用方可以拆分同一时间区间后重试。它不会静默丢弃,也不会把积欠当作下一帧的新事件。若实际产品需要卡顿后丢弃,应显式推进逻辑时钟并记录丢弃数量,否则积欠会在后续帧继续追赶。生成记录后才提交时钟,也让参数、预算和计数溢出失败保留同一种恢复边界。

分帧回归比较事件,不比较最终总数
Program.cs 的 Check 检查固定结果与分帧对照,Reject 检查拒绝路径。一组对照将同一秒拆成 1 至 2,000 帧,另一组使用固定随机种子生成 1,000 套不规则边界;所有输入都包含同一个最终时刻。比较项是每个事件的 Id 和 TimeUs,单看总数无法发现事件被挤到帧末。
边界检查覆盖了 99,999、100,000、100,001 微秒:只有中间一次领取首个事件,且年龄为零。相同截止时刻再次调用得到空数组;时间倒退、非正速率、超预算与计数溢出均拒绝。每秒 3 个的附加输入给出 333,333.3333333333 微秒的首个出生时刻,说明整数输入时钟没有把事件强制量化到整数微秒。
cd "$WORK_DIR"
dotnet build "$LAB_DIR/EmissionLab.csproj" -c Release
dotnet run --project "$LAB_DIR/EmissionLab.csproj" -c Release --no-build
$LAB_DIR 保存 Emission.cs、Program.cs 与项目文件。2026-10-24 的实际结果如下;运行耗时包含断言和分帧扫描,不代表帧预算或 GPU 性能。
build: exit=0, warnings=0, errors=0, elapsed_s=8.28
run: exit=0, passed=24, failed=0, skipped=0
elapsed_ms=716.6489
partition_cases=3000, partition_mismatches=0
wrong_30fps_count=0, correct_count=10
Step(250000): id=1, birth_us=100000, age_us=150000
Step(250000): id=2, birth_us=200000, age_us=50000
frame_end_batching: age_us=[0,0]
决定性失败样本可以保留在接入侧,防止只修复数量的实现重新出现:
var births = new Emitter(10).Step(250000);
Check(births[0] == new Birth(1, 100000, 150000));
Check(births[1] == new Birth(2, 200000, 50000));
var blocked = new Emitter(10);
Reject(() => blocked.Step(409700000), typeof(InvalidOperationException));
Check(blocked.ClockUs == 0);
出生记录还需要接上历史轨迹
静止发射器的匀速案例只需用速度乘年龄初始化位置,寿命、颜色曲线也应从相同年龄采样。存在加速度时,应推进对应的运动解;若出生到帧末已经超过寿命,则该事件可以不进入可见缓冲,但事件序号仍然已经消耗。把所有补发粒子先按零年龄放入,再正常更新整个 dt,会走向相反错误:晚出生的粒子被多推进了一段时间。
移动发射器还需要在出生时刻重建位置与方向。用当前帧末的发射器变换配合正确年龄,只解决了粒子自身的运动时间,没有恢复喷口轨迹。仅有前后两个变换时可以做明确的插值近似;快速转向或瞬移则需要轨迹采样、断段规则。本文没有验证这部分引擎集成,也没有测试 GPU 上传和分配成本。
时钟由单个发射实例拥有,调用方提交单调的模拟时间;暂停、时间缩放与回放都应在这个时间域内定义。实例重新启用时,是否清零序号也会影响按 Id 派生的随机属性,不能只重置画面。固定速率下,分帧一致性已经可以用事件序列验收;变速发射应改用累计发射量的分段积分,并保存速率变化边界。小数余量适合解决“发多少”,出生时刻则决定这些粒子在本帧应当已经走到哪里。