本篇依然来自于我们的 《前端周刊》 项目!
由团队成员 德育处主任 翻译,他的文章总是能在严肃的技术细节中穿插让人会心一笑的段子,让技术阅读像刷短视频一样丝滑~
欢迎大家 进群 和他聊聊写代码时的奇思妙想🤣 还能第一时间追踪全球最新前端资讯!

本文会一步步拆解如何用 Three.js 的渲染目标实现线框与实体的混合效果,顺便聊聊在 Solid.js 中怎么管理 WebGL 的生命周期。

我做过一个叫 Blackbird 的实验性网站,当时就是靠它熟悉了 Solid.js 里的 WebGL 用法。它讲述了 SR-71 是如何以超级技术细节制造的过程。而咱们这次要聊的线框效果,正好能帮着展示 SR-71 表面下的技术细节,同时又能保留符合网站风格的金属质感外观。
以下是 Blackbird 网站上的效果:

在这篇教程里,我们会从头复刻这个效果:先把模型渲染两遍(一遍实体、一遍线框),再用着色器把两者混合,做出平滑的动画过渡。最终得到的技术很灵活,能用于技术展示、全息效果,或者任何想同时呈现 3D 物体结构和表面的场景。
可以先看看演示效果

这里面有三个核心要素:材质属性、渲染目标,还有一个黑白着色器渐变。咱们这就开始!
在开始之前,需要先了解一下 Solid.js
Solid.js 这框架你可能不常听说,我个人项目已经全换成它了 ------ 一是因为它的开发体验极简到离谱,二是 JSX 这东西实在太好用了。其实这个演示里的 Solid.js 部分完全可以去掉,换成原生 JS 也行。但说不定你用了会爱上它呢🙂
感兴趣的话可以去了解下 Solid.js。
我为何要换 Solid.js ?
简单说:全栈 JSX 支持,还没有 Next、Nuxt 那些条条框框,而且压缩后才 8kb 左右,简直离谱。
稍微技术点说:它用 JSX 写,但不搞虚拟 DOM,所以 "响应式"(类似 React 的 useState
)不会重新渲染整个组件,只会更更新单个 DOM 节点。而且它是同构的,再也不用写 "use client"
了。
搭建场景
实现这个效果不用太复杂的东西:一个网格(Mesh)、相机(Camera)、渲染器(Renderer)和场景(Scene)就行。我用了一个叫 Stage
的基础类(起这名有点像剧场那味儿)来管理初始化时机。
跟踪窗口尺寸的全局对象
调用 window.innerWidth
和 window.innerHeight
会触发文档重排(想了解更多文档重排可以点这里)。所以我把这些值存在一个对象里,只在必要时更新,读取也从这个对象拿,避免直接用 window
导致重排。注意默认值都设为 0
,因为用 SSR(服务端渲染)时 window
会是 undefined
,得等应用挂载、GL 类初始化、window
可用后再设置,不然就会遇到那个经典错误:Cannot read properties of undefined (reading 'window')。
ini
// src/gl/viewport.js
export const viewport = {
width: 0,
height: 0,
devicePixelRatio: 1,
aspectRatio: 0,
};
export const resizeViewport = () => {
viewport.width = window.innerWidth;
viewport.height = window.innerHeight;
viewport.aspectRatio = viewport.width / viewport.height;
viewport.devicePixelRatio = Math.min(window.devicePixelRatio, 2);
};
基础的 Three.js 场景、渲染器和相机
在渲染任何东西之前,我们需要个简单的框架来处理场景设置、渲染循环和 resize 逻辑。与其把这些代码散在多个文件里,不如用一个 Stage 类把相机、渲染器和场景的初始化都包起来。这样管理 WebGL 的生命周期会更方便,尤其是后面要加更复杂的对象和效果时。
kotlin
// src/gl/stage.js
import { WebGLRenderer, Scene, PerspectiveCamera } from 'three';
import { viewport, resizeViewport } from './viewport';
class Stage {
init(element) {
resizeViewport() // Set the initial viewport dimensions, helps to avoid using window inside of viewport.js for SSR-friendliness
this.camera = new PerspectiveCamera(45, viewport.aspectRatio, 0.1, 1000);
this.camera.position.set(0, 0, 2); // back the camera up 2 units so it isn't on top of the meshes we make later, you won't see them otherwise.
this.renderer = new WebGLRenderer();
this.renderer.setSize(viewport.width, viewport.height);
element.appendChild(this.renderer.domElement); // attach the renderer to the dom so our canvas shows up
this.renderer.setPixelRatio(viewport.devicePixelRatio); // Renders higher pixel ratios for screens that require it.
this.scene = new Scene();
}
render() {
this.renderer.render(this.scene, this.camera);
requestAnimationFrame(this.render.bind(this));
// All of the scenes child classes with a render method will have it called automatically
this.scene.children.forEach((child) => {
if (child.render && typeof child.render === 'function') {
child.render();
}
});
}
resize() {
this.renderer.setSize(viewport.width, viewport.height);
this.camera.aspect = viewport.aspectRatio;
this.camera.updateProjectionMatrix();
// All of the scenes child classes with a resize method will have it called automatically
this.scene.children.forEach((child) => {
if (child.resize && typeof child.resize === 'function') {
child.resize();
}
});
}
}
export default new Stage();
再来个好看的网格
场景准备好了,得放个有意思的东西进去渲染。环面结(torus knot)就很合适:曲线多、细节足,能同时展示线框和实体效果。我们先用简单的 MeshNormalMaterial
,开启线框模式,这样能清楚看到它的结构,之后再换成混合着色器版本。
scala
// src/gl/torus.js
import { Mesh, MeshBasicMaterial, TorusKnotGeometry } from 'three';
export default class Torus extends Mesh {
constructor() {
super();
this.geometry = new TorusKnotGeometry(1, 0.285, 300, 26);
this.material = new MeshNormalMaterial({
color: 0xffff00,
wireframe: true,
});
this.position.set(0, 0, -8); // Back up the mesh from the camera so its visible
}
}
关于灯光的小提示

