1#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
34pub struct FragmentDrawId(pub u32);
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub struct FragmentRef {
43 pub id: crate::resources::FragmentId,
44 pub image: Option<crate::resources::ImageId>,
45}
46
47impl From<crate::resources::FragmentId> for FragmentRef {
48 fn from(id: crate::resources::FragmentId) -> Self {
49 FragmentRef { id, image: None }
50 }
51}
52
53impl crate::resources::FragmentId {
54 pub fn with_image(self, image: crate::resources::ImageId) -> FragmentRef {
57 FragmentRef {
58 id: self,
59 image: Some(image),
60 }
61 }
62}
63
64#[derive(Clone, Copy, Debug, PartialEq)]
69pub struct Draw {
70 pub id: crate::resources::FragmentId,
71 pub image: Option<crate::resources::ImageId>,
72 pub params: [f32; 16],
74}
75
76#[derive(Default)]
89pub struct FragmentList {
90 draws: crate::retain::Kept<Draw>,
91}
92
93impl FragmentList {
94 pub(crate) fn begin_frame(&mut self, keep_prev: bool) {
97 self.draws.begin(keep_prev);
98 }
99
100 pub(crate) fn push(&mut self, draw: Draw) -> FragmentDrawId {
102 let id = FragmentDrawId(self.draws.len() as u32);
103 self.draws.push(draw);
104 id
105 }
106
107 pub(crate) fn get(&self, id: FragmentDrawId) -> Draw {
108 self.draws[id.0 as usize]
109 }
110
111 pub(crate) fn prev_get(&self, id: FragmentDrawId) -> Option<Draw> {
116 self.draws.prev().get(id.0 as usize).copied()
117 }
118}
119
120pub fn params_of(params: &[f32]) -> ([f32; 16], usize) {
124 let mut out = [0.0f32; 16];
125 let n = params.len().min(16);
126 out[..n].copy_from_slice(¶ms[..n]);
127 (out, params.len().saturating_sub(16))
128}
129
130pub const PRELUDE: &str = r#"
139// The frame's own numbers, shared with the quad pipeline (group 0).
140struct KuiGlobals {
141 viewport: vec2<f32>,
142 atlas_size: vec2<f32>,
143 time: f32,
144 scale: f32,
145 _pad: vec2<f32>,
146};
147@group(0) @binding(0) var<uniform> kui_globals: KuiGlobals;
148// The atlas — or, for a fragment whose `image` has a texture of its own,
149// that texture bound in the atlas's place with `atlas_size` set to its
150// size, exactly as a texture-backed `image` node is drawn (ADR 0025).
151@group(0) @binding(1) var kui_atlas: texture_2d<f32>;
152@group(0) @binding(2) var kui_sampler: sampler;
153@group(0) @binding(3) var kui_sampler_nearest: sampler;
154
155// The texel rect of the node's `image` in `kui_atlas`, `[x, y, w, h]`;
156// zero with no image. Module-private so `kui_sample` needs no argument
157// for it; the epilogue sets it before calling `fragment`.
158var<private> kui_image_rect: vec4<f32>;
159
160// What an app's `fragment` function is given.
161struct FragmentIn {
162 // The pixel being painted, in the node's own space: physical px from
163 // the node's top-left corner, y down.
164 local: vec2<f32>,
165 // The node's size in physical px.
166 size: vec2<f32>,
167 // The frame clock in seconds, the same one transitions read. Only
168 // moves between frames, so a fragment that uses it wants `animate`.
169 time: f32,
170 // Physical px per logical px.
171 scale: f32,
172 // The quad's colour, straight alpha: white on a `fragment` node, the
173 // `bg` fill on a `polygon` (ADR 0025, decision 6). A fragment that
174 // wants a colour the view chose reads it here rather than spending
175 // four params on one.
176 color: vec4<f32>,
177 // The node's `image` as a texel rect, `[x, y, w, h]`, in whatever
178 // `kui_sample` reads from — the atlas or the image's own texture; the
179 // app never needs to know which. `zw` is the image's size in texels,
180 // which is what a data texture's row and column count are. Zero with
181 // no image.
182 image: vec4<f32>,
183};
184
185// The node's `image` at `uv`, `(0,0)` its top-left and `(1,1)` its
186// bottom-right, bilinear between texels and clamped half a texel in from
187// the rect's edge so the neighbour past it — a glyph, in the atlas —
188// never bleeds in. Transparent black with no image. Straight alpha, as
189// the image was registered.
190fn kui_sample(uv: vec2<f32>) -> vec4<f32> {
191 let r = kui_image_rect;
192 let lo = r.xy + vec2<f32>(0.5, 0.5);
193 let hi = r.xy + r.zw - vec2<f32>(0.5, 0.5);
194 let t = clamp(r.xy + clamp(uv, vec2<f32>(0.0), vec2<f32>(1.0)) * r.zw, lo, max(lo, hi));
195 let c = textureSample(kui_atlas, kui_sampler, t / kui_globals.atlas_size);
196 return select(vec4<f32>(0.0), c, r.z > 0.0 && r.w > 0.0);
197}
198
199// `kui_sample` reading the nearest texel instead of blending four — a
200// heatmap cell, a pixel-art sprite, anything whose texels are values.
201fn kui_sample_nearest(uv: vec2<f32>) -> vec4<f32> {
202 let r = kui_image_rect;
203 let lo = r.xy + vec2<f32>(0.5, 0.5);
204 let hi = r.xy + r.zw - vec2<f32>(0.5, 0.5);
205 let t = clamp(r.xy + clamp(uv, vec2<f32>(0.0), vec2<f32>(1.0)) * r.zw, lo, max(lo, hi));
206 let c = textureSample(kui_atlas, kui_sampler_nearest, t / kui_globals.atlas_size);
207 return select(vec4<f32>(0.0), c, r.z > 0.0 && r.w > 0.0);
208}
209
210// Half-width of every SDF edge ramp, in physical px. The renderer's `AA`.
211const KUI_AA: f32 = 0.75;
212
213// Signed distance to a box with one radius per corner. `p` is centered
214// (y down), `radii` is tl, tr, br, bl; each is clamped to the half extents
215// so oversized radii degrade to a pill, never a fold.
216fn kui_sd_rounded_box(p: vec2<f32>, half: vec2<f32>, radii: vec4<f32>) -> f32 {
217 let right = p.x > 0.0;
218 let bottom = p.y > 0.0;
219 let top_r = select(radii.x, radii.y, right);
220 let bottom_r = select(radii.w, radii.z, right);
221 let r = select(top_r, bottom_r, bottom);
222 let rr = min(r, min(half.x, half.y));
223 let q = abs(p) - half + vec2<f32>(rr, rr);
224 return length(max(q, vec2<f32>(0.0, 0.0))) + min(max(q.x, q.y), 0.0) - rr;
225}
226"#;
227
228pub const EPILOGUE: &str = r#"
236// The sixteen params, then the image's texel rect — one slot per draw,
237// laid out as `kui_wgpu`'s `FragmentParams`.
238struct KuiFragmentParams { p: array<vec4<f32>, 4>, image: vec4<f32> };
239@group(1) @binding(0) var<uniform> kui_fragment_params: KuiFragmentParams;
240
241@fragment
242fn kui_fs_fragment(
243 @builtin(position) frag_pos: vec4<f32>,
244 @location(0) local: vec2<f32>,
245 @location(1) size: vec2<f32>,
246 @location(2) color: vec4<f32>,
247 @location(6) clip: vec4<f32>,
248 @location(7) radii: vec4<f32>,
249 @location(8) clip_radii: vec4<f32>,
250) -> @location(0) vec4<f32> {
251 var kui_in: FragmentIn;
252 kui_in.local = local;
253 kui_in.size = size;
254 kui_in.time = kui_globals.time;
255 kui_in.scale = kui_globals.scale;
256 kui_in.color = vec4<f32>(color.rgb, 1.0);
257 kui_in.image = kui_fragment_params.image;
258 kui_image_rect = kui_fragment_params.image;
259 let kui_c = fragment(kui_in, kui_fragment_params.p);
260
261 // The node's own box, exactly as a solid gets it: a square one by the
262 // area of the pixel inside it, so one on whole pixels is solid to its
263 // edge and a stack of them meets without a seam; a rounded one by its
264 // SDF.
265 let kui_half = size * 0.5;
266 let kui_d = kui_sd_rounded_box(local - kui_half, kui_half, radii);
267 let kui_lo = max(local - vec2<f32>(0.5, 0.5), vec2<f32>(0.0, 0.0));
268 let kui_hi = min(local + vec2<f32>(0.5, 0.5), size);
269 let kui_area = clamp(kui_hi - kui_lo, vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 1.0));
270 let kui_cov = select(
271 1.0 - smoothstep(-KUI_AA, KUI_AA, kui_d),
272 kui_area.x * kui_area.y,
273 all(radii <= vec4<f32>(0.0)),
274 );
275
276 // The inherited clip, in framebuffer space, rounded when rounded.
277 let kui_p = frag_pos.xy;
278 var kui_inside: f32;
279 if all(clip_radii <= vec4<f32>(0.0)) {
280 kui_inside = f32(
281 kui_p.x >= clip.x && kui_p.y >= clip.y
282 && kui_p.x <= clip.x + clip.z && kui_p.y <= clip.y + clip.w
283 );
284 } else {
285 let kui_ch = clip.zw * 0.5;
286 let kui_cd = kui_sd_rounded_box(kui_p - (clip.xy + kui_ch), kui_ch, clip_radii);
287 kui_inside = 1.0 - smoothstep(-KUI_AA, KUI_AA, kui_cd);
288 }
289
290 // `color.a` is the group opacity the subtree inherited, times the fill
291 // alpha on a polygon; `rgb` reached the function as `in.color`, and a
292 // fragment that ignores it returns its own colour as it always did.
293 let kui_a = clamp(kui_c.a, 0.0, 1.0) * kui_cov * kui_inside * color.a;
294 return vec4<f32>(clamp(kui_c.rgb, vec3<f32>(0.0), vec3<f32>(1.0)) * kui_a, kui_a);
295}
296"#;
297
298pub const JOIN: &str = r#"
317fn join_radii(cx: f32, e: f32, has: bool, sx: f32, r: f32, lone: f32) -> vec2<f32> {
318 // A corner's (convex, concave) radii: `e` the neighbour's end on this
319 // side, `sx` outward.
320 if !has {
321 return vec2<f32>(lone, 0.0);
322 }
323 let d = (e - cx) * sx;
324 return vec2<f32>(min(r, max(-d, 0.0) * 0.5), min(r, max(d, 0.0) * 0.5));
325}
326
327fn join_cut(p: vec2<f32>, cx: f32, cy: f32, sx: f32, sy: f32, rc: f32) -> bool {
328 // Inside the piece's box, but outside a convex corner's arc.
329 let near = (cx - p.x) * sx < rc && (cy - p.y) * sy < rc;
330 let c = vec2<f32>(cx - sx * rc, cy - sy * rc);
331 return rc > 0.0 && near && distance(p, c) > rc;
332}
333
334fn join_fillet(p: vec2<f32>, cx: f32, cy: f32, sx: f32, sy: f32, rf: f32) -> bool {
335 // Past the piece's end, inside a concave corner's fillet.
336 let dx = (p.x - cx) * sx;
337 let dy = (cy - p.y) * sy;
338 let c = vec2<f32>(cx + sx * rf, cy - sy * rf);
339 return rf > 0.0 && dx >= 0.0 && dx < rf && dy >= 0.0 && dy < rf && distance(p, c) >= rf;
340}
341
342fn join_inside(p: vec2<f32>, h: f32, a: f32, b: f32, pv: vec2<f32>, hp: bool, nx: vec2<f32>, hn: bool, r: f32) -> bool {
343 let lone = min(r, (b - a) * 0.5);
344 let tl = join_radii(a, pv.x, hp, -1.0, r, lone);
345 let tr = join_radii(b, pv.y, hp, 1.0, r, lone);
346 let bl = join_radii(a, nx.x, hn, -1.0, r, lone);
347 let br = join_radii(b, nx.y, hn, 1.0, r, lone);
348 let in_box = p.x >= a && p.x < b && p.y >= 0.0 && p.y < h;
349 let cut = join_cut(p, a, 0.0, -1.0, -1.0, tl.x) || join_cut(p, b, 0.0, 1.0, -1.0, tr.x)
350 || join_cut(p, a, h, -1.0, 1.0, bl.x) || join_cut(p, b, h, 1.0, 1.0, br.x);
351 let fill = join_fillet(p, a, 0.0, -1.0, -1.0, tl.y) || join_fillet(p, b, 0.0, 1.0, -1.0, tr.y)
352 || join_fillet(p, a, h, -1.0, 1.0, bl.y) || join_fillet(p, b, h, 1.0, 1.0, br.y);
353 return (in_box && !cut) || fill;
354}
355
356fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
357 let a = params[0].x;
358 let b = params[0].y;
359 let pv = params[0].zw;
360 let nx = params[1].xy;
361 let h = in.size.y;
362 let r = min(params[1].z, h * 0.5);
363 let flags = u32(params[1].w + 0.5);
364 // A neighbour shapes the corners only where it overlaps this piece.
365 let hp = (flags & 1u) != 0u && pv.x < b && pv.y > a;
366 let hn = (flags & 2u) != 0u && nx.x < b && nx.y > a;
367 let x = in.local.x;
368 // Past the fillets nothing; away from both ends every pixel.
369 if x < a - r - 1.0 || x > b + r + 1.0 {
370 return vec4<f32>(0.0);
371 }
372 if x > a + r + 1.0 && x < b - r - 1.0 {
373 return vec4<f32>(in.color.rgb, 1.0);
374 }
375 var n = 0.0;
376 for (var i = 0; i < 4; i++) {
377 for (var j = 0; j < 4; j++) {
378 let o = vec2<f32>((f32(i) + 0.5) * 0.25 - 0.5, (f32(j) + 0.5) * 0.25 - 0.5);
379 if join_inside(in.local + o, h, a, b, pv, hp, nx, hn, r) {
380 n += 1.0;
381 }
382 }
383 }
384 return vec4<f32>(in.color.rgb, n / 16.0);
385}
386"#;
387
388pub const ENTRY_POINT: &str = "kui_fs_fragment";
391
392pub const POLYGON_MAX_POINTS: usize = 8;
395
396pub const POLYGON: &str = r#"
410fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
411 var v: array<vec2<f32>, 8>;
412 for (var i = 0; i < 4; i++) {
413 v[i * 2] = params[i].xy * in.size;
414 v[i * 2 + 1] = params[i].zw * in.size;
415 }
416 let p = in.local;
417 var d = dot(p - v[0], p - v[0]);
418 var s = 1.0;
419 var j = 7;
420 for (var i = 0; i < 8; i++) {
421 let e = v[j] - v[i];
422 let w = p - v[i];
423 let ee = dot(e, e);
424 if ee > 0.0 {
425 let b = w - e * clamp(dot(w, e) / ee, 0.0, 1.0);
426 d = min(d, dot(b, b));
427 let c = vec3<bool>((p.y >= v[i].y), (p.y < v[j].y), (e.x * w.y > e.y * w.x));
428 if all(c) || all(!c) {
429 s = -s;
430 }
431 }
432 j = i;
433 }
434 let dist = s * sqrt(d);
435 let cov = clamp(0.5 - dist, 0.0, 1.0);
436 return vec4<f32>(in.color.rgb, cov);
437}
438"#;
439
440pub fn module_source(app: &str) -> String {
444 format!("{PRELUDE}\n{app}\n{EPILOGUE}")
445}
446
447fn prelude_lines() -> usize {
451 PRELUDE.lines().count() + 1
452}
453
454pub fn validate(app: &str) -> Result<(), String> {
462 use naga::valid::{Capabilities, ValidationFlags, Validator};
463 let full = module_source(app);
464 let module = naga::front::wgsl::parse_str(&full)
465 .map_err(|e| renumber(&e.emit_to_string(&full), prelude_lines()))?;
466 Validator::new(ValidationFlags::all(), Capabilities::empty())
467 .validate(&module)
468 .map(|_| ())
469 .map_err(|e| renumber(&e.emit_to_string(&full), prelude_lines()))
470}
471
472fn renumber(msg: &str, offset: usize) -> String {
477 let mut out = String::with_capacity(msg.len());
478 let mut rest = msg;
479 while let Some(i) = rest.find("wgsl:") {
480 out.push_str(&rest[..i + 5]);
481 rest = &rest[i + 5..];
482 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
483 if digits.is_empty() {
484 continue;
485 }
486 let n = digits.parse::<usize>().unwrap_or(offset + 1);
487 out.push_str(&n.saturating_sub(offset).max(1).to_string());
488 rest = &rest[digits.len()..];
489 }
490 out.push_str(rest);
491 out
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 const GRADIENT: &str = "\
499fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
500 let t = in.local.y / max(in.size.y, 1.0);
501 return mix(params[0], params[1], t);
502}";
503
504 #[test]
505 fn a_gradient_validates() {
506 validate(GRADIENT).unwrap();
507 }
508
509 #[test]
512 fn the_stock_polygon_validates() {
513 validate(POLYGON).unwrap();
514 }
515
516 #[test]
517 fn the_stock_join_validates() {
518 validate(JOIN).unwrap();
519 }
520
521 #[test]
523 fn the_quad_colour_is_reachable_from_the_app() {
524 let src = "\
525fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
526 return vec4<f32>(in.color.rgb * params[0].x, 1.0);
527}";
528 validate(src).unwrap();
529 }
530
531 #[test]
535 fn the_image_input_is_reachable_from_the_app() {
536 let src = "\
537fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
538 let uv = in.local / max(in.size, vec2<f32>(1.0));
539 let cells = in.image.zw;
540 return mix(kui_sample(uv), kui_sample_nearest(uv), step(1.0, cells.x)) * params[0];
541}";
542 validate(src).unwrap();
543 }
544
545 #[test]
546 fn the_prelude_is_reachable_from_the_app() {
547 let src = "\
549fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
550 let d = kui_sd_rounded_box(in.local - in.size * 0.5, in.size * 0.5, vec4<f32>(8.0));
551 let a = 1.0 - smoothstep(-KUI_AA, KUI_AA, d);
552 return vec4<f32>(params[0].rgb, a * (0.5 + 0.5 * sin(in.time)) * in.scale / in.scale);
553}";
554 validate(src).unwrap();
555 }
556
557 #[test]
558 fn a_syntax_error_reports_the_apps_own_line() {
559 let src = "\
561fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
562 return vec4<f32>(1.0, 0.0, 0.0, 1.0)
563}";
564 let err = validate(src).unwrap_err();
565 assert!(
566 err.contains("wgsl:2:") || err.contains("wgsl:3:"),
567 "should point into the app's source, got:\n{err}"
568 );
569 assert!(
570 !err.contains(&format!("wgsl:{}:", prelude_lines() + 2)),
571 "line number was not moved out of the prelude:\n{err}"
572 );
573 }
574
575 #[test]
576 fn a_missing_fragment_function_is_refused() {
577 let err = validate("fn other() -> f32 { return 1.0; }").unwrap_err();
578 assert!(err.contains("fragment"), "{err}");
579 }
580
581 #[test]
582 fn the_wrong_signature_is_refused() {
583 let err = validate("fn fragment() -> vec4<f32> { return vec4<f32>(1.0); }").unwrap_err();
584 assert!(
585 !err.is_empty(),
586 "a no-argument `fragment` must not validate"
587 );
588 }
589
590 #[test]
591 fn an_empty_source_is_refused() {
592 assert!(validate("").is_err());
593 }
594
595 #[test]
596 fn the_module_is_the_three_parts_in_order() {
597 let m = module_source(GRADIENT);
598 let (p, a, e) = (
599 m.find("struct FragmentIn").unwrap(),
600 m.find("fn fragment(").unwrap(),
601 m.find(ENTRY_POINT).unwrap(),
602 );
603 assert!(p < a && a < e, "prelude, app, epilogue");
604 assert!(m.contains(ENTRY_POINT));
605 }
606}