Bunyip a game engine in Go GitHub

Example examples/shaders

Shaders

Shaders

This example is four shaders written by the game rather than the engine. Two of them colour sprites in the 2D stream: a wave that ripples the texture coordinates and a dissolve that burns a sprite away along a noise image. The other two run on meshes under the engine's own lighting: a lava surface that writes albedo, roughness and emissive before the light is applied, and a flag whose vertex hook displaces the cloth in the lit pass and the shadow pass alike. Sliders drive their uniforms while the program runs.

This example precompiles its shaders. bunyip-shader composes each .wgsl with Bunyip's bindings and entry points, then compiles it to SPIR-V using gogpu/naga, a compiler written in Go. No external compiler executable or native compiler library is required. The .spv output is embedded in the binary. Games can also compile source at runtime with Graphics.CompileShader and Graphics.CompileMeshShader; this example uses NewShader and NewMeshShader in gfx, Shader.SetUniforms and Shader.SetImage, Graphics.Shaded for the 2D case and Material.Shader for the mesh case. The guide is Shaders.

Run it with:

CGO_ENABLED=0 go run ./examples/shaders -seconds 3 -shot out.png

The flags are -seconds N and -shot file.png. The three sliders set the wave amplitude, the lava heat and the wind strength; Escape quits. After editing a .wgsl, run CGO_ENABLED=0 go generate ./examples/shaders/ to rebuild the SPIR-V.

Generate directives and embedded SPIR-V

The four go:generate lines are the build step. -kind mesh selects the mesh prelude, which is what decides whether the file supplies fragment or surface, vertex and finish. The default kind is the 2D one.

Each .spv is embedded with go:embed. Generate a game's SPIR-V before building the example that embeds it. The compiler command imports the engine's existing shaders, but does not import this example, so a new game shader can be compiled before adding its go:embed declaration.

//go:generate go run ../../cmd/bunyip-shader -o wave.spv wave.wgsl
//go:generate go run ../../cmd/bunyip-shader -o dissolve.spv dissolve.wgsl
//go:generate go run ../../cmd/bunyip-shader -kind mesh -o lava.spv lava.wgsl
//go:generate go run ../../cmd/bunyip-shader -kind mesh -o flag.spv flag.wgsl

var (
	//go:embed wave.spv
	waveSPV []byte
	//go:embed dissolve.spv
	dissolveSPV []byte
	//go:embed lava.spv
	lavaSPV []byte
	//go:embed flag.spv
	flagSPV []byte
)

The game type and the cloth mesh

The game holds four shaders, two textures, two meshes and the three slider values.

clothMesh builds a subdivided quad in the x-y plane with u running along x, which is what lets the flag shader pin the edge at u = 0 and wave the rest. The subdivision matters: a vertex shader can only move vertices that exist, so a quad of two triangles would not ripple.

type game struct {
	seconds float64
	shot    string

	font      *gfx.Font
	ui        *ui.Context
	checker   *gfx.Texture
	noise     *gfx.Texture
	wave      *gfx.Shader
	dissolve  *gfx.Shader
	lava      *gfx.Shader
	flag      *gfx.Shader
	cube      *gfx.Mesh
	cloth     *gfx.Mesh
	amplitude float32
	heat      float32
	wind      float32
	shotDone  bool
}

// clothMesh is a subdivided quad in the x-y plane, 2 by 1.2 units, with
// u running along x so the flag's vertex hook can pin one edge.
func clothMesh(nx, ny int) ([]gfx.Vertex, []uint32) {
	var verts []gfx.Vertex
	var idx []uint32
	for j := 0; j <= ny; j++ {
		for i := 0; i <= nx; i++ {
			u, v := float32(i)/float32(nx), float32(j)/float32(ny)
			verts = append(verts, gfx.Vertex{Pos: lin.V3(u*2, 1.2-v*1.2, 0), Normal: lin.V3(0, 0, 1), UV: lin.V2(u, v)})
		}
	}
	stride := uint32(nx + 1)
	for j := 0; j < ny; j++ {
		for i := 0; i < nx; i++ {
			a := uint32(j)*stride + uint32(i)
			idx = append(idx, a, a+stride, a+1, a+1, a+stride, a+stride+1)
		}
	}
	return verts, idx
}

Init: loading precompiled shaders

NewShader takes 2D SPIR-V and NewMeshShader takes mesh SPIR-V; the two pipelines differ, so the constructor picks which one the module is built for.

