【翻译】用 React 操控 Blender 中的 3D 场景

11 阅读5分钟

原文链接Driving 3D scenes in Blender with React

作者Roman Liutikov,

本文中,我将探索一个有趣的想法:利用 React 在 Blender 中创建并管理 3D 场景。该项目会在 Blender 中嵌入 JavaScript 引擎,并实现一个自定义的 React 协调器,将 React 组件映射为 Blender 对象。

image.png 视频链接:cdn.romanliutikov.com/videos/reac…

此前,基于静态 Hermes 引擎,通过自定义 React 协调器原生渲染 ImGui 界面,在这个过程中我萌生了另一个有趣的想法 —— 能否借助 React 声明式的结构描述方式,来管理 Blender 中的 3D 场景?

整体架构

Blender 是一款基于 Python 开发的应用,开发者可以用 Python 编写插件并调用 Blender 的 API。这意味着我们能在插件中嵌入 JavaScript 解释器,在其中运行 React 代码,再通过解释器调用暴露出来的 Python API。

我选择了 QuickJS 作为嵌入的 JS 引擎,因为它可以通过 pip 包管理器直接安装,是一款轻量且高效的 JavaScript 引擎。Blender 的 Python 插件会加载 QuickJS,并通过命令接口将 Blender API 暴露给 JavaScript 环境;而在这之上,React 会配合自定义协调器运行,将 React 的各类操作转化为 Blender 命令。

自定义协调器

如果你曾好奇 React 为何能渲染到不同的目标载体(DOM、原生移动端视图、终端界面等),答案就是协调器(Reconciler) 。React 核心本身并不知晓 DOM 元素或 Blender 对象的存在,它仅负责管理组件树,并告知协调器何时执行创建、更新或删除操作。

可以将 React 理解为一个虚拟机的接口,它能把声明式代码转化为一系列命令式操作;而开发者需要为想要适配的任意目标载体,实现这个接口的具体逻辑。

针对这款 Blender 版 React,协调器的核心作用是将 React 操作转化为命令并发送至 Python 层,示例代码如下:

// 当 React 创建新元素时
createInstance(type, props) {
  const node = new BlenderNode(type, props);
  // 向 Python/Blender 发送命令
  sendCommand({
    type: "create_primitive",
    shape: type, // 几何体类型,如"cube"(立方体)、"sphere"(球体)等
    name: node.id,
    location: props.position, // 位置
    rotation: props.rotation, // 旋转角度
    scale: props.scale, // 缩放比例
  });
  return node;
}

// 当 React 更新组件属性时
commitUpdate(instance, type, oldProps, newProps) {
  if (newProps.position !== oldProps.position) {
    sendCommand({
      type: "set_transform",
      name: instance.blenderName,
      location: newProps.position,
    });
  }
}

Python 层接收到这些命令后,会调用对应的 Blender API 函数执行操作:

elif kind == "create_primitive":
    shape = cmd["shape"]
    if shape == "cube":
        bpy.ops.mesh.primitive_cube_add(
            location=loc,
            rotation=rot,
            scale=scl
        )
    elif shape == "sphere":
        bpy.ops.mesh.primitive_uv_sphere_add(
            location=loc,
            rotation=rot,
            scale=scl
        )
    # 其他几何体类型的创建逻辑...

用组件定义 3D 场景

实现协调器后,我们就能用熟悉的 React 写法来定义 Blender 场景了,以下是一个简单的示例:

(ns app.core
  (:require [uix.core :as uix :refer [$ defui]]
            [blender.client :as bc]))

