feat: Fix image distortion shader and improve loading state

- Fix distortion.frag.glsl to match Flutter original implementation
  - Update computeUV function with single Newton-Raphson iteration
  - Fix coordinate transformation (normalized to pixel)
  - Fix distortion application logic
  - Add break after first matching area (Flutter behavior)

- Add image loading state management
  - Add imageLoaded state
  - Add loading progress callback
  - Add loading UI indicator
  - Improve error handling

- Add comprehensive debug logging
  - ShaderManager: fetch status and shader lengths
  - ThreeScene: shader compilation check, render calls
  - ImageDistortion: lifecycle and loading status

- Add test/debug shaders for troubleshooting
  - test.frag.glsl: Simple pass-through shader
  - debug.frag.glsl: Area visualization shader

- Fix infinite loop bug in animationCallback
  - Use setState updater function to avoid dependency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
BaekRyang 2025-11-04 11:51:39 +09:00
parent c3b5aaadcb
commit e371321fd2
20 changed files with 425 additions and 152 deletions

View File

@ -8,7 +8,8 @@
"Bash(tree:*)", "Bash(tree:*)",
"Bash(git add:*)", "Bash(git add:*)",
"Bash(git commit:*)", "Bash(git commit:*)",
"Bash(git push:*)" "Bash(git push:*)",
"Bash(npm run dev:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

39
dist/debug.frag.glsl vendored Normal file
View File

@ -0,0 +1,39 @@
uniform vec2 u_resolution;
uniform sampler2D u_texture;
uniform vec2 u_points[32];
uniform int u_numAreas;
uniform vec2 u_dragVectors[8];
uniform float u_distortionStrengths[8];
varying vec2 vUv;
void main() {
vec2 texCoord = vUv;
// 디버그: 영역 표시
for (int i = 0; i < 8; i++) {
if (i >= u_numAreas) break;
// 포인트를 픽셀 좌표로 변환
vec2 p0 = u_points[i * 4 + 0] * u_resolution;
vec2 p1 = u_points[i * 4 + 1] * u_resolution;
vec2 p2 = u_points[i * 4 + 2] * u_resolution;
vec2 p3 = u_points[i * 4 + 3] * u_resolution;
vec2 pixelCoord = vUv * u_resolution;
// 경계 상자 체크만
vec2 minP = min(min(p0, p1), min(p2, p3));
vec2 maxP = max(max(p0, p1), max(p2, p3));
// 영역 안에 있으면 빨간색으로 표시
if (pixelCoord.x >= minP.x && pixelCoord.x <= maxP.x &&
pixelCoord.y >= minP.y && pixelCoord.y <= maxP.y) {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // 빨간색
return;
}
}
// 영역 밖은 원본 이미지
gl_FragColor = texture2D(u_texture, texCoord);
}

View File

