阅读视图

发现新文章,点击刷新页面。

从摄影新手到三维光影师:Three.js 核心要素的故事

当我第一次学习摄影时,老师告诉我一句话:

“你不是在拍东西,而是在拍光。”

后来我学习 Three.js 时突然意识到:
这句话原来依旧成立。

Three.js 不只是一个 3D 引擎,更像是一台虚拟相机。要拍好这张“虚拟的照片”,我们必须掌握三个核心要素:

场景(Scene)

相机(Camera)
灯光与材质(Light & Material)

于是,我把学习过程想象成一个摄影新手成长为三维光影师的故事。

空无一物的影棚 —— Scene 场景

故事从一个空影棚开始。

当我第一次打开 Three.js 时,教程告诉我:

const scene = new THREE.Scene();

这就像摄影师走进了一个空旷的工作室:
没有布景、没有模特、没有灯光,甚至连相机都还没架好, 在影棚这个场景中,摄影师可以在这个场景中放任何的东西:

  • 架好摄像机(Camera 📹)
  • 拍照的物体(Mesh 网格物体)、物体拥有着自己的形状(Geometry几何体)和材质(Material)
  • 摆设好灯光(Light)
  • 也可以是任意的对象 (Object3D)

摄影师往 Scene 里布置道具,而程序员的你往 Scene 里添加各种对象,因此 场景就是一个可以放任何东西的容器

找到你要观看的角度 —— Camera 相机

刚学摄影时,我最常做的事情,就是移动、蹲下、趴着、绕圈……
只为了找到一个“对的角度”。

Three.js 的相机就是你的眼睛。创建相机就像准备拍摄时拿起单反:

const camera = new THREE.PerspectiveCamera(const camera = new THREE.PerspectiveCamera(
  50, // 相机视野角度,摄像机的视野角度越大,摄像机看到的场景就越大,反之越小
  window.innerWidth / window.innerHeight, // 宽高比
  0.1, // 近平面(近端渲染距离),指定从距离相机多近的位置开始渲染,推荐默认值0.1
  1000 // 远平面(远端渲染距离)指定摄像机从它所在的位置最远能看到多远,太小场景中的远处物体会看不见,太大会浪费资源影响性能,推荐默认值1000
);

// 2.1 设置相机的位置,放在不同的位置看到的风景当然不一样
camera.position.set(5, 10, 10); // x, y, z

camera.lookAt(0, 0, 0); // 设置相机方向(这就是你女朋友让你找最佳角度的原因)

摄影师会说:“我走两步,让模特在背景中更突出。”
程序员会说:

camera.position.z = 3;
camera.lookAt(0, 0, 0)

本质完全一样:
都是在调整观察世界的方式。

让世界真正亮起来 —— Light & Material 灯光与材质

你可以有再漂亮的模特、再好的相机,如果没有光——
一切都会变成漆黑一片。

Three.js 也是如此。你搭了一个完美的 3D 模型,如果没有光,它看起来只是纯黑。

于是我制作“虚拟布光”:

const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 10, 5);
scene.add(light);

摄影师打灯,而我在代码里放置光源:

  • DirectionalLight(平行光)= 太阳光
  • PointLight(点光源)= 想象灯泡发光,由点向八方发射
  • SpotLight(聚光灯)= 舞台灯,从上打下来,呈现圆锥体,它离光越远,它的尺寸就越大。这种光源会产生阴影
  • AmbientLight(环境光)= 影棚柔光,环境光没有特定的来源方向,不会产生阴影

同时材质(Material)也等同于现实世界的“被光击中时的反应”:

  • 皮肤 = standard material
  • 金属 = metalness 高
  • 塑料 = roughness 较高
  • 玻璃 = transparent=True + envMap

想要一个皮肤质感的物体?
那么你就得给材质加入 roughness、metalness、normalMap 就像摄影师在打柔光,为人物皮肤创造质感。

光与材质的搭配,就是 Three.js 里的“布光艺术”。

最终章:按下快门 —— Renderer 渲染器