SetImage(0, tex) binds an extra texture the shader reads as image0. A shader has four such slots, separate from the material's own textures. The noise texture is created with Data: true, which uploads the bytes as they are rather than treating them as sRGB colour: a shader reading a noise value wants the number, not a colour conversion. Repeat: true lets the shaders sample it with coordinates outside the unit square.

func (g *game) Init(ctx *engine.Context) error {
	var err error
	if g.font, err = ctx.Gfx.NewFont(goregular.TTF, 15, gfx.FontOptions{}); err != nil {
		return err
	}
	g.ui = ui.New(ctx.Gfx, ui.DarkTheme(g.font))
	if g.checker, err = ctx.Gfx.NewTexture(checker(128), gfx.TextureOptions{Linear: true}); err != nil {
		return err
	}
	if g.noise, err = ctx.Gfx.NewTexture(noise(256, 7), gfx.TextureOptions{Linear: true, Data: true, Repeat: true}); err != nil {
		return err
	}
	if g.wave, err = ctx.Gfx.NewShader(waveSPV); err != nil {
		return err
	}
	if g.dissolve, err = ctx.Gfx.NewShader(dissolveSPV); err != nil {
		return err
	}
	if g.lava, err = ctx.Gfx.NewMeshShader(lavaSPV); err != nil {
		return err
	}
	if g.flag, err = ctx.Gfx.NewMeshShader(flagSPV); err != nil {
		return err
	}
	g.wave.SetImage(0, g.noise)
	g.dissolve.SetImage(0, g.noise)
	g.lava.SetImage(0, g.noise)
	cv, ci := gfx.CubeMesh()
	if g.cube, err = ctx.Gfx.NewMesh(cv, ci); err != nil {
		return err
	}
	fv, fi := clothMesh(40, 24)
	if g.cloth, err = ctx.Gfx.NewMesh(fv, fi); err != nil {
		return err
	}
	g.amplitude, g.heat, g.wind = 0.03, 1, 1
	return nil
}

func (g *game) Shutdown(ctx *engine.Context) {
	g.cloth.Destroy()
	g.flag.Destroy()
	g.cube.Destroy()
	g.lava.Destroy()
	g.dissolve.Destroy()
	g.wave.Destroy()
	g.noise.Destroy()
	g.checker.Destroy()
	g.font.Destroy()
}

A Shader is a GPU resource with Destroy, like a texture or a mesh.

Update

func (g *game) Update(ctx *engine.Context) error {
	if ctx.Input.KeyPressed(input.KeyEscape) || (g.seconds > 0 && ctx.Time >= g.seconds) {
		ctx.Quit()
	}
	if g.shot != "" && !g.shotDone && (g.seconds == 0 || ctx.Time >= g.seconds/2) {
		ctx.Screenshot(g.shot)
		g.shotDone = true
	}
	return nil
}

Draw: the mesh shaders

SetUniforms packs exported struct fields into the engine's std140-compatible uniform layout and returns an error for unsupported types or oversized blocks. The Go struct must match the shader's Params field order and types. The examples use adjacent f32 fields, so their layouts match directly. Arrays and nested structs need the explicit WGSL layout described in the shader guide; packing does not reflect the shader declaration. Each call passes an anonymous Go struct beside its draw.

A mesh shader is attached through Material.Shader. The lava slab is a cube scaled flat with no base colour or texture, because the shader writes those itself. The plain cubes around it use the standard material path in the same frame, so both pipelines are in one scene.

The flag is drawn with DoubleSided: true, since a rippling cloth shows both faces. Its vertex hook runs in the shadow pass as well, so the shadow it casts ripples with it. Meshes whose shader has a vertex hook are skipped by the frustum culling, because the bind-pose bounds no longer describe where the geometry ends up.

