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:config
将layout.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
的反转就是设置另一个scale
是1/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
,就像overflow
和clip-path
设置了visible
和none
以外的值一样。
opacity
这是一个意想不到的问题。相对而言,这也是规范中相对较新的一个变化,如果在3D渲染环境下设置的opacity
值小于1
,效果就像是在层叠上下文一样(失去3D渲染上下文,就像前面所说的3D拍平)。这效果并不是在所有浏览器中都会发生的,比如在Edge、Safari和Brave下正常,而Chrome、Firefox和Opera看到的效果就是拍平后的效果。
请看下面的示例,一组立方体在3D空间内同时旋转:
结构很简单,在.assembly
容器内有很多个(这个示例是有20
个).cube
元素,而且每个.cube
有6
个面。
<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完全不显示任何东西):
我比较好奇的是下面这个简单示例,在场景中也有设置了filter
的blur()
效果,但是在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在body
或html
替代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
容器,并且在这个元素上设置perspective
和mix-blend-mode
。
这似乎是解决了一切问题!
本文根据@ANA TUDOR的《Things to Watch Out for When Working with CSS 3D》所译,整个译文带有我们自己的理解与思想,如果译得不好或有不对之处还请同行朋友指点。如需转载此译文,需注明英文出处:https://css-tricks.com/things-watch-working-css-3d/。
如需转载,烦请注明出处:https://www.fedev.cn/css3/things-watch-working-css-3d.htmlNike Hyperdunk 2018