阅读视图

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

用一个粒子效果告别蛇年迎来马年~

我们即将迎来马年,随手整了一个粒子切换效果,在这里分享给大家,本期功能实现主要是运用了Three.JS!

cover2

1.加载模型

这种物体的形状很难通过纯数学公式推导出来,所以我是在sketchfab上找的两个模型

20260208174832

20260208174916

这两个模型都是.glb类型的,在Three.JS中我们可以通过GLTFLoaderDRACOLoader很轻松的加载这种类型的模型文件!

const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('/draco/')

const gltfLoader = new GLTFLoader()
gltfLoader.setDRACOLoader(dracoLoader)

const gltf = await gltfLoader.loadAsync(path);
const model = gltf.scene;

关于DRACOLoader

简单来说,DRACOLoaderThree.js 中专门用来解压经过 Draco 压缩过的 3D 模型的“解压器”。

如果你在开发 WebGL 项目时发现模型文件(通常是 .gltf 或 .glb)太大,导致加载缓慢,你通常会使用 Google 开发的 Draco 算法 对模型进行压缩。而 DRACOLoader 就是为了让浏览器能读懂这些压缩数据而存在的。

const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('/draco/')

const gltfLoader = new GLTFLoader()
gltfLoader.setDRACOLoader(dracoLoader)

const modelFiles = [
    {path: '/snake_model.glb', scale: 8, position: {x: 0, y: 0, z: 0}},
    {path: '/horse.glb', scale: 18, position: {x: 0, y: -14, z: 0}}
];


for (const modelConfig of modelFiles) {
    try {
        const gltf = await gltfLoader.loadAsync(modelConfig.path);
        const model = gltf.scene;
        model.scale.set(modelConfig.scale, modelConfig.scale, modelConfig.scale);
        model.position.set(modelConfig.position.x, modelConfig.position.y, modelConfig.position.z);
        model.updateMatrixWorld(true);
        scene.add(model);

        console.log(`Loaded: ${modelConfig.path}`);
    } catch (error) {
        console.error(`Failed to load ${modelConfig.path}:`, error);
    }
}

20260209091146

2.模型粒子化

现在我们的两个模型已经成功加载,我们的模型粒子化的思路是拿到模型的顶点数据然后使用new THREE.Points来展示,所以我们先隐藏我们的模型文件

for (const modelConfig of modelFiles) {
    try {
        ...
        ...
      - scene.add(model);
      + model.visible = false;
    
    } catch (error) {
        
    }
}

2.1 MeshSurfaceSampler

MeshSurfaceSampler 是 Three.js 扩展库(three/examples/jsm/math/MeshSurfaceSampler.js)中的一个实用类。它通过加权随机算法,根据模型表面的几何面积分布,在三角形网格上提取随机点的坐标、法线以及颜色。

通俗的来说我们的模型是由许多个三角形组成的,MeshSurfaceSampler通过算法会判断三角形面积,如果更大的三角形则权重更多被分配的点也就更多!

举个栗子🌰

import { MeshSurfaceSampler } from 'three/examples/jsm/math/MeshSurfaceSampler.js';

// 1. 创建采样器
const sampler = new MeshSurfaceSampler(yourLoadedMesh)
    .setWeightAttribute('color') // 可选:如果有颜色属性,可以按颜色密度采样
    .build();

// 2. 采样循环
const tempPosition = new THREE.Vector3();
const tempNormal = new THREE.Vector3();

for (let i = 0; i < particleCount; i++) {
    sampler.sample(tempPosition, tempNormal);
    
    // 将采样到的位置存入数组或属性中
    positions.push(tempPosition.x, tempPosition.y, tempPosition.z);
}

2.2 合并Mash

从上面的例子我们能看到MeshSurfaceSampler接收的是一个单一的Mesh,但是我们的模型可能会包含多个Mesh,比如本次案例中的都是有两个Mesh,所以在使用MeshSurfaceSampler前我们需要把多个Mesh合并成一个!

BufferGeometryUtils.mergeGeometries 是 Three.js 扩展库 BufferGeometryUtils 中的一个静态方法。它的主要作用是将一组 BufferGeometry 合并成一个单一的几何体。

function getMergedMeshFromScene(scene) {
    const geometries = [];

    scene.updateMatrixWorld(true);

    scene.traverse((child) => {
        if (child.isMesh) {
            const clonedGeom = child.geometry.clone();
            clonedGeom.applyMatrix4(child.matrixWorld);
            for (const key in clonedGeom.attributes) {
                if (key !== 'position') clonedGeom.deleteAttribute(key);
            }
            geometries.push(clonedGeom);
        }
    });

    // 合并所有几何体
    const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries);
    return new THREE.Mesh(mergedGeometry);
}

2.3 展示粒子


function generatePositionsFromModel(mesh, totalCount = particleCount) {
    const positions = new Float32Array(totalCount * 3);

    const tempPosition = new THREE.Vector3();

    const sampler = new MeshSurfaceSampler(mesh).build();
    for (let i = 0; i < totalCount; i++) {
        sampler.sample(tempPosition);
        tempPosition.applyMatrix4(mesh.matrixWorld);
        const i3 = i * 3;
        positions[i3] = tempPosition.x;
        positions[i3 + 1] = tempPosition.y;
        positions[i3 + 2] = tempPosition.z;
    }

    return {positions};
}


 const modelData = generatePositionsFromModel(getMergedMeshFromScene(model), particleCount);
 modelDataArray.push(modelData);

现在我们已经有了模型的顶点坐标只需要使用THREE.Points配合THREE.PointsMaterial

function makeParticles(modelData) {

    const {positions} = modelData;

    const geometry = new THREE.BufferGeometry();

    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    
    const material = new THREE.PointsMaterial({
        color: 0xffffff,      
        size: 0.5,             
        sizeAttenuation: true, 
        transparent: true,
        opacity: 0.8
    });

    return new THREE.Points(geometry, material);
}

const particles = makeParticles(modelDataArray[0]);
scene.add(particles);

20260209093343

我们的粒子小蛇就展示出来了,只不过现在这个粒子还很粗糙,我们会在后面优化~

3.粒子切换