func (g *game) Draw(ctx *engine.Context) error {
	gr := ctx.Gfx
	t := float32(ctx.Time)
	// The 3D scene: a lava slab with plain cubes on it.
	gr.SetCamera(gfx.Camera{Position: lin.V3(6*float32(math.Sin(float64(t)*0.2)), 4.5, 6*float32(math.Cos(float64(t)*0.2))), Target: lin.V3(0, 0, 0)})
	gr.SetLight(gfx.Light{Direction: lin.V3(-0.4, -1, -0.3), Color: gfx.Color{R: 1.5, G: 1.4, B: 1.3, A: 1},
		Sky: gfx.Sky{Zenith: gfx.Color{R: 0.25, G: 0.3, B: 0.4, A: 1}, Ground: gfx.Color{R: 0.1, G: 0.05, B: 0.03, A: 1}}, Shadows: true, ShadowDistance: 20})
	if err := g.lava.SetUniforms(struct{ Heat float32 }{g.heat}); err != nil {
		return err
	}
	gr.DrawMesh(g.cube, gfx.Material{Shader: g.lava}, lin.Translate(lin.V3(0, -0.5, 0)).Mul(lin.Scale(lin.V3(8, 0.4, 8))))
	for i := range 5 {
		a := float64(i) * 2 * math.Pi / 5
		gr.DrawMesh(g.cube, gfx.Material{BaseColor: gfx.RGB(200, 200, 210), Roughness: 0.4, Metallic: 0.6},
			lin.Translate(lin.V3(2.5*float32(math.Cos(a)), 0.2, 2.5*float32(math.Sin(a)))).Mul(lin.Rotate(t+float32(i), lin.V3(0, 1, 0))).Mul(lin.Scale(lin.V3(0.8, 0.8, 0.8))))
	}
	// A flag on a pole: the vertex hook ripples the cloth and its shadow.
	if err := g.flag.SetUniforms(struct{ Strength float32 }{g.wind}); err != nil {
		return err
	}
	gr.DrawMesh(g.cube, gfx.Material{BaseColor: gfx.RGB(90, 90, 100), Roughness: 0.5}, lin.Translate(lin.V3(0, 1.2, 0)).Mul(lin.Scale(lin.V3(0.06, 3.2, 0.06))))
	gr.DrawMesh(g.cloth, gfx.Material{Shader: g.flag, DoubleSided: true}, lin.Translate(lin.V3(0.05, 1.6, 0)).Mul(lin.Rotate(t*0.2, lin.V3(0, 1, 0))))

Draw: the 2D shaders, blends and transforms

Shaded(shader, body) applies a 2D shader to everything the closure draws, and restores the previous state at the end. It is the same closure form as Blended and Transformed, which appear below it. Sprites drawn under a shader still go into the ordinary 2D stream, so compatible draws can batch together. Texture, blend and clip changes can also break a batch, even when the shader stays the same.

The dissolve's progress is driven from ctx.Time through a cosine, so it burns away and back without any state on the game.

	// 2D: the wave shader over a checker, then the dissolve.
	if err := g.wave.SetUniforms(struct{ Amplitude, Frequency float32 }{g.amplitude, 24}); err != nil {
		return err
	}
	gr.Shaded(g.wave, func() {
		gr.Draw(g.checker, gfx.Sprite{Pos: lin.V2(ctx.Width-300, 20), Size: lin.V2(260, 180)})
	})
	progress := float32(0.5 - 0.5*math.Cos(float64(t)*0.8))
	if err := g.dissolve.SetUniforms(struct{ Progress, Edge float32 }{progress, 0.08}); err != nil {
		return err
	}
	gr.Shaded(g.dissolve, func() {
		gr.Draw(g.checker, gfx.Sprite{Pos: lin.V2(ctx.Width-300, 220), Size: lin.V2(260, 180), Color: gfx.RGB(120, 200, 255)})
	})
	// Blend modes: additive glows and a multiplied shadow over the checker.
	gr.Draw(g.checker, gfx.Sprite{Pos: lin.V2(ctx.Width-300, 420), Size: lin.V2(260, 120)})
	gr.Blended(gfx.BlendAdd, func() {
		for i := range 3 {
			x := ctx.Width - 260 + float32(i)*90 + 30*float32(math.Sin(float64(t)*2+float64(i)))
			gr.FillCircle(x, 480, 40, gfx.RGBA(255, 90, 30, 160))
		}
	})
	gr.Blended(gfx.BlendMultiply, func() {
		gr.FillRect(ctx.Width-300, 500, 260, 40, gfx.RGB(90, 110, 160))
	})
	// The transform stack: a sheared, rotating sprite.
	gr.Transformed(lin.Translate2(ctx.Width-170, 620).Mul(lin.Rotate2(t*0.5)).Mul(lin.Shear2(0.4, 0)), func() {
		gr.Draw(g.checker, gfx.Sprite{Pos: lin.V2(-40, -40), Size: lin.V2(80, 80), Color: gfx.RGB(255, 230, 150)})
	})

	u := g.ui
	u.Begin(ctx.Input, func() {
		u.Panel("Shaders", ui.Rect{X: 12, Y: 12, W: 320, H: 250}, func() {
			u.Slider("Wave amplitude", &g.amplitude, 0, 0.1)
			u.Slider("Lava heat", &g.heat, 0, 3)
			u.Slider("Wind", &g.wind, 0, 2)
			u.Label("wave.wgsl and dissolve.wgsl colour sprites; lava.wgsl shapes a surface before lighting; flag.wgsl moves vertices. Additive glows, a multiplied shadow, and a sheared sprite below.")
		})
	})
	return nil
}

