Canvas3D + Engine Source section: `https://appsudo.com/doc#engine3d-game` This guide contains two engine3d game structures. Use the [3D Engine reference](https://appsudo.com/doc#engine3d) for exact signatures, defaults, properties, and methods. ## Guide map - [Example: envgfx](#example-envgfx) - [Example: worldgfx](#example-worldgfx) - [3D Engine reference](https://appsudo.com/doc#engine3d) ## Example: envgfx envgfx is an interactive sandbox. The user can drag objects, release while moving to throw, tap an object to pop it upward, drag empty space to orbit, and pinch to zoom. Its seven buttons are Ball, Crate, Reset, Grav On or Off, Night or Day, FX On or Off, and Grab Floor or Free. Controller shortcuts are A for ball, X for crate, B for gravity, Y for light, Select for effects, Start for reset, right stick for orbit, and triggers for zoom. ![envgfx preview showing the physics sandbox](/doc/assets/engine3d-envgfx-preview.png) ### Folder structure The host entry file creates the canvas, sets its app directory, target frame rate and multisampling, builds the scene window, exposes `get_gamepad`, and cleans up on close. In `metadata.json`, `icon` names the logo file, `type` declares an app, `tags` categorizes it as a game, `author` supplies the author name, `displayName` supplies the visible title, `scope` marks the app as system scoped, and `version` identifies the release. Logo files provide app artwork. The `env` package separates world construction, interaction, shader packages, and generated model data so each concern can be changed independently. ### `envgfx.py` ```python from appsudo import App from components.canvas3d import Canvas3D from env.world import EnvScene class EnvGfxApp(App): def __init__(self, application_path): App.__init__(self, "EnvGfx", application_path) self.setup() def setup(self): self.ui_view = Canvas3D() self.ui_view.set_app_dir(self.application_path) self.ui_view.set_target_fps(60) self.ui_view.set_msaa(4) self.world = EnvScene(self.ui_view, self.application_path) def get_gamepad(self): return None def on_close(self): self.world.destroy() ``` ### `metadata.json` ```json { "icon": "logo.png", "type": "App", "tags": "Game", "author": "Appsudo", "displayName": "Env Gfx", "scope": "system", "version": "1.0.0" } ``` ### `env/world.py` ```python import math import random from collections import deque from components.extras.engine3d import (BoxCollider, Camera, CustomEffect, EngineWindow, Gradient, Light, Material, MeshRenderer, OrbitCamera, ParticleSystem, PlaneCollider, PostStack, ProceduralSky, RenderTexture, Rigidbody, Spin, SphereCollider, TextureAsset, load_gltf) from components.extras.engine3d.animation import Animator from components.extras.math3d import Vec3 from env.interact import Interaction from env.models import build_tube_glb from env.shaders import packages PALETTE = ["#e03030", "#30c050", "#3060e0", "#e0c030", "#c030e0", "#30c8d0", "#f07820", "#f0f0f0"] WOOD = ["#b08050", "#a06840", "#c09060"] MAX_SPAWNED = 40 GRAVITY_ON = (0.0, -9.81, 0.0) GRAVITY_OFF = (0.0, -0.6, 0.0) SUN_DIR = (5.0, 9.0, 4.0) MONITOR_LAYER = 5 WALL_LAYER = 3 DAY = { "sky": dict(top="#2f61d9", horizon="#b8ccea", bottom="#6a6e78", sun_color="#fff0cc", sun_intensity=1.0, intensity=1.0), "sun": ("#fff2dc", 3.2), "warm": 7.0, } NIGHT = { "sky": dict(top="#050a1e", horizon="#1c2a4c", bottom="#08090e", sun_color="#8fa8ff", sun_intensity=0.2, intensity=0.55), "sun": ("#8aa0e0", 1.5), "warm": 12.0, } def checker_texture(size=256, tiles=16): cell = size // tiles out = bytearray() for y in range(size): for x in range(size): dark = ((x // cell) + (y // cell)) % 2 == 0 v = 150 if dark else 168 out += bytes((v, v + 3, v + 8, 255)) return TextureAsset.from_pixels(size, size, bytes(out), "rgba8", name="env_floor") def layout(w, h, scale, count): pad = 10.0 * scale bh = 46.0 * scale cols = count if w >= h * 1.2 else 4 rows = (count + cols - 1) // cols bw = (w - pad * (cols + 1)) / cols rects = [] top = h - pad - rows * bh - (rows - 1) * pad for i in range(count): r, c = divmod(i, cols) rects.append((pad + c * (bw + pad), top + r * (bh + pad), bw, bh)) return rects class EnvScene(EngineWindow): def __init__(self, canvas, application_path=None): self.camera = None self.interaction = None self.night = False self.gravity_on = True self.fx_on = True self.spawned = deque() self.counter = 0 self.rng = random.Random(7) self.ui_rects = [] self.hud_rect = (0.0, 0.0, 0.0, 0.0) self.scan = None self.sun_light = None self.warm_light = None self.sun_target = DAY["sun"][1] super().__init__(canvas, root=application_path or "") def setup(self, e): for text in packages(): e.assets.shader_text(text) self.build(e) e.add_gui(self.draw_gui) def build(self, e): a = e.assets sc = e.scene self.spawned.clear() self.counter = 0 e.physics.gravity = GRAVITY_ON if self.gravity_on else GRAVITY_OFF sc.skybox = ProceduralSky(sun_dir=SUN_DIR, **(NIGHT if self.night else DAY)["sky"]) post = PostStack.standard() self.scan = CustomEffect("env/scanlines", "ldr") self.scan.enabled = self.fx_on post.add(self.scan) cam = sc.create("cam", None, (0, 4.0, 11.0), Camera(post=post, fov=50)) cam.look_at(Vec3(0, 0.8, 0)) cam.add_component(OrbitCamera((0, 0.8, 0), 12.0, 18.0, 27.0, min_distance=4.0, max_distance=22.0)) self.camera = cam.get_component(Camera) day = NIGHT if self.night else DAY sun = sc.create("sun", None, SUN_DIR, Light(color=day["sun"][0], shadows=True, intensity=day["sun"][1], shadow_distance=28)) sun.look_at(Vec3(0, 0, 0)) self.sun_light = sun.get_component(Light) self.sun_target = day["sun"][1] warm = sc.create("warm", None, (-3.0, 1.6, 2.0), Light(kind="point", color="#ff8844", intensity=day["warm"], range=8.0)) self.warm_light = warm.get_component(Light) floor_mat = Material.pbr("#ffffff", 0.0, 0.75) floor_mat.set("base_map", checker_texture()) sc.create("floor", None, (0, 0, 0), MeshRenderer(a.primitive("plane", width=16, depth=16), floor_mat), PlaneCollider()) for i, (x, z, sx, sz) in enumerate(((0, -8.3, 17, 0.6), (0, 8.3, 17, 0.6), (-8.3, 0, 0.6, 17), (8.3, 0, 0.6, 17))): wall = sc.create(f"wall{i}", None, (x, 1.5, z), BoxCollider((sx, 3.0, sz))) wall.layer = WALL_LAYER sph = a.primitive("sphere", radius=0.5) for i in range(4): m = Material.pbr("#d8a030", i / 3.0, 0.12 + 0.25 * i) sc.create(f"gold{i}", None, (i * 1.25 - 1.9, 0.5, -1.4), MeshRenderer(sph, m), SphereCollider(0.5, bounciness=0.45), Rigidbody(2.0, freeze_rotation=False)) holo = sc.create("holo", None, (3.0, 0.9, -1.4), MeshRenderer(sph, Material("env/hologram")), SphereCollider(0.5, bounciness=0.4), Rigidbody(3.0), Spin((0, 1, 0), 30)) holo.scale = Vec3(1.8, 1.8, 1.8) model = load_gltf(build_tube_glb(), "tube") model.source = None tube = model.instantiate(sc, None, "tube", (-4.4, 0.0, -0.4)) tube.get_component(Animator).play("wave") tube.add_component(BoxCollider((0.6, 2.0, 0.6), center=(0.0, 1.0, 0.0))) tube.add_component(Rigidbody(2.0)) fire = ParticleSystem(300, 80, (0.8, 1.5), (0.8, 1.6), (0.25, 0.4), 0.0, Gradient([(0, "#ffee88ff"), (0.5, "#ff6a20aa"), (1, "#40100000")]), (0, 0.8, 0), 0.4, "cone", 18, 0.1) sc.create("fire", None, (-1.8, 0.1, 3.2), fire) tex = RenderTexture(256, 256, False, True, "env_monitor") rtcam = sc.create("rtcam", None, (0, 14, 0.01), Camera(target=tex, depth=-1, fov=40, clear="color", background="#203040")) rtcam.look_at(Vec3(0, 0, 0)) rtcam.get_component(Camera).culling_mask = ~(1 << MONITOR_LAYER) mon_mat = Material.unlit("#ffffff") mon_mat.set("base_map", tex) mon_mat.double_sided = True mon = sc.create("monitor", None, (4.6, 2.6, 0.4), MeshRenderer(a.primitive("quad", width=2.6, height=2.6), mon_mat)) mon.layer = MONITOR_LAYER mon.rotate(Vec3(0, 1, 0), -28) for i in range(3): self.spawn_crate(e, (0.4 + i * 0.1, 0.45 + i * 0.85, 1.8), False) for i in range(5): self.spawn_ball(e, (-0.8 + i * 0.5, 3.0 + i * 0.9, 1.0 + 0.1 * i), False) self.interaction = cam.add_component(Interaction(self)) def unique(self, prefix): self.counter += 1 return f"{prefix}{self.counter}" def track(self, node): self.spawned.append(node) while len(self.spawned) > MAX_SPAWNED: old = self.spawned.popleft() if self.interaction is not None and self.interaction.held is not None and self.interaction.held.body.node is old: self.interaction.cancel() old.destroy() def random_drop(self): r = self.rng return (r.uniform(-3.0, 3.0), r.uniform(4.0, 6.0), r.uniform(-2.0, 3.0)) def spawn_ball(self, e, pos=None, track=True): r = self.rng s = r.uniform(0.45, 0.8) color = r.choice(PALETTE) mesh = e.assets.primitive("sphere", radius=0.5) node = e.scene.create(self.unique("ball"), None, pos or self.random_drop(), MeshRenderer(mesh, Material.pbr(color, r.choice((0.0, 0.0, 0.8)), r.uniform(0.15, 0.5))), SphereCollider(0.5, bounciness=0.6), Rigidbody(s, freeze_rotation=False)) node.scale = Vec3(s, s, s) if track: self.track(node) return node def spawn_crate(self, e, pos=None, track=True): r = self.rng s = r.uniform(0.8, 1.3) mesh = e.assets.primitive("cube", size=0.8) node = e.scene.create(self.unique("crate"), None, pos or self.random_drop(), MeshRenderer(mesh, Material.pbr(r.choice(WOOD), 0.0, 0.8)), BoxCollider((0.8, 0.8, 0.8), bounciness=0.1), Rigidbody(s * s)) node.scale = Vec3(s, s, s) if track: self.track(node) return node def reset(self): e = self.engine if self.interaction is not None: self.interaction.cancel() e.new_scene() self.build(e) def toggle_gravity(self): self.gravity_on = not self.gravity_on self.engine.physics.gravity = GRAVITY_ON if self.gravity_on else GRAVITY_OFF for body in self.engine.scene.find_components(Rigidbody): body.wake() def toggle_light(self): self.night = not self.night cfg = NIGHT if self.night else DAY sc = self.engine.scene sc.skybox = ProceduralSky(sun_dir=SUN_DIR, **cfg["sky"]) self.sun_light.color = cfg["sun"][0] self.sun_light.intensity = cfg["sun"][1] self.warm_light.intensity = cfg["warm"] def toggle_fx(self): self.fx_on = not self.fx_on self.scan.enabled = self.fx_on def toggle_grab(self): it = self.interaction it.cancel() it.mode = "free" if it.mode == "floor" else "floor" def button(self, ui, text, x, y, w, h, size): hot, clicked = ui._hit(x, y, w, h) color = ui.theme["button_hot" if hot else "button"] ui.panel(x, y, w, h, color) px = size * ui.scale ui.label(text, x + w * 0.5, y + (h - px) * 0.5, size, "#ffffff", "center") return clicked def in_ui(self, x, y): hx, hy, hw, hh = self.hud_rect if hx <= x <= hx + hw and hy <= y <= hy + hh: return True for rx, ry, rw, rh in self.ui_rects: if rx <= x <= rx + rw and ry <= y <= ry + rh: return True return False def draw_gui(self, e): inp = e.input w, h = e.width, e.height ui = e.ui ui.scale = max(1.0, min(w, h) / 480.0) s = ui.scale labels = [ "Ball", "Crate", "Reset", "Grav On" if self.gravity_on else "Grav Off", "Night" if not self.night else "Day", "FX On" if self.fx_on else "FX Off", "Grab: Floor" if self.interaction.mode == "floor" else "Grab: Free", ] rects = layout(w, h, s, len(labels)) self.ui_rects = rects self.hud_rect = (10.0 * s, 10.0 * s, 230.0 * s, 62.0 * s) hx, hy, hw, hh = self.hud_rect ui.panel(hx, hy, hw, hh, "#10141ccc") ui.label(f"FPS {e.time.fps:4.0f}", hx + 10 * s, hy + 6 * s, 22, "#ffffff") ui.label(f"{e.renderer.stats.draws} draws {len(e.scene.find_components(Rigidbody))} bodies", hx + 10 * s, hy + 34 * s, 16, "#cfdcf5") actions = [self.spawn_ball, self.spawn_crate, self.reset, self.toggle_gravity, self.toggle_light, self.toggle_fx, self.toggle_grab] pressed = [False] * len(labels) for i, label in enumerate(labels): x, y, bw, bh = rects[i] pressed[i] = self.button(ui, label, x, y, bw, bh, 17) if pressed[2]: self.reset() return for i, fn in enumerate(actions): if pressed[i]: fn(e) if i < 2 else fn() self.gamepad_actions(e, inp) def gamepad_actions(self, e, inp): if inp.get_button_down("a"): self.spawn_ball(e) if inp.get_button_down("x"): self.spawn_crate(e) if inp.get_button_down("b"): self.toggle_gravity() if inp.get_button_down("y"): self.toggle_light() if inp.get_button_down("select"): self.toggle_fx() if inp.get_button_down("start"): self.reset() ``` ### `env/interact.py` ```python import math from components.extras.engine3d import Component, MeshRenderer, Rigidbody PICK_MASK = 0xFFFF & ~(1 << 3) LIFT = 0.35 STIFFNESS = 14.0 MAX_SPEED = 16.0 THROW_SPEED = 12.0 TAP_TIME = 0.25 TAP_MOVE = 14.0 HIGHLIGHT = "#14305a" ARENA = 7.2 def _xyz(v): if hasattr(v, "x"): return (v.x, v.y, v.z) return (v[0], v[1], v[2]) def _dot(a, b): return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] def _clamp_len(v, m): n = math.sqrt(_dot(v, v)) if n <= m or n < 1e-9: return v k = m / n return (v[0] * k, v[1] * k, v[2] * k) class Held: def __init__(self, body, pid, offset, hold_y, plane_point, now, x, y): self.body = body self.pid = pid self.offset = offset self.hold_y = hold_y self.plane_point = plane_point self.start = now self.x, self.y = x, y self.moved = 0.0 self.gravity = body.use_gravity self.saved = [] class Interaction(Component): execution_order = -50 def __init__(self, window): super().__init__() self.window = window self.held = None self.ui_ids = set() self.mode = "floor" self.now = 0.0 def update(self, dt): eng = self.engine inp = eng.input cam = self.window.camera if cam is None: return self.now += dt w, h = inp.width, inp.height for pid in list(self.ui_ids): if pid in inp.pointers: inp.claimed.add(pid) else: self.ui_ids.discard(pid) for p in list(inp.pointers.values()): if not p.pressed or p.id in self.ui_ids or p.id in inp.claimed: continue if self.window.in_ui(p.x, p.y): self.ui_ids.add(p.id) inp.claimed.add(p.id) elif self.held is None: self._grab(p, cam, w, h) if self.held is not None: self._drag(inp, cam, w, h, dt) def _grab(self, p, cam, w, h): ray = cam.screen_point_to_ray(p.x, p.y, w, h) hit = self.engine.physics.raycast(_xyz(ray.origin), _xyz(ray.direction), 200.0, PICK_MASK) if hit is None: return body = hit.collider.body if body is None or body.kinematic: return node = body.node pos = _xyz(node.world_position) point = _xyz(hit.point) offset = (point[0] - pos[0], point[1] - pos[1], point[2] - pos[2]) held = Held(body, p.id, offset, pos[1] + LIFT, point, self.now, p.x, p.y) body.use_gravity = False body.wake() self._highlight(node, held, True) self.held = held self.engine.input.claimed.add(p.id) def _drag(self, inp, cam, w, h, dt): held = self.held p = inp.pointers.get(held.pid) if p is None: self._release() return inp.claimed.add(p.id) held.moved += abs(p.x - held.x) + abs(p.y - held.y) held.x, held.y = p.x, p.y ray = cam.screen_point_to_ray(p.x, p.y, w, h) o, d = _xyz(ray.origin), _xyz(ray.direction) if self.mode == "floor": n = (0.0, 1.0, 0.0) pt = (0.0, held.hold_y - held.offset[1], 0.0) else: f = _xyz(cam.node.forward) n = (-f[0], -f[1], -f[2]) pt = held.plane_point denom = _dot(n, d) if abs(denom) < 1e-4: return t = _dot(n, (pt[0] - o[0], pt[1] - o[1], pt[2] - o[2])) / denom if t < 0.0 or t > 120.0: return target = (o[0] + d[0] * t, o[1] + d[1] * t, o[2] + d[2] * t) want = [target[0] - held.offset[0], target[1] - held.offset[1], target[2] - held.offset[2]] if self.mode == "floor": want[1] = held.hold_y want[0] = max(-ARENA, min(ARENA, want[0])) want[2] = max(-ARENA, min(ARENA, want[2])) want[1] = max(0.1, want[1]) pos = _xyz(held.body.node.world_position) v = ((want[0] - pos[0]) * STIFFNESS, (want[1] - pos[1]) * STIFFNESS, (want[2] - pos[2]) * STIFFNESS) v = _clamp_len(v, MAX_SPEED) held.body.set_velocity(v) held.body.angular_velocity = [0.0, 0.0, 0.0] def _release(self): held = self.held if held is None: return self.held = None body = held.body if body.node is None: return body.use_gravity = self.window.gravity_on if held.gravity else False self._highlight(body.node, held, False) if self.now - held.start < TAP_TIME and held.moved < TAP_MOVE: body.set_velocity((0.0, 0.0, 0.0)) body.add_impulse((0.0, 5.0 * body.mass, 0.0)) else: v = _clamp_len(tuple(body.velocity), THROW_SPEED) body.set_velocity(v) body.wake() def _highlight(self, node, held, on): if on: for r in node.get_components(MeshRenderer): for m in r.materials: if "emissive" in m.program.properties: held.saved.append((m, m.get("emissive"))) m.set("emissive", HIGHLIGHT) return for m, old in held.saved: m.set("emissive", old) held.saved = [] def on_destroy(self): self.held = None def cancel(self): self._release() ``` ### `env/shaders.py` ```python import json HOLOGRAM = { "name": "env/hologram", "base": "pbr", "mat_fields": {"holo": "vec4"}, "properties": [ {"name": "holo_color", "type": "color", "field": "holo", "index": [0, 3], "default": "#40e0ff"}, {"name": "holo_speed", "type": "float", "field": "holo", "index": 3, "default": 3.0}, ], "varyings": [["h", "float"]], "vertex_hook": "lp.xyz = lp.xyz + vin.normal * 0.02 * sin(frame.time.x * 4.0 + lp.y * 12.0);", "vertex_post": "vout.h = lp.y;", "color_post": "float band = 0.5 + 0.5 * sin(fin.h * 30.0 - frame.time.x * mat.holo.w);\ncolor = mix(color, mat.holo.rgb * 2.5, 0.35 + 0.5 * band);", } SCANLINES = { "kind": "post", "name": "env/scanlines", "samplers": ["src"], "fragment": "float3 c = SAMPLE2D(src, fin.uv).rgb;\nfloat l = 0.94 + 0.06 * sin(fin.uv.y * post.screen.y * 1.5 + post.flip.z * 4.0);\nreturn float4(c * l, 1.0);", } def packages(): return [json.dumps(HOLOGRAM), json.dumps(SCANLINES)] ``` ### `env/models.py` ```python import json import math import struct def build_tube_glb(): rings, seg, height = 18, 14, 2.0 pos, nor, uv, joints, weights = [], [], [], [], [] for r in range(rings + 1): y = r / rings * height for s in range(seg + 1): a = s / seg * math.tau x, z = math.cos(a) * 0.22, math.sin(a) * 0.22 pos += [x, y, z] nor += [math.cos(a), 0.0, math.sin(a)] uv += [s / seg, 1.0 - r / rings] t = y / (height / 2.0) j = min(int(t), 1) f = t - j joints += [j, j + 1, 0, 0] weights += [1.0 - f, f, 0.0, 0.0] idx = [] row = seg + 1 for r in range(rings): for s in range(seg): a, b, c, d = r * row + s, r * row + s + 1, (r + 1) * row + s, (r + 1) * row + s + 1 idx += [a, b, c, b, d, c] n = len(pos) // 3 blob = bytearray() views = [] def add(data, target=None): while len(blob) % 4: blob.append(0) off = len(blob) blob.extend(data) views.append({"buffer": 0, "byteOffset": off, "byteLength": len(data), **({"target": target} if target else {})}) return len(views) - 1 vp = add(struct.pack(f"<{len(pos)}f", *pos), 34962) vn = add(struct.pack(f"<{len(nor)}f", *nor), 34962) vu = add(struct.pack(f"<{len(uv)}f", *uv), 34962) vj = add(bytes(int(v) for v in joints), 34962) vw = add(struct.pack(f"<{len(weights)}f", *weights), 34962) vi = add(struct.pack(f"<{len(idx)}H", *idx), 34963) ibm = [] joint_y = [0.0, 1.0, 2.0] for y in joint_y: ibm += [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, -y, 0, 1] vb = add(struct.pack(f"<{len(ibm)}f", *ibm)) times = [0.0, 0.5, 1.0, 1.5, 2.0] q = lambda deg: (0.0, 0.0, math.sin(math.radians(deg) / 2), math.cos(math.radians(deg) / 2)) r1 = sum((q(d) for d in (0, 35, 0, -35, 0)), ()) r2 = sum((q(d) for d in (0, 50, 0, -50, 0)), ()) vt = add(struct.pack("<5f", *times)) vr1 = add(struct.pack(f"<{len(r1)}f", *r1)) vr2 = add(struct.pack(f"<{len(r2)}f", *r2)) mn = [min(pos[i::3]) for i in range(3)] mx = [max(pos[i::3]) for i in range(3)] acc = [ {"bufferView": vp, "componentType": 5126, "count": n, "type": "VEC3", "min": mn, "max": mx}, {"bufferView": vn, "componentType": 5126, "count": n, "type": "VEC3"}, {"bufferView": vu, "componentType": 5126, "count": n, "type": "VEC2"}, {"bufferView": vj, "componentType": 5121, "count": n, "type": "VEC4"}, {"bufferView": vw, "componentType": 5126, "count": n, "type": "VEC4"}, {"bufferView": vi, "componentType": 5123, "count": len(idx), "type": "SCALAR"}, {"bufferView": vb, "componentType": 5126, "count": 3, "type": "MAT4"}, {"bufferView": vt, "componentType": 5126, "count": 5, "type": "SCALAR", "min": [0.0], "max": [2.0]}, {"bufferView": vr1, "componentType": 5126, "count": 5, "type": "VEC4"}, {"bufferView": vr2, "componentType": 5126, "count": 5, "type": "VEC4"}, ] doc = { "asset": {"version": "2.0", "generator": "envgfx"}, "scene": 0, "scenes": [{"nodes": [0, 3]}], "nodes": [ {"name": "j0", "children": [1]}, {"name": "j1", "translation": [0, 1, 0], "children": [2]}, {"name": "j2", "translation": [0, 1, 0]}, {"name": "tube", "mesh": 0, "skin": 0}, ], "meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1, "TEXCOORD_0": 2, "JOINTS_0": 3, "WEIGHTS_0": 4}, "indices": 5, "material": 0}]}], "materials": [{"name": "skin", "pbrMetallicRoughness": {"baseColorFactor": [0.2, 0.7, 0.3, 1.0], "metallicFactor": 0.0, "roughnessFactor": 0.45}, "doubleSided": True}], "skins": [{"joints": [0, 1, 2], "inverseBindMatrices": 6}], "animations": [{"name": "wave", "channels": [ {"sampler": 0, "target": {"node": 1, "path": "rotation"}}, {"sampler": 1, "target": {"node": 2, "path": "rotation"}}], "samplers": [{"input": 7, "output": 8, "interpolation": "LINEAR"}, {"input": 7, "output": 9, "interpolation": "LINEAR"}]}], "accessors": acc, "bufferViews": views, "buffers": [{"byteLength": len(blob)}], } js_ = json.dumps(doc).encode() while len(js_) % 4: js_ += b" " while len(blob) % 4: blob.append(0) total = 12 + 8 + len(js_) + 8 + len(blob) return struct.pack("