26.Three.js解决方案之透明度bug

26 Three.js解决方案之透明度bug

透明度(transparency)Three.js中很容易实现,但是透明度又存在一个 “Bug”,解决起来又比较难。

请注意,这里说的 “Bug” 是加了引号的,具体原因我们稍后讲解。


我们先从一个简单的示例开始。

示例1:渲染一个半透明的立方体

所有材质的基类Three.Material2个属性和设置透明度有关:

  1. transparent:设置材质是否透明
  2. opacity:设置材质透明度,取值范围0 - 1

由于Material是所有材质的基类,也就意味着所有材质都拥有上述2个属性


假设我们想创建一个半透明的立方体,代码如下:

const geometry = new Three.BoxBufferGeometry(2, 2, 2)
const material = new Three.MeshBasicMaterial({
    color: 'red',
    transparent: true,
    opacity: 0.5
})
const cube = new Three.Mesh(geometry, material)
scene.add(cube)

上面我们只是给材质设置了一个 红色,当我们运行程序的时候会发现:单独一个半透明立方体,似乎看不出任何半透明的意思。

即使给材质添加 side: Three.DoubleSide

我们需要添加多个半透明立方体,才更容易看出来彼此半透明。

我们继续修改示例。


渲染8个小立方体

我们的渲染目标:

  1. 渲染8个不同颜色,透明度都为0.5的立方体

  2. 8个立方体分布在一个2 x 2 x 2的空间中

    这种分布方式,在魔方玩具中被称为 “二阶魔方”

  3. 每个立方体 里外2个面都要进行渲染


具体实现代码:

const colors = ['red', 'blue', 'darkorange', 'darkviolet', 'green', 'tomato', 'sienna', 'crimson']
const cube_size = 1 //立方体尺寸
const cube_margin = 0.6 //立方体间距空隙
colors.forEach((color, index) => {
    const geometry = new Three.BoxBufferGeometry(cube_size, cube_size, cube_size)
    const material = new Three.MeshPhongMaterial({
        color,
        transparent: true,
        opacity: 0.5,
        side: Three.DoubleSide
    })

    const cube = new Three.Mesh(geometry, material)
    cube.position.x = (index % 2 ? 1 : -1) * cube_size * cube_margin
    cube.position.y = (Math.floor(index / 4) ? -1 : 1) * cube_size * cube_margin
    cube.position.z = ((index % 4) >= 2) ? 1 : -1 * cube_size * cube_margin / 2

    scene.add(cube)
})

在目前最新的Three.js r127版本中,对于预置颜色的单词,只支持全小写,例如:

  1. 红色 只可以写 red,不可以写成 Red
  2. 再或者 暗桔色 只可以写 darkorange,不可以写成DarkOrange

这是因为在Color源码中,记录内置颜色值的对象key都是小写,我已针对这个问题提交了自己的pr

https://github.com/mrdoob/three.js/pull/21687

我修改了一点代码,让取值时对颜色值的字符串执行.toLowerCase(),这样即使颜色值字符串有大写可以最终实际被转化为小写。


实际运行后,就会看到8个半透明的小立方体。通过OrbitControls旋转视角,看着感觉挺好的呀。


当你尝试不断变换视角查看立方体时,在某些特殊的视角下,我们看不到立方体左侧后表面。

也就是说,原本立方体左侧后表面应该也被渲染,但是实际上并未被渲染。


如果你实在是没有看出来什么问题,暂时相信我一下,就好像你已真的发现了那样。

下面听一下关于出现这个 “bug” 的解释。


Three.js绘制3D对象的方式

上面提到的渲染 “Bug”,是由于Three.js绘制3D对象的方式造成的。


对于每一个几何图形,每个三角形一次只绘制一个。

立方体的1个面是一个 正方形,而这个正方形是由2个三角形构成的。

在绘制1个面(正方形)Three.js会先后绘制2个三角形,最终拼接成1个正方形。


每次绘制一个三角形时,会记录2个事情:

  1. 三角形的颜色

  2. 三角形的像素深度

    像素深度是指存储每个像素所用的位数,用来度量图像的分辨率。

  3. 当绘制下一个三角形时,对于每一个像素,如果深度比之前记录的深度还要深,则不会绘制任何像素

    这其实是Three.js绘制物体时采取的一种节省性能的策略


这套策略对于不透明的物体来说非常有用,但是对于透明的物体却不起作用。


一个立方体有6个面,每个面2个三角形,也就意味着一个立方体需要绘制12个三角形。

而这12个三角形究竟先绘制哪个,他们绘制的顺序是什么呢?

