From ee16465c595de66e0e0a27f30645da2193285fe8 Mon Sep 17 00:00:00 2001 From: Umar Ahmed Date: Fri, 16 Jun 2023 02:50:29 -0400 Subject: [PATCH] wip: signals --- src/components/Env/LightComponent.tsx | 58 ++++++++++ src/components/Env/index.tsx | 64 +---------- src/components/Env/useSignals.tsx | 98 ++++++++++++++++ src/components/Outliner/LightListItem.tsx | 131 +++++++++++++++------- src/hooks/useStore.tsx | 73 +++++++++++- 5 files changed, 327 insertions(+), 97 deletions(-) create mode 100644 src/components/Env/LightComponent.tsx create mode 100644 src/components/Env/useSignals.tsx diff --git a/src/components/Env/LightComponent.tsx b/src/components/Env/LightComponent.tsx new file mode 100644 index 0000000..61f2c89 --- /dev/null +++ b/src/components/Env/LightComponent.tsx @@ -0,0 +1,58 @@ +import { useRef } from "react"; +import { Light, useStore } from "../../hooks/useStore"; +import { useSignals } from "./useSignals"; +import { Lightformer } from "@react-three/drei"; +import * as THREE from "three"; +import { LayerMaterial } from "lamina"; +import { LightformerLayers } from "./LightformerLayers"; + +export function LightComponent({ light }: { light: Light }) { + const { + id, + distance, + phi, + theta, + intensity, + shape, + scale, + scaleX, + scaleY, + visible, + rotation, + opacity, + } = light; + + const ref = useRef(null); + + const signals = useStore((state) => state.getSignalsForTarget(id)); + + useSignals(ref, signals); + + return ( + + + + + + ); +} diff --git a/src/components/Env/index.tsx b/src/components/Env/index.tsx index b1a3cc9..3bd3534 100644 --- a/src/components/Env/index.tsx +++ b/src/components/Env/index.tsx @@ -4,7 +4,9 @@ import { LayerMaterial } from "lamina"; import { useControls, folder, LevaInputs } from "leva"; import { useStore } from "../../hooks/useStore"; import { LightformerLayers } from "./LightformerLayers"; -import { useState } from "react"; +import React, { useRef, useState } from "react"; +import { useSignals } from "./useSignals"; +import { LightComponent } from "./LightComponent"; export function Env() { const mode = useStore((state) => state.mode); @@ -72,63 +74,9 @@ export function Env() { near={0.01} > - {lights.map((light) => { - const { - id, - distance, - phi, - theta, - intensity, - shape, - scale, - scaleX, - scaleY, - visible, - rotation, - opacity, - animate, - animationSpeed, - animationFloatIntensity, - animationRotationIntensity, - animationFloatingRange, - } = light; - - return ( - - - - - - - - ); - })} + {lights.map((light) => ( + + ))} ); } diff --git a/src/components/Env/useSignals.tsx b/src/components/Env/useSignals.tsx new file mode 100644 index 0000000..818995a --- /dev/null +++ b/src/components/Env/useSignals.tsx @@ -0,0 +1,98 @@ +import { useFrame } from "@react-three/fiber"; +import React, { useRef } from "react"; +import { Easings, type Easing, type Signal } from "../../hooks/useStore"; + +export function useSignals>( + ref: R, + signals: Signal[] = [] +) { + useFrame(({ clock }) => { + if (!ref.current) { + return; + } + + const dt = clock.getElapsedTime(); + + for (const signal of signals) { + if (signal.animation === "pingpong") { + const t = dt % (signal.duration * 2); + + if (t < signal.duration) { + const v = ease(signal.easing, t / signal.duration); + ref.current[signal.property][signal.axis] = + signal.start + (signal.end - signal.start) * v; + } else { + const v = ease( + signal.easing, + (t - signal.duration) / signal.duration + ); + ref.current[signal.property][signal.axis] = + signal.end + (signal.start - signal.end) * v; + } + } else if (signal.animation === "loop") { + const t = dt % signal.duration; + const v = ease(signal.easing, t / signal.duration); + + ref.current[signal.property][signal.axis] = + signal.start + (signal.end - signal.start) * v; + } else if (signal.animation === "once") { + const t = Math.min(dt, signal.duration); + const v = ease(signal.easing, t / signal.duration); + + ref.current[signal.property][signal.axis] = + signal.start + (signal.end - signal.start) * v; + } + } + + // Manually update the transform matrix + ref.current.updateMatrix(); + }); + + return ref; +} + +function ease(easing: Easing, value: number) { + const [mX1, mY1, mX2, mY2] = Easings[easing]; + const epsilon = 1e-6; + const curveX = (t: number) => { + const v = 1 - t; + return 3 * v * v * t * mX1 + 3 * v * t * t * mX2 + t * t * t; + }; + const curveY = (t: number) => { + const v = 1 - t; + return 3 * v * v * t * mY1 + 3 * v * t * t * mY2 + t * t * t; + }; + const solveCurveX = (x: number) => { + let t2 = x; + let derivative; + let x2; + for (let i = 0; i < 8; i++) { + x2 = curveX(t2) - x; + if (Math.abs(x2) < epsilon) { + return t2; + } + derivative = 3 * t2 * t2 - 6 * t2 * x + 3 * x; + if (Math.abs(derivative) < epsilon) { + break; + } + t2 -= x2 / derivative; + } + let t1 = 1; + let t0 = 0; + t2 = x; + while (t1 > t0) { + x2 = curveX(t2) - x; + if (Math.abs(x2) < epsilon) { + return t2; + } + if (x2 > 0) { + t1 = t2; + } else { + t0 = t2; + } + t2 = (t1 + t0) * 0.5; + } + return t2; + }; + return curveY(solveCurveX(value)); +} diff --git a/src/components/Outliner/LightListItem.tsx b/src/components/Outliner/LightListItem.tsx index 414ca2a..03c2f49 100644 --- a/src/components/Outliner/LightListItem.tsx +++ b/src/components/Outliner/LightListItem.tsx @@ -9,8 +9,9 @@ import { } from "@heroicons/react/24/solid"; import * as ContextMenu from "@radix-ui/react-context-menu"; import clsx from "clsx"; -import { folder, useControls } from "leva"; -import { useStore, Light } from "../../hooks/useStore"; +import { LevaInputs, button, folder, useControls } from "leva"; +import { useStore, Light, Easings } from "../../hooks/useStore"; +import * as THREE from "three"; export function LightListItem({ light }: { light: Light }) { const { @@ -43,6 +44,10 @@ export function LightListItem({ light }: { light: Light }) { const toggleSoloLightById = useStore((state) => state.toggleSoloLightById); const isSolo = useStore((state) => state.isSolo); const textureMaps = useStore((state) => state.textureMaps); + const signals = useStore((state) => state.getSignalsForTarget(id)); + const addSignal = useStore((state) => state.addSignal); + const removeSignalById = useStore((state) => state.removeSignalById); + const updateSignal = useStore((state) => state.updateSignal); useControls(() => { if (selectedLightId !== id) { @@ -224,47 +229,96 @@ export function LightListItem({ light }: { light: Light }) { } })(), - [`animate ~${id}`]: { - label: "Animate", - value: light.animate ?? false, - onChange: (v) => updateLight({ id, animate: v }), - }, + [`Add Signal ~${id}`]: button(() => + addSignal({ + id: THREE.MathUtils.generateUUID(), + targetId: id, + property: "position", + axis: "x", + animation: "pingpong", + name: "New Signal", + start: 0, + end: 1, + duration: 1, + easing: "linear", + }) + ), ...(() => { - if (!light.animate) { + if (signals.length === 0) { return {}; } - return { - [`animationSpeed ~${id}`]: { - label: "Animation Speed", - value: light.animationSpeed ?? 1, - min: 0, - onChange: (v) => updateLight({ id, animationSpeed: v }), - }, - [`animationRotationIntensity ~${id}`]: { - label: "Rotation Intensity", - value: light.animationRotationIntensity ?? 1, - min: 0, - onChange: (v) => - updateLight({ id, animationRotationIntensity: v }), - }, - [`animationFloatIntensity ~${id}`]: { - label: "Float Intensity", - value: light.animationFloatIntensity ?? 1, - min: 0, - onChange: (v) => - updateLight({ id, animationFloatIntensity: v }), - }, - [`animationFloatingRange ~${id}`]: { - label: "Floating Range", - value: light.animationFloatingRange ?? [0, 2], - min: 0, - max: 2, - onChange: (v) => - updateLight({ id, animationFloatingRange: v }), - }, - }; + const folders: Record = {}; + + for (const signal of signals) { + const { + id: signalId, + property, + axis, + animation, + name, + start, + end, + duration, + easing, + } = signal; + + folders[signal.id] = folder({ + [`property ~${id} ~${signalId}`]: { + label: "Property", + value: property, + options: ["position", "rotation", "scale"], + onChange: (v) => + updateSignal({ id: signalId, property: v }), + }, + + [`animation ~${id} ~${signalId}`]: { + label: "Animation", + value: animation, + options: ["pingpong", "loop", "once"], + onChange: (v) => + updateSignal({ id: signalId, animation: v }), + }, + + [`axis ~${id} ~${signalId}`]: { + label: "Axis", + value: axis, + options: ["x", "y", "z"], + onChange: (v) => updateSignal({ id: signalId, axis: v }), + }, + [`start ~${id} ~${signalId}`]: { + label: "Start", + value: start, + onChange: (v) => updateSignal({ id: signalId, start: v }), + }, + [`end ~${id} ~${signalId}`]: { + label: "End", + value: end, + onChange: (v) => updateSignal({ id: signalId, end: v }), + }, + [`duration ~${id} ~${signalId}`]: { + label: "Duration", + value: duration, + min: 0, + onChange: (v) => + updateSignal({ id: signalId, duration: v }), + }, + [`easing ~${id} ~${signalId}`]: { + label: "Easing", + value: easing, + options: Object.keys(Easings), + onChange: (v) => { + updateSignal({ id: signalId, easing: v }); + }, + }, + [`Remove Signal ~${id} ~${signalId}`]: button(() => + removeSignalById(signalId) + ), + }); + } + + return folders; })(), }, { @@ -275,6 +329,7 @@ export function LightListItem({ light }: { light: Light }) { }; } }, [ + signals, selectedLightId, id, name, diff --git a/src/hooks/useStore.tsx b/src/hooks/useStore.tsx index c92dcc2..afc3c06 100644 --- a/src/hooks/useStore.tsx +++ b/src/hooks/useStore.tsx @@ -3,6 +3,44 @@ import create from "zustand"; import { persist } from "zustand/middleware"; import { immer } from "zustand/middleware/immer"; +export type Easing = + | "ease" + | "linear" + | "ease-in" + | "ease-out" + | "ease-in-out" + | "in-out-sine" + | "in-out-quadratic" + | "in-out-cubic" + | "fast-out-slow-in" + | "in-out-back"; + +export const Easings: Record = { + ease: [0.25, 0.1, 0.25, 1], + linear: [0, 0, 1, 1], + "ease-in": [0.42, 0, 1, 1], + "ease-out": [0, 0, 0.58, 1], + "ease-in-out": [0.42, 0, 0.58, 1], + "in-out-sine": [0.45, 0.05, 0.55, 0.95], + "in-out-quadratic": [0.46, 0.03, 0.52, 0.96], + "in-out-cubic": [0.65, 0.05, 0.36, 1], + "fast-out-slow-in": [0.4, 0, 0.2, 1], + "in-out-back": [0.68, -0.55, 0.27, 1.55], +}; + +export type Signal = { + id: string; + name: string; + targetId: string; + property: "position" | "rotation" | "scale"; + axis: "x" | "y" | "z"; + start: number; + end: number; + animation: "loop" | "pingpong" | "once" | "additive"; + easing: Easing; + duration: number; +}; + export type Camera = { id: string; name: string; @@ -69,6 +107,11 @@ type State = { setMode: (mode: State["mode"]) => void; modelUrl: string; isSolo: boolean; + signals: Signal[]; + addSignal: (signal: Signal) => void; + removeSignalById: (id: string) => void; + updateSignal: (signal: Partial) => void; + getSignalsForTarget: (targetId: string) => Signal[]; textureMaps: THREE.Texture[]; cameras: Camera[]; selectedCameraId: string; @@ -101,6 +144,34 @@ export const useStore = create()( setMode: (mode) => set({ mode }), modelUrl: "/911-transformed.glb", isSolo: false, + signals: [ + { + id: THREE.MathUtils.generateUUID(), + name: "Signal 2", + targetId: "light-1", + property: "position", + axis: "x", + start: 0, + end: 1, + animation: "pingpong", + easing: "linear", + duration: 3, + }, + ], + addSignal: (signal: Signal) => + set((state) => void state.signals.push(signal)), + removeSignalById: (id: string) => + set((state) => ({ + signals: state.signals.filter((s) => s.id !== id), + })), + updateSignal: (signal: Partial) => + set((state) => ({ + signals: state.signals.map((s: Signal) => + s.id === signal.id ? { ...s, ...signal } : s + ), + })), + getSignalsForTarget: (targetId: string) => + get().signals.filter((s) => s.targetId === targetId), textureMaps: [], setTextureMaps: (maps: THREE.Texture[]) => set((state) => void (state.textureMaps = maps)), @@ -129,7 +200,7 @@ export const useStore = create()( lights: [ { name: `Light A`, - id: THREE.MathUtils.generateUUID(), + id: "light-1", shape: "rect", type: "solid", color: "#fff",