@dickpy/dsh-imagegen 1.5.8 → 1.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/lib/client.js +2678 -598
- package/lib/client.js.map +1 -1
- package/lib/index.js +18 -3
- package/package.json +82 -82
- package/src/canvas-store.ts +389 -376
- package/src/client/CanvasBackgrounds.tsx +1792 -0
- package/src/client/CanvasWorkspace.tsx +2130 -2211
- package/src/client/canvas-workspace.module.css +1187 -1189
- package/src/client/locales.ts +27 -9
- package/src/protocol.ts +580 -580
|
@@ -0,0 +1,1792 @@
|
|
|
1
|
+
/** Animated canvas backdrops for the infinite canvas workspace, ported from
|
|
2
|
+
* reactbits.dev backgrounds. They render inside the grid layer, which never
|
|
3
|
+
* receives pointer events, so every listener observes the viewport element. */
|
|
4
|
+
|
|
5
|
+
import { useEffect, useRef } from 'react'
|
|
6
|
+
import css from './canvas-workspace.module.css'
|
|
7
|
+
|
|
8
|
+
/** Interactive flowmap-style dot field ("fluid distortion"): the pointer's
|
|
9
|
+
* velocity pushes dots sideways like a fluid; they spring back home when it
|
|
10
|
+
* moves on, with a barely-visible idle drift keeping the field alive. */
|
|
11
|
+
export function FlowBackground(): React.JSX.Element {
|
|
12
|
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const canvas = canvasRef.current
|
|
15
|
+
// canvas lives inside the grid layer; events must be observed on the
|
|
16
|
+
// viewport container itself (the grid never receives pointer events).
|
|
17
|
+
const layer = canvas?.parentElement
|
|
18
|
+
const viewport = layer?.parentElement
|
|
19
|
+
if (canvas === null || canvas === undefined || viewport === null || viewport === undefined) return
|
|
20
|
+
const ctx = canvas.getContext('2d')
|
|
21
|
+
if (ctx === null) return
|
|
22
|
+
let disposed = false
|
|
23
|
+
const pointer = { x: -1e4, y: -1e4, vx: 0, vy: 0, seen: false }
|
|
24
|
+
const SPACING = 44
|
|
25
|
+
const RADIUS = 120
|
|
26
|
+
let width = 0
|
|
27
|
+
let height = 0
|
|
28
|
+
let points: Array<{ hx: number; hy: number; x: number; y: number; vx: number; vy: number }> = []
|
|
29
|
+
const rebuild = (): void => {
|
|
30
|
+
const rect = viewport.getBoundingClientRect()
|
|
31
|
+
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio ?? 1))
|
|
32
|
+
width = Math.max(1, Math.round(rect.width))
|
|
33
|
+
height = Math.max(1, Math.round(rect.height))
|
|
34
|
+
canvas.width = Math.round(width * dpr)
|
|
35
|
+
canvas.height = Math.round(height * dpr)
|
|
36
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
37
|
+
points = []
|
|
38
|
+
for (let y = SPACING / 2; y < height; y += SPACING) {
|
|
39
|
+
for (let x = SPACING / 2; x < width; x += SPACING) points.push({ hx: x, hy: y, x, y, vx: 0, vy: 0 })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
rebuild()
|
|
43
|
+
const observer = new ResizeObserver(rebuild)
|
|
44
|
+
observer.observe(viewport)
|
|
45
|
+
const onMove = (event: PointerEvent): void => {
|
|
46
|
+
const rect = viewport.getBoundingClientRect()
|
|
47
|
+
const x = event.clientX - rect.left
|
|
48
|
+
const y = event.clientY - rect.top
|
|
49
|
+
if (pointer.seen) {
|
|
50
|
+
pointer.vx = pointer.vx * 0.6 + (x - pointer.x) * 0.4
|
|
51
|
+
pointer.vy = pointer.vy * 0.6 + (y - pointer.y) * 0.4
|
|
52
|
+
}
|
|
53
|
+
pointer.x = x
|
|
54
|
+
pointer.y = y
|
|
55
|
+
pointer.seen = true
|
|
56
|
+
}
|
|
57
|
+
const onLeave = (): void => { pointer.x = -1e4; pointer.y = -1e4; pointer.vx = 0; pointer.vy = 0 }
|
|
58
|
+
viewport.addEventListener('pointermove', onMove, true)
|
|
59
|
+
viewport.addEventListener('pointerleave', onLeave)
|
|
60
|
+
let frame = 0
|
|
61
|
+
let time = 0
|
|
62
|
+
const tick = (): void => {
|
|
63
|
+
time += 0.016
|
|
64
|
+
const r2 = RADIUS * RADIUS
|
|
65
|
+
for (const p of points) {
|
|
66
|
+
// A barely-visible idle drift keeps the field alive without the pointer.
|
|
67
|
+
p.vx += (p.hx + Math.sin(time * 1.3 + p.hy * 0.055) * 0.5 - p.x) * 0.03
|
|
68
|
+
p.vy += (p.hy + Math.cos(time * 1.1 + p.hx * 0.055) * 0.5 - p.y) * 0.03
|
|
69
|
+
const dx = p.x - pointer.x
|
|
70
|
+
const dy = p.y - pointer.y
|
|
71
|
+
const d2 = dx * dx + dy * dy
|
|
72
|
+
if (d2 < RADIUS * RADIUS && d2 > 0.01) {
|
|
73
|
+
const d = Math.sqrt(d2)
|
|
74
|
+
const force = (1 - d / RADIUS) * 0.9
|
|
75
|
+
p.vx += pointer.vx * force + (dx / d) * force * 2.2
|
|
76
|
+
p.vy += pointer.vy * force + (dy / d) * force * 2.2
|
|
77
|
+
}
|
|
78
|
+
p.vx *= 0.86
|
|
79
|
+
p.vy *= 0.86
|
|
80
|
+
p.x += p.vx
|
|
81
|
+
p.y += p.vy
|
|
82
|
+
}
|
|
83
|
+
ctx.clearRect(0, 0, width, height)
|
|
84
|
+
for (const p of points) {
|
|
85
|
+
const speed = Math.min(4, Math.hypot(p.vx, p.vy))
|
|
86
|
+
ctx.fillStyle = `rgba(96, 125, 255, ${(0.16 + speed * 0.16).toFixed(3)})`
|
|
87
|
+
ctx.beginPath()
|
|
88
|
+
ctx.arc(p.x, p.y, 1.4 + Math.min(1.8, speed * 0.5), 0, Math.PI * 2)
|
|
89
|
+
ctx.fill()
|
|
90
|
+
}
|
|
91
|
+
frame = window.requestAnimationFrame(tick)
|
|
92
|
+
}
|
|
93
|
+
frame = window.requestAnimationFrame(tick)
|
|
94
|
+
return () => {
|
|
95
|
+
disposed = true
|
|
96
|
+
window.cancelAnimationFrame(frame)
|
|
97
|
+
observer.disconnect()
|
|
98
|
+
viewport.removeEventListener('pointermove', onMove)
|
|
99
|
+
viewport.removeEventListener('pointerleave', onLeave)
|
|
100
|
+
}
|
|
101
|
+
}, [])
|
|
102
|
+
return <canvas ref={canvasRef} className={css.flowCanvas} aria-hidden="true" />
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** "Liquid ether" background in the reactbits.dev style: a GPU stable-fluids
|
|
106
|
+
* velocity simulation whose speed is mapped through a three-stop palette
|
|
107
|
+
* (raw WebGL, no dependencies). Pointer movement stirs the fluid; after a
|
|
108
|
+
* second of stillness an auto-driven virtual pointer resumes wandering so
|
|
109
|
+
* the field keeps breathing on its own and blends back to the real pointer
|
|
110
|
+
* on input. Unlike the original, the auto driver keeps running while the
|
|
111
|
+
* pointer merely rests inside — on a working canvas it usually does. */
|
|
112
|
+
export function LiquidEtherBackground(): React.JSX.Element {
|
|
113
|
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const canvas = canvasRef.current
|
|
116
|
+
const layer = canvas?.parentElement
|
|
117
|
+
const viewport = layer?.parentElement
|
|
118
|
+
if (canvas === null || canvas === undefined || viewport === null || viewport === undefined) return
|
|
119
|
+
const gl = canvas.getContext('webgl', { alpha: true, antialias: false, depth: false, stencil: false })
|
|
120
|
+
if (gl === null) return
|
|
121
|
+
|
|
122
|
+
// Simulation constants mirror the reactbits LiquidEther defaults.
|
|
123
|
+
const MOUSE_FORCE = 20
|
|
124
|
+
const CURSOR_SIZE = 100 // splat radius, in simulation cells
|
|
125
|
+
const DT = 0.014
|
|
126
|
+
const RESOLUTION = 0.5 // simulation grid spans half the CSS size per axis
|
|
127
|
+
const POISSON_ITERATIONS = 32
|
|
128
|
+
const AUTO_SPEED = 0.5 // normalized units per second
|
|
129
|
+
const AUTO_INTENSITY = 2.2
|
|
130
|
+
const AUTO_RESUME_DELAY = 1000
|
|
131
|
+
const AUTO_RAMP = 600
|
|
132
|
+
const TAKEOVER_DURATION = 250
|
|
133
|
+
const PALETTE = ['#5227FF', '#FF9FFC', '#B497CF']
|
|
134
|
+
|
|
135
|
+
// Shared fullscreen-quad vertex shader; sim passes shrink the quad by one
|
|
136
|
+
// cell per side so the border clamps (the display pass uses (1, 1)).
|
|
137
|
+
const quadVertex = `
|
|
138
|
+
precision highp float;
|
|
139
|
+
attribute vec3 position;
|
|
140
|
+
uniform vec2 uScale;
|
|
141
|
+
varying vec2 uv;
|
|
142
|
+
void main() {
|
|
143
|
+
vec3 pos = position;
|
|
144
|
+
pos.xy *= uScale;
|
|
145
|
+
uv = vec2(0.5) + pos.xy * 0.5;
|
|
146
|
+
gl_Position = vec4(pos, 1.0);
|
|
147
|
+
}
|
|
148
|
+
`
|
|
149
|
+
// BFECC advection: backtrace, forward-again to measure the error, then
|
|
150
|
+
// sample from the halfway-corrected position.
|
|
151
|
+
const advectionFragment = `
|
|
152
|
+
precision highp float;
|
|
153
|
+
uniform sampler2D uVelocity;
|
|
154
|
+
uniform float uDt;
|
|
155
|
+
uniform vec2 uFboSize;
|
|
156
|
+
varying vec2 uv;
|
|
157
|
+
void main() {
|
|
158
|
+
vec2 ratio = max(uFboSize.x, uFboSize.y) / uFboSize;
|
|
159
|
+
vec2 velOld = texture2D(uVelocity, uv).xy;
|
|
160
|
+
vec2 spotOld = uv - velOld * uDt * ratio;
|
|
161
|
+
vec2 velNew = texture2D(uVelocity, spotOld).xy;
|
|
162
|
+
vec2 spotAgain = spotOld + velNew * uDt * ratio;
|
|
163
|
+
vec2 spotMid = uv - (spotAgain - uv) * 0.5;
|
|
164
|
+
vec2 velMid = texture2D(uVelocity, spotMid).xy;
|
|
165
|
+
vec2 spotOld2 = spotMid - velMid * uDt * ratio;
|
|
166
|
+
gl_FragColor = vec4(texture2D(uVelocity, spotOld2).xy, 0.0, 0.0);
|
|
167
|
+
}
|
|
168
|
+
`
|
|
169
|
+
const divergenceFragment = `
|
|
170
|
+
precision highp float;
|
|
171
|
+
uniform sampler2D uVelocity;
|
|
172
|
+
uniform float uDt;
|
|
173
|
+
uniform vec2 uPx;
|
|
174
|
+
varying vec2 uv;
|
|
175
|
+
void main() {
|
|
176
|
+
float x0 = texture2D(uVelocity, uv - vec2(uPx.x, 0.0)).x;
|
|
177
|
+
float x1 = texture2D(uVelocity, uv + vec2(uPx.x, 0.0)).x;
|
|
178
|
+
float y0 = texture2D(uVelocity, uv - vec2(0.0, uPx.y)).y;
|
|
179
|
+
float y1 = texture2D(uVelocity, uv + vec2(0.0, uPx.y)).y;
|
|
180
|
+
gl_FragColor = vec4((x1 - x0 + y1 - y0) / 2.0 / uDt);
|
|
181
|
+
}
|
|
182
|
+
`
|
|
183
|
+
const poissonFragment = `
|
|
184
|
+
precision highp float;
|
|
185
|
+
uniform sampler2D uPressure;
|
|
186
|
+
uniform sampler2D uDivergence;
|
|
187
|
+
uniform vec2 uPx;
|
|
188
|
+
varying vec2 uv;
|
|
189
|
+
void main() {
|
|
190
|
+
float p0 = texture2D(uPressure, uv + vec2(uPx.x * 2.0, 0.0)).r;
|
|
191
|
+
float p1 = texture2D(uPressure, uv - vec2(uPx.x * 2.0, 0.0)).r;
|
|
192
|
+
float p2 = texture2D(uPressure, uv + vec2(0.0, uPx.y * 2.0)).r;
|
|
193
|
+
float p3 = texture2D(uPressure, uv - vec2(0.0, uPx.y * 2.0)).r;
|
|
194
|
+
float div = texture2D(uDivergence, uv).r;
|
|
195
|
+
gl_FragColor = vec4((p0 + p1 + p2 + p3) / 4.0 - div);
|
|
196
|
+
}
|
|
197
|
+
`
|
|
198
|
+
const pressureFragment = `
|
|
199
|
+
precision highp float;
|
|
200
|
+
uniform sampler2D uPressure;
|
|
201
|
+
uniform sampler2D uVelocity;
|
|
202
|
+
uniform float uDt;
|
|
203
|
+
uniform vec2 uPx;
|
|
204
|
+
varying vec2 uv;
|
|
205
|
+
void main() {
|
|
206
|
+
float p0 = texture2D(uPressure, uv + vec2(uPx.x, 0.0)).r;
|
|
207
|
+
float p1 = texture2D(uPressure, uv - vec2(uPx.x, 0.0)).r;
|
|
208
|
+
float p2 = texture2D(uPressure, uv + vec2(0.0, uPx.y)).r;
|
|
209
|
+
float p3 = texture2D(uPressure, uv - vec2(0.0, uPx.y)).r;
|
|
210
|
+
vec2 gradient = vec2(p0 - p1, p2 - p3) * 0.5;
|
|
211
|
+
gl_FragColor = vec4(texture2D(uVelocity, uv).xy - gradient * uDt, 0.0, 1.0);
|
|
212
|
+
}
|
|
213
|
+
`
|
|
214
|
+
// Splat: quadratic-falloff force bump added on top of the field with
|
|
215
|
+
// additive blending (no texture read — sampling the render target
|
|
216
|
+
// itself is undefined in WebGL).
|
|
217
|
+
const splatFragment = `
|
|
218
|
+
precision highp float;
|
|
219
|
+
uniform vec2 uCenter;
|
|
220
|
+
uniform vec2 uRadius;
|
|
221
|
+
uniform vec2 uForce;
|
|
222
|
+
varying vec2 uv;
|
|
223
|
+
void main() {
|
|
224
|
+
vec2 offset = (uv - uCenter) / uRadius;
|
|
225
|
+
float weight = 1.0 - min(length(offset), 1.0);
|
|
226
|
+
gl_FragColor = vec4(uForce * weight * weight, 0.0, 0.0);
|
|
227
|
+
}
|
|
228
|
+
`
|
|
229
|
+
// Display: speed through the palette. rgb stays <= alpha so the canvas
|
|
230
|
+
// composites correctly with premultiplied alpha.
|
|
231
|
+
const displayFragment = `
|
|
232
|
+
precision highp float;
|
|
233
|
+
uniform sampler2D uVelocity;
|
|
234
|
+
uniform sampler2D uPalette;
|
|
235
|
+
varying vec2 uv;
|
|
236
|
+
void main() {
|
|
237
|
+
float speed = clamp(length(texture2D(uVelocity, uv).xy), 0.0, 1.0);
|
|
238
|
+
vec3 ink = texture2D(uPalette, vec2(speed, 0.5)).rgb;
|
|
239
|
+
gl_FragColor = vec4(ink * speed, speed);
|
|
240
|
+
}
|
|
241
|
+
`
|
|
242
|
+
|
|
243
|
+
const createProgram = (fragmentSource: string): { program: WebGLProgram, uniforms: Record<string, WebGLUniformLocation> } => {
|
|
244
|
+
const compile = (type: number, source: string): WebGLShader => {
|
|
245
|
+
const shader = gl.createShader(type)
|
|
246
|
+
if (shader === null) throw new Error('webgl: shader alloc failed')
|
|
247
|
+
gl.shaderSource(shader, source)
|
|
248
|
+
gl.compileShader(shader)
|
|
249
|
+
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS) !== true) {
|
|
250
|
+
throw new Error(gl.getShaderInfoLog(shader) ?? 'webgl: shader compile failed')
|
|
251
|
+
}
|
|
252
|
+
return shader
|
|
253
|
+
}
|
|
254
|
+
const program = gl.createProgram()
|
|
255
|
+
if (program === null) throw new Error('webgl: program alloc failed')
|
|
256
|
+
gl.attachShader(program, compile(gl.VERTEX_SHADER, quadVertex))
|
|
257
|
+
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fragmentSource))
|
|
258
|
+
gl.bindAttribLocation(program, 0, 'position')
|
|
259
|
+
gl.linkProgram(program)
|
|
260
|
+
if (gl.getProgramParameter(program, gl.LINK_STATUS) !== true) {
|
|
261
|
+
throw new Error(gl.getProgramInfoLog(program) ?? 'webgl: program link failed')
|
|
262
|
+
}
|
|
263
|
+
// Detached + deleted right after linking, so deleting the program frees everything.
|
|
264
|
+
for (const shader of gl.getAttachedShaders(program) ?? []) {
|
|
265
|
+
gl.detachShader(program, shader)
|
|
266
|
+
gl.deleteShader(shader)
|
|
267
|
+
}
|
|
268
|
+
const uniforms: Record<string, WebGLUniformLocation> = {}
|
|
269
|
+
const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS) as number
|
|
270
|
+
for (let index = 0; index < count; index++) {
|
|
271
|
+
const info = gl.getActiveUniform(program, index)
|
|
272
|
+
const location = info !== null ? gl.getUniformLocation(program, info.name) : null
|
|
273
|
+
if (info !== null && location !== null) uniforms[info.name] = location
|
|
274
|
+
}
|
|
275
|
+
return { program, uniforms }
|
|
276
|
+
}
|
|
277
|
+
const createSimTexture = (width: number, height: number, type: number, filter: number): { texture: WebGLTexture, fbo: WebGLFramebuffer, width: number, height: number } => {
|
|
278
|
+
const texture = gl.createTexture()
|
|
279
|
+
if (texture === null) throw new Error('webgl: texture alloc failed')
|
|
280
|
+
gl.activeTexture(gl.TEXTURE0)
|
|
281
|
+
gl.bindTexture(gl.TEXTURE_2D, texture)
|
|
282
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter)
|
|
283
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter)
|
|
284
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
|
285
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
|
286
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, type, null)
|
|
287
|
+
const fbo = gl.createFramebuffer()
|
|
288
|
+
if (fbo === null) throw new Error('webgl: framebuffer alloc failed')
|
|
289
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo)
|
|
290
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0)
|
|
291
|
+
gl.clearColor(0, 0, 0, 0)
|
|
292
|
+
gl.clear(gl.COLOR_BUFFER_BIT)
|
|
293
|
+
return { texture, fbo, width, height }
|
|
294
|
+
}
|
|
295
|
+
// Float textures are required by the solver; prefer a renderable +
|
|
296
|
+
// linearly-filterable format, degrade to nearest, else give up.
|
|
297
|
+
const chooseSimFormat = (): { type: number, filter: number } | null => {
|
|
298
|
+
const halfFloat = gl.getExtension('OES_texture_half_float')
|
|
299
|
+
const halfLinear = gl.getExtension('OES_texture_half_float_linear')
|
|
300
|
+
const fullFloat = gl.getExtension('OES_texture_float')
|
|
301
|
+
const fullLinear = gl.getExtension('OES_texture_float_linear')
|
|
302
|
+
const candidates: Array<{ type: number, filter: number }> = []
|
|
303
|
+
if (halfFloat !== null && halfLinear !== null) candidates.push({ type: halfFloat.HALF_FLOAT_OES, filter: gl.LINEAR })
|
|
304
|
+
if (fullFloat !== null && fullLinear !== null) candidates.push({ type: gl.FLOAT, filter: gl.LINEAR })
|
|
305
|
+
if (halfFloat !== null) candidates.push({ type: halfFloat.HALF_FLOAT_OES, filter: gl.NEAREST })
|
|
306
|
+
if (fullFloat !== null) candidates.push({ type: gl.FLOAT, filter: gl.NEAREST })
|
|
307
|
+
for (const candidate of candidates) {
|
|
308
|
+
const probe = createSimTexture(4, 4, candidate.type, candidate.filter)
|
|
309
|
+
const complete = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE
|
|
310
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
|
|
311
|
+
gl.deleteTexture(probe.texture)
|
|
312
|
+
gl.deleteFramebuffer(probe.fbo)
|
|
313
|
+
if (complete) return candidate
|
|
314
|
+
}
|
|
315
|
+
return null
|
|
316
|
+
}
|
|
317
|
+
const simFormat = chooseSimFormat()
|
|
318
|
+
if (simFormat === null) return
|
|
319
|
+
const advectionPass = createProgram(advectionFragment)
|
|
320
|
+
const splatPass = createProgram(splatFragment)
|
|
321
|
+
const divergencePass = createProgram(divergenceFragment)
|
|
322
|
+
const poissonPass = createProgram(poissonFragment)
|
|
323
|
+
const pressurePass = createProgram(pressureFragment)
|
|
324
|
+
const displayPass = createProgram(displayFragment)
|
|
325
|
+
|
|
326
|
+
const quadBuffer = gl.createBuffer()
|
|
327
|
+
if (quadBuffer === null) return
|
|
328
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer)
|
|
329
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 0, 1, -1, 0, -1, 1, 0, 1, 1, 0]), gl.STATIC_DRAW)
|
|
330
|
+
gl.enableVertexAttribArray(0)
|
|
331
|
+
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0)
|
|
332
|
+
|
|
333
|
+
const paletteTexture = gl.createTexture()
|
|
334
|
+
if (paletteTexture === null) return
|
|
335
|
+
gl.activeTexture(gl.TEXTURE0)
|
|
336
|
+
gl.bindTexture(gl.TEXTURE_2D, paletteTexture)
|
|
337
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
|
338
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
|
339
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
|
340
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
|
341
|
+
const paletteData = new Uint8Array(PALETTE.length * 4)
|
|
342
|
+
for (const [index, hex] of PALETTE.entries()) {
|
|
343
|
+
paletteData[index * 4 + 0] = parseInt(hex.slice(1, 3), 16)
|
|
344
|
+
paletteData[index * 4 + 1] = parseInt(hex.slice(3, 5), 16)
|
|
345
|
+
paletteData[index * 4 + 2] = parseInt(hex.slice(5, 7), 16)
|
|
346
|
+
paletteData[index * 4 + 3] = 255
|
|
347
|
+
}
|
|
348
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, PALETTE.length, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, paletteData)
|
|
349
|
+
|
|
350
|
+
type SimTexture = { texture: WebGLTexture, fbo: WebGLFramebuffer, width: number, height: number }
|
|
351
|
+
let velocities: [SimTexture, SimTexture] | null = null
|
|
352
|
+
let divergenceTex: SimTexture | null = null
|
|
353
|
+
let pressures: [SimTexture, SimTexture] | null = null
|
|
354
|
+
const destroySimTexture = (texture: SimTexture): void => {
|
|
355
|
+
gl.deleteTexture(texture.texture)
|
|
356
|
+
gl.deleteFramebuffer(texture.fbo)
|
|
357
|
+
}
|
|
358
|
+
const resizeCanvas = (): void => {
|
|
359
|
+
const rect = viewport.getBoundingClientRect()
|
|
360
|
+
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio ?? 1))
|
|
361
|
+
canvas.width = Math.max(1, Math.round(rect.width * dpr))
|
|
362
|
+
canvas.height = Math.max(1, Math.round(rect.height * dpr))
|
|
363
|
+
for (const texture of [...(velocities ?? []), ...(divergenceTex !== null ? [divergenceTex] : []), ...(pressures ?? [])]) destroySimTexture(texture)
|
|
364
|
+
velocities = null
|
|
365
|
+
divergenceTex = null
|
|
366
|
+
pressures = null
|
|
367
|
+
if (rect.width < 1 || rect.height < 1) return
|
|
368
|
+
const simWidth = Math.max(1, Math.round(RESOLUTION * rect.width))
|
|
369
|
+
const simHeight = Math.max(1, Math.round(RESOLUTION * rect.height))
|
|
370
|
+
velocities = [createSimTexture(simWidth, simHeight, simFormat.type, simFormat.filter), createSimTexture(simWidth, simHeight, simFormat.type, simFormat.filter)]
|
|
371
|
+
divergenceTex = createSimTexture(simWidth, simHeight, simFormat.type, simFormat.filter)
|
|
372
|
+
pressures = [createSimTexture(simWidth, simHeight, simFormat.type, simFormat.filter), createSimTexture(simWidth, simHeight, simFormat.type, simFormat.filter)]
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const reducedMotion = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true
|
|
376
|
+
const pointer = {
|
|
377
|
+
x: 0, y: 0, oldX: 0, oldY: 0, diffX: 0, diffY: 0,
|
|
378
|
+
seen: false, lastInteract: performance.now(),
|
|
379
|
+
autoActive: false, takeover: false, takeoverStart: 0,
|
|
380
|
+
fromX: 0, fromY: 0, toX: 0, toY: 0,
|
|
381
|
+
}
|
|
382
|
+
const auto = { active: false, x: 0, y: 0, targetX: 0, targetY: 0, last: 0, started: 0 }
|
|
383
|
+
const stopAuto = (): void => {
|
|
384
|
+
auto.active = false
|
|
385
|
+
pointer.autoActive = false
|
|
386
|
+
}
|
|
387
|
+
const pickTarget = (): void => {
|
|
388
|
+
const margin = 0.2
|
|
389
|
+
auto.targetX = (Math.random() * 2 - 1) * (1 - margin)
|
|
390
|
+
auto.targetY = (Math.random() * 2 - 1) * (1 - margin)
|
|
391
|
+
}
|
|
392
|
+
const onPointerMove = (event: PointerEvent): void => {
|
|
393
|
+
const rect = viewport.getBoundingClientRect()
|
|
394
|
+
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) return
|
|
395
|
+
if (rect.width === 0 || rect.height === 0) return
|
|
396
|
+
const x = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
|
397
|
+
const y = -(((event.clientY - rect.top) / rect.height) * 2 - 1)
|
|
398
|
+
pointer.lastInteract = performance.now()
|
|
399
|
+
if (pointer.autoActive && !pointer.takeover) {
|
|
400
|
+
// Blend from the virtual pointer to the real one instead of jumping.
|
|
401
|
+
pointer.takeover = true
|
|
402
|
+
pointer.takeoverStart = performance.now()
|
|
403
|
+
pointer.fromX = pointer.x
|
|
404
|
+
pointer.fromY = pointer.y
|
|
405
|
+
pointer.toX = x
|
|
406
|
+
pointer.toY = y
|
|
407
|
+
pointer.autoActive = false
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
if (!pointer.seen) {
|
|
411
|
+
pointer.oldX = x
|
|
412
|
+
pointer.oldY = y
|
|
413
|
+
pointer.seen = true
|
|
414
|
+
}
|
|
415
|
+
pointer.x = x
|
|
416
|
+
pointer.y = y
|
|
417
|
+
}
|
|
418
|
+
const onPointerLeave = (): void => { pointer.seen = false }
|
|
419
|
+
const updateAuto = (now: number): void => {
|
|
420
|
+
if (reducedMotion) return
|
|
421
|
+
if (now - pointer.lastInteract < AUTO_RESUME_DELAY) {
|
|
422
|
+
if (auto.active) stopAuto()
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
if (!auto.active) {
|
|
426
|
+
auto.active = true
|
|
427
|
+
auto.x = pointer.x
|
|
428
|
+
auto.y = pointer.y
|
|
429
|
+
auto.last = now
|
|
430
|
+
auto.started = now
|
|
431
|
+
pickTarget()
|
|
432
|
+
}
|
|
433
|
+
pointer.autoActive = true
|
|
434
|
+
let dtSec = (now - auto.last) / 1000
|
|
435
|
+
auto.last = now
|
|
436
|
+
if (dtSec > 0.2) dtSec = 0.016
|
|
437
|
+
const dx = auto.targetX - auto.x
|
|
438
|
+
const dy = auto.targetY - auto.y
|
|
439
|
+
const dist = Math.hypot(dx, dy)
|
|
440
|
+
if (dist < 0.01) {
|
|
441
|
+
pickTarget()
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
const t = Math.min(1, (now - auto.started) / AUTO_RAMP)
|
|
445
|
+
const ramp = t * t * (3 - 2 * t)
|
|
446
|
+
const step = Math.min(AUTO_SPEED * dtSec * ramp, dist)
|
|
447
|
+
auto.x += (dx / dist) * step
|
|
448
|
+
auto.y += (dy / dist) * step
|
|
449
|
+
pointer.x = auto.x
|
|
450
|
+
pointer.y = auto.y
|
|
451
|
+
}
|
|
452
|
+
const updatePointer = (now: number): void => {
|
|
453
|
+
if (pointer.takeover) {
|
|
454
|
+
const t = (now - pointer.takeoverStart) / TAKEOVER_DURATION
|
|
455
|
+
if (t >= 1) {
|
|
456
|
+
pointer.takeover = false
|
|
457
|
+
pointer.x = pointer.toX
|
|
458
|
+
pointer.y = pointer.toY
|
|
459
|
+
pointer.oldX = pointer.x
|
|
460
|
+
pointer.oldY = pointer.y
|
|
461
|
+
pointer.diffX = 0
|
|
462
|
+
pointer.diffY = 0
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
const k = t * t * (3 - 2 * t)
|
|
466
|
+
pointer.x = pointer.fromX + (pointer.toX - pointer.fromX) * k
|
|
467
|
+
pointer.y = pointer.fromY + (pointer.toY - pointer.fromY) * k
|
|
468
|
+
}
|
|
469
|
+
pointer.diffX = pointer.x - pointer.oldX
|
|
470
|
+
pointer.diffY = pointer.y - pointer.oldY
|
|
471
|
+
pointer.oldX = pointer.x
|
|
472
|
+
pointer.oldY = pointer.y
|
|
473
|
+
if (pointer.autoActive && !pointer.takeover) {
|
|
474
|
+
pointer.diffX *= AUTO_INTENSITY
|
|
475
|
+
pointer.diffY *= AUTO_INTENSITY
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const bindTarget = (target: SimTexture | null): void => {
|
|
480
|
+
if (target === null) {
|
|
481
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
|
|
482
|
+
gl.viewport(0, 0, canvas.width, canvas.height)
|
|
483
|
+
} else {
|
|
484
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo)
|
|
485
|
+
gl.viewport(0, 0, target.width, target.height)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const bindTexture = (unit: number, texture: WebGLTexture): void => {
|
|
489
|
+
gl.activeTexture(gl.TEXTURE0 + unit)
|
|
490
|
+
gl.bindTexture(gl.TEXTURE_2D, texture)
|
|
491
|
+
}
|
|
492
|
+
const drawQuad = (): void => { gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4) }
|
|
493
|
+
const step = (now: number): void => {
|
|
494
|
+
if (velocities === null || divergenceTex === null || pressures === null) return
|
|
495
|
+
updateAuto(now)
|
|
496
|
+
updatePointer(now)
|
|
497
|
+
const velocity = velocities[0]
|
|
498
|
+
const pxX = 1 / velocity.width
|
|
499
|
+
const pxY = 1 / velocity.height
|
|
500
|
+
const scaleX = 1 - pxX * 2
|
|
501
|
+
const scaleY = 1 - pxY * 2
|
|
502
|
+
// 1. advect the velocity field (velocity[0] -> velocity[1])
|
|
503
|
+
bindTarget(velocities[1])
|
|
504
|
+
gl.useProgram(advectionPass.program)
|
|
505
|
+
gl.uniform2f(advectionPass.uniforms.uScale, scaleX, scaleY)
|
|
506
|
+
bindTexture(0, velocity.texture)
|
|
507
|
+
gl.uniform1i(advectionPass.uniforms.uVelocity, 0)
|
|
508
|
+
gl.uniform1f(advectionPass.uniforms.uDt, DT)
|
|
509
|
+
gl.uniform2f(advectionPass.uniforms.uFboSize, velocity.width, velocity.height)
|
|
510
|
+
drawQuad()
|
|
511
|
+
// 2. inject the pointer force (additive splat into velocity[1])
|
|
512
|
+
if (Math.abs(pointer.diffX) > 1e-5 || Math.abs(pointer.diffY) > 1e-5) {
|
|
513
|
+
const radiusU = CURSOR_SIZE * pxX * 0.5
|
|
514
|
+
const radiusV = CURSOR_SIZE * pxY * 0.5
|
|
515
|
+
const centerX = Math.min(Math.max((pointer.x + 1) / 2, radiusU + pxX), 1 - radiusU - pxX)
|
|
516
|
+
const centerY = Math.min(Math.max((pointer.y + 1) / 2, radiusV + pxY), 1 - radiusV - pxY)
|
|
517
|
+
gl.enable(gl.BLEND)
|
|
518
|
+
gl.blendFunc(gl.ONE, gl.ONE)
|
|
519
|
+
gl.useProgram(splatPass.program)
|
|
520
|
+
gl.uniform2f(splatPass.uniforms.uScale, 1, 1)
|
|
521
|
+
gl.uniform2f(splatPass.uniforms.uCenter, centerX, centerY)
|
|
522
|
+
gl.uniform2f(splatPass.uniforms.uRadius, radiusU, radiusV)
|
|
523
|
+
gl.uniform2f(splatPass.uniforms.uForce, (pointer.diffX / 2) * MOUSE_FORCE, (pointer.diffY / 2) * MOUSE_FORCE)
|
|
524
|
+
drawQuad()
|
|
525
|
+
gl.disable(gl.BLEND)
|
|
526
|
+
}
|
|
527
|
+
// 3. divergence of the advected field
|
|
528
|
+
bindTarget(divergenceTex)
|
|
529
|
+
gl.useProgram(divergencePass.program)
|
|
530
|
+
gl.uniform2f(divergencePass.uniforms.uScale, scaleX, scaleY)
|
|
531
|
+
bindTexture(0, velocities[1].texture)
|
|
532
|
+
gl.uniform1i(divergencePass.uniforms.uVelocity, 0)
|
|
533
|
+
gl.uniform1f(divergencePass.uniforms.uDt, DT)
|
|
534
|
+
gl.uniform2f(divergencePass.uniforms.uPx, pxX, pxY)
|
|
535
|
+
drawQuad()
|
|
536
|
+
// 4. solve pressure with Jacobi iterations (warm start from last frame)
|
|
537
|
+
gl.useProgram(poissonPass.program)
|
|
538
|
+
gl.uniform2f(poissonPass.uniforms.uScale, scaleX, scaleY)
|
|
539
|
+
gl.uniform2f(poissonPass.uniforms.uPx, pxX, pxY)
|
|
540
|
+
bindTexture(1, divergenceTex.texture)
|
|
541
|
+
gl.uniform1i(poissonPass.uniforms.uDivergence, 1)
|
|
542
|
+
let pressureOut = pressures[0]
|
|
543
|
+
for (let iteration = 0; iteration < POISSON_ITERATIONS; iteration++) {
|
|
544
|
+
const source = pressures[iteration % 2]
|
|
545
|
+
pressureOut = pressures[(iteration + 1) % 2]
|
|
546
|
+
bindTarget(pressureOut)
|
|
547
|
+
bindTexture(0, source.texture)
|
|
548
|
+
gl.uniform1i(poissonPass.uniforms.uPressure, 0)
|
|
549
|
+
drawQuad()
|
|
550
|
+
}
|
|
551
|
+
// 5. project: subtract the pressure gradient (velocity[1] -> velocity[0])
|
|
552
|
+
bindTarget(velocities[0])
|
|
553
|
+
gl.useProgram(pressurePass.program)
|
|
554
|
+
gl.uniform2f(pressurePass.uniforms.uScale, scaleX, scaleY)
|
|
555
|
+
bindTexture(0, pressureOut.texture)
|
|
556
|
+
gl.uniform1i(pressurePass.uniforms.uPressure, 0)
|
|
557
|
+
bindTexture(1, velocities[1].texture)
|
|
558
|
+
gl.uniform1i(pressurePass.uniforms.uVelocity, 1)
|
|
559
|
+
gl.uniform1f(pressurePass.uniforms.uDt, DT)
|
|
560
|
+
gl.uniform2f(pressurePass.uniforms.uPx, pxX, pxY)
|
|
561
|
+
drawQuad()
|
|
562
|
+
// 6. display: map speed through the palette
|
|
563
|
+
bindTarget(null)
|
|
564
|
+
gl.clearColor(0, 0, 0, 0)
|
|
565
|
+
gl.clear(gl.COLOR_BUFFER_BIT)
|
|
566
|
+
gl.useProgram(displayPass.program)
|
|
567
|
+
gl.uniform2f(displayPass.uniforms.uScale, 1, 1)
|
|
568
|
+
bindTexture(0, velocity.texture)
|
|
569
|
+
gl.uniform1i(displayPass.uniforms.uVelocity, 0)
|
|
570
|
+
bindTexture(1, paletteTexture)
|
|
571
|
+
gl.uniform1i(displayPass.uniforms.uPalette, 1)
|
|
572
|
+
drawQuad()
|
|
573
|
+
}
|
|
574
|
+
let frame = window.requestAnimationFrame(function tick() {
|
|
575
|
+
step(performance.now())
|
|
576
|
+
frame = window.requestAnimationFrame(tick)
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
resizeCanvas()
|
|
580
|
+
let resizeFrame = 0
|
|
581
|
+
const observer = new ResizeObserver(() => {
|
|
582
|
+
if (resizeFrame !== 0) window.cancelAnimationFrame(resizeFrame)
|
|
583
|
+
resizeFrame = window.requestAnimationFrame(() => {
|
|
584
|
+
resizeFrame = 0
|
|
585
|
+
resizeCanvas()
|
|
586
|
+
})
|
|
587
|
+
})
|
|
588
|
+
observer.observe(viewport)
|
|
589
|
+
viewport.addEventListener('pointermove', onPointerMove, true)
|
|
590
|
+
viewport.addEventListener('pointerleave', onPointerLeave)
|
|
591
|
+
return () => {
|
|
592
|
+
window.cancelAnimationFrame(frame)
|
|
593
|
+
if (resizeFrame !== 0) window.cancelAnimationFrame(resizeFrame)
|
|
594
|
+
observer.disconnect()
|
|
595
|
+
viewport.removeEventListener('pointermove', onPointerMove)
|
|
596
|
+
viewport.removeEventListener('pointerleave', onPointerLeave)
|
|
597
|
+
for (const texture of [...(velocities ?? []), ...(divergenceTex !== null ? [divergenceTex] : []), ...(pressures ?? [])]) destroySimTexture(texture)
|
|
598
|
+
gl.deleteTexture(paletteTexture)
|
|
599
|
+
gl.deleteBuffer(quadBuffer)
|
|
600
|
+
for (const pass of [advectionPass, splatPass, divergencePass, poissonPass, pressurePass, displayPass]) gl.deleteProgram(pass.program)
|
|
601
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
|
|
602
|
+
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
|
603
|
+
}
|
|
604
|
+
}, [])
|
|
605
|
+
return <canvas ref={canvasRef} className={css.liquidCanvas} aria-hidden="true" />
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
type ShaderFrameState = {
|
|
609
|
+
gl: WebGLRenderingContext
|
|
610
|
+
program: WebGLProgram
|
|
611
|
+
uniforms: Record<string, WebGLUniformLocation>
|
|
612
|
+
time: number
|
|
613
|
+
width: number
|
|
614
|
+
height: number
|
|
615
|
+
dpr: number
|
|
616
|
+
/** True when the surface underneath the grid layer is light, so shaders
|
|
617
|
+
* should pick their reactbits lightMode (dark ink on white) branch. */
|
|
618
|
+
light: boolean
|
|
619
|
+
/** Smoothed pointer in CSS pixels (origin top-left); `active` eases to 0
|
|
620
|
+
* when the pointer leaves so effects can fade out instead of jumping. */
|
|
621
|
+
pointer: { x: number; y: number; active: number }
|
|
622
|
+
draw: () => void
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const SHADER_QUAD_VERTEX = `
|
|
626
|
+
precision highp float;
|
|
627
|
+
attribute vec3 position;
|
|
628
|
+
varying vec2 vUv;
|
|
629
|
+
void main() {
|
|
630
|
+
vUv = position.xy * 0.5 + 0.5;
|
|
631
|
+
gl_Position = vec4(position, 1.0);
|
|
632
|
+
}
|
|
633
|
+
`
|
|
634
|
+
|
|
635
|
+
/** Walk up from the grid layer to the first opaque surface and decide
|
|
636
|
+
* light vs dark, so the shader backdrops can pick their lightMode ink. */
|
|
637
|
+
function detectLightSurface(element: HTMLElement): boolean {
|
|
638
|
+
let node: HTMLElement | null = element
|
|
639
|
+
while (node !== null) {
|
|
640
|
+
const color = getComputedStyle(node).backgroundColor
|
|
641
|
+
const match = color.match(/rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,]+([\d.]+))?\s*\)/)
|
|
642
|
+
if (match !== null) {
|
|
643
|
+
const alpha = match[4] !== undefined ? Number(match[4]) : 1
|
|
644
|
+
if (alpha >= 0.5) {
|
|
645
|
+
const luminance = (0.2126 * Number(match[1]) + 0.7152 * Number(match[2]) + 0.0722 * Number(match[3])) / 255
|
|
646
|
+
return luminance > 0.55
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
node = node.parentElement
|
|
650
|
+
}
|
|
651
|
+
return false
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Shared engine for the fullscreen-shader backdrops: compiles one program,
|
|
655
|
+
* owns the DPR-sized canvas, the rAF loop, viewport resize and smoothed
|
|
656
|
+
* pointer tracking. `frame` sets uniforms and calls draw(). WebGL2 is
|
|
657
|
+
* preferred (floating lines needs dynamic-ish GLSL); returns the detected
|
|
658
|
+
* theme plus a dispose function, or null when WebGL is unavailable. */
|
|
659
|
+
function startShaderCanvas(
|
|
660
|
+
canvas: HTMLCanvasElement,
|
|
661
|
+
viewport: HTMLElement,
|
|
662
|
+
fragmentSource: string,
|
|
663
|
+
frame: (state: ShaderFrameState) => void,
|
|
664
|
+
): { light: boolean, dispose: () => void } | null {
|
|
665
|
+
const contextAttributes: WebGLContextAttributes = { alpha: true, antialias: false, depth: false, stencil: false }
|
|
666
|
+
const gl = canvas.getContext('webgl2', contextAttributes) ?? canvas.getContext('webgl', contextAttributes)
|
|
667
|
+
if (gl === null) return null
|
|
668
|
+
try {
|
|
669
|
+
const compile = (type: number, source: string): WebGLShader => {
|
|
670
|
+
const shader = gl.createShader(type)
|
|
671
|
+
if (shader === null) throw new Error('webgl: shader alloc failed')
|
|
672
|
+
gl.shaderSource(shader, source)
|
|
673
|
+
gl.compileShader(shader)
|
|
674
|
+
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS) !== true) {
|
|
675
|
+
throw new Error(gl.getShaderInfoLog(shader) ?? 'webgl: shader compile failed')
|
|
676
|
+
}
|
|
677
|
+
return shader
|
|
678
|
+
}
|
|
679
|
+
const program = gl.createProgram()
|
|
680
|
+
if (program === null) return null
|
|
681
|
+
gl.attachShader(program, compile(gl.VERTEX_SHADER, SHADER_QUAD_VERTEX))
|
|
682
|
+
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fragmentSource))
|
|
683
|
+
gl.bindAttribLocation(program, 0, 'position')
|
|
684
|
+
gl.linkProgram(program)
|
|
685
|
+
if (gl.getProgramParameter(program, gl.LINK_STATUS) !== true) {
|
|
686
|
+
throw new Error(gl.getProgramInfoLog(program) ?? 'webgl: program link failed')
|
|
687
|
+
}
|
|
688
|
+
// Detached + deleted right after linking, so deleting the program frees everything.
|
|
689
|
+
for (const shader of gl.getAttachedShaders(program) ?? []) {
|
|
690
|
+
gl.detachShader(program, shader)
|
|
691
|
+
gl.deleteShader(shader)
|
|
692
|
+
}
|
|
693
|
+
const uniforms: Record<string, WebGLUniformLocation> = {}
|
|
694
|
+
const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS) as number
|
|
695
|
+
for (let index = 0; index < count; index++) {
|
|
696
|
+
const info = gl.getActiveUniform(program, index)
|
|
697
|
+
const location = info !== null ? gl.getUniformLocation(program, info.name) : null
|
|
698
|
+
if (info !== null && location !== null) uniforms[info.name] = location
|
|
699
|
+
}
|
|
700
|
+
const quad = gl.createBuffer()
|
|
701
|
+
if (quad === null) return null
|
|
702
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, quad)
|
|
703
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 0, 1, -1, 0, -1, 1, 0, 1, 1, 0]), gl.STATIC_DRAW)
|
|
704
|
+
gl.enableVertexAttribArray(0)
|
|
705
|
+
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0)
|
|
706
|
+
|
|
707
|
+
const state: ShaderFrameState = {
|
|
708
|
+
gl, program, uniforms,
|
|
709
|
+
time: 0, width: 1, height: 1, dpr: 1,
|
|
710
|
+
light: detectLightSurface(viewport),
|
|
711
|
+
pointer: { x: 0, y: 0, active: 0 },
|
|
712
|
+
draw: () => { gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4) },
|
|
713
|
+
}
|
|
714
|
+
const pointerTarget = { x: 0, y: 0, active: 0 }
|
|
715
|
+
const resize = (): void => {
|
|
716
|
+
const rect = viewport.getBoundingClientRect()
|
|
717
|
+
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio ?? 1))
|
|
718
|
+
canvas.width = Math.max(1, Math.round(rect.width * dpr))
|
|
719
|
+
canvas.height = Math.max(1, Math.round(rect.height * dpr))
|
|
720
|
+
// Resizing the drawing buffer does NOT update the GL viewport — without
|
|
721
|
+
// this the whole shader renders into the default 300x150 corner.
|
|
722
|
+
gl.viewport(0, 0, canvas.width, canvas.height)
|
|
723
|
+
state.width = canvas.width
|
|
724
|
+
state.height = canvas.height
|
|
725
|
+
state.dpr = dpr
|
|
726
|
+
pointerTarget.x = rect.width / 2
|
|
727
|
+
pointerTarget.y = rect.height / 2
|
|
728
|
+
}
|
|
729
|
+
const onPointerMove = (event: PointerEvent): void => {
|
|
730
|
+
const rect = viewport.getBoundingClientRect()
|
|
731
|
+
pointerTarget.x = event.clientX - rect.left
|
|
732
|
+
pointerTarget.y = event.clientY - rect.top
|
|
733
|
+
pointerTarget.active = 1
|
|
734
|
+
}
|
|
735
|
+
const onPointerLeave = (): void => { pointerTarget.active = 0 }
|
|
736
|
+
const disposeGl = (): void => {
|
|
737
|
+
gl.deleteBuffer(quad)
|
|
738
|
+
gl.deleteProgram(program)
|
|
739
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
|
|
740
|
+
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
resize()
|
|
744
|
+
if (globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true) {
|
|
745
|
+
// Static wallpaper: render exactly one frame, no loop and no input. The
|
|
746
|
+
// GL objects stay alive so the presented frame survives on the canvas.
|
|
747
|
+
frame(state)
|
|
748
|
+
gl.deleteBuffer(quad)
|
|
749
|
+
gl.deleteProgram(program)
|
|
750
|
+
return null
|
|
751
|
+
}
|
|
752
|
+
const start = performance.now()
|
|
753
|
+
let frameHandle = 0
|
|
754
|
+
const tick = (): void => {
|
|
755
|
+
state.time = (performance.now() - start) / 1000
|
|
756
|
+
state.pointer.x += (pointerTarget.x - state.pointer.x) * 0.05
|
|
757
|
+
state.pointer.y += (pointerTarget.y - state.pointer.y) * 0.05
|
|
758
|
+
state.pointer.active += (pointerTarget.active - state.pointer.active) * 0.05
|
|
759
|
+
frame(state)
|
|
760
|
+
frameHandle = window.requestAnimationFrame(tick)
|
|
761
|
+
}
|
|
762
|
+
frameHandle = window.requestAnimationFrame(tick)
|
|
763
|
+
const observer = new ResizeObserver(resize)
|
|
764
|
+
observer.observe(viewport)
|
|
765
|
+
viewport.addEventListener('pointermove', onPointerMove, true)
|
|
766
|
+
viewport.addEventListener('pointerleave', onPointerLeave)
|
|
767
|
+
return {
|
|
768
|
+
light: state.light,
|
|
769
|
+
dispose: () => {
|
|
770
|
+
window.cancelAnimationFrame(frameHandle)
|
|
771
|
+
observer.disconnect()
|
|
772
|
+
viewport.removeEventListener('pointermove', onPointerMove)
|
|
773
|
+
viewport.removeEventListener('pointerleave', onPointerLeave)
|
|
774
|
+
disposeGl()
|
|
775
|
+
},
|
|
776
|
+
}
|
|
777
|
+
} catch {
|
|
778
|
+
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
|
779
|
+
return null
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** reactbits.dev "FloatingLines": three fields of glowing sine waves drawn by
|
|
784
|
+
* a single fragment shader and screen-blended over the canvas surface; the
|
|
785
|
+
* pointer bends nearby waves and shifts a slight parallax. */
|
|
786
|
+
export function FloatingLinesBackground(): React.JSX.Element {
|
|
787
|
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
788
|
+
useEffect(() => {
|
|
789
|
+
const canvas = canvasRef.current
|
|
790
|
+
const layer = canvas?.parentElement
|
|
791
|
+
const viewport = layer?.parentElement
|
|
792
|
+
if (canvas === null || canvas === undefined || viewport === null || viewport === undefined) return
|
|
793
|
+
const fragment = `
|
|
794
|
+
precision highp float;
|
|
795
|
+
uniform float iTime;
|
|
796
|
+
uniform vec3 iResolution;
|
|
797
|
+
uniform vec2 iMouse;
|
|
798
|
+
uniform float bendInfluence;
|
|
799
|
+
uniform vec2 parallaxOffset;
|
|
800
|
+
uniform float uLightMode;
|
|
801
|
+
const vec3 BLACK = vec3(0.0);
|
|
802
|
+
const vec3 PINK = vec3(233.0, 71.0, 245.0) / 255.0;
|
|
803
|
+
const vec3 BLUE = vec3(47.0, 75.0, 162.0) / 255.0;
|
|
804
|
+
// reactbits defaults: three fields of six lines, offsets baked below.
|
|
805
|
+
const vec3 TOP_POS = vec3(10.0, 0.5, -0.4);
|
|
806
|
+
const vec3 MIDDLE_POS = vec3(5.0, 0.0, 0.2);
|
|
807
|
+
const vec3 BOTTOM_POS = vec3(2.0, -0.7, 0.4);
|
|
808
|
+
const float LINE_DISTANCE = 0.05;
|
|
809
|
+
const float BEND_RADIUS = 5.0;
|
|
810
|
+
const float BEND_STRENGTH = -0.5;
|
|
811
|
+
mat2 rotate2(float r) { return mat2(cos(r), sin(r), -sin(r), cos(r)); }
|
|
812
|
+
vec3 backgroundColor(vec2 uv) {
|
|
813
|
+
vec3 col = vec3(0.0);
|
|
814
|
+
float y = sin(uv.x - 0.2) * 0.3 - 0.1;
|
|
815
|
+
float m = uv.y - y;
|
|
816
|
+
col += mix(BLUE, BLACK, smoothstep(0.0, 1.0, abs(m)));
|
|
817
|
+
col += mix(PINK, BLACK, smoothstep(0.0, 1.0, abs(m - 0.8)));
|
|
818
|
+
return col * 0.5;
|
|
819
|
+
}
|
|
820
|
+
float wave(vec2 uv, float offset, vec2 screenUv, vec2 mouseUv) {
|
|
821
|
+
float xMovement = iTime * 0.1;
|
|
822
|
+
float amp = sin(offset + iTime * 0.2) * 0.3;
|
|
823
|
+
float y = sin(uv.x + offset + xMovement) * amp;
|
|
824
|
+
vec2 d = screenUv - mouseUv;
|
|
825
|
+
float influence = exp(-dot(d, d) * BEND_RADIUS);
|
|
826
|
+
y += (mouseUv.y - screenUv.y) * influence * BEND_STRENGTH * bendInfluence;
|
|
827
|
+
float m = uv.y - y;
|
|
828
|
+
return 0.0175 / max(abs(m) + 0.01, 1e-3) + 0.01;
|
|
829
|
+
}
|
|
830
|
+
void main() {
|
|
831
|
+
vec2 baseUv = (2.0 * gl_FragCoord.xy - iResolution.xy) / iResolution.y;
|
|
832
|
+
baseUv.y *= -1.0;
|
|
833
|
+
baseUv += parallaxOffset;
|
|
834
|
+
vec3 col = vec3(0.0);
|
|
835
|
+
vec3 lineCol = backgroundColor(baseUv);
|
|
836
|
+
vec2 mouseUv = (2.0 * iMouse - iResolution.xy) / iResolution.y;
|
|
837
|
+
mouseUv.y *= -1.0;
|
|
838
|
+
for (int i = 0; i < 6; ++i) {
|
|
839
|
+
float fi = float(i);
|
|
840
|
+
vec2 ruv = baseUv * rotate2(BOTTOM_POS.z * log(length(baseUv) + 1.0));
|
|
841
|
+
col += lineCol * wave(ruv + vec2(LINE_DISTANCE * fi + BOTTOM_POS.x, BOTTOM_POS.y), 1.5 + 0.2 * fi, baseUv, mouseUv) * 0.2;
|
|
842
|
+
}
|
|
843
|
+
for (int i = 0; i < 6; ++i) {
|
|
844
|
+
float fi = float(i);
|
|
845
|
+
vec2 ruv = baseUv * rotate2(MIDDLE_POS.z * log(length(baseUv) + 1.0));
|
|
846
|
+
col += lineCol * wave(ruv + vec2(LINE_DISTANCE * fi + MIDDLE_POS.x, MIDDLE_POS.y), 2.0 + 0.15 * fi, baseUv, mouseUv);
|
|
847
|
+
}
|
|
848
|
+
for (int i = 0; i < 6; ++i) {
|
|
849
|
+
float fi = float(i);
|
|
850
|
+
vec2 ruv = baseUv * rotate2(TOP_POS.z * log(length(baseUv) + 1.0));
|
|
851
|
+
ruv.x *= -1.0;
|
|
852
|
+
col += lineCol * wave(ruv + vec2(LINE_DISTANCE * fi + TOP_POS.x, TOP_POS.y), 1.0 + 0.2 * fi, baseUv, mouseUv) * 0.1;
|
|
853
|
+
}
|
|
854
|
+
if (uLightMode > 0.5) {
|
|
855
|
+
vec3 energy = max(col, vec3(0.0));
|
|
856
|
+
float peak = max(energy.r, max(energy.g, energy.b));
|
|
857
|
+
float coverage = smoothstep(0.018, 0.5, peak);
|
|
858
|
+
vec3 chroma = clamp(energy / max(peak, 0.0001), 0.0, 1.0);
|
|
859
|
+
chroma = pow(chroma, vec3(1.35));
|
|
860
|
+
float chromaPeak = max(chroma.r, max(chroma.g, chroma.b));
|
|
861
|
+
chroma /= max(chromaPeak, 0.0001);
|
|
862
|
+
vec3 ink = mix(chroma, clamp(chroma * 0.82, 0.0, 1.0), smoothstep(0.5, 1.0, coverage));
|
|
863
|
+
gl_FragColor = vec4(mix(vec3(1.0), ink, coverage * 0.94), 1.0);
|
|
864
|
+
} else {
|
|
865
|
+
gl_FragColor = vec4(col, 1.0);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
`
|
|
869
|
+
const handle = startShaderCanvas(canvas, viewport, fragment, state => {
|
|
870
|
+
const { gl, uniforms, time, width, height, dpr, pointer } = state
|
|
871
|
+
gl.useProgram(state.program)
|
|
872
|
+
gl.uniform1f(uniforms.iTime, time)
|
|
873
|
+
gl.uniform3f(uniforms.iResolution, width, height, 1)
|
|
874
|
+
// reactbits feeds the pointer in backing-store pixels, y-up.
|
|
875
|
+
gl.uniform2f(uniforms.iMouse, pointer.x * dpr, (height / dpr - pointer.y) * dpr)
|
|
876
|
+
gl.uniform1f(uniforms.bendInfluence, pointer.active)
|
|
877
|
+
gl.uniform2f(
|
|
878
|
+
uniforms.parallaxOffset,
|
|
879
|
+
(pointer.x / Math.max(1, width / dpr) - 0.5) * 0.2,
|
|
880
|
+
-(pointer.y / Math.max(1, height / dpr) - 0.5) * 0.2,
|
|
881
|
+
)
|
|
882
|
+
gl.uniform1f(uniforms.uLightMode, state.light ? 1 : 0)
|
|
883
|
+
state.draw()
|
|
884
|
+
})
|
|
885
|
+
if (handle === null) return
|
|
886
|
+
// The glowing ink only reads correctly over a dark surface; the lightMode
|
|
887
|
+
// branch paints its own light backdrop, so blending must be off there.
|
|
888
|
+
canvas.style.mixBlendMode = handle.light ? 'normal' : 'screen'
|
|
889
|
+
return () => { handle.dispose() }
|
|
890
|
+
}, [])
|
|
891
|
+
return <canvas ref={canvasRef} className={css.sceneCanvas} aria-hidden="true" />
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** reactbits.dev "Galaxy": four parallax layers of twinkling stars flying
|
|
895
|
+
* outward, pushed away from the pointer (single transparent fragment shader). */
|
|
896
|
+
export function GalaxyBackground(): React.JSX.Element {
|
|
897
|
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
898
|
+
useEffect(() => {
|
|
899
|
+
const canvas = canvasRef.current
|
|
900
|
+
const layer = canvas?.parentElement
|
|
901
|
+
const viewport = layer?.parentElement
|
|
902
|
+
if (canvas === null || canvas === undefined || viewport === null || viewport === undefined) return
|
|
903
|
+
const fragment = `
|
|
904
|
+
precision highp float;
|
|
905
|
+
uniform float uTime;
|
|
906
|
+
uniform vec3 uResolution;
|
|
907
|
+
uniform float uStarSpeed;
|
|
908
|
+
uniform vec2 uMouse;
|
|
909
|
+
uniform float uMouseActiveFactor;
|
|
910
|
+
uniform float uLightMode;
|
|
911
|
+
varying vec2 vUv;
|
|
912
|
+
const float NUM_LAYER = 4.0;
|
|
913
|
+
const float STAR_COLOR_CUTOFF = 0.2;
|
|
914
|
+
const mat2 MAT45 = mat2(0.7071, -0.7071, 0.7071, 0.7071);
|
|
915
|
+
const float PERIOD = 3.0;
|
|
916
|
+
// reactbits Galaxy defaults, baked.
|
|
917
|
+
const vec2 FOCAL = vec2(0.5, 0.5);
|
|
918
|
+
const vec2 ROTATION = vec2(1.0, 0.0);
|
|
919
|
+
const float DENSITY = 1.0;
|
|
920
|
+
const float HUE_SHIFT = 140.0;
|
|
921
|
+
const float SPEED = 1.0;
|
|
922
|
+
const float GLOW = 0.3;
|
|
923
|
+
const float SATURATION = 0.0;
|
|
924
|
+
const float TWINKLE = 0.3;
|
|
925
|
+
const float ROTATION_SPEED = 0.1;
|
|
926
|
+
const float REPULSION = 2.0;
|
|
927
|
+
float Hash21(vec2 p) {
|
|
928
|
+
p = fract(p * vec2(123.34, 456.21));
|
|
929
|
+
p += dot(p, p + 45.32);
|
|
930
|
+
return fract(p.x * p.y);
|
|
931
|
+
}
|
|
932
|
+
float tri(float x) { return abs(fract(x) * 2.0 - 1.0); }
|
|
933
|
+
float tris(float x) {
|
|
934
|
+
float t = fract(x);
|
|
935
|
+
return 1.0 - smoothstep(0.0, 1.0, abs(2.0 * t - 1.0));
|
|
936
|
+
}
|
|
937
|
+
float trisn(float x) {
|
|
938
|
+
float t = fract(x);
|
|
939
|
+
return 2.0 * (1.0 - smoothstep(0.0, 1.0, abs(2.0 * t - 1.0))) - 1.0;
|
|
940
|
+
}
|
|
941
|
+
vec3 hsv2rgb(vec3 c) {
|
|
942
|
+
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
|
|
943
|
+
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
|
|
944
|
+
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
|
|
945
|
+
}
|
|
946
|
+
float Star(vec2 uv, float flare) {
|
|
947
|
+
float d = length(uv);
|
|
948
|
+
float m = (0.05 * GLOW) / d;
|
|
949
|
+
float rays = smoothstep(0.0, 1.0, 1.0 - abs(uv.x * uv.y * 1000.0));
|
|
950
|
+
m += rays * flare * GLOW;
|
|
951
|
+
uv *= MAT45;
|
|
952
|
+
rays = smoothstep(0.0, 1.0, 1.0 - abs(uv.x * uv.y * 1000.0));
|
|
953
|
+
m += rays * 0.3 * flare * GLOW;
|
|
954
|
+
m *= smoothstep(1.0, 0.2, d);
|
|
955
|
+
return m;
|
|
956
|
+
}
|
|
957
|
+
vec3 StarLayer(vec2 uv) {
|
|
958
|
+
vec3 col = vec3(0.0);
|
|
959
|
+
vec2 gv = fract(uv) - 0.5;
|
|
960
|
+
vec2 id = floor(uv);
|
|
961
|
+
for (int y = -1; y <= 1; y++) {
|
|
962
|
+
for (int x = -1; x <= 1; x++) {
|
|
963
|
+
vec2 si = id + vec2(float(x), float(y));
|
|
964
|
+
float seed = Hash21(si);
|
|
965
|
+
float size = fract(seed * 345.32);
|
|
966
|
+
float glossLocal = tri(uStarSpeed / (PERIOD * seed + 1.0));
|
|
967
|
+
float flareSize = smoothstep(0.9, 1.0, size) * glossLocal;
|
|
968
|
+
float red = smoothstep(STAR_COLOR_CUTOFF, 1.0, Hash21(si + 1.0)) + STAR_COLOR_CUTOFF;
|
|
969
|
+
float blu = smoothstep(STAR_COLOR_CUTOFF, 1.0, Hash21(si + 3.0)) + STAR_COLOR_CUTOFF;
|
|
970
|
+
float grn = min(red, blu) * seed;
|
|
971
|
+
vec3 base = vec3(red, grn, blu);
|
|
972
|
+
float hue = atan(base.g - base.r, base.b - base.r) / (2.0 * 3.14159) + 0.5;
|
|
973
|
+
hue = fract(hue + HUE_SHIFT / 360.0);
|
|
974
|
+
float sat = length(base - vec3(dot(base, vec3(0.299, 0.587, 0.114)))) * SATURATION;
|
|
975
|
+
float val = max(max(base.r, base.g), base.b);
|
|
976
|
+
base = hsv2rgb(vec3(hue, sat, val));
|
|
977
|
+
vec2 pad = vec2(tris(seed * 34.0 + uTime * SPEED / 10.0), tris(seed * 38.0 + uTime * SPEED / 30.0)) - 0.5;
|
|
978
|
+
float star = Star(gv - vec2(float(x), float(y)) - pad, flareSize);
|
|
979
|
+
float twinkle = trisn(uTime * SPEED + seed * 6.2831) * 0.5 + 1.0;
|
|
980
|
+
twinkle = mix(1.0, twinkle, TWINKLE);
|
|
981
|
+
star *= twinkle;
|
|
982
|
+
col += star * size * base;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return col;
|
|
986
|
+
}
|
|
987
|
+
void main() {
|
|
988
|
+
vec2 focalPx = FOCAL * uResolution.xy;
|
|
989
|
+
vec2 uv = (vUv * uResolution.xy - focalPx) / uResolution.y;
|
|
990
|
+
// mouse repulsion (reactbits mouseRepulsion default)
|
|
991
|
+
vec2 mousePosUV = (uMouse * uResolution.xy - focalPx) / uResolution.y;
|
|
992
|
+
float mouseDist = length(uv - mousePosUV);
|
|
993
|
+
vec2 repulsion = normalize(uv - mousePosUV) * (REPULSION / (mouseDist + 0.1));
|
|
994
|
+
uv += repulsion * 0.05 * uMouseActiveFactor;
|
|
995
|
+
float autoRotAngle = uTime * ROTATION_SPEED;
|
|
996
|
+
uv = mat2(cos(autoRotAngle), -sin(autoRotAngle), sin(autoRotAngle), cos(autoRotAngle)) * uv;
|
|
997
|
+
uv = mat2(ROTATION.x, -ROTATION.y, ROTATION.y, ROTATION.x) * uv;
|
|
998
|
+
vec3 col = vec3(0.0);
|
|
999
|
+
for (float i = 0.0; i < 1.0; i += 1.0 / NUM_LAYER) {
|
|
1000
|
+
float depth = fract(i + uStarSpeed * SPEED);
|
|
1001
|
+
float scale = mix(20.0 * DENSITY, 0.5 * DENSITY, depth);
|
|
1002
|
+
float fade = depth * smoothstep(1.0, 0.9, depth);
|
|
1003
|
+
col += StarLayer(uv * scale + i * 453.32) * fade;
|
|
1004
|
+
}
|
|
1005
|
+
// transparent path, premultiplied for the canvas compositor
|
|
1006
|
+
if (uLightMode > 0.5) {
|
|
1007
|
+
float energy = max(max(col.r, col.g), col.b);
|
|
1008
|
+
float coverage = clamp(smoothstep(0.0, 0.42, energy) * 0.92, 0.0, 0.92);
|
|
1009
|
+
vec3 ink = clamp(col * 0.48, 0.0, 0.82);
|
|
1010
|
+
gl_FragColor = vec4(mix(vec3(1.0), ink, coverage), 1.0);
|
|
1011
|
+
} else {
|
|
1012
|
+
float alpha = smoothstep(0.0, 0.3, length(col));
|
|
1013
|
+
gl_FragColor = vec4(col * alpha, alpha);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
`
|
|
1017
|
+
const handle = startShaderCanvas(canvas, viewport, fragment, state => {
|
|
1018
|
+
const { gl, uniforms, time, width, height, dpr, pointer } = state
|
|
1019
|
+
gl.useProgram(state.program)
|
|
1020
|
+
gl.uniform1f(uniforms.uTime, time)
|
|
1021
|
+
gl.uniform3f(uniforms.uResolution, width, height, width / Math.max(1, height))
|
|
1022
|
+
// reactbits: uStarSpeed = (elapsedSeconds * starSpeed) / 10 with starSpeed 0.5
|
|
1023
|
+
gl.uniform1f(uniforms.uStarSpeed, time * 0.05)
|
|
1024
|
+
gl.uniform2f(
|
|
1025
|
+
uniforms.uMouse,
|
|
1026
|
+
pointer.x / Math.max(1, width / dpr),
|
|
1027
|
+
1 - pointer.y / Math.max(1, height / dpr),
|
|
1028
|
+
)
|
|
1029
|
+
gl.uniform1f(uniforms.uMouseActiveFactor, pointer.active)
|
|
1030
|
+
gl.uniform1f(uniforms.uLightMode, state.light ? 1 : 0)
|
|
1031
|
+
state.draw()
|
|
1032
|
+
})
|
|
1033
|
+
if (handle === null) return
|
|
1034
|
+
return () => { handle.dispose() }
|
|
1035
|
+
}, [])
|
|
1036
|
+
return <canvas ref={canvasRef} className={css.sceneCanvas} aria-hidden="true" />
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/** reactbits.dev "Silk": slow flowing fabric sheen (single opaque fragment
|
|
1040
|
+
* shader in the reactbits default #7B7481 mauve). */
|
|
1041
|
+
export function SilkBackground(): React.JSX.Element {
|
|
1042
|
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
1043
|
+
useEffect(() => {
|
|
1044
|
+
const canvas = canvasRef.current
|
|
1045
|
+
const layer = canvas?.parentElement
|
|
1046
|
+
const viewport = layer?.parentElement
|
|
1047
|
+
if (canvas === null || canvas === undefined || viewport === null || viewport === undefined) return
|
|
1048
|
+
const fragment = `
|
|
1049
|
+
precision highp float;
|
|
1050
|
+
varying vec2 vUv;
|
|
1051
|
+
uniform float uTime;
|
|
1052
|
+
uniform float uLightMode;
|
|
1053
|
+
const vec3 COLOR = vec3(123.0, 116.0, 129.0) / 255.0; // reactbits default #7B7481
|
|
1054
|
+
const float e = 2.71828182845904523536;
|
|
1055
|
+
float noise(vec2 texCoord) {
|
|
1056
|
+
vec2 r = e * sin(e * texCoord);
|
|
1057
|
+
return fract(r.x * r.y * (1.0 + texCoord.x));
|
|
1058
|
+
}
|
|
1059
|
+
void main() {
|
|
1060
|
+
float rnd = noise(gl_FragCoord.xy);
|
|
1061
|
+
vec2 tex = vUv;
|
|
1062
|
+
float tOffset = 5.0 * uTime;
|
|
1063
|
+
tex.y += 0.03 * sin(8.0 * tex.x - tOffset);
|
|
1064
|
+
float pattern = 0.6 +
|
|
1065
|
+
0.4 * sin(5.0 * (tex.x + tex.y +
|
|
1066
|
+
cos(3.0 * tex.x + 5.0 * tex.y) +
|
|
1067
|
+
0.02 * tOffset) +
|
|
1068
|
+
sin(20.0 * (tex.x + tex.y - 0.1 * tOffset)));
|
|
1069
|
+
float grain = rnd / 15.0 * 1.5;
|
|
1070
|
+
vec3 result = COLOR * pattern - vec3(grain);
|
|
1071
|
+
if (uLightMode > 0.5) {
|
|
1072
|
+
float fold = smoothstep(0.28, 0.9, pattern);
|
|
1073
|
+
float specular = smoothstep(0.72, 0.98, pattern);
|
|
1074
|
+
vec3 lightBase = mix(COLOR * 0.72, min(COLOR * 1.18, vec3(1.0)), fold);
|
|
1075
|
+
lightBase = mix(lightBase, vec3(1.0), specular * 0.92);
|
|
1076
|
+
float fineNoise = noise(gl_FragCoord.xy * 0.63 + vec2(17.0, 41.0));
|
|
1077
|
+
result = lightBase + (rnd + fineNoise - 1.0) * clamp(1.5 * 0.038, 0.0, 0.16);
|
|
1078
|
+
}
|
|
1079
|
+
gl_FragColor = vec4(clamp(result, 0.0, 1.0), 1.0);
|
|
1080
|
+
}
|
|
1081
|
+
`
|
|
1082
|
+
const handle = startShaderCanvas(canvas, viewport, fragment, state => {
|
|
1083
|
+
const { gl, uniforms, time } = state
|
|
1084
|
+
gl.useProgram(state.program)
|
|
1085
|
+
// reactbits advances uTime by 0.1 per real second.
|
|
1086
|
+
gl.uniform1f(uniforms.uTime, time * 0.1)
|
|
1087
|
+
gl.uniform1f(uniforms.uLightMode, state.light ? 1 : 0)
|
|
1088
|
+
state.draw()
|
|
1089
|
+
})
|
|
1090
|
+
if (handle === null) return
|
|
1091
|
+
return () => { handle.dispose() }
|
|
1092
|
+
}, [])
|
|
1093
|
+
return <canvas ref={canvasRef} className={css.sceneCanvas} aria-hidden="true" />
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** reactbits.dev "Waves": vertical perlin-noise wave lines that spring away
|
|
1097
|
+
* from the pointer (canvas 2D port; strokes follow the theme label color). */
|
|
1098
|
+
export function WavesBackground(): React.JSX.Element {
|
|
1099
|
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
1100
|
+
useEffect(() => {
|
|
1101
|
+
const canvas = canvasRef.current
|
|
1102
|
+
const layer = canvas?.parentElement
|
|
1103
|
+
const viewport = layer?.parentElement
|
|
1104
|
+
if (canvas === null || canvas === undefined || viewport === null || viewport === undefined) return
|
|
1105
|
+
const ctx = canvas.getContext('2d')
|
|
1106
|
+
if (ctx === null) return
|
|
1107
|
+
|
|
1108
|
+
// Compact classic 2D Perlin (the reactbits Waves Noise class).
|
|
1109
|
+
const GRAD3 = [[1, 1], [-1, 1], [1, -1], [-1, -1], [1, 0], [-1, 0], [1, 0], [-1, 0], [0, 1], [0, -1], [0, 1], [0, -1]]
|
|
1110
|
+
const PERM_TABLE = [
|
|
1111
|
+
151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225, 140, 36, 103, 30, 69, 142, 8, 99, 37, 240,
|
|
1112
|
+
21, 10, 23, 190, 6, 148, 247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32, 57, 177, 33, 88,
|
|
1113
|
+
237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175, 74, 165, 71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83,
|
|
1114
|
+
111, 229, 122, 60, 211, 133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54, 65, 25, 63, 161, 1, 216,
|
|
1115
|
+
80, 73, 209, 76, 132, 187, 208, 89, 18, 169, 200, 196, 135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186,
|
|
1116
|
+
3, 64, 52, 217, 226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212, 207, 206, 59, 227, 47, 16, 58,
|
|
1117
|
+
17, 182, 189, 28, 42, 223, 183, 170, 213, 119, 248, 152, 2, 44, 154, 163, 70, 221, 153, 101, 155, 167, 43, 172, 9,
|
|
1118
|
+
129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104, 218, 246, 97, 228, 251, 34, 242, 193,
|
|
1119
|
+
238, 210, 144, 12, 191, 179, 162, 241, 81, 51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157,
|
|
1120
|
+
184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93, 222, 114, 67, 29, 24, 72, 243, 141, 128,
|
|
1121
|
+
195, 78, 66, 215, 61, 156, 180,
|
|
1122
|
+
]
|
|
1123
|
+
const perm = new Int32Array(512)
|
|
1124
|
+
const permShift = Math.floor(Math.random() * 256)
|
|
1125
|
+
for (let i = 0; i < 512; i++) perm[i] = PERM_TABLE[(i + permShift) & 255] as number
|
|
1126
|
+
const fade = (t: number): number => t * t * t * (t * (t * 6 - 15) + 10)
|
|
1127
|
+
const mixNum = (a: number, b: number, t: number): number => (1 - t) * a + t * b
|
|
1128
|
+
const gradDot = (hash: number, x: number, y: number): number => {
|
|
1129
|
+
const g = GRAD3[hash % 12] as number[]
|
|
1130
|
+
return g[0] * x + g[1] * y
|
|
1131
|
+
}
|
|
1132
|
+
const perlin2 = (x: number, y: number): number => {
|
|
1133
|
+
let xi = Math.floor(x)
|
|
1134
|
+
let yi = Math.floor(y)
|
|
1135
|
+
x -= xi
|
|
1136
|
+
y -= yi
|
|
1137
|
+
xi &= 255
|
|
1138
|
+
yi &= 255
|
|
1139
|
+
const n00 = gradDot(perm[xi + perm[yi]] as number, x, y)
|
|
1140
|
+
const n01 = gradDot(perm[xi + perm[yi + 1]] as number, x, y - 1)
|
|
1141
|
+
const n10 = gradDot(perm[xi + 1 + perm[yi]] as number, x - 1, y)
|
|
1142
|
+
const n11 = gradDot(perm[xi + 1 + perm[yi + 1]] as number, x - 1, y - 1)
|
|
1143
|
+
const u = fade(x)
|
|
1144
|
+
return mixNum(mixNum(n00, n10, u), mixNum(n01, n11, u), fade(y))
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
const WAVE = { speedX: 0.0125, speedY: 0.005, ampX: 32, ampY: 16, xGap: 10, yGap: 32, friction: 0.925, tension: 0.005, maxMove: 100 }
|
|
1148
|
+
type WavePoint = { x: number, y: number, wx: number, wy: number, cx: number, cy: number, vx: number, vy: number }
|
|
1149
|
+
let lines: WavePoint[][] = []
|
|
1150
|
+
let width = 0
|
|
1151
|
+
let height = 0
|
|
1152
|
+
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio ?? 1))
|
|
1153
|
+
const rebuild = (): void => {
|
|
1154
|
+
const rect = viewport.getBoundingClientRect()
|
|
1155
|
+
width = Math.max(1, Math.round(rect.width))
|
|
1156
|
+
height = Math.max(1, Math.round(rect.height))
|
|
1157
|
+
canvas.width = Math.round(width * dpr)
|
|
1158
|
+
canvas.height = Math.round(height * dpr)
|
|
1159
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
1160
|
+
lines = []
|
|
1161
|
+
const totalLines = Math.ceil((width + 200) / WAVE.xGap)
|
|
1162
|
+
const totalPoints = Math.ceil((height + 30) / WAVE.yGap)
|
|
1163
|
+
const xStart = (width - WAVE.xGap * totalLines) / 2
|
|
1164
|
+
const yStart = (height - WAVE.yGap * totalPoints) / 2
|
|
1165
|
+
for (let i = 0; i <= totalLines; i++) {
|
|
1166
|
+
const points: WavePoint[] = []
|
|
1167
|
+
for (let j = 0; j <= totalPoints; j++) {
|
|
1168
|
+
points.push({ x: xStart + WAVE.xGap * i, y: yStart + WAVE.yGap * j, wx: 0, wy: 0, cx: 0, cy: 0, vx: 0, vy: 0 })
|
|
1169
|
+
}
|
|
1170
|
+
lines.push(points)
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
// Stroke follows the theme label color at the dots pattern's 35% strength.
|
|
1174
|
+
const lineColor = getComputedStyle(viewport).getPropertyValue('--dsw-alias-label-primary').trim() || '#94a3b8'
|
|
1175
|
+
|
|
1176
|
+
const mouse = { x: -10, y: 0, sx: 0, sy: 0, lx: 0, ly: 0, vs: 0, angle: 0, set: false }
|
|
1177
|
+
const onPointerMove = (event: PointerEvent): void => {
|
|
1178
|
+
const rect = viewport.getBoundingClientRect()
|
|
1179
|
+
mouse.x = event.clientX - rect.left
|
|
1180
|
+
mouse.y = event.clientY - rect.top
|
|
1181
|
+
if (!mouse.set) {
|
|
1182
|
+
mouse.sx = mouse.x
|
|
1183
|
+
mouse.sy = mouse.y
|
|
1184
|
+
mouse.lx = mouse.x
|
|
1185
|
+
mouse.ly = mouse.y
|
|
1186
|
+
mouse.set = true
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
const movePoints = (time: number): void => {
|
|
1190
|
+
for (const points of lines) {
|
|
1191
|
+
for (const point of points) {
|
|
1192
|
+
const move = perlin2((point.x + time * WAVE.speedX) * 0.002, (point.y + time * WAVE.speedY) * 0.0015) * 12
|
|
1193
|
+
point.wx = Math.cos(move) * WAVE.ampX
|
|
1194
|
+
point.wy = Math.sin(move) * WAVE.ampY
|
|
1195
|
+
const dx = point.x - mouse.sx
|
|
1196
|
+
const dy = point.y - mouse.sy
|
|
1197
|
+
const dist = Math.hypot(dx, dy)
|
|
1198
|
+
const l = Math.max(175, mouse.vs)
|
|
1199
|
+
if (dist < l) {
|
|
1200
|
+
const s = 1 - dist / l
|
|
1201
|
+
const f = Math.cos(dist * 0.001) * s
|
|
1202
|
+
point.vx += Math.cos(mouse.angle) * f * l * mouse.vs * 0.00065
|
|
1203
|
+
point.vy += Math.sin(mouse.angle) * f * l * mouse.vs * 0.00065
|
|
1204
|
+
}
|
|
1205
|
+
point.vx += (0 - point.cx) * WAVE.tension
|
|
1206
|
+
point.vy += (0 - point.cy) * WAVE.tension
|
|
1207
|
+
point.vx *= WAVE.friction
|
|
1208
|
+
point.vy *= WAVE.friction
|
|
1209
|
+
point.cx += point.vx * 2
|
|
1210
|
+
point.cy += point.vy * 2
|
|
1211
|
+
point.cx = Math.min(WAVE.maxMove, Math.max(-WAVE.maxMove, point.cx))
|
|
1212
|
+
point.cy = Math.min(WAVE.maxMove, Math.max(-WAVE.maxMove, point.cy))
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
const draw = (): void => {
|
|
1217
|
+
ctx.clearRect(0, 0, width, height)
|
|
1218
|
+
ctx.beginPath()
|
|
1219
|
+
ctx.strokeStyle = lineColor
|
|
1220
|
+
ctx.lineWidth = 1
|
|
1221
|
+
ctx.globalAlpha = 0.35
|
|
1222
|
+
for (const points of lines) {
|
|
1223
|
+
const first = points[0]
|
|
1224
|
+
if (first === undefined) continue
|
|
1225
|
+
ctx.moveTo(first.x + first.wx, first.y + first.wy)
|
|
1226
|
+
for (let idx = 0; idx < points.length; idx++) {
|
|
1227
|
+
const point = points[idx] as WavePoint
|
|
1228
|
+
const isLast = idx === points.length - 1
|
|
1229
|
+
// The original drops the cursor offset on each line's last point.
|
|
1230
|
+
const cursor = isLast ? 0 : point.cx
|
|
1231
|
+
const cursorY = isLast ? 0 : point.cy
|
|
1232
|
+
ctx.lineTo(point.x + point.wx + cursor, point.y + point.wy + cursorY)
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
ctx.stroke()
|
|
1236
|
+
ctx.globalAlpha = 1
|
|
1237
|
+
}
|
|
1238
|
+
const tick = (time: number): void => {
|
|
1239
|
+
mouse.sx += (mouse.x - mouse.sx) * 0.1
|
|
1240
|
+
mouse.sy += (mouse.y - mouse.sy) * 0.1
|
|
1241
|
+
const dx = mouse.x - mouse.lx
|
|
1242
|
+
const dy = mouse.y - mouse.ly
|
|
1243
|
+
mouse.vs += (Math.hypot(dx, dy) - mouse.vs) * 0.1
|
|
1244
|
+
mouse.vs = Math.min(100, mouse.vs)
|
|
1245
|
+
mouse.lx = mouse.x
|
|
1246
|
+
mouse.ly = mouse.y
|
|
1247
|
+
mouse.angle = Math.atan2(dy, dx)
|
|
1248
|
+
movePoints(time)
|
|
1249
|
+
draw()
|
|
1250
|
+
frame = window.requestAnimationFrame(tick)
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
rebuild()
|
|
1254
|
+
let frame = 0
|
|
1255
|
+
if (globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true) {
|
|
1256
|
+
movePoints(0)
|
|
1257
|
+
draw()
|
|
1258
|
+
} else {
|
|
1259
|
+
frame = window.requestAnimationFrame(tick)
|
|
1260
|
+
viewport.addEventListener('pointermove', onPointerMove, true)
|
|
1261
|
+
}
|
|
1262
|
+
const observer = new ResizeObserver(rebuild)
|
|
1263
|
+
observer.observe(viewport)
|
|
1264
|
+
return () => {
|
|
1265
|
+
window.cancelAnimationFrame(frame)
|
|
1266
|
+
observer.disconnect()
|
|
1267
|
+
viewport.removeEventListener('pointermove', onPointerMove)
|
|
1268
|
+
}
|
|
1269
|
+
}, [])
|
|
1270
|
+
return <canvas ref={canvasRef} className={css.sceneCanvas} aria-hidden="true" />
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/** reactbits.dev "FaultyTerminal": a glowing CRT terminal of random digits
|
|
1274
|
+
* with scanlines, glitch displacement and flicker; the pointer ripples the
|
|
1275
|
+
* character grid (single opaque fragment shader). */
|
|
1276
|
+
export function FaultyTerminalBackground(): React.JSX.Element {
|
|
1277
|
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
1278
|
+
useEffect(() => {
|
|
1279
|
+
const canvas = canvasRef.current
|
|
1280
|
+
const layer = canvas?.parentElement
|
|
1281
|
+
const viewport = layer?.parentElement
|
|
1282
|
+
if (canvas === null || canvas === undefined || viewport === null || viewport === undefined) return
|
|
1283
|
+
const fragment = `
|
|
1284
|
+
precision mediump float;
|
|
1285
|
+
varying vec2 vUv;
|
|
1286
|
+
uniform float iTime;
|
|
1287
|
+
uniform vec3 iResolution;
|
|
1288
|
+
uniform vec2 uMouse;
|
|
1289
|
+
uniform float uPageLoadProgress;
|
|
1290
|
+
uniform float uLightMode;
|
|
1291
|
+
// reactbits FaultyTerminal defaults, baked (timeScale 0.3 folded in below).
|
|
1292
|
+
const float TIME_SCALE = 0.333333;
|
|
1293
|
+
const float SCALE = 1.0;
|
|
1294
|
+
const vec2 GRID_MUL = vec2(2.0, 1.0);
|
|
1295
|
+
const float DIGIT_SIZE = 1.5;
|
|
1296
|
+
const float SCANLINE = 0.3;
|
|
1297
|
+
const float FLICKER = 1.0;
|
|
1298
|
+
const float NOISE_AMP = 1.0;
|
|
1299
|
+
const float CURVATURE = 0.2;
|
|
1300
|
+
const float MOUSE_STRENGTH = 0.2;
|
|
1301
|
+
float time;
|
|
1302
|
+
float hash21(vec2 p) {
|
|
1303
|
+
p = fract(p * 234.56);
|
|
1304
|
+
p += dot(p, p + 34.56);
|
|
1305
|
+
return fract(p.x * p.y);
|
|
1306
|
+
}
|
|
1307
|
+
float noise(vec2 p) {
|
|
1308
|
+
return sin(p.x * 10.0) * sin(p.y * (3.0 + sin(time * 0.090909))) + 0.2;
|
|
1309
|
+
}
|
|
1310
|
+
mat2 rotate(float angle) {
|
|
1311
|
+
float c = cos(angle);
|
|
1312
|
+
float s = sin(angle);
|
|
1313
|
+
return mat2(c, -s, s, c);
|
|
1314
|
+
}
|
|
1315
|
+
float fbm(vec2 p) {
|
|
1316
|
+
p *= 1.1;
|
|
1317
|
+
float f = 0.0;
|
|
1318
|
+
float amp = 0.5 * NOISE_AMP;
|
|
1319
|
+
mat2 modify0 = rotate(time * 0.02);
|
|
1320
|
+
f += amp * noise(p);
|
|
1321
|
+
p = modify0 * p * 2.0;
|
|
1322
|
+
amp *= 0.454545;
|
|
1323
|
+
mat2 modify1 = rotate(time * 0.02);
|
|
1324
|
+
f += amp * noise(p);
|
|
1325
|
+
p = modify1 * p * 2.0;
|
|
1326
|
+
amp *= 0.454545;
|
|
1327
|
+
mat2 modify2 = rotate(time * 0.08);
|
|
1328
|
+
f += amp * noise(p);
|
|
1329
|
+
return f;
|
|
1330
|
+
}
|
|
1331
|
+
float pattern(vec2 p, out vec2 q, out vec2 r) {
|
|
1332
|
+
vec2 offset1 = vec2(1.0);
|
|
1333
|
+
vec2 offset0 = vec2(0.0);
|
|
1334
|
+
mat2 rot01 = rotate(0.1 * time);
|
|
1335
|
+
mat2 rot1 = rotate(0.1);
|
|
1336
|
+
q = vec2(fbm(p + offset1), fbm(rot01 * p + offset1));
|
|
1337
|
+
r = vec2(fbm(rot1 * q + offset0), fbm(q + offset0));
|
|
1338
|
+
return fbm(p + r);
|
|
1339
|
+
}
|
|
1340
|
+
float digit(vec2 p) {
|
|
1341
|
+
vec2 grid = GRID_MUL * 15.0;
|
|
1342
|
+
vec2 s = floor(p * grid) / grid;
|
|
1343
|
+
p = p * grid;
|
|
1344
|
+
vec2 q, r;
|
|
1345
|
+
float intensity = pattern(s * 0.1, q, r) * 1.3 - 0.03;
|
|
1346
|
+
vec2 mouseWorld = uMouse * SCALE;
|
|
1347
|
+
float distToMouse = distance(s, mouseWorld);
|
|
1348
|
+
float mouseInfluence = exp(-distToMouse * 8.0) * MOUSE_STRENGTH * 10.0;
|
|
1349
|
+
intensity += mouseInfluence;
|
|
1350
|
+
float ripple = sin(distToMouse * 20.0 - iTime * 5.0) * 0.1 * mouseInfluence;
|
|
1351
|
+
intensity += ripple;
|
|
1352
|
+
float cellRandom = fract(sin(dot(s, vec2(12.9898, 78.233))) * 43758.5453);
|
|
1353
|
+
float cellDelay = cellRandom * 0.8;
|
|
1354
|
+
float cellProgress = clamp((uPageLoadProgress - cellDelay) / 0.2, 0.0, 1.0);
|
|
1355
|
+
intensity *= smoothstep(0.0, 1.0, cellProgress);
|
|
1356
|
+
p = fract(p);
|
|
1357
|
+
p *= DIGIT_SIZE;
|
|
1358
|
+
float px5 = p.x * 5.0;
|
|
1359
|
+
float py5 = (1.0 - p.y) * 5.0;
|
|
1360
|
+
float x = fract(px5);
|
|
1361
|
+
float y = fract(py5);
|
|
1362
|
+
float i = floor(py5) - 2.0;
|
|
1363
|
+
float j = floor(px5) - 2.0;
|
|
1364
|
+
float n = i * i + j * j;
|
|
1365
|
+
float f = n * 0.0625;
|
|
1366
|
+
float isOn = step(0.1, intensity - f);
|
|
1367
|
+
float brightness = isOn * (0.2 + y * 0.8) * (0.75 + x * 0.25);
|
|
1368
|
+
return step(0.0, p.x) * step(p.x, 1.0) * step(0.0, p.y) * step(p.y, 1.0) * brightness;
|
|
1369
|
+
}
|
|
1370
|
+
float onOff(float a, float b, float c) {
|
|
1371
|
+
return step(c, sin(iTime + a * cos(iTime * b))) * FLICKER;
|
|
1372
|
+
}
|
|
1373
|
+
float displace(vec2 look) {
|
|
1374
|
+
float y = look.y - mod(iTime * 0.25, 1.0);
|
|
1375
|
+
float window = 1.0 / (1.0 + 50.0 * y * y);
|
|
1376
|
+
return sin(look.y * 20.0 + iTime) * 0.0125 * onOff(4.0, 2.0, 0.8) * (1.0 + cos(iTime * 60.0)) * window;
|
|
1377
|
+
}
|
|
1378
|
+
vec3 getColor(vec2 p) {
|
|
1379
|
+
float bar = step(mod(p.y + time * 20.0, 1.0), 0.2) * 0.4 + 1.0;
|
|
1380
|
+
bar *= SCANLINE;
|
|
1381
|
+
float displacement = displace(p);
|
|
1382
|
+
p.x += displacement;
|
|
1383
|
+
float middle = digit(p);
|
|
1384
|
+
const float off = 0.002;
|
|
1385
|
+
float sum = digit(p + vec2(-off, -off)) + digit(p + vec2(0.0, -off)) + digit(p + vec2(off, -off)) +
|
|
1386
|
+
digit(p + vec2(-off, 0.0)) + digit(p + vec2(0.0, 0.0)) + digit(p + vec2(off, 0.0)) +
|
|
1387
|
+
digit(p + vec2(-off, off)) + digit(p + vec2(0.0, off)) + digit(p + vec2(off, off));
|
|
1388
|
+
return vec3(0.9) * middle + sum * 0.1 * vec3(1.0) * bar;
|
|
1389
|
+
}
|
|
1390
|
+
vec2 barrel(vec2 uv) {
|
|
1391
|
+
vec2 c = uv * 2.0 - 1.0;
|
|
1392
|
+
c *= 1.0 + CURVATURE * dot(c, c);
|
|
1393
|
+
return c * 0.5 + 0.5;
|
|
1394
|
+
}
|
|
1395
|
+
void main() {
|
|
1396
|
+
time = iTime * TIME_SCALE;
|
|
1397
|
+
vec2 p = barrel(vUv) * SCALE;
|
|
1398
|
+
vec3 col = getColor(p);
|
|
1399
|
+
if (uLightMode > 0.5) {
|
|
1400
|
+
float energy = max(max(col.r, col.g), col.b);
|
|
1401
|
+
float coverage = clamp(smoothstep(0.0, 0.72, energy) * 0.9, 0.0, 0.9);
|
|
1402
|
+
vec3 ink = clamp(col * 0.42, 0.0, 0.76);
|
|
1403
|
+
col = mix(vec3(1.0), ink, coverage);
|
|
1404
|
+
}
|
|
1405
|
+
gl_FragColor = vec4(col, 1.0);
|
|
1406
|
+
}
|
|
1407
|
+
`
|
|
1408
|
+
// The original randomizes the start of the terminal clock per mount.
|
|
1409
|
+
const timeOffset = Math.random() * 100
|
|
1410
|
+
const handle = startShaderCanvas(canvas, viewport, fragment, state => {
|
|
1411
|
+
const { gl, uniforms, time, width, height, dpr, pointer } = state
|
|
1412
|
+
gl.useProgram(state.program)
|
|
1413
|
+
gl.uniform1f(uniforms.iTime, (time + timeOffset) * 0.3)
|
|
1414
|
+
gl.uniform3f(uniforms.iResolution, width, height, width / Math.max(1, height))
|
|
1415
|
+
gl.uniform2f(
|
|
1416
|
+
uniforms.uMouse,
|
|
1417
|
+
pointer.x / Math.max(1, width / dpr),
|
|
1418
|
+
1 - pointer.y / Math.max(1, height / dpr),
|
|
1419
|
+
)
|
|
1420
|
+
// reactbits fades the character cells in over 2s on mount.
|
|
1421
|
+
gl.uniform1f(uniforms.uPageLoadProgress, Math.min(1, time / 2))
|
|
1422
|
+
gl.uniform1f(uniforms.uLightMode, state.light ? 1 : 0)
|
|
1423
|
+
state.draw()
|
|
1424
|
+
})
|
|
1425
|
+
if (handle === null) return
|
|
1426
|
+
return () => { handle.dispose() }
|
|
1427
|
+
}, [])
|
|
1428
|
+
return <canvas ref={canvasRef} className={css.sceneCanvas} aria-hidden="true" />
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/** reactbits.dev "DotField": a purple dot lattice that bulges away from the
|
|
1432
|
+
* pointer as it moves, springing back home, with a soft glow under the
|
|
1433
|
+
* cursor while the field is engaged (canvas 2D). */
|
|
1434
|
+
export function DotFieldBackground(): React.JSX.Element {
|
|
1435
|
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
1436
|
+
useEffect(() => {
|
|
1437
|
+
const canvas = canvasRef.current
|
|
1438
|
+
const layer = canvas?.parentElement
|
|
1439
|
+
const viewport = layer?.parentElement
|
|
1440
|
+
if (canvas === null || canvas === undefined || viewport === null || viewport === undefined) return
|
|
1441
|
+
const ctx = canvas.getContext('2d', { alpha: true })
|
|
1442
|
+
if (ctx === null) return
|
|
1443
|
+
const FIELD = { radius: 1.5, spacing: 14, cursorRadius: 500, bulgeStrength: 67, glowRadius: 160 }
|
|
1444
|
+
const light = detectLightSurface(viewport)
|
|
1445
|
+
const glowInner = light ? 'rgba(82, 39, 255, 0.16)' : 'rgba(18, 15, 23, 1)'
|
|
1446
|
+
const glowOuter = light ? 'rgba(82, 39, 255, 0)' : 'rgba(18, 15, 23, 0)'
|
|
1447
|
+
type FieldDot = { ax: number, ay: number, sx: number, sy: number }
|
|
1448
|
+
let dots: FieldDot[] = []
|
|
1449
|
+
let width = 0
|
|
1450
|
+
let height = 0
|
|
1451
|
+
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio ?? 1))
|
|
1452
|
+
const rebuild = (): void => {
|
|
1453
|
+
const rect = viewport.getBoundingClientRect()
|
|
1454
|
+
width = Math.max(1, Math.round(rect.width))
|
|
1455
|
+
height = Math.max(1, Math.round(rect.height))
|
|
1456
|
+
canvas.width = Math.round(width * dpr)
|
|
1457
|
+
canvas.height = Math.round(height * dpr)
|
|
1458
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
1459
|
+
const step = FIELD.radius + FIELD.spacing
|
|
1460
|
+
const cols = Math.floor(width / step)
|
|
1461
|
+
const rows = Math.floor(height / step)
|
|
1462
|
+
const padX = (width % step) / 2
|
|
1463
|
+
const padY = (height % step) / 2
|
|
1464
|
+
dots = []
|
|
1465
|
+
for (let row = 0; row < rows; row++) {
|
|
1466
|
+
for (let col = 0; col < cols; col++) {
|
|
1467
|
+
const ax = padX + col * step + step / 2
|
|
1468
|
+
const ay = padY + row * step + step / 2
|
|
1469
|
+
dots.push({ ax, ay, sx: ax, sy: ay })
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
const mouse = { x: -9999, y: -9999, prevX: -9999, prevY: -9999, speed: 0, seen: false }
|
|
1474
|
+
const onPointerMove = (event: PointerEvent): void => {
|
|
1475
|
+
const rect = viewport.getBoundingClientRect()
|
|
1476
|
+
mouse.x = event.clientX - rect.left
|
|
1477
|
+
mouse.y = event.clientY - rect.top
|
|
1478
|
+
if (!mouse.seen) {
|
|
1479
|
+
mouse.prevX = mouse.x
|
|
1480
|
+
mouse.prevY = mouse.y
|
|
1481
|
+
mouse.seen = true
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
let engagement = 0
|
|
1485
|
+
let glowOpacity = 0
|
|
1486
|
+
const draw = (frameCount: number): void => {
|
|
1487
|
+
const dx = mouse.prevX - mouse.x
|
|
1488
|
+
const dy = mouse.prevY - mouse.y
|
|
1489
|
+
mouse.speed += (Math.hypot(dx, dy) - mouse.speed) * 0.5
|
|
1490
|
+
if (mouse.speed < 0.001) mouse.speed = 0
|
|
1491
|
+
mouse.prevX = mouse.x
|
|
1492
|
+
mouse.prevY = mouse.y
|
|
1493
|
+
const target = Math.min(mouse.speed / 5, 1)
|
|
1494
|
+
engagement += (target - engagement) * 0.06
|
|
1495
|
+
if (engagement < 0.001) engagement = 0
|
|
1496
|
+
glowOpacity += (engagement - glowOpacity) * 0.08
|
|
1497
|
+
|
|
1498
|
+
ctx.clearRect(0, 0, width, height)
|
|
1499
|
+
if (glowOpacity > 0.01 && mouse.seen) {
|
|
1500
|
+
const glow = ctx.createRadialGradient(mouse.x, mouse.y, 0, mouse.x, mouse.y, FIELD.glowRadius)
|
|
1501
|
+
glow.addColorStop(0, glowInner)
|
|
1502
|
+
glow.addColorStop(1, glowOuter)
|
|
1503
|
+
ctx.globalAlpha = glowOpacity
|
|
1504
|
+
ctx.fillStyle = glow
|
|
1505
|
+
ctx.beginPath()
|
|
1506
|
+
ctx.arc(mouse.x, mouse.y, FIELD.glowRadius, 0, Math.PI * 2)
|
|
1507
|
+
ctx.fill()
|
|
1508
|
+
ctx.globalAlpha = 1
|
|
1509
|
+
}
|
|
1510
|
+
const gradient = ctx.createLinearGradient(0, 0, width, height)
|
|
1511
|
+
gradient.addColorStop(0, 'rgba(168, 85, 247, 0.35)')
|
|
1512
|
+
gradient.addColorStop(1, 'rgba(180, 151, 207, 0.25)')
|
|
1513
|
+
ctx.fillStyle = gradient
|
|
1514
|
+
|
|
1515
|
+
const crSq = FIELD.cursorRadius * FIELD.cursorRadius
|
|
1516
|
+
const rad = FIELD.radius / 2
|
|
1517
|
+
ctx.beginPath()
|
|
1518
|
+
for (const dot of dots) {
|
|
1519
|
+
const dxDot = mouse.x - dot.ax
|
|
1520
|
+
const dyDot = mouse.y - dot.ay
|
|
1521
|
+
const distSq = dxDot * dxDot + dyDot * dyDot
|
|
1522
|
+
if (distSq < crSq && engagement > 0.01) {
|
|
1523
|
+
const dist = Math.sqrt(distSq)
|
|
1524
|
+
const t = 1 - dist / FIELD.cursorRadius
|
|
1525
|
+
const push = t * t * FIELD.bulgeStrength * engagement
|
|
1526
|
+
const angle = Math.atan2(dyDot, dxDot)
|
|
1527
|
+
dot.sx += (dot.ax - Math.cos(angle) * push - dot.sx) * 0.15
|
|
1528
|
+
dot.sy += (dot.ay - Math.sin(angle) * push - dot.sy) * 0.15
|
|
1529
|
+
} else {
|
|
1530
|
+
dot.sx += (dot.ax - dot.sx) * 0.1
|
|
1531
|
+
dot.sy += (dot.ay - dot.sy) * 0.1
|
|
1532
|
+
}
|
|
1533
|
+
ctx.moveTo(dot.sx + rad, dot.sy)
|
|
1534
|
+
ctx.arc(dot.sx, dot.sy, rad, 0, Math.PI * 2)
|
|
1535
|
+
}
|
|
1536
|
+
ctx.fill()
|
|
1537
|
+
frame = window.requestAnimationFrame(tick)
|
|
1538
|
+
}
|
|
1539
|
+
const tick = (): void => { draw(performance.now()) }
|
|
1540
|
+
|
|
1541
|
+
rebuild()
|
|
1542
|
+
let frame = 0
|
|
1543
|
+
if (globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true) {
|
|
1544
|
+
draw(0)
|
|
1545
|
+
} else {
|
|
1546
|
+
frame = window.requestAnimationFrame(tick)
|
|
1547
|
+
viewport.addEventListener('pointermove', onPointerMove, true)
|
|
1548
|
+
}
|
|
1549
|
+
const observer = new ResizeObserver(rebuild)
|
|
1550
|
+
observer.observe(viewport)
|
|
1551
|
+
return () => {
|
|
1552
|
+
window.cancelAnimationFrame(frame)
|
|
1553
|
+
observer.disconnect()
|
|
1554
|
+
viewport.removeEventListener('pointermove', onPointerMove)
|
|
1555
|
+
}
|
|
1556
|
+
}, [])
|
|
1557
|
+
return <canvas ref={canvasRef} className={css.sceneCanvas} aria-hidden="true" />
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
/** reactbits.dev "DotGrid": a chunky dot lattice (reactbits default #5227FF)
|
|
1561
|
+
* that reacts to fast pointer strokes and clicks with inertia shockwaves —
|
|
1562
|
+
* dots fly out and elastically spring back (canvas 2D, spring port of the
|
|
1563
|
+
* original's gsap inertia tweens). */
|
|
1564
|
+
export function DotGridBackground(): React.JSX.Element {
|
|
1565
|
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
1566
|
+
useEffect(() => {
|
|
1567
|
+
const canvas = canvasRef.current
|
|
1568
|
+
const layer = canvas?.parentElement
|
|
1569
|
+
const viewport = layer?.parentElement
|
|
1570
|
+
if (canvas === null || canvas === undefined || viewport === null || viewport === undefined) return
|
|
1571
|
+
const ctx = canvas.getContext('2d')
|
|
1572
|
+
if (ctx === null) return
|
|
1573
|
+
const GRID = { dotSize: 16, gap: 32, color: '#5227FF', proximity: 150, speedTrigger: 100, shockRadius: 250, shockStrength: 5 }
|
|
1574
|
+
// Elastic return, standing in for the original's gsap InertiaPlugin.
|
|
1575
|
+
const omega = 7
|
|
1576
|
+
const stiffness = omega * omega
|
|
1577
|
+
const damping = 2 * 0.28 * omega
|
|
1578
|
+
type GridDot = { cx: number, cy: number, xo: number, yo: number, vxo: number, vyo: number }
|
|
1579
|
+
let dots: GridDot[] = []
|
|
1580
|
+
let width = 0
|
|
1581
|
+
let height = 0
|
|
1582
|
+
let lastFrame = 0
|
|
1583
|
+
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio ?? 1))
|
|
1584
|
+
const rebuild = (): void => {
|
|
1585
|
+
const rect = viewport.getBoundingClientRect()
|
|
1586
|
+
width = Math.max(1, Math.round(rect.width))
|
|
1587
|
+
height = Math.max(1, Math.round(rect.height))
|
|
1588
|
+
canvas.width = Math.round(width * dpr)
|
|
1589
|
+
canvas.height = Math.round(height * dpr)
|
|
1590
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
1591
|
+
const cell = GRID.dotSize + GRID.gap
|
|
1592
|
+
const cols = Math.floor((width + GRID.gap) / cell)
|
|
1593
|
+
const rows = Math.floor((height + GRID.gap) / cell)
|
|
1594
|
+
const startX = (width - (cell * cols - GRID.gap)) / 2 + GRID.dotSize / 2
|
|
1595
|
+
const startY = (height - (cell * rows - GRID.gap)) / 2 + GRID.dotSize / 2
|
|
1596
|
+
dots = []
|
|
1597
|
+
for (let y = 0; y < rows; y++) {
|
|
1598
|
+
for (let x = 0; x < cols; x++) {
|
|
1599
|
+
dots.push({ cx: startX + x * cell, cy: startY + y * cell, xo: 0, yo: 0, vxo: 0, vyo: 0 })
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
const pointer = { x: -1e4, y: -1e4, vx: 0, vy: 0, speed: 0, lastTime: 0, lastX: 0, lastY: 0 }
|
|
1604
|
+
let lastMoveAt = 0
|
|
1605
|
+
const onPointerMove = (event: PointerEvent): void => {
|
|
1606
|
+
const now = performance.now()
|
|
1607
|
+
const dt = pointer.lastTime !== 0 ? now - pointer.lastTime : 16
|
|
1608
|
+
const dx = event.clientX - pointer.lastX
|
|
1609
|
+
const dy = event.clientY - pointer.lastY
|
|
1610
|
+
let vx = (dx / Math.max(1, dt)) * 1000
|
|
1611
|
+
let vy = (dy / Math.max(1, dt)) * 1000
|
|
1612
|
+
const speed = Math.hypot(vx, vy)
|
|
1613
|
+
if (speed > 5000) {
|
|
1614
|
+
const scale = 5000 / speed
|
|
1615
|
+
vx *= scale
|
|
1616
|
+
vy *= scale
|
|
1617
|
+
}
|
|
1618
|
+
pointer.lastTime = now
|
|
1619
|
+
pointer.lastX = event.clientX
|
|
1620
|
+
pointer.lastY = event.clientY
|
|
1621
|
+
pointer.vx = vx
|
|
1622
|
+
pointer.vy = vy
|
|
1623
|
+
pointer.speed = speed
|
|
1624
|
+
const rect = viewport.getBoundingClientRect()
|
|
1625
|
+
pointer.x = event.clientX - rect.left
|
|
1626
|
+
pointer.y = event.clientY - rect.top
|
|
1627
|
+
// Throttled to 50ms like the original.
|
|
1628
|
+
if (now - lastMoveAt < 50) return
|
|
1629
|
+
lastMoveAt = now
|
|
1630
|
+
if (pointer.speed <= GRID.speedTrigger) return
|
|
1631
|
+
for (const dot of dots) {
|
|
1632
|
+
const dist = Math.hypot(dot.cx - pointer.x, dot.cy - pointer.y)
|
|
1633
|
+
if (dist < GRID.proximity) {
|
|
1634
|
+
dot.vxo += (dot.cx - pointer.x + pointer.vx * 0.005) * omega
|
|
1635
|
+
dot.vyo += (dot.cy - pointer.y + pointer.vy * 0.005) * omega
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
const onClick = (event: MouseEvent): void => {
|
|
1640
|
+
const rect = viewport.getBoundingClientRect()
|
|
1641
|
+
const cx = event.clientX - rect.left
|
|
1642
|
+
const cy = event.clientY - rect.top
|
|
1643
|
+
for (const dot of dots) {
|
|
1644
|
+
const dist = Math.hypot(dot.cx - cx, dot.cy - cy)
|
|
1645
|
+
if (dist < GRID.shockRadius) {
|
|
1646
|
+
const falloff = Math.max(0, 1 - dist / GRID.shockRadius)
|
|
1647
|
+
dot.vxo += (dot.cx - cx) * GRID.shockStrength * falloff * omega
|
|
1648
|
+
dot.vyo += (dot.cy - cy) * GRID.shockStrength * falloff * omega
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
const tick = (time: number): void => {
|
|
1653
|
+
const dt = Math.min(0.05, lastFrame !== 0 ? (time - lastFrame) / 1000 : 0.016)
|
|
1654
|
+
lastFrame = time
|
|
1655
|
+
ctx.clearRect(0, 0, width, height)
|
|
1656
|
+
ctx.fillStyle = GRID.color
|
|
1657
|
+
for (const dot of dots) {
|
|
1658
|
+
dot.vxo += (-stiffness * dot.xo - damping * dot.vxo) * dt
|
|
1659
|
+
dot.vyo += (-stiffness * dot.yo - damping * dot.vyo) * dt
|
|
1660
|
+
dot.xo += dot.vxo * dt
|
|
1661
|
+
dot.yo += dot.vyo * dt
|
|
1662
|
+
ctx.beginPath()
|
|
1663
|
+
ctx.arc(dot.cx + dot.xo, dot.cy + dot.yo, GRID.dotSize / 2, 0, Math.PI * 2)
|
|
1664
|
+
ctx.fill()
|
|
1665
|
+
}
|
|
1666
|
+
frame = window.requestAnimationFrame(tick)
|
|
1667
|
+
}
|
|
1668
|
+
let frame = 0
|
|
1669
|
+
rebuild()
|
|
1670
|
+
if (globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true) {
|
|
1671
|
+
// Static lattice: the grid only ever moves from pointer input anyway.
|
|
1672
|
+
ctx.fillStyle = GRID.color
|
|
1673
|
+
for (const dot of dots) {
|
|
1674
|
+
ctx.beginPath()
|
|
1675
|
+
ctx.arc(dot.cx, dot.cy, GRID.dotSize / 2, 0, Math.PI * 2)
|
|
1676
|
+
ctx.fill()
|
|
1677
|
+
}
|
|
1678
|
+
} else {
|
|
1679
|
+
frame = window.requestAnimationFrame(tick)
|
|
1680
|
+
viewport.addEventListener('pointermove', onPointerMove, true)
|
|
1681
|
+
viewport.addEventListener('click', onClick, true)
|
|
1682
|
+
}
|
|
1683
|
+
const observer = new ResizeObserver(rebuild)
|
|
1684
|
+
observer.observe(viewport)
|
|
1685
|
+
return () => {
|
|
1686
|
+
window.cancelAnimationFrame(frame)
|
|
1687
|
+
observer.disconnect()
|
|
1688
|
+
viewport.removeEventListener('pointermove', onPointerMove)
|
|
1689
|
+
viewport.removeEventListener('click', onClick)
|
|
1690
|
+
}
|
|
1691
|
+
}, [])
|
|
1692
|
+
return <canvas ref={canvasRef} className={css.sceneCanvas} aria-hidden="true" />
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/** reactbits.dev "ShapeGrid": a continuously scrolling square grid; the cell
|
|
1696
|
+
* under the pointer fills dark while it passes (canvas 2D). */
|
|
1697
|
+
export function ShapeGridBackground(): React.JSX.Element {
|
|
1698
|
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
1699
|
+
useEffect(() => {
|
|
1700
|
+
const canvas = canvasRef.current
|
|
1701
|
+
const layer = canvas?.parentElement
|
|
1702
|
+
const viewport = layer?.parentElement
|
|
1703
|
+
if (canvas === null || canvas === undefined || viewport === null || viewport === undefined) return
|
|
1704
|
+
const ctx = canvas.getContext('2d')
|
|
1705
|
+
if (ctx === null) return
|
|
1706
|
+
const SHAPE = { size: 40, speed: 1, border: '#999999', hoverFill: '#222222' }
|
|
1707
|
+
let width = 0
|
|
1708
|
+
let height = 0
|
|
1709
|
+
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio ?? 1))
|
|
1710
|
+
const offset = { x: 0, y: 0 }
|
|
1711
|
+
const opacities = new Map<string, number>()
|
|
1712
|
+
let hovered: { x: number, y: number } | null = null
|
|
1713
|
+
const rebuild = (): void => {
|
|
1714
|
+
const rect = viewport.getBoundingClientRect()
|
|
1715
|
+
width = Math.max(1, Math.round(rect.width))
|
|
1716
|
+
height = Math.max(1, Math.round(rect.height))
|
|
1717
|
+
canvas.width = Math.round(width * dpr)
|
|
1718
|
+
canvas.height = Math.round(height * dpr)
|
|
1719
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
1720
|
+
}
|
|
1721
|
+
const wrap = (value: number, modulo: number): number => ((value % modulo) + modulo) % modulo
|
|
1722
|
+
const cellOffsetX = (): number => wrap(offset.x, SHAPE.size)
|
|
1723
|
+
const cellOffsetY = (): number => wrap(offset.y, SHAPE.size)
|
|
1724
|
+
const draw = (): void => {
|
|
1725
|
+
ctx.clearRect(0, 0, width, height)
|
|
1726
|
+
const offsetX = cellOffsetX()
|
|
1727
|
+
const offsetY = cellOffsetY()
|
|
1728
|
+
const cols = Math.ceil(width / SHAPE.size) + 3
|
|
1729
|
+
const rows = Math.ceil(height / SHAPE.size) + 3
|
|
1730
|
+
ctx.strokeStyle = SHAPE.border
|
|
1731
|
+
ctx.lineWidth = 1
|
|
1732
|
+
for (let col = -2; col < cols; col++) {
|
|
1733
|
+
for (let row = -2; row < rows; row++) {
|
|
1734
|
+
const sx = col * SHAPE.size + offsetX
|
|
1735
|
+
const sy = row * SHAPE.size + offsetY
|
|
1736
|
+
const key = `${col},${row}`
|
|
1737
|
+
const alpha = opacities.get(key)
|
|
1738
|
+
if (alpha !== undefined) {
|
|
1739
|
+
ctx.globalAlpha = alpha
|
|
1740
|
+
ctx.fillStyle = SHAPE.hoverFill
|
|
1741
|
+
ctx.fillRect(sx, sy, SHAPE.size, SHAPE.size)
|
|
1742
|
+
ctx.globalAlpha = 1
|
|
1743
|
+
}
|
|
1744
|
+
ctx.strokeRect(sx, sy, SHAPE.size, SHAPE.size)
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
const updateOpacities = (): void => {
|
|
1749
|
+
for (const [key, opacity] of opacities) {
|
|
1750
|
+
const goal = hovered !== null && key === `${hovered.x},${hovered.y}` ? 1 : 0
|
|
1751
|
+
const next = opacity + (goal - opacity) * 0.15
|
|
1752
|
+
if (next < 0.005) opacities.delete(key)
|
|
1753
|
+
else opacities.set(key, next)
|
|
1754
|
+
}
|
|
1755
|
+
if (hovered !== null && !opacities.has(`${hovered.x},${hovered.y}`)) opacities.set(`${hovered.x},${hovered.y}`, 0)
|
|
1756
|
+
}
|
|
1757
|
+
const tick = (): void => {
|
|
1758
|
+
// direction 'right': the original steps the offset by -speed per frame.
|
|
1759
|
+
offset.x = wrap(offset.x - SHAPE.speed, SHAPE.size)
|
|
1760
|
+
updateOpacities()
|
|
1761
|
+
draw()
|
|
1762
|
+
frame = window.requestAnimationFrame(tick)
|
|
1763
|
+
}
|
|
1764
|
+
const onPointerMove = (event: PointerEvent): void => {
|
|
1765
|
+
const rect = viewport.getBoundingClientRect()
|
|
1766
|
+
const col = Math.floor((event.clientX - rect.left - cellOffsetX()) / SHAPE.size)
|
|
1767
|
+
const row = Math.floor((event.clientY - rect.top - cellOffsetY()) / SHAPE.size)
|
|
1768
|
+
if (hovered === null || hovered.x !== col || hovered.y !== row) hovered = { x: col, y: row }
|
|
1769
|
+
}
|
|
1770
|
+
const onPointerLeave = (): void => { hovered = null }
|
|
1771
|
+
|
|
1772
|
+
rebuild()
|
|
1773
|
+
let frame = 0
|
|
1774
|
+
if (globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true) {
|
|
1775
|
+
draw()
|
|
1776
|
+
} else {
|
|
1777
|
+
frame = window.requestAnimationFrame(tick)
|
|
1778
|
+
viewport.addEventListener('pointermove', onPointerMove, true)
|
|
1779
|
+
viewport.addEventListener('pointerleave', onPointerLeave)
|
|
1780
|
+
}
|
|
1781
|
+
const observer = new ResizeObserver(rebuild)
|
|
1782
|
+
observer.observe(viewport)
|
|
1783
|
+
return () => {
|
|
1784
|
+
window.cancelAnimationFrame(frame)
|
|
1785
|
+
observer.disconnect()
|
|
1786
|
+
viewport.removeEventListener('pointermove', onPointerMove)
|
|
1787
|
+
viewport.removeEventListener('pointerleave', onPointerLeave)
|
|
1788
|
+
}
|
|
1789
|
+
}, [])
|
|
1790
|
+
return <canvas ref={canvasRef} className={css.sceneCanvas} aria-hidden="true" />
|
|
1791
|
+
}
|
|
1792
|
+
|