下载 App
10/211 浏览案例分享
【案例分享】静默失败定位手册:五种把「没反应」变成读数的手段
投稿方向:星火编辑器论坛「案例分享」专区 / 生态贡献度
类型:方法论 + 图文案例
素材来源:《别停下》开发实录(01 径向渐变 / 03 静默陷阱)的实测记录
一句话结论
静默失败的共同点是没有反馈通道:不报错、不崩、画面正常,只能盯着屏幕猜。要定位它,先造一条能读的通道。
五种手段按成本从低到高排:对照实验隔开变量,读数化把"看起来没变"变成一列数,最小复现砍到只剩触发路径,时序探针在同一帧看谁先到,断言式日志让日志能失败。都不需要引擎新功能,只是换个方式记录已有状态。
手段一:对照实验 —— 一次只改一个变量
做法:固定其它量,只改要验的那一个,两次结果并排。两行读数一样,说明这个变量没被消费;不一样,才叫"生效"。
03 的硬币就是标准对照:硬币厚度 0.03m,其它都不动,只换 margin。
margin:碰撞厚度 偏差
默认 0.04:0.11m +267%
0.01:0.05m +67%
同一枚硬币、同一位置、只差一个参数,厚度差一倍多。03 另一组也是单变量:rollingFriction 取 0 / 0.05 / 0.25+,结果是正常 / 偶尔卡住 / 斜着不倒或弹飞。
不用它会怎么误判:没有这张对照表,"硬币像在漂浮"会被归因到模型原点、缩放、质量上。改完画面会好一点,但不是 margin 那一个变量,等于把误差藏起来。
手段二:读数化 —— 把「看起来没变」变成一列数
做法:找一条随效果单调变化的量(亮度、计数、帧时间),沿固定方向采样成列。人眼判"差不多",数轴判"完全一样"。
01 用的就是径向亮度剖面:从中心往外按半径环采样,取每环像素亮度的中位数。
python
for r in range(0, 720, 20):
px = sample_circle(img, cx, cy, r) # 该半径环上的像素
print(r, median_brightness(px))
读数是:半径 0~240 中位亮度 15,恰好是背景色 nvgRGBA(11,15,26) 的亮度;半径 260~720 中位亮度 63/90,是氛围灯色。两张不同动量的截图剖面完全重合,边界固定在半径约 250,而代码里传的 inner 换算过去是 514。250 ≈ 0.35 × min(W,H) = 0.35 × 720 = 252,正是引擎默认值。
不用它会怎么误判:01 的记录里,前七版都在调 inner、outer 和颜色。没有这列数,"剖面一样"会被解释成"动量还不够大",于是继续加大参数。
手段三:最小复现 —— 把工程砍到只剩触发路径
做法:一个节点、一套最小装配、一次调用。能复现,说明跟业务代码无关;不能复现,就逐个加回去,最后加回去的那件就是触发条件。产物不一定是个能跑的工程,有时是一份"最小必需装配清单":去掉哪件就静默失效。
03 的角色控制器就是这份清单:只创建 KinematicCharacterController 再调 SetWalkDirection,角色不走、不受重力、也无任何报错。三件组件配齐才动起来,第三件 CharacterComponent 负责把物理位置写回 node。
lua
local function buildWalker(node, radius, height, dir, speed)
local shape = node:CreateComponent("CollisionShape")
shape:SetCapsule(radius, height)
local kcc = node:CreateComponent("KinematicCharacterController")
local cc = node:CreateComponent("CharacterComponent") -- 少这行:物理在走,画面不动
kcc:SetWalkDirection(dir * speed)
-- 装配清单断言:读回节点上实际挂着的组件,三件不齐就点名
for _, name in ipairs({ "CollisionShape", "KinematicCharacterController", "CharacterComponent" }) do
if node:GetComponent(name) == nil then
print("[KCC] 装配缺件: " .. name)
end
end
end
不用它会怎么误判:在完整工程里查"角色不走",会先怀疑输入、动画、相机。每条都要单独排查,且全不报错,成本叠加。
手段四:时序探针 —— 在同一帧分点,看谁先到
做法:给每帧一个序号,同一对象在两个点各取一次读数,比较"第一次变化发生在哪一帧"。谁先出现,断点就在两者之间。
03 给过一组判读规则,正好是两路读数:node 坐标在变、画面不动,说明物理在算但没写回;node 坐标也不变,说明物理没在算。只看画面时,两种情况都是"不动"。
lua
local probe = { frame = 0, last = nil, moveFrame = nil, drawFrame = nil }
local function tick(node, drawnThisFrame)
probe.frame = probe.frame + 1
local p = node.position
if probe.last and (p.x ~= probe.last.x or p.z ~= probe.last.z) then
probe.moveFrame = probe.moveFrame or probe.frame
end
if drawnThisFrame then
probe.drawFrame = probe.drawFrame or probe.frame
end
probe.last = { x = p.x, z = p.z }
print(string.format("[PROBE] f=%d move=%s draw=%s", probe.frame,
tostring(probe.moveFrame), tostring(probe.drawFrame)))
end
读数只有三种:move 有、draw 没有,断在写回或渲染;两个都有,这条路径是通的;两个都没有,物理没算。
不用它会怎么误判:把"物理没算"和"没写回"当成同一个 bug,要么翻渲染代码,要么调物理参数,两边都可能改坏。画面是同一张"不动",读数才分得开。
手段五:断言式日志 —— 让日志有可能失败
做法:不打印"我做了什么",打印"读回来是什么"。print("隐藏完成") 只证明这行被执行,不证明效果存在;打印读回来的有效状态,它才会在错时叫。
03 的第三例:node:SetEnabled(false) 后,子节点上的 StaticModel / AnimatedModel 仍可见。根因是 Component:IsEnabledEffective() 只检查直接父节点,不递归祖先。
lua
-- 禁用父节点,然后读回子节点上渲染组件的有效状态
local model = childNode:GetComponent("StaticModel")
if model == nil then
print("[VIS] 子节点上没有 StaticModel,这条断言不适用")
else
parentNode:SetEnabled(false)
print("[VIS] 父节点已禁用,子组件有效状态 = " .. tostring(model:IsEnabledEffective()))
end
按 03 记录的规则推断:直接父节点 childNode 自己仍启用,所以读回来是 true,日志当场指出没生效的那一项,不用等肉眼发现模型还在。修法是 SetDeepEnabled(false),同一段日志随之翻成 false。
不用它会怎么误判:日志写"隐藏完成",画面还在,你会去查模型资源、层级顺序、渲染排序,三个方向都不对。能失败的日志把范围压到"祖先没递归检查"这一条。
症状 → 先用哪个手段
症状:先做什么 手段
参数改了,画面完全没变:同一输入只改这一个参数,两行并排 对照实验
"好像变了又好像没变":取一条读数,沿固定方向采样成列 读数化
配了但不工作,且无报错:砍到一个节点加最小装配,看还复现吗 最小复现
数值和画面都不动,分不清哪段:同一帧两个点各取一次,比谁先变 时序探针
加了一段逻辑后老功能没了:把"完成了"换成读回的状态断言 断言式日志
物体行为怪但看得见:默认值 vs 显式值,单变量对照 对照实验
踩过的坑
- 对照实验改了两个变量。两组都调了 margin 又改 friction,读数有差异也说不清是谁造成的。单变量是这条方法的全部价值。
- 读数口径前后不一致。01 的剖面按半径环取中位数,换张图改成取平均值,两次读数就不可比。采样位置、步长、聚合方式要写死在脚本里。
- 最小复现砍掉了触发条件。只留一个节点,问题消失了,就以为原工程也没事。复现失败时要逐个加回去,加回哪一步复现,触发条件就是那一步。
- 时序探针打在自己以为的帧边界上。同一帧里物理写入和绘制谁先谁后,决定 moveFrame 和 drawFrame 的含义。探针点选错,读数会把断点指反。
- 日志只证明执行、不证明生效。print("完成") 是最常见的假日志:它永远成功,所以永远没有信息量。
适用边界
- 五条都要求"能读到某个量"。完全黑盒、没有中间状态的场景,先造读数,再谈定位。
- 对照实验只在变量确实被消费时才有读数差异。参数被引擎忽略时(01 的 inner / outer),两行会一模一样,这不是实验失败,本身就是结论。
- 最小复现会改变时序和依赖关系。砍掉的东西含异步或初始化顺序时,最小工程可能不触发原 bug;这时保留时序骨架,只砍业务逻辑。
- 时序探针要求帧序号可信。帧率抖动、跳帧、多线程写回都会让"谁先到"的结论失真。
- 断言式日志依赖状态可读。IsEnabledEffective() 只查直接父节点,用它做断言时,断言语义必须和引擎检查范围一致,否则日志本身就在骗你。
可迁移的经验
1. 静默失败不是没有信息,是信息没被记录。五种手段做的是同一件事:把引擎已存在的状态换成能被比较的形式。
2. 先造读数,再改代码。没有读数的修改只能靠"看起来好了",下一轮还会踩回来。
3. 一次只动一个变量。对照组不是形式,它决定能否把因果从相关里分出来。
4. 最小复现的产出可以是一份清单。03 的三件组件就是"最小必需装配清单",比能跑的最小工程更常用。
5. 日志要能失败。不能失败的日志等于注释。
(完)
星火编辑器-新手教程-关于多游戏模式的配置和触发的获取截图
星火编辑器-新手教程-关于多游戏模式的配置和触发的获取
HELLO 大家好,我是阿木木牧。最近第一版的内部小范围(几人)第一个图的测试已经结束了。 整体游戏以装备数值的崩坏而告终~ (虽然大家都很欢快。。。) 现在准备开始开发下个阶段的内容啦,就是将游戏模式拆分出来。分成单人模式,多人组队模式(合作PVE),多人匹配模式(对抗模式)。那么,怎么配置呢?说来也很简单哈~ 1,在游戏编辑器中,数据编辑器,找到地图设置。 2,编辑地图设置。比如我希望最大数量
14 赞
3 回复
00:19
如何实现单位的随意拖拽摆放?【星火编辑器教程】截图
如何实现单位的随意拖拽摆放?【星火编辑器教程】
很多小伙伴想做自走棋类的游戏,那么就会涉及到单位的一个最基础的操作,就是选中单位并拖拽和摆放,那如何实现单位的拖拽摆放呢,今天就来教大家如何实现这样的功能。实现效果如下视频所示。 教程开始 第一步: 打开编辑器的触发编辑器,新建一个文件夹(我就叫“拖拽功能”,方便对拖拽功能的逻辑进行归类,如果你不建文件夹也没关系,习惯而已) 第二步: 点击文件夹右边的+号,添加一个变量,我这里叫“变量_目前选中的
19 赞
12 回复
无须申请,星火编辑器现已公测开放下载截图
无须申请,星火编辑器现已公测开放下载
—————————————————— 各位关注星火编辑器的小伙伴: 感谢大家一直以来对编辑器支持! 破晓已去,曙光降临! 星火编辑器现已开启公测! · 关于下载和资格申请 现已无需申请,请去下面的官方首页直接下载! 缘起测试是星火编辑器自诞生以来第一次对外测试! 从2022年八月开始到十一月,历时三个月。 缘起之后缘聚测试,历时又七个月! 缘聚之后,破晓降临,历时又十二个月! 曾经微弱的星火现已渐
官方
331 赞
375 回复