The sliders write straight into the game's fields through pointers, and the next frame's SetUniforms picks the values up, which is the whole loop between the interface and the shaders.

wave.wgsl

A 2D shader supplies fn fragment(uv: vec2f, color: vec4f) -> vec4f, returning a premultiplied colour. @group(1) @binding(0) var<uniform> declares the block that SetUniforms fills. tex and texSampler read the sprite's texture; image0 and image0Sampler read the first extra image. time() is the elapsed time supplied by the prelude.

This one samples the noise, scrolls it, uses it to modulate a sine offset applied to the horizontal texture coordinate, and tints the result. Rippling the coordinate rather than the colour is what makes the image itself wobble.

struct Params { amplitude: f32, frequency: f32, };
@group(1) @binding(0) var<uniform> u: Params;

fn fragment(inputUV: vec2f, color: vec4f) -> vec4f {
    var uv = inputUV;
    let n = textureSample(image0, image0Sampler, uv * 2.0 + vec2f(time() * 0.1, 0.0)).r;
    uv.x += sin(uv.y * u.frequency + time() * 3.0) * u.amplitude * n;
    let c = textureSample(tex, texSampler, uv) * color;
    return c * vec4f(1.0, 0.85 + 0.15 * n, 0.7 + 0.3 * n, 1.0);
}

dissolve.wgsl

The dissolve compares the noise value at each texel with a threshold that rises with progress. Below the threshold the fragment is discarded by returning a fully transparent colour; just above it, a glowing edge is added, whose width is the edge uniform. Multiplying the glow by c.a keeps it inside the sprite's own shape.

struct Params { progress: f32, edge: f32, };
@group(1) @binding(0) var<uniform> u: Params;

fn fragment(uv: vec2f, color: vec4f) -> vec4f {
    let c = textureSample(tex, texSampler, uv) * color;
    let n = textureSample(image0, image0Sampler, uv).r;
    let cut = u.progress * (1.0 + u.edge);
    if n < cut - u.edge { return vec4f(0.0); }
    let glow = 1.0 - clamp((n - (cut - u.edge)) / u.edge, 0.0, 1.0);
    let fire = vec3f(1.0, 0.5, 0.1) * glow * 2.0 * c.a;
    return vec4f(c.rgb + fire, c.a);
}

lava.wgsl

A mesh shader supplies fn surface(input: Surface) -> Surface, which runs before lighting. Copy the input to a mutable var, adjust material properties such as albedo, roughness, metallic, normal and emissive, then return the modified surface. Shadows, point lights and fog still apply. Mesh uniform blocks use @group(4) @binding(0). The sampleImage0 helper samples the first extra image using its filtering and repeat settings.

s.worldPos positions the pattern in the world rather than on the surface, so the cracks do not stretch with the cube's scale. s.emissive += adds to whatever the material set instead of replacing it.

The optional fn finish(lit: vec4f, s: Surface) -> vec4f hook runs after the lighting and can adjust the lit colour, which is used here to fade the slab's edges towards black.

struct Params { heat: f32, };
@group(4) @binding(0) var<uniform> u: Params;

fn surface(input: Surface) -> Surface {
    var s = input;
    let p = s.worldPos.xz * 1.5 + vec2f(time() * 0.05, 0.0);
    let n = sampleImage0(p * 0.25).r;
    let crack = smoothstep(0.45, 0.55, n);
    let pulse = 0.6 + 0.4 * sin(time() * 2.0 + n * 12.0);
    s.albedo = mix(vec3f(0.05, 0.04, 0.04), vec3f(0.2, 0.1, 0.08), n);
    s.roughness = mix(0.95, 0.4, crack);
    s.emissive += vec3f(1.0, 0.35, 0.05) * crack * pulse * u.heat;
    return s;
}

fn finish(lit: vec4f, s: Surface) -> vec4f {
    let rim = smoothstep(0.0, 0.5, 1.0 - abs(s.uv.x - 0.5) * 2.0) * smoothstep(0.0, 0.5, 1.0 - abs(s.uv.y - 0.5) * 2.0);
    return vec4f(lit.rgb * mix(0.3, 1.0, rim), lit.a);
}

