Shaders
Bunyip shaders are written in WGSL and compiled to SPIR-V by
gogpu/naga, a native Go dependency. Both runtime
compilation and the offline tool work with CGO_ENABLED=0; neither needs a
compiler executable, shared compiler library or Vulkan SDK to compile source.
Rendering still requires Bunyip's Vulkan runtime.
Language and execution model
WGSL provides vectors, matrices, functions, loops, structures and GPU texture operations. See the WGSL specification for syntax. Bunyip adds ordinary declarations, helper functions and generated entry points. These helpers do not create a separate language.
Game sources define a sprite fragment function or a mesh surface function.
The engine supplies the stage entry point; do not declare your own @vertex
or @fragment entry point in a composed game source. Complete modules with
explicit entry points can be compiled through Compiler.CompileRaw or
bunyip-shader -raw, but must match their pipeline's resource interface.
There is no GLSL preprocessor, #version, UNIFORMS macro or implicit numeric
conversion contract. Write WGSL declarations directly. The renderer exposes
vertex and fragment pipelines; compiling compute or other unsupported pipeline
interfaces does not provide an engine dispatch API.
Bunyip targets Vulkan through SPIR-V 1.3. Built-in shaders use the compiler's
var<push_constant> extension to preserve Vulkan push-data layouts. This is
an implementation choice, not a promise that the source runs unchanged in
WebGPU. Optional language/compiler features must also be supported by the
renderer and GPU. The engine does not enable arbitrary shader capabilities.
The pinned Naga compiler does not yet implement every WGSL feature. In
particular, Bunyip rejects module-level private variables with initializers
because this compiler version can silently drop their initialization. Use
const for fixed values, or assign a private variable in your hook or entry
point before reading it. Private variables without an explicit initializer
remain supported. Compiler errors leave an existing shader unchanged.
Runtime compilation
shader, err := ctx.Gfx.CompileShader(context.Background(), source)
if err != nil {
return err
}
ctx.Gfx.Shaded(shader, func() {
ctx.Gfx.Draw(texture, sprite)
})
CompileMeshShader creates a mesh shader from source. Shader.ReloadSource
recompiles the existing shader kind while keeping its uniforms and images.
Compile or pipeline-creation failures leave the previous shader intact.
These methods run synchronously on the game goroutine, where GPU resources belong. Compile during loading or in a worker, rather than on every draw. For background work, compile only the CPU representation:
compiler := shaders.Compiler{}
data, err := compiler.Compile(context.Background(), shaders.Sprite, source)
if err != nil {
return err
}
// Transfer data to the game goroutine before creating the GPU resource.
shader, err := ctx.Gfx.NewShader(data)
Compiler has useful zero defaults and can compile concurrently. Context
cancellation is checked between phases; it cannot interrupt a phase already
executing or Vulkan pipeline creation. No background workers are retained
after a call. Keep compiled bytes if you want a game-specific cache.
The creating Graphics owns the resulting shader. Optional early
Destroy retires GPU resources safely. Shaders and textures belong to one
Graphics; load separate resources for another window.
Sprite shaders
struct Params {
amplitude: f32,
frequency: f32,
};
@group(1) @binding(0) var<uniform> u: Params;
fn fragment(inputUV: vec2f, color: vec4f) -> vec4f {
var uv = inputUV;
uv.x += sin(uv.y * u.frequency + time() * 3.0) * u.amplitude;
return textureSample(tex, texSampler, uv) * color;
}
The input UV selects the sprite's texture region. The color is premultiplied tint; return premultiplied color. Sampling a color texture supplies the engine's linear, premultiplied texels. Preserve alpha conventions when replacing or mixing colors.
| Declaration/helper | Meaning |
|---|---|
tex, texSampler |
Current drawable texture and sampler; white for untextured shapes |
image0 through image3 |
Extra textures assigned with Shader.SetImage |
image0Sampler through image3Sampler |
Their filtering/addressing samplers |
time() |
Game time in seconds |
viewSize() |
Current view dimensions in view units |
pixelScale() |
Framebuffer pixels per view unit |
position() |
Fragment position in view units |
Sprite texture/sampler pairs occupy group 0 bindings 0/1, 2/3, 4/5, 6/7 and 8/9. User uniforms occupy group 1 binding 0. Engine entry-point inputs, outputs and push data are reserved; do not redeclare them.
Uniform values
if err := shader.SetUniforms(struct {
Amplitude float32
Frequency float32
}{0.03, 24}); err != nil {
return err
}
SetUniforms copies an exported Go struct or pointer to one into a
std140-compatible block. It matches fields by declaration order, not names.
There is no shader reflection or automatic type conversion.
| Go value | WGSL representation |
|---|---|
float32, int32, uint32 |
f32, i32, u32 |
bool |
u32, containing 0 or 1; test with != 0u |
lin.Vec2/Vec3/Vec4, gfx.Color |
vec2f/vec3f/vec4f; Color is vec4f |
lin.Mat3/Mat4 |
Column-major mat3x3f/mat4x4f |
| Fixed arrays/nested structs | Matching declarations with std140-compatible padding |
Scalar arrays have a 16-byte stride in the Go packer. Use padded WGSL
elements rather than assuming array<f32,N> has that stride:
struct Weight { @size(16) value: f32, };
struct Params { weights: array<Weight, 2>, };
@group(1) @binding(0) var<uniform> u: Params;
// Corresponds to Go: struct { Weights [2]float32 }
// Read the first weight as u.weights[0].value.
Nested records need matching 16-byte structure alignment and tail padding
where std140 requires it. A Go [4]float32 is a padded scalar array, not a
vec4f; use lin.Vec4 for a vector. Most game parameters are simplest as
scalars and vectors, with explicit vector arrays for repeated records.
Blocks must fit 1024 packed bytes. Unsupported types, unexported fields, empty structures and oversized blocks return errors without changing the previous values. Each queued draw retains its uniform and image snapshot.
Mesh surface and vertex hooks
fn surface(input: Surface) -> Surface {
var s = input;
s.roughness = 0.4;
s.emissive += vec3f(1.0, 0.2, 0.0);
return s;
}
Create with CompileMeshShader and assign Material.Shader. The engine
fills a Surface from material factors and maps before calling your hook,
then performs lighting, fog and the normal render passes.
Mesh hooks use straight color. The engine divides the albedo texel by
its own alpha before filling Surface.albedo; finish receives and
returns straight RGB with alpha beside it. For blended materials, the
engine premultiplies the result after finish and fog, for both sorted
and order-independent transparency. Setting s.alpha to 0.25 fades the
whole shaded surface to a quarter of its color before tone mapping.
Surface fields include:
- Base appearance:
albedo: vec3f,alpha: f32,normal: vec3f,metallic,roughness,emissive: vec3f,occlusion,unlit: bool. - Geometry:
uv/uv2: vec2f,color: vec4f,worldPos/viewDir: vec3f. - Layered materials:
clearcoat,clearcoatRoughness,sheen: vec3f,sheenRoughness,subsurface,thickness,transmission,ior,volume,attenuation: vec3f,attenuationDistance. - Specular detail:
specular,specularColor: vec3f,iridescence,iridescenceIOR,iridescenceThickness,anisotropy,tangent: vec3f,shell. Unannotated fields here are f32.
Normals, positions and tangent directions are world-space in the surface.
Iridescence thickness is in nanometres. shell ranges from the underlying
surface to the outer fur shell. Local Surface.unlit is a boolean; this
does not imply booleans can be stored directly in a uniform buffer.
An optional fn finish(lit: vec4f, s: Surface) -> vec4f adjusts the lit
result. An optional vertex hook transforms object-space geometry after
morph blending and before skinning:
fn vertex(input: VertexData) -> VertexData {
var v = input;
v.position.z += sin(v.uv.x * 6.0 - time() * 4.0) * 0.15 * v.uv.x;
return v;
}
VertexData contains position, normal, UV, second UV and color.
The engine compiles static/skinned vertex variants for lit and shadow passes,
so displacement also changes shadows. Set Shader.VertexBounds to a
conservative displacement as a multiple of the mesh bounding radius;
zero disables culling for a shader with a vertex hook.
Mesh image helpers sampleImage0(uv) through sampleImage3(uv) sample
the textures set with SetImage. Mesh material bindings are engine-owned.
Material sampling helpers return vec4f: albedoTex, metalRoughTex,
normalTex, emissiveTex, occlusionTex, thicknessTex,
transmissionTex, iridescenceTex, anisotropyTex, specularTex and
furTex. Each accepts a vec2f UV. The engine normally samples these maps
while constructing Surface; call them directly for custom material logic.
The mesh user block is group 4 binding 0 and is shared across stages (sprite
user blocks use group 1 binding 0). Source hooks must
remain valid in each generated stage, including vertex stages when present.
Offline compilation and loading
//go:generate go run github.com/matjam/bunyip/cmd/bunyip-shader -o wave.spv wave.wgsl
//go:embed wave.spv
var waveSPV []byte
Graphics.NewShader loads sprite SPIR-V; NewMeshShader loads the mesh
bundle produced with -kind mesh. Mesh bundles contain regular and
order-independent transparency fragments plus vertex variants when needed.
Runtime-created bytes have the same format. Shader.Reload accepts newly
compiled bytes, useful with asset.Watcher.
The command accepts -stage frag|oitfrag|vert|skinvert|shadowvert|shadowskinvert
for individual generated programs. -print prints composed WGSL for reading
compiler diagnostics; -raw compiles a complete module without composition.
Errors include the stage and prefix line count. Keep the composed program
when diagnosing errors inside engine-provided code.
See the shader example for wave, dissolve, lava and flag shaders, with uniform values controlled by the UI.