shadertoy-cli 2.2.3

Agent-friendly ShaderToy project, rendering, debugging, and live-preview CLI
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};

struct TempRoot(PathBuf);

impl TempRoot {
    fn new() -> Self {
        static NEXT: AtomicU64 = AtomicU64::new(0);
        let path = std::env::temp_dir().join(format!(
            "shadertoy-cli-storage-{}-{}",
            std::process::id(),
            NEXT.fetch_add(1, Ordering::Relaxed)
        ));
        let _ = std::fs::remove_dir_all(&path);
        std::fs::create_dir_all(&path).expect("create temporary test directory");
        Self(path)
    }
}

impl Drop for TempRoot {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.0);
    }
}

fn shadertoy(args: &[&str]) -> Output {
    Command::new(env!("CARGO_BIN_EXE_shadertoy"))
        .args(args)
        .output()
        .expect("run shadertoy")
}

fn write_shared_storage_project(root: &Path) {
    std::fs::create_dir_all(root.join("shaders")).expect("create shaders directory");
    std::fs::write(
        root.join("ShaderToy.toml"),
        r#"format = 1

[project]
name = "shared-storage-cli-repro"

[render]
width = 2
height = 1
fps = 60.0
preview_time = 0.0

[[pass]]
name = "writer"
kind = "buffer"
source = "shaders/writer.frag"
width = 1
height = 1

[[pass.storage]]
binding = 0
name = "shared-data"
size = 16

[[pass]]
name = "image"
kind = "image"
source = "shaders/image.frag"

[[pass.storage]]
binding = 0
name = "shared-data"
size = 16

[[pass.input]]
channel = 0
source = "writer"
frame = "current"
filter = "nearest"
wrap = "clamp"
"#,
    )
    .expect("write manifest");

    std::fs::write(
        root.join("shaders/writer.frag"),
        r#"layout(std430, binding = 0) buffer SharedData { vec4 value; };
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    if (iFrame == 0)
        value = vec4(0.125, 0.25, 0.5, 1.0);
    fragColor = value;
}
"#,
    )
    .expect("write writer shader");

    std::fs::write(
        root.join("shaders/image.frag"),
        r#"layout(std430, binding = 0) buffer SharedData { vec4 value; };
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec4 textureValue = texture(iChannel0, vec2(0.5));
    fragColor = fragCoord.x < 1.0 ? textureValue : value;
}
"#,
    )
    .expect("write image shader");
}

#[cfg(target_os = "linux")]
#[test]
fn cli_render_shares_named_ssbo_between_fragment_passes() {
    let temp = TempRoot::new();
    let project = temp.0.join("project");
    write_shared_storage_project(&project);
    let project_arg = project.to_string_lossy().into_owned();

    let checked = shadertoy(&["check", "--project", &project_arg, "--json"]);
    assert!(checked.status.success(), "{checked:?}");

    let output = temp.0.join("result.png");
    let output_arg = output.to_string_lossy().into_owned();
    let rendered = shadertoy(&[
        "render",
        "--project",
        &project_arg,
        "--frame",
        "0",
        "-o",
        &output_arg,
    ]);
    assert!(rendered.status.success(), "{rendered:?}");

    let image = image::open(&output).expect("open render").to_rgb8();
    assert_eq!(image.dimensions(), (2, 1));
    let texture = image.get_pixel(0, 0).0;
    let storage = image.get_pixel(1, 0).0;
    for actual in [texture, storage] {
        assert!((i16::from(actual[0]) - 32).abs() <= 1, "{actual:?}");
        assert!((i16::from(actual[1]) - 64).abs() <= 1, "{actual:?}");
        assert!((i16::from(actual[2]) - 128).abs() <= 1, "{actual:?}");
    }

    let inspected = shadertoy(&[
        "--json",
        "inspect",
        "--project",
        &project_arg,
        "storage",
        "shared-data",
        "--frame",
        "0",
        "--type",
        "f32",
        "--count",
        "4",
    ]);
    assert!(inspected.status.success(), "{inspected:?}");
    let report: serde_json::Value =
        serde_json::from_slice(&inspected.stdout).expect("parse storage inspection");
    let values = report["values"].as_array().expect("storage values");
    assert_eq!(values.len(), 4);
    for (actual, expected) in values.iter().zip([0.125, 0.25, 0.5, 1.0]) {
        let actual = actual.as_f64().expect("finite f32 value");
        assert!((actual - expected).abs() < 1e-6, "{report}");
    }

    let state = temp.0.join("with-storage.ststate");
    let state_arg = state.to_string_lossy().into_owned();
    let captured = shadertoy(&[
        "state",
        "capture",
        "--project",
        &project_arg,
        "--frame",
        "0",
        "--include-storage",
        "-o",
        &state_arg,
    ]);
    assert!(captured.status.success(), "{captured:?}");

    let inspected_state = shadertoy(&["--json", "state", "inspect", &state_arg]);
    assert!(inspected_state.status.success(), "{inspected_state:?}");
    let header: serde_json::Value =
        serde_json::from_slice(&inspected_state.stdout).expect("parse state header");
    assert_eq!(header["header"]["format"], 4);
    assert_eq!(header["header"]["storage_buffers"]["shared-data"], 16);

    let replacement = temp.0.join("replacement.bin");
    let mut replacement_bytes = Vec::new();
    for value in [0.75f32, 0.5, 0.25, 1.0] {
        replacement_bytes.extend_from_slice(&value.to_le_bytes());
    }
    std::fs::write(&replacement, replacement_bytes).unwrap();
    let replacement_assignment = format!("shared-data={}", replacement.to_string_lossy());
    let modified = temp.0.join("modified.ststate");
    let modified_arg = modified.to_string_lossy().into_owned();
    let set = shadertoy(&[
        "state",
        "set-storage",
        &state_arg,
        &replacement_assignment,
        "-o",
        &modified_arg,
    ]);
    assert!(set.status.success(), "{set:?}");

    let resumed_path = temp.0.join("resumed.png");
    let resumed_arg = resumed_path.to_string_lossy().into_owned();
    let resumed = shadertoy(&[
        "render",
        "--project",
        &project_arg,
        "--state",
        &modified_arg,
        "--frame",
        "1",
        "-o",
        &resumed_arg,
    ]);
    assert!(resumed.status.success(), "{resumed:?}");
    let resumed_image = image::open(&resumed_path).unwrap().to_rgb8();
    let pixel = resumed_image.get_pixel(1, 0).0;
    assert!((i16::from(pixel[0]) - 191).abs() <= 1, "{pixel:?}");
    assert!((i16::from(pixel[1]) - 128).abs() <= 1, "{pixel:?}");
    assert!((i16::from(pixel[2]) - 64).abs() <= 1, "{pixel:?}");
}