@ -1,88 +1,87 @@
uniform vec2 u_resolution; uniform vec2 u_resolution;
uniform sampler2D u_texture; uniform sampler2D u_texture;
uniform vec2 u_points[32]; // 최대 8영역 × 4포인트 uniform vec2 u_points[32]; // 최대 8영역 × 4포인트 (정규화된 좌표)
uniform int u_numAreas; uniform int u_numAreas;
uniform vec2 u_dragVectors[8]; uniform vec2 u_dragVectors[8]; // 정규화된 좌표
uniform float u_distortionStrengths[8]; uniform float u_distortionStrengths[8];
varying vec2 vUv; varying vec2 vUv;
// 사각형 내부의 포인트에 대한 UV 좌표 계산 // Flutter 원본 computeUV 함수 (정확히 동일하게 변환)
vec2 computeUV(vec2 xy, vec2 p0, vec2 p1, vec2 p2, vec2 p3) { vec2 computeUV(vec2 xy, vec2 p0, vec2 p1, vec2 p2, vec2 p3) {
// 경계 상자 체크
vec2 minP = min(min(p0, p1), min(p2, p3)); vec2 minP = min(min(p0, p1), min(p2, p3));
vec2 maxP = max(max(p0, p1), max(p2, p3)); vec2 maxP = max(max(p0, p1), max(p2, p3));
if (xy.x < minP.x || xy.x > maxP.x || xy.y < minP.y || xy.y > maxP.y) { if (xy.x < minP.x || xy.x > maxP.x || xy.y < minP.y || xy.y > maxP.y) {
return vec2(-1.0, -1.0); return vec2(-1.0, -1.0); // 외부
} }
// 초기 추정값 (정규화된 좌표)
vec2 rectSize = maxP - minP; vec2 rectSize = maxP - minP;
vec2 rectUV = (xy - minP) / rectSize; if (rectSize.x == 0.0 || rectSize.y == 0.0) {
return vec2(-1.0, -1.0); // 축퇴
}
vec2 rectMin = minP;
vec2 rectUV = (xy - rectMin) / rectSize;
float u0 = rectUV.x; float u0 = rectUV.x;
float v0 = rectUV.y; float v0 = rectUV.y;
// Newton-Raphson 반복법으로 정확한 UV 계산 // 1회 Newton-Raphson (Flutter 원본과 동일)
for (int iter = 0; iter < 3; iter++) { vec2 left = mix(p0, p1, u0);
vec2 xy0 = mix(mix(p0, p1, u0), mix(p3, p2, u0), v0); vec2 right = mix(p3, p2, u0);
vec2 xy0 = mix(left, right, v0);
vec2 dxy = xy - xy0;
vec2 du_vec = mix(p1 - p0, p2 - p3, v0); vec2 du_vec = mix(p1 - p0, p2 - p3, v0);
vec2 dv_vec = mix(p3 - p0, p2 - p1, u0); vec2 dv_vec = mix(p3 - p0, p2 - p1, u0);
vec2 dxy = xy - xy0;
float det = du_vec.x * dv_vec.y - du_vec.y * dv_vec.x; float det = du_vec.x * dv_vec.y - du_vec.y * dv_vec.x;
if (abs(det) > 1e-6) {
if (abs(det) < 1e-6) break; float inv_det = 1.0 / det;
float du = (dv_vec.y * dxy.x - dv_vec.x * dxy.y) * inv_det;
float du = (dv_vec.y * dxy.x - dv_vec.x * dxy.y) / det; float dv = (-du_vec.y * dxy.x + du_vec.x * dxy.y) * inv_det;
float dv = (-du_vec.y * dxy.x + du_vec.x * dxy.y) / det;
u0 += du; u0 += du;
v0 += dv; v0 += dv;
} }
// 포인트가 내부에 있는지 확인
if (u0 >= 0.0 && u0 <= 1.0 && v0 >= 0.0 && v0 <= 1.0) {
return vec2(u0, v0); return vec2(u0, v0);
}
return vec2(-1.0, -1.0);
} }
void main() { void main() {
vec2 uv = vUv; vec2 xy = vUv * u_resolution; // 픽셀 좌표
vec2 pixelCoord = vUv * u_resolution; vec2 texCoord = vUv;
bool found = false;
// 모든 영역의 왜곡 적용 // Flutter 원본과 동일: 첫 번째 매칭되는 영역만 적용
for (int i = 0; i < 8; i++) { for (int i = 0; i < 8; i++) {
if (i >= u_numAreas) break; if (i >= u_numAreas) break;
int baseIndex = i * 4; // 포인트는 정규화된 좌표로 전달받았으므로 픽셀 좌표로 변환
vec2 p0 = u_points[baseIndex + 0] * u_resolution; vec2 p0 = u_points[i * 4 + 0] * u_resolution;
vec2 p1 = u_points[baseIndex + 1] * u_resolution; vec2 p1 = u_points[i * 4 + 1] * u_resolution;
vec2 p2 = u_points[baseIndex + 2] * u_resolution; vec2 p2 = u_points[i * 4 + 2] * u_resolution;
vec2 p3 = u_points[baseIndex + 3] * u_resolution; vec2 p3 = u_points[i * 4 + 3] * u_resolution;
vec2 areaUV = computeUV(pixelCoord, p0, p1, p2, p3); vec2 uv_local = computeUV(xy, p0, p1, p2, p3);
if (areaUV.x >= 0.0) { if (uv_local.x >= 0.0 && uv_local.x <= 1.0 && uv_local.y >= 0.0 && uv_local.y <= 1.0) {
// 이 영역 내부에 포인트가 있음 vec2 uvCenter = vec2(0.5, 0.5);
vec2 center = vec2(0.5, 0.5); float distToCenter = distance(uv_local, uvCenter);
float distToCenter = length(areaUV - center); float maxUvRadius = 0.5; // Flutter 원본과 동일
float maxUvRadius = 0.707; // sqrt(0.5^2 + 0.5^2)
// 부드러운 감쇠 if (distToCenter < maxUvRadius) {
float influence = 1.0 - smoothstep(0.0, maxUvRadius, distToCenter); float influence = 1.0 - smoothstep(0.0, maxUvRadius, distToCenter);
// dragVector도 정규화된 좌표이므로 픽셀로 변환
// 왜곡 적용 vec2 distortion = (u_dragVectors[i] * u_resolution) * influence * u_distortionStrengths[i];
vec2 distortion = (u_dragVectors[i] / u_resolution) * influence * u_distortionStrengths[i]; // texCoord는 이미 정규화된 좌표이므로 정규화된 왜곡 적용
uv += distortion; texCoord += distortion / u_resolution;
texCoord = clamp(texCoord, 0.0, 1.0);
}
found = true;
break; // Flutter 원본처럼 첫 번째 영역만 적용
} }
} }
// 텍스처 외부 샘플링 방지를 위한 클램핑 gl_FragColor = texture2D(u_texture, texCoord);
uv = clamp(uv, 0.0, 1.0);
// 텍스처 샘플링
gl_FragColor = texture2D(u_texture, uv);
} }

2
dist/index.d.mts vendored
View File

