Files
ngzz-mc/internal/ui/ui.go
2026-09-19 14:21:08 +08:00

344 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build gl
// Package ui 2D UI 渲染器正交投影、四边形批处理、位图字体文本、九宫格UI还原.md §3
//
// 与 3D 渲染共用 GL 上下文但状态隔离UI与输入.md §2每帧 3D 之后调用,
// 内部保存/恢复必要状态。
package ui
import (
"fmt"
"image"
"strings"
"mc/internal/font"
"mc/internal/textr"
"github.com/go-gl/gl/v3.3-core/gl"
)
// quad 一个四边形屏幕像素坐标y 轴向下)。
type quad struct {
x, y, w, h float32
u0, v0, u1, v1 float32
r, g, b, a float32
tex uint32
}
// Renderer UI 渲染器。
type Renderer struct {
prog uint32
vao, vbo uint32
texWhite uint32
fontTex uint32
fontAtlas *font.Atlas
texCache map[string]uint32 // 长文本光栅化纹理缓存
quads []quad
w, h int32
scale float32 // 像素缩放guiScale
}
// New 创建 UI 渲染器(窗口逻辑分辨率 × 缩放)。
func New(winW, winH int, scale float32) (*Renderer, error) {
r := &Renderer{w: int32(winW), h: int32(winH), scale: scale, texCache: make(map[string]uint32)}
vs, err := compileShader(gl.VERTEX_SHADER, uiVertSrc)
if err != nil {
return nil, fmt.Errorf("ui.New 顶点着色器: %w", err)
}
fs, err := compileShader(gl.FRAGMENT_SHADER, uiFragSrc)
if err != nil {
return nil, fmt.Errorf("ui.New 片元着色器: %w", err)
}
prog := gl.CreateProgram()
gl.AttachShader(prog, vs)
gl.AttachShader(prog, fs)
gl.LinkProgram(prog)
var ok int32
gl.GetProgramiv(prog, gl.LINK_STATUS, &ok)
if ok == 0 {
return nil, fmt.Errorf("ui.New: 着色器链接失败")
}
gl.DeleteShader(vs)
gl.DeleteShader(fs)
r.prog = prog
gl.GenVertexArrays(1, &r.vao)
gl.GenBuffers(1, &r.vbo)
// 白色占位纹理
gl.GenTextures(1, &r.texWhite)
gl.BindTexture(gl.TEXTURE_2D, r.texWhite)
gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr([]uint8{255, 255, 255, 255}))
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
return r, nil
}
// Resize 更新窗口像素尺寸与整数 guiScale渲染.md §5.4、UI还原.md §3.1)。
func (r *Renderer) Resize(winW, winH int, scale float32) {
if winW < 1 {
winW = 1
}
if winH < 1 {
winH = 1
}
r.w, r.h = int32(winW), int32(winH)
if scale < 1 {
scale = 1
}
r.scale = scale
}
// SetFont 设置位图字体UI还原.md §4上传字形图集纹理
func (r *Renderer) SetFont(at *font.Atlas) {
r.fontAtlas = at
if at.Image == nil {
return
}
if r.fontTex == 0 {
gl.GenTextures(1, &r.fontTex)
}
gl.BindTexture(gl.TEXTURE_2D, r.fontTex)
b := at.Image.Bounds()
gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, int32(b.Dx()), int32(b.Dy()), 0, gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr(imageBytesUI(at.Image)))
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
}
// DrawImage 按像素尺寸画一张已上传纹理。
func (r *Renderer) DrawImage(tex uint32, x, y, w, h float32) {
if r == nil || tex == 0 || w <= 0 || h <= 0 {
return
}
r.pushQuad(quad{x: x, y: y, w: w, h: h, u0: 0, v0: 0, u1: 1, v1: 1,
r: 1, g: 1, b: 1, a: 1, tex: tex})
}
// DrawRect 绘制纯色矩形。
func (r *Renderer) DrawRect(x, y, w, h float32, rgba [4]float32) {
r.pushQuad(quad{x: x, y: y, w: w, h: h, u0: 0, v0: 0, u1: 1, v1: 1,
r: rgba[0], g: rgba[1], b: rgba[2], a: rgba[3], tex: r.texWhite})
}
// DrawText 绘制文本ASCII 位图字形,含 1px 阴影UI还原.md §4
func (r *Renderer) DrawText(x, y float32, s string, scale float32, rgba [4]float32) {
if r.fontAtlas == nil {
return
}
s = strings.ReplaceAll(s, "§", "") // MVP忽略颜色码后置支持
cx := x
for _, ch := range s {
g, ok := r.fontAtlas.Glyphs[ch]
if !ok {
g = r.fontAtlas.Missing()
}
px := float32(g.X) / float32(r.fontAtlas.Image.Bounds().Dx())
py := float32(g.Y) / float32(r.fontAtlas.Image.Bounds().Dy())
pw := float32(g.W) / float32(r.fontAtlas.Image.Bounds().Dx())
ph := float32(g.H) / float32(r.fontAtlas.Image.Bounds().Dy())
gw := float32(g.W) * scale
gh := float32(g.H) * scale
// 阴影(黑色,偏移 1px
r.pushQuad(quad{x: cx + 1*scale, y: y + 1*scale, w: gw, h: gh, u0: px, v0: py, u1: px + pw, v1: py + ph,
r: 0.1, g: 0.1, b: 0.1, a: rgba[3] * 0.8, tex: r.fontTex})
r.pushQuad(quad{x: cx, y: y, w: gw, h: gh, u0: px, v0: py, u1: px + pw, v1: py + ph,
r: rgba[0], g: rgba[1], b: rgba[2], a: rgba[3], tex: r.fontTex})
cx += float32(g.Width) * scale
}
}
// TextWidth 文本像素宽度(布局用)。
func (r *Renderer) TextWidth(s string, scale float32) float32 {
if r.fontAtlas == nil {
return float32(len(s)) * 8 * scale
}
return float32(textrWidth(r.fontAtlas, s)) * scale
}
// textrWidth 计算文本宽度(复用 textr 逻辑的轻量版)。
func textrWidth(at *font.Atlas, s string) int {
w := 0
for _, ch := range s {
if g, ok := at.Glyphs[ch]; ok {
w += g.Width
} else {
w += 8
}
}
return w
}
// UploadImage 上传图像为纹理并返回 ID物品图标/长文本用)。
// 注意UI 纹理按 1:1 像素风绘制,不使用 mipmap——
// 非 2 次幂纹理 GenerateMipmap 在部分驱动上失败会导致纹理不完整、采样为纯白(白屏根因)。
func (r *Renderer) UploadImage(img image.Image) uint32 {
var tex uint32
gl.GenTextures(1, &tex)
gl.BindTexture(gl.TEXTURE_2D, tex)
b := img.Bounds()
gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, int32(b.Dx()), int32(b.Dy()), 0, gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr(imageBytesUI(img)))
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
return tex
}
// DeleteTexture 释放 UploadImage 产生的纹理Loading 刷新日志行时复用)。
func (r *Renderer) DeleteTexture(id uint32) {
if r == nil || id == 0 {
return
}
gl.DeleteTextures(1, &id)
}
// Begin 开始一帧 UI 绘制(清空批次)。
func (r *Renderer) Begin() { r.quads = r.quads[:0] }
// Flush 提交批次(切换投影 → 绘制 → 恢复状态)。
func (r *Renderer) Flush() {
if len(r.quads) == 0 {
return
}
// 保存 3D 状态
gl.Disable(gl.DEPTH_TEST)
gl.UseProgram(r.prog)
// 正交投影像素坐标y 向下)
var proj [16]float32
ortho2D(&proj, 0, float32(r.w), float32(r.h), 0)
projLoc := gl.GetUniformLocation(r.prog, gl.Str("uProj\x00"))
gl.UniformMatrix4fv(projLoc, 1, false, &proj[0])
sampLoc := gl.GetUniformLocation(r.prog, gl.Str("uTex\x00"))
gl.Uniform1i(sampLoc, 0)
// 批处理:合并为顶点数组(每 quad 6 顶点,含纹理 ID 通过多次绘制分组)
gl.BindVertexArray(r.vao)
gl.BindBuffer(gl.ARRAY_BUFFER, r.vbo)
gl.EnableVertexAttribArray(0) // pos
gl.EnableVertexAttribArray(1) // uv
gl.EnableVertexAttribArray(2) // color
stride := int32(9 * 4)
currentTex := uint32(0xFFFFFFFF)
flushBatch := func(tex uint32, verts []float32) {
if len(verts) == 0 {
return
}
gl.ActiveTexture(gl.TEXTURE0)
gl.BindTexture(gl.TEXTURE_2D, tex)
gl.BufferData(gl.ARRAY_BUFFER, len(verts)*4, gl.Ptr(verts), gl.STREAM_DRAW)
gl.VertexAttribPointer(0, 2, gl.FLOAT, false, stride, gl.PtrOffset(0))
gl.VertexAttribPointer(1, 2, gl.FLOAT, false, stride, gl.PtrOffset(2*4))
gl.VertexAttribPointer(2, 4, gl.FLOAT, false, stride, gl.PtrOffset(4*4))
gl.DrawArrays(gl.TRIANGLES, 0, int32(len(verts)/9))
}
var batch []float32
for _, q := range r.quads {
if q.tex != currentTex {
flushBatch(currentTex, batch)
batch = batch[:0]
currentTex = q.tex
}
batch = append(batch, quadVertices(q)...)
}
flushBatch(currentTex, batch)
gl.BindVertexArray(0)
gl.Enable(gl.DEPTH_TEST)
r.quads = r.quads[:0]
}
// pushQuad 入批。
func (r *Renderer) pushQuad(q quad) { r.quads = append(r.quads, q) }
// quadVertices 四边形 → 6 顶点2 三角形)。
func quadVertices(q quad) []float32 {
x0, y0 := q.x, q.y
x1, y1 := q.x+q.w, q.y+q.h
return []float32{
x0, y0, q.u0, q.v0, q.r, q.g, q.b, q.a, 0,
x1, y0, q.u1, q.v0, q.r, q.g, q.b, q.a, 0,
x1, y1, q.u1, q.v1, q.r, q.g, q.b, q.a, 0,
x0, y0, q.u0, q.v0, q.r, q.g, q.b, q.a, 0,
x1, y1, q.u1, q.v1, q.r, q.g, q.b, q.a, 0,
x0, y1, q.u0, q.v1, q.r, q.g, q.b, q.a, 0,
}
}
// ortho2D 正交投影矩阵(列主序,近 -1 远 1
func ortho2D(m *[16]float32, l, r, b, t float32) {
*m = [16]float32{}
m[0] = 2 / (r - l)
m[5] = 2 / (t - b)
m[10] = -1
m[12] = -(r + l) / (r - l)
m[13] = -(t + b) / (t - b)
m[15] = 1
}
// imageBytesUI image.Image → RGBA 字节流。
func imageBytesUI(img image.Image) []byte {
b := img.Bounds()
out := make([]byte, 0, b.Dx()*b.Dy()*4)
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
cr, cg, cb, ca := img.At(x, y).RGBA()
out = append(out, byte(cr>>8), byte(cg>>8), byte(cb>>8), byte(ca>>8))
}
}
return out
}
// uiVertSrc UI 顶点着色器。
const uiVertSrc = `
#version 330 core
layout(location = 0) in vec2 aPos;
layout(location = 1) in vec2 aUV;
layout(location = 2) in vec4 aColor;
uniform mat4 uProj;
out vec2 vUV;
out vec4 vColor;
void main() {
gl_Position = uProj * vec4(aPos, 0.0, 1.0);
vUV = aUV;
vColor = aColor;
}
`
// uiFragSrc UI 片元着色器:纹理色 × 颜色 tintalpha 混合)。
// 注意:必须输出纹理 RGB——否则彩色纹理按钮/背景)会被丢弃成纯白(白屏根因)。
const uiFragSrc = `
#version 330 core
in vec2 vUV;
in vec4 vColor;
uniform sampler2D uTex;
out vec4 fragColor;
void main() {
vec4 t = texture(uTex, vUV);
fragColor = t * vColor;
}
`
// compileShader 编译着色器(与 render 包一致的简化版)。
func compileShader(kind uint32, src string) (uint32, error) {
s := gl.CreateShader(kind)
cs, free := gl.Strs(src + "\x00")
gl.ShaderSource(s, 1, cs, nil)
free()
gl.CompileShader(s)
var ok int32
gl.GetShaderiv(s, gl.COMPILE_STATUS, &ok)
if ok == 0 {
var logLen int32
gl.GetShaderiv(s, gl.INFO_LOG_LENGTH, &logLen)
log := strings.Repeat(" ", int(logLen))
gl.GetShaderInfoLog(s, logLen, nil, gl.Str(log))
return 0, fmt.Errorf("着色器编译失败: %s", log)
}
return s, nil
}
var _ = textr.New // 保持引用(长文本光栅化路径后置启用)