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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion crates/compositor/src/compositor_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5697,7 +5697,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", "pop-coral", "pixel-candy", "star-sprout"] {
// 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}","#));
Expand Down Expand Up @@ -5740,6 +5740,69 @@ mod tests {
assert!(failures.is_empty(), "{failures:#?}");
}

/// Les thèmes cerclés gardent le trait de leur dessin (`design/cursors/<thème>`) : sur la
/// fleche comme sur la main, le modele est pour une bonne part de la couleur du trait (le
/// plateau, le jonc, les rainures entre les doigts), autour de la couleur du corps, et montre
/// ce qu'il porte devant : l'etoile jaune de Star Sprout, le calque et les tirets jaunes de la
/// fleche de Pop Coral, les tirets corail de sa main. Les parts comptent les pixels francs :
/// ceux que l'antialiasing mêle aux bords n'entrent dans aucune.
#[test]
fn the_rimmed_models_keep_the_outline_of_their_art() {
type Rgb = [i32; 3];
let navy = |[r, g, b]: Rgb| b > r + 20 && r < 110 && g < 130;
let black = |[r, g, b]: Rgb| r.max(g).max(b) < 70;
let mint = |[r, g, b]: Rgb| g > 200 && g > r + 15 && b > 150;
let ivory = |[r, g, b]: Rgb| r > 200 && g > 190 && b > 160 && r - b < 70;
let coral = |[r, g, b]: Rgb| r > 200 && g < 150 && b < 140;
let yellow = |[r, g, b]: Rgb| r > 200 && g > 150 && b < 120;
// (thème, état, trait, corps, ornement, parts minimales du trait, du corps, de l'ornement)
let cases: [(&str, &str, &dyn Fn(Rgb) -> bool, &dyn Fn(Rgb) -> bool, &dyn Fn(Rgb) -> bool, [f32; 3]); 6] = [
("studio-ink", "arrow", &black, &ivory, &|_| false, [0.3, 0.15, 0.0]),
("studio-ink", "pointer", &black, &ivory, &|_| false, [0.2, 0.3, 0.0]),
("pop-coral", "arrow", &navy, &coral, &yellow, [0.2, 0.2, 0.05]),
("pop-coral", "pointer", &navy, &yellow, &coral, [0.18, 0.3, 0.02]),
("star-sprout", "arrow", &navy, &mint, &yellow, [0.25, 0.2, 0.03]),
("star-sprout", "pointer", &navy, &ivory, &yellow, [0.25, 0.2, 0.03]),
];
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 extruded = model_scene_json("null", Some(true), "default", true, 5.0);
let hidden = model_scene_json("null", Some(true), "default", false, 5.0);
let bare = compose_model(&comp, &blue, &hidden, &model_track("arrow", false, 0.5));
let mut failures = Vec::new();
for (theme, state, rim, body, extra, [min_rim, min_body, min_extra]) in cases {
let still = model_track(state, false, 0.5);
let json = extruded
.replace(&format!(r#"/{state}.png","#), &format!(r#"/{state}.png","sculpt":"{theme}/{state}","#));
let (hover, hover_b) = (compose_model(&comp, &blue, &json, &still), compose_model(&comp, &orange, &json, &still));
let mask = model_opaque(&hover, &hover_b, &bare);
let (mut total, mut counts) = (0usize, [0usize; 3]);
for (i, _) in mask.iter().enumerate().filter(|(_, m)| **m) {
let p = [hover[i * 4] as i32, hover[i * 4 + 1] as i32, hover[i * 4 + 2] as i32];
total += 1;
for (k, class) in [rim, body, extra].iter().enumerate() {
counts[k] += usize::from(class(p));
}
}
let [rim_share, body_share, extra_share] = counts.map(|k| k as f32 / total.max(1) as f32);
println!("{theme}/{state} : {total} px, trait {rim_share:.3}, corps {body_share:.3}, ornement {extra_share:.3}");
if !(min_rim..0.7).contains(&rim_share) {
failures.push(format!("{theme}/{state}: {rim_share:.3} de trait, il a disparu ou tout mange"));
}
if body_share < min_body {
failures.push(format!("{theme}/{state}: {body_share:.3} de couleur du corps"));
}
if extra_share < min_extra {
failures.push(format!("{theme}/{state}: {extra_share:.3} d'ornement"));
}
}
assert!(failures.is_empty(), "{failures:#?}");
}

/// Pendant de `tests/cursor_tap_render.rs` (Windows) : sous un lissage qui traine loin
/// derriere la souris, la pointe du modele se pose sur la pastille rouge du clic, et l'anneau
/// de l'impact (mode 16) l'entoure, a plat et incline.
Expand Down
131 changes: 99 additions & 32 deletions crates/compositor/src/sculpt.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
//! Curseurs sculptés : la flèche et la main des cinq thèmes d'origine, modelées en volumes dans les
//! shaders (mode 15, `sculpt_proto`) au lieu d'extruder leur PNG — capsules et unions lissées,
//! extrusions arrondies, voxels, polyèdres taillés. 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.
//! Curseurs sculptés : la flèche et la main des thèmes d'origine, modelées en volumes dans les
//! 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 n'en a pas :
//! son dessin à facettes est extrudé comme tout sprite. 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
//! ci-dessous sont le miroir des `SCULPT_*` des trois shaders.
//!
//! Les voxels de la flèche de Pixel Candy (`SCULPT_ARROWPIX`, `SCULPT_ARROWBACK`) sont son polygone
//! échantillonné : une cellule de 1/16 porte un voxel quand son centre est à plus de 0,01 dedans, un
//! bloc de la couche violette quand il est à moins de 0,9 cellule. Changer le polygone, c'est
//! régénérer ces deux tables.
//! Les grilles de voxels de Pixel Candy sont des tables que
//! `scripts/generate-pixel-candy-voxels.mjs` écrit dans les trois shaders : on change la grille
//! dans le script, on le relance, et on ajuste ici la boîte si la forme a bougé.

use crate::frame_geometry::SpriteShape;

Expand All @@ -24,48 +24,64 @@ const ZREF_ARROW: f32 = 0.2;
const ZREF_HAND: f32 = 0.185;
/// Le dessous de tous les modèles, dans le prototype.
const Z_LOW: f32 = 0.05;
/// Le plus haut : la table de la flèche taillée, l'étoile de Star Sprout sur la manchette.
const Z_HIGH_ARROW: f32 = 0.29;
/// Le côté d'un cube de Pixel Candy : seize lignes font un curseur haut de 1.
const VOX: f32 = 0.0625;
/// Le plus haut : les yeux de l'étoile de Star Sprout, sur la flèche comme sur la manchette.
const Z_HIGH_ARROW: f32 = 0.31;
const Z_HIGH_HAND: f32 = 0.4;
/// Boîtes des deux formes dans le prototype (x0, x1, haut), qui contiennent leurs cinq variantes :
/// couche violette des voxels, étoile, cristal compris. Leur hauteur est celle du sprite, 1 / SCULPT_SCALE :
/// le mode 15 tient le plus grand côté de la boîte pour 1 (`sprite_size`).
const BOX_ARROW: [f32; 3] = [-0.1, 0.75, 0.09];
const BOX_HAND: [f32; 3] = [-0.27, 0.73, 0.07];
/// Boîtes des modèles dans le prototype (x0, x1, haut). Leur hauteur est celle du sprite,
/// 1 / SCULPT_SCALE : le mode 15 tient le plus grand côté de la boîte pour 1 (`sprite_size`).
/// Celles des cubes de Pixel Candy ; puis des thèmes cerclés, que leur trait élargit et que
/// débordent l'étoile et les feuilles (Star Sprout), les tirets du clic et les calques (Pop
/// Coral), le pouce.
const BOX_PIXEL_ARROW: [f32; 3] = [-0.03, 0.72, 0.03];
const BOX_PIXEL_HAND: [f32; 3] = [-0.345, 0.72, 0.03];
const BOX_INK_ARROW: [f32; 3] = [-0.08, 0.6, 0.03];
const BOX_INK_HAND: [f32; 3] = [-0.36, 0.6, 0.03];
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];

/// Dans l'ordre des identifiants du shader.
/// Dans l'ordre des identifiants du shader. Prism Glow y garde sa place, sans modèle.
const THEMES: [&str; 5] = ["studio-ink", "prism-glow", "pop-coral", "pixel-candy", "star-sprout"];
const EXTRUDED: &str = "prism-glow";

/// Le haut de la silhouette (y du prototype) : la couche violette des voxels dépasse la pointe
/// d'une cellule, la pointe arrondie de la flèche et le sommet de sa table taillée dépassent le
/// hotspot de 0,03, le bout de l'index y est.
fn silhouette_top(theme: usize, arrow: bool) -> f32 {
/// La boîte du modèle (x0, x1, haut) dans le prototype.
fn model_box(theme: usize, arrow: bool) -> [f32; 3] {
match (THEMES[theme], arrow) {
("pixel-candy", _) => 0.0625,
(_, true) => 0.03,
(_, false) => 0.0,
("studio-ink", true) => BOX_INK_ARROW,
("studio-ink", false) => BOX_INK_HAND,
("pop-coral", true) => BOX_CORAL_ARROW,
("pop-coral", false) => BOX_CORAL_HAND,
("pixel-candy", true) => BOX_PIXEL_ARROW,
("pixel-candy", false) => BOX_PIXEL_HAND,
("star-sprout", true) => BOX_SPROUT_ARROW,
("star-sprout", false) => BOX_SPROUT_HAND,
(other, _) => unreachable!("thème sculpté inconnu : {other}"),
}
}

/// Le curseur sculpté que nomme la scène (`"<thème>/<état>"`, cf. `resolveCursorSprites`), sous
/// la forme que le mode 15 attend. `None` pour un nom inconnu : l'appelant extrude le sprite.
/// la forme que le mode 15 attend. `None` pour un nom inconnu ou un thème sans modèle (Prism Glow,
/// qu'un ancien projet peut encore nommer) : l'appelant extrude le sprite.
pub fn sculpted_shape(name: &str) -> Option<SpriteShape> {
let (theme, state) = name.split_once('/')?;
let theme = THEMES.iter().position(|t| *t == theme)?;
let theme = THEMES.iter().position(|t| *t == theme && *t != EXTRUDED)?;
let arrow = match state {
"arrow" => true,
"pointer" => false,
_ => return None,
};
let [x0, x1, y1] = if arrow { BOX_ARROW } else { BOX_HAND };
let [x0, x1, y1] = model_box(theme, arrow);
let (zref, z_high) = if arrow { (ZREF_ARROW, Z_HIGH_ARROW) } else { (ZREF_HAND, Z_HIGH_HAND) };
let s = SCULPT_SCALE;
// Repère du modèle : y vers le bas, le coin haut-gauche de la boîte est donc (x0, y1).
let size = [(x1 - x0) * s, 1.0];
Some(SpriteShape {
size,
hotspot: [-x0 * s / size[0], y1 * s],
top: (y1 - silhouette_top(theme, arrow)) * s,
top: y1 * s,
max_height: (z_high - zref) * s,
thick: (zref - Z_LOW) * s,
sculpt: 1 + 2 * theme as u32 + u32::from(!arrow),
Expand All @@ -92,14 +108,16 @@ mod tests {
#[test]
fn each_theme_arrow_and_hand_has_its_own_id_in_shader_order() {
for (k, name) in NAMES.iter().enumerate() {
let shape = sculpted_shape(name).expect(name);
assert_eq!(shape.sculpt, k as u32 + 1, "{name}");
match sculpted_shape(name) {
Some(shape) => assert_eq!(shape.sculpt, k as u32 + 1, "{name}"),
None => assert!(name.starts_with(EXTRUDED), "{name} : pas de modèle"),
}
}
}

#[test]
fn other_states_and_themes_keep_the_extruded_sprite() {
for name in ["default/arrow", "pop-coral/text", "pop-coral", "", "pop-coral/arrow/x"] {
for name in ["default/arrow", "pop-coral/text", "pop-coral", "", "pop-coral/arrow/x", "prism-glow/arrow"] {
assert!(sculpted_shape(name).is_none(), "{name}");
}
}
Expand All @@ -108,7 +126,7 @@ mod tests {
/// une hauteur ; la boîte a la hauteur du sprite, son plus grand côté, comme le veut le mode 15.
#[test]
fn the_box_holds_the_hotspot_and_the_silhouette_top() {
for name in NAMES {
for name in NAMES.into_iter().filter(|n| !n.starts_with(EXTRUDED)) {
let s = sculpted_shape(name).unwrap();
assert!((0.0..1.0).contains(&s.hotspot[0]) && (0.0..1.0).contains(&s.hotspot[1]), "{name}");
assert!(s.top <= s.hotspot[1] + 1e-6, "{name} : haut de silhouette sous le hotspot");
Expand All @@ -131,6 +149,7 @@ mod tests {
("SCULPT_ZREF_ARROW", ZREF_ARROW),
("SCULPT_ZREF_HAND", ZREF_HAND),
("SCULPT_HOVER", Z_LOW),
("SCULPT_VOX", VOX),
] {
let line = src
.lines()
Expand All @@ -145,4 +164,52 @@ mod tests {
}
}
}

/// Les nombres de la table `name` d'un shader, du `=` à la fin de la liste, constructeurs de
/// vecteurs retirés : la même table s'écrit alors pareil dans les trois langages.
fn table(src: &str, name: &str) -> Vec<f32> {
// La déclaration : `NAME =` en WGSL, `NAME[taille]` en HLSL et en Metal (pas un `NAME[i]` du code).
let decl = format!("{name}[");
let sized = |&i: &usize| src[i + decl.len()..].starts_with(|c: char| c.is_ascii_digit());
let at = src.find(&format!("{name} =")).or_else(|| src.match_indices(&decl).map(|(i, _)| i).find(sized));
let rest = &src[at.expect(name)..];
let rest = &rest[rest.find('=').unwrap()..];
let open = rest.find(['(', '{']).unwrap() + 1;
let close = [rest.find(");"), rest.find("};")].into_iter().flatten().min().unwrap();
let mut list = rest[open..close].to_string();
for ty in ["vec2<f32>", "vec3<f32>", "vec4<f32>", "vec4<i32>", "float2", "float3", "float4", "int4"] {
list = list.replace(ty, "");
}
list.split(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-'))
.filter(|t| !t.is_empty())
.map(|t| t.parse().unwrap_or_else(|_| panic!("{name} : nombre illisible {t}")))
.collect()
}

/// Les grilles de Pixel Candy, que `scripts/generate-pixel-candy-voxels.mjs` écrit dans les
/// trois shaders, y sont les mêmes ; chaque pixel coloré est plein, et d'une seule couleur.
#[test]
fn the_voxel_tables_match_in_the_three_shaders() {
let hlsl = include_str!("shaders.hlsl");
let metal = include_str!("shaders.metal");
let wgsl = include_str!("vk_shaders/layer.wgsl");
let names = ["PIX_BODY", "PIX_LINE", "PIX_HI", "PIX_SHADE", "PIX_RECT_N", "PIX_GRID", "PIX_ORIGIN", "PIX_BOX", "PIX_RECT"];
for name in names {
let reference = table(wgsl, name);
assert!(!reference.is_empty(), "{name} vide");
assert_eq!(table(hlsl, name), reference, "{name} : HLSL et WGSL diffèrent");
assert_eq!(table(metal, name), reference, "{name} : Metal et WGSL diffèrent");
}
let body = table(wgsl, "PIX_BODY");
let classes = ["PIX_LINE", "PIX_HI", "PIX_SHADE"].map(|name| table(wgsl, name));
for (i, &face) in body.iter().enumerate() {
let mut seen = 0u32;
for class in &classes {
let m = class[i] as u32;
assert_eq!(m & !(face as u32), 0, "couleur hors de la forme, ligne {i}");
assert_eq!(m & seen, 0, "deux couleurs sur un pixel, ligne {i}");
seen |= m;
}
}
}
}
Loading
Loading