CSS 3D应该注意的事项

发布于 大漠

我一直喜欢3D。我也开始使用CSS 3D Transform,而且浏览器对它的支持度越来越好。但给我的感觉,使用Transform就是用来创建2D图形,并且使用旋转和位移可以创建一些3D图形。但在实际使用的时候,还是越到了不少的麻烦,而且这些麻烦出乎我的意料。我想或许大家也同样遇到过这样的问题,为了大家在使用CSS 3D Transform能避免这些麻烦,我把我碰到的与在大家分享一下。

3D渲染上下文

我清楚的记得一个晚上遇到一个麻烦,而且这个麻烦引起我的好奇心,于是我想亲自写一个测试用例来试试看,看看浏览器如何处理平面的交叉。该测试用例只包含了两个平面元素:

<div class='plane'></div>
<div class='plane'></div>

它们尺寸大小相同,并且使用绝对定位,让元素在屏幕中居中(水平垂直居中),并且给他们设置了个背景色,让它们在屏幕中可见:

$dim: 40vmin;

.plane {
  position: absolute;
  top: 50%;
  left: 50%;
  margin: -.5*$dim;
  width: $dim;
  height: $dim;
  background: #ee8c25;
}

body元素当作一个场景,并且设置了perspective视角),让视角覆盖整个视窗。视角的值越大,元素似乎看起来离自已越远,看起来越小,反之越大。

body {
  margin: 0;
  height: 100vh;
  perspective: 40em;
}

测试示例实际上就是测试两个平面相交,所以使用Transform中的rotateY()做了一个Y轴旋转,并且设置了一个不同的背景颜色:

.plane:last-child {
  transform: rotateY(60deg);
  background: #d14730;
}

结果是令人失望的。浏览器似乎并没有正确的处理好两个平面交叉:

事实上是我错了,造成这个现象是我的代码导致的。我应该做的是让两个平面在3D上下文渲染。3D上下文渲染和层叠上下文渲染还是不同的。就像如果它们不在同一个层叠上下文中,我们不能使用z-index来改变元素在z轴的顺序。同样的,如果不在同一个3D渲染范围内,3D Transform同样不能改变元素的顺序,让元素交叉。

为了确保这两个平面是在相同的3D渲染环境,最简单的方法就是把它们放在同一个容器中:

<div class='assembly'>
    <div class='plane'></div>
    <div class='plane'></div>   
</div>

同样让容器元素通过绝对定位,让它放置在容器中间,同时给它设置一个transform-style:preserve-3d

div {
    position: absolute;
}

.assembly {
    top: 50%;
    left: 50%;
    transform-style: preserve-3d;
}

这样就解决了这个问题:

如果你使用Firefox查看上面的示例,你看到的效果是两个平面并没有交叉,这或许是Firefox独有的特性吧,但你使用Webkit内核浏览器或者Edge浏览器看到的效果是我们想要的效果。

上图演示是前面示例的效果,两个平面没有交叉。

现在你可能会感到非常奇怪,为什么不给这两个平面添加一个容器,不在场景中设置transform-style:preserve-3d就不能正常工作(在我们的示例中是body元素)?那么,在这种特殊情况下,如果在最初的示例中增加这个规则(在boyd元素是直接添加transform-style:preserve-3d),它就能正常工作(除非你是在Firefox浏览器查看这效果,正如前面所说,Firefox对于3D的顺序和交叉还是有问题的)。

实际上,如果我们想要在网页上使用3D,我们的场景可能不会直接是body元素,可能会在场景中添加其他属性,这些可能会影响页面的性能。

打断3D(造成压扁)

比方说,我们的场景是页面中的一个div元素,而且有其他的内容围绕着这个场景div

在第二个元素上多添加了几个transform的属性,使之更加明显,看上去有些部分在场景外。上面示例看到的效果是我们不想要的。我们希望添加一些属性让文本更好的阅读。

overflow

最初让我想到的解决方案是在场景中添加一个overflow:hidden。然而,这样做是能让文本更好的阅读,但3D交叉效果又不正常了。

即使在场景中设置了preserve-3d,只要设置了overflow的值,就算是visible也会使效果变得像transform-style设置了flat(压平)一样。因此,多添加一个容器可能会让我写更多一点代码,但能让我们绕开这些麻烦。

这就是为什么我们要把一切元素都放置在一个独立的元素中,把这个容器当作场景,即使该元素不使用3D Transform。例如下面这个示例:

所有旋转的六边形列都放在.helix元素内:

<div class='helix'>
    <div class='col'>
        <!-- all the hexagons inside a column -->
    </div>
    <!-- the other columns -->
</div>

除了保证整个组件绝对定位在视窗中间的样式之外,.helix元素没有任何其他样式(除非是继承下来的样式),并且所有的列都在同一个3D渲染范围内:

