Skip to content

Add Input.get_device_orientation() returning hardware-fused quaternion#119142

Open
lxdua wants to merge 10 commits into
godotengine:masterfrom
lxdua:feature/device-orientation-api
Open

Add Input.get_device_orientation() returning hardware-fused quaternion#119142
lxdua wants to merge 10 commits into
godotengine:masterfrom
lxdua:feature/device-orientation-api

Conversation

@lxdua

@lxdua lxdua commented May 1, 2026

Copy link
Copy Markdown

Closes godotengine/godot-proposals/issues/14704

Summary

Adds Input.get_device_orientation() returning a Quaternion representing the
device's absolute orientation, sourced from system-level hardware sensor fusion
on mobile platforms. Fills the gap between Godot and Unity's Input.gyro.attitude.

Implementation

Android: TYPE_GAME_ROTATION_VECTOR (preferred) → TYPE_ROTATION_VECTOR
(fallback). The quaternion from SensorManager.getQuaternionFromVector() is
corrected for the current screen rotation, then mapped from the Android sensor
coordinate system to Godot's convention at the DisplayServerAndroid bridge.

iOS / Apple Embedded: CMDeviceMotion.attitude.quaternion, with per-screen-
orientation correction quaternions in godot_view_apple_embedded.mm.

Other platforms: returns Quaternion() (identity).

API

  • New method: Input.get_device_orientation() -> Quaternion
  • New project setting: input_devices/sensors/enable_device_orientation (default false)
  • Both documented in doc/classes/Input.xml and doc/classes/ProjectSettings.xml

Testing

Verified on a Redmi Turbo 5 Max (Android, arm64) in landscape:

  • 1:1 match between physical phone rotation and a 3D mesh driven by the API
  • Smooth at slow & fast rotation, no jitter
  • No drift over ~5 minutes of continuous use
  • Default-disabled: returns identity when project setting is off

⚠️ I do not have access to an iOS device. The iOS code path follows the same
pattern as the existing accelerometer/gyro handling in handleMotion and
compiles, but real-device verification is needed before merging. Help testing
on iOS is appreciated.

Default behavior

Disabled by default (battery-friendly), opt-in via project setting — identical
to the existing enable_gyroscope / enable_magnetometer pattern.

Exposes the system-level fused device orientation as a Quaternion via

a new Input.get_device_orientation() method, complementing the existing

raw gyroscope/gravity/accelerometer accessors.

Backed by hardware sensor fusion on mobile platforms:

- Android: TYPE_GAME_ROTATION_VECTOR (preferred, no magnetometer drift),

  with TYPE_ROTATION_VECTOR as a fallback. The quaternion produced by

  SensorManager.getQuaternionFromVector() is corrected for the current

  screen rotation via pre-computed quaternion multiplication, then

  remapped from the Android sensor coordinate system to Godot's

  convention at the platform bridge layer.

- iOS / Apple Embedded: CMDeviceMotion.attitude.quaternion, with

  per-screen-orientation correction quaternions.

- Desktop and other platforms: returns Quaternion() (identity).

Gated by a new project setting, disabled by default to save battery:

  input_devices/sensors/enable_device_orientation = false

Tested on a Redmi 2602BRT18C (Android, arm64) in landscape mode:

1:1 match between physical device rotation and the returned quaternion,

no visible jitter, no drift over ~5 minutes of continuous use.
@lxdua lxdua requested review from a team as code owners May 1, 2026 14:39
Address pre-commit suggestions from CI.
@Nintorch Nintorch requested review from a team and removed request for a team May 1, 2026 14:47
@Nintorch Nintorch added this to the 4.x milestone May 1, 2026
@Nintorch Nintorch changed the title Add Input.get_device_orientation() returning hardware-fused quaternion Add Input.get_device_orientation() returning hardware-fused quaternion May 1, 2026
@m4gr3d m4gr3d requested a review from Alex2782 May 1, 2026 23:02
@m4gr3d

m4gr3d commented May 1, 2026

Copy link
Copy Markdown
Contributor

1:1 match between physical phone rotation and a 3D mesh driven by the API

@lxdua Can you provide the sample you used to validate the API so others can replicate on other devices.

Comment on lines +798 to +799
// Pre-computed screen rotation correction quaternions (around Z axis).
// Format: { w, x, y, z }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you document where these values are coming from?

@lxdua

lxdua commented May 1, 2026

Copy link
Copy Markdown
Author

物理电话旋转与由API驱动的3D网格实现1:1匹配

你能提供你用来验证API的样本,让其他人能在其他设备上复制吗?

A single-script 3D scene that creates a virtual phone model at runtime — no scene file needed:

extends Node3D

