Skip to content

Commit 2ea6c19

Browse files
authored
Merge pull request #437 from bichikim/cursor/bichi-scroll-component-separation-f509
Bichi scroll component separation
2 parents 86dca4f + db1a3e9 commit 2ea6c19

12 files changed

+679
-154
lines changed

apps/bichi/src/components/scroll-stage/ScrollStage.tsx

Lines changed: 40 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,21 @@
1-
import * as THREE from 'three'
21
import {onCleanup, onMount} from 'solid-js'
3-
import vertexShader from '../../shaders/vertex.glsl?raw'
4-
import fragmentShader from '../../shaders/fragment.glsl?raw'
5-
6-
/** Clamp value between min and max (GSAP.utils.clamp equivalent). */
7-
function clamp(min: number, max: number, value: number): number {
8-
return Math.min(max, Math.max(min, value))
9-
}
10-
11-
/** Linear interpolation (GSAP.utils.interpolate equivalent). */
12-
function lerp(current: number, target: number, ease: number): number {
13-
return current + (target - current) * ease
14-
}
2+
import {
3+
applyScrollDomTransforms,
4+
attachCanvasToBody,
5+
getScrollStageElements,
6+
initializeLineElement,
7+
removeCanvasFromBody,
8+
} from './scroll-stage-dom'
9+
import {createScrollState, type Viewport} from './scroll-stage-scroll'
10+
import {createUniformAnimator} from './scroll-stage-uniforms'
11+
import {createWebglStage} from './scroll-stage-webgl'
12+
import {EASE, ROTATION_SPEED} from './scroll-stage-settings'
1513

1614
export interface ScrollStageProps {
1715
/** Ref to the .content element that contains .scroll__content and .layout__line */
1816
contentRef: () => HTMLElement | undefined
1917
}
2018

