The unit circle is a circle with a radius of 1.0.
The figure below is a unit circle. [ Note 1 ]
In the image above, when you drag the blue handle around the circle, the X and Y positions change, representing the point's position on the circle. And at the top, Y is 1 and X is 0. On the right, X is 1 and Y is 0.
If you remember basic third grade math, multiply something by 1 and it stays the same. So 123 * 1 = 123. Pretty basic, right? Well, a unit circle, a circle with a radius of 1.0 is also a form of 1. It's a spin of 1 . So something can be multiplied by this unit circle, in a way it's kind of like multiplying by 1.
We'll take the X and Y values from an arbitrary point on the unit circle and multiply the vertex position by the values from the previous example.
Below is the shader modification.
struct Uniforms {
color: vec4f,
resolution: vec2f,
translation: vec2f,
rotation: vec2f,
};
struct Vertex {
@location(0) position: vec2f,
};
struct VSOutput {
@builtin(position) position: vec4f,
};
@group(0) @binding(0) var<uniform> uni: Uniforms;
@vertex fn vs(vert: Vertex) -> VSOutput {
var vsOut: VSOutput;
// Rotate the position
let rotatedPosition = vec2f(
vert.position.x * uni.rotation.x - vert.position.y * uni.rotation.y, //here
vert.position.x * uni.rotation.y + vert.position.y * uni.rotation.x //here
);
// Add in the translation
// let position = vert.position + uni.translation;
let position = rotatedPosition + uni.translation;
// convert the position from pixels to a 0.0 to 1.0 value
let zeroToOne = position / uni.resolution;
// convert from 0 <-> 1 to 0 <-> 2
let zeroToTwo = zeroToOne * 2.0;
// covert from 0 <-> 2 to -1 <-> +1 (clip space)
let flippedClipSpace = zeroToTwo - 1.0;
// flip Y
let clipSpace = flippedClipSpace * vec2f(1, -1);
vsOut.position = vec4f(clipSpace, 0.0, 1.0);
return vsOut;
}
Updated JavaScript to increase uniform size.
// color, resolution, translation
//const uniformBufferSize = (4 + 2 + 2) * 4;
// color, resolution, translation, rotation, padding
const uniformBufferSize = (4 + 2 + 2 + 2) * 4 + 8; //here
const uniformBuffer = device.createBuffer({
label: 'uniforms',
size: uniformBufferSize,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const uniformValues = new Float32Array(uniformBufferSize / 4);
// offsets to the various uniform values in float32 indices
const kColorOffset = 0;
const kResolutionOffset = 4;
const kTranslationOffset = 6;
const kRotationOffset = 8; //here
const colorValue = uniformValues.subarray(kColorOffset, kColorOffset + 4);
const resolutionValue = uniformValues.subarray(kResolutionOffset, kResolutionOffset + 2);
const translationValue = uniformValues.subarray(kTranslationOffset, kTranslationOffset + 2);
const rotationValue = uniformValues.subarray(kRotationOffset, kRotationOffset + 2); //here
We need UI for display. This article is not a tutorial on making a UI, so I'm only going to use one. First some HTML to give a placeholder for the unit circle
<body>
<canvas></canvas>
<div id="circle"></div>
</body>
and then some CSS for positioning
#circle {
position: fixed;
right: 0;
bottom: 0;
width: 300px;
background-color: var(--bg-color);
}
And finally the JavaScript that uses it.
import UnitCircle from './resources/js/unit-circle.js'; //here
...
const gui = new GUI();
gui.onChange(render);
gui.add(settings.translation, '0', 0, 1000).name('translation.x');
gui.add(settings.translation, '1', 0, 1000).name('translation.y');
const unitCircle = new UnitCircle(); //here
document.querySelector('#circle').appendChild(unitCircle.domElement); //here
unitCircle.onChange(render); //here
function render() {
...
// Set the uniform values in our JavaScript side Float32Array
resolutionValue.set([canvas.width, canvas.height]);
translationValue.set(settings.translation);
rotationValue.set([unitCircle.x, unitCircle.y]); //here
// upload the uniform values to the uniform buffer
device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
Below is the displayed result. Drag the handle on the circle to rotate or the slider to translate.
Why does it work? Here is a mathematical explanation.
rotatedX = a_position.x * u_rotation.x - a_position.y * u_rotation.y;
rotatedY = a_position.x * u_rotation.y + a_position.y * u_rotation.x;
Suppose you have a rectangle and want to rotate it. Before starting to rotate, the upper right corner is at (3.0, -9.0). Let's take a point on the unit circle 30 degrees clockwise from 3 o'clock.
The position on the circle above is x = 0.87, y = 0.50
3.0 * 0.87 - -9.0 * 0.50 = 7.1
3.0 * 0.50 + -9.0 * 0.87 = -6.3
This is exactly where it rotates
Take the same 60 degrees clockwise
The positions on the circle are 0.87 and 0.50
3.0 * 0.50 - -9.0 * 0.87 = 9.3
3.0 * 0.87 + -9.0 * 0.50 = -1.9
You can see that when the point is rotated clockwise, the X value becomes larger and the Y value becomes smaller. If you continue beyond 90 degrees, X will start to get smaller again and Y will start to get larger. This pattern rotates alternately.
Points on the unit circle have another name. They are called sine and cosine. So for any given angle, the sine and cosine can be obtained like this.
function printSineAndCosineForAnAngle(angleInDegrees) {
const angleInRadians = angleInDegrees * Math.PI / 180;
const s = Math.sin(angleInRadians);
const c = Math.cos(angleInRadians);
console.log('s =', s, 'c =', c);
}
If you copy and paste the code into the JavaScript console and type printSineAndCosignForAngle(30), you'll see it print s = 0.50 c = 0.87 (note: I rounded the numbers)
If you put them together, you can rotate the vertex position to any angle you want. Just set the rotation to be the sine and cosine of the angle you want to rotate to.
...
const angleInRadians = angleInDegrees * Math.PI / 180;
rotation[0] = Math.cos(angleInRadians);
rotation[1] = Math.sin(angleInRadians);
Change the code below to have only one rotation parameter.
const degToRad = d => d * Math.PI / 180;
const settings = {
translation: [150, 100],
rotation: degToRad(30),//here
};
const radToDegOptions = {
min: -360, max: 360, step: 1, converters: GUI.converters.radToDeg };
const gui = new GUI();
gui.onChange(render);
gui.add(settings.translation, '0', 0, 1000).name('translation.x');
gui.add(settings.translation, '1', 0, 1000).name('translation.y');
gui.add(settings, 'rotation', radToDegOptions);
// const unitCircle = new UnitCircle();
// document.querySelector('#circle').appendChild(unitCircle.domElement);
// unitCircle.onChange(render);
function render() {
...
// Set the uniform values in our JavaScript side Float32Array
resolutionValue.set([canvas.width, canvas.height]);
translationValue.set(settings.translation); //here
// rotationValue.set([unitCircle.x, unitCircle.y]);
rotationValue.set([
Math.cos(settings.rotation), //here
Math.sin(settings.rotation), //here
]);
Drag the slider to translate or rotate.
I hope this is pretty intuitive. Next is a simpler one. Scale transform .
Note 1
What are radians?
Radians are the unit of measurement used for circles, rotations, and angles. Just like we can measure distances in inches, yards, meters, etc., we can measure angles in degrees or radians.
You probably know that the math for metric measurements is easier than that for imperial measurements. To go from inches to feet, we divide by 12. To go from inches to yards, we divide by 36. I don't know about you, but I can't divide by 36 in my head. It's much easier to use the metric system. To convert millimeters to centimeters, we divide by 10. To convert from millimeters to meters, we divide by 1000. I can mentally divide by 1000.
Radians are similar to degrees. Angles make math difficult. Radians make math easy. A circle has 360 degrees, but only 2π radians. So a full circle is 2π radians. A half circle is 1π radians. 1/4 turn, or 90 degrees, is 1/2π radian. So if you want to rotate something 90 degrees, just use Math.PI * 0.5 . If you want to rotate it 45 degrees, use Math.PI * 0.25 etc.
Almost any math involving angles, circles, or rotations is pretty straightforward if you start thinking about radians. So try it. Use radians instead of degrees, except for UI displays.
This unit circle has +Y down to match that our pixel space is also Y down. The normal clip space for WebGPU is +Y up . In the previous article, we have flipped the Y in the shader.