FBX转3DTiles带坐标的高效转换工具

Python Tkinter实现FBX转3DTiles程序开发指南

1. 程序架构设计

本程序采用模块化设计,主要包含以下核心模块:

模块名称 功能描述 依赖库
图形界面模块 提供用户交互界面 tkinter
FBX解析模块 读取FBX文件并提取3D数据 fbx-sdk/pyfbx
坐标转换模块 处理地理坐标转换 pyproj
格式转换模块 生成GLB和B3DM文件 pygltflib
压缩优化模块 顶点和文件压缩 draco
文件拆分模块 控制输出文件大小 os, shutil

2. 核心功能实现

2.1 图形界面开发

python 复制代码
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import threading

class FBXTo3DTilesConverter:
    def __init__(self, root):
        self.root = root
        self.root.title("FBX转3DTiles转换器")
        self.root.geometry("600x400")
        
        self.setup_ui()
        
    def setup_ui(self):
        # 文件选择区域
        file_frame = ttk.LabelFrame(self.root, text="文件选择", padding=10)
        file_frame.pack(fill="x", padx=10, pady=5)
        
        self.fbx_path = tk.StringVar()
        ttk.Entry(file_frame, textvariable=self.fbx_path, width=50).pack(side="left", padx=5)
        ttk.Button(file_frame, text="选择FBX文件", 
                  command=self.select_fbx_file).pack(side="left", padx=5)
        
        # 输出选项区域
        options_frame = ttk.LabelFrame(self.root, text="输出选项", padding=10)
        options_frame.pack(fill="x", padx=10, pady=5)
        
        # 输出格式选择
        self.output_format = tk.StringVar(value="b3dm")
        ttk.Radiobutton(options_frame, text="B3DM", 
                       variable=self.output_format, value="b3dm").pack(side="left")
        ttk.Radiobutton(options_frame, text="GLB", 
                       variable=self.output_format, value="glb").pack(side="left")
        
        # 压缩选项
        self.compression_enabled = tk.BooleanVar(value=True)
        ttk.Checkbutton(options_frame, text="启用顶点压缩", 
                       variable=self.compression_enabled).pack(side="left", padx=20)
        
        # 坐标设置
        coord_frame = ttk.LabelFrame(self.root, text="坐标设置", padding=10)
        coord_frame.pack(fill="x", padx=10, pady=5)
        
        self.coord_x = tk.StringVar(value="116.3974")  # 北京经度
        self.coord_y = tk.StringVar(value="39.9093")   # 北京纬度
        
        ttk.Label(coord_frame, text="经度:").pack(side="left")
        ttk.Entry(coord_frame, textvariable=self.coord_x, width=10).pack(side="left", padx=5)
        ttk.Label(coord_frame, text="纬度:").pack(side="left", padx=10)
        ttk.Entry(coord_frame, textvariable=self.coord_y, width=10).pack(side="left", padx=5)
        
        # 转换按钮
        ttk.Button(self.root, text="开始转换", 
                  command=self.start_conversion).pack(pady=20)
        
        # 进度显示
        self.progress = ttk.Progressbar(self.root, mode='indeterminate')
        self.progress.pack(fill="x", padx=10, pady=5)
        
        self.status_label = ttk.Label(self.root, text="就绪")
        self.status_label.pack()

2.2 FBX文件解析与数据处理

python 复制代码
import fbx
import numpy as np
from pyproj import Transformer

