阅读视图

发现新文章,点击刷新页面。

【Three.js 与 Shader】编写你的第一个自定义着色器,让模型拥有灵魂

前言

以前我觉得 Shader 是神仙才能看懂的东西,直到我发现它其实就是告诉 GPU“怎么画”的说明书。

两年前我第一次接触 Shader,看了一篇教程,开头就是 gl_FragColorvaryinguniform 这些天书一样的词。我心想:这玩意儿是人写的吗?

后来项目里有个需求:让模型边缘发蓝光。网上搜了一堆方案,最后发现除了自己写 Shader 别无他法。硬着头皮啃了一周,终于把第一个能跑起来的着色器怼出来了。运行起来的那一刻,模型边缘真的泛着蓝光,我当时激动得差点原地蹦起来。

原来 Shader 没那么可怕,它就像一本固定的菜谱,你告诉 GPU“颜色怎么混合、顶点怎么移动”,它就能画出你想要的效果。

今天我就用最笨的方式,带你写三个最简单的自定义着色器。不扯虚的,代码直接跑,效果直接看。


一、Shader 是啥?

通俗点说,渲染一个 3D 模型要经过两个阶段:

  • 顶点着色器(Vertex Shader):负责处理每个顶点的位置、变换。好比整容医生,决定骨架长啥样。
  • 片元着色器(Fragment Shader):负责计算每个像素的颜色。好比化妆师,给每个点涂上什么颜色。

Three.js 默认的材质(比如 MeshStandardMaterial)内部已经有一套写好的着色器。我们用自定义着色器,就是替换掉默认的,自己控制这两个阶段。


二、Three.js 里的自定义材质

Three.js 提供了两种方式来写自定义着色器:

  • ShaderMaterial:自动帮你补全一些默认的 uniform 和 attribute,适合初学者。
  • RawShaderMaterial:完全自己控制,什么都不帮你补,适合进阶。

我们先从 ShaderMaterial 开始,省事。

基础结构

const material = new THREE.ShaderMaterial({
  uniforms: {
    time: { value: 0 },
    color: { value: new THREE.Color(0xffaa00) }
  },
  vertexShader: `
    void main() {
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    uniform vec3 color;
    void main() {
      gl_FragColor = vec4(color, 1.0);
    }
  `
});
  • uniforms:可以从 JavaScript 传给着色器的变量,每帧可以更新。
  • vertexShader:顶点着色器代码,字符串形式。
  • fragmentShader:片元着色器代码。

gl_Position 是顶点着色器必须输出的最终位置,gl_FragColor 是片元着色器必须输出的最终颜色。


三、第一个例子:让模型颜色随时间变化

我们用上面的基础结构,加一个 time uniform,让颜色在红色和蓝色之间循环。

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(2, 2, 5);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

new OrbitControls(camera, renderer.domElement);

// 创建一个立方体
const geometry = new THREE.BoxGeometry(2, 2, 2);

// 自定义着色器材质
const material = new THREE.ShaderMaterial({
  uniforms: {
    time: { value: 0 }
  },
  vertexShader: `
    void main() {
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    uniform float time;
    void main() {
      // 让红色分量在 0.5 到 1.0 之间变化,蓝色分量反向变化
      float r = 0.6 + 0.4 * sin(time);
      float b = 0.6 + 0.4 * cos(time);
      gl_FragColor = vec4(r, 0.2, b, 1.0);
    }
  `
});

const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

function animate() {
  requestAnimationFrame(animate);

  // 更新 uniform 中的时间
  material.uniforms.time.value += 0.05;

  renderer.render(scene, camera);
}
animate();

把这段代码贴进一个 HTML 文件里运行,你会看到一个立方体颜色在紫色系里渐变。


四、第二个例子:让顶点动起来(波浪效果)

现在我们来动顶点。让立方体的顶点按照正弦波上下浮动,像一块果冻。

const geometry = new THREE.BoxGeometry(2, 2, 2, 32, 32, 32); // 增加细分段数,让波浪更平滑

