Use this skill when writing, reviewing, debugging, or porting a Kage shader for Ebitengine — anything involving `//kage:unit pixels`, `ebiten.NewShader`, `DrawTrianglesShader`, `DrawRectShader`, or a `Fragment(dstPos vec4, src0Pos vec2, color vec4) vec4` entry point. Use it especially when translating an existing shader from GLSL, Shadertoy, HLSL, MSL, Godot, or Unity, where the source assumes 0..1 texture coordinates, `gl_FragCoord`, `texture()` sampling, a bottom-left origin, `uniform` declarations, a ternary operator, or straight (non-premultiplied) alpha — every one of which is wrong in Kage. Covers the atlas-relative coordinate model, sampling several source images of different sizes, premultiplied alpha, the Go subset Kage actually accepts, and how to compile-check a shader without a GPU.
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:
0..1.//kage:unit pixels). The texel unit is
legacy.Everything below follows from those.
Check every one of these before declaring a shader done.
//kage:unit pixels is present, on its own line, exactly once.vec4 is premultiplied alpha (each of .r/.g/.b ≤ .a).2/3 is 0).DrawRectShader: every non-nil source image is exactly width×height.go test check in
Verifying a shader compiles.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) vec4dstPos.xy — the fragment's position on the destination texture. Only
.xy is meaningful; ignore .z and .w.src0Pos — the interpolated position on source texture 0. It comes from
Vertex.SrcX/SrcY, converted to image 0's texture coordinates. If
Images[0] is nil, SrcX/SrcY are passed through unconverted.color — for DrawTrianglesShader, the interpolated Vertex.ColorR/G/B/A,
four arbitrary interpolated floats. For DrawRectShader it is
DrawRectShaderOptions.ColorScale, which is vec4(1) only because a
zero-value ColorScale is (1, 1, 1, 1).custom vec4 parameter receives Vertex.Custom0..Custom3
(DrawTrianglesShader only, Ebitengine v2.8+).Any trailing parameters may be omitted; func Fragment() vec4 is legal.
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.
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 regionSampling 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.
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).aDiffering 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.
Put this on its own line, conventionally just above package main:
//kage:unit pixelsOmitting 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+) |
Ebitengine works in premultiplied alpha everywhere, shaders included.
vec4 you return must be premultiplied: rgb ≤ a component-wise.
Nothing clamps it for you; an invalid color renders as a too-bright,
washed-out blend.return clr * 0.5, never clr.a *= 0.5.return clr * vec4(1, 0.5, 0.5, 1).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.F(a*rgb) == a*F(rgb) across the alpha range in
play. Scaling the whole vec4 (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.mix does 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: mix is interpolation, not
a blend operator.+ has no equal-alpha exception, because addition carries no output-alpha
policy of its own. Summing two premultiplied vec4 values also sums their
alphas, and the straight RGB that comes back out is scaled by that larger
alpha: two colors at a = 0.5 add to a = 1 with 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.pow exponents, and
thresholds against a fixed nonzero value. A positively homogeneous
operation — a per-channel min/max against zero, say — commutes even
though it is not linear.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.(max-min)/max and 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.(max-min)/(max+min) below L = 0.5 and
(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 usual min(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.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.
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:
for i := <const>; i <op> <const>; i <op>= <const> { } (ops < <= > >= == !=
and += -= ++ --) and, as of v2.10, for i := range <constant int> and
for i, v := range <array>. There is no for cond { } and no
uniform-controlled bound. To vary the count at runtime, loop to a constant
upper bound and break on a uniform.var Time float declares a uniform; var t float is a compile error. Uniforms cannot
be assigned in the shader and cannot have initial values. const globals may
be any case.var f float = i
fails; write float(i). % is not defined on floats; use mod(x, y).2/3 is 0. Untyped constant division follows Go's rules, so
color.r >= 2/3 silently compares against zero. Write 2.0/3.0.float has no guaranteed precision. It is the target language's
float at its highest available precision, which on GLES is whatever the
driver gives for highp. Do not rely on a specific mantissa width, and do
not port a shader that packs data into float bits.len takes an array only, unlike the Go-ish len(vec4) some third-party
docs suggest.imageSrc0* through imageSrc3*.Vertex is just an
ordinary helper and is never called.0..1 UVs, pixel coordinates, a bottom-left origin (Shadertoy, OpenGL)
or top-left (Ebitengine, Direct3D), and whether it wants a resolution or
aspect-ratio uniform.gl_FragCoord/fragCoord becomes dstPos.xy - imageDstOrigin() and
iResolution becomes imageDstSize() when the draw covers the whole
destination image; otherwise work from src0Pos/imageSrc0Size(), or pass
the draw's origin and size as uniforms. A bottom-left origin needs
uv.y = 1 - uv.y.break.#defines to const, uniforms to exported globals.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.
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:
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.
bf5e4bc
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.