From 719fe5fb2aa891d87f6ee97439f7f9229deec7f1 Mon Sep 17 00:00:00 2001 From: NianGao Dev Date: Sat, 15 Aug 2026 22:39:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(render):=20=E7=BA=B9=E7=90=86=E5=9B=BE?= =?UTF-8?q?=E9=9B=86=E6=9E=84=E5=BB=BA=E4=B8=8E=E5=8C=BA=E5=9D=97=E7=BD=91?= =?UTF-8?q?=E6=A0=BC=E6=9E=84=E5=BB=BA=EF=BC=88=E9=9D=A2=E5=89=94=E9=99=A4?= =?UTF-8?q?/=E4=B8=89=E9=80=9A=E9=81=93/=E5=85=89=E7=85=A7=E9=A1=B6?= =?UTF-8?q?=E7=82=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/atlas/atlas.go | 82 +++++++++++++++++++++ internal/atlas/atlas_test.go | 59 +++++++++++++++ internal/mesh/mesh.go | 136 +++++++++++++++++++++++++++++++++++ internal/mesh/mesh_test.go | 95 ++++++++++++++++++++++++ 4 files changed, 372 insertions(+) create mode 100644 internal/atlas/atlas.go create mode 100644 internal/atlas/atlas_test.go create mode 100644 internal/mesh/mesh.go create mode 100644 internal/mesh/mesh_test.go diff --git a/internal/atlas/atlas.go b/internal/atlas/atlas.go new file mode 100644 index 00000000..d2d5081d --- /dev/null +++ b/internal/atlas/atlas.go @@ -0,0 +1,82 @@ +// Package atlas 纹理图集构建:把纹理键列表打包成单张图(渲染.md §4)。 +// +// 所有纹理统一按 tileSize 采样(最近邻缩放,保持像素风), +// 返回图集图像与「纹理键 → 像素矩形」映射,供网格构建换算 UV。 +package atlas + +import ( + "fmt" + "image" + "image/draw" + "image/png" + "os" +) + +// Build 打包纹理键列表为图集。 +// resolve:纹理键 → PNG 文件路径;缺失纹理跳过(键不进入 uv 表,运行时用占位纹理)。 +// 列数取「大于等于根号的整数」,使图集接近正方形(GPU 友好)。 +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: 纹理列表为空") + } + if tileSize <= 0 { + tileSize = 16 + } + cols := 1 + for cols*cols < len(keys) { + cols++ + } + rows := (len(keys) + cols - 1) / cols + img := image.NewRGBA(image.Rect(0, 0, cols*tileSize, rows*tileSize)) + uv := make(map[string]image.Rectangle, len(keys)) + + for i, key := range keys { + path, ok := resolve(key) + if !ok { + continue // 缺失纹理:跳过(占位) + } + tile, err := loadPNG(path) + if err != nil { + return nil, nil, fmt.Errorf("atlas.Build %s: %w", key, err) + } + x := (i % cols) * tileSize + y := (i / cols) * tileSize + r := image.Rect(x, y, x+tileSize, y+tileSize) + // 最近邻缩放(非标准尺寸纹理保持像素风) + scaled := scaleNearest(tile, tileSize) + draw.Draw(img, r, scaled, image.Point{}, draw.Src) + uv[key] = r + } + return img, uv, nil +} + +// loadPNG 解码 PNG。 +func loadPNG(path string) (image.Image, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + img, err := png.Decode(f) + if err != nil { + return nil, err + } + return img, nil +} + +// scaleNearest 最近邻缩放到 tileSize×tileSize。 +func scaleNearest(src image.Image, size int) image.Image { + sb := src.Bounds() + if sb.Dx() == size && sb.Dy() == size { + return src + } + dst := image.NewRGBA(image.Rect(0, 0, size, size)) + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + sx := sb.Min.X + x*sb.Dx()/size + sy := sb.Min.Y + y*sb.Dy()/size + dst.Set(x, y, src.At(sx, sy)) + } + } + return dst +} diff --git a/internal/atlas/atlas_test.go b/internal/atlas/atlas_test.go new file mode 100644 index 00000000..a521bfaf --- /dev/null +++ b/internal/atlas/atlas_test.go @@ -0,0 +1,59 @@ +package atlas + +import ( + "image" + "path/filepath" + "testing" +) + +// testResolve 从真实素材包解析纹理。 +func testResolve(key string) (string, bool) { + p := filepath.Join("..", "..", "resourcepacks", "texture-pack-default1.20.5-26.2", + "assets", "minecraft", "textures", key+".png") + if _, err := filepath.Abs(p); err != nil { + return "", false + } + if _, err := testStat(p); err != nil { + return "", false + } + return p, true +} + +func testStat(p string) (interface{}, error) { + f, err := filepath.Glob(p) + if err != nil || len(f) == 0 { + return nil, err + } + return f[0], nil +} + +// TestBuild 验证图集打包:UV 矩形齐全、图像尺寸正确。 +func TestBuild(t *testing.T) { + keys := []string{"block/stone", "block/dirt", "block/grass_block_top"} + img, uv, err := Build(testResolve, keys, 16) + if err != nil { + t.Fatalf("构建图集失败: %v", err) + } + b := img.Bounds() + if b.Dx() != 32 || b.Dy() != 32 { + t.Fatalf("图集尺寸期望 32×32,实际 %dx%d", b.Dx(), b.Dy()) + } + for _, k := range keys { + if _, ok := uv[k]; !ok { + t.Fatalf("纹理 %s 缺 UV", k) + } + } + // 三个键的 UV 不重叠且各不相同 + if uv[keys[0]] == uv[keys[1]] { + t.Fatal("UV 矩形重叠") + } +} + +// TestScaleNearest 验证最近邻缩放尺寸。 +func TestScaleNearest(t *testing.T) { + src := image.NewRGBA(image.Rect(0, 0, 32, 32)) + dst := scaleNearest(src, 16) + if db := dst.Bounds(); db.Dx() != 16 || db.Dy() != 16 { + t.Fatalf("缩放后尺寸期望 16×16,实际 %dx%d", db.Dx(), db.Dy()) + } +} diff --git a/internal/mesh/mesh.go b/internal/mesh/mesh.go new file mode 100644 index 00000000..0ed4591a --- /dev/null +++ b/internal/mesh/mesh.go @@ -0,0 +1,136 @@ +// Package mesh 区块网格构建:面剔除 + 顶点格式 + 光照(场景/渲染.md §3)。 +// +// 网格在 CPU 构建(worker 池),GL 只负责上传——渲染 goroutine 不产生分配压力。 +package mesh + +import ( + "mc/internal/block" + "mc/internal/chunk" +) + +// Vertex 顶点格式(渲染.md §5):位置 + UV + 天空/方块光各 4-bit。 +type Vertex struct { + X, Y, Z float32 + U, V float32 + Sky, Block uint8 +} + +// ChunkMesh 一个区块的三类网格(渲染.md §6 三 pass)。 +type ChunkMesh struct { + Opaque []Vertex // 不透明(主 pass) + Cutout []Vertex // alpha test(树叶/玻璃) + Translucent []Vertex // 半透明(水,远→近排序 pass) +} + +// Neighbor 邻居方块查询(跨区块面剔除用,区块管理.md §6)。 +type Neighbor func(x, y, z int32) block.State + +// UVFunc 纹理键 → 图集 UV(归一化坐标,含 inset)。 +type UVFunc func(texKey string) (u0, v0, u1, v1 float32, ok bool) + +// Builder 网格构建器。 +type Builder struct { + reg *block.Registry + uv UVFunc +} + +// NewBuilder 创建构建器。 +func NewBuilder(reg *block.Registry, uv UVFunc) *Builder { + return &Builder{reg: reg, uv: uv} +} + +// face 一个面的 4 个角偏移与朝向。 +type faceDef struct { + dir [3]int32 + corners [4][3]int32 +} + +// faces 六面定义(角顺序与 UV 顶点顺序一致)。 +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, 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 参数)。 +var faceTexture = [6]string{"up", "down", "east", "west", "south", "north"} + +// Build 构建区块网格(面剔除:仅当邻格非完全不透明时生成该面,渲染.md §3.1)。 +func (b *Builder) Build(c *chunk.Chunk, nb Neighbor) *ChunkMesh { + m := &ChunkMesh{} + blocks := c.Snapshot() + baseX, baseZ := float32(c.CX*16), float32(c.CZ*16) + + for x := 0; x < 16; x++ { + for y := 0; y < chunk.Height; y++ { + for z := 0; z < 16; z++ { + s := blocks[chunk.Index(x, y, z)] + if s == block.Air { + continue + } + var target *[]Vertex + switch { + case b.reg.IsTranslucent(s): + target = &m.Translucent + case b.reg.IsCutout(s): + target = &m.Cutout + default: + target = &m.Opaque + } + wx := int32(x) + c.CX*16 + wy := int32(y) + wz := int32(z) + c.CZ*16 + sky, blk := c.SkyLight(x, y, z), c.BlockLight(x, y, z) + + for fi, f := range faces { + nx := wx + f.dir[0] + ny := wy + f.dir[1] + nz := wz + f.dir[2] + neighbor := nb(nx, ny, nz) + if b.reg.IsOpaque(neighbor) { + continue // 面被不透明邻格遮挡(面剔除) + } + // 同类型邻格也剔除(水贴水不渲染内面;树叶相邻保留边缘,简化规则) + if neighbor != block.Air && !b.reg.IsOpaque(neighbor) && + b.reg.IsTranslucent(neighbor) == b.reg.IsTranslucent(s) && b.reg.IsTranslucent(s) { + continue + } + texKey := b.reg.Texture(s, faceTexture[fi]) + u0, v0, u1, v1, ok := b.uv(texKey) + if !ok { + u0, v0, u1, v1 = 0, 0, 1, 1 + } + for _, corner := range f.corners { + *target = append(*target, Vertex{ + X: baseX + float32(x) + float32(corner[0]), + Y: float32(y) + float32(corner[1]), + Z: baseZ + float32(z) + float32(corner[2]), + U: u0 + (u1-u0)*float32(cornerUV(corner, f)[0]), + V: v0 + (v1-v0)*float32(cornerUV(corner, f)[1]), + Sky: sky, + Block: blk, + }) + } + } + } + } + } + return m +} + +// cornerUV 角在面上的 UV 权重(0/1),顺序与 corners 一致。 +func cornerUV(c [3]int32, f faceDef) [2]int32 { + var u, v int32 + switch f.dir { + case [3]int32{0, 1, 0}, [3]int32{0, -1, 0}: // 顶/底:x→u,z→v + u, v = c[0], c[2] + case [3]int32{1, 0, 0}, [3]int32{-1, 0, 0}: // 东/西:z→u,y→v + u, v = c[2], 1-c[1] + default: // 南/北:x→u,y→v + u, v = c[0], 1-c[1] + } + return [2]int32{u, v} +} diff --git a/internal/mesh/mesh_test.go b/internal/mesh/mesh_test.go new file mode 100644 index 00000000..21677eb2 --- /dev/null +++ b/internal/mesh/mesh_test.go @@ -0,0 +1,95 @@ +package mesh + +import ( + "path/filepath" + "testing" + + "mc/internal/block" + "mc/internal/chunk" +) + +// loadReg 加载注册表。 +func loadReg(t *testing.T) *block.Registry { + t.Helper() + r, err := block.Load(filepath.Join("..", "..", "assets", "config", "blocks.json")) + if err != nil { + t.Fatalf("加载注册表失败: %v", err) + } + return r +} + +// airNb 邻居查询:全部视为空气(不剔除任何面)。 +func airNb(x, y, z int32) block.State { return block.Air } + +// dummyUV 固定 UV(测试不关心贴图坐标)。 +func dummyUV(key string) (float32, float32, float32, float32, bool) { + return 0, 0, 1, 1, true +} + +// TestSingleBlock 单方块 6 面全生成:24 顶点(6 面 × 4 顶点四边形)。 +func TestSingleBlock(t *testing.T) { + reg := loadReg(t) + stone, _ := reg.ID("stone") + c := chunk.New(0, 0) + c.Fill(0, 0, 0, block.NewState(stone, 0)) + c.SetBlockLight(0, 0, 0, 10) + + b := NewBuilder(reg, dummyUV) + m := b.Build(c, airNb) + if len(m.Opaque) != 24 { + t.Fatalf("不透明顶点期望 24,实际 %d", len(m.Opaque)) + } + if len(m.Cutout) != 0 || len(m.Translucent) != 0 { + t.Fatalf("其他通道应为空: cutout=%d translucent=%d", len(m.Cutout), len(m.Translucent)) + } + // 顶点光照传递 + for _, v := range m.Opaque { + if v.Block != 10 { + t.Fatalf("顶点方块光期望 10,实际 %d", v.Block) + } + } +} + +// TestFaceCulling 相邻不透明方块剔除共享面:2 块相邻 → 各 5 面 → 40 顶点。 +func TestFaceCulling(t *testing.T) { + reg := loadReg(t) + stone, _ := reg.ID("stone") + c := chunk.New(0, 0) + c.Fill(0, 0, 0, block.NewState(stone, 0)) + c.Fill(1, 0, 0, block.NewState(stone, 0)) + + // 邻居查询:跨区块查空气,区块内查实际方块 + nb := func(x, y, z int32) block.State { + if x < 0 || x >= 16 || y < 0 || y >= chunk.Height || z < 0 || z >= 16 { + return block.Air + } + return c.Block(int(x), int(y), int(z)) + } + b := NewBuilder(reg, dummyUV) + m := b.Build(c, nb) + if len(m.Opaque) != 40 { + t.Fatalf("面剔除后顶点期望 40,实际 %d", len(m.Opaque)) + } +} + +// TestChannels 透明分类:火把→cutout,水→translucent(渲染.md §6)。 +func TestChannels(t *testing.T) { + reg := loadReg(t) + torch, _ := reg.ID("torch") + water, _ := reg.ID("water") + c := chunk.New(0, 0) + c.Fill(0, 0, 0, block.NewState(torch, 0)) + c.Fill(2, 0, 0, block.NewState(water, 0)) + + b := NewBuilder(reg, dummyUV) + m := b.Build(c, airNb) + if len(m.Cutout) != 24 { + t.Fatalf("火把 cutout 顶点期望 24,实际 %d", len(m.Cutout)) + } + if len(m.Translucent) != 24 { + t.Fatalf("水 translucent 顶点期望 24,实际 %d", len(m.Translucent)) + } + if len(m.Opaque) != 0 { + t.Fatalf("不应有不透明顶点,实际 %d", len(m.Opaque)) + } +}