@volter/blender-engine 0.1.147 → 0.1.149
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/README.md +4 -11
- package/browser/addons/vrm/LICENSE_(OPTION1)_MIT.txt +22 -0
- package/browser/addons/vrm/LICENSE_(OPTION2)_GPL-3_0.txt +674 -0
- package/browser/addons/vrm/LICENSE_MAIN.txt +1 -0
- package/browser/addons/vrm/README.txt +3 -0
- package/browser/addons/vrm/__init__.py +41 -0
- package/browser/addons/vrm/blender_manifest.toml +16 -0
- package/browser/addons/vrm/common/__init__.py +1 -0
- package/browser/addons/vrm/common/animation.py +58 -0
- package/browser/addons/vrm/common/blender_manifest.py +82 -0
- package/browser/addons/vrm/common/char.py +16 -0
- package/browser/addons/vrm/common/convert.py +287 -0
- package/browser/addons/vrm/common/convert_any.py +48 -0
- package/browser/addons/vrm/common/debug.py +40 -0
- package/browser/addons/vrm/common/deep.py +162 -0
- package/browser/addons/vrm/common/error_dialog.py +274 -0
- package/browser/addons/vrm/common/fs.py +71 -0
- package/browser/addons/vrm/common/gl.py +20 -0
- package/browser/addons/vrm/common/gltf.py +1149 -0
- package/browser/addons/vrm/common/human_bone_mapper/__init__.py +1 -0
- package/browser/addons/vrm/common/human_bone_mapper/biped_mapping.py +84 -0
- package/browser/addons/vrm/common/human_bone_mapper/cats_blender_plugin_fix_model_mapping.py +63 -0
- package/browser/addons/vrm/common/human_bone_mapper/human_bone_mapper.py +203 -0
- package/browser/addons/vrm/common/human_bone_mapper/microsoft_rocketbox_mapping.py +69 -0
- package/browser/addons/vrm/common/human_bone_mapper/mixamo_mapping.py +62 -0
- package/browser/addons/vrm/common/human_bone_mapper/mmd_mapping.py +115 -0
- package/browser/addons/vrm/common/human_bone_mapper/mpfb_mapping.py +120 -0
- package/browser/addons/vrm/common/human_bone_mapper/ready_player_me_mapping.py +65 -0
- package/browser/addons/vrm/common/human_bone_mapper/reallusion_mapping.py +94 -0
- package/browser/addons/vrm/common/human_bone_mapper/rigify_meta_rig_mapping.py +65 -0
- package/browser/addons/vrm/common/human_bone_mapper/structure_based_mapping.py +891 -0
- package/browser/addons/vrm/common/human_bone_mapper/unreal_mapping.py +62 -0
- package/browser/addons/vrm/common/human_bone_mapper/vrm_addon_mapping.py +112 -0
- package/browser/addons/vrm/common/human_bone_mapper/vroid_mapping.py +89 -0
- package/browser/addons/vrm/common/legacy_gltf.py +16 -0
- package/browser/addons/vrm/common/logger.py +84 -0
- package/browser/addons/vrm/common/mtoon0.blend +0 -0
- package/browser/addons/vrm/common/mtoon1.blend +0 -0
- package/browser/addons/vrm/common/mtoon1_outline.blend +0 -0
- package/browser/addons/vrm/common/mtoon_auto_setup.blend +0 -0
- package/browser/addons/vrm/common/mtoon_unversioned.py +62 -0
- package/browser/addons/vrm/common/ops/__init__.py +10 -0
- package/browser/addons/vrm/common/ops/export_scene.py +67 -0
- package/browser/addons/vrm/common/ops/icyp.py +53 -0
- package/browser/addons/vrm/common/ops/import_scene.py +57 -0
- package/browser/addons/vrm/common/ops/vrm.py +2380 -0
- package/browser/addons/vrm/common/ops/wm.py +167 -0
- package/browser/addons/vrm/common/preferences.py +362 -0
- package/browser/addons/vrm/common/progress.py +87 -0
- package/browser/addons/vrm/common/rotation.py +89 -0
- package/browser/addons/vrm/common/safe_removal.py +46 -0
- package/browser/addons/vrm/common/scene_watcher.py +230 -0
- package/browser/addons/vrm/common/shader.py +2243 -0
- package/browser/addons/vrm/common/shape_key_mapper/__init__.py +1 -0
- package/browser/addons/vrm/common/shape_key_mapper/arkit_mapping.py +168 -0
- package/browser/addons/vrm/common/shape_key_mapper/mmd_mapping.py +62 -0
- package/browser/addons/vrm/common/shape_key_mapper/ready_player_me_mapping.py +52 -0
- package/browser/addons/vrm/common/shape_key_mapper/vrchat_mapping.py +17 -0
- package/browser/addons/vrm/common/third_party_user_extension.py +214 -0
- package/browser/addons/vrm/common/transparent_z_write.blend +0 -0
- package/browser/addons/vrm/common/version.py +161 -0
- package/browser/addons/vrm/common/vrm0/__init__.py +1 -0
- package/browser/addons/vrm/common/vrm0/human_bone.py +662 -0
- package/browser/addons/vrm/common/vrm0/material_property.py +346 -0
- package/browser/addons/vrm/common/vrm1/__init__.py +1 -0
- package/browser/addons/vrm/common/vrm1/expression_preset.py +207 -0
- package/browser/addons/vrm/common/vrm1/human_bone.py +822 -0
- package/browser/addons/vrm/common/workspace.py +227 -0
- package/browser/addons/vrm/common/writable_context.py +77 -0
- package/browser/addons/vrm/documentation/en-us/animation/index.md +63 -0
- package/browser/addons/vrm/documentation/en-us/create-humanoid-vrm-from-scratch/index.md +131 -0
- package/browser/addons/vrm/documentation/en-us/create-simple-vrm-from-scratch/index.md +30 -0
- package/browser/addons/vrm/documentation/en-us/development/index.md +136 -0
- package/browser/addons/vrm/documentation/en-us/index.md +41 -0
- package/browser/addons/vrm/documentation/en-us/installation/index.md +152 -0
- package/browser/addons/vrm/documentation/en-us/material-mtoon/index.md +122 -0
- package/browser/addons/vrm/documentation/en-us/material-pbr/index.md +94 -0
- package/browser/addons/vrm/documentation/en-us/scripting-api/index.md +391 -0
- package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrm/index.md +57 -0
- package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrma/index.md +17 -0
- package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrm/index.md +61 -0
- package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrma/index.md +13 -0
- package/browser/addons/vrm/documentation/ja-jp/animation/index.md +57 -0
- package/browser/addons/vrm/documentation/ja-jp/create-humanoid-vrm-from-scratch/index.md +129 -0
- package/browser/addons/vrm/documentation/ja-jp/create-simple-vrm-from-scratch/index.md +29 -0
- package/browser/addons/vrm/documentation/ja-jp/development/index.md +137 -0
- package/browser/addons/vrm/documentation/ja-jp/index.md +37 -0
- package/browser/addons/vrm/documentation/ja-jp/installation/index.md +150 -0
- package/browser/addons/vrm/documentation/ja-jp/material-mtoon/index.md +113 -0
- package/browser/addons/vrm/documentation/ja-jp/material-pbr/index.md +90 -0
- package/browser/addons/vrm/documentation/ja-jp/scripting-api/index.md +389 -0
- package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrm/index.md +55 -0
- package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrma/index.md +19 -0
- package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrm/index.md +56 -0
- package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrma/index.md +14 -0
- package/browser/addons/vrm/editor/__init__.py +1 -0
- package/browser/addons/vrm/editor/extension.py +425 -0
- package/browser/addons/vrm/editor/extension_accessor.py +75 -0
- package/browser/addons/vrm/editor/handler.py +84 -0
- package/browser/addons/vrm/editor/khr_character/__init__.py +1 -0
- package/browser/addons/vrm/editor/khr_character/panel.py +69 -0
- package/browser/addons/vrm/editor/khr_character/property_group.py +27 -0
- package/browser/addons/vrm/editor/khr_xmp_json_ld/__init__.py +1 -0
- package/browser/addons/vrm/editor/khr_xmp_json_ld/ops.py +464 -0
- package/browser/addons/vrm/editor/khr_xmp_json_ld/panel.py +136 -0
- package/browser/addons/vrm/editor/khr_xmp_json_ld/property_group.py +83 -0
- package/browser/addons/vrm/editor/khr_xmp_json_ld/ui_list.py +198 -0
- package/browser/addons/vrm/editor/make_armature.py +709 -0
- package/browser/addons/vrm/editor/menu.py +147 -0
- package/browser/addons/vrm/editor/migration.py +297 -0
- package/browser/addons/vrm/editor/mtoon1/__init__.py +1 -0
- package/browser/addons/vrm/editor/mtoon1/handler.py +20 -0
- package/browser/addons/vrm/editor/mtoon1/migration.py +536 -0
- package/browser/addons/vrm/editor/mtoon1/ops.py +304 -0
- package/browser/addons/vrm/editor/mtoon1/panel.py +442 -0
- package/browser/addons/vrm/editor/mtoon1/property_group.py +4977 -0
- package/browser/addons/vrm/editor/mtoon1/scene_watcher.py +381 -0
- package/browser/addons/vrm/editor/node_constraint1/__init__.py +1 -0
- package/browser/addons/vrm/editor/node_constraint1/panel.py +317 -0
- package/browser/addons/vrm/editor/node_constraint1/property_group.py +25 -0
- package/browser/addons/vrm/editor/ops.py +471 -0
- package/browser/addons/vrm/editor/panel.py +315 -0
- package/browser/addons/vrm/editor/property_group.py +888 -0
- package/browser/addons/vrm/editor/search.py +682 -0
- package/browser/addons/vrm/editor/spring_bone1/__init__.py +1 -0
- package/browser/addons/vrm/editor/spring_bone1/handler.py +994 -0
- package/browser/addons/vrm/editor/spring_bone1/menu.py +246 -0
- package/browser/addons/vrm/editor/spring_bone1/migration.py +86 -0
- package/browser/addons/vrm/editor/spring_bone1/ops.py +1679 -0
- package/browser/addons/vrm/editor/spring_bone1/panel.py +605 -0
- package/browser/addons/vrm/editor/spring_bone1/property_group.py +1457 -0
- package/browser/addons/vrm/editor/spring_bone1/ui_list.py +408 -0
- package/browser/addons/vrm/editor/subscription.py +123 -0
- package/browser/addons/vrm/editor/t_pose.py +713 -0
- package/browser/addons/vrm/editor/validation.py +1271 -0
- package/browser/addons/vrm/editor/vrm0/__init__.py +1 -0
- package/browser/addons/vrm/editor/vrm0/gizmo_group.py +63 -0
- package/browser/addons/vrm/editor/vrm0/handler.py +23 -0
- package/browser/addons/vrm/editor/vrm0/menu.py +145 -0
- package/browser/addons/vrm/editor/vrm0/migration.py +761 -0
- package/browser/addons/vrm/editor/vrm0/ops.py +2145 -0
- package/browser/addons/vrm/editor/vrm0/panel.py +1333 -0
- package/browser/addons/vrm/editor/vrm0/property_group.py +1818 -0
- package/browser/addons/vrm/editor/vrm0/ui_list.py +688 -0
- package/browser/addons/vrm/editor/vrm1/__init__.py +1 -0
- package/browser/addons/vrm/editor/vrm1/handler.py +28 -0
- package/browser/addons/vrm/editor/vrm1/menu.py +76 -0
- package/browser/addons/vrm/editor/vrm1/migration.py +321 -0
- package/browser/addons/vrm/editor/vrm1/ops.py +2679 -0
- package/browser/addons/vrm/editor/vrm1/panel.py +1243 -0
- package/browser/addons/vrm/editor/vrm1/property_group.py +2087 -0
- package/browser/addons/vrm/editor/vrm1/scene_watcher.py +90 -0
- package/browser/addons/vrm/editor/vrm1/ui_list.py +533 -0
- package/browser/addons/vrm/exporter/__init__.py +1 -0
- package/browser/addons/vrm/exporter/abstract_base_vrm_exporter.py +628 -0
- package/browser/addons/vrm/exporter/export_scene.py +930 -0
- package/browser/addons/vrm/exporter/gltf2_export_user_extension.py +45 -0
- package/browser/addons/vrm/exporter/khr_character_exporter.py +34 -0
- package/browser/addons/vrm/exporter/vrm0_exporter.py +4020 -0
- package/browser/addons/vrm/exporter/vrm1_exporter.py +3682 -0
- package/browser/addons/vrm/exporter/vrm_animation_exporter.py +1035 -0
- package/browser/addons/vrm/external/__init__.py +1 -0
- package/browser/addons/vrm/external/auto_rig_pro_support.py +3 -0
- package/browser/addons/vrm/external/io_scene_gltf2_support.py +418 -0
- package/browser/addons/vrm/external/rigify_support.py +1 -0
- package/browser/addons/vrm/importer/__init__.py +1 -0
- package/browser/addons/vrm/importer/abstract_base_vrm_importer.py +1548 -0
- package/browser/addons/vrm/importer/file_handler.py +34 -0
- package/browser/addons/vrm/importer/gltf2_import_user_extension.py +268 -0
- package/browser/addons/vrm/importer/import_scene.py +646 -0
- package/browser/addons/vrm/importer/license_validation.py +198 -0
- package/browser/addons/vrm/importer/vrm0_importer.py +1723 -0
- package/browser/addons/vrm/importer/vrm1_importer.py +2014 -0
- package/browser/addons/vrm/importer/vrm_animation_importer.py +763 -0
- package/browser/addons/vrm/importer/vrm_diff.py +316 -0
- package/browser/addons/vrm/locale/__init__.py +1 -0
- package/browser/addons/vrm/locale/ja_jp.py +826 -0
- package/browser/addons/vrm/locale/ruff.toml +8 -0
- package/browser/addons/vrm/locale/translation_dictionary.py +10 -0
- package/browser/addons/vrm/locale/zh_hans.py +928 -0
- package/browser/addons/vrm/registration.py +670 -0
- package/browser/addons/vrm.json +18 -0
- package/browser/blender-emscripten-engine.mts +60 -128
- package/browser/blender-engine.mts +20 -186
- package/browser/blender-wali-engine.mts +35 -81
- package/browser/describe-thrown.mts +3 -6
- package/browser/document-chunks.mts +4 -16
- package/browser/gltf_export.py +3161 -0
- package/browser/heap-growth.mts +23 -0
- package/browser/index.ts +2 -0
- package/browser/protocol.ts +119 -35
- package/browser/pull-job.mts +2 -10
- package/browser/rna.ts +190 -73
- package/browser/runtime.ts +197 -82
- package/browser/session-addons.mts +33 -0
- package/browser/session-frame.mts +5 -24
- package/browser/session.py +1338 -1427
- package/browser/three/attach-presenter.ts +11 -39
- package/browser/three/blender-base64.ts +1 -36
- package/browser/three/blender-corner-normals.ts +1 -23
- package/browser/three/blender-display-shaders.json +1 -1
- package/browser/three/blender-display-shaders.lut +0 -0
- package/browser/three/blender-display-transform.ts +2 -1
- package/browser/three/blender-frame-columns.ts +5 -13
- package/browser/three/blender-graph-material.ts +18 -3
- package/browser/three/blender-node-graph.ts +10 -34
- package/browser/three/blender-physical-material.ts +2 -31
- package/browser/three/blender-runtime-armature.ts +7 -60
- package/browser/three/blender-runtime-frame.ts +9 -23
- package/browser/three/blender-runtime-geometry.ts +22 -26
- package/browser/three/blender-runtime-lighting.ts +48 -178
- package/browser/three/blender-runtime-shadows.ts +187 -1
- package/browser/three/blender-runtime-skeleton.ts +53 -1
- package/browser/three/blender-runtime-view.ts +505 -283
- package/browser/three/blender-runtime-weights.ts +9 -72
- package/browser/three/blender-sky.ts +13 -33
- package/browser/three/blender-triangulate.ts +7 -35
- package/browser/three/blender-viewport-sampling.ts +235 -0
- package/browser/three/blender-workbench-material.ts +25 -29
- package/browser/three/blender-workbench-outline.ts +199 -0
- package/browser/three/blender-world-volume.ts +62 -10
- package/browser/three/presenter.ts +10 -26
- package/browser/three/release.ts +5 -21
- package/browser/three/sky-precompute-worker.ts +1 -18
- package/browser/three/sky-worker.ts +5 -23
- package/browser/three/world-field-sampler.ts +12 -26
- package/browser/volter_three.py +9 -105
- package/browser/worker.ts +156 -151
- package/package.json +5 -2
- package/wasm/BUNDLE.json +17 -17
- package/wasm/DEPENDENCY-LICENSES.txt +14 -0
- package/wasm/blender_browser.data.br +0 -0
- package/wasm/blender_browser.js +1 -1
- package/wasm/blender_browser.wasm.br +0 -0
- package/browser/frame-transfer.mts +0 -19
- package/browser/three/agx-base-srgb.lut +0 -0
- package/browser/three/agx-look-medium-high-contrast.lut +0 -0
- package/browser/three/agx-look-punchy.lut +0 -0
- package/browser/three/blender-agx.ts +0 -235
- package/browser/three/blender-display-lut.ts +0 -145
- package/browser/three/blender-filmic.ts +0 -49
- package/browser/three/blender-standard.ts +0 -68
- package/browser/three/blender-world-visibility.ts +0 -278
- package/browser/three/filmic-srgb.lut +0 -0
|
@@ -0,0 +1,3161 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Export components and levels through Blender’s glTF exporter. Components include view-layer
|
|
3
|
+
content outside Notes and References and carry one animation per action. Levels replace linked
|
|
4
|
+
placements with markers carrying component identity, action, tint, visibility and NLA metadata,
|
|
5
|
+
and export their own scene animation. Standard glTF extensions describe display transforms,
|
|
6
|
+
environment, render settings, light units, ray visibility, animation tracks and blending data.
|
|
7
|
+
Collection membership, timelines and keyed-bone masks preserve authored relationships. Preserve
|
|
8
|
+
quaternion key signs and lengths for Blender-compatible layering, and carry declared runtime
|
|
9
|
+
constraints without baking them. Temporary objects, collection state, poses and keyed values are
|
|
10
|
+
restored before answering; export creates no save, history step or present. A sibling manifest
|
|
11
|
+
records source, exporter, settings and artifact identities so unchanged exports can return
|
|
12
|
+
current. This module runs in session.py or headless desktop Blender: blender -b --factory-startup
|
|
13
|
+
<file.blend> --python gltf_export.py -- <component|level> <out.glb> <project> [--force]."""
|
|
14
|
+
import bisect
|
|
15
|
+
import hashlib
|
|
16
|
+
import importlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import struct
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
import types
|
|
24
|
+
|
|
25
|
+
import bpy
|
|
26
|
+
import math
|
|
27
|
+
import mathutils
|
|
28
|
+
|
|
29
|
+
FORMAT = 1
|
|
30
|
+
AUTHORING = ("Notes", "References")
|
|
31
|
+
|
|
32
|
+
# RFC 0007 section 1, exactly.
|
|
33
|
+
COMPONENT_SETTINGS = {
|
|
34
|
+
"export_format": "GLB",
|
|
35
|
+
"export_yup": True,
|
|
36
|
+
"export_skins": True,
|
|
37
|
+
"export_animations": True,
|
|
38
|
+
"export_animation_mode": "ACTIONS",
|
|
39
|
+
"export_force_sampling": True,
|
|
40
|
+
"export_extras": True,
|
|
41
|
+
# What Blender renders, not the raw mesh: modifiers applied (the exporter leaves an Armature
|
|
42
|
+
# modifier to the skin). The scene's lights and cameras go too.
|
|
43
|
+
"export_apply": True,
|
|
44
|
+
"export_cameras": True,
|
|
45
|
+
"export_lights": True,
|
|
46
|
+
# Light units as Blender renders them and three.js reads them: a sun's W/m^2 as is, a point or
|
|
47
|
+
# spot's watts over 4 pi. The default (SPEC) multiplies by 683 lm/W.
|
|
48
|
+
"export_import_convert_lighting_mode": "COMPAT",
|
|
49
|
+
}
|
|
50
|
+
# Sections 2 and 3: the same exporter and settings; the timeline as ONE scene animation (the
|
|
51
|
+
# exporter splits SCENE mode per object unless told not to).
|
|
52
|
+
LEVEL_SETTINGS = dict(COMPONENT_SETTINGS, export_animation_mode="SCENE", export_anim_scene_split_object=False)
|
|
53
|
+
|
|
54
|
+
# Override edits a marker carries (D1), and the hierarchy bookkeeping every override has.
|
|
55
|
+
CARRIED = ("location", "rotation_euler", "rotation_quaternion", "rotation_axis_angle", "scale",
|
|
56
|
+
"hide_render", "hide_viewport", "animation_data.action", "color")
|
|
57
|
+
STRUCTURAL = ("parent", "matrix_parent_inverse", "data")
|
|
58
|
+
|
|
59
|
+
_HOOK_MODULE = "volter_gltf_export_hooks"
|
|
60
|
+
_HOLDER = "__volter_export__"
|
|
61
|
+
# Display LUT size and log2 shaper range. t = (log2(x + 2^-10) + 10) / 18 maps black to the first sample.
|
|
62
|
+
LUT_SIZE = 48
|
|
63
|
+
LUT_DOMAIN = (-10.0, 8.0)
|
|
64
|
+
LUT_FORMAT = 2
|
|
65
|
+
# Blender's per-object ray visibility, as `STD_ray_visibility` keys.
|
|
66
|
+
RAY_VISIBILITY = (("camera", "camera"), ("shadow", "shadow"), ("diffuse", "diffuse"), ("glossy", "glossy"),
|
|
67
|
+
("transmission", "transmission"), ("volume", "volume_scatter"))
|
|
68
|
+
# The standard's own `STD_*` extensions (their schemas are the standard's registry), listed in `extensionsUsed` only.
|
|
69
|
+
STD_EXTENSIONS = ("STD_display", "STD_environment", "STD_render", "STD_light", "STD_ray_visibility",
|
|
70
|
+
"STD_material_shadow", "STD_animation_tracks", "STD_animation_blending")
|
|
71
|
+
# Blender's NLA enums as the standard names them (AnimationLayers' blend modes; a strip's extrapolation).
|
|
72
|
+
BLENDS = {"REPLACE": "replace", "ADD": "add", "SUBTRACT": "subtract", "MULTIPLY": "multiply", "COMBINE": "combine"}
|
|
73
|
+
EXTRAPOLATIONS = {"HOLD": "hold", "HOLD_FORWARD": "hold-forward", "NOTHING": "none"}
|
|
74
|
+
# Blender's sky models, by their published names.
|
|
75
|
+
SKY_MODELS = {"NISHITA": "nishita", "SINGLE_SCATTERING": "nishita", "MULTIPLE_SCATTERING": "nishita-multiple",
|
|
76
|
+
"PREETHAM": "preetham", "HOSEK_WILKIE": "hosek-wilkie"}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _sha256(path):
|
|
80
|
+
digest = hashlib.sha256()
|
|
81
|
+
with open(path, "rb") as handle:
|
|
82
|
+
for chunk in iter(lambda: handle.read(1 << 20), b""):
|
|
83
|
+
digest.update(chunk)
|
|
84
|
+
return digest.hexdigest()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _gltf_addon():
|
|
88
|
+
module = next((m for name, m in list(sys.modules.items()) if name.split(".")[-1] == "io_scene_gltf2"), None)
|
|
89
|
+
if module is None:
|
|
90
|
+
import addon_utils
|
|
91
|
+
addon_utils.enable("io_scene_gltf2", default_set=False)
|
|
92
|
+
module = sys.modules.get("io_scene_gltf2")
|
|
93
|
+
if module is None:
|
|
94
|
+
raise RuntimeError("Blender's glTF exporter (io_scene_gltf2) is not available in this Blender.")
|
|
95
|
+
return module
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def exporter_version():
|
|
99
|
+
"""What made a file: Blender, its glTF add-on, and this script (its SHA-256), so a change to any of
|
|
100
|
+
the three exports again."""
|
|
101
|
+
module = _gltf_addon()
|
|
102
|
+
version = getattr(module, "bl_info", {}).get("version", ())
|
|
103
|
+
return {"blender": bpy.app.version_string, "gltf": ".".join(str(part) for part in version),
|
|
104
|
+
"script": _sha256(os.path.abspath(__file__))[:16]}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _extension_class():
|
|
108
|
+
module = _gltf_addon()
|
|
109
|
+
return importlib.import_module(module.__name__ + ".io.com.gltf2_io_extensions").Extension
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _project_relative(path, project):
|
|
113
|
+
absolute = os.path.normpath(os.path.abspath(path))
|
|
114
|
+
if project:
|
|
115
|
+
root = os.path.normpath(os.path.abspath(project))
|
|
116
|
+
relative = os.path.relpath(absolute, root)
|
|
117
|
+
if not relative.startswith(".."):
|
|
118
|
+
return relative.replace(os.sep, "/")
|
|
119
|
+
return absolute.replace(os.sep, "/")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _library_file(library, project):
|
|
123
|
+
path = bpy.path.abspath(library.filepath, library=library.library)
|
|
124
|
+
return _project_relative(path, project)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _hidden_collections(scene):
|
|
128
|
+
"""Collections hidden from renders, a parent hiding its children."""
|
|
129
|
+
hidden = set()
|
|
130
|
+
|
|
131
|
+
def walk(collection, parent_hidden):
|
|
132
|
+
here = parent_hidden or collection.hide_render
|
|
133
|
+
if here:
|
|
134
|
+
hidden.add(collection.name_full)
|
|
135
|
+
for child in collection.children:
|
|
136
|
+
walk(child, here)
|
|
137
|
+
|
|
138
|
+
walk(scene.collection, False)
|
|
139
|
+
return hidden
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _render_visible(obj, hidden):
|
|
143
|
+
if obj.hide_render:
|
|
144
|
+
return False
|
|
145
|
+
owners = [c for c in obj.users_collection]
|
|
146
|
+
return not owners or any(c.name_full not in hidden for c in owners)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _world(scene):
|
|
150
|
+
"""The world as data (`STD_environment`): its surface when it is one Background colour (colour and
|
|
151
|
+
strength) or a sky by its parameters, which a renderer draws as its background and ambient light, or two of
|
|
152
|
+
them mixed by Light Path's Is Camera Ray (what lights the scene, and a `background` the camera sees), and
|
|
153
|
+
its volume when it is a homogeneous medium. Anything else (an image, a node graph) is named: it needs
|
|
154
|
+
a bake beside its parameters. Answers the data and what it could not carry."""
|
|
155
|
+
world = scene.world
|
|
156
|
+
if world is None:
|
|
157
|
+
return None, []
|
|
158
|
+
if not world.use_nodes or world.node_tree is None:
|
|
159
|
+
return {"color": [round(float(c), 6) for c in world.color[:3]], "strength": 1.0}, []
|
|
160
|
+
outputs = [n for n in world.node_tree.nodes if n.type == "OUTPUT_WORLD"]
|
|
161
|
+
output = next((n for n in outputs if n.is_active_output), outputs[0] if outputs else None)
|
|
162
|
+
gaps = []
|
|
163
|
+
links = output.inputs["Surface"].links if output is not None else []
|
|
164
|
+
source = links[0].from_node if links else None
|
|
165
|
+
if source is None:
|
|
166
|
+
data = {"color": [0.0, 0.0, 0.0], "strength": 0.0}
|
|
167
|
+
elif source.type == "MIX_SHADER" and _camera_ray_mix(source):
|
|
168
|
+
# "THE CAMERA SEES ONE THING, THE SCENE IS LIT BY ANOTHER": a Mix Shader by Light Path's Is Camera Ray between
|
|
169
|
+
# two backgrounds. Its first input lights the scene (the radiance); its second is what camera rays see
|
|
170
|
+
# (`background`).
|
|
171
|
+
lit, seen = (_background_radiance(socket.links[0].from_node, world, gaps) for socket in source.inputs[1:3])
|
|
172
|
+
if lit is None or seen is None:
|
|
173
|
+
data = None
|
|
174
|
+
gaps.append("the world %s mixes by camera ray, but an input is not one colour or a sky: it is not carried yet" % world.name)
|
|
175
|
+
else:
|
|
176
|
+
data = dict(lit, background=seen)
|
|
177
|
+
else:
|
|
178
|
+
data = _background_radiance(source, world, gaps)
|
|
179
|
+
if data is None:
|
|
180
|
+
gaps.append("the world %s is a %s graph, not one colour or a sky: it is not carried yet" % (world.name, source.type))
|
|
181
|
+
volume, volume_gap = _world_volume(world, output)
|
|
182
|
+
if volume_gap:
|
|
183
|
+
gaps.append(volume_gap)
|
|
184
|
+
if volume is not None:
|
|
185
|
+
data = dict(data or {"strength": 0.0}, volume=volume)
|
|
186
|
+
gaps.append("the world %s has a volume: an infinite medium, which absorbs the sun and the world's own light before "
|
|
187
|
+
"they arrive, as Blender renders it (a haze around a play space is a volume object, a box)" % world.name)
|
|
188
|
+
return data, gaps
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _camera_ray_mix(node):
|
|
192
|
+
"""A Mix Shader whose factor is Light Path's Is Camera Ray, between two linked backgrounds."""
|
|
193
|
+
factor = node.inputs[0]
|
|
194
|
+
if not factor.is_linked or not node.inputs[1].is_linked or not node.inputs[2].is_linked:
|
|
195
|
+
return False
|
|
196
|
+
link = factor.links[0]
|
|
197
|
+
return link.from_node.type == "LIGHT_PATH" and link.from_socket.name == "Is Camera Ray"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _background_radiance(source, world, gaps):
|
|
201
|
+
"""A Background node as the world's radiance: one colour and strength, or a sky by its published model and physical
|
|
202
|
+
parameters (rule G: a host with a physical sky draws it; a bake for hosts without one goes beside them, never
|
|
203
|
+
instead). None for anything else."""
|
|
204
|
+
if source is None or source.type != "BACKGROUND" or source.inputs[1].is_linked:
|
|
205
|
+
return None
|
|
206
|
+
if not source.inputs[0].is_linked:
|
|
207
|
+
return {"color": [round(float(c), 6) for c in source.inputs[0].default_value[:3]],
|
|
208
|
+
"strength": round(float(source.inputs[1].default_value), 6)}
|
|
209
|
+
sky = source.inputs[0].links[0].from_node
|
|
210
|
+
if sky.type != "TEX_SKY":
|
|
211
|
+
return None
|
|
212
|
+
model = SKY_MODELS.get(sky.sky_type)
|
|
213
|
+
number = lambda value: round(float(value), 6)
|
|
214
|
+
params = {"model": model or str(sky.sky_type).lower(),
|
|
215
|
+
"sun": {"elevation": number(sky.sun_elevation), "rotation": number(sky.sun_rotation),
|
|
216
|
+
"angularDiameter": number(sky.sun_size), "intensity": number(sky.sun_intensity),
|
|
217
|
+
"disc": bool(sky.sun_disc)},
|
|
218
|
+
"altitude": number(sky.altitude), "air": number(sky.air_density),
|
|
219
|
+
"aerosol": number(sky.aerosol_density),
|
|
220
|
+
"ozone": number(sky.ozone_density), "groundAlbedo": number(sky.ground_albedo)}
|
|
221
|
+
if model in ("preetham", "hosek-wilkie"):
|
|
222
|
+
params["turbidity"] = number(sky.turbidity)
|
|
223
|
+
gaps.append("the world %s is a sky: its parameters are carried; a host without a physical sky needs a bake" % world.name)
|
|
224
|
+
if model is None:
|
|
225
|
+
gaps.append("the world %s's sky model %s has no published name in the standard" % (world.name, sky.sky_type))
|
|
226
|
+
return {"sky": params, "strength": round(float(source.inputs[1].default_value), 6)}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _volume_coefficients(node):
|
|
230
|
+
"""A volume shader with unlinked inputs as a homogeneous medium's coefficients per metre (EEVEE's own
|
|
231
|
+
formulas): scattering, absorption and emission (linear RGB) and the phase function's anisotropy.
|
|
232
|
+
An unlinked Add Shader has no medium; unsupported or linked shader inputs raise ValueError."""
|
|
233
|
+
inputs = node.inputs
|
|
234
|
+
def value(name):
|
|
235
|
+
socket = inputs[name]
|
|
236
|
+
if socket.is_linked:
|
|
237
|
+
raise ValueError("its %s is linked" % name)
|
|
238
|
+
return socket.default_value
|
|
239
|
+
def rgb(v):
|
|
240
|
+
return [float(c) for c in v[:3]]
|
|
241
|
+
if node.type == "PRINCIPLED_VOLUME":
|
|
242
|
+
density = max(float(value("Density")), 0.0)
|
|
243
|
+
color = rgb(value("Color"))
|
|
244
|
+
absorption_color = [math.sqrt(max(c, 0.0)) for c in rgb(value("Absorption Color"))]
|
|
245
|
+
scattering = [c * density for c in color]
|
|
246
|
+
absorption = [max(1.0 - c, 0.0) * max(1.0 - a, 0.0) * density for c, a in zip(color, absorption_color)]
|
|
247
|
+
strength = max(float(value("Emission Strength")), 0.0)
|
|
248
|
+
emission = [c * strength for c in rgb(value("Emission Color"))]
|
|
249
|
+
if float(value("Blackbody Intensity")) > 0:
|
|
250
|
+
raise ValueError("its blackbody emission is not carried")
|
|
251
|
+
return scattering, absorption, emission, float(value("Anisotropy"))
|
|
252
|
+
if node.type == "VOLUME_SCATTER":
|
|
253
|
+
density = max(float(value("Density")), 0.0)
|
|
254
|
+
return [c * density for c in rgb(value("Color"))], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], float(value("Anisotropy"))
|
|
255
|
+
if node.type == "VOLUME_ABSORPTION":
|
|
256
|
+
density = max(float(value("Density")), 0.0)
|
|
257
|
+
return [0.0, 0.0, 0.0], [max(1.0 - c, 0.0) * density for c in rgb(value("Color"))], [0.0, 0.0, 0.0], 0.0
|
|
258
|
+
if node.type == "ADD_SHADER":
|
|
259
|
+
parts = [_volume_coefficients(link.from_node) for socket in node.inputs for link in socket.links]
|
|
260
|
+
if not parts:
|
|
261
|
+
return None
|
|
262
|
+
scattering = [sum(p[0][k] for p in parts) for k in range(3)]
|
|
263
|
+
weight = sum(sum(p[0]) for p in parts)
|
|
264
|
+
anisotropy = sum(sum(p[0]) * p[3] for p in parts) / weight if weight > 0 else 0.0
|
|
265
|
+
return (scattering, [sum(p[1][k] for p in parts) for k in range(3)],
|
|
266
|
+
[sum(p[2][k] for p in parts) for k in range(3)], anisotropy)
|
|
267
|
+
raise ValueError("it is a %s node" % node.type)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _world_volume(world, output):
|
|
271
|
+
"""The world's Volume socket as a homogeneous medium (`STD_environment.volume`), and what could not be
|
|
272
|
+
carried. Nothing is dropped silently: a volume that isn't homogeneous is named."""
|
|
273
|
+
socket = output.inputs["Volume"] if output is not None else None
|
|
274
|
+
if socket is None or not socket.is_linked:
|
|
275
|
+
return None, None
|
|
276
|
+
try:
|
|
277
|
+
found = _volume_coefficients(socket.links[0].from_node)
|
|
278
|
+
except ValueError as why:
|
|
279
|
+
return None, "the world %s's volume is not a homogeneous medium (%s): it is not carried" % (world.name, why)
|
|
280
|
+
if found is None:
|
|
281
|
+
return None, None
|
|
282
|
+
scattering, absorption, emission, anisotropy = found
|
|
283
|
+
r6 = lambda values: [round(v, 6) for v in values]
|
|
284
|
+
return {"scattering": r6(scattering), "absorption": r6(absorption), "emission": r6(emission),
|
|
285
|
+
"anisotropy": round(max(-1.0, min(1.0, anisotropy)), 6)}, None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _object_volumes(objects, visibility):
|
|
289
|
+
"""BOUNDED VOLUMES (`STD_volume`): a mesh whose material is a homogeneous volume and no surface (a haze or fog
|
|
290
|
+
bank's box) is the volume's domain: its coefficients per metre, as the world's are, and its shape (its bounding
|
|
291
|
+
box, a box when the mesh is one; else the box around it, named), in the node's own Y-up frame. The node is hidden
|
|
292
|
+
(KHR_node_visibility), as Blender renders no surface there. Any other volume (beside a surface, not homogeneous)
|
|
293
|
+
is named, not carried. Answers {object: data} and the warnings."""
|
|
294
|
+
found, warnings = {}, []
|
|
295
|
+
for obj in objects:
|
|
296
|
+
if obj.type != "MESH" or not visibility[obj.name]:
|
|
297
|
+
continue
|
|
298
|
+
volumes = []
|
|
299
|
+
for slot in obj.material_slots:
|
|
300
|
+
material = slot.material
|
|
301
|
+
if material is None or not material.use_nodes or material.node_tree is None:
|
|
302
|
+
continue
|
|
303
|
+
outputs = [n for n in material.node_tree.nodes if n.type == "OUTPUT_MATERIAL"]
|
|
304
|
+
output = next((n for n in outputs if n.is_active_output), outputs[0] if outputs else None)
|
|
305
|
+
if output is not None and output.inputs["Volume"].is_linked:
|
|
306
|
+
volumes.append((material, output))
|
|
307
|
+
if not volumes:
|
|
308
|
+
continue
|
|
309
|
+
material, output = volumes[0]
|
|
310
|
+
surface = output.inputs["Surface"]
|
|
311
|
+
if len(obj.material_slots) != 1 or surface.is_linked:
|
|
312
|
+
warnings.append("%s's material %s has a volume beside a surface: it is not carried (a volume's domain is a mesh "
|
|
313
|
+
"of a volume material alone)" % (obj.name, material.name))
|
|
314
|
+
continue
|
|
315
|
+
try:
|
|
316
|
+
coefficients = _volume_coefficients(output.inputs["Volume"].links[0].from_node)
|
|
317
|
+
except ValueError as why:
|
|
318
|
+
warnings.append("%s's volume is not a homogeneous medium (%s): it is not carried" % (obj.name, why))
|
|
319
|
+
continue
|
|
320
|
+
if coefficients is None:
|
|
321
|
+
continue
|
|
322
|
+
scattering, absorption, emission, anisotropy = coefficients
|
|
323
|
+
corners = [mathutils.Vector(c) for c in obj.bound_box]
|
|
324
|
+
low = mathutils.Vector((min(c.x for c in corners), min(c.y for c in corners), min(c.z for c in corners)))
|
|
325
|
+
high = mathutils.Vector((max(c.x for c in corners), max(c.y for c in corners), max(c.z for c in corners)))
|
|
326
|
+
centre, size = (low + high) / 2, high - low
|
|
327
|
+
if len(obj.data.vertices) != 8:
|
|
328
|
+
warnings.append("%s's volume domain is not a box: it is carried as the box around it" % obj.name)
|
|
329
|
+
r6 = lambda values: [round(float(v), 6) for v in values]
|
|
330
|
+
# (Blender's local x, y, z are the glTF node's x, z, -y)
|
|
331
|
+
found[obj.name] = {"shape": "box", "center": r6((centre.x, centre.z, -centre.y)), "size": r6((size.x, size.z, size.y)),
|
|
332
|
+
"scattering": r6(scattering), "absorption": r6(absorption), "emission": r6(emission),
|
|
333
|
+
"anisotropy": round(max(-1.0, min(1.0, anisotropy)), 6)}
|
|
334
|
+
return found, warnings
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _pick(owner, name):
|
|
338
|
+
return getattr(owner, name) if hasattr(owner, name) else None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _render(scene):
|
|
342
|
+
"""The scene's render settings, as the neutral data a renderer honours or names (Q7; `STD_render`).
|
|
343
|
+
Look-defining: shadows and whether light bounces; the rest are quality hints."""
|
|
344
|
+
render, eevee = scene.render, scene.eevee
|
|
345
|
+
data = {"shadows": bool(eevee.use_shadows)}
|
|
346
|
+
if render.engine == "CYCLES":
|
|
347
|
+
# A path tracer: light bounces everywhere.
|
|
348
|
+
data["globalIllumination"] = {"mode": "full"}
|
|
349
|
+
else:
|
|
350
|
+
data["softShadows"] = {"rays": int(eevee.shadow_ray_count), "steps": int(eevee.shadow_step_count)}
|
|
351
|
+
# EEVEE enables Fast GI only when ray tracing is enabled.
|
|
352
|
+
if eevee.use_fast_gi and eevee.use_raytracing:
|
|
353
|
+
data["globalIllumination"] = {"mode": "occlusion" if eevee.fast_gi_method == "AMBIENT_OCCLUSION_ONLY" else "screen-space",
|
|
354
|
+
"resolution": round(1.0 / max(1, int(eevee.fast_gi_resolution)), 6)}
|
|
355
|
+
data["raytracing"] = bool(eevee.use_raytracing)
|
|
356
|
+
# The range EEVEE draws volumes over: its custom range where that is on, else the camera's clip range
|
|
357
|
+
# (the scene's camera; none: the custom values, as EEVEE has nothing else to go by).
|
|
358
|
+
start, end = float(eevee.volumetric_start), float(eevee.volumetric_end)
|
|
359
|
+
if not eevee.use_volume_custom_range and scene.camera is not None and scene.camera.type == "CAMERA":
|
|
360
|
+
start, end = float(scene.camera.data.clip_start), float(scene.camera.data.clip_end)
|
|
361
|
+
data["volumetric"] = {"start": round(start, 6), "end": round(end, 6),
|
|
362
|
+
"tileSize": int(eevee.volumetric_tile_size), "samples": int(eevee.volumetric_samples),
|
|
363
|
+
"shadows": bool(eevee.use_volumetric_shadows)}
|
|
364
|
+
data["antialiasing"] = {"samples": int(eevee.taa_render_samples), "filterWidth": round(render.filter_size, 6)}
|
|
365
|
+
data["transparentBackground"] = bool(render.film_transparent)
|
|
366
|
+
data["motionBlur"] = bool(render.use_motion_blur)
|
|
367
|
+
return data
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _display(scene, lut):
|
|
371
|
+
"""How the scene is seen (`STD_display`): its colour management, which glTF has no place for. The LUT
|
|
372
|
+
is what a host applies; the view's name is information only."""
|
|
373
|
+
view = scene.view_settings
|
|
374
|
+
display = {"exposure": round(view.exposure, 6), "gamma": round(view.gamma, 6), "view": view.view_transform}
|
|
375
|
+
display["lut"] = {"uri": lut["file"], "size": lut["size"],
|
|
376
|
+
"shaper": {"type": "log2", "min": lut["shaper"]["min"], "max": lut["shaper"]["max"]},
|
|
377
|
+
"input": "linear-rec709", "output": "display"}
|
|
378
|
+
display["key"] = lut["key"]
|
|
379
|
+
return display
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _light(light):
|
|
383
|
+
"""What KHR_lights_punctual can't say of a light (`STD_light`): the units its intensity is in, its shadow
|
|
384
|
+
(the emitter's radius softens it), a sun's angular diameter, and an AREA light, which glTF has no light
|
|
385
|
+
for: its shape, size, colour and radiance. A Blender area light of power P (W, times 2^exposure; times its
|
|
386
|
+
area when not normalised) seen from the front is a point light of P: its radiance is P / (4 pi area)."""
|
|
387
|
+
data = {"units": "scene-linear",
|
|
388
|
+
"shadow": {"cast": bool(light.use_shadow), "radius": round(float(_pick(light, "shadow_soft_size") or 0.0), 6)}}
|
|
389
|
+
if light.type == "SUN":
|
|
390
|
+
data["angularDiameter"] = round(float(light.angle), 6)
|
|
391
|
+
elif light.type == "AREA":
|
|
392
|
+
width = float(light.size)
|
|
393
|
+
height = float(light.size_y) if light.shape in ("RECTANGLE", "ELLIPSE") else width
|
|
394
|
+
ellipse = light.shape in ("DISK", "ELLIPSE")
|
|
395
|
+
area = max((math.pi / 4 if ellipse else 1.0) * width * height, 1e-9)
|
|
396
|
+
power = float(light.energy) * 2 ** float(light.exposure)
|
|
397
|
+
if not light.normalize:
|
|
398
|
+
power *= area
|
|
399
|
+
data["area"] = {"shape": "ellipse" if ellipse else "rectangle", "width": round(width, 6), "height": round(height, 6),
|
|
400
|
+
"color": [round(float(c), 6) for c in light.color], "radiance": round(power / (4 * math.pi * area), 9)}
|
|
401
|
+
return data
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _area_lights(objects, visibility):
|
|
405
|
+
return ["the AREA light %s has no glTF light (KHR_lights_punctual is point, spot and sun): it goes as its node's "
|
|
406
|
+
"STD_light.area (shape, size, colour, radiance) only" % o.name
|
|
407
|
+
for o in objects if o.type == "LIGHT" and o.data.type == "AREA" and visibility[o.name]]
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _hidden_over_visible(objects, visibility):
|
|
411
|
+
"""`KHR_node_visibility` hides a node's whole subtree; Blender's render visibility does not."""
|
|
412
|
+
exported = set(objects)
|
|
413
|
+
found = []
|
|
414
|
+
for obj in objects:
|
|
415
|
+
if visibility[obj.name]:
|
|
416
|
+
continue
|
|
417
|
+
shown = [c.name for c in obj.children_recursive if c in exported and visibility[c.name]]
|
|
418
|
+
if shown:
|
|
419
|
+
found.append("%s is hidden in renders but its children %s are not; the glTF hides them too"
|
|
420
|
+
% (obj.name, ", ".join(sorted(shown)[:5])))
|
|
421
|
+
return found
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _collection_ancestors(scene):
|
|
425
|
+
"""Every collection of the scene's hierarchy, by name, with the collections that hold it."""
|
|
426
|
+
ancestors = {}
|
|
427
|
+
|
|
428
|
+
def walk(collection, chain):
|
|
429
|
+
for child in collection.children:
|
|
430
|
+
if child.name == _HOLDER:
|
|
431
|
+
continue
|
|
432
|
+
ancestors.setdefault(child.name, set()).update(chain)
|
|
433
|
+
walk(child, chain + [child.name])
|
|
434
|
+
|
|
435
|
+
walk(scene.collection, [])
|
|
436
|
+
# A collection of the file outside the scene (a variant a level places: `Helldiver.Rifleman`, some of
|
|
437
|
+
# the Helldiver's objects) holds its objects too, with the collections that hold it.
|
|
438
|
+
parents = {}
|
|
439
|
+
for collection in bpy.data.collections:
|
|
440
|
+
for child in collection.children:
|
|
441
|
+
parents.setdefault(child.name, set()).add(collection.name)
|
|
442
|
+
for collection in bpy.data.collections:
|
|
443
|
+
if collection.library is not None or collection.name in ancestors or collection.name == _HOLDER:
|
|
444
|
+
continue
|
|
445
|
+
chain, todo = set(), list(parents.get(collection.name, ()))
|
|
446
|
+
while todo:
|
|
447
|
+
name = todo.pop()
|
|
448
|
+
if name not in chain:
|
|
449
|
+
chain.add(name)
|
|
450
|
+
todo.extend(parents.get(name, ()))
|
|
451
|
+
ancestors[collection.name] = chain
|
|
452
|
+
return ancestors
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _collections_of(names, ancestors):
|
|
456
|
+
"""The collections named and every collection holding them, sorted; the scene's own collection,
|
|
457
|
+
the authoring collections and the export's holder are not among them."""
|
|
458
|
+
found = set()
|
|
459
|
+
for name in names:
|
|
460
|
+
if name in ancestors and name not in AUTHORING:
|
|
461
|
+
found.add(name)
|
|
462
|
+
found.update(a for a in ancestors[name] if a not in AUTHORING)
|
|
463
|
+
return sorted(found)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _node_collections(objects, ancestors):
|
|
467
|
+
return {o.name: _collections_of([c.name for c in o.users_collection], ancestors) for o in objects}
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _strip(strip):
|
|
471
|
+
"""One NLA strip's timing, as Blender plays it, in the standard's names (`STD_animation_tracks`): its
|
|
472
|
+
scene range, the clip's range it plays, scale and repeats, blend in and out, extrapolation, blend,
|
|
473
|
+
influence, and whether it plays backwards. None for a strip with no action."""
|
|
474
|
+
if strip.action is None:
|
|
475
|
+
return None
|
|
476
|
+
data = {"clip": strip.action.name,
|
|
477
|
+
"start": round(float(strip.frame_start), 6), "end": round(float(strip.frame_end), 6),
|
|
478
|
+
"clipStart": round(float(strip.action_frame_start), 6), "clipEnd": round(float(strip.action_frame_end), 6),
|
|
479
|
+
"scale": round(float(strip.scale), 6), "repeat": round(float(strip.repeat), 6),
|
|
480
|
+
"blendIn": round(float(strip.blend_in), 6), "blendOut": round(float(strip.blend_out), 6),
|
|
481
|
+
"extrapolation": EXTRAPOLATIONS[strip.extrapolation], "blend": BLENDS[strip.blend_type],
|
|
482
|
+
"influence": round(float(strip.influence), 6) if strip.use_animated_influence else 1.0,
|
|
483
|
+
"reversed": bool(strip.use_reverse), "mute": bool(strip.mute)}
|
|
484
|
+
if strip.use_animated_influence:
|
|
485
|
+
# THE INFLUENCE AS THE FILE ANIMATES IT: its F-curve read at every frame of the strip.
|
|
486
|
+
curve = strip.fcurves.find("influence")
|
|
487
|
+
if curve is not None:
|
|
488
|
+
first, last = int(math.floor(strip.frame_start)), int(math.ceil(strip.frame_end))
|
|
489
|
+
data["influenceKeys"] = [[frame, round(min(1.0, max(0.0, float(curve.evaluate(frame)))), 6)]
|
|
490
|
+
for frame in range(first, last + 1)]
|
|
491
|
+
return data
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _tracks(obj):
|
|
495
|
+
"""An armature's NLA tracks, bottom to top (RFC 0007 §1, `STD_animation_tracks.tracks`): each one's name
|
|
496
|
+
and mute, and its strips unless muted. A placement marker carries its override's own tracks the same way."""
|
|
497
|
+
data = obj.animation_data
|
|
498
|
+
if data is None:
|
|
499
|
+
return None
|
|
500
|
+
tracks = []
|
|
501
|
+
for track in data.nla_tracks:
|
|
502
|
+
entry = {"name": track.name, "mute": bool(track.mute)}
|
|
503
|
+
if track.is_solo:
|
|
504
|
+
entry["solo"] = True
|
|
505
|
+
if not track.mute:
|
|
506
|
+
entry["strips"] = [s for s in (_strip(strip) for strip in track.strips if strip.type == "CLIP") if s is not None]
|
|
507
|
+
tracks.append(entry)
|
|
508
|
+
return tracks
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _nla_settings(obj):
|
|
512
|
+
"""An armature's NLA settings a game plays by, where they are not Blender's defaults: the NLA turned off
|
|
513
|
+
(`tracksEnabled: false`), and the active action's own blend over the strips (`clipBlend`: blend, influence)."""
|
|
514
|
+
data = obj.animation_data
|
|
515
|
+
if data is None:
|
|
516
|
+
return {}
|
|
517
|
+
settings = {}
|
|
518
|
+
if not data.use_nla:
|
|
519
|
+
settings["tracksEnabled"] = False
|
|
520
|
+
if data.action_blend_type != "REPLACE" or abs(data.action_influence - 1.0) > 1e-6:
|
|
521
|
+
settings["clipBlend"] = {"blend": BLENDS[data.action_blend_type], "influence": round(float(data.action_influence), 6)}
|
|
522
|
+
return settings
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
_OBJECT_CHANNELS = ("location", "rotation_quaternion", "rotation_euler", "rotation_axis_angle", "scale", "delta_location",
|
|
526
|
+
"delta_rotation_quaternion", "delta_rotation_euler", "delta_scale")
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _moves_itself(obj):
|
|
530
|
+
"""Whether an object's own transform is animated: by its action, or by an unmuted NLA strip's."""
|
|
531
|
+
data = obj.animation_data
|
|
532
|
+
if data is None:
|
|
533
|
+
return False
|
|
534
|
+
actions = [data.action] if data.action is not None else []
|
|
535
|
+
for track in data.nla_tracks:
|
|
536
|
+
if not track.mute:
|
|
537
|
+
actions.extend(strip.action for strip in track.strips if strip.action is not None)
|
|
538
|
+
return any(c.data_path in _OBJECT_CHANNELS for action in actions for c in _action_curves(action))
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _bake_placement_motion(scene, moving):
|
|
542
|
+
"""THE PLACEMENT'S OWN MOTION (RFC 0007 §3): key each moving placement's marker at every scene frame
|
|
543
|
+
where Blender puts its override root (its action and NLA strips, any blend, as Blender evaluates
|
|
544
|
+
them), so the level's scene animation carries it. Answers the actions made, for the caller to remove."""
|
|
545
|
+
made = []
|
|
546
|
+
if not moving:
|
|
547
|
+
return made
|
|
548
|
+
for marker, _root, _inverse in moving:
|
|
549
|
+
marker.rotation_mode = "QUATERNION"
|
|
550
|
+
marker.animation_data_create()
|
|
551
|
+
action = bpy.data.actions.new("__volter_marker_motion")
|
|
552
|
+
marker.animation_data.action = action
|
|
553
|
+
made.append(action)
|
|
554
|
+
for frame in range(scene.frame_start, scene.frame_end + 1):
|
|
555
|
+
scene.frame_set(frame)
|
|
556
|
+
for marker, root, inverse in moving:
|
|
557
|
+
marker.matrix_world = root.matrix_world @ inverse
|
|
558
|
+
for path in ("location", "rotation_quaternion", "scale"):
|
|
559
|
+
marker.keyframe_insert(path, frame=frame)
|
|
560
|
+
return made
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _level_actions(root):
|
|
564
|
+
"""The actions a placement plays that the level made (its active action or its NLA strips' actions
|
|
565
|
+
that are not its component's), keying its bones."""
|
|
566
|
+
data = root.animation_data
|
|
567
|
+
if data is None or root.type != "ARMATURE":
|
|
568
|
+
return []
|
|
569
|
+
candidates = [data.action] if data.action is not None else []
|
|
570
|
+
for track in data.nla_tracks:
|
|
571
|
+
if not track.mute:
|
|
572
|
+
candidates.extend(strip.action for strip in track.strips if strip.action is not None)
|
|
573
|
+
found = []
|
|
574
|
+
for action in candidates:
|
|
575
|
+
if action.library is None and action not in found and any(c.data_path.startswith("pose.bones[") for c in _action_curves(action)):
|
|
576
|
+
found.append(action)
|
|
577
|
+
return found
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _companion(out, placement):
|
|
581
|
+
"""The file a placement's own actions are written to, beside the level's."""
|
|
582
|
+
base = os.path.splitext(os.path.basename(out))[0]
|
|
583
|
+
return "%s.%s.actions.glb" % (base, re.sub(r"[^A-Za-z0-9._-]", "_", placement))
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _export_placement_actions(scene, view_layer, root, actions, path):
|
|
587
|
+
"""A PLACEMENT'S OWN ACTIONS (made in the level, played by the override's NLA): Blender's exporter writes
|
|
588
|
+
them for the placed armature alone (its bones, no meshes), one animation each, through the same passes as
|
|
589
|
+
a component's (RFC 0007 §1, §2): its runtime constraints muted while Blender samples (they are the script's,
|
|
590
|
+
applied at runtime, never baked into the keys), only what each action moves, samples between frames where
|
|
591
|
+
whole frames miss its motion, each rotation key in its action's sign with its keyed length. Answers its
|
|
592
|
+
warnings and what the sub-frame pass did, for the level's manifest."""
|
|
593
|
+
module = _gltf_addon()
|
|
594
|
+
keep = {action.name for action in actions}
|
|
595
|
+
runtime, _data, warnings = _runtime_constraints([root])
|
|
596
|
+
registered = hasattr(bpy.types.Scene, "gltf_action_filter")
|
|
597
|
+
if not registered:
|
|
598
|
+
bpy.types.Scene.gltf_action_filter = bpy.props.CollectionProperty(type=module.GLTF2_filter_action)
|
|
599
|
+
# (a scene that stored no list before stores none after: the property is unset again)
|
|
600
|
+
stored = {scene_.name for scene_ in bpy.data.scenes if scene_.is_property_set("gltf_action_filter")}
|
|
601
|
+
listed = bpy.data.scenes[0].gltf_action_filter
|
|
602
|
+
saved = [(item.action, item.keep) for item in listed]
|
|
603
|
+
listed.clear()
|
|
604
|
+
try:
|
|
605
|
+
for action in bpy.data.actions:
|
|
606
|
+
item = listed.add()
|
|
607
|
+
item.action = action
|
|
608
|
+
item.keep = action.name in keep
|
|
609
|
+
settings = dict(COMPONENT_SETTINGS, export_extras=False, export_cameras=False, export_lights=False, export_apply=False)
|
|
610
|
+
_with_hooks(_Hooks({}, {"keyRate": _key_rate(scene)}, {}), lambda: _muted(runtime, lambda: _at_rest([root], lambda: _export_set(
|
|
611
|
+
scene, view_layer, [root], lambda: _gltf(path, settings)))))
|
|
612
|
+
_unique_channels(path)
|
|
613
|
+
_weights_keyed(path)
|
|
614
|
+
candidates = {key: value for key, value in _subframe_candidates([root]).items() if key[1] in keep}
|
|
615
|
+
dense = _muted(runtime, lambda: _densify_subframes(path, [root], scene, candidates))
|
|
616
|
+
warnings.extend(_subframe_warnings(dense, candidates))
|
|
617
|
+
_align_rotation_signs(path, [root], scene)
|
|
618
|
+
finally:
|
|
619
|
+
listed.clear()
|
|
620
|
+
for action, kept in saved:
|
|
621
|
+
item = listed.add()
|
|
622
|
+
item.action, item.keep = action, kept
|
|
623
|
+
for scene_ in bpy.data.scenes:
|
|
624
|
+
if scene_.name not in stored and scene_.is_property_set("gltf_action_filter"):
|
|
625
|
+
scene_.property_unset("gltf_action_filter")
|
|
626
|
+
if not registered:
|
|
627
|
+
del bpy.types.Scene.gltf_action_filter
|
|
628
|
+
warnings.extend(_euler_bones([root], keep))
|
|
629
|
+
warnings.extend(_evaluated_bones([root]))
|
|
630
|
+
return {"warnings": warnings, "subframeSampling": _subframe_manifest(dense)}
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _strip_warnings(objects):
|
|
634
|
+
"""What of the NLA a game cannot play as Blender does, by name."""
|
|
635
|
+
found = []
|
|
636
|
+
for obj in objects:
|
|
637
|
+
data = obj.animation_data if obj.type == "ARMATURE" else None
|
|
638
|
+
if data is None:
|
|
639
|
+
continue
|
|
640
|
+
for track in data.nla_tracks:
|
|
641
|
+
if track.mute:
|
|
642
|
+
continue
|
|
643
|
+
for strip in track.strips:
|
|
644
|
+
if strip.type != "CLIP":
|
|
645
|
+
found.append("%s's NLA strip %s is a %s strip: only action strips play" % (obj.name, strip.name, strip.type))
|
|
646
|
+
if strip.use_animated_time:
|
|
647
|
+
found.append("%s's NLA strip %s has an animated strip time: a game plays the strip's own timing"
|
|
648
|
+
% (obj.name, strip.name))
|
|
649
|
+
if data.action_extrapolation != "HOLD" and data.action is not None:
|
|
650
|
+
found.append("%s's active action extrapolates as %s: a game plays its active action looping, or held at its "
|
|
651
|
+
"end, as the script sets it" % (obj.name, data.action_extrapolation))
|
|
652
|
+
return found
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _euler_bones(objects, only=None):
|
|
656
|
+
"""Actions that key bones' rotations in Euler angles (or axis-angle): glTF carries rotations as
|
|
657
|
+
quaternions, so layered they blend as quaternions, where Blender's NLA blends the angles. Named per
|
|
658
|
+
action, with its bones (RFC 0007 §5); `only`, the action names to look at (all when None)."""
|
|
659
|
+
found = []
|
|
660
|
+
for obj in objects:
|
|
661
|
+
if obj.type != "ARMATURE" or obj.pose is None:
|
|
662
|
+
continue
|
|
663
|
+
angled = {b.name for b in obj.pose.bones if b.rotation_mode != "QUATERNION"}
|
|
664
|
+
if not angled:
|
|
665
|
+
continue
|
|
666
|
+
by_action = {}
|
|
667
|
+
for action in bpy.data.actions:
|
|
668
|
+
if only is not None and action.name not in only:
|
|
669
|
+
continue
|
|
670
|
+
for curve in _action_curves(action):
|
|
671
|
+
found_path = _BONE_PATH.match(curve.data_path)
|
|
672
|
+
if found_path and found_path.group(2) in ("rotation_euler", "rotation_axis_angle") and found_path.group(1) in angled:
|
|
673
|
+
by_action.setdefault(action.name, set()).add(found_path.group(1))
|
|
674
|
+
for action, bones in sorted(by_action.items()):
|
|
675
|
+
found.append("%s's action %s keys Euler rotations (%s): glTF carries rotations as quaternions, so layered it "
|
|
676
|
+
"blends as quaternions where Blender's NLA blends the angles; the two differ most for angles far "
|
|
677
|
+
"apart" % (obj.name, action, ", ".join(sorted(bones))))
|
|
678
|
+
return found
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _evaluated_bones(objects):
|
|
682
|
+
"""Bones Blender poses from other bones as it plays (a constraint baked into the clips, every bone of
|
|
683
|
+
an IK chain, a bone not inheriting its parent's rotation or scale): each action carries their
|
|
684
|
+
evaluated pose, so a layer over another action blends those poses, where Blender evaluates them
|
|
685
|
+
again from the blended keys. Named, with the bones."""
|
|
686
|
+
found = []
|
|
687
|
+
for obj in objects:
|
|
688
|
+
if obj.type != "ARMATURE" or obj.pose is None:
|
|
689
|
+
continue
|
|
690
|
+
solved, loose = set(), set()
|
|
691
|
+
for pb in obj.pose.bones:
|
|
692
|
+
# (a runtime Damped Track is the script's, not baked)
|
|
693
|
+
runtime = {c.name for c in pb.constraints if c.name in _runtime_names(pb) and c.type == "DAMPED_TRACK"}
|
|
694
|
+
for constraint in pb.constraints:
|
|
695
|
+
if constraint.mute or constraint.influence <= 0 or constraint.name in runtime:
|
|
696
|
+
continue
|
|
697
|
+
if constraint.type in ("IK", "SPLINE_IK"):
|
|
698
|
+
length = constraint.chain_count
|
|
699
|
+
bone, depth = pb, 0
|
|
700
|
+
while bone is not None and (length == 0 or depth < length):
|
|
701
|
+
solved.add(bone.name)
|
|
702
|
+
bone, depth = bone.parent, depth + 1
|
|
703
|
+
else:
|
|
704
|
+
solved.add(pb.name)
|
|
705
|
+
if not pb.bone.use_inherit_rotation or pb.bone.inherit_scale != "FULL":
|
|
706
|
+
loose.add(pb.name)
|
|
707
|
+
parts = []
|
|
708
|
+
if solved:
|
|
709
|
+
parts.append("constrained (IK chains included): " + ", ".join(sorted(solved)))
|
|
710
|
+
if loose:
|
|
711
|
+
parts.append("not inheriting their parent's rotation or scale: " + ", ".join(sorted(loose)))
|
|
712
|
+
if parts:
|
|
713
|
+
found.append("%s's bones that Blender poses from others are carried as each action evaluates them, so a "
|
|
714
|
+
"layer over another action blends those poses, not Blender's evaluation of the blended keys; "
|
|
715
|
+
"%s" % (obj.name, "; ".join(parts)))
|
|
716
|
+
return found
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
# BETWEEN FRAMES (RFC 0007 §1): where interpolating whole-frame samples is this far from what Blender
|
|
720
|
+
# evaluates at a half step, the export samples there too.
|
|
721
|
+
SUBFRAME_DEGREES = 0.25
|
|
722
|
+
SUBFRAME_METRES = 0.001
|
|
723
|
+
SUBFRAME_SCALE = 0.001
|
|
724
|
+
SUBFRAME_MOST = 32
|
|
725
|
+
_Y_UP = mathutils.Matrix(((1.0, 0.0, 0.0, 0.0), (0.0, 0.0, 1.0, 0.0), (0.0, -1.0, 0.0, 0.0), (0.0, 0.0, 0.0, 1.0)))
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _world_unit(obj):
|
|
729
|
+
"""How long one unit of an armature's own space is in the world (its largest scale)."""
|
|
730
|
+
return max(abs(v) for v in obj.matrix_world.to_scale()) or 1.0
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _subframe_candidates(objects):
|
|
734
|
+
"""WHICH ACTIONS MAY NEED SAMPLING BETWEEN FRAMES, from their curves alone (no frame is evaluated): per
|
|
735
|
+
armature and action, the largest difference at a half frame between Blender's curve and the interpolation
|
|
736
|
+
of its whole-frame values, for each keyed bone rotation (quaternion or Euler: its own rotation, in degrees)
|
|
737
|
+
and location (in metres in the world). Answers {(armature, action): (degrees, metres, bone, frame)}."""
|
|
738
|
+
found = {}
|
|
739
|
+
for obj in objects:
|
|
740
|
+
if obj.type != "ARMATURE" or obj.pose is None:
|
|
741
|
+
continue
|
|
742
|
+
unit = _world_unit(obj)
|
|
743
|
+
modes = {b.name: b.rotation_mode for b in obj.pose.bones}
|
|
744
|
+
modes[_OBJECT] = obj.rotation_mode
|
|
745
|
+
for action in bpy.data.actions:
|
|
746
|
+
curves = {}
|
|
747
|
+
for curve in _action_curves(action):
|
|
748
|
+
matched = _BONE_PATH.match(curve.data_path)
|
|
749
|
+
if matched and matched.group(1) in modes:
|
|
750
|
+
bone, path = matched.group(1), matched.group(2)
|
|
751
|
+
elif curve.data_path in ("location", "rotation_quaternion", "rotation_euler"):
|
|
752
|
+
bone, path = _OBJECT, curve.data_path
|
|
753
|
+
else:
|
|
754
|
+
continue
|
|
755
|
+
if path in ("rotation_quaternion", "rotation_euler", "location") and 0 <= curve.array_index < 4:
|
|
756
|
+
curves.setdefault((bone, path), [None, None, None, None])[curve.array_index] = curve
|
|
757
|
+
if not curves:
|
|
758
|
+
continue
|
|
759
|
+
first, last = (int(math.floor(v)) for v in action.frame_range)
|
|
760
|
+
halves = range(2 * first, 2 * last + 1)
|
|
761
|
+
worst = [0.0, 0.0, None, None]
|
|
762
|
+
for (bone, path), four in curves.items():
|
|
763
|
+
if path == "rotation_quaternion" and modes[bone] != "QUATERNION":
|
|
764
|
+
continue
|
|
765
|
+
if path == "rotation_euler" and modes[bone] in ("QUATERNION", "AXIS_ANGLE"):
|
|
766
|
+
continue
|
|
767
|
+
size = 4 if path == "rotation_quaternion" else 3
|
|
768
|
+
rest = (1.0, 0.0, 0.0, 0.0) if path == "rotation_quaternion" else (0.0, 0.0, 0.0)
|
|
769
|
+
at = [[c.evaluate(f / 2.0) if c is not None else rest[i] for f in halves] for i, c in enumerate(four[:size])]
|
|
770
|
+
|
|
771
|
+
def value(k):
|
|
772
|
+
v = [at[i][k] for i in range(size)]
|
|
773
|
+
if path == "rotation_quaternion":
|
|
774
|
+
return tuple(_unit_quaternion([v[1], v[2], v[3], v[0]]))
|
|
775
|
+
if path == "rotation_euler":
|
|
776
|
+
q = mathutils.Euler(v, modes[bone]).to_quaternion()
|
|
777
|
+
return (q.x, q.y, q.z, q.w)
|
|
778
|
+
return v
|
|
779
|
+
values = [value(k) for k in range(len(at[0]))]
|
|
780
|
+
for k in range(0, len(at[0]) - 2, 2):
|
|
781
|
+
a, b, half = values[k], values[k + 2], values[k + 1]
|
|
782
|
+
if path == "location":
|
|
783
|
+
metres = math.sqrt(sum((m - (x + y) / 2.0) ** 2 for x, y, m in zip(a, b, half))) * unit
|
|
784
|
+
if metres > worst[1]:
|
|
785
|
+
worst[1] = metres
|
|
786
|
+
if worst[2] is None:
|
|
787
|
+
worst[2], worst[3] = bone, first + k / 2.0 + 0.5
|
|
788
|
+
continue
|
|
789
|
+
points = [(0.5, half)]
|
|
790
|
+
if abs(sum(x * y for x, y in zip(a, b))) < 0.9962:
|
|
791
|
+
# Turning over 10 degrees in a frame: Blender's normalised interpolation meets the arc in
|
|
792
|
+
# the middle and parts from it near the quarters, so look there too.
|
|
793
|
+
for u in (0.25, 0.75):
|
|
794
|
+
at_u = first + k / 2.0 + u
|
|
795
|
+
v = [c.evaluate(at_u) if c is not None else rest[i] for i, c in enumerate(four[:size])]
|
|
796
|
+
if path == "rotation_quaternion":
|
|
797
|
+
points.append((u, (v[1], v[2], v[3], v[0])))
|
|
798
|
+
else:
|
|
799
|
+
q = mathutils.Euler(v, modes[bone]).to_quaternion()
|
|
800
|
+
points.append((u, (q.x, q.y, q.z, q.w)))
|
|
801
|
+
difference = _off_arc(a, b, points)
|
|
802
|
+
if difference > worst[0]:
|
|
803
|
+
worst[0], worst[2], worst[3] = difference, bone, first + k / 2.0 + 0.5
|
|
804
|
+
if worst[0] > 0.0 or worst[1] > 0.0:
|
|
805
|
+
found[(obj.name, action.name)] = tuple(worst)
|
|
806
|
+
return found
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _scene_actions(obj):
|
|
810
|
+
"""What moves an object in the scene's animation: its active action (scene frame = action frame) and each
|
|
811
|
+
unmuted action strip of its unmuted NLA tracks, with the strip that maps scene frames into the action."""
|
|
812
|
+
data = obj.animation_data
|
|
813
|
+
if data is None:
|
|
814
|
+
return []
|
|
815
|
+
found = [(data.action, None)] if data.action is not None else []
|
|
816
|
+
if data.use_nla:
|
|
817
|
+
for track in data.nla_tracks:
|
|
818
|
+
if track.mute:
|
|
819
|
+
continue
|
|
820
|
+
found.extend((strip.action, strip) for strip in track.strips
|
|
821
|
+
if strip.type == "CLIP" and not strip.mute and strip.action is not None)
|
|
822
|
+
return found
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _strip_frame(strip, frame):
|
|
826
|
+
"""The action frame a strip shows at a scene frame inside it (its range, scale, repeats, reversed)."""
|
|
827
|
+
if strip is None:
|
|
828
|
+
return frame
|
|
829
|
+
span = strip.action_frame_end - strip.action_frame_start
|
|
830
|
+
into = max(0.0, frame - strip.frame_start) / (strip.scale or 1.0)
|
|
831
|
+
if span > 0 and into > span:
|
|
832
|
+
into = span if frame >= strip.frame_end else into % span
|
|
833
|
+
at = strip.action_frame_start + into
|
|
834
|
+
return strip.action_frame_end - into if strip.use_reverse else at
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _scene_subframe_worst(objects, scene):
|
|
838
|
+
"""Measure scene-animation interpolation error from active actions and NLA strips at half frames, adding quarter frames for rotations over ten degrees per frame. Return worst rotation and position errors with object, bone and frame."""
|
|
839
|
+
worst_turn, worst_move = (0.0, None, None, None), (0.0, None, None, None)
|
|
840
|
+
first, last = scene.frame_start, scene.frame_end
|
|
841
|
+
for obj in objects:
|
|
842
|
+
for action, strip in _scene_actions(obj):
|
|
843
|
+
groups = _curve_groups(obj, action)
|
|
844
|
+
if not groups:
|
|
845
|
+
continue
|
|
846
|
+
lo, hi = (first, last) if strip is None else (max(first, int(math.ceil(strip.frame_start))), min(last, int(math.floor(strip.frame_end))))
|
|
847
|
+
unit = _world_unit(obj) if obj.type == "ARMATURE" else 1.0
|
|
848
|
+
for group in groups:
|
|
849
|
+
bone = None if group[0] == _OBJECT else group[0]
|
|
850
|
+
|
|
851
|
+
def value(frame):
|
|
852
|
+
found = _curve_value(group, _strip_frame(strip, frame))
|
|
853
|
+
return found if group[1] == "location" else tuple(_unit_quaternion(found))
|
|
854
|
+
previous = value(lo) if lo <= hi else None
|
|
855
|
+
for frame in range(lo, hi):
|
|
856
|
+
a, b = previous, value(frame + 1)
|
|
857
|
+
previous = b
|
|
858
|
+
if group[1] == "location":
|
|
859
|
+
mid = value(frame + 0.5)
|
|
860
|
+
gap = math.sqrt(sum((m - (x + y) / 2.0) ** 2 for x, y, m in zip(a, b, mid))) * (unit if bone else 1.0)
|
|
861
|
+
if gap > worst_move[0]:
|
|
862
|
+
worst_move = (gap, obj.name, bone, frame + 0.5)
|
|
863
|
+
continue
|
|
864
|
+
points = [(0.5, value(frame + 0.5))]
|
|
865
|
+
if abs(sum(x * y for x, y in zip(a, b))) < 0.9962:
|
|
866
|
+
points += [(u, value(frame + u)) for u in (0.25, 0.75)]
|
|
867
|
+
gap = _off_arc(a, b, points)
|
|
868
|
+
if gap > worst_turn[0]:
|
|
869
|
+
worst_turn = (gap, obj.name, bone, frame + 0.5)
|
|
870
|
+
return worst_turn, worst_move
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _scene_subframe_warnings(worst):
|
|
874
|
+
turn, move = worst
|
|
875
|
+
if turn[0] < 0.01 and move[0] < 0.0001:
|
|
876
|
+
return []
|
|
877
|
+
where = lambda w: "%s%s, frame %.2f" % (w[1], " / " + w[2] if w[2] else "", w[3] or 0.0)
|
|
878
|
+
return ["between frames: the level's scene animation (cameras, keyed objects, the placements' motion, its own rigs) "
|
|
879
|
+
"is sampled at whole frames, and a cutscene plays between them; its curves part from the interpolation of "
|
|
880
|
+
"their whole-frame values by up to %.2f deg (%s) and %.1f mm (%s)" % (turn[0], where(turn) if turn[1] else "-",
|
|
881
|
+
move[0] * 1000.0, where(move) if move[1] else "-")]
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _action_slot_for(action):
|
|
885
|
+
"""The action's only object slot, or None when there is not exactly one."""
|
|
886
|
+
slots = [slot for slot in action.slots if slot.target_id_type == "OBJECT"]
|
|
887
|
+
return slots[0] if len(slots) == 1 else None
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _as_exported(obj, action, slot, run):
|
|
891
|
+
"""Run with `obj` posed as Blender's glTF exporter samples an action (its animation/action.py): the NLA off,
|
|
892
|
+
no track soloed, out of tweak mode, every pose bone at its rest, the action and its slot assigned; then put
|
|
893
|
+
everything back."""
|
|
894
|
+
data = obj.animation_data_create()
|
|
895
|
+
kept = (data.action, data.action_slot if data.action is not None else None, data.use_nla, data.use_tweak_mode)
|
|
896
|
+
soloed = [track for track in data.nla_tracks if track.is_solo]
|
|
897
|
+
held = _pose_channels([obj])
|
|
898
|
+
try:
|
|
899
|
+
if data.use_tweak_mode:
|
|
900
|
+
data.use_tweak_mode = False
|
|
901
|
+
for track in soloed:
|
|
902
|
+
track.is_solo = False
|
|
903
|
+
data.use_nla = False
|
|
904
|
+
data.action = None
|
|
905
|
+
_clear_pose([obj])
|
|
906
|
+
data.action = action
|
|
907
|
+
if slot is not None:
|
|
908
|
+
data.action_slot = slot
|
|
909
|
+
return run()
|
|
910
|
+
finally:
|
|
911
|
+
data.action = None
|
|
912
|
+
_put_back(held)
|
|
913
|
+
data.action = kept[0]
|
|
914
|
+
if kept[0] is not None and kept[1] is not None:
|
|
915
|
+
data.action_slot = kept[1]
|
|
916
|
+
data.use_nla = kept[2]
|
|
917
|
+
for track in soloed:
|
|
918
|
+
track.is_solo = True
|
|
919
|
+
if kept[3] and not data.use_tweak_mode:
|
|
920
|
+
try:
|
|
921
|
+
data.use_tweak_mode = True
|
|
922
|
+
except Exception: # noqa: BLE001 (no strip to tweak any more)
|
|
923
|
+
pass
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _bone_transforms(obj, parents):
|
|
927
|
+
"""Each exported bone's glTF translation, rotation and scale from its pose now, computed as Blender's
|
|
928
|
+
exporter computes them (sampling_cache.armature_caching, then armature/sampler.py's correction and
|
|
929
|
+
gltf2_blender_math.transform): `parents` gives each bone's exported parent bone, or None at the root."""
|
|
930
|
+
axis = _Y_UP
|
|
931
|
+
fixed = {}
|
|
932
|
+
for name, parent in parents.items():
|
|
933
|
+
bone = obj.pose.bones[name]
|
|
934
|
+
if parent is not None:
|
|
935
|
+
rest = obj.pose.bones[parent].bone.matrix_local.inverted_safe() @ bone.bone.matrix_local
|
|
936
|
+
fixed[name] = (rest.inverted_safe(), rest, parent)
|
|
937
|
+
elif bone.parent is None:
|
|
938
|
+
fixed[name] = (bone.bone.matrix_local.inverted_safe(), axis @ bone.bone.matrix_local, None)
|
|
939
|
+
else:
|
|
940
|
+
fixed[name] = (None, axis, None)
|
|
941
|
+
|
|
942
|
+
def now():
|
|
943
|
+
out = {}
|
|
944
|
+
# The armature object's node: its world matrix in its parent's (sampling_cache.object_caching), each part
|
|
945
|
+
# through gltf2_blender_math.transform and swizzled to Y-up (object/sampler.py).
|
|
946
|
+
matrix = (obj.parent.matrix_world.inverted_safe() @ obj.matrix_world) if obj.parent is not None else obj.matrix_world.copy()
|
|
947
|
+
t, r, sc = matrix.decompose()
|
|
948
|
+
r.normalize()
|
|
949
|
+
q = r.to_matrix().to_4x4().to_quaternion()
|
|
950
|
+
sc = mathutils.Matrix.Diagonal(sc).to_4x4().to_scale()
|
|
951
|
+
out[_OBJECT] = {"translation": [t[0], t[2], -t[1]],
|
|
952
|
+
"rotation": [q.x, q.z, -q.y, q.w],
|
|
953
|
+
"scale": [sc[0], sc[2], sc[1]]}
|
|
954
|
+
for name, (before, correction, parent) in fixed.items():
|
|
955
|
+
bone = obj.pose.bones[name]
|
|
956
|
+
if parent is not None:
|
|
957
|
+
matrix = before @ obj.pose.bones[parent].matrix.inverted_safe() @ bone.matrix
|
|
958
|
+
elif before is not None:
|
|
959
|
+
matrix = before @ bone.matrix
|
|
960
|
+
else:
|
|
961
|
+
matrix = bone.matrix.copy()
|
|
962
|
+
t, r, sc = matrix.decompose()
|
|
963
|
+
r.normalize()
|
|
964
|
+
stretch = mathutils.Matrix.Identity(4)
|
|
965
|
+
stretch[0][0], stretch[1][1], stretch[2][2] = sc
|
|
966
|
+
q = (correction @ r.to_matrix().to_4x4()).to_quaternion()
|
|
967
|
+
out[name] = {"translation": list((correction @ mathutils.Matrix.Translation(t)).to_translation()),
|
|
968
|
+
"rotation": [q.x, q.y, q.z, q.w], "scale": list((correction @ stretch).to_scale())}
|
|
969
|
+
return out
|
|
970
|
+
return now
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _unit_quaternion(q):
|
|
974
|
+
n = math.sqrt(sum(v * v for v in q)) or 1.0
|
|
975
|
+
return [v / n for v in q]
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def _quaternion_degrees(a, b):
|
|
979
|
+
"""The angle between two rotations ([x, y, z, w]), in double precision, either sign."""
|
|
980
|
+
a, b = _unit_quaternion(a), _unit_quaternion(b)
|
|
981
|
+
# conj(a) * b
|
|
982
|
+
w = a[3] * b[3] + a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
983
|
+
x = a[3] * b[0] - a[0] * b[3] - a[1] * b[2] + a[2] * b[1]
|
|
984
|
+
y = a[3] * b[1] + a[0] * b[2] - a[1] * b[3] - a[2] * b[0]
|
|
985
|
+
z = a[3] * b[2] - a[0] * b[1] + a[1] * b[0] - a[2] * b[3]
|
|
986
|
+
return math.degrees(2.0 * math.atan2(math.sqrt(x * x + y * y + z * z), abs(w)))
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _quaternion_slerp(a, b, t):
|
|
990
|
+
"""The shortest arc from `a` to `b` at `t` ([x, y, z, w]), as a reader interpolates two keys."""
|
|
991
|
+
a, b = _unit_quaternion(a), _unit_quaternion(b)
|
|
992
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
993
|
+
if dot < 0.0:
|
|
994
|
+
b, dot = [-v for v in b], -dot
|
|
995
|
+
if dot > 0.9999999:
|
|
996
|
+
return _unit_quaternion([x + (y - x) * t for x, y in zip(a, b)])
|
|
997
|
+
theta = math.acos(min(1.0, dot))
|
|
998
|
+
s0, s1 = math.sin((1.0 - t) * theta) / math.sin(theta), math.sin(t * theta) / math.sin(theta)
|
|
999
|
+
return [s0 * x + s1 * y for x, y in zip(a, b)]
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _off_arc(a, b, points):
|
|
1003
|
+
"""The largest angle, in degrees, between each (u, rotation) of `points` and the shortest arc from `a` to `b`
|
|
1004
|
+
at u: what a reader interpolating keys `a` and `b` misses there. `a` and `b` are unit [x, y, z, w]; the
|
|
1005
|
+
points' rotations need not be unit. Pure Python, the arc's angle worked out once."""
|
|
1006
|
+
ax, ay, az, aw = a
|
|
1007
|
+
bx, by, bz, bw = b
|
|
1008
|
+
dot = ax * bx + ay * by + az * bz + aw * bw
|
|
1009
|
+
if dot < 0.0:
|
|
1010
|
+
bx, by, bz, bw, dot = -bx, -by, -bz, -bw, -dot
|
|
1011
|
+
theta = math.acos(min(1.0, dot))
|
|
1012
|
+
sine = math.sin(theta)
|
|
1013
|
+
worst = 0.0
|
|
1014
|
+
for u, (mx, my, mz, mw) in points:
|
|
1015
|
+
if sine < 1e-9:
|
|
1016
|
+
w0, w1 = 1.0 - u, u
|
|
1017
|
+
else:
|
|
1018
|
+
w0, w1 = math.sin((1.0 - u) * theta) / sine, math.sin(u * theta) / sine
|
|
1019
|
+
px, py, pz, pw = w0 * ax + w1 * bx, w0 * ay + w1 * by, w0 * az + w1 * bz, w0 * aw + w1 * bw
|
|
1020
|
+
# conj(p) * m: its vector part against its scalar part is half the angle between them.
|
|
1021
|
+
w = pw * mw + px * mx + py * my + pz * mz
|
|
1022
|
+
x = pw * mx - px * mw - py * mz + pz * my
|
|
1023
|
+
y = pw * my + px * mz - py * mw - pz * mx
|
|
1024
|
+
z = pw * mz - px * my + py * mx - pz * mw
|
|
1025
|
+
angle = math.degrees(2.0 * math.atan2(math.sqrt(x * x + y * y + z * z), abs(w)))
|
|
1026
|
+
if angle > worst:
|
|
1027
|
+
worst = angle
|
|
1028
|
+
return worst
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
_OBJECT = "\u0000object"
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def _curve_groups(obj, action):
|
|
1035
|
+
"""The action's curves on `obj`'s bones, per bone and property: (bone, path, four curves, rotation mode), for
|
|
1036
|
+
a quaternion or Euler rotation and a location. The armature object's own channels (root motion keyed on the
|
|
1037
|
+
object) are one more group, its bone named _OBJECT."""
|
|
1038
|
+
modes = {b.name: b.rotation_mode for b in obj.pose.bones} if obj.pose is not None else {}
|
|
1039
|
+
modes[_OBJECT] = obj.rotation_mode
|
|
1040
|
+
curves = {}
|
|
1041
|
+
for curve in _action_curves(action):
|
|
1042
|
+
matched = _BONE_PATH.match(curve.data_path)
|
|
1043
|
+
if matched and matched.group(1) in modes:
|
|
1044
|
+
bone, path = matched.group(1), matched.group(2)
|
|
1045
|
+
elif curve.data_path in ("location", "rotation_quaternion", "rotation_euler"):
|
|
1046
|
+
bone, path = _OBJECT, curve.data_path
|
|
1047
|
+
else:
|
|
1048
|
+
continue
|
|
1049
|
+
if path in ("rotation_quaternion", "rotation_euler", "location") and 0 <= curve.array_index < 4:
|
|
1050
|
+
curves.setdefault((bone, path), [None, None, None, None])[curve.array_index] = curve
|
|
1051
|
+
groups = []
|
|
1052
|
+
for (bone, path), four in sorted(curves.items()):
|
|
1053
|
+
mode = modes[bone]
|
|
1054
|
+
if path == "rotation_quaternion" and mode != "QUATERNION":
|
|
1055
|
+
continue
|
|
1056
|
+
if path == "rotation_euler" and mode in ("QUATERNION", "AXIS_ANGLE"):
|
|
1057
|
+
continue
|
|
1058
|
+
groups.append((bone, path, four, mode))
|
|
1059
|
+
return groups
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _curve_value(group, frame):
|
|
1063
|
+
"""A group's value at a frame: a rotation as [x, y, z, w], a location as [x, y, z]."""
|
|
1064
|
+
_bone, path, four, mode = group
|
|
1065
|
+
if path == "rotation_quaternion":
|
|
1066
|
+
v = [c.evaluate(frame) if c is not None else (1.0 if i == 0 else 0.0) for i, c in enumerate(four)]
|
|
1067
|
+
return [v[1], v[2], v[3], v[0]]
|
|
1068
|
+
v = [c.evaluate(frame) if c is not None else 0.0 for c in four[:3]]
|
|
1069
|
+
if path == "rotation_euler":
|
|
1070
|
+
q = mathutils.Euler(v, mode).to_quaternion()
|
|
1071
|
+
return [q.x, q.y, q.z, q.w]
|
|
1072
|
+
return v
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _straight(group):
|
|
1076
|
+
"""Whether every key of the group's curves interpolates linearly, with no modifier: then between two of its
|
|
1077
|
+
keys each component moves in a straight line."""
|
|
1078
|
+
return all(c is None or (len(c.modifiers) == 0 and all(p.interpolation == "LINEAR" for p in c.keyframe_points))
|
|
1079
|
+
for c in group[2])
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _sample_frames(groups, first, last, unit, chains):
|
|
1083
|
+
"""WHERE TO SAMPLE AN ACTION: its whole frames (as the exporter samples it), its own keys (an action keyed
|
|
1084
|
+
off whole frames, every 0.8 frames for a 30 fps source in a 24 fps scene, turns between whole frames), and,
|
|
1085
|
+
where its curves still part from what a reader interpolates between two of those by more than the limit
|
|
1086
|
+
(looked at a quarter, half and three quarters of the way), the middle, down to 1 / SUBFRAME_MOST of a frame. A rotation is judged along its bone's chain: the sum of
|
|
1087
|
+
its own and its parents' differences (`chains`: each bone with its parents), so a fingertip, which turns
|
|
1088
|
+
with every bone above it, stays within the limit too. Rotations keyed linearly still part from a reader's
|
|
1089
|
+
interpolation (Blender normalises the interpolated components, a reader turns along the arc), so every
|
|
1090
|
+
rotation is judged; a linearly keyed location is not. Answers the frames, sorted, and what the curves leave
|
|
1091
|
+
between them: (degrees along a chain, bone, frame)."""
|
|
1092
|
+
frames = {float(f) for f in range(int(math.ceil(first)), int(math.floor(last)) + 1)}
|
|
1093
|
+
frames.update((first, last))
|
|
1094
|
+
for group in groups:
|
|
1095
|
+
for curve in group[2]:
|
|
1096
|
+
if curve is not None:
|
|
1097
|
+
frames.update(round(p.co[0], 4) for p in curve.keyframe_points if first < p.co[0] < last)
|
|
1098
|
+
judged = [g for g in groups if g[1] != "location" or not _straight(g)]
|
|
1099
|
+
straight = {id(g) for g in judged if _straight(g)}
|
|
1100
|
+
left = (0.0, None, None)
|
|
1101
|
+
if not judged:
|
|
1102
|
+
return sorted(frames), left
|
|
1103
|
+
cache = {}
|
|
1104
|
+
|
|
1105
|
+
def value(group, at):
|
|
1106
|
+
key = (id(group), at)
|
|
1107
|
+
if key not in cache:
|
|
1108
|
+
found = _curve_value(group, at)
|
|
1109
|
+
cache[key] = found if group[1] == "location" else tuple(_unit_quaternion(found))
|
|
1110
|
+
return cache[key]
|
|
1111
|
+
|
|
1112
|
+
ordered = sorted(frames)
|
|
1113
|
+
todo = list(zip(ordered, ordered[1:]))
|
|
1114
|
+
while todo:
|
|
1115
|
+
a, b = todo.pop()
|
|
1116
|
+
middle = (a + b) / 2.0
|
|
1117
|
+
turned, moved = {}, False
|
|
1118
|
+
for group in judged:
|
|
1119
|
+
va, vb = value(group, a), value(group, b)
|
|
1120
|
+
if group[1] == "location":
|
|
1121
|
+
vm = value(group, middle)
|
|
1122
|
+
moved = moved or math.sqrt(sum((m - (x + y) / 2.0) ** 2 for x, y, m in zip(va, vb, vm))) * unit > SUBFRAME_METRES
|
|
1123
|
+
continue
|
|
1124
|
+
if id(group) in straight and abs(sum(x * y for x, y in zip(va, vb))) > 0.99985:
|
|
1125
|
+
# Keyed linearly and turning under 2 degrees here: its normalised interpolation is on the arc
|
|
1126
|
+
# to within thousandths of a degree.
|
|
1127
|
+
continue
|
|
1128
|
+
# At the quarters too: Blender's normalised interpolation meets the arc in the middle and parts from
|
|
1129
|
+
# it most near a quarter.
|
|
1130
|
+
turned[group[0]] = _off_arc(va, vb, [(u, value(group, a + (b - a) * u)) for u in (0.25, 0.5, 0.75)])
|
|
1131
|
+
along = max(((sum(turned.get(b, 0.0) for b in chains[bone]), bone) for bone in turned), default=(0.0, None))
|
|
1132
|
+
if (moved or along[0] > SUBFRAME_DEGREES) and (b - a) / 2.0 >= 1.0 / SUBFRAME_MOST:
|
|
1133
|
+
frames.add(middle)
|
|
1134
|
+
todo.extend(((a, middle), (middle, b)))
|
|
1135
|
+
continue
|
|
1136
|
+
if along[0] > left[0]:
|
|
1137
|
+
left = (along[0], along[1], middle)
|
|
1138
|
+
return sorted(frames), left
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def _densify_subframes(out, objects, scene, candidates):
|
|
1142
|
+
"""SAMPLE BETWEEN FRAMES WHERE IT MATTERS (RFC 0007 §1). Blender's exporter samples each action at whole
|
|
1143
|
+
frames only: it calls `scene.frame_set(int(frame))` (its sampled/sampling_cache.py), so a fractional
|
|
1144
|
+
`export_frame_step` is cut to whole frames, and the step itself is a whole number.
|
|
1145
|
+
|
|
1146
|
+
For each action whose curves say a half frame may be off by more than SUBFRAME_DEGREES (or
|
|
1147
|
+
SUBFRAME_METRES), this chooses where to sample (_sample_frames: whole frames, the action's own keys, and
|
|
1148
|
+
the middles its bending curves need), poses the armature as the exporter does (_as_exported) and evaluates
|
|
1149
|
+
Blender at each of those frames that isn't whole, computing each bone's glTF values with the exporter's own
|
|
1150
|
+
formulas (_bone_transforms). Whole frames keep the exporter's own keys; a few are evaluated too, and must
|
|
1151
|
+
give the same values, else the action is left as exported and named. A bone channel whose new samples lie
|
|
1152
|
+
more than the limit from its whole-frame interpolation gets all of them; every other channel stays as the
|
|
1153
|
+
exporter wrote it. Runs before the sign pass, which then writes every key, the new ones too, in its
|
|
1154
|
+
action's sign with its keyed length.
|
|
1155
|
+
|
|
1156
|
+
Answers {"actions": {action: keys}, "channels": n, "bytes": change in the file's size, "remaining":
|
|
1157
|
+
(degrees, action, bone, frame), "mismatch": [...], "evaluated": frames evaluated}."""
|
|
1158
|
+
result = {"actions": {}, "channels": 0, "bytes": 0, "remaining": (0.0, None, None, None), "mismatch": [], "evaluated": 0}
|
|
1159
|
+
wanted = {action for (_armature, action), (degrees, metres, _b, _f) in candidates.items()
|
|
1160
|
+
if degrees > SUBFRAME_DEGREES or metres > SUBFRAME_METRES}
|
|
1161
|
+
armatures = {o.name: o for o in objects if o.type == "ARMATURE" and o.pose is not None}
|
|
1162
|
+
if not wanted or not armatures:
|
|
1163
|
+
return result
|
|
1164
|
+
before_bytes = os.path.getsize(out)
|
|
1165
|
+
data, length, document, binary = _glb_parts(out)
|
|
1166
|
+
nodes = document.get("nodes", [])
|
|
1167
|
+
accessors, views = document.get("accessors", []), document.get("bufferViews", [])
|
|
1168
|
+
parent_of = {child: index for index, node in enumerate(nodes) for child in node.get("children", [])}
|
|
1169
|
+
rate = _key_rate(scene)
|
|
1170
|
+
frame, subframe = scene.frame_current, scene.frame_subframe
|
|
1171
|
+
|
|
1172
|
+
def read(index):
|
|
1173
|
+
accessor = accessors[index]
|
|
1174
|
+
view = views[accessor["bufferView"]]
|
|
1175
|
+
width = {"SCALAR": 1, "VEC3": 3, "VEC4": 4}[accessor["type"]]
|
|
1176
|
+
start = view.get("byteOffset", 0) + accessor.get("byteOffset", 0)
|
|
1177
|
+
flat = struct.unpack_from("<%df" % (accessor["count"] * width), binary, start)
|
|
1178
|
+
return [list(flat[i * width:(i + 1) * width]) for i in range(accessor["count"])] if width > 1 else list(flat)
|
|
1179
|
+
|
|
1180
|
+
def append(values, kind, bounds=False):
|
|
1181
|
+
flat = [v for value in values for v in value] if kind != "SCALAR" else list(values)
|
|
1182
|
+
packed = struct.pack("<%df" % len(flat), *flat)
|
|
1183
|
+
binary.extend(b"\0" * ((4 - len(binary) % 4) % 4))
|
|
1184
|
+
views.append({"buffer": 0, "byteOffset": len(binary), "byteLength": len(packed)})
|
|
1185
|
+
binary.extend(packed)
|
|
1186
|
+
accessor = {"bufferView": len(views) - 1, "componentType": 5126, "count": len(values), "type": kind}
|
|
1187
|
+
if bounds:
|
|
1188
|
+
accessor["min"], accessor["max"] = [min(values)], [max(values)]
|
|
1189
|
+
accessors.append(accessor)
|
|
1190
|
+
return len(accessors) - 1
|
|
1191
|
+
|
|
1192
|
+
def interpolate(path, frames, values, at):
|
|
1193
|
+
"""A channel's exported keys read at a frame, as a reader reads them (held past the ends)."""
|
|
1194
|
+
if at <= frames[0]:
|
|
1195
|
+
return values[0]
|
|
1196
|
+
if at >= frames[-1]:
|
|
1197
|
+
return values[-1]
|
|
1198
|
+
for k in range(1, len(frames)):
|
|
1199
|
+
if at <= frames[k]:
|
|
1200
|
+
u = (at - frames[k - 1]) / (frames[k] - frames[k - 1]) if frames[k] > frames[k - 1] else 1.0
|
|
1201
|
+
if path == "rotation":
|
|
1202
|
+
return _quaternion_slerp(values[k - 1], values[k], u)
|
|
1203
|
+
return [x + (y - x) * u for x, y in zip(values[k - 1], values[k])]
|
|
1204
|
+
try:
|
|
1205
|
+
for animation in document.get("animations", []):
|
|
1206
|
+
name = animation.get("name", "")
|
|
1207
|
+
action = bpy.data.actions.get(name)
|
|
1208
|
+
if action is None or name not in wanted:
|
|
1209
|
+
continue
|
|
1210
|
+
channels = []
|
|
1211
|
+
for channel in animation.get("channels", []):
|
|
1212
|
+
target = channel.get("target", {})
|
|
1213
|
+
node = target.get("node")
|
|
1214
|
+
if node is None or target.get("path") not in ("translation", "rotation", "scale"):
|
|
1215
|
+
continue
|
|
1216
|
+
bone = nodes[node].get("name", "")
|
|
1217
|
+
owner = next((a for a in armatures.values() if a.pose.bones.get(bone) is not None), None)
|
|
1218
|
+
if owner is None and bone in armatures:
|
|
1219
|
+
owner, bone = armatures[bone], _OBJECT
|
|
1220
|
+
sampler = animation["samplers"][channel["sampler"]]
|
|
1221
|
+
if owner is None or sampler.get("interpolation", "LINEAR") != "LINEAR":
|
|
1222
|
+
continue
|
|
1223
|
+
channels.append((node, channel, owner, bone, target["path"], sampler))
|
|
1224
|
+
owners = {owner.name for _n, _c, owner, _b, _p, _s in channels}
|
|
1225
|
+
if len(owners) != 1:
|
|
1226
|
+
continue
|
|
1227
|
+
obj = armatures[owners.pop()]
|
|
1228
|
+
slot = _action_slot_for(action)
|
|
1229
|
+
if slot is None and len(action.slots) > 1:
|
|
1230
|
+
result["mismatch"].append("%s has several slots: sampled at whole frames only" % name)
|
|
1231
|
+
continue
|
|
1232
|
+
slide = _action_slide(action)
|
|
1233
|
+
exported = {}
|
|
1234
|
+
for _n, _c, _o, _b, _p, sampler in channels:
|
|
1235
|
+
key = (sampler["input"], sampler["output"])
|
|
1236
|
+
if key not in exported:
|
|
1237
|
+
exported[key] = ([round(t * rate + slide, 4) for t in read(sampler["input"])], read(sampler["output"]))
|
|
1238
|
+
first = min(f[0][0] for f in exported.values())
|
|
1239
|
+
last = max(f[0][-1] for f in exported.values())
|
|
1240
|
+
if last - first < 1:
|
|
1241
|
+
continue
|
|
1242
|
+
unit = _world_unit(obj)
|
|
1243
|
+
groups = _curve_groups(obj, action)
|
|
1244
|
+
chains = {_OBJECT: [_OBJECT]}
|
|
1245
|
+
for bone in obj.pose.bones:
|
|
1246
|
+
chains[bone.name] = [bone.name] + [parent.name for parent in bone.parent_recursive] + [_OBJECT]
|
|
1247
|
+
frames, curves_left = _sample_frames(groups, first, last, unit, chains)
|
|
1248
|
+
between = [f for f in frames if abs(f - round(f)) > 1e-4]
|
|
1249
|
+
if not between:
|
|
1250
|
+
continue
|
|
1251
|
+
parents = {}
|
|
1252
|
+
for node, _c, _o, bone, _p, _s in channels:
|
|
1253
|
+
if bone == _OBJECT:
|
|
1254
|
+
continue
|
|
1255
|
+
parent = parent_of.get(node)
|
|
1256
|
+
parent_name = nodes[parent].get("name") if parent is not None else None
|
|
1257
|
+
parents[bone] = parent_name if parent_name is not None and obj.pose.bones.get(parent_name) is not None else None
|
|
1258
|
+
transforms = _bone_transforms(obj, parents)
|
|
1259
|
+
# Three whole frames are evaluated too: the exporter's formulas must give its own keys there.
|
|
1260
|
+
checks = sorted({float(math.ceil(first)), float(round((first + last) / 2.0)), float(math.floor(last))})
|
|
1261
|
+
|
|
1262
|
+
def evaluate():
|
|
1263
|
+
found = {}
|
|
1264
|
+
for at in checks + between:
|
|
1265
|
+
whole = math.floor(at)
|
|
1266
|
+
scene.frame_set(int(whole), subframe=at - whole)
|
|
1267
|
+
found[at] = transforms()
|
|
1268
|
+
result["evaluated"] += len(found)
|
|
1269
|
+
return found
|
|
1270
|
+
posed = _as_exported(obj, action, slot, evaluate)
|
|
1271
|
+
worst_match = 0.0
|
|
1272
|
+
for _n, _c, _o, bone, path, sampler in channels:
|
|
1273
|
+
key_frames, values = exported[(sampler["input"], sampler["output"])]
|
|
1274
|
+
for at in checks:
|
|
1275
|
+
if at in key_frames:
|
|
1276
|
+
mine, theirs = posed[at][bone][path], values[key_frames.index(at)]
|
|
1277
|
+
worst_match = max(worst_match, _quaternion_degrees(mine, theirs) if path == "rotation" else
|
|
1278
|
+
max(abs(x - y) / max(1.0, abs(y)) for x, y in zip(mine, theirs)) * 1000.0)
|
|
1279
|
+
if worst_match > 0.01:
|
|
1280
|
+
result["mismatch"].append("%s: Blender's poses at whole frames differ from the exported keys by %.4f "
|
|
1281
|
+
"(degrees or mm): sampled at whole frames only" % (name, worst_match))
|
|
1282
|
+
continue
|
|
1283
|
+
seconds = [(f - slide) / rate for f in frames]
|
|
1284
|
+
new_input = None
|
|
1285
|
+
moved = 0
|
|
1286
|
+
placed = {}
|
|
1287
|
+
for _n, channel, _o, bone, path, sampler in channels:
|
|
1288
|
+
key_frames, values = exported[(sampler["input"], sampler["output"])]
|
|
1289
|
+
if sampler["input"] not in placed:
|
|
1290
|
+
where = []
|
|
1291
|
+
for at in between:
|
|
1292
|
+
k = min(max(bisect.bisect_left(key_frames, at), 1), len(key_frames) - 1)
|
|
1293
|
+
span = key_frames[k] - key_frames[k - 1]
|
|
1294
|
+
where.append((at, k, min(1.0, max(0.0, (at - key_frames[k - 1]) / span)) if span > 0 else 1.0))
|
|
1295
|
+
placed[sampler["input"]] = where
|
|
1296
|
+
where = placed[sampler["input"]]
|
|
1297
|
+
if path == "rotation":
|
|
1298
|
+
off = 0.0
|
|
1299
|
+
for at, k, u in where:
|
|
1300
|
+
off = max(off, _off_arc(_unit_quaternion(values[k - 1]), _unit_quaternion(values[k]), [(u, posed[at][bone][path])]))
|
|
1301
|
+
else:
|
|
1302
|
+
off = max(max(abs(m - (x + (y - x) * u)) for x, y, m in zip(values[k - 1], values[k], posed[at][bone][path]))
|
|
1303
|
+
for at, k, u in where) * (unit if path == "translation" else 1.0)
|
|
1304
|
+
if off <= (SUBFRAME_DEGREES / 25.0 if path == "rotation" else SUBFRAME_METRES / 10.0 if path == "translation"
|
|
1305
|
+
else SUBFRAME_SCALE / 10.0):
|
|
1306
|
+
continue
|
|
1307
|
+
if new_input is None:
|
|
1308
|
+
new_input = append(seconds, "SCALAR", bounds=True)
|
|
1309
|
+
at_key = dict(zip(key_frames, values))
|
|
1310
|
+
dense = [posed[f][bone][path] if f in posed and f not in checks else at_key[f] if f in at_key
|
|
1311
|
+
else interpolate(path, key_frames, values, f) for f in frames]
|
|
1312
|
+
output = append(dense, "VEC4" if path == "rotation" else "VEC3")
|
|
1313
|
+
animation["samplers"].append({"input": new_input, "output": output, "interpolation": "LINEAR"})
|
|
1314
|
+
channel["sampler"] = len(animation["samplers"]) - 1
|
|
1315
|
+
moved += 1
|
|
1316
|
+
if curves_left[0] > result["remaining"][0]:
|
|
1317
|
+
result["remaining"] = (curves_left[0], name, curves_left[1], curves_left[2])
|
|
1318
|
+
if not moved:
|
|
1319
|
+
continue
|
|
1320
|
+
used = sorted({channel["sampler"] for channel in animation["channels"]})
|
|
1321
|
+
index = {old: new for new, old in enumerate(used)}
|
|
1322
|
+
animation["samplers"] = [animation["samplers"][old] for old in used]
|
|
1323
|
+
for channel in animation["channels"]:
|
|
1324
|
+
channel["sampler"] = index[channel["sampler"]]
|
|
1325
|
+
result["actions"][name] = len(frames)
|
|
1326
|
+
result["channels"] += moved
|
|
1327
|
+
finally:
|
|
1328
|
+
scene.frame_set(frame, subframe=subframe)
|
|
1329
|
+
if not result["actions"]:
|
|
1330
|
+
return result
|
|
1331
|
+
binary = _compact(document, binary)
|
|
1332
|
+
document["buffers"][0]["byteLength"] = len(binary) + (4 - len(binary) % 4) % 4
|
|
1333
|
+
_write_glb(out, document, binary)
|
|
1334
|
+
result["bytes"] = os.path.getsize(out) - before_bytes
|
|
1335
|
+
return result
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
_COMPACT_SAFE = ("KHR_node_visibility", "KHR_lights_punctual", "KHR_texture_transform", "KHR_animation_pointer",
|
|
1339
|
+
"KHR_mesh_quantization") + STD_EXTENSIONS
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def _compact(document, binary):
|
|
1343
|
+
"""Drop the accessors and buffer views nothing uses any more (the samples a denser channel replaced) and
|
|
1344
|
+
pack the rest, each view's bytes as they were, in their order. Left as it is when the file uses an
|
|
1345
|
+
extension this does not know the references of."""
|
|
1346
|
+
used_extensions = [e for e in document.get("extensionsUsed", []) if not e.startswith("KHR_materials_") and e not in _COMPACT_SAFE]
|
|
1347
|
+
accessors, views = document.get("accessors", []), document.get("bufferViews", [])
|
|
1348
|
+
if used_extensions or any("sparse" in a for a in accessors) or len(document.get("buffers", [])) != 1:
|
|
1349
|
+
return binary
|
|
1350
|
+
refs = [] # (holder, key) pairs holding an accessor index
|
|
1351
|
+
for mesh in document.get("meshes", []):
|
|
1352
|
+
for primitive in mesh.get("primitives", []):
|
|
1353
|
+
attributes = primitive.get("attributes", {})
|
|
1354
|
+
refs.extend((attributes, key) for key in attributes)
|
|
1355
|
+
if "indices" in primitive:
|
|
1356
|
+
refs.append((primitive, "indices"))
|
|
1357
|
+
for target in primitive.get("targets", []):
|
|
1358
|
+
refs.extend((target, key) for key in target)
|
|
1359
|
+
for skin in document.get("skins", []):
|
|
1360
|
+
if "inverseBindMatrices" in skin:
|
|
1361
|
+
refs.append((skin, "inverseBindMatrices"))
|
|
1362
|
+
for animation in document.get("animations", []):
|
|
1363
|
+
for sampler in animation.get("samplers", []):
|
|
1364
|
+
refs.extend([(sampler, "input"), (sampler, "output")])
|
|
1365
|
+
lengths = ((animation.get("extensions") or {}).get("STD_animation_blending") or {}).get("rotationLengths") or {}
|
|
1366
|
+
refs.extend((lengths, key) for key in lengths)
|
|
1367
|
+
keep = sorted({holder[key] for holder, key in refs})
|
|
1368
|
+
remap = {old: new for new, old in enumerate(keep)}
|
|
1369
|
+
for holder, key in refs:
|
|
1370
|
+
holder[key] = remap[holder[key]]
|
|
1371
|
+
document["accessors"] = accessors = [accessors[old] for old in keep]
|
|
1372
|
+
view_refs = [(a, "bufferView") for a in accessors if "bufferView" in a] + \
|
|
1373
|
+
[(image, "bufferView") for image in document.get("images", []) if "bufferView" in image]
|
|
1374
|
+
kept_views = sorted({holder[key] for holder, key in view_refs})
|
|
1375
|
+
view_map = {old: new for new, old in enumerate(kept_views)}
|
|
1376
|
+
packed = bytearray()
|
|
1377
|
+
new_views = []
|
|
1378
|
+
for old in kept_views:
|
|
1379
|
+
view = dict(views[old])
|
|
1380
|
+
start = view.get("byteOffset", 0)
|
|
1381
|
+
packed.extend(b"\0" * ((4 - len(packed) % 4) % 4))
|
|
1382
|
+
chunk = binary[start:start + view["byteLength"]]
|
|
1383
|
+
view["byteOffset"] = len(packed)
|
|
1384
|
+
packed.extend(chunk)
|
|
1385
|
+
new_views.append(view)
|
|
1386
|
+
for holder, key in view_refs:
|
|
1387
|
+
holder[key] = view_map[holder[key]]
|
|
1388
|
+
document["bufferViews"] = new_views
|
|
1389
|
+
document["buffers"][0]["byteLength"] = len(packed) + (4 - len(packed) % 4) % 4
|
|
1390
|
+
return packed
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def _subframe_warnings(dense, candidates):
|
|
1394
|
+
"""What remains between frames, named (RFC 0007 §5)."""
|
|
1395
|
+
found = list(dense["mismatch"])
|
|
1396
|
+
if dense["actions"]:
|
|
1397
|
+
found.append("between frames: %d actions sampled also off whole frames where a half frame was over %.2f deg "
|
|
1398
|
+
"(%s keys); the largest half-step difference their curves leave is %.2f deg (%s, %s, frame %.2f), in "
|
|
1399
|
+
"that bone's own rotation" % (len(dense["actions"]), SUBFRAME_DEGREES,
|
|
1400
|
+
", ".join("%s %d" % (name, k) for name, k in sorted(dense["actions"].items())),
|
|
1401
|
+
dense["remaining"][0], dense["remaining"][1], dense["remaining"][2], dense["remaining"][3] or 0.0))
|
|
1402
|
+
under = [(degrees, action, bone, frame) for (_armature, action), (degrees, metres, bone, frame) in candidates.items()
|
|
1403
|
+
if action not in dense["actions"] and degrees > 0.01]
|
|
1404
|
+
if under:
|
|
1405
|
+
worst = max(under)
|
|
1406
|
+
found.append("between frames, the actions sampled at whole frames: the largest half-frame difference of their "
|
|
1407
|
+
"curves is %.2f deg (%s, %s, frame %.1f); only keyed curves are measured, so a bone posed by IK or "
|
|
1408
|
+
"a constraint can differ more" % worst)
|
|
1409
|
+
return found
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
def _subframe_manifest(dense):
|
|
1413
|
+
return {"limitDegrees": SUBFRAME_DEGREES, "limitMetres": SUBFRAME_METRES, "actions": dense["actions"],
|
|
1414
|
+
"channels": dense["channels"], "bytes": dense["bytes"], "framesEvaluated": dense["evaluated"],
|
|
1415
|
+
"remaining": {"degrees": round(dense["remaining"][0], 4), "action": dense["remaining"][1],
|
|
1416
|
+
"bone": dense["remaining"][2], "frame": dense["remaining"][3]}}
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def _driven_shape_keys(objects):
|
|
1420
|
+
"""Shape keys a driver sets (a corrective shape following a bone): the glTF exporter bakes them into each
|
|
1421
|
+
action as morph weights, so a layer over another action blends those weights, where Blender drives them
|
|
1422
|
+
again from the blended pose. Named, with the keys."""
|
|
1423
|
+
found = []
|
|
1424
|
+
for obj in objects:
|
|
1425
|
+
keys = obj.data.shape_keys if obj.type == "MESH" and obj.data is not None else None
|
|
1426
|
+
drivers = keys.animation_data.drivers if keys is not None and keys.animation_data is not None else []
|
|
1427
|
+
names = sorted({d.data_path.split('"')[1] for d in drivers if d.data_path.startswith("key_blocks[")})
|
|
1428
|
+
if names:
|
|
1429
|
+
found.append("%s's shape keys %s are driven: each action carries them as morph weights as it evaluates "
|
|
1430
|
+
"them, so a layer over another action blends those weights, not Blender's drivers on the "
|
|
1431
|
+
"blended pose" % (obj.name, ", ".join(names)))
|
|
1432
|
+
return found
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
def _object_only_armature_actions(objects):
|
|
1436
|
+
"""An armature's actions that key no bone: Blender's glTF exporter carries an armature's action only
|
|
1437
|
+
when a bone is keyed (its __is_armature_slot), so the armature object's own moves in them are lost."""
|
|
1438
|
+
found = []
|
|
1439
|
+
for obj in objects:
|
|
1440
|
+
if obj.type != "ARMATURE":
|
|
1441
|
+
continue
|
|
1442
|
+
animation = obj.animation_data
|
|
1443
|
+
used = {animation.action} if animation and animation.action else set()
|
|
1444
|
+
for track in (animation.nla_tracks if animation else []):
|
|
1445
|
+
used.update(strip.action for strip in track.strips if strip.action)
|
|
1446
|
+
lone = []
|
|
1447
|
+
for action in bpy.data.actions:
|
|
1448
|
+
slots = [s for s in action.slots if s.target_id_type == "OBJECT"
|
|
1449
|
+
and (action in used or s.identifier == "OB" + obj.name)]
|
|
1450
|
+
if not slots:
|
|
1451
|
+
continue
|
|
1452
|
+
curves = _action_curves(action)
|
|
1453
|
+
if curves and not any(_BONE_PATH.match(c.data_path) or c.data_path.startswith("pose.bones[") for c in curves):
|
|
1454
|
+
lone.append(action.name)
|
|
1455
|
+
if lone:
|
|
1456
|
+
found.append("%s's actions %s key the armature object and no bone: the glTF exporter carries an armature's "
|
|
1457
|
+
"action only with a bone keyed, so they are not exported (key a bone in them too)"
|
|
1458
|
+
% (obj.name, ", ".join(sorted(lone))))
|
|
1459
|
+
return found
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
def _action_curves(action):
|
|
1463
|
+
from bpy_extras import anim_utils
|
|
1464
|
+
curves = []
|
|
1465
|
+
for slot in action.slots:
|
|
1466
|
+
bag = anim_utils.action_get_channelbag_for_slot(action, slot)
|
|
1467
|
+
if bag is not None:
|
|
1468
|
+
curves.extend(bag.fcurves)
|
|
1469
|
+
return curves
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
def _key_rate(scene):
|
|
1473
|
+
"""Blender's exporter writes a key at frame f at time f / (fps * fps_base)."""
|
|
1474
|
+
return round(float(scene.render.fps * (scene.render.fps_base or 1.0)), 6)
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
def _action_slide(action):
|
|
1478
|
+
"""The frame an action's clip starts at, when the exporter slid it to zero (its negative frames,
|
|
1479
|
+
SLIDE, the exporter's default); 0 otherwise. A key at action frame f is at clip time
|
|
1480
|
+
(f - slide) / keyRate."""
|
|
1481
|
+
start = float(action.frame_range[0])
|
|
1482
|
+
return start if start < 0 else 0.0
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
def _action_slides():
|
|
1486
|
+
slides = {}
|
|
1487
|
+
for action in bpy.data.actions:
|
|
1488
|
+
slide = _action_slide(action)
|
|
1489
|
+
if slide != 0.0:
|
|
1490
|
+
slides[action.name] = slide
|
|
1491
|
+
return slides
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
def _collection_tree(scene, authoring):
|
|
1495
|
+
"""Each collection of the scene's hierarchy with its parents (`extras.collections` on the scene)."""
|
|
1496
|
+
tree = {}
|
|
1497
|
+
|
|
1498
|
+
def walk(collection, parent):
|
|
1499
|
+
for child in collection.children:
|
|
1500
|
+
if child.name == _HOLDER or child.name_full in authoring:
|
|
1501
|
+
continue
|
|
1502
|
+
tree.setdefault(child.name, [])
|
|
1503
|
+
if parent is not None and parent not in tree[child.name]:
|
|
1504
|
+
tree[child.name].append(parent)
|
|
1505
|
+
walk(child, child.name)
|
|
1506
|
+
|
|
1507
|
+
walk(scene.collection, None)
|
|
1508
|
+
return tree
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
def _runtime_names(bone):
|
|
1512
|
+
"""The constraints a pose bone's `std.runtime` marks for a script (a name, several separated by commas, or
|
|
1513
|
+
true for all of them), by name."""
|
|
1514
|
+
marked = bone.get("std.runtime")
|
|
1515
|
+
if marked is None or marked is False or marked == 0:
|
|
1516
|
+
return []
|
|
1517
|
+
if isinstance(marked, str):
|
|
1518
|
+
return [n.strip() for n in marked.split(",") if n.strip()]
|
|
1519
|
+
return [c.name for c in bone.constraints]
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
def _runtime_constraints(objects):
|
|
1523
|
+
"""The constraints scripts drive, by armature and bone, and the warnings for those that cannot be."""
|
|
1524
|
+
found, warnings = {}, []
|
|
1525
|
+
for obj in objects:
|
|
1526
|
+
if obj.type != "ARMATURE" or obj.pose is None:
|
|
1527
|
+
continue
|
|
1528
|
+
for bone in obj.pose.bones:
|
|
1529
|
+
for name in _runtime_names(bone):
|
|
1530
|
+
constraint = bone.constraints.get(name)
|
|
1531
|
+
if constraint is None:
|
|
1532
|
+
warnings.append("%s/%s names a runtime constraint %s the bone does not have" % (obj.name, bone.name, name))
|
|
1533
|
+
continue
|
|
1534
|
+
if constraint.type != "DAMPED_TRACK":
|
|
1535
|
+
warnings.append("%s/%s's %s is a %s: only a Damped Track runs at runtime, so it is baked into the clips"
|
|
1536
|
+
% (obj.name, bone.name, name, constraint.type))
|
|
1537
|
+
continue
|
|
1538
|
+
found.setdefault((obj.name, bone.name), []).append(constraint)
|
|
1539
|
+
# Read now, before the export mutes them for sampling: the file's own mute state.
|
|
1540
|
+
data = {key: [_constraint_data(c) for c in constraints] for key, constraints in found.items()}
|
|
1541
|
+
return found, data, warnings
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
def _constraint_data(constraint):
|
|
1545
|
+
target = constraint.target
|
|
1546
|
+
data = {"name": constraint.name, "type": constraint.type,
|
|
1547
|
+
"target": target.name if target is not None else None, "subtarget": constraint.subtarget or None,
|
|
1548
|
+
"trackAxis": constraint.track_axis, "headTail": round(float(constraint.head_tail), 6),
|
|
1549
|
+
"influence": round(float(constraint.influence), 6), "mute": bool(constraint.mute)}
|
|
1550
|
+
# Head/Tail places the aim point along the TARGET bone, head to tail: its length goes with it.
|
|
1551
|
+
if target is not None and target.type == "ARMATURE" and constraint.subtarget and target.data.bones.get(constraint.subtarget):
|
|
1552
|
+
data["subtargetLength"] = round(float(target.data.bones[constraint.subtarget].length), 6)
|
|
1553
|
+
return data
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
_BONE_PATH = re.compile(r'^pose\.bones\["((?:[^"\\]|\\.)*)"\]\.(\w+)')
|
|
1557
|
+
_BONE_NAME = re.compile(r'^pose\.bones\["((?:[^"\\]|\\.)*)"\]')
|
|
1558
|
+
_GLTF_PATH = {"location": "translation", "rotation_quaternion": "rotation", "rotation_euler": "rotation",
|
|
1559
|
+
"rotation_axis_angle": "rotation", "scale": "scale"}
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
def _action_keyed_bones(action):
|
|
1563
|
+
"""std's character-layer mask: the authored F-curves' bones, never the baked channels.
|
|
1564
|
+
Read every action slot, and keep constant keys and bone custom-property/constraint curves too."""
|
|
1565
|
+
return sorted({bpy.utils.unescape_identifier(found.group(1))
|
|
1566
|
+
for curve in _action_curves(action)
|
|
1567
|
+
if (found := _BONE_NAME.match(curve.data_path)) is not None})
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
def _animation_keyed_bones(animation, bones):
|
|
1571
|
+
"""Write std's reserved mask while retaining other animation extras and std members."""
|
|
1572
|
+
extras = dict(animation.extras or {})
|
|
1573
|
+
namespace = extras.get("std")
|
|
1574
|
+
namespace = dict(namespace) if isinstance(namespace, dict) else {}
|
|
1575
|
+
namespace["keyedBones"] = sorted(set(bones))
|
|
1576
|
+
extras["std"] = namespace
|
|
1577
|
+
animation.extras = extras
|
|
1578
|
+
|
|
1579
|
+
|
|
1580
|
+
def _keyed_bone_channels(action):
|
|
1581
|
+
"""The (bone, glTF path) pairs an action keys."""
|
|
1582
|
+
keyed = set()
|
|
1583
|
+
for curve in _action_curves(action):
|
|
1584
|
+
found = _BONE_PATH.match(curve.data_path)
|
|
1585
|
+
if found and found.group(2) in _GLTF_PATH:
|
|
1586
|
+
keyed.add((found.group(1).replace('\\"', '"'), _GLTF_PATH[found.group(2)]))
|
|
1587
|
+
return keyed
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
def _rest_channels(armature):
|
|
1591
|
+
"""Each bone's rest local transform as the exporter writes a joint: translation, rotation
|
|
1592
|
+
(x, y, z, w) and scale, by bone name."""
|
|
1593
|
+
axis = _Y_UP
|
|
1594
|
+
rest = {}
|
|
1595
|
+
for bone in armature.data.bones:
|
|
1596
|
+
local = (bone.parent.matrix_local.inverted_safe() @ bone.matrix_local) if bone.parent is not None else axis @ bone.matrix_local
|
|
1597
|
+
t, r, s = local.decompose()
|
|
1598
|
+
rest[bone.name] = {"translation": (t.x, t.y, t.z), "rotation": (r.x, r.y, r.z, r.w), "scale": (s.x, s.y, s.z)}
|
|
1599
|
+
return rest
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def _sampled_values(channel):
|
|
1603
|
+
accessor = channel.sampler.output
|
|
1604
|
+
data = getattr(accessor.buffer_view, "data", None)
|
|
1605
|
+
if data is None:
|
|
1606
|
+
return None
|
|
1607
|
+
return struct.unpack("<%df" % (len(data) // 4), bytes(data))
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
def _leaves_rest(path, values, rest):
|
|
1611
|
+
if path == "rotation":
|
|
1612
|
+
for k in range(0, len(values) - 3, 4):
|
|
1613
|
+
dot = abs(values[k] * rest[0] + values[k + 1] * rest[1] + values[k + 2] * rest[2] + values[k + 3] * rest[3])
|
|
1614
|
+
# A turn of more than about 0.1 degrees (float32 keys carry about 1e-7 of noise).
|
|
1615
|
+
if dot < 1.0 - 5e-7:
|
|
1616
|
+
return True
|
|
1617
|
+
return False
|
|
1618
|
+
width = len(rest)
|
|
1619
|
+
for k in range(0, len(values) - width + 1, width):
|
|
1620
|
+
if any(abs(values[k + i] - rest[i]) > 1e-5 for i in range(width)):
|
|
1621
|
+
return True
|
|
1622
|
+
return False
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def _glb_parts(path):
|
|
1626
|
+
with open(path, "rb") as handle:
|
|
1627
|
+
data = bytearray(handle.read())
|
|
1628
|
+
length = int.from_bytes(data[12:16], "little")
|
|
1629
|
+
document = json.loads(bytes(data[20:20 + length]).decode("utf-8"))
|
|
1630
|
+
binary_length = int.from_bytes(data[20 + length:24 + length], "little")
|
|
1631
|
+
binary = bytearray(data[28 + length:28 + length + binary_length])
|
|
1632
|
+
return data, length, document, binary
|
|
1633
|
+
|
|
1634
|
+
|
|
1635
|
+
def _write_glb(path, document, binary):
|
|
1636
|
+
text = json.dumps(document, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
1637
|
+
text += b" " * ((4 - len(text) % 4) % 4)
|
|
1638
|
+
binary = bytes(binary) + b"\0" * ((4 - len(binary) % 4) % 4)
|
|
1639
|
+
total = 12 + 8 + len(text) + 8 + len(binary)
|
|
1640
|
+
with open(path, "wb") as handle:
|
|
1641
|
+
handle.write(b"glTF" + (2).to_bytes(4, "little") + total.to_bytes(4, "little"))
|
|
1642
|
+
handle.write(len(text).to_bytes(4, "little") + b"JSON" + text)
|
|
1643
|
+
handle.write(len(binary).to_bytes(4, "little") + b"BIN\0" + binary)
|
|
1644
|
+
|
|
1645
|
+
|
|
1646
|
+
def _keys_bone_rotation(action, bone):
|
|
1647
|
+
"""Whether the action keys `bone`'s rotation in any mode (quaternion, Euler, axis-angle)."""
|
|
1648
|
+
prefix = 'pose.bones["%s"].rotation_' % bone.replace('"', '\\"')
|
|
1649
|
+
return any(c.data_path.startswith(prefix) for c in _action_curves(action))
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
def _keyed_quaternion_curves(action, bone):
|
|
1653
|
+
"""The action's four rotation_quaternion curves of `bone` (None where one is not keyed), or None."""
|
|
1654
|
+
from bpy_extras import anim_utils
|
|
1655
|
+
bags = [anim_utils.action_get_channelbag_for_slot(action, slot) for slot in action.slots]
|
|
1656
|
+
curves_of = [list(bag.fcurves) for bag in bags if bag is not None]
|
|
1657
|
+
path = 'pose.bones["%s"].rotation_quaternion' % bone.replace('"', '\\"')
|
|
1658
|
+
for curves in curves_of:
|
|
1659
|
+
found = [None, None, None, None]
|
|
1660
|
+
for curve in curves:
|
|
1661
|
+
if curve.data_path == path and 0 <= curve.array_index < 4:
|
|
1662
|
+
found[curve.array_index] = curve
|
|
1663
|
+
if any(c is not None for c in found):
|
|
1664
|
+
return found
|
|
1665
|
+
return None
|
|
1666
|
+
|
|
1667
|
+
|
|
1668
|
+
_SHAPE_KEY_PATH = re.compile(r'^key_blocks\["((?:[^"\\]|\\.)*)"\]\.value$')
|
|
1669
|
+
|
|
1670
|
+
|
|
1671
|
+
def _blending(document, animation):
|
|
1672
|
+
"""An animation's `STD_animation_blending` (made, and listed in the file's `extensionsUsed`, when it has
|
|
1673
|
+
none yet)."""
|
|
1674
|
+
used = document.setdefault("extensionsUsed", [])
|
|
1675
|
+
if "STD_animation_blending" not in used:
|
|
1676
|
+
used.append("STD_animation_blending")
|
|
1677
|
+
return animation.setdefault("extensions", {}).setdefault("STD_animation_blending", {})
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
def _weights_keyed(out):
|
|
1681
|
+
"""WHICH MORPH TARGETS AN ACTION KEYS (RFC 0007 §2, `STD_animation_blending.weightsKeyed`). A glTF `weights` channel
|
|
1682
|
+
animates every target of its mesh together, so an action keying one shape key writes the others too (at
|
|
1683
|
+
their value when Blender sampled it). Blender's NLA leaves a shape key the action does not key to the
|
|
1684
|
+
layers below. Where an action keys only some of a mesh's targets, the animation names the ones it keys:
|
|
1685
|
+
`{ "<channel index>": [target index, ...] }`, the indices into the mesh's `extras.targetNames`. Answers how
|
|
1686
|
+
many channels were named."""
|
|
1687
|
+
data, length, document, binary = _glb_parts(out)
|
|
1688
|
+
nodes, meshes = document.get("nodes", []), document.get("meshes", [])
|
|
1689
|
+
named = 0
|
|
1690
|
+
for animation in document.get("animations", []):
|
|
1691
|
+
action = bpy.data.actions.get(animation.get("name", ""))
|
|
1692
|
+
if action is None:
|
|
1693
|
+
continue
|
|
1694
|
+
keyed = set()
|
|
1695
|
+
for curve in _action_curves(action):
|
|
1696
|
+
found = _SHAPE_KEY_PATH.match(curve.data_path)
|
|
1697
|
+
if found:
|
|
1698
|
+
keyed.add(found.group(1).replace('\\"', '"'))
|
|
1699
|
+
if not keyed:
|
|
1700
|
+
continue
|
|
1701
|
+
written = {}
|
|
1702
|
+
for index, channel in enumerate(animation.get("channels", [])):
|
|
1703
|
+
target = channel.get("target", {})
|
|
1704
|
+
if target.get("path") != "weights" or "node" not in target:
|
|
1705
|
+
continue
|
|
1706
|
+
mesh = nodes[target["node"]].get("mesh")
|
|
1707
|
+
names = ((meshes[mesh].get("extras") or {}).get("targetNames") or []) if mesh is not None else []
|
|
1708
|
+
indices = [i for i, name in enumerate(names) if name in keyed]
|
|
1709
|
+
if names and len(indices) < len(names):
|
|
1710
|
+
written[str(index)] = indices
|
|
1711
|
+
if written:
|
|
1712
|
+
_blending(document, animation)["weightsKeyed"] = written
|
|
1713
|
+
named += len(written)
|
|
1714
|
+
if named:
|
|
1715
|
+
_write_glb(out, document, binary)
|
|
1716
|
+
return named
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
def _unique_channels(out):
|
|
1720
|
+
"""One channel per target in each animation, as glTF requires: Blender's exporter gathers an action once
|
|
1721
|
+
for every unmuted NLA strip that plays it and merges them, so two tracks playing one action give its
|
|
1722
|
+
animation every channel twice. The first of each is kept (they are the same samples); samplers no channel
|
|
1723
|
+
uses go. Answers how many channels were taken out."""
|
|
1724
|
+
data, length, document, binary = _glb_parts(out)
|
|
1725
|
+
removed = 0
|
|
1726
|
+
for animation in document.get("animations", []):
|
|
1727
|
+
seen, kept = set(), []
|
|
1728
|
+
for channel in animation.get("channels", []):
|
|
1729
|
+
target = channel.get("target", {})
|
|
1730
|
+
key = (target.get("node"), target.get("path"))
|
|
1731
|
+
if key in seen:
|
|
1732
|
+
removed += 1
|
|
1733
|
+
continue
|
|
1734
|
+
seen.add(key)
|
|
1735
|
+
kept.append(channel)
|
|
1736
|
+
if len(kept) == len(animation.get("channels", [])):
|
|
1737
|
+
continue
|
|
1738
|
+
used = sorted({channel["sampler"] for channel in kept})
|
|
1739
|
+
index = {old: new for new, old in enumerate(used)}
|
|
1740
|
+
animation["samplers"] = [animation["samplers"][old] for old in used]
|
|
1741
|
+
for channel in kept:
|
|
1742
|
+
channel["sampler"] = index[channel["sampler"]]
|
|
1743
|
+
animation["channels"] = kept
|
|
1744
|
+
if removed:
|
|
1745
|
+
_write_glb(out, document, binary)
|
|
1746
|
+
return removed
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
def _align_rotation_signs(out, objects, scene):
|
|
1750
|
+
"""Write every rotation key of each action in the sign the action keys it (the rest rotation times
|
|
1751
|
+
the keyed basis quaternion): Blender blends quaternion components as keyed. A bone the action does not
|
|
1752
|
+
key, which moves only as Blender evaluates it (an IK chain, a bone not inheriting its parent's
|
|
1753
|
+
rotation), has no keyed sign: its keys are written in the sign nearest its rest, so two actions'
|
|
1754
|
+
poses of it blend the short way. A bone keyed in Euler angles or axis-angle is left as written
|
|
1755
|
+
(named by _euler_bones).
|
|
1756
|
+
|
|
1757
|
+
THE KEYED LENGTH: between keys far apart Blender interpolates a quaternion's four components, whose
|
|
1758
|
+
length falls below 1, and its NLA blends those unnormalised components. The channel stays unit
|
|
1759
|
+
quaternions, as glTF requires; where the keyed length departs from 1, its length at every key is
|
|
1760
|
+
written beside it as a float accessor, named in the animation's `STD_animation_blending.rotationLengths`
|
|
1761
|
+
({channel index: accessor index}), so a reader that layers actions rebuilds `length * q` as Blender
|
|
1762
|
+
blends it, and one that ignores it still reads valid rotations.
|
|
1763
|
+
|
|
1764
|
+
Answers how many keys were turned, how many shared outputs were split, how many channels carry their
|
|
1765
|
+
lengths and the bytes those add."""
|
|
1766
|
+
armatures = [o for o in objects if o.type == "ARMATURE" and o.pose is not None]
|
|
1767
|
+
if not armatures:
|
|
1768
|
+
return {"turned": 0, "split": 0, "lengths": 0, "lengthBytes": 0}
|
|
1769
|
+
data, length, document, binary = _glb_parts(out)
|
|
1770
|
+
rate = _key_rate(scene)
|
|
1771
|
+
nodes = document.get("nodes", [])
|
|
1772
|
+
accessors, views = document.get("accessors", []), document.get("bufferViews", [])
|
|
1773
|
+
|
|
1774
|
+
def floats(index):
|
|
1775
|
+
accessor = accessors[index]
|
|
1776
|
+
view = views[accessor["bufferView"]]
|
|
1777
|
+
start = view.get("byteOffset", 0) + accessor.get("byteOffset", 0)
|
|
1778
|
+
width = {"SCALAR": 1, "VEC3": 3, "VEC4": 4}[accessor["type"]]
|
|
1779
|
+
return start, list(struct.unpack_from("<%df" % (accessor["count"] * width), binary, start))
|
|
1780
|
+
|
|
1781
|
+
wanted = {} # output accessor -> list of (sampler dict, per-key flips)
|
|
1782
|
+
by_sampler = {}
|
|
1783
|
+
lengthed = [] # (animation, channel index, the keyed length at each key)
|
|
1784
|
+
for animation in document.get("animations", []):
|
|
1785
|
+
action = bpy.data.actions.get(animation.get("name", ""))
|
|
1786
|
+
if action is None:
|
|
1787
|
+
continue
|
|
1788
|
+
for channel_index, channel in enumerate(animation.get("channels", [])):
|
|
1789
|
+
target = channel.get("target", {})
|
|
1790
|
+
if target.get("path") != "rotation" or "node" not in target:
|
|
1791
|
+
continue
|
|
1792
|
+
bone = nodes[target["node"]].get("name", "")
|
|
1793
|
+
owner = next((a for a in armatures if a.pose.bones.get(bone) is not None), None)
|
|
1794
|
+
if owner is None:
|
|
1795
|
+
continue
|
|
1796
|
+
quaternion = owner.pose.bones[bone].rotation_mode == "QUATERNION"
|
|
1797
|
+
curves = _keyed_quaternion_curves(action, bone) if quaternion else None
|
|
1798
|
+
keyed_curves = curves is not None
|
|
1799
|
+
if curves is None:
|
|
1800
|
+
if _keys_bone_rotation(action, bone):
|
|
1801
|
+
continue
|
|
1802
|
+
curves = [None, None, None, None] # evaluated only: the sign nearest rest
|
|
1803
|
+
rest = nodes[target["node"]].get("rotation", [0.0, 0.0, 0.0, 1.0])
|
|
1804
|
+
rest = mathutils.Quaternion((rest[3], rest[0], rest[1], rest[2]))
|
|
1805
|
+
sampler = animation["samplers"][channel["sampler"]]
|
|
1806
|
+
_, times = floats(sampler["input"])
|
|
1807
|
+
_, values = floats(sampler["output"])
|
|
1808
|
+
flips, lengths = [], []
|
|
1809
|
+
slide = _action_slide(action)
|
|
1810
|
+
for k, seconds in enumerate(times):
|
|
1811
|
+
frame = seconds * rate + slide
|
|
1812
|
+
keyed = mathutils.Quaternion([curves[i].evaluate(frame) if curves[i] is not None else (1.0 if i == 0 else 0.0)
|
|
1813
|
+
for i in range(4)])
|
|
1814
|
+
lengths.append(keyed.magnitude)
|
|
1815
|
+
reference = rest @ keyed
|
|
1816
|
+
x, y, z, w = values[4 * k:4 * k + 4]
|
|
1817
|
+
flips.append(w * reference.w + x * reference.x + y * reference.y + z * reference.z < 0.0)
|
|
1818
|
+
if keyed_curves and max(abs(value - 1.0) for value in lengths) > 1e-4:
|
|
1819
|
+
lengthed.append((animation, channel_index, lengths))
|
|
1820
|
+
# One sampler serving two channels that need different signs cannot be written for both.
|
|
1821
|
+
known = by_sampler.get(id(sampler))
|
|
1822
|
+
if known is not None and known != flips:
|
|
1823
|
+
raise RuntimeError("the exporter shares one sampler between rotation channels that need different signs "
|
|
1824
|
+
"(%s, bone %s): the keyed signs cannot be kept" % (animation.get("name"), bone))
|
|
1825
|
+
if known is None:
|
|
1826
|
+
by_sampler[id(sampler)] = flips
|
|
1827
|
+
wanted.setdefault(sampler["output"], []).append((sampler, flips))
|
|
1828
|
+
turned = split = 0
|
|
1829
|
+
for output, uses in wanted.items():
|
|
1830
|
+
start, values = floats(output)
|
|
1831
|
+
# The first use's signs are written in place; a use that needs other signs gets its own copy.
|
|
1832
|
+
groups = {}
|
|
1833
|
+
for sampler, flips in uses:
|
|
1834
|
+
groups.setdefault(tuple(flips), []).append(sampler)
|
|
1835
|
+
for index, (flips, samplers) in enumerate(sorted(groups.items(), key=lambda item: -len(item[1]))):
|
|
1836
|
+
turned_values = list(values)
|
|
1837
|
+
for k, flip in enumerate(flips):
|
|
1838
|
+
if flip:
|
|
1839
|
+
for i in range(4):
|
|
1840
|
+
turned_values[4 * k + i] = -turned_values[4 * k + i]
|
|
1841
|
+
packed = struct.pack("<%df" % len(turned_values), *turned_values)
|
|
1842
|
+
if index == 0:
|
|
1843
|
+
binary[start:start + len(packed)] = packed
|
|
1844
|
+
else:
|
|
1845
|
+
binary.extend(b"\0" * ((4 - len(binary) % 4) % 4))
|
|
1846
|
+
views.append({"buffer": 0, "byteOffset": len(binary), "byteLength": len(packed)})
|
|
1847
|
+
binary.extend(packed)
|
|
1848
|
+
accessors.append(dict(accessors[output], bufferView=len(views) - 1, byteOffset=0))
|
|
1849
|
+
for sampler in samplers:
|
|
1850
|
+
sampler["output"] = len(accessors) - 1
|
|
1851
|
+
split += 1
|
|
1852
|
+
turned += sum(1 for flip in flips if flip) * len(samplers)
|
|
1853
|
+
added = 0
|
|
1854
|
+
# Each animation's lengths in one buffer view, an accessor per channel into it.
|
|
1855
|
+
by_animation = {}
|
|
1856
|
+
for animation, channel_index, lengths in lengthed:
|
|
1857
|
+
by_animation.setdefault(id(animation), (animation, []))[1].append((channel_index, lengths))
|
|
1858
|
+
for animation, entries in by_animation.values():
|
|
1859
|
+
binary.extend(b"\0" * ((4 - len(binary) % 4) % 4))
|
|
1860
|
+
start = len(binary)
|
|
1861
|
+
views.append({"buffer": 0, "byteOffset": start, "byteLength": 0})
|
|
1862
|
+
named = _blending(document, animation).setdefault("rotationLengths", {})
|
|
1863
|
+
for channel_index, lengths in entries:
|
|
1864
|
+
packed = struct.pack("<%df" % len(lengths), *lengths)
|
|
1865
|
+
accessors.append({"bufferView": len(views) - 1, "byteOffset": len(binary) - start, "componentType": 5126,
|
|
1866
|
+
"count": len(lengths), "type": "SCALAR"})
|
|
1867
|
+
binary.extend(packed)
|
|
1868
|
+
named[str(channel_index)] = len(accessors) - 1
|
|
1869
|
+
added += len(packed)
|
|
1870
|
+
views[-1]["byteLength"] = len(binary) - start
|
|
1871
|
+
if turned == 0 and not lengthed:
|
|
1872
|
+
return {"turned": 0, "split": 0, "lengths": 0, "lengthBytes": 0}
|
|
1873
|
+
if split == 0 and not lengthed:
|
|
1874
|
+
# Only key signs changed: the file keeps its JSON, its new binary is written in place.
|
|
1875
|
+
data[28 + length:28 + length + len(binary)] = binary
|
|
1876
|
+
with open(out, "wb") as handle:
|
|
1877
|
+
handle.write(bytes(data))
|
|
1878
|
+
else:
|
|
1879
|
+
document["buffers"][0]["byteLength"] = len(binary) + (4 - len(binary) % 4) % 4
|
|
1880
|
+
_write_glb(out, document, binary)
|
|
1881
|
+
return {"turned": turned, "split": split, "lengths": len(lengthed), "lengthBytes": added}
|
|
1882
|
+
|
|
1883
|
+
|
|
1884
|
+
def _pose_channels(objects):
|
|
1885
|
+
"""Every pose bone's channels of the armatures among `objects`, exactly as the document stores them: its
|
|
1886
|
+
rotation mode, location, quaternion, Euler angles, axis-angle and scale, each as written (an Euler past 180
|
|
1887
|
+
degrees, a quaternion with a negative w or off unit length, a negative scale), for _put_back."""
|
|
1888
|
+
return [(bone, bone.rotation_mode, tuple(bone.location), tuple(bone.rotation_quaternion), tuple(bone.rotation_euler),
|
|
1889
|
+
tuple(bone.rotation_axis_angle), tuple(bone.scale))
|
|
1890
|
+
for obj in objects if obj.type == "ARMATURE" and obj.pose is not None for bone in obj.pose.bones]
|
|
1891
|
+
|
|
1892
|
+
|
|
1893
|
+
def _put_back(held):
|
|
1894
|
+
"""Write each bone's channels back bit for bit: the mode first (changing it converts the rotation), then
|
|
1895
|
+
every channel as it was. Never through matrix_basis, which wraps angles, flips and normalises quaternions
|
|
1896
|
+
and folds a negative scale into the rotation."""
|
|
1897
|
+
for bone, mode, location, quaternion, euler, axis_angle, scale in held:
|
|
1898
|
+
if bone.rotation_mode != mode:
|
|
1899
|
+
bone.rotation_mode = mode
|
|
1900
|
+
bone.location, bone.rotation_quaternion, bone.rotation_euler = location, quaternion, euler
|
|
1901
|
+
bone.rotation_axis_angle, bone.scale = axis_angle, scale
|
|
1902
|
+
|
|
1903
|
+
|
|
1904
|
+
def _clear_pose(objects):
|
|
1905
|
+
"""Each pose bone at its rest, in whatever rotation mode it has: what clearing its basis gives, written
|
|
1906
|
+
channel by channel."""
|
|
1907
|
+
for obj in objects:
|
|
1908
|
+
if obj.type == "ARMATURE" and obj.pose is not None:
|
|
1909
|
+
for bone in obj.pose.bones:
|
|
1910
|
+
bone.location, bone.scale = (0.0, 0.0, 0.0), (1.0, 1.0, 1.0)
|
|
1911
|
+
bone.rotation_quaternion, bone.rotation_euler = (1.0, 0.0, 0.0, 0.0), (0.0, 0.0, 0.0)
|
|
1912
|
+
bone.rotation_axis_angle = (0.0, 0.0, 1.0, 0.0)
|
|
1913
|
+
|
|
1914
|
+
|
|
1915
|
+
def _at_rest(objects, run):
|
|
1916
|
+
"""Run with every pose bone of the armatures among `objects` at its rest, then put their channels back
|
|
1917
|
+
exactly. Blender's exporter clears the pose when it switches to an action, but not for the action already
|
|
1918
|
+
playing: without this, a bone that action does not key would carry whatever pose the file was saved with
|
|
1919
|
+
(RFC 0007 §1: each action is sampled from the rest pose)."""
|
|
1920
|
+
held = _pose_channels(objects)
|
|
1921
|
+
try:
|
|
1922
|
+
_clear_pose(objects)
|
|
1923
|
+
return run()
|
|
1924
|
+
finally:
|
|
1925
|
+
_put_back(held)
|
|
1926
|
+
|
|
1927
|
+
|
|
1928
|
+
_ANIMATION_FIELDS = ("action", "action_slot", "use_nla", "use_tweak_mode", "action_blend_type", "action_influence",
|
|
1929
|
+
"action_extrapolation", "last_slot_identifier")
|
|
1930
|
+
|
|
1931
|
+
|
|
1932
|
+
def _raw(value):
|
|
1933
|
+
"""A stored value as it is, comparable bit for bit (repr keeps -0.0 apart from 0.0)."""
|
|
1934
|
+
try:
|
|
1935
|
+
return tuple(value) if not isinstance(value, str) and hasattr(value, "__len__") else value
|
|
1936
|
+
except TypeError:
|
|
1937
|
+
return value
|
|
1938
|
+
|
|
1939
|
+
|
|
1940
|
+
def _same(a, b):
|
|
1941
|
+
return repr(a) == repr(b)
|
|
1942
|
+
|
|
1943
|
+
|
|
1944
|
+
def _animated_ids(scene):
|
|
1945
|
+
"""Every data-block of the scene an action may animate: its objects, their data, shape keys and materials,
|
|
1946
|
+
the scene and its world."""
|
|
1947
|
+
found, seen = [], set()
|
|
1948
|
+
|
|
1949
|
+
def add(idb):
|
|
1950
|
+
if idb is not None and isinstance(idb, bpy.types.ID) and idb.as_pointer() not in seen:
|
|
1951
|
+
seen.add(idb.as_pointer())
|
|
1952
|
+
found.append(idb)
|
|
1953
|
+
add(scene)
|
|
1954
|
+
add(scene.world)
|
|
1955
|
+
for obj in scene.objects:
|
|
1956
|
+
add(obj)
|
|
1957
|
+
data = obj.data
|
|
1958
|
+
add(data)
|
|
1959
|
+
add(getattr(data, "shape_keys", None))
|
|
1960
|
+
for slot in obj.material_slots:
|
|
1961
|
+
add(slot.material)
|
|
1962
|
+
return found
|
|
1963
|
+
|
|
1964
|
+
|
|
1965
|
+
def _paths_by_type():
|
|
1966
|
+
"""Every property path any action keys (its F-curves' data paths), by the type of data-block its slot animates."""
|
|
1967
|
+
from bpy_extras import anim_utils
|
|
1968
|
+
found = {}
|
|
1969
|
+
for action in bpy.data.actions:
|
|
1970
|
+
for slot in action.slots:
|
|
1971
|
+
bag = anim_utils.action_get_channelbag_for_slot(action, slot)
|
|
1972
|
+
if bag is not None:
|
|
1973
|
+
found.setdefault(slot.target_id_type, set()).update(c.data_path for c in bag.fcurves)
|
|
1974
|
+
return found
|
|
1975
|
+
|
|
1976
|
+
|
|
1977
|
+
def _owner(idb, path):
|
|
1978
|
+
"""The struct a data path ends on and the property's name there, and whether it is a custom property."""
|
|
1979
|
+
if path.endswith('"]'):
|
|
1980
|
+
head, _, key = path.rpartition('["')
|
|
1981
|
+
return (idb.path_resolve(head) if head else idb), key[:-2], True
|
|
1982
|
+
head, _, name = path.rpartition(".")
|
|
1983
|
+
return (idb.path_resolve(head) if head else idb), name, False
|
|
1984
|
+
|
|
1985
|
+
|
|
1986
|
+
def _keyed_values(scene):
|
|
1987
|
+
"""The raw value of every property an action keys on the scene's data-blocks, the active action's or not:
|
|
1988
|
+
switching actions writes what each evaluates, and nothing else writes them back."""
|
|
1989
|
+
held = []
|
|
1990
|
+
paths = _paths_by_type()
|
|
1991
|
+
for idb in _animated_ids(scene):
|
|
1992
|
+
for path in paths.get(idb.id_type, ()):
|
|
1993
|
+
try:
|
|
1994
|
+
owner, name, custom = _owner(idb, path)
|
|
1995
|
+
value = owner[name] if custom else getattr(owner, name)
|
|
1996
|
+
except (ValueError, KeyError, AttributeError, TypeError):
|
|
1997
|
+
continue
|
|
1998
|
+
if isinstance(value, (bpy.types.ID, bpy.types.bpy_struct)):
|
|
1999
|
+
continue
|
|
2000
|
+
held.append((owner, name, custom, _raw(value)))
|
|
2001
|
+
return held
|
|
2002
|
+
|
|
2003
|
+
|
|
2004
|
+
def _put_back_values(held):
|
|
2005
|
+
for owner, name, custom, value in held:
|
|
2006
|
+
try:
|
|
2007
|
+
now = _raw(owner[name] if custom else getattr(owner, name))
|
|
2008
|
+
if _same(now, value):
|
|
2009
|
+
continue
|
|
2010
|
+
if custom:
|
|
2011
|
+
owner[name] = value
|
|
2012
|
+
else:
|
|
2013
|
+
setattr(owner, name, value)
|
|
2014
|
+
except (ValueError, KeyError, AttributeError, TypeError):
|
|
2015
|
+
continue
|
|
2016
|
+
|
|
2017
|
+
|
|
2018
|
+
def _kept_document(scene, run):
|
|
2019
|
+
"""AN EXPORT NEVER CHANGES THE DOCUMENT. Run, then put back everything an export (Blender's exporter or this
|
|
2020
|
+
script's passes) may touch, exactly as it was, raw:
|
|
2021
|
+
- every object's transform channels (its rotation mode, location, quaternion, Euler, axis-angle, scale and
|
|
2022
|
+
their deltas), never through a matrix;
|
|
2023
|
+
- every armature's pose channels;
|
|
2024
|
+
- every shape key's value;
|
|
2025
|
+
- every property any action keys, the active action's or another's;
|
|
2026
|
+
- the animation data of every data-block that has it (objects, their data, shape keys, materials, the
|
|
2027
|
+
scene, the world): its action and slot, the NLA's flags, each track's mute and solo;
|
|
2028
|
+
- every constraint's mute, and the scene's frame.
|
|
2029
|
+
Only what differs is written back, and the scene is evaluated again at its own frame."""
|
|
2030
|
+
objects = list(scene.objects)
|
|
2031
|
+
pose = _pose_channels(objects)
|
|
2032
|
+
transforms = [(obj, obj.rotation_mode, [(name, _raw(getattr(obj, name))) for name in _OBJECT_CHANNELS]) for obj in objects]
|
|
2033
|
+
shapes = [(block, _raw(block.value)) for idb in _animated_ids(scene) if isinstance(idb, bpy.types.Key)
|
|
2034
|
+
for block in idb.key_blocks]
|
|
2035
|
+
keyed = _keyed_values(scene)
|
|
2036
|
+
animation = []
|
|
2037
|
+
for idb in _animated_ids(scene):
|
|
2038
|
+
data = getattr(idb, "animation_data", None)
|
|
2039
|
+
if data is None:
|
|
2040
|
+
continue
|
|
2041
|
+
fields = {name: getattr(data, name) for name in _ANIMATION_FIELDS}
|
|
2042
|
+
animation.append((data, fields, [(track, track.mute, track.is_solo) for track in data.nla_tracks]))
|
|
2043
|
+
mutes = [(c, c.mute) for obj in objects for c in obj.constraints]
|
|
2044
|
+
mutes += [(c, c.mute) for obj in objects if obj.type == "ARMATURE" and obj.pose is not None
|
|
2045
|
+
for bone in obj.pose.bones for c in bone.constraints]
|
|
2046
|
+
frame, subframe = scene.frame_current, scene.frame_subframe
|
|
2047
|
+
try:
|
|
2048
|
+
return run()
|
|
2049
|
+
finally:
|
|
2050
|
+
for constraint, mute in mutes:
|
|
2051
|
+
if constraint.mute != mute:
|
|
2052
|
+
constraint.mute = mute
|
|
2053
|
+
for data, fields, tracks in animation:
|
|
2054
|
+
tweak = fields.pop("use_tweak_mode")
|
|
2055
|
+
if data.use_tweak_mode and not tweak:
|
|
2056
|
+
data.use_tweak_mode = False
|
|
2057
|
+
for name in ("action", "action_slot", "last_slot_identifier", "use_nla", "action_blend_type",
|
|
2058
|
+
"action_influence", "action_extrapolation"):
|
|
2059
|
+
if getattr(data, name) != fields[name] and not data.is_property_readonly(name):
|
|
2060
|
+
try:
|
|
2061
|
+
setattr(data, name, fields[name])
|
|
2062
|
+
except (AttributeError, TypeError, RuntimeError): # (a slot the restored action does not hold)
|
|
2063
|
+
pass
|
|
2064
|
+
for track, mute, solo in tracks:
|
|
2065
|
+
if track.mute != mute:
|
|
2066
|
+
track.mute = mute
|
|
2067
|
+
if track.is_solo != solo:
|
|
2068
|
+
track.is_solo = solo
|
|
2069
|
+
if tweak and not data.use_tweak_mode:
|
|
2070
|
+
try:
|
|
2071
|
+
data.use_tweak_mode = True
|
|
2072
|
+
except Exception: # noqa: BLE001 (no strip to tweak any more)
|
|
2073
|
+
pass
|
|
2074
|
+
_put_back(pose)
|
|
2075
|
+
for obj, mode, channels in transforms:
|
|
2076
|
+
if obj.rotation_mode != mode:
|
|
2077
|
+
obj.rotation_mode = mode
|
|
2078
|
+
for name, value in channels:
|
|
2079
|
+
if not _same(_raw(getattr(obj, name)), value):
|
|
2080
|
+
setattr(obj, name, value)
|
|
2081
|
+
for block, value in shapes:
|
|
2082
|
+
if not _same(_raw(block.value), value):
|
|
2083
|
+
block.value = value
|
|
2084
|
+
_put_back_values(keyed)
|
|
2085
|
+
# Evaluated again at its own frame, so the poses the document holds (and saves) are its own again.
|
|
2086
|
+
scene.frame_set(frame, subframe=subframe)
|
|
2087
|
+
|
|
2088
|
+
|
|
2089
|
+
def _muted(runtime, run):
|
|
2090
|
+
"""Run with the runtime constraints muted, so the sampled clips do not bake them."""
|
|
2091
|
+
held = [(c, c.mute) for constraints in runtime.values() for c in constraints]
|
|
2092
|
+
try:
|
|
2093
|
+
for constraint, _ in held:
|
|
2094
|
+
constraint.mute = True
|
|
2095
|
+
return run()
|
|
2096
|
+
finally:
|
|
2097
|
+
for constraint, was in held:
|
|
2098
|
+
constraint.mute = was
|
|
2099
|
+
|
|
2100
|
+
|
|
2101
|
+
def _display_key(scene):
|
|
2102
|
+
"""What the display LUT depends on: the view transform, look, display device and curve, and the LUT's
|
|
2103
|
+
own format. Two scenes with the same key share one file."""
|
|
2104
|
+
view, display = scene.view_settings, scene.display_settings
|
|
2105
|
+
curve = []
|
|
2106
|
+
if view.use_curve_mapping:
|
|
2107
|
+
mapping = view.curve_mapping
|
|
2108
|
+
curve = [[round(p.location[0], 6), round(p.location[1], 6)] for c in mapping.curves for p in c.points]
|
|
2109
|
+
ocio = getattr(bpy.app, "ocio", None)
|
|
2110
|
+
basis = json.dumps({"format": LUT_FORMAT, "size": LUT_SIZE, "domain": LUT_DOMAIN, "view": view.view_transform,
|
|
2111
|
+
"look": view.look, "device": display.display_device, "curve": curve,
|
|
2112
|
+
"blender": bpy.app.version_string, "ocio": getattr(ocio, "version_string", None)}, sort_keys=True)
|
|
2113
|
+
return hashlib.sha256(basis.encode("utf-8")).hexdigest()[:16]
|
|
2114
|
+
|
|
2115
|
+
|
|
2116
|
+
def _display_dir(out):
|
|
2117
|
+
"""`.volter/export/display`, beside the kinds' folders."""
|
|
2118
|
+
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(out))), "display")
|
|
2119
|
+
|
|
2120
|
+
|
|
2121
|
+
def _display_description(key):
|
|
2122
|
+
lo, hi = LUT_DOMAIN
|
|
2123
|
+
return {"file": "../display/%s.cube" % key, "key": key, "size": LUT_SIZE,
|
|
2124
|
+
"shaper": {"type": "log2-offset", "min": lo, "max": hi},
|
|
2125
|
+
"input": "scene-linear Rec.709", "output": "display-encoded"}
|
|
2126
|
+
|
|
2127
|
+
|
|
2128
|
+
def _display_lut_for(scene, out, luts):
|
|
2129
|
+
"""The display LUT for this scene's view settings: baked into `.volter/export/display/<key>.cube`
|
|
2130
|
+
only when no file for them exists (`luts`, the keys the caller holds intact, each with its recorded
|
|
2131
|
+
SHA-256, when it holds the project's
|
|
2132
|
+
files; else the folder beside `out`). Answers the baked file (None when it stood) and the
|
|
2133
|
+
description."""
|
|
2134
|
+
key = _display_key(scene)
|
|
2135
|
+
cube = os.path.join(_display_dir(out), key + ".cube")
|
|
2136
|
+
exists = (key in luts) if luts is not None else os.path.isfile(cube)
|
|
2137
|
+
if exists:
|
|
2138
|
+
return None, _display_description(key)
|
|
2139
|
+
_display_lut(scene, cube)
|
|
2140
|
+
return cube, _display_description(key)
|
|
2141
|
+
|
|
2142
|
+
|
|
2143
|
+
def _display_lut(scene, cube):
|
|
2144
|
+
"""Bake the scene's display transform into a 3D LUT at `cube`.
|
|
2145
|
+
|
|
2146
|
+
The lattice is LUT_SIZE^3 scene-linear Rec.709 colours, 2^(lo + t (hi - lo)) - 2^lo per channel
|
|
2147
|
+
(0 at t = 0), red fastest (the Cube format's order). Blender draws them through the scene's view transform, look and
|
|
2148
|
+
display device with `save_render`, at exposure 0 and gamma 1 (carried as numbers, applied by a
|
|
2149
|
+
host), into a 16-bit PNG read back as raw values. Pure Python, so the editor's Blender needs no
|
|
2150
|
+
numpy. The scene is left as it was."""
|
|
2151
|
+
import array
|
|
2152
|
+
import tempfile
|
|
2153
|
+
|
|
2154
|
+
n, (lo, hi) = LUT_SIZE, LUT_DOMAIN
|
|
2155
|
+
axis = [2.0 ** (lo + (hi - lo) * i / (n - 1)) - 2.0 ** lo for i in range(n)]
|
|
2156
|
+
width, height = n * n, n
|
|
2157
|
+
pixels = array.array("f", bytes(4 * 4 * width * height))
|
|
2158
|
+
at = 0
|
|
2159
|
+
for b in range(n):
|
|
2160
|
+
for g in range(n):
|
|
2161
|
+
for r in range(n):
|
|
2162
|
+
pixels[at], pixels[at + 1], pixels[at + 2], pixels[at + 3] = axis[r], axis[g], axis[b], 1.0
|
|
2163
|
+
at += 4
|
|
2164
|
+
render, view = scene.render, scene.view_settings
|
|
2165
|
+
settings = render.image_settings
|
|
2166
|
+
kept = {"format": settings.file_format, "mode": settings.color_mode, "depth": settings.color_depth,
|
|
2167
|
+
"management": settings.color_management, "dither": render.dither_intensity,
|
|
2168
|
+
"exposure": view.exposure, "gamma": view.gamma}
|
|
2169
|
+
lattice = written = None
|
|
2170
|
+
path = os.path.join(tempfile.gettempdir(), "volter-display-lut-%d.png" % os.getpid())
|
|
2171
|
+
try:
|
|
2172
|
+
settings.file_format, settings.color_mode, settings.color_depth = "PNG", "RGB", "16"
|
|
2173
|
+
settings.color_management = "FOLLOW_SCENE"
|
|
2174
|
+
render.dither_intensity = 0.0
|
|
2175
|
+
view.exposure, view.gamma = 0.0, 1.0
|
|
2176
|
+
lattice = bpy.data.images.new("__volter_display_lut", width, height, float_buffer=True, alpha=False)
|
|
2177
|
+
lattice.colorspace_settings.name = "Linear Rec.709"
|
|
2178
|
+
lattice.pixels.foreach_set(pixels)
|
|
2179
|
+
lattice.save_render(path, scene=scene)
|
|
2180
|
+
written = bpy.data.images.load(path)
|
|
2181
|
+
written.colorspace_settings.name = "Non-Color"
|
|
2182
|
+
shown = array.array("f", bytes(4 * len(written.pixels)))
|
|
2183
|
+
written.pixels.foreach_get(shown)
|
|
2184
|
+
finally:
|
|
2185
|
+
settings.file_format, settings.color_mode, settings.color_depth = kept["format"], kept["mode"], kept["depth"]
|
|
2186
|
+
settings.color_management = kept["management"]
|
|
2187
|
+
render.dither_intensity = kept["dither"]
|
|
2188
|
+
view.exposure, view.gamma = kept["exposure"], kept["gamma"]
|
|
2189
|
+
for image in (lattice, written):
|
|
2190
|
+
if image is not None:
|
|
2191
|
+
bpy.data.images.remove(image)
|
|
2192
|
+
if os.path.exists(path):
|
|
2193
|
+
os.remove(path)
|
|
2194
|
+
channels = len(shown) // (width * height)
|
|
2195
|
+
lines = ["TITLE \"%s %s on %s\"" % (view.view_transform, view.look, scene.display_settings.display_device),
|
|
2196
|
+
"# Input: scene-linear Rec.709 encoded as (log2(x + 2^%g) - %g) / %g per channel; output: display values." % (lo, lo, hi - lo),
|
|
2197
|
+
"LUT_3D_SIZE %d" % n, "DOMAIN_MIN 0 0 0", "DOMAIN_MAX 1 1 1"]
|
|
2198
|
+
for index in range(width * height):
|
|
2199
|
+
base = index * channels
|
|
2200
|
+
lines.append("%.6f %.6f %.6f" % (shown[base], shown[base + 1], shown[base + 2]))
|
|
2201
|
+
os.makedirs(os.path.dirname(cube), exist_ok=True)
|
|
2202
|
+
with open(cube, "w", encoding="utf-8", newline="\n") as handle:
|
|
2203
|
+
handle.write("\n".join(lines) + "\n")
|
|
2204
|
+
|
|
2205
|
+
|
|
2206
|
+
def _authoring(obj):
|
|
2207
|
+
return any(c.name in AUTHORING for c in obj.users_collection)
|
|
2208
|
+
|
|
2209
|
+
|
|
2210
|
+
def _authoring_collections(scene):
|
|
2211
|
+
found = set()
|
|
2212
|
+
|
|
2213
|
+
def walk(collection, inside):
|
|
2214
|
+
here = inside or collection.name in AUTHORING
|
|
2215
|
+
if here:
|
|
2216
|
+
found.add(collection.name_full)
|
|
2217
|
+
for child in collection.children:
|
|
2218
|
+
walk(child, here)
|
|
2219
|
+
|
|
2220
|
+
walk(scene.collection, False)
|
|
2221
|
+
return found
|
|
2222
|
+
|
|
2223
|
+
|
|
2224
|
+
def _fcurves(idblock):
|
|
2225
|
+
data = getattr(idblock, "animation_data", None)
|
|
2226
|
+
if data is None or data.action is None:
|
|
2227
|
+
return []
|
|
2228
|
+
action = data.action
|
|
2229
|
+
from bpy_extras import anim_utils
|
|
2230
|
+
bag = anim_utils.action_get_channelbag_for_slot(action, data.action_slot)
|
|
2231
|
+
return list(bag.fcurves) if bag is not None else []
|
|
2232
|
+
|
|
2233
|
+
|
|
2234
|
+
def _manifest_path(out):
|
|
2235
|
+
return os.path.splitext(out)[0] + ".json"
|
|
2236
|
+
|
|
2237
|
+
|
|
2238
|
+
def _current(out, blend_sha, version, settings, previous):
|
|
2239
|
+
"""The earlier export's manifest when it still stands: the same `.blend`, exporter and settings,
|
|
2240
|
+
and its file intact. `previous` is that manifest and the file's SHA-256 as a caller holding the
|
|
2241
|
+
files reads them (the editor, whose project files are not in Blender's filesystem); without it,
|
|
2242
|
+
both are read beside `out`."""
|
|
2243
|
+
if previous is not None:
|
|
2244
|
+
manifest, file_sha = previous.get("manifest"), previous.get("sha256")
|
|
2245
|
+
if not isinstance(manifest, dict):
|
|
2246
|
+
return None
|
|
2247
|
+
else:
|
|
2248
|
+
path = _manifest_path(out)
|
|
2249
|
+
if not (os.path.isfile(path) and os.path.isfile(out)):
|
|
2250
|
+
return None
|
|
2251
|
+
try:
|
|
2252
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
2253
|
+
manifest = json.load(handle)
|
|
2254
|
+
except (OSError, ValueError):
|
|
2255
|
+
return None
|
|
2256
|
+
file_sha = _sha256(out)
|
|
2257
|
+
if (manifest.get("format") != FORMAT or manifest.get("blendSha256") != blend_sha
|
|
2258
|
+
or manifest.get("exporter") != version or manifest.get("settings") != settings):
|
|
2259
|
+
return None
|
|
2260
|
+
if manifest.get("sha256") != file_sha:
|
|
2261
|
+
return None
|
|
2262
|
+
return manifest
|
|
2263
|
+
|
|
2264
|
+
|
|
2265
|
+
def _lut_sha(out, key, luts):
|
|
2266
|
+
"""The display LUT's SHA-256: of its file beside the export, or, when the caller holds the project's files
|
|
2267
|
+
(the editor), as the caller recorded it (`luts`: {key: SHA-256})."""
|
|
2268
|
+
path = os.path.join(_display_dir(out), key + ".cube")
|
|
2269
|
+
if os.path.isfile(path):
|
|
2270
|
+
return _sha256(path)
|
|
2271
|
+
return luts.get(key) if isinstance(luts, dict) else None
|
|
2272
|
+
|
|
2273
|
+
|
|
2274
|
+
def _lut_missing(manifest, out, luts):
|
|
2275
|
+
"""The display LUT a current export names, when its file is gone (it is baked again alone)."""
|
|
2276
|
+
key = (manifest.get("display") or {}).get("lut")
|
|
2277
|
+
if not key:
|
|
2278
|
+
return None
|
|
2279
|
+
if luts is not None:
|
|
2280
|
+
return None if key in luts else key
|
|
2281
|
+
path = os.path.join(_display_dir(out), key + ".cube")
|
|
2282
|
+
recorded = (manifest.get("display") or {}).get("sha256")
|
|
2283
|
+
# Gone, or not the file this export wrote (damaged): baked again.
|
|
2284
|
+
if not os.path.isfile(path) or (recorded and _sha256(path) != recorded):
|
|
2285
|
+
return key
|
|
2286
|
+
return None
|
|
2287
|
+
|
|
2288
|
+
|
|
2289
|
+
def _color_inputs(node):
|
|
2290
|
+
"""A colour Mix factor and its two colour inputs for ShaderNodeMix and ShaderNodeMixRGB."""
|
|
2291
|
+
if node.bl_idname == "ShaderNodeMix" and node.data_type == "RGBA":
|
|
2292
|
+
colours = [i for i in node.inputs if i.type == "RGBA" and i.enabled]
|
|
2293
|
+
factor = next((i for i in node.inputs if i.name == "Factor" and i.enabled), None)
|
|
2294
|
+
return (factor, colours[0], colours[1]) if len(colours) == 2 else None
|
|
2295
|
+
if node.bl_idname == "ShaderNodeMixRGB":
|
|
2296
|
+
return node.inputs["Fac"], node.inputs["Color1"], node.inputs["Color2"]
|
|
2297
|
+
return None
|
|
2298
|
+
|
|
2299
|
+
|
|
2300
|
+
def _multiply(node):
|
|
2301
|
+
return _color_inputs(node) is not None and node.blend_type == "MULTIPLY"
|
|
2302
|
+
|
|
2303
|
+
|
|
2304
|
+
def _from(socket):
|
|
2305
|
+
return socket.links[0].from_node if socket.is_linked else None
|
|
2306
|
+
|
|
2307
|
+
|
|
2308
|
+
def _occluded_base(material):
|
|
2309
|
+
"""Recover Base Color multiplied by a Color Attribute through a Mix node when the glTF exporter omits that factor. Return the colour or texture, attribute name and multiply strength."""
|
|
2310
|
+
# (the exporter also hands this hook node trees that are no Material, e.g. Blender 5's InlineShaderNodes)
|
|
2311
|
+
tree = getattr(material, "node_tree", None)
|
|
2312
|
+
if tree is None:
|
|
2313
|
+
return None
|
|
2314
|
+
bsdf = next((n for n in tree.nodes if n.type == "BSDF_PRINCIPLED"), None)
|
|
2315
|
+
mix = _from(bsdf.inputs["Base Color"]) if bsdf is not None else None
|
|
2316
|
+
if mix is None or not _multiply(mix):
|
|
2317
|
+
return None
|
|
2318
|
+
strength, first, second = _color_inputs(mix)
|
|
2319
|
+
for colour, other in ((first, second), (second, first)):
|
|
2320
|
+
attribute = _from(colour)
|
|
2321
|
+
if attribute is None or attribute.bl_idname not in ("ShaderNodeVertexColor", "ShaderNodeAttribute"):
|
|
2322
|
+
continue
|
|
2323
|
+
name = attribute.layer_name if attribute.bl_idname == "ShaderNodeVertexColor" else attribute.attribute_name
|
|
2324
|
+
level = float(strength.default_value) if strength is not None and not strength.is_linked else 1.0
|
|
2325
|
+
source = _from(other)
|
|
2326
|
+
if source is None:
|
|
2327
|
+
return list(other.default_value), name, level
|
|
2328
|
+
if source.bl_idname == "ShaderNodeTexImage":
|
|
2329
|
+
return None, name, level
|
|
2330
|
+
if _multiply(source):
|
|
2331
|
+
factor, a, b = _color_inputs(source)
|
|
2332
|
+
if factor is not None and not factor.is_linked and abs(float(factor.default_value) - 1.0) < 1e-6:
|
|
2333
|
+
for texture, constant in ((a, b), (b, a)):
|
|
2334
|
+
if (_from(texture) is not None and _from(texture).bl_idname == "ShaderNodeTexImage"
|
|
2335
|
+
and not constant.is_linked):
|
|
2336
|
+
return list(constant.default_value), name, level
|
|
2337
|
+
return None
|
|
2338
|
+
return None
|
|
2339
|
+
|
|
2340
|
+
|
|
2341
|
+
class _Hooks:
|
|
2342
|
+
"""The exporter's own extension hooks, for one call."""
|
|
2343
|
+
is_critical = True
|
|
2344
|
+
|
|
2345
|
+
def __init__(self, nodes, scene_extras, visibility, collections=None, runtime=None, scene_extensions=None, volumes=None):
|
|
2346
|
+
self.nodes = nodes # Blender object name -> {"name": ..., "extras": {...}, "tracks": {...}}
|
|
2347
|
+
self.scene_extras = scene_extras # what the scene's extras gain (a level's timeline, its key rate)
|
|
2348
|
+
self.scene_extensions = scene_extensions or {} # the scene's std extensions (display, environment, render)
|
|
2349
|
+
self.volumes = volumes or {} # Blender object name -> its STD_volume (a bounded volume's domain)
|
|
2350
|
+
self.visibility = visibility # Blender object name -> render-visible
|
|
2351
|
+
self.collections = collections or {} # Blender object name -> the collections holding it
|
|
2352
|
+
self.runtime = runtime or {} # (armature, bone) -> its runtime constraints' data
|
|
2353
|
+
self.extension = _extension_class()
|
|
2354
|
+
self.used_visibility = 0
|
|
2355
|
+
self.kept_moved = {} # action -> bones kept because they leave rest without a key
|
|
2356
|
+
self.occluded = {} # material name -> its base colour restored from behind a Color Attribute multiply
|
|
2357
|
+
self.slide_mismatch = []
|
|
2358
|
+
self._rests = {}
|
|
2359
|
+
self._scene_bones = {}
|
|
2360
|
+
|
|
2361
|
+
def _extend(self, owner, name, data):
|
|
2362
|
+
"""`data` as the owner's std extension `name` (listed in `extensionsUsed`, never required)."""
|
|
2363
|
+
if owner.extensions is None:
|
|
2364
|
+
owner.extensions = {}
|
|
2365
|
+
owner.extensions[name] = self.extension(name=name, extension=data, required=False)
|
|
2366
|
+
|
|
2367
|
+
def animation_action_hook(self, gltf2_animation, blender_object, action_data, export_settings):
|
|
2368
|
+
# Use the actual action before the add-on merges/renames animations. This also runs for
|
|
2369
|
+
# placement-action sidecars, whose export_extras=False suppresses user properties only.
|
|
2370
|
+
_animation_keyed_bones(gltf2_animation, _action_keyed_bones(action_data.action))
|
|
2371
|
+
|
|
2372
|
+
def gather_gltf_hook(self, active_scene, scenes, animations, export_settings):
|
|
2373
|
+
# SCENE mode has no single action and bypasses animation_action_hook. Its one animation
|
|
2374
|
+
# carries the union of the exported rigs' authored actions, not every baked joint.
|
|
2375
|
+
scene_mode = export_settings.get("gltf_animation_mode") == "SCENE"
|
|
2376
|
+
for animation in animations:
|
|
2377
|
+
namespace = (animation.extras or {}).get("std")
|
|
2378
|
+
if scene_mode or not isinstance(namespace, dict) or "keyedBones" not in namespace:
|
|
2379
|
+
_animation_keyed_bones(animation, self._scene_bones.get(animation.name, ()))
|
|
2380
|
+
|
|
2381
|
+
def gather_node_hook(self, gltf2_node, blender_object, export_settings):
|
|
2382
|
+
if blender_object is None:
|
|
2383
|
+
return
|
|
2384
|
+
name = blender_object.name
|
|
2385
|
+
entry = self.nodes.get(name)
|
|
2386
|
+
if entry is not None:
|
|
2387
|
+
gltf2_node.name = entry["name"]
|
|
2388
|
+
extras = dict(gltf2_node.extras or {})
|
|
2389
|
+
extras.update(entry["extras"])
|
|
2390
|
+
gltf2_node.extras = extras
|
|
2391
|
+
if entry.get("tracks"):
|
|
2392
|
+
self._extend(gltf2_node, "STD_animation_tracks", entry["tracks"])
|
|
2393
|
+
held = self.collections.get(name)
|
|
2394
|
+
if held and entry is None:
|
|
2395
|
+
extras = dict(gltf2_node.extras or {})
|
|
2396
|
+
extras["collections"] = held
|
|
2397
|
+
gltf2_node.extras = extras
|
|
2398
|
+
if getattr(blender_object, "type", None) == "ARMATURE" and entry is None:
|
|
2399
|
+
# ITS CLIP AND ITS NLA (`STD_animation_tracks`): the action the file has the armature play (so a
|
|
2400
|
+
# component shows the pose Blender shows), the frame each clip's time 0 is at, its tracks and settings.
|
|
2401
|
+
tracks = _tracks(blender_object)
|
|
2402
|
+
slides = _action_slides()
|
|
2403
|
+
data = dict(_nla_settings(blender_object))
|
|
2404
|
+
if blender_object.animation_data is not None and blender_object.animation_data.action is not None:
|
|
2405
|
+
data["clip"] = blender_object.animation_data.action.name
|
|
2406
|
+
if slides:
|
|
2407
|
+
data["clipOffsets"] = slides
|
|
2408
|
+
if tracks:
|
|
2409
|
+
data["tracks"] = tracks
|
|
2410
|
+
if data:
|
|
2411
|
+
self._extend(gltf2_node, "STD_animation_tracks", data)
|
|
2412
|
+
flags = {key: _pick(blender_object, "visible_" + name) for key, name in RAY_VISIBILITY}
|
|
2413
|
+
if any(value is False for value in flags.values()):
|
|
2414
|
+
# WHAT THE OBJECT IS SEEN BY in Blender's render (its ray visibility): an object kept out of
|
|
2415
|
+
# shadows (scattered pebbles, flora) must not cast one in a host either.
|
|
2416
|
+
self._extend(gltf2_node, "STD_ray_visibility", {key: value for key, value in flags.items() if value is not None})
|
|
2417
|
+
if name in self.volumes:
|
|
2418
|
+
self._extend(gltf2_node, "STD_volume", self.volumes[name])
|
|
2419
|
+
if getattr(blender_object, "type", None) == "LIGHT":
|
|
2420
|
+
# WHAT KHR_lights_punctual CAN'T SAY of the light: its units, its shadow and softness, and an AREA
|
|
2421
|
+
# light, which glTF has no light for at all (rule G: a host that can draw it gets it).
|
|
2422
|
+
self._extend(gltf2_node, "STD_light", _light(blender_object.data))
|
|
2423
|
+
if self.visibility.get(name, True) is False:
|
|
2424
|
+
if gltf2_node.extensions is None:
|
|
2425
|
+
gltf2_node.extensions = {}
|
|
2426
|
+
gltf2_node.extensions["KHR_node_visibility"] = self.extension(
|
|
2427
|
+
name="KHR_node_visibility", extension={"visible": False}, required=False)
|
|
2428
|
+
self.used_visibility += 1
|
|
2429
|
+
|
|
2430
|
+
def animation_channels_armature_sampled(self, channels, blender_object, blender_action, slot_identifier, cache_key,
|
|
2431
|
+
export_settings):
|
|
2432
|
+
# AN ACTION CARRIES WHAT IT MOVES: a bone property stays when the action keys it, or when what
|
|
2433
|
+
# Blender evaluated with this action alone (constraints live) leaves the rest pose; channels on
|
|
2434
|
+
# the armature node itself and morph weights always stay.
|
|
2435
|
+
if blender_action is None or blender_object is None or blender_object.type != "ARMATURE":
|
|
2436
|
+
return
|
|
2437
|
+
keyed = _keyed_bone_channels(blender_action)
|
|
2438
|
+
rest = self._rests.get(blender_object.name)
|
|
2439
|
+
if rest is None:
|
|
2440
|
+
rest = self._rests[blender_object.name] = _rest_channels(blender_object)
|
|
2441
|
+
bones = set(rest)
|
|
2442
|
+
kept = []
|
|
2443
|
+
for channel in channels:
|
|
2444
|
+
target = channel.target
|
|
2445
|
+
node = target.node if target is not None else None
|
|
2446
|
+
name = node.name if node is not None else None
|
|
2447
|
+
if name not in bones or target.path not in ("translation", "rotation", "scale"):
|
|
2448
|
+
kept.append(channel)
|
|
2449
|
+
continue
|
|
2450
|
+
if (name, target.path) in keyed:
|
|
2451
|
+
kept.append(channel)
|
|
2452
|
+
continue
|
|
2453
|
+
values = _sampled_values(channel)
|
|
2454
|
+
if values is not None and _leaves_rest(target.path, values, rest[name][target.path]):
|
|
2455
|
+
kept.append(channel)
|
|
2456
|
+
self.kept_moved.setdefault(blender_action.name, set()).add(name)
|
|
2457
|
+
channels[:] = kept
|
|
2458
|
+
# The mapping from clip time to action frame the sign pass and the sampler use: the exporter's.
|
|
2459
|
+
vtree = export_settings.get("vtree")
|
|
2460
|
+
uuid = next((u for u, n in vtree.nodes.items() if getattr(n, "blender_object", None) == blender_object), None) if vtree else None
|
|
2461
|
+
exported = export_settings.get("slide", {}).get(uuid, {}).get(blender_action.name, 0.0) if uuid is not None else 0.0
|
|
2462
|
+
if abs(float(exported) - _action_slide(blender_action)) > 1e-6:
|
|
2463
|
+
self.slide_mismatch.append("%s: the exporter slid it by %s, the artifact says %s" % (blender_action.name, exported, _action_slide(blender_action)))
|
|
2464
|
+
|
|
2465
|
+
def gather_joint_hook(self, gltf2_node, blender_bone, export_settings):
|
|
2466
|
+
constraints = self.runtime.get((blender_bone.id_data.name, blender_bone.name))
|
|
2467
|
+
if constraints:
|
|
2468
|
+
extras = dict(gltf2_node.extras or {})
|
|
2469
|
+
extras["constraints"] = constraints
|
|
2470
|
+
gltf2_node.extras = extras
|
|
2471
|
+
|
|
2472
|
+
def gather_material_hook(self, gltf2_material, blender_material, export_settings):
|
|
2473
|
+
# WHICH FACES CAST SHADOWS (RFC 0007 §2, `STD_material_shadow`): both, unless the material culls its back
|
|
2474
|
+
# faces for shadows; then only the front.
|
|
2475
|
+
if isinstance(blender_material, bpy.types.Material) and blender_material.use_backface_culling_shadow:
|
|
2476
|
+
self._extend(gltf2_material, "STD_material_shadow", {"side": "front"})
|
|
2477
|
+
# Restore a base colour behind a Color Attribute multiply (`_occluded_base`) when the
|
|
2478
|
+
# exporter's factor is absent or white, preserving its alpha for blended materials.
|
|
2479
|
+
occluded = _occluded_base(blender_material)
|
|
2480
|
+
pbr = gltf2_material.pbr_metallic_roughness
|
|
2481
|
+
sent = None if pbr is None else pbr.base_color_factor
|
|
2482
|
+
if (occluded is not None and occluded[0] is not None and pbr is not None
|
|
2483
|
+
and (sent is None or [float(v) for v in sent[:3]] == [1.0, 1.0, 1.0])):
|
|
2484
|
+
factor = [float(v) for v in occluded[0]][:3] + [float(sent[3]) if sent is not None and len(sent) > 3 else 1.0]
|
|
2485
|
+
if factor != [float(v) for v in (sent or [1.0, 1.0, 1.0, 1.0])]:
|
|
2486
|
+
pbr.base_color_factor = factor
|
|
2487
|
+
self.occluded[getattr(blender_material, "name", gltf2_material.name)] = {"factor": [round(v, 6) for v in factor],
|
|
2488
|
+
"attribute": occluded[1], "strength": occluded[2]}
|
|
2489
|
+
|
|
2490
|
+
def gather_scene_hook(self, gltf2_scene, blender_scene, export_settings):
|
|
2491
|
+
if export_settings.get("gltf_animation_mode") == "SCENE":
|
|
2492
|
+
# Capture this scene while its filtered tree is current: the final glTF hook runs
|
|
2493
|
+
# after all scenes, when vtree describes only the last one.
|
|
2494
|
+
bones = self._scene_bones.setdefault(blender_scene.name, set())
|
|
2495
|
+
tree = export_settings["vtree"]
|
|
2496
|
+
for uuid in tree.get_all_objects():
|
|
2497
|
+
node = tree.nodes[uuid]
|
|
2498
|
+
obj = node.blender_object
|
|
2499
|
+
# A removed armature object still exports its joints. Filtered-out rigs with
|
|
2500
|
+
# no exported joints remain in the virtual tree too; they contribute no mask.
|
|
2501
|
+
if obj is not None and obj.type == "ARMATURE" and (node.node is not None or node.bones):
|
|
2502
|
+
for action, _strip in _scene_actions(obj):
|
|
2503
|
+
bones.update(_action_keyed_bones(action))
|
|
2504
|
+
for name, data in self.scene_extensions.items():
|
|
2505
|
+
if data is not None:
|
|
2506
|
+
self._extend(gltf2_scene, name, data)
|
|
2507
|
+
if not self.scene_extras:
|
|
2508
|
+
return
|
|
2509
|
+
extras = dict(gltf2_scene.extras or {})
|
|
2510
|
+
extras.update(self.scene_extras)
|
|
2511
|
+
gltf2_scene.extras = extras
|
|
2512
|
+
|
|
2513
|
+
|
|
2514
|
+
def _with_hooks(hooks, run):
|
|
2515
|
+
module = types.ModuleType(_HOOK_MODULE)
|
|
2516
|
+
module.glTF2ExportUserExtension = lambda: hooks
|
|
2517
|
+
sys.modules[_HOOK_MODULE] = module
|
|
2518
|
+
addons = bpy.context.preferences.addons
|
|
2519
|
+
entry = addons.new()
|
|
2520
|
+
entry.module = _HOOK_MODULE
|
|
2521
|
+
try:
|
|
2522
|
+
return run()
|
|
2523
|
+
finally:
|
|
2524
|
+
try:
|
|
2525
|
+
addons.remove(entry)
|
|
2526
|
+
finally:
|
|
2527
|
+
sys.modules.pop(_HOOK_MODULE, None)
|
|
2528
|
+
|
|
2529
|
+
|
|
2530
|
+
def _export_set(scene, view_layer, objects, run):
|
|
2531
|
+
"""Export exactly `objects` (and nothing that is not in the view layer), through a temporary
|
|
2532
|
+
collection made the active one, then take the collection away again."""
|
|
2533
|
+
holder = bpy.data.collections.new(_HOLDER)
|
|
2534
|
+
scene.collection.children.link(holder)
|
|
2535
|
+
previous = view_layer.active_layer_collection
|
|
2536
|
+
try:
|
|
2537
|
+
for obj in objects:
|
|
2538
|
+
if holder.objects.get(obj.name) is None:
|
|
2539
|
+
holder.objects.link(obj)
|
|
2540
|
+
layer = view_layer.layer_collection.children.get(holder.name)
|
|
2541
|
+
if layer is None:
|
|
2542
|
+
raise RuntimeError("The export collection did not join the view layer.")
|
|
2543
|
+
view_layer.active_layer_collection = layer
|
|
2544
|
+
return run()
|
|
2545
|
+
finally:
|
|
2546
|
+
try:
|
|
2547
|
+
view_layer.active_layer_collection = previous
|
|
2548
|
+
finally:
|
|
2549
|
+
scene.collection.children.unlink(holder)
|
|
2550
|
+
bpy.data.collections.remove(holder)
|
|
2551
|
+
|
|
2552
|
+
|
|
2553
|
+
class _Steps:
|
|
2554
|
+
"""Where an export's time goes, step by step (in its answer and the runner's line, never in the
|
|
2555
|
+
manifest, which carries no time)."""
|
|
2556
|
+
|
|
2557
|
+
def __init__(self):
|
|
2558
|
+
self.ms = {}
|
|
2559
|
+
|
|
2560
|
+
def run(self, name, fn):
|
|
2561
|
+
started = time.perf_counter()
|
|
2562
|
+
try:
|
|
2563
|
+
return fn()
|
|
2564
|
+
finally:
|
|
2565
|
+
self.ms[name] = self.ms.get(name, 0) + round((time.perf_counter() - started) * 1000)
|
|
2566
|
+
|
|
2567
|
+
|
|
2568
|
+
def _gltf(out, settings):
|
|
2569
|
+
os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
|
|
2570
|
+
export = lambda: bpy.ops.export_scene.gltf(filepath=out, use_active_collection=True,
|
|
2571
|
+
use_active_collection_with_nested=False, **settings)
|
|
2572
|
+
if settings.get("export_animation_mode") != "SCENE":
|
|
2573
|
+
result = export()
|
|
2574
|
+
elif tuple(getattr(_gltf_addon(), "bl_info", {}).get("version", ())) in SAMPLING_READ_FROM:
|
|
2575
|
+
result = _sampling_what_moves(export)
|
|
2576
|
+
else:
|
|
2577
|
+
print("gltf_export: the glTF add-on %s is not one whose scene bake _sampling_what_moves was read against (%s): "
|
|
2578
|
+
"every object is baked, as the add-on does" % (getattr(_gltf_addon(), "bl_info", {}).get("version"),
|
|
2579
|
+
", ".join(".".join(map(str, v)) for v in SAMPLING_READ_FROM)), file=sys.stderr)
|
|
2580
|
+
result = export()
|
|
2581
|
+
if "FINISHED" not in result:
|
|
2582
|
+
raise RuntimeError("Blender's glTF exporter did not finish: %s" % sorted(result))
|
|
2583
|
+
|
|
2584
|
+
|
|
2585
|
+
def _holds_still(obj, seen=None):
|
|
2586
|
+
"""Whether an object's transform stays as it is over the timeline, relative to the exported node above it: it,
|
|
2587
|
+
and every object above it, holds still by its own account (`_moves`). A parent's motion would leave a child where
|
|
2588
|
+
it is relative to the parent, which is what a glTF node's transform says, but the add-on's bake of the child
|
|
2589
|
+
carries it (and a curve's path moves its children along it), so a child of anything that moves is baked."""
|
|
2590
|
+
seen = set() if seen is None else seen
|
|
2591
|
+
at = obj
|
|
2592
|
+
while at is not None:
|
|
2593
|
+
if at in seen:
|
|
2594
|
+
# (asked already further up this question: a constraint's target cycle, or a parent asked again)
|
|
2595
|
+
return True
|
|
2596
|
+
seen.add(at)
|
|
2597
|
+
if _moves(at, seen):
|
|
2598
|
+
return False
|
|
2599
|
+
at = at.parent
|
|
2600
|
+
return True
|
|
2601
|
+
|
|
2602
|
+
|
|
2603
|
+
def _animated(block):
|
|
2604
|
+
"""Whether an ID is keyed or driven: an action, an NLA strip or a driver on it."""
|
|
2605
|
+
animation = getattr(block, "animation_data", None) if block is not None else None
|
|
2606
|
+
return animation is not None and (animation.action is not None or len(animation.drivers) > 0
|
|
2607
|
+
or any(len(track.strips) > 0 for track in animation.nla_tracks))
|
|
2608
|
+
|
|
2609
|
+
|
|
2610
|
+
def _targets(constraint):
|
|
2611
|
+
"""The objects a constraint reads: its target, its pole target, an Armature constraint's targets."""
|
|
2612
|
+
found = []
|
|
2613
|
+
for name in ("target", "pole_target"):
|
|
2614
|
+
target = getattr(constraint, name, None)
|
|
2615
|
+
if isinstance(target, bpy.types.Object):
|
|
2616
|
+
found.append(target)
|
|
2617
|
+
for one in getattr(constraint, "targets", ()) or ():
|
|
2618
|
+
target = getattr(one, "target", None)
|
|
2619
|
+
if isinstance(target, bpy.types.Object):
|
|
2620
|
+
found.append(target)
|
|
2621
|
+
return found
|
|
2622
|
+
|
|
2623
|
+
|
|
2624
|
+
def _moves(obj, seen):
|
|
2625
|
+
"""Whether something of the object's own may move it (or its children) over the timeline:
|
|
2626
|
+
- it is keyed or driven (an action, an NLA strip or a driver on it);
|
|
2627
|
+
- its data is keyed or driven, or its data's shape keys are: a curve's `eval_time` (the path its children
|
|
2628
|
+
follow, which Ctrl+P > Follow Path keys) or its points, a light's or a camera's settings, a mesh's keys;
|
|
2629
|
+
- it is a curve whose path its children follow (`use_path`), which hooks and shape keys change too;
|
|
2630
|
+
- a constraint moves it: one that is on, or one whose target moves (each constraint's targets are asked);
|
|
2631
|
+
- it follows a vertex (which a deforming parent moves), or a simulation (a rigid body).
|
|
2632
|
+
Armatures (their bones) and meshes with shape keys are never counted still. Materials are not asked: their
|
|
2633
|
+
animation is exported, if at all, apart from the objects' bake this step narrows."""
|
|
2634
|
+
if obj.type == "ARMATURE":
|
|
2635
|
+
return True
|
|
2636
|
+
data = obj.data
|
|
2637
|
+
if obj.type == "MESH" and data is not None and data.shape_keys is not None:
|
|
2638
|
+
return True
|
|
2639
|
+
if _animated(obj) or _animated(data) or _animated(getattr(data, "shape_keys", None)):
|
|
2640
|
+
return True
|
|
2641
|
+
if obj.type == "CURVE" and data is not None and data.use_path:
|
|
2642
|
+
return True
|
|
2643
|
+
for constraint in obj.constraints:
|
|
2644
|
+
if constraint.enabled or any(not _holds_still(target, seen) for target in _targets(constraint)):
|
|
2645
|
+
return True
|
|
2646
|
+
if obj.parent is not None and obj.parent_type in ("VERTEX", "VERTEX_3"):
|
|
2647
|
+
return True
|
|
2648
|
+
return obj.rigid_body is not None
|
|
2649
|
+
|
|
2650
|
+
|
|
2651
|
+
def _evaluating_what_exports(view_layer, placements, exported, run):
|
|
2652
|
+
"""The level's placements out of the view layer while Blender's exporter bakes the scene, where nothing
|
|
2653
|
+
exported needs them: the add-on's own "Disable Viewport for Other Objects, for performance", which it does only
|
|
2654
|
+
in its other modes. Their markers stand in for them in the file and carry their motion (already keyed), so every
|
|
2655
|
+
frame the bake sets evaluates the level without the placement rigs and skins absent from the output. A placement
|
|
2656
|
+
something exported uses (a parent, a constraint's or a driver's target, a modifier's object), or that such a
|
|
2657
|
+
placement uses in turn, stays. Put back as it was after the export."""
|
|
2658
|
+
exported_ids = set(exported)
|
|
2659
|
+
for obj in exported:
|
|
2660
|
+
if obj.data is not None:
|
|
2661
|
+
exported_ids.add(obj.data)
|
|
2662
|
+
members = {}
|
|
2663
|
+
for collection in placements:
|
|
2664
|
+
for obj in collection.all_objects:
|
|
2665
|
+
members.setdefault(obj, collection)
|
|
2666
|
+
users = bpy.data.user_map(subset=set(members))
|
|
2667
|
+
needed = set()
|
|
2668
|
+
grew = True
|
|
2669
|
+
while grew:
|
|
2670
|
+
grew = False
|
|
2671
|
+
for obj in members:
|
|
2672
|
+
if obj not in needed and any(user in exported_ids or user in needed for user in users.get(obj, ())):
|
|
2673
|
+
needed.add(obj)
|
|
2674
|
+
grew = True
|
|
2675
|
+
kept = {members[obj] for obj in needed}
|
|
2676
|
+
excluded = []
|
|
2677
|
+
|
|
2678
|
+
def walk(layer):
|
|
2679
|
+
for child in layer.children:
|
|
2680
|
+
if child.collection in placements:
|
|
2681
|
+
if child.collection not in kept and not child.exclude:
|
|
2682
|
+
excluded.append(child)
|
|
2683
|
+
else:
|
|
2684
|
+
walk(child)
|
|
2685
|
+
walk(view_layer.layer_collection)
|
|
2686
|
+
for layer in excluded:
|
|
2687
|
+
layer.exclude = True
|
|
2688
|
+
try:
|
|
2689
|
+
return run()
|
|
2690
|
+
finally:
|
|
2691
|
+
for layer in excluded:
|
|
2692
|
+
layer.exclude = False
|
|
2693
|
+
|
|
2694
|
+
|
|
2695
|
+
# Apply the scene-bake optimization only to the glTF add-on versions with matching sampling
|
|
2696
|
+
# internals. Other versions use the add-on’s complete bake.
|
|
2697
|
+
SAMPLING_READ_FROM = ((5, 2, 39), (5, 2, 40))
|
|
2698
|
+
|
|
2699
|
+
|
|
2700
|
+
def _sampling_what_moves(run):
|
|
2701
|
+
"""Bake only scene objects that may move, including descendants of moving nodes.
|
|
2702
|
+
Wrap the exporter’s gather and cache functions for this call and restore them afterwards.
|
|
2703
|
+
Preserve its sampled values and output bytes."""
|
|
2704
|
+
from io_scene_gltf2.blender.exp.animation import scene_animation
|
|
2705
|
+
from io_scene_gltf2.blender.exp.animation.sampled import sampling_cache
|
|
2706
|
+
from io_scene_gltf2.blender.exp.tree import VExportNode
|
|
2707
|
+
gather, caching = scene_animation.gather_object_sampled_channels, sampling_cache.object_caching
|
|
2708
|
+
known = {}
|
|
2709
|
+
|
|
2710
|
+
def still(uuid, export_settings):
|
|
2711
|
+
"""The node and every node above it hold still. (A child of a moving node keeps its place relative to it,
|
|
2712
|
+
but the add-on's bake of it carries the rounding of the parent's motion, which it keeps: such a child is
|
|
2713
|
+
baked as the add-on bakes it, so the file stays the same.)"""
|
|
2714
|
+
if uuid in known:
|
|
2715
|
+
return known[uuid]
|
|
2716
|
+
nodes = export_settings["vtree"].nodes
|
|
2717
|
+
node = nodes[uuid]
|
|
2718
|
+
if node.blender_type == VExportNode.COLLECTION:
|
|
2719
|
+
answer = True
|
|
2720
|
+
elif node.blender_type == VExportNode.BONE or node.blender_object is None:
|
|
2721
|
+
answer = False
|
|
2722
|
+
else:
|
|
2723
|
+
answer = _holds_still(node.blender_object)
|
|
2724
|
+
if answer and node.parent_uuid is not None:
|
|
2725
|
+
answer = still(node.parent_uuid, export_settings)
|
|
2726
|
+
known[uuid] = answer
|
|
2727
|
+
return answer
|
|
2728
|
+
|
|
2729
|
+
def gather_moving(uuid, *rest):
|
|
2730
|
+
if still(uuid, rest[-1]):
|
|
2731
|
+
return None, None
|
|
2732
|
+
return gather(uuid, *rest)
|
|
2733
|
+
|
|
2734
|
+
def caching_moving(data, uuids, *rest):
|
|
2735
|
+
export_settings = rest[-1]
|
|
2736
|
+
return caching(data, [uuid for uuid in uuids if not still(uuid, export_settings)], *rest)
|
|
2737
|
+
|
|
2738
|
+
scene_animation.gather_object_sampled_channels = gather_moving
|
|
2739
|
+
sampling_cache.object_caching = caching_moving
|
|
2740
|
+
try:
|
|
2741
|
+
return run()
|
|
2742
|
+
finally:
|
|
2743
|
+
scene_animation.gather_object_sampled_channels = gather
|
|
2744
|
+
sampling_cache.object_caching = caching
|
|
2745
|
+
|
|
2746
|
+
|
|
2747
|
+
def _contents(out):
|
|
2748
|
+
"""What the written GLB holds, read from its JSON chunk."""
|
|
2749
|
+
with open(out, "rb") as handle:
|
|
2750
|
+
header = handle.read(20)
|
|
2751
|
+
length = int.from_bytes(header[12:16], "little")
|
|
2752
|
+
document = json.loads(handle.read(length).decode("utf-8"))
|
|
2753
|
+
return {
|
|
2754
|
+
"nodes": len(document.get("nodes", [])),
|
|
2755
|
+
"meshes": len(document.get("meshes", [])),
|
|
2756
|
+
"skins": len(document.get("skins", [])),
|
|
2757
|
+
"animations": [a.get("name", "") for a in document.get("animations", [])],
|
|
2758
|
+
"extensionsUsed": document.get("extensionsUsed", []),
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
|
|
2762
|
+
def _finish(kind, out, project, blend_sha, version, settings, extra, started, sidecars, steps):
|
|
2763
|
+
finishing = time.perf_counter()
|
|
2764
|
+
body = {
|
|
2765
|
+
"format": FORMAT,
|
|
2766
|
+
"kind": kind,
|
|
2767
|
+
"blend": _project_relative(bpy.data.filepath, project),
|
|
2768
|
+
"blendSha256": blend_sha,
|
|
2769
|
+
"exporter": version,
|
|
2770
|
+
"settings": settings,
|
|
2771
|
+
"file": os.path.basename(out),
|
|
2772
|
+
"sha256": _sha256(out),
|
|
2773
|
+
"bytes": os.path.getsize(out),
|
|
2774
|
+
}
|
|
2775
|
+
if sidecars:
|
|
2776
|
+
body["sidecars"] = [{"file": os.path.basename(path), "sha256": _sha256(path)} for path in sidecars]
|
|
2777
|
+
body.update(_contents(out))
|
|
2778
|
+
body.update(extra)
|
|
2779
|
+
with open(_manifest_path(out), "w", encoding="utf-8", newline="\n") as handle:
|
|
2780
|
+
json.dump(body, handle, indent=2, sort_keys=False)
|
|
2781
|
+
handle.write("\n")
|
|
2782
|
+
steps.ms["manifest"] = round((time.perf_counter() - finishing) * 1000)
|
|
2783
|
+
return {"current": False, "ms": round((time.perf_counter() - started) * 1000), "manifest": body,
|
|
2784
|
+
"steps": steps.ms,
|
|
2785
|
+
"sidecars": [os.path.abspath(path) for path in sidecars]}
|
|
2786
|
+
|
|
2787
|
+
|
|
2788
|
+
def _current_answer(out, current, started, luts):
|
|
2789
|
+
"""A current export; its display LUT baked again alone when its file is gone."""
|
|
2790
|
+
missing = _lut_missing(current, out, luts)
|
|
2791
|
+
sidecars = []
|
|
2792
|
+
if missing is not None:
|
|
2793
|
+
cube, description = _display_lut_for(bpy.context.scene, out, luts)
|
|
2794
|
+
if cube is not None and description["key"] == missing:
|
|
2795
|
+
sidecars.append(os.path.abspath(cube))
|
|
2796
|
+
return {"current": True, "ms": round((time.perf_counter() - started) * 1000), "manifest": current, "sidecars": sidecars}
|
|
2797
|
+
|
|
2798
|
+
|
|
2799
|
+
def export_component(out, project=None, force=False, previous=None, blend_sha=None, luts=None):
|
|
2800
|
+
started = time.perf_counter()
|
|
2801
|
+
scene, view_layer = bpy.context.scene, bpy.context.view_layer
|
|
2802
|
+
blend_sha = blend_sha or _sha256(bpy.data.filepath)
|
|
2803
|
+
version = exporter_version()
|
|
2804
|
+
settings = dict(COMPONENT_SETTINGS)
|
|
2805
|
+
if not force:
|
|
2806
|
+
current = _current(out, blend_sha, version, settings, previous)
|
|
2807
|
+
if current is not None:
|
|
2808
|
+
return _current_answer(out, current, started, luts)
|
|
2809
|
+
authoring = _authoring_collections(scene)
|
|
2810
|
+
collections = [c.name for c in scene.collection.children if c.name_full not in authoring]
|
|
2811
|
+
hidden = _hidden_collections(scene)
|
|
2812
|
+
objects = [o for o in view_layer.objects if not _authoring(o) and not any(c.name_full in authoring for c in o.users_collection)]
|
|
2813
|
+
visibility = {o.name: _render_visible(o, hidden) for o in objects}
|
|
2814
|
+
warnings = []
|
|
2815
|
+
if len(collections) != 1:
|
|
2816
|
+
warnings.append("a component holds one collection named after it; this file has %d: %s"
|
|
2817
|
+
% (len(collections), ", ".join(collections) or "none"))
|
|
2818
|
+
warnings.extend(_hidden_over_visible(objects, visibility))
|
|
2819
|
+
warnings.extend(_area_lights(objects, visibility))
|
|
2820
|
+
warnings.extend(_strip_warnings(objects))
|
|
2821
|
+
warnings.extend(_euler_bones(objects))
|
|
2822
|
+
warnings.extend(_object_only_armature_actions(objects))
|
|
2823
|
+
warnings.extend(_evaluated_bones(objects))
|
|
2824
|
+
warnings.extend(_driven_shape_keys(objects))
|
|
2825
|
+
world, world_gaps = _world(scene)
|
|
2826
|
+
warnings.extend(world_gaps)
|
|
2827
|
+
volumes, volume_warnings = _object_volumes(objects, visibility)
|
|
2828
|
+
warnings.extend(volume_warnings)
|
|
2829
|
+
for name in volumes:
|
|
2830
|
+
visibility[name] = False
|
|
2831
|
+
runtime, runtime_data, runtime_warnings = _runtime_constraints(objects)
|
|
2832
|
+
warnings.extend(runtime_warnings)
|
|
2833
|
+
steps = _Steps()
|
|
2834
|
+
candidates = steps.run("subframeMeasure", lambda: _subframe_candidates(objects))
|
|
2835
|
+
cube, lut = steps.run("displayLut", lambda: _display_lut_for(scene, out, luts))
|
|
2836
|
+
hooks = _Hooks({}, {"keyRate": _key_rate(scene), "collections": _collection_tree(scene, authoring)}, visibility,
|
|
2837
|
+
_node_collections(objects, _collection_ancestors(scene)), runtime_data,
|
|
2838
|
+
scene_extensions={"STD_display": _display(scene, lut), "STD_environment": world, "STD_render": _render(scene)},
|
|
2839
|
+
volumes=volumes)
|
|
2840
|
+
steps.run("gltfExporter", lambda: _with_hooks(hooks, lambda: _muted(runtime, lambda: _at_rest(objects, lambda: _export_set(
|
|
2841
|
+
scene, view_layer, objects, lambda: _gltf(out, settings))))))
|
|
2842
|
+
duplicates = steps.run("uniqueChannels", lambda: _unique_channels(out))
|
|
2843
|
+
weights = steps.run("weightsKeyed", lambda: _weights_keyed(out))
|
|
2844
|
+
dense = steps.run("subframeSampling", lambda: _muted(runtime, lambda: _densify_subframes(out, objects, scene, candidates)))
|
|
2845
|
+
warnings.extend(_subframe_warnings(dense, candidates))
|
|
2846
|
+
signs = steps.run("rotationSigns", lambda: _align_rotation_signs(out, objects, scene))
|
|
2847
|
+
warnings.extend("an action's frames are mapped differently from the exporter's: " + one for one in hooks.slide_mismatch)
|
|
2848
|
+
return _finish("component", out, project, blend_sha, version, settings,
|
|
2849
|
+
{"collections": collections, "hiddenNodes": hooks.used_visibility, "warnings": warnings,
|
|
2850
|
+
"display": {"lut": lut["key"], "sha256": _lut_sha(out, lut["key"], luts)},
|
|
2851
|
+
"runtimeConstraints": sum(len(c) for c in runtime.values()), "rotationKeysTurned": signs["turned"],
|
|
2852
|
+
"rotationOutputsSplit": signs["split"], "duplicateChannelsRemoved": duplicates,
|
|
2853
|
+
"rotationLengths": {"channels": signs["lengths"], "bytes": signs["lengthBytes"]},
|
|
2854
|
+
"weightsKeyed": weights,
|
|
2855
|
+
"subframeSampling": _subframe_manifest(dense),
|
|
2856
|
+
"movedWithoutKeys": {action: sorted(bones) for action, bones in sorted(hooks.kept_moved.items())}},
|
|
2857
|
+
started, [cube] if cube else [], steps)
|
|
2858
|
+
|
|
2859
|
+
|
|
2860
|
+
def _marker_component(component):
|
|
2861
|
+
"""What a marker names (RFC 0007 §2, §4): its component's exported file beside the level's folder
|
|
2862
|
+
(`../components/<stem>.glb`, as assembly resolves it) and its collection; never the .blend."""
|
|
2863
|
+
stem = os.path.splitext(os.path.basename(component["file"]))[0]
|
|
2864
|
+
return {"uri": "../components/%s.glb" % stem, "collection": component["collection"]}
|
|
2865
|
+
|
|
2866
|
+
|
|
2867
|
+
def _placement_root(collection):
|
|
2868
|
+
members = set(collection.all_objects)
|
|
2869
|
+
roots = [o for o in collection.all_objects if o.parent is None or o.parent not in members]
|
|
2870
|
+
roots.sort(key=lambda o: (o.type != "ARMATURE", o.name))
|
|
2871
|
+
return roots[0] if roots else None, roots
|
|
2872
|
+
|
|
2873
|
+
|
|
2874
|
+
def _tint(root):
|
|
2875
|
+
if "tint" in root.keys():
|
|
2876
|
+
value = root["tint"]
|
|
2877
|
+
try:
|
|
2878
|
+
return [round(float(x), 6) for x in value]
|
|
2879
|
+
except TypeError:
|
|
2880
|
+
return value if isinstance(value, str) else None
|
|
2881
|
+
reference = root.override_library.reference if root.override_library else None
|
|
2882
|
+
if reference is not None and tuple(root.color) != tuple(reference.color):
|
|
2883
|
+
return [round(float(x), 6) for x in root.color]
|
|
2884
|
+
return None
|
|
2885
|
+
|
|
2886
|
+
|
|
2887
|
+
def _unsupported_edits(collection):
|
|
2888
|
+
counts = {}
|
|
2889
|
+
for obj in collection.all_objects:
|
|
2890
|
+
library = obj.override_library
|
|
2891
|
+
if library is None:
|
|
2892
|
+
continue
|
|
2893
|
+
for prop in library.properties:
|
|
2894
|
+
path = prop.rna_path
|
|
2895
|
+
# (the placement's NLA is carried: extras.tracks, and its own motion keyed on the marker)
|
|
2896
|
+
if path in CARRIED or path in STRUCTURAL or path.startswith(("modifiers[", "animation_data.nla_tracks",
|
|
2897
|
+
"animation_data.action_slot")):
|
|
2898
|
+
continue
|
|
2899
|
+
key = path.split("[")[0] if path.startswith("pose.bones") else path
|
|
2900
|
+
key = "pose.bones" if key.startswith("pose.bones") else key
|
|
2901
|
+
counts["%s:%s" % (obj.name, key)] = counts.get("%s:%s" % (obj.name, key), 0) + 1
|
|
2902
|
+
return ["%s (%d)" % (key, n) for key, n in sorted(counts.items())]
|
|
2903
|
+
|
|
2904
|
+
|
|
2905
|
+
def _keyed_visibility(obj):
|
|
2906
|
+
keys = []
|
|
2907
|
+
for curve in _fcurves(obj):
|
|
2908
|
+
if curve.data_path == "hide_render":
|
|
2909
|
+
keys.extend([float(p.co[0]), not bool(round(p.co[1]))] for p in curve.keyframe_points)
|
|
2910
|
+
return sorted(keys)
|
|
2911
|
+
|
|
2912
|
+
|
|
2913
|
+
def export_level(out, project=None, force=False, previous=None, blend_sha=None, luts=None):
|
|
2914
|
+
started = time.perf_counter()
|
|
2915
|
+
scene, view_layer = bpy.context.scene, bpy.context.view_layer
|
|
2916
|
+
blend_sha = blend_sha or _sha256(bpy.data.filepath)
|
|
2917
|
+
version = exporter_version()
|
|
2918
|
+
settings = dict(LEVEL_SETTINGS)
|
|
2919
|
+
if not force:
|
|
2920
|
+
current = _current(out, blend_sha, version, settings, previous)
|
|
2921
|
+
# (a placement's own actions are beside the level: gone, the level is exported again)
|
|
2922
|
+
if current is not None and (previous is not None or all(
|
|
2923
|
+
os.path.isfile(os.path.join(os.path.dirname(out), entry["file"]))
|
|
2924
|
+
for entry in (current.get("placementActions") or {}).values())):
|
|
2925
|
+
return _current_answer(out, current, started, luts)
|
|
2926
|
+
authoring = _authoring_collections(scene)
|
|
2927
|
+
hidden = _hidden_collections(scene)
|
|
2928
|
+
in_layer = set(view_layer.objects)
|
|
2929
|
+
# THE PLACEMENTS: the outermost override collections in the scene, and the instancers of
|
|
2930
|
+
# linked collections.
|
|
2931
|
+
overrides = []
|
|
2932
|
+
|
|
2933
|
+
def walk(collection, inside):
|
|
2934
|
+
for child in collection.children:
|
|
2935
|
+
if child.name_full in authoring:
|
|
2936
|
+
continue
|
|
2937
|
+
is_override = child.override_library is not None and child.override_library.reference is not None \
|
|
2938
|
+
and child.override_library.reference.library is not None
|
|
2939
|
+
if is_override and not inside:
|
|
2940
|
+
overrides.append(child)
|
|
2941
|
+
walk(child, inside or is_override)
|
|
2942
|
+
|
|
2943
|
+
walk(scene.collection, False)
|
|
2944
|
+
placed = set()
|
|
2945
|
+
for collection in overrides:
|
|
2946
|
+
placed.update(collection.all_objects)
|
|
2947
|
+
instancers = [o for o in view_layer.objects if o.instance_type == "COLLECTION" and o.instance_collection is not None
|
|
2948
|
+
and o.instance_collection.library is not None and o not in placed and not _authoring(o)]
|
|
2949
|
+
objects = [o for o in view_layer.objects
|
|
2950
|
+
if o not in placed and o.override_library is None and o.library is None and not _authoring(o)
|
|
2951
|
+
and not any(c.name_full in authoring for c in o.users_collection)]
|
|
2952
|
+
markers, nodes, unsupported, warnings = [], {}, [], []
|
|
2953
|
+
ancestors = _collection_ancestors(scene)
|
|
2954
|
+
components = {}
|
|
2955
|
+
made = []
|
|
2956
|
+
moving = []
|
|
2957
|
+
motion = []
|
|
2958
|
+
companions = []
|
|
2959
|
+
companion_results = {}
|
|
2960
|
+
switched = []
|
|
2961
|
+
stranded = sorted(o.name for o in objects if o.parent is not None and o.parent in placed)
|
|
2962
|
+
if stranded:
|
|
2963
|
+
warnings.append("objects parented to a placement stand where they are now, without its motion: " + ", ".join(stranded))
|
|
2964
|
+
try:
|
|
2965
|
+
for index, collection in enumerate(overrides):
|
|
2966
|
+
root, roots = _placement_root(collection)
|
|
2967
|
+
if root is None:
|
|
2968
|
+
warnings.append("override %s holds no object; it has no marker" % collection.name)
|
|
2969
|
+
continue
|
|
2970
|
+
reference = root.override_library.reference if root.override_library else None
|
|
2971
|
+
placement = root.matrix_world.copy()
|
|
2972
|
+
if reference is not None:
|
|
2973
|
+
placement = root.matrix_world @ reference.matrix_world.inverted()
|
|
2974
|
+
for other in roots[1:]:
|
|
2975
|
+
other_ref = other.override_library.reference if other.override_library else None
|
|
2976
|
+
if other_ref is None:
|
|
2977
|
+
continue
|
|
2978
|
+
expected = placement @ other_ref.matrix_world
|
|
2979
|
+
if any(abs(a - b) > 1e-4 for row_a, row_b in zip(expected, other.matrix_world) for a, b in zip(row_a, row_b)):
|
|
2980
|
+
unsupported.append({"placement": collection.name, "edit": "%s is moved apart from %s" % (other.name, root.name)})
|
|
2981
|
+
library_collection = collection.override_library.reference
|
|
2982
|
+
component = {"file": _library_file(library_collection.library, project), "collection": library_collection.name}
|
|
2983
|
+
components[(component["file"], component["collection"])] = component
|
|
2984
|
+
tracks = {}
|
|
2985
|
+
action = root.animation_data.action.name if root.animation_data and root.animation_data.action else None
|
|
2986
|
+
visible = any(o.type == "MESH" and _render_visible(o, hidden) for o in collection.all_objects) \
|
|
2987
|
+
and collection.name_full not in hidden
|
|
2988
|
+
extras = {"component": _marker_component(component)}
|
|
2989
|
+
held = _collections_of([collection.name], ancestors)
|
|
2990
|
+
if held:
|
|
2991
|
+
extras["collections"] = held
|
|
2992
|
+
# The placement's active action as Blender plays it, `false` when it has none (an override may
|
|
2993
|
+
# clear the component's: then only its NLA plays). Not null: the glTF writer drops a null, and the
|
|
2994
|
+
# placement would then play its component's clip.
|
|
2995
|
+
if root.type == "ARMATURE":
|
|
2996
|
+
tracks["clip"] = action if action is not None else False
|
|
2997
|
+
# THE PLACEMENT'S OWN NLA, as authored on the override (its tracks over the component's):
|
|
2998
|
+
# it plays on the placed clone in a cutscene (RFC 0007 §3).
|
|
2999
|
+
own = _level_actions(root)
|
|
3000
|
+
if own:
|
|
3001
|
+
extras["actions"] = {"file": _companion(out, collection.name)}
|
|
3002
|
+
companions.append((root, own, os.path.join(os.path.dirname(out), extras["actions"]["file"]), collection.name))
|
|
3003
|
+
placement_tracks = _tracks(root) if root.type == "ARMATURE" else None
|
|
3004
|
+
if placement_tracks and any(t.get("strips") for t in placement_tracks):
|
|
3005
|
+
tracks["tracks"] = placement_tracks
|
|
3006
|
+
tracks.update(_nla_settings(root))
|
|
3007
|
+
tint = _tint(root)
|
|
3008
|
+
if tint is not None:
|
|
3009
|
+
extras["tint"] = tint
|
|
3010
|
+
marker = bpy.data.objects.new("__volter_marker.%d" % index, None)
|
|
3011
|
+
marker.empty_display_type = "PLAIN_AXES"
|
|
3012
|
+
made.append(marker)
|
|
3013
|
+
if root.parent is not None and root.parent not in placed and root.parent in in_layer:
|
|
3014
|
+
marker.parent = root.parent
|
|
3015
|
+
marker.matrix_world = placement
|
|
3016
|
+
nodes[marker.name] = {"name": collection.name, "extras": extras, "tracks": tracks}
|
|
3017
|
+
markers.append({"name": collection.name, "component": component, "action": action, "visible": visible,
|
|
3018
|
+
"tint": tint, "parent": marker.parent.name if marker.parent else None})
|
|
3019
|
+
for edit in _unsupported_edits(collection):
|
|
3020
|
+
unsupported.append({"placement": collection.name, "edit": edit})
|
|
3021
|
+
if _moves_itself(root):
|
|
3022
|
+
moving.append((marker, root, reference.matrix_world.inverted() if reference is not None else mathutils.Matrix()))
|
|
3023
|
+
for obj in instancers:
|
|
3024
|
+
linked = obj.instance_collection
|
|
3025
|
+
component = {"file": _library_file(linked.library, project), "collection": linked.name}
|
|
3026
|
+
components[(component["file"], component["collection"])] = component
|
|
3027
|
+
if tuple(linked.instance_offset) != (0.0, 0.0, 0.0):
|
|
3028
|
+
unsupported.append({"placement": obj.name, "edit": "the collection's instance offset %s is not carried"
|
|
3029
|
+
% tuple(round(v, 4) for v in linked.instance_offset)})
|
|
3030
|
+
extras = {"component": _marker_component(component)}
|
|
3031
|
+
held = _collections_of([c.name for c in obj.users_collection], ancestors)
|
|
3032
|
+
if held:
|
|
3033
|
+
extras["collections"] = held
|
|
3034
|
+
tint = obj["tint"] if "tint" in obj.keys() else None
|
|
3035
|
+
if tint is not None:
|
|
3036
|
+
extras["tint"] = [float(x) for x in tint] if not isinstance(tint, str) else tint
|
|
3037
|
+
nodes[obj.name] = {"name": obj.name, "extras": extras}
|
|
3038
|
+
markers.append({"name": obj.name, "component": component, "action": None,
|
|
3039
|
+
"visible": _render_visible(obj, hidden), "tint": extras.get("tint"), "parent": obj.parent.name if obj.parent else None})
|
|
3040
|
+
switched.append(obj)
|
|
3041
|
+
obj.instance_type = "NONE"
|
|
3042
|
+
visibility = {o.name: _render_visible(o, hidden) for o in objects}
|
|
3043
|
+
for marker in made:
|
|
3044
|
+
entry = next(m for m in markers if m["name"] == nodes[marker.name]["name"])
|
|
3045
|
+
visibility[marker.name] = entry["visible"]
|
|
3046
|
+
fps = scene.render.fps / (scene.render.fps_base or 1.0)
|
|
3047
|
+
timeline = {
|
|
3048
|
+
"fps": round(fps, 6),
|
|
3049
|
+
"keyRate": _key_rate(scene),
|
|
3050
|
+
"start": scene.frame_start,
|
|
3051
|
+
"end": scene.frame_end,
|
|
3052
|
+
"current": scene.frame_current,
|
|
3053
|
+
"camera": scene.camera.name if scene.camera else None,
|
|
3054
|
+
"markers": [{"name": m.name, "frame": m.frame, "camera": m.camera.name if m.camera else None}
|
|
3055
|
+
for m in sorted(scene.timeline_markers, key=lambda m: (m.frame, m.name))],
|
|
3056
|
+
}
|
|
3057
|
+
warnings.extend(_hidden_over_visible(objects, visibility))
|
|
3058
|
+
keyed = {o.name: _keyed_visibility(o) for o in objects}
|
|
3059
|
+
keyed = {name: keys for name, keys in keyed.items() if keys}
|
|
3060
|
+
if keyed:
|
|
3061
|
+
timeline["visibility"] = keyed
|
|
3062
|
+
exported = objects + made
|
|
3063
|
+
frame, subframe = scene.frame_current, scene.frame_subframe
|
|
3064
|
+
steps = _Steps()
|
|
3065
|
+
moving_names = sorted(nodes[marker.name]["name"] for marker, _root, _inverse in moving)
|
|
3066
|
+
try:
|
|
3067
|
+
motion = steps.run("placementMotion", lambda: _bake_placement_motion(scene, moving))
|
|
3068
|
+
finally:
|
|
3069
|
+
scene.frame_set(frame, subframe=subframe)
|
|
3070
|
+
# HOW THE LEVEL IS SEEN AND RENDERED: its colour management (without which a renderer cannot
|
|
3071
|
+
# draw its emission and highlights as Blender does: AgX maps an emission of 8 to a pale tint,
|
|
3072
|
+
# where no tone mapping clips it white), its world and its render settings.
|
|
3073
|
+
warnings.extend(_area_lights(objects, visibility))
|
|
3074
|
+
warnings.extend(_strip_warnings(objects + [c for c in placed if c.type == "ARMATURE"]))
|
|
3075
|
+
warnings.extend(_euler_bones(objects))
|
|
3076
|
+
warnings.extend(_object_only_armature_actions(objects))
|
|
3077
|
+
warnings.extend(_evaluated_bones(objects))
|
|
3078
|
+
warnings.extend(_driven_shape_keys(objects))
|
|
3079
|
+
scene_worst = steps.run("subframeMeasure", lambda: _scene_subframe_worst(
|
|
3080
|
+
objects + [root for _marker, root, _inverse in moving], scene))
|
|
3081
|
+
warnings.extend(_scene_subframe_warnings(scene_worst))
|
|
3082
|
+
world, world_gaps = _world(scene)
|
|
3083
|
+
warnings.extend(world_gaps)
|
|
3084
|
+
volumes, volume_warnings = _object_volumes(objects, visibility)
|
|
3085
|
+
warnings.extend(volume_warnings)
|
|
3086
|
+
for name in volumes:
|
|
3087
|
+
visibility[name] = False
|
|
3088
|
+
runtime, runtime_data, runtime_warnings = _runtime_constraints(objects)
|
|
3089
|
+
warnings.extend(runtime_warnings)
|
|
3090
|
+
cube, lut = steps.run("displayLut", lambda: _display_lut_for(scene, out, luts))
|
|
3091
|
+
hooks = _Hooks(nodes, {"timeline": timeline, "keyRate": _key_rate(scene), "collections": _collection_tree(scene, authoring)},
|
|
3092
|
+
visibility, _node_collections(objects, ancestors), runtime_data,
|
|
3093
|
+
scene_extensions={"STD_display": _display(scene, lut), "STD_environment": world, "STD_render": _render(scene)},
|
|
3094
|
+
volumes=volumes)
|
|
3095
|
+
try:
|
|
3096
|
+
steps.run("gltfExporter", lambda: _with_hooks(hooks, lambda: _muted(runtime, lambda: _export_set(
|
|
3097
|
+
scene, view_layer, exported, lambda: _evaluating_what_exports(
|
|
3098
|
+
view_layer, overrides, exported, lambda: _gltf(out, settings))))))
|
|
3099
|
+
for root, own, path, name in companions:
|
|
3100
|
+
companion = steps.run("placementActions", lambda root=root, own=own, path=path: _export_placement_actions(
|
|
3101
|
+
scene, view_layer, root, own, path))
|
|
3102
|
+
companion_results[name] = companion
|
|
3103
|
+
warnings.extend("placement %s's own actions: %s" % (name, one) for one in companion["warnings"])
|
|
3104
|
+
finally:
|
|
3105
|
+
scene.frame_set(frame, subframe=subframe)
|
|
3106
|
+
finally:
|
|
3107
|
+
for obj in switched:
|
|
3108
|
+
obj.instance_type = "COLLECTION"
|
|
3109
|
+
for marker in made:
|
|
3110
|
+
bpy.data.objects.remove(marker, do_unlink=True)
|
|
3111
|
+
for action in motion:
|
|
3112
|
+
bpy.data.actions.remove(action)
|
|
3113
|
+
if hooks.occluded:
|
|
3114
|
+
attributes = sorted({(row["attribute"], row["strength"]) for row in hooks.occluded.values()})
|
|
3115
|
+
warnings.append("%d material(s) multiply their base colour by a Color Attribute (%s); their base colour is "
|
|
3116
|
+
"carried, the multiply is not (glTF draws only COLOR_0, as a factor of 1)" % (
|
|
3117
|
+
len(hooks.occluded), ", ".join("%r at %g" % pair for pair in attributes)))
|
|
3118
|
+
return _finish("level", out, project, blend_sha, version, settings, {
|
|
3119
|
+
"markers": markers,
|
|
3120
|
+
"components": sorted(components.values(), key=lambda c: (c["file"], c["collection"])),
|
|
3121
|
+
"unsupported": unsupported,
|
|
3122
|
+
"movingPlacements": moving_names,
|
|
3123
|
+
"placementActions": {name: {"file": os.path.basename(path), "actions": sorted(a.name for a in own),
|
|
3124
|
+
"subframeSampling": companion_results[name]["subframeSampling"]}
|
|
3125
|
+
for _root, own, path, name in companions},
|
|
3126
|
+
"hiddenNodes": hooks.used_visibility,
|
|
3127
|
+
# The base colours put back from behind a Color Attribute multiply (`_occluded_base`), by material.
|
|
3128
|
+
"occludedBaseColors": hooks.occluded,
|
|
3129
|
+
"warnings": warnings,
|
|
3130
|
+
"display": {"lut": lut["key"], "sha256": _lut_sha(out, lut["key"], luts)},
|
|
3131
|
+
"runtimeConstraints": sum(len(c) for c in runtime.values()),
|
|
3132
|
+
"sceneBetweenFrames": {"degrees": round(scene_worst[0][0], 4), "degreesAt": list(scene_worst[0][1:]),
|
|
3133
|
+
"metres": round(scene_worst[1][0], 6), "metresAt": list(scene_worst[1][1:])},
|
|
3134
|
+
}, started, ([cube] if cube else []) + [path for _root, _own, path, _name in companions], steps)
|
|
3135
|
+
|
|
3136
|
+
|
|
3137
|
+
def export(kind, out, project=None, force=False, previous=None, blend_sha=None, luts=None):
|
|
3138
|
+
"""Export the open `.blend` as a component or a level to `out` (a `.glb`), its manifest beside
|
|
3139
|
+
it, and answer `{current, ms, manifest}`; `current` when the earlier export still stands.
|
|
3140
|
+
`blend_sha` is the `.blend`'s SHA-256 when the caller holds the file (the editor, where Blender
|
|
3141
|
+
reads the document from a stream and keeps no copy); without it the file is read here."""
|
|
3142
|
+
if not bpy.data.filepath:
|
|
3143
|
+
raise RuntimeError("Save the model as a .blend in the project before exporting it.")
|
|
3144
|
+
if kind not in ("component", "level"):
|
|
3145
|
+
raise ValueError("kind is component or level, not %r" % (kind,))
|
|
3146
|
+
run = export_component if kind == "component" else export_level
|
|
3147
|
+
return _kept_document(bpy.context.scene, lambda: run(out, project, force, previous, blend_sha, luts))
|
|
3148
|
+
|
|
3149
|
+
|
|
3150
|
+
if __name__ == "__main__" and "--" in sys.argv:
|
|
3151
|
+
arguments = sys.argv[sys.argv.index("--") + 1:]
|
|
3152
|
+
kind, out = arguments[0], os.path.abspath(arguments[1])
|
|
3153
|
+
project = arguments[2] if len(arguments) > 2 and not arguments[2].startswith("--") else None
|
|
3154
|
+
answer = export(kind, out, project, force="--force" in arguments)
|
|
3155
|
+
manifest = answer["manifest"]
|
|
3156
|
+
print("@@GLTF-EXPORT " + json.dumps({"current": answer["current"], "ms": answer["ms"], "steps": answer.get("steps", {}), "file": manifest["file"],
|
|
3157
|
+
"bytes": manifest["bytes"], "nodes": manifest["nodes"],
|
|
3158
|
+
"animations": len(manifest["animations"]),
|
|
3159
|
+
"markers": len(manifest.get("markers", [])),
|
|
3160
|
+
"unsupported": len(manifest.get("unsupported", [])),
|
|
3161
|
+
"warnings": manifest.get("warnings", [])}))
|