Godot代码

基于godot引擎实现的传送门

虹销雨霁 1 0
✨AI总结✨

在godot引擎中,如何实现经典传送门呢?这期文章给出详细参考。脚本一共分为两个文件,一个主脚本,一个区域脚本。

展示图片如下:

按照图1、2中的节点进行摆放即可。

1.传送门脚本

extends Node3D
class_name Portal

## 虚拟相机近裁面允许的最小值,防止设为 0 导致渲染异常
const EXIT_CAMERA_NEAR_MIN: float = 0.01

@export var target_portal:Portal
@export var reset_surface_gravity_after_teleport := false
## 传送门渲染的视觉层。设置为与关卡和模型不同的层。
## 用于在相机视图中隐藏目标传送门,这样就能透过传送门看到另一侧。
## 这里不用@export_flags_3d_render,是因为set_layer_mask_value的参数是层级语义,而@export_flags_3d_render是掩码语义
@export var cull_layer : int = 4
## 近裁面偏移量,从四个角投影距离的最小值中减去此值,提供安全边际防止边缘裁切。
@export var exit_near_subtract: float = 0.05
## 传送门碰撞体的边距(前后/左右/上下)
@export var portal_x_margin : = 0.1
@export var portal_y_margin : = 0.1
@export var portal_z_margin : = 1.0

# 初始化引用节点
@onready var virtual_camera:Camera3D =  %VirtualCamera
@onready var virtual_viewport:SubViewport = %SubViewport
@onready var portal_visual:CSGBox3D = %CSGBox3D
@onready var front_area:PortalArea = %FrontArea
@onready var rear_area:PortalArea = %RearArea

# 本传送门专属的 Ghost 渲染层编号(Layer 5, 6, ...),
# 由 _all_ghost_layers 静态计数器在 _ready 时自动分配。
# Ghost 节点只设此层,主摄像机关闭此层,仅 VirtualCamera 开启此层,
# 从而确保 Ghost 只在传送门画面中可见,不会被主摄像机直接看到。
var _my_ghost_layer: int

# --- 追踪状态 ---
# _in_front / _in_rear: 记录哪些 CharacterBody3D 当前在前/后检测区域内
var _in_front: Dictionary = {}
var _in_rear: Dictionary = {}
# _prev_z: 每个被追踪物体在上一帧的本地 z 坐标(使用相机位置计算),
# 用于检测物体是否穿越了传送门平面(z=0)
var _prev_z: Dictionary = {}
# _cooldowns: 传送后的帧冷却计数,防止因 Area3D 信号时序问题导致的同帧重复传送
var _cooldowns: Dictionary = {}
# _ghosts: 物体实例 ID → Ghost 根节点,用于在传送门画面中显示物体的镜像
var _ghosts: Dictionary = {}
# _camera_near_backup: 物体实例 ID → 主相机原始 near 值,
# 进入传送门区域时备份并在离开时恢复
var _camera_near_backup: Dictionary = {}
# _transmitted_spotlights: 原始 SpotLight3D 实例 ID → 出口侧的光照代理。
# 代理只在原始光锥命中本传送门时启用,让手电筒能够照亮传送门对面的场景。
var _transmitted_spotlights: Dictionary = {}


# 如果物体在穿过传送门时移动超过此距离,则认为是传送(而非行走穿过)
# 用于防止通过其他能力传送时误触传送门移动
const MOVE_WAS_TELEPORT_THRESHOLD = 5.0
# 传送后目标传送门的冷却帧数,防止因信号时序问题导致的同帧重复传送
const TELEPORT_COOLDOWN_FRAMES := 2
# 当角色进入传送门区域时,将主相机近裁面缩小到此值(0.001),
# 防止近裁面裁剪掉传送门视觉面(CSGBox3D),暴露传送门背后的环境。
# 这是消除传送瞬间视觉抖动的核心手段。
const CAM_NEAR_FIX := 0.001
# 全局 Ghost 层注册表,每个 Portal 实例在 _ready 中注册自己的层号。
# 用于在 _update_virtual_camera 中批量关闭主摄像机上的所有 Ghost 层。
static var _all_ghost_layers: Array = []

