renderer/vulkan: custom-shader post pipeline (SPV-direct, Y-aware)

Custom shaders (`custom-shader = ...` in config) now work on the
Vulkan backend with the same visual result as OpenGL. Five pieces
land together:

1. `shadertoy.Target` gains `.spv`. `loadFromFile` for `.spv`
   returns the raw SPIR-V binary (4-byte-aligned) emitted by
   glslang, skipping the spirv-cross GLSL roundtrip that the
   `.glsl` target uses. Vulkan consumes SPIR-V natively — feeding
   the user shader through GLSL→SPIR-V→GLSL→SPIR-V was double
   compile work AND lost the upstream source structure for any
   text-level rewrites. Return type is unified as
   `[]const []const u8`; OpenGL's `initShaders` keeps its existing
   `[:0]const u8` signature by reinterpreting (the underlying bytes
   are still null-terminated for the GLSL path).

2. `Vulkan.custom_shader_target = .spv`. The post pipeline now
   loads SPIR-V directly into a `Module.initFromSpirv`, skipping
   the second glslang compile inside `Module.init` that the
   built-in shaders go through.

3. `vulkanizeGlsl` becomes `pub` so `shadertoy.zig` can call it on
   the GLSL before `spirvFromGlsl`. Without that, the SPIR-V comes
   out with every binding at `set 0` (glslang's default for
   unannotated bindings), but our post pipeline's descriptor set
   layout splits UBO into set 0 and the sampler into set 1 — the
   shader's `iChannel0` would read from the wrong slot and the
   window goes transparent. Running `vulkanizeGlsl` on the
   shadertoy GLSL first rewrites `layout(binding = N)` →
   `layout(set = S, binding = N)` with the same set/binding scheme
   the renderer pipelines wire up.

4. Y-flip: shadertoy expects `gl_FragCoord` lower-left, Vulkan's
   is upper-left. `shadertoy.zig` injects `#define GHASTTY_VULKAN 1`
   before the prefix's `main()` (placed after `#version` since GLSL
   requires it first), and the prefix's main branches:

     #ifdef GHASTTY_VULKAN
       mainImage(_fragColor, vec2(gl_FragCoord.x,
                                  iResolution.y - gl_FragCoord.y));
     #else
       mainImage(_fragColor, gl_FragCoord.xy);
     #endif

5. `RenderPass.begin`'s viewport Y-flip is now conditional on the
   attachment kind: enabled for `.target` (the dmabuf Qt mmaps and
   paints with origin-upper-left), disabled for `.texture` (the
   custom-shader back_texture). The flipped fragCoord from (4) and
   the un-flipped back_texture orientation cancel — the shadertoy
   `uv = fragCoord/iResolution` sampling reads the right row, the
   terminal content inside the custom shader paints upright. The
   final post pass writes to `frame.target` so it keeps the
   Y-flipped viewport.

`vulkanizeGlsl`'s `texture()` → `textureLod(..., 0.0)` rewrite is
now restricted to known unnormalized samplers (the two atlas
samplers `atlas_grayscale` / `atlas_color`) instead of every
`texture()` call. The implicit-LOD opcode is only forbidden for
unnormalized samplers — forcing every sampler through
`textureLod` made the driver work harder per call across the
whole custom shader.

Co-Authored-By: claude-flow <ruv@ruv.net>
pull/12846/head
Nathan 2026-05-24 16:59:59 -05:00
parent 6ef36d9934
commit 3cdda1ec9b
7 changed files with 325 additions and 41 deletions

View File

