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地理数据处理的需求。


参考来源

相关推荐
默_笙1 天前
🍙 给每个请求过安检:FastAPI 是怎么把校验写进类型注解的
python
qq_426003961 天前
启动playwright录制codegen生成自动化测试脚本
python·自动化
虎头金猫1 天前
4K 视频总卡在公网带宽?用 N1 + OpenList 把网盘播放链路重新理顺
运维·服务器·网络·python·容器·beautifulsoup·pandas
长沙三为智能科技1 天前
家政小程序开发从0到上线:五阶段交付流程与验收清单
python
伞伞悦读1 天前
【第38期】Python 模块与包详解:import、from、模块搜索路径、包结构和 __init__
开发语言·python
只睡四小时1 天前
Canvas 弹道联机实战:700 行 + 固定时间步长
python·websocket·html5·游戏开发·canvas
奇思妙想聪明勤奋的小羊1 天前
DeepAgents第5章:子Agent 与上下文隔离—让 Agent学会委派
人工智能·python·学习·语言模型
lpfasd1231 天前
2026年第38周GitHub趋势周报
python·科技·github
IZero071 天前
Jev 与 Laya
python·语言模型