I want to achieve the effect of card tilting. When the mouse pointer approaches one corner of the card, this corner of the card will tilt downward. The current problem is that the card is not displayed when z < 0, and the size and position of the card are different from what I specified.
Here is my wgsl code:
const PI: f32 = 3.141592653589793;
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) tex_coords: vec2<f32>,
}
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) tex_coords: vec2<f32>,
@location(1) camera_pos: vec3<f32>,
@location(2) offset: vec2<f32>,
}
struct Uniforms {
fov: f32, // In degree, [1, 179], currently 90
x_rot: f32, // In degree, [-45, 45]
y_rot: f32,
card_x: f32, // Top-left coordinate, [-1, 1]
card_y: f32,
card_width: f32, // Already divided by window's width
card_height: f32,
_padding: f32,
};
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@vertex
fn vs_main(
input: VertexInput,
) -> VertexOutput {
let fov = uniforms.fov;
let x_rot = uniforms.x_rot;
let y_rot = uniforms.y_rot;
let uv = input.tex_coords;
// Rotation matrix.
let sin_b = sin(y_rot * PI / 180.0);
let cos_b = cos(y_rot * PI / 180.0);
let sin_c = sin(x_rot * PI / 180.0);
let cos_c = cos(x_rot * PI / 180.0);
let rot_y = mat3x3f(
vec3f(cos_b, 0.0, -sin_b),
vec3f(0.0, 1.0, 0.0),
vec3f(sin_b, 0.0, cos_b)
);
let rot_x = mat3x3f(
vec3f(1.0, 0.0, 0.0),
vec3f(0.0, cos_c, sin_c),
vec3f(0.0, -sin_c, cos_c)
);
let inv_rot_mat = rot_y * rot_x;
// Field of view.
let fov_half_rad = fov * 0.5 * PI / 180.0;
let tan_fov = tan(fov_half_rad);
// Expand vertex position (anti-clipping)
let expanded_pos = vec3f(
(uv.x - 0.5) * uniforms.card_width,
(0.5 - uv.y) * uniforms.card_height,
0.0
);
// Apply rotation transformation
let rotated_pos = inv_rot_mat * expanded_pos;
// Calculate target translation (convert normalized coordinates to model space offset)
let target_x = uniforms.card_x * tan_fov;
let target_y = uniforms.card_y * tan_fov;
let translated_pos = rotated_pos + vec3f(target_x, target_y, 0.0);
// Apply projection
let projected_pos = vec3f(
translated_pos.x / tan_fov,
translated_pos.y / tan_fov,
translated_pos.z
);
// Calculate clip space coordinates
var output: VertexOutput;
output.clip_position = vec4f(projected_pos.xyz, 1.0);
output.tex_coords = uv;
// Pass camera position and offset to fragment shader
output.camera_pos = vec3f(uv - 0.5, 1.0 / tan_fov);
output.offset = vec2f(0.0);
return output;
}
@group(0) @binding(1)
var t_diffuse: texture_2d<f32>;
@group(0) @binding(2)
var s_diffuse: sampler;
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
let uv_projected = input.camera_pos.xy / input.camera_pos.z + input.offset;
let uv = uv_projected + 0.5;
var o = textureSample(t_diffuse, s_diffuse, uv);
// Transparency control: keep center [0.25, 0.75], fade edges
let edge_threshold = 0.25;
o.a *= step(max(abs(uv.x - 0.5), abs(uv.y - 0.5)), edge_threshold);
return o;
}
Depth stencil is disabled.
The screenshot: screenshot
The emulated perspective view is global.
I have tried to use depth testing, but it didn't help.
I managed to get the exact effect I wanted by calculating the perspective-transformed coordinates of the four corner points on the tilted card. The texture's affine transformation was derived using SVD decomposition to solve the equations. Here's the relevant code (implemented with Rust's winit & vello crate).
Handle cursor move event:
WindowEvent::CursorMoved { position, .. } => {
let mx = position.x as f32;
let my = position.y as f32;
self.mouse_pos = [mx, my];
[self.x_rot, self.y_rot] = cursor_moved(
mx,
my,
self.card_x,
self.card_y,
self.card_width,
self.card_height,
25.0,
);
if let Some(window) = self.window.as_ref() {
window.request_redraw();
}
}
Draw the card:
let points = get_projected_points(
self.x_rot,
self.y_rot,
self.card_x,
self.card_y,
self.card_width,
self.card_height,
);
let paths = [
MoveTo(Point::new(points[0].0 as f64, points[0].1 as f64)),
LineTo(Point::new(points[1].0 as f64, points[1].1 as f64)),
LineTo(Point::new(points[2].0 as f64, points[2].1 as f64)),
LineTo(Point::new(points[3].0 as f64, points[3].1 as f64)),
LineTo(Point::new(points[0].0 as f64, points[0].1 as f64)),
];
scene.stroke(
&Stroke::new(1.0),
Affine::IDENTITY,
Color::WHITE,
None,
&paths,
);
let transform = calculate_transform_matrix(
points,
self.card_image.as_ref().unwrap().width as f32,
self.card_image.as_ref().unwrap().height as f32,
);
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
self.card_image.as_ref().unwrap(),
Some(transform),
&paths,
);
Commented utility function:
fn remap(num: f32, source_min: f32, source_max: f32, target_min: f32, target_max: f32) -> f32 {
let clamped_num = num.clamp(source_min, source_max);
let source_range = source_max - source_min;
let target_range = target_max - target_min;
if source_range == 0.0 || target_range == 0.0 {
return target_min;
}
let normalized = (clamped_num - source_min) / source_range;
target_min + normalized * target_range
}
fn cursor_moved(
mouse_x: f32,
mouse_y: f32,
card_x: f32,
card_y: f32,
card_width: f32,
card_height: f32,
max_angle: f32,
) -> [f32; 2] {
let pos = [
mouse_x - (card_x + card_width / 2.0),
mouse_y - (card_y + card_height / 2.0),
];
let is_inside = pos[0].abs() <= card_width / 2.0 && pos[1].abs() <= card_height / 2.0;
let mut x_rot = 0.0;
let mut y_rot = 0.0;
if !is_inside {
return [x_rot, y_rot];
}
// 1: Inverted vertical mapping direction
y_rot = remap(
pos[0],
-card_width / 2.0,
card_width / 2.0,
-max_angle.to_radians(),
max_angle.to_radians(),
);
x_rot = remap(
pos[1],
-card_height / 2.0,
card_height / 2.0,
-max_angle.to_radians(), // Mouse down maps to positive angle
max_angle.to_radians(),
);
[x_rot, y_rot]
}
fn get_projected_points(
x_rot: f32,
y_rot: f32,
card_x: f32,
card_y: f32,
card_width: f32,
card_height: f32,
) -> [(f32, f32); 4] {
let center_x = card_x + card_width / 2.0;
let center_y = card_y + card_height / 2.0;
let w_half = card_width / 2.0;
let h_half = card_height / 2.0;
// View distance parameter (adjustable, smaller values create stronger perspective effect)
const VIEW_DISTANCE: f32 = 800.0;
let sin_x = x_rot.sin();
let cos_x = x_rot.cos();
let sin_y = y_rot.sin();
let cos_y = y_rot.cos();
let mut points = [(0.0, 0.0); 4];
let calculate_point = |dx: f32, dy: f32| -> (f32, f32) {
// 1. Rotate around Y-axis
let rotated_x = dx * cos_y;
let rotated_z = dx * sin_y; // Z-coordinate after Y-axis rotation
// 2. Rotate around X-axis
let final_y = dy * cos_x - rotated_z * sin_x;
let final_z = dy * sin_x + rotated_z * cos_x; // Z-coordinate after X-axis rotation
// 3. Perspective projection formula
let scale = VIEW_DISTANCE / (VIEW_DISTANCE - final_z);
let proj_x = rotated_x * scale;
let proj_y = final_y * scale;
// 4. Convert back to original coordinate system
(proj_x + center_x, proj_y + center_y)
};
// Calculate four vertices (order: top-left, top-right, bottom-right, bottom-left)
points[0] = calculate_point(-w_half, -h_half);
points[1] = calculate_point(w_half, -h_half);
points[2] = calculate_point(w_half, h_half);
points[3] = calculate_point(-w_half, h_half);
points
}
/// Computes a 3x3 transformation matrix to fit the image to the deformed card's border
/// Parameters:
/// - card_points: Four corner coordinates of the deformed card obtained from get_projected_points
/// (needs to be converted to image coordinate system)
/// - img_width: Original image width
/// - img_height: Original image height
fn calculate_transform_matrix(
card_points: [(f32, f32); 4],
img_width: f32,
img_height: f32,
) -> Affine {
// Original image four corner coordinates
// (clockwise order: top-left, top-right, bottom-right, bottom-left)
let src_points = vec![
(0.0, 0.0),
(img_width, 0.0),
(img_width, img_height),
(0.0, img_height),
];
// Construct coefficient matrix A and vector b for the least squares problem
let mut a_data = Vec::with_capacity(8 * 6);
let mut b_data = Vec::with_capacity(8);
for i in 0..4 {
let (sx, sy) = src_points[i];
let (dx, dy) = card_points[i];
// Row for x' = a*sx + b*sy + c
a_data.extend([sx, sy, 1.0, 0.0, 0.0, 0.0]);
b_data.push(dx);
// Row for y' = d*sx + e*sy + f
a_data.extend([0.0, 0.0, 0.0, sx, sy, 1.0]);
b_data.push(dy);
}
let a = DMatrix::from_row_slice(8, 6, &a_data);
let b = DVector::from_row_slice(&b_data);
// Solve the least squares problem: Ax = b
let x = a.svd(true, true).solve(&b, 1e-15).unwrap();
// Construct the 3x3 transformation matrix
Affine::new([
x[0] as f64,
x[3] as f64,
x[1] as f64,
x[4] as f64,
x[2] as f64,
x[5] as f64,
])
}
I hope this will help someone in need later :)