fix(render): 修复白屏/黑屏——视图矩阵、UI着色器、网格构建性能与安装目录

- 相机视图矩阵按 R_cam^T·T(-pos) 正确推导(旧布局非正交,非零视角下场景塌缩为灰屏)
- UI 片元着色器改为纹理×颜色(旧实现丢弃纹理 RGB,彩色纹理全白)
- UI 纹理与图集 mipmap:图集补齐 2 次幂、UI 上传不生成 mipmap(NPOT 不完整纹理采样纯白)
- 网格构建持一次读锁(旧实现每格邻居查询各加锁约 40 万次/区块,出生区块迟迟无网格,
  玩家透过脚下的洞看到地下横截面——进游戏黑屏主因);脏区块按距离排序优先构建
- 开启背面剔除(CW 正面):区块边界面向未加载邻居的内侧面不再以暗色出现在视野里
- 出生逻辑:等区块加载后定位地表出生,相机略微俯视
- dev 截图测试:-shot 等待玩家所在 5×5 区块网格就绪再拍(48s 兜底),先测试后打包
- 安装器改为按用户安装到 LOCALAPPDATA(Program Files 无写权限导致启动即崩)
- dev 截图工具独立成 devshot_gl.go
This commit is contained in:
NianGao Dev
2026-08-16 08:33:08 +08:00
parent 6dd5a8122a
commit fc151e7c07
9 changed files with 207 additions and 50 deletions

View File