const material = new THREE.ShaderMaterial({
  uniforms: {
    time: { value: 0 }
  },
  vertexShader: `
    uniform float time;
    void main() {
      // 根据顶点原来的位置计算偏移量
      float offsetX = sin(position.y * 2.0 + time * 3.0) * 0.2;
      float offsetZ = cos(position.y * 2.0 + time * 2.0) * 0.2;
      vec3 newPosition = position + vec3(offsetX, 0.0, offsetZ);
      
      gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
    }
  `,
  fragmentShader: `
    void main() {
      gl_FragColor = vec4(0.6, 0.8, 1.0, 1.0);
    }
  `
});

这里的关键是:我们在顶点着色器里修改了 position,然后再进行 MVP 变换。注意 position 是模型局部坐标,计算时要小心不要破坏模型结构。

运行后,立方体的侧面会像波浪一样起伏。


五、第三个例子:简单边缘光

边缘光(Fresnel Effect)是让模型边缘发光的常见效果。原理是视线方向与法线方向越垂直(边缘),光越强。

我们需要在顶点着色器里把法线和视线方向传给片元着色器。

const material = new THREE.ShaderMaterial({
  uniforms: {
    time: { value: 0 }
  },
  vertexShader: `
    varying vec3 vNormal;
    varying vec3 vViewPosition;
    
    void main() {
      vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
      vViewPosition = -mvPosition.xyz; // 指向相机的方向
      vNormal = normalize(normalMatrix * normal); // 将法线转换到视图空间
      
      gl_Position = projectionMatrix * mvPosition;
    }
  `,
  fragmentShader: `
    varying vec3 vNormal;
    varying vec3 vViewPosition;
    
    void main() {
      vec3 normal = normalize(vNormal);
      vec3 viewDir = normalize(vViewPosition);
      
      // 计算视线与法线的点积,越接近0(垂直)强度越大
      float fresnel = 1.0 - abs(dot(normal, viewDir));
      fresnel = pow(fresnel, 2.0); // 增强对比
      
      vec3 baseColor = vec3(0.3, 0.6, 1.0);
      vec3 edgeColor = vec3(0.8, 0.9, 1.0);
      
      vec3 finalColor = mix(baseColor, edgeColor, fresnel);
      
      gl_FragColor = vec4(finalColor, 1.0);
    }
  `
});

这个效果在球体上最明显,模型边缘会有一圈亮光,非常有科技感。


六、坑点总结

  1. 矩阵顺序projectionMatrix * modelViewMatrix * vec4(position, 1.0) 顺序不能错,Three.js 的矩阵是右乘,坐标从右向左变换。
  2. 法线变换:不能用 modelViewMatrix 直接乘法线,要用 normalMatrix(它是 modelViewMatrix 的逆转置的左上3x3)。
  3. 变量精度:移动设备上可能需要指定精度,比如 precision highp float; 放在着色器开头。
  4. uniform 更新:记得在动画循环里更新 material.uniforms.xxx.value
  5. 调试技巧:可以用 gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); 先确认片元着色器是否运行,用 gl_Position = vec4(position, 1.0);(跳过 MVP)看顶点原始位置。

七、进阶方向

这三个例子只是冰山一角。有了自定义着色器,你还可以做:

  • 流光效果
  • 溶解消失
  • 噪声纹理生成地形
  • 后处理滤镜

Three.js 官方提供了很多现成的着色器例子,在 examples/jsm/shaders/ 目录下。有空可以翻翻源码,看看大佬们怎么写。


互动

你写过最得意的 Shader 效果是啥?或者你在学习 Shader 时遇到过什么坑?评论区分享出来,咱们一起讨论 😏

下篇预告:【Three.js 后期处理进阶】用 Shader 实现自己的滤镜,让画面拥有电影质感

Three.js多视口渲染:如何在一个屏幕上同时展示三个视角

前言

客户说:“我要一个监控大屏,左边看整体,中间看特写,右边看俯视图。” 我说:“行,加钱就行。”

上次写了篇画中画,没想到反响还不错。评论区有人问:“能不能一个屏幕放三个视角?像监控室那种。”

我心想这不就是多视口渲染的升级版吗?一个画中画不够,那就来三个。

