在 Threejs 中自己实现一套阴影

2,226 阅读5分钟

一. 背景

  Threejs 给我们封装的阴影虽然简单易用,但太过上层,不够灵活。   之前在预研厘米秀新形象时,需要把 Unity 中的着色器代码“翻译”到 Threejs 的自定义材质中。我们发现新形象在 Unity 中定义的第一个 PASS 依赖于阴影,也就是说我们需要必须先拿到阴影数据,才能再对后续的 PASS 进行“翻译”。但是 Threejs 并不会给我们提供阴影信息。另外,就算真的可以通过各种操作拿到 Threejs 生成的阴影纹理,我们也还是需要在自定义材质中通过着色器来理解以及消费它。   综上,干脆在 Threejs 上自己实现一套阴影,足够灵活,也能自己把握细节。

二. 阴影是如何产生的

1619512717_62_w564_h438.png
在自然界中,一个不自发光的物体要被看见,是需要光源照射的。由于光是沿直线传播的,当光线被某些物体(图中橘色物体)遮挡后,那些本来有颜色的区域(点C)因为没有照射而变回黑色,这些区域就是阴影。

三. 如何用 ShadowMap 生成阴影

  理论上,在绘制点的颜色时,只要判断该点有没有被“遮挡”,就知道是否要绘制成阴影。而判断“遮挡”的方案有很多,最常用的就是 ShadowMap。   我们只要知道该点与光源的连线上,有没有比它离光源更近的点存在。其中点与光源的距离,在 ShadowMap 中就是深度。具体的做法是:

  • (1) 生成深度纹理图:所谓深度纹理图,就是每个位置的最小深度。我们站在光源的位置,按照光线传播的视角,观察场景,计算场景中的物体距离光源的距离(也就是该视角下的深度),并记录各个位置上的最小值,从而获得一张深度纹理。
  • (2) 使用深度纹理图:对于世界中的某个点 p,我们要先得到它在光源视角下的深度,再和深度纹理图中对应的深度进行比较,就可以判定它是否在阴影中了。

  ps: 更多关于 ShadowMap 生成阴影的原理以及阴影质量的逐步优化可以看我的另一篇文章《3D世界中的阴影—ShadowMap原理解析》

四. 落地代码

1619513368_50_w593_h567.png

  下面的 demo 代码,都是用来实现以上这个场景的阴影效果。其中蓝青色的正方体代表光源位置。

1. 每一帧的渲染流程

  我们在设备上看到的图像,都是一帧一帧绘制出来的。在这个 demo 中每一帧的渲染,包括以下两个部分:

  • (1) 离屏渲染:将相机移动到光源处,渲染场景到缓冲区,拿到 shadow map。
  • (2) 切换渲染目标,将场景渲染到屏幕上。其中场景中的正方体和地板,都要配上自定义材质来使用 shadow map。

  下面是渲染流程的相关代码:

import { OrbitControls } from "./assets/orbitcontrols.js";

let renderer, stats, camera, camera4SM;
let scene, bufferScene, bufferTexture;

const domElement = document.getElementById("canvas-frame");

