shadertoy: decouple from vulkan/shaders.zig via LoadOptions hooks

Removes the cross-backend reach where `src/renderer/shadertoy.zig`
imported `vulkan/shaders.zig` directly to call `vulkanizeGlsl` and
hard-coded `target == .spv` checks for the `GHASTTY_VULKAN` define.
Backend-agnostic file no longer touches anything backend-specific.

Replaces the `target` parameter with a `LoadOptions` struct:

    pub const LoadOptions = struct {
        target: Target,
        extra_defines: []const []const u8 = &.{},
        rewrite: ?Rewriter = null,
    };

`generic.zig` builds it per-call from comptime decls on the
`GraphicsAPI` type:

    .extra_defines = if (@hasDecl(GraphicsAPI, "custom_shader_extra_defines"))
        GraphicsAPI.custom_shader_extra_defines else &.{},
    .rewrite = if (@hasDecl(GraphicsAPI, "rewriteCustomShaderSource"))
        GraphicsAPI.rewriteCustomShaderSource else null,

Vulkan.zig declares both:
    pub const custom_shader_extra_defines = &.{"GHASTTY_VULKAN 1"};
    pub const rewriteCustomShaderSource = shaders.vulkanizeGlsl;

OpenGL and Metal omit the decls entirely → zero-cost for backends
that don't need them. Same pattern existing comptime hooks already
follow (`supports_custom_shaders`, `custom_shader_target`).

Verified via Docker (zig 0.15.2 linux-arm64):
  zig build -Drenderer=vulkan -Dapp-runtime=none → clean
  zig build -Drenderer=opengl -Dapp-runtime=none → clean

Step 4 of 6 in the PR-17 review refactor.

Co-Authored-By: claude-flow <ruv@ruv.net>
pull/12846/head
ntomsic 2026-05-25 19:01:44 -05:00
parent c2f7b6c395
commit 6ca24b7b4a
3 changed files with 95 additions and 35 deletions

View File

@ -100,6 +100,23 @@ 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;
/// Extra `#define` lines `shadertoy.loadFromFile` injects into the
/// prefix between `#version` and the rest. `GHASTTY_VULKAN`
/// activates the Vulkan-side `gl_FragCoord` flip + `texture()`
/// upper-left wrap so `mainImage` sees shadertoy-convention coords
/// even though Vulkan rasterizes Y-down. OpenGL/MSL backends omit
/// this decl entirely and pass `&.{}` from `generic.zig`.
pub const custom_shader_extra_defines: []const []const u8 = &.{"GHASTTY_VULKAN 1"};
/// GLSL GLSL rewriter `shadertoy.loadFromFile` runs after the
/// prefix splice and before the SPIR-V compile. Plugs the
/// `vulkanizeGlsl` pass that rewrites `layout(binding = N)` into
/// `layout(set = S, binding = N)` so the resulting SPIR-V matches
/// the renderer's multi-set descriptor layout. Without this, the
/// shader's `iChannel0` lands at set 0 binding 0 while the post
/// pipeline binds it at set 1 binding 0 sampler returns garbage.
pub const rewriteCustomShaderSource = shaders.vulkanizeGlsl;
/// Single-buffered for v1; fence-paced submit-then-wait means there's
/// only ever one frame in flight.
pub const swap_chain_count = 1;

View File

