用 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.innerWidthwindow.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():它会自动跟踪依赖(这里是 elementactor),依赖变化时就会执行函数,再也不用写带依赖数组的 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 这个渲染目标里。我们要更新渲染循环,让着色器同时能用到 solidTargetwireframeTarget

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.150.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 联系我

相关推荐
passerby606125 分钟前
完成前端时间处理的另一块版图
前端·github·web components
掘了33 分钟前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅36 分钟前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅1 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅1 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment1 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅2 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊2 小时前
jwt介绍
前端
爱敲代码的小鱼2 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax
Cobyte2 小时前
AI全栈实战:使用 Python+LangChain+Vue3 构建一个 LLM 聊天应用
前端·后端·aigc