World to local transforms

Anyone here know their way around matrix math? I am trying to do a world-to-local coordinate transform for game objects, and can’t for the life of me figure out what I’m doing wrong. It works just fine with translation and rotation or translation and scale, but not with rotation and scale together. I’ve tried a couple different methods, tried changing the order of translation/rotation/scale, etc, but it always ends up the same.

I have a little demo/test project here: World-to-Local Test.zip (27.6 KB)

When you click, it converts the world position of the mouse to a local position for the “child” game object, and moves the child there. You can see that a translation and non-uniform scale on “parent” work just fine, but if you add some rotation to “parent”, it breaks. (But if you keep the rotation and reset the scale to 1, 1, 1, it also works fine.)

Well, this time it wasn’t actually my fault! The problem is that go.get_world_rotation() does not work for objects that are scaled.

2 Likes

This.

Right now, doing world-to-local and local-to-world transforms with game objects is impossible (without knowing/assuming the full GO hierarchy and composing the transforms down the tree). I think the simplest way to fix this is to add a go.get_world_transform() function that returns the complete transform matrix, since I am only using go.get_world_rotation() to reconstruct that matrix anyway.

3 Likes

Well discuss this on Monday.

3 Likes

@britzl Did you guys get a chance to look into this?

If it matters, the things I want it for (currently):

  • Rendercam - Most of the camera functions: Follow, View Bounds, probably Shake & Recoil, should take place in world space. Right now these all break if you put a camera on a transformed parent.

  • A custom level editor I’m working on - I need to transform positions for editing child objects.

Yes, it’s in our current sprint to add a function to get the game object transform.

2 Likes

Awesome! Thanks!

OK, now that go.get_world_rotation always gives the result you expect (thanks @jhonny.goransson!), my code just works! I spent a bit cleaning things up and testing and put together a little module, in case anyone else needs it.

It will be very nice and probably quite a bit faster when a go.get_world_transform function is added, but for now this will get the job done.


local M = {}

-- NOTE: All of these functions modify the input vector, so if you want to
--       keep the original vector, input a copy of it to the transform function.

-- Convenience function to convert to vector4 and back while multiplying.
function M.v3_by_matrix(v3, m)
	local v4 = m * vmath.vector4(v3.x, v3.y, v3.z, 1)
	v3.x, v3.y, v3.z = v4.x, v4.y, v4.z
	return v3
end

-- If you want the world-to-local matrix, just invert the result with vmath.inv().
function M.get_to_world_matrix(wp, wr, ws)
	-- Make matrix4 from rotation quaternion.
	local m = vmath.matrix4_from_quat(wr)

	-- Multiply axes by world scale.
	m.m00, m.m01, m.m02 = m.m00 * ws.x, m.m01 * ws.y, m.m02 * ws.z
	m.m10, m.m11, m.m12 = m.m10 * ws.x, m.m11 * ws.y, m.m12 * ws.z
	m.m20, m.m21, m.m22 = m.m20 * ws.x, m.m21 * ws.y, m.m22 * ws.z

	-- Plug in world position.
	m.m03, m.m13, m.m23 = wp.x, wp.y, wp.z

	return m
end

function M.local_to_world(v3, wp, wr, ws)
	-- Scale.
	v3.x = v3.x * ws.x;  v3.y = v3.y * ws.y;  v3.z = v3.z * ws.z
	-- Rotation.
	v3 = vmath.rotate(wr, v3)
	-- Translation.
	v3.x = v3.x + wp.x;  v3.y = v3.y + wp.y;  v3.z = v3.z + wp.z
	return v3
end

function M.world_to_local(v3, wp, wr, ws)
	-- Translation inverse.
	v3.x = v3.x - wp.x;  v3.y = v3.y - wp.y;  v3.z = v3.z - wp.z
	-- Rotation inverse.
	local r = vmath.conj(wr)
	v3 = vmath.rotate(r, v3)
	-- Scale inverse.
	v3.x = v3.x / ws.x;  v3.y = v3.y / ws.y;  v3.z = v3.z / ws.z
	return v3
end

return M

Example usage:

local transform = require "transform" -- The module shown above.

-- Move a child object to the world position of the mouse cursor:
local wpos = rendercam.screen_to_world_2d(action.screen_x, action.screen_y)
local wp = go.get_world_position("parent")
local wr = go.get_world_rotation("parent")
local ws = go.get_world_scale("parent")
local lpos = transform.world_to_local(vmath.vector3(wpos), wp, wr, ws)
go.set_position(lpos, "child")