简单起见,我们用了 MeshNormalMaterial,这样就不用折腾灯光了。Blackbird 网站原本的效果用了 6 盏灯,实在太多了 ------ 我那台 M1 Max 的 GPU 渲染复杂模型加实时六点光照时,帧率直接掉到 30fps。后来减到 2 盏灯,视觉上没区别,帧率却能跑到 120fps,舒服多了。Three.js 可不像 Blender,随便加 14 盏灯,然后让电脑渲 12 小时你去睡觉 ------WebGL 里的灯光是有性能代价的🫠
用 Solid JSX 组件把这些包起来
javascript
// src/components/GlCanvas.tsx
import { onMount, onCleanup } from 'solid-js';
import Stage from '~/gl/stage';
export default function GlCanvas() {
// let is used instead of refs, these aren't reactive
let el;
let gl;
let observer;
onMount(() => {
if(!el) return
gl = Stage;
gl.init(el);
gl.render();
observer = new ResizeObserver((entry) => gl.resize());
observer.observe(el); // use ResizeObserver instead of the window resize event.
// It is debounced AND fires once when initialized, no need to call resize() onMount
});
onCleanup(() => {
if (observer) {
observer.disconnect();
}
});
return (
<div
ref={el}
style={{
position: 'fixed',
inset: 0,
height: '100lvh',
width: '100vw',
}}
/>
);
}
在 Solid 里用 let
声明引用,没有专门的 useRef()
函数。信号(signals)是唯一的响应式方式。想了解更多 Solid 的引用用法可以点这里。
然后把这个组件放进 app.tsx
:
javascript
// src/app.tsx
import { Router } from '@solidjs/router';
import { FileRoutes } from '@solidjs/start/router';
import { Suspense } from 'solid-js';
import GlCanvas from './components/GlCanvas';
export default function App() {
return (
<Router
root={(props) => (
<Suspense>
{props.children}
<GlCanvas />
</Suspense>
)}
>
<FileRoutes />
</Router>
);
}
我用的每个 3D 元素都和页面上的特定元素绑定(通常是为了时间线和滚动效果),所以会为每个类创建单独的组件。这样当一个页面上有 5、6 个 WebGL 效果时,管理起来会更清楚。
javascript
// src/components/WireframeDemo.tsx
import { createEffect, createSignal, onMount } from 'solid-js'
import Stage from '~/gl/stage';
import Torus from '~/gl/torus';
export default function WireframeDemo() {
let el;
const [element, setElement] = createSignal(null);
const [actor, setActor] = createSignal(null);
createEffect(() => {
setElement(el);
if (!element()) return;
setActor(new Torus()); // Stage is initialized when the page initially mounts,
// so it's not available until the next tick.
// A signal forces this update to the next tick,
// after Stage is available.
Stage.scene.add(actor());
});
return <div ref={el} />;
}
用 createEffect()
代替 onMount()
:它会自动跟踪依赖(这里是 element
和 actor
),依赖变化时就会执行函数,再也不用写带依赖数组的 useEffect()
了🙃。想了解更多 Solid 的 createEffect
可以点这里。
再来个简单的路由放这个组件:
javascript
// src/routes/index.tsx
import WireframeDemo from '~/components/WiframeDemo';
export default function Home() {
return (
<main>
<WireframeDemo />
</main>
);
}

现在你会看到这样的效果:

