From e68ba02c1ab3a57ffe5a7b8eb643688a61647134 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sun, 27 Sep 2026 02:06:18 +0200 Subject: [PATCH 1/2] fix(cursor): let the bounce level drive the 3D cursor's whole click gesture The modelled cursor's contact and rebound did not depend on the level, so None, Light and Strong looked the same from the front. The level now drives the whole gesture, in real depth rather than a scale bounce: None stays still, Light touches from 27 to 74 ms and rebounds 41% above its hover, Strong touches from 12 to 100 ms, leans twice as far and rebounds 82% above it. Adds an opt-in sheet and video of the three. --- crates/compositor/src/cursor.rs | 2 +- crates/compositor/src/frame_geometry.rs | 55 +++++++++++------ crates/compositor/tests/cursor_tap_render.rs | 63 ++++++++++++++++++++ docs/3d-effects-v2.md | 17 ++++-- 4 files changed, 112 insertions(+), 25 deletions(-) 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..ca6a447fd 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`. /// - 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); @@ -7732,10 +7743,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 à diff --git a/crates/compositor/tests/cursor_tap_render.rs b/crates/compositor/tests/cursor_tap_render.rs index 8500b163c..af0bd362a 100644 --- a/crates/compositor/tests/cursor_tap_render.rs +++ b/crates/compositor/tests/cursor_tap_render.rs @@ -370,6 +370,69 @@ 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(); + 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..2693c7152 100644 --- a/docs/3d-effects-v2.md +++ b/docs/3d-effects-v2.md @@ -327,10 +327,15 @@ 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`. @@ -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 From fc0057e2b74d1b57cd7afc66eb6378764f1fb3ad Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sun, 27 Sep 2026 02:16:35 +0200 Subject: [PATCH 2/2] fix(cursor): no click aim at None, fail the levels video on an ffmpeg error At None the modelled cursor still turned toward a click's target; the aim now follows the level like the rest of the gesture. The opt-in levels video now fails, and keeps its frames, when ffmpeg fails. --- crates/compositor/src/frame_geometry.rs | 8 ++++++-- crates/compositor/tests/cursor_tap_render.rs | 3 +++ docs/3d-effects-v2.md | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index ca6a447fd..e1fde0ee3 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -3849,7 +3849,7 @@ pub struct CursorPose { /// - 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 : pendant l'appui, l'épaisseur descend à 1 − `MODEL_SQUASH`, puis le rebond @@ -3881,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 { @@ -3888,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(); @@ -7803,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 af0bd362a..e668d5c1b 100644 --- a/crates/compositor/tests/cursor_tap_render.rs +++ b/crates/compositor/tests/cursor_tap_render.rs @@ -426,6 +426,9 @@ fn cursor_press_levels() { .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")); diff --git a/docs/3d-effects-v2.md b/docs/3d-effects-v2.md index 2693c7152..599bce6a2 100644 --- a/docs/3d-effects-v2.md +++ b/docs/3d-effects-v2.md @@ -337,8 +337,8 @@ Deux décisions : - **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,