现在我们的粒子已经成功展示!根据前面两步我们能知道粒子的展示就是根据模型的顶点来计算的,所以从一个模型切换到另一个模型就是单纯的顶点切换!

function beginMorph(index) {
    isTrans = true;
    prog = 0;

    const fromPts = new Float32Array(particles.geometry.attributes.position.array);
    const modelData = generatePositionsFromModel(getMergedMeshFromScene(rawModel[index]), particleCount);
    const toPts = new Float32Array(modelData.positions);

    particles.userData = {from: fromPts, to: toPts};
}

通过beginMorph我们把当前的粒子状态和目标状态存入到userData中,然后在tick中进行动画处理

const morphSpeed = .03;

const tick = () => {
    window.requestAnimationFrame(tick)
    controls.update()

    if (isTrans) {
        prog += morphSpeed;
        // 使用平滑的缓动函数
        const eased = prog >= 1 ? 1 : 1 - Math.pow(1 - prog, 3);

        const { from, to } = particles.userData;
        const particleArr = particles.geometry.attributes.position.array;

        for (let i = 0; i < particleArr.length; i++) {
            particleArr[i] = from[i] + (to[i] - from[i]) * eased;
        }
        // 通知 GPU 更新
        particles.geometry.attributes.position.needsUpdate = true;
        if (prog >= 1) isTrans = false;
    }


    renderer.render(scene, camera);
}

change

此时我们基础的粒子切换效果就已经实现啦!

4.粒子优化

此时我们的粒子效果还是存在几个问题的!

  • 大小固定/粒子是正方形
  • 没有颜色
  • 效果单调

要解决上面几个问题我们还使用THREE.PointsMaterial就有点不够看了,接下来我们使用THREE.ShaderMaterial搭配自定义着色器来优化效果!

4.1 大小随机化/粒子改为圆形

我们想让粒子的大小产生一个随机变化就要考虑通过顶点着色器中gl_PointSize来随机改变粒子大小!粒子改为圆形就要在片元着色器中修改gl_FragColor!

function generatePositionsFromModel(mesh, totalCount = particleCount) {
    const positions = new Float32Array(totalCount * 3);
    const sizes = new Float32Array(totalCount);
    const rnd = new Float32Array(totalCount * 3);

    const tempPosition = new THREE.Vector3();

    const sampler = new MeshSurfaceSampler(mesh).build();
    for (let i = 0; i < totalCount; i++) {
        sizes[i] = .7 + Math.random() * 1.1;
        sampler.sample(tempPosition);
        tempPosition.applyMatrix4(mesh.matrixWorld);
        const i3 = i * 3;
        positions[i3] = tempPosition.x;
        positions[i3 + 1] = tempPosition.y;
        positions[i3 + 2] = tempPosition.z;

        rnd[i3] = Math.random() * 10;
        rnd[i3 + 1] = Math.random() * Math.PI * 2;
        rnd[i3 + 2] = .5 + .5 * Math.random();
    }

    return {positions, sizes, rnd};
}

首先修改generatePositionsFromModel方法,针对每个顶点坐标产生一组随机数范围在.7 ~ .77

function makeParticles(modelData) {

    const {positions, sizes, rnd} = modelData;

    const geometry = new THREE.BufferGeometry();

    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1))
    geometry.setAttribute("random", new THREE.BufferAttribute(rnd, 3));

    const material = new THREE.ShaderMaterial({
        uniforms: {time: {value: 0}, hueSpeed: {value: 0.12}},
        vertexShader: ..., 
        fragmentShader: ...,
        transparent: true, 
        depthWrite: false, 
        vertexColors: true, 
        blending: THREE.AdditiveBlending
    });

    return new THREE.Points(geometry, material);
}
uniform float time;
attribute float size;
attribute vec3 random;
varying vec3 vCol;
varying float vR;
void main(){
    vec3 p=position;
    vec4 mv=modelViewMatrix*vec4(p,1.);
    float pulse=.9+.1*sin(time*1.15+random.y);
    gl_PointSize=size*pulse*(350./-mv.z);
    gl_Position=projectionMatrix*mv;
}
uniform float time;
void main() {
    float d = length(gl_PointCoord - vec2(0.5));
    float alpha = 1.0 - smoothstep(0.4, 0.5, d);
    if (alpha < 0.01) discard;
    gl_FragColor = vec4(1.0, 1.0, 1.0, alpha);
}

20260209100331

此时的粒子就大小改为随机并且是圆形粒子了~

4.2 粒子添加颜色

粒子添加颜色和上一步的粒子大小类似都需要针对每一个顶点生成一个随机的颜色

const palette = [0xff3c78, 0xff8c00, 0xfff200, 0x00cfff, 0xb400ff, 0xffffff, 0xff4040].map(c => new THREE.Color(c));

    const tempPosition = new THREE.Vector3();

    const sampler = new MeshSurfaceSampler(mesh).build();
    for (let i = 0; i < totalCount; i++) {
        ...
        ...

        const base = palette[Math.random() * palette.length | 0], hsl = {h: 0, s: 0, l: 0};
        base.getHSL(hsl);
        hsl.h += (Math.random() - .5) * .05;
        hsl.s = Math.min(1, Math.max(.7, hsl.s + (Math.random() - .5) * .3));
        hsl.l = Math.min(.9, Math.max(.5, hsl.l + (Math.random() - .5) * .4));

        const c = new THREE.Color().setHSL(hsl.h, hsl.s, hsl.l);
        colors[i3] = c.r;
        colors[i3 + 1] = c.g;
        colors[i3 + 2] = c.b;

        ...
    }

修改片元着色器

uniform float time;
uniform float hueSpeed;
varying vec3 vCol;
varying float vR;

vec3 hueShift(vec3 c, float h) {
    const vec3 k = vec3(0.57735);
    float cosA = cos(h);
    float sinA = sin(h);
    return c * cosA + cross(k, c) * sinA + k * dot(k, c) * (1.0 - cosA);
}