## ------------------------------------------------------------------
## Sensor orientation PR test scene.
##
## 1. Project Settings → Display → Window → Handheld → Orientation = Sensor.
## 2. Turn OFF the device's system-level rotation lock.
## 3. Tap "Reset" to calibrate. Virtual phone should track physical
##    pitch / roll consistently in every orientation the device OS
##    actually switches to.
##
## The `Viewport:` line already tells you portrait vs. landscape, so
## there's no explicit coverage UI — just physically rotate and confirm
## behavior in every state the device will let you enter.
##
## If you suspect the pre-rotation is causing axis confusion in
## landscape, flip USE_BASE_ORIENTATION to false; the mesh will always
## render portrait but the raw motion axes should match 1:1.
## ------------------------------------------------------------------

const USE_BASE_ORIENTATION := false   # Set true to pre-rotate mesh into current layout.

var phone_mesh: MeshInstance3D
var label: Label
var reset_button: Button

var reference_inv := Quaternion.IDENTITY
var base_orientation := Quaternion.IDENTITY
var calibrated := false


func _ready() -> void:
	var camera := Camera3D.new()
	camera.position = Vector3(0, 0, 3)
	add_child(camera)
	camera.look_at(Vector3.ZERO)
	camera.current = true

	var light := DirectionalLight3D.new()
	light.rotation_degrees = Vector3(-45, 30, 0)
	add_child(light)

	phone_mesh = MeshInstance3D.new()
	var box := BoxMesh.new()
	box.size = Vector3(0.8, 1.4, 0.1)
	phone_mesh.mesh = box
	var body_mat := StandardMaterial3D.new()
	body_mat.albedo_color = Color(0.2, 0.5, 1.0)
	phone_mesh.set_surface_override_material(0, body_mat)
	add_child(phone_mesh)

	var cam_dot := _make_sphere(0.06, Color(1.0, 0.2, 0.2))
	cam_dot.position = Vector3(0, 0.55, 0.06)
	phone_mesh.add_child(cam_dot)

	var back_dot := _make_sphere(0.06, Color(0.2, 1.0, 0.2))
	back_dot.position = Vector3(0, 0.55, -0.06)
	phone_mesh.add_child(back_dot)

	var right_dot := _make_sphere(0.05, Color(1.0, 0.9, 0.1))
	right_dot.position = Vector3(0.45, 0, 0.06)
	phone_mesh.add_child(right_dot)

	var axes := _make_axes()
	axes.position = Vector3(-0.8, -0.6, 0)
	axes.scale = Vector3.ONE * 0.35
	add_child(axes)

	var control := Control.new()
	control.set_anchors_preset(Control.PRESET_FULL_RECT)
	control.mouse_filter = Control.MOUSE_FILTER_IGNORE
	add_child(control)

	label = Label.new()
	label.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
	label.offset_top = -180
	label.offset_bottom = -16
	label.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
	label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	label.add_theme_font_size_override("font_size", 22)
	label.add_theme_color_override("font_outline_color", Color.BLACK)
	label.add_theme_constant_override("outline_size", 4)
	control.add_child(label)

	reset_button = Button.new()
	reset_button.text = "Reset"
	reset_button.add_theme_font_size_override("font_size", 28)
	reset_button.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
	reset_button.offset_left = -100
	reset_button.offset_right = 100
	reset_button.offset_top = -276
	reset_button.offset_bottom = -196
	reset_button.pressed.connect(_on_reset_pressed)
	control.add_child(reset_button)


func _make_sphere(radius: float, color: Color) -> MeshInstance3D:
	var m := MeshInstance3D.new()
	var s := SphereMesh.new()
	s.radius = radius
	s.height = radius * 2.0
	m.mesh = s
	var mat := StandardMaterial3D.new()
	mat.albedo_color = color
	mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
	m.set_surface_override_material(0, mat)
	return m


func _make_axes() -> Node3D:
	var root := Node3D.new()
	var colors := [Color.RED, Color.GREEN, Color.BLUE]
	var dirs   := [Vector3.RIGHT, Vector3.UP, Vector3.BACK]
	for i in range(3):
		var rod := MeshInstance3D.new()
		var cyl := CylinderMesh.new()
		cyl.top_radius = 0.02
		cyl.bottom_radius = 0.02
		cyl.height = 0.6
		rod.mesh = cyl
		var mat := StandardMaterial3D.new()
		mat.albedo_color = colors[i]
		mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
		rod.set_surface_override_material(0, mat)
		rod.position = dirs[i] * 0.3
		if dirs[i] == Vector3.RIGHT:
			rod.rotation_degrees = Vector3(0, 0, -90)
		elif dirs[i] == Vector3.BACK:
			rod.rotation_degrees = Vector3(90, 0, 0)
		root.add_child(rod)
	return root


func _is_landscape() -> bool:
	var size := get_window().size
	return size.x > size.y


func _screen_to_base_rotation() -> Quaternion:
	if not USE_BASE_ORIENTATION:
		return Quaternion.IDENTITY
	if _is_landscape():
		return Quaternion(Vector3(0, 0, 1), deg_to_rad(90))
	return Quaternion.IDENTITY


