简介基于 three.js 的 C 移植版本 three-cpp 资源包面向需要在 C11 环境中完成 3D 渲染工作、或希望研究图形引擎源码的开发者。项目由 jdduke 做了最初的 C 移植目标是尽可能完整地重建 three.js 修订 65 之前的功能当前各 3-x 仓库正处于大规模重构阶段这份代码恰好保留了早期版本的完整结构适合用来梳理场景图、骨骼、光照、着色器等子系统之间的协作关系。压缩包共 394 个文件主体是 289 个头文件和 82 个 C 源文件头文件负责声明接口源文件实现具体逻辑此外还有 CMake 构建脚本、预编译的静态库与动态库、文本说明等整体约 4.28MB。资源覆盖场景图、几何体、材质、灯光、动画、着色器语言等多类模块并按这些主题组织目录方便按需查阅和二次裁切。目前已有 521 人浏览学习。除源码本身资源还附带 GLEW、RapidJSON 依赖查找脚本与可选的 Vagrant 环境配置能显著降低构建门槛帮助使用者在不同操作系统上快速编译运行读者既能通过源码对照理解 three.js 到 C 的移植路径也能直接以此为基础扩展新的三维应用对图形编程学习和项目开发都具有实用价值。1. three.cpp把 three.js 的场景图搬进 C这个继承者到底带来了什么three.cpp 这个项目做的就是一件事把 three.js 里 Object3D、Scene、Camera、Mesh 那一整套场景图逻辑移植到 C再用 OpenGL 后端把它渲染出来。我最早换过去是在一个桌面点云项目里JS 侧构建 50 万顶点会卡住主线程C 里预分配数组几乎无感从那时起就把它当作桌面三维的首选方案。它适合两类人一是做工业可视化、点云预览、仿真后处理这类桌面应用的 C 开发者二是团队已经熟 three.js又不想在重度渲染场景里依赖浏览器 GC 和事件循环。和直接用 OpenGL 相比three.cpp 帮你保留了场景图和材质抽象和 three.js 相比它的资源生命周期、多线程边界、着色器写法全变了。这篇文章从架构继承、环境搭建、API 迁移、避坑到自动化回归一步步拆开希望能让你少走几次我走弯的路。2. 架构继承three.cpp 继承了 three.js 的场景图而不是继承它的内存模型2.1 Object3D 与 Scene父子层级、局部坐标和 world matrix 如何落成 C 代码three.js 能火很大程度是因为 Object3D 那套“父节点 add 子节点、变换自动级联”的模型非常直观。three.cpp 继承的正是这套场景图语义而不是反过来照抄它的原型链。每个可渲染对象都继承自 Object3DObject3D 上有 position、rotation、scale、quaternion还有 children 列表和 parent 指针。#include three/scenes/Scene.hpp #include three/objects/Mesh.hpp #include three/cameras/PerspectiveCamera.hpp #include three/core/Object3D.hpp int setupScene() { auto scene Scene::create(); auto camera PerspectiveCamera::create(45.f, 1.6f, 0.1f, 1000.f); auto mesh Mesh::create(/* geometry, material */); // 节点命名与挂载 mesh-name main_model; scene-add(camera); scene-add(mesh); // 再挂一个子节点变换会跟着父级一起算 auto child Object3D::create(); child-position.set(0.f, 0.5f, 0.f); mesh-add(child); // 手动触发世界矩阵级联更新 scene-updateMatrixWorld(); return 0; }这段代码里最需要注意的是create()的返回类型。three.cpp 的分支很多有的封装了自定义的 SharedPointer有的直接用std::shared_ptr行为都差不多——对象从create()出来之后自带引用计数add()把它挂进场景树。如果拿原始指针去 add生命周期就很容易失控这也是后面避坑章节要重点讲的。参数方面PerspectiveCamera::create(45.f, 1.6f, 0.1f, 1000.f)四个参数分别是视场角、宽高比、近裁剪面、远裁剪面。宽高比这里用了 1.6 来对应 1280×800 的窗口你换窗口尺寸时一定要同步改否则画面会明显拉伸变形。近远裁剪面先设 0.1 到 1000 开大一点定位到具体物体再收紧能减少精度问题。scene-updateMatrixWorld()是个容易漏掉的点。three.js 里 render 会自动调用它但很多 three.cpp 分支不会在 add 之后主动更新世界矩阵。如果你在渲染前要自己做拾取、射线检测或包围盒计算没调这个方法拿到的就是上一次的脏矩阵。我的习惯是在每帧 render 之前无条件调一次损耗很小但能少掉一整个 class 的玄学 bug。2.2 渲染后端GLRenderer 的绑定方式与资源上传责任three.js 的 WebGLRenderer 会帮你处理窗口、上下文、动画循环而 three.cpp 的 GLRenderer 大多数分支只管“绑定已存在的 OpenGL 上下文”窗口创建是外部事情。这意味着你要自己拉起 GLFW 或 SDL自己激活上下文再创建 GLRenderer。#include three/renderers/GLRenderer.hpp #include three/core/Color.hpp void initRenderer() { // 前提GLFW 已经创建窗口并调用了 glfwMakeContextCurrent(window) auto renderer GLRenderer::create(); renderer-setSize(1280, 720); renderer-setPixelRatio(1.f); renderer-setClearColor(Color(0.1f, 0.1f, 0.12f, 1.f)); // 有的分支渲染内部会上传几何体有的分支需要先手动更新 if (needManualUpload) { geometry-update(renderer); } renderer-render(scene, camera); }setPixelRatio在这里不能照抄 web 端习惯。JS 里常见是设 2 来适配高分屏three.cpp 的 GL 上下文由外部窗口创建像素比必须和窗口系统的 scale factor 对齐。桌面端两次渲染之间的坐标系统一旦对不上最常见就是 UI 元素偏移半像素、抗锯齿效果异常。geometry-update(renderer)这一步我把它归为“看文档不如看源码”的典型。老分支要求你先手动上传 BufferGeometry新分支在 render 内部判定了不同 fork 行为差异很大。判断方法很简单把场景里一个立方体跑起来旋转时如果纹理发黑、顶点位置错乱优先怀疑这里没上传成功。维度three.jsthree.cpp 常见实现所有权Scene.add 内部持有引用可达即存活节点树持有但有的 fork 只保存裸指针事件EventDispatcher随时监听信号槽或手动轮询自由度低内存分配JS 引擎托管SharedPointer / shared_ptr 计数多线程主线程模型无内建线程自行设计这张表其实回答了“three.cpp 继承了什么”的核心问题它继承了场景图逻辑和材质体系但资源所有权、线程模型、生命周期这些运行时保障都需要 C 侧自己接回来。你在 JS 里习惯了不关心内存释放到这边每创建一个对象都要想清楚谁持有它、什么时候释放。3. 环境搭建与最小示例让第一个立方体在 OpenGL 窗口里转起来3.1 构建依赖与最小 CMakeGLFW、OpenGL、头文件路径拉一个 three.cpp 源码包下来第一步先不要急着读代码把依赖树理清楚。常见依赖就这么几类窗口与 GL 上下文用 GLFW 或 SDL渲染走 OpenGL纹理读写会带 zlib、libpng 或 stb_image。下面这份 CMake 是以 GLFW 为窗口层的写法如果你是 SDL 分支把find_package(glfw3)换成 SDL2 即可。cmake_minimum_required(VERSION 3.16) project(three_cpp_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(OpenGL REQUIRED) find_package(glfw3 REQUIRED) # 假设 three.cpp 源码在 third_party/three_cpp 下 add_subdirectory(third_party/three_cpp three_cpp_build) add_executable(demo main.cpp) target_include_directories(demo PRIVATE ${THREE_CPP_INCLUDE_DIR}) target_link_libraries(demo PRIVATE three_cpp OpenGL::GL glfw )THREE_CPP_INCLUDE_DIR这个变量名不是每个分支都叫这个有些 fork 的 CMakeLists 直接导出了three_cpp目标那你连 include_directories 都可以省掉。我建议你打开源码包根目录的 CMakeLists.txt搜add_library和target_include_directories花两分钟确认实际导出的目标名比撞运气试错快得多。依赖版本上GLFW 3.3 基本没问题OpenGL 建议至少按 3.3 Core Profile 来编译。不要为了兼容老机器降级到 2.1three.cpp 很多分支的 GLSL 版本都直接写#version 330 core降级了会一片黑屏加编译报错。libpng/zlib 这类系统库如果版本太老纹理加载容易崩在解码阶段优先让包管理器自动拉。3.2 最小示例创建场景、绑定上下文、跑起一个旋转立方体我平时验证一个新环境从来不去跑项目里那些大而全的示例而是自己写一个 80 行的最小 main能转起来就算环境过了。下面这段代码就是验证 three.cpp 环境可用的基准版本。#include three/scenes/Scene.hpp #include three/cameras/PerspectiveCamera.hpp #include three/geometries/BoxGeometry.hpp #include three/materials/MeshBasicMaterial.hpp #include three/objects/Mesh.hpp #include three/renderers/GLRenderer.hpp #include GLFW/glfw3.h int main() { glfwInit(); glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); auto window glfwCreateWindow(800, 600, three_cpp demo, nullptr, nullptr); glfwMakeContextCurrent(window); auto renderer GLRenderer::create(); renderer-setSize(800, 600); auto scene Scene::create(); auto camera PerspectiveCamera::create(45.f, 800.f / 600.f, 0.1f, 1000.f); camera-position.set(0.f, 0.f, 5.f); auto geometry BoxGeometry::create(1.f, 1.f, 1.f); auto material MeshBasicMaterial::create(); auto mesh Mesh::create(geometry, material); scene-add(mesh); while (!glfwWindowShouldClose(window)) { mesh-rotation.y 0.02f; renderer-render(scene, camera); glfwSwapBuffers(window); glfwPollEvents(); } glfwDestroyWindow(window); glfwTerminate(); return 0; }代码执行顺序上有三个关键点glfwMakeContextCurrent 必须在 GLRenderer::create 之前调用场景和相机的创建顺序无所谓但加入场景必须在 render 之前完成每帧结束后的 swapBuffers 不能省否则 GL 双缓冲不会触发呈现。有一个细节我把旋转增量固定成了 0.02f而不是用时间戳驱动原因是验证环境时希望帧率变化不影响观察。真正做交互时要把增量改成基于帧时间的 delta帧率 60 和 144 下旋转速度才不会差两倍。头文件路径需要特别注意。BoxGeometry.hpp在旧分支里可能是CubeGeometry.hpp有些分支连目录结构都不同比如three/geometries/和three/core/混排。遇到编译报错找不到头文件不用慌直接去源码目录里搜class BoxGeometry找到实际位置改 include 路径就行。这个“先搜类名再定路径”的习惯比背头文件路径可靠得多。3.3 主循环与相机控制脱离 requestAnimationFrame 后如何写帧循环浏览器里 requestAnimationFrame 替你管了帧率和对齐C 桌面端没有这个兜底主循环必须自己拉起来。常见的做法是 while 循环加 glfwPollEvents配合帧时间计算控制速度。double lastTime glfwGetTime(); while (!glfwWindowShouldClose(window)) { double now glfwGetTime(); double delta now - lastTime; lastTime now; // 用时间步驱动旋转避免帧率影响表现 mesh-rotation.y 0.5f * static_castfloat(delta); // 键盘控制相机平移 if (glfwGetKey(window, GLFW_KEY_D) GLFW_PRESS) camera-position.x 2.f * static_castfloat(delta); if (glfwGetKey(window, GLFW_KEY_A) GLFW_PRESS) camera-position.x - 2.f * static_castfloat(delta); renderer-render(scene, camera); glfwSwapBuffers(window); glfwPollEvents(); }固定时间步和可变时间步的取舍我的经验是渲染验证用可变时间步玩家交互用固定时间步。如果你做的是工业可视化这种帧率要求稳定的场景直接上固定时间步自己攒一个 accumulator每帧最多 update 两次物理或逻辑多了会导致回弹抖动。4. 从 three.js 迁移到 three.cppAPI 对应、数据流与材质差异4.1 同名 API 的语义变化一张对照表看透迁移成本迁移到这个继承者时最大的错觉是“函数名一样参数一样逻辑就一样”。实际上add、set、on 这些长着相同脸孔的方法在 C 里的语义差别很大。我把最常见的 API 差异整理成了一张表之前带新人迁移时直接用这张表起步。three.jsthree.cpp第一次迁移注意scene.add(mesh)scene-add(mesh)JS 保留引用C 分支有的只存裸指针mesh.position.set(x,y,z)mesh-position.set(x,y,z)行为基本一致注意局部坐标和世界坐标层级material.color.setHex(0xff0000)有的分支写成 setHex有的 setStyle颜色接口命名不统一看分支实现geometry.setAttribute(name, attr)geometry-setAttribute(name, attr)分隔符由点变箭头数据持有语义不同new THREE.TextureLoader().load(url)Texture2D::create(path)同步读文件居多注意 IO 阻塞renderer.render(scene, camera)renderer-render(scene, camera)先激活 GL 上下文否则静默黑屏add的事件行为差异是最典型的。three.js 里 add 之后会触发 added 事件很多业务代码依赖这一点做节点联动。three.cpp 的场景树在大多数分支里没有完整事件派发add 就是单纯挂节点。从 JS 迁过来时先搜一下业务代码里有没有监听 added/removed 相关事件有的话得自己封装通知机制。颜色接口的混乱是另一个上手易踩的点。我见过同一个仓库里有的类用setHex(0xRRGGBB)有的类要传Color对象还有的要传四个 float。读源码的时候统一定位到Color.hpp看清楚你手上这个分支的构造函数和 setter 重载比 IDE 自动补全更可靠。4.2 数据流Float32Array 换成 vector 索引、法线、UV 怎么衔接three.js 到 three.cpp 的数据迁移核心就是 JavaScript 的 TypedArray 到 C 容器这一步。先从业务侧导出顶点数据再灌进 BufferGeometry。下面这段是一个最小三角形面的数据创建。#include three/core/BufferGeometry.hpp #include three/core/Float32BufferAttribute.hpp #include vector #include cstdint void buildTriangleGeometry() { auto geom BufferGeometry::create(); std::vectorfloat positions { -0.5f, -0.5f, 0.f, 0.5f, -0.5f, 0.f, 0.5f, 0.5f, 0.f }; std::vectoruint32_t indices { 0, 1, 2 }; geom-setAttribute(position, Float32BufferAttribute::create(positions, 3)); geom-setIndex(IndexBufferAttribute::create(indices)); geom-computeVertexNormals(); }这里Float32BufferAttribute::create(positions, 3)的第二个参数是分量数位置是 3 分量法线也是 3 分量UV 是 2 分量。这个参数错了渲染时顶点会被读取成错位数据表现是模型扭曲成碎片但不报错排查起来相当难受。我建议你创建 attribute 时就把分量数写成常量并在旁边注释防止后面改几何体时顺手改错。computeVertexNormals()在 three.js 里是调用即得three.cpp 则要看分支有没有实现。如果这个类没有这个方法就得自己遍历三角形并累加法线。更省事的做法是直接在建模软件里导出法线把法线 attribute 一并写入 geometry运行时少算一步性能也会好不少。4.3 材质与着色器内置材质不够用时自定义 Shader 的 uniform 传递方式迁移过来后最常卡壳的是 ShaderMaterial。three.js 的 shader 工程化做得很好chunk 机制、灯光宏、物理材质一应俱全three.cpp 的内置材质也有 Lambert、Phong、Standard 等基础选项但你想做自定义效果时自由度会更高同时也更糙——没有那么多自动生成的 uniform 声明。#include three/materials/ShaderMaterial.hpp auto shaderMat ShaderMaterial::create(); shaderMat-setVertexShader(R( #version 330 core layout(location 0) in vec3 position; uniform mat4 modelViewMatrix; uniform mat4 projectionMatrix; void main() { gl_Position projectionMatrix * modelViewMatrix * vec4(position, 1.0); } )); shaderMat-setFragmentShader(R( #version 330 core uniform vec3 uColor; out vec4 fragColor; void main() { fragColor vec4(uColor, 1.0); } )); shaderMat-uniforms[uColor]-set(Vector3(1.f, 0.2f, 0.2f));自定义 shader 最容易翻车的就是没有自动声明modelViewMatrix和projectionMatrix。three.js 的运行时会在后台把这些矩阵统一注入three.cpp 的分支做得不完整经常需要你手动声明。判断标准很简单材质编译通过但渲染出来的模型是乱的优先检查这两个矩阵有没有声明这是血泪换来的经验。同一个源码包里uniforms的访问方式也有差异有的是 map 加字符串下标有的是方法调用getUniform(uColor)。编译报错时直接去ShaderMaterial.hpp里看公开接口以源码为准不要被网上旧教程带偏。内置材质的needsUpdate标识也同样重要改完材质参数不增标记画面可能一个像素都不变。5. 避坑记录three.cpp 的编译问题与运行期翻车排查5.1 黑屏但不报错最常见的原因是 GL 上下文未激活现象程序正常启动窗口正常弹出帧循环顺利跑起来但渲染区域全黑控制台无任何报错。原因GLRenderer 创建前没有激活窗口的 OpenGL 上下文。很多分支的 renderer 构造函数里不会主动调用 makeCurrent绑定的是创建时刻的暗自状态这时 GL 指令全都丢进了无效上下文。解决确保glfwMakeContextCurrent(window)在GLRenderer::create()之前调用并且每帧 swap 前不要切换上下文。上下文一旦激活不要频繁在不同窗口间切来切去这是玄学黑屏的主要来源之一。5.2 纹理上下颠倒UV 原点约定与图像解码方向不一致现象模型表面的贴图上下颠倒或者左右镜像旋转模型时更明显。原因three.js 的纹理默认 flipY 为 true而 three.cpp 的纹理加载分支普遍直接交给图像库解码规范不统一有的解码后原点在左上有的在左下。UV 和图像坐标系一旦错配翻转就出现了。解决先确定你用的图像库输出原点再显式设置纹理翻转。一般 API 是texture-flipY true或texture-setFlipY(true)具体看分支。调试时固定一张带方向文字的测试图比如在左上角标个字母转起来一眼就能看出方向对不对。5.3 顶点更新后画面没动静BufferGeometry 版本号未更新现象程序运行中修改了顶点坐标或索引数据逻辑上数据已经变了但渲染出来的模型没有任何变化。原因GPU 上传 VBO 时依赖 geometry 的版本号或更新标志你改了 CPU 侧的 std::vector但没通知 geometry“数据已经脏了”渲染器拿到的是缓存旧数据。解决修改 attribute 数据后调用版本更新逻辑常见是geom-attribute(position)-setNeedsUpdate(true)或者直接重新 setAttribute。我建议在修改函数里封装一个updatePositionAttribute()方法每次改完自动 bump 版本避免遗忘。5.4 智能指针悬空崩溃Scene::add() 不一定保存所有权现象Mesh 在某个函数里创建并加入 scene函数结束后程序崩溃在渲染循环的 draw call 里堆栈指向节点访问。原因这里的 add 只是把裸指针或弱引用插进场景树不持有所有权。mesh 离开作用域后智能指针析构场景树还在渲染一块已释放内存。解决确认当前分支里 add 的持有语义。如果只存裸指针就把 mesh 保存在长生命周期的容器里或者用 shared_ptr 显式持有。千万不要图省事在栈上建 Mesh 然后 add 进 scene这是我自己踩得最重的一次坑。5.5 多线程加载纹理后渲染闪烁渲染器线程模型是单线程现象用后台线程加载模型或纹理加载完成后主线程渲染出现间歇性闪黑、纹理内容残缺。原因three.cpp 的多数分支在渲染器内部没有做线程同步后台线程写入纹理缓存时主线程正在读取同一块 GPU 资源形成数据竞争。解决后台线程只做磁盘读取和解析最终纹理上传和 renderer 相关操作全部切回主线程。最简单的做法是加载完成后把结果丢进队列主线程每帧开头消费队列。不要试图给渲染器加锁渲染器不是线程安全的。6. 进阶用法用 Node 绑定做离屏渲染与截图回归验证 three.cpp 的渲染正确性6.1 通过 N-API 绑定跑离屏渲染并输出 PNGthree.cpp 的价值不只是桌面窗口里渲染它还能绑定到服务端 JavaScript 环境做离屏渲染。常见做法是编译成 N-API 扩展把 Scene、Camera、Renderer 对接到 Node 侧这样测试、截图、批量生成预览图都能脱离显示器运行。做自动化回归时这个方案比单纯跑桌面程序可控得多。const three require(three-cpp); three.init(1024, 768); const scene three.createScene(); const camera three.createPerspectiveCamera(45, 1024 / 768, 0.1, 100); const mesh three.createBox(1, 1, 1); scene.add(mesh); camera.position.set(0, 0, 5); three.renderToFile(scene, camera, regression_baseline.png); three.dispose();上面这段接口以常见绑定风格为例具体 bindings 一定以你手头编译出的扩展为准。对比三张截图时我一般固定相机、固定光源、固定模型摆放把环境独立出来。第一次跑出的截图作为 baseline 存档之后每次改动渲染后端或迁移版本都重新出图对比。这种回归方式能抓出大量肉眼难分辨的矩阵运算和坐标翻转问题。6.2 三张图对比判断移植有没有引入坐标与材质偏差截图出来只是第一步怎么比对才是重点。我自己的做法是准备三张图同一场景在 three.js 里的渲染结果、three.cpp 新分支的渲染结果、上一版 three.cpp 的渲染结果。用简单的像素差值脚本算平均绝对误差误差阈值设到 1.5% 以内视为对齐。如果差异集中在暗部基本是清除色或伽马设置不同如果整张图错位先查相机矩阵和宽高比如果只有某个模型颜色偏大概率是材质 uniform 没有对齐。这个排查路径比对着屏幕看半天高效得多。写一个批处理脚本连续出图、连续比对几分钟就能把整个场景数据流验证完。每次拿到新分支时我都会先跑一遍 cube 加固定相机截图和上一版做像素级对比确认基础变换没问题再继续写业务代码。这个习惯救过我很多次也让我在迁移材质、灯光、阴影这些更复杂的特性时能快速定位是哪一层引入的回归。希望帮到你。本文还有配套的精品资源点击获取