Writing and porting Kage shaders
Kage is Ebitengine's shading language. It looks like Go and is translated at runtime into GLSL, GLSL ES, HLSL, MSL, or PSSL.
The three things that break ports from every other shading language:
- An image is a sub-rectangle of a larger atlas texture. Coordinates handed
to and from a Kage shader are absolute positions on that atlas, not positions
within the image and not
0..1. - Destination and source images generally live on different atlases, at different origins, so a destination coordinate is not a source coordinate.
- Always author in the pixel unit (
//kage:unit pixels). The texel unit is legacy.
Everything below follows from those.
Non-negotiables
Check every one of these before declaring a shader done.
-
//kage:unit pixelsis present, on its own line, exactly once. - Every coordinate that gets scaled, divided, rotated, mirrored, tiled, or wrapped is made origin-relative first, and the origin is added back after.
- Every sampling argument is expressed in source image 0's coordinate space, never image N's.
- The returned
vec4is premultiplied alpha (each of.r/.g/.b≤.a). - No integer literal division in a float context (
2/3is0). - The shader compiles: run the
go testcheck in Verifying a shader compiles.
The coordinate model
An *ebiten.Image is a region of a bigger internal texture (an atlas). Three
region pairs describe that placement, all in pixels:
| Region | Origin | Size | Whole texture |
|---|---|---|---|
| Destination | imageDstOrigin() |
imageDstSize() |
imageDstTextureSize() |
| Source N (0..3) | imageSrcNOrigin() |
imageSrcNSize() |
imageSrcNTextureSize() |
Origin and size are what ordinary pixel-unit sampling uses. The whole-texture
sizes convert between pixels and texels, which is exactly what hand-migrating a
texel-unit calculation needs; imageSrcNTextureSize is new in v2.10, and
imageDstTextureSize, deprecated in v2.6, is revived there.
These regions describe whole images, not the area a draw call covers. The
destination region is the destination image's bounds, so a
DrawRectShader(w, h, ...) rectangle placed by a GeoM translation, or a
triangle fan covering part of the screen, is a sub-region of it. Consequently
dstPos.xy - imageDstOrigin() is image-local, not draw-local, and
imageDstSize() is not the draw's extent. When an effect must be positioned or
scaled relative to what is being drawn, drive it from src0Pos — which is
interpolated from the vertices and therefore does follow the geometry — or pass
the draw's origin and size as uniforms.
The Fragment entry point receives absolute positions on those textures:
func Fragment(dstPos vec4, src0Pos vec2, color vec4) vec4
dstPos.xy— the fragment's position on the destination texture. Only.xyis meaningful; ignore.zand.w.src0Pos— the interpolated position on source texture 0. It comes fromVertex.SrcX/SrcY, converted to image 0's texture coordinates. IfImages[0]is nil,SrcX/SrcYare passed through unconverted.color— forDrawTrianglesShader, the interpolatedVertex.ColorR/G/B/A, four arbitrary interpolated floats. ForDrawRectShaderit isDrawRectShaderOptions.ColorScale, which isvec4(1)only because a zero-valueColorScaleis (1, 1, 1, 1).- An optional fourth
custom vec4parameter receivesVertex.Custom0..Custom3(DrawTrianglesShaderonly, Ebitengine v2.8+).
Any trailing parameters may be omitted; func Fragment() vec4 is legal.
Normalizing to 0..1
Neither dstPos nor src0Pos is guaranteed to start at zero. Sometimes one
does — an image can sit at its texture's origin, and DrawTrianglesShader with
Images[0] == nil passes SrcX/SrcY through unchanged — which is what makes
this the single most common porting bug: code that divides a raw coordinate by
a size looks fine until the image lands elsewhere on an atlas.
// WRONG: dstPos.xy is atlas-absolute, so uv is offset by the atlas origin.
uv := dstPos.xy / imageDstSize()
Subtract the origin first, and add it back when converting a normalized value into a position:
// Destination image, normalized to 0..1.
dstUV := (dstPos.xy - imageDstOrigin()) / imageDstSize()
// Source image 0, normalized to 0..1.
srcUV := (src0Pos - imageSrc0Origin()) / imageSrc0Size()
// Back to a samplable position on source image 0.
pos := srcUV*imageSrc0Size() + imageSrc0Origin()
imageSrc0Size() is zero when DrawTrianglesShader runs with
Images[0] == nil — SrcX/SrcY then reach the shader unconverted and no
source region is set — so normalizing by it divides by zero. Pass the size you
mean as a uniform in that case. DrawRectShader is safe without an image: in
the pixel unit it synthesizes source region 0 as the drawn rectangle.
imageDstSize() is the destination image, so dstUV normalizes over the
image and not over the area being drawn. A ported fragCoord/iResolution
usually means the latter, and the two coincide only when the draw covers the
whole destination image. For DrawRectShader the drawn-area coordinate is
srcUV, because source region 0 spans the drawn rectangle and is unaffected by
GeoM; otherwise pass the draw's origin and size as uniforms. See
reference/recipes.md.
The same subtract-transform-add sandwich applies to any non-translation
operation on a coordinate, not just division: floor(p/cell)*cell for
pixelation, mod for tiling, a rotation matrix, a mirror. Do it in
origin-relative space.
Sampling
imageSrc0At(pos vec2) vec4 // returns vec4(0) outside the image region
imageSrc0UnsafeAt(pos vec2) vec4 // faster; undefined outside the image region
imageSrcNAtFromSrc0Pos(pos vec2) vec4 // N >= 1; returns vec4(0) outside the image region
imageSrcNUnsafeAtFromSrc0Pos(pos vec2) vec4 // N >= 1; faster; undefined outside the image region
Sampling is always nearest-neighbour. There is no linear filter, no mipmap, no
wrap mode inside a shader; ebiten.Filter and ebiten.Address apply to
DrawImage/DrawTriangles, not to a custom shader. Implement clamping,
repeating, and interpolation yourself — see
reference/recipes.md.
The UnsafeAt forms omit that region bounds check, so their result outside the
source region is undefined: depending on the position and the backend they may
return atlas padding, a neighbouring image's pixels, zero, or something else
entirely. Use them only where the position is provably inside the region — after
a clamp or a wrap, for instance.
Sampling more than one source image
For N ≥ 1 the functions are imageSrcNAtFromSrc0Pos and
imageSrcNUnsafeAtFromSrc0Pos, and their names state the rule: they take a
position in source image 0's coordinate space. The implementation rebases the
position: it fetches pos - imageSrc0Origin() + imageSrcNOrigin(), and
imageSrcNAtFromSrc0Pos returns vec4(0) outside
[imageSrc0Origin(), imageSrc0Origin() + imageSrcNSize()). (imageSrcNAt and
imageSrcNUnsafeAt are the same functions under their old names, deprecated as
of v2.10.)
So all four slots share one coordinate space, and the normal thing to write is
src0Pos for every one of them. Pixel (x, y) of image 0 lines up with pixel
(x, y) of image N, whatever atlases they landed on and whatever their sizes:
return imageSrc0At(src0Pos) * imageSrc1AtFromSrc0Pos(src0Pos).a
Differing sizes need no correction. Where image N is smaller than image 0,
imageSrcNAtFromSrc0Pos just returns vec4(0).
Arithmetic is needed only when you deliberately want a different mapping, such as stretching a smaller mask across the whole of image 0. That is a scaling choice, not a size fix, and the origin you add back is still image 0's:
uv := (src0Pos - imageSrc0Origin()) / imageSrc0Size()
src0PosForStretchedSrc1 := uv*imageSrc1Size() + imageSrc0Origin() // note: Src0Origin, not Src1Origin
return imageSrc1AtFromSrc0Pos(src0PosForStretchedSrc1)
Adding imageSrc1Origin() there is the classic bug: the origin is applied
internally, so adding it again double-counts the atlas offset and samples an
unrelated part of the atlas. imageSrcNOrigin() for N ≥ 1 is almost never what
you want in a sampling expression; imageSrc0Origin() is the origin that
matters.
Always use the pixel unit
Put this on its own line, conventionally just above package main:
//kage:unit pixels
Omitting it selects the legacy texel unit, in which src0Pos and every
origin/size value become texels of the atlas texture (0..1 across the whole
atlas, not across your image), and DrawTrianglesShader/DrawRectShader panic
when the source images differ in size. A file may contain at most one
//kage:unit directive.
The texel-unit helpers imageSrcRegionOnTexture(), imageDstRegionOnTexture(),
and the index-less imageSrcTextureSize() are deprecated as of v2.6. Do not
introduce them, and replace them when porting old Kage code:
| Deprecated | Replacement |
|---|---|
imageSrcRegionOnTexture() |
imageSrc0Origin(), imageSrc0Size() |
imageDstRegionOnTexture() |
imageDstOrigin(), imageDstSize() |
imageSrcTextureSize() |
imageSrc0TextureSize() (v2.10+) |
Premultiplied alpha
Ebitengine works in premultiplied alpha everywhere, shaders included.
- Sampling returns premultiplied colors.
- The
vec4you return must be premultiplied:rgb ≤ acomponent-wise. Nothing clamps it for you; an invalid color renders as a too-bright, washed-out blend. - To fade, scale the whole vector:
return clr * 0.5, neverclr.a *= 0.5. - To tint, multiply:
return clr * vec4(1, 0.5, 0.5, 1). - A shader ported from a straight-alpha source needs its final color
premultiplied:
return vec4(rgb*a, a). Fixing only the output is not enough if the original also computed on straight-alpha samples, since sampling always hands you premultiplied ones. Whether the original's were straight depends on its texture data and its own conventions, not on the language it was written in. - An RGB operation may stay in premultiplied space when it commutes with
premultiplication: when
F(a*rgb) == a*F(rgb)across the alpha range in play. Scaling the wholevec4(a fade) qualifies — it leaves the straight RGB untouched and scales only alpha — as does a constant per-channel tint with an alpha factor of 1. mixdoes not, despite looking linear. Over premultiplied values it interpolates in premultiplied space, which is not the straight-RGB result the original computed — unless the two alphas are equal, in which case the two agree. It is not source-over compositing either:mixis interpolation, not a blend operator.+has no equal-alpha exception, because addition carries no output-alpha policy of its own. Summing two premultipliedvec4values also sums their alphas, and the straight RGB that comes back out is scaled by that larger alpha: two colors ata = 0.5add toa = 1with straight RGB at half the straight-space sum. Whether the original preserved, maximized, clamped, or separately computed its output alpha has to be read off the original and reproduced deliberately.- Nonlinearity is not itself the criterion. Common failures are gamma
correction, contrast curves with a fixed pivot, most
powexponents, and thresholds against a fixed nonzero value. A positively homogeneous operation — a per-channelmin/maxagainst zero, say — commutes even though it is not linear. - HSV and HSL must be judged case by case against
F(a*rgb) == a*F(rgb)rather than assumed to fail, and the two do not behave alike. A conversion followed by its own inverse is the identity in both, so it commutes. Reshaping value or lightness generally does not. - In HSV, saturation is
(max-min)/maxand hue is a ratio, so both are invariant under positive scaling while value alone carries the scale. An operation touching only hue and saturation commutes, as long as the concrete implementation introduces no offset and no fixed-value step. - In HSL, saturation is
(max-min)/(max+min)belowL = 0.5and(max-min)/(2-max-min)above it. The first form is scale-invariant; the fixed white point makes the second one scale-dependent, and scaling can carry a color across the boundary between them. Multiplying saturation by a constant does commute, since that white point cancels between the forward and inverse formulas — but only while the whole operation stays homogeneous, with no offset, no fixed assignment, and no clamp. The usualmin(s*k, 1)is enough to break it, whenever the straight-space saturation reaches the limit and the one derived from premultiplied RGB does not. A saturation curve or a fixed saturation does not commute either. Evaluate each HSL operation on its own, and unpremultiply when you have not. - To convert, unpremultiply, compute, premultiply again, guarding the divide:
rgb := clr.rgb / max(clr.a, 1e-6). Convert whenever you have not established equivalence.
Worked through, for a straight (1, 0, 0) at a = 1 and a straight
(0, 0, 1) at a = 0.25. Note that (0, 0, 1, 0.25) is not a legal
premultiplied color at all, since blue exceeds alpha:
| first | second | mix(…, 0.5) |
|
|---|---|---|---|
| straight | (1,0,0) a=1 |
(0,0,1) a=0.25 |
(0.5,0,0.5) a=0.625 |
| premultiplied | (1,0,0,1) |
(0,0,0.25,0.25) |
(0.5,0,0.125,0.625) |
The premultiplied result unpremultiplies to straight (0.8, 0, 0.2); the
straight result premultiplies to (0.3125, 0, 0.3125, 0.625). The two disagree
precisely because the alphas differ.
What Kage is not
Kage is a small subset of Go's syntax, not Go and not GLSL. Verified against this repository:
Available: bool, int, float, vec2/vec3/vec4,
ivec2/ivec3/ivec4, mat2/mat3/mat4 (column-major), fixed-size arrays;
swizzling (.xyzw, .rgba, .stpq, not mixed) and [i] indexing; top-level
helper functions with multiple return values; const; if/else;
break/continue; the full builtin math library; discard();
frontfacing() (v2.9+).
Not available: struct, switch, goto, defer, go, init, import,
methods, nested functions and closures, strings, slices, maps, pointers.
Recursion passes Kage's own compiler but the target shading languages reject it,
so it fails at draw time — do not use it.
Rules that bite:
- Loops are constant-bounded. The only accepted forms are
for i := <const>; i <op> <const>; i <op>= <const> { }(ops< <= > >= == !=and+= -= ++ --) and, as of v2.10,for i := range <constant int>andfor i, v := range <array>. There is nofor cond { }and no uniform-controlled bound. To vary the count at runtime, loop to a constant upper bound andbreakon a uniform. - Globals must be exported, and exported globals are uniforms.
var Time floatdeclares a uniform;var t floatis a compile error. Uniforms cannot be assigned in the shader and cannot have initial values.constglobals may be any case. - No implicit int↔float conversion, exactly as in Go.
var f float = ifails; writefloat(i).%is not defined on floats; usemod(x, y). 2/3is0. Untyped constant division follows Go's rules, socolor.r >= 2/3silently compares against zero. Write2.0/3.0.floathas no guaranteed precision. It is the target language'sfloatat its highest available precision, which on GLES is whatever the driver gives forhighp. Do not rely on a specific mantissa width, and do not port a shader that packs data into float bits.lentakes an array only, unlike the Go-ishlen(vec4)some third-party docs suggest.- There are exactly 4 source images,
imageSrc0*throughimageSrc3*. - There is no vertex entry point. Kage compiles a fragment shader; vertex
transformation is Ebitengine's. A function you name
Vertexis just an ordinary helper and is never called.
Migrating an existing shader
- Read the original for its coordinate assumptions first. Note whether it
uses
0..1UVs, pixel coordinates, a bottom-left origin (Shadertoy, OpenGL) or top-left (Ebitengine, Direct3D), and whether it wants a resolution or aspect-ratio uniform. - Translate the coordinate preamble, not just the body. Replace the
original's UV derivation with the origin-relative form above.
gl_FragCoord/fragCoordbecomesdstPos.xy - imageDstOrigin()andiResolutionbecomesimageDstSize()when the draw covers the whole destination image; otherwise work fromsrc0Pos/imageSrc0Size(), or pass the draw's origin and size as uniforms. A bottom-left origin needsuv.y = 1 - uv.y. - Translate the body using the table in reference/porting-to-kage.md.
- Rewrite unbounded loops to a constant bound with a
break. - Fix the output to premultiplied alpha.
- Move
#defines toconst,uniforms to exported globals. - Compile-check, then render and compare against the original.
Port the coordinate handling first and verify it in isolation (return
vec4(dstUV, 0, 1) and confirm a clean red/green ramp across the image) before
porting the effect. Almost every "the effect is offset / tiled wrong / only
works when the image happens to be the whole atlas" bug is a missing origin.
Verifying a shader compiles
ebiten.NewShader compiles Kage with no GPU, window, or display, so a plain
go test is the fastest check:
package shaders_test
import (
"os"
"testing"
"github.com/hajimehoshi/ebiten/v2"
)
func TestShaderCompiles(t *testing.T) {
src, err := os.ReadFile("effect.kage")
if err != nil {
t.Fatal(err)
}
if _, err := ebiten.NewShader(src); err != nil {
t.Fatal(err)
}
}
Errors are reported as line:column: message against the source.
Two limits to keep in mind:
- This validates Kage only. Translation to GLSL/HLSL/MSL happens in the graphics driver on first use, so backend-level rejections (recursion, driver-specific limits) surface at draw time, not here.
- It cannot catch a wrong result. For that, render with skills/run-ebitengine-app-headless and compare pixels.
A real .kage file needs no build constraint; the Go toolchain ignores the
extension. //go:build ignore above the //kage:unit directive is only for
Kage source kept with a .go extension so that Go tooling skips it, which is
what examples/shader/*.go in this repository does.
Reference
- reference/porting-to-kage.md — construct-by-construct translation table for GLSL, Shadertoy, HLSL, and Godot, plus the CPU-side invocation these ports need.
- reference/recipes.md — copy-paste Kage snippets: clamp/repeat addressing, bilinear sampling, normalization helpers, cross-image remapping, pixelation, blur, branchless selectors.
- https://ebitengine.org/en/documents/shader.html — the official language reference, including the complete builtin function list.
- https://github.com/tinne26/kage-desk/tree/main/docs — tutorials and further pitfalls.