311 lines
14 KiB
GDScript
311 lines
14 KiB
GDScript
"""
|
|
Asset: Godot Simple Portal System
|
|
File: portal.gd
|
|
Description: A simple portal system for viewport-based portals in Godot 4.
|
|
Instructions: For detailed documentation, see the README or visit: https://github.com/Donitzo/godot-simple-portal-system
|
|
Repository: https://github.com/Donitzo/godot-simple-portal-system
|
|
License: CC0 License
|
|
"""
|
|
|
|
extends MeshInstance3D
|
|
class_name Portal
|
|
## The portal represents a single portal mesh in a pair of portals.
|
|
|
|
## The delay between the main viewport changing size and the portal viewport resizing.
|
|
const _RESIZE_THROTTLE_SECONDS: float = 0.1
|
|
|
|
## The minimum camera near clipping distance.
|
|
const _EXIT_CAMERA_NEAR_MIN: float = 0.01
|
|
|
|
## The portal mesh's local bounding box.
|
|
@onready var _mesh_aabb: AABB = mesh.get_aabb()
|
|
|
|
## The vertical resolution of the portal viewport which covers the entire screen not just the portal mesh. Use 0 to use the real resolution.
|
|
@export var vertical_viewport_resolution: int = 512
|
|
|
|
## Disable viewport distance. Portals further away than this won't have their viewports rendered.
|
|
@export var disable_viewport_distance: float = 11
|
|
## Whether to destroy the disabled viewport to save texture memory. Useful when you have a lot of portals. The viewport is re/-created when within disable_viewport_distance and visible.
|
|
@export var destroy_disabled_viewport: bool = true
|
|
|
|
## The maximum fade-out distance.
|
|
@export var fade_out_distance_max: float = 10
|
|
## The minimum fade-out distance.
|
|
@export var fade_out_distance_min: float = 8
|
|
## The fade-out color.
|
|
@export var fade_out_color: Color = Color.WHITE
|
|
|
|
## The scale of the exit side of the portal. < 1 means the exit is smaller than the entrance.
|
|
@export var exit_scale: float = 1.0
|
|
## A value subtracted from the exit camera near clipping plane. Useful for handling clipping issues.
|
|
@export var exit_near_subtract: float = 0.05
|
|
|
|
## The main camera. Leave unset to use the default 3D camera.
|
|
@export var main_camera: Camera3D
|
|
|
|
## An environment set for the exit camera. Leave unset to use the default environment.
|
|
@export var exit_environment: Environment
|
|
## The cull mask for the exit camera. Use it to hide certain objects in the exit camera.
|
|
@export_flags_3d_render var exit_cull_mask: int = 0b11111111111111111111
|
|
|
|
## The exit portal. Leave unset to use this portal as an exit only.
|
|
@export var exit_portal: Portal
|
|
|
|
## An environment set for the exit camera. Leave unset to use the default environment.
|
|
@export var portal_shader: Shader = preload("res://addons/simple-portal-system/shaders/portal.gdshader")
|
|
|
|
# The viewport rendering the portal surface
|
|
var _viewport: SubViewport
|
|
|
|
# The exit camera copies the main camera's position relative to the exit portal
|
|
var _exit_camera: Camera3D
|
|
|
|
# The number of seconds until the viewport updates its size
|
|
var _seconds_until_resize: float
|
|
|
|
func _enter_tree() -> void:
|
|
add_to_group("portals")
|
|
|
|
func _ready() -> void:
|
|
if not is_inside_tree():
|
|
push_error("The portal \"%s\" is not inside a SceneTree." % name)
|
|
|
|
# An exit-free portal does not need to do anything
|
|
if exit_portal == null:
|
|
visible = false
|
|
set_process(false)
|
|
return
|
|
|
|
if not exit_portal.is_inside_tree() or exit_portal.get_tree() != get_tree():
|
|
push_error("The exit_portal \"%s\" of \"%s\" is not inside the same SceneTree." % [exit_portal.name, name])
|
|
|
|
# Non-uniform parent scaling can introduce skew which isn't compensated for
|
|
if get_parent() != null:
|
|
var parent_scale: Vector3 = get_parent().global_transform.basis.get_scale()
|
|
if abs(parent_scale.x - parent_scale.y) > 0.01 or abs(parent_scale.x - parent_scale.z) > 0.01:
|
|
push_warning("The parent of \"%s\" is not uniformly scaled. The portal will not work correctly." % name)
|
|
|
|
# The portals should be updated last so the main camera has its final position
|
|
process_priority = 1000
|
|
|
|
# Used in raycasting
|
|
add_to_group("portals")
|
|
|
|
# Get the main camera
|
|
if main_camera == null:
|
|
main_camera = get_viewport().get_camera_3d()
|
|
|
|
# The portal shader renders the viewport on-top of the portal mesh in screen-space
|
|
material_override = ShaderMaterial.new()
|
|
material_override.shader = portal_shader
|
|
material_override.set_shader_parameter("fade_out_distance_max", fade_out_distance_max)
|
|
material_override.set_shader_parameter("fade_out_distance_min", fade_out_distance_min)
|
|
material_override.set_shader_parameter("fade_out_color", fade_out_color)
|
|
|
|
# Create the viewport when _ready if it's not destroyed when disabled.
|
|
# This may potentially get rid of the initial lag when the viewport is first created at the cost of texture memory.
|
|
if not destroy_disabled_viewport:
|
|
_createa_viewport()
|
|
|
|
get_viewport().connect("size_changed", _handle_resize)
|
|
|
|
func _handle_resize() -> void:
|
|
_seconds_until_resize = _RESIZE_THROTTLE_SECONDS
|
|
|
|
func _create_viewport() -> void:
|
|
# Create the viewport for the portal surface
|
|
_viewport = SubViewport.new()
|
|
_viewport.name = "Viewport"
|
|
_viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ONCE
|
|
add_child(_viewport)
|
|
material_override.set_shader_parameter("albedo", _viewport.get_texture())
|
|
|
|
# Create the exit camera which renders the portal surface for the viewport
|
|
_exit_camera = Camera3D.new()
|
|
_exit_camera.name = "Camera"
|
|
_exit_camera.environment = exit_environment
|
|
_exit_camera.cull_mask = exit_cull_mask
|
|
_viewport.add_child(_exit_camera)
|
|
|
|
# Resize the viewport on the next _process
|
|
_seconds_until_resize = 0
|
|
|
|
func _process(delta: float) -> void:
|
|
# Disable the viewport if the portal is further away than disable_viewport_distance or if the portal is invisible in the scene tree
|
|
var disable_viewport: bool = not is_visible_in_tree() or \
|
|
main_camera.global_position.distance_squared_to(global_position) > disable_viewport_distance * disable_viewport_distance
|
|
|
|
# Enable or disable 3D rendering for the viewport (if it exists)
|
|
if _viewport != null:
|
|
_viewport.disable_3d = disable_viewport
|
|
|
|
if disable_viewport:
|
|
# Destroy the disabled viewport to save memory
|
|
if _viewport != null and destroy_disabled_viewport:
|
|
material_override.set_shader_parameter("albedo", null)
|
|
_viewport.queue_free()
|
|
_viewport = null
|
|
|
|
# Ensure the portal can re-size the second it is enabled again
|
|
if not is_nan(_seconds_until_resize):
|
|
_seconds_until_resize = 0
|
|
|
|
# Don't process the rest if the viewport is disabled
|
|
return
|
|
|
|
# Re/-Create viewport
|
|
if _viewport == null:
|
|
_create_viewport()
|
|
|
|
# Throttle the viewport resizing for better performance
|
|
if not is_nan(_seconds_until_resize):
|
|
_seconds_until_resize -= delta
|
|
if _seconds_until_resize <= 0:
|
|
_seconds_until_resize = NAN
|
|
|
|
var viewport_size: Vector2i = get_viewport().size
|
|
if vertical_viewport_resolution == 0:
|
|
# Resize the viewport to the main viewport size
|
|
_viewport.size = viewport_size
|
|
else:
|
|
# Resize the viewport to the fixed height vertical_viewport_resolution and dynamic width
|
|
var aspect_ratio: float = float(viewport_size.x) / viewport_size.y
|
|
_viewport.size = Vector2i(int(vertical_viewport_resolution * aspect_ratio + 0.5), vertical_viewport_resolution)
|
|
|
|
# Move the exit camera relative to the exit portal based on the main camera's position relative to the entrance portal
|
|
_exit_camera.global_transform = real_to_exit_transform(main_camera.global_transform)
|
|
|
|
# Get the four X, Y corners of the scaled entrance portal bounding box clamped to Z=0 (portal surface) relative to the exit portal.
|
|
# The entrance portal bounding box is used since the entrance portal mesh does not need to match the exit portal mesh.
|
|
var corner_1: Vector3 = exit_portal.to_global(Vector3(_mesh_aabb.position.x, _mesh_aabb.position.y, 0) * exit_scale)
|
|
var corner_2: Vector3 = exit_portal.to_global(Vector3(_mesh_aabb.position.x + _mesh_aabb.size.x, _mesh_aabb.position.y, 0) * exit_scale)
|
|
var corner_3: Vector3 = exit_portal.to_global(Vector3(_mesh_aabb.position.x + _mesh_aabb.size.x, _mesh_aabb.position.y + _mesh_aabb.size.y, 0) * exit_scale)
|
|
var corner_4: Vector3 = exit_portal.to_global(Vector3(_mesh_aabb.position.x, _mesh_aabb.position.y + _mesh_aabb.size.y, 0) * exit_scale)
|
|
|
|
# Calculate the distance along the exit camera forward vector at which each of the portal corners projects
|
|
var camera_forward: Vector3 = - _exit_camera.global_transform.basis.z.normalized()
|
|
|
|
var d_1: float = (corner_1 - _exit_camera.global_position).dot(camera_forward)
|
|
var d_2: float = (corner_2 - _exit_camera.global_position).dot(camera_forward)
|
|
var d_3: float = (corner_3 - _exit_camera.global_position).dot(camera_forward)
|
|
var d_4: float = (corner_4 - _exit_camera.global_position).dot(camera_forward)
|
|
|
|
# The near clip distance is the shortest distance which still contains all the corners
|
|
_exit_camera.near = max(_EXIT_CAMERA_NEAR_MIN, min(d_1, d_2, d_3, d_4) - exit_near_subtract)
|
|
_exit_camera.far = main_camera.far
|
|
_exit_camera.fov = main_camera.fov
|
|
_exit_camera.keep_aspect = main_camera.keep_aspect
|
|
|
|
## Return a new Transform3D relative to the exit portal based on the real Transform3D relative to this portal.
|
|
func real_to_exit_transform(real: Transform3D) -> Transform3D:
|
|
# Convert from global space to local space at the entrance (this) portal
|
|
var local: Transform3D = global_transform.affine_inverse() * real
|
|
# Compensate for any scale the entrance portal may have
|
|
var unscaled: Transform3D = local.scaled(global_transform.basis.get_scale())
|
|
# Flip it (the portal always flips the view 180 degrees)
|
|
var flipped: Transform3D = unscaled.rotated(Vector3.UP, PI)
|
|
# Apply any scale the exit portal may have (and apply custom exit scale)
|
|
var exit_scale_vector: Vector3 = exit_portal.global_transform.basis.get_scale()
|
|
var scaled_at_exit: Transform3D = flipped.scaled(Vector3.ONE / exit_scale_vector * exit_scale)
|
|
# Convert from local space at the exit portal to global space
|
|
var local_at_exit: Transform3D = exit_portal.global_transform * scaled_at_exit
|
|
return local_at_exit
|
|
|
|
## Return a new position relative to the exit portal based on the real position relative to this portal.
|
|
func real_to_exit_position(real: Vector3) -> Vector3:
|
|
# Convert from global space to local space at the entrance (this) portal
|
|
var local: Vector3 = global_transform.affine_inverse() * real
|
|
# Compensate for any scale the entrance portal may have
|
|
var unscaled: Vector3 = local * global_transform.basis.get_scale()
|
|
# Apply any scale the exit portal may have (and apply custom exit scale)
|
|
var exit_scale_vector: Vector3 = Vector3(-1, 1, 1) * exit_portal.global_transform.basis.get_scale()
|
|
var scaled_at_exit: Vector3 = unscaled / exit_scale_vector * exit_scale
|
|
# Convert from local space at the exit portal to global space
|
|
var local_at_exit: Vector3 = exit_portal.global_transform * scaled_at_exit
|
|
return local_at_exit
|
|
|
|
## Return a new direction relative to the exit portal based on the real direction relative to this portal.
|
|
func real_to_exit_direction(real: Vector3) -> Vector3:
|
|
# Convert from global to local space at the entrance (this) portal
|
|
var local: Vector3 = global_transform.basis.inverse() * real
|
|
# Compensate for any scale the entrance portal may have
|
|
var unscaled: Vector3 = local * global_transform.basis.get_scale()
|
|
# Flip it (the portal always flips the view 180 degrees)
|
|
var flipped: Vector3 = unscaled.rotated(Vector3.UP, PI)
|
|
# Apply any scale the exit portal may have (and apply custom exit scale)
|
|
var exit_scale_vector: Vector3 = exit_portal.global_transform.basis.get_scale()
|
|
var scaled_at_exit: Vector3 = flipped / exit_scale_vector * exit_scale
|
|
# Convert from local space at the exit portal to global space
|
|
var local_at_exit: Vector3 = exit_portal.global_transform.basis * scaled_at_exit
|
|
return local_at_exit
|
|
|
|
## Raycast against portals (See instructions).
|
|
static func raycast(tree: SceneTree, from: Vector3, dir: Vector3, handle_raycast: Callable,
|
|
max_distance: float = INF, max_recursions: int = 16, ignore_backside: bool = true) -> void:
|
|
var portals: Array = tree.get_nodes_in_group("portals")
|
|
var ignore_portal: Portal = null
|
|
var recursive_distance: float = 0
|
|
|
|
for r in max_recursions + 1:
|
|
var closest_hit: Vector3
|
|
var closest_dir: Vector3
|
|
var closest_portal: Portal
|
|
var closest_distance_sqr: float = INF
|
|
|
|
# Find the closest portal the ray intersects
|
|
for portal in portals:
|
|
# Ignore exit portals and invisible portals
|
|
if portal == ignore_portal or not portal.is_inside_tree() or tree != portal.get_tree() or not portal.is_visible_in_tree():
|
|
continue
|
|
|
|
var local_from: Vector3 = portal.to_local(from)
|
|
var local_dir: Vector3 = portal.global_transform.basis.inverse() * dir
|
|
|
|
# Check if ray is parallel to the portal
|
|
if local_dir.z == 0:
|
|
continue
|
|
|
|
# Ignore backside
|
|
if local_dir.z > 0 and ignore_backside:
|
|
continue
|
|
|
|
# Get the intersection point of the ray with the Z axis
|
|
var t: float = - local_from.z / local_dir.z
|
|
|
|
# Is the intersection behind the start position?
|
|
if t < 0:
|
|
continue
|
|
|
|
# Check if the ray hit inside the portal bounding box (ignoring Z)
|
|
var local_hit: Vector3 = local_from + t * local_dir
|
|
var aabb: AABB = portal._mesh_aabb
|
|
if local_hit.x < aabb.position.x or local_hit.x > aabb.position.x + aabb.size.x or \
|
|
local_hit.y < aabb.position.y or local_hit.y > aabb.position.y + aabb.size.y:
|
|
continue
|
|
|
|
# Check if this was the closest portal
|
|
var hit: Vector3 = portal.to_global(local_hit)
|
|
var distance_sqr: float = hit.distance_squared_to(from)
|
|
if distance_sqr < closest_distance_sqr:
|
|
closest_hit = hit
|
|
closest_dir = dir
|
|
closest_distance_sqr = distance_sqr
|
|
closest_portal = portal
|
|
|
|
# Calculate the ray distance
|
|
var hit_distance: float = INF if is_inf(closest_distance_sqr) else sqrt(closest_distance_sqr)
|
|
|
|
# Call the user-defined raycast function
|
|
if handle_raycast.call(from, dir, hit_distance, recursive_distance, r):
|
|
break
|
|
|
|
# Was no portal hit or was the maximum raycast distance reached?
|
|
recursive_distance += hit_distance
|
|
if is_inf(closest_distance_sqr) or recursive_distance >= max_distance:
|
|
break
|
|
|
|
# Re-direct the ray through the portal
|
|
from = closest_portal.real_to_exit_position(closest_hit)
|
|
dir = closest_portal.real_to_exit_direction(closest_dir)
|
|
ignore_portal = closest_portal.exit_portal
|