Storybook 刚刚达到了一个重要的里程牌:7.0 版本!为了庆贺,该团队举办了他们的第一次用户大会 - Storybook Day。为了更特地,在流动页面中增加了一个视觉上令人惊叹的 3D 插图。

原文:How we built the Storybook Day 3D animation

源码:storybook-day

3D 插图应用 React Three Fiber (R3F) 实现,灵感来自俄罗斯方块。在本文中,将深入探讨。内容蕴含:

  • 防止物体与球体沉积重叠
  • 用挤压法模仿俄罗斯方块
  • 通过景深和暗影等加强视觉效果
  • 通过缩小资料数量来优化性能

根本实现

脚手架创立:

npx create-react-app my-app --template typescript

装置依赖:

npm i @react-three/fiber @react-three/drei canvas-sketch-util -S

App.tsx

import React from 'react';import { Canvas } from '@react-three/fiber'import BlocksScene from './BlocksScene'function App() {  return (    <div style={{ height: '100vh' }}>      <Canvas        shadows        gl={{ antialias: false, stencil: false }}        camera={{ position: [0, 0, 30], near: 0.1, far: 60, fov: 45 }}      >        <color attach="background" args={['#e3f3ff']} />        <ambientLight intensity={0.5} />        <directionalLight castShadow position={[2.5, 12, 12]} intensity={1} />        <pointLight position={[20, 20, 20]} intensity={1} />        <pointLight position={[-20, -20, -20]} intensity={1} />        <BlocksScene />      </Canvas>    </div>  );}export default App;

BlocksScene.tsx

import React, { Suspense } from "react"// @ts-ignoreimport * as Random from 'canvas-sketch-util/random'import Block, { blockTypes } from './Block'import * as THREE from 'three'import { Float } from '@react-three/drei'import VersionText from './VersionText'const size = 5.5const colors = ['#FC521F', '#CA90FF', '#1EA7FD', '#FFAE00', '#37D5D3', '#FC521F', '#66BF3C']const blocks = new Array(40).fill(0).map((_, index) => ({  id: index,  position: [Random.range(-size * 3, size * 3), Random.range(-size, size), Random.range(-size, size)],  size: Random.range(0.1875, 0.375) * size,  color: Random.pick(colors),  type: Random.pick(blockTypes),  rotation: new THREE.Quaternion(...Random.quaternion()),}))const BlocksScene = () => {  return (    <Suspense fallback={null}>      <group position={[0, 0.5, 0]}>        <VersionText />        {blocks.map(block => (          <Float            key={block.id}            position={block.position as any}            quaternion={block.rotation}            scale={block.size}            speed={1}            rotationIntensity={2}            floatIntensity={2}            floatingRange={[-0.25, 0.25]}          >            <Block type={block.type} color={block.color} />          </Float>        ))}      </group>    </Suspense>  )}export default BlocksScene

Block.tsx