当场景布好、相机调好、灯光到位后——
摄影师要做的就是按下快门。

在 Three.js 里:

renderer.render(scene, camera);

渲染器就是那个“快门”,
真正把世界投射到屏幕上。

摄影师用快门把现实世界的光记录下来;
Three.js 用 GPU 把虚拟世界的光影计算出来。

本质上,两者做的是同一件事:

把真实或虚拟的三维世界,投射成一张二维图像。

import * as THREE from "three";

// 1. 创建场景
const scene = new THREE.Scene();

// 2. 创建相机(透视投影相机)
const camera = new THREE.PerspectiveCamera(
  50, // 相机视野角度,摄像机的视野角度越大,摄像机看到的场景就越大,反之越小
  window.innerWidth / window.innerHeight, // 宽高比
  0.1, // 近平面(近端渲染距离),指定从距离相机多近的位置开始渲染,推荐默认值0.1
  1000 // 远平面(远端渲染距离)指定摄像机从它所在的位置最远能看到多远,太小场景中的远处物体会看不见,太大会浪费资源影响性能,推荐默认值1000
);

// 2.1 设置相机的位置
camera.position.set(5, 10, 10); // x, y, z

camera.lookAt(0, 0, 0); // 设置相机方向(默认看向场景原点)

// 3. 创建渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true }); // 开启抗锯齿,使边缘更平滑
// 3.1 设置渲染器的大小
renderer.setSize(window.innerWidth, window.innerHeight);
// 3.2 将渲染器的canvas内容添加到body
document.body.appendChild(renderer.domElement);

// 4. 创建一个立方体几何体
const geometry = new THREE.BoxGeometry(4, 4, 4); // 宽、高、深

// 为了让光源有效果,我们使用 MeshLambertMaterial 或 MeshPhongMaterial
//  创建材质 MeshLambertMaterial (兰伯特材质) 是一种非光泽材质,会受光照影响,但没有镜面高光
const material = new THREE.MeshLambertMaterial({
  color: 0x00ff00, // 颜色
  // wireframe: true, // 如果需要线框效果可以加上
});

// 6. 创建一个网格模型(网格模型由几何体和材质组成)
// Mesh 构造函数通常只接受一个材质。如果需要多材质,Three.js 有专门的 MultiMaterial 或 Group 来处理
const cube = new THREE.Mesh(geometry, material); // 使用 MeshLambertMaterial

// 6.1 将几何模型添加到场景中
scene.add(cube);

// 6.2 设置相机看向物体(拍摄对象)的位置(默认状态下相机看向的是场景的原点(0,0,0))
camera.lookAt(cube.position);

// 7. 创建光源
const spotLight = new THREE.SpotLight(0xffffff); // 创建聚光灯,颜色为白色
// 7.1 设置光源的位置
spotLight.position.set(0, 20, 20); // 调整光源位置,使其能够照亮立方体
// 7.2 设置光源照射的强度,默认值为1, 越大越亮
spotLight.intensity = 2;
// 7.3 将光源添加到场景中
scene.add(spotLight);

// 8. 为了方便观察 3D 图像,添加三维坐标系对象
const axesHelper = new THREE.AxesHelper(6); // 参数表示坐标系的大小 (x轴红色, y轴绿色, z轴蓝色)
scene.add(axesHelper); // 将坐标系添加到场景中

// 9. 渲染函数
function animate() {
  requestAnimationFrame(animate); // 请求再次执行渲染函数animate,形成循环

  // 让立方体动起来
  cube.rotation.x += 0.01; // 沿x轴旋转
  cube.rotation.y += 0.01; // 沿y轴旋转
  cube.rotation.z += 0.01;

  renderer.render(scene, camera); // 使用渲染器,通过相机将场景渲染出来
}

animate(); // 执行渲染函数,进入无限循环,完成渲染

Electron 的打包

Electron Forge 是 Electron apps 一体化的 打包和分发工具,及将开发好的 Electron 应用打包为可安装,可执行的文件,最终交付给终端用户的核心过程

