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