I spent an embarrassingly long time debugging this issue. I really hope this issue gets fixed before 1.0.
I believe the problem is that the LocalToWorld.Rotation property is calling the quaternion constructor that takes a float4x4. This constructor requires the passed in 4x4 matrix to be an orthonormal matrix. When the LocalToWorld is scaled, the basis vectors of the transform are no longer unit-length which means it is not orthonormal.
As others have mentioned, one fix is to call quaternion.LookRotationSafe. However, as
@KwahuNashoba suggested, another option is to first multiply the LocalToWorld by the inverse scale before calling that quaternion constructor that takes a float4x4. I haven’t profiled which one is faster, but a quick look through the code makes it look like it would be faster.
Here are a few extensions methods that might be helpful to others. This allows you to replace LocalToWorld.Rotation with LocalToWorld.Rotation(). This also adds LocalToWorld.Scale() and LocalToWorld.NonUniformScale().
public static class LocalToWorldExtensions {
public static float3 NonUniformScale(this LocalToWorld ltw)
{
float sx = math.length(ltw.Right);
float sy = math.length(ltw.Up);
float sz = math.length(ltw.Forward);
return new float3(sx, sy, sz);
}
// Note: This method assumes that the LocalToWorld has a uniform scale being applied
// to it. If this is not true, use NonUniformScale above instead.
public static float Scale(this LocalToWorld ltw) => math.length(ltw.Right);
// The Rotation property in LocalToWorld is wrong when the LTW is scaled.
// See: https://discussions.unity.com/t/788891
public static quaternion Rotation(this LocalToWorld ltw) =>
quaternion.LookRotationSafe(ltw.Forward, ltw.Up);
// Alternative version
public static quaternion Rotation2(this LocalToWorld ltw)
{
float3 scale = ltw.NonUniformScale();
float4x4 unscaled = math.mul(ltw.Value, float4x4.Scale(1 / scale));
return new quaternion(unscaled);
}
}
I also noticed that this bug is present in the CopyTransformToGameObjectSystem. Once again, that quaternion constructor is being used to retrieve the rotation of a LocalToWorld float4x4 matrix. This is easily fixed by using one of the other methods above. However, as others have pointed out on the forum, the CopyTransformToGameObject doesn’t work with scale simply because it never writes to the transform’s scale in the CopyTransformToGameObjectSystem. Unfortunately, this can’t easily be fixed as TransformAccess does not provide a way to set the world scale (lossyScale). I think the only way to fix this to support all use cases would be to write a non-bursted system that writes directly to the transform component on the game object instead of going through TransformAccess.
However, I assume that most people that are using CopyTransformToGameObject are doing so on top-level game objects since this is meant to track the world position of an entity. Here is a forked version of the CopyTransformToGameObject functionality that should work correctly with scale, but only when syncing a top-level (has no parent) game object:
using System;
using Unity.Entities;
[GenerateAuthoringComponent]
[Serializable]
public struct CopyScaledTransformToGameObject : IComponentData { }
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Transforms;
using UnityEngine;
using UnityEngine.Jobs;
[ExecuteAlways]
[UpdateInGroup(typeof(TransformSystemGroup))]
[UpdateAfter(typeof(LocalToParentSystem))]
public partial class CopyScaledTransformToGameObjectSystem : SystemBase
{
[BurstCompile]
struct CopyTransforms : IJobParallelForTransform
{
[DeallocateOnJobCompletion]
[ReadOnly] public NativeArray<LocalToWorld> LocalToWorlds;
public void Execute(int index, TransformAccess transform)
{
var value = LocalToWorlds[index];
transform.localPosition = value.Position;
transform.localRotation = value.Rotation();
transform.localScale = value.NonUniformScale();
}
}
EntityQuery m_TransformGroup;
protected override void OnCreate()
{
m_TransformGroup = GetEntityQuery(ComponentType.ReadOnly(typeof(CopyTransformToGameObject)), ComponentType.ReadOnly<LocalToWorld>(), typeof(UnityEngine.Transform));
}
protected override void OnUpdate()
{
var transforms = m_TransformGroup.GetTransformAccessArray();
var copyTransformsJob = new CopyTransforms
{
LocalToWorlds = m_TransformGroup.ToComponentDataArrayAsync<LocalToWorld>(Allocator.TempJob, out var dependency),
};
Dependency = copyTransformsJob.Schedule(transforms, dependency);
}
}
I haven’t tested this myself as I don’t currently need it, but I was helping someone else who ran into the same problem. Let me know if there are problems and I can edit the post.