diff --git a/indra/llprimitive/llgltfmaterial.cpp b/indra/llprimitive/llgltfmaterial.cpp
index 4d7b10982a9ae2d09e7de439f8e5838bd0b69919..d0ecf611ff6260dec962866012561e6c297a9bb4 100644
--- a/indra/llprimitive/llgltfmaterial.cpp
+++ b/indra/llprimitive/llgltfmaterial.cpp
@@ -54,8 +54,8 @@ LLMatrix3 LLGLTFMaterial::TextureTransform::asMatrix()
     const F32 cos_r = cos(mRotation);
     const F32 sin_r = sin(mRotation);
     rotation.mMatrix[0][0] = cos_r;
-    rotation.mMatrix[0][1] = sin_r;
-    rotation.mMatrix[1][0] = -sin_r;
+    rotation.mMatrix[0][1] = -sin_r;
+    rotation.mMatrix[1][0] = sin_r;
     rotation.mMatrix[1][1] = cos_r;
 
     LLMatrix3 offset;
diff --git a/indra/llrender/llshadermgr.cpp b/indra/llrender/llshadermgr.cpp
index 6cada320fa1f7e5457bfd425b1bfab3cd0bfc468..13074032e07c852c8bc599d87e71479560887472 100644
--- a/indra/llrender/llshadermgr.cpp
+++ b/indra/llrender/llshadermgr.cpp
@@ -177,6 +177,11 @@ BOOL LLShaderMgr::attachShaderFeatures(LLGLSLShader * shader)
 			return FALSE;
 		}
 	}
+
+    if (!shader->attachVertexObject("deferred/textureUtilV.glsl"))
+    {
+        return FALSE;
+    }
 	
 	///////////////////////////////////////
 	// Attach Fragment Shader Features Next
diff --git a/indra/newview/app_settings/shaders/class1/deferred/pbralphaV.glsl b/indra/newview/app_settings/shaders/class1/deferred/pbralphaV.glsl
index b7715fbe6ec976d28e6c3432a3805aae83ba4bbd..24faf1763f9b7e1a87d6372e3c7e7b4eb3303d4e 100644
--- a/indra/newview/app_settings/shaders/class1/deferred/pbralphaV.glsl
+++ b/indra/newview/app_settings/shaders/class1/deferred/pbralphaV.glsl
@@ -70,6 +70,8 @@ out vec3 vary_tangent;
 flat out float vary_sign;
 out vec3 vary_normal;
 