如何打包(mac 为🌰)

  1. 安装 Electron Forge's CLI 到 devDependencies,并且使用脚本将项目导入到 Forge 中
// 安装 Electron Forge's CLI 到 `devDependencies`
npm install --save-dev @electron-forge/cli

// 将项目导入到 Forge 中
npx electron-forge import

一旦将项目导入到 Forge 的脚本完成了,Forge 将会添加一些脚本到 your package.json

//...
"scripts": {
    "start": "electron-forge start",
    "package": "electron-forge package",
    "make": "electron-forge make"
},
//...
  1. 创建分发文件
// 执行这个脚本的时候将会根据当前系统来打包对应的可运行文件,
// 比如 mac 在项目的 out/make/zip/darwin/arm64/$packageName-darwin-arm64-1.0.0.zip 中
pnpm run make
  1. 打开对应的文件夹,解压 zip 文件,双击文件即可

image.png

image.png

打包的问题

  1. 打包不同的格式来适应不同的机器

答:使用 GitHub Actions / CI 生成在不同的 runner 生成不同的包,比如 Windows 的就使用 Windows 的 runner

  1. 如何自定义 icon 答:直接参考 www.electronforge.io/guides/crea… ,需要注意的是要为不同的系统准备不同的 icon 后缀,配置的时候不要指定 icon 的后缀
// 比如我是将文件放到 public/images 中
public
  - images
     - icon.icns
     - icon.xxx

// forge.config.js
module.exports = {
  // ...
  packagerConfig: {
    icon: 'public/images/icon' // no file extension required
  }
  // ...
};

image.png

最终结果

image.png

  1. 怎么代码签名 代码签名是发布桌面应用程序的重要环节,并且在自动更新步骤中是必不可少的,需要去对应的系统申请不同的认证方式,macOS 的 TEAM_ID 需要钱,如有需要自行申请

Electron - IPC 解决主进程和渲染进程之间的通信

Electron 的主进程是一个 Node.js 环境,因此 主进程可以使用 Node.js 的内置模块以及相关 Node.js 环境的 npm 安装包,主进程拥有对操作系统的完全访问权限。 但是渲染器进程默认运行在网页端而不是运行 Node.js, 为了将 Electron 的不同进程类型桥接在一起,我们需要使用一个称为 preload 的特殊脚本

注意的是:从 Electron 20 开始,preload 脚本默认被沙箱化,并且不再能够访问完整的 Node.js 环境。这意味着您只能访问一组有限的 API, 代码参考: github.com/kejuqu/elec…

BrowserWindow's preload 脚本运行在既有能访问 HTML DOM 又有部分 Node.js 和 Electron 子集功能, 详细的区别:

可用的 API 详细
Electron modules 渲染器进程模块
Node.js modules eventstimersurl
Polyfilled globals BufferprocessclearImmediatesetImmediate

Preload 脚本在渲染器页面加载前被注入,如果要为渲染器添加需要特权访问的功能可以通过contextBridge API 来定义 全局对象 

暴露 node.js 模块的信息到页面(renderer 进程)

contextBridge.exposeInMainWorld 在主进程和渲染进程之间建立一个安全的 “通道”,将主进程指定的 API/变量/函数暴露到渲染进程的 全局对象(window) 对象上,供前端直接调用。 将 process.versions 的 node, chrome, electron 暴露到 renderer 进程

// 新建 src/preloads/versions.js
import { contextBridge } from "electron";

// contextBridge.exposeInMainWorld(key,value)

// 通过 contextBridge.exposeInMainWorld 向 window 对象暴露一个名为 versions 的对象
contextBridge.exposeInMainWorld("versions", {
  node: () => process.versions.node,
  chrome: () => process.versions.chrome,
  electron: () => process.versions.electron,
  // we can also expose variables, not just functions,
  value: "any",
  ping: () => "pong",
});

为了将 preload 脚本和 renderer 进程,需要在要暴露的 page 创建时,及在创建 BrowserWindow的构造函数指定 webPreferences.preload 的路径

