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>
);
相機的視野由一個金字塔形狀的體積定義,稱為」視椎體「。距離相機最小(近平面)和最大(遠平面)距離內的物體將被渲染。
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)、陰影、景深和材質儲存來建立令人難忘的活動頁面。