给材质切换线框模式
我特别喜欢 Blackbird 网站的线框风格!它很符合故事里那种原型机的感觉,全纹理的模型太 "干净" 了,线框则带点 "粗糙" 和未完成感。在 Three.js 里,差不多任何材质都能这么改成线框:
ini
// /gl/torus.js
this.material.wireframe = true
this.material.needsUpdate = true;

彩虹色环面结从线框切换到实体颜色
但我们想实现的是动态切换模型的部分区域,而不是整个模型。
这时候就该渲染目标登场了。
有意思的部分:渲染目标
渲染目标是个挺深的话题,但简单说就是:屏幕上看到的每一帧都是 GPU 要渲染的内容,在 WebGL 里,你可以把这一帧导出,作为纹理重新用到另一个网格上 ------ 相当于给渲染输出找个 "目标",这就是渲染目标。
我们需要两个这样的目标,所以可以建一个类重复使用。
javascript
// src/gl/render-target.js
import { WebGLRenderTarget } from 'three';
import { viewport } from '../viewport';
import Torus from '../torus';
import Stage from '../stage';
export default class RenderTarget extends WebGLRenderTarget {
constructor() {
super();
this.width = viewport.width * viewport.devicePixelRatio;
this.height = viewport.height * viewport.devicePixelRatio;
}
resize() {
const w = viewport.width * viewport.devicePixelRatio;
const h = viewport.height * viewport.devicePixelRatio;
this.setSize(w, h)
}
}
这东西其实就是个纹理输出,没别的。
现在可以建个类来处理这些输出了。我知道类有点多,但把功能拆成一个个小单元,能更清楚地知道每个部分在干嘛。调试 WebGL 时,那种 800 行的 "意大利面" 大杂烩类简直是噩梦。
javascript
// src/gl/targeted-torus.js
import {
Mesh,
MeshNormalMaterial,
PerspectiveCamera,
PlaneGeometry,
} from 'three';
import Torus from './torus';
import { viewport } from './viewport';
import RenderTarget from './render-target';
import Stage from './stage';
export default class TargetedTorus extends Mesh {
targetSolid = new RenderTarget();
targetWireframe = new RenderTarget();
scene = new Torus(); // The shape we created earlier
camera = new PerspectiveCamera(45, viewport.aspectRatio, 0.1, 1000);
constructor() {
super();
this.geometry = new PlaneGeometry(1, 1);
this.material = new MeshNormalMaterial();
}
resize() {
this.targetSolid.resize();
this.targetWireframe.resize();
this.camera.aspect = viewport.aspectRatio;
this.camera.updateProjectionMatrix();
}
}
现在把 WireframeDemo.tsx
组件里的 Torus
换成 TargetedTorus
:
javascript
// src/components/WireframeDemo.tsx
import { createEffect, createSignal, onMount } from 'solid-js';
import Stage from '~/gl/stage';
import TargetedTorus from '~/gl/targeted-torus';
export default function WireframeDemo() {
let el;
const [element, setElement] = createSignal(null);
const [actor, setActor] = createSignal(null);
createEffect(() => {
setElement(el);
if (!element()) return;
setActor(new TargetedTorus()); // << change me
Stage.scene.add(actor());
});
return <div ref={el} data-gl="wireframe" />;
}
"现在我只看到一个蓝色方块,Nathan,感觉我们在倒退啊,快把那个好看的形状弄回来"。
嘘,这是故意的,相信我!

从 MeshNormalMaterial 到 ShaderMaterial
现在我们可以用 ShaderMaterial
把环面结的渲染结果贴到那个蓝色平面上作为纹理了。MeshNormalMaterial
不支持纹理,而且我们很快也需要用到着色器。在 targeted-torus.js
里删掉 MeshNormalMaterial
,换成这个:
ini
// src/gl/targeted-torus.js
this.material = new ShaderMaterial({
vertexShader: `
varying vec2 v_uv;
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
v_uv = uv;
}
`,
fragmentShader: `
varying vec2 v_uv;
varying vec3 v_position;
void main() {
gl_FragColor = vec4(0.67, 0.08, 0.86, 1.0);
}
`,
});
现在有了个更漂亮的紫色平面,这多亏了两个着色器:
- 顶点着色器(vertex shader)负责处理材质的顶点位置,这个我们就不改了
- 片元着色器(fragment shader)给材质的每个像素分配颜色和属性,这个着色器让每个像素都变成紫色
使用渲染目标纹理
想显示环面结而不是紫色,我们可以通过 uniforms
给片元着色器传一张纹理:
ini
// src/gl/targeted-torus.js
this.material = new ShaderMaterial({
vertexShader: `
varying vec2 v_uv;
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
v_uv = uv;
}
`,
fragmentShader: `
varying vec2 v_uv;
varying vec3 v_position;
// declare 2 uniforms
uniform sampler2D u_texture_solid;
uniform sampler2D u_texture_wireframe;
void main() {
// declare 2 images
vec4 wireframe_texture = texture2D(u_texture_wireframe, v_uv);
vec4 solid_texture = texture2D(u_texture_solid, v_uv);
// set the color to that of the image
gl_FragColor = solid_texture;
}
`,
uniforms: {
u_texture_solid: { value: this.targetSolid.texture },
u_texture_wireframe: { value: this.targetWireframe.texture },
},
});
再给 TargetedTorus
类加个 render
方法(Stage 类会自动调用它):
kotlin
// src/gl/targeted-torus.js
render() {
this.material.uniforms.u_texture_solid.value = this.targetSolid.texture;
Stage.renderer.render(this.scene, this.camera);
Stage.renderer.setRenderTarget(this.targetSolid);
Stage.renderer.clear();
Stage.renderer.setRenderTarget(null);
}
环面结回来了! 我们把图像纹理传给了着色器,它就渲染出了原始效果。

