diff --git a/crates/compositor/src/cursor.rs b/crates/compositor/src/cursor.rs index b34a37a08..d6a90d31d 100644 --- a/crates/compositor/src/cursor.rs +++ b/crates/compositor/src/cursor.rs @@ -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; diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 79e5f210b..e1fde0ee3 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -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; @@ -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, @@ -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); @@ -3870,6 +3881,8 @@ 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 { @@ -3877,7 +3890,7 @@ pub fn cursor_pose( } 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(); @@ -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 à @@ -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 diff --git a/crates/compositor/tests/cursor_tap_render.rs b/crates/compositor/tests/cursor_tap_render.rs index 8500b163c..e668d5c1b 100644 --- a/crates/compositor/tests/cursor_tap_render.rs +++ b/crates/compositor/tests/cursor_tap_render.rs @@ -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| 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| { + 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")); + } + } +} + /// Vidéo et planche à regarder (opt-in, `OPENSCREEN_CURSOR_TAP_OUT`). #[test] fn cursor_tap_video() { diff --git a/docs/3d-effects-v2.md b/docs/3d-effects-v2.md index 52e266667..599bce6a2 100644 --- a/docs/3d-effects-v2.md +++ b/docs/3d-effects-v2.md @@ -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, @@ -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