File size: 5,554 Bytes
84e8204 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | 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
|