@ -56,7 +56,7 @@ interface AreaBounds {
* *
*/ */
interface ShaderUniforms { interface ShaderUniforms {
[uniform: string]: THREE.IUniform<any>; [uniform: string]: THREE.IUniform;
/** 화면 해상도 */ /** 화면 해상도 */
u_resolution: THREE.IUniform<THREE.Vector2>; u_resolution: THREE.IUniform<THREE.Vector2>;
/** 이미지 텍스처 */ /** 이미지 텍스처 */

2
dist/index.d.ts vendored
View File

@ -56,7 +56,7 @@ interface AreaBounds {
* *
*/ */
interface ShaderUniforms { interface ShaderUniforms {
[uniform: string]: THREE.IUniform<any>; [uniform: string]: THREE.IUniform;
/** 화면 해상도 */ /** 화면 해상도 */
u_resolution: THREE.IUniform<THREE.Vector2>; u_resolution: THREE.IUniform<THREE.Vector2>;
/** 이미지 텍스처 */ /** 이미지 텍스처 */

65
dist/index.js vendored
View File

@ -91,17 +91,32 @@ var ThreeScene = class {
* @param fragmentShader 프래그먼트 셰이더 소스 * @param fragmentShader 프래그먼트 셰이더 소스
*/ */
setShaderMaterial(vertexShader, fragmentShader) { setShaderMaterial(vertexShader, fragmentShader) {
console.log("[ThreeScene] setShaderMaterial \uD638\uCD9C\uB428");
console.log("[ThreeScene] vertexShader \uAE38\uC774:", vertexShader.length);
console.log("[ThreeScene] fragmentShader \uAE38\uC774:", fragmentShader.length);
const geometry = new THREE.PlaneGeometry(2, 2); const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({ const material = new THREE.ShaderMaterial({
uniforms: this.uniforms, uniforms: this.uniforms,
vertexShader, vertexShader,
fragmentShader fragmentShader
}); });
console.log("[ThreeScene] ShaderMaterial \uC0DD\uC131\uB428");
const renderer = this.renderer;
const testScene = new THREE.Scene();
const testMesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
testScene.add(testMesh);
try {
renderer.compile(testScene, this.camera);
console.log("[ThreeScene] \uC170\uC774\uB354 \uCEF4\uD30C\uC77C \uC131\uACF5!");
} catch (e) {
console.error("[ThreeScene] \uC170\uC774\uB354 \uCEF4\uD30C\uC77C \uC5D0\uB7EC:", e);
}
if (this.mesh) { if (this.mesh) {
this.scene.remove(this.mesh); this.scene.remove(this.mesh);
} }
this.mesh = new THREE.Mesh(geometry, material); this.mesh = new THREE.Mesh(geometry, material);
this.scene.add(this.mesh); this.scene.add(this.mesh);
console.log("[ThreeScene] mesh\uB97C \uC52C\uC5D0 \uCD94\uAC00\uD568");
} }
/** /**
* 유니폼 업데이트 * 유니폼 업데이트
@ -117,6 +132,7 @@ var ThreeScene = class {
* 렌더링 * 렌더링
*/ */
render() { render() {
console.log("[ThreeScene] render() \uD638\uCD9C\uB428, mesh:", this.mesh);
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera);
} }
/** /**
@ -148,25 +164,36 @@ var ShaderManager = class {
* @returns 로드된 셰이더 소스 코드 * @returns 로드된 셰이더 소스 코드
*/ */
async loadShaders(vertexPath, fragmentPath) { async loadShaders(vertexPath, fragmentPath) {
console.log("[ShaderManager] loadShaders \uC2DC\uC791:", { vertexPath, fragmentPath });
try { try {
console.log("[ShaderManager] fetch \uC2DC\uC791...");
const [vertexResponse, fragmentResponse] = await Promise.all([ const [vertexResponse, fragmentResponse] = await Promise.all([
fetch(vertexPath), fetch(vertexPath),
fetch(fragmentPath) fetch(fragmentPath)
]); ]);
console.log("[ShaderManager] fetch \uC644\uB8CC:", {
vertexStatus: vertexResponse.status,
fragmentStatus: fragmentResponse.status
});
if (!vertexResponse.ok) { if (!vertexResponse.ok) {
throw new Error(`\uBC84\uD14D\uC2A4 \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328: ${vertexResponse.statusText}`); throw new Error(`\uBC84\uD14D\uC2A4 \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328: ${vertexResponse.statusText}`);
} }
if (!fragmentResponse.ok) { if (!fragmentResponse.ok) {
throw new Error(`\uD504\uB798\uADF8\uBA3C\uD2B8 \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328: ${fragmentResponse.statusText}`); throw new Error(`\uD504\uB798\uADF8\uBA3C\uD2B8 \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328: ${fragmentResponse.statusText}`);
} }
console.log("[ShaderManager] text() \uBCC0\uD658 \uC2DC\uC791...");
this.vertexShaderSource = await vertexResponse.text(); this.vertexShaderSource = await vertexResponse.text();
this.fragmentShaderSource = await fragmentResponse.text(); this.fragmentShaderSource = await fragmentResponse.text();
console.log("[ShaderManager] \uC170\uC774\uB354 \uB85C\uB4DC \uC644\uB8CC!", {
vertexLength: this.vertexShaderSource.length,
fragmentLength: this.fragmentShaderSource.length
});
return { return {
vertex: this.vertexShaderSource, vertex: this.vertexShaderSource,
fragment: this.fragmentShaderSource fragment: this.fragmentShaderSource
}; };
} catch (error) { } catch (error) {
console.error("\uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328:", error); console.error("[ShaderManager] \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328:", error);
throw new Error("\uC170\uC774\uB354 \uB85C\uB529\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4"); throw new Error("\uC170\uC774\uB354 \uB85C\uB529\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4");
} }
} }
@ -323,6 +350,7 @@ var ImageDistortion = ({
const shaderManagerRef = (0, import_react2.useRef)(new ShaderManager()); const shaderManagerRef = (0, import_react2.useRef)(new ShaderManager());
const textureRef = (0, import_react2.useRef)(null); const textureRef = (0, import_react2.useRef)(null);
const [isReady, setIsReady] = (0, import_react2.useState)(false); const [isReady, setIsReady] = (0, import_react2.useState)(false);
const [imageLoaded, setImageLoaded] = (0, import_react2.useState)(false);
const [currentAreas, setCurrentAreas] = (0, import_react2.useState)(areas); const [currentAreas, setCurrentAreas] = (0, import_react2.useState)(areas);
(0, import_react2.useEffect)(() => { (0, import_react2.useEffect)(() => {
setCurrentAreas(areas); setCurrentAreas(areas);
@ -359,22 +387,34 @@ var ImageDistortion = ({
return; return;
} }
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2DC\uC791:", imageSrc); console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2DC\uC791:", imageSrc);
setImageLoaded(false);
const loader = new THREE2.TextureLoader(); const loader = new THREE2.TextureLoader();
loader.load( loader.load(
imageSrc, imageSrc,
(texture) => { (texture) => {
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC131\uACF5"); console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC131\uACF5!", {
width: texture.image.width,
height: texture.image.height
});
textureRef.current = texture; textureRef.current = texture;
setImageLoaded(true);
if (sceneRef.current) { if (sceneRef.current) {
sceneRef.current.updateUniforms({ sceneRef.current.updateUniforms({
u_texture: { value: texture } u_texture: { value: texture }
}); });
sceneRef.current.render(); sceneRef.current.render();
console.log("[ImageDistortion] \uD14D\uC2A4\uCC98 \uC5C5\uB370\uC774\uD2B8 \uBC0F \uB80C\uB354\uB9C1 \uC644\uB8CC");
} }
}, },
void 0, (progress) => {
console.log(
"[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB529 \uC911...",
Math.round(progress.loaded / progress.total * 100) + "%"
);
},
(error) => { (error) => {
console.error("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2E4\uD328:", error); console.error("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2E4\uD328:", error);
setImageLoaded(false);
} }
); );
return () => { return () => {
@ -430,7 +470,24 @@ var ImageDistortion = ({
position: "relative", position: "relative",
...style ...style
}, },
className className,
children: !imageLoaded && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
"div",
{
style: {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(0, 0, 0, 0.7)",
color: "white",
padding: "20px",
borderRadius: "8px",
zIndex: 999
},
children: "\uC774\uBBF8\uC9C0 \uB85C\uB529 \uC911..."
}
)
} }
); );
}; };

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

65
dist/index.mjs vendored
View File

@ -47,17 +47,32 @@ var ThreeScene = class {
* @param fragmentShader 프래그먼트 셰이더 소스 * @param fragmentShader 프래그먼트 셰이더 소스
*/ */
setShaderMaterial(vertexShader, fragmentShader) { setShaderMaterial(vertexShader, fragmentShader) {
console.log("[ThreeScene] setShaderMaterial \uD638\uCD9C\uB428");
console.log("[ThreeScene] vertexShader \uAE38\uC774:", vertexShader.length);
console.log("[ThreeScene] fragmentShader \uAE38\uC774:", fragmentShader.length);
const geometry = new THREE.PlaneGeometry(2, 2); const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({ const material = new THREE.ShaderMaterial({
uniforms: this.uniforms, uniforms: this.uniforms,
vertexShader, vertexShader,
fragmentShader fragmentShader
}); });
console.log("[ThreeScene] ShaderMaterial \uC0DD\uC131\uB428");
const renderer = this.renderer;
const testScene = new THREE.Scene();
const testMesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
testScene.add(testMesh);
try {
renderer.compile(testScene, this.camera);
console.log("[ThreeScene] \uC170\uC774\uB354 \uCEF4\uD30C\uC77C \uC131\uACF5!");
} catch (e) {
console.error("[ThreeScene] \uC170\uC774\uB354 \uCEF4\uD30C\uC77C \uC5D0\uB7EC:", e);
}
if (this.mesh) { if (this.mesh) {
this.scene.remove(this.mesh); this.scene.remove(this.mesh);
} }
this.mesh = new THREE.Mesh(geometry, material); this.mesh = new THREE.Mesh(geometry, material);
this.scene.add(this.mesh); this.scene.add(this.mesh);
console.log("[ThreeScene] mesh\uB97C \uC52C\uC5D0 \uCD94\uAC00\uD568");
} }
/** /**
* 유니폼 업데이트 * 유니폼 업데이트
@ -73,6 +88,7 @@ var ThreeScene = class {
* 렌더링 * 렌더링
*/ */
render() { render() {
console.log("[ThreeScene] render() \uD638\uCD9C\uB428, mesh:", this.mesh);
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera);
} }
/** /**
@ -104,25 +120,36 @@ var ShaderManager = class {
* @returns 로드된 셰이더 소스 코드 * @returns 로드된 셰이더 소스 코드
*/ */
async loadShaders(vertexPath, fragmentPath) { async loadShaders(vertexPath, fragmentPath) {
console.log("[ShaderManager] loadShaders \uC2DC\uC791:", { vertexPath, fragmentPath });
try { try {
console.log("[ShaderManager] fetch \uC2DC\uC791...");
const [vertexResponse, fragmentResponse] = await Promise.all([ const [vertexResponse, fragmentResponse] = await Promise.all([
fetch(vertexPath), fetch(vertexPath),
fetch(fragmentPath) fetch(fragmentPath)
]); ]);
console.log("[ShaderManager] fetch \uC644\uB8CC:", {
vertexStatus: vertexResponse.status,
fragmentStatus: fragmentResponse.status
});
if (!vertexResponse.ok) { if (!vertexResponse.ok) {
throw new Error(`\uBC84\uD14D\uC2A4 \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328: ${vertexResponse.statusText}`); throw new Error(`\uBC84\uD14D\uC2A4 \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328: ${vertexResponse.statusText}`);
} }
if (!fragmentResponse.ok) { if (!fragmentResponse.ok) {
throw new Error(`\uD504\uB798\uADF8\uBA3C\uD2B8 \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328: ${fragmentResponse.statusText}`); throw new Error(`\uD504\uB798\uADF8\uBA3C\uD2B8 \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328: ${fragmentResponse.statusText}`);
} }
console.log("[ShaderManager] text() \uBCC0\uD658 \uC2DC\uC791...");
this.vertexShaderSource = await vertexResponse.text(); this.vertexShaderSource = await vertexResponse.text();
this.fragmentShaderSource = await fragmentResponse.text(); this.fragmentShaderSource = await fragmentResponse.text();
console.log("[ShaderManager] \uC170\uC774\uB354 \uB85C\uB4DC \uC644\uB8CC!", {
vertexLength: this.vertexShaderSource.length,
fragmentLength: this.fragmentShaderSource.length
});
return { return {
vertex: this.vertexShaderSource, vertex: this.vertexShaderSource,
fragment: this.fragmentShaderSource fragment: this.fragmentShaderSource
}; };
} catch (error) { } catch (error) {
console.error("\uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328:", error); console.error("[ShaderManager] \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328:", error);
throw new Error("\uC170\uC774\uB354 \uB85C\uB529\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4"); throw new Error("\uC170\uC774\uB354 \uB85C\uB529\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4");
} }
} }
@ -279,6 +306,7 @@ var ImageDistortion = ({
const shaderManagerRef = useRef2(new ShaderManager()); const shaderManagerRef = useRef2(new ShaderManager());
const textureRef = useRef2(null); const textureRef = useRef2(null);
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [currentAreas, setCurrentAreas] = useState(areas); const [currentAreas, setCurrentAreas] = useState(areas);
useEffect2(() => { useEffect2(() => {
setCurrentAreas(areas); setCurrentAreas(areas);
@ -315,22 +343,34 @@ var ImageDistortion = ({
return; return;
} }
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2DC\uC791:", imageSrc); console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2DC\uC791:", imageSrc);
setImageLoaded(false);
const loader = new THREE2.TextureLoader(); const loader = new THREE2.TextureLoader();
loader.load( loader.load(
imageSrc, imageSrc,
(texture) => { (texture) => {
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC131\uACF5"); console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC131\uACF5!", {
width: texture.image.width,
height: texture.image.height
});
textureRef.current = texture; textureRef.current = texture;
setImageLoaded(true);
if (sceneRef.current) { if (sceneRef.current) {
sceneRef.current.updateUniforms({ sceneRef.current.updateUniforms({
u_texture: { value: texture } u_texture: { value: texture }
}); });
sceneRef.current.render(); sceneRef.current.render();
console.log("[ImageDistortion] \uD14D\uC2A4\uCC98 \uC5C5\uB370\uC774\uD2B8 \uBC0F \uB80C\uB354\uB9C1 \uC644\uB8CC");
} }
}, },
void 0, (progress) => {
console.log(
"[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB529 \uC911...",
Math.round(progress.loaded / progress.total * 100) + "%"
);
},
(error) => { (error) => {
console.error("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2E4\uD328:", error); console.error("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2E4\uD328:", error);
setImageLoaded(false);
} }
); );
return () => { return () => {
@ -386,7 +426,24 @@ var ImageDistortion = ({
position: "relative", position: "relative",
...style ...style
}, },
className className,
children: !imageLoaded && /* @__PURE__ */ jsx(
"div",
{
style: {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(0, 0, 0, 0.7)",
color: "white",
padding: "20px",
borderRadius: "8px",
zIndex: 999
},
children: "\uC774\uBBF8\uC9C0 \uB85C\uB529 \uC911..."
}
)
} }
); );
}; };

2
dist/index.mjs.map vendored

File diff suppressed because one or more lines are too long

8
dist/test.frag.glsl vendored Normal file
View File

@ -0,0 +1,8 @@
uniform sampler2D u_texture;
varying vec2 vUv;
void main() {
// 간단한 테스트: 텍스처를 그대로 표시 (왜곡 없음)
vec4 color = texture2D(u_texture, vUv);
gl_FragColor = color;
}

View File

@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState, useCallback } from 'react'; import React, { useEffect, useRef, useState, useCallback } from 'react';
import * as THREE from 'three'; import * as THREE from 'three';
import { DistortionArea } from '../types'; import { type DistortionArea } from '../types';
import { ThreeScene } from '../engine/ThreeScene'; import { ThreeScene } from '../engine/ThreeScene';
import { ShaderManager } from '../engine/ShaderManager'; import { ShaderManager } from '../engine/ShaderManager';
import { AnimationLoop } from '../engine/AnimationLoop'; import { AnimationLoop } from '../engine/AnimationLoop';
@ -46,6 +46,7 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
const textureRef = useRef<THREE.Texture | null>(null); const textureRef = useRef<THREE.Texture | null>(null);
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [currentAreas, setCurrentAreas] = useState<DistortionArea[]>(areas); const [currentAreas, setCurrentAreas] = useState<DistortionArea[]>(areas);
// 영역 변경 시 상태 업데이트 // 영역 변경 시 상태 업데이트
@ -99,22 +100,34 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
} }
console.log('[ImageDistortion] 이미지 로드 시작:', imageSrc); console.log('[ImageDistortion] 이미지 로드 시작:', imageSrc);
setImageLoaded(false);
const loader = new THREE.TextureLoader(); const loader = new THREE.TextureLoader();
loader.load( loader.load(
imageSrc, imageSrc,
(texture) => { (texture) => {
console.log('[ImageDistortion] 이미지 로드 성공'); console.log('[ImageDistortion] 이미지 로드 성공!', {
width: texture.image.width,
height: texture.image.height
});
textureRef.current = texture; textureRef.current = texture;
setImageLoaded(true);
if (sceneRef.current) { if (sceneRef.current) {
sceneRef.current.updateUniforms({ sceneRef.current.updateUniforms({
u_texture: { value: texture }, u_texture: { value: texture },
}); });
sceneRef.current.render(); sceneRef.current.render();
console.log('[ImageDistortion] 텍스처 업데이트 및 렌더링 완료');
} }
}, },
undefined, (progress) => {
console.log('[ImageDistortion] 이미지 로딩 중...',
Math.round((progress.loaded / progress.total) * 100) + '%'
);
},
(error) => { (error) => {
console.error('[ImageDistortion] 이미지 로드 실패:', error); console.error('[ImageDistortion] 이미지 로드 실패:', error);
setImageLoaded(false);
} }
); );
@ -188,6 +201,24 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
...style, ...style,
}} }}
className={className} className={className}
/> >
{!imageLoaded && (
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: 'rgba(0, 0, 0, 0.7)',
color: 'white',
padding: '20px',
borderRadius: '8px',
zIndex: 999,
}}
>
...
</div>
)}
</div>
); );
}; };

