ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Ebitengine Kage 着色器配方手册:坐标归一化、寻址模式与采样技巧实战

Ebitengine Kage 着色器配方手册:坐标归一化、寻址模式与采样技巧实战 游戏开发图形学【免费下载链接】ebitenA dead simple 2D game engine for Go项目地址https://gitcode.com/GitHub_Trending/eb/ebiten点击查看免费下载导读本文是 Ebitengine Kage 着色语言//kage:unit pixels像素单位 atlas 相对坐标的实战配方集对应仓库 skills/writing-kage-shaders/reference/recipes.md是 skills/writing-kage-shaders/SKILL.md 的配套代码库。文中每个代码片段都假设使用像素单位、面向 atlas 相对坐标编写可直接复制到你的.kage文件或带//go:build ignore的.go文件中运行。读完本文你将掌握在 atlas 坐标系下正确归一化坐标、把效果锚定到被绘制的区域而非整张目标图像、跨多个源图像采样与拉伸、手工实现 clamp/repeat 寻址与双线性插值、像素化、可分离模糊、预乘 alpha 与无分支选择器以及一套不依赖 GPU 的调试方法。前置知识像素单位与 atlas 相对坐标所有配方都以两条约定为前提缺一不可像素单位文件顶部必须有独立的//kage:unit pixels指令通常放在package main上方一行恰好一次。省略它将进入遗留的 texel 单位此时src0Pos以及所有 origin/size 值都变成整张 atlas 纹理的 texel0..1 跨越整张 atlas而不是你的图像且源图像尺寸不同时DrawTrianglesShader/DrawRectShader会 panic。atlas 相对坐标Ebitengine 中每个*ebiten.Image都是更大内部纹理atlas上的一个子矩形。Kage 拿到手的dstPos、src0Pos是这张 atlas 上的绝对像素位置既不是图像内相对坐标也不是 0..1 UV。Ebitengine 提供三组内置函数描述这些区域源码生成于 internal/graphics/shader.go区域原点尺寸整张纹理尺寸目标图像imageDstOrigin()imageDstSize()imageDstTextureSize()源图像 N0..3imageSrcNOrigin()imageSrcNSize()imageSrcNTextureSize()注意这些区域描述的是整张图像而不是某次绘制调用覆盖的范围DrawRectShader的矩形、三角形扇等只是它的子区域。因此dstPos.xy - imageDstOrigin()是图像局部坐标imageDstSize()也不是绘制的范围。归一化把坐标映射到 0..1把坐标转成 0..1 UV 的正确写法是先减原点、再除尺寸从 0..1 转回可采样位置则是逆运算乘尺寸、加原点// Destination position, normalized to 0..1 over the destination image. func dstUV(dstPos vec4) vec2 { return (dstPos.xy - imageDstOrigin()) / imageDstSize() } // Source position, normalized to 0..1 over source image 0. func srcUV(src0Pos vec2) vec2 { return (src0Pos - imageSrc0Origin()) / imageSrc0Size() } // The inverse: a 0..1 coordinate back to a samplable position on source 0. func src0PosOf(uv vec2) vec2 { return uv*imageSrc0Size() imageSrc0Origin() }最常见的移植 bug 就是直接dstPos.xy / imageDstSize()——图像一旦落在 atlas 的非原点位置UV 就会被原点偏移。两个 source 助手的先决条件imageSrc0Size()在DrawTrianglesShader且Images[0] nil时是零——此时SrcX/SrcY未经转换直接到达着色器没有任何源区域被设置所以归一化会除以零。这种情况下请把尺寸作为 uniform 传进去。DrawRectShader则相反在像素单位下它会把源区域 0合成为被绘制的矩形因此即使不带图像也是安全的。dstUV的陷阱它没有上述前置条件但它归一化的是整张目标图像而不是正在绘制的区域。从 Shadertoy 移植的fragCoord/iResolution几乎总是指后者。两者只有在绘制覆盖整张目标图像时才一致一旦有GeoM平移、矩形小于图像、或绘制到子图像目标两者就分道扬镳——而且画面不会破只是效果被锚定到图像而不是四边形上。归一化到被绘制的区域对DrawRectShader来说上面的srcUV已经是绘制区域坐标源区域 0 恰好横跨被绘制的矩形——有图像时它是该图像边界必须与矩形尺寸一致Images[0] nil时像素单位合成(0,0)-(width,height)。无论哪种情况src0Pos都从区域原点运行到原点加尺寸横跨整个四边形因此srcUV就是矩形上的 0..1。它也不受GeoM影响旋转或缩放绘制会移动dstPos但矩形自身坐标不变——这正是为全屏四边形编写的着色器所期望的行为。// fragCoord/iResolution, over the drawn rectangle. func drawnUV(src0Pos vec2) vec2 { return (src0Pos - imageSrc0Origin()) / imageSrc0Size() } // fragCoord itself, in pixels from the rectangles top-left corner. func drawnPos(src0Pos vec2) vec2 { return src0Pos - imageSrc0Origin() }而DrawTrianglesShader没有可恢复的绘制矩形映射由顶点SrcX/SrcY决定。当Images[0] nil时SrcX/SrcY未经转换直达着色器——往里面写 0 和 1src0Pos本身就是 UV着色器里完全不需要归一化。当效果真的锚定在目标空间时例如围绕移动四边形的暗角 vignette把绘制的 origin 和 size 作为 uniform 传入用它们归一化dstPos。没有任何内置函数报告它们imageDstOrigin/imageDstSize只描述目标图像。对应地SKILL.md 与 skills/writing-kage-shaders/reference/porting-to-kage.md 中的坐标对照表也都标注了 † 号提醒仅当绘制覆盖整张目标图像时成立。变换一个坐标减原点 → 变换 → 加原点任何非平移操作除法、floor(p/cell)*cell像素化、mod平铺、旋转矩阵、镜像都必须先在 origin 相对空间里做。绕图像中心旋转func rotateAroundCenter(src0Pos vec2, angle float) vec2 { p : src0Pos - imageSrc0Origin() - imageSrc0Size()/2 s, c : sin(angle), cos(angle) p vec2(p.x*c-p.y*s, p.x*sp.y*c) return p imageSrc0Size()/2 imageSrc0Origin() }先减去原点再减去半尺寸把坐标系移到图像中心旋转后按原样加回。采样另一张源图像四个源槽共享图像 0 的坐标空间所以直接把src0Pos原样传过去即可无论各图像尺寸如何func Fragment(dstPos vec4, src0Pos vec2, color vec4) vec4 { return imageSrc0At(src0Pos) * imageSrc1AtFromSrc0Pos(src0Pos).a }imageSrcNAtFromSrc0Pos和imageSrcNUnsafeAtFromSrc0PosN ≥ 1是 v2.10 新增的。imageSrcNAt与imageSrcNUnsafeAt是它们改名前的同名函数仍然可用但自 v2.10 起弃用——仓库 internal/legacyshader/deprecated.go 中的注释即为证。函数名的后缀就是规则它们接收图像 0 坐标空间里的位置。底层实现internal/graphics/shader.go会重定位取pos - imageSrc0Origin() imageSrcNOrigin()并且imageSrcNAtFromSrc0Pos在越界时返回vec4(0)。因此图像 0 的像素 (x, y) 与图像 N 的像素 (x, y) 对齐尺寸不同无需修正图像 N 比图像 0 小时越界处直接返回vec4(0)。想要把图像 1拉伸覆盖整张图像 0而不是逐像素对齐就在图像 0 的空间里归一化、按图像 1 的尺寸缩放并加回图像 0 的原点。结果仍是图像 0 空间中的位置这正是imageSrc1AtFromSrc0Pos所需的func src0PosForStretchedSrc1(src0Pos vec2) vec2 { uv : (src0Pos - imageSrc0Origin()) / imageSrc0Size() return uv*imageSrc1Size() imageSrc0Origin() }经典错误是这里加imageSrc1Origin()——原点是函数内部自己加的再加一次等于把 atlas 偏移算了两次会采样到 atlas 上无关的部分。imageSrcNOrigin()N ≥ 1几乎从不出现在采样表达式里imageSrc0Origin()才是那个重要的原点。寻址模式clamp 与 repeatimageSrc0At和imageSrcNAtFromSrc0Pos在图像外返回vec4(0)等价于 clamp-to-zero。想要另外两种行为需自己实现// Clamp to edge. func clampToEdge(pos vec2) vec2 { const epsilon 0.001 origin : imageSrc0Origin() return clamp(pos, origin, originimageSrc0Size()-vec2(epsilon)) } // Repeat. func repeat(pos vec2) vec2 { origin : imageSrc0Origin() return mod(pos-origin, imageSrc0Size()) origin }两者都产生证明在区域内的位置——这正是imageSrc0UnsafeAt的前置条件它跳过边界检查在区域外结果是未定义的可能是 atlas 的 padding、相邻图像的像素、零或其他东西取决于位置和后端。所以 unsafe 形式只应在 clamp 或 wrap 之后使用。repeat的mod(pos-origin, size) origin与 Ebitengine 内置的adjustSrcPosForAddressRepeat见 internal/builtinshader/defs.go如出一辙可以互相印证。注意clampToEdge钳制到图像区域[origin, originsize)钳制到origin的坐标会让第一个采样点落在origin-0.5实际在图像之外——这是钳制区域不是钳制纹素中心见下节。双线性采样Kage 的采样永远是最近邻没有线性过滤、没有 mipmap、没有 wrap 模式——ebiten.Filter/ebiten.Address只作用于DrawImage/DrawTriangles不作用于自定义着色器。绘制放大时需手工插值func bilinearAt(pos vec2) vec4 { p : pos - vec2(0.5) f : fract(p) base : floor(p) vec2(0.5) tl : imageSrc0At(base) tr : imageSrc0At(base vec2(1, 0)) bl : imageSrc0At(base vec2(0, 1)) br : imageSrc0At(base vec2(1, 1)) return mix(mix(tl, tr, f.x), mix(bl, br, f.x), f.y) }四个 tap 都用imageSrc0At会在图像边缘表现为 clamp-to-zero距边界半个像素内一两个 tap 落到区域外、贡献vec4(0)放大时就会出现暗边或透明边。要 clamp-to-edge 或 repeat就对每个 tap 施加寻址变换这同时让每个 tap 都证明在区域内func bilinearClampedAt(pos vec2) vec4 { p : pos - vec2(0.5) f : fract(p) base : floor(p) vec2(0.5) tl : imageSrc0UnsafeAt(clampToEdge(base)) tr : imageSrc0UnsafeAt(clampToEdge(base vec2(1, 0))) bl : imageSrc0UnsafeAt(clampToEdge(base vec2(0, 1))) br : imageSrc0UnsafeAt(clampToEdge(base vec2(1, 1))) return mix(mix(tl, tr, f.x), mix(bl, br, f.x), f.y) }把clampToEdge换成repeat就是平铺wrapping。另一种钳制思路先对传入的pos钳制一次、再派生出 tap也能得到 clamp-to-edge——但只能钳制到纹素中心范围[origin0.5, originsize-0.5]而不是clampToEdge算的那个区域范围func clampToTexelCenters(pos vec2) vec2 { origin : imageSrc0Origin() return clamp(pos, originvec2(0.5), originimageSrc0Size()-vec2(0.5)) }这样内部插值得以保留最远边缘处每对 tap 的第二个采样点恰好落在最后一个中心外一个纹素的位置权重精确为零。此时配对输入钳制要配imageSrc0At——它的vec4(0)在权重为零时无害不要配imageSrc0UnsafeAt未定义值乘零不必等于零。像素化Pixelation固定单元大小取每个单元中心的颜色const CellSize 12.0 func Fragment(dstPos vec4, src0Pos vec2, color vec4) vec4 { origin : imageSrc0Origin() cell : floor((src0Pos-origin)/CellSize)*CellSize vec2(CellSize/2) return imageSrc0At(cell origin) }对整个单元取平均需要循环且循环界必须是常量Kage 只接受常量界的forconst CellSize 12.0 func Fragment(dstPos vec4, src0Pos vec2, color vec4) vec4 { origin : imageSrc0Origin() cell : floor((src0Pos-origin)/CellSize) * CellSize var acc vec4 for y : 0.0; y CellSize; y { for x : 0.0; x CellSize; x { acc imageSrc0At(cell vec2(x, y) origin) } } return acc / (CellSize * CellSize) }此处CellSize必须是整数值否则循环次数和除数不一致。由 uniform 控制的循环次数Kage 没有 uniform 控制的循环界。技巧是循环到常量上界、在 uniform 处break。var CellSize float const MaxCellSize 32.0 func Fragment(dstPos vec4, src0Pos vec2, color vec4) vec4 { // Flooring keeps cells on an integer grid: a fractional size puts adjacent // cell origins at fractional positions, so neighbouring cells sample // overlapping texels under nearest sampling. Clamping keeps the divisor // non-zero and the loop bound within the constants reach. size : floor(clamp(CellSize, 1, MaxCellSize)) origin : imageSrc0Origin() cell : floor((src0Pos-origin)/size) * size var acc vec4 var n float for y : 0.0; y MaxCellSize; y { if y size { break } for x : 0.0; x MaxCellSize; x { if x size { break } acc imageSrc0At(cell vec2(x, y) origin) n } } return acc / n }floor让单元落在整数网格上分数尺寸会让相邻单元原点落在分数位置最近邻采样下相邻单元会采样到重叠的纹素clamp保证除数非零、循环界在常量可达范围内。size量化后n恒等于size*size但计数仍然值得保留——如果去掉floor以允许平滑动画的单元尺寸循环每轴会跑ceil(size)次此时n依然正确。注意那种情况下单元会重叠网格不再对齐纹素。可分离核水平模糊整像素偏移不需要原点运算因为原点会抵消imageSrc0At(src0Pos vec2(1, 0))就是相邻纹素。只有缩放坐标才需要减-变换-加三明治。const Radius 4 func blurH(pos vec2) vec4 { var acc vec4 var weight float for i : -Radius; i Radius; i { w : 1.0 - abs(float(i))/(Radius1) acc imageSrc0At(posvec2(float(i), 0)) * w weight w } return acc / weight }三角形权重核归一化保证亮度不变垂直方向对称实现即可组合成可分离模糊。预乘 alphaEbitengine 处处使用预乘 alpha着色器也不例外采样返回预乘颜色你返回的vec4必须预乘逐分量rgb ≤ a没有任何东西帮你 clamp非法颜色会渲染成过亮、洗白的混合结果。// Fade: scale the whole vector, never the alpha alone. Scaling all four // components commutes with premultiplication, so this matches what the // straight-alpha equivalent would produce. func fade(clr vec4, t float) vec4 { return clr * t } // Round-trip to straight alpha, needed by any operation that does not commute // with premultiplication — gamma among them — and by mix between samples // whose alphas differ. Addition needs each input converted as well, but // converting is not the whole fix: defines no output alpha, so the port // must also reproduce whatever policy the original used. func withStraightRGB(clr vec4) vec4 { rgb : clr.rgb / max(clr.a, 1e-6) rgb pow(rgb, vec3(1.0/2.2)) // whatever the computation is return vec4(rgb*clr.a, clr.a) } // A color written as 0..255 channels, premultiplied. func rgba8(r, g, b, a float) vec4 { return vec4(r/255*(a/255), g/255*(a/255), b/255*(a/255), a/255) }要点淡入淡出要缩放整个向量clr * t绝不只缩放 alpha换算回直通 alpha 后再计算、再预乘回去除法要防零max(clr.a, 1e-6)。mix看似线性但在预乘值上它按预乘空间插值——只有当两个 alpha 相等时才与直通 RGB 结果一致而且它也不是 source-over 合成。连alpha 相等这个例外都没有它没有自己的输出 alpha 策略必须连同原效果的输出 alpha 策略一起复刻。判断某个 RGB 运算能否留在预乘空间标准是它是否与预乘可交换F(a*rgb) a*F(rgb)缩放整个向量可以恒定逐通道 tintalpha 因子为 1可以min/max对零这类正齐次运算也可以gamma、带固定枢轴的对比度曲线、多数pow指数、对固定非零值的阈值都不行。HSV 与 HSL 要分别判断——HSV 中色相/饱和度在正缩放下不变、只有明度携带缩放只动色相饱和度的运算可交换HSL 的饱和度在L 0.5时是(max-min)/(maxmin)缩放不变、L ≥ 0.5时是(max-min)/(2-max-min)固定白点导致缩放依赖常见的min(s*k, 1)足以破坏可交换性。没有确证等价就转换。无分支选择器当分支会在波前wavefront内大面积发散时有用短分支用普通if即可。func whenGreaterOrEqual(a, b float) float { return step(b, a) } func whenLess(a, b float) float { return 1 - step(b, a) } func whenEqual(a, b float) float { return 1 - abs(sign(a-b)) } // Pick a when sel is 0, b when sel is 1. func pick(a, b vec4, sel float) vec4 { return mix(a, b, sel) }step(b, a)在a b时返回 1mix(a, b, sel)在sel为 0/1 时精确选出 a/b。调试把坐标当作颜色移植任何效果之前先把坐标作为颜色返回并观察结果。正确的目标渐变应该是目标图像的左上角为黑、向右变红、向下变绿——如果绘制只覆盖该图像的一部分渐变横跨整张图像绘制区域只显示其中一片。func Fragment(dstPos vec4, src0Pos vec2, color vec4) vec4 { uv : (dstPos.xy - imageDstOrigin()) / imageDstSize() return vec4(uv, 0, 1) }如果渐变偏移、被裁剪或图像只在某些尺寸下看起来正确就是某个原点丢失了。用src0Pos/imageSrc0*替换可同样地检查源侧。这也呼应 SKILL.md 的移植流程先移植坐标处理并单独验证返回vec4(dstUV, 0, 1)确认图像上出现干净的红/绿渐变再移植效果本体——几乎所有效果偏移/平铺错误/只在图像恰好是整张 atlas 时正常的 bug 都是丢了一个原点。无 GPU 的编译验证写完配方后用普通go test即可验证编译ebiten.NewShader不需要 GPU、窗口或显示器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) } }错误按line:column: message报告。两个限制这只验证 Kage 本身GLSL/HLSL/MSL 转换发生在图形驱动首次使用时后端级拒绝递归、驱动特定限制要到绘制时才会暴露它也无法捕捉结果错误——那需要配合 skills/run-ebitengine-app-headless 无头渲染并逐像素比对。.kage文件不需要 build 约束Go 工具链会忽略该扩展名//go:build ignore只用于以.go扩展名保存 Kage 源码的场景本仓库 examples/shader 即如此让 Go 工具跳过它。结语这套配方围绕一个核心思想展开在 atlas 相对坐标系中先减原点、变换、再加回原点。无论是 0..1 归一化、绘制区域坐标、旋转、跨图像采样、clamp/repeat 寻址、双线性插值还是像素化单元计算全部建立在这一惯例之上。调试用的颜色渐变则提供了验证它的最快手段。想系统了解坐标模型与逐结构移植对照请阅读 skills/writing-kage-shaders/SKILL.md 与 skills/writing-kage-shaders/reference/porting-to-kage.md。赞分享游戏开发图形学【免费下载链接】ebitenA dead simple 2D game engine for Go项目地址https://gitcode.com/GitHub_Trending/eb/ebiten点击查看免费下载相关推荐protobuf-ts与JSON性能对比为什么选择二进制序列化protobuf ts与JSON性能对比为什么选择二进制序列化 在现代应用开发中数据序列化格式的选择直接影响系统性能。 protobuf ts 作为Typ7个最常见的Ebitengine Kage着色器编译错误及解决方案7个最常见的Ebitengine Kage着色器编译错误及解决方案 你是否曾在开发Ebitengine游戏时因Kage着色器编译错误而卡壳数小时本文将系统梳游戏开发图形学解密音频中的对话pyannote.audio如何让机器理解谁在何时说话解密音频中的对话pyannote.audio如何让机器理解谁在何时说话 在当今音频内容爆炸的时代会议记录、播客分析、客服质检等场景都面临一个共同挑战如何自人工智能语音音频深度学习预训练微调上一篇如何快速上手ColBERTv1.95步实现高效文本检索的终极教程下一篇WebRTC Medooze Media Server实战教程构建实时视频会议系统创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表