flag.wgsl

fn vertex(input: VertexData) -> VertexData returns the modified vertex before the model matrix is applied, in object space, and in both the lit pass and the shadow pass, which is what makes the flag's shadow match the flag. The displacement is scaled by v.uv.x so the edge at u = 0 stays pinned to the pole.

The normal is recomputed from the slope of the same wave, because moving a vertex without moving its normal leaves the lighting flat. surface then stripes the cloth from the vertical texture coordinate.

struct Params { strength: f32, };
@group(4) @binding(0) var<uniform> u: Params;

fn vertex(input: VertexData) -> VertexData {
    var v = input;
    let free = v.uv.x;
    let wave = sin(v.uv.x * 6.0 - time() * 4.0) + 0.5 * sin(v.uv.y * 4.0 - time() * 6.0);
    v.position.z += wave * 0.15 * free * u.strength;
    let slope = cos(v.uv.x * 6.0 - time() * 4.0) * 6.0 * 0.15 * free * u.strength;
    v.normal = normalize(vec3f(-slope * 0.5, 0.0, 1.0));
    return v;
}

fn surface(input: Surface) -> Surface {
    var s = input;
    let band = step(0.5, fract(s.uv.y * 3.0));
    s.albedo = mix(vec3f(0.9, 0.2, 0.15), vec3f(0.95, 0.95, 0.9), band);
    s.roughness = 0.8;
    return s;
}

The generated textures and main

checker is a two-tone board and noise is smooth value noise on an 8 by 8 grid, interpolated with a smoothstep and wrapped with a modulo so it tiles. The noise is the input to three of the four shaders, which is why it is worth generating rather than shipping.

// checker makes a two-tone checkerboard.
func checker(size int) image.Image {
	img := image.NewRGBA(image.Rect(0, 0, size, size))
	for y := range size {
		for x := range size {
			c := color.RGBA{60, 70, 90, 255}
			if (x/16+y/16)%2 == 0 {
				c = color.RGBA{220, 210, 190, 255}
			}
			img.SetRGBA(x, y, c)
		}
	}
	return img
}

// noise makes smooth value noise, tiling.
func noise(size int, seed uint64) image.Image {
	const cells = 8
	random := rng.New(seed)
	grid := make([]float64, cells*cells)
	for i := range grid {
		grid[i] = float64(random.Float())
	}
	at := func(x, y int) float64 { return grid[(y%cells)*cells+x%cells] }
	img := image.NewRGBA(image.Rect(0, 0, size, size))
	for y := range size {
		for x := range size {
			fx, fy := float64(x)/float64(size)*cells, float64(y)/float64(size)*cells
			ix, iy := int(fx), int(fy)
			tx, ty := fx-float64(ix), fy-float64(iy)
			tx, ty = tx*tx*(3-2*tx), ty*ty*(3-2*ty)
			v := (at(ix, iy)*(1-tx)+at(ix+1, iy)*tx)*(1-ty) + (at(ix, iy+1)*(1-tx)+at(ix+1, iy+1)*tx)*ty
			b := uint8(v * 255)
			img.SetRGBA(x, y, color.RGBA{b, b, b, 255})
		}
	}
	return img
}

func main() {
	seconds := flag.Float64("seconds", 0, "exit after this many seconds")
	shot := flag.String("shot", "", "write a screenshot to this PNG")
	flag.Parse()
	err := engine.Run(engine.Config{Title: "Bunyip shaders", Width: 1024, Height: 720},
		&game{seconds: *seconds, shot: *shot})
	if err != nil {
		fmt.Fprintln(os.Stderr, "shaders:", err)
		os.Exit(1)
	}
}

What to try

  • Change a constant in lava.wgsl, run CGO_ENABLED=0 go generate ./examples/shaders/, and run the program again; that is the whole edit cycle.
  • Add speed: f32 to the Params struct in wave.wgsl and to the struct passed to SetUniforms in Draw, and give it a slider.
  • Write a finish hook in flag.wgsl that darkens the cloth towards its free edge, and see it apply after the lighting.
  • Bind a second image with SetImage(1, ...) in Init and sample it with textureSample(image1, image1Sampler, uv) in dissolve.wgsl for a different burn pattern.
  • Reduce clothMesh(40, 24) in Init to clothMesh(4, 3) to see how much the vertex hook depends on the subdivision.

Source files

main.go

The whole directory on GitHub