void main() {
    vec2 uv = gl_PointCoord - 0.5;
    float d = length(uv);

    float core = smoothstep(0.05, 0.0, d);
    float angle = atan(uv.y, uv.x);
    float flare = pow(max(0.0, sin(angle * 6.0 + time * 2.0 * vR)), 4.0);
    flare *= smoothstep(0.5, 0.0, d);
    float glow = smoothstep(0.4, 0.1, d);

    float alpha = core * 1.0 + flare * 0.5 + glow * 0.2;

    vec3 color = hueShift(vCol, time * hueSpeed);
    vec3 finalColor = mix(color, vec3(1.0, 0.95, 0.9), core);
    finalColor = mix(finalColor, color, flare * 0.5 + glow * 0.5);

    if (alpha < 0.01) discard;

    gl_FragColor = vec4(finalColor, alpha);
}

20260209100747

4.3 设置亮度差

现在我们的粒子看着还是略显单调!我们可以给粒子做局部提亮!


function createSparkles() {

    const geo = new THREE.BufferGeometry();
    const pos = new Float32Array(particleSparkCount * 3);
    const size = new Float32Array(particleSparkCount);
    const rnd = new Float32Array(particleSparkCount * 3);

    for (let i = 0; i < particleSparkCount; i++) {
        size[i] = 0.5 + Math.random() * 0.8;
        rnd[i * 3] = Math.random() * 10;
        rnd[i * 3 + 1] = Math.random() * Math.PI * 2;
        rnd[i * 3 + 2] = 0.5 + 0.5 * Math.random();
    }
    geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
    geo.setAttribute('size', new THREE.BufferAttribute(size, 1));
    geo.setAttribute('random', new THREE.BufferAttribute(rnd, 3));

    const mat = new THREE.ShaderMaterial({
        uniforms: {time: {value: 0}},
        vertexShader: `
            uniform float time;
            attribute float size;
            attribute vec3 random;
            void main() {
                vec3 p = position;
                float t = time * 0.25 * random.z;
                float ax = t + random.y, ay = t * 0.75 + random.x;
                float amp = (0.6 + sin(random.x + t * 0.6) * 0.3) * random.z;
                p.x += sin(ax + p.y * 0.06 + random.x * 0.1) * amp;
                p.y += cos(ay + p.z * 0.06 + random.y * 0.1) * amp;
                p.z += sin(ax * 0.85 + p.x * 0.06 + random.z * 0.1) * amp;
                vec4 mvPosition = modelViewMatrix * vec4(p, 1.0);
                gl_PointSize = size * (300.0 / -mvPosition.z);
                gl_Position = projectionMatrix * mvPosition;
            }`,
        fragmentShader: `
            uniform float time;
            void main() {
                float d = length(gl_PointCoord - vec2(0.5));
                float alpha = 1.0 - smoothstep(0.4, 0.5, d);
                if (alpha < 0.01) discard;
                gl_FragColor = vec4(1.0, 1.0, 1.0, alpha);
            }`,
        transparent: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending
    });

    return new THREE.Points(geo, mat);
}

const particlesSpark = createSparkles(modelDataArray[0])
scene.add(particlesSpark);


const targetPositions = modelDataArray[0].positions;

const particleArr = particles.geometry.attributes.position.array;
const sparkleArr = particlesSpark.geometry.attributes.position.array;

for (let j = 0; j < particleCount; j++) {
    const idx = j * 3;

    // 直接从 targetPositions 拷贝三个连续的数值 (x, y, z)
    particleArr[idx] = targetPositions[idx];
    particleArr[idx + 1] = targetPositions[idx + 1];
    particleArr[idx + 2] = targetPositions[idx + 2];

    // 同步更新闪烁粒子
    if (j < particleSparkCount) {
        sparkleArr[idx] = targetPositions[idx];
        sparkleArr[idx + 1] = targetPositions[idx + 1];
        sparkleArr[idx + 2] = targetPositions[idx + 2];
    }
}

// 必须通知 GPU 更新
particles.geometry.attributes.position.needsUpdate = true;
particlesSpark.geometry.attributes.position.needsUpdate = true;

20260209102034转存失败,建议直接上传图片文件

我们又添加了一个createSparkles然后粒子位置和最开始的模型粒子位置一致,只不过颜色我们设置成白色!但是到这还没结束!我们的提亮魔法还要依靠THREE的后期处理能力!

EffectComposerThree.js 的 后期处理(Post-processing)管理器。它负责管理一个“通道(Pass)”队列。它不再直接将场景渲染到画布上,而是渲染到一个或多个缓冲帧中,经过各种视觉特效处理后,再呈现给用户。

const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), .45, .5, .85));
const after = new AfterimagePass();
after.uniforms.damp.value = .92;
composer.addPass(after);
composer.addPass(new OutputPass());
  • UnrealBloomPass 是用来做荧光、发光的效果
  • AfterimagePass 是用来做拖尾影效果

结束语

希望所有人 2026 事事如意!

参考代码

Three.js & GLSL Particle Metamorphosis

【ThreeJS实战】从86MB到4MB:复杂模型加载优化黑魔法

前言:正当我沉浸在将draw call从52000优化到1的喜悦中无法自拔时,产品经理这时候又杀过来了:"客户说模型加载要30秒,还没进去就关页面了,你优化一下?"我打开Network面板一看,卧槽,86MB的GLB文件!这谁顶得住啊...

如果你也遇到过这种情况:精心打磨的3D场景,本地运行丝滑流畅,一上线用户骂娘——"破网站卡死了"、"怎么还在转圈"、"手机直接闪退"。别急着怪用户网速慢,先看看你的模型是不是太胖了

我这有个复杂模型,几何体+贴图一共86MB,在4G网络下加载需要30秒(Chrome模拟Slow 4G(3mb/s)一直加载...)。今天咱们不讲Blender操作模型(之前用Blender是因为没招,现在有更狠的),直接用命令行黑魔法把它压到4MB!!,加载时间从30秒干到1.5秒

以下是优化前的绝望现场整整加载了30多秒...

image.png

一、优化思路

既然知道了加载为什么那么慢的原因,那我们就可以开始想想该怎么优化了

我目前的思路就是用gltf-transform 先把模型体积压下来,要不然渲染的时候再流畅,客户等到第二十秒的时候关闭浏览器,也没有意义了。。