// 帧数检测
function initStatus() { // ... }

// 初始化 render
function initThree() { // ... }

// 初始化场景
function initScene() {
  // 需要绘制到屏幕的场景
  scene = new THREE.Scene();
  const axisHelper = new THREE.AxesHelper(100);
  scene.add(axisHelper);
  // 离屏缓冲区
  bufferScene = new THREE.Scene();
  bufferTexture = new THREE.WebGLRenderTarget(
    domElement.clientWidth,
    domElement.clientHeight
  );
  bufferTexture.depthBuffer = true;
  bufferTexture.depthTexture = new THREE.DepthTexture();
}

// 初始化相机
function initCamera() {
  const width = domElement.clientWidth;
  const height = domElement.clientHeight;

  camera = new THREE.PerspectiveCamera(45, width / height, 1, 10000);
  camera.position.set(50, 50, 200);
  camera.lookAt(scene.position);
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  scene.add(camera);
  // 光源处的相机,用于生成 shadow map
  camera4SM = new THREE.OrthographicCamera(-100, 100, 100, -100, 1, 70);
  camera4SM.position.set(20, 50, 0);
  camera4SM.lookAt(bufferScene.position);
  camera4SM.aspect = width / height;
  camera4SM.updateProjectionMatrix();
  bufferScene.add(camera4SM);

}

// 初始化光源
function initLight() { // ...}

// 每一帧调用的渲染函数
function render() {
  // 渲染到目标缓冲区
  renderer.setClearColor("rgb(255,255,255)", 1.0);
  renderer.setRenderTarget(bufferTexture);
  renderer.render(bufferScene, camera4SM);

  // 渲染到屏幕
  renderer.setClearColor("rgb(150,150,150)", 1.0);
  renderer.setRenderTarget(null);
  renderer.render(scene, camera);

  stats.update();
  requestAnimationFrame(render);
}

function start() {
  initStatus();
  initThree();
  initScene();
  initCamera();
  initLight();
  initObject();

  render();
}

start();

2. 生成深度纹理图 - shadow map

  使用离屏渲染,将深度图这一帧缓存起来。
1619514934_21_w300_h296.png

(1) 新加一个自定义材质,用来记录深度

function initObject() {
  // add object in buffer scene
  const getSMMaterial = new THREE.ShaderMaterial({
    uniforms: {
      projectionMatrixSM: { value: camera4SM.projectionMatrix },
    },
    vertexShader: document.getElementById("vertexShaderSM").textContent,
    fragmentShader: document.getElementById("fragmentShaderSM").textContent,
  });
  // ...
}

  相关的着色器代码:   具体的做法就是把投影之后的片元深度值,写到 rgb 中的 r 值。这就是为啥我们的 shadow map 是偏红色的~

<script id="vertexShaderSM" type="x-shader/x-vertex">
    uniform mat4 projectionMatrixSM;
    void main(){
      gl_Position = projectionMatrixSM * modelViewMatrix * vec4( position, 1.0 );
    }
</script>
<script id="fragmentShaderSM" type="x-shader/x-fragment">
    void main() {
      gl_FragColor = vec4(gl_FragCoord.z, 0.0, 0.0, 0.0); // 将片元的深度值写入r值
    }
</script>

(2) 把使用该材质的正方体和地板放到离屏渲染的场景里

function initObject() {
  // ...
  const groundGeo = new THREE.BoxGeometry(40, 40, 1);
  const groundInBuffer = new THREE.Mesh(groundGeo, getSMMaterial);
  groundInBuffer.rotation.x = Math.PI / 2;
  groundInBuffer.name = "groundPlane";
  bufferScene.add(groundInBuffer);
  
  const cubeGeo = new THREE.BoxGeometry(20, 20, 20);
  const cubeInBuffer = new THREE.Mesh(cubeGeo, getSMMaterial);
  cubeInBuffer.position.y += 10;
  cubeInBuffer.name = "cubeInBuffer";
  bufferScene.add(cubeInBuffer);
  // ...
}

(3) 用光源处的相机渲染场景到缓存里

function render() {
  // 渲染到目标缓冲区
  renderer.setClearColor("rgb(255,255,255)", 1.0);
  renderer.setRenderTarget(bufferTexture);
  renderer.render(bufferScene, camera4SM);
  // ...
}

3. 使用深度纹理图生成阴影

1619515049_66_w1132_h604.png

(1) 新加一个自定义材质,用来使用深度图生成阴影

function initObject() {
  // add object in buffer scene
  // ...
  // add object in screen scene
  const useSM4CubeMat = new THREE.ShaderMaterial({
    // attributes 居然传不进去
    uniforms: {
      modelViewMatrixSM: { value: cubeInBuffer.modelViewMatrix },
      projectionMatrixSM: { value: camera4SM.projectionMatrix },
      depthTexture: { value: bufferTexture.texture },
      color: { value: new THREE.Vector3(0, 1, 0) },
    },
    vertexShader: document.getElementById("vertexShader").textContent,
    fragmentShader: document.getElementById("fragmentShader").textContent,
  });
  
    const useSM4GroundMat = new THREE.ShaderMaterial({
    uniforms: {
      modelViewMatrixSM: { value: groundInBuffer.modelViewMatrix },
      projectionMatrixSM: { value: camera4SM.projectionMatrix },
      depthTexture: { value: bufferTexture.texture },
      color: { value: new THREE.Vector3(1, 1, 1) },
    },
    vertexShader: document.getElementById("vertexShader").textContent,
    fragmentShader: document.getElementById("fragmentShader").textContent,
  });
}

  相关的着色器代码:

  • 先做归一化:mvp 矩阵处理完的坐标还会被自动转化成裁剪空间的坐标,范围在 [0, 1] 区间,所以这里也要做归一化。
  • 获取深度:拿到深度纹理中对应坐标存储的数据。
  • 判断片元属否在阴影中:如果该片元的深度大于shadow map 存储的对应深度,则表示相同位置下,有比该点距离光源更近的点存在,被遮挡了。
<script id="vertexShader" type="x-shader/x-vertex">
    uniform mat4 modelViewMatrixSM;
    uniform mat4 projectionMatrixSM;
    varying vec4 result;
    void main(){
      gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
      result = projectionMatrixSM * modelViewMatrixSM * vec4( position, 1.0 );
    }
</script>
<script id="fragmentShader" type="x-shader/x-fragment">
    uniform sampler2D depthTexture;
    uniform vec3 color;
    varying vec4 result;
    void main() {
      vec3 shadowCoord = (result.xyz / result.w) / 2.0 + 0.5; // 归一化
      vec4 rgbaDepth = texture2D(depthTexture, shadowCoord.xy); 
      float depth = rgbaDepth.r; // 拿到深度纹理中对应坐标存储的深度

      float visibility = (shadowCoord.z > depth + 0.3) ? 0.0 : 1.0; // 判断片元是否在阴影中
      vec4 v_Color = vec4(color, 1.0);
      gl_FragColor = vec4(v_Color.rgb * visibility, v_Color.a);
    }
</script>

(2) 把使用该材质的正方体和地板放到屏幕渲染的场景里

function initObject() {
	// ...
	const cubeBufGeo = new THREE.BufferGeometry();
  cubeBufGeo.fromGeometry(cubeGeo);
  const cubeInScreen = new THREE.Mesh(cubeBufGeo, useSM4CubeMat);
  cubeInScreen.position.y += 10;
  cubeInScreen.name = "cubeInScreen";
  scene.add(cubeInScreen);
    
	const planeInScreen = new THREE.Mesh(groundGeo, useSM4GroundMat);
  planeInScreen.rotation.x = Math.PI / 2;
  planeInScreen.name = "planeInScreen";
  scene.add(planeInScreen);
  
  // 展示 shadow map
  //...
}

(3) 渲染场景到屏幕上

// 每一帧调用的渲染函数
function render() {
  // 渲染到目标缓冲区
  // ...

  // 渲染到屏幕
  renderer.setClearColor("rgb(150,150,150)", 1.0);
  renderer.setRenderTarget(null);
  renderer.render(scene, camera);

  stats.update();
  requestAnimationFrame(render);
}

五. 最终效果

1619593417_70_w1914_h750.png

六. 附录