答:绘制顺序取决于我们的视角,越接近相机的三角形越优先被绘制。

这就是为什么我们上面提到的绘制bug只有在某些特定角度下才会出现的原因。

越靠近相机的三角形越先被绘制,这也意味着在某些角度下,远离摄像机的某个面(立方体背面或侧背面)有可能不会被绘制。

这种情况不仅会出现在立方体身上,球体上也会出现。


针对以上情况,有一种解决方案:

  1. 将每个立方体添加2次到场景中
  2. 1次添加的立方体设置只让渲染 背面(Three.BackSide)
  3. 2次添加的立方体设置只让渲染 前面(Three.FrontSide)

这样一番操作过后,确保Three.js可以将每个立方体的前面、后面都会渲染,拼合一下2次的渲染结果,将 “正确的” 结果渲染出来。


补充说明

  1. 上面的解决方案实际上需要绘制2次,这也许会造成性能上的浪费。
  2. 如果你并不是特别在乎那一点点渲染 “Bug”,你完全可以忽视它。

接下来我们再通过另外一个示例,讲解另外一种有针对性的解决方案。


绘制2个中心交叉的平面正方形

我们的示例目标是:

  1. 绘制2个平面的正方形

    创建平面 在Three.js中使用的是Three.PlaneBufferGeometry

  2. 给每个平面添加一个颜色和纹理贴图,两面都渲染,并设置正方形平面透明度为0.5

    贴图我们直接使用网上的2张图片资源

    图片都是由背景色的,并非背景透明的PNG

  3. 让这2个平面形成 十字交叉 的状态

    也就是说让其中一个平面的y轴旋转180


实现的代码:

const planeDataArr = [
    {
        color: 'red',
        ratation: 0,
        imgsrc: 'https://threejsfundamentals.org/threejs/resources/images/happyface.png'
    },
    {
        color: 'yellow',
        ratation: Math.PI * 0.5,
        imgsrc: 'https://threejsfundamentals.org/threejs/resources/images/hmmmface.png'
    }
]

planeDataArr.forEach((value) => {
    const geometry = new Three.PlaneBufferGeometry(2, 2)
    const textureLoader = new TextureLoader()
    const material = new Three.MeshBasicMaterial({
        color: value.color,
        map: textureLoader.load(value.imgsrc),
        opacity: 0.5,
        transparent: true,
        side:Three.DoubleSide
    })
    const plane = new Three.Mesh(geometry, material)
    plane.rotation.y = value.ratation
    scene.add(plane)
})

这次,我们将很容易看到,当红色平面一侧完全覆盖住黄色平面一侧时,会完全看不到黄色平面那一侧。

实际运行效果我就不贴图了,你可以将上面代码实际运行一下。

你就假装此刻你看到了。


用我们上面讲过的理论可解释这个现象:即 红色平面一侧颜色深度大于黄色平面一侧,当完全覆盖住之后黄色平面那一侧就不会再进行渲染,所以我们就看不到了。


解决方案:将上面2个平面拆分成4个平面,这样可以确保每个平面都会被渲染。

由于我们这个场景2个平面十字交叉,所以我们就直接创建4个小的平面,然后将这4个小平面组合成 “2个十字相交的平面”。

具体实现的方式是:

  1. 将原本 较大的1个平面拆分成2个小平面

  2. 设置这2个小平面的纹理贴图偏移,各自占一半

    这样可以最终让2个小平面贴合成1个完整的平面(纹理)

  3. 为了方便我们计算旋转,好让他们形成十字交叉,所以我们可以将 每组小平面放置在同一个空间中

    这需要使用到Three.Object3D


实际代码:

const planeDataArr = [
    {
        color: 'red',
        ratation: 0,
        imgsrc: 'https://threejsfundamentals.org/threejs/resources/images/happyface.png'
    },
    {
        color: 'yellow',
        ratation: Math.PI * 0.5,
        imgsrc: 'https://threejsfundamentals.org/threejs/resources/images/hmmmface.png'
    }
]

planeDataArr.forEach((value) => {
    const base = new Three.Object3D()
    base.rotation.y = value.ratation
    scene.add(base)

    const plane_size = 2
    const half_size = plane_size / 2
    const geometry = new Three.PlaneBufferGeometry(half_size, plane_size)
    const arr = [-1, 1]
    arr.forEach((x) => {
        const textureLoader = new TextureLoader()
        const texture = textureLoader.load(value.imgsrc)
        texture.offset.x = x < 1 ? 0 : 0.5
        texture.repeat.x = 0.5
        const material = new Three.MeshBasicMaterial({
            color: value.color,
            map: texture,
            transparent: true,
            opacity: 0.5,
            side: Three.DoubleSide
        })
        const plane = new Three.Mesh(geometry, material)
        plane.position.x = x * half_size / 2
        base.add(plane)
    });
})

