@mirage-engine/core 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/dist/mirage-engine.js +1020 -431
- package/dist/mirage-engine.umd.js +136 -72
- package/dist/src/animation/Animator.d.ts +11 -0
- package/dist/src/core/Engine.d.ts +2 -0
- package/dist/src/core/Syncer.d.ts +8 -1
- package/dist/src/renderer/Renderer.d.ts +8 -4
- package/dist/src/store/MeshRegistry.d.ts +9 -0
- package/dist/src/store/TextureLifecycleManager.d.ts +16 -0
- package/dist/src/types/animate.d.ts +19 -0
- package/dist/src/types/common.d.ts +5 -0
- package/dist/src/types/index.d.ts +1 -0
- package/package.json +2 -2
- package/src/animation/Animator.ts +163 -0
- package/src/core/Engine.ts +56 -4
- package/src/core/Syncer.ts +71 -16
- package/src/dom/Extractor.ts +165 -20
- package/src/env.d.ts +4 -0
- package/src/renderer/Renderer.ts +186 -74
- package/src/store/MeshRegistry.ts +32 -0
- package/src/store/TextureLifecycleManager.ts +111 -0
- package/src/types/animate.ts +26 -0
- package/src/types/common.ts +2 -0
- package/src/types/index.ts +2 -1
package/src/env.d.ts
ADDED
package/src/renderer/Renderer.ts
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
travelerClipArea,
|
|
11
11
|
} from "../types";
|
|
12
12
|
import { Painter } from "@mirage-engine/painter";
|
|
13
|
+
import { MeshRegistry } from "../store/MeshRegistry";
|
|
14
|
+
import { TextureLifecycleManager } from "../store/TextureLifecycleManager";
|
|
13
15
|
|
|
14
16
|
export class Renderer {
|
|
15
17
|
public readonly canvas: HTMLCanvasElement;
|
|
@@ -24,17 +26,31 @@ export class Renderer {
|
|
|
24
26
|
|
|
25
27
|
private target: HTMLElement;
|
|
26
28
|
private mountContainer: HTMLElement;
|
|
29
|
+
private registry: MeshRegistry;
|
|
27
30
|
private targetRect: DOMRect;
|
|
28
31
|
|
|
29
|
-
private
|
|
32
|
+
private travelers: Set<THREE.Mesh> = new Set();
|
|
33
|
+
private textureManager: TextureLifecycleManager;
|
|
34
|
+
// private meshMap: Map<HTMLElement, THREE.Mesh> = new Map();
|
|
35
|
+
|
|
36
|
+
public readonly fixedMeshes: Set<THREE.Mesh> = new Set();
|
|
30
37
|
|
|
31
38
|
constructor(
|
|
32
39
|
target: HTMLElement,
|
|
33
40
|
config: CoreConfig,
|
|
34
41
|
mountContainer: HTMLElement,
|
|
42
|
+
registry: MeshRegistry,
|
|
35
43
|
) {
|
|
36
44
|
this.target = target;
|
|
37
45
|
this.mountContainer = mountContainer;
|
|
46
|
+
this.registry = registry;
|
|
47
|
+
|
|
48
|
+
this.textureManager = new TextureLifecycleManager((el, texture) => {
|
|
49
|
+
const mesh = this.registry.get(el);
|
|
50
|
+
if (mesh && mesh.material instanceof THREE.ShaderMaterial) {
|
|
51
|
+
Painter.forceUpdateUniforms(mesh.material, { texture: texture });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
38
54
|
|
|
39
55
|
this.mode = config.mode ?? "overlay";
|
|
40
56
|
this.clipArea = config.travelerClipArea ?? 1;
|
|
@@ -60,8 +76,6 @@ export class Renderer {
|
|
|
60
76
|
this.camera.position.z = 100;
|
|
61
77
|
this.camera.layers.set(0);
|
|
62
78
|
|
|
63
|
-
// [new]
|
|
64
|
-
// THREE.ColorManagement.enabled = false;
|
|
65
79
|
|
|
66
80
|
this.renderer = new THREE.WebGLRenderer({
|
|
67
81
|
canvas: this.canvas,
|
|
@@ -71,6 +85,11 @@ export class Renderer {
|
|
|
71
85
|
// premultipliedAlpha: true
|
|
72
86
|
});
|
|
73
87
|
|
|
88
|
+
|
|
89
|
+
THREE.ColorManagement.enabled = false;
|
|
90
|
+
this.renderer.outputColorSpace = THREE.LinearSRGBColorSpace;
|
|
91
|
+
|
|
92
|
+
|
|
74
93
|
this.renderer.setPixelRatio(window.devicePixelRatio);
|
|
75
94
|
this.renderer.setSize(width, height);
|
|
76
95
|
|
|
@@ -86,7 +105,7 @@ export class Renderer {
|
|
|
86
105
|
format: THREE.RGBAFormat,
|
|
87
106
|
stencilBuffer: false,
|
|
88
107
|
depthBuffer: true,
|
|
89
|
-
samples:
|
|
108
|
+
// samples: 0.7,
|
|
90
109
|
},
|
|
91
110
|
);
|
|
92
111
|
}
|
|
@@ -138,6 +157,7 @@ export class Renderer {
|
|
|
138
157
|
public dispose() {
|
|
139
158
|
this.renderer.dispose();
|
|
140
159
|
this.canvas.remove();
|
|
160
|
+
this.textureManager.disposeAll();
|
|
141
161
|
// TODO: Scene 내부 Mesh들도 순회하며 dispose
|
|
142
162
|
}
|
|
143
163
|
|
|
@@ -156,7 +176,7 @@ export class Renderer {
|
|
|
156
176
|
this.camera.updateProjectionMatrix();
|
|
157
177
|
}
|
|
158
178
|
|
|
159
|
-
public syncScene(graphNode: SceneNode) {
|
|
179
|
+
public syncScene(graphNode: SceneNode, pendingDeletions: Set<HTMLElement>) {
|
|
160
180
|
const newRect = this.target.getBoundingClientRect();
|
|
161
181
|
|
|
162
182
|
const isResized =
|
|
@@ -182,28 +202,59 @@ export class Renderer {
|
|
|
182
202
|
|
|
183
203
|
this.renderOrder = 0;
|
|
184
204
|
|
|
185
|
-
const activeElements = new Set<HTMLElement>();
|
|
186
|
-
|
|
187
|
-
this.reconcileNode(graphNode, activeElements);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
205
|
+
// const activeElements = new Set<HTMLElement>();
|
|
206
|
+
|
|
207
|
+
// this.reconcileNode(graphNode, activeElements);
|
|
208
|
+
this.reconcileNode(graphNode);
|
|
209
|
+
|
|
210
|
+
if (pendingDeletions.size > 0) {
|
|
211
|
+
pendingDeletions.forEach((el) => {
|
|
212
|
+
const meshToDestroy = this.registry.get(el) as THREE.Mesh | undefined;
|
|
213
|
+
if (meshToDestroy) {
|
|
214
|
+
this.scene.remove(meshToDestroy);
|
|
215
|
+
this.travelers.delete(meshToDestroy);
|
|
216
|
+
this.fixedMeshes.delete(meshToDestroy);
|
|
217
|
+
meshToDestroy.geometry.dispose();
|
|
218
|
+
meshToDestroy.traverse((child) => {
|
|
219
|
+
if (child instanceof THREE.Mesh) {
|
|
220
|
+
if (child.geometry) child.geometry.dispose();
|
|
221
|
+
if (child.material) {
|
|
222
|
+
if (Array.isArray(child.material)) {
|
|
223
|
+
child.material.forEach((mat) => mat.dispose());
|
|
224
|
+
} else {
|
|
225
|
+
child.material.dispose();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
this.registry.remove(el);
|
|
231
|
+
this.textureManager.unregister(el);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
197
234
|
}
|
|
235
|
+
|
|
236
|
+
// mesh.geometry.dispose();
|
|
237
|
+
// if (mesh.material instanceof THREE.Material) mesh.material.dispose();
|
|
238
|
+
// this.meshMap.delete(el);
|
|
239
|
+
// }
|
|
240
|
+
// }
|
|
198
241
|
}
|
|
199
242
|
|
|
200
|
-
|
|
201
|
-
|
|
243
|
+
// 탐색 후 완성된 Scene node를 이용하여 mesh를 만들거나 조정
|
|
244
|
+
// => 이 과정에서 scene node에 있는 ele를 넣은 hash => activeElements
|
|
245
|
+
// => 이후 activeElements를 이용하여 mesh를 정리!!!+ map에서도 삭제
|
|
202
246
|
|
|
203
|
-
|
|
247
|
+
// private reconcileNode(node: SceneNode, activeElements: Set<HTMLElement>) {
|
|
248
|
+
private reconcileNode(node: SceneNode) {
|
|
249
|
+
// activeElements.add(node.element);
|
|
250
|
+
|
|
251
|
+
// let mesh = this.meshMap.get(node.element);
|
|
252
|
+
let mesh = this.registry.get(node.element) as THREE.Mesh | undefined;
|
|
204
253
|
if (!mesh) {
|
|
205
254
|
const geometry = new THREE.PlaneGeometry(1, 1);
|
|
206
255
|
let material: THREE.MeshBasicMaterial | THREE.Material;
|
|
256
|
+
const initialTexture = node.isTraveler ? this.renderTarget?.texture : this.textureManager.get(node.element);
|
|
257
|
+
|
|
207
258
|
material = Painter.create(
|
|
208
259
|
"BOX",
|
|
209
260
|
node.styles,
|
|
@@ -211,24 +262,43 @@ export class Renderer {
|
|
|
211
262
|
node.rect.width,
|
|
212
263
|
node.rect.height,
|
|
213
264
|
this.qualityFactor,
|
|
214
|
-
|
|
215
|
-
node.shaderHooks
|
|
265
|
+
initialTexture,
|
|
266
|
+
node.shaderHooks,
|
|
216
267
|
);
|
|
217
268
|
mesh = new THREE.Mesh(geometry, material);
|
|
218
269
|
if (node.type === "TEXT") mesh.name = "BG_MESH";
|
|
219
270
|
this.scene.add(mesh);
|
|
220
|
-
this.
|
|
271
|
+
this.registry.register(node.element, mesh);
|
|
221
272
|
}
|
|
222
273
|
|
|
274
|
+
// [Important] use whene mesh animating with js
|
|
223
275
|
mesh.userData.domRect = node.rect;
|
|
224
276
|
|
|
225
277
|
this.updateMeshProperties(mesh, node);
|
|
226
278
|
this.updateMeshLayers(mesh, node);
|
|
227
|
-
if (node.isTraveler)
|
|
279
|
+
if (node.isTraveler) {
|
|
280
|
+
mesh.layers.enable(28);
|
|
281
|
+
this.travelers.add(mesh);
|
|
282
|
+
} else {
|
|
283
|
+
mesh.layers.disable(28);
|
|
284
|
+
this.travelers.delete(mesh);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (node.isFixed) {
|
|
288
|
+
this.fixedMeshes.add(mesh);
|
|
289
|
+
} else {
|
|
290
|
+
this.fixedMeshes.delete(mesh);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (node.styles.imageSrc) {
|
|
294
|
+
this.textureManager.register(node.element, node.styles.imageSrc);
|
|
295
|
+
} else {
|
|
296
|
+
this.textureManager.unregister(node.element);
|
|
297
|
+
}
|
|
228
298
|
|
|
229
299
|
if (node.type === "BOX") {
|
|
230
300
|
for (const child of node.children) {
|
|
231
|
-
this.reconcileNode(child
|
|
301
|
+
this.reconcileNode(child);
|
|
232
302
|
}
|
|
233
303
|
} else if (node.type === "TEXT") {
|
|
234
304
|
this.reconcileTextChild(mesh, node);
|
|
@@ -236,55 +306,66 @@ export class Renderer {
|
|
|
236
306
|
}
|
|
237
307
|
|
|
238
308
|
private reconcileTextChild(parentMesh: THREE.Mesh, node: SceneNode) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const currentStyleHash = JSON.stringify(node.textStyles);
|
|
244
|
-
const cachedStyleHash = textMesh?.userData?.styleHash;
|
|
309
|
+
const lines = node.textLines || [{ text: node.textContent || "", rect: node.rect }];
|
|
310
|
+
const currentStyleHash = JSON.stringify(node.textStyles) + node.textContent + lines.map(l => l.text).join("|");
|
|
311
|
+
const cachedStyleHash = parentMesh.userData?.textChildStyleHash;
|
|
245
312
|
const isDirty =
|
|
246
|
-
!textMesh ||
|
|
247
313
|
node.dirtyMask & DIRTY_CONTENT ||
|
|
248
314
|
currentStyleHash !== cachedStyleHash;
|
|
249
315
|
|
|
250
316
|
if (isDirty) {
|
|
251
|
-
|
|
317
|
+
// Remove all existing TEXT_CHILD meshes
|
|
318
|
+
const existingChildren = parentMesh.children.filter(c => c.name.startsWith("TEXT_CHILD"));
|
|
319
|
+
existingChildren.forEach(child => {
|
|
320
|
+
const textMesh = child as THREE.Mesh;
|
|
252
321
|
(textMesh.material as THREE.MeshBasicMaterial).map?.dispose();
|
|
253
322
|
textMesh.geometry.dispose();
|
|
254
323
|
parentMesh.remove(textMesh);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const material = Painter.create(
|
|
258
|
-
"TEXT",
|
|
259
|
-
node.textStyles!,
|
|
260
|
-
node.textContent || "",
|
|
261
|
-
node.rect.width,
|
|
262
|
-
node.rect.height,
|
|
263
|
-
this.qualityFactor,
|
|
264
|
-
);
|
|
324
|
+
});
|
|
265
325
|
|
|
266
|
-
const
|
|
267
|
-
textMesh = new THREE.Mesh(geometry, material);
|
|
268
|
-
|
|
269
|
-
textMesh.name = "TEXT_CHILD";
|
|
270
|
-
textMesh.userData = { styleHash: currentStyleHash };
|
|
271
|
-
this.updateMeshLayers(textMesh, node);
|
|
272
|
-
textMesh.position.z = 0.005;
|
|
273
|
-
parentMesh.add(textMesh);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
if (textMesh) {
|
|
277
|
-
const parentRect = parentMesh.userData.domRect;
|
|
326
|
+
const parentRect = node.rect;
|
|
278
327
|
const parentCenterX = parentRect.x + parentRect.width / 2;
|
|
279
328
|
const parentCenterY = parentRect.y + parentRect.height / 2;
|
|
280
329
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
330
|
+
lines.forEach((line, index) => {
|
|
331
|
+
const material = Painter.create(
|
|
332
|
+
"TEXT",
|
|
333
|
+
node.textStyles!,
|
|
334
|
+
line.text,
|
|
335
|
+
line.rect.width,
|
|
336
|
+
line.rect.height,
|
|
337
|
+
this.qualityFactor,
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
const geometry = new THREE.PlaneGeometry(1, 1);
|
|
341
|
+
const textMesh = new THREE.Mesh(geometry, material);
|
|
342
|
+
|
|
343
|
+
textMesh.name = `TEXT_CHILD_${index}`;
|
|
344
|
+
this.updateMeshLayers(textMesh, node);
|
|
345
|
+
|
|
346
|
+
// Parent is already scaled to node.rect.width/height.
|
|
347
|
+
// We counter-scale the child so its absolute size is exactly line.rect.width/height.
|
|
348
|
+
const scaleX = node.rect.width === 0 ? 1 : line.rect.width / node.rect.width;
|
|
349
|
+
const scaleY = node.rect.height === 0 ? 1 : line.rect.height / node.rect.height;
|
|
350
|
+
textMesh.scale.set(scaleX, scaleY, 1);
|
|
351
|
+
|
|
352
|
+
const textCenterX = line.rect.x + line.rect.width / 2;
|
|
353
|
+
const textCenterY = line.rect.y + line.rect.height / 2;
|
|
354
|
+
|
|
355
|
+
const offsetX = textCenterX - parentCenterX;
|
|
356
|
+
const offsetY = -(textCenterY - parentCenterY);
|
|
357
|
+
|
|
358
|
+
// Position must also be counter-scaled relative to parent's scale
|
|
359
|
+
textMesh.position.set(
|
|
360
|
+
node.rect.width === 0 ? 0 : offsetX / node.rect.width,
|
|
361
|
+
node.rect.height === 0 ? 0 : offsetY / node.rect.height,
|
|
362
|
+
0.005
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
parentMesh.add(textMesh);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
parentMesh.userData.textChildStyleHash = currentStyleHash;
|
|
288
369
|
}
|
|
289
370
|
}
|
|
290
371
|
|
|
@@ -306,11 +387,44 @@ export class Renderer {
|
|
|
306
387
|
const localX = rect.x - targetPageX;
|
|
307
388
|
const localY = rect.y - targetPageY;
|
|
308
389
|
|
|
390
|
+
const baseX = localX - canvasWidth / 2 + rect.width / 2;
|
|
391
|
+
const baseY = -localY + canvasHeight / 2 - rect.height / 2;
|
|
392
|
+
|
|
309
393
|
mesh.position.set(
|
|
310
|
-
|
|
311
|
-
|
|
394
|
+
baseX,
|
|
395
|
+
baseY,
|
|
312
396
|
styles.zIndex + this.renderOrder * Z_MICRO_OFFSET,
|
|
313
397
|
);
|
|
398
|
+
const pureDOM_X = rect.x; // 트랜스폼 오염 전 순수 좌표 (가정)
|
|
399
|
+
const pureDOM_Y = rect.y;
|
|
400
|
+
|
|
401
|
+
mesh.userData.basePosition = { x: baseX, y: baseY };
|
|
402
|
+
mesh.userData.originalBasePosition = { x: baseX, y: baseY };
|
|
403
|
+
mesh.userData.baseSize = { width: rect.width, height: rect.height };
|
|
404
|
+
// ✨ 추가: 역산을 위한 순수 DOM 초기 좌표 저장
|
|
405
|
+
mesh.userData.baseDOM = { x: pureDOM_X, y: pureDOM_Y };
|
|
406
|
+
mesh.userData.isFixed = node.isFixed;
|
|
407
|
+
mesh.userData.initialScroll = { x: window.scrollX, y: window.scrollY };
|
|
408
|
+
|
|
409
|
+
// ✨ 추가: 초기 transform 상태를 저장하여 애니메이션 시 delta 값만 적용하도록 함 (이중 적용 방지)
|
|
410
|
+
const targetEl = node.element.nodeType === Node.TEXT_NODE ? node.element.parentElement! : node.element;
|
|
411
|
+
const computed = window.getComputedStyle(targetEl);
|
|
412
|
+
let baseTx = 0, baseTy = 0;
|
|
413
|
+
if (computed.transform && computed.transform !== "none") {
|
|
414
|
+
const matrix = new DOMMatrix(computed.transform);
|
|
415
|
+
baseTx = matrix.m41;
|
|
416
|
+
baseTy = matrix.m42;
|
|
417
|
+
}
|
|
418
|
+
mesh.userData.baseTransform = { x: baseTx, y: baseTy };
|
|
419
|
+
|
|
420
|
+
// ✨ 추가: 애니메이션이 끝나고 씬이 갱신되면 비율 캐시 초기화!
|
|
421
|
+
delete mesh.userData.originRatioX;
|
|
422
|
+
delete mesh.userData.originRatioY;
|
|
423
|
+
// mesh.position.set(
|
|
424
|
+
// localX - canvasWidth / 2 + rect.width / 2,
|
|
425
|
+
// -localY + canvasHeight / 2 - rect.height / 2,
|
|
426
|
+
// styles.zIndex + this.renderOrder * Z_MICRO_OFFSET,
|
|
427
|
+
// );
|
|
314
428
|
Painter.update(
|
|
315
429
|
mesh.material as THREE.Material,
|
|
316
430
|
"BOX",
|
|
@@ -319,7 +433,7 @@ export class Renderer {
|
|
|
319
433
|
node.rect.width,
|
|
320
434
|
node.rect.height,
|
|
321
435
|
this.qualityFactor,
|
|
322
|
-
node.isTraveler ? this.renderTarget?.texture :
|
|
436
|
+
node.isTraveler ? this.renderTarget?.texture : this.textureManager.get(node.element),
|
|
323
437
|
);
|
|
324
438
|
}
|
|
325
439
|
|
|
@@ -330,13 +444,11 @@ export class Renderer {
|
|
|
330
444
|
}
|
|
331
445
|
|
|
332
446
|
private captureRenderTarget() {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
if (travelers.length === 0) return;
|
|
447
|
+
// [Problem] this method called on requestAnimationFrame
|
|
448
|
+
// => this logic travers all meshes every frame, need optimization
|
|
449
|
+
|
|
450
|
+
// if (travelers.length === 0) return;
|
|
451
|
+
if (this.travelers.size === 0) return;
|
|
340
452
|
|
|
341
453
|
const oldClearColor = new THREE.Color();
|
|
342
454
|
const oldClearAlpha = this.renderer.getClearAlpha();
|
|
@@ -357,7 +469,7 @@ export class Renderer {
|
|
|
357
469
|
|
|
358
470
|
const pixelRatio = this.renderer.getPixelRatio();
|
|
359
471
|
|
|
360
|
-
for (const traveler of travelers) {
|
|
472
|
+
for (const traveler of this.travelers) {
|
|
361
473
|
vector.setFromMatrixPosition(traveler.matrixWorld);
|
|
362
474
|
vector.project(this.camera);
|
|
363
475
|
|
|
@@ -392,7 +504,7 @@ export class Renderer {
|
|
|
392
504
|
this.renderer.setScissorTest(false);
|
|
393
505
|
this.renderer.autoClear = true;
|
|
394
506
|
this.renderer.setRenderTarget(null);
|
|
395
|
-
this.camera.layers.set(
|
|
507
|
+
this.camera.layers.set(0);
|
|
396
508
|
this.renderer.setClearColor(oldClearColor, oldClearAlpha);
|
|
397
509
|
}
|
|
398
510
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
|
|
3
|
+
export class MeshRegistry {
|
|
4
|
+
private store: WeakMap<HTMLElement, THREE.Mesh>;
|
|
5
|
+
|
|
6
|
+
constructor() {
|
|
7
|
+
this.store = new WeakMap();
|
|
8
|
+
}
|
|
9
|
+
public register(element: HTMLElement, mesh: THREE.Mesh): void {
|
|
10
|
+
this.store.set(element, mesh);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
public get(element: HTMLElement): THREE.Mesh | undefined {
|
|
14
|
+
return this.store.get(element);
|
|
15
|
+
}
|
|
16
|
+
// public register(element: HTMLElement, meshGroup: THREE.Group): void {
|
|
17
|
+
// this.store.set(element, meshGroup);
|
|
18
|
+
// }
|
|
19
|
+
|
|
20
|
+
// public get(element: HTMLElement): THREE.Group | undefined {
|
|
21
|
+
// return this.store.get(element);
|
|
22
|
+
// }
|
|
23
|
+
|
|
24
|
+
public has(element: HTMLElement): boolean {
|
|
25
|
+
return this.store.has(element);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Optional
|
|
29
|
+
public remove(element: HTMLElement): void {
|
|
30
|
+
this.store.delete(element);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
|
|
3
|
+
export type TextureReadyCallback = (
|
|
4
|
+
element: HTMLElement,
|
|
5
|
+
texture: THREE.Texture | null,
|
|
6
|
+
) => void;
|
|
7
|
+
|
|
8
|
+
export class TextureLifecycleManager {
|
|
9
|
+
private observer: IntersectionObserver;
|
|
10
|
+
private textures: WeakMap<HTMLElement, THREE.Texture> = new WeakMap();
|
|
11
|
+
private loadStatus: WeakMap<HTMLElement, boolean> = new WeakMap();
|
|
12
|
+
private elementUrls: WeakMap<HTMLElement, string> = new WeakMap();
|
|
13
|
+
private onUpdate: TextureReadyCallback;
|
|
14
|
+
|
|
15
|
+
constructor(onUpdate: TextureReadyCallback) {
|
|
16
|
+
this.onUpdate = onUpdate;
|
|
17
|
+
this.observer = new IntersectionObserver(
|
|
18
|
+
(entries) => {
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
const el = entry.target as HTMLElement;
|
|
21
|
+
if (entry.isIntersecting) {
|
|
22
|
+
this.loadTexture(el);
|
|
23
|
+
} else {
|
|
24
|
+
this.disposeTexture(el);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
{ rootMargin: "300px" },
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public register(element: HTMLElement, url: string) {
|
|
33
|
+
if (element.nodeType !== Node.ELEMENT_NODE) return;
|
|
34
|
+
const currentUrl = this.elementUrls.get(element);
|
|
35
|
+
if (currentUrl !== url) {
|
|
36
|
+
this.elementUrls.set(element, url);
|
|
37
|
+
// Restart observation to trigger intersecting check
|
|
38
|
+
this.observer.unobserve(element);
|
|
39
|
+
this.observer.observe(element);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public unregister(element: HTMLElement) {
|
|
44
|
+
if (element.nodeType === Node.ELEMENT_NODE) {
|
|
45
|
+
this.observer.unobserve(element);
|
|
46
|
+
}
|
|
47
|
+
this.disposeTexture(element);
|
|
48
|
+
this.elementUrls.delete(element);
|
|
49
|
+
this.loadStatus.delete(element);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private async loadTexture(element: HTMLElement) {
|
|
53
|
+
if (this.loadStatus.get(element) || this.textures.has(element)) return;
|
|
54
|
+
|
|
55
|
+
const url = this.elementUrls.get(element);
|
|
56
|
+
if (!url) return;
|
|
57
|
+
|
|
58
|
+
this.loadStatus.set(element, true);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetch(url);
|
|
62
|
+
const blob = await response.blob();
|
|
63
|
+
const bitmap = await createImageBitmap(blob, { imageOrientation: "flipY" });
|
|
64
|
+
|
|
65
|
+
// Check if URL changed or element was unregistered while decoding
|
|
66
|
+
if (this.elementUrls.get(element) !== url) {
|
|
67
|
+
bitmap.close();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Check if element is no longer intersecting (debounced unobserve might have fired)
|
|
72
|
+
// Actually we just check if it's still observed and has the URL.
|
|
73
|
+
// A more robust check for intersection state might be needed, but for now we rely on disposeTexture.
|
|
74
|
+
|
|
75
|
+
const texture = new THREE.CanvasTexture(bitmap);
|
|
76
|
+
texture.needsUpdate = true;
|
|
77
|
+
this.textures.set(element, texture);
|
|
78
|
+
this.onUpdate(element, texture);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
console.warn("[MirageEngine] Failed to load texture:", url, e);
|
|
81
|
+
} finally {
|
|
82
|
+
// Re-check url, if it changed, we should probably trigger loadTexture again,
|
|
83
|
+
// but IntersectionObserver will handle it if it's still intersecting.
|
|
84
|
+
if (this.elementUrls.has(element)) {
|
|
85
|
+
this.loadStatus.set(element, false);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private disposeTexture(element: HTMLElement) {
|
|
91
|
+
const texture = this.textures.get(element);
|
|
92
|
+
if (texture) {
|
|
93
|
+
texture.dispose();
|
|
94
|
+
this.textures.delete(element);
|
|
95
|
+
this.onUpdate(element, null);
|
|
96
|
+
}
|
|
97
|
+
// Also cancel loading state if we had one
|
|
98
|
+
this.loadStatus.set(element, false);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
public get(element: HTMLElement) {
|
|
102
|
+
return this.textures.get(element);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
public disposeAll() {
|
|
106
|
+
this.observer.disconnect();
|
|
107
|
+
// Assuming WeakMap handles GC of remaining textures, but in WebGL we must explicitly delete.
|
|
108
|
+
// However, WeakMap is not iterable, so we can't easily iterate and delete.
|
|
109
|
+
// The renderer should unregister nodes upon pendingDeletions.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface StyleData {
|
|
2
|
+
// 1. Transform
|
|
3
|
+
x?: number;
|
|
4
|
+
y?: number;
|
|
5
|
+
z?: number;
|
|
6
|
+
scaleX?: number;
|
|
7
|
+
scaleY?: number;
|
|
8
|
+
scaleZ?: number;
|
|
9
|
+
rotateX?: number;
|
|
10
|
+
rotateY?: number;
|
|
11
|
+
rotateZ?: number;
|
|
12
|
+
|
|
13
|
+
// for future use
|
|
14
|
+
matrix?: Float32Array;
|
|
15
|
+
|
|
16
|
+
// 2. Material / Appearance
|
|
17
|
+
opacity?: number;
|
|
18
|
+
backgroundColor?: [number, number, number];
|
|
19
|
+
backgroundImage?: string;
|
|
20
|
+
borderRadius?: number | [number, number, number, number];
|
|
21
|
+
|
|
22
|
+
// 3. Layout / Depth
|
|
23
|
+
width?: number;
|
|
24
|
+
height?: number;
|
|
25
|
+
zIndex?: number;
|
|
26
|
+
}
|
package/src/types/common.ts
CHANGED
|
@@ -20,12 +20,14 @@ export interface SceneNode {
|
|
|
20
20
|
|
|
21
21
|
textContent?: string;
|
|
22
22
|
textStyles?: TextStyles;
|
|
23
|
+
textLines?: { text: string; rect: NodeRect }[];
|
|
23
24
|
|
|
24
25
|
dirtyMask: number;
|
|
25
26
|
// TODO: SceneNode의 sort 로직 추가(아마도)
|
|
26
27
|
|
|
27
28
|
visibility: Visibility;
|
|
28
29
|
isTraveler: boolean;
|
|
30
|
+
isFixed: boolean;
|
|
29
31
|
shaderHooks?: ShaderHooks;
|
|
30
32
|
|
|
31
33
|
children: SceneNode[];
|
package/src/types/index.ts
CHANGED