@@ -14,7 +14,7 @@ import (
// Build 打包纹理键列表为图集。
// resolve:纹理键 → PNG 文件路径;缺失纹理跳过(键不进入 uv 表,运行时用占位纹理)。
// 列数取「大于等于根号的整数」,使图集接近正方形(GPU 友好)。
// 尺寸取 2 的次幂(mipmap 兼容:非 2 次幂图集在部分驱动上 GenerateMipmap 失败会致纹理不完整)。
func Build(resolve func(key string) (string, bool), keys []string, tileSize int) (image.Image, map[string]image.Rectangle, error) {
if len(keys) == 0 {
return nil, nil, fmt.Errorf("atlas.Build: 纹理列表为空")
@@ -27,6 +27,9 @@ func Build(resolve func(key string) (string, bool), keys []string, tileSize int)
cols++
}
rows := (len(keys) + cols - 1) / cols
// 补齐到 2 的次幂(mipmap 要求)
cols = nextPow2(cols)
rows = nextPow2(rows)
img := image.NewRGBA(image.Rect(0, 0, cols*tileSize, rows*tileSize))
uv := make(map[string]image.Rectangle, len(keys))
@@ -50,6 +53,15 @@ func Build(resolve func(key string) (string, bool), keys []string, tileSize int)
return img, uv, nil
}
// nextPow2 返回不小于 n 的最小 2 次幂。
func nextPow2(n int) int {
p := 1
for p < n {
p <<= 1
}
return p
}
// loadPNG 解码 PNG。
func loadPNG(path string) (image.Image, error) {
f, err := os.Open(path)

View File

@@ -32,26 +32,27 @@ func (c *Camera) Right() [3]float64 {
}
// View 视图矩阵(列主序 4×4,GL 用)。
// 推导:相机旋转 R_cam = RotY(yaw)·RotX(pitch);视图 = R_camᵀ·T(-pos)。
// 旧实现矩阵布局错误导致非零视角下画面全灰(白屏/灰屏根因之一)。
func (c *Camera) View() [16]float32 {
// 旋转 = RotX(-pitch) × RotY(-yaw),再平移 -pos
cp, sp := math.Cos(-c.Pitch), math.Sin(-c.Pitch)
cy, sy := math.Cos(-c.Yaw), math.Sin(-c.Yaw)
cp, sp := math.Cos(c.Pitch), math.Sin(c.Pitch)
cy, sy := math.Cos(c.Yaw), math.Sin(c.Yaw)
// R = RotY(-yaw) × RotX(-pitch) 的计算结果(行主序推导后转列主序)
// 列主序布局: m[col*4+row]
m := [16]float32{}
var m [16]float32
// 列 0
m[0] = float32(cy)
m[1] = float32(-sy * sp)
m[2] = float32(-sy * cp)
m[4] = float32(sy)
m[5] = float32(cy * sp)
m[6] = float32(cy * cp)
m[8] = 0
m[9] = float32(cp)
m[10] = float32(-sp)
m[1] = 0
m[2] = float32(sy)
// 列 1
m[4] = float32(-sy * sp)
m[5] = float32(cp)
m[6] = float32(cy * sp)
// 列 2
m[8] = float32(-sy * cp)
m[9] = float32(-sp)
m[10] = float32(cy * cp)
m[15] = 1
// 平移部分:t = -R·pos
// 平移:t = -R·pos
px, py, pz := float32(c.Pos[0]), float32(c.Pos[1]), float32(c.Pos[2])
m[12] = -(m[0]*px + m[4]*py + m[8]*pz)
m[13] = -(m[1]*px + m[5]*py + m[9]*pz)

View File

@@ -13,6 +13,12 @@ import (
// chunkKey 区块坐标 → key(与 world 包一致)。
func chunkKey(cx, cz int32) uint64 { return uint64(uint32(cx))<<32 | uint64(uint32(cz)) }
// HasChunk 区块网格是否已上传(dev 截图测试等待视野就绪用)。
func (r *Renderer) HasChunk(cx, cz int32) bool {
_, ok := r.chunks[chunkKey(cx, cz)]
return ok
}
// UploadChunk 上传区块网格到 GPU(dirty 网格重建后调用,渲染.md §3.3)。
// 三类网格合并为单 VBO + 单 IBO:不透明 → cutout → 半透明 顺序连续存放。
func (r *Renderer) UploadChunk(cx, cz int32, m *mesh.ChunkMesh) {

View File

@@ -80,6 +80,11 @@ func New(title string, width, height int) (*Renderer, error) {
gl.Enable(gl.DEPTH_TEST)
gl.Enable(gl.BLEND)
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
// 背面剔除:区块边界面向未加载邻居的内侧面从外部看是背面,不剔除会以暗色贴图
// 出现在视野里("脚下黑块"根因);同时省一半片元工作量。
// 本渲染器方块网格按 CW 绕序定义正面(mesh.faceDef 角顺序)。
gl.Enable(gl.CULL_FACE)
gl.FrontFace(gl.CW)
gl.ClearColor(0.53, 0.81, 0.92, 1) // 天空蓝
return r, nil
}

View File

@@ -146,14 +146,15 @@ func textrWidth(at *font.Atlas, s string) int {
}
// 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.GenerateMipmap(gl.TEXTURE_2D)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST_MIPMAP_LINEAR)
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)
@@ -274,7 +275,8 @@ void main() {
}
`
// uiFragSrc UI 片元着色器(纹理 × 颜色,alpha 混合)。
// uiFragSrc UI 片元着色器:纹理色 × 颜色 tint(alpha 混合)。
// 注意:必须输出纹理 RGB——否则彩色纹理(按钮/背景)会被丢弃成纯白(白屏根因)。
const uiFragSrc = `
#version 330 core
in vec2 vUV;
@@ -283,7 +285,7 @@ uniform sampler2D uTex;
out vec4 fragColor;
void main() {
vec4 t = texture(uTex, vUV);
fragColor = vec4(vColor.rgb, vColor.a) * vec4(1.0, 1.0, 1.0, t.a);
fragColor = t * vColor;
}
`

View File

@@ -32,7 +32,7 @@ const (
// World 世界实例。
type World struct {
mu sync.Mutex
mu sync.RWMutex
chunks map[uint64]*chunk.Chunk
states map[uint64]chunkState
@@ -170,9 +170,31 @@ func (w *World) Block(x, y, z int32) block.State {
if y < 0 || y >= chunk.Height {
return block.Air
}
w.mu.Lock()
w.mu.RLock()
ch := w.chunks[coordKey(coord.ChunkCoord(x), coord.ChunkCoord(z))]
w.mu.RUnlock()
if ch == nil {
return block.Air
}
return ch.Block(int(coord.LocalCoord(x)), int(y), int(coord.LocalCoord(z)))
}
// WithReadLock 在持有世界读锁期间执行 fn。
// 网格构建用它包住整个 Build:邻居查询走 BlockUnlocked,免去每格一次加锁
// (旧实现每个面邻居查询各加一次锁,单区块约 40 万次加锁,mesh 构建极慢,
// 出生区块迟迟无网格,玩家透过脚下的洞看到相邻区块地下横截面——"进游戏黑屏"主因之一)。
func (w *World) WithReadLock(fn func()) {
w.mu.RLock()
defer w.mu.RUnlock()
fn()
}
// BlockUnlocked 无锁读方块(仅在 WithReadLock 回调内调用;未加载区块与世界边界视为空气)。
func (w *World) BlockUnlocked(x, y, z int32) block.State {
if y < 0 || y >= chunk.Height {
return block.Air
}
ch := w.chunks[coordKey(coord.ChunkCoord(x), coord.ChunkCoord(z))]
w.mu.Unlock()
if ch == nil {
return block.Air
}