# 处理边界情况:防止物体刚好落在传送门平面时传送两次。
# sign(0) 返回 0 会导致穿越检测误判,这里强制返回 1 或 -1。
func _nonzero_sign(value):
	var s = sign(value)
	if s == 0:
		s = 1
	return s

# Called when the node enters the scene tree for the first time.
func _ready() -> void:
	# 分配本传送门专属的 Ghost 渲染层(第一个 Portal 得到 Layer 5,第二个 Layer 6,以此类推)
	_my_ghost_layer = 5 + _all_ghost_layers.size()
	_all_ghost_layers.append(_my_ghost_layer)
	# - 关闭默认层1(让传送门在主相机中不可见)
	portal_visual.set_layer_mask_value(1, false)
	# - 开启cull_layer层(用于渲染传送门视觉效果)
	portal_visual.set_layer_mask_value(cull_layer, true)
	_update_portal_area_size()
	_set_portal_camera_env()
	_connect_area_signals()

func _exit_tree() -> void:
	for proxy in _transmitted_spotlights.values():
		if is_instance_valid(proxy):
			proxy.queue_free()
	_transmitted_spotlights.clear()
	
# 渲染帧回调:更新虚拟相机位置 + 同步所有 Ghost 节点变换
func _process(_delta: float) -> void:
	_update_virtual_camera()
	_update_ghosts()
	_update_transmitted_spotlights()

# 物理帧回调:更新虚拟相机位置 + 检测传送门平面穿越并执行传送。
# 虚拟相机在物理帧也更新,可以减少物理帧与渲染帧之间的延迟,
# 确保传送后 VirtualCamera 在同一帧内完成重定位。
func _physics_process(_delta: float) -> void:
	_update_virtual_camera()
	_check_teleportations()
	
#region 显示画面
# 设置传送门相机环境:
# SubViewport 的画面最终会作为纹理进入主视口,并再次经过主视口的后处理。
# 因此这里只保留雾、SSAO、GI 等场景效果,把曝光和颜色调整设为中性,
# 避免黑暗场景被重复压暗。
func _set_portal_camera_env():
	var world_3d = get_viewport().world_3d
	if not world_3d or not world_3d.environment or not virtual_camera:
		return
	var portal_environment := world_3d.environment.duplicate() as Environment
	portal_environment.tonemap_mode = Environment.TONE_MAPPER_LINEAR
	portal_environment.tonemap_exposure = 1.0
	portal_environment.adjustment_enabled = false
	virtual_camera.environment = portal_environment
		
# 更新传送门相机的近裁剪平面。
# 原理:将目标传送门四边形的四个角投影到虚拟相机 forward 方向,
# 取最小投影距离作为 near 值,确保整个传送门画面不被裁切。
# 局限:可能会裁剪到不需要的画面,理想方案是自定义投影矩阵(倾斜近裁面)。
func _update_camera_near(camera: Camera3D):
	var portal_size = target_portal.portal_visual.size
	var half_w = portal_size.x / 2.0
	var half_h = portal_size.y / 2.0
	# 计算目标传送门四个角在世界坐标中的位置
	var corners_local = [
		target_portal.global_transform * Vector3(-half_w, -half_h, 0),
		target_portal.global_transform * Vector3( half_w, -half_h, 0),
		target_portal.global_transform * Vector3( half_w,  half_h, 0),
		target_portal.global_transform * Vector3(-half_w,  half_h, 0),
	]
	# 将四个角投影到相机前方向,取最小距离作为近裁面
	var cam_forward = -camera.global_transform.basis.z.normalized()
	var cam_pos = camera.global_position
	var min_dist = INF
	for corner in corners_local:
		var dist = (corner - cam_pos).dot(cam_forward)
		if dist < min_dist:
			min_dist = dist
	camera.near = max(EXIT_CAMERA_NEAR_MIN, min_dist - exit_near_subtract)