21-
const EASE = 0.05
22-
const SOFT_THRESHOLD = 0.01
23-
const CAMERA_FOV = 75
24-
const CAMERA_NEAR = 0.1
25-
const CAMERA_FAR = 10
26-
const CAMERA_Z = 2.5
27-
const ICOSAHEDRON_DETAIL = 64
28-
const ROTATION_SPEED = 0.05
29-
const MOBILE_SCALE = 0.75
30-
const PIXEL_RATIO_MAX = 1.5
31-
const EASE_MULTIPLIER = 2
32-
33-
const SETTINGS = {
34-
uAmplitude: {end: 4, start: 4},
35-
uDeepPurple: {end: 0, start: 1},
36-
uDensity: {end: 1, start: 1},
37-
uFrequency: {end: 4, start: 0},
38-
uOpacity: {end: 0.66, start: 0.1},
39-
uStrength: {end: 1.1, start: 0},
40-
} as const
41-
4219
export function ScrollStage(props: ScrollStageProps) {
4320
onMount(() => {
4421
const contentElement = props.contentRef()
@@ -47,140 +24,55 @@ export function ScrollStage(props: ScrollStageProps) {
4724
return
4825
}
4926

50-
const scrollContent = contentElement.querySelector<HTMLElement>('.scroll__content')
51-
const lineElement_ = contentElement.querySelector<HTMLElement>('.layout__line')
27+
const elements = getScrollStageElements(contentElement)
5228

53-
if (!scrollContent || !lineElement_) {
29+
if (!elements) {
5430
return
5531
}
56-
const scrollContentElement = scrollContent
57-
const lineElement = lineElement_
58-
59-
let viewport = {height: window.innerHeight, width: window.innerWidth}
60-
61-
const scroll = {
62-
hard: 0,
63-
height: 0,
64-
limit: 0,
65-
normalized: 0,
66-
running: false,
67-
soft: 0,
68-
}
69-
70-
const currentUniforms: Record<string, number> = {
71-
uAmplitude: SETTINGS.uAmplitude.start,
72-
uDeepPurple: SETTINGS.uDeepPurple.start,
73-
uDensity: SETTINGS.uDensity.start,
74-
uFrequency: SETTINGS.uFrequency.start,
75-
uOpacity: SETTINGS.uOpacity.start,
76-
uStrength: SETTINGS.uStrength.start,
77-
}
7832

79-
function setSizes() {
80-
scroll.height = scrollContentElement.getBoundingClientRect().height
81-
scroll.limit = scrollContentElement.clientHeight - viewport.height
82-
document.body.style.height = `${scroll.height}px`
83-
}
84-
85-
const scene = new THREE.Scene()
86-
87-
scene.background = new THREE.Color(0x00_00_00)
88-
const renderer = new THREE.WebGLRenderer({alpha: true, antialias: true})
89-
const canvas = renderer.domElement
90-
91-
canvas.classList.add('webgl')
92-
canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:0;pointer-events:none'
93-
document.body.insertBefore(canvas, document.body.firstChild)
94-
95-
const camera = new THREE.PerspectiveCamera(CAMERA_FOV, viewport.width / viewport.height, CAMERA_NEAR, CAMERA_FAR)
96-
97-
camera.position.set(0, 0, CAMERA_Z)
98-
scene.add(camera)
99-
100-
const geometry = new THREE.IcosahedronGeometry(1, ICOSAHEDRON_DETAIL)
101-
102-
const material = new THREE.ShaderMaterial({
103-
blending: THREE.AdditiveBlending,
104-
fragmentShader,
105-
transparent: true,
106-
uniforms: {
107-
uAmplitude: {value: SETTINGS.uAmplitude.start},
108-
uDeepPurple: {value: SETTINGS.uDeepPurple.start},
109-
uDensity: {value: SETTINGS.uDensity.start},
110-
uFrequency: {value: SETTINGS.uFrequency.start},
111-
uOpacity: {value: SETTINGS.uOpacity.start},
112-
uStrength: {value: SETTINGS.uStrength.start},
113-
},
114-
vertexShader,
115-
wireframe: true,
116-
})
117-
const mesh = new THREE.Mesh(geometry, material)
118-
119-
scene.add(mesh)
33+
initializeLineElement(elements.lineElement)
12034

121-
const clock = new THREE.Clock()
122-
let rafId: number
35+
let viewport: Viewport = {height: window.innerHeight, width: window.innerWidth}
36+
const scrollState = createScrollState(elements.scrollContentElement, {ease: EASE})
12337

124-
function updateScrollValues() {
125-
scroll.hard = clamp(0, scroll.limit, window.scrollY)
126-
scroll.soft = lerp(scroll.soft, scroll.hard, EASE)
38+
scrollState.setViewport(viewport)
12739

128-
if (scroll.soft < SOFT_THRESHOLD) {
129-
scroll.soft = 0
130-
}
40+
const stage = createWebglStage(viewport)
13141

132-
scroll.normalized = scroll.limit > 0 ? scroll.soft / scroll.limit : 0
133-
scrollContentElement.style.transform = `translateY(${-scroll.soft}px)`
134-
mesh.rotation.x = scroll.normalized * Math.PI
135-
lineElement.style.transform = `scaleX(${scroll.normalized})`
136-
lineElement.style.transformOrigin = 'left'
42+
attachCanvasToBody(stage.canvas)
13743

138-
for (const key of Object.keys(SETTINGS) as (keyof typeof SETTINGS)[]) {
139-
const setting = SETTINGS[key]
44+
const uniformAnimator = createUniformAnimator(stage.material, EASE)
45+
let rafId: number
14046

141-
const target = setting.start + scroll.normalized * (setting.end - setting.start)
47+
const updateScrollValues = () => {
48+
scrollState.updatePosition(window.scrollY)
49+
const {metrics} = scrollState.state
14250

143-
currentUniforms[key] = lerp(currentUniforms[key], target, EASE * EASE_MULTIPLIER)
144-
;(material.uniforms[key] as THREE.IUniform<number>).value = currentUniforms[key]
145-
}
51+
applyScrollDomTransforms(elements, metrics.soft, metrics.normalized)
52+
stage.mesh.rotation.x = metrics.normalized * Math.PI
53+
uniformAnimator.update(metrics.normalized)
14654
}
14755

148-
function update() {
149-
const elapsed = clock.getElapsedTime()
56+
const update = () => {
57+
const elapsed = stage.clock.getElapsedTime()
15058

151-
mesh.rotation.y = elapsed * ROTATION_SPEED
59+
stage.mesh.rotation.y = elapsed * ROTATION_SPEED
15260
updateScrollValues()
153-
renderer.render(scene, camera)
61+
stage.render()
15462
rafId = requestAnimationFrame(update)
15563
}
15664

157-
function onResize() {
65+
const onResize = () => {
15866
viewport = {height: window.innerHeight, width: window.innerWidth}
159-
setSizes()
160-
161-
if (viewport.width < viewport.height) {
162-
mesh.scale.set(MOBILE_SCALE, MOBILE_SCALE, MOBILE_SCALE)
163-
} else {
164-
mesh.scale.set(1, 1, 1)
165-
}
166-
167-
camera.aspect = viewport.width / viewport.height
168-
camera.updateProjectionMatrix()
169-
renderer.setSize(viewport.width, viewport.height)
170-
renderer.setPixelRatio(Math.min(window.devicePixelRatio, PIXEL_RATIO_MAX))
67+
scrollState.setViewport(viewport)
68+
scrollState.updateSizes()
69+
stage.updateScale(viewport)
70+
stage.resize(viewport)
17171
}
17272

173-
function onScroll() {
174-
if (!scroll.running) {
175-
scroll.running = true
176-
177-
requestAnimationFrame(() => {
178-
scroll.running = false
179-
})
180-
}
181-
}
73+
const {onScroll} = scrollState
18274

183-
setSizes()
75+
scrollState.updateSizes()
18476
onResize()
18577
window.addEventListener('scroll', onScroll)
18678
window.addEventListener('resize', onResize)
@@ -190,14 +82,8 @@ export function ScrollStage(props: ScrollStageProps) {
19082
cancelAnimationFrame(rafId)
19183
window.removeEventListener('scroll', onScroll)
19284
window.removeEventListener('resize', onResize)
193-
geometry.dispose()
194-
material.dispose()
195-
renderer.forceContextLoss()
196-
renderer.dispose()
197-
198-
if (canvas.parentNode) {
199-
canvas.remove()
200-
}
85+
stage.dispose()
86+
removeCanvasFromBody(stage.canvas)
20187
})
20288
})
20389

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import {beforeEach, describe, expect, it} from 'vitest'
2+
import {
3+
applyScrollDomTransforms,
4+
attachCanvasToBody,
5+
getScrollStageElements,
6+
initializeLineElement,
7+
removeCanvasFromBody,
8+
setBodyHeight,
9+
} from '../scroll-stage-dom'
10+
11+
describe('scroll-stage-dom', () => {
12+
beforeEach(() => {
13+
document.body.innerHTML = ''
14+
document.body.style.height = ''
15+
})
16+
17+
it('finds scroll stage elements', () => {
18+
const content = document.createElement('div')
19+
const scrollContent = document.createElement('div')
20+
21+
scrollContent.className = 'scroll__content'
22+
const lineElement = document.createElement('div')
23+
24+
lineElement.className = 'layout__line'
25+
content.append(scrollContent, lineElement)
26+
27+
const elements = getScrollStageElements(content)
28+
29+
expect(elements).not.toBeNull()
30+
expect(elements?.scrollContentElement).toBe(scrollContent)
31+
expect(elements?.lineElement).toBe(lineElement)
32+
})
33+
34+
it('returns null when required elements are missing', () => {
35+
const content = document.createElement('div')
36+
const scrollContent = document.createElement('div')
37+
38+
scrollContent.className = 'scroll__content'
39+
content.append(scrollContent)
40+
41+
const elements = getScrollStageElements(content)
42+
43+
expect(elements).toBeNull()
44+
})
45+
46+
it('initializes line element transform origin', () => {
47+
const lineElement = document.createElement('div')
48+
49+
initializeLineElement(lineElement)
50+
expect(lineElement.style.transformOrigin).toBe('left')
51+
})
52+
53+
it('applies scroll transforms to DOM elements', () => {
54+
const scrollContentElement = document.createElement('div')
55+
const lineElement = document.createElement('div')
56+
57+
applyScrollDomTransforms({lineElement, scrollContentElement}, 120, 0.4)
58+
expect(scrollContentElement.style.transform).toBe('translateY(-120px)')
59+
expect(lineElement.style.transform).toBe('scaleX(0.4)')
60+
})
61+
62+
it('sets the document body height', () => {
63+
setBodyHeight(256)
64+
expect(document.body.style.height).toBe('256px')
65+
})
66+
67+
it('attaches and removes canvas from the body', () => {
68+
const canvas = document.createElement('canvas')
69+
70+
attachCanvasToBody(canvas)
71+
expect(document.body.firstChild).toBe(canvas)
72+
removeCanvasFromBody(canvas)
73+
expect(document.body.contains(canvas)).toBe(false)
74+
})
75+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {describe, expect, it} from 'vitest'
2+
import {clamp, lerp} from '../scroll-stage-math'
3+
4+
describe('scroll-stage-math', () => {
5+
it('clamps values within bounds', () => {
6+
expect(clamp(0, 10, -5)).toBe(0)
7+
expect(clamp(0, 10, 5)).toBe(5)
8+
expect(clamp(0, 10, 15)).toBe(10)
9+
})
10+
11+
it('lerps between current and target', () => {
12+
expect(lerp(0, 10, 0.5)).toBe(5)
13+
expect(lerp(10, 10, 0.5)).toBe(10)
14+
})
15+
})

0 commit comments

Comments
 (0)