用 WebGL + Solid.js 构建混合材质 Shader
本篇依然来自于我们的 《前端周刊》 项目! 由团队成员 德育处主任 翻译,他的文章总是能在严肃的技术细节中穿插让人会心一笑的段子,让技术阅读像刷短视频一样丝滑~ 欢迎大家和他聊聊写代码时的奇思妙想🤣 还能第一时间追踪全球最新前端资讯! 原文:tympanus.net/codrops/202…  本文会一步步拆解如何用 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')。 ``` // 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 的生命周期会更方便,尤其是后面要加更复杂的对象和效果时。 ``` // 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`,开启线框模式,这样能清楚看到它的结构,之后再换成混合着色器版本。 ``` // 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 组件把这些包起来 ``` // 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 ( ); } ``` 在 Solid 里用 `let` 声明引用,没有专门的 `useRef()` 函数。信号(signals)是唯一的响应式方式。想了解更多 Solid 的引用用法可以[点这里](https://link.juejin.cn/?target=https%3A%2F%2Fdocs.solidjs.com%2Fconcepts%2Frefs)。 然后把这个组件放进 `app.tsx`: ``` // 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 ( ( {props.children} )} > ); } ``` 我用的每个 3D 元素都和页面上的特定元素绑定(通常是为了时间线和滚动效果),所以会为每个类创建单独的组件。这样当一个页面上有 5、6 个 WebGL 效果时,管理起来会更清楚。 ``` // 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 ; } ``` 用 `createEffect()` 代替 `onMount()`:它会自动跟踪依赖(这里是 `element` 和 `actor`),依赖变化时就会执行函数,再也不用写带依赖数组的 `useEffect()` 了🙃。想了解更多 Solid 的` createEffec`t 可[以点这](https://link.juejin.cn/?target=https%3A%2F%2Fdocs.solidjs.com%2Freference%2Fbasic-reactivity%2Fcreate-effect)里。 再来个简单的路由放这个组件: ``` // src/routes/index.tsx import WireframeDemo from '~/components/WiframeDemo'; export default function Home() { return ( ); } ```  现在你会看到这样的效果:  ## 给材质切换线框模式 我特别喜欢 Blackbird 网站的线框风格!它很符合故事里那种原型机的感觉,全纹理的模型太 “干净” 了,线框则带点 “粗糙” 和未完成感。在 Three.js 里,差不多任何材质都能这么改成线框: ``` // /gl/torus.js this.material.wireframe = true this.material.needsUpdate = true; ```  彩虹色环面结从线框切换到实体颜色 但我们想实现的是动态切换模型的部分区域,而不是整个模型。 这时候就该渲染目标登场了。 ## 有意思的部分:渲染目标 渲染目标是个挺深的话题,但简单说就是:屏幕上看到的每一帧都是 GPU 要渲染的内容,在 WebGL 里,你可以把这一帧导出,作为纹理重新用到另一个网格上 —— 相当于给渲染输出找个 “目标”,这就是渲染目标。 我们需要两个这样的目标,所以可以建一个类重复使用。 ``` // 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 行的 “意大利面” 大杂烩类简直是噩梦。 ``` // 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`: ``` // 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()); // ; } ``` “现在我只看到一个蓝色方块,Nathan,感觉我们在倒退啊,快把那个好看的形状弄回来”。 嘘,这是故意的,相信我!  ### 从 MeshNormalMaterial 到 ShaderMaterial 现在我们可以用 `ShaderMaterial` 把环面结的渲染结果贴到那个蓝色平面上作为纹理了。`MeshNormalMaterial` 不支持纹理,而且我们很快也需要用到着色器。在 `targeted-torus.js` 里删掉 `MeshNormalMaterial`,换成这个: ``` // 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` 给片元着色器传一张纹理: ``` // 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 类会自动调用它): ``` // 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`。 ``` // 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` 就是完美对半分。 ``` // 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 加点动画吧。 ``` // 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, }); } } ```  ### 感谢 恭喜你,花了这么些时间学会了混合两种材质。不过肯定值得,对吧?至少希望这篇文章能帮你少费点劲搞定两个渲染目标的协调工作。 