二、DRACOLoader

ThreeJS DRACOLoader直接无缝解压缩被压缩的模型

安装压缩模型工具(不用Blender,命令行搞定)

# 安装gltf-transform(一行命令搞定Draco压缩+WebP+KTX2)
npm install -g @gltf-transform/cli

至于我为什么选择gltf-transform而不是gltf-pipeline,以下是它们的对比:

特性 gltf-pipeline gltf-transform
Draco压缩 ✅ 支持 ✅ 支持(更快)
WebP纹理 ❌ 不支持 ✅ 支持(关键!)
KTX2/Basis ❌ 不支持 ✅ 支持
安装体积 大(依赖多) 小(WASM核心)
推荐度 ⭐⭐⭐ ⭐⭐⭐⭐⭐

压缩你的GLB(80MB → 4MB)

gltf-transform optimize input.glb output.glb \
  --compress draco \
  --texture-compress webp \
  --texture-size 2048

以下是我压缩之后的体积:

image.png

可以看到,模型的体积得到了巨大的缩减,从原来的86mb到现在的4mb左右!

参数说明

参数 说明 建议值
--texture-compress webp 贴图转WebP格式 必加,体积减半
--texture-compress ktx2 贴图转KTX2(GPU直读) 如果目标设备支持,比WebP更好
--texture-size 2048 限制最大贴图尺寸 必加,4096→2048省4倍显存
--compress draco 启用Draco几何压缩 必加,默认就是sequential模式
--compress-method sequential Draco编码模式 sequential(默认,小体积)或 edgeloop(快解码)
--compress-level 10 Draco压缩级别 0-10,10压最狠但解压慢,建议7-10
--flatten 打平节点层级 如果模型层级太深,加这个减少DrawCall(但会丢失动画)

以下是优化之后的加载时间,就问你快不快!

image.png

Three.js加载代码(关键!)

/**
 * 优化后的 GLB 加载步骤(Draco / gltf-transform)
 *
 * 依赖:Three.js、GLTFLoader、DRACOLoader
 * 解码器:把 three 的 examples/jsm/libs/draco/gltf/ 放到站点 /draco/ 下,或使用 CDN 路径
 */

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader';

// ————— 步骤 1:创建 Draco 解码器并指定路径 —————
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/draco/');
// 或用 CDN(与项目 three 版本一致):'https://cdn.jsdelivr.net/npm/three@0.182.0/examples/jsm/libs/draco/gltf/'

// ————— 步骤 2:把 DRACOLoader 挂到 GLTFLoader 上 —————
const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);

// ————— 步骤 3:正常 load,普通 GLB 与 Draco 压缩的 GLB 都能加载 —————
loader.load(
  'https://your-cdn.com/model-optimized.glb',
  (gltf) => {
    scene.add(gltf.scene);
  },
  undefined,
  (err) => console.error(err),
);

// Promise 写法(可选):
export function loadOptimizedGLB(url) {
  return new Promise((resolve, reject) => {
    loader.load(url, resolve, undefined, reject);
  });
}
// 使用方式:const gltf = await loadOptimizedGLB(url);

注意setDecoderPath 指向的是 Draco 的 WASM 解码文件,需要从 Three.js 的 examples/jsm/libs/draco/ 目录复制到你的 public 文件夹,或者用 CDN(上面示例用的是从threejs复制的本地解码文件)。

image.png

image.png

避坑指南

  1. 别重复压缩:Draco是有损压缩,压一次损失一点精度,别压两遍!先备份原文件。
  2. WebP兼容性:虽然现代浏览器都支持WebP,但如果你要兼容IE11(虽然不应该),只能用PNG/JPG。
  3. KTX2谨慎用:KTX2(Basis Universal)压缩率最高,但需要 GPU 支持,老旧手机可能解码失败,建议 WebP 更稳妥。
  4. 量化精度:如果你发现压缩后的模型出现裂缝(顶点没对齐),把 --quantization-position 从 10 调到 14。

还有一件事:Draco是有损压缩,但视觉上几乎看不出差别(工业模型顶点精度够高),解压是在Web Worker里进行的,不会卡主线程。

三、又到了喜闻乐见的前后对比(刺激!)

指标 原始模型 Draco压缩
文件体积 86MB 4MB
4G加载时间 30秒 1.5秒

可以看到加载时间跨度很大,从30秒到1.5秒,足足提升了20倍,客户本来都要睡着了,但现在客户眨了一下眼睛,就发现眼前屏幕里的世界都不一样了~

总结

优化路径:86MB(原始)→ 4MB(Draco+WebP)→ 1.5秒加载完成

核心认知

  • gltf-transform:一站式解决几何体+贴图压缩,不用Blender,一行命令搞定
  • Draco:解决"下载慢"(几何体从18MB压到2MB)
  • WebP:解决"贴图肥"(68MB压到2MB,兼容性最好)

没用到的手段(进阶可选)

  • KTX2:比WebP体积更小且GPU直读,但需要设备支持,老旧手机可能解码失败
  • 分块加载:如果4MB还是大,可以拆成"外壳1MB+细节3MB",首屏秒开

不用Blender,全程命令行+代码搞定,这才是工程师的浪漫。

下篇预告:【ThreeJS实战】GPU还是100%?LOD策略:让远处模型自动"减肥"

互动:你用gltf-transform压了多少倍?我20倍算不算狠?评论区报出你的原始体积vs优化后体积,看看谁是真正的"压王"😏

Three.js 透视相机完全指南:从入门到精通

什么是透视相机?

生活中的类比

想象你拿着一部手机摄像头对着一个房间:

  • 📱 手机摄像头 = Three.js 中的相机
  • 🎬 摄像头能看到的范围 = 视锥体(Frustum)
  • 📐 摄像头的视角宽度 = 视野角度(FOV)

透视相机就像真实的摄像头一样,离物体越近,看到的范围越小;离物体越远,看到的范围越大。这就是"透视"的含义。

代码示例

import * as THREE from 'three';

// 创建透视相机
const camera = new THREE.PerspectiveCamera(
    40,      // 视野角度(FOV)
    2,       // 宽高比(aspect ratio)(一般3D游戏中的屏幕适配都是在调整这个值)
    0.1,     // 近裁剪面(near)
    1000     // 远裁剪面(far)
);