div {
    position: absolute;
    transform-style: preserve-3d;
}

.helix {
    top: 50%;
    left: 50%;
}

在场景中设置了overflow:hidden(这个示例body元素就是场景),这样做是因为六边形不依赖于视窗的大小,我不知道它们是否会延伸到容器外而导致滚动条的出现,这种现象是我不想看到的。

我承认我不止一次碰到这样的现象,从中吸引了相关教训。我保守起见,为了不让溢出出现在这里,直接使用overflow:hidden,这样能让溢出看起来不明显。

transform-style:preserve-3d告诉浏览器设置了3D Transform的子元素不应该在他们的父元素内拍平(元素上设置了transform-style:preserve-3d)。因此,就算是直觉,场景中设置了overflow:hidden,防止其子元素打破他们的父容器,也不会让3D元素在场景内拍平。

但有时一个3D Transform子元素仍然可以在其父容器是平面的。比如说下面这种情况,我们一张具有两面的卡片:

<div class='card'>
    <div class='face'>front</div>
    <div class='face'>back</div>
</div>

通过绝对定位让所有元素在场景中(这个示例场景指的是body元素)水平垂直居中,并且给这两个卡片具有相同的大小尺寸。为了让这们在相同的空间,给卡片设置transform-style:preserve-3d。为了让背面可见,设置backface-visibility:hidden,并且在第二张卡片上设置rotate,让其沿着垂直轴(Y轴)旋转半圈(.5turn):

$dim: 40vmin;

div {
    position: absolute;
    width: $dim;
    height: $dim;
}

.card {
    top: 50%;
    left: 50%;
    margin: -.5*$dim;
    transform-style: preserve-3d;
}

.face {
    backface-visibility: hidden;
    background: #ee8c25;

    &:last-child {
        transform: rotateY(.5turn);
        background: #d14730;
    }
}

示例的效果如下所示:

两张卡片在他的父容器内仍然是平的,只不过第二张卡片绕着它的垂直轴(Y轴)旋转了半圈。它的朝向是相反的方式,但仍然是在同一平面上。到目前为止,这一切看上去都是正常的。

好,现在我想这两个卡片不是矩形的。给它们一个border-radius: 50%。但看起来没有任何变化:

接下来在.card上设置overflow:hidden,效果就正常了:

哎呀,这打破了我们的3D卡片。既然我们无法做到这一点,我们就在.face上设置:

.face {
    border-radius: 50%;
}

在这种情况之下,解决这个问题的方法比打破3D卡这个问题更简单。但是,如果我们想要的是另一种形状,比如说一个正八边形,正八边形还是很容易实现的,比如采用两个元素(或者元素和伪元素的配合):

<div class='octagon'>
    <div class='inner'></div>
</div>

给这两个元素设置相同的尺寸,并且.inner元素设置rotate的值为45deg,给他们设置一个背景色,然后在.octagon元素上设置overflow:hidden,就可以看到一个正八边形:

$dim: 65vmin;

div {
    width: $dim;
    height: $dim;
}

.octagon {
    overflow: hidden;
}

.inner {
    transform: rotate(45deg);
    background: #ee8c25;
}

你看到的八边形效果如下所示:

如果你对如何制作正多边形感兴趣,建议你阅读《Sass绘制多边形》和《單一 div 的正多邊形變換 ( 純 CSS )》。

如果希望在正多边形中添加文本呢?

<div class='octagon'>
  <div class='inner'>octagon</div>
</div>

你将看到的效果是这样,不尽人意:

造成这个现象是因为裁角的时候把文本也裁剪掉了。为了让文本能正常显示,给它设置一个text-align:center,并且设置一个line-height的值等于.octagon的高度(或者.inner),让文本垂直居中:

.inner {
    font: 10vmin/ #{$dim} sans-serif;
    text-align: center;
}

现在看起来好多了,但文本仍然是旋转的,因为.inner元素设置了一个rotate(45deg)

为了解决这个问题,只需要在.octagon元素上增加一个rotate,其旋转的角度值和.inner的旋转值一样,只是旋转方向刚好与.inner元素相反,所以是一个负值:

.octagon {
    transform: rotate(-45deg);
}

这下,文本在正八边形中的文本显示正常了:

现在让我们看看,如果我们想一正八边形的卡片,又将如何应用它。我们不能直接在.card上直接运用overflow:hidden(让它在.octagon元素上作用,同时两个面.inner又会是什么样)。因为在卡片上设置了overflow:hidden样式,根据前面介绍的内容,这样就会打破3D空间,让两个页不在同一个3D渲染环境。

替代方案是,需要把这些规则用在.octagon元素,并且使用它们的伪元素来做卡片的两个面:

