用 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 联系我

相关推荐
Spider_Man14 分钟前
打造属于你的前端沙盒 🎉
前端·typescript·github
用户479492835691517 分钟前
🤫 你不知道的 JavaScript:`"👦🏻".length` 竟然不是 1?
前端·javascript·面试
掘金一周18 分钟前
凌晨零点,一个TODO,差点把我们整个部门抬走 | 掘金一周 9.11
前端·人工智能·后端
用户81744134274821 分钟前
kubernetes核心概念 Service
前端
东北南西29 分钟前
Web Worker 从原理到实战 —— 把耗时工作搬到后台线程,避免页面卡顿
前端·javascript
Zz_waiting.31 分钟前
案例开发 - 日程管理 - 第六期
前端·javascript·vue.js·路由·router
袁煦丞38 分钟前
企业微信开发者的‘跨网穿梭门’:cpolar内网穿透实验室第499个成功挑战
前端·程序员·远程工作
Simon_He42 分钟前
vue-markdown-renderer:比 vercel streamdown 更低 CPU、更多节点支持、真正的流式渲染体验
前端·vue.js·markdown
小桥风满袖1 小时前
极简三分钟ES6 - 模块化
前端·javascript
练习时长一年1 小时前
自定义事件发布器
java·前端·数据库