class FBXProcessor:
    def __init__(self):
        self.manager = fbx.FbxManager.Create()
        self.scene = None
        self.transformer = Transformer.from_crs("EPSG:4326", "EPSG:4978", always_xy=True)
        
    def load_fbx(self, file_path):
        """加载FBX文件并提取3D数据"""
        importer = fbx.FbxImporter.Create(self.manager, "")
        
        if not importer.Initialize(file_path):
            raise Exception(f"FBX文件加载失败: {importer.GetStatus().GetErrorString()}")
            
        self.scene = fbx.FbxScene.Create(self.manager, "")
        importer.Import(self.scene)
        importer.Destroy()
        
        return self.extract_mesh_data()
    
    def extract_mesh_data(self):
        """从场景中提取网格数据"""
        mesh_data = {
            'vertices': [],
            'indices': [],
            'normals': [],
            'uvs': [],
            'materials': []
        }
        
        root_node = self.scene.GetRootNode()
        self.process_node(root_node, mesh_data)
        
        return mesh_data
    
    def process_node(self, node, mesh_data):
        """递归处理场景节点"""
        if node.GetNodeAttribute():
            attribute_type = node.GetNodeAttribute().GetAttributeType()
            
            if attribute_type == fbx.FbxNodeAttribute.eMesh:
                self.process_mesh(node, mesh_data)
                
        for i in range(node.GetChildCount()):
            self.process_node(node.GetChild(i), mesh_data)
    
    def process_mesh(self, node, mesh_data):
        """处理网格数据"""
        mesh = node.GetNodeAttribute()
        geometry = mesh.GetGeometry()
        
        # 提取顶点数据
        control_points = geometry.GetControlPoints()
        for i in range(control_points.GetCount()):
            point = control_points.GetAt(i)
            mesh_data['vertices'].extend([point[0], point[1], point[2]])
        
        # 处理多边形数据
        self.process_polygons(geometry, mesh_data)

2.3 坐标转换与地理定位

python 复制代码
class CoordinateConverter:
    def __init__(self, default_lon=116.3974, default_lat=39.9093):
        # 北京默认坐标(天安门)
        self.default_lon = default_lon
        self.default_lat = default_lat
        self.transformer = Transformer.from_crs("EPSG:4326", "EPSG:4978", always_xy=True)
    
    def convert_to_earth_centered(self, vertices, offset_lon=None, offset_lat=None):
        """将局部坐标转换为地心坐标系"""
        lon = offset_lon if offset_lon else self.default_lon
        lat = offset_lat if offset_lat else self.default_lat
        
        # 计算参考点的地心坐标
        ref_x, ref_y, ref_z = self.transformer.transform(lon, lat, 0)
        
        # 应用坐标变换(简化处理)
        earth_centered_vertices = []
        for i in range(0, len(vertices), 3):
            x = vertices[i] + ref_x
            y = vertices[i+1] + ref_y  
            z = vertices[i+2] + ref_z
            earth_centered_vertices.extend([x, y, z])
            
        return earth_centered_vertices
    
    def create_tileset_json(self, bounding_box, file_list):
        """生成3DTiles的tileset.json文件"""
        tileset = {
            "asset": {
                "version": "1.0",
                "tilesetVersion": "1.2.3"
            },
            "properties": {
                "longitude": self.default_lon,
                "latitude": self.default_lat
            },
            "geometricError": 100,
            "root": {
                "boundingVolume": {
                    "region": bounding_box
                },
                "geometricError": 0,
                "refine": "ADD",
                "content": {
                    "uri": file_list[0] if file_list else ""
                },
                "children": []
            }
        }
        return tileset

2.4 格式转换与压缩

python 复制代码
import struct
import zlib
from pygltflib import GLTF2, Buffer, BufferView, Accessor, Mesh, Primitive, Node, Scene