func _on_reset_pressed() -> void:
	var raw := Input.get_device_orientation()
	reference_inv = raw.inverse()
	base_orientation = _screen_to_base_rotation()
	calibrated = true


func _process(_delta: float) -> void:
	var raw: Quaternion = Input.get_device_orientation()
	var shown: Quaternion = reference_inv * raw if calibrated else raw
	phone_mesh.quaternion = shown * base_orientation

	var e := shown.get_euler()
	var raw_e := raw.get_euler()
	var vp := get_window().size

	label.text = ("%s\nViewport: %dx%d   [%s]\n\n"
			+ "Shown  P=%6.1f°  Y=%6.1f°  R=%6.1f°\n"
			+ "Raw    P=%6.1f°  Y=%6.1f°  R=%6.1f°\n"
			+ "Raw q  (w=%.3f, x=%.3f, y=%.3f, z=%.3f)") % [
		"[Calibrated]" if calibrated else "[Press Reset to calibrate]",
		vp.x, vp.y, "LANDSCAPE" if _is_landscape() else "PORTRAIT",
		rad_to_deg(e.x), rad_to_deg(e.y), rad_to_deg(e.z),
		rad_to_deg(raw_e.x), rad_to_deg(raw_e.y), rad_to_deg(raw_e.z),
		raw.w, raw.x, raw.y, raw.z,
	]

if (values.length < 4) {
return;
}
float[] quaternion = new float[4];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should avoid allocating a new object each time. Instead store it as a class field and reuse it each time.

* Each quaternion is { w, x, y, z }.
*/
private static float[] quaternionMultiply(float[] a, float[] b) {
return new float[] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't return a new float, instead take an additional float[] parameter to be used to return the result.

Comment thread platform/android/java/lib/src/main/java/org/godotengine/godot/Godot.kt Outdated
@m4gr3d m4gr3d modified the milestones: 4.x, 4.8 May 1, 2026
@lxdua lxdua force-pushed the feature/device-orientation-api branch from e8015d0 to 15ede5e Compare May 2, 2026 01:20
lxdua and others added 2 commits May 2, 2026 09:55
Avoid per-event allocations in the TYPE_(GAME_)ROTATION_VECTOR branch of onSensorChanged() by hoisting the quaternion scratch buffers to instance fields and converting quaternionMultiply() to an alias-safe out-parameter form. The callback can fire at 50-200 Hz, so dropping the two new float[4] allocations per event avoids avoidable GC pressure.

Also expand the documentation around the rotation correction table: derive each entry from q(θ) = ( cos(θ/2), 0, 0, sin(θ/2) ), explain the q_screen_in_world = q_screen_in_device * q_device_in_world framing, and note the Android rotation-vector identity pose (flat, screen up, long edge toward magnetic north) so callers are not surprised by non-trivial quaternions when the device feels 'still'.
…Godot.kt

Change "mRotationVector" to "rotationVector"

Co-authored-by: Fredia Huya-Kouadio <fhuyakou@gmail.com>
@lxdua lxdua force-pushed the feature/device-orientation-api branch from f1f2603 to 0a512b1 Compare May 2, 2026 01:56
…estion

The previous commit renamed the field declaration only; this fixes the two remaining call-sites in registerListeners() that still referred to the old name, which was breaking the Kotlin compile.
@lxdua

lxdua commented May 2, 2026

Copy link
Copy Markdown
Author

During the testing process, I discovered that Godot does not yet have APIs for asking the portrait and landscape screen orientations of mobile devices. This seems to be another area that needs to be addressed.🫤

@lxdua lxdua requested a review from m4gr3d May 4, 2026 07:23
Comment thread doc/classes/Input.xml Outdated
Comment thread doc/classes/Input.xml Outdated
lxdua and others added 2 commits May 4, 2026 18:52
Co-authored-by: A Thousand Ships <96648715+AThousandShips@users.noreply.github.com>
Co-authored-by: A Thousand Ships <96648715+AThousandShips@users.noreply.github.com>
@lxdua lxdua requested a review from AThousandShips May 4, 2026 10:53

@m4gr3d m4gr3d left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Android logic looks good.

@lxdua lxdua force-pushed the feature/device-orientation-api branch from e2cda7a to c4dd543 Compare May 5, 2026 14:45
@m4gr3d

m4gr3d commented May 5, 2026

Copy link
Copy Markdown
Contributor

Tested on a Pixel 10 Fold using the script from #119142 (comment) and it works as expected!

screen-20260505-102718-1778002015269.mp4

Both PR godotengine#119142 (device orientation sensor) and upstream (joystick
touchpad functions) added methods in the same location after
get_gyroscope(). Keep both additions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add an interface for device orientation to the input for mobile devices

4 participants