From 8c5b58b1366326803a0a07c79965c724c5e15688 Mon Sep 17 00:00:00 2001 From: git-chad Date: Tue, 13 Oct 2026 15:48:21 -0300 Subject: [PATCH 1/2] Rework the Mesh Gradient layer: smooth presets, canvas points, shuffle, simple distortion and looping motion The old presets were high-frequency warps with tonemapped colors, so they read as oily blotches rather than a mesh gradient, and the names did not match the results. Eight new presets (Peach, Lagoon, Dusk, Citrus, Mint, Aurora, Ink, Basement) are soft color fields with exact colors and light grain, and a new gradient now starts from Peach. Each color point shows on the canvas while the layer is selected: drag to move it (Shift locks the axis), double-click to pick its color, Alt-drag to change its reach. Shuffle replaces Randomize: it builds a harmonious OKLCH palette (analogous, split complementary, triad or monochrome with a dark and a light anchor) or reuses a preset palette, lays the points out evenly, and keeps distortion, motion and finish. Distortion gains a Simple mode with Softness, Warp and Detail, which give each point a soft core so low-weight points no longer form hard dots; the raw controls live under Advanced. Motion gets styles (Drift, Breathe, Swirl, Flow) built from whole harmonics of the timeline duration, which passes now receive through updateTimelineDuration in the editor and the runtime, so a video export loops without a seam. Saved gradients migrate to Advanced with their Original motion and old preset ids fall back to Custom, so they render exactly as before. --- .changeset/mesh-gradient-v2.md | 5 + .../src/renderer/create-headless-renderer.ts | 1 + .../src/renderer/create-webgpu-renderer.ts | 1 + .../src/renderer/gradient-pass.ts | 160 ++++++++-- .../src/renderer/pass-node.ts | 4 + .../src/renderer/pipeline-manager.ts | 13 + .../editor/editor-canvas-viewport.tsx | 5 + .../editor/gradient-points-overlay.tsx | 237 ++++++++++++++ .../editor/properties-sidebar-content.tsx | 7 +- src/lib/editor/config/layer-registry.ts | 138 +++++++-- src/lib/editor/mesh-gradient.ts | 289 ++++++++++++++++++ src/lib/editor/project-file.ts | 12 + src/renderer/create-webgpu-renderer.ts | 1 + src/renderer/gradient-pass.ts | 160 ++++++++-- src/renderer/pass-node.ts | 4 + src/renderer/pipeline-manager.ts | 13 + src/store/canvas-handles-store.ts | 6 + src/store/layer-store.ts | 273 +---------------- 18 files changed, 966 insertions(+), 363 deletions(-) create mode 100644 .changeset/mesh-gradient-v2.md create mode 100644 src/components/editor/gradient-points-overlay.tsx create mode 100644 src/lib/editor/mesh-gradient.ts 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/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/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/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-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/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/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) From beffef7005b4d908a19ce106c74dba5aad0b30f1 Mon Sep 17 00:00:00 2001 From: git-chad Date: Tue, 13 Oct 2026 15:53:29 -0300 Subject: [PATCH 2/2] Fix the Greptile findings on brush masks, runtime inner effects and motif colors, and mark Mesh Gradient as reworked - Painted brush masks stay inside their saved bounds, and an empty brush mask hides the layer - The runtime accepts every inner effect the editor offers, so exported Blob Tracking projects keep them - Pattern motif images rebuild when the palette changes - Mesh Gradient shows the orange reworked dot --- .../src/renderer/blob-tracking-inner-effects.ts | 15 +++++++++++++++ .../shader-lab-react/src/renderer/layer-mask.ts | 14 ++++++++++++-- .../shader-lab-react/src/renderer/pattern-pass.ts | 6 ++++++ src/lib/editor/config/layer-catalog.ts | 1 + src/renderer/layer-mask.ts | 14 ++++++++++++-- src/renderer/pattern-pass.ts | 6 ++++++ 6 files changed, 52 insertions(+), 4 deletions(-) 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/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/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/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/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/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