glTF 2.1 来了:向后兼容的大场景进化

2026-06-12 19 预计阅读时间: 1 分钟
来源: oschina.net AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:11 分钟

2017 年 glTF 2.0 发布时,它解决的是「3D 资产怎么交换」的问题——一个统一的、自描述的 JSON+二进制格式,让 Blender、Unity、Three.js 不再各自造轮子。七年过去,围绕 2.0 已经长出了网格压缩(KHR_mesh_quantization)、纹理优化(KHR_texture_basisu)、3D 高斯泼溅等一整套扩展生态。但生态越丰富,一个矛盾就越尖锐:当场景从单个模型变成城市级、工厂级的大规模组合时,glTF 2.0 的核心规范开始力不从心。

Khronos 3D 格式工作组刚刚宣布了 glTF 2.1 的计划——不是推翻重来,而是在严格向后兼容的前提下,针对「大场景」这一个核心痛点做精准修订。

七年生态,一个瓶颈

glTF 2.0 的设计假设是:一个文件描述一个完整场景。这在展示单个角色、一辆车、一间房时完全够用。但现实中的 3D 工作流早就突破了这个边界:

  • 数字孪生项目需要把几千个设备模型组合成一个工厂场景;
  • 开放世界游戏需要流式加载地形和建筑;
  • 3D 高斯泼溅的数据量动辄数百 MB 甚至 GB。

目前的做法是靠扩展(extensions)和外部工具链来拼凑,每个团队各搞一套分片策略,没有统一的引用和加载语义。glTF 2.1 要做的,就是把这些实践中反复出现的模式收进核心规范。

2.1 的核心方向:大场景的标准化拆分与引用

根据 Khronos 公布的计划,2.1 的修订范围非常克制,核心动机只有一个——让 glTF 在处理大型组合场景时不再依赖临时扩展。可以预期的新特性方向包括:

1. 场景分片与外部引用

2.0 中所有数据必须内嵌在同一个 glTF 文件里(或通过 BIN 引用同一文件的二进制块)。2.1 很可能引入跨文件引用机制——一个主文件可以声明式地引用子场景文件,浏览器或引擎按需加载,而不是一次性读入全部数据。

2. 大规模实体的索引优化

当节点数量从几十变成几万,JSON 数组的线性扫描和内存占用就成了瓶颈。2.1 可能在核心层面支持更紧凑的索引结构或分块布局。

3. 对 3D 高斯泼溅的原生友好

高斯泼溅不是传统网格,它的数据结构(位置、协方差、颜色、透明度)更像粒子系统。目前靠 KHR_extension 搬进 glTF,2.1 有机会在核心规范中为这类非网格数据提供更自然的容器。

向后兼容是硬约束——所有 2.0 文件在 2.1 解析器中必须原样工作,新特性全部通过新增字段或新语义实现,不修改已有字段的含义。

实践:用现有 glTF 工具链准备大场景分片

2.1 规范还在制定中,但大场景分片的思路现在就可以落地。下面是一个用 Python pygltflib 把一个大场景拆成主文件 + 子模块的示例,这种模式与 2.1 预期的引用方向一致,迁移时只需替换引用声明即可。

# pip install pygltflib

from pygltflib import GLTF2, Scene, Node, Mesh, Primitive, Buffer, BufferView, Accessor
import json, struct, os

def create_sub_scene_gltf(name, positions, indices, output_dir):
    """创建一个独立的子场景 glTF 文件(比如一个建筑模块)"""
    # 将顶点和索引打包为二进制
    pos_bytes = b''.join(struct.pack('3f', *p) for p in positions)
    idx_bytes = b''.join(struct.pack('H', i) for i in indices)

    bin_data = pos_bytes + idx_bytes
    bin_path = os.path.join(output_dir, f'{name}.bin')
    with open(bin_path, 'wb') as f:
        f.write(bin_data)

    pos_len = len(pos_bytes)
    idx_len = len(idx_bytes)

    gltf = GLTF2()
    gltf.buffers = [Buffer(uri=f'{name}.bin', byteLength=len(bin_data))]
    gltf.bufferViews = [
        BufferView(buffer=0, byteOffset=0, byteLength=pos_len, target=34962),  # ARRAY_BUFFER
        BufferView(buffer=0, byteOffset=pos_len, byteLength=idx_len, target=34963),  # ELEMENT_ARRAY_BUFFER
    ]
    gltf.accessors = [
        Accessor(bufferView=0, componentType=5126, count=len(positions), type='VEC3', max=[max(p[i] for p in positions) for i in range(3)], min=[min(p[i] for p in positions) for i in range(3)]),
        Accessor(bufferView=1, componentType=5123, count=len(indices), type='SCALAR', max=[max(indices)], min=[min(indices)]),
    ]
    gltf.meshes = [Mesh(primitives=[Primitive(attributes={'POSITION': 0}, indices=1)])]
    gltf.nodes = [Node(mesh=0)]
    gltf.scenes = [Scene(nodes=[0])]
    gltf.scene = 0

    gltf_path = os.path.join(output_dir, f'{name}.gltf')
    gltf.save(gltf_path)
    return gltf_path


