在Unity里做一扇能自动开合的门,几乎是每个刚接触交互场景的人都会撞上的第一个"真·需求"。看起来简单——玩家走近,门开;玩家离开,门关。但真动手做的时候,问题就冒出来了:门是开了,可玩家被门板顶住过不去;门关得太快,人还没走完就夹住了;用碰撞体做触发,结果门自己把自己卡死;更别提门轴位置没设对,整扇门像被踹飞一样绕着奇怪的点旋转。我见过太多项目里那扇门要么穿模、要么卡人、要么干脆不响应,最后被临时改成"永远敞开"糊弄过去。
这篇内容就围绕用触发器做可穿过的开关门这件事展开。核心关键词是Unity、触发器、开关门、可穿过、碰撞体、门轴旋转。它解决的是"角色靠近门时门自动打开、角色能顺利通过、离开后门自动关闭"这一整套交互闭环,适合刚上手Unity交互逻辑的新手,也适合想把手头那扇"半成品门"修好的朋友。我会把触发器的工作原理、门轴父子层级的搭法、碰撞体与触发器的分工、旋转插值的写法、以及一堆实测踩出来的坑,全部摊开讲清楚。你照着做,能拿到一扇真正能走过去的门,而不是一个只会转圈的门板模型。
1. 先把"可穿过"这件事想明白:触发器与碰撞体的分工
很多人做门失败,根子不在代码,而在一开始就没分清**碰撞体(Collider)和触发器(Trigger)**到底谁管什么。这两个东西在Unity里长得几乎一样,都是挂在物体上的绿色线框,但行为完全不同。你把它们混用,门要么推不动,要么穿不过,要么触发不了。
1.1 碰撞体负责"挡",触发器负责"感知"
先把这个底层逻辑钉死。碰撞体是物理系统里的实体,它会参与碰撞计算,角色撞上去会被挡住,子弹打上去会反弹,刚体会被它顶开。触发器则是把碰撞体的Is Trigger勾上之后的形态,它不再产生物理阻挡,物体可以直接穿过去,但物理系统仍然会告诉你"有东西进来了""有东西离开了"。一句话概括:碰撞体是墙,触发器是感应门帘。
放到门的场景里,这个分工就非常清晰了。门板本身需要一堵"墙"来挡住玩家吗?不需要,因为我们的目标是可穿过。所以门板上的碰撞体应该勾上Is Trigger,让它只负责感知玩家靠近,不负责物理阻挡。真正决定"门开没开、能不能过"的,是门板旋转到的角度——门转到90度,通道就露出来了,玩家自然能走过去。
这里有个反直觉的点要强调:可穿过的门,门板碰撞体必须是触发器,而不是实体碰撞体。我见过有人给门板挂实体碰撞体,然后指望靠旋转把玩家"让"过去,结果门转到一半玩家就被门板边缘顶飞了。因为实体碰撞体在旋转过程中始终参与物理计算,玩家贴着门走就会被推。勾上Is Trigger之后,门板对玩家完全"透明",只做感知,问题立刻消失。
1.2 为什么不用实体碰撞体加动画硬转
有人会问:那我给门板做一段开门动画,动画播放时门板转开,不也能过吗?能,但代价很大。动画驱动的门,玩家在动画播放中途走进门框范围,门板还在转,实体碰撞体照样会推人;而且动画和物理是两套时间轴,容易出现"门看着开了但碰撞体没跟上"的错位。用触发器加代码旋转,门板角度和通行状态是同一套逻辑控制的,什么时候能过、什么时候不能过,完全由你说了算,可控性高得多。
还有一层考虑是性能与简洁。一扇门用触发器加一个旋转脚本,几行代码搞定,不需要 Animator、不需要动画剪辑、不需要状态机。场景里几十扇门的时候,这个差距会被放大。所以除非门有复杂的开合特效(比如双开门带锁舌动画),否则触发器方案是首选。
1.3 门板、门框、触发区的三层结构
要让这套逻辑跑顺,场景里的层级得搭对。我的习惯是三层:
- 门框(DoorFrame):静态物体,不挂脚本,纯装饰,可选挂实体碰撞体防止玩家从门框边缘挤过去。
- 门板(DoorPanel):挂在门框下的子物体,位置对齐门轴,挂触发器碰撞体,挂开门脚本。
- 触发区(TriggerZone):一个独立的空物体或大号触发器,覆盖门前门后一片区域,专门负责感知玩家进出。
门板和触发区分开,是因为它们的职责不同:门板要旋转,触发区要覆盖足够大的范围。如果把触发区直接做成门板本身,那玩家必须贴到门板上才触发,体验很差——人都快撞门了门才开。触发区做大一圈,玩家还有两步距离门就开始转,走过去刚好开到位,这才是舒服的节奏。
2. 门轴父子层级:旋转中心错了,门就像被踹飞
门能不能"像门一样转",全看旋转中心设在哪。Unity里物体的旋转永远绕自己的Transform原点进行,原点在门板正中间,门就会像风车一样原地打转;原点在门板一侧边缘,门才会像合页一样开合。这个道理一说就懂,但实操里栽跟头的人特别多,因为模型导入时的原点位置往往不是你想要的。
2.1 用空物体做门轴,是最稳的做法
最省心也最不容易出错的搭法,是用一个空物体当门轴(Hinge),把门板作为它的子物体。具体操作:
- 在门框位置创建一个空物体,命名
DoorHinge,把它的位置精确放到门板要绕的那条竖边(合页所在的那条边)上。 - 把门板模型拖成
DoorHinge的子物体。 - 调整门板的本地坐标,让门板的合页边正好对齐
DoorHinge的原点。 - 开门脚本挂在
DoorHinge上,旋转DoorHinge的 Y 轴,门板就跟着绕合页转。
这么做的核心好处是:门板的模型原点在哪都无所谓,旋转中心由父物体DoorHinge决定。模型原点在正中间?没关系,只要它作为子物体摆好位置,父物体一转,它照样绕合页走。这比直接改模型原点、或者在代码里算偏移量要干净得多。
2.2 直接转门板会踩的坑
如果你图省事,直接把脚本挂在门板上转门板自己,那必须保证门板的Transform原点就在合页边上。可现实是,从建模软件导出的门板,原点十有八九在几何中心。这时候你转门板,它就会绕中心自转,看起来像一扇被人在中间钉了钉子然后硬掰的门,非常诡异。
有人会用代码补偿:先算出门板中心到合页的偏移向量,旋转时再把这个偏移反向加回去。这个思路数学上成立,但实现起来容易出错,尤其是门板有缩放、父物体有旋转的时候,偏移量的坐标系换算能把人绕晕。能用父子层级解决的,就别用数学硬算,这是我在场景搭建上的一条铁律。
2.3 门轴朝向与开门方向的对应关系
门轴放好了,还得确认开门方向对不对。绕 Y 轴旋转,正角度和负角度开向相反的两侧。你得根据门框的位置决定往哪边开:门往房间里开还是往走廊开,取决于DoorHinge的本地坐标轴朝向和旋转正负。
一个实用的判断方法:在 Scene 视图里手动把DoorHinge的 Y 旋转拖到 90 度,看门板往哪边倒。倒向你想要的方向,就记下这个正负号,代码里用同一个符号。如果倒反了,要么把角度取反,要么把DoorHinge的 Y 轴转 180 度再重新对齐门板。别在代码里反复试正负号,先在编辑器里目视确认,能省一半调试时间。
3. 触发检测的三种写法与它们的适用边界
触发器搭好了,接下来是"怎么知道玩家来了"。Unity里检测触发器进出,主流有三种写法:OnTriggerEnter/Exit、OnTriggerStay、以及基于物理查询的Physics.OverlapBox。它们各有脾气,选错了会出现"门开了不关""门反复抽搐""玩家站着不动门却关了"这类怪现象。
3.1 OnTriggerEnter 与 OnTriggerExit:成对使用才安全
最经典的是OnTriggerEnter进门开、OnTriggerExit出门关。写法简单,语义清晰。但这里有个必须注意的前提:进入和退出必须严格配对。如果玩家在触发区内被销毁、被传送走、或者触发区被禁用,OnTriggerExit可能不会触发,门就永远开着。
我的处理办法是加一个计数器而不是布尔值。触发区里可能同时有多个物体(玩家、NPC、掉落的道具),用int记录当前在区内的物体数量,进入加一、退出减一,数量大于零就开门,等于零就关门。这样即使某个物体异常消失导致退出没触发,也只是计数偏大、门多开一会儿,不会出现逻辑反转。当然更稳妥的是在OnTriggerExit里做一次兜底清理,但计数器已经能挡掉大部分问题。
还有一个细节:触发检测的对象要过滤。触发区里可能飞进来一颗子弹、一片落叶,它们也会触发OnTriggerEnter。所以进门时要用CompareTag或者检查组件,只对带特定标签(比如Player)的物体计数。不加过滤,门会被乱七八糟的东西开开关关。
3.2 OnTriggerStay:别用它做开关判断
OnTriggerStay每帧都在触发区内调用,很多人图省事用它:在区内就开门,不在就关门。这个写法能跑,但有两个隐患。第一,它每帧调用,性能上比 Enter/Exit 差,门多了会累积。第二,也是最坑的——它无法区分"刚进来"和"一直在里面",如果你在 Stay 里做开门动画的启动判断,会导致动画每帧被重置,门卡在第一帧抽搐。
OnTriggerStay的正确用途是"持续施加影响",比如站在治疗区里持续回血。用它做开关门这种"状态切换"逻辑,属于用错了工具。我一般只在需要"在区内期间持续做某事"时才碰它。
3.3 Physics.OverlapBox:适合需要即时查询的场景
第三种是主动查询:在Update里用Physics.OverlapBox检测触发区范围内有没有玩家。它的好处是不依赖物理回调,查询结果即时、可控,适合门需要在"玩家离开后延迟一小段再关"这种需要精确掌握时机的场景。坏处是每帧查询有开销,而且需要手动管理检测层(LayerMask),配置错了就查不到人。
三种写法对比如下:
| 写法 | 触发时机 | 性能 | 适用场景 | 主要坑点 |
|---|---|---|---|---|
| OnTriggerEnter/Exit | 进入/离开瞬间 | 低开销 | 绝大多数开关门 | 退出不配对导致门常开 |
| OnTriggerStay | 每帧 | 较高 | 区内持续效果 | 无法区分进入与停留 |
| Physics.OverlapBox | 主动查询 | 每帧查询开销 | 需要精确控制关闭时机 | 层配置错误查不到 |
对绝大多数开关门需求,Enter/Exit 加计数器是性价比最高的方案,我下面给的代码也基于这个思路。
4. 旋转插值:让门"转得像个门"而不是"啪一下弹开"
门能触发了,接下来是转得好看。直接transform.rotation = Quaternion.Euler(0, 90, 0)是瞬移式开门,门"啪"一下到位,毫无质感。要做成平滑开合,就得用插值。这里的选择和参数直接决定门的手感。
4.1 Lerp、Slerp 与 SmoothDamp 的取舍
Unity里常用的平滑手段有三个:Mathf.Lerp(或Vector3.Lerp)做数值插值、Quaternion.Slerp做旋转插值、Mathf.SmoothDamp做带阻尼的平滑。开门这种绕单轴旋转,用Quaternion.Slerp最自然,因为它保证旋转走最短路径、不会出现欧拉角翻转的怪相。
但Slerp有个经典陷阱:它每帧把当前值往目标值插,插值系数固定时,越接近目标越慢,永远到不了。所以要么用Time.deltaTime * speed作为系数(速度可控但仍有渐近),要么在接近目标时直接吸附。我的做法是:用Slerp做过程,同时判断当前角度与目标角度差小于某个阈值(比如0.5度)时,直接赋值到目标角度,避免门永远差一点点没关严。
SmoothDamp则适合"门被撞了一下要弹回"这种带惯性的效果,它内部维护速度变量,运动更柔和。但开关门这种确定性动作,Slerp加吸附已经足够,不必上SmoothDamp。
4.2 用角度变量驱动,而不是直接插值 Quaternion
更稳的写法是维护一个currentAngle浮点变量,每帧把它往目标角度(0 或 90)插值,再用Quaternion.Euler(0, currentAngle, 0)赋给门轴。这样做的好处是角度是标量,插值、判断、吸附都简单直观,不会陷入四元数的坑。代码大致是这样:
using UnityEngine; public class DoorController : MonoBehaviour { [Header("门轴与角度")] public Transform hinge; // 门轴空物体 public float openAngle = 90f; // 开门角度 public float closedAngle = 0f; // 关门角度 public float rotateSpeed = 3f; // 旋转速度 [Header("触发设置")] public string playerTag = "Player"; private int objectsInZone = 0; // 触发区内物体计数 private float currentAngle; // 当前角度 private float targetAngle; // 目标角度 void Start() { currentAngle = closedAngle; targetAngle = closedAngle; ApplyAngle(); } void Update() { // 角度向目标平滑逼近 currentAngle = Mathf.Lerp(currentAngle, targetAngle, Time.deltaTime * rotateSpeed); // 接近目标时吸附,避免永远差一点 if (Mathf.Abs(currentAngle - targetAngle) < 0.5f) currentAngle = targetAngle; ApplyAngle(); } void ApplyAngle() { hinge.localRotation = Quaternion.Euler(0f, currentAngle, 0f); } void OnTriggerEnter(Collider other) { if (!other.CompareTag(playerTag)) return; objectsInZone++; targetAngle = openAngle; } void OnTriggerExit(Collider other) { if (!other.CompareTag(playerTag)) return; objectsInZone = Mathf.Max(0, objectsInZone - 1); if (objectsInZone == 0) targetAngle = closedAngle; } }这段代码把"触发计数"和"角度插值"拆成两件事,逻辑非常清晰。objectsInZone保证多人或多次进出时门不会乱关,currentAngle的吸附保证门关得严实。
4.3 旋转速度与开门角度的实测调参
rotateSpeed这个值,实测下来 2 到 4 之间比较舒服。太小(比如 0.5)门慢悠悠转,玩家等得不耐烦;太大(比如 10)门"唰"一下,像被风吹开。90 度的门,rotateSpeed = 3大约半秒多开到位,节奏刚好。
openAngle不一定是 90 度。单开门 90 度够用;如果门框窄、门板宽,90 度可能门板会扫到旁边的墙,这时候开到 80 度甚至 75 度就够玩家通过,还能避免穿墙。开门角度要按门板宽度和门框空间实测,别默认 90 度就完事。我做过一扇窄走廊的门,90 度时门板末端正好戳进墙里,改成 78 度后既能让玩家过,又不穿模。
5. 那些让门"看着开了却过不去"的隐藏问题
代码跑通、门也转了,结果玩家还是过不去,或者过去时被莫名其妙顶一下。这类问题最磨人,因为门明明在动,逻辑看着也没错。下面几个是我踩过、也见别人踩过的典型坑。
5.1 门板触发器挡住了玩家?检查是不是忘了勾 Is Trigger
最常见的就是门板碰撞体没勾Is Trigger。门转开了,但实体碰撞体还在原地参与物理,玩家走过去被门板边缘推。回到第 1 节说的,门板碰撞体必须勾上Is Trigger。检查方法:选中门板,看 Inspector 里 Collider 组件的Is Trigger有没有打勾。没打勾,门转得再漂亮也过不去。
还有一种隐蔽情况:门板上有多个碰撞体(比如模型自带的 MeshCollider 加手动加的 BoxCollider),你只勾了其中一个的Is Trigger,另一个还是实体,照样挡人。这种情况要把所有碰撞体统一处理,或者干脆删掉多余的,只留一个触发器。
5.2 触发区太大,门在玩家还没到时反复开合
触发区做大了体验好,但做太大就出问题:玩家在远处走动,触发区边缘反复进出,门就开开关关抽搐。尤其是触发区和另一个触发区重叠、或者玩家在边界来回蹭的时候。
解决办法有两个。一是给触发区加一点"迟滞":进门用大范围,出门用小范围,或者关门加一个延迟(玩家离开后等 0.5 秒再关),避免边界抖动。二是控制触发区尺寸,覆盖门前门后各一到两个身位即可,别做成半个房间那么大。我一般用玩家胶囊体半径的 3 到 4 倍作为触发区半径,实测边界抖动基本消失。
5.3 玩家从门缝挤过去时被门板扫到
门开到一半,玩家急着过,门板还在转,实体部分扫到玩家。如果门板是触发器,这个不会发生;但如果门框、门板边缘有实体碰撞体,或者玩家身上有刚体,就可能被扫。处理思路是保证门板旋转路径上没有任何实体碰撞体,门框的实体碰撞体只放在门框立柱上,不放在门洞中间。
另外,玩家角色如果是CharacterController,它和触发器不产生物理推挤,只触发回调,所以门板触发器不会推它。但如果玩家是Rigidbody加碰撞体,就要确保门板是触发器,否则刚体会被旋转的门板弹飞。这也是为什么我一直强调门板必须触发器——它对两种角色控制器都友好。
5.4 门关了但玩家卡在门里
玩家站在门洞中间,门开始关,门板转过来把玩家夹住。触发器门板不会物理夹人,但视觉上门板会穿过玩家,很难看。解决办法是关门延迟加"区内有人不关"判断:只要触发区计数大于零,就不关门。这正好是第 3 节计数器方案的天然优势——玩家还在区内,objectsInZone不为零,targetAngle就不会切到关闭,门保持开着,等玩家完全离开才关。
如果玩家站在门洞正中间、触发区边缘刚好没覆盖到,那就得把触发区做大一点,确保门洞前后都在触发范围内。这也是触发区要覆盖门前门后一片区域的原因。
6. 从"能开"到"好用":几个提升手感的细节
门能开能关能过,算及格。但要让它用起来舒服,还有几个细节值得加。这些不是必须,但加上之后,门的手感会明显不一样。
6.1 开门音效与关门延迟的配合
门开始转的瞬间播一个"吱呀"声,转到位再播一个"咔"的落位声,关门同理。音效不用复杂,两个短音频就够。关键是音效触发时机要跟角度挂钩,而不是跟触发事件挂钩。玩家进门触发的是"开始转",音效应该在门真正开始动的那一帧播,而不是触发那一帧——如果门有延迟启动,音效提前播会显得脱节。
关门延迟我一般设 0.3 到 0.5 秒。玩家刚出门,门立刻关会显得急,等半秒再关,节奏自然。实现上就是在OnTriggerExit里不直接切targetAngle,而是启动一个计时器,计时结束再切。计时期间如果玩家又进来了,取消计时。
6.2 双开门与单向门的变体思路
双开门就是两个DoorHinge,一个往左开一个往右开,角度一正一负,共用同一个触发计数。单向门(只能从一侧开)则是在触发时判断玩家在门的哪一侧,只允许从指定侧触发。判断方法可以用玩家位置和门轴位置的叉乘,或者简单点用玩家在门轴本地坐标的 Z 值正负。
这些变体都不需要改核心逻辑,只是在触发判断和门轴数量上做文章。核心的"触发器计数 + 角度插值"骨架不变,这也是这套方案的好处——扩展性强。
6.3 用 LayerMask 过滤触发对象,比 Tag 更省心
用CompareTag过滤玩家,简单直接,但标签是字符串,拼错了不报错、运行时才发现门不响应。项目大了之后,标签管理也容易乱。更稳的做法是用Layer(层):给玩家单独一个层,触发区检测时用LayerMask只认这一层。
OnTriggerEnter本身不直接带层过滤,但可以在回调里判断other.gameObject.layer,或者更彻底地在物理设置里配置层碰撞矩阵,让触发区只和玩家层产生触发。层过滤的好处是配置在 Inspector 里可见、可维护,不依赖字符串。我现在的项目基本都用层过滤,标签只留给少数需要语义标记的地方。
7. 一套可以直接抄的完整搭建流程
把前面所有东西串起来,给一个从零到能跑的完整步骤。你新建一个空场景,照着走一遍,十分钟能拿到一扇能用的门。
7.1 场景搭建步骤
- 放一个门框模型(或者用几个 Cube 拼一个),位置摆好。
- 在门框合页那条边的位置,创建空物体
DoorHinge,位置精确对齐合页边。 - 把门板模型拖成
DoorHinge的子物体,调整本地位置让门板合页边对齐DoorHinge原点。 - 给门板加
BoxCollider,勾上Is Trigger。如果门板模型自带碰撞体,删掉或统一勾触发器。 - 在门前门后创建一个空物体
TriggerZone,加BoxCollider并勾Is Trigger,尺寸覆盖门前门后各一到两个身位。 - 把
DoorController脚本挂到DoorHinge上(或者挂到TriggerZone上,看你的组织习惯,挂门轴上更集中)。 - 在脚本里把
hinge指向DoorHinge,openAngle设 90(或实测值),rotateSpeed设 3。 - 给玩家对象设好
Player标签或对应层。 - 运行,走近门,门开;走远,门关。
7.2 参数速查表
| 参数 | 建议值 | 说明 |
|---|---|---|
| openAngle | 75~90 | 按门板宽度和门框空间实测 |
| rotateSpeed | 2~4 | 3 为手感基准 |
| 触发区半径 | 玩家半径 3~4 倍 | 覆盖门前门后 |
| 关门延迟 | 0.3~0.5 秒 | 避免刚出门就关 |
| 吸附阈值 | 0.5 度 | 防止门关不严 |
7.3 上线前自查清单
- 门板碰撞体是否全部勾了
Is Trigger? - 门轴
DoorHinge原点是否在合页边上? - 触发区是否覆盖门洞前后?
- 触发过滤是否只认玩家(Tag 或 Layer)?
- 关门是否有延迟或区内有人不关的判断?
- 开门角度是否实测过不穿墙?
这几条过一遍,门基本不会出大问题。我每次搭新场景的门都会对着这个清单扫一遍,比事后调试省事得多。
8. 写在最后的一点实操体会
这套触发器开关门的方案,我从最早做第一人称场景一直用到现在,中间换过引擎版本、换过角色控制器类型,核心思路没变过:门板触发器负责感知、门轴父子层级负责旋转中心、角度插值负责手感、计数器负责状态安全。四件事各司其职,代码短、扩展性强、不容易出玄学 bug。
真要说有什么心得,就是别急着写代码,先在 Scene 视图里把门轴摆对、把门手动转到 90 度看方向。我早期图快,脚本一挂就运行,结果门往墙里开、往反方向开、绕中心自转,各种问题挨个调,反而慢。后来养成习惯,先在编辑器里目视确认旋转中心和方向,再写逻辑,一次成型。门这种东西,视觉上对了,逻辑往往就顺了。
另外,触发区的大小和关门延迟这两个参数,别照搬别人的数值,一定要在自己的场景里跑起来感受。同样的 0.5 秒延迟,在开阔大厅里显得利落,在狭窄走廊里就显得急。手感这东西,参数表只能给起点,最终还得靠你在场景里走两圈定下来。