renderer/vulkan: scaffold build-time SPV precompile (foundation)

Adds the `vulkan_spvgen` ExeEntrypoint + the host tool itself.
The tool takes a (shader_name, stage) argv pair, looks up the
matching `source.*` decl in `renderer/vulkan/shaders.zig`,
runs `vulkanizeGlsl` + `glslang.vk.compileToSpv`, and writes the
SPIR-V bytes to stdout.

Goal: eliminate the residual ~10 MB Vulkan-vs-OpenGL leak
delta. The 9 built-in shaders currently go through
`Module.init` (glslang at runtime) on every first surface init,
populating glslang's thread-local TPoolAllocator. The pool's
high-water mark is set by these compiles and never released
(Zig pthreads + C++ thread_local = no destructor hook). Pre-
compile at build time, embed via @embedFile, call
`Module.initFromSpirv` for built-ins → glslang never gets
invoked for them → pool stays empty for users with no custom
shader, and shrinks to just-custom-shader size for users with
one.

Remaining work (next session):
- src/build/VulkanSpv.zig — host-target build helper that
  builds the gen tool, runs it 9 times, captures stdout into
  a generated `vulkan_spv.zig` module exposing @embedFile'd
  bytes per shader. Mirror HelpStrings.zig's pattern.
- src/build/SharedDeps.zig — wire the generated module into
  libghostty when -Drenderer=vulkan.
- src/renderer/vulkan/shaders.zig — replace the 9
  `Module.init(alloc, dev, source.X, .stage)` calls with
  `Module.initFromSpirv(dev, vulkan_spv.X, .stage)`.

Foundation alone doesn't change runtime behavior (entrypoint
unused until VulkanSpv.zig wires it in). Both variants still
build clean.

Co-Authored-By: claude-flow <ruv@ruv.net>
pull/12846/head
Nathan 2026-05-26 13:09:01 -05:00
parent 50139298f3
commit d73fb67080
3 changed files with 97 additions and 0 deletions

View File

@ -688,6 +688,14 @@ pub const ExeEntrypoint = enum {
webgen_config,
webgen_actions,
webgen_commands,
/// Build-time tool: compiles one of the renderer's built-in
/// GLSL shaders to SPIR-V and writes the bytes to stdout.
/// Invoked by `src/build/VulkanSpv.zig` once per (shader, stage)
/// pair so libghostty can `@embedFile` the resulting .spv
/// instead of running glslang at runtime eliminates the
/// per-process TPoolAllocator high-water-mark leak (~10 MB)
/// that the Vulkan path otherwise pays on first surface init.
vulkan_spvgen,
};
/// The release channel for the build.

View File

@ -10,6 +10,7 @@ const entrypoint = switch (build_config.exe_entrypoint) {
.webgen_config => @import("build/webgen/main_config.zig"),
.webgen_actions => @import("build/webgen/main_actions.zig"),
.webgen_commands => @import("build/webgen/main_commands.zig"),
.vulkan_spvgen => @import("vulkan_spvgen.zig"),
};
/// The main entrypoint for the program.

88
src/vulkan_spvgen.zig Normal file
View File

@ -0,0 +1,88 @@
//! Build-time tool: compiles one of `src/renderer/vulkan/shaders.zig`'s
//! `source.*` constants to SPIR-V and writes the bytes to stdout.
//!
//! Invoked by `src/build/VulkanSpv.zig` once per (shader_name, stage)
//! pair so the renderer can `@embedFile` the resulting .spv blobs
//! and call `Module.initFromSpirv` for built-ins instead of going
//! through `glslang.vk.compileToSpv` at runtime. The runtime path
//! is what populates glslang's per-thread `TPoolAllocator`, which
//! never releases its high-water-mark pages (Zig pthreads don't
//! run C++ thread_local destructors) heaptrack attributed ~10 MB
//! to that residual leak on the Vulkan variant, exactly the delta
//! over OpenGL (which never invokes glslang for its built-ins
//! because the GPU driver compiles GLSL natively).
//!
//! Usage:
//! vulkan_spvgen <shader_name> <stage>
//!
//! Where `shader_name` is one of the public decls of
//! `vulkan.shaders.source` (e.g. `bg_color_frag`, `cell_text_vert`)
//! and `stage` is `vertex` or `fragment`.
//!
//! On success: writes binary SPIR-V to stdout, exits 0.
//! On failure: writes a diagnostic to stderr, exits 1.
const std = @import("std");
const shaders = @import("renderer/vulkan/shaders.zig");
const glslang = @import("glslang");
pub fn main() !void {
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{};
defer _ = gpa.deinit();
const alloc = gpa.allocator();
const args = try std.process.argsAlloc(alloc);
defer std.process.argsFree(alloc, args);
if (args.len != 3) {
std.debug.print(
"usage: {s} <shader_name> <vertex|fragment>\n",
.{args[0]},
);
std.process.exit(1);
}
const name = args[1];
const stage = std.meta.stringToEnum(shaders.Stage, args[2]) orelse {
std.debug.print("invalid stage: {s}\n", .{args[2]});
std.process.exit(1);
};
try glslang.init();
defer glslang.finalize();
// Resolve the source by name. The runtime renderer accesses
// `shaders.source.bg_color_frag` etc. directly; we look up the
// matching decl by name at comptime so the build step can pass
// any of the 9 built-ins by string argv.
const src: [:0]const u8 = src: {
inline for (@typeInfo(shaders.source).@"struct".decls) |decl| {
if (std.mem.eql(u8, decl.name, name)) {
break :src @field(shaders.source, decl.name);
}
}
std.debug.print("unknown shader: {s}\n", .{name});
std.process.exit(1);
};
// Vulkan-flavor rewrite (gl_VertexID gl_VertexIndex, multi-set
// descriptor layout, etc.). Same path the runtime took before
// this precompile change.
const translated = try shaders.vulkanizeGlsl(alloc, src);
defer alloc.free(translated);
const spv = try glslang.vk.compileToSpv(
alloc,
translated,
stage.vkBindingStage(),
);
defer alloc.free(spv);
// Write the raw SPIR-V words (u32 little-endian on every host
// we build for; Vulkan loaders accept the in-memory byte order
// of the platform). The build step captures stdout into a .spv
// file the renderer @embedFiles at compile time.
var buf: [4096]u8 = undefined;
var stdout = std.fs.File.stdout().writerStreaming(&buf);
try stdout.interface.writeAll(std.mem.sliceAsBytes(spv));
try stdout.end();
}