.face {
    overflow: hidden;
    transform: rotate(45deg);
    backface-visibility: hidden;

    &:before {
        left: 0;
        transform: rotate(-45deg);
        background: #ee8c25;
        content: 'front';
    }

    &:last-child {
        transform: rotateY(.5turn) rotate(45deg);

        &:before {
            background: #d14730;
            content: 'back'
        }
    }
}

最后看到的效果就是我们想要的效果:

clip-path

能引起类似的问题还有另一个属性clip-path。回到上面卡片的示例,我们不能在.card元素上直接使用clip-path来绘制三角形,因为我们需要一个3D Ttransform的子元素,也就是第二个面。我们应该用在卡片的面上:

.face {
    clip-path: polygon(100% 50%, 0 0, 0 100%);
}

注意clip-path属性在Webkit内核浏览器下还是需要添加-webkit-前缀。对于Firefox(47+)浏览器需要通过about:configlayout.css.clip-path-shapes.enabled设置为true,另外Edge是不支持这个属性的(但你可以在这里进行投票,让Edge早日能支持这个属性)。

如果您从未接触过clip-path这个属性,建议你先阅读《CSS的clip-path》一文进行了解。

上面的代码看到的效果应该是这样的:

虽然不存在3D的问题,但效果看起来真的很别扭。如果从正面观察卡片,三角形的顶角是朝右的,按理说,后面应该是朝左的。但效果并不是我们所期望的,反面也朝右了。要解决这个问题,那么需要为不同的面设置不同的路径。clip-path绘制正面的三角形超右,绘制反正的三角形超左。

.face:last-child {
    clip-path: polygon(0 50%, 100% 0, 100% 100%);
}

下面看到的效果才是我们想要的:

**注意:**还需要修改text-align的值:正面的默认值为left,反正的就需要设置为right

另外,我们还可以在反面是使用scaleX(-1)来实现。如查你想进一步的了解scale的工作机制,可以看看下面的示例:

将上面介绍的原理运用到我们前面介绍的DEMO中:

.face:last-child {
    transform: rotateY(.5turn) scaleX(-1);
}

效果如下:

这样看起来三角的方向是对了,但文字又出问题了。这意味着我们实际上要把文本放在背景元素的伪元素上,并且在.face元素上做一个反转。scale的反转就是设置另一个scale1/f。在我们这个示例中,f就是-1。也就是说在伪元素上设置scale的值为1/-1=-1,就可以让文本看起来正常:

.face:last-child:before {
    transform: scaleX(-1);
    background: #d14730;
    text-align: right;
    content: 'back';
}

最后的效果如下:

如果mask设置非none值时,也会致使transform-style变为flat,就像overflowclip-path设置了visiblenone以外的值一样。

opacity

这是一个意想不到的问题。相对而言,这也是规范中相对较新的一个变化,如果在3D渲染环境下设置的opacity值小于1,效果就像是在层叠上下文一样(失去3D渲染上下文,就像前面所说的3D拍平)。这效果并不是在所有浏览器中都会发生的,比如在Edge、Safari和Brave下正常,而Chrome、Firefox和Opera看到的效果就是拍平后的效果。

请看下面的示例,一组立方体在3D空间内同时旋转:

结构很简单,在.assembly容器内有很多个(这个示例是有20个).cube元素,而且每个.cube6个面。

<div class='assembly'>
  <div class='cube'>
    <div class='cube__face'></div>
    <!-- five more cube faces -->
  </div>
  <!-- more cubes, each with 6 faces -->
</div>

现在我们说,想要的立方体是半透明的。那么我们这样做,能不能做到呢?

.cube {
    opacity: .5;
}

这样一来,就算在.cube上设置了transform-style:preserve-3d,也会变成flat的效果,就像.cube在它的父容器里被拍平了。现在只是Chrome、Opera和Firefox,但是在将来,所有浏览器都会是这样:

在Brave、Edge和Safari中,设置opacity值小于1时,立方体没有拍平。

在Chrome、Firefox、Opera浏览器中,结果是.cube被拍平了。

我们不能把opacity:0.5设置在已经设置了transform-style:preserve-3d.assembly上。其效果和前面所说的将一样:

我们把opacity:0.5设置到.cube的每个面上,会不会引起同样的问题:

也可以把opacity设置在场景元素上(下面的示例是设置了body元素上),但需要注意,它也会影响场景的background或者伪元素。它也不会使个别立方体或面单独具有半透明度,只能整体一样,而且也没办法让不同的立方体有不同的透明值。

对比一下,在各个面上设置opacity和在场景中设置opacity效果差异:

上图的效果是在每个面设置opacity:0.5的效果。

上图的效果是在场景中设置opacity:0.5的效果。

filter