其实原理都一样:一个场景,多个相机,分区域渲染。只不过从两个变成三个,需要多处理一些布局和交互细节。

今天就用一个监控大屏的例子,把多视口渲染讲透。最终效果:左边是全局俯视,中间是自由跟随相机,右边是某个设备的特写。三个视角实时更新,互不干扰。


一、最终效果预览

先描述一下我们要实现的效果:

  • 左侧视口:固定俯视视角,看整个车间布局。
  • 中间视口:自由相机,可以拖拽旋转,观察任意角度。
  • 右侧视口:特写某个设备,相机始终盯着它,跟随移动。

三个视口共用同一个场景,但各有各的相机和控制逻辑。运行起来就像监控室里的多块屏幕。


二、核心思路

Three.js 的渲染器允许我们在同一帧里多次调用 render() 方法,只要每次渲染前用 setViewportsetScissor 设置好渲染区域就行。

关键点:

  1. 创建多个相机,分别设置位置和朝向。
  2. 在动画循环里,依次设置视口并渲染。
  3. 处理深度清除:第二个及之后的视口渲染前要清除深度缓冲区,否则画面会错乱。
  4. 如果有交互(比如控制器),需要判断鼠标落在哪个视口,激活对应的控制器。

三、代码实现

1. 基础设置

先搭好场景、光照和几个简单的物体(用立方体和球体模拟车间设备)。

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111122);

// 添加一些物体
const gridHelper = new THREE.GridHelper(20, 20, 0x4db8ff, 0x2266aa);
scene.add(gridHelper);

const cube = new THREE.Mesh(
  new THREE.BoxGeometry(2, 2, 2),
  new THREE.MeshStandardMaterial({ color: 0xff8844 })
);
cube.position.set(2, 1, 2);
cube.castShadow = true;
cube.receiveShadow = true;
scene.add(cube);

const sphere = new THREE.Mesh(
  new THREE.SphereGeometry(1.5, 32, 16),
  new THREE.MeshStandardMaterial({ color: 0x44aaff })
);
sphere.position.set(-2, 1.5, -1);
sphere.castShadow = true;
sphere.receiveShadow = true;
scene.add(sphere);

const cylinder = new THREE.Mesh(
  new THREE.CylinderGeometry(1, 1, 3, 32),
  new THREE.MeshStandardMaterial({ color: 0x88cc44 })
);
cylinder.position.set(0, 1.5, -3);
cylinder.castShadow = true;
cylinder.receiveShadow = true;
scene.add(cylinder);

// 灯光
const ambientLight = new THREE.AmbientLight(0x404060);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 1);
dirLight.position.set(5, 10, 7);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 1024;
dirLight.shadow.mapSize.height = 1024;
scene.add(dirLight);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);

2. 创建三个相机

每个相机负责一个视角。

// 相机1:俯视固定
const cameraTop = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
cameraTop.position.set(0, 15, 0);
cameraTop.lookAt(0, 0, 0);

// 相机2:自由视角
const cameraFree = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
cameraFree.position.set(5, 5, 10);
cameraFree.lookAt(0, 2, 0);

// 相机3:特写立方体
const cameraCloseup = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
cameraCloseup.position.set(4, 3, 4);
cameraCloseup.lookAt(cube.position); // 盯着立方体

注意:宽高比我们还没设置,等渲染时根据视口大小动态更新。

3. 设置控制器

自由视角的相机需要控制器,其他两个不需要(或者也可以加,但本例中俯视和特写是固定的)。

const controlsFree = new OrbitControls(cameraFree, renderer.domElement);
controlsFree.enableDamping = true;
controlsFree.target.set(0, 2, 0);

但控制器会监听整个画布的鼠标事件,我们需要判断鼠标当前在哪个视口,只有落在自由视口时才让 controlsFree 生效。后面会处理。

4. 定义视口布局

假设屏幕宽度为 window.innerWidth,高度为 window.innerHeight。我们分成三等份,每个视口占三分之一宽度,高度占满。

const viewports = [
  { left: 0, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraTop },
  { left: window.innerWidth / 3, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraFree },
  { left: 2 * window.innerWidth / 3, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraCloseup }
];

5. 处理鼠标事件,激活对应控制器

