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

@@ -11,6 +11,7 @@ import (
"flag"
"os"
"os/signal"
"sort"
"sync"
"time"
@@ -18,6 +19,7 @@ import (
"mc/internal/atlas"
"mc/internal/block"
"mc/internal/camera"
"mc/internal/chunk"
"mc/internal/config"
"mc/internal/crash"
"mc/internal/engine"
@@ -41,6 +43,12 @@ import (
var devMode = flag.Bool("dev", devDefault, "开发模式:直接读取 assets/(默认随构建 tag)")
// 开发测试参数(dev 测试通过后再打包)
var (
autoEnter = flag.Bool("auto", false, "自动进入游戏(跳过主菜单点击)")
shotPath = flag.String("shot", "", "截图路径:运行约 2 秒后保存 PNG 并退出(dev 测试用)")
)
// clientApp 客户端应用:把会话与渲染粘合起来(engine.Ticker + engine.Renderer)。
type clientApp struct {
sess *game.Session
@@ -70,6 +78,7 @@ type clientApp struct {
winW, winH int
tickCnt uint64
frames int // 渲染帧计数(截图时机用)
lastTime time.Time
}
@@ -84,6 +93,12 @@ func (a *clientApp) Tick(dt float64) {
// 屏幕状态机(UI还原.md §2:主菜单/暂停不处理游戏输入)
switch a.screen {
case 0: // 主菜单
// dev 测试:-auto 自动进入游戏(跳过点击)
if *autoEnter {
a.screen = 1
a.rend.SetCursorMode(true)
return
}
if a.in.clicked {
switch a.titleScreen.HitTest(a.in.clickX, a.in.clickY) {
case "play":
@@ -157,22 +172,38 @@ func (a *clientApp) Tick(dt float64) {
}
}
// 脏区块网格重建(CPU 侧,预算内)
// 脏区块网格重建(CPU 侧,预算内;按距玩家距离排序,先建视野中心——
// 随机顺序会致出生区块迟迟无网格,玩家脚下的地面"透明",看到相邻区块地下横截面(黑)。
start := time.Now()
px, pz := a.sess.Player.Pos[0], a.sess.Player.Pos[2]
type dirtyJob struct {
c *chunk.Chunk
d float64
}
var dirty []dirtyJob
for _, c := range a.sess.World.ActiveChunks() {
if time.Since(start) > 6*time.Millisecond {
break // 帧预算(区块管理.md §4.3.2)
}
if !c.Dirty.Load() {
continue
}
m := a.meshBuilder.Build(c, func(x, y, z int32) block.State {
return a.sess.World.Block(x, y, z)
dx := float64(c.CX)*16 + 8 - px
dz := float64(c.CZ)*16 + 8 - pz
dirty = append(dirty, dirtyJob{c: c, d: dx*dx + dz*dz})
}
sort.Slice(dirty, func(i, j int) bool { return dirty[i].d < dirty[j].d })
for _, dj := range dirty {
if time.Since(start) > 6*time.Millisecond {
break // 帧预算(区块管理.md §4.3.2)
}
// 整个构建持一次读锁:邻居查询免逐次加锁(渲染.md §3.3)
a.sess.World.WithReadLock(func() {
m := a.meshBuilder.Build(dj.c, func(x, y, z int32) block.State {
return a.sess.World.BlockUnlocked(x, y, z)
})
a.mu.Lock()
a.pendingMesh = append(a.pendingMesh, &worldMeshJob{cx: dj.c.CX, cz: dj.c.CZ, m: m})
a.mu.Unlock()
})
a.mu.Lock()
a.pendingMesh = append(a.pendingMesh, &worldMeshJob{cx: c.CX, cz: c.CZ, m: m})
a.mu.Unlock()
c.Dirty.Store(false)
dj.c.Dirty.Store(false)
}
}
@@ -211,6 +242,35 @@ func (a *clientApp) Render() {
}
a.uiR.Flush()
a.rend.EndFrame()
// 截图(dev 测试:游戏内等玩家所在区块 5×5 网格就绪再拍,避免视野空洞导致误判)
a.frames++
if *shotPath != "" && a.frames >= 200 {
ready := a.screen != 1 // 非游戏画面(标题/暂停)无需等区块
if !ready {
ready = true
pcx, pcz := int32(a.sess.Player.Pos[0])>>4, int32(a.sess.Player.Pos[2])>>4
for cx := pcx - 2; cx <= pcx+2 && ready; cx++ {
for cz := pcz - 2; cz <= pcz+2; cz++ {
if !a.rend.HasChunk(cx, cz) {
ready = false
break
}
}
}
}
if ready || a.frames > 2400 { // 就绪即拍;48 秒兜底
if !ready {
a.logf("截图兜底:视野未完全就绪(frame=%d)", a.frames)
}
if err := a.saveScreenshot(*shotPath); err != nil {
a.logf("截图失败: %v", err)
} else {
a.logf("截图已保存: %s", *shotPath)
}
a.rend.Window().SetShouldClose(true)
}
}
}
// ShouldClose 窗口关闭判定。
@@ -320,9 +380,24 @@ func main() {
}
}
// 4) 游戏会话(玩家先高空下落,区块加载后落地)
p := physics.NewPlayer(8.5, 100, 8.5)
cam := camera.New(p.Eye(), 0, 0, cfg.Client.FOV)
// 4) 游戏会话(出生逻辑:先等出生区块加载,再定位地表出生——
// 否则玩家从高空坠落会穿过未加载区块掉进地下,游戏内一片漆黑)
var surf int32 = 120
for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); {
w.Update(8.5, 100, 8.5, 8*time.Millisecond)
if w.Block(8, 80, 8) != block.Air {
break
}
time.Sleep(2 * time.Millisecond)
}
for y := int32(255); y > 0; y-- {
if w.Block(8, y, 8) != block.Air {
surf = y
break
}
}
p := physics.NewPlayer(8.5, float64(surf+2), 8.5)
cam := camera.New(p.Eye(), 0, -0.35, cfg.Client.FOV) // 略微俯视,视野内以地形为主
inv := inventory.New(items)
sess := game.NewSession(w, reg, items, recipes, p, inv, cam)

33
cmd/client/devshot_gl.go Normal file
View File

@@ -0,0 +1,33 @@
//go:build gl
// 开发测试工具:自动进游戏 + 截图(dev 测试通过后再打包)。
package main
import (
"fmt"
"image"
"image/png"
"os"
"github.com/go-gl/gl/v3.3-core/gl"
)
// saveScreenshot 从默认帧缓冲读取像素并保存为 PNG(渲染线程调用)。
func (a *clientApp) saveScreenshot(path string) error {
w, h := int32(a.winW), int32(a.winH)
pix := make([]uint8, w*h*4)
gl.ReadPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr(pix))
img := image.NewRGBA(image.Rect(0, 0, int(w), int(h)))
// 垂直翻转(GL 原点在左下)
for y := int32(0); y < h; y++ {
srcRow := (h - 1 - y) * w * 4
dstRow := y * w * 4
copy(img.Pix[dstRow:dstRow+w*4], pix[srcRow:srcRow+w*4])
}
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("创建截图文件: %w", err)
}
defer f.Close()
return png.Encode(f, img)
}