409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
'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>
|
||
)
|
||
}
|