dp-konzultace/addons/simple-portal-system/scripts/portal.gd
2025-02-10 23:34:26 +01:00

308 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 _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:
_create_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