camera.position.z = 120; // 相机位置

四个关键参数详解

参数 1️⃣:视野角度(FOV - Field of View)

定义:相机能看到的垂直视角范围,单位是度数(°)。

直观理解

  • 🔭 FOV = 10° → 像用望远镜看,视角很窄,看到的东西很小但很清晰
  • 📷 FOV = 40° → 像用普通手机摄像头,视角适中
  • 🐟 FOV = 90° → 像用鱼眼镜头,视角很宽,看到很多东西但会变形
// 小视角 - 看得清楚但范围小
const camera1 = new THREE.PerspectiveCamera(20, 2, 0.1, 1000);

// 中等视角 - 平衡
const camera2 = new THREE.PerspectiveCamera(40, 2, 0.1, 1000);

// 大视角 - 看得多但容易变形
const camera3 = new THREE.PerspectiveCamera(75, 2, 0.1, 1000);

实际应用

  • 🎮 第一人称游戏:FOV 通常 60-90°
  • 🏢 建筑可视化:FOV 通常 40-50°
  • 🎥 电影效果:FOV 通常 24-35°

参数 2️⃣:宽高比(Aspect Ratio)

定义:相机画面的宽度与高度的比例

计算方式

const aspect = window.innerWidth / window.innerHeight;
// 例如:1920 / 1080 = 1.777...

为什么重要

  • ✅ 如果 aspect 与实际画布比例一致,画面不会被拉伸
  • ❌ 如果不一致,圆形会变成椭圆,正方形会变成矩形
// 假设窗口是 1920×1080
const aspect = 1920 / 1080; // ≈ 1.78

const camera = new THREE.PerspectiveCamera(40, aspect, 0.1, 1000);

响应式设计

function onWindowResize() {
    const width = window.innerWidth;
    const height = window.innerHeight;
    
    camera.aspect = width / height;
    camera.updateProjectionMatrix(); // 重要!更新投影矩阵
    
    renderer.setSize(width, height);
}

window.addEventListener('resize', onWindowResize);

参数 3️⃣:近裁剪面(Near)

定义:相机能看到的最近距离。比这个距离更近的物体不会被显示。

为什么需要它

  • 🎯 性能优化:不渲染相机背后的物体
  • 🔍 避免穿模:防止相机进入物体内部时看到内部结构
// near = 0.1 意味着距离相机 0.1 单位以内的物体看不到
const camera = new THREE.PerspectiveCamera(40, 2, 0.1, 1000);

// 如果物体在 z = 0.05,它会被裁剪掉
const cube = new THREE.Mesh(geometry, material);
cube.position.z = 0.05; // ❌ 看不到

参数 4️⃣:远裁剪面(Far)

定义:相机能看到的最远距离。比这个距离更远的物体不会被显示。

为什么需要它

  • 🎯 性能优化:不渲染太远的物体
  • 🔍 深度精度:提高深度缓冲的精度
// far = 1000 意味着距离相机 1000 单位以外的物体看不到
const camera = new THREE.PerspectiveCamera(40, 2, 0.1, 1000);

// 如果物体在 z = 1500,它会被裁剪掉
const star = new THREE.Mesh(geometry, material);
star.position.z = 1500; // ❌ 看不到

视锥体的可视范围计算

什么是视锥体?

视锥体是一个四棱锥形的空间,只有在这个空间内的物体才能被看到。

计算可视范围的公式

在距离相机 distance 处,可视范围的大小为:

垂直高度 = 2 × tan(FOV/2) × distance
水平宽度 = 垂直高度 × aspect

实际计算示例

假设我们有这样的相机配置:

const fov = 40;           // 视野角度
const aspect = 2;         // 宽高比(2:1)
const distance = 120;     // 相机距离(z = 120)

// 计算垂直可视高度
const vFOV = fov * Math.PI / 180; // 转换为弧度
const height = 2 * Math.tan(vFOV / 2) * distance;
// height = 2 × tan(20°) × 120
// height = 2 × 0.364 × 120
// height ≈ 87.2

// 计算水平可视宽度
const width = height * aspect;
// width = 87.2 × 2
// width ≈ 174.4

结论:在 z=120 处,相机能看到的范围是:

  • 📏 宽度:174.4 单位
  • 📏 高度:87.2 单位

坐标范围的确定

场景布局示例

假设我们要在场景中放置一个 5×4 的网格(5列 4行),每个物体之间间距为 15 单位:

const spread = 15; // 间距

// 物体位置计算
for (let x = -2; x <= 2; x++) {
    for (let y = -1; y <= 2; y++) {
        const obj = createObject();
        obj.position.x = x * spread;  // x: -30, -15, 0, 15, 30
        obj.position.y = y * spread;  // y: -15, 0, 15, 30
        scene.add(obj);
    }
}

对象分布范围

  • X 轴:-30 到 30(宽度 60)
  • Y 轴:-15 到 30(高度 45)

检查是否超出可视范围

// 相机可视范围
const visibleWidth = 174.4;
const visibleHeight = 87.2;

// 对象范围
const objectWidth = 60;
const objectHeight = 45;

// 检查
console.log(`宽度是否超出: ${objectWidth > visibleWidth}`); // false ✅
console.log(`高度是否超出: ${objectHeight > visibleHeight}`); // false ✅

如果超出范围怎么办?

问题:当 spread=20 时,对象范围变为 80×60,超出了可视范围。

解决方案

方案 1:增加相机距离

// 原来
camera.position.z = 120;

// 改为
camera.position.z = 160; // 距离越远,看到的范围越大

方案 2:增加视野角度

// 原来
const fov = 40;

// 改为
const fov = 60; // 视角越大,看到的范围越大

方案 3:减小间距

// 原来
const spread = 20;

// 改为
const spread = 15; // 物体靠得更近

方案 4:使用正交相机

如果你不需要透视效果,可以使用正交相机(OrthographicCamera),它的可视范围不会随距离变化:

const camera = new THREE.OrthographicCamera(
    -100,  // left
    100,   // right
    50,    // top
    -50,   // bottom
    0.1,   // near
    1000   // far
);