我们需要知道鼠标当前落在哪个视口,然后决定哪个控制器应该启用。其他控制器的 enabled 设为 false

function onMouseClick(event) {
  const mouseX = event.clientX;
  const mouseY = event.clientY;

  // 遍历视口,判断鼠标是否在内部
  let activeIndex = -1;
  viewports.forEach((vp, index) => {
    if (mouseX >= vp.left && mouseX <= vp.left + vp.width &&
        mouseY >= window.innerHeight - vp.bottom - vp.height && mouseY <= window.innerHeight - vp.bottom) {
      activeIndex = index;
    }
  });

  // 根据 activeIndex 启用/禁用控制器
  // 这里我们只有自由相机需要控制器,其他两个不需要
  if (activeIndex === 1) {
    controlsFree.enabled = true;
  } else {
    controlsFree.enabled = false;
  }
}

renderer.domElement.addEventListener('click', onMouseClick);

注意坐标转换:屏幕坐标系原点在左上角,而 setViewport 用的是左下角原点,所以判断时需要转换。上面代码中的 mouseY 判断已转换。

6. 动画循环:多视口渲染

这是核心。每帧先更新控制器(如果启用),然后依次渲染每个视口。

function animate() {
  requestAnimationFrame(animate);

  // 更新自由相机的控制器
  controlsFree.update();

  // 让特写相机始终盯着立方体(如果立方体在动)
  cameraCloseup.lookAt(cube.position);

  // 为每个视口设置视口并渲染
  viewports.forEach((vp) => {
    // 设置视口
    renderer.setViewport(vp.left, vp.bottom, vp.width, vp.height);
    
    // 设置剪裁区域(可选,避免渲染到其他区域)
    renderer.setScissor(vp.left, vp.bottom, vp.width, vp.height);
    renderer.setScissorTest(true);

    // 更新相机的宽高比
    const aspect = vp.width / vp.height;
    vp.camera.aspect = aspect;
    vp.camera.updateProjectionMatrix();

    // 如果是第一个视口,清除颜色和深度;后面的只清除深度
    if (vp === viewports[0]) {
      renderer.clear();
    } else {
      renderer.clearDepth();
    }

    // 渲染当前相机
    renderer.render(scene, vp.camera);
  });

  // 渲染完成后关闭剪裁测试(可选)
  renderer.setScissorTest(false);
}
animate();

这里使用了 setScissorsetScissorTest(true) 来确保每个相机的渲染只在自己区域内,防止绘制到其他区域。同时用 clearDepth 避免深度冲突。

7. 窗口大小变化时更新布局

window.addEventListener('resize', () => {
  renderer.setSize(window.innerWidth, window.innerHeight);

  viewports[0].width = window.innerWidth / 3;
  viewports[0].height = window.innerHeight;
  viewports[1].left = window.innerWidth / 3;
  viewports[1].width = window.innerWidth / 3;
  viewports[1].height = window.innerHeight;
  viewports[2].left = 2 * window.innerWidth / 3;
  viewports[2].width = window.innerWidth / 3;
  viewports[2].height = window.innerHeight;
});

四、完整代码

把上面的代码片段组合起来,就是一个完整的多视口示例。为了方便你直接运行,我整理成一个完整的 HTML 文件,并加了一点动画让立方体旋转,让效果更生动。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Three.js 多视口渲染:三屏监控</title>
    <style>
        body { margin: 0; overflow: hidden; font-family: 'Microsoft YaHei'; }
        #info {
            position: absolute; top: 20px; left: 20px;
            background: rgba(0,0,0,0.7); color: white;
            padding: 8px 16px; border-radius: 20px;
            z-index: 100; pointer-events: none;
        }
        .label {
            position: absolute; bottom: 20px;
            background: rgba(0,0,0,0.5); color: white;
            padding: 4px 12px; border-radius: 12px;
            font-size: 14px; pointer-events: none;
            z-index: 200;
        }
        #label-left { left: calc(16.67% - 50px); }
        #label-center { left: 50%; transform: translateX(-50%); }
        #label-right { right: calc(16.67% - 60px); }
    </style>
