目录
- 效果图
- 建模
- 多边形形状
- 随机多边形
- 漂浮动画
- 光照
- 后期处理
效果图
最近受到轮回系作品《寒蝉鸣泣之时》中时空裂缝场景的启发,我用three.js实现了一个实时渲染的时空裂缝场景。本文将简要地介绍下实现该效果的要点。
以下特效全屏观看效果最佳~
<div id="sketch"></div> | |
<div><div class="fixed z- top-0 left-0 loader-screen w-screen h-screen transition-all duration-300 bg-black"><div class="absolute hv-center"><div class="loading text-white text-xl tracking-widest whitespace-no-wrap"><span style="--i:">L</span><span style="--i:">O</span><span style="--i:">A</span><span style="--i:">D</span><span style="--i:">I</span><span style="--i:">N</span><span style="--i:">G</span></div></div></div><div class="fixed z- top-0 left-0 w-screen h-screen text-white text-xl"><div class="absolute hv-center"><div class="scene- space-y-10 text-center whitespace-no-wrap"><div class="space-y-"><div class="shuffle-text shuffle-text-">欢迎来到时空裂缝!</div><div class="shuffle-text shuffle-text-">在这里,你可以体验穿梭时空的感觉!</div><div class="shuffle-text shuffle-text-">准备好了,就点击下面的按钮吧~</div></div><button data-text="开始穿梭"class="dash-btn btn btn-primary btn-ghost btn-border-stroke btn-text-float-up"><div class="btn-borders"><div class="border-top"></div><div class="border-right"></div><div class="border-bottom"></div><div class="border-left"></div></div><span class="btn-text">开始穿梭</span></button></div><div class="scene- space-y-10 text-center whitespace-no-wrap"><div class="space-y-"><div class="shuffle-text shuffle-text-">穿梭的感觉如何?</div><div class="shuffle-text shuffle-text-">如果觉得不错,可以推荐给其他小伙伴~</div><div class="shuffle-text shuffle-text-">我是alphardex,一个爱写特效的前端</div></div></div></div></div> | |
</div> |
CSS
body { | |
margin:; | |
overflow: hidden; | |
} | |
#sketch { | |
width:vw; | |
height:vh; | |
background: black; | |
} | |
body { | |
background: black; | |
} | |
* { | |
user-select: none; | |
} | |
#sketch { | |
opacity:; | |
} | |
.scene-, | |
.scene- { | |
display: none; | |
} | |
.loading span { | |
animation: blur.5s calc(var(--i) / 5 * 1s) alternate infinite; | |
} | |
@keyframes blur { | |
to { | |
filter: blur(px); | |
} | |
} | |
.shuffle-text { | |
display: none; | |
opacity:.6; | |
} | |
.dash-btn { | |
opacity:; | |
pointer-events: none; | |
} | |
.btn { | |
--hue:; | |
--ease-in-duration:.25s; | |
--ease-in-exponential: cubic-bezier(.95, 0.05, 0.795, 0.035); | |
--ease-out-duration:.65s; | |
--ease-out-delay: var(--ease-in-duration); | |
--ease-out-exponential: cubic-bezier(.19, 1, 0.22, 1); | |
position: relative; | |
padding:rem 3rem; | |
font-size:rem; | |
line-height:.5; | |
color: white; | |
text-decoration: none; | |
background-color: hsl(var(--hue),%, 41%); | |
border:px solid hsl(var(--hue), 100%, 41%); | |
outline: transparent; | |
overflow: hidden; | |
cursor: pointer; | |
user-select: none; | |
white-space: nowrap; | |
transition:.25s; | |
} | |
.btn:hover { | |
background: hsl(var(--hue),%, 31%); | |
} | |
.btn-primary { | |
--hue:; | |
} | |
.btn-ghost { | |
color: hsl(var(--hue),%, 41%); | |
background-color: transparent; | |
border-color: hsl(var(--hue),%, 41%); | |
} | |
.btn-ghost:hover { | |
color: white; | |
} | |
.btn-border-stroke { | |
border-color: hsla(var(--hue),%, 41%, 0.35); | |
} | |
.btn-border-stroke .btn-borders { | |
position: absolute; | |
top:; | |
left:; | |
width:%; | |
height:%; | |
} | |
.btn-border-stroke .btn-borders .border-top { | |
position: absolute; | |
top:; | |
width:%; | |
height:px; | |
background: hsl(var(--hue),%, 41%); | |
transform: scaleX(); | |
transform-origin: left; | |
} | |
.btn-border-stroke .btn-borders .border-right { | |
position: absolute; | |
right:; | |
width:px; | |
height:%; | |
background: hsl(var(--hue),%, 41%); | |
transform: scaleY(); | |
transform-origin: bottom; | |
} | |
.btn-border-stroke .btn-borders .border-bottom { | |
position: absolute; | |
bottom:; | |
width:%; | |
height:px; | |
background: hsl(var(--hue),%, 41%); | |
transform: scaleX(); | |
transform-origin: left; | |
} | |
.btn-border-stroke .btn-borders .border-left { | |
position: absolute; | |
left:; | |
width:px; | |
height:%; | |
background: hsl(var(--hue),%, 41%); | |
transform: scaleY(); | |
transform-origin: bottom; | |
} | |
.btn-border-stroke .btn-borders .border-left { | |
transition: var(--ease-out-duration) var(--ease-out-delay) var(--ease-out-exponential); | |
} | |
.btn-border-stroke .btn-borders .border-bottom { | |
transition: var(--ease-out-duration) var(--ease-out-delay) var(--ease-out-exponential); | |
} | |
.btn-border-stroke .btn-borders .border-right { | |
transition: var(--ease-in-duration) var(--ease-in-exponential); | |
} | |
.btn-border-stroke .btn-borders .border-top { | |
transition: var(--ease-in-duration) var(--ease-in-exponential); | |
} | |
.btn-border-stroke:hover { | |
color: hsl(var(--hue),%, 41%); | |
background: transparent; | |
} | |
.btn-border-stroke:hover .border-top, | |
.btn-border-stroke:hover .border-bottom { | |
transform: scaleX(); | |
} | |
.btn-border-stroke:hover .border-left, | |
.btn-border-stroke:hover .border-right { | |
transform: scaleY(); | |
} | |
.btn-border-stroke:hover .border-left { | |
transition: var(--ease-in-duration) var(--ease-in-exponential); | |
} | |
.btn-border-stroke:hover .border-bottom { | |
transition: var(--ease-in-duration) var(--ease-in-exponential); | |
} | |
.btn-border-stroke:hover .border-right { | |
transition: var(--ease-out-duration) var(--ease-out-delay) var(--ease-out-exponential); | |
} | |
.btn-border-stroke:hover .border-top { | |
transition: var(--ease-out-duration) var(--ease-out-delay) var(--ease-out-exponential); | |
} | |
.btn-text-float-up::after { | |
position: absolute; | |
content: attr(data-text); | |
top:; | |
left:; | |
width:%; | |
height:%; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
opacity:; | |
transform: translateY(%); | |
transition:.25s ease-out; | |
} | |
.btn-text-float-up .btn-text { | |
display: block; | |
transition:.75s 0.1s var(--ease-out-exponential); | |
} | |
.btn-text-float-up:hover .btn-text { | |
opacity:; | |
transform: translateY(-%); | |
transition:.25s ease-out; | |
} | |
.btn-text-float-up:hover::after { | |
opacity:; | |
transform: translateY(); | |
transition:.75s 0.1s var(--ease-out-exponential); | |
} |
JS
const vertexShader = ` | |
uniform float iTime; | |
uniform vec iResolution; | |
uniform vec iMouse; | |
varying vec vUv; | |
varying vec vNormal; | |
varying vec vMvPosition; | |
varying vec vPosition; | |
uniform vec uMouse; | |
uniform float uRandom; | |
uniform float uLayerId; | |
// transform | |
mat rotation2d(float angle){ | |
float s=sin(angle); | |
float c=cos(angle); | |
return mat( | |
c,-s, | |
s,c | |
); | |
} | |
mat rotation3d(vec3 axis,float angle){ | |
axis=normalize(axis); | |
float s=sin(angle); | |
float c=cos(angle); | |
float oc=.-c; | |
return mat( | |
oc*axis.x*axis.x+c,oc*axis.x*axis.y-axis.z*s,oc*axis.z*axis.x+axis.y*s,., | |
oc*axis.x*axis.y+axis.z*s,oc*axis.y*axis.y+c,oc*axis.y*axis.z-axis.x*s,., | |
oc*axis.z*axis.x-axis.y*s,oc*axis.y*axis.z+axis.x*s,oc*axis.z*axis.z+c,., | |
.,0.,0.,1. | |
); | |
} | |
vec rotate(vec2 v,float angle){ | |
return rotationd(angle)*v; | |
} | |
vec rotate(vec3 v,vec3 axis,float angle){ | |
return(rotationd(axis,angle)*vec4(v,1.)).xyz; | |
} | |
vec distort(vec3 p){ | |
vec tx1=vec3(-uMouse.x*uRandom*.05,-uMouse.y*uRandom*.02,0.); | |
p+=tx; | |
float angle=iTime*uRandom; | |
p=rotate(p,vec(0.,1.,0.),angle); | |
vec tx2=vec3(-uMouse.x*uRandom*.5,-uMouse.y*uRandom*.2,0.); | |
p+=tx; | |
p*=(.-uLayerId*.5); | |
return p; | |
} | |
void main(){ | |
vec p=position; | |
vec N=normal; | |
p=distort(p); | |
N=distort(N); | |
gl_Position=projectionMatrix*modelViewMatrix*vec(p,1.); | |
vUv=uv; | |
vNormal=N; | |
vMvPosition=modelViewMatrix*vec(p,1.); | |
vPosition=p; | |
} | |
`; | |
const fragmentShader = ` | |
uniform float iTime; | |
uniform vec iResolution; | |
uniform vec iMouse; | |
varying vec vUv; | |
varying vec vNormal; | |
varying vec vMvPosition; | |
varying vec vPosition; | |
uniform samplerD uTexture; | |
uniform vec uLightPosition; | |
uniform vec uLightColor; | |
uniform float uRandom; | |
uniform vec uMouse; | |
// transform | |
mat rotation2d(float angle){ | |
float s=sin(angle); | |
float c=cos(angle); | |
return mat( | |
c,-s, | |
s,c | |
); | |
} | |
mat rotation3d(vec3 axis,float angle){ | |
axis=normalize(axis); | |
float s=sin(angle); | |
float c=cos(angle); | |
float oc=.-c; | |
return mat( | |
oc*axis.x*axis.x+c,oc*axis.x*axis.y-axis.z*s,oc*axis.z*axis.x+axis.y*s,., | |
oc*axis.x*axis.y+axis.z*s,oc*axis.y*axis.y+c,oc*axis.y*axis.z-axis.x*s,., | |
oc*axis.z*axis.x-axis.y*s,oc*axis.y*axis.z+axis.x*s,oc*axis.z*axis.z+c,., | |
.,0.,0.,1. | |
); | |
} | |
vec rotate(vec2 v,float angle){ | |
return rotationd(angle)*v; | |
} | |
vec rotate(vec3 v,vec3 axis,float angle){ | |
return(rotationd(axis,angle)*vec4(v,1.)).xyz; | |
} | |
// lighting | |
float saturate(float a){ | |
return clamp(a,.,1.); | |
} | |
float diffuse(vec n,vec3 l){ | |
float diff=saturate(dot(n,l)); | |
return diff; | |
} | |
float specular(vec n,vec3 l,float shininess){ | |
float spec=pow(saturate(dot(n,l)),shininess); | |
return spec; | |
} | |
float blendSoftLight(float base,float blend){ | |
return(blend<.)?(2.*base*blend+base*base*(1.-2.*blend)):(sqrt(base)*(2.*blend-1.)+2.*base*(1.-blend)); | |
} | |
vec blendSoftLight(vec3 base,vec3 blend){ | |
return vec(blendSoftLight(base.r,blend.r),blendSoftLight(base.g,blend.g),blendSoftLight(base.b,blend.b)); | |
} | |
// distort | |
vec distort(vec2 p){ | |
vec m=uMouse; | |
p.x-=(uRandom-m.x*.)*.5; | |
p.y-=uRandom*.-iTime*.1; | |
p.x-=.; | |
p.y-=.; | |
p=rotate(p,uRandom); | |
p*=.; | |
return p; | |
} | |
vec distortNormal(vec3 p){ | |
p*=vec(-1.*uRandom*15.,-1.*uRandom*15.,30.5); | |
return p; | |
} | |
// lighting | |
vec lighting(vec3 tex,vec3 normal){ | |
vec viewLightPosition=viewMatrix*vec4(uLightPosition,0.); | |
vec N=normalize(normal); | |
vec L=normalize(viewLightPosition.xyz); | |
vec dif=tex*uLightColor*diffuse(N,L); | |
vec C=-normalize(vMvPosition.xyz); | |
vec R=reflect(-L,N); | |
vec spe=uLightColor*specular(R,C,500.); | |
vec lightingColor=vec4(dif+spe,.5); | |
vec softlight=blendSoftLight(tex,spe); | |
float dotRC=dot(R,C); | |
float theta=acos(dotRC/length(R)*length(C)); | |
float a=.-theta*.3; | |
vec col=vec4(tex,a*.01)+vec4(softlight,.02)+(lightingColor*a); | |
return col; | |
} | |
void main(){ | |
vec p=vUv; | |
vec N=vNormal; | |
p=distort(p); | |
N=distortNormal(N); | |
vec tex=texture2D(uTexture,p); | |
vec col=tex; | |
col=lighting(tex.xyz,N); | |
gl_FragColor=col; | |
} | |
`; | |
const vertexShader = ` | |
uniform float iTime; | |
uniform vec iResolution; | |
uniform vec iMouse; | |
varying vec vUv; | |
void main(){ | |
vec p=position; | |
gl_Position=projectionMatrix*modelViewMatrix*vec(p,1.); | |
vUv=uv; | |
} | |
`; | |
const fragmentShader = ` | |
uniform float iTime; | |
uniform vec iResolution; | |
uniform vec iMouse; | |
uniform samplerD tDiffuse; | |
varying vec vUv; | |
uniform float uRGBShift; | |
vec RGBShift(sampler2D t,vec2 rUv,vec2 gUv,vec2 bUv){ | |
vec color1=texture2D(t,rUv); | |
vec color2=texture2D(t,gUv); | |
vec color3=texture2D(t,bUv); | |
vec color=vec4(color1.r,color2.g,color3.b,color2.a); | |
return color; | |
} | |
highp float random(vec co) | |
{ | |
highp float a=.9898; | |
highp float b=.233; | |
highp float c=.5453; | |
highp float dt=dot(co.xy,vec(a,b)); | |
highp float sn=mod(dt,.14); | |
return fract(sin(sn)*c); | |
} | |
void main(){ | |
vec p=vUv; | |
vec col=vec4(0.); | |
// RGB Shift | |
float n=random(p+mod(iTime,.))*.1+.5; | |
vec offset=vec2(cos(n),sin(n))*.0025*uRGBShift; | |
vec rUv=p+offset; | |
vec gUv=p; | |
vec bUv=p-offset; | |
col=RGBShift(tDiffuse,rUv,gUv,bUv); | |
gl_FragColor=col; | |
} | |
`; | |
class Fragment extends kokomi.Component { | |
constructor(base, config = {}) { | |
super(base); | |
const { material, points } = config; | |
this.points = kokomi.polySort(points); | |
// const geometry = new THREE.PlaneGeometry(.1, 0.1, 16, 16);const shape = kokomi.createPolygonShape(this.points, { | |
scale:.01, | |
}); | |
const geometry = new THREE.ExtrudeGeometry(shape, { | |
steps:, | |
depth:.0001, | |
bevelEnabled: true, | |
bevelThickness:.0005, | |
bevelSize:.0005, | |
bevelSegments:, | |
}); | |
geometry.center(); | |
const matClone = material.clone(); | |
matClone.uniforms.uRandom.value = THREE.MathUtils.randFloat(.1, 1.1); | |
const mesh = new THREE.Mesh(geometry, matClone); | |
this.mesh = mesh; | |
const uj = new kokomi.UniformInjector(this.base); | |
this.uj = uj; | |
} | |
addExisting() { | |
this.base.scene.add(this.mesh); | |
} | |
update() { | |
this.uj.injectShadertoyUniforms(this.mesh.material.uniforms); | |
gsap.to(this.mesh.material.uniforms.uMouse.value, { | |
x: this.base.interactionManager.mouse.x, | |
}); | |
gsap.to(this.mesh.material.uniforms.uMouse.value, { | |
y: this.base.interactionManager.mouse.y, | |
}); | |
const lp = this.base.clock.elapsedTime *.01; | |
this.mesh.material.uniforms.uLightPosition.value.copy( | |
new THREE.Vector(Math.cos(lp), Math.sin(lp), 10) | |
); | |
} | |
} | |
class FragmentGroup extends kokomi.Component { | |
constructor(base, config = {}) { | |
super(base); | |
const { material, layerId =, polygons } = config; | |
const g = new THREE.Group(); | |
this.g = g; | |
const frags = polygons.map((points, i) => { | |
const frag = new Fragment(this.base, { | |
material, | |
points, | |
}); | |
frag.addExisting(); | |
const firstPoint = frag.points[]; | |
frag.mesh.position.set( | |
firstPoint.x *.01, | |
firstPoint.y * -.01, | |
THREE.MathUtils.randFloat(-, -1) | |
); | |
frag.mesh.material.uniforms.uLayerId.value = layerId; | |
g.add(frag.mesh); | |
return frag; | |
}); | |
this.g.position.z = - 1.5 * layerId; | |
this.frags = frags; | |
} | |
addExisting() { | |
this.base.scene.add(this.g); | |
} | |
} | |
const generatePolygons = (config = {}) => { | |
const { gridX =, gridY = 20, maxX = 9, maxY = 9 } = config; | |
const polygons = []; | |
for (let i =; i < gridX; i++) { | |
for (let j =; j < gridY; j++) { | |
const points = []; | |
let edgeCount =; | |
const randEdgePossibility = Math.random(); | |
if (randEdgePossibility > && randEdgePossibility <= 0.2) { | |
edgeCount =; | |
} else if (randEdgePossibility >.2 && randEdgePossibility <= 0.55) { | |
edgeCount =; | |
} else if (randEdgePossibility >.55 && randEdgePossibility <= 0.9) { | |
edgeCount =; | |
} else if (randEdgePossibility >.9 && randEdgePossibility <= 0.95) { | |
edgeCount =; | |
} else if (randEdgePossibility >.95 && randEdgePossibility <= 1) { | |
edgeCount =; | |
} | |
let firstPoint = { | |
x:, | |
y:, | |
}; | |
let angle = THREE.MathUtils.randFloat(, 2 * Math.PI); | |
for (let k =; k < edgeCount; k++) { | |
if (k ===) { | |
firstPoint = { | |
x: (i % maxX) *, | |
y: (j % maxY) *, | |
}; | |
points.push(firstPoint); | |
} else { | |
// random polarconst r =; | |
angle += THREE.MathUtils.randFloat(, Math.PI / 2); | |
const anotherPoint = { | |
x: firstPoint.x + r * Math.cos(angle), | |
y: firstPoint.y + r * Math.sin(angle), | |
}; | |
points.push(anotherPoint); | |
} | |
} | |
polygons.push(points); | |
} | |
} | |
return polygons; | |
}; | |
class FragmentWorld extends kokomi.Component { | |
constructor(base, config = {}) { | |
super(base); | |
const { material } = config; | |
const fgsContainer = new THREE.Group(); | |
this.base.scene.add(fgsContainer); | |
fgsContainer.position.copy(new THREE.Vector(-0.36, 0.36, 0.1)); | |
// fragment groupsconst polygons = generatePolygons(); | |
const fgs = [...Array().keys()].map((item, i) => { | |
const fg = new FragmentGroup(this.base, { | |
material, | |
layerId: i, | |
polygons, | |
}); | |
fg.addExisting(); | |
fgsContainer.add(fg.g); | |
return fg; | |
}); | |
this.fgs = fgs; | |
// clone group for infinite loopconst fgsContainer = new THREE.Group().copy(fgsContainer.clone()); | |
fgsContainer.position.y = fgsContainer.position.y - 1; | |
const totalG = new THREE.Group(); | |
totalG.add(fgsContainer); | |
totalG.add(fgsContainer); | |
this.totalG = totalG; | |
// animethis.floatDistance =; | |
this.floatSpeed =; | |
this.floatMaxDistance =; | |
this.isDashing = false; | |
} | |
addExisting() { | |
this.base.scene.add(this.totalG); | |
} | |
update() { | |
this.floatDistance += this.floatSpeed; | |
const y = this.floatDistance *.001; | |
if (y > this.floatMaxDistance) { | |
this.floatDistance =; | |
} | |
if (this.totalG) { | |
this.totalG.position.y = y; | |
} | |
} | |
speedUp() { | |
gsap.to(this, { | |
floatSpeed:, | |
duration:, | |
ease: "power.in", | |
}); | |
} | |
speedDown() { | |
gsap.to(this, { | |
floatSpeed:, | |
duration:, | |
ease: "power.inOut", | |
}); | |
} | |
async dash(duration =, cb) { | |
if (this.isDashing) { | |
return; | |
} | |
this.isDashing = true; | |
this.speedUp(); | |
await kokomi.sleep(duration); | |
if (cb) { | |
cb(); | |
} | |
this.speedDown(); | |
} | |
changeTexture(name) { | |
this.fgs.forEach((fg) => { | |
fg.frags.forEach((frag) => { | |
const tex = this.base.am.items[name]; | |
tex.wrapS = THREE.RepeatWrapping; | |
tex.wrapT = THREE.RepeatWrapping; | |
frag.mesh.material.uniforms.uTexture.value = tex; | |
}); | |
}); | |
} | |
} | |
class Sketch extends kokomi.Base { | |
create() { | |
this.camera.position.set(, 0, 1.5); | |
this.camera.fov =; | |
this.camera.near =.01; | |
this.camera.far =; | |
this.camera.updateProjectionMatrix(); | |
const resourceList = [ | |
{ | |
name: "tex", | |
type: "texture", | |
path: "https://s.loli.net/2022/11/19/cqOho3ZKCXfTdzw.jpg", | |
}, | |
{ | |
name: "tex", | |
type: "texture", | |
path: "https://s.loli.net/2022/11/20/8E6yHP9kAawc7Wr.jpg", | |
}, | |
]; | |
const am = new kokomi.AssetManager(this, resourceList); | |
this.am = am; | |
am.on("ready", async () => { | |
const tex = am.items["tex"]; | |
tex.wrapS = THREE.RepeatWrapping; | |
tex.wrapT = THREE.RepeatWrapping; | |
const uj = new kokomi.UniformInjector(this); | |
const material = new THREE.ShaderMaterial({ | |
vertexShader, | |
fragmentShader, | |
side: THREE.DoubleSide, | |
transparent: true, | |
uniforms: { | |
...uj.shadertoyUniforms, | |
uTexture: { | |
value: tex, | |
}, | |
uLightPosition: { | |
value: new THREE.Vector(-0.2, -0.2, 3), | |
}, | |
uLightColor: { | |
value: new THREE.Color("#eeeeee"), | |
}, | |
uRandom: { | |
value: THREE.MathUtils.randFloat(.1, 1.1), | |
}, | |
uMouse: { | |
value: new THREE.Vector(0.5, 0.5), | |
}, | |
uLayerId: { | |
value:, | |
}, | |
}, | |
}); | |
// fragment worldconst fw = new FragmentWorld(this, { | |
material, | |
}); | |
fw.addExisting(); | |
this.fw = fw; | |
// postprocessingconst ce = new kokomi.CustomEffect(this, { | |
vertexShader: vertexShader, | |
fragmentShader: fragmentShader, | |
uniforms: { | |
uRGBShift: { | |
value:, | |
}, | |
}, | |
}); | |
ce.addExisting(); | |
// DOMconst shuffleText = (sel) => { | |
gsap.set(sel, { | |
display: "block", | |
}); | |
const st = new ShuffleText(document.querySelector(sel)); | |
st.start(); | |
}; | |
const start = async () => { | |
document.querySelector(".loader-screen").classList.add("hollow"); | |
await kokomi.sleep(); | |
gsap.to("#sketch", { | |
opacity:, | |
}); | |
await kokomi.sleep(); | |
await startScene(); | |
}; | |
const startScene = async () => { | |
gsap.set(".scene-", { | |
display: "block", | |
}); | |
shuffleText(".shuffle-text-"); | |
await kokomi.sleep(); | |
shuffleText(".shuffle-text-"); | |
await kokomi.sleep(); | |
shuffleText(".shuffle-text-"); | |
await kokomi.sleep(); | |
gsap.to(".scene-", { | |
opacity:, | |
pointerEvents: "none", | |
}); | |
}; | |
const startScene = async () => { | |
gsap.set(".scene-", { | |
display: "block", | |
}); | |
shuffleText(".shuffle-text-"); | |
await kokomi.sleep(); | |
shuffleText(".shuffle-text-"); | |
await kokomi.sleep(); | |
shuffleText(".shuffle-text-"); | |
await kokomi.sleep(); | |
gsap.to(".dash-btn", { | |
opacity:, | |
pointerEvents: "auto", | |
}); | |
document | |
.querySelector(".dash-btn") | |
.addEventListener("click", async () => { | |
gsap.set(".dash-btn", { | |
pointerEvents: "none", | |
}); | |
gsap.to(".scene-", { | |
opacity:, | |
pointerEvents: "none", | |
display: "none", | |
}); | |
await this.fw.dash(, () => { | |
this.fw.changeTexture("tex"); | |
}); | |
await kokomi.sleep(); | |
await startScene(); | |
}); | |
}; | |
await start(); | |
}); | |
} | |
} | |
const createSketch = () => { | |
const sketch = new Sketch(); | |
sketch.create(); | |
return sketch; | |
}; | |
createSketch(); |
运行效果
建模
多边形形状
首先,创造一个最初始的平面
建模,也就是定制化geometry
要想创建玻璃碎片一般的形状的话,也就是要创造一个多边形的形状
这就要用到kokomi.js的这2个函数createPolygonShape和polySort:前者能接收一系列的点来创造一个多边形Shape,后者能给无序的点进行排序以符合多边形的描画
创建形状Shape后,再传进ExtrudeGeometry将其3D化成geometry即可,这里depth等值故意设得很小,是为了模拟玻璃碎片的纤细程度
let points = [ | |
{ x:, y: 0 }, | |
{ x:, y: 0 }, | |
{ x:, y: 45 }, | |
{ x:, y: 25 }, | |
]; | |
points = kokomi.polySort(points); | |
const shape = kokomi.createPolygonShape(points, { | |
scale:.01, | |
}); | |
const geometry = new THREE.ExtrudeGeometry(shape, { | |
steps:, | |
depth:.0001, | |
bevelEnabled: true, | |
bevelThickness:.0005, | |
bevelSize:.0005, | |
bevelSegments:, | |
}); | |
geometry.center(); |
随机多边形
为了创建随机的多边形,我特意设计了一套算法,大致是这样的:
- 多边形是按二维网格排布的,这样就能尽可能避免有重合的情况出现
- 多边形的边数edgeCount按个人喜好用随机概率来控制
- 多边形的第一个点决定了它在网格上的位置,其他的点是以它为圆心延伸出来的随机角度的点(跟圆有关因此用到了极坐标公式)
const generatePolygons = (config = {}) => { | |
const { gridX =, gridY = 20, maxX = 9, maxY = 9 } = config; | |
const polygons = []; | |
for (let i =; i < gridX; i++) { | |
for (let j =; j < gridY; j++) { | |
const points = []; | |
let edgeCount =; | |
const randEdgePossibility = Math.random(); | |
if (randEdgePossibility > && randEdgePossibility <= 0.2) { | |
edgeCount =; | |
} else if (randEdgePossibility >.2 && randEdgePossibility <= 0.55) { | |
edgeCount =; | |
} else if (randEdgePossibility >.55 && randEdgePossibility <= 0.9) { | |
edgeCount =; | |
} else if (randEdgePossibility >.9 && randEdgePossibility <= 0.95) { | |
edgeCount =; | |
} else if (randEdgePossibility >.95 && randEdgePossibility <= 1) { | |
edgeCount =; | |
} | |
let firstPoint = { | |
x:, | |
y:, | |
}; | |
let angle = THREE.MathUtils.randFloat(, 2 * Math.PI); | |
for (let k =; k < edgeCount; k++) { | |
if (k ===) { | |
firstPoint = { | |
x: (i % maxX) *, | |
y: (j % maxY) *, | |
}; | |
points.push(firstPoint); | |
} else { | |
// random polarconst r =; | |
angle += THREE.MathUtils.randFloat(, Math.PI / 2); | |
const anotherPoint = { | |
x: firstPoint.x + r * Math.cos(angle), | |
y: firstPoint.y + r * Math.sin(angle), | |
}; | |
points.push(anotherPoint); | |
} | |
} | |
polygons.push(points); | |
} | |
} | |
return polygons; | |
}; |
用该算法来创建多边形组,再调整下相机和多边形组的位置和缩放,就有了下图的效果
漂浮动画
将多边形组整体向上偏移,超出界限则重置高度
let floatDistance =; | |
let floatSpeed =; | |
let floatMaxDistance =; | |
this.update(() => { | |
floatDistance += floatSpeed; | |
const y = floatDistance *.001; | |
if (y > floatMaxDistance) { | |
floatDistance =; | |
} | |
totalG.position.y = y; | |
}); |
将相机靠近,你就会觉得像是每个多边形在上升(其实是整体的容器在上升)
接下来还有2点可以优化下:
- 要想达成一种大小错落的层次感,我们可以拷贝一份多边形组,将其的z轴位置往后移即可
- 要想达成无限上升的动画“假象”,我们需要再整体拷贝一份多边形组(包括组本身和偏移z轴后的组),将它和之前的那组在y轴上错开,这样动画就能无限衔接了
光照
这里可以自由表现,可以尝试以下几种手法:
- 漫反射光和镜面反射光相结合
- 扭曲顶点、法线和uv
- 根据光线动态计算透明度,以形成玻璃般的效果
后期处理
同样也可以自由表现,可以尝试以下几种手法:
- RGB扭曲(该特效所采用的)
- 色差
- 景深效果
- 噪声点阵。