实战代码

完整的响应式相机设置

import * as THREE from 'three';

class ResponsiveCamera {
    constructor() {
        this.fov = 40;
        this.near = 0.1;
        this.far = 1000;
        this.distance = 120;
        
        this.updateCamera();
    }
    
    updateCamera() {
        const aspect = window.innerWidth / window.innerHeight;
        
        this.camera = new THREE.PerspectiveCamera(
            this.fov,
            aspect,
            this.near,
            this.far
        );
        
        this.camera.position.z = this.distance;
    }
    
    // 计算指定深度处的可视范围
    getVisibleRange(depth = null) {
        const vFOV = (this.fov * Math.PI) / 180;
        // 如果没有指定深度,使用相机的默认距离
        const distance = depth !== null ? depth : this.distance;
        const height = 2 * Math.tan(vFOV / 2) * distance;
        const width = height * (window.innerWidth / window.innerHeight);

        return { width, height };
    }
    
    // 检查物体是否在可视范围内
    isObjectVisible(obj) {
        const pos = obj.position;
        
        // 计算物体相对于相机的距离(沿着相机的视线方向)
        const distanceFromCamera = this.camera.position.z - pos.z;
        
        // 计算物体所在深度的可视范围
        const range = this.getVisibleRange(distanceFromCamera);
        console.log('物体距离相机:', distanceFromCamera, '该深度的可视范围:', range);

        return (
            Math.abs(pos.x) <= range.width / 2 &&
            Math.abs(pos.y) <= range.height / 2 &&
            distanceFromCamera >= this.near &&
            distanceFromCamera <= this.far
        );
    }
    
    // 窗口大小改变时更新
    onWindowResize() {
        this.updateCamera();
        this.camera.updateProjectionMatrix();
    }
}

// 使用示例
// 使用示例
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x222222);

// 4. 创建渲染器:将3D场景渲染到网页上
const renderer = new THREE.WebGLRenderer({ antialias: true }); // antialias开启抗锯齿
renderer.setSize(window.innerWidth, window.innerHeight); // 设置渲染尺寸
// 将渲染器的画布添加到网页中
document.body.appendChild(renderer.domElement);

const camera = new ResponsiveCamera();
window.addEventListener('resize', () => camera.onWindowResize());

// 添加光源
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);

const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(50, 50, 50);
scene.add(directionalLight);

// 检查物体是否可见
const cube = new THREE.Mesh(new THREE.BoxGeometry(8, 8, 8), createMaterial());
cube.position.set(20, 20, -10);
scene.add(cube);

function animate(time) {
    renderer.render(scene, camera.getCamera());
    requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

console.log(camera.isObjectVisible(cube)); // true or false

小结

  1. FOV 和 distance 决定可视范围

    • FOV 越大,看到的范围越大
    • distance 越大,看到的范围越大
  2. aspect 必须与画布比例一致

    • 否则画面会被拉伸变形
  3. near 和 far 定义了深度范围

    • 在这个范围外的物体看不到

📂 核心代码与完整示例:    my-three-app

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多Three.js开发干货

THREE.js 摄像机

前置代码

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    html,
    body {
      margin: 0;
      height: 100%;
    }

    #c {
      width: 100%;
      height: 100%;
      display: block;
    }

    .split {
      position: absolute;
      left: 0;
      top: 0;
      width: 100%;
      height: 100%;
      display: flex;
    }

    .split>div {
      width: 100%;
      height: 100%;
    }
  </style>
</head>

<body>

  <canvas id="c">

  </canvas>
  <div class="split">
    <div id="view1"></div>
    <div id="view2"></div>
  </div>
  <script type="importmap">
    {
        "imports": {
            "three": "https://esm.sh/three@0.174.0/build/three.module.js",
            "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.174.0/examples/jsm/"
        }   
    }
    </script>
  <script type="module" src="./index.js"></script>
</body>

</html>

透视摄像机PerspectiveCamera

PerspectiveCamera 通过四个属性来定义一个视锥, near定义视锥前端, far定义远端, fov是视野, 通过计算正确的高度来从摄像机的位置获取指定的以near为单位的视野, 定义的是视锥的前端和远端的高度 aspect间接地定义了视锥前端和远端的宽度, 实际上视锥的宽度是通过高度乘以 aspect 来得到的

下面这个例子我们使用 three 的剪函数, 把视图分成两部分, 主视图正常渲染, 辅视图用来观察 cameraHelper 的渲染

import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GUI } from "three/addons/libs/lil-gui.module.min.js";

