@tool extends Node3D ## XR ToolsViewport 2D in 3D ## ## This script manages a 2D scene rendered as a texture on a 3D quad. ## ## Pointer and keyboard input are mapped into the 2D scene. ## Signal for pointer events signal pointer_event(event) ## Transparent property enum TransparancyMode { OPAQUE, ## Render opaque TRANSPARENT, ## Render transparent SCISSOR, ## Render using alpha-scissor } ## Viewport Update Mode enum UpdateMode { UPDATE_ONCE, ## Update once (redraw triggered if set again to UPDATE_ONCE) UPDATE_ALWAYS, ## Update on every frame UPDATE_THROTTLED, ## Update at throttled rate } # The following dirty flags are private (leading _) to suppress them in the # generated documentation. Unfortunately gdlint complaints on private constants # (see https://github.com/Scony/godot-gdscript-toolkit/issues/223). Until this # is fixed we suppress the rule. # gdlint: disable=constant-name # State dirty flags const _DIRTY_NONE := 0x0000 # Everything up to date const _DIRTY_MATERIAL := 0x0001 # Material needs update const _DIRTY_SCENE := 0x0002 # Scene needs update const _DIRTY_SIZE := 0x0004 # Viewport size needs update const _DIRTY_ALBEDO := 0x0008 # Albedo texture needs update const _DIRTY_UPDATE := 0x0010 # Update mode needs update const _DIRTY_TRANSPARENCY := 0x0020 # Transparency needs update const _DIRTY_ALPHA_SCISSOR := 0x0040 # Alpha scissor needs update const _DIRTY_UNSHADED := 0x0080 # Shade mode needs update const _DIRTY_FILTERED := 0x0100 # Filter mode needs update const _DIRTY_SURFACE := 0x0200 # Surface material needs update const _DIRTY_REDRAW := 0x0400 # Redraw required const _DIRTY_ALL := 0x07FF # All dirty # Default layer of 1:static-world, 21:pointable, 23:ui-objects const DEFAULT_LAYER := 0b0000_0000_0101_0000_0000_0000_0000_0001 # Physics property group @export_group("Physics") ## Physical screen size property @export var screen_size : Vector2 = Vector2(3.0, 2.0): set = set_screen_size ## Viewport collision enabled property @export var enabled : bool = true: set = set_enabled ## Collision layer @export_flags_3d_physics var collision_layer : int = DEFAULT_LAYER: set = set_collision_layer # Content property group @export_group("Content") ## Scene property @export var scene : PackedScene: set = set_scene ## Viewport size property @export var viewport_size : Vector2 = Vector2(300.0, 200.0): set = set_viewport_size ## Update Mode property @export var update_mode : UpdateMode = UpdateMode.UPDATE_ALWAYS: set = set_update_mode ## Update throttle property @export var throttle_fps : float = 30.0 # Input property group @export_group("Input") ## Allow physical keyboard input to viewport @export var input_keyboard : bool = true ## Allow gamepad input to viewport @export var input_gamepad : bool = false # Rendering property group @export_group("Rendering") ## Custom material template @export var material : StandardMaterial3D = null: set = set_material ## Transparent property @export var transparent : TransparancyMode = TransparancyMode.TRANSPARENT: set = set_transparent ## Alpha Scissor Threshold property (ignored when custom material provided) var alpha_scissor_threshold : float = 0.25: set = set_alpha_scissor_threshold ## Unshaded flag (ignored when custom material provided) var unshaded : bool = false: set = set_unshaded ## Filtering flag (ignored when custom material provided) var filter : bool = true: set = set_filter var is_ready : bool = false var scene_node : Node var viewport_texture : ViewportTexture var time_since_last_update : float = 0.0 var _screen_material : StandardMaterial3D var _dirty := _DIRTY_ALL # Called when the node enters the scene tree for the first time. func _ready(): is_ready = true # Listen for pointer events on the screen body $StaticBody3D.connect("pointer_event", _on_pointer_event) # Apply physics properties _update_screen_size() _update_enabled() _update_collision_layer() # Update the render objects _update_render() # Provide custom property information func _get_property_list() -> Array[Dictionary]: # Select visibility of properties var show_alpha_scissor := not material and transparent == TransparancyMode.SCISSOR var show_unshaded := not material var show_filter := not material # Return extra properties return [ { name = "Rendering", type = TYPE_NIL, usage = PROPERTY_USAGE_GROUP }, { name = "alpha_scissor_threshold", type = TYPE_FLOAT, usage = PROPERTY_USAGE_DEFAULT if show_alpha_scissor else PROPERTY_USAGE_NO_EDITOR, hint = PROPERTY_HINT_RANGE, hint_string = "0.0,1.0" }, { name = "unshaded", type = TYPE_BOOL, usage = PROPERTY_USAGE_DEFAULT if show_unshaded else PROPERTY_USAGE_NO_EDITOR }, { name = "filter", type = TYPE_BOOL, usage = PROPERTY_USAGE_DEFAULT if show_filter else PROPERTY_USAGE_NO_EDITOR } ] # Allow revert of custom properties func _property_can_revert(property : StringName) -> bool: match property: "alpha_scissor_threshold": return true "unshaded": return true "filter": return true _: return false # Provide revert values for custom properties func _property_get_revert(property : StringName): # Variant match property: "alpha_scissor_threshold": return 0.25 "unshaded": return false "filter": return true ## Get the 2D scene instance func get_scene_instance() -> Node: return scene_node ## Connect a 2D scene signal func connect_scene_signal(which : String, callback : Callable, flags : int = 0): if scene_node: scene_node.connect(which, callback, flags) # Handle pointer event from screen-body func _on_pointer_event(event : XRToolsPointerEvent) -> void: pointer_event.emit(event) # Handler for input events func _input(event): # Map keyboard events to the viewport if enabled if input_keyboard and (event is InputEventKey or event is InputEventShortcut): $Viewport.push_input(event) return # Map gamepad events to the viewport if enable if input_gamepad and (event is InputEventJoypadButton or event is InputEventJoypadMotion): $Viewport.push_input(event) return # Process event func _process(delta): # Process screen refreshing if Engine.is_editor_hint(): # Perform periodic material refreshes to handle the user modifying the # material properties in the editor time_since_last_update += delta if time_since_last_update > 1.0: time_since_last_update = 0.0 # Trigger material refresh _dirty = _DIRTY_MATERIAL _update_render() elif update_mode == UpdateMode.UPDATE_THROTTLED: # Perform throttled updates of the viewport var frame_time = 1.0 / throttle_fps time_since_last_update += delta if time_since_last_update > frame_time: time_since_last_update = 0.0 # Trigger update $Viewport.render_target_update_mode = SubViewport.UPDATE_ONCE else: # This is no longer needed set_process(false) ## Set screen size property func set_screen_size(new_size: Vector2) -> void: screen_size = new_size if is_ready: _update_screen_size() ## Set enabled property func set_enabled(is_enabled: bool) -> void: enabled = is_enabled if is_ready: _update_enabled() ## Set collision layer property func set_collision_layer(new_layer: int) -> void: collision_layer = new_layer if is_ready: _update_collision_layer() ## Set scene property func set_scene(new_scene: PackedScene) -> void: scene = new_scene _dirty |= _DIRTY_SCENE if is_ready: _update_render() ## Set viewport size property func set_viewport_size(new_size: Vector2) -> void: viewport_size = new_size _dirty |= _DIRTY_SIZE if is_ready: _update_render() ## Set update mode property func set_update_mode(new_update_mode: UpdateMode) -> void: update_mode = new_update_mode _dirty |= _DIRTY_UPDATE if is_ready: _update_render() ## Set material property func set_material(new_material: StandardMaterial3D) -> void: material = new_material notify_property_list_changed() _dirty |= _DIRTY_MATERIAL if is_ready: _update_render() ## Set transparent property func set_transparent(new_transparent: TransparancyMode) -> void: transparent = new_transparent notify_property_list_changed() _dirty |= _DIRTY_TRANSPARENCY if is_ready: _update_render() ## Set the alpha scisser threshold func set_alpha_scissor_threshold(new_threshold: float) -> void: alpha_scissor_threshold = new_threshold _dirty |= _DIRTY_ALPHA_SCISSOR if is_ready: _update_render() ## Set the unshaded property func set_unshaded(new_unshaded : bool) -> void: unshaded = new_unshaded _dirty |= _DIRTY_UNSHADED if is_ready: _update_render() ## Set filter property func set_filter(new_filter: bool) -> void: filter = new_filter _dirty |= _DIRTY_FILTERED if is_ready: _update_render() # Screen size update handler func _update_screen_size() -> void: $Screen.mesh.size = screen_size $StaticBody3D.screen_size = screen_size $StaticBody3D/CollisionShape3D.shape.size = Vector3( screen_size.x, screen_size.y, 0.02) # Enabled update handler func _update_enabled() -> void: if Engine.is_editor_hint(): return $StaticBody3D/CollisionShape3D.disabled = !enabled # Collision layer update handler func _update_collision_layer() -> void: $StaticBody3D.collision_layer = collision_layer # This complex function processes the render dirty flags and performs the # minimal number of updates to get the render objects into the correct state. func _update_render() -> void: # Handle material change if _dirty & _DIRTY_MATERIAL: _dirty &= ~_DIRTY_MATERIAL # Construct the new screen material if material: # Copy custom material _screen_material = material.duplicate() else: # Create new local material _screen_material = StandardMaterial3D.new() # Disable culling _screen_material.params_cull_mode = StandardMaterial3D.CULL_DISABLED # Ensure local material is configured _dirty |= _DIRTY_TRANSPARENCY | \ _DIRTY_ALPHA_SCISSOR | \ _DIRTY_UNSHADED | \ _DIRTY_FILTERED # Ensure new material renders viewport onto surface _dirty |= _DIRTY_ALBEDO | _DIRTY_SURFACE # If we have no screen material then skip everything else if not _screen_material: return # Handle scene change if _dirty & _DIRTY_SCENE: _dirty &= ~_DIRTY_SCENE # Out with the old if is_instance_valid(scene_node): $Viewport.remove_child(scene_node) scene_node.queue_free() # In with the new if scene: # Instantiate provided scene scene_node = scene.instantiate() $Viewport.add_child(scene_node) elif $Viewport.get_child_count() == 1: # Use already-provided scene scene_node = $Viewport.get_child(0) # Ensure the new scene is rendered at least once _dirty |= _DIRTY_REDRAW # Handle viewport size change if _dirty & _DIRTY_SIZE: _dirty &= ~_DIRTY_SIZE # Set the viewport size $Viewport.size = viewport_size $StaticBody3D.viewport_size = viewport_size # Update our viewport texture, it will have changed _dirty |= _DIRTY_ALBEDO # Handle albedo change: if _dirty & _DIRTY_ALBEDO: _dirty &= ~_DIRTY_ALBEDO # Set the screen material to use the viewport for the albedo channel viewport_texture = $Viewport.get_texture() _screen_material.albedo_texture = viewport_texture # Handle update mode change if _dirty & _DIRTY_UPDATE: _dirty &= ~_DIRTY_UPDATE # Apply update rules if Engine.is_editor_hint(): # Update once. Process function used for editor refreshes $Viewport.render_target_update_mode = SubViewport.UPDATE_ONCE set_process(true) elif update_mode == UpdateMode.UPDATE_ONCE: # Update once. Process function not used $Viewport.render_target_update_mode = SubViewport.UPDATE_ONCE set_process(false) elif update_mode == UpdateMode.UPDATE_ALWAYS: # Update always. Process function not used $Viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS set_process(false) elif update_mode == UpdateMode.UPDATE_THROTTLED: # Update once. Process function triggers periodic refresh $Viewport.render_target_update_mode = SubViewport.UPDATE_ONCE set_process(true) # Handle transparency update if _dirty & _DIRTY_TRANSPARENCY: _dirty &= ~_DIRTY_TRANSPARENCY # If using a temporary material then update transparency if _screen_material and not material: # Set the transparancy mode match transparent: TransparancyMode.OPAQUE: _screen_material.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED TransparancyMode.TRANSPARENT: _screen_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA TransparancyMode.SCISSOR: _screen_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR # Set the viewport background transparency mode and force a redraw $Viewport.transparent_bg = transparent != TransparancyMode.OPAQUE _dirty |= _DIRTY_REDRAW # Handle alpha scissor update if _dirty & _DIRTY_ALPHA_SCISSOR: _dirty &= ~_DIRTY_ALPHA_SCISSOR # If using a temporary material with alpha-scissor then update if _screen_material and not material and transparent == TransparancyMode.SCISSOR: _screen_material.params_alpha_scissor_threshold = alpha_scissor_threshold # Handle unshaded update if _dirty & _DIRTY_UNSHADED: _dirty &= ~_DIRTY_UNSHADED # If using a temporary material then update the shading mode and force a redraw if _screen_material and not material: _screen_material.shading_mode = ( BaseMaterial3D.SHADING_MODE_UNSHADED if unshaded else BaseMaterial3D.SHADING_MODE_PER_PIXEL) #_dirty |= _DIRTY_REDRAW # Handle filter update if _dirty & _DIRTY_FILTERED: _dirty &= ~_DIRTY_FILTERED # If using a temporary material then update the filter mode and force a redraw if _screen_material and not material: _screen_material.texture_filter = ( BaseMaterial3D.TEXTURE_FILTER_LINEAR if filter else BaseMaterial3D.TEXTURE_FILTER_NEAREST) #_dirty |= _DIRTY_REDRAW # Handle surface material update if _dirty & _DIRTY_SURFACE: _dirty &= ~_DIRTY_SURFACE # Set the screen to render using the new screen material $Screen.set_surface_override_material(0, _screen_material) # Handle forced redraw of the viewport if _dirty & _DIRTY_REDRAW: _dirty &= ~_DIRTY_REDRAW # Force a redraw of the viewport if Engine.is_editor_hint() or update_mode == UpdateMode.UPDATE_ONCE: $Viewport.render_target_update_mode = SubViewport.UPDATE_ONCE