diff --git a/docs/src/app/(shaders)/logo-3d/layout.tsx b/docs/src/app/(shaders)/logo-3d/layout.tsx new file mode 100644 index 000000000..47d67285f --- /dev/null +++ b/docs/src/app/(shaders)/logo-3d/layout.tsx @@ -0,0 +1,9 @@ +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: '3d Logo Filter • Paper', +}; + +export default function Layout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/docs/src/app/(shaders)/logo-3d/page.tsx b/docs/src/app/(shaders)/logo-3d/page.tsx new file mode 100644 index 000000000..c57fb2269 --- /dev/null +++ b/docs/src/app/(shaders)/logo-3d/page.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { Logo3d, logo3dPresets } from '@paper-design/shaders-react'; +import { useControls, button, folder } from 'leva'; +import { setParamsSafe, useResetLevaParams } from '@/helpers/use-reset-leva-params'; +import { usePresetHighlight } from '@/helpers/use-preset-highlight'; +import { cleanUpLevaParams } from '@/helpers/clean-up-leva-params'; +import { logo3dMeta } from '@paper-design/shaders'; +import { ShaderFit } from '@paper-design/shaders'; +import { levaImageButton } from '@/helpers/leva-image-button'; +import { useState, Suspense, useEffect, useCallback } from 'react'; +import { ShaderDetails } from '@/components/shader-details'; +import { ShaderContainer } from '@/components/shader-container'; +import { useUrlParams } from '@/helpers/use-url-params'; +import { logo3dDef } from '@/shader-defs/logo-3d-def'; +import { toHsla } from '@/helpers/color-utils'; +import { useColors } from '@/helpers/use-colors'; + +// Override just for the docs, we keep it transparent in the preset +// logo3dPresets[0].params.colorBack = '#000000'; + +const { worldWidth, worldHeight, ...defaults } = logo3dPresets[0].params; + +const imageFiles = [ + 'contra.svg', + 'apple.svg', + // 'paradigm.svg', + 'paper-logo-only.svg', + // 'brave.svg', + // 'capy.svg', + // 'infinite.svg', + 'linear.svg', + 'mercury.svg', + 'mymind.svg', + 'resend.svg', + 'shopify.svg', + 'wealth-simple.svg', + 'chanel.svg', + 'cibc.svg', + 'cloudflare.svg', + 'discord.svg', + 'nasa.svg', + 'nike.svg', + 'volkswagen.svg', + 'diamond.svg', +] as const; + +const Logo3dWithControls = () => { + const [imageIdx, setImageIdx] = useState(-1); + const [image, setImage] = useState('/images/logos/diamond.svg'); + + useEffect(() => { + if (imageIdx >= 0) { + const name = imageFiles[imageIdx]; + const img = new Image(); + img.src = `/images/logos/${name}`; + img.onload = () => setImage(img); + } + }, [imageIdx]); + + const handleClick = useCallback(() => { + setImageIdx((prev) => (prev + 1) % imageFiles.length); + // setImageIdx(() => Math.floor(Math.random() * imageFiles.length)); + }, []); + + const { colors, setColors } = useColors({ + defaultColors: defaults.colors, + maxColorCount: logo3dMeta.maxColorCount, + }); + + const [params, setParams] = useControls(() => { + return { + colorBack: { value: toHsla(defaults.colorBack), order: 100 }, + colorBase: { value: toHsla(defaults.colorBase), order: 102 }, + lightsSpread: { value: defaults.lightsSpread, min: 0, max: 1, order: 207 }, + lightsDiffuse: { value: defaults.lightsDiffuse, min: 0, max: 1, order: 209 }, + lightsSpecular: { value: defaults.lightsSpecular, min: 0, max: 1, order: 210 }, + lightsShadow: { value: defaults.lightsShadow, min: 0, max: 1, order: 211 }, + speed: { value: defaults.speed, min: 0, max: 2, order: 300 }, + scale: { value: defaults.scale, min: 0.2, max: 10, order: 301 }, + // rotation: { value: defaults.rotation, min: 0, max: 360, order: 302 }, + // offsetX: { value: defaults.offsetX, min: -1, max: 1, order: 303 }, + // offsetY: { value: defaults.offsetY, min: -1, max: 1, order: 304 }, + // fit: { value: defaults.fit, options: ['contain', 'cover'] as ShaderFit[], order: 305 }, + Image: folder( + { + 'Upload image': levaImageButton((img?: HTMLImageElement) => setImage(img ?? '')), + }, + { order: -1 } + ), + }; + }, [colors.length]); + + useControls(() => { + const presets = Object.fromEntries( + logo3dPresets.map(({ name, params: { worldWidth, worldHeight, ...preset } }) => [ + name, + button(() => { + const { colors, ...presetParams } = preset; + setColors(colors); + setParamsSafe(params, setParams, presetParams); + }), + ]) + ); + return { + Presets: folder(presets, { order: -2 }), + }; + }); + + // Reset to defaults on mount, so that Leva doesn't show values from other + // shaders when navigating (if two shaders have a color1 param for example) + useResetLevaParams(params, setParams, defaults); + useUrlParams(params, setParams, logo3dDef, setColors); + usePresetHighlight(logo3dPresets, params); + cleanUpLevaParams(params); + + return ( + <> + + + + + + + + ); +}; + +export default Logo3dWithControls; diff --git a/docs/src/shader-defs/logo-3d-def.ts b/docs/src/shader-defs/logo-3d-def.ts new file mode 100644 index 000000000..4235020ed --- /dev/null +++ b/docs/src/shader-defs/logo-3d-def.ts @@ -0,0 +1,19 @@ +import { logo3dPresets } from '@paper-design/shaders-react'; +import type { ShaderDef } from './shader-def-types'; +import { animatedCommonParams } from './common-param-def'; + +const defaultParams = logo3dPresets[0].params; + +export const logo3dDef: ShaderDef = { + name: '3d Logo', + description: 'TBD', + params: [ + { + name: 'image', + type: 'HTMLImageElement | string', + description: + 'An optional image used as an effect mask. A transparent background is required. If no image is provided, the shader defaults to one of the predefined shapes.', + }, + ...animatedCommonParams, + ], +}; diff --git a/packages/shaders-react/src/index.ts b/packages/shaders-react/src/index.ts index 44d3c5471..8631aade8 100644 --- a/packages/shaders-react/src/index.ts +++ b/packages/shaders-react/src/index.ts @@ -115,6 +115,10 @@ export { HalftoneCmyk, halftoneCmykPresets } from './shaders/halftone-cmyk.js'; export type { HalftoneCmykProps } from './shaders/halftone-cmyk.js'; export type { HalftoneCmykUniforms, HalftoneCmykParams } from '@paper-design/shaders'; +export { Logo3d, logo3dPresets } from './shaders/logo-3d.js'; +export type { Logo3dProps } from './shaders/logo-3d.js'; +export type { Logo3dUniforms, Logo3dParams } from '@paper-design/shaders'; + export { isPaperShaderElement, getShaderColorFromString } from '@paper-design/shaders'; export type { PaperShaderElement, ShaderFit, ShaderSizingParams, ShaderSizingUniforms } from '@paper-design/shaders'; diff --git a/packages/shaders-react/src/shaders/logo-3d.tsx b/packages/shaders-react/src/shaders/logo-3d.tsx new file mode 100644 index 000000000..5765fa2ad --- /dev/null +++ b/packages/shaders-react/src/shaders/logo-3d.tsx @@ -0,0 +1,145 @@ +import { memo, useLayoutEffect, useState } from 'react'; +import { ShaderMount, type ShaderComponentProps } from '../shader-mount.js'; +import { colorPropsAreEqual } from '../color-props-are-equal.js'; +import { + logo3dFragmentShader, + ShaderFitOptions, + defaultObjectSizing, + type Logo3dUniforms, + type Logo3dParams, + toProcessedLogo3d, + type ImageShaderPreset, + getShaderColorFromString, +} from '@paper-design/shaders'; +import { transparentPixel } from '../transparent-pixel.js'; +import { suspend } from '../suspend.js'; + +export interface Logo3dProps extends ShaderComponentProps, Logo3dParams { + /** + * Suspends the component when the image is being processed. + */ + suspendWhenProcessingImage?: boolean; +} + +type Logo3dPreset = ImageShaderPreset; + +export const defaultPreset: Logo3dPreset = { + name: 'Default', + params: { + ...defaultObjectSizing, + scale: 0.7, + speed: 1, + frame: 0, + colorBack: '#000000', + colorBase: '#ea05ff', + colors: ['#ffea61', '#00ffee'], + lightsSpread: 0.4, + lightsDiffuse: 1, + lightsSpecular: 1, + lightsShadow: 0.2, + }, +}; + +export const logo3dPresets: Logo3dPreset[] = [defaultPreset]; + +export const Logo3d: React.FC = memo(function Logo3dImpl({ + // Own props + colorBack = defaultPreset.params.colorBack, + colorBase = defaultPreset.params.colorBase, + colors = defaultPreset.params.colors, + speed = defaultPreset.params.speed, + frame = defaultPreset.params.frame, + image = '', + lightsSpread = defaultPreset.params.lightsSpread, + lightsDiffuse = defaultPreset.params.lightsDiffuse, + lightsSpecular = defaultPreset.params.lightsSpecular, + lightsShadow = defaultPreset.params.lightsShadow, + suspendWhenProcessingImage = false, + + // Sizing props + fit = defaultPreset.params.fit, + scale = defaultPreset.params.scale, + rotation = defaultPreset.params.rotation, + originX = defaultPreset.params.originX, + originY = defaultPreset.params.originY, + offsetX = defaultPreset.params.offsetX, + offsetY = defaultPreset.params.offsetY, + worldWidth = defaultPreset.params.worldWidth, + worldHeight = defaultPreset.params.worldHeight, + ...props +}: Logo3dProps) { + const imageUrl = typeof image === 'string' ? image : image.src; + const [processedStateImage, setProcessedStateImage] = useState(transparentPixel); + + let processedImage: string; + + if (suspendWhenProcessingImage && typeof window !== 'undefined' && imageUrl) { + processedImage = suspend( + (): Promise => toProcessedLogo3d(imageUrl).then((result) => URL.createObjectURL(result.pngBlob)), + [imageUrl, 'logo3d'] + ); + } else { + processedImage = processedStateImage; + } + + useLayoutEffect(() => { + if (suspendWhenProcessingImage) { + // Skip doing work in the effect as it's been handled by suspense. + return; + } + + if (!imageUrl) { + setProcessedStateImage(transparentPixel); + return; + } + + let url: string; + let current = true; + + toProcessedLogo3d(imageUrl).then((result) => { + if (current) { + url = URL.createObjectURL(result.pngBlob); + setProcessedStateImage(url); + } + }); + + return () => { + current = false; + }; + }, [imageUrl, suspendWhenProcessingImage]); + + const uniforms = { + // Own uniforms + u_colors: colors.map(getShaderColorFromString), + u_colorsCount: colors.length, + u_colorBack: getShaderColorFromString(colorBack), + u_colorBase: getShaderColorFromString(colorBase), + u_image: processedImage, + u_lightsSpread: lightsSpread, + u_lightsDiffuse: lightsDiffuse, + u_lightsSpecular: lightsSpecular, + u_lightsShadow: lightsShadow, + + // Sizing uniforms + u_fit: ShaderFitOptions[fit], + u_scale: scale, + u_rotation: rotation, + u_offsetX: offsetX, + u_offsetY: offsetY, + u_originX: originX, + u_originY: originY, + u_worldWidth: worldWidth, + u_worldHeight: worldHeight, + } satisfies Logo3dUniforms; + + return ( + + ); +}, colorPropsAreEqual); diff --git a/packages/shaders/src/index.ts b/packages/shaders/src/index.ts index 1fa838072..677862264 100644 --- a/packages/shaders/src/index.ts +++ b/packages/shaders/src/index.ts @@ -220,6 +220,14 @@ export { type HalftoneCmykType, } from './shaders/halftone-cmyk.js'; +export { + logo3dMeta, + logo3dFragmentShader, + toProcessedLogo3d, + type Logo3dParams, + type Logo3dUniforms, +} from './shaders/logo-3d.js'; + // ----- Utils ----- // export { getShaderColorFromString } from './get-shader-color-from-string.js'; export { getShaderNoiseTexture } from './get-shader-noise-texture.js'; diff --git a/packages/shaders/src/shaders/logo-3d.ts b/packages/shaders/src/shaders/logo-3d.ts new file mode 100644 index 000000000..5442ce8c2 --- /dev/null +++ b/packages/shaders/src/shaders/logo-3d.ts @@ -0,0 +1,710 @@ +import type { vec4 } from '../types.js'; +import type { ShaderMotionParams } from '../shader-mount.js'; +import type { ShaderSizingParams, ShaderSizingUniforms } from '../shader-sizing.js'; +import { declarePI, rotation2 } from '../shader-utils.js'; + +export const logo3dMeta = { + maxColorCount: 4, +} as const; + +/** + * + */ + +// language=GLSL +export const logo3dFragmentShader: string = `#version 300 es +precision mediump float; + +in mediump vec2 v_imageUV; +out vec4 fragColor; + +uniform sampler2D u_image; +uniform float u_imageAspectRatio; + +uniform vec2 u_resolution; +uniform float u_time; + +uniform vec4 u_colors[${ logo3dMeta.maxColorCount }]; +uniform float u_colorsCount; +uniform vec4 u_colorBack; +uniform vec4 u_colorBase; +uniform float u_lightsSpread; +uniform float u_lightsDiffuse; +uniform float u_lightsSpecular; +uniform float u_lightsShadow; + +${ declarePI } +${ rotation2 } +#define HALF_PI 1.5707963267949 + +float getImgFrame(vec2 uv, float th) { + float frame = 1.; + frame *= smoothstep(0., th, uv.y); + frame *= 1.0 - smoothstep(1. - th, 1., uv.y); + frame *= smoothstep(0., th, uv.x); + frame *= 1.0 - smoothstep(1. - th, 1., uv.x); + return frame; +} + +float blurEdge5x5(sampler2D tex, vec2 uv, float radius, float centerSample) { + vec2 texel = 1.0 / vec2(textureSize(tex, 0)); + vec2 r = max(radius, 0.0) * texel; + + const float a = 1.0; + const float b = 4.0; + const float c = 6.0; + + float norm = 256.0; + float sum = 0.0; + + // y = -2 + sum += a * ( + a * texture(tex, uv + vec2(-2.0*r.x, -2.0*r.y)).r + + b * texture(tex, uv + vec2(-1.0*r.x, -2.0*r.y)).r + + c * texture(tex, uv + vec2(0.0, -2.0*r.y)).r + + b * texture(tex, uv + vec2(1.0*r.x, -2.0*r.y)).r + + a * texture(tex, uv + vec2(2.0*r.x, -2.0*r.y)).r); + + // y = -1 + sum += b * ( + a * texture(tex, uv + vec2(-2.0*r.x, -1.0*r.y)).r + + b * texture(tex, uv + vec2(-1.0*r.x, -1.0*r.y)).r + + c * texture(tex, uv + vec2(0.0, -1.0*r.y)).r + + b * texture(tex, uv + vec2(1.0*r.x, -1.0*r.y)).r + + a * texture(tex, uv + vec2(2.0*r.x, -1.0*r.y)).r); + + // y = 0 + sum += c * ( + a * texture(tex, uv + vec2(-2.0*r.x, 0.0)).r + + b * texture(tex, uv + vec2(-1.0*r.x, 0.0)).r + + c * centerSample + + b * texture(tex, uv + vec2(1.0*r.x, 0.0)).r + + a * texture(tex, uv + vec2(2.0*r.x, 0.0)).r); + + // y = +1 + sum += b * ( + a * texture(tex, uv + vec2(-2.0*r.x, 1.0*r.y)).r + + b * texture(tex, uv + vec2(-1.0*r.x, 1.0*r.y)).r + + c * texture(tex, uv + vec2(0.0, 1.0*r.y)).r + + b * texture(tex, uv + vec2(1.0*r.x, 1.0*r.y)).r + + a * texture(tex, uv + vec2(2.0*r.x, 1.0*r.y)).r); + + // y = +2 + sum += a * ( + a * texture(tex, uv + vec2(-2.0*r.x, 2.0*r.y)).r + + b * texture(tex, uv + vec2(-1.0*r.x, 2.0*r.y)).r + + c * texture(tex, uv + vec2(0.0, 2.0*r.y)).r + + b * texture(tex, uv + vec2(1.0*r.x, 2.0*r.y)).r + + a * texture(tex, uv + vec2(2.0*r.x, 2.0*r.y)).r); + + return sum / norm; +} + +float lst(float edge0, float edge1, float x) { + return clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0); +} + +float sst(float edge0, float edge1, float x) { + return smoothstep(edge0, edge1, x); +} + +float getHeight(vec2 uv) { + float a = texture(u_image, uv).r; + a += 8. * sst(.0, .75, length(uv - .5)); + return a; +} + +vec3 computeNormal(vec2 uv) { + vec2 uTexelSize = vec2(1. / 100.); + float hC = getHeight(uv); + float hR = getHeight(uv + vec2(uTexelSize.x, 0.0)); + float hL = getHeight(uv - vec2(uTexelSize.x, 0.0)); + float hU = getHeight(uv + vec2(0.0, uTexelSize.y)); + float hD = getHeight(uv - vec2(0.0, uTexelSize.y)); + + float dX = (hR - hL); + float dY = (hU - hD); + + return normalize(vec3(-dX, -dY, 1.0)); +} + +mat2 rotZ(float a) { + float c = cos(a), s = sin(a); + return mat2(c, -s, s, c); +} +vec3 rotateAroundZ(vec3 v, float overlayBevel) { + mat2 r = rotZ(overlayBevel); + return vec3(r * v.xy, v.z); +} + + +void main() { + float t = .1 * u_time; + + vec2 uv = v_imageUV; + vec2 dudx = dFdx(v_imageUV); + vec2 dudy = dFdy(v_imageUV); + vec4 img = textureGrad(u_image, uv, dudx, dudy); + + float edge = img.r; + float imgAlpha = img.g; + + float edgeBorder = blurEdge5x5(u_image, uv, 10., edge); + edge = 1. - edgeBorder; + edge *= imgAlpha; + + float frame = getImgFrame(v_imageUV, 0.); + imgAlpha *= frame; + edge *= frame; + + vec3 uLightDir1 = normalize(vec3(.5, .5, .5)); + vec3 uLightDir2 = normalize(vec3(-.5, -.5, .5)); + + uLightDir1 = rotateAroundZ(uLightDir1, 3. * t); + uLightDir2 = rotateAroundZ(uLightDir2, 3. * t); + + vec3 normal = computeNormal(uv); + vec3 viewDir = vec3(0., 0., 1.); + + vec3 materialColor = u_colorBase.rgb; + + vec3 diffuse = vec3(0.); + vec3 specular = vec3(0.); + + float lightCount = max(u_colorsCount, 1.); + float diffuseIntensity = u_lightsDiffuse; + float specularIntensity = 7. * u_lightsSpecular; + + diffuse = materialColor; + float totalNdotL = 0.; + + for (int i = 0; i < ${ logo3dMeta.maxColorCount }; i++) { + if (i >= int(u_colorsCount)) break; + + float fi = (float(i) + .5) / float(u_colorsCount); + float idx = float(i); + float sectorSize = TWO_PI / lightCount; + + // Each light orbits within its sector, never overlapping + float sectorCenter = fi * TWO_PI + u_time * 0.5; + float angleNoise = sin(u_time * 1.1 + idx * 2.3) * 0.3 + + sin(u_time * 0.7 + idx * 3.7) * 0.2 + + sin(u_time * 1.9 + idx * 1.3) * 0.15; + float angle = sectorCenter + angleNoise * sectorSize * u_lightsSpread; + + // Staggered heights so lights are distributed in 3D + float baseElev = 0.33 * HALF_PI; + float heightOffset = sin(fi * PI) * 0.3; + float elevNoise = sin(u_time * 0.8 + idx * 4.1) * 0.25 + + sin(u_time * 1.3 + idx * 2.7) * 0.2 + + cos(u_time * 0.6 + idx * 5.3) * 0.15; + float elevation = baseElev + heightOffset + elevNoise * u_lightsSpread; + elevation = clamp(elevation, 0., HALF_PI); + + float cosElev = cos(elevation); + vec3 L = normalize(vec3(cos(angle) * cosElev, sin(angle) * cosElev, sin(elevation))); + + vec3 lightColor = u_colors[i].rgb; + vec3 halfDir = normalize(L + viewDir); + + float NdotShadow = max(dot(normal, L), 0.); + totalNdotL += pow(NdotShadow, 20.); + + float NdotL = max(dot(normal, L), 0.); + NdotL = pow(NdotL, 5.) * diffuseIntensity; + diffuse = mix(diffuse, lightColor, NdotL); + + float NdotH = dot(normal, halfDir); + specular += pow(NdotH, 800.) * lightColor * specularIntensity; + } + + float opacity = imgAlpha; + vec3 color = diffuse + specular; + color -= clamp(1. - totalNdotL, 0., 1.) * u_lightsShadow; + + color = clamp(color, vec3(0.), vec3(1.)); + color *= opacity; + + vec3 bgColor = u_colorBack.rgb * u_colorBack.a; + color = color + bgColor * (1. - opacity); + opacity = opacity + u_colorBack.a * (1. - opacity); + + fragColor = vec4(color, opacity); +} +`; + +// Configuration for Poisson solver +export const POISSON_CONFIG_OPTIMIZED = { + measurePerformance: false, // Set to true to see performance metrics + workingSize: 512, // Size to solve Poisson at (will upscale to original size) + iterations: 32, // SOR converges ~2-20x faster than standard Gauss-Seidel +}; + +// Precomputed pixel data for sparse processing +interface SparsePixelData { + interiorPixels: Uint32Array; // Indices of interior pixels + boundaryPixels: Uint32Array; // Indices of boundary pixels + pixelCount: number; + // Neighbor indices for each interior pixel (4 neighbors per pixel) + // Layout: [east, west, north, south] for each pixel + neighborIndices: Int32Array; +} + +export function toProcessedLogo3d(file: File | string): Promise<{ imageData: ImageData; pngBlob: Blob }> { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const isBlob = typeof file === 'string' && file.startsWith('blob:'); + + return new Promise((resolve, reject) => { + if (!file || !ctx) { + reject(new Error('Invalid file or canvas context')); + return; + } + + const blobContentTypePromise = isBlob && fetch(file).then((res) => res.headers.get('Content-Type')); + const img = new Image(); + img.crossOrigin = 'anonymous'; + const totalStartTime = performance.now(); + + img.onload = async () => { + // Force SVG to load at a high fidelity size if it's an SVG + let isSVG; + + const blobContentType = await blobContentTypePromise; + + if (blobContentType) { + isSVG = blobContentType === 'image/svg+xml'; + } else if (typeof file === 'string') { + isSVG = file.endsWith('.svg') || file.startsWith('data:image/svg+xml'); + } else { + isSVG = file.type === 'image/svg+xml'; + } + + let originalWidth = img.width || img.naturalWidth; + let originalHeight = img.height || img.naturalHeight; + + if (isSVG) { + // Scale SVG to max dimension while preserving aspect ratio + const svgMaxSize = 4096; + const aspectRatio = originalWidth / originalHeight; + + if (originalWidth > originalHeight) { + originalWidth = svgMaxSize; + originalHeight = svgMaxSize / aspectRatio; + } else { + originalHeight = svgMaxSize; + originalWidth = svgMaxSize * aspectRatio; + } + + img.width = originalWidth; + img.height = originalHeight; + } + + // Always scale to working resolution for consistency + const minDimension = Math.min(originalWidth, originalHeight); + const targetSize = POISSON_CONFIG_OPTIMIZED.workingSize; + + // Calculate scale to fit within workingSize + const scaleFactor = targetSize / minDimension; + const width = Math.round(originalWidth * scaleFactor); + const height = Math.round(originalHeight * scaleFactor); + + if (POISSON_CONFIG_OPTIMIZED.measurePerformance) { + console.log(`[Processing Mode]`); + console.log(` Original: ${originalWidth}×${originalHeight}`); + console.log(` Working: ${width}×${height} (${(scaleFactor * 100).toFixed(1)}% scale)`); + if (scaleFactor < 1) { + console.log(` Speedup: ~${Math.round(1 / (scaleFactor * scaleFactor))}×`); + } + } + + canvas.width = originalWidth; + canvas.height = originalHeight; + + // Use a smaller canvas for shape detection and Poisson solving + const shapeCanvas = document.createElement('canvas'); + shapeCanvas.width = width; + shapeCanvas.height = height; + + const shapeCtx = shapeCanvas.getContext('2d')!; + shapeCtx.drawImage(img, 0, 0, width, height); + + // 1) Build optimized masks using TypedArrays + const startMask = performance.now(); + + const shapeImageData = shapeCtx.getImageData(0, 0, width, height); + const data = shapeImageData.data; + + // Use Uint8Array for masks (1 byte per pixel vs 8+ bytes for boolean array) + const shapeMask = new Uint8Array(width * height); + const boundaryMask = new Uint8Array(width * height); + + // First pass: identify shape pixels + let shapePixelCount = 0; + for (let i = 0, idx = 0; i < data.length; i += 4, idx++) { + const a = data[i + 3]; + const isShape = a === 0 ? 0 : 1; + shapeMask[idx] = isShape; + shapePixelCount += isShape; + } + + // 2) Optimized boundary detection using sparse approach + // Only check shape pixels, not all pixels + const boundaryIndices: number[] = []; + const interiorIndices: number[] = []; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = y * width + x; + if (!shapeMask[idx]) continue; + + // Check if pixel is on boundary (optimized: early exit) + let isBoundary = false; + + // Check 4-connected neighbors first (most common case) + if (x === 0 || x === width - 1 || y === 0 || y === height - 1) { + isBoundary = true; + } else { + // Check all 8 neighbors (including diagonals) for comprehensive boundary detection + isBoundary = + !shapeMask[idx - 1] || // left + !shapeMask[idx + 1] || // right + !shapeMask[idx - width] || // top + !shapeMask[idx + width] || // bottom + !shapeMask[idx - width - 1] || // top-left + !shapeMask[idx - width + 1] || // top-right + !shapeMask[idx + width - 1] || // bottom-left + !shapeMask[idx + width + 1]; // bottom-right + } + + if (isBoundary) { + boundaryMask[idx] = 1; + boundaryIndices.push(idx); + } else { + interiorIndices.push(idx); + } + } + } + + if (POISSON_CONFIG_OPTIMIZED.measurePerformance) { + console.log(`[Mask Building] Time: ${(performance.now() - startMask).toFixed(2)}ms`); + console.log( + ` Shape pixels: ${shapePixelCount} / ${width * height} (${((shapePixelCount / (width * height)) * 100).toFixed(1)}%)` + ); + console.log(` Interior pixels: ${interiorIndices.length}`); + console.log(` Boundary pixels: ${boundaryIndices.length}`); + } + + // 3) Precompute sparse data structure for solver + const sparseData = buildSparseData( + shapeMask, + boundaryMask, + new Uint32Array(interiorIndices), + new Uint32Array(boundaryIndices), + width, + height + ); + + // 4) Solve Poisson equation with optimized sparse solver + const startSolve = performance.now(); + const u = solvePoissonSparse(sparseData, shapeMask, boundaryMask, width, height); + + if (POISSON_CONFIG_OPTIMIZED.measurePerformance) { + console.log(`[Poisson Solve] Time: ${(performance.now() - startSolve).toFixed(2)}ms`); + } + + // 5) Generate output image + let maxVal = 0; + let finalImageData: ImageData; + + // Only check shape pixels for max value + for (let i = 0; i < interiorIndices.length; i++) { + const idx = interiorIndices[i]!; + if (u[idx]! > maxVal) maxVal = u[idx]!; + } + + // Create gradient image at working resolution + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = width; + tempCanvas.height = height; + const tempCtx = tempCanvas.getContext('2d')!; + + const tempImg = tempCtx.createImageData(width, height); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = y * width + x; + const px = idx * 4; + + if (!shapeMask[idx]) { + tempImg.data[px] = 255; + tempImg.data[px + 1] = 255; + tempImg.data[px + 2] = 255; + tempImg.data[px + 3] = 0; // Alpha = 0 for background + } else { + const poissonRatio = u[idx]! / maxVal; + let gray = 255 * (1 - poissonRatio); + tempImg.data[px] = gray; + tempImg.data[px + 1] = gray; + tempImg.data[px + 2] = gray; + tempImg.data[px + 3] = 255; // Alpha = 255 for shape + } + } + } + tempCtx.putImageData(tempImg, 0, 0); + + // Upscale to original resolution with smooth interpolation + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(tempCanvas, 0, 0, width, height, 0, 0, originalWidth, originalHeight); + + // Now get the upscaled image data for final output + const outImg = ctx.getImageData(0, 0, originalWidth, originalHeight); + + // Re-apply edges from original resolution with anti-aliasing + // This ensures edges are pixel-perfect while gradient is smooth + const originalCanvas = document.createElement('canvas'); + originalCanvas.width = originalWidth; + originalCanvas.height = originalHeight; + const originalCtx = originalCanvas.getContext('2d')!; + // originalCtx.fillStyle = "white"; + // originalCtx.fillRect(0, 0, originalWidth, originalHeight); + originalCtx.drawImage(img, 0, 0, originalWidth, originalHeight); + const originalData = originalCtx.getImageData(0, 0, originalWidth, originalHeight); + + // Process each pixel: Red channel = gradient, Alpha channel = original alpha + for (let i = 0; i < outImg.data.length; i += 4) { + const a = originalData.data[i + 3]!; + // Use only alpha to determine background vs shape + const upscaledAlpha = outImg.data[i + 3]!; + if (a === 0) { + // Background pixel + outImg.data[i] = 255; + outImg.data[i + 1] = 0; + } else { + // Red channel carries the gradient + // Check if upscale missed this pixel by looking at alpha channel + // If upscaled alpha is 0, the low-res version thought this was background + outImg.data[i] = upscaledAlpha === 0 ? 0 : outImg.data[i]!; // gradient or 0 + outImg.data[i + 1] = a; // original alpha + } + + // Unused channels fixed + outImg.data[i + 2] = 255; + outImg.data[i + 3] = 255; + } + + ctx.putImageData(outImg, 0, 0); + finalImageData = outImg; + canvas.toBlob((blob) => { + if (!blob) { + reject(new Error('Failed to create PNG blob')); + return; + } + + if (POISSON_CONFIG_OPTIMIZED.measurePerformance) { + const totalTime = performance.now() - totalStartTime; + console.log(`[Total Processing Time] ${totalTime.toFixed(2)}ms`); + if (scaleFactor < 1) { + const estimatedFullResTime = totalTime * Math.pow((originalWidth * originalHeight) / (width * height), 1.5); + console.log(`[Estimated time at full resolution] ~${estimatedFullResTime.toFixed(0)}ms`); + console.log( + `[Time saved] ~${(estimatedFullResTime - totalTime).toFixed(0)}ms (${Math.round(estimatedFullResTime / totalTime)}× faster)` + ); + } + } + + resolve({ + imageData: finalImageData, + pngBlob: blob, + }); + }, 'image/png'); + }; + + img.onerror = () => reject(new Error('Failed to load image')); + img.src = typeof file === 'string' ? file : URL.createObjectURL(file); + }); +} + +function buildSparseData( + shapeMask: Uint8Array, + boundaryMask: Uint8Array, + interiorPixels: Uint32Array, + boundaryPixels: Uint32Array, + width: number, + height: number +): SparsePixelData { + const pixelCount = interiorPixels.length; + + // Build neighbor indices for sparse processing + // For each interior pixel, store indices of its 4 neighbors + // Use -1 for out-of-bounds or non-shape neighbors + const neighborIndices = new Int32Array(pixelCount * 4); + + for (let i = 0; i < pixelCount; i++) { + const idx = interiorPixels[i]!; + const x = idx % width; + const y = Math.floor(idx / width); + + // East neighbor + neighborIndices[i * 4 + 0] = x < width - 1 && shapeMask[idx + 1] ? idx + 1 : -1; + // West neighbor + neighborIndices[i * 4 + 1] = x > 0 && shapeMask[idx - 1] ? idx - 1 : -1; + // North neighbor + neighborIndices[i * 4 + 2] = y > 0 && shapeMask[idx - width] ? idx - width : -1; + // South neighbor + neighborIndices[i * 4 + 3] = y < height - 1 && shapeMask[idx + width] ? idx + width : -1; + } + + return { + interiorPixels, + boundaryPixels, + pixelCount, + neighborIndices, + }; +} + +function solvePoissonSparse( + sparseData: SparsePixelData, + shapeMask: Uint8Array, + boundaryMask: Uint8Array, + width: number, + height: number +): Float32Array { + // This controls how smooth the falloff gradient will be and extend into the shape + const ITERATIONS = POISSON_CONFIG_OPTIMIZED.iterations; + + // Keep C constant - only iterations control gradient spread + const C = 0.01; + + const u = new Float32Array(width * height); + const { interiorPixels, neighborIndices, pixelCount } = sparseData; + + // Performance tracking + const startTime = performance.now(); + + // Red-Black SOR for better symmetry with fewer iterations + // omega between 1.8-1.95 typically gives best convergence for Poisson + const omega = 1.9; + + // Pre-classify pixels as red or black for efficient processing + const redPixels: number[] = []; + const blackPixels: number[] = []; + + for (let i = 0; i < pixelCount; i++) { + const idx = interiorPixels[i]!; + const x = idx % width; + const y = Math.floor(idx / width); + + if ((x + y) % 2 === 0) { + redPixels.push(i); + } else { + blackPixels.push(i); + } + } + + for (let iter = 0; iter < ITERATIONS; iter++) { + // Red pass: update red pixels + for (const i of redPixels) { + const idx = interiorPixels[i]!; + + // Get precomputed neighbor indices + const eastIdx = neighborIndices[i * 4 + 0]!; + const westIdx = neighborIndices[i * 4 + 1]!; + const northIdx = neighborIndices[i * 4 + 2]!; + const southIdx = neighborIndices[i * 4 + 3]!; + + // Sum neighbors (use 0 for out-of-bounds) + let sumN = 0; + if (eastIdx >= 0) sumN += u[eastIdx]!; + if (westIdx >= 0) sumN += u[westIdx]!; + if (northIdx >= 0) sumN += u[northIdx]!; + if (southIdx >= 0) sumN += u[southIdx]!; + + // SOR update: blend new value with old value + const newValue = (C + sumN) / 4; + u[idx] = omega * newValue + (1 - omega) * u[idx]!; + } + + // Black pass: update black pixels + for (const i of blackPixels) { + const idx = interiorPixels[i]!; + + // Get precomputed neighbor indices + const eastIdx = neighborIndices[i * 4 + 0]!; + const westIdx = neighborIndices[i * 4 + 1]!; + const northIdx = neighborIndices[i * 4 + 2]!; + const southIdx = neighborIndices[i * 4 + 3]!; + + // Sum neighbors (use 0 for out-of-bounds) + let sumN = 0; + if (eastIdx >= 0) sumN += u[eastIdx]!; + if (westIdx >= 0) sumN += u[westIdx]!; + if (northIdx >= 0) sumN += u[northIdx]!; + if (southIdx >= 0) sumN += u[southIdx]!; + + // SOR update: blend new value with old value + const newValue = (C + sumN) / 4; + u[idx] = omega * newValue + (1 - omega) * u[idx]!; + } + } + + // Jacobi smoothing passes to eliminate Red-Black SOR checkerboard artifacts + const temp = new Float32Array(width * height); + for (let pass = 0; pass < 2; pass++) { + for (let i = 0; i < pixelCount; i++) { + const idx = interiorPixels[i]!; + const eastIdx = neighborIndices[i * 4 + 0]!; + const westIdx = neighborIndices[i * 4 + 1]!; + const northIdx = neighborIndices[i * 4 + 2]!; + const southIdx = neighborIndices[i * 4 + 3]!; + + let sumN = 0; + if (eastIdx >= 0) sumN += u[eastIdx]!; + if (westIdx >= 0) sumN += u[westIdx]!; + if (northIdx >= 0) sumN += u[northIdx]!; + if (southIdx >= 0) sumN += u[southIdx]!; + + temp[idx] = (C + sumN) / 4; + } + for (let i = 0; i < pixelCount; i++) { + const idx = interiorPixels[i]!; + u[idx] = temp[idx]!; + } + } + + if (POISSON_CONFIG_OPTIMIZED.measurePerformance) { + const elapsed = performance.now() - startTime; + + console.log(`[Optimized Poisson Solver (SOR ω=${omega})]`); + console.log(` Working size: ${width}×${height}`); + console.log(` Iterations: ${ITERATIONS}`); + console.log(` Time: ${elapsed.toFixed(2)}ms`); + console.log(` Interior pixels processed: ${pixelCount}`); + console.log(` Speed: ${((ITERATIONS * pixelCount) / (elapsed * 1000)).toFixed(2)} Mpixels/sec`); + } + + return u; +} + +export interface Logo3dUniforms extends ShaderSizingUniforms { + u_colorBack: [number, number, number, number]; + u_colorBase: [number, number, number, number]; + u_colors: vec4[]; + u_colorsCount: number; + u_image: HTMLImageElement | string | undefined; + u_lightsSpread: number; + u_lightsDiffuse: number; + u_lightsSpecular: number; + u_lightsShadow: number; +} + +export interface Logo3dParams extends ShaderSizingParams, ShaderMotionParams { + colors?: string[]; + colorBack?: string; + colorBase?: string; + image?: HTMLImageElement | string | undefined; + lightsSpread?: number; + lightsDiffuse?: number; + lightsSpecular?: number; + lightsShadow?: number; +}