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>
What is off:
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>