Caveats:

This won’t work with skew.

In other words, if you have a parent object with non-uniform scale and a child object with rotation, transforming to and from the child’s local space won’t give a correct result. We really need to be able to get the object’s world transform directly to do this (but I don’t think it’s a very common use case).

Some interesting performance benchmarks:
I ran each of these 100,000 times and checked how long it took:

  • Empty function with return value — 1 ms
  • Convert vector3 to vector4 or vice versa — 25 ms
  • vmath.inv — 21 ms
  • vmath.ortho_inv — 20 ms
  • M.v3_by_matrix — 81 ms
  • Multiply vector4 * matrix — 30 ms
  • M.get_to_world_matrix — 185 ms
  • M.local_to_world — 98 ms
  • M.world_to_local — 116 ms

So if you’re doing more than about 5 transforms with the same object at once, then it makes sense to use M.get_to_world_matrix() and multiply yourself, otherwise it’s faster to use the transform functions. These times do not include the original get_world_position, get_world_rotation, and get_world_scale calls for the module’s transform functions, just the functions themselves.

I tried a few other methods for transforming coordinates, and the ones I put here were by far the fastest (not using matrices at all).

5 Likes

Glad it worked! You can expect go.get_world_transform in the next release btw :slight_smile:

4 Likes

I’ve started playing with world_to_local() and, although it’s all magic to me, it seems to work. The value returned, however, doesn’t quite match the actual go.get_position() value. I have an issue in my project which might be related to this slight precision discrepancy.

I’ve stripped it all down to a minimal example: world_to_local.zip (3.8 KB)

function world_to_local(v3, wp, wr, ws)
	-- Translation inverse.
	v3.x = v3.x - wp.x;  v3.y = v3.y - wp.y;  v3.z = v3.z - wp.z
	-- Rotation inverse.
	local r = vmath.conj(wr)
	v3 = vmath.rotate(r, v3)
	-- Scale inverse.
	v3.x = v3.x / ws.x;  v3.y = v3.y / ws.y;  v3.z = v3.z / ws.z
	return v3
end

function init(self)

	-- Set parent to terrain for both crosses
	go.set_parent("cross1","terrain", true)
	go.set_parent("cross2","terrain", true)

	-- Compare go.get_position() with world_to_local() after one second.
	timer.delay(1, false, function()

		-- Get local position using world_to_local()
		local world_position = go.get_world_position("cross2")
		local wp = go.get_world_position("terrain")
		local wr = go.get_world_rotation("terrain")
		local ws = go.get_world_scale("terrain")
		local local_position = world_to_local(world_position, wp, wr, ws)

		-- Get local position using go.get_position()
		local local_position_go = go.get_position("cross2")

		print( "world_to_local():     ", local_position ) -- Returns vmath.vector3(128.02966308594, 16.321811676025, 0)
		print( "get go.get_position():", local_position_go ) -- Returns vmath.vector3(128.02966308594, 16.321823120117, 0)
		print( "---" )

	end)

end

What’s the reason the value is a tiny bit off? Is there a way to match the go.get_position() and world_to_local() values somehow?

1 Like

It’s just floating point precision I assume. To be honest I’m surprised the X coordinate matches perfectly, with all the math it goes through.

Are you trying to compare them to see if they’re equal? If so, you’ll have to compare each coordinate yourself and account for some imprecision somehow. You could either round the numbers first, or do something like: "if (x1 - x2) < 0.001 " - then they are the same.

1 Like

Unfortunately not, otherwise that would have been a good solution. I’m firing raycasts from a game object, and for some reason they sometimes miss when the object is nested, probably because they start inside of the fixture. They never miss when the parent object isn’t rotated.

I’ll keep scratching at this to see if I can figure out why.

1 Like

Hmm, hard to say without more info. Seems odd that something you would use a raycast for would break because of a 0.00001 pixel difference. I do usually start my raycasts a little bit “behind” where you might expect, to make them a bit forgiving.

It’s often helpful to draw some debug lines to match your raycasts.

2 Likes

Well, I do both of these things too! :slight_smile: I agree, the slight difference might be a red herring.

2 Likes

I finally got it working!

The solution was using local_to_world() rather than go.get_world_position(). For some reason those two methods sometimes produce rather different results. Most of the time they’re the same, but sometimes, immediately after go.set_parent() has been used, go.get_world_position() is delayed by one frame. This was what caused my issue.

As it stands your functions seem to have solved all the problems I was having, and now (touch wood) everything works like it should. Thanks for being a superstar @ross.grams!

2 Likes