function main() {
  const canvas = document.querySelector("#c");
  const view1Elem = document.querySelector("#view1");
  const view2Elem = document.querySelector("#view2");

  const renderer = new THREE.WebGLRenderer({ antialias: true, canvas });

  // #region 左视图的相机
  const fov = 45;
  const aspect = 2; // the canvas default
  const near = 0.1;
  const far = 100;
  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
  camera.position.set(0, 10, 20);

  const cameraHelper = new THREE.CameraHelper(camera);

  const controls = new OrbitControls(camera, view1Elem);
  controls.target.set(0, 5, 0);
  controls.update();

  // #endregion

  // #region 右视图的相机
  const camera2 = new THREE.PerspectiveCamera(
    60, // fov
    2, // aspect
    0.1, // near
    500, // far
  );
  camera2.position.set(40, 10, 30);
  camera2.lookAt(0, 5, 0);

  const controls2 = new OrbitControls(camera2, view2Elem);
  controls2.target.set(0, 5, 0);
  controls2.update();

  // #endregion

  /**
   * 设置裁剪区域和视口, 返回宽高比
   * @param {HTMLElement} elem
   * @returns
   */
  function setScissorForElement(elem) {
    // 获取 canvas 与元素的边界矩形
    const canvasRect = canvas.getBoundingClientRect();
    const elemRect = elem.getBoundingClientRect();

    // 相对位置计算元素在 canvas 内的左右上下边界
    const right = Math.min(elemRect.right, canvasRect.right) - canvasRect.left;
    const left = Math.max(0, elemRect.left - canvasRect.left);
    const bottom = Math.min(elemRect.bottom, canvasRect.bottom) - canvasRect.top;
    const top = Math.max(0, elemRect.top - canvasRect.top);

    const width = Math.min(canvasRect.width, right - left);
    const height = Math.min(canvasRect.height, bottom - top);

    // 设置裁剪
    const positiveYUpBottom = canvasRect.height - bottom;

    // 对 renderer 设置裁剪区域和视口
    renderer.setScissor(left, positiveYUpBottom, width, height);
    renderer.setViewport(left, positiveYUpBottom, width, height);

    return width / height;
  }

  // gui 使用,限制对象中属性的最大值最小值
  class MinMaxGUIHelper {
    constructor(obj, minProp, maxProp, minDif) {
      this.obj = obj;
      this.minProp = minProp;
      this.maxProp = maxProp;
      this.minDif = minDif;
    }
    get min() {
      return this.obj[this.minProp];
    }
    set min(v) {
      this.obj[this.minProp] = v;
      this.obj[this.maxProp] = Math.max(this.obj[this.maxProp], v + this.minDif);
    }
    get max() {
      return this.obj[this.maxProp];
    }
    set max(v) {
      this.obj[this.maxProp] = v;
      this.min = this.min; // this will call the min setter
    }
  }

  // #region 添加相机属性的gui界面
  const gui = new GUI();
  gui.add(camera, "fov", 1, 180);
  const minMaxGUIHelper = new MinMaxGUIHelper(camera, "near", "far", 0.1);
  gui.add(minMaxGUIHelper, "min", 0.1, 50, 0.1).name("near");
  gui.add(minMaxGUIHelper, "max", 0.1, 50, 0.1).name("far");

  // #endregion

  const scene = new THREE.Scene();
  scene.background = new THREE.Color("black");
  scene.add(cameraHelper);

  {
    const planeSize = 40;

    const loader = new THREE.TextureLoader();
    const texture = loader.load("https://threejs.org/manual/examples/resources/images/checker.png");
    texture.wrapS = THREE.RepeatWrapping;
    texture.wrapT = THREE.RepeatWrapping;
    texture.magFilter = THREE.NearestFilter;
    //texture.colorSpace = THREE.SRGBColorSpace;
    const repeats = planeSize / 2;
    texture.repeat.set(repeats, repeats);

    const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
    const planeMat = new THREE.MeshPhongMaterial({
      map: texture,
      side: THREE.DoubleSide,
    });
    const mesh = new THREE.Mesh(planeGeo, planeMat);
    mesh.rotation.x = Math.PI * -0.5;
    scene.add(mesh);
  }

  {
    const cubeSize = 4;
    const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
    const cubeMat = new THREE.MeshPhongMaterial({ color: "#8AC" });
    const mesh = new THREE.Mesh(cubeGeo, cubeMat);
    mesh.position.set(cubeSize + 1, cubeSize / 2, 0);
    scene.add(mesh);
  }

  {
    const sphereRadius = 3;
    const sphereWidthDivisions = 32;
    const sphereHeightDivisions = 16;
    const sphereGeo = new THREE.SphereGeometry(
      sphereRadius,
      sphereWidthDivisions,
      sphereHeightDivisions,
    );
    const sphereMat = new THREE.MeshPhongMaterial({ color: "#CA8" });
    const mesh = new THREE.Mesh(sphereGeo, sphereMat);
    mesh.position.set(-sphereRadius - 1, sphereRadius + 2, 0);
    scene.add(mesh);
  }

  {
    const color = 0xffffff;
    const intensity = 3;
    const light = new THREE.DirectionalLight(color, intensity);
    light.position.set(0, 10, 0);
    light.target.position.set(-5, 0, 0);
    scene.add(light);
    scene.add(light.target);
  }

  function resizeRendererToDisplaySize(renderer) {
    const canvas = renderer.domElement;
    const width = canvas.clientWidth;
    const height = canvas.clientHeight;
    const needResize = canvas.width !== width || canvas.height !== height;
    if (needResize) {
      renderer.setSize(width, height, false);
    }

    return needResize;
  }

  function render() {
    resizeRendererToDisplaySize(renderer);

    // 启用剪刀函数
    renderer.setScissorTest(true);

    // #region 视图1 渲染
    const aspect1 = setScissorForElement(view1Elem);
    camera.aspect = aspect1;
    camera.updateProjectionMatrix();
    // 不在视图 1中渲染 helper
    cameraHelper.visible = false;
    cameraHelper.update();
    renderer.render(scene, camera);
    // #endregion

    // #region 视图2 渲染
    const aspect2 = setScissorForElement(view2Elem);
    camera2.aspect = aspect2;
    camera2.updateProjectionMatrix();
    // 在第二台摄像机中绘制cameraHelper
    cameraHelper.visible = true;
    // 单独给视图 2 设置个背景色
    scene.background.set(0x000040);
    renderer.render(scene, camera2);

    // #endregion

    requestAnimationFrame(render);
  }

  requestAnimationFrame(render);
}

main();

image.png


正交摄像机OrthographicCamera

与透视摄像机不同的是, 它需要设置left right top bottom nearfar 指定一个长方形, 使得视野是平行的而不是透视的

使用 zoom 属性可以缩放世界 -> 屏幕的映射比例, 不改变实际尺寸

< 1 看到更多 > 1 看到更少

import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GUI } from "three/addons/libs/lil-gui.module.min.js";