View File

@ -1,5 +1,5 @@
import { DistortionArea, Point } from '../types';
import { applyEasing } from '../utils/easing'; import { applyEasing } from '../utils/easing';
import type {DistortionArea, Point} from "../types";
/** /**
* *

View File

@ -15,12 +15,20 @@ export class ShaderManager {
vertexPath: string, vertexPath: string,
fragmentPath: string fragmentPath: string
): Promise<{ vertex: string; fragment: string }> { ): Promise<{ vertex: string; fragment: string }> {
console.log('[ShaderManager] loadShaders 시작:', { vertexPath, fragmentPath });
try { try {
console.log('[ShaderManager] fetch 시작...');
const [vertexResponse, fragmentResponse] = await Promise.all([ const [vertexResponse, fragmentResponse] = await Promise.all([
fetch(vertexPath), fetch(vertexPath),
fetch(fragmentPath), fetch(fragmentPath),
]); ]);
console.log('[ShaderManager] fetch 완료:', {
vertexStatus: vertexResponse.status,
fragmentStatus: fragmentResponse.status
});
if (!vertexResponse.ok) { if (!vertexResponse.ok) {
throw new Error(`버텍스 셰이더 로드 실패: ${vertexResponse.statusText}`); throw new Error(`버텍스 셰이더 로드 실패: ${vertexResponse.statusText}`);
} }
@ -28,15 +36,21 @@ export class ShaderManager {
throw new Error(`프래그먼트 셰이더 로드 실패: ${fragmentResponse.statusText}`); throw new Error(`프래그먼트 셰이더 로드 실패: ${fragmentResponse.statusText}`);
} }
console.log('[ShaderManager] text() 변환 시작...');
this.vertexShaderSource = await vertexResponse.text(); this.vertexShaderSource = await vertexResponse.text();
this.fragmentShaderSource = await fragmentResponse.text(); this.fragmentShaderSource = await fragmentResponse.text();
console.log('[ShaderManager] 셰이더 로드 완료!', {
vertexLength: this.vertexShaderSource.length,
fragmentLength: this.fragmentShaderSource.length
});
return { return {
vertex: this.vertexShaderSource, vertex: this.vertexShaderSource,
fragment: this.fragmentShaderSource, fragment: this.fragmentShaderSource,
}; };
} catch (error) { } catch (error) {
console.error('셰이더 로드 실패:', error); console.error('[ShaderManager] 셰이더 로드 실패:', error);
throw new Error('셰이더 로딩에 실패했습니다'); throw new Error('셰이더 로딩에 실패했습니다');
} }
} }

View File

@ -1,5 +1,5 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { ShaderUniforms } from '@/types'; import type { ShaderUniforms } from '../types';
/** /**
* Three.js * Three.js
@ -61,6 +61,10 @@ export class ThreeScene {
* @param fragmentShader * @param fragmentShader
*/ */
public setShaderMaterial(vertexShader: string, fragmentShader: string) { public setShaderMaterial(vertexShader: string, fragmentShader: string) {
console.log('[ThreeScene] setShaderMaterial 호출됨');
console.log('[ThreeScene] vertexShader 길이:', vertexShader.length);
console.log('[ThreeScene] fragmentShader 길이:', fragmentShader.length);
const geometry = new THREE.PlaneGeometry(2, 2); const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({ const material = new THREE.ShaderMaterial({
uniforms: this.uniforms, uniforms: this.uniforms,
@ -68,12 +72,28 @@ export class ThreeScene {
fragmentShader, fragmentShader,
}); });
console.log('[ThreeScene] ShaderMaterial 생성됨');
// 셰이더 컴파일 에러 확인
const renderer = this.renderer;
const testScene = new THREE.Scene();
const testMesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
testScene.add(testMesh);
try {
renderer.compile(testScene, this.camera);
console.log('[ThreeScene] 셰이더 컴파일 성공!');
} catch (e) {
console.error('[ThreeScene] 셰이더 컴파일 에러:', e);
}
if (this.mesh) { if (this.mesh) {
this.scene.remove(this.mesh); this.scene.remove(this.mesh);
} }
this.mesh = new THREE.Mesh(geometry, material); this.mesh = new THREE.Mesh(geometry, material);
this.scene.add(this.mesh); this.scene.add(this.mesh);
console.log('[ThreeScene] mesh를 씬에 추가함');
} }
/** /**
@ -91,6 +111,7 @@ export class ThreeScene {
* *
*/ */
public render() { public render() {
console.log('[ThreeScene] render() 호출됨, mesh:', this.mesh);
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera);
} }