// main.js
const createWindow = () => {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    // 添加在这里
    webPreferences: {
      preload: path.resolve(__dirname, "src", "preloads", "versions.js"),
    },
  });

  win.loadFile("index.html");
};

// 然后在 index.html 中新增
<body>
    <h1>Hello from Electron renderer!</h1>
    <p>👋</p>
    <!-- display windows.versions info -->
    <p id="versions"></p>
    <!-- Import the renderer code here -->
    <script src="./src/renderers/versions.js"></script>
</body>


// src/renderers/versions.js
const versionNode = document.getElementById("versions-content");

// 在 renderer 里可以使用 dom 和其他 web 相关技术
console.log("window.versions: ", window.versions);

versionNode.innerText = `Node.js version: ${window.versions.node()}, Electron version: ${window.versions.electron()}, Chrome version: ${window.versions.chrome()}, ping: ${
  window.versions.ping
}`;

进程间的通信

为了解决主进程不能访问 DOM, web page(渲染进程)不能访问 Node.js API 的问题,Electron 的 ipcMainipcRenderer 模块实现了, 利用 ipcRenderer.invoke(channel: string, ...args: any[]): Promise<any> 可以从 web page 向 主进程发送消息,然后在主进程通过 ipcMain.handle(channel: string, listener: (event: IpcMainInvokeEvent, ...args: any[]) => (Promise<any>) | (any)): void;(handle 或者 ipcMain 下边的对应方法)来处理 ipcRenderer 发送过来的channel

// src/preloads/versions.js
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('versions', {
  node: () => process.versions.node,
  chrome: () => process.versions.chrome,
  electron: () => process.versions.electron,
  ping: () => ipcRenderer.invoke('ping')
  // we can also expose variables, not just functions
})