func _update_camera_near_2(camera: Camera3D):
	var portal_pos = target_portal.global_position
	var cam_pos = camera.global_position
	var cam_forward = -camera.global_transform.basis.z.normalized()
	var to_portal = portal_pos - cam_pos
	var dist = to_portal.dot(cam_forward)
	if dist <= 0:
		return
	camera.near = max(EXIT_CAMERA_NEAR_MIN, dist - exit_near_subtract)

# 更新传送门相机位置,使其显示目标传送门视角的内容。
#   VirtualCamera 变换 = 目标传送门全局变换 × (当前传送门全局变换的逆 × 主相机全局变换)
func _update_virtual_camera():
	var cur_camera = get_viewport().get_camera_3d()
	if not cur_camera:
		return
	
	# 关闭主摄像机上所有已注册的 Ghost 层,防止 Ghost 被主摄像机直接渲染
	for layer in _all_ghost_layers:
		cur_camera.set_cull_mask_value(layer, false)
		
	# 首先,计算相机相对于当前传送门的位置/旋转
	var cur_camera_transform_rel_to_this_portal = self.global_transform.affine_inverse() * cur_camera.global_transform
	# 然后,将这个相对变换应用到目标传送门,得到传送门相机应有的位置
	var moved_to_other_portal = target_portal.global_transform * cur_camera_transform_rel_to_this_portal
	
	# 设置传送门相机的变换
	virtual_camera.global_transform = moved_to_other_portal
	virtual_camera.fov = cur_camera.fov
	
	# 复制相机的剔除掩码,隐藏目标传送门层 + 开启本传送门的 Ghost 层
	# 设置传送门相机的剔除掩码,关闭目标传送门所在层
	# 这样相机就看不到目标传送门,可以渲染另一侧的画面
	virtual_camera.cull_mask = cur_camera.cull_mask
	virtual_camera.set_cull_mask_value(target_portal.cull_layer, false)
	virtual_camera.set_cull_mask_value(_my_ghost_layer, true)
	
	# 同步主视口的渲染设置(抗锯齿、TAA等),保证传送门画面与主画面一致
	virtual_viewport.size = get_viewport().get_visible_rect().size
	virtual_viewport.msaa_3d = get_viewport().msaa_3d
	virtual_viewport.screen_space_aa = get_viewport().screen_space_aa
	virtual_viewport.use_taa = get_viewport().use_taa
	virtual_viewport.use_debanding = get_viewport().use_debanding
	virtual_viewport.use_occlusion_culling = get_viewport().use_occlusion_culling
	virtual_viewport.mesh_lod_threshold = get_viewport().mesh_lod_threshold
	
	_update_camera_near(virtual_camera)
	# 当主相机靠近传送门时加厚传送门视觉体,防止被近裁面裁剪
	_thicken_portal_if_necessary()

# --- 穿门聚光灯 ---
# SubViewport 虽然映射了相机位置,但场景中的 SpotLight3D 仍留在入口侧。
# 这里在出口侧创建一个无脚本的灯光代理,并使用与相机/物体相同的传送变换。
# 只有原始光锥真正覆盖入口传送门时代理才会亮,避免无条件照亮出口。
func _update_transmitted_spotlights() -> void:
	if not is_instance_valid(target_portal):
		_hide_transmitted_spotlights()
		return

	var current_camera := get_viewport().get_camera_3d()
	if not current_camera:
		_hide_transmitted_spotlights()
		return

	var light_owner: Node = current_camera
	while light_owner.get_parent() and not light_owner is CharacterBody3D:
		light_owner = light_owner.get_parent()

	var source_lights := light_owner.find_children("*", "SpotLight3D", true, false)
	var current_light_ids: Dictionary = {}
	for node in source_lights:
		var source_light := node as SpotLight3D
		if not source_light:
			continue

		var light_id := source_light.get_instance_id()
		current_light_ids[light_id] = true
		var proxy_light := _transmitted_spotlights.get(light_id) as SpotLight3D
		if not is_instance_valid(proxy_light):
			proxy_light = _create_transmitted_spotlight(source_light)
			if not proxy_light:
				continue
			_transmitted_spotlights[light_id] = proxy_light

		_sync_transmitted_spotlight(source_light, proxy_light)
		proxy_light.global_transform = _map_transform_through_portal(source_light.global_transform)
		proxy_light.visible = source_light.is_visible_in_tree() and _spotlight_hits_portal(source_light)

	for light_id in _transmitted_spotlights.keys():
		if current_light_ids.has(light_id):
			continue
		var stale_proxy := _transmitted_spotlights[light_id] as SpotLight3D
		if is_instance_valid(stale_proxy):
			stale_proxy.queue_free()
		_transmitted_spotlights.erase(light_id)

