Compare commits

..

2 Commits

Author SHA1 Message Date
BaekRyang
c72846b06e Refactor lens distortion calculation in distortion shader
- 렌즈 왜곡 계산을 로컬 UV에서 글로벌 UV 및 픽셀 거리 기반으로 변경
- 영역 중심 기준의 자연스러운 볼록/오목 효과 및 감쇠 로직 구현
2026-02-25 16:52:48 +09:00
BaekRyang
ecf3e81101 Refactor step easing into independent snapSteps property
- EasingFunction에서 step 옵션을 제거하고 독립적인 snapSteps 속성으로 분리
- AnimationLoop에 snapSteps 기반의 움직임 양자화 로직 구현
- 에디터 파라미터 패널에 움직임 단계 조절 슬라이더 추가
- 기본 설정값에 SNAP_STEPS 추가 및 패키지 버전 업데이트 (1.2.10)
2026-02-25 16:14:46 +09:00
14 changed files with 201 additions and 107 deletions

View File

@ -13,7 +13,8 @@
"Bash(findstr:*)", "Bash(findstr:*)",
"Bash(npm link:*)", "Bash(npm link:*)",
"Bash(find:*)", "Bash(find:*)",
"Bash(nul)" "Bash(nul)",
"Bash(cd:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

6
dist/index.d.mts vendored
View File

