""" 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