From 1a3246f255ee985ba5f0645dceaf802ab6b3188c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 7 Sep 2025 12:46:32 -0500 Subject: [PATCH 1/4] chore: uptick --- package.json | 1 + pnpm-lock.yaml | 8 + src/app/(scene)/geo.tsx | 145 +++++++++++----- src/app/(scene)/page-client.tsx | 285 ++++++++++++++++++-------------- src/app/(scene)/presets.ts | 134 +++++++++++++++ 5 files changed, 410 insertions(+), 163 deletions(-) create mode 100644 src/app/(scene)/presets.ts 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..881372a 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,63 @@ 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 + 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 +211,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..3f91268 100644 --- a/src/app/(scene)/page-client.tsx +++ b/src/app/(scene)/page-client.tsx @@ -11,100 +11,80 @@ import { Stats } from '@react-three/drei' import { Canvas, useFrame } from '@react-three/fiber' -import gsap from 'gsap' -import { Suspense, useEffect, useMemo, useRef } from 'react' +import { folder, useControls } from 'leva' +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 + + // 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] - ) + // Update instance transforms efficiently (no popping) + 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) => ( + ))} @@ -112,49 +92,124 @@ function FlowerInstances({ } 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 modulator = useRef(new NoiseModulator()) + const configRef = useRef(modulator.current.getCurrent()) + const animRef = useRef({ gradientRot: 0, phase: 0 }) - const animRef = useAnimations({ - gradientDuration: animation.gradientDuration, - phaseDuration: animation.phaseDuration - }) + const { + metalnessCtl, + opacity, + rotCtl, + roughnessCtl, + scaleCtl, + speed, + ...ranges + } = useControls('controls', { + metalnessCtl: { max: 1, min: 0, step: 0.001, value: 0.27 }, + // static controls + opacity: { max: 0.3, min: 0.005, step: 0.001, value: 0.04 }, + ranges: folder({ + fadeAlphaRange: { max: 1, min: 0, step: 0.01, value: [0.1, 1] }, + fadeWidthRange: { max: 1, min: 0, step: 0.001, value: [0, 0.8] }, + gradientDurationRange: { + max: 120, + min: 0.1, + step: 0.1, + value: [2, 80] + }, + instanceCountRange: { max: 50, min: 1, step: 1, value: [5, 50] }, + // static: opacity/metalness handled above + petalAmpRange: { max: 1, min: -1, step: 0.001, value: [-0.2, 1] }, + petalSegmentsRange: { max: 1024, min: 16, step: 1, value: [16, 1024] }, + petalWidthRange: { + max: 0.2, + min: 0.0001, + step: 0.0001, + value: [0.0005, 0.2] + }, + petalsRange: { max: 40, min: 1, step: 1, value: [1, 30] }, + phaseDurationRange: { max: 60, min: 0.1, step: 0.1, value: [0.5, 40] } + + // static: rot/roughness/scale handled above + }), + rotCtl: { max: 1080, min: -1080, step: 1, value: -214 }, + roughnessCtl: { max: 1, min: 0, step: 0.001, value: 0.52 }, + scaleCtl: { max: 1, min: 0, step: 0.0001, value: 0.33 }, - 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 } + speed: { max: 5, min: 0, step: 0.01, value: 1 } }) - 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 + + // Update config directly via ref (ranges only for animated params) + // Apply UI ranges to modulator before update + modulator.current.setRanges({ + fadeAlpha: { + max: (ranges.fadeAlphaRange as number[])[1], + min: (ranges.fadeAlphaRange as number[])[0] + }, + fadeWidth: { + max: (ranges.fadeWidthRange as number[])[1], + min: (ranges.fadeWidthRange as number[])[0] + }, + gradientDuration: { + max: (ranges.gradientDurationRange as number[])[1], + min: (ranges.gradientDurationRange as number[])[0] + }, + instanceCount: { + max: (ranges.instanceCountRange as number[])[1], + min: (ranges.instanceCountRange as number[])[0] + }, + petalAmp: { + max: (ranges.petalAmpRange as number[])[1], + min: (ranges.petalAmpRange as number[])[0] + }, + petalSegments: { + max: (ranges.petalSegmentsRange as number[])[1], + min: (ranges.petalSegmentsRange as number[])[0] + }, + petalWidth: { + max: (ranges.petalWidthRange as number[])[1], + min: (ranges.petalWidthRange as number[])[0] + }, + petals: { + max: (ranges.petalsRange as number[])[1], + min: (ranges.petalsRange as number[])[0] + }, + phaseDuration: { + max: (ranges.phaseDurationRange as number[])[1], + min: (ranges.phaseDurationRange as number[])[0] + } + // static params not modulated: rot, roughness, scale, metalness, opacity + }) + + 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) % + 360 + animRef.current.gradientRot = + (animRef.current.gradientRot + + (deltaTime * 360) / configRef.current.gradientDuration) % + 360 }) - const instances = ( - - ) + const instances = return ( -
- +
+ {instances} {instances} @@ -167,18 +222,6 @@ function Scene() { ) } -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() { return ( - + diff --git a/src/app/(scene)/presets.ts b/src/app/(scene)/presets.ts new file mode 100644 index 0000000..9524c8e --- /dev/null +++ b/src/app/(scene)/presets.ts @@ -0,0 +1,134 @@ +import { createNoise2D } from 'simplex-noise' + +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 + +const noise2D = createNoise2D() + +export class NoiseModulator { + private time = 0 + private current: PresetConfig + private target: PresetConfig + private offsets: Record + private speeds: Record + private smoothing: Record + private ranges: Record + + constructor() { + this.current = { ...defaults } + this.target = { ...defaults } + + // Random offset for each param to decorrelate noise + this.offsets = Object.keys(defaults).reduce( + (acc, k) => ({ ...acc, [k]: Math.random() * 1000 }), + {} as Record + ) + + // Different speeds for each param (smooth organic pace) + this.speeds = { + fadeAlpha: 0.08, + fadeWidth: 0.06, + gradientDuration: 0.02, + instanceCount: 0.015, + metalness: 0.04, + opacity: 0.09, // Keep but will be overridden + petalAmp: 0.03, + petalSegments: 0.01, + petalWidth: 0.12, // Visible pulsing + petals: 0.025, // Slow morph between petal counts + phaseDuration: 0.02, + rot: 0.035, + roughness: 0.045, + scale: 0.028 + } + + // Per-parameter transition smoothing (1/sec). Higher = faster response + this.smoothing = { + fadeAlpha: 1.5, + fadeWidth: 1.2, + gradientDuration: 0.5, + instanceCount: 1.0, + metalness: 0.9, + opacity: 2.0, + petalAmp: 1.8, + petalSegments: 0.6, + petalWidth: 3.0, + petals: 0.8, + phaseDuration: 0.6, + rot: 1.6, + roughness: 1.0, + scale: 1.4 + } + + // Ranges for modulation (EXTREME for visibility) + this.ranges = { + fadeAlpha: { max: 1, min: 0.1 }, + fadeWidth: { max: 0.8, min: 0 }, + gradientDuration: { max: 80, min: 2 }, + instanceCount: { max: 50, min: 5 }, + metalness: { max: 1, min: 0 }, + opacity: { max: 0.3, min: 0.005 }, + petalAmp: { max: 1, min: -0.2 }, // Can go negative! + petalSegments: { max: 1024, min: 16 }, + petalWidth: { max: 0.2, min: 0.0005 }, // HUGE range for visibility + petals: { max: 30, min: 1 }, // 1-30 petals! + phaseDuration: { max: 40, min: 0.5 }, + rot: { max: 720, min: -720 }, + roughness: { max: 1, min: 0 }, + scale: { max: 0.95, min: 0.01 } // Nearly disappearing to huge + } + } + + update(deltaTime: number): PresetConfig { + this.time += deltaTime + + Object.keys(defaults).forEach(key => { + const k = key as keyof PresetConfig + + const noiseValue = noise2D(this.time * this.speeds[k], this.offsets[k]) + const normalized = (noiseValue + 1) * 0.5 + const range = this.ranges[k] + this.target[k] = (range.min + normalized * (range.max - range.min)) as any + + const cur = this.current[k] as number + const tgt = this.target[k] as number + const s = this.smoothing[k] + const a = 1 - Math.exp(-s * deltaTime) + this.current[k] = (cur + (tgt - cur) * a) as any + }) + + 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 } + }) + } +} From 69e81c68e4c16a2a620c790b724d9e8260379767 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 7 Sep 2025 13:17:20 -0500 Subject: [PATCH 2/4] chore: uptick --- src/app/(scene)/geo.tsx | 2 + src/app/(scene)/page-client.tsx | 78 +++++++++------ src/app/(scene)/presets.ts | 164 +++++++++++++++++++++----------- 3 files changed, 158 insertions(+), 86 deletions(-) diff --git a/src/app/(scene)/geo.tsx b/src/app/(scene)/geo.tsx index 881372a..4649544 100644 --- a/src/app/(scene)/geo.tsx +++ b/src/app/(scene)/geo.tsx @@ -158,6 +158,8 @@ function FlowerBand({ useFrame(() => { const cfg = configRef.current + + if (!cfg) return const p = phaseRef?.current?.phase ?? 0 // Update positions with current config (fast path) diff --git a/src/app/(scene)/page-client.tsx b/src/app/(scene)/page-client.tsx index 3f91268..46234b8 100644 --- a/src/app/(scene)/page-client.tsx +++ b/src/app/(scene)/page-client.tsx @@ -3,13 +3,13 @@ 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 { folder, useControls } from 'leva' import { Suspense, useRef } from 'react' @@ -37,6 +37,8 @@ function FlowerInstances({ useFrame(() => { const cfg = configRef.current + if (!cfg) return + // Update material if (materialRef.current) { materialRef.current.gradientRotation = animRef.current!.gradientRot @@ -91,8 +93,9 @@ function FlowerInstances({ ) } -function Scene() { - const modulator = useRef(new NoiseModulator()) +function Scene({ 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 }) @@ -107,28 +110,32 @@ function Scene() { } = useControls('controls', { metalnessCtl: { max: 1, min: 0, step: 0.001, value: 0.27 }, // static controls - opacity: { max: 0.3, min: 0.005, step: 0.001, value: 0.04 }, + opacity: { max: 0.3, min: 0.005, step: 0.001, value: 0.02 }, ranges: folder({ - fadeAlphaRange: { max: 1, min: 0, step: 0.01, value: [0.1, 1] }, - fadeWidthRange: { max: 1, min: 0, step: 0.001, value: [0, 0.8] }, + // style (tight around prior defaults) + fadeAlphaRange: { max: 1, min: 0, step: 0.01, value: [0.9, 0.96] }, + fadeWidthRange: { max: 1, min: 0, step: 0.001, value: [0.14, 0.18] }, gradientDurationRange: { max: 120, min: 0.1, step: 0.1, - value: [2, 80] + value: [10, 20] }, - instanceCountRange: { max: 50, min: 1, step: 1, value: [5, 50] }, - // static: opacity/metalness handled above - petalAmpRange: { max: 1, min: -1, step: 0.001, value: [-0.2, 1] }, - petalSegmentsRange: { max: 1024, min: 16, step: 1, value: [16, 1024] }, + + // scalars (tight around prior defaults) + instanceCountRange: { max: 50, min: 1, step: 1, value: [48, 50] }, + petalAmpRange: { max: 1, min: -1, step: 0.001, value: [0.33, 0.39] }, + petalSegmentsRange: { max: 1024, min: 16, step: 1, value: [320, 400] }, petalWidthRange: { max: 0.2, min: 0.0001, step: 0.0001, - value: [0.0005, 0.2] + value: [0.018, 0.024] }, - petalsRange: { max: 40, min: 1, step: 1, value: [1, 30] }, - phaseDurationRange: { max: 60, min: 0.1, step: 0.1, value: [0.5, 40] } + petalsRange: { max: 40, min: 1, step: 1, value: [4, 6] }, + + // animation pacing (closer to earlier feel) + phaseDurationRange: { max: 60, min: 0.1, step: 0.1, value: [6, 12] } // static: rot/roughness/scale handled above }), @@ -136,7 +143,7 @@ function Scene() { roughnessCtl: { max: 1, min: 0, step: 0.001, value: 0.52 }, scaleCtl: { max: 1, min: 0, step: 0.0001, value: 0.33 }, - speed: { max: 5, min: 0, step: 0.01, value: 1 } + speed: { max: 5, min: 0, step: 0.01, value: 0.48 } }) // Single useFrame for all updates @@ -197,28 +204,25 @@ function Scene() { // Update animations directly animRef.current.phase = (animRef.current.phase + - (deltaTime * 360) / configRef.current.phaseDuration) % + (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) % + (deltaTime * 360) / + (configRef.current.gradientDuration * + (1 + (parseInt(uid.current, 36) % 7) * 0.12))) % 360 }) const instances = return ( -
- - {instances} - {instances} - - - - {instances} - {instances} - -
+ + {instances} + {instances} + ) } @@ -244,7 +248,18 @@ export default function PageClient() { backgroundIntensity={0.005} /> - + + + + + + + + @@ -253,3 +268,8 @@ export default function PageClient() {
) } + +interface SceneProps extends Partial { + seed?: string | number + timeScale?: number +} diff --git a/src/app/(scene)/presets.ts b/src/app/(scene)/presets.ts index 9524c8e..b0a68a2 100644 --- a/src/app/(scene)/presets.ts +++ b/src/app/(scene)/presets.ts @@ -1,4 +1,4 @@ -import { createNoise2D } from 'simplex-noise' +// Noise removed; we use seeded uniform targets and lerp const defaults = { fadeAlpha: 0.93, @@ -19,98 +19,132 @@ const defaults = { export type PresetConfig = typeof defaults -const noise2D = createNoise2D() +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 smoothing: Record private ranges: Record + private elapsed: Record + private duration: Record + private rng: () => number + private timeScale = 1 - constructor() { + 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]: Math.random() * 1000 }), + (acc, k) => ({ ...acc, [k]: this.rng() * 1000 }), {} as Record ) // Different speeds for each param (smooth organic pace) this.speeds = { - fadeAlpha: 0.08, - fadeWidth: 0.06, - gradientDuration: 0.02, - instanceCount: 0.015, + fadeAlpha: 0.05, + fadeWidth: 0.05, + gradientDuration: 0.015, + instanceCount: 0.01, metalness: 0.04, - opacity: 0.09, // Keep but will be overridden - petalAmp: 0.03, - petalSegments: 0.01, - petalWidth: 0.12, // Visible pulsing - petals: 0.025, // Slow morph between petal counts - phaseDuration: 0.02, - rot: 0.035, - roughness: 0.045, - scale: 0.028 + 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 } - // Per-parameter transition smoothing (1/sec). Higher = faster response - this.smoothing = { - fadeAlpha: 1.5, - fadeWidth: 1.2, - gradientDuration: 0.5, - instanceCount: 1.0, - metalness: 0.9, - opacity: 2.0, - petalAmp: 1.8, - petalSegments: 0.6, - petalWidth: 3.0, - petals: 0.8, - phaseDuration: 0.6, - rot: 1.6, - roughness: 1.0, - scale: 1.4 - } + // 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: 1, min: 0.1 }, - fadeWidth: { max: 0.8, min: 0 }, - gradientDuration: { max: 80, min: 2 }, - instanceCount: { max: 50, min: 5 }, + 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: 1, min: -0.2 }, // Can go negative! - petalSegments: { max: 1024, min: 16 }, - petalWidth: { max: 0.2, min: 0.0005 }, // HUGE range for visibility - petals: { max: 30, min: 1 }, // 1-30 petals! - phaseDuration: { max: 40, min: 0.5 }, + 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 } // Nearly disappearing to huge + 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 { - this.time += deltaTime + const dt = deltaTime * this.timeScale + this.time += dt Object.keys(defaults).forEach(key => { const k = key as keyof PresetConfig - - const noiseValue = noise2D(this.time * this.speeds[k], this.offsets[k]) - const normalized = (noiseValue + 1) * 0.5 - const range = this.ranges[k] - this.target[k] = (range.min + normalized * (range.max - range.min)) as any - - const cur = this.current[k] as number - const tgt = this.target[k] as number - const s = this.smoothing[k] - const a = 1 - Math.exp(-s * deltaTime) - this.current[k] = (cur + (tgt - cur) * a) as any + 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 @@ -131,4 +165,20 @@ export class NoiseModulator { 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) + } } From e6076e61c0b5c6ea7a98d7508e80456cddb14a15 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 7 Sep 2025 13:33:34 -0500 Subject: [PATCH 3/4] chore: uptick --- src/app/(scene)/page-client.tsx | 139 +++++++++++--------------------- 1 file changed, 49 insertions(+), 90 deletions(-) diff --git a/src/app/(scene)/page-client.tsx b/src/app/(scene)/page-client.tsx index 46234b8..34115e8 100644 --- a/src/app/(scene)/page-client.tsx +++ b/src/app/(scene)/page-client.tsx @@ -11,7 +11,7 @@ import { } from '@react-three/drei' import type { ThreeElements } from '@react-three/fiber' import { Canvas, useFrame } from '@react-three/fiber' -import { folder, useControls } from 'leva' +import { useControls } from 'leva' import { Suspense, useRef } from 'react' import * as THREE from 'three' @@ -49,7 +49,6 @@ function FlowerInstances({ materialRef.current.roughness = cfg.roughness } - // Update instance transforms efficiently (no popping) const mesh = meshRef.current if (mesh) { @@ -93,104 +92,52 @@ function FlowerInstances({ ) } -function Scene({ seed, timeScale = 1, ...props }: SceneProps) { +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 { - metalnessCtl, - opacity, - rotCtl, - roughnessCtl, - scaleCtl, - speed, - ...ranges - } = useControls('controls', { - metalnessCtl: { max: 1, min: 0, step: 0.001, value: 0.27 }, - // static controls - opacity: { max: 0.3, min: 0.005, step: 0.001, value: 0.02 }, - ranges: folder({ - // style (tight around prior defaults) - fadeAlphaRange: { max: 1, min: 0, step: 0.01, value: [0.9, 0.96] }, - fadeWidthRange: { max: 1, min: 0, step: 0.001, value: [0.14, 0.18] }, - gradientDurationRange: { - max: 120, - min: 0.1, - step: 0.1, - value: [10, 20] + const { metalnessCtl, opacity, rotCtl, roughnessCtl, scaleCtl, speed } = + useControls(`scene/${uid.current}`, { + metalnessCtl: { + max: 1, + min: 0, + step: 0.001, + value: initValues?.metalnessCtl ?? 0.27 }, - - // scalars (tight around prior defaults) - instanceCountRange: { max: 50, min: 1, step: 1, value: [48, 50] }, - petalAmpRange: { max: 1, min: -1, step: 0.001, value: [0.33, 0.39] }, - petalSegmentsRange: { max: 1024, min: 16, step: 1, value: [320, 400] }, - petalWidthRange: { - max: 0.2, - min: 0.0001, + 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: [0.018, 0.024] + value: initValues?.scaleCtl ?? 0.33 }, - petalsRange: { max: 40, min: 1, step: 1, value: [4, 6] }, - - // animation pacing (closer to earlier feel) - phaseDurationRange: { max: 60, min: 0.1, step: 0.1, value: [6, 12] } - - // static: rot/roughness/scale handled above - }), - rotCtl: { max: 1080, min: -1080, step: 1, value: -214 }, - roughnessCtl: { max: 1, min: 0, step: 0.001, value: 0.52 }, - scaleCtl: { max: 1, min: 0, step: 0.0001, value: 0.33 }, - - speed: { max: 5, min: 0, step: 0.01, value: 0.48 } - }) + speed: { max: 5, min: 0, step: 0.01, value: initValues?.speed ?? 0.48 } + }) // Single useFrame for all updates useFrame((_, dt) => { const deltaTime = dt * speed - // Update config directly via ref (ranges only for animated params) - // Apply UI ranges to modulator before update - modulator.current.setRanges({ - fadeAlpha: { - max: (ranges.fadeAlphaRange as number[])[1], - min: (ranges.fadeAlphaRange as number[])[0] - }, - fadeWidth: { - max: (ranges.fadeWidthRange as number[])[1], - min: (ranges.fadeWidthRange as number[])[0] - }, - gradientDuration: { - max: (ranges.gradientDurationRange as number[])[1], - min: (ranges.gradientDurationRange as number[])[0] - }, - instanceCount: { - max: (ranges.instanceCountRange as number[])[1], - min: (ranges.instanceCountRange as number[])[0] - }, - petalAmp: { - max: (ranges.petalAmpRange as number[])[1], - min: (ranges.petalAmpRange as number[])[0] - }, - petalSegments: { - max: (ranges.petalSegmentsRange as number[])[1], - min: (ranges.petalSegmentsRange as number[])[0] - }, - petalWidth: { - max: (ranges.petalWidthRange as number[])[1], - min: (ranges.petalWidthRange as number[])[0] - }, - petals: { - max: (ranges.petalsRange as number[])[1], - min: (ranges.petalsRange as number[])[0] - }, - phaseDuration: { - max: (ranges.phaseDurationRange as number[])[1], - min: (ranges.phaseDurationRange as number[])[0] - } - // static params not modulated: rot, roughness, scale, metalness, opacity - }) + // No external ranges; use the modulator's internal defaults configRef.current = modulator.current.update(deltaTime) @@ -249,14 +196,25 @@ export default function PageClient() { /> - - - + + @@ -272,4 +230,5 @@ export default function PageClient() { interface SceneProps extends Partial { seed?: string | number timeScale?: number + initValues?: Record } From e365b51f749f052c6c4861feb77ed07644032ea0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 12 Sep 2025 15:56:47 -0500 Subject: [PATCH 4/4] chore: uptick --- src/app/(scene)/page-client.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/(scene)/page-client.tsx b/src/app/(scene)/page-client.tsx index 34115e8..6a1ded5 100644 --- a/src/app/(scene)/page-client.tsx +++ b/src/app/(scene)/page-client.tsx @@ -11,7 +11,8 @@ import { } from '@react-three/drei' import type { ThreeElements } from '@react-three/fiber' import { Canvas, useFrame } from '@react-three/fiber' -import { useControls } from 'leva' +import { Leva, useControls } from 'leva' +import { useSearchParams } from 'next/navigation' import { Suspense, useRef } from 'react' import * as THREE from 'three' @@ -174,6 +175,8 @@ function Scene({ initValues, seed, timeScale = 1, ...props }: SceneProps) { } export default function PageClient() { + const params = useSearchParams() + return ( - + {params.has('stats') || (params.has('dev') && )} + ) }