'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(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 ( ) }