renderer: create explicit sampler state for custom shaders

The GLSL to MSL conversion process uses a passed-in sampler state for
the `iChannel0` parameter and we weren't providing it. This magically
worked on Apple Silicon for unknown reasons but failed on Intel GPUs.

In normal, hand-written MSL, we'd explicitly create the sampler state as
a normal variable (we do this in `shaders.metal` already!), but the
Shadertoy conversion stuff doesn't do this, probably because the exact
sampler parameters can't be safely known.

This fixes a Metal validation error when using custom shaders:

```
-[MTLDebugRenderCommandEncoder validateCommonDrawErrors:]:5970: failed 
assertion `Draw Errors Validation Fragment Function(main0): missing Sampler 
binding at index 0 for iChannel0Smplr[0].
```
This commit is contained in:
Mitchell Hashimoto
2025-09-18 08:57:47 -07:00
parent 51292a9793
commit b34f3f7208
9 changed files with 222 additions and 0 deletions

43
pkg/opengl/Sampler.zig Normal file
View File

@@ -0,0 +1,43 @@
const Sampler = @This();
const std = @import("std");
const c = @import("c.zig").c;
const errors = @import("errors.zig");
const glad = @import("glad.zig");
const Texture = @import("Texture.zig");
id: c.GLuint,
/// Create a single sampler.
pub fn create() errors.Error!Sampler {
var id: c.GLuint = undefined;
glad.context.GenSamplers.?(1, &id);
try errors.getError();
return .{ .id = id };
}
/// glBindSampler
pub fn bind(v: Sampler, index: c_uint) !void {
glad.context.BindSampler.?(index, v.id);
try errors.getError();
}
pub fn parameter(
self: Sampler,
name: Texture.Parameter,
value: anytype,
) errors.Error!void {
switch (@TypeOf(value)) {
c.GLint => glad.context.SamplerParameteri.?(
self.id,
@intFromEnum(name),
value,
),
else => unreachable,
}
try errors.getError();
}
pub fn destroy(v: Sampler) void {
glad.context.DeleteSamplers.?(1, &v.id);
}

View File

@@ -18,6 +18,7 @@ pub const Buffer = @import("Buffer.zig");
pub const Framebuffer = @import("Framebuffer.zig"); pub const Framebuffer = @import("Framebuffer.zig");
pub const Renderbuffer = @import("Renderbuffer.zig"); pub const Renderbuffer = @import("Renderbuffer.zig");
pub const Program = @import("Program.zig"); pub const Program = @import("Program.zig");
pub const Sampler = @import("Sampler.zig");
pub const Shader = @import("Shader.zig"); pub const Shader = @import("Shader.zig");
pub const Texture = @import("Texture.zig"); pub const Texture = @import("Texture.zig");
pub const VertexArray = @import("VertexArray.zig"); pub const VertexArray = @import("VertexArray.zig");

View File

@@ -25,6 +25,7 @@ pub const RenderPass = @import("metal/RenderPass.zig");
pub const Pipeline = @import("metal/Pipeline.zig"); pub const Pipeline = @import("metal/Pipeline.zig");
const bufferpkg = @import("metal/buffer.zig"); const bufferpkg = @import("metal/buffer.zig");
pub const Buffer = bufferpkg.Buffer; pub const Buffer = bufferpkg.Buffer;
pub const Sampler = @import("metal/Sampler.zig");
pub const Texture = @import("metal/Texture.zig"); pub const Texture = @import("metal/Texture.zig");
pub const shaders = @import("metal/shaders.zig"); pub const shaders = @import("metal/shaders.zig");
@@ -285,6 +286,18 @@ pub inline fn textureOptions(self: Metal) Texture.Options {
}; };
} }
pub inline fn samplerOptions(self: Metal) Sampler.Options {
return .{
.device = self.device,
// These parameters match Shadertoy behaviors.
.min_filter = .linear,
.mag_filter = .linear,
.s_address_mode = .clamp_to_edge,
.t_address_mode = .clamp_to_edge,
};
}
/// Pixel format for image texture options. /// Pixel format for image texture options.
pub const ImageTextureFormat = enum { pub const ImageTextureFormat = enum {
/// 1 byte per pixel grayscale. /// 1 byte per pixel grayscale.

View File

@@ -20,6 +20,7 @@ pub const RenderPass = @import("opengl/RenderPass.zig");
pub const Pipeline = @import("opengl/Pipeline.zig"); pub const Pipeline = @import("opengl/Pipeline.zig");
const bufferpkg = @import("opengl/buffer.zig"); const bufferpkg = @import("opengl/buffer.zig");
pub const Buffer = bufferpkg.Buffer; pub const Buffer = bufferpkg.Buffer;
pub const Sampler = @import("opengl/Sampler.zig");
pub const Texture = @import("opengl/Texture.zig"); pub const Texture = @import("opengl/Texture.zig");
pub const shaders = @import("opengl/shaders.zig"); pub const shaders = @import("opengl/shaders.zig");
@@ -364,6 +365,17 @@ pub inline fn textureOptions(self: OpenGL) Texture.Options {
}; };
} }
/// Returns the options to use when constructing samplers.
pub inline fn samplerOptions(self: OpenGL) Sampler.Options {
_ = self;
return .{
.min_filter = .linear,
.mag_filter = .linear,
.wrap_s = .clamp_to_edge,
.wrap_t = .clamp_to_edge,
};
}
/// Pixel format for image texture options. /// Pixel format for image texture options.
pub const ImageTextureFormat = enum { pub const ImageTextureFormat = enum {
/// 1 byte per pixel grayscale. /// 1 byte per pixel grayscale.

View File

@@ -85,6 +85,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
const Target = GraphicsAPI.Target; const Target = GraphicsAPI.Target;
const Buffer = GraphicsAPI.Buffer; const Buffer = GraphicsAPI.Buffer;
const Sampler = GraphicsAPI.Sampler;
const Texture = GraphicsAPI.Texture; const Texture = GraphicsAPI.Texture;
const RenderPass = GraphicsAPI.RenderPass; const RenderPass = GraphicsAPI.RenderPass;
@@ -428,6 +429,19 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
front_texture: Texture, front_texture: Texture,
back_texture: Texture, back_texture: Texture,
/// Shadertoy uses a sampler for accessing the various channel
/// textures. In Metal, we need to explicitly create these since
/// the glslang-to-msl compiler doesn't do it for us (as we
/// normally would in hand-written MSL). To keep it clean and
/// consistent, we just force all rendering APIs to provide an
/// explicit sampler.
///
/// Samplers are immutable and describe sampling properties so
/// we can share the sampler across front/back textures (although
/// we only need it for the source texture at a time, we don't
/// need to "swap" it).
sampler: Sampler,
uniforms: UniformBuffer, uniforms: UniformBuffer,
const UniformBuffer = Buffer(shadertoy.Uniforms); const UniformBuffer = Buffer(shadertoy.Uniforms);
@@ -459,9 +473,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
); );
errdefer back_texture.deinit(); errdefer back_texture.deinit();
const sampler = try Sampler.init(api.samplerOptions());
errdefer sampler.deinit();
return .{ return .{
.front_texture = front_texture, .front_texture = front_texture,
.back_texture = back_texture, .back_texture = back_texture,
.sampler = sampler,
.uniforms = uniforms, .uniforms = uniforms,
}; };
} }
@@ -469,6 +487,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
pub fn deinit(self: *CustomShaderState) void { pub fn deinit(self: *CustomShaderState) void {
self.front_texture.deinit(); self.front_texture.deinit();
self.back_texture.deinit(); self.back_texture.deinit();
self.sampler.deinit();
self.uniforms.deinit(); self.uniforms.deinit();
} }
@@ -1509,6 +1528,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.pipeline = pipeline, .pipeline = pipeline,
.uniforms = state.uniforms.buffer, .uniforms = state.uniforms.buffer,
.textures = &.{state.back_texture}, .textures = &.{state.back_texture},
.samplers = &.{state.sampler},
.draw = .{ .draw = .{
.type = .triangle, .type = .triangle,
.vertex_count = 3, .vertex_count = 3,

View File

@@ -9,6 +9,7 @@ const objc = @import("objc");
const mtl = @import("api.zig"); const mtl = @import("api.zig");
const Pipeline = @import("Pipeline.zig"); const Pipeline = @import("Pipeline.zig");
const Sampler = @import("Sampler.zig");
const Texture = @import("Texture.zig"); const Texture = @import("Texture.zig");
const Target = @import("Target.zig"); const Target = @import("Target.zig");
const Metal = @import("../Metal.zig"); const Metal = @import("../Metal.zig");
@@ -41,6 +42,9 @@ pub const Step = struct {
/// MTLBuffer /// MTLBuffer
buffers: []const ?objc.Object = &.{}, buffers: []const ?objc.Object = &.{},
textures: []const ?Texture = &.{}, textures: []const ?Texture = &.{},
/// Set of samplers to use for this step. The index maps to an index
/// of a fragment texture, set via setFragmentSamplerState(_:index:).
samplers: []const ?Sampler = &.{},
draw: Draw, draw: Draw,
/// Describes the draw call for this step. /// Describes the draw call for this step.
@@ -200,6 +204,15 @@ pub fn step(self: *const Self, s: Step) void {
); );
}; };
// Set samplers.
for (s.samplers, 0..) |samp, i| if (samp) |sampler| {
self.encoder.msgSend(
void,
objc.sel("setFragmentSamplerState:atIndex:"),
.{ sampler.sampler.value, @as(c_ulong, i) },
);
};
// Draw! // Draw!
self.encoder.msgSend( self.encoder.msgSend(
void, void,

View File

@@ -0,0 +1,66 @@
//! Wrapper for handling samplers.
const Self = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const builtin = @import("builtin");
const objc = @import("objc");
const mtl = @import("api.zig");
const Metal = @import("../Metal.zig");
const log = std.log.scoped(.metal);
/// Options for initializing a sampler.
pub const Options = struct {
/// MTLDevice
device: objc.Object,
min_filter: mtl.MTLSamplerMinMagFilter,
mag_filter: mtl.MTLSamplerMinMagFilter,
s_address_mode: mtl.MTLSamplerAddressMode,
t_address_mode: mtl.MTLSamplerAddressMode,
};
/// The underlying MTLSamplerState Object.
sampler: objc.Object,
pub const Error = error{
/// A Metal API call failed.
MetalFailed,
};
/// Initialize a sampler
pub fn init(
opts: Options,
) Error!Self {
// Create our descriptor
const desc = init: {
const Class = objc.getClass("MTLSamplerDescriptor").?;
const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
break :init id_init;
};
defer desc.release();
// Properties
desc.setProperty("minFilter", opts.min_filter);
desc.setProperty("magFilter", opts.mag_filter);
desc.setProperty("sAddressMode", opts.s_address_mode);
desc.setProperty("tAddressMode", opts.t_address_mode);
// Create the sampler state
const id = opts.device.msgSend(
?*anyopaque,
objc.sel("newSamplerStateWithDescriptor:"),
.{desc},
) orelse return error.MetalFailed;
return .{
.sampler = objc.Object.fromId(id),
};
}
pub fn deinit(self: Self) void {
self.sampler.release();
}

View File

@@ -8,6 +8,7 @@ const builtin = @import("builtin");
const gl = @import("opengl"); const gl = @import("opengl");
const OpenGL = @import("../OpenGL.zig"); const OpenGL = @import("../OpenGL.zig");
const Sampler = @import("Sampler.zig");
const Target = @import("Target.zig"); const Target = @import("Target.zig");
const Texture = @import("Texture.zig"); const Texture = @import("Texture.zig");
const Pipeline = @import("Pipeline.zig"); const Pipeline = @import("Pipeline.zig");
@@ -35,6 +36,7 @@ pub const Step = struct {
uniforms: ?gl.Buffer = null, uniforms: ?gl.Buffer = null,
buffers: []const ?gl.Buffer = &.{}, buffers: []const ?gl.Buffer = &.{},
textures: []const ?Texture = &.{}, textures: []const ?Texture = &.{},
samplers: []const ?Sampler = &.{},
draw: Draw, draw: Draw,
/// Describes the draw call for this step. /// Describes the draw call for this step.
@@ -103,6 +105,11 @@ pub fn step(self: *Self, s: Step) void {
_ = tex.texture.bind(tex.target) catch return; _ = tex.texture.bind(tex.target) catch return;
}; };
// Bind relevant samplers.
for (s.samplers, 0..) |s_, i| if (s_) |sampler| {
_ = sampler.sampler.bind(@intCast(i)) catch return;
};
// Bind 0th buffer as the vertex buffer, // Bind 0th buffer as the vertex buffer,
// and bind the rest as storage buffers. // and bind the rest as storage buffers.
if (s.buffers.len > 0) { if (s.buffers.len > 0) {

View File

@@ -0,0 +1,47 @@
//! Wrapper for handling samplers.
const Self = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const builtin = @import("builtin");
const gl = @import("opengl");
const OpenGL = @import("../OpenGL.zig");
const log = std.log.scoped(.opengl);
/// Options for initializing a sampler.
pub const Options = struct {
min_filter: gl.Texture.MinFilter,
mag_filter: gl.Texture.MagFilter,
wrap_s: gl.Texture.Wrap,
wrap_t: gl.Texture.Wrap,
};
sampler: gl.Sampler,
pub const Error = error{
/// An OpenGL API call failed.
OpenGLFailed,
};
/// Initialize a sampler
pub fn init(
opts: Options,
) Error!Self {
const sampler = gl.Sampler.create() catch return error.OpenGLFailed;
errdefer sampler.destroy();
sampler.parameter(.WrapS, @intFromEnum(opts.wrap_s)) catch return error.OpenGLFailed;
sampler.parameter(.WrapT, @intFromEnum(opts.wrap_t)) catch return error.OpenGLFailed;
sampler.parameter(.MinFilter, @intFromEnum(opts.min_filter)) catch return error.OpenGLFailed;
sampler.parameter(.MagFilter, @intFromEnum(opts.mag_filter)) catch return error.OpenGLFailed;
return .{
.sampler = sampler,
};
}
pub fn deinit(self: Self) void {
self.sampler.destroy();
}