View File

@ -0,0 +1,39 @@
uniform vec2 u_resolution;
uniform sampler2D u_texture;
uniform vec2 u_points[32];
uniform int u_numAreas;
uniform vec2 u_dragVectors[8];
uniform float u_distortionStrengths[8];
varying vec2 vUv;
void main() {
vec2 texCoord = vUv;
// 디버그: 영역 표시
for (int i = 0; i < 8; i++) {
if (i >= u_numAreas) break;
// 포인트를 픽셀 좌표로 변환
vec2 p0 = u_points[i * 4 + 0] * u_resolution;
vec2 p1 = u_points[i * 4 + 1] * u_resolution;
vec2 p2 = u_points[i * 4 + 2] * u_resolution;
vec2 p3 = u_points[i * 4 + 3] * u_resolution;
vec2 pixelCoord = vUv * u_resolution;
// 경계 상자 체크만
vec2 minP = min(min(p0, p1), min(p2, p3));
vec2 maxP = max(max(p0, p1), max(p2, p3));
// 영역 안에 있으면 빨간색으로 표시
if (pixelCoord.x >= minP.x && pixelCoord.x <= maxP.x &&
pixelCoord.y >= minP.y && pixelCoord.y <= maxP.y) {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // 빨간색
return;
}
}
// 영역 밖은 원본 이미지
gl_FragColor = texture2D(u_texture, texCoord);
}