</head>
<body>
    <div id="info">🎥 三视口监控:俯视 | 自由 | 特写</div>
    <div class="label" id="label-left">📐 俯视固定</div>
    <div class="label" id="label-center">🎮 自由视角 (点击激活)</div>
    <div class="label" id="label-right">🔍 设备特写</div>

    <!-- 引入 Three.js 核心库和 OrbitControls -->
    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.128.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.128.0/examples/jsm/"
            }
        }
    </script>

    <script type="module">
        import * as THREE from 'three';
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

        // --- 初始化场景 ---
        const scene = new THREE.Scene();
        scene.background = new THREE.Color(0x111122);

        // 网格地面
        const gridHelper = new THREE.GridHelper(20, 20, 0x4db8ff, 0x2266aa);
        scene.add(gridHelper);

        // 添加一些物体
        const cubeGeo = new THREE.BoxGeometry(2, 2, 2);
        const cubeMat = new THREE.MeshStandardMaterial({ color: 0xff8844, emissive: 0x221100 });
        const cube = new THREE.Mesh(cubeGeo, cubeMat);
        cube.position.set(2, 1, 2);
        cube.castShadow = true;
        cube.receiveShadow = true;
        scene.add(cube);

        const sphereGeo = new THREE.SphereGeometry(1.5, 32, 16);
        const sphereMat = new THREE.MeshStandardMaterial({ color: 0x44aaff, emissive: 0x001122 });
        const sphere = new THREE.Mesh(sphereGeo, sphereMat);
        sphere.position.set(-2, 1.5, -1);
        sphere.castShadow = true;
        sphere.receiveShadow = true;
        scene.add(sphere);

        const cylinderGeo = new THREE.CylinderGeometry(1, 1, 3, 32);
        const cylinderMat = new THREE.MeshStandardMaterial({ color: 0x88cc44, emissive: 0x112200 });
        const cylinder = new THREE.Mesh(cylinderGeo, cylinderMat);
        cylinder.position.set(0, 1.5, -3);
        cylinder.castShadow = true;
        cylinder.receiveShadow = true;
        scene.add(cylinder);

        // 添加一个移动的小球作为动态元素
        const ballGeo = new THREE.SphereGeometry(0.5, 16);
        const ballMat = new THREE.MeshStandardMaterial({ color: 0xffaa33 });
        const ball = new THREE.Mesh(ballGeo, ballMat);
        ball.castShadow = true;
        scene.add(ball);

        // 灯光
        const ambientLight = new THREE.AmbientLight(0x404060);
        scene.add(ambientLight);

        const dirLight = new THREE.DirectionalLight(0xffffff, 1);
        dirLight.position.set(5, 10, 7);
        dirLight.castShadow = true;
        dirLight.shadow.mapSize.width = 1024;
        dirLight.shadow.mapSize.height = 1024;
        scene.add(dirLight);

        const fillLight = new THREE.PointLight(0x4466aa, 0.5);
        fillLight.position.set(-3, 2, 4);
        scene.add(fillLight);

        // --- 渲染器 ---
        const renderer = new THREE.WebGLRenderer({ antialias: true });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.shadowMap.enabled = true;
        renderer.setPixelRatio(window.devicePixelRatio);
        document.body.appendChild(renderer.domElement);

        // --- 三个相机 ---
        // 1. 俯视相机
        const cameraTop = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
        cameraTop.position.set(0, 15, 0);
        cameraTop.lookAt(0, 0, 0);

        // 2. 自由相机
        const cameraFree = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
        cameraFree.position.set(5, 5, 10);
        cameraFree.lookAt(0, 2, 0);

        // 3. 特写相机
        const cameraCloseup = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
        cameraCloseup.position.set(4, 3, 4);
        cameraCloseup.lookAt(cube.position);

        // --- 控制器(只给自由相机)---
        const controlsFree = new OrbitControls(cameraFree, renderer.domElement);
        controlsFree.enableDamping = true;
        controlsFree.target.set(0, 2, 0);

        // --- 视口定义 ---
        const viewports = [
            { left: 0, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraTop },
            { left: window.innerWidth / 3, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraFree },
            { left: 2 * window.innerWidth / 3, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraCloseup }
        ];

        // --- 鼠标点击激活对应控制器 ---
        function onMouseClick(event) {
            const mouseX = event.clientX;
            const mouseY = event.clientY;

            let activeIndex = -1;
            for (let i = 0; i < viewports.length; i++) {
                const vp = viewports[i];
                // 转换鼠标坐标(左下原点)
                const vpLeft = vp.left;
                const vpRight = vp.left + vp.width;
                const vpBottom = vp.bottom;
                const vpTop = vp.bottom + vp.height;

                if (mouseX >= vpLeft && mouseX <= vpRight && 
                    mouseY >= window.innerHeight - vpTop && mouseY <= window.innerHeight - vpBottom) {
                    activeIndex = i;
                    break;
                }
            }

            // 自由相机索引为1,其他相机没有控制器
            controlsFree.enabled = (activeIndex === 1);
        }
        renderer.domElement.addEventListener('click', onMouseClick);

        // --- 窗口大小自适应 ---
        window.addEventListener('resize', () => {
            renderer.setSize(window.innerWidth, window.innerHeight);

            viewports[0].width = window.innerWidth / 3;
            viewports[0].height = window.innerHeight;
            viewports[1].left = window.innerWidth / 3;
            viewports[1].width = window.innerWidth / 3;
            viewports[1].height = window.innerHeight;
            viewports[2].left = 2 * window.innerWidth / 3;
            viewports[2].width = window.innerWidth / 3;
            viewports[2].height = window.innerHeight;
        });

        // --- 动画变量 ---
        let time = 0;

        // --- 动画循环 ---
        function animate() {
            requestAnimationFrame(animate);

            // 让小球围绕中心旋转
            time += 0.01;
            ball.position.x = Math.sin(time) * 3;
            ball.position.z = Math.cos(time) * 3;
            ball.position.y = 0.5 + Math.sin(time * 2) * 0.5;

            // 让立方体旋转
            cube.rotation.y += 0.01;

            // 更新自由相机的控制器
            controlsFree.update();

            // 让特写相机始终盯着立方体
            cameraCloseup.lookAt(cube.position);

            // 依次渲染每个视口
            viewports.forEach((vp, index) => {
                // 设置视口
                renderer.setViewport(vp.left, vp.bottom, vp.width, vp.height);
                renderer.setScissor(vp.left, vp.bottom, vp.width, vp.height);
                renderer.setScissorTest(true);

                // 更新相机宽高比
                vp.camera.aspect = vp.width / vp.height;
                vp.camera.updateProjectionMatrix();

                // 第一个视口清除颜色和深度,后续只清除深度
                if (index === 0) {
                    renderer.clear();
                } else {
                    renderer.clearDepth();
                }

                renderer.render(scene, vp.camera);
            });

            renderer.setScissorTest(false);
        }

        animate();
    </script>
</body>
</html>

五、坑点总结

  1. 视口坐标setViewportsetScissor 用的都是左下角原点,而鼠标事件是左上角原点,转换时要注意。
  2. 深度清除:多视口渲染时,第二个及之后的视口必须调用 clearDepth(),否则旧深度会导致新画面显示不全。
  3. 控制器冲突:多个控制器同时监听同一个画布会互相干扰,必须根据鼠标位置动态启用/禁用。
  4. 性能:渲染多个视口意味着每帧多次渲染,对性能有影响。可以适当降低分辨率或关闭阴影来优化。
  5. 宽高比:每个相机要单独设置 aspect 并调用 updateProjectionMatrix

六、拓展想法

这个多视口技术还有很多玩法:

  • 给每个视口添加不同的后期效果(比如一个泛光,一个黑白)。
  • 实现分屏游戏(比如左右分屏的双人竞技)。
  • 结合 CSS 把视口放在 HTML 元素上,实现 3D 画中画嵌套 HTML。

我正准备写一篇《Three.js 后期处理进阶:给每个视口加上不同滤镜》,感兴趣的话可以关注后续。


互动

你用过 Three.js 的多视口渲染吗?实现了什么有趣的效果?欢迎评论区晒出来,让我也开开眼 😏

❌