diff --git a/cmd/client/client_gl.go b/cmd/client/client_gl.go index a95ba901..f0cd80e4 100644 --- a/cmd/client/client_gl.go +++ b/cmd/client/client_gl.go @@ -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) diff --git a/cmd/client/devshot_gl.go b/cmd/client/devshot_gl.go new file mode 100644 index 00000000..a3269272 --- /dev/null +++ b/cmd/client/devshot_gl.go @@ -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) +} diff --git a/internal/atlas/atlas.go b/internal/atlas/atlas.go index d2d5081d..ddb417fc 100644 --- a/internal/atlas/atlas.go +++ b/internal/atlas/atlas.go @@ -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) diff --git a/internal/camera/camera.go b/internal/camera/camera.go index 635c8842..cb74bd18 100644 --- a/internal/camera/camera.go +++ b/internal/camera/camera.go @@ -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) diff --git a/internal/render/mesh_gl.go b/internal/render/mesh_gl.go index 5ea190d6..c6d8bca6 100644 --- a/internal/render/mesh_gl.go +++ b/internal/render/mesh_gl.go @@ -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) { diff --git a/internal/render/renderer.go b/internal/render/renderer.go index f3191f03..52501dab 100644 --- a/internal/render/renderer.go +++ b/internal/render/renderer.go @@ -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 } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 16e75ab6..73a87ef7 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -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; } ` diff --git a/internal/world/world.go b/internal/world/world.go index 004596eb..42ab4840 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -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 } diff --git a/scripts/installer.nsi b/scripts/installer.nsi index 2f49ec31..0f152d56 100644 --- a/scripts/installer.nsi +++ b/scripts/installer.nsi @@ -18,9 +18,10 @@ Name "${PRODUCT_NAME}" OutFile "..\dist\${INSTALLER_NAME}" -InstallDir "$PROGRAMFILES64\${PRODUCT_NAME_EN}" -InstallDirRegKey HKLM "${UNINSTALL_KEY}" "InstallLocation" -RequestExecutionLevel admin +; 按用户安装(游戏需要往自身目录写日志/存档,Program Files 无写入权限会导致启动崩溃) +InstallDir "$LOCALAPPDATA\Programs\${PRODUCT_NAME_EN}" +InstallDirRegKey HKCU "${UNINSTALL_KEY}" "InstallLocation" +RequestExecutionLevel user SetCompressor /SOLID lzma !define MUI_ABORTWARNING @@ -68,15 +69,15 @@ Section "主程序" SEC01 FileWrite $0 "世界存档目录:游戏会自动把世界保存到这里。" FileClose $0 - ; 注册卸载信息 - WriteRegStr HKLM "${UNINSTALL_KEY}" "DisplayName" "${PRODUCT_NAME}" - WriteRegStr HKLM "${UNINSTALL_KEY}" "DisplayVersion" "${VERSION}" - WriteRegStr HKLM "${UNINSTALL_KEY}" "Publisher" "${PRODUCT_PUBLISHER}" - WriteRegStr HKLM "${UNINSTALL_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXE}" - WriteRegStr HKLM "${UNINSTALL_KEY}" "InstallLocation" "$INSTDIR" - WriteRegStr HKLM "${UNINSTALL_KEY}" "UninstallString" '"$INSTDIR\uninstall.exe"' - WriteRegDWORD HKLM "${UNINSTALL_KEY}" "NoModify" 1 - WriteRegDWORD HKLM "${UNINSTALL_KEY}" "NoRepair" 1 + ; 注册卸载信息(按用户安装 → HKCU) + WriteRegStr HKCU "${UNINSTALL_KEY}" "DisplayName" "${PRODUCT_NAME}" + WriteRegStr HKCU "${UNINSTALL_KEY}" "DisplayVersion" "${VERSION}" + WriteRegStr HKCU "${UNINSTALL_KEY}" "Publisher" "${PRODUCT_PUBLISHER}" + WriteRegStr HKCU "${UNINSTALL_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXE}" + WriteRegStr HKCU "${UNINSTALL_KEY}" "InstallLocation" "$INSTDIR" + WriteRegStr HKCU "${UNINSTALL_KEY}" "UninstallString" '"$INSTDIR\uninstall.exe"' + WriteRegDWORD HKCU "${UNINSTALL_KEY}" "NoModify" 1 + WriteRegDWORD HKCU "${UNINSTALL_KEY}" "NoRepair" 1 WriteUninstaller "$INSTDIR\uninstall.exe" @@ -98,5 +99,5 @@ Section "Uninstall" RMDir /r "$INSTDIR\resourcepacks" RMDir /r "$INSTDIR\mods" ; 保留用户存档(worlds/)与日志,不删除 - DeleteRegKey HKLM "${UNINSTALL_KEY}" + DeleteRegKey HKCU "${UNINSTALL_KEY}" SectionEnd