共计 8723 个字符,预计需要花费 22 分钟才能阅读完成。
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-ignore | |
import * 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.5 | |
const 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 const | |
export type BlockType = keyof typeof BLOCK_TYPES | |
export 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-ignore | |
import * 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-ignore | |
import pack from 'pack-spheres' | |
const size = 5.5 | |
const 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.75 | |
export 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)、暗影、景深和材质存储来创立令人难忘的流动页面。