function main() {
  const canvas = document.querySelector("#c");
  const view1Elem = document.querySelector("#view1");
  const view2Elem = document.querySelector("#view2");

  const renderer = new THREE.WebGLRenderer({
    antialias: true,
    canvas,
    logarithmicDepthBuffer: true,
  });

  // #region 左视图的相机
  const size = 1;
  const near = 5;
  const far = 50;
  const camera = new THREE.OrthographicCamera(-size, size, size, -size, near, far);
  camera.zoom = 0.2;
  camera.position.set(0, 20, 0);
  // camera.lookAt(0, 0, 0);
  const cameraHelper = new THREE.CameraHelper(camera);

  const controls = new OrbitControls(camera, view1Elem);
  controls.target.set(2, 0, 0);
  controls.update();

  // #endregion

  // #region 右视图的相机
  const camera2 = new THREE.PerspectiveCamera(
    60, // fov
    2, // aspect
    0.1, // near
    500, // far
  );
  camera2.position.set(40, 10, 30);
  camera2.lookAt(0, 10, 0);

  const controls2 = new OrbitControls(camera2, view2Elem);
  controls2.target.set(0, 5, 0);
  controls2.update();

  // #endregion

  /**
   * 设置裁剪区域和视口, 返回宽高比
   * @param {HTMLElement} elem
   * @returns
   */
  function setScissorForElement(elem) {
    // 获取 canvas 与元素的边界矩形
    const canvasRect = canvas.getBoundingClientRect();
    const elemRect = elem.getBoundingClientRect();

    // 相对位置计算元素在 canvas 内的左右上下边界
    const right = Math.min(elemRect.right, canvasRect.right) - canvasRect.left;
    const left = Math.max(0, elemRect.left - canvasRect.left);
    const bottom = Math.min(elemRect.bottom, canvasRect.bottom) - canvasRect.top;
    const top = Math.max(0, elemRect.top - canvasRect.top);

    const width = Math.min(canvasRect.width, right - left);
    const height = Math.min(canvasRect.height, bottom - top);

    // 设置裁剪
    const positiveYUpBottom = canvasRect.height - bottom;

    // 对 renderer 设置裁剪区域和视口
    renderer.setScissor(left, positiveYUpBottom, width, height);
    renderer.setViewport(left, positiveYUpBottom, width, height);

    return width / height;
  }

  // gui 使用,限制对象中属性的最大值最小值
  class MinMaxGUIHelper {
    constructor(obj, minProp, maxProp, minDif) {
      this.obj = obj;
      this.minProp = minProp;
      this.maxProp = maxProp;
      this.minDif = minDif;
    }
    get min() {
      return this.obj[this.minProp];
    }
    set min(v) {
      this.obj[this.minProp] = v;
      this.obj[this.maxProp] = Math.max(this.obj[this.maxProp], v + this.minDif);
    }
    get max() {
      return this.obj[this.maxProp];
    }
    set max(v) {
      this.obj[this.maxProp] = v;
      this.min = this.min; // this will call the min setter
    }
  }

  // #region 添加相机属性的gui界面
  const gui = new GUI();
  // gui.add(camera, "fov", 1, 180);
  const minMaxGUIHelper = new MinMaxGUIHelper(camera, "near", "far", 0.1);
  gui.add(minMaxGUIHelper, "min", 0.1, 50, 0.1).name("near");
  gui.add(minMaxGUIHelper, "max", 0.1, 50, 0.1).name("far");
  gui.add(camera, "zoom", 0.01, 1).name("zoom").listen(); // 调整相机展现多少单位大小

  // #endregion

  const scene = new THREE.Scene();
  scene.background = new THREE.Color("black");
  scene.add(cameraHelper);

  {
    const planeSize = 40;

    const loader = new THREE.TextureLoader();
    const texture = loader.load("https://threejs.org/manual/examples/resources/images/checker.png");
    texture.wrapS = THREE.RepeatWrapping;
    texture.wrapT = THREE.RepeatWrapping;
    texture.magFilter = THREE.NearestFilter;
    //texture.colorSpace = THREE.SRGBColorSpace;
    const repeats = planeSize / 2;
    texture.repeat.set(repeats, repeats);

    const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
    const planeMat = new THREE.MeshPhongMaterial({
      map: texture,
      side: THREE.DoubleSide,
    });
    const mesh = new THREE.Mesh(planeGeo, planeMat);
    mesh.rotation.x = Math.PI * -0.5;
    scene.add(mesh);
  }

  {
    const cubeSize = 4;
    const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
    const cubeMat = new THREE.MeshPhongMaterial({ color: "#8AC" });
    const mesh = new THREE.Mesh(cubeGeo, cubeMat);
    mesh.position.set(cubeSize + 1, cubeSize / 2, 0);
    scene.add(mesh);
  }

  {
    const sphereRadius = 3;
    const sphereWidthDivisions = 32;
    const sphereHeightDivisions = 16;
    const sphereGeo = new THREE.SphereGeometry(
      sphereRadius,
      sphereWidthDivisions,
      sphereHeightDivisions,
    );
    const sphereMat = new THREE.MeshPhongMaterial({ color: "#CA8" });
    const mesh = new THREE.Mesh(sphereGeo, sphereMat);
    mesh.position.set(-sphereRadius - 1, sphereRadius + 2, 0);
    scene.add(mesh);
  }

  {
    const color = 0xffffff;
    const intensity = 3;
    const light = new THREE.DirectionalLight(color, intensity);
    light.position.set(0, 10, 0);
    light.target.position.set(-5, 0, 0);
    scene.add(light);
    scene.add(light.target);
  }

  function resizeRendererToDisplaySize(renderer) {
    const canvas = renderer.domElement;
    const width = canvas.clientWidth;
    const height = canvas.clientHeight;
    const needResize = canvas.width !== width || canvas.height !== height;
    if (needResize) {
      renderer.setSize(width, height, false);
    }

    return needResize;
  }

  function render() {
    resizeRendererToDisplaySize(renderer);

    // 启用剪刀函数
    renderer.setScissorTest(true);

    // #region 视图1 渲染
    const aspect1 = setScissorForElement(view1Elem);
    camera.left = -aspect1;
    camera.right = aspect1;
    camera.updateProjectionMatrix();
    // 不在视图 1中渲染 helper
    cameraHelper.visible = false;
    cameraHelper.update();
    renderer.render(scene, camera);
    // #endregion

    // #region 视图2 渲染
    const aspect2 = setScissorForElement(view2Elem);
    camera2.aspect = aspect2;
    camera2.updateProjectionMatrix();
    // 在第二台摄像机中绘制cameraHelper
    cameraHelper.visible = true;
    // 单独给视图 2 设置个背景色
    scene.background.set(0x000040);
    renderer.render(scene, camera2);

    // #endregion

    requestAnimationFrame(render);
  }

  requestAnimationFrame(render);
}

main();


image.png


❌