(defui app []
  ($ :<>
    ($ :cube {:position #js [0 0 0]})
    ($ :sphere {:position #js [3 0 0]})
    ($ :camera {:position #js [10 -10 8]
                :rotation #js [1.0 0 0.8]})))

(defn init []
  (bc/render #'app))

几行代码,就创建出了一个立方体、一个球体和一台摄像机。其中$宏是 UIx 框架创建 React 元素的方式,:cube:sphere这类关键字会被映射为 Blender 的基础几何体类型。

我在 React 之上使用了 ClojureScript 开发,因为 ClojureScript 的热重载功能体验极佳,而这一功能对于交互式的探索式编程至关重要。在相关演示视频中,你能看到修改代码时,Blender 中的对象会实时同步更新。

将材质作为子组件

在这个模型中,一个非常自然的设计是将材质作为网格对象的子组件

;; 红色金属质感的立方体
($ :cube {:position #js [0 0 0]}
  ($ :material {:color #js [1 0.2 0.2]
                :metallic 0.8
                :roughness 0.2}))

;; 绿色自发光球体
($ :sphere {:position #js [3 0 0]}
  ($ :material {:color #js [0.1 0.1 0.1]
                :emission #js [0 1 0]
                :emissionStrength 5.0}))

当材质组件作为网格对象的子组件被添加时,协调器会自动将该材质分配给父网格对象,这一逻辑在appendInitialChild钩子中实现:

appendInitialChild(parent, child) {
  parent.children.push(child);
  child.parent = parent;

  if (isMaterialType(child.type)) {
    assignMaterialToParent(child);
  }
}

借助 Hooks 实现动画

由于我们运行的是完整的 React 环境,React Hooks 可以直接使用。以下是通过requestAnimationFrame实现立方体旋转动画的示例:

(defhook use-frame [f]
  (let [f* (uix/use-effect-event f)]
    (uix/use-effect
      (fn []
        (let [raf-id (atom nil)
              time (atom (js/getTime))
              animate (fn animate [t]
                        (f* (- t @time))
                        (reset! time t)
                        (reset! raf-id (js/requestAnimationFrame animate)))]
          (reset! raf-id (js/requestAnimationFrame animate))
          #(js/cancelAnimationFrame @raf-id)))
      [])))

(defui spinning-cube []
  (let [[rotation set-rotation] (uix/use-state 0)]
    (use-frame
      (fn [dt]
        (set-rotation + (* dt 0.005))))
    ($ :cube {:position #js [0 0 0]
              :rotation #js [0 0 rotation]}
      ($ :material {:color #js [0 0.5 0.2]
                    :metallic 0
                    :roughness 0.2}))))

这里的use-frame钩子与 react-three-fiber 中的同名钩子功能相似:它会提供一个回调函数,该函数在每一帧都会执行并传入时间差(delta time),开发者可以在回调中更新组件状态。

立方体能够实现平滑旋转的核心原因是:每次状态更新都会触发组件重渲染,进而以恒定的帧率更新 Blender 对象的旋转属性

在 Python 层,requestAnimationFrame功能是通过 Blender 的定时器系统实现的:

def request_animation_frame(callback):
    raf_id = _next_raf_id[0]
    _next_raf_id[0] += 1

    _raf_callbacks[raf_id] = callback
    _pending_rafs.append((raf_id, callback))

    if not _raf_running[0]:
        _raf_running[0] = True
        bpy.app.timers.register(raf_loop, first_interval=1/60)

    return raf_id

几何节点的组件化实现

这部分的实现让整个方案变得更有价值。Blender 的几何节点是一套强大的程序化建模系统,将其封装为 React 组件后,能实现非常灵活的组合式建模逻辑。

;; 在平面网格上散布立方体
($ :plane {:position #js [0 0 0]}
  ($ :geometryNodes
    ($ :meshGrid {"Size X" 5 "Size Y" 5
                  "Vertices X" 20 "Vertices Y" 20})
    ($ :instanceOnPoints
      {:Instance ($ :meshCube {:Size #js [0.1 0.1 0.1]})})))

注意代码中_meshCube作为属性传递给_instanceOnPoints的写法:协调器会检测到属性中的 React 元素,并自动创建对应的几何节点,同时将其连接到正确的输入插槽。

对于连续的建模操作,几何节点还能自动链式连接

;; 创建螺旋管状模型
($ :cube {:position #js [0 0 0]}
  ($ :geometryNodes
    ($ :curveSpiral {:Rotations 3 :Height 2.0})
    ($ :curveToMesh {"Profile Curve" ($ :curveCircle {:Radius 0.1})})))

协调器会遍历几何节点的子组件,在 Blender 的节点编辑器中创建对应的节点,并自动完成节点之间的连线:生成器节点连接到处理器节点,最后一个节点则连接到输出节点。

父子层级关系

React 的组件树可以自然地映射为 Blender 的对象层级关系:

($ :empty {:position #js [0 0 3]}
  ;; 子对象的位置相对父对象计算
  ($ :cube {:position #js [1 0 0]}
    ($ :material {:color #js [1 0 0]}))
  ($ :sphere {:position #js [-1 0 0]}
    ($ :material {:color #js [0 0 1]})))

当移动这个空对象(Empty)时,其下的立方体和球体会随之一起移动。每当一个组件作为子组件被挂载时,协调器都会调用 Blender 的set_parent接口完成父子关系绑定:

elif kind == "set_parent":
    child_name = cmd["child"]
    parent_name = cmd.get("parent")

    child_obj = bpy.data.objects.get(child_name)
    if parent_name:
        parent_obj = bpy.data.objects.get(parent_name)
        child_obj.parent = parent_obj

总结

这是又一次有趣的探索,验证了 “将 React 作为虚拟机” 的理念,能被应用到各种意想不到的环境中。本项目的源码已开源在 GitHub:github.com/roman01la/b…,欢迎大家体验!