+vec2 texture_transform(vec2 vertex_texcoord, mat3 khr_gltf_transform, mat4 sl_animation_transform);
+
 
 void main()
 {
@@ -87,10 +89,10 @@ void main()
 
     vary_fragcoord.xyz = vert.xyz + vec3(0,0,near_clip);
 
-	basecolor_texcoord = (texture_matrix0 * vec4(texture_basecolor_matrix * vec3(texcoord0,1), 1)).xy;
-	normal_texcoord = (texture_matrix0 * vec4(texture_normal_matrix * vec3(texcoord0,1), 1)).xy;
-	metallic_roughness_texcoord = (texture_matrix0 * vec4(texture_metallic_roughness_matrix * vec3(texcoord0,1), 1)).xy;
-	emissive_texcoord = (texture_matrix0 * vec4(texture_emissive_matrix * vec3(texcoord0,1), 1)).xy;
+	basecolor_texcoord = texture_transform(texcoord0, texture_basecolor_matrix, texture_matrix0);
+	normal_texcoord = texture_transform(texcoord0, texture_normal_matrix, texture_matrix0);
+	metallic_roughness_texcoord = texture_transform(texcoord0, texture_metallic_roughness_matrix, texture_matrix0);
+	emissive_texcoord = texture_transform(texcoord0, texture_emissive_matrix, texture_matrix0);
 
 #ifdef HAS_SKIN
 	vec3 n = (mat*vec4(normal.xyz+position.xyz,1.0)).xyz-pos.xyz;
@@ -135,6 +137,9 @@ out vec2 emissive_texcoord;
 
 out vec4 vertex_color;
 
+vec2 texture_transform(vec2 vertex_texcoord, mat3 khr_gltf_transform, mat4 sl_animation_transform);
+
+
 void main()
 {
 	//transform vertex
@@ -142,8 +147,8 @@ void main()
     gl_Position = vert;
     vary_position = vert.xyz;
 
-	basecolor_texcoord = (texture_matrix0 * vec4(texture_basecolor_matrix * vec3(texcoord0,1), 1)).xy;
-	emissive_texcoord = (texture_matrix0 * vec4(texture_emissive_matrix * vec3(texcoord0,1), 1)).xy;
+	basecolor_texcoord = texture_transform(texcoord0, texture_basecolor_matrix, texture_matrix0);
+	emissive_texcoord = texture_transform(texcoord0, texture_emissive_matrix, texture_matrix0);
 
 	vertex_color = diffuse_color;
 }
diff --git a/indra/newview/app_settings/shaders/class1/deferred/pbrglowV.glsl b/indra/newview/app_settings/shaders/class1/deferred/pbrglowV.glsl
index 75b24336c54174ada3c2c34af7148de2dd242721..bcad1c1cebee35fef7bb20ad9a4c7df8160c3636 100644
--- a/indra/newview/app_settings/shaders/class1/deferred/pbrglowV.glsl
+++ b/indra/newview/app_settings/shaders/class1/deferred/pbrglowV.glsl
@@ -47,6 +47,8 @@ out vec2 emissive_texcoord;
  
 out vec4 vertex_emissive;
 
+vec2 texture_transform(vec2 vertex_texcoord, mat3 khr_gltf_transform, mat4 sl_animation_transform);
+
 void main()
 {
 #ifdef HAS_SKIN
@@ -62,8 +64,8 @@ void main()
     gl_Position = modelview_projection_matrix * vec4(position.xyz, 1.0); 
 #endif
 
-    basecolor_texcoord = (texture_matrix0 * vec4(texture_basecolor_matrix * vec3(texcoord0,1), 1)).xy;
-    emissive_texcoord = (texture_matrix0 * vec4(texture_emissive_matrix * vec3(texcoord0,1), 1)).xy;
+    basecolor_texcoord = texture_transform(texcoord0, texture_basecolor_matrix, texture_matrix0);
+    emissive_texcoord = texture_transform(texcoord0, texture_emissive_matrix, texture_matrix0);
 
     vertex_emissive = emissive;
 }
diff --git a/indra/newview/app_settings/shaders/class1/deferred/pbropaqueV.glsl b/indra/newview/app_settings/shaders/class1/deferred/pbropaqueV.glsl
index 8320640e42a462e16eaa03e67917b217aa13e89f..f0e3d4f034681a9e288a31bf12ba779a7c849e9d 100644
--- a/indra/newview/app_settings/shaders/class1/deferred/pbropaqueV.glsl
+++ b/indra/newview/app_settings/shaders/class1/deferred/pbropaqueV.glsl
@@ -60,6 +60,8 @@ out vec3 vary_tangent;
 flat out float vary_sign;
 out vec3 vary_normal;
 
+vec2 texture_transform(vec2 vertex_texcoord, mat3 khr_gltf_transform, mat4 sl_animation_transform);
+
 void main()
 {
 #ifdef HAS_SKIN
@@ -75,11 +77,11 @@ void main()
 	//transform vertex
 	gl_Position = modelview_projection_matrix * vec4(position.xyz, 1.0); 
 #endif
-	
-	basecolor_texcoord = (texture_matrix0 * vec4(texture_basecolor_matrix * vec3(texcoord0,1), 1)).xy;
-	normal_texcoord = (texture_matrix0 * vec4(texture_normal_matrix * vec3(texcoord0,1), 1)).xy;
-	metallic_roughness_texcoord = (texture_matrix0 * vec4(texture_metallic_roughness_matrix * vec3(texcoord0,1), 1)).xy;
-	emissive_texcoord = (texture_matrix0 * vec4(texture_emissive_matrix * vec3(texcoord0,1), 1)).xy;
+
+    basecolor_texcoord = texture_transform(texcoord0, texture_basecolor_matrix, texture_matrix0);
+    normal_texcoord = texture_transform(texcoord0, texture_normal_matrix, texture_matrix0);
+    metallic_roughness_texcoord = texture_transform(texcoord0, texture_metallic_roughness_matrix, texture_matrix0);
+    emissive_texcoord = texture_transform(texcoord0, texture_emissive_matrix, texture_matrix0);
 
 #ifdef HAS_SKIN
 	vec3 n = (mat*vec4(normal.xyz+position.xyz,1.0)).xyz-pos.xyz;
@@ -118,13 +120,15 @@ out vec2 emissive_texcoord;
  
 out vec4 vertex_color;
 
+vec2 texture_transform(vec2 vertex_texcoord, mat3 khr_gltf_transform, mat4 sl_animation_transform);
+
 void main()
 {
     //transform vertex
     gl_Position = modelview_projection_matrix * vec4(position.xyz, 1.0); 
 
-    basecolor_texcoord = (texture_matrix0 * vec4(texture_basecolor_matrix * vec3(texcoord0,1), 1)).xy;
-    emissive_texcoord = (texture_matrix0 * vec4(texture_emissive_matrix * vec3(texcoord0,1), 1)).xy;
+    basecolor_texcoord = texture_transform(texcoord0, texture_basecolor_matrix, texture_matrix0);
+    emissive_texcoord = texture_transform(texcoord0, texture_emissive_matrix, texture_matrix0);
 
     vertex_color = diffuse_color;
 }
diff --git a/indra/newview/app_settings/shaders/class1/deferred/textureUtilV.glsl b/indra/newview/app_settings/shaders/class1/deferred/textureUtilV.glsl
new file mode 100644
index 0000000000000000000000000000000000000000..b146c665f9682325df85628ec6bf4353938ecd50
--- /dev/null
+++ b/indra/newview/app_settings/shaders/class1/deferred/textureUtilV.glsl
@@ -0,0 +1,55 @@
+/** 
+ * @file class1/deferred/textureUtilV.glsl
+ *
+ * $LicenseInfo:firstyear=2023&license=viewerlgpl$
+ * Second Life Viewer Source Code
+ * Copyright (C) 2023, Linden Research, Inc.
+ * 
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation;
+ * version 2.1 of the License only.
+ * 
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ * 
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ * 
+ * Linden Research, Inc., 945 Battery Street, San Francisco, CA  94111  USA
+ * $/LicenseInfo$
+ */
+
+// vertex_texcoord - The UV texture coordinates sampled from the vertex at
+//     runtime. Per SL convention, this is in a right-handed UV coordinate
+//     system. Collada models also have right-handed UVs.
+// khr_gltf_transform - The texture transform matrix as defined in the
+//     KHR_texture_transform GLTF extension spec. It assumes a left-handed UV
+//     coordinate system. GLTF models also have left-handed UVs.
+// sl_animation_transform - The texture transform matrix for texture
+//     animations, available through LSL script functions such as
+//     LlSetTextureAnim. It assumes a right-handed UV coordinate system.
+// texcoord - The final texcoord to use for image sampling
+vec2 texture_transform(vec2 vertex_texcoord, mat3 khr_gltf_transform, mat4 sl_animation_transform)
+{
+    vec2 texcoord = vertex_texcoord;
+
+    // Convert to left-handed coordinate system. The offset of 1 is necessary
+    // for rotations to be applied correctly.
+    // In the future, we could bake this coordinate conversion into the uniform
+    // that khr_gltf_transform comes from, since it's applied immediately
+    // before.
+    texcoord.y = 1.0 - texcoord.y;
+    texcoord = (khr_gltf_transform * vec3(texcoord, 1.0)).xy;
+    // Convert back to right-handed coordinate system
+    texcoord.y = 1.0 - texcoord.y;
+    texcoord = (sl_animation_transform * vec4(texcoord, 0, 1)).xy;
+
+    // To make things more confusing, all SL image assets are upside-down
+    // We may need an additional sign flip here when we implement a Vulkan backend
+
+    return texcoord;
+}
diff --git a/indra/newview/llviewershadermgr.cpp b/indra/newview/llviewershadermgr.cpp
index bddce2d6d92343d8cb83da075024a09a7031b726..584dc5c3441be2c0f1e8a18664f9bdec806a7c8c 100644
--- a/indra/newview/llviewershadermgr.cpp
+++ b/indra/newview/llviewershadermgr.cpp
@@ -654,6 +654,7 @@ std::string LLViewerShaderMgr::loadBasicShaders()
     shaders.push_back( make_pair( "environment/srgbF.glsl",                 1 ) );
 	shaders.push_back( make_pair( "avatar/avatarSkinV.glsl",                1 ) );
 	shaders.push_back( make_pair( "avatar/objectSkinV.glsl",                1 ) );
+    shaders.push_back( make_pair( "deferred/textureUtilV.glsl",             1 ) );
 	if (gGLManager.mGLSLVersionMajor >= 2 || gGLManager.mGLSLVersionMinor >= 30)
 	{
 		shaders.push_back( make_pair( "objects/indexedTextureV.glsl",           1 ) );
@@ -1320,6 +1321,7 @@ BOOL LLViewerShaderMgr::loadShadersDeferred()
         gDeferredPBROpaqueProgram.mShaderFiles.push_back(make_pair("deferred/pbropaqueV.glsl", GL_VERTEX_SHADER));
         gDeferredPBROpaqueProgram.mShaderFiles.push_back(make_pair("deferred/pbropaqueF.glsl", GL_FRAGMENT_SHADER));
         gDeferredPBROpaqueProgram.mShaderLevel = mShaderLevel[SHADER_DEFERRED];
+        gDeferredPBROpaqueProgram.clearPermutations();
         
         success = make_rigged_variant(gDeferredPBROpaqueProgram, gDeferredSkinnedPBROpaqueProgram);
         if (success)