From 95bd510bf85c93dc7613b4abf52440403feec312 Mon Sep 17 00:00:00 2001 From: NianGao Dev Date: Sun, 16 Aug 2026 22:32:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(render):=20=E5=9E=82=E7=9B=B4=E9=9D=A2?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E6=81=A2=E5=A4=8D=E2=80=94=E2=80=94=E7=BB=95?= =?UTF-8?q?=E5=BA=8F=E6=94=B9=E5=9B=9E=E7=AA=97=E5=8F=A3=E7=A9=BA=E9=97=B4?= =?UTF-8?q?=20CW=20+=20=E4=BE=A7=E9=9D=A2=E7=BA=B9=E7=90=86=E9=94=AE?= =?UTF-8?q?=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 六面绕序按 GL 窗口坐标(x 右、y 上)验证:曾按 y 向下约定反转五面, 垂直面全部变成背面被剔除(只有顶面渲染、透空见区块断面) - mesh.TestFaceWinding 鞋带公式回归测试锁死绕序约定 - Registry.Texture 对 east/west/north 回退 side、south 回退 front/side (原版 blocks.json 惯例),立面不再落到不存在的 block/ 灰块 - World.SetBlock 破坏/放置空气时按列重算天空光(洞壁不再全黑) --- internal/block/registry.go | 16 +++++++++++++++ internal/mesh/mesh.go | 16 ++++++++------- internal/mesh/mesh_test.go | 37 +++++++++++++++++++++++++++++++++++ internal/render/overlay_gl.go | 12 ++++++------ internal/world/world.go | 6 ++++++ 文档/场景/渲染.md | 2 ++ 6 files changed, 76 insertions(+), 13 deletions(-) diff --git a/internal/block/registry.go b/internal/block/registry.go index 6a7070b0..576a38ce 100644 --- a/internal/block/registry.go +++ b/internal/block/registry.go @@ -135,11 +135,27 @@ func (r *Registry) IsTranslucent(s State) bool { return r.Get(s.ID()).Translucen func (r *Registry) IsFluid(s State) bool { return r.Get(s.ID()).Fluid } // Texture 取方块某个面的纹理键(如 "up"/"side"/"all")。 +// 原版 blocks.json 惯例:四个侧面统一用 "side"(南面可用 "front" 覆盖,如熔炉)。 +// mesh 层传入 east/west/south/north 方位名,这里回退到 side/front, +// 否则 grass_block 等立面会落到 "block/"(图集中不存在)→ 全图集灰块。 func (r *Registry) Texture(s State, face string) string { d := r.Get(s.ID()) if t, ok := d.Textures[face]; ok { return t } + switch face { + case "south": // 南面优先 front(熔炉门面),其次 side + if t, ok := d.Textures["front"]; ok { + return t + } + if t, ok := d.Textures["side"]; ok { + return t + } + case "east", "west", "north": + if t, ok := d.Textures["side"]; ok { + return t + } + } if t, ok := d.Textures["all"]; ok { return t } diff --git a/internal/mesh/mesh.go b/internal/mesh/mesh.go index 90c7cd45..a2b0e6db 100644 --- a/internal/mesh/mesh.go +++ b/internal/mesh/mesh.go @@ -78,15 +78,17 @@ type faceDef struct { // faces 六面定义(角顺序与 UV 顶点顺序一致)。 // 绕序铁律:所有面必须为「窗口空间顺时针」(FrontFace=CW 的正面)—— -// 旧定义只有顶面是 CW,垂直面与底面是 CCW 被背面剔除, -// 表现为「立面透明的空洞、移动时面闪烁」(方格立面缺失根因)。 +// 窗口坐标 x 向右、y 向上(GL 约定,非图像 y 向下), +// 用鞋带公式(y 向上)验证:从面外侧看向方块,area < 0 才是 CW。 +// 曾按 y 向下的约定反转五面,导致垂直面全部变成背面被剔除 +// (表现为「只有顶面渲染」,世界透空看到区块断面——立面缺失根因)。 var faces = [6]faceDef{ {dir: [3]int32{0, 1, 0}, corners: [4][3]int32{{0, 1, 0}, {1, 1, 0}, {1, 1, 1}, {0, 1, 1}}}, // 顶 - {dir: [3]int32{0, -1, 0}, corners: [4][3]int32{{0, 0, 0}, {1, 0, 0}, {1, 0, 1}, {0, 0, 1}}}, // 底(反向) - {dir: [3]int32{1, 0, 0}, corners: [4][3]int32{{1, 1, 0}, {1, 1, 1}, {1, 0, 1}, {1, 0, 0}}}, // 东(+x)(反向) - {dir: [3]int32{-1, 0, 0}, corners: [4][3]int32{{0, 1, 1}, {0, 1, 0}, {0, 0, 0}, {0, 0, 1}}}, // 西(-x)(反向) - {dir: [3]int32{0, 0, 1}, corners: [4][3]int32{{1, 1, 1}, {0, 1, 1}, {0, 0, 1}, {1, 0, 1}}}, // 南(+z)(反向) - {dir: [3]int32{0, 0, -1}, corners: [4][3]int32{{0, 1, 0}, {1, 1, 0}, {1, 0, 0}, {0, 0, 0}}}, // 北(-z)(反向) + {dir: [3]int32{0, -1, 0}, corners: [4][3]int32{{0, 0, 1}, {1, 0, 1}, {1, 0, 0}, {0, 0, 0}}}, // 底 + {dir: [3]int32{1, 0, 0}, corners: [4][3]int32{{1, 0, 0}, {1, 0, 1}, {1, 1, 1}, {1, 1, 0}}}, // 东(+x) + {dir: [3]int32{-1, 0, 0}, corners: [4][3]int32{{0, 0, 1}, {0, 0, 0}, {0, 1, 0}, {0, 1, 1}}}, // 西(-x) + {dir: [3]int32{0, 0, 1}, corners: [4][3]int32{{1, 0, 1}, {0, 0, 1}, {0, 1, 1}, {1, 1, 1}}}, // 南(+z) + {dir: [3]int32{0, 0, -1}, corners: [4][3]int32{{0, 0, 0}, {1, 0, 0}, {1, 1, 0}, {0, 1, 0}}}, // 北(-z) } // faceTexture 面方向 → 注册表纹理面名(block.Registry.Texture 参数)。 diff --git a/internal/mesh/mesh_test.go b/internal/mesh/mesh_test.go index 86df8436..a506582f 100644 --- a/internal/mesh/mesh_test.go +++ b/internal/mesh/mesh_test.go @@ -88,6 +88,43 @@ func TestFaceCulling(t *testing.T) { } } +// TestFaceWinding 六面绕序:从面外侧看必须为窗口空间顺时针(FrontFace=CW 的正面)。 +// 窗口坐标 x 向右、y 向上(GL 约定)——鞋带公式面积 < 0 为 CW。 +// 回归:曾按 y 向下约定反转五面,垂直面全被背面剔除("只有顶面渲染")。 +func TestFaceWinding(t *testing.T) { + // 各面外侧视角的相机基向量(right/up),用于把角投影到窗口 2D 坐标 + view := map[[3]int32][2][3]float32{ + {0, 1, 0}: {{1, 0, 0}, {0, 0, -1}}, // 顶:从 +y 俯视 + {0, -1, 0}: {{1, 0, 0}, {0, 0, 1}}, // 底:从 -y 仰视 + {1, 0, 0}: {{0, 0, -1}, {0, 1, 0}}, // 东:从 +x 看 + {-1, 0, 0}: {{0, 0, 1}, {0, 1, 0}}, // 西:从 -x 看 + {0, 0, 1}: {{1, 0, 0}, {0, 1, 0}}, // 南:从 +z 看 + {0, 0, -1}: {{-1, 0, 0}, {0, 1, 0}}, // 北:从 -z 看 + } + for _, f := range faces { + basis, ok := view[f.dir] + if !ok { + t.Fatalf("面 %v 缺少视角定义", f.dir) + } + right, up := basis[0], basis[1] + var px, py [4]float32 + for i, c := range f.corners { + w := [3]float32{float32(c[0]), float32(c[1]), float32(c[2])} + px[i] = w[0]*right[0] + w[1]*right[1] + w[2]*right[2] + py[i] = w[0]*up[0] + w[1]*up[1] + w[2]*up[2] + } + // 鞋带公式(y 向上:面积 > 0 为 CCW,< 0 为 CW) + area := float32(0) + for i := 0; i < 4; i++ { + j := (i + 1) % 4 + area += px[i]*py[j] - px[j]*py[i] + } + if area >= 0 { + t.Fatalf("面 %v 绕序错误:面积 %.1f >= 0(应为 CW,面积 < 0),会被背面剔除", f.dir, area) + } + } +} + // TestChannels 透明分类:火把→cutout,水→translucent(渲染.md §6)。 func TestChannels(t *testing.T) { reg := loadReg(t) diff --git a/internal/render/overlay_gl.go b/internal/render/overlay_gl.go index 0a02dee1..3f2c9162 100644 --- a/internal/render/overlay_gl.go +++ b/internal/render/overlay_gl.go @@ -15,14 +15,14 @@ import ( ) // cubeFaces 单位立方体 6 面角顺序(与 mesh.faceDef 一致, -// 窗口空间 CW 绕序为 FrontFace(CW) 的正面)。 +// 窗口空间 CW 绕序为 FrontFace(CW) 的正面;窗口坐标 y 向上)。 var cubeFaces = [6][4][3]float32{ {{0, 1, 0}, {1, 1, 0}, {1, 1, 1}, {0, 1, 1}}, // 顶 - {{0, 0, 0}, {1, 0, 0}, {1, 0, 1}, {0, 0, 1}}, // 底(反向) - {{1, 1, 0}, {1, 1, 1}, {1, 0, 1}, {1, 0, 0}}, // 东(+x)(反向) - {{0, 1, 1}, {0, 1, 0}, {0, 0, 0}, {0, 0, 1}}, // 西(-x)(反向) - {{1, 1, 1}, {0, 1, 1}, {0, 0, 1}, {1, 0, 1}}, // 南(+z)(反向) - {{0, 1, 0}, {1, 1, 0}, {1, 0, 0}, {0, 0, 0}}, // 北(-z)(反向) + {{0, 0, 1}, {1, 0, 1}, {1, 0, 0}, {0, 0, 0}}, // 底 + {{1, 0, 0}, {1, 0, 1}, {1, 1, 1}, {1, 1, 0}}, // 东(+x) + {{0, 0, 1}, {0, 0, 0}, {0, 1, 0}, {0, 1, 1}}, // 西(-x) + {{1, 0, 1}, {0, 0, 1}, {0, 1, 1}, {1, 1, 1}}, // 南(+z) + {{0, 0, 0}, {1, 0, 0}, {1, 1, 0}, {0, 1, 0}}, // 北(-z) } // cubeVerts 按六面 UV 构建单位立方体顶点(mesh.Vertex 格式:pos+uv+light 字节, diff --git a/internal/world/world.go b/internal/world/world.go index 6d36f539..c0df9218 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -250,6 +250,12 @@ func (w *World) SetBlock(x, y, z int32, s block.State) { prev := ch.Block(lx, int(y), lz) ch.SetBlock(lx, int(y), lz, s) + // 天空光按列重算:破坏方块后洞内空气获得天光(否则挖出的洞壁全黑, + // 光照.md §3 列更新);放置方块后下方渐变变暗(原版行为) + if s == block.Air || prev == block.Air { + light.SkyFill(w, x, x, z, z) + } + prevLight := w.reg.Light(prev) newLight := w.reg.Light(s) switch { diff --git a/文档/场景/渲染.md b/文档/场景/渲染.md index f8e7dda9..03ae0587 100644 --- a/文档/场景/渲染.md +++ b/文档/场景/渲染.md @@ -29,6 +29,8 @@ func main() { - 只为「邻格是空气/透明」的面生成顶点,被完全不透明方块包裹的面直接丢弃(设计.md §5.1)。 - 这一条不做,draw call 和顶点数直接爆炸。 +- **绕序铁律(GL 3.3 core)**:`gl.FrontFace(gl.CW)` 下,六面顶点序必须是**窗口空间顺时针**——窗口坐标 **x 向右、y 向上**(GL 约定,不是图像坐标 y 向下)。用鞋带公式验证:从面外侧看向方块,有向面积 **< 0 才是 CW**。曾按 y 向下约定把五面反转,导致垂直面全部变成背面被剔除——世界只剩顶面、透空看到区块断面("只有顶部渲染"根因)。回归测试:`mesh.TestFaceWinding`。 +- **纹理键约定(原版 blocks.json)**:四个侧面统一用 `"side"`(南面可被 `"front"` 覆盖,如熔炉),`mesh` 层传 `east/west/south/north` 方位名,注册表 `Texture()` 负责回退到 `side/front/all`,否则立面会落到 `block/`(图集中不存在)→ 整图集平均灰块。 ### 3.2 第二步:Greedy Meshing(分水岭)