diff --git a/package.json b/package.json index eb15631..4a15c2d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "next": "15.5.2", "react": "19.1.0", "react-dom": "19.1.0", + "simplex-noise": "^4.0.3", "three": "^0.180.0", "three-bvh-csg": "^0.0.17", "three-stdlib": "^2.36.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8cc079..c649273 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: react-dom: specifier: 19.1.0 version: 19.1.0(react@19.1.0) + simplex-noise: + specifier: ^4.0.3 + version: 4.0.3 three: specifier: ^0.180.0 version: 0.180.0 @@ -2326,6 +2329,9 @@ packages: simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + simplex-noise@4.0.3: + resolution: {integrity: sha512-qSE2I4AngLQG7BXqoZj51jokT4WUXe8mOBrvfOXpci8+6Yu44+/dD5zqDpOx3Ux792eamTd2lLcI8jqFntk/lg==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -5124,6 +5130,8 @@ snapshots: is-arrayish: 0.3.2 optional: true + simplex-noise@4.0.3: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: diff --git a/src/app/(scene)/geo.tsx b/src/app/(scene)/geo.tsx index 530a501..4649544 100644 --- a/src/app/(scene)/geo.tsx +++ b/src/app/(scene)/geo.tsx @@ -3,21 +3,20 @@ import { useMemo } from 'react' import * as THREE from 'three' export default function Geo({ - petalAmp = 0.35, - petalSegments = 360, - petalWidth = 0.02, - petals = 6, + configRef, phaseRef -}: GeoProps) { +}: { + configRef: React.RefObject + phaseRef: React.RefObject<{ phase: number; gradientRot: number }> +}) { + const cfg = configRef.current + return ( ) } @@ -112,50 +111,65 @@ interface FlowerParams { width?: number } -interface GeoProps { - petalAmp?: number - petalSegments?: number - petalWidth?: number - petals?: number - phaseRef?: { current: { phase: number; gradientRot: number } } -} - function FlowerBand({ - amplitude = 0.3, baseRadius = 0.5, - petals = 6, - phaseDeg = 0, + configRef, phaseRef, - segments = 360, - width = 0.02 -}: FlowerParams & { - phaseRef?: { current: { phase: number; gradientRot: number } } + segments = 360 +}: { + baseRadius?: number + configRef: React.RefObject + phaseRef?: React.RefObject<{ phase: number; gradientRot: number }> + segments?: number }) { + const MAX_SEGMENTS = 512 + + const trig = useMemo(() => { + const cosTable = new Float32Array(MAX_SEGMENTS + 1) + const sinTable = new Float32Array(MAX_SEGMENTS + 1) + const twoPi = Math.PI * 2 + + for (let i = 0; i <= MAX_SEGMENTS; i++) { + const t = i / MAX_SEGMENTS + const theta = t * twoPi + cosTable[i] = Math.cos(theta) + sinTable[i] = Math.sin(theta) + } + + return { cosTable, sinTable } + }, []) + const { geom, positions } = useMemo(() => { + // Create with max segments to avoid recreation const g = createFlowerBandGeometry({ - amplitude, + amplitude: 0.5, baseRadius, - petals, - phaseDeg, - segments, - width + petals: 6, + phaseDeg: 0, + segments: MAX_SEGMENTS, + width: 0.05 }) - return { - geom: g, - positions: g.getAttribute('position') as THREE.BufferAttribute - } - }, [amplitude, baseRadius, petals, phaseDeg, segments, width]) + const pos = g.getAttribute('position') as THREE.BufferAttribute + pos.setUsage(THREE.DynamicDrawUsage) + + return { geom: g, positions: pos } + }, [baseRadius]) useFrame(() => { - const p = phaseRef?.current?.phase ?? phaseDeg - updateFlowerPositions(positions, { - amplitude, + const cfg = configRef.current + + if (!cfg) return + const p = phaseRef?.current?.phase ?? 0 + + // Update positions with current config (fast path) + updateFlowerPositionsFast(positions, trig.cosTable, trig.sinTable, { + amplitude: cfg.petalAmp, baseRadius, - petals, + petals: Math.max(1, cfg.petals), phaseDeg: p, - segments, - width + segments: MAX_SEGMENTS, + width: cfg.petalWidth }) }) @@ -199,3 +213,52 @@ function updateFlowerPositions( positions.needsUpdate = true } + +function updateFlowerPositionsFast( + positions: THREE.BufferAttribute, + cosTable: Float32Array, + sinTable: Float32Array, + { + amplitude, + baseRadius, + petals, + phaseDeg, + segments, + width + }: Required +) { + const array = positions.array as Float32Array + const phase = (phaseDeg * Math.PI) / 180 + const invRes = 1 / Math.max(segments, 1) + + for (let i = 0; i <= segments; i++) { + const t = i * invRes + const thetaIdx = Math.round(t * segments) + const cos = cosTable[thetaIdx] + const sin = sinTable[thetaIdx] + + const r = + baseRadius * + (1 + amplitude * Math.sin(petals * (t * Math.PI * 2) + phase)) + + const inner = Math.max(0.0005, r - width) + + const ox = r * cos + const oy = r * sin + const ix = inner * cos + const iy = inner * sin + + const oIndex = i * 2 * 3 + const iIndex = oIndex + 3 + + array[oIndex + 0] = ox + array[oIndex + 1] = oy + array[oIndex + 2] = 0 + + array[iIndex + 0] = ix + array[iIndex + 1] = iy + array[iIndex + 2] = 0 + } + + positions.needsUpdate = true +} diff --git a/src/app/(scene)/page-client.tsx b/src/app/(scene)/page-client.tsx index bfe789d..6a1ded5 100644 --- a/src/app/(scene)/page-client.tsx +++ b/src/app/(scene)/page-client.tsx @@ -3,183 +3,180 @@ import './material' import { - Center, Environment, Instance, Instances, OrbitControls, Stats } from '@react-three/drei' +import type { ThreeElements } from '@react-three/fiber' import { Canvas, useFrame } from '@react-three/fiber' -import gsap from 'gsap' -import { Suspense, useEffect, useMemo, useRef } from 'react' +import { Leva, useControls } from 'leva' +import { useSearchParams } from 'next/navigation' +import { Suspense, useRef } from 'react' import * as THREE from 'three' -import { useSmoothControls } from '@/hooks/use-smooth-controls' - import { FX as Effects } from './effects' import Geo from './geo' - -function useAnimations(config: AnimationConfig) { - const animRef = useRef({ gradientRot: 0, phase: 0 }) - - useEffect(() => { - const t = animRef.current - - const a = gsap.to(t, { - duration: config.phaseDuration, - ease: 'none', - onRepeat: () => { - t.phase = 0 - }, - phase: 360, - repeat: -1 - }) - - const b = gsap.to(t, { - duration: config.gradientDuration, - ease: 'none', - gradientRot: 360, - onRepeat: () => { - t.gradientRot = 0 - }, - repeat: -1 - }) - - return () => { - a.kill() - b.kill() - } - }, [config.phaseDuration, config.gradientDuration]) - - return animRef -} +import { NoiseModulator } from './presets' function FlowerInstances({ animRef, - cfg, - count = 25, - scalars -}: FlowerInstancesProps) { + configRef +}: { + animRef: React.RefObject<{ phase: number; gradientRot: number }> + configRef: React.RefObject +}) { const materialRef = useRef(null) + const meshRef = useRef(null) + const tempMatrix = useRef(new THREE.Matrix4()).current + const tempEuler = useRef(new THREE.Euler()).current + const tempQuat = useRef(new THREE.Quaternion()).current + const tempScale = useRef(new THREE.Vector3()).current + const tempPos = useRef(new THREE.Vector3(0, 0, 0)).current + + useFrame(() => { + const cfg = configRef.current + + if (!cfg) return + + // Update material + if (materialRef.current) { + materialRef.current.gradientRotation = animRef.current!.gradientRot + materialRef.current.fadeAlpha = cfg.fadeAlpha + materialRef.current.fadeWidth = cfg.fadeWidth + materialRef.current.metalness = cfg.metalness + materialRef.current.opacity = cfg.opacity + materialRef.current.roughness = cfg.roughness + } - useFrame( - () => - materialRef.current && - (materialRef.current.gradientRotation = animRef.current.gradientRot) - ) - - const transforms = useMemo( - () => - Array.from({ length: count }).map((_, i, r) => ({ - rotation: (i * Math.PI) / scalars.rot, - scale: 1 - (i / r.length) * scalars.scale - })), - [count, scalars.scale, scalars.rot] - ) + const mesh = meshRef.current + + if (mesh) { + const count = Math.max(1, Math.min(50, Math.floor(cfg.instanceCount))) + const safeRot = Math.abs(cfg.rot) < 0.001 ? 0.001 : cfg.rot + mesh.count = 50 + + for (let i = 0; i < 50; i++) { + const active = i < count + const rot = (i * Math.PI) / safeRot + const s = active ? 1 - (i / count) * cfg.scale : 0 + tempEuler.set(0, 0, rot) + tempQuat.setFromEuler(tempEuler) + tempScale.set(s, s, s) + tempMatrix.compose(tempPos, tempQuat, tempScale) + mesh.setMatrixAt(i, tempMatrix) + } + + mesh.instanceMatrix.needsUpdate = true + } + }) return ( <> - - + + {/* @ts-expect-error - custom mat */} - {transforms.map((t, i) => ( - + {Array.from({ length: 50 }).map((_, i) => ( + ))} ) } -function Scene() { - const animation = useSmoothControls('animation', { - gradientDuration: { max: 30, min: 1, step: 0.1, value: 12 }, - phaseDuration: { max: 30, min: 1, step: 0.1, value: 8 } - }) - - const animRef = useAnimations({ - gradientDuration: animation.gradientDuration, - phaseDuration: animation.phaseDuration - }) +function Scene({ initValues, seed, timeScale = 1, ...props }: SceneProps) { + const uid = useRef((seed ?? Math.random().toString(36).slice(2)).toString()) + const modulator = useRef(new NoiseModulator({ seed: uid.current, timeScale })) + const configRef = useRef(modulator.current.getCurrent()) + const animRef = useRef({ gradientRot: 0, phase: 0 }) - const cfg = useSmoothControls('shape', { - fadeAlpha: { max: 1, min: 0, value: 0.93 }, - fadeWidth: { max: 1, min: 0, value: 0.16 }, - metalness: { max: 1, min: 0, value: 0.27 }, - opacity: { max: 1, min: 0, value: 0.04 }, - roughness: { max: 1, min: 0, value: 0.52 } - }) + const { metalnessCtl, opacity, rotCtl, roughnessCtl, scaleCtl, speed } = + useControls(`scene/${uid.current}`, { + metalnessCtl: { + max: 1, + min: 0, + step: 0.001, + value: initValues?.metalnessCtl ?? 0.27 + }, + opacity: { + max: 0.3, + min: 0.005, + step: 0.001, + value: initValues?.opacity ?? 0.02 + }, + rotCtl: { + max: 1080, + min: -1080, + step: 1, + value: initValues?.rotCtl ?? -214 + }, + roughnessCtl: { + max: 1, + min: 0, + step: 0.001, + value: initValues?.roughnessCtl ?? 0.52 + }, + scaleCtl: { + max: 1, + min: 0, + step: 0.0001, + value: initValues?.scaleCtl ?? 0.33 + }, + speed: { max: 5, min: 0, step: 0.01, value: initValues?.speed ?? 0.48 } + }) - const scalars = useSmoothControls('scalars', { - instanceCount: { max: 50, min: 5, step: 1, value: 50 }, - petalAmp: { max: 1, min: 0, step: 0.001, value: 0.36 }, - petalSegments: { max: 1024, min: 64, step: 1, value: 360 }, - petalWidth: { max: 0.2, min: 0.001, step: 0.001, value: 0.02 }, - petals: { max: 100, min: 2, step: 1, value: 4 }, - rot: { max: 360, min: -360, value: -214 }, - scale: { max: 1, min: 0, step: 0.0001, value: 0.33 } + // Single useFrame for all updates + useFrame((_, dt) => { + const deltaTime = dt * speed + + // No external ranges; use the modulator's internal defaults + + configRef.current = modulator.current.update(deltaTime) + + // Override static controls from Leva + configRef.current.opacity = opacity + configRef.current.metalness = metalnessCtl + configRef.current.roughness = roughnessCtl + configRef.current.rot = rotCtl + configRef.current.scale = scaleCtl + + // Update animations directly + animRef.current.phase = + (animRef.current.phase + + (deltaTime * 360) / + (configRef.current.phaseDuration * + (1 + (parseInt(uid.current, 36) % 5) * 0.15))) % + 360 + animRef.current.gradientRot = + (animRef.current.gradientRot + + (deltaTime * 360) / + (configRef.current.gradientDuration * + (1 + (parseInt(uid.current, 36) % 7) * 0.12))) % + 360 }) - const instances = ( - - ) + const instances = return ( -
- - {instances} - {instances} - - - - {instances} - {instances} - -
+ + {instances} + {instances} + ) } -interface AnimationConfig { - gradientDuration: number - phaseDuration: number -} - -interface FlowerInstancesProps { - animRef: React.RefObject<{ phase: number; gradientRot: number }> - cfg: any - count?: number - scalars: any -} - export default function PageClient() { + const params = useSearchParams() + return ( - + + + + + + + - + {params.has('stats') || (params.has('dev') && )} + ) } + +interface SceneProps extends Partial { + seed?: string | number + timeScale?: number + initValues?: Record +} diff --git a/src/app/(scene)/presets.ts b/src/app/(scene)/presets.ts new file mode 100644 index 0000000..b0a68a2 --- /dev/null +++ b/src/app/(scene)/presets.ts @@ -0,0 +1,184 @@ +// Noise removed; we use seeded uniform targets and lerp + +const defaults = { + fadeAlpha: 0.93, + fadeWidth: 0.16, + gradientDuration: 40, + instanceCount: 50, + metalness: 0.27, + opacity: 0.04, + petalAmp: 0.36, + petalSegments: 360, + petalWidth: 0.02, + petals: 4, + phaseDuration: 15, + rot: -214, + roughness: 0.52, + scale: 0.33 +} + +export type PresetConfig = typeof defaults + +function hashStringToInt(seed: string): number { + let h = 2166136261 >>> 0 + for (let i = 0; i < seed.length; i++) + h = Math.imul(h ^ seed.charCodeAt(i), 16777619) + + return h >>> 0 +} + +function mulberry32(a: number) { + return function () { + a |= 0 + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +export class NoiseModulator { + private time = 0 + private current: PresetConfig + private target: PresetConfig + private from: PresetConfig + private offsets: Record + private speeds: Record + private ranges: Record + private elapsed: Record + private duration: Record + private rng: () => number + private timeScale = 1 + + constructor(opts?: { seed?: number | string; timeScale?: number }) { + this.current = { ...defaults } + this.target = { ...defaults } + this.from = { ...defaults } + + const seedNum = + typeof opts?.seed === 'string' + ? hashStringToInt(opts.seed) + : (opts?.seed ?? (Math.random() * 2 ** 32) >>> 0) + + this.rng = mulberry32(seedNum as number) + this.timeScale = opts?.timeScale ?? 1 + + // Random offset for each param to decorrelate noise + this.offsets = Object.keys(defaults).reduce( + (acc, k) => ({ ...acc, [k]: this.rng() * 1000 }), + {} as Record + ) + + // Different speeds for each param (smooth organic pace) + this.speeds = { + fadeAlpha: 0.05, + fadeWidth: 0.05, + gradientDuration: 0.015, + instanceCount: 0.01, + metalness: 0.04, + opacity: 0.09, + petalAmp: 0.02, + petalSegments: 0.008, + petalWidth: 0.05, + petals: 0.02, + phaseDuration: 0.015, + rot: 0.03, + roughness: 0.04, + scale: 0.02 + } + + // Initialize tween state (targets sampled after ranges are defined) + this.elapsed = Object.keys(defaults).reduce( + (acc, k) => ({ ...acc, [k]: 0 }), + {} as Record + ) + this.duration = Object.keys(defaults).reduce( + (acc, k) => ({ + ...acc, + [k]: this.computeDuration(k as keyof PresetConfig) + }), + {} as Record + ) + + // Ranges for modulation (EXTREME for visibility) + this.ranges = { + fadeAlpha: { max: 0.96, min: 0.9 }, + fadeWidth: { max: 0.18, min: 0.14 }, + gradientDuration: { max: 20, min: 10 }, + instanceCount: { max: 50, min: 48 }, + metalness: { max: 1, min: 0 }, + opacity: { max: 0.3, min: 0.005 }, + petalAmp: { max: 0.39, min: 0.33 }, + petalSegments: { max: 400, min: 320 }, + petalWidth: { max: 0.024, min: 0.018 }, + petals: { max: 6, min: 4 }, + phaseDuration: { max: 12, min: 6 }, + rot: { max: 720, min: -720 }, + roughness: { max: 1, min: 0 }, + scale: { max: 0.95, min: 0.01 } + } + + // First targets within ranges now that ranges exist + Object.keys(defaults).forEach(key => { + const k = key as keyof PresetConfig + this.target[k] = this.sampleInRange(k) as any + }) + } + + update(deltaTime: number): PresetConfig { + const dt = deltaTime * this.timeScale + this.time += dt + + Object.keys(defaults).forEach(key => { + const k = key as keyof PresetConfig + this.elapsed[k] += dt + const d = Math.max(0.0001, this.duration[k]) + const t = Math.min(1, this.elapsed[k] / d) + const a = this.from[k] as number + const b = this.target[k] as number + this.current[k] = (a + (b - a) * t) as any + + if (t >= 1) { + this.from[k] = this.current[k] + this.target[k] = this.sampleInRange(k) as any + this.duration[k] = this.computeDuration(k) + this.elapsed[k] = 0 + } + }) + + return this.current + } + + getCurrent(): PresetConfig { + return this.current + } + + setRanges( + partial: Partial> + ) { + Object.entries(partial).forEach(([k, v]) => { + if (!v) return + const key = k as keyof PresetConfig + const min = Math.min(v.min, v.max) + const max = Math.max(v.min, v.max) + this.ranges[key] = { max, min } + }) + } + + private sampleInRange(key: keyof PresetConfig): number { + const r = this.ranges[key] + const u = this.rng() + + return r.min + u * (r.max - r.min) + } + + private computeDuration(key: keyof PresetConfig): number { + // Derive a pleasant range of durations from the per-param speed + const s = Math.max(0.001, this.speeds[key]) + const base = 0.4 / s // ~3–40s depending on speed + const varMul = 0.7 + this.rng() * 0.6 // 0.7–1.3 variation + + return (base * varMul) / Math.max(0.001, this.timeScale) + } +}