func _create_transmitted_spotlight(source_light: SpotLight3D) -> SpotLight3D:
	var proxy_light := source_light.duplicate() as SpotLight3D
	if not proxy_light:
		proxy_light = SpotLight3D.new()
	proxy_light.name = "PortalLight_" + source_light.name
	# 原灯光可能挂有动态角度、闪烁等脚本;代理只接收同步后的最终属性。
	proxy_light.set_script(null)
	proxy_light.visible = false
	target_portal.add_child(proxy_light)
	return proxy_light

func _sync_transmitted_spotlight(source_light: SpotLight3D, proxy_light: SpotLight3D) -> void:
	# 同步 Light3D/SpotLight3D 的全部渲染属性,同时跳过脚本导出变量。
	for property in source_light.get_property_list():
		var property_name := String(property.name)
		if (
			property_name.begins_with("light_")
			or property_name.begins_with("shadow_")
			or property_name.begins_with("distance_fade_")
			or property_name.begins_with("spot_")
		):
			proxy_light.set(property_name, source_light.get(property_name))

func _spotlight_hits_portal(source_light: SpotLight3D) -> bool:
	if not source_light.is_visible_in_tree() or source_light.light_energy <= 0.0:
		return false

	var portal_basis := global_transform.basis.orthonormalized()
	var portal_frame := Transform3D(portal_basis, global_position)
	var inverse_portal_frame := portal_frame.affine_inverse()
	var local_origin := inverse_portal_frame * source_light.global_position
	var world_forward := -source_light.global_transform.basis.z.normalized()
	var local_forward := (portal_basis.inverse() * world_forward).normalized()
	if absf(local_forward.z) < 0.0001:
		return false

	# 求手电筒中心射线与传送门平面(本地 z=0)的交点。
	var distance_to_plane := -local_origin.z / local_forward.z
	if distance_to_plane <= 0.0 or distance_to_plane > source_light.spot_range:
		return false

	var hit_point := local_origin + local_forward * distance_to_plane
	var half_size := Vector2(portal_visual.size.x, portal_visual.size.y) * 0.5
	var outside_x := maxf(absf(hit_point.x) - half_size.x, 0.0)
	var outside_y := maxf(absf(hit_point.y) - half_size.y, 0.0)
	var distance_from_portal_rect_sq := outside_x * outside_x + outside_y * outside_y
	var cone_radius := tan(deg_to_rad(source_light.spot_angle)) * distance_to_plane
	return distance_from_portal_rect_sq <= cone_radius * cone_radius

func _map_transform_through_portal(source_transform: Transform3D) -> Transform3D:
	var source_basis := global_transform.basis.orthonormalized()
	var target_basis := target_portal.global_transform.basis.orthonormalized()
	var source_frame := Transform3D(source_basis, global_position)
	var target_frame := Transform3D(target_basis, target_portal.global_position)
	var mapped_transform := target_frame * (source_frame.affine_inverse() * source_transform)
	mapped_transform.basis = mapped_transform.basis.orthonormalized()
	return mapped_transform

func _hide_transmitted_spotlights() -> void:
	for proxy in _transmitted_spotlights.values():
		if is_instance_valid(proxy):
			proxy.visible = false
#endregion

