Skip to main content

iridia_exec/
probe.rs

1//! One vertex of a vertex program, run to see what it does with chosen constants: the clip position a probe reads to
2//! learn what a constant is (a matrix the program places the vertex with, a table it reads the instance through).
3//!
4//! A probe sets the constants it asks about to values that make the answer show, gives the program a vertex, and
5//! compares clip positions. What it does not ask about the program still reads: its buffers are the caller's to answer
6//! ([`BufferReads`], a table probe watches which it reads), and its textures are answered here with stated stand-ins
7//! ([`SAMPLE_STAND_IN`], [`LOAD_STAND_IN`], [`QUERY_STAND_IN`]), counted ([`TextureUse`]). A stand-in lets a program that
8//! reads a texture of its own still show what it does with the camera; where it also moves the vertex by the texel, a
9//! check of the clip position fails, and the probe names the stand-ins as the likely reason ([`TextureUse::stand_ins`])
10//! rather than the constant it asked about.
11//!
12//! The answering is [`AnsweredReads`]: every texture read of a run answered by the caller ([`Answer`]) and recorded, so
13//! a run shows what a program does with what it reads, apart from what any texture holds. The vertex probe answers with
14//! the stand-ins ([`StandIns`]); a pixel program run through a [`crate::ProgramShader`] takes it as its [`Sampler`], and
15//! [`affine_uv`] reads from its records the map a program applies to a texture's coordinate.
16use std::collections::BTreeMap;
17
18mod buffers;
19pub use buffers::{AnsweredBuffers, BufferExtent, BufferRead};
20
21use iridia::{Program, ResourceId, Stage, SystemValue};
22
23use crate::{
24    Backend, Dim, Element, Executable, ImageLoad, ImageQuery, ImageSample, Resources, Runner, Sample, Sampler,
25};
26
27/// What a texture sample of the program is answered with: mid grey in every lane.
28pub const SAMPLE_STAND_IN: [f32; 4] = [0.5; 4];
29/// What a texel load is answered with: zero in every lane.
30pub const LOAD_STAND_IN: [u32; 4] = [0; 4];
31/// What a texture size or level-count query is answered with: one texel, one level, one sample.
32pub const QUERY_STAND_IN: [u32; 4] = [1; 4];
33
34/// A program's buffers, as the caller answers them: `count` (1 to 4) words from word `word` of the buffer `view`, and a
35/// buffer's length in words.
36pub trait BufferReads {
37    /// Answer `count` (1 to 4) words from word `word` of `view`; lanes past `count` are 0.
38    fn read(&mut self, view: ResourceId, word: u32, count: u8) -> Result<[u32; 4], String>;
39    /// The buffer's length in words: long enough for any read, unless the caller says otherwise.
40    fn words(&mut self, view: ResourceId) -> Result<u32, String> {
41        let _ = view;
42        Ok(1 << 20)
43    }
44}
45
46/// Buffers of zeros, for a program whose buffers the probe does not ask about.
47#[derive(Debug)]
48pub struct Zeros;
49
50impl BufferReads for Zeros {
51    fn read(&mut self, _view: ResourceId, _word: u32, _count: u8) -> Result<[u32; 4], String> {
52        Ok([0; 4])
53    }
54}
55
56/// How the caller answers a run's texture reads ([`AnsweredReads`]), each by its request; `R` names the texture (a
57/// program's resource [`Element`], or a bind slot). A load or a query is refused unless the answer gives one, so no read
58/// is answered by a value nobody stated.
59pub trait Answer<R> {
60    /// Answer a sample request with the texel's four lanes.
61    fn sample(&mut self, request: &ImageSample<R>) -> Result<[f32; 4], String>;
62    /// Answer a texel load; refused unless the answer gives one.
63    fn load(&mut self, request: &ImageLoad<R>) -> Result<[u32; 4], String> {
64        let _ = request;
65        Err("a texel load, which the answer does not give".into())
66    }
67    /// Answer a size or level-count query; refused unless the answer gives one.
68    fn query(&mut self, request: &ImageQuery<R>) -> Result<[u32; 4], String> {
69        let _ = request;
70        Err("a size or level-count query, which the answer does not give".into())
71    }
72}
73
74/// A closure answers samples only.
75impl<R, F: FnMut(&ImageSample<R>) -> Result<[f32; 4], String>> Answer<R> for F {
76    fn sample(&mut self, request: &ImageSample<R>) -> Result<[f32; 4], String> {
77        self(request)
78    }
79}
80
81/// The vertex probe's answer: [`SAMPLE_STAND_IN`], [`LOAD_STAND_IN`] and [`QUERY_STAND_IN`] to every read.
82#[derive(Clone, Copy, Debug, Default)]
83pub struct StandIns;
84
85impl<R> Answer<R> for StandIns {
86    fn sample(&mut self, _request: &ImageSample<R>) -> Result<[f32; 4], String> {
87        Ok(SAMPLE_STAND_IN)
88    }
89    fn load(&mut self, _request: &ImageLoad<R>) -> Result<[u32; 4], String> {
90        Ok(LOAD_STAND_IN)
91    }
92    fn query(&mut self, _request: &ImageQuery<R>) -> Result<[u32; 4], String> {
93        Ok(QUERY_STAND_IN)
94    }
95}
96
97/// A run's texture reads, each answered by `answer` and recorded in the order the program made them (a read the answer
98/// refuses is not recorded; the run fails with its reason). As a [`Sampler`] (`R` a bind slot) it is a pixel program's
99/// textures; stores, atomics and structured buffers are refused, as a sampler that gives none refuses them.
100#[derive(Debug)]
101pub struct AnsweredReads<R, A> {
102    /// How the reads are answered.
103    pub answer: A,
104    /// The samples answered, in order.
105    pub samples: Vec<ImageSample<R>>,
106    /// The loads answered, in order.
107    pub loads: Vec<ImageLoad<R>>,
108    /// The queries answered, in order.
109    pub queries: Vec<ImageQuery<R>>,
110}
111
112impl<R, A: Answer<R>> AnsweredReads<R, A> {
113    /// Reads answered by `answer`, with nothing recorded yet.
114    pub fn new(answer: A) -> Self {
115        AnsweredReads {
116            answer,
117            samples: Vec::new(),
118            loads: Vec::new(),
119            queries: Vec::new(),
120        }
121    }
122
123    /// Answer a sample and record it; a refused one is not recorded.
124    pub fn sample(&mut self, request: ImageSample<R>) -> Result<[f32; 4], String> {
125        let value = self.answer.sample(&request)?;
126        self.samples.push(request);
127        Ok(value)
128    }
129
130    /// Answer a load and record it; a refused one is not recorded.
131    pub fn load(&mut self, request: ImageLoad<R>) -> Result<[u32; 4], String> {
132        let value = self.answer.load(&request)?;
133        self.loads.push(request);
134        Ok(value)
135    }
136
137    /// Answer a query and record it; a refused one is not recorded.
138    pub fn query(&mut self, request: ImageQuery<R>) -> Result<[u32; 4], String> {
139        let value = self.answer.query(&request)?;
140        self.queries.push(request);
141        Ok(value)
142    }
143
144    /// How many reads of each kind were answered.
145    pub fn counts(&self) -> TextureUse {
146        let n = |len: usize| u32::try_from(len).unwrap_or(u32::MAX);
147        TextureUse {
148            samples: n(self.samples.len()),
149            loads: n(self.loads.len()),
150            queries: n(self.queries.len()),
151        }
152    }
153}
154
155impl<A: Answer<u32>> Sampler for AnsweredReads<u32, A> {
156    /// A plain 2D sample, answered as the image sample it is.
157    fn sample(&mut self, request: Sample) -> Result<[f32; 4], String> {
158        let [u, v] = request.uv;
159        AnsweredReads::sample(
160            self,
161            ImageSample {
162                texture: request.texture,
163                sampler: request.sampler,
164                dim: Dim::D2,
165                coord: [u, v, 0.],
166                layer: request.layer,
167                lod: request.lod.into(),
168                offset: [0; 3],
169                gather: None,
170                depth_ref: None,
171                depth: false,
172            },
173        )
174    }
175    fn sample_image(&mut self, request: ImageSample<u32>) -> Result<[f32; 4], String> {
176        AnsweredReads::sample(self, request)
177    }
178    fn load_image(&mut self, request: ImageLoad<u32>) -> Result<[u32; 4], String> {
179        AnsweredReads::load(self, request)
180    }
181    fn query_image(&mut self, request: ImageQuery<u32>) -> Result<[u32; 4], String> {
182        AnsweredReads::query(self, request)
183    }
184}
185
186/// The map a program applies to a texture coordinate, `sampled = scale * input + offset` on each axis ([`affine_uv`]).
187#[derive(Clone, Copy, Debug, PartialEq)]
188pub struct UvMap {
189    /// The factor on each axis.
190    pub scale: [f64; 2],
191    /// The offset on each axis.
192    pub offset: [f64; 2],
193}
194
195/// The map from an input coordinate to the coordinate a program sampled at, from `points` (input, sampled) of runs at
196/// known inputs: on each axis a scale and an offset, fitted to the two points farthest apart on that axis and confirmed by
197/// every other point (at least four points, the inputs not on one line, so each axis is confirmed apart from the other).
198/// A point is on the map within two roundings of an f32 multiply and add (`4 * f32::EPSILON` of the sum of the
199/// magnitudes of the product, the offset and the sampled value), the most a program's `input * scale + offset` in f32
200/// is off the exact map. Where the points are not on such a map, a 2D affine map of three of them tells why: one that
201/// every point is on is a rotation or a shear (an axis takes the other); none, and the coordinate is not affine in the
202/// input (a warp, or one made from another input or from time). Each is refused with its reason.
203#[allow(clippy::indexing_slicing, clippy::arithmetic_side_effects)] // axes are 0 or 1 of two-component arrays, point indices run over the (at least four) points, and the rest is float math
204pub fn affine_uv(points: &[([f32; 2], [f32; 2])]) -> Result<UvMap, String> {
205    if points.len() < 4 {
206        return Err(format!(
207            "{} points: a map is fitted to some and confirmed by a fourth",
208            points.len()
209        ));
210    }
211    let points: Vec<([f64; 2], [f64; 2])> = points
212        .iter()
213        .map(|(i, o)| (i.map(f64::from), o.map(f64::from)))
214        .collect();
215    let on_map = |predicted: f64, terms: f64, sampled: f64| {
216        (predicted - sampled).abs() <= 4. * f64::from(f32::EPSILON) * (terms + sampled.abs())
217    };
218    // three inputs not on one line, for the 2D map
219    let Some(&(p0, _)) = points.first() else {
220        return Err("no points".into());
221    };
222    let triangle = (1..points.len()).find_map(|i| {
223        (i + 1..points.len()).find_map(|j| {
224            let (pi, pj) = (points[i].0, points[j].0);
225            let det = (pi[0] - p0[0]) * (pj[1] - p0[1]) - (pi[1] - p0[1]) * (pj[0] - p0[0]);
226            (det != 0.).then_some((i, j, det))
227        })
228    });
229    let Some((i, j, det)) = triangle else {
230        return Err("the inputs are on one line: neither axis is confirmed apart from the other".into());
231    };
232    let mut scale = [0.; 2];
233    let mut offset = [0.; 2];
234    let mut separable = true;
235    for axis in 0..2 {
236        let low = points
237            .iter()
238            .min_by(|a, b| a.0[axis].total_cmp(&b.0[axis]))
239            .ok_or("no points")?;
240        let high = points
241            .iter()
242            .max_by(|a, b| a.0[axis].total_cmp(&b.0[axis]))
243            .ok_or("no points")?;
244        let a = (high.1[axis] - low.1[axis]) / (high.0[axis] - low.0[axis]);
245        let c = low.1[axis] - a * low.0[axis];
246        separable &= points.iter().all(|(input, sampled)| {
247            on_map(
248                a * input[axis] + c,
249                (a * input[axis]).abs() + c.abs(),
250                sampled[axis],
251            )
252        });
253        (scale[axis], offset[axis]) = (a, c);
254    }
255    if separable {
256        return Ok(UvMap { scale, offset });
257    }
258    // sampled[axis] = a * u + b * v + c on the three points (Cramer's rule)
259    let (q0, qi, qj) = (points[0], points[i], points[j]);
260    let solve = |axis: usize| {
261        let (du_i, dv_i, ds_i) = (qi.0[0] - q0.0[0], qi.0[1] - q0.0[1], qi.1[axis] - q0.1[axis]);
262        let (du_j, dv_j, ds_j) = (qj.0[0] - q0.0[0], qj.0[1] - q0.0[1], qj.1[axis] - q0.1[axis]);
263        let a = (ds_i * dv_j - dv_i * ds_j) / det;
264        let b = (du_i * ds_j - ds_i * du_j) / det;
265        [a, b, q0.1[axis] - a * q0.0[0] - b * q0.0[1]]
266    };
267    let maps = [solve(0), solve(1)];
268    let affine = points.iter().all(|(input, sampled)| {
269        (0..2).all(|axis| {
270            let [a, b, c] = maps[axis];
271            let terms = (a * input[0]).abs() + (b * input[1]).abs() + c.abs();
272            on_map(a * input[0] + b * input[1] + c, terms, sampled[axis])
273        })
274    });
275    if affine {
276        Err(format!(
277            "an axis takes the other (a rotation or a shear): u from v by {}, v from u by {}",
278            maps[0][1], maps[1][0]
279        ))
280    } else {
281        Err("not affine in the input coordinate (a warp, or a coordinate made from another input or from time)".into())
282    }
283}
284
285/// How many texture reads of a run were answered with a stand-in.
286#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
287pub struct TextureUse {
288    /// Samples answered with a stand-in.
289    pub samples: u32,
290    /// Loads answered with a stand-in.
291    pub loads: u32,
292    /// Size and level-count queries, as the lifted program asks them: one `GetDimensions` of a size and a level count
293    /// may be two.
294    pub queries: u32,
295}
296
297impl TextureUse {
298    /// Whether the run read no texture.
299    pub fn none(&self) -> bool {
300        *self == TextureUse::default()
301    }
302
303    /// The stand-ins the run's texture reads were answered with, as the part of a reason that names them; `None` when it
304    /// read no texture.
305    pub fn stand_ins(&self) -> Option<String> {
306        if self.none() {
307            return None;
308        }
309        let mut parts = Vec::new();
310        for (n, what, stand_in) in [
311            (self.samples, "samples", format!("{SAMPLE_STAND_IN:?}")),
312            (self.loads, "loads", format!("{LOAD_STAND_IN:?}")),
313            (
314                self.queries,
315                "size and level-count queries",
316                format!("{QUERY_STAND_IN:?}"),
317            ),
318        ] {
319            if n > 0 {
320                parts.push(format!("{n} {what} answered with {stand_in}"));
321            }
322        }
323        Some(format!(
324            "the program reads a texture, answered with stand-ins ({}): a vertex it moves by the texel is not where the \
325             constants alone put it",
326            parts.join(", ")
327        ))
328    }
329
330    /// `why` a check of the run's clip position failed, with the stand-ins the run's texture reads were answered with
331    /// where it read any ([`TextureUse::stand_ins`]).
332    pub fn reason(&self, why: String) -> String {
333        match self.stand_ins() {
334            Some(stand_ins) => format!("{why}; {stand_ins}"),
335            None => why,
336        }
337    }
338}
339
340/// One run's answer: the clip position, the texture reads answered with stand-ins and the caller's buffers as the run
341/// left them.
342#[derive(Debug)]
343pub struct ProbeRun<B> {
344    /// The clip-space position (x, y, z, w) the program wrote.
345    pub clip: [f32; 4],
346    /// How many texture reads were answered with stand-ins.
347    pub textures: TextureUse,
348    /// The caller's buffers as the run left them.
349    pub buffers: B,
350}
351
352/// A vertex program made ready to run one vertex at a time.
353#[derive(Debug)]
354pub struct VertexProbe {
355    program: Program,
356    executable: Executable,
357    position: usize,
358}
359
360impl VertexProbe {
361    /// The probe of `program`, a vertex program that writes a clip position and that the executor takes.
362    pub fn new(program: Program) -> Result<Self, String> {
363        if program.interface.stage != Stage::Vertex {
364            return Err("the program is not a vertex program".into());
365        }
366        let position = program
367            .interface
368            .outputs
369            .iter()
370            .position(|o| o.system == Some(SystemValue::ClipPosition))
371            .ok_or("the program writes no clip position")?;
372        let executable =
373            Executable::new(&program).map_err(|e| format!("the executor does not take the program: {e}"))?;
374        Ok(VertexProbe {
375            program,
376            executable,
377            position,
378        })
379    }
380
381    /// The vertex program the probe runs.
382    pub fn program(&self) -> &Program {
383        &self.program
384    }
385
386    /// Runs one vertex: the constant buffers' rows by resource (a buffer not given is zeros), the program's inputs in
387    /// the order of its interface, and its buffers as `buffers` answers them.
388    pub fn run<B: BufferReads>(
389        &self,
390        constants: BTreeMap<ResourceId, Vec<[u32; 4]>>,
391        inputs: &[[u32; 4]],
392        buffers: B,
393    ) -> Result<ProbeRun<B>, String> {
394        let mut resources = Resources {
395            constants,
396            ..Resources::default()
397        };
398        for r in &self.program.interface.resources {
399            if let iridia::ResourceKind::ConstantBuffer { layout } = &r.kind {
400                resources
401                    .constants
402                    .entry(r.id)
403                    .or_insert_with(|| vec![[0; 4]; layout.size.div_ceil(16).max(1) as usize]);
404            }
405        }
406        let bound = self
407            .executable
408            .bind_resources(&resources)
409            .map_err(|e| format!("the constants do not bind: {e}"))?;
410        let mut backend = Answers {
411            buffers,
412            textures: AnsweredReads::new(StandIns),
413        };
414        let [out] = Runner::<1>::new(&self.executable, &bound, &mut backend, None)
415            .run([inputs])
416            .map_err(|e| format!("the program does not run: {e}"))?;
417        let clip = out
418            .values
419            .get(self.position)
420            .ok_or("the run gave no clip position")?
421            .map(f32::from_bits);
422        Ok(ProbeRun {
423            clip,
424            textures: backend.textures.counts(),
425            buffers: backend.buffers,
426        })
427    }
428}
429
430/// The backend of a run: the caller's buffers, and stand-ins for the textures, recorded.
431struct Answers<B> {
432    buffers: B,
433    textures: AnsweredReads<Element, StandIns>,
434}
435
436impl<B: BufferReads> Backend for Answers<B> {
437    fn sample(&mut self, request: ImageSample<Element>) -> Result<[f32; 4], String> {
438        self.textures.sample(request)
439    }
440    fn load(&mut self, request: ImageLoad<Element>) -> Result<[u32; 4], String> {
441        self.textures.load(request)
442    }
443    fn query(&mut self, request: ImageQuery<Element>) -> Result<[u32; 4], String> {
444        self.textures.query(request)
445    }
446    fn read_buffer(&mut self, view: ResourceId, word: u32, count: u8) -> Result<[u32; 4], String> {
447        self.buffers.read(view, word, count)
448    }
449    fn buffer_words(&mut self, view: ResourceId) -> Result<u32, String> {
450        self.buffers.words(view)
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use crate::Lod;
458
459    /// Points of `sampled = scale * input + offset`, the inputs not on one line.
460    fn points(scale: [f32; 2], offset: [f32; 2], inputs: &[[f32; 2]]) -> Vec<([f32; 2], [f32; 2])> {
461        inputs
462            .iter()
463            .map(|&[u, v]| ([u, v], [u * scale[0] + offset[0], v * scale[1] + offset[1]]))
464            .collect()
465    }
466
467    const INPUTS: [[f32; 2]; 4] = [[0.1, 0.2], [0.7, 0.3], [0.4, 0.9], [0.55, 0.45]];
468
469    /// A tiling and its centring offset are read back, within the stated rounding bound.
470    #[test]
471    fn an_affine_map_is_read_back_from_four_points() {
472        let map = affine_uv(&points([20., 7.5], [-9.5, -3.25], &INPUTS)).unwrap();
473        for (got, want) in map.scale.iter().chain(&map.offset).zip([20., 7.5, -9.5, -3.25]) {
474            assert!((got - want).abs() < 1e-4, "{map:?}");
475        }
476    }
477
478    /// Fewer than four points, inputs on one line, a point off the map and a map that turns an axis into the other are
479    /// each refused with their own reason.
480    #[test]
481    fn a_map_that_cannot_be_confirmed_or_is_no_scale_and_offset_is_refused() {
482        let three = points([2., 2.], [0., 0.], &INPUTS[..3]);
483        assert!(affine_uv(&three).unwrap_err().contains("confirmed by a fourth"));
484        let line: Vec<_> = (0..4)
485            .map(|k| {
486                let t = k as f32 * 0.25;
487                ([t, t], [t, t])
488            })
489            .collect();
490        assert!(affine_uv(&line).unwrap_err().contains("one line"));
491        let warp: Vec<_> = INPUTS.iter().map(|&[u, v]| ([u, v], [u * u, v])).collect();
492        assert!(affine_uv(&warp).unwrap_err().contains("not affine"));
493        let turned: Vec<_> = INPUTS.iter().map(|&[u, v]| ([u, v], [v, -u])).collect();
494        assert!(affine_uv(&turned).unwrap_err().contains("rotation or a shear"));
495    }
496
497    /// As a sampler, every read is answered by the caller and recorded: a plain 2D sample as the image sample it is; a
498    /// load the answer does not give is refused and not recorded.
499    #[test]
500    fn answered_reads_answer_and_record_each_read() {
501        let mut reads =
502            AnsweredReads::new(|r: &ImageSample<u32>| Ok([r.coord[0], r.coord[1], r.texture as f32, 1.]));
503        let plain = Sample {
504            texture: 3,
505            sampler: 1,
506            uv: [0.25, 0.75],
507            layer: None,
508            lod: Lod::Level(2.),
509        };
510        assert_eq!(Sampler::sample(&mut reads, plain), Ok([0.25, 0.75, 3., 1.]));
511        let load = ImageLoad {
512            texture: 3,
513            dim: Dim::D2,
514            storage: false,
515            texel: [0; 3],
516            layer: None,
517            sample: None,
518            mip: None,
519        };
520        assert!(reads.load_image(load).unwrap_err().contains("texel load"));
521        let [r] = reads.samples.as_slice() else {
522            panic!("{:?}", reads.samples)
523        };
524        assert_eq!(
525            (r.texture, r.sampler, r.dim, r.coord),
526            (3, 1, Dim::D2, [0.25, 0.75, 0.])
527        );
528        assert_eq!(r.lod, crate::ImageLod::Level(2.));
529        assert_eq!(
530            reads.counts(),
531            TextureUse {
532                samples: 1,
533                loads: 0,
534                queries: 0
535            }
536        );
537    }
538}