Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/compositor/src/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const AUTO_FOLLOW_REFERENCE_MS: f32 = 1000.0 / 40.0;
const FOLLOW_DEAD_ZONE: f32 = 0.5;

// Convergence du curseur dessiné sur le point cliqué (`pinned_at`). La tenue couvre le contact du
// curseur modélisé (27 à 74 ms après le clic), plus une image à 24 i/s.
// curseur modélisé : de 27 à 74 ms après le clic à « Light », de 12 à 100 ms à « Strong ».
const PIN_APPROACH_S: f32 = 0.25;
const PIN_HOLD_S: f32 = 0.1;
const PIN_RELEASE_S: f32 = 0.25;
Expand Down
63 changes: 43 additions & 20 deletions crates/compositor/src/frame_geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3726,9 +3726,14 @@ pub const MODEL_LIGHT: [f32; 3] = [-0.4194, -0.5792, 0.6990];

/// Garde au sol au repos, en unités du modèle.
const MODEL_HOVER: f32 = 0.35;
/// Gain sur `tap` pour la descente : à 1,25 le modèle reste posé de 27 à 74 ms après le clic,
/// assez pour qu'au moins une image le montre au contact jusqu'à 21 i/s.
/// Gain sur `tap` pour la descente, fois le niveau de `clickBounce` : à « Light » (1) le modèle
/// reste posé de 27 à 74 ms après le clic, assez pour qu'au moins une image le montre au contact
/// jusqu'à 21 i/s ; à « Strong » (2), il touche dès 12 ms et appuie jusqu'à 100 ms (la tenue de
/// `CursorTrack::pinned_at`) ; à « None », il ne descend pas.
const MODEL_CONTACT_GAIN: f32 = 1.25;
/// Rebond au-dessus du survol, sur la remontée de `tap` (+0,164 à 165 ms), fois le niveau : il
/// remonte de 41 % de sa hauteur de survol à « Light », de 82 % à « Strong ».
const MODEL_REBOUND_GAIN: f32 = 2.5;
/// Tangage au repos (queue relevée, pointe vers le bas) et supplément au creux de la pression.
const MODEL_PITCH_IDLE_DEG: f32 = 18.0;
const MODEL_PITCH_PRESS_DEG: f32 = 10.0;
Expand Down Expand Up @@ -3833,20 +3838,23 @@ pub struct CursorPose {
/// La pose à `t`. `click_bounce` est le réglage brut (0 à 2 : None, Light, Strong ; 1 par défaut),
/// `pointing` la part « pointeur » du sprite (`pointing_factor`).
///
/// - Hauteur : `MODEL_HOVER` au repos ; chaque clic le fait descendre TOUCHER le plan au creux de
/// `regions::tap`, la courbe de l'impact du clic, dont le creux (49,5 ms) est celui de la
/// pression de `CursorTrack::bounce` : le modèle, le plan et le rebond d'échelle lisent le même
/// contact à la même image. Dernier clic avant `t`, comme `bounce`. Tous les états.
/// - Tangage : 18° au repos, jusqu'à +10° au creux de la pression, fois `clickBounce`.
/// - Hauteur : `MODEL_HOVER` au repos ; chaque clic le fait descendre TOUCHER le plan autour du
/// creux de `regions::tap`, la courbe de l'impact du clic, dont le creux (49,5 ms) est celui de
/// la pression de `CursorTrack::bounce` : le modèle, le plan et le rebond d'échelle lisent le
/// même contact à la même image. Puis il rebondit au-dessus du survol et s'y repose. Le niveau
/// règle tout le geste, comme le rebond d'échelle en 2D : plus fort, il touche plus tôt, appuie
/// plus longtemps et rebondit plus haut (`MODEL_CONTACT_GAIN`, `MODEL_REBOUND_GAIN`) ; à 0, il
/// ne bouge pas. Dernier clic avant `t`, comme `bounce`. Tous les états.
/// - Tangage : 18° au repos, +10° pendant l'appui, fois `clickBounce`.
/// - Lacet : vers la vitesse horizontale lissée (`follow_at`, différence centrée), et vers la
/// cible d'un clic dans les 300 ms qui le précèdent ; borné à ±25° en douceur (`tanh`), nul au
/// repos. Les contributions des clics montent avant eux et retombent sur la fenêtre de l'impact,
/// donc la pose reste continue en `t`.
/// donc la pose reste continue en `t` ; à « None », seule la vitesse compte.
/// - Tangage et lacet sont multipliés par `pointing` : entiers pour la flèche, nuls pour un
/// curseur centré.
/// - Écrasement : sur la même courbe `tap`, l'épaisseur descend à 1 − `MODEL_SQUASH` au creux,
/// puis le rebond l'épaissit un instant. Fois `clickBounce`, comme le tangage ; tous les états.
/// L'empreinte ne change pas : pas de rebond d'échelle en 3D.
/// - Écrasement : pendant l'appui, l'épaisseur descend à 1 − `MODEL_SQUASH`, puis le rebond
/// l'épaissit un instant. Fois `clickBounce`, comme le tangage ; tous les états.
/// L'empreinte ne change pas : pas de rebond d'échelle en 3D, le mouvement le remplace.
pub fn cursor_pose(
track: &crate::cursor::CursorTrack,
t: f32,
Expand All @@ -3855,11 +3863,14 @@ pub fn cursor_pose(
) -> CursorPose {
use crate::regions::{tap, CLICK_IMPACT_WINDOW_S};
let k = track.last_click_at(t).map(|tc| tap((t - tc) / CLICK_IMPACT_WINDOW_S)).unwrap_or(0.0);
let clearance = MODEL_HOVER * (1.0 + MODEL_CONTACT_GAIN * k).max(0.0);
let strength = (click_bounce / MODEL_CLICK_BOUNCE_REF).clamp(0.0, 2.0);
let press = (-k).max(0.0) * strength;
let pitch = pointing * (MODEL_PITCH_IDLE_DEG + MODEL_PITCH_PRESS_DEG * press).to_radians();
let squash = (1.0 + MODEL_SQUASH * k * strength).max(MODEL_SQUASH_MIN);
// L'appui, de 0 au survol à 1 posé, et le rebond au-dessus du survol.
let press = (-k * MODEL_CONTACT_GAIN * strength).clamp(0.0, 1.0);
let rebound = k.max(0.0) * strength;
let clearance = MODEL_HOVER * (1.0 - press + MODEL_REBOUND_GAIN * rebound);
let pitch =
pointing * (MODEL_PITCH_IDLE_DEG + MODEL_PITCH_PRESS_DEG * strength * press).to_radians();
let squash = (1.0 + MODEL_SQUASH * (rebound - strength * press)).max(MODEL_SQUASH_MIN);

let smooth = |x: f32| {
let u = x.clamp(0.0, 1.0);
Expand All @@ -3870,14 +3881,16 @@ pub fn cursor_pose(
(Some(a), Some(b)) => (b.0 - a.0) / (2.0 * h),
_ => 0.0,
};
// La visée d'un clic fait partie de son geste : absente à « None », entière dès « Light ».
let aim = strength.min(1.0);
if let Some(here) = track.follow_at(t) {
for (tc, target) in track.clicks_with_points(t - CLICK_IMPACT_WINDOW_S, t + MODEL_AIM_S) {
let w = if tc > t {
smooth(1.0 - (tc - t) / MODEL_AIM_S)
} else {
1.0 - smooth((t - tc) / CLICK_IMPACT_WINDOW_S)
};
v += w * (target.0 - here.0) / MODEL_AIM_S;
v += aim * w * (target.0 - here.0) / MODEL_AIM_S;
}
}
let yaw = pointing * MODEL_YAW_MAX_DEG.to_radians() * (v / MODEL_YAW_SPEED).tanh();
Expand Down Expand Up @@ -7732,10 +7745,18 @@ mod tests {
let press_ms = press.expect("un creux") as f32;
assert_eq!(cursor_pose(&track, 0.5 + press_ms / 1000.0, MODEL_CLICK_BOUNCE_REF, 1.0).clearance, 0.0);
assert_eq!(cursor_pose(&track, 0.5 + crate::regions::CLICK_IMPACT_WINDOW_S, MODEL_CLICK_BOUNCE_REF, 1.0), rest);
// clickBounce règle la pression, pas le contact.
let soft = cursor_pose(&track, 0.5 + CONTACT_S, 0.0, 1.0);
assert_eq!(soft.clearance, 0.0);
assert!((soft.pitch - rest.pitch).abs() < 1e-6);
// Le niveau règle tout le geste. « None » : le modèle ne bouge pas au clic.
for ms in 0..260 {
assert_eq!(cursor_pose(&track, 0.5 + ms as f32 / 1000.0, 0.0, 1.0), rest, "{ms} ms");
}
// « Strong » : il touche plus tôt, appuie plus longtemps et plus penché, rebondit plus haut.
let at = |ms: f32, level: f32| cursor_pose(&track, 0.5 + ms / 1000.0, level, 1.0);
let posed = |level: f32| (0..260).filter(|&ms| at(ms as f32, level).clearance == 0.0).count();
assert!(at(15.0, 2.0).clearance == 0.0 && at(15.0, 1.0).clearance > 0.0, "plus tôt");
assert!(posed(2.0) > posed(1.0) + 30, "plus longtemps : {} ms / {} ms", posed(2.0), posed(1.0));
assert!(at(CONTACT_S * 1000.0, 2.0).pitch > down.pitch + 5f32.to_radians(), "plus penché");
let peak = |level: f32| (100..260).map(|ms| at(ms as f32, level).clearance).fold(0.0, f32::max);
assert!(peak(1.0) > MODEL_HOVER * 1.3 && peak(2.0) > peak(1.0) + 0.3 * MODEL_HOVER, "plus haut");
}

/// « Posé » veut dire posé, pour chaque état : au repos le point le plus bas du modèle est à
Expand Down Expand Up @@ -7784,6 +7805,8 @@ mod tests {
let quiet = crate::cursor::CursorTrack::new(samples, vec![], vec![]);
assert!(cursor_pose(&clicked, 1.05, MODEL_CLICK_BOUNCE_REF, 1.0).yaw > cursor_pose(&quiet, 1.05, MODEL_CLICK_BOUNCE_REF, 1.0).yaw + 1e-3);
assert!(cursor_pose(&clicked, 3.5, MODEL_CLICK_BOUNCE_REF, 1.0).yaw.abs() < 1e-3);
// À « None », le clic ne fait rien tourner : seul le mouvement compte.
assert_eq!(cursor_pose(&clicked, 1.05, 0.0, 1.0).yaw, cursor_pose(&quiet, 1.05, 0.0, 1.0).yaw);
}

/// Les pointeurs penchent et tournent comme la flèche ; les curseurs centrés restent à plat et
Expand Down
66 changes: 66 additions & 0 deletions crates/compositor/tests/cursor_tap_render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,72 @@ fn the_impact_ring_is_centred_on_the_click() {
assert!(late == late_quiet, "l'anneau survit à sa fenêtre");
}

/// Planche des niveaux du clic (opt-in, `OPENSCREEN_CURSOR_TAP_OUT`) : None, Light et Strong en
/// lignes, des instants autour du clic de la flèche en colonnes, agrandis ×2 autour de la pointe.
/// Et la même chose en mouvement, les trois niveaux côte à côte, à 30 i/s puis ralentie ×4.
#[test]
fn cursor_press_levels() {
let Ok(dir) = std::env::var("OPENSCREEN_CURSOR_TAP_OUT") else {
eprintln!("OPENSCREEN_CURSOR_TAP_OUT absent — saute");
return;
};
let Some(gpu) = gpu() else { return };
let comp = Compositor::new_sized(&gpu, OUT.0, OUT.1).expect("compositor");
let screen = MockFrame::new(&gpu);
let track = gesture("levels");
let frames = format!("{dir}/levels");
std::fs::create_dir_all(&frames).expect("dossier des frames");
let ([_, _], tc) = TARGETS[0];
let levels = [0.0, 1.0, 2.0];
let img = |rgba: Vec<u8>| image::RgbaImage::from_raw(OUT.0, OUT.1, rgba).expect("readback");
let (_, tip) = render(&comp, &screen, &scene_json("null", false, 1.0), &track, tc + CONTACT_S);
let tip = tip.expect("curseur");
const CELL: u32 = 180;
let cell = |rgba: Vec<u8>| {
let (x, y) = ((tip[0] - CELL as f32 * 0.35) as u32, (tip[1] - CELL as f32 * 0.3) as u32);
image::imageops::crop_imm(&img(rgba), x, y, CELL, CELL).to_image()
};
let offsets = [-0.1, 0.0, 0.017, 0.033, 0.05, 0.083, 0.117, 0.15, 0.2, 0.27];
let mut sheet = image::RgbaImage::new(offsets.len() as u32 * 2 * CELL, levels.len() as u32 * 2 * CELL);
for (row, level) in levels.iter().enumerate() {
for (col, dt) in offsets.iter().enumerate() {
let (rgba, _) = render(&comp, &screen, &scene_json("null", false, *level), &track, tc + dt);
let big = image::imageops::resize(&cell(rgba), 2 * CELL, 2 * CELL, image::imageops::FilterType::Nearest);
image::imageops::overlay(&mut sheet, &big, (col as u32 * 2 * CELL) as i64, (row as u32 * 2 * CELL) as i64);
}
}
sheet.save(format!("{dir}/press-levels.png")).expect("planche");
println!("planche des niveaux : lignes {levels:?}, colonnes {offsets:?} s après le clic");
for (speed, name) in [(1.0, "press-levels.mp4"), (0.25, "press-levels-slow.mp4")] {
let n = (0.6 / speed * 30.0) as u32;
for k in 0..n {
let t = tc - 0.2 + k as f32 / 30.0 * speed;
let mut row = image::RgbaImage::new(levels.len() as u32 * 2 * CELL, 2 * CELL);
for (i, level) in levels.iter().enumerate() {
let (rgba, _) = render(&comp, &screen, &scene_json("null", false, *level), &track, t);
let big = image::imageops::resize(&cell(rgba), 2 * CELL, 2 * CELL, image::imageops::FilterType::Triangle);
image::imageops::overlay(&mut row, &big, (i as u32 * 2 * CELL) as i64, 0);
}
row.save(format!("{frames}/{k:03}.png")).expect("frame");
}
let ffmpeg = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../thirdparty/ffmpeg-n8.1.2-win64-lgpl-shared/bin/ffmpeg.exe");
let status = std::process::Command::new(ffmpeg)
.args(["-y", "-loglevel", "error", "-framerate", "30", "-i"])
.arg(format!("{frames}/%03d.png"))
.args(["-c:v", "h264_mf", "-b:v", "8M", "-pix_fmt", "nv12"])
.arg(format!("{dir}/{name}"))
.status();
// Un échec garde les images, pour relancer ou comprendre.
let status = status.expect("lancement de ffmpeg");
assert!(status.success(), "ffmpeg a échoué pour {name} : {status}");
println!("ffmpeg : {status:?} -> {dir}/{name}");
for k in 0..n {
let _ = std::fs::remove_file(format!("{frames}/{k:03}.png"));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}

/// Vidéo et planche à regarder (opt-in, `OPENSCREEN_CURSOR_TAP_OUT`).
#[test]
fn cursor_tap_video() {
Expand Down
21 changes: 13 additions & 8 deletions docs/3d-effects-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,13 +327,18 @@ Deux décisions :

- **Hauteur** : 0,35 unité de garde au repos. Chaque clic le pose **au contact** avec la courbe
`tap()`, celle de l'impact du clic, dont le creux (49,5 ms) est celui de la pression de
`bounce()`. Gain 1,25 : posé de 27 à 74 ms, donc au moins une image au contact jusqu'à
21 i/s. Tous les états.
- **Tangage** : queue relevée, pointe vers le bas, 18° au repos, jusqu'à +10° au creux de la
pression, fois `clickBounce` (« Light » = 1, le défaut ; « Strong » = 2).
`bounce()`, puis il **rebondit** au-dessus de sa garde et s'y repose. Tous les états.
- **Le niveau `clickBounce` règle tout le geste**, comme le rebond d'échelle en 2D : l'appui est
`tap()` × 1,25 × niveau, borné à 1, le rebond la remontée de `tap()` × 2,5 × niveau.
« Light » (1, le défaut) : posé de 27 à 74 ms, donc au moins une image au contact jusqu'à
21 i/s, rebond à +41 % de la garde. « Strong » (2) : posé de 12 à 100 ms, rebond à +82 %.
« None » : le modèle ne bouge pas au clic. Auparavant, contact et rebond ne dépendaient pas du
niveau : de face, les trois se ressemblaient (retour produit du 27/09/2026).
- **Tangage** : queue relevée, pointe vers le bas, 18° au repos, +10° pendant l'appui, fois le
niveau.
- **Lacet** : vers la vitesse horizontale lissée (`follow_at`, différence centrée sur ±100 ms),
et vers la cible d'un clic dans les 300 ms qui le précèdent. Borné en douceur à ±25°
(`tanh`), nul au repos, continu en `t`.
et vers la cible d'un clic dans les 300 ms qui le précèdent (pas à « None », où le clic ne
fait rien bouger). Borné en douceur à ±25° (`tanh`), nul au repos, continu en `t`.
- **Part « pointeur »** (`pointing_factor`), tirée du seul hotspot, sans table par état :
distance du hotspot au centre du sprite rapportée au demi-côté (norme max), `smoothstep` de
0,3 à 0,75. Tangage et lacet en sont multipliés. Flèche (0,83), main qui pointe, aide,
Expand All @@ -347,8 +352,8 @@ Deux décisions :
repos, au plan au contact, jamais dessous. Mesuré sur huit états, à plat et iso : 0 pour les
états centrés (face du dessous au sol), +1,6 à +1,8 % d'unité pour les pointeurs (le chanfrein
arrondit le coin qui touche), sous le seuil testé de 2 %.
- **Pas de rebond d'échelle** en 3D : le contact le remplace.
- **Écrasement** : sur la même courbe `tap()`, l'épaisseur descend à 70 % au creux, puis le
- **Pas de rebond d'échelle** en 3D : le geste le remplace, en vrai relief.
- **Écrasement** : pendant l'appui, l'épaisseur descend à 70 %, puis le
rebond l'épaissit un instant (104,8 % à 165 ms). Fois `clickBounce`, jamais sous 55 %
(deux chanfreins et un peu de flanc). Le dessus descend, le point le plus bas reste posé, le
hotspot reste sur son pixel. L'empreinte, elle, ne change pas : un étalement de 5 % défaisait
Expand Down
Loading