#region 物理检测
# 根据传送门视觉尺寸和边距,设置前后两个检测区域(Area3D)的大小和位置。
# front_area 在传送门本地 z 负方向(玩家靠近的一侧),
# rear_area 在 z 正方向(传送门背后一侧)。
func _update_portal_area_size():
	var shape_size = Vector3(
		portal_visual.size.x + self.portal_x_margin * 2,
		portal_visual.size.y + self.portal_y_margin * 2,
		self.portal_z_margin)
	var z_offset = self.portal_z_margin / 2
	front_area.set_area_size_and_position(shape_size, -z_offset)
	rear_area.set_area_size_and_position(shape_size, z_offset)
#endregion

#region 传送
# 连接前后检测区域的 body_entered / body_exited 信号
func _connect_area_signals():
	front_area.connect_body_signals(_on_front_entered, _on_front_exited)
	rear_area.connect_body_signals(_on_rear_entered, _on_rear_exited)

# 物体进入前方检测区域:注册追踪、缩小相机近裁面、创建 Ghost
func _on_front_entered(body: Node3D):
	if not body is CharacterBody3D:
		return
	var id = body.get_instance_id()
	_in_front[id] = true
	if not _prev_z.has(id):
		_prev_z[id] = _get_local_z(body)
	_apply_camera_near_fix(body)
	_try_create_ghost(body)

# 物体离开前方检测区域:如果不在任何区域中则清理所有追踪状态
func _on_front_exited(body: Node3D):
	if not body is CharacterBody3D:
		return
	_in_front.erase(body.get_instance_id())
	_check_cleanup(body)

# 物体进入后方检测区域:逻辑与 _on_front_entered 相同
func _on_rear_entered(body: Node3D):
	if not body is CharacterBody3D:
		return
	var id = body.get_instance_id()
	_in_rear[id] = true
	if not _prev_z.has(id):
		_prev_z[id] = _get_local_z(body)
	_apply_camera_near_fix(body)
	_try_create_ghost(body)

# 物体离开后方检测区域
func _on_rear_exited(body: Node3D):
	if not body is CharacterBody3D:
		return
	_in_rear.erase(body.get_instance_id())
	_check_cleanup(body)

# 清理检查:当物体同时不在前后区域中时,清除所有追踪数据、
# 恢复相机近裁面、销毁 Ghost 节点。
func _check_cleanup(body: Node3D):
	var id = body.get_instance_id()
	if not _in_front.has(id) and not _in_rear.has(id):
		_prev_z.erase(id)
		_cooldowns.erase(id)
		_restore_camera_near(body)
		_remove_ghost(body)

# 计算物体(优先使用其子相机位置)在传送门本地坐标系中的 z 分量。
# 使用相机位置而非物体中心是因为玩家通过相机视角感知穿越时刻,
# 相机在 y=1.232 处与物体中心有偏移,用相机位置可以更精确地在
# "玩家眼睛穿过传送门平面" 的那一帧触发传送。
func _get_local_z(body: Node3D) -> float:
	var cam = _find_camera(body)
	var pos = cam.global_position if cam else body.global_position
	return (self.global_transform.affine_inverse() * pos).z

# 递归查找节点树中的 Camera3D 子节点
func _find_camera(node: Node) -> Camera3D:
	if node is Camera3D:
		return node
	for child in node.get_children():
		var found = _find_camera(child)
		if found:
			return found
	return null

