diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index f7c5204b6..d0e2fc72f 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -43,9 +43,9 @@ pub use crate::frame_geometry::{ live_params_from_scene, webcam_shape_code, FIXTURE_FRAMES, LayerCB, LiveParams, OUT_H, OUT_W, }; use crate::frame_geometry::{ - cursor_model_cb, cursor_sprite_cb, cursor_sprite_dst, parse_hex, plan_cursor, plan_frame, - tilted_screen_cb, CursorPlacement, CursorPlanInput, FrameGeometryInput, ShadowCaster, - SpriteShape, + cursor_glass_cb, cursor_model_cb, cursor_sprite_cb, cursor_sprite_dst, parse_hex, plan_cursor, + plan_frame, tilted_screen_cb, CursorPlacement, CursorPlanInput, FrameGeometryInput, + ShadowCaster, SpriteShape, }; use crate::scene::{Scene, SceneBackground, WallpaperMotion}; @@ -3041,6 +3041,27 @@ impl Compositor { } } + // Le verre a plat de Glass Lens (mode 19) : la copie de l'image composee qu'il + // refracte au binding 1 (texY), comme le cristal du mode 15. Parite Windows/macOS. + if let Some(name) = sprite.glass.as_deref().filter(|_| plan.model.is_none()) { + for placement in placements { + let Some(cb) = + cursor_glass_cb(placement, plan.size_px, name, plan.alpha, plan.clip, [rw, rh]) + else { + continue; + }; + binds.push(self.make_bind(&cb, Some((&self.ann_copy_view, &dummy, &dummy)), &dummy)); + grow(cb.dst); + } + return (!binds.is_empty()).then_some(CursorDraw { + _tex: vec![(tex, view)], + binds, + bounds, + impacts, + glass: plan.glass, + }); + } + for placement in placements { // Geometrie partagee avec Windows et macOS (`cursor_sprite_cb`) : // mode 7 droit, ou mode 13 pose sur le plan -- le clip vit alors @@ -6039,7 +6060,7 @@ mod tests { for state in ["arrow", "pointer"] { let still = model_track(state, false, 0.5); let sprite = compose_model(&comp, &blue, &extruded, &still); - for theme in ["studio-ink", "prism-glow", "pop-coral", "pixel-candy", "star-sprout"] { + for theme in ["studio-ink", "prism-glow", "pop-coral", "pixel-candy", "star-sprout", "glass-lens"] { // Le sprite reste celui du theme par defaut : seul le nom pose le modele. let json = extruded .replace(&format!(r#"/{state}.png","#), &format!(r#"/{state}.png","sculpt":"{theme}/{state}","#)); @@ -6049,7 +6070,7 @@ mod tests { failures.push(format!("{theme}/{state}: le sprite extrude au lieu du modele")); } let (tip, u) = model_tip(&json, &still); - let glass = theme == "prism-glow"; + let glass = matches!(theme, "prism-glow" | "glass-lens"); let mask = if glass { model_covered(&hover, &hover_b, &bare, &bare_orange) } else { @@ -6094,6 +6115,79 @@ mod tests { assert!(failures.is_empty(), "{failures:#?}"); } + /// Pendant de `the_flat_glass_lens_refracts_the_picture` (Windows) : Glass Lens, 3D éteinte, + /// passe par le verre à plat du WGSL (mode 19), sans ombre, si bien que tout pixel changé est + /// le sien. Il tient au hotspot, en bas à droite de lui ; l'écran se voit au travers ; le trait + /// le cerne ; et le verre déplace les barres de l'écran. + #[test] + fn the_flat_glass_lens_refracts_the_picture() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized"); + let (y, uv) = model_screen_planes(false); + let blue = FakeFrame::from_planes(&gpu, 640, 360, &y, &uv); + let (y, uv) = model_screen_planes(true); + let orange = FakeFrame::from_planes(&gpu, 640, 360, &y, &uv); + let flat = model_scene_json("null", Some(false), "glass-lens", true, 5.0); + let hidden = model_scene_json("null", Some(false), "glass-lens", false, 5.0); + let bare = compose_model(&comp, &blue, &hidden, &model_track("arrow", false, 0.5)); + let bare_orange = compose_model(&comp, &orange, &hidden, &model_track("arrow", false, 0.5)); + let mut failures = Vec::new(); + for state in ["arrow", "pointer"] { + let still = model_track(state, false, 0.5); + let sprite = compose_model(&comp, &blue, &flat, &still); + let json = flat.replace(&format!(r#"/{state}.png","#), &format!(r#"/{state}.png","glass":"glass-lens/{state}","#)); + let (on_blue, on_orange) = (compose_model(&comp, &blue, &json, &still), compose_model(&comp, &orange, &json, &still)); + model_save(&format!("glass-flat-{state}"), &on_blue); + if on_blue == sprite { + failures.push(format!("glass-lens/{state}: le sprite plat au lieu du verre")); + } + let (tip, u) = model_tip(&json, &still); + let rgb = |img: &[u8], i: usize| [img[i * 4], img[i * 4 + 1], img[i * 4 + 2]]; + let (mut body, mut c, mut near) = (0usize, [0.0f32; 2], f32::MAX); + let (mut through, mut line, mut moved) = (0usize, 0usize, 0usize); + for i in 0..1280 * 720 { + let (a, b) = (rgb(&on_blue, i), rgb(&on_orange, i)); + if a == rgb(&bare, i) && b == rgb(&bare_orange, i) { + continue; + } + let (x, y) = ((i % 1280) as f32, (i / 1280) as f32); + body += 1; + c = [c[0] + x, c[1] + y]; + near = near.min((x - tip[0]).hypot(y - tip[1])); + if a != b { + through += 1; + moved += ((model_luma(&a) - model_luma(&rgb(&bare, i))).abs() > 25.0) as usize; + } else if model_luma(&a) < 60.0 { + line += 1; + } + } + let c = [c[0] / body.max(1) as f32, c[1] / body.max(1) as f32]; + println!( + "glass-lens/{state} a plat : unite {u:.1} px, corps {body} px, a {near:.1} px du hotspot {tip:?}, \ + centroide {c:?}, {through} px laissent voir l'ecran, {line} px de trait, {moved} px deplaces" + ); + if (body as f32) < 0.12 * u * u { + failures.push(format!("glass-lens/{state}: {body} px de corps pour {u:.0} px d'unite")); + } + if near > 0.08 * u { + failures.push(format!("glass-lens/{state}: le verre est a {near:.1} px du hotspot")); + } + if !(c[0] > tip[0] && c[1] > tip[1] + 0.2 * u) { + failures.push(format!("glass-lens/{state}: corps en {c:?}, pas en bas a droite de {tip:?}")); + } + if through * 10 < body * 5 { + failures.push(format!("glass-lens/{state}: l'ecran ne se voit que sur {through} px")); + } + if line * 20 < body { + failures.push(format!("glass-lens/{state}: {line} px de trait seulement")); + } + if moved * 50 < through { + failures.push(format!("glass-lens/{state}: le verre ne deplace l'ecran que sur {moved} px")); + } + } + assert!(failures.is_empty(), "{failures:#?}"); + } + /// Le métrage, puis ses flous de confidentialité, puis le curseur, net par-dessus (parité /// Windows et macOS) : le cristal de Prism Glow réfracte l'image déjà floutée. Sous le flou, /// des rayures fines décalées d'une colonne donnent la même image floutée ; si le verre lisait diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index 5440cfb51..07ea2dd7c 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -2189,6 +2189,17 @@ impl Compositor { } } let (rw, rh) = (self.render_w as f32, self.render_h as f32); + // Le verre à plat de Glass Lens (mode 19) : la copie qu'il réfracte est déjà en + // texture(5) (`CursorPlan::glass`). Parité Windows. + if model.is_none() { + let glass = sprite.glass.as_deref().and_then(|name| { + crate::frame_geometry::cursor_glass_cb(placement, size_px, name, a, clip, [rw, rh]) + }); + if let Some(cb) = glass { + self.draw_solid(enc, &cb); + return Ok(Some(cb.dst)); + } + } let ar = iw as f32 / ih.max(1) as f32; let (pw, ph) = if ar >= 1.0 { (size_px, size_px / ar) } else { (size_px * ar, size_px) }; let cb = crate::frame_geometry::cursor_sprite_cb( diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 11952713d..26dc8f67b 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -1837,6 +1837,17 @@ impl Compositor { Err(e) => eprintln!("[curseur] champ de \"{path}\" : {e:#}"), } } + // Le verre à plat de Glass Lens (mode 19) : la copie qu'il réfracte est déjà en t5 + // (`CursorPlan::glass`). + if model.is_none() { + let glass = sprite.glass.as_deref().and_then(|name| { + crate::frame_geometry::cursor_glass_cb(placement, size_px, name, a, clip, [self.rw(), self.rh()]) + }); + if let Some(cb) = glass { + self.draw_layer(&cb); + return Ok(Some(cb.dst)); + } + } let ar = iw as f32 / ih as f32; let (pw, ph) = if ar >= 1.0 { (size_px, size_px / ar) } else { (size_px * ar, size_px) }; let cb = crate::frame_geometry::cursor_sprite_cb( diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index a8ef080e4..0decdb21f 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -66,12 +66,13 @@ pub struct LayerCB { impl LayerCB { /// Le calque est l'un des modèles 3D — le curseur modelé (mode 15), l'impact de son clic - /// (16), l'appareil et son ombre (17) — que seule la variante « modèles » du shader de calque - /// compile. Dans le même shader que les autres, leurs branches fixaient l'occupation de TOUS - /// les calques (168 VGPR sur RADV) ; les trois backends décident donc du shader d'un calque - /// par ce test, au moment de le dessiner (cf. le haut de `vk_shaders/layer.wgsl`). + /// (16), l'appareil et son ombre (17) — ou le verre à plat de Glass Lens (19), qui en reprend + /// les formes : seule la variante « modèles » du shader de calque les compile. Dans le même + /// shader que les autres, leurs branches fixaient l'occupation de TOUS les calques (168 VGPR + /// sur RADV) ; les trois backends décident donc du shader d'un calque par ce test, au moment de + /// le dessiner (cf. le haut de `vk_shaders/layer.wgsl`). pub fn needs_models(&self) -> bool { - self.mode > 14.5 && self.mode < 17.5 + (self.mode > 14.5 && self.mode < 17.5) || self.mode > 18.5 } } @@ -1212,6 +1213,61 @@ pub fn cursor_sprite_cb( } } +// ============ Verre à plat (mode 19) ============ +// +// Glass Lens, 3D éteinte : la lentille de son modèle (`sculpt.rs`, `s_glass` dans les shaders) +// vue de face, posée sur l'image comme le sprite plat (le quad projeté et le warp inverse du +// mode 13), mais dessinée par le shader, qui réfracte la copie de l'image composée +// (`CursorPlan::glass`) au travers d'un relief tiré des mêmes formes. Le PNG du thème ne sert plus +// qu'au sélecteur. +// +// Emplacements du `LayerCB` au mode 19 : +// dst, quad_px bbox du rect du modèle projeté (sortie 0..1, px) +// fx, src_prev coins TL, TR puis BR, BL, en px locaux à la bbox (comme au mode 13) +// dst_prev rect de clip « Clip to canvas » (sortie 0..1) +// mb.x 1 = warp projectif (caméra réelle) +// src.xy l'unité du modèle en fractions de la sortie (x, y) : le décalage de la réfraction +// color.rg coin haut-gauche du rect, repère du modèle (unités), comme au mode 15 +// color.b l'unité du modèle en px de sortie : l'antialiasing +// color.a opacité +// radius_px rapport w/h du rect (`sprite_size` des shaders) +// trail_a.x le curseur sculpté (`SpriteShape::sculpt`) +// Texture : la copie de l'image composée, t5/texture(5) (`texDof`) sous Windows et macOS, au +// binding 1 (`texY`) sous Linux, comme pour le cristal du mode 15. + +/// Le verre à plat du curseur sculpté `name` posé en `placement` : la boîte du modèle a pour plus +/// grand côté `size_px`, comme au mode 15. `None` pour un nom qui n'est pas un modèle. +pub fn cursor_glass_cb( + placement: CursorPlacement, + size_px: f32, + name: &str, + alpha: f32, + clip: [f32; 4], + render_px: [f32; 2], +) -> Option { + let shape = crate::sculpt::sculpted_shape(name)?; + let [rw, rh] = render_px; + let sprite_px = [shape.size[0] * size_px, shape.size[1] * size_px]; + let mut cb = cursor_sprite_cb(placement, sprite_px, shape.hotspot, alpha, clip, render_px); + if cb.mode < 12.5 { + // Écran droit (mode 7) : son rect, sous la forme du mode 13. Le warp bilinéaire d'un + // rect est exact. + let [w, h] = [cb.dst[2] * rw, cb.dst[3] * rh]; + cb.quad_px = [w, h]; + cb.fx = [0.0, 0.0, w, 0.0]; + cb.src_prev = [w, h, 0.0, h]; + cb.dst_prev = clip; + } + // L'unité telle que le plan la montre : le bord gauche du rect projeté (TL -> BL). + let unit_px = (cb.src_prev[2] - cb.fx[0]).hypot(cb.src_prev[3] - cb.fx[1]) / shape.size[1]; + cb.mode = 19.0; + cb.src = [unit_px / rw, unit_px / rh, 0.0, 0.0]; + cb.color = [shape.origin()[0], shape.origin()[1], unit_px, alpha]; + cb.radius_px = shape.size[0] / shape.size[1]; + cb.trail_a = [shape.sculpt as f32, 0.0, 0.0, 0.0]; + Some(cb) +} + /// Gain de l'éclairage de la caméra réelle : une lampe posée sur la caméra, dont la lumière /// décroît avec le carré de la distance. 1 = la décroissance physique ; moins, pour que le /// contenu reste lisible. L'œil en orbite passe loin de l'axe : à 22° d'azimut, l'écart d'un bord @@ -3538,9 +3594,10 @@ pub struct CursorPlan { /// L'impact des clics récents sur l'écran (mode 16), à dessiner SOUS le curseur. Vide sans /// curseur modélisé, sans clic récent ou à `clickBounce` nul. pub impacts: Vec, - /// Le curseur modélisé est le cristal de Prism Glow : il réfracte l'image déjà composée, le - /// métrage et ses flous de confidentialité, que le backend lui copie juste avant de le - /// dessiner. Seuls des pixels floutés passent ainsi à travers le verre. + /// Le curseur est de verre — le cristal de Prism Glow ou la lentille de Glass Lens en 3D, le + /// verre à plat de Glass Lens sans elle (`SceneCursorSprite::glass`) : il réfracte l'image déjà + /// composée, le métrage et ses flous de confidentialité, que le backend lui copie juste avant + /// de le dessiner. Seuls des pixels floutés passent ainsi à travers le verre. pub glass: bool, } @@ -3673,8 +3730,8 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option, dst: [f32; 4]| -> Option { cxy.and_then(|p| { @@ -3843,7 +3900,10 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option s.sculpt.as_deref().is_some_and(crate::sculpt::refracts), + None => sprite.and_then(|s| s.glass.as_deref()).is_some_and(|n| crate::sculpt::sculpted_shape(n).is_some()), + }, }) } @@ -4507,12 +4567,13 @@ mod tests { assert!(BackgroundKey::of(None, blur, fallback, size).is_some(), "fond de repli"); } - /// Les modèles 3D, et eux seuls, passent par la variante « modèles » du shader de calque. + /// Les modèles 3D et le verre à plat qui en reprend les formes, eux seuls, passent par la + /// variante « modèles » du shader de calque. #[test] fn only_the_3d_models_need_the_models_shader() { - for mode in 0..=18 { + for mode in 0..=19 { let cb = super::LayerCB { mode: mode as f32, ..Default::default() }; - assert_eq!(cb.needs_models(), (15..=17).contains(&mode), "mode {mode}"); + assert_eq!(cb.needs_models(), (15..=17).contains(&mode) || mode == 19, "mode {mode}"); } } @@ -8077,6 +8138,7 @@ mod tests { hotspot_x: hx, hotspot_y: hy, sculpt: None, + glass: None, }, ); } @@ -8309,6 +8371,7 @@ mod tests { hotspot_x: 0.1947, hotspot_y: 0.0656, sculpt: Some("studio-ink/arrow".into()), + glass: None, }; themed.cursor.cursor_sprites.insert("arrow".into(), themed_arrow.clone()); assert_eq!( @@ -8322,6 +8385,56 @@ mod tests { assert!(model_plan([0.0; 3], &bare, &track, 0.3, true).expect("plan").model.is_none()); } + /// Glass Lens est de verre 3D éteinte aussi : le plan demande la copie de l'image composée + /// sans modèle (le verre à plat), puis avec lui (sa lentille sculptée). Le thème par défaut + /// n'a rien à réfracter. + #[test] + fn the_glass_lens_is_glass_with_and_without_3d() { + let track = still_track(vec![]); + let mut scene = model_scene(); + scene.cursor.theme = "glass-lens".into(); + let arrow = crate::scene::SceneCursorSprite { + path: "glass-lens/arrow.png".into(), + hotspot_x: 0.3045, + hotspot_y: 0.0255, + sculpt: Some("glass-lens/arrow".into()), + glass: Some("glass-lens/arrow".into()), + }; + scene.cursor.cursor_sprites.insert("arrow".into(), arrow); + let flat = model_plan([0.0; 3], &scene, &track, 0.3, false).expect("plan"); + assert!(flat.model.is_none() && flat.glass, "3D éteinte : le verre à plat"); + let modelled = model_plan([0.0; 3], &scene, &track, 0.3, true).expect("plan"); + assert!(modelled.model.is_some() && modelled.glass, "3D : la lentille sculptée"); + let plain = model_plan([0.0; 3], &model_scene(), &track, 0.3, false).expect("plan"); + assert!(!plain.glass); + } + + /// Le verre à plat (mode 19) pose la boîte du modèle sculpté comme le sprite plat pose le + /// sien : son hotspot au point visé, son plus grand côté à `size_px`, ses coins sous la forme + /// du mode 13 et le clip dans `dst_prev`. Il porte le modèle et l'unité qui règle la + /// réfraction ; un nom qui n'est pas un modèle n'a pas de verre. + #[test] + fn the_flat_glass_layer_sits_the_model_box_on_the_hotspot() { + let render = [1920.0, 1080.0]; + let (clip, center) = ([0.1, 0.2, 0.7, 0.6], [0.5, 0.4]); + let place = CursorPlacement::Upright { center }; + let cb = cursor_glass_cb(place, 100.0, "glass-lens/arrow", 0.8, clip, render).expect("modèle"); + let shape = crate::sculpt::sculpted_shape("glass-lens/arrow").unwrap(); + assert_eq!(cb.mode, 19.0); + assert!(cb.needs_models()); + assert_eq!((cb.dst_prev, cb.trail_a[0], cb.color[3]), (clip, shape.sculpt as f32, 0.8)); + let [w, h] = [shape.size[0] * 100.0, shape.size[1] * 100.0]; + assert!((cb.quad_px[0] - w).abs() < 1e-3 && (cb.quad_px[1] - h).abs() < 1e-3); + assert_eq!(cb.fx, [0.0, 0.0, cb.quad_px[0], 0.0]); + assert_eq!(cb.src_prev, [cb.quad_px[0], cb.quad_px[1], 0.0, cb.quad_px[1]]); + let tip = [cb.dst[0] * render[0] + shape.hotspot[0] * w, cb.dst[1] * render[1] + shape.hotspot[1] * h]; + assert!((tip[0] - center[0] * render[0]).abs() < 1e-2, "{tip:?}"); + assert!((tip[1] - center[1] * render[1]).abs() < 1e-2, "{tip:?}"); + assert!((cb.color[2] - 100.0).abs() < 1e-3); + assert!((cb.src[0] - 100.0 / render[0]).abs() < 1e-6 && (cb.src[1] - 100.0 / render[1]).abs() < 1e-6); + assert!(cursor_glass_cb(place, 100.0, "studio-ink/text", 0.8, clip, render).is_none()); + } + /// Les poses d'essai, pour chaque état de `MODEL_STATES` : au repos, posé, tourné. fn model_cases() -> Vec<(String, CursorPlan, SpriteShape, crate::cursor_sdf::CursorSdf)> { let scene = model_scene(); diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index bddd7d55c..4b9da53cd 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -595,6 +595,11 @@ pub struct SceneCursorSprite { /// shaders le modèlent au lieu d'extruder le sprite, qui reste l'art en 2D. #[serde(default)] pub sculpt: Option, + /// Le verre de cet état à plat, `"/<état>"` comme `sculpt` mais 3D éteinte comprise : + /// le shader dessine ce modèle en lentille de verre posée sur l'image (mode 19, + /// `frame_geometry::cursor_glass_cb`) au lieu du sprite, qui ne sert plus qu'au sélecteur. + #[serde(default)] + pub glass: Option, } #[derive(Debug, Clone, Copy, Deserialize)] diff --git a/crates/compositor/src/sculpt.rs b/crates/compositor/src/sculpt.rs index 89fa9a277..b22662353 100644 --- a/crates/compositor/src/sculpt.rs +++ b/crates/compositor/src/sculpt.rs @@ -2,9 +2,11 @@ //! shaders (mode 15, `sculpt_proto`) au lieu d'extruder leur PNG — voxels (Pixel Candy), pièces //! cerclées du trait de leur dessin (Studio Ink, Pop Coral, Star Sprout). Prism Glow est à part : //! un MAILLAGE tracé sur son dessin à facettes, un cristal lancé de rayons boîte par boîte -//! dans son serti marine (`prism_mesh`). Ce module en tient ce que la géométrie doit savoir côté -//! CPU : l'identifiant que lit le shader, et la boîte du modèle (`SpriteShape`) qui pose le -//! hotspot, règle la garde au sol et borne la boîte de dessin. +//! dans son serti marine (`prism_mesh`). Glass Lens est une lentille de verre cerclée d'un trait +//! sombre et d'un liseré de verre (`s_glass`) : le seul dont le verre se dessine aussi à plat +//! (mode 19, `frame_geometry::cursor_glass_cb`). Ce module en tient ce que la géométrie doit +//! savoir côté CPU : l'identifiant que lit le shader, et la boîte du modèle (`SpriteShape`) qui +//! pose le hotspot, règle la garde au sol et borne la boîte de dessin. //! //! Les formes sont écrites dans le repère du PROTOTYPE où elles ont été dessinées : hauteur du //! curseur 1, x à droite, y VERS LE HAUT, z vers la caméra, l'écran en z = 0. Les constantes @@ -43,9 +45,13 @@ const BOX_CORAL_ARROW: [f32; 3] = [-0.35, 0.6, 0.06]; const BOX_CORAL_HAND: [f32; 3] = [-0.38, 0.62, 0.04]; const BOX_SPROUT_ARROW: [f32; 3] = [-0.08, 0.87, 0.03]; const BOX_SPROUT_HAND: [f32; 3] = [-0.33, 0.56, 0.03]; +/// Glass Lens : la flèche et le gant de Studio Ink, que le liseré de verre élargit au-delà du +/// trait (`GLASS_RIM` des shaders). `scripts/generate-glass-lens-cursor.mjs` lit ces boîtes. +const BOX_GLASS_ARROW: [f32; 3] = [-0.1, 0.56, 0.03]; +const BOX_GLASS_HAND: [f32; 3] = [-0.4, 0.63, 0.03]; /// Dans l'ordre des identifiants du shader. -const THEMES: [&str; 5] = ["studio-ink", "prism-glow", "pop-coral", "pixel-candy", "star-sprout"]; +const THEMES: [&str; 6] = ["studio-ink", "prism-glow", "pop-coral", "pixel-candy", "star-sprout", "glass-lens"]; /// La boîte du modèle (x0, x1, haut) dans le prototype. fn model_box(theme: usize, arrow: bool) -> [f32; 3] { @@ -58,13 +64,16 @@ fn model_box(theme: usize, arrow: bool) -> [f32; 3] { ("pixel-candy", false) => BOX_PIXEL_HAND, ("star-sprout", true) => BOX_SPROUT_ARROW, ("star-sprout", false) => BOX_SPROUT_HAND, + ("glass-lens", true) => BOX_GLASS_ARROW, + ("glass-lens", false) => BOX_GLASS_HAND, (other, _) => unreachable!("thème sculpté inconnu : {other}"), } } -/// Le curseur sculpté que nomme la scène est de verre (Prism Glow) : il réfracte ce qui est dessous. +/// Le curseur sculpté que nomme la scène est de verre (le cristal de Prism Glow, la lentille de +/// Glass Lens) : il réfracte ce qui est dessous. pub fn refracts(name: &str) -> bool { - sculpted_shape(name).is_some_and(|s| THEMES[((s.sculpt - 1) / 2) as usize] == "prism-glow") + sculpted_shape(name).is_some_and(|s| matches!(THEMES[((s.sculpt - 1) / 2) as usize], "prism-glow" | "glass-lens")) } /// Le curseur sculpté que nomme la scène (`"/<état>"`, cf. `resolveCursorSprites`), sous @@ -109,7 +118,11 @@ pub fn sculpted_shape(name: &str) -> Option { mod tests { use super::*; - const NAMES: [&str; 10] = [ + /// La largeur du contour de Glass Lens, trait sombre et liseré de verre compris : ses boîtes + /// en dépendent, les shaders la portent. + const GLASS_RIM: f32 = 0.085; + + const NAMES: [&str; 12] = [ "studio-ink/arrow", "studio-ink/pointer", "prism-glow/arrow", @@ -120,6 +133,8 @@ mod tests { "pixel-candy/pointer", "star-sprout/arrow", "star-sprout/pointer", + "glass-lens/arrow", + "glass-lens/pointer", ]; #[test] @@ -167,6 +182,7 @@ mod tests { ("SCULPT_HOVER", Z_LOW), ("SCULPT_VOX", VOX), ("PRISM_BEVEL", crate::prism_mesh::BEVEL), + ("GLASS_RIM", GLASS_RIM), ] { let line = src .lines() @@ -230,13 +246,14 @@ mod tests { } } - /// Seul Prism Glow est de verre, flèche et main. + /// Seuls Prism Glow et Glass Lens sont de verre, flèche et main. #[test] - fn only_prism_glow_refracts() { + fn only_the_glass_themes_refract() { for name in NAMES { - assert_eq!(refracts(name), name.starts_with("prism-glow/"), "{name}"); + let glass = name.starts_with("prism-glow/") || name.starts_with("glass-lens/"); + assert_eq!(refracts(name), glass, "{name}"); } - assert!(!refracts("prism-glow/text")); + assert!(!refracts("prism-glow/text") && !refracts("glass-lens/text")); } /// Le maillage de Prism Glow tient dans sa boîte (le shader n'y lance de rayons que là), son diff --git a/crates/compositor/src/shaders.hlsl b/crates/compositor/src/shaders.hlsl index c17d207d9..847882303 100644 --- a/crates/compositor/src/shaders.hlsl +++ b/crates/compositor/src/shaders.hlsl @@ -14,7 +14,7 @@ cbuffer Layer : register(b0) float4 src; // u0,v0,u1,v1 dans l'espace source 0..1 ; modes 15 et 17 : décalage px du rayon, P, U float2 quad_px; // taille du quad en pixels (pour les SDF) float radius_px; // rayon des coins arrondis en px (0 = aucun) ; mode 17 : rayon des coins hauts du corps (unités du modèle) - float mode; // 0 = vidéo NV12, 1 = couleur pleine, 2 = ombre portée, 4 = curseur, 15 = curseur 3D, 16 = impact du clic, 17 = appareil modelé, 18 = écran cadré flouté en bloc + float mode; // 0 = vidéo NV12, 1 = couleur pleine, 2 = ombre portée, 4 = curseur, 15 = curseur 3D, 16 = impact du clic, 17 = appareil modelé, 18 = écran cadré flouté en bloc, 19 = curseur de verre à plat float4 color; // couleur pleine / teinte (ombre : rgb + opacité dans a) ; mode 15 : coin du sprite, écrasement, opacité ; mode 17 : .r = l'appareil (1 portable, 2 téléphone, 3 moniteur), .g = thème sombre, .b = rayon des coins bas du corps, .a = opacité float4 fx; // fx.x = spread ombre (px), fx.y,fx.z libres ; mode 15 : rotation du plan (rad), tangage ; mode 17 : rotation du plan (rad), épaisseur float4 src_prev; // src à la frame précédente (flou de mouvement par vélocité) ; mode 15 : hotspot, lacet ; mode 17 : marges du corps (unités du modèle) @@ -933,6 +933,80 @@ float2 s_rimmed(float3 p, int theme, int shape) return s_opu(r, float2(s_ellipsoid(e, float3(0.075, 0.13, 0.1)) * rs, 5.0)); } +// ---- Glass Lens ---- +// Une lentille de verre dans un trait sombre, et au-delà un liseré de verre. Ses formes sont la +// flèche et le gant de Studio Ink (RIM_POLY 0, RIM_GLOVE 1) ; mesurées depuis le bord de la +// lentille, le trait en occupe GLASS_LINE et le contour entier GLASS_RIM (miroir de `sculpt.rs`). +// En volume (mode 15, `s_glass`) : un socle de verre sous toute la silhouette, de SCULPT_HOVER à +// GLASS_TOP, arrondi sur toute la largeur du liseré ; la lentille, bombée de GLASS_DOME, qui en +// sort dans le trait ; le trait, une bande graphite au dessus plat sur le bord de la lentille. +// À plat (mode 19, `cursor_glass`), le même relief vu de face. Le verre (matière GLASS_MAT) entre +// par sa face bombée et sort par le dessous plat du socle vers l'image (`glass_shade`). +static const float GLASS_LINE = 0.04; +static const float GLASS_RIM = 0.085; +static const float GLASS_TOP = 0.18; +static const float GLASS_DOME = 0.03; +// La lentille monte sur cette largeur depuis son bord, puis reste à plat. +static const float GLASS_RISE = 0.08; +static const float GLASS_IOR = 1.5; +static const float GLASS_DISPERSION = 0.012; +static const float GLASS_MAT = 10.0; +// À plat : l'air sous le socle, que le mode 15 tient de la garde au sol du modèle (prototype). +static const float GLASS_GAP_FLAT = 0.15; +// L'écart des deux prises de part et d'autre du point réfracté, rapporté au décalage +// (`glass_smear`). +static const float GLASS_SMEAR = 0.15; +// La lueur propre du verre (linéaire) : sur un fond noir, la lentille reste un verre fumé visible. +static const float GLASS_LIFT = 0.02; +// L'ombre de la lentille, rapportée à celle d'un modèle opaque : le verre laisse passer la lumière. +static const float GLASS_SHADOW = 0.6; + +// Glass Lens dans le plan (prototype) : x = la silhouette, y = la lentille (distances signées, +// négatives dedans). Le trait est entre 0 et GLASS_LINE de la lentille, le liseré au-delà ; les +// fentes entre les doigts, creusées dans la lentille, s'y remplissent du trait. Les formes +// descendent de ce que le contour a de plus que le trait de Studio Ink : la pointe et le bout de +// l'index restent au hotspot. +float2 s_glass2(float2 p, int shape) +{ + if (shape == 0) + { + float poly = s_rim_poly(p + float2(0.0, GLASS_RIM - 0.06), 0); + return float2(poly - GLASS_RIM, poly); + } + float2 q = p + float2(0.0, GLASS_RIM - RIM_GLOVE_R[5].z); + float2 v = s_rim_glove(q, 1, s_rim_poly(q, 14) - 0.05); + return float2(v.x - GLASS_RIM, v.y); +} + +// La hauteur du verre au-dessus de GLASS_TOP en fonction de la distance `d` à la lentille +// (`s_glass2`), et sa pente : x = la hauteur, y = sa dérivée. La lentille monte de GLASS_DOME sur +// GLASS_RISE ; le liseré tombe en quart de rond jusqu'au bord, l'arrondi du socle (`s_glass`). Le +// trait n'est pas du verre : rien ne s'y lit. +float2 s_glass_relief(float d) +{ + if (d < 0.0) + { + float u = saturate(-d / GLASS_RISE); + return float2(GLASS_DOME * u * (2.0 - u), -2.0 * GLASS_DOME * (1.0 - u) / GLASS_RISE); + } + float w = GLASS_RIM - GLASS_LINE; + float x = saturate((d - GLASS_LINE) / w); + float s = sqrt(max(1.0 - x * x, 0.05)); + return float2(w * (s - 1.0), -x / s); +} + +// Glass Lens en volume, repère du prototype : (distance, matière). +float2 s_glass(float3 p, int shape) +{ + float2 g = s_glass2(p.xy, shape); + float half_h = 0.5 * (GLASS_TOP - SCULPT_HOVER); + float slab = s_extrude(g.x, p.z - SCULPT_HOVER - half_h, half_h, min(GLASS_RIM - GLASS_LINE, half_h)); + float u = saturate(-g.y / GLASS_RISE); + float dome = 0.8 * s_extrude(g.y, p.z - GLASS_TOP, 0.009 + GLASS_DOME * u * (2.0 - u), 0.008); + float band = s_extrude(abs(g.y - 0.5 * GLASS_LINE) - 0.5 * GLASS_LINE, p.z - GLASS_TOP, 0.016, 0.008); + return s_opu(float2(min(slab, dome), GLASS_MAT), float2(band, 5.0)); +} + // Pixel Candy est un pixel art, dessiné une fois : la même grille donne ses PNG 2D et, ici, un cube // par pixel. Une ligne par entier, bit c = colonne c, ligne 0 en haut. `PIX_BODY` : les pixels // pleins ; `PIX_LINE`, `PIX_HI`, `PIX_SHADE` : ceux du contour prune, du reflet rose pâle et de @@ -2005,6 +2079,10 @@ float2 sculpt_proto(float3 p, int theme, int shape) { return s_voxels(p, shape); } + if (theme == 5) + { + return s_glass(p, shape); + } // Un seul appel pour les trois thèmes cerclés et leurs deux formes (FXC recopie chaque appel). return s_rimmed(p, theme, shape); } @@ -2192,6 +2270,12 @@ SculptMat sculpt_material(float mat, float3 p, int theme, int shape) // Le serti de Prism Glow : un émail marine, brillant sur son arrondi seulement (`model_shade`). return s_mat(s_lin(0.02, 0.05, 0.33), 0.35, 0.5, 0.05, 0.3); } + if (theme == 5) + { + // Le trait de Glass Lens : un graphite laqué, dont seuls les bords arrondis brillent (le + // verre a son propre ombrage, `glass_shade`). + return s_mat(s_lin(0.05, 0.055, 0.07), 0.25, 0.7, 0.0, 0.35); + } if (theme == 2) { // Corail : la flèche, le calque et les tirets de la main ; jaune : le gant, le calque et les @@ -2266,10 +2350,10 @@ float3 model_shade(float3 q, float3 n, float3 rd, float3 L, float fall, float sh // La matière d'un voxel se lit juste sous la surface : son flanc lit sa propre cellule. float3 p = sculpt_point(q) - float3(n.x, -n.y, n.z) * 0.01; m = sculpt_material(mat, p, (id - 1) / 2, (id - 1) % 2); - if (id == 3 || id == 4) + if (id == 3 || id == 4 || id > 10) { - // Le serti de Prism Glow, comme un sprite : son dessus plat, face à la lampe, prendrait - // tout son reflet et virerait au gris. + // Le serti de Prism Glow et le trait de Glass Lens, comme un sprite : leur dessus plat, + // face à la lampe, prendrait tout son reflet et virerait au gris. gloss = 1.0 - smoothstep(0.97, 0.995, abs(n.z)); } } @@ -2365,27 +2449,31 @@ float3 plane_to_world(float3 v, ModelFrame f) return float3(x * f.c.y + v.z * f.s.y, y * f.c.x - z * f.s.x, y * f.s.x + z * f.c.x); } -// Ce que montre l'image là où le rayon (q, d) du modèle retombe sur le plan : la copie de l'image -// composée faite juste avant le curseur (t5), flous de confidentialité compris, lue au pixel de -// sortie de ce point, en linéaire. Seuls des pixels déjà floutés passent donc à travers le verre. -// L'œil est en (-mb.zw, persp) du repère caméra, le rayon du pixel `local` y va vers +// L'uv de sortie du point où le rayon (q, d) du modèle retombe sur le plan, (-1, -1) s'il n'y +// retombe pas. L'œil est en (-mb.zw, persp) du repère caméra, le rayon du pixel `local` y va vers // (local + src.xy, -persp) : on remonte de ce point du plan à son `local`, puis à son uv de sortie. -// Hors de l'image, l'écran sombre du studio. -float3 prism_screen(float3 q, float3 d, float3 nz, float hz, float3 tip, float unit, ModelFrame f) +float2 screen_uv(float3 q, float3 d, float3 nz, float hz, float3 tip, float unit, ModelFrame f) { float denom = dot(d, nz); if (denom > -1e-4) { - return SCULPT_SCREEN * 0.2; + return float2(-1.0, -1.0); } float3 g = q + d * ((hz - dot(q, nz)) / denom); float3 w = plane_to_world(tip + unit * model_to_plane(g, f), f); float k = 1.0 - w.z / src.z; if (k < 1e-3) { - return SCULPT_SCREEN * 0.2; + return float2(-1.0, -1.0); } - float2 uv = dst.xy + ((w.xy + mb.zw) / k - src.xy) / quad_px * dst.zw; + return dst.xy + ((w.xy + mb.zw) / k - src.xy) / quad_px * dst.zw; +} + +// Ce que montre l'image en `uv` : la copie de l'image composée faite juste avant le curseur (t5), +// flous de confidentialité compris, en linéaire. Seuls des pixels déjà floutés passent donc à +// travers le verre. Hors de l'image, l'écran sombre du studio. +float3 screen_at(float2 uv) +{ if (any(uv < 0.0) || any(uv > 1.0)) { return SCULPT_SCREEN * 0.2; @@ -2393,6 +2481,12 @@ float3 prism_screen(float3 q, float3 d, float3 nz, float hz, float3 tip, float u return pow(texDof.SampleLevel(samp, uv, 0.0).rgb, 2.2); } +// Ce que montre l'image là où le rayon (q, d) du modèle retombe sur le plan. +float3 prism_screen(float3 q, float3 d, float3 nz, float hz, float3 tip, float unit, ModelFrame f) +{ + return screen_at(screen_uv(q, d, nz, hz, tip, unit, f)); +} + // La couleur (sRGB, comme `model_shade`) du cristal au point `p` de la facette `h`, vu le long de // `rd` : reflet du studio, l'enregistrement réfracté par canal (la dispersion) à travers les // facettes et le fond plat, la lueur de la couleur de la facette dans l'art, ses plis en liseré. @@ -2468,6 +2562,63 @@ float3 prism_shade(float3 p, float3 n, float3 rd, int h, float px, float3 l, flo return model_tonemap(col); } +// ---- Le verre de Glass Lens ---- + +// La lumière qu'un verre de normale `n` (vue le long de `rd`, éclairé par `L`, `l`, `fill`) +// ajoute à ce qu'il transmet `trans` (linéaire) : le reflet du studio pondéré de Fresnel, l'éclat +// net de la lampe et son halo, et un liseré de lumière où la surface se dérobe, plus franc du +// côté de la lampe. Commun au verre en volume et au verre à plat. +float3 glass_light(float3 trans, float3 n, float3 rd, float3 L, float fall, float3 l, float3 fill) +{ + float c = saturate(-dot(rd, n)); + float F = prism_fresnel(c, GLASS_IOR); + float ndh = saturate(dot(n, normalize(L - rd))); + float ndl = dot(n, L); + float3 key = s_lin(1.0, 0.97, 0.93) * 2.1 * fall; + float spe = (pow(ndh, 900.0) * 5.0 + pow(ndh, 40.0) * 0.12) * saturate(ndl * 4.0); + float edge = pow(1.0 - c, 4.0) * (0.25 + 0.75 * saturate(ndl + 0.3)); + float3 col = (1.0 - F) * (trans * s_lin(0.97, 0.985, 1.0) + GLASS_LIFT) + F * model_env(reflect(rd, n), 0.03, l, fill); + return col + key * spe + s_lin(0.92, 0.96, 1.0) * edge * 0.6; +} + +// L'image en `uv`, moyennée le long de la réfraction, de `uv0` (où le pixel tomberait sans verre) +// à `uv` : là où le bord de la lentille comprime l'image, trois prises au lieu d'une l'empêchent de +// s'émietter en pixels qui scintillent quand le curseur bouge. +float3 glass_smear(float2 uv, float2 uv0) +{ + float2 s = all(uv0 >= 0.0) ? (uv - uv0) * GLASS_SMEAR : float2(0.0, 0.0); + return (screen_at(uv) + screen_at(uv - s) + screen_at(uv + s)) / 3.0; +} + +// Couleur (sRGB, comme `model_shade`) du verre de Glass Lens au point `q` du modèle, de normale +// `n`, vu le long de `rd` : l'enregistrement réfracté par canal (la dispersion), entré par la face +// touchée, sorti par le dessous plat du socle vers l'image, comme le cristal de Prism Glow sort +// par son fond ; une réflexion totale y renvoie le studio. L'image transmise n'est pas tonemappée : +// sous la lentille, un blanc reste blanc. +float3 glass_shade(float3 q, float3 n, float3 rd, float3 L, float fall, float3 l, float3 fill, float3 nz, + float hz, float3 tip, float unit, ModelFrame f) +{ + float zb = -model_thick(); + float2 uv0 = screen_uv(q, rd, nz, hz, tip, unit, f); + float3 trans = 0.0; + [loop] for (int ch = 0; ch < 3; ch++) + { + float3 mask = float3(ch == 0, ch == 1, ch == 2); + float ior = GLASS_IOR + GLASS_DISPERSION * (ch - 1); + float3 d = refract(rd, n, 1.0 / ior); + float3 seen = model_env(d, 0.05, l, fill); + if (d.z < -1e-4) + { + float3 qb = q + d * ((zb - q.z) / d.z); + float3 d2 = refract(d, float3(0.0, 0.0, 1.0), ior); + seen = dot(d2, d2) < 1e-8 ? model_env(reflect(d, float3(0.0, 0.0, 1.0)), 0.05, l, fill) + : glass_smear(screen_uv(qb, d2, nz, hz, tip, unit, f), uv0); + } + trans += seen * mask; + } + return pow(saturate(glass_light(trans, n, rd, L, fall, l, fill)), 1.0 / 2.2); +} + // Les passes de la boucle unique de `cursor_model`, dans l'ordre. Chaque tour n'évalue le modèle // QU'UNE fois (`model_eval`) : FXC recopie une fonction à chaque appel, et un curseur sculpté est // assez gros pour qu'un appel par passe multiplie par neuf le temps de compilation de ps_main. @@ -2596,14 +2747,18 @@ float4 cursor_model(float2 local) if (cov_s > 0.0) { float3 c; - if (mat > PRISM_MAT - 0.5) + float3 tl = lamp - q; + float fall = SCULPT_LAMP_DIST * SCULPT_LAMP_DIST / dot(tl, tl); + if (abs(mat - PRISM_MAT) < 0.5) { c = prism_shade(q, n, ray, hc, t_best / dlen, l, fill, nz, hz, tip, unit, f); } + else if (mat > GLASS_MAT - 0.5) + { + c = glass_shade(q, n, ray, normalize(tl), fall, l, fill, nz, hz, tip, unit, f); + } else { - float3 tl = lamp - q; - float fall = SCULPT_LAMP_DIST * SCULPT_LAMP_DIST / dot(tl, tl); c = model_shade(q, n, ray, normalize(tl), fall, sh, ao, mat, l, fill); } acc += float4(c * cov_s, cov_s); @@ -2716,7 +2871,7 @@ float4 cursor_model(float2 local) plane_next = true; } } - if (stage == STAGE_NORMAL && mat > PRISM_MAT - 0.5) + if (stage == STAGE_NORMAL && abs(mat - PRISM_MAT) < 0.5) { // Le cristal : la normale de sa facette, sans sondes ; ni occlusion ni ombre // propre. Un bord réel à moins d'un pixel (le serti, un pli) appelle les trois @@ -2758,7 +2913,7 @@ float4 cursor_model(float2 local) { sub = 3; } - if (k == 0 && mat > 7.5) + if (k == 0 && mat > 7.5 && mat < PRISM_MAT + 0.5) { // Le serti de Prism Glow au bord du cristal : son contour à moins d'un pixel. int ps = prism_shape(); @@ -2842,11 +2997,96 @@ float4 cursor_model(float2 local) } float w = 1.0 / max(shaded, 1); - float shadow = inside * max(dropped * MODEL_SHADOW_ALPHA, contact * MODEL_CONTACT_ALPHA); + // La lentille de Glass Lens laisse passer la lumière : son ombre est plus claire. + float clear = sculpt_id() > 10 ? GLASS_SHADOW : 1.0; + float shadow = inside * clear * max(dropped * MODEL_SHADOW_ALPHA, contact * MODEL_CONTACT_ALPHA); float a = acc.a * w * color.a; return float4(acc.rgb * w * color.a, a + (1.0 - a) * shadow * color.a); // prémultiplié, ombre noire } +// ============ Verre à plat (mode 19) ============ +// Glass Lens, 3D éteinte (`frame_geometry::cursor_glass_cb`, qui documente les emplacements) : le +// relief de son modèle vu de face (`s_glass2`, `s_glass_relief`), posé sur l'image comme le sprite +// du mode 13. Un rayon de vue droit y entre, plie à la surface, traverse le verre jusqu'au dessous +// plat du socle, en sort et descend GLASS_GAP_FLAT jusqu'à l'image : la copie de l'image composée +// (t5), lue par canal (la dispersion), décalée d'autant. Le trait est une bande graphite. +float4 cursor_glass(float2 local, float2 pout) +{ + if (pout.x < dst_prev.x || pout.x > dst_prev.x + dst_prev.z || + pout.y < dst_prev.y || pout.y > dst_prev.y + dst_prev.w) + { + return float4(0.0, 0.0, 0.0, 0.0); + } + float3 r = quad_inverse(local, fx.xy, fx.zw, src_prev.xy, src_prev.zw, mb.x); + if (r.z < 0.5) + { + return float4(0.0, 0.0, 0.0, 0.0); + } + // Le point du prototype (y vers le haut) sous ce pixel, et un pixel en unités du prototype. + float2 m = color.rg + saturate(r.xy) * sprite_size(); + float2 p = float2(m.x, -m.y) / SCULPT_SCALE; + float px = 1.0 / max(color.b * SCULPT_SCALE, 1e-3); + int shape = (sculpt_id() - 1) % 2; + // La forme et le gradient de la lentille : trois évaluations dans une boucle, un seul appel + // pour FXC. + float2 g = 0.0; + float2 gd = 0.0; + float e = 0.25 * px; + [loop] for (int k = 0; k < 3; k++) + { + float2 v = s_glass2(p + (k == 1 ? float2(e, 0.0) : (k == 2 ? float2(0.0, e) : float2(0.0, 0.0))), shape); + if (k == 0) g = v; + else if (k == 1) gd.x = v.y; + else gd.y = v.y; + } + float cov = saturate(0.5 - g.x / px); + if (cov <= 0.0) + { + return float4(0.0, 0.0, 0.0, 0.0); + } + float2 out_dir = (gd - g.y) / e; + out_dir /= max(length(out_dir), 1e-4); + // Le relief, repère de la sortie (x à droite, y vers le bas, z vers le spectateur). + float2 relief = s_glass_relief(g.y); + float2 dir = float2(out_dir.x, -out_dir.y); + float3 n = normalize(float3(-relief.y * dir, 1.0)); + float3 rd = float3(0.0, 0.0, -1.0); + // Le verre : par canal, la réfraction à la surface, la traversée jusqu'au dessous du socle, + // la sortie, puis l'air jusqu'à l'image. Le liseré, lui, est un bord arrondi : la lumière en + // ressort par le côté, après sa seule largeur de verre. Décalage en unités du prototype, puis + // de la sortie. + bool rim = g.y > GLASS_LINE; + float depth = rim ? GLASS_RIM - GLASS_LINE : GLASS_TOP - SCULPT_HOVER + relief.x; + float3 trans = 0.0; + [loop] for (int ch = 0; ch < 3; ch++) + { + float3 mask = float3(ch == 0, ch == 1, ch == 2); + float ior = GLASS_IOR + GLASS_DISPERSION * (ch - 1); + float3 d = refract(rd, n, 1.0 / ior); + float3 d2 = refract(d, float3(0.0, 0.0, 1.0), ior); + float2 off = d.xy * depth / max(-d.z, 0.05); + if (!rim && dot(d2, d2) > 1e-8) + { + off += d2.xy * GLASS_GAP_FLAT / max(-d2.z, 0.05); + } + off *= SCULPT_SCALE * src.xy; + trans += glass_smear(pout + off, pout) * mask; + } + float3 col = glass_light(trans, n, rd, MODEL_LIGHT, 1.0, MODEL_LIGHT, MODEL_FILL); + // Le trait : une bande graphite au dessus plat, comme en volume ; ses bords arrondis sur un + // quart de sa largeur prennent la lumière du côté de la lampe. + float edge_k = saturate(1.0 - min(g.y, GLASS_LINE - g.y) / (0.25 * GLASS_LINE)); + float side = g.y < 0.5 * GLASS_LINE ? -1.0 : 1.0; + float3 nl = normalize(float3(side * edge_k * dir, 1.0)); + float3 hl = normalize(MODEL_LIGHT + float3(0.0, 0.0, 1.0)); + float3 line_col = s_lin(0.05, 0.055, 0.07) * (0.6 + 0.5 * saturate(dot(nl, MODEL_LIGHT))) + + s_lin(1.0, 0.97, 0.93) * 0.8 * edge_k * pow(saturate(dot(nl, hl)), 40.0); + float in_line = saturate(0.5 + g.y / px) * saturate(0.5 - (g.y - GLASS_LINE) / px); + col = lerp(col, line_col, in_line); + float a = cov * color.a; + return float4(pow(saturate(col), 1.0 / 2.2) * a, a); // prémultiplié +} + // ============ Impact du clic (mode 16) ============ // Sous le curseur modélisé : une tache de pression et un anneau posés SUR l'écran, centrés sur le // point cliqué. Même warp que le mode 13 (fx/src_prev = coins, mb.x = 1 : projectif) ; le carré @@ -3470,6 +3710,17 @@ float4 device_frame(float2 local) float4 ps_main(VSOut i) : SV_Target { + // mode 19 : VERRE À PLAT de Glass Lens (cf. `cursor_glass`). Testé avant le mode 18, dont la + // branche n'a pas de borne haute. C'est un modèle, compilé avec les modes 15 à 17. + if (mode > 18.5) + { +#ifdef LAYER_MODELS + return cursor_glass(i.local, i.pout); +#else + return float4(0.0, 0.0, 0.0, 0.0); +#endif + } + // mode 18 : l'écran CADRÉ (ombre, cadre, métrage, appareil) flouté comme UN objet rigide // (`FrameGeometry::screen_trail`). t2 = son rendu isolé, prémultiplié, à la taille de la // sortie. Chaque tap retrouve le point de l'objet qui couvrait ce pixel plus tôt sur la diff --git a/crates/compositor/src/shaders.metal b/crates/compositor/src/shaders.metal index 7ca87d3f2..2a469a504 100644 --- a/crates/compositor/src/shaders.metal +++ b/crates/compositor/src/shaders.metal @@ -57,7 +57,7 @@ struct Layer float4 src; // u0,v0,u1,v1 dans l'espace source 0..1 ; modes 15 et 17 : décalage px du rayon, P, U float2 quad_px; // taille du quad en pixels (pour les SDF) float radius_px; // rayon des coins arrondis en px (0 = aucun) ; mode 17 : rayon des coins hauts du corps (unités du modèle) - float mode; // 0 = vidéo NV12, 1 = couleur pleine, 2 = ombre portée, ..., 15 = curseur 3D, 16 = impact du clic, 17 = appareil modelé, 18 = écran cadré flouté en bloc + float mode; // 0 = vidéo NV12, 1 = couleur pleine, 2 = ombre portée, ..., 15 = curseur 3D, 16 = impact du clic, 17 = appareil modelé, 18 = écran cadré flouté en bloc, 19 = curseur de verre à plat float4 color; // couleur pleine / teinte (ombre : rgb + opacité dans a) ; mode 15 : coin du sprite, texel, opacité ; mode 17 : .r = l'appareil (1 portable, 2 téléphone, 3 moniteur), .g = thème sombre, .b = rayon des coins bas du corps, .a = opacité float4 fx; // fx.x = spread ombre (px), fx.y,fx.z libres ; mode 15 : rotation du plan (rad), tangage ; mode 17 : rotation du plan (rad), épaisseur float4 src_prev; // src à la frame précédente (flou de mouvement par vélocité) ; mode 15 : hotspot, lacet ; mode 17 : marges du corps (unités du modèle) @@ -879,6 +879,59 @@ static float2 s_rimmed(float3 p, int theme, int shape) return s_opu(r, float2(s_ellipsoid(e, float3(0.075, 0.13, 0.1)) * rs, 5.0)); } +// ---- Glass Lens ---- (cf. HLSL : une lentille de verre dans un trait sombre, un liseré de verre +// au-delà ; `s_glass` en volume, `cursor_glass` à plat, `glass_shade` pour le verre.) +constant float GLASS_LINE = 0.04; +constant float GLASS_RIM = 0.085; +constant float GLASS_TOP = 0.18; +constant float GLASS_DOME = 0.03; +constant float GLASS_RISE = 0.08; +constant float GLASS_IOR = 1.5; +constant float GLASS_DISPERSION = 0.012; +constant float GLASS_MAT = 10.0; +constant float GLASS_GAP_FLAT = 0.15; +constant float GLASS_SMEAR = 0.15; +constant float GLASS_LIFT = 0.02; +constant float GLASS_SHADOW = 0.6; + +// x = la silhouette, y = la lentille (distances signées du prototype, négatives dedans). +static float2 s_glass2(float2 p, int shape) +{ + if (shape == 0) + { + float poly = s_rim_poly(p + float2(0.0, GLASS_RIM - 0.06), 0); + return float2(poly - GLASS_RIM, poly); + } + float2 q = p + float2(0.0, GLASS_RIM - RIM_GLOVE_R[5].z); + float2 v = s_rim_glove(q, 1, s_rim_poly(q, 14) - 0.05); + return float2(v.x - GLASS_RIM, v.y); +} + +// La hauteur du verre au-dessus de GLASS_TOP selon la distance `d` à la lentille, et sa pente. +inline float2 s_glass_relief(float d) +{ + if (d < 0.0) + { + float u = saturate(-d / GLASS_RISE); + return float2(GLASS_DOME * u * (2.0 - u), -2.0 * GLASS_DOME * (1.0 - u) / GLASS_RISE); + } + float w = GLASS_RIM - GLASS_LINE; + float x = saturate((d - GLASS_LINE) / w); + float s = sqrt(max(1.0 - x * x, 0.05)); + return float2(w * (s - 1.0), -x / s); +} + +static float2 s_glass(float3 p, int shape) +{ + float2 g = s_glass2(p.xy, shape); + float half_h = 0.5 * (GLASS_TOP - SCULPT_HOVER); + float slab = s_extrude(g.x, p.z - SCULPT_HOVER - half_h, half_h, min(GLASS_RIM - GLASS_LINE, half_h)); + float u = saturate(-g.y / GLASS_RISE); + float dome = 0.8 * s_extrude(g.y, p.z - GLASS_TOP, 0.009 + GLASS_DOME * u * (2.0 - u), 0.008); + float band = s_extrude(abs(g.y - 0.5 * GLASS_LINE) - 0.5 * GLASS_LINE, p.z - GLASS_TOP, 0.016, 0.008); + return s_opu(float2(min(slab, dome), GLASS_MAT), float2(band, 5.0)); +} + // Pixel Candy : tables générées par scripts/generate-pixel-candy-voxels.mjs (cf. HLSL). // constant int PIX_BODY[32] = { 1, 3, 7, 15, 31, 63, 127, 255, 511, 1023, 2047, 127, 247, 243, 480, 192, 48, 120, 120, 120, 504, 4088, 32760, 65534, 65535, 65535, 65534, 32766, 32764, 16380, 16376, 16376 }; @@ -1912,6 +1965,10 @@ static float2 sculpt_proto(float3 p, int theme, int shape) { return s_voxels(p, shape); } + if (theme == 5) + { + return s_glass(p, shape); + } return s_rimmed(p, theme, shape); } @@ -2067,6 +2124,7 @@ static SculptMat sculpt_material(float mat, float3 p, int theme, int shape) return s_mat(s_lin(0.1, 0.1, 0.11), 0.55, 0.05, 0.0, 0.1); } if (theme == 1) return s_mat(s_lin(0.02, 0.05, 0.33), 0.35, 0.5, 0.05, 0.3); + if (theme == 5) return s_mat(s_lin(0.05, 0.055, 0.07), 0.25, 0.7, 0.0, 0.35); if (theme == 2) { if ((primary && shape == 0) || (mat > 2.5 && mat < 3.5 && shape == 1)) return s_mat(s_lin(1.0, 0.40, 0.30), 0.85, 0.08, 0.15, 0.03); @@ -2124,7 +2182,7 @@ static float3 model_shade(float3 q, float3 n, float3 rd, float3 L, float fall, f { float3 p = sculpt_point(q, layer) - float3(n.x, -n.y, n.z) * 0.01; m = sculpt_material(mat, p, (id - 1) / 2, (id - 1) % 2); - if (id == 3 || id == 4) + if (id == 3 || id == 4 || id > 10) { gloss = 1.0 - smoothstep(0.97, 0.995, abs(n.z)); } @@ -2211,22 +2269,29 @@ static float3 plane_to_world(float3 v, ModelFrame f) return float3(x * f.c.y + v.z * f.s.y, y * f.c.x - z * f.s.x, y * f.s.x + z * f.c.x); } -static float3 prism_screen(float3 q, float3 d, float3 nz, float hz, float3 tip, float unit, ModelFrame f, - constant Layer &layer, texture2d texFrame) +// L'uv de sortie du point où le rayon (q, d) du modèle retombe sur le plan, (-1, -1) s'il n'y +// retombe pas (cf. HLSL). +static float2 screen_uv(float3 q, float3 d, float3 nz, float hz, float3 tip, float unit, ModelFrame f, + constant Layer &layer) { float denom = dot(d, nz); if (denom > -1e-4) { - return SCULPT_SCREEN * 0.2; + return float2(-1.0); } float3 g = q + d * ((hz - dot(q, nz)) / denom); float3 w = plane_to_world(tip + unit * model_to_plane(g, f), f); float k = 1.0 - w.z / layer.src.z; if (k < 1e-3) { - return SCULPT_SCREEN * 0.2; + return float2(-1.0); } - float2 uv = layer.dst.xy + ((w.xy + layer.mb.zw) / k - layer.src.xy) / layer.quad_px * layer.dst.zw; + return layer.dst.xy + ((w.xy + layer.mb.zw) / k - layer.src.xy) / layer.quad_px * layer.dst.zw; +} + +// La copie de l'image composée en `uv`, en linéaire ; hors de l'image, l'écran sombre du studio. +static float3 screen_at(float2 uv, texture2d texFrame) +{ if (any(uv < 0.0) || any(uv > 1.0)) { return SCULPT_SCREEN * 0.2; @@ -2234,6 +2299,12 @@ static float3 prism_screen(float3 q, float3 d, float3 nz, float hz, float3 tip, return pow(texFrame.sample(samp, uv, level(0.0)).rgb, float3(2.2)); } +static float3 prism_screen(float3 q, float3 d, float3 nz, float hz, float3 tip, float unit, ModelFrame f, + constant Layer &layer, texture2d texFrame) +{ + return screen_at(screen_uv(q, d, nz, hz, tip, unit, f, layer), texFrame); +} + static float3 prism_shade(float3 p, float3 n, float3 rd, int h, float px, float3 l, float3 fill, float3 nz, float hz, float3 tip, float unit, ModelFrame f, constant Layer &layer, texture2d texFrame) @@ -2302,6 +2373,55 @@ static float3 prism_shade(float3 p, float3 n, float3 rd, int h, float px, float3 return model_tonemap(col); } +// ---- Le verre de Glass Lens ---- (cf. HLSL) + +// Ce que le verre ajoute à ce qu'il transmet : Fresnel, l'éclat de la lampe, le liseré de lumière. +static float3 glass_light(float3 trans, float3 n, float3 rd, float3 L, float fall, float3 l, float3 fill) +{ + float c = saturate(-dot(rd, n)); + float F = prism_fresnel(c, GLASS_IOR); + float ndh = saturate(dot(n, normalize(L - rd))); + float ndl = dot(n, L); + float3 key = s_lin(1.0, 0.97, 0.93) * 2.1 * fall; + float spe = (pow(ndh, 900.0) * 5.0 + pow(ndh, 40.0) * 0.12) * saturate(ndl * 4.0); + float edge = pow(1.0 - c, 4.0) * (0.25 + 0.75 * saturate(ndl + 0.3)); + float3 col = (1.0 - F) * (trans * s_lin(0.97, 0.985, 1.0) + GLASS_LIFT) + F * model_env(reflect(rd, n), 0.03, l, fill); + return col + key * spe + s_lin(0.92, 0.96, 1.0) * edge * 0.6; +} + +// L'image en `uv`, moyennée le long de la réfraction depuis `uv0` (cf. HLSL). +static float3 glass_smear(float2 uv, float2 uv0, texture2d texFrame) +{ + float2 s = all(uv0 >= 0.0) ? (uv - uv0) * GLASS_SMEAR : float2(0.0); + return (screen_at(uv, texFrame) + screen_at(uv - s, texFrame) + screen_at(uv + s, texFrame)) / 3.0; +} + +static float3 glass_shade(float3 q, float3 n, float3 rd, float3 L, float fall, float3 l, float3 fill, float3 nz, + float hz, float3 tip, float unit, ModelFrame f, constant Layer &layer, + texture2d texFrame) +{ + float zb = -model_thick(layer); + float2 uv0 = screen_uv(q, rd, nz, hz, tip, unit, f, layer); + float3 trans = float3(0.0); + for (int ch = 0; ch < 3; ch++) + { + float3 mask = float3(float(ch == 0), float(ch == 1), float(ch == 2)); + float ior = GLASS_IOR + GLASS_DISPERSION * float(ch - 1); + float3 d = refract(rd, n, 1.0 / ior); + float3 seen = model_env(d, 0.05, l, fill); + if (d.z < -1e-4) + { + float3 qb = q + d * ((zb - q.z) / d.z); + float3 d2 = refract(d, float3(0.0, 0.0, 1.0), ior); + seen = dot(d2, d2) < 1e-8 + ? model_env(reflect(d, float3(0.0, 0.0, 1.0)), 0.05, l, fill) + : glass_smear(screen_uv(qb, d2, nz, hz, tip, unit, f, layer), uv0, texFrame); + } + trans += seen * mask; + } + return pow(saturate(glass_light(trans, n, rd, L, fall, l, fill)), float3(1.0 / 2.2)); +} + // Les passes de la boucle unique de `cursor_model` (cf. HLSL : un seul appel de `model_eval`). constant int STAGE_MARCH = 0; constant int STAGE_NORMAL = 1; @@ -2408,14 +2528,18 @@ static float4 cursor_model(float2 local, constant Layer &layer, if (cov_s > 0.0) { float3 c; - if (mat > PRISM_MAT - 0.5) + float3 tl = lamp - q; + float fall = SCULPT_LAMP_DIST * SCULPT_LAMP_DIST / dot(tl, tl); + if (abs(mat - PRISM_MAT) < 0.5) { c = prism_shade(q, n, ray, hc, t_best / dlen, l, fill, nz, hz, tip, unit, f, layer, texFrame); } + else if (mat > GLASS_MAT - 0.5) + { + c = glass_shade(q, n, ray, normalize(tl), fall, l, fill, nz, hz, tip, unit, f, layer, texFrame); + } else { - float3 tl = lamp - q; - float fall = SCULPT_LAMP_DIST * SCULPT_LAMP_DIST / dot(tl, tl); c = model_shade(q, n, ray, normalize(tl), fall, sh, ao, mat, l, fill, layer, texSdf, texImg); } acc += float4(c * cov_s, cov_s); @@ -2525,7 +2649,7 @@ static float4 cursor_model(float2 local, constant Layer &layer, plane_next = true; } } - if (stage == STAGE_NORMAL && mat > PRISM_MAT - 0.5) + if (stage == STAGE_NORMAL && abs(mat - PRISM_MAT) < 0.5) { n = prism_normal(hc, ray); k = 0; @@ -2562,7 +2686,7 @@ static float4 cursor_model(float2 local, constant Layer &layer, { sub = 3; } - if (k == 0 && mat > 7.5) + if (k == 0 && mat > 7.5 && mat < PRISM_MAT + 0.5) { int ps = prism_shape(layer); if (ps >= 0) @@ -2643,11 +2767,82 @@ static float4 cursor_model(float2 local, constant Layer &layer, } float w = 1.0 / max(shaded, 1); - float shadow = inside * max(dropped * MODEL_SHADOW_ALPHA, contact * MODEL_CONTACT_ALPHA); + // La lentille de Glass Lens laisse passer la lumière : son ombre est plus claire. + float clear = sculpt_id(layer) > 10 ? GLASS_SHADOW : 1.0; + float shadow = inside * clear * max(dropped * MODEL_SHADOW_ALPHA, contact * MODEL_CONTACT_ALPHA); float a = acc.a * w * layer.color.a; return float4(acc.rgb * w * layer.color.a, a + (1.0 - a) * shadow * layer.color.a); // prémultiplié, ombre noire } +// ============ Verre à plat (mode 19) ============ (cf. HLSL, `cursor_glass`) +static float4 cursor_glass(float2 local, float2 pout, constant Layer &layer, + texture2d texFrame) +{ + if (pout.x < layer.dst_prev.x || pout.x > layer.dst_prev.x + layer.dst_prev.z || + pout.y < layer.dst_prev.y || pout.y > layer.dst_prev.y + layer.dst_prev.w) + { + return float4(0.0); + } + float3 r = quad_inverse(local, layer.fx.xy, layer.fx.zw, layer.src_prev.xy, layer.src_prev.zw, layer.mb.x); + if (r.z < 0.5) + { + return float4(0.0); + } + float2 m = layer.color.rg + saturate(r.xy) * sprite_size(layer); + float2 p = float2(m.x, -m.y) / SCULPT_SCALE; + float px = 1.0 / max(layer.color.b * SCULPT_SCALE, 1e-3); + int shape = (sculpt_id(layer) - 1) % 2; + float2 g = float2(0.0); + float2 gd = float2(0.0); + float e = 0.25 * px; + for (int k = 0; k < 3; k++) + { + float2 v = s_glass2(p + (k == 1 ? float2(e, 0.0) : (k == 2 ? float2(0.0, e) : float2(0.0))), shape); + if (k == 0) g = v; + else if (k == 1) gd.x = v.y; + else gd.y = v.y; + } + float cov = saturate(0.5 - g.x / px); + if (cov <= 0.0) + { + return float4(0.0); + } + float2 out_dir = (gd - g.y) / e; + out_dir /= max(length(out_dir), 1e-4); + float2 relief = s_glass_relief(g.y); + float2 dir = float2(out_dir.x, -out_dir.y); + float3 n = normalize(float3(-relief.y * dir, 1.0)); + float3 rd = float3(0.0, 0.0, -1.0); + bool rim = g.y > GLASS_LINE; + float depth = rim ? GLASS_RIM - GLASS_LINE : GLASS_TOP - SCULPT_HOVER + relief.x; + float3 trans = float3(0.0); + for (int ch = 0; ch < 3; ch++) + { + float3 mask = float3(float(ch == 0), float(ch == 1), float(ch == 2)); + float ior = GLASS_IOR + GLASS_DISPERSION * float(ch - 1); + float3 d = refract(rd, n, 1.0 / ior); + float3 d2 = refract(d, float3(0.0, 0.0, 1.0), ior); + float2 off = d.xy * depth / max(-d.z, 0.05); + if (!rim && dot(d2, d2) > 1e-8) + { + off += d2.xy * GLASS_GAP_FLAT / max(-d2.z, 0.05); + } + off *= SCULPT_SCALE * layer.src.xy; + trans += glass_smear(pout + off, pout, texFrame) * mask; + } + float3 col = glass_light(trans, n, rd, MODEL_LIGHT, 1.0, MODEL_LIGHT, MODEL_FILL); + float edge_k = saturate(1.0 - min(g.y, GLASS_LINE - g.y) / (0.25 * GLASS_LINE)); + float side = g.y < 0.5 * GLASS_LINE ? -1.0 : 1.0; + float3 nl = normalize(float3(side * edge_k * dir, 1.0)); + float3 hl = normalize(MODEL_LIGHT + float3(0.0, 0.0, 1.0)); + float3 line_col = s_lin(0.05, 0.055, 0.07) * (0.6 + 0.5 * saturate(dot(nl, MODEL_LIGHT))) + + s_lin(1.0, 0.97, 0.93) * 0.8 * edge_k * pow(saturate(dot(nl, hl)), 40.0); + float in_line = saturate(0.5 + g.y / px) * saturate(0.5 - (g.y - GLASS_LINE) / px); + col = mix(col, line_col, in_line); + float a = cov * layer.color.a; + return float4(pow(saturate(col), float3(1.0 / 2.2)) * a, a); // prémultiplié +} + // ============ Impact du clic (mode 16) ============ // Port ligne pour ligne de `cursor_impact` (HLSL), dont les commentaires font foi. static float4 cursor_impact(float2 local, constant Layer &layer) @@ -3243,6 +3438,17 @@ fragment float4 ps_main(VSOut i [[stage_in]], // par le repli du mode 18, qui garde texture(2) pour son rendu isolé. texture2d texDof [[texture(5)]]) { + // mode 19 : VERRE À PLAT de Glass Lens (`cursor_glass`). Testé avant le mode 18, dont la + // branche n'a pas de borne haute. C'est un modèle, compilé avec les modes 15 à 17. + if (layer.mode > 18.5) + { +#ifdef LAYER_MODELS + return cursor_glass(i.local, i.pout, layer, texDof); +#else + return float4(0.0); +#endif + } + // mode 18 : l'écran CADRÉ (ombre, cadre, métrage, appareil) flouté comme UN objet rigide // (`FrameGeometry::screen_trail`), port 1:1 du HLSL. texImg = son rendu isolé, prémultiplié, // à la taille de la sortie. À plat, sa boîte va de `dst_prev` (frame précédente) à `fx` diff --git a/crates/compositor/src/vk_shaders/layer.wgsl b/crates/compositor/src/vk_shaders/layer.wgsl index ecf4960a1..48902d420 100644 --- a/crates/compositor/src/vk_shaders/layer.wgsl +++ b/crates/compositor/src/vk_shaders/layer.wgsl @@ -880,6 +880,54 @@ fn s_rimmed(p: vec3, theme: i32, shape: i32) -> vec2 { return s_opu(r, vec2(s_ellipsoid(e, vec3(0.075, 0.13, 0.1)) * rs, 5.0)); } +// ---- Glass Lens ---- (cf. HLSL : une lentille de verre dans un trait sombre, un liseré de verre +// au-delà ; `s_glass` en volume, `cursor_glass` à plat, `glass_shade` pour le verre.) +const GLASS_LINE: f32 = 0.04; +const GLASS_RIM: f32 = 0.085; +const GLASS_TOP: f32 = 0.18; +const GLASS_DOME: f32 = 0.03; +const GLASS_RISE: f32 = 0.08; +const GLASS_IOR: f32 = 1.5; +const GLASS_DISPERSION: f32 = 0.012; +const GLASS_MAT: f32 = 10.0; +const GLASS_GAP_FLAT: f32 = 0.15; +const GLASS_SMEAR: f32 = 0.15; +const GLASS_LIFT: f32 = 0.02; +const GLASS_SHADOW: f32 = 0.6; + +// x = la silhouette, y = la lentille (distances signées du prototype, négatives dedans). +fn s_glass2(p: vec2, shape: i32) -> vec2 { + if shape == 0 { + let poly = s_rim_poly(p + vec2(0.0, GLASS_RIM - 0.06), 0); + return vec2(poly - GLASS_RIM, poly); + } + let q = p + vec2(0.0, GLASS_RIM - RIM_GLOVE_R[5].z); + let v = s_rim_glove(q, 1, s_rim_poly(q, 14) - 0.05); + return vec2(v.x - GLASS_RIM, v.y); +} + +// La hauteur du verre au-dessus de GLASS_TOP selon la distance `d` à la lentille, et sa pente. +fn s_glass_relief(d: f32) -> vec2 { + if d < 0.0 { + let u = saturate(-d / GLASS_RISE); + return vec2(GLASS_DOME * u * (2.0 - u), -2.0 * GLASS_DOME * (1.0 - u) / GLASS_RISE); + } + let w = GLASS_RIM - GLASS_LINE; + let x = saturate((d - GLASS_LINE) / w); + let s = sqrt(max(1.0 - x * x, 0.05)); + return vec2(w * (s - 1.0), -x / s); +} + +fn s_glass(p: vec3, shape: i32) -> vec2 { + let g = s_glass2(p.xy, shape); + let half_h = 0.5 * (GLASS_TOP - SCULPT_HOVER); + let slab = s_extrude(g.x, p.z - SCULPT_HOVER - half_h, half_h, min(GLASS_RIM - GLASS_LINE, half_h)); + let u = saturate(-g.y / GLASS_RISE); + let dome = 0.8 * s_extrude(g.y, p.z - GLASS_TOP, 0.009 + GLASS_DOME * u * (2.0 - u), 0.008); + let band = s_extrude(abs(g.y - 0.5 * GLASS_LINE) - 0.5 * GLASS_LINE, p.z - GLASS_TOP, 0.016, 0.008); + return s_opu(vec2(min(slab, dome), GLASS_MAT), vec2(band, 5.0)); +} + // Pixel Candy : tables générées par scripts/generate-pixel-candy-voxels.mjs (cf. HLSL). // const PIX_BODY = array(1, 3, 7, 15, 31, 63, 127, 255, 511, 1023, 2047, 127, 247, 243, 480, 192, 48, 120, 120, 120, 504, 4088, 32760, 65534, 65535, 65535, 65534, 32766, 32764, 16380, 16376, 16376); @@ -1102,6 +1150,9 @@ fn sculpt_proto(p: vec3, theme: i32, shape: i32) -> vec2 { if theme == 3 { return s_voxels(p, shape); } + if theme == 5 { + return s_glass(p, shape); + } return s_rimmed(p, theme, shape); } @@ -1236,6 +1287,9 @@ fn sculpt_material(mat: f32, p: vec3, theme: i32, shape: i32) -> SculptMat if theme == 1 { return SculptMat(s_lin(0.02, 0.05, 0.33), 0.35, 0.5, 0.05, 0.3); } + if theme == 5 { + return SculptMat(s_lin(0.05, 0.055, 0.07), 0.25, 0.7, 0.0, 0.35); + } if theme == 2 { if (primary && shape == 0) || (mat > 2.5 && mat < 3.5 && shape == 1) { return SculptMat(s_lin(1.0, 0.40, 0.30), 0.85, 0.08, 0.15, 0.03); @@ -1298,8 +1352,9 @@ fn model_shade(q: vec3, n: vec3, rd: vec3, L: vec3, fall: f3 if id > 0 { let p = sculpt_point(q) - vec3(n.x, -n.y, n.z) * 0.01; m = sculpt_material(mat, p, (id - 1) / 2, (id - 1) % 2); - if id == 3 || id == 4 { - // Le serti de Prism Glow, comme un sprite : brillant sur son arrondi seulement. + if id == 3 || id == 4 || id > 10 { + // Le serti de Prism Glow et le trait de Glass Lens, comme un sprite : brillants sur + // leur arrondi seulement. gloss = 1.0 - smoothstep(0.97, 0.995, abs(n.z)); } } else { @@ -1384,25 +1439,36 @@ fn plane_to_world(v: vec3, f: ModelFrame) -> vec3 { return vec3(x * f.c.y + v.z * f.s.y, y * f.c.x - z * f.s.x, y * f.s.x + z * f.c.x); } -// L'image composee la ou le rayon retombe sur le plan (cf. HLSL) : sa copie au binding 1. -fn prism_screen(q: vec3, d: vec3, nz: vec3, hz: f32, tip: vec3, unit: f32, f: ModelFrame) -> vec3 { +// L'uv de sortie du point ou le rayon (q, d) du modele retombe sur le plan, (-1, -1) s'il n'y +// retombe pas (cf. HLSL). +fn screen_uv(q: vec3, d: vec3, nz: vec3, hz: f32, tip: vec3, unit: f32, f: ModelFrame) -> vec2 { let denom = dot(d, nz); if denom > -1e-4 { - return SCULPT_SCREEN * 0.2; + return vec2(-1.0); } let g = q + d * ((hz - dot(q, nz)) / denom); let w = plane_to_world(tip + unit * model_to_plane(g, f), f); let k = 1.0 - w.z / layer.src.z; if k < 1e-3 { - return SCULPT_SCREEN * 0.2; + return vec2(-1.0); } - let uv = layer.dst.xy + ((w.xy + layer.mb.zw) / k - layer.src.xy) / layer.quad_px * layer.dst.zw; + return layer.dst.xy + ((w.xy + layer.mb.zw) / k - layer.src.xy) / layer.quad_px * layer.dst.zw; +} + +// L'image composee en `uv` : sa copie au binding 1, en lineaire ; hors de l'image, l'ecran sombre +// du studio. +fn screen_at(uv: vec2) -> vec3 { if any(uv < vec2(0.0)) || any(uv > vec2(1.0)) { return SCULPT_SCREEN * 0.2; } return pow(textureSampleLevel(texY, samp, uv, 0.0).rgb, vec3(2.2)); } +// L'image composee la ou le rayon retombe sur le plan (cf. HLSL). +fn prism_screen(q: vec3, d: vec3, nz: vec3, hz: f32, tip: vec3, unit: f32, f: ModelFrame) -> vec3 { + return screen_at(screen_uv(q, d, nz, hz, tip, unit, f)); +} + fn prism_shade(p: vec3, n: vec3, rd: vec3, h: i32, px: f32, l: vec3, fill: vec3, nz: vec3, hz: f32, tip: vec3, unit: f32, f: ModelFrame) -> vec3 { let shape = prism_shape(); @@ -1466,6 +1532,53 @@ fn prism_shade(p: vec3, n: vec3, rd: vec3, h: i32, px: f32, l: ve return model_tonemap(col); } +// ---- Le verre de Glass Lens ---- (cf. HLSL) + +// Ce que le verre ajoute a ce qu'il transmet : Fresnel, l'eclat de la lampe, le liseré de lumiere. +fn glass_light(trans: vec3, n: vec3, rd: vec3, L: vec3, fall: f32, l: vec3, + fill: vec3) -> vec3 { + let c = saturate(-dot(rd, n)); + let F = prism_fresnel(c, GLASS_IOR); + let ndh = saturate(dot(n, normalize(L - rd))); + let ndl = dot(n, L); + let key = s_lin(1.0, 0.97, 0.93) * 2.1 * fall; + let spe = (pow(ndh, 900.0) * 5.0 + pow(ndh, 40.0) * 0.12) * saturate(ndl * 4.0); + let edge = pow(1.0 - c, 4.0) * (0.25 + 0.75 * saturate(ndl + 0.3)); + let col = (1.0 - F) * (trans * s_lin(0.97, 0.985, 1.0) + vec3(GLASS_LIFT)) + + F * model_env(reflect(rd, n), 0.03, l, fill); + return col + key * spe + s_lin(0.92, 0.96, 1.0) * edge * 0.6; +} + +// L'image en `uv`, moyennee le long de la refraction depuis `uv0` (cf. HLSL). +fn glass_smear(uv: vec2, uv0: vec2) -> vec3 { + let s = select(vec2(0.0), (uv - uv0) * GLASS_SMEAR, all(uv0 >= vec2(0.0))); + return (screen_at(uv) + screen_at(uv - s) + screen_at(uv + s)) / 3.0; +} + +fn glass_shade(q: vec3, n: vec3, rd: vec3, L: vec3, fall: f32, l: vec3, + fill: vec3, nz: vec3, hz: f32, tip: vec3, unit: f32, f: ModelFrame) -> vec3 { + let zb = -model_thick(); + let uv0 = screen_uv(q, rd, nz, hz, tip, unit, f); + var trans = vec3(0.0); + for (var ch = 0; ch < 3; ch++) { + let mask = vec3(f32(ch == 0), f32(ch == 1), f32(ch == 2)); + let ior = GLASS_IOR + GLASS_DISPERSION * f32(ch - 1); + let d = refract(rd, n, 1.0 / ior); + var seen = model_env(d, 0.05, l, fill); + if d.z < -1e-4 { + let qb = q + d * ((zb - q.z) / d.z); + let d2 = refract(d, vec3(0.0, 0.0, 1.0), ior); + if dot(d2, d2) < 1e-8 { + seen = model_env(reflect(d, vec3(0.0, 0.0, 1.0)), 0.05, l, fill); + } else { + seen = glass_smear(screen_uv(qb, d2, nz, hz, tip, unit, f), uv0); + } + } + trans = trans + seen * mask; + } + return pow(saturate(glass_light(trans, n, rd, L, fall, l, fill)), vec3(1.0 / 2.2)); +} + // Les passes de la boucle unique de `cursor_model` (cf. HLSL : un seul appel de `model_eval`). const STAGE_MARCH: i32 = 0; const STAGE_NORMAL: i32 = 1; @@ -1564,11 +1677,13 @@ fn cursor_model(local: vec2) -> vec4 { if stage == STAGE_SHADE { if cov_s > 0.0 { var c: vec3; - if mat > PRISM_MAT - 0.5 { + let tl = lamp - q; + let fall = SCULPT_LAMP_DIST * SCULPT_LAMP_DIST / dot(tl, tl); + if abs(mat - PRISM_MAT) < 0.5 { c = prism_shade(q, n, ray, hc, t_best / dlen, l, fill, nz, hz, tip, unit, f); + } else if mat > GLASS_MAT - 0.5 { + c = glass_shade(q, n, ray, normalize(tl), fall, l, fill, nz, hz, tip, unit, f); } else { - let tl = lamp - q; - let fall = SCULPT_LAMP_DIST * SCULPT_LAMP_DIST / dot(tl, tl); c = model_shade(q, n, ray, normalize(tl), fall, sh, ao, mat, l, fill); } acc = acc + vec4(c * cov_s, cov_s); @@ -1653,7 +1768,7 @@ fn cursor_model(local: vec2) -> vec4 { plane_next = true; } } - if stage == STAGE_NORMAL && mat > PRISM_MAT - 0.5 { + if stage == STAGE_NORMAL && abs(mat - PRISM_MAT) < 0.5 { // Le cristal : la normale de sa facette, ni occlusion ni ombre propre ; un bord // reel a moins d'un pixel appelle les trois rayons de plus. n = prism_normal(hc, ray); @@ -1681,7 +1796,7 @@ fn cursor_model(local: vec2) -> vec4 { if m.y != mat || abs(d - d_best) > 0.02 * t_best / dlen { sub = 3; } - if k == 0 && mat > 7.5 { + if k == 0 && mat > 7.5 && mat < PRISM_MAT + 0.5 { // Le serti de Prism Glow au bord du cristal : son contour a moins d'un pixel. let ps = prism_shape(); if ps >= 0 { @@ -1744,11 +1859,80 @@ fn cursor_model(local: vec2) -> vec4 { } let w = 1.0 / f32(max(shaded, 1)); - let shadow = inside * max(dropped * MODEL_SHADOW_ALPHA, contact * MODEL_CONTACT_ALPHA); + // La lentille de Glass Lens laisse passer la lumiere : son ombre est plus claire. + let clear = select(1.0, GLASS_SHADOW, sculpt_id() > 10); + let shadow = inside * clear * max(dropped * MODEL_SHADOW_ALPHA, contact * MODEL_CONTACT_ALPHA); let a = acc.a * w * layer.color.a; return vec4(acc.rgb * w * layer.color.a, a + (1.0 - a) * shadow * layer.color.a); // premultiplie, ombre noire } +// ============ Verre a plat (mode 19) ============ (cf. HLSL, `cursor_glass`) : la copie de +// l'image composee au binding 1. +fn cursor_glass(local: vec2, pout: vec2) -> vec4 { + if pout.x < layer.dst_prev.x || pout.x > layer.dst_prev.x + layer.dst_prev.z || + pout.y < layer.dst_prev.y || pout.y > layer.dst_prev.y + layer.dst_prev.w { + return vec4(0.0); + } + let r = quad_inverse(local, layer.fx.xy, layer.fx.zw, layer.src_prev.xy, layer.src_prev.zw, layer.mb.x); + if r.z < 0.5 { + return vec4(0.0); + } + let m = layer.color.rg + saturate(r.xy) * sprite_size(); + let p = vec2(m.x, -m.y) / SCULPT_SCALE; + let px = 1.0 / max(layer.color.b * SCULPT_SCALE, 1e-3); + let shape = (sculpt_id() - 1) % 2; + var g = vec2(0.0); + var gd = vec2(0.0); + let e = 0.25 * px; + for (var k = 0; k < 3; k++) { + let o = select(select(vec2(0.0), vec2(0.0, e), k == 2), vec2(e, 0.0), k == 1); + let v = s_glass2(p + o, shape); + if k == 0 { + g = v; + } else if k == 1 { + gd.x = v.y; + } else { + gd.y = v.y; + } + } + let cov = saturate(0.5 - g.x / px); + if cov <= 0.0 { + return vec4(0.0); + } + var out_dir = (gd - vec2(g.y)) / e; + out_dir = out_dir / max(length(out_dir), 1e-4); + let relief = s_glass_relief(g.y); + let dir = vec2(out_dir.x, -out_dir.y); + let n = normalize(vec3(-relief.y * dir, 1.0)); + let rd = vec3(0.0, 0.0, -1.0); + let rim = g.y > GLASS_LINE; + let depth = select(GLASS_TOP - SCULPT_HOVER + relief.x, GLASS_RIM - GLASS_LINE, rim); + var trans = vec3(0.0); + for (var ch = 0; ch < 3; ch++) { + let mask = vec3(f32(ch == 0), f32(ch == 1), f32(ch == 2)); + let ior = GLASS_IOR + GLASS_DISPERSION * f32(ch - 1); + let d = refract(rd, n, 1.0 / ior); + let d2 = refract(d, vec3(0.0, 0.0, 1.0), ior); + var off = d.xy * depth / max(-d.z, 0.05); + if !rim && dot(d2, d2) > 1e-8 { + off = off + d2.xy * GLASS_GAP_FLAT / max(-d2.z, 0.05); + } + off = off * SCULPT_SCALE * layer.src.xy; + trans = trans + glass_smear(pout + off, pout) * mask; + } + var col = glass_light(trans, n, rd, MODEL_LIGHT, 1.0, MODEL_LIGHT, MODEL_FILL); + let edge_k = saturate(1.0 - min(g.y, GLASS_LINE - g.y) / (0.25 * GLASS_LINE)); + let side = select(1.0, -1.0, g.y < 0.5 * GLASS_LINE); + let nl = normalize(vec3(side * edge_k * dir, 1.0)); + let hl = normalize(MODEL_LIGHT + vec3(0.0, 0.0, 1.0)); + let line_col = s_lin(0.05, 0.055, 0.07) * (0.6 + 0.5 * saturate(dot(nl, MODEL_LIGHT))) + + s_lin(1.0, 0.97, 0.93) * 0.8 * edge_k * pow(saturate(dot(nl, hl)), 40.0); + let in_line = saturate(0.5 + g.y / px) * saturate(0.5 - (g.y - GLASS_LINE) / px); + col = mix(col, line_col, in_line); + let a = cov * layer.color.a; + return vec4(pow(saturate(col), vec3(1.0 / 2.2)) * a, a); // premultiplie +} + // ---- Impact du clic (mode 16) ---- // Port ligne pour ligne de `cursor_impact` (HLSL), dont les commentaires font foi. fn cursor_impact(local: vec2) -> vec4 { @@ -2665,6 +2849,13 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { } // Mode 17 -- cadre d'appareil modelise (`device_frame`). return device_frame(i.local); + } else if layer.mode > 18.5 { + // Mode 19 -- verre a plat de Glass Lens (`cursor_glass`) : un modele, que seul le + // pipeline `LAYER_MODELS` compile, comme les modes 15 a 17. + if !LAYER_MODELS { + return vec4(0.0, 0.0, 0.0, 0.0); + } + return cursor_glass(i.local, i.pout); } else if layer.mode > 17.5 { // Mode 18 -- ecran cadre floute en bloc (`screen_trail`). return screen_trail(i.pout); diff --git a/crates/compositor/tests/cursor_model_render.rs b/crates/compositor/tests/cursor_model_render.rs index 70151158f..68f940cbb 100644 --- a/crates/compositor/tests/cursor_model_render.rs +++ b/crates/compositor/tests/cursor_model_render.rs @@ -83,6 +83,14 @@ fn gpu() -> Option { enum Tint { Blue, Orange, + /// Pour l'œil, pas pour les masques : un damier clair et sombre sur un dégradé de teintes, où + /// la réfraction d'un verre se lit d'un coup. + Checker, + /// Pour l'œil aussi : une page claire, des lignes de texte et un bouton bleu, ce qu'une démo + /// filme le plus souvent. + Page, + /// La même page en thème sombre : là où un trait noir ne se voit plus. + DarkPage, } /// Une frame NV12 synthétique : `data[0]` = la texture, `data[1]` = tranche 0, comme une frame @@ -97,9 +105,21 @@ struct FakeFrame { impl FakeFrame { fn new(gpu: &Gpu, tint: Tint) -> FakeFrame { let (w, h) = SRC; + let checker = matches!(tint, Tint::Checker); + let page = matches!(tint, Tint::Page | Tint::DarkPage); + let dark = matches!(tint, Tint::DarkPage); let (u, v) = match tint { Tint::Blue => (150, 120), - Tint::Orange => (100, 170), + Tint::Orange | Tint::Checker => (100, 170), + Tint::Page | Tint::DarkPage => (128, 128), + }; + // Le bouton de la page : un rect bleu, texte blanc. + let button = |row: usize, col: usize| (150..190).contains(&row) && (330..470).contains(&col); + // Une ligne de texte sur trois rangées de 18 px, en mots de largeur variable. + let text = |row: usize, col: usize| { + let (line, y) = (row / 18, row % 18); + let word = (col + line * 37) / 23; + (5..11).contains(&y) && (col + line * 37) % 23 < 4 + (word * 7 + line * 3) % 15 && line % 5 != 4 }; let desc = D3D11_TEXTURE2D_DESC { Width: w, @@ -124,12 +144,26 @@ impl FakeFrame { for row in 0..h as usize { for col in 0..w as usize { let bar = row % 24 >= 8 && row % 24 < 12 && (col / 40) % 3 != 2; - *dst.add(row * pitch + col) = if bar { 90 } else { 150 }; + let luma = match (checker, (row / 10 + col / 10) % 2 == 0) { + (true, light) => if light { 200 } else { 60 }, + _ if page && button(row, col) => if text(row, col) { 235 } else { 105 }, + _ if page && dark => if text(row, col) { 200 } else { 28 }, + _ if page => if text(row, col) { 45 } else { 232 }, + (false, _) => if bar { 90 } else { 150 }, + }; + *dst.add(row * pitch + col) = luma; } } for row in 0..(h / 2) as usize { for col in 0..w as usize { let uv = (h as usize + row) * pitch + col; + let (u, v) = if checker { + ((70 + col * 120 / w as usize) as u8, (190 - row * 2 * 120 / h as usize) as u8) + } else if page && button(2 * row, col) { + (175, 105) + } else { + (u, v) + }; *dst.add(uv) = if col % 2 == 0 { u } else { v }; } } @@ -148,11 +182,6 @@ impl FakeFrame { } } -/// Les seize sprites livrés, au format `cursorSprites` de la scène. -fn sprites_json() -> String { - sprites_json_with(None) -} - /// Le dossier des curseurs livrés, `public/cursors`. fn cursors_dir() -> String { std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -163,27 +192,38 @@ fn cursors_dir() -> String { /// Les thèmes d'origine et les hotspots de leur flèche et de leur main plates (`CURSOR_THEMES`, /// `src/lib/cursor/cursorThemes.ts`, sur 32). -const SCULPTED: [(&str, [f32; 2], [f32; 2]); 5] = [ +const SCULPTED: [(&str, [f32; 2], [f32; 2]); 6] = [ ("studio-ink", [6.2304, 2.0992], [12.848, 2.0704]), ("prism-glow", [6.3456, 2.0672], [11.968, 2.0352]), ("pop-coral", [10.4768, 2.1792], [12.3456, 2.0]), ("pixel-candy", [6.5, 2.0], [10.75, 2.0]), ("star-sprout", [4.7232, 2.1152], [13.1712, 2.0384]), + ("glass-lens", [6.2304, 2.0992], [12.848, 2.0704]), ]; -/// `sprites_json`, avec la flèche et la main plates du thème d'origine `sculpted` et leur curseur -/// sculpté, comme les passe `resolveCursorSprites` en 3D. -fn sprites_json_with(sculpted: Option<&str>) -> String { +/// Les thèmes de verre : le cristal de Prism Glow, la lentille de Glass Lens. +fn is_glass(theme: &str) -> bool { + matches!(theme, "prism-glow" | "glass-lens") +} + +/// Les seize sprites livrés au format `cursorSprites` de la scène, avec la flèche et la main +/// plates du thème d'origine `sculpted`, comme les passe `resolveCursorSprites` : leur curseur +/// sculpté en 3D, et le verre à plat de Glass Lens dans tous les cas. +fn sprites_json_with(sculpted: Option<&str>, model3d: bool) -> String { let dir = cursors_dir(); let theme = sculpted.map(|name| *SCULPTED.iter().find(|(t, ..)| *t == name).expect("thème d'origine")); let entries: Vec = STATES .iter() .map(|(key, [hx, hy])| match (theme, *key) { - (Some((name, [ax, ay], _)), "arrow") | (Some((name, _, [ax, ay])), "pointer") => format!( - r#""{key}":{{"path":"{dir}/{name}/{key}.png","hotspotX":{},"hotspotY":{},"sculpt":"{name}/{key}"}}"#, - ax / 32.0, - ay / 32.0 - ), + (Some((name, [ax, ay], _)), "arrow") | (Some((name, _, [ax, ay])), "pointer") => { + let sculpt = if model3d { format!(r#","sculpt":"{name}/{key}""#) } else { String::new() }; + let glass = if name == "glass-lens" { format!(r#","glass":"{name}/{key}""#) } else { String::new() }; + format!( + r#""{key}":{{"path":"{dir}/{name}/{key}.png","hotspotX":{},"hotspotY":{}{sculpt}{glass}}}"#, + ax / 32.0, + ay / 32.0 + ) + } _ => format!(r#""{key}":{{"path":"{dir}/default/{key}.png","hotspotX":{hx},"hotspotY":{hy}}}"#), }) .collect(); @@ -192,12 +232,17 @@ fn sprites_json_with(sculpted: Option<&str>) -> String { /// `model3d` : `None` = clé absente (payload d'avant le réglage). fn scene_json(rotation: &str, model3d: Option, theme: &str, motion_blur: f32, size: f32) -> String { - scene_json_with(rotation, model3d, theme, motion_blur, size, &sprites_json()) + scene_json_with(rotation, model3d, theme, motion_blur, size, &sprites_json_with(None, false)) } /// Un thème d'origine en 3D : sa flèche et sa main sculptées. fn sculpted_scene_json(rotation: &str, theme: &str, size: f32) -> String { - scene_json_with(rotation, Some(true), theme, 0.0, size, &sprites_json_with(Some(theme))) + scene_json_with(rotation, Some(true), theme, 0.0, size, &sprites_json_with(Some(theme), true)) +} + +/// Glass Lens, 3D éteinte : son verre à plat. +fn glass_scene_json(rotation: &str, size: f32) -> String { + scene_json_with(rotation, Some(false), "glass-lens", 0.0, size, &sprites_json_with(Some("glass-lens"), false)) } fn scene_json_with( @@ -799,7 +844,7 @@ fn the_sculpted_cursors_stand_at_the_hotspot() { let (hover, p) = render(&comp, &blue, &json, &still); let hover_b = render(&comp, &orange, &json, &still).0; save(&format!("sculpt-{theme}-{state}"), &hover); - let glass = theme == "prism-glow"; + let glass = is_glass(theme); let mask = if glass { covered_mask(&hover, &hover_b, &bare, &bare_orange) } else { opaque_mask(&hover, &hover_b, &bare) }; let (mut body, mut c, mut near) = (0usize, [0.0f32; 2], f32::MAX); for y in 0..720 { @@ -848,6 +893,75 @@ fn the_sculpted_cursors_stand_at_the_hotspot() { assert!(failures.is_empty(), "{failures:#?}"); } +/// Glass Lens, 3D éteinte : son verre à plat (mode 19), sans ombre, donc tout pixel changé est le +/// sien. La flèche et la main sont là, pointe au hotspot et corps en bas à droite ; l'écran se +/// voit au travers (une bonne part du corps change avec sa teinte) ; le trait les cerne (des +/// pixels sombres, les mêmes sur les deux teintes) ; et le verre réfracte : il déplace les barres +/// de l'écran, si bien que des pixels qui laissent voir l'écran n'y montrent pas le pixel d'en +/// dessous. +#[test] +fn the_flat_glass_lens_refracts_the_picture() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("compositor"); + let (blue, orange) = (FakeFrame::new(&gpu, Tint::Blue), FakeFrame::new(&gpu, Tint::Orange)); + let bare = render_any(&comp, &blue, &hidden_json("null"), &resting("glass-bare", false)).0; + let bare_orange = render_any(&comp, &orange, &hidden_json("null"), &resting("glass-bare", false)).0; + let mut failures = Vec::new(); + for state in ["arrow", "pointer"] { + let json = glass_scene_json("null", 5.0); + let still = resting_at(&format!("glass-flat-{state}"), Some(state), false, 0.5); + let (on_blue, p) = render(&comp, &blue, &json, &still); + let on_orange = render(&comp, &orange, &json, &still).0; + save(&format!("glass-flat-{state}"), &on_blue); + let rgb = |img: &[u8], i: usize| [img[i * 4], img[i * 4 + 1], img[i * 4 + 2], 255]; + let (mut body, mut c, mut near) = (0usize, [0.0f32; 2], f32::MAX); + let (mut through, mut line, mut moved) = (0usize, 0usize, 0usize); + for i in 0..1280 * 720 { + let (a, b) = (rgb(&on_blue, i), rgb(&on_orange, i)); + if a == rgb(&bare, i) && b == rgb(&bare_orange, i) { + continue; + } + let (x, y) = ((i % 1280) as f32, (i / 1280) as f32); + body += 1; + c = [c[0] + x, c[1] + y]; + near = near.min((x - p.tip[0]).hypot(y - p.tip[1])); + if a != b { + through += 1; + moved += ((luma(a) - luma(rgb(&bare, i))).abs() > 25.0) as usize; + } else if luma(a) < 60.0 { + line += 1; + } + } + let c = [c[0] / body.max(1) as f32, c[1] / body.max(1) as f32]; + let u = p.unit; + println!( + "glass-lens/{state} à plat : unité {u:.1} px, corps {body} px, pointe à {near:.1} px du hotspot, \ + centroïde {c:?} pour la pointe {:?}, {through} px laissent voir l'écran, {line} px de trait, \ + {moved} px déplacés", + p.tip + ); + if (body as f32) < 0.12 * u * u { + failures.push(format!("glass-lens/{state} : {body} px de corps pour {u:.0} px d'unité")); + } + if near > 0.08 * u { + failures.push(format!("glass-lens/{state} : le verre est à {near:.1} px du hotspot")); + } + if !(c[0] > p.tip[0] && c[1] > p.tip[1] + 0.2 * u) { + failures.push(format!("glass-lens/{state} : corps en {c:?}, pas en bas à droite de {:?}", p.tip)); + } + if through * 10 < body * 5 { + failures.push(format!("glass-lens/{state} : l'écran ne se voit que sur {through} px")); + } + if line * 20 < body { + failures.push(format!("glass-lens/{state} : {line} px de trait seulement")); + } + if moved * 50 < through { + failures.push(format!("glass-lens/{state} : le verre ne déplace l'écran que sur {moved} px")); + } + } + assert!(failures.is_empty(), "{failures:#?}"); +} + /// Planches à regarder (opt-in, `OPENSCREEN_CURSOR3D_OUT`) : les seize états en l'air sur un /// écran à plat, chacun à côté de son sprite plat ; la flèche, la main et le I posés sur un écran /// incliné ; et les grandes flèches (taille 8) qui servent à la comparaison avec le modèle @@ -902,6 +1016,35 @@ fn contact_sheets() { } sculpted.save(format!("{dir}/sculpted.png")).expect("planche"); + // Glass Lens à la taille par défaut, sur une page claire puis sombre : son verre à plat (3D + // éteinte, sans clic : le sprite plat rapetisse au clic) sur l'écran droit puis incliné, et son + // modèle 3D dans les mêmes cases. + let checker = FakeFrame::new(&gpu, Tint::Checker); + let page = FakeFrame::new(&gpu, Tint::Page); + let dark_page = FakeFrame::new(&gpu, Tint::DarkPage); + let mut glass = image::RgbaImage::new(4 * CELL, 4 * CELL); + for (row, screen) in [&page, &dark_page].into_iter().enumerate() { + for (col, (state, rotation, click)) in cases.iter().enumerate() { + let still = resting_as(&format!("sheet-glass-flat-{col}"), Some(state), false); + let (rgba, p) = render(&comp, screen, &glass_scene_json(rotation, 3.6), &still); + let y = (2 * row as u32 * CELL) as i64; + image::imageops::overlay(&mut glass, &crop(&rgba, p), (col as u32 * CELL) as i64, y); + let track = resting_as(&format!("sheet-glass-{col}"), Some(state), *click); + let (rgba, p) = render(&comp, screen, &sculpted_scene_json(rotation, "glass-lens", 3.6), &track); + image::imageops::overlay(&mut glass, &crop(&rgba, p), (col as u32 * CELL) as i64, y + CELL as i64); + } + } + glass.save(format!("{dir}/glass-lens.png")).expect("planche"); + for (bg, screen) in [("checker", &checker), ("page", &page), ("dark", &dark_page)] { + for state in ["arrow", "pointer"] { + let still = resting_as(&format!("big-glass-{state}"), Some(state), false); + let (rgba, _) = render(&comp, screen, &glass_scene_json("null", 10.0), &still); + save(&format!("big-glass-flat-{state}-{bg}"), &rgba); + let (rgba, _) = render(&comp, screen, &sculpted_scene_json("null", "glass-lens", 10.0), &still); + save(&format!("big-glass-3d-{state}-{bg}"), &rgba); + } + } + let (still, clicked) = (resting("big-still", false), resting("big-clicked", true)); for (name, rotation) in [("flat", "null"), ("iso", r#""iso""#)] { for (case, tr) in [("hover", &still), ("touch", &clicked)] { diff --git a/design/cursors/3d-direction.md b/design/cursors/3d-direction.md index 1205d3174..f715a93cb 100644 --- a/design/cursors/3d-direction.md +++ b/design/cursors/3d-direction.md @@ -3,9 +3,9 @@ `3d-concept.png` is a visual reference, not a render from OpenScreen. With the 3D option on, mode 15 ray-marches the arrow and hand of Studio Ink, Pop -Coral, Pixel Candy and Star Sprout as sculpted models: signed distance functions -written in the compositor's three shaders (HLSL, Metal, WGSL), not a PNG face or -a height map. Prism Glow's arrow and hand are glass crystals: a triangle mesh +Coral, Pixel Candy, Star Sprout and Glass Lens as sculpted models: signed distance +functions written in the compositor's three shaders (HLSL, Metal, WGSL), not a PNG +face or a height map. Prism Glow's arrow and hand are glass crystals: a triangle mesh traced on its drawing, ray-traced in a navy rim. `crates/compositor/src/sculpt.rs` names the ten models and holds their bounding boxes and hotspots. The scene names the model (`"/"`); text, resize, move and the other OS states keep @@ -18,6 +18,7 @@ their built-in art and the compositor's beveled extrusion. | Pop Coral | Cut paper: flat-topped sheets, no rounded bead or gloss. A coral sheet on a navy sheet, a yellow sheet behind them offset down-left, two yellow click dashes. | A yellow sheet on a navy sheet, the navy showing between the fingers, a coral sheet behind offset down-left, two coral click dashes. | | Pixel Candy | Simplified pixel art, one cube per pixel: a plum outline, a pink body with a pale highlight down the lit edge and a darker shade along the outline. The 3D model and the 2D sprite come from the same grid. | The same, a pixel hand: the index up, three knuckles apart, the thumb. | | Star Sprout | Glossy mint cushion framed by the navy outline of its drawing, carrying a star with a face and two leaves. | Ivory glove cushion in the same navy frame, navy ridges between the fingers, a mint cuff and the same star. | +| Glass Lens | A clear glass lens in a flat-topped graphite outline, and beyond it a rounded rim of glass. The picture under it shows through, refracted: magnified by the lens, bent along its edge with a slight dispersion. | The same glass glove, the outline running between the fingers as ridges. | Studio Ink, Pop Coral and Star Sprout keep their drawn outline, as in `3d-concept.png` (and `star-sprout/3d-reference.png` for the Star Sprout hand). @@ -44,6 +45,19 @@ sharp on top, so only blurred pixels show through the glass. The rim is its silhouette extruded, a distance field like the other models: it gives the coverage and the shadows. +Glass Lens takes the Studio Ink arrow and glove (`s_glass2`) and widens their +outline by `GLASS_RIM`: a graphite band of `GLASS_LINE`, then the glass rim. In +volume (`s_glass`), a glass slab runs under the whole silhouette, rounded across +the rim, and the lens rises `GLASS_DOME` above it inside the band. A ray that hits +the glass is refracted per colour channel, crosses to the flat underside of the +slab, leaves it and lands on the picture under the cursor, the same copy of the +composed frame the Prism Glow crystal refracts (`glass_shade`); three samples +along the refraction keep a compressed edge from breaking into sparkles. Its +shadow is lighter than an opaque model's. With the 3D option off, the theme stays +glass: mode 19 (`cursor_glass`) draws the same relief seen face on, its height +read off the same distances (`s_glass_relief`), and refracts the picture the same +way, the air under the slab standing in for the model's hover. + All models share one close lamp: a gradient and a highlight even on flat faces, soft self shadows, ambient occlusion and a studio environment in the reflections, plus the compositor's cast and contact shadows on the screen. The diff --git a/design/cursors/README.md b/design/cursors/README.md index c6cd471c4..da17f3d39 100644 --- a/design/cursors/README.md +++ b/design/cursors/README.md @@ -1,8 +1,9 @@ # Original OpenScreen cursor themes -These five themes replace the removed Sweezy packs. The artwork was created for +These six themes replace the removed Sweezy packs. The artwork was created for OpenScreen as transparent raster images. The PNGs in this directory are the -original high-resolution masters; there is no SVG conversion step. +original high-resolution masters; there is no SVG conversion step. Glass Lens is +the exception: it has no raster master (see below). | Theme | Intent | | --- | --- | @@ -11,6 +12,7 @@ original high-resolution masters; there is no SVG conversion step. | Pop Coral | Warm, playful choice for casual recordings | | Pixel Candy | A small retro option for people who liked pixel packs | | Star Sprout | An original tiny character for people who liked mascot packs | +| Glass Lens | A clear lens of glass in a defined outline: the recording shows through the cursor | Each `source.png` contains an arrow on the left and a hand on the right. Run `node scripts/generate-original-cursor-themes.mjs` to crop, remove low-alpha @@ -26,6 +28,14 @@ picker and the flat cursor keep using the PNGs. Text, resize, move and other states remain state-accurate through the built-in art and receive the shared 3D extrusion. +Glass Lens is glass with the 3D option off too: the compositor draws its arrow +and hand as its model seen face on (mode 19), a lens that refracts the picture +under it, instead of a PNG. Its shapes are the Studio Ink arrow and glove, and +`node scripts/generate-glass-lens-cursor.mjs` draws its picker PNGs from the +same distance fields, read from the shaders, then prints their hotspots. +`glass-lens/renders.png` shows the compositor's own renders, 2D then 3D, on a +light page, a dark page and a checker. + `contact-sheet.png` shows the sprites enlarged on a light background; `dark-32px.png` shows them at their 32-pixel reference size on a dark background. The 3D modeling direction and the reference sheet the models follow are in diff --git a/design/cursors/glass-lens/renders.png b/design/cursors/glass-lens/renders.png new file mode 100644 index 000000000..0cfd86cbe Binary files /dev/null and b/design/cursors/glass-lens/renders.png differ diff --git a/design/cursors/requirements.md b/design/cursors/requirements.md index 1cf03d987..88ab1ede1 100644 --- a/design/cursors/requirements.md +++ b/design/cursors/requirements.md @@ -46,6 +46,7 @@ their 3D models. | Pop Coral | Cut paper: a coral sheet on a navy sheet, flat tops, matte; the yellow offset becomes a flat sheet behind; the click dashes become flat pieces. | A yellow sheet on a navy sheet, flat tops, matte; the coral offset becomes a flat sheet behind; the coral click dashes become flat pieces. | | Pixel Candy | Simplified pixel art that reads at 20 px as at full size; the 3D model is one cube per pixel of the same grid as the sprite. | A pixel hand from the same kind of grid: index, three knuckles, thumb. | | Star Sprout | Puffy mint cushion; the navy outline stays as a physical rim (a navy tray and a rounded bead) around it, the star and its leaves in front, each in its own navy rim. | Puffy ivory glove in the same navy rim, with navy ridges between the fingers; separate mint cuff framed in navy; the star and leaves in front, as in `star-sprout/3d-reference.png`. | +| Glass Lens | A glass lens with a defined dark outline and an outer rim of glass, after the macOS glass material: the picture under it is refracted by real shader optics (magnified by the lens, bent and slightly dispersed along its edge), in 3D and, unlike the other themes, in 2D too. | The same, a glass glove with dark ridges between the fingers. | The 2D PNG may keep a drawn contour where it helps readability. Its contour must not be copied blindly onto the 3D material: decide per model whether it diff --git a/electron/native-bridge/services/compositorViewService.test.ts b/electron/native-bridge/services/compositorViewService.test.ts index 4a099a7f9..fad7b74e2 100644 --- a/electron/native-bridge/services/compositorViewService.test.ts +++ b/electron/native-bridge/services/compositorViewService.test.ts @@ -530,6 +530,7 @@ describe("resolveSceneAssetPaths", () => { let originalResourcesPath: PropertyDescriptor | undefined; let originalVitePublic: string | undefined; const themed = CURSOR_THEMES.find((t) => t.assets.arrow); + const glassy = CURSOR_THEMES.find((t) => t.assets.arrow?.glass); beforeEach(() => { resources = fs.mkdtempSync(path.join(os.tmpdir(), "openscreen-scene-assets-")); @@ -540,6 +541,7 @@ describe("resolveSceneAssetPaths", () => { fs.writeFileSync(path.join(modelDir, "selfie_segmentation_landscape.onnx"), "onnx"); const assetPaths = [ ...Object.values(themed?.assets ?? {}).map((asset) => asset.assetPath), + ...Object.values(glassy?.assets ?? {}).map((asset) => asset.assetPath), ...Object.values(DEFAULT_CURSOR_SPRITES).map((s) => s.assetPath), ]; for (const assetPath of assetPaths) { @@ -581,6 +583,7 @@ describe("resolveSceneAssetPaths", () => { hotspotX: number; hotspotY: number; sculpt?: string; + glass?: string; }; // The renderer asks for an effect and knows nothing about the disk; this process answers @@ -690,6 +693,18 @@ describe("resolveSceneAssetPaths", () => { expect(fs.existsSync(arrow.path)).toBe(true); }); + // A glass theme is drawn as glass by the compositor with 3D off as well: its glass model is + // named either way, the sculpted one only in 3D. + it("names a glass theme's glass model with 3D on and off", () => { + if (!glassy) throw new Error("a bundled theme needs a glass arrow"); + for (const model3d of [false, true]) { + const arrow: ResolvedSprite = resolved({ cursor: { theme: glassy.id, model3d } }).cursor + .cursorSprites.arrow; + expect(arrow.glass).toBe(`${glassy.id}/arrow`); + expect(arrow.sculpt).toBe(model3d ? `${glassy.id}/arrow` : undefined); + } + }); + it("fills the states a theme doesn't ship with the built-in art", () => { if (!themed) return; const sprites = resolved({ cursor: { theme: themed.id } }).cursor.cursorSprites; diff --git a/electron/native-bridge/services/compositorViewService.ts b/electron/native-bridge/services/compositorViewService.ts index 6dbfb1461..89581945f 100644 --- a/electron/native-bridge/services/compositorViewService.ts +++ b/electron/native-bridge/services/compositorViewService.ts @@ -101,6 +101,15 @@ export function resolveSceneAssetPath(relativePath: string): string | null { return null; } +/** One cursor sprite as the compositor reads it (`SceneCursorSprite` in `scene.rs`). */ +type SceneCursorSprite = { + path: string; + hotspotX: number; + hotspotY: number; + sculpt?: string; + glass?: string; +}; + /** * The theme's sprite set with every `assetPath` turned into an absolute on-disk path. * @@ -112,11 +121,8 @@ function resolveCursorSpritePaths( themeId: string, asArrow: readonly CursorKind[], model3d = false, -): Record { - const resolved: Record< - string, - { path: string; hotspotX: number; hotspotY: number; sculpt?: string } - > = {}; +): Record { + const resolved: Record = {}; for (const [type, sprite] of Object.entries(resolveCursorSprites(themeId, asArrow, model3d))) { const absolute = resolveSceneAssetPath(sprite.assetPath); if (absolute) { @@ -125,6 +131,7 @@ function resolveCursorSpritePaths( hotspotX: sprite.hotspotX, hotspotY: sprite.hotspotY, ...(sprite.sculpt ? { sculpt: sprite.sculpt } : {}), + ...(sprite.glass ? { glass: sprite.glass } : {}), }; } } @@ -144,10 +151,7 @@ export function resolveSceneAssetPaths(sceneJson: string): string { theme?: string; asArrow?: unknown; model3d?: boolean; - cursorSprites?: Record< - string, - { path: string; hotspotX: number; hotspotY: number; sculpt?: string } - >; + cursorSprites?: Record; }; webcamEffect?: { mode?: string; diff --git a/public/cursors/glass-lens/arrow.png b/public/cursors/glass-lens/arrow.png new file mode 100644 index 000000000..69825d289 Binary files /dev/null and b/public/cursors/glass-lens/arrow.png differ diff --git a/public/cursors/glass-lens/pointer.png b/public/cursors/glass-lens/pointer.png new file mode 100644 index 000000000..506599b44 Binary files /dev/null and b/public/cursors/glass-lens/pointer.png differ diff --git a/scripts/generate-glass-lens-cursor.mjs b/scripts/generate-glass-lens-cursor.mjs new file mode 100644 index 000000000..6ce083e2f --- /dev/null +++ b/scripts/generate-glass-lens-cursor.mjs @@ -0,0 +1,211 @@ +// Glass Lens has no raster master: its arrow and hand are the shapes the compositor models +// (`s_glass2` in crates/compositor/src/shaders.hlsl), and this script draws the theme picker's +// PNGs from those same distance fields. The shape tables and the GLASS_* widths are read from the +// HLSL and the model boxes from sculpt.rs, so the 2D art cannot drift from the model. +// The compositor never draws these PNGs: with the 3D option off it renders the glass itself +// (mode 19), refracting the picture under it, and in 3D it ray-marches the model. They only have +// to read as glass in a picker cell: a translucent body, the dark outline, a pale glass rim. +// Run with `node scripts/generate-glass-lens-cursor.mjs` after changing the shapes: it writes +// public/cursors/glass-lens/{arrow,pointer}.png and prints the hotspots for +// src/lib/cursor/cursorThemes.ts. + +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { deflateSync } from "node:zlib"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SRC = path.join(ROOT, "crates", "compositor", "src"); +const OUT = path.join(ROOT, "public", "cursors", "glass-lens"); +const SPRITE = 128; +// Samples per pixel side, for the antialiasing. +const SS = 4; + +const hlsl = await readFile(path.join(SRC, "shaders.hlsl"), "utf8"); +const sculpt = await readFile(path.join(SRC, "sculpt.rs"), "utf8"); + +function constant(src, name) { + const m = src.match(new RegExp(`static const float ${name} = (-?[\\d.]+);`)); + if (!m) throw new Error(`${name} not found in shaders.hlsl`); + return Number(m[1]); +} +// The numbers of the HLSL table `name`, vector constructors dropped, in groups of `n`. +function table(src, name, n) { + const decl = src.match(new RegExp(`${name}\\[\\d+\\] = \\{([^}]*)\\}`)); + if (!decl) throw new Error(`${name} not found in shaders.hlsl`); + const values = decl[1] + .replace(/float[234]/g, "") + .match(/-?\d+(\.\d+)?/g) + .map(Number); + return Array.from({ length: values.length / n }, (_, i) => values.slice(i * n, i * n + n)); +} +function box(name) { + const m = sculpt.match(new RegExp(`const ${name}: \\[f32; 3\\] = \\[([^\\]]*)\\]`)); + if (!m) throw new Error(`${name} not found in sculpt.rs`); + return m[1].split(",").map(Number); +} + +const SCULPT_SCALE = constant(hlsl, "SCULPT_SCALE"); +const LINE = constant(hlsl, "GLASS_LINE"); +const RIM = constant(hlsl, "GLASS_RIM"); +const RIM_POLY = table(hlsl, "RIM_POLY", 2); +const RIM_GLOVE = table(hlsl, "RIM_GLOVE", 4); +const RIM_GLOVE_R = table(hlsl, "RIM_GLOVE_R", 4); + +// ---- The shapes, as `s_rim_poly`, `s_rim_glove` and `s_glass2` in the shaders ---- + +const dot = (a, b) => a[0] * b[0] + a[1] * b[1]; +const sub = (a, b) => [a[0] - b[0], a[1] - b[1]]; +const clamp01 = (x) => Math.min(Math.max(x, 0), 1); + +function segment(p, a, b) { + const pa = sub(p, a); + const ba = sub(b, a); + const h = clamp01(dot(pa, ba) / dot(ba, ba)); + return Math.hypot(pa[0] - ba[0] * h, pa[1] - ba[1] * h); +} + +function rimPoly(p, base) { + let d = dot(sub(p, RIM_POLY[base]), sub(p, RIM_POLY[base])); + let s = 1; + for (let i = base, j = base + 6; i < base + 7; j = i, i++) { + const e = sub(RIM_POLY[j], RIM_POLY[i]); + const w = sub(p, RIM_POLY[i]); + const h = clamp01(dot(w, e) / dot(e, e)); + d = Math.min(d, (w[0] - e[0] * h) ** 2 + (w[1] - e[1] * h) ** 2); + const c0 = p[1] >= RIM_POLY[i][1]; + const c1 = p[1] < RIM_POLY[j][1]; + const c2 = e[0] * w[1] > e[1] * w[0]; + if ((c0 && c1 && c2) || (!c0 && !c1 && !c2)) s = -s; + } + return s * Math.sqrt(d); +} + +function smin(a, b, k) { + const h = Math.max(k - Math.abs(a - b), 0) / k; + return Math.min(a, b) - h * h * k * 0.25; +} + +function rimGlove(p, g, palm) { + const [r0, r1, r2] = [0, 1, 2].map((k) => RIM_GLOVE_R[3 * g + k]); + const seg = (k, r) => + segment(p, RIM_GLOVE[9 * g + k].slice(0, 2), RIM_GLOVE[9 * g + k].slice(2)) - r; + const fingers = Math.min( + seg(0, r0[0]), + seg(1, r0[1]), + seg(2, r0[2]), + seg(3, r0[3]), + seg(4, r1[0]), + ); + const grooves = Math.min(seg(5, r1[1]), seg(6, r1[2]), seg(7, r1[3])); + const sil = smin(palm, fingers, 0.03); + const v = RIM_GLOVE[9 * g + 8]; + const wedge = Math.max(-dot(sub(p, v.slice(0, 2)), v.slice(2)), p[0] + r2[0], r2[1] - p[1]); + return [sil, Math.max(sil, -Math.min(grooves, wedge))]; +} + +// [silhouette, lens]: signed distances in the prototype frame, negative inside. +function glass2(p, shape) { + if (shape === "arrow") { + const poly = rimPoly([p[0], p[1] + RIM - 0.06], 0); + return [poly - RIM, poly]; + } + const q = [p[0], p[1] + RIM - RIM_GLOVE_R[5][2]]; + const v = rimGlove(q, 1, rimPoly(q, 14) - 0.05); + return [v[0] - RIM, v[1]]; +} + +// ---- The picker art ---- + +// Light from the top left, as `MODEL_LIGHT` in the shaders (x right, y up here). +const LIGHT = [-0.58, 0.81]; + +// Straight-alpha RGBA of the point `p`: the outline graphite and opaque, the glass rim pale and +// lit on its upper left, the lens a faint frost with a highlight along its upper-left edge. +function shade(p, shape) { + const [sil, lens] = glass2(p, shape); + if (sil > 0) return [0, 0, 0, 0]; + if (lens >= 0 && lens <= LINE) return [22, 24, 30, 1]; + const e = 0.002; + const n = [glass2([p[0] + e, p[1]], shape)[1] - lens, glass2([p[0], p[1] + e], shape)[1] - lens]; + const lit = clamp01(0.5 + (0.5 * dot(n, LIGHT)) / (Math.hypot(n[0], n[1]) || 1)); + if (lens > LINE) return [244, 248, 255, 0.5 + 0.4 * lit]; + const glow = Math.exp(lens / 0.03) * (1 - lit); + return [236, 242, 252, 0.28 + 0.35 * glow + 0.12 * clamp01(-p[1] * 1.2)]; +} + +// The model box (x0, x1, top) at the sprite's full height, centred across it. +function render(shape) { + const [x0, x1, top] = box(shape === "arrow" ? "BOX_GLASS_ARROW" : "BOX_GLASS_HAND"); + const scale = SPRITE * SCULPT_SCALE; + const left = (SPRITE - (x1 - x0) * scale) / 2; + const rgba = Buffer.alloc(SPRITE * SPRITE * 4); + for (let y = 0; y < SPRITE; y++) { + for (let x = 0; x < SPRITE; x++) { + const acc = [0, 0, 0, 0]; + for (let sy = 0; sy < SS; sy++) { + for (let sx = 0; sx < SS; sx++) { + const p = [ + x0 + (x + (sx + 0.5) / SS - left) / scale, + top - (y + (sy + 0.5) / SS) / scale, + ]; + const [r, g, b, a] = shade(p, shape); + acc[0] += r * a; + acc[1] += g * a; + acc[2] += b * a; + acc[3] += a; + } + } + const a = acc[3] / (SS * SS); + const i = (y * SPRITE + x) * 4; + if (a > 0) + rgba.set([...acc.slice(0, 3).map((c) => Math.round(c / acc[3])), Math.round(a * 255)], i); + } + } + // The hotspot, the prototype origin, in the 32-pixel reference of cursorThemes.ts. + const hotspot = [(left - x0 * scale) / 4, (top * scale) / 4]; + return { rgba, hotspot }; +} + +// ---- PNG ---- + +const CRC = new Uint32Array(256).map((_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); +function crc32(bytes) { + let c = 0xffffffff; + for (const b of bytes) c = CRC[(c ^ b) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} +function chunk(type, data) { + const out = Buffer.alloc(12 + data.length); + out.writeUInt32BE(data.length, 0); + out.write(type, 4, "ascii"); + data.copy(out, 8); + out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length); + return out; +} +function png(width, height, rgba) { + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header.set([8, 6, 0, 0, 0], 8); + const raw = Buffer.alloc((width * 4 + 1) * height); + for (let y = 0; y < height; y++) + rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4); + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", header), + chunk("IDAT", deflateSync(raw, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +await mkdir(OUT, { recursive: true }); +for (const shape of ["arrow", "pointer"]) { + const { rgba, hotspot } = render(shape); + await writeFile(path.join(OUT, `${shape}.png`), png(SPRITE, SPRITE, rgba)); + console.log(`${shape}: hotspotX ${hotspot[0].toFixed(4)}, hotspotY ${hotspot[1].toFixed(4)}`); +} diff --git a/src/components/ai-edition/CursorPane.preview.test.tsx b/src/components/ai-edition/CursorPane.preview.test.tsx index fa222938f..0aa86791f 100644 --- a/src/components/ai-edition/CursorPane.preview.test.tsx +++ b/src/components/ai-edition/CursorPane.preview.test.tsx @@ -38,7 +38,7 @@ afterEach(() => { describe("CursorPane theme picker", () => { it("shows the original cursor themes beside the default", () => { - expect(CURSOR_THEMES).toHaveLength(5); + expect(CURSOR_THEMES).toHaveLength(6); render( diff --git a/src/lib/cursor/cursorThemes.test.ts b/src/lib/cursor/cursorThemes.test.ts index e712e8ad4..63707c75e 100644 --- a/src/lib/cursor/cursorThemes.test.ts +++ b/src/lib/cursor/cursorThemes.test.ts @@ -78,6 +78,22 @@ describe("normalizeCursorThemeId", () => { expect(sprites.text).toBe(DEFAULT_CURSOR_SPRITES.text); }); + // Glass Lens is glass with 3D off too: the compositor draws its model face on (mode 19) in place + // of the sprite, so the scene names it whatever the 3D option, and for no other theme. + it("names the flat glass of Glass Lens with and without 3D, and of no other theme", () => { + for (const theme of CURSOR_THEMES) { + for (const model3d of [false, true]) { + const sprites = resolveCursorSprites(theme.id, ["text"], model3d); + for (const state of ["arrow", "pointer"] as const) { + const want = theme.id === "glass-lens" ? `${theme.id}/${state}` : undefined; + expect(sprites[state].glass, `${theme.id} ${state} ${model3d}`).toBe(want); + } + expect(sprites.text.glass).toBe(sprites.arrow.glass); + expect(sprites["resize-ew"].glass).toBeUndefined(); + } + } + }); + it("leaves the default art to the extruded sprite in 3D", () => { for (const sprite of Object.values(resolveCursorSprites(DEFAULT_CURSOR_THEME_ID, [], true))) { expect(sprite.sculpt).toBeUndefined(); diff --git a/src/lib/cursor/cursorThemes.ts b/src/lib/cursor/cursorThemes.ts index b3a743bdc..88bd6a260 100644 --- a/src/lib/cursor/cursorThemes.ts +++ b/src/lib/cursor/cursorThemes.ts @@ -18,6 +18,9 @@ export interface CursorThemeAsset { /** The compositor models this state in 3D (`crates/compositor/src/sculpt.rs`) instead of * extruding the flat asset, which stays the 2D art. */ sculpted?: boolean; + /** With the 3D option off too, the compositor draws this state as a lens of glass over the + * picture (its 3D model seen face on), not the flat asset, which only shows in the picker. */ + glass?: boolean; } export interface CursorTheme { @@ -54,6 +57,9 @@ export interface CursorSprite { /** The compositor's sculpted model for this state in 3D, `"/"` * (`crates/compositor/src/sculpt.rs`). Without it, 3D extrudes the sprite. */ sculpt?: string; + /** The glass model the compositor draws for this state with 3D off, `"/"`, in + * place of the sprite (see `CursorThemeAsset.glass`). */ + glass?: string; } /** @@ -128,9 +134,10 @@ export function readCursorAsArrow( } /** - * Bundled cursor themes. These five packs are original OpenScreen artwork. Their raster + * Bundled cursor themes. These six packs are original OpenScreen artwork. Their raster * masters live in design/cursors/ and are prepared by scripts/generate-original-cursor-themes.mjs, - * except Pixel Candy's pixel art, drawn as a grid in scripts/generate-pixel-candy-voxels.mjs. + * except Pixel Candy's pixel art, drawn as a grid in scripts/generate-pixel-candy-voxels.mjs, and + * Glass Lens, whose picker PNGs scripts/generate-glass-lens-cursor.mjs draws from its 3D model. * The former Sweezy packs were removed * because their terms forbid redistribution without written permission. * @@ -138,7 +145,8 @@ export function readCursorAsArrow( * with hotspots normalized to the 32-logical reference (divide a 128px-pack hotspot by 4). * Mark an asset `sculpted` when the compositor has a 3D model for it (`sculpt.rs`; Prism Glow's * is a crystal mesh traced on its drawing, `prism_mesh.rs`); other states are extruded from their - * sprite in 3D. An id that leaves this list reads back as the default art + * sprite in 3D. Mark it `glass` when the compositor draws it as glass with 3D off as well (Glass + * Lens, mode 19). An id that leaves this list reads back as the default art * through `normalizeCursorThemeId`, so a project saved with it still opens. */ export const CURSOR_THEMES: readonly CursorTheme[] = [ @@ -252,6 +260,30 @@ export const CURSOR_THEMES: readonly CursorTheme[] = [ }, }, }, + { + id: "glass-lens", + name: "Glass Lens", + assets: { + arrow: { + assetPath: "cursors/glass-lens/arrow.png", + width: 32, + height: 32, + hotspotX: 9.744, + hotspotY: 0.816, + sculpted: true, + glass: true, + }, + pointer: { + assetPath: "cursors/glass-lens/pointer.png", + width: 32, + height: 32, + hotspotX: 12.872, + hotspotY: 0.816, + sculpted: true, + glass: true, + }, + }, + }, ]; /** All selectable theme ids, including the built-in default. */ @@ -321,6 +353,7 @@ export function resolveCursorSprites( hotspotX: asset.hotspotX / asset.width, hotspotY: asset.hotspotY / asset.height, ...(model3d && asset.sculpted ? { sculpt: `${themeId}/${type}` } : {}), + ...(asset.glass ? { glass: `${themeId}/${type}` } : {}), }; } // A kind drawn as the arrow takes the arrow's sprite, with the theme's art and its 3D model. diff --git a/technical-documentation/architecture/cursor.md b/technical-documentation/architecture/cursor.md index b603f2610..bd8db12a8 100644 --- a/technical-documentation/architecture/cursor.md +++ b/technical-documentation/architecture/cursor.md @@ -22,6 +22,8 @@ flowchart LR N --> E["Export"] ``` +Glass cursors read the picture under them. When the cursor is the Prism Glow crystal or Glass Lens (`CursorPlan::glass`), each backend copies the frame as composed so far, footage and privacy blurs included, just before drawing the cursor, and the shader refracts that copy. Glass Lens does this with the 3D option off as well: its sprite names a glass model (`glass` in `cursorSprites`), and mode 19 (`cursor_glass_cb`) draws that model face on instead of the PNG, which only serves the theme picker. + ## Settings The cursor settings pane is `CursorPane` in `src/components/ai-edition/RightPanes.tsx`; the current v4 inspector exposes it through the cursor facet in `src/components/ai-edition/v4/FloatingInspector.tsx:57-63,1073`. It controls showing the cursor, clipping it to the canvas, theme, size, smoothing, motion blur, and click bounce. `RightPanes.tsx:1592-1692` binds those controls to editor settings and forwards the rendering parameters to the native compositor. Preview and export both render through that compositor, so a change shows in both.