import React from "react"import { Sphere, Cylinder, Torus, Cone, Box } from '@react-three/drei'export const BLOCK_TYPES = {  sphere: { shape: Sphere, args: [0.5, 32, 32] },  cylinder: { shape: Cylinder, args: [0.5, 0.5, 1, 32] }, // 圆柱  torus: { shape: Torus, args: [0.5, 0.25, 16, 32] }, // 圆环  cone: { shape: Cone, args: [0.5, 1, 32] }, // 圆锥  box: { shape: Box, args: [1, 1, 1] },} as constexport type BlockType = keyof typeof BLOCK_TYPESexport const blockTypes = Object.keys(BLOCK_TYPES) as BlockType[]interface BlockProps {  type: BlockType  color: string}const Block = ({ type, color }: BlockProps) => {  const Component = BLOCK_TYPES[type].shape  return (    <Component args={BLOCK_TYPES[type].args as any} castShadow>      <meshPhongMaterial color={color} />    </Component>  )}export default Block

VersionText.tsx

import React from 'react'import { Center, Text3D } from '@react-three/drei'import * as THREE from 'three'import font from './font' // 字体比拟多,参考:原文const textProps = {  font: font,  curveSegments: 32,  size: 10,  height: 2.5,  letterSpacing: -3.25,  bevelEnabled: true,  bevelSize: 0.04,  bevelThickness: 0.1,  bevelSegments: 3}const material = new THREE.MeshPhysicalMaterial({  thickness: 20,  roughness: 0.8,  clearcoat: 0.9,  clearcoatRoughness: 0.8,  transmission: 0.9,  ior: 1.25,  envMapIntensity: 0,  // color: '#0aff4f'  color: '#9de1b4'})const VersionText = () => {  return (    <Center rotation={[-Math.PI * 0.03125, Math.PI * 0.0625, 0]}>      {/* @ts-ignore */}      <Text3D position={[-4, 0, 0]} {...textProps} material={material}>7.</Text3D>      {/* @ts-ignore */}      <Text3D position={[4, 0, 0]} {...textProps} material={material}>0</Text3D>    </Center>  )}export default VersionText

留神以上代码,尽管让块随机散布在整个场景中了,然而有的与文本重叠或彼此重叠。如果这些块没有重叠,那在美学上会更令人愉悦。那么如何防止重叠呢?

球体重叠搁置块

pack-spheres 库可能让块均匀分布,并避免任何潜在的重叠问题。该库采纳蛮力办法在立方体内排列不同半径的球体。

装置依赖

npm i pack-spheres -S
const spheres = pack({  maxCount: 40,  minRadius: 0.125,  maxRadius: 0.25})

缩放球体以适应场景空间,并沿 x 轴程度拉伸。最初,在每个球体的核心搁置一个块,缩放到球体的半径。

这样就实现了块散布,大小和地位也令人满意。

解决文本和块之间的重叠,须要一种不同的办法。最后,思考应用 pack-spheres 来检测球体和文本几何体之间的碰撞。最终抉择了一个更简略的解决方案:沿 z 轴略微挪动球体。

文本实质上是所有块中的一部分。

全副更改都在 BlocksScene.tsx 文件中:

import React, { Suspense } from "react"// @ts-ignoreimport * as Random from 'canvas-sketch-util/random'import Block, { blockTypes } from './Block'import * as THREE from 'three'import { Float } from '@react-three/drei'import VersionText from './VersionText'// @ts-ignoreimport pack from 'pack-spheres'const size = 5.5const colors = ['#FC521F', '#CA90FF', '#1EA7FD', '#FFAE00', '#37D5D3', '#FC521F', '#66BF3C']// 横向拉伸const scale = [size * 6, size, size]const spheres = pack({  maxCount: 40,  minRadius: 0.125,  maxRadius: 0.25}).map((sphere: any) => {  const inFront = sphere.position[2] >= 0  return {    ...sphere,    position: [      sphere.position[0],      sphere.position[1],      // 偏移以防止与 7.0 文本重叠      inFront ? sphere.position[2] + 0.6 : sphere.position[2] - 0.6    ]  }})const blocks = spheres.map((sphere: any, index: number) => ({  ...sphere,  id: index,  // 缩放 地位、半径,适应场景  position: sphere.position.map((v: number, idx: number) => v * scale[idx]),  size: sphere.radius * size * 1.5,  color: Random.pick(colors),  type: Random.pick(blockTypes),  rotation: new THREE.Quaternion(...Random.quaternion()),}))const BlocksScene = () => {  return (    <Suspense fallback={null}>      <group position={[0, 0.5, 0]}>        <VersionText />        {blocks.map((block: any) => (          <Float            key={block.id}            position={block.position as any}            quaternion={block.rotation}            scale={block.size}            speed={1}            rotationIntensity={2}            floatIntensity={2}            floatingRange={[-0.25, 0.25]}          >            <Block type={block.type} color={block.color} />          </Float>        ))}      </group>    </Suspense>  )}export default BlocksScene

挤压形式模仿俄罗斯方块

到目前为止,只应用了根底块,还没有俄罗斯格调的方块。

Three.js 中的 ExtrudeGeometry 的概念十分乏味。能够应用相似于 SVG 门路或 CSS 形态的语法为其提供 2D 形态,它将沿 z 轴拉伸它。次性能非常适合创立俄罗斯方块。

Drei 的 Extrude 提供了一种绝对简略的语法创立此类形态。以下是如何生成 “T” 块的示例:

import React, { useMemo } from 'react'import * as THREE from 'three'import { Extrude } from '@react-three/drei'export const SIDE = 0.75export const EXTRUDE_SETTINGS = {  steps: 2,  depth: SIDE * 0.75,  bevelEnabled: false}export const TBlock = ({ color, ...props }: any) => {  const shape = useMemo(() => {    const _shape = new THREE.Shape()    _shape.moveTo(0, 0)    _shape.lineTo(SIDE, 0)    _shape.lineTo(SIDE, SIDE * 3)    _shape.lineTo(0, SIDE *3)    _shape.lineTo(0, SIDE * 2)    _shape.lineTo(-SIDE, SIDE * 2)    _shape.lineTo(-SIDE, SIDE)    _shape.lineTo(0, SIDE)        return _shape  }, [])  return (    <Extrude args={[shape, EXTRUDE_SETTINGS]} {...props}>      <meshPhongMaterial color={color} />    </Extrude>  )}

暗影

通过减少暗影深度能够使场景栩栩如生。能够在场景中设置光源和物体,应用 castShadow 投射暗影。为了提供更柔和的暗影,采纳 Drei 提供的ContactShadows 组件。

ContactShadows 组件的暗影是一种“假暗影”成果。它们是通过从下方拍摄场景并将暗影渲染到接收器立体上来生成。暗影在几帧中积攒,更加柔和、真切。

ContactShadows 组件能够通过调整分辨率、不透明度、含糊、色彩等其余属性来自定义外观。

在 'App.tsx' 中退出 ContactShadows 组件,并进行设置。

import React from 'react';import { Canvas } from '@react-three/fiber'import { ContactShadows } from '@react-three/drei';import BlocksScene from './BlocksScene'function App() {  return (    <div style={{ height: '100vh' }}>      <Canvas        shadows        gl={{ antialias: false, stencil: false }}        camera={{ position: [0, 0, 30], near: 0.1, far: 60, fov: 45 }}      >        <color attach="background" args={['#e3f3ff']} />        <ambientLight intensity={0.5} />        <directionalLight castShadow position={[2.5, 12, 12]} intensity={1} />        <pointLight position={[20, 20, 20]} intensity={1} />        <pointLight position={[-20, -20, -20]} intensity={1} />        <BlocksScene />        <ContactShadows          resolution={512}          opacity={0.5}          position={[0, -8, 0]}          width={20}          height={10}          color='#333'        />      </Canvas>    </div>  );}export default App;

景深成果(深度含糊成果)

在此阶段,场景中的每个对象都以雷同的清晰度渲染,导致场景看起来有些平淡。摄影师会应用大光圈和浅景深来营造令人愉悦的含糊美感。能够通过对场景利用后处理(@react-three/postprocessing)来模仿这种成果,减少电影感。

EffectComposer 治理和运行后处理通道。它首先将场景渲染到缓冲区,而后在将最终图像渲染到屏幕上之前利用一个滤镜成果。

选取对焦间隔

应用景深成果,能够将焦点放在场景中的特定间隔(focusDistance)上,并使其余所有内容都变得含糊。然而如何定义对焦间隔呢?它是以世界单位还是其余什么形式掂量?

import { Canvas } from '@react-three/fiber';import { EffectComposer, DepthOfField } from '@react-three/postprocessing';export const Scene = () => (  <Canvas>    {/* Rest of Our scene */}    <EffectComposer multisampling={8}>      <DepthOfField focusDistance={0.5} bokehScale={7} focalLength={0.2} />    </EffectComposer>  </Canvas>);

相机的视线由一个金字塔形态的体积定义,称为”视椎体“。间隔相机最小(近立体)和最大(远立体)间隔内的物体将被渲染。

来自:3D 编程简介 - 透视投影

focusDistance 参数示意处于焦点的物体间隔相机的间隔。它的值在 0 到 1 之间,其中 0 代表相机的近立体,1 代码相机的远立体。

本文将 focusDistance 设置为 0.5。凑近该值的物体将聚焦(清晰),而较远的物体将含糊。将 bokehScale 设置为 7, 值为 0 时不含糊,值越大越含糊。

应用材料库进行性能优化

暗影和景深是很酷的视觉效果,但它们的渲染老本相当高,会对性能产生重大影响。性能优化中,有用的倡议是应用资料存储来防止为每个块创立新的材质实例。

Block 组件应用 color 为每个实例创立惟一的材质。例如,每个成色块都有本人的材质实例。很节约,对吧?

const Block = ({ type, color }: BlockProps) => {  const Component = BLOCK_TYPES[type].shape  return (    <Component args={BLOCK_TYPES[type].args as any} castShadow>      <meshPhongMaterial color={color} />    </Component>  )}

通过应用材质存储,能够在多个块实例中重复使用雷同的材质。通过缩小须要创立和渲染的材质数量进步性能。

import * as THREE from 'three';THREE.ColorManagement.legacyMode = false;const colors: string[] = [  '#FC521F',  '#CA90FF',  '#1EA7FD',  '#FFAE00',  '#37D5D3',  '#FC521F',  '#66BF3C',  '#0AB94F'];interface Materials {  [color: string]: THREE.MeshPhongMaterial;}const materials: Materials = colors.reduce(  (acc, color) => ({ ...acc, [color]: new THREE.MeshPhongMaterial({ color }) }),  {});export { colors, materials };

store 为每种可能的块色彩生成一种材质,并将其存储在对象中。块组件无需为每个实例创立材质,只需从材质存储中援用即可。

const Block = ({ type, color }: BlockProps) => {  const Component = BLOCK_TYPES[type].shape;  return (    <Component      args={OTHER_TYPES[type as OtherBlockType].args as any}      material={materials[color]}    />  );}

总结

3D 当初是 Web 的一部分, R3F 是将 HTML 和 WebGL 交错在一起的绝佳工具。R3F 生态系统十分丰盛,drei 和 postprocessing 等库简化了简单的 3D 工作。 Storybook Day 的 3D 场景完满地展现了平台的可能性。应用球体包装(pack-sphere)、挤压(Extrude)、暗影、景深和材质存储来创立令人难忘的流动页面。