@ -301,12 +301,24 @@ pub fn drawFrameEnd(self: *OpenGL) void {
pub fn initShaders(
self: *const OpenGL,
alloc: Allocator,
custom_shaders: []const [:0]const u8,
custom_shaders: []const []const u8,
) !shaders.Shaders {
_ = alloc;
// `loadFromFiles` returns `[]const []const u8` so the SPV-target
// Vulkan path can share the loader, but for `.glsl` the underlying
// allocation IS null-terminated (`glslFromSpv` returns
// `[:0]const u8` and writes a trailing null one past `.len`).
// Cast each entry back to `[:0]const u8` so the downstream
// `Pipeline.init` calls that expect a sentinel-terminated string
// keep working without changing their signatures.
const z_shaders = try self.alloc.alloc([:0]const u8, custom_shaders.len);
defer self.alloc.free(z_shaders);
for (custom_shaders, z_shaders) |bytes, *out| {
out.* = @ptrCast(bytes);
}
return try shaders.Shaders.init(
self.alloc,
custom_shaders,
z_shaders,
);
}

View File

@ -77,18 +77,25 @@ pub const Buffer = bufferpkg.Buffer;
// ---- comptime contract --------------------------------------------------
/// Custom user shaders (`shadertoy.zig`) target GLSL same as OpenGL.
pub const custom_shader_target: shadertoy.Target = .glsl;
/// Custom user shaders compile to SPIR-V directly skip the
/// GLSL SPIR-V GLSL roundtrip that `.glsl` would do. The
/// roundtrip exists for backends that consume GLSL (OpenGL, Metal
/// via MSL), but Vulkan ingests SPIR-V natively and we already have
/// a glslang shim for the renderer's built-in shaders. Bypassing
/// the roundtrip halves the per-shader compile cost AND avoids the
/// spirv-cross-emitted main() losing the upstream `gl_FragCoord.xy`
/// pattern we hook for the Y-flip fix.
pub const custom_shader_target: shadertoy.Target = .spv;
/// Custom shaders are not yet supported on the Vulkan backend. The
/// renderer's first pass draws into `CustomShaderState.back_texture`
/// when custom shaders are configured, and a second "post" pass is
/// expected to composite back_texture frame.target through the
/// user's shader. We haven't built that second pass for Vulkan yet,
/// so enabling custom shaders here would leave `frame.target` empty
/// and the window blank. Until the post pipeline lands, the generic
/// renderer skips loading custom shaders for Vulkan and warns once.
pub const supports_custom_shaders: bool = false;
/// Custom shaders ARE now supported on the Vulkan backend.
/// `shaders.Shaders.init` builds one post pipeline per user shader
/// (UBO at set 0 binding 1, iChannel0 sampler at set 1 binding 0,
/// matching `shadertoy_prefix.glsl` after `vulkanizeGlsl` rewrites
/// the layouts). The renderer's post pass at the end of `drawFrame`
/// chains them first pipeline samples `back_texture` and writes
/// `front_texture`, swap, repeat; the last one writes
/// `frame.target` instead.
pub const supports_custom_shaders: bool = true;
/// Vulkan's clip-space Y axis points down (unlike OpenGL).
pub const custom_shader_y_is_down = true;
@ -257,7 +264,10 @@ pub fn drawFrameEnd(self: *Vulkan) void {
pub fn initShaders(
self: *const Vulkan,
alloc: Allocator,
custom_shaders: []const [:0]const u8,
/// For Vulkan these are SPIR-V binaries (loaded with
/// `shadertoy.Target = .spv`), not GLSL strings see
/// `custom_shader_target` above.
custom_shaders: []const []const u8,
) !shaders.Shaders {
_ = self;
return try shaders.Shaders.init(alloc, devicePtr(), custom_shaders);

View File

@ -852,7 +852,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// ignored.
const can_use_custom = !@hasDecl(GraphicsAPI, "supports_custom_shaders") or
GraphicsAPI.supports_custom_shaders;
const custom_shaders: []const [:0]const u8 = if (can_use_custom)
const custom_shaders: []const []const u8 = if (can_use_custom)
(shadertoy.loadFromFiles(
arena_alloc,
self.config.custom_shaders,

View File

@ -49,4 +49,14 @@ layout(location = 0) out vec4 _fragColor;
#define texture2D texture
void mainImage( out vec4 fragColor, in vec2 fragCoord );
void main() { mainImage (_fragColor, gl_FragCoord.xy); }
void main() {
// Vulkan's `gl_FragCoord` origin is upper-left, OpenGL's is
// lower-left; ShaderToy convention is lower-left, so on Vulkan
// we mirror y. The backend (`renderer/shadertoy.zig`) injects
// `#define GHASTTY_VULKAN 1` only for `.spv` targets.
#ifdef GHASTTY_VULKAN
mainImage(_fragColor, vec2(gl_FragCoord.x, iResolution.y - gl_FragCoord.y));
#else
mainImage(_fragColor, gl_FragCoord.xy);
#endif
}

View File

@ -40,16 +40,34 @@ pub const Uniforms = extern struct {
};
/// The target to load shaders for.
pub const Target = enum { glsl, msl };
///
/// - `.glsl`: roundtripped through SPIR-V back to GLSL via
/// spirv-cross. Normalizes/validates the source. The OpenGL
/// backend consumes this.
/// - `.msl`: spirv-cross translation to Metal Shading Language.
/// - `.spv`: raw SPIR-V binary (no spirv-cross roundtrip). The
/// Vulkan backend consumes this Vulkan compiles GLSL SPIR-V
/// itself via glslang for its built-in shaders, and feeding
/// the user shader through GLSLSPIR-VGLSLSPIR-V again costs
/// 2× the compile work AND loses the original source structure
/// (which broke our `gl_FragCoord` Y-flip rewrite when the
/// spirv-cross-emitted main() didn't match the upstream prefix).
pub const Target = enum { glsl, msl, spv };
/// Load a set of shaders from files and convert them to the target
/// format. The shader order is preserved.
///
/// Result element type depends on `target`: `.glsl`/`.msl` produce
/// null-terminated UTF-8 source strings; `.spv` produces SPIR-V
/// binary bytes (4-byte-aligned, no trailing null). We unify the
/// return type as `[]const []const u8` and have the caller cast/
/// reinterpret as needed.
pub fn loadFromFiles(
alloc_gpa: Allocator,
paths: configpkg.RepeatablePath,
target: Target,
) ![]const [:0]const u8 {
var list: std.ArrayList([:0]const u8) = .empty;
) ![]const []const u8 {
var list: std.ArrayList([]const u8) = .empty;
defer list.deinit(alloc_gpa);
errdefer for (list.items) |shader| alloc_gpa.free(shader);
@ -75,11 +93,16 @@ pub fn loadFromFiles(
/// Load a single shader from a file and convert it to the target language
/// ready to be used with renderers.
///
/// For `.glsl` / `.msl` the returned slice is a null-terminated UTF-8
/// source string; the underlying allocation is `[:0]const u8` and
/// callers that need the sentinel may safely cast. For `.spv` the
/// returned slice is raw SPIR-V bytes no terminator, 4-byte aligned.
pub fn loadFromFile(
alloc_gpa: Allocator,
path: []const u8,
target: Target,
) ![:0]const u8 {
) ![]const u8 {
var arena = ArenaAllocator.init(alloc_gpa);
defer arena.deinit();
const alloc = arena.allocator();
@ -97,14 +120,36 @@ pub fn loadFromFile(
);
};
// Convert to full GLSL
const glsl: [:0]const u8 = glsl: {
// Convert to full GLSL. For `.spv` we inject a
// `#define GHASTTY_VULKAN 1` so the prefix's `main()` can flip
// `gl_FragCoord.y` (Vulkan's origin is upper-left vs OpenGL's
// lower-left, which would otherwise paint custom shaders upside
// down).
const glsl_raw: [:0]const u8 = glsl: {
var stream: std.Io.Writer.Allocating = .init(alloc);
try glslFromShader(&stream.writer, src);
const defines: []const []const u8 = if (target == .spv)
&.{"GHASTTY_VULKAN 1"}
else
&.{};
try glslFromShader(&stream.writer, src, defines);
try stream.writer.writeByte(0);
break :glsl stream.written()[0 .. stream.written().len - 1 :0];
};
// For `.spv` we also run `vulkanizeGlsl` on the source so the
// resulting SPIR-V uses the renderer's multi-set descriptor
// layout (UBO=set 0, samplers=set 1, storage=set 2). Without
// this, glslang assigns everything to `set 0` and our post
// pipeline's descriptor set layout (one set per resource type)
// would point at the wrong slots the shader's `iChannel0` ends
// up at set 0 binding 0 while our pipeline binds it at set 1
// binding 0, sampling returns garbage / zero, output is
// transparent.
const glsl: [:0]const u8 = if (target == .spv) blk: {
const vshaders = @import("vulkan/shaders.zig");
break :blk try vshaders.vulkanizeGlsl(alloc, glsl_raw);
} else glsl_raw;
// Convert to SPIR-V
const spirv: []const u8 = spirv: {
var stream: std.Io.Writer.Allocating = .init(alloc);
@ -129,12 +174,22 @@ pub fn loadFromFile(
break :spirv list.items;
};
// Convert to MSL
// Important: using the alloc_gpa here on purpose because this is
// the final result that will be returned to the caller (the arena
// gets torn down on function exit).
return switch (target) {
// Important: using the alloc_gpa here on purpose because this
// is the final result that will be returned to the caller.
.glsl => try glslFromSpv(alloc_gpa, spirv),
.msl => try mslFromSpv(alloc_gpa, spirv),
.spv => spv: {
// Copy the SPIR-V binary out of the arena into a
// 4-byte-aligned allocation under `alloc_gpa`. Vulkan
// expects `pCode: []const u32`, so over-aligning is safe;
// we return as `[]const u8` to share the unified return
// type with the GLSL/MSL paths.
const dst = try alloc_gpa.alignedAlloc(u8, .of(u32), spirv.len);
@memcpy(dst, spirv);
break :spv dst;
},
};
}
@ -144,9 +199,33 @@ pub fn loadFromFile(
/// 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 glslFromShader(writer: *std.Io.Writer, src: []const u8) !void {
pub fn glslFromShader(
writer: *std.Io.Writer,
src: []const u8,
/// Macros to inject as `#define <body>` lines after the prefix's
/// `#version` directive (GLSL requires `#version` first, so we
/// can't simply prepend). Empty for the default OpenGL/MSL paths;
/// the Vulkan SPV path uses this to flag the prefix's `main()`
/// to Y-flip `gl_FragCoord`.
defines: []const []const u8,
) !void {
const prefix = @embedFile("shaders/shadertoy_prefix.glsl");
try writer.writeAll(prefix);
if (defines.len == 0) {
try writer.writeAll(prefix);
} else {
// Find the first newline after `#version ...` and inject the
// defines on the following line. We assume the prefix begins
// with a `#version` directive on its own line (true today;
// the comptime split below would crash loudly otherwise).
const first_nl = std.mem.indexOfScalar(u8, prefix, '\n').?;
try writer.writeAll(prefix[0 .. first_nl + 1]);
for (defines) |def| {
try writer.writeAll("#define ");
try writer.writeAll(def);
try writer.writeAll("\n");
}
try writer.writeAll(prefix[first_nl + 1 ..]);
}
try writer.writeAll("\n\n");
try writer.writeAll(src);
}
@ -348,7 +427,7 @@ fn spvCross(
fn testGlslZ(alloc: Allocator, src: []const u8) ![:0]const u8 {
var buf: std.Io.Writer.Allocating = .init(alloc);
defer buf.deinit();
try glslFromShader(&buf.writer, src);
try glslFromShader(&buf.writer, src, &.{});
return try buf.toOwnedSliceSentinel(0);
}

View File

@ -121,6 +121,21 @@ pub fn begin(opts: Options) Self {
.texture => |t| .{ t.view, t.image, @intCast(t.width), @intCast(t.height) },
.target => |t| .{ t.view, t.image, t.width, t.height },
};
// Y-flip only when writing to a final `Target` (the dmabuf that
// Qt mmaps and paints with origin-upper-left). Intermediate
// `Texture` targets (the custom-shader back_texture) stay in
// OpenGL-style Y-up orientation so the shadertoy `mainImage`'s
// `uv = fragCoord/iResolution` sampling lands on the right row
// the shader's flipped `fragCoord` (set by the
// `GHASTTY_VULKAN` define in the shadertoy prefix) cancels with
// the un-flipped texture orientation. Without this distinction
// the terminal CONTENT inside the custom shader shows
// upside-down because the back_texture was already y-flipped at
// render time AND the shader then samples with a flipped uv.
const y_flip_viewport: bool = switch (attach.target) {
.target => true,
.texture => false,
};
// Transition to COLOR_ATTACHMENT_OPTIMAL. Sources from
// UNDEFINED (fresh target) or whatever we always discard
@ -202,13 +217,23 @@ pub fn begin(opts: Options) Self {
// top of the window appears at the bottom. `gl_FragCoord` still
// reports origin-upper-left, matching `cell_bg.f.glsl`'s
// `layout(origin_upper_left)` request.
const viewport: vk.VkViewport = .{
//
// See `y_flip_viewport` above for why intermediate textures
// (custom-shader back_texture) opt out of the flip.
const viewport: vk.VkViewport = if (y_flip_viewport) .{
.x = 0,
.y = @floatFromInt(height),
.width = @floatFromInt(width),
.height = -@as(f32, @floatFromInt(height)),
.minDepth = 0,
.maxDepth = 1,
} else .{
.x = 0,
.y = 0,
.width = @floatFromInt(width),
.height = @floatFromInt(height),
.minDepth = 0,
.maxDepth = 1,
};
opts.device.dispatch.cmdSetViewport(opts.cb, 0, 1, &viewport);
const scissor: vk.VkRect2D = .{

View File

@ -153,7 +153,12 @@ const ResourceSet = enum(u8) {
/// authoring.
///
/// Caller frees the returned buffer with the same allocator.
fn vulkanizeGlsl(
///
/// Also called from `shadertoy.zig` when building SPIR-V for the
/// Vulkan backend's custom-shader path, so the binding layouts in the
/// user's shader come out in the same set/binding scheme the
/// renderer's pipelines wire up.
pub fn vulkanizeGlsl(
alloc: std.mem.Allocator,
src: []const u8,
) std.mem.Allocator.Error![:0]const u8 {
@ -193,14 +198,20 @@ fn vulkanizeGlsl(
} else if (std.mem.eql(u8, ident, "sampler2DRect")) {
try out.appendSlice(alloc, "sampler2D");
} else if (std.mem.eql(u8, ident, "texture") and
nextNonSpaceIsOpenParen(src, i))
nextSamplerIsUnnormalized(src, i))
{
// Replace `texture(args)` with `textureLod(args, 0.0)`.
// Replace `texture(args)` with `textureLod(args, 0.0)`
// ONLY when the sampler argument is one we created
// with `unnormalized_coordinates = true` (the atlas
// samplers in cell_text.f). Vulkan forbids implicit-LOD
// sampling for those see VUID-vkCmdDraw-None-08610.
// For every other sampler (cell_bg.f's storage, the
// shadertoy `iChannel0`, etc.) leave `texture()` alone:
// it's the faster opcode the driver wants for normal
// mipmapped or LOD-derivative sampling.
try out.appendSlice(alloc, "textureLod(");
// Skip past the `(`.
while (i < src.len and src[i] != '(') : (i += 1) {}
i += 1; // consume the '('
// Copy the args verbatim until the matching `)`.
var depth: i32 = 1;
while (i < src.len and depth > 0) {
const cc = src[i];
@ -212,7 +223,6 @@ fn vulkanizeGlsl(
try out.append(alloc, cc);
i += 1;
}
// Insert the explicit LOD argument and the closing `)`.
try out.appendSlice(alloc, ", 0.0)");
if (i < src.len) i += 1; // consume the closing `)`
} else {
@ -313,6 +323,39 @@ fn nextNonSpaceIsOpenParen(src: []const u8, i: usize) bool {
return p < src.len and src[p] == '(';
}
/// Names of samplers we create with `unnormalized_coordinates =
/// VK_TRUE`. The shaders here all use only the two atlas samplers
/// for cell_text; if more get added (or renamed) update this list.
/// The fragment shader `cell_text.f.glsl` is the only renderer
/// shader that references either name, so this list is intentionally
/// tiny broader matching would force `textureLod` on the custom
/// shader's `iChannel0`, which is normalized, and bypassing the
/// implicit-LOD opcode path makes the driver work harder per call.
const unnormalized_sampler_names = [_][]const u8{
"atlas_grayscale",
"atlas_color",
};
/// True when `texture(IDENT, ...)` at position `i` (positioned right
/// after the `texture` identifier) names an unnormalized sampler.
/// Walks past whitespace and the `(`, then reads the next identifier
/// and matches it against `unnormalized_sampler_names`.
fn nextSamplerIsUnnormalized(src: []const u8, i: usize) bool {
var p = i;
while (p < src.len and isAnySpace(src[p])) : (p += 1) {}
if (p >= src.len or src[p] != '(') return false;
p += 1;
while (p < src.len and isAnySpace(src[p])) : (p += 1) {}
if (p >= src.len or !isIdentChar(src[p])) return false;
const start = p;
while (p < src.len and isIdentChar(src[p])) : (p += 1) {}
const name = src[start..p];
for (unnormalized_sampler_names) |needle| {
if (std.mem.eql(u8, name, needle)) return true;
}
return false;
}
fn isHorizSpace(c: u8) bool {
return c == ' ' or c == '\t';
}
@ -632,7 +675,16 @@ const empty_pipeline: Pipeline = .{
/// follow-up commit once the rest of the integration is wired.
pub const Shaders = struct {
pipelines: PipelineCollection,
post_pipelines: []const Pipeline,
/// One per user-supplied custom shader. Built by `Shaders.init`
/// from the `post_shaders` arg empty when no custom shaders.
/// Owned by `Shaders` (deinit destroys each).
post_pipelines: []Pipeline,
/// Allocator used to allocate `post_pipelines`; held so deinit
/// can free the slice.
post_alloc: ?Allocator = null,
/// Compiled `VkShaderModule`s for each user shader, parallel to
/// `post_pipelines`. Owned by `Shaders` (deinit destroys each).
post_modules: []Module = &.{},
modules: Modules,
/// Process-wide descriptor pool. Sized for one set per pipeline
@ -685,10 +737,13 @@ pub const Shaders = struct {
pub fn init(
alloc: Allocator,
device: *const @import("Device.zig"),
post_shaders: []const [:0]const u8,
// SPIR-V binaries (4-byte-aligned) from
// `shadertoy.loadFromFiles` with `target = .spv`. The Vulkan
// backend bypasses the spirv-cross GLSL roundtrip the other
// backends use, so each entry here is the SPIR-V the
// built-in glslang shim would have produced.
post_shaders: []const []const u8,
) !Shaders {
_ = post_shaders;
// Compile each built-in shader. Errors are fatal the
// renderer can't run without these. The `errdefer` chain
// tears down any successfully-compiled modules if a later
@ -987,9 +1042,92 @@ pub const Shaders = struct {
pipelines.cell_bg = cell_bg_pipeline;
pipelines.cell_text = cell_text_pipeline;
// ---- post (custom shader) pipelines ----------------------
//
// One pipeline per user shader source in `post_shaders`. Each
// pipeline is the same shape:
//
// set 0 binding 1 Globals UBO (shadertoy uniforms)
// set 1 binding 0 iChannel0 combined image sampler
// (the prior pass's back_texture +
// `state.sampler` from CustomShaderState)
//
// The vertex shader is the same `full_screen_vert` triangle
// generator we use for bg_color; the user-supplied source IS
// the fragment shader (run through `vulkanizeGlsl` and the
// glslang shim same path the built-in shaders take).
// Color format matches `textureOptions()` and `initTarget`
// (BGRA SRGB) since post passes write to either a
// back_texture or `frame.target` and both use that format.
//
// Shadertoy shaders sample with normalized coordinates so the
// post pipeline's sampler is the normalized `state.sampler`,
// not the atlas sampler.
var post_pipelines: []Pipeline = &.{};
var post_modules: []Module = &.{};
if (post_shaders.len > 0) {
post_pipelines = try alloc.alloc(Pipeline, post_shaders.len);
errdefer alloc.free(post_pipelines);
post_modules = try alloc.alloc(Module, post_shaders.len);
errdefer alloc.free(post_modules);
// Init counter so partial failures can deinit only what
// was built.
var built: usize = 0;
errdefer {
for (post_pipelines[0..built]) |p| p.deinit();
for (post_modules[0..built]) |m| m.deinit();
}
// Shared descriptor set layouts across post pipelines.
// Tracked in `set_layouts` so deinit destroys once.
const post_ubo_dsl = try createSingleBindingDsl(
device,
1,
vk.VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
vk.VK_SHADER_STAGE_FRAGMENT_BIT,
);
tracker.track(post_ubo_dsl);
const post_sampler_dsl = try createSingleBindingDsl(
device,
0,
vk.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
vk.VK_SHADER_STAGE_FRAGMENT_BIT,
);
tracker.track(post_sampler_dsl);
for (post_shaders, 0..) |spv_bytes, i| {
// Reinterpret the binary as the 32-bit word slice
// Vulkan's VkShaderModuleCreateInfo wants. The
// allocation is over-aligned to `u32` in shadertoy.zig
// so this cast is safe.
if (spv_bytes.len % 4 != 0) {
log.err("custom shader SPIR-V size {} not a multiple of 4", .{spv_bytes.len});
return error.VulkanFailed;
}
const spv_words: []const u32 = std.mem.bytesAsSlice(u32, @as([]align(@alignOf(u32)) const u8, @alignCast(spv_bytes)));
post_modules[i] = try Module.initFromSpirv(device, spv_words, .fragment);
post_pipelines[i] = try Pipeline.init(.{
.device = device,
.descriptor_pool = &pool,
.vertex_module = modules.full_screen_vert.handle,
.fragment_module = post_modules[i].handle,
.vertex_input = null,
.descriptor_set_layouts = &.{ post_ubo_dsl, post_sampler_dsl },
.empty_set_layout = empty_dsl,
.color_format = vk.VK_FORMAT_B8G8R8A8_SRGB,
.blending_enabled = false,
.topology = vk.VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST,
});
built = i + 1;
}
}
return .{
.pipelines = pipelines,
.post_pipelines = &.{},
.post_pipelines = post_pipelines,
.post_alloc = if (post_shaders.len > 0) alloc else null,
.post_modules = post_modules,
.modules = modules,
.descriptor_pool = pool,
.set_layouts = set_layouts,
@ -1053,6 +1191,16 @@ pub const Shaders = struct {
if (p_ptr.pipeline != null) p_ptr.deinit();
}
// Post (custom shader) pipelines + their fragment modules.
// Same teardown order as the built-in pipelines/modules:
// pipeline first (holds VkPipelineLayout), then shader module.
for (self.post_pipelines) |p| p.deinit();
for (self.post_modules) |m| m.deinit();
if (self.post_alloc) |a| {
a.free(self.post_pipelines);
a.free(self.post_modules);
}
// Atlas sampler held by `Shaders` for the cell_text pipeline's
// texture bindings.
if (self.atlas_sampler) |samp| samp.deinit();