gongxiangp2/components/board/BrandFluidField.tsx

409 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useEffect, useRef } from 'react'
// 偏粉的参考稿版本:粉雾、玫瑰与奶白,避免再回到偏橙的材质感。
const BRAND_COLORS = ['#ffc7de', '#ff8fc7', '#fff1f7'] as const
const FLOW_TEXTURE_WIDTH = 512
const FLOW_TEXTURE_HEIGHT = 160
const VERTEX_SHADER = `
attribute vec2 a_position;
varying vec2 v_uv;
void main() {
v_uv = a_position * 0.5 + 0.5;
gl_Position = vec4(a_position, 0.0, 1.0);
}
`
const SIMULATION_SHADER = `
precision highp float;
varying vec2 v_uv;
uniform float u_time;
uniform float u_seed;
float hash31(vec3 value) {
value = fract(value * vec3(0.1031, 0.1030, 0.0973));
value += dot(value, value.yzx + 33.33 + u_seed);
return fract((value.x + value.y) * value.z);
}
float valueNoise3(vec3 point) {
vec3 cell = floor(point);
vec3 local = fract(point);
local = local * local * (3.0 - 2.0 * local);
float x00 = mix(hash31(cell), hash31(cell + vec3(1.0, 0.0, 0.0)), local.x);
float x10 = mix(hash31(cell + vec3(0.0, 1.0, 0.0)), hash31(cell + vec3(1.0, 1.0, 0.0)), local.x);
float x01 = mix(hash31(cell + vec3(0.0, 0.0, 1.0)), hash31(cell + vec3(1.0, 0.0, 1.0)), local.x);
float x11 = mix(hash31(cell + vec3(0.0, 1.0, 1.0)), hash31(cell + vec3(1.0, 1.0, 1.0)), local.x);
float lower = mix(x00, x10, local.y);
float upper = mix(x01, x11, local.y);
return mix(lower, upper, local.z);
}
float fbm3(vec3 point) {
float value = 0.0;
float amplitude = 0.55;
for (int octave = 0; octave < 3; octave++) {
value += valueNoise3(point) * amplitude;
point = vec3(point.y + point.z, point.z + point.x, point.x + point.y) * 1.07
+ vec3(13.7, 9.2, 5.4);
amplitude *= 0.50;
}
return value;
}
float rollingDensity(vec2 point, vec2 warp, float channel, float time) {
float phase = channel * 7.91 + u_seed * 2.37;
vec2 channelOffset = vec2(
cos(phase) * 2.2 + channel * 1.1,
sin(phase) * 2.0 - channel * 0.8
);
float broadMass = fbm3(vec3(
point + warp * 1.24 + channelOffset,
time * 0.34 + phase
));
float foldingMass = valueNoise3(vec3(
point * 1.82 - warp * 0.52 + channelOffset * 1.53,
-time * 0.43 + phase * 1.71
));
float densityField = broadMass * 0.70 + foldingMass * 0.30;
float body = smoothstep(0.30, 0.76, densityField);
float volume = smoothstep(0.22, 0.82, broadMass * 0.60 + foldingMass * 0.40);
return body * mix(0.38, 0.98, volume);
}
void main() {
float time = u_time * 0.62;
vec2 point = vec2((v_uv.x - 0.24) * 3.30, v_uv.y * 1.35);
vec2 primaryWarp = vec2(
fbm3(vec3(point * 0.72 + u_seed, time * 0.29 + u_seed)),
fbm3(vec3(point * 0.76 + vec2(6.3, -4.7) - u_seed * 0.71, -time * 0.31 + u_seed))
) - 0.5;
vec3 dye = vec3(
rollingDensity(point, primaryWarp, 0.0, time),
rollingDensity(point, primaryWarp, 1.0, time),
rollingDensity(point, primaryWarp, 2.0, time)
);
// 色彩只聚集在右侧,并用倾斜的软边界进入白纸区域,避免一条直上直下的分界线。
float diagonalField = v_uv.x + (v_uv.y - 0.5) * 0.20;
float materialField = smoothstep(0.58, 0.93, diagonalField);
dye *= materialField;
gl_FragColor = vec4(clamp(dye, 0.0, 1.0), 1.0);
}
`
const DISPLAY_SHADER = `
precision highp float;
varying vec2 v_uv;
uniform sampler2D u_dye;
uniform vec2 u_texel;
uniform vec3 u_color_a;
uniform vec3 u_color_b;
uniform vec3 u_color_c;
void main() {
vec3 center = texture2D(u_dye, v_uv).rgb;
vec3 cardinal = (
texture2D(u_dye, v_uv + vec2(u_texel.x, 0.0)).rgb +
texture2D(u_dye, v_uv - vec2(u_texel.x, 0.0)).rgb +
texture2D(u_dye, v_uv + vec2(0.0, u_texel.y)).rgb +
texture2D(u_dye, v_uv - vec2(0.0, u_texel.y)).rgb
) * 0.25;
vec3 diagonal = (
texture2D(u_dye, v_uv + u_texel).rgb +
texture2D(u_dye, v_uv - u_texel).rgb +
texture2D(u_dye, v_uv + vec2(u_texel.x, -u_texel.y)).rgb +
texture2D(u_dye, v_uv + vec2(-u_texel.x, u_texel.y)).rgb
) * 0.25;
vec3 nearby = mix(cardinal, diagonal, 0.42);
vec3 dye = mix(center, nearby, 0.24);
float baseWash = smoothstep(0.30, 0.64, v_uv.x);
dye += vec3(0.007, 0.006, 0.006) * baseWash;
float total = dye.r + dye.g + dye.b + 0.0001;
vec3 pigment = (u_color_a * dye.r + u_color_b * dye.g + u_color_c * dye.b) / total;
float opacity = 1.0 - exp(-total * 1.04);
vec3 paper = vec3(0.978);
vec3 color = mix(paper, pigment, clamp(opacity * 0.42, 0.0, 0.46));
float diagonalClearance = v_uv.x + (v_uv.y - 0.5) * 0.18;
float copyClearance = 1.0 - smoothstep(0.28, 0.88, diagonalClearance);
color = mix(color, paper, copyClearance * 0.99);
gl_FragColor = vec4(color, 1.0);
}
`
type ShaderProgram = {
program: WebGLProgram
fragmentShader: WebGLShader
}
type RenderTarget = {
texture: WebGLTexture
framebuffer: WebGLFramebuffer
}
function createShader(gl: WebGLRenderingContext, type: number, source: string) {
const shader = gl.createShader(type)
if (!shader) throw new Error('无法创建品牌动态色彩着色器')
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const message = gl.getShaderInfoLog(shader) || '品牌动态色彩着色器编译失败'
gl.deleteShader(shader)
throw new Error(message)
}
return shader
}
function createProgram(
gl: WebGLRenderingContext,
vertexShader: WebGLShader,
fragmentSource: string,
): ShaderProgram {
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource)
const program = gl.createProgram()
if (!program) {
gl.deleteShader(fragmentShader)
throw new Error('无法创建品牌动态色彩程序')
}
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const message = gl.getProgramInfoLog(program) || '品牌动态色彩程序链接失败'
gl.deleteProgram(program)
gl.deleteShader(fragmentShader)
throw new Error(message)
}
return { program, fragmentShader }
}
function createRenderTarget(gl: WebGLRenderingContext, width: number, height: number): RenderTarget {
const texture = gl.createTexture()
const framebuffer = gl.createFramebuffer()
if (!texture || !framebuffer) throw new Error('无法创建品牌动态色彩画布')
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer)
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0)
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
gl.deleteFramebuffer(framebuffer)
gl.deleteTexture(texture)
throw new Error('品牌动态色彩画布初始化失败')
}
return { texture, framebuffer }
}
function hexToRgb(hex: string) {
const value = Number.parseInt(hex.replace('#', ''), 16)
return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255]
}
export default function BrandFluidField() {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const gl = canvas.getContext('webgl', {
alpha: false,
antialias: false,
powerPreference: 'high-performance',
preserveDrawingBuffer: false,
})
if (!gl) {
canvas.dataset.webgl = 'unavailable'
return
}
let vertexShader: WebGLShader | null = null
let simulation: ShaderProgram | null = null
let display: ShaderProgram | null = null
let positionBuffer: WebGLBuffer | null = null
let target: RenderTarget | null = null
let frame = 0
let lastFrameTime = 0
let isInViewport = true
let isDocumentVisible = !document.hidden
try {
vertexShader = createShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER)
simulation = createProgram(gl, vertexShader, SIMULATION_SHADER)
display = createProgram(gl, vertexShader, DISPLAY_SHADER)
positionBuffer = gl.createBuffer()
if (!positionBuffer) throw new Error('无法创建品牌动态色彩顶点缓冲')
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW)
target = createRenderTarget(gl, FLOW_TEXTURE_WIDTH, FLOW_TEXTURE_HEIGHT)
} catch {
canvas.dataset.webgl = 'unavailable'
return
}
const vertex = vertexShader
const simulationProgram = simulation
const displayProgram = display
const buffer = positionBuffer
const renderTarget = target
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const startedAt = performance.now()
const simulationUniforms = {
time: gl.getUniformLocation(simulationProgram.program, 'u_time'),
seed: gl.getUniformLocation(simulationProgram.program, 'u_seed'),
}
const displayUniforms = {
dye: gl.getUniformLocation(displayProgram.program, 'u_dye'),
texel: gl.getUniformLocation(displayProgram.program, 'u_texel'),
colorA: gl.getUniformLocation(displayProgram.program, 'u_color_a'),
colorB: gl.getUniformLocation(displayProgram.program, 'u_color_b'),
colorC: gl.getUniformLocation(displayProgram.program, 'u_color_c'),
}
const bindPlane = (program: WebGLProgram) => {
const position = gl.getAttribLocation(program, 'a_position')
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.enableVertexAttribArray(position)
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0)
}
const resize = () => {
const mobileRatio = window.matchMedia('(max-width: 767px)').matches ? 1.2 : 1.5
const ratio = Math.min(window.devicePixelRatio || 1, mobileRatio)
const width = Math.max(1, Math.round(canvas.clientWidth * ratio))
const height = Math.max(1, Math.round(canvas.clientHeight * ratio))
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width
canvas.height = height
}
}
const renderFrame = (elapsed: number) => {
gl.bindFramebuffer(gl.FRAMEBUFFER, renderTarget.framebuffer)
gl.viewport(0, 0, FLOW_TEXTURE_WIDTH, FLOW_TEXTURE_HEIGHT)
gl.useProgram(simulationProgram.program)
bindPlane(simulationProgram.program)
gl.uniform1f(simulationUniforms.time, elapsed)
// 与参考稿 ORIGINAL specimen 使用同一个确定性 seed,保证首帧纹理而非随机近似。
gl.uniform1f(simulationUniforms.seed, 597 / 97)
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
resize()
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.viewport(0, 0, canvas.width, canvas.height)
gl.clearColor(0.9804, 0.9804, 0.9804, 1)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.useProgram(displayProgram.program)
bindPlane(displayProgram.program)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, renderTarget.texture)
gl.uniform1i(displayUniforms.dye, 0)
gl.uniform2f(displayUniforms.texel, 1 / FLOW_TEXTURE_WIDTH, 1 / FLOW_TEXTURE_HEIGHT)
gl.uniform3fv(displayUniforms.colorA, hexToRgb(BRAND_COLORS[0]))
gl.uniform3fv(displayUniforms.colorB, hexToRgb(BRAND_COLORS[1]))
gl.uniform3fv(displayUniforms.colorC, hexToRgb(BRAND_COLORS[2]))
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
canvas.dataset.webgl = 'ready'
}
const draw = (now: number) => {
frame = 0
if (!isInViewport || !isDocumentVisible) return
if (now - lastFrameTime >= 1000 / 30) {
lastFrameTime = now
renderFrame(7.2 + (now - startedAt) / 1000)
}
frame = window.requestAnimationFrame(draw)
}
const start = () => {
if (reduceMotion || frame || !isInViewport || !isDocumentVisible) return
frame = window.requestAnimationFrame(draw)
}
const stop = () => {
if (!frame) return
window.cancelAnimationFrame(frame)
frame = 0
}
const intersectionObserver = new IntersectionObserver(([entry]) => {
isInViewport = entry?.isIntersecting ?? true
if (isInViewport) start()
else stop()
}, { rootMargin: '80px' })
const resizeObserver = new ResizeObserver(() => {
if (reduceMotion) renderFrame(12)
})
const handleVisibilityChange = () => {
isDocumentVisible = !document.hidden
if (isDocumentVisible) start()
else stop()
}
intersectionObserver.observe(canvas)
resizeObserver.observe(canvas)
document.addEventListener('visibilitychange', handleVisibilityChange)
if (reduceMotion) renderFrame(12)
else start()
return () => {
stop()
intersectionObserver.disconnect()
resizeObserver.disconnect()
document.removeEventListener('visibilitychange', handleVisibilityChange)
gl.deleteFramebuffer(renderTarget.framebuffer)
gl.deleteTexture(renderTarget.texture)
gl.deleteBuffer(buffer)
gl.deleteProgram(simulationProgram.program)
gl.deleteProgram(displayProgram.program)
gl.deleteShader(simulationProgram.fragmentShader)
gl.deleteShader(displayProgram.fragmentShader)
gl.deleteShader(vertex)
}
}, [])
return (
<div
data-testid="brand-fluid-field"
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 z-0 h-[72px] overflow-hidden bg-[#f4f4f2] md:inset-0 md:h-auto"
style={{
background: 'linear-gradient(90deg, #fafafa 0%, #fafafa 56%, #fff7fb 70%, #ffdceb 86%, #ffedf5 100%)',
}}
>
<canvas
ref={canvasRef}
className="block h-full w-full [filter:saturate(0.82)]"
/>
<span
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-7 bg-gradient-to-b from-transparent to-[#fafafa] md:hidden"
/>
</div>
)
}