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(0) vec4<f32> {
250 var kui_in: FragmentIn;
251 kui_in.local = local;
252 kui_in.size = size;
253 kui_in.time = kui_globals.time;
254 kui_in.scale = kui_globals.scale;
255 kui_in.color = vec4<f32>(color.rgb, 1.0);
256 kui_in.image = kui_fragment_params.image;
257 kui_image_rect = kui_fragment_params.image;
258 let kui_c = fragment(kui_in, kui_fragment_params.p);
259
260 // The node's own box, exactly as a solid gets it: a square one by the
261 // area of the pixel inside it, so one on whole pixels is solid to its
262 // edge and a stack of them meets without a seam; a rounded one by its
263 // SDF.
264 let kui_half = size * 0.5;
265 let kui_d = kui_sd_rounded_box(local - kui_half, kui_half, radii);
266 let kui_lo = max(local - vec2<f32>(0.5, 0.5), vec2<f32>(0.0, 0.0));
267 let kui_hi = min(local + vec2<f32>(0.5, 0.5), size);
268 let kui_area = clamp(kui_hi - kui_lo, vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 1.0));
269 let kui_cov = select(
270 1.0 - smoothstep(-KUI_AA, KUI_AA, kui_d),
271 kui_area.x * kui_area.y,
272 all(radii <= vec4<f32>(0.0)),
273 );
274
275 // The inherited clip, in framebuffer space, rounded when rounded.
276 let kui_p = frag_pos.xy;
277 var kui_inside: f32;
278 if all(clip_radii <= vec4<f32>(0.0)) {
279 kui_inside = f32(
280 kui_p.x >= clip.x && kui_p.y >= clip.y
281 && kui_p.x <= clip.x + clip.z && kui_p.y <= clip.y + clip.w
282 );
283 } else {
284 let kui_ch = clip.zw * 0.5;
285 let kui_cd = kui_sd_rounded_box(kui_p - (clip.xy + kui_ch), kui_ch, clip_radii);
286 kui_inside = 1.0 - smoothstep(-KUI_AA, KUI_AA, kui_cd);
287 }
288
289 // `color.a` is the group opacity the subtree inherited, times the fill
290 // alpha on a polygon; `rgb` reached the function as `in.color`, and a
291 // fragment that ignores it returns its own colour as it always did.
292 let kui_a = clamp(kui_c.a, 0.0, 1.0) * kui_cov * kui_inside * color.a;
293 return vec4<f32>(clamp(kui_c.rgb, vec3<f32>(0.0), vec3<f32>(1.0)) * kui_a, kui_a);
294}
295"#;
296
297pub const JOIN: &str = r#"
315fn join_radii(cx: f32, e: f32, has: bool, sx: f32, r: f32, lone: f32) -> vec2<f32> {
316 // A corner's (convex, concave) radii: `e` the neighbour's end on this
317 // side, `sx` outward.
318 if !has {
319 return vec2<f32>(lone, 0.0);
320 }
321 let d = (e - cx) * sx;
322 return vec2<f32>(min(r, max(-d, 0.0) * 0.5), min(r, max(d, 0.0) * 0.5));
323}
324
325fn join_cut(p: vec2<f32>, cx: f32, cy: f32, sx: f32, sy: f32, rc: f32) -> bool {
326 // Inside the piece's box, but outside a convex corner's arc.
327 let near = (cx - p.x) * sx < rc && (cy - p.y) * sy < rc;
328 let c = vec2<f32>(cx - sx * rc, cy - sy * rc);
329 return rc > 0.0 && near && distance(p, c) > rc;
330}
331
332fn join_fillet(p: vec2<f32>, cx: f32, cy: f32, sx: f32, sy: f32, rf: f32) -> bool {
333 // Past the piece's end, inside a concave corner's fillet.
334 let dx = (p.x - cx) * sx;
335 let dy = (cy - p.y) * sy;
336 let c = vec2<f32>(cx + sx * rf, cy - sy * rf);
337 return rf > 0.0 && dx >= 0.0 && dx < rf && dy >= 0.0 && dy < rf && distance(p, c) >= rf;
338}
339
340fn join_inside(p: vec2<f32>, h: f32, a: f32, b: f32, pv: vec2<f32>, hp: bool, nx: vec2<f32>, hn: bool, r: f32) -> bool {
341 let lone = min(r, (b - a) * 0.5);
342 let tl = join_radii(a, pv.x, hp, -1.0, r, lone);
343 let tr = join_radii(b, pv.y, hp, 1.0, r, lone);
344 let bl = join_radii(a, nx.x, hn, -1.0, r, lone);
345 let br = join_radii(b, nx.y, hn, 1.0, r, lone);
346 let in_box = p.x >= a && p.x < b && p.y >= 0.0 && p.y < h;
347 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)
348 || join_cut(p, a, h, -1.0, 1.0, bl.x) || join_cut(p, b, h, 1.0, 1.0, br.x);
349 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)
350 || join_fillet(p, a, h, -1.0, 1.0, bl.y) || join_fillet(p, b, h, 1.0, 1.0, br.y);
351 return (in_box && !cut) || fill;
352}
353
354fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
355 let a = params[0].x;
356 let b = params[0].y;
357 let pv = params[0].zw;
358 let nx = params[1].xy;
359 let h = in.size.y;
360 let r = min(params[1].z, h * 0.5);
361 let flags = u32(params[1].w + 0.5);
362 // A neighbour shapes the corners only where it overlaps this piece.
363 let hp = (flags & 1u) != 0u && pv.x < b && pv.y > a;
364 let hn = (flags & 2u) != 0u && nx.x < b && nx.y > a;
365 let x = in.local.x;
366 // Past the fillets nothing; away from both ends every pixel.
367 if x < a - r - 1.0 || x > b + r + 1.0 {
368 return vec4<f32>(0.0);
369 }
370 if x > a + r + 1.0 && x < b - r - 1.0 {
371 return vec4<f32>(in.color.rgb, 1.0);
372 }
373 var n = 0.0;
374 for (var i = 0; i < 4; i++) {
375 for (var j = 0; j < 4; j++) {
376 let o = vec2<f32>((f32(i) + 0.5) * 0.25 - 0.5, (f32(j) + 0.5) * 0.25 - 0.5);
377 if join_inside(in.local + o, h, a, b, pv, hp, nx, hn, r) {
378 n += 1.0;
379 }
380 }
381 }
382 return vec4<f32>(in.color.rgb, n / 16.0);
383}
384"#;
385
386pub const ENTRY_POINT: &str = "kui_fs_fragment";
389
390pub const POLYGON_MAX_POINTS: usize = 8;
393
394pub const POLYGON: &str = r#"
408fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
409 var v: array<vec2<f32>, 8>;
410 for (var i = 0; i < 4; i++) {
411 v[i * 2] = params[i].xy * in.size;
412 v[i * 2 + 1] = params[i].zw * in.size;
413 }
414 let p = in.local;
415 var d = dot(p - v[0], p - v[0]);
416 var s = 1.0;
417 var j = 7;
418 for (var i = 0; i < 8; i++) {
419 let e = v[j] - v[i];
420 let w = p - v[i];
421 let ee = dot(e, e);
422 if ee > 0.0 {
423 let b = w - e * clamp(dot(w, e) / ee, 0.0, 1.0);
424 d = min(d, dot(b, b));
425 let c = vec3<bool>((p.y >= v[i].y), (p.y < v[j].y), (e.x * w.y > e.y * w.x));
426 if all(c) || all(!c) {
427 s = -s;
428 }
429 }
430 j = i;
431 }
432 let dist = s * sqrt(d);
433 let cov = clamp(0.5 - dist, 0.0, 1.0);
434 return vec4<f32>(in.color.rgb, cov);
435}
436"#;
437
438pub fn module_source(app: &str) -> String {
442 format!("{PRELUDE}\n{app}\n{EPILOGUE}")
443}
444
445fn prelude_lines() -> usize {
449 PRELUDE.lines().count() + 1
450}
451
452pub fn validate(app: &str) -> Result<(), String> {
460 use naga::valid::{Capabilities, ValidationFlags, Validator};
461 let full = module_source(app);
462 let module = naga::front::wgsl::parse_str(&full)
463 .map_err(|e| renumber(&e.emit_to_string(&full), prelude_lines()))?;
464 Validator::new(ValidationFlags::all(), Capabilities::empty())
465 .validate(&module)
466 .map(|_| ())
467 .map_err(|e| renumber(&e.emit_to_string(&full), prelude_lines()))
468}
469
470fn renumber(msg: &str, offset: usize) -> String {
475 let mut out = String::with_capacity(msg.len());
476 let mut rest = msg;
477 while let Some(i) = rest.find("wgsl:") {
478 out.push_str(&rest[..i + 5]);
479 rest = &rest[i + 5..];
480 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
481 if digits.is_empty() {
482 continue;
483 }
484 let n = digits.parse::<usize>().unwrap_or(offset + 1);
485 out.push_str(&n.saturating_sub(offset).max(1).to_string());
486 rest = &rest[digits.len()..];
487 }
488 out.push_str(rest);
489 out
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 const GRADIENT: &str = "\
497fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
498 let t = in.local.y / max(in.size.y, 1.0);
499 return mix(params[0], params[1], t);
500}";
501
502 #[test]
503 fn a_gradient_validates() {
504 validate(GRADIENT).unwrap();
505 }
506
507 #[test]
510 fn the_stock_polygon_validates() {
511 validate(POLYGON).unwrap();
512 }
513
514 #[test]
515 fn the_stock_join_validates() {
516 validate(JOIN).unwrap();
517 }
518
519 #[test]
521 fn the_quad_colour_is_reachable_from_the_app() {
522 let src = "\
523fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
524 return vec4<f32>(in.color.rgb * params[0].x, 1.0);
525}";
526 validate(src).unwrap();
527 }
528
529 #[test]
533 fn the_image_input_is_reachable_from_the_app() {
534 let src = "\
535fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
536 let uv = in.local / max(in.size, vec2<f32>(1.0));
537 let cells = in.image.zw;
538 return mix(kui_sample(uv), kui_sample_nearest(uv), step(1.0, cells.x)) * params[0];
539}";
540 validate(src).unwrap();
541 }
542
543 #[test]
544 fn the_prelude_is_reachable_from_the_app() {
545 let src = "\
547fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
548 let d = kui_sd_rounded_box(in.local - in.size * 0.5, in.size * 0.5, vec4<f32>(8.0));
549 let a = 1.0 - smoothstep(-KUI_AA, KUI_AA, d);
550 return vec4<f32>(params[0].rgb, a * (0.5 + 0.5 * sin(in.time)) * in.scale / in.scale);
551}";
552 validate(src).unwrap();
553 }
554
555 #[test]
556 fn a_syntax_error_reports_the_apps_own_line() {
557 let src = "\
559fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
560 return vec4<f32>(1.0, 0.0, 0.0, 1.0)
561}";
562 let err = validate(src).unwrap_err();
563 assert!(
564 err.contains("wgsl:2:") || err.contains("wgsl:3:"),
565 "should point into the app's source, got:\n{err}"
566 );
567 assert!(
568 !err.contains(&format!("wgsl:{}:", prelude_lines() + 2)),
569 "line number was not moved out of the prelude:\n{err}"
570 );
571 }
572
573 #[test]
574 fn a_missing_fragment_function_is_refused() {
575 let err = validate("fn other() -> f32 { return 1.0; }").unwrap_err();
576 assert!(err.contains("fragment"), "{err}");
577 }
578
579 #[test]
580 fn the_wrong_signature_is_refused() {
581 let err = validate("fn fragment() -> vec4<f32> { return vec4<f32>(1.0); }").unwrap_err();
582 assert!(
583 !err.is_empty(),
584 "a no-argument `fragment` must not validate"
585 );
586 }
587
588 #[test]
589 fn an_empty_source_is_refused() {
590 assert!(validate("").is_err());
591 }
592
593 #[test]
594 fn the_module_is_the_three_parts_in_order() {
595 let m = module_source(GRADIENT);
596 let (p, a, e) = (
597 m.find("struct FragmentIn").unwrap(),
598 m.find("fn fragment(").unwrap(),
599 m.find(ENTRY_POINT).unwrap(),
600 );
601 assert!(p < a && a < e, "prelude, app, epilogue");
602 assert!(m.contains(ENTRY_POINT));
603 }
604}