diff --git a/.changeset/mesh-gradient-v2.md b/.changeset/mesh-gradient-v2.md new file mode 100644 index 00000000..1f702694 --- /dev/null +++ b/.changeset/mesh-gradient-v2.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Mesh Gradient gets eight new smooth presets (Peach, Lagoon, Dusk, Citrus, Mint, Aurora, Ink, Basement), a Simple distortion mode with Softness, Warp and Detail, and motion styles (Drift, Breathe, Swirl, Flow) that loop seamlessly over the timeline duration. Passes receive the timeline duration through a new `updateTimelineDuration` hook. Saved gradients keep their exact look: they load in Advanced mode with their Original motion. diff --git a/packages/shader-lab-react/src/renderer/blob-tracking-inner-effects.ts b/packages/shader-lab-react/src/renderer/blob-tracking-inner-effects.ts index bbb71b32..6a72c979 100644 --- a/packages/shader-lab-react/src/renderer/blob-tracking-inner-effects.ts +++ b/packages/shader-lab-react/src/renderer/blob-tracking-inner-effects.ts @@ -34,6 +34,21 @@ export const INNER_EFFECT_TYPES: readonly ShaderLabEffectLayerType[] = [ "smear", "threshold", "voxel", + "annotations", + "connected-dots", + "displaced-rings", + "erosion", + "flares", + "focus-blur", + "glass", + "gradient-map", + "grain", + "lumen-print", + "outline", + "photocopy", + "photographic-cells", + "relief", + "signal-rot", ] export type ShaderLabBlobInnerEffect = diff --git a/packages/shader-lab-react/src/renderer/create-headless-renderer.ts b/packages/shader-lab-react/src/renderer/create-headless-renderer.ts index 194ba660..30d01df9 100644 --- a/packages/shader-lab-react/src/renderer/create-headless-renderer.ts +++ b/packages/shader-lab-react/src/renderer/create-headless-renderer.ts @@ -80,6 +80,7 @@ export function createHeadlessRenderer( } pipeline.updateLogicalSize(frame.logicalSize) + pipeline.updateTimelineDuration(frame.clock.duration) pipeline.syncLayers(reverseComposition(frame.layers)) if (ownsRenderer) { diff --git a/packages/shader-lab-react/src/renderer/create-webgpu-renderer.ts b/packages/shader-lab-react/src/renderer/create-webgpu-renderer.ts index d99b4071..42b03203 100644 --- a/packages/shader-lab-react/src/renderer/create-webgpu-renderer.ts +++ b/packages/shader-lab-react/src/renderer/create-webgpu-renderer.ts @@ -61,6 +61,7 @@ export async function createWebGPURenderer( } pipeline.updateLogicalSize(frame.logicalSize) + pipeline.updateTimelineDuration(frame.clock.duration) pipeline.syncLayers(reverseComposition(frame.layers)) return pipeline.render(frame.clock.time, frame.clock.delta) }, diff --git a/packages/shader-lab-react/src/renderer/gradient-pass.ts b/packages/shader-lab-react/src/renderer/gradient-pass.ts index c0426416..3ec98266 100644 --- a/packages/shader-lab-react/src/renderer/gradient-pass.ts +++ b/packages/shader-lab-react/src/renderer/gradient-pass.ts @@ -43,6 +43,29 @@ type NoiseMode = | "value" | "voronoi" type TonemapMode = "aces" | "cinematic" | "none" | "reinhard" | "totos" +type MotionStyle = "breathe" | "drift" | "flow" | "off" | "original" | "swirl" + +const MOTION_STYLES: readonly MotionStyle[] = [ + "breathe", + "drift", + "flow", + "off", + "original", + "swirl", +] +const FALLBACK_LOOP_SECONDS = 10 + +function resolveMotionStyle(params: LayerParameterValues): MotionStyle { + if (MOTION_STYLES.includes(params.motionStyle as MotionStyle)) + return params.motionStyle as MotionStyle + return params.animate === false ? "off" : "original" +} + +function unit(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.min(1, Math.max(0, value)) + : fallback +} function hexToRgb(hex: string): [number, number, number] { const normalized = hex.trim().replace("#", "") @@ -80,6 +103,8 @@ export class GradientPass extends PassNode { }[] private readonly pointWeightUniforms: Node[] private readonly timeUniform: Node + private readonly loopPhaseUniform: Node + private readonly coreUniform: Node private readonly vignetteRadiusUniform: Node private readonly vignetteSoftnessUniform: Node private readonly vignetteStrengthUniform: Node @@ -91,6 +116,9 @@ export class GradientPass extends PassNode { private readonly warpScaleUniform: Node private isAnimated = true + private motionStyle: MotionStyle = "original" + private motionSpeed = 0.2 + private loopSeconds = FALLBACK_LOOP_SECONDS private noiseMode: NoiseMode = "simplex" private tonemapMode: TonemapMode = "aces" private warpIterations = 1 @@ -98,6 +126,8 @@ export class GradientPass extends PassNode { constructor(layerId: string) { super(layerId) this.timeUniform = uniform(0) + this.loopPhaseUniform = uniform(1) + this.coreUniform = uniform(0) this.animateUniform = uniform(1) this.aspectUniform = uniform(1) this.activePointsUniform = uniform(5) @@ -133,28 +163,42 @@ export class GradientPass extends PassNode { typeof params.activePoints === "number" ? Math.max(2, Math.min(5, Math.round(params.activePoints))) : 5 + const simple = params.distortionMode === "simple" + const softness = unit(params.softness, 0.6) + const warp = unit(params.warp, 0.3) + const detail = unit(params.detail, 0.2) const noiseSeed = typeof params.noiseSeed === "number" ? params.noiseSeed : 0 - const warpAmount = + const warpAmountRaw = typeof params.warpAmount === "number" ? Math.max(0, params.warpAmount) : 0.18 - const warpScale = + const warpAmount = simple ? warp * 0.75 : warpAmountRaw + const warpScaleRaw = typeof params.warpScale === "number" ? Math.max(0.05, params.warpScale) : 1.4 - const warpIterations = + const warpScale = simple ? 0.55 + warp * 0.6 + detail * 1.6 : warpScaleRaw + const warpIterationsRaw = typeof params.warpIterations === "number" ? Math.max(1, Math.min(5, Math.round(params.warpIterations))) : 1 - const warpDecay = - typeof params.warpDecay === "number" ? Math.max(0.1, params.warpDecay) : 1 - const warpBias = + const warpIterations = simple ? 1 + Math.round(detail * 2) : warpIterationsRaw + const warpDecayRaw = + typeof params.warpDecay === "number" + ? Math.max(0.1, params.warpDecay) + : 1 + const warpDecay = simple ? 1.6 - detail * 0.8 : warpDecayRaw + const warpBiasRaw = typeof params.warpBias === "number" ? Math.max(0, Math.min(1, params.warpBias)) : 0.5 - const vortexAmount = - typeof params.vortexAmount === "number" ? params.vortexAmount : 0.12 + const warpBias = simple ? 0.5 : warpBiasRaw + const vortexAmountRaw = + typeof params.vortexAmount === "number" + ? params.vortexAmount + : 0.12 + const vortexAmount = simple ? 0 : vortexAmountRaw const motionAmount = typeof params.motionAmount === "number" ? Math.max(0, params.motionAmount) @@ -163,9 +207,13 @@ export class GradientPass extends PassNode { typeof params.motionSpeed === "number" ? Math.max(0, params.motionSpeed) : 0.2 - const animateEnabled = params.animate !== false - const falloff = - typeof params.falloff === "number" ? Math.max(0.25, params.falloff) : 1.85 + const motionStyle = resolveMotionStyle(params) + const animateEnabled = motionStyle !== "off" + const falloffRaw = + typeof params.falloff === "number" + ? Math.max(0.25, params.falloff) + : 1.85 + const falloff = simple ? 4.6 - softness * 3.4 : falloffRaw const glowStrength = typeof params.glowStrength === "number" ? Math.max(0, params.glowStrength) @@ -201,6 +249,7 @@ export class GradientPass extends PassNode { this.motionAmountUniform.value = motionAmount this.motionSpeedUniform.value = motionSpeed this.falloffUniform.value = falloff + this.coreUniform.value = simple ? 0.08 + softness * 0.22 : 0 this.glowStrengthUniform.value = glowStrength this.glowThresholdUniform.value = glowThreshold this.grainAmountUniform.value = grainAmount @@ -234,13 +283,14 @@ export class GradientPass extends PassNode { let nextTonemapMode: TonemapMode = "aces" let nextNoiseMode: NoiseMode = "simplex" - switch (params.noiseType) { + const noiseType = simple ? "simplex" : params.noiseType + switch (noiseType) { case "perlin": case "ridge": case "turbulence": case "value": case "voronoi": - nextNoiseMode = params.noiseType + nextNoiseMode = noiseType break default: nextNoiseMode = "simplex" @@ -264,6 +314,8 @@ export class GradientPass extends PassNode { this.isAnimated = animateEnabled && motionSpeed > 0 && (motionAmount > 0 || warpAmount > 0) + this.motionSpeed = motionSpeed + this.updateLoopPhase() let needsRebuild = false @@ -282,6 +334,11 @@ export class GradientPass extends PassNode { needsRebuild = true } + if (motionStyle !== this.motionStyle) { + this.motionStyle = motionStyle + needsRebuild = true + } + if (noiseSeed !== this.noiseSeed) { this.noiseSeed = noiseSeed needsRebuild = true @@ -300,6 +357,19 @@ export class GradientPass extends PassNode { return this.isAnimated } + override updateTimelineDuration(duration: number): boolean { + const next = duration > 0 ? duration : FALLBACK_LOOP_SECONDS + if (next === this.loopSeconds) return false + this.loopSeconds = next + this.updateLoopPhase() + return this.isAnimated + } + + private updateLoopPhase(): void { + const cycles = Math.max(1, Math.round((this.motionSpeed * this.loopSeconds) / 2)) + this.loopPhaseUniform.value = (Math.PI * 2 * cycles) / this.loopSeconds + } + protected override beforeRender(time: number): void { this.timeUniform.value = time } @@ -342,10 +412,17 @@ export class GradientPass extends PassNode { uv().x.mul(2).sub(1), float(1).sub(uv().y).mul(2).sub(1) ) + const original = this.motionStyle === "original" const time = this.timeUniform .mul(this.motionSpeedUniform) .mul(this.animateUniform) - const warpedUv = baseUv.toVar() + const phase = this.timeUniform + .mul(this.loopPhaseUniform) + .mul(this.animateUniform) + const amount = this.motionAmountUniform + const breathe = this.motionStyle === "breathe" + const zoom = breathe ? float(1).add(sin(phase).mul(amount).mul(0.06)) : float(1) + const warpedUv = baseUv.div(zoom).toVar() const biasX = this.warpBiasUniform.mul(2) const biasY = float(1).sub(this.warpBiasUniform).mul(2) @@ -353,11 +430,20 @@ export class GradientPass extends PassNode { const strength = this.warpAmountUniform.div( pow(float(i), this.warpDecayUniform) ) + const flowOffset = + this.motionStyle === "flow" + ? vec2(cos(phase), sin(phase)).mul(amount).mul(0.6) + : vec2(0, 0) const warpInput = warpedUv .mul(this.warpScaleUniform) .add(float(this.noiseSeed).mul(73.7)) - const timeOffsetX = time.mul(0.1).add(float(i * 100)) - const timeOffsetY = time.mul(0.1).add(float(i * 200)) + .add(flowOffset) + const timeOffsetX = original + ? time.mul(0.1).add(float(i * 100)) + : float(i * 100) + const timeOffsetY = original + ? time.mul(0.1).add(float(i * 200)) + : float(i * 200) let noiseX: Node let noiseY: Node @@ -419,7 +505,11 @@ export class GradientPass extends PassNode { dot(warpedUv, warpedUv), float(1e-4) ).sqrt() - const vortexAngle = distanceFromCenter.mul(this.vortexAmountUniform) + const vortex = + this.motionStyle === "swirl" + ? this.vortexAmountUniform.add(sin(phase).mul(amount).mul(1.2)) + : this.vortexAmountUniform + const vortexAngle = distanceFromCenter.mul(vortex) const rotatedUv = vec2( warpedUv.x.mul(cos(vortexAngle)).sub(warpedUv.y.mul(sin(vortexAngle))), warpedUv.x.mul(sin(vortexAngle)).add(warpedUv.y.mul(cos(vortexAngle))) @@ -435,25 +525,37 @@ export class GradientPass extends PassNode { float(1), float(0) ) + const drift = this.motionStyle === "drift" + const harmonic = float(index % 2 === 0 ? 1 : 2) + const legacyOffset = vec2( + sin(time.mul(pointIndex.mul(0.73)).add(pointIndex)), + cos(time.mul(pointIndex.mul(0.41)).add(pointIndex.mul(1.7))) + ).mul(amount) + const driftOffset = vec2( + sin(phase.add(pointIndex.mul(1.3))), + cos(phase.mul(harmonic).add(pointIndex.mul(2.1))).mul(0.8) + ).mul(amount) + let offset: Node = vec2(0, 0) + if (original) offset = legacyOffset + else if (drift) offset = driftOffset const pointPosition = vec2( - this.pointPositionUniforms[index]!.x.add( - sin(time.mul(pointIndex.mul(0.73)).add(pointIndex)).mul( - this.motionAmountUniform - ) - ), - this.pointPositionUniforms[index]!.y.add( - cos(time.mul(pointIndex.mul(0.41)).add(pointIndex.mul(1.7))).mul( - this.motionAmountUniform - ) - ) - ) + this.pointPositionUniforms[index]!.x, + this.pointPositionUniforms[index]!.y + ).add(offset) const delta = rotatedUv.sub(pointPosition) - const distance = max(dot(delta, delta), float(1e-4)).sqrt() + const distance = max( + dot(delta, delta).add(this.coreUniform.mul(this.coreUniform)), + float(1e-4) + ).sqrt() const baseWeight = float(1).div( max(pow(distance, this.falloffUniform), float(1e-4)) ) + const pulse = breathe + ? float(1).add(sin(phase.add(pointIndex.mul(1.25))).mul(amount).mul(0.6)) + : float(1) const weighted = baseWeight .mul(this.pointWeightUniforms[index]!) + .mul(pulse) .mul(active) const color = vec3( this.pointColorUniforms[index]!.red, diff --git a/packages/shader-lab-react/src/renderer/layer-mask.ts b/packages/shader-lab-react/src/renderer/layer-mask.ts index d2d188a7..8d3412a2 100644 --- a/packages/shader-lab-react/src/renderer/layer-mask.ts +++ b/packages/shader-lab-react/src/renderer/layer-mask.ts @@ -201,9 +201,17 @@ export function brushSignedDistance( const toOutside = squaredDistanceTo(paint, false, size, spacingX, spacingY) const field = new Uint16Array(size * size) for (let index = 0; index < field.length; index += 1) { + const x = index % size + const y = Math.floor(index / size) + const toBounds = Math.min( + (x + 0.5) * spacingX, + (size - 0.5 - x) * spacingX, + (y + 0.5) * spacingY, + (size - 0.5 - y) * spacingY + ) const distance = paint[index] !== 0 - ? -Math.sqrt(toOutside[index]!) + ? -Math.min(Math.sqrt(toOutside[index]!), toBounds) : Math.sqrt(toInside[index]!) field[index] = THREE.DataUtils.toHalfFloat( Math.max(-MAX_BRUSH_DISTANCE, Math.min(MAX_BRUSH_DISTANCE, distance)) @@ -256,7 +264,9 @@ export class LayerMaskNode { if (next.shape === "brush") { if (!this.paintTexture) { this.paintTexture = new THREE.DataTexture( - new Uint16Array(CELL_PAINT_SIZE ** 2), + new Uint16Array(CELL_PAINT_SIZE ** 2).fill( + THREE.DataUtils.toHalfFloat(MAX_BRUSH_DISTANCE) + ), CELL_PAINT_SIZE, CELL_PAINT_SIZE, THREE.RedFormat, diff --git a/packages/shader-lab-react/src/renderer/pass-node.ts b/packages/shader-lab-react/src/renderer/pass-node.ts index 9c8bab78..c8c1c503 100644 --- a/packages/shader-lab-react/src/renderer/pass-node.ts +++ b/packages/shader-lab-react/src/renderer/pass-node.ts @@ -226,6 +226,10 @@ export class PassNode { // Default pass has no logical-size-dependent uniforms. } + updateTimelineDuration(_duration: number): boolean { + return false + } + needsContinuousRender(): boolean { return false } diff --git a/packages/shader-lab-react/src/renderer/pattern-pass.ts b/packages/shader-lab-react/src/renderer/pattern-pass.ts index c4bcb378..0168bdb8 100644 --- a/packages/shader-lab-react/src/renderer/pattern-pass.ts +++ b/packages/shader-lab-react/src/renderer/pattern-pass.ts @@ -601,6 +601,12 @@ export class PatternPass extends PassNode { motifs: number texture: THREE.CanvasTexture | null }> { + for (const key of this.motifImages.keys()) { + const separator = key.indexOf("\n") + if (separator >= 0 && key.slice(separator + 1) !== this.motifPalette) { + this.motifImages.delete(key) + } + } const results = await Promise.allSettled( this.motifUrls.map((url) => { const palette = this.motifPalette diff --git a/packages/shader-lab-react/src/renderer/pipeline-manager.ts b/packages/shader-lab-react/src/renderer/pipeline-manager.ts index 3ca6aa4d..3b69a6cb 100644 --- a/packages/shader-lab-react/src/renderer/pipeline-manager.ts +++ b/packages/shader-lab-react/src/renderer/pipeline-manager.ts @@ -207,6 +207,7 @@ export class PipelineManager { private dirty = true private width: number private height: number + private timelineDuration = 0 private logicalWidth: number private logicalHeight: number private rtA: THREE.WebGLRenderTarget @@ -310,6 +311,7 @@ export class PipelineManager { : this.createPass(node) pass.resize(this.width, this.height) pass.updateLogicalSize(this.logicalWidth, this.logicalHeight) + pass.updateTimelineDuration(this.timelineDuration) pass.updateMaskLogicalSize(this.logicalWidth, this.logicalHeight) this.passMap.set(layerId, pass) this.dirty = true @@ -472,6 +474,17 @@ export class PipelineManager { this.dirty = true } + updateTimelineDuration(duration: number): void { + const next = Number.isFinite(duration) ? Math.max(0, duration) : 0 + if (next === this.timelineDuration) return + this.timelineDuration = next + let changed = false + for (const pass of this.passMap.values()) { + changed = pass.updateTimelineDuration(next) || changed + } + if (changed) this.dirty = true + } + updateLogicalSize(size: { height: number; width: number }): void { const nextWidth = Math.max(1, size.width) const nextHeight = Math.max(1, size.height) diff --git a/src/components/editor/editor-canvas-viewport.tsx b/src/components/editor/editor-canvas-viewport.tsx index fbb38cc9..8d81f6ee 100644 --- a/src/components/editor/editor-canvas-viewport.tsx +++ b/src/components/editor/editor-canvas-viewport.tsx @@ -50,6 +50,7 @@ import { import { useAssetStore } from "@/store/asset-store" import { useCanvasHandlesStore } from "@/store/canvas-handles-store" import { useCellPaintStore } from "@/store/cell-paint-store" +import { GradientPointsOverlay } from "@/components/editor/gradient-points-overlay" import { LayoutGridOverlay } from "@/components/editor/layout-grid-overlay" import { useEditorStore } from "@/store/editor-store" import { useLayerStore } from "@/store/layer-store" @@ -444,6 +445,10 @@ export function EditorCanvasViewport() { panning={isSpacePressed} disabled={exportingPreview || !isReady || !!pendingSceneSlug} /> + l.id === state.selectedLayerId) + if (!layer || layer.type !== "gradient") return null + return isEditableLayerChain(state.layers, layer) ? layer : null +} + +function readPoint(params: LayerParameterValues, index: number) { + const position = params[`point${index + 1}Position`] + const weight = params[`point${index + 1}Weight`] + const color = params[`point${index + 1}Color`] + return { + color: typeof color === "string" ? color : "#ffffff", + position: (Array.isArray(position) && position.length === 2 + ? [Number(position[0]), Number(position[1])] + : [0, 0]) as [number, number], + weight: typeof weight === "number" ? weight : 1, + } +} + +const clamp = (value: number, min: number, max: number) => + Math.min(max, Math.max(min, value)) + +export function GradientPointsOverlay({ + panning, + disabled, +}: { + panning: boolean + disabled: boolean +}) { + const layer = useLayerStore(selectGradientLayer) + const host = useRef(null) + const drag = useRef(null) + const colorInput = useRef(null) + const [editing, setEditing] = useState(null) + const [box, setBox] = useState({ width: 1, height: 1 }) + + useEffect(() => { + const element = host.current + if (!element) return + const update = () => + setBox({ + width: Math.max(1, element.clientWidth), + height: Math.max(1, element.clientHeight), + }) + update() + const observer = new ResizeObserver(update) + observer.observe(element) + return () => observer.disconnect() + }, []) + + if (!layer || disabled) + return ( +
+ ) + const id = layer.id + const params = layer.params + const count = clamp( + Math.round(typeof params.activePoints === "number" ? params.activePoints : 5), + 2, + 5 + ) + const halfHeight = box.height / 2 + const toPx = ([x, y]: [number, number]): [number, number] => [ + box.width / 2 + x * halfHeight, + box.height / 2 + y * halfHeight, + ] + const units = (event: ReactPointerEvent): [number, number] => { + const rect = host.current!.getBoundingClientRect() + const scale = rect.height / box.height + return [ + (event.clientX - rect.left - rect.width / 2) / (halfHeight * scale), + (event.clientY - rect.top - rect.height / 2) / (halfHeight * scale), + ] + } + const write = (key: string, value: [number, number] | number | string) => + useLayerStore.getState().updateLayerParam(id, key, value) + + const begin = (index: number) => (event: ReactPointerEvent) => { + if (panning || event.button !== 0 || drag.current) return + event.preventDefault() + event.stopPropagation() + const point = readPoint( + useLayerStore.getState().layers.find((l) => l.id === id)?.params ?? params, + index + ) + drag.current = { + index, + pointer: event.pointerId, + kind: event.altKey ? "weight" : "move", + start: units(event), + original: point.position, + originalWeight: point.weight, + } + useEditorStore.getState().beginInteractiveEdit() + event.currentTarget.setPointerCapture(event.pointerId) + } + const move = (event: ReactPointerEvent) => { + const active = drag.current + if (!active || event.pointerId !== active.pointer) return + const [x, y] = units(event) + if (active.kind === "weight") { + const next = clamp(active.originalWeight - (y - active.start[1]) * 2, MIN_WEIGHT, MAX_WEIGHT) + write(`point${active.index + 1}Weight`, Math.round(next * 100) / 100) + return + } + let dx = x - active.start[0] + let dy = y - active.start[1] + if (event.shiftKey) { + if (Math.abs(dx) > Math.abs(dy)) dy = 0 + else dx = 0 + } + write(`point${active.index + 1}Position`, [ + Math.round(clamp(active.original[0] + dx, -POSITION_LIMIT, POSITION_LIMIT) * 1000) / 1000, + Math.round(clamp(active.original[1] + dy, -POSITION_LIMIT, POSITION_LIMIT) * 1000) / 1000, + ]) + } + const end = (event: ReactPointerEvent) => { + const active = drag.current + if (!active || event.pointerId !== active.pointer) return + drag.current = null + useEditorStore.getState().endInteractiveEdit() + } + const pickColor = (index: number) => { + setEditing(index) + const input = colorInput.current + if (!input) return + input.value = readPoint(params, index).color.slice(0, 7) + input.click() + } + + return ( +
+ + {POINT_NAMES.slice(0, count).map((name, index) => { + const point = readPoint(params, index) + const [x, y] = toPx(point.position) + const reach = 10 + point.weight * 8 + return ( + + + { + event.stopPropagation() + pickColor(index) + }} + onLostPointerCapture={end} + onPointerCancel={end} + onPointerDown={begin(index)} + onPointerMove={move} + onPointerUp={end} + r={9} + stroke="white" + strokeWidth={2.5} + style={{ + cursor: panning ? "inherit" : "grab", + filter: "drop-shadow(0 1px 2px rgba(0,0,0,0.45))", + pointerEvents: "all", + }} + /> + + ) + })} + + { + if (editing === null) return + write(`point${editing + 1}Color`, event.currentTarget.value) + }} + ref={colorInput} + style={ + editing === null + ? undefined + : { + left: toPx(readPoint(params, editing).position)[0], + top: toPx(readPoint(params, editing).position)[1], + } + } + tabIndex={-1} + type="color" + /> +
+ ) +} diff --git a/src/components/editor/properties-sidebar-content.tsx b/src/components/editor/properties-sidebar-content.tsx index cd599ab3..565a6766 100644 --- a/src/components/editor/properties-sidebar-content.tsx +++ b/src/components/editor/properties-sidebar-content.tsx @@ -1013,8 +1013,9 @@ export function SelectedLayerPropertiesContent({
- Randomize all points, distortion, animation, and finish - settings. + New palette and point layout. Distortion, motion and finish + stay. Drag the color points on the canvas; double-click one to + change its color, Alt-drag to change its reach.
diff --git a/src/lib/editor/config/layer-catalog.ts b/src/lib/editor/config/layer-catalog.ts index c02d5f94..95d6df9b 100644 --- a/src/lib/editor/config/layer-catalog.ts +++ b/src/lib/editor/config/layer-catalog.ts @@ -388,6 +388,7 @@ export const NEW_LAYER_TYPES: ReadonlySet = new Set([ export const REWORKED_LAYER_TYPES: ReadonlySet = new Set([ "blob-tracking", + "gradient", "pattern", "plotter", "threshold", diff --git a/src/lib/editor/config/layer-registry.ts b/src/lib/editor/config/layer-registry.ts index c2299dee..264b9158 100644 --- a/src/lib/editor/config/layer-registry.ts +++ b/src/lib/editor/config/layer-registry.ts @@ -1,3 +1,4 @@ +import { MESH_GRADIENT_PRESETS } from "@/lib/editor/mesh-gradient" import { EFFECT_PICKER_ORDER, getLayerLabel, @@ -1014,21 +1015,21 @@ const bloomParams = [ const gradientParams = [ { - defaultValue: "custom", + defaultValue: "peach", group: "Points", key: "preset", label: "Preset", options: [ { label: "Custom", value: "custom" }, - { label: "Forest", value: "aurora" }, - { label: "Ember", value: "sunset" }, - { label: "Abyss", value: "deep-ocean" }, - { label: "Violet", value: "neon-glow" }, + ...MESH_GRADIENT_PRESETS.map((preset) => ({ + label: preset.label, + value: preset.id, + })), ], type: "select", }, { - defaultValue: 3, + defaultValue: 5, group: "Points", key: "activePoints", label: "Active Points", @@ -1038,14 +1039,14 @@ const gradientParams = [ type: "number", }, { - defaultValue: "#0E0C0C", + defaultValue: "#ff7a59", group: "Points", key: "point1Color", label: "Point 1 Color", type: "color", }, { - defaultValue: [-0.82, -0.62] as [number, number], + defaultValue: [-1.15, 0.55] as [number, number], group: "Points", key: "point1Position", label: "Point 1 Position", @@ -1055,7 +1056,7 @@ const gradientParams = [ type: "vec2", }, { - defaultValue: 0.42, + defaultValue: 1.2, group: "Points", key: "point1Weight", label: "Point 1 Weight", @@ -1065,14 +1066,14 @@ const gradientParams = [ type: "number", }, { - defaultValue: "#C1FF00", + defaultValue: "#ffd9b8", group: "Points", key: "point2Color", label: "Point 2 Color", type: "color", }, { - defaultValue: [0.22, 0.72] as [number, number], + defaultValue: [1.05, 0.7] as [number, number], group: "Points", key: "point2Position", label: "Point 2 Position", @@ -1082,7 +1083,7 @@ const gradientParams = [ type: "vec2", }, { - defaultValue: 1.55, + defaultValue: 1, group: "Points", key: "point2Weight", label: "Point 2 Weight", @@ -1092,7 +1093,7 @@ const gradientParams = [ type: "number", }, { - defaultValue: "#B2DAD5", + defaultValue: "#ffb3c7", group: "Points", key: "point3Color", label: "Point 3 Color", @@ -1103,7 +1104,7 @@ const gradientParams = [ }, }, { - defaultValue: [0.88, -0.26] as [number, number], + defaultValue: [0.05, -0.15] as [number, number], group: "Points", key: "point3Position", label: "Point 3 Position", @@ -1117,7 +1118,7 @@ const gradientParams = [ }, }, { - defaultValue: 0.64, + defaultValue: 0.9, group: "Points", key: "point3Weight", label: "Point 3 Weight", @@ -1131,7 +1132,7 @@ const gradientParams = [ }, }, { - defaultValue: "#3B4148", + defaultValue: "#ffc857", group: "Points", key: "point4Color", label: "Point 4 Color", @@ -1142,7 +1143,7 @@ const gradientParams = [ }, }, { - defaultValue: [-0.34, 0.52] as [number, number], + defaultValue: [-0.7, -0.8] as [number, number], group: "Points", key: "point4Position", label: "Point 4 Position", @@ -1156,7 +1157,7 @@ const gradientParams = [ }, }, { - defaultValue: 0.82, + defaultValue: 1, group: "Points", key: "point4Weight", label: "Point 4 Weight", @@ -1170,7 +1171,7 @@ const gradientParams = [ }, }, { - defaultValue: "#F3E7D0", + defaultValue: "#fff1e3", group: "Points", key: "point5Color", label: "Point 5 Color", @@ -1181,7 +1182,7 @@ const gradientParams = [ }, }, { - defaultValue: [0.58, -0.76] as [number, number], + defaultValue: [1.2, -0.7] as [number, number], group: "Points", key: "point5Position", label: "Point 5 Position", @@ -1195,7 +1196,7 @@ const gradientParams = [ }, }, { - defaultValue: 0.48, + defaultValue: 1.1, group: "Points", key: "point5Weight", label: "Point 5 Weight", @@ -1208,6 +1209,54 @@ const gradientParams = [ key: "activePoints", }, }, + { + animatable: false, + defaultValue: "simple", + group: "Distortion", + key: "distortionMode", + label: "Mode", + options: [ + { label: "Simple", value: "simple" }, + { label: "Advanced", value: "advanced" }, + ], + type: "select", + }, + { + defaultValue: 0.7, + description: "How far the colors melt into each other.", + group: "Distortion", + key: "softness", + label: "Softness", + max: 1, + min: 0, + step: 0.01, + type: "number", + visibleWhen: { equals: "simple", key: "distortionMode" }, + }, + { + defaultValue: 0.3, + description: "How much the color fields bend and ripple.", + group: "Distortion", + key: "warp", + label: "Warp", + max: 1, + min: 0, + step: 0.01, + type: "number", + visibleWhen: { equals: "simple", key: "distortionMode" }, + }, + { + defaultValue: 0.1, + description: "Adds finer ripples on top of the large shapes.", + group: "Distortion", + key: "detail", + label: "Detail", + max: 1, + min: 0, + step: 0.01, + type: "number", + visibleWhen: { equals: "simple", key: "distortionMode" }, + }, { defaultValue: "ridge", group: "Distortion", @@ -1222,6 +1271,7 @@ const gradientParams = [ { label: "Turbulence", value: "turbulence" }, ], type: "select", + visibleWhen: { equals: "advanced", key: "distortionMode" }, }, { defaultValue: 70.3, @@ -1232,6 +1282,7 @@ const gradientParams = [ min: 0, step: 0.1, type: "number", + visibleWhen: { equals: "advanced", key: "distortionMode" }, }, { defaultValue: 0.18, @@ -1242,6 +1293,7 @@ const gradientParams = [ min: 0, step: 0.01, type: "number", + visibleWhen: { equals: "advanced", key: "distortionMode" }, }, { defaultValue: 2.35, @@ -1252,6 +1304,7 @@ const gradientParams = [ min: 0.1, step: 0.01, type: "number", + visibleWhen: { equals: "advanced", key: "distortionMode" }, }, { defaultValue: 1, @@ -1262,6 +1315,7 @@ const gradientParams = [ min: 1, step: 1, type: "number", + visibleWhen: { equals: "advanced", key: "distortionMode" }, }, { defaultValue: 1, @@ -1272,6 +1326,7 @@ const gradientParams = [ min: 0.1, step: 0.01, type: "number", + visibleWhen: { equals: "advanced", key: "distortionMode" }, }, { defaultValue: 0.5, @@ -1282,6 +1337,7 @@ const gradientParams = [ min: 0, step: 0.01, type: "number", + visibleWhen: { equals: "advanced", key: "distortionMode" }, }, { defaultValue: 0.69, @@ -1292,6 +1348,7 @@ const gradientParams = [ min: -1, step: 0.01, type: "number", + visibleWhen: { equals: "advanced", key: "distortionMode" }, }, { defaultValue: true, @@ -1299,30 +1356,46 @@ const gradientParams = [ key: "animate", label: "Animate", type: "boolean", + visibleWhen: { equals: "gradient", key: "__internal" }, }, { - defaultValue: 1, + animatable: false, + defaultValue: "breathe", + description: "Every style loops seamlessly over the timeline duration.", + group: "Animation", + key: "motionStyle", + label: "Motion", + options: [ + { label: "Off", value: "off" }, + { label: "Drift", value: "drift" }, + { label: "Breathe", value: "breathe" }, + { label: "Swirl", value: "swirl" }, + { label: "Flow", value: "flow" }, + { label: "Original", value: "original" }, + ], + type: "select", + }, + { + defaultValue: 0.4, group: "Animation", key: "motionAmount", - label: "Motion Amount", + label: "Amount", max: 1, min: 0, step: 0.01, type: "number", + visibleWhen: { key: "motionStyle", notEquals: "off" }, }, { - defaultValue: 0.4, + defaultValue: 0.3, group: "Animation", key: "motionSpeed", - label: "Motion Speed", + label: "Speed", max: 2, min: 0, step: 0.01, type: "number", - visibleWhen: { - equals: true, - key: "animate", - }, + visibleWhen: { key: "motionStyle", notEquals: "off" }, }, { defaultValue: 2.05, @@ -1333,9 +1406,10 @@ const gradientParams = [ min: 0.5, step: 0.01, type: "number", + visibleWhen: { equals: "advanced", key: "distortionMode" }, }, { - defaultValue: "cinematic", + defaultValue: "none", group: "Finish", key: "tonemapMode", label: "Tonemap", @@ -1369,7 +1443,7 @@ const gradientParams = [ type: "number", }, { - defaultValue: 0.08, + defaultValue: 0.04, group: "Finish", key: "grainAmount", label: "Grain", @@ -1379,7 +1453,7 @@ const gradientParams = [ type: "number", }, { - defaultValue: 0.12, + defaultValue: 0, group: "Finish", key: "vignetteStrength", label: "Vignette Strength", diff --git a/src/lib/editor/mesh-gradient.ts b/src/lib/editor/mesh-gradient.ts new file mode 100644 index 00000000..6611caf4 --- /dev/null +++ b/src/lib/editor/mesh-gradient.ts @@ -0,0 +1,289 @@ +import type { LayerParameterValues, ParameterValue } from "@/types/editor" + +type Point = [number, number] +type PresetPoint = { color: string; position: Point; weight: number } +type MeshGradientPreset = { + id: string + label: string + points: PresetPoint[] + softness: number + warp: number + detail: number + motionStyle: string + motionAmount: number + motionSpeed: number + grainAmount: number +} + +export const MESH_GRADIENT_PRESETS: readonly MeshGradientPreset[] = [ + { + id: "peach", + label: "Peach", + points: [ + { color: "#ff7a59", position: [-1.15, 0.55], weight: 1.2 }, + { color: "#ffd9b8", position: [1.05, 0.7], weight: 1 }, + { color: "#ffb3c7", position: [0.05, -0.15], weight: 0.9 }, + { color: "#ffc857", position: [-0.7, -0.8], weight: 1 }, + { color: "#fff1e3", position: [1.2, -0.7], weight: 1.1 }, + ], + softness: 0.7, + warp: 0.3, + detail: 0.1, + motionStyle: "breathe", + motionAmount: 0.4, + motionSpeed: 0.3, + grainAmount: 0.04, + }, + { + id: "lagoon", + label: "Lagoon", + points: [ + { color: "#06303c", position: [-1.2, 0.7], weight: 1.3 }, + { color: "#138a8a", position: [0.1, 0.4], weight: 1 }, + { color: "#7fe0cf", position: [1.1, 0.25], weight: 1 }, + { color: "#2d6fd6", position: [-0.6, -0.65], weight: 0.9 }, + { color: "#e4fbf4", position: [1.05, -0.8], weight: 0.9 }, + ], + softness: 0.6, + warp: 0.4, + detail: 0.15, + motionStyle: "flow", + motionAmount: 0.45, + motionSpeed: 0.3, + grainAmount: 0.05, + }, + { + id: "dusk", + label: "Dusk", + points: [ + { color: "#160f3a", position: [-1.2, 0.75], weight: 1.4 }, + { color: "#5a2a8a", position: [-0.2, 0.35], weight: 1 }, + { color: "#e2557f", position: [0.75, 0.05], weight: 1 }, + { color: "#ff9e6d", position: [-0.45, -0.75], weight: 1 }, + { color: "#ffe08a", position: [1.2, -0.75], weight: 0.9 }, + ], + softness: 0.65, + warp: 0.35, + detail: 0.1, + motionStyle: "swirl", + motionAmount: 0.35, + motionSpeed: 0.25, + grainAmount: 0.05, + }, + { + id: "citrus", + label: "Citrus", + points: [ + { color: "#ff6a00", position: [-1.1, -0.6], weight: 1.2 }, + { color: "#ffc93c", position: [-0.2, 0.55], weight: 1.1 }, + { color: "#a7d930", position: [0.95, 0.6], weight: 0.9 }, + { color: "#fff6c2", position: [0.6, -0.55], weight: 1 }, + { color: "#ff9f1c", position: [1.25, -0.1], weight: 0.8 }, + ], + softness: 0.7, + warp: 0.25, + detail: 0.1, + motionStyle: "drift", + motionAmount: 0.35, + motionSpeed: 0.3, + grainAmount: 0.04, + }, + { + id: "mint", + label: "Mint", + points: [ + { color: "#a8e6cf", position: [-1.1, 0.6], weight: 1.1 }, + { color: "#f3fbf7", position: [0.15, 0.15], weight: 1.2 }, + { color: "#56c596", position: [-0.5, -0.75], weight: 0.9 }, + { color: "#c9ecff", position: [1.1, 0.55], weight: 1 }, + { color: "#e6f5c9", position: [1.15, -0.7], weight: 0.9 }, + ], + softness: 0.75, + warp: 0.2, + detail: 0.05, + motionStyle: "drift", + motionAmount: 0.3, + motionSpeed: 0.25, + grainAmount: 0.03, + }, + { + id: "polar", + label: "Aurora", + points: [ + { color: "#030d14", position: [-0.2, -0.75], weight: 1.5 }, + { color: "#00c2a2", position: [-1.0, 0.45], weight: 1 }, + { color: "#7b2ff7", position: [0.65, 0.6], weight: 1 }, + { color: "#1e8fff", position: [1.25, -0.25], weight: 0.9 }, + { color: "#b6fff6", position: [-0.3, 0.85], weight: 0.6 }, + ], + softness: 0.55, + warp: 0.45, + detail: 0.2, + motionStyle: "flow", + motionAmount: 0.5, + motionSpeed: 0.3, + grainAmount: 0.05, + }, + { + id: "ink", + label: "Ink", + points: [ + { color: "#050608", position: [-1.0, 0.5], weight: 1.4 }, + { color: "#141b2d", position: [0.9, 0.7], weight: 1.2 }, + { color: "#3a1c71", position: [0.2, -0.2], weight: 0.9 }, + { color: "#ff4d00", position: [1.1, -0.65], weight: 0.7 }, + { color: "#0b0b10", position: [-1.1, -0.75], weight: 1.2 }, + ], + softness: 0.6, + warp: 0.3, + detail: 0.15, + motionStyle: "drift", + motionAmount: 0.3, + motionSpeed: 0.25, + grainAmount: 0.06, + }, + { + id: "basement", + label: "Basement", + points: [ + { color: "#0a0a0a", position: [-1.15, 0.6], weight: 1.3 }, + { color: "#ff4d00", position: [0.15, 0.1], weight: 1.1 }, + { color: "#ff8a3d", position: [1.1, 0.65], weight: 0.9 }, + { color: "#ffd8bf", position: [1.1, -0.7], weight: 0.9 }, + { color: "#2a1206", position: [-0.75, -0.8], weight: 1.1 }, + ], + softness: 0.6, + warp: 0.35, + detail: 0.1, + motionStyle: "breathe", + motionAmount: 0.35, + motionSpeed: 0.3, + grainAmount: 0.06, + }, +] + +export const LEGACY_MESH_GRADIENT_PRESETS = new Set([ + "aurora", + "sunset", + "deep-ocean", + "neon-glow", +]) + +export function meshGradientPresetParams( + id: string +): Record | null { + const preset = MESH_GRADIENT_PRESETS.find((entry) => entry.id === id) + if (!preset) return null + const params: Record = { + activePoints: preset.points.length, + distortionMode: "simple", + softness: preset.softness, + warp: preset.warp, + detail: preset.detail, + motionStyle: preset.motionStyle, + motionAmount: preset.motionAmount, + motionSpeed: preset.motionSpeed, + tonemapMode: "none", + glowStrength: 0, + grainAmount: preset.grainAmount, + vignetteStrength: 0, + } + for (const [index, point] of preset.points.entries()) { + params[`point${index + 1}Color`] = point.color + params[`point${index + 1}Position`] = point.position + params[`point${index + 1}Weight`] = point.weight + } + return params +} + +function oklchToHex(l: number, c: number, h: number): string { + for (let chroma = c; chroma >= 0; chroma -= 0.01) { + const a = chroma * Math.cos((h * Math.PI) / 180) + const b = chroma * Math.sin((h * Math.PI) / 180) + const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3 + const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3 + const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3 + const linear = [ + 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_, + -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_, + -0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_, + ] + if (linear.every((value) => value >= -0.0005 && value <= 1.0005)) { + return `#${linear + .map((value) => { + const v = Math.min(1, Math.max(0, value)) + const srgb = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055 + return Math.round(srgb * 255) + .toString(16) + .padStart(2, "0") + }) + .join("")}` + } + } + return "#808080" +} + +const HARMONIES: readonly (readonly number[])[] = [ + [0, 18, 36, -18, -36], + [0, 150, 210, 20, -20], + [0, 120, 240, 30, 150], + [0, 8, -8, 16, -16], + [0, 180, 20, 200, -20], +] + +function shuffled(items: readonly T[], random: () => number): T[] { + const result = [...items] + for (let index = result.length - 1; index > 0; index -= 1) { + const swap = Math.floor(random() * (index + 1)) + ;[result[index], result[swap]] = [result[swap]!, result[index]!] + } + return result +} + +function spreadPoints(count: number, random: () => number): Point[] { + const points: Point[] = [] + for (let attempt = 0; points.length < count && attempt < 400; attempt += 1) { + const candidate: Point = [ + Number((random() * 2.6 - 1.3).toFixed(2)), + Number((random() * 1.7 - 0.85).toFixed(2)), + ] + const clear = points.every( + ([x, y]) => Math.hypot(x - candidate[0], y - candidate[1]) > 0.75 + ) + if (clear || attempt > 300) points.push(candidate) + } + return points +} + +export function shuffleMeshGradient( + random: () => number = Math.random +): LayerParameterValues { + const count = random() < 0.35 ? 4 : 5 + const colors: string[] = [] + if (random() < 0.35) { + const preset = MESH_GRADIENT_PRESETS[Math.floor(random() * MESH_GRADIENT_PRESETS.length)]! + colors.push(...preset.points.map((point) => point.color)) + } else { + const hue = random() * 360 + const harmony = HARMONIES[Math.floor(random() * HARMONIES.length)]! + const dark = random() < 0.5 + const lightness = dark + ? [0.22, 0.38, 0.55, 0.68, 0.82] + : [0.42, 0.62, 0.74, 0.86, 0.95] + for (let index = 0; index < 5; index += 1) { + const l = lightness[index]! + (random() - 0.5) * 0.05 + const edge = index === 0 || index === 4 + const c = (edge ? 0.05 : 0.12) + random() * 0.06 + colors.push(oklchToHex(l, c, (hue + harmony[index]! + 360) % 360)) + } + } + const order = shuffled(colors, random) + const positions = spreadPoints(5, random) + const params: LayerParameterValues = { preset: "custom", activePoints: count } + for (let index = 0; index < 5; index += 1) { + params[`point${index + 1}Color`] = order[index] ?? "#ffffff" + params[`point${index + 1}Position`] = positions[index] ?? [0, 0] + params[`point${index + 1}Weight`] = Number((0.8 + random() * 0.5).toFixed(2)) + } + return params +} diff --git a/src/lib/editor/project-file.ts b/src/lib/editor/project-file.ts index 5b8406dc..ed40465a 100644 --- a/src/lib/editor/project-file.ts +++ b/src/lib/editor/project-file.ts @@ -3,6 +3,7 @@ import { normalizeCompositionForDocument, } from "@/lib/editor/composition" import { modelMaterialDefaults } from "@/lib/editor/config/model-options" +import { LEGACY_MESH_GRADIENT_PRESETS } from "@/lib/editor/mesh-gradient" import { CURRENT_PROJECT_FILE_VERSION } from "./project-version" import { validateLayerHierarchy } from "@/renderer/layer-hierarchy" import { MISSING_DEPTH_ERROR_PREFIX } from "@/renderer/layer-media-error" @@ -726,6 +727,17 @@ export function migrateLayerParams( } } + if (layer.type === "gradient") { + if (params.distortionMode === undefined) params.distortionMode = "advanced" + if (params.motionStyle === undefined) + params.motionStyle = params.animate === false ? "off" : "original" + if ( + typeof params.preset === "string" && + LEGACY_MESH_GRADIENT_PRESETS.has(params.preset) + ) + params.preset = "custom" + } + if (layer.type === "text" && params.backgroundAlpha === undefined) { params.backgroundAlpha = 1 } diff --git a/src/renderer/create-webgpu-renderer.ts b/src/renderer/create-webgpu-renderer.ts index e5687971..868e8770 100644 --- a/src/renderer/create-webgpu-renderer.ts +++ b/src/renderer/create-webgpu-renderer.ts @@ -75,6 +75,7 @@ export async function createWebGPURenderer( } pipeline.updateLogicalSize(frame.logicalSize) + pipeline.updateTimelineDuration(frame.clock.duration) pipeline.updateBackgroundColor(frame.sceneConfig.backgroundColor) pipeline.updateSceneConfig(frame.sceneConfig) pipeline.updateOutputCropAspectRatio(frame.cropAspectRatio) diff --git a/src/renderer/gradient-pass.ts b/src/renderer/gradient-pass.ts index 6ed9c9a9..49df54f4 100644 --- a/src/renderer/gradient-pass.ts +++ b/src/renderer/gradient-pass.ts @@ -43,6 +43,29 @@ type NoiseMode = | "value" | "voronoi" type TonemapMode = "aces" | "cinematic" | "none" | "reinhard" | "totos" +type MotionStyle = "breathe" | "drift" | "flow" | "off" | "original" | "swirl" + +const MOTION_STYLES: readonly MotionStyle[] = [ + "breathe", + "drift", + "flow", + "off", + "original", + "swirl", +] +const FALLBACK_LOOP_SECONDS = 10 + +function resolveMotionStyle(params: LayerParameterValues): MotionStyle { + if (MOTION_STYLES.includes(params.motionStyle as MotionStyle)) + return params.motionStyle as MotionStyle + return params.animate === false ? "off" : "original" +} + +function unit(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.min(1, Math.max(0, value)) + : fallback +} function hexToRgb(hex: string): [number, number, number] { const normalized = hex.trim().replace("#", "") @@ -80,6 +103,8 @@ export class GradientPass extends PassNode { }[] private readonly pointWeightUniforms: Node[] private readonly timeUniform: Node + private readonly loopPhaseUniform: Node + private readonly coreUniform: Node private readonly vignetteRadiusUniform: Node private readonly vignetteSoftnessUniform: Node private readonly vignetteStrengthUniform: Node @@ -91,6 +116,9 @@ export class GradientPass extends PassNode { private readonly warpScaleUniform: Node private isAnimated = true + private motionStyle: MotionStyle = "original" + private motionSpeed = 0.2 + private loopSeconds = FALLBACK_LOOP_SECONDS private noiseMode: NoiseMode = "simplex" private tonemapMode: TonemapMode = "aces" private warpIterations = 1 @@ -98,6 +126,8 @@ export class GradientPass extends PassNode { constructor(layerId: string) { super(layerId) this.timeUniform = uniform(0) + this.loopPhaseUniform = uniform(1) + this.coreUniform = uniform(0) this.animateUniform = uniform(1) this.aspectUniform = uniform(1) this.activePointsUniform = uniform(5) @@ -133,28 +163,42 @@ export class GradientPass extends PassNode { typeof params.activePoints === "number" ? Math.max(2, Math.min(5, Math.round(params.activePoints))) : 5 + const simple = params.distortionMode === "simple" + const softness = unit(params.softness, 0.6) + const warp = unit(params.warp, 0.3) + const detail = unit(params.detail, 0.2) const noiseSeed = typeof params.noiseSeed === "number" ? params.noiseSeed : 0 - const warpAmount = + const warpAmountRaw = typeof params.warpAmount === "number" ? Math.max(0, params.warpAmount) : 0.18 - const warpScale = + const warpAmount = simple ? warp * 0.75 : warpAmountRaw + const warpScaleRaw = typeof params.warpScale === "number" ? Math.max(0.05, params.warpScale) : 1.4 - const warpIterations = + const warpScale = simple ? 0.55 + warp * 0.6 + detail * 1.6 : warpScaleRaw + const warpIterationsRaw = typeof params.warpIterations === "number" ? Math.max(1, Math.min(5, Math.round(params.warpIterations))) : 1 - const warpDecay = - typeof params.warpDecay === "number" ? Math.max(0.1, params.warpDecay) : 1 - const warpBias = + const warpIterations = simple ? 1 + Math.round(detail * 2) : warpIterationsRaw + const warpDecayRaw = + typeof params.warpDecay === "number" + ? Math.max(0.1, params.warpDecay) + : 1 + const warpDecay = simple ? 1.6 - detail * 0.8 : warpDecayRaw + const warpBiasRaw = typeof params.warpBias === "number" ? Math.max(0, Math.min(1, params.warpBias)) : 0.5 - const vortexAmount = - typeof params.vortexAmount === "number" ? params.vortexAmount : 0.12 + const warpBias = simple ? 0.5 : warpBiasRaw + const vortexAmountRaw = + typeof params.vortexAmount === "number" + ? params.vortexAmount + : 0.12 + const vortexAmount = simple ? 0 : vortexAmountRaw const motionAmount = typeof params.motionAmount === "number" ? Math.max(0, params.motionAmount) @@ -163,9 +207,13 @@ export class GradientPass extends PassNode { typeof params.motionSpeed === "number" ? Math.max(0, params.motionSpeed) : 0.2 - const animateEnabled = params.animate !== false - const falloff = - typeof params.falloff === "number" ? Math.max(0.25, params.falloff) : 1.85 + const motionStyle = resolveMotionStyle(params) + const animateEnabled = motionStyle !== "off" + const falloffRaw = + typeof params.falloff === "number" + ? Math.max(0.25, params.falloff) + : 1.85 + const falloff = simple ? 4.6 - softness * 3.4 : falloffRaw const glowStrength = typeof params.glowStrength === "number" ? Math.max(0, params.glowStrength) @@ -201,6 +249,7 @@ export class GradientPass extends PassNode { this.motionAmountUniform.value = motionAmount this.motionSpeedUniform.value = motionSpeed this.falloffUniform.value = falloff + this.coreUniform.value = simple ? 0.08 + softness * 0.22 : 0 this.glowStrengthUniform.value = glowStrength this.glowThresholdUniform.value = glowThreshold this.grainAmountUniform.value = grainAmount @@ -234,13 +283,14 @@ export class GradientPass extends PassNode { let nextTonemapMode: TonemapMode = "aces" let nextNoiseMode: NoiseMode = "simplex" - switch (params.noiseType) { + const noiseType = simple ? "simplex" : params.noiseType + switch (noiseType) { case "perlin": case "ridge": case "turbulence": case "value": case "voronoi": - nextNoiseMode = params.noiseType + nextNoiseMode = noiseType break default: nextNoiseMode = "simplex" @@ -264,6 +314,8 @@ export class GradientPass extends PassNode { this.isAnimated = animateEnabled && motionSpeed > 0 && (motionAmount > 0 || warpAmount > 0) + this.motionSpeed = motionSpeed + this.updateLoopPhase() let needsRebuild = false @@ -282,6 +334,11 @@ export class GradientPass extends PassNode { needsRebuild = true } + if (motionStyle !== this.motionStyle) { + this.motionStyle = motionStyle + needsRebuild = true + } + if (noiseSeed !== this.noiseSeed) { this.noiseSeed = noiseSeed needsRebuild = true @@ -300,6 +357,19 @@ export class GradientPass extends PassNode { return this.isAnimated } + override updateTimelineDuration(duration: number): boolean { + const next = duration > 0 ? duration : FALLBACK_LOOP_SECONDS + if (next === this.loopSeconds) return false + this.loopSeconds = next + this.updateLoopPhase() + return this.isAnimated + } + + private updateLoopPhase(): void { + const cycles = Math.max(1, Math.round((this.motionSpeed * this.loopSeconds) / 2)) + this.loopPhaseUniform.value = (Math.PI * 2 * cycles) / this.loopSeconds + } + protected override beforeRender(time: number): void { this.timeUniform.value = time } @@ -342,10 +412,17 @@ export class GradientPass extends PassNode { uv().x.mul(2).sub(1), float(1).sub(uv().y).mul(2).sub(1) ) + const original = this.motionStyle === "original" const time = this.timeUniform .mul(this.motionSpeedUniform) .mul(this.animateUniform) - const warpedUv = baseUv.toVar() + const phase = this.timeUniform + .mul(this.loopPhaseUniform) + .mul(this.animateUniform) + const amount = this.motionAmountUniform + const breathe = this.motionStyle === "breathe" + const zoom = breathe ? float(1).add(sin(phase).mul(amount).mul(0.06)) : float(1) + const warpedUv = baseUv.div(zoom).toVar() const biasX = this.warpBiasUniform.mul(2) const biasY = float(1).sub(this.warpBiasUniform).mul(2) @@ -353,11 +430,20 @@ export class GradientPass extends PassNode { const strength = this.warpAmountUniform.div( pow(float(i), this.warpDecayUniform) ) + const flowOffset = + this.motionStyle === "flow" + ? vec2(cos(phase), sin(phase)).mul(amount).mul(0.6) + : vec2(0, 0) const warpInput = warpedUv .mul(this.warpScaleUniform) .add(float(this.noiseSeed).mul(73.7)) - const timeOffsetX = time.mul(0.1).add(float(i * 100)) - const timeOffsetY = time.mul(0.1).add(float(i * 200)) + .add(flowOffset) + const timeOffsetX = original + ? time.mul(0.1).add(float(i * 100)) + : float(i * 100) + const timeOffsetY = original + ? time.mul(0.1).add(float(i * 200)) + : float(i * 200) let noiseX: Node let noiseY: Node @@ -418,7 +504,11 @@ export class GradientPass extends PassNode { dot(warpedUv, warpedUv), float(1e-4) ).sqrt() - const vortexAngle = distanceFromCenter.mul(this.vortexAmountUniform) + const vortex = + this.motionStyle === "swirl" + ? this.vortexAmountUniform.add(sin(phase).mul(amount).mul(1.2)) + : this.vortexAmountUniform + const vortexAngle = distanceFromCenter.mul(vortex) const rotatedUv = vec2( warpedUv.x.mul(cos(vortexAngle)).sub(warpedUv.y.mul(sin(vortexAngle))), warpedUv.x.mul(sin(vortexAngle)).add(warpedUv.y.mul(cos(vortexAngle))) @@ -434,25 +524,37 @@ export class GradientPass extends PassNode { float(1), float(0) ) + const drift = this.motionStyle === "drift" + const harmonic = float(index % 2 === 0 ? 1 : 2) + const legacyOffset = vec2( + sin(time.mul(pointIndex.mul(0.73)).add(pointIndex)), + cos(time.mul(pointIndex.mul(0.41)).add(pointIndex.mul(1.7))) + ).mul(amount) + const driftOffset = vec2( + sin(phase.add(pointIndex.mul(1.3))), + cos(phase.mul(harmonic).add(pointIndex.mul(2.1))).mul(0.8) + ).mul(amount) + let offset: Node = vec2(0, 0) + if (original) offset = legacyOffset + else if (drift) offset = driftOffset const pointPosition = vec2( - this.pointPositionUniforms[index]!.x.add( - sin(time.mul(pointIndex.mul(0.73)).add(pointIndex)).mul( - this.motionAmountUniform - ) - ), - this.pointPositionUniforms[index]!.y.add( - cos(time.mul(pointIndex.mul(0.41)).add(pointIndex.mul(1.7))).mul( - this.motionAmountUniform - ) - ) - ) + this.pointPositionUniforms[index]!.x, + this.pointPositionUniforms[index]!.y + ).add(offset) const delta = rotatedUv.sub(pointPosition) - const distance = max(dot(delta, delta), float(1e-4)).sqrt() + const distance = max( + dot(delta, delta).add(this.coreUniform.mul(this.coreUniform)), + float(1e-4) + ).sqrt() const baseWeight = float(1).div( max(pow(distance, this.falloffUniform), float(1e-4)) ) + const pulse = breathe + ? float(1).add(sin(phase.add(pointIndex.mul(1.25))).mul(amount).mul(0.6)) + : float(1) const weighted = baseWeight .mul(this.pointWeightUniforms[index]!) + .mul(pulse) .mul(active) const color = vec3( this.pointColorUniforms[index]!.red, diff --git a/src/renderer/layer-mask.ts b/src/renderer/layer-mask.ts index 317644ab..e33d4328 100644 --- a/src/renderer/layer-mask.ts +++ b/src/renderer/layer-mask.ts @@ -201,9 +201,17 @@ export function brushSignedDistance( const toOutside = squaredDistanceTo(paint, false, size, spacingX, spacingY) const field = new Uint16Array(size * size) for (let index = 0; index < field.length; index += 1) { + const x = index % size + const y = Math.floor(index / size) + const toBounds = Math.min( + (x + 0.5) * spacingX, + (size - 0.5 - x) * spacingX, + (y + 0.5) * spacingY, + (size - 0.5 - y) * spacingY + ) const distance = paint[index] !== 0 - ? -Math.sqrt(toOutside[index]!) + ? -Math.min(Math.sqrt(toOutside[index]!), toBounds) : Math.sqrt(toInside[index]!) field[index] = THREE.DataUtils.toHalfFloat( Math.max(-MAX_BRUSH_DISTANCE, Math.min(MAX_BRUSH_DISTANCE, distance)) @@ -256,7 +264,9 @@ export class LayerMaskNode { if (next.shape === "brush") { if (!this.paintTexture) { this.paintTexture = new THREE.DataTexture( - new Uint16Array(CELL_PAINT_SIZE ** 2), + new Uint16Array(CELL_PAINT_SIZE ** 2).fill( + THREE.DataUtils.toHalfFloat(MAX_BRUSH_DISTANCE) + ), CELL_PAINT_SIZE, CELL_PAINT_SIZE, THREE.RedFormat, diff --git a/src/renderer/pass-node.ts b/src/renderer/pass-node.ts index 40ebc42b..a5e908d7 100644 --- a/src/renderer/pass-node.ts +++ b/src/renderer/pass-node.ts @@ -231,6 +231,10 @@ export class PassNode { return false } + updateTimelineDuration(_duration: number): boolean { + return false + } + updateOutputCropAspectRatio(_ratio: number | null): boolean { return false } diff --git a/src/renderer/pattern-pass.ts b/src/renderer/pattern-pass.ts index 064e9301..a08ed68e 100644 --- a/src/renderer/pattern-pass.ts +++ b/src/renderer/pattern-pass.ts @@ -601,6 +601,12 @@ export class PatternPass extends PassNode { motifs: number texture: THREE.CanvasTexture | null }> { + for (const key of this.motifImages.keys()) { + const separator = key.indexOf("\n") + if (separator >= 0 && key.slice(separator + 1) !== this.motifPalette) { + this.motifImages.delete(key) + } + } const results = await Promise.allSettled( this.motifUrls.map((url) => { const palette = this.motifPalette diff --git a/src/renderer/pipeline-manager.ts b/src/renderer/pipeline-manager.ts index ddd447a6..eb4fa17c 100644 --- a/src/renderer/pipeline-manager.ts +++ b/src/renderer/pipeline-manager.ts @@ -203,6 +203,7 @@ export class PipelineManager { private width: number private height: number private logicalWidth: number + private timelineDuration = 0 private logicalHeight: number private sceneConfig: SceneConfig | null = null private readonly baseMaterial: THREE.MeshBasicMaterial @@ -325,6 +326,7 @@ export class PipelineManager { : this.createPass(node.layer) pass.resize(this.width, this.height) pass.updateLogicalSize(this.logicalWidth, this.logicalHeight) + pass.updateTimelineDuration(this.timelineDuration) this.syncMaskFrame(pass) this.passMap.set(layerId, pass) this.markDirty() @@ -507,6 +509,17 @@ export class PipelineManager { this.markDirty() } + updateTimelineDuration(duration: number): void { + const next = Number.isFinite(duration) ? Math.max(0, duration) : 0 + if (next === this.timelineDuration) return + this.timelineDuration = next + let changed = false + for (const pass of this.passMap.values()) { + changed = pass.updateTimelineDuration(next) || changed + } + if (changed) this.markDirty() + } + updateLogicalSize(size: Size): void { const nextWidth = Math.max(1, size.width) const nextHeight = Math.max(1, size.height) diff --git a/src/store/canvas-handles-store.ts b/src/store/canvas-handles-store.ts index 578b0e81..72e83ecb 100644 --- a/src/store/canvas-handles-store.ts +++ b/src/store/canvas-handles-store.ts @@ -3,6 +3,12 @@ import { useLayerStore } from "@/store/layer-store" const HANDLE_PARAMS: Partial>> = { annotations: new Set(["targetCenter", "targetSize"]), + gradient: new Set( + [1, 2, 3, 4, 5].flatMap((point) => [ + `point${point}Position`, + `point${point}Weight`, + ]) + ), model: new Set(["elevation", "location", "orbit", "rotation", "scale", "shift"]), shape: new Set(["center", "rotation", "size"]), text: new Set(["fontSize", "offset", "rotation"]), diff --git a/src/store/layer-store.ts b/src/store/layer-store.ts index 72b94ea4..3201e7cf 100644 --- a/src/store/layer-store.ts +++ b/src/store/layer-store.ts @@ -22,6 +22,7 @@ import { import { normalizeTextFontWeight } from "@/lib/editor/text-fonts" import { validateLayerHierarchy } from "@/renderer/layer-hierarchy" import { useAudioStore } from "@/store/audio-store" +import { meshGradientPresetParams, shuffleMeshGradient } from "@/lib/editor/mesh-gradient" import { useEditorStore } from "@/store/editor-store" import { useTimelineStore } from "@/store/timeline-store" import type { @@ -29,7 +30,6 @@ import type { EditorLayer, FluidInteractionEvent, LayerCompositeMode, - LayerParameterValues, LayerType, MaskConfig, ParameterValue, @@ -142,273 +142,6 @@ function getGradientNoiseDefaults(noiseType: string): { } } -function getGradientPresetDefaults( - preset: string -): Record | null { - switch (preset) { - case "aurora": - return { - activePoints: 5, - point1Color: "#ed6a5a", - point1Position: [-0.8, -0.6], - point1Weight: 1.0, - point2Color: "#f4f1bb", - point2Position: [0.2, 0.7], - point2Weight: 1.0, - point3Color: "#9bc1bc", - point3Position: [0.9, -0.3], - point3Weight: 1.0, - point4Color: "#5d576b", - point4Position: [-0.4, 0.5], - point4Weight: 1.0, - point5Color: "#e6ebe0", - point5Position: [0.6, -0.8], - point5Weight: 1.0, - noiseType: "simplex", - warpAmount: 0.8, - warpScale: 4.0, - warpIterations: 3, - warpDecay: 1.0, - warpBias: 0.65, - vortexAmount: 0.3, - falloff: 3.5, - tonemapMode: "totos", - glowStrength: 0.0, - glowThreshold: 0.0, - grainAmount: 0.08, - vignetteStrength: 0.0, - vignetteRadius: 1.5, - vignetteSoftness: 1, - } - case "sunset": - return { - activePoints: 4, - point1Color: "#1a0a2e", - point1Position: [-0.6, -0.8], - point1Weight: 0.8, - point2Color: "#c4420a", - point2Position: [0.3, 0.4], - point2Weight: 1.2, - point3Color: "#e8821a", - point3Position: [0.8, 0.7], - point3Weight: 0.9, - point4Color: "#4a1942", - point4Position: [-0.5, 0.3], - point4Weight: 1.0, - noiseType: "simplex", - warpAmount: 0.6, - warpScale: 3.5, - warpIterations: 2, - warpDecay: 1.2, - warpBias: 0.5, - vortexAmount: 0.0, - falloff: 3.5, - tonemapMode: "totos", - glowStrength: 0.0, - glowThreshold: 0.0, - grainAmount: 0.08, - vignetteStrength: 0.15, - vignetteRadius: 1.4, - vignetteSoftness: 0.8, - } - case "deep-ocean": - return { - activePoints: 4, - point1Color: "#020b1a", - point1Position: [0.0, -0.7], - point1Weight: 0.8, - point2Color: "#0a3d62", - point2Position: [-0.6, 0.4], - point2Weight: 1.2, - point3Color: "#3c8dbc", - point3Position: [0.7, 0.1], - point3Weight: 0.9, - point4Color: "#061224", - point4Position: [0.3, 0.8], - point4Weight: 1.0, - noiseType: "turbulence", - warpAmount: 0.04, - warpScale: 0.28, - warpIterations: 3, - warpDecay: 0.8, - warpBias: 0.4, - vortexAmount: 0.35, - falloff: 3.5, - tonemapMode: "totos", - glowStrength: 0.0, - glowThreshold: 0.0, - grainAmount: 0.06, - vignetteStrength: 0.2, - vignetteRadius: 1.3, - vignetteSoftness: 0.7, - } - case "neon-glow": - return { - activePoints: 5, - point1Color: "#0a0a0a", - point1Position: [0.0, 0.0], - point1Weight: 0.6, - point2Color: "#b80050", - point2Position: [-0.7, -0.5], - point2Weight: 1.3, - point3Color: "#0088aa", - point3Position: [0.8, 0.3], - point3Weight: 1.1, - point4Color: "#220033", - point4Position: [0.2, -0.8], - point4Weight: 0.9, - point5Color: "#1a0a2e", - point5Position: [-0.5, 0.7], - point5Weight: 1.0, - noiseType: "simplex", - warpAmount: 0.7, - warpScale: 4.0, - warpIterations: 3, - warpDecay: 1.0, - warpBias: 0.35, - vortexAmount: -0.25, - falloff: 3.5, - tonemapMode: "totos", - glowStrength: 0.0, - glowThreshold: 0.0, - grainAmount: 0.05, - vignetteStrength: 0.1, - vignetteRadius: 1.5, - vignetteSoftness: 1, - } - default: - return null - } -} - -function randomBetween(min: number, max: number): number { - return min + Math.random() * (max - min) -} - -function randomInt(min: number, max: number): number { - return Math.floor(randomBetween(min, max + 1)) -} - -function clampValue(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)) -} - -function randomChoice(values: readonly T[]): T { - return values[Math.floor(Math.random() * values.length)] as T -} - -function randomHexColor(): string { - const hue = randomInt(0, 359) - const saturation = randomInt(45, 95) / 100 - const lightness = randomInt(30, 78) / 100 - const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation - const hueSector = hue / 60 - const x = chroma * (1 - Math.abs((hueSector % 2) - 1)) - const match = lightness - chroma / 2 - - let r = 0 - let g = 0 - let b = 0 - - if (hueSector < 1) { - r = chroma - g = x - } else if (hueSector < 2) { - r = x - g = chroma - } else if (hueSector < 3) { - g = chroma - b = x - } else if (hueSector < 4) { - g = x - b = chroma - } else if (hueSector < 5) { - r = x - b = chroma - } else { - r = chroma - b = x - } - - const toHex = (channel: number) => - Math.round((channel + match) * 255) - .toString(16) - .padStart(2, "0") - - return `#${toHex(r)}${toHex(g)}${toHex(b)}` -} - -function buildRandomGradientParams(): LayerParameterValues { - const params = resetLayerParameters("gradient") - const noiseType = randomChoice([ - "simplex", - "perlin", - "value", - "voronoi", - "ridge", - "turbulence", - ] as const) - const noiseDefaults = getGradientNoiseDefaults(noiseType) - - params.preset = "custom" - params.activePoints = randomInt(2, 5) - - for (let index = 1; index <= 5; index += 1) { - params[`point${index}Color`] = randomHexColor() - params[`point${index}Position`] = [ - Number(randomBetween(-1.1, 1.1).toFixed(2)), - Number(randomBetween(-1.1, 1.1).toFixed(2)), - ] - params[`point${index}Weight`] = Number(randomBetween(0.2, 2.4).toFixed(2)) - } - - params.noiseType = noiseType - params.noiseSeed = Number(randomBetween(0, 100).toFixed(1)) - params.warpAmount = Number( - clampValue( - randomBetween( - (noiseDefaults?.warpAmount ?? 0.3) * 0.6, - (noiseDefaults?.warpAmount ?? 0.3) * 1.4 + 0.08 - ), - 0, - 1 - ).toFixed(2) - ) - params.warpScale = Number( - clampValue( - randomBetween( - (noiseDefaults?.warpScale ?? 2.5) * 0.6, - (noiseDefaults?.warpScale ?? 2.5) * 1.4 + 0.2 - ), - 0.1, - 6 - ).toFixed(2) - ) - params.warpIterations = randomInt(1, 4) - params.warpDecay = Number(randomBetween(0.4, 1.8).toFixed(2)) - params.warpBias = Number(randomBetween(0, 1).toFixed(2)) - params.vortexAmount = Number(randomBetween(-1, 1).toFixed(2)) - params.animate = Math.random() > 0.2 - params.motionAmount = Number(randomBetween(0, 1).toFixed(2)) - params.motionSpeed = Number(randomBetween(0, 2).toFixed(2)) - params.falloff = Number(randomBetween(0.8, 3.8).toFixed(2)) - params.tonemapMode = randomChoice([ - "none", - "aces", - "reinhard", - "totos", - "cinematic", - ] as const) - params.glowStrength = Number(randomBetween(0, 0.45).toFixed(2)) - params.glowThreshold = Number(randomBetween(0, 0.6).toFixed(2)) - params.grainAmount = Number(randomBetween(0, 0.25).toFixed(2)) - params.vignetteStrength = Number(randomBetween(0, 0.35).toFixed(2)) - params.vignetteRadius = Number(randomBetween(0.6, 1.5).toFixed(2)) - params.vignetteSoftness = Number(randomBetween(0.2, 1).toFixed(2)) - - return params -} - function getDitheringPresetDefaults( preset: string ): Record | null { @@ -1033,7 +766,7 @@ export const useLayerStore = create((set, get) => ({ layer.id === id && layer.type === "gradient" ? { ...layer, - params: buildRandomGradientParams(), + params: { ...layer.params, ...shuffleMeshGradient() }, runtimeError: null, } : layer @@ -1098,7 +831,7 @@ export const useLayerStore = create((set, get) => ({ key === "preset" && typeof value === "string" ) { - const defaults = getGradientPresetDefaults(value) + const defaults = meshGradientPresetParams(value) if (defaults) { Object.assign(nextParams, defaults)