这个也让我感到惊奇,虽然不像opacity,但它在所有浏览器都是一样的。接着再拿3D立方体举例。通过hue-rotate()函数,让立方体在旋转的时候每个面都有不同的hue值。在.cube或者.assembly上设置filter的值不是none时,3D立方体就将会被拍平。

$n: 20; // number of cubes

@for $i from 0 to $n {
    $angle: random(360)*1deg;

    .cube:nth-child(#{$i + 1}) {
        filter: hue-rotate($angle);
    }
}

filter在Webkit内核浏览器中仍需要添加-webkit-前缀。

给每个面随机的色相有正常工作,但每个3D立方体还是被拍平了:

这个问题的解决方案是把filter设置在立方体的每个面上:

$n: 20; // number of cubes

@for $i from 0 to $n {
    $angle: random(360)*1deg;

    .cube:nth-child(#{$i + 1}) .cube__face {
        filter: hue-rotate($angle);
    }
}

这样一来,3D立方体的每个面的色相是随机的,而且也在3D渲染上下文中,没有被拍平:

我们也不能把filter设置在.assembly上面。比方说,希望所有的立方体都具有模糊效果,你就为了方便在.assembly上设置filter:

.assembly {
    filter: blur(4px);
}

其结果就是整个都被拍平了,平面变得模糊。Edge是个例外,直接一切都消失了。

我们可以做的就是尽量在立方体的每个面上使用blur(),虽然结果不会完全一样。而且就算是这样做,在不同的浏览器内核是渲染也将不一致,看上去就像是有Bug一样

我们可以尝试在场景中设置blur(),尽管在各浏览器看上去好像还是个Bug(在Chrome和Firefox有时会闪烁,各个面消失;Edge完全不显示任何东西):

我比较好奇的是下面这个简单示例,在场景中也有设置了filterblur()效果,但是在Blink内核的浏览器和Edge中效果很好,只是在Firefox下有问题。

总体而言,在3D渲染上下文中使用filter似乎问题很多,所以在使用的时候需要谨慎。

说了这么久的filter,如果你从未接触过的话,建议你先点击这里了解filter相关的东西,然后在回过头去阅读前面的内容。

mix-blend-mode

比如说我们一个.container元素,这个元素有一个多彩的background。在这个元素内有一个设置了background-image.mover元素,并且.mover元素有一个改变位置的动画效果和设置mix-blend-mode:overlay的样式。看到效果将是,.mover(这里称之为草莓,因为背景图片是草莓)移动位置,其位置在.container元素不同元素块上时,呈现给用户的效果将会不一样:

混合模式目前在Edge中不支持,所以在Edge中将看不到任何效果,但你可以在这里投票,让其早日也能支持混合模式。但是有一点需要注意,不能直接使用body或者html来替代.container元素,因为在Blink内核的浏览器中还存在bug。这个bug在bodyhtml替代container时,.mover运用混合模式时会出问题。但在Firefox和Safari中不存在这个现象。

好吧,上面看到的是2D平面的,但我们要聊的是3D的,那我们需要.mover是一个带有图片的3D立方体,在3D空间中旋转。

到目前为止,在没有设置混合模式之一,这一切都很好。接下来在立方体上设置mix-blend-mode:overlay。问题来了,3D渲染打破了,立方体又被拍平了:

由于我们需要在立方体上使用3D Transform来制作动画,而且他们的子元素都需要使用3D Transform,所以需要在立方体(.cube)上设置transform-style的值为preserve-3d。但我们还需要在.cube上设置mix-blend-mode:overlay,这样问题就来了,在.cube上设置mix-blend-mode:overlay致使transform-style的值变为flat,那么所有立方体就被拍平了。

尝试把mix-blend-mode:overlay设置在立方体的各个面上,但这问题依旧还是存在:

要解决这个问题,需要在.container.mover容器之间再添加一个.scene容器,并且在这个元素上设置perspectivemix-blend-mode

这似乎是解决了一切问题!

本文根据@ANA TUDOR的《Things to Watch Out for When Working with CSS 3D》所译,整个译文带有我们自己的理解与思想,如果译得不好或有不对之处还请同行朋友指点。如需转载此译文,需注明英文出处:https://css-tricks.com/things-watch-working-css-3d/

大漠

常用昵称“大漠”,W3CPlus创始人,目前就职于手淘。对HTML5、CSS3和Sass等前端脚本语言有非常深入的认识和丰富的实践经验,尤其专注对CSS3的研究,是国内最早研究和使用CSS3技术的一批人。CSS3、Sass和Drupal中国布道者。2014年出版《图解CSS3:核心技术与案例实战》。

如需转载,烦请注明出处:https://www.fedev.cn/css3/things-watch-working-css-3d.htmlNike Hyperdunk 2018