feat(renderer): port the OpenGL backend, shaders and size math

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)
This commit is contained in:
ddidderr committed 2026-10-11 22:26:09 +02:00
1 parent 5a5fa7893b
commit 568ce784f9
35 files changed
+5477 -5

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
# ghostty-renderer — R1 (OpenGL backend, shaders, shadertoy, size, cursor)
Crate lint: `unsafe_code = "deny"`; the only `#[allow(unsafe_code)]` item is `opengl::gl`
(thin safe wrapper over `glow`, every unsafe block has a `// SAFETY:` comment; the
module doc states the shared invariants). The rest of the crate is safe code.
Deps: `glow 0.18`, `naga 30` (features glsl-in, glsl-out, **wgsl-in** — see below),
`bytemuck` (derive), `ghostty-font`, `log`, `thiserror`; dev: `khronos-egl` (dynamic).
`ghostty-config` / `ghostty-terminal` deps removed.
## Module map (Zig -> Rust, `crates/ghostty-renderer/src/`)
| Zig | Rust |
|---|---|
| `pkg/opengl/*` (needed subset) | `opengl/gl.rs` (`Gl` = `Arc<glow::Context>` handle, GL enums, `Error`) |
| `opengl/buffer.zig` | `opengl/buffer.rs` (`Buffer<T: Pod>`, `Options`) |
| `opengl/Texture.zig`, `Sampler.zig`, `Target.zig` | `texture.rs`, `sampler.rs`, `target.rs` (RAII: `Drop` deletes the GL object) |
| `opengl/Pipeline.zig` | `pipeline.rs` (`Vertex` trait with `ATTRIBUTES` replaces comptime reflection) |
| `opengl/RenderPass.zig` | `render_pass.rs` (`RenderPass`, `Step`, `Draw`, `Attachment`) |
| `opengl/Frame.zig` | `frame.rs` (`Frame::complete(sync) -> Health`) |
| `opengl/shaders.zig` | `opengl/shaders.rs` (`Shaders`, `Uniforms`, `CellText`, `Image`, `BgImage`, `process_includes`) |
| `OpenGL.zig` | `opengl/mod.rs` (`OpenGL`) |
| `shaders/*.glsl` | `crates/ghostty-renderer/shaders/` (verbatim copies, `include_str!`) |
| `shadertoy.zig` | `shadertoy.rs` |
| `size.zig`, `cursor.zig`, `backend.zig` (+ `Health` from renderer.zig), `message.zig`, `Options.zig`, `math.zig` (ortho2d) | `size.rs`, `cursor.rs`, `backend.rs`, `message.rs`, `options.rs`, `math.rs` |
## API overview
```rust
// apprt (GTK GLArea realize, context current) supplies the loader:
let mut api = OpenGL::init(AlphaBlending::Native); // or ::with_gl(blending, gl)
api.prepare_context(|name| gtk_get_proc_address(name))?; // loads glow, checks >= 4.3,
// enables DEBUG_OUTPUT + FRAMEBUFFER_SRGB
let shaders = api.init_shaders(&custom_glsl_strings)?; // bg_color, cell_bg, cell_text, image, bg_image, post_pipelines
let target = Arc::new(api.init_target(w, h)?); // sRGB renderbuffer if blending.is_linear()
let frame = api.begin_frame(target.clone());
let mut pass = frame.render_pass(&[Attachment { target: AttachmentTarget::Target(&target), clear_color: Some([0.;4]) }]);
pass.step(&Step { pipeline: &shaders.cell_bg, uniforms: Some(ubo.raw()),
buffers: &[None, Some(bg_cells.raw())], // [0] = vertex buffer, [1..] = SSBO at that index
textures: &[], samplers: &[], draw: Draw::new(Primitive::Triangle, 3) });
pass.complete();
let health = frame.complete(true); // glFinish, glGetError, present (blit to default FB)
```
* Uniform buffer is bound at binding 1, SSBOs at their slice index, textures/samplers at their slice index (same as Zig).
* GPU structs (`bytemuck::Pod`, layouts asserted in tests): `Uniforms` 144 B std140 (pack `bools`/`padding_extend`
with `Bools::to_bits` / `PaddingExtend::to_bits`), `CellText` 32 B, `Image` 48 B, `BgImage` 8 B (`BgImageInfo::to_bits`),
`shadertoy::Uniforms` 4496 B. Explicit `pad*` fields reproduce Zig `extern struct`/`align()` padding (set to 0; use `..Default::default()`).
* Atlas upload: `api.init_atlas_texture(&atlas)` creates the `GL_TEXTURE_RECTANGLE` texture (data `None`);
upload with `Texture::replace_region(0,0,size,size,&atlas.data)` and track `atlas.modified/resized`.
* Textures: `texture_options()`, `image_texture_options(fmt, srgb)`, `sampler_options()`, `*_buffer_options()` mirror OpenGL.zig.
* Threading hooks: `surface_init(loader)`, `display_realized(loader)`, `finalize_surface_init`, `thread_enter/exit`, `draw_frame_start/end`
(GTK semantics preserved: no-ops except context prepare). The GL context must be current on whichever thread calls GL.
* `present` keeps `Arc<Target>` as `last_target` (Zig copied the struct); call `clear_last_target()` when the target is replaced.
* Viewport is NOT set by the renderer (GLArea sets it; `surface_size()` reads GL_VIEWPORT) — a surfaceless EGL context has none.
## shadertoy (glslang + SPIR-V + spirv-cross replaced by naga)
`compile_shadertoy(user_src, errlog) -> Compiled` then `glsl_from_compiled(&Compiled) -> String` (GLSL 430 core), or
`load_from_file(s)` (4 MB limit, optional/required path list `PathItem`; `Target::Msl` -> `Error::Unsupported`).
`glsl_from_shader` still returns prefix + "\n\n" + src verbatim. Errors: `Error::GlslangFailed` (parse; `ShaderLog.info` has naga's
rendered diagnostics, warn-logged like Zig), `ValidationFailed`, `SpvcFailed`.
naga's GLSL frontend only takes a Vulkan-flavoured subset, so `adapt_for_naga` rewrites the **prefix** (the .glsl files are untouched):
`#version 430`->`460`; `uniform` dropped from block members; `layout(location=0) in vec4 gl_FragCoord;` removed (naga builtin);
`sampler2D iChannel0` -> `texture2D`+`sampler` pair with `#define iChannel0 sampler2D(tex, smp)` (so `texture(iChannel0, uv)` and
`texture2D` keep working); `layout(set=0,binding=1,std140)` on Globals; the `mainImage` prototype and `main()` move after the user code
(naga rejects forward declarations). Output keeps `layout(std140, binding = 1) uniform ...` and `layout(binding = 0) uniform sampler2D`,
so the Ghostty binding scheme works; verified by compiling+linking converted crt/focus shaders on Mesa. Limitations: user shaders that pass
`sampler2D` as a function parameter, or define helpers *after* `mainImage` that it calls (invalid GLSL anyway), will fail.
`naga` needs the `wgsl-in` feature only because naga 30.0.1's `front::interpolator` is cfg-gated on spv-in/wgsl-in (glsl-in alone does not compile).
## Tests
Unit 30 + integration 3 (`tests/egl_headless.rs`), all passing.
* size.zig 4/4 ported (`padding_balanced_on_zero`, `grid_size_update_exact`, `grid_size_update_rounding`, `coordinate_conversion`) + 3 added.
* cursor.zig 4/4 ported but drive a locally defined `RenderStateCursor` instead of Terminal/RenderState (TODO(integration)); + password/lock test.
* shadertoy.zig: `spirv`, `spirv invalid`, `shadertoy to glsl` ported (+ focus shader, file loading, helper-order tests);
`shadertoy to msl` skipped (Metal out of scope; replaced by an Unsupported test).
* Layout tests: std140 offsets of `Uniforms`/`shadertoy::Uniforms`, `CellText`=32, `Image`=48, `BgImage`=8, bool bit values vs common.glsl, include processing.
* `tests/egl_headless.rs`: surfaceless Mesa EGL 4.3 core context (`khronos-egl` dynamic, `EGL_PLATFORM_SURFACELESS_MESA`);
builds all 5 pipelines + 2 converted shadertoy post pipelines, renders bg_color into a `Target` and reads back the exact color,
runs cell_bg/cell_text/image/bg_image/post passes with no GL error, buffer growth, shader compile-error reporting.
If no EGL display/context is available the tests print `SKIP:` and return (they do run here).
## Deviations / notes
* `Buffer::init_fill`: Zig sets `len = data.len * sizeof(T)` (bug); we store the element count.
* `Pipeline` drop also deletes its FBO and VAO (Zig only destroyed the program).
* `Frame` no longer holds the renderer; `complete` returns `Health` and the caller calls `frameCompleted`.
* `shadertoy::Uniforms` field order follows Zig (`selection_background_color` before `selection_foreground_color`) while the GLSL
block declares `iSelectionForegroundColor` before `iSelectionBackgroundColor` — upstream quirk preserved, flagged for review.
* Allocator-failure paths and comptime pipeline-collection type building are replaced by plain structs/`Result`.
## TODO(integration) / deferred
* Deferred per assignment: generic.zig, cell.zig, image.zig, link.zig, row.zig, State.zig, Thread.zig, Overlay.zig.
* `opengl::AlphaBlending` (config), `cursor::RenderStateCursor`/`TerminalCursorStyle` (terminal), `size::CellCountInt` (=u16),
`shadertoy::PathItem` (config `RepeatablePath`), `message::{ThreadDerivedConfig, RendererDerivedConfig, Flattened, SharedGridKey}` stubs,
`options::Options` lacks `surface_mailbox/rt_surface/thread`; `Message::init_change_config` takes pre-built derived configs.
* Metal/WebGL (`Backend::Metal/Webgl` variants exist only for parity with backend.zig).