@ -856,7 +856,21 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
(shadertoy.loadFromFiles(
arena_alloc,
self.config.custom_shaders,
GraphicsAPI.custom_shader_target,
.{
.target = GraphicsAPI.custom_shader_target,
// Optional per-backend hooks. Resolved at
// comptime via `@hasDecl`, so backends that
// don't need them stay free of extra-define /
// GLSL-rewrite logic.
.extra_defines = if (@hasDecl(GraphicsAPI, "custom_shader_extra_defines"))
GraphicsAPI.custom_shader_extra_defines
else
&.{},
.rewrite = if (@hasDecl(GraphicsAPI, "rewriteCustomShaderSource"))
GraphicsAPI.rewriteCustomShaderSource
else
null,
},
) catch |err| err: {
log.warn("error loading custom shaders err={}", .{err});
break :err &.{};

View File

@ -54,18 +54,53 @@ pub const Uniforms = extern struct {
/// spirv-cross-emitted main() didn't match the upstream prefix).
pub const Target = enum { glsl, msl, spv };
/// Optional GLSL GLSL rewriter applied between the prefix splice
/// and the SPIR-V compile. Vulkan plugs in `vulkanizeGlsl` here so
/// SPIR-V output uses the renderer's multi-set descriptor layout;
/// other backends pass `null`. Owns its allocation under the
/// caller's allocator (`shadertoy.loadFromFile` runs it inside an
/// arena that's torn down at function exit, so the rewriter's
/// returned slice may be arena-owned).
pub const Rewriter = *const fn (
alloc: Allocator,
src: []const u8,
) Allocator.Error![:0]const u8;
/// What `loadFromFile`/`loadFromFiles` need beyond the path itself.
/// Keeps the function decoupled from any specific backend every
/// backend-flavored knob becomes an explicit field, and `shadertoy`
/// itself reaches into no other backend's submodules.
pub const LoadOptions = struct {
/// Output language / format. See `Target` for the per-variant
/// rationale.
target: Target,
/// `#define <body>` lines injected after the prefix's
/// `#version` directive. Vulkan passes
/// `&.{"GHASTTY_VULKAN 1"}` so the prefix's `main()` flips
/// `gl_FragCoord.y` and wraps `texture()` for upper-left
/// sampling; OpenGL/MSL pass `&.{}`.
extra_defines: []const []const u8 = &.{},
/// Optional second-pass GLSL transform run between the prefix
/// splice and the SPIR-V compile. Vulkan installs
/// `vulkan/shaders.zig:vulkanizeGlsl` here for the multi-set
/// descriptor layout rewrite; other backends leave it null.
rewrite: ?Rewriter = null,
};
/// 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/
/// Result element type depends on `opts.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,
opts: LoadOptions,
) ![]const []const u8 {
var list: std.ArrayList([]const u8) = .empty;
defer list.deinit(alloc_gpa);
@ -77,7 +112,7 @@ pub fn loadFromFiles(
.required => |path| .{ path, false },
};
const shader = loadFromFile(alloc_gpa, path, target) catch |err| {
const shader = loadFromFile(alloc_gpa, path, opts) catch |err| {
if (err == error.FileNotFound and optional) {
continue;
}
@ -101,7 +136,7 @@ pub fn loadFromFiles(
pub fn loadFromFile(
alloc_gpa: Allocator,
path: []const u8,
target: Target,
opts: LoadOptions,
) ![]const u8 {
var arena = ArenaAllocator.init(alloc_gpa);
defer arena.deinit();
@ -120,38 +155,32 @@ pub fn loadFromFile(
);
};
// Convert to full GLSL. For `.spv` we inject
// `#define GHASTTY_VULKAN 1` so the prefix's `main()` mirrors
// `gl_FragCoord.y` AND wraps `texture()` to flip uv.y. Together
// those make `mainImage` see a shadertoy-convention fragCoord
// (lower-left origin) AND sample `iChannel0` correctly even
// though Vulkan natively uses upper-left for both. OpenGL/MSL
// builds don't get the define and use the GL-native paths
// unchanged.
// Convert to full GLSL. `opts.extra_defines` lets a backend
// inject `#define <body>` lines after the prefix's `#version`
// directive Vulkan uses this to flip `gl_FragCoord.y` and
// wrap `texture()` for upper-left sampling so `mainImage` sees
// shadertoy-convention coords; OpenGL/MSL pass `&.{}` and use
// the GL-native paths unchanged.
const glsl_raw: [:0]const u8 = glsl: {
var stream: std.Io.Writer.Allocating = .init(alloc);
const defines: []const []const u8 = if (target == .spv)
&.{"GHASTTY_VULKAN 1"}
else
&.{};
try glslFromShader(&stream.writer, src, defines);
try glslFromShader(&stream.writer, src, opts.extra_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;
// Optional second-pass GLSL transform. Vulkan installs
// `vulkanizeGlsl` here so the resulting SPIR-V uses the
// renderer's multi-set descriptor layout (UBO=set 0,
// samplers=set 1, storage=set 2). Without that rewrite,
// glslang assigns everything to `set 0` and the post pipeline's
// descriptor set layout points at the wrong slots the
// shader's `iChannel0` ends up at set 0 binding 0 while the
// pipeline binds it at set 1 binding 0, sampling returns
// garbage / zero, output is transparent.
const glsl: [:0]const u8 = if (opts.rewrite) |f|
try f(alloc, glsl_raw)
else
glsl_raw;
// Convert to SPIR-V
const spirv: []const u8 = spirv: {
@ -180,7 +209,7 @@ pub fn loadFromFile(
// 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) {
return switch (opts.target) {
.glsl => try glslFromSpv(alloc_gpa, spirv),
.msl => try mslFromSpv(alloc_gpa, spirv),
.spv => spv: {