假设我们目标平面宽高均为2,那么:

  1. 我们将该目标平面拆分成2个 宽1、高2的平面
  2. 获取并设置纹理贴图,并分别设置纹理的offset.xrepeat.x各占 一半,也就是0.5
  3. 我们知道拆分出的2个小平面他们x轴相差1个小平面的宽度,由于我们设置的内部循环数组为[-1,1],所以2个小平面的x值应该是 正负宽度一半的一半。

这一次,我们再运行就会发现,无论任何视角下,红色平面不再会这该黄色平面了。


这是第2种解决透明 “bug” 的方案:将对象进行拆分

但是请注意,该解决方式只适合那些简单,且位置相对固定的3D对象。

若物体本身就比较复杂,面比较多,还要再拆分,那么就太消耗渲染性能

并且位置必须相对固定,若不固定会增加我们拼接的难度


接下来讲解第3种解决方案。

启用alphaTest来避免遮挡问题

首先我们回顾一下上面的例子,当时示例中2个平面的纹理贴图背景是不透明的。

那我们可以尝试另外2个图片贴图,他们是背景透明的PNG图片。

我们要使用材质(Three.Material)的一个属性.alphaTest


.alphaTest属性介绍

.alphaTest是一个透明度检测值,值得类型是Number,取值范围为0 - 1

若透明度低于该值,则不会进行渲染。

反之,只有某个点透明度高于该值的才会进行渲染

.alphaTest默认值为0

也就是说默认情况下即使透明度为0也会进行渲染

假设我们给材质设置有color,那么肯定就会渲染出内容


我们在最初2个平面的代码基础上进行修改。

  1. 修改纹理贴图资源,这次使用背景透明的PNG图片
  2. 材质不再设置.opacity属性,改设置.alphaTest属性

修改后的代码如下:

const planeDataArr = [
    {
        color: 'red',
        ratation: 0,
        imgsrc: 'https://threejsfundamentals.org/threejs/resources/images/tree-01.png'
    },
    {
        color: 'yellow',
        ratation: Math.PI * 0.5,
        imgsrc: 'https://threejsfundamentals.org/threejs/resources/images/tree-02.png'
    }
]

planeDataArr.forEach((value) => {
    const geometry = new Three.PlaneBufferGeometry(2, 2)
    const textureLoader = new TextureLoader()
    const material = new Three.MeshBasicMaterial({
        color: value.color,
        map: textureLoader.load(value.imgsrc),
        alphaTest: 0.5,
        transparent: true,
        side:Three.DoubleSide
    })
    const plane = new Three.Mesh(geometry, material)
    plane.rotation.y = value.ratation
    scene.add(plane)
})

请注意,上面代码中我们取了一个透明度的中间值,将.alphaTest属性值设置为0.5

你可以尝试将.alphaTest分别设置为0.20.8看看结果会有什么变化。

最终边缘清晰度取决于贴图图片中抠图的精细程度。若边缘越不清晰(也就是越模糊),最终呈现出的白边会越严重。


实际运行后就会发现,2棵不同颜色的树木,彼此十字交叉,可以透过前面树的枝叶看到另外一颗树。

本文小节

我们讲解了为什么会在某些视角下,某些半透明的物体个别地方三角面不会被渲染的原因。

通过几个示例,讲解了3种解决方案:

  1. 将物体添加2份,1份负责渲染前面,另外一份负责渲染后面

    缺点:增加渲染工作量

  2. 将物体(或平面)进行拆分,已确保每1份均会有机会被渲染

    缺点:只适合简单的物体,且位置固定容易拼凑

  3. 通过设置.alphaTest,以实现透明渲染

    缺点:若贴图抠图不够精细,容易出现白边


就像上面我们提到的,每一种解决方案都有各自的使用场景和缺点。

我们今后在实际的项目中,一定要根据实际情况来作出选择,看使用哪种方案。

说白了,无非就是在性能、复杂度、精细化方面进行取舍,最终找出合适的方案。


你可能会注意到本章节我并没有贴出完整的示例代码,而仅仅贴出了核心的代码。

我是这样认为的,如果到了今天你依然无法自己写出完整的代码,还需要靠复制我完整的示例代码,那你干脆别学Three.js了,放弃吧。


至此,本章结束。

目前我们所有的示例都是基于1个 画布(canvas)1个 镜头(camera),下一节我们讲解同一个网页中渲染多个 画布 和多个镜头。

上一页
下一页