# 每个物理帧检测是否有物体穿越了传送门平面(本地 z=0)。
# 穿越判定:比较物体本帧与上帧的本地 z 坐度符号是否翻转。
# 检测到穿越后立即执行传送,并同步更新双方 VirtualCamera。
func _check_teleportations():
	var to_teleport = []
	# 第一遍:检测穿越,收集需要传送的物体 ID
	for id in _prev_z.keys():
		# 冷却期内只更新 prev_z,不做穿越判定
		if _cooldowns.get(id, 0) > 0:
			_cooldowns[id] -= 1
			var b = instance_from_id(id)
			if is_instance_valid(b):
				_prev_z[id] = _get_local_z(b)
			continue
		var body = instance_from_id(id) as Node3D
		if not is_instance_valid(body):
			continue
		var current_z = _get_local_z(body)
		var prev = _prev_z.get(id, 0.0)
		# z 坐标符号翻转 = 穿越了传送门平面
		if _nonzero_sign(prev) != _nonzero_sign(current_z):
			to_teleport.append(id)
		else:
			_prev_z[id] = current_z

	# 第二遍:对穿越的物体执行传送
	for id in to_teleport:
		var body = instance_from_id(id) as CharacterBody3D
		if not is_instance_valid(body):
			continue
		_remove_ghost(body)
		# 传送前先恢复相机近裁面,这样目标传送门可以重新保存正确的原始值
		_restore_camera_near(body)
		var prev_pos = body.global_position
		_apply_teleport(body)
		# 传送后立即同步更新双方 VirtualCamera,避免一帧延迟导致的画面跳变
		_update_virtual_camera()
		if is_instance_valid(target_portal):
			target_portal._update_virtual_camera()
		# 清除源传送门的追踪数据
		_in_front.erase(id)
		_in_rear.erase(id)
		_prev_z.erase(id)
		_cooldowns.erase(id)
		_camera_near_backup.erase(id)
		# 仅当移动距离超过阈值时才通知目标传送门接收(排除被其他能力瞬移误触)
		if body.global_position.distance_to(prev_pos) > MOVE_WAS_TELEPORT_THRESHOLD:
			if is_instance_valid(target_portal):
				target_portal._receive_body(body)

# 目标传送门接收刚传送过来的物体:初始化 z 追踪、设置冷却帧、
# 重新应用相机近裁面修复、创建 Ghost。
# 冷却帧用于防止因 Area3D body_entered 信号时序不确定导致的误传送。
func _receive_body(body: CharacterBody3D):
	var id = body.get_instance_id()
	_prev_z[id] = _get_local_z(body)
	_cooldowns[id] = TELEPORT_COOLDOWN_FRAMES
	_apply_camera_near_fix(body)
	_try_create_ghost(body)

# 执行实际的空间变换传送。
# 变换公式(与 VirtualCamera 位置计算使用相同的仿射变换数学):
#   新全局变换 = 目标传送门全局变换 × (当前传送门全局变换的逆 × 物体全局变换)
# 同时将物体的线速度通过两个传送门之间的旋转变换进行映射。
func _apply_teleport(body: CharacterBody3D):
	# Portal node scale is visual configuration and must never scale the player
	# or the player's offset from the exit portal.
	var source_basis := global_transform.basis.orthonormalized()
	var target_basis := target_portal.global_transform.basis.orthonormalized()
	var source_frame := Transform3D(source_basis, global_position)
	var target_frame := Transform3D(target_basis, target_portal.global_position)
	var relative_transform := source_frame.affine_inverse() * body.global_transform
	var mapped_transform := target_frame * relative_transform
	mapped_transform.basis = mapped_transform.basis.orthonormalized()
	body.global_transform = mapped_transform

	var relative_velocity := source_basis.inverse() * body.velocity
	body.velocity = target_basis * relative_velocity

	if reset_surface_gravity_after_teleport and body.has_method("reset_surface_gravity"):
		body.call("reset_surface_gravity")

	body.reset_physics_interpolation()

# --- Ghost 系统 ---
# Ghost 是物体的纯视觉镜像副本,放置在目标传送门侧的对应位置。
# 当物体部分穿过传送门平面时,Ghost 让另一侧的 VirtualCamera 能看到
# 物体"探出"传送门的部分。Ghost 仅复制 MeshInstance3D(不复制碰撞体),
# 并设置为专属 Ghost 渲染层,确保只在 VirtualCamera 中可见。

