Search code examples
javascriptmatrixthree.jsvertex-shaderuv-mapping

Rotate UVs in Vertex Shader without distorting texture


I need to rotate and scale UVs in a vertex shader such that the rotated texture fills its available bounding box. The following test implementation successfully rotates and auto-scales the texture but the image gets skewed / distorted as the rotation value increases.

I'm accounting for the texture's aspect ratio for auto-scaling but I'm definitely missing something in the rotation step.

This question seems related but I'm unable to translate the proposed solution to my vertex shader because I don't know how Three.js works under the hood.

Any help is greatly appreciated!

const VERTEX_SHADER = (`
  varying vec2 vUv;
  uniform vec2 tSize; // Texture size (width, height)
  uniform float rotation; // Rotation angle in radians

  vec2 rotateAndScaleUV(vec2 uv, float angle, vec2 tSize) {

    vec2 center = vec2(0.5);

    // Step 1: Move UVs to origin for rotation
    vec2 uvOrigin = uv - center;

    // Step 2: Apply rotation matrix
    float cosA = cos(rotation);
    float sinA = sin(rotation);
    mat2 rotMat = mat2(cosA, -sinA, sinA, cosA);
    vec2 rotatedUv = rotMat * uvOrigin;

    // Step 3: Auto-scale to fill available space
    float aspectRatio = tSize.x / tSize.y;
    float scale = 1.0 / max(abs(cosA) + abs(sinA) / aspectRatio, abs(sinA) + abs(cosA) * aspectRatio);
    return rotatedUv * scale + center; // Scale and move back to correct position

  }

  void main() {
    vUv = rotateAndScaleUV(uv, rotation, tSize);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`);

// Scene setup
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.getElementById('container').appendChild(renderer.domElement);

// Load an image and create a mesh that matches its aspect ratio
new THREE.TextureLoader().load('https://images.unsplash.com/photo-1551893478-d726eaf0442c?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzMjM4NDZ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3MDcyNDI0MTB8&ixlib=rb-4.0.3&q=80&w=400', texture => {
  
  texture.minFilter = THREE.LinearFilter;
  texture.generateMipMaps = false;
  
  const img = texture.image;
  const aspectRatio = img.width / img.height;
  
  // Create geometry with the same aspect ratio
  const geometry = new THREE.PlaneGeometry(aspectRatio, 1);
  
  // Shader material
  const shaderMaterial = new THREE.ShaderMaterial({
    uniforms: {
      textureMap: { value: texture },
      tSize: { value: [img.width, img.height] },
      rotation: { value: 0 }
    },
    vertexShader: VERTEX_SHADER,
    fragmentShader: `
      uniform sampler2D textureMap;
      varying vec2 vUv;
      void main() {
        gl_FragColor = texture2D(textureMap, vUv);
      }
    `
  });
  
  camera.position.z = 1;

  // Create and add mesh to the scene
  const mesh = new THREE.Mesh(geometry, shaderMaterial);
  scene.add(mesh);
  
  // UI controls
  document.getElementById('rotation').addEventListener('input', e => {
    shaderMaterial.uniforms.rotation.value = parseFloat(e.target.value);
    renderer.render(scene, camera);
  });
  
  window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
    renderer.render(scene, camera);
  }, false);
  
  renderer.render(scene, camera);
  
});
body {margin: 0; color: grey;}
#container {
  width: 100vw;
  height: 100vh;
}
#ui {
  position: absolute;
  top: 5%;
  left: 50%;
  transform: translateX(-50%);
  z-index: 10;
}
<script src="https://cdn.jsdelivr.net/npm/[email protected]/three.min.js"></script>
<div id="container"></div>
<div id="ui">
  <label for="rotation">Rotation:</label>
  <input type="range" id="rotation" min="-1" max="1" step="0.001" value="0">
</div>


Solution

  • What is off:

    • Once you center in, you need to center out;
    • You scale is kind of off cause of MAD, multiplication then addition, you multiply by scale from the origin 0, 0 and add then center, you need to center before scale.

    const VERTEX_SHADER = (`
      varying vec2 vUv;
      uniform vec2 tSize; // Texture size (width, height)
      uniform float rotation; // Rotation angle in radians
    
      vec2 rotateAndScaleUV(vec2 uv, float angle, vec2 tSize) {
    
        vec2 p = uv;
        float mid = 0.5;    
        float aspect = tSize.x / tSize.y;
        
        float cosA = cos(rotation);
        float sinA = sin(rotation);
        float scale = 1.0 / max(abs(cosA) + abs(sinA) / aspect, abs(sinA) + abs(cosA) * aspect);
        
        mat2 rotMat = mat2(cosA, -sinA, sinA, cosA);
    
        p -= vec2(mid);
        p *= scale;
        p.y *= 1.0 / aspect;
        p *= rotMat;
        
        p.y *= aspect;
        p += vec2(mid);
    
        return p;
    
      }
    
      void main() {
        vUv = rotateAndScaleUV(uv, rotation, tSize);
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
      }
    `);
    
    // Scene setup
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
    const renderer = new THREE.WebGLRenderer();
    renderer.setSize(window.innerWidth, window.innerHeight);
    document.getElementById('container').appendChild(renderer.domElement);
    
    // Load an image and create a mesh that matches its aspect ratio
    new THREE.TextureLoader().load('https://images.unsplash.com/photo-1551893478-d726eaf0442c?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzMjM4NDZ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3MDcyNDI0MTB8&ixlib=rb-4.0.3&q=80&w=400', texture => {
      
      texture.minFilter = THREE.LinearFilter;
      texture.generateMipMaps = false;
      
      const img = texture.image;
      const aspectRatio = img.width / img.height;
      
      // Create geometry with the same aspect ratio
      const geometry = new THREE.PlaneGeometry(aspectRatio, 1);
      
      // Shader material
    console.log(img.width, img.height);
      const shaderMaterial = new THREE.ShaderMaterial({
      
        uniforms: {
          textureMap: { value: texture },
          tSize: { value: [img.width, img.height] },
          rotation: { value: 0 }
        },
        vertexShader: VERTEX_SHADER,
        fragmentShader: `
          uniform sampler2D textureMap;
          varying vec2 vUv;
          void main() {
            gl_FragColor = texture2D(textureMap, vUv);
          }
        `
      });
      
      camera.position.z = 1;
    
      // Create and add mesh to the scene
      const mesh = new THREE.Mesh(geometry, shaderMaterial);
      scene.add(mesh);
      
      // UI controls
      document.getElementById('rotation').addEventListener('input', e => {
        shaderMaterial.uniforms.rotation.value = parseFloat(e.target.value);
        renderer.render(scene, camera);
      });
      
      window.addEventListener('resize', () => {
        camera.aspect = window.innerWidth / window.innerHeight;
        camera.updateProjectionMatrix();
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.render(scene, camera);
      }, false);
      
      renderer.render(scene, camera);
      
    });
    body {margin: 0; color: grey;}
    #container {
      width: 100vw;
      height: 100vh;
    }
    #ui {
      position: absolute;
      top: 5%;
      left: 50%;
      transform: translateX(-50%);
      z-index: 10;
    }
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/three.min.js"></script>
    <div id="container"></div>
    <div id="ui">
      <label for="rotation">Rotation:</label>
      <input type="range" id="rotation" min="-1" max="1" step="0.001" value="0">
    </div>