@ -11,7 +11,7 @@ interface Point {
/** /**
* *
*/ */
type EasingFunction = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'easeInQuad' | 'easeOutQuad' | 'easeInCubic' | 'easeOutCubic' | 'steps2' | 'steps3' | 'steps4' | 'steps5' | 'steps6' | 'steps8' | 'steps10'; type EasingFunction = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'easeInQuad' | 'easeOutQuad' | 'easeInCubic' | 'easeOutCubic';
/** /**
* *
*/ */
@ -73,6 +73,8 @@ interface DistortionArea {
/** 렌즈 강도 (양수: 볼록, 음수: 오목, 0: 없음, 범위: -1.0 ~ 1.0) */ /** 렌즈 강도 (양수: 볼록, 음수: 오목, 0: 없음, 범위: -1.0 ~ 1.0) */
strength: number; strength: number;
}; };
/** 스텝 양자화 단계 수 (0=없음, 1~5단계, 이징과 독립적으로 적용) */
snapSteps?: number;
} }
/** /**
* *
@ -429,6 +431,8 @@ declare const DEFAULT_AREA: {
}; };
/** 기본 렌즈 효과 강도 */ /** 기본 렌즈 효과 강도 */
readonly LENS_STRENGTH: 0; readonly LENS_STRENGTH: 0;
/** 기본 스텝 양자화 단계 수 (0=없음) */
readonly SNAP_STEPS: 0;
}; };
/** /**

6
dist/index.d.ts vendored
View File

@ -11,7 +11,7 @@ interface Point {
/** /**
* *
*/ */
type EasingFunction = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'easeInQuad' | 'easeOutQuad' | 'easeInCubic' | 'easeOutCubic' | 'steps2' | 'steps3' | 'steps4' | 'steps5' | 'steps6' | 'steps8' | 'steps10'; type EasingFunction = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'easeInQuad' | 'easeOutQuad' | 'easeInCubic' | 'easeOutCubic';
/** /**
* *
*/ */
@ -73,6 +73,8 @@ interface DistortionArea {
/** 렌즈 강도 (양수: 볼록, 음수: 오목, 0: 없음, 범위: -1.0 ~ 1.0) */ /** 렌즈 강도 (양수: 볼록, 음수: 오목, 0: 없음, 범위: -1.0 ~ 1.0) */
strength: number; strength: number;
}; };
/** 스텝 양자화 단계 수 (0=없음, 1~5단계, 이징과 독립적으로 적용) */
snapSteps?: number;
} }
/** /**
* *
@ -429,6 +431,8 @@ declare const DEFAULT_AREA: {
}; };
/** 기본 렌즈 효과 강도 */ /** 기본 렌즈 효과 강도 */
readonly LENS_STRENGTH: 0; readonly LENS_STRENGTH: 0;
/** 기본 스텝 양자화 단계 수 (0=없음) */
readonly SNAP_STEPS: 0;
}; };
/** /**

81
dist/index.js vendored
View File

@ -243,7 +243,6 @@ var ShaderManager = class {
}; };
// src/utils/easing.ts // src/utils/easing.ts
var createStepEasing = (steps) => (t) => Math.floor(t * steps) / steps;
var easingFunctions = { var easingFunctions = {
linear: (t) => t, linear: (t) => t,
easeIn: (t) => t * t, easeIn: (t) => t * t,
@ -252,14 +251,7 @@ var easingFunctions = {
easeInQuad: (t) => t * t, easeInQuad: (t) => t * t,
easeOutQuad: (t) => t * (2 - t), easeOutQuad: (t) => t * (2 - t),
easeInCubic: (t) => t * t * t, easeInCubic: (t) => t * t * t,
easeOutCubic: (t) => 1 - Math.pow(1 - t, 3), easeOutCubic: (t) => 1 - Math.pow(1 - t, 3)
steps2: createStepEasing(2),
steps3: createStepEasing(3),
steps4: createStepEasing(4),
steps5: createStepEasing(5),
steps6: createStepEasing(6),
steps8: createStepEasing(8),
steps10: createStepEasing(10)
}; };
var applyEasing = (progress, easingType) => { var applyEasing = (progress, easingType) => {
const clampedProgress = Math.max(0, Math.min(1, progress)); const clampedProgress = Math.max(0, Math.min(1, progress));
@ -353,20 +345,40 @@ var AnimationLoop = class {
} }
const easedProgress = applyEasing(progress, movement.easing); const easedProgress = applyEasing(progress, movement.easing);
let dragVector; let dragVector;
const snapSteps = area.snapSteps ?? 0;
if (movement.preset && isRotationPreset(movement.preset)) { if (movement.preset && isRotationPreset(movement.preset)) {
const angle = easedProgress * Math.PI * 2;
const radius = Math.sqrt(baseVector.x * baseVector.x + baseVector.y * baseVector.y); const radius = Math.sqrt(baseVector.x * baseVector.x + baseVector.y * baseVector.y);
const direction = movement.preset === "rotate-cw" ? 1 : -1; const direction = movement.preset === "rotate-cw" ? 1 : -1;
dragVector = { if (snapSteps > 0) {
x: Math.cos(angle * direction) * radius, const totalAngleSteps = snapSteps * 4;
y: Math.sin(angle * direction) * radius const rawAngle = progress * Math.PI * 2;
}; const quantizedAngle = Math.round(rawAngle / (Math.PI * 2) * totalAngleSteps) / totalAngleSteps * Math.PI * 2;
dragVector = {
x: Math.cos(quantizedAngle * direction) * radius,
y: Math.sin(quantizedAngle * direction) * radius
};
} else {
const angle = easedProgress * Math.PI * 2;
dragVector = {
x: Math.cos(angle * direction) * radius,
y: Math.sin(angle * direction) * radius
};
}
} else { } else {
const oscillation = Math.sin(easedProgress * Math.PI * 2); if (snapSteps > 0) {
dragVector = { const oscillation = Math.sin(progress * Math.PI * 2);
x: baseVector.x * oscillation, const quantized = Math.round(oscillation * snapSteps) / snapSteps;
y: baseVector.y * oscillation dragVector = {
}; x: baseVector.x * quantized,
y: baseVector.y * quantized
};
} else {
const oscillation = Math.sin(easedProgress * Math.PI * 2);
dragVector = {
x: baseVector.x * oscillation,
y: baseVector.y * oscillation
};
}
} }
return { return {
...area, ...area,
@ -839,7 +851,9 @@ var DEFAULT_AREA = {
/** 기본 벡터 B */ /** 기본 벡터 B */
VECTOR_B: { x: -0.1, y: -0.1 }, VECTOR_B: { x: -0.1, y: -0.1 },
/** 기본 렌즈 효과 강도 */ /** 기본 렌즈 효과 강도 */
LENS_STRENGTH: 0 LENS_STRENGTH: 0,
/** 기본 스텝 양자화 단계 수 (0=없음) */
SNAP_STEPS: 0
}; };
// src/components/ImageDistortion.tsx // src/components/ImageDistortion.tsx
@ -1110,14 +1124,7 @@ var EASING_OPTIONS = [
{ value: "easeOut", label: "\uAC10\uC18D (Ease Out)" }, { value: "easeOut", label: "\uAC10\uC18D (Ease Out)" },
{ value: "easeInOut", label: "\uAC00\uAC10\uC18D (Ease In Out)" }, { value: "easeInOut", label: "\uAC00\uAC10\uC18D (Ease In Out)" },
{ value: "easeInQuad", label: "\uAC00\uC18D\xB2 (Ease In Quad)" }, { value: "easeInQuad", label: "\uAC00\uC18D\xB2 (Ease In Quad)" },
{ value: "easeOutQuad", label: "\uAC10\uC18D\xB2 (Ease Out Quad)" }, { value: "easeOutQuad", label: "\uAC10\uC18D\xB2 (Ease Out Quad)" }
{ value: "steps2", label: "2\uB2E8\uACC4 \uC2A4\uD15D" },
{ value: "steps3", label: "3\uB2E8\uACC4 \uC2A4\uD15D" },
{ value: "steps4", label: "4\uB2E8\uACC4 \uC2A4\uD15D" },
{ value: "steps5", label: "5\uB2E8\uACC4 \uC2A4\uD15D" },
{ value: "steps6", label: "6\uB2E8\uACC4 \uC2A4\uD15D" },
{ value: "steps8", label: "8\uB2E8\uACC4 \uC2A4\uD15D" },
{ value: "steps10", label: "10\uB2E8\uACC4 \uC2A4\uD15D" }
]; ];
var ParameterPanel = ({ area, onUpdateArea }) => { var ParameterPanel = ({ area, onUpdateArea }) => {
if (!area) { if (!area) {
@ -1199,6 +1206,24 @@ var ParameterPanel = ({ area, onUpdateArea }) => {
} }
) )
] }), ] }),
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "parameter-group", children: [
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("label", { children: [
"\uC6C0\uC9C1\uC784 \uB2E8\uACC4: ",
(area.snapSteps ?? 0) === 0 ? "\uC5C6\uC74C" : `${area.snapSteps}\uB2E8\uACC4`
] }),
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
"input",
{
type: "range",
min: "0",
max: "5",
step: "1",
value: area.snapSteps ?? 0,
onChange: (e) => onUpdateArea({ snapSteps: parseInt(e.target.value) }),
className: "slider"
}
)
] }),
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "parameter-group", children: [ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "parameter-group", children: [
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("label", { children: "\uD3EC\uC778\uD2B8 \uC88C\uD45C (\uCE94\uBC84\uC2A4\uC5D0\uC11C \uB4DC\uB798\uADF8)" }), /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("label", { children: "\uD3EC\uC778\uD2B8 \uC88C\uD45C (\uCE94\uBC84\uC2A4\uC5D0\uC11C \uB4DC\uB798\uADF8)" }),
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "points-display", children: area.basePoints.map((point, idx) => /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "point-coord", children: [ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "points-display", children: area.basePoints.map((point, idx) => /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "point-coord", children: [

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

81
dist/index.mjs vendored
View File

@ -183,7 +183,6 @@ var ShaderManager = class {
}; };
// src/utils/easing.ts // src/utils/easing.ts
var createStepEasing = (steps) => (t) => Math.floor(t * steps) / steps;
var easingFunctions = { var easingFunctions = {
linear: (t) => t, linear: (t) => t,
easeIn: (t) => t * t, easeIn: (t) => t * t,
@ -192,14 +191,7 @@ var easingFunctions = {
easeInQuad: (t) => t * t, easeInQuad: (t) => t * t,
easeOutQuad: (t) => t * (2 - t), easeOutQuad: (t) => t * (2 - t),
easeInCubic: (t) => t * t * t, easeInCubic: (t) => t * t * t,
easeOutCubic: (t) => 1 - Math.pow(1 - t, 3), easeOutCubic: (t) => 1 - Math.pow(1 - t, 3)
steps2: createStepEasing(2),
steps3: createStepEasing(3),
steps4: createStepEasing(4),
steps5: createStepEasing(5),
steps6: createStepEasing(6),
steps8: createStepEasing(8),
steps10: createStepEasing(10)
}; };
var applyEasing = (progress, easingType) => { var applyEasing = (progress, easingType) => {
const clampedProgress = Math.max(0, Math.min(1, progress)); const clampedProgress = Math.max(0, Math.min(1, progress));
@ -293,20 +285,40 @@ var AnimationLoop = class {
} }
const easedProgress = applyEasing(progress, movement.easing); const easedProgress = applyEasing(progress, movement.easing);
let dragVector; let dragVector;
const snapSteps = area.snapSteps ?? 0;
if (movement.preset && isRotationPreset(movement.preset)) { if (movement.preset && isRotationPreset(movement.preset)) {
const angle = easedProgress * Math.PI * 2;
const radius = Math.sqrt(baseVector.x * baseVector.x + baseVector.y * baseVector.y); const radius = Math.sqrt(baseVector.x * baseVector.x + baseVector.y * baseVector.y);
const direction = movement.preset === "rotate-cw" ? 1 : -1; const direction = movement.preset === "rotate-cw" ? 1 : -1;
dragVector = { if (snapSteps > 0) {
x: Math.cos(angle * direction) * radius, const totalAngleSteps = snapSteps * 4;
y: Math.sin(angle * direction) * radius const rawAngle = progress * Math.PI * 2;
}; const quantizedAngle = Math.round(rawAngle / (Math.PI * 2) * totalAngleSteps) / totalAngleSteps * Math.PI * 2;
dragVector = {
x: Math.cos(quantizedAngle * direction) * radius,
y: Math.sin(quantizedAngle * direction) * radius
};
} else {
const angle = easedProgress * Math.PI * 2;
dragVector = {
x: Math.cos(angle * direction) * radius,
y: Math.sin(angle * direction) * radius
};
}
} else { } else {
const oscillation = Math.sin(easedProgress * Math.PI * 2); if (snapSteps > 0) {
dragVector = { const oscillation = Math.sin(progress * Math.PI * 2);
x: baseVector.x * oscillation, const quantized = Math.round(oscillation * snapSteps) / snapSteps;
y: baseVector.y * oscillation dragVector = {
}; x: baseVector.x * quantized,
y: baseVector.y * quantized
};
} else {
const oscillation = Math.sin(easedProgress * Math.PI * 2);
dragVector = {
x: baseVector.x * oscillation,
y: baseVector.y * oscillation
};
}
} }
return { return {
...area, ...area,
@ -779,7 +791,9 @@ var DEFAULT_AREA = {
/** 기본 벡터 B */ /** 기본 벡터 B */
VECTOR_B: { x: -0.1, y: -0.1 }, VECTOR_B: { x: -0.1, y: -0.1 },
/** 기본 렌즈 효과 강도 */ /** 기본 렌즈 효과 강도 */
LENS_STRENGTH: 0 LENS_STRENGTH: 0,
/** 기본 스텝 양자화 단계 수 (0=없음) */
SNAP_STEPS: 0
}; };
// src/components/ImageDistortion.tsx // src/components/ImageDistortion.tsx
@ -1050,14 +1064,7 @@ var EASING_OPTIONS = [
{ value: "easeOut", label: "\uAC10\uC18D (Ease Out)" }, { value: "easeOut", label: "\uAC10\uC18D (Ease Out)" },
{ value: "easeInOut", label: "\uAC00\uAC10\uC18D (Ease In Out)" }, { value: "easeInOut", label: "\uAC00\uAC10\uC18D (Ease In Out)" },
{ value: "easeInQuad", label: "\uAC00\uC18D\xB2 (Ease In Quad)" }, { value: "easeInQuad", label: "\uAC00\uC18D\xB2 (Ease In Quad)" },
{ value: "easeOutQuad", label: "\uAC10\uC18D\xB2 (Ease Out Quad)" }, { value: "easeOutQuad", label: "\uAC10\uC18D\xB2 (Ease Out Quad)" }
{ value: "steps2", label: "2\uB2E8\uACC4 \uC2A4\uD15D" },
{ value: "steps3", label: "3\uB2E8\uACC4 \uC2A4\uD15D" },
{ value: "steps4", label: "4\uB2E8\uACC4 \uC2A4\uD15D" },
{ value: "steps5", label: "5\uB2E8\uACC4 \uC2A4\uD15D" },
{ value: "steps6", label: "6\uB2E8\uACC4 \uC2A4\uD15D" },
{ value: "steps8", label: "8\uB2E8\uACC4 \uC2A4\uD15D" },
{ value: "steps10", label: "10\uB2E8\uACC4 \uC2A4\uD15D" }
]; ];
var ParameterPanel = ({ area, onUpdateArea }) => { var ParameterPanel = ({ area, onUpdateArea }) => {
if (!area) { if (!area) {
@ -1139,6 +1146,24 @@ var ParameterPanel = ({ area, onUpdateArea }) => {
} }
) )
] }), ] }),
/* @__PURE__ */ jsxs2("div", { className: "parameter-group", children: [
/* @__PURE__ */ jsxs2("label", { children: [
"\uC6C0\uC9C1\uC784 \uB2E8\uACC4: ",
(area.snapSteps ?? 0) === 0 ? "\uC5C6\uC74C" : `${area.snapSteps}\uB2E8\uACC4`
] }),
/* @__PURE__ */ jsx3(
"input",
{
type: "range",
min: "0",
max: "5",
step: "1",
value: area.snapSteps ?? 0,
onChange: (e) => onUpdateArea({ snapSteps: parseInt(e.target.value) }),
className: "slider"
}
)
] }),
/* @__PURE__ */ jsxs2("div", { className: "parameter-group", children: [ /* @__PURE__ */ jsxs2("div", { className: "parameter-group", children: [
/* @__PURE__ */ jsx3("label", { children: "\uD3EC\uC778\uD2B8 \uC88C\uD45C (\uCE94\uBC84\uC2A4\uC5D0\uC11C \uB4DC\uB798\uADF8)" }), /* @__PURE__ */ jsx3("label", { children: "\uD3EC\uC778\uD2B8 \uC88C\uD45C (\uCE94\uBC84\uC2A4\uC5D0\uC11C \uB4DC\uB798\uADF8)" }),
/* @__PURE__ */ jsx3("div", { className: "points-display", children: area.basePoints.map((point, idx) => /* @__PURE__ */ jsxs2("div", { className: "point-coord", children: [ /* @__PURE__ */ jsx3("div", { className: "points-display", children: area.basePoints.map((point, idx) => /* @__PURE__ */ jsxs2("div", { className: "point-coord", children: [

2
dist/index.mjs.map vendored

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{ {
"name": "@baekryang/responsive-image-canvas", "name": "@baekryang/responsive-image-canvas",
"version": "1.2.7", "version": "1.2.10",
"publishConfig": { "publishConfig": {
"registry": "https://git.bnovalab.com/api/packages/baekryang/npm/" "registry": "https://git.bnovalab.com/api/packages/baekryang/npm/"
}, },

View File

@ -13,13 +13,6 @@ const EASING_OPTIONS: { value: EasingFunction; label: string }[] = [
{ value: 'easeInOut', label: '가감속 (Ease In Out)' }, { value: 'easeInOut', label: '가감속 (Ease In Out)' },
{ value: 'easeInQuad', label: '가속² (Ease In Quad)' }, { value: 'easeInQuad', label: '가속² (Ease In Quad)' },
{ value: 'easeOutQuad', label: '감속² (Ease Out Quad)' }, { value: 'easeOutQuad', label: '감속² (Ease Out Quad)' },
{ value: 'steps2', label: '2단계 스텝' },
{ value: 'steps3', label: '3단계 스텝' },
{ value: 'steps4', label: '4단계 스텝' },
{ value: 'steps5', label: '5단계 스텝' },
{ value: 'steps6', label: '6단계 스텝' },
{ value: 'steps8', label: '8단계 스텝' },
{ value: 'steps10', label: '10단계 스텝' },
]; ];
export const ParameterPanel: React.FC<ParameterPanelProps> = ({ area, onUpdateArea }) => { export const ParameterPanel: React.FC<ParameterPanelProps> = ({ area, onUpdateArea }) => {
@ -107,6 +100,22 @@ export const ParameterPanel: React.FC<ParameterPanelProps> = ({ area, onUpdateAr
/> />
</div> </div>
{/* 스텝 양자화 */}
<div className="parameter-group">
<label>
: {(area.snapSteps ?? 0) === 0 ? '없음' : `${area.snapSteps}단계`}
</label>
<input
type="range"
min="0"
max="5"
step="1"
value={area.snapSteps ?? 0}
onChange={(e) => onUpdateArea({ snapSteps: parseInt(e.target.value) })}
className="slider"
/>
</div>
{/* 포인트 좌표 (읽기 전용 표시) */} {/* 포인트 좌표 (읽기 전용 표시) */}
<div className="parameter-group"> <div className="parameter-group">
<label> ( )</label> <label> ( )</label>

View File

@ -41,23 +41,49 @@ export class AnimationLoop {
// 벡터 계산 // 벡터 계산
let dragVector: Point; let dragVector: Point;
// 스텝 양자화 (영역 속성에서 가져옴)
const snapSteps = area.snapSteps ?? 0;
// 회전 프리셋인 경우 원운동 // 회전 프리셋인 경우 원운동
if (movement.preset && isRotationPreset(movement.preset)) { if (movement.preset && isRotationPreset(movement.preset)) {
const angle = easedProgress * Math.PI * 2;
const radius = Math.sqrt(baseVector.x * baseVector.x + baseVector.y * baseVector.y); const radius = Math.sqrt(baseVector.x * baseVector.x + baseVector.y * baseVector.y);
const direction = movement.preset === 'rotate-cw' ? 1 : -1; const direction = movement.preset === 'rotate-cw' ? 1 : -1;
dragVector = {
x: Math.cos(angle * direction) * radius, if (snapSteps > 0) {
y: Math.sin(angle * direction) * radius, // 스텝 양자화: 각도를 이산적 단계로 양자화
}; const totalAngleSteps = snapSteps * 4;
const rawAngle = progress * Math.PI * 2;
const quantizedAngle = Math.round(rawAngle / (Math.PI * 2) * totalAngleSteps) / totalAngleSteps * Math.PI * 2;
dragVector = {
x: Math.cos(quantizedAngle * direction) * radius,
y: Math.sin(quantizedAngle * direction) * radius,
};
} else {
const angle = easedProgress * Math.PI * 2;
dragVector = {
x: Math.cos(angle * direction) * radius,
y: Math.sin(angle * direction) * radius,
};
}
} else { } else {
// 일반 왕복 모션 (sin 기반으로 진짜 좌↔우/상↔하 왕복) // 일반 왕복 모션 (sin 기반으로 진짜 좌↔우/상↔하 왕복)
// sin(0)=0 → sin(π/2)=1 → sin(π)=0 → sin(3π/2)=-1 → sin(2π)=0 // sin(0)=0 → sin(π/2)=1 → sin(π)=0 → sin(3π/2)=-1 → sin(2π)=0
const oscillation = Math.sin(easedProgress * Math.PI * 2); if (snapSteps > 0) {
dragVector = { // 스텝 양자화: oscillation 출력을 양자화
x: baseVector.x * oscillation, // Math.round(sin * N) / N → 한 방향 N단계, 전체 2N+1개 위치
y: baseVector.y * oscillation, const oscillation = Math.sin(progress * Math.PI * 2);
}; const quantized = Math.round(oscillation * snapSteps) / snapSteps;
dragVector = {
x: baseVector.x * quantized,
y: baseVector.y * quantized,
};
} else {
const oscillation = Math.sin(easedProgress * Math.PI * 2);
dragVector = {
x: baseVector.x * oscillation,
y: baseVector.y * oscillation,
};
}
} }
return { return {

View File

@ -76,13 +76,28 @@ void main() {
vec2 distortion = u_dragVectors[i] * influence * u_distortionStrengths[i]; vec2 distortion = u_dragVectors[i] * influence * u_distortionStrengths[i];
texCoord += distortion; texCoord += distortion;
// 렌즈 왜곡 효과 (방사형 UV 왜곡) // 렌즈 왜곡 효과 (볼록: 중심 확대, 오목: 중심 축소)
if (abs(u_lensEffects[i]) > 0.001) { if (abs(u_lensEffects[i]) > 0.001) {
vec2 centered = uv_local - vec2(0.5); // 영역 중심의 글로벌 UV 좌표
float dist2 = dot(centered, centered); vec2 minP_area = min(min(p0, p1), min(p2, p3));
float lensK = u_lensEffects[i] * 2.0; // 강도 스케일링 vec2 maxP_area = max(max(p0, p1), max(p2, p3));
vec2 lensDistortion = centered * lensK * dist2; vec2 areaSize = maxP_area - minP_area;
texCoord += lensDistortion * u_distortionStrengths[i]; vec2 areaCenterUV = (minP_area + maxP_area) * 0.5 / u_resolution;
// 현재 픽셀에서 영역 중심까지의 글로벌 UV 오프셋
vec2 offset = vUv - areaCenterUV;
// 픽셀 공간 거리로 원형 감쇠 (긴 변 기준으로 영역 전체 커버)
float distPx = length(offset * u_resolution);
float maxRadiusPx = max(areaSize.x, areaSize.y) * 0.5;
float normalizedDist = distPx / maxRadiusPx;
if (normalizedDist < 1.0) {
// 중심에서 최대 강도, 가장자리로 갈수록 자연스럽게 0으로 감소
float lensAmount = u_lensEffects[i] * (1.0 - normalizedDist * normalizedDist);
// 볼록(+): 텍스처 좌표를 중심으로 당김 → 확대
// offset은 글로벌 UV이므로 픽셀 공간에서 등방성(isotropic) 확대
texCoord -= offset * lensAmount * u_distortionStrengths[i];
}
} }
} }
} }

View File

@ -17,14 +17,7 @@ export type EasingFunction =
| 'easeInQuad' | 'easeInQuad'
| 'easeOutQuad' | 'easeOutQuad'
| 'easeInCubic' | 'easeInCubic'
| 'easeOutCubic' | 'easeOutCubic';
| 'steps2'
| 'steps3'
| 'steps4'
| 'steps5'
| 'steps6'
| 'steps8'
| 'steps10';
/** /**
* *
@ -104,6 +97,8 @@ export interface DistortionArea {
/** 렌즈 강도 (양수: 볼록, 음수: 오목, 0: 없음, 범위: -1.0 ~ 1.0) */ /** 렌즈 강도 (양수: 볼록, 음수: 오목, 0: 없음, 범위: -1.0 ~ 1.0) */
strength: number; strength: number;
}; };
/** 스텝 양자화 단계 수 (0=없음, 1~5단계, 이징과 독립적으로 적용) */
snapSteps?: number;
} }
/** /**

View File

@ -40,4 +40,6 @@ export const DEFAULT_AREA = {
VECTOR_B: { x: -0.1, y: -0.1 }, VECTOR_B: { x: -0.1, y: -0.1 },
/** 기본 렌즈 효과 강도 */ /** 기본 렌즈 효과 강도 */
LENS_STRENGTH: 0, LENS_STRENGTH: 0,
/** 기본 스텝 양자화 단계 수 (0=없음) */
SNAP_STEPS: 0,
} as const; } as const;

View File

@ -2,10 +2,6 @@ import { type EasingFunction } from '../types';
type EasingFunc = (t: number) => number; type EasingFunc = (t: number) => number;
/** 스텝 이징 헬퍼: floor(t * n) / n → n단계로 양자화 */
const createStepEasing = (steps: number): EasingFunc =>
(t) => Math.floor(t * steps) / steps;
/** /**
* *
*/ */
@ -21,14 +17,6 @@ const easingFunctions: Record<EasingFunction, EasingFunc> = {
easeInCubic: (t) => t * t * t, easeInCubic: (t) => t * t * t,
easeOutCubic: (t) => 1 - Math.pow(1 - t, 3), easeOutCubic: (t) => 1 - Math.pow(1 - t, 3),
steps2: createStepEasing(2),
steps3: createStepEasing(3),
steps4: createStepEasing(4),
steps5: createStepEasing(5),
steps6: createStepEasing(6),
steps8: createStepEasing(8),
steps10: createStepEasing(10),
}; };
/** /**