# 为物体创建 Ghost 节点(如果尚未创建)
func _try_create_ghost(body: Node3D):
	var id = body.get_instance_id()
	if _ghosts.has(id):
		return
	if not is_instance_valid(target_portal):
		return
	var ghost_root = Node3D.new()
	ghost_root.name = "Ghost_" + body.name
	_clone_visual_nodes(body, ghost_root)
	# Ghost 挂载到目标传送门下,位置通过变换公式计算
	target_portal.add_child(ghost_root)
	_ghosts[id] = ghost_root
	_update_single_ghost(body)

# 递归克隆源节点的 MeshInstance3D 子节点到 Ghost 根节点
func _clone_visual_nodes(source: Node, target_parent: Node):
	for child in source.get_children():
		if child is MeshInstance3D:
			var dupe = child.duplicate()
			_set_ghost_layer_recursive(dupe)
			target_parent.add_child(dupe)
		else:
			_clone_visual_nodes(child, target_parent)

# 将 Ghost 节点(及其子节点)的渲染层设为仅本传送门专属的 Ghost 层。
# 主摄像机关闭此层 → 看不到 Ghost;VirtualCamera 开启此层 → 能看到 Ghost。
func _set_ghost_layer_recursive(node: Node):
	if node is VisualInstance3D:
		node.layers = 0
		node.set_layer_mask_value(_my_ghost_layer, true)
	for child in node.get_children():
		_set_ghost_layer_recursive(child)

# 立即更新单个 Ghost 的变换(创建时调用一次)
func _update_single_ghost(body: Node3D):
	var ghost = _ghosts.get(body.get_instance_id())
	if not ghost or not is_instance_valid(ghost):
		return
	var rel = self.global_transform.affine_inverse() * body.global_transform
	ghost.global_transform = target_portal.global_transform * rel

# 每帧同步所有 Ghost 的变换,使其与对应物体保持镜像关系
func _update_ghosts():
	for id in _ghosts:
		var ghost = _ghosts[id]
		if not is_instance_valid(ghost):
			continue
		var body = instance_from_id(id)
		if not is_instance_valid(body):
			continue
		var rel = self.global_transform.affine_inverse() * body.global_transform
		ghost.global_transform = target_portal.global_transform * rel

# 销毁指定物体的 Ghost 节点
func _remove_ghost(body: Node3D):
	var id = body.get_instance_id()
	var ghost = _ghosts.get(id)
	if ghost:
		if is_instance_valid(ghost):
			ghost.queue_free()
		_ghosts.erase(id)

# --- 相机近裁面修复 ---
# 当角色进入传送门检测区域时,将主相机 near 缩小到 0.001。
# 这是因为传送门视觉面(CSGBox3D)深度很小(约 0.05),
# 当相机靠近传送门时默认近裁面(0.05)会裁掉视觉面,
# 导致玩家看到传送门背后的墙壁/环境,产生视觉抖动。

# 备份并缩小相机近裁面(仅在首次进入时备份原始值)
func _apply_camera_near_fix(body: Node3D):
	var id = body.get_instance_id()
	if _camera_near_backup.has(id):
		return
	var cam = _find_camera(body)
	if not cam:
		return
	_camera_near_backup[id] = cam.near
	cam.near = CAM_NEAR_FIX

# 恢复相机近裁面到原始值,并清除备份
func _restore_camera_near(body: Node3D):
	var id = body.get_instance_id()
	if not _camera_near_backup.has(id):
		return
	var cam = _find_camera(body)
	if cam:
		cam.near = _camera_near_backup[id]
	_camera_near_backup.erase(id)

