Escaped config strings (ghostty_os::osbytes) kept reaching OS boundaries undecoded, and decoding was occasionally applied to plain text. The root cause was that both were plain `String`s. Now: - `EncodedOs` is a newtype (no Display/AsRef<Path>) that decodes only via to_os/to_path/to_bytes/to_wide; plain text enters via from_plain_text. OS helpers (env, home, xdg, passwd, resources dir, path expansion), config::Path, WorkingDirectory, theme/file_load/edit paths, terminal pwd, OpenUrl/Pwd actions and termio cwd use it, so the compiler forces decoding at every boundary. os::open takes &OsStr. Config output is unchanged. Fixes all rows of docs/rewrite/notes/review-rv5-codex.md (final-Z7.md): screen-file copy; regex/OSC 8 URLs no longer decoded (regression); lossless Windows WTF-8/WTF-16 for args, env and CreateProcessW; Windows resources dir/TERMINFO; color OSC request lists and X11 color lookup no longer allocate globally (C zero-allocation test extended); macOS Mach handler resumes all threads before KERN_FAILURE (hang regression) and SIGABRT minidumps go through the exception thread; plus open-config, background images, crash dir, ssh cache, exe dir/PATH, fontconfig filenames, file links, kitty TMPDIR, .hushlogin and benchmark paths. The typing also surfaced and fixed gtk-custom-css, bell audio, bash HISTFILE, OSC 52 writes and the C config getters. Test Plan: - cargo test --workspace (3376 passed) - just check-macos (both), just check-windows, just check-wasm - diff <(ghostty-rs +show-config --default --docs) \ <(ghostty +show-config --default --docs)
902 lines
34 KiB
Rust
902 lines
34 KiB
Rust
//! Shadertoy-style custom shader support (`renderer/shadertoy.zig`).
|
|
//!
|
|
//! Ghostty converts user shaders (shadertoy GLSL, which only define a
|
|
//! `mainImage` function) into full GLSL for the active renderer backend. The
|
|
//! Zig implementation goes GLSL -> glslang -> SPIR-V -> spirv-cross -> target
|
|
//! GLSL/MSL. This port is pure Rust: GLSL -> `naga` (glsl-in frontend,
|
|
//! validated) -> `naga` glsl-out backend at version 430 core. See
|
|
//! `docs/rewrite/notes/ghostty-renderer-R1.md` for the (small) source
|
|
//! adaptations needed for naga's Vulkan-flavoured GLSL frontend.
|
|
|
|
use std::{io::Read, path::Path};
|
|
|
|
use bytemuck::{Pod, Zeroable};
|
|
use naga::{
|
|
back::{glsl as glsl_out, msl as msl_out},
|
|
front::glsl as glsl_in,
|
|
valid::{Capabilities, ModuleInfo, ValidationFlags, Validator},
|
|
};
|
|
|
|
use crate::shadertoy_sampler;
|
|
|
|
/// The shadertoy prefix (declares the uniforms and the `main` entry point).
|
|
pub const PREFIX: &str = include_str!("../shaders/shadertoy_prefix.glsl");
|
|
|
|
/// The uniform struct used for shadertoy shaders (std140, 4496 bytes).
|
|
///
|
|
/// Field order matches `shadertoy.zig`; explicit `pad*` fields reproduce the
|
|
/// padding of the Zig `extern struct` with `align(16)` fields.
|
|
#[repr(C)]
|
|
#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable)]
|
|
pub struct Uniforms {
|
|
pub resolution: [f32; 3],
|
|
pub time: f32,
|
|
pub time_delta: f32,
|
|
pub frame_rate: f32,
|
|
pub frame: i32,
|
|
pub pad0: [u8; 4],
|
|
pub channel_time: [[f32; 4]; 4],
|
|
pub channel_resolution: [[f32; 4]; 4],
|
|
pub mouse: [f32; 4],
|
|
pub date: [f32; 4],
|
|
pub sample_rate: f32,
|
|
pub pad1: [u8; 12],
|
|
pub current_cursor: [f32; 4],
|
|
pub previous_cursor: [f32; 4],
|
|
pub current_cursor_color: [f32; 4],
|
|
pub previous_cursor_color: [f32; 4],
|
|
pub current_cursor_style: i32,
|
|
pub previous_cursor_style: i32,
|
|
pub cursor_visible: i32,
|
|
pub cursor_change_time: f32,
|
|
pub time_focus: f32,
|
|
pub focus: i32,
|
|
pub pad2: [u8; 8],
|
|
pub palette: [[f32; 4]; 256],
|
|
pub background_color: [f32; 4],
|
|
pub foreground_color: [f32; 4],
|
|
pub cursor_color: [f32; 4],
|
|
pub cursor_text: [f32; 4],
|
|
pub selection_background_color: [f32; 4],
|
|
pub selection_foreground_color: [f32; 4],
|
|
}
|
|
|
|
impl Default for Uniforms {
|
|
fn default() -> Self {
|
|
Zeroable::zeroed()
|
|
}
|
|
}
|
|
|
|
/// The target to load shaders for.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Target {
|
|
Glsl,
|
|
/// Metal Shading Language (naga's `msl-out` replaces spirv-cross).
|
|
Msl,
|
|
}
|
|
|
|
/// An entry of a `custom-shader` config path list (`RepeatablePath.Value`).
|
|
// TODO(integration): replace with ghostty_config's RepeatablePath.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum PathItem {
|
|
/// A path that may be missing (`?path`); missing files are skipped.
|
|
Optional(std::path::PathBuf),
|
|
/// A path that must exist.
|
|
Required(std::path::PathBuf),
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum Error {
|
|
#[error("io error: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
/// The shader source exceeds the 4MB limit.
|
|
#[error("shader file too big")]
|
|
FileTooBig,
|
|
/// GLSL parsing failed (upstream: `error.GlslangFailed`); details are in
|
|
/// the [`ShaderLog`].
|
|
#[error("GLSL compilation failed")]
|
|
GlslangFailed,
|
|
/// The parsed module failed validation (upstream: link failure).
|
|
#[error("shader validation failed")]
|
|
ValidationFailed,
|
|
/// Writing the target GLSL failed (upstream: `error.SpvcFailed`).
|
|
#[error("shader generation failed: {0}")]
|
|
SpvcFailed(String),
|
|
#[error("unsupported shader target")]
|
|
Unsupported,
|
|
}
|
|
|
|
/// Retrieve errors from shader compilation (`SpirvLog`).
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct ShaderLog {
|
|
pub info: String,
|
|
pub debug: String,
|
|
}
|
|
|
|
/// Load a set of shaders from files and convert them to the target
|
|
/// format. The shader order is preserved.
|
|
pub fn load_from_files(paths: &[PathItem], target: Target) -> Result<Vec<String>, Error> {
|
|
let mut list = Vec::new();
|
|
|
|
for item in paths {
|
|
let (path, optional) = match item {
|
|
PathItem::Optional(p) => (p, true),
|
|
PathItem::Required(p) => (p, false),
|
|
};
|
|
|
|
let shader = match load_from_file(path, target) {
|
|
Ok(s) => s,
|
|
Err(Error::Io(e)) if optional && e.kind() == std::io::ErrorKind::NotFound => {
|
|
continue;
|
|
}
|
|
Err(e) => return Err(e),
|
|
};
|
|
log::info!("loaded custom shader path={}", path.display());
|
|
list.push(shader);
|
|
}
|
|
|
|
Ok(list)
|
|
}
|
|
|
|
/// Load a single shader from a file and convert it to the target language
|
|
/// ready to be used with renderers.
|
|
pub fn load_from_file(path: &Path, target: Target) -> Result<String, Error> {
|
|
// Read it all into memory -- we don't expect shaders to be large.
|
|
const MAX: u64 = 4 * 1024 * 1024; // 4MB
|
|
let mut src = Vec::new();
|
|
let file = std::fs::File::open(path)?;
|
|
file.take(MAX + 1).read_to_end(&mut src)?;
|
|
if src.len() as u64 > MAX {
|
|
return Err(Error::FileTooBig);
|
|
}
|
|
let src = String::from_utf8_lossy(&src);
|
|
|
|
// Convert to full GLSL, then compile.
|
|
let mut errlog = ShaderLog::default();
|
|
let compiled = match compile_shadertoy(&src, Some(&mut errlog)) {
|
|
Ok(c) => c,
|
|
Err(err) => {
|
|
if !errlog.info.is_empty() || !errlog.debug.is_empty() {
|
|
log::warn!(
|
|
"spirv error path={} info={} debug={}",
|
|
path.display(),
|
|
errlog.info,
|
|
errlog.debug
|
|
);
|
|
}
|
|
return Err(err);
|
|
}
|
|
};
|
|
|
|
match target {
|
|
Target::Glsl => glsl_from_compiled(&compiled),
|
|
Target::Msl => msl_from_compiled(&compiled),
|
|
}
|
|
}
|
|
|
|
/// Convert a ShaderToy shader into valid GLSL.
|
|
///
|
|
/// ShaderToy shaders aren't full shaders, they're just implementing a
|
|
/// mainImage function and don't define any of the uniforms. This function
|
|
/// will convert the ShaderToy shader into a valid GLSL shader that can be
|
|
/// compiled and linked.
|
|
pub fn glsl_from_shader(src: &str) -> String {
|
|
let mut out = String::with_capacity(PREFIX.len() + src.len() + 2);
|
|
out.push_str(PREFIX);
|
|
out.push_str("\n\n");
|
|
out.push_str(src);
|
|
out
|
|
}
|
|
|
|
/// A parsed and validated shader module, ready to be converted to target GLSL.
|
|
pub struct Compiled {
|
|
module: naga::Module,
|
|
info: ModuleInfo,
|
|
}
|
|
|
|
/// Compile a ShaderToy shader (just the user's source, without the prefix)
|
|
/// to a validated module. Equivalent to `glslFromShader` + `spirvFromGlsl`.
|
|
pub fn compile_shadertoy(src: &str, errlog: Option<&mut ShaderLog>) -> Result<Compiled, Error> {
|
|
spirv_from_glsl(&glsl_from_shader(src), errlog)
|
|
}
|
|
|
|
/// Compile full fragment-shader GLSL (as produced by [`glsl_from_shader`])
|
|
/// into a validated module (`spirvFromGlsl`; the SPIR-V is naga's IR).
|
|
///
|
|
/// The prefix is written for glslang's Vulkan GLSL. naga's GLSL frontend
|
|
/// accepts a stricter subset, so the well known prefix constructs are adapted
|
|
/// first (see [`adapt_for_naga`]).
|
|
pub fn spirv_from_glsl(src: &str, errlog: Option<&mut ShaderLog>) -> Result<Compiled, Error> {
|
|
let adapted = adapt_for_naga(src);
|
|
|
|
let mut frontend = glsl_in::Frontend::default();
|
|
let module = match frontend.parse(
|
|
&glsl_in::Options::from(naga::ShaderStage::Fragment),
|
|
&adapted,
|
|
) {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
if let Some(log) = errlog {
|
|
log.info = e.emit_to_string(&adapted);
|
|
log.debug = String::new();
|
|
}
|
|
return Err(Error::GlslangFailed);
|
|
}
|
|
};
|
|
|
|
let info = match Validator::new(ValidationFlags::all(), Capabilities::all()).validate(&module) {
|
|
Ok(i) => i,
|
|
Err(e) => {
|
|
if let Some(log) = errlog {
|
|
log.info = e.emit_to_string(&adapted);
|
|
log.debug = format!("{e:?}");
|
|
}
|
|
return Err(Error::ValidationFailed);
|
|
}
|
|
};
|
|
|
|
Ok(Compiled { module, info })
|
|
}
|
|
|
|
/// Rewrites the (verbatim Ghostty) shadertoy prefix constructs that naga's
|
|
/// GLSL frontend cannot parse. Source text outside the prefix is untouched
|
|
/// except that `main` is moved after the user code.
|
|
///
|
|
/// * `#version 430 core` -> `#version 460 core` (naga accepts 440/450/460).
|
|
/// * `uniform` qualifiers on interface block members are dropped.
|
|
/// * `layout(location = 0) in vec4 gl_FragCoord;` is dropped (naga knows the
|
|
/// builtin).
|
|
/// * the combined `sampler2D iChannel0` becomes a texture + sampler pair and
|
|
/// `iChannel0` a macro that combines them, so `texture(iChannel0, uv)`
|
|
/// keeps working.
|
|
/// * the `mainImage` prototype and `main` are moved after the user code
|
|
/// (naga requires callee definitions before their callers).
|
|
fn adapt_for_naga(full: &str) -> String {
|
|
// Only the verbatim prefix gets the constructs below; the user's code is
|
|
// only touched by the combined-sampler rewrite.
|
|
let (prefix, user) = match full.strip_prefix(PREFIX) {
|
|
Some(rest) => (PREFIX, rest),
|
|
None => (full, ""),
|
|
};
|
|
let mut s = prefix
|
|
.replacen("#version 430 core", "#version 460 core", 1)
|
|
.replace(" uniform ", " ")
|
|
.replacen("layout(location = 0) in vec4 gl_FragCoord;", "", 1)
|
|
// std140 pads `float iChannelTime[4]` to stride 16, which naga types
|
|
// differently from a plain local `float[4]`, so whole-array loads and
|
|
// copies (`float t[4] = iChannelTime;`, passing it to a function) do
|
|
// not validate. The block member is renamed and `iChannelTime` becomes
|
|
// a function that reconstructs the plain array from the padded
|
|
// elements; element reads (`iChannelTime[i]`) keep working.
|
|
.replacen(
|
|
" float iChannelTime[4];\n",
|
|
" float iChannelTimeUbo[4];\n",
|
|
1,
|
|
)
|
|
.replacen(
|
|
"\n#define CURSORSTYLE_BLOCK ",
|
|
"\nfloat[4] iChannelTime_plain() {\n return float[4](iChannelTimeUbo[0], iChannelTimeUbo[1], iChannelTimeUbo[2], iChannelTimeUbo[3]);\n}\n#define iChannelTime iChannelTime_plain()\n\n#define CURSORSTYLE_BLOCK ",
|
|
1,
|
|
)
|
|
.replacen(
|
|
"layout(binding = 0) uniform sampler2D iChannel0;",
|
|
"layout(set = 0, binding = 0) uniform texture2D iChannel0_tex;\n\
|
|
layout(set = 0, binding = 2) uniform sampler iChannel0_smp;\n\
|
|
#define iChannel0 sampler2D(iChannel0_tex, iChannel0_smp)",
|
|
1,
|
|
)
|
|
.replacen(
|
|
"layout(binding = 1, std140)",
|
|
"layout(set = 0, binding = 1, std140)",
|
|
1,
|
|
)
|
|
.replacen(
|
|
"void mainImage( out vec4 fragColor, in vec2 fragCoord );\n",
|
|
"",
|
|
1,
|
|
)
|
|
.replacen(
|
|
"void main() { mainImage (_fragColor, gl_FragCoord.xy); }",
|
|
"",
|
|
1,
|
|
);
|
|
if !user.is_empty() {
|
|
// `texture2D` is a type for naga (and the `sampler2D` parameter
|
|
// rewrite): the prefix macro is replaced by rewriting the calls.
|
|
s = s.replacen("#define texture2D texture", "", 1);
|
|
s.push_str(&shadertoy_sampler::rewrite(user));
|
|
}
|
|
s.push_str("\nvoid main() { mainImage (_fragColor, gl_FragCoord.xy); }\n");
|
|
s
|
|
}
|
|
|
|
/// The name of the fragment entry point in generated MSL. spirv-cross calls
|
|
/// it `main0` because `main` is reserved in MSL; naga would turn `main0` into
|
|
/// `main0_` (it suffixes names ending in a digit), so we use a name it keeps
|
|
/// as is. The Metal renderer looks the function up by this name.
|
|
pub const MSL_ENTRY_POINT: &str = "ghostty_main";
|
|
|
|
/// Convert a compiled shader to MSL (`mslFromSpv`).
|
|
///
|
|
/// The resource slots follow what spirv-cross produces with
|
|
/// `SPVC_COMPILER_OPTION_MSL_ENABLE_DECORATION_BINDING` for the shadertoy
|
|
/// prefix: the `Globals` uniform block at `[[buffer(1)]]` ("we need this to
|
|
/// properly locate the uniform block to index 1") and `iChannel0` as
|
|
/// `[[texture(0)]]` / `[[sampler(0)]]`, which is where the Metal render pass
|
|
/// binds them.
|
|
pub fn msl_from_compiled(compiled: &Compiled) -> Result<String, Error> {
|
|
let fail = |e: &dyn std::fmt::Display| {
|
|
log::warn!("spirv-cross error message={e}");
|
|
Error::SpvcFailed(e.to_string())
|
|
};
|
|
|
|
// Rename the entry point (info is indexed by entry point order, which
|
|
// doesn't change).
|
|
let mut module = compiled.module.clone();
|
|
for ep in &mut module.entry_points {
|
|
if ep.name == "main" {
|
|
ep.name = MSL_ENTRY_POINT.into();
|
|
}
|
|
}
|
|
|
|
let mut resources = msl_out::EntryPointResources::default();
|
|
let mut bind = |binding: u32, target: msl_out::BindTarget| {
|
|
resources
|
|
.resources
|
|
.insert(naga::ResourceBinding { group: 0, binding }, target);
|
|
};
|
|
// iChannel0 texture (the sampler is split off at binding 2, see
|
|
// `adapt_for_naga`).
|
|
bind(
|
|
0,
|
|
msl_out::BindTarget {
|
|
texture: Some(0),
|
|
..Default::default()
|
|
},
|
|
);
|
|
bind(
|
|
1,
|
|
msl_out::BindTarget {
|
|
buffer: Some(1),
|
|
..Default::default()
|
|
},
|
|
);
|
|
bind(
|
|
2,
|
|
msl_out::BindTarget {
|
|
sampler: Some(msl_out::BindSamplerTarget::Resource(0)),
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
let mut options = msl_out::Options {
|
|
// Needed for `[[position]]` fragment inputs etc. on all supported
|
|
// macOS versions (the metallib is built for macOS 10.14 / MSL 2.1).
|
|
lang_version: (2, 1),
|
|
fake_missing_bindings: false,
|
|
..Default::default()
|
|
};
|
|
options
|
|
.per_entry_point_map
|
|
.insert(MSL_ENTRY_POINT.into(), resources);
|
|
|
|
let pipeline = msl_out::PipelineOptions {
|
|
entry_point: Some((naga::ShaderStage::Fragment, MSL_ENTRY_POINT.into())),
|
|
..Default::default()
|
|
};
|
|
|
|
let (msl, _info) = msl_out::write_string(&module, &compiled.info, &options, &pipeline)
|
|
.map_err(|e| fail(&e))?;
|
|
Ok(fix_uniform_array_strides(&module, msl))
|
|
}
|
|
|
|
/// A uniform-block member that is an array of scalars. std140 gives it a
|
|
/// 16-byte element stride (what we upload and what spirv-cross emits padded
|
|
/// types for), but naga's MSL writer ignores the array `stride` and emits a
|
|
/// tightly packed `float[N]`.
|
|
struct StridedArray {
|
|
member: String,
|
|
/// naga's MSL name of the array wrapper struct (`type_<handle index>`).
|
|
type_name: String,
|
|
/// MSL scalar element type.
|
|
scalar: &'static str,
|
|
len: u32,
|
|
/// Lanes of the padded element (stride / scalar size).
|
|
lanes: u32,
|
|
}
|
|
|
|
fn strided_uniform_arrays(module: &naga::Module) -> Vec<StridedArray> {
|
|
let mut out = Vec::new();
|
|
for (_, gv) in module.global_variables.iter() {
|
|
if gv.space != naga::AddressSpace::Uniform {
|
|
continue;
|
|
}
|
|
let naga::TypeInner::Struct { members, .. } = &module.types[gv.ty].inner else {
|
|
continue;
|
|
};
|
|
for m in members {
|
|
let naga::TypeInner::Array {
|
|
base,
|
|
size: naga::ArraySize::Constant(n),
|
|
stride,
|
|
} = &module.types[m.ty].inner
|
|
else {
|
|
continue;
|
|
};
|
|
let naga::TypeInner::Scalar(sc) = &module.types[*base].inner else {
|
|
continue;
|
|
};
|
|
let scalar = match (sc.kind, sc.width) {
|
|
(naga::ScalarKind::Float, 4) => "float",
|
|
(naga::ScalarKind::Sint, 4) => "int",
|
|
(naga::ScalarKind::Uint, 4) => "uint",
|
|
_ => continue,
|
|
};
|
|
let size = u32::from(sc.width);
|
|
if *stride <= size {
|
|
continue;
|
|
}
|
|
let (Some(name), true) = (m.name.clone(), stride % size == 0) else {
|
|
continue;
|
|
};
|
|
out.push(StridedArray {
|
|
member: name,
|
|
type_name: format!("type_{}", m.ty.index()),
|
|
scalar,
|
|
len: n.get(),
|
|
lanes: stride / size,
|
|
});
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Make the MSL uniform block match the std140 layout we upload: scalar
|
|
/// arrays become arrays of `vecN` (stride 16 for std140) and element reads
|
|
/// take `.x`. The writer ignores array strides, so this is done on the text.
|
|
fn fix_uniform_array_strides(module: &naga::Module, mut msl: String) -> String {
|
|
for a in strided_uniform_arrays(module) {
|
|
let wrapper = format!("{}_std140", a.type_name);
|
|
// 1. The block member uses the padded wrapper.
|
|
let decl = format!(" {} {};", a.type_name, a.member);
|
|
if !msl.contains(&decl) {
|
|
continue;
|
|
}
|
|
msl = msl.replace(&decl, &format!(" {wrapper} {};", a.member));
|
|
// 2. Define the wrapper before the struct that uses it.
|
|
let def = format!(
|
|
"struct {wrapper} {{\n metal::vec<{}, {}> inner[{}];\n}};\n",
|
|
a.scalar, a.lanes, a.len
|
|
);
|
|
if let Some(at) = msl.find(&format!("\n {wrapper} {};", a.member))
|
|
&& let Some(start) = msl[..at].rfind("\nstruct ")
|
|
{
|
|
msl.insert_str(start + 1, &def);
|
|
}
|
|
// 3. Element reads: `<expr>.member.inner[idx]` -> `... [idx].x`.
|
|
let needle = format!(".{}.inner[", a.member);
|
|
let mut from = 0;
|
|
while let Some(rel) = msl[from..].find(&needle) {
|
|
let open = from + rel + needle.len() - 1;
|
|
let mut depth = 0usize;
|
|
let mut close = None;
|
|
for (k, ch) in msl[open..].char_indices() {
|
|
match ch {
|
|
'[' => depth += 1,
|
|
']' => {
|
|
depth -= 1;
|
|
if depth == 0 {
|
|
close = Some(open + k);
|
|
break;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
let Some(close) = close else { break };
|
|
msl.insert_str(close + 1, ".x");
|
|
from = close + 3;
|
|
}
|
|
}
|
|
msl
|
|
}
|
|
|
|
/// Convert a compiled shader to GLSL 430 core (`glslFromSpv`).
|
|
pub fn glsl_from_compiled(compiled: &Compiled) -> Result<String, Error> {
|
|
const GLSL_VERSION: u16 = 430;
|
|
|
|
let mut options = glsl_out::Options {
|
|
version: glsl_out::Version::Desktop(GLSL_VERSION),
|
|
writer_flags: glsl_out::WriterFlags::empty(),
|
|
..Default::default()
|
|
};
|
|
// Map the (set 0) resources to the GL binding points used by the
|
|
// renderer: iChannel0 texture at unit 0, the Globals block at binding 1.
|
|
for (binding, slot) in [(0u32, 0u8), (1, 1), (2, 0)] {
|
|
options
|
|
.binding_map
|
|
.insert(naga::ResourceBinding { group: 0, binding }, slot);
|
|
}
|
|
let pipeline = glsl_out::PipelineOptions {
|
|
shader_stage: naga::ShaderStage::Fragment,
|
|
entry_point: "main".into(),
|
|
multiview: None,
|
|
};
|
|
|
|
let mut out = String::new();
|
|
let mut writer = glsl_out::Writer::new(
|
|
&mut out,
|
|
&compiled.module,
|
|
&compiled.info,
|
|
&options,
|
|
&pipeline,
|
|
naga::proc::BoundsCheckPolicies::default(),
|
|
)
|
|
.map_err(|e| {
|
|
log::warn!("spirv-cross error message={e}");
|
|
Error::SpvcFailed(e.to_string())
|
|
})?;
|
|
writer.write().map_err(|e| {
|
|
log::warn!("spirv-cross error message={e}");
|
|
Error::SpvcFailed(e.to_string())
|
|
})?;
|
|
Ok(out)
|
|
}
|
|
|
|
const TEST_CRT: &str = include_str!("../shaders/test_shadertoy_crt.glsl");
|
|
const TEST_FOCUS: &str = include_str!("../shaders/test_shadertoy_focus.glsl");
|
|
const TEST_INVALID: &str = include_str!("../shaders/test_shadertoy_invalid.glsl");
|
|
|
|
/// Test shader sources, for use in GL integration tests.
|
|
#[doc(hidden)]
|
|
pub mod test_shaders {
|
|
pub const CRT: &str = super::TEST_CRT;
|
|
pub const FOCUS: &str = super::TEST_FOCUS;
|
|
pub const INVALID: &str = super::TEST_INVALID;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use core::mem::{offset_of, size_of};
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn spirv() {
|
|
let src = glsl_from_shader(TEST_CRT);
|
|
spirv_from_glsl(&src, None).expect("crt shader compiles");
|
|
}
|
|
|
|
#[test]
|
|
fn spirv_invalid() {
|
|
let src = glsl_from_shader(TEST_INVALID);
|
|
let mut errlog = ShaderLog::default();
|
|
let err = spirv_from_glsl(&src, Some(&mut errlog))
|
|
.err()
|
|
.expect("invalid shader fails");
|
|
assert!(matches!(err, Error::GlslangFailed));
|
|
assert_ne!(errlog.info, "");
|
|
}
|
|
|
|
#[test]
|
|
fn shadertoy_to_glsl() {
|
|
let src = glsl_from_shader(TEST_CRT);
|
|
let compiled = spirv_from_glsl(&src, None).unwrap();
|
|
let glsl = glsl_from_compiled(&compiled).unwrap();
|
|
assert!(glsl.starts_with("#version 430 core"));
|
|
assert!(glsl.contains("layout(std140, binding = 1) uniform"));
|
|
assert!(glsl.contains("layout(binding = 0) uniform sampler2D"));
|
|
}
|
|
|
|
#[test]
|
|
fn shadertoy_to_msl() {
|
|
// `shadertoy to msl`
|
|
let compiled = compile_shadertoy(TEST_CRT, None).unwrap();
|
|
assert_eq!(compiled.module.entry_points.len(), 1);
|
|
let msl = msl_from_compiled(&compiled).unwrap();
|
|
assert!(msl.contains("#include <metal_stdlib>"));
|
|
// The Metal renderer looks the fragment function up by this name.
|
|
assert!(
|
|
msl.contains("fragment ghostty_mainOutput ghostty_main("),
|
|
"{msl}"
|
|
);
|
|
// Uniform block at buffer 1, channel at texture 0 / sampler 0.
|
|
assert!(msl.contains("[[buffer(1)]]"), "{msl}");
|
|
assert!(msl.contains("[[texture(0)]]"), "{msl}");
|
|
assert!(msl.contains("[[sampler(0)]]"), "{msl}");
|
|
}
|
|
|
|
#[test]
|
|
fn shadertoy_focus_to_msl() {
|
|
let compiled = compile_shadertoy(TEST_FOCUS, None).unwrap();
|
|
let msl = msl_from_compiled(&compiled).unwrap();
|
|
assert!(msl.contains("ghostty_main("));
|
|
}
|
|
|
|
#[test]
|
|
fn load_from_file_msl() {
|
|
let dir = std::env::temp_dir().join(format!("ghostty-shadertoy-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("crt.glsl");
|
|
std::fs::write(&path, TEST_CRT).unwrap();
|
|
let msl = load_from_file(&path, Target::Msl).unwrap();
|
|
assert!(msl.contains("ghostty_main("));
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn shadertoy_focus_to_glsl() {
|
|
let compiled = compile_shadertoy(TEST_FOCUS, None).unwrap();
|
|
let glsl = glsl_from_compiled(&compiled).unwrap();
|
|
assert!(glsl.contains("iTimeFocus") || glsl.contains("_group_0_binding_1_fs"));
|
|
}
|
|
|
|
#[test]
|
|
fn glsl_from_shader_is_prefix_plus_source() {
|
|
let out = glsl_from_shader("void mainImage(out vec4 c, in vec2 p) { c = vec4(0.0); }");
|
|
assert!(out.starts_with(PREFIX));
|
|
assert!(out.contains(&format!("{PREFIX}\n\nvoid mainImage")));
|
|
}
|
|
|
|
#[test]
|
|
fn user_helpers_after_main_image_compile() {
|
|
let src = "void mainImage(out vec4 c, in vec2 p) { c = vec4(helper(p), 0.0, 0.0, 1.0); }\n\
|
|
float helper(vec2 p) { return p.x; }\n";
|
|
// helper is defined after use: invalid GLSL, must be rejected, not panic.
|
|
assert!(compile_shadertoy(src, None).is_err());
|
|
let src = "float helper(vec2 p) { return p.x / iResolution.x; }\n\
|
|
void mainImage(out vec4 c, in vec2 p) { c = vec4(helper(p), texture2D(iChannel0, p / iResolution.xy).g, iTime, 1.0); }\n";
|
|
compile_shadertoy(src, None).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn sampler2d_parameter_helper_compiles() {
|
|
let src = "vec4 fetch(sampler2D tex, vec2 uv) { return texture(tex, uv); }\n\
|
|
void mainImage(out vec4 c, in vec2 p) { c = fetch(iChannel0, p / iResolution.xy); }\n";
|
|
let mut log = ShaderLog::default();
|
|
let r = compile_shadertoy(src, Some(&mut log));
|
|
assert!(r.is_ok(), "{}", log.info);
|
|
}
|
|
|
|
fn compiles(src: &str) {
|
|
let mut log = ShaderLog::default();
|
|
let c = compile_shadertoy(src, Some(&mut log));
|
|
assert!(c.is_ok(), "{}\n{}", log.info, log.debug);
|
|
glsl_from_compiled(&c.unwrap()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn sampler2d_parameter_forwarding_and_prototype() {
|
|
compiles(
|
|
"vec4 inner(in sampler2D s, vec2 uv);\n\
|
|
vec4 outer(sampler2D t, vec2 uv) { return inner(t, uv) + inner(t, uv * 2.0); }\n\
|
|
vec4 inner(in sampler2D s, vec2 uv) { return texture(s, uv); }\n\
|
|
void mainImage(out vec4 c, in vec2 p) { c = outer(iChannel0, p / iResolution.xy); }\n",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sampler2d_parameter_other_texture_functions() {
|
|
compiles(
|
|
"vec4 f(sampler2D s, vec2 uv) {\n\
|
|
vec2 sz = vec2(textureSize(s, 0));\n\
|
|
vec4 a = textureLod(s, uv, 0.0);\n\
|
|
vec4 b = texelFetch(s, ivec2(uv * sz), 0);\n\
|
|
return a + b + texture2D(s, uv);\n\
|
|
}\n\
|
|
void mainImage(out vec4 c, in vec2 p) { c = f(iChannel0, p / iResolution.xy); }\n",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn combined_sampler_builtin_uses_compile() {
|
|
compiles(
|
|
"void mainImage(out vec4 c, in vec2 p) {\n\
|
|
vec2 uv = p / iResolution.xy;\n\
|
|
c = textureLod(iChannel0, uv, 0.0) + texelFetch(iChannel0, ivec2(p), 0)\n\
|
|
+ vec4(textureSize(iChannel0, 0), 0.0, 0.0) + texture2D(iChannel0, uv);\n\
|
|
}\n",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn load_from_files_skips_missing_optional() {
|
|
let dir =
|
|
std::env::temp_dir().join(format!("ghostty-shadertoy-files-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let good = dir.join("crt.glsl");
|
|
std::fs::write(&good, TEST_CRT).unwrap();
|
|
let missing = dir.join("missing.glsl");
|
|
|
|
let items = [
|
|
PathItem::Optional(missing.clone()),
|
|
PathItem::Required(good.clone()),
|
|
];
|
|
let shaders = load_from_files(&items, Target::Glsl).unwrap();
|
|
assert_eq!(shaders.len(), 1);
|
|
|
|
let items = [PathItem::Required(missing.clone())];
|
|
assert!(matches!(
|
|
load_from_files(&items, Target::Glsl),
|
|
Err(Error::Io(_))
|
|
));
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn uniforms_std140_layout() {
|
|
// std140 offsets of the `Globals` block in shadertoy_prefix.glsl.
|
|
assert_eq!(offset_of!(Uniforms, resolution), 0);
|
|
assert_eq!(offset_of!(Uniforms, time), 12);
|
|
assert_eq!(offset_of!(Uniforms, time_delta), 16);
|
|
assert_eq!(offset_of!(Uniforms, frame_rate), 20);
|
|
assert_eq!(offset_of!(Uniforms, frame), 24);
|
|
assert_eq!(offset_of!(Uniforms, channel_time), 32);
|
|
assert_eq!(offset_of!(Uniforms, channel_resolution), 96);
|
|
assert_eq!(offset_of!(Uniforms, mouse), 160);
|
|
assert_eq!(offset_of!(Uniforms, date), 176);
|
|
assert_eq!(offset_of!(Uniforms, sample_rate), 192);
|
|
assert_eq!(offset_of!(Uniforms, current_cursor), 208);
|
|
assert_eq!(offset_of!(Uniforms, previous_cursor), 224);
|
|
assert_eq!(offset_of!(Uniforms, current_cursor_color), 240);
|
|
assert_eq!(offset_of!(Uniforms, previous_cursor_color), 256);
|
|
assert_eq!(offset_of!(Uniforms, current_cursor_style), 272);
|
|
assert_eq!(offset_of!(Uniforms, previous_cursor_style), 276);
|
|
assert_eq!(offset_of!(Uniforms, cursor_visible), 280);
|
|
assert_eq!(offset_of!(Uniforms, cursor_change_time), 284);
|
|
assert_eq!(offset_of!(Uniforms, time_focus), 288);
|
|
assert_eq!(offset_of!(Uniforms, focus), 292);
|
|
assert_eq!(offset_of!(Uniforms, palette), 304);
|
|
assert_eq!(offset_of!(Uniforms, background_color), 4400);
|
|
assert_eq!(offset_of!(Uniforms, foreground_color), 4416);
|
|
assert_eq!(offset_of!(Uniforms, cursor_color), 4432);
|
|
assert_eq!(offset_of!(Uniforms, cursor_text), 4448);
|
|
assert_eq!(offset_of!(Uniforms, selection_background_color), 4464);
|
|
assert_eq!(offset_of!(Uniforms, selection_foreground_color), 4480);
|
|
assert_eq!(size_of::<Uniforms>(), 4496);
|
|
}
|
|
|
|
/// (size, align) of an MSL type as emitted by naga for the shadertoy
|
|
/// uniform block, resolving the wrapper structs defined in `msl`.
|
|
fn msl_size_align(msl: &str, ty: &str) -> (usize, usize) {
|
|
match ty {
|
|
"float" | "int" | "uint" => (4, 4),
|
|
"metal::packed_float3" => (12, 4),
|
|
"metal::float2" => (8, 8),
|
|
"metal::float3" | "metal::float4" => (16, 16),
|
|
_ => {
|
|
if let Some(rest) = ty.strip_prefix("metal::vec<") {
|
|
let (scalar, n) = rest.trim_end_matches('>').split_once(", ").unwrap();
|
|
let n: usize = n.parse().unwrap();
|
|
assert!(matches!(scalar, "float" | "int" | "uint"));
|
|
let n = if n == 3 { 4 } else { n };
|
|
return (4 * n, 4 * n);
|
|
}
|
|
// A wrapper struct `struct <ty> { <elem> inner[N]; };`.
|
|
let head = format!("struct {ty} {{\n ");
|
|
let at = msl.find(&head).unwrap_or_else(|| panic!("no struct {ty}"));
|
|
let body = &msl[at + head.len()..];
|
|
let line = &body[..body.find(';').unwrap()];
|
|
let (elem, count) = line.rsplit_once(" inner[").unwrap();
|
|
let count: usize = count.trim_end_matches(']').parse().unwrap();
|
|
let (es, ea) = msl_size_align(msl, elem);
|
|
(es * count, ea)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The `(offset, name)` of each member of `struct Globals` in `msl`,
|
|
/// skipping `char _padN[k]` fillers, plus the struct size.
|
|
fn msl_globals_layout(msl: &str) -> (Vec<(usize, String)>, usize) {
|
|
let at = msl.find("struct Globals {\n").unwrap();
|
|
let body = &msl[at + "struct Globals {\n".len()..];
|
|
let body = &body[..body.find("\n};").unwrap()];
|
|
let mut off = 0usize;
|
|
let mut max_align = 1;
|
|
let mut members = Vec::new();
|
|
for line in body.lines() {
|
|
let line = line.trim().trim_end_matches(';');
|
|
let (ty, name) = line.rsplit_once(' ').unwrap();
|
|
if ty == "char" {
|
|
let n: usize = name
|
|
.split('[')
|
|
.nth(1)
|
|
.unwrap()
|
|
.trim_end_matches(']')
|
|
.parse()
|
|
.unwrap();
|
|
off += n;
|
|
continue;
|
|
}
|
|
let (size, align) = msl_size_align(msl, ty);
|
|
off = off.next_multiple_of(align);
|
|
max_align = max_align.max(align);
|
|
members.push((off, name.to_owned()));
|
|
off += size;
|
|
}
|
|
(members, off.next_multiple_of(max_align))
|
|
}
|
|
|
|
#[test]
|
|
fn msl_uniform_block_matches_std140_upload_layout() {
|
|
// A shader that reads the scalar array so the accessor path is used.
|
|
check_msl_layout(
|
|
"void mainImage(out vec4 c, in vec2 p) { \
|
|
int i = int(p.x) & 3; c = vec4(iChannelTime[i], iChannelTime[2], iTime, iSampleRate); }",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn whole_array_copy_of_padded_uniform_validates_and_keeps_layout() {
|
|
// Whole-array loads/copies of the std140-padded `iChannelTime` must
|
|
// validate (naga types the padded array differently from a plain
|
|
// local `float[4]`) and not disturb the MSL block layout.
|
|
let user = "float sum4(float a[4]) { return a[0] + a[1] + a[2] + a[3]; }\n\
|
|
void mainImage(out vec4 c, in vec2 p) { \
|
|
float t[4] = iChannelTime; float u[4]; u = iChannelTime; \
|
|
c = vec4(t[1] + u[2], sum4(iChannelTime), iChannelTime[3], iTime); }";
|
|
let msl = check_msl_layout(user);
|
|
// The block member keeps the padded wrapper and is read per element.
|
|
assert!(msl.contains(".iChannelTimeUbo.inner[0].x"), "{msl}");
|
|
// GLSL output (OpenGL) validates too.
|
|
let compiled = compile_shadertoy(user, None).unwrap();
|
|
glsl_from_compiled(&compiled).unwrap();
|
|
}
|
|
|
|
fn check_msl_layout(user: &str) -> String {
|
|
use std::mem::offset_of as o;
|
|
let compiled = compile_shadertoy(user, None).unwrap();
|
|
let msl = msl_from_compiled(&compiled).unwrap();
|
|
let (members, size) = msl_globals_layout(&msl);
|
|
|
|
let expect = [
|
|
("iResolution", o!(Uniforms, resolution)),
|
|
("iTime", o!(Uniforms, time)),
|
|
("iTimeDelta", o!(Uniforms, time_delta)),
|
|
("iFrameRate", o!(Uniforms, frame_rate)),
|
|
("iFrame", o!(Uniforms, frame)),
|
|
("iChannelTimeUbo", o!(Uniforms, channel_time)),
|
|
("iChannelResolution", o!(Uniforms, channel_resolution)),
|
|
("iMouse", o!(Uniforms, mouse)),
|
|
("iDate", o!(Uniforms, date)),
|
|
("iSampleRate", o!(Uniforms, sample_rate)),
|
|
("iCurrentCursor", o!(Uniforms, current_cursor)),
|
|
("iPreviousCursor", o!(Uniforms, previous_cursor)),
|
|
("iCurrentCursorColor", o!(Uniforms, current_cursor_color)),
|
|
("iPreviousCursorColor", o!(Uniforms, previous_cursor_color)),
|
|
("iCurrentCursorStyle", o!(Uniforms, current_cursor_style)),
|
|
("iPreviousCursorStyle", o!(Uniforms, previous_cursor_style)),
|
|
("iCursorVisible", o!(Uniforms, cursor_visible)),
|
|
("iTimeCursorChange", o!(Uniforms, cursor_change_time)),
|
|
("iTimeFocus", o!(Uniforms, time_focus)),
|
|
("iFocus", o!(Uniforms, focus)),
|
|
("iPalette", o!(Uniforms, palette)),
|
|
("iBackgroundColor", o!(Uniforms, background_color)),
|
|
("iForegroundColor", o!(Uniforms, foreground_color)),
|
|
("iCursorColor", o!(Uniforms, cursor_color)),
|
|
("iCursorText", o!(Uniforms, cursor_text)),
|
|
// Upstream quirk: the Zig struct (and so ours) has the selection
|
|
// colors swapped relative to the GLSL block; both are vec3 so the
|
|
// offsets agree.
|
|
(
|
|
"iSelectionForegroundColor",
|
|
o!(Uniforms, selection_background_color),
|
|
),
|
|
(
|
|
"iSelectionBackgroundColor",
|
|
o!(Uniforms, selection_foreground_color),
|
|
),
|
|
];
|
|
assert_eq!(members.len(), expect.len(), "{msl}");
|
|
for ((off, name), (ename, eoff)) in members.iter().zip(expect) {
|
|
assert_eq!(name, ename);
|
|
assert_eq!(*off, eoff, "MSL offset of {name}\n{msl}");
|
|
}
|
|
assert_eq!(size, std::mem::size_of::<Uniforms>());
|
|
// Reads of the padded array take the first lane.
|
|
assert!(msl.contains(".iChannelTimeUbo.inner["), "{msl}");
|
|
assert!(msl.contains("].x"), "{msl}");
|
|
msl
|
|
}
|
|
}
|