extends Camera3D ## ## TouchCamera - lag-free mobile navigation for the 3D viewport (Step 3.1). ## ## Gesture map (configurable): ## one finger drag -> orbit (rotate around the focus point) ## two finger pinch -> dolly zoom ## two finger drag -> pan (move the focus point in screen space) ## three finger drag -> pan (alternative, larger screens) ## ## Design: raw InputEvent handling (not _input echo), one gesture re-evaluated ## per frame, no allocations in the hot path. Values are smoothed with an ## exponential filter so motion feels fluid even when touch sampling is jittery. ## @export var orbit_speed: float = 0.4 @export var pan_speed: float = 0.6 @export var zoom_speed: float = 1.2 @export var min_distance: float = 0.5 @export var max_distance: float = 200.0 @export var smoothing: float = 18.0 # higher = snappier, lower = floatier var focus: Vector3 = Vector3.ZERO var _yaw: float = 0.6 var _pitch: float = -0.5 var _distance: float = 8.0 ## When false, a single finger is reserved for sculpting/painting and only ## multi-finger gestures navigate. The UI flips this when a sculpt tool is on. var one_finger_orbit: bool = true var _painting := false var _last_paint: Vector2 = Vector2.ZERO # Smoothed targets var _t_yaw: float = 0.6 var _t_pitch: float = -0.5 var _t_distance: float = 8.0 var _t_focus: Vector3 = Vector3.ZERO # Touch bookkeeping var _touches: Dictionary = {} # index -> position var _prev_centroid: Vector2 = Vector2.ZERO var _prev_spread: float = 0.0 var _gesture_active := false func _ready() -> void: _t_yaw = _yaw _t_pitch = _pitch _t_distance = _distance _t_focus = focus _apply_transform() func _input(event: InputEvent) -> void: if event is InputEventScreenTouch: _handle_touch(event) elif event is InputEventScreenDrag: _handle_drag(event) func _handle_touch(e: InputEventScreenTouch) -> void: if e.pressed: _touches[e.index] = e.position if e.index == 0 and not one_finger_orbit and _touches.size() == 1: _painting = true _last_paint = e.position StudioBus.stroke_begin.emit(_ray_center(e.position)) else: _touches.erase(e.index) if e.index == 0 and _painting: _painting = false StudioBus.stroke_end.emit() if _touches.size() >= 2: _recompute_gesture_baseline() func _handle_drag(e: InputEventScreenDrag) -> void: _touches[e.index] = e.position if _painting and e.index == 0 and not one_finger_orbit: var world := _ray_center(e.position) var prev_world := _ray_center(_last_paint) StudioBus.stroke_apply.emit(world, world - prev_world) _last_paint = e.position return var n := _touches.size() if n == 1: # Orbit var d := e.relative _t_yaw -= d.x * orbit_speed * 0.01 _t_pitch = clampf(_t_pitch - d.y * orbit_speed * 0.01, -1.5, 1.5) elif n == 2: var pts := _touches.values() var centroid: Vector2 = (pts[0] + pts[1]) * 0.5 var spread: float = (pts[0] as Vector2).distance_to(pts[1] as Vector2) if _prev_spread > 0.0: var dzoom := (spread - _prev_spread) * zoom_speed * 0.01 _t_distance = clampf(_t_distance - dzoom, min_distance, max_distance) var dpan := centroid - _prev_centroid _apply_pan(dpan) _prev_centroid = centroid _prev_spread = spread elif n >= 3: var pts := _touches.values() var centroid := Vector2.ZERO for p in pts: centroid += p centroid /= float(pts.size()) var dpan := centroid - _prev_centroid _apply_pan(dpan) _prev_centroid = centroid func _recompute_gesture_baseline() -> void: var pts := _touches.values() if pts.size() >= 2: _prev_centroid = (pts[0] + pts[1]) * 0.5 _prev_spread = (pts[0] as Vector2).distance_to(pts[1] as Vector2) func _apply_pan(screen_delta: Vector2) -> void: # Pan along camera right/up, scaled by distance so it feels consistent at # any zoom level. var scale := _distance * pan_speed * 0.002 var right := global_transform.basis.x var up := global_transform.basis.y _t_focus -= right * screen_delta.x * scale _t_focus += up * screen_delta.y * scale focus = _t_focus func _process(delta: float) -> void: var t := 1.0 - exp(-smoothing * delta) _yaw = lerpf(_yaw, _t_yaw, t) _pitch = lerpf(_pitch, _t_pitch, t) _distance = lerpf(_distance, _t_distance, t) focus = focus.lerp(_t_focus, t) _apply_transform() func _apply_transform() -> void: var offset := Vector3( cos(_pitch) * sin(_yaw), sin(_pitch), cos(_pitch) * cos(_yaw) ) * _distance global_position = focus + offset look_at(focus, Vector3.UP) ## --- Public helpers --------------------------------------------------------- func frame_aabb(box: AABB, margin: float = 1.6) -> void: _t_focus = box.get_center() focus = _t_focus var radius := maxf(0.5, box.size.length() * 0.5) _t_distance = clampf(radius * margin * 2.0, min_distance, max_distance) _distance = _t_distance func set_focus(p: Vector3) -> void: _t_focus = p focus = p func screen_to_ray(screen_pos: Vector2) -> Dictionary: return { "origin": project_ray_origin(screen_pos), "dir": project_ray_normal(screen_pos), } ## Ray-cast the viewport at a screen point against physics; returns the world ## hit (or a point in front of the camera if nothing is hit). func _ray_center(screen_pos: Vector2) -> Vector3: var origin := project_ray_origin(screen_pos) var dir := project_ray_normal(screen_pos) var space := get_world_3d().direct_space_state var q := PhysicsRayQueryParameters3D.create(origin, origin + dir * 1000.0) var res := space.intersect_ray(q) if not res.is_empty(): return res["position"] return origin + dir * _distance