Forward+ 分块装不下所有光源时,不能按遍历顺序截断
同一个房间、同一台相机、同一组局部光源,只是把场景对象的遍历顺序换了一次,墙上的补光就从橙色变成蓝色。若 Forward+ 每个屏幕分块只保存固定数量的光源索引,这种变化不一定来自 Shader:候选数超过容量后,CPU 或 Compute Shader 若简单保留“最先遇到的 N 个”,遍历顺序已经悄悄变成了画面规则。
定长列表本身没有错。它让每个分块的索引存储有明确上界,也让后续光照循环可控。错误在于溢出没有选择合同:被丢弃的光源既不一定最弱,也无法在相同输入下稳定复现。容量只能限制结果数量,不能替代排序依据。

光源中心在分块外,不代表它没有贡献
探针把屏幕分块固定为 (0,0)..(16,16),列表容量固定为 3。每个候选带有屏幕中心、影响半径与强度。这里没有用“光源中心是否落入分块”作为相交条件,而是先求光源中心到矩形最近点的距离:
private static float Contribution(TileBounds tile, LightCandidate light)
{
var closestX = Math.Clamp(light.ScreenX, tile.MinX, tile.MaxX);
var closestY = Math.Clamp(light.ScreenY, tile.MinY, tile.MaxY);
var dx = light.ScreenX - closestX;
var dy = light.ScreenY - closestY;
var distanceSquared = dx * dx + dy * dy;
var radiusSquared = light.Radius * light.Radius;
if (distanceSquared >= radiusSquared)
return 0f;
return light.Intensity * (1f - distanceSquared / radiusSquared);
}
固定候选中的光源 5 位于分块下方,中心不在矩形内,但半径仍覆盖分块边缘,贡献度为 0.6750。它高于另一个相交候选,因此容量为 3 时应被保留。另一个远处光源 88 即使强度更高,只要影响圆与分块不相交,贡献就是零,不能占据索引槽位。
这套分数不是完整的光照重要性模型。它没有考虑深度切片、材质响应、阴影成本或曝光,只给当前探针一个可复算的选择依据。生产渲染器可以换成球体与分块视锥相交后的上界估计,但“中心在外即排除”仍不是可靠合同。
容量达到上限时,需要全序而不是早停
相交候选按贡献度降序排列;贡献度相同,再按稳定光源 ID 升序。次键很重要:并行收集或容器重排可能改变输入顺序,但不应改变同分候选的最终身份。
$LAB_DIR/Program.cs 中的 TileLightSelector.BuildAndCommit 保留了完整提交边界:
ranked.Sort(static (left, right) =>
{
var byScore = right.Score.CompareTo(left.Score);
return byScore != 0 ? byScore : left.Id.CompareTo(right.Id);
});
var selected = ranked.Take(capacity).ToArray();
Active = new TileLightSnapshot(
revision,
selected,
Math.Max(0, ranked.Count - selected.Length));
测试把同一候选数组反转后重新选择,两次结果都为 41>12>5;两个同分光源以 ID 得到 2>9。这说明列表内容由候选属性决定,不再依赖调用者恰好怎样枚举场景。OverflowCount 同时记录未进入活动列表的相交候选数量,使“容量刚好够用”和“已经发生画面降级”不再共享同一个成功状态。

简单全排序的复杂度为 O(L log L),其中 L 是当前分块的相交候选数。这里没有据此声称它适合直接搬进 GPU。真实 Forward+ 可以使用固定容量小顶堆、分组归约或选择算法,把工作收敛到 O(L log K) 或并行近似选择,K 为分块容量;但任何实现都应产出同一类合同:确定的比较键、明确的容量和可观测的溢出。
非有限值不能进入比较器
贡献度参与排序,NaN 会破坏比较关系。若一部分候选已经写入活动数组后才发现非法半径,当前帧可能留下半份新列表;若把 NaN 当作最低分继续执行,资产或裁剪错误又会被伪装成普通溢出。
探针先校验整批候选的 ID、坐标、半径和强度。出现非法光源时返回 InvalidLight,不会替换此前的 TileLightSnapshot。同样,容量小于等于零或分块边界反向也会保持活动修订。这不是要让渲染长期显示旧列表,而是让失败具有稳定后果:本帧沿用上一份可解释状态,同时由诊断计数暴露数据问题。
最终验证命令与决定性输出如下:
cd "$LAB_DIR"
dotnet build ForwardPlusTileLab.csproj -c Release --no-restore
dotnet run --project ForwardPlusTileLab.csproj -c Release --no-build
Build succeeded. 0 Warning(s) 0 Error(s)
normal result=Committed selected=41>12>5 overflow=1
scores=41:1.0000,12:0.8000,5:0.6750
boundary result=InvalidLight active_revision_before=7 active_revision_after=7 selected=41>12>5
tests pass=8 fail=0 skip=0 duration_ms=71.34
构建与运行退出码均为 0,墙钟耗时分别为 7.88 秒和 2.44 秒。这些时间只证明本次验证实际完成,不构成 CPU 或 GPU 性能结论。八项检查覆盖溢出选择、输入重排、同分次键、不相交排除、溢出计数,以及非法光源、容量和边界均不污染活动快照。
这个切片还没有解决跨帧闪烁。两盏光源贡献度在容量边缘反复交叉时,即使每帧选择都确定,画面仍可能频繁换灯;那需要在贡献度上增加滞回或为已入选光源保留有限偏置。容量长期溢出则是另一种信号:应调整分块尺寸、深度切片、光源影响范围或列表预算,而不是继续隐藏计数。
回到开头那面改变颜色的墙,真正需要固定的不是场景遍历顺序,而是分块在容量不足时怎样裁决。相交测试排除无关候选,贡献度与稳定 ID 建立全序,溢出计数说明当前画面正在退化,非法数据则停在活动列表之外。具体排序算法可以随 GPU 架构变化,这四项合同不应随实现一起消失。