// main.js
app.whenReady().then(() => {
  createWindow();

  app.on("activate", () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
  
  // 主要添加这个 处理
  ipcMain.handle("ping", async () => {
    return "pong";
  });
});

注意

不要通过 contextBridge.exposeInMainWorld 将 ipcRenderer 这个模块都给暴露到 global 上,因为这样可能会造成 web page 可以发送任意的 IPC 消息给主进程,造成恶意攻击的危害

Electron 第一步

Build cross-platform desktop apps with JavaScript, HTML, and CSS。 这就是 Electron 让开发者用 Web 技术(HTML/CSS/JavaScript)  就能构建出和原生应用(如 Windows 的 .exe、macOS 的 .app)体验一致的桌面软件,无需学习 C++、Swift 等原生开发语言 代码参考: github.com/kejuqu/elec…

Electron 的核心组成部分

主要分成如下三个部分:

组成部分 核心作用 技术依赖 / 本质
1. 主进程(Main Process) 负责管理全局资源和原生能力调用,是应用的入口点。 Node.js 运行时(基于 V8 引擎)
2. 渲染进程(Renderer Process) 应用的 “界面”,负责渲染用户看到的 UI(即 Web 页面),可有多进程。 Chrome 浏览器内核(Blink 引擎)
3. 预加载脚本(Preload Script) 主进程和渲染进程的 “桥梁”,解决两者通信和权限隔离问题。

Electron 的核心逻辑可以理解为:用 Chrome 内核做界面渲染(渲染进程),用 Node.js 做原生能力支撑(主进程),用预加载脚本解决两者的通信和安全问题—— 最终实现 “Web 技术写桌面应用” 的目标,是 Web 开发者切入桌面开发的最低门槛工具

开始 Electron

初始化项目

// 1. 创建一个项目名字为 electron-app
mkdir electron-app && cd electron-app

// 2. 初始化项目,自动创建 package.json
npm init 
// package.json
{
  "name": "electron-app",
  "version": "1.0.0",
  "description": "to develop an electron app",
  "main": "main.js", // 这是需要自己加的, main 为入口文件
  "scripts": {
    "dev": "electron .", // 这是需要自己加的
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "jakequc",
  "license": "MIT",
  "packageManager": "pnpm@10.22.0",
  "devDependencies": {
    "electron": "^39.2.3"
  }
}

// 3. 安装 electron 为开发依赖,因为 electron 不会在运行时用到
pnpm install electron --save-dev

// 4. 在根目录下创建入口文件 main.js,内容为
console.log("Hello from Electron 👋");

// 5. npm run dev
// 控制台出现 Hello from Electron 👋 表示初始化项目成功 🎉🎉🎉

可能遇到或疑惑

为啥 electron 安装到 devDependencies?

Electron 的角色是「开发 / 构建工具」,而非最终产品运行时必需的依赖,Electron 的作用仅体现在开发调试最终打包两个阶段,用户拿到的「桌面应用」里,根本不需要 Electron 本身,也可以说 Electron 被嵌入到了产物中

安装失败

方法1: 可以参考这个 www.electronjs.org/docs/latest… 链接

方法2: 去 # electron always "Electron failed to install correctly, please delete node_modules/electron and try installing again"

// 在你的安装终端执行
node node_modules/electron/install.js

方法3: 更改 nodeLinker 保证安装 npm 包是实际存在在磁盘上的,而不是软链接或者替代的安装策略

// 如果是 pnpm 包管理工具,在项目更目录的 pnpm-workspace.yaml
nodeLinker: "hoisted"

// 如果是 yarn,通过命令自动生成配置
yarn config set nodeLinker node-modules

加载 web 页面到浏览器窗口

Electron 的每个 window 窗口都可以加载一个 本地的 HTML 文件或者是一个远程的地址

创建 web page HTML 文件

恭喜你已经基本搭建了 Electron,现在我们让 Electron 加载 web 页面,我们先从一个 local HTML 文件开始,在根目录下创建一个 index.html 文件

// index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self'"
    />
    <meta
      http-equiv="X-Content-Security-Policy"
      content="default-src 'self'; script-src 'self'"
    />
    <title>Hello from Electron renderer!</title>
  </head>
  <body>
    <h1>Hello from Electron renderer!</h1>
    <p>👋</p>
  </body>
</html>

使用 Electron main.js 来加载 html 文件到 BrowerWindow 中

electron 包使用的模块:

  • app 控制一个应用的事件生命周期
  • BrowserWindow,它用于创建和管理应用程序窗口。每个 web page 就是一个渲染进程,每个渲染进程可以使用 js APIs 和任何前端开发技术,比如 React、Vite 等
// main.js 替换为:
const { app, BrowserWindow } = require('electron')

const createWindow = () => {
  const win = new BrowserWindow({
    width: 800,
    height: 600
  })

  win.loadFile('index.html')
}

app.whenReady().then(() => {
  createWindow()
})

操作系统平台判断

注意在不同的操作系统中应用窗口可能有不同的行为,因此可以借助 process.platform 变量来帮助你条件性的在不同操作系统上做不同的事情, process.platform 有三个值:

  • win32 (Windows)
  • linux(Linux)
  • darwin (macOS)

退出整个应用

在 Windows 和 Linux 关闭所有的 窗口后将会自动退出整个应用,但是 macOs 上需要单独调用 app.quit() 才能实现,此时需要监听 electron 的 app 模块中的 window-all-closed 事件

app.on("window-all-closed", () => {
  // 如果是 macOS 系统,需要显示的调用 app.quit() 才能退出整个应用
  if (process.platform !== "darwin") {
    app.quit();
  }
});

打开一个可运行的窗口

相比之下,macOS 应用通常即使在没有任何窗口打开的情况下也会继续运行。当没有可用窗口时激活该应用,应该打开一个新窗口。当窗口激活的时候可以监听 app 模块的 activate 事件,因为 windows 不能在 ready 事件前创建 BrowserWindow, 所以我们应该在 whenReady 里监听 activate 事件

app.whenReady().then(() => {
  createWindow();

  // windows 必须等待 应用 ready 之后才能创建窗口,当前没有窗口打开时,应该创建一个窗口
  app.on("activate", () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
});
❌