feat(render): 纹理图集构建与区块网格构建(面剔除/三通道/光照顶点)
This commit is contained in:
82
internal/atlas/atlas.go
Normal file
82
internal/atlas/atlas.go
Normal file
@@ -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
|
||||
}
|
||||
59
internal/atlas/atlas_test.go
Normal file
59
internal/atlas/atlas_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
136
internal/mesh/mesh.go
Normal file
136
internal/mesh/mesh.go
Normal file
@@ -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}
|
||||
}
|
||||
95
internal/mesh/mesh_test.go
Normal file
95
internal/mesh/mesh_test.go
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user