Ports the terminal-independent half of ZIG/src/renderer (OpenGL only): - opengl/*: buffers, frames, pipelines, render passes, samplers, targets, textures and the OpenGL GraphicsAPI backend on top of glow; the GTK GLArea glue is replaced by a get_proc_address loader closure - the GLSL shaders, copied verbatim and embedded; uniform/vertex structs as bytemuck Pod types with std140 offsets asserted in tests - shadertoy custom shaders converted with naga (GLSL in -> validate -> GLSL 430 out) instead of glslang + spirv-cross - size (screen/grid/cell/padding math), cursor style logic, backend health, options and message types The crate opts into unsafe_code = "deny"; unsafe glow calls live only in opengl/gl.rs. A headless EGL integration test builds every pipeline and the converted shadertoy shaders on Mesa and reads back rendered pixels. Note: Zig's shadertoy Uniforms order the selection background/foreground colors opposite to the GLSL block (likely an upstream bug); kept as in Zig for parity and recorded in the notes. Deferred to the next renderer pass: generic, cell, image, link, row, State, Thread, Overlay (need terminal render state). Test Plan: - cargo test -p ghostty-renderer (33 tests incl. tests/egl_headless.rs)
466 lines
17 KiB
Rust
466 lines
17 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;
|
|
use std::path::Path;
|
|
|
|
use bytemuck::{Pod, Zeroable};
|
|
use naga::back::glsl as glsl_out;
|
|
use naga::front::glsl as glsl_in;
|
|
use naga::valid::{Capabilities, ModuleInfo, ValidationFlags, Validator};
|
|
|
|
/// 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 is out of scope for the Rust port; converting to it fails with
|
|
/// [`Error::Unsupported`].
|
|
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(String),
|
|
/// A path that must exist.
|
|
Required(String),
|
|
}
|
|
|
|
#[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("GLSL 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::new(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}");
|
|
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 => Err(Error::Unsupported),
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
let mut s = full
|
|
.replacen("#version 430 core", "#version 460 core", 1)
|
|
.replace(" uniform ", " ")
|
|
.replacen("layout(location = 0) in vec4 gl_FragCoord;", "", 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,
|
|
);
|
|
s.push_str("\nvoid main() { mainImage (_fragColor, gl_FragCoord.xy); }\n");
|
|
s
|
|
}
|
|
|
|
/// 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 super::*;
|
|
use core::mem::{offset_of, size_of};
|
|
|
|
#[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_unsupported() {
|
|
let compiled = compile_shadertoy(TEST_CRT, None).unwrap();
|
|
assert_eq!(compiled.module.entry_points.len(), 1);
|
|
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();
|
|
assert!(matches!(
|
|
load_from_file(&path, Target::Msl),
|
|
Err(Error::Unsupported)
|
|
));
|
|
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 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.to_string_lossy().into_owned()),
|
|
PathItem::Required(good.to_string_lossy().into_owned()),
|
|
];
|
|
let shaders = load_from_files(&items, Target::Glsl).unwrap();
|
|
assert_eq!(shaders.len(), 1);
|
|
|
|
let items = [PathItem::Required(missing.to_string_lossy().into_owned())];
|
|
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);
|
|
}
|
|
}
|