# --- 传送门视觉加厚 ---
# 当主相机非常靠近传送门时(距离 < 1.0),将 CSGBox3D 的深度从 0.05 增到 0.3,
# 并向相机所在侧的反方向偏移,作为近裁面修复的双重保险。
# 远离时恢复为极小值(0.00001),保持正常视觉效果。
# 当主相机非常靠近传送门时,加厚传送门视觉体(CSGBox3D)并整体向远离相机的方向偏移,
# 作为近裁面修复(_apply_camera_near_fix)的双重保险,防止近裁面裁剪掉传送门视觉面。
#
# 原理:
#   相机的 near plane 会裁掉距离过近的物体。传送门视觉面默认极薄,
#   当相机靠近时很容易被裁掉,导致玩家看到传送门背后的环境而非传送门画面。
#   加厚 + 偏移可以让整个视觉体退到近裁面的安全距离之外。
#
#   仅加厚不偏移 → 厚体以传送门平面为中心向两侧扩展,朝向相机的那一面反而凸出来,
#                    离相机更近,更容易被裁。
#   加厚 + 偏移   → 整体往远离相机方向推,朝向相机的那一面退回到传送门平面位置,
#                    整个体都在远离相机的方向上,近裁面裁不到。
func _thicken_portal_if_necessary():
	# 获取当前主相机,没有相机则无需处理
	var cur_camera = get_viewport().get_camera_3d()
	if not cur_camera:
		return

	# 取出传送门本地坐标系的三个轴方向
	# forward(z):传送门正面朝向(前方为z+,后方为z-)
	# right(x): 传送门左右方向
	# up(y):    传送门上下方向
	var forward := self.global_transform.basis.z
	var right := self.global_transform.basis.x
	var up := self.global_transform.basis.y

	# 从传送门中心指向相机的向量
	var offset: Vector3 = cur_camera.global_position - self.global_position

	# 用点积将偏移向量投影到传送门的三个轴上,
	# 得到相机在传送门本地坐标系中的三个分量:
	#   dist_forward > 0 → 相机在传送门前方(z+侧)
	#   dist_forward < 0 → 相机在传送门后方(z-侧)
	#   dist_right / dist_up → 相机偏移传送门中心的左右/上下距离
	var dist_forward: float = offset.dot(forward)
	var dist_right: float = offset.dot(right)
	var dist_up: float = offset.dot(up)

	# 传送门视觉面的半宽和半高,用于判断相机是否在传送门的范围内
	var half_w := portal_visual.size.x / 2.0
	var half_h := portal_visual.size.y / 2.0

	# 判断相机是否远离传送门或不在传送门正前方范围内:
	#   - 前后距离 > 1.0 米:太远,近裁面不会裁到这里
	#   - 左右超出传送门宽度 + 0.3 米的容差:相机不在传送门正前方
	#   - 上下超出传送门高度 + 0.3 米的容差:相机不在传送门正前方
	# 满足任一条件 → 不需要加厚,恢复为极薄(接近0),位置回到中心
	if abs(dist_forward) > 1.0 or abs(dist_right) > half_w + 0.3 or abs(dist_up) > half_h + 0.3:
		portal_visual.size.z = 0.00001
		portal_visual.position.z = 0.0
		return

	# 相机靠近传送门且在范围内 → 将视觉体深度设为 0.3 米
	var thickness := 0.3
	portal_visual.size.z = thickness

	# 根据相机所在的一侧,将整个厚体向远离相机的方向偏移半个厚度:
	#
	#   以相机在前方(z+侧)为例:
	#     position.z = -0.15
	#     厚体范围:z = -0.15 到 z = +0.15(无偏移)→ z = -0.3 到 z = 0(偏移后)
	#     朝向相机的那一面从 z = +0.15 退回到 z = 0,远离了相机
	#
	#   相机在后方(z-侧)则反向偏移,同理
	if _nonzero_sign(dist_forward) == 1:
		portal_visual.position.z = -thickness / 2.0
	else:
		portal_visual.position.z = thickness / 2.0
#endregion

2.区域脚本

extends Area3D
class_name PortalArea

@onready var portal_collision:CollisionShape3D = %CollisionShape3D

func _ready() -> void:
	monitoring = true
	monitorable = false
	collision_mask = 2

func set_area_size_and_position(shape_size:Vector3, z_offset:float):
	portal_collision.shape.size = shape_size
	position = Vector3(position.x, position.y, position.z + z_offset)

func connect_body_signals(on_entered: Callable, on_exited: Callable):
	body_entered.connect(on_entered)
	body_exited.connect(on_exited)

总结

多个传送门可能会比较卡,注意合理分配。遇到问题记得留言!