├── screenshot.png ├── LICENSE ├── README.md └── io_b2g.py /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PixelPerfectFOSS/b2g/HEAD/screenshot.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Timur Gafarov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # B2G: export Blender animation to GreenSock! 2 | 3 | This is an experimental Blender 3.4 addon that lets you to save your actions as JavaScript animation code compatible with [GSAP 3](https://greensock.com/gsap/). It works by converting Blender keyframes to GSAP timeline methods such as `fromTo` and `to`. 4 | 5 | For example, from this: 6 | 7 | [![Screenshot](https://raw.githubusercontent.com/gecko0307/b2g/main/screenshot.png)](https://raw.githubusercontent.com/gecko0307/b2g/main/screenshot.png) 8 | 9 | ...you get this: 10 | 11 | ```javascript 12 | tl.fromTo(data["Cube"], 1.0, { y: -3.0 }, { y: 3.0, ease: config.bezierEase(0.5384,0.6506,0.3718,1.7029) }, 0.0); 13 | ``` 14 | 15 | ## Usage 16 | 17 | Install the addon (`io_b2g.py`), create an animation and export it: `Export → GSAP timeline`. 18 | 19 | B2G outputs an ECMAScript module that can be plugged in to the project and used like this: 20 | 21 | ```javascript 22 | import animation from "./blender-animation"; 23 | 24 | const tl = gsap.timeline({ repeat: -1, paused: true }); 25 | animation.create(tl, {}); 26 | tl.play(0); 27 | ``` 28 | 29 | B2G supports all interpolation/easing modes, such as Bezier, Quadratic, Cubic and others. Most of them are converted directly to GSAP's eases and work right out of the box on JS side, except Bezier and Constant. If you use them, you should provide the following configuration to `create` function: 30 | 31 | ```javascript 32 | const config = { 33 | bezierEase: function(x1, y1, x2, y2) { 34 | return CustomEase.create(null, [x1, y1, x2, y2].join(",")); 35 | }, 36 | constantEase: function(x) { 37 | return (x < 1.0)? 0.0 : 1.0; 38 | } 39 | }; 40 | 41 | const tl = gsap.timeline({ repeat: -1, paused: true }); 42 | animation.create(tl, config); 43 | tl.play(0); 44 | ``` 45 | 46 | The code above requires [CustomEase](https://greensock.com/docs/v3/Eases/CustomEase) plugin. 47 | 48 | B2G only animates object properties, it doesn't render your objects. In your rendering code (which can be based on canvas, WebGL, or a third-party graphics engine) you can use animated data exposed by B2G module in the following way: 49 | 50 | ```javascript 51 | import animation from "./blender-animation"; 52 | 53 | function renderCube() { 54 | props = animation.data["Cube"]; 55 | // Use props.x, props.y, props.z, props.rotationX, etc. 56 | } 57 | ``` 58 | 59 | Keys in `animation.data` are Blender object names. 60 | -------------------------------------------------------------------------------- /io_b2g.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import json 3 | import os 4 | import math 5 | import mathutils 6 | from mathutils import Vector, Matrix 7 | from bpy_extras.io_utils import ExportHelper 8 | from bpy.props import StringProperty, BoolProperty, EnumProperty, IntProperty 9 | from bpy.types import Operator 10 | 11 | bl_info = { 12 | 'name': 'B2G', 13 | 'description': 'Export animation to GSAP', 14 | 'author': 'Timur Gafarov', 15 | 'version': (1, 0), 16 | 'blender': (3, 4, 0), 17 | 'location': 'File > Import-Export', 18 | 'warning': '', 19 | 'wiki_url': '', 20 | 'tracker_url': '', 21 | 'support': 'COMMUNITY', 22 | 'category': 'Import-Export' 23 | } 24 | 25 | interpolationToEaseFunc = { 26 | 'CONSTANT': 'config.constantEase', 27 | 'LINEAR': 'linear', 28 | 'BEZIER': 'config.bezierEase', 29 | 'SINE': 'sine.{mode}', 30 | 'QUAD': 'power1.{mode}', 31 | 'CUBIC': 'power2.{mode}', 32 | 'QUART': 'power3.{mode}', 33 | 'QUINT': 'power4.{mode}', 34 | 'EXPO': 'expo.{mode}', 35 | 'CIRC': 'circ.{mode}', 36 | 'BACK': 'back.{mode}', 37 | 'BOUNCE': 'bounce.{mode}', 38 | 'ELASTIC': 'elastic.{mode}' 39 | } 40 | 41 | easingToGsapEaseMode = { 42 | 'EASE_IN': 'in', 43 | 'EASE_OUT': 'out', 44 | 'EASE_IN_OUT': 'inOut' 45 | } 46 | 47 | def interpolationIsDynamicEffect(interpolation): 48 | if interpolation in ['BACK', 'BOUNCE', 'ELASTIC']: 49 | return True 50 | else: 51 | return False 52 | 53 | def blenderEaseToGsapEase(interpolation, easing, back=1.0, amplitude=1.0, period=0.3, bezierPoints=[]): 54 | easeFunc = interpolationToEaseFunc[interpolation] 55 | if easing == 'AUTO': 56 | if interpolationIsDynamicEffect(interpolation): 57 | easing = 'EASE_OUT' 58 | else: 59 | easing = 'EASE_IN' 60 | easeMode = easingToGsapEaseMode[easing] 61 | ease = easeFunc.format(mode=easeMode) 62 | if interpolation == 'BACK': 63 | return '"%s(%s)"' % (ease, back) 64 | elif interpolation == 'BOUNCE': 65 | return '"%s"' % (ease) 66 | elif interpolation == 'ELASTIC': 67 | return '"%s(%s, %s)"' % (ease, amplitude, period) 68 | elif interpolation == 'BEZIER': 69 | return '%s(%s)' % (ease, ','.join(str(p) for p in bezierPoints)) 70 | elif interpolation == 'CONSTANT': 71 | return ease 72 | else: 73 | return '"%s"' % ease 74 | 75 | positionNames = ['x', 'y', 'z'] 76 | rotationNames = ['rotationX', 'rotationY', 'rotationZ'] 77 | scaleNames = ['scaleX', 'scaleY', 'scaleZ'] 78 | 79 | def exportMain(context, path, settings, operator): 80 | scene = bpy.context.scene 81 | fps = scene.render.fps / scene.render.fps_base 82 | playheadFrame = scene.frame_current 83 | scene.frame_set(0); 84 | 85 | data = {} 86 | tweensCode = '' 87 | 88 | for obj in scene.objects: 89 | if obj.animation_data == None: 90 | continue 91 | 92 | action = obj.animation_data.action 93 | 94 | objPosition = obj.matrix_world.to_translation() 95 | objScale = obj.matrix_world.to_scale() 96 | objRotation = obj.matrix_world.to_euler('XYZ') 97 | 98 | data[obj.name] = { 99 | 'x': round(objPosition[0], 4), 100 | 'y': round(objPosition[1], 4), 101 | 'z': round(objPosition[2], 4), 102 | 'rotationX': round(math.degrees(objRotation[0]), 4), 103 | 'rotationY': round(math.degrees(objRotation[1]), 4), 104 | 'rotationZ': round(math.degrees(objRotation[2]), 4), 105 | 'scaleX': round(objScale[0], 4), 106 | 'scaleY': round(objScale[1], 4), 107 | 'scaleZ': round(objScale[2], 4) 108 | } 109 | 110 | isFirstTweenOfProperty = { 111 | 'x': True, 112 | 'y': True, 113 | 'z': True, 114 | 'rotationX': True, 115 | 'rotationY': True, 116 | 'rotationZ': True, 117 | 'scaleX': True, 118 | 'scaleY': True, 119 | 'scaleZ': True 120 | } 121 | 122 | for fcurve in action.fcurves: 123 | propName = '_prop' 124 | print(fcurve.data_path) 125 | if fcurve.data_path == 'location': 126 | propName = positionNames[fcurve.array_index] 127 | elif fcurve.data_path == 'rotation_euler': 128 | propName = rotationNames[fcurve.array_index] 129 | elif fcurve.data_path == 'scale': 130 | propName = scaleNames[fcurve.array_index] 131 | else: 132 | continue 133 | firstKeyframe = True 134 | prevFrame = 0 135 | prevTime = 0 136 | prevValue = 0 137 | prevInterpolation = '' 138 | prevEasing = '' 139 | prevRightHandleFrame = 0 140 | prevRightHandleValue = 0 141 | prevBack = 0 142 | prevAmplitude = 0 143 | prevPeriod = 0 144 | for keyframe in fcurve.keyframe_points: 145 | frame = keyframe.co[0] 146 | time = frame / fps 147 | value = keyframe.co[1] 148 | leftHandleFrame = keyframe.handle_left[0] 149 | leftHandleValue = keyframe.handle_left[1] 150 | if not firstKeyframe: 151 | timePos = round(prevTime, 4) 152 | duration = round(time - prevTime, 4) 153 | startValue = round(prevValue, 4) 154 | endValue = round(value, 4) 155 | if fcurve.data_path == 'rotation_euler': 156 | startValue = round(math.degrees(startValue), 4) 157 | endValue = round(math.degrees(endValue), 4) 158 | bx1 = round(abs(prevRightHandleFrame - prevFrame) / abs(frame - prevFrame), 4) 159 | by1 = round(abs(prevRightHandleValue - prevValue) / abs(value - prevValue), 4) 160 | bx2 = round(abs(leftHandleFrame - prevFrame) / abs(frame - prevFrame), 4) 161 | by2 = round(abs(leftHandleValue - prevValue) / abs(value - prevValue), 4) 162 | gsapEase = blenderEaseToGsapEase(prevInterpolation, prevEasing, 163 | back=round(prevBack, 4), 164 | amplitude=round(prevAmplitude / abs(value - prevValue), 4), 165 | period=round(prevPeriod / fps, 4), 166 | bezierPoints=[bx1, by1, bx2, by2]) 167 | startObj = '{ %s: %s }' % (propName, startValue) 168 | endObj = '{ %s: %s, ease: %s }' % (propName, endValue, gsapEase) 169 | if isFirstTweenOfProperty[propName]: 170 | tweensCode += '\ttl.fromTo(data["%s"], %s, %s, %s, %s);\n' % (obj.name, duration, startObj, endObj, timePos) 171 | isFirstTweenOfProperty[propName] = False 172 | else: 173 | tweensCode += '\ttl.to(data["%s"], %s, %s, %s);\n' % (obj.name, duration, endObj, timePos) 174 | prevFrame = keyframe.co[0] 175 | prevTime = time 176 | prevValue = value 177 | prevInterpolation = keyframe.interpolation 178 | prevEasing = keyframe.easing 179 | prevRightHandleFrame = keyframe.handle_right[0] 180 | prevRightHandleValue = keyframe.handle_right[1] 181 | prevBack = keyframe.back 182 | prevAmplitude = keyframe.amplitude 183 | prevPeriod = keyframe.period 184 | firstKeyframe = False 185 | 186 | dataCode = 'const data = %s;\n\n' % json.dumps(data, indent=4) 187 | timelineFuncCode = 'function create(tl, config) {\n%s}\n\n' % (tweensCode) 188 | exportCode = 'export default {\n\tdata, create\n};\n' 189 | code = dataCode + timelineFuncCode + exportCode 190 | 191 | with open(path, 'w') as file: 192 | file.write(code) 193 | 194 | scene.frame_set(playheadFrame); 195 | 196 | return {'FINISHED'} 197 | 198 | class GSAPExporter(Operator, ExportHelper): 199 | bl_idname = 'gecko0307.b2g' 200 | bl_label = 'Export GSAP animation' 201 | 202 | filename_ext = '.js' 203 | 204 | filter_glob: StringProperty( 205 | default='*.js', 206 | options={'HIDDEN'}, 207 | maxlen=255, # Max internal buffer length, longer would be clamped. 208 | ) 209 | 210 | def execute(self, context): 211 | settings = { 212 | 213 | } 214 | return exportMain(context, self.filepath, settings, self) 215 | 216 | def menuFuncExport(self, context): 217 | self.layout.operator(GSAPExporter.bl_idname, text='GSAP timeline') 218 | 219 | def register(): 220 | bpy.utils.register_class(GSAPExporter) 221 | bpy.types.TOPBAR_MT_file_export.append(menuFuncExport) 222 | 223 | def unregister(): 224 | bpy.utils.unregister_class(GSAPExporter) 225 | bpy.types.TOPBAR_MT_file_export.remove(menuFuncExport) 226 | 227 | if __name__ == '__main__': 228 | register() 229 | --------------------------------------------------------------------------------