class FormatConverter:
    def __init__(self):
        self.draco_compression = False
        
    def enable_draco_compression(self, enable=True):
        self.draco_compression = enable
    
    def convert_to_glb(self, mesh_data, output_path):
        """将网格数据转换为GLB格式"""
        gltf = GLTF2()
        
        # 创建缓冲区
        vertex_buffer = self.create_vertex_buffer(mesh_data['vertices'])
        index_buffer = self.create_index_buffer(mesh_data['indices'])
        
        # 添加缓冲区视图和访问器
        self.add_buffer_views(gltf, vertex_buffer, index_buffer)
        self.add_accessors(gltf, len(mesh_data['vertices']) // 3, len(mesh_data['indices']))
        
        # 创建网格和场景
        mesh = self.create_mesh(gltf)
        scene = self.create_scene(gltf, mesh)
        
        gltf.scenes = [scene]
        gltf.scene = 0
        
        # 保存GLB文件
        gltf.save_binary(output_path)
        
        return output_path
    
    def convert_to_b3dm(self, glb_path, output_path, feature_table=None):
        """将GLB转换为B3DM格式"""
        with open(glb_path, 'rb') as f:
            glb_data = f.read()
            
        # 创建B3DM头部
        header = self.create_b3dm_header(len(glb_data))
        
        # 创建要素表(可选)
        feature_table_json = self.create_feature_table(feature_table or {})
        feature_table_bin = b''  # 二进制要素数据
        
        # 组合B3DM文件
        b3dm_data = header + feature_table_json + feature_table_bin + glb_data
        
        with open(output_path, 'wb') as f:
            f.write(b3dm_data)
            
        return output_path
    
    def create_b3dm_header(self, glb_length):
        """创建B3DM文件头"""
        magic = b'b3dm'
        version = 1
        byte_length = 28 + glb_length  # 头部长度 + GLB数据长度
        feature_table_json_length = 0
        feature_table_bin_length = 0
        batch_table_json_length = 0
        batch_table_bin_length = 0
        
        return struct.pack('<4sIIIII', magic, version, byte_length, 
                          feature_table_json_length, feature_table_bin_length,
                          batch_table_json_length, batch_table_bin_length)

2.5 文件拆分与大小控制

python 复制代码
import os
import math

class FileSplitter:
    def __init__(self, max_file_size=20*1024*1024):  # 20MB
        self.max_file_size = max_file_size
        
    def split_large_mesh(self, mesh_data, output_dir, base_name):
        """根据文件大小要求拆分大型网格"""
        total_vertices = len(mesh_data['vertices']) // 3
        vertices_per_chunk = self.calculate_optimal_chunk_size(mesh_data)
        
        output_files = []
        
        for i in range(0, total_vertices, vertices_per_chunk):
            chunk_data = self.extract_mesh_chunk(mesh_data, i, vertices_per_chunk)
            
            if self.output_format == 'b3dm':
                output_path = self.create_b3dm_chunk(chunk_data, output_dir, base_name, i)
            else:
                output_path = self.create_glb_chunk(chunk_data, output_dir, base_name, i)
                
            output_files.append(output_path)
            
        return output_files
    
    def calculate_optimal_chunk_size(self, mesh_data):
        """计算最优的块大小以满足文件大小限制"""
        # 估算每个顶点占用的字节数
        bytes_per_vertex = 32  # 保守估计:位置(12) + 法线(12) + UV(8)
        bytes_per_index = 4
        
        total_vertices = len(mesh_data['vertices']) // 3
        total_indices = len(mesh_data['indices'])
        
        # 计算基础大小(不含压缩)
        base_size = (total_vertices * bytes_per_vertex + 
                    total_indices * bytes_per_index)
        
        # 考虑压缩比率(假设压缩比为0.3)
        compression_ratio = 0.3
        estimated_size = base_size * compression_ratio
        
        # 计算每个块的最大顶点数
        max_vertices_per_chunk = int((self.max_file_size / compression_ratio) / bytes_per_vertex)
        
        return min(max_vertices_per_chunk, total_vertices)
    
    def extract_mesh_chunk(self, mesh_data, start_vertex, vertex_count):
        """提取网格数据的指定块"""
        end_vertex = min(start_vertex + vertex_count, len(mesh_data['vertices']) // 3)
        
        chunk_data = {
            'vertices': [],
            'indices': [],
            'normals': [],
            'uvs': []
        }
        
        # 提取顶点数据
        vertex_start = start_vertex * 3
        vertex_end = end_vertex * 3
        chunk_data['vertices'] = mesh_data['vertices'][vertex_start:vertex_end]
        
        # 重新映射索引
        index_remap = {}
        new_index = 0
        
        for i, index in enumerate(mesh_data['indices']):
            if start_vertex <= index < end_vertex:
                if index not in index_remap:
                    index_remap[index] = new_index
                    new_index += 1
                chunk_data['indices'].append(index_remap[index])
                
        return chunk_data

3. 主程序集成

python 复制代码
class MainApplication:
    def __init__(self):
        self.fbx_processor = FBXProcessor()
        self.coord_converter = CoordinateConverter()
        self.format_converter = FormatConverter()
        self.file_splitter = FileSplitter()
        
    def convert_fbx_to_3dtiles(self, fbx_path, output_dir, options):
        """主转换函数"""
        try:
            # 1. 读取FBX文件
            mesh_data = self.fbx_processor.load_fbx(fbx_path)
            
            # 2. 坐标转换
            earth_vertices = self.coord_converter.convert_to_earth_centered(
                mesh_data['vertices'], 
                options.get('longitude'),
                options.get('latitude')
            )
            mesh_data['vertices'] = earth_vertices
            
            # 3. 设置压缩
            if options.get('compression', True):
                self.format_converter.enable_draco_compression(True)
            
            # 4. 文件拆分和格式转换
            output_files = self.file_splitter.split_large_mesh(
                mesh_data, output_dir, os.path.basename(fbx_path)
            )
            
            # 5. 生成tileset.json
            tileset = self.coord_converter.create_tileset_json(
                self.calculate_bounding_box(earth_vertices),
                output_files
            )
            
            with open(os.path.join(output_dir, 'tileset.json'), 'w') as f:
                json.dump(tileset, f, indent=2)
                
            return True, output_files
            
        except Exception as e:
            return False, str(e)
    
    def calculate_bounding_box(self, vertices):
        """计算包围盒"""
        if not vertices:
            return [0, 0, 0, 0, 0, 0]
            
        x_coords = vertices[0::3]
        y_coords = vertices[1::3] 
        z_coords = vertices[2::3]
        
        return [
            min(x_coords), min(y_coords), min(z_coords),
            max(x_coords), max(y_coords), max(z_coords)
        ]

# 启动应用程序
if __name__ == "__main__":
    root = tk.Tk()
    app = FBXTo3DTilesConverter(root)
    root.mainloop()

4. 技术要点说明

4.1 坐标系统处理

本程序采用WGS84地理坐标系(EPSG:4326)到地心地固坐标系(EPSG:4978)的转换,确保3D模型能够正确放置在真实地理位置上。北京默认坐标设置为天安门位置(经度116.3974,纬度39.9093)。

4.2 文件格式选择

  • GLB格式:标准的glTF二进制格式,适用于WebGL和大多数3D引擎
  • B3DM格式:3D Tiles的批处理3D模型格式,包含额外的要素表和批处理表,适合大规模场景

4.3 压缩技术应用

程序支持Draco顶点压缩,可显著减少文件大小而不损失视觉质量。压缩比率根据模型复杂度自动调整,确保在保持质量的同时最大化压缩效果。

4.4 性能优化策略

  • 采用增量式文件处理,避免内存溢出
  • 实现智能网格分割算法,保持模型完整性
  • 多线程处理大型文件转换任务

该解决方案提供了完整的FBX到3DTiles转换工作流,具备良好的用户体验和可靠的技术实现,能够满足大规模3D地理数据处理的需求。


参考来源

相关推荐
aqi0013 分钟前
15天学会AI应用开发(十三)上下文与RAG的阶段性总结
人工智能·python·大模型·ai编程·ai应用
程序员杰哥15 分钟前
接口测试知识总结
自动化测试·软件测试·python·测试工具·职场和发展·测试用例·接口测试
大海变好AI29 分钟前
专业的智能体算力架构高性价比品牌公司
python
江华森1 小时前
深入理解 Flask 实现原理
后端·python·flask
FriendshipT1 小时前
Ultralytics:解读SPPF模块
人工智能·pytorch·python·深度学习·目标检测
AOwhisky1 小时前
Python 基础语法(上篇 + 下篇)——综合自测题
linux·windows·python
埃博拉酱2 小时前
Pip/Conda 混用导致的 CRT 版本冲突问题:[WinError 1114] 动态链接库(DLL)初始化例程失败
windows·python
刘小八2 小时前
LangGraph 人机交互实战:Interrupt、人工审批与工作流恢复
人工智能·python·人机交互
YMWM_2 小时前
python-venv虚拟环境
linux·开发语言·python