View File

@ -1,88 +1,87 @@
uniform vec2 u_resolution; uniform vec2 u_resolution;
uniform sampler2D u_texture; uniform sampler2D u_texture;
uniform vec2 u_points[32]; // 최대 8영역 × 4포인트 uniform vec2 u_points[32]; // 최대 8영역 × 4포인트 (정규화된 좌표)
uniform int u_numAreas; uniform int u_numAreas;
uniform vec2 u_dragVectors[8]; uniform vec2 u_dragVectors[8]; // 정규화된 좌표
uniform float u_distortionStrengths[8]; uniform float u_distortionStrengths[8];
varying vec2 vUv; varying vec2 vUv;
// 사각형 내부의 포인트에 대한 UV 좌표 계산 // Flutter 원본 computeUV 함수 (정확히 동일하게 변환)
vec2 computeUV(vec2 xy, vec2 p0, vec2 p1, vec2 p2, vec2 p3) { vec2 computeUV(vec2 xy, vec2 p0, vec2 p1, vec2 p2, vec2 p3) {
// 경계 상자 체크
vec2 minP = min(min(p0, p1), min(p2, p3)); vec2 minP = min(min(p0, p1), min(p2, p3));
vec2 maxP = max(max(p0, p1), max(p2, p3)); vec2 maxP = max(max(p0, p1), max(p2, p3));
if (xy.x < minP.x || xy.x > maxP.x || xy.y < minP.y || xy.y > maxP.y) { if (xy.x < minP.x || xy.x > maxP.x || xy.y < minP.y || xy.y > maxP.y) {
return vec2(-1.0, -1.0); return vec2(-1.0, -1.0); // 외부
} }
// 초기 추정값 (정규화된 좌표)
vec2 rectSize = maxP - minP; vec2 rectSize = maxP - minP;
vec2 rectUV = (xy - minP) / rectSize; if (rectSize.x == 0.0 || rectSize.y == 0.0) {
return vec2(-1.0, -1.0); // 축퇴
}
vec2 rectMin = minP;
vec2 rectUV = (xy - rectMin) / rectSize;
float u0 = rectUV.x; float u0 = rectUV.x;
float v0 = rectUV.y; float v0 = rectUV.y;
// Newton-Raphson 반복법으로 정확한 UV 계산 // 1회 Newton-Raphson (Flutter 원본과 동일)
for (int iter = 0; iter < 3; iter++) { vec2 left = mix(p0, p1, u0);
vec2 xy0 = mix(mix(p0, p1, u0), mix(p3, p2, u0), v0); vec2 right = mix(p3, p2, u0);
vec2 xy0 = mix(left, right, v0);
vec2 dxy = xy - xy0;
vec2 du_vec = mix(p1 - p0, p2 - p3, v0); vec2 du_vec = mix(p1 - p0, p2 - p3, v0);
vec2 dv_vec = mix(p3 - p0, p2 - p1, u0); vec2 dv_vec = mix(p3 - p0, p2 - p1, u0);
vec2 dxy = xy - xy0;
float det = du_vec.x * dv_vec.y - du_vec.y * dv_vec.x; float det = du_vec.x * dv_vec.y - du_vec.y * dv_vec.x;
if (abs(det) > 1e-6) {
if (abs(det) < 1e-6) break; float inv_det = 1.0 / det;
float du = (dv_vec.y * dxy.x - dv_vec.x * dxy.y) * inv_det;
float du = (dv_vec.y * dxy.x - dv_vec.x * dxy.y) / det; float dv = (-du_vec.y * dxy.x + du_vec.x * dxy.y) * inv_det;
float dv = (-du_vec.y * dxy.x + du_vec.x * dxy.y) / det;
u0 += du; u0 += du;
v0 += dv; v0 += dv;
} }
// 포인트가 내부에 있는지 확인
if (u0 >= 0.0 && u0 <= 1.0 && v0 >= 0.0 && v0 <= 1.0) {
return vec2(u0, v0); return vec2(u0, v0);
}
return vec2(-1.0, -1.0);
} }
void main() { void main() {
vec2 uv = vUv; vec2 xy = vUv * u_resolution; // 픽셀 좌표
vec2 pixelCoord = vUv * u_resolution; vec2 texCoord = vUv;
bool found = false;
// 모든 영역의 왜곡 적용 // Flutter 원본과 동일: 첫 번째 매칭되는 영역만 적용
for (int i = 0; i < 8; i++) { for (int i = 0; i < 8; i++) {
if (i >= u_numAreas) break; if (i >= u_numAreas) break;
int baseIndex = i * 4; // 포인트는 정규화된 좌표로 전달받았으므로 픽셀 좌표로 변환
vec2 p0 = u_points[baseIndex + 0] * u_resolution; vec2 p0 = u_points[i * 4 + 0] * u_resolution;
vec2 p1 = u_points[baseIndex + 1] * u_resolution; vec2 p1 = u_points[i * 4 + 1] * u_resolution;
vec2 p2 = u_points[baseIndex + 2] * u_resolution; vec2 p2 = u_points[i * 4 + 2] * u_resolution;
vec2 p3 = u_points[baseIndex + 3] * u_resolution; vec2 p3 = u_points[i * 4 + 3] * u_resolution;
vec2 areaUV = computeUV(pixelCoord, p0, p1, p2, p3); vec2 uv_local = computeUV(xy, p0, p1, p2, p3);
if (areaUV.x >= 0.0) { if (uv_local.x >= 0.0 && uv_local.x <= 1.0 && uv_local.y >= 0.0 && uv_local.y <= 1.0) {
// 이 영역 내부에 포인트가 있음 vec2 uvCenter = vec2(0.5, 0.5);
vec2 center = vec2(0.5, 0.5); float distToCenter = distance(uv_local, uvCenter);
float distToCenter = length(areaUV - center); float maxUvRadius = 0.5; // Flutter 원본과 동일
float maxUvRadius = 0.707; // sqrt(0.5^2 + 0.5^2)
// 부드러운 감쇠 if (distToCenter < maxUvRadius) {
float influence = 1.0 - smoothstep(0.0, maxUvRadius, distToCenter); float influence = 1.0 - smoothstep(0.0, maxUvRadius, distToCenter);
// dragVector도 정규화된 좌표이므로 픽셀로 변환
// 왜곡 적용 vec2 distortion = (u_dragVectors[i] * u_resolution) * influence * u_distortionStrengths[i];
vec2 distortion = (u_dragVectors[i] / u_resolution) * influence * u_distortionStrengths[i]; // texCoord는 이미 정규화된 좌표이므로 정규화된 왜곡 적용
uv += distortion; texCoord += distortion / u_resolution;
texCoord = clamp(texCoord, 0.0, 1.0);
}
found = true;
break; // Flutter 원본처럼 첫 번째 영역만 적용
} }
} }
// 텍스처 외부 샘플링 방지를 위한 클램핑 gl_FragColor = texture2D(u_texture, texCoord);
uv = clamp(uv, 0.0, 1.0);
// 텍스처 샘플링
gl_FragColor = texture2D(u_texture, uv);
} }

View File

@ -0,0 +1,8 @@
uniform sampler2D u_texture;
varying vec2 vUv;
void main() {
// 간단한 테스트: 텍스처를 그대로 표시 (왜곡 없음)
vec4 color = texture2D(u_texture, vUv);
gl_FragColor = color;
}

View File

@ -4,7 +4,7 @@ import * as THREE from 'three';
* *
*/ */
export interface ShaderUniforms { export interface ShaderUniforms {
[uniform: string]: THREE.IUniform<any>; [uniform: string]: THREE.IUniform;
/** 화면 해상도 */ /** 화면 해상도 */
u_resolution: THREE.IUniform<THREE.Vector2>; u_resolution: THREE.IUniform<THREE.Vector2>;
/** 이미지 텍스처 */ /** 이미지 텍스처 */

View File

@ -1,4 +1,4 @@
import { EasingFunction } from '../types'; import { type EasingFunction } from '../types';
type EasingFunc = (t: number) => number; type EasingFunc = (t: number) => number;