原文链接:Driving 3D scenes in Blender with React
本文中,我将探索一个有趣的想法:利用 React 在 Blender 中创建并管理 3D 场景。该项目会在 Blender 中嵌入 JavaScript 引擎,并实现一个自定义的 React 协调器,将 React 组件映射为 Blender 对象。
视频链接: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…,欢迎大家体验!