def create_master_scene(sub_scene_names, output_dir):
    """创建主场景文件,引用子模块。
    目前 glTF 2.0 不支持跨文件引用,这里用自定义扩展模拟,
    等 2.1 标准化后替换为正式引用字段即可。"""
    # 主场景本身可以是空的,只声明引用关系
    master = GLTF2()
    master.extensionsUsed = ['KHR_scene_reference']  # 自定义占位
    master.extensions = {
        'KHR_scene_reference': {
            'references': [
                {'uri': f'{name}.gltf', 'transform': [1, 0, 0, 0, 1, 0, 0, 0, 1, tx, 0, ty, tz]}
                for name, (tx, ty, tz) in zip(sub_scene_names, [(0, 0, 0), (10, 0, 0), (20, 0, 0)])
            ]
        }
    }
    master.scenes = [Scene(nodes=[])]
    master.scene = 0

    master_path = os.path.join(output_dir, 'master.gltf')
    master.save(master_path)
    return master_path


# --- 使用示例 ---
output_dir = './split_scene'
os.makedirs(output_dir, exist_ok=True)

# 模块 A:一个简单立方体
cube_positions = [[-1,-1,-1],[1,-1,-1],[1,1,-1],[-1,1,-1],[-1,-1,1],[1,-1,1],[1,1,1],[-1,1,1]]
cube_indices = [0,1,2,0,2,3,4,5,6,4,6,7,0,4,7,0,7,3,1,5,6,1,6,2,3,7,6,3,6,2,0,1,5,0,5,4]
create_sub_scene_gltf('module_a', cube_positions, cube_indices, output_dir)

# 模块 B:偏移位置的另一组几何(示例用相同数据)
create_sub_scene_gltf('module_b', cube_positions, cube_indices, output_dir)

# 主文件
create_master_scene(['module_a', 'module_b'], output_dir)

print('场景拆分完成,检查 ./split_scene/ 目录')

运行后 ./split_scene/ 目录下会生成 module_a.gltfmodule_a.binmodule_b.gltfmodule_b.binmaster.gltf。每个子模块是独立的合法 glTF 2.0 文件,可以单独加载验证;主文件用自定义扩展声明引用关系。当 glTF 2.1 正式引入跨文件引用后,只需把 extensions 里的占位声明换成标准字段,子文件无需改动。

用 Three.js 按需加载子场景

拆分之后,加载策略也要跟上。下面是 Three.js 中按需加载子模块的典型模式:

// 前端按需加载子场景模块
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';

const loader = new GLTFLoader();
const sceneModules = {};  // 缓存已加载模块

async function loadModule(name, position) {
  if (sceneModules[name]) return sceneModules[name];

  const gltf = await loader.loadAsync(`./split_scene/${name}.gltf`);
  gltf.scene.position.set(position.x, position.y, position.z);
  scene.add(gltf.scene);
  sceneModules[name] = gltf.scene;
  return gltf.scene;
}

// 只加载视口内可见的模块,模拟流式加载
function loadVisibleModules(camera, moduleRegistry) {
  moduleRegistry.forEach(mod => {
    if (isInFrustum(camera, mod.bounds) && !sceneModules[mod.name]) {
      loadModule(mod.name, mod.position);
    }
  });
}

这种「主文件描述结构 + 子文件按需加载」的模式,正是 glTF 2.1 要在规范层面标准化的东西。

迁移与采用建议

glTF 2.1 还在制定阶段,但以下几点现在就可以准备:

  • 检查你的导出流程是否依赖单文件假设:如果 Blender/3ds Max 导出时总是把所有内容塞进一个 .glb,评估拆分导出的可行性。
  • 梳理你用到的扩展:KHR_mesh_quantization、KHR_texture_basisu 这些已经成熟的扩展大概率会保持现状,但涉及场景结构的扩展(比如自定义的分片方案)需要关注 2.1 是否提供了替代的标准字段。
  • 验证向后兼容:2.1 的承诺是 2.0 文件零改动即可运行,但你的解析器如果硬编码了「所有数据必须在一个文件里」的假设,就需要提前改造。
  • 关注高斯泼溅数据:如果你的工作流涉及 3DGS,留意 2.1 对非网格数据的标准化方案,这可能是最大的数据结构变化。

2.1 不是一次大重构,而是七年实践后的精准补刀。对大多数团队来说,最好的准备方式不是等规范发布再动手,而是现在就把大场景的拆分和按需加载做进工程——等 2.1 落地时,你只需要把自定义方案换成标准声明,其余代码一行不用改。


相关推荐