用着色器混合线框和实体材质
在做这个项目之前,着色器对我来说就是黑魔法。这是我第一次在生产环境用它,我平时做前端习惯了 "盒子思维",而着色器是 0 到 1 的坐标体系,理解起来难多了。但我用过 Photoshop 和 After Effects 的图层,这些软件和着色器做的事很像:都是 GPU 计算。这就让事情简单多了 ------ 先想象或画出想要的效果,想想在 Photoshop 里怎么做,再琢磨着色器里该怎么实现。如果你对着色器不熟,从 Photoshop 或 AE 的思路切入,会轻松很多。
填充两个渲染目标
现在我们只通过法线把数据存到了 solidTarget
这个渲染目标里。我们要更新渲染循环,让着色器同时能用到 solidTarget
和 wireframeTarget
。
kotlin
// src/gl/targeted-torus.js
render() {
// Render wireframe version to wireframe render target
this.scene.material.wireframe = true;
Stage.renderer.setRenderTarget(this.targetWireframe);
Stage.renderer.render(this.scene, this.camera);
this.material.uniforms.u_texture_wireframe.value = this.targetWireframe.texture;
// Render solid version to solid render target
this.scene.material.wireframe = false;
Stage.renderer.setRenderTarget(this.targetSolid);
Stage.renderer.render(this.scene, this.camera);
this.material.uniforms.u_texture_solid.value = this.targetSolid.texture;
// Reset render target
Stage.renderer.setRenderTarget(null);
}
这样一来,内部的流程就像这样:

在两个纹理间渐变
我们的片元着色器要加两部分内容:
- smoothstep 会在两个值之间创建线性渐变。UV 坐标范围是 0 到 1,这里用
0.15
和0.65
作为边界(比用 0 和 1 效果更明显)。然后用 uv 的 x 值决定传给 smoothstep 的参数。 vec4 mixed = mix(wireframe_texture, solid_texture, blend);
mix 的作用如其名,按 blend 的比例混合两个值,0.5
就是完美对半分。
ini
// src/gl/targeted-torus.js
fragmentShader: `
varying vec2 v_uv;
varying vec3 v_position;
// declare 2 uniforms
uniform sampler2D u_texture_solid;
uniform sampler2D u_texture_wireframe;
void main() {
// declare 2 images
vec4 wireframe_texture = texture2D(u_texture_wireframe, v_uv);
vec4 solid_texture = texture2D(u_texture_solid, v_uv);
float blend = smoothstep(0.15, 0.65, v_uv.x);
vec4 mixed = mix(wireframe_texture, solid_texture, blend);
gl_FragColor = mixed;
}
`,
搞定,混合效果出来了:

带线框纹理的彩虹色环面结
说实话,静态的看起来有点无聊,我们用 GSAP 加点动画吧。
scala
// src/gl/torus.js
import {
Mesh,
MeshNormalMaterial,
TorusKnotGeometry,
} from 'three';
import gsap from 'gsap';
export default class Torus extends Mesh {
constructor() {
super();
this.geometry = new TorusKnotGeometry(1, 0.285, 300, 26);
this.material = new MeshNormalMaterial();
this.position.set(0, 0, -8);
// add me!
gsap.to(this.rotation, {
y: 540 * (Math.PI / 180), // needs to be in radians, not degrees
ease: 'power3.inOut',
duration: 4,
repeat: -1,
yoyo: true,
});
}
}

感谢
恭喜你,花了这么些时间学会了混合两种材质。不过肯定值得,对吧?至少希望这篇文章能帮你少费点劲